Skip to content

PgDeadlockLogParser captures the log's timezone abbreviation and never reads it — occurred_at assumes UTC no matter what log_timezone says #2993

Closed

Description

@erikdarlingdata

Found while auditing the pgmon fleet's timestamp handling. Latent today, undefended forever: the three targets sampled directly (target-a, target-b, target-c — 3 of 50) all run log_timezone = UTC via a shared configuration-file setting, so nothing is currently wrong on this fleet. Nothing in the code keeps it that way.

Mechanism

PerformanceMonitor.Collectors/PgDeadlockLogParser.cs:61 — the log-prefix regex captures the zone:

@"^(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\.\d+) (?<zone>\w+) \[(?<pid>\d+)\][^\n]*ERROR:  deadlock detected\s*\n"

The zone group is never read anywhere in the file. The timestamp goes through DateTime.TryParse(..., DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, ...) (lines 122–125), which treats the naive log timestamp as UTC unconditionally. A repo-wide search for log_timezone returns zero hits — the GUC that decides what those timestamps mean is never consulted, collected, or checked.

What happens when it bites

A target whose log_timezone is not UTC gets every parsed deadlock's occurred_at shifted by the zone offset, silently: trend buckets land in the wrong hour, deadlock detail misaligns against every other UTC-keyed collection, and nothing errors. The failure direction is the bad one — plausible-looking data, wrong moment.

Options

  1. Convert using the captured abbreviation — worst option; zone abbreviations are ambiguous (CST is three different zones), which is presumably why the capture was never wired up.
  2. Read log_timezone at collection time (one GUC; the server-config collection already reads settings) and convert properly — correct, small.
  3. Cheapest honest defense: same GUC read, but refuse to parse under a non-UTC log_timezone and record the refusal as a note naming the setting — turns silent-wrong into visible-unsupported.

2, with 3's note as the interim, is the shape the honest-empties convention wants. No measurement decides anything here — it is a defense, not a tuning question.

Related: the same collector's log reads are currently denied fleet-wide on the managed path (filed separately), so this parser is doubly unexercised in production right now.

Activity

  1. erikdarlingdata commented on Sep 5, 2026

    @erikdarlingdata
    OwnerAuthor

    Independent re-verification of this issue's claims (separate session, same investigation).

    Code defect confirmed as described. Read PerformanceMonitor.Collectors/PgDeadlockLogParser.cs directly:

    • Line 61's regex captures (?<zone>\w+) from the log-prefix timestamp.
    • That named group is never referenced again anywhere in the file (git grep -n "zone" PerformanceMonitor.Collectors/PgDeadlockLogParser.cs returns only the regex definition line).
    • Lines 122–129: DateTime.TryParse(timestampText, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var occurredAt) unconditionally treats the naive timestamp as UTC.
    • The companion SQL-side extraction in PgDeadlocksCollector.cs:96 doesn't even capture the zone into its regexp_matches array (\w+ matched, not grouped) — so the zone is discarded before it reaches C# on that transport too.
    • Lite.Tests/PgDeadlockLogParserTests.cs only ever exercises a UTC literal in its fixtures — no regression coverage exists for a non-UTC zone.
    • Confirmed the GUC distinction: this parser reads log-file timestamps, so log_timezone (not the session timezone GUC) is the relevant setting.

    Correction to the "zero hits" claim. git grep -w "log_timezone" (positive-controlled against git grep -w "timezone", which returns 72 hits, so the grep tooling isn't silently failing) returns 2 hits, not zero — both in Darling/Darling.Tests/DarlingManagedPostgresTests.cs, asserting that the internal managed store's own postgresql.conf deliberately does not set log_timezone (only the session timezone GUC matters there, per that test's own comment). That's a different concern — the store's own config, not a monitored target's log format — so it doesn't defend against this issue at all. But "zero hits anywhere in the codebase" as literally stated isn't accurate; "zero hits that defend against a target's log_timezone" is the accurate framing.

    Independent fleet sample — 16 targets, not just the original 3. Queried log_timezone directly via get_pg_server_config (not trusting the original 3-target sample) across one representative from each display-name family on the fleet, for 16 of the 50 monitored targets:

    • 16/16 report log_timezone = UTC.
    • On every one: default_value = GMT (compiled default, so this is not an unconfigured default), source = configuration file (not ALTER SYSTEM, not per-role/per-database), is_default = false.
    • The setting, its source, and its non-default status are byte-identical across four unrelated naming families on the fleet. That pattern — identical non-default value from identical source across otherwise-unrelated targets — is what a shared parameter-group/config-file setting looks like, not independent per-instance configuration. I can't directly inspect the parameter group from here, but the evidence is consistent with the "structurally UTC fleet-wide today" framing rather than "we happened to sample UTC every time."

    Net: this issue's mechanism and risk framing hold up under independent re-check. The fleet is not currently exposed, but that's a fact about today's shared config, not something the code enforces — the defect described here is real and worth the fix as scoped.

  2. erikdarlingdata commented on Sep 5, 2026

    @erikdarlingdata
    OwnerAuthor

    Closed by #3001 (3f47ce40). Closing by hand — this repository merges to dev, so a closing keyword never fires.

    The option chosen was to assert the assumption, but reading the zone from the log line itself rather than from a log_timezone GUC read — and that is better than the GUC for two reasons worth recording. 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, not 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 has no target connection at all.

    The Europe/London case settles it: winter lines are stamped GMT and genuinely are UTC, so a GUC read would refuse them. The allowlist is UTC/GMT/UCT plus ^[+-]0+(?::0+)*$, and GMT is load-bearing — this issue's own re-verification recorded default_value = GMT on 16 targets, so a target that lost its configuration-file setting renders GMT and is correctly accepted rather than refused.

    The dead zone capture this issue complained about is now read, and the refusal is built on it. A non-zero-offset prefix throws PgLogTimezoneUnsupportedException, recorded as PERMISSIONS rather than ERROR — matching the three existing classified-exception arms and the framing of a satisfiable, per-cycle-re-derived condition.

    The scope addition is the more important half

    The zone token widened from \w+ to [^ \n]+ in both the C# pattern and the server-side regexp_matches. A numeric-offset zone such as +07 matched no block at all under \w+, so the server reported no deadlocks — the same defect as this issue, silent in the opposite direction, and the one shape that would have escaped the new check by never reaching it.

    A probe against a mutated build quantified the original defect: an EST-stamped line at 22:25:24 was stored as 22:25:24.100 Kind=Utc, five hours early, with IST and +07 wrong the other way.

    A claim this PR wrote and then retracted

    The parser remarks initially said the straddled-window loss was bounded by log rotation, on the grounds that both transports read the newest file only. That is true of the pg_read_file route and false of the RDS log-API route, which is consume-once: RdsLogSource.ReadNewestAsync persists its resume marker as a side effect of the fetch, before the parser is reached, so text the refusal abandons is never re-fetched and the readable UTC deadlocks sharing that chunk are lost. Retracted in eee73527a, which states the asymmetry per transport instead of a single bound.

    That loss is real and is not fixed here. The symmetric fix — commit the marker only after the chunk's rows are written — is being worked separately, and was chosen over a non-throwing parser entry point because the asymmetric route needs an answer to DarlingWorker.RunOneAsync's row-count-or-throw contract, which has no honest representation for "stored 8 rows AND refused some".

    One thing this PR ships that is already filed against

    Its new PERMISSIONS arm hardcodes 0, 0, 0 for its timing fields. That is consistent with every existing classified arm and is not a defect against current convention — but it is a fresh instance of the pattern #3011 describes, and #3011's sweep must include this arm or the fix will miss it.

    Not verified

    The server-side pattern was never run against a live PostgreSQL. There was no local PostgreSQL or container runtime available, and a read-only probe over SSM was blocked by the sandbox classifier. The probe was built from the shipped BuildQuery(...).Text rather than a retyped copy, so it is ready to run. The bracket-expression form is what the shipped query already relies on in production, and the .NET pattern mirroring it is tested directly — but that is inference rather than measurement. No live PostgreSQL target was reached, so neither transport was exercised end to end.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions