Skip to content

query_plan_dim's TOAST file sits at 40 % utilisation on the second SQL Server store (~93 GB slack from the V54 conversion + cycling deletes) — publish dimension-table TOAST utilisation in the store self-metrics; reclaim is --recompress-plan-dim --vacuum-full at the maintainer's word #3783

Closed

Description

@erikdarlingdata

query_plan_dim is 157 GB on the second SQL Server store class — 2.3× its near-twin — and is that store's single largest object

Measured 2026-09-20 13:4xZ (store-size pass): the plan dimension is 157 GB on the second SQL Server store (42 servers) against 67 GB on the largest (43 servers). It is 69 % of that store's collect schema (store total 229 GiB) and the product's second-largest object class overall. Same product, same build, near-twin fleets; the asymmetry is the finding. For scale: the largest store just proved the compression path elsewhere (415 → 359 GiB in two days as the staged aggregate lifts ran), and oversized_plan_backlog on the same second store is 2.4 GB.

What is VERIFIED at source (dev, 2026-09-20)

HYPOTHESES to discriminate — three read-only store reads, no lane until they land

  1. Text-row tail. --recompress-plan-dim --dry-run on both stores: how many rows are still pre-V54 text, and how many bytes. If the second store carries a large stable-plan text tail the largest does not (it was on the product earlier, or its plans are more stable), the fix is running the existing verb — no code.
  2. Purge throughput < insert rate. From the retention log's dimension-GC lines over the last 7 days: rows purged per day vs rows inserted per day (the digest table has no first_seen; insert rate = distinct new digests per day from last_seen on rows whose last_seen is their only stamp, or simply count(*) delta between two days). A 50 k row cap per statement is 50 k per sweep; if the sweep runs fewer than ~15 statements a day against 755 k inserts, the table grows by construction and the lever is the purge's per-sweep budget, not the data.
  3. Bloat, not content. Plain table with sustained deletes: pg_stat_user_tables (n_dead_tup, n_live_tup, last_autovacuum, last_autoanalyze) and pg_relation_size main vs TOAST vs pg_total_relation_size. A dead-tuple ratio in the tens of percent or a stale last_autovacuum says the 157 GB is partly heap the vacuum has not reclaimed — the lever is autovacuum settings on the table (autovacuum_vacuum_scale_factor for a 12 M-row table is far too coarse at the default 0.2) or a scheduled VACUUM, not retention.

Only after those: if inserts genuinely run ~755 k distinct plans/day on 42 servers, that is plan churn on the monitored fleet — a finding about the fleet, not the store (the #3648 MAX(max_dop) work already showed plan turnover there is real), and the product-side question becomes whether the dimension should hold every distinct plan or only plans whose statements are still in Query Store (planContentRetentionDays shorter than the fact horizon, which the knob already allows).

Scrub: "the second SQL Server store class" / "the largest store"; no store names. Reads are the monitor seat's (authorised, read-only); the lane forms on the numbers.

Activity

  1. erikdarlingdata commented on Sep 20, 2026

    @erikdarlingdata
    OwnerAuthor

    Discriminated (monitor seat's three reads, 13:4xZ) — all three hypotheses dead; the split is horizon × file slack

    read second store largest store
    text-row tail (--recompress-plan-dim --dry-run) 0 of 12.55 M rows (100 % gzip) 0 of 6.69 M (100 % gzip)
    main-table dead tuples / last autovacuum 0.9–2.0 % / today same class / today
    TOAST dead tuples 0 (autovacuumed today) 0
    live TOAST data (chunk arithmetic) 30.7 M chunks ≈ 61 GB 20.7 M ≈ 41 GB
    TOAST file size / utilisation 154 GB / ~40 % (~93 GB slack) 64 GB / ~64 % (~23 GB slack)
    plan retention actually held (GC cutoff) ~22 days ~15 days
    GC drain vs #2386 baseline ~478 k rows/day; rows FLAT 12.4 M → 12.1 M —

    Mechanism. Live data ratio 1.5× = the retention-horizon ratio almost exactly (22 d vs 15 d; the cutoff is bounded by surviving digest-carrying facts, so the real question is why that store's fact horizon reaches deeper). The 1.5× becomes 2.4× on disk through TOAST file slack: the second store's toast file sits at a high-water mark — the V54-era text→gz conversion plus ~755 k rows/day cycling through row-capped deletes left ~93 GB of empty pages that ordinary VACUUM returns to the table but never to the OS. The verb's own help text names this case: --vacuum-full to compact a previously-converted store whose file still sits at its pre-conversion high-water mark. The +24 GB since #2386 is slack accumulation, not net rows: the table is in steady state.

    Levers, smallest first.

    1. --recompress-plan-dim --vacuum-full on the second store — ≈ 90 GB reclaimed. A WRITE with an ACCESS EXCLUSIVE lock window on the store's largest table and a full rebuild's worth of disk headroom during it; maintainer's word + a maintenance slot, executed by the operator, not a lane. The verb already exists; nothing to build.
    2. Equalise the plan retention horizon — why does the second store hold 22 days? ≈ 20 GB of live data; a config read (planContentRetentionDays / the widest fact retention on that store) before any change.
    3. Do nothing — steady state; slack stops growing once utilisation floors.

    Not a code defect as filed. Two things worth carrying out of it: (a) the store-size self-metrics (#3610) should publish TOAST utilisation for the plain dimension tables (live bytes / file bytes), so a 40 % file reads as a finding rather than being discovered on a size pass — one column on the inventory, a lane if wanted; (b) the churn number (755 k distinct plans/day on 42 servers) is unchanged since #2386 and is a fleet-side fact, not this issue's.

    Re-labelling enhancement for (a); the reclaim itself is an operator action recorded here when it runs.

  2. changed the title [-]query_plan_dim is 157 GB on the second SQL Server store — 2.3× its near-twin and that store's largest object; discriminate text-tail vs purge throughput vs bloat before choosing the lever[/-] [+]query_plan_dim's TOAST file sits at 40 % utilisation on the second SQL Server store (~93 GB slack from the V54 conversion + cycling deletes) — publish dimension-table TOAST utilisation in the store self-metrics; reclaim is --recompress-plan-dim --vacuum-full at the maintainer's word[/+] on Sep 20, 2026
  3. added
    enhancementNew feature or request
    and removed
    bugSomething isn't working
    on Sep 20, 2026
  4. erikdarlingdata commented on Sep 20, 2026

    @erikdarlingdata
    OwnerAuthor

    Companion column for the same inventory pass (monitor seat, 2026-09-20 16:4xZ): the day's three unattributed read kills all landed inside timed-checkpoint fsync phases — checkpoints completing :23–:24 with sync = 25.2 s (12:23) and 14.0 s (16:24), job_history empty in both windows. A store whose checkpoint syncs run 25 s should say so on its own card rather than leak it as mystery read kills. pg_stat_checkpointer (PG 17+; pg_stat_bgwriter before) carries write_time / sync_time / num_timed / num_requested — one checkpoint row on store_metrics (object_kind = 'checkpointer', the deltas since the previous run as *_ms bigint, num_requested as the WAL-forced count) beside the TOAST columns, so this lane's V137 rung is the natural carrier if it has not pushed yet; otherwise the next rung. The self-alert arm: sync tail > 10 s (the MCP read deadline it kills) or num_requested > 0 in a run → Informational, naming the two levers already on the board (max_wal_size #3802; refresh slicing #3745).

  5. erikdarlingdata commented on Sep 20, 2026

    @erikdarlingdata
    OwnerAuthor

    Live bytes are NULL-by-design on the shipped store, and one statement would light them (code half, #3826).

    The V137 rung created toast_live_bytes and wrote it NULL because every extension-free read was measured and found wanting on exactly this file's shape (rig: TimescaleDB 2.28.1 / PostgreSQL 18, 215 MB of 9.6 KB TOASTed values, half deleted, ordinary VACUUM, pgstattuple as the oracle at 47.9 % live):

    instrument reads verdict
    n_live_tup / (n_live_tup + n_dead_tup) × file 100.0 % a lie — after VACUUM the dead tuples are gone and the file keeps its pages
    n_live_tup(toast) × 1996 48.9 % honest on 9.6 KB values; overstates by up to one partial chunk per value, so value-size-dependent
    pg_freespacemap: file − sum(avail) 48.5 % (+1.2 %; 48.4 % reproduced on the code half's rig) honest byte measurement — needs CREATE EXTENSION pg_freespacemap

    pg_freespacemap (and pgstattuple) are contrib modules the bundled image ships and does not install; installing one is a product-dependency decision, so #3826 does not make it. What it does instead:

    • get_store_metrics publishes toast_bytes, toast_live_bytes, toast_utilisation_pct and toast_utilisation_note on every dimension object; on the shipped store the percentage is NULL and the note says not measured and names the option below. Nothing is computed from total_bytes or a tuple share.
    • The Store TOAST Slack informational self-alert (under 50 % on a file over 10 GiB; daily re-fire; resolution when the reclaim lands) is built and dormant: a NULL utilisation neither fires nor resolves, pinned.
    • The sweep already probes pg_extension every run, and only where pg_freespacemap is installed runs a separate, fenced UPDATE filling this run's dimension rows from pg_freespace(reltoastrelid) — so the maintainer's single statement lights the percentage, the note and the alert on the next hourly sweep, with no further release.

    The exact option, when you want it:

    CREATE EXTENSION IF NOT EXISTS pg_freespacemap;   -- on the store database, once

    (A future rung could carry that statement so fresh stores get it at V1; #3826 deliberately leaves the decision here.) One number to check first on a store of the size that motivated this: pg_freespace() materialises one row per page of the TOAST file — ~26 k rows and single-digit ms on the 215 MB rig, an estimated ~20 M rows and tens of seconds on a 154 GB file, inside the sweep's 300 s budget but not free. The fence means that can be measured on a copy before installing on the store itself.

    The reclaim path is unchanged and unautomated: --recompress-plan-dim --vacuum-full, at your word, in a maintenance window — ACCESS EXCLUSIVE lock on the dimension for the rebuild, free disk for a full copy of the live data. The checkpointer half (the row every sweep, the checkpointer block, the Store Checkpointer Pressure informational self-alert on sync > 10 s or any requested checkpoint, naming #3802 and #3745) lands in the same PR.

  6. erikdarlingdata commented on Sep 20, 2026

    @erikdarlingdata
    OwnerAuthor

    Closed by the merge of #3826 (its body names this issue with a closing keyword; keywords do not fire against dev, so the watcher closes it).

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

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions