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.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/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 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)); + } +}