Bind store window bounds instead of comparing naive UTC to now() - #2971
Conversation
Review summaryReviewed the naive-UTC-vs-
One correctness/consistency issue found in both apps (posted inline): in No SQL injection, secrets, or missing-index-DMV concerns found in this diff. |
Every timestamp column in both stores is `timestamp without time zone` holding naive UTC; `now()` is `timestamptz`. PostgreSQL resolves the mixed comparison by converting the NAIVE side at the store session's TimeZone, which initdb takes from the host OS, so a window silently widens west of UTC and can invert to nothing east of it. No error is raised and every store anyone develops against runs UTC, which is why this survived review. Measured on timescale/timescaledb:latest-pg17 seeded one row per 52 seconds, running the shipped query strings: `collection_time > now() - interval '1 hour'` returned 70 rows at TimeZone=UTC, 347 at America/New_York and 0 at Pacific/Kiritimati; the bound form returned 70 in all three. Four predicates now bind the bound as a Kind-Unspecified DateTime computed from DateTime.UtcNow — the clock that stamped collection_time, and the convention the rest of these reads already follow (NaiveUtcNow, PgReadKindDisciplineTests): - DarlingAlertReadAdapter.ForcePlanFailuresSql, which returned 0 rows east of UTC, so the forced-plan-failure alert never fired there. - TimescaleSupport.BaselineBackfillProbeSql, whose `now()::timestamp` is LOCALTIMESTAMP: need_from moved +14h at Kiritimati, under-asking for baseline coverage. - Lite's LocalDataService.ForcePlanFailuresSql, the DuckDB twin. - Lite's long-running-query read, whose 10-minute freshness floor lands in the future east of UTC, so that alert returned nothing at all. DarlingModuleMap.RefreshSql keeps its bare now() and states why: across UTC-12..UTC+14 its 48-hour window delivers 34-60 hours, which stays above the 24-hour refresh cadence and inside the 4-day raw retention. It is waived by arithmetic, and the guard records it as the single exception. StoreSqlClockDisciplineTests holds the class: no store-side SQL literal may compare a naive collector timestamp against a bare clock. It reads literals through CSharpSourceWalker, which gains a StringLiteralBodies entry point rather than a sixth private lexer, strips SQL comments before matching, and carries floors on files/literals/columns scanned so it cannot pass vacuously. Its discriminator is pinned in both directions against ten hazard forms and thirteen benign ones, including the writes it deliberately does not claim. DarlingManagedPostgres gains a v9 conf block pinning timezone = 'UTC'. That is defence in depth, not the fix: it reaches managed stores only, and a bring-your-own store keeps whatever zone it was built with.
Three follow-ups from running the suites. DocCommentHygieneTests failed: inserting ForcePlanFailureWindow between ForcePlanFailuresSql's <summary> and the const itself left two stacked summaries on the const and no doc on the window. Both files now declare the window above the query's own doc block, so each member carries one summary. The adapter's measured figures are also corrected to the ones actually reproduced here (70 / 347, not 66 / 343) and now name the zero-row case. The guard's SQL filter accepts predicate FRAGMENTS, not just statements. These readers assemble filters as standalone literals and interpolate them — LocalDataService.WaitStats builds five that way — so "AND r.collection_time >= NOW() - INTERVAL '10 MINUTES'" carries no statement keyword and a statement-only filter skipped exactly the shape most likely to reintroduce this. Proven both ways: that fragment as a mutation is caught with the arm and MISSED without it. The known limit is stated in the source — one literal at a time, so a predicate split across concatenated literals is outside it, and no such split exists today. sample_time is excluded from the naive-UTC vocabulary. It is the one column name in the store whose frame depends on the table: cpu_utilization_stats.sample_time is deliberately the monitored server's LOCAL clock (#1262), while memory_pressure_events.sample_time must be UTC. CollectorTimestampFrameTests pins both per column, and records that its own first cut was a store-wide "all naive timestamps are UTC" rule that would have forbidden the CPU collector's intentional local clock. This scan keys on names, so it cannot tell those two apart and must not claim to.
The previous commit's message describes a fragment arm on the guard's SQL filter and four fragment controls. They are not in it. They were written, verified, and then destroyed: the mutation run that proved the arm load-bearing finished with `git checkout --` on this file to undo its mutation, and the arm was still UNCOMMITTED at that point, so the restore took it too. Only the sample_time exclusion, written afterwards, survived. That message overclaims and cannot be edited now without rewriting a commit a review is running against, so this records it instead. The arm itself: LooksLikeSql accepts a bare predicate FRAGMENT — a literal carrying both a clock and a comparison — as well as a whole statement. These readers assemble filters as standalone literals and interpolate them, five of them in LocalDataService.WaitStats alone, so "AND r.collection_time >= NOW() - INTERVAL '10 MINUTES'" has no statement keyword and the statement-only filter walked straight past the shape most likely to reintroduce this. Controls go from 10/13 to 12/15, covering the fragment in both directions, and the source states the limit the arm does NOT reach: one literal at a time, so a predicate welded together from two literals by concatenation is out of scope, and no such split exists today.
event_time is the second column name whose frame depends on the table, and excluding sample_time alone was a scenario-shaped fix. default_trace_events .event_time is server-LOCAL (the .trc files store local time) while system_health.event_time is UTC — ViewerSystemEventsTests puts it in one line: "system_health.event_time is UTC, default_trace.event_time is local". A name-keyed scan cannot tell those apart, so treating event_time as naive UTC was a claim the repo contradicts in four places. Both now sit in AmbiguousFrameColumns with the evidence for each and the rule for adding a third: a documented split, per table. CollectorTimestampFrameTests remains the authority, and the reason the split belongs there rather than here is that a server-local column compared against a bare clock IS still a defect — just a different one, whose fix is to de-skew by that server's offset rather than to bind naive UTC. Census in the floors comment corrected to what the scan actually reports now: 1,627 SQL-shaped literals and 56 column names.
|
Reviewed the diff (Darling + Lite changes, the new Correctness — checked out clean:
Lite/Darling parity — Scope check — grepped both stores for remaining bare No security or performance concerns — all binds are parameterized, nothing new touches file/network/process boundaries. Nothing else to flag. |
6949d77 to
040b8f6
Compare
|
Reviewed the diff (Darling + Lite clock-discipline fix, the
One minor, non-blocking observation: the new window-length constants ( No correctness, security, or Lite/Darling parity issues found. |
Every timestamp column in both stores is
timestamp without time zoneholding naive UTC — measured, there is not onetimestamptzcolumn in either schema.now()istimestamptz. PostgreSQL resolves the mixed comparison by converting the NAIVE side at the store session'sTimeZone, whichinitdbtakes from the host OS and whichBuildConfAppenddoes not pin, so a window silently widens west of UTC and inverts to nothing east of it. Nothing raises an error, and every store anyone develops or tests against runs UTC, which is why these read as correct in review.Measured, running the shipped query strings
timescale/timescaledb:latest-pg17(17.11) seeded one row per 52 seconds, and DuckDB 1.5.5 (the pinned version) seeded the same way:collection_time > now() - interval '1 hour'$1ForcePlanFailuresSqlbeforeForcePlanFailuresSqlafterBaselineBackfillProbeSqlneed_frombeforeBaselineBackfillProbeSqlneed_fromafterThe fix, and why parameters rather than
AT TIME ZONE 'UTC'Both spellings are correct. Binding a Kind-Unspecified
DateTimefromDateTime.UtcNowwins on four counts:NaiveUtcNow()exists in four service files — including 79 lines below the offending predicate inDarlingAlertReadAdapter, which was the only windowed read in its own file not using it — andPgReadKindDisciplineTestsalready enforces the C# bind half of exactly this rule. This PR holds the SQL literal half, which that scan cannot see.collection_time(DarlingCollectorRunnerwritesDateTime.UtcNowthroughSpecifyKind(..., Unspecified)), so the two sides cannot disagree about what "two hours ago" means.AT TIME ZONEneeds ICU; a parameter does not.now() AT TIME ZONE 'UTC' - interval '2 hours'is correct only becauseAT TIME ZONEbinds tighter than-, which is something you have to know.Four predicates now bind their bound. The fifth,
DarlingModuleMap.RefreshSql, keeps its barenow(): across UTC-12..UTC+14 its 48-hour window delivers 34–60 hours, which stays above the 24-hour refresh cadence (so no handle is missed between runs) and inside the 4-day raw retention (so the widened end scans nothing dropped). It is waived by arithmetic, not by being harmless; the query's remarks carry the derivation and the two numbers that have to keep holding, and the guard records it as its single exception.Lite/Services/LocalDataService.WaitStats.cswas not in the original report — a span-level scan of the whole store-side literal corpus found it. It is the long-running-query alert's snapshot-freshness floor, and it fails in the same direction as the Darling alert.And this one was already diagnosed in the repo.
DarlingAlertReadAdapter.LongRunningQueriesSqlTemplateis that same query ported to Postgres, and its doc comment has said all along:So the defect was identified, correctly explained, and fixed on the Darling side at the moment of the port — and the Lite original it was ported from kept it. That is the strongest argument in this PR for a class-level guard rather than four fixes: the knowledge was already here, written down next to the fix, and it still did not propagate. (
$4is also the parameter number Darling's twin uses, so the two now match.)The guard
StoreSqlClockDisciplineTestsholds the class rather than the four instances: no store-side SQL literal may compare a naive collector timestamp column against a barenow()/CURRENT_TIMESTAMP/LOCALTIMESTAMP/now()::timestamp.CSharpSourceWalker, which gains aStringLiteralBodiesentry point rather than a sixth private lexer (The shared source-walker blanks interpolated-string holes, so a call inside an interpolation is invisible to every scan built on it #2913's lesson) — additive, and the mirror image of the existingStripCommentsAndStrings.now()constantly, including in the waiver this pin allows.sample_timeis deliberately excluded: it is the one column name whose frame depends on the table (cpu_utilization_stats.sample_timeis intentionally the monitored server's local clock per [FEATURE] Add a headless Windows Service collector mode for Lite (gMSA-compatible), with the existing UI as a read-only viewer #1262,memory_pressure_events.sample_timemust be UTC), andCollectorTimestampFrameTestsalready pins both per column — its own remarks record that its first cut was a store-wide "all naive timestamps are UTC" rule that would have forbidden the CPU collector's intentional local clock. A name-keyed scan cannot tell those two apart and must not claim to.LocalDataService.WaitStatsalone), so"AND r.collection_time >= NOW() - INTERVAL '10 MINUTES'"has no statement keyword and a statement-only filter walks past the shape most likely to reintroduce this. Proven both ways: that fragment as a mutation is caught with the arm and missed without it. The limit it does not reach is stated in the source — one literal at a time, so a predicate welded from two literals by concatenation is out of scope, and no such split exists today.SET col = now(),VALUES (CURRENT_TIMESTAMP),alter_job(..., next_start => now()), TimescaleDB's genuinely-timestamptzcatalog views, and a clock spelled only in a comment all appear in the corpus and must not be dragged in. One of those controls caught a real bug in the detector's own regex, where the optional::timestampate the first eleven characters of::timestamptzand made a correctnow()::timestamptz AT TIME ZONE 'UTC'read as an offender.Mutation-tested: reverting each of the four fixes turns it red, and neutering the waiver key exposes the module-map site — so the allowlist is what suppresses that one, not a blind spot in the detector.
Scope boundary, stated rather than allowlisted
The guard flags comparisons and clamps, not writes. Lite has six bare-clock writes (
now()::TIMESTAMPintoconfig_database_state_expected.updated_at,server_tags.created_at, andCURRENT_TIMESTAMPinto the repair-marker tables). Those store local wall time in naive-UTC columns, which is a different defect shape — and all of them are inert today: none of those columns is read back or compared anywhere, and the one ordering use (ORDER BY attempted_at) is offset-invariant.collector_state.updated_at, which is compared againstMAX(collection_time), is written fromDateTime.UtcNowand is correct. Widening the guard to the write side would need six allowlist entries that misrepresent them as blessed, plus a data-migration question for existing stores, so it is deliberately out of scope here rather than quietly waived.Defence in depth
DarlingManagedPostgresgains a v9 conf block pinningtimezone = 'UTC'. Verified above: with the session pinned, even the un-fixed bare form returns the right answer. It rides the established versioned-marker mechanism, so existing field stores gain it on their next service-owned start rather than only a freshinitdb;timezoneis SIGHUP-context and the append runs beforepg_ctl start, so it is effective on that start. It is placed after the v8 hardware check and carries no fingerprint line, preserving v8's documented invariant about the text it reads.This is a backstop, not the fix: it reaches managed stores only, and a bring-your-own store keeps whatever zone its owner built it with. The predicates are what actually hold.
Verification scope
The Windows-only suites cannot run on macOS. What ran here: all four projects plus
Lite.Testscompile withEnableWindowsTargeting; the new guard's real source compiled into anet10.0harness against the actualCSharpSourceWalkerand executed (2/2, plus the five mutations); the shippedForcePlanFailuresSqlandBaselineBackfillProbeSqlexecuted through Npgsql against live PostgreSQL 17.11 under three session zones; DuckDB 1.5.5 probed for the Lite half. Not run locally: the xUnit suites themselves — CI is the arbiter for those.No CHANGELOG entry in this PR; the entry text is reported to the coordinating session for consolidation.