Repository navigation
query_store: collect per database on Azure SQL Database (#1836) - #1844
Conversation
The Azure enumeration probed every candidate database through QUOTENAME(@db) + N'.sys.sp_executesql' - a cross-database reference Azure SQL Database rejects for EVERY database - into an empty CATCH, so the item list came back empty and the cycle logged SUCCESS with 0 rows, forever. Azure SQL DB now runs the collector per database (RunsPerDatabase), like query_stats and the five database-scoped siblings: each per-database connection runs the same eligibility gate the on-prem cursor probes with (actual_state IN (1, 2, 4), readonly_reason & 8 = 0) against the CURRENT database, then the payload. Ineligible databases return no result set at all without touching the Query Store views. The dead Azure enumeration is deleted rather than left unreachable. Both execution shapes are generated from ONE payload body; the on-prem form is that string quote-doubled for [db].sys.sp_executesql nesting, and a test pins the containment so a hand-edited second copy fails loudly instead of drifting out of the reader's 53 ordinals. plan_type is now populated on Azure (evergreen engine; the column's own applies-to banner names Azure SQL Database). replica_role is deliberately gated OFF there and emits the same typed NULL placeholder a pre-2022 box does: replica_group_id's doc page is silent on Azure where its siblings are explicit, and the feature is documented as unavailable on Hyperscale - a column that does not bind fails the whole SELECT in every database. The 24h catch-up clamp moved into the collector's cutoff computation so it holds on the new path (the host's per-database branch is shared with the XE ring-buffer collectors, which must not be clamped), and signals the host when it fires so the bounded history hole stays logged. Both runners also now emit the per-database truncation WARNING on that branch, which this change makes reachable. Verified against SQL 2022: the Azure-shape batch returns 53 fields with database_name taken from the connected database, and a Query-Store-off database returns zero fields and zero rows with no error. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ReviewWent through the full diff (8 files, both Correctness
Lite/Darling parityDiffed StyleNew/changed T-SQL ( SecurityNo new dynamic SQL surface beyond what already existed — Residual risk (self-disclosed, worth reiterating)The PR body already flags this, but worth surfacing for reviewers: not yet reproduced against a live Azure SQL Database — only SQL Server 2022 exercised both shapes live. The No blocking issues found. |
…ore and the parquet archive Lite gets a FULL repair where Darling gets a bounded one: Darling's long tier is a materialized rollup that cannot be rebuilt from raw that no longer exists, while Lite's long tier IS the parquet archive -- the same rows, rewritable. Measured against a real archive: ~46 MB across three monthly files, 6.6-8.5% of rows carrying the split signature (exactly 2 rows per group, every time), the largest file rewriting in under a second. The collapse key ADAPTS PER FILE. Archive files are monthly and the schema moved underneath them: runtime_stats_interval_id arrived with erikdarlingdata#1841 tier 2 and replica_role with erikdarlingdata#1844/erikdarlingdata#1872, so a real June 2026 file has neither. Naming an absent column fails the rewrite outright; silently assuming the newest schema would group on a key that is not that era's dedup key at all and combine rows that are not slices of one interval. Covered by a fixture written WITHOUT those columns. Two engine behaviors found by running it, both of which would have shipped silently: - DuckDB widens SUM(BIGINT) to HUGEINT and hands back a BigInteger that Convert.ToInt64 cannot convert (the erikdarlingdata#1839 trap). Cast in SQL. - AN IN-PLACE PARQUET REWRITE POISONS DUCKDB'S EXTERNAL FILE CACHE. The cache keys on PATH at INSTANCE scope, so replacing the bytes at a path already read makes every later read fail with "No magic bytes found at end of file" -- on new connections too -- until the process restarts. That would have left the app unable to read its own archive after a repair. PRAGMA clear_cache does not exist and enable_object_cache is a different cache; toggling enable_external_file_cache off and back on evicts the entries and leaves caching enabled. Verified standalone; the instance scope is the trap, since a "fresh connection" fixes it only on a separate in-memory instance. Archive files are rewritten to a temp sibling and swapped in only after the row count checks out, so an interruption leaves the original in place. COPY carries COMPRESSION ZSTD because that is what wrote them -- omitting it switches codec and inflates the file (28 MB to 69 MB measured). The verb discloses the size change per file, including the case where a row-REMOVING repair still grows the file, because parquet size follows row-group layout rather than row count. ArchiveService.WithRaisedCopyMemoryLimit is now internal and reused rather than copied: the 4GB floor is a known-broken-below-2GB constraint, and a second drifting literal is exactly how erikdarlingdata#942 broke compaction. Lite 1923 passed / 0 failed, full solution build 0 warnings / 0 errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes #1836.
The bug
query_storereturned zero rows on Azure SQL Database, on every cycle, since the collector existed.It is enumeration-shaped (list the databases whose Query Store is usable, then collect each one), and its Azure enumeration probed every candidate through
QUOTENAME(@db) + N'.sys.sp_executesql'— a cross-database three-part reference that Azure SQL Database rejects for every database, frommasterand from a user database alike. Each rejection landed in an emptyCATCH, so the list came back empty, the cycle loggedSUCCESSwith 0 rows, and nothing said why. Setting the server entry's Database field could not save it either: it would still have been asking one database for the whole instance, the wrong way.The fix
Azure SQL DB now runs the collector per database (
RunsPerDatabase), the same wayquery_statsand the five database-scoped siblings do, so a logical server with ten databases collects ten databases instead of none. Each per-database run starts with the same eligibility gate the on-prem cursor probes with (actual_state IN (1, 2, 4)andreadonly_reason & 8 = 0, the #1546/#1558 semantics), evaluated locally against the connected database: an ineligible database costs one catalog lookup and returns no result set, never touching the Query Store views. The dead Azure enumeration is deleted rather than left unreachable, so a future edit cannot revive it.The on-prem/RDS/MI enumeration + per-item pair is unchanged in behavior.
One payload, two shapes
The guard this rework is designed around.
BuildPayloadBodyreturns the payload as ordinary single-quoted T-SQL — what Azure's per-database connection executes verbatim — andBuildPerItemQueryquote-doubles that same string to nest it inside[db].sys.sp_executesql. The singleReplaceis the whole difference between the two paths' SQL. A test asserts the containment, so hand-editing either path's columns fails loudly rather than drifting the two paths out of the reader's 53 fixed ordinals. Same structure asIndexObjectStatsCollector, the existing dual-shape collector.Two column decisions, going opposite ways
plan_typeis now ON for Azure. The probe reports PRODUCTVERSION 12 there, but the engine is evergreen;sys.query_store_plan's applies-to banner names Azure SQL Database and its only documented exclusion is Synapse (engine edition 6, neverIsAzureSqlDb).replica_roleis explicitly gated OFF for Azure, emitting the same typed NULL placeholder at the same ordinal a pre-2022 box does — the column set is unchanged, only the attribution is given up.sys.query_store_runtime_stats.replica_group_idis documented for SQL Server 2022+ and is silent on Azure SQL Database on a page whose sibling columns name Azure explicitly, and Query Store for secondary replicas is documented as unavailable on Hyperscale with no statement about whether the view still binds. A column that does not bind is not a NULL: it fails the whole SELECT, which on this path means every database. The comment names the exact one-line probe that would justify flipping it.Consequences handled rather than left
WatermarkPolicy's premise is a "deliberate, logged, bounded" hole; the definition now signals the host when it clamps, and both runners log the same WARNING the enumeration path emits.Test plan
RunsPerDatabase(true Azure, false on-prem/MI), AzureBuildQuery(eligibility predicates present, guard precedes payload, no cross-database reference, nosp_executesql, payload columns present), the shared-body containment,BuildQuerythrowing off-Azure, the replica/plan_type gate split, the clamp and its host signal, andReadAsynctakingdatabase_namefromCurrentDatabaseName(and throwing rather than writing blank-database rows without it).database_namefrom the connected database and drops self-marker rows; a Query-Store-off database returns 0 fields / 0 rows with no error, confirming theRETURNguard short-circuits cleanly throughExecuteReader; the on-prem wrapper still collects across databases frommaster.Not reproduced on live Azure SQL Database — that is the one remaining validation, along with the
replica_group_idprobe noted above.🤖 Generated with Claude Code