Dev.to · 10 min read

European SaaS Expired Session Cleanup: Idempotent Python Drains Rate-Limited Workers

European SaaS Expired Session Cleanup: Idempotent Python Drains Rate-Limited Workers

For a European SaaS, cheap expired sessions cleanup sounds like a cron calling a public URL; the real constraint is proving that retries cannot delete the wrong session or flood a constrained worker pool. Short answer: expose an authenticated public HTTP trigger, let cron call it, and make that trigger enqueue deterministic, retry-safe cleanup batches rather than deleting expired sessions inside the request. Choose the scheduler only after this invariant holds: any invocation may be delayed, duplicated, or retried without changing the final set of live sessions. This ADR selects a thin scheduled trigger, a durable run ledger, keyset pagination, and an idempotent queue consumer. It does not select a vendor. “Cheap” and “easy” are useful filters for a European SaaS team, but a low scheduler bill cannot compensate for an unbounded scan or a retry that doubles the work. What should a European SaaS use for expired session cleanup over public HTTP cron? Use the smallest scheduler that can send an authenticated HTTPS request on the required cadence and expose enough execution history to reconcile missed calls. The public URL is a routing property, not an authorization policy. A caller still needs a secret, the service should reject unauthorized requests, and logs must avoid session tokens and other sensitive payloads. The scheduler does not own cleanup correctness. Its only contract is to request a run. The application owns the cutoff timestamp, run identity, queue admission, idempotency, and evidence that a run finished. This boundary keeps a scheduler retry from becoming a second concurrent table scan. The selected architecture has four invariants: A run captures one immutable expires_before value; workers never substitute their local clock. Each batch has a deterministic idempotency key derived from the run and cursor range. Admission respects worker capacity, including backoff when the queue reports HTTP 429 Too Many Requests. Deletion is conditional: a session is removed only if it is still expired when the worker commits. That last check matters. A user might refresh a session after the scanner reads it but before a worker handles the job. Deleting by previously observed ID alone creates a time-of-check/time-of-use race. A conditional delete against the recorded expiry boundary turns that race into a harmless no-op. Decision boundaries and failure modes The failure boundary sits after the run record is committed and before work is admitted to the pool. Once a trigger has created or found the run, it can return 202 Accepted; a dispatcher can then feed bounded batches to workers. Keeping the HTTP request short avoids coupling correctness to an arbitrary request timeout, while the ledger makes an ambiguous client-side timeout auditable. Duplicates are normal. A cron implementation describes when commands run, including fields for minute, hour, day of month, month, and day of week. It does not provide application-level exactly-once deletion. Treating “cron fired” as “cleanup completed” collapses two distinct states and leaves no honest answer when a request is accepted but the caller loses the response. Name the failures before choosing a service: A duplicate trigger must resolve to the same logical run rather than launch another scan. A late trigger must retain its captured cutoff; otherwise two workers can disagree about expiry. A 429 from queue admission must pause according to Retry-After when supplied, then retry the same batch key. A worker retry must repeat a conditional delete, not an unconditional delete. A partial batch must record item outcomes so completed items are not inferred from a batch-level success flag. A missed schedule must be visible in the run ledger and alerting, not guessed from scheduler logs alone. HTTP 429 explicitly means that the client sent too many requests in a period, and the response may include Retry-After. The cautious interpretation is to reduce admission pressure. Retrying immediately with a fresh job identifier defeats both the rate limit and deduplication — a small control-plane mistake that can turn one cleanup window into a growing backlog. I’m not sure there is a universal best batch size; database index shape, average session lifetime, queue latency, and deletion cost decide it. Resolve that uncertainty with a load test using production-like expiry distributions, then cap both rows per page and in-flight batches. Do not tune from a uniform synthetic dataset if real expirations arrive in login-driven bursts. Compare mechanisms, not landing pages The meaningful comparison is where state lives and who must make retries safe. Price can be evaluated after those answers are explicit. Option Retry and idempotency model Failure boundary Suitable when Limitation Cron calls cleanup synchronously One request owns scan and deletion Request timeout can obscure partial progress The dataset is small, execution is tightly bounded, and overlap is prevented Not suitable for a rate-limited worker pool or cleanup that may outlive the request Cron enqueues one job Queue deduplicates one run key; job owns the scan A long job still needs checkpoints One consumer can drain within its lease and retry from a cursor A single job can monopolize capacity and makes per-batch backpressure coarse Cron creates a run; dispatcher enqueues batches Ledger and deterministic batch keys separate orchestration from work Run creation, admission, and item completion are independently observable Cleanup shares a constrained pool with other background jobs More application state and operational bookkeeping Database-native scheduled procedure Transaction and database locks govern overlap Scheduling and mutation share the database failure domain Cleanup is local, bounded, and the database supports the required scheduling model Couples scheduling to the data tier and can compete with foreground queries The third option is the decision here because the concrete job is to drain work without overrunning a rate-limited pool. The catch is the run ledger: it is another state machine to migrate, monitor, and retain. A small service with a provably bounded session table should stick with synchronous cleanup; the extra dispatcher would be ceremony. A database-native procedure is also a valid choice when no external side effects or shared worker capacity are involved. No option gets durability merely from a green scheduler dashboard. The evidence chain must connect scheduled intent, accepted run, admitted batches, conditional mutations, and terminal run state. Critical path in Python The endpoint below is intentionally framework-neutral. Repository and queue methods represent local interfaces, not a vendor API. The important details are the stable run key, captured cutoff, deterministic batch key, and the fact that queue pressure does not mint new identities. from dataclasses import dataclass from datetime import datetime, timezone from hashlib import sha256 from hmac import compare_digest @dataclass(frozen=True) class CleanupRun: run_id: str expires_before: datetime def cleanup_trigger(request, runs, dispatcher, expected_token: str): supplied = request.headers.get("Authorization", "") if not compare_digest(supplied, f"Bearer {expected_token}"): return {"status": 401} cutoff = datetime.now(timezone.utc).replace(second=0, microsecond=0) run_id = sha256(f"expired-sessions:{cutoff.isoformat()}".encode()).hexdigest() run = runs.create_or_get( CleanupRun(run_id=run_id, expires_before=cutoff) ) dispatcher.wake(run.run_id) return {"status": 202, "run_id": run.run_id} def dispatch_next_batch(run, sessions, queue, batch_size: int = 250): cursor = sessions.load_cursor(run.run_id) page = sessions.find_expired( expires_before=run.expires_before, after=cursor, limit=batch_size, ) if not page.items: return "complete" batch_key = sha256( f"{run.run_id}:{cursor}:{page.next_cursor}".encode() ).hexdigest() queue.enqueue_once( key=batch_key, payload={ "run_id": run.run_id, "expires_before": run.expires_before.isoformat(), "session_ids": [item.session_id for item in page.items], }, ) sessions.save_cursor_if_current(run.run_id, cursor, page.next_cursor) return "admitted" def consume_batch(payload, sessions): cutoff = datetime.fromisoformat(payload["expires_before"]) for session_id in payload["session_ids"]: sessions.delete_if_expired(session_id, expires_before=cutoff) There is a deliberate constraint in create_or_get: calls within the same minute map to one run. That is correct only when the required cadence is no finer than a minute and reusing that cutoff matches the product’s expiry contract. If manual backfills need distinct runs, their caller must supply a separate, authenticated operation identifier rather than weakening scheduled-run deduplication. The cursor update and queue admission also need a defined durability contract. In one datastore, they can share a transaction. Across systems, use an outbox so a committed cursor cannot outrun an uncommitted message; workers still rely on enqueue_once and conditional deletion because delivery can be repeated. Fast code is secondary here. Recoverable state transitions are the design. Backoff belongs at admission, before more work enters the constrained pool. When admission receives a 429, retain the same batch key, honor Retry-After if present, and delay the next attempt. Also reserve worker capacity for interactive or higher-priority jobs; a cleanup drain that consumes every slot has met its own throughput target by violating the system’s priority policy. Verification, rollout, and the rejected option Test invariants rather than the happy-path timer. Send the trigger twice for the same minute and assert one run. Replay a batch and assert the second conditional delete changes zero rows. Refresh a session between scan and consume and assert it remains. Inject a 429 at queue admission and assert the retry retains its batch key and no cursor is advanced before durable admission. Finally, interrupt the dispatcher after each state transition and verify that another process can resume from the ledger. Roll out with deletion disabled first, recording only candidate counts and cursor progress. Compare candidates with the product’s session-expiry rules, inspect retention and data-residency requirements for the ledger, then enable bounded batches under a strict in-flight cap. Metrics should distinguish trigger acceptance, undispatched runs, admission throttling, worker retries, conditional-delete no-ops, oldest expired-session age, and terminal completion. Avoid labels containing session IDs; they create high-cardinality telemetry and can leak identifying material. The rejected design is “one public URL that scans and deletes everything before returning.” It has less code and may have the lowest visible infrastructure cost. It is still the wrong default for this worker pool because request duration, scan progress, and deletion progress become one opaque unit, and a retry after an ambiguous timeout has no stable checkpoint. Keep that design when the upper bound is demonstrably small, the mutation fits comfortably inside the request budget, the database can prevent overlap, and there is no rate-limited downstream work. Document those bounds as numbers from your own load test. Your mileage may vary — especially when expiry traffic follows regional login peaks — so the decision should be reopened when the bound or shared-pool policy changes. The scheduler shortlist can now be evaluated cleanly: authenticated HTTP calls, timezone behavior, retry controls, execution history, European data-handling requirements, and total operational effort. The architecture does not depend on a scheduler claiming exactly-once execution, and it does not ask the cheapest component in the system to carry the most important correctness guarantee. Sources https://man7.org/linux/man-pages/man5/crontab.5.html https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/429

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More Programming & Dev News