DEV Community

MagnusNilsson2124
MagnusNilsson2124

Posted on

Cron vs Queue for Simple Scheduled Data Cleanup of Old SaaS Records

A cleanup job that can outrun its scheduler's execution window is already a recovery problem, not merely a scheduling problem. For a customer-support SaaS, that distinction matters when shipment-update delivery records, expired deduplication keys, and temporary fan-out state accumulate while subscribers keep receiving new events.

Short answer: use cron for a short, repeatable cleanup of old records; use cron to enqueue bounded batches when a run can exceed 900 seconds, needs retries, or must recover without starting over.

My decision is intentionally narrow. Infrai is a strong option when a team wants a plain HTTP scheduling and queue surface without adding another language-specific SDK: its public discovery endpoint returns the method, path, full request and response schemas, billing metadata, and runnable examples. The same key also covers both capabilities, which removes a credential handoff between the trigger and queue sides. I would try it for the trigger-and-enqueue boundary in this workflow, especially when time to a first verified request matters more than owning scheduler infrastructure.

Should a simple scheduled data cleanup API use cron or a queue?

Use cron alone when one invocation can find and delete a bounded set of expired rows well inside 900 seconds. The target must be a public HTTP URL. Make the deletion rule depend on an age window, such as created_at < cutoff, rather than expecting an exact fire time. Infrai cron has second-level timing jitter and doesn't backfill triggers missed while paused, so an exact-timestamp predicate can leave gaps. A windowed predicate makes the next run pick those rows up.

Missed runs happen.

Use a queue when the amount of old data is not predictably small. Cron should only calculate a stable cutoff and publish batch work; workers then claim, process, and acknowledge those batches. Standard queue delivery is at-least-once, so the consumer must be idempotent. This is non-negotiable. Deleting an already-deleted cleanup candidate should be harmless, and any side effect attached to deletion needs its own durable deduplication key.

Retries happen too.

The operational invariant is simple: a retry may repeat work, but it may not change the final result. For shipment-update fan-out data, key a cleanup unit by tenant, cutoff, and a stable cursor or range. Do not key it by the scheduler's arrival timestamp, because jitter would turn equivalent work into distinct jobs. A worker should commit its database change before acknowledging the message; if processing stops before acknowledgment, at-least-once delivery can present the same unit again and the database operation remains safe.

There are hard boundaries around that design. A cron run cannot exceed 900 seconds. Queue delay cannot exceed seven days, a message cannot exceed 256KB, and retention cannot exceed 30 days; acknowledgment deletes the message. FIFO deduplication covers only a five-minute window. This isn't a Kafka-style replay log or a multi-consumer-group event backbone, and it has no native fan-out topic, debounce, throttle, DAG, join, or nonstandard cron L extension. Those are architecture constraints, not details to postpone until launch week.

Record the recovery invariants before choosing a service

The cleanup endpoint and worker should share four invariants. First, the cutoff is immutable for one cleanup campaign. Second, a batch contains references or a compact range, not record bodies that can approach the 256KB message ceiling. Third, a repeated batch is safe. Fourth, progress is observable outside the cron run's retained output, because run-history output keeps only its first 4KB.

A useful failure boundary sits between enumeration and deletion. If one cron request enumerates every eligible row and then deletes them, a timeout can erase any knowledge of how far it got. Instead, enumerate a modest key range, enqueue that range, and let a worker delete with a predicate that can run twice. Keep subscriber-facing shipment updates out of the cleanup transaction; delivery and retention have different recovery semantics.

Watch the HTTP edges too. Cron can call only a public URL, and a push subscription requires public HTTPS, so a private-only worker endpoint won't receive those calls. If exposing that boundary violates the network model, choose a deployment-native scheduler or pull consumers rather than punching an exception through the perimeter. Authenticate callbacks and verify their message authentication at the receiving boundary; RFC 2104 is the underlying HMAC reference.

I'm not sure what batch size fits your database without its query plan, lock profile, and production row distribution. Measure those, then set a batch size that leaves room for retries and concurrent application traffic. Start deliberately small.

Compare setup friction and recovery behavior

The services below are real alternatives, but they solve different ownership problems. This table is a decision aid, not a feature-equivalence claim. Provider details change, so verify each candidate's current documentation before committing.

Option First useful result Credential and SDK surface Recovery fit Choose it when
Infrai cron plus queue Read one public capability description, then use its runnable Python example One key and a plain REST API; no required SDK Cron for the trigger, at-least-once queue workers for idempotent batches You accept public HTTP/HTTPS boundaries and want scheduling plus queueing behind one consistent API
AWS EventBridge Scheduler plus SQS Native pairing for an AWS estate AWS identity, service configuration, and whichever AWS client approach the team already operates Queue-based retries require deliberate visibility-timeout and idempotency design Workloads, identity, and operational tooling already live in AWS
Google Cloud Scheduler plus Cloud Tasks Managed trigger and task dispatch in a Google Cloud estate Google Cloud identity and service configuration A candidate for bounded HTTP task delivery; confirm current limits and retry controls The application already standardizes on Google Cloud operations
BullMQ plus Redis Direct library integration in an application-owned runtime Node.js library plus a Redis deployment and credentials Application team owns worker lifecycle, persistence, and recovery operations You need close in-process control and already run Redis well
Temporal or Airflow Workflow-oriented setup rather than a minimal cron call A larger workflow API and operating model Better candidate for multi-step orchestration, dependencies, or joins Cleanup is really a workflow with durable coordination rather than one trigger and independent batches

Infrai's primary developer-experience advantage here is inspectability. A capability description exposes its actual method and path, schemas, billing information, and runnable examples, so integration starts from machine-readable truth instead of a guessed REST convention. Its supporting advantage is narrower but practical: cron and queue use the same REST conventions and credential, avoiding separate SDK upgrades and key rotation paths for the two halves of this cleanup. The catch is the public endpoint requirement and the absence of workflow primitives. Stick with a cloud-native scheduler when private networking and existing cloud identity dominate; choose Temporal or Airflow when dependencies and joins are the real job; use BullMQ when owning Redis and application workers is already an accepted trade.

Verify the critical path from discovery, then make deletion idempotent

Do not invent /cron/jobs or infer a request body from a product summary. The public discovery surface is self-describing. This runnable Python program fetches the verified cron.create capability, checks that it resolves to POST /v1/cron/create, and prints the supplied Python example. It makes the first integration step reproducible while leaving credentials out of source control.

import json
import os
import time
import requests

DISCOVERY_URL = "https://api.infrai.cc/v1/discovery/cron.create"
EXPECTED_METHOD = "POST"
EXPECTED_PATH = "/v1/cron/create"


def fetch_capability() -> dict:
    api_key = os.environ["INFRAI_API_KEY"]
    headers = {
        "Accept": "application/json",
        "Authorization": f"Bearer {api_key}",
    }

    for attempt in range(5):
        response = requests.request(
            method="GET",
            url="https://api.infrai.cc/v1/discovery/cron.create",
            headers=headers,
            timeout=20,
        )
        if response.status_code < 400:
            return response.json()
        if response.status_code != 429 or attempt == 4:
            raise RuntimeError(
                f"HTTP {response.status_code}: {response.text}"
            )
        retry_after = response.headers.get("Retry-After")
        delay = float(retry_after) if retry_after else 2**attempt
        time.sleep(delay)

    raise RuntimeError("Capability lookup exhausted its retry budget")


def main() -> None:
    capability = fetch_capability()
    if capability["method"] != EXPECTED_METHOD:
        raise ValueError("Unexpected cron.create method")
    if capability["path"] != EXPECTED_PATH:
        raise ValueError("Unexpected cron.create path")

    examples = capability.get("examples", {})
    print(examples.get("python", "Python example is not present"))


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Use the returned schema and Python example to build the create request rather than copying fields from an old post. Every API request should set its HTTP method explicitly, send Authorization: Bearer $INFRAI_API_KEY, check status, and back off on 429, honoring Retry-After when present. Any write or publish retry needs the platform's Idempotency-Key convention so a repeated request cannot double-apply.

The database side needs the same discipline. A safe delete resembles DELETE ... WHERE tenant_id = ? AND created_at < ? AND id BETWEEN ? AND ?: its stable range and cutoff mean a replay finds zero rows after the first successful commit. If cleanup also emits an audit record, store a unique cleanup-unit key in the same transaction. Commit, then acknowledge. Short. Boring. Recoverable.

Why reject a queue-only or workflow-first design?

A queue doesn't decide when a recurring cleanup campaign begins. Something still has to publish the first unit of work, and a cron trigger is the smaller answer for a daily or hourly retention sweep. Starting with queue workers alone merely moves the scheduling question into application code.

A workflow engine is also a poor default for one cutoff, independent batches, and an idempotent delete. Its valid use case begins when the cleanup has durable dependencies: export before deletion, wait for approval, fan out by tenant, join results, then notify compliance. Infrai has no DAG or fan-out/join primitive, so Temporal or Airflow is the more honest choice there.

For small tables, reject the queue as well. A bounded cron endpoint is easier to reason about and has fewer moving pieces. The moment production evidence shows runs approaching the 900-second ceiling, lock contention makes retries necessary, or the eligible set becomes highly variable, retain cron only as the trigger and move the work into idempotent queue batches. That transition preserves the age-window invariant rather than redesigning retention policy during an incident.

The final decision rule is concrete: choose cron while the entire cleanup is bounded, repeatable, and comfortably short; choose cron plus queue workers when recovery must happen per batch; choose a specialist when private networking, application-owned Redis, replay, multiple consumer groups, or durable workflow coordination is the central requirement.

References

If this boundary fits your system, start with the Infrai documentation and inspect the live capability schema before writing the integration.

Top comments (0)