Found during the #1887 end-to-end smoke (live Azure SQL Database, GP_S_Gen5_1, shipped Darling service
from origin/dev @ 4adf3236). Not caused by #1872 — see "Why this is not the replica flip" below.
The #1887 run's own checks all passed; this is a separate, pre-existing defect that only a sustained
end-to-end run surfaces, which is exactly why the hand-composed SQL in #1848/#1872 never saw it.
Symptom
query_store_stats execution counts are understated, non-deterministically, on both the viewer grid
read (ViewerDataService.QueryStoreTopSql) and the query_store_stats_interval_hourly CAGG that the
corrected/daily rollups are built on.
Live example — the CAGG's last(execution_count, collection_time) versus the truth, same store, same run:
module_name | query_id | plan_id | cagg_execution_count | true_total
-------------------------------+----------+---------+----------------------+-----------
dbo.pm_get_orders_by_customer | 12 | 2 | 136 | 144
dbo.pm_order_summary | 14 | 3 | 8 | 94 <-- 91% low
dbo.pm_recent_orders | 15 | 4 | 61 | 69
Adhoc | 38 | 6 | 2 | 6
Two of the five rows report a number that is not merely stale but wildly wrong (8 instead of 94). Which
rows are wrong changes from run to run — it is a tie-break, not a consistent bias.
Root cause
sys.query_store_runtime_stats returns the flushed slice and the still-in-memory slice of the same
runtime_stats_id as two separate rows. Live from the Azure database (flush_interval_seconds = 900):
runtime_stats_id|plan_id|count_executions|first_execution_time |last_execution_time
12 |2 |136 |2026-07-31 04:20:35.767 |2026-07-31 04:32:18.570 <- flushed, static
12 |2 |17 |2026-07-31 04:20:35.767 |2026-07-31 04:37:59.093 <- in memory, growing
14 |3 |86 |2026-07-31 04:20:35.837 |2026-07-31 04:32:18.580
14 |3 |17 |2026-07-31 04:20:35.837 |2026-07-31 04:37:59.100
15 |4 |61 |2026-07-31 04:20:35.853 |2026-07-31 04:32:18.603
15 |4 |17 |2026-07-31 04:20:35.853 |2026-07-31 04:37:59.117
total_rows|distinct_ids
12 |8
The collector's BuildPayloadBody selects straight from that view with no aggregation, so both slices
are stored. They then share every column of the read-side dedup key —
(database_name, query_id, plan_id, runtime_stats_interval_id, first_execution_time, execution_type_desc, replica_role)
— and the same collection_time, proven in the store:
query_id | plan_id | runtime_stats_interval_id | first_execution_time | execution_type_desc | replica_role | rows_sharing_the_entire_dedup_key | execution_counts
----------+---------+---------------------------+-------------------------+---------------------+--------------+-----------------------------------+------------------
12 | 2 | 1 | 2026-07-31 04:20:35.767 | Regular | Primary | 2 | {8,136}
14 | 3 | 1 | 2026-07-31 04:20:35.837 | Regular | Primary | 2 | {8,86}
15 | 4 | 1 | 2026-07-31 04:20:35.853 | Regular | Primary | 2 | {8,61}
38 | 6 | 1 | 2026-07-31 04:29:04.077 | Regular | Primary | 2 | {2,4}
So both the viewer's ROW_NUMBER() OVER (PARTITION BY ... ORDER BY collection_time DESC) and the CAGG's
last(execution_count, collection_time) are ordering by a value that is identical for both rows. The
survivor is whichever the engine happens to emit first. When the in-memory sliver wins, the grid shows it.
The dedup itself is correct and load-bearing (#1841) — it is there to collapse re-collections of the same
interval across cycles. The bug is that it now also silently collapses two additive slices of one
interval within a single cycle, which is a different thing entirely.
The slices are additive — confirmed three independent ways
-
Arithmetic against the known workload. The seed ran each procedure a known number of times
(100/50/25 in the burst, then one of each per 20s round; ~43 rounds by the last cycle → ~143/93/68).
The sums land on 144/94/69. max() (136/86/61) does not.
-
The product's own procedure_stats collector, reading the entirely separate
sys.dm_exec_procedure_stats, in the same store on the same collection cycle (04:35:03, against
the query_store cycle at 04:35:03.870265 whose slices are tabulated above):
collection_time | object_name | execution_count
----------------------------+---------------------------+-----------------
2026-07-31 04:35:03.753426 | pm_get_orders_by_customer | 144
2026-07-31 04:35:03.753426 | pm_order_summary | 94
2026-07-31 04:35:03.753426 | pm_recent_orders | 69
Exactly the slice sums ({8,136} → 144, {8,86} → 94, {8,61} → 69), and not max() (136/86/61).
The alignment holds at the next observation too: at 04:37:59 the DMV slices were 136+17, 86+17,
61+17 and procedure_stats at 04:38:05 recorded 153 / 103 / 78. This is in-product corroboration
that the correct total is SUM, not last/max.
- The flushed row never absorbs the in-memory one. Across observations at 04:35 and 04:38 the
136
row stayed at 136 with a static last_execution_time, while its twin grew 8 → 11 → 17 with an advancing
one. A superseding row would have replaced it; an additive slice behaves exactly like this.
Why this is not the replica flip (#1872)
sys.query_store_replicas holds one row per replica_group_id (4 groups, 4 names), so the
LEFT JOIN ... ON qsr.replica_group_id = qsrs.replica_group_id the flip added cannot fan out:
replica_group_id|name_rows
1|1
2|1
3|1
4|1
- Both colliding rows carry the same
replica_role (Primary), so the collision is identical to what
it would be with the pre-flip NULL placeholder. replica_role is in the dedup partition either way.
- The duplication is visible in the raw DMV before any join (12 rows / 8 distinct ids, above).
Scope
Fix direction (not applied — this is a design call)
Aggregating the slices belongs on the collect side, where the identity is unambiguous: group the
payload by the natural runtime-stats key and SUM the counters (and take MAX(last_execution_time)),
so one interval yields one row per cycle and the existing cross-cycle dedup keeps working unchanged.
Doing it on the read side instead would mean teaching every consumer the difference between "same interval,
later cycle" (keep latest) and "same interval, same cycle" (add) — the CAGG's last() cannot express that.
I did not implement it: it changes the collector's emitted row shape and the semantics the #1841 dedup was
built around, which is a decision worth making deliberately rather than inside a validation run.
Found by: #1887 live Azure end-to-end smoke. Azure resources torn down.
🤖 Generated with Claude Code
Found during the #1887 end-to-end smoke (live Azure SQL Database, GP_S_Gen5_1, shipped Darling service
from
origin/dev@4adf3236). Not caused by #1872 — see "Why this is not the replica flip" below.The #1887 run's own checks all passed; this is a separate, pre-existing defect that only a sustained
end-to-end run surfaces, which is exactly why the hand-composed SQL in #1848/#1872 never saw it.
Symptom
query_store_statsexecution counts are understated, non-deterministically, on both the viewer gridread (
ViewerDataService.QueryStoreTopSql) and thequery_store_stats_interval_hourlyCAGG that thecorrected/daily rollups are built on.
Live example — the CAGG's
last(execution_count, collection_time)versus the truth, same store, same run:Two of the five rows report a number that is not merely stale but wildly wrong (8 instead of 94). Which
rows are wrong changes from run to run — it is a tie-break, not a consistent bias.
Root cause
sys.query_store_runtime_statsreturns the flushed slice and the still-in-memory slice of the sameruntime_stats_idas two separate rows. Live from the Azure database (flush_interval_seconds = 900):The collector's
BuildPayloadBodyselects straight from that view with no aggregation, so both slicesare stored. They then share every column of the read-side dedup key —
(database_name, query_id, plan_id, runtime_stats_interval_id, first_execution_time, execution_type_desc, replica_role)— and the same
collection_time, proven in the store:So both the viewer's
ROW_NUMBER() OVER (PARTITION BY ... ORDER BY collection_time DESC)and the CAGG'slast(execution_count, collection_time)are ordering by a value that is identical for both rows. Thesurvivor is whichever the engine happens to emit first. When the in-memory sliver wins, the grid shows it.
The dedup itself is correct and load-bearing (#1841) — it is there to collapse re-collections of the same
interval across cycles. The bug is that it now also silently collapses two additive slices of one
interval within a single cycle, which is a different thing entirely.
The slices are additive — confirmed three independent ways
Arithmetic against the known workload. The seed ran each procedure a known number of times
(100/50/25 in the burst, then one of each per 20s round; ~43 rounds by the last cycle → ~143/93/68).
The sums land on 144/94/69.
max()(136/86/61) does not.The product's own
procedure_statscollector, reading the entirely separatesys.dm_exec_procedure_stats, in the same store on the same collection cycle (04:35:03, againstthe
query_storecycle at04:35:03.870265whose slices are tabulated above):Exactly the slice sums (
{8,136}→ 144,{8,86}→ 94,{8,61}→ 69), and notmax()(136/86/61).The alignment holds at the next observation too: at
04:37:59the DMV slices were136+17,86+17,61+17andprocedure_statsat04:38:05recorded153 / 103 / 78. This is in-product corroborationthat the correct total is
SUM, notlast/max.136row stayed at 136 with a static
last_execution_time, while its twin grew 8 → 11 → 17 with an advancingone. A superseding row would have replaced it; an additive slice behaves exactly like this.
Why this is not the replica flip (#1872)
sys.query_store_replicasholds one row perreplica_group_id(4 groups, 4 names), so theLEFT JOIN ... ON qsr.replica_group_id = qsrs.replica_group_idthe flip added cannot fan out:replica_role(Primary), so the collision is identical to whatit would be with the pre-flip
NULLplaceholder.replica_roleis in the dedup partition either way.Scope
SQL Database only. Whether box SQL Server surfaces the same same-cycle duplicate pair is untested and
should be checked before assuming either way.
query_store_stats_interval_hourlyand everything derived from it(
query_store_stats_corrected_hourly, the daily tiers) plus the viewer's Query Store grid. Related to butdistinct from Query Store corrected rollups: the DAILY tier still carries a ~2x hour-straddle residual — an interval-grain daily re-dedup would remove it, at a fourth near-raw-cardinality CAGG #1869, which is about an hour-straddle residual in the daily tier, not a same-cycle tie.
GetQueryStoreTopQueriesAsync) against DuckDB and looks to have the same tie —not verified live, flagged for the parity check rather than asserted.
Fix direction (not applied — this is a design call)
Aggregating the slices belongs on the collect side, where the identity is unambiguous: group the
payload by the natural runtime-stats key and
SUMthe counters (and takeMAX(last_execution_time)),so one interval yields one row per cycle and the existing cross-cycle dedup keeps working unchanged.
Doing it on the read side instead would mean teaching every consumer the difference between "same interval,
later cycle" (keep latest) and "same interval, same cycle" (add) — the CAGG's
last()cannot express that.I did not implement it: it changes the collector's emitted row shape and the semantics the #1841 dedup was
built around, which is a decision worth making deliberately rather than inside a validation run.
Found by: #1887 live Azure end-to-end smoke. Azure resources torn down.
🤖 Generated with Claude Code