Context
Every queue is at-least-once. A worker will process the same job twice — after a timeout, a deploy, a provider retry — and in a system that awards points, that is money. Idempotency is not a quality attribute here; it is the correctness condition, which is why DEC-C6 madeprocessed_jobs
mandatory for every consumer rather than a per-worker choice.
The second half is DEC-C5’s evidence guarantee: every failure and every exhausted retry is
persisted, not merely logged. A log line rotates away; a dead_letters row is something an
operator can find, understand and replay. Successes at volume are metrics, not rows — the asymmetry
is deliberate.
Scope (normative)
core.processed_jobs: the dedupe fence, written inside the work transaction.core.dead_letters: failures with payload, attempts, last error and review state.- The consumer harness: idempotency gate, retry with backoff and jitter, dead-letter writer.
- TTL cleanup for
processed_jobs. - BetterStack alerting on new dead letters.
- Replay from Softcrum Ops, republishing with the same
job_id.
Non-scope (normative)
QueuePortitself and its adapters — TS-002.- Notification rails — those are
messaging, though they use this harness. - A UI for replay —
frontend/ops.
Behaviour (normative)
- A consumer checks and inserts
processed_jobsinside the same transaction as its work. Outside it, a crash between the two leaves the job marked done with nothing done. - A duplicate
job_idacks and skips. Silently, with a metric — a duplicate is normal operation, not an error. - Retry policy: 5 attempts, exponential backoff with jitter, base 30 s, cap 1 h. Overriding it per queue requires a rationale in that module’s spec.
- Exhausted retries and hard failures write a
dead_lettersrow with the full payload, attempt count, last error andcorrelation_id, and alert BetterStack. - Poison messages skip retries entirely. A schema-invalid payload will never become valid; retrying it five times is five wasted minutes and five identical alerts.
- Replay republishes with the same
job_id, soprocessed_jobsguarantees no double effect. That property is what makes replay safe enough to use in bulk. processed_jobsrows expire on TTL; the TTL must exceed the maximum retry window with margin, or a late retry after expiry would re-apply the work.- A dead letter moves
pending_review → replayed | discarded, and discarding requires a reason. - High-volume successes are metrics, not rows. Writing a row per successful job would make this table the largest in the platform for no benefit.
Data (normative)
API (normative)
Ops-only in practice: a tenant does not manage our queues.
Events (normative)
None on the outbox. Dead letters alert through observability, not through domain events — a consumer of domain events reacting to our queue failures would be a loop waiting to happen.Acceptance criteria (normative)
- The same
job_idprocessed twice concurrently produces exactly one effect. - A crash between the work and the fence leaves neither — proven by an induced failure test.
- A transient failure retries 5 times with growing intervals, then writes a dead letter.
- A schema-invalid payload goes straight to dead letters with zero retries.
- Replay of a dead letter with the same
job_idproduces no second effect. - TTL cleanup removes expired rows and never removes one inside its retry window.
- Discarding without a reason is rejected.
- Negative: no successful job writes a row beyond its
processed_jobsfence.
Execution
Single slice, synchronous command. Schema in TS-001; the harness in TS-002 alongsideQueuePort.
Runbook: ../../runbooks/dead-letter-replay.md.