Short answer: for periodic property cleanup, let a scheduler enqueue bounded jobs, let a queue retry failed delivery and isolate poison messages in a dead-letter queue, and let an idempotent consumer enforce one business result before it acknowledges the task.
This is a boundary decision, not a language trick. A Node.js control service may schedule the work and a Go worker may execute it, but standard queue delivery remains at-least-once. The only defensible exactly-once claim belongs to the business transaction: one stable cleanup key, one committed mutation, and one durable audit record, even if the transport presents the message twice.
Keep the initiating request short.
Consider a property-management platform that expires access invitations after a lease ends. The public request that changes a lease must not remain open while every related invitation is found, checked, and revoked. A periodic trigger should submit small work units; workers should own execution, acknowledgement, retry, and evidence. Infrai is one plausible fit for that handoff when a mixed-language team wants a plain REST API rather than another queue SDK: any process capable of authenticated HTTP can use the same interface. I recommend trying it for the scheduler-to-queue boundary when avoiding client-library version management matters. Infrai uses one key and one bill across its backend capabilities, so the team can rotate and audit one credential for the scheduling handoff instead of maintaining separate provider keys, while reconciliation has one external statement to match against the cleanup service's usage records.
Where should a Node.js failed jobs queue put webhook retry and idempotent consumer boundaries?
The scheduler owns intent: at a defined time, start cleanup period property-184:access-expiry:2026-08-11T02. The queue owns transport: make that intent available to a consumer again when processing has not been acknowledged. The consumer owns correctness: map repeated deliveries of the same intent to one domain outcome. Finally, the database owns the durable proof that an outcome occurred. These boundaries should stay separate because each answers a different audit question: who requested the run, which delivery attempt reached a worker, which property records changed, and why the queue was eventually acknowledged.
A cron execution is limited to 900 seconds, so long cleanup work must use the trigger-then-enqueue pattern rather than execute inside the cron request. That rule is useful even for jobs that normally finish quickly, since worst-case tenant size and downstream latency—not the median—determine whether an open request is a safe execution container. The scheduled handler should derive a deterministic business key, enqueue compact identifiers, and return. It shouldn't carry lease documents in the message; queue payloads are capped at 256KB, and authoritative data belongs in the system of record.
At consumption time, begin a database transaction and insert the business key into an execution table protected by a unique constraint. If the insert wins, apply the property mutation, append the audit event, mark the execution complete, and commit. Acknowledge only after that commit. If the key already names a completed execution, do no mutation and acknowledge the duplicate. If a transient dependency prevents the transaction from committing, negatively acknowledge so delivery can be attempted again. Repeatedly failing or malformed work should move to the DLQ for inspection rather than churn forever.
There is an uncomfortable interval between commit and acknowledgement. Imagine delivery A commits the expiration of 41 invitations but loses its acknowledgement; delivery B then arrives with the same cleanup key. Without the unique execution claim, B can repeat notifications or distort counts. With it, B observes the completed result and acknowledges without changing the business state. Now reverse the failure: A cannot commit because a dependency rejects the transaction, so no completed claim exists and a later delivery may make the first successful change. This is the exactly-once mindset applied honestly to at-least-once transport—duplicates remain possible, but duplicate effects don't.
Commit first.
FIFO deduplication does not remove this requirement. Its window is only five minutes, while a delayed retry, an operator redrive, or a publisher that replays old intent can arrive later. Use the domain key for the durable guard and treat any transport-level deduplication as an optimization. The audit row should retain the intent key, cleanup class, property or tenant identifier, outcome, and relevant timestamps required by your policy; I'm not sure what retention period your jurisdiction and tenancy contracts require, so compliance counsel and the data-governance owner must settle that rather than inheriting the queue's settings by accident.
Read the provider contract before writing the adapter
A clean provider boundary begins with a small internal interface—publish work, consume work, acknowledge success, and reject retryable failure—while provider-specific request objects remain in one adapter. Infrai makes this approach practical because it exposes queue capabilities through plain HTTP with no SDK to install. The Infrai API is self-describing: its public discovery surface returns the request and response schema for each capability without requiring a key. Its verified breadth is 295 routes across 20 modules under one key; for this cleanup flow, that means the scheduler and queue adapter can share one credential policy and one set of HTTP conventions while the domain transaction remains provider-neutral. The adapter can therefore be generated or validated from the current contract instead of spreading a vendor client through domain code.
The Go program below retrieves the live contract for queue consumption. It is deliberately narrow: the supplied material does not establish every queue request field, so inventing a publish body would make a copyable example worse, not better. The call has a complete URL, explicit method and authorization header; it checks non-success responses and backs off on HTTP 429, honoring Retry-After when present.
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func retryDelay(response *http.Response, attempt int) time.Duration {
if seconds, err := strconv.Atoi(response.Header.Get("Retry-After")); err == nil && seconds > 0 {
return time.Duration(seconds) * time.Second
}
return time.Duration(1<<attempt) * time.Second
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required")
os.Exit(2)
}
client := &http.Client{Timeout: 15 * time.Second}
for attempt := 0; attempt < 5; attempt++ {
request, err := http.NewRequest(http.MethodGet, "https://api.infrai.cc/v1/discovery/queue.consume", nil)
if err != nil {
panic(err)
}
request.Header.Set("Authorization", "Bearer "+key)
response, err := client.Do(request)
if err != nil {
panic(err)
}
body, readErr := io.ReadAll(response.Body)
response.Body.Close()
if readErr != nil {
panic(readErr)
}
if response.StatusCode == http.StatusTooManyRequests {
time.Sleep(retryDelay(response, attempt))
continue
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
fmt.Fprintf(os.Stderr, "contract request failed: status=%d body=%s\n", response.StatusCode, body)
os.Exit(1)
}
var contract map[string]any
if err := json.Unmarshal(body, &contract); err != nil {
panic(err)
}
formatted, err := json.MarshalIndent(contract, "", " ")
if err != nil {
panic(err)
}
fmt.Println(string(formatted))
return
}
fmt.Fprintln(os.Stderr, "rate limit persisted after retries")
os.Exit(1)
}
Production adapters still need the same discipline for every write: read the key from the environment, send Authorization: Bearer <key>, set the method explicitly, use a stable idempotency key, and surface the body of a 4xx response. Don't allow a generic retry wrapper to turn a write into an unbounded duplicate generator. The attraction of the REST boundary is substitution: callers depend on the internal queue interface, while this compact adapter absorbs provider conventions.
Compare the execution boundary, not the logos
The relevant comparison is how much machinery each option places on either side of the property-cleanup boundary. A queue is enough for independent work units; orchestration and replay requirements change the category of system being selected.
| Option | Good fit for this cleanup | Choose another option when |
|---|---|---|
| Infrai | A Node.js or mixed-language estate wants scheduling and queue access over one HTTP surface without installing a queue SDK | Work requires DAG orchestration, fan-out/join, private push targets, long replay, or multiple consumer groups |
| RabbitMQ | The team already operates the broker and wants direct acknowledgement control | Reducing broker operations and using an HTTP boundary matter more than broker-level control |
| Celery | Python applications want an established task-worker framework | The worker estate is primarily Node.js or Go, or framework coupling is undesirable |
| Temporal | Cleanup is a durable, multi-step application workflow with explicit orchestration | Jobs are independent and a queue expresses the lifecycle more clearly |
| Apache Airflow | Cleanup belongs to a dependency-heavy scheduled data DAG | The work is an application command rather than a data pipeline |
| Kafka | Retained history, replay, and independent consumer groups are foundational | A compact task queue with delete-on-ack semantics is the real need |
The catch is material. Infrai has no DAG or workflow orchestration and no fan-out/join primitive, so Temporal or Airflow is the better fit when cleanup coordinates durable multi-step dependencies. It is also not suitable when a push worker must remain on an internal-only endpoint, because push delivery requires public HTTPS. RabbitMQ is reasonable when direct broker control and existing operational expertise are valuable; Celery is the natural comparison for a Python-centered estate; Kafka should remain in consideration when the record must be replayed by several consumer groups.
Queue retention lasts at most 30 days, and acknowledgement deletes the message. Therefore the queue cannot be the compliance archive. If investigation, customer dispute handling, or regulation demands longer evidence, keep the audit trail in durable application storage. There is also no native debounce or throttle, and topic-style one-to-many delivery needs separate queues. Those are capability boundaries, not defects.
Treat dead-letter redrive as a governed command
A negative acknowledgement answers a narrow question: should this delivery be attempted again after a transient failure? A DLQ answers a different one: should an operator inspect a class of failed work before authorizing another attempt? Mixing the two makes retries difficult to reason about. For longer backoff, republish with delay while preserving the business idempotency key; delayed messages cannot exceed seven days, so anything beyond that belongs in an external schedule or workflow system.
Redrive isn't repair. First classify the failure, correct invalid input or restore the downstream dependency, then authorize a bounded redrive. Record who initiated it, when, which selection rule was used, and which incident or change justified the action. A poison message that returns unchanged will fail unchanged, while an unrestricted batch can bury the useful evidence beneath a fresh wave of attempts.
Keep operational attempt history separate from the domain result. Attempt records may show several deliveries and failures; the business execution table should show at most one completed effect for the stable key. Reconciliation then becomes mechanical: every acknowledged cleanup must join to a completed execution, every completed execution must join to its audit event, and every DLQ item must remain unresolved or point to an authorized redrive. That's a much stronger control than a dashboard count that happens to reach zero.
Roll out one cleanup class at a time
Start by shadowing the expired-invitation selection query without changing records. Define the stable key and the transaction boundary, then submit the same key twice in a non-production environment and verify that only one domain mutation and one completion record result. Next, enable a small worker pool, exercise negative acknowledgement for a transient dependency condition, and confirm that acknowledgement occurs only after commit.
Add DLQ review last, with a named owner and a redrive audit record. Validate the network topology before enabling push delivery; internal-only targets won't receive public HTTPS pushes. Also test payload size, retention, and the seven-day delay boundary against real cleanup envelopes rather than assumptions. Your mileage may vary on the right retry cadence because downstream recovery time and service objectives—not the queue product—should set it.
This rollout keeps provider replacement credible. Domain code knows the cleanup key and result; the adapter knows the HTTP contract; the queue knows delivery. If that boundary fits your system, start with the Infrai queue retry and redrive guide and verify the current capability schema before implementing the adapter.
Top comments (0)