diff --git a/Lite/Analysis/AnalysisService.cs b/Lite/Analysis/AnalysisService.cs index ad79a4e..43fd508 100644 --- a/Lite/Analysis/AnalysisService.cs +++ b/Lite/Analysis/AnalysisService.cs @@ -51,7 +51,7 @@ public class AnalysisService /// public string? InsufficientDataMessage { get; private set; } - public AnalysisService(DuckDbInitializer duckDb) + public AnalysisService(DuckDbInitializer duckDb, IPlanFetcher? planFetcher = null) { _duckDb = duckDb; _findingStore = new FindingStore(duckDb); @@ -59,7 +59,7 @@ public AnalysisService(DuckDbInitializer duckDb) _scorer = new FactScorer(); _graph = new RelationshipGraph(); _engine = new InferenceEngine(_graph); - _drillDown = new DrillDownCollector(duckDb); + _drillDown = new DrillDownCollector(duckDb, planFetcher); _anomalyDetector = new AnomalyDetector(duckDb); } diff --git a/Lite/Analysis/DrillDownCollector.cs b/Lite/Analysis/DrillDownCollector.cs index 19fcfcb..928d20f 100644 --- a/Lite/Analysis/DrillDownCollector.cs +++ b/Lite/Analysis/DrillDownCollector.cs @@ -21,11 +21,13 @@ namespace PerformanceMonitorLite.Analysis; public class DrillDownCollector { private readonly DuckDbInitializer _duckDb; + private readonly IPlanFetcher? _planFetcher; private const int TextLimit = 500; - public DrillDownCollector(DuckDbInitializer duckDb) + public DrillDownCollector(DuckDbInitializer duckDb, IPlanFetcher? planFetcher = null) { _duckDb = duckDb; + _planFetcher = planFetcher; } /// @@ -550,106 +552,102 @@ ORDER BY waiter_count DESC } /// - /// For findings that have query hashes (from top_cpu_queries, bad_actor_query, or queries_at_spike), - /// fetch cached plan XML and run PlanAnalyzer to surface warnings and missing indexes. + /// For findings that have query hashes (bad actors), fetch the execution plan + /// live from SQL Server via IPlanFetcher, then run PlanAnalyzer to surface + /// warnings and missing indexes. No plan storage needed — fetch on demand + /// only for queries that make it into high-impact findings. /// private async Task CollectPlanAnalysis(AnalysisFinding finding, AnalysisContext context) { - if (finding.DrillDown == null) return; + if (finding.DrillDown == null || _planFetcher == null) return; - // Collect query hashes from drill-down data - var queryHashes = new List(); + // Only analyze plans for bad actor findings (1 plan each). + // Skip top_cpu_queries (5 plans would be too heavy). + if (!finding.RootFactKey.StartsWith("BAD_ACTOR_")) return; - if (finding.DrillDown.TryGetValue("bad_actor_query", out var badActor) && badActor != null) - { - // bad_actor_query is a single anonymous object — extract hash from finding key - var hash = finding.RootFactKey.Replace("BAD_ACTOR_", ""); - if (!string.IsNullOrEmpty(hash)) queryHashes.Add(hash); - } - - // Only analyze plans for findings with a small number of query hashes (bad actors). - // Skip top_cpu_queries (5 plans would be too heavy) — those have next_tools for manual follow-up. - if (queryHashes.Count == 0) return; - - using var readLock = _duckDb.AcquireReadLock(); - using var connection = _duckDb.CreateConnection(); - await connection.OpenAsync(); - - var planFindings = new List(); + var queryHash = finding.RootFactKey.Replace("BAD_ACTOR_", ""); + if (string.IsNullOrEmpty(queryHash)) return; - foreach (var hash in queryHashes.Take(3)) // Max 3 plans to analyze + // Look up plan_handle from DuckDB for this query_hash + string? planHandle = null; + try { + using var readLock = _duckDb.AcquireReadLock(); + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + using var cmd = connection.CreateCommand(); cmd.CommandText = @" -SELECT query_plan_xml +SELECT plan_handle FROM v_query_stats WHERE server_id = $1 AND query_hash = $2 -AND query_plan_xml IS NOT NULL -AND LENGTH(query_plan_xml) > 100 +AND plan_handle IS NOT NULL AND plan_handle != '' ORDER BY collection_time DESC LIMIT 1"; cmd.Parameters.Add(new DuckDBParameter { Value = context.ServerId }); - cmd.Parameters.Add(new DuckDBParameter { Value = hash }); + cmd.Parameters.Add(new DuckDBParameter { Value = queryHash }); using var reader = await cmd.ExecuteReaderAsync(); - if (!await reader.ReadAsync()) continue; + if (await reader.ReadAsync() && !reader.IsDBNull(0)) + planHandle = reader.GetString(0); + } + catch { return; } - var planXml = reader.IsDBNull(0) ? null : reader.GetString(0); - if (string.IsNullOrEmpty(planXml)) continue; + if (string.IsNullOrEmpty(planHandle)) return; - try - { - var plan = ShowPlanParser.Parse(planXml); - PlanAnalyzer.Analyze(plan); + // Fetch plan XML live from SQL Server + var planXml = await _planFetcher.FetchPlanXmlAsync(context.ServerId, planHandle); + if (string.IsNullOrEmpty(planXml)) return; - var allWarnings = plan.Batches - .SelectMany(b => b.Statements) - .Where(s => s.RootNode != null) - .SelectMany(s => - { - var nodeWarnings = new List(); - CollectPlanNodes(s.RootNode!, nodeWarnings); - return s.PlanWarnings - .Concat(nodeWarnings.SelectMany(n => n.Warnings)); - }) - .ToList(); + try + { + var plan = ShowPlanParser.Parse(planXml); + PlanAnalyzer.Analyze(plan); - var missingIndexes = plan.AllMissingIndexes; + var allWarnings = plan.Batches + .SelectMany(b => b.Statements) + .Where(s => s.RootNode != null) + .SelectMany(s => + { + var nodeWarnings = new List(); + CollectPlanNodes(s.RootNode!, nodeWarnings); + return s.PlanWarnings + .Concat(nodeWarnings.SelectMany(n => n.Warnings)); + }) + .ToList(); - if (allWarnings.Count == 0 && missingIndexes.Count == 0) continue; + var missingIndexes = plan.AllMissingIndexes; - planFindings.Add(new - { - query_hash = hash, - warning_count = allWarnings.Count, - critical_count = allWarnings.Count(w => w.Severity == PlanWarningSeverity.Critical), - warnings = allWarnings - .OrderByDescending(w => w.Severity) - .Take(10) - .Select(w => new - { - severity = w.Severity.ToString(), - type = w.WarningType, - message = McpHelpers.Truncate(w.Message, 300) - }), - missing_indexes = missingIndexes.Take(5).Select(idx => new - { - table = $"{idx.Schema}.{idx.Table}", - impact = idx.Impact, - create_statement = idx.CreateStatement - }) - }); - } - catch + if (allWarnings.Count == 0 && missingIndexes.Count == 0) return; + + finding.DrillDown["plan_analysis"] = new { - // Plan parsing can fail on malformed XML — skip silently - } + query_hash = queryHash, + warning_count = allWarnings.Count, + critical_count = allWarnings.Count(w => w.Severity == PlanWarningSeverity.Critical), + warnings = allWarnings + .OrderByDescending(w => w.Severity) + .Take(10) + .Select(w => new + { + severity = w.Severity.ToString(), + type = w.WarningType, + message = McpHelpers.Truncate(w.Message, 300) + }), + missing_indexes = missingIndexes.Take(5).Select(idx => new + { + table = $"{idx.Schema}.{idx.Table}", + impact = idx.Impact, + create_statement = idx.CreateStatement + }) + }; + } + catch + { + // Plan parsing can fail on malformed XML — skip silently } - - if (planFindings.Count > 0) - finding.DrillDown["plan_analysis"] = planFindings; } private static void CollectPlanNodes(PlanNode node, List nodes) diff --git a/Lite/Analysis/IPlanFetcher.cs b/Lite/Analysis/IPlanFetcher.cs new file mode 100644 index 0000000..151d72c --- /dev/null +++ b/Lite/Analysis/IPlanFetcher.cs @@ -0,0 +1,19 @@ +using System.Threading.Tasks; + +namespace PerformanceMonitorLite.Analysis; + +/// +/// Fetches execution plan XML from SQL Server on demand. +/// Platform-agnostic interface — Lite implements via RemoteCollectorService's +/// SQL connection, Dashboard implements via DatabaseService's connection. +/// Used by DrillDownCollector to analyze plans for high-impact findings +/// without storing plan XML in DuckDB or SQL Server tables. +/// +public interface IPlanFetcher +{ + /// + /// Fetches the execution plan XML for a given plan_handle. + /// Returns null if the plan is no longer in cache. + /// + Task FetchPlanXmlAsync(int serverId, string planHandle); +} diff --git a/Lite/Analysis/SqlPlanFetcher.cs b/Lite/Analysis/SqlPlanFetcher.cs new file mode 100644 index 0000000..d7ff439 --- /dev/null +++ b/Lite/Analysis/SqlPlanFetcher.cs @@ -0,0 +1,63 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using PerformanceMonitorLite.Services; + +namespace PerformanceMonitorLite.Analysis; + +/// +/// Lite implementation of IPlanFetcher — fetches plans from SQL Server +/// using the server connection managed by ServerManager. +/// +public class SqlPlanFetcher : IPlanFetcher +{ + private readonly ServerManager _serverManager; + + public SqlPlanFetcher(ServerManager serverManager) + { + _serverManager = serverManager; + } + + public async Task FetchPlanXmlAsync(int serverId, string planHandle) + { + if (string.IsNullOrEmpty(planHandle)) return null; + + // serverId is a hash — find the server by matching the hash + var server = _serverManager.GetAllServers() + .FirstOrDefault(s => s.ServerName.GetHashCode() == serverId); + if (server == null) return null; + + try + { + var connectionString = server.GetConnectionString(_serverManager.CredentialService); + var builder = new SqlConnectionStringBuilder(connectionString) + { + ConnectTimeout = 10, + CommandTimeout = 15 + }; + + await using var connection = new SqlConnection(builder.ConnectionString); + await connection.OpenAsync(); + + await using var cmd = new SqlCommand(@" +SET NOCOUNT ON; +SELECT query_plan +FROM sys.dm_exec_query_plan(CONVERT(varbinary(64), @plan_handle, 1))", connection); + + cmd.CommandTimeout = 15; + cmd.Parameters.AddWithValue("@plan_handle", planHandle); + + var result = await cmd.ExecuteScalarAsync(); + if (result == null || result is DBNull) return null; + + return result.ToString(); + } + catch (Exception ex) + { + AppLogger.Error("SqlPlanFetcher", + $"Failed to fetch plan for handle {planHandle}: {ex.Message}"); + return null; + } + } +} diff --git a/Lite/Mcp/McpHostService.cs b/Lite/Mcp/McpHostService.cs index e81dab8..04e53f0 100644 --- a/Lite/Mcp/McpHostService.cs +++ b/Lite/Mcp/McpHostService.cs @@ -51,7 +51,8 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) builder.Services.AddSingleton(_dataService); builder.Services.AddSingleton(_serverManager); builder.Services.AddSingleton(_muteRuleService); - builder.Services.AddSingleton(new AnalysisService(_duckDb)); + var planFetcher = new SqlPlanFetcher(_serverManager); + builder.Services.AddSingleton(new AnalysisService(_duckDb, planFetcher)); /* Register MCP server with all tool classes */ builder.Services