diff --git a/Darling/Darling.Tests/PlanSync4530DrillDownLiveTests.cs b/Darling/Darling.Tests/PlanSync4530DrillDownLiveTests.cs
new file mode 100644
index 0000000000..83be379195
--- /dev/null
+++ b/Darling/Darling.Tests/PlanSync4530DrillDownLiveTests.cs
@@ -0,0 +1,154 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Npgsql;
+using PerformanceMonitor.Analysis;
+using PerformanceMonitor.Collectors;
+using PerformanceMonitor.Darling.Analysis;
+using PerformanceMonitor.Darling.Storage;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #4530 — the drill-down half of the wiring.
+/// reads and passes the result into
+/// PlanAdvisoryAggregator.ExtractCancellable, so a PLAN_WARNING finding's drill-down should carry
+/// rule 38's Warning (not its Info branch) when the server is seeded as Standard Edition / MAXDOP 8.
+///
+/// #1776 own-store: mints its own scratch database (), never the
+/// shared live-postgres collection.
+///
+public sealed class PlanSync4530DrillDownLiveTests
+{
+ private const int TestServerId = -453_100;
+ private const string ServerName = "PlanSync4530DrillDownSrv";
+ private const string Db = "PlanSync4530Db";
+
+ /// Same DOP-2 batch-mode plan XML the reader/entry-point pins use.
+ private const string BatchModeDop2Xml = """
+
+
+
+
+
+
+
+
+
+
+
+
+ """;
+
+ private static async Task SeedQueryStatsPlanAsync(NpgsqlConnection connection)
+ {
+ await using var command = new NpgsqlCommand(@"
+INSERT INTO query_stats
+ (collection_id, collection_time, server_id, server_name, database_name, query_hash, query_plan_xml,
+ delta_worker_time)
+VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", connection);
+ command.Parameters.AddWithValue(CollectionIdGenerator.Next());
+ command.Parameters.AddWithValue(DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified));
+ command.Parameters.AddWithValue(TestServerId);
+ command.Parameters.AddWithValue(ServerName);
+ command.Parameters.AddWithValue(Db);
+ command.Parameters.AddWithValue("0x4530DOP2HASH");
+ command.Parameters.AddWithValue(BatchModeDop2Xml);
+ command.Parameters.AddWithValue(500_000L);
+ await command.ExecuteNonQueryAsync();
+ }
+
+ private static async Task SeedServerMetadataAsync(NpgsqlConnection connection)
+ {
+ await using (var props = new NpgsqlCommand(@"
+INSERT INTO server_properties (collection_id, collection_time, server_id, server_name, edition)
+VALUES ($1, $2, $3, $4, $5)", connection))
+ {
+ props.Parameters.AddWithValue(CollectionIdGenerator.Next());
+ props.Parameters.AddWithValue(DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified));
+ props.Parameters.AddWithValue(TestServerId);
+ props.Parameters.AddWithValue(ServerName);
+ props.Parameters.AddWithValue("Standard Edition (64-bit)");
+ await props.ExecuteNonQueryAsync();
+ }
+
+ await using (var cfg = new NpgsqlCommand(@"
+INSERT INTO server_config (config_id, capture_time, server_id, server_name, configuration_name, value_in_use)
+VALUES ($1, $2, $3, $4, $5, $6)", connection))
+ {
+ cfg.Parameters.AddWithValue(CollectionIdGenerator.Next());
+ cfg.Parameters.AddWithValue(DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified));
+ cfg.Parameters.AddWithValue(TestServerId);
+ cfg.Parameters.AddWithValue(ServerName);
+ cfg.Parameters.AddWithValue("max degree of parallelism");
+ cfg.Parameters.AddWithValue(8L);
+ await cfg.ExecuteNonQueryAsync();
+ }
+ }
+
+ [Fact]
+ public async Task PlanAdvisoryDrillDown_OnAStandardEditionMaxDop8Server_SurfacesRule38Warning()
+ {
+ var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG");
+ Assert.SkipWhen(string.IsNullOrEmpty(connectionString),
+ "Set DARLING_TEST_PG to a Postgres connection string to run the live #4530 drill-down test.");
+
+ var ct = TestContext.Current.CancellationToken;
+
+ await using var scratch = await ScratchPostgres.CreateAsync(connectionString!, ct);
+
+ await using (var connection = new NpgsqlConnection(scratch.ConnectionString))
+ {
+ await connection.OpenAsync(ct);
+ await PgMigrations.MigrateAsync(connection, ct);
+
+ await SeedQueryStatsPlanAsync(connection);
+ await SeedServerMetadataAsync(connection);
+ }
+
+ await using var postgres = NpgsqlDataSource.Create(scratch.ConnectionString);
+
+ var periodEnd = DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified);
+ var context = new AnalysisContext
+ {
+ ServerId = TestServerId,
+ ServerName = ServerName,
+ TimeRangeStart = periodEnd.AddHours(-4),
+ TimeRangeEnd = periodEnd,
+ ServerUtcOffset = TimeSpan.Zero,
+ };
+
+ var finding = new AnalysisFinding
+ {
+ RootFactKey = "PLAN_WARNING",
+ StoryPath = "PLAN_WARNING",
+ PathKeys = ["PLAN_WARNING"],
+ Severity = 1.0,
+ };
+
+ await new PgDrillDownCollector(postgres).EnrichFindingsAsync([finding], context);
+
+ Assert.NotNull(finding.DrillDown);
+ Assert.True(finding.DrillDown!.TryGetValue("plan_warnings", out var raw), "plan_warnings was not collected");
+
+ var warnings = JsonSerializer.SerializeToElement(raw);
+ var rule38 = warnings.EnumerateArray()
+ .Where(w => w.GetProperty("type").GetString() == "Standard Edition DOP Limitation")
+ .ToList();
+
+ Assert.Single(rule38);
+ Assert.Equal("Warning", rule38[0].GetProperty("severity").GetString());
+ Assert.Contains("MAXDOP is set to 8", rule38[0].GetProperty("message").GetString());
+ }
+}
diff --git a/Darling/Darling.Tests/PlanSync4530EntryPointsTests.cs b/Darling/Darling.Tests/PlanSync4530EntryPointsTests.cs
new file mode 100644
index 0000000000..a6d2724d82
--- /dev/null
+++ b/Darling/Darling.Tests/PlanSync4530EntryPointsTests.cs
@@ -0,0 +1,90 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using PerformanceMonitor.PlanAnalysis;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #4530 step 2 — wiring the resolved server's (edition, MAXDOP) into every
+/// plan-analysis ENTRY POINT, so rule 38 (Standard Edition DOP 2 limitation, ported and pinned by
+/// ) can give its Warning instead of its uninformative Info branch at the MCP
+/// tools and the drill-downs. This file pins the two shared-layer overloads the entry points call —
+///
+/// and
+/// — actually carry the metadata through to the analyzer, rather than the rule's own logic (already covered).
+///
+public sealed class PlanSync4530EntryPointsTests
+{
+ /// DOP 2, one batch-mode node (a columnstore scan). Rule 38's condition.
+ private static string BuildBatchModeDop2Xml(bool maxdop2Hint = false) => $"""
+
+
+
+
+
+
+
+
+
+
+
+
+ """;
+
+ ///
+ /// The MCP-tool entry point: the formatter's metadata overload produces the Warning, and the 5-arg
+ /// overload (no metadata, what the pasted-plan / analyze_plan_xml path uses) produces the Info, for
+ /// the SAME plan XML.
+ ///
+ [Fact]
+ public void BuildAnalysisResult_WithMetadata_StandardAndMaxDop8_ProducesWarning_NoMetadata_ProducesInfo()
+ {
+ var xml = BuildBatchModeDop2Xml();
+ var metadata = new ServerMetadata { Edition = "Standard Edition", MaxDop = 8 };
+
+ var withMetadata = McpPlanAnalysisFormatter.BuildAnalysisResult(xml, "srv", "xml", null, metadata, CancellationToken.None);
+ var withoutMetadata = McpPlanAnalysisFormatter.BuildAnalysisResult(xml, "srv", "xml", null, CancellationToken.None);
+
+ using var withDoc = JsonDocument.Parse(withMetadata);
+ using var withoutDoc = JsonDocument.Parse(withoutMetadata);
+
+ var withWarnings = withDoc.RootElement.GetProperty("statements")[0].GetProperty("warnings")
+ .EnumerateArray().Where(w => w.GetProperty("type").GetString() == "Standard Edition DOP Limitation").ToList();
+ var withoutWarnings = withoutDoc.RootElement.GetProperty("statements")[0].GetProperty("warnings")
+ .EnumerateArray().Where(w => w.GetProperty("type").GetString() == "Standard Edition DOP Limitation").ToList();
+
+ Assert.Single(withWarnings);
+ Assert.Equal("Warning", withWarnings[0].GetProperty("severity").GetString());
+ Assert.Contains("MAXDOP is set to 8", withWarnings[0].GetProperty("message").GetString());
+
+ Assert.Single(withoutWarnings);
+ Assert.Equal("Info", withoutWarnings[0].GetProperty("severity").GetString());
+ }
+
+ ///
+ /// The drill-down/aggregator entry point: PlanAdvisoryAggregator's new metadata overload
+ /// surfaces the rule-38 Warning in Details.Warnings; the metadata-less overload does not.
+ ///
+ [Fact]
+ public void PlanAdvisoryAggregator_ExtractCancellable_WithMetadata_SurfacesRule38Warning()
+ {
+ var xml = BuildBatchModeDop2Xml();
+ var metadata = new ServerMetadata { Edition = "Standard Edition", MaxDop = 8 };
+
+ var withMetadata = PlanAdvisoryAggregator.ExtractCancellable([xml], metadata, CancellationToken.None);
+ var withoutMetadata = PlanAdvisoryAggregator.ExtractCancellable([xml], CancellationToken.None);
+
+ Assert.Contains(withMetadata.Warnings, w => w.RuleNumber == 38 && w.Severity == PlanWarningSeverity.Warning);
+ Assert.DoesNotContain(withoutMetadata.Warnings, w => w.RuleNumber == 38 && w.Severity == PlanWarningSeverity.Warning);
+ }
+}
diff --git a/Darling/Darling.Tests/PlanSync4530ReaderLiveTests.cs b/Darling/Darling.Tests/PlanSync4530ReaderLiveTests.cs
new file mode 100644
index 0000000000..f587af6b7b
--- /dev/null
+++ b/Darling/Darling.Tests/PlanSync4530ReaderLiveTests.cs
@@ -0,0 +1,165 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Npgsql;
+using PerformanceMonitor.Collectors;
+using PerformanceMonitor.Darling.Storage;
+using PerformanceMonitor.PlanAnalysis;
+using Xunit;
+using static Darling.Tests.RepoFile;
+
+namespace Darling.Tests;
+
+///
+/// #4530 — the untested half of the wiring: that actually
+/// reads the edition and MAXDOP off a migrated store rather than a hand-built .
+/// and only ever pass a
+/// literal ServerMetadata into the analyzer; a wrong column or table name in
+/// would return null silently and rule
+/// 38 would stay on its Info branch on every real store, with nothing here to catch it.
+///
+/// Seeds collect.server_properties (edition "Standard Edition (64-bit)") and
+/// collect.server_config (max degree of parallelism = 8) with the store's real migrated
+/// columns, reads them back through the reader, and runs the SAME plan XML
+/// uses through the MCP entry point
+/// (DarlingMcpPlanTools.AnalyzeQueryPlan) to confirm rule 38 gives its Warning end to end.
+///
+/// #1776 own-store: this uses ScratchPostgres, not the shared live-postgres
+/// collection, and never touches another test's rows.
+///
+public sealed class PlanSync4530ReaderLiveTests
+{
+ private const int TestServerId = -453_000;
+
+ /// Same DOP-2 batch-mode plan XML uses.
+ private const string BatchModeDop2Xml = """
+
+
+
+
+
+
+
+
+
+
+
+
+ """;
+
+ private static async Task SeedServerPropertiesAsync(NpgsqlConnection connection, string edition)
+ {
+ await using var command = new NpgsqlCommand(@"
+INSERT INTO server_properties
+ (collection_id, collection_time, server_id, server_name, edition, product_version, product_level,
+ cpu_count, physical_memory_mb)
+VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", connection);
+ command.Parameters.AddWithValue(CollectionIdGenerator.Next());
+ command.Parameters.AddWithValue(DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified));
+ command.Parameters.AddWithValue(TestServerId);
+ command.Parameters.AddWithValue("PlanSync4530Srv");
+ command.Parameters.AddWithValue(edition);
+ command.Parameters.AddWithValue("16.0.4215.2");
+ command.Parameters.AddWithValue("RTM");
+ command.Parameters.AddWithValue(4);
+ command.Parameters.AddWithValue(16384L);
+ await command.ExecuteNonQueryAsync();
+ }
+
+ private static async Task SeedMaxDopAsync(NpgsqlConnection connection, int maxDop)
+ {
+ await using var command = new NpgsqlCommand(@"
+INSERT INTO server_config
+ (config_id, capture_time, server_id, server_name, configuration_name, value_configured, value_in_use)
+VALUES ($1, $2, $3, $4, $5, $6, $7)", connection);
+ command.Parameters.AddWithValue(CollectionIdGenerator.Next());
+ command.Parameters.AddWithValue(DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified));
+ command.Parameters.AddWithValue(TestServerId);
+ command.Parameters.AddWithValue("PlanSync4530Srv");
+ command.Parameters.AddWithValue("max degree of parallelism");
+ command.Parameters.AddWithValue((long)maxDop);
+ command.Parameters.AddWithValue((long)maxDop);
+ await command.ExecuteNonQueryAsync();
+ }
+
+ [Fact]
+ public async Task DarlingServerMetadataReader_ReadsEditionAndMaxDop_FromAMigratedStore_AndRule38Warns()
+ {
+ var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG");
+ Assert.SkipWhen(string.IsNullOrEmpty(connectionString),
+ "Set DARLING_TEST_PG to a Postgres connection string to run the live #4530 reader test.");
+
+ var ct = TestContext.Current.CancellationToken;
+
+ await using var scratch = await ScratchPostgres.CreateAsync(connectionString!, ct);
+
+ await using (var connection = new NpgsqlConnection(scratch.ConnectionString))
+ {
+ await connection.OpenAsync(ct);
+ await PgMigrations.MigrateAsync(connection, ct);
+
+ await SeedServerPropertiesAsync(connection, "Standard Edition (64-bit)");
+ await SeedMaxDopAsync(connection, 8);
+ }
+
+ await using var postgres = NpgsqlDataSource.Create(scratch.ConnectionString);
+
+ /* ---- (1) the reader itself, direct call, against the migrated store. ---- */
+ var metadata = await DarlingServerMetadataReader.ReadAsync(postgres, TestServerId, ct);
+ Assert.NotNull(metadata);
+ Assert.Contains("Standard", metadata!.Edition, StringComparison.Ordinal);
+ Assert.Equal(8, metadata.MaxDop);
+
+ /* ---- (2) through the product's own analysis path: rule 38 gives its Warning. ---- */
+ var withMetadata = McpPlanAnalysisFormatter.BuildAnalysisResult(
+ BatchModeDop2Xml, "PlanSync4530Srv", "xml", null, metadata, ct);
+ using var withDoc = JsonDocument.Parse(withMetadata);
+ var warning = withDoc.RootElement.GetProperty("statements")[0].GetProperty("warnings")
+ .EnumerateArray().Single(w => w.GetProperty("type").GetString() == "Standard Edition DOP Limitation");
+ Assert.Equal("Warning", warning.GetProperty("severity").GetString());
+ Assert.Contains("MAXDOP is set to 8", warning.GetProperty("message").GetString());
+
+ /* ---- (3) a server with no rows at all: reader returns null, rule 38 stays Info. ---- */
+ var noRowsMetadata = await DarlingServerMetadataReader.ReadAsync(postgres, TestServerId - 1, ct);
+ Assert.Null(noRowsMetadata);
+
+ var withoutMetadata = McpPlanAnalysisFormatter.BuildAnalysisResult(
+ BatchModeDop2Xml, "PlanSync4530Srv", "xml", null, noRowsMetadata, ct);
+ using var withoutDoc = JsonDocument.Parse(withoutMetadata);
+ var info = withoutDoc.RootElement.GetProperty("statements")[0].GetProperty("warnings")
+ .EnumerateArray().Single(w => w.GetProperty("type").GetString() == "Standard Edition DOP Limitation");
+ Assert.Equal("Info", info.GetProperty("severity").GetString());
+ }
+
+ ///
+ /// Source-census pin: Lite's GetServerMetadataForPlanAnalysisAsync SQL (Lite.Tests
+ /// can't run on macOS, so this is read by hand there — see the RED note in the PR) names the same
+ /// views and columns as Darling's migrated schema, so both SKUs answer rule 38 off equivalent data.
+ ///
+ [Fact]
+ public void LiteReaderSql_NamesTheSameViewAndColumns_AsDarlingsMigratedSchema()
+ {
+ var liteSource = ReadRepoFile(Path.Combine("Lite", "Services", "LocalDataService.PlanServerMetadata.cs"));
+
+ Assert.Contains("FROM v_server_properties", liteSource, StringComparison.Ordinal);
+ Assert.Contains("FROM v_server_config", liteSource, StringComparison.Ordinal);
+ Assert.Contains("edition", liteSource, StringComparison.Ordinal);
+ Assert.Contains("'max degree of parallelism'", liteSource, StringComparison.Ordinal);
+ Assert.Contains("value_in_use", liteSource, StringComparison.Ordinal);
+
+ /* Darling's own reader SQL, so a rename on one side without the other fails here. */
+ Assert.Contains("edition", DarlingServerMetadataReader.ServerMetadataSql, StringComparison.Ordinal);
+ Assert.Contains("max degree of parallelism", DarlingServerMetadataReader.ServerMetadataSql, StringComparison.Ordinal);
+ Assert.Contains("value_in_use", DarlingServerMetadataReader.ServerMetadataSql, StringComparison.Ordinal);
+ }
+}
diff --git a/Darling/Darling.Tests/packages.lock.json b/Darling/Darling.Tests/packages.lock.json
index 096a802291..86eadb2935 100644
--- a/Darling/Darling.Tests/packages.lock.json
+++ b/Darling/Darling.Tests/packages.lock.json
@@ -803,7 +803,8 @@
"dependencies": {
"Npgsql": "[10.0.3, )",
"PerformanceMonitor.Collectors": "[3.8.0, )",
- "PerformanceMonitor.Common": "[3.8.0, )"
+ "PerformanceMonitor.Common": "[3.8.0, )",
+ "PerformanceMonitor.PlanAnalysis": "[3.8.0, )"
}
},
"performancemonitor.darling.viewer": {
diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Plans.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Plans.cs
index e991eca8f2..0d024abd11 100644
--- a/Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Plans.cs
+++ b/Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Plans.cs
@@ -82,7 +82,9 @@ is NOT swallowed here (#2443). */
if (!string.IsNullOrWhiteSpace(plan.ParseError))
return;
- PlanAnalysisPipeline.Run(plan, context.CancellationToken);
+ // #4530: one store read per drill-down call so rule 38 can see the server's edition/MAXDOP.
+ var metadata = await DarlingServerMetadataReader.ReadAsync(_postgres, context.ServerId, context.CancellationToken);
+ PlanAnalysisPipeline.Run(plan, null, metadata, context.CancellationToken);
// #4514: includes statements nested inside a stored procedure or UDF body, so a
// finding inside an EXEC plan's body reaches the drill-down.
@@ -182,7 +184,9 @@ private async Task CollectPlanAdvisoryDetail(AnalysisFinding finding, AnalysisCo
if (planXmls.Count == 0)
return;
- var details = PlanAdvisoryAggregator.ExtractCancellable(planXmls, context.CancellationToken);
+ // #4530: one store read per collector call so rule 38 can see the server's edition/MAXDOP.
+ var metadata = await DarlingServerMetadataReader.ReadAsync(_postgres, context.ServerId, context.CancellationToken);
+ var details = PlanAdvisoryAggregator.ExtractCancellable(planXmls, metadata, context.CancellationToken);
if (pathKeys.Contains("MISSING_INDEX") && details.MissingIndexes.Count > 0)
{
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanTools.cs
index ff0f799c23..96d108982c 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanTools.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanTools.cs
@@ -13,6 +13,7 @@
using ModelContextProtocol.Server;
using Npgsql;
using PerformanceMonitor.Common;
+using PerformanceMonitor.Darling.Storage;
using PerformanceMonitor.PlanAnalysis;
#pragma warning disable CA1707 // MCP tools use snake_case naming convention
@@ -71,7 +72,10 @@ public static async Task AnalyzeQueryPlan(
"unavailable",
$"No stored plan found for query_hash '{query_hash}'{DbSuffix(database_name)}. The plan collector may not have captured a plan for this query, or the row has aged out of the store.");
- return McpPlanAnalysisFormatter.BuildAnalysisResult(xml, resolved.ServerName, "query_stats", query_hash, cancellationToken);
+ // #4530: one store read per call so rule 38 can see the server's edition/MAXDOP. Non-fatal
+ // (null on a miss or a read failure) — the analyzer then falls back to its Info branch.
+ var metadata = await DarlingServerMetadataReader.ReadAsync(postgres, resolved.ServerId, cancellationToken);
+ return McpPlanAnalysisFormatter.BuildAnalysisResult(xml, resolved.ServerName, "query_stats", query_hash, metadata, cancellationToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
@@ -107,7 +111,9 @@ public static async Task AnalyzeProcedurePlan(
"unavailable",
$"No stored plan found for sql_handle '{sql_handle}'. The plan collector may not have captured a plan for this procedure, or the row has aged out of the store.");
- return McpPlanAnalysisFormatter.BuildAnalysisResult(xml, resolved.ServerName, "procedure_stats", sql_handle, cancellationToken);
+ // #4530: one store read per call so rule 38 can see the server's edition/MAXDOP.
+ var metadata = await DarlingServerMetadataReader.ReadAsync(postgres, resolved.ServerId, cancellationToken);
+ return McpPlanAnalysisFormatter.BuildAnalysisResult(xml, resolved.ServerName, "procedure_stats", sql_handle, metadata, cancellationToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
@@ -141,7 +147,9 @@ public static async Task AnalyzeQueryStorePlan(
$"No stored Query Store plan found for query_id {query_id} in database '{database_name}'{PlanSuffix(plan_id)}. Query Store plan capture may be disabled for this database, or the plan has been purged.");
var identifier = plan_id is null ? $"{database_name}:{query_id}" : $"{database_name}:{query_id}:{plan_id}";
- return McpPlanAnalysisFormatter.BuildAnalysisResult(xml, resolved.ServerName, "query_store", identifier, cancellationToken);
+ // #4530: one store read per call so rule 38 can see the server's edition/MAXDOP.
+ var metadata = await DarlingServerMetadataReader.ReadAsync(postgres, resolved.ServerId, cancellationToken);
+ return McpPlanAnalysisFormatter.BuildAnalysisResult(xml, resolved.ServerName, "query_store", identifier, metadata, cancellationToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
diff --git a/Darling/PerformanceMonitor.Darling.Storage/DarlingServerMetadataReader.cs b/Darling/PerformanceMonitor.Darling.Storage/DarlingServerMetadataReader.cs
new file mode 100644
index 0000000000..260e6b29da
--- /dev/null
+++ b/Darling/PerformanceMonitor.Darling.Storage/DarlingServerMetadataReader.cs
@@ -0,0 +1,91 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Npgsql;
+using PerformanceMonitor.PlanAnalysis;
+
+namespace PerformanceMonitor.Darling.Storage;
+
+///
+/// Reads the store's newest server_properties row plus max degree of parallelism from
+/// server_config so plan-analysis rule 38 (#4530) can see the server's edition and MAXDOP without
+/// a live connection. Darling analyzes STORED plans (, the drill-downs),
+/// where erikdarlingdata/PerformanceStudio fetches the same two values live off the target server
+/// (ServerMetadataService.FetchServerMetadataAsync) — PM has no live session at these call sites,
+/// so it reads the collected copy instead. A failure or no rows is non-fatal (returns null), same
+/// as PS's fetch, because a missing edition/MAXDOP degrades rule 38 to its Info branch rather than failing
+/// the whole analysis.
+///
+public static class DarlingServerMetadataReader
+{
+ ///
+ /// One statement per store: the newest server_properties row for hardware/edition, LEFT JOINed to
+ /// the latest max degree of parallelism value from server_config (the same
+ /// latest-value-per-name pattern PgFactCollector.Config.cs.ServerConfigSql uses, narrowed to the
+ /// one setting rule 38 needs). Both anchored on server_id; no time window, because "the server's
+ /// current metadata" has no window, matching every other latest-snapshot config read in this store.
+ ///
+ public const string ServerMetadataSql = @"
+WITH props AS (
+ SELECT server_name, edition, product_version, product_level, cpu_count, physical_memory_mb
+ FROM server_properties
+ WHERE server_id = $1
+ ORDER BY collection_time DESC
+ LIMIT 1
+),
+maxdop AS (
+ SELECT value_in_use
+ FROM server_config
+ WHERE server_id = $1
+ AND configuration_name = 'max degree of parallelism'
+ ORDER BY capture_time DESC
+ LIMIT 1
+)
+SELECT props.server_name, props.edition, props.product_version, props.product_level,
+ props.cpu_count, props.physical_memory_mb, maxdop.value_in_use
+FROM props
+LEFT JOIN maxdop ON true";
+
+ ///
+ /// The server's edition and MAXDOP for plan analysis, or null when the store has no
+ /// server_properties row for this server, or the read fails.
+ ///
+ public static async Task ReadAsync(
+ NpgsqlDataSource postgres, int serverId, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ await using var connection = await postgres.OpenConnectionAsync(cancellationToken);
+ await using var command = new NpgsqlCommand(ServerMetadataSql, connection);
+ command.CommandTimeout = StorageCommandDeadlines.McpReadSeconds;
+ command.Parameters.AddWithValue(serverId);
+
+ await using var reader = await command.ExecuteReaderAsync(cancellationToken);
+ if (!await reader.ReadAsync(cancellationToken))
+ return null;
+
+ return new ServerMetadata
+ {
+ ServerName = reader.IsDBNull(0) ? null : reader.GetString(0),
+ Edition = reader.IsDBNull(1) ? null : reader.GetString(1),
+ ProductVersion = reader.IsDBNull(2) ? null : reader.GetString(2),
+ ProductLevel = reader.IsDBNull(3) ? null : reader.GetString(3),
+ CpuCount = reader.IsDBNull(4) ? 0 : Convert.ToInt32(reader.GetValue(4)),
+ PhysicalMemoryMB = reader.IsDBNull(5) ? 0L : Convert.ToInt64(reader.GetValue(5)),
+ MaxDop = reader.IsDBNull(6) ? 0 : Convert.ToInt32(Convert.ToDouble(reader.GetValue(6))),
+ };
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ return null;
+ }
+ }
+}
diff --git a/Darling/PerformanceMonitor.Darling.Storage/PerformanceMonitor.Darling.Storage.csproj b/Darling/PerformanceMonitor.Darling.Storage/PerformanceMonitor.Darling.Storage.csproj
index 2e99dc0b54..1c46d92dfd 100644
--- a/Darling/PerformanceMonitor.Darling.Storage/PerformanceMonitor.Darling.Storage.csproj
+++ b/Darling/PerformanceMonitor.Darling.Storage/PerformanceMonitor.Darling.Storage.csproj
@@ -23,6 +23,9 @@
(HealthSeverity) and the readonly_reason decode (QueryStoreReadonlyReason), both of which are
Common's single copies. Referencing Common is cheaper than restating either one here. -->
+
+
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ProcedureHistoryWindow.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/ProcedureHistoryWindow.xaml.cs
index 98c75b9c22..492d3e5e79 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ProcedureHistoryWindow.xaml.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ProcedureHistoryWindow.xaml.cs
@@ -246,6 +246,7 @@ private async void ViewPlan_Click(object sender, RoutedEventArgs e)
var viewer = new PlanViewerControl();
try
{
+ viewer.ServerMetadata = await _dataService.GetPlanAnalysisServerMetadataAsync(_serverId);
await viewer.LoadPlan(planXml, label, queryText: null);
}
catch (Exception ex)
@@ -308,6 +309,7 @@ private async Task FetchAndShowLivePlanAsync(string argsJson, string label, stri
var viewer = new PlanViewerControl();
try
{
+ viewer.ServerMetadata = await _dataService.GetPlanAnalysisServerMetadataAsync(_serverId);
await viewer.LoadPlan(result.PlanXml, label, queryText);
}
catch (Exception ex)
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/QueryStatsHistoryWindow.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/QueryStatsHistoryWindow.xaml.cs
index 70f403968b..35f096548e 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/QueryStatsHistoryWindow.xaml.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/QueryStatsHistoryWindow.xaml.cs
@@ -245,6 +245,7 @@ private async void ViewPlan_Click(object sender, RoutedEventArgs e)
var viewer = new PlanViewerControl();
try
{
+ viewer.ServerMetadata = await _dataService.GetPlanAnalysisServerMetadataAsync(_serverId);
await viewer.LoadPlan(planXml, label, _queryText);
}
catch (Exception ex)
@@ -283,7 +284,7 @@ private async void GetActualPlan_Click(object sender, RoutedEventArgs e)
_actualPlanCts.Token);
if (planXml != null)
- await ViewerActualPlanFlow.OpenFloatingPlanAsync(this, planXml, label, _queryText);
+ await ViewerActualPlanFlow.OpenFloatingPlanAsync(this, _dataService, _serverId, planXml, label, _queryText);
}
/// "View Cached Plan" — asks the service to read the RIGHT-CLICKED snapshot's plan from the target's
@@ -333,6 +334,7 @@ private async Task FetchAndShowLivePlanAsync(string argsJson, string label, stri
var viewer = new PlanViewerControl();
try
{
+ viewer.ServerMetadata = await _dataService.GetPlanAnalysisServerMetadataAsync(_serverId);
await viewer.LoadPlan(result.PlanXml, label, queryText);
}
catch (Exception ex)
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/QueryStoreHistoryWindow.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/QueryStoreHistoryWindow.xaml.cs
index d8f7d6534d..a4cd4a33d2 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/QueryStoreHistoryWindow.xaml.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/QueryStoreHistoryWindow.xaml.cs
@@ -278,6 +278,7 @@ private async void ViewPlan_Click(object sender, RoutedEventArgs e)
var viewer = new PlanViewerControl();
try
{
+ viewer.ServerMetadata = await _dataService.GetPlanAnalysisServerMetadataAsync(_serverId);
await viewer.LoadPlan(planXml, label, _queryText);
}
catch (Exception ex)
@@ -318,7 +319,7 @@ private async void GetActualPlan_Click(object sender, RoutedEventArgs e)
_actualPlanCts.Token);
if (planXml != null)
- await ViewerActualPlanFlow.OpenFloatingPlanAsync(this, planXml, label, _queryText);
+ await ViewerActualPlanFlow.OpenFloatingPlanAsync(this, _dataService, _serverId, planXml, label, _queryText);
}
// ── Column Filter Popup (mirrors WaitDrillDownWindow / ViewerServerTab.Filters.cs) ──
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerActualPlanFlow.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerActualPlanFlow.cs
index 16258cb414..5888d8dff1 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerActualPlanFlow.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerActualPlanFlow.cs
@@ -102,11 +102,15 @@ public static class ViewerActualPlanFlow
/// via — the plan host the history / drill-down windows use (they have no
/// Plan Viewer tab). The Top Queries surface opens into its tab instead, so it does not call this.
///
- public static async Task OpenFloatingPlanAsync(Window owner, string planXml, string label, string? queryText)
+ public static async Task OpenFloatingPlanAsync(
+ Window owner, ViewerDataService dataService, int serverId, string planXml, string label, string? queryText)
{
var viewer = new PlanViewerControl { AccuracyRatioDivergenceLimit = ViewerExportSettings.AccuracyRatioDivergenceLimit };
try
{
+ /* #4530: best-effort — a failed metadata read leaves ServerMetadata null, same as the pre-#4530
+ behaviour, so a viewer with no store row for this server still opens the plan. */
+ viewer.ServerMetadata = await dataService.GetPlanAnalysisServerMetadataAsync(serverId);
await viewer.LoadPlan(planXml, label, queryText);
}
catch (Exception ex)
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs
index b01d47799a..3ea675ebb3 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs
@@ -2515,6 +2515,17 @@ a Timescale store the hypertable distinguishes a real V23 from a store still at
return 16;
}
+ ///
+ /// #4530: the server's edition/MAXDOP for plan-analysis rule 38, for the viewer's own plan-opening
+ /// sites () — this app has no
+ /// live connection to the monitored server at plan-view time, so it reads the collected copy the
+ /// same way the MCP plan tools and the drill-downs do. Non-fatal: a missing row or a read failure
+ /// returns null, same as itself.
+ ///
+ public Task GetPlanAnalysisServerMetadataAsync(
+ int serverId, CancellationToken cancellationToken = default) =>
+ DarlingServerMetadataReader.ReadAsync(_dataSource, serverId, cancellationToken);
+
/// All registered servers, ordered as the server list displays them.
public async Task> GetServersAsync(CancellationToken cancellationToken = default)
{
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Plans.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Plans.cs
index d22c786b9f..eac57ded48 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Plans.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Plans.cs
@@ -57,6 +57,7 @@ private async Task OpenPlanTab(string planXml, string label, string? queryText =
var viewer = new PlanViewerControl { AccuracyRatioDivergenceLimit = ViewerExportSettings.AccuracyRatioDivergenceLimit };
try
{
+ viewer.ServerMetadata = await _dataService.GetPlanAnalysisServerMetadataAsync(_server.ServerId);
await viewer.LoadPlan(planXml, label, queryText);
}
catch (System.Xml.XmlException ex)
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/WaitDrillDownWindow.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/WaitDrillDownWindow.xaml.cs
index 279d41741b..5ad3ba21ef 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/WaitDrillDownWindow.xaml.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/WaitDrillDownWindow.xaml.cs
@@ -385,6 +385,7 @@ as an unhandled exception. Same fetch-failure idiom as ViewerServerTab.Plans.cs.
var viewer = new PlanViewerControl();
try
{
+ viewer.ServerMetadata = await _dataService.GetPlanAnalysisServerMetadataAsync(_serverId);
await viewer.LoadPlan(planXml, label, row.QueryText);
}
catch (Exception ex)
@@ -469,7 +470,7 @@ every snapshot would show as "may modify data" regardless of what was actually c
_actualPlanCts.Token);
if (planXml != null)
- await ViewerActualPlanFlow.OpenFloatingPlanAsync(this, planXml, label, row.QueryText);
+ await ViewerActualPlanFlow.OpenFloatingPlanAsync(this, _dataService, _serverId, planXml, label, row.QueryText);
}
private void Close_Click(object sender, RoutedEventArgs e) => Close();
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/packages.lock.json b/Darling/PerformanceMonitor.Darling.Viewer/packages.lock.json
index ff18314c8d..c3897b3573 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/packages.lock.json
+++ b/Darling/PerformanceMonitor.Darling.Viewer/packages.lock.json
@@ -502,7 +502,8 @@
"dependencies": {
"Npgsql": "[10.0.3, )",
"PerformanceMonitor.Collectors": "[3.8.0, )",
- "PerformanceMonitor.Common": "[3.8.0, )"
+ "PerformanceMonitor.Common": "[3.8.0, )",
+ "PerformanceMonitor.PlanAnalysis": "[3.8.0, )"
}
},
"performancemonitor.notifications": {
diff --git a/Lite.Tests/PlanServerMetadataReaderTests.cs b/Lite.Tests/PlanServerMetadataReaderTests.cs
new file mode 100644
index 0000000000..3fea8887f6
--- /dev/null
+++ b/Lite.Tests/PlanServerMetadataReaderTests.cs
@@ -0,0 +1,113 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor Lite.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Threading.Tasks;
+using DuckDB.NET.Data;
+using PerformanceMonitorLite.Services;
+using Xunit;
+
+namespace PerformanceMonitorLite.Tests;
+
+///
+/// #4530 — the untested half of Lite's plan-metadata wiring. (Darling.Tests,
+/// ported to this repo) only ever passes a hand-built ServerMetadata into the analyzer; this pins
+/// against a seeded DuckDB fixture, so
+/// a wrong view or column name in PlanServerMetadataSql fails here instead of returning null on
+/// every real install and leaving rule 38 on its uninformative Info branch.
+///
+public sealed class PlanServerMetadataReaderTests : IClassFixture, IDisposable
+{
+ private readonly SharedDuckDbFixture _fixture;
+ private readonly LocalDataService _dataService;
+ private long _nextId = -4_530_900;
+ private DuckDBConnection? _seedConn;
+
+ private const int ServerId = -453_090;
+
+ public PlanServerMetadataReaderTests(SharedDuckDbFixture fixture)
+ {
+ fixture.ResetData();
+ _fixture = fixture;
+ _dataService = new LocalDataService(fixture.DuckDb);
+ }
+
+ public void Dispose() => _seedConn?.Dispose();
+
+ private async Task SeedConnectionAsync()
+ {
+ if (_seedConn is null)
+ {
+ _seedConn = _fixture.DuckDb.CreateConnection();
+ await _seedConn.OpenAsync();
+ }
+ return _seedConn;
+ }
+
+ private async Task SeedServerPropertiesAsync(string edition)
+ {
+ using var readLock = _fixture.DuckDb.AcquireReadLock();
+ var conn = await SeedConnectionAsync();
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = @"
+INSERT INTO server_properties (collection_id, collection_time, server_id, server_name, edition, product_version, product_level, engine_edition, cpu_count, physical_memory_mb)
+VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)";
+ void P(object v) => cmd.Parameters.Add(new DuckDBParameter { Value = v });
+ P(_nextId--);
+ P(DateTime.UtcNow);
+ P(ServerId);
+ P("PlanServerMetadataSrv");
+ P(edition);
+ P("16.0.4150.1");
+ P("RTM");
+ P(2);
+ P(4);
+ P(16384L);
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ private async Task SeedMaxDopAsync(int maxDop)
+ {
+ using var readLock = _fixture.DuckDb.AcquireReadLock();
+ var conn = await SeedConnectionAsync();
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = @"
+INSERT INTO server_config (config_id, capture_time, server_id, server_name, configuration_name, value_configured, value_in_use)
+VALUES ($1, $2, $3, $4, $5, $6, $7)";
+ void P(object v) => cmd.Parameters.Add(new DuckDBParameter { Value = v });
+ P(_nextId--);
+ P(DateTime.UtcNow);
+ P(ServerId);
+ P("PlanServerMetadataSrv");
+ P("max degree of parallelism");
+ P((long)maxDop);
+ P((long)maxDop);
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ [Fact]
+ public async Task GetServerMetadataForPlanAnalysisAsync_ReadsEditionAndMaxDop_FromTheSeededFixture()
+ {
+ await SeedServerPropertiesAsync("Standard Edition (64-bit)");
+ await SeedMaxDopAsync(8);
+
+ var metadata = await _dataService.GetServerMetadataForPlanAnalysisAsync(ServerId);
+
+ Assert.NotNull(metadata);
+ Assert.Contains("Standard", metadata!.Edition, StringComparison.Ordinal);
+ Assert.Equal(8, metadata.MaxDop);
+ }
+
+ [Fact]
+ public async Task GetServerMetadataForPlanAnalysisAsync_NoRows_ReturnsNull()
+ {
+ var metadata = await _dataService.GetServerMetadataForPlanAnalysisAsync(ServerId);
+
+ Assert.Null(metadata);
+ }
+}
diff --git a/Lite/Analysis/DrillDownCollector.Plans.cs b/Lite/Analysis/DrillDownCollector.Plans.cs
index 0dee2f53a6..9d70ff766c 100644
--- a/Lite/Analysis/DrillDownCollector.Plans.cs
+++ b/Lite/Analysis/DrillDownCollector.Plans.cs
@@ -80,7 +80,9 @@ is NOT swallowed here (#2443). */
if (!string.IsNullOrWhiteSpace(plan.ParseError))
return;
- PlanAnalysisPipeline.Run(plan, context.CancellationToken);
+ // #4530: one store read per drill-down call so rule 38 can see the server's edition/MAXDOP.
+ var metadata = await ReadServerMetadataForPlanAnalysisAsync(context.ServerId, context.CancellationToken);
+ PlanAnalysisPipeline.Run(plan, null, metadata, context.CancellationToken);
// #4514: includes statements nested inside a stored procedure or UDF body, so a
// finding inside an EXEC plan's body reaches the drill-down.
@@ -128,6 +130,53 @@ is NOT swallowed here (#2443). */
}
}
+ ///
+ /// #4530: the drill-down's own store read for rule 38 (edition/MAXDOP), against the same DuckDB
+ /// connection pattern the collector's other reads here use. Mirrors
+ /// DarlingServerMetadataReader.ReadAsync; a failure or no rows returns null.
+ ///
+ private async Task ReadServerMetadataForPlanAnalysisAsync(int serverId, System.Threading.CancellationToken cancellationToken)
+ {
+ try
+ {
+ using var readLock = _duckDb.AcquireReadLock(cancellationToken);
+ using var connection = _duckDb.CreateConnection();
+ await connection.OpenAsync(cancellationToken);
+
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = @"
+SELECT p.edition, p.product_version, p.product_level, p.cpu_count, p.physical_memory_mb,
+ (SELECT c.value_in_use
+ FROM v_server_config AS c
+ WHERE c.server_id = $1
+ AND c.configuration_name = 'max degree of parallelism'
+ AND c.capture_time = (SELECT MAX(capture_time) FROM v_server_config WHERE server_id = $1)
+ LIMIT 1) AS max_dop
+FROM v_server_properties AS p
+WHERE p.server_id = $1
+ORDER BY p.collection_time DESC
+LIMIT 1";
+ cmd.Parameters.Add(new DuckDBParameter { Value = serverId });
+
+ using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
+ if (!await reader.ReadAsync(cancellationToken)) return null;
+
+ return new PerformanceMonitor.PlanAnalysis.ServerMetadata
+ {
+ Edition = reader.IsDBNull(0) ? null : reader.GetString(0),
+ ProductVersion = reader.IsDBNull(1) ? null : reader.GetString(1),
+ ProductLevel = reader.IsDBNull(2) ? null : reader.GetString(2),
+ CpuCount = reader.IsDBNull(3) ? 0 : reader.GetInt32(3),
+ PhysicalMemoryMB = reader.IsDBNull(4) ? 0L : Convert.ToInt64(reader.GetValue(4)),
+ MaxDop = reader.IsDBNull(5) ? 0 : Convert.ToInt32(Convert.ToDouble(reader.GetValue(5))),
+ };
+ }
+ catch (Exception ex) when (!AnalysisAbandon.IsExpected(ex, cancellationToken))
+ {
+ return null;
+ }
+ }
+
///
/// WS4: re-parses the top collected query plans (the same top-10-by-cost set the fact collector
/// summarized) and attaches the specific missing indexes / plan warnings to a MISSING_INDEX or
@@ -171,7 +220,9 @@ ORDER BY delta_worker_time DESC
if (planXmls.Count == 0)
return;
- var details = PlanAdvisoryAggregator.ExtractCancellable(planXmls, context.CancellationToken);
+ // #4530: one store read per collector call so rule 38 can see the server's edition/MAXDOP.
+ var metadata = await ReadServerMetadataForPlanAnalysisAsync(context.ServerId, context.CancellationToken);
+ var details = PlanAdvisoryAggregator.ExtractCancellable(planXmls, metadata, context.CancellationToken);
if (pathKeys.Contains("MISSING_INDEX") && details.MissingIndexes.Count > 0)
{
diff --git a/Lite/Controls/FinOpsTab.xaml.cs b/Lite/Controls/FinOpsTab.xaml.cs
index 8b0a65aa52..d10d7be09c 100644
--- a/Lite/Controls/FinOpsTab.xaml.cs
+++ b/Lite/Controls/FinOpsTab.xaml.cs
@@ -152,7 +152,9 @@ private int GetSelectedServerId()
private PlanNavigationController? _planActions;
private PlanNavigationController PlanActions => _planActions ??= new PlanNavigationController(
Window.GetWindow(this)!,
- (xml, label, qt) => Windows.PlanViewerWindow.ShowPlanAsync(Window.GetWindow(this)!, xml, label, qt),
+ async (xml, label, qt) => await Windows.PlanViewerWindow.ShowPlanAsync(
+ Window.GetWindow(this)!, xml, label, qt,
+ _dataService != null ? await _dataService.GetServerMetadataForPlanAnalysisAsync(GetSelectedServerId()) : null),
(db, qt, est, iso, ct) => ActualPlanExecutor.ExecuteForActualPlanAsync(
GetSelectedConnectionString() ?? "", db, qt, est, iso, isAzureSqlDb: false, timeoutSeconds: 0, ct,
productName: "SQL Server Performance Monitor Lite"),
diff --git a/Lite/Controls/ServerTab.Plans.cs b/Lite/Controls/ServerTab.Plans.cs
index 9e2fb6c566..23b33144df 100644
--- a/Lite/Controls/ServerTab.Plans.cs
+++ b/Lite/Controls/ServerTab.Plans.cs
@@ -255,6 +255,9 @@ private async Task OpenPlanTab(string planXml, string label, string? queryText =
var viewer = new PlanViewerControl { AccuracyRatioDivergenceLimit = App.AccuracyRatioDivergenceLimit };
try
{
+ /* #4530: the server's edition/MAXDOP for rule 38, best-effort (null on a missing row or a
+ read failure, same as GetServerMetadataForPlanAnalysisAsync's own contract). */
+ viewer.ServerMetadata = await _dataService.GetServerMetadataForPlanAnalysisAsync(_serverId);
/* LoadPlan parses+analyzes off the UI thread; it throws XmlException for malformed
plan XML, replacing the redundant up-front XDocument.Parse validation. */
await viewer.LoadPlan(planXml, label, queryText);
diff --git a/Lite/Mcp/McpPlanTools.cs b/Lite/Mcp/McpPlanTools.cs
index 0d9c1d3411..cd1983c198 100644
--- a/Lite/Mcp/McpPlanTools.cs
+++ b/Lite/Mcp/McpPlanTools.cs
@@ -41,7 +41,9 @@ public static async Task AnalyzeQueryPlan(
"unavailable",
$"No plan found for query_hash '{query_hash}'. The query may have been evicted from the plan cache since the last collection.");
- return McpPlanAnalysisFormatter.BuildAnalysisResult(xml, resolved.ServerName, "query_stats", query_hash, cancellationToken);
+ // #4530: one store read per call so rule 38 can see the server's edition/MAXDOP.
+ var metadata = await dataService.GetServerMetadataForPlanAnalysisAsync(resolved.ServerId);
+ return McpPlanAnalysisFormatter.BuildAnalysisResult(xml, resolved.ServerName, "query_stats", query_hash, metadata, cancellationToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
@@ -77,7 +79,9 @@ public static async Task AnalyzeProcedurePlan(
"unavailable",
$"No plan found for plan_handle '{plan_handle}'. The procedure may have been evicted from the plan cache since the last collection.");
- return McpPlanAnalysisFormatter.BuildAnalysisResult(xml, resolved.ServerName, "procedure_stats", plan_handle, cancellationToken);
+ // #4530: one store read per call so rule 38 can see the server's edition/MAXDOP.
+ var metadata = await dataService.GetServerMetadataForPlanAnalysisAsync(resolved.ServerId);
+ return McpPlanAnalysisFormatter.BuildAnalysisResult(xml, resolved.ServerName, "procedure_stats", plan_handle, metadata, cancellationToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
@@ -125,7 +129,9 @@ public static async Task AnalyzeQueryStorePlan(
"unavailable",
$"No plan found for plan_id {plan_id} in database '{database_name}'. Query Store may not be enabled or the plan may have been purged.");
- return McpPlanAnalysisFormatter.BuildAnalysisResult(xml, resolved.ServerName, "query_store", $"{database_name}:{plan_id}", cancellationToken);
+ // #4530: one store read per call so rule 38 can see the server's edition/MAXDOP.
+ var metadata = await dataService.GetServerMetadataForPlanAnalysisAsync(resolved.ServerId);
+ return McpPlanAnalysisFormatter.BuildAnalysisResult(xml, resolved.ServerName, "query_store", $"{database_name}:{plan_id}", metadata, cancellationToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
diff --git a/Lite/Services/LocalDataService.PlanServerMetadata.cs b/Lite/Services/LocalDataService.PlanServerMetadata.cs
new file mode 100644
index 0000000000..6d44e4c265
--- /dev/null
+++ b/Lite/Services/LocalDataService.PlanServerMetadata.cs
@@ -0,0 +1,72 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor Lite.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Threading.Tasks;
+using DuckDB.NET.Data;
+using PerformanceMonitor.PlanAnalysis;
+
+namespace PerformanceMonitorLite.Services;
+
+public partial class LocalDataService
+{
+ ///
+ /// The read: the newest v_server_properties
+ /// row for hardware/edition, LEFT JOINed to the latest max degree of parallelism value from
+ /// v_server_config — the same latest-value-per-name pattern
+ /// reads, narrowed to the one setting plan-analysis rule 38
+ /// (#4530) needs. Anchored on server_id alone: "the server's current metadata" has no time
+ /// window, matching every other latest-snapshot config read in this store.
+ ///
+ public const string PlanServerMetadataSql = @"
+SELECT p.edition, p.product_version, p.product_level, p.cpu_count, p.physical_memory_mb,
+ (SELECT c.value_in_use
+ FROM v_server_config AS c
+ WHERE c.server_id = $1
+ AND c.configuration_name = 'max degree of parallelism'
+ AND c.capture_time = (SELECT MAX(capture_time) FROM v_server_config WHERE server_id = $1)
+ LIMIT 1) AS max_dop
+FROM v_server_properties AS p
+WHERE p.server_id = $1
+ORDER BY p.collection_time DESC
+LIMIT 1";
+
+ ///
+ /// The server's edition and MAXDOP for plan analysis (#4530), or null when the store has no
+ /// v_server_properties row for this server, or the read fails. Mirrors
+ /// DarlingServerMetadataReader.ReadAsync: a failure is non-fatal, because a missing
+ /// edition/MAXDOP degrades rule 38 to its Info branch rather than failing the whole analysis.
+ ///
+ public async Task GetServerMetadataForPlanAnalysisAsync(int serverId)
+ {
+ try
+ {
+ using var connection = await OpenConnectionAsync();
+ using var command = connection.CreateCommand();
+ command.CommandText = PlanServerMetadataSql;
+ command.Parameters.Add(new DuckDBParameter { Value = serverId });
+
+ using var reader = await command.ExecuteReaderAsync();
+ if (!await reader.ReadAsync()) return null;
+
+ return new ServerMetadata
+ {
+ Edition = reader.IsDBNull(0) ? null : reader.GetString(0),
+ ProductVersion = reader.IsDBNull(1) ? null : reader.GetString(1),
+ ProductLevel = reader.IsDBNull(2) ? null : reader.GetString(2),
+ CpuCount = reader.IsDBNull(3) ? 0 : reader.GetInt32(3),
+ PhysicalMemoryMB = reader.IsDBNull(4) ? 0L : ToInt64(reader.GetValue(4)),
+ MaxDop = reader.IsDBNull(5) ? 0 : Convert.ToInt32(Convert.ToDouble(reader.GetValue(5))),
+ };
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ }
+}
diff --git a/Lite/Windows/PlanViewerWindow.xaml.cs b/Lite/Windows/PlanViewerWindow.xaml.cs
index d84b864a40..06c99ae74a 100644
--- a/Lite/Windows/PlanViewerWindow.xaml.cs
+++ b/Lite/Windows/PlanViewerWindow.xaml.cs
@@ -43,10 +43,17 @@ private void Window_KeyDown(object sender, System.Windows.Input.KeyEventArgs e)
}
/// Loads a plan into this window's viewer. Throws for invalid XML.
- public async Task LoadPlanAsync(string planXml, string label, string? queryText)
+ ///
+ /// #4530: the server's edition/MAXDOP for plan-analysis rule 38, set by the caller from a store read
+ /// (this window has no live connection to the monitored server). Null keeps rule 38 on its Info branch,
+ /// same as before #4530.
+ ///
+ public async Task LoadPlanAsync(
+ string planXml, string label, string? queryText, PerformanceMonitor.PlanAnalysis.ServerMetadata? serverMetadata = null)
{
if (!string.IsNullOrWhiteSpace(label))
Title = label.Length > 80 ? label[..80] : label;
+ Viewer.ServerMetadata = serverMetadata;
await Viewer.LoadPlan(planXml, label, queryText);
}
@@ -54,13 +61,15 @@ public async Task LoadPlanAsync(string planXml, string label, string? queryText)
/// Opens a new, owned, non-modal plan window and loads the plan. A new window per call lets the user
/// compare plans side by side; owned so it stays usable above a modal host window.
///
- public static async Task ShowPlanAsync(Window owner, string planXml, string label, string? queryText)
+ public static async Task ShowPlanAsync(
+ Window owner, string planXml, string label, string? queryText,
+ PerformanceMonitor.PlanAnalysis.ServerMetadata? serverMetadata = null)
{
var window = new PlanViewerWindow { Owner = owner };
window.Show();
try
{
- await window.LoadPlanAsync(planXml, label, queryText);
+ await window.LoadPlanAsync(planXml, label, queryText, serverMetadata);
}
catch (System.Xml.XmlException ex)
{
diff --git a/Lite/Windows/ProcedureHistoryWindow.xaml.cs b/Lite/Windows/ProcedureHistoryWindow.xaml.cs
index 7613a80a04..640de8c6cc 100644
--- a/Lite/Windows/ProcedureHistoryWindow.xaml.cs
+++ b/Lite/Windows/ProcedureHistoryWindow.xaml.cs
@@ -53,7 +53,8 @@ public ProcedureHistoryWindow(LocalDataService dataService, int serverId, string
_planActions = new PlanNavigationController(
this,
- (xml, label, qt) => PlanViewerWindow.ShowPlanAsync(this, xml, label, qt),
+ async (xml, label, qt) => await PlanViewerWindow.ShowPlanAsync(
+ this, xml, label, qt, await _dataService.GetServerMetadataForPlanAnalysisAsync(_serverId)),
(db, qt, est, iso, ct) => ActualPlanExecutor.ExecuteForActualPlanAsync(
_connectionString ?? "", db, qt, est, iso, isAzureSqlDb: false, timeoutSeconds: 0, ct,
productName: "SQL Server Performance Monitor Lite"),
diff --git a/Lite/Windows/QueryStatsHistoryWindow.xaml.cs b/Lite/Windows/QueryStatsHistoryWindow.xaml.cs
index 48c7f4bdbc..170ee2a86b 100644
--- a/Lite/Windows/QueryStatsHistoryWindow.xaml.cs
+++ b/Lite/Windows/QueryStatsHistoryWindow.xaml.cs
@@ -53,7 +53,8 @@ public QueryStatsHistoryWindow(LocalDataService dataService, int serverId, strin
_planActions = new PlanNavigationController(
this,
- (xml, label, qt) => PlanViewerWindow.ShowPlanAsync(this, xml, label, qt),
+ async (xml, label, qt) => await PlanViewerWindow.ShowPlanAsync(
+ this, xml, label, qt, await _dataService.GetServerMetadataForPlanAnalysisAsync(_serverId)),
(db, qt, est, iso, ct) => ActualPlanExecutor.ExecuteForActualPlanAsync(
_connectionString ?? "", db, qt, est, iso, isAzureSqlDb: false, timeoutSeconds: 0, ct,
productName: "SQL Server Performance Monitor Lite"),
diff --git a/Lite/Windows/QueryStoreHistoryWindow.xaml.cs b/Lite/Windows/QueryStoreHistoryWindow.xaml.cs
index 40bff5da77..735ee1ad33 100644
--- a/Lite/Windows/QueryStoreHistoryWindow.xaml.cs
+++ b/Lite/Windows/QueryStoreHistoryWindow.xaml.cs
@@ -56,7 +56,8 @@ public QueryStoreHistoryWindow(LocalDataService dataService, int serverId, strin
_planActions = new PlanNavigationController(
this,
- (xml, label, qt) => PlanViewerWindow.ShowPlanAsync(this, xml, label, qt),
+ async (xml, label, qt) => await PlanViewerWindow.ShowPlanAsync(
+ this, xml, label, qt, await _dataService.GetServerMetadataForPlanAnalysisAsync(_serverId)),
(db, qt, est, iso, ct) => ActualPlanExecutor.ExecuteForActualPlanAsync(
_connectionString ?? "", db, qt, est, iso, isAzureSqlDb: false, timeoutSeconds: 0, ct,
productName: "SQL Server Performance Monitor Lite"),
diff --git a/Lite/Windows/WaitDrillDownWindow.xaml.cs b/Lite/Windows/WaitDrillDownWindow.xaml.cs
index 657042f22b..8f6702f6e4 100644
--- a/Lite/Windows/WaitDrillDownWindow.xaml.cs
+++ b/Lite/Windows/WaitDrillDownWindow.xaml.cs
@@ -59,7 +59,8 @@ public WaitDrillDownWindow(
_planActions = new PlanNavigationController(
this,
- (xml, label, qt) => PlanViewerWindow.ShowPlanAsync(this, xml, label, qt),
+ async (xml, label, qt) => await PlanViewerWindow.ShowPlanAsync(
+ this, xml, label, qt, await _dataService.GetServerMetadataForPlanAnalysisAsync(_serverId)),
(db, qt, est, iso, ct) => ActualPlanExecutor.ExecuteForActualPlanAsync(
_connectionString ?? "", db, qt, est, iso, isAzureSqlDb: false, timeoutSeconds: 0, ct,
productName: "SQL Server Performance Monitor Lite"),
diff --git a/PerformanceMonitor.PlanAnalysis/McpPlanAnalysisFormatter.cs b/PerformanceMonitor.PlanAnalysis/McpPlanAnalysisFormatter.cs
index 422b9e1e54..5d1657f3c4 100644
--- a/PerformanceMonitor.PlanAnalysis/McpPlanAnalysisFormatter.cs
+++ b/PerformanceMonitor.PlanAnalysis/McpPlanAnalysisFormatter.cs
@@ -98,10 +98,25 @@ public static string BuildAnalysisResult(
string? serverName,
string source,
string? identifier,
+ CancellationToken cancellationToken = default) =>
+ BuildAnalysisResult(xml, serverName, source, identifier, null, cancellationToken);
+
+ ///
+ /// #4530: the overload. Passing the resolved server's metadata through
+ /// lets rule 38 (Standard Edition DOP 2 limitation) give its Warning instead of its uninformative Info
+ /// branch. The 5-argument overload forwards null, which
+ /// treats the same as no metadata available.
+ ///
+ public static string BuildAnalysisResult(
+ string xml,
+ string? serverName,
+ string source,
+ string? identifier,
+ ServerMetadata? serverMetadata,
CancellationToken cancellationToken = default)
{
var plan = ShowPlanParser.Parse(xml, cancellationToken);
- PlanAnalysisPipeline.Run(plan, cancellationToken);
+ PlanAnalysisPipeline.Run(plan, null, serverMetadata, cancellationToken);
// #4514: includes statements nested inside a stored procedure or UDF body, so the MCP
// analyze_plan_xml/analyze_query_plan/analyze_query_store_plan tools see the same
diff --git a/PerformanceMonitor.PlanAnalysis/PlanAdvisoryAggregator.cs b/PerformanceMonitor.PlanAnalysis/PlanAdvisoryAggregator.cs
index 30682fc137..ee294be153 100644
--- a/PerformanceMonitor.PlanAnalysis/PlanAdvisoryAggregator.cs
+++ b/PerformanceMonitor.PlanAnalysis/PlanAdvisoryAggregator.cs
@@ -50,7 +50,14 @@ public static Details Extract(IEnumerable planXmls) =>
ExtractCancellable(planXmls, CancellationToken.None);
/// Cancellable form of .
- public static Details ExtractCancellable(IEnumerable planXmls, CancellationToken cancellationToken)
+ public static Details ExtractCancellable(IEnumerable planXmls, CancellationToken cancellationToken) =>
+ ExtractCancellable(planXmls, null, cancellationToken);
+
+ ///
+ /// #4530: the overload. The drill-down callers pass the resolved server's
+ /// metadata so rule 38 can see the edition/MAXDOP; the other overload forwards null.
+ ///
+ public static Details ExtractCancellable(IEnumerable planXmls, ServerMetadata? serverMetadata, CancellationToken cancellationToken)
{
var byKey = new Dictionary(StringComparer.OrdinalIgnoreCase);
var warnings = new List();
@@ -66,7 +73,7 @@ public static Details ExtractCancellable(IEnumerable planXmls, Cancellat
try
{
plan = ShowPlanParser.Parse(xml, cancellationToken);
- PlanAnalysisPipeline.Run(plan, cancellationToken);
+ PlanAnalysisPipeline.Run(plan, null, serverMetadata, cancellationToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
diff --git a/PerformanceMonitor.Ui/PlanViewerControl.xaml.cs b/PerformanceMonitor.Ui/PlanViewerControl.xaml.cs
index ebddfe3e45..e903df34bf 100644
--- a/PerformanceMonitor.Ui/PlanViewerControl.xaml.cs
+++ b/PerformanceMonitor.Ui/PlanViewerControl.xaml.cs
@@ -135,6 +135,13 @@ private void OnThemeChanged(string _)
}
}
+ ///
+ /// #4530: the server's edition/MAXDOP for rule 38, set by the caller from a store read (this app has no
+ /// live connection to the monitored server at plan-view time — see ). null
+ /// when the caller has no metadata; the analyzer then falls back to rule 38's Info branch.
+ ///
+ public PerformanceMonitor.PlanAnalysis.ServerMetadata? ServerMetadata { get; set; }
+
///
/// The full query text the host passed , held for "Copy Query Text"'s
/// truncated-single-statement fallback (#4582, PerformanceStudio's _queryText): the same
@@ -160,10 +167,11 @@ public async System.Threading.Tasks.Task LoadPlan(string planXml, string label,
/* Parse + analyze off the UI thread — a multi-MB showplan is two heavy passes that would
otherwise freeze the window for seconds. Only the render below touches the UI. A refused
or exception-terminated parse sets ParsedPlan.ParseError instead of throwing; see below. */
+ var serverMetadata = ServerMetadata;
_currentPlan = await System.Threading.Tasks.Task.Run(() =>
{
var plan = ShowPlanParser.Parse(planXml);
- PlanAnalysisPipeline.Run(plan);
+ PlanAnalysisPipeline.Run(plan, null, serverMetadata, System.Threading.CancellationToken.None);
return plan;
});