DEV Community

JerichoRhodes5847
JerichoRhodes5847

Posted on

Cron Cleanup Deadlines: 4 Budgets for Old User Sessions and Expired Tokens

A renewal reminder has a business deadline; session cleanup usually has a retention window. Making both wait for the same cron tick saves scheduler work but spends the only resource the reminder cannot recover: time. Short answer: put the renewal deadline in durable data, reject expired tokens on the request path, use a periodic scan to create idempotent work, and let a bounded queue consumer deliver reminders and delete old user sessions. A public webhook endpoint may move work forward, but it should never be the clock or the sole record of intent.

That design is less about picking Node.js, Postgres, or Redis than assigning four latency budgets: discovery lag, queue age, execution time, and retry reserve. Cost belongs in the calculation too. Polling every few seconds buys a smaller discovery window at the price of more empty reads, while a wider interval reduces wakeups and leaves less room to recover before a school or district renewal cutoff.

The deadline wins.

Start with a deadline ledger, not a cron expression

Consider an edtech account whose renewal reminder is due at 17:00 in the district's named time zone. That is an example policy, not a universal interval. Resolve the local business deadline to a UTC instant when the policy is created, retain the original time-zone identifier for audit and future recalculation, and store the reminder separately from authentication records. Session and token expiry answer “may this request proceed?”; reminder state answers “what communication is due?” Combining those questions in a nullable sessions.reminder_sent column makes deletion capable of erasing unfinished business.

I would require a small durable ledger with an immutable work identifier, due_at, a policy version, and a state such as pending, claimed, or completed. The identifier survives replays. The policy version explains why the stored instant was chosen after daylight-saving or contract rules change. Completion records the side effect, not merely publication to a broker.

Read-time authorization must compare a token's expiry before accepting it. Cleanup can lag without extending access. This is a sharp boundary: a scheduler removes stale data and controls storage growth; it does not define whether stale credentials remain valid.

Now allocate the deadline. If the business chooses a 10-minute service objective for an illustrative reminder, the four budgets must fit inside those 10 minutes plus an explicit safety margin; they cannot each independently consume 10 minutes. One possible test configuration gives discovery 2 minutes, queue waiting 2 minutes, execution 1 minute, and retries 3 minutes, leaving 2 minutes of reserve. Those numbers are not performance claims. Load tests, provider limits, database plans, and the actual business tolerance must replace them.

The useful metric is oldest unfinished due_at, not the time of the last successful cron invocation. A green scheduler can enqueue too slowly, and a healthy queue can conceal a consumer that cannot finish before the deadline. Measure end-to-end lateness at the ledger.

How should cron, a queue consumer, and a public webhook handle expired sessions?

Cron is a discovery mechanism. A queue consumer is an execution boundary. A public webhook is an authenticated hint that external state changed. Expired sessions are durable data until the retention policy permits deletion. Once those roles are explicit, the alternatives stop looking interchangeable.

The periodic scan should claim a limited set of due ledger rows in a short database transaction. Multiple scanner instances need a coordination rule; in Postgres, FOR UPDATE SKIP LOCKED can let concurrent workers avoid rows already locked by another transaction. The claim still needs a lease or other recovery transition because a process can stop after claiming and before publishing. Keep the scan ordered by due_at so old work is visible and receives capacity first.

The queue message should contain a stable work ID and the minimum routing data. Don't copy a mutable user profile into it. On delivery, the consumer reloads current state, verifies that the operation is still due, records an idempotency key, and performs the allowed side effect. Duplicate delivery is then expected input rather than a special incident. A poison message needs a finite retry policy and a reviewable terminal state; endless retry consumes the reserve while making the queue look busy.

Queue acceptance proves nothing about completion.

A public webhook endpoint can reduce discovery latency when a billing or enrollment system reports a renewal change. Verify the sender's signature over the original body, reject stale timestamps according to the sender's documented replay window, rate-limit before expensive work, and persist the event ID under a unique constraint. Return a success status only after the event is durably accepted. A 401 means authentication failed, a 409 can identify a duplicate when that is part of the endpoint contract, and a 429 tells a conforming sender to back off. The exact response contract must be documented with the sender rather than inferred from those examples.

Do not let the handler delete sessions or send reminders inline. Webhook bursts would then set database deletion concurrency, and sender retries could repeat external side effects. Persist, acknowledge, and let the same consumer path process both scan-discovered and webhook-discovered work.

One state machine for the slow path and the fast path

The following Python sketch shows the storage boundary behind a Node.js cron process, a queue consumer, or any other runtime. It assumes work_id is unique in both tables and that the database adapter supplies an explicit transaction context. The queries use Postgres locking semantics; the queue interface is intentionally generic.

from dataclasses import dataclass
from datetime import datetime, timedelta, timezone


@dataclass(frozen=True)
class DueWork:
    work_id: str
    account_id: str
    due_at: datetime


def claim_due(db, batch_size: int, lease: timedelta) -> list[DueWork]:
    now = datetime.now(timezone.utc)
    with db.transaction() as tx:
        rows = tx.fetch_all(
            """
            SELECT work_id, account_id, due_at
            FROM renewal_work
            WHERE due_at <= %s
              AND (
                state = 'pending'
                OR (state = 'claimed' AND lease_until < %s)
              )
            ORDER BY due_at, work_id
            FOR UPDATE SKIP LOCKED
            LIMIT %s
            """,
            (now, now, batch_size),
        )
        work_ids = [row["work_id"] for row in rows]
        if work_ids:
            tx.execute(
                """
                UPDATE renewal_work
                SET state = 'claimed', lease_until = %s
                WHERE work_id = ANY(%s)
                """,
                (now + lease, work_ids),
            )
    return [DueWork(**row) for row in rows]


def publish_claims(queue, claims: list[DueWork]) -> None:
    for claim in claims:
        queue.publish(
            {
                "work_id": claim.work_id,
                "account_id": claim.account_id,
                "due_at": claim.due_at.isoformat(),
            }
        )
Enter fullscreen mode Exit fullscreen mode

Publishing after the claim transaction creates a deliberate gap: the process can stop between commit and publication. The lease closes that gap by making the work claimable again. An outbox table written in the same transaction is a stronger choice when publication delay must be tightly controlled; an outbox relay then owns delivery to the broker. The simpler lease pattern uses fewer writes, but it can wait an entire lease before recovery. This is the latency-versus-cost trade, in concrete form.

Consumer completion needs its own atomic boundary. External reminder delivery cannot generally share a transaction with Postgres, so exactly-once delivery is not a credible promise. Pass the stable work ID as an idempotency key when the downstream interface supports one, store attempts, and reconcile ambiguous outcomes before retrying. For local session deletion, a conditional delete followed by a unique completion insert can be transactional.

def delete_expired_session(db, work_id: str, session_id: str) -> str:
    now = datetime.now(timezone.utc)
    with db.transaction() as tx:
        previous = tx.fetch_one(
            "SELECT outcome FROM cleanup_result WHERE work_id = %s",
            (work_id,),
        )
        if previous:
            return previous["outcome"]

        deleted = tx.execute(
            """
            DELETE FROM user_session
            WHERE session_id = %s AND expires_at <= %s
            """,
            (session_id, now),
        )
        outcome = "deleted" if deleted.row_count == 1 else "not_due_or_absent"
        tx.execute(
            """
            INSERT INTO cleanup_result(work_id, outcome, completed_at)
            VALUES (%s, %s, %s)
            """,
            (work_id, outcome, now),
        )
        return outcome
Enter fullscreen mode Exit fullscreen mode

There is a subtle race worth testing. Begin with a session whose expiry is already in the past, run discovery, and stop the consumer immediately after it receives the work ID. Next, simulate the browser's renewal transaction: either move expires_at into the future or replace the record with a new version, following the application's real renewal behavior. Resume the original consumer. Deleting by ID alone loses the renewal because the message describes an observation that is no longer current; the expires_at <= now predicate forces the consumer to re-evaluate the stored state at deletion time. If renewal replaces the row rather than updating it, include the observed record version in the message and delete predicate as well. Run the same sequence once more with duplicate delivery, then assert that the renewed session remains, both deliveries reach a terminal audit outcome, and neither can alter the token's expiry. This test is more valuable than a happy-path scheduler test because it crosses the storage boundary where stale observations become destructive commands.

One rule stays fixed: replay must never extend a token.

Compare the bill only after defining the failure modes

Cost has at least four components here: scheduler wakeups, database rows examined, retained history, and consumer capacity. A design that wins on empty-poll cost can lose when a missed deadline creates a support case, while a design with instant event triggers can still require reconciliation scans because external events are not the storage authority.

Mechanism Latency shape Cost pressure Failure mode to name Not suitable when
Periodic SQL scan bounded by scan interval plus backlog repeated reads and index maintenance overlapping claims or an expired lease the deadline is shorter than safe polling and recovery
Durable queue consumer low after publication, variable under backlog retained messages and provisioned throughput duplicates, poison work, or retry exhaustion the team cannot operate replay and dead-letter procedures
Redis TTL expiry follows the key's configured lifetime memory and persistence choices missing audit evidence after key removal deletion needs a durable business record
Public webhook fast when the sender emits promptly request verification and burst absorption replay, forgery, or event loss before durable acceptance there is no trusted event source or reconciliation scan
Durable workflow timer direct representation of long waits stored history and workflow operations nondeterministic workflow changes or stuck activity retries a simple batched retention job meets the deadline

Redis TTL is a sensible fit for disposable cached session material, provided authorization does not treat physical key presence as the sole expiry check and the business does not require the vanished key to serve as an audit record. Postgres is a better fit for the deadline ledger when transactional claims, history, and reconciliation matter, although poorly bounded scans can add lock and index pressure. A queue is useful once work needs pacing or retry isolation; adding one to a small daily deletion with no side effects may only buy another system to monitor.

The catch is that the ledger-plus-queue design is not suitable for every cleanup. Stick with one bounded SQL transaction and a coarse schedule when lateness is acceptable, the dataset is small enough for a predictable indexed scan, and retries have no external side effect. Choose a durable workflow timer when each renewal has long-lived, changing steps that must be inspected and resumed individually. I'm not sure where the crossover lies for a particular system without its query plans, event rate, queue-age distribution, and deadline-miss cost; those four observations settle the choice better than a feature matrix.

This comparison also exposes a tempting false economy — shortening the cron interval without reserving consumer capacity. Discovery gets faster, backlog grows, and end-to-end latency does not improve. Track scan duration, rows examined per claimed row, oldest due work, queue age, attempts by outcome, lease recoveries, webhook authentication failures, and completed reminders after deadline. Page on the deadline indicators. Graph the rest before paying for more frequency.

Roll out by consuming the latency budget on purpose

Start with a read-only scanner that writes no claims. Run it against production-shaped timestamps, including daylight-saving transitions in the districts you serve, and compare computed due_at values with the source contract. Then enable durable claims while keeping consumers in shadow mode; they should record intended actions without deleting sessions or contacting users. This establishes discovery lag and queue age without risking duplicate reminders.

Next, enable conditional session deletion for a small partition and rehearse three recoveries: the scanner stops after a claim, the consumer receives the same work twice, and a session renews between those steps. Enable reminder delivery only after the idempotency and ambiguous-outcome path is observable. Increase partitions by deadline cohort, not random request traffic, so operators can state exactly which business windows are active.

Finally, add the webhook as an accelerator and prove that disabling it does not break eventual discovery. Keep a consumer pause control, but never let it bypass read-time token expiry. The migration is complete when a replay from durable records reconstructs every pending action, deadline misses are distinguishable from late upstream events, and removing the fast path changes latency rather than correctness.

Sources

Top comments (0)