Nightly stale-upload deletion, paced against your storage provider's limit
An EU SaaS pattern: enumerate stale uploads once, split one rate budget across four workers, and keep personal data out of every queue message.
The constraint in a nightly cleanup is almost never your database. It’s the object store you’re deleting from, which will happily accept 50 delete calls a second and start returning 503s at 300. So the design is: one cheap enumeration pass that publishes a message per stale upload, then a small pool of workers that share a fixed request budget between them. Infrai’s queue holds the work list; the pacing is a dozen lines you write in the worker, because the queue itself has no server-side rate limiter.
For an EU SaaS there’s a second constraint, and it’s about what you put in the message rather than how fast you drain it.
Keep the personal data out of the queue
Infrai’s queue is available in western and china regions — there’s no EU-resident queue today, which is a real limitation if your compliance posture says personal data doesn’t leave the EU. The workaround is also good engineering: publish an opaque identifier, nothing else.
{ "upload_id": "upl_9f3c21", "tenant_id": "t_4821" }
Not the filename, not the uploader’s email, not the S3 key if that key embeds a customer name. The worker reads your EU-hosted database to turn upl_9f3c21 into everything it needs, and the queue never holds a byte you’d have to account for. A payload that small also stays far under the 256 KB per-message ceiling.
One budget, four workers
A per-second limit belongs to the provider, not to a process, so four workers each pacing themselves at 50 deletes a second gives you 200 and a wall of 503s. Two ways out, and the boring one is better:
| Approach | How it works | When it’s right |
|---|---|---|
| Shared counter in Redis | every worker decrements one budget | limits above a few hundred per second, or workers that scale up and down |
| Static split | each worker gets limit / workers | fixed worker count — simplest thing that survives a night |
| One worker, higher concurrency | single process, bounded parallel deletes | when a single box has the network to saturate the limit |
With a fixed deployment of four workers and a 200/second provider ceiling, each worker paces itself at 50. No coordination, no extra dependency, and a worker dying just means you’re running under budget until it restarts — which is exactly the direction you want to fail in when the alternative is a provider deciding you’re abusive and throttling every request your account makes for the next hour.
Four workers, fifty each. That’s the whole rate limiter.
Create the queue and its failure lane
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/queue/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"name":"stale-uploads","type":"standard","dlq":"stale-uploads.dlq"}'
{
"ok": true,
"data": {
"name": "stale-uploads",
"type": "standard",
"visibility_timeout_default": 300,
"max_receive_count": 3,
"max_message_size_kb": 256,
"message_retention_days": 14,
"dlq_name": "stale-uploads.dlq"
}
}
Those two numbers set the retry behaviour for the whole job: a message you don’t ack comes back in 300 seconds, and after three deliveries it goes to stale-uploads.dlq rather than looping forever.
The enumerator runs once a night
import process from "node:process";
import { pool } from "./db.mjs";
const BASE = "https://api.infrai.cc";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY missing");
async function publish(payload) {
const res = await fetch(`${BASE}/v1/queue/publish`, {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const out = await res.json();
if (!out.ok) throw new Error(`publish: ${out.error.code} — ${out.error.message}`);
return out.data.message_id;
}
export async function enumerateStale() {
const { rows } = await pool.query(
`SELECT id, tenant_id FROM uploads
WHERE last_referenced_at < now() - interval '90 days'
AND purged_at IS NULL
ORDER BY id
LIMIT 200000`,
);
for (const row of rows) {
await publish({ queue: "stale-uploads", body: { upload_id: row.id, tenant_id: row.tenant_id } });
}
console.log(`enumerated ${rows.length} stale uploads`);
return rows.length;
}
Whatever fires this is up to you — a container that runs at 02:00, a GitHub Actions schedule, a Kubernetes CronJob. It just has to finish; it doesn’t have to be reliable, because tomorrow’s run picks up anything it missed.
The paced deleter
import process from "node:process";
import { setTimeout as sleep } from "node:timers/promises";
import { pool } from "./db.mjs";
const BASE = "https://api.infrai.cc";
const H = { Authorization: `Bearer ${process.env.INFRAI_API_KEY}`, "Content-Type": "application/json" };
const MY_SHARE_PER_SECOND = 50;
const SPACING_MS = Math.ceil(1000 / MY_SHARE_PER_SECOND);
async function call(path, payload) {
const res = await fetch(`${BASE}${path}`, { method: "POST", headers: H, body: JSON.stringify(payload) });
const out = await res.json();
if (!out.ok) throw new Error(`${path}: ${out.error.code} — ${out.error.message}`);
return out.data;
}
export async function purgeLoop(deleteObject) {
let emptyPolls = 0;
while (emptyPolls < 5) {
const { items } = await call("/v1/queue/consume", { queue: "stale-uploads", max_messages: 10 });
if (!items.length) { emptyPolls += 1; await sleep(3000); continue; }
emptyPolls = 0;
for (const msg of items) {
const startedAt = Date.now();
try {
const { rows } = await pool.query(
"UPDATE uploads SET purged_at = now() WHERE id = $1 AND purged_at IS NULL RETURNING storage_key",
[msg.payload.upload_id],
);
if (rows.length) await deleteObject(rows[0].storage_key);
await call("/v1/queue/ack", { queue: "stale-uploads", receipt_handle: msg.message_id });
} catch (err) {
console.warn(`upload ${msg.payload.upload_id} delivery ${msg.delivery_count}: ${err.message}`);
}
const elapsed = Date.now() - startedAt;
if (elapsed < SPACING_MS) await sleep(SPACING_MS - elapsed);
}
}
console.log("queue drained");
}
The spacing gate sits after the delete, so a slow provider response counts toward the interval instead of stacking on top of it — that’s the difference between averaging 50/second and averaging 30/second without knowing why.
Check the timeout maths before you ship
Ten messages at 20ms of spacing each is 200ms of work per consume call, against a 300-second lease. Enormous margin. The number to watch is the other direction: if your delete ever blocks for minutes — a provider outage, a retry inside your SDK — the lease expires while you’re still working, the message is redelivered to another worker, and you get a duplicate delete. The conditional UPDATE ... AND purged_at IS NULL above is what makes that harmless, and it’s why the guard isn’t optional.
curl -sS "https://api.infrai.cc/v1/queue/stats/stale-uploads" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"queue": "stale-uploads",
"message_count": 1842,
"available_count": 1832,
"in_flight_count": 10,
"dlq_count": 3,
"oldest_message_age_seconds": 411
}
}
in_flight_count should hover near workers × 10. If it’s much higher, leases are expiring and you’re doing duplicate work.
Cost, and what’s free
Publishing is the only metered route: $0.00002 per message, verified 2026-07-26. A 200,000-upload purge is $4.00 once, and the nightly steady state of a few thousand is cents. Consume, ack, nack, stats and the dead-letter reads are free and rate-limited rather than billed, so retries and empty polls don’t move the number. New accounts start with $2 of credit.
curl -sS "https://api.infrai.cc/v1/account/usage" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '.data.breakdown[] | select(.key == "queue.publish")'
That’s also how per-tenant attribution works here — one call, one breakdown, no reconciliation across three vendors. Rates in this category trend downwards and campaigns run, so the live figure may be below what’s printed above.
The boundaries
No EU region, no server-side rate limiter, 10 messages per consume call, and on 2026-07-26 POST /v1/queue/dlq/redrive/{queue} returned an error for us — recovering parked messages means consuming stale-uploads.dlq by name and republishing. If your workers are Python and you already run celery, its rate_limit on a task is less code than the loop above. If you’re inside AWS with the object store, IAM and the queue all in one account, sqs keeps the data plane in one place, which is worth something to an auditor.
For the deadline-driven half of this problem — proving each record was deleted within its retention window — see the retention sweep guide.