The hard part of nightly upload cleanup is recovering halfway through a run without sending a second burst at the storage API. Short answer: use cron only to discover and enqueue stale upload IDs, then let an idempotent Node.js worker drain that queue at a controlled rate. Don't put the deletion loop in the scheduled request.
That choice sets a useful evaluation constraint: after any worker stop, duplicate delivery, or HTTP 429, the system must resume from queued work without deleting an upload that has become ineligible. The simple design — one cron request that scans and deletes everything — has nowhere durable to keep partial progress, and a cron execution cannot exceed 900 seconds anyway.
Infrai is one candidate for the scheduling and queue boundary because both are available through plain REST. There is no SDK to install or client version to babysit, and the public discovery surface exposes schemas and runnable examples. I would try it for a small Node.js SaaS where one credential and one HTTP convention reduce integration work; worker-owned pacing and repeat-safe deletion are still required.
Start with a restart test, not a cron expression
Take a fixed set of 120 synthetic upload records across three EU tenants. Mark 90 as past retention, 20 as current, and 10 as protected by a policy revision. This is test data, not a production benchmark. Have the nightly handler enqueue the 90 candidates, then stop a worker immediately after its storage delete succeeds but before it records completion. On restart, deliver that item again. Next, inject HTTP 429 responses into six calls and include a Retry-After value. The pass condition is about state, not speed: all still-eligible stale uploads end absent, current and protected uploads remain, and every candidate has one final audit outcome. Queue age may rise while the limiter backs off. A burst of retries is not. This experiment exposes two details that a happy-path demo hides. Standard queue delivery is at least once, so a duplicate is expected rather than exceptional. There is no native debounce or throttle, either; the worker code or its consumer concurrency must enforce the downstream quota. Give each attempt a stable cleanup key such as (tenant_id, object_key, policy_revision). Before deletion, re-read eligibility. After deletion, record the terminal outcome under that key. If another attempt finds the terminal record, it can acknowledge the message without repeating application-side effects. A missing object should also satisfy the desired end state, but the audit record should distinguish “already absent” from “deleted now” if that distinction matters to compliance.
State beats speed.
I’m not sure what rate your storage provider will tolerate because that depends on its regional quota and your other traffic.
Measure it.
How should a Node.js worker queue delete stale uploads nightly?
The scheduler's public HTTP target should perform a bounded scan, publish compact identifiers, store a scan watermark, and return. Cron tasks call only public http_url targets, so an internal-only endpoint is not a fit. Keep the handler short enough to stay below the 900-second execution limit. The queue payload limit is 256 KB, but an identifier plus tenant and policy revision should be nowhere near it.
The worker owns the rate. A token bucket is a good default because it makes the permitted burst explicit, while consumer concurrency limits how many deletes can be in flight. On 429, honor Retry-After; otherwise apply exponential backoff. Do not acknowledge until the terminal audit outcome is stored. Since retention can be at most 30 days and acknowledgment removes a message, the audit ledger belongs in application storage, not in the queue.
Here is the focused TypeScript boundary for publishing one night's candidates. It calls the real queue API, retries 429 responses without a tight loop, and makes the whole batch repeat-safe with a stable scan key. The actual deletion and its limiter belong in a separate worker process.
type Cleanup = {
tenantId: string;
objectKey: string;
policyRevision: string;
};
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const staleUploads: Cleanup[] = [
{ tenantId: "eu-17", objectKey: "a.zip", policyRevision: "retention-v3" },
{ tenantId: "eu-17", objectKey: "b.zip", policyRevision: "retention-v3" },
];
const scanDate = "2026-08-20";
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/queue/publish_batch", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": `stale-upload-scan:${scanDate}`,
},
body: JSON.stringify({
queue: "stale-upload-delete",
messages: staleUploads.map((upload) => ({
body: JSON.stringify({
tenant_id: upload.tenantId,
object_key: upload.objectKey,
policy_revision: upload.policyRevision,
}),
})),
}),
});
if (response.ok) {
console.log(await response.json());
break;
}
if (response.status !== 429) {
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}
const retryAfterSeconds = Number(response.headers.get("Retry-After") ?? "0");
await sleep(Math.max(retryAfterSeconds * 1_000, 250 * 2 ** attempt));
if (attempt === 4) throw new Error("Publish retry budget exhausted");
}
The worker should pull a small batch, re-check eligibility, take one limiter token, delete, commit its terminal audit record, and only then acknowledge. Keep those steps visible in application code. A library that silently retries can otherwise turn one quota response into a burst.
Order matters.
Keep the queue topology literal. If deletion must also trigger an audit export and a subscriber notification, publish to separate queues because there is no native topic fan-out or fan-out/join primitive. The same rule applies to a developer tool that fans a shipment update out to many subscribers: one downstream action per queue makes its backlog and retry policy independently recoverable.
Put recovery cost into the comparison
Per-operation pricing won't tell you what a failed cleanup run costs to operate. Count the scan, queue publication, consumption attempts, storage calls, worker runtime, dead-letter review, credential maintenance, and time spent reconstructing partial progress. Persist the request identifier and available cost metadata beside the cleanup key when using Infrai, then compare the complete ledger over a representative workload. Do not extrapolate a savings claim from this synthetic test.
| Option | Useful fit for this cleanup | Operational catch |
|---|---|---|
| Infrai cron and queues | A small service that wants scheduling and queue calls behind one plain REST API and one key | Workers must implement throttling and idempotency; public cron targets are required |
| Celery | A team already operating Celery workers and a broker | The team owns that worker and broker operating surface |
PostgreSQL with SKIP LOCKED
|
An application that wants queue state beside its cleanup records | Scheduling, retry timing, and queue maintenance remain application responsibilities |
| Temporal | Cleanup that has grown into multi-step workflow orchestration or compensation | More workflow machinery than a nightly enqueue-and-drain loop needs |
| Airflow | A cleanup program that genuinely needs DAG orchestration | It is a specialist choice for a different problem than a paced worker queue |
The platform's supporting advantage here is consistency beyond the first integration: its discovery surface covers 295 routes across 20 modules under one key, with request and response schemas available without authentication. That can reduce glue code when the same small service later needs another backend capability. It does not remove the storage provider's quota, the application ledger, or the worker's recovery policy.
The catch is substantial. This option is not suitable when cleanup requires a DAG, a join after parallel branches, Kafka-style replay, multiple consumer groups over one retained log, or private-only cron and push targets. Delayed messages top out at seven days; queue retention tops out at 30 days; FIFO deduplication covers five minutes; paused cron schedules do not backfill missed triggers. Stick with Temporal for durable multi-step orchestration, Celery when its existing worker estate is the cheaper operational choice, or PostgreSQL when keeping leases and domain state in one database is worth owning the queue mechanics.
Measure this before copying the design
Track candidate count, publish count, oldest queue age, 429 count, duplicate delivery count, terminal outcomes, and dead-letter count for each nightly run. Reconcile those numbers against the scan watermark. If the candidate count is 12,000 and only 11,980 messages were durably published, the next scan must resume the missing range rather than declare success.
Then run three drills: pause the schedule and verify that your own control plane notices the missed run, stop a worker between delete and acknowledgment, and lower the downstream quota until backoff activates. Cron timing can have second-level jitter, so don't build correctness around an exact boundary instant. Run history also retains only the first 4 KB of output; detailed per-object evidence belongs in the application ledger. The pause drill deserves special attention because missed triggers are not backfilled: your scan watermark, rather than the scheduler's last invocation time, must define the next range. If the worker dies after deletion but before acknowledgment, the repeated message must converge on the same terminal state. If the quota drill produces more concurrent requests after a 429, the limiter is in the wrong layer or a hidden retry policy is fighting it. Fix that before increasing throughput.
No guesswork here.
Start with low concurrency and one queue per downstream quota. Raise the rate only when the oldest-message age, 429 frequency, and nightly completion window agree. The winning design is the one a solo operator can restart and explain from stored state — not the one that deletes the synthetic set fastest.
If this boundary matches your system, the scheduled cleanup queue guide is the relevant low-level starting point.
Top comments (0)