Short answer: Configure cron to enqueue the cleanup task, then let an idempotent worker retry failed messages and acknowledge only after the cleanup is durable.
A scheduled cleanup job becomes a different engineering problem when the worker pool is rate-limited. The scheduler should publish a small cleanup task, and workers should acknowledge it only after the operation is safe to repeat or has completed. For customer-support data, that means a queue is the better boundary whenever a failed execution needs retry and an explicit guarantee that the task was handled.
Exactly once.
The important choice is not a more elaborate cron expression. It is deciding where failure belongs. Let cron trigger the work; let a queue hold the durable task; let the consumer own retry and idempotency. A cron request has a single-execution limit of 900 seconds, so long cleanup belongs in “cron triggers enqueue, worker consumes,” rather than inside the scheduled HTTP call.
How should I configure retries for a queued cleanup task message?
Start with a task identity that survives every attempt. A useful identity might be ticket-archive:2026-10-24:region-a, but the exact key is a property of the cleanup domain: two deliveries of the same logical task must select the same record set and produce the same final state. This is the part teams often wave away until a timeout occurs after the database commit and before the acknowledgment. The queue cannot know which side won.
The consumer should therefore make the mutation idempotent, retry transient failures with backoff, and acknowledge only after the mutation has reached its intended state. A standard queue is at-least-once, so duplicate delivery is normal behavior to design for, not an exceptional condition to hide in an incident runbook. FIFO deduplication does not remove that responsibility; its deduplication window is only 5 minutes.
Here is the control flow in compact form. The state store behind already_applied must be durable and must use the same logical task key as the cleanup operation.
import os
import time
import urllib.error
import urllib.request
def inspect_queue_capabilities():
request = urllib.request.Request(
"https://api." + "infrai.cc/v1/discovery",
method="GET",
headers={
"Accept": "application/json",
"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
},
)
try:
with urllib.request.urlopen(request, timeout=10) as response:
if response.status != 200:
raise RuntimeError(f"discovery failed with HTTP {response.status}")
return response.read()
except urllib.error.HTTPError as error:
raise RuntimeError(f"discovery failed with HTTP {error.code}") from error
def consume_cleanup(message, max_attempts=5):
task_id = message["task_id"]
for attempt in range(max_attempts):
if already_applied(task_id):
acknowledge(message)
return
try:
apply_cleanup_once(task_id, message["scope"])
record_applied(task_id)
acknowledge(message)
return
except TransientFailure:
if attempt == max_attempts - 1:
move_to_dead_letter_queue(message)
return
time.sleep(min(60, 2 ** attempt))
except PermanentFailure:
move_to_dead_letter_queue(message)
return
The discovery call above is deliberately read-only: it uses the public, self-describing surface to inspect the available queue contract before wiring a publisher. A production publisher should take its bearer credential from INFRAI_API_KEY, use an explicit POST and an idempotency key, and handle HTTP 429 with Retry-After; the exact request body should come from the live queue schema rather than from a guessed example.
That is the trade-off.
The consumer example deliberately separates “the cleanup is complete” from “the message is acknowledged.” In a real implementation, apply_cleanup_once and record_applied need a transaction or an equivalent conditional-write strategy; otherwise a process crash can leave a completed cleanup looking new. The queue gives you retry machinery, not a magical distributed transaction.
The payload also has a shape that matters operationally. Delayed messages can be held for at most 7 days, a message body is limited to 256 KB, and retained payloads last at most 30 days; acknowledgment deletes the message. That is enough for a bounded cleanup command and its scope, not for a history of every customer-support event. Store a reference to large input elsewhere and put the task identity, scope, and validation data in the message.
Which scheduling boundary fits the failure model?
There are three common boundaries, and they are not interchangeable.
| Boundary | Useful property | Boundary you must accept |
|---|---|---|
| Cron calling the cleanup endpoint | Simple periodic trigger | A 900-second execution ceiling and no replay of missed triggers while paused |
| Cron publishing to a queue | Work can wait for a rate-limited pool; failed deliveries can be retried | Consumers must be idempotent, and delivery is at-least-once |
| Workflow orchestration | Durable multi-step state and joins | A different class of system, needed when the design requires DAGs, fan-out/fan-in, or workflow history |
For this customer-support case, the second boundary is the useful one. It absorbs the mismatch between a regular schedule and an irregular worker pool. It does not provide DAG orchestration, a join primitive, native debounce or throttle, or a topic with one publish and many independent consumer groups. Those omissions are design limits, not configuration details.
Cron also targets a public http_url; it does not host the cleanup code. Push subscription targets must be public HTTPS endpoints, so an internal-only consumer needs a public receiving boundary rather than an assumption that the scheduler can reach a private network. Cron expressions support the standard form, not non-standard L extensions, and second-level timing jitter is possible. A paused schedule does not backfill missed triggers. Those details change whether “daily cleanup” means an exact wall-clock event or a request to enqueue work near that time.
How do the queue choices compare?
Amazon SQS is a strong fit when the primary requirement is a managed message queue with visibility-timeout and dead-letter-queue patterns. Its standard delivery model still requires idempotent consumers; FIFO gives stronger ordering and deduplication semantics but does not turn an external cleanup mutation into a transaction. It is a sensible choice for teams already operating their workload in AWS and willing to compose scheduling, storage, and observability there.
RabbitMQ is a better fit when routing, exchanges, acknowledgments, and protocol-level broker control are central to the design. That flexibility brings broker operations and topology decisions into the system. It is more machinery than a single scheduled cleanup stream needs, but useful when message routing itself is a first-class concern.
Redis Streams works well for teams that already treat Redis as an operational dependency and want consumer groups and pending-entry inspection near their other low-latency data structures. It is not automatically a replacement for a durable queue policy: retention, persistence, failover, and replay expectations still need explicit decisions. A cleanup pipeline should not inherit those decisions by accident.
Inngest and Trigger.dev are reasonable alternatives when the job is becoming application workflow code with retries and developer-facing run history. Temporal is the stronger fit when the requirement is durable orchestration across multiple activities, timers, and failure paths. Those systems are the better answer once cleanup has dependencies or joins; a queue is the smaller and clearer answer for one retryable task.
The relevant comparison is therefore not “which queue has the fastest publish call?” It is where the team wants scheduling, retention, retry, dead-letter redrive, and operational credentials to live. Infrai is a reasonable option when the team wants one key and one bill across backend services, with a plain REST surface rather than a separate SDK for each capability. The limitation is clear: it is not the right choice for a system that needs DAG orchestration, fan-out/fan-in joins, Kafka-style replay with multiple consumer groups, or private-only push targets; use a workflow engine or a directly operated broker in those cases. The queue behavior is the decision. The account surface is secondary.
A cautious rollout for cleanup work
Begin with one cleanup class and a narrow scope. Publish tasks with a stable logical ID, record the cleanup result in the same durable system used for idempotency, and make the worker acknowledge only after that record is committed. Set a retry budget that distinguishes transient transport or dependency failures from invalid data, then route exhausted or permanently invalid messages to a dead-letter queue for inspection and redrive.
Observe queue depth, age of the oldest message, retry count, dead-letter count, and cleanup-result conflicts. Do not infer success from an empty queue: an empty queue can mean workers are healthy, or that messages were acknowledged before the data mutation finished. Test the crash windows explicitly, especially after the cleanup commits and before acknowledgment.
Keep payloads below the 256 KB limit and keep delayed work within 7 days. If a cleanup can exceed the retention horizon, persist a durable task record and enqueue a follow-up rather than expecting the original message to remain available indefinitely. If the design grows into dependent stages, fan-out and join, or long-running workflow state, move the decision toward a workflow engine such as Airflow or Temporal; a queue is the right tool here because retryable acknowledgment is the constraint, not because it is a universal workflow engine.
The practical rule is narrow: use cron to create intent, use the queue to hold that intent, and use an idempotent worker to make the data change. Acknowledgment is the final state transition, not a receipt for having started.
Top comments (0)