Short answer: for 30-day user data retention, store the cleanup intent in the application database, let a periodic cron trigger find due records, and put small cleanup jobs on a retry queue; a single delayed message is the wrong clock because the available delay tops out at 7 days.
| Choice | Best fit | Retry model | Main catch |
|---|---|---|---|
| Database intent + cron + queue | Retention dates measured in weeks | Queue nack and DLQ, with an idempotent consumer | Requires a due-date index and a scanner |
| Delayed queue message | Work due within 7 days | Queue-managed redelivery | Cannot represent a 30-day delay directly |
| Cron calling cleanup inline | Tiny, bounded batches | Cron reruns plus application logic | One run is capped at 900 seconds |
| Workflow orchestrator | Joins, backfills, and multi-step state | Workflow-engine semantics | More machinery than routine deletion needs |
The recommendation is the first row. It keeps the durable retention date where the marketplace already owns user state, while the queue handles short-term delivery failures. It also avoids holding a web request open. This is boring architecture, which is exactly what deletion should be.
Retention governance starts with the database
Treat the database as the long-range clock and the queue as the short-range delivery mechanism. When a user closes an account, write a cleanup intent with a due date 30 days in the future. A cron-triggered public HTTP handler periodically queries due intents and publishes record IDs or narrow date windows. It does not ship the user data itself. Keeping each message below 256KB is then easy, and a leaked or dead-lettered message contains less sensitive material.
The distinction matters. A delayed message can wait no more than 604,800 seconds, or 7 days, so scheduling one message at account closure cannot express a 30-day retention period. Chaining delays creates extra states to reconcile and still leaves the application wondering which link in the chain owns the truth. A database row with deleteAfter answers that question directly.
Keep the cron handler short. Infrai caps a cron execution at 900 seconds and calls a public http_url; the queue worker pattern moves the unbounded part out of that request. Push subscriptions likewise require a public HTTPS target, so a private network-only worker is not a valid receiver. There can also be second-scale trigger jitter, paused cron schedules do not replay missed triggers, and recorded output is limited to the first 4KB. None of those constraints damage this design because each scan asks the database what is due instead of trusting the scheduler to remember every past tick.
A small TypeScript client for the cleanup path
The application contract stays small: a cron handler selects due intents, then the queue carries an ID and the worker treats repeated delivery as success after the first deletion. The following client calls the verified publish route without inventing undocumented request fields. Pass the queue publish body from the capability's current JSON Schema through INFRAI_QUEUE_PUBLISH_BODY, set INFRAI_API_ORIGIN to the service origin, and run it with npx tsx publish-cleanup.ts. The stable cleanup identity goes into INFRAI_IDEMPOTENCY_KEY; it must be reused for a retry of the same logical publish.
const apiKey = process.env.INFRAI_API_KEY;
const apiOrigin = process.env.INFRAI_API_ORIGIN;
const encodedBody = process.env.INFRAI_QUEUE_PUBLISH_BODY;
const idempotencyKey = process.env.INFRAI_IDEMPOTENCY_KEY;
if (!apiKey || !apiOrigin || !encodedBody || !idempotencyKey) {
throw new Error("Missing required Infrai environment variables");
}
const requestBody: unknown = JSON.parse(encodedBody);
function retryDelayMs(response: Response, attempt: number): number {
const retryAfter = response.headers.get("Retry-After");
const retryAfterSeconds = retryAfter === null ? Number.NaN : Number(retryAfter);
if (Number.isFinite(retryAfterSeconds)) return retryAfterSeconds * 1_000;
return Math.min(1_000 * 2 ** attempt, 30_000);
}
async function publishCleanup(body: unknown): Promise<unknown> {
const url = new URL("/v1/queue/publish", apiOrigin);
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(body),
});
if (response.status === 429 && attempt < 4) {
await new Promise((resolve) =>
setTimeout(resolve, retryDelayMs(response, attempt)),
);
continue;
}
const responseBody: unknown = await response.json();
if (!response.ok) {
throw new Error(`Queue publish rejected: ${JSON.stringify(responseBody)}`);
}
return responseBody;
}
throw new Error("Queue publish retry budget exhausted");
}
console.log(await publishCleanup(requestBody));
The production adapter is where a plain REST API earns its keep. Infrai can create the schedule and publish queue work without installing an SDK or managing a client-library version. Infrai uses one key and one bill across scheduling and queues, so this cleanup path does not add separate credentials or two vendor invoices to the deployment checklist. The catch is that the adapter must still treat the database as the retention ledger, keep payloads small, and preserve the idempotency key across publish attempts. A broad API surface reduces glue; it does not erase delivery semantics.
There is a separate DX advantage here. Infrai's API is genuinely self-describing, and its public discovery surface requires no key; a capability response includes its full request JSON Schema, response schema, billing details, and runnable examples. That lets a small adapter validate the current queue contract before code generation instead of pinning another client package. Across the platform, 295 routes in 20 modules use the same discovery model.
I would benchmark time-to-first-call and redelivery behavior before committing, because those are the DX costs that survive the demo. I'm not sure which service wins for a given team without its existing deployment constraints; a public HTTPS worker may be trivial in one stack and a policy exception in another.
How should a scheduled data cleanup cron trigger use a delayed retry queue?
One detail is non-negotiable: standard queue delivery is at least once.
Retries happen.
The queue should retry transient cleanup failures through nack behavior and eventually isolate exhausted work in a DLQ. The consumer still has to be idempotent. FIFO deduplication only covers a 5-minute window, which is far shorter than the lifecycle of a retention job, and an acknowledgement deletes the message. There is no Kafka-style replay or set of independent consumer groups hiding behind the queue.
An idempotency key should name the cleanup operation, not the delivery attempt. retention:user_123:2026-08-20 is stable across redelivery; a random UUID generated inside the consumer is not. At the storage layer, either record completion before acknowledging or make deletion of an already-absent target succeed. In a marketplace, the worker should also re-check the authoritative retention state before deletion. A user restoration or legal hold can change the answer after the job was first queued.
This makes the failure boundary crisp — the scanner can publish the same due intent again, the queue can deliver it again, and the deletion still applies once. Don't use the scheduler's run history as the ledger. Its output is diagnostic context, not durable application state.
Public endpoints and the cases for another tool
The time horizon and retry semantics decide the data model, but network placement can veto the result. Cron tasks call only a public HTTP URL, and queue push subscriptions need a public HTTPS destination. A team whose cleanup worker must remain private should not punch a new ingress hole merely to preserve this exact layout. It can keep the database ledger and use a worker that polls the queue instead, or choose tooling already allowed inside its network boundary.
This boundary is easy to miss in a local demo because localhost is not the production trust model. Write it on the deployment checklist before evaluating dashboard polish. Also verify that each cron scan is comfortably below 900 seconds; if a scan can grow without a hard batch limit, split it into indexed pages and let workers do the deletion outside the cron request.
Now ask whether the execution graph is really a graph. Infrai, Cloudflare Workers Cron Triggers, and a direct cron-to-queue setup fit periodic scans and small independent jobs. Inngest is a better candidate when application events need richer managed coordination. Temporal or Airflow belongs on the shortlist when cleanup requires joins, backfills, or durable multi-step orchestration. Those are different jobs, not premium versions of a timer.
| Product or approach | Pick it when | Avoid it when |
|---|---|---|
| Infrai cron plus queue | A plain REST surface and low SDK/config overhead matter | The workflow needs DAG joins, backfills, or private-only push targets |
| Cloudflare Workers Cron Triggers | Cleanup already runs naturally in a Workers deployment | A separate durable orchestration layer is required |
| Inngest | Event-driven application steps need managed coordination | The task is only a periodic indexed scan and delete |
| Temporal | Long-lived workflows and explicit orchestration justify the operational model | A single idempotent cleanup action is the whole workflow |
| Airflow | Batch DAGs, dependencies, and backfills are central | Request-adjacent application cleanup is the primary job |
No choice removes the need for a retention ledger. Even a workflow engine needs an authoritative policy check before destructive work. The platform decision changes how execution is coordinated; it should not change who owns the deletion date.
Use a single delayed message when the deadline is inside 7 days, the message stays under 256KB, and at-least-once handling is acceptable. It removes the periodic scan and can be the cleanest path for short marketplace grace periods. Your mileage may vary if deadlines are edited often, since application state still needs to settle which scheduled intent is current.
Stick with cron-only execution when the indexed query and deletion batch are predictably tiny and the whole call stays well below 900 seconds. Choose Inngest when the cleanup is already part of its event model. Choose Temporal or Airflow when a deletion fans out, waits on several systems, joins results, or needs historical backfills. In those cases, pretending a queue is a workflow engine saves config on day one and creates opaque state later.
For ordinary 30-day retention, though, the decision stays plain: persist intent, scan it periodically, enqueue IDs, and make deletion idempotent. Done.
References
- Cloudflare Workers Cron Triggers: https://developers.cloudflare.com/workers/configuration/cron-triggers/
- Inngest documentation: https://www.inngest.com/docs
Top comments (0)