Bound the per-database watermark read to the clamp horizon (#2344) - #2346
Conversation
| await conn.OpenAsync(cancellationToken); | ||
| using var cmd = conn.CreateCommand(); | ||
| cmd.CommandText = $"SELECT MAX({columnName}) FROM {tableName} WHERE server_id = $1 AND {databaseColumnName} = $2"; | ||
| cmd.CommandText = collectedSince is null |
There was a problem hiding this comment.
Nothing here exercises this new conditional SQL text against a live DuckDB connection with collectedSince non-null — WatermarkPolicyTests only covers the pure ReadFloor/ClampCatchup arithmetic, and there's no test in Lite.Tests (e.g. via SharedDuckDbFixture) that actually calls GetLastCollectedTimeForDatabaseAsync with a floor and checks the $3-param query executes and returns the right row. The Darling twin (DarlingCollectorRunner.GetLastCollectedTimeForDatabaseAsync) has the same gap on the Postgres side. The reasoning behind why the bound is safe is thoroughly tested, but the SQL wiring itself (conditional predicate + conditional positional parameter, on two different drivers) is only validated by the PR author's manual EXPLAIN run against production — worth a small integration test on each host so a future refactor of this method doesn't silently break the $3 binding.
|
Reviewed the diff against What it does: bounds Verified:
One gap worth a look (left as an inline comment): there's no integration test that exercises the new No T-SQL in this diff (it's the C# collector-runner layer), no missing-index suggestions, no security concerns — table/column names interpolated into the SQL text are internal constants ( |
|
Force-pushed a rebase onto clean |
46ea903 to
3b67129
Compare
| [#2220]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2220 | ||
| [#2228]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2228 | ||
| [#2218]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2218 | ||
| [#2266]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2266 |
There was a problem hiding this comment.
This removes the [#2266]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/2266 link-reference definition, but [#2266] is still referenced three times elsewhere in this file (lines ~30, ~38, ~46 in the new version, in the entries for the scale-test reporting fix, the skipped-maintenance-cycle log line, and the heal-test flakiness fix). With the definition gone, those three occurrences will render as literal [#2266] text instead of links to the issue. Looks like an unintended deletion — please restore the line (or drop it back in alongside the [#2344] addition) rather than removing it.
|
Reviewed the watermark-read bounding change (Darling/Postgres and Lite/DuckDB Correctness of the bound itself: sound. The read floor ( Lite/Darling parity: clean. Both hosts add the identical One concrete bug — CHANGELOG.md: this PR deletes the No T-SQL collector code is touched here (this is a Postgres/DuckDB store-read change, not a monitored-server query), so the T-SQL style rules don't really apply, and I didn't see any missing-index-DMV suggestions to flag either way. |
3b67129 to
442f284
Compare
ReviewSolid change overall - the bound is on the correct (partitioning) column, the Postgres-vs-DuckDB Kind handling for the collectedSince param follows the established #1969 convention on the Darling side, and Lite/Darling stay in lockstep across all four touched call sites. WatermarkPolicyTests pins the boundary math well. One correctness issue worth fixing before merge, left as inline comments on the four call sites (Darling's Azure + enumerated arms, Lite's Azure + enumerated arms): The ReadFloor safety argument does not extend to the backfill-hole bookkeeping. ReadFloor's doc (and the PR description) proves the bound is free for the final clamped instant - max(stored, now - MaxCatchup) is the same whether the stored row is found or not. That is true for the value that ends up in the live query's cutoff. But the adaptive-shrink code immediately below each read site uses the raw watermark for a second purpose: deciding whether to call RecordQueryStoreBackfillHoleAsync(..., rawWatermark, ...), which persists a hole record for the trickle-in backfill worker. When the true stored watermark is older than the read floor (now - 3h, i.e. a database that has been failing for a while, or - the original #1556 field scenario - a service that just came back from being down for days), the bounded read now returns null instead of the true stale timestamp. That routes into the 'never-succeeded' branch, which explicitly skips hole recording ('pre-watermark history is the tail's job') and also skips the operator-visible clamp WARNING. So for exactly the outage-recovery case this whole watermark/backfill system exists to handle, the real gap silently stops being recorded or logged after this change - where before the PR, the unbounded read would have found the true raw value and recorded/logged it correctly. The live query's own cutoff is unaffected (it still resolves to the same now - 1h either way), so this does not reproduce as an incorrect query - it reproduces as a silently dropped backfill hole and missing WARNING on multi-hour+ outages, which is harder to notice. Also flagged a minor DuckDB/Postgres asymmetry on the DateTimeKind handling of the new parameter - low confidence, may not matter functionally for DuckDB, but worth a look since the codebase has an explicit convention for it elsewhere. |
| /* #2344: same bound as the enumerated arm. Safe here for the same reason and | ||
| by a different route — this branch does not clamp itself, but query_store's own | ||
| BuildCutoffParameters does (the #1836 double-clamp the policy documents), so the | ||
| value this read returns is clamped before anything uses it. */ | ||
| var azureReadFloor = string.Equals(definition.Name, QueryStoreCollector.Instance.Name, StringComparison.Ordinal) | ||
| ? WatermarkPolicy.ReadFloor(collectionTime) | ||
| : null; |
There was a problem hiding this comment.
Bounding the read here can silently drop a real backfill hole. ReadFloor's "found vs. not-found reach the same clamped instant" argument only covers the value that flows into BuildCutoffParameters/ClampCatchup. But the adaptive-shrink block right below this (lines ~449-475) also consumes the raw watermark for a different purpose: if context.Watermark is DateTime azureRaw is true, a stale azureRaw gets recorded via RecordQueryStoreBackfillHoleAsync(..., azureRaw, tighterFloor, ...) so the backfill worker knows to trickle in the real gap.
If the true stored watermark is older than ReadFloor (now - 3h) — e.g. this database has been failing for a while, or the service just came back from being down for days (the original #1556 scenario) — the bounded read now returns null where it used to return the true stale timestamp. That flips execution into the else branch at line ~466, which explicitly records no hole ("pre-watermark history is the tail's job") and skips the clamp WARNING too. So for the exact outage-recovery case this whole system was built for, the real historical gap now goes unrecorded and unlogged, whereas before this PR it would have been correctly captured.
Worth considering: either read the raw watermark unbounded specifically for this hole-recording path, or explicitly detect "found nothing because it's outside the read floor" vs. "genuinely never collected" so the hole can still be recorded (even if only approximately, e.g. from the read floor itself).
| /* #2344: bound the read for the ONE collector whose value is clamped right | ||
| below. Name-guarded rather than applied to every enumerating definition, | ||
| for the reason WatermarkPolicy's remarks give: a ring-buffer source whose | ||
| legitimate catch-up spans days must keep reading its whole history, and the | ||
| floor would silently truncate it. The clamp and the bound travel together. */ | ||
| var readFloor = string.Equals(definition.Name, QueryStoreCollector.Instance.Name, StringComparison.Ordinal) | ||
| ? WatermarkPolicy.ReadFloor(collectionTime) | ||
| : null; |
There was a problem hiding this comment.
Same issue as the Azure arm above, on this enumerated path: with the bound in place, raw comes back null instead of a true-but-stale watermark once it's older than now - 3h. That skips the raw.HasValue && clamped != raw block entirely (line ~767) — no WARNING, no RecordQueryStoreBackfillHoleAsync call for the real gap — and then the adaptive-shrink block below (line ~793) falls into the "never-succeeded" branch, which also records no hole by design. A multi-hour+ outage's history gap now goes unrecorded/unlogged where the unbounded read used to catch it correctly.
| /* #2344: same bound as the enumerated arm, safe by the other route — this | ||
| branch does not clamp itself, but query_store's BuildCutoffParameters does. */ | ||
| var azureReadFloor = string.Equals(definition.Name, QueryStoreCollector.Instance.Name, StringComparison.Ordinal) | ||
| ? WatermarkPolicy.ReadFloor(collectionTime) | ||
| : null; |
There was a problem hiding this comment.
Lite's twin of the same issue flagged on Darling's Azure arm: ReadFloor's safety argument covers the clamped instant, not the raw watermark that the adaptive-shrink block below (line ~264 onward) uses to decide whether/how to call RecordQueryStoreBackfillHoleAsync. Once the true stored watermark predates the read floor (now - 3h), the bounded read returns null, the code takes the "never-succeeded" branch at line ~281, and the real gap goes unrecorded with no WARNING — for exactly the extended-outage scenario this backfill machinery exists to handle.
| /* #2344: bound the read for the ONE collector whose value is clamped on the | ||
| next line. Name-guarded rather than applied to every enumerating definition: | ||
| a ring-buffer source whose legitimate catch-up spans days must keep reading | ||
| its whole history, so the clamp and the bound travel together. */ | ||
| var readFloor = string.Equals(definition.Name, QueryStoreCollector.Instance.Name, StringComparison.Ordinal) | ||
| ? WatermarkPolicy.ReadFloor(collectionTime) | ||
| : null; |
There was a problem hiding this comment.
Same issue on the enumerated arm: once the true stored watermark is older than the read floor, raw comes back null, skipping both the WARNING/hole-record at line ~578 and falling into the no-hole "never-succeeded" branch of the adaptive-shrink block at line ~614 — silently dropping the real gap for a database that's been failing/down for more than ~3 hours.
| cmd.Parameters.Add(new DuckDB.NET.Data.DuckDBParameter { Value = databaseName }); | ||
| if (collectedSince is DateTime floor) | ||
| { | ||
| cmd.Parameters.Add(new DuckDB.NET.Data.DuckDBParameter { Value = floor }); |
There was a problem hiding this comment.
Minor asymmetry vs. the Darling/Postgres twin: there, floor is explicitly re-marked DateTime.SpecifyKind(floor, DateTimeKind.Unspecified) before binding, citing the #1969 trap (Npgsql infers timestamptz from Kind.Utc and shifts the value). Here floor (Kind=Utc, from collectionTime = DateTime.UtcNow) is bound as-is. There's existing precedent in this codebase for stripping Kind before a DuckDB parameter used in a collection_time comparison (LocalDataService.FinOps.Pvs.cs:111), so this may just be an inconsistency in following that convention rather than a functional bug (DuckDB.NET's binding may be Kind-agnostic, unlike Npgsql) — worth a quick check either way for consistency with the rest of the codebase.
|
Reviewed the diff and merging. This is the one of the three I care most about, because it is the only Query Store cost that does not subside after catch-up — it is our own store's size, not the monitored server's workload. Fresh numbers from use1 that make the case, 183 runs over the last 30 minutes post-catch-up:
The worst live sample splits as What I verified rather than took on faith:
Prediction I will check on the box after the nightly: |
With #2333's catalog walk gone, the per-database split named the phase that does not subside after catch-up: wm, 1-3s per database per cycle. It is a read against OUR store, not the monitored server -- an unbounded MAX over a non-partitioning timestamp, so it touches every chunk that database has. Measured on the live use1 store (106 GB, 5 chunks), same server/database pair: unbounded, cold: 25,766 buffer reads + 195 written (temp spill) unbounded, warm: 228 ms bounded to 3h: 29 ms, 5 chunks excluded The unbounded cost is a function of store size and cache residency rather than of the monitored workload, so it degrades precisely where an operator is weakest: a long-lived store, a busier Query Store, slower disks. The bound changes no answer, and that is the whole justification: every consumer ends at max(stored, now - MaxCatchup), because ClampCatchup floors anything older and a null result falls back to query_store's 60-minute first-run window -- the same instant as the floor. So a row below the horizon cannot move the result whether it is found or not, and the unbounded MAX was paying to confirm a value the clamp would have produced anyway. The predicate goes on collection_time (the partitioning column) because a predicate on the watermark column alone prunes nothing, and a row's watermark can never exceed its own collection_time, so nothing qualifying hides behind it. Bounded for query_store ONLY, on both hosts, name-guarded like the other query_store-specific behavior in these runners: a ring-buffer collector whose legitimate catch-up spans days must keep reading its whole history, and the floor would silently truncate it. WatermarkPolicy.ReadFloor carries the reasoning and the measurements; its tests pin that the floor sits strictly older than the clamp horizon and that a buried watermark and a not-found one reach the same instant. Closes #2344 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
442f284 to
2d5f214
Compare
|
Rebased onto While resolving I also took the cosmetic nit the review bot raised on #2343: the double blank line before the Worth flagging for anyone watching CI here: a CONFLICTING PR silently stops GitHub running |
ReviewReviewed the diff (CHANGELOG, Correctness — checked out. The core claim ("bounding the read changes no caller's outcome") rests on the invariant that a row's watermark column can never exceed its own Verified the more subtle claim in the Azure-arm comments — that the per-database branch doesn't clamp itself but is safe because Lite/Darling parity — good. Both Tests — the new Security/perf — no new user-controlled input reaches the interpolated No blocking issues found. |
Closes #2344. The first of Erik's "further juice to squeeze before releasing" — and it is the phase that survives catch-up, so it is the one that follows an operator home.
What the split showed
After #2333 removed the plan-fetch catalog walk, the per-database log line reads:
plan_fetchis catch-up work and drains to near-nothing by design.wmdoes not. Andwmis not a query against the monitored SQL Server — it is a read against our own store.Measured, live use1 store (106 GB, 5 chunks, 42 primaries)
Same server/database pair,
EXPLAIN (ANALYZE, BUFFERS):Warm is 7.7x. The cold row is the one that matters in the field: the cost is a function of store size and cache residency, not of the monitored workload — so it is worst on a long-lived store, a busier Query Store, slower disks, or a box whose cache is not dominated by this one query. Our dogfood store is five chunks; a 90-day-retention store is many more.
Why the bound is free rather than a tradeoff
Every consumer of this value ends at
max(stored, now - MaxCatchup):MaxCatchupis 1 hour;ClampCatchupfloors anything older tonow - 1h.So a row older than the horizon cannot change the outcome whether it is found or not, and the unbounded
MAXwas paying to confirm a value the clamp would have produced anyway.WatermarkPolicyTestspins both halves: the floor sits strictly older than the horizon (the ordering is the safety argument), and a buried watermark and a not-found one reach the identical instant.Two mechanical details that are easy to get wrong:
collection_time(the partitioning column). A predicate on the watermark column alone prunes no chunks. It is safe because a row's watermark value can never exceed its owncollection_time— an execution cannot be collected before it happens — so nothing qualifying hides behind the bound.default_trace_events) must keep reading its whole history; handing it this floor would silently truncate that. The clamp and the bound travel together — a future definition wanting one has to adopt the other, which the policy remarks say explicitly.GetLastCollectedTimeAsyncis deliberately untouched: that path does not clamp, so the equivalence does not hold there.Parity and verification
Both SKUs, same shape (Postgres gets chunk exclusion; DuckDB gets min-max pruning and a smaller scan — different mechanism, same predicate and same argument). All four affected projects build.
The policy arithmetic was executed against the built assembly in a throwaway net10.0 harness — which caught a bug in my own test fixture (an
InlineData(2)case asserting a 2-hour-old watermark sits below a 3-hour floor; it does not). That would have been a red CI round. Fixed to 4h before pushing. The Windows test projects build here but cannot run, so the harness is the only pre-CI execution available.Not yet measured end-to-end in production: the before/after above is the query in isolation via EXPLAIN, not the
wmphase in the running service. That lands when a nightly carrying this installs, and the log split will show it directly.🤖 Generated with Claude Code