diff --git a/README.md b/README.md index f71747c..b8eafcd 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,41 @@ planview analyze my_query.sqlplan --output text planview analyze my_query.sqlplan --output text --warnings-only ``` +### Explore plans interactively (Repl pilot) + +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 repl +> 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-]> close +``` + +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 +``` + +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 +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 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..33fae9d --- /dev/null +++ b/docs/repl-pilot.md @@ -0,0 +1,84 @@ +# Repl Read-only Pilot + +## Goal + +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 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 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 + +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 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; 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. + +## TDD slices + +- 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, 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 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} # 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] # 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 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`, `query-store`, `credential`, and their existing options remain on the original System.CommandLine root. +- 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. + +## 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. diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs index befd5ea..195fdb5 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,11 +373,12 @@ public bool LoadPlan(string planXml, string label, string? queryText = null) CountNodeWarnings(s.RootNode, ref warningCount, ref criticalCount); } - PlanSessionManager.Instance.Register(_mcpSessionId, new PlanSession + const string sessionSource = "file"; + PlanSessionManager.Instance.Register(new PlanViewer.Core.Models.PlanSession { SessionId = _mcpSessionId, Label = label, - Source = "file", + Source = sessionSource, Plan = _currentPlan, QueryText = queryText, StatementCount = allStatements.Count, diff --git a/src/PlanViewer.App/Mcp/McpHostService.cs b/src/PlanViewer.App/Mcp/McpHostService.cs index 779445b..c3c24f7 100644 --- a/src/PlanViewer.App/Mcp/McpHostService.cs +++ b/src/PlanViewer.App/Mcp/McpHostService.cs @@ -9,6 +9,8 @@ using ModelContextProtocol.AspNetCore; using PlanViewer.App.Services; using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Models; +using PlanViewer.Core.Services; namespace PlanViewer.App.Mcp; @@ -53,6 +55,11 @@ 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, + 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 916ccd7..da6f8a5 100644 --- a/src/PlanViewer.App/Mcp/McpPlanTools.cs +++ b/src/PlanViewer.App/Mcp/McpPlanTools.cs @@ -1,8 +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; @@ -51,9 +55,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, - [Description("The session_id from list_plans.")] string session_id) + PlanOperations operations, + [Description("The session_id from list_plans.")] string session_id, + CancellationToken cancellationToken) { var session = sessionManager.GetSession(session_id); if (session == null) @@ -61,8 +67,22 @@ public static string AnalyzePlan( try { - var result = ResultMapper.Map(session.Plan, session.Source); - return JsonSerializer.Serialize(result, McpHelpers.JsonOptions); + using var queryScope = operations.AcquireQueryScope(cancellationToken); + var result = operations.GetAnalysisForRequest(session, cancellationToken); + 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) + { + throw; } catch (Exception ex) { @@ -73,22 +93,31 @@ 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, - [Description("The session_id from list_plans.")] string session_id) + PlanOperations operations, + [Description("The session_id from list_plans.")] string session_id, + CancellationToken cancellationToken) { var session = sessionManager.GetSession(session_id); if (session == null) - return SessionNotFound(sessionManager, session_id); + return Task.FromResult(SessionNotFound(sessionManager, session_id)); try { - var result = ResultMapper.Map(session.Plan, session.Source); - return TextFormatter.Format(result); + using var queryScope = operations.AcquireQueryScope(cancellationToken); + var summary = TextFormatter.FormatCancellable( + operations.GetAnalysisForRequest(session, cancellationToken), + cancellationToken); + return Task.FromResult(summary); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; } catch (Exception ex) { - return McpHelpers.FormatError("get_plan_summary", ex); + return Task.FromResult(McpHelpers.FormatError("get_plan_summary", ex)); } } @@ -97,6 +126,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 +136,26 @@ 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, + severity, + includeOperatorWarnings: false, + validateSeverity: false); + 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, + returned_warning_count = result.ReturnedWarningCount, + truncated = result.Truncated, + warnings = result.Warnings + }, McpHelpers.JsonOptions); } catch (Exception ex) @@ -142,29 +169,25 @@ 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); + 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, + returned_index_count = result.ReturnedIndexCount, + truncated = result.Truncated, + indexes = result.Indexes + }, McpHelpers.JsonOptions); } @@ -179,20 +202,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(); @@ -208,6 +230,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 +241,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, top, useBareObjectNames: true); + return JsonSerializer.Serialize( + new { ranked_by = result.RankedBy, operators = result.Operators }, McpHelpers.JsonOptions); } @@ -261,7 +258,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")] @@ -282,8 +279,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) @@ -305,22 +302,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.DatabaseName ?? statement.OperatorTree?.DatabaseName; return ReproScriptBuilder.BuildReproScript( - queryText, databaseName, session.Plan.RawXml, null); + queryText, databaseName, GetRawPlanXml(session), null); } catch (Exception ex) { @@ -328,6 +320,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.RawPlanXml ?? 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 015b839..bc94c47 100644 --- a/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs +++ b/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs @@ -1,12 +1,15 @@ 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; 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) @@ -23,8 +26,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); @@ -32,7 +37,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 { @@ -43,6 +50,10 @@ 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); @@ -58,10 +69,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. " + @@ -74,6 +87,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); @@ -115,7 +129,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." @@ -123,7 +139,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."; @@ -134,7 +155,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 { @@ -151,27 +178,22 @@ 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 allStatements = parsed.Batches.SelectMany(b => b.Statements).ToList(); - - sessionManager.Register(sessionId, new PlanSession - { - SessionId = sessionId, - Label = label, - Source = "query-store", - Plan = parsed, - 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 - }); + cancellationToken.ThrowIfCancellationRequested(); + var parsed = PlanAnalysisPipeline.Analyze( + xml, + AnalyzerConfig.Default, + serverMetadata, + cancellationToken); + var session = CaptureSession( + sessionId, + label, + parsed, + qsPlan.QueryText, + conn.ServerName); + operations.AdmitSnapshot( + session, + Encoding.UTF8.GetByteCount(xml), + cancellationToken); return new { @@ -190,15 +212,20 @@ 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 + 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)"", @@ -219,7 +246,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(); @@ -234,12 +262,50 @@ 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); } } + 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 executableStatement = allStatements.FirstOrDefault(statement => statement.RootNode is not null); + var session = new PlanSession + { + SessionId = sessionId, + Label = label, + Source = "query-store", + Plan = parsed, + Analysis = analysis, + RawPlanXml = parsed.RawXml, + DatabaseName = executableStatement?.RootNode?.DatabaseName, + 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/Mcp/PlanSessionManager.cs b/src/PlanViewer.App/Mcp/PlanSessionManager.cs index b20a5ee..9fded26 100644 --- a/src/PlanViewer.App/Mcp/PlanSessionManager.cs +++ b/src/PlanViewer.App/Mcp/PlanSessionManager.cs @@ -1,70 +1,42 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +using PlanViewer.Core.Interfaces; using PlanViewer.Core.Models; 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 catalog shared by the desktop UI and MCP tools. /// -public sealed class PlanSessionManager +public sealed class PlanSessionManager : IPlanCatalog { public static PlanSessionManager Instance { get; } = new(); private readonly ConcurrentDictionary _sessions = new(); - public void Register(string sessionId, PlanSession session) => - _sessions[sessionId] = session; + public void Register(PlanSession session) => + _sessions[session.SessionId] = session; - public void Unregister(string sessionId) => + public bool TryRegister(PlanSession session) => + _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.Select(s => new PlanSessionSummary + _sessions.Values.Select(session => 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 + 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(); } - -/// -/// 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 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.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/src/PlanViewer.Cli/CliRouting.cs b/src/PlanViewer.Cli/CliRouting.cs new file mode 100644 index 0000000..8a68555 --- /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/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.Cli/PlanViewer.Cli.csproj b/src/PlanViewer.Cli/PlanViewer.Cli.csproj index 4cf474d..cfea7ed 100644 --- a/src/PlanViewer.Cli/PlanViewer.Cli.csproj +++ b/src/PlanViewer.Cli/PlanViewer.Cli.csproj @@ -14,6 +14,12 @@ + + + + + + diff --git a/src/PlanViewer.Cli/Program.cs b/src/PlanViewer.Cli/Program.cs index bfab9e0..f0b451f 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; @@ -14,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 (use 'planview repl' 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..5e628b5 --- /dev/null +++ b/src/PlanViewer.Cli/ReplSurface/McpPlanPathPolicy.cs @@ -0,0 +1,205 @@ +using Repl.Mcp; + +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, + TimeSpan? rootsTimeout = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + ArgumentNullException.ThrowIfNull(clientRoots); + + 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)); + + 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)) || + (OperatingSystem.IsWindows() && ContainsWindowsAlternateDataStream(lexicalPath)) || + !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 ((OperatingSystem.IsWindows() && ContainsWindowsAlternateDataStream(openedPath)) || + !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, + 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(timeoutSource.Token).ConfigureAwait(false); + } + catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested) + { + throw new UnauthorizedAccessException("Client roots negotiation timed out.", exception); + } + 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(ResolveDirectoryEntry(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 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, PathComparisonKind)) + 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); + var rootPrefix = Path.EndsInDirectorySeparator(normalizedRoot) + ? normalizedRoot + : normalizedRoot + Path.DirectorySeparatorChar; + return candidate.StartsWith(rootPrefix, PathComparisonKind); + } + + 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 => + PathComparisonKind == StringComparison.OrdinalIgnoreCase + ? StringComparer.OrdinalIgnoreCase + : StringComparer.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 +{ + 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 new file mode 100644 index 0000000..ea46346 --- /dev/null +++ b/src/PlanViewer.Cli/ReplSurface/PlanReplApp.cs @@ -0,0 +1,42 @@ +using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Services; +using Repl; +using Repl.Mcp; + +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) + { + 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 = "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 new file mode 100644 index 0000000..b56141d --- /dev/null +++ b/src/PlanViewer.Cli/ReplSurface/PlanReplModule.cs @@ -0,0 +1,167 @@ +using System.ComponentModel; +using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Models; +using PlanViewer.Core.Output; +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, IMcpClientRoots? roots = null) => + OpenAsync(path, roots, cancellationToken)) + .WithDescription("Open a SQL Server execution plan"); + + 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, IMcpClientRoots? roots = null) => + OpenAsync(path, roots, cancellationToken)) + .WithDescription("Open a SQL Server execution plan"); + + plan.Context( + "{id}", + scope => + { + scope.Map("summary", (string id) => Execute(() => operations.GetSummary(id))) + .WithDescription("Show a concise plan summary") + .ReadOnly(); + + scope.Map( + "warnings", + (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, 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, 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, CancellationToken cancellationToken) => + Execute(() => operations.GetMissingIndexes(id, cancellationToken))) + .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, + IMcpClientRoots? roots, + CancellationToken cancellationToken) + { + try + { + PlanSessionSummary opened; + if (roots is null) + { + opened = await operations.OpenAsync(path, cancellationToken).ConfigureAwait(false); + } + else + { + 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); + } + catch (UnauthorizedAccessException) + { + return Results.Error("path_not_allowed", "Plan path is outside the allowed roots."); + } + catch (FileNotFoundException ex) + { + 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) + where T : notnull + { + try + { + return operation(); + } + catch (ArgumentException ex) + { + return Results.Error("invalid_argument", ex.Message); + } + catch (KeyNotFoundException) + { + return Results.NotFound("Plan session was not found."); + } + catch (InvalidOperationException ex) + { + return Results.Error("server_busy", 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..6ed6d9f --- /dev/null +++ b/src/PlanViewer.Core/Models/PlanSession.cs @@ -0,0 +1,37 @@ +using PlanViewer.Core.Output; + +namespace PlanViewer.Core.Models; + +/// +/// 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 +{ + 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; } +} + +public sealed class PlanSessionSummary +{ + 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 6834412..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; } = ""; @@ -303,6 +312,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 new file mode 100644 index 0000000..a9e219d --- /dev/null +++ b/src/PlanViewer.Core/Output/PlanOperationResults.cs @@ -0,0 +1,187 @@ +using System.Text.Json.Serialization; + +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")] + 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("returned_warning_count")] + public int ReturnedWarningCount { get; init; } + + [JsonPropertyName("truncated")] + public bool Truncated { 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("returned_index_count")] + public int ReturnedIndexCount { get; init; } + + [JsonPropertyName("truncated")] + public bool Truncated { 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; } = ""; + + [JsonPropertyName("columns_truncated")] + public bool ColumnsTruncated { get; init; } +} diff --git a/src/PlanViewer.Core/Output/ResultMapper.cs b/src/PlanViewer.Core/Output/ResultMapper.cs index cb970c4..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, @@ -249,6 +265,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, @@ -293,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 @@ -322,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/Output/TextFormatter.cs b/src/PlanViewer.Core/Output/TextFormatter.cs index 322dea2..bc6f303 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); + + internal 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 e4f0a76..d903898 100644 --- a/src/PlanViewer.Core/PlanViewer.Core.csproj +++ b/src/PlanViewer.Core/PlanViewer.Core.csproj @@ -20,8 +20,9 @@ - + + + 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 new file mode 100644 index 0000000..57f5858 --- /dev/null +++ b/src/PlanViewer.Core/Services/InMemoryPlanCatalog.cs @@ -0,0 +1,66 @@ +using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Models; + +namespace PlanViewer.Core.Services; + +/// +/// Thread-safe in-memory implementation of the shared plan catalog. +/// +internal sealed class InMemoryPlanCatalog : IPlanCatalog +{ + private readonly object _syncRoot = new(); + private readonly Dictionary _sessions = + new(StringComparer.OrdinalIgnoreCase); + + public void Register(PlanSession session) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentException.ThrowIfNullOrWhiteSpace(session.SessionId); + lock (_syncRoot) + _sessions[session.SessionId] = session; + } + + public bool TryRegister(PlanSession session) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentException.ThrowIfNullOrWhiteSpace(session.SessionId); + lock (_syncRoot) + return _sessions.TryAdd(session.SessionId, session); + } + + public bool Unregister(string sessionId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + lock (_syncRoot) + return _sessions.Remove(sessionId); + } + + public PlanSession? GetSession(string sessionId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + lock (_syncRoot) + return _sessions.GetValueOrDefault(sessionId); + } + + 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/PlanAnalysisPipeline.cs b/src/PlanViewer.Core/Services/PlanAnalysisPipeline.cs new file mode 100644 index 0000000..f13b80d --- /dev/null +++ b/src/PlanViewer.Core/Services/PlanAnalysisPipeline.cs @@ -0,0 +1,76 @@ +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(); + 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, + 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()) + { + 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 new file mode 100644 index 0000000..438880e --- /dev/null +++ b/src/PlanViewer.Core/Services/PlanOperations.cs @@ -0,0 +1,743 @@ +using System.Buffers; +using System.Security.Cryptography; +using System.Text; +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 +{ + 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 DefaultMaxConcurrentQueries = 4; + 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 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( + string path, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + if (!Path.GetExtension(path).Equals(".sqlplan", StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("Only .sqlplan files can be opened."); + + using var openSlot = await _budget.AcquireOpenSlotAsync(cancellationToken).ConfigureAwait(false); + return await OpenPathCoreAsync(path, cancellationToken).ConfigureAwait(false); + } + + 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."); + } + + 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)) + { + 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( + string path, + CancellationToken cancellationToken) + { + var fullPath = Path.GetFullPath(path); + 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) + { + _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) + 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 preflight = await PlanXmlPreflight.ValidateAsync(stream, cancellationToken).ConfigureAwait(false); + using var retainedEstimate = _budget.ReserveRetainedEstimate( + EstimateRetainedAnalysisBytes(stream.Length, preflight)); + 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."); + + var plan = await PlanAnalysisPipeline.AnalyzeAsync( + planXml, + _config, + serverMetadata: null, + cancellationToken, + 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()) + throw new InvalidDataException("Could not parse any statements from the plan XML."); + + 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 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, + CriticalWarningCount = analysis.Summary.CriticalWarnings, + MissingIndexCount = analysis.Summary.MissingIndexes + }; + + if (_budget.TryRegister(session, DefaultMaxSessions, retainedEstimate)) + return ToSummary(session); + _budget.EnsureSessionCapacity(DefaultMaxSessions); + } + } + + internal 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) => + checked( + (sourceBytes * 2) + + ((long)preflight.StatementCount * 2 * 1024) + + ((long)preflight.OperatorCount * 4 * 1024) + + ((long)preflight.ElementCount * 256) + + ((long)preflight.AttributeCount * 128)); + + private static async Task ReadPlanXmlAsync( + FileStream stream, + CancellationToken cancellationToken) + { + stream.Position = 0; + 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, + leaveOpen: true); + var content = new StringBuilder((int)Math.Min(stream.Length, DefaultMaxPlanFileBytes)); + var buffer = ArrayPool.Shared.Rent(64 * 1024); + try + { + while (true) + { + 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); + } + + return new DecodedPlan( + content.ToString(), + sha256.Hash?.ToArray() + ?? throw new InvalidDataException("Could not hash the decoded plan XML.")); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + private readonly record struct DecodedPlan(string Content, byte[] ContentHash); + + public bool Close(string sessionId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + return _budget.TryUnregister(sessionId); + } + + public PlanSummaryResult GetSummary(string sessionId) => + GetSummary(GetRequiredSession(sessionId)); + + public PlanSummaryResult GetSummary(PlanSession session) + { + ArgumentNullException.ThrowIfNull(session); + var analysis = GetAnalysis(session); + 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) => + GetMissingIndexes(sessionId, CancellationToken.None); + + 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); + using var querySlot = AcquireQuerySlot(cancellationToken); + var indexes = new List(DefaultMaxMissingIndexResults); + var totalIndexCount = 0; + var columnsTruncated = false; + foreach (var statement in GetAnalysisCancellable(session, cancellationToken).Statements) + { + cancellationToken.ThrowIfCancellationRequested(); + foreach (var index in statement.MissingIndexes) + { + cancellationToken.ThrowIfCancellationRequested(); + 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 = equalityColumns.Values, + InequalityColumns = inequalityColumns.Values, + IncludeColumns = includeColumns.Values, + CreateStatement = Truncate(index.CreateStatement, DefaultMaxResponseTextLength), + ColumnsTruncated = itemColumnsTruncated + }); + } + } + + return new MissingIndexesResult + { + SessionId = session.SessionId, + MissingIndexCount = totalIndexCount, + ReturnedIndexCount = indexes.Count, + Truncated = indexes.Count < totalIndexCount || columnsTruncated, + Indexes = indexes + }; + } + + public ExpensiveOperatorsResult GetExpensiveOperators(string sessionId, int top = 10) => + 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) => + 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."); + + using var querySlot = AcquireQuerySlot(cancellationToken); + var analysis = GetAnalysisCancellable(session, cancellationToken); + var topByActual = new List(top); + var topByCost = new List(top); + var hasActuals = false; + foreach (var statement in analysis.Statements) + { + 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 ranked = hasActuals ? topByActual : topByCost; + return new ExpensiveOperatorsResult + { + SessionId = session.SessionId, + RankedBy = hasActuals ? "actual_elapsed_ms" : "cost_percent", + Operators = ranked.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 = useBareObjectNames ? item.Node.BareObjectName : item.Node.ObjectName, + Statement = item.Statement + }).ToList() + }; + } + + public PlanWarningsResult GetWarnings(string sessionId, string? severity = null) => + 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) => + 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 && + !new[] { "Critical", "Warning", "Info" }.Contains(severity, StringComparer.OrdinalIgnoreCase)) + { + throw new ArgumentException("Severity must be Critical, Warning, or Info.", nameof(severity)); + } + + using var querySlot = AcquireQuerySlot(cancellationToken); + var analysis = GetAnalysisCancellable(session, cancellationToken); + var returnedWarnings = new List(DefaultMaxWarningResults); + var totalWarningCount = 0; + foreach (var statement in analysis.Statements) + { + cancellationToken.ThrowIfCancellationRequested(); + var statementText = Truncate(statement.StatementText, 200); + foreach (var warning in statement.Warnings) + { + cancellationToken.ThrowIfCancellationRequested(); + AddWarning(warning, statementText); + } + if (includeOperatorWarnings && statement.OperatorTree is not null) + { + VisitOperators(statement.OperatorTree, cancellationToken, node => + { + foreach (var warning in node.Warnings) + { + cancellationToken.ThrowIfCancellationRequested(); + AddWarning(warning, statementText); + } + }); + } + } + + return new PlanWarningsResult + { + SessionId = session.SessionId, + WarningCount = totalWarningCount, + ReturnedWarningCount = returnedWarnings.Count, + 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)); + } + } + + internal IDisposable AcquireQueryScope(CancellationToken cancellationToken) => + AcquireQuerySlot(cancellationToken); + + private IDisposable AcquireQuerySlot(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return _enforceQueryAdmission + ? _budget.AcquireQuerySlot(cancellationToken) + : NoopDisposable.Instance; + } + + internal AnalysisResult GetAnalysisForRequest( + PlanSession session, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(session); + cancellationToken.ThrowIfCancellationRequested(); + return GetAnalysisCancellable(session, cancellationToken); + } + + public AnalysisResult GetAnalysis(string sessionId) => + GetAnalysis(GetRequiredSession(sessionId)); + + public AnalysisResult GetAnalysis(PlanSession session) + { + ArgumentNullException.ThrowIfNull(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) => + session.Analysis ?? ResultMapper.MapCancellable(session.Plan, session.Source, metadata: null, cancellationToken); + + 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 readonly record struct RankedOperator(OperatorResult Node, string Statement); + + private static void VisitOperators( + OperatorResult node, + CancellationToken cancellationToken, + Action visit) + { + var pending = new Stack(); + pending.Push(node); + while (pending.TryPop(out var current)) + { + cancellationToken.ThrowIfCancellationRequested(); + visit(current); + for (var index = current.Children.Count - 1; index >= 0; index--) + pending.Push(current.Children[index]); + } + } + + private static void AddRanked( + List ranked, + RankedOperator candidate, + int maximum, + bool rankByActuals) + { + var candidateScore = rankByActuals + ? candidate.Node.ActualElapsedMs ?? 0 + : candidate.Node.CostPercent; + var insertAt = 0; + while (insertAt < ranked.Count) + { + 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() + { + Severity = Truncate(warning.Severity, 32), + Type = Truncate(warning.Type, 256), + Message = Truncate(warning.Message, DefaultMaxResponseTextLength), + NodeId = warning.NodeId, + Operator = warning.Operator is null ? null : Truncate(warning.Operator, 512), + Statement = statement + }; + + 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)"; + + 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('-'); + 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) || + baseId.Equals("list", StringComparison.OrdinalIgnoreCase) + ? $"plan-{baseId}" + : 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/src/PlanViewer.Core/Services/PlanResourceBudget.cs b/src/PlanViewer.Core/Services/PlanResourceBudget.cs new file mode 100644 index 0000000..06ecf11 --- /dev/null +++ b/src/PlanViewer.Core/Services/PlanResourceBudget.cs @@ -0,0 +1,164 @@ +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 SemaphoreSlim _querySlots = new(PlanOperations.DefaultMaxConcurrentQueries); + 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) + { + await _openSlots.WaitAsync(cancellationToken).ConfigureAwait(false); + 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, + RetainedEstimateReservation reservation) + { + 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."); + } + + if (!_catalog.TryRegister(session)) + return false; + + try + { + CommitUnderLock(reservation, session.SessionId); + return true; + } + catch + { + _catalog.Unregister(session.SessionId); + throw; + } + } + } + + 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 CommitUnderLock(RetainedEstimateReservation reservation, string sessionId) + { + 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) + { + 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 PlanResourceBudget? Owner => _owner; + internal long Bytes { get; } + 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 SemaphoreLease(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..339aa58 --- /dev/null +++ b/src/PlanViewer.Core/Services/PlanXmlPreflight.cs @@ -0,0 +1,155 @@ +using System.Security.Cryptography; +using System.Xml; + +namespace PlanViewer.Core.Services; + +internal readonly record struct PlanXmlPreflightResult( + int StatementCount, + int OperatorCount, + int ElementCount, + int AttributeCount, + byte[] ContentHash); + +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, + 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 ancestry = new Stack<(string LocalName, string NamespaceUri)>(); + var statements = 0; + var operators = 0; + var elements = 0; + var attributes = 0; + stream.Position = 0; + try + { + 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(); + 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.EndElement) + { + ancestry.Pop(); + continue; + } + + if (reader.NodeType != XmlNodeType.Element) + continue; + if (reader.Depth > DefaultMaxXmlDepth) + { + throw new InvalidDataException( + $"Plan XML exceeds the supported depth limit of {DefaultMaxXmlDepth}."); + } + + 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 isConditionalStatement = + isShowPlanElement && + parent.LocalName == "Condition" && + parent.NamespaceUri == ShowPlanNamespace; + var isFallbackStatement = + isShowPlanElement && + reader.LocalName == "StmtSimple" && + !isDirectStatement; + if (isDirectStatement || isCursorOperation || isConditionalStatement || isFallbackStatement) + { + 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)); + } + + return new PlanXmlPreflightResult( + statements, + operators, + elements, + attributes, + sha256.Hash?.ToArray() + ?? throw new InvalidDataException("Could not hash the plan XML during preflight.")); + } + catch (XmlException exception) + { + throw new InvalidDataException($"Could not parse plan XML: {exception.Message}", exception); + } + finally + { + stream.Position = 0; + } + + } +} 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.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..35a66f3 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,119 @@ 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, + Action? beforeCostComputation = null) + { + 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, beforeCostComputation); + } + 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, + Action? beforeCostComputation = null) + { 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(); + beforeCostComputation?.Invoke(); + ComputeOperatorCosts(plan, cancellationToken); + 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 +137,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 +155,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 +194,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 +213,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 +221,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 +307,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 +346,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 +367,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 +380,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 +397,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/CliRoutingTests.cs b/tests/PlanViewer.Core.Tests/CliRoutingTests.cs new file mode 100644 index 0000000..c866b6e --- /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("query-store")] + [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_PreserveLegacyRouting() => + Assert.False(CliRouting.ShouldUseRepl([])); + + [Fact] + public void ReplAlias_IsRemovedBeforeDispatch() => + Assert.Equal(["plan", "list"], CliRouting.GetReplArgs(["repl", "plan", "list"])); +} 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/McpPlanPathPolicyTests.cs b/tests/PlanViewer.Core.Tests/McpPlanPathPolicyTests.cs new file mode 100644 index 0000000..a6a8c2c --- /dev/null +++ b/tests/PlanViewer.Core.Tests/McpPlanPathPolicyTests.cs @@ -0,0 +1,195 @@ +using PlanViewer.Cli.ReplSurface; +using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Services; +using Repl.Mcp; + +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)] + [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_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() + { + 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 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, + 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..4b46b79 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/McpPlanToolsContractTests.cs @@ -0,0 +1,293 @@ +using System.Text; +using System.Text.Json; +using PlanViewer.App.Mcp; +using PlanViewer.Core.Interfaces; +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; + +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 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); + + 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( + 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() + { + 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()); + } + + + [Fact] +#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(); + manager.Register(session); + var operations = new PlanOperations(manager); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAsync( + () => McpPlanTools.AnalyzePlan(manager, operations, session.SessionId, cancellation.Token)); + await Assert.ThrowsAsync( + () => McpPlanTools.GetPlanSummary(manager, operations, session.SessionId, cancellation.Token)); + } + + [Fact] + 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(new CorePlanSession + { + SessionId = sessionId, + Label = "row_goal_plan.sqlplan", + Source = "file", + Plan = plan + }); + var operations = new PlanOperations(manager, AnalyzerConfig.Default, enforceQueryAdmission: false); + + 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", 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] + public void GivenQueryStorePlan_WhenCaptured_ThenLegacyToolsRemainExactWithoutTheParserGraph() + { + 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(new CorePlanSession + { + SessionId = sessionId, + Label = label, + Source = "query-store", + Plan = legacyPlan, + QueryText = queryText + }); + var capturedManager = new PlanSessionManager(); + var capturedOperations = new PlanOperations(capturedManager, AnalyzerConfig.Default); + var captured = McpQueryStoreTools.CaptureSession( + sessionId, + label, + capturedPlan, + queryText, + "server"); + capturedOperations.AdmitSnapshot( + captured, + 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.RawPlanXml)); + 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)); + } + + + + private static CorePlanSession CreateEstimatedSession() + { + var plan = PlanTestHelper.LoadAndAnalyze("row_goal_plan.sqlplan"); + var analysis = ResultMapper.Map(plan, "row_goal_plan.sqlplan"); + return new CorePlanSession + { + 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(CorePlanSession session) => throw new InvalidOperationException(); + public bool TryRegister(CorePlanSession session) => throw new InvalidOperationException(); + public bool Unregister(string sessionId) => 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 new file mode 100644 index 0000000..8f55870 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/McpSmokeTests.cs @@ -0,0 +1,201 @@ +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using PlanViewer.Cli; + +namespace PlanViewer.Core.Tests; + +public sealed class McpSmokeTests +{ + [Fact] + public async Task GeneratedMcpServer_ExposesOnlyBoundedPlanToolsOverStdio() + { + 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", + Command = "dotnet", + 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).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]{12}", + 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.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/PlanAnalysisPipelineTests.cs b/tests/PlanViewer.Core.Tests/PlanAnalysisPipelineTests.cs new file mode 100644 index 0000000..7bffb15 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/PlanAnalysisPipelineTests.cs @@ -0,0 +1,57 @@ +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 AnalyzeParsed_ObservesCancellationAtTheAnalysisBoundary() + { + var plan = PlanTestHelper.LoadAndAnalyze("row_goal_plan.sqlplan"); + using var cancellation = new CancellationTokenSource(); + + Assert.ThrowsAny(() => + PlanAnalysisPipeline.AnalyzeParsed( + plan, + AnalyzerConfig.Default, + serverMetadata: null, + cancellation.Token, + beforeAnalysis: (_, _) => cancellation.Cancel())); + } + + [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/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..0d43d2c --- /dev/null +++ b/tests/PlanViewer.Core.Tests/PlanOperationsTests.cs @@ -0,0 +1,694 @@ +using System.Text; +using PlanViewer.Core.Services; + +namespace PlanViewer.Core.Tests; + +public sealed class PlanOperationsTests +{ + [Fact] + public async Task ParseAsync_ObservesCancellationBeforeCostComputation() + { + var xml = await File.ReadAllTextAsync( + Path.GetFullPath(Path.Combine("Plans", "row_goal_plan.sqlplan")), + TestContext.Current.CancellationToken); + using var cancellation = new CancellationTokenSource(); + + await Assert.ThrowsAnyAsync( + () => 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] + 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.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.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] + 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_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, severity: null, TestContext.Current.CancellationToken); + + 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() + { + 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", TestContext.Current.CancellationToken); + + 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, TestContext.Current.CancellationToken); + + 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 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() + { + 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, TestContext.Current.CancellationToken); + + 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_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() + { + 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, TestContext.Current.CancellationToken); + + 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.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() + { + 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", TestContext.Current.CancellationToken)); + Assert.Throws(() => operations.GetExpensiveOperators(opened.SessionId, 0, TestContext.Current.CancellationToken)); + Assert.Throws(() => operations.GetExpensiveOperators(opened.SessionId, 101, TestContext.Current.CancellationToken)); + } + + + [Fact] + 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); + + 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, sessionIds.Count); + 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]{{12}}$", 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")); + 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, 2).Select(async _ => + { + try + { + await operations.OpenAsync(path, TestContext.Current.CancellationToken); + return true; + } + catch (InvalidOperationException) + { + return false; + } + })); + + 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); + 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] + 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() + { + 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); + } + } + + 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/PlanReplTests.cs b/tests/PlanViewer.Core.Tests/PlanReplTests.cs new file mode 100644 index 0000000..ccfac09 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/PlanReplTests.cs @@ -0,0 +1,70 @@ +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 = $"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); + 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); + } + + [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/PlanResourceBudgetTests.cs b/tests/PlanViewer.Core.Tests/PlanResourceBudgetTests.cs new file mode 100644 index 0000000..03cf657 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/PlanResourceBudgetTests.cs @@ -0,0 +1,173 @@ +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); + var firstLease = await first.AcquireOpenSlotAsync(TestContext.Current.CancellationToken); + using var secondLease = await second.AcquireOpenSlotAsync(TestContext.Current.CancellationToken); + + var queued = first.AcquireOpenSlotAsync(TestContext.Current.CancellationToken); + Assert.False(queued.IsCompleted); + + firstLease.Dispose(); + using var admitted = await queued.WaitAsync(TestContext.Current.CancellationToken); + } + + + [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 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() + { + var catalog = new InMemoryPlanCatalog(); + 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, reservation)); + + 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.False(opens[^1].IsCompleted); + releaseFactories.TrySetResult(); + await Task.WhenAll(opens).WaitAsync(TestContext.Current.CancellationToken); + + 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/PlanViewer.Core.Tests.csproj b/tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj index 80553b3..2891bc5 100644 --- a/tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj +++ b/tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj @@ -12,12 +12,15 @@ + + + diff --git a/tests/PlanViewer.Core.Tests/PlanXmlPreflightTests.cs b/tests/PlanViewer.Core.Tests/PlanXmlPreflightTests.cs new file mode 100644 index 0000000..88ed338 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/PlanXmlPreflightTests.cs @@ -0,0 +1,259 @@ +using System.Security.Cryptography; +using System.Text; +using PlanViewer.Core.Services; + +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() + { + 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_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_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() + { + 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() + { + 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); + } + } + + private static FileStream OpenPlan(string path) => new( + path, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + 64 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); +}