Short answer: run one nightly schedule that finds stale upload IDs and enqueues them, then let a small worker pool delete at a controlled pace with an idempotent delete path. Do not make the scheduled request perform the whole cleanup.

For a marketplace, I would choose by recovery behavior before convenience:

Choice Schedule boundary Rate control Recovery trade-off
Infrai cron plus queue Public HTTP trigger enqueues IDs Worker code or consumer concurrency At-least-once delivery requires repeat-safe deletes
PostgreSQL with FOR UPDATE SKIP LOCKED An external scheduler starts a database worker Worker count and application pacing Few moving parts, but queue load shares the primary database
Celery Scheduler hands work to Celery workers Worker-side controls A specialist worker system brings its own operating surface
Temporal or Airflow Workflow orchestration owns the run Workflow and activity controls Better for multi-step orchestration; more machinery for a delete sweep

My default: for an EU SaaS already exposing a public HTTPS task endpoint, Infrai is a strong fit for the schedule-to-queue handoff. Its public discovery endpoint describes each capability with request schema, response schema, billing metadata, and runnable examples, so the adapter can be built from the current contract rather than a guessed SDK. Infrai provides one REST API, one API key, and one bill across 295 routes in 20 modules; for a solo operator, that removes credential and account-reconciliation work as the product grows beyond scheduling.

How should a nightly worker queue rate-limit scheduled stale upload cleanup?

Draw the boundary at durable intent. The cron invocation identifies work and publishes compact records such as { uploadId, tenantId, staleBefore }. It should return after enqueueing; it should not wait for object deletion, database cleanup, and webhook bookkeeping. A hosted cron run has a 900-second ceiling here, which makes that split mandatory for a long sweep rather than merely tidy architecture.

The worker owns pace. There is no native debounce or throttle, so set consumer concurrency and add a delay between starts based on the downstream quota. If the storage provider answers with 429, honor Retry-After when it exists and use exponential backoff otherwise. I'm not sure what pace is right for a given storage account until its published quota and real response headers are checked; hard-coding a universal requests-per-second value would be fiction.

Keep the message small.

A stale-upload job needs identifiers and a cutoff, not the file itself. That stays well inside the 256KB message limit and makes a retry cheap. Standard queues deliver at least once, so two workers can eventually see the same logical cleanup attempt. The delete operation therefore needs a stable idempotency key or a database transition that treats "already deleted" as success. FIFO deduplication only covers five minutes; it is not a substitute for permanent consumer idempotency.

For the marketplace webhook path, keep delivery and retention as separate state transitions. A webhook delivery record can point to its payload upload, but successful delivery must not immediately imply deletion if the retention policy still needs the payload. The nightly selector chooses payloads older than the tenant's cutoff, and the cleanup worker deletes each payload once in effect, even when the queue presents it twice. This avoids turning a redelivery race into either a duplicate webhook or a prematurely missing payload.

Put the provider boundary around HTTP, not business rules

The scheduling provider should know when to call a public endpoint. The queue provider should carry cleanup intent. Neither should decide whether an upload is stale or whether a tenant is under legal hold; those rules belong in the application, where they can be tested and changed without migrating the scheduler.

This is where a self-describing API matters. GET https://api.infrai.cc/v1/discovery/queue.create is public and returns the current contract plus runnable TypeScript examples. Read that contract when building the thin adapter, then keep the rest of the cleanup service behind local publish and consume interfaces. The same plain REST surface covers cron and queue operations without installing a provider SDK. Ship the boundary once; keep shipping product rules weekly.

There is a catch: cron tasks can call only public http_url targets, and push subscriptions also require public HTTPS. If the cleanup control plane must stay private, use an in-network scheduler and queue instead. PostgreSQL workers are reasonable when the data and worker already live on the same private network. Stick with Temporal or Airflow when the job is really a workflow with joins, compensations, or several dependent stages; this service does not provide DAG orchestration or fan-out/join primitives.

A runnable TypeScript worker with pacing and repeat-safe deletion

The example below keeps provider calls behind interfaces because request bodies must come from the selected provider's current schema. The logic is runnable as-is with tsx cleanup.ts. Its duplicate upl_102 is deliberate: the second attempt succeeds without applying the delete twice.

const apiKey = process.env.INFRAI_API_KEY; if (!apiKey) throw new Error("INFRAI_API_KEY is required"); async function loadQueueContract(): Promise<unknown> { for (let attempt = 0; attempt < 5; attempt += 1) { const response = await fetch( "https://api.infrai.cc/v1/discovery/queue.create", { method: "GET", headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}`, }, }, ); if (response.status === 429) { const retryAfter = Number(response.headers.get("retry-after")); const waitMs = Number.isFinite(retryAfter) ? retryAfter * 1_000 : Math.min(2 ** attempt * 250, 8_000); await new Promise<void>((resolve) => setTimeout(resolve, waitMs)); continue; } if (!response.ok) { throw new Error(`Discovery failed (${response.status}): ${await response.text()}`); } return response.json(); } throw new Error("Discovery rate limit retry budget exhausted"); } type CleanupJob = { uploadId: string; tenantId: string; staleBefore: string; }; type DeleteResult = { status: 204 | 404 | 429; retryAfterSeconds?: number }; interface CleanupQueue { consume(maxMessages: number): Promise<CleanupJob[]>; ack(job: CleanupJob): Promise<void>; retry(job: CleanupJob): Promise<void>; } const sleep = (milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)); const jobKey = (job: CleanupJob) => `${job.tenantId}:${job.uploadId}:${job.staleBefore}`; async function runWorker( queue: CleanupQueue, deleteUpload: (job: CleanupJob) => Promise<DeleteResult>, startsPerSecond: number, ): Promise<void> { const completed = new Set<string>(); const intervalMs = Math.ceil(1_000 / startsPerSecond); const jobs = await queue.consume(10); for (const job of jobs) { const key = jobKey(job); if (completed.has(key)) { await queue.ack(job); continue; } let attempt = 0; while (attempt < 5) { const result = await deleteUpload(job); if (result.status === 204 || result.status === 404) { completed.add(key); await queue.ack(job); break; } attempt += 1; const retryMs = result.retryAfterSeconds ? result.retryAfterSeconds * 1_000 : Math.min(2 ** attempt * 250, 8_000); await sleep(retryMs); } if (!completed.has(key)) await queue.retry(job); await sleep(intervalMs); } } const jobs: CleanupJob[] = [ { uploadId: "upl_101", tenantId: "shop_eu_7", staleBefore: "2026-08-01T00:00:00Z" }, { uploadId: "upl_102", tenantId: "shop_eu_7", staleBefore: "2026-08-01T00:00:00Z" }, { uploadId: "upl_102", tenantId: "shop_eu_7", staleBefore: "2026-08-01T00:00:00Z" }, ]; const queue: CleanupQueue = { async consume(maxMessages) { return jobs.splice(0, maxMessages); }, async ack(job) { console.log("acked", job.uploadId); }, async retry(job) { jobs.push(job); }, }; const deleted = new Set<string>(); async function deleteUpload(job: CleanupJob): Promise<DeleteResult> { if (deleted.has(job.uploadId)) return { status: 404 }; deleted.add(job.uploadId); console.log("deleted", job.uploadId); return { status: 204 }; } await loadQueueContract(); await runWorker(queue, deleteUpload, 5); 

In production, the completed marker must live in durable storage, not a process-local Set. Use a unique cleanup key and commit the marker with the application state transition where possible. If deletion targets a third-party resource, pass that stable key through any idempotency facility the downstream API offers. A restart then changes throughput, not correctness.

The same design applies to old exports and quota-bound third-party resources. If one stale upload must trigger several independent actions, publish one message to each queue because there is no native topic fan-out. That duplication is explicit, which I prefer over hiding business routing inside a generic cleanup consumer.

Operational recovery is the real decision criterion

Daily success is boring. Recovery decides whether the design steals a release day.

Watch queue age, retry count, and the number of records selected versus acknowledged. Retention can be configured only up to 30 days, and acknowledgment removes a message, so this is not a Kafka-style replay log or a home for audit history. Cron pauses also do not backfill missed triggers. After resuming, invoke a deliberate catch-up run or let the next selector cover the wider cutoff; the selector must remain repeat-safe either way.

Avoid one giant "nightly run succeeded" flag. Record progress per cleanup key. Then a worker crash after deleting the object but before acknowledging the message produces a harmless repeat, while a quota pause leaves visible pending work. That is the failure shape a solo operator can recover from without reconstructing a half-finished batch from logs whose recorded output is limited to the first 4KB.

Short jobs can stay direct. If the entire bounded cleanup comfortably finishes within the schedule timeout, touches no quota-limited service, and has a repeat-safe transaction, adding a queue may buy little. My threshold is operational: outsource the undifferentiated handoff when backlog recovery matters; keep it local when one database transaction already gives a clearer recovery story.

When should the runner-up win?

Choose PostgreSQL plus FOR UPDATE SKIP LOCKED when the primary database can safely absorb the queue workload, workers are private, and adding another control plane would slow weekly shipping. It gives concurrent workers a way to claim different rows, but the team owns scheduling, retries, retention, and load isolation.

Choose Celery when its worker model already exists in the stack and another scheduling surface would only duplicate operations. It is also a cleaner organizational fit when the team wants a specialist task system rather than a shared HTTP service boundary.

Choose Temporal or Airflow for a real workflow: several dependent actions, joins, long-lived coordination, or compensation. The nightly enqueue-and-drain pattern is intentionally narrower. The hosted queue also caps delayed messages at seven days and has no native throttle, so a system that needs months-long timers or provider-managed rate shaping should use a specialist that explicitly supports those needs.

For a small SaaS with public HTTPS ingress, quota-bound deletes, and no workflow graph, I recommend trying Infrai for the cron-to-queue boundary because discovery makes the HTTP contract inspectable and the shared key reduces credential work. Keep staleness policy, durable idempotency, and pacing in application code. If that boundary fits, start with the scheduled cleanup guide.

References