Skip to content

[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

Description

@erikdarlingdata

Component

Shared collectors (QueryStoreCollector), both apps

Field evidence (dogfood, 2026-08-10)

A 4-core multi-tenant box post-resize-restart (all plans recompiled) + a freshly restored tenant catalog: every database hit MaxTextBytesPerDatabase (64MB, compile-time const) on every pass, at 44–109s of SQL time per database, ~380–427s per full server pass, repeating every cycle. The staged shape (#2134) worked exactly as designed — bounded, resumable, no wedge — but the drain tax is large and highly visible (60–100s PerformanceMonitorLite statements in sp_WhoIsActive on a prod primary).

Why passes cost this much

  1. The per-pass ceiling is generous and fixed: MaxTextBytesPerDatabase = 64 * 1024 * 1024 (QueryStoreCollector.cs:327). 64MB of QS text/plan extraction ≈ 45–110s on small hardware because CONVERT(nvarchar(max), qsp.query_plan) decompresses plan XML server-side — that's the dominant CPU term.
  2. No cross-pass plan memory: query_plan_text ships once per plan per pass (ROW_NUMBER() OVER (PARTITION BY qsp.plan_id ...) = 1). A hot plan re-decompresses and re-ships every cycle; the store dedups on arrival — after the server already paid the extraction. In a recompile storm, most of every 64MB is plans the store has.

Proposed levers

  • (a) Budget knob: make the per-database text byte budget a store setting (per-server override ideal), default 64MB, floor ~4MB — the Expose the hardcoded self-alert thresholds as store-backed settings #2107 store-backed knob pattern. Small boxes drain in 15–25s bites instead of 60–110s ones; same total work.
  • (b) Known-hash skip (the big win): qsp.query_plan_hash is available before the CONVERT. Maintain the recently-shipped hash set per (server, database) in collector state, pass it in (temp table / TVP), and extend the ship condition to ROW_NUMBER = 1 AND qsp.query_plan_hash NOT IN (SELECT h FROM #known). Recompile storms then ship stats rows but almost no plan payload. Caveat to design around: query_plan_hash is a plan-shape hash, not a full-XML digest — distinct XML can rarely share a hash; acceptable for perf telemetry but should be a documented tradeoff (or refresh hashes older than N days).

The two compose: (b) shrinks steady-state and storm payloads; (a) bounds the worst case on hardware that can't afford big bites.

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