Skip to content

PostgreSQL targets key their hour-of-week buckets on their own TimeZone setting, so a New York target's Tue 09:00 bucket is 09:00 in New York in every season — the clock read becomes the provider's second seam, UTC becomes the fallback instead of the rule (partial #3691, lane 20) - #3757

Merged
erikdarlingdata merged 1 commit into
devfrom
feat/3691-pg-target-clock
Sep 19, 2026

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Partial #3691 — lane 20. Checklist line completed, verbatim: PostgreSQL-target buckets stay UTC-keyed after Q6 (#3749) (and the root-doc line beneath it).

What a PostgreSQL operator now learns

That the "Tue 09:00" bucket a finding names on a New York PostgreSQL target is 09:00 in New York, in February and in July alike. Before this PR it was 09:00 UTC — 04:00 on the server in winter and 05:00 in summer — and, worse, one bucket pooled two different local hours across a DST change: the exact smear #3749 (Q6) fixed for SQL Server targets, still live on the other engine.

The gap this closes (#3749's own out-of-lane note)

Q6 re-keyed every hour-of-week bucket to the target's local clock through one shared expression (BaselineLocalClock.LocalCollectionTimeSql) and three bound parameters ($4..$6: the one offset transition inside the window and the offset minutes either side of it). The clock those parameters come from was read from the newest server_properties row — a table only the SQL Server collectors write. A PostgreSQL target has no such row, so the read answered (NULL, NULL), BaselineLocalClock.Resolve fell to its UTC arm, and every PgTargetBaselineProvider bucket stayed UTC hour-of-week by construction. #3749's body says so in its "does NOT do" list and reports it as the finding this PR takes.

A PostgreSQL target's clock is collected: pg_server_config is the hourly snapshot of its pg_settings, and TimeZone is one of its rows — the IANA name the server keeps its clock in (UTC, America/New_York, …), with source saying where it came from. That name is the input the resolver already prefers over a fixed offset, because a zone id knows when the offset changes, which is the whole of the DST fix.

The mechanism

One new seam, one override, nothing else redeclared. PgBaselineProvider.ReadServerClockAsync was a private static; it is now protected virtual, with the SQL Server body byte-identical (the same ServerClockSql, the same reader — LocalClockBucketKeyTests.TheClockRead_IsTheStoresIdiom… still pins the text and Lite's twin of it). Its signature gains the window end (AsNaive(analysisTime)) so an override can anchor on the window an anchored pass (#2506) asked for; the base ignores it, because the SQL Server clock is a property of the host, not of the window.

PgTargetBaselineProvider (new partial PgTargetBaselineProvider.Clock.cs) overrides it with PgTargetClockSql: the TimeZone row of the latest pg_server_config snapshot at or before the window end, session-scoped sources excluded — PgTargetFactCollector.PgTargetConfigSnapshotSql's rule, copied token for token (the test pins the shared anchor and exclusion against the collector's text), not re-derived. It returns (null, setting); Resolve turns the zone into the step function, the base binds $4..$6, caches the LocalClockWindow beside the buckets, and GetBaselineAsync looks the analysis time up through the same three numbers. The two engines therefore share how a clock becomes a bucket key and differ only in where the clock is read. UTC resolves to offset 0 — byte-for-byte the pre-PR keying — so a target that keeps UTC changes nothing, which is most of the measured population.

Why the session-scope exclusion matters more here than for the config facts. TimeZone is a setting a client can and routinely does set for its own session (libpq's PGTZ, a driver's connection-string option), so a snapshot whose TimeZone row is source IN ('client','session','override') describes the collector's connection, not the server. Such a snapshot yields no row and that compute keys on UTC — honest, not wrong: the server's clock is unknown from that snapshot, and an older snapshot's value is deliberately not taken in its place, because the rule is "the snapshot that applied", not "any snapshot that had an answer". No snapshot at all (config collector off; a store older than V102) is the same (NULL, NULL) the base gives a row-less SQL Server target, and the same UTC keying.

UTC was the RULE for this engine; it is now the FALLBACK. The root doc of PgTargetBaselineProvider says so in those words: "PostgreSQL targets resolve their clock from the TimeZone setting in the latest pg_server_config snapshot at or before the window end; UTC only when no snapshot carries a server-scoped one." The base's own doc no longer describes the row-less arm as the PostgreSQL target's fate.

Why this shape, and what was rejected

Blast radius

  • SQL Server targets: unchanged. The base body is the same statement and reader; the only base-side edit is the signature and one call site. LocalClockBucketKeyTests (13 + live) and Lite's 8 are unaffected — the census reads statements, and this PR adds none.
  • PostgreSQL targets: the first compute after CacheTtl on a target whose TimeZone is not UTC re-buckets to local time — the "re-bucket once" the Q6 ruling asks for, automatic because nothing keyed is stored. No migration rung, no aggregate change (ladder stays V132 landed).
  • Cost: one indexed one-row read (idx_pg_server_config_time) beside the 30-day scan, on the compute's own connection, inside its one classified catch — where the base puts its own clock read. AnalysisShutdownResidueTests still pins exactly one catch in the provider.

What it does NOT do

Doc riders (three, docs only)

Tests

New Darling/Darling.Tests/PgTargetClockTests.cs — five pins plus one gated live class:

  1. TheClockRead_IsAProtectedVirtualSeam_OverriddenOnceByThePostgresProvider — reflection: protected virtual, the exact signature, the override's GetBaseDefinition() is the seam; source: the compute calls the seam exactly once with AsNaive(analysisTime); the Clock partial never names DateTime.UtcNow.
  2. EachEnginesClockRead_NamesOnlyItsOwnCollectorsTable — base body still executes ServerClockSql over server_properties and never pg_server_config; override executes PgTargetClockSql over pg_server_config and never server_properties.
  3. TheOverridesRead_IsTheConfigFactsRule_… — FROM pg_server_config, c.name = 'TimeZone', NOT IN ( + DarlingPgServerConfigReader.SessionScopedSources + ), MAX(collection_time), collection_time <= $2, LIMIT 1, no now()/CURRENT_TIMESTAMP, no v_ view, no $3; and the anchor subquery + exclusion line are token-identical to PgTargetConfigSnapshotSql's.
  4. ThePostgresProvider_RedeclaresExactlyTheTwoSeams_AndNoState — exactly two overrides, zero instance fields, no resolver/window type in the partial, CommandTimeout = DarlingAnalysisService.AnalysisCommandTimeoutSeconds.
  5. TheRootDoc_StatesTheSettingAsTheRuleAndUtcAsTheFallback — the rider's sentence is present; the base doc no longer says "(a PostgreSQL target)" of the row-less arm.
  6. Live (DARLING_TEST_PG, [Collection("live-postgres")], LiveStoreCleanup): two twins with identical 32-day one-minute pg_database_stats series — 100 tps in the 09:00 hour New York time every weekday, 10 tps otherwise, across the 2026-03-08 spring-forward, the local hour computed in SQL with the same one-step function the provider binds. New York twin (snapshot TimeZone = America/New_York, source = configuration file; plus a later-than-window-end Tokyo snapshot that must be ignored): the real PgTargetBaselineProvider's pg_tps bucket for Tue 2026-03-10 13:30Z (09:30 EDT) is (9, Tue), 270 samples over 5 local days, median = mean = 100, MAD 0. UTC twin (no snapshot): (13, Tue), 270 samples, median 10, mean 20 — the four EST Tuesdays' 08:xx idle rows pooled with the EDT Tuesday's 09:xx load rows. Then a newer in-window snapshot with TimeZone = Asia/Tokyo, source = session → ClearCache → the New York twin keys (13, Tue), median 10: session scope excluded, and no fallback to the older snapshot. The UTC twin is the red-first half of the pair: it is exactly what the New York twin read before this PR.

Executed here: the five pins and the live class green against a throwaway timescale/timescaledb:2.28.1-pg18 (Docker context desktop-linux, torn down), through a net10.0 harness inside the worktree (AssemblyName=Darling.Tests) — 244 tests across PgTargetClockTests, LocalClockBucketKeyTests (+ its live class, green on the same rig), PgTargetAnomalyTests, PgTargetFactCollectorTests, PgTargetKnobsTests, BaselineSupplyTests, RepoFileAdoptionTests, CommentFilterAdoptionTests, StoreSqlClockDisciplineTests, DocCommentHygieneTests, LiveCleanupConversionRatchetTests, LivePostgresCollectionHygieneTests, then AnalysisPassCommandTimeoutTests, AnalysisPassTokenThreadingTests, AnalysisShutdownResidueTests, MeasurementContractCensusTests, TsqlConventionGuardTests, FactCollectorCommandTimeoutTests, FactCollectorFailureReportingTests (79): all green except DocCommentHygieneTests.TheDerivedProjectListCoversEveryProjectInTheTree, which found the harness's own csproj in the tree (artifact; the harness is not committed). Builds with 0 warnings: PerformanceMonitor.Darling.Analysis, PerformanceMonitor.Analysis, Darling.Tests, deprecated/Dashboard.Tests; Lite.Tests carries its one pre-existing xUnit2000 on LogTailOverlapThresholdPinTests, untouched. CI's Windows job is the first execution of everything not listed.

Which component(s) does this affect?

  • Lite
  • Darling
  • Lite Tests
  • Darling Tests
  • SQL collection scripts
  • Documentation
  • Full Dashboard (deprecated)
  • CLI Installer (deprecated)

How was this tested?

See Tests above: pins executed locally through the in-worktree harness; live class executed against a throwaway TimescaleDB 2.28.1 / PostgreSQL 18 container; CI's Darling PostgreSQL tests is the second execution.

Checklist

  • I have read the contributing guide
  • My code builds with zero warnings (dotnet build -c Debug)
  • I have tested my changes against at least one SQL Server version
  • I have not introduced any hardcoded credentials or server names

…ne setting, so a New York target's Tuesday-09:00 bucket is 09:00 in New York in every season (partial #3691, lane 20)

The clock read in PgBaselineProvider becomes a protected virtual seam (SQL Server body unchanged, byte for byte); PgTargetBaselineProvider overrides it once to read the TimeZone row of the latest pg_server_config snapshot at or before the window end, session-scoped sources excluded (the config facts' rule, copied), and hands the shared resolver the zone name. UTC is now the fallback for a target with no server-scoped snapshot, not the rule for the engine.

Doc riders: the root doc names the $4..$6 bind the base has made since #3749 and states the setting-then-UTC rule; the TpsTrendKey prose in PgTargetScorer.Database.cs and PgTargetFactCollector.Database.cs no longer claims a co-fire reads the trend (nothing has since #3747).
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