Skip to content

get_query_store_top reads about 1 GB per call at its 24 h default, and about 30 s at 168 h, after the per-interval table and BRIN work #4952

Closed

Description

@erikdarlingdata

Class: bug (performance). On a 43-server SQL Server store running v3.9.0, get_query_store_top costs 5-10 s and 0.5-1.1 GB of disk reads per call at its default hours_back = 24. At its widest window, 168 h, the main read alone takes about 28 s, against the mcp role's 60 s statement_timeout.

Most of the cost isn't the rows the window needs:

  • the gate's legacy-row probe reads the server's whole raw Query Store slice just to answer "no";
  • the table read walks every index entry the server has.

Census (the service's own read-latency histograms, get_read_latency hours=168 surface=mcp, about 4.5 h since the install)

route calls mean p95 (bucket bound) p99 (bucket bound) max timeouts
get_query_store_top 209 14.6 s ≤ 45 s ≤ 60 s 54.8 s 0

The store's pg_stat_statements (mcp role) shows the same two statements every call:

  • The table read (QueryStoreTopTableSql): 201 calls, mean 10.2 s, max 38.0 s. It read 22.4 M shared blocks from outside shared buffers, about 870 MB per call, at a 2.5 % hit ratio.
  • Clause 6's legacy-row probe (QueryStoreIntervalWide.HasLegacyRowSql): 201 calls, mean 4.1 s, max 16.4 s. It read 6.8 M blocks, about 260 MB per call.

This one tool's calls held the store volume at its 250 MB/s throughput cap for about five minutes, and slowed an unrelated export about 5x.

Plans (EXPLAIN (ANALYZE, BUFFERS), the exact statements as bound, one run each)

case statement time shared read / hit rows
heaviest QS server, 24 h legacy probe 2.67 s 56,074 / 9,208 0 returned; 855 k raw rows visited
heaviest QS server, 24 h table read 7.66 s 86,486 / 3,628 407,812 table rows
median QS server, 24 h legacy probe 1.32 s 25,145 / 4,399 0 returned
median QS server, 24 h table read 3.45 s 39,101 / 1,967 189,140 table rows
heaviest QS server, 168 h legacy probe (from the raw floor, 4.2 days) 2.84 s 72,319 / 16,458 0 returned; ~3.5 M raw rows visited
heaviest QS server, 168 h table read 28.16 s 307,916 / 321,489 2,024,349 table rows
heaviest QS server gate floor read (PlainTableFloorSql) 2.8 ms 10 / 2 not a cost

What the plans do:

  • Legacy probe:
    • It does a Bitmap Index Scan on each raw chunk's (server_id, collection_time) index, then a Bitmap Heap Scan.
    • Filter: (interval_start_time_utc IS NULL) then discards every row, 855 k at 24 h.
    • It is an EXISTS that finds nothing, so it has to fetch every heap page the server's window touches.
  • Table read, 24 h:
    • It does a BitmapAnd of ix_query_store_interval_wide_collection_time_brin (all servers' 24 h) and ux_query_store_interval_wide (Index Cond: server_id = $1 AND first_execution_time >= $2 - 1560 min).
    • The ux side alone took 4.34 s and 32,787 index blocks, more than half the statement.
    • first_execution_time is the 7th key of that index (after database_name, runtime_stats_interval_id, plan_id, query_id, replica_role). The condition therefore cannot narrow the range: the scan reads every entry the server has and tests the bound on each one. It kept 845 k entries, out of about 2.1 M the server holds (estimated from the index's size, inferred).
    • The heap side is 56,328 lossy blocks for 407 k rows, then a 70 MB external sort.
  • Table read, 168 h:
    • The planner switches to a plain Index Scan on ux_query_store_interval_wide, in index order, feeding an Incremental Sort. That is random heap access for 2.02 M rows: 24.0 s of the 28.2 s is shared-read I/O.
    • The sort spilled 84 MB.

What last week's tuning already changed on this path

Suspected cause

  1. The legacy-row probe pays for a full raw read to return false (measured).
    • HasLegacyRowSql's remarks call it "a bounded, indexed (server_id, collection_time) read on the common path" that "costs nothing extra there".
    • On this store it is 25-40 % of a call's I/O: 440-565 MB to prove that no interval_start_time_utc IS NULL row exists.
    • It visits the same raw rows the raw path itself would read, so the table route's "saving" is spent before the table read starts (inferred: the raw path wasn't EXPLAINed here).
  2. ux_query_store_interval_wide can't range-scan server_id + first_execution_time (measured): the 24 h index walk reads the server's whole index range.
  3. The 168 h plan is an ordered index scan with random heap fetches (measured). It sits at half the mcp role's 60 s timeout warm. The census max, 54.8 s, is within 6 s of the timeout.
  4. The heap rows themselves are the window's share (measured). fillfactor = 50 doubles the pages they occupy. That is by design (HOT updates).

Options (inferred, for whoever designs the fix; not measured)

  • For the probe, either:
    • a partial index on query_store_stats (server_id, collection_time) WHERE interval_start_time_utc IS NULL. It would be empty on a field store, so it costs near-zero writes and turns the probe into an index probe; or
    • retiring the probe once raw retention has aged past every pre-tier-2 row (recorded once, not re-proven per call).
  • For the index walk: a btree on (server_id, first_execution_time).
  • The tool description doesn't tell the caller that a wide window costs tens of seconds.

Code:

  • DarlingDataReader.GetQueryStoreTopWithReachAsync / TryGetQueryStoreTopFromTableAsync;
  • QueryStoreIntervalWide.ResolveReadAsync (clause 6), HasLegacyRowSql, PlainTableFloorSql;
  • V145's ux_query_store_interval_wide.

Activity

  1. added
    bugSomething isn't working
    in-progressActively being worked by a local session or its agents (PR open or in flight)
    on Oct 2, 2026
  2. erikdarlingdata commented on Oct 2, 2026

    @erikdarlingdata
    OwnerAuthor

    Part 1 (cache the legacy-row probe's "no") cannot be built as planned: its soundness premise fails, narrowly. No code was changed. This needs a ruling.

    The planned cache remembers, per store and server, the earliest window start for which QueryStoreIntervalWide.HasLegacyRowSql returned false, and skips the probe for later calls that start at or after it. That is sound only if no current write path stores interval_start_time_utc NULL into collect.query_store_stats, so that every row written after a proven-clean start is a current-shape row. I checked every writer. One of them can still store NULL.

    The write paths

    • The collector (can store NULL).
      • PerformanceMonitor.Collectors/QueryStoreCollector.cs: the final SELECT does LEFT JOIN sys.query_store_runtime_stats_interval AS qsrsi (about line 1069), and its comment says "interval_start_time_utc and interval_end_time_utc go NULL if the join misses" (about line 816).
      • ReadRowsAsync maps that NULL to a null IntervalStartTimeUtc (about line 1637). The only client-side row skip is the self-query marker (about line 1648). The payload writer writes the value as it is (about line 1846).
      • The LEFT JOIN is deliberate and pinned: Lite.Tests/QueryStoreCollectorDefinitionTests.cs (about line 1354) asserts the LEFT JOIN and refuses an inner JOIN.
      • DarlingCollectorRunner.cs (about line 4175) is the one binary COPY into query_store_stats. Its Alert pass: the forced-plan failure read re-runs its two-hour aggregate on every pass, between collections too #4659 comment calls it the one place every Query Store row is written: the live fan-out, overlapped runs, the re-attempt and the backfill all go through it. Nothing drops or fills in a NULL start before it.
    • QueryStoreSliceRepair (cannot create NULL). It re-inserts existing rows and combines each column with min(), which skips NULLs. It can carry a NULL that is already there, never create one.
    • QueryStoreIntervalWide.UpsertSql writes query_store_interval_wide, not query_store_stats.
    • tools/SliceRepairRepro is a repro tool, not product code.

    How a NULL can arrive

    The slice staged ahead of that SELECT is limited to interval ids found in the same view (about lines 991-997), under READ UNCOMMITTED (about line 952). A miss therefore needs Query Store to remove an interval row between the two statements of one batch, for example during its own cleanup. That is rare. This issue's plans also found no such row in the windows they probed (0 returned from about 855 k raw rows at 24 h, and about 3.5 M at 168 h on the heaviest server). But that is a sample, and the code allows the case.

    Consequence for the cache

    A join-miss row has its runtime_stats_interval_id and a NULL start. Today's probe matches it, so any window that contains it reads raw. A cached "no" would skip the probe for that window and read query_store_interval_wide, which keeps only the latest snapshot of the interval. A read that places NULL-start rows at collection_time and needs every snapshot of them (the trend reads' second arm) would see one snapshot instead of all of them. The top read itself would count that row correctly, since its identity is intact.

    Related: the remarks on HasLegacyRowSql define a legacy row as one with no runtime_stats_interval_id, but the SQL tests only interval_start_time_utc IS NULL. A join-miss row matches the SQL and not the definition. The predicate is wider than its documented intent.

    Options (not decided here)

    1. Accept the join-miss case as a residual, and say so in the cache's comment (a row written after the entry is read from the interval table until the process restarts).
    2. Bound the staleness: give a cached "no" a time to live, so a later NULL row is seen within that time. The store's watermark cache already re-reads hourly for the same kind of reason (DatabaseWatermarkCache).
    3. Change the write side so a join miss no longer stores a NULL start. That alters the deliberate LEFT JOIN design in the collector.

    Lite

    Lite has no per-call probe. It has no interval table and no table-or-raw decision, so it has no counterpart to clause 6. The one interval_start_time_utc IS NULL in Lite/Services/LocalDataService.QueryStore.cs (about line 995) is the legacy arm of the duration-trend read, an inline partition. GetQueryStoreTopQueriesAsync runs no probe, so Lite needs no change for part 1.

    Part 2 (the index options) is not affected by this finding.

  3. erikdarlingdata commented on Oct 3, 2026

    @erikdarlingdata
    OwnerAuthor

    Fixed in part by #4969, now on dev: the table read uses a new background btree. The rest is tracked in #4972 (the legacy-row probe) and #4971 (the viewer's table read at 168 h and the duration-trend read).

  4. removed
    in-progressActively being worked by a local session or its agents (PR open or in flight)
    on Oct 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions