Skip to content

query_store collector costs 40-110s per run on multi-tenant primaries — the read has no interval watermark, so every cycle pays for the whole window #2312

Description

@erikdarlingdata

Filed off today's fleet re-architecture (#2296 close-out), which made this cost visible per-server for the first time. All numbers are from collection_log on the two prod boxes, 2026-08-15 → 2026-08-17.

The measurement

The query_store collector costs 40–110 seconds per run on the big multi-tenant primaries, around the clock, and always has — it was buried in fleet averages and attributed to cross-region latency until today's split put the same servers on an in-region box:

  • multi-53, hourly averages from use2's history (cross-region era): ~83–107s all Friday and overnight; 16–27s in the hours right after the [BUG] 3.4.0 query_store collector runs 37–100 min on Azure SQL DB (3.3.0 median: 4.8 s) — starves all other collectors #2150 text-flip deploy on 8-16 13:00 (a 4–6x cut); re-inflated to 65–78s through Sunday as Query Store content re-accumulated.
  • Same server, in-region on use1 this evening: 43–53s — ~25% better than its same-day cross-region baseline. Latency was ~a quarter of the story; the read itself is the rest.
  • Fleet-wide on use1 (42 primaries, Sunday evening): query_store avg 23.0s/run — the heaviest collector by 2x (plan_correction 10.8s, procedure_stats 5.5s, query_stats 2.9s). The trend across the evening rose 13.5s → 26.1s with flat rows per run (~6–10k) and trivial store-side time (~400 ms) — pure SQL-side read cost tracking workload, not backfill and not the Postgres write.
  • The replica twin of the same collector reads 33 ms median on use2 — Query Store is primary-side; the replicas have nothing to serve, which is what isolates the cost to the QS DMV read itself.

The consequence

A 40–100s collector cannot fit its 60s cadence: the collection body outlives the sweep, relaunches are skipped, and the server collects at a multiple of the configured interval — #2296's arithmetic, now reproduced without cross-region latency. use1 logged 31 skip-relaunch warnings in its first loaded hour. Once the next install lands, #2308's sweep_pressure will read SATURATED/AT_RISK on the heavy primaries from exactly this number.

Mechanism hypotheses to verify (in order)

  1. The runtime-stats read has no interval watermark. QS aggregates runtime stats per interval, and a closed interval is immutable — but the collector re-reads its whole window every cycle at full price. The same discipline that fixed plan fetch ([PERF] QS collector drain-mode passes cost the full byte budget every cycle — make the budget a knob, skip already-shipped plans by hash #2164) and text fetch ([BUG] 3.4.0 query_store collector runs 37–100 min on Azure SQL DB (3.3.0 median: 4.8 s) — starves all other collectors #2150) — fetch once past a watermark, never re-ship — should apply to closed intervals, leaving only the open interval's delta as the per-cycle cost.
  2. Per-database fan-out. Query Store is per-database and these primaries carry dozens of tenant databases; if the read iterates databases serially, per-server cost scales with tenant count regardless of activity.
  3. The TOP-N sort ([BUG] 3.4.0 query_store collector runs 37–100 min on Azure SQL DB (3.3.0 median: 4.8 s) — starves all other collectors #2150's measurement): choosing shipped rows still materializes every qualifying row's columns through the sort per database per cycle.

Interim mitigation available today (operator call)

config_collector_schedules can set query_store to a 5-minute cadence (per-server for the heavies or fleet-wide on the primaries box) — QS interval aggregation means minute-level collection mostly re-reads unchanged data, so the grain loss is small relative to 60→300s. One store row, live within a sweep, reversible. Not applied yet.

What resolves this issue

The collector-side fix (likely 1 + revisiting 3): per-interval watermarking so steady-state cost is proportional to NEW intervals, with the measurement above as the before/after yardstick — multi-53 at ~50s/run is the acceptance test, and the post-#2150-dip shows what a 4–6x cut looks like when real.

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