From 2f4bc3b91ea4699a4e1130caab0e5d1bb760892b Mon Sep 17 00:00:00 2001 From: autocarl Date: Wed, 15 Jul 2026 17:03:52 -0400 Subject: [PATCH 01/16] Add read-only Repl plan exploration pilot --- README.md | 29 ++- docs/repl-pilot.md | 67 +++++ src/PlanViewer.App/Mcp/McpHostService.cs | 3 + src/PlanViewer.App/Mcp/McpPlanTools.cs | 82 ++---- src/PlanViewer.App/Mcp/PlanSessionManager.cs | 71 ++--- src/PlanViewer.Cli/CliRouting.cs | 15 ++ src/PlanViewer.Cli/PlanViewer.Cli.csproj | 2 + src/PlanViewer.Cli/Program.cs | 8 + src/PlanViewer.Cli/ReplSurface/PlanReplApp.cs | 26 ++ .../ReplSurface/PlanReplModule.cs | 98 +++++++ .../Interfaces/IPlanCatalog.cs | 15 ++ src/PlanViewer.Core/Models/PlanSession.cs | 31 +++ .../Output/PlanOperationResults.cs | 162 ++++++++++++ .../Services/InMemoryPlanCatalog.cs | 50 ++++ .../Services/PlanOperations.cs | 243 ++++++++++++++++++ .../PlanViewer.Core.Tests/CliRoutingTests.cs | 30 +++ tests/PlanViewer.Core.Tests/McpSmokeTests.cs | 32 +++ .../PlanViewer.Core.Tests/PlanCatalogTests.cs | 40 +++ .../PlanOperationsTests.cs | 145 +++++++++++ tests/PlanViewer.Core.Tests/PlanReplTests.cs | 50 ++++ .../PlanViewer.Core.Tests.csproj | 2 + 21 files changed, 1083 insertions(+), 118 deletions(-) create mode 100644 docs/repl-pilot.md create mode 100644 src/PlanViewer.Cli/CliRouting.cs create mode 100644 src/PlanViewer.Cli/ReplSurface/PlanReplApp.cs create mode 100644 src/PlanViewer.Cli/ReplSurface/PlanReplModule.cs create mode 100644 src/PlanViewer.Core/Interfaces/IPlanCatalog.cs create mode 100644 src/PlanViewer.Core/Models/PlanSession.cs create mode 100644 src/PlanViewer.Core/Output/PlanOperationResults.cs create mode 100644 src/PlanViewer.Core/Services/InMemoryPlanCatalog.cs create mode 100644 src/PlanViewer.Core/Services/PlanOperations.cs create mode 100644 tests/PlanViewer.Core.Tests/CliRoutingTests.cs create mode 100644 tests/PlanViewer.Core.Tests/McpSmokeTests.cs create mode 100644 tests/PlanViewer.Core.Tests/PlanCatalogTests.cs create mode 100644 tests/PlanViewer.Core.Tests/PlanOperationsTests.cs create mode 100644 tests/PlanViewer.Core.Tests/PlanReplTests.cs diff --git a/README.md b/README.md index f71747c..81ccb86 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ dotnet build To verify the build: ```bash -dotnet test tests/PlanViewer.Core.Tests # 37 tests should pass +dotnet test PlanViewer.sln dotnet run --project src/PlanViewer.Cli -- analyze --help ``` @@ -149,6 +149,33 @@ planview analyze my_query.sqlplan --output text planview analyze my_query.sqlplan --output text --warnings-only ``` +### Explore plans interactively (Repl pilot) + +Running `planview` without arguments starts a long-lived read-only session. Existing +`analyze`, `querystore`, and `credential` commands continue to use the legacy +System.CommandLine graph. + +```text +planview +> open slow-query.sqlplan +[plan slow-query]> summary +[plan slow-query]> warnings --severity critical +[plan slow-query]> operators --top 10 +[plan slow-query]> missing-indexes --json +``` + +The same graph also works in one-shot and MCP modes: + +```bash +planview plan open slow-query.sqlplan --json --no-logo +planview repl # explicit interactive alias +planview mcp serve # stdio MCP server generated from the graph +``` + +The pilot uses `Repl` `0.11.0-dev.181`; loaded plans live only for the lifetime of +the process and all exposed plan operations are read-only. See +[`docs/repl-pilot.md`](docs/repl-pilot.md) for architecture and compatibility notes. + ### Capture and analyze plans from a live server Connect to a SQL Server instance, run queries, and capture their execution plans automatically. diff --git a/docs/repl-pilot.md b/docs/repl-pilot.md new file mode 100644 index 0000000..d25ff19 --- /dev/null +++ b/docs/repl-pilot.md @@ -0,0 +1,67 @@ +# Repl Read-only Pilot + +## Goal + +Add a Repl 0.11.0-dev.181 command surface for loaded SQL Server plans while preserving the existing System.CommandLine CLI and its JSON contracts. + +## Architecture + +1. Put immutable plan-session models plus `IPlanCatalog` and its thread-safe in-memory implementation in `PlanViewer.Core`. +2. Put typed, renderer-free read operations (`open`, `list`, `summary`, `warnings`, `missing indexes`, `expensive operators`) in `PlanViewer.Core`. +3. Keep the Avalonia `PlanSessionManager` as a thin compatibility singleton over the Core catalog and make existing MCP tools delegate to Core operations without changing MCP names or JSON property names. +4. Add a Repl `IReplModule` in `PlanViewer.Cli`, pinned to `Repl`/`Repl.Mcp`/`Repl.Testing` 0.11.0-dev.181. Launch Repl for no args and for the new `plan` graph; dispatch all existing argument prefixes to System.CommandLine unchanged. +5. Return typed values from Repl handlers so text, JSON, tests, and MCP are renderer concerns rather than handler concerns. + +## TDD slices + +- Catalog registration, list, lookup, and removal. +- File-open success plus file-not-found, empty, and invalid-plan failures. +- Summary and severity-filtered warning DTOs. +- Expensive-operator ranking and `top` validation. +- Missing-index result shape. +- Repl one-shot graph and interactive session persistence, including paths with spaces and JSON output. +- Legacy CLI JSON contract characterization before Program routing changes. +- Existing MCP JSON shape characterization before delegation refactor. + +## Verification + +- `dotnet build PlanViewer.sln -c Release` +- `dotnet test PlanViewer.sln -c Release` +- Compare legacy JSON output before and after for a fixed `.sqlplan`. +- Run a Repl one-shot JSON smoke test. +- Run a redirected interactive smoke test covering open, summary, warnings, operators, missing indexes, list, and exit. +- Run `git diff --check` and report the exact Git provenance. + + +## Command graph + +```text +open {path} # root convenience command; navigates to the plan +plan open {path} # canonical open command +plan list # process-local loaded plan sessions +plan {id} summary +plan {id} warnings [--severity Critical|Warning|Info] +plan {id} expensive-operators [--top 10] +plan {id} operators [--top 10] # alias +plan {id} missing-indexes +``` + +Inside an interactive plan context, the `plan {id}` prefix is omitted. Handlers +return typed DTOs; operation results carry stable `JsonPropertyName` annotations, while +catalog summaries retain their existing property casing. Rendering is +selected by Repl (`--json`, text, YAML, or Markdown) rather than performed by the +handlers. + +## Compatibility boundary + +- `analyze`, `querystore`, `credential`, and their existing options remain on the + original System.CommandLine root. +- The Repl graph is selected only for no arguments, `repl`, `plan`, `open`, or + `mcp`. +- The existing `analyze --compact` JSON output is treated as a public contract and + is checked byte-for-byte against a pre-change characterization fixture. +- The Avalonia MCP session manager now implements `IPlanCatalog`, and pilot MCP + operations delegate to `PlanOperations` while retaining their existing MCP tool + names and top-level JSON shapes. +- Sessions are process-local and read-only. Query Store mutations and credentials + are intentionally outside this pilot. diff --git a/src/PlanViewer.App/Mcp/McpHostService.cs b/src/PlanViewer.App/Mcp/McpHostService.cs index 779445b..f5bacd5 100644 --- a/src/PlanViewer.App/Mcp/McpHostService.cs +++ b/src/PlanViewer.App/Mcp/McpHostService.cs @@ -9,6 +9,7 @@ using ModelContextProtocol.AspNetCore; using PlanViewer.App.Services; using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Services; namespace PlanViewer.App.Mcp; @@ -53,6 +54,8 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) /* Register services that MCP tools need via dependency injection */ builder.Services.AddSingleton(_sessionManager); + builder.Services.AddSingleton(_sessionManager); + builder.Services.AddSingleton(new PlanOperations(_sessionManager)); builder.Services.AddSingleton(_connectionStore); builder.Services.AddSingleton(_credentialService); diff --git a/src/PlanViewer.App/Mcp/McpPlanTools.cs b/src/PlanViewer.App/Mcp/McpPlanTools.cs index 916ccd7..2b552d6 100644 --- a/src/PlanViewer.App/Mcp/McpPlanTools.cs +++ b/src/PlanViewer.App/Mcp/McpPlanTools.cs @@ -53,6 +53,7 @@ public static string GetConnections(ConnectionStore connectionStore) "This is the primary tool for understanding plan quality. Use list_plans first to get session_id values.")] public static string AnalyzePlan( PlanSessionManager sessionManager, + PlanOperations operations, [Description("The session_id from list_plans.")] string session_id) { var session = sessionManager.GetSession(session_id); @@ -61,7 +62,7 @@ public static string AnalyzePlan( try { - var result = ResultMapper.Map(session.Plan, session.Source); + var result = operations.GetAnalysis(session_id); return JsonSerializer.Serialize(result, McpHelpers.JsonOptions); } catch (Exception ex) @@ -75,6 +76,7 @@ public static string AnalyzePlan( "missing indexes, cost, DOP, memory grants. Faster than analyze_plan for quick assessment.")] public static string GetPlanSummary( PlanSessionManager sessionManager, + PlanOperations operations, [Description("The session_id from list_plans.")] string session_id) { var session = sessionManager.GetSession(session_id); @@ -83,8 +85,7 @@ public static string GetPlanSummary( try { - var result = ResultMapper.Map(session.Plan, session.Source); - return TextFormatter.Format(result); + return TextFormatter.Format(operations.GetAnalysis(session_id)); } catch (Exception ex) { @@ -97,6 +98,7 @@ public static string GetPlanSummary( "Optionally filter by severity (Critical, Warning, or Info).")] public static string GetPlanWarnings( PlanSessionManager sessionManager, + PlanOperations operations, [Description("The session_id from list_plans.")] string session_id, [Description("Optional severity filter: Critical, Warning, or Info.")] string? severity = null) { @@ -106,29 +108,16 @@ public static string GetPlanWarnings( try { - var result = ResultMapper.Map(session.Plan, session.Source); - var allWarnings = result.Statements - .SelectMany(s => s.Warnings.Select(w => new - { - severity = w.Severity, - type = w.Type, - message = w.Message, - node_id = w.NodeId, - @operator = w.Operator, - statement = McpHelpers.Truncate(s.StatementText, 200) - })) - .Where(w => severity == null || - w.severity.Equals(severity, StringComparison.OrdinalIgnoreCase)) - .ToList(); - - if (allWarnings.Count == 0) + var result = operations.GetWarnings(session_id, severity); + if (result.WarningCount == 0) { return severity != null ? $"No {severity} warnings found in this plan." : "No warnings found in this plan."; } - return JsonSerializer.Serialize(new { warning_count = allWarnings.Count, warnings = allWarnings }, + return JsonSerializer.Serialize( + new { warning_count = result.WarningCount, warnings = result.Warnings }, McpHelpers.JsonOptions); } catch (Exception ex) @@ -142,29 +131,19 @@ public static string GetPlanWarnings( "ready-to-run CREATE INDEX statements.")] public static string GetMissingIndexes( PlanSessionManager sessionManager, + PlanOperations operations, [Description("The session_id from list_plans.")] string session_id) { var session = sessionManager.GetSession(session_id); if (session == null) return SessionNotFound(sessionManager, session_id); - var indexes = session.Plan.AllMissingIndexes; - if (indexes.Count == 0) + var result = operations.GetMissingIndexes(session_id); + if (result.MissingIndexCount == 0) return "No missing index suggestions in this plan."; - var result = indexes.Select(idx => new - { - database = idx.Database, - schema_name = idx.Schema, - table = idx.Table, - impact = idx.Impact, - equality_columns = idx.EqualityColumns, - inequality_columns = idx.InequalityColumns, - include_columns = idx.IncludeColumns, - create_statement = idx.CreateStatement - }); - - return JsonSerializer.Serialize(new { missing_index_count = indexes.Count, indexes = result }, + return JsonSerializer.Serialize( + new { missing_index_count = result.MissingIndexCount, indexes = result.Indexes }, McpHelpers.JsonOptions); } @@ -208,6 +187,7 @@ public static string GetPlanParameters( "or actual elapsed time (if available). Useful for quickly finding bottleneck operators.")] public static string GetExpensiveOperators( PlanSessionManager sessionManager, + PlanOperations operations, [Description("The session_id from list_plans.")] string session_id, [Description("Number of operators to return. Default 10.")] int top = 10) { @@ -218,35 +198,9 @@ public static string GetExpensiveOperators( var topError = McpHelpers.ValidateTop(top); if (topError != null) return topError; - var allNodes = new List<(PlanNode Node, string Statement)>(); - foreach (var stmt in session.Plan.Batches.SelectMany(b => b.Statements)) - { - if (stmt.RootNode == null) continue; - CollectNodes(stmt.RootNode, McpHelpers.Truncate(stmt.StatementText, 100) ?? "", allNodes); - } - - var hasActuals = allNodes.Any(n => n.Node.ActualElapsedMs > 0); - var ranked = hasActuals - ? allNodes.OrderByDescending(n => n.Node.ActualElapsedMs) - : allNodes.OrderByDescending(n => n.Node.CostPercent); - - var result = ranked.Take(top).Select(n => new - { - node_id = n.Node.NodeId, - physical_op = n.Node.PhysicalOp, - logical_op = n.Node.LogicalOp, - cost_percent = n.Node.CostPercent, - estimated_rows = n.Node.EstimateRows, - actual_rows = n.Node.ActualRows, - actual_elapsed_ms = n.Node.ActualElapsedMs, - actual_cpu_ms = n.Node.ActualCPUMs, - logical_reads = n.Node.ActualLogicalReads, - physical_reads = n.Node.ActualPhysicalReads, - object_name = n.Node.ObjectName, - statement = n.Statement - }); - - return JsonSerializer.Serialize(new { ranked_by = hasActuals ? "actual_elapsed_ms" : "cost_percent", operators = result }, + var result = operations.GetExpensiveOperators(session_id, top); + return JsonSerializer.Serialize( + new { ranked_by = result.RankedBy, operators = result.Operators }, McpHelpers.JsonOptions); } diff --git a/src/PlanViewer.App/Mcp/PlanSessionManager.cs b/src/PlanViewer.App/Mcp/PlanSessionManager.cs index b20a5ee..7eacce5 100644 --- a/src/PlanViewer.App/Mcp/PlanSessionManager.cs +++ b/src/PlanViewer.App/Mcp/PlanSessionManager.cs @@ -1,70 +1,35 @@ -using System.Collections.Concurrent; +using System; using System.Collections.Generic; -using System.Linq; +using PlanViewer.Core.Interfaces; using PlanViewer.Core.Models; +using PlanViewer.Core.Services; namespace PlanViewer.App.Mcp; /// -/// Thread-safe bridge between UI plan state and MCP tools. -/// The UI registers/unregisters plans as tabs are opened/closed. -/// MCP tools read plan data without touching the UI thread. +/// Application singleton backed by the same catalog abstraction used by Repl. /// -public sealed class PlanSessionManager +public sealed class PlanSessionManager : IPlanCatalog { public static PlanSessionManager Instance { get; } = new(); - private readonly ConcurrentDictionary _sessions = new(); + private readonly InMemoryPlanCatalog _catalog = new(); - public void Register(string sessionId, PlanSession session) => - _sessions[sessionId] = session; + public void Register(PlanSession session) => _catalog.Register(session); - public void Unregister(string sessionId) => - _sessions.TryRemove(sessionId, out _); + public bool TryRegister(PlanSession session) => _catalog.TryRegister(session); - public PlanSession? GetSession(string sessionId) => - _sessions.TryGetValue(sessionId, out var session) ? session : null; + // Compatibility overload for existing UI and Query Store callers during the pilot. + public void Register(string sessionId, PlanSession session) + { + if (!sessionId.Equals(session.SessionId, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException("The registration key must match the session ID.", nameof(sessionId)); + _catalog.Register(session); + } - public IReadOnlyList GetAllSessions() => - _sessions.Values.Select(s => new PlanSessionSummary - { - SessionId = s.SessionId, - Label = s.Label, - Source = s.Source, - StatementCount = s.StatementCount, - WarningCount = s.WarningCount, - CriticalWarningCount = s.CriticalWarningCount, - MissingIndexCount = s.MissingIndexCount, - HasActualStats = s.HasActualStats - }).ToList(); -} + public bool Unregister(string sessionId) => _catalog.Unregister(sessionId); -/// -/// Immutable snapshot of a loaded plan, safe for cross-thread reads by MCP tools. -/// -public sealed class PlanSession -{ - public required string SessionId { get; init; } - public required string Label { get; init; } - public required string Source { get; init; } - public required ParsedPlan Plan { get; init; } - public string? QueryText { get; init; } - public string? ConnectionInfo { get; init; } - public int StatementCount { get; init; } - public bool HasActualStats { get; init; } - public int WarningCount { get; init; } - public int CriticalWarningCount { get; init; } - public int MissingIndexCount { get; init; } -} + public PlanSession? GetSession(string sessionId) => _catalog.GetSession(sessionId); -public sealed class PlanSessionSummary -{ - public string SessionId { get; set; } = ""; - public string Label { get; set; } = ""; - public string Source { get; set; } = ""; - public int StatementCount { get; set; } - public int WarningCount { get; set; } - public int CriticalWarningCount { get; set; } - public int MissingIndexCount { get; set; } - public bool HasActualStats { get; set; } + public IReadOnlyList GetAllSessions() => _catalog.GetAllSessions(); } diff --git a/src/PlanViewer.Cli/CliRouting.cs b/src/PlanViewer.Cli/CliRouting.cs new file mode 100644 index 0000000..72da2f5 --- /dev/null +++ b/src/PlanViewer.Cli/CliRouting.cs @@ -0,0 +1,15 @@ +namespace PlanViewer.Cli; + +public static class CliRouting +{ + private static readonly HashSet ReplCommands = + new(StringComparer.OrdinalIgnoreCase) { "plan", "open", "mcp", "repl" }; + + public static bool ShouldUseRepl(IReadOnlyList args) => + args.Count == 0 || ReplCommands.Contains(args[0]); + + public static string[] GetReplArgs(IReadOnlyList args) => + args.Count > 0 && args[0].Equals("repl", StringComparison.OrdinalIgnoreCase) + ? args.Skip(1).ToArray() + : args.ToArray(); +} diff --git a/src/PlanViewer.Cli/PlanViewer.Cli.csproj b/src/PlanViewer.Cli/PlanViewer.Cli.csproj index 4cf474d..8505a32 100644 --- a/src/PlanViewer.Cli/PlanViewer.Cli.csproj +++ b/src/PlanViewer.Cli/PlanViewer.Cli.csproj @@ -14,6 +14,8 @@ + + diff --git a/src/PlanViewer.Cli/Program.cs b/src/PlanViewer.Cli/Program.cs index bfab9e0..2a7b711 100644 --- a/src/PlanViewer.Cli/Program.cs +++ b/src/PlanViewer.Cli/Program.cs @@ -1,7 +1,15 @@ using System.CommandLine; +using PlanViewer.Cli; using PlanViewer.Cli.Commands; using PlanViewer.Core.Services; using PlanViewer.Core.Interfaces; +using PlanViewer.Cli.ReplSurface; + +if (CliRouting.ShouldUseRepl(args)) +{ + var repl = PlanReplApp.Create(); + return await repl.RunAsync(CliRouting.GetReplArgs(args)); +} // Create credential service (platform-specific) ICredentialService? credentialService = null; diff --git a/src/PlanViewer.Cli/ReplSurface/PlanReplApp.cs b/src/PlanViewer.Cli/ReplSurface/PlanReplApp.cs new file mode 100644 index 0000000..99b3bb7 --- /dev/null +++ b/src/PlanViewer.Cli/ReplSurface/PlanReplApp.cs @@ -0,0 +1,26 @@ +using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Services; +using Repl; +using Repl.Mcp; + +namespace PlanViewer.Cli.ReplSurface; + +public static class PlanReplApp +{ + public static ReplApp Create() => Create(new InMemoryPlanCatalog()); + + public static ReplApp Create(IPlanCatalog catalog) + { + ArgumentNullException.ThrowIfNull(catalog); + var operations = new PlanOperations(catalog); + var app = ReplApp.Create() + .WithDescription("Interactive SQL Server execution plan analysis.") + .WithBanner("Open a plan with: plan open ") + .UseDefaultInteractive() + .UseCliProfile(); + + app.MapModule(new PlanReplModule(catalog, operations)); + app.UseMcpServer(options => options.ServerName = "PerformanceStudio"); + return app; + } +} diff --git a/src/PlanViewer.Cli/ReplSurface/PlanReplModule.cs b/src/PlanViewer.Cli/ReplSurface/PlanReplModule.cs new file mode 100644 index 0000000..ec5d7a6 --- /dev/null +++ b/src/PlanViewer.Cli/ReplSurface/PlanReplModule.cs @@ -0,0 +1,98 @@ +using System.ComponentModel; +using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Services; +using Repl; +using Repl.Mcp; + +namespace PlanViewer.Cli.ReplSurface; + +public sealed class PlanReplModule(IPlanCatalog catalog, PlanOperations operations) : IReplModule +{ + public void Map(IReplMap map) + { + map.Map( + "open {path}", + [Description("Open a .sqlplan file and enter its plan context")] + (string path, CancellationToken cancellationToken) => OpenAsync(path, cancellationToken)) + .WithDescription("Open a SQL Server execution plan") + .ReadOnly(); + + map.Context("plan", plan => + { + plan.Map("list", () => catalog.GetAllSessions()) + .WithDescription("List plans loaded in this process") + .ReadOnly(); + + plan.Map( + "open {path}", + [Description("Open a .sqlplan file and enter its plan context")] + (string path, CancellationToken cancellationToken) => OpenAsync(path, cancellationToken)) + .WithDescription("Open a SQL Server execution plan") + .ReadOnly(); + + plan.Context( + "{id}", + scope => + { + scope.Map("summary", (string id) => operations.GetSummary(id)) + .WithDescription("Show a concise plan summary") + .ReadOnly(); + + scope.Map( + "warnings", + (string id, [Description("Critical, Warning, or Info")] string? severity = null) => + Execute(() => operations.GetWarnings(id, severity))) + .WithDescription("List plan warnings, optionally filtered by severity") + .ReadOnly(); + + scope.Map( + "expensive-operators", + (string id, [Description("Number of operators to return (1-100)")] int top = 10) => + Execute(() => operations.GetExpensiveOperators(id, top))) + .WithDescription("List the most expensive operators") + .ReadOnly(); + + scope.Map( + "operators", + (string id, [Description("Number of operators to return (1-100)")] int top = 10) => + Execute(() => operations.GetExpensiveOperators(id, top))) + .WithDescription("Alias for expensive-operators") + .ReadOnly(); + + scope.Map("missing-indexes", (string id) => operations.GetMissingIndexes(id)) + .WithDescription("List missing-index suggestions") + .ReadOnly(); + }, + validation: (string id) => catalog.GetSession(id) is not null); + }); + } + + private async Task OpenAsync(string path, CancellationToken cancellationToken) + { + try + { + var opened = await operations.OpenAsync(path, cancellationToken).ConfigureAwait(false); + return Results.NavigateTo($"plan {opened.SessionId}", opened); + } + catch (FileNotFoundException ex) + { + return Results.NotFound(ex.Message); + } + catch (InvalidDataException ex) + { + return Results.Error("invalid_plan", ex.Message); + } + } + + private static object Execute(Func operation) + { + try + { + return operation()!; + } + catch (ArgumentException ex) + { + return Results.Error("invalid_argument", ex.Message); + } + } +} diff --git a/src/PlanViewer.Core/Interfaces/IPlanCatalog.cs b/src/PlanViewer.Core/Interfaces/IPlanCatalog.cs new file mode 100644 index 0000000..d8f1c7d --- /dev/null +++ b/src/PlanViewer.Core/Interfaces/IPlanCatalog.cs @@ -0,0 +1,15 @@ +using PlanViewer.Core.Models; + +namespace PlanViewer.Core.Interfaces; + +/// +/// Shared catalog of plans loaded by a long-lived CLI, GUI, test, or MCP session. +/// +public interface IPlanCatalog +{ + void Register(PlanSession session); + bool TryRegister(PlanSession session); + bool Unregister(string sessionId); + PlanSession? GetSession(string sessionId); + IReadOnlyList GetAllSessions(); +} diff --git a/src/PlanViewer.Core/Models/PlanSession.cs b/src/PlanViewer.Core/Models/PlanSession.cs new file mode 100644 index 0000000..2359942 --- /dev/null +++ b/src/PlanViewer.Core/Models/PlanSession.cs @@ -0,0 +1,31 @@ +namespace PlanViewer.Core.Models; + +/// +/// Immutable snapshot of a loaded plan that can be shared by CLI, GUI, and MCP surfaces. +/// +public sealed class PlanSession +{ + public required string SessionId { get; init; } + public required string Label { get; init; } + public required string Source { get; init; } + public required ParsedPlan Plan { get; init; } + public string? QueryText { get; init; } + public string? ConnectionInfo { get; init; } + public int StatementCount { get; init; } + public bool HasActualStats { get; init; } + public int WarningCount { get; init; } + public int CriticalWarningCount { get; init; } + public int MissingIndexCount { get; init; } +} + +public sealed class PlanSessionSummary +{ + public string SessionId { get; set; } = ""; + public string Label { get; set; } = ""; + public string Source { get; set; } = ""; + public int StatementCount { get; set; } + public int WarningCount { get; set; } + public int CriticalWarningCount { get; set; } + public int MissingIndexCount { get; set; } + public bool HasActualStats { get; set; } +} diff --git a/src/PlanViewer.Core/Output/PlanOperationResults.cs b/src/PlanViewer.Core/Output/PlanOperationResults.cs new file mode 100644 index 0000000..eb9df4f --- /dev/null +++ b/src/PlanViewer.Core/Output/PlanOperationResults.cs @@ -0,0 +1,162 @@ +using System.Text.Json.Serialization; + +namespace PlanViewer.Core.Output; + +public sealed class PlanSummaryResult +{ + [JsonPropertyName("session_id")] + public string SessionId { get; init; } = ""; + + [JsonPropertyName("label")] + public string Label { get; init; } = ""; + + [JsonPropertyName("source")] + public string Source { get; init; } = ""; + + [JsonPropertyName("total_statements")] + public int TotalStatements { get; init; } + + [JsonPropertyName("total_warnings")] + public int TotalWarnings { get; init; } + + [JsonPropertyName("critical_warnings")] + public int CriticalWarnings { get; init; } + + [JsonPropertyName("missing_indexes")] + public int MissingIndexes { get; init; } + + [JsonPropertyName("has_actual_stats")] + public bool HasActualStats { get; init; } + + [JsonPropertyName("max_estimated_cost")] + public double MaxEstimatedCost { get; init; } + + [JsonPropertyName("warning_types")] + public IReadOnlyList WarningTypes { get; init; } = []; +} + + +public sealed class PlanWarningsResult +{ + [JsonPropertyName("session_id")] + public string SessionId { get; init; } = ""; + + [JsonPropertyName("warning_count")] + public int WarningCount { get; init; } + + [JsonPropertyName("warnings")] + public IReadOnlyList Warnings { get; init; } = []; +} + +public sealed class PlanWarningItem +{ + [JsonPropertyName("severity")] + public string Severity { get; init; } = ""; + + [JsonPropertyName("type")] + public string Type { get; init; } = ""; + + [JsonPropertyName("message")] + public string Message { get; init; } = ""; + + [JsonPropertyName("node_id")] + public int? NodeId { get; init; } + + [JsonPropertyName("operator")] + public string? Operator { get; init; } + + [JsonPropertyName("statement")] + public string Statement { get; init; } = ""; +} + + +public sealed class ExpensiveOperatorsResult +{ + [JsonPropertyName("session_id")] + public string SessionId { get; init; } = ""; + + [JsonPropertyName("ranked_by")] + public string RankedBy { get; init; } = ""; + + [JsonPropertyName("operators")] + public IReadOnlyList Operators { get; init; } = []; +} + +public sealed class ExpensiveOperatorItem +{ + [JsonPropertyName("node_id")] + public int NodeId { get; init; } + + [JsonPropertyName("physical_op")] + public string PhysicalOp { get; init; } = ""; + + [JsonPropertyName("logical_op")] + public string LogicalOp { get; init; } = ""; + + [JsonPropertyName("cost_percent")] + public int CostPercent { get; init; } + + [JsonPropertyName("estimated_rows")] + public double EstimatedRows { get; init; } + + [JsonPropertyName("actual_rows")] + public long ActualRows { get; init; } + + [JsonPropertyName("actual_elapsed_ms")] + public long ActualElapsedMs { get; init; } + + [JsonPropertyName("actual_cpu_ms")] + public long ActualCpuMs { get; init; } + + [JsonPropertyName("logical_reads")] + public long LogicalReads { get; init; } + + [JsonPropertyName("physical_reads")] + public long PhysicalReads { get; init; } + + [JsonPropertyName("object_name")] + public string? ObjectName { get; init; } + + [JsonPropertyName("statement")] + public string Statement { get; init; } = ""; +} + + +public sealed class MissingIndexesResult +{ + [JsonPropertyName("session_id")] + public string SessionId { get; init; } = ""; + + [JsonPropertyName("missing_index_count")] + public int MissingIndexCount { get; init; } + + [JsonPropertyName("indexes")] + public IReadOnlyList Indexes { get; init; } = []; +} + +public sealed class MissingIndexItem +{ + [JsonPropertyName("database")] + public string? Database { get; init; } + + [JsonPropertyName("schema_name")] + public string? SchemaName { get; init; } + + [JsonPropertyName("table")] + public string Table { get; init; } = ""; + + [JsonPropertyName("impact")] + public double Impact { get; init; } + + [JsonPropertyName("equality_columns")] + public IReadOnlyList EqualityColumns { get; init; } = []; + + [JsonPropertyName("inequality_columns")] + public IReadOnlyList InequalityColumns { get; init; } = []; + + [JsonPropertyName("include_columns")] + public IReadOnlyList IncludeColumns { get; init; } = []; + + [JsonPropertyName("create_statement")] + public string CreateStatement { get; init; } = ""; +} diff --git a/src/PlanViewer.Core/Services/InMemoryPlanCatalog.cs b/src/PlanViewer.Core/Services/InMemoryPlanCatalog.cs new file mode 100644 index 0000000..3c7b319 --- /dev/null +++ b/src/PlanViewer.Core/Services/InMemoryPlanCatalog.cs @@ -0,0 +1,50 @@ +using System.Collections.Concurrent; +using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Models; + +namespace PlanViewer.Core.Services; + +/// +/// Thread-safe in-memory implementation of the shared plan catalog. +/// +public class InMemoryPlanCatalog : IPlanCatalog +{ + private readonly ConcurrentDictionary _sessions = + new(StringComparer.OrdinalIgnoreCase); + + public void Register(PlanSession session) + { + ArgumentNullException.ThrowIfNull(session); + _sessions[session.SessionId] = session; + } + + public bool TryRegister(PlanSession session) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentException.ThrowIfNullOrWhiteSpace(session.SessionId); + return _sessions.TryAdd(session.SessionId, session); + } + + public bool Unregister(string sessionId) => + _sessions.TryRemove(sessionId, out _); + + public PlanSession? GetSession(string sessionId) => + _sessions.TryGetValue(sessionId, out var session) ? session : null; + + public IReadOnlyList GetAllSessions() => + _sessions.Values + .OrderBy(session => session.Label, StringComparer.OrdinalIgnoreCase) + .ThenBy(session => session.SessionId, StringComparer.OrdinalIgnoreCase) + .Select(session => new PlanSessionSummary + { + SessionId = session.SessionId, + Label = session.Label, + Source = session.Source, + StatementCount = session.StatementCount, + WarningCount = session.WarningCount, + CriticalWarningCount = session.CriticalWarningCount, + MissingIndexCount = session.MissingIndexCount, + HasActualStats = session.HasActualStats + }) + .ToList(); +} diff --git a/src/PlanViewer.Core/Services/PlanOperations.cs b/src/PlanViewer.Core/Services/PlanOperations.cs new file mode 100644 index 0000000..9a5702c --- /dev/null +++ b/src/PlanViewer.Core/Services/PlanOperations.cs @@ -0,0 +1,243 @@ +using System.Text.RegularExpressions; +using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Models; +using PlanViewer.Core.Output; + +namespace PlanViewer.Core.Services; + +/// +/// Typed, renderer-free operations over plans loaded in a shared catalog. +/// +public sealed class PlanOperations +{ + private readonly IPlanCatalog _catalog; + private readonly AnalyzerConfig _config; + + public PlanOperations(IPlanCatalog catalog, AnalyzerConfig? config = null) + { + _catalog = catalog ?? throw new ArgumentNullException(nameof(catalog)); + _config = config ?? ConfigLoader.Load(); + } + + public async Task OpenAsync( + string path, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + var fullPath = Path.GetFullPath(path); + if (!File.Exists(fullPath)) + throw new FileNotFoundException($"Plan file not found: {fullPath}", fullPath); + + var planXml = await File.ReadAllTextAsync(fullPath, cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(planXml)) + throw new InvalidDataException("Plan file is empty."); + + var plan = ShowPlanParser.Parse(planXml); + PlanAnalyzer.Analyze(plan, _config); + BenefitScorer.Score(plan); + + if (plan.Batches.SelectMany(batch => batch.Statements).Count() == 0) + throw new InvalidDataException("Could not parse any statements from the plan XML."); + + var label = Path.GetFileName(fullPath); + var analysis = ResultMapper.Map(plan, label); + var baseId = CreateBaseSessionId(Path.GetFileNameWithoutExtension(fullPath)); + for (var suffix = 1; ; suffix++) + { + var sessionId = suffix == 1 ? baseId : $"{baseId}-{suffix}"; + var session = new PlanSession + { + SessionId = sessionId, + Label = label, + Source = label, + Plan = plan, + StatementCount = analysis.Summary.TotalStatements, + HasActualStats = analysis.Summary.HasActualStats, + WarningCount = analysis.Summary.TotalWarnings, + CriticalWarningCount = analysis.Summary.CriticalWarnings, + MissingIndexCount = analysis.Summary.MissingIndexes + }; + + if (_catalog.TryRegister(session)) + return ToSummary(session); + } + } + + public PlanSummaryResult GetSummary(string sessionId) + { + var session = GetRequiredSession(sessionId); + var analysis = ResultMapper.Map(session.Plan, session.Source); + return new PlanSummaryResult + { + SessionId = session.SessionId, + Label = session.Label, + Source = session.Source, + TotalStatements = analysis.Summary.TotalStatements, + TotalWarnings = analysis.Summary.TotalWarnings, + CriticalWarnings = analysis.Summary.CriticalWarnings, + MissingIndexes = analysis.Summary.MissingIndexes, + HasActualStats = analysis.Summary.HasActualStats, + MaxEstimatedCost = analysis.Summary.MaxEstimatedCost, + WarningTypes = analysis.Summary.WarningTypes + }; + } + + public MissingIndexesResult GetMissingIndexes(string sessionId) + { + var session = GetRequiredSession(sessionId); + var indexes = session.Plan.AllMissingIndexes.Select(index => new MissingIndexItem + { + Database = index.Database, + SchemaName = index.Schema, + Table = index.Table, + Impact = index.Impact, + EqualityColumns = index.EqualityColumns, + InequalityColumns = index.InequalityColumns, + IncludeColumns = index.IncludeColumns, + CreateStatement = index.CreateStatement + }).ToList(); + + return new MissingIndexesResult + { + SessionId = sessionId, + MissingIndexCount = indexes.Count, + Indexes = indexes + }; + } + + public ExpensiveOperatorsResult GetExpensiveOperators(string sessionId, int top = 10) + { + if (top is < 1 or > 100) + throw new ArgumentOutOfRangeException(nameof(top), top, "Top must be between 1 and 100."); + + var analysis = GetAnalysis(sessionId); + var operators = new List<(OperatorResult Node, string Statement)>(); + foreach (var statement in analysis.Statements) + { + if (statement.OperatorTree is not null) + CollectOperators(statement.OperatorTree, Truncate(statement.StatementText, 100), operators); + } + + var hasActuals = operators.Any(item => item.Node.ActualElapsedMs > 0); + var ranked = hasActuals + ? operators.OrderByDescending(item => item.Node.ActualElapsedMs ?? 0) + : operators.OrderByDescending(item => item.Node.CostPercent); + + return new ExpensiveOperatorsResult + { + SessionId = sessionId, + RankedBy = hasActuals ? "actual_elapsed_ms" : "cost_percent", + Operators = ranked.Take(top).Select(item => new ExpensiveOperatorItem + { + NodeId = item.Node.NodeId, + PhysicalOp = item.Node.PhysicalOp, + LogicalOp = item.Node.LogicalOp, + CostPercent = item.Node.CostPercent, + EstimatedRows = item.Node.EstimatedRows, + ActualRows = item.Node.ActualRows ?? 0, + ActualElapsedMs = item.Node.ActualElapsedMs ?? 0, + ActualCpuMs = item.Node.ActualCpuMs ?? 0, + LogicalReads = item.Node.ActualLogicalReads ?? 0, + PhysicalReads = item.Node.ActualPhysicalReads ?? 0, + ObjectName = item.Node.ObjectName, + Statement = item.Statement + }).ToList() + }; + } + + public PlanWarningsResult GetWarnings(string sessionId, string? severity = null) + { + if (severity is not null && + !new[] { "Critical", "Warning", "Info" }.Contains(severity, StringComparer.OrdinalIgnoreCase)) + { + throw new ArgumentException("Severity must be Critical, Warning, or Info.", nameof(severity)); + } + + var analysis = GetAnalysis(sessionId); + var warnings = new List(); + foreach (var statement in analysis.Statements) + { + var statementText = Truncate(statement.StatementText, 200); + warnings.AddRange(statement.Warnings.Select(warning => ToWarningItem(warning, statementText))); + if (statement.OperatorTree is not null) + CollectWarnings(statement.OperatorTree, statementText, warnings); + } + + if (severity is not null) + { + warnings = warnings + .Where(warning => warning.Severity.Equals(severity, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + return new PlanWarningsResult + { + SessionId = sessionId, + WarningCount = warnings.Count, + Warnings = warnings + }; + } + + public AnalysisResult GetAnalysis(string sessionId) + { + var session = GetRequiredSession(sessionId); + return ResultMapper.Map(session.Plan, session.Source); + } + + private static void CollectOperators( + OperatorResult node, + string statement, + ICollection<(OperatorResult Node, string Statement)> operators) + { + operators.Add((node, statement)); + foreach (var child in node.Children) + CollectOperators(child, statement, operators); + } + + private static void CollectWarnings( + OperatorResult node, + string statement, + ICollection warnings) + { + foreach (var warning in node.Warnings) + warnings.Add(ToWarningItem(warning, statement)); + foreach (var child in node.Children) + CollectWarnings(child, statement, warnings); + } + + private static PlanWarningItem ToWarningItem(WarningResult warning, string statement) => new() + { + Severity = warning.Severity, + Type = warning.Type, + Message = warning.Message, + NodeId = warning.NodeId, + Operator = warning.Operator, + Statement = statement + }; + + private static string Truncate(string value, int maxLength) => + value.Length <= maxLength ? value : value[..maxLength] + "... (truncated)"; + + private PlanSession GetRequiredSession(string sessionId) => + _catalog.GetSession(sessionId) + ?? throw new KeyNotFoundException($"Plan session {sessionId} was not found."); + + private static string CreateBaseSessionId(string label) + { + var baseId = Regex.Replace(label.Trim(), "[^A-Za-z0-9._-]+", "-").Trim("-".ToCharArray()); + return baseId.Length == 0 ? "plan" : baseId; + } + + private static PlanSessionSummary ToSummary(PlanSession session) => new() + { + SessionId = session.SessionId, + Label = session.Label, + Source = session.Source, + StatementCount = session.StatementCount, + WarningCount = session.WarningCount, + CriticalWarningCount = session.CriticalWarningCount, + MissingIndexCount = session.MissingIndexCount, + HasActualStats = session.HasActualStats + }; +} diff --git a/tests/PlanViewer.Core.Tests/CliRoutingTests.cs b/tests/PlanViewer.Core.Tests/CliRoutingTests.cs new file mode 100644 index 0000000..daa3888 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/CliRoutingTests.cs @@ -0,0 +1,30 @@ +using PlanViewer.Cli; + +namespace PlanViewer.Core.Tests; + +public sealed class CliRoutingTests +{ + [Theory] + [InlineData("analyze")] + [InlineData("querystore")] + [InlineData("credential")] + [InlineData("--help")] + public void LegacyCommands_RemainOnSystemCommandLine(string command) => + Assert.False(CliRouting.ShouldUseRepl([command])); + + [Theory] + [InlineData("plan")] + [InlineData("open")] + [InlineData("mcp")] + [InlineData("repl")] + public void ReplCommands_UseTheReplGraph(string command) => + Assert.True(CliRouting.ShouldUseRepl([command])); + + [Fact] + public void NoArguments_StartsInteractiveMode() => + Assert.True(CliRouting.ShouldUseRepl([])); + + [Fact] + public void ReplAlias_IsRemovedBeforeDispatch() => + Assert.Equal(["plan", "list"], CliRouting.GetReplArgs(["repl", "plan", "list"])); +} diff --git a/tests/PlanViewer.Core.Tests/McpSmokeTests.cs b/tests/PlanViewer.Core.Tests/McpSmokeTests.cs new file mode 100644 index 0000000..8054516 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/McpSmokeTests.cs @@ -0,0 +1,32 @@ +using ModelContextProtocol.Client; +using PlanViewer.Cli; + +namespace PlanViewer.Core.Tests; + +public sealed class McpSmokeTests +{ + [Fact] + public async Task GeneratedMcpServer_ListsReadOnlyPlanToolsOverStdio() + { + var cancellationToken = TestContext.Current.CancellationToken; + var assemblyPath = typeof(CliRouting).Assembly.Location; + var transport = new StdioClientTransport(new StdioClientTransportOptions + { + Name = "PerformanceStudio test", + Command = "dotnet", + Arguments = [assemblyPath, "mcp", "serve"], + WorkingDirectory = Path.GetDirectoryName(assemblyPath) + }); + + await using var client = await McpClient.CreateAsync( + transport, + cancellationToken: cancellationToken); + var tools = await client.ListToolsAsync(cancellationToken: cancellationToken); + var names = tools.Select(tool => tool.Name).ToArray(); + + Assert.Contains(names, name => name.Contains("summary", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(names, name => name.Contains("warnings", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(names, name => name.Contains("missing", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(names, name => name.Contains("operators", StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/tests/PlanViewer.Core.Tests/PlanCatalogTests.cs b/tests/PlanViewer.Core.Tests/PlanCatalogTests.cs new file mode 100644 index 0000000..fea27e0 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/PlanCatalogTests.cs @@ -0,0 +1,40 @@ +using PlanViewer.Core.Models; +using PlanViewer.Core.Services; + +namespace PlanViewer.Core.Tests; + +public sealed class PlanCatalogTests +{ + [Fact] + public void Register_ListLookupAndRemove_UseTheSharedSessionContract() + { + var catalog = new InMemoryPlanCatalog(); + var beta = CreateSession("beta", "Beta"); + var alpha = CreateSession("alpha", "Alpha"); + + catalog.Register(beta); + catalog.Register(alpha); + + Assert.Same(beta, catalog.GetSession("beta")); + Assert.Collection( + catalog.GetAllSessions(), + item => Assert.Equal("alpha", item.SessionId), + item => Assert.Equal("beta", item.SessionId)); + Assert.True(catalog.Unregister("beta")); + Assert.Null(catalog.GetSession("beta")); + Assert.False(catalog.Unregister("missing")); + } + + private static PlanSession CreateSession(string id, string label) => new() + { + SessionId = id, + Label = label, + Source = "file", + Plan = new ParsedPlan(), + StatementCount = 1, + WarningCount = 2, + CriticalWarningCount = 1, + MissingIndexCount = 3, + HasActualStats = true + }; +} diff --git a/tests/PlanViewer.Core.Tests/PlanOperationsTests.cs b/tests/PlanViewer.Core.Tests/PlanOperationsTests.cs new file mode 100644 index 0000000..91bab62 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/PlanOperationsTests.cs @@ -0,0 +1,145 @@ +using PlanViewer.Core.Services; + +namespace PlanViewer.Core.Tests; + +public sealed class PlanOperationsTests +{ + [Fact] + public async Task OpenAsync_LoadsAnalyzesAndRegistersAPlanFile() + { + var catalog = new InMemoryPlanCatalog(); + var operations = new PlanOperations(catalog); + var path = Path.GetFullPath(Path.Combine("Plans", "row_goal_plan.sqlplan")); + + var opened = await operations.OpenAsync(path, TestContext.Current.CancellationToken); + + Assert.Equal("row_goal_plan", opened.SessionId); + Assert.Equal("row_goal_plan.sqlplan", opened.Label); + Assert.Equal(1, opened.StatementCount); + Assert.Equal(2, opened.WarningCount); + var session = catalog.GetSession(opened.SessionId); + Assert.NotNull(session); + Assert.Equal("row_goal_plan.sqlplan", session.Source); + Assert.NotEmpty(session.Plan.Batches); + } + + [Fact] + public async Task GetSummary_ReturnsAConciseTypedProjection() + { + var operations = new PlanOperations(new InMemoryPlanCatalog()); + var path = Path.GetFullPath(Path.Combine("Plans", "row_goal_plan.sqlplan")); + var opened = await operations.OpenAsync(path, TestContext.Current.CancellationToken); + + var summary = operations.GetSummary(opened.SessionId); + + Assert.Equal(opened.SessionId, summary.SessionId); + Assert.Equal(1, summary.TotalStatements); + Assert.Equal(2, summary.TotalWarnings); + Assert.Equal(0, summary.CriticalWarnings); + Assert.False(summary.HasActualStats); + Assert.Contains("Row Goal", summary.WarningTypes); + } + + [Fact] + public async Task GetWarnings_FiltersAllStatementAndOperatorWarningsBySeverity() + { + var operations = new PlanOperations(new InMemoryPlanCatalog()); + var path = Path.GetFullPath(Path.Combine("Plans", "row_goal_plan.sqlplan")); + var opened = await operations.OpenAsync(path, TestContext.Current.CancellationToken); + + var warnings = operations.GetWarnings(opened.SessionId, "warning"); + + var warning = Assert.Single(warnings.Warnings); + Assert.Equal(1, warnings.WarningCount); + Assert.Equal("Warning", warning.Severity); + Assert.Equal("Top Above Scan", warning.Type); + Assert.NotNull(warning.NodeId); + Assert.NotEmpty(warning.Statement); + } + + + [Fact] + public async Task GetExpensiveOperators_RanksEstimatedPlansByCostAndHonorsTop() + { + var operations = new PlanOperations(new InMemoryPlanCatalog()); + var path = Path.GetFullPath(Path.Combine("Plans", "row_goal_plan.sqlplan")); + var opened = await operations.OpenAsync(path, TestContext.Current.CancellationToken); + + var result = operations.GetExpensiveOperators(opened.SessionId, 1); + + Assert.Equal("cost_percent", result.RankedBy); + var item = Assert.Single(result.Operators); + Assert.Equal("Index Scan", item.PhysicalOp); + Assert.Equal(80, item.CostPercent); + } + + + [Fact] + public async Task GetMissingIndexes_ReturnsStructuredSuggestions() + { + var operations = new PlanOperations(new InMemoryPlanCatalog()); + var path = Path.GetFullPath(Path.Combine("Plans", "top_above_scan_plan.sqlplan")); + var opened = await operations.OpenAsync(path, TestContext.Current.CancellationToken); + + var result = operations.GetMissingIndexes(opened.SessionId); + + Assert.Equal(opened.SessionId, result.SessionId); + Assert.Equal(result.Indexes.Count, result.MissingIndexCount); + var index = Assert.Single(result.Indexes); + Assert.NotEmpty(index.Table); + Assert.Contains("CREATE", index.CreateStatement, StringComparison.OrdinalIgnoreCase); + } + + + [Fact] + public async Task OpenAsync_UsesUniqueSessionIdsForTheSameFile() + { + var operations = new PlanOperations(new InMemoryPlanCatalog()); + var path = Path.GetFullPath(Path.Combine("Plans", "row_goal_plan.sqlplan")); + + var first = await operations.OpenAsync(path, TestContext.Current.CancellationToken); + var second = await operations.OpenAsync(path, TestContext.Current.CancellationToken); + + Assert.Equal("row_goal_plan", first.SessionId); + Assert.Equal("row_goal_plan-2", second.SessionId); + } + + [Fact] + public async Task OpenAsync_RejectsMissingFiles() + { + var operations = new PlanOperations(new InMemoryPlanCatalog()); + var missing = Path.GetFullPath(Path.Combine("Plans", "missing.sqlplan")); + + await Assert.ThrowsAsync( + () => operations.OpenAsync(missing, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task FiltersAndTop_RejectInvalidValues() + { + var operations = new PlanOperations(new InMemoryPlanCatalog()); + var path = Path.GetFullPath(Path.Combine("Plans", "row_goal_plan.sqlplan")); + var opened = await operations.OpenAsync(path, TestContext.Current.CancellationToken); + + Assert.Throws(() => operations.GetWarnings(opened.SessionId, "urgent")); + Assert.Throws(() => operations.GetExpensiveOperators(opened.SessionId, 0)); + Assert.Throws(() => operations.GetExpensiveOperators(opened.SessionId, 101)); + } + + + [Fact] + public async Task OpenAsync_AllocatesUniqueIdsConcurrently() + { + var catalog = new InMemoryPlanCatalog(); + var operations = new PlanOperations(catalog); + var path = Path.GetFullPath(Path.Combine("Plans", "row_goal_plan.sqlplan")); + + var opened = await Task.WhenAll( + Enumerable.Range(0, 8) + .Select(_ => operations.OpenAsync(path, TestContext.Current.CancellationToken))); + + Assert.Equal(8, opened.Select(result => result.SessionId).Distinct().Count()); + Assert.Equal(8, catalog.GetAllSessions().Count); + } + +} diff --git a/tests/PlanViewer.Core.Tests/PlanReplTests.cs b/tests/PlanViewer.Core.Tests/PlanReplTests.cs new file mode 100644 index 0000000..a65f5c3 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/PlanReplTests.cs @@ -0,0 +1,50 @@ +using PlanViewer.Cli.ReplSurface; +using PlanViewer.Core.Output; +using PlanViewer.Core.Services; +using Repl.Testing; + +namespace PlanViewer.Core.Tests; + +public sealed class PlanReplTests +{ + [Fact] + public async Task InteractiveSession_OpenNavigatesAndReusesTypedHandlers() + { + var catalog = new InMemoryPlanCatalog(); + await using var host = ReplTestHost.Create(() => PlanReplApp.Create(catalog)); + var cancellationToken = TestContext.Current.CancellationToken; + await using var session = await host.OpenSessionAsync(cancellationToken: cancellationToken); + var path = Path.GetFullPath(Path.Combine("Plans", "top_above_scan_plan.sqlplan")); + + var command = string.Concat("plan open ", (char)34, path, (char)34, " --no-logo"); + var opened = await session.RunCommandAsync(command, cancellationToken); + var id = Assert.Single(catalog.GetAllSessions()).SessionId; + var summary = await session.RunCommandAsync($"plan {id} summary --json --no-logo", cancellationToken); + var warnings = await session.RunCommandAsync($"plan {id} warnings --json --no-logo", cancellationToken); + var operators = await session.RunCommandAsync($"plan {id} operators --top 1 --json --no-logo", cancellationToken); + var indexes = await session.RunCommandAsync($"plan {id} missing-indexes --json --no-logo", cancellationToken); + + Assert.Equal(0, opened.ExitCode); + Assert.True(summary.ExitCode == 0, $"Summary failed: {summary.OutputText}"); + Assert.True(warnings.ExitCode == 0, $"Warnings failed: {warnings.OutputText}"); + Assert.True(operators.ExitCode == 0, $"Operators failed: {operators.OutputText}"); + Assert.True(indexes.ExitCode == 0, $"Indexes failed: {indexes.OutputText}"); + Assert.Equal(1, summary.GetResult().TotalStatements); + Assert.NotEmpty(warnings.GetResult().Warnings); + Assert.Single(operators.GetResult().Operators); + Assert.Single(indexes.GetResult().Indexes); + } + + [Fact] + public async Task OneShotList_UsesJsonStructuredOutput() + { + await using var host = ReplTestHost.Create(PlanReplApp.Create); + var cancellationToken = TestContext.Current.CancellationToken; + await using var session = await host.OpenSessionAsync(cancellationToken: cancellationToken); + + var execution = await session.RunCommandAsync("plan list --json --no-logo", cancellationToken); + + Assert.Equal(0, execution.ExitCode); + Assert.Contains("[]", execution.OutputText); + } +} diff --git a/tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj b/tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj index 80553b3..397dcaf 100644 --- a/tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj +++ b/tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj @@ -12,12 +12,14 @@ + + From 4c4678bb748fc4b578c402f69006a9b908e67663 Mon Sep 17 00:00:00 2001 From: autocarl Date: Wed, 15 Jul 2026 20:07:21 -0400 Subject: [PATCH 02/16] fix(repl): harden plan exploration pilot --- src/PlanViewer.App/Mcp/McpHostService.cs | 3 +- src/PlanViewer.App/Mcp/McpPlanTools.cs | 14 +- src/PlanViewer.Cli/PlanViewer.Cli.csproj | 8 +- src/PlanViewer.Cli/Program.cs | 2 +- .../ReplSurface/McpPlanPathPolicy.cs | 160 ++++++++++ .../ReplSurface/OpenedFilePathResolver.cs | 77 +++++ src/PlanViewer.Cli/ReplSurface/PlanReplApp.cs | 18 +- .../ReplSurface/PlanReplModule.cs | 77 ++++- src/PlanViewer.Core/Output/AnalysisResult.cs | 3 + .../Output/PlanOperationResults.cs | 10 + src/PlanViewer.Core/Output/ResultMapper.cs | 1 + .../Services/InMemoryPlanCatalog.cs | 64 ++-- .../Services/PlanOperations.cs | 273 ++++++++++++++++-- .../PlanViewer.Core.Tests/CliRoutingTests.cs | 2 +- .../McpPlanPathPolicyTests.cs | 140 +++++++++ .../McpPlanToolsContractTests.cs | 104 +++++++ tests/PlanViewer.Core.Tests/McpSmokeTests.cs | 185 +++++++++++- .../PlanOperationsTests.cs | 216 +++++++++++++- tests/PlanViewer.Core.Tests/PlanReplTests.cs | 22 +- .../PlanViewer.Core.Tests.csproj | 3 +- .../PublicApiCompatibilityTests.cs | 35 +++ 21 files changed, 1328 insertions(+), 89 deletions(-) create mode 100644 src/PlanViewer.Cli/ReplSurface/McpPlanPathPolicy.cs create mode 100644 src/PlanViewer.Cli/ReplSurface/OpenedFilePathResolver.cs create mode 100644 tests/PlanViewer.Core.Tests/McpPlanPathPolicyTests.cs create mode 100644 tests/PlanViewer.Core.Tests/McpPlanToolsContractTests.cs create mode 100644 tests/PlanViewer.Core.Tests/PublicApiCompatibilityTests.cs diff --git a/src/PlanViewer.App/Mcp/McpHostService.cs b/src/PlanViewer.App/Mcp/McpHostService.cs index f5bacd5..e0cf5f5 100644 --- a/src/PlanViewer.App/Mcp/McpHostService.cs +++ b/src/PlanViewer.App/Mcp/McpHostService.cs @@ -9,6 +9,7 @@ using ModelContextProtocol.AspNetCore; using PlanViewer.App.Services; using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Models; using PlanViewer.Core.Services; namespace PlanViewer.App.Mcp; @@ -55,7 +56,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) /* Register services that MCP tools need via dependency injection */ builder.Services.AddSingleton(_sessionManager); builder.Services.AddSingleton(_sessionManager); - builder.Services.AddSingleton(new PlanOperations(_sessionManager)); + builder.Services.AddSingleton(new PlanOperations(_sessionManager, AnalyzerConfig.Default)); builder.Services.AddSingleton(_connectionStore); builder.Services.AddSingleton(_credentialService); diff --git a/src/PlanViewer.App/Mcp/McpPlanTools.cs b/src/PlanViewer.App/Mcp/McpPlanTools.cs index 2b552d6..bb97c0e 100644 --- a/src/PlanViewer.App/Mcp/McpPlanTools.cs +++ b/src/PlanViewer.App/Mcp/McpPlanTools.cs @@ -62,7 +62,7 @@ public static string AnalyzePlan( try { - var result = operations.GetAnalysis(session_id); + var result = operations.GetAnalysis(session); return JsonSerializer.Serialize(result, McpHelpers.JsonOptions); } catch (Exception ex) @@ -85,7 +85,7 @@ public static string GetPlanSummary( try { - return TextFormatter.Format(operations.GetAnalysis(session_id)); + return TextFormatter.Format(operations.GetAnalysis(session)); } catch (Exception ex) { @@ -108,7 +108,11 @@ public static string GetPlanWarnings( try { - var result = operations.GetWarnings(session_id, severity); + var result = operations.GetWarnings( + session, + severity, + includeOperatorWarnings: false, + validateSeverity: false); if (result.WarningCount == 0) { return severity != null @@ -138,7 +142,7 @@ public static string GetMissingIndexes( if (session == null) return SessionNotFound(sessionManager, session_id); - var result = operations.GetMissingIndexes(session_id); + var result = operations.GetMissingIndexes(session); if (result.MissingIndexCount == 0) return "No missing index suggestions in this plan."; @@ -198,7 +202,7 @@ public static string GetExpensiveOperators( var topError = McpHelpers.ValidateTop(top); if (topError != null) return topError; - var result = operations.GetExpensiveOperators(session_id, top); + var result = operations.GetExpensiveOperators(session, top, useBareObjectNames: true); return JsonSerializer.Serialize( new { ranked_by = result.RankedBy, operators = result.Operators }, McpHelpers.JsonOptions); diff --git a/src/PlanViewer.Cli/PlanViewer.Cli.csproj b/src/PlanViewer.Cli/PlanViewer.Cli.csproj index 8505a32..cfea7ed 100644 --- a/src/PlanViewer.Cli/PlanViewer.Cli.csproj +++ b/src/PlanViewer.Cli/PlanViewer.Cli.csproj @@ -14,8 +14,12 @@ - - + + + + + + diff --git a/src/PlanViewer.Cli/Program.cs b/src/PlanViewer.Cli/Program.cs index 2a7b711..f0897e0 100644 --- a/src/PlanViewer.Cli/Program.cs +++ b/src/PlanViewer.Cli/Program.cs @@ -22,7 +22,7 @@ // Credential storage not available — analyze-only mode still works } -var root = new RootCommand("SQL Server execution plan analyzer") +var root = new RootCommand("SQL Server execution plan analyzer (run without arguments for interactive plan exploration)") { AnalyzeCommand.Create(credentialService), QueryStoreCommand.Create(credentialService), diff --git a/src/PlanViewer.Cli/ReplSurface/McpPlanPathPolicy.cs b/src/PlanViewer.Cli/ReplSurface/McpPlanPathPolicy.cs new file mode 100644 index 0000000..75f7710 --- /dev/null +++ b/src/PlanViewer.Cli/ReplSurface/McpPlanPathPolicy.cs @@ -0,0 +1,160 @@ +using Repl.Mcp; + +namespace PlanViewer.Cli.ReplSurface; + +internal static class McpPlanPathPolicy +{ + public static async ValueTask OpenAsync( + string path, + IMcpClientRoots clientRoots, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + ArgumentNullException.ThrowIfNull(clientRoots); + + var roots = await GetEffectiveRootsAsync(clientRoots, cancellationToken).ConfigureAwait(false); + var candidates = Path.IsPathFullyQualified(path) + ? [path] + : roots.Select(root => Path.Combine(root, path)); + + foreach (var candidate in candidates) + { + cancellationToken.ThrowIfCancellationRequested(); + string lexicalPath; + try + { + lexicalPath = Path.GetFullPath(candidate); + } + catch (Exception exception) when (exception is ArgumentException or IOException) + { + continue; + } + + // Never probe an absolute pathname until lexical containment has been established. + if (!roots.Any(root => IsWithinRoot(lexicalPath, root)) || + !Path.GetExtension(lexicalPath).Equals(".sqlplan", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + FileStream? stream = null; + try + { + stream = new FileStream( + lexicalPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 64 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + var openedPath = OpenedFilePathResolver.GetFinalPath(stream); + if (!Path.GetExtension(openedPath).Equals(".sqlplan", StringComparison.OrdinalIgnoreCase) || + !roots.Any(root => IsWithinRoot(openedPath, root))) + { + await stream.DisposeAsync().ConfigureAwait(false); + continue; + } + + return new McpAuthorizedPlanFile(stream, Path.GetFileName(openedPath)); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + if (stream is not null) + await stream.DisposeAsync().ConfigureAwait(false); + } + } + + throw new UnauthorizedAccessException("Plan path is outside the allowed roots or does not exist."); + } + + private static async ValueTask> GetEffectiveRootsAsync( + IMcpClientRoots clientRoots, + CancellationToken cancellationToken) + { + if (!clientRoots.IsSupported && !clientRoots.HasSoftRoots) + return [ResolveExistingDirectory(Directory.GetCurrentDirectory())]; + + IReadOnlyList advertisedRoots; + try + { + advertisedRoots = await clientRoots.GetAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + throw new UnauthorizedAccessException("Client roots could not be obtained.", exception); + } + + cancellationToken.ThrowIfCancellationRequested(); + var roots = advertisedRoots + .Where(root => root.Uri.IsAbsoluteUri && root.Uri.IsFile) + .Select(root => TryResolveExistingDirectory(root.Uri.LocalPath)) + .OfType() + .Distinct(PathComparison) + .ToList(); + if (roots.Count == 0) + throw new UnauthorizedAccessException("The client did not advertise a usable file root."); + return roots; + } + + private static string? TryResolveExistingDirectory(string path) + { + try + { + return ResolveExistingDirectory(path); + } + catch (Exception exception) when (exception is ArgumentException or IOException or UnauthorizedAccessException) + { + return null; + } + } + + private static string ResolveExistingDirectory(string path) + { + var fullPath = Path.GetFullPath(path); + var volumeRoot = Path.GetPathRoot(fullPath) + ?? throw new IOException("Root path has no filesystem root."); + var current = volumeRoot; + var relative = Path.GetRelativePath(volumeRoot, fullPath); + if (!relative.Equals(".", StringComparison.Ordinal)) + { + foreach (var segment in relative.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries)) + { + var directory = new DirectoryInfo(Path.Combine(current, segment)); + if (!directory.Exists) + throw new DirectoryNotFoundException("Root directory does not exist."); + current = directory.ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? directory.FullName; + } + } + + return Path.GetFullPath(current); + } + + private static bool IsWithinRoot(string candidate, string root) + { + var normalizedRoot = Path.TrimEndingDirectorySeparator(root); + var rootPrefix = Path.EndsInDirectorySeparator(normalizedRoot) + ? normalizedRoot + : normalizedRoot + Path.DirectorySeparatorChar; + return candidate.StartsWith(rootPrefix, PathComparisonKind); + } + + private static StringComparer PathComparison => + OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + + private static StringComparison PathComparisonKind => + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; +} + +internal sealed class McpAuthorizedPlanFile(FileStream stream, string label) : IAsyncDisposable +{ + public FileStream Stream { get; } = stream; + public string Label { get; } = label; + + public ValueTask DisposeAsync() => Stream.DisposeAsync(); +} diff --git a/src/PlanViewer.Cli/ReplSurface/OpenedFilePathResolver.cs b/src/PlanViewer.Cli/ReplSurface/OpenedFilePathResolver.cs new file mode 100644 index 0000000..ccb3bcc --- /dev/null +++ b/src/PlanViewer.Cli/ReplSurface/OpenedFilePathResolver.cs @@ -0,0 +1,77 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32.SafeHandles; + +namespace PlanViewer.Cli.ReplSurface; + +internal static class OpenedFilePathResolver +{ + private const int MacOsGetPath = 50; + + public static string GetFinalPath(FileStream stream) + { + ArgumentNullException.ThrowIfNull(stream); + return OperatingSystem.IsWindows() + ? GetWindowsPath(stream.SafeFileHandle) + : OperatingSystem.IsLinux() + ? GetLinuxPath(stream.SafeFileHandle) + : OperatingSystem.IsMacOS() + ? GetMacOsPath(stream.SafeFileHandle) + : throw new IOException("Secure opened-file path validation is not supported on this platform."); + } + + private static string GetLinuxPath(SafeFileHandle handle) + { + var descriptor = handle.DangerousGetHandle().ToInt64(); + var target = new FileInfo($"/proc/self/fd/{descriptor}").ResolveLinkTarget(returnFinalTarget: true) + ?? throw new IOException("Could not resolve the opened file handle."); + return Path.GetFullPath(target.FullName); + } + + private static string GetWindowsPath(SafeFileHandle handle) + { + var buffer = new StringBuilder(512); + var length = GetFinalPathNameByHandle(handle, buffer, (uint)buffer.Capacity, 0); + if (length == 0) + throw CreateNativeIOException("Could not resolve the opened Windows file handle."); + if (length >= buffer.Capacity) + { + buffer.EnsureCapacity(checked((int)length + 1)); + length = GetFinalPathNameByHandle(handle, buffer, (uint)buffer.Capacity, 0); + if (length == 0) + throw CreateNativeIOException("Could not resolve the opened Windows file handle."); + } + + var path = buffer.ToString(); + if (path.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase)) + path = @"\\" + path[8..]; + else if (path.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase)) + path = path[4..]; + return Path.GetFullPath(path); + } + + private static string GetMacOsPath(SafeFileHandle handle) + { + var buffer = new byte[4096]; + if (Fcntl(handle.DangerousGetHandle().ToInt32(), MacOsGetPath, buffer) != 0) + throw CreateNativeIOException("Could not resolve the opened macOS file handle."); + var terminator = Array.IndexOf(buffer, (byte)0); + if (terminator < 0) + terminator = buffer.Length; + return Path.GetFullPath(Encoding.UTF8.GetString(buffer, 0, terminator)); + } + + private static IOException CreateNativeIOException(string message) => + new(message, new Win32Exception(Marshal.GetLastWin32Error())); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint GetFinalPathNameByHandle( + SafeFileHandle file, + StringBuilder filePath, + uint filePathLength, + uint flags); + + [DllImport("libc", EntryPoint = "fcntl", SetLastError = true)] + private static extern int Fcntl(int fileDescriptor, int command, byte[] buffer); +} diff --git a/src/PlanViewer.Cli/ReplSurface/PlanReplApp.cs b/src/PlanViewer.Cli/ReplSurface/PlanReplApp.cs index 99b3bb7..ea46346 100644 --- a/src/PlanViewer.Cli/ReplSurface/PlanReplApp.cs +++ b/src/PlanViewer.Cli/ReplSurface/PlanReplApp.cs @@ -7,6 +7,17 @@ namespace PlanViewer.Cli.ReplSurface; public static class PlanReplApp { + private static readonly HashSet McpCommandPaths = + [ + "plan list", + "plan open {path}", + "plan {id} summary", + "plan {id} warnings", + "plan {id} expensive-operators", + "plan {id} missing-indexes", + "plan {id} close" + ]; + public static ReplApp Create() => Create(new InMemoryPlanCatalog()); public static ReplApp Create(IPlanCatalog catalog) @@ -20,7 +31,12 @@ public static ReplApp Create(IPlanCatalog catalog) .UseCliProfile(); app.MapModule(new PlanReplModule(catalog, operations)); - app.UseMcpServer(options => options.ServerName = "PerformanceStudio"); + app.UseMcpServer(options => + { + options.ServerName = "planview"; + options.CommandFilter = command => McpCommandPaths.Contains(command.Path); + options.AutoPromoteReadOnlyToResources = false; + }); return app; } } diff --git a/src/PlanViewer.Cli/ReplSurface/PlanReplModule.cs b/src/PlanViewer.Cli/ReplSurface/PlanReplModule.cs index ec5d7a6..dc39423 100644 --- a/src/PlanViewer.Cli/ReplSurface/PlanReplModule.cs +++ b/src/PlanViewer.Cli/ReplSurface/PlanReplModule.cs @@ -1,5 +1,7 @@ using System.ComponentModel; using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Models; +using PlanViewer.Core.Output; using PlanViewer.Core.Services; using Repl; using Repl.Mcp; @@ -13,9 +15,9 @@ public void Map(IReplMap map) map.Map( "open {path}", [Description("Open a .sqlplan file and enter its plan context")] - (string path, CancellationToken cancellationToken) => OpenAsync(path, cancellationToken)) - .WithDescription("Open a SQL Server execution plan") - .ReadOnly(); + (string path, CancellationToken cancellationToken, IMcpClientRoots? roots = null) => + OpenAsync(path, roots, cancellationToken)) + .WithDescription("Open a SQL Server execution plan"); map.Context("plan", plan => { @@ -26,15 +28,15 @@ public void Map(IReplMap map) plan.Map( "open {path}", [Description("Open a .sqlplan file and enter its plan context")] - (string path, CancellationToken cancellationToken) => OpenAsync(path, cancellationToken)) - .WithDescription("Open a SQL Server execution plan") - .ReadOnly(); + (string path, CancellationToken cancellationToken, IMcpClientRoots? roots = null) => + OpenAsync(path, roots, cancellationToken)) + .WithDescription("Open a SQL Server execution plan"); plan.Context( "{id}", scope => { - scope.Map("summary", (string id) => operations.GetSummary(id)) + scope.Map("summary", (string id) => Execute(() => operations.GetSummary(id))) .WithDescription("Show a concise plan summary") .ReadOnly(); @@ -59,29 +61,78 @@ public void Map(IReplMap map) .WithDescription("Alias for expensive-operators") .ReadOnly(); - scope.Map("missing-indexes", (string id) => operations.GetMissingIndexes(id)) + scope.Map("missing-indexes", (string id) => Execute(() => operations.GetMissingIndexes(id))) .WithDescription("List missing-index suggestions") .ReadOnly(); + + scope.Map("close", (string id) => Close(id)) + .WithDescription("Close this in-memory plan session"); }, validation: (string id) => catalog.GetSession(id) is not null); }); } - private async Task OpenAsync(string path, CancellationToken cancellationToken) + private async Task OpenAsync( + string path, + IMcpClientRoots? roots, + CancellationToken cancellationToken) { try { - var opened = await operations.OpenAsync(path, cancellationToken).ConfigureAwait(false); + PlanSessionSummary opened; + if (roots is null) + { + opened = await operations.OpenAsync(path, cancellationToken).ConfigureAwait(false); + } + else + { + await using var authorized = await McpPlanPathPolicy + .OpenAsync(path, roots, cancellationToken) + .ConfigureAwait(false); + opened = await operations + .OpenAsync(authorized.Stream, authorized.Label, cancellationToken) + .ConfigureAwait(false); + } return Results.NavigateTo($"plan {opened.SessionId}", opened); } + catch (UnauthorizedAccessException) + { + return Results.Error("path_not_allowed", "Plan path is outside the allowed roots."); + } catch (FileNotFoundException ex) { - return Results.NotFound(ex.Message); + return roots is null + ? Results.NotFound(ex.Message) + : Results.NotFound("Plan file was not found within the allowed roots."); + } + catch (IOException ex) + { + return roots is null + ? Results.Error("file_error", ex.Message) + : Results.Error("file_error", "Plan file could not be read within the allowed roots."); + } + catch (ArgumentException ex) + { + return roots is null + ? Results.Error("invalid_path", ex.Message) + : Results.Error("invalid_path", "Plan path is invalid."); } catch (InvalidDataException ex) { return Results.Error("invalid_plan", ex.Message); } + catch (InvalidOperationException ex) + { + return Results.Error("session_limit", ex.Message); + } + } + + private object Close(string id) + { + if (!operations.Close(id)) + return Results.NotFound("Plan session was not found."); + + return Results.NavigateTo("plan", new PlanCloseResult { SessionId = id, Closed = true }); } private static object Execute(Func operation) @@ -94,5 +145,9 @@ private static object Execute(Func operation) { return Results.Error("invalid_argument", ex.Message); } + catch (KeyNotFoundException) + { + return Results.NotFound("Plan session was not found."); + } } } diff --git a/src/PlanViewer.Core/Output/AnalysisResult.cs b/src/PlanViewer.Core/Output/AnalysisResult.cs index 6834412..fca7e66 100644 --- a/src/PlanViewer.Core/Output/AnalysisResult.cs +++ b/src/PlanViewer.Core/Output/AnalysisResult.cs @@ -303,6 +303,9 @@ public class OperatorResult [JsonPropertyName("object_name")] public string? ObjectName { get; set; } + [JsonIgnore] + public string? BareObjectName { get; set; } + [JsonPropertyName("index_name")] public string? IndexName { get; set; } diff --git a/src/PlanViewer.Core/Output/PlanOperationResults.cs b/src/PlanViewer.Core/Output/PlanOperationResults.cs index eb9df4f..8a9d93c 100644 --- a/src/PlanViewer.Core/Output/PlanOperationResults.cs +++ b/src/PlanViewer.Core/Output/PlanOperationResults.cs @@ -2,6 +2,16 @@ namespace PlanViewer.Core.Output; + +public sealed class PlanCloseResult +{ + [JsonPropertyName("session_id")] + public string SessionId { get; init; } = ""; + + [JsonPropertyName("closed")] + public bool Closed { get; init; } +} + public sealed class PlanSummaryResult { [JsonPropertyName("session_id")] diff --git a/src/PlanViewer.Core/Output/ResultMapper.cs b/src/PlanViewer.Core/Output/ResultMapper.cs index cb970c4..14fbf7d 100644 --- a/src/PlanViewer.Core/Output/ResultMapper.cs +++ b/src/PlanViewer.Core/Output/ResultMapper.cs @@ -249,6 +249,7 @@ private static OperatorResult MapNode(PlanNode node) EstimatedCPU = node.EstimateCPU, EstimatedRowSize = node.EstimatedRowSize, ObjectName = node.FullObjectName ?? node.ObjectName, + BareObjectName = node.ObjectName, IndexName = node.IndexName, DatabaseName = node.DatabaseName, SeekPredicates = node.SeekPredicates, diff --git a/src/PlanViewer.Core/Services/InMemoryPlanCatalog.cs b/src/PlanViewer.Core/Services/InMemoryPlanCatalog.cs index 3c7b319..79a3230 100644 --- a/src/PlanViewer.Core/Services/InMemoryPlanCatalog.cs +++ b/src/PlanViewer.Core/Services/InMemoryPlanCatalog.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using PlanViewer.Core.Interfaces; using PlanViewer.Core.Models; @@ -9,42 +8,59 @@ namespace PlanViewer.Core.Services; /// public class InMemoryPlanCatalog : IPlanCatalog { - private readonly ConcurrentDictionary _sessions = + private readonly object _syncRoot = new(); + private readonly Dictionary _sessions = new(StringComparer.OrdinalIgnoreCase); public void Register(PlanSession session) { ArgumentNullException.ThrowIfNull(session); - _sessions[session.SessionId] = session; + ArgumentException.ThrowIfNullOrWhiteSpace(session.SessionId); + lock (_syncRoot) + _sessions[session.SessionId] = session; } public bool TryRegister(PlanSession session) { ArgumentNullException.ThrowIfNull(session); ArgumentException.ThrowIfNullOrWhiteSpace(session.SessionId); - return _sessions.TryAdd(session.SessionId, session); + lock (_syncRoot) + return _sessions.TryAdd(session.SessionId, session); } - public bool Unregister(string sessionId) => - _sessions.TryRemove(sessionId, out _); + public bool Unregister(string sessionId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + lock (_syncRoot) + return _sessions.Remove(sessionId); + } - public PlanSession? GetSession(string sessionId) => - _sessions.TryGetValue(sessionId, out var session) ? session : null; + public PlanSession? GetSession(string sessionId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + lock (_syncRoot) + return _sessions.GetValueOrDefault(sessionId); + } - public IReadOnlyList GetAllSessions() => - _sessions.Values - .OrderBy(session => session.Label, StringComparer.OrdinalIgnoreCase) - .ThenBy(session => session.SessionId, StringComparer.OrdinalIgnoreCase) - .Select(session => new PlanSessionSummary - { - SessionId = session.SessionId, - Label = session.Label, - Source = session.Source, - StatementCount = session.StatementCount, - WarningCount = session.WarningCount, - CriticalWarningCount = session.CriticalWarningCount, - MissingIndexCount = session.MissingIndexCount, - HasActualStats = session.HasActualStats - }) - .ToList(); + public IReadOnlyList GetAllSessions() + { + lock (_syncRoot) + { + return _sessions.Values + .OrderBy(session => session.Label, StringComparer.OrdinalIgnoreCase) + .ThenBy(session => session.SessionId, StringComparer.OrdinalIgnoreCase) + .Select(session => new PlanSessionSummary + { + SessionId = session.SessionId, + Label = session.Label, + Source = session.Source, + StatementCount = session.StatementCount, + WarningCount = session.WarningCount, + CriticalWarningCount = session.CriticalWarningCount, + MissingIndexCount = session.MissingIndexCount, + HasActualStats = session.HasActualStats + }) + .ToList(); + } + } } diff --git a/src/PlanViewer.Core/Services/PlanOperations.cs b/src/PlanViewer.Core/Services/PlanOperations.cs index 9a5702c..a0e63fd 100644 --- a/src/PlanViewer.Core/Services/PlanOperations.cs +++ b/src/PlanViewer.Core/Services/PlanOperations.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using System.Text.RegularExpressions; using PlanViewer.Core.Interfaces; using PlanViewer.Core.Models; @@ -10,8 +11,17 @@ namespace PlanViewer.Core.Services; /// public sealed class PlanOperations { + public const long DefaultMaxPlanFileBytes = 16L * 1024 * 1024; + public const int DefaultMaxSessions = 32; + public const int DefaultMaxStatements = 10_000; + public const int DefaultMaxOperators = 100_000; + public const int DefaultMaxConcurrentOpens = 2; + + private static readonly ConditionalWeakTable CatalogRegistrationGates = new(); + private readonly IPlanCatalog _catalog; private readonly AnalyzerConfig _config; + private readonly SemaphoreSlim _openSlots = new(DefaultMaxConcurrentOpens); public PlanOperations(IPlanCatalog catalog, AnalyzerConfig? config = null) { @@ -24,28 +34,102 @@ public async Task OpenAsync( CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(path); + if (!Path.GetExtension(path).Equals(".sqlplan", StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("Only .sqlplan files can be opened."); + + await _openSlots.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + return await OpenPathCoreAsync(path, cancellationToken).ConfigureAwait(false); + } + finally + { + _openSlots.Release(); + } + } + + public async Task OpenAsync( + FileStream stream, + string label, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(stream); + ArgumentException.ThrowIfNullOrWhiteSpace(label); + if (!Path.GetExtension(label).Equals(".sqlplan", StringComparison.OrdinalIgnoreCase) || + !Path.GetFileName(label).Equals(label, StringComparison.Ordinal)) + { + throw new InvalidDataException("The plan label must be a .sqlplan file name."); + } + + await _openSlots.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + return await OpenStreamCoreAsync(stream, label, cancellationToken).ConfigureAwait(false); + } + finally + { + _openSlots.Release(); + } + } + private async Task OpenPathCoreAsync( + string path, + CancellationToken cancellationToken) + { var fullPath = Path.GetFullPath(path); - if (!File.Exists(fullPath)) - throw new FileNotFoundException($"Plan file not found: {fullPath}", fullPath); + await using var stream = new FileStream( + fullPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 64 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + return await OpenStreamCoreAsync( + stream, + Path.GetFileName(fullPath), + cancellationToken).ConfigureAwait(false); + } + + private async Task OpenStreamCoreAsync( + FileStream stream, + string label, + CancellationToken cancellationToken) + { + EnsureSessionCapacity(); + if (!stream.CanRead || !stream.CanSeek) + throw new ArgumentException("The plan stream must be readable and seekable.", nameof(stream)); + if (stream.Position != 0) + throw new ArgumentException("The plan stream must be positioned at the beginning.", nameof(stream)); + if (stream.Length > DefaultMaxPlanFileBytes) + { + throw new InvalidDataException( + $"Plan file exceeds the {DefaultMaxPlanFileBytes / (1024 * 1024)} MiB size limit."); + } - var planXml = await File.ReadAllTextAsync(fullPath, cancellationToken).ConfigureAwait(false); + var planXml = await ReadPlanXmlAsync(stream, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); if (string.IsNullOrWhiteSpace(planXml)) throw new InvalidDataException("Plan file is empty."); var plan = ShowPlanParser.Parse(planXml); + cancellationToken.ThrowIfCancellationRequested(); + if (!string.IsNullOrWhiteSpace(plan.ParseError)) + throw new InvalidDataException($"Could not parse plan XML: {plan.ParseError}"); + if (!plan.Batches.SelectMany(batch => batch.Statements).Any()) + throw new InvalidDataException("Could not parse any statements from the plan XML."); + + ValidateComplexity(plan, cancellationToken); PlanAnalyzer.Analyze(plan, _config); + cancellationToken.ThrowIfCancellationRequested(); BenefitScorer.Score(plan); + cancellationToken.ThrowIfCancellationRequested(); - if (plan.Batches.SelectMany(batch => batch.Statements).Count() == 0) - throw new InvalidDataException("Could not parse any statements from the plan XML."); - - var label = Path.GetFileName(fullPath); var analysis = ResultMapper.Map(plan, label); - var baseId = CreateBaseSessionId(Path.GetFileNameWithoutExtension(fullPath)); - for (var suffix = 1; ; suffix++) + var baseId = CreateBaseSessionId(Path.GetFileNameWithoutExtension(label)); + while (true) { - var sessionId = suffix == 1 ? baseId : $"{baseId}-{suffix}"; + cancellationToken.ThrowIfCancellationRequested(); + var sessionId = $"{baseId}-{Guid.NewGuid():N}"; var session = new PlanSession { SessionId = sessionId, @@ -59,15 +143,50 @@ public async Task OpenAsync( MissingIndexCount = analysis.Summary.MissingIndexes }; - if (_catalog.TryRegister(session)) + if (TryRegisterBounded(session)) return ToSummary(session); + EnsureSessionCapacity(); + } + } + + private static async Task ReadPlanXmlAsync( + FileStream stream, + CancellationToken cancellationToken) + { + using var content = new MemoryStream((int)Math.Min(stream.Length, DefaultMaxPlanFileBytes)); + var buffer = new byte[64 * 1024]; + while (true) + { + var bytesRead = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (bytesRead == 0) + break; + if (content.Length + bytesRead > DefaultMaxPlanFileBytes) + { + throw new InvalidDataException( + $"Plan file exceeds the {DefaultMaxPlanFileBytes / (1024 * 1024)} MiB size limit."); + } + + content.Write(buffer, 0, bytesRead); } + + content.Position = 0; + using var reader = new StreamReader(content, detectEncodingFromByteOrderMarks: true); + return await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); } - public PlanSummaryResult GetSummary(string sessionId) + public bool Close(string sessionId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + return _catalog.Unregister(sessionId); + } + + public PlanSummaryResult GetSummary(string sessionId) => + GetSummary(GetRequiredSession(sessionId)); + + public PlanSummaryResult GetSummary(PlanSession session) { - var session = GetRequiredSession(sessionId); - var analysis = ResultMapper.Map(session.Plan, session.Source); + ArgumentNullException.ThrowIfNull(session); + var analysis = GetAnalysis(session); return new PlanSummaryResult { SessionId = session.SessionId, @@ -83,9 +202,12 @@ public PlanSummaryResult GetSummary(string sessionId) }; } - public MissingIndexesResult GetMissingIndexes(string sessionId) + public MissingIndexesResult GetMissingIndexes(string sessionId) => + GetMissingIndexes(GetRequiredSession(sessionId)); + + public MissingIndexesResult GetMissingIndexes(PlanSession session) { - var session = GetRequiredSession(sessionId); + ArgumentNullException.ThrowIfNull(session); var indexes = session.Plan.AllMissingIndexes.Select(index => new MissingIndexItem { Database = index.Database, @@ -100,18 +222,25 @@ public MissingIndexesResult GetMissingIndexes(string sessionId) return new MissingIndexesResult { - SessionId = sessionId, + SessionId = session.SessionId, MissingIndexCount = indexes.Count, Indexes = indexes }; } - public ExpensiveOperatorsResult GetExpensiveOperators(string sessionId, int top = 10) + public ExpensiveOperatorsResult GetExpensiveOperators(string sessionId, int top = 10) => + GetExpensiveOperators(GetRequiredSession(sessionId), top); + + public ExpensiveOperatorsResult GetExpensiveOperators( + PlanSession session, + int top = 10, + bool useBareObjectNames = false) { + ArgumentNullException.ThrowIfNull(session); if (top is < 1 or > 100) throw new ArgumentOutOfRangeException(nameof(top), top, "Top must be between 1 and 100."); - var analysis = GetAnalysis(sessionId); + var analysis = GetAnalysis(session); var operators = new List<(OperatorResult Node, string Statement)>(); foreach (var statement in analysis.Statements) { @@ -126,7 +255,7 @@ public ExpensiveOperatorsResult GetExpensiveOperators(string sessionId, int top return new ExpensiveOperatorsResult { - SessionId = sessionId, + SessionId = session.SessionId, RankedBy = hasActuals ? "actual_elapsed_ms" : "cost_percent", Operators = ranked.Take(top).Select(item => new ExpensiveOperatorItem { @@ -140,27 +269,35 @@ public ExpensiveOperatorsResult GetExpensiveOperators(string sessionId, int top ActualCpuMs = item.Node.ActualCpuMs ?? 0, LogicalReads = item.Node.ActualLogicalReads ?? 0, PhysicalReads = item.Node.ActualPhysicalReads ?? 0, - ObjectName = item.Node.ObjectName, + ObjectName = useBareObjectNames ? item.Node.BareObjectName : item.Node.ObjectName, Statement = item.Statement }).ToList() }; } - public PlanWarningsResult GetWarnings(string sessionId, string? severity = null) + public PlanWarningsResult GetWarnings(string sessionId, string? severity = null) => + GetWarnings(GetRequiredSession(sessionId), severity); + + public PlanWarningsResult GetWarnings( + PlanSession session, + string? severity = null, + bool includeOperatorWarnings = true, + bool validateSeverity = true) { - if (severity is not null && + ArgumentNullException.ThrowIfNull(session); + if (validateSeverity && severity is not null && !new[] { "Critical", "Warning", "Info" }.Contains(severity, StringComparer.OrdinalIgnoreCase)) { throw new ArgumentException("Severity must be Critical, Warning, or Info.", nameof(severity)); } - var analysis = GetAnalysis(sessionId); + var analysis = GetAnalysis(session); var warnings = new List(); foreach (var statement in analysis.Statements) { var statementText = Truncate(statement.StatementText, 200); warnings.AddRange(statement.Warnings.Select(warning => ToWarningItem(warning, statementText))); - if (statement.OperatorTree is not null) + if (includeOperatorWarnings && statement.OperatorTree is not null) CollectWarnings(statement.OperatorTree, statementText, warnings); } @@ -173,18 +310,89 @@ public PlanWarningsResult GetWarnings(string sessionId, string? severity = null) return new PlanWarningsResult { - SessionId = sessionId, + SessionId = session.SessionId, WarningCount = warnings.Count, Warnings = warnings }; } - public AnalysisResult GetAnalysis(string sessionId) + public AnalysisResult GetAnalysis(string sessionId) => + GetAnalysis(GetRequiredSession(sessionId)); + + public AnalysisResult GetAnalysis(PlanSession session) { - var session = GetRequiredSession(sessionId); + ArgumentNullException.ThrowIfNull(session); return ResultMapper.Map(session.Plan, session.Source); } + private bool TryRegisterBounded(PlanSession session) + { + var gate = CatalogRegistrationGates.GetValue(_catalog, _ => new object()); + lock (gate) + { + if (_catalog.GetAllSessions().Count >= DefaultMaxSessions) + return false; + return _catalog.TryRegister(session); + } + } + + private void EnsureSessionCapacity() + { + if (_catalog.GetAllSessions().Count >= DefaultMaxSessions) + { + throw new InvalidOperationException( + $"The plan session limit of {DefaultMaxSessions} has been reached. Close a session before opening another plan."); + } + } + + private static void ValidateComplexity(ParsedPlan plan, CancellationToken cancellationToken) + { + var pendingStatements = new Stack( + plan.Batches.SelectMany(batch => batch.Statements).Reverse()); + var pendingOperators = new Stack(); + var statements = 0; + while (pendingStatements.TryPop(out var statement)) + { + cancellationToken.ThrowIfCancellationRequested(); + statements++; + if (statements > DefaultMaxStatements) + { + throw new InvalidDataException( + $"Plan exceeds the {DefaultMaxStatements} statement complexity limit."); + } + + if (statement.RootNode is not null) + pendingOperators.Push(statement.RootNode); + if (statement.StoredProcPlan is not null) + PushStatements(statement.StoredProcPlan.Statements, pendingStatements); + for (var index = statement.UdfPlans.Count - 1; index >= 0; index--) + PushStatements(statement.UdfPlans[index].Statements, pendingStatements); + } + + var operators = 0; + while (pendingOperators.TryPop(out var node)) + { + cancellationToken.ThrowIfCancellationRequested(); + operators++; + if (operators > DefaultMaxOperators) + { + throw new InvalidDataException( + $"Plan exceeds the {DefaultMaxOperators} operator complexity limit."); + } + + foreach (var child in node.Children) + pendingOperators.Push(child); + } + } + + private static void PushStatements( + IReadOnlyList statements, + Stack pending) + { + for (var index = statements.Count - 1; index >= 0; index--) + pending.Push(statements[index]); + } + private static void CollectOperators( OperatorResult node, string statement, @@ -225,8 +433,13 @@ private PlanSession GetRequiredSession(string sessionId) => private static string CreateBaseSessionId(string label) { - var baseId = Regex.Replace(label.Trim(), "[^A-Za-z0-9._-]+", "-").Trim("-".ToCharArray()); - return baseId.Length == 0 ? "plan" : baseId; + var baseId = Regex.Replace(label.Trim(), "[^A-Za-z0-9._-]+", "-").Trim('-'); + if (baseId.Length == 0) + return "plan"; + return baseId.Equals("open", StringComparison.OrdinalIgnoreCase) || + baseId.Equals("list", StringComparison.OrdinalIgnoreCase) + ? $"plan-{baseId}" + : baseId; } private static PlanSessionSummary ToSummary(PlanSession session) => new() diff --git a/tests/PlanViewer.Core.Tests/CliRoutingTests.cs b/tests/PlanViewer.Core.Tests/CliRoutingTests.cs index daa3888..ba8dcc4 100644 --- a/tests/PlanViewer.Core.Tests/CliRoutingTests.cs +++ b/tests/PlanViewer.Core.Tests/CliRoutingTests.cs @@ -6,7 +6,7 @@ public sealed class CliRoutingTests { [Theory] [InlineData("analyze")] - [InlineData("querystore")] + [InlineData("query-store")] [InlineData("credential")] [InlineData("--help")] public void LegacyCommands_RemainOnSystemCommandLine(string command) => diff --git a/tests/PlanViewer.Core.Tests/McpPlanPathPolicyTests.cs b/tests/PlanViewer.Core.Tests/McpPlanPathPolicyTests.cs new file mode 100644 index 0000000..f3b3367 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/McpPlanPathPolicyTests.cs @@ -0,0 +1,140 @@ +using PlanViewer.Cli.ReplSurface; +using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Services; +using Repl.Mcp; + +namespace PlanViewer.Core.Tests; + +public sealed class McpPlanPathPolicyTests +{ + [Fact] + public async Task OpenAsync_DeniesAdvertisedEmptyRoots() + { + var roots = new StubClientRoots(isSupported: true, hasSoftRoots: false, []); + + await Assert.ThrowsAsync(async () => + await McpPlanPathPolicy.OpenAsync( + "probe.sqlplan", + roots, + TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task OpenAsync_AllowsAPlanUnderTheFilesystemRoot() + { + var path = Path.Combine(Path.GetTempPath(), $"root-plan-{Guid.NewGuid():N}.sqlplan"); + File.Copy(Path.Combine(AppContext.BaseDirectory, "Plans", "row_goal_plan.sqlplan"), path); + try + { + var filesystemRoot = Path.GetPathRoot(path)!; + var roots = new StubClientRoots( + isSupported: true, + hasSoftRoots: false, + [new McpClientRoot(new Uri(filesystemRoot), "filesystem")]); + + await using var authorized = await McpPlanPathPolicy.OpenAsync( + path, + roots, + TestContext.Current.CancellationToken); + + Assert.Equal(Path.GetFileName(path), authorized.Label); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public async Task OpenAsync_ValidatesAndReturnsTheSameOpenedHandle() + { + var temporary = Path.Combine(Path.GetTempPath(), $"mcp-handle-{Guid.NewGuid():N}"); + var root = Path.Combine(temporary, "root"); + var slot = Path.Combine(root, "slot"); + var originalSlot = Path.Combine(root, "slot-original"); + var outside = Path.Combine(temporary, "outside"); + Directory.CreateDirectory(slot); + Directory.CreateDirectory(outside); + var fileName = "plan.sqlplan"; + var expected = await File.ReadAllTextAsync( + Path.Combine(AppContext.BaseDirectory, "Plans", "row_goal_plan.sqlplan"), + TestContext.Current.CancellationToken); + var replacement = await File.ReadAllTextAsync( + Path.Combine(AppContext.BaseDirectory, "Plans", "top_above_scan_plan.sqlplan"), + TestContext.Current.CancellationToken); + await File.WriteAllTextAsync( + Path.Combine(slot, fileName), + expected, + TestContext.Current.CancellationToken); + await File.WriteAllTextAsync( + Path.Combine(outside, fileName), + replacement, + TestContext.Current.CancellationToken); + + try + { + var roots = new StubClientRoots( + isSupported: true, + hasSoftRoots: false, + [new McpClientRoot(new Uri(root + Path.DirectorySeparatorChar), "plans")]); + await using var authorized = await McpPlanPathPolicy.OpenAsync( + Path.Combine("slot", fileName), + roots, + TestContext.Current.CancellationToken); + + var swapped = false; + try + { + Directory.Move(slot, originalSlot); + Directory.CreateSymbolicLink(slot, outside); + swapped = true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException) + { + // Some platforms deny the rename or symbolic-link creation while the handle is open. + } + + using var reader = new StreamReader(authorized.Stream, leaveOpen: true); + var openedContent = await reader.ReadToEndAsync(TestContext.Current.CancellationToken); + Assert.Equal(expected, openedContent); + if (swapped) + { + Assert.Equal( + replacement, + await File.ReadAllTextAsync( + Path.Combine(slot, fileName), + TestContext.Current.CancellationToken)); + } + + authorized.Stream.Position = 0; + var operations = new PlanOperations(new InMemoryPlanCatalog()); + var opened = await operations.OpenAsync( + authorized.Stream, + authorized.Label, + TestContext.Current.CancellationToken); + Assert.Equal(fileName, opened.Label); + } + finally + { + Directory.Delete(temporary, recursive: true); + } + } + + private sealed class StubClientRoots( + bool isSupported, + bool hasSoftRoots, + IReadOnlyList roots) : IMcpClientRoots + { + public bool IsSupported { get; } = isSupported; + public bool HasSoftRoots { get; } = hasSoftRoots; + public IReadOnlyList Current => roots; + + public ValueTask> GetAsync(CancellationToken cancellationToken = default) => + ValueTask.FromResult(roots); + + public void SetSoftRoots(IEnumerable newRoots) => + throw new NotSupportedException(); + + public void ClearSoftRoots() => throw new NotSupportedException(); + } +} diff --git a/tests/PlanViewer.Core.Tests/McpPlanToolsContractTests.cs b/tests/PlanViewer.Core.Tests/McpPlanToolsContractTests.cs new file mode 100644 index 0000000..b9cfaa1 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/McpPlanToolsContractTests.cs @@ -0,0 +1,104 @@ +using System.Text.Json; +using PlanViewer.App.Mcp; +using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Models; +using PlanViewer.Core.Output; +using PlanViewer.Core.Services; + +namespace PlanViewer.Core.Tests; + +public sealed class McpPlanToolsContractTests +{ + [Fact] + public void GetPlanWarnings_PreservesTheHistoricalStatementWarningScope() + { + var manager = new PlanSessionManager(); + var session = CreateEstimatedSession(); + manager.Register(session); + + var result = McpPlanTools.GetPlanWarnings(manager, new PlanOperations(manager), session.SessionId); + + Assert.Equal("No warnings found in this plan.", result); + } + + [Fact] + public void GetPlanWarnings_PreservesHistoricalInvalidSeverityBehavior() + { + var manager = new PlanSessionManager(); + var session = CreateEstimatedSession(); + manager.Register(session); + + var result = McpPlanTools.GetPlanWarnings( + manager, + new PlanOperations(manager), + session.SessionId, + "urgent"); + + Assert.Equal("No urgent warnings found in this plan.", result); + } + + [Fact] + public void GetExpensiveOperators_PreservesHistoricalBareObjectName() + { + var manager = new PlanSessionManager(); + var session = CreateEstimatedSession(); + var node = PlanTestHelper.FirstStatement(session.Plan).RootNode!; + while (node.ObjectName is null) + node = Assert.Single(node.Children); + node.ObjectName = "BareTable"; + node.FullObjectName = "[database].[dbo].[BareTable]"; + manager.Register(session); + + var json = McpPlanTools.GetExpensiveOperators(manager, new PlanOperations(manager), session.SessionId, 10); + + using var document = JsonDocument.Parse(json); + var item = document.RootElement.GetProperty("operators") + .EnumerateArray() + .Single(element => element.GetProperty("object_name").ValueKind != JsonValueKind.Null); + Assert.Equal("BareTable", item.GetProperty("object_name").GetString()); + } + + [Fact] + public void GetExpensiveOperators_UsesTheSessionSnapshotAfterLookup() + { + var manager = new PlanSessionManager(); + var session = CreateEstimatedSession(); + manager.Register(session); + var operations = new PlanOperations(new ThrowingCatalog()); + + var json = McpPlanTools.GetExpensiveOperators(manager, operations, session.SessionId, 1); + + using var document = JsonDocument.Parse(json); + var item = document.RootElement.GetProperty("operators")[0]; + Assert.Equal(JsonValueKind.Number, item.GetProperty("actual_rows").ValueKind); + Assert.Equal(0, item.GetProperty("actual_rows").GetInt64()); + Assert.Equal(0, item.GetProperty("actual_elapsed_ms").GetInt64()); + } + + private static PlanSession CreateEstimatedSession() + { + var plan = PlanTestHelper.LoadAndAnalyze("row_goal_plan.sqlplan"); + var analysis = ResultMapper.Map(plan, "row_goal_plan.sqlplan"); + return new PlanSession + { + SessionId = $"contract-{Guid.NewGuid():N}", + Label = "row_goal_plan.sqlplan", + Source = "row_goal_plan.sqlplan", + Plan = plan, + StatementCount = analysis.Summary.TotalStatements, + HasActualStats = analysis.Summary.HasActualStats, + WarningCount = analysis.Summary.TotalWarnings, + CriticalWarningCount = analysis.Summary.CriticalWarnings, + MissingIndexCount = analysis.Summary.MissingIndexes + }; + } + + private sealed class ThrowingCatalog : IPlanCatalog + { + public void Register(PlanSession session) => throw new InvalidOperationException(); + public bool TryRegister(PlanSession session) => throw new InvalidOperationException(); + public bool Unregister(string sessionId) => throw new InvalidOperationException(); + public PlanSession? GetSession(string sessionId) => throw new InvalidOperationException("Catalog was queried twice."); + public IReadOnlyList GetAllSessions() => throw new InvalidOperationException(); + } +} diff --git a/tests/PlanViewer.Core.Tests/McpSmokeTests.cs b/tests/PlanViewer.Core.Tests/McpSmokeTests.cs index 8054516..23dede1 100644 --- a/tests/PlanViewer.Core.Tests/McpSmokeTests.cs +++ b/tests/PlanViewer.Core.Tests/McpSmokeTests.cs @@ -1,4 +1,5 @@ using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; using PlanViewer.Cli; namespace PlanViewer.Core.Tests; @@ -6,10 +7,14 @@ namespace PlanViewer.Core.Tests; public sealed class McpSmokeTests { [Fact] - public async Task GeneratedMcpServer_ListsReadOnlyPlanToolsOverStdio() + public async Task GeneratedMcpServer_ExposesOnlyBoundedPlanToolsOverStdio() { - var cancellationToken = TestContext.Current.CancellationToken; - var assemblyPath = typeof(CliRouting).Assembly.Location; + using var timeout = CancellationTokenSource.CreateLinkedTokenSource( + TestContext.Current.CancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(20)); + var cancellationToken = timeout.Token; + var assemblyPath = GetCliAssemblyPath(); + var plansRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "Plans")); var transport = new StdioClientTransport(new StdioClientTransportOptions { Name = "PerformanceStudio test", @@ -17,16 +22,180 @@ public async Task GeneratedMcpServer_ListsReadOnlyPlanToolsOverStdio() Arguments = [assemblyPath, "mcp", "serve"], WorkingDirectory = Path.GetDirectoryName(assemblyPath) }); + var clientOptions = new McpClientOptions + { + Capabilities = new ClientCapabilities + { + Roots = new RootsCapability { ListChanged = true } + }, + Handlers = new McpClientHandlers + { + RootsHandler = (_, _) => ValueTask.FromResult(new ListRootsResult + { + Roots = [new Root { Uri = new Uri(plansRoot + Path.DirectorySeparatorChar).AbsoluteUri, Name = "plans" }] + }) + } + }; await using var client = await McpClient.CreateAsync( transport, + clientOptions, cancellationToken: cancellationToken); + Assert.Equal("planview", client.ServerInfo.Name); var tools = await client.ListToolsAsync(cancellationToken: cancellationToken); - var names = tools.Select(tool => tool.Name).ToArray(); + var names = tools.Select(tool => tool.Name).Order(StringComparer.Ordinal).ToArray(); + + Assert.Equal( + [ + "plan_close", + "plan_expensive-operators", + "plan_list", + "plan_missing-indexes", + "plan_open", + "plan_summary", + "plan_warnings" + ], + names); + Assert.NotEqual(true, tools.Single(tool => tool.Name == "plan_open").ProtocolTool.Annotations?.ReadOnlyHint); + Assert.True(tools.Single(tool => tool.Name == "plan_summary").ProtocolTool.Annotations?.ReadOnlyHint); + Assert.Empty(await client.ListResourcesAsync(cancellationToken: cancellationToken)); + + var outsidePath = Path.Combine(Path.GetTempPath(), $"outside-{Guid.NewGuid():N}.sqlplan"); + try + { + File.Copy(Path.Combine(plansRoot, "top_above_scan_plan.sqlplan"), outsidePath); + var denied = await client.CallToolAsync( + "plan_open", + new Dictionary { ["path"] = outsidePath }, + cancellationToken: cancellationToken); + Assert.True(denied.IsError); + var deniedText = string.Join( + "\n", + denied.Content.OfType().Select(block => block.Text)); + Assert.DoesNotContain(outsidePath, deniedText, StringComparison.Ordinal); + + var linkPath = Path.Combine(plansRoot, $"outside-link-{Guid.NewGuid():N}.sqlplan"); + try + { + var linkCreated = false; + try + { + File.CreateSymbolicLink(linkPath, outsidePath); + linkCreated = true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException) + { + // Some platforms do not permit unprivileged symbolic-link creation. + } + + if (linkCreated) + { + var linked = await client.CallToolAsync( + "plan_open", + new Dictionary { ["path"] = linkPath }, + cancellationToken: cancellationToken); + Assert.True(linked.IsError); + } + } + finally + { + File.Delete(linkPath); + } + + var opened = await client.CallToolAsync( + "plan_open", + new Dictionary { ["path"] = "top_above_scan_plan.sqlplan" }, + cancellationToken: cancellationToken); + Assert.False(opened.IsError); + + var openedText = string.Join( + "\n", + opened.Content.OfType().Select(block => block.Text)); + var id = System.Text.RegularExpressions.Regex.Match( + openedText, + "top_above_scan_plan-[0-9a-f]{32}", + System.Text.RegularExpressions.RegexOptions.IgnoreCase).Value; + Assert.NotEmpty(id); + var summary = await client.CallToolAsync( + "plan_summary", + new Dictionary { ["id"] = id }, + cancellationToken: cancellationToken); + Assert.False(summary.IsError); + } + finally + { + File.Delete(outsidePath); + } + } + + [Fact] + public async Task GeneratedMcpServer_DeniesEmptyAdvertisedRoots() + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource( + TestContext.Current.CancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(20)); + var cancellationToken = timeout.Token; + var assemblyPath = GetCliAssemblyPath(); + var workingDirectory = Path.GetDirectoryName(assemblyPath)!; + var fileName = $"empty-roots-{Guid.NewGuid():N}.sqlplan"; + var probePath = Path.Combine(workingDirectory, fileName); + File.Copy(Path.Combine(AppContext.BaseDirectory, "Plans", "row_goal_plan.sqlplan"), probePath); + try + { + var transport = new StdioClientTransport(new StdioClientTransportOptions + { + Name = "PerformanceStudio empty-roots test", + Command = "dotnet", + Arguments = [assemblyPath, "mcp", "serve"], + WorkingDirectory = workingDirectory + }); + var clientOptions = new McpClientOptions + { + Capabilities = new ClientCapabilities + { + Roots = new RootsCapability { ListChanged = true } + }, + Handlers = new McpClientHandlers + { + RootsHandler = (_, _) => ValueTask.FromResult(new ListRootsResult { Roots = [] }) + } + }; + + await using var client = await McpClient.CreateAsync( + transport, + clientOptions, + cancellationToken: cancellationToken); + var denied = await client.CallToolAsync( + "plan_open", + new Dictionary { ["path"] = fileName }, + cancellationToken: cancellationToken); + + Assert.True(denied.IsError); + } + finally + { + File.Delete(probePath); + } + } + + private static string GetCliAssemblyPath() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + var configuration = directory.Parent?.Name + ?? throw new InvalidOperationException("Could not determine the build configuration."); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "PlanViewer.sln"))) + directory = directory.Parent; - Assert.Contains(names, name => name.Contains("summary", StringComparison.OrdinalIgnoreCase)); - Assert.Contains(names, name => name.Contains("warnings", StringComparison.OrdinalIgnoreCase)); - Assert.Contains(names, name => name.Contains("missing", StringComparison.OrdinalIgnoreCase)); - Assert.Contains(names, name => name.Contains("operators", StringComparison.OrdinalIgnoreCase)); + Assert.NotNull(directory); + var assemblyPath = Path.Combine( + directory.FullName, + "src", + "PlanViewer.Cli", + "bin", + configuration, + "net10.0", + "planview.dll"); + Assert.True(File.Exists(assemblyPath), $"CLI assembly not found: {assemblyPath}"); + return assemblyPath; } } diff --git a/tests/PlanViewer.Core.Tests/PlanOperationsTests.cs b/tests/PlanViewer.Core.Tests/PlanOperationsTests.cs index 91bab62..d9a8b40 100644 --- a/tests/PlanViewer.Core.Tests/PlanOperationsTests.cs +++ b/tests/PlanViewer.Core.Tests/PlanOperationsTests.cs @@ -13,7 +13,7 @@ public async Task OpenAsync_LoadsAnalyzesAndRegistersAPlanFile() var opened = await operations.OpenAsync(path, TestContext.Current.CancellationToken); - Assert.Equal("row_goal_plan", opened.SessionId); + Assert.Matches("^row_goal_plan-[0-9a-f]{32}$", opened.SessionId); Assert.Equal("row_goal_plan.sqlplan", opened.Label); Assert.Equal(1, opened.StatementCount); Assert.Equal(2, opened.WarningCount); @@ -100,8 +100,9 @@ public async Task OpenAsync_UsesUniqueSessionIdsForTheSameFile() var first = await operations.OpenAsync(path, TestContext.Current.CancellationToken); var second = await operations.OpenAsync(path, TestContext.Current.CancellationToken); - Assert.Equal("row_goal_plan", first.SessionId); - Assert.Equal("row_goal_plan-2", second.SessionId); + Assert.Matches("^row_goal_plan-[0-9a-f]{32}$", first.SessionId); + Assert.Matches("^row_goal_plan-[0-9a-f]{32}$", second.SessionId); + Assert.NotEqual(first.SessionId, second.SessionId); } [Fact] @@ -142,4 +143,213 @@ public async Task OpenAsync_AllocatesUniqueIdsConcurrently() Assert.Equal(8, catalog.GetAllSessions().Count); } + [Theory] + [InlineData("open")] + [InlineData("list")] + public async Task OpenAsync_ReservesLiteralCommandNamesInSessionIds(string reservedName) + { + var operations = new PlanOperations(new InMemoryPlanCatalog()); + var source = Path.GetFullPath(Path.Combine("Plans", "row_goal_plan.sqlplan")); + var directory = Directory.CreateTempSubdirectory("plan-id-"); + var path = Path.Combine(directory.FullName, $"{reservedName}.sqlplan"); + try + { + File.Copy(source, path); + + var opened = await operations.OpenAsync(path, TestContext.Current.CancellationToken); + + Assert.Matches($"^plan-{reservedName}-[0-9a-f]{{32}}$", opened.SessionId); + } + finally + { + directory.Delete(recursive: true); + } + } + + [Fact] + public async Task OpenAsync_SurfacesTheParserErrorForMalformedXml() + { + var operations = new PlanOperations(new InMemoryPlanCatalog()); + var path = Path.Combine(Path.GetTempPath(), $"malformed-{Guid.NewGuid():N}.sqlplan"); + try + { + await File.WriteAllTextAsync(path, "", TestContext.Current.CancellationToken); + + var exception = await Assert.ThrowsAsync( + () => operations.OpenAsync(path, TestContext.Current.CancellationToken)); + + Assert.Contains("XML", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("Could not parse any statements", exception.Message); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public async Task OpenAsync_HonorsCancellationWithoutRegisteringASession() + { + var catalog = new InMemoryPlanCatalog(); + var operations = new PlanOperations(catalog); + var path = Path.GetFullPath(Path.Combine("Plans", "row_goal_plan.sqlplan")); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync( + () => operations.OpenAsync(path, cancellation.Token)); + + Assert.Empty(catalog.GetAllSessions()); + } + + [Fact] + public async Task OpenAsync_RejectsPlansOverTheStatementComplexityBudget() + { + var operations = new PlanOperations(new InMemoryPlanCatalog()); + var path = Path.Combine(Path.GetTempPath(), $"complex-{Guid.NewGuid():N}.sqlplan"); + try + { + var xml = new System.Text.StringBuilder( + ""); + for (var id = 0; id <= PlanOperations.DefaultMaxStatements; id++) + { + xml.Append($""); + } + xml.Append(""); + await File.WriteAllTextAsync(path, xml.ToString(), TestContext.Current.CancellationToken); + + var exception = await Assert.ThrowsAsync( + () => operations.OpenAsync(path, TestContext.Current.CancellationToken)); + + Assert.Contains("statement complexity", exception.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public async Task OpenAsync_DoesNotReuseAClosedSessionIdentity() + { + var operations = new PlanOperations(new InMemoryPlanCatalog()); + var path = Path.GetFullPath(Path.Combine("Plans", "row_goal_plan.sqlplan")); + var first = await operations.OpenAsync(path, TestContext.Current.CancellationToken); + Assert.True(operations.Close(first.SessionId)); + + var second = await operations.OpenAsync(path, TestContext.Current.CancellationToken); + + Assert.NotEqual(first.SessionId, second.SessionId); + } + + [Fact] + public async Task OpenAsync_CountsNestedUdfStatementsAgainstTheComplexityBudget() + { + var operations = new PlanOperations(new InMemoryPlanCatalog()); + var path = Path.Combine(Path.GetTempPath(), $"nested-complex-{Guid.NewGuid():N}.sqlplan"); + try + { + var xml = new System.Text.StringBuilder( + """"""); + for (var id = 0; id < PlanOperations.DefaultMaxStatements; id++) + { + xml.Append($""""""); + } + xml.Append(""); + await File.WriteAllTextAsync(path, xml.ToString(), TestContext.Current.CancellationToken); + + var exception = await Assert.ThrowsAsync( + () => operations.OpenAsync(path, TestContext.Current.CancellationToken)); + + Assert.Contains("statement complexity", exception.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public async Task OpenAsync_RejectsFilesLargerThanTheDefaultBudget() + { + var operations = new PlanOperations(new InMemoryPlanCatalog()); + var path = Path.Combine(Path.GetTempPath(), $"oversized-{Guid.NewGuid():N}.sqlplan"); + try + { + await using (var stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + stream.SetLength(16 * 1024 * 1024 + 1); + } + + var exception = await Assert.ThrowsAsync( + () => operations.OpenAsync(path, TestContext.Current.CancellationToken)); + + Assert.Contains("exceeds", exception.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public async Task OpenAsync_RejectsMoreThanTheDefaultSessionBudget() + { + var operations = new PlanOperations(new InMemoryPlanCatalog()); + var path = Path.GetFullPath(Path.Combine("Plans", "row_goal_plan.sqlplan")); + + for (var index = 0; index < 32; index++) + await operations.OpenAsync(path, TestContext.Current.CancellationToken); + + var exception = await Assert.ThrowsAsync( + () => operations.OpenAsync(path, TestContext.Current.CancellationToken)); + + Assert.Contains("session limit", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task OpenAsync_EnforcesTheSessionBudgetAtomicallyUnderConcurrency() + { + var catalog = new InMemoryPlanCatalog(); + var operations = new PlanOperations(catalog); + var path = Path.GetFullPath(Path.Combine("Plans", "row_goal_plan.sqlplan")); + + var admitted = await Task.WhenAll(Enumerable.Range(0, 40).Select(async _ => + { + try + { + await operations.OpenAsync(path, TestContext.Current.CancellationToken); + return true; + } + catch (InvalidOperationException) + { + return false; + } + })); + + Assert.Equal(32, admitted.Count(value => value)); + Assert.Equal(32, catalog.GetAllSessions().Count); + } + + [Fact] + public async Task OpenAsync_RejectsFilesWithoutSqlPlanExtension() + { + var operations = new PlanOperations(new InMemoryPlanCatalog()); + var source = Path.GetFullPath(Path.Combine("Plans", "row_goal_plan.sqlplan")); + var path = Path.Combine(Path.GetTempPath(), $"plan-{Guid.NewGuid():N}.xml"); + try + { + File.Copy(source, path); + + var exception = await Assert.ThrowsAsync( + () => operations.OpenAsync(path, TestContext.Current.CancellationToken)); + + Assert.Contains(".sqlplan", exception.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + File.Delete(path); + } + } + } diff --git a/tests/PlanViewer.Core.Tests/PlanReplTests.cs b/tests/PlanViewer.Core.Tests/PlanReplTests.cs index a65f5c3..ccfac09 100644 --- a/tests/PlanViewer.Core.Tests/PlanReplTests.cs +++ b/tests/PlanViewer.Core.Tests/PlanReplTests.cs @@ -16,7 +16,7 @@ public async Task InteractiveSession_OpenNavigatesAndReusesTypedHandlers() await using var session = await host.OpenSessionAsync(cancellationToken: cancellationToken); var path = Path.GetFullPath(Path.Combine("Plans", "top_above_scan_plan.sqlplan")); - var command = string.Concat("plan open ", (char)34, path, (char)34, " --no-logo"); + var command = $"plan open \"{path}\" --no-logo"; var opened = await session.RunCommandAsync(command, cancellationToken); var id = Assert.Single(catalog.GetAllSessions()).SessionId; var summary = await session.RunCommandAsync($"plan {id} summary --json --no-logo", cancellationToken); @@ -47,4 +47,24 @@ public async Task OneShotList_UsesJsonStructuredOutput() Assert.Equal(0, execution.ExitCode); Assert.Contains("[]", execution.OutputText); } + + [Fact] + public async Task InteractiveSession_CloseEvictsTheLoadedPlan() + { + var catalog = new InMemoryPlanCatalog(); + await using var host = ReplTestHost.Create(() => PlanReplApp.Create(catalog)); + var cancellationToken = TestContext.Current.CancellationToken; + await using var session = await host.OpenSessionAsync(cancellationToken: cancellationToken); + var path = Path.GetFullPath(Path.Combine("Plans", "row_goal_plan.sqlplan")); + + var opened = await session.RunCommandAsync( + $"plan open \"{path}\" --no-logo", + cancellationToken); + var id = Assert.Single(catalog.GetAllSessions()).SessionId; + var closed = await session.RunCommandAsync($"plan {id} close --no-logo", cancellationToken); + + Assert.Equal(0, opened.ExitCode); + Assert.Equal(0, closed.ExitCode); + Assert.Empty(catalog.GetAllSessions()); + } } diff --git a/tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj b/tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj index 397dcaf..2891bc5 100644 --- a/tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj +++ b/tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj @@ -12,7 +12,7 @@ - + @@ -20,6 +20,7 @@ + diff --git a/tests/PlanViewer.Core.Tests/PublicApiCompatibilityTests.cs b/tests/PlanViewer.Core.Tests/PublicApiCompatibilityTests.cs new file mode 100644 index 0000000..73377fd --- /dev/null +++ b/tests/PlanViewer.Core.Tests/PublicApiCompatibilityTests.cs @@ -0,0 +1,35 @@ +using PlanViewer.App.Mcp; +using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Models; +using PlanViewer.Core.Services; + +namespace PlanViewer.Core.Tests; + +public sealed class PublicApiCompatibilityTests +{ + [Fact] + public void PlanCatalog_PreservesTheOriginalTryRegisterSignature() + { + Assert.NotNull(typeof(IPlanCatalog).GetMethod( + nameof(IPlanCatalog.TryRegister), + [typeof(PlanSession)])); + Assert.NotNull(typeof(PlanSessionManager).GetMethod( + nameof(PlanSessionManager.TryRegister), + [typeof(PlanSession)])); + } + + [Fact] + public void PlanOperations_PreservesTheOriginalWarningAndOperatorSignatures() + { + Assert.NotNull(typeof(PlanOperations).GetMethod( + nameof(PlanOperations.GetWarnings), + [typeof(string), typeof(string)])); + Assert.NotNull(typeof(PlanOperations).GetMethod( + nameof(PlanOperations.GetExpensiveOperators), + [typeof(string), typeof(int)])); + } + + [Fact] + public void InMemoryPlanCatalog_RemainsUnsealed() => + Assert.False(typeof(InMemoryPlanCatalog).IsSealed); +} From d8f90c37b0ec5fa89b7344bbe1d22e8ca8faa030 Mon Sep 17 00:00:00 2001 From: autocarl Date: Wed, 15 Jul 2026 20:07:21 -0400 Subject: [PATCH 03/16] docs(repl): clarify pilot guarantees --- README.md | 28 +++++++++++------ docs/repl-pilot.md | 78 +++++++++++++++++++++++++++------------------- 2 files changed, 64 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 81ccb86..a4a6bab 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ Side-by-side comparison of two plans showing cost, runtime, I/O, memory, and wai ### Query Store Integration Fetch top queries by CPU, duration, logical reads, physical reads, writes, memory, or executions from Query Store and load their plans directly into the analyzer. -![Query Store Integration](screenshots/performance_studio_querystore_analysis_top_cpu_by_query_hash.png) +![Query Store Integration](screenshots/performance_studio_query-store_analysis_top_cpu_by_query_hash.png) ### Minimap and colored links by accuracy ratio divergence The minimap provides a high-level overview of the entire plan, allowing you to quickly navigate to areas of interest. Colored links between operators indicate accuracy ratio divergence, helping you identify where estimates are most off from actuals. @@ -151,17 +151,18 @@ planview analyze my_query.sqlplan --output text --warnings-only ### Explore plans interactively (Repl pilot) -Running `planview` without arguments starts a long-lived read-only session. Existing -`analyze`, `querystore`, and `credential` commands continue to use the legacy +Running `planview` without arguments starts a long-lived plan-exploration session. Existing +`analyze`, `query-store`, and `credential` commands continue to use the legacy System.CommandLine graph. ```text planview > open slow-query.sqlplan -[plan slow-query]> summary -[plan slow-query]> warnings --severity critical -[plan slow-query]> operators --top 10 -[plan slow-query]> missing-indexes --json +[plan slow-query-]> summary +[plan slow-query-]> warnings --severity critical +[plan slow-query-]> operators --top 10 +[plan slow-query-]> missing-indexes --json +[plan slow-query-]> close ``` The same graph also works in one-shot and MCP modes: @@ -172,9 +173,16 @@ planview repl # explicit interactive alias planview mcp serve # stdio MCP server generated from the graph ``` -The pilot uses `Repl` `0.11.0-dev.181`; loaded plans live only for the lifetime of -the process and all exposed plan operations are read-only. See -[`docs/repl-pilot.md`](docs/repl-pilot.md) for architecture and compatibility notes. +Bare `planview` is intentionally interactive and waits on stdin; scripts should use an +explicit legacy command, a one-shot `plan` command, or `planview --help`. + +The pilot uses the stable `Repl` `0.11.0` release. Loaded plans live only for the lifetime of +the process; `open` and `close` change only that in-memory catalog and never write the +source plan. MCP `plan_open` validates and reads the same opened file handle inside client +roots, accepts only `.sqlplan` files, and uses the server launch directory only when native +roots are unsupported and no soft roots exist. Advertised empty roots deny access. MCP and +local mode share the 16 MiB file/2-concurrent-open/32-session resource limits. See +[`docs/repl-pilot.md`](docs/repl-pilot.md) for architecture, security, and compatibility notes. ### Capture and analyze plans from a live server diff --git a/docs/repl-pilot.md b/docs/repl-pilot.md index d25ff19..9c057d8 100644 --- a/docs/repl-pilot.md +++ b/docs/repl-pilot.md @@ -2,66 +2,80 @@ ## Goal -Add a Repl 0.11.0-dev.181 command surface for loaded SQL Server plans while preserving the existing System.CommandLine CLI and its JSON contracts. +Add a Repl 0.11.0 command surface for loaded SQL Server plans while preserving the existing System.CommandLine CLI and its JSON contracts. + +Read-only describes the analyzed plans and external systems: the pilot never writes a plan file, database, Query Store, or credential. `open` and `close` do mutate the process-local session catalog and are therefore not annotated as read-only MCP operations. ## Architecture 1. Put immutable plan-session models plus `IPlanCatalog` and its thread-safe in-memory implementation in `PlanViewer.Core`. -2. Put typed, renderer-free read operations (`open`, `list`, `summary`, `warnings`, `missing indexes`, `expensive operators`) in `PlanViewer.Core`. -3. Keep the Avalonia `PlanSessionManager` as a thin compatibility singleton over the Core catalog and make existing MCP tools delegate to Core operations without changing MCP names or JSON property names. -4. Add a Repl `IReplModule` in `PlanViewer.Cli`, pinned to `Repl`/`Repl.Mcp`/`Repl.Testing` 0.11.0-dev.181. Launch Repl for no args and for the new `plan` graph; dispatch all existing argument prefixes to System.CommandLine unchanged. +2. Put typed, renderer-free operations (`open`, `close`, `list`, `summary`, `warnings`, `missing indexes`, `expensive operators`) in `PlanViewer.Core`. +3. Keep the Avalonia `PlanSessionManager` as a thin compatibility singleton over the Core catalog. Existing App MCP tools delegate to Core operations using the already-resolved session snapshot, avoiding a second catalog lookup while preserving their historical output behavior. +4. Add a Repl `IReplModule` in `PlanViewer.Cli`, pinned to the stable `Repl`/`Repl.Mcp`/`Repl.Testing` 0.11.0 release. Launch Repl for no args and for the new `plan` graph; dispatch all existing argument prefixes to System.CommandLine unchanged. 5. Return typed values from Repl handlers so text, JSON, tests, and MCP are renderer concerns rather than handler concerns. +## Security and resource boundaries + +Local CLI/Repl commands may open any accessible `.sqlplan` file, matching normal command-line file semantics. The generated MCP server applies a stricter policy: + +- `plan_open` rejects lexically outside paths before probing them, opens an allowed candidate once, validates its kernel-resolved handle against canonical MCP roots, and parses that same handle. +- The server launch directory is a fallback root only when native roots are unsupported and no soft roots are configured. Advertised-but-empty, invalid, or unavailable roots deny access. +- MCP errors do not disclose absolute paths outside the allowed roots. +- Only an explicit allow-list of plan tools is exported; automatic read-only resource promotion is disabled. +- Files are read through a hard 16 MiB streaming ceiling even if they grow during loading; plans are limited to 10,000 statements and 100,000 operators including recursively retained UDF/stored-procedure subplans, concurrent opens to 2, and the process catalog to 32 sessions. +- `plan_close` provides explicit eviction. Catalog registration and the 32-session ceiling are enforced atomically, and closed session IDs are never reused. +- Loading honors cancellation before and between parse, analysis, scoring, and registration stages. + ## TDD slices -- Catalog registration, list, lookup, and removal. -- File-open success plus file-not-found, empty, and invalid-plan failures. +- Catalog registration, bounded concurrent allocation, list, lookup, and removal. +- File-open success plus extension, missing, empty, malformed, oversized, and complexity failures. - Summary and severity-filtered warning DTOs. - Expensive-operator ranking and `top` validation. - Missing-index result shape. -- Repl one-shot graph and interactive session persistence, including paths with spaces and JSON output. -- Legacy CLI JSON contract characterization before Program routing changes. -- Existing MCP JSON shape characterization before delegation refactor. +- Repl one-shot graph, close/eviction, and interactive session persistence. +- App MCP behavior characterization for warning scope, invalid severity handling, bare object names, numeric actual-stat defaults, and single-snapshot lookup. +- Real stdio MCP coverage for the exact tool allow-list, annotations, empty resource list, roots confinement, advertised empty-root denial, symbolic-link escape rejection, and an allowed open/summary round trip. +- Direct path-policy coverage pins same-handle validation/reading across a pathname swap where the platform permits the swap. ## Verification - `dotnet build PlanViewer.sln -c Release` - `dotnet test PlanViewer.sln -c Release` -- Compare legacy JSON output before and after for a fixed `.sqlplan`. -- Run a Repl one-shot JSON smoke test. -- Run a redirected interactive smoke test covering open, summary, warnings, operators, missing indexes, list, and exit. -- Run `git diff --check` and report the exact Git provenance. - +- Compare legacy compact JSON output byte-for-byte against `upstream/dev` for a fixed `.sqlplan`. +- Run Repl one-shot and persistent-session smoke tests. +- Start the generated MCP server over stdio with a real `McpClient` and a bounded timeout. +- Run `git diff --check`, package-vulnerability inspection, and added-line secret/injection scans. ## Command graph ```text -open {path} # root convenience command; navigates to the plan +open {path} # local convenience command; navigates to the plan plan open {path} # canonical open command plan list # process-local loaded plan sessions plan {id} summary plan {id} warnings [--severity Critical|Warning|Info] plan {id} expensive-operators [--top 10] -plan {id} operators [--top 10] # alias +plan {id} operators [--top 10] # local alias plan {id} missing-indexes +plan {id} close # evict the in-memory session ``` -Inside an interactive plan context, the `plan {id}` prefix is omitted. Handlers -return typed DTOs; operation results carry stable `JsonPropertyName` annotations, while -catalog summaries retain their existing property casing. Rendering is -selected by Repl (`--json`, text, YAML, or Markdown) rather than performed by the -handlers. +Inside an interactive plan context, the `plan {id}` prefix is omitted. Handlers return typed DTOs; operation results carry stable `JsonPropertyName` annotations, while catalog summaries retain their existing property casing. Rendering is selected by Repl (`--json`, text, YAML, or Markdown) rather than performed by handlers. + +The generated MCP server identifies itself as `planview`. It exports the canonical commands only; the root `open` convenience command and `operators` alias remain local. ## Compatibility boundary -- `analyze`, `querystore`, `credential`, and their existing options remain on the - original System.CommandLine root. -- The Repl graph is selected only for no arguments, `repl`, `plan`, `open`, or - `mcp`. -- The existing `analyze --compact` JSON output is treated as a public contract and - is checked byte-for-byte against a pre-change characterization fixture. -- The Avalonia MCP session manager now implements `IPlanCatalog`, and pilot MCP - operations delegate to `PlanOperations` while retaining their existing MCP tool - names and top-level JSON shapes. -- Sessions are process-local and read-only. Query Store mutations and credentials - are intentionally outside this pilot. +- `analyze`, `query-store`, `credential`, and their existing options remain on the original System.CommandLine root. +- The Repl graph is selected only for no arguments, `repl`, `plan`, `open`, or `mcp`. +- The existing `analyze --compact` JSON output is checked manually byte-for-byte against a pre-change baseline. +- The Avalonia MCP session manager implements `IPlanCatalog`; committed App-side tests pin the historical warning scope, invalid-severity message, bare `object_name`, numeric default metrics, and snapshot behavior. +- The desktop MCP host constructs shared operations with `AnalyzerConfig.Default`, so an unrelated malformed `planview.config.json` cannot prevent that server from starting. +- Sessions are process-local. Query Store mutations and credentials are intentionally outside this pilot. + +## Dependency status + +The pilot is pinned to the stable `0.11.0` packages. The CLI, persistent-session, and real MCP tests exercise the Repl APIs used by this integration. + +The Repl repository is MIT licensed. The `0.11.0` NuGet packages still omit license metadata; correcting `PackageLicenseExpression` requires a subsequent Repl package publication and remains an upstream packaging follow-up rather than being represented as fixed by this repository. From 4ab01f1ac57bc7ec7c12349e09692808e0b9abdb Mon Sep 17 00:00:00 2001 From: autocarl Date: Wed, 15 Jul 2026 22:53:38 -0400 Subject: [PATCH 04/16] Harden plan analysis resource handling --- .../Commands/PlanAnalysisRunner.cs | 12 +- src/PlanViewer.Core/Models/PlanSession.cs | 22 +- src/PlanViewer.Core/Output/AnalysisResult.cs | 9 + .../Output/PlanOperationResults.cs | 12 ++ src/PlanViewer.Core/Output/ResultMapper.cs | 64 ++++-- src/PlanViewer.Core/PlanViewer.Core.csproj | 2 + src/PlanViewer.Core/Services/BenefitScorer.cs | 29 ++- .../Services/InMemoryPlanCatalog.cs | 2 +- .../Services/PlanAnalysisPipeline.cs | 47 +++++ .../Services/PlanAnalyzer.Node.cs | 109 +++++----- src/PlanViewer.Core/Services/PlanAnalyzer.cs | 55 +++-- .../Services/PlanOperations.cs | 192 +++++++++-------- .../Services/PlanResourceBudget.cs | 148 +++++++++++++ .../Services/PlanXmlPreflight.cs | 87 ++++++++ .../PlanAnalysisPipelineTests.cs | 68 ++++++ .../PlanOperationsTests.cs | 196 ++++++++++++++++-- .../PlanResourceBudgetTests.cs | 111 ++++++++++ .../PlanXmlPreflightTests.cs | 74 +++++++ 18 files changed, 1032 insertions(+), 207 deletions(-) create mode 100644 src/PlanViewer.Core/Services/PlanAnalysisPipeline.cs create mode 100644 src/PlanViewer.Core/Services/PlanResourceBudget.cs create mode 100644 src/PlanViewer.Core/Services/PlanXmlPreflight.cs create mode 100644 tests/PlanViewer.Core.Tests/PlanAnalysisPipelineTests.cs create mode 100644 tests/PlanViewer.Core.Tests/PlanResourceBudgetTests.cs create mode 100644 tests/PlanViewer.Core.Tests/PlanXmlPreflightTests.cs diff --git a/src/PlanViewer.Cli/Commands/PlanAnalysisRunner.cs b/src/PlanViewer.Cli/Commands/PlanAnalysisRunner.cs index 86cb250..46dce53 100644 --- a/src/PlanViewer.Cli/Commands/PlanAnalysisRunner.cs +++ b/src/PlanViewer.Cli/Commands/PlanAnalysisRunner.cs @@ -19,16 +19,8 @@ public static class PlanAnalysisRunner /// serverMetadata for live captures (enables server-context rules); pass null /// for offline .sqlplan files. /// - public static ParsedPlan Analyze(string planXml, AnalyzerConfig config, ServerMetadata? serverMetadata = null) - { - var plan = ShowPlanParser.Parse(planXml); - if (serverMetadata != null) - PlanAnalyzer.Analyze(plan, config, serverMetadata); - else - PlanAnalyzer.Analyze(plan, config); - BenefitScorer.Score(plan); - return plan; - } + public static ParsedPlan Analyze(string planXml, AnalyzerConfig config, ServerMetadata? serverMetadata = null) => + PlanAnalysisPipeline.Analyze(planXml, config, serverMetadata); /// /// Writes {label}.analysis.json and/or {label}.analysis.txt into outDir per diff --git a/src/PlanViewer.Core/Models/PlanSession.cs b/src/PlanViewer.Core/Models/PlanSession.cs index 2359942..d3832b6 100644 --- a/src/PlanViewer.Core/Models/PlanSession.cs +++ b/src/PlanViewer.Core/Models/PlanSession.cs @@ -1,7 +1,10 @@ +using PlanViewer.Core.Output; + namespace PlanViewer.Core.Models; /// -/// Immutable snapshot of a loaded plan that can be shared by CLI, GUI, and MCP surfaces. +/// Container for a loaded plan and its detached analysis projection. The references are +/// init-only; the historical nested plan and output models remain mutable. /// public sealed class PlanSession { @@ -9,6 +12,7 @@ public sealed class PlanSession public required string Label { get; init; } public required string Source { get; init; } public required ParsedPlan Plan { get; init; } + public AnalysisResult? Analysis { get; init; } public string? QueryText { get; init; } public string? ConnectionInfo { get; init; } public int StatementCount { get; init; } @@ -20,12 +24,12 @@ public sealed class PlanSession public sealed class PlanSessionSummary { - public string SessionId { get; set; } = ""; - public string Label { get; set; } = ""; - public string Source { get; set; } = ""; - public int StatementCount { get; set; } - public int WarningCount { get; set; } - public int CriticalWarningCount { get; set; } - public int MissingIndexCount { get; set; } - public bool HasActualStats { get; set; } + public string SessionId { get; init; } = ""; + public string Label { get; init; } = ""; + public string Source { get; init; } = ""; + public int StatementCount { get; init; } + public int WarningCount { get; init; } + public int CriticalWarningCount { get; init; } + public int MissingIndexCount { get; init; } + public bool HasActualStats { get; init; } } diff --git a/src/PlanViewer.Core/Output/AnalysisResult.cs b/src/PlanViewer.Core/Output/AnalysisResult.cs index fca7e66..dbea260 100644 --- a/src/PlanViewer.Core/Output/AnalysisResult.cs +++ b/src/PlanViewer.Core/Output/AnalysisResult.cs @@ -251,6 +251,15 @@ public class WarningResult public class MissingIndexResult { + [JsonIgnore] + public string Database { get; set; } = ""; + + [JsonIgnore] + public string SchemaName { get; set; } = ""; + + [JsonIgnore] + public string BareTable { get; set; } = ""; + [JsonPropertyName("table")] public string Table { get; set; } = ""; diff --git a/src/PlanViewer.Core/Output/PlanOperationResults.cs b/src/PlanViewer.Core/Output/PlanOperationResults.cs index 8a9d93c..d2e3919 100644 --- a/src/PlanViewer.Core/Output/PlanOperationResults.cs +++ b/src/PlanViewer.Core/Output/PlanOperationResults.cs @@ -54,6 +54,12 @@ public sealed class PlanWarningsResult [JsonPropertyName("warning_count")] public int WarningCount { get; init; } + [JsonPropertyName("returned_warning_count")] + public int ReturnedWarningCount { get; init; } + + [JsonPropertyName("truncated")] + public bool Truncated { get; init; } + [JsonPropertyName("warnings")] public IReadOnlyList Warnings { get; init; } = []; } @@ -140,6 +146,12 @@ public sealed class MissingIndexesResult [JsonPropertyName("missing_index_count")] public int MissingIndexCount { get; init; } + [JsonPropertyName("returned_index_count")] + public int ReturnedIndexCount { get; init; } + + [JsonPropertyName("truncated")] + public bool Truncated { get; init; } + [JsonPropertyName("indexes")] public IReadOnlyList Indexes { get; init; } = []; } diff --git a/src/PlanViewer.Core/Output/ResultMapper.cs b/src/PlanViewer.Core/Output/ResultMapper.cs index 14fbf7d..4a485a2 100644 --- a/src/PlanViewer.Core/Output/ResultMapper.cs +++ b/src/PlanViewer.Core/Output/ResultMapper.cs @@ -8,7 +8,14 @@ namespace PlanViewer.Core.Output; /// public static class ResultMapper { - public static AnalysisResult Map(ParsedPlan plan, string source, ServerMetadata? metadata = null) + public static AnalysisResult Map(ParsedPlan plan, string source, ServerMetadata? metadata = null) => + MapCancellable(plan, source, metadata, CancellationToken.None); + + internal static AnalysisResult MapCancellable( + ParsedPlan plan, + string source, + ServerMetadata? metadata, + CancellationToken cancellationToken) { var result = new AnalysisResult { @@ -19,13 +26,15 @@ public static AnalysisResult Map(ParsedPlan plan, string source, ServerMetadata? foreach (var batch in plan.Batches) { + cancellationToken.ThrowIfCancellationRequested(); foreach (var stmt in batch.Statements) { - result.Statements.Add(MapStatement(stmt)); + cancellationToken.ThrowIfCancellationRequested(); + result.Statements.Add(MapStatement(stmt, cancellationToken)); } } - result.Summary = BuildSummary(result); + result.Summary = BuildSummary(result, cancellationToken); if (metadata != null) { @@ -74,8 +83,11 @@ public static AnalysisResult Map(ParsedPlan plan, string source, ServerMetadata? return result; } - private static StatementResult MapStatement(PlanStatement stmt) + private static StatementResult MapStatement( + PlanStatement stmt, + CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); var result = new StatementResult { StatementText = stmt.StatementText, @@ -192,11 +204,14 @@ private static StatementResult MapStatement(PlanStatement stmt) { result.MissingIndexes.Add(new MissingIndexResult { + Database = mi.Database, + SchemaName = mi.Schema, + BareTable = mi.Table, Table = $"{mi.Database}.{mi.Schema}.{mi.Table}", Impact = mi.Impact, - EqualityColumns = mi.EqualityColumns, - InequalityColumns = mi.InequalityColumns, - IncludeColumns = mi.IncludeColumns, + EqualityColumns = mi.EqualityColumns.ToList(), + InequalityColumns = mi.InequalityColumns.ToList(), + IncludeColumns = mi.IncludeColumns.ToList(), CreateStatement = mi.CreateStatement }); } @@ -204,7 +219,7 @@ private static StatementResult MapStatement(PlanStatement stmt) // Operator tree if (stmt.RootNode != null) { - result.OperatorTree = MapNode(stmt.RootNode); + result.OperatorTree = MapNode(stmt.RootNode, cancellationToken); } // Plan guide @@ -235,8 +250,9 @@ private static StatementResult MapStatement(PlanStatement stmt) return result; } - private static OperatorResult MapNode(PlanNode node) + private static OperatorResult MapNode(PlanNode node, CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); var result = new OperatorResult { NodeId = node.NodeId, @@ -294,19 +310,22 @@ private static OperatorResult MapNode(PlanNode node) // Children foreach (var child in node.Children) - result.Children.Add(MapNode(child)); + result.Children.Add(MapNode(child, cancellationToken)); return result; } - private static AnalysisSummary BuildSummary(AnalysisResult result) + private static AnalysisSummary BuildSummary( + AnalysisResult result, + CancellationToken cancellationToken) { var allWarnings = new List(); foreach (var stmt in result.Statements) { + cancellationToken.ThrowIfCancellationRequested(); allWarnings.AddRange(stmt.Warnings); - CollectNodeWarnings(stmt.OperatorTree, allWarnings); + CollectNodeWarnings(stmt.OperatorTree, allWarnings, cancellationToken); } return new AnalysisSummary @@ -323,12 +342,23 @@ private static AnalysisSummary BuildSummary(AnalysisResult result) }; } - private static void CollectNodeWarnings(OperatorResult? node, List warnings) + private static void CollectNodeWarnings( + OperatorResult? node, + List warnings, + CancellationToken cancellationToken) { - if (node == null) return; - warnings.AddRange(node.Warnings); - foreach (var child in node.Children) - CollectNodeWarnings(child, warnings); + if (node == null) + return; + + var pending = new Stack(); + pending.Push(node); + while (pending.TryPop(out var current)) + { + cancellationToken.ThrowIfCancellationRequested(); + warnings.AddRange(current.Warnings); + for (var index = current.Children.Count - 1; index >= 0; index--) + pending.Push(current.Children[index]); + } } /// diff --git a/src/PlanViewer.Core/PlanViewer.Core.csproj b/src/PlanViewer.Core/PlanViewer.Core.csproj index e4f0a76..72392bb 100644 --- a/src/PlanViewer.Core/PlanViewer.Core.csproj +++ b/src/PlanViewer.Core/PlanViewer.Core.csproj @@ -23,6 +23,8 @@ + + diff --git a/src/PlanViewer.Core/Services/BenefitScorer.cs b/src/PlanViewer.Core/Services/BenefitScorer.cs index 149e03f..21b3145 100644 --- a/src/PlanViewer.Core/Services/BenefitScorer.cs +++ b/src/PlanViewer.Core/Services/BenefitScorer.cs @@ -25,16 +25,21 @@ public static class BenefitScorer "Bare Scan", // Rule 34 }; - public static void Score(ParsedPlan plan) + public static void Score(ParsedPlan plan) => + ScoreCancellable(plan, CancellationToken.None); + + internal static void ScoreCancellable(ParsedPlan plan, CancellationToken cancellationToken) { foreach (var batch in plan.Batches) { + cancellationToken.ThrowIfCancellationRequested(); foreach (var stmt in batch.Statements) { + cancellationToken.ThrowIfCancellationRequested(); ScoreStatementWarnings(stmt); if (stmt.RootNode != null) - ScoreNodeTree(stmt.RootNode, stmt); + ScoreNodeTree(stmt.RootNode, stmt, cancellationToken); if (stmt.WaitStats.Count > 0 && stmt.QueryTimeStats != null) ScoreWaitStats(stmt); @@ -82,7 +87,7 @@ private static void EmitWaitStatWarnings(PlanStatement stmt) { >= 50 => PlanWarningSeverity.Critical, >= 10 => PlanWarningSeverity.Warning, - _ => PlanWarningSeverity.Info, + _ => PlanWarningSeverity.Info, }; stmt.PlanWarnings.Add(new PlanWarning @@ -99,8 +104,8 @@ private static void EmitWaitStatWarnings(PlanStatement stmt) private static string FormatLatency(double ms) { if (ms >= 1000) return $"{ms / 1000:N2} s"; - if (ms >= 10) return $"{ms:N0} ms"; - if (ms >= 1) return $"{ms:N1} ms"; + if (ms >= 10) return $"{ms:N0} ms"; + if (ms >= 1) return $"{ms:N1} ms"; return $"{ms * 1000:N0} µs"; } @@ -145,19 +150,23 @@ private static void ScoreStatementWarnings(PlanStatement stmt) } break; - // Rules that cannot be quantified: leave MaxBenefitPercent as null - // Rule 18 (Compile Memory Exceeded), Rule 20 (Local Variables), - // Rule 27 (Optimize For Unknown) + // Rules that cannot be quantified: leave MaxBenefitPercent as null + // Rule 18 (Compile Memory Exceeded), Rule 20 (Local Variables), + // Rule 27 (Optimize For Unknown) } } } - private static void ScoreNodeTree(PlanNode node, PlanStatement stmt) + private static void ScoreNodeTree( + PlanNode node, + PlanStatement stmt, + CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); ScoreNodeWarnings(node, stmt); foreach (var child in node.Children) - ScoreNodeTree(child, stmt); + ScoreNodeTree(child, stmt, cancellationToken); } private static void ScoreNodeWarnings(PlanNode node, PlanStatement stmt) diff --git a/src/PlanViewer.Core/Services/InMemoryPlanCatalog.cs b/src/PlanViewer.Core/Services/InMemoryPlanCatalog.cs index 79a3230..57f5858 100644 --- a/src/PlanViewer.Core/Services/InMemoryPlanCatalog.cs +++ b/src/PlanViewer.Core/Services/InMemoryPlanCatalog.cs @@ -6,7 +6,7 @@ namespace PlanViewer.Core.Services; /// /// Thread-safe in-memory implementation of the shared plan catalog. /// -public class InMemoryPlanCatalog : IPlanCatalog +internal sealed class InMemoryPlanCatalog : IPlanCatalog { private readonly object _syncRoot = new(); private readonly Dictionary _sessions = diff --git a/src/PlanViewer.Core/Services/PlanAnalysisPipeline.cs b/src/PlanViewer.Core/Services/PlanAnalysisPipeline.cs new file mode 100644 index 0000000..bacabd3 --- /dev/null +++ b/src/PlanViewer.Core/Services/PlanAnalysisPipeline.cs @@ -0,0 +1,47 @@ +using PlanViewer.Core.Models; + +namespace PlanViewer.Core.Services; + +internal static class PlanAnalysisPipeline +{ + internal static ParsedPlan Analyze( + string planXml, + AnalyzerConfig config, + CancellationToken cancellationToken = default) => + Analyze(planXml, config, serverMetadata: null, cancellationToken); + + internal static ParsedPlan Analyze( + string planXml, + AnalyzerConfig config, + ServerMetadata? serverMetadata, + CancellationToken cancellationToken = default) => + Analyze(planXml, config, serverMetadata, cancellationToken, beforeAnalysis: null); + + internal static ParsedPlan Analyze( + string planXml, + AnalyzerConfig config, + ServerMetadata? serverMetadata, + CancellationToken cancellationToken, + Action? beforeAnalysis) + { + ArgumentNullException.ThrowIfNull(planXml); + ArgumentNullException.ThrowIfNull(config); + cancellationToken.ThrowIfCancellationRequested(); + + var plan = ShowPlanParser.Parse(planXml); + cancellationToken.ThrowIfCancellationRequested(); + if (!string.IsNullOrWhiteSpace(plan.ParseError) || + !plan.Batches.SelectMany(batch => batch.Statements).Any()) + { + return plan; + } + + beforeAnalysis?.Invoke(plan, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + PlanAnalyzer.AnalyzeCancellable(plan, config, serverMetadata, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + BenefitScorer.ScoreCancellable(plan, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + return plan; + } +} diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs index ddc11dd..2a702bf 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs @@ -8,12 +8,17 @@ namespace PlanViewer.Core.Services; public static partial class PlanAnalyzer { - private static void AnalyzeNodeTree(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) + private static void AnalyzeNodeTree( + PlanNode node, + PlanStatement stmt, + AnalyzerConfig cfg, + CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); AnalyzeNode(node, stmt, cfg); foreach (var child in node.Children) - AnalyzeNodeTree(child, stmt, cfg); + AnalyzeNodeTree(child, stmt, cfg, cancellationToken); } private static void AnalyzeNode(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) @@ -190,16 +195,16 @@ private static void Rule06_ScalarUdfReference(PlanNode node, PlanStatement stmt, or "CLRUserDefinedFunctionRequiresDataAccess" or "CouldNotGenerateValidParallelPlan"; if (!cfg.IsRuleDisabled(6) && !serialPlanCoversUdf) - foreach (var udf in node.ScalarUdfs) - { - var type = udf.IsClrFunction ? "CLR" : "T-SQL"; - node.Warnings.Add(new PlanWarning + foreach (var udf in node.ScalarUdfs) { - WarningType = "Scalar UDF", - Message = $"Scalar {type} UDF: {udf.FunctionName}. Scalar UDFs run once per row and prevent parallelism. Options: rewrite as an inline table-valued function, assign the result to a variable if only one row is needed, dump results to a #temp table and apply the UDF to the final result set, or on SQL Server 2019+ check if the UDF is eligible for automatic scalar UDF inlining.", - Severity = PlanWarningSeverity.Warning - }); - } + var type = udf.IsClrFunction ? "CLR" : "T-SQL"; + node.Warnings.Add(new PlanWarning + { + WarningType = "Scalar UDF", + Message = $"Scalar {type} UDF: {udf.FunctionName}. Scalar UDFs run once per row and prevent parallelism. Options: rewrite as an inline table-valued function, assign the result to a variable if only one row is needed, dump results to a #temp table and apply the UDF to the final result set, or on SQL Server 2019+ check if the UDF is eligible for automatic scalar UDF inlining.", + Severity = PlanWarningSeverity.Warning + }); + } } @@ -210,52 +215,52 @@ private static void Rule07_SpillSeverity(PlanNode node, PlanStatement stmt, Anal // Exchange spills on Parallelism operators get special handling since their // timing is unreliable but the write count tells the story. if (!cfg.IsRuleDisabled(7)) - foreach (var w in node.Warnings.ToList()) - { - if (w.SpillDetails == null) - continue; + foreach (var w in node.Warnings.ToList()) + { + if (w.SpillDetails == null) + continue; - var isExchangeSpill = w.SpillDetails.SpillType == "Exchange"; + var isExchangeSpill = w.SpillDetails.SpillType == "Exchange"; - if (isExchangeSpill) - { - // Exchange spills: severity based on write count since timing is unreliable - var writes = w.SpillDetails.WritesToTempDb; - if (writes >= 1_000_000) - w.Severity = PlanWarningSeverity.Critical; - else if (writes >= 10_000) - w.Severity = PlanWarningSeverity.Warning; + if (isExchangeSpill) + { + // Exchange spills: severity based on write count since timing is unreliable + var writes = w.SpillDetails.WritesToTempDb; + if (writes >= 1_000_000) + w.Severity = PlanWarningSeverity.Critical; + else if (writes >= 10_000) + w.Severity = PlanWarningSeverity.Warning; - // Surface Parallelism operator time when available (actual plans) - if (node.ActualElapsedMs > 0) + // Surface Parallelism operator time when available (actual plans) + if (node.ActualElapsedMs > 0) + { + var operatorMs = GetParallelismOperatorElapsedMs(node); + var stmtMs = stmt.QueryTimeStats?.ElapsedTimeMs ?? 0; + if (stmtMs > 0 && operatorMs > 0) + { + var pct = (double)operatorMs / stmtMs; + w.Message += $" Operator time: {operatorMs:N0}ms ({pct * 100:N0}% of statement)."; + } + } + } + else if (node.ActualElapsedMs > 0) { - var operatorMs = GetParallelismOperatorElapsedMs(node); + // Sort/Hash spills: severity based on operator time percentage + var operatorMs = GetOperatorOwnElapsedMs(node); var stmtMs = stmt.QueryTimeStats?.ElapsedTimeMs ?? 0; - if (stmtMs > 0 && operatorMs > 0) + + if (stmtMs > 0) { var pct = (double)operatorMs / stmtMs; w.Message += $" Operator time: {operatorMs:N0}ms ({pct * 100:N0}% of statement)."; - } - } - } - else if (node.ActualElapsedMs > 0) - { - // Sort/Hash spills: severity based on operator time percentage - var operatorMs = GetOperatorOwnElapsedMs(node); - var stmtMs = stmt.QueryTimeStats?.ElapsedTimeMs ?? 0; - - if (stmtMs > 0) - { - var pct = (double)operatorMs / stmtMs; - w.Message += $" Operator time: {operatorMs:N0}ms ({pct * 100:N0}% of statement)."; - if (pct >= 0.5) - w.Severity = PlanWarningSeverity.Critical; - else if (pct >= 0.1) - w.Severity = PlanWarningSeverity.Warning; + if (pct >= 0.5) + w.Severity = PlanWarningSeverity.Critical; + else if (pct >= 0.1) + w.Severity = PlanWarningSeverity.Warning; + } } } - } } @@ -881,14 +886,14 @@ private static void Rule29_ImplicitConversionSeek(PlanNode node, PlanStatement s // Rule 29: Enhance implicit conversion warnings — Seek Plan is more severe // Skip for 0-execution nodes — the operator never ran if (!cfg.IsRuleDisabled(29) && !(node.HasActualStats && node.ActualExecutions == 0)) - foreach (var w in node.Warnings.ToList()) - { - if (w.WarningType == "Implicit Conversion" && w.Message.StartsWith("Seek Plan")) + foreach (var w in node.Warnings.ToList()) { - w.Severity = PlanWarningSeverity.Critical; - w.Message = $"Implicit conversion prevented an index seek, forcing a scan instead. Fix the data type mismatch: ensure the parameter or variable type matches the column type exactly. {w.Message}"; + if (w.WarningType == "Implicit Conversion" && w.Message.StartsWith("Seek Plan")) + { + w.Severity = PlanWarningSeverity.Critical; + w.Message = $"Implicit conversion prevented an index seek, forcing a scan instead. Fix the data type mismatch: ensure the parameter or variable type matches the column type exactly. {w.Message}"; + } } - } } diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.cs index 4472936..7fea227 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.cs @@ -24,22 +24,33 @@ public static partial class PlanAnalyzer @"\bCASE\s+(WHEN\b|$)", RegexOptions.IgnoreCase | RegexOptions.Compiled); - public static void Analyze(ParsedPlan plan, AnalyzerConfig? config = null, ServerMetadata? serverMetadata = null) + public static void Analyze(ParsedPlan plan, AnalyzerConfig? config = null, ServerMetadata? serverMetadata = null) => + AnalyzeCancellable(plan, config, serverMetadata, CancellationToken.None); + + internal static void AnalyzeCancellable( + ParsedPlan plan, + AnalyzerConfig? config, + ServerMetadata? serverMetadata, + CancellationToken cancellationToken) { var cfg = config ?? AnalyzerConfig.Default; foreach (var batch in plan.Batches) { + cancellationToken.ThrowIfCancellationRequested(); foreach (var stmt in batch.Statements) { + cancellationToken.ThrowIfCancellationRequested(); AnalyzeStatement(stmt, cfg, serverMetadata); if (stmt.RootNode != null) - AnalyzeNodeTree(stmt.RootNode, stmt, cfg); + AnalyzeNodeTree(stmt.RootNode, stmt, cfg, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); MarkLegacyWarnings(stmt); } } + cancellationToken.ThrowIfCancellationRequested(); // Apply severity overrides to all warnings if (cfg.Rules?.SeverityOverrides?.Count > 0) ApplySeverityOverrides(plan, cfg); @@ -80,17 +91,35 @@ public static void Analyze(ParsedPlan plan, AnalyzerConfig? config = null, Serve // Rule number → WarningType mapping for severity overrides private static readonly Dictionary RuleWarningTypes = new() { - [1] = "Filter Operator", [2] = "Eager Index Spool", [3] = "Serial Plan", - [4] = "UDF Execution", [5] = "Row Estimate Mismatch", [6] = "Scalar UDF", - [7] = "Spill", [8] = "Parallel Skew", [9] = "Memory Grant", - [10] = "Key Lookup", [11] = "Scan With Predicate", [12] = "Non-SARGable Predicate", - [13] = "Data Type Mismatch", [14] = "Lazy Spool Ineffective", [15] = "Join OR Clause", - [16] = "Nested Loops High Executions", [17] = "Many-to-Many Merge Join", - [18] = "Compile Memory Exceeded", [19] = "High Compile CPU", [20] = "Local Variables", - [22] = "Table Variable", [23] = "Table-Valued Function", - [24] = "Top Above Scan", [25] = "Ineffective Parallelism", [26] = "Row Goal", - [27] = "Optimize For Unknown", [28] = "NOT IN with Nullable Column", - [29] = "Implicit Conversion", [30] = "Wide Index Suggestion", + [1] = "Filter Operator", + [2] = "Eager Index Spool", + [3] = "Serial Plan", + [4] = "UDF Execution", + [5] = "Row Estimate Mismatch", + [6] = "Scalar UDF", + [7] = "Spill", + [8] = "Parallel Skew", + [9] = "Memory Grant", + [10] = "Key Lookup", + [11] = "Scan With Predicate", + [12] = "Non-SARGable Predicate", + [13] = "Data Type Mismatch", + [14] = "Lazy Spool Ineffective", + [15] = "Join OR Clause", + [16] = "Nested Loops High Executions", + [17] = "Many-to-Many Merge Join", + [18] = "Compile Memory Exceeded", + [19] = "High Compile CPU", + [20] = "Local Variables", + [22] = "Table Variable", + [23] = "Table-Valued Function", + [24] = "Top Above Scan", + [25] = "Ineffective Parallelism", + [26] = "Row Goal", + [27] = "Optimize For Unknown", + [28] = "NOT IN with Nullable Column", + [29] = "Implicit Conversion", + [30] = "Wide Index Suggestion", [31] = "Parallel Wait Bottleneck", [32] = "Scan Cardinality Misestimate", [33] = "Estimated Plan CE Guess", diff --git a/src/PlanViewer.Core/Services/PlanOperations.cs b/src/PlanViewer.Core/Services/PlanOperations.cs index a0e63fd..8679ee8 100644 --- a/src/PlanViewer.Core/Services/PlanOperations.cs +++ b/src/PlanViewer.Core/Services/PlanOperations.cs @@ -1,4 +1,3 @@ -using System.Runtime.CompilerServices; using System.Text.RegularExpressions; using PlanViewer.Core.Interfaces; using PlanViewer.Core.Models; @@ -11,22 +10,24 @@ namespace PlanViewer.Core.Services; /// public sealed class PlanOperations { - public const long DefaultMaxPlanFileBytes = 16L * 1024 * 1024; - public const int DefaultMaxSessions = 32; - public const int DefaultMaxStatements = 10_000; - public const int DefaultMaxOperators = 100_000; - public const int DefaultMaxConcurrentOpens = 2; - - private static readonly ConditionalWeakTable CatalogRegistrationGates = new(); + internal const long DefaultMaxPlanFileBytes = 16L * 1024 * 1024; + internal const int DefaultMaxSessions = 32; + internal const int DefaultMaxStatements = 10_000; + internal const int DefaultMaxOperators = 100_000; + internal const int DefaultMaxConcurrentOpens = 2; + internal const int DefaultMaxWarningResults = 500; + internal const int DefaultMaxMissingIndexResults = 100; + internal const int DefaultMaxResponseTextLength = 4_096; private readonly IPlanCatalog _catalog; private readonly AnalyzerConfig _config; - private readonly SemaphoreSlim _openSlots = new(DefaultMaxConcurrentOpens); + private readonly PlanResourceBudget _budget; public PlanOperations(IPlanCatalog catalog, AnalyzerConfig? config = null) { _catalog = catalog ?? throw new ArgumentNullException(nameof(catalog)); _config = config ?? ConfigLoader.Load(); + _budget = PlanResourceBudget.ForCatalog(_catalog); } public async Task OpenAsync( @@ -37,15 +38,8 @@ public async Task OpenAsync( if (!Path.GetExtension(path).Equals(".sqlplan", StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("Only .sqlplan files can be opened."); - await _openSlots.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - return await OpenPathCoreAsync(path, cancellationToken).ConfigureAwait(false); - } - finally - { - _openSlots.Release(); - } + using var openSlot = await _budget.AcquireOpenSlotAsync(cancellationToken).ConfigureAwait(false); + return await OpenPathCoreAsync(path, cancellationToken).ConfigureAwait(false); } public async Task OpenAsync( @@ -61,15 +55,27 @@ public async Task OpenAsync( throw new InvalidDataException("The plan label must be a .sqlplan file name."); } - await _openSlots.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - return await OpenStreamCoreAsync(stream, label, cancellationToken).ConfigureAwait(false); - } - finally + using var openSlot = await _budget.AcquireOpenSlotAsync(cancellationToken).ConfigureAwait(false); + return await OpenStreamCoreAsync(stream, label, cancellationToken).ConfigureAwait(false); + } + + internal async Task OpenOwnedStreamAsync( + Func> streamFactory, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(streamFactory); + using var openSlot = await _budget.AcquireOpenSlotAsync(cancellationToken).ConfigureAwait(false); + var source = await streamFactory(cancellationToken).ConfigureAwait(false); + await using var owner = source.Owner.ConfigureAwait(false); + ArgumentNullException.ThrowIfNull(source.Stream); + ArgumentException.ThrowIfNullOrWhiteSpace(source.Label); + if (!Path.GetExtension(source.Label).Equals(".sqlplan", StringComparison.OrdinalIgnoreCase) || + !Path.GetFileName(source.Label).Equals(source.Label, StringComparison.Ordinal)) { - _openSlots.Release(); + throw new InvalidDataException("The plan label must be a .sqlplan file name."); } + + return await OpenStreamCoreAsync(source.Stream, source.Label, cancellationToken).ConfigureAwait(false); } private async Task OpenPathCoreAsync( @@ -95,7 +101,7 @@ private async Task OpenStreamCoreAsync( string label, CancellationToken cancellationToken) { - EnsureSessionCapacity(); + _budget.EnsureSessionCapacity(DefaultMaxSessions); if (!stream.CanRead || !stream.CanSeek) throw new ArgumentException("The plan stream must be readable and seekable.", nameof(stream)); if (stream.Position != 0) @@ -106,36 +112,41 @@ private async Task OpenStreamCoreAsync( $"Plan file exceeds the {DefaultMaxPlanFileBytes / (1024 * 1024)} MiB size limit."); } + var preflight = await PlanXmlPreflight.ValidateAsync(stream, cancellationToken).ConfigureAwait(false); + using var retainedEstimate = _budget.ReserveRetainedEstimate( + EstimateRetainedAnalysisBytes(stream.Length, preflight)); var planXml = await ReadPlanXmlAsync(stream, cancellationToken).ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); if (string.IsNullOrWhiteSpace(planXml)) throw new InvalidDataException("Plan file is empty."); - var plan = ShowPlanParser.Parse(planXml); - cancellationToken.ThrowIfCancellationRequested(); + var plan = PlanAnalysisPipeline.Analyze( + planXml, + _config, + serverMetadata: null, + cancellationToken, + ValidateComplexity); if (!string.IsNullOrWhiteSpace(plan.ParseError)) throw new InvalidDataException($"Could not parse plan XML: {plan.ParseError}"); if (!plan.Batches.SelectMany(batch => batch.Statements).Any()) throw new InvalidDataException("Could not parse any statements from the plan XML."); - ValidateComplexity(plan, cancellationToken); - PlanAnalyzer.Analyze(plan, _config); - cancellationToken.ThrowIfCancellationRequested(); - BenefitScorer.Score(plan); - cancellationToken.ThrowIfCancellationRequested(); - - var analysis = ResultMapper.Map(plan, label); + var analysis = ResultMapper.MapCancellable(plan, label, metadata: null, cancellationToken); + plan.RawXml = string.Empty; + plan.Batches.Clear(); var baseId = CreateBaseSessionId(Path.GetFileNameWithoutExtension(label)); while (true) { cancellationToken.ThrowIfCancellationRequested(); - var sessionId = $"{baseId}-{Guid.NewGuid():N}"; + var opaqueId = Guid.NewGuid().ToString("N")[..12]; + var sessionId = $"{baseId}-{opaqueId}"; var session = new PlanSession { SessionId = sessionId, Label = label, Source = label, Plan = plan, + Analysis = analysis, StatementCount = analysis.Summary.TotalStatements, HasActualStats = analysis.Summary.HasActualStats, WarningCount = analysis.Summary.TotalWarnings, @@ -143,12 +154,23 @@ private async Task OpenStreamCoreAsync( MissingIndexCount = analysis.Summary.MissingIndexes }; - if (TryRegisterBounded(session)) + if (_budget.TryRegister(session, DefaultMaxSessions)) + { + retainedEstimate.Commit(sessionId); return ToSummary(session); - EnsureSessionCapacity(); + } + _budget.EnsureSessionCapacity(DefaultMaxSessions); } } + private static long EstimateRetainedAnalysisBytes( + long sourceBytes, + PlanXmlPreflightResult preflight) => + checked( + (sourceBytes * 2) + + ((long)preflight.StatementCount * 2 * 1024) + + ((long)preflight.OperatorCount * 4 * 1024)); + private static async Task ReadPlanXmlAsync( FileStream stream, CancellationToken cancellationToken) @@ -177,7 +199,7 @@ private static async Task ReadPlanXmlAsync( public bool Close(string sessionId) { ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); - return _catalog.Unregister(sessionId); + return _budget.TryUnregister(sessionId); } public PlanSummaryResult GetSummary(string sessionId) => @@ -208,22 +230,27 @@ public MissingIndexesResult GetMissingIndexes(string sessionId) => public MissingIndexesResult GetMissingIndexes(PlanSession session) { ArgumentNullException.ThrowIfNull(session); - var indexes = session.Plan.AllMissingIndexes.Select(index => new MissingIndexItem + var sourceIndexes = GetAnalysis(session).Statements + .SelectMany(statement => statement.MissingIndexes) + .ToList(); + var indexes = sourceIndexes.Take(DefaultMaxMissingIndexResults).Select(index => new MissingIndexItem { - Database = index.Database, - SchemaName = index.Schema, - Table = index.Table, + Database = Truncate(index.Database, 512), + SchemaName = Truncate(index.SchemaName, 512), + Table = Truncate(index.BareTable, 512), Impact = index.Impact, - EqualityColumns = index.EqualityColumns, - InequalityColumns = index.InequalityColumns, - IncludeColumns = index.IncludeColumns, - CreateStatement = index.CreateStatement + EqualityColumns = BoundColumns(index.EqualityColumns), + InequalityColumns = BoundColumns(index.InequalityColumns), + IncludeColumns = BoundColumns(index.IncludeColumns), + CreateStatement = Truncate(index.CreateStatement, DefaultMaxResponseTextLength) }).ToList(); return new MissingIndexesResult { SessionId = session.SessionId, - MissingIndexCount = indexes.Count, + MissingIndexCount = sourceIndexes.Count, + ReturnedIndexCount = indexes.Count, + Truncated = indexes.Count < sourceIndexes.Count, Indexes = indexes }; } @@ -308,11 +335,15 @@ public PlanWarningsResult GetWarnings( .ToList(); } + var totalWarningCount = warnings.Count; + var returnedWarnings = warnings.Take(DefaultMaxWarningResults).ToList(); return new PlanWarningsResult { SessionId = session.SessionId, - WarningCount = warnings.Count, - Warnings = warnings + WarningCount = totalWarningCount, + ReturnedWarningCount = returnedWarnings.Count, + Truncated = returnedWarnings.Count < totalWarningCount, + Warnings = returnedWarnings }; } @@ -322,27 +353,7 @@ public AnalysisResult GetAnalysis(string sessionId) => public AnalysisResult GetAnalysis(PlanSession session) { ArgumentNullException.ThrowIfNull(session); - return ResultMapper.Map(session.Plan, session.Source); - } - - private bool TryRegisterBounded(PlanSession session) - { - var gate = CatalogRegistrationGates.GetValue(_catalog, _ => new object()); - lock (gate) - { - if (_catalog.GetAllSessions().Count >= DefaultMaxSessions) - return false; - return _catalog.TryRegister(session); - } - } - - private void EnsureSessionCapacity() - { - if (_catalog.GetAllSessions().Count >= DefaultMaxSessions) - { - throw new InvalidOperationException( - $"The plan session limit of {DefaultMaxSessions} has been reached. Close a session before opening another plan."); - } + return session.Analysis ?? ResultMapper.Map(session.Plan, session.Source); } private static void ValidateComplexity(ParsedPlan plan, CancellationToken cancellationToken) @@ -398,9 +409,14 @@ private static void CollectOperators( string statement, ICollection<(OperatorResult Node, string Statement)> operators) { - operators.Add((node, statement)); - foreach (var child in node.Children) - CollectOperators(child, statement, operators); + var pending = new Stack(); + pending.Push(node); + while (pending.TryPop(out var current)) + { + operators.Add((current, statement)); + for (var index = current.Children.Count - 1; index >= 0; index--) + pending.Push(current.Children[index]); + } } private static void CollectWarnings( @@ -408,22 +424,30 @@ private static void CollectWarnings( string statement, ICollection warnings) { - foreach (var warning in node.Warnings) - warnings.Add(ToWarningItem(warning, statement)); - foreach (var child in node.Children) - CollectWarnings(child, statement, warnings); + var pending = new Stack(); + pending.Push(node); + while (pending.TryPop(out var current)) + { + foreach (var warning in current.Warnings) + warnings.Add(ToWarningItem(warning, statement)); + for (var index = current.Children.Count - 1; index >= 0; index--) + pending.Push(current.Children[index]); + } } private static PlanWarningItem ToWarningItem(WarningResult warning, string statement) => new() { - Severity = warning.Severity, - Type = warning.Type, - Message = warning.Message, + Severity = Truncate(warning.Severity, 32), + Type = Truncate(warning.Type, 256), + Message = Truncate(warning.Message, DefaultMaxResponseTextLength), NodeId = warning.NodeId, - Operator = warning.Operator, + Operator = warning.Operator is null ? null : Truncate(warning.Operator, 512), Statement = statement }; + private static IReadOnlyList BoundColumns(IEnumerable columns) => + columns.Take(64).Select(column => Truncate(column, 512)).ToList(); + private static string Truncate(string value, int maxLength) => value.Length <= maxLength ? value : value[..maxLength] + "... (truncated)"; @@ -434,6 +458,10 @@ private PlanSession GetRequiredSession(string sessionId) => private static string CreateBaseSessionId(string label) { var baseId = Regex.Replace(label.Trim(), "[^A-Za-z0-9._-]+", "-").Trim('-'); + if (baseId.Length == 0) + return "plan"; + if (baseId.Length > 48) + baseId = baseId[..48].TrimEnd('.', '_', '-'); if (baseId.Length == 0) return "plan"; return baseId.Equals("open", StringComparison.OrdinalIgnoreCase) || diff --git a/src/PlanViewer.Core/Services/PlanResourceBudget.cs b/src/PlanViewer.Core/Services/PlanResourceBudget.cs new file mode 100644 index 0000000..a9493d0 --- /dev/null +++ b/src/PlanViewer.Core/Services/PlanResourceBudget.cs @@ -0,0 +1,148 @@ +using System.Runtime.CompilerServices; +using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Models; + +namespace PlanViewer.Core.Services; + +internal sealed class PlanResourceBudget +{ + internal const long DefaultMaxEstimatedRetainedBytes = 64L * 1024 * 1024; + + private static readonly ConditionalWeakTable Budgets = new(); + + private readonly IPlanCatalog _catalog; + private readonly object _syncRoot = new(); + private readonly SemaphoreSlim _openSlots = new(PlanOperations.DefaultMaxConcurrentOpens); + private readonly Dictionary _retainedEstimateBySession = new(StringComparer.OrdinalIgnoreCase); + private long _reservedAndRetainedEstimate; + + private PlanResourceBudget(IPlanCatalog catalog) + { + _catalog = catalog; + } + + internal static PlanResourceBudget ForCatalog(IPlanCatalog catalog) + { + ArgumentNullException.ThrowIfNull(catalog); + return Budgets.GetValue(catalog, static value => new PlanResourceBudget(value)); + } + + internal async Task AcquireOpenSlotAsync(CancellationToken cancellationToken) + { + if (!await _openSlots.WaitAsync(0, cancellationToken).ConfigureAwait(false)) + { + throw new InvalidOperationException( + $"The concurrent plan-open limit of {PlanOperations.DefaultMaxConcurrentOpens} has been reached. Retry after an active open completes."); + } + + return new OpenSlotLease(_openSlots); + } + + internal bool TryRegister(PlanSession session, int maxSessions) + { + lock (_syncRoot) + { + if (_catalog.GetAllSessions().Count >= maxSessions) + { + throw new InvalidOperationException( + $"The plan session limit of {maxSessions} has been reached. Close a session before opening another plan."); + } + + return _catalog.TryRegister(session); + } + } + + internal void EnsureSessionCapacity(int maxSessions) + { + lock (_syncRoot) + { + if (_catalog.GetAllSessions().Count >= maxSessions) + { + throw new InvalidOperationException( + $"The plan session limit of {maxSessions} has been reached. Close a session before opening another plan."); + } + } + } + + internal bool TryUnregister(string sessionId) + { + lock (_syncRoot) + { + if (!_catalog.Unregister(sessionId)) + return false; + if (_retainedEstimateBySession.Remove(sessionId, out var bytes)) + _reservedAndRetainedEstimate -= bytes; + return true; + } + } + + internal RetainedEstimateReservation ReserveRetainedEstimate(long bytes) + { + if (bytes < 0) + throw new ArgumentOutOfRangeException(nameof(bytes)); + + lock (_syncRoot) + { + if (bytes > DefaultMaxEstimatedRetainedBytes - _reservedAndRetainedEstimate) + { + throw new InvalidOperationException( + $"Opening this plan would exceed the aggregate {DefaultMaxEstimatedRetainedBytes / (1024 * 1024)} MiB retained-analysis estimate budget. Close a session before opening another plan."); + } + + _reservedAndRetainedEstimate += bytes; + return new RetainedEstimateReservation(this, bytes); + } + } + + private void Commit(RetainedEstimateReservation reservation, string sessionId) + { + lock (_syncRoot) + { + if (!_retainedEstimateBySession.TryAdd(sessionId, reservation.Bytes)) + throw new InvalidOperationException($"A retained-byte reservation already exists for session {sessionId}."); + reservation.MarkCommitted(); + } + } + + private void ReleaseReservation(long bytes) + { + lock (_syncRoot) + _reservedAndRetainedEstimate -= bytes; + } + + internal sealed class RetainedEstimateReservation : IDisposable + { + private PlanResourceBudget? _owner; + private bool _committed; + + internal RetainedEstimateReservation(PlanResourceBudget owner, long bytes) + { + _owner = owner; + Bytes = bytes; + } + + internal long Bytes { get; } + + internal void Commit(string sessionId) + { + var owner = _owner ?? throw new ObjectDisposedException(nameof(RetainedEstimateReservation)); + owner.Commit(this, sessionId); + } + + internal void MarkCommitted() => _committed = true; + + public void Dispose() + { + var owner = Interlocked.Exchange(ref _owner, null); + if (owner is not null && !_committed) + owner.ReleaseReservation(Bytes); + } + } + + private sealed class OpenSlotLease(SemaphoreSlim semaphore) : IDisposable + { + private SemaphoreSlim? _semaphore = semaphore; + + public void Dispose() => Interlocked.Exchange(ref _semaphore, null)?.Release(); + } +} diff --git a/src/PlanViewer.Core/Services/PlanXmlPreflight.cs b/src/PlanViewer.Core/Services/PlanXmlPreflight.cs new file mode 100644 index 0000000..ae234a0 --- /dev/null +++ b/src/PlanViewer.Core/Services/PlanXmlPreflight.cs @@ -0,0 +1,87 @@ +using System.Xml; + +namespace PlanViewer.Core.Services; + +internal readonly record struct PlanXmlPreflightResult(int StatementCount, int OperatorCount); + +internal static class PlanXmlPreflight +{ + internal const int DefaultMaxXmlDepth = 512; + + internal static async Task ValidateAsync( + FileStream stream, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(stream); + if (!stream.CanRead || !stream.CanSeek) + throw new ArgumentException("The plan stream must be readable and seekable.", nameof(stream)); + + var settings = new XmlReaderSettings + { + Async = true, + CloseInput = false, + DtdProcessing = DtdProcessing.Prohibit, + IgnoreComments = true, + IgnoreProcessingInstructions = true, + IgnoreWhitespace = true, + MaxCharactersInDocument = PlanOperations.DefaultMaxPlanFileBytes, + XmlResolver = null + }; + + var statements = 0; + var operators = 0; + stream.Position = 0; + try + { + using var reader = XmlReader.Create(stream, settings); + while (await reader.ReadAsync().ConfigureAwait(false)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (stream.Length > PlanOperations.DefaultMaxPlanFileBytes || + stream.Position > PlanOperations.DefaultMaxPlanFileBytes) + { + throw new InvalidDataException( + $"Plan file exceeds the {PlanOperations.DefaultMaxPlanFileBytes / (1024 * 1024)} MiB size limit."); + } + if (reader.NodeType != XmlNodeType.Element) + continue; + if (reader.Depth > DefaultMaxXmlDepth) + { + throw new InvalidDataException( + $"Plan XML exceeds the supported depth limit of {DefaultMaxXmlDepth}."); + } + + switch (reader.LocalName) + { + case "StmtSimple": + case "StmtCursor": + statements++; + if (statements > PlanOperations.DefaultMaxStatements) + { + throw new InvalidDataException( + $"Plan exceeds the {PlanOperations.DefaultMaxStatements} statement complexity limit."); + } + break; + case "RelOp": + operators++; + if (operators > PlanOperations.DefaultMaxOperators) + { + throw new InvalidDataException( + $"Plan exceeds the {PlanOperations.DefaultMaxOperators} operator complexity limit."); + } + break; + } + } + } + catch (XmlException exception) + { + throw new InvalidDataException($"Could not parse plan XML: {exception.Message}", exception); + } + finally + { + stream.Position = 0; + } + + return new PlanXmlPreflightResult(statements, operators); + } +} diff --git a/tests/PlanViewer.Core.Tests/PlanAnalysisPipelineTests.cs b/tests/PlanViewer.Core.Tests/PlanAnalysisPipelineTests.cs new file mode 100644 index 0000000..ca33f6d --- /dev/null +++ b/tests/PlanViewer.Core.Tests/PlanAnalysisPipelineTests.cs @@ -0,0 +1,68 @@ +using PlanViewer.Core.Models; +using PlanViewer.Core.Output; +using PlanViewer.Core.Services; + +namespace PlanViewer.Core.Tests; + +public sealed class PlanAnalysisPipelineTests +{ + [Fact] + public void Analyze_RunsTheSharedParseAnalyzeAndScorePipeline() + { + var path = Path.GetFullPath(Path.Combine("Plans", "row_goal_plan.sqlplan")); + var xml = File.ReadAllText(path); + + var plan = PlanAnalysisPipeline.Analyze( + xml, + AnalyzerConfig.Default, + TestContext.Current.CancellationToken); + var result = ResultMapper.Map(plan, Path.GetFileName(path)); + + Assert.Null(plan.ParseError); + Assert.Equal(2, result.Summary.TotalWarnings); + Assert.Contains("Row Goal", result.Summary.WarningTypes); + } + + [Fact] + public void Analyze_ObservesCancellationDuringStatementAnalysis() + { + var plan = new ParsedPlan + { + Batches = + [ + new PlanBatch + { + Statements = Enumerable.Range(0, 20_000) + .Select(index => new PlanStatement + { + StatementId = index, + StatementText = "SELECT * FROM dbo.example WHERE name LIKE '%value'" + }) + .ToList() + } + ] + }; + using var cancellation = new CancellationTokenSource(); + cancellation.CancelAfter(TimeSpan.FromMilliseconds(1)); + + Assert.ThrowsAny(() => + PlanAnalyzer.AnalyzeCancellable(plan, AnalyzerConfig.Default, serverMetadata: null, cancellation.Token)); + } + + [Fact] + public void ResultMapper_DetachesCollectionsFromTheParserGraph() + { + var plan = PlanTestHelper.LoadAndAnalyze("top_above_scan_plan.sqlplan"); + var statement = Assert.Single(plan.Batches.SelectMany(batch => batch.Statements)); + var sourceIndex = Assert.Single(statement.MissingIndexes); + + var analysis = ResultMapper.Map(plan, "top_above_scan_plan.sqlplan"); + var mappedStatement = Assert.Single(analysis.Statements); + var mappedIndex = Assert.Single(mappedStatement.MissingIndexes); + + Assert.NotSame(sourceIndex.EqualityColumns, mappedIndex.EqualityColumns); + Assert.NotSame(sourceIndex.InequalityColumns, mappedIndex.InequalityColumns); + Assert.NotSame(sourceIndex.IncludeColumns, mappedIndex.IncludeColumns); + } + +} diff --git a/tests/PlanViewer.Core.Tests/PlanOperationsTests.cs b/tests/PlanViewer.Core.Tests/PlanOperationsTests.cs index d9a8b40..52046e0 100644 --- a/tests/PlanViewer.Core.Tests/PlanOperationsTests.cs +++ b/tests/PlanViewer.Core.Tests/PlanOperationsTests.cs @@ -13,14 +13,34 @@ public async Task OpenAsync_LoadsAnalyzesAndRegistersAPlanFile() var opened = await operations.OpenAsync(path, TestContext.Current.CancellationToken); - Assert.Matches("^row_goal_plan-[0-9a-f]{32}$", opened.SessionId); + Assert.Matches("^row_goal_plan-[0-9a-f]{12}$", opened.SessionId); Assert.Equal("row_goal_plan.sqlplan", opened.Label); Assert.Equal(1, opened.StatementCount); Assert.Equal(2, opened.WarningCount); var session = catalog.GetSession(opened.SessionId); Assert.NotNull(session); Assert.Equal("row_goal_plan.sqlplan", session.Source); - Assert.NotEmpty(session.Plan.Batches); + Assert.NotNull(session.Analysis); + Assert.Empty(session.Plan.Batches); + } + + [Fact] + public async Task GetAnalysis_UsesTheSnapshotCapturedAtRegistration() + { + var catalog = new InMemoryPlanCatalog(); + var operations = new PlanOperations(catalog); + var path = Path.GetFullPath(Path.Combine("Plans", "row_goal_plan.sqlplan")); + var opened = await operations.OpenAsync(path, TestContext.Current.CancellationToken); + var session = Assert.IsType(catalog.GetSession(opened.SessionId)); + + Assert.Empty(session.Plan.Batches); + var captured = operations.GetAnalysis(session); + session.Plan.Batches.Clear(); + var afterMutation = operations.GetAnalysis(session); + + Assert.Same(captured, afterMutation); + Assert.Equal(1, afterMutation.Summary.TotalStatements); + Assert.Equal(2, afterMutation.Summary.TotalWarnings); } [Fact] @@ -40,6 +60,36 @@ public async Task GetSummary_ReturnsAConciseTypedProjection() Assert.Contains("Row Goal", summary.WarningTypes); } + [Fact] + public async Task GetWarnings_BoundsTheReturnedItemsAndReportsTruncation() + { + var catalog = new InMemoryPlanCatalog(); + var operations = new PlanOperations(catalog); + var path = Path.GetFullPath(Path.Combine("Plans", "row_goal_plan.sqlplan")); + var opened = await operations.OpenAsync(path, TestContext.Current.CancellationToken); + var session = Assert.IsType(catalog.GetSession(opened.SessionId)); + var analysis = Assert.IsType(session.Analysis); + var statement = Assert.Single(analysis.Statements); + statement.Warnings = Enumerable.Range(0, PlanOperations.DefaultMaxWarningResults + 1) + .Select(index => new PlanViewer.Core.Output.WarningResult + { + Type = $"warning-{index}", + Severity = "Warning", + Message = new string('x', PlanOperations.DefaultMaxResponseTextLength + 1) + }) + .ToList(); + statement.OperatorTree = null; + + var result = operations.GetWarnings(session); + + Assert.Equal(PlanOperations.DefaultMaxWarningResults + 1, result.WarningCount); + Assert.Equal(PlanOperations.DefaultMaxWarningResults, result.ReturnedWarningCount); + Assert.Equal(PlanOperations.DefaultMaxWarningResults, result.Warnings.Count); + Assert.True(result.Truncated); + Assert.All(result.Warnings, warning => + Assert.True(warning.Message.Length <= PlanOperations.DefaultMaxResponseTextLength + 17)); + } + [Fact] public async Task GetWarnings_FiltersAllStatementAndOperatorWarningsBySeverity() { @@ -74,6 +124,42 @@ public async Task GetExpensiveOperators_RanksEstimatedPlansByCostAndHonorsTop() } + [Fact] + public async Task GetMissingIndexes_BoundsTheReturnedItemsAndReportsTruncation() + { + var catalog = new InMemoryPlanCatalog(); + var operations = new PlanOperations(catalog); + var path = Path.GetFullPath(Path.Combine("Plans", "row_goal_plan.sqlplan")); + var opened = await operations.OpenAsync(path, TestContext.Current.CancellationToken); + var session = Assert.IsType(catalog.GetSession(opened.SessionId)); + var analysis = Assert.IsType(session.Analysis); + var statement = Assert.Single(analysis.Statements); + statement.MissingIndexes = Enumerable.Range(0, PlanOperations.DefaultMaxMissingIndexResults + 1) + .Select(index => new PlanViewer.Core.Output.MissingIndexResult + { + Database = "database", + SchemaName = "dbo", + BareTable = $"table-{index}", + Table = $"database.dbo.table-{index}", + EqualityColumns = Enumerable.Repeat(new string('c', 600), 100).ToList(), + CreateStatement = new string('x', PlanOperations.DefaultMaxResponseTextLength + 1) + }) + .ToList(); + + var result = operations.GetMissingIndexes(session); + + Assert.Equal(PlanOperations.DefaultMaxMissingIndexResults + 1, result.MissingIndexCount); + Assert.Equal(PlanOperations.DefaultMaxMissingIndexResults, result.ReturnedIndexCount); + Assert.Equal(PlanOperations.DefaultMaxMissingIndexResults, result.Indexes.Count); + Assert.True(result.Truncated); + Assert.All(result.Indexes, index => + { + Assert.True(index.CreateStatement.Length <= PlanOperations.DefaultMaxResponseTextLength + 17); + Assert.True(index.EqualityColumns.Count <= 64); + Assert.All(index.EqualityColumns, column => Assert.True(column.Length <= 512 + 17)); + }); + } + [Fact] public async Task GetMissingIndexes_ReturnsStructuredSuggestions() { @@ -100,11 +186,48 @@ public async Task OpenAsync_UsesUniqueSessionIdsForTheSameFile() var first = await operations.OpenAsync(path, TestContext.Current.CancellationToken); var second = await operations.OpenAsync(path, TestContext.Current.CancellationToken); - Assert.Matches("^row_goal_plan-[0-9a-f]{32}$", first.SessionId); - Assert.Matches("^row_goal_plan-[0-9a-f]{32}$", second.SessionId); + Assert.Matches("^row_goal_plan-[0-9a-f]{12}$", first.SessionId); + Assert.Matches("^row_goal_plan-[0-9a-f]{12}$", second.SessionId); Assert.NotEqual(first.SessionId, second.SessionId); } + [Fact] + public async Task OpenAsync_BoundsTheHumanReadableSessionIdentity() + { + var operations = new PlanOperations(new InMemoryPlanCatalog()); + var source = Path.GetFullPath(Path.Combine("Plans", "row_goal_plan.sqlplan")); + var directory = Directory.CreateTempSubdirectory("plan-long-id-"); + var path = Path.Combine(directory.FullName, $"{new string('a', 180)}.sqlplan"); + try + { + File.Copy(source, path); + + var opened = await operations.OpenAsync(path, TestContext.Current.CancellationToken); + + Assert.True(opened.SessionId.Length <= 61, opened.SessionId); + Assert.Matches("^[a-z]{1,48}-[0-9a-f]{12}$", opened.SessionId); + } + finally + { + directory.Delete(recursive: true); + } + } + + [Fact] + public async Task OpenAsync_UsesPlanFallbackWhenTheTruncatedLabelHasNoReadableCharacters() + { + var operations = new PlanOperations(new InMemoryPlanCatalog()); + var path = Path.GetFullPath(Path.Combine("Plans", "row_goal_plan.sqlplan")); + await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + + var opened = await operations.OpenAsync( + stream, + $"{new string('.', 60)}.sqlplan", + TestContext.Current.CancellationToken); + + Assert.Matches("^plan-[0-9a-f]{12}$", opened.SessionId); + } + [Fact] public async Task OpenAsync_RejectsMissingFiles() { @@ -129,17 +252,22 @@ public async Task FiltersAndTop_RejectInvalidValues() [Fact] - public async Task OpenAsync_AllocatesUniqueIdsConcurrently() + public async Task OpenAsync_AllocatesUniqueIdsAcrossConcurrentAdmissionWaves() { var catalog = new InMemoryPlanCatalog(); var operations = new PlanOperations(catalog); var path = Path.GetFullPath(Path.Combine("Plans", "row_goal_plan.sqlplan")); + var sessionIds = new HashSet(StringComparer.Ordinal); - var opened = await Task.WhenAll( - Enumerable.Range(0, 8) - .Select(_ => operations.OpenAsync(path, TestContext.Current.CancellationToken))); + for (var wave = 0; wave < 4; wave++) + { + var opened = await Task.WhenAll( + operations.OpenAsync(path, TestContext.Current.CancellationToken), + operations.OpenAsync(path, TestContext.Current.CancellationToken)); + Assert.All(opened, result => Assert.True(sessionIds.Add(result.SessionId), result.SessionId)); + } - Assert.Equal(8, opened.Select(result => result.SessionId).Distinct().Count()); + Assert.Equal(8, sessionIds.Count); Assert.Equal(8, catalog.GetAllSessions().Count); } @@ -158,7 +286,7 @@ public async Task OpenAsync_ReservesLiteralCommandNamesInSessionIds(string reser var opened = await operations.OpenAsync(path, TestContext.Current.CancellationToken); - Assert.Matches($"^plan-{reservedName}-[0-9a-f]{{32}}$", opened.SessionId); + Assert.Matches($"^plan-{reservedName}-[0-9a-f]{{12}}$", opened.SessionId); } finally { @@ -313,8 +441,18 @@ public async Task OpenAsync_EnforcesTheSessionBudgetAtomicallyUnderConcurrency() var catalog = new InMemoryPlanCatalog(); var operations = new PlanOperations(catalog); var path = Path.GetFullPath(Path.Combine("Plans", "row_goal_plan.sqlplan")); + for (var index = 0; index < PlanOperations.DefaultMaxSessions - 1; index++) + { + catalog.Register(new PlanViewer.Core.Models.PlanSession + { + SessionId = $"seed-{index}", + Label = $"seed-{index}.sqlplan", + Source = $"seed-{index}.sqlplan", + Plan = new PlanViewer.Core.Models.ParsedPlan() + }); + } - var admitted = await Task.WhenAll(Enumerable.Range(0, 40).Select(async _ => + var admitted = await Task.WhenAll(Enumerable.Range(0, 2).Select(async _ => { try { @@ -327,8 +465,40 @@ public async Task OpenAsync_EnforcesTheSessionBudgetAtomicallyUnderConcurrency() } })); - Assert.Equal(32, admitted.Count(value => value)); - Assert.Equal(32, catalog.GetAllSessions().Count); + Assert.Single(admitted, value => value); + Assert.Equal(PlanOperations.DefaultMaxSessions, catalog.GetAllSessions().Count); + } + + [Fact] + public async Task OpenAsync_BoundsEstimatedRetainedAnalysisMemoryByComplexity() + { + var operations = new PlanOperations(new InMemoryPlanCatalog()); + var path = Path.Combine(Path.GetTempPath(), $"retained-complexity-{Guid.NewGuid():N}.sqlplan"); + try + { + var xml = new System.Text.StringBuilder( + ""); + for (var id = 0; id < PlanOperations.DefaultMaxStatements; id++) + { + xml.Append($""); + } + xml.Append(""); + await File.WriteAllTextAsync(path, xml.ToString(), TestContext.Current.CancellationToken); + + var first = await operations.OpenAsync(path, TestContext.Current.CancellationToken); + await operations.OpenAsync(path, TestContext.Current.CancellationToken); + await operations.OpenAsync(path, TestContext.Current.CancellationToken); + var exception = await Assert.ThrowsAsync( + () => operations.OpenAsync(path, TestContext.Current.CancellationToken)); + + Assert.Contains("retained-analysis", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.True(operations.Close(first.SessionId)); + await operations.OpenAsync(path, TestContext.Current.CancellationToken); + } + finally + { + File.Delete(path); + } } [Fact] diff --git a/tests/PlanViewer.Core.Tests/PlanResourceBudgetTests.cs b/tests/PlanViewer.Core.Tests/PlanResourceBudgetTests.cs new file mode 100644 index 0000000..a04f850 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/PlanResourceBudgetTests.cs @@ -0,0 +1,111 @@ +using PlanViewer.Core.Services; + +namespace PlanViewer.Core.Tests; + +public sealed class PlanResourceBudgetTests +{ + [Fact] + public async Task ForCatalog_SharesConcurrentOpenSlotsAcrossFacades() + { + var catalog = new InMemoryPlanCatalog(); + var first = PlanResourceBudget.ForCatalog(catalog); + var second = PlanResourceBudget.ForCatalog(catalog); + + Assert.Same(first, second); + using var firstLease = await first.AcquireOpenSlotAsync(TestContext.Current.CancellationToken); + using var secondLease = await second.AcquireOpenSlotAsync(TestContext.Current.CancellationToken); + + var error = await Assert.ThrowsAsync( + async () => await first.AcquireOpenSlotAsync(TestContext.Current.CancellationToken)); + Assert.Contains("concurrent plan-open limit", error.Message, StringComparison.Ordinal); + } + + + [Fact] + public void TryRegister_ReportsCapacityInsteadOfMasqueradingItAsAnIdCollision() + { + var catalog = new InMemoryPlanCatalog(); + var budget = PlanResourceBudget.ForCatalog(catalog); + Assert.True(catalog.TryRegister(CreateSession("first"))); + + var error = Assert.Throws( + () => budget.TryRegister(CreateSession("second"), maxSessions: 1)); + + Assert.Contains("session limit of 1", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void ReserveRetainedEstimate_EnforcesAndReleasesTheAggregateBudget() + { + var budget = PlanResourceBudget.ForCatalog(new InMemoryPlanCatalog()); + using var first = budget.ReserveRetainedEstimate(PlanResourceBudget.DefaultMaxEstimatedRetainedBytes); + + Assert.Throws(() => budget.ReserveRetainedEstimate(1)); + + first.Dispose(); + using var afterRelease = budget.ReserveRetainedEstimate(PlanResourceBudget.DefaultMaxEstimatedRetainedBytes); + } + + [Fact] + public async Task OpenOwnedStreamAsync_AcquiresTheSharedSlotBeforeInvokingTheFactory() + { + var operations = new PlanOperations(new InMemoryPlanCatalog(), PlanViewer.Core.Models.AnalyzerConfig.Default); + var releaseFactories = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var twoFactoriesStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var activeFactories = 0; + var maxActiveFactories = 0; + var path = Path.GetFullPath(Path.Combine("Plans", "row_goal_plan.sqlplan")); + + async ValueTask<(FileStream Stream, string Label, IAsyncDisposable Owner)> OpenAsync( + CancellationToken cancellationToken) + { + var active = Interlocked.Increment(ref activeFactories); + UpdateMaximum(ref maxActiveFactories, active); + if (active == PlanOperations.DefaultMaxConcurrentOpens) + twoFactoriesStarted.TrySetResult(); + + await releaseFactories.Task.WaitAsync(cancellationToken); + Interlocked.Decrement(ref activeFactories); + var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + return (stream, Path.GetFileName(path), stream); + } + + var opens = Enumerable.Range(0, PlanOperations.DefaultMaxConcurrentOpens + 1) + .Select(_ => operations.OpenOwnedStreamAsync(OpenAsync, TestContext.Current.CancellationToken)) + .ToArray(); + + await twoFactoriesStarted.Task.WaitAsync(TestContext.Current.CancellationToken); + Assert.Equal(PlanOperations.DefaultMaxConcurrentOpens, Volatile.Read(ref maxActiveFactories)); + Assert.Equal(PlanOperations.DefaultMaxConcurrentOpens, Volatile.Read(ref activeFactories)); + + Assert.True(opens[^1].IsFaulted); + releaseFactories.TrySetResult(); + await Task.WhenAll(opens[..PlanOperations.DefaultMaxConcurrentOpens]) + .WaitAsync(TestContext.Current.CancellationToken); + var error = await Assert.ThrowsAsync(async () => await opens[^1]); + Assert.Contains("concurrent plan-open limit", error.Message, StringComparison.Ordinal); + Assert.Equal(PlanOperations.DefaultMaxConcurrentOpens, maxActiveFactories); + } + + private static void UpdateMaximum(ref int maximum, int candidate) + { + var current = Volatile.Read(ref maximum); + while (candidate > current) + { + var observed = Interlocked.CompareExchange(ref maximum, candidate, current); + if (observed == current) + return; + current = observed; + } + } + + + private static PlanViewer.Core.Models.PlanSession CreateSession(string id) => new() + { + SessionId = id, + Label = $"{id}.sqlplan", + Source = $"{id}.sqlplan", + Plan = new PlanViewer.Core.Models.ParsedPlan() + }; + +} diff --git a/tests/PlanViewer.Core.Tests/PlanXmlPreflightTests.cs b/tests/PlanViewer.Core.Tests/PlanXmlPreflightTests.cs new file mode 100644 index 0000000..8529ad4 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/PlanXmlPreflightTests.cs @@ -0,0 +1,74 @@ +using System.Text; +using PlanViewer.Core.Services; + +namespace PlanViewer.Core.Tests; + +public sealed class PlanXmlPreflightTests +{ + [Fact] + public async Task ValidateAsync_RejectsStatementBudgetBeforeMaterialization() + { + var path = Path.Combine(Path.GetTempPath(), $"preflight-statements-{Guid.NewGuid():N}.sqlplan"); + try + { + var xml = new StringBuilder( + ""); + for (var id = 0; id <= PlanOperations.DefaultMaxStatements; id++) + xml.Append($""); + xml.Append(""); + await File.WriteAllTextAsync(path, xml.ToString(), TestContext.Current.CancellationToken); + + await using var stream = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + 64 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + + var exception = await Assert.ThrowsAsync( + () => PlanXmlPreflight.ValidateAsync(stream, TestContext.Current.CancellationToken)); + + Assert.Contains("statement complexity", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, stream.Position); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public async Task ValidateAsync_RejectsExcessiveXmlDepthAndRewindsTheStream() + { + var path = Path.Combine(Path.GetTempPath(), $"preflight-depth-{Guid.NewGuid():N}.sqlplan"); + try + { + var xml = new StringBuilder(""); + for (var depth = 0; depth <= PlanXmlPreflight.DefaultMaxXmlDepth; depth++) + xml.Append(""); + for (var depth = 0; depth <= PlanXmlPreflight.DefaultMaxXmlDepth; depth++) + xml.Append(""); + xml.Append(""); + await File.WriteAllTextAsync(path, xml.ToString(), TestContext.Current.CancellationToken); + + await using var stream = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + 64 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + + var exception = await Assert.ThrowsAsync( + () => PlanXmlPreflight.ValidateAsync(stream, TestContext.Current.CancellationToken)); + + Assert.Contains("depth", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, stream.Position); + } + finally + { + File.Delete(path); + } + } +} From 5c847698bb80141f24321b17ebd367db6ad1f05a Mon Sep 17 00:00:00 2001 From: autocarl Date: Wed, 15 Jul 2026 22:53:47 -0400 Subject: [PATCH 05/16] Preserve CLI App and MCP compatibility --- .../Controls/PlanViewerControl.axaml.cs | 4 +- src/PlanViewer.App/Mcp/McpPlanTools.cs | 25 ++++ src/PlanViewer.App/Mcp/McpQueryStoreTools.cs | 11 +- src/PlanViewer.App/Mcp/PlanSessionManager.cs | 132 ++++++++++++++++-- src/PlanViewer.Cli/CliRouting.cs | 2 +- .../ReplSurface/PlanReplModule.cs | 18 ++- .../PlanViewer.Core.Tests/CliRoutingTests.cs | 4 +- .../HistoricalCliContractTests.cs | 78 +++++++++++ .../McpPlanToolsContractTests.cs | 42 +++++- tests/PlanViewer.Core.Tests/McpSmokeTests.cs | 2 +- .../PublicApiCompatibilityTests.cs | 57 +++++++- 11 files changed, 333 insertions(+), 42 deletions(-) create mode 100644 tests/PlanViewer.Core.Tests/HistoricalCliContractTests.cs diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs index befd5ea..da4b574 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs @@ -374,12 +374,14 @@ public bool LoadPlan(string planXml, string label, string? queryText = null) CountNodeWarnings(s.RootNode, ref warningCount, ref criticalCount); } - PlanSessionManager.Instance.Register(_mcpSessionId, new PlanSession + var analysisSnapshot = ResultMapper.Map(_currentPlan, label); + PlanSessionManager.Instance.Register(_mcpSessionId, new PlanViewer.App.Mcp.PlanSession { SessionId = _mcpSessionId, Label = label, Source = "file", Plan = _currentPlan, + Analysis = analysisSnapshot, QueryText = queryText, StatementCount = allStatements.Count, HasActualStats = allStatements.Any(s => s.QueryTimeStats != null), diff --git a/src/PlanViewer.App/Mcp/McpPlanTools.cs b/src/PlanViewer.App/Mcp/McpPlanTools.cs index bb97c0e..e243bdd 100644 --- a/src/PlanViewer.App/Mcp/McpPlanTools.cs +++ b/src/PlanViewer.App/Mcp/McpPlanTools.cs @@ -16,6 +16,31 @@ namespace PlanViewer.App.Mcp; [McpServerToolType] public sealed class McpPlanTools { + // Historical overloads remain callable; MCP discovery uses the attributed overloads below. + public static string AnalyzePlan(PlanSessionManager sessionManager, string session_id) => + AnalyzePlan(sessionManager, CreateOperations(sessionManager), session_id); + + public static string GetPlanSummary(PlanSessionManager sessionManager, string session_id) => + GetPlanSummary(sessionManager, CreateOperations(sessionManager), session_id); + + public static string GetPlanWarnings( + PlanSessionManager sessionManager, + string session_id, + string? severity = null) => + GetPlanWarnings(sessionManager, CreateOperations(sessionManager), session_id, severity); + + public static string GetMissingIndexes(PlanSessionManager sessionManager, string session_id) => + GetMissingIndexes(sessionManager, CreateOperations(sessionManager), session_id); + + public static string GetExpensiveOperators( + PlanSessionManager sessionManager, + string session_id, + int top = 10) => + GetExpensiveOperators(sessionManager, CreateOperations(sessionManager), session_id, top); + + private static PlanOperations CreateOperations(PlanSessionManager sessionManager) => + new(sessionManager, AnalyzerConfig.Default); + [McpServerTool(Name = "list_plans")] [Description("Lists all execution plans currently loaded in the application. Returns session IDs, labels, " + "statement counts, warning counts, and source type. Use this first to discover available plans.")] diff --git a/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs b/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs index 015b839..731a1be 100644 --- a/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs +++ b/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs @@ -7,6 +7,7 @@ using PlanViewer.App.Services; using PlanViewer.Core.Interfaces; using PlanViewer.Core.Models; +using PlanViewer.Core.Output; using PlanViewer.Core.Services; #pragma warning disable CA1707 // Identifiers should not contain underscores (MCP snake_case convention) @@ -151,10 +152,11 @@ public static async Task GetQueryStoreTop( { var xml = qsPlan.PlanXml .Replace("encoding=\"utf-16\"", "encoding=\"utf-8\""); - var parsed = ShowPlanParser.Parse(xml); - PlanAnalyzer.Analyze(parsed, serverMetadata: serverMetadata); - BenefitScorer.Score(parsed); - + var parsed = PlanAnalysisPipeline.Analyze( + xml, + AnalyzerConfig.Default, + serverMetadata); + var analysis = ResultMapper.Map(parsed, label); var allStatements = parsed.Batches.SelectMany(b => b.Statements).ToList(); sessionManager.Register(sessionId, new PlanSession @@ -163,6 +165,7 @@ public static async Task GetQueryStoreTop( Label = label, Source = "query-store", Plan = parsed, + Analysis = analysis, QueryText = qsPlan.QueryText, ConnectionInfo = conn.ServerName, StatementCount = allStatements.Count, diff --git a/src/PlanViewer.App/Mcp/PlanSessionManager.cs b/src/PlanViewer.App/Mcp/PlanSessionManager.cs index 7eacce5..6e4e2cf 100644 --- a/src/PlanViewer.App/Mcp/PlanSessionManager.cs +++ b/src/PlanViewer.App/Mcp/PlanSessionManager.cs @@ -1,35 +1,137 @@ -using System; +using System.Collections.Concurrent; using System.Collections.Generic; +using System.Linq; using PlanViewer.Core.Interfaces; using PlanViewer.Core.Models; -using PlanViewer.Core.Services; +using PlanViewer.Core.Output; +using CorePlanSession = PlanViewer.Core.Models.PlanSession; +using CorePlanSessionSummary = PlanViewer.Core.Models.PlanSessionSummary; namespace PlanViewer.App.Mcp; /// -/// Application singleton backed by the same catalog abstraction used by Repl. +/// Application singleton that preserves the historical App API while implementing the +/// shared Core catalog contract. /// public sealed class PlanSessionManager : IPlanCatalog { public static PlanSessionManager Instance { get; } = new(); - private readonly InMemoryPlanCatalog _catalog = new(); + private readonly ConcurrentDictionary _sessions = new(); - public void Register(PlanSession session) => _catalog.Register(session); + public void Register(CorePlanSession session) => + _sessions[session.SessionId] = PlanSession.FromCore(session); - public bool TryRegister(PlanSession session) => _catalog.TryRegister(session); + public bool TryRegister(CorePlanSession session) => + _sessions.TryAdd(session.SessionId, PlanSession.FromCore(session)); - // Compatibility overload for existing UI and Query Store callers during the pilot. - public void Register(string sessionId, PlanSession session) + public void Register(string sessionId, PlanSession session) => + _sessions[sessionId] = session; + + public void Unregister(string sessionId) => + _sessions.TryRemove(sessionId, out _); + + public PlanSession? GetSession(string sessionId) => + _sessions.TryGetValue(sessionId, out var session) ? session : null; + + public IReadOnlyList GetAllSessions() => + _sessions.Values.Select(PlanSessionSummary.FromApp).ToList(); + + bool IPlanCatalog.Unregister(string sessionId) => _sessions.TryRemove(sessionId, out _); + + CorePlanSession? IPlanCatalog.GetSession(string sessionId) => + _sessions.TryGetValue(sessionId, out var session) ? session.ToCore() : null; + + IReadOnlyList IPlanCatalog.GetAllSessions() => + _sessions.Values.Select(session => new CorePlanSessionSummary + { + SessionId = session.SessionId, + Label = session.Label, + Source = session.Source, + StatementCount = session.StatementCount, + WarningCount = session.WarningCount, + CriticalWarningCount = session.CriticalWarningCount, + MissingIndexCount = session.MissingIndexCount, + HasActualStats = session.HasActualStats + }).ToList(); +} + +/// +/// Historical App-facing plan-session model retained for source and binary compatibility. +/// +public sealed class PlanSession +{ + public required string SessionId { get; init; } + public required string Label { get; init; } + public required string Source { get; init; } + public required ParsedPlan Plan { get; init; } + public AnalysisResult? Analysis { get; init; } + public string? QueryText { get; init; } + public string? ConnectionInfo { get; init; } + public int StatementCount { get; init; } + public bool HasActualStats { get; init; } + public int WarningCount { get; init; } + public int CriticalWarningCount { get; init; } + public int MissingIndexCount { get; init; } + + internal CorePlanSession ToCore(string? sessionId = null) => new() { - if (!sessionId.Equals(session.SessionId, StringComparison.OrdinalIgnoreCase)) - throw new ArgumentException("The registration key must match the session ID.", nameof(sessionId)); - _catalog.Register(session); - } + SessionId = sessionId ?? SessionId, + Label = Label, + Source = Source, + Plan = Plan, + Analysis = Analysis, + QueryText = QueryText, + ConnectionInfo = ConnectionInfo, + StatementCount = StatementCount, + HasActualStats = HasActualStats, + WarningCount = WarningCount, + CriticalWarningCount = CriticalWarningCount, + MissingIndexCount = MissingIndexCount + }; - public bool Unregister(string sessionId) => _catalog.Unregister(sessionId); + internal static PlanSession FromCore(CorePlanSession session) => new() + { + SessionId = session.SessionId, + Label = session.Label, + Source = session.Source, + Plan = session.Plan, + Analysis = session.Analysis, + QueryText = session.QueryText, + ConnectionInfo = session.ConnectionInfo, + StatementCount = session.StatementCount, + HasActualStats = session.HasActualStats, + WarningCount = session.WarningCount, + CriticalWarningCount = session.CriticalWarningCount, + MissingIndexCount = session.MissingIndexCount + }; - public PlanSession? GetSession(string sessionId) => _catalog.GetSession(sessionId); + public static implicit operator CorePlanSession(PlanSession session) => session.ToCore(); +} - public IReadOnlyList GetAllSessions() => _catalog.GetAllSessions(); +/// +/// Historical mutable App summary DTO retained for compatibility. +/// +public sealed class PlanSessionSummary +{ + public string SessionId { get; set; } = ""; + public string Label { get; set; } = ""; + public string Source { get; set; } = ""; + public int StatementCount { get; set; } + public int WarningCount { get; set; } + public int CriticalWarningCount { get; set; } + public int MissingIndexCount { get; set; } + public bool HasActualStats { get; set; } + + internal static PlanSessionSummary FromApp(PlanSession session) => new() + { + SessionId = session.SessionId, + Label = session.Label, + Source = session.Source, + StatementCount = session.StatementCount, + WarningCount = session.WarningCount, + CriticalWarningCount = session.CriticalWarningCount, + MissingIndexCount = session.MissingIndexCount, + HasActualStats = session.HasActualStats + }; } diff --git a/src/PlanViewer.Cli/CliRouting.cs b/src/PlanViewer.Cli/CliRouting.cs index 72da2f5..8a68555 100644 --- a/src/PlanViewer.Cli/CliRouting.cs +++ b/src/PlanViewer.Cli/CliRouting.cs @@ -6,7 +6,7 @@ public static class CliRouting new(StringComparer.OrdinalIgnoreCase) { "plan", "open", "mcp", "repl" }; public static bool ShouldUseRepl(IReadOnlyList args) => - args.Count == 0 || ReplCommands.Contains(args[0]); + args.Count > 0 && ReplCommands.Contains(args[0]); public static string[] GetReplArgs(IReadOnlyList args) => args.Count > 0 && args[0].Equals("repl", StringComparison.OrdinalIgnoreCase) diff --git a/src/PlanViewer.Cli/ReplSurface/PlanReplModule.cs b/src/PlanViewer.Cli/ReplSurface/PlanReplModule.cs index dc39423..e051a58 100644 --- a/src/PlanViewer.Cli/ReplSurface/PlanReplModule.cs +++ b/src/PlanViewer.Cli/ReplSurface/PlanReplModule.cs @@ -86,12 +86,15 @@ private async Task OpenAsync( } else { - await using var authorized = await McpPlanPathPolicy - .OpenAsync(path, roots, cancellationToken) - .ConfigureAwait(false); - opened = await operations - .OpenAsync(authorized.Stream, authorized.Label, cancellationToken) - .ConfigureAwait(false); + opened = await operations.OpenOwnedStreamAsync( + async token => + { + var authorized = await McpPlanPathPolicy + .OpenAsync(path, roots, token) + .ConfigureAwait(false); + return (authorized.Stream, authorized.Label, (IAsyncDisposable)authorized); + }, + cancellationToken).ConfigureAwait(false); } return Results.NavigateTo($"plan {opened.SessionId}", opened); } @@ -136,10 +139,11 @@ private object Close(string id) } private static object Execute(Func operation) + where T : notnull { try { - return operation()!; + return operation(); } catch (ArgumentException ex) { diff --git a/tests/PlanViewer.Core.Tests/CliRoutingTests.cs b/tests/PlanViewer.Core.Tests/CliRoutingTests.cs index ba8dcc4..c866b6e 100644 --- a/tests/PlanViewer.Core.Tests/CliRoutingTests.cs +++ b/tests/PlanViewer.Core.Tests/CliRoutingTests.cs @@ -21,8 +21,8 @@ public void ReplCommands_UseTheReplGraph(string command) => Assert.True(CliRouting.ShouldUseRepl([command])); [Fact] - public void NoArguments_StartsInteractiveMode() => - Assert.True(CliRouting.ShouldUseRepl([])); + public void NoArguments_PreserveLegacyRouting() => + Assert.False(CliRouting.ShouldUseRepl([])); [Fact] public void ReplAlias_IsRemovedBeforeDispatch() => diff --git a/tests/PlanViewer.Core.Tests/HistoricalCliContractTests.cs b/tests/PlanViewer.Core.Tests/HistoricalCliContractTests.cs new file mode 100644 index 0000000..3a44da9 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/HistoricalCliContractTests.cs @@ -0,0 +1,78 @@ +using System.Diagnostics; +using System.Security.Cryptography; + +namespace PlanViewer.Core.Tests; + +public sealed class HistoricalCliContractTests +{ + private const string ExpectedCompactOutputSha256 = + "0c609fed8e250d9366eb9a6cd5eaf40b661ee30d7ba2546bd7726960592e9d87"; + + [Fact] + public async Task AnalyzeCompact_PreservesHistoricalOutputBytes() + { + var solutionRoot = FindSolutionRoot(); + var configuration = new DirectoryInfo(AppContext.BaseDirectory).Parent?.Name ?? "Release"; + var cliAssembly = Path.Combine( + solutionRoot, + "src", + "PlanViewer.Cli", + "bin", + configuration, + "net10.0", + "planview.dll"); + Assert.True(File.Exists(cliAssembly), $"CLI assembly not found: {cliAssembly}"); + + var startInfo = new ProcessStartInfo("dotnet") + { + WorkingDirectory = solutionRoot, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + startInfo.ArgumentList.Add(cliAssembly); + startInfo.ArgumentList.Add("analyze"); + startInfo.ArgumentList.Add("tests/PlanViewer.Core.Tests/Plans/row_goal_plan.sqlplan"); + startInfo.ArgumentList.Add("--compact"); + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource( + TestContext.Current.CancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(30)); + using var process = Process.Start(startInfo); + Assert.NotNull(process); + try + { + await using var stdout = new MemoryStream(); + var copyOutput = process.StandardOutput.BaseStream.CopyToAsync(stdout, timeout.Token); + var readError = process.StandardError.ReadToEndAsync(timeout.Token); + + await process.WaitForExitAsync(timeout.Token); + await copyOutput; + var standardError = await readError; + + Assert.True( + process.ExitCode == 0, + $"Historical analyze command exited with {process.ExitCode}: {standardError}"); + var actualHash = Convert.ToHexString(SHA256.HashData(stdout.ToArray())).ToLowerInvariant(); + Assert.Equal(ExpectedCompactOutputSha256, actualHash); + } + finally + { + if (!process.HasExited) + process.Kill(entireProcessTree: true); + } + } + + private static string FindSolutionRoot() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + if (File.Exists(Path.Combine(current.FullName, "PlanViewer.sln"))) + return current.FullName; + current = current.Parent; + } + + throw new DirectoryNotFoundException("Could not locate the solution root."); + } +} diff --git a/tests/PlanViewer.Core.Tests/McpPlanToolsContractTests.cs b/tests/PlanViewer.Core.Tests/McpPlanToolsContractTests.cs index b9cfaa1..e8d192d 100644 --- a/tests/PlanViewer.Core.Tests/McpPlanToolsContractTests.cs +++ b/tests/PlanViewer.Core.Tests/McpPlanToolsContractTests.cs @@ -1,7 +1,9 @@ using System.Text.Json; using PlanViewer.App.Mcp; using PlanViewer.Core.Interfaces; -using PlanViewer.Core.Models; +using AnalyzerConfig = PlanViewer.Core.Models.AnalyzerConfig; +using CorePlanSession = PlanViewer.Core.Models.PlanSession; +using CorePlanSessionSummary = PlanViewer.Core.Models.PlanSessionSummary; using PlanViewer.Core.Output; using PlanViewer.Core.Services; @@ -75,11 +77,37 @@ public void GetExpensiveOperators_UsesTheSessionSnapshotAfterLookup() Assert.Equal(0, item.GetProperty("actual_elapsed_ms").GetInt64()); } - private static PlanSession CreateEstimatedSession() + + [Fact] + public void HistoricalOverloads_DelegateToTheSharedOperationsBehavior() + { + var manager = new PlanSessionManager(); + var session = CreateEstimatedSession(); + manager.Register(session); + var operations = new PlanOperations(manager, AnalyzerConfig.Default); + + Assert.Equal( + McpPlanTools.AnalyzePlan(manager, operations, session.SessionId), + McpPlanTools.AnalyzePlan(manager, session.SessionId)); + Assert.Equal( + McpPlanTools.GetPlanSummary(manager, operations, session.SessionId), + McpPlanTools.GetPlanSummary(manager, session.SessionId)); + Assert.Equal( + McpPlanTools.GetPlanWarnings(manager, operations, session.SessionId, "Warning"), + McpPlanTools.GetPlanWarnings(manager, session.SessionId, "Warning")); + Assert.Equal( + McpPlanTools.GetMissingIndexes(manager, operations, session.SessionId), + McpPlanTools.GetMissingIndexes(manager, session.SessionId)); + Assert.Equal( + McpPlanTools.GetExpensiveOperators(manager, operations, session.SessionId, 3), + McpPlanTools.GetExpensiveOperators(manager, session.SessionId, 3)); + } + + private static CorePlanSession CreateEstimatedSession() { var plan = PlanTestHelper.LoadAndAnalyze("row_goal_plan.sqlplan"); var analysis = ResultMapper.Map(plan, "row_goal_plan.sqlplan"); - return new PlanSession + return new CorePlanSession { SessionId = $"contract-{Guid.NewGuid():N}", Label = "row_goal_plan.sqlplan", @@ -95,10 +123,10 @@ private static PlanSession CreateEstimatedSession() private sealed class ThrowingCatalog : IPlanCatalog { - public void Register(PlanSession session) => throw new InvalidOperationException(); - public bool TryRegister(PlanSession session) => throw new InvalidOperationException(); + public void Register(CorePlanSession session) => throw new InvalidOperationException(); + public bool TryRegister(CorePlanSession session) => throw new InvalidOperationException(); public bool Unregister(string sessionId) => throw new InvalidOperationException(); - public PlanSession? GetSession(string sessionId) => throw new InvalidOperationException("Catalog was queried twice."); - public IReadOnlyList GetAllSessions() => throw new InvalidOperationException(); + public CorePlanSession? GetSession(string sessionId) => throw new InvalidOperationException("Catalog was queried twice."); + public IReadOnlyList GetAllSessions() => throw new InvalidOperationException(); } } diff --git a/tests/PlanViewer.Core.Tests/McpSmokeTests.cs b/tests/PlanViewer.Core.Tests/McpSmokeTests.cs index 23dede1..8f55870 100644 --- a/tests/PlanViewer.Core.Tests/McpSmokeTests.cs +++ b/tests/PlanViewer.Core.Tests/McpSmokeTests.cs @@ -113,7 +113,7 @@ public async Task GeneratedMcpServer_ExposesOnlyBoundedPlanToolsOverStdio() opened.Content.OfType().Select(block => block.Text)); var id = System.Text.RegularExpressions.Regex.Match( openedText, - "top_above_scan_plan-[0-9a-f]{32}", + "top_above_scan_plan-[0-9a-f]{12}", System.Text.RegularExpressions.RegexOptions.IgnoreCase).Value; Assert.NotEmpty(id); var summary = await client.CallToolAsync( diff --git a/tests/PlanViewer.Core.Tests/PublicApiCompatibilityTests.cs b/tests/PlanViewer.Core.Tests/PublicApiCompatibilityTests.cs index 73377fd..3f13119 100644 --- a/tests/PlanViewer.Core.Tests/PublicApiCompatibilityTests.cs +++ b/tests/PlanViewer.Core.Tests/PublicApiCompatibilityTests.cs @@ -1,6 +1,7 @@ using PlanViewer.App.Mcp; using PlanViewer.Core.Interfaces; using PlanViewer.Core.Models; +using CorePlanSession = PlanViewer.Core.Models.PlanSession; using PlanViewer.Core.Services; namespace PlanViewer.Core.Tests; @@ -12,10 +13,10 @@ public void PlanCatalog_PreservesTheOriginalTryRegisterSignature() { Assert.NotNull(typeof(IPlanCatalog).GetMethod( nameof(IPlanCatalog.TryRegister), - [typeof(PlanSession)])); + [typeof(CorePlanSession)])); Assert.NotNull(typeof(PlanSessionManager).GetMethod( nameof(PlanSessionManager.TryRegister), - [typeof(PlanSession)])); + [typeof(CorePlanSession)])); } [Fact] @@ -28,8 +29,56 @@ public void PlanOperations_PreservesTheOriginalWarningAndOperatorSignatures() nameof(PlanOperations.GetExpensiveOperators), [typeof(string), typeof(int)])); } + [Fact] + public void AppMcp_PreservesHistoricalSessionTypesAndManagerSignatures() + { + var appAssembly = typeof(PlanSessionManager).Assembly; + var sessionType = appAssembly.GetType("PlanViewer.App.Mcp.PlanSession"); + var summaryType = appAssembly.GetType("PlanViewer.App.Mcp.PlanSessionSummary"); + Assert.NotNull(sessionType); + Assert.NotNull(summaryType); + + var unregister = typeof(PlanSessionManager).GetMethod("Unregister", [typeof(string)]); + var getSession = typeof(PlanSessionManager).GetMethod("GetSession", [typeof(string)]); + var getAllSessions = typeof(PlanSessionManager).GetMethod("GetAllSessions", Type.EmptyTypes); + Assert.NotNull(unregister); + Assert.NotNull(getSession); + Assert.NotNull(getAllSessions); + Assert.Equal(typeof(void), unregister.ReturnType); + Assert.Equal(sessionType, getSession.ReturnType); + Assert.Equal(typeof(IReadOnlyList<>).MakeGenericType(summaryType), getAllSessions.ReturnType); + Assert.NotNull(typeof(PlanSessionManager).GetMethod("Register", [typeof(string), sessionType])); + } + + [Fact] + public void AppMcp_PreservesHistoricalPlanToolOverloads() + { + var manager = typeof(PlanSessionManager); + Assert.NotNull(typeof(McpPlanTools).GetMethod("AnalyzePlan", [manager, typeof(string)])); + Assert.NotNull(typeof(McpPlanTools).GetMethod("GetPlanSummary", [manager, typeof(string)])); + Assert.NotNull(typeof(McpPlanTools).GetMethod("GetPlanWarnings", [manager, typeof(string), typeof(string)])); + Assert.NotNull(typeof(McpPlanTools).GetMethod("GetMissingIndexes", [manager, typeof(string)])); + Assert.NotNull(typeof(McpPlanTools).GetMethod("GetExpensiveOperators", [manager, typeof(string), typeof(int)])); + } + [Fact] - public void InMemoryPlanCatalog_RemainsUnsealed() => - Assert.False(typeof(InMemoryPlanCatalog).IsSealed); + public void AppMcp_ManagerPreservesHistoricalRegistrationKeySemantics() + { + var manager = new PlanSessionManager(); + var session = new PlanViewer.App.Mcp.PlanSession + { + SessionId = "payload-id", + Label = "compat.sqlplan", + Source = "compat.sqlplan", + Plan = new ParsedPlan() + }; + + manager.Register("catalog-key", session); + + Assert.Same(session, manager.GetSession("catalog-key")); + Assert.Null(manager.GetSession("payload-id")); + Assert.Equal("payload-id", Assert.Single(manager.GetAllSessions()).SessionId); + } + } From 3de452096dc55c0d910cc3fc30c000fc41b8b912 Mon Sep 17 00:00:00 2001 From: autocarl Date: Wed, 15 Jul 2026 22:53:53 -0400 Subject: [PATCH 06/16] Document the bounded REPL pilot --- README.md | 8 ++++---- docs/repl-pilot.md | 19 +++++++++++-------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index a4a6bab..67ddc24 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ Side-by-side comparison of two plans showing cost, runtime, I/O, memory, and wai ### Query Store Integration Fetch top queries by CPU, duration, logical reads, physical reads, writes, memory, or executions from Query Store and load their plans directly into the analyzer. -![Query Store Integration](screenshots/performance_studio_query-store_analysis_top_cpu_by_query_hash.png) +![Query Store Integration](screenshots/performance_studio_querystore_analysis_top_cpu_by_query_hash.png) ### Minimap and colored links by accuracy ratio divergence The minimap provides a high-level overview of the entire plan, allowing you to quickly navigate to areas of interest. Colored links between operators indicate accuracy ratio divergence, helping you identify where estimates are most off from actuals. @@ -151,12 +151,12 @@ planview analyze my_query.sqlplan --output text --warnings-only ### Explore plans interactively (Repl pilot) -Running `planview` without arguments starts a long-lived plan-exploration session. Existing -`analyze`, `query-store`, and `credential` commands continue to use the legacy +Run `planview repl` to start a long-lived plan-exploration session. Zero arguments and the +existing `analyze`, `query-store`, and `credential` commands continue to use the legacy System.CommandLine graph. ```text -planview +planview repl > open slow-query.sqlplan [plan slow-query-]> summary [plan slow-query-]> warnings --severity critical diff --git a/docs/repl-pilot.md b/docs/repl-pilot.md index 9c057d8..82d9592 100644 --- a/docs/repl-pilot.md +++ b/docs/repl-pilot.md @@ -8,10 +8,10 @@ Read-only describes the analyzed plans and external systems: the pilot never wri ## Architecture -1. Put immutable plan-session models plus `IPlanCatalog` and its thread-safe in-memory implementation in `PlanViewer.Core`. -2. Put typed, renderer-free operations (`open`, `close`, `list`, `summary`, `warnings`, `missing indexes`, `expensive operators`) in `PlanViewer.Core`. +1. Put detached analysis snapshots plus `IPlanCatalog` and its thread-safe in-memory implementation in `PlanViewer.Core`. REPL sessions retain the analyzed projection rather than the mutable parser graph; desktop sessions keep their UI graph and expose a separately captured MCP projection. +2. Put typed, renderer-free operations (`open`, `close`, `list`, `summary`, `warnings`, `missing indexes`, `expensive operators`) and the shared parse/analyze/score pipeline in `PlanViewer.Core`. 3. Keep the Avalonia `PlanSessionManager` as a thin compatibility singleton over the Core catalog. Existing App MCP tools delegate to Core operations using the already-resolved session snapshot, avoiding a second catalog lookup while preserving their historical output behavior. -4. Add a Repl `IReplModule` in `PlanViewer.Cli`, pinned to the stable `Repl`/`Repl.Mcp`/`Repl.Testing` 0.11.0 release. Launch Repl for no args and for the new `plan` graph; dispatch all existing argument prefixes to System.CommandLine unchanged. +4. Add a Repl `IReplModule` in `PlanViewer.Cli`, pinned to the stable `Repl`/`Repl.Mcp`/`Repl.Testing` 0.11.0 release. Launch Repl only for explicit `repl`, `plan`, `open`, or `mcp` prefixes; zero arguments and existing prefixes remain on System.CommandLine. 5. Return typed values from Repl handlers so text, JSON, tests, and MCP are renderer concerns rather than handler concerns. ## Security and resource boundaries @@ -22,9 +22,11 @@ Local CLI/Repl commands may open any accessible `.sqlplan` file, matching normal - The server launch directory is a fallback root only when native roots are unsupported and no soft roots are configured. Advertised-but-empty, invalid, or unavailable roots deny access. - MCP errors do not disclose absolute paths outside the allowed roots. - Only an explicit allow-list of plan tools is exported; automatic read-only resource promotion is disabled. -- Files are read through a hard 16 MiB streaming ceiling even if they grow during loading; plans are limited to 10,000 statements and 100,000 operators including recursively retained UDF/stored-procedure subplans, concurrent opens to 2, and the process catalog to 32 sessions. -- `plan_close` provides explicit eviction. Catalog registration and the 32-session ceiling are enforced atomically, and closed session IDs are never reused. -- Loading honors cancellation before and between parse, analysis, scoring, and registration stages. +- Files are read through a hard 16 MiB streaming ceiling even if they grow during loading. A forward-only XML preflight prohibits DTDs and rejects depth over 512, more than 10,000 statement elements, or more than 100,000 operators before `XDocument` and the parser graph are materialized; the parsed graph is checked again before analysis. +- The limit of 2 concurrent opens is shared by every `PlanOperations` facade over the same catalog, and excess calls fail fast before path resolution or file acquisition. Admission is coordinated atomically across those facades, with at most 32 sessions and a 64 MiB aggregate retained-analysis estimate. The estimate is reserved after streaming preflight and conservatively accounts for UTF-16 source text, statements (2 KiB each), and operators (4 KiB each); `plan_close` releases it. +- Warning responses return at most 500 items, missing-index responses at most 100 suggestions, and operator queries at most 100 items. Result metadata reports truncation, and plan-controlled text fields are bounded before serialization. +- `plan_close` provides explicit eviction and releases the retained-memory reservation. Session labels are capped at 48 characters and use a fresh 12-hex-character opaque suffix; active-ID collisions are retried. +- Streaming preflight, per-statement/per-operator analysis, scoring, mapping, and registration honor cancellation. Synchronous XML materialization is bounded by preflight and checks cancellation immediately after returning. ## TDD slices @@ -68,8 +70,9 @@ The generated MCP server identifies itself as `planview`. It exports the canonic ## Compatibility boundary - `analyze`, `query-store`, `credential`, and their existing options remain on the original System.CommandLine root. -- The Repl graph is selected only for no arguments, `repl`, `plan`, `open`, or `mcp`. -- The existing `analyze --compact` JSON output is checked manually byte-for-byte against a pre-change baseline. +- Legacy `analyze` keeps its existing input behavior. The pilot intentionally accepts only `.sqlplan` in `open`/`plan_open`; this narrow local-file boundary is not retrofitted onto historical commands. +- The Repl graph is selected only for explicit `repl`, `plan`, `open`, or `mcp` prefixes; zero arguments preserve legacy routing. +- A committed process test runs `analyze --compact` and compares the exact stdout SHA-256 with the pre-change baseline (`0c609fed8e250d9366eb9a6cd5eaf40b661ee30d7ba2546bd7726960592e9d87`). - The Avalonia MCP session manager implements `IPlanCatalog`; committed App-side tests pin the historical warning scope, invalid-severity message, bare `object_name`, numeric default metrics, and snapshot behavior. - The desktop MCP host constructs shared operations with `AnalyzerConfig.Default`, so an unrelated malformed `planview.config.json` cannot prevent that server from starting. - Sessions are process-local. Query Store mutations and credentials are intentionally outside this pilot. From 10419f9fb2ce3f9075d8ee1030cc648af555bfc7 Mon Sep 17 00:00:00 2001 From: autocarl Date: Wed, 15 Jul 2026 23:57:46 -0400 Subject: [PATCH 07/16] fix(core): enforce plan resource boundaries --- .../ReplSurface/McpPlanPathPolicy.cs | 33 ++- .../ReplSurface/PlanReplModule.cs | 24 +- .../Services/PlanAnalysisPipeline.cs | 13 + .../Services/PlanOperations.cs | 275 +++++++++++++----- .../Services/PlanResourceBudget.cs | 17 +- .../Services/PlanXmlPreflight.cs | 89 ++++-- .../McpPlanPathPolicyTests.cs | 11 + .../PlanOperationsTests.cs | 94 +++++- .../PlanResourceBudgetTests.cs | 34 +++ .../PlanXmlPreflightTests.cs | 143 +++++++++ 10 files changed, 615 insertions(+), 118 deletions(-) diff --git a/src/PlanViewer.Cli/ReplSurface/McpPlanPathPolicy.cs b/src/PlanViewer.Cli/ReplSurface/McpPlanPathPolicy.cs index 75f7710..009645f 100644 --- a/src/PlanViewer.Cli/ReplSurface/McpPlanPathPolicy.cs +++ b/src/PlanViewer.Cli/ReplSurface/McpPlanPathPolicy.cs @@ -32,6 +32,7 @@ public static async ValueTask OpenAsync( // Never probe an absolute pathname until lexical containment has been established. if (!roots.Any(root => IsWithinRoot(lexicalPath, root)) || + (OperatingSystem.IsWindows() && ContainsWindowsAlternateDataStream(lexicalPath)) || !Path.GetExtension(lexicalPath).Equals(".sqlplan", StringComparison.OrdinalIgnoreCase)) { continue; @@ -48,7 +49,8 @@ public static async ValueTask OpenAsync( bufferSize: 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan); var openedPath = OpenedFilePathResolver.GetFinalPath(stream); - if (!Path.GetExtension(openedPath).Equals(".sqlplan", StringComparison.OrdinalIgnoreCase) || + if ((OperatingSystem.IsWindows() && ContainsWindowsAlternateDataStream(openedPath)) || + !Path.GetExtension(openedPath).Equals(".sqlplan", StringComparison.OrdinalIgnoreCase) || !roots.Any(root => IsWithinRoot(openedPath, root))) { await stream.DisposeAsync().ConfigureAwait(false); @@ -125,7 +127,7 @@ private static string ResolveExistingDirectory(string path) [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries)) { - var directory = new DirectoryInfo(Path.Combine(current, segment)); + var directory = new DirectoryInfo(ResolveDirectoryEntry(current, segment)); if (!directory.Exists) throw new DirectoryNotFoundException("Root directory does not exist."); current = directory.ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? directory.FullName; @@ -135,6 +137,21 @@ private static string ResolveExistingDirectory(string path) return Path.GetFullPath(current); } + private static string ResolveDirectoryEntry(string parent, string segment) + { + if (!OperatingSystem.IsWindows()) + return Path.Combine(parent, segment); + + foreach (var entry in Directory.EnumerateDirectories(parent)) + { + var name = Path.GetFileName(Path.TrimEndingDirectorySeparator(entry)); + if (name.Equals(segment, StringComparison.Ordinal)) + return entry; + } + + throw new DirectoryNotFoundException("Root directory does not exist with the advertised casing."); + } + private static bool IsWithinRoot(string candidate, string root) { var normalizedRoot = Path.TrimEndingDirectorySeparator(root); @@ -144,11 +161,15 @@ private static bool IsWithinRoot(string candidate, string root) return candidate.StartsWith(rootPrefix, PathComparisonKind); } - private static StringComparer PathComparison => - OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + internal static bool ContainsWindowsAlternateDataStream(string path) + { + var start = path.Length >= 2 && char.IsAsciiLetter(path[0]) && path[1] == ':' ? 2 : 0; + return path.AsSpan(start).Contains(':'); + } + + private static StringComparer PathComparison => StringComparer.Ordinal; - private static StringComparison PathComparisonKind => - OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + private static StringComparison PathComparisonKind => StringComparison.Ordinal; } internal sealed class McpAuthorizedPlanFile(FileStream stream, string label) : IAsyncDisposable diff --git a/src/PlanViewer.Cli/ReplSurface/PlanReplModule.cs b/src/PlanViewer.Cli/ReplSurface/PlanReplModule.cs index e051a58..b56141d 100644 --- a/src/PlanViewer.Cli/ReplSurface/PlanReplModule.cs +++ b/src/PlanViewer.Cli/ReplSurface/PlanReplModule.cs @@ -42,26 +42,32 @@ public void Map(IReplMap map) scope.Map( "warnings", - (string id, [Description("Critical, Warning, or Info")] string? severity = null) => - Execute(() => operations.GetWarnings(id, severity))) + (string id, CancellationToken cancellationToken, + [Description("Critical, Warning, or Info")] string? severity = null) => + Execute(() => operations.GetWarnings(id, severity, cancellationToken))) .WithDescription("List plan warnings, optionally filtered by severity") .ReadOnly(); scope.Map( "expensive-operators", - (string id, [Description("Number of operators to return (1-100)")] int top = 10) => - Execute(() => operations.GetExpensiveOperators(id, top))) + (string id, CancellationToken cancellationToken, + [Description("Number of operators to return (1-100)")] int top = 10) => + Execute(() => operations.GetExpensiveOperators(id, top, cancellationToken))) .WithDescription("List the most expensive operators") .ReadOnly(); scope.Map( "operators", - (string id, [Description("Number of operators to return (1-100)")] int top = 10) => - Execute(() => operations.GetExpensiveOperators(id, top))) + (string id, CancellationToken cancellationToken, + [Description("Number of operators to return (1-100)")] int top = 10) => + Execute(() => operations.GetExpensiveOperators(id, top, cancellationToken))) .WithDescription("Alias for expensive-operators") .ReadOnly(); - scope.Map("missing-indexes", (string id) => Execute(() => operations.GetMissingIndexes(id))) + scope.Map( + "missing-indexes", + (string id, CancellationToken cancellationToken) => + Execute(() => operations.GetMissingIndexes(id, cancellationToken))) .WithDescription("List missing-index suggestions") .ReadOnly(); @@ -153,5 +159,9 @@ private static object Execute(Func operation) { return Results.NotFound("Plan session was not found."); } + catch (InvalidOperationException ex) + { + return Results.Error("server_busy", ex.Message); + } } } diff --git a/src/PlanViewer.Core/Services/PlanAnalysisPipeline.cs b/src/PlanViewer.Core/Services/PlanAnalysisPipeline.cs index bacabd3..dbc4c92 100644 --- a/src/PlanViewer.Core/Services/PlanAnalysisPipeline.cs +++ b/src/PlanViewer.Core/Services/PlanAnalysisPipeline.cs @@ -30,6 +30,19 @@ internal static ParsedPlan Analyze( var plan = ShowPlanParser.Parse(planXml); cancellationToken.ThrowIfCancellationRequested(); + return AnalyzeParsed(plan, config, serverMetadata, cancellationToken, beforeAnalysis); + } + + internal static ParsedPlan AnalyzeParsed( + ParsedPlan plan, + AnalyzerConfig config, + ServerMetadata? serverMetadata = null, + CancellationToken cancellationToken = default, + Action? beforeAnalysis = null) + { + ArgumentNullException.ThrowIfNull(plan); + ArgumentNullException.ThrowIfNull(config); + cancellationToken.ThrowIfCancellationRequested(); if (!string.IsNullOrWhiteSpace(plan.ParseError) || !plan.Batches.SelectMany(batch => batch.Statements).Any()) { diff --git a/src/PlanViewer.Core/Services/PlanOperations.cs b/src/PlanViewer.Core/Services/PlanOperations.cs index 8679ee8..b4df987 100644 --- a/src/PlanViewer.Core/Services/PlanOperations.cs +++ b/src/PlanViewer.Core/Services/PlanOperations.cs @@ -1,3 +1,5 @@ +using System.Buffers; +using System.Text; using System.Text.RegularExpressions; using PlanViewer.Core.Interfaces; using PlanViewer.Core.Models; @@ -15,6 +17,7 @@ public sealed class PlanOperations internal const int DefaultMaxStatements = 10_000; internal const int DefaultMaxOperators = 100_000; internal const int DefaultMaxConcurrentOpens = 2; + internal const int DefaultMaxConcurrentQueries = 4; internal const int DefaultMaxWarningResults = 500; internal const int DefaultMaxMissingIndexResults = 100; internal const int DefaultMaxResponseTextLength = 4_096; @@ -169,31 +172,48 @@ private static long EstimateRetainedAnalysisBytes( checked( (sourceBytes * 2) + ((long)preflight.StatementCount * 2 * 1024) + - ((long)preflight.OperatorCount * 4 * 1024)); + ((long)preflight.OperatorCount * 4 * 1024) + + ((long)preflight.ElementCount * 256) + + ((long)preflight.AttributeCount * 128)); private static async Task ReadPlanXmlAsync( FileStream stream, CancellationToken cancellationToken) { - using var content = new MemoryStream((int)Math.Min(stream.Length, DefaultMaxPlanFileBytes)); - var buffer = new byte[64 * 1024]; - while (true) + stream.Position = 0; + using var reader = new StreamReader( + stream, + Encoding.UTF8, + detectEncodingFromByteOrderMarks: true, + bufferSize: 64 * 1024, + leaveOpen: true); + var content = new StringBuilder((int)Math.Min(stream.Length, DefaultMaxPlanFileBytes)); + var buffer = ArrayPool.Shared.Rent(64 * 1024); + try { - var bytesRead = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); - if (bytesRead == 0) - break; - if (content.Length + bytesRead > DefaultMaxPlanFileBytes) + while (true) { - throw new InvalidDataException( - $"Plan file exceeds the {DefaultMaxPlanFileBytes / (1024 * 1024)} MiB size limit."); + var charactersRead = await reader + .ReadAsync(buffer.AsMemory(), cancellationToken) + .ConfigureAwait(false); + if (charactersRead == 0) + break; + if (stream.Position > DefaultMaxPlanFileBytes || + content.Length + charactersRead > DefaultMaxPlanFileBytes) + { + throw new InvalidDataException( + $"Plan file exceeds the {DefaultMaxPlanFileBytes / (1024 * 1024)} MiB size limit."); + } + + content.Append(buffer, 0, charactersRead); } - content.Write(buffer, 0, bytesRead); + return content.ToString(); + } + finally + { + ArrayPool.Shared.Return(buffer); } - - content.Position = 0; - using var reader = new StreamReader(content, detectEncodingFromByteOrderMarks: true); - return await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); } public bool Close(string sessionId) @@ -225,66 +245,114 @@ public PlanSummaryResult GetSummary(PlanSession session) } public MissingIndexesResult GetMissingIndexes(string sessionId) => - GetMissingIndexes(GetRequiredSession(sessionId)); + GetMissingIndexes(sessionId, CancellationToken.None); - public MissingIndexesResult GetMissingIndexes(PlanSession session) + internal MissingIndexesResult GetMissingIndexes( + string sessionId, + CancellationToken cancellationToken) => + GetMissingIndexes(GetRequiredSession(sessionId), cancellationToken); + + public MissingIndexesResult GetMissingIndexes(PlanSession session) => + GetMissingIndexes(session, CancellationToken.None); + + internal MissingIndexesResult GetMissingIndexes( + PlanSession session, + CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(session); - var sourceIndexes = GetAnalysis(session).Statements - .SelectMany(statement => statement.MissingIndexes) - .ToList(); - var indexes = sourceIndexes.Take(DefaultMaxMissingIndexResults).Select(index => new MissingIndexItem + using var querySlot = _budget.AcquireQuerySlot(cancellationToken); + var indexes = new List(DefaultMaxMissingIndexResults); + var totalIndexCount = 0; + foreach (var statement in GetAnalysis(session, cancellationToken).Statements) { - Database = Truncate(index.Database, 512), - SchemaName = Truncate(index.SchemaName, 512), - Table = Truncate(index.BareTable, 512), - Impact = index.Impact, - EqualityColumns = BoundColumns(index.EqualityColumns), - InequalityColumns = BoundColumns(index.InequalityColumns), - IncludeColumns = BoundColumns(index.IncludeColumns), - CreateStatement = Truncate(index.CreateStatement, DefaultMaxResponseTextLength) - }).ToList(); + cancellationToken.ThrowIfCancellationRequested(); + foreach (var index in statement.MissingIndexes) + { + cancellationToken.ThrowIfCancellationRequested(); + totalIndexCount++; + if (indexes.Count >= DefaultMaxMissingIndexResults) + continue; + indexes.Add(new MissingIndexItem + { + Database = Truncate(index.Database, 512), + SchemaName = Truncate(index.SchemaName, 512), + Table = Truncate(index.BareTable, 512), + Impact = index.Impact, + EqualityColumns = BoundColumns(index.EqualityColumns), + InequalityColumns = BoundColumns(index.InequalityColumns), + IncludeColumns = BoundColumns(index.IncludeColumns), + CreateStatement = Truncate(index.CreateStatement, DefaultMaxResponseTextLength) + }); + } + } return new MissingIndexesResult { SessionId = session.SessionId, - MissingIndexCount = sourceIndexes.Count, + MissingIndexCount = totalIndexCount, ReturnedIndexCount = indexes.Count, - Truncated = indexes.Count < sourceIndexes.Count, + Truncated = indexes.Count < totalIndexCount, Indexes = indexes }; } public ExpensiveOperatorsResult GetExpensiveOperators(string sessionId, int top = 10) => - GetExpensiveOperators(GetRequiredSession(sessionId), top); + GetExpensiveOperators(sessionId, top, CancellationToken.None); + + internal ExpensiveOperatorsResult GetExpensiveOperators( + string sessionId, + int top, + CancellationToken cancellationToken) => + GetExpensiveOperators(GetRequiredSession(sessionId), top, useBareObjectNames: false, cancellationToken); public ExpensiveOperatorsResult GetExpensiveOperators( PlanSession session, int top = 10, - bool useBareObjectNames = false) + bool useBareObjectNames = false) => + GetExpensiveOperators(session, top, useBareObjectNames, CancellationToken.None); + + internal ExpensiveOperatorsResult GetExpensiveOperators( + PlanSession session, + int top, + CancellationToken cancellationToken) => + GetExpensiveOperators(session, top, useBareObjectNames: false, cancellationToken); + + internal ExpensiveOperatorsResult GetExpensiveOperators( + PlanSession session, + int top, + bool useBareObjectNames, + CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(session); if (top is < 1 or > 100) throw new ArgumentOutOfRangeException(nameof(top), top, "Top must be between 1 and 100."); - var analysis = GetAnalysis(session); - var operators = new List<(OperatorResult Node, string Statement)>(); + using var querySlot = _budget.AcquireQuerySlot(cancellationToken); + var analysis = GetAnalysis(session, cancellationToken); + var topByActual = new List(top); + var topByCost = new List(top); + var hasActuals = false; foreach (var statement in analysis.Statements) { - if (statement.OperatorTree is not null) - CollectOperators(statement.OperatorTree, Truncate(statement.StatementText, 100), operators); + cancellationToken.ThrowIfCancellationRequested(); + if (statement.OperatorTree is null) + continue; + var statementText = Truncate(statement.StatementText, 100); + VisitOperators(statement.OperatorTree, cancellationToken, node => + { + var candidate = new RankedOperator(node, statementText); + hasActuals |= node.ActualElapsedMs > 0; + AddRanked(topByActual, candidate, top, rankByActuals: true); + AddRanked(topByCost, candidate, top, rankByActuals: false); + }); } - var hasActuals = operators.Any(item => item.Node.ActualElapsedMs > 0); - var ranked = hasActuals - ? operators.OrderByDescending(item => item.Node.ActualElapsedMs ?? 0) - : operators.OrderByDescending(item => item.Node.CostPercent); - + var ranked = hasActuals ? topByActual : topByCost; return new ExpensiveOperatorsResult { SessionId = session.SessionId, RankedBy = hasActuals ? "actual_elapsed_ms" : "cost_percent", - Operators = ranked.Take(top).Select(item => new ExpensiveOperatorItem + Operators = ranked.Select(item => new ExpensiveOperatorItem { NodeId = item.Node.NodeId, PhysicalOp = item.Node.PhysicalOp, @@ -303,13 +371,33 @@ public ExpensiveOperatorsResult GetExpensiveOperators( } public PlanWarningsResult GetWarnings(string sessionId, string? severity = null) => - GetWarnings(GetRequiredSession(sessionId), severity); + GetWarnings(sessionId, severity, CancellationToken.None); + + internal PlanWarningsResult GetWarnings( + string sessionId, + string? severity, + CancellationToken cancellationToken) => + GetWarnings(GetRequiredSession(sessionId), severity, includeOperatorWarnings: true, validateSeverity: true, cancellationToken); public PlanWarningsResult GetWarnings( PlanSession session, string? severity = null, bool includeOperatorWarnings = true, - bool validateSeverity = true) + bool validateSeverity = true) => + GetWarnings(session, severity, includeOperatorWarnings, validateSeverity, CancellationToken.None); + + internal PlanWarningsResult GetWarnings( + PlanSession session, + string? severity, + CancellationToken cancellationToken) => + GetWarnings(session, severity, includeOperatorWarnings: true, validateSeverity: true, cancellationToken); + + internal PlanWarningsResult GetWarnings( + PlanSession session, + string? severity, + bool includeOperatorWarnings, + bool validateSeverity, + CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(session); if (validateSeverity && severity is not null && @@ -318,25 +406,32 @@ public PlanWarningsResult GetWarnings( throw new ArgumentException("Severity must be Critical, Warning, or Info.", nameof(severity)); } - var analysis = GetAnalysis(session); - var warnings = new List(); + using var querySlot = _budget.AcquireQuerySlot(cancellationToken); + var analysis = GetAnalysis(session, cancellationToken); + var returnedWarnings = new List(DefaultMaxWarningResults); + var totalWarningCount = 0; foreach (var statement in analysis.Statements) { + cancellationToken.ThrowIfCancellationRequested(); var statementText = Truncate(statement.StatementText, 200); - warnings.AddRange(statement.Warnings.Select(warning => ToWarningItem(warning, statementText))); + foreach (var warning in statement.Warnings) + { + cancellationToken.ThrowIfCancellationRequested(); + AddWarning(warning, statementText); + } if (includeOperatorWarnings && statement.OperatorTree is not null) - CollectWarnings(statement.OperatorTree, statementText, warnings); - } - - if (severity is not null) - { - warnings = warnings - .Where(warning => warning.Severity.Equals(severity, StringComparison.OrdinalIgnoreCase)) - .ToList(); + { + VisitOperators(statement.OperatorTree, cancellationToken, node => + { + foreach (var warning in node.Warnings) + { + cancellationToken.ThrowIfCancellationRequested(); + AddWarning(warning, statementText); + } + }); + } } - var totalWarningCount = warnings.Count; - var returnedWarnings = warnings.Take(DefaultMaxWarningResults).ToList(); return new PlanWarningsResult { SessionId = session.SessionId, @@ -345,6 +440,19 @@ public PlanWarningsResult GetWarnings( Truncated = returnedWarnings.Count < totalWarningCount, Warnings = returnedWarnings }; + + void AddWarning(WarningResult warning, string statementText) + { + if (severity is not null && + !warning.Severity.Equals(severity, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + totalWarningCount++; + if (returnedWarnings.Count < DefaultMaxWarningResults) + returnedWarnings.Add(ToWarningItem(warning, statementText)); + } } public AnalysisResult GetAnalysis(string sessionId) => @@ -356,6 +464,11 @@ public AnalysisResult GetAnalysis(PlanSession session) return session.Analysis ?? ResultMapper.Map(session.Plan, session.Source); } + private static AnalysisResult GetAnalysis( + PlanSession session, + CancellationToken cancellationToken) => + session.Analysis ?? ResultMapper.MapCancellable(session.Plan, session.Source, metadata: null, cancellationToken); + private static void ValidateComplexity(ParsedPlan plan, CancellationToken cancellationToken) { var pendingStatements = new Stack( @@ -404,35 +517,49 @@ private static void PushStatements( pending.Push(statements[index]); } - private static void CollectOperators( + private readonly record struct RankedOperator(OperatorResult Node, string Statement); + + private static void VisitOperators( OperatorResult node, - string statement, - ICollection<(OperatorResult Node, string Statement)> operators) + CancellationToken cancellationToken, + Action visit) { var pending = new Stack(); pending.Push(node); while (pending.TryPop(out var current)) { - operators.Add((current, statement)); + cancellationToken.ThrowIfCancellationRequested(); + visit(current); for (var index = current.Children.Count - 1; index >= 0; index--) pending.Push(current.Children[index]); } } - private static void CollectWarnings( - OperatorResult node, - string statement, - ICollection warnings) + private static void AddRanked( + List ranked, + RankedOperator candidate, + int maximum, + bool rankByActuals) { - var pending = new Stack(); - pending.Push(node); - while (pending.TryPop(out var current)) + var candidateScore = rankByActuals + ? candidate.Node.ActualElapsedMs ?? 0 + : candidate.Node.CostPercent; + var insertAt = 0; + while (insertAt < ranked.Count) { - foreach (var warning in current.Warnings) - warnings.Add(ToWarningItem(warning, statement)); - for (var index = current.Children.Count - 1; index >= 0; index--) - pending.Push(current.Children[index]); + var item = ranked[insertAt]; + var score = rankByActuals ? item.Node.ActualElapsedMs ?? 0 : item.Node.CostPercent; + if (candidateScore > score) + { + break; + } + insertAt++; } + if (insertAt >= maximum) + return; + ranked.Insert(insertAt, candidate); + if (ranked.Count > maximum) + ranked.RemoveAt(maximum); } private static PlanWarningItem ToWarningItem(WarningResult warning, string statement) => new() diff --git a/src/PlanViewer.Core/Services/PlanResourceBudget.cs b/src/PlanViewer.Core/Services/PlanResourceBudget.cs index a9493d0..e7b322d 100644 --- a/src/PlanViewer.Core/Services/PlanResourceBudget.cs +++ b/src/PlanViewer.Core/Services/PlanResourceBudget.cs @@ -13,6 +13,7 @@ internal sealed class PlanResourceBudget private readonly IPlanCatalog _catalog; private readonly object _syncRoot = new(); private readonly SemaphoreSlim _openSlots = new(PlanOperations.DefaultMaxConcurrentOpens); + private readonly SemaphoreSlim _querySlots = new(PlanOperations.DefaultMaxConcurrentQueries); private readonly Dictionary _retainedEstimateBySession = new(StringComparer.OrdinalIgnoreCase); private long _reservedAndRetainedEstimate; @@ -35,7 +36,19 @@ internal async Task AcquireOpenSlotAsync(CancellationToken cancella $"The concurrent plan-open limit of {PlanOperations.DefaultMaxConcurrentOpens} has been reached. Retry after an active open completes."); } - return new OpenSlotLease(_openSlots); + return new SemaphoreLease(_openSlots); + } + + internal IDisposable AcquireQuerySlot(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!_querySlots.Wait(0, cancellationToken)) + { + throw new InvalidOperationException( + $"The concurrent plan-query limit of {PlanOperations.DefaultMaxConcurrentQueries} has been reached. Retry after an active query completes."); + } + + return new SemaphoreLease(_querySlots); } internal bool TryRegister(PlanSession session, int maxSessions) @@ -139,7 +152,7 @@ public void Dispose() } } - private sealed class OpenSlotLease(SemaphoreSlim semaphore) : IDisposable + private sealed class SemaphoreLease(SemaphoreSlim semaphore) : IDisposable { private SemaphoreSlim? _semaphore = semaphore; diff --git a/src/PlanViewer.Core/Services/PlanXmlPreflight.cs b/src/PlanViewer.Core/Services/PlanXmlPreflight.cs index ae234a0..b003de8 100644 --- a/src/PlanViewer.Core/Services/PlanXmlPreflight.cs +++ b/src/PlanViewer.Core/Services/PlanXmlPreflight.cs @@ -2,11 +2,20 @@ namespace PlanViewer.Core.Services; -internal readonly record struct PlanXmlPreflightResult(int StatementCount, int OperatorCount); +internal readonly record struct PlanXmlPreflightResult( + int StatementCount, + int OperatorCount, + int ElementCount, + int AttributeCount); internal static class PlanXmlPreflight { internal const int DefaultMaxXmlDepth = 512; + internal const int DefaultMaxXmlElements = 250_000; + internal const int DefaultMaxXmlAttributes = 1_000_000; + + private const string ShowPlanNamespace = + "http://schemas.microsoft.com/sqlserver/2004/07/showplan"; internal static async Task ValidateAsync( FileStream stream, @@ -28,8 +37,11 @@ internal static async Task ValidateAsync( XmlResolver = null }; + var ancestry = new Stack<(string LocalName, string NamespaceUri)>(); var statements = 0; var operators = 0; + var elements = 0; + var attributes = 0; stream.Position = 0; try { @@ -43,6 +55,13 @@ internal static async Task ValidateAsync( throw new InvalidDataException( $"Plan file exceeds the {PlanOperations.DefaultMaxPlanFileBytes / (1024 * 1024)} MiB size limit."); } + + if (reader.NodeType == XmlNodeType.EndElement) + { + ancestry.Pop(); + continue; + } + if (reader.NodeType != XmlNodeType.Element) continue; if (reader.Depth > DefaultMaxXmlDepth) @@ -51,26 +70,56 @@ internal static async Task ValidateAsync( $"Plan XML exceeds the supported depth limit of {DefaultMaxXmlDepth}."); } - switch (reader.LocalName) + elements++; + if (elements > DefaultMaxXmlElements) + { + throw new InvalidDataException( + $"Plan XML exceeds the supported element limit of {DefaultMaxXmlElements}."); + } + + attributes = checked(attributes + reader.AttributeCount); + if (attributes > DefaultMaxXmlAttributes) + { + throw new InvalidDataException( + $"Plan XML exceeds the supported attribute limit of {DefaultMaxXmlAttributes}."); + } + + var parent = ancestry.TryPeek(out var value) ? value : default; + var isShowPlanElement = reader.NamespaceURI.Equals(ShowPlanNamespace, StringComparison.Ordinal); + var isDirectStatement = + parent.LocalName == "Statements" && + parent.NamespaceUri == ShowPlanNamespace; + var isCursorOperation = + isShowPlanElement && + reader.LocalName == "Operation" && + parent.LocalName == "CursorPlan" && + parent.NamespaceUri == ShowPlanNamespace; + var isFallbackStatement = + isShowPlanElement && + reader.LocalName == "StmtSimple" && + !isDirectStatement; + if (isDirectStatement || isCursorOperation || isFallbackStatement) { - case "StmtSimple": - case "StmtCursor": - statements++; - if (statements > PlanOperations.DefaultMaxStatements) - { - throw new InvalidDataException( - $"Plan exceeds the {PlanOperations.DefaultMaxStatements} statement complexity limit."); - } - break; - case "RelOp": - operators++; - if (operators > PlanOperations.DefaultMaxOperators) - { - throw new InvalidDataException( - $"Plan exceeds the {PlanOperations.DefaultMaxOperators} operator complexity limit."); - } - break; + statements++; + if (statements > PlanOperations.DefaultMaxStatements) + { + throw new InvalidDataException( + $"Plan exceeds the {PlanOperations.DefaultMaxStatements} statement complexity limit."); + } } + + if (isShowPlanElement && reader.LocalName == "RelOp") + { + operators++; + if (operators > PlanOperations.DefaultMaxOperators) + { + throw new InvalidDataException( + $"Plan exceeds the {PlanOperations.DefaultMaxOperators} operator complexity limit."); + } + } + + if (!reader.IsEmptyElement) + ancestry.Push((reader.LocalName, reader.NamespaceURI)); } } catch (XmlException exception) @@ -82,6 +131,6 @@ internal static async Task ValidateAsync( stream.Position = 0; } - return new PlanXmlPreflightResult(statements, operators); + return new PlanXmlPreflightResult(statements, operators, elements, attributes); } } diff --git a/tests/PlanViewer.Core.Tests/McpPlanPathPolicyTests.cs b/tests/PlanViewer.Core.Tests/McpPlanPathPolicyTests.cs index f3b3367..0ffbdc5 100644 --- a/tests/PlanViewer.Core.Tests/McpPlanPathPolicyTests.cs +++ b/tests/PlanViewer.Core.Tests/McpPlanPathPolicyTests.cs @@ -7,6 +7,17 @@ namespace PlanViewer.Core.Tests; public sealed class McpPlanPathPolicyTests { + [Theory] + [InlineData(@"C:\plans\query.sqlplan", false)] + [InlineData(@"C:\plans\host.txt:query.sqlplan", true)] + [InlineData(@"\\server\share\query.sqlplan", false)] + public void ContainsWindowsAlternateDataStream_DistinguishesTheDriveDesignator( + string path, + bool expected) + { + Assert.Equal(expected, McpPlanPathPolicy.ContainsWindowsAlternateDataStream(path)); + } + [Fact] public async Task OpenAsync_DeniesAdvertisedEmptyRoots() { diff --git a/tests/PlanViewer.Core.Tests/PlanOperationsTests.cs b/tests/PlanViewer.Core.Tests/PlanOperationsTests.cs index 52046e0..1ae45f8 100644 --- a/tests/PlanViewer.Core.Tests/PlanOperationsTests.cs +++ b/tests/PlanViewer.Core.Tests/PlanOperationsTests.cs @@ -80,7 +80,7 @@ public async Task GetWarnings_BoundsTheReturnedItemsAndReportsTruncation() .ToList(); statement.OperatorTree = null; - var result = operations.GetWarnings(session); + var result = operations.GetWarnings(session, severity: null, TestContext.Current.CancellationToken); Assert.Equal(PlanOperations.DefaultMaxWarningResults + 1, result.WarningCount); Assert.Equal(PlanOperations.DefaultMaxWarningResults, result.ReturnedWarningCount); @@ -97,7 +97,7 @@ public async Task GetWarnings_FiltersAllStatementAndOperatorWarningsBySeverity() var path = Path.GetFullPath(Path.Combine("Plans", "row_goal_plan.sqlplan")); var opened = await operations.OpenAsync(path, TestContext.Current.CancellationToken); - var warnings = operations.GetWarnings(opened.SessionId, "warning"); + var warnings = operations.GetWarnings(opened.SessionId, "warning", TestContext.Current.CancellationToken); var warning = Assert.Single(warnings.Warnings); Assert.Equal(1, warnings.WarningCount); @@ -115,7 +115,7 @@ public async Task GetExpensiveOperators_RanksEstimatedPlansByCostAndHonorsTop() var path = Path.GetFullPath(Path.Combine("Plans", "row_goal_plan.sqlplan")); var opened = await operations.OpenAsync(path, TestContext.Current.CancellationToken); - var result = operations.GetExpensiveOperators(opened.SessionId, 1); + var result = operations.GetExpensiveOperators(opened.SessionId, 1, TestContext.Current.CancellationToken); Assert.Equal("cost_percent", result.RankedBy); var item = Assert.Single(result.Operators); @@ -124,6 +124,57 @@ public async Task GetExpensiveOperators_RanksEstimatedPlansByCostAndHonorsTop() } + [Fact] + public void GetExpensiveOperators_UsesActualElapsedTimeAndKeepsStableTies() + { + var firstTie = new PlanViewer.Core.Output.OperatorResult + { + NodeId = 2, + PhysicalOp = "First tie", + ActualElapsedMs = 10, + CostPercent = 1 + }; + var secondTie = new PlanViewer.Core.Output.OperatorResult + { + NodeId = 3, + PhysicalOp = "Second tie", + ActualElapsedMs = 10, + CostPercent = 99 + }; + var root = new PlanViewer.Core.Output.OperatorResult + { + NodeId = 1, + PhysicalOp = "Root", + ActualElapsedMs = 1, + CostPercent = 100, + Children = [firstTie, secondTie] + }; + var session = new PlanViewer.Core.Models.PlanSession + { + SessionId = "actual-ranking", + Label = "actual.sqlplan", + Source = "actual.sqlplan", + Plan = new PlanViewer.Core.Models.ParsedPlan(), + Analysis = new PlanViewer.Core.Output.AnalysisResult + { + Statements = + [ + new PlanViewer.Core.Output.StatementResult + { + StatementText = "SELECT 1", + OperatorTree = root + } + ] + } + }; + var operations = new PlanOperations(new InMemoryPlanCatalog()); + + var result = operations.GetExpensiveOperators(session, top: 2, TestContext.Current.CancellationToken); + + Assert.Equal("actual_elapsed_ms", result.RankedBy); + Assert.Equal([2, 3], result.Operators.Select(item => item.NodeId)); + } + [Fact] public async Task GetMissingIndexes_BoundsTheReturnedItemsAndReportsTruncation() { @@ -146,7 +197,7 @@ public async Task GetMissingIndexes_BoundsTheReturnedItemsAndReportsTruncation() }) .ToList(); - var result = operations.GetMissingIndexes(session); + var result = operations.GetMissingIndexes(session, TestContext.Current.CancellationToken); Assert.Equal(PlanOperations.DefaultMaxMissingIndexResults + 1, result.MissingIndexCount); Assert.Equal(PlanOperations.DefaultMaxMissingIndexResults, result.ReturnedIndexCount); @@ -167,7 +218,7 @@ public async Task GetMissingIndexes_ReturnsStructuredSuggestions() var path = Path.GetFullPath(Path.Combine("Plans", "top_above_scan_plan.sqlplan")); var opened = await operations.OpenAsync(path, TestContext.Current.CancellationToken); - var result = operations.GetMissingIndexes(opened.SessionId); + var result = operations.GetMissingIndexes(opened.SessionId, TestContext.Current.CancellationToken); Assert.Equal(opened.SessionId, result.SessionId); Assert.Equal(result.Indexes.Count, result.MissingIndexCount); @@ -245,9 +296,9 @@ public async Task FiltersAndTop_RejectInvalidValues() var path = Path.GetFullPath(Path.Combine("Plans", "row_goal_plan.sqlplan")); var opened = await operations.OpenAsync(path, TestContext.Current.CancellationToken); - Assert.Throws(() => operations.GetWarnings(opened.SessionId, "urgent")); - Assert.Throws(() => operations.GetExpensiveOperators(opened.SessionId, 0)); - Assert.Throws(() => operations.GetExpensiveOperators(opened.SessionId, 101)); + Assert.Throws(() => operations.GetWarnings(opened.SessionId, "urgent", TestContext.Current.CancellationToken)); + Assert.Throws(() => operations.GetExpensiveOperators(opened.SessionId, 0, TestContext.Current.CancellationToken)); + Assert.Throws(() => operations.GetExpensiveOperators(opened.SessionId, 101, TestContext.Current.CancellationToken)); } @@ -487,7 +538,6 @@ public async Task OpenAsync_BoundsEstimatedRetainedAnalysisMemoryByComplexity() var first = await operations.OpenAsync(path, TestContext.Current.CancellationToken); await operations.OpenAsync(path, TestContext.Current.CancellationToken); - await operations.OpenAsync(path, TestContext.Current.CancellationToken); var exception = await Assert.ThrowsAsync( () => operations.OpenAsync(path, TestContext.Current.CancellationToken)); @@ -501,6 +551,32 @@ public async Task OpenAsync_BoundsEstimatedRetainedAnalysisMemoryByComplexity() } } + [Fact] + public async Task OpenAsync_RejectsHighCardinalityAuxiliaryElementsByRetainedEstimate() + { + const int auxiliaryElementCount = 245_000; + var operations = new PlanOperations(new InMemoryPlanCatalog()); + var path = Path.Combine(Path.GetTempPath(), $"retained-elements-{Guid.NewGuid():N}.sqlplan"); + try + { + var xml = new System.Text.StringBuilder( + ""); + for (var index = 0; index < auxiliaryElementCount; index++) + xml.Append(""); + xml.Append(""); + await File.WriteAllTextAsync(path, xml.ToString(), TestContext.Current.CancellationToken); + + var exception = await Assert.ThrowsAsync( + () => operations.OpenAsync(path, TestContext.Current.CancellationToken)); + + Assert.Contains("retained-analysis", exception.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + File.Delete(path); + } + } + [Fact] public async Task OpenAsync_RejectsFilesWithoutSqlPlanExtension() { diff --git a/tests/PlanViewer.Core.Tests/PlanResourceBudgetTests.cs b/tests/PlanViewer.Core.Tests/PlanResourceBudgetTests.cs index a04f850..8000df5 100644 --- a/tests/PlanViewer.Core.Tests/PlanResourceBudgetTests.cs +++ b/tests/PlanViewer.Core.Tests/PlanResourceBudgetTests.cs @@ -21,6 +21,40 @@ public async Task ForCatalog_SharesConcurrentOpenSlotsAcrossFacades() } + [Fact] + public void GivenSharedCatalog_WhenQuerySlotsAreExhausted_ThenOperationsFailFastAndHonorCancellation() + { + var catalog = new InMemoryPlanCatalog(); + var budget = PlanResourceBudget.ForCatalog(catalog); + var operations = new PlanOperations(catalog, PlanViewer.Core.Models.AnalyzerConfig.Default); + var leases = Enumerable.Range(0, PlanOperations.DefaultMaxConcurrentQueries) + .Select(_ => budget.AcquireQuerySlot(TestContext.Current.CancellationToken)) + .ToList(); + try + { + var busy = Assert.Throws( + () => operations.GetMissingIndexes( + CreateSession("busy"), + TestContext.Current.CancellationToken)); + Assert.Contains("concurrent plan-query limit", busy.Message, StringComparison.Ordinal); + } + finally + { + foreach (var lease in leases) + lease.Dispose(); + } + + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + Assert.Throws( + () => operations.GetWarnings( + CreateSession("cancelled"), + severity: null, + includeOperatorWarnings: true, + validateSeverity: true, + cancelled.Token)); + } + [Fact] public void TryRegister_ReportsCapacityInsteadOfMasqueradingItAsAnIdCollision() { diff --git a/tests/PlanViewer.Core.Tests/PlanXmlPreflightTests.cs b/tests/PlanViewer.Core.Tests/PlanXmlPreflightTests.cs index 8529ad4..5765c77 100644 --- a/tests/PlanViewer.Core.Tests/PlanXmlPreflightTests.cs +++ b/tests/PlanViewer.Core.Tests/PlanXmlPreflightTests.cs @@ -38,6 +38,141 @@ public async Task ValidateAsync_RejectsStatementBudgetBeforeMaterialization() } } + [Fact] + public async Task ValidateAsync_RejectsFallbackStatementsBeforeMaterialization() + { + var path = Path.Combine(Path.GetTempPath(), $"preflight-fallback-statements-{Guid.NewGuid():N}.sqlplan"); + try + { + var xml = new StringBuilder( + ""); + for (var id = 0; id <= PlanOperations.DefaultMaxStatements; id++) + xml.Append($""); + xml.Append(""); + await File.WriteAllTextAsync(path, xml.ToString(), TestContext.Current.CancellationToken); + + await using var stream = OpenPlan(path); + + var exception = await Assert.ThrowsAsync( + () => PlanXmlPreflight.ValidateAsync(stream, TestContext.Current.CancellationToken)); + + Assert.Contains("statement complexity", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, stream.Position); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public async Task ValidateAsync_RejectsAlternateStatementTypesBeforeMaterialization() + { + var path = Path.Combine(Path.GetTempPath(), $"preflight-alternate-statements-{Guid.NewGuid():N}.sqlplan"); + try + { + var xml = new StringBuilder( + ""); + for (var id = 0; id <= PlanOperations.DefaultMaxStatements; id++) + xml.Append($""); + xml.Append(""); + await File.WriteAllTextAsync(path, xml.ToString(), TestContext.Current.CancellationToken); + + await using var stream = OpenPlan(path); + + var exception = await Assert.ThrowsAsync( + () => PlanXmlPreflight.ValidateAsync(stream, TestContext.Current.CancellationToken)); + + Assert.Contains("statement complexity", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, stream.Position); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public async Task ValidateAsync_RejectsCursorOperationsAsStatementsBeforeMaterialization() + { + var path = Path.Combine(Path.GetTempPath(), $"preflight-cursor-statements-{Guid.NewGuid():N}.sqlplan"); + try + { + var xml = new StringBuilder( + ""); + for (var id = 0; id < PlanOperations.DefaultMaxStatements; id++) + xml.Append($""); + xml.Append(""); + await File.WriteAllTextAsync(path, xml.ToString(), TestContext.Current.CancellationToken); + + await using var stream = OpenPlan(path); + + var exception = await Assert.ThrowsAsync( + () => PlanXmlPreflight.ValidateAsync(stream, TestContext.Current.CancellationToken)); + + Assert.Contains("statement complexity", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, stream.Position); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public async Task ValidateAsync_RejectsExcessiveElementCountBeforeMaterialization() + { + const int maximumElements = PlanXmlPreflight.DefaultMaxXmlElements; + var path = Path.Combine(Path.GetTempPath(), $"preflight-elements-{Guid.NewGuid():N}.sqlplan"); + try + { + var xml = new StringBuilder(""); + for (var index = 0; index < maximumElements; index++) + xml.Append(""); + xml.Append(""); + await File.WriteAllTextAsync(path, xml.ToString(), TestContext.Current.CancellationToken); + + await using var stream = OpenPlan(path); + + var exception = await Assert.ThrowsAsync( + () => PlanXmlPreflight.ValidateAsync(stream, TestContext.Current.CancellationToken)); + + Assert.Contains("element", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, stream.Position); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public async Task ValidateAsync_RejectsExcessiveAttributeCountBeforeMaterialization() + { + const int elementCount = (PlanXmlPreflight.DefaultMaxXmlAttributes / 5) + 1; + var path = Path.Combine(Path.GetTempPath(), $"preflight-attributes-{Guid.NewGuid():N}.sqlplan"); + try + { + var xml = new StringBuilder(""); + for (var index = 0; index < elementCount; index++) + xml.Append(""); + xml.Append(""); + await File.WriteAllTextAsync(path, xml.ToString(), TestContext.Current.CancellationToken); + + await using var stream = OpenPlan(path); + + var exception = await Assert.ThrowsAsync( + () => PlanXmlPreflight.ValidateAsync(stream, TestContext.Current.CancellationToken)); + + Assert.Contains("attribute", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, stream.Position); + } + finally + { + File.Delete(path); + } + } + [Fact] public async Task ValidateAsync_RejectsExcessiveXmlDepthAndRewindsTheStream() { @@ -71,4 +206,12 @@ public async Task ValidateAsync_RejectsExcessiveXmlDepthAndRewindsTheStream() File.Delete(path); } } + + private static FileStream OpenPlan(string path) => new( + path, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + 64 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); } From 48c50c1e38f1d46079dcce8bcc3dbcf6de22f930 Mon Sep 17 00:00:00 2001 From: autocarl Date: Wed, 15 Jul 2026 23:57:46 -0400 Subject: [PATCH 08/16] fix(app): preserve shared analysis contracts --- .../Controls/PlanViewerControl.axaml.cs | 8 +- src/PlanViewer.App/Mcp/McpPlanTools.cs | 16 ++- src/PlanViewer.App/Mcp/McpQueryStoreTools.cs | 61 ++++++++---- src/PlanViewer.App/PlanViewer.App.csproj | 4 + .../McpPlanToolsContractTests.cs | 97 +++++++++++++++++++ 5 files changed, 159 insertions(+), 27 deletions(-) diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs index da4b574..c31d805 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs @@ -339,8 +339,7 @@ public bool LoadPlan(string planXml, string label, string? queryText = null) return false; } - PlanAnalyzer.Analyze(_currentPlan, ConfigLoader.Load(), _serverMetadata); - BenefitScorer.Score(_currentPlan); + PlanAnalysisPipeline.AnalyzeParsed(_currentPlan, ConfigLoader.Load(), _serverMetadata); var allStatements = _currentPlan.Batches .SelectMany(b => b.Statements) @@ -374,12 +373,13 @@ public bool LoadPlan(string planXml, string label, string? queryText = null) CountNodeWarnings(s.RootNode, ref warningCount, ref criticalCount); } - var analysisSnapshot = ResultMapper.Map(_currentPlan, label); + const string sessionSource = "file"; + var analysisSnapshot = ResultMapper.Map(_currentPlan, sessionSource); PlanSessionManager.Instance.Register(_mcpSessionId, new PlanViewer.App.Mcp.PlanSession { SessionId = _mcpSessionId, Label = label, - Source = "file", + Source = sessionSource, Plan = _currentPlan, Analysis = analysisSnapshot, QueryText = queryText, diff --git a/src/PlanViewer.App/Mcp/McpPlanTools.cs b/src/PlanViewer.App/Mcp/McpPlanTools.cs index e243bdd..ad48575 100644 --- a/src/PlanViewer.App/Mcp/McpPlanTools.cs +++ b/src/PlanViewer.App/Mcp/McpPlanTools.cs @@ -146,7 +146,13 @@ public static string GetPlanWarnings( } return JsonSerializer.Serialize( - new { warning_count = result.WarningCount, warnings = result.Warnings }, + new + { + warning_count = result.WarningCount, + returned_warning_count = result.ReturnedWarningCount, + truncated = result.Truncated, + warnings = result.Warnings + }, McpHelpers.JsonOptions); } catch (Exception ex) @@ -172,7 +178,13 @@ public static string GetMissingIndexes( return "No missing index suggestions in this plan."; return JsonSerializer.Serialize( - new { missing_index_count = result.MissingIndexCount, indexes = result.Indexes }, + new + { + missing_index_count = result.MissingIndexCount, + returned_index_count = result.ReturnedIndexCount, + truncated = result.Truncated, + indexes = result.Indexes + }, McpHelpers.JsonOptions); } diff --git a/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs b/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs index 731a1be..cc9f2a0 100644 --- a/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs +++ b/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs @@ -156,25 +156,13 @@ public static async Task GetQueryStoreTop( xml, AnalyzerConfig.Default, serverMetadata); - var analysis = ResultMapper.Map(parsed, label); - var allStatements = parsed.Batches.SelectMany(b => b.Statements).ToList(); - - sessionManager.Register(sessionId, new PlanSession - { - SessionId = sessionId, - Label = label, - Source = "query-store", - Plan = parsed, - Analysis = analysis, - QueryText = qsPlan.QueryText, - ConnectionInfo = conn.ServerName, - StatementCount = allStatements.Count, - HasActualStats = false, // Query Store plans are always estimated - WarningCount = allStatements.Sum(s => s.PlanWarnings.Count), - CriticalWarningCount = allStatements.Sum(s => - s.PlanWarnings.Count(w => w.Severity == Core.Models.PlanWarningSeverity.Critical)), - MissingIndexCount = parsed.AllMissingIndexes.Count - }); + var session = CaptureSession( + sessionId, + label, + parsed, + qsPlan.QueryText, + conn.ServerName); + sessionManager.Register(sessionId, session); return new { @@ -193,8 +181,8 @@ public static async Task GetQueryStoreTop( avg_duration_ms = qsPlan.AvgDurationUs / 1000.0, total_logical_reads = qsPlan.TotalLogicalIoReads, avg_logical_reads = qsPlan.AvgLogicalIoReads, - warning_count = allStatements.Sum(s => s.PlanWarnings.Count), - missing_index_count = parsed.AllMissingIndexes.Count, + warning_count = session.WarningCount, + missing_index_count = session.MissingIndexCount, last_executed_utc = qsPlan.LastExecutedUtc.ToString("yyyy-MM-dd HH:mm:ss"), loaded = true }; @@ -243,6 +231,37 @@ public static async Task GetQueryStoreTop( } } + internal static PlanSession CaptureSession( + string sessionId, + string label, + ParsedPlan parsed, + string? queryText, + string connectionInfo) + { + var analysis = ResultMapper.Map(parsed, "query-store"); + var allStatements = parsed.Batches.SelectMany(batch => batch.Statements).ToList(); + var session = new PlanSession + { + SessionId = sessionId, + Label = label, + Source = "query-store", + Plan = parsed, + Analysis = analysis, + QueryText = queryText, + ConnectionInfo = connectionInfo, + StatementCount = allStatements.Count, + HasActualStats = false, + WarningCount = allStatements.Sum(statement => statement.PlanWarnings.Count), + CriticalWarningCount = allStatements.Sum(statement => + statement.PlanWarnings.Count(warning => warning.Severity == Core.Models.PlanWarningSeverity.Critical)), + MissingIndexCount = parsed.AllMissingIndexes.Count + }; + + parsed.RawXml = string.Empty; + parsed.Batches.Clear(); + return session; + } + private static Core.Models.ServerConnection? FindConnection( ConnectionStore store, string name) { diff --git a/src/PlanViewer.App/PlanViewer.App.csproj b/src/PlanViewer.App/PlanViewer.App.csproj index 373c606..9469d86 100644 --- a/src/PlanViewer.App/PlanViewer.App.csproj +++ b/src/PlanViewer.App/PlanViewer.App.csproj @@ -48,4 +48,8 @@ + + + + diff --git a/tests/PlanViewer.Core.Tests/McpPlanToolsContractTests.cs b/tests/PlanViewer.Core.Tests/McpPlanToolsContractTests.cs index e8d192d..11026b9 100644 --- a/tests/PlanViewer.Core.Tests/McpPlanToolsContractTests.cs +++ b/tests/PlanViewer.Core.Tests/McpPlanToolsContractTests.cs @@ -39,6 +39,57 @@ public void GetPlanWarnings_PreservesHistoricalInvalidSeverityBehavior() Assert.Equal("No urgent warnings found in this plan.", result); } + [Fact] + public void BoundedResponses_ExplicitlyReportTruncation() + { + var manager = new PlanSessionManager(); + var template = CreateEstimatedSession(); + var analysis = ResultMapper.Map(template.Plan, template.Source); + var statement = Assert.Single(analysis.Statements); + statement.Warnings = Enumerable.Range(0, PlanOperations.DefaultMaxWarningResults + 1) + .Select(index => new WarningResult + { + Type = $"warning-{index}", + Severity = "Warning", + Message = "bounded" + }) + .ToList(); + statement.MissingIndexes = Enumerable.Range(0, PlanOperations.DefaultMaxMissingIndexResults + 1) + .Select(index => new MissingIndexResult + { + BareTable = $"table-{index}", + Table = $"dbo.table-{index}", + CreateStatement = "CREATE INDEX" + }) + .ToList(); + var session = new CorePlanSession + { + SessionId = template.SessionId, + Label = template.Label, + Source = template.Source, + Plan = template.Plan, + Analysis = analysis, + StatementCount = template.StatementCount, + HasActualStats = template.HasActualStats, + WarningCount = analysis.Summary.TotalWarnings, + CriticalWarningCount = analysis.Summary.CriticalWarnings, + MissingIndexCount = analysis.Summary.MissingIndexes + }; + manager.Register(session); + + using var warnings = JsonDocument.Parse(McpPlanTools.GetPlanWarnings(manager, session.SessionId)); + using var indexes = JsonDocument.Parse(McpPlanTools.GetMissingIndexes(manager, session.SessionId)); + + Assert.True(warnings.RootElement.GetProperty("truncated").GetBoolean()); + Assert.Equal( + PlanOperations.DefaultMaxWarningResults, + warnings.RootElement.GetProperty("returned_warning_count").GetInt32()); + Assert.True(indexes.RootElement.GetProperty("truncated").GetBoolean()); + Assert.Equal( + PlanOperations.DefaultMaxMissingIndexResults, + indexes.RootElement.GetProperty("returned_index_count").GetInt32()); + } + [Fact] public void GetExpensiveOperators_PreservesHistoricalBareObjectName() { @@ -78,6 +129,52 @@ public void GetExpensiveOperators_UsesTheSessionSnapshotAfterLookup() } + [Fact] + public void AnalyzePlan_PreservesTheUiSessionSourceWhenASnapshotIsPresent() + { + var manager = new PlanSessionManager(); + var plan = PlanTestHelper.LoadAndAnalyze("row_goal_plan.sqlplan"); + var sessionId = $"ui-{Guid.NewGuid():N}"; + manager.Register(sessionId, new PlanViewer.App.Mcp.PlanSession + { + SessionId = sessionId, + Label = "row_goal_plan.sqlplan", + Source = "file", + Plan = plan, + Analysis = ResultMapper.Map(plan, "file") + }); + + var json = McpPlanTools.AnalyzePlan(manager, sessionId); + + using var document = JsonDocument.Parse(json); + Assert.Equal("file", document.RootElement.GetProperty("plan_source").GetString()); + Assert.DoesNotContain("error", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("error", McpPlanTools.GetPlanSummary(manager, sessionId), StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("error", McpPlanTools.GetPlanWarnings(manager, sessionId), StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("error", McpPlanTools.GetMissingIndexes(manager, sessionId), StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("error", McpPlanTools.GetExpensiveOperators(manager, sessionId), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void GivenQueryStorePlan_WhenCaptured_ThenSourceIsStableAndParserGraphIsReleased() + { + var plan = PlanTestHelper.LoadAndAnalyze("row_goal_plan.sqlplan"); + + var session = McpQueryStoreTools.CaptureSession( + "query-store-session", + "QS:database Q1 P2", + plan, + "SELECT 1", + "server"); + + Assert.Equal("query-store", session.Source); + Assert.Equal("query-store", session.Analysis?.PlanSource); + Assert.Equal("QS:database Q1 P2", session.Label); + Assert.Equal(1, session.StatementCount); + Assert.Empty(session.Plan.Batches); + Assert.Empty(session.Plan.RawXml); + } + [Fact] public void HistoricalOverloads_DelegateToTheSharedOperationsBehavior() { From 6908fe6aefaeb2a8c1c296735494b61c6df993fb Mon Sep 17 00:00:00 2001 From: autocarl Date: Wed, 15 Jul 2026 23:57:46 -0400 Subject: [PATCH 09/16] docs: align repl routing and resource guarantees --- README.md | 4 ++-- docs/repl-pilot.md | 10 +++++----- src/PlanViewer.Cli/Program.cs | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 67ddc24..b8eafcd 100644 --- a/README.md +++ b/README.md @@ -173,8 +173,8 @@ planview repl # explicit interactive alias planview mcp serve # stdio MCP server generated from the graph ``` -Bare `planview` is intentionally interactive and waits on stdin; scripts should use an -explicit legacy command, a one-shot `plan` command, or `planview --help`. +Bare `planview` preserves the legacy System.CommandLine behavior. Interactive exploration is +entered only through `planview repl`; scripts can use explicit legacy or one-shot `plan` commands. The pilot uses the stable `Repl` `0.11.0` release. Loaded plans live only for the lifetime of the process; `open` and `close` change only that in-memory catalog and never write the diff --git a/docs/repl-pilot.md b/docs/repl-pilot.md index 82d9592..1616e25 100644 --- a/docs/repl-pilot.md +++ b/docs/repl-pilot.md @@ -18,15 +18,15 @@ Read-only describes the analyzed plans and external systems: the pilot never wri Local CLI/Repl commands may open any accessible `.sqlplan` file, matching normal command-line file semantics. The generated MCP server applies a stricter policy: -- `plan_open` rejects lexically outside paths before probing them, opens an allowed candidate once, validates its kernel-resolved handle against canonical MCP roots, and parses that same handle. +- `plan_open` rejects lexically outside paths before probing them, opens an allowed candidate once, validates its kernel-resolved handle against canonical MCP roots, and parses that same handle. Windows roots use their enumerated on-disk casing and ordinal containment so case-sensitive sibling directories cannot alias an allowed root; NTFS alternate-data-stream paths are rejected. - The server launch directory is a fallback root only when native roots are unsupported and no soft roots are configured. Advertised-but-empty, invalid, or unavailable roots deny access. - MCP errors do not disclose absolute paths outside the allowed roots. - Only an explicit allow-list of plan tools is exported; automatic read-only resource promotion is disabled. -- Files are read through a hard 16 MiB streaming ceiling even if they grow during loading. A forward-only XML preflight prohibits DTDs and rejects depth over 512, more than 10,000 statement elements, or more than 100,000 operators before `XDocument` and the parser graph are materialized; the parsed graph is checked again before analysis. -- The limit of 2 concurrent opens is shared by every `PlanOperations` facade over the same catalog, and excess calls fail fast before path resolution or file acquisition. Admission is coordinated atomically across those facades, with at most 32 sessions and a 64 MiB aggregate retained-analysis estimate. The estimate is reserved after streaming preflight and conservatively accounts for UTF-16 source text, statements (2 KiB each), and operators (4 KiB each); `plan_close` releases it. -- Warning responses return at most 500 items, missing-index responses at most 100 suggestions, and operator queries at most 100 items. Result metadata reports truncation, and plan-controlled text fields are bounded before serialization. +- Files are read through a hard 16 MiB streaming ceiling even if they grow during loading. A forward-only XML preflight prohibits DTDs and rejects depth over 512, more than 10,000 structurally recognized statements/cursor operations, 100,000 operators, 250,000 total elements, or 1,000,000 total attributes before `XDocument` and the parser graph are materialized; the parsed graph is checked again before analysis. +- The limit of 2 concurrent opens is shared by every `PlanOperations` facade over the same catalog, and excess calls fail fast before path resolution or file acquisition. Admission is coordinated atomically across those facades, with at most 32 sessions and a 64 MiB aggregate retained-analysis estimate. The estimate is reserved after streaming preflight and conservatively accounts for UTF-16 source text, statements (2 KiB each), operators (4 KiB each), XML elements (256 bytes each), and attributes (128 bytes each); `plan_close` releases it. +- Warning responses return at most 500 items, missing-index responses at most 100 suggestions, and operator queries at most 100 items. The query paths count or rank by bounded top-K storage instead of materializing an additional full-tree result list. At most 4 full-tree query traversals run concurrently per catalog; excess calls fail fast. Result metadata reports truncation, and plan-controlled text fields are bounded before serialization. - `plan_close` provides explicit eviction and releases the retained-memory reservation. Session labels are capped at 48 characters and use a fresh 12-hex-character opaque suffix; active-ID collisions are retried. -- Streaming preflight, per-statement/per-operator analysis, scoring, mapping, and registration honor cancellation. Synchronous XML materialization is bounded by preflight and checks cancellation immediately after returning. +- Streaming preflight, direct bounded text decoding, per-statement/per-operator analysis, scoring, mapping, registration, and Repl query traversals honor cancellation. XML decoding reads the authorized stream directly with a pooled buffer rather than retaining a second byte-for-byte copy. ## TDD slices diff --git a/src/PlanViewer.Cli/Program.cs b/src/PlanViewer.Cli/Program.cs index f0897e0..f0b451f 100644 --- a/src/PlanViewer.Cli/Program.cs +++ b/src/PlanViewer.Cli/Program.cs @@ -22,7 +22,7 @@ // Credential storage not available — analyze-only mode still works } -var root = new RootCommand("SQL Server execution plan analyzer (run without arguments for interactive plan exploration)") +var root = new RootCommand("SQL Server execution plan analyzer (use 'planview repl' for interactive plan exploration)") { AnalyzeCommand.Create(credentialService), QueryStoreCommand.Create(credentialService), From ce896261379490a5372b6478352266ad4d2f5359 Mon Sep 17 00:00:00 2001 From: autocarl Date: Thu, 16 Jul 2026 00:10:33 -0400 Subject: [PATCH 10/16] test(core): keep cancellation analysis clean --- src/PlanViewer.Core/Services/PlanOperations.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/PlanViewer.Core/Services/PlanOperations.cs b/src/PlanViewer.Core/Services/PlanOperations.cs index b4df987..16e7d83 100644 --- a/src/PlanViewer.Core/Services/PlanOperations.cs +++ b/src/PlanViewer.Core/Services/PlanOperations.cs @@ -263,7 +263,7 @@ internal MissingIndexesResult GetMissingIndexes( using var querySlot = _budget.AcquireQuerySlot(cancellationToken); var indexes = new List(DefaultMaxMissingIndexResults); var totalIndexCount = 0; - foreach (var statement in GetAnalysis(session, cancellationToken).Statements) + foreach (var statement in GetAnalysisCancellable(session, cancellationToken).Statements) { cancellationToken.ThrowIfCancellationRequested(); foreach (var index in statement.MissingIndexes) @@ -328,7 +328,7 @@ internal ExpensiveOperatorsResult GetExpensiveOperators( throw new ArgumentOutOfRangeException(nameof(top), top, "Top must be between 1 and 100."); using var querySlot = _budget.AcquireQuerySlot(cancellationToken); - var analysis = GetAnalysis(session, cancellationToken); + var analysis = GetAnalysisCancellable(session, cancellationToken); var topByActual = new List(top); var topByCost = new List(top); var hasActuals = false; @@ -407,7 +407,7 @@ internal PlanWarningsResult GetWarnings( } using var querySlot = _budget.AcquireQuerySlot(cancellationToken); - var analysis = GetAnalysis(session, cancellationToken); + var analysis = GetAnalysisCancellable(session, cancellationToken); var returnedWarnings = new List(DefaultMaxWarningResults); var totalWarningCount = 0; foreach (var statement in analysis.Statements) @@ -464,7 +464,7 @@ public AnalysisResult GetAnalysis(PlanSession session) return session.Analysis ?? ResultMapper.Map(session.Plan, session.Source); } - private static AnalysisResult GetAnalysis( + private static AnalysisResult GetAnalysisCancellable( PlanSession session, CancellationToken cancellationToken) => session.Analysis ?? ResultMapper.MapCancellable(session.Plan, session.Source, metadata: null, cancellationToken); From 490ad95ced3d7918becdf9030b9d641dd0d69730 Mon Sep 17 00:00:00 2001 From: autocarl Date: Thu, 16 Jul 2026 00:16:56 -0400 Subject: [PATCH 11/16] fix(core): bind preflight to decoded plan bytes --- docs/repl-pilot.md | 2 +- .../Services/PlanOperations.cs | 23 +++++++++++++++---- .../Services/PlanXmlPreflight.cs | 21 ++++++++++++++--- .../PlanXmlPreflightTests.cs | 17 ++++++++++++++ 4 files changed, 55 insertions(+), 8 deletions(-) diff --git a/docs/repl-pilot.md b/docs/repl-pilot.md index 1616e25..e087917 100644 --- a/docs/repl-pilot.md +++ b/docs/repl-pilot.md @@ -22,7 +22,7 @@ Local CLI/Repl commands may open any accessible `.sqlplan` file, matching normal - The server launch directory is a fallback root only when native roots are unsupported and no soft roots are configured. Advertised-but-empty, invalid, or unavailable roots deny access. - MCP errors do not disclose absolute paths outside the allowed roots. - Only an explicit allow-list of plan tools is exported; automatic read-only resource promotion is disabled. -- Files are read through a hard 16 MiB streaming ceiling even if they grow during loading. A forward-only XML preflight prohibits DTDs and rejects depth over 512, more than 10,000 structurally recognized statements/cursor operations, 100,000 operators, 250,000 total elements, or 1,000,000 total attributes before `XDocument` and the parser graph are materialized; the parsed graph is checked again before analysis. +- Files are read through a hard 16 MiB streaming ceiling even if they grow during loading. A forward-only XML preflight prohibits DTDs and rejects depth over 512, more than 10,000 structurally recognized statements/cursor operations, 100,000 operators, 250,000 total elements, or 1,000,000 total attributes before `XDocument` and the parser graph are materialized; the parsed graph is checked again before analysis. Streaming SHA-256 values bind the preflight and decoded passes to identical bytes, rejecting in-place content changes before materialization. - The limit of 2 concurrent opens is shared by every `PlanOperations` facade over the same catalog, and excess calls fail fast before path resolution or file acquisition. Admission is coordinated atomically across those facades, with at most 32 sessions and a 64 MiB aggregate retained-analysis estimate. The estimate is reserved after streaming preflight and conservatively accounts for UTF-16 source text, statements (2 KiB each), operators (4 KiB each), XML elements (256 bytes each), and attributes (128 bytes each); `plan_close` releases it. - Warning responses return at most 500 items, missing-index responses at most 100 suggestions, and operator queries at most 100 items. The query paths count or rank by bounded top-K storage instead of materializing an additional full-tree result list. At most 4 full-tree query traversals run concurrently per catalog; excess calls fail fast. Result metadata reports truncation, and plan-controlled text fields are bounded before serialization. - `plan_close` provides explicit eviction and releases the retained-memory reservation. Session labels are capped at 48 characters and use a fresh 12-hex-character opaque suffix; active-ID collisions are retried. diff --git a/src/PlanViewer.Core/Services/PlanOperations.cs b/src/PlanViewer.Core/Services/PlanOperations.cs index 16e7d83..0bb1a58 100644 --- a/src/PlanViewer.Core/Services/PlanOperations.cs +++ b/src/PlanViewer.Core/Services/PlanOperations.cs @@ -1,4 +1,5 @@ using System.Buffers; +using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; using PlanViewer.Core.Interfaces; @@ -118,8 +119,11 @@ private async Task OpenStreamCoreAsync( var preflight = await PlanXmlPreflight.ValidateAsync(stream, cancellationToken).ConfigureAwait(false); using var retainedEstimate = _budget.ReserveRetainedEstimate( EstimateRetainedAnalysisBytes(stream.Length, preflight)); - var planXml = await ReadPlanXmlAsync(stream, cancellationToken).ConfigureAwait(false); + var decodedPlan = await ReadPlanXmlAsync(stream, cancellationToken).ConfigureAwait(false); + if (!CryptographicOperations.FixedTimeEquals(preflight.ContentHash, decodedPlan.ContentHash)) + throw new InvalidDataException("Plan file changed while it was being read."); cancellationToken.ThrowIfCancellationRequested(); + var planXml = decodedPlan.Content; if (string.IsNullOrWhiteSpace(planXml)) throw new InvalidDataException("Plan file is empty."); @@ -176,13 +180,19 @@ private static long EstimateRetainedAnalysisBytes( ((long)preflight.ElementCount * 256) + ((long)preflight.AttributeCount * 128)); - private static async Task ReadPlanXmlAsync( + private static async Task ReadPlanXmlAsync( FileStream stream, CancellationToken cancellationToken) { stream.Position = 0; - using var reader = new StreamReader( + using var sha256 = SHA256.Create(); + await using var hashingStream = new CryptoStream( stream, + sha256, + CryptoStreamMode.Read, + leaveOpen: true); + using var reader = new StreamReader( + hashingStream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 64 * 1024, @@ -208,7 +218,10 @@ private static async Task ReadPlanXmlAsync( content.Append(buffer, 0, charactersRead); } - return content.ToString(); + return new DecodedPlan( + content.ToString(), + sha256.Hash?.ToArray() + ?? throw new InvalidDataException("Could not hash the decoded plan XML.")); } finally { @@ -216,6 +229,8 @@ private static async Task ReadPlanXmlAsync( } } + private readonly record struct DecodedPlan(string Content, byte[] ContentHash); + public bool Close(string sessionId) { ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); diff --git a/src/PlanViewer.Core/Services/PlanXmlPreflight.cs b/src/PlanViewer.Core/Services/PlanXmlPreflight.cs index b003de8..e5a9ce7 100644 --- a/src/PlanViewer.Core/Services/PlanXmlPreflight.cs +++ b/src/PlanViewer.Core/Services/PlanXmlPreflight.cs @@ -1,3 +1,4 @@ +using System.Security.Cryptography; using System.Xml; namespace PlanViewer.Core.Services; @@ -6,7 +7,8 @@ internal readonly record struct PlanXmlPreflightResult( int StatementCount, int OperatorCount, int ElementCount, - int AttributeCount); + int AttributeCount, + byte[] ContentHash); internal static class PlanXmlPreflight { @@ -45,7 +47,13 @@ internal static async Task ValidateAsync( stream.Position = 0; try { - using var reader = XmlReader.Create(stream, settings); + using var sha256 = SHA256.Create(); + await using var hashingStream = new CryptoStream( + stream, + sha256, + CryptoStreamMode.Read, + leaveOpen: true); + using var reader = XmlReader.Create(hashingStream, settings); while (await reader.ReadAsync().ConfigureAwait(false)) { cancellationToken.ThrowIfCancellationRequested(); @@ -121,6 +129,14 @@ internal static async Task ValidateAsync( if (!reader.IsEmptyElement) ancestry.Push((reader.LocalName, reader.NamespaceURI)); } + + return new PlanXmlPreflightResult( + statements, + operators, + elements, + attributes, + sha256.Hash?.ToArray() + ?? throw new InvalidDataException("Could not hash the plan XML during preflight.")); } catch (XmlException exception) { @@ -131,6 +147,5 @@ internal static async Task ValidateAsync( stream.Position = 0; } - return new PlanXmlPreflightResult(statements, operators, elements, attributes); } } diff --git a/tests/PlanViewer.Core.Tests/PlanXmlPreflightTests.cs b/tests/PlanViewer.Core.Tests/PlanXmlPreflightTests.cs index 5765c77..ab46e64 100644 --- a/tests/PlanViewer.Core.Tests/PlanXmlPreflightTests.cs +++ b/tests/PlanViewer.Core.Tests/PlanXmlPreflightTests.cs @@ -1,3 +1,4 @@ +using System.Security.Cryptography; using System.Text; using PlanViewer.Core.Services; @@ -5,6 +6,22 @@ namespace PlanViewer.Core.Tests; public sealed class PlanXmlPreflightTests { + [Fact] + public async Task ValidateAsync_HashesTheExactPreflightBytes() + { + var path = Path.GetFullPath(Path.Combine("Plans", "row_goal_plan.sqlplan")); + var expected = SHA256.HashData(await File.ReadAllBytesAsync( + path, + TestContext.Current.CancellationToken)); + await using var stream = OpenPlan(path); + + var result = await PlanXmlPreflight.ValidateAsync( + stream, + TestContext.Current.CancellationToken); + + Assert.Equal(expected, result.ContentHash); + } + [Fact] public async Task ValidateAsync_RejectsStatementBudgetBeforeMaterialization() { From 7b1dadc3c70de4ecb80942114f3c7b85ac45edf1 Mon Sep 17 00:00:00 2001 From: autocarl Date: Thu, 16 Jul 2026 00:18:47 -0400 Subject: [PATCH 12/16] fix(mcp): bound client roots negotiation --- docs/repl-pilot.md | 2 +- .../ReplSurface/McpPlanPathPolicy.cs | 19 +++++++++-- .../McpPlanPathPolicyTests.cs | 32 +++++++++++++++++++ 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/docs/repl-pilot.md b/docs/repl-pilot.md index e087917..7d9512c 100644 --- a/docs/repl-pilot.md +++ b/docs/repl-pilot.md @@ -19,7 +19,7 @@ Read-only describes the analyzed plans and external systems: the pilot never wri Local CLI/Repl commands may open any accessible `.sqlplan` file, matching normal command-line file semantics. The generated MCP server applies a stricter policy: - `plan_open` rejects lexically outside paths before probing them, opens an allowed candidate once, validates its kernel-resolved handle against canonical MCP roots, and parses that same handle. Windows roots use their enumerated on-disk casing and ordinal containment so case-sensitive sibling directories cannot alias an allowed root; NTFS alternate-data-stream paths are rejected. -- The server launch directory is a fallback root only when native roots are unsupported and no soft roots are configured. Advertised-but-empty, invalid, or unavailable roots deny access. +- The server launch directory is a fallback root only when native roots are unsupported and no soft roots are configured. Advertised-but-empty, invalid, unavailable, or unresponsive roots deny access; native roots negotiation has a 5-second server-side deadline. - MCP errors do not disclose absolute paths outside the allowed roots. - Only an explicit allow-list of plan tools is exported; automatic read-only resource promotion is disabled. - Files are read through a hard 16 MiB streaming ceiling even if they grow during loading. A forward-only XML preflight prohibits DTDs and rejects depth over 512, more than 10,000 structurally recognized statements/cursor operations, 100,000 operators, 250,000 total elements, or 1,000,000 total attributes before `XDocument` and the parser graph are materialized; the parsed graph is checked again before analysis. Streaming SHA-256 values bind the preflight and decoded passes to identical bytes, rejecting in-place content changes before materialization. diff --git a/src/PlanViewer.Cli/ReplSurface/McpPlanPathPolicy.cs b/src/PlanViewer.Cli/ReplSurface/McpPlanPathPolicy.cs index 009645f..7781bc8 100644 --- a/src/PlanViewer.Cli/ReplSurface/McpPlanPathPolicy.cs +++ b/src/PlanViewer.Cli/ReplSurface/McpPlanPathPolicy.cs @@ -4,15 +4,21 @@ namespace PlanViewer.Cli.ReplSurface; internal static class McpPlanPathPolicy { + private static readonly TimeSpan DefaultRootsTimeout = TimeSpan.FromSeconds(5); + public static async ValueTask OpenAsync( string path, IMcpClientRoots clientRoots, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + TimeSpan? rootsTimeout = null) { ArgumentException.ThrowIfNullOrWhiteSpace(path); ArgumentNullException.ThrowIfNull(clientRoots); - var roots = await GetEffectiveRootsAsync(clientRoots, cancellationToken).ConfigureAwait(false); + var timeout = rootsTimeout ?? DefaultRootsTimeout; + if (timeout <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(rootsTimeout), "The client-roots timeout must be positive."); + var roots = await GetEffectiveRootsAsync(clientRoots, timeout, cancellationToken).ConfigureAwait(false); var candidates = Path.IsPathFullyQualified(path) ? [path] : roots.Select(root => Path.Combine(root, path)); @@ -71,15 +77,22 @@ public static async ValueTask OpenAsync( private static async ValueTask> GetEffectiveRootsAsync( IMcpClientRoots clientRoots, + TimeSpan timeout, CancellationToken cancellationToken) { if (!clientRoots.IsSupported && !clientRoots.HasSoftRoots) return [ResolveExistingDirectory(Directory.GetCurrentDirectory())]; IReadOnlyList advertisedRoots; + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutSource.CancelAfter(timeout); try { - advertisedRoots = await clientRoots.GetAsync(cancellationToken).ConfigureAwait(false); + advertisedRoots = await clientRoots.GetAsync(timeoutSource.Token).ConfigureAwait(false); + } + catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested) + { + throw new UnauthorizedAccessException("Client roots negotiation timed out.", exception); } catch (OperationCanceledException) { diff --git a/tests/PlanViewer.Core.Tests/McpPlanPathPolicyTests.cs b/tests/PlanViewer.Core.Tests/McpPlanPathPolicyTests.cs index 0ffbdc5..67b6109 100644 --- a/tests/PlanViewer.Core.Tests/McpPlanPathPolicyTests.cs +++ b/tests/PlanViewer.Core.Tests/McpPlanPathPolicyTests.cs @@ -18,6 +18,21 @@ public void ContainsWindowsAlternateDataStream_DistinguishesTheDriveDesignator( Assert.Equal(expected, McpPlanPathPolicy.ContainsWindowsAlternateDataStream(path)); } + [Fact] + public async Task OpenAsync_TimesOutUnresponsiveNativeRoots() + { + var roots = new HangingClientRoots(); + + var exception = await Assert.ThrowsAsync(async () => + await McpPlanPathPolicy.OpenAsync( + "probe.sqlplan", + roots, + TestContext.Current.CancellationToken, + TimeSpan.FromMilliseconds(20))); + + Assert.Contains("timed out", exception.Message, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task OpenAsync_DeniesAdvertisedEmptyRoots() { @@ -131,6 +146,23 @@ await File.ReadAllTextAsync( } } + private sealed class HangingClientRoots : IMcpClientRoots + { + public bool IsSupported => true; + public bool HasSoftRoots => false; + public IReadOnlyList Current => []; + + public async ValueTask> GetAsync( + CancellationToken cancellationToken = default) + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return []; + } + + public void SetSoftRoots(IEnumerable roots) => throw new NotSupportedException(); + public void ClearSoftRoots() => throw new NotSupportedException(); + } + private sealed class StubClientRoots( bool isSupported, bool hasSoftRoots, From 03026e3947d50275241b819d22351162e2d30e12 Mon Sep 17 00:00:00 2001 From: autocarl Date: Thu, 16 Jul 2026 00:24:53 -0400 Subject: [PATCH 13/16] fix(app): preserve query store tool behavior --- src/PlanViewer.App/Mcp/McpPlanTools.cs | 52 ++++++++--------- src/PlanViewer.App/Mcp/McpQueryStoreTools.cs | 3 + src/PlanViewer.App/Mcp/PlanSessionManager.cs | 2 + .../McpPlanToolsContractTests.cs | 56 ++++++++++++++----- 4 files changed, 72 insertions(+), 41 deletions(-) diff --git a/src/PlanViewer.App/Mcp/McpPlanTools.cs b/src/PlanViewer.App/Mcp/McpPlanTools.cs index ad48575..0cb0368 100644 --- a/src/PlanViewer.App/Mcp/McpPlanTools.cs +++ b/src/PlanViewer.App/Mcp/McpPlanTools.cs @@ -199,20 +199,19 @@ public static string GetPlanParameters( if (session == null) return SessionNotFound(sessionManager, session_id); - var statements = session.Plan.Batches - .SelectMany(b => b.Statements) - .Where(s => s.Parameters.Count > 0) - .Select(s => new + var statements = GetAnalysisSnapshot(session).Statements + .Where(statement => statement.Parameters.Count > 0) + .Select(statement => new { - statement = McpHelpers.Truncate(s.StatementText, 200), - parameters = s.Parameters.Select(p => new + statement = McpHelpers.Truncate(statement.StatementText, 200), + parameters = statement.Parameters.Select(parameter => new { - name = p.Name, - data_type = p.DataType, - compiled_value = p.CompiledValue, - runtime_value = p.RuntimeValue, - sniffing_mismatch = p.CompiledValue != null && p.RuntimeValue != null - && p.CompiledValue != p.RuntimeValue + name = parameter.Name, + data_type = parameter.DataType, + compiled_value = parameter.CompiledValue, + runtime_value = parameter.RuntimeValue, + sniffing_mismatch = parameter.CompiledValue != null && parameter.RuntimeValue != null + && parameter.CompiledValue != parameter.RuntimeValue }) }) .ToList(); @@ -256,7 +255,7 @@ public static string GetPlanXml( if (session == null) return SessionNotFound(sessionManager, session_id); - return McpHelpers.Truncate(session.Plan.RawXml, 512_000) ?? "No plan XML available."; + return McpHelpers.Truncate(GetRawPlanXml(session), 512_000) ?? "No plan XML available."; } [McpServerTool(Name = "compare_plans")] @@ -277,8 +276,8 @@ public static string ComparePlans( try { - var resultA = ResultMapper.Map(sessionA.Plan, sessionA.Source); - var resultB = ResultMapper.Map(sessionB.Plan, sessionB.Source); + var resultA = GetAnalysisSnapshot(sessionA); + var resultB = GetAnalysisSnapshot(sessionB); return ComparisonFormatter.Compare(resultA, resultB, sessionA.Label, sessionB.Label); } catch (Exception ex) @@ -300,22 +299,17 @@ public static string GetReproScript( try { - var stmt = session.Plan.Batches - .SelectMany(b => b.Statements) - .FirstOrDefault(s => s.RootNode != null); + var statement = GetAnalysisSnapshot(session).Statements + .FirstOrDefault(candidate => candidate.OperatorTree is not null); - if (stmt == null) + if (statement is null) return "No executable statement found in this plan."; - var queryText = session.QueryText ?? stmt.StatementText ?? ""; - - // Extract database from first operator node's DatabaseName property - string? databaseName = null; - if (stmt.RootNode?.DatabaseName != null) - databaseName = stmt.RootNode.DatabaseName; + var queryText = session.QueryText ?? statement.StatementText ?? ""; + var databaseName = session.CapturedDatabaseName ?? statement.OperatorTree?.DatabaseName; return ReproScriptBuilder.BuildReproScript( - queryText, databaseName, session.Plan.RawXml, null); + queryText, databaseName, GetRawPlanXml(session), null); } catch (Exception ex) { @@ -323,6 +317,12 @@ public static string GetReproScript( } } + private static AnalysisResult GetAnalysisSnapshot(PlanSession session) => + session.Analysis ?? ResultMapper.Map(session.Plan, session.Source); + + private static string? GetRawPlanXml(PlanSession session) => + session.CapturedRawXml ?? session.Plan.RawXml; + private static string SessionNotFound(PlanSessionManager sessionManager, string sessionId) { var available = sessionManager.GetAllSessions(); diff --git a/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs b/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs index cc9f2a0..67c9f3f 100644 --- a/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs +++ b/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs @@ -240,6 +240,7 @@ internal static PlanSession CaptureSession( { var analysis = ResultMapper.Map(parsed, "query-store"); var allStatements = parsed.Batches.SelectMany(batch => batch.Statements).ToList(); + var executableStatement = allStatements.FirstOrDefault(statement => statement.RootNode is not null); var session = new PlanSession { SessionId = sessionId, @@ -247,6 +248,8 @@ internal static PlanSession CaptureSession( Source = "query-store", Plan = parsed, Analysis = analysis, + CapturedRawXml = parsed.RawXml, + CapturedDatabaseName = executableStatement?.RootNode?.DatabaseName, QueryText = queryText, ConnectionInfo = connectionInfo, StatementCount = allStatements.Count, diff --git a/src/PlanViewer.App/Mcp/PlanSessionManager.cs b/src/PlanViewer.App/Mcp/PlanSessionManager.cs index 6e4e2cf..ef4776b 100644 --- a/src/PlanViewer.App/Mcp/PlanSessionManager.cs +++ b/src/PlanViewer.App/Mcp/PlanSessionManager.cs @@ -66,6 +66,8 @@ public sealed class PlanSession public required string Source { get; init; } public required ParsedPlan Plan { get; init; } public AnalysisResult? Analysis { get; init; } + internal string? CapturedRawXml { get; init; } + internal string? CapturedDatabaseName { get; init; } public string? QueryText { get; init; } public string? ConnectionInfo { get; init; } public int StatementCount { get; init; } diff --git a/tests/PlanViewer.Core.Tests/McpPlanToolsContractTests.cs b/tests/PlanViewer.Core.Tests/McpPlanToolsContractTests.cs index 11026b9..52d8154 100644 --- a/tests/PlanViewer.Core.Tests/McpPlanToolsContractTests.cs +++ b/tests/PlanViewer.Core.Tests/McpPlanToolsContractTests.cs @@ -156,23 +156,49 @@ public void AnalyzePlan_PreservesTheUiSessionSourceWhenASnapshotIsPresent() } [Fact] - public void GivenQueryStorePlan_WhenCaptured_ThenSourceIsStableAndParserGraphIsReleased() + public void GivenQueryStorePlan_WhenCaptured_ThenLegacyToolsRemainExactWithoutTheParserGraph() { - var plan = PlanTestHelper.LoadAndAnalyze("row_goal_plan.sqlplan"); - - var session = McpQueryStoreTools.CaptureSession( - "query-store-session", - "QS:database Q1 P2", - plan, - "SELECT 1", + const string sessionId = "query-store-session"; + const string label = "QS:database Q1 P2"; + const string queryText = "SELECT * FROM dbo.PostTypes WHERE Id = @PostTypeId"; + var legacyPlan = PlanTestHelper.LoadAndAnalyze("param-sniffing-posttypeid2.sqlplan"); + var capturedPlan = PlanTestHelper.LoadAndAnalyze("param-sniffing-posttypeid2.sqlplan"); + var legacyManager = new PlanSessionManager(); + legacyManager.Register(sessionId, new PlanViewer.App.Mcp.PlanSession + { + SessionId = sessionId, + Label = label, + Source = "query-store", + Plan = legacyPlan, + QueryText = queryText + }); + var capturedManager = new PlanSessionManager(); + var captured = McpQueryStoreTools.CaptureSession( + sessionId, + label, + capturedPlan, + queryText, "server"); - - Assert.Equal("query-store", session.Source); - Assert.Equal("query-store", session.Analysis?.PlanSource); - Assert.Equal("QS:database Q1 P2", session.Label); - Assert.Equal(1, session.StatementCount); - Assert.Empty(session.Plan.Batches); - Assert.Empty(session.Plan.RawXml); + capturedManager.Register(sessionId, captured); + + Assert.Equal("query-store", captured.Source); + Assert.Equal("query-store", captured.Analysis?.PlanSource); + Assert.Equal(label, captured.Label); + Assert.False(string.IsNullOrEmpty(captured.CapturedRawXml)); + Assert.Empty(captured.Plan.Batches); + Assert.Empty(captured.Plan.RawXml); + Assert.Equal( + McpPlanTools.GetPlanParameters(legacyManager, sessionId), + McpPlanTools.GetPlanParameters(capturedManager, sessionId)); + Assert.Equal( + McpPlanTools.GetPlanXml(legacyManager, sessionId), + McpPlanTools.GetPlanXml(capturedManager, sessionId)); + Assert.Equal( + McpPlanTools.ComparePlans(legacyManager, sessionId, sessionId), + McpPlanTools.ComparePlans(capturedManager, sessionId, sessionId)); + Assert.Equal( + McpPlanTools.GetReproScript(legacyManager, sessionId), + McpPlanTools.GetReproScript(capturedManager, sessionId)); } [Fact] From dc03dc1bf4c12e3938842266ee3502d466a92ecc Mon Sep 17 00:00:00 2001 From: autocarl Date: Thu, 16 Jul 2026 00:41:09 -0400 Subject: [PATCH 14/16] fix(core): propagate cancellation through plan parsing --- src/PlanViewer.App/Mcp/McpPlanTools.cs | 27 ++- .../Services/PlanAnalysisPipeline.cs | 16 ++ .../Services/PlanOperations.cs | 16 +- .../Services/PlanXmlPreflight.cs | 6 +- .../Services/ShowPlanParser.RelOp.cs | 20 ++- .../Services/ShowPlanParser.cs | 161 ++++++++++++------ .../McpPlanToolsContractTests.cs | 28 ++- .../PlanOperationsTests.cs | 16 ++ .../PlanXmlPreflightTests.cs | 25 +++ 9 files changed, 247 insertions(+), 68 deletions(-) diff --git a/src/PlanViewer.App/Mcp/McpPlanTools.cs b/src/PlanViewer.App/Mcp/McpPlanTools.cs index 0cb0368..07007a8 100644 --- a/src/PlanViewer.App/Mcp/McpPlanTools.cs +++ b/src/PlanViewer.App/Mcp/McpPlanTools.cs @@ -3,6 +3,7 @@ using System.ComponentModel; using System.Linq; using System.Text.Json; +using System.Threading; using ModelContextProtocol.Server; using PlanViewer.App.Services; using PlanViewer.Core.Models; @@ -79,7 +80,8 @@ public static string GetConnections(ConnectionStore connectionStore) public static string AnalyzePlan( PlanSessionManager sessionManager, PlanOperations operations, - [Description("The session_id from list_plans.")] string session_id) + [Description("The session_id from list_plans.")] string session_id, + CancellationToken cancellationToken = default) { var session = sessionManager.GetSession(session_id); if (session == null) @@ -87,8 +89,15 @@ public static string AnalyzePlan( try { - var result = operations.GetAnalysis(session); - return JsonSerializer.Serialize(result, McpHelpers.JsonOptions); + using var queryScope = operations.AcquireQueryScope(cancellationToken); + var result = operations.GetAnalysisForRequest(session, cancellationToken); + var json = JsonSerializer.Serialize(result, McpHelpers.JsonOptions); + cancellationToken.ThrowIfCancellationRequested(); + return json; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; } catch (Exception ex) { @@ -102,7 +111,8 @@ public static string AnalyzePlan( public static string GetPlanSummary( PlanSessionManager sessionManager, PlanOperations operations, - [Description("The session_id from list_plans.")] string session_id) + [Description("The session_id from list_plans.")] string session_id, + CancellationToken cancellationToken = default) { var session = sessionManager.GetSession(session_id); if (session == null) @@ -110,7 +120,14 @@ public static string GetPlanSummary( try { - return TextFormatter.Format(operations.GetAnalysis(session)); + using var queryScope = operations.AcquireQueryScope(cancellationToken); + var summary = TextFormatter.Format(operations.GetAnalysisForRequest(session, cancellationToken)); + cancellationToken.ThrowIfCancellationRequested(); + return summary; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; } catch (Exception ex) { diff --git a/src/PlanViewer.Core/Services/PlanAnalysisPipeline.cs b/src/PlanViewer.Core/Services/PlanAnalysisPipeline.cs index dbc4c92..f13b80d 100644 --- a/src/PlanViewer.Core/Services/PlanAnalysisPipeline.cs +++ b/src/PlanViewer.Core/Services/PlanAnalysisPipeline.cs @@ -33,6 +33,22 @@ internal static ParsedPlan Analyze( return AnalyzeParsed(plan, config, serverMetadata, cancellationToken, beforeAnalysis); } + internal static async Task AnalyzeAsync( + string planXml, + AnalyzerConfig config, + ServerMetadata? serverMetadata, + CancellationToken cancellationToken, + Action? beforeAnalysis) + { + ArgumentNullException.ThrowIfNull(planXml); + ArgumentNullException.ThrowIfNull(config); + cancellationToken.ThrowIfCancellationRequested(); + + var plan = await ShowPlanParser.ParseAsync(planXml, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + return AnalyzeParsed(plan, config, serverMetadata, cancellationToken, beforeAnalysis); + } + internal static ParsedPlan AnalyzeParsed( ParsedPlan plan, AnalyzerConfig config, diff --git a/src/PlanViewer.Core/Services/PlanOperations.cs b/src/PlanViewer.Core/Services/PlanOperations.cs index 0bb1a58..2f58a0b 100644 --- a/src/PlanViewer.Core/Services/PlanOperations.cs +++ b/src/PlanViewer.Core/Services/PlanOperations.cs @@ -127,12 +127,12 @@ private async Task OpenStreamCoreAsync( if (string.IsNullOrWhiteSpace(planXml)) throw new InvalidDataException("Plan file is empty."); - var plan = PlanAnalysisPipeline.Analyze( + var plan = await PlanAnalysisPipeline.AnalyzeAsync( planXml, _config, serverMetadata: null, cancellationToken, - ValidateComplexity); + ValidateComplexity).ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(plan.ParseError)) throw new InvalidDataException($"Could not parse plan XML: {plan.ParseError}"); if (!plan.Batches.SelectMany(batch => batch.Statements).Any()) @@ -470,6 +470,18 @@ void AddWarning(WarningResult warning, string statementText) } } + internal IDisposable AcquireQueryScope(CancellationToken cancellationToken) => + _budget.AcquireQuerySlot(cancellationToken); + + internal AnalysisResult GetAnalysisForRequest( + PlanSession session, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(session); + cancellationToken.ThrowIfCancellationRequested(); + return GetAnalysisCancellable(session, cancellationToken); + } + public AnalysisResult GetAnalysis(string sessionId) => GetAnalysis(GetRequiredSession(sessionId)); diff --git a/src/PlanViewer.Core/Services/PlanXmlPreflight.cs b/src/PlanViewer.Core/Services/PlanXmlPreflight.cs index e5a9ce7..339aa58 100644 --- a/src/PlanViewer.Core/Services/PlanXmlPreflight.cs +++ b/src/PlanViewer.Core/Services/PlanXmlPreflight.cs @@ -102,11 +102,15 @@ internal static async Task ValidateAsync( reader.LocalName == "Operation" && parent.LocalName == "CursorPlan" && parent.NamespaceUri == ShowPlanNamespace; + var isConditionalStatement = + isShowPlanElement && + parent.LocalName == "Condition" && + parent.NamespaceUri == ShowPlanNamespace; var isFallbackStatement = isShowPlanElement && reader.LocalName == "StmtSimple" && !isDirectStatement; - if (isDirectStatement || isCursorOperation || isFallbackStatement) + if (isDirectStatement || isCursorOperation || isConditionalStatement || isFallbackStatement) { statements++; if (statements > PlanOperations.DefaultMaxStatements) diff --git a/src/PlanViewer.Core/Services/ShowPlanParser.RelOp.cs b/src/PlanViewer.Core/Services/ShowPlanParser.RelOp.cs index e47bf19..0cb9c4e 100644 --- a/src/PlanViewer.Core/Services/ShowPlanParser.RelOp.cs +++ b/src/PlanViewer.Core/Services/ShowPlanParser.RelOp.cs @@ -9,8 +9,12 @@ namespace PlanViewer.Core.Services; public static partial class ShowPlanParser { - private static PlanNode ParseRelOp(XElement relOpEl, int depth = 0) + private static PlanNode ParseRelOp( + XElement relOpEl, + int depth = 0, + CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); if (depth > MaxParseDepth) throw new InvalidOperationException("Plan operator nesting exceeds the supported depth limit."); @@ -132,7 +136,7 @@ private static PlanNode ParseRelOp(XElement relOpEl, int depth = 0) // Recurse into child RelOps foreach (var childRelOp in FindChildRelOps(relOpEl)) { - var childNode = ParseRelOp(childRelOp, depth + 1); + var childNode = ParseRelOp(childRelOp, depth + 1, cancellationToken); childNode.Parent = node; node.Children.Add(childNode); } @@ -242,8 +246,12 @@ private static void ParseOperatorProperties(PlanNode node, XElement relOpEl, XEl { var op = scanType switch { - "EQ" => "=", "GT" => ">", "GE" => ">=", - "LT" => "<", "LE" => "<=", _ => scanType ?? "=" + "EQ" => "=", + "GT" => ">", + "GE" => ">=", + "LT" => "<", + "LE" => "<=", + _ => scanType ?? "=" }; for (int ci = 0; ci < cols.Count && ci < exprs.Count; ci++) seekParts.Add($"{cols[ci]} {op} {exprs[ci]}"); @@ -385,8 +393,8 @@ private static void ParseOperatorProperties(PlanNode node, XElement relOpEl, XEl var isHeap = node.IndexKind?.Equals("Heap", StringComparison.OrdinalIgnoreCase) == true || node.PhysicalOp.StartsWith("RID Lookup", StringComparison.OrdinalIgnoreCase); node.PhysicalOp = isHeap ? "RID Lookup (Heap)" : "Key Lookup (Clustered)"; - node.LogicalOp = isHeap ? "RID Lookup" : "Key Lookup"; - node.IconName = isHeap ? "rid_lookup" : "bookmark_lookup"; + node.LogicalOp = isHeap ? "RID Lookup" : "Key Lookup"; + node.IconName = isHeap ? "rid_lookup" : "bookmark_lookup"; } // Table cardinality and rows to be read (on per XSD) diff --git a/src/PlanViewer.Core/Services/ShowPlanParser.cs b/src/PlanViewer.Core/Services/ShowPlanParser.cs index 61cf643..b5fdc2f 100644 --- a/src/PlanViewer.Core/Services/ShowPlanParser.cs +++ b/src/PlanViewer.Core/Services/ShowPlanParser.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Xml; using System.Xml.Linq; using PlanViewer.Core.Models; @@ -15,72 +16,116 @@ public static partial class ShowPlanParser // maliciously deep tree throws a catchable exception instead of an uncatchable // StackOverflowException that takes the whole process down. private const int MaxParseDepth = 1000; + private const int MaxParseCharacters = 16 * 1024 * 1024; public static ParsedPlan Parse(string xml) { var plan = new ParsedPlan { RawXml = xml }; + try + { + return ParseDocument(XDocument.Parse(xml), plan, CancellationToken.None); + } + catch (Exception exception) + { + plan.ParseError = exception.Message; + return plan; + } + } - XDocument doc; + internal static async Task ParseAsync( + string xml, + CancellationToken cancellationToken) + { + var plan = new ParsedPlan { RawXml = xml }; try { - doc = XDocument.Parse(xml); + var settings = new XmlReaderSettings + { + Async = true, + DtdProcessing = DtdProcessing.Prohibit, + MaxCharactersInDocument = MaxParseCharacters, + XmlResolver = null + }; + using var textReader = new StringReader(xml); + using var xmlReader = XmlReader.Create(textReader, settings); + var document = await XDocument + .LoadAsync(xmlReader, LoadOptions.None, cancellationToken) + .ConfigureAwait(false); + return ParseDocument(document, plan, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; } - catch (Exception ex) + catch (Exception exception) { - plan.ParseError = ex.Message; + plan.ParseError = exception.Message; return plan; } + } - // The tree walk below can throw on hostile/malformed plans (including the depth - // guards in ParseRelOp/ParseStatementAndChildren that stop unbounded recursion). - // Contain it so a bad plan becomes a ParseError, never a crash for the caller. + private static ParsedPlan ParseDocument( + XDocument document, + ParsedPlan plan, + CancellationToken cancellationToken) + { try { - var root = doc.Root; - if (root == null) return plan; + cancellationToken.ThrowIfCancellationRequested(); + var root = document.Root; + if (root is null) + return plan; - plan.BuildVersion = root.Attribute("Version")?.Value; - plan.Build = root.Attribute("Build")?.Value; - plan.ClusteredMode = root.Attribute("ClusteredMode")?.Value is "true" or "1"; + plan.BuildVersion = root.Attribute("Version")?.Value; + plan.Build = root.Attribute("Build")?.Value; + plan.ClusteredMode = root.Attribute("ClusteredMode")?.Value is "true" or "1"; - // Standard path: ShowPlanXML → BatchSequence → Batch → Statements - var batches = root.Descendants(Ns + "Batch"); - foreach (var batchEl in batches) - { - var batch = new PlanBatch(); - // A Batch can contain multiple elements (e.g., DECLARE + SELECT). - // Use Elements() to iterate all of them, not just the first. - foreach (var statementsEl in batchEl.Elements(Ns + "Statements")) + // Standard path: ShowPlanXML → BatchSequence → Batch → Statements + foreach (var batchEl in root.Descendants(Ns + "Batch")) { - foreach (var stmtEl in statementsEl.Elements()) + cancellationToken.ThrowIfCancellationRequested(); + var batch = new PlanBatch(); + foreach (var statementsEl in batchEl.Elements(Ns + "Statements")) { - var stmts = ParseStatementAndChildren(stmtEl); - batch.Statements.AddRange(stmts); + foreach (var statementElement in statementsEl.Elements()) + { + cancellationToken.ThrowIfCancellationRequested(); + batch.Statements.AddRange(ParseStatementAndChildren( + statementElement, + depth: 0, + cancellationToken)); + } } + if (batch.Statements.Count > 0) + plan.Batches.Add(batch); } - if (batch.Statements.Count > 0) - plan.Batches.Add(batch); - } - // Fallback: some plan XML has StmtSimple directly under QueryPlan - if (plan.Batches.Count == 0) - { - var batch = new PlanBatch(); - foreach (var stmtEl in root.Descendants(Ns + "StmtSimple")) + // Fallback: some plan XML has StmtSimple directly under QueryPlan. + if (plan.Batches.Count == 0) { - var stmt = ParseStatement(stmtEl); - if (stmt != null) - batch.Statements.Add(stmt); + var batch = new PlanBatch(); + foreach (var statementElement in root.Descendants(Ns + "StmtSimple")) + { + cancellationToken.ThrowIfCancellationRequested(); + var statement = ParseStatement(statementElement, cancellationToken); + if (statement is not null) + batch.Statements.Add(statement); + } + if (batch.Statements.Count > 0) + plan.Batches.Add(batch); } - if (batch.Statements.Count > 0) - plan.Batches.Add(batch); - } - ComputeOperatorCosts(plan); + cancellationToken.ThrowIfCancellationRequested(); + ComputeOperatorCosts(plan); + cancellationToken.ThrowIfCancellationRequested(); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; } - catch (Exception ex) + catch (Exception exception) { - plan.ParseError = ex.Message; + plan.ParseError = exception.Message; } return plan; } @@ -89,8 +134,12 @@ public static ParsedPlan Parse(string xml) /// Handles StmtSimple, StmtCond (IF/ELSE), and StmtCursor recursively. /// Returns a flat list of all parseable statements found. /// - private static List ParseStatementAndChildren(XElement stmtEl, int depth = 0) + private static List ParseStatementAndChildren( + XElement stmtEl, + int depth = 0, + CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); var results = new List(); if (depth > MaxParseDepth) throw new InvalidOperationException("Plan statement nesting exceeds the supported depth limit."); @@ -103,21 +152,21 @@ private static List ParseStatementAndChildren(XElement stmtEl, in if (condEl != null) { foreach (var child in condEl.Elements()) - results.AddRange(ParseStatementAndChildren(child, depth + 1)); + results.AddRange(ParseStatementAndChildren(child, depth + 1, cancellationToken)); } var thenStmts = stmtEl.Element(Ns + "Then")?.Element(Ns + "Statements"); if (thenStmts != null) { foreach (var child in thenStmts.Elements()) - results.AddRange(ParseStatementAndChildren(child, depth + 1)); + results.AddRange(ParseStatementAndChildren(child, depth + 1, cancellationToken)); } var elseStmts = stmtEl.Element(Ns + "Else")?.Element(Ns + "Statements"); if (elseStmts != null) { foreach (var child in elseStmts.Elements()) - results.AddRange(ParseStatementAndChildren(child, depth + 1)); + results.AddRange(ParseStatementAndChildren(child, depth + 1, cancellationToken)); } } else if (localName == "StmtCursor") @@ -142,7 +191,7 @@ private static List ParseStatementAndChildren(XElement stmtEl, in var relOpEl = qpEl.Element(Ns + "RelOp"); if (relOpEl == null) continue; - var stmt = ParseQueryPlanAsStatement(stmtEl, qpEl, relOpEl); + var stmt = ParseQueryPlanAsStatement(stmtEl, qpEl, relOpEl, cancellationToken); if (stmt != null) { // Override statement text with cursor context @@ -161,7 +210,7 @@ private static List ParseStatementAndChildren(XElement stmtEl, in else { // StmtSimple or any other statement type - var stmt = ParseStatement(stmtEl); + var stmt = ParseStatement(stmtEl, cancellationToken); if (stmt != null) results.Add(stmt); } @@ -169,8 +218,11 @@ private static List ParseStatementAndChildren(XElement stmtEl, in return results; } - private static PlanStatement? ParseStatement(XElement stmtEl) + private static PlanStatement? ParseStatement( + XElement stmtEl, + CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); var stmt = new PlanStatement { StatementText = stmtEl.Attribute("StatementText")?.Value ?? "", @@ -252,7 +304,7 @@ private static List ParseStatementAndChildren(XElement stmtEl, in var relOpEl = queryPlanEl.Element(Ns + "RelOp"); if (relOpEl != null) { - var opNode = ParseRelOp(relOpEl); + var opNode = ParseRelOp(relOpEl, cancellationToken: cancellationToken); var stmtType = stmt.StatementType.Length > 0 ? stmt.StatementType.ToUpperInvariant() : "QUERY"; @@ -291,7 +343,7 @@ private static List ParseStatementAndChildren(XElement stmtEl, in { foreach (var childStmt in udfStmts.Elements()) { - var parsed = ParseStatementAndChildren(childStmt); + var parsed = ParseStatementAndChildren(childStmt, cancellationToken: cancellationToken); udfInfo.Statements.AddRange(parsed); } } @@ -312,7 +364,7 @@ private static List ParseStatementAndChildren(XElement stmtEl, in { foreach (var childStmt in spStmts.Elements()) { - var parsed = ParseStatementAndChildren(childStmt); + var parsed = ParseStatementAndChildren(childStmt, cancellationToken: cancellationToken); spInfo.Statements.AddRange(parsed); } } @@ -325,8 +377,13 @@ private static List ParseStatementAndChildren(XElement stmtEl, in /// /// Parse a QueryPlan element that comes from a cursor Operation (no parent StmtSimple attributes). /// - private static PlanStatement? ParseQueryPlanAsStatement(XElement stmtEl, XElement queryPlanEl, XElement relOpEl) + private static PlanStatement? ParseQueryPlanAsStatement( + XElement stmtEl, + XElement queryPlanEl, + XElement relOpEl, + CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); var stmt = new PlanStatement { StatementText = stmtEl.Attribute("StatementText")?.Value ?? "", @@ -337,7 +394,7 @@ private static List ParseStatementAndChildren(XElement stmtEl, in ParseStmtAttributes(stmt, stmtEl); ParseQueryPlanElements(stmt, stmtEl, queryPlanEl); - var opNode = ParseRelOp(relOpEl); + var opNode = ParseRelOp(relOpEl, cancellationToken: cancellationToken); var stmtType = stmt.StatementType.Length > 0 ? stmt.StatementType.ToUpperInvariant() : "QUERY"; diff --git a/tests/PlanViewer.Core.Tests/McpPlanToolsContractTests.cs b/tests/PlanViewer.Core.Tests/McpPlanToolsContractTests.cs index 52d8154..562e0bd 100644 --- a/tests/PlanViewer.Core.Tests/McpPlanToolsContractTests.cs +++ b/tests/PlanViewer.Core.Tests/McpPlanToolsContractTests.cs @@ -129,6 +129,22 @@ public void GetExpensiveOperators_UsesTheSessionSnapshotAfterLookup() } + [Fact] + public void FullAnalysisHandlers_PropagateRequestCancellation() + { + var manager = new PlanSessionManager(); + var session = CreateEstimatedSession(); + manager.Register(session); + var operations = new PlanOperations(manager); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + Assert.Throws( + () => McpPlanTools.AnalyzePlan(manager, operations, session.SessionId, cancellation.Token)); + Assert.Throws( + () => McpPlanTools.GetPlanSummary(manager, operations, session.SessionId, cancellation.Token)); + } + [Fact] public void AnalyzePlan_PreservesTheUiSessionSourceWhenASnapshotIsPresent() { @@ -210,10 +226,18 @@ public void HistoricalOverloads_DelegateToTheSharedOperationsBehavior() var operations = new PlanOperations(manager, AnalyzerConfig.Default); Assert.Equal( - McpPlanTools.AnalyzePlan(manager, operations, session.SessionId), + McpPlanTools.AnalyzePlan( + manager, + operations, + session.SessionId, + TestContext.Current.CancellationToken), McpPlanTools.AnalyzePlan(manager, session.SessionId)); Assert.Equal( - McpPlanTools.GetPlanSummary(manager, operations, session.SessionId), + McpPlanTools.GetPlanSummary( + manager, + operations, + session.SessionId, + TestContext.Current.CancellationToken), McpPlanTools.GetPlanSummary(manager, session.SessionId)); Assert.Equal( McpPlanTools.GetPlanWarnings(manager, operations, session.SessionId, "Warning"), diff --git a/tests/PlanViewer.Core.Tests/PlanOperationsTests.cs b/tests/PlanViewer.Core.Tests/PlanOperationsTests.cs index 1ae45f8..3ecf050 100644 --- a/tests/PlanViewer.Core.Tests/PlanOperationsTests.cs +++ b/tests/PlanViewer.Core.Tests/PlanOperationsTests.cs @@ -1,9 +1,25 @@ +using System.Text; using PlanViewer.Core.Services; namespace PlanViewer.Core.Tests; public sealed class PlanOperationsTests { + [Fact] + public async Task ParseAsync_ObservesCancellationDuringLargeDocumentLoading() + { + var xml = new StringBuilder( + ""); + for (var index = 0; index < 200_000; index++) + xml.Append(""); + xml.Append(""); + using var cancellation = new CancellationTokenSource(); + cancellation.CancelAfter(TimeSpan.FromMilliseconds(1)); + + await Assert.ThrowsAnyAsync( + () => ShowPlanParser.ParseAsync(xml.ToString(), cancellation.Token)); + } + [Fact] public async Task OpenAsync_LoadsAnalyzesAndRegistersAPlanFile() { diff --git a/tests/PlanViewer.Core.Tests/PlanXmlPreflightTests.cs b/tests/PlanViewer.Core.Tests/PlanXmlPreflightTests.cs index ab46e64..88ed338 100644 --- a/tests/PlanViewer.Core.Tests/PlanXmlPreflightTests.cs +++ b/tests/PlanViewer.Core.Tests/PlanXmlPreflightTests.cs @@ -82,6 +82,31 @@ public async Task ValidateAsync_RejectsFallbackStatementsBeforeMaterialization() } } + [Fact] + public async Task ValidateAsync_RejectsAlternateStatementsNestedUnderCondition() + { + var path = Path.Combine(Path.GetTempPath(), $"preflight-condition-statements-{Guid.NewGuid():N}.sqlplan"); + try + { + var xml = new StringBuilder( + ""); + for (var id = 0; id < PlanOperations.DefaultMaxStatements; id++) + xml.Append($""); + xml.Append(""); + await File.WriteAllTextAsync(path, xml.ToString(), TestContext.Current.CancellationToken); + await using var stream = OpenPlan(path); + + var exception = await Assert.ThrowsAsync( + () => PlanXmlPreflight.ValidateAsync(stream, TestContext.Current.CancellationToken)); + + Assert.Contains("statement complexity", exception.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + File.Delete(path); + } + } + [Fact] public async Task ValidateAsync_RejectsAlternateStatementTypesBeforeMaterialization() { From 91f86170d852410400b2079cade373337f85b83c Mon Sep 17 00:00:00 2001 From: autocarl Date: Thu, 16 Jul 2026 10:04:06 -0400 Subject: [PATCH 15/16] fix: enforce shared budgets and cancellable MCP analysis --- .../Controls/PlanViewerControl.axaml.cs | 2 - src/PlanViewer.App/Mcp/McpPlanTools.cs | 56 ++++++--- src/PlanViewer.App/Mcp/McpQueryStoreTools.cs | 112 ++++++++++++++++-- src/PlanViewer.App/Mcp/PlanSessionManager.cs | 8 +- src/PlanViewer.Core/Models/PlanSession.cs | 2 + .../Output/PlanOperationResults.cs | 3 + src/PlanViewer.Core/Output/TextFormatter.cs | 89 +++++++++++--- src/PlanViewer.Core/PlanViewer.Core.csproj | 5 +- .../Services/InMemoryPlanCatalog.cs | 2 +- .../Services/PlanAnalysisPipeline.cs | 19 ++- .../Services/PlanOperations.cs | 112 +++++++++++++++--- .../Services/PlanResourceBudget.cs | 40 ++++--- .../Services/ShowPlanParser.Costs.cs | 16 ++- .../Services/ShowPlanParser.cs | 11 +- .../McpPlanToolsContractTests.cs | 65 +++++++--- .../PlanAnalysisPipelineTests.cs | 27 ++--- .../PlanOperationsTests.cs | 93 +++++++++++++-- .../PlanResourceBudgetTests.cs | 3 +- 18 files changed, 517 insertions(+), 148 deletions(-) diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs index c31d805..df69210 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs @@ -374,14 +374,12 @@ public bool LoadPlan(string planXml, string label, string? queryText = null) } const string sessionSource = "file"; - var analysisSnapshot = ResultMapper.Map(_currentPlan, sessionSource); PlanSessionManager.Instance.Register(_mcpSessionId, new PlanViewer.App.Mcp.PlanSession { SessionId = _mcpSessionId, Label = label, Source = sessionSource, Plan = _currentPlan, - Analysis = analysisSnapshot, QueryText = queryText, StatementCount = allStatements.Count, HasActualStats = allStatements.Any(s => s.QueryTimeStats != null), diff --git a/src/PlanViewer.App/Mcp/McpPlanTools.cs b/src/PlanViewer.App/Mcp/McpPlanTools.cs index 07007a8..5c5a1cb 100644 --- a/src/PlanViewer.App/Mcp/McpPlanTools.cs +++ b/src/PlanViewer.App/Mcp/McpPlanTools.cs @@ -1,9 +1,12 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.IO; using System.Linq; +using System.Text; using System.Text.Json; using System.Threading; +using System.Threading.Tasks; using ModelContextProtocol.Server; using PlanViewer.App.Services; using PlanViewer.Core.Models; @@ -42,6 +45,23 @@ public static string GetExpensiveOperators( private static PlanOperations CreateOperations(PlanSessionManager sessionManager) => new(sessionManager, AnalyzerConfig.Default); + public static string AnalyzePlan( + PlanSessionManager sessionManager, + PlanOperations operations, + string session_id) => + AnalyzePlan(sessionManager, operations, session_id, CancellationToken.None) + .GetAwaiter() + .GetResult(); + + public static string GetPlanSummary( + PlanSessionManager sessionManager, + PlanOperations operations, + string session_id) => + GetPlanSummary(sessionManager, operations, session_id, CancellationToken.None) + .GetAwaiter() + .GetResult(); + + [McpServerTool(Name = "list_plans")] [Description("Lists all execution plans currently loaded in the application. Returns session IDs, labels, " + "statement counts, warning counts, and source type. Use this first to discover available plans.")] @@ -77,11 +97,11 @@ public static string GetConnections(ConnectionStore connectionStore) [Description("Returns the full JSON analysis result for a loaded plan. Includes all statements, warnings, " + "missing indexes, parameters, operator tree, memory grants, and wait stats. " + "This is the primary tool for understanding plan quality. Use list_plans first to get session_id values.")] - public static string AnalyzePlan( + public static async Task AnalyzePlan( PlanSessionManager sessionManager, PlanOperations operations, [Description("The session_id from list_plans.")] string session_id, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken) { var session = sessionManager.GetSession(session_id); if (session == null) @@ -91,9 +111,16 @@ public static string AnalyzePlan( { using var queryScope = operations.AcquireQueryScope(cancellationToken); var result = operations.GetAnalysisForRequest(session, cancellationToken); - var json = JsonSerializer.Serialize(result, McpHelpers.JsonOptions); - cancellationToken.ThrowIfCancellationRequested(); - return json; + await using var output = new MemoryStream(); + await JsonSerializer.SerializeAsync( + output, + result, + McpHelpers.JsonOptions, + cancellationToken).ConfigureAwait(false); + return Encoding.UTF8.GetString( + output.GetBuffer(), + 0, + checked((int)output.Length)); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -108,22 +135,23 @@ public static string AnalyzePlan( [McpServerTool(Name = "get_plan_summary")] [Description("Returns a concise human-readable text summary of a loaded plan: statement count, warnings, " + "missing indexes, cost, DOP, memory grants. Faster than analyze_plan for quick assessment.")] - public static string GetPlanSummary( + public static Task GetPlanSummary( PlanSessionManager sessionManager, PlanOperations operations, [Description("The session_id from list_plans.")] string session_id, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken) { var session = sessionManager.GetSession(session_id); if (session == null) - return SessionNotFound(sessionManager, session_id); + return Task.FromResult(SessionNotFound(sessionManager, session_id)); try { using var queryScope = operations.AcquireQueryScope(cancellationToken); - var summary = TextFormatter.Format(operations.GetAnalysisForRequest(session, cancellationToken)); - cancellationToken.ThrowIfCancellationRequested(); - return summary; + var summary = TextFormatter.FormatCancellable( + operations.GetAnalysisForRequest(session, cancellationToken), + cancellationToken); + return Task.FromResult(summary); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -131,7 +159,7 @@ public static string GetPlanSummary( } catch (Exception ex) { - return McpHelpers.FormatError("get_plan_summary", ex); + return Task.FromResult(McpHelpers.FormatError("get_plan_summary", ex)); } } @@ -323,7 +351,7 @@ public static string GetReproScript( return "No executable statement found in this plan."; var queryText = session.QueryText ?? statement.StatementText ?? ""; - var databaseName = session.CapturedDatabaseName ?? statement.OperatorTree?.DatabaseName; + var databaseName = session.DatabaseName ?? statement.OperatorTree?.DatabaseName; return ReproScriptBuilder.BuildReproScript( queryText, databaseName, GetRawPlanXml(session), null); @@ -338,7 +366,7 @@ private static AnalysisResult GetAnalysisSnapshot(PlanSession session) => session.Analysis ?? ResultMapper.Map(session.Plan, session.Source); private static string? GetRawPlanXml(PlanSession session) => - session.CapturedRawXml ?? session.Plan.RawXml; + session.RawPlanXml ?? session.Plan.RawXml; private static string SessionNotFound(PlanSessionManager sessionManager, string sessionId) { diff --git a/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs b/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs index 67c9f3f..49ac632 100644 --- a/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs +++ b/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs @@ -1,6 +1,8 @@ using System; using System.ComponentModel; using System.Linq; +using System.Text; +using System.Threading; using System.Text.Json; using System.Threading.Tasks; using ModelContextProtocol.Server; @@ -17,6 +19,18 @@ namespace PlanViewer.App.Mcp; [McpServerToolType] public sealed class McpQueryStoreTools { + public static Task CheckQueryStore( + ConnectionStore connectionStore, + ICredentialService credentialService, + string connection_name, + string database) => + CheckQueryStore( + connectionStore, + credentialService, + connection_name, + database, + CancellationToken.None); + [McpServerTool(Name = "check_query_store")] [Description("Checks whether Query Store is enabled and accessible on a database. " + "Use this before calling get_query_store_top to verify the target database supports Query Store.")] @@ -24,8 +38,10 @@ public static async Task CheckQueryStore( ConnectionStore connectionStore, ICredentialService credentialService, [Description("Server name from get_connections.")] string connection_name, - [Description("Database name to check.")] string database) + [Description("Database name to check.")] string database, + CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); try { var conn = FindConnection(connectionStore, connection_name); @@ -33,7 +49,9 @@ public static async Task CheckQueryStore( return ConnectionNotFound(connectionStore, connection_name); var connectionString = conn.GetConnectionString(credentialService, database); - var (enabled, state, readOnlyReplica) = await QueryStoreService.CheckEnabledAsync(connectionString); + var (enabled, state, readOnlyReplica) = await QueryStoreService + .CheckEnabledAsync(connectionString, cancellationToken) + .ConfigureAwait(false); return JsonSerializer.Serialize(new { @@ -44,12 +62,49 @@ public static async Task CheckQueryStore( read_only_replica = readOnlyReplica }, McpHelpers.JsonOptions); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } catch (Exception ex) { return McpHelpers.FormatError("check_query_store", ex); } } + public static Task GetQueryStoreTop( + PlanSessionManager sessionManager, + ConnectionStore connectionStore, + ICredentialService credentialService, + string connection_name, + string database, + int top = 10, + string order_by = "cpu", + int hours_back = 24, + long? query_id = null, + long? plan_id = null, + string? query_hash = null, + string? plan_hash = null, + string? module = null, + string? execution_type = null) => + GetQueryStoreTop( + sessionManager, + new PlanOperations(sessionManager, AnalyzerConfig.Default), + connectionStore, + credentialService, + connection_name, + database, + CancellationToken.None, + top, + order_by, + hours_back, + query_id, + plan_id, + query_hash, + plan_hash, + module, + execution_type); + [McpServerTool(Name = "get_query_store_top")] [Description("Fetches the top N queries from Query Store ranked by the specified metric. " + "Uses the application's built-in Query Store query — no arbitrary SQL is executed. " + @@ -59,10 +114,12 @@ public static async Task CheckQueryStore( "plan_hash, or module name (schema.name, supports % wildcards).")] public static async Task GetQueryStoreTop( PlanSessionManager sessionManager, + PlanOperations operations, ConnectionStore connectionStore, ICredentialService credentialService, [Description("Server name from get_connections.")] string connection_name, [Description("Database name to query.")] string database, + CancellationToken cancellationToken, [Description("Number of top queries to return. Default 10, max 50.")] int top = 10, [Description("Ranking metric: cpu, avg-cpu, duration, avg-duration, reads, avg-reads, " + "writes, avg-writes, physical-reads, avg-physical-reads, memory, avg-memory, executions. " + @@ -75,6 +132,7 @@ public static async Task GetQueryStoreTop( [Description("Filter by module name (schema.name, supports % wildcards).")] string? module = null, [Description("Filter by execution type: regular, aborted, exception, or failed (= aborted + exception).")] string? execution_type = null) { + cancellationToken.ThrowIfCancellationRequested(); try { var conn = FindConnection(connectionStore, connection_name); @@ -116,7 +174,9 @@ public static async Task GetQueryStoreTop( var connectionString = conn.GetConnectionString(credentialService, database); // Check Query Store is enabled first - var (enabled, state, readOnlyReplica) = await QueryStoreService.CheckEnabledAsync(connectionString); + var (enabled, state, readOnlyReplica) = await QueryStoreService + .CheckEnabledAsync(connectionString, cancellationToken) + .ConfigureAwait(false); if (!enabled) return readOnlyReplica ? $"[{database}] is a read-only replica with no Query Store data to read (state: {state ?? "unknown"}). Enable Query Store on the primary replica." @@ -124,7 +184,12 @@ public static async Task GetQueryStoreTop( // Fetch plans using the app's built-in query var plans = await QueryStoreService.FetchTopPlansAsync( - connectionString, top, order_by, hours_back, filter); + connectionString, + top, + order_by, + hours_back, + filter, + cancellationToken).ConfigureAwait(false); if (plans.Count == 0) return $"No Query Store data found in [{database}] for the last {hours_back} hours."; @@ -135,7 +200,13 @@ public static async Task GetQueryStoreTop( { var isAzure = conn.ServerName.Contains(".database.windows.net", StringComparison.OrdinalIgnoreCase) || conn.ServerName.Contains(".database.azure.com", StringComparison.OrdinalIgnoreCase); - serverMetadata = await ServerMetadataService.FetchServerMetadataAsync(connectionString, isAzure); + serverMetadata = await ServerMetadataService + .FetchServerMetadataAsync(connectionString, isAzure, cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; } catch { @@ -152,17 +223,22 @@ public static async Task GetQueryStoreTop( { var xml = qsPlan.PlanXml .Replace("encoding=\"utf-16\"", "encoding=\"utf-8\""); + cancellationToken.ThrowIfCancellationRequested(); var parsed = PlanAnalysisPipeline.Analyze( xml, AnalyzerConfig.Default, - serverMetadata); + serverMetadata, + cancellationToken); var session = CaptureSession( sessionId, label, parsed, qsPlan.QueryText, conn.ServerName); - sessionManager.Register(sessionId, session); + operations.AdmitSnapshot( + session.ToCore(), + Encoding.UTF8.GetByteCount(xml), + cancellationToken); return new { @@ -184,12 +260,17 @@ public static async Task GetQueryStoreTop( warning_count = session.WarningCount, missing_index_count = session.MissingIndexCount, last_executed_utc = qsPlan.LastExecutedUtc.ToString("yyyy-MM-dd HH:mm:ss"), - loaded = true + loaded = true, + load_error = (string?)null }; } - catch + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - // Plan XML couldn't be parsed — return stats without loading + throw; + } + catch (Exception ex) + { + // Return bounded failure context without exposing a stack trace. return new { session_id = (string)"", @@ -210,7 +291,8 @@ public static async Task GetQueryStoreTop( warning_count = 0, missing_index_count = 0, last_executed_utc = qsPlan.LastExecutedUtc.ToString("yyyy-MM-dd HH:mm:ss"), - loaded = false + loaded = false, + load_error = McpHelpers.Truncate(ex.Message, 512) }; } }).ToList(); @@ -225,6 +307,10 @@ public static async Task GetQueryStoreTop( plans = results }, McpHelpers.JsonOptions); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } catch (Exception ex) { return McpHelpers.FormatError("get_query_store_top", ex); @@ -248,8 +334,8 @@ internal static PlanSession CaptureSession( Source = "query-store", Plan = parsed, Analysis = analysis, - CapturedRawXml = parsed.RawXml, - CapturedDatabaseName = executableStatement?.RootNode?.DatabaseName, + RawPlanXml = parsed.RawXml, + DatabaseName = executableStatement?.RootNode?.DatabaseName, QueryText = queryText, ConnectionInfo = connectionInfo, StatementCount = allStatements.Count, diff --git a/src/PlanViewer.App/Mcp/PlanSessionManager.cs b/src/PlanViewer.App/Mcp/PlanSessionManager.cs index ef4776b..d3db7c3 100644 --- a/src/PlanViewer.App/Mcp/PlanSessionManager.cs +++ b/src/PlanViewer.App/Mcp/PlanSessionManager.cs @@ -66,8 +66,8 @@ public sealed class PlanSession public required string Source { get; init; } public required ParsedPlan Plan { get; init; } public AnalysisResult? Analysis { get; init; } - internal string? CapturedRawXml { get; init; } - internal string? CapturedDatabaseName { get; init; } + public string? RawPlanXml { get; init; } + public string? DatabaseName { get; init; } public string? QueryText { get; init; } public string? ConnectionInfo { get; init; } public int StatementCount { get; init; } @@ -83,6 +83,8 @@ public sealed class PlanSession Source = Source, Plan = Plan, Analysis = Analysis, + RawPlanXml = RawPlanXml, + DatabaseName = DatabaseName, QueryText = QueryText, ConnectionInfo = ConnectionInfo, StatementCount = StatementCount, @@ -99,6 +101,8 @@ public sealed class PlanSession Source = session.Source, Plan = session.Plan, Analysis = session.Analysis, + RawPlanXml = session.RawPlanXml, + DatabaseName = session.DatabaseName, QueryText = session.QueryText, ConnectionInfo = session.ConnectionInfo, StatementCount = session.StatementCount, diff --git a/src/PlanViewer.Core/Models/PlanSession.cs b/src/PlanViewer.Core/Models/PlanSession.cs index d3832b6..6ed6d9f 100644 --- a/src/PlanViewer.Core/Models/PlanSession.cs +++ b/src/PlanViewer.Core/Models/PlanSession.cs @@ -13,6 +13,8 @@ public sealed class PlanSession public required string Source { get; init; } public required ParsedPlan Plan { get; init; } public AnalysisResult? Analysis { get; init; } + public string? RawPlanXml { get; init; } + public string? DatabaseName { get; init; } public string? QueryText { get; init; } public string? ConnectionInfo { get; init; } public int StatementCount { get; init; } diff --git a/src/PlanViewer.Core/Output/PlanOperationResults.cs b/src/PlanViewer.Core/Output/PlanOperationResults.cs index d2e3919..a9e219d 100644 --- a/src/PlanViewer.Core/Output/PlanOperationResults.cs +++ b/src/PlanViewer.Core/Output/PlanOperationResults.cs @@ -181,4 +181,7 @@ public sealed class MissingIndexItem [JsonPropertyName("create_statement")] public string CreateStatement { get; init; } = ""; + + [JsonPropertyName("columns_truncated")] + public bool ColumnsTruncated { get; init; } } diff --git a/src/PlanViewer.Core/Output/TextFormatter.cs b/src/PlanViewer.Core/Output/TextFormatter.cs index 322dea2..a8b7f8b 100644 --- a/src/PlanViewer.Core/Output/TextFormatter.cs +++ b/src/PlanViewer.Core/Output/TextFormatter.cs @@ -4,19 +4,29 @@ namespace PlanViewer.Core.Output; public static class TextFormatter { - public static string Format(AnalysisResult result) + public static string Format(AnalysisResult result) => + FormatCancellable(result, CancellationToken.None); + + public static string FormatCancellable(AnalysisResult result, CancellationToken cancellationToken) { using var writer = new StringWriter(); - WriteText(result, writer); + WriteTextCancellable(result, writer, cancellationToken); return writer.ToString(); } - public static void WriteText(AnalysisResult result, TextWriter writer) + public static void WriteText(AnalysisResult result, TextWriter writer) => + WriteTextCancellable(result, writer, CancellationToken.None); + + internal static void WriteTextCancellable( + AnalysisResult result, + TextWriter writer, + CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); // Server context (connected mode only) if (result.ServerContext != null) { - WriteServerContext(result.ServerContext, writer); + WriteServerContext(result.ServerContext, writer, cancellationToken); } else if (result.SqlServerBuild != null) { @@ -32,12 +42,16 @@ public static void WriteText(AnalysisResult result, TextWriter writer) { writer.WriteLine("Warning types:"); foreach (var wt in result.Summary.WarningTypes) + { + cancellationToken.ThrowIfCancellationRequested(); writer.WriteLine($" {wt}"); + } } writer.WriteLine(); for (int i = 0; i < result.Statements.Count; i++) { + cancellationToken.ThrowIfCancellationRequested(); var stmt = result.Statements[i]; writer.WriteLine($"=== Statement {i + 1}: ==="); writer.WriteLine(stmt.StatementText); @@ -78,7 +92,7 @@ public static void WriteText(AnalysisResult result, TextWriter writer) if (stmt.OperatorTree != null) { var nodeTimings = new List<(OperatorResult Node, long OwnCpuMs, long OwnElapsedMs)>(); - CollectNodeTimings(stmt.OperatorTree, nodeTimings); + CollectNodeTimings(stmt.OperatorTree, nodeTimings, cancellationToken); var topNodes = nodeTimings .Where(t => t.OwnCpuMs > 0 || t.OwnElapsedMs > 0) @@ -93,6 +107,7 @@ public static void WriteText(AnalysisResult result, TextWriter writer) var totalElapsed = stmt.QueryTime?.ElapsedTimeMs ?? 0; foreach (var (n, ownCpu, ownElapsed) in topNodes) { + cancellationToken.ThrowIfCancellationRequested(); var label = n.ObjectName != null ? $"{n.PhysicalOp} ({n.ObjectName})" : n.PhysicalOp; @@ -134,10 +149,14 @@ public static void WriteText(AnalysisResult result, TextWriter writer) // Build a lookup from wait type to benefit % var benefitLookup = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var wb in stmt.WaitBenefits) + { + cancellationToken.ThrowIfCancellationRequested(); benefitLookup[wb.WaitType] = wb.MaxBenefitPercent; + } foreach (var w in stmt.WaitStats.OrderByDescending(w => w.WaitTimeMs)) { + cancellationToken.ThrowIfCancellationRequested(); var benefitTag = benefitLookup.TryGetValue(w.WaitType, out var pct) ? $" (up to {(pct >= 100 ? pct.ToString("N0") : pct.ToString("N1"))}% benefit)" : ""; @@ -150,6 +169,7 @@ public static void WriteText(AnalysisResult result, TextWriter writer) writer.WriteLine("Parameters:"); foreach (var p in stmt.Parameters) { + cancellationToken.ThrowIfCancellationRequested(); var sniff = p.SniffingIssue ? " [SNIFFING]" : ""; writer.WriteLine($" {p.Name} {p.DataType} = {p.CompiledValue ?? "?"}{sniff}"); } @@ -168,6 +188,7 @@ public static void WriteText(AnalysisResult result, TextWriter writer) .ThenBy(w => w.Type); foreach (var w in sortedWarnings) { + cancellationToken.ThrowIfCancellationRequested(); var benefitTag = w.MaxBenefitPercent.HasValue ? $" (up to {(w.MaxBenefitPercent.Value >= 100 ? w.MaxBenefitPercent.Value.ToString("N0") : w.MaxBenefitPercent.Value.ToString("N1"))}% benefit)" : ""; @@ -181,12 +202,12 @@ public static void WriteText(AnalysisResult result, TextWriter writer) if (stmt.OperatorTree != null) { var nodeWarnings = new List(); - CollectNodeWarnings(stmt.OperatorTree, nodeWarnings); + CollectNodeWarnings(stmt.OperatorTree, nodeWarnings, cancellationToken); if (nodeWarnings.Count > 0) { writer.WriteLine(); writer.WriteLine("Operator warnings:"); - WriteGroupedOperatorWarnings(nodeWarnings, writer); + WriteGroupedOperatorWarnings(nodeWarnings, writer, cancellationToken); } } @@ -196,6 +217,7 @@ public static void WriteText(AnalysisResult result, TextWriter writer) writer.WriteLine("Missing indexes:"); foreach (var mi in stmt.MissingIndexes) { + cancellationToken.ThrowIfCancellationRequested(); writer.WriteLine($" {mi.Table} (impact: {mi.Impact:F0}%)"); writer.WriteLine($" {mi.CreateStatement}"); } @@ -205,8 +227,12 @@ public static void WriteText(AnalysisResult result, TextWriter writer) } } - private static void WriteServerContext(ServerContextResult ctx, TextWriter writer) + private static void WriteServerContext( + ServerContextResult ctx, + TextWriter writer, + CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); writer.WriteLine("=== Server Context ==="); // Server line: name + edition + version @@ -267,29 +293,43 @@ private static void WriteServerContext(ServerContextResult ctx, TextWriter write if (notable.Count > 0) { foreach (var n in notable) + { + cancellationToken.ThrowIfCancellationRequested(); writer.WriteLine($" {n}"); + } } if (db.NonDefaultScopedConfigs.Count > 0) { writer.WriteLine(" Non-default scoped configs:"); foreach (var sc in db.NonDefaultScopedConfigs) + { + cancellationToken.ThrowIfCancellationRequested(); writer.WriteLine($" {sc.Name} = {sc.Value}"); + } } } writer.WriteLine(); } - private static void CollectNodeWarnings(OperatorResult node, List warnings) + private static void CollectNodeWarnings( + OperatorResult node, + List warnings, + CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); warnings.AddRange(node.Warnings); foreach (var child in node.Children) - CollectNodeWarnings(child, warnings); + CollectNodeWarnings(child, warnings, cancellationToken); } - private static void WriteGroupedOperatorWarnings(List warnings, TextWriter writer) + private static void WriteGroupedOperatorWarnings( + List warnings, + TextWriter writer, + CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); // Sort by benefit descending (nulls last), then severity, then type var sorted = warnings .OrderByDescending(w => w.MaxBenefitPercent ?? -1) @@ -329,6 +369,7 @@ private static void WriteGroupedOperatorWarnings(List warnings, T foreach (var group in grouped) { + cancellationToken.ThrowIfCancellationRequested(); var items = group.ToList(); if (items.Count > 1 && group.Key.Item2 != "") { @@ -381,8 +422,12 @@ public static string FormatMemoryGrantKB(long kb) /// private static string EscapeNewlines(string text) => text.Replace('\n', '\x1F'); - private static void CollectNodeTimings(OperatorResult node, List<(OperatorResult Node, long OwnCpuMs, long OwnElapsedMs)> timings) + private static void CollectNodeTimings( + OperatorResult node, + List<(OperatorResult Node, long OwnCpuMs, long OwnElapsedMs)> timings, + CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); // Skip exchanges — negligible own work, misleading elapsed times if (node.PhysicalOp != "Parallelism") { @@ -396,7 +441,7 @@ private static void CollectNodeTimings(OperatorResult node, List<(OperatorResult ownCpu = node.ActualCpuMs.Value; else { - var childCpuSum = GetChildCpuSum(node); + var childCpuSum = GetChildCpuSum(node, cancellationToken); ownCpu = Math.Max(0, node.ActualCpuMs.Value - childCpuSum); } } @@ -409,7 +454,7 @@ private static void CollectNodeTimings(OperatorResult node, List<(OperatorResult ownElapsed = node.ActualElapsedMs.Value; else { - var childSum = GetChildElapsedSum(node); + var childSum = GetChildElapsedSum(node, cancellationToken); ownElapsed = Math.Max(0, node.ActualElapsedMs.Value - childSum); } } @@ -429,15 +474,18 @@ private static void CollectNodeTimings(OperatorResult node, List<(OperatorResult } foreach (var child in node.Children) - CollectNodeTimings(child, timings); + CollectNodeTimings(child, timings, cancellationToken); } /// /// Sums CPU time from all direct children, skipping through transparent /// operators (Compute Scalar, etc.) that have no runtime stats. /// - private static long GetChildCpuSum(OperatorResult node) + private static long GetChildCpuSum( + OperatorResult node, + CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); long sum = 0; foreach (var child in node.Children) { @@ -448,7 +496,7 @@ private static long GetChildCpuSum(OperatorResult node) else { // Transparent operator (e.g. Compute Scalar) — skip through - sum += GetChildCpuSum(child); + sum += GetChildCpuSum(child, cancellationToken); } } return sum; @@ -458,8 +506,11 @@ private static long GetChildCpuSum(OperatorResult node) /// Sums elapsed time from all direct children, skipping through exchange /// and transparent operators. /// - private static long GetChildElapsedSum(OperatorResult node) + private static long GetChildElapsedSum( + OperatorResult node, + CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); long sum = 0; foreach (var child in node.Children) { @@ -478,7 +529,7 @@ private static long GetChildElapsedSum(OperatorResult node) } else { - childTime = GetChildElapsedSum(child); + childTime = GetChildElapsedSum(child, cancellationToken); } sum += childTime; } diff --git a/src/PlanViewer.Core/PlanViewer.Core.csproj b/src/PlanViewer.Core/PlanViewer.Core.csproj index 72392bb..99c0ef4 100644 --- a/src/PlanViewer.Core/PlanViewer.Core.csproj +++ b/src/PlanViewer.Core/PlanViewer.Core.csproj @@ -20,11 +20,8 @@ - + - - diff --git a/src/PlanViewer.Core/Services/InMemoryPlanCatalog.cs b/src/PlanViewer.Core/Services/InMemoryPlanCatalog.cs index 57f5858..2bd0434 100644 --- a/src/PlanViewer.Core/Services/InMemoryPlanCatalog.cs +++ b/src/PlanViewer.Core/Services/InMemoryPlanCatalog.cs @@ -6,7 +6,7 @@ namespace PlanViewer.Core.Services; /// /// Thread-safe in-memory implementation of the shared plan catalog. /// -internal sealed class InMemoryPlanCatalog : IPlanCatalog +public sealed class InMemoryPlanCatalog : IPlanCatalog { private readonly object _syncRoot = new(); private readonly Dictionary _sessions = diff --git a/src/PlanViewer.Core/Services/PlanAnalysisPipeline.cs b/src/PlanViewer.Core/Services/PlanAnalysisPipeline.cs index f13b80d..7a45707 100644 --- a/src/PlanViewer.Core/Services/PlanAnalysisPipeline.cs +++ b/src/PlanViewer.Core/Services/PlanAnalysisPipeline.cs @@ -2,15 +2,15 @@ namespace PlanViewer.Core.Services; -internal static class PlanAnalysisPipeline +public static class PlanAnalysisPipeline { - internal static ParsedPlan Analyze( + public static ParsedPlan Analyze( string planXml, AnalyzerConfig config, CancellationToken cancellationToken = default) => Analyze(planXml, config, serverMetadata: null, cancellationToken); - internal static ParsedPlan Analyze( + public static ParsedPlan Analyze( string planXml, AnalyzerConfig config, ServerMetadata? serverMetadata, @@ -49,12 +49,19 @@ internal static async Task AnalyzeAsync( return AnalyzeParsed(plan, config, serverMetadata, cancellationToken, beforeAnalysis); } - internal static ParsedPlan AnalyzeParsed( + public static ParsedPlan AnalyzeParsed( ParsedPlan plan, AnalyzerConfig config, ServerMetadata? serverMetadata = null, - CancellationToken cancellationToken = default, - Action? beforeAnalysis = null) + CancellationToken cancellationToken = default) => + AnalyzeParsed(plan, config, serverMetadata, cancellationToken, beforeAnalysis: null); + + internal static ParsedPlan AnalyzeParsed( + ParsedPlan plan, + AnalyzerConfig config, + ServerMetadata? serverMetadata, + CancellationToken cancellationToken, + Action? beforeAnalysis) { ArgumentNullException.ThrowIfNull(plan); ArgumentNullException.ThrowIfNull(config); diff --git a/src/PlanViewer.Core/Services/PlanOperations.cs b/src/PlanViewer.Core/Services/PlanOperations.cs index 2f58a0b..3957194 100644 --- a/src/PlanViewer.Core/Services/PlanOperations.cs +++ b/src/PlanViewer.Core/Services/PlanOperations.cs @@ -63,7 +63,7 @@ public async Task OpenAsync( return await OpenStreamCoreAsync(stream, label, cancellationToken).ConfigureAwait(false); } - internal async Task OpenOwnedStreamAsync( + public async Task OpenOwnedStreamAsync( Func> streamFactory, CancellationToken cancellationToken = default) { @@ -161,15 +161,64 @@ private async Task OpenStreamCoreAsync( MissingIndexCount = analysis.Summary.MissingIndexes }; - if (_budget.TryRegister(session, DefaultMaxSessions)) - { - retainedEstimate.Commit(sessionId); + if (_budget.TryRegister(session, DefaultMaxSessions, retainedEstimate)) return ToSummary(session); - } _budget.EnsureSessionCapacity(DefaultMaxSessions); } } + public PlanSessionSummary AdmitSnapshot( + PlanSession session, + long sourceBytes, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(session); + if (sourceBytes is < 0 or > DefaultMaxPlanFileBytes) + { + throw new ArgumentOutOfRangeException( + nameof(sourceBytes), + sourceBytes, + $"Source bytes must be between 0 and {DefaultMaxPlanFileBytes}."); + } + if (session.Analysis is null) + throw new ArgumentException("A detached analysis snapshot is required.", nameof(session)); + if (session.Plan.Batches.Count > 0 || !string.IsNullOrEmpty(session.Plan.RawXml)) + throw new ArgumentException("The parser graph must be released before snapshot admission.", nameof(session)); + + cancellationToken.ThrowIfCancellationRequested(); + var operatorCount = CountSnapshotOperators(session.Analysis, cancellationToken); + var estimate = checked( + (sourceBytes * 2) + + ((long)session.Analysis.Statements.Count * 2 * 1024) + + (operatorCount * 4 * 1024)); + using var reservation = _budget.ReserveRetainedEstimate(estimate); + if (!_budget.TryRegister(session, DefaultMaxSessions, reservation)) + throw new InvalidOperationException($"A plan session with ID '{session.SessionId}' is already registered."); + return ToSummary(session); + } + + private static long CountSnapshotOperators( + AnalysisResult analysis, + CancellationToken cancellationToken) + { + var pending = new Stack( + analysis.Statements + .Select(statement => statement.OperatorTree) + .Where(operatorTree => operatorTree is not null) + .Cast()); + long count = 0; + while (pending.TryPop(out var operatorResult)) + { + cancellationToken.ThrowIfCancellationRequested(); + count++; + if (count > DefaultMaxOperators) + throw new InvalidDataException($"Plan exceeds the {DefaultMaxOperators} operator complexity limit."); + foreach (var child in operatorResult.Children) + pending.Push(child); + } + return count; + } + private static long EstimateRetainedAnalysisBytes( long sourceBytes, PlanXmlPreflightResult preflight) => @@ -262,7 +311,7 @@ public PlanSummaryResult GetSummary(PlanSession session) public MissingIndexesResult GetMissingIndexes(string sessionId) => GetMissingIndexes(sessionId, CancellationToken.None); - internal MissingIndexesResult GetMissingIndexes( + public MissingIndexesResult GetMissingIndexes( string sessionId, CancellationToken cancellationToken) => GetMissingIndexes(GetRequiredSession(sessionId), cancellationToken); @@ -278,6 +327,7 @@ internal MissingIndexesResult GetMissingIndexes( using var querySlot = _budget.AcquireQuerySlot(cancellationToken); var indexes = new List(DefaultMaxMissingIndexResults); var totalIndexCount = 0; + var columnsTruncated = false; foreach (var statement in GetAnalysisCancellable(session, cancellationToken).Statements) { cancellationToken.ThrowIfCancellationRequested(); @@ -287,16 +337,25 @@ internal MissingIndexesResult GetMissingIndexes( totalIndexCount++; if (indexes.Count >= DefaultMaxMissingIndexResults) continue; + var equalityColumns = BoundColumns(index.EqualityColumns); + var inequalityColumns = BoundColumns(index.InequalityColumns); + var includeColumns = BoundColumns(index.IncludeColumns); + var itemColumnsTruncated = + equalityColumns.Truncated || + inequalityColumns.Truncated || + includeColumns.Truncated; + columnsTruncated |= itemColumnsTruncated; indexes.Add(new MissingIndexItem { Database = Truncate(index.Database, 512), SchemaName = Truncate(index.SchemaName, 512), Table = Truncate(index.BareTable, 512), Impact = index.Impact, - EqualityColumns = BoundColumns(index.EqualityColumns), - InequalityColumns = BoundColumns(index.InequalityColumns), - IncludeColumns = BoundColumns(index.IncludeColumns), - CreateStatement = Truncate(index.CreateStatement, DefaultMaxResponseTextLength) + EqualityColumns = equalityColumns.Values, + InequalityColumns = inequalityColumns.Values, + IncludeColumns = includeColumns.Values, + CreateStatement = Truncate(index.CreateStatement, DefaultMaxResponseTextLength), + ColumnsTruncated = itemColumnsTruncated }); } } @@ -306,7 +365,7 @@ internal MissingIndexesResult GetMissingIndexes( SessionId = session.SessionId, MissingIndexCount = totalIndexCount, ReturnedIndexCount = indexes.Count, - Truncated = indexes.Count < totalIndexCount, + Truncated = indexes.Count < totalIndexCount || columnsTruncated, Indexes = indexes }; } @@ -314,7 +373,7 @@ internal MissingIndexesResult GetMissingIndexes( public ExpensiveOperatorsResult GetExpensiveOperators(string sessionId, int top = 10) => GetExpensiveOperators(sessionId, top, CancellationToken.None); - internal ExpensiveOperatorsResult GetExpensiveOperators( + public ExpensiveOperatorsResult GetExpensiveOperators( string sessionId, int top, CancellationToken cancellationToken) => @@ -388,7 +447,7 @@ internal ExpensiveOperatorsResult GetExpensiveOperators( public PlanWarningsResult GetWarnings(string sessionId, string? severity = null) => GetWarnings(sessionId, severity, CancellationToken.None); - internal PlanWarningsResult GetWarnings( + public PlanWarningsResult GetWarnings( string sessionId, string? severity, CancellationToken cancellationToken) => @@ -470,10 +529,10 @@ void AddWarning(WarningResult warning, string statementText) } } - internal IDisposable AcquireQueryScope(CancellationToken cancellationToken) => + public IDisposable AcquireQueryScope(CancellationToken cancellationToken) => _budget.AcquireQuerySlot(cancellationToken); - internal AnalysisResult GetAnalysisForRequest( + public AnalysisResult GetAnalysisForRequest( PlanSession session, CancellationToken cancellationToken) { @@ -599,8 +658,27 @@ private static void AddRanked( Statement = statement }; - private static IReadOnlyList BoundColumns(IEnumerable columns) => - columns.Take(64).Select(column => Truncate(column, 512)).ToList(); + private static BoundedColumnList BoundColumns(IEnumerable columns) + { + var values = new List(64); + var truncated = false; + foreach (var column in columns) + { + if (values.Count == 64) + { + truncated = true; + break; + } + if (column.Length > 512) + truncated = true; + values.Add(Truncate(column, 512)); + } + return new BoundedColumnList(values, truncated); + } + + private readonly record struct BoundedColumnList( + IReadOnlyList Values, + bool Truncated); private static string Truncate(string value, int maxLength) => value.Length <= maxLength ? value : value[..maxLength] + "... (truncated)"; diff --git a/src/PlanViewer.Core/Services/PlanResourceBudget.cs b/src/PlanViewer.Core/Services/PlanResourceBudget.cs index e7b322d..55fc64a 100644 --- a/src/PlanViewer.Core/Services/PlanResourceBudget.cs +++ b/src/PlanViewer.Core/Services/PlanResourceBudget.cs @@ -51,7 +51,10 @@ internal IDisposable AcquireQuerySlot(CancellationToken cancellationToken) return new SemaphoreLease(_querySlots); } - internal bool TryRegister(PlanSession session, int maxSessions) + internal bool TryRegister( + PlanSession session, + int maxSessions, + RetainedEstimateReservation reservation) { lock (_syncRoot) { @@ -61,7 +64,19 @@ internal bool TryRegister(PlanSession session, int maxSessions) $"The plan session limit of {maxSessions} has been reached. Close a session before opening another plan."); } - return _catalog.TryRegister(session); + if (!_catalog.TryRegister(session)) + return false; + + try + { + CommitUnderLock(reservation, session.SessionId); + return true; + } + catch + { + _catalog.Unregister(session.SessionId); + throw; + } } } @@ -107,14 +122,13 @@ internal RetainedEstimateReservation ReserveRetainedEstimate(long bytes) } } - private void Commit(RetainedEstimateReservation reservation, string sessionId) + private void CommitUnderLock(RetainedEstimateReservation reservation, string sessionId) { - lock (_syncRoot) - { - if (!_retainedEstimateBySession.TryAdd(sessionId, reservation.Bytes)) - throw new InvalidOperationException($"A retained-byte reservation already exists for session {sessionId}."); - reservation.MarkCommitted(); - } + if (!ReferenceEquals(reservation.Owner, this)) + throw new InvalidOperationException("The retained-byte reservation belongs to another plan catalog."); + if (!_retainedEstimateBySession.TryAdd(sessionId, reservation.Bytes)) + throw new InvalidOperationException($"A retained-byte reservation already exists for session {sessionId}."); + reservation.MarkCommitted(); } private void ReleaseReservation(long bytes) @@ -134,14 +148,8 @@ internal RetainedEstimateReservation(PlanResourceBudget owner, long bytes) Bytes = bytes; } + internal PlanResourceBudget? Owner => _owner; internal long Bytes { get; } - - internal void Commit(string sessionId) - { - var owner = _owner ?? throw new ObjectDisposedException(nameof(RetainedEstimateReservation)); - owner.Commit(this, sessionId); - } - internal void MarkCommitted() => _committed = true; public void Dispose() diff --git a/src/PlanViewer.Core/Services/ShowPlanParser.Costs.cs b/src/PlanViewer.Core/Services/ShowPlanParser.Costs.cs index eb48f5a..2d28305 100644 --- a/src/PlanViewer.Core/Services/ShowPlanParser.Costs.cs +++ b/src/PlanViewer.Core/Services/ShowPlanParser.Costs.cs @@ -9,30 +9,38 @@ namespace PlanViewer.Core.Services; public static partial class ShowPlanParser { - private static void ComputeOperatorCosts(ParsedPlan plan) + private static void ComputeOperatorCosts( + ParsedPlan plan, + CancellationToken cancellationToken) { foreach (var batch in plan.Batches) { + cancellationToken.ThrowIfCancellationRequested(); foreach (var stmt in batch.Statements) { + cancellationToken.ThrowIfCancellationRequested(); if (stmt.RootNode == null) continue; var totalCost = stmt.StatementSubTreeCost > 0 ? stmt.StatementSubTreeCost : stmt.RootNode.EstimatedTotalSubtreeCost; if (totalCost <= 0) totalCost = 1; - ComputeNodeCosts(stmt.RootNode, totalCost); + ComputeNodeCosts(stmt.RootNode, totalCost, cancellationToken); } } } - private static void ComputeNodeCosts(PlanNode node, double totalStatementCost) + private static void ComputeNodeCosts( + PlanNode node, + double totalStatementCost, + CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); var childrenSubtreeCost = node.Children.Sum(c => c.EstimatedTotalSubtreeCost); node.EstimatedOperatorCost = Math.Max(0, node.EstimatedTotalSubtreeCost - childrenSubtreeCost); node.CostPercent = (int)Math.Round((node.EstimatedOperatorCost / totalStatementCost) * 100); node.CostPercent = Math.Min(100, Math.Max(0, node.CostPercent)); foreach (var child in node.Children) - ComputeNodeCosts(child, totalStatementCost); + ComputeNodeCosts(child, totalStatementCost, cancellationToken); } } diff --git a/src/PlanViewer.Core/Services/ShowPlanParser.cs b/src/PlanViewer.Core/Services/ShowPlanParser.cs index b5fdc2f..35a66f3 100644 --- a/src/PlanViewer.Core/Services/ShowPlanParser.cs +++ b/src/PlanViewer.Core/Services/ShowPlanParser.cs @@ -34,7 +34,8 @@ public static ParsedPlan Parse(string xml) internal static async Task ParseAsync( string xml, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + Action? beforeCostComputation = null) { var plan = new ParsedPlan { RawXml = xml }; try @@ -51,7 +52,7 @@ internal static async Task ParseAsync( var document = await XDocument .LoadAsync(xmlReader, LoadOptions.None, cancellationToken) .ConfigureAwait(false); - return ParseDocument(document, plan, cancellationToken); + return ParseDocument(document, plan, cancellationToken, beforeCostComputation); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -67,7 +68,8 @@ internal static async Task ParseAsync( private static ParsedPlan ParseDocument( XDocument document, ParsedPlan plan, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + Action? beforeCostComputation = null) { try { @@ -116,7 +118,8 @@ private static ParsedPlan ParseDocument( } cancellationToken.ThrowIfCancellationRequested(); - ComputeOperatorCosts(plan); + beforeCostComputation?.Invoke(); + ComputeOperatorCosts(plan, cancellationToken); cancellationToken.ThrowIfCancellationRequested(); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) diff --git a/tests/PlanViewer.Core.Tests/McpPlanToolsContractTests.cs b/tests/PlanViewer.Core.Tests/McpPlanToolsContractTests.cs index 562e0bd..65b62ad 100644 --- a/tests/PlanViewer.Core.Tests/McpPlanToolsContractTests.cs +++ b/tests/PlanViewer.Core.Tests/McpPlanToolsContractTests.cs @@ -1,3 +1,4 @@ +using System.Text; using System.Text.Json; using PlanViewer.App.Mcp; using PlanViewer.Core.Interfaces; @@ -130,7 +131,34 @@ public void GetExpensiveOperators_UsesTheSessionSnapshotAfterLookup() [Fact] - public void FullAnalysisHandlers_PropagateRequestCancellation() +#pragma warning disable xUnit1051 // Intentionally verify an independently cancelled MCP request token. + public async Task QueryStoreHandlers_PropagateRequestCancellation() + { + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var manager = new PlanSessionManager(); + + await Assert.ThrowsAsync( + () => McpQueryStoreTools.CheckQueryStore( + null!, + null!, + "server", + "database", + cancellation.Token)); + await Assert.ThrowsAsync( + () => McpQueryStoreTools.GetQueryStoreTop( + manager, + new PlanOperations(manager), + null!, + null!, + "server", + "database", + cancellation.Token)); + } +#pragma warning restore xUnit1051 + + [Fact] + public async Task FullAnalysisHandlers_PropagateRequestCancellation() { var manager = new PlanSessionManager(); var session = CreateEstimatedSession(); @@ -139,14 +167,14 @@ public void FullAnalysisHandlers_PropagateRequestCancellation() using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); - Assert.Throws( + await Assert.ThrowsAsync( () => McpPlanTools.AnalyzePlan(manager, operations, session.SessionId, cancellation.Token)); - Assert.Throws( + await Assert.ThrowsAsync( () => McpPlanTools.GetPlanSummary(manager, operations, session.SessionId, cancellation.Token)); } [Fact] - public void AnalyzePlan_PreservesTheUiSessionSourceWhenASnapshotIsPresent() + public void AnalyzePlan_PreservesTheUiSessionSourceWhenMappedOnDemand() { var manager = new PlanSessionManager(); var plan = PlanTestHelper.LoadAndAnalyze("row_goal_plan.sqlplan"); @@ -156,8 +184,7 @@ public void AnalyzePlan_PreservesTheUiSessionSourceWhenASnapshotIsPresent() SessionId = sessionId, Label = "row_goal_plan.sqlplan", Source = "file", - Plan = plan, - Analysis = ResultMapper.Map(plan, "file") + Plan = plan }); var json = McpPlanTools.AnalyzePlan(manager, sessionId); @@ -189,18 +216,25 @@ public void GivenQueryStorePlan_WhenCaptured_ThenLegacyToolsRemainExactWithoutTh QueryText = queryText }); var capturedManager = new PlanSessionManager(); + var capturedOperations = new PlanOperations(capturedManager, AnalyzerConfig.Default); var captured = McpQueryStoreTools.CaptureSession( sessionId, label, capturedPlan, queryText, "server"); - capturedManager.Register(sessionId, captured); + capturedOperations.AdmitSnapshot( + captured.ToCore(), + Encoding.UTF8.GetByteCount(captured.RawPlanXml!), + TestContext.Current.CancellationToken); + var registered = Assert.IsType(capturedManager.GetSession(sessionId)); Assert.Equal("query-store", captured.Source); + Assert.Equal(captured.RawPlanXml, registered.RawPlanXml); + Assert.Equal(captured.DatabaseName, registered.DatabaseName); Assert.Equal("query-store", captured.Analysis?.PlanSource); Assert.Equal(label, captured.Label); - Assert.False(string.IsNullOrEmpty(captured.CapturedRawXml)); + Assert.False(string.IsNullOrEmpty(captured.RawPlanXml)); Assert.Empty(captured.Plan.Batches); Assert.Empty(captured.Plan.RawXml); Assert.Equal( @@ -217,6 +251,7 @@ public void GivenQueryStorePlan_WhenCaptured_ThenLegacyToolsRemainExactWithoutTh McpPlanTools.GetReproScript(capturedManager, sessionId)); } +#pragma warning disable xUnit1051 // Intentionally exercise synchronous compatibility overloads. [Fact] public void HistoricalOverloads_DelegateToTheSharedOperationsBehavior() { @@ -226,18 +261,10 @@ public void HistoricalOverloads_DelegateToTheSharedOperationsBehavior() var operations = new PlanOperations(manager, AnalyzerConfig.Default); Assert.Equal( - McpPlanTools.AnalyzePlan( - manager, - operations, - session.SessionId, - TestContext.Current.CancellationToken), + McpPlanTools.AnalyzePlan(manager, operations, session.SessionId), McpPlanTools.AnalyzePlan(manager, session.SessionId)); Assert.Equal( - McpPlanTools.GetPlanSummary( - manager, - operations, - session.SessionId, - TestContext.Current.CancellationToken), + McpPlanTools.GetPlanSummary(manager, operations, session.SessionId), McpPlanTools.GetPlanSummary(manager, session.SessionId)); Assert.Equal( McpPlanTools.GetPlanWarnings(manager, operations, session.SessionId, "Warning"), @@ -250,6 +277,8 @@ public void HistoricalOverloads_DelegateToTheSharedOperationsBehavior() McpPlanTools.GetExpensiveOperators(manager, session.SessionId, 3)); } +#pragma warning restore xUnit1051 + private static CorePlanSession CreateEstimatedSession() { var plan = PlanTestHelper.LoadAndAnalyze("row_goal_plan.sqlplan"); diff --git a/tests/PlanViewer.Core.Tests/PlanAnalysisPipelineTests.cs b/tests/PlanViewer.Core.Tests/PlanAnalysisPipelineTests.cs index ca33f6d..7bffb15 100644 --- a/tests/PlanViewer.Core.Tests/PlanAnalysisPipelineTests.cs +++ b/tests/PlanViewer.Core.Tests/PlanAnalysisPipelineTests.cs @@ -24,29 +24,18 @@ public void Analyze_RunsTheSharedParseAnalyzeAndScorePipeline() } [Fact] - public void Analyze_ObservesCancellationDuringStatementAnalysis() + public void AnalyzeParsed_ObservesCancellationAtTheAnalysisBoundary() { - var plan = new ParsedPlan - { - Batches = - [ - new PlanBatch - { - Statements = Enumerable.Range(0, 20_000) - .Select(index => new PlanStatement - { - StatementId = index, - StatementText = "SELECT * FROM dbo.example WHERE name LIKE '%value'" - }) - .ToList() - } - ] - }; + var plan = PlanTestHelper.LoadAndAnalyze("row_goal_plan.sqlplan"); using var cancellation = new CancellationTokenSource(); - cancellation.CancelAfter(TimeSpan.FromMilliseconds(1)); Assert.ThrowsAny(() => - PlanAnalyzer.AnalyzeCancellable(plan, AnalyzerConfig.Default, serverMetadata: null, cancellation.Token)); + PlanAnalysisPipeline.AnalyzeParsed( + plan, + AnalyzerConfig.Default, + serverMetadata: null, + cancellation.Token, + beforeAnalysis: (_, _) => cancellation.Cancel())); } [Fact] diff --git a/tests/PlanViewer.Core.Tests/PlanOperationsTests.cs b/tests/PlanViewer.Core.Tests/PlanOperationsTests.cs index 3ecf050..0d43d2c 100644 --- a/tests/PlanViewer.Core.Tests/PlanOperationsTests.cs +++ b/tests/PlanViewer.Core.Tests/PlanOperationsTests.cs @@ -6,18 +6,60 @@ namespace PlanViewer.Core.Tests; public sealed class PlanOperationsTests { [Fact] - public async Task ParseAsync_ObservesCancellationDuringLargeDocumentLoading() + public async Task ParseAsync_ObservesCancellationBeforeCostComputation() { - var xml = new StringBuilder( - ""); - for (var index = 0; index < 200_000; index++) - xml.Append(""); - xml.Append(""); + var xml = await File.ReadAllTextAsync( + Path.GetFullPath(Path.Combine("Plans", "row_goal_plan.sqlplan")), + TestContext.Current.CancellationToken); using var cancellation = new CancellationTokenSource(); - cancellation.CancelAfter(TimeSpan.FromMilliseconds(1)); await Assert.ThrowsAnyAsync( - () => ShowPlanParser.ParseAsync(xml.ToString(), cancellation.Token)); + () => ShowPlanParser.ParseAsync(xml, cancellation.Token, cancellation.Cancel)); + } + + [Fact] + public void AdmitSnapshot_EnforcesSessionLimitAndReleasesCapacityOnClose() + { + var operations = new PlanOperations(new InMemoryPlanCatalog()); + for (var index = 0; index < PlanOperations.DefaultMaxSessions; index++) + operations.AdmitSnapshot(CreateDetachedSession($"snapshot-{index}"), 0, TestContext.Current.CancellationToken); + + Assert.Throws( + () => operations.AdmitSnapshot( + CreateDetachedSession("snapshot-rejected"), + 0, + TestContext.Current.CancellationToken)); + Assert.True(operations.Close("snapshot-0")); + operations.AdmitSnapshot( + CreateDetachedSession("snapshot-replacement"), + 0, + TestContext.Current.CancellationToken); + } + + [Fact] + public void AdmitSnapshot_ReleasesRetainedEstimateWhenClosed() + { + var operations = new PlanOperations(new InMemoryPlanCatalog()); + const long retainedSourceBytes = 15L * 1024 * 1024; + operations.AdmitSnapshot( + CreateDetachedSession("retained-1"), + retainedSourceBytes, + TestContext.Current.CancellationToken); + operations.AdmitSnapshot( + CreateDetachedSession("retained-2"), + retainedSourceBytes, + TestContext.Current.CancellationToken); + + Assert.Throws( + () => operations.AdmitSnapshot( + CreateDetachedSession("retained-3"), + retainedSourceBytes, + TestContext.Current.CancellationToken)); + Assert.True(operations.Close("retained-1")); + operations.AdmitSnapshot( + CreateDetachedSession("retained-3"), + retainedSourceBytes, + TestContext.Current.CancellationToken); } [Fact] @@ -227,6 +269,32 @@ public async Task GetMissingIndexes_BoundsTheReturnedItemsAndReportsTruncation() }); } + [Fact] + public async Task GetMissingIndexes_ReportsColumnTruncation() + { + var catalog = new InMemoryPlanCatalog(); + var operations = new PlanOperations(catalog); + var path = Path.GetFullPath(Path.Combine("Plans", "row_goal_plan.sqlplan")); + var opened = await operations.OpenAsync(path, TestContext.Current.CancellationToken); + var session = Assert.IsType(catalog.GetSession(opened.SessionId)); + var statement = Assert.Single(Assert.IsType(session.Analysis).Statements); + statement.MissingIndexes = + [ + new PlanViewer.Core.Output.MissingIndexResult + { + BareTable = "wide-index", + EqualityColumns = Enumerable.Range(0, 65).Select(index => $"column-{index}").ToList() + } + ]; + + var result = operations.GetMissingIndexes(session, TestContext.Current.CancellationToken); + + Assert.True(result.Truncated); + var item = Assert.Single(result.Indexes); + Assert.True(item.ColumnsTruncated); + Assert.Equal(64, item.EqualityColumns.Count); + } + [Fact] public async Task GetMissingIndexes_ReturnsStructuredSuggestions() { @@ -614,4 +682,13 @@ public async Task OpenAsync_RejectsFilesWithoutSqlPlanExtension() } } + private static PlanViewer.Core.Models.PlanSession CreateDetachedSession(string sessionId) => new() + { + SessionId = sessionId, + Label = $"{sessionId}.sqlplan", + Source = "query-store", + Plan = new PlanViewer.Core.Models.ParsedPlan(), + Analysis = new PlanViewer.Core.Output.AnalysisResult { PlanSource = "query-store" } + }; + } diff --git a/tests/PlanViewer.Core.Tests/PlanResourceBudgetTests.cs b/tests/PlanViewer.Core.Tests/PlanResourceBudgetTests.cs index 8000df5..6ff315c 100644 --- a/tests/PlanViewer.Core.Tests/PlanResourceBudgetTests.cs +++ b/tests/PlanViewer.Core.Tests/PlanResourceBudgetTests.cs @@ -62,8 +62,9 @@ public void TryRegister_ReportsCapacityInsteadOfMasqueradingItAsAnIdCollision() var budget = PlanResourceBudget.ForCatalog(catalog); Assert.True(catalog.TryRegister(CreateSession("first"))); + using var reservation = budget.ReserveRetainedEstimate(0); var error = Assert.Throws( - () => budget.TryRegister(CreateSession("second"), maxSessions: 1)); + () => budget.TryRegister(CreateSession("second"), maxSessions: 1, reservation)); Assert.Contains("session limit of 1", error.Message, StringComparison.Ordinal); } From 1372214187a5bd116459457c7b6bf43b720d7a14 Mon Sep 17 00:00:00 2001 From: autocarl Date: Thu, 16 Jul 2026 10:41:58 -0400 Subject: [PATCH 16/16] fix: restore desktop MCP compatibility and simplify sessions --- docs/repl-pilot.md | 6 +- .../Controls/PlanViewerControl.axaml.cs | 2 +- src/PlanViewer.App/Mcp/McpHostService.cs | 5 +- src/PlanViewer.App/Mcp/McpPlanTools.cs | 42 ------- src/PlanViewer.App/Mcp/McpQueryStoreTools.cs | 47 +------ src/PlanViewer.App/Mcp/PlanSessionManager.cs | 115 ++---------------- .../ReplSurface/McpPlanPathPolicy.cs | 17 ++- src/PlanViewer.Core/Output/TextFormatter.cs | 2 +- src/PlanViewer.Core/PlanViewer.Core.csproj | 4 +- .../Services/InMemoryPlanCatalog.cs | 2 +- .../Services/PlanAnalysisPipeline.cs | 19 +-- .../Services/PlanOperations.cs | 49 ++++++-- .../Services/PlanResourceBudget.cs | 7 +- .../McpPlanPathPolicyTests.cs | 12 ++ .../McpPlanToolsContractTests.cs | 61 ++++------ .../PlanResourceBudgetTests.cs | 45 +++++-- .../PublicApiCompatibilityTests.cs | 84 ------------- 17 files changed, 151 insertions(+), 368 deletions(-) delete mode 100644 tests/PlanViewer.Core.Tests/PublicApiCompatibilityTests.cs diff --git a/docs/repl-pilot.md b/docs/repl-pilot.md index 7d9512c..33fae9d 100644 --- a/docs/repl-pilot.md +++ b/docs/repl-pilot.md @@ -18,13 +18,13 @@ Read-only describes the analyzed plans and external systems: the pilot never wri Local CLI/Repl commands may open any accessible `.sqlplan` file, matching normal command-line file semantics. The generated MCP server applies a stricter policy: -- `plan_open` rejects lexically outside paths before probing them, opens an allowed candidate once, validates its kernel-resolved handle against canonical MCP roots, and parses that same handle. Windows roots use their enumerated on-disk casing and ordinal containment so case-sensitive sibling directories cannot alias an allowed root; NTFS alternate-data-stream paths are rejected. +- `plan_open` rejects lexically outside paths before probing them, opens an allowed candidate once, validates its kernel-resolved handle against resolved MCP roots, and parses that same handle. Containment is ordinal on Linux and case-insensitive on Windows and macOS to match their default filesystems and client URI casing; per-directory/per-volume case-sensitive configurations on Windows or macOS are outside this pilot's scope. NTFS alternate-data-stream paths are rejected. - The server launch directory is a fallback root only when native roots are unsupported and no soft roots are configured. Advertised-but-empty, invalid, unavailable, or unresponsive roots deny access; native roots negotiation has a 5-second server-side deadline. - MCP errors do not disclose absolute paths outside the allowed roots. - Only an explicit allow-list of plan tools is exported; automatic read-only resource promotion is disabled. - Files are read through a hard 16 MiB streaming ceiling even if they grow during loading. A forward-only XML preflight prohibits DTDs and rejects depth over 512, more than 10,000 structurally recognized statements/cursor operations, 100,000 operators, 250,000 total elements, or 1,000,000 total attributes before `XDocument` and the parser graph are materialized; the parsed graph is checked again before analysis. Streaming SHA-256 values bind the preflight and decoded passes to identical bytes, rejecting in-place content changes before materialization. -- The limit of 2 concurrent opens is shared by every `PlanOperations` facade over the same catalog, and excess calls fail fast before path resolution or file acquisition. Admission is coordinated atomically across those facades, with at most 32 sessions and a 64 MiB aggregate retained-analysis estimate. The estimate is reserved after streaming preflight and conservatively accounts for UTF-16 source text, statements (2 KiB each), operators (4 KiB each), XML elements (256 bytes each), and attributes (128 bytes each); `plan_close` releases it. -- Warning responses return at most 500 items, missing-index responses at most 100 suggestions, and operator queries at most 100 items. The query paths count or rank by bounded top-K storage instead of materializing an additional full-tree result list. At most 4 full-tree query traversals run concurrently per catalog; excess calls fail fast. Result metadata reports truncation, and plan-controlled text fields are bounded before serialization. +- The limit of 2 concurrent opens is shared by every `PlanOperations` facade over the same catalog; excess calls wait asynchronously, honor request cancellation, and do not resolve paths or acquire files until admitted. Admission is coordinated atomically across those facades, with at most 32 sessions and a 64 MiB aggregate retained-analysis estimate. The estimate is reserved after streaming preflight and conservatively accounts for UTF-16 source text, statements (2 KiB each), operators (4 KiB each), XML elements (256 bytes each), and attributes (128 bytes each); `plan_close` releases it. +- Warning responses return at most 500 items, missing-index responses at most 100 suggestions, and operator queries at most 100 items. The query paths count or rank by bounded top-K storage instead of materializing an additional full-tree result list. The generated CLI/MCP pilot admits at most 4 full-tree query traversals concurrently per catalog and rejects excess work; the existing desktop App MCP host remains exempt from that new cap. Result metadata reports truncation, and plan-controlled text fields are bounded before serialization. - `plan_close` provides explicit eviction and releases the retained-memory reservation. Session labels are capped at 48 characters and use a fresh 12-hex-character opaque suffix; active-ID collisions are retried. - Streaming preflight, direct bounded text decoding, per-statement/per-operator analysis, scoring, mapping, registration, and Repl query traversals honor cancellation. XML decoding reads the authorized stream directly with a pooled buffer rather than retaining a second byte-for-byte copy. diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs index df69210..195fdb5 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs @@ -374,7 +374,7 @@ public bool LoadPlan(string planXml, string label, string? queryText = null) } const string sessionSource = "file"; - PlanSessionManager.Instance.Register(_mcpSessionId, new PlanViewer.App.Mcp.PlanSession + PlanSessionManager.Instance.Register(new PlanViewer.Core.Models.PlanSession { SessionId = _mcpSessionId, Label = label, diff --git a/src/PlanViewer.App/Mcp/McpHostService.cs b/src/PlanViewer.App/Mcp/McpHostService.cs index e0cf5f5..c3c24f7 100644 --- a/src/PlanViewer.App/Mcp/McpHostService.cs +++ b/src/PlanViewer.App/Mcp/McpHostService.cs @@ -56,7 +56,10 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) /* Register services that MCP tools need via dependency injection */ builder.Services.AddSingleton(_sessionManager); builder.Services.AddSingleton(_sessionManager); - builder.Services.AddSingleton(new PlanOperations(_sessionManager, AnalyzerConfig.Default)); + builder.Services.AddSingleton(new PlanOperations( + _sessionManager, + AnalyzerConfig.Default, + enforceQueryAdmission: false)); builder.Services.AddSingleton(_connectionStore); builder.Services.AddSingleton(_credentialService); diff --git a/src/PlanViewer.App/Mcp/McpPlanTools.cs b/src/PlanViewer.App/Mcp/McpPlanTools.cs index 5c5a1cb..da6f8a5 100644 --- a/src/PlanViewer.App/Mcp/McpPlanTools.cs +++ b/src/PlanViewer.App/Mcp/McpPlanTools.cs @@ -20,48 +20,6 @@ namespace PlanViewer.App.Mcp; [McpServerToolType] public sealed class McpPlanTools { - // Historical overloads remain callable; MCP discovery uses the attributed overloads below. - public static string AnalyzePlan(PlanSessionManager sessionManager, string session_id) => - AnalyzePlan(sessionManager, CreateOperations(sessionManager), session_id); - - public static string GetPlanSummary(PlanSessionManager sessionManager, string session_id) => - GetPlanSummary(sessionManager, CreateOperations(sessionManager), session_id); - - public static string GetPlanWarnings( - PlanSessionManager sessionManager, - string session_id, - string? severity = null) => - GetPlanWarnings(sessionManager, CreateOperations(sessionManager), session_id, severity); - - public static string GetMissingIndexes(PlanSessionManager sessionManager, string session_id) => - GetMissingIndexes(sessionManager, CreateOperations(sessionManager), session_id); - - public static string GetExpensiveOperators( - PlanSessionManager sessionManager, - string session_id, - int top = 10) => - GetExpensiveOperators(sessionManager, CreateOperations(sessionManager), session_id, top); - - private static PlanOperations CreateOperations(PlanSessionManager sessionManager) => - new(sessionManager, AnalyzerConfig.Default); - - public static string AnalyzePlan( - PlanSessionManager sessionManager, - PlanOperations operations, - string session_id) => - AnalyzePlan(sessionManager, operations, session_id, CancellationToken.None) - .GetAwaiter() - .GetResult(); - - public static string GetPlanSummary( - PlanSessionManager sessionManager, - PlanOperations operations, - string session_id) => - GetPlanSummary(sessionManager, operations, session_id, CancellationToken.None) - .GetAwaiter() - .GetResult(); - - [McpServerTool(Name = "list_plans")] [Description("Lists all execution plans currently loaded in the application. Returns session IDs, labels, " + "statement counts, warning counts, and source type. Use this first to discover available plans.")] diff --git a/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs b/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs index 49ac632..bc94c47 100644 --- a/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs +++ b/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs @@ -19,18 +19,6 @@ namespace PlanViewer.App.Mcp; [McpServerToolType] public sealed class McpQueryStoreTools { - public static Task CheckQueryStore( - ConnectionStore connectionStore, - ICredentialService credentialService, - string connection_name, - string database) => - CheckQueryStore( - connectionStore, - credentialService, - connection_name, - database, - CancellationToken.None); - [McpServerTool(Name = "check_query_store")] [Description("Checks whether Query Store is enabled and accessible on a database. " + "Use this before calling get_query_store_top to verify the target database supports Query Store.")] @@ -72,39 +60,6 @@ public static async Task CheckQueryStore( } } - public static Task GetQueryStoreTop( - PlanSessionManager sessionManager, - ConnectionStore connectionStore, - ICredentialService credentialService, - string connection_name, - string database, - int top = 10, - string order_by = "cpu", - int hours_back = 24, - long? query_id = null, - long? plan_id = null, - string? query_hash = null, - string? plan_hash = null, - string? module = null, - string? execution_type = null) => - GetQueryStoreTop( - sessionManager, - new PlanOperations(sessionManager, AnalyzerConfig.Default), - connectionStore, - credentialService, - connection_name, - database, - CancellationToken.None, - top, - order_by, - hours_back, - query_id, - plan_id, - query_hash, - plan_hash, - module, - execution_type); - [McpServerTool(Name = "get_query_store_top")] [Description("Fetches the top N queries from Query Store ranked by the specified metric. " + "Uses the application's built-in Query Store query — no arbitrary SQL is executed. " + @@ -236,7 +191,7 @@ public static async Task GetQueryStoreTop( qsPlan.QueryText, conn.ServerName); operations.AdmitSnapshot( - session.ToCore(), + session, Encoding.UTF8.GetByteCount(xml), cancellationToken); diff --git a/src/PlanViewer.App/Mcp/PlanSessionManager.cs b/src/PlanViewer.App/Mcp/PlanSessionManager.cs index d3db7c3..9fded26 100644 --- a/src/PlanViewer.App/Mcp/PlanSessionManager.cs +++ b/src/PlanViewer.App/Mcp/PlanSessionManager.cs @@ -3,15 +3,11 @@ using System.Linq; using PlanViewer.Core.Interfaces; using PlanViewer.Core.Models; -using PlanViewer.Core.Output; -using CorePlanSession = PlanViewer.Core.Models.PlanSession; -using CorePlanSessionSummary = PlanViewer.Core.Models.PlanSessionSummary; namespace PlanViewer.App.Mcp; /// -/// Application singleton that preserves the historical App API while implementing the -/// shared Core catalog contract. +/// Application catalog shared by the desktop UI and MCP tools. /// public sealed class PlanSessionManager : IPlanCatalog { @@ -19,31 +15,20 @@ public sealed class PlanSessionManager : IPlanCatalog private readonly ConcurrentDictionary _sessions = new(); - public void Register(CorePlanSession session) => - _sessions[session.SessionId] = PlanSession.FromCore(session); + public void Register(PlanSession session) => + _sessions[session.SessionId] = session; - public bool TryRegister(CorePlanSession session) => - _sessions.TryAdd(session.SessionId, PlanSession.FromCore(session)); + public bool TryRegister(PlanSession session) => + _sessions.TryAdd(session.SessionId, session); - public void Register(string sessionId, PlanSession session) => - _sessions[sessionId] = session; - - public void Unregister(string sessionId) => + public bool Unregister(string sessionId) => _sessions.TryRemove(sessionId, out _); public PlanSession? GetSession(string sessionId) => _sessions.TryGetValue(sessionId, out var session) ? session : null; public IReadOnlyList GetAllSessions() => - _sessions.Values.Select(PlanSessionSummary.FromApp).ToList(); - - bool IPlanCatalog.Unregister(string sessionId) => _sessions.TryRemove(sessionId, out _); - - CorePlanSession? IPlanCatalog.GetSession(string sessionId) => - _sessions.TryGetValue(sessionId, out var session) ? session.ToCore() : null; - - IReadOnlyList IPlanCatalog.GetAllSessions() => - _sessions.Values.Select(session => new CorePlanSessionSummary + _sessions.Values.Select(session => new PlanSessionSummary { SessionId = session.SessionId, Label = session.Label, @@ -55,89 +40,3 @@ IReadOnlyList IPlanCatalog.GetAllSessions() => HasActualStats = session.HasActualStats }).ToList(); } - -/// -/// Historical App-facing plan-session model retained for source and binary compatibility. -/// -public sealed class PlanSession -{ - public required string SessionId { get; init; } - public required string Label { get; init; } - public required string Source { get; init; } - public required ParsedPlan Plan { get; init; } - public AnalysisResult? Analysis { get; init; } - public string? RawPlanXml { get; init; } - public string? DatabaseName { get; init; } - public string? QueryText { get; init; } - public string? ConnectionInfo { get; init; } - public int StatementCount { get; init; } - public bool HasActualStats { get; init; } - public int WarningCount { get; init; } - public int CriticalWarningCount { get; init; } - public int MissingIndexCount { get; init; } - - internal CorePlanSession ToCore(string? sessionId = null) => new() - { - SessionId = sessionId ?? SessionId, - Label = Label, - Source = Source, - Plan = Plan, - Analysis = Analysis, - RawPlanXml = RawPlanXml, - DatabaseName = DatabaseName, - QueryText = QueryText, - ConnectionInfo = ConnectionInfo, - StatementCount = StatementCount, - HasActualStats = HasActualStats, - WarningCount = WarningCount, - CriticalWarningCount = CriticalWarningCount, - MissingIndexCount = MissingIndexCount - }; - - internal static PlanSession FromCore(CorePlanSession session) => new() - { - SessionId = session.SessionId, - Label = session.Label, - Source = session.Source, - Plan = session.Plan, - Analysis = session.Analysis, - RawPlanXml = session.RawPlanXml, - DatabaseName = session.DatabaseName, - QueryText = session.QueryText, - ConnectionInfo = session.ConnectionInfo, - StatementCount = session.StatementCount, - HasActualStats = session.HasActualStats, - WarningCount = session.WarningCount, - CriticalWarningCount = session.CriticalWarningCount, - MissingIndexCount = session.MissingIndexCount - }; - - public static implicit operator CorePlanSession(PlanSession session) => session.ToCore(); -} - -/// -/// Historical mutable App summary DTO retained for compatibility. -/// -public sealed class PlanSessionSummary -{ - public string SessionId { get; set; } = ""; - public string Label { get; set; } = ""; - public string Source { get; set; } = ""; - public int StatementCount { get; set; } - public int WarningCount { get; set; } - public int CriticalWarningCount { get; set; } - public int MissingIndexCount { get; set; } - public bool HasActualStats { get; set; } - - internal static PlanSessionSummary FromApp(PlanSession session) => new() - { - SessionId = session.SessionId, - Label = session.Label, - Source = session.Source, - StatementCount = session.StatementCount, - WarningCount = session.WarningCount, - CriticalWarningCount = session.CriticalWarningCount, - MissingIndexCount = session.MissingIndexCount, - HasActualStats = session.HasActualStats - }; -} diff --git a/src/PlanViewer.Cli/ReplSurface/McpPlanPathPolicy.cs b/src/PlanViewer.Cli/ReplSurface/McpPlanPathPolicy.cs index 7781bc8..5e628b5 100644 --- a/src/PlanViewer.Cli/ReplSurface/McpPlanPathPolicy.cs +++ b/src/PlanViewer.Cli/ReplSurface/McpPlanPathPolicy.cs @@ -158,7 +158,7 @@ private static string ResolveDirectoryEntry(string parent, string segment) foreach (var entry in Directory.EnumerateDirectories(parent)) { var name = Path.GetFileName(Path.TrimEndingDirectorySeparator(entry)); - if (name.Equals(segment, StringComparison.Ordinal)) + if (name.Equals(segment, PathComparisonKind)) return entry; } @@ -180,9 +180,20 @@ internal static bool ContainsWindowsAlternateDataStream(string path) return path.AsSpan(start).Contains(':'); } - private static StringComparer PathComparison => StringComparer.Ordinal; + private static StringComparer PathComparison => + PathComparisonKind == StringComparison.OrdinalIgnoreCase + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; - private static StringComparison PathComparisonKind => StringComparison.Ordinal; + private static StringComparison PathComparisonKind => + GetPathComparisonForPlatform(OperatingSystem.IsWindows(), OperatingSystem.IsMacOS()); + + // Windows and default macOS volumes are case-insensitive. Linux remains ordinal; + // case-sensitive Windows/macOS configurations are outside this pilot's scope. + internal static StringComparison GetPathComparisonForPlatform(bool isWindows, bool isMacOS) => + isWindows || isMacOS + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; } internal sealed class McpAuthorizedPlanFile(FileStream stream, string label) : IAsyncDisposable diff --git a/src/PlanViewer.Core/Output/TextFormatter.cs b/src/PlanViewer.Core/Output/TextFormatter.cs index a8b7f8b..bc6f303 100644 --- a/src/PlanViewer.Core/Output/TextFormatter.cs +++ b/src/PlanViewer.Core/Output/TextFormatter.cs @@ -7,7 +7,7 @@ public static class TextFormatter public static string Format(AnalysisResult result) => FormatCancellable(result, CancellationToken.None); - public static string FormatCancellable(AnalysisResult result, CancellationToken cancellationToken) + internal static string FormatCancellable(AnalysisResult result, CancellationToken cancellationToken) { using var writer = new StringWriter(); WriteTextCancellable(result, writer, cancellationToken); diff --git a/src/PlanViewer.Core/PlanViewer.Core.csproj b/src/PlanViewer.Core/PlanViewer.Core.csproj index 99c0ef4..d903898 100644 --- a/src/PlanViewer.Core/PlanViewer.Core.csproj +++ b/src/PlanViewer.Core/PlanViewer.Core.csproj @@ -20,7 +20,9 @@ - + + + diff --git a/src/PlanViewer.Core/Services/InMemoryPlanCatalog.cs b/src/PlanViewer.Core/Services/InMemoryPlanCatalog.cs index 2bd0434..57f5858 100644 --- a/src/PlanViewer.Core/Services/InMemoryPlanCatalog.cs +++ b/src/PlanViewer.Core/Services/InMemoryPlanCatalog.cs @@ -6,7 +6,7 @@ namespace PlanViewer.Core.Services; /// /// Thread-safe in-memory implementation of the shared plan catalog. /// -public sealed class InMemoryPlanCatalog : IPlanCatalog +internal sealed class InMemoryPlanCatalog : IPlanCatalog { private readonly object _syncRoot = new(); private readonly Dictionary _sessions = diff --git a/src/PlanViewer.Core/Services/PlanAnalysisPipeline.cs b/src/PlanViewer.Core/Services/PlanAnalysisPipeline.cs index 7a45707..f13b80d 100644 --- a/src/PlanViewer.Core/Services/PlanAnalysisPipeline.cs +++ b/src/PlanViewer.Core/Services/PlanAnalysisPipeline.cs @@ -2,15 +2,15 @@ namespace PlanViewer.Core.Services; -public static class PlanAnalysisPipeline +internal static class PlanAnalysisPipeline { - public static ParsedPlan Analyze( + internal static ParsedPlan Analyze( string planXml, AnalyzerConfig config, CancellationToken cancellationToken = default) => Analyze(planXml, config, serverMetadata: null, cancellationToken); - public static ParsedPlan Analyze( + internal static ParsedPlan Analyze( string planXml, AnalyzerConfig config, ServerMetadata? serverMetadata, @@ -49,19 +49,12 @@ internal static async Task AnalyzeAsync( return AnalyzeParsed(plan, config, serverMetadata, cancellationToken, beforeAnalysis); } - public static ParsedPlan AnalyzeParsed( - ParsedPlan plan, - AnalyzerConfig config, - ServerMetadata? serverMetadata = null, - CancellationToken cancellationToken = default) => - AnalyzeParsed(plan, config, serverMetadata, cancellationToken, beforeAnalysis: null); - internal static ParsedPlan AnalyzeParsed( ParsedPlan plan, AnalyzerConfig config, - ServerMetadata? serverMetadata, - CancellationToken cancellationToken, - Action? beforeAnalysis) + ServerMetadata? serverMetadata = null, + CancellationToken cancellationToken = default, + Action? beforeAnalysis = null) { ArgumentNullException.ThrowIfNull(plan); ArgumentNullException.ThrowIfNull(config); diff --git a/src/PlanViewer.Core/Services/PlanOperations.cs b/src/PlanViewer.Core/Services/PlanOperations.cs index 3957194..438880e 100644 --- a/src/PlanViewer.Core/Services/PlanOperations.cs +++ b/src/PlanViewer.Core/Services/PlanOperations.cs @@ -26,12 +26,22 @@ public sealed class PlanOperations private readonly IPlanCatalog _catalog; private readonly AnalyzerConfig _config; private readonly PlanResourceBudget _budget; + private readonly bool _enforceQueryAdmission; public PlanOperations(IPlanCatalog catalog, AnalyzerConfig? config = null) + : this(catalog, config, enforceQueryAdmission: true) + { + } + + internal PlanOperations( + IPlanCatalog catalog, + AnalyzerConfig? config, + bool enforceQueryAdmission) { _catalog = catalog ?? throw new ArgumentNullException(nameof(catalog)); _config = config ?? ConfigLoader.Load(); _budget = PlanResourceBudget.ForCatalog(_catalog); + _enforceQueryAdmission = enforceQueryAdmission; } public async Task OpenAsync( @@ -63,7 +73,7 @@ public async Task OpenAsync( return await OpenStreamCoreAsync(stream, label, cancellationToken).ConfigureAwait(false); } - public async Task OpenOwnedStreamAsync( + internal async Task OpenOwnedStreamAsync( Func> streamFactory, CancellationToken cancellationToken = default) { @@ -167,7 +177,7 @@ private async Task OpenStreamCoreAsync( } } - public PlanSessionSummary AdmitSnapshot( + internal PlanSessionSummary AdmitSnapshot( PlanSession session, long sourceBytes, CancellationToken cancellationToken) @@ -311,7 +321,7 @@ public PlanSummaryResult GetSummary(PlanSession session) public MissingIndexesResult GetMissingIndexes(string sessionId) => GetMissingIndexes(sessionId, CancellationToken.None); - public MissingIndexesResult GetMissingIndexes( + internal MissingIndexesResult GetMissingIndexes( string sessionId, CancellationToken cancellationToken) => GetMissingIndexes(GetRequiredSession(sessionId), cancellationToken); @@ -324,7 +334,7 @@ internal MissingIndexesResult GetMissingIndexes( CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(session); - using var querySlot = _budget.AcquireQuerySlot(cancellationToken); + using var querySlot = AcquireQuerySlot(cancellationToken); var indexes = new List(DefaultMaxMissingIndexResults); var totalIndexCount = 0; var columnsTruncated = false; @@ -373,7 +383,7 @@ internal MissingIndexesResult GetMissingIndexes( public ExpensiveOperatorsResult GetExpensiveOperators(string sessionId, int top = 10) => GetExpensiveOperators(sessionId, top, CancellationToken.None); - public ExpensiveOperatorsResult GetExpensiveOperators( + internal ExpensiveOperatorsResult GetExpensiveOperators( string sessionId, int top, CancellationToken cancellationToken) => @@ -401,7 +411,7 @@ internal ExpensiveOperatorsResult GetExpensiveOperators( if (top is < 1 or > 100) throw new ArgumentOutOfRangeException(nameof(top), top, "Top must be between 1 and 100."); - using var querySlot = _budget.AcquireQuerySlot(cancellationToken); + using var querySlot = AcquireQuerySlot(cancellationToken); var analysis = GetAnalysisCancellable(session, cancellationToken); var topByActual = new List(top); var topByCost = new List(top); @@ -447,7 +457,7 @@ internal ExpensiveOperatorsResult GetExpensiveOperators( public PlanWarningsResult GetWarnings(string sessionId, string? severity = null) => GetWarnings(sessionId, severity, CancellationToken.None); - public PlanWarningsResult GetWarnings( + internal PlanWarningsResult GetWarnings( string sessionId, string? severity, CancellationToken cancellationToken) => @@ -480,7 +490,7 @@ internal PlanWarningsResult GetWarnings( throw new ArgumentException("Severity must be Critical, Warning, or Info.", nameof(severity)); } - using var querySlot = _budget.AcquireQuerySlot(cancellationToken); + using var querySlot = AcquireQuerySlot(cancellationToken); var analysis = GetAnalysisCancellable(session, cancellationToken); var returnedWarnings = new List(DefaultMaxWarningResults); var totalWarningCount = 0; @@ -529,10 +539,18 @@ void AddWarning(WarningResult warning, string statementText) } } - public IDisposable AcquireQueryScope(CancellationToken cancellationToken) => - _budget.AcquireQuerySlot(cancellationToken); + internal IDisposable AcquireQueryScope(CancellationToken cancellationToken) => + AcquireQuerySlot(cancellationToken); + + private IDisposable AcquireQuerySlot(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return _enforceQueryAdmission + ? _budget.AcquireQuerySlot(cancellationToken) + : NoopDisposable.Instance; + } - public AnalysisResult GetAnalysisForRequest( + internal AnalysisResult GetAnalysisForRequest( PlanSession session, CancellationToken cancellationToken) { @@ -550,6 +568,15 @@ public AnalysisResult GetAnalysis(PlanSession session) return session.Analysis ?? ResultMapper.Map(session.Plan, session.Source); } + private sealed class NoopDisposable : IDisposable + { + internal static NoopDisposable Instance { get; } = new(); + + public void Dispose() + { + } + } + private static AnalysisResult GetAnalysisCancellable( PlanSession session, CancellationToken cancellationToken) => diff --git a/src/PlanViewer.Core/Services/PlanResourceBudget.cs b/src/PlanViewer.Core/Services/PlanResourceBudget.cs index 55fc64a..06ecf11 100644 --- a/src/PlanViewer.Core/Services/PlanResourceBudget.cs +++ b/src/PlanViewer.Core/Services/PlanResourceBudget.cs @@ -30,12 +30,7 @@ internal static PlanResourceBudget ForCatalog(IPlanCatalog catalog) internal async Task AcquireOpenSlotAsync(CancellationToken cancellationToken) { - if (!await _openSlots.WaitAsync(0, cancellationToken).ConfigureAwait(false)) - { - throw new InvalidOperationException( - $"The concurrent plan-open limit of {PlanOperations.DefaultMaxConcurrentOpens} has been reached. Retry after an active open completes."); - } - + await _openSlots.WaitAsync(cancellationToken).ConfigureAwait(false); return new SemaphoreLease(_openSlots); } diff --git a/tests/PlanViewer.Core.Tests/McpPlanPathPolicyTests.cs b/tests/PlanViewer.Core.Tests/McpPlanPathPolicyTests.cs index 67b6109..a6a8c2c 100644 --- a/tests/PlanViewer.Core.Tests/McpPlanPathPolicyTests.cs +++ b/tests/PlanViewer.Core.Tests/McpPlanPathPolicyTests.cs @@ -7,6 +7,18 @@ namespace PlanViewer.Core.Tests; public sealed class McpPlanPathPolicyTests { + [Theory] + [InlineData(true, false, StringComparison.OrdinalIgnoreCase)] + [InlineData(false, true, StringComparison.OrdinalIgnoreCase)] + [InlineData(false, false, StringComparison.Ordinal)] + public void GetPathComparisonForPlatform_UsesFilesystemCompatibleCaseRules( + bool isWindows, + bool isMacOS, + StringComparison expected) + { + Assert.Equal(expected, McpPlanPathPolicy.GetPathComparisonForPlatform(isWindows, isMacOS)); + } + [Theory] [InlineData(@"C:\plans\query.sqlplan", false)] [InlineData(@"C:\plans\host.txt:query.sqlplan", true)] diff --git a/tests/PlanViewer.Core.Tests/McpPlanToolsContractTests.cs b/tests/PlanViewer.Core.Tests/McpPlanToolsContractTests.cs index 65b62ad..4b46b79 100644 --- a/tests/PlanViewer.Core.Tests/McpPlanToolsContractTests.cs +++ b/tests/PlanViewer.Core.Tests/McpPlanToolsContractTests.cs @@ -78,8 +78,9 @@ public void BoundedResponses_ExplicitlyReportTruncation() }; manager.Register(session); - using var warnings = JsonDocument.Parse(McpPlanTools.GetPlanWarnings(manager, session.SessionId)); - using var indexes = JsonDocument.Parse(McpPlanTools.GetMissingIndexes(manager, session.SessionId)); + var operations = new PlanOperations(manager); + using var warnings = JsonDocument.Parse(McpPlanTools.GetPlanWarnings(manager, operations, session.SessionId)); + using var indexes = JsonDocument.Parse(McpPlanTools.GetMissingIndexes(manager, operations, session.SessionId)); Assert.True(warnings.RootElement.GetProperty("truncated").GetBoolean()); Assert.Equal( @@ -174,28 +175,38 @@ await Assert.ThrowsAsync( } [Fact] - public void AnalyzePlan_PreservesTheUiSessionSourceWhenMappedOnDemand() + public async Task AnalyzePlan_PreservesTheUiSessionSourceWhenMappedOnDemand() { var manager = new PlanSessionManager(); var plan = PlanTestHelper.LoadAndAnalyze("row_goal_plan.sqlplan"); var sessionId = $"ui-{Guid.NewGuid():N}"; - manager.Register(sessionId, new PlanViewer.App.Mcp.PlanSession + manager.Register(new CorePlanSession { SessionId = sessionId, Label = "row_goal_plan.sqlplan", Source = "file", Plan = plan }); + var operations = new PlanOperations(manager, AnalyzerConfig.Default, enforceQueryAdmission: false); - var json = McpPlanTools.AnalyzePlan(manager, sessionId); + var json = await McpPlanTools.AnalyzePlan( + manager, + operations, + sessionId, + TestContext.Current.CancellationToken); + var summary = await McpPlanTools.GetPlanSummary( + manager, + operations, + sessionId, + TestContext.Current.CancellationToken); using var document = JsonDocument.Parse(json); Assert.Equal("file", document.RootElement.GetProperty("plan_source").GetString()); Assert.DoesNotContain("error", json, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("error", McpPlanTools.GetPlanSummary(manager, sessionId), StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("error", McpPlanTools.GetPlanWarnings(manager, sessionId), StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("error", McpPlanTools.GetMissingIndexes(manager, sessionId), StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("error", McpPlanTools.GetExpensiveOperators(manager, sessionId), StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("error", summary, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("error", McpPlanTools.GetPlanWarnings(manager, operations, sessionId), StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("error", McpPlanTools.GetMissingIndexes(manager, operations, sessionId), StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("error", McpPlanTools.GetExpensiveOperators(manager, operations, sessionId), StringComparison.OrdinalIgnoreCase); } [Fact] @@ -207,7 +218,7 @@ public void GivenQueryStorePlan_WhenCaptured_ThenLegacyToolsRemainExactWithoutTh var legacyPlan = PlanTestHelper.LoadAndAnalyze("param-sniffing-posttypeid2.sqlplan"); var capturedPlan = PlanTestHelper.LoadAndAnalyze("param-sniffing-posttypeid2.sqlplan"); var legacyManager = new PlanSessionManager(); - legacyManager.Register(sessionId, new PlanViewer.App.Mcp.PlanSession + legacyManager.Register(new CorePlanSession { SessionId = sessionId, Label = label, @@ -224,10 +235,10 @@ public void GivenQueryStorePlan_WhenCaptured_ThenLegacyToolsRemainExactWithoutTh queryText, "server"); capturedOperations.AdmitSnapshot( - captured.ToCore(), + captured, Encoding.UTF8.GetByteCount(captured.RawPlanXml!), TestContext.Current.CancellationToken); - var registered = Assert.IsType(capturedManager.GetSession(sessionId)); + var registered = Assert.IsType(capturedManager.GetSession(sessionId)); Assert.Equal("query-store", captured.Source); Assert.Equal(captured.RawPlanXml, registered.RawPlanXml); @@ -251,33 +262,7 @@ public void GivenQueryStorePlan_WhenCaptured_ThenLegacyToolsRemainExactWithoutTh McpPlanTools.GetReproScript(capturedManager, sessionId)); } -#pragma warning disable xUnit1051 // Intentionally exercise synchronous compatibility overloads. - [Fact] - public void HistoricalOverloads_DelegateToTheSharedOperationsBehavior() - { - var manager = new PlanSessionManager(); - var session = CreateEstimatedSession(); - manager.Register(session); - var operations = new PlanOperations(manager, AnalyzerConfig.Default); - - Assert.Equal( - McpPlanTools.AnalyzePlan(manager, operations, session.SessionId), - McpPlanTools.AnalyzePlan(manager, session.SessionId)); - Assert.Equal( - McpPlanTools.GetPlanSummary(manager, operations, session.SessionId), - McpPlanTools.GetPlanSummary(manager, session.SessionId)); - Assert.Equal( - McpPlanTools.GetPlanWarnings(manager, operations, session.SessionId, "Warning"), - McpPlanTools.GetPlanWarnings(manager, session.SessionId, "Warning")); - Assert.Equal( - McpPlanTools.GetMissingIndexes(manager, operations, session.SessionId), - McpPlanTools.GetMissingIndexes(manager, session.SessionId)); - Assert.Equal( - McpPlanTools.GetExpensiveOperators(manager, operations, session.SessionId, 3), - McpPlanTools.GetExpensiveOperators(manager, session.SessionId, 3)); - } -#pragma warning restore xUnit1051 private static CorePlanSession CreateEstimatedSession() { diff --git a/tests/PlanViewer.Core.Tests/PlanResourceBudgetTests.cs b/tests/PlanViewer.Core.Tests/PlanResourceBudgetTests.cs index 6ff315c..03cf657 100644 --- a/tests/PlanViewer.Core.Tests/PlanResourceBudgetTests.cs +++ b/tests/PlanViewer.Core.Tests/PlanResourceBudgetTests.cs @@ -12,12 +12,14 @@ public async Task ForCatalog_SharesConcurrentOpenSlotsAcrossFacades() var second = PlanResourceBudget.ForCatalog(catalog); Assert.Same(first, second); - using var firstLease = await first.AcquireOpenSlotAsync(TestContext.Current.CancellationToken); + var firstLease = await first.AcquireOpenSlotAsync(TestContext.Current.CancellationToken); using var secondLease = await second.AcquireOpenSlotAsync(TestContext.Current.CancellationToken); - var error = await Assert.ThrowsAsync( - async () => await first.AcquireOpenSlotAsync(TestContext.Current.CancellationToken)); - Assert.Contains("concurrent plan-open limit", error.Message, StringComparison.Ordinal); + var queued = first.AcquireOpenSlotAsync(TestContext.Current.CancellationToken); + Assert.False(queued.IsCompleted); + + firstLease.Dispose(); + using var admitted = await queued.WaitAsync(TestContext.Current.CancellationToken); } @@ -55,6 +57,33 @@ public void GivenSharedCatalog_WhenQuerySlotsAreExhausted_ThenOperationsFailFast cancelled.Token)); } + [Fact] + public void GivenDesktopFacade_WhenSharedQuerySlotsAreExhausted_ThenReadOperationsRemainAvailable() + { + var catalog = new InMemoryPlanCatalog(); + var budget = PlanResourceBudget.ForCatalog(catalog); + var operations = new PlanOperations( + catalog, + PlanViewer.Core.Models.AnalyzerConfig.Default, + enforceQueryAdmission: false); + var leases = Enumerable.Range(0, PlanOperations.DefaultMaxConcurrentQueries) + .Select(_ => budget.AcquireQuerySlot(TestContext.Current.CancellationToken)) + .ToList(); + try + { + var result = operations.GetMissingIndexes( + CreateSession("desktop"), + TestContext.Current.CancellationToken); + + Assert.Empty(result.Indexes); + } + finally + { + foreach (var lease in leases) + lease.Dispose(); + } + } + [Fact] public void TryRegister_ReportsCapacityInsteadOfMasqueradingItAsAnIdCollision() { @@ -113,12 +142,10 @@ public async Task OpenOwnedStreamAsync_AcquiresTheSharedSlotBeforeInvokingTheFac Assert.Equal(PlanOperations.DefaultMaxConcurrentOpens, Volatile.Read(ref maxActiveFactories)); Assert.Equal(PlanOperations.DefaultMaxConcurrentOpens, Volatile.Read(ref activeFactories)); - Assert.True(opens[^1].IsFaulted); + Assert.False(opens[^1].IsCompleted); releaseFactories.TrySetResult(); - await Task.WhenAll(opens[..PlanOperations.DefaultMaxConcurrentOpens]) - .WaitAsync(TestContext.Current.CancellationToken); - var error = await Assert.ThrowsAsync(async () => await opens[^1]); - Assert.Contains("concurrent plan-open limit", error.Message, StringComparison.Ordinal); + await Task.WhenAll(opens).WaitAsync(TestContext.Current.CancellationToken); + Assert.Equal(PlanOperations.DefaultMaxConcurrentOpens, maxActiveFactories); } diff --git a/tests/PlanViewer.Core.Tests/PublicApiCompatibilityTests.cs b/tests/PlanViewer.Core.Tests/PublicApiCompatibilityTests.cs deleted file mode 100644 index 3f13119..0000000 --- a/tests/PlanViewer.Core.Tests/PublicApiCompatibilityTests.cs +++ /dev/null @@ -1,84 +0,0 @@ -using PlanViewer.App.Mcp; -using PlanViewer.Core.Interfaces; -using PlanViewer.Core.Models; -using CorePlanSession = PlanViewer.Core.Models.PlanSession; -using PlanViewer.Core.Services; - -namespace PlanViewer.Core.Tests; - -public sealed class PublicApiCompatibilityTests -{ - [Fact] - public void PlanCatalog_PreservesTheOriginalTryRegisterSignature() - { - Assert.NotNull(typeof(IPlanCatalog).GetMethod( - nameof(IPlanCatalog.TryRegister), - [typeof(CorePlanSession)])); - Assert.NotNull(typeof(PlanSessionManager).GetMethod( - nameof(PlanSessionManager.TryRegister), - [typeof(CorePlanSession)])); - } - - [Fact] - public void PlanOperations_PreservesTheOriginalWarningAndOperatorSignatures() - { - Assert.NotNull(typeof(PlanOperations).GetMethod( - nameof(PlanOperations.GetWarnings), - [typeof(string), typeof(string)])); - Assert.NotNull(typeof(PlanOperations).GetMethod( - nameof(PlanOperations.GetExpensiveOperators), - [typeof(string), typeof(int)])); - } - [Fact] - public void AppMcp_PreservesHistoricalSessionTypesAndManagerSignatures() - { - var appAssembly = typeof(PlanSessionManager).Assembly; - var sessionType = appAssembly.GetType("PlanViewer.App.Mcp.PlanSession"); - var summaryType = appAssembly.GetType("PlanViewer.App.Mcp.PlanSessionSummary"); - Assert.NotNull(sessionType); - Assert.NotNull(summaryType); - - var unregister = typeof(PlanSessionManager).GetMethod("Unregister", [typeof(string)]); - var getSession = typeof(PlanSessionManager).GetMethod("GetSession", [typeof(string)]); - var getAllSessions = typeof(PlanSessionManager).GetMethod("GetAllSessions", Type.EmptyTypes); - Assert.NotNull(unregister); - Assert.NotNull(getSession); - Assert.NotNull(getAllSessions); - Assert.Equal(typeof(void), unregister.ReturnType); - Assert.Equal(sessionType, getSession.ReturnType); - Assert.Equal(typeof(IReadOnlyList<>).MakeGenericType(summaryType), getAllSessions.ReturnType); - Assert.NotNull(typeof(PlanSessionManager).GetMethod("Register", [typeof(string), sessionType])); - } - - [Fact] - public void AppMcp_PreservesHistoricalPlanToolOverloads() - { - var manager = typeof(PlanSessionManager); - Assert.NotNull(typeof(McpPlanTools).GetMethod("AnalyzePlan", [manager, typeof(string)])); - Assert.NotNull(typeof(McpPlanTools).GetMethod("GetPlanSummary", [manager, typeof(string)])); - Assert.NotNull(typeof(McpPlanTools).GetMethod("GetPlanWarnings", [manager, typeof(string), typeof(string)])); - Assert.NotNull(typeof(McpPlanTools).GetMethod("GetMissingIndexes", [manager, typeof(string)])); - Assert.NotNull(typeof(McpPlanTools).GetMethod("GetExpensiveOperators", [manager, typeof(string), typeof(int)])); - } - - - [Fact] - public void AppMcp_ManagerPreservesHistoricalRegistrationKeySemantics() - { - var manager = new PlanSessionManager(); - var session = new PlanViewer.App.Mcp.PlanSession - { - SessionId = "payload-id", - Label = "compat.sqlplan", - Source = "compat.sqlplan", - Plan = new ParsedPlan() - }; - - manager.Register("catalog-key", session); - - Assert.Same(session, manager.GetSession("catalog-key")); - Assert.Null(manager.GetSession("payload-id")); - Assert.Equal("payload-id", Assert.Single(manager.GetAllSessions()).SessionId); - } - -}