Dead-Letter Queue
Failed jobs as the DLQ
Simple Durable Jobs has historically treated exhausted failed jobs as the
dead-letter queue. Today the DLQ is every automatic terminal failure: jobs that
exhaust retries and jobs whose handler returns NoRetry; operator cancellation
is excluded. There is still no separate archive table: poison jobs remain in the
jobs table with status = failed, and Requeue is the replay path.
Newer schemas add explicit DLQ metadata to those rows:
dead_lettered_at: when the job reached an automatic terminal failuredead_letter_reason: a concise summary, such asmax retries exhausted: ...
Rows that existed before this metadata was added are not backfilled. They remain queryable by failed status, but they do not appear in explicit DLQ queries unless they fail again and reach an automatic terminal failure.
List and count
Use the named triage helpers to inspect poison jobs without scanning all failed rows:
dead, err := q.ListDeadLettered(ctx,
jobs.DeadLetterQueue("emails"),
jobs.DeadLetterType("send-email"),
jobs.DeadLetterLimit(50),
)
if err != nil {
return err
}
count, err := q.CountDeadLettered(ctx, jobs.DeadLetterQueue("emails"))
if err != nil {
return err
}
_ = countResults are ordered by dead_lettered_at DESC. Use DeadLetterOffset with
DeadLetterLimit for pagination.
On SQLite that column is stored as text carrying a UTC offset, so the sort is a
newest-first ordering of instants only when every row shares one clock face. Rows
dead-lettered by this version do; rows written by releases before this one carry
the offset of whichever process wrote them, so a mixed-zone fleet — or one worker
across a daylight-saving fall-back — can leave those legacy rows out of order
relative to each other. They are still returned, and the DeadLetteredSince /
DeadLetteredUntil window selects them by instant regardless. They age out with
retention. Postgres and MySQL store a real instant and are unaffected.
Triage and replay
Dead-lettered jobs keep their original job row, arguments, last error, and DLQ
metadata for inspection. After fixing code or an external dependency, replay a
job with Requeue:
With a payload codec configured, last_error and the error suffix of
dead_letter_reason are encrypted at rest, just like job arguments and results.
The fixed dead_letter_reason label (such as max retries exhausted: ) stays
plaintext so the SQL retries-exhausted classification keeps working; only the
appended error text is encrypted. Both values are decoded transparently on
readback through storage and in the dashboard, so triage helpers and the embedded
UI show readable text. Direct SQL against the last_error and dead_letter_reason
columns sees the ciphertext form (base64 behind an sdjenc: tag). Under the
default identity codec the error text is stored verbatim. See
Payload Codec for details.
ok, err := q.Requeue(ctx, jobID)
if err != nil {
return err
}
if !ok {
// job was missing or no longer failed/cancelled
}Requeue clears dead_lettered_at and dead_letter_reason, resets execution
state, and deletes checkpoints so the workflow starts from the beginning.
Handlers must still be idempotent because execution is at least once.
Retention interaction
Retention GC is on by default, and dead-letter metadata does not protect a
failed row from it: terminal failed and cancelled rows are pruned by
completed_at, 90 days after they reached their terminal status on a worker
started with no retention options. Triage that has not happened by then has no
row left to triage.
Widen RetentionFailedAfter to however long operators actually need to inspect,
diagnose, export, or requeue dead-lettered jobs. WithRetention replaces the
stock windows rather than merging with them, so restate the windows you still
want — a window omitted from the call is 0, which keeps that status forever:
w := jobs.NewWorker(q,
jobs.WithRetention(
jobs.RetentionCompletedAfter(30*24*time.Hour),
jobs.RetentionFailedAfter(180*24*time.Hour),
jobs.RetentionConsumedSignalsAfter(7*24*time.Hour),
),
)See Retention GC for the stock windows and for how to turn retention off entirely.
Deletes are permanent. When retention removes a dead-lettered job, it also removes the row operators would use for DLQ triage and replay.