Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- **The Long Running Query filter checkboxes stopped eating an underscore out of the wait and procedure names they name** ([#1847], found by the collision audit above) - WPF reads a single `_` in a control's Content as an access-key marker, which both removes the character from the rendered label and claims an Alt key. Four checkboxes in each app's Settings window are labelled with raw SQL identifiers, so "Exclude SP_SERVER_DIAGNOSTICS" had been rendering as "Exclude SPSERVER_DIAGNOSTICS" (and silently binding Alt+S), with `BROKER_RECEIVE_WAITFOR`, `XE_LIVE_TARGET_TVF`, and `sp_MScdc_capture_job` mangled the same way. The underscores are now doubled, which is WPF's escape for a literal one - the labels read correctly and claim no keys, which is also what freed Alt+S for Settings' new Save.

- **Azure SQL Database now attributes its Query Store rows to a replica role instead of storing a NULL** ([#1872]) - [#1844] added `replica_role` so an Availability Group primary's Top Queries could not silently blend secondary-replica workload into the primary's own numbers, and [#1836] deliberately gated it OFF for Azure SQL DB. That gate was about bind SAFETY, not taste: `sys.query_store_runtime_stats.replica_group_id` is documented for "SQL Server (Starting with SQL Server 2022 (16.x))" with no Azure SQL Database in its applies-to note, on a page whose sibling columns name Azure explicitly when they mean it; and Query Store for secondary replicas is documented as unavailable on the Hyperscale service tier, with the docs silent on whether the view and column still BIND there. **A column that does not bind is not a NULL** - it fails the whole payload for that database, and Azure collects per database, so on Azure that would have meant every database going dark rather than one column. The gate's own comment named the exact evidence that would lift it, and that evidence is now in hand from live databases on **both** service tiers it worried about ([#1848] General Purpose, [#1872] Hyperscale, same engine banner and EngineEdition on each): `OBJECT_ID('sys.query_store_replicas')` and `COL_LENGTH('sys.query_store_runtime_stats', 'replica_group_id')` both return non-NULL on each tier, the view binds and holds the same four role rows box SQL Server has, and - the decisive check - the full 55-column payload composed with the attribution ON executed clean on Hyperscale, binding all 55 columns and returning `replica_role = Primary`. A per-tier gate was considered and rejected as unbuildable: Hyperscale reports the same engine edition as General Purpose, so the collector cannot tell them apart when it builds the query, and now does not need to. **Nothing changes on the read side** - the column already existed and every dedup key and rollup that touches Query Store already partitions on `replica_role`, so an Azure server's rows go from one NULL group to one `Primary` group and the grids render identically. **Managed Instance is deliberately unchanged** and keeps the pure version gate, for the same reason `plan_type_desc` gives: MI under-reports its product version too, but its feature set follows a per-instance update policy rather than being evergreen, so "Azure means 2022+" is not sound there. Riding along, a stale comment correction found during the same validation ([#1871]): `BuildPayloadBody`'s summary said the two execution paths select "53 reader ordinals" when the real count is 55 - the class-level doc and the reader itself already agreed on 55, and the count had simply not been updated when [#1841] added the interval-identity pair. It matters because that one comment is the drift guard between the two paths' column sets, so a wrong number sits in exactly the place a future audit would trust.

- **CI: the version-bump check survives the deprecated/ layout transition** ([#1821]) - the check compares the PR's Dashboard.csproj version against MAIN's copy, but read main's copy only at the NEW deprecated/ path - which does not exist on main until the v3.3.0 promotion itself lands, so the release PR failed the check on a path error rather than a version verdict. The main-side read now falls back to the pre-move location; removable once main carries the new layout.

### Fixed
Expand Down Expand Up @@ -2069,6 +2071,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[#1863]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1863
[#1867]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1867
[#1869]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1869
[#1844]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1844
[#1848]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1848
[#1871]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1871
[#1872]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1872

[#1877]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1877
[#1879]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1879
[#1854]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1854
Expand Down
88 changes: 60 additions & 28 deletions Lite.Tests/QueryStoreCollectorDefinitionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -224,27 +224,51 @@ public void BuildQuery_NonAzure_Throws_EnumerationDrivesThoseTargets()
}

[Fact]
public void BuildQuery_Azure_GatesReplicaAttributionOff_ButKeepsTheColumn()
{
/* Explicitly gated off, not dropped (#1836): Azure emits the same nvarchar(1) NULL placeholder
at the same ordinal a pre-2022 box does, so the 55-ordinal reader contract is identical.
replica_group_id's own doc page names SQL Server 2022+ and is silent on Azure SQL Database
while its sibling columns name Azure explicitly, and Query Store for secondary replicas is
documented as unavailable on Hyperscale — a column that does not bind fails the WHOLE
payload for that database, and on Azure that means every database. */
public void BuildQuery_Azure_AttributesReplicaRole_ProvenLiveOnBothServiceTiers()
{
/* This pin was DELIBERATELY INVERTED by #1872 — it previously asserted the attribution absent.
#1836 gated it off for bind safety, not taste: replica_group_id's doc page names SQL Server
2022+ and is silent on Azure SQL Database while its sibling columns name Azure explicitly,
and Query Store for secondary replicas is documented as unavailable on Hyperscale. A column
that does not bind fails the WHOLE payload for that database, and on Azure that means every
database — so the gate stayed on until the docs' silence was answered by a live run.

It was, on both tiers the old gate worried about (2026-07-31 UTC, both Microsoft SQL Azure
(RTM) 12.0.2000.8, EngineEdition 5): General Purpose GP_S_Gen5_1 (#1848) and Hyperscale
HS_S_Gen5_2 (#1872) each returned OBJECT_ID('sys.query_store_replicas') = -660 and
COL_LENGTH('sys.query_store_runtime_stats', 'replica_group_id') = 8, and on Hyperscale the
full 55-column payload composed with the attribution ON bound 55 of 55 columns and returned
replica_role = 'Primary'. Do not re-flip this without new live evidence that it stopped
binding — re-read the two issue threads first. */
var plan = QueryStoreCollector.Instance.BuildQuery(MakeContext(isAzureSqlDb: true, probeResult: 16));

Assert.Contains("replica_role = CONVERT(nvarchar(1), NULL)", plan.Text, StringComparison.Ordinal);
Assert.DoesNotContain("sys.query_store_replicas", plan.Text, StringComparison.Ordinal);
Assert.DoesNotContain("replica_group_id", plan.Text, StringComparison.Ordinal);
Assert.Contains("replica_role = qsr.replica_name", plan.Text, StringComparison.Ordinal);
Assert.Contains("LEFT JOIN sys.query_store_replicas AS qsr", plan.Text, StringComparison.Ordinal);
Assert.Contains("ON qsr.replica_group_id = qsrs.replica_group_id", plan.Text, StringComparison.Ordinal);

/* plan_type_desc goes the OTHER way, deliberately: it lives on sys.query_store_plan, whose
applies-to banner names Azure SQL Database and whose only documented exclusion is Synapse
(engine edition 6, never IsAzureSqlDb). Azure SQL DB is evergreen, so the PRODUCTVERSION
probe — which reports major 12 there — must not decide it. */
/* Attribute, never filter — the same rule the on-prem 2022+ shape holds to. On Azure the
single-quoted body is NOT quote-doubled, so the literal would read N'Primary' here. */
Assert.DoesNotContain("replica_name = N'Primary'", plan.Text, StringComparison.Ordinal);

/* plan_type_desc rides the same "Azure means newest" rule, and always has: it lives on
sys.query_store_plan, whose applies-to banner names Azure SQL Database and whose only
documented exclusion is Synapse (engine edition 6, never IsAzureSqlDb). Azure SQL DB is
evergreen, so the PRODUCTVERSION probe — which reports major 12 there — must not decide it. */
Assert.Contains("plan_type = qsp.plan_type_desc,", plan.Text, StringComparison.Ordinal);
}

[Fact]
public void BuildQuery_Azure_AttributesReplicaRole_EvenWhenTheVersionProbeFails()
{
/* The Azure arm of the gate is an OR, not an AND: Azure SQL DB reports PRODUCTVERSION major 12
and the probe can fail outright (defaulting to 13), so if attribution depended on the version
at all it would never turn on there. Pins that the edition alone decides it (#1872). */
var plan = QueryStoreCollector.Instance.BuildQuery(MakeContext(isAzureSqlDb: true, probeResult: null));

Assert.Contains("replica_role = qsr.replica_name", plan.Text, StringComparison.Ordinal);
Assert.Contains("LEFT JOIN sys.query_store_replicas AS qsr", plan.Text, StringComparison.Ordinal);
}

[Fact]
public void BuildQuery_Azure_ClampsStaleWatermark_AndFallsBack60Minutes()
{
Expand Down Expand Up @@ -407,20 +431,28 @@ given target and this still pins that answer. */
/* plan_type flipped ON for Azure SQL DB with #1836 (was NULL here before): the probe reports
major 12 on Azure, but the engine is evergreen and sys.query_store_plan's applies-to banner
names Azure SQL Database — the same "Azure means newest" rule isNew has always used.
Managed Instance keeps the pure version gate, because its feature set follows a per-instance
update policy rather than being evergreen. */
replica_role joined it with #1872, once the catalog was proven to bind live on both the
General Purpose and Hyperscale service tiers. */
Assert.Contains("plan_type = qsp.plan_type_desc,", plan.Text, StringComparison.Ordinal);
Assert.Contains(
"plan_type = NULL,",
QueryStoreCollector.Instance.BuildPerItemQuery("SO", new CollectorContext
{
ServerId = 42,
ServerName = "test-server",
CollectionTime = new DateTime(2026, 7, 2, 12, 0, 0, DateTimeKind.Utc),
Deltas = s_deltas,
Target = new CollectorTargetInfo { IsAzureManagedInstance = true },
}).Text,
StringComparison.Ordinal);
Assert.Contains("replica_role = qsr.replica_name", plan.Text, StringComparison.Ordinal);

/* Managed Instance keeps the PURE VERSION GATE on both, and #1872 deliberately did not change
that: MI under-reports PRODUCTVERSION too, but its feature set follows a per-instance update
policy rather than being evergreen, so "Azure means 2022+" is not sound there — and unlike
Azure SQL DB, MI has no per-database path this could have been verified on. Its probe here
defaults to 13, so both columns emit their NULL placeholder. */
var managedInstance = QueryStoreCollector.Instance.BuildPerItemQuery("SO", new CollectorContext
{
ServerId = 42,
ServerName = "test-server",
CollectionTime = new DateTime(2026, 7, 2, 12, 0, 0, DateTimeKind.Utc),
Deltas = s_deltas,
Target = new CollectorTargetInfo { IsAzureManagedInstance = true },
}).Text;

Assert.Contains("plan_type = NULL,", managedInstance, StringComparison.Ordinal);
Assert.Contains("replica_role = CONVERT(nvarchar(1), NULL)", managedInstance, StringComparison.Ordinal);
Assert.DoesNotContain("sys.query_store_replicas", managedInstance, StringComparison.Ordinal);
}

[Fact]
Expand Down
59 changes: 39 additions & 20 deletions PerformanceMonitor.Collectors/QueryStoreCollector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,10 @@ public sealed class Row
/// <summary>
/// The replica role sys.query_store_replicas attributed this runtime-stats row to ('Primary',
/// 'Secondary', 'Geo Secondary', 'Geo HA Secondary'). NULL = the server did not attribute it
/// (pre-2022, a 2022 standalone whose sys.query_store_replicas is empty, or Azure SQL DB, where
/// the attribution is gated off for bind safety) — deliberately not coalesced. See
/// hasReplicaAttribution in <see cref="BuildPayloadBody"/>.
/// (pre-2022, a 2022 standalone whose sys.query_store_replicas is empty, or Managed Instance,
/// which keeps the pure version gate) — deliberately not coalesced. Azure SQL DB DOES attribute
/// as of #1872, having been gated off for bind safety until the catalog was proven live on both
/// General Purpose and Hyperscale. See hasReplicaAttribution in <see cref="BuildPayloadBody"/>.
/// </summary>
public string? ReplicaRole { get; set; }

Expand Down Expand Up @@ -411,7 +412,7 @@ public override CollectorQuery BuildQuery(CollectorContext context)
/// written as ordinary single-quoted T-SQL because that is what Azure SQL DB's per-database
/// connection executes verbatim; <see cref="BuildPerItemQuery"/> quote-doubles the same string to
/// nest it inside <c>[db].sys.sp_executesql N'...'</c> for on-prem. Both forms therefore select
/// the identical 53 reader ordinals in the identical order, which is the whole point: this method
/// the identical 55 reader ordinals in the identical order, which is the whole point: this method
/// is what stops the two paths from drifting into two column sets that one shared
/// <see cref="ReadItemAsync"/> then mis-reads.
///
Expand Down Expand Up @@ -470,22 +471,40 @@ replica SET number that accumulates per role across failovers.
role on an AG with the feature enabled. NULL honestly means "the server did not attribute
this row" and is deliberately NOT coalesced to an invented value.

Azure SQL DB is gated OFF explicitly (#1836) rather than riding the "Azure means newest"
rule plan_type_desc gets above, and the difference is bind SAFETY, not taste. The column
set does not change — Azure emits the same nvarchar(1) NULL placeholder at the same
ordinal a pre-2022 box does, so the reader contract is identical — only the attribution is
given up. Two reasons: sys.query_store_runtime_stats.replica_group_id is documented as
"SQL Server (Starting with SQL Server 2022 (16.x))" with NO Azure SQL Database in its
applies-to note, on a page whose sibling columns DO name Azure explicitly when they mean
it; and Query Store for secondary replicas is documented as unavailable on the Hyperscale
service tier, with the docs silent on whether the view and column still BIND there. A
missing column is not a NULL — it fails the whole SELECT for that database, and
on Azure this collector's per-database loop would then fail in every database, which is a
worse outcome than the attribution we are declining. Lifting this needs one live probe, not
a docs re-read: SELECT OBJECT_ID('sys.query_store_replicas'),
COL_LENGTH('sys.query_store_runtime_stats', 'replica_group_id') on a real Azure SQL DB
(including Hyperscale) — non-NULL on both is the evidence to flip it. */
bool hasReplicaAttribution = productVersion >= 16 && !context.Target.IsAzureSqlDb;
Azure SQL DB is ON, riding the same "Azure means newest" rule plan_type_desc gets above —
but only because the live probe the previous version of this comment demanded was actually
run. It was gated OFF from #1836 until then, and that carve-out was about bind SAFETY, not
taste: a missing column is not a NULL, it fails the whole SELECT for that database, and on
Azure this collector's per-database loop would then fail in EVERY database — a worse outcome
than declining the attribution. The docs still do not settle it (replica_group_id's
applies-to note names only "SQL Server (Starting with SQL Server 2022 (16.x))", and Query
Store for secondary replicas is documented as unavailable on the Hyperscale service tier,
silent on whether the view and column still BIND there), so this is flipped on live evidence
instead — gathered on both service tiers the old comment worried about, 2026-07-31 UTC, both
running Microsoft SQL Azure (RTM) 12.0.2000.8, EngineEdition 5:

- The exact probe the old comment specified, answered on both: General Purpose
(GP_S_Gen5_1, #1848) and Hyperscale (HS_S_Gen5_2, #1872) each returned
OBJECT_ID('sys.query_store_replicas') = -660 and
COL_LENGTH('sys.query_store_runtime_stats', 'replica_group_id') = 8.
- sys.query_store_replicas binds on both and holds the same 4 role rows box SQL Server has
(Primary, Secondary, Geo Secondary, Geo HA Secondary) — a static enumeration of ROLES,
not instances, which is why a database with 0 HA replicas does not empty it. Every
runtime-stats row carries replica_group_id = 1 and LEFT JOINs cleanly to 'Primary'.
- Decisive (#1872): the full 55-column payload composed with hasReplicaAttribution = true —
the exact text this method emits — executed on Hyperscale. 55 of 55 columns bound, exit
0, and replica_role came back 'Primary' on every row.

A per-tier gate was considered and rejected: Hyperscale reports EngineEdition 5, the same as
General Purpose, so the collector cannot tell the tiers apart at query-build time. It does
not need to — both bind identically.

Managed Instance deliberately keeps the pure version gate, for the same reason plan_type_desc
gives above: MI under-reports PRODUCTVERSION too, but its feature set follows a per-instance
update policy rather than being evergreen, so "Azure means 2022+" is not sound there and MI
has no per-database path to verify it on. IsAzureSqlDb is false for MI, so MI falls through
to productVersion >= 16 and stays off until a probe on MI says otherwise. */
bool hasReplicaAttribution = productVersion >= 16 || context.Target.IsAzureSqlDb;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Risk: this flips the gate ON for every Azure SQL DB (all engine-edition-5 tiers), but the live evidence only covers General Purpose and Hyperscale, neither with real secondary replicas.

The PR's own follow-up (#1887) admits Business Critical / Hyperscale-with-HA-replicas / provisioned Hyperscale are unverified — but Business Critical is exactly the tier replica_role exists to serve (it has real AG-style secondary replicas), and it's untested here.

If sys.query_store_replicas / replica_group_id don't bind on some tier, the failure isn't loud. I traced the per-database loop that runs this query (Lite/Services/RemoteCollectorService.DefinitionRunner.cs:135-213, mirrored in Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs): a SqlException on one database is caught and logged at Debug level, then that database is silently skipped (RemoteCollectorService.DefinitionRunner.cs:206-213). Only a 100%-failure-across-every-database-in-the-cycle case surfaces a Warning and rethrows.

So on a server with a mix of tiers (e.g. some GP, some Business Critical databases), a bind failure on the untested tier would quietly drop Query Store collection for just those databases, with no operator-visible signal at default log levels — the exact "every database going dark" scenario the removed comment warned about becomes instead a silent partial loss, which is arguably harder to notice.

Given the comment's own framing of the risk, might be worth verifying against a database with actual secondary replicas (Business Critical, or provisioned Hyperscale with HA) before this ships broadly, rather than deferring that check to #1887 after the gate is already on in production for all tiers.


/* Build version-conditional column fragments for the Query Store query.
None of these contain a single quote, so they splice into the body identically whether the
Expand Down
Loading