Skip to content

PostgreSQL deadlock reports store and show their SQL normalized, and no read returns a hash of the raw report (#4005) - #4013

Merged
erikdarlingdata merged 7 commits into
devfrom
fix/4005-pg-deadlocks-redaction
Sep 23, 2026
Merged

erikdarlingdata merged 7 commits into
devfrom
fix/4005-pg-deadlocks-redaction

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Closes #4005.

Why

collect.pg_deadlocks stored each deadlock report's DETAIL block verbatim (tabs stripped) and the victim's statement verbatim. Every read returned them as stored: get_pg_deadlocks (victim statement), get_pg_deadlock_detail (graph), the web dispatch on both, the desktop viewer's grid, the deadlock alert (email, webhook {{incidents_json}}, Teams and Slack cards, the persisted alert context), and the analysis drill-down (pg_deadlock_exemplars), whose finding persists the statement, its fingerprint in the advice prose, and the graph for 30 days. That is the exposure #3944 ruled on, through a different table.

deadlock_hash was SHA-256 over the raw graph. Readers got it in get_pg_deadlocks, get_pg_deadlock_detail, the drill-down, and the alert's dedup key (rendered as "Dedup Key" on cards and persisted in the context JSON). get_pg_deadlock_detail also looked rows up by it. Once the graph is shown normalized, that hash is the #4004 guessing oracle: rebuild the graph, try values for each ?, and compare, offline or by lookup.

The block pattern also found ERROR: deadlock detected and DETAIL: anywhere on their lines, so a logged statement carrying those words was stored as a deadlock. Measured: the old pattern matches a LOG: statement: SELECT 'x ERROR: deadlock detected line followed by a forged DETAIL, as a report from pid 1600.

What changes

Write: one reader, one lexer. PgDeadlockLogParser no longer parses a report with its own pattern.

Read: every path normalizes, idempotently, and no raw hash leaves.

  • DarlingPgDeadlockReader (MCP, web dispatch, WPF viewer, deadlock alert) normalizes the victim statement and graph of every row. A row this build wrote comes back unchanged.
  • A row stored before pg_deadlocks keeps each deadlock query and the victim statement verbatim, literals included, and returns them to readers and alerts #4005 is recognised in SQL (PgDeadlockLogParser.RawGraphHashSql: its hash equals SHA-256 of its graph). Its hash is never returned; it is named at-<occurred_at>-<victim_pid> instead, from values every read already shows.
  • get_pg_deadlock_detail finds a report by either kind of identity. A hash lookup never matches a row whose hash is over its raw graph, so a guessed raw hash finds nothing.
  • The drill-down (PgTargetDrillDownCollector.Deadlocks) now reads as much text as normalizing needs, normalizes it, then cuts to its 2000/4000-character caps in memory. Cutting first could land inside a literal, and the lexer would then withhold the whole statement. The exemplar's identity follows the same rule as the reader.
  • Stored findings: PgFindingStore.ReadFinding normalizes a pg_deadlock_exemplars section written before this change. It replaces the fingerprint in the stored advice prose and names each exemplar's report by time and pid. Every stored-finding read (get_analysis_findings, the viewers) gets that. A section this build writes carries sql_normalized: true and is left as written, because a statement already normalized and cut to its cap can end inside a '?'.

Found while here, fixed in lane. Without an identity, get_pg_deadlock_detail returned the reports whose hashes sort first, not the newest. Its LIMIT sat on the DISTINCT ON sort, which leads with the hash. It now sorts newest first in an outer query.

Wording. Tool descriptions, the detail note, the web panel note, the README collector row and the runbook now say the SQL is normalized.

No store migration. No parity item: Lite and the deprecated Dashboard do not monitor PostgreSQL.

Test plan

  • Lite.Tests PgDeadlockLogParserTests (54): three new regression tests.
    • Literals in both queries, with the exact normalized graph and victim statement, idempotence, and the identity over the normalized graph.
    • A query cut mid-literal is withheld. With the HINT the next query is read from its own head; without it, it is withheld too.
    • A statement literal holding a report is not read as one, and a real report right after it still is.
    • Existing tests updated deliberately: victim attribution is pinned by pid now that both statements normalize alike; the collector seam now carries one column.
  • Darling.Tests PgDeadlockNormalizationTests (6, live PG):
    • A pre-fix row reads normalized under at-..., and its raw hash is neither returned nor finds it.
    • A new row reads back unchanged under its own hash.
    • The detail read without an identity returns the newest reports.
    • Notification body: the incident built from the read has a normalized statement and no raw hash, checked in both the context JSON and {{incidents_json}}.
    • Every read's inline raw-hash SQL matches the parser's.
  • PgTargetDeadlockDrillDownTests (9, live):
    • The e2e plants a pre-fix exemplar row with a literal and a raw hash; the drill-down carries neither.
    • A pre-fix stored finding read back through get_analysis_findings is normalized.
    • A unit test pins the stored-finding normalization and its idempotence.
  • RdsDeadlockIngestorTests, PgDeadlockLogTimezoneTests: identity and zone pins updated deliberately (the zone is now read out of the returned report text).
  • Proved against the old behaviour by mutation (each reverted after):
    • Normalization off: 9 tests fail (4 Darling, 5 Lite).
    • Raw hash returned and looked up: 3 fail.
    • The stored-finding hook removed: the e2e fails.
  • The pg_read_file pattern on PostgreSQL 18.6, over a 4.78 MB synthetic tail with 8 reports: old 20-26 ms, new 20 ms, same 8 matches. The HINT line is taken, and a report with no HINT does not take the next report's first line.
  • Full Darling.Tests and Lite.Tests: see below.
  • 0 warnings on both builds.

Full runs, on the rig (PostgreSQL 18.6 + TimescaleDB 2.30.1), DARLING_TEST_PG set:

  • Darling.Tests: 13011 total, 3 failed, 31 skipped. All three are accounted for:
    • PgLogEventsPipelineTests.TheTailerExtraction_LeftBothSiblingsSqlByteIdentical: the deadlock sibling's SQL pin. Updated deliberately, with the reason in the test.
    • LivePostgresCollectionHygieneTests: the new class had not joined the live-postgres collection yet. Fixed.
    • TrendPayloadBudgetLiveTests: get_file_io_trend hit a transient Exception while reading from stream under full-suite load. This class is untouched here; it passes alone (1/1).
  • After those fixes, every touched or affected class re-run green: PgLogEventsPipelineTests 38, LivePostgresCollectionHygieneTests 11, PgTargetDeadlockDrillDownTests 9, PgDeadlockNormalizationTests 6, RdsDeadlockIngestorTests 11, PgDeadlockLogTimezoneTests 2, DocCommentHygiene 77, McpPayloadContractCensusTests 68, DarlingPgOperationalAlertTests 30.
  • Lite.Tests: 5171 total, 1 failed: PgLoggingCollectorGateTests.AnOrdinaryDeadlockRowStillParses, the collector seam's old four-column shape, since updated. The affected classes re-ran green (73).

For the reviewer

🤖 Generated with Claude Code

https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv

erikdarlingdata and others added 5 commits September 23, 2026 05:22
…eturns a hash of the raw report (#4005)

The deadlock parser reads each candidate report through PgLogEntryAssembler, so the
label is the line's own and the HINT proves the DETAIL whole, and puts every query
through PgLogTextRedactor.RedactDetail before anything is read out of it. The
identity is over the timestamp and the normalized graph. Every read normalizes a
row stored before this, and names it by its timestamp and victim pid instead of
its raw hash, which it never looks a row up by. The detail read without an
identity now returns the newest reports.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv
…and a report is named by its time and pid (#4005)

Findings persist their drill-down and prose for 30 days, so one written before
the deadlock SQL was normalized kept its exemplar's statement, fingerprint and
graph raw, and a hash that may be over the raw graph. PgFindingStore's read puts
them through the same normalization and names the report by its timestamp and
victim pid, which the detail read now finds whichever build stored the row.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv
… follow the one-column candidate read (#4005)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv
…ng this build wrote as written (#4005)

A cut first can land inside a literal, and the lexer then withholds the whole
statement. The read now takes what normalizing needs and the caps apply after.
A section this build writes carries sql_normalized, so the stored-finding read
rewrites only sections written before the SQL was normalized.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv
erikdarlingdata and others added 2 commits September 23, 2026 06:52
…up (#4005)

LiveCleanupConversionRatchetTests failed CI: the new class's teardown
deleted its rows on the body's own connection, which a failed body can
leave unusable (#1902). It now runs through LiveStoreCleanup.RunAsync with
bodySucceeded set as the body's last statement, like every other live
class.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv
@erikdarlingdata
erikdarlingdata merged commit 93787e8 into dev Sep 23, 2026
15 of 16 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/4005-pg-deadlocks-redaction branch September 23, 2026 11:01
erikdarlingdata added a commit that referenced this pull request Sep 23, 2026
…ntries in their sections (#4080)

Adds 42 entries and 42 link refs (#3992, #3995, #3996, #3998, #4001, #4002, #4003, #4007, #4010, #4011, #4013, #4015, #4020, #4022, #4025, #4029, #4030, #4031, #4036, #4038, #4039, #4040, #4044, #4047, #4048, #4049, #4050, #4051, #4055, #4061, #4063, #4064, #4065, #4066, #4067, #4068, #4069, #4070, #4071, #4073, #4074, #4078). Each PR's entry was buffered, and this lands every entry whose PR was merged on origin/dev when it ran.

#3989 left 26 entries under bare 'Changed' and 'Fixed' lines above '### Added'. They move into '### Changed' and '### Fixed', below the new entries, and one blank line stays under [Unreleased].


Claude-Session: https://claude.ai/code/session_01Ua31ugERL5DmhFVRtf6keQ

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant