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; });