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
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
Tests and server/ projects are outside src/ and are unaffected.
-->
<PropertyGroup>
<Version>1.14.1</Version>
<Version>1.14.2</Version>
<Authors>Erik Darling</Authors>
<Company>Darling Data LLC</Company>
<Product>Performance Studio</Product>
Expand Down
12 changes: 8 additions & 4 deletions src/PlanViewer.App/Controls/QuerySessionControl.QueryStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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;
}
}
Expand Down
6 changes: 4 additions & 2 deletions src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
11 changes: 7 additions & 4 deletions src/PlanViewer.App/Mcp/McpQueryStoreTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,15 @@ public static async Task<string> 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)
Expand Down Expand Up @@ -114,9 +115,11 @@ public static async Task<string> 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(
Expand Down
7 changes: 5 additions & 2 deletions src/PlanViewer.Cli/Commands/QueryStoreCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
48 changes: 40 additions & 8 deletions src/PlanViewer.Core/Services/QueryStoreService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,29 +9,61 @@ namespace PlanViewer.Core.Services;

public static partial class QueryStoreService
{
/// <summary>
/// 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.
/// </summary>
public static bool IsQueryStoreReadable(string? state, bool readOnlyReplica, bool hasData) =>
(state != null && state.StartsWith("READ", StringComparison.OrdinalIgnoreCase))
|| (readOnlyReplica && hasData);

/// <summary>
/// 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.
/// </summary>
public static async Task<(bool Enabled, string? State)> CheckEnabledAsync(
/// <remarks>
/// 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 <c>actual_state_desc</c> 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.
/// </remarks>
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);
}

/// <summary>
Expand Down
4 changes: 2 additions & 2 deletions src/PlanViewer.Ssms/Properties/AssemblyInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
2 changes: 1 addition & 1 deletion src/PlanViewer.Ssms/source.extension.vsixmanifest
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
xmlns:d="http://schemas.microsoft.com/developer/vsx-schema-design/2011">
<Metadata>
<Identity Id="PlanViewer.Ssms.64F79022-9D9A-4463-A1AE-4B19426A0CB1"
Version="1.13.0"
Version="1.14.2"
Language="en-US"
Publisher="Darling Data" />
<DisplayName>Performance Studio for SSMS</DisplayName>
Expand Down
32 changes: 32 additions & 0 deletions tests/PlanViewer.Core.Tests/QueryStoreReadableDecisionTests.cs
Original file line number Diff line number Diff line change
@@ -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));
}
}
Loading