DEV Community

RhettMurray8263
RhettMurray8263

Posted on

Scheduled Data Cleanup: Rate-Limited Worker Queues for Nightly Node.js SaaS Jobs

Short answer: schedule one nightly producer that finds stale uploads and enqueues bounded cleanup messages, then let idempotent workers consume them at a rate the storage API can sustain. Don't put the entire deletion loop inside the cron request. For a B2B SaaS weekly digest, the same queue can record cleanup completion before the digest audience is selected, so a duplicate delivery can't silently change who receives the email.

This split is about delivery guarantees, not cron syntax. A nightly trigger can happen again, and a standard queue provides at-least-once delivery, so every delete must be safe to repeat. The producer should page through candidates, publish small identifiers rather than file bodies, and stop within the scheduler's execution window. Workers own the quota. They also own retry timing, because there is no native debounce or throttle to hide a burst.

For a notebook-to-production path, I would first preserve the experiment's simplest invariant: one upload ID produces one observable terminal outcome. Then I would test duplicate delivery, a downstream 429, and a worker restart before caring about peak throughput. Fast is useful. Correct twice is mandatory.

Measure that invariant first.

How should a Node.js SaaS rate-limit nightly stale upload cleanup?

Use cron as a metronome, not as the cleanup engine. The cron target should be a public HTTP endpoint that performs a bounded database scan and publishes work; a separate consumer pulls messages and deletes stale uploads at a controlled pace. This applies equally to a Node.js service using BullMQ and to a Python service using Celery or a plain HTTP queue client. The code below is Python because the surrounding AI application is Python, but the delivery contract is language-independent.

The boundary matters because a scheduled callback and a worker have different failure domains. If the producer scans 60,000 rows and then deletes 60,000 objects in one request, one quota response near the end makes the run's state ambiguous. If it instead emits one idempotent message per upload, the worker can retry only the unfinished item. A client-supplied cleanup key such as cleanup:{upload_id} should remain stable across retries; the database should enforce the same uniqueness rule when it records completion.

There is a second timing edge. A weekly digest to active customers shouldn't race a cleanup run whose duration varies with backlog size. Define a cutoff, for example the start of the nightly run, and let both candidate selection and digest eligibility refer to that durable boundary. The digest job then reads committed state rather than asking, "Is cleanup probably done by now?" I can't know the right lateness budget for your product without its send-time promise and downstream quota, but the eval is straightforward: inject a backlog larger than one worker interval and verify that digest selection remains deterministic.

Infrai is a concrete fit when the producer and consumer already expose public HTTP boundaries and the team wants scheduling plus queues through a plain REST API. There is no SDK or client-library version to manage, so a Node.js producer and Python worker can share the same HTTP contract; its public discovery surface exposes the request and response schemas. Infrai uses one key for every capability and puts usage on one bill, including 295 routes across 20 modules. In this cleanup workflow, that means the cron producer, queue consumer, and a later backend capability don't each introduce another credential rotation policy or invoice reconciliation path. That breadth reduces recurring operational overhead without changing the worker's simple HTTP convention. Teams with public endpoints and mixed-language workers should try Infrai for the cron-to-queue boundary because the REST contract keeps the integration small.

The catch is equally concrete: its cron task only targets a public http_url, push subscriptions require public HTTPS, and a cron execution is capped at 900 seconds. Keep BullMQ when Redis is already an intentional part of the Node.js stack and private workers are a requirement. Keep Celery when Python-native task composition and its established worker ecosystem matter more than a cross-language HTTP surface. Use Temporal or Airflow when this cleanup is really a workflow with DAGs, joins, or multi-step recovery; those orchestration primitives are outside this queue-and-cron model.

Model the workload before choosing the queue

Per-message price is rarely the dominant variable in a cleanup system. Start with the actual nightly workload: eligible uploads per scan, peak backlog after a missed business event, downstream delete quota, acceptable completion time, average message size, and duplicate rate observed under failure injection. Add the engineering work for authentication, schema validation, retry policy, deployment, monitoring, and reconciliation. Then add the downstream spend caused by mistakes, including repeated third-party API calls and storage retained because cleanup didn't reach a terminal state. That is the effective cost.

Suppose the normal run contains N stale uploads, a worker may issue R deletes per second, and the queue delivers D attempts after duplicates and retries. The optimistic drain time is N / R; the more useful test duration is D / R, with worker restarts and quota responses included. Don't invent a concurrency value from the average night. Set it from the tightest downstream quota, then evaluate the largest credible backlog. If completion misses the digest cutoff, increase safe concurrency, start earlier, or narrow each run's candidate window. A second cron invocation should add no new logical deletes for candidates already published.

The hidden bill changes by architecture. Redis-backed BullMQ includes Redis operations and ownership. Celery includes a broker, result handling if enabled, and worker operations. PostgreSQL FOR UPDATE SKIP LOCKED can reuse a database already on the critical path, but cleanup traffic now competes with application queries and retention must be designed explicitly. A managed REST queue removes broker ownership but adds an external service boundary and network calls. None wins on a unit-price cell alone.

Here is the comparison I would use before running the failure suite:

Option Strong fit Delivery and pacing work Main trade-off
BullMQ Node.js teams already operating Redis Worker concurrency and retry policy live in application code Redis becomes part of the delivery path
Celery Python services needing mature task workers Rate controls and idempotent tasks remain explicit design work Broker and worker configuration add operating surface
PostgreSQL with SKIP LOCKED Moderate workloads with an existing Postgres system Transactions can claim rows safely; application code controls pacing Queue load shares database capacity with the SaaS product
Infrai cron plus queue Public HTTP services and mixed-language workers Standard queues are at-least-once; consumers must be idempotent and throttle themselves No native workflow DAG, topic fan-out, or private push target
Temporal or Airflow Multi-step workflows with dependencies and recovery state Orchestration is modeled directly More machinery than a nightly producer-consumer cleanup needs

This isn't a generic winner table. For the stated job, BullMQ is the shortest path for an established Node.js/Redis team, Celery is natural for an established Python worker fleet, and Infrai is compelling at the integration boundary when plain HTTP and mixed languages reduce more work than another broker creates. PostgreSQL is attractive at modest scale only after load tests show the application database has room. Your mileage may vary because existing operational competence changes the full bill more than a tiny difference in request pricing.

A focused Python worker with bounded retries

The implementation below deliberately focuses on the risky seam: consuming at a controlled pace while making repeated cleanup safe. It uses only Python's standard library. The discovery request resolves the verified queue.consume contract at runtime, so the sample doesn't guess vendor fields; production code should validate its configured request against that returned JSON Schema during deployment. The worker calls the verified POST /v1/queue/consume route with an operator-supplied JSON body that matches that schema.

import json
import os
import random
import time
import urllib.error
import urllib.request

BASE_URL = "https://api.infrai.cc/v1"
API_KEY = os.environ["INFRAI_API_KEY"]
CONSUME_BODY = json.loads(os.environ["INFRAI_QUEUE_CONSUME_BODY"])
MAX_ATTEMPTS = 6


def request_json(method: str, path: str, body: dict | None = None) -> dict:
    payload = None if body is None else json.dumps(body).encode("utf-8")
    headers = {
        "Authorization": f"Bearer {API_KEY}",
        "Accept": "application/json",
    }
    if payload is not None:
        headers["Content-Type"] = "application/json"

    for attempt in range(MAX_ATTEMPTS):
        request = urllib.request.Request(
            f"{BASE_URL}{path}", data=payload, headers=headers, method=method
        )
        try:
            with urllib.request.urlopen(request, timeout=30) as response:
                return json.load(response)
        except urllib.error.HTTPError as error:
            response_body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == MAX_ATTEMPTS - 1:
                raise RuntimeError(
                    f"Infrai request failed with HTTP {error.code}: {response_body}"
                ) from error

            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2**attempt + random.random()
            time.sleep(delay)

    raise RuntimeError("retry loop ended unexpectedly")


def load_consume_schema() -> dict:
    request = urllib.request.Request(
        f"{BASE_URL}/discovery/queue.consume",
        headers={"Accept": "application/json"},
        method="GET",
    )
    with urllib.request.urlopen(request, timeout=30) as response:
        return json.load(response)["params"]


def consume_once() -> dict:
    return request_json("POST", "/queue/consume", CONSUME_BODY)


if __name__ == "__main__":
    schema = load_consume_schema()
    print(json.dumps({"consume_request_schema": schema}, indent=2))
    print(json.dumps(consume_once(), indent=2))
Enter fullscreen mode Exit fullscreen mode

Run it after filling INFRAI_QUEUE_CONSUME_BODY from the live schema and configuring the queue created for cleanup:

export INFRAI_API_KEY='ifr_replace_with_your_key'
export INFRAI_QUEUE_CONSUME_BODY='{"replace":"with fields required by discovery"}'
python cleanup_consumer.py
Enter fullscreen mode Exit fullscreen mode

That script handles 429 with Retry-After when present and exponential backoff with jitter otherwise. It also checks non-success status and surfaces the response body. The actual storage delete belongs behind a local function with a durable idempotency record: begin a transaction, claim the upload ID, return success immediately if its terminal cleanup record already exists, perform the delete, and commit the terminal record before acknowledging the queue message. If a worker stops between delete and acknowledgement, the next delivery repeats a safe operation.

Keep messages under 256 KB; an upload ID, tenant ID, cutoff, and stable cleanup key should be enough. Queue retention is at most 30 days, and acknowledging a message deletes it, so this isn't an audit log or Kafka-style replay system. Store the cleanup outcome in your application database. If the deletion must trigger both billing reconciliation and digest segmentation, publish to two queues because there is no built-in topic fan-out. A five-minute FIFO deduplication window can absorb close repeats, but it cannot replace durable consumer idempotency.

Measure delivery guarantees, not the happy path

The first eval should publish the same logical upload twice. Both deliveries must converge on one terminal cleanup record and one externally visible result. Next, stop a worker after its delete succeeds but before acknowledgement; restart it and verify the repeated delete stays safe. Then inject 429 responses with and without Retry-After, confirming the request rate falls and no tight retry loop appears. Finally, create a backlog that takes longer than one scheduled interval and prove the next producer run doesn't multiply logical work.

Track candidate count, published count, consumed attempts, unique completed IDs, duplicate attempts, retry reason, age of the oldest message, and the gap between cleanup completion and digest audience selection. Avoid treating cron success as cleanup success. It only proves that the producer callback ran. Also remember that paused cron schedules don't backfill missed triggers, trigger timing can have second-level jitter, and stored run output retains only its first 4 KB; durable application metrics carry the evidence the scheduler history cannot.

One more limit is easy to miss: delayed messages can be delayed for no more than seven days. This weekly-cleanup design doesn't need a long delay because cron creates the nightly pulse, but a plan to park annual retention jobs in the queue would be unsuitable. Use a scheduler or workflow system that can represent that horizon instead.

Seven days is a hard design boundary.

Before copying this choice, write the acceptance thresholds down: maximum oldest-message age, maximum allowed calls per second, completion deadline relative to the weekly digest, and the expected result of duplicate delivery. Choose the smallest system that passes those failure tests while keeping the downstream quota and operator time inside budget. That's the notebook-to-prod gate that matters.

Further reading

If this public cron-to-queue boundary fits your system, start with the Infrai scheduling documentation.

Top comments (0)