Skip to content

Dedup Query Store intervals before aggregating (#1841 tier 1) - #1845

Merged
erikdarlingdata merged 1 commit into
devfrom
fix/1841-qs-read-dedup
Jul 30, 2026
Merged

erikdarlingdata merged 1 commit into
devfrom
fix/1841-qs-read-dedup

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Read-side fix for #1841. Fixes #1841 is deliberately NOT used — tier 2 remains.

The bug

query_store_stats rows are CUMULATIVE per-Query-Store-interval snapshots, and the collector is incremental on last_execution_time, so while a query keeps running its OPEN interval is re-fetched every cycle and stored again with a growing execution_count. The schema has no interval-id column and every aggregate read summed the raw rows, so one interval's work was counted once per collection.

Live evidence from a real store, same (server_id, database_name, query_id, plan_id) inside ONE hour bucket: 496 rows with execution_count = 1 each (one interval, 496 collections, its CPU and duration billed 496 times), plus cases growing 1 -> 23 cumulatively where every re-collection also re-charged the accumulated total.

Execution-count weighting did not rescue the averages: repeated snapshots of one interval carry DIFFERENT growing weights AND different avg_* values, so an open interval was weighted by the triangular sum of its own growth.

The fix

Collapse each interval to its LATEST snapshot before aggregating, keyed on first_execution_time (the interval identity the schema does expose), via the ROW_NUMBER() ... WHERE rn = 1 shape already used by PgFactCollector.QueryPerf / PgDrillDownCollector.Queries and their Lite twins. This corrects data already collected — no migration, no re-collection.

The partition is (database_name, query_id, plan_id, first_execution_time, execution_type_desc, replica_role), uniform across both apps. execution_type_desc and replica_role are there because sys.query_store_runtime_stats is keyed by (plan_id, interval, execution_type, replica_group) — a narrower key could DROP a legitimate row, turning a double-count into a silent under-count.

Read Verdict
Lite slicer / top queries / comparison fixed
Viewer slicer / top queries / comparison / regressions / slicer overlay fixed
Service MCP + REST get_query_store_top fixed
Custom Views raw route fixed
Lite + Viewer duration trend left un-deduped, see below
History drilldown (both apps) already correct — raw per-collection projection, no aggregate
Stored-plan lookups (3 sites) already correct — latest-row-only
Hourly/daily CAGGs tier 2, see below

Three things this does NOT fix, all documented at the source

Bar placement lags one bucket. Bars key on collection_time, so an interval is attributed to the hour it was last COLLECTED, not the hour it ran — on Query Store's default 60-minute interval, reliably one bucket late. Window totals are right; placement is not. Fixing it needs first_execution_time, which is the monitored server's LOCAL wall clock while the axis is UTC.

The duration trend is deliberately left un-deduped and still overstates. Deduping is right for totals and wrong for that chart: it keeps one row per interval, at the collection where the interval closed, and a 60-minute interval against a 5-minute cadence collapses twelve snapshots onto one collection_time. Measured: a 1-hour window renders a SINGLE point, valued 0 because the LAG has no predecessor; a 4-hour window drops from ~48 points to 4. An honestly-labelled inflated line beats an empty chart until tier 2 can place work at the time it ran. Pinned by test in both apps so the exclusion stays a decision.

The CAGGs are not repaired. query_store_stats_hourly materialized sum(execution_count) and both weighted sums from un-deduped rows, and _daily inherits them hierarchically. No read-side change can undo that — the duplicates are gone once materialized, and the CAGG output carries no first_execution_time to key on. A TimescaleDB continuous aggregate cannot contain a window function or DISTINCT ON at all, so this needs a rebuild (or write-side dedup), not an edit. Consequence to know: a Custom Views panel whose window reaches past the raw tier still reads the old inflated numbers, so numbers step at the routing boundary.

Testing

  • Real-DuckDB round-trip (Lite.Tests/QueryStoreDedupReadTests.cs) seeding both live shapes — an interval collected 4x with a FLAT count of 1 (the 496x shape) and one collected 3x while it grew 10 -> 25 -> 40. Watched RED first: 22 vs 13 ms CPU, 75 vs 40 executions, 4 vs 1, 15 vs 11.
  • Real-Postgres round-trip covering the four Viewer reads that had NO live coverage at all, plus the MCP surface. Watched RED first (4 vs 1).
  • First test that executes the compose compiler's output against Postgres — the raw route is a hand-assembled derived table, exactly where a string pin passes and Postgres rejects. Watched RED first (53 vs 41).
  • SQL pins on every affected constant, plus a source-containment guard for Lite, whose Query Store SQL is inline and had no drift guard — a fifth Lite read could have shipped un-deduped silently.
  • Full suites green against a live PG 18 + TimescaleDB store: Darling 3785 passed / 0 failed, Lite 1652 passed / 0 failed, 0 build warnings.

Independent review caught the trend regression and a Postgres qual-pushdown problem (the compose partition now carries server_name, or the panel's server scope could not push through the window function and a fleet store would rank every server's rows first).

🤖 Generated with Claude Code

query_store_stats rows are CUMULATIVE per-Query-Store-interval snapshots,
and the collector is incremental on last_execution_time, so an OPEN
interval is re-fetched every cycle and stored again with a growing
execution_count. The schema carries no interval id, and every aggregate
read summed the raw rows -- counting one interval's work once per
collection. A live store held 496 collections of a single interval inside
one hour bucket, each billing its full CPU and duration.

Read-side fix only: collapse each interval to its LATEST snapshot before
aggregating, keyed on first_execution_time, matching the ROW_NUMBER
convention already used by the plan-regression analysis collectors. This
corrects data already collected; no migration or re-collection needed.

Fixed: Lite's slicer, top-queries grid and comparison; the Darling
Viewer's equivalents plus regressions and the slicer overlay; the
service's MCP/REST get_query_store_top; and the Custom Views raw route.

Deliberately unchanged, each documented at the source:

- Bar placement still keys on collection_time, so a bar lags by one
  bucket on Query Store's default 60-minute interval. Correcting it needs
  first_execution_time, which is server-LOCAL against a UTC axis.
- The duration trend keeps its un-deduped (overstating) form. Deduping
  keeps one row per interval-close, and a 60-minute interval against a
  5-minute cadence collapses twelve snapshots onto one timestamp -- a
  1-hour window would render a single zero-valued point.
- The hourly/daily CAGGs materialized their sums from un-deduped rows and
  cannot be repaired at read time; a continuous aggregate cannot contain a
  window function, so they need a rebuild.

The compose partition carries server_name as well as server_id so the
panel's server scope can still push through the window function.

Tests: real-DuckDB and real-Postgres round-trips assert an interval is
counted once, each watched failing against the old queries first; SQL
pins on every affected constant; a source-containment guard for Lite,
whose SQL is inline and had no drift guard; and the first test that
executes the compose compiler's output against Postgres.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment on lines +94 to +100
WITH deduped_baseline AS (
/* LOAD-BEARING (correctness, not just perf) — #1841. The rows are CUMULATIVE per-interval
snapshots and the collector re-fetches the OPEN interval every cycle, so the SAME interval
(same first_execution_time) is stored repeatedly with a growing execution_count. Keep the
LATEST snapshot per interval before aggregating.

This read is especially exposed: the baseline arm is UNBOUNDED (collection_time < $2,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Performance regression risk: unbounded baseline now sorted, not just aggregated.

Before this PR, baseline_performance did a single streaming/hash aggregate (SUM/AVG/COUNT DISTINCT) over every row with collection_time < $2. Now it first materializes a ROW_NUMBER() OVER (PARTITION BY ... ORDER BY collection_time DESC) across that same unbounded row set before aggregating — which for a long-lived server (the comment itself says "potentially months") means Postgres has to build partitions and sort within them (or hash+sort per partition) over the entire history on every regressions-tab load, not just scan-and-reduce it once.

This is a real cost trade for correctness (the comment explains why the dedup is necessary here), but it's worth flagging explicitly as a performance regression on busy/long-retention servers, since the recent-window dedup (bounded) doesn't have this problem. Might be worth confirming via EXPLAIN (ANALYZE, BUFFERS) against a store with a large history before/after, since the regressions tab is presumably used interactively.

Comment on lines +130 to +132
}

return "(SELECT * FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY server_id, server_name, database_name, "

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The correctness of this dedup is well covered (string pins + the live-Postgres compose test), but its performance for a server-scoped panel depends entirely on Postgres pushing the outer f.server_name = ANY(...) qual down through this window function — which only happens because server_name is included in PARTITION BY here. That's a real, documented Postgres planner optimization (predicate push-down below WindowAgg is safe only when the predicate's columns are a subset of every window's PARTITION BY), so the design is sound, but there's no test that pins the query plan — only that the SQL text contains server_name in the partition and that the live query returns the right values. If a future refactor reorders the partition list or a Postgres version regresses this optimization, a fleet-scoped panel would silently start ranking every server's raw query_store_stats rows for the whole window before narrowing — exactly the regression the partition placement is meant to prevent. Worth an EXPLAIN-based assertion (or at least a comment note to re-verify with EXPLAIN against a multi-server seed) rather than relying solely on string/value pins for this specific guarantee.

@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review summary

What this does: collapses query_store_stats (CUMULATIVE per-Query-Store-interval snapshots, re-collected every cycle while an interval is open) to its latest row per (database_name, query_id, plan_id, first_execution_time, execution_type_desc, replica_role) before aggregating, across every affected read in both Lite and the Darling Viewer/service/Compose surfaces, and deliberately leaves two reads un-deduped (raw drilldown history, duration trend) with the reasoning documented inline at each site.

Correctness. The dedup key choice (first_execution_time as interval identity, execution_type_desc/replica_role to avoid under-counting across the real grain of sys.query_store_runtime_stats) is sound and consistently applied everywhere it needs to be. QueryStoreComparisonSql/QueryStoreRegressionsSql correctly build one deduped CTE per window arm and route both the top-N-hashes selection and the final weighted-average aggregation through the same deduped CTE (rn = 1 reapplied at each consumption site) — easy to get wrong (dedup once, forget to reapply on the join) and its done right here. The two deliberate exclusions (QueryStoreHistorySql, QueryStoreDurationTrendSql) are well justified and pinned by tests in both apps so they read as decisions, not oversights. Test coverage is unusually strong for a read-side fix: real DuckDB and real Postgres round-trips seeded with both live-observed shapes (flat re-collection, growing cumulative count), a first-ever live-Postgres run of the Compose compilers output, and source-containment guards (Lite) so a future 5th un-deduped read fails a test rather than shipping quietly.

Lite/Darling parity. No drift found. Every dedup added to a Darling read has a matching Lite read updated with the identical partition key and rn = 1 shape, and both deliberate exclusions are documented with matching rationale in both apps. Features that only exist on one side (Compose/MCP on Darling, no Lite equivalent) are correctly out of scope for parity.

Performance. Left two inline notes: (1) QueryStoreRegressionsSqls baseline arm is unbounded (collection_time < $2, potentially months per the PRs own comment) and now needs a windowed sort over that full history instead of a single streaming aggregate — a real cost trade for correctness, worth confirming with EXPLAIN ANALYZE against a long-retention store. (2) The Compose raw-route dedup (ComposeCompiler.BuildFactRelation) relies on Postgres pushing the outer server_name = ANY(...) qual through the window function for a server-scoped panel to avoid ranking the whole fleets rows first; the reasoning and partition design are correct, but nothing pins the actual query plan, only the SQL text and resulting values.

Security. No concerns. All new/changed SQL is parameterized; no string-built predicates from user input; BuildFactRelations dynamic pieces come from an internal catalog lookup and bound parameter placeholders, not request data.

Nothing else stood out as a correctness or parity risk. The two performance notes above are suggestions, not blockers.

@erikdarlingdata
erikdarlingdata merged commit 9c526b4 into dev Jul 30, 2026
4 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/1841-qs-read-dedup branch July 30, 2026 17:58
erikdarlingdata added a commit that referenced this pull request Jul 31, 2026
From the review bot's inline comment on #1919, taking its first option
rather than the comment-softening one: softening would have been a
Lite/Darling parity scope-down.

QueryStoreDedupReadTests.EveryQueryStoreAggregateInTheFile_CarriesADedupCte
reads exactly one file, LocalDataService.QueryStore.cs, which is right
for what it checks but left 2 of Lite's 7 dedup sites -- the ones in
Analysis/DrillDownCollector.Queries.cs and
Analysis/DuckDbFactCollector.QueryPerf.cs, both of which this same PR
added tie-breaks to -- covered by no test at all. Dropping
execution_count DESC from either would have gone uncaught, which is the
recurrence mode #1841/#1845/#1853/#1907 keep demonstrating, and Darling
was already guarded against it.

Lite.Tests/QueryStoreSliceTieBreakSourceTests mirrors Darling's
counterpart exactly: an enumerated DedupSites list (3 files, 7 sites)
rather than a glob, so MOVING a read has to be re-declared instead of
silently shrinking coverage, and a missing tie-break fails naming the
file. It also carries a parity check that reads Darling's guard and
asserts its declared total, so neither app can quietly fall behind the
other.

The source comments in both apps claimed "a source-containment test pins
all of them" -- true only on the Darling side. Both now name the actual
tests and their real counts.

Watched red: dropping the tie-break from DuckDbFactCollector.QueryPerf.cs
leaves the OLD guard green (proving the gap was real) while the new guard
fails with "Lite\Analysis\DuckDbFactCollector.QueryPerf.cs @ char 9829".

Lite 1903 passed / 0 failed, build 0 warnings / 0 errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ianwalkeruk pushed a commit to ianwalkeruk/PerformanceMonitor that referenced this pull request Jul 31, 2026
…gdata#1841 tier 2)

Query Store rows are cumulative per-interval snapshots and the collector
re-fetches the OPEN interval every cycle, so every aggregate has to collapse
an interval to its latest snapshot before summing. Tier 1 (erikdarlingdata#1845) did that on
the first_execution_time PROXY because the schema exposed no interval key and
no interval clock. Both now exist.

The collector selects runtime_stats_interval_id and interval_start_time_utc
(sys.query_store_runtime_stats_interval.start_time AT TIME ZONE 'UTC') from
the one shared payload body, LEFT JOINed for the same reason the replica join
is: an unresolved interval row must cost one column, not every runtime-stats
row for that database. Neither column is version-gated - both are original
Query Store surface, verified present on SQL Server 2016 SP3, the collector's
own AppliesTo floor. Lite schema v49, Darling store V41, both additive,
nullable, and appended so the positional writers stay aligned; the Darling
viewer gains the matching probe sentinel and map arm.

Every dedup keys on the real id, with the proxy kept beside it rather than
COALESCEd: the id is NULL on exactly the pre-tier-2 generation, so the two can
never collide and a legacy-only window degrades to precisely tier 1's key.
Where the id exists it also fixes what the proxy could not - a row whose
first_execution_time is NULL had no identity at all and collapsed with every
other such interval of the same plan, which under-counts. The four
analysis-layer dedups that predate tier 1 get the same key upgrade.

The slicer bars and the duration trend now place work at the interval's start.
The trend's tier-1 exclusion is deliberately reversed: its reasoning was sound
but rested on a false premise, that the interval clock is the monitored
server's LOCAL wall time. Query Store's interval start_time and
first_execution_time are both datetimeoffset - verified live on a server
reading +00:00 while sitting at UTC-4 - and the collector already normalized
through DateTimeOffset.UtcDateTime before storing, so the interval clock
always shared an axis with collection_time. Rows without the identity keep the
pre-tier-2 treatment exactly; the arms split on IS NULL / IS NOT NULL, which
partitions the rows with no overlap and no gap, so a mixed window counts every
row once and resolves itself as those rows age out.

The decision-pin tests from erikdarlingdata#1845 are re-pinned to the new behavior including
the legacy boundary. Watched red first against real DuckDB and a real
PostgreSQL 18.4 + TimescaleDB 2.28.1 store: the one-bucket lag (a bar at 16:00
for work that ran at 15:00), the un-deduped trend (4 points where 2 intervals
ran), and the tier-1 under-count (5 executions where 11 ran).

Filed rather than deferred silently: erikdarlingdata#1849 (the hourly/daily rollups still
bake in the inflation - carries the corrected-rollup design and the live
TimescaleDB probe results showing a CAGG cannot bucket on
interval_start_time_utc) and erikdarlingdata#1850 (the analysis dedups still drop a replica's
rows on an AG primary).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ianwalkeruk pushed a commit to ianwalkeruk/PerformanceMonitor that referenced this pull request Jul 31, 2026
…S dedups by replica (erikdarlingdata#1850)

erikdarlingdata#1846 — alert history rendered "0.00" for alerts that have no number to
report. The value columns are NOT NULL doubles, producers hand the store a
display STRING, and the store's parser yields 0 for text carrying no digit,
so a role ("PRIMARY"), a connection state ("DISCONNECTED"), a suspend reason
or the literal "resolved" all landed as a sentinel that rendered as a
measurement. Both grids now render an em dash for those metrics. Write side
untouched: the column stays NOT NULL, 0 stays the sentinel, and rows already
collected display correctly with no migration.

Enumerated from the fire sites rather than the issue text, which found 19
metrics rather than 9: the seven AG metrics, Server Unreachable/Restored, and
every resolution notice — Darling's BuildResolutionRecord hardcodes
CurrentValueText "resolved" for both its own self-alert recoveries and the
shared engine's resolution callback, and those reach history only in Darling.
Resolutions match through the existing shared AlertMetricClassifier
.IsResolution, so a future recovery metric is right without an edit, and the
predicate lives in PerformanceMonitor.Common rather than being hand-mirrored
into two formatters — the drift that class exists to stop.

The dash is gated on a stored 0, never the name alone, and that gate is
load-bearing: the parser scans to the first digit ANYWHERE, so AG Sync Fell
Behind spells its lag into its prose and any of them can pick a digit out of
an object name. Three Darling self-alerts are excluded for that reason
(erikdarlingdata#1881), Store Disk Pressure most of all — its parsed value is percent-free,
where a real 0 means a full volume. A measurement that happens to be zero is
not a missing value; both suites pin that separately.

erikdarlingdata#1850 — four analysis-layer Query Store dedups kept a key narrower than the
read side's, with no replica_role. sys.query_store_runtime_stats is keyed by
(plan_id, interval, execution_type, replica_group), and on a SQL Server 2022+
AG with Query Store for secondary replicas the primary holds ONE shared Query
Store carrying every replica's rows — so the rn = 1 filter was not
de-duplicating those rows, it was DROPPING one. An under-count, which is worse
than the double-count the dedup exists to fix: a double-count is visible in
the number, a dropped row is silent. erikdarlingdata#1845 used this reasoning for the
read-side key; these four are the same defect one layer down.

Split rather than blended: replica_role is carried through the grouping and
the ranking, a regression is measured WITHIN a replica, and it is exposed in
both drill-down row shapes. The joins use IS NOT DISTINCT FROM, never = —
replica_role is NULL on every standalone/non-AG/pre-2022 server and NULL =
NULL is UNKNOWN, so an equi-join would have silently disabled plan-regression
detection for nearly every install. Verified live on real DuckDB and real
PostgreSQL 18.4 + TimescaleDB, with a NULL-replica arm in both suites.

Watched RED first on both stores: two replicas over the same two intervals,
the old key reporting one offender at 3x against a true two offenders and 12x
— dropping the primary reported the secondary's milder regression as the
server's worst. One downstream consequence fixed mechanically: the force-plan
recommendation reads fixed keys, so two rows for one query would have rendered
two sp_query_store_force_plan calls naming the same query with different plan
ids; it now keeps the worst per query, also watched red. Which replica should
drive that recommendation is erikdarlingdata#1882.

The Darling FROM/JOIN drift guard needed a real fix rather than an
accommodation: it scanned comments and read the word "join" in English prose
as a relation, and read the FROM inside IS NOT DISTINCT FROM as one too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ianwalkeruk pushed a commit to ianwalkeruk/PerformanceMonitor that referenced this pull request Jul 31, 2026
…tion

sys.query_store_runtime_stats returns the flushed slice and the
still-in-memory slice of one runtime_stats_interval_id as two separate
rows, and they are ADDITIVE members of one interval rather than
competing snapshots of it. The collector selected straight from the
view, so both were stored -- and they then shared every column of the
read-side dedup key (erikdarlingdata#1841/erikdarlingdata#1845/erikdarlingdata#1853) AND collection_time, so the
viewer's ROW_NUMBER and the rollups' last(execution_count,
collection_time) were both ordering by a value identical for both rows.
The survivor was whichever the engine emitted first: 8 executions shown
where 94 was true, differently on each run.

Reproduces on box SQL Server 2022 (16.0.4255.1), not just Azure: 100
flushed + 25 in memory came back as two rows while
dm_exec_procedure_stats reported 125 at the same instant.

BuildPayloadBody -- the one body both execution shapes run -- now groups
on the natural key of the view (plan_id, runtime_stats_interval_id,
execution_type, replica group). execution_count SUMs; every avg_* takes
the count-weighted mean, because Query Store stores an average and a
count but never a total; min_*/max_* take the extreme; first/last
execution time the interval's span. The emitted row shape is unchanged
-- same 55 columns, same order, no migration -- only the row count per
interval.

The cutoff moves from a per-slice WHERE to HAVING MAX(...) at interval
grain, which is load-bearing: the flushed slice is static, so a
per-slice predicate stops matching it once the growing in-memory slice
advances the watermark, and the sum degrades to the sliver alone. The
IN (...) pre-filter is a prune only, and the fixed query is faster than
the one it replaces (375ms vs 453ms on a real 212k-row Query Store) --
half the rows means half the plan XML to materialize.

The deprecated Dashboard's collect.query_store_collector had the same
defect against the same view and takes the same fix.

Rows already collected cannot be repaired: all 19 read-side dedup sites
in both apps gain a documented execution_count tie-break so a pre-fix
tie resolves to the flushed slice deterministically instead of flapping.
Closest-available, not correct -- residual tracked in erikdarlingdata#1912.

Verified live on SQL Server 2022 (emitted SQL returns 125/70 against raw
slices of {100,25}/{60,10}, matching dm_exec_procedure_stats) and on
PostgreSQL 18 + TimescaleDB 2.28.1 (corrected rollups report the
hand-computed 155 and the exact weighted mean; the same store fed split
slices can never reach the interval's true 125).

Lite 1854 passed / 0 failed, Darling 3997 passed / 0 failed, full
solution rebuild 0 warnings / 0 errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ianwalkeruk pushed a commit to ianwalkeruk/PerformanceMonitor that referenced this pull request Jul 31, 2026
…r action for it

The erikdarlingdata#1845-style annotation the lead asked for, placed where that precedent
put it: in ComposeCompiler's doc, beside the erikdarlingdata#1849 routing boundary it
shares. It records that a SECOND, permanent understatement sits underneath
the inflated-rollup one -- rollups materialized from pre-erikdarlingdata#1907 rows carry
ONE SLICE of each split interval, the repair reaches only as far back as raw
still does, and re-materializing past that DESTROYS the rollup rather than
correcting it. Nothing about it is visible in a panel; the numbers are
simply low, which is exactly why it is written down.

CHANGELOG Important gets two entries. Lite's is the v39-style one-time note:
first launch takes a few extra seconds, the archive is rewritten, and the
file may get slightly BIGGER despite losing rows -- 28.0 MB to 31.6 MB while
22,760 rows were removed -- because parquet size follows row-group layout
and per-column compression rather than row count. Disclosed rather than
pretended away.

Darling's is the operator action next to --backfill-rollups, and it leads
with the timing because the timing is the unusual part: the verb gets LESS
useful every day you wait. It repairs only what raw still holds, so run it
right after upgrading and it fixes the recent history you are looking at;
run it a week later and it correctly reports nothing to do while the numbers
stay wrong. What it cannot reach is permanent. DARLING01 named explicitly.

Also fixes a doc-hygiene displacement the guard caught -- my insertion had
pushed QueryArchiveAsync's summary onto the wrong member -- and corrects
that summary, which still said DuckDB's parquet cache is per-connection.
It is per-PATH at INSTANCE scope; a fresh connection from the same store
does not escape it, which is the entire trap.

Darling 4033 passed / 0 failed against live PG 18.4 + TSDB 2.28.1,
Lite 1927 passed / 0 failed, full -t:Rebuild 0 warnings / 0 errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
erikdarlingdata added a commit to ianwalkeruk/PerformanceMonitor that referenced this pull request Jul 31, 2026
…ata#1921, option 1)

erikdarlingdata#1841 moved the slicer BARS to the hour an interval ran and left the
OVERLAY on the hour it was collected, so a point sat up to one Query Store
interval -- 60 minutes by default, 1440 at most -- to the RIGHT of the bar
describing the very same work. The file's own comment stated the invariant
this broke: its dedup half kept holding, its placement half silently
stopped.

Both apps now place the overlay on COALESCE(interval_start_time_utc,
collection_time), and the WINDOW moves with the x-value -- filtering on
collection time while plotting on the interval start disagrees at both
edges AND disagrees with the bars about whether an interval is in the
window at all. Same collection_time pruning bounds the slicer already
ships (erikdarlingdata#1892/erikdarlingdata#1923); the issue's own "no ceiling" scope note predates that
round. ORDER BY moves too, or the chart gets a non-monotonic series and
draws a zig-zag rather than a curve.

TWO THINGS THE INSTRUCTION DID NOT ANTICIPATE, both verified before acting:

- The nominated watched-red pin does not move. Its rows are seeded WITHOUT
  an interval identity, so COALESCE correctly falls back to collection_time
  and the point stays at bucketStart+15m. Rather than force it, its
  assertion is unchanged and now pins the legacy FALLBACK, and the
  behavioral proof went into the tier-2 test whose rows can actually show
  the move -- where the interval starts in h0 and every collection lands in
  h1. Watched red there: reverting the x-value fails it with
  "Expected 06:00, Actual 07:10", the 70-minute offset, and the legacy pin
  keeps passing. Both generations are now covered by the test whose data
  can demonstrate each.

- Lite has no dedicated overlay read. It fed the overlay from the history
  GRID's read, which is a deliberately raw per-collection projection
  (erikdarlingdata#1845) windowed on collection time -- right for a list of collection
  events, wrong for a series drawn over the bars. Moving that shared read
  would have silently changed the history grid too, so Lite gained a
  dedicated read instead, matching the structure Darling has had since
  erikdarlingdata#683. It picks up the DEDUP the overlay never had: an interval collected
  repeatedly used to draw as a rising staircase of restatements and now
  draws as one point at its final values, which is what the bar beside it
  always showed. That is the same invariant, not extra scope.

Both source guards caught the new read and were updated deliberately
(5 -> 6 dedup sites, 7 -> 8 rank filters, tie-break total 7 -> 8) -- the
erikdarlingdata#1912 guard fired from the direction of a read being ADDED rather than
edited, which is what it was written for.

Darling 4033 passed / 0 failed against live PG 18.4 + TSDB 2.28.1,
Lite 1937 passed / 0 failed, full -t:Rebuild 0 warnings / 0 errors.

Co-Authored-By: Claude Fable 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