Skip to content

Query Store backfill: the candidate database list reads the newest chunks by index probe, same list (#4662) - #4681

Closed
erikdarlingdata wants to merge 2 commits into
devfrom
feature/4662-qs-backfill-candidate-read
Closed

erikdarlingdata wants to merge 2 commits into
devfrom
feature/4662-qs-backfill-candidate-read

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Sep 28, 2026 •

Copy link
Copy Markdown
Owner

Closes #4662.

Why

QueryStoreBackfill.CandidateSql aggregated the whole raw window (about four days) of one server's Query Store rows on every backfill tick just to list about a dozen database names. On the uncompressed day-chunks that is a full Index Only Scan over every row the server wrote.

What changes

Only QueryStoreBackfill.cs (the SQL, its doc comment, one optional parameter) and the candidate tests. The list is unchanged: same set, same order.

The window is read as two halves split at $3, so each half gets its cheap plan:

  • Cold half (floor, $3]: the compressed chunks. Same decompress-and-hash as before (database_name is a compressed column with no index there).
  • Hot half (floor, infinity) and > $3: the newest uncompressed chunks. TimescaleDB plans a SkipScan down the existing (server_id, database_name, ...) index, about one probe per database.

$3 = start of the UTC day TimescaleSupport.CompressAfterDays back (start of yesterday). Day-chunks compress only once their END is older than compress_after, so yesterday's and today's chunks are uncompressed in steady state.

Exact for ANY $3, by construction: both halves carry collection_time > $2, so a $3 below the floor makes the cold half empty and the hot half the whole old window. A wrong $3 can only cost speed. UNION (not ALL), one outer ORDER BY database_name, no COLLATE. MergeHoleDatabases is untouched. It sorts ordinally, so the final list order never came from the SQL; the ORDER BY is kept and pinned anyway.

GetCandidateDatabasesAsync gets a trailing optional DateTime? hotSince = null (test seam; default derives from the wall clock).

Rig measurement (EXPLAIN ANALYZE, BUFFERS)

Local TimescaleDB 2.30.1 / PG 18.6, real collect.query_store_stats shape: 1-day chunks, segmentby server_id, the three real indexes. Server 1: 1,056,000 rows, 20 databases; server 2: 633,600 rows, 12 databases; 5.5 days of 15-minute ticks. Chunks older than yesterday compressed (4 compressed, 2 uncompressed, as the 1-day policy leaves them). Warm, medians of 6-8 runs, default parallel settings.

Server 1 form ms median (min) buffers hit+read
OLD SELECT DISTINCT ... ORDER BY 87.1 (79.2) 5,248
A GROUP BY database_name 116.4 (102.6) 5,204
A2 GROUP BY with count(*) in a subquery 90.7 (77.6) 5,235
B recursive-CTE loose index scan 1,772.6 (1,733.7) 28,483
C DISTINCT ON over the whole window (SkipScan everywhere) 277.1 (267.8) 1,567
Hot half alone (what SkipScan buys) 0.8 154
  • A is slower (no parallel plan). A2 ties OLD: no vectorized aggregation node appears for grouping on this text column, so there was nothing to win. B decompresses every compressed batch on each of its 20 steps. C saves buffers but must sort every decompressed row for its Merge Append.
  • Shipped split, same server 1: 88.4 (85.7) ms / 5,248 buffers OLD vs 53.2 (48.0) ms / 1,726 buffers (-40% time, -67% buffers).
  • Server 2: 66.9 (57.6) ms / 3,265 OLD vs 43.0 (37.7) ms / 1,199 shipped.
  • The bound-parameter path (psql \bind, unnamed statement like Npgsql) plans the same: cold half decompresses the compressed chunks only, SkipScan on the two hot chunks.

Risk, measured. If yesterday's chunk is ALSO compressed (a compress_after shorter than 1 day; not the policy's steady state), the hot half falls off the SkipScan path and the split loses: server 1 98.4 ms / 4,788 buffers vs OLD 73.2 ms / 4,045; server 2 122.5 (69.3 min) ms vs OLD 57.4 ms. Results stay exact; only the speed goes. The split point follows CompressAfterDays, so changing that constant moves it with it. This is why the production EXPLAIN matters.

SQL for production EXPLAIN

Correction (found in verification): the floor is nowUtc - HorizonFor(_timescaleAvailable). On a TimescaleDB store that is 23 hours: RollupStoreHorizon is the smaller of RawMaxAge (4 days minus the 1-day route margin, so 3 days) and the hourly refresh reach (1 day minus 1 hour). On plain PostgreSQL it is 3 days, with no compression. It is never 4 days, so the rig table above measured a window the service does not read. At 23 hours the split point (the start of yesterday, UTC) is always older than the floor. The first half is then empty by its own predicate, and the second half is the old query. The literal forms below use the 23-hour floor. On a store without TimescaleDB, use 3 days.
OLD, bound form: SELECT DISTINCT database_name FROM query_store_stats WHERE server_id = $1 AND collection_time > $2 ORDER BY database_name

OLD, literal:

SELECT DISTINCT database_name FROM query_store_stats
WHERE server_id = <id> AND collection_time > (now() AT TIME ZONE 'UTC') - interval '23 hours' ORDER BY database_name;

NEW, bound form ($1 int, $2 and $3 timestamp):

SELECT database_name FROM (SELECT DISTINCT database_name FROM query_store_stats WHERE server_id = $1 AND collection_time > $2 AND collection_time <= $3 UNION SELECT DISTINCT database_name FROM query_store_stats WHERE server_id = $1 AND collection_time > $2 AND collection_time > $3) AS c ORDER BY database_name

NEW, literal:

SELECT database_name FROM (
  SELECT DISTINCT database_name FROM query_store_stats
   WHERE server_id = <id> AND collection_time > (now() AT TIME ZONE 'UTC') - interval '23 hours'
     AND collection_time <= date_trunc('day', now() AT TIME ZONE 'UTC') - interval '1 day'
  UNION
  SELECT DISTINCT database_name FROM query_store_stats
   WHERE server_id = <id> AND collection_time > (now() AT TIME ZONE 'UTC') - interval '23 hours'
     AND collection_time > date_trunc('day', now() AT TIME ZONE 'UTC') - interval '1 day'
) AS c ORDER BY database_name;

collection_time is timestamp and Npgsql binds UTC, so the literal forms read the floor as timestamp in UTC, whatever the session TimeZone. Check in the plan: SkipScan on the chunks from start of yesterday, ColumnarScan only on older chunks, and that the newest two chunks are uncompressed (timescaledb_information.chunks). A form that is not faster there does not merge.

RED runs (each mutation applied to CandidateSql, then reverted)

Failing tests are the shape pin (Sql_CandidateSql_IsTheExactSplitShape) plus the live ones named.

  1. Misses the compressed-only database (hot half only): shape pin, new exactness test (Beta, Gamma missing: first element _x, expected Beta), and the existing CandidateDatabases_ServicesAQuietHole_... (new_db missing).
  2. >= for >: shape pin and the new exactness test (floor_db appears; "Collections differ at index 5").
  3. Different order. COLLATE "C": shape pin and the new test (raw row sequence differs from the old statement's). lower(): the shape pin only. The live test cannot tell it apart, because this cluster's collation (Windows libc English_United States) already sorts these names case-insensitively, the same as lower(). State that honestly: COLLATE "C" is the live-order RED.
  4. Drops server_id (WHERE $1 > 0 AND ...): shape pin, the new test, CandidateDatabases_ServicesAQuietHole_... and CandidateRead_BoundAtItsOwnHorizon_ScansNoCompressedChunk.

Lite

Unchanged, on purpose. DuckDB has no chunk or compression tiers, so a split has nothing to exploit and two scans plus a UNION can only add cost. I did not measure DuckDB. The Lite twin pin (Sql_LiteTwinCarriesTheSameBoundShapes) passes.

Test plan

  • QueryStoreBackfillCandidateLiveTests and QueryStoreBackfillTests: 18 passed, 0 skipped, on the rig (before the origin/dev merge).
  • New live test seeds two servers with shared names: active, compressed-only, both halves, exactly on the split, one microsecond past it, older than the floor, exactly at the floor (excluded), NULL name, hole-only. It asserts a hard-coded list per server (ordinal, because MergeHoleDatabases sorts ordinally), the old SQL plus the merge, six split points (the seeded one, the production default, before the floor, at the floor, after every row, on the newest row), and the raw row sequence against the old statement. The existing plan-shape test now binds a literal $3.
  • Build: 0 warnings, 0 errors.
  • Full Darling suite, once, after merging origin/dev, fresh database, UTC rig: Total 16683, Failed 2, Skipped 64, Not Run 1 (765 s). Both failures are in classes this PR does not touch. (1) WaitRateTileReadCountLiveTests.Waits_DetectAnomaliesAsync_ReadsWaitRateTileWindowSqlOnce_NotTwice: passes when its class is re-run alone on a freshly created database (load-sensitive under the full run). (2) DarlingCliCommandsHostCheckTests.CheckSettingsAsync_ManagedStoreWithAStaleBlock_ReturnsStaleSettingsExitCode_Gated: still fails alone on the rig with 42P01 relation "collect.managed_conf_verdicts" does not exist from DarlingStoreHostProfile.ReadStoredManagedConfVerdictsAsync. The test creates an EMPTY darling database when the cluster has none (this bare rig had none; the CI cluster creates and migrates one), so it looks like a rig difference, and this diff touches neither DarlingStoreHostProfile nor a migration. I did NOT confirm it against a clean dev checkout or a dev CI run (dev latest Build runs were cancelled or queued when I looked; the last success was c9847bf at 23:03Z), so it needs checking on CI.
  • Production EXPLAIN.

What to double-check

  • The production EXPLAIN, and that the newest two chunks are uncompressed there.
  • DateTime.UtcNow inside the method chooses only the split point; results do not depend on it.

Addendum: rig pass on the current read and a recursive walk

Rig and design pass on the backfill candidate read (#4662)

This section changes no code.

Result in one paragraph

On the rig, the one plan flip that stale or missing statistics were supposed to cause never happened. A custom plan never flipped, for either form, with fresh, stale (reltuples at 68% of live) or missing (pg_statistic deleted) statistics on the newest chunk and the boundary chunk. Two things did make the read bad:

  1. A bound floor under force_generic_plan. The old form reproduces the production bad plan exactly: Gather Merge over ChunkAppend over an Index Only Scan on idx_query_store_stats_server_time_forcing, 188-327 ms and 12-21K buffers. The recursive walk with a bound $2 is as bad (159-313 ms, 44-50K buffers), because a name-ordered Merge Append gets no runtime chunk exclusion, so every chunk is opened on every step, compressed ones included.
  2. Compressed chunks inside the window. The old form goes to 143-235 ms, and the recursive walk to 1.4-3.8 s (a Sort over the decompressed batches on every step).

Inlining the floor as a TIMESTAMP literal removes the first failure in every condition tested (at most 2.74 ms and 614 buffers), because chunk exclusion then happens at plan time in every plan_cache_mode. The second failure is only reachable if a chunk inside the 23 h window is compressed. The product policy (TimescaleSupport.CompressAfterDays = 1, which compresses a chunk 24 h after its end) never does that. It is still worth checking on the production store (read-only query below), because its signature fits the production numbers better than stale statistics do.

Recommendation

  • The change that carries the fix: inline the floor as a TIMESTAMP literal and keep server_id bound. Format it in C# with the invariant culture as yyyy-MM-dd HH:mm:ss.ffffff. That truncates to microseconds, which is the same boundary Npgsql's DateTime binding produces today, so collection_time > floor means exactly the same thing. The value is computed internally and is never user input.
  • Pick the form per store. The worker already knows which store it is on (_hasContinuousAggregates).
    • TimescaleDB store: keep SELECT DISTINCT … ORDER BY database_name, with the literal floor. SkipScan was chosen in every uncompressed condition, in both plan modes. With compressed chunks in the window it degrades to 175-235 ms, not the 1.4-3.4 s the walk takes there.
    • Plain PostgreSQL store: use the recursive walk. SkipScan does not exist without TimescaleDB, and the old form is bad there in both modes (366 ms-1.14 s, 30-227K buffers). The walk takes 0.33-0.45 ms and 110 buffers in both modes.
  • Single-form alternative: use the walk with the literal floor everywhere. That form is good in every state the product's own policy can reach, on both stores. Its cost is the 1.4-3.4 s worst case if a window chunk is ever compressed.

Rig

  • PostgreSQL 18.6 and TimescaleDB 2.30.1 on port 5571, UTC, with the product's three indexes:
    • (server_id, collection_time);
    • (server_id, database_name, query_id, plan_id, collection_time DESC), called (A) below;
    • (server_id, collection_time DESC) INCLUDE (database_name, …), called (B) below.
  • Compression uses segmentby server_id.
  • The product sets ChunkIntervalDays = 1 (TimescaleSupport). There is no 12 h or 6 h setting on dev, so the production interval change was made outside the product. I seeded it the way you described: 1-day chunks from 09-22 to 09-24, 12 h chunks on 09-25 and 09-26, and 6 h chunks from 09-27 to 09-28 22:30. Chunks ending by 09-27 12:00 are compressed.
  • Server 1 has 24 databases and 3.9M rows, about 1.5M a day and about 375K per 6 h chunk; a full index-only scan of one chunk is thousands of buffers. To exercise the skip trap:
    • db_05 stops at 09-27 20:00, db_13 at 19:00 and db_21 at 23:00;
    • db_24 starts at 09-28 20:00.
  • Server 2 has 2 databases and 64K rows.
  • Servers 3 and 4 hold the exactness edge cases.
  • Every run was PREPARE plus three EXPLAIN (ANALYZE, BUFFERS) EXECUTE calls. The table gives the median time and the buffers of the last run.
  • The floors are closed-chunk positions for a 23 h horizon:
    • last hour: 09-27 23:30 in chunk [18:00, 24:00), so now = 09-28 22:30;
    • first hour: 18:30 in the same chunk;
    • mid: 15:00 in [12:00, 18:00).
  • Data runs to 22:30 for every floor, so the newest chunk is somewhat larger than it would be at the earlier "now" values. That only inflates the generic-plan numbers.

Form × condition (server 1, 24 DBs; ms / buffers / plan)

Forms:

  • OLD: today's SQL, floor bound as $2.
  • REC: the recursive walk, floor bound as $2.
  • OLD_L and REC_L: the same two forms with the floor as a literal.

"c" is force_custom_plan and "g" is force_generic_plan.

Condition OLD c OLD g REC c REC g OLD_L g REC_L g
6 h, fresh stats, last hour 2.50 / 595 SkipScan 239.96 / 12,295 Gather Merge→IOS forcing idx 1.98 / 597 MergeAppend (A) 175.33 / 44,743 all chunks + Sort/ColumnarScan n/r n/r
6 h, fresh, first hour 1.17 / 410 SkipScan 263.41 / 15,110 1.26 / 453 199.85 / 50,080 n/r n/r
6 h, fresh, mid 1.21 / 500 315.90 / 17,246 1.53 / 544 6.50 / 8,219 n/r n/r
6 h, newest chunk stale (68%), last hour 2.03 / 612 SkipScan 327.39 / 18,587 2.17 / 614 312.76 / 44,760 2.74 / 612 SkipScan 2.44 / 614 MergeAppend (A)
6 h, stale, first hour 1.07 / 427 276.38 / 21,375 1.31 / 473 182.42 / 50,100 1.04 / 427 1.35 / 473
6 h, no pg_statistic on newest AND boundary chunk, last hour 1.76 / 612 SkipScan 187.89 / 15,657 2.13 / 614 159.17 / 44,760 1.84 / 612 SkipScan 2.57 / 614
6 h, no stats, first hour 1.36 / 427 258.37 / 18,820 1.35 / 473 180.84 / 50,100 1.03 / 427 1.31 / 473
6 h, boundary + next chunk compressed, last hour (not reachable under product policy) 234.67 / 33,199 Gather Merge→ColumnarScan 143.29 / 11,037 1,808.75 / 26,111 1,556.29 / 91,509 177.42 / 1,415 1,367.33 / 26,111
same, first hour 163.46 / 34,966 156.44 / 12,835 3,550.78 / 73,870 3,795.65 / 147,472 180.36 / 11,684 3,227.46 / 73,870
1-day chunks (product default), last hour (23.5 h of the boundary chunk below the floor) 6.71 / 1,693 SkipScan 6.63 / 1,743 5.92 / 1,692 7.69 / 2,792 5.85 / 1,693 7.49 / 1,692
1-day, first hour 0.75 / 149 1.32 / 199 2.09 / 157 1.60 / 1,407 1.11 / 149 1.73 / 157
1-day, mid 0.75 / 149 0.91 / 199 1.04 / 157 1.54 / 1,407 0.91 / 149 0.90 / 157
Plain PostgreSQL (a database without TimescaleDB, 3.2M rows, 3-day floor) 374.10 / 103,616 Gather Merge→Seq Scan 1,143.39 / 30,485 IOS on (B) 0.45 / 110 IOS (A) 0.33 / 110 429.22 / 227,437 (OLD_L g) 0.37 / 110
  • In the rows where I ran them, OLD_L c and REC_L c matched OLD c and REC c to within noise; for example, stale / last hour gave 2.06 / 612 and 2.32 / 614.
  • "n/r": I added the literal forms after the fresh-stats pass. The only differences between "fresh" and the stale and no-stats rows are on chunks 15 and 11, and every custom and literal cell in those rows is the same plan at the same cost.
  • The 1-day copy holds only 09-26 to 09-28, so its generic numbers understate a full-retention store.
  • Server 2 (2 DBs) behaves the same way at a smaller scale:
    • custom plans run 0.17-0.48 ms and 59-83 buffers for every form;
    • OLD g runs 40-50 ms, and 8.9K buffers when the newest chunk is stale;
    • REC g runs 1.4-2.7 ms and about 1,066 buffers, because it opens every chunk;
    • OLD_L g and REC_L g run 0.18-0.51 ms and 62-72 buffers;
    • on plain PostgreSQL, OLD takes 7.18 ms / 1,977 buffers (c) and 35.85 ms / 556 buffers (g), against 0.06 ms / 14 buffers for REC.
  • On the skip trap, the walk and SkipScan pay the same cost, because both skip the boundary chunk's rows that sit below the floor. With 6 h chunks it adds about 150 buffers at the last-hour floor. With 1-day chunks it adds about 1,540 (149 → 1,693), about 6-7 ms. That is bounded by one chunk of (A) entries for the server, and it was never a flip.

Exactness

I compared the walk against the old SQL, after applying the reader's rules (drop NULL, then MergeHoleDatabases' ordinal sort). The comparison covered 5 servers (24 DBs, 2 DBs, edge cases, NULL-only, nonexistent), 8 floors (including one inside the compressed era) and both stores. Result: 0 mismatches. Edge cases:

  • Beta, beta and BETA are all kept.
  • Ünïcode, unicode and unicode (trailing space) are all kept.
  • '' is kept.
  • A row exactly at the floor is excluded, and floor + 1 µs is included.
  • A row 1 s below the floor in the boundary chunk is excluded.
  • A compressed-only DB appears only when the floor reaches its chunk, and it appears in both forms.
  • The NULL-only server gives an empty list in both.

NULL handling is the same in effect. The old SQL returns a NULL row, which the reader skips (IsDBNull). The walk never emits NULL: its anchor has IS NOT NULL, > never matches NULL, and the final filter drops the terminating NULL. Row order cannot matter, because MergeHoleDatabases re-sorts ordinal. A hole-only database comes from state, not SQL, so it is unaffected. DISTINCT and > both follow the deterministic column collation, so the walk visits every distinct value.

Chosen SQL

The product form: $1 is server_id, and <floor> is inlined by C# as now − HorizonFor(...), formatted yyyy-MM-dd HH:mm:ss.ffffff with the invariant culture.

TimescaleDB store:

SELECT DISTINCT database_name FROM query_store_stats
WHERE server_id = $1 AND collection_time > TIMESTAMP '<floor>'
ORDER BY database_name

Plain PostgreSQL store (or everywhere, for the single-form option):

WITH RECURSIVE walk AS (
    (SELECT s.database_name FROM query_store_stats AS s
     WHERE s.server_id = $1 AND s.collection_time > TIMESTAMP '<floor>' AND s.database_name IS NOT NULL
     ORDER BY s.database_name LIMIT 1)
    UNION ALL
    SELECT (SELECT s.database_name FROM query_store_stats AS s
            WHERE s.server_id = $1 AND s.collection_time > TIMESTAMP '<floor>' AND s.database_name > w.database_name
            ORDER BY s.database_name LIMIT 1)
    FROM walk AS w
    WHERE w.database_name IS NOT NULL
)
SELECT database_name FROM walk WHERE database_name IS NOT NULL

The literal production form is the same text with the floor filled in. For a run at 00:30Z on 2026-09-29, it is TIMESTAMP '2026-09-28 01:30:00'. Recompute it at run time with SELECT (now() AT TIME ZONE 'UTC') - interval '23 hours';.

PREPARE / EXPLAIN EXECUTE script (both plan modes)

Replace the floor and <id>. This is read-only. plan_cache_mode is checked on every EXECUTE.

SET search_path = collect, public;
PREPARE cand_ts(integer) AS
  SELECT DISTINCT database_name FROM query_store_stats
  WHERE server_id = $1 AND collection_time > TIMESTAMP '2026-09-28 01:30:00' ORDER BY database_name;
PREPARE cand_walk(integer) AS
  WITH RECURSIVE walk AS (
      (SELECT s.database_name FROM query_store_stats AS s
       WHERE s.server_id = $1 AND s.collection_time > TIMESTAMP '2026-09-28 01:30:00' AND s.database_name IS NOT NULL
       ORDER BY s.database_name LIMIT 1)
      UNION ALL
      SELECT (SELECT s.database_name FROM query_store_stats AS s
              WHERE s.server_id = $1 AND s.collection_time > TIMESTAMP '2026-09-28 01:30:00' AND s.database_name > w.database_name
              ORDER BY s.database_name LIMIT 1)
      FROM walk AS w WHERE w.database_name IS NOT NULL)
  SELECT database_name FROM walk WHERE database_name IS NOT NULL;
PREPARE cand_old(integer, timestamp) AS   -- today's bound form, for contrast
  SELECT DISTINCT database_name FROM query_store_stats WHERE server_id = $1 AND collection_time > $2 ORDER BY database_name;

SET plan_cache_mode = force_custom_plan;
EXPLAIN (ANALYZE, BUFFERS) EXECUTE cand_ts(<id>);
EXPLAIN (ANALYZE, BUFFERS) EXECUTE cand_walk(<id>);
EXPLAIN (ANALYZE, BUFFERS) EXECUTE cand_old(<id>, TIMESTAMP '2026-09-28 01:30:00');
SET plan_cache_mode = force_generic_plan;
EXPLAIN (ANALYZE, BUFFERS) EXECUTE cand_ts(<id>);
EXPLAIN (ANALYZE, BUFFERS) EXECUTE cand_walk(<id>);
EXPLAIN (ANALYZE, BUFFERS) EXECUTE cand_old(<id>, TIMESTAMP '2026-09-28 01:30:00');  -- expect the forcing-index scan
RESET plan_cache_mode;
DEALLOCATE ALL;

Read-only check on the production store for the compressed-in-window hypothesis:

SELECT chunk_name, range_start, range_end, is_compressed
FROM timescaledb_information.chunks
WHERE hypertable_name = 'query_store_stats' AND range_end > (now() AT TIME ZONE 'UTC') - interval '23 hours'
ORDER BY range_start;
SELECT schedule_interval, config FROM timescaledb_information.jobs
WHERE hypertable_name = 'query_store_stats' AND proc_name = 'policy_compression';

Why the plan can't flip

  1. The literal floor removes the plan-mode dependency. A generic plan and a custom plan see the same constant, so chunk exclusion happens at plan time in both. With a bound floor, a generic plan cannot exclude chunks inside a name-ordered Merge Append: PostgreSQL's run-time pruning does not apply to inheritance chunks, and TimescaleDB's ChunkAppend does its startup exclusion only for unordered or time-ordered appends. That was the only flip reproduced on uncompressed data. The same thing happens if plan_cache_mode is set at the role or database level, and after Npgsql auto-prepare switches to a generic plan: even an unnamed statement gets a generic plan under force_generic_plan.

  2. The walk has one sane plan per step. Each step is ORDER BY database_name LIMIT 1 with database_name > prev as an index condition on (A). The only alternative is a (B) range scan plus a Sort, which reads every in-window row of that chunk before it returns one. The planner picks that only if it believes the chunk has almost no rows above the floor, and that belief can only be right where it is actually cheap:

    • For a chunk wholly inside the window, every row qualifies. Missing statistics give 1/3, stale statistics give a histogram that sits entirely above the floor, and PostgreSQL scales reltuples to the current page count.
    • For the boundary chunk, a low estimate means few rows above the floor.

    Measured: the same plan in every mode and statistics state, on both stores.

  3. DISTINCT with SkipScan is cost-based, and that is why it needs the literal. Once the literal bounds it to the 2-5 chunks in the window, its only alternative reads the window once instead of every chunk. Where it did switch (compressed chunks in the window), the alternative was still the better plan: about 150-235 ms against the walk's 1.4-3.8 s.

What I could not confirm

  • The inferred cause (a custom plan on stale statistics). I could not reproduce it: 0 flips in 24 custom-plan cells across fresh, stale and missing statistics. The production numbers (145.7 ms/call at about 714 buffers/call in 22:35-23:44, with the floor in the last hour of the 18:00-24:00 chunk and then the first hour of the 00:00 chunk) are high on time and low on buffers. That is the decompression signature: OLD_L g with two compressed window chunks measured 177 ms and 1,415 buffers. The chunk query above will show whether a window chunk was compressed at the time. If one is, neither form fixes it; the compression timing needs to change. That needs a decision.
  • Lite. Lite's backfill reads DuckDB and has no plan cache, so it is outside this rig pass.

Addendum: candidate designs for a read that does not walk

Status: design only. No candidate has been measured. Every cost marked "expected" is unmeasured. The seed and bench plan at the end are ready for a measurement run.

Call rate

The backfill loop ticks every 5 minutes (s_queryStoreBackfillInterval, DarlingWorker.cs:458) and runs the candidate read once per server per tick, so about 500 calls an hour is about 42 servers × 12 ticks. That is a fixed rate, not a loop. One latent loop from the code, not seen in production (A and B have zero calls there): a slice that throws saves no state, so the same database re-runs A, B and the slice on every tick, and the databases after it in name order are never reached.

Where the cost comes from

The floor F cuts one chunk, [S, E). Today's plan is SkipScan on index (A), (server_id, database_name, query_id, plan_id, collection_time DESC). It finds each name with one seek per opened chunk. The exception is the cut chunk: there, a name with no row after F is read entry by entry, because collection_time is the fifth key column and can only be a filter. That per-entry walk is the whole cost. In chunks at or after E every row passes, so nothing walks there.

Candidates (expected cost, unmeasured)

Production, for the same read (buffers median / max): end − 20 min 175 / 1,225; end − 60 min 39 / 551; mid 39 / 185.

Candidate Shape Expected cost per tick Result vs today
Today DISTINCT … collection_time > F seeks, plus a walk of each quiet name's entries in [S, F] same
1a, time-first probe names by seek, then per name database_name = n AND collection_time > F ORDER BY collection_time DESC LIMIT 1 busy name: a few rows. Quiet name: every row of every database after F, once per quiet name. Worse than today. The name-first shape of the same probe (on (A)) walks the quiet name's entries, which is today's cost. same
1b, index (server_id, database_name, collection_time) walk with EXISTS (… database_name = n AND collection_time > F) one seek per name per opened chunk, no walk. Needs a fourth index on this table (write cost not measured). same
2, no row-level time filter DISTINCT … collection_time >= S, S read from the catalog one seek per name per opened chunk, no walk, no new index superset: adds names whose rows sit only in [S, F]
3, backfill state as the watermark walk bounded at S, exact probe only for names with no done: key steady state (all Done): seeks only. A quiet name with no Done key still walks. same decisions
4, split at E part 1 DISTINCT … collection_time >= E; part 2 DISTINCT … collection_time > F AND collection_time < E, read time-first part 1: seeks only. Part 2: every row of the server in (F, E). That is small when F is near E, where today is worst, and up to a whole chunk when F is just after S. same

Candidate 2: why the extra names are no-ops (RunServerSliceAsync, origin/dev)

  • The state is loaded at :208, before the candidate read. The hole check (:224) and the Done check (:240) are dictionary lookups, so an extra name that already has a done: key costs no store read.
  • An extra name has rows in [S, F] and none after F. Without a Done key it reaches A (:535), collection_time <= F LIMIT 1, which must hit one of those rows. GetStoredFloorAsync returns floorLimit, :254 saves done:<name>, and the loop moves on. The total is one A (production median 7 buffers) and one state upsert, once per database. B and the slice never run for it.
  • The slice choice does not change: the remaining names keep their order (MergeHoleDatabases re-sorts ordinal), and an extra name never slices.
  • One behaviour change. A quiet database that was never marked Done gets its Done key now. Today it gets one on its next active tick anyway (A hits its older rows), unless those rows leave raw retention first. In that case today's code digs a first-contact tail when the database comes back, and candidate 2 would not. An outage gap is still covered by the hole that the live clamp records, which is serviced before the Done check. This needs a decision.
  • The hourly orphan prune (PruneOrphanedDatabaseStateKeysSql) deletes Done keys only for databases missing from database_states. A dropped database whose rows are still in the opened chunks costs one A per prune until those rows leave the cut chunk. That is at most one chunk interval longer than today.
  • On TimescaleDB, "no time filter" cannot mean no bound at all. That opens every retained chunk, compressed ones included, which is the cost Ring-buffer collectors re-derive their watermark with an unbounded MAX that decompresses every retained chunk each cycle; persist it at write time instead #4197 removed (206 ms / 21,411 buffers). On plain PostgreSQL there are no chunks, so there it can mean no bound: the recursive walk becomes one index seek per name, and the extras are every name still stored, each marked Done once.

Candidate 3

  • Passing the Done set costs nothing, because the state is already in memory.
  • It has to be a recursive walk. A database_name <> ALL($done) filter in SkipScan is only a filter (a btree cannot use <> as an index condition), so it walks the same entries as today.
  • The walk takes the next name with collection_time >= S (one seek per chunk). It passes over names that have a Done key without probing them, and probes collection_time > F only for the rest.
  • It changes no behaviour. The cost: a quiet name with no Done key keeps walking until its rows leave the cut chunk. A new database has no key, so it always gets the exact probe, which is the case the list exists for.

Candidate 4: the boundary comes from the catalog

Read it at run time, never from a constant:

SELECT range_start AT TIME ZONE 'UTC' AS cut_start, range_end AT TIME ZONE 'UTC' AS cut_end
FROM timescaledb_information.chunks
WHERE hypertable_schema = 'collect' AND hypertable_name = 'query_store_stats'
  AND range_start <= TIMESTAMP '<floor>' AT TIME ZONE 'UTC'
  AND range_end   >  TIMESTAMP '<floor>' AT TIME ZONE 'UTC';
  • No row means no chunk holds the floor (a gap, or a floor before the first chunk). Fall back to today's SQL with the literal floor.
  • The rig's catalog shows why a constant fails. For the floor 09-27 23:40, the cut chunk is [09-27 18:00, 09-28 00:00) in collect (the 6 h era), and [09-27 00:00, 09-28 00:00) in collect1d. set_chunk_time_interval changes only new chunks, so a constant is wrong for every chunk created before the last change. I did not list the 12 h era's chunks.
  • Not measured: the catalog read's own cost, and whether part 2 is really read time-first. The planner may prefer SkipScan on (A) with the filter, which walks.

Recommendation (provisional: unmeasured on the rig)

  • Candidate list, TimescaleDB store: candidate 2. Read the cut chunk's start from the catalog, then run today's SQL with collection_time >= TIMESTAMP '<cut_start>'. It removes the walk, adds no index and no write cost, and keeps today's SQL shape and SkipScan plan. By the proof above, the extra names are no-ops. If the behaviour change is not acceptable, use candidate 3: it costs the same seeks and makes exactly today's decisions, but a quiet name with no Done key still walks.
  • Plain PostgreSQL store: the recursive walk with no time bound (candidate 2's plain form).
  • 1b stays rig-only. It is exact and walk-free, but it adds a fourth index to this table.
  • B: not measured, per the correction. Candidate 2 does not change B.

Final SQL, TimescaleDB store: first the catalog read above (the floor as today, now − HorizonFor, formatted yyyy-MM-dd HH:mm:ss.ffffff with the invariant culture), then:

SELECT DISTINCT database_name FROM query_store_stats
WHERE server_id = $1 AND collection_time >= TIMESTAMP '<cut_start>'
ORDER BY database_name

Plain PostgreSQL store:

WITH RECURSIVE walk AS (
    (SELECT s.database_name FROM query_store_stats AS s
     WHERE s.server_id = $1 AND s.database_name IS NOT NULL
     ORDER BY s.database_name LIMIT 1)
    UNION ALL
    SELECT (SELECT s.database_name FROM query_store_stats AS s
            WHERE s.server_id = $1 AND s.database_name > w.database_name
            ORDER BY s.database_name LIMIT 1)
    FROM walk AS w WHERE w.database_name IS NOT NULL)
SELECT database_name FROM walk WHERE database_name IS NOT NULL

Production sweep (read-only)

The floor is a literal, so the sweep can use any floor. Take the current cut chunk [S, E) from the first query, then run each form at E − 20 min, E − 60 min and the chunk's midpoint.

SET search_path = collect, public;
SELECT x.f AS tick_floor, c.range_start AT TIME ZONE 'UTC' AS cut_start,
       c.range_end AT TIME ZONE 'UTC' AS cut_end, c.is_compressed
FROM (SELECT date_trunc('second', now() AT TIME ZONE 'UTC') - interval '23 hours' AS f) x
LEFT JOIN timescaledb_information.chunks c
  ON c.hypertable_schema = 'collect' AND c.hypertable_name = 'query_store_stats'
 AND c.range_start <= x.f AT TIME ZONE 'UTC' AND c.range_end > x.f AT TIME ZONE 'UTC';
-- the catalog read's own cost
EXPLAIN (ANALYZE, BUFFERS) SELECT range_start, range_end FROM timescaledb_information.chunks
WHERE hypertable_schema = 'collect' AND hypertable_name = 'query_store_stats'
  AND range_start <= TIMESTAMP '<floor>' AT TIME ZONE 'UTC' AND range_end > TIMESTAMP '<floor>' AT TIME ZONE 'UTC';
-- per server id: today, candidate 2, candidate 4 part 1, candidate 4 part 2
EXPLAIN (ANALYZE, BUFFERS) SELECT DISTINCT database_name FROM query_store_stats
  WHERE server_id = <id> AND collection_time > TIMESTAMP '<floor>' ORDER BY database_name;
EXPLAIN (ANALYZE, BUFFERS) SELECT DISTINCT database_name FROM query_store_stats
  WHERE server_id = <id> AND collection_time >= TIMESTAMP '<cut_start>' ORDER BY database_name;
EXPLAIN (ANALYZE, BUFFERS) SELECT DISTINCT database_name FROM query_store_stats
  WHERE server_id = <id> AND collection_time >= TIMESTAMP '<cut_end>' ORDER BY database_name;
EXPLAIN (ANALYZE, BUFFERS) SELECT DISTINCT database_name FROM query_store_stats
  WHERE server_id = <id> AND collection_time > TIMESTAMP '<floor>' AND collection_time < TIMESTAMP '<cut_end>';
-- candidate 2's extra names on this server, and whether each already has a Done key
SELECT n.database_name,
       EXISTS (SELECT 1 FROM collector_state s WHERE s.server_id = <id> AND s.collector_name = 'query_store_backfill'
               AND s.state_key = 'done:' || n.database_name) AS has_done
FROM (SELECT DISTINCT database_name FROM query_store_stats WHERE server_id = <id> AND collection_time >= TIMESTAMP '<cut_start>'
      EXCEPT
      SELECT DISTINCT database_name FROM query_store_stats WHERE server_id = <id> AND collection_time > TIMESTAMP '<floor>') n;

A plain statement with literal values gets a custom plan, so no PREPARE is needed.

1b's write cost: not measured

Recipe, on a scratch hypertable in the rig, never on collect.query_store_stats:

  • Create scratch_qss (LIKE collect.query_store_stats) as a 6 h hypertable with the product's three indexes.
  • CHECKPOINT, record pg_current_wal_lsn(), then \copy 1M rows in with \timing on.
  • Record pg_wal_lsn_diff(pg_current_wal_lsn(), <start>).
  • Repeat on a fresh table that also has (server_id, database_name, collection_time). Compare the time and the WAL, and read the new index's size with hypertable_index_size.
  • Drop both tables.

Seed and bench plan

  • Floors: 09-27 23:40, 23:00 and 21:00, all in the [18:00, 24:00) 6 h chunk. Repeat 23:40 on collect1d, where it cuts [09-27 00:00, 09-28 00:00).
  • Server 1 additions:
    • db_quiet: 30,000 (query_id, plan_id) pairs, with rows at 09-27 18:10, 19:30 and 20:50 only;
    • db_busy: 2,000 pairs at p = 0.5 on 5-minute ticks, from 09-27 18:00 to 09-28 22:30.
  • Mirror both into collect1d and q2plain, then VACUUM ANALYZE.
  • Measure every candidate without 1b first, then create 1b and re-run. The new index would change the other candidates' plans.
  • Exactness: add candidates 4 and 1b to q2exact.py FORMS, plus a floor exactly on a chunk boundary (09-27 18:00:00). Check candidate 2 against the proof (every extra name has a row at or below the floor), not against list equality.

CHANGELOG

SECTION: Changed
ENTRY:

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