Recovery Worker & Policy Matrix¶
Verified by tests
DeadLetterRecoveryWorkerTests, DefaultDeadLetterRecoveryPolicyTests, DeadLetterRecoveryPolicyTests, EFCoreDeadLetterRecoveryServiceTests, DeadLetterRecoverySqlTests, DeadLetterMetricsTests — library CI run #31657041675 (2026-08-13)
DeadLetterRecoveryWorker is the policy engine for wh_dead_letters. On a
backstop cadence (default 10 min, ScanIntervalMinutes) it scans for due
rows (up to ScanBatchSize, default 200, per cycle), consults the
configured IDeadLetterRecoveryPolicy for each, and either re-emits the row
onto its source work table (via the atomic recover_dead_letter SQL
function, which re-inserts with attempts=0), holds it for review, or marks
it permanently failed. Besides the backstop poll, the worker also wakes
within milliseconds of a new DLQ row when the DeadLetterReady NOTIFY
signal is wired — migration 056 adds an AFTER INSERT trigger on
wh_dead_letters that fires it.
Default policy by failure reason¶
DeadLetterRecoveryOptions.PolicyByReason ships with this matrix:
MessageFailureReason |
Policy | Max recovery attempts | Cooldown | After exhaustion |
|---|---|---|---|---|
Throttled |
AggressiveRetry | 3 | 30 min | PermanentlyFailed |
TransportException |
MediumRetry | 3 | 1 h | PermanentlyFailed |
LeaseExpired |
AggressiveRetry | 5 | immediate | PermanentlyFailed |
MaxAttemptsExceeded |
ConservativeRetry | 1 | 6 h | HoldForReview |
EventStorageFailure |
HoldForReview | 0 | — | HoldForReview |
ValidationError |
HoldForReview | 0 | — | HoldForReview |
SerializationError |
HoldForReview | 0 | — | HoldForReview |
TransportNotReady |
MediumRetry | 3 | 30 min | PermanentlyFailed |
Unknown |
OneShotThenHold | 1 | 1 h | HoldForReview |
"After exhaustion" is driven by RecoveryPolicy.HoldForReviewAfterExhaustion:
once recovery_attempts reaches the policy's MaxRecoveryAttempts, the
worker transitions the row to HoldForReview (when true) or
PermanentlyFailed (when false) — it never keeps retrying past the
budget. Failure reasons without an entry in PolicyByReason (e.g. the
newer SecurityContextEstablishmentFailure, EmptyStreamId,
MessageBodyTooLarge reasons) fall back to the Unknown policy.
Reasons that indicate data corruption or schema mismatch
(ValidationError, SerializationError, EventStorageFailure) skip recovery
entirely — they're held for an operator to look at. Reasons that indicate
transient broker stress (Throttled, TransportException) retry
aggressively. MaxAttemptsExceeded gets one careful retry after a 6-hour
cooldown — long enough that whatever caused the original attempt to fail
has likely passed.
Custom policy¶
Operators that need finer control replace the default policy via DI:
services.AddSingleton<IDeadLetterRecoveryPolicy, MyCustomPolicy>();
public sealed class MyCustomPolicy(
IOptions<DeadLetterRecoveryOptions> options) : IDeadLetterRecoveryPolicy {
// Delegate to the default dictionary-lookup policy for anything we don't override.
private readonly DefaultDeadLetterRecoveryPolicy _fallback = new(options);
public RecoveryPolicy GetPolicy(DeadLetterEntry entry) {
if (entry.MessageType.Contains("InventoryAdjust")) {
return new("InventorySpecific", MaxRecoveryAttempts: 5,
Cooldown: TimeSpan.FromHours(2),
HoldForReviewAfterExhaustion: true);
}
return _fallback.GetPolicy(entry);
}
public StreamRecoveryMode GetStreamMode(DeadLetterEntry entry)
=> entry.StreamId is null ? StreamRecoveryMode.PerMessage
: StreamRecoveryMode.TailAware;
public bool ShouldRecover(DeadLetterEntry entry) {
if (entry.RecoveryStatus == DeadLetterRecoveryStatus.HoldForReview) { return false; }
return _fallback.ShouldRecover(entry);
}
}
Stream recovery modes¶
StreamRecoveryMode is the stream-coordination hint on the policy surface:
PerMessage— recover each row independently. Default for any DLQ row without astream_id.TailAware— coordinate recovery with sibling DLQ entries on the samestream_id, preserving FIFO when every recovery succeeds. The default policy returnsTailAwarewheneverstream_idis set.
Updated
At this commit the recovery worker does not consult
IDeadLetterRecoveryPolicy.GetStreamMode — rows recover per-message, in
FIFO order by dead_lettered_at (the order fetch_dead_letters_due
returns them). StreamRecoveryMode ships on the policy interface so custom
policies compile against the final shape, but TailAware gather-and-re-emit
coordination is design intent, not shipped behavior.
Generation-tagged auto-replay¶
Every DLQ row records the generation (typically the Whizbang+app version
combo) it dead-lettered under. On every deploy:
- The
DeadLetterRecoveryWorkerruns the sweep (reset_dead_letters_for_generation) once at startup. - For every row whose current generation is NOT in
retried_on_generations,next_recovery_atis reset toNOW()andrecovery_statusreturns toPending. The sweep skipsPermanentlyFailedrows and rows held via operator dispositionHoldIndefinitely— butHoldForReviewrows are included, so a held row gets one fresh attempt on each new build. - The generation is appended to
retried_on_generationsso the next deploy doesn't re-trigger it.
This implements the "we shipped a fix — try again" semantic. Operators don't need to manually trigger a sweep after a hotfix; the row gets a free attempt on the new generation. If the fix didn't address it, the row falls back to its normal recovery cadence.
Disable via the options API (DeadLetterRecoveryOptions is registered with
AddOptions() and is not auto-bound from appsettings.json):
services.Configure<DeadLetterRecoveryOptions>(o => o.EnableGenerationReplay = false);
// Other knobs on the same options class:
services.Configure<DeadLetterRecoveryOptions>(o => {
o.Enabled = true; // killswitch for the whole worker (default true)
o.ScanIntervalMinutes = 10; // backstop cadence (default 10)
o.ScanBatchSize = 200; // rows fetched per scan cycle (default 200)
});
Telemetry¶
The worker reports through DeadLetterMetrics (meter Whizbang.DeadLetters):
| Metric | Type | Dimensions |
|---|---|---|
whizbang.dead_letters.recovered |
counter | source_table |
whizbang.dead_letters.held |
counter | policy_name, reason |
whizbang.dead_letters.permanently_failed |
counter | policy_name, reason |
whizbang.dead_letters.recovery_attempts |
counter | reason |
whizbang.dead_letters.generation_replay_scheduled |
counter | generation |
The worker also exposes in-process counters for tests and health endpoints:
TotalScans, TotalRecovered, TotalHeld, TotalPermanentlyFailed,
TotalGenerationReplays.
See also¶
- Internal DLQ table
- Operator HTTP API — manual operator actions
- Transport DLQ recovery — the broker-side flow