From aa4fbd11abfd6c055d80fa693162bed91ec86612 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:39:28 -0400 Subject: [PATCH 1/2] Fix Query Store gate on read-only secondary replicas Query Store access was gated on sys.database_query_store_options.actual_state_desc starting with "READ", so a readable secondary whose local state reports OFF was rejected as "not enabled" even though the primary's captured data is replicated and readable through sys.query_store_*. A Hyperscale named replica reports OFF; an Azure SQL DB geo-replica reports READ_CAPTURE_SECONDARY (which already passed). CheckEnabledAsync now also treats a read-only database that already holds Query Store data as readable, and returns whether the target is a read-only replica so callers stop advising ALTER DATABASE ... SET QUERY_STORE = ON on a database that can't be written to. The decision is extracted into IsQueryStoreReadable and covered by a truth-table test. Updated all five call sites (CLI, two MCP tools, two App controls). Validated end to end against a live Azure geo-replica and, via the reporter, a Hyperscale named replica (OFF / READ_ONLY / ~438k rows). Closes #378 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../QuerySessionControl.QueryStore.cs | 12 +++-- .../Controls/QueryStoreGridControl.axaml.cs | 6 ++- src/PlanViewer.App/Mcp/McpQueryStoreTools.cs | 11 +++-- .../Commands/QueryStoreCommand.cs | 7 ++- .../Services/QueryStoreService.cs | 48 +++++++++++++++---- .../QueryStoreReadableDecisionTests.cs | 32 +++++++++++++ 6 files changed, 96 insertions(+), 20 deletions(-) create mode 100644 tests/PlanViewer.Core.Tests/QueryStoreReadableDecisionTests.cs diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.QueryStore.cs b/src/PlanViewer.App/Controls/QuerySessionControl.QueryStore.cs index 3f54695..ca308f8 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.QueryStore.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.QueryStore.cs @@ -144,10 +144,12 @@ private async Task OpenQueryStoreForDatabaseAsync(string database, DateTime? ini SetStatus($"Checking Query Store on {database}..."); try { - var (enabled, state) = await QueryStoreService.CheckEnabledAsync(connStr); + var (enabled, state, readOnlyReplica) = await QueryStoreService.CheckEnabledAsync(connStr); if (!enabled) { - SetStatus($"Query Store not enabled on {database} ({state ?? "unknown"})"); + SetStatus(readOnlyReplica + ? $"{database} is a read-only replica with no Query Store data ({state ?? "unknown"}); enable it on the primary" + : $"Query Store not enabled on {database} ({state ?? "unknown"})"); return; } } @@ -203,10 +205,12 @@ private async void QueryStore_Click(object? sender, RoutedEventArgs e) SetStatus("Checking Query Store..."); try { - var (enabled, state) = await QueryStoreService.CheckEnabledAsync(_connectionString); + var (enabled, state, readOnlyReplica) = await QueryStoreService.CheckEnabledAsync(_connectionString); if (!enabled) { - SetStatus($"Query Store not enabled ({state ?? "unknown"})"); + SetStatus(readOnlyReplica + ? $"Read-only replica with no Query Store data ({state ?? "unknown"}); enable it on the primary" + : $"Query Store not enabled ({state ?? "unknown"})"); return; } } diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs index efcca82..aed7938 100644 --- a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs +++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs @@ -139,10 +139,12 @@ private async void QsDatabase_SelectionChanged(object? sender, SelectionChangedE try { - var (enabled, state) = await QueryStoreService.CheckEnabledAsync(newConnStr); + var (enabled, state, readOnlyReplica) = await QueryStoreService.CheckEnabledAsync(newConnStr); if (!enabled) { - StatusText.Text = $"Query Store not enabled on {db} ({state ?? "unknown"})"; + StatusText.Text = readOnlyReplica + ? $"{db} is a read-only replica with no Query Store data ({state ?? "unknown"})" + : $"Query Store not enabled on {db} ({state ?? "unknown"})"; QsDatabaseBox.SelectedItem = _database; // revert return; } diff --git a/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs b/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs index 7f60c6e..015b839 100644 --- a/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs +++ b/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs @@ -32,14 +32,15 @@ public static async Task CheckQueryStore( return ConnectionNotFound(connectionStore, connection_name); var connectionString = conn.GetConnectionString(credentialService, database); - var (enabled, state) = await QueryStoreService.CheckEnabledAsync(connectionString); + var (enabled, state, readOnlyReplica) = await QueryStoreService.CheckEnabledAsync(connectionString); return JsonSerializer.Serialize(new { server = conn.ServerName, database, query_store_enabled = enabled, - state + state, + read_only_replica = readOnlyReplica }, McpHelpers.JsonOptions); } catch (Exception ex) @@ -114,9 +115,11 @@ public static async Task GetQueryStoreTop( var connectionString = conn.GetConnectionString(credentialService, database); // Check Query Store is enabled first - var (enabled, state) = await QueryStoreService.CheckEnabledAsync(connectionString); + var (enabled, state, readOnlyReplica) = await QueryStoreService.CheckEnabledAsync(connectionString); if (!enabled) - return $"Query Store is not enabled on [{database}]. State: {state ?? "unknown"}."; + return readOnlyReplica + ? $"[{database}] is a read-only replica with no Query Store data to read (state: {state ?? "unknown"}). Enable Query Store on the primary replica." + : $"Query Store is not enabled on [{database}]. State: {state ?? "unknown"}."; // Fetch plans using the app's built-in query var plans = await QueryStoreService.FetchTopPlansAsync( diff --git a/src/PlanViewer.Cli/Commands/QueryStoreCommand.cs b/src/PlanViewer.Cli/Commands/QueryStoreCommand.cs index 5807570..da6aed5 100644 --- a/src/PlanViewer.Cli/Commands/QueryStoreCommand.cs +++ b/src/PlanViewer.Cli/Commands/QueryStoreCommand.cs @@ -282,11 +282,14 @@ private static async Task RunAsync( { // Verify Query Store is enabled Console.Error.Write($"Checking Query Store on {server}/{database}... "); - var (enabled, state) = await QueryStoreService.CheckEnabledAsync(connectionString); + var (enabled, state, readOnlyReplica) = await QueryStoreService.CheckEnabledAsync(connectionString); if (!enabled) { Console.Error.WriteLine($"NOT ENABLED (state: {state ?? "unknown"})"); - Console.Error.WriteLine("Enable Query Store: ALTER DATABASE [" + database + "] SET QUERY_STORE = ON;"); + if (readOnlyReplica) + Console.Error.WriteLine("This is a read-only replica with no Query Store data to read. Enable Query Store on the primary replica — it cannot be enabled here."); + else + Console.Error.WriteLine("Enable Query Store: ALTER DATABASE [" + database + "] SET QUERY_STORE = ON;"); Environment.ExitCode = 1; return; } diff --git a/src/PlanViewer.Core/Services/QueryStoreService.cs b/src/PlanViewer.Core/Services/QueryStoreService.cs index c201fa2..cc00de1 100644 --- a/src/PlanViewer.Core/Services/QueryStoreService.cs +++ b/src/PlanViewer.Core/Services/QueryStoreService.cs @@ -9,29 +9,61 @@ namespace PlanViewer.Core.Services; public static partial class QueryStoreService { + /// + /// Decides whether Query Store data can be read, given the local Query Store state and + /// whether the database is a read-only replica that already holds captured data. + /// A READ* state (READ_ONLY / READ_WRITE / READ_CAPTURE_SECONDARY) is readable directly. + /// A read-only replica reports its local state as OFF even though the primary's captured + /// data is replicated and readable, so a read-only replica that holds data is also + /// readable. A writable database whose Query Store is OFF/ERROR is not. See issue #378. + /// + public static bool IsQueryStoreReadable(string? state, bool readOnlyReplica, bool hasData) => + (state != null && state.StartsWith("READ", StringComparison.OrdinalIgnoreCase)) + || (readOnlyReplica && hasData); + /// /// Verifies Query Store is enabled and in a readable state on the target database. - /// Returns true if Query Store is accessible. + /// Returns true if Query Store data is accessible. /// - public static async Task<(bool Enabled, string? State)> CheckEnabledAsync( + /// + /// On a readable secondary replica (an Always On AG secondary, or an Azure SQL + /// geo-replica / read-scale-out replica) the database is read-only, so the local Query + /// Store capture engine can't run and actual_state_desc reports OFF — or, on + /// SQL 2022+/Azure with the secondary-replica feature enabled, READ_CAPTURE_SECONDARY. + /// Either way the primary's captured data is replicated into the database's internal + /// tables and is fully readable through the sys.query_store_* views (SSMS reads it there + /// too). So when the database is a read-only replica that already holds Query Store data, + /// treat it as enabled-for-reading even though the local state isn't a READ* state; + /// refusing to open would block a scenario that works. See issue #378. + /// + public static async Task<(bool Enabled, string? State, bool ReadOnlyReplica)> CheckEnabledAsync( string connectionString, CancellationToken ct = default) { const string sql = @" SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SELECT - actual_state_desc + actual_state_desc, + read_only_replica = + CONVERT(bit, CASE WHEN DATABASEPROPERTYEX(DB_NAME(), 'Updateability') = 'READ_ONLY' THEN 1 ELSE 0 END), + has_query_store_data = + CONVERT(bit, CASE WHEN EXISTS (SELECT 1 FROM sys.query_store_query) THEN 1 ELSE 0 END) FROM sys.database_query_store_options;"; await using var conn = new SqlConnection(connectionString); await conn.OpenAsync(ct); await using var cmd = new SqlCommand(sql, conn); - var state = (string?)await cmd.ExecuteScalarAsync(ct); + await using var reader = await cmd.ExecuteReaderAsync(ct); + + // No row means Query Store has never been configured on this database. + if (!await reader.ReadAsync(ct)) + return (false, null, false); - if (state == null) - return (false, null); + var state = reader.IsDBNull(0) ? null : reader.GetString(0); + var readOnlyReplica = !reader.IsDBNull(1) && reader.GetBoolean(1); + var hasData = !reader.IsDBNull(2) && reader.GetBoolean(2); - var enabled = state.StartsWith("READ", StringComparison.OrdinalIgnoreCase); - return (enabled, state); + var enabled = IsQueryStoreReadable(state, readOnlyReplica, hasData); + return (enabled, state, readOnlyReplica); } /// diff --git a/tests/PlanViewer.Core.Tests/QueryStoreReadableDecisionTests.cs b/tests/PlanViewer.Core.Tests/QueryStoreReadableDecisionTests.cs new file mode 100644 index 0000000..535c70b --- /dev/null +++ b/tests/PlanViewer.Core.Tests/QueryStoreReadableDecisionTests.cs @@ -0,0 +1,32 @@ +using PlanViewer.Core.Services; + +namespace PlanViewer.Core.Tests; + +// Locks in the Query Store readability gate, including the readable-secondary-replica case +// from issue #378 where the local state reports OFF but the primary's captured data is +// replicated and readable. See QueryStoreService.IsQueryStoreReadable. +public class QueryStoreReadableDecisionTests +{ + [Theory] + // Writable primary with Query Store on -> readable. + [InlineData("READ_WRITE", false, false, true)] + [InlineData("READ_ONLY", false, false, true)] + // SQL 2022+/Azure secondary-replica feature: local state is a READ* value -> readable. + [InlineData("READ_CAPTURE_SECONDARY", true, true, true)] + // #378: read-only replica reports OFF but the primary's data is replicated -> readable. + [InlineData("OFF", true, true, true)] + // Read-only replica that holds no Query Store data yet -> nothing to read. + [InlineData("OFF", true, false, false)] + // Writable primary with Query Store genuinely off or errored -> not readable. + [InlineData("OFF", false, false, false)] + [InlineData("ERROR", false, false, false)] + // Writable primary that is off: stale rows alone do not make it readable (only replicas relax). + [InlineData("OFF", false, true, false)] + // No row / null state -> not readable. + [InlineData(null, false, false, false)] + public void IsQueryStoreReadable_MatchesExpectedGate( + string? state, bool readOnlyReplica, bool hasData, bool expected) + { + Assert.Equal(expected, QueryStoreService.IsQueryStoreReadable(state, readOnlyReplica, hasData)); + } +} From 1dc6796a9c83a93ba997d658b7ed21273eb6055b Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:35:01 -0400 Subject: [PATCH 2/2] Bump version to 1.14.2 for release Ships the Query Store read-only-secondary gate fix (#379), the only change on dev since v1.14.1. Also sync the legacy SSMS extension version (source.extension.vsixmanifest + Properties/AssemblyInfo.cs) from 1.13.0 to 1.14.2; Directory.Build.props documents these must be bumped manually since the non-SDK project does not inherit it, and they were missed in the 1.14.0 and 1.14.1 releases. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Directory.Build.props | 2 +- src/PlanViewer.Ssms/Properties/AssemblyInfo.cs | 4 ++-- src/PlanViewer.Ssms/source.extension.vsixmanifest | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index da1ddbf..24579aa 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -15,7 +15,7 @@ Tests and server/ projects are outside src/ and are unaffected. --> - 1.14.1 + 1.14.2 Erik Darling Darling Data LLC Performance Studio diff --git a/src/PlanViewer.Ssms/Properties/AssemblyInfo.cs b/src/PlanViewer.Ssms/Properties/AssemblyInfo.cs index c895019..cab2b16 100644 --- a/src/PlanViewer.Ssms/Properties/AssemblyInfo.cs +++ b/src/PlanViewer.Ssms/Properties/AssemblyInfo.cs @@ -7,5 +7,5 @@ [assembly: AssemblyProduct("Performance Studio for SSMS")] [assembly: AssemblyCopyright("Copyright Darling Data 2026")] [assembly: ComVisible(false)] -[assembly: AssemblyVersion("1.13.0.0")] -[assembly: AssemblyFileVersion("1.13.0.0")] +[assembly: AssemblyVersion("1.14.2.0")] +[assembly: AssemblyFileVersion("1.14.2.0")] diff --git a/src/PlanViewer.Ssms/source.extension.vsixmanifest b/src/PlanViewer.Ssms/source.extension.vsixmanifest index f20fe4e..6e8b988 100644 --- a/src/PlanViewer.Ssms/source.extension.vsixmanifest +++ b/src/PlanViewer.Ssms/source.extension.vsixmanifest @@ -3,7 +3,7 @@ xmlns:d="http://schemas.microsoft.com/developer/vsx-schema-design/2011"> Performance Studio for SSMS