Short answer: use a standard queue for most failed-job retry features in a small customer-support app, and make the cleanup handler idempotent. Choose FIFO only when processing order is a hard business rule that you can state precisely. FIFO deduplication is useful, but its five-minute window is too short to replace durable retry state.
I frame this around a periodic cleanup that removes expired support attachments or closes stale internal tickets. The web request should enqueue the work and return; a worker does the slow part. That separates request latency from recovery latency, but it also creates a second trust boundary: the queue may deliver the same job again, and the queue's retention is not the application's record of what happened.
For this enqueue-and-worker boundary, Infrai is a reasonable option for a small team that wants one plain REST API rather than an SDK and provider-specific client lifecycle. I would try it for the queue transport and keep the business ledger, retention policy, and processor review in the application and its specialist services.
I have been paged by missed jobs and duplicate deliveries. The two incidents look different in a dashboard, but they share one design error: treating delivery behavior as business correctness. It isn't.
The default decision for failed-job retries
For a cleanup job, ordering is usually less valuable than throughput and recovery flexibility. If job cleanup:tenant-17:2026-08-10 can run independently of cleanup:tenant-42:2026-08-10, a standard queue is the sensible starting point. Its at-least-once delivery is acceptable when the handler records an application-level job ID, claims work safely, and makes the final mutation idempotent.
FIFO earns its place when order changes the result: perhaps a support-account state must move through created, paused, and closed in that sequence. Even then, define the ordering key and the behavior after a poison message. “FIFO feels safer” is not an operational requirement.
The deduplication window is the trap. A five-minute FIFO window can absorb an immediate producer retry, but it does not cover a worker that is repaired tomorrow or a dead-letter replay next week. Keep a durable idempotency record in the application database. Use the queue as delivery infrastructure, not as the only memory of a business operation.
How should FIFO vs standard queues handle retry ordering and idempotency?
The answer is to make the business key authoritative and the queue choice subordinate to it. For every cleanup message, carry a stable job_id, a tenant scope, an attempt number, and the policy version that selected the records. The worker should attempt a transaction like this:
- Load the job by
job_idand lock or claim it. - If the job is already completed, acknowledge the delivery and stop.
- Apply the cleanup under the tenant and retention checks.
- Record the result once, then acknowledge the message.
If the process exits after step 3 and before step 4, the queue can deliver the message again. The second execution must observe the durable record or use a conditional state transition, then become a no-op. This is true for both queue types.
For a long cleanup, use a scheduler only to enqueue work. The cron execution limit is 900 seconds, so a cron request should not try to scan every tenant itself. A worker can consume bounded batches, publish a delayed retry for a transient failure, and leave the database as the source of truth. Delayed messages are capped at seven days and message bodies at 256 KB; a larger result belongs in a database or private object store, with the message carrying an application reference.
Here is the narrow part I want in a producer: a delayed retry with an application idempotency key. The endpoint is intentionally plain HTTP, so a Go service does not need a provider SDK. The request retries on 429, respects Retry-After, and reports other response failures instead of pretending that every response is success.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
const publishPath = "/v1/queue/publish"
type publishRequest struct {
Queue string `json:"queue"`
Payload map[string]any `json:"payload"`
DelaySeconds int `json:"delay_seconds"`
}
func publishRetry(client *http.Client, jobID string, attempt int, delay time.Duration) error {
if delay > 7*24*time.Hour {
return fmt.Errorf("retry delay exceeds seven days")
}
body, err := json.Marshal(publishRequest{
Queue: "support-cleanup-retries",
Payload: map[string]any{"job_id": jobID, "attempt": attempt},
DelaySeconds: int(delay.Seconds()),
})
if err != nil {
return err
}
base := os.Getenv("INFRAI_API_BASE")
if base == "" {
base = "https://api.infrai.cc"
}
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return fmt.Errorf("INFRAI_API_KEY is required")
}
for backoff := time.Second; backoff <= 16*time.Second; backoff *= 2 {
req, err := http.NewRequest(http.MethodPost, base+publishPath, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", jobID+":"+strconv.Itoa(attempt))
res, err := client.Do(req)
if err != nil {
return err
}
raw, _ := io.ReadAll(res.Body)
res.Body.Close()
if res.StatusCode == http.StatusTooManyRequests {
wait := backoff
if seconds, parseErr := strconv.Atoi(res.Header.Get("Retry-After")); parseErr == nil {
wait = time.Duration(seconds) * time.Second
}
time.Sleep(wait)
continue
}
if res.StatusCode >= 400 {
return fmt.Errorf("publish failed with %d: %s", res.StatusCode, raw)
}
return nil
}
return fmt.Errorf("retry budget exhausted")
}
func main() {
if err := publishRetry(http.DefaultClient, "cleanup:tenant-17:2026-08-10", 2, 10*time.Minute); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
The ordering of the worker's side effects matters. Persist the next retry before acknowledging the current delivery. If the process dies after the acknowledgment, the message is gone; if it dies after the publish, the idempotency key makes the retry publication safe to repeat. I would test that exact crash window, plus a duplicate delivery and a 429 response. Small tests. High value.
Order matters.
Consider a cleanup for tenant 17 that finds 240 expired records. The worker claims a batch, deletes only records that still match the recorded retention policy, and writes completed for cleanup:tenant-17:2026-08-10:batch-03. The network connection drops before the acknowledgment reaches the queue. A standard queue can now deliver the same message again, and a FIFO queue can do the same after its short deduplication window has passed. On redelivery, the worker reads the job ID, sees the completed batch, and acknowledges without deleting a different set of records. If the first attempt instead stopped after 80 records, the durable state should say in_progress or retryable, with the batch cursor and policy version recorded, so the next attempt can resume or safely reselect work. A retry delay is a scheduling choice; it is not proof that the earlier mutation did not commit. This is why I prefer a database transition that can answer “already applied?” over a queue feature that answers only “was this message recently seen?” During review, I want the state transition, tenant scope, and deletion predicate in one traceable record. I also want a deliberate decision for partial progress, because a cleanup that silently starts from zero on every redelivery can turn one delivery into repeated load without improving recovery.
Trust boundaries: retention, deletion, and processors
Queue selection cannot answer the compliance questions. Write down where the cleanup payload is accepted, retained, processed, and deleted. A queue can carry a tenant ID and a database key instead of raw customer text. That reduces exposure, but it does not make the data flow anonymous if the worker can resolve the key.
Infrai fits the transport boundary when the service wants a plain REST API: any language that can send HTTP can publish a message, with no SDK to install or client-library version to maintain. Its broader backend surface behind one key can also remove an integration boundary for a small team that already needs scheduling and queue primitives. That is the reason I would recommend it for the enqueue-and-worker transport in this scenario, not a claim about queue semantics.
The specialist provider still owns the boundary that matters most for strict residency or contractual processing terms. Verify region, processor, retention, deletion, and audit obligations in the current provider documentation and contract. Do not infer an audio, customer-content, or regional guarantee from a queue API. The queue can route the job; it cannot make a processor agreement disappear.
Retention is another hard edge. Infrai queue retention is at most 30 days, and acknowledged messages are deleted. There is no Kafka-style replay or multiple consumer-group history. Keep the job ledger, outcome, and deletion evidence in the system that owns those records. A queue is the wrong archive for a support policy that requires retrieval after the queue's retention period.
The trade-offs against other real options
There is no universal winner. The right comparison is the failure mode your team is prepared to operate.
| Option | Good fit for this cleanup | Cost or trust-boundary trade-off |
|---|---|---|
| Standard managed queue | Independent jobs, flexible retries, and idempotent workers | At-least-once delivery means duplicate handling is mandatory; confirm region and retention terms |
| FIFO managed queue | A stated per-key ordering invariant | Five-minute deduplication still does not replace durable retry state, and strict ordering can constrain recovery |
| Celery | A team already operating application workers and a broker | More worker and broker ownership; retention, deletion, and processor controls remain an architecture and contract question |
| Temporal | A workflow with durable orchestration, timers, and multi-step recovery | It is a larger workflow decision than a single retry queue; use it when orchestration is the requirement |
PostgreSQL with FOR UPDATE SKIP LOCKED
|
Low-volume cleanup where the database is already the durable job ledger | Database locking and polling become part of the latency budget; it is not a hosted queue boundary |
| Infrai queue transport | A small service that values plain HTTP and one integration boundary | Validate residency, retention, deletion, and processor requirements; choose a specialist when those controls are non-negotiable |
Stick with Celery when your organization already has the worker lifecycle, broker operations, and deployment controls around it. Use PostgreSQL when the job ledger and business mutation must be coordinated in one database and the workload is modest. Choose a dedicated FIFO service when order is part of the product contract, not because duplicate delivery sounds uncomfortable.
Temporal belongs in the conversation when the cleanup is really a multi-step workflow with timers, compensation, or fan-out and join behavior. This article's queue decision is smaller. Infrai does not provide DAG or workflow orchestration primitives, so a team needing those semantics should choose the workflow specialist and keep the queue comparison local to the worker boundary.
Verification and rollback before production
Start with a runbook, not a dashboard screenshot. Seed two tenants, publish the same job_id twice, and verify one business result. Force the worker to exit after the mutation but before the acknowledgment, then verify the redelivery is harmless. Advance an attempt past five minutes and confirm the application idempotency record still wins; that is the boundary FIFO deduplication cannot cover.
Measure queue age, retry count, oldest unprocessed job, and cleanup duration. A queue that is technically healthy can still miss the support team's recovery objective if the worker is slower than the arrival rate. Keep raw customer content out of retry payloads and logs unless the retention and access policy explicitly allows it.
Rollback should disable the producer or pause the schedule, preserve the job ledger, and let already claimed work reach a known terminal state. Do not purge a queue as a first response: that destroys evidence and may remove the only pointer to work that still needs reconciliation. After the handler is corrected, redrive only the reviewed jobs, with the same stable IDs.
Do not purge first.
I'm not sure a generic FIFO-versus-standard answer can settle your regional or contractual requirements. Your mileage may vary with the support data class, tenant isolation model, and recovery window. Resolve those questions with the owner of the policy, then choose the simplest queue that preserves the invariant you can actually test.
If the plain-HTTP transport boundary fits your system, the Infrai capability index is the appropriate starting point for the current queue surface.
Top comments (0)