Skip to content

Refuse deadlock reports from a non-UTC PostgreSQL log (#2993) - #3001

Merged
erikdarlingdata merged 5 commits into
devfrom
fix/2993-deadlock-log-timezone
Sep 5, 2026
Merged

erikdarlingdata merged 5 commits into
devfrom
fix/2993-deadlock-log-timezone

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Sep 5, 2026 •

Copy link
Copy Markdown
Owner

Part of #2993.

PgDeadlockLogParser captured the log prefix's timezone in a named group and read it nowhere, then put the naive timestamp through DateTime.TryParse with AdjustToUniversal | AssumeUniversal. On a target whose log_timezone is not UTC, every occurred_at was shifted by the zone offset with nothing erroring: trend buckets in the wrong hour, deadlock detail misaligned against every other UTC-keyed collection, plausible rows at the wrong moment. Measured against the parser with the check removed, an EST-stamped 22:25:24 line comes back as 22:25:24 Kind=Utc — five hours early — and IST and +07 are wrong in the other direction by the same mechanism.

What this does

Option 3 from the issue, not option 2, and not from a log_timezone read. The abbreviation is unusable for conversion — CST is three different zones, which is the good reason the capture was never wired up — but it answers "is this offset zero" exactly, and that is the only question occurred_at needs answered. PostgreSQL renders %m in log_timezone and prints that zone's abbreviation beside the stamp, so the captured token is the GUC as the server rendered it for the line being parsed. That makes it a better witness than current_setting('log_timezone') on two counts: it describes the line rather than the collector's connection at some later moment, and it needs no round trip, so it works identically on the RDS log-API transport, which receives log text and runs no SQL. On Europe/London the two even disagree for half the year — winter lines are stamped GMT and really are UTC, and a GUC read would refuse them.

A non-zero-offset prefix throws PgLogTimezoneUnsupportedException, which DarlingWorker records as PERMISSIONS with a message naming log_timezone, quoting the observed zone, saying the remedy, and saying outright that this is not "no deadlocks were detected". PERMISSIONS rather than ERROR for the reason the FeatureDisabled arm beside it gives: none of the store's five statuses means "this target is configured in a way this source cannot be read under", the cause is a setting an operator can change (a parameter-group change on a managed fleet, effective on reload), and CollectorRuntimePrecondition's PERMISSIONS arm already frames the condition as satisfiable and re-derives it every read. Both transports land on the one arm — the pg_read_file route throws out of ReadAsync, the RDS route out of the ingestor's Extract.

Refusing rather than skipping is the point. This is the only intolerant thing in a deliberately tolerant parser: everything else a block can be wrong about is read whole on the next overlapping pass, while this neither clears itself nor announces itself. Returning null would have stored no deadlocks and read as a server that has none — the same silent-wrong one layer up.

Why the allowlist is not just UTC

GMT and the numeric zero forms are in it deliberately, and the issue's own independent re-verification is what makes that concrete: across 16 sampled targets log_timezone = UTC comes from source = configuration file with default_value = GMT and is_default = false. So PostgreSQL's own compiled default here is GMT — a target that lost the configuration-file setting would render GMT, which is zero-offset and genuinely UTC. An allowlist of UTC alone would have refused it and taken deadlock capture away from a server that was never wrong. UT is deliberately absent for the mirror-image reason: it is not a zone PostgreSQL renders, and an over-long allowlist is the same silent-wrong defect as no check at all, arriving one entry at a time.

The dead capture is now load-bearing

The zone group is read, and reading it is what the refusal is built on, so it can no longer be a thing someone "just uses" for a conversion.

The zone token widens, in both patterns

\w+ to [^ \n]+, in the C# pattern and in the collector's server-side regexp_matches. A zone with no abbreviation renders as a numeric offset (+07, -03:30), and \w+ matches neither the sign nor the colon — so the whole block matched nothing and the server reported no deadlocks at all. Same category as the shifted stamp, silent in the other direction, and the one shape that would have escaped the new check by never reaching it. The collector's query now returns the zone as its own column (log_zone_text), which is what gives the SQL route anything to check.

Both are matched with a bracket-expression form the query already relies on in production ([^\n]*), rather than a new escape construct, since neither the .NET nor the PostgreSQL pattern can be run against a live server from here.

Verification

Every new test was proved red first, one mutation at a time, against the real committed source:

mutation assertion that goes red
refusal removed from FromBlock RefusesAReportWhoseLogPrefixIsNotUtc_NamingTheSetting — Assert.Throws() Failure: No exception was thrown (5 tests)
zone class narrowed back to \w+ RefusesANumericNonZeroOffsetPrefix_RatherThanMatchingNothing(zone: "+07") — no exception thrown; the probe prints +07 -> 0 deadlocks (7 tests)
reader ordinals left unshifted TheCollectorHandsTheParserItsFourColumnsInOrder — Assert.Single() Failure: The collection was empty (1 test)
arm status changed to ERROR Worker_Records_A_NonUtc_Log_As_Permissions_Not_Error (1 test)
collector query reverted The_Collector_Query_Returns_The_Prefix_Zone (1 test)

The non-UTC fixture is the captured report with only its prefix zone substituted. It is what makes any of this meaningful: the whole fleet is uniformly UTC, so a fixture carrying the captured zone passes identically whether the check exists or not.

TheCollectorHandsTheParserItsFourColumnsInOrder drives the real ReadAsync through a four-column fake reader, because the zone arrived as a new column in the middle of the list and a mis-ordinal would hand the parser the zone where it expects the victim pid — which parses as nothing, returns null, and drops every deadlock silently. An Assert.Contains on the query text cannot see that.

Darling.Tests and Lite.Tests are net10.0-windows and cannot run on macOS, so the real test files were compiled into throwaway net10.0 xunit v3 hosts staged outside the repo: Lite.Tests 38 tests / 0 failed / 0 not run, Darling.Tests 2 / 0 / 0.

The straddled window, raised in review

The throw is per block and unwinds Extract's loop, so a window holding a non-UTC block and a readable UTC one abandons both. That is the trade rather than an oversight, and it is now documented at the throw and pinned by AStraddledWindowRefusesTheWholeRead_NotJustTheOffendingBlock, whose fixture puts the readable block FIRST so a catch-and-continue would return one deadlock and no exception (proved: it goes red on exactly that mutation).

Storing the UTC half would leave a partial history from a target just declared unreadable, with nothing in the data marking what is missing — a reader could not then tell a quiet server from a half-collected one, which is the failure this whole change exists to stop. One log has one log_timezone at any instant, so the mixed window only arises while a change to that setting straddles the read.

What that costs is not the same on the two transports, and an earlier revision of this description and of the source claimed otherwise. Review caught it and the claim is corrected in eee73527a. PgDeadlocksCollector re-reads an overlapping byte-window tail every cycle, so a straddled window there loses nothing permanently. The RDS log-API route is consume-once: RdsLogSource.ReadNewestAsync sets _markers[key] = response.Marker as a side effect of the fetch, before the parser is reached, so text the refusal abandons is not re-fetched for the life of that marker and the readable siblings in it are lost. That loss is real and is not fixed here — see Not covered.

Not covered

The server-side regexp_matches change is unexercised here — no PostgreSQL target is reachable from this environment, and Darling PostgreSQL tests runs against a store rather than a monitored target. Its bracket-expression form is the one the query already uses, and the C# pattern it mirrors is tested directly.

pg_deadlocks log reads are denied fleet-wide on the managed path (filed separately), so both halves of this are unexercised in production today.

The consume-once loss on the RDS transport is not fixed here, and it needs a decision that touches another lane. The marker advance lives in RdsLogSource.ReadNewestAsync, which is #3002's active file, so the symmetric fix — do not persist the marker until the chunk's rows are written — is not mine to make unilaterally. The alternative is asymmetric and entirely in these files: give the consume-once route a non-throwing entry point that stores what parsed and reports the refusal, justified by the two routes' different read semantics rather than by preference. Both are real; the choice is a coordination call, and the mechanism is verified either way. It is latent today because the grant is denied fleet-wide, and it becomes live the moment that is restored.

This PR does not attempt an ARE-level validation of the server-side pattern against a live PostgreSQL from a development machine: there is no local PostgreSQL here, and the one read-only probe available (the monitoring store over SSM) was not reachable in this environment. The bracket-expression form is the one the shipped query already relies on in production, and the .NET pattern it mirrors is tested directly.

PgPlanLogParser, the sibling that shares the RDS transport, parses no timestamp out of the log prefix and stores none, so it does not carry this defect.

PgDeadlockLogParser captured the log prefix's zone and read it nowhere, then
put the naive timestamp through DateTime.TryParse with AssumeUniversal. A
target whose log_timezone is not UTC got every occurred_at shifted by the zone
offset with nothing erroring.

The captured zone is now read. It cannot be converted from — CST is three
different zones — but it answers whether the offset is zero exactly, and it is
log_timezone as the server rendered it for the line being parsed, so it needs
no GUC read and works on the managed transport, which has no connection to ask.
A non-zero-offset prefix throws PgLogTimezoneUnsupportedException, which the
worker records as PERMISSIONS with a message naming the setting.

The zone token widens from \w+ to [^ \n]+ in both the C# pattern and the
collector's server-side one: a zone with no abbreviation renders as a numeric
offset, which \w+ could not match at all, so the whole block matched nothing
and the server reported no deadlocks.
Comment thread PerformanceMonitor.Collectors/PgDeadlockLogParser.cs
@claude

claude Bot commented Sep 5, 2026

Copy link
Copy Markdown

Reviewed the diff (parser, exception type, collector query, worker classification, and both new test files).

Wiring checks out end to end. The zone column threads consistently: Postgres regexp_matches returns it as m[2]/log_zone_text → ReadAsync reads ordinal 1 → FromBlock(timestampText, zoneText, victimPidText, detail) → Extract's match.Groups["zone"]. Both call sites (PgDeadlocksCollector.ReadAsync and RdsDeadlockIngestor via Extract) were updated together, and no other caller of the old 3-arg FromBlock signature was left behind.

Regex mirroring is correct. \w+ → [^ \n]+ was made identically in the .NET pattern and the Postgres-side regexp_matches literal, and the added capture group's position (m[2]) lines up with the [^ \n]+ for zone landing between timestamp and pid in both patterns. IsZeroOffsetLogZone's allowlist (UTC/GMT/UCT) plus the ^[+-]0+(?::0+)*$ shape check correctly separates zero offsets (+00, -0000, +00:00) from non-zero ones sharing a zero prefix (+0030, +0530), which the test matrix exercises well.

No Lite/Darling parity gap. PgDeadlocksCollector/PgDeadlockLogParser are only ever exercised under Darling today — Lite has no PostgreSQL target support (CollectorCatalog/RemoteCollectorService never dispatch pg_* collectors), so the new DarlingWorker.cs catch arm has no Lite counterpart to keep in sync. The shared-library tests living in Lite.Tests are just exercising shared code from that test project, not asserting Lite runtime behavior.

No SQL injection / secrets concerns. The new Postgres query text is a static literal; ex.Message and ex.ObservedZone only ever flow into DarlingObservability.LogCollectionAsync, which binds them as NpgsqlParameter values, never string-concatenated into SQL.

One edge case flagged inline on PgDeadlockLogParser.cs: throwing from inside the Extract loop (and the analogous row loop in PgDeadlocksCollector.ReadAsync) discards any already-parsed, legitimately-UTC deadlocks earlier in the same read batch if one block in the same tail carries a bad zone (e.g. right after a log_timezone change). The overlapping-window re-read likely recovers it next cycle, so this is probably an accepted trade-off rather than an oversight — flagged for confirmation rather than as a blocking issue.

Style-wise this matches CONTRIBUTING.md conventions (WHY-focused comments, schema/ordinal discipline); no T-SQL touched by this PR.

CollectionLogDrainForensicsStoreTests.ThePeerMarkIsCapturedAtDispatchNotAtCompletion
counts the RunOneAsync arms that pass drain and fetchPhases as null, and the
new log_timezone arm is an eighth. It qualifies for both on the reasons the
pin's own comment gives: nothing was drained and no item completed. The
arithmetic is the change; the arm is not.

The refusal is per block, so it abandons the whole read when a window straddles
a log_timezone change and holds a readable UTC block beside a non-UTC one. That
is the trade rather than an oversight, and it is now documented and pinned:
storing the UTC half would leave a partial history from a target just declared
unreadable with nothing in the data marking what is missing, and a reader could
not tell a quiet server from a half-collected one. Both transports read the
newest log file only, so a rotation clears the straddle, and every cycle until
then records the refusal with the setting named.
@claude

claude Bot commented Sep 5, 2026

Copy link
Copy Markdown

Reviewed the diff (C#/regex changes across PerformanceMonitor.Collectors, Darling, and the corresponding test files — no T-SQL touched, so the T-SQL style section doesn't apply here).

Correctness

  • IsZeroOffsetLogZone and s_zeroOffset (^[+-]0+(?::0+)*$) checked against all the numeric spellings PostgreSQL actually renders (+00, -00, +00:00, -0000) and correctly reject non-zero-but-similar forms like +0030 (anchored, so it can't match a zero-looking prefix of a non-zero offset). The UTC/GMT/UCT allowlist plus the numeric-shape check looks right, and deliberately excluding UT and ambiguous abbreviations (CST, BST, etc.) matches the stated rationale.
  • Widening the zone capture from \w+ to [^ \n]+ in both the C# regex (PgDeadlockLogParser.cs) and the server-side regexp_matches pattern (PgDeadlocksCollector.cs) is correctly paired — missing either half would leave one transport still silently dropping numeric-offset-zoned blocks.
  • PgDeadlocksCollector.ReadAsync's reader-ordinal shift (stamp/zone/pid/detail → ordinals 0-3) matches the new SELECT column order, and is the one thing an Assert.Contains on query text couldn't catch — good that TheCollectorHandsTheParserItsFourColumnsInOrder exercises it through the real FakeCollectorDataReader rather than just checking SQL text.
  • The whole-read-abandons-on-first-non-UTC-block behavior (FromBlock throws per block, unwinding Extract's loop) is a deliberate, well-documented trade-off and is pinned by AStraddledWindowRefusesTheWholeRead_NotJustTheOffendingBlock with the readable block placed first so a catch-and-continue regression would be caught.
  • DarlingWorker's new catch (PgLogTimezoneUnsupportedException ex) arm is correctly ordered ahead of the general Exception handler and classifies to PERMISSIONS (not ERROR), consistent with the sibling RdsLogUnavailableException/PiMetricsUnavailableException arms. RdsDeadlockIngestor.IngestAsync correctly keeps PgDeadlockLogParser.Extract(...) outside its tolerant catch (Exception ex) when (ex is not OperationCanceledException) block so the new exception isn't accidentally reclassified as RdsLogUnavailableException.
  • WritePayload still strips DateTimeKind via DateTime.SpecifyKind(..., Unspecified) before binding, matching the naive-UTC store contract in CONTRIBUTING.md.

Lite/Darling parity

  • Checked whether Lite needed an equivalent catch for PgLogTimezoneUnsupportedException alongside DarlingWorker.cs. It doesn't: Lite has no PostgreSQL target seam at all (CollectorTargetEngine is never set to PostgreSql anywhere under Lite/, its schema generator filters CollectorCatalog down to SqlServer-only, and its dispatch switch has no pg_* case). PgDeadlocksCollector/PgDeadlockLogParser are shared-library code exercised only via unit tests under Lite.Tests, never invoked by the Lite app at runtime. So the Darling-only catch handler is correctly scoped — no parity gap here.

Security / other

  • No new external input parsing beyond what's server-log-derived and already trusted at that boundary; no injection surface changed (query text is a compile-time literal, not built from user input).

No blocking issues found. Nice test discipline — the red-first mutation table in the PR description is a good practice and the fixtures (e.g., placing the readable block first in the straddle test) actually validate the property claimed rather than just exercising the code path.

The prose beside both counted assertions said seven while the assertions said
eight. A count restated in a comment is what drifted here in the first place, so
the comments describe the arms and let the assertion hold the number.
@claude

claude Bot commented Sep 5, 2026

Copy link
Copy Markdown

Reviewed the diff (parser, collector query, exception type, worker classification, RDS ingestor, tests). This is an unusually well-documented and well-tested change — the red-then-green mutation testing in the description is a good pattern.

Lite/Darling parity: No gap here. Lite is SQL-Server-only (no Postgres target support at all — Npgsql isn't even referenced from Lite/), so PgDeadlocksCollector/PgLogTimezoneUnsupportedException never runs there. The DarlingWorker.cs catch arm and CONTRIBUTING.md's Two-Store Parity rules don't apply to this change.

One correctness issue found, posted inline on RdsDeadlockIngestor.cs:93: the new whole-block throw combined with RdsLogSource's unconditional resume-marker advance means a non-UTC (or straddled) chunk on the RDS/Aurora transport is lost permanently, not "retried until the log rotates" as PgDeadlockLogParser's remarks claim — that reasoning only holds for the pg_read_file route's overlapping tail read. Currently masked because RDS pg_deadlocks log reads are denied fleet-wide per the PR description, but worth addressing or at least documenting before that access is restored.

Everything else checked out: exception-arm ordering/placement in DarlingWorker.cs is correct and disjoint from neighboring catches, the collector query's regex groups line up with the C# FromBlock ordinals, the zero-offset allowlist and regex match their test matrix, and the straddled-window trade-off is deliberately made and pinned for the pg_read_file path.

The remarks said both transports read the newest file only, so a rotation
clears a straddled window. That is true of the pg_read_file route, which
re-reads an overlapping byte-window tail every cycle, and false of the RDS
log-API route: RdsLogSource.ReadNewestAsync advances and persists its resume
marker as a side effect of the fetch, before the parser is reached, so text
abandoned by the refusal is not re-fetched for the life of that marker.

The claim is replaced by the asymmetry, named per transport, with the reason it
is a property of the read rather than of the refusal. The loss on the
consume-once route is real and is not addressed here.
@claude

claude Bot commented Sep 5, 2026

Copy link
Copy Markdown

Reviewed the diff (parser, collector, exception type, DarlingWorker/RdsDeadlockIngestor wiring, and both test suites) against CONTRIBUTING.md and the Lite/Darling parity requirement. No correctness, security, or parity issues found. Notes from the pass:

Exception propagation verified end-to-end. Traced both transports to confirm PgLogTimezoneUnsupportedException actually reaches the new catch (PgLogTimezoneUnsupportedException ex) arm in DarlingWorker.cs rather than being swallowed or mis-classified upstream:

  • pg_read_file route: PgDeadlocksCollector.ReadAsync throws inside DarlingCollectorRunner.RunAsync's plain (non-enumerated, non-per-database) read block (DarlingCollectorRunner.cs:2052). The only surrounding catch there is catch (Exception ex) when (EnumeratedCollectorDriver.ItemBudgetExpired(...)), which won't match this exception, so it propagates cleanly out to RunOneAsync.
  • RDS log-API route: RdsDeadlockIngestor.IngestAsync calls PgDeadlockLogParser.Extract outside its own tolerant try/catch (that catch only wraps the AWS fetch), and neither it nor DarlingCollectorRunner.IngestRdsDeadlocksAsync adds a wrapping catch, so the exception also reaches RunOneAsync unmodified.

Both dispatch through the same ["pg_deadlocks"] lambda inside RunOneAsync's try block (DarlingWorker.cs:6397-6400), so the new catch arm (placed correctly, before the general catch (Exception ex)) is reachable from both code paths, matching the PR description's claim.

Lite/Darling parity: not a gap here. PgDeadlocksCollector is registered in the shared CollectorCatalog, but Lite's RemoteCollectorService.RunCollectorAsync dispatch switch has no "pg_deadlocks" case, and DuckDbSchemaGenerator/McpEngineCapability both confirm Lite never sets CollectorTargetEngine.PostgreSql — Lite doesn't monitor Postgres targets at all today. So the new DarlingWorker classification arm has no Lite counterpart to be missing; parity is preserved by the shared parser/collector living in PerformanceMonitor.Collectors and being pinned by Lite.Tests.

Regex/allowlist logic checked by hand against the test matrix (s_zeroOffset anchoring, [^ \n]+ zone capture in both the C# pattern and the mirrored PostgreSQL regexp_matches pattern, ordinal shifts in PgDeadlocksCollector.ReadAsync for the new log_zone_text column) — all consistent, no off-by-one or matching-order bugs found. The straddled-window behavior (whole-read abandonment, including already-parsed UTC siblings) is implemented exactly as documented and matches its own test.

One pre-existing/disclosed item worth double-checking before merge rather than a new finding: the PR description already flags that the RDS log-API route's marker advance (RdsLogSource.ReadNewestAsync) happens before the parser is reached, so a straddled window on that consume-once transport permanently drops the readable siblings, and that the fix is deferred pending coordination with #3002. Confirmed that is still true in this diff and not silently reintroduced — just flagging that it should stay tracked to closure.

No missing-index DMV suggestions made per project convention. No T-SQL style issues (the touched query is PostgreSQL, and OPTION(RECOMPILE)/T-SQL alias conventions don't apply there; the file's existing AS-based Postgres aliasing style is preserved).

@erikdarlingdata
erikdarlingdata merged commit 3f47ce4 into dev Sep 5, 2026
11 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/2993-deadlock-log-timezone branch September 5, 2026 12:48
erikdarlingdata added a commit that referenced this pull request Sep 23, 2026
DARLING01's PG18 target (log_timezone America/Los_Angeles) records this
refusal on pg_log_events, and the message said "every timestamp in its
deadlock reports" and "NOT 'no deadlocks were detected'". The one throw
site is PgLogEntryAssembler, which both PgLogEventClassifier
(pg_log_events) and PgDeadlockLogParser (pg_deadlocks) read through, so
the text written for deadlocks in #3001 now shows on log-event rows too.

The message now names the log and says it is NOT an empty log. It keeps
the zone token, log_timezone, the CST example, the parameter-group note
and the reload-without-restart note, and drops the dashes and the
semicolon. Lite's pin moves to "this is NOT an empty log" and fails if
"deadlock" comes back. The exception's doc comment and the worker's catch
arm comment name both readers.


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