perf(dlq): index wolverine_dead_letters for replay/cleanup scans (GH-3279)#3323
Merged
Conversation
…3279) The durability agent's DLQ replay (INSERT ... SELECT ... WHERE replayable) and cleanup DELETE both filter on `replayable`, and the expiration sweep filters on `expires`. With no index these seq-scan the entire (potentially multi-GB) dead-letter table on every durability cycle — the reporter saw these as the top-2 SQL statements by AAS during an 8M-event rebuild. DeadLettersTable now provisions indexes automatically as part of normal schema creation/migration: - Postgres/SqlServer/Sqlite: partial (filtered) index scoped to the matching rows, so it stays tiny (replayed rows are deleted immediately). - MySql/Oracle: plain index on the column (no filtered-index support). The SqlServer filter predicate uses the canonical bracketed form ([replayable]=1) and Postgres uses the boolean/`is not null` forms so the predicates round-trip through the system catalogs and don't thrash (drop+recreate) on every migration — covered by new idempotency tests that create the schema and assert FindDeltaAsync reports SchemaPatchDifference.None. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This was referenced Jul 9, 2026
Merged
This was referenced Jul 13, 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 #3279.
Problem
The durability agent's background DLQ replay (
INSERT ... SELECT ... FROM wolverine_dead_letters WHERE replayable = $1) and cleanupDELETE ... WHERE replayable = $1both filter onreplayable, and the expiration sweep (DeleteExpiredDeadLetterMessagesOperation) filters onexpires. With no index on those columns, each durability cycle forces a full sequential scan of the entirewolverine_dead_letterstable.The reporter (a DBA) observed these as the top-2 SQL statements by Average Active Sessions in AWS Performance Insights during an ~8M-event Marten projection rebuild — a 9+ GB dead-letter table scanned in full (
Buffers: shared hit=519487 read=627396, ~1.9 s, I/O-wait dominated) on every replay cycle.Fix
DeadLettersTablenow provisions the indexes automatically as part of normal schema creation/migration — no configuration, no opt-in. Thereplayableindex is always added (that column always exists); theexpiresindex is added only whenDeadLetterQueueExpirationEnabledis on (that's when the column exists).WHERE replayable, partialWHERE expires is not nullWHERE ([replayable]=1), filteredWHERE ([expires] IS NOT NULL)WHERE replayable = 1, partialWHERE expires is not nullreplayable/expires(no filtered-index support)replayable/expires(WHERE-clause partial indexes are 23c+ only)Partial/filtered indexes stay tiny because replayable rows are deleted from the DLQ immediately after being moved back to the inbox.
Predicate round-trip (why the exact predicate strings matter)
A filtered-index predicate that doesn't round-trip through the DB's system catalogs makes Weasel see a perpetual "index changed" delta and drop + recreate the index on every startup. SQL Server stores filter definitions in a canonical bracketed form (
([replayable]=(1))), so the configured predicate uses[replayable]=1to match after canonicalization; Postgres uses the boolean /is not nullforms. New idempotency tests create the schema and assertFindDeltaAsync().Difference == SchemaPatchDifference.Nonefor Postgres, SQL Server, and SQLite (the three backends that emit predicates). The SQL Server test caught exactly this drift before the predicate was corrected.Gating
Automatic, no flag. The partial index is negligible in size/write cost, both hot queries filter on the column, and the deployments that need it most are precisely the large-DLQ ones that would never think to flip an opt-in. Documented a caveat for operators upgrading with an already-huge DLQ table: the migration's
CREATE INDEXtakes a write-blocking lock, so they may want to pre-create the index withCREATE INDEX CONCURRENTLY/WITH (ONLINE = ON).Verification
wolverine.slnxbuilds clean in Release (0 warnings, 0 errors).🤖 Generated with Claude Code