Repository navigation
GH-5209 Dead letters: failure fingerprints, operation counts, replay history, archive/restore and tenant scoping - #5210
Merged
Conversation
…history, archive/restore and tenant scoping
Additive for 6.x: no existing public member changes signature, and the new
IDeadLetters members have default implementations.
- Failure fingerprint: IDeadLetterFingerprinter (DefaultDeadLetterFingerprinter
hashes the exception type + first application stack frame, skipping a
configurable list of framework namespaces, ignoring line numbers and the
exception message, with a "no stack trace" fallback). Computed on every path
into dead letter storage and stamped as the failure-group / failure-title
headers. A group already stamped on the envelope survives a later stackless
exception (native DLQ recovery, IDeadLetterInterceptor substitutions).
- DeadLetterEnvelopeQuery: FailureGroup, ExceptionMessageMatch (Exact /
Contains), TenantId (routes to the tenant's database in
MultiTenantedMessageStore), Archived (excluded by default).
- SummarizeAllAsync(serviceName, DeadLetterSummaryOptions, token) grouped by
failure group / message type / endpoint / exception type with first and last
failed timestamps. Rows written before fingerprinting are grouped by
exception type under an "unfingerprinted:" key that also works as a filter.
- ReplayMatchingAsync / DiscardMatchingAsync / ArchiveMatchingAsync /
RestoreMatchingAsync return DeadLetterOperationResult { Affected }. The
header-filter path sums its id chunks.
- Replay history: ReplayCount, LastReplayedAt, FirstFailedAt, FailedAt on
DeadLetterEnvelope. The relational replay move records history in
wolverine_dead_letter_history, which the dead letter insert reads back.
- Archive/restore with an archived_at column; DurabilitySettings
.ArchivedDeadLetterRetention expires archived rows. Unarchived expiry is
unchanged.
- IDeadLetters.Capabilities (DeadLetterCapabilities flags).
Relational stores (PostgreSQL, SQL Server, SQLite, MySQL, Oracle) implement
everything, with additive Weasel migrations (seven nullable columns, a
failure_group index and the history table). RavenDb and CosmosDb implement
everything except replay history.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCggXgxnG3EBXoR9rrq37U
…r_history Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DCggXgxnG3EBXoR9rrq37U
This was referenced Oct 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #5209
Store-level support for managing dead letters as groups of the same failure: a stable failure fingerprint, grouped summaries, affected-row counts from bulk operations, replay history across replay → re-failure, archive/restore, exception-message match modes and tenant scoping.
Strictly additive for 6.x. No existing public member changes signature. The new
IDeadLettersmembers are default interface implementations, so third-party stores keep compiling. The schema changes are additive Weasel migrations.New public API
Fingerprinting (
Wolverine.Persistence.Durability.DeadLetterManagement)IDeadLetterFingerprinter.Fingerprint(Envelope, Exception?)→DeadLetterFingerprintDeadLetterFingerprint(string Group, string Title), withHashed(identity, title),UnfingerprintedGroupPrefix("unfingerprinted:"),UnfingerprintedGroupFor(...),UnfingerprintedTitleFor(...),TryParseUnfingerprintedGroup(...),MaxGroupLength,MaxTitleLengthDefaultDeadLetterFingerprinter, withFrameworkNamespacePrefixes,FindApplicationFrame(stackTrace, exceptionMessage)andIsFrameworkFrame(frame)DeadLetterFingerprinting.Apply(fingerprinter, envelope, exception)andDeadLetterFingerprinting.DefaultDurabilitySettings.DeadLetterFingerprinter. AnIDeadLetterFingerprinterregistered in the container replaces it at startup.DeadLetterQueueConstants.FailureGroupHeader("failure-group") andFailureTitleHeader("failure-title"), both added toDiagnosticHeadersDeadLetterQueueConstants.StampFailureMetadata(Envelope, Exception, IDeadLetterFingerprinter?)overload. The existing overload now also stamps the failure group, using the default fingerprinter.Query and results
DeadLetterEnvelopeQuery:FailureGroup,ExceptionMessageMatch(DeadLetterMessageMatch.Exact/Contains; null keeps the originalLIKEbehavior),TenantId,Archived(DeadLetterArchiveFilter.Exclude/Only/Any)DeadLetterEnvelopeQueryhelpers:EffectiveArchiveFilter(),CopyForPaging(int),MatchesExceptionMessage(...),MatchesFailureGroup(...),MatchesArchived(...)DeadLetterEnvelope:FailureGroup,FailureTitle,FailedAt,FirstFailedAt,ReplayCount,LastReplayedAt,ArchivedAtDeadLetterEnvelopeGetRequest(HTTP /MessageStoreCollection):ExceptionMessageMatch,FailureGroup,ArchivedDeadLetterOperationResult { int? Affected }, withNotReportedandAdd(...)DeadLetterCapabilitiesflags:FailureGroups,OperationCounts,ArchiveAndRestore,ReplayHistory,ExceptionMessageMatching,AllDeadLetterGrouping(FailureGroup,MessageType,Endpoint,ExceptionType),DeadLetterSummaryOptions { Grouping, Query },DeadLetterGroupSummary(ServiceName, Database, Grouping, Key, Count) { Title, ExceptionType, PredatesFingerprinting, FirstFailedAt, LastFailedAt }DeadLetterSummaries.Group(...),KeyFor(...),SummarizeByQueryingAsync(...),ContainsPattern(...),LikeEscapeClauseIDeadLetters(all with default implementations)DeadLetterCapabilities Capabilities(defaultNone)Task<IReadOnlyList<DeadLetterGroupSummary>> SummarizeAllAsync(string serviceName, DeadLetterSummaryOptions options, CancellationToken token)(default: page throughQueryAsyncand group in memory)Task<DeadLetterOperationResult> ReplayMatchingAsync(DeadLetterEnvelopeQuery, CancellationToken)(default:ReplayAsync, thenAffected = null)Task<DeadLetterOperationResult> DiscardMatchingAsync(DeadLetterEnvelopeQuery, CancellationToken)(default:DiscardAsync, thenAffected = null)Task<DeadLetterOperationResult> ArchiveMatchingAsync(...)/RestoreMatchingAsync(...)(default:NotSupportedException)Settings
DurabilitySettings.ArchivedDeadLetterRetention(TimeSpan?, default null)RDBMS (
Wolverine.RDBMS)DatabaseConstants:DeadLetterHistoryTable,FailureGroup,FailureTitle,FailedAt,FirstFailedAt,ReplayCount,LastReplayedAt,ArchivedAt,DeadLetterExtendedFields,DeadLetterQueryFields,DeadLetterQueryFieldCountDatabasePersistence:ReadDeadLetterExtendedFieldsAsync,DeadLetterGroupingColumns,DeadLetterGroupingColumnCount,DeadLetterSummaryAggregates,ReadDeadLetterGroupSummaryAsyncDeleteExpiredDeadLetterMessagesOperation(database, logger, utcNow, DurabilitySettings)constructor overload (the type itself is internal)Behavior
exceptionType|first application frame. Frames fromSystem.,Microsoft.,Npgsql.,MySqlConnector.,Oracle.,Wolverine.,JasperFx.,Weasel.,Marten.,Polecat.,Fisher.,Lamar.,Internal.Generated.andlambda_methodare skipped, and the list is configurable. If nothing qualifies, the first frame is used. File and line are stripped, and the exception message is removed from the stack text before parsing. With no stack trace the key is"<ExceptionType>: no stack trace". The title isExceptionType: Type.Method(...).MessageContext,DurableReceiver,BufferedReceiver, outbox / sending failures, batching and native-DLQ recovery are all covered.StampFailureMetadatastamps it too. When the exception has no stack trace and the envelope already carries afailure-groupheader, that header is kept. This keeps the real group across anIDeadLetterInterceptorthat substitutes a redacted exception, and across a native-DLQ recovery that kept the headers. A throwing custom fingerprinter falls back to the default.failure_group. Summaries group them by exception type underunfingerprinted:<type>, titled "<type>(recorded before failure fingerprinting)", and that key works as aFailureGroupfilter. No backfill.delete+insert … select … group by idintowolverine_dead_letter_historybefore deleting the dead letter rows. This is portable across all five engines, with no provider-specific upsert, and safe underMessageIdentity.IdAndDestination. The dead-letter insert reads the history back with scalar subqueries. History older than the expiration window is swept by the hourly expiration operation, which is now always scheduled.replayable. Replay always skips archived rows. A query withMessageIdsdefaults toArchived = Any, so discarding by id still reaches an archived row. The originalSummarizeAllAsync(serviceName, TimeRange, token)now leaves archived rows out.ArchivedDeadLetterRetentionis set, archived rows are deleted once that long has passed sincearchived_at, whether or notDeadLetterQueueExpirationEnabledis set, and theexpiressweep then only touches unarchived rows. When it is null, nothing changes.MultiTenantedMessageStoreroutesquery.TenantIdto the tenant's database for every dead-letter operation. Without a tenant, the counted operations and the grouped summary fan out across every database. The original query, replay and discard without a tenant still throwNotSupportedException, unchanged. A single-database store ignoresTenantId.Schema (additive)
Each relational
wolverine_dead_letterstable gains seven nullable columns:failure_group,failure_title,failed_at,first_failed_at,replay_count,last_replayed_atandarchived_at. It also gains an index onfailure_group, and a newwolverine_dead_letter_historytable (id PK,replay_count,last_replayed_at,first_failed_at, index onlast_replayed_at) appears. The existingdeadletter_admin_compliancesuites ran against schemas created before this branch, so the migration path itself was exercised.Store support
IDeadLettersCapabilities = None)nullArchivedDeadLetterRetentionis enforced by the relational stores.Tests run locally (each suite on its own,
Release,net9.0)dead_letter_fingerprintinganddead_letter_query_helpersclasses.dead_letter_failure_group_compliance,deadletter_admin_compliance, the newMultiTenancy.dead_letters_scoped_to_a_tenant,PostgresqlMessageStoreTests,…_with_IdAndDestination_Identity,…_with_DeadLetter_Expiration,compliance_using_table_partitioningand the DLQBug_*classes. 350 tests, green.deadletter_admin_compliance,SqlServerMessageStoreTests,…_IdAndDestination_Identity,…_DeadLetter_Expiration,…_DQL_expirationandSqlServerBackedMessageStoreTests. 264 + 6, green.deadletter_admin_compliance,MySqlMessageStoreTestsandmessage_store_compliance(107 together), and…_IdAndDestination_Identity(74). Green.deadletter_admin_compliance,oracle_durability_command_translation(statement counts updated for the two new statements in the replay move) andoracle_durability_agent_recovery(40 together), plusOracleMessageStoreTestsand…_IdAndDestination_Identity(152). Green.ArchivedAt == nulldoes not match a missing field, so the archived filter is a date-range predicate.IS_DEFINED-guarded predicates.dotnet build wolverine.slnx -c Release -f net9.0: clean.The compliance coverage (
DeadLetterFailureGroupCompliance) runs on every store above and includes: stable fingerprints across message text, distinct groups for distinct frames, grouped summaries (failure group / message type / endpoint) with timestamps, theFailureGroupfilter on query, replay and discard, counts including the >500-row header-filter chunk path, Contains vs Exact with wildcard characters in the value, archive / restore (excluded from query, summaries and replay by default; discard by id reaches archived rows), pre-fingerprint rows, and replay history across two replay → re-failure cycles. The relational subclass adds archived retention and the history sweep. Framework-frame skipping, line-number stability, message removal, stackless fallback, header preservation and DI registration are unit tested in CoreTests.Docs
docs/guide/durability/dead-letter-storage.mdgains new sections: Failure Groups (fingerprints and theIDeadLetterFingerprinterextension point), Managing Dead Letters Programmatically (the query fields, summaries, counts, replay history, archiving / restoring and retention, store support), plus the new HTTP request fields. The samples are compiled#regions inCoreTests/Persistence/dead_letter_failure_group_samples.cs.Release note
🤖 Generated with Claude Code
https://claude.ai/code/session_01DCggXgxnG3EBXoR9rrq37U