Skip to content

PLAN_REGRESSION's read deduplicates the whole raw Query Store slice every pass; keep the latest snapshot per interval as it is written #3953

Description

@erikdarlingdata

Summary

PgFactCollector.PlanRegressionSql (Darling's PLAN_REGRESSION fact) deduplicates the server's whole raw Query Store slice on every analysis pass. Production measured it at 7.1 s or more on a 43-server store (#3902). #3956 (for #3902) removed the second copy of that work, in the regressed-queries drill-down, but the fact's own read is still proportional to the raw slice. No SQL-only rewrite changes that. This issue proposes the storage change that does, with the numbers behind it.

Why a rewrite can't fix it

The dedup keeps one row per Query Store interval: the latest snapshot, because snapshots are cumulative. That needs grouping at the interval grain. PostgreSQL can only do it by sorting the slice on the eight-column interval identity, because an "argmax per group" can't be expressed as a hashable standard aggregate.

Measured on a PG 18.4 / TSDB 2.28.1 rig seeded to #2827's production funnel (4.15M raw rows per server, 1.72M intervals, 81K plans, work_mem 31MB):

variant time
shipped (GROUP BY + ordered array_agg) 20.1 s idle; 26.8-35.2 s under load (median 27.1 s over three interleaved runs)
exact candidate pre-filter (skip queries that provably cannot regress) 45 s (the planner rebuilt the candidate aggregate in each parallel worker); 17.3 s with the CTE MATERIALIZED; 19.7 s grouped on a hash key
DISTINCT ON, or the planner forced off the uncompressed chunks' index scans within noise

The existing interval-grain CAGG (query_store_stats_interval_hourly) can't serve the read:

  • It lacks four of the seven columns the dedup projects: query_plan_hash, last_execution_time, is_forced_plan and force_failure_count. A CAGG can't be ALTERed to add them.
  • It is materialized-only and lags 1-2 h behind raw, but the detector ranks plans by their newest execution.
  • At production's measured 2.3x snapshot multiplicity (4M raw rows to 1.72M intervals), an interval-grain source is still about 1.9M rows. The cost is in the number of intervals, not in re-collections.
  • A second CAGG carrying the missing columns would buy about 2x on the read, and it would add a refresh job as heavy as L1's, the store's heaviest.

Proposal: keep the latest snapshot per interval as it is written

Add a table, maintained by the Query Store writer, with one row per interval identity: (server_id, database_name, query_id, plan_id, execution_type_desc, replica_role, runtime_stats_interval_id, first_execution_time). The unique index needs NULLS NOT DISTINCT, because replica_role is NULL off an AG and must still collapse. Each row holds the latest snapshot's values.

What it needs

  1. A migration for the table and its unique index (NULLS NOT DISTINCT, PostgreSQL 15+, within the product's minimum of 17).
  2. The writer upsert, in the Query Store row writer after each batch's raw insert, deduplicated within the batch first. ON CONFLICT DO UPDATE refuses to touch one row twice in a single statement.
  3. Retention in lockstep with raw. Either a plain table purged by DELETE, or a hypertable partitioned on first_execution_time. A hypertable's partition column must be NOT NULL, and collection_time can't be the key because the upsert moves it. The read-time floor above keeps findings identical whenever the purge runs.
  4. Coverage-aware fallback in place of a heavy backfill. Record when the table started filling, and route both reads to the raw SQL until raw's retained floor is younger than that. On a store with armed purges that is about 4 days after deploy. A one-shot backfill over about 170M raw rows (43 servers, 4 days) is too heavy for a startup migration. A plain-PG store never purges raw, so it would never cross over without an operator verb in the spirit of --backfill-rollups. Decide whether that store class needs one.

Design input: #3892's write amplification

#3892 measured the busy store's checkpoint fsync cost as driven by the count of files dirtied between checkpoints. Query Store ingestion is the reason that store dirties about 4x more than its sibling (689 write operations per second against 181 on the same inventory). This table adds an upsert to exactly that write path: another relation's heap and index pages dirtied every cycle, the open intervals rewritten in place each time they are re-collected. The design needs a measured answer to whether that is affordable before anything ships. The options include batching the upsert per collector cycle, keeping the table unindexed beyond its unique key, and choosing a partitioning that keeps the hot open intervals in one small relation.

Out of scope here

Related

Activity

  1. erikdarlingdata commented on Sep 23, 2026

    @erikdarlingdata
    OwnerAuthor

    Ruled (Erik, 2026-09-23): build it. Keep the latest snapshot per Query Store interval in a collector-written table with a 15-day horizon. That also makes #3958's 14-day window real (option 1 there). Conditions:

  2. added
    in-progressActively being worked by a local session or its agents (PR open or in flight)
    on Sep 23, 2026
  3. erikdarlingdata commented on Sep 23, 2026

    @erikdarlingdata
    OwnerAuthor

    Ruled (Erik, 2026-09-23): go tonight. First finish the narrow v2 design round the review asked for, then build it.

  4. erikdarlingdata commented on Sep 23, 2026

    @erikdarlingdata
    OwnerAuthor

    Design v2 (the narrow round the review asked for). The table, key, 15-day retention and fallback rule are unchanged from v1.

    The table. collect.query_store_interval_latest: one row per Query Store interval, holding its latest Regular snapshot. Hypertable on first_execution_time, 1-day chunks, one NULLS NOT DISTINCT unique index, compression at 2 days.

    How it stays current. Inside the raw COPY's transaction, one set-based upsert reads the batch back from raw by collection_time = $n, behind a savepoint with a 5 s lock_timeout. On an apply fault it rolls back to the savepoint, records a pending-replay row in the same transaction, and commits raw. So raw ingestion no longer depends on the table (review finding 1). The next apply for that server replays the pending rows. Nothing in the write transaction reads raw unbounded (finding 2). Coverage creation and an hourly gap check run before the COPY, bound by bare parameters.

    The read change. PLAN_REGRESSION and its drill-down aggregate the table instead of deduplicating raw. A server's reads use the table only when its coverage holds everything raw would read and it has no pending rows; otherwise they run the shipped raw SQL. Over the same snapshots the results are identical (md5 oracle).

    Budget (finding 3). The ceiling is the mcp and viewer roles' 15 s statement_timeout, and #3902's 5 s is the goal. The busy server's 14-day plan count can't be measured tonight (client-site), so it gates the merge. At or under 250K plan groups, the table ships alone. Over that, a per-(plan, day) rollup lands in the same PR.

    Best-plan age (finding 4). The drill-down carries best_plan_last_seen, and the advice states the age. The unattended bot gets MaxBestPlanAgeDays = 4, which keeps its would-force journal in the regime it has been scored in.

    Migration. One rung (V140): the table, a coverage table and the pending table. Engine-plain; the hypertable conversion runs at startup, collection_log's way.

  5. erikdarlingdata commented on Sep 23, 2026

    @erikdarlingdata
    OwnerAuthor

    Ruled (Erik, 2026-09-23): shelved. The latest-snapshot table was built and tested (branch fix/3953-qs-interval-latest, commit f6075fb, with V140 and three tables). Measured on the rig, a full 14-day pass only goes from about 6.5 s to 5.5 s, and the followed drill-down gets 4x slower (71 ms to 286 ms). That doesn't pay for three new tables, a writer inside the Query Store COPY transaction, the replay path, and the compression-band conflict. Reopen if field data from the busy client-site server shows PLAN_REGRESSION dominating analyze_server; the branch and its handoff (design v2 plus the parked hypertable patch) are ready to resume.

  6. removed
    in-progressActively being worked by a local session or its agents (PR open or in flight)
    on Sep 23, 2026
  7. erikdarlingdata commented on Sep 25, 2026

    @erikdarlingdata
    OwnerAuthor

    Field data for the shelving condition ("reopen if field data from the busy client-site server shows PLAN_REGRESSION dominating"). This comes from the production store that ingests the fleet's live Query Store data (43 SQL Servers), on build 3.8.0-nightly.20260924.467, via get_store_query_stats role=owner. It's cumulative since the service restart, about 5 h of steady-state passes (2026-09-24 22:09Z → 2026-09-25 ~02:15Z).

    The PlanRegressionSql statement (WITH deduped AS ( … (array_agg(query_plan_hash ORDER BY collection_time DESC, execution_count DESC))[1] …) is the store's single heaviest statement:

    measure value
    calls 344 (about one per server per pass)
    mean / max 15.0 s / 58.5 s (the rig figure at shelving was ~6.5 s for a 14-day pass)
    total 5,164 s = 22.5% of all owner-role store time (22,914 s)
    shared blocks read 19.7 M (≈162 GB)
    temp blocks written 10.35 M (≈85 GB in ~5 h), about 250 MB per call and 91% of the temp writes among the top 40 statements

    The next heaviest statements are 1,951 s and 1,756 s, so this one is roughly 2.6× anything else the store runs.

    Two things the rig numbers didn't show:

    What I did NOT measure:

    • The per-server split. pg_stat_statements aggregates across servers, so "the busy server" specifically isn't isolated.
    • analyze_server's own time composition. I didn't run it, because unanchored runs persist findings.

    I'm not proposing work_mem as a mitigation: the statement's own comment records 512 MB making it slower (25.6 s → 59.3 s). Not reopening; that's Erik's call.

  8. erikdarlingdata commented on Sep 25, 2026

    @erikdarlingdata
    OwnerAuthor

    Reopened on this issue's own shelving condition. The 2026-09-25 field data above shows PlanRegressionSql is the owner store's single heaviest statement: 22.5% of its time and 85 GB of temp writes in about 5 hours of steady-state passes, on build 3.8.0-nightly.20260924.467. Ranked tier 3 (measured store cost). It gets a lane when a slot frees up.

  9. 31 remaining items

  10. erikdarlingdata commented on Sep 26, 2026

    @erikdarlingdata
    OwnerAuthor

    Fixed by #4341, merged to dev (deec3e0): migration V145 adds the wide per-interval Query Store table, and the grid and MCP reads use it for windows it covers.

  11. removed
    in-progressActively being worked by a local session or its agents (PR open or in flight)
    on Sep 26, 2026
  12. erikdarlingdata commented on Sep 26, 2026

    @erikdarlingdata
    OwnerAuthor

    Claude posting for Erik Darling.

    #4389 is merged (112effd). It makes two live tests independent of setup order and the wall clock:

    • the literal-end straddle pin enables TimescaleDB itself, and seeds interval-stamped rows;
    • the shared baseline cache live test is anchored within one UTC day.

    Both had been failing intermittently across open PRs.

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

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions