From a4dcb2d053bee8da2635a9f0c8b7db2c6fb1b738 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:18:57 -0400 Subject: [PATCH] WS3: percent-autogrowth-on-large-files config recommendation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First WS3 catalog fact for the recommendations-engine rebuild. Flags large (>= 10 GB) data/log files set to PERCENTAGE autogrowth — a % growth on a big file is a single huge, stalling allocation — and recommends switching to a size-tiered fixed-MB FILEGROWTH. Advise + copy-paste only this PR (no Apply: no handler is registered for the FILE_AUTOGROWTH_PERCENT fact key). Mirrors the existing DB_CONFIG config-advisory pattern end to end: - Collectors: new FILE_AUTOGROWTH_PERCENT fact (Source "config") from collect.database_size_stats (Dashboard) / database_size_stats (Lite), latest row per file, system DBs excluded; metadata carries file/db counts. - Scoring: new "config"-source scorer arm scores it base 0.3 (advisory), below the 0.5 incident threshold; all other config-source leaf/amplifier facts keep scoring 0 exactly as before. - Advice: authored AdviceBlock (Headline/Investigation/Remediation). - Drill-down: lists offending files with a per-file copy-paste ALTER DATABASE ... MODIFY FILE built from a SHARED renderer (collector and reader render byte-identical statements); collected below the 0.5 gate. - Rooting: added FILE_AUTOGROWTH_PERCENT to ConfigAdvisoryRootKeys so it roots standalone at 0.3. - Reader: copy-paste flows through MapEngineFinding via a persisted advisory RemediationAction carrying typed FileGrowthTargets (the drill-down is ephemeral / not read back) — round-trips through AlertContextSerializer. - MCP next-tool maps updated for parity (both apps). Tests: scorer (0.3 when files present, 0 when none), advice-block-exists, standalone rooting at 0.3, reader copy-paste + MapEngineFinding, serialize/ deserialize round-trip, and Lite collector emission smoke tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- Dashboard.Tests/AnalysisNotificationTests.cs | 56 ++++++++ Dashboard.Tests/FactScorerTests.cs | 51 ++++++++ Dashboard.Tests/InferenceEngineTests.cs | 18 +++ Dashboard.Tests/RecommendationDeduperTests.cs | 54 ++++++++ Dashboard/Analysis/AnalysisService.cs | 3 +- .../Analysis/SqlServerDrillDownCollector.cs | 80 ++++++++++++ Dashboard/Analysis/SqlServerFactCollector.cs | 67 ++++++++++ Dashboard/Mcp/McpAnalysisTools.cs | 1 + .../Recommendations/RecommendationsReader.cs | 22 +++- Lite.Tests/FactCollectorTests.cs | 36 ++++++ Lite/Analysis/DrillDownCollector.cs | 69 ++++++++++ Lite/Analysis/DuckDbFactCollector.cs | 60 +++++++++ Lite/Analysis/TestDataSeeder.cs | 40 ++++++ Lite/Mcp/McpAnalysisTools.cs | 5 + PerformanceMonitor.Analysis/FactAdvice.cs | 7 + .../FactRemediation.cs | 120 ++++++++++++++++++ PerformanceMonitor.Analysis/FactScorer.cs | 20 +++ .../InferenceEngine.cs | 1 + .../RemediationAction.cs | 23 +++- .../AlertContext.cs | 46 ++++++- 20 files changed, 772 insertions(+), 7 deletions(-) diff --git a/Dashboard.Tests/AnalysisNotificationTests.cs b/Dashboard.Tests/AnalysisNotificationTests.cs index b2869481..ae1e66e2 100644 --- a/Dashboard.Tests/AnalysisNotificationTests.cs +++ b/Dashboard.Tests/AnalysisNotificationTests.cs @@ -636,4 +636,60 @@ public void PersistedRcsiAction_RendersTwoSidedConsentGate_WithFindingNull() Assert.Contains("80%", inaction); // 80% reader/writer Assert.Contains("RCSI eliminates", inaction); // the >=50% reader/writer arm } + + // ── WS3: percent-autogrowth advisory action persists + round-trips ────────── + + // The advisory FILE_AUTOGROWTH_PERCENT action is built from the drill-down and carries + // its per-file targets through the SAME SerializeAction/DeserializeAction round-trip the + // store uses for remediation_action_json — so the Recommendations reader can rebuild the + // copy-paste on read (the drill-down itself is ephemeral). No handler is registered for + // the key, so it never produces an Apply button. + [Fact] + public void FileAutogrowthAction_BuildsFromDrillDown_AndRoundTrips() + { + var finding = new AnalysisFinding + { + ServerId = 1, + ServerName = "S", + Category = "config", + StoryPath = "FILE_AUTOGROWTH_PERCENT", + StoryPathHash = "fa9001", + RootFactKey = "FILE_AUTOGROWTH_PERCENT", + DrillDown = new Dictionary + { + ["autogrowth_percent_files"] = new List + { + new { database = "AppDb", logical_file_name = "AppDb_log", file_type = "LOG", + total_size_mb = 250000.0, growth_pct = 10, + issue = "10% autogrowth on 244.1 GB LOG file", + alter_statement = "ignored-on-read" } + } + } + }; + + var action = FactRemediation.BuildFileAutogrowthAction(finding); + Assert.NotNull(action); + Assert.Equal("FILE_AUTOGROWTH_PERCENT", action!.FactKey); + Assert.NotNull(action.FileGrowthTargets); + Assert.Single(action.FileGrowthTargets!); + // 250 GB file -> 1024 MB tier. + Assert.Equal(1024, action.FileGrowthTargets![0].RecommendedGrowthMb); + + var json = AlertContextSerializer.SerializeAction(action); + Assert.NotNull(json); + var restored = AlertContextSerializer.DeserializeAction(json); + + Assert.NotNull(restored); + Assert.Equal("FILE_AUTOGROWTH_PERCENT", restored!.FactKey); + Assert.NotNull(restored.FileGrowthTargets); + var t = Assert.Single(restored.FileGrowthTargets!); + Assert.Equal("AppDb", t.Database); + Assert.Equal("AppDb_log", t.LogicalFileName); + Assert.Equal(10, t.CurrentGrowthPercent); + Assert.Equal(1024, t.RecommendedGrowthMb); + // The shared renderer rebuilds the exact copy-paste from the restored target. + Assert.Equal( + "ALTER DATABASE [AppDb] MODIFY FILE (NAME = [AppDb_log], FILEGROWTH = 1024MB);", + FactRemediation.BuildModifyFileStatement(t.Database, t.LogicalFileName, t.RecommendedGrowthMb)); + } } diff --git a/Dashboard.Tests/FactScorerTests.cs b/Dashboard.Tests/FactScorerTests.cs index bddc498a..408190d9 100644 --- a/Dashboard.Tests/FactScorerTests.cs +++ b/Dashboard.Tests/FactScorerTests.cs @@ -72,4 +72,55 @@ public void Amplifier_SeverityCappedAt2() Assert.True(cx.Severity <= 2.0, "Severity should never exceed 2.0"); Assert.Equal(2.0, cx.Severity); } + + /* ── WS3: percent-autogrowth-on-large-files config fact ── */ + + // A FILE_AUTOGROWTH_PERCENT fact scores the 0.3 advisory base when at least one large + // percent-growth file was found (file_count > 0) — mirroring DB_CONFIG's single-misconfig + // base. It is deliberately below the 0.5 incident threshold; it surfaces only because it + // is a config-advisory root key (see the InferenceEngine rooting test). + [Fact] + public void FileAutogrowthPercent_ScoresAdvisoryBase_WhenFilesPresent() + { + var facts = new List + { + new() { Source = "config", Key = "FILE_AUTOGROWTH_PERCENT", Value = 2, + Metadata = new() { ["file_count"] = 2, ["database_count"] = 1 } } + }; + + var scorer = new FactScorer(); + scorer.ScoreAll(facts); + + Assert.Equal(0.3, facts[0].BaseSeverity, precision: 4); + } + + // No offending files (file_count == 0) → no severity. Defends the collector contract that + // the fact is emitted only when file_count > 0, and the scorer's own guard. + [Fact] + public void FileAutogrowthPercent_ScoresZero_WhenNoFiles() + { + var facts = new List + { + new() { Source = "config", Key = "FILE_AUTOGROWTH_PERCENT", Value = 0, + Metadata = new() { ["file_count"] = 0, ["database_count"] = 0 } } + }; + + var scorer = new FactScorer(); + scorer.ScoreAll(facts); + + Assert.Equal(0.0, facts[0].BaseSeverity, precision: 4); + } + + // Advice exists for the fact key (a missing AdviceBlock is the P1 dead-fact bug class — + // a fact that roots but renders no advice). Headline must be the authored copy. + [Fact] + public void FileAutogrowthPercent_HasAdviceBlock() + { + var advice = FactAdvice.GetForFactKey("FILE_AUTOGROWTH_PERCENT"); + + Assert.NotNull(advice); + Assert.Equal("Large file(s) growing in percentage steps", advice!.Headline); + Assert.False(string.IsNullOrWhiteSpace(advice.Investigation)); + Assert.False(string.IsNullOrWhiteSpace(advice.Remediation)); + } } diff --git a/Dashboard.Tests/InferenceEngineTests.cs b/Dashboard.Tests/InferenceEngineTests.cs index 4f3d9673..a8d3930e 100644 --- a/Dashboard.Tests/InferenceEngineTests.cs +++ b/Dashboard.Tests/InferenceEngineTests.cs @@ -80,4 +80,22 @@ public void IncidentFact_BelowMinimumSeverity_DoesNotRoot() Assert.DoesNotContain(stories, s => s.RootFactKey == "CPU_SQL_PERCENT"); } + + // WS3: a FILE_AUTOGROWTH_PERCENT fact at its 0.3 advisory base roots a standalone + // recommendation, below the 0.5 incident threshold — because it is a config-advisory root + // key. Mirrors ConfigFact_RootsStandalone_BelowMinimumSeverity for the new key. + [Fact] + public void FileAutogrowthPercentFact_RootsStandalone_BelowMinimumSeverity() + { + var engine = new InferenceEngine(new RelationshipGraph()); + var facts = new List + { + new() { Key = "FILE_AUTOGROWTH_PERCENT", Source = "config", Value = 2, Severity = 0.3, + Metadata = new Dictionary { ["file_count"] = 2 } } + }; + + var stories = engine.BuildStories(facts); + + Assert.Contains(stories, s => s.RootFactKey == "FILE_AUTOGROWTH_PERCENT"); + } } diff --git a/Dashboard.Tests/RecommendationDeduperTests.cs b/Dashboard.Tests/RecommendationDeduperTests.cs index 47764da1..e8009ac1 100644 --- a/Dashboard.Tests/RecommendationDeduperTests.cs +++ b/Dashboard.Tests/RecommendationDeduperTests.cs @@ -401,6 +401,60 @@ public void MapEngineFinding_DbConfig_CarriesSettingActionAndHash() Assert.Contains("AUTO_SHRINK OFF", item.CopyPasteSql); } + // ---- WS3: percent-autogrowth advisory copy-paste flows through the reader ---- + + [Fact] + public void BuildCopyPasteFromAction_FileAutogrowth_RendersModifyFileStatements() + { + var action = new RemediationAction( + "FILE_AUTOGROWTH_PERCENT", "advise", System.Array.Empty(), + FileGrowthTargets: new[] + { + // Bracket-doubling is exercised on both the db and the logical file name. + new FileGrowthTarget("My]Db", "data]1", 60000, 10, 512), + new FileGrowthTarget("My]Db", "log]1", 250000, 25, 1024) + }); + + var sql = RecommendationsReader.BuildCopyPasteFromAction(action); + + Assert.NotNull(sql); + Assert.Contains("ALTER DATABASE [My]]Db] MODIFY FILE (NAME = [data]]1], FILEGROWTH = 512MB);", sql); + Assert.Contains("ALTER DATABASE [My]]Db] MODIFY FILE (NAME = [log]]1], FILEGROWTH = 1024MB);", sql); + } + + [Fact] + public void MapEngineFinding_FileAutogrowth_CarriesCopyPasteAndNoDeDupeSetting() + { + var action = new RemediationAction( + "FILE_AUTOGROWTH_PERCENT", "advise", System.Array.Empty(), + FileGrowthTargets: new[] { new FileGrowthTarget("MyDb", "MyDb_data", 120000, 10, 512) }); + + var finding = new AnalysisFinding + { + ServerId = 1, + ServerName = "S", + DatabaseName = "MyDb", + Severity = 0.3, + Category = "config", + StoryText = "Large file(s) growing in percentage steps", + RootFactKey = "FILE_AUTOGROWTH_PERCENT", + StoryPathHash = "fa9000", + StoryPath = "FILE_AUTOGROWTH_PERCENT", + Remediation = action + }; + + var item = RecommendationsReader.MapEngineFinding(finding); + + // Advise + copy-paste only: the copy-paste flows, but the row never de-dupes (file- + // level, not a per-DB config-setting collision) and the action stays attached. + Assert.Equal(RecommendationSetting.None, item.Setting); + Assert.Same(action, item.Remediation); + Assert.NotNull(item.CopyPasteSql); + Assert.Contains("MODIFY FILE (NAME = [MyDb_data], FILEGROWTH = 512MB);", item.CopyPasteSql); + // Headline from the authored advice block. + Assert.Equal("Large file(s) growing in percentage steps", item.Title); + } + [Fact] public void MapLegacyIssue_MemoryPressure_IsNoneAndAdviseOnly() { diff --git a/Dashboard/Analysis/AnalysisService.cs b/Dashboard/Analysis/AnalysisService.cs index c0c20d86..bb72eb80 100644 --- a/Dashboard/Analysis/AnalysisService.cs +++ b/Dashboard/Analysis/AnalysisService.cs @@ -167,7 +167,8 @@ public async Task> AnalyzeAsync(AnalysisContext context) finding.Remediation = FactRemediation.BuildAction(finding) ?? FactRemediation.BuildRcsiAction(finding) - ?? FactRemediation.BuildClearPlanAction(finding); + ?? FactRemediation.BuildClearPlanAction(finding) + ?? FactRemediation.BuildFileAutogrowthAction(finding); // WS3: advisory only (no handler -> no Apply); carried for the read-time copy-paste } // 7. Insert the survivors in one batched pass, persisting remediation_action_json diff --git a/Dashboard/Analysis/SqlServerDrillDownCollector.cs b/Dashboard/Analysis/SqlServerDrillDownCollector.cs index 2c305961..6a008107 100644 --- a/Dashboard/Analysis/SqlServerDrillDownCollector.cs +++ b/Dashboard/Analysis/SqlServerDrillDownCollector.cs @@ -55,6 +55,13 @@ the 0.5 display gate. */ if (pathKeys.Contains("DB_CONFIG")) await CollectConfigIssues(finding, context); + /* WS3: the percent-autogrowth drill-down is a single cheap config-table read + and is required to render the copy-paste MODIFY FILE fix for a + FILE_AUTOGROWTH_PERCENT finding, which scores 0.3 (advisory). Collect it + regardless of the 0.5 display gate, like the config drill-down above. */ + if (pathKeys.Contains("FILE_AUTOGROWTH_PERCENT")) + await CollectAutogrowthPercentFiles(finding, context); + // Below the 0.5 display gate, only the cheap config drill-down above runs; // the expensive collectors (plan fetches, multi-row reads) are skipped. if (finding.Severity < 0.5) @@ -803,6 +810,79 @@ FROM pivoted finding.DrillDown!["config_issues"] = items; } + /// + /// Lists the large (>= 10 GB) data/log files on PERCENTAGE autogrowth (WS3), latest + /// snapshot per file, excluding system databases — and attaches a copy-paste + /// ALTER DATABASE ... MODIFY FILE fix per file (FILEGROWTH set to a size-tiered + /// fixed MB). The structured fields (database, logical_file_name, + /// total_size_mb, growth_pct) are what the shared extractor + /// (FactRemediation.ExtractFileGrowthTargets) reads; the rendered alter_statement + /// uses the SHARED renderer so it is byte-identical to the reader's copy-paste rebuild. + /// Advisory only — no Apply. + /// + private async Task CollectAutogrowthPercentFiles(AnalysisFinding finding, AnalysisContext context) + { + using var connection = new SqlConnection(_connectionString); + await connection.OpenAsync(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +;WITH latest AS ( + SELECT + database_name, + file_id, + file_type_desc, + file_name, + total_size_mb, + growth_pct, + ROW_NUMBER() OVER (PARTITION BY database_name, file_id ORDER BY collection_time DESC) AS rn + FROM collect.database_size_stats + WHERE database_name NOT IN ('master', 'msdb', 'model', 'tempdb') +) +SELECT TOP (50) + database_name, + file_type_desc, + file_name, + total_size_mb, + growth_pct +FROM latest +WHERE rn = 1 +AND is_percent_growth = 1 +AND total_size_mb >= @minSizeMb +ORDER BY total_size_mb DESC;"; + + cmd.Parameters.Add(new SqlParameter("@minSizeMb", 10240.0)); /* 10 GB */ + + var items = new List(); + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var database = reader.IsDBNull(0) ? "" : reader.GetString(0); + var fileType = reader.IsDBNull(1) ? "" : reader.GetString(1); + var logical = reader.IsDBNull(2) ? "" : reader.GetString(2); + var sizeMb = reader.IsDBNull(3) ? 0.0 : Convert.ToDouble(reader.GetValue(3)); + var growthPct = reader.IsDBNull(4) ? 0 : Convert.ToInt32(reader.GetValue(4)); + if (string.IsNullOrEmpty(database) || string.IsNullOrEmpty(logical)) continue; + + var growthMb = FactRemediation.RecommendedGrowthMbFor(sizeMb); + items.Add(new + { + database, + logical_file_name = logical, + file_type = fileType, + total_size_mb = sizeMb, + growth_pct = growthPct, + issue = $"{growthPct}% autogrowth on {sizeMb / 1024.0:N1} GB {fileType} file", + alter_statement = FactRemediation.BuildModifyFileStatement(database, logical, growthMb) + }); + } + + if (items.Count > 0) + finding.DrillDown!["autogrowth_percent_files"] = items; + } + private sealed class ConfigIssueRow { public string Database = ""; diff --git a/Dashboard/Analysis/SqlServerFactCollector.cs b/Dashboard/Analysis/SqlServerFactCollector.cs index 8ff5a979..7211eac4 100644 --- a/Dashboard/Analysis/SqlServerFactCollector.cs +++ b/Dashboard/Analysis/SqlServerFactCollector.cs @@ -47,6 +47,7 @@ public async Task> CollectFactsAsync(AnalysisContext context) await CollectPerfmonFactsAsync(context, facts); await CollectMemoryClerkFactsAsync(context, facts); await CollectDatabaseConfigFactsAsync(context, facts); + await CollectFileAutogrowthFactsAsync(context, facts); await CollectProcedureStatsFactsAsync(context, facts); await CollectActiveQueryFactsAsync(context, facts); await CollectRunningJobFactsAsync(context, facts); @@ -1548,6 +1549,72 @@ GROUP BY database_name } } + /// + /// Collects the percent-autogrowth-on-large-files config fact (WS3): data/log files set + /// to grow in PERCENTAGE steps that are also large (>= 10 GB), where a single growth is a + /// huge, stalling allocation. Reads the latest snapshot per file from + /// collect.database_size_stats, excludes system databases, and emits ONE aggregate + /// FILE_AUTOGROWTH_PERCENT fact carrying the offending-file/database counts (the per-file + /// detail + copy-paste fix is attached later by the drill-down collector). + /// + private async Task CollectFileAutogrowthFactsAsync(AnalysisContext context, List facts) + { + try + { + using var connection = new SqlConnection(_connectionString); + await connection.OpenAsync(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +;WITH latest AS ( + SELECT + database_name, + file_id, + total_size_mb, + is_percent_growth, + ROW_NUMBER() OVER (PARTITION BY database_name, file_id ORDER BY collection_time DESC) AS rn + FROM collect.database_size_stats + WHERE database_name NOT IN ('master', 'msdb', 'model', 'tempdb') +) +SELECT + file_count = COUNT(*), + database_count = COUNT(DISTINCT database_name) +FROM latest +WHERE rn = 1 +AND is_percent_growth = 1 +AND total_size_mb >= @minSizeMb;"; + + cmd.Parameters.Add(new SqlParameter("@minSizeMb", 10240.0)); /* 10 GB */ + + using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return; + + var fileCount = reader.IsDBNull(0) ? 0L : Convert.ToInt64(reader.GetValue(0)); + if (fileCount == 0) return; + + var databaseCount = reader.IsDBNull(1) ? 0L : Convert.ToInt64(reader.GetValue(1)); + + facts.Add(new Fact + { + Source = "config", + Key = "FILE_AUTOGROWTH_PERCENT", + Value = fileCount, + ServerId = context.ServerId, + Metadata = new Dictionary + { + ["file_count"] = fileCount, + ["database_count"] = databaseCount + } + }); + } + catch (Exception ex) + { + Logger.Error("SqlServerFactCollector.CollectFileAutogrowthFactsAsync failed", ex); + } + } + /// /// Collects procedure stats: top procedure by delta CPU time in the period. /// diff --git a/Dashboard/Mcp/McpAnalysisTools.cs b/Dashboard/Mcp/McpAnalysisTools.cs index dbbf8a96..f6b8c8bc 100644 --- a/Dashboard/Mcp/McpAnalysisTools.cs +++ b/Dashboard/Mcp/McpAnalysisTools.cs @@ -453,6 +453,7 @@ internal static class ToolRecommendations ["PLAN_REGRESSION"] = [new("analyze_query_store_plan", "Compare the regressed plan against the prior plan"), new("get_query_trend", "Confirm the regression timing and that the new plan is consistently worse"), new("get_query_store_top", "Pull the full Query Store entry and forced-plan history before forcing")], ["PERFMON_PLE"] = [new("get_memory_stats", "Check buffer pool"), new("get_memory_clerks", "See memory allocation")], ["DB_CONFIG"] = [new("audit_config", "Check configuration")], + ["FILE_AUTOGROWTH_PERCENT"] = [new("get_database_sizes", "See per-file sizes and autogrowth settings"), new("get_file_io_stats", "Check per-file growth/latency")], ["DISK_SPACE"] = [new("get_file_io_stats", "Check per-file sizes")], ["LATCH_EX"] = [new("get_latch_stats", "Check latch contention"), new("get_tempdb_trend", "Check TempDB")], ["BAD_ACTOR"] = [new("get_top_queries_by_cpu", "See full query stats"), new("analyze_query_plan", "Analyze the execution plan")], diff --git a/Dashboard/Services/Recommendations/RecommendationsReader.cs b/Dashboard/Services/Recommendations/RecommendationsReader.cs index 0755afd7..87189dc3 100644 --- a/Dashboard/Services/Recommendations/RecommendationsReader.cs +++ b/Dashboard/Services/Recommendations/RecommendationsReader.cs @@ -247,7 +247,27 @@ internal static RecommendationSetting SettingFromLegacy(string? problemArea, str /// internal static string? BuildCopyPasteFromAction(RemediationAction? action) { - if (action?.DbConfigTargets is not { Count: > 0 } targets) + if (action is null) + return null; + + // WS3: a percent-autogrowth advisory action carries per-file MODIFY FILE targets + // (no DB-config targets). Render them via the SHARED renderer so the copy-paste is + // byte-identical to the drill-down's alter_statement. These two target lists are + // mutually exclusive (distinct fact keys), so order does not matter. + if (action.FileGrowthTargets is { Count: > 0 } fileTargets) + { + var fsb = new StringBuilder(); + foreach (var target in fileTargets) + { + if (fsb.Length > 0) + fsb.AppendLine(); + fsb.Append(FactRemediation.BuildModifyFileStatement( + target.Database, target.LogicalFileName, target.RecommendedGrowthMb)); + } + return fsb.Length == 0 ? null : fsb.ToString(); + } + + if (action.DbConfigTargets is not { Count: > 0 } targets) return null; var sb = new StringBuilder(); diff --git a/Lite.Tests/FactCollectorTests.cs b/Lite.Tests/FactCollectorTests.cs index 051a971c..e75a701c 100644 --- a/Lite.Tests/FactCollectorTests.cs +++ b/Lite.Tests/FactCollectorTests.cs @@ -485,4 +485,40 @@ public async Task CollectFacts_EverythingOnFire_AllNewCollectorsProduceFacts() output.WriteLine($" {key}: value={f.Value:F2} source={f.Source} metadata_keys={string.Join(",", f.Metadata.Keys)}"); } } + + /* ── WS3: percent-autogrowth-on-large-files collector smoke test ── */ + + // The Lite collector emits FILE_AUTOGROWTH_PERCENT ONLY for large (>= 10 GB) percent-growth + // files in non-system databases. Small files, fixed-MB-growth files, and system databases + // are excluded. This is the fact-emission smoke test that would have caught a dead fact. + [Fact] + public async Task CollectFacts_PercentAutogrowthOnLargeFiles_FiresForQualifyingFilesOnly() + { + var facts = await SeedAndCollectAsync(s => s.SeedPercentAutogrowthFilesAsync( + ("AppDb", "AppDb_log", "LOG", 200000, true, 10), // 200 GB %-growth -> qualifies + ("AppDb", "AppDb_data", "ROWS", 60000, true, 10), // 60 GB %-growth -> qualifies + ("ReportDb", "Rep_data", "ROWS", 5000, true, 25), // 5 GB %-growth -> too small + ("AppDb", "AppDb_fix", "ROWS", 50000, false, 0), // 50 GB fixed -> excluded + ("master", "master", "ROWS", 100000, true, 10))); // system DB -> excluded + + Assert.True(facts.ContainsKey("FILE_AUTOGROWTH_PERCENT"), + "FILE_AUTOGROWTH_PERCENT should be collected when a large percent-growth file exists"); + var fact = facts["FILE_AUTOGROWTH_PERCENT"]; + Assert.Equal("config", fact.Source); + Assert.Equal(2, fact.Value); // the two qualifying AppDb files + Assert.Equal(2, fact.Metadata["file_count"]); + Assert.Equal(1, fact.Metadata["database_count"]); // both qualifying files are in AppDb + } + + // No qualifying file -> the fact is not emitted at all (collector returns before Add). + [Fact] + public async Task CollectFacts_PercentAutogrowthOnLargeFiles_AbsentWhenNoQualifyingFile() + { + var facts = await SeedAndCollectAsync(s => s.SeedPercentAutogrowthFilesAsync( + ("AppDb", "AppDb_data", "ROWS", 5000, true, 10), // too small + ("AppDb", "AppDb_fix", "ROWS", 80000, false, 0))); // fixed growth + + Assert.False(facts.ContainsKey("FILE_AUTOGROWTH_PERCENT"), + "FILE_AUTOGROWTH_PERCENT should not be emitted when no large percent-growth file exists"); + } } diff --git a/Lite/Analysis/DrillDownCollector.cs b/Lite/Analysis/DrillDownCollector.cs index e26c204b..dbf345e6 100644 --- a/Lite/Analysis/DrillDownCollector.cs +++ b/Lite/Analysis/DrillDownCollector.cs @@ -53,6 +53,14 @@ 0.5 display gate. (Lite is advise/copy-paste only — no Apply — but still if (pathKeys.Contains("DB_CONFIG")) await CollectConfigIssues(finding, context); + /* WS3: the percent-autogrowth drill-down is a single cheap config-table read + and is required to render the copy-paste MODIFY FILE fix for a + FILE_AUTOGROWTH_PERCENT finding, which scores 0.3 (advisory). Collect it + regardless of the 0.5 display gate, like the config drill-down above. (Lite + is advise/copy-paste only — the fix lives in this drill-down, not an Apply.) */ + if (pathKeys.Contains("FILE_AUTOGROWTH_PERCENT")) + await CollectAutogrowthPercentFiles(finding, context); + // Below the 0.5 display gate, only the cheap config drill-down above runs; // the expensive collectors (plan fetches, multi-row reads) are skipped. if (finding.Severity < 0.5) @@ -844,6 +852,67 @@ FROM v_database_config finding.DrillDown!["config_issues"] = items; } + /// + /// Lists the large (>= 10 GB) data/log files on PERCENTAGE autogrowth (WS3), latest + /// snapshot per file, excluding system databases — and attaches a copy-paste + /// ALTER DATABASE ... MODIFY FILE fix per file (FILEGROWTH set to a size-tiered fixed MB). + /// Same structured fields + SHARED renderer as the Dashboard collector so the copy-paste + /// is byte-identical across apps. Lite is advise/copy-paste only — there is no Apply, so + /// this drill-down IS the fix surface. + /// + private async Task CollectAutogrowthPercentFiles(AnalysisFinding finding, AnalysisContext context) + { + using var readLock = _duckDb.AcquireReadLock(); + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +WITH latest AS ( + SELECT database_name, file_id, file_type_desc, file_name, total_size_mb, growth_pct, + ROW_NUMBER() OVER (PARTITION BY database_name, file_id ORDER BY collection_time DESC) AS rn + FROM v_database_size_stats + WHERE server_id = $1 +) +SELECT database_name, file_type_desc, file_name, total_size_mb, growth_pct +FROM latest +WHERE rn = 1 +AND is_percent_growth = true +AND total_size_mb >= 10240 +AND database_name NOT IN ('master', 'msdb', 'model', 'tempdb') +ORDER BY total_size_mb DESC +LIMIT 50"; + + cmd.Parameters.Add(new DuckDBParameter { Value = context.ServerId }); + + var items = new List(); + using var reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var database = reader.IsDBNull(0) ? "" : reader.GetString(0); + var fileType = reader.IsDBNull(1) ? "" : reader.GetString(1); + var logical = reader.IsDBNull(2) ? "" : reader.GetString(2); + var sizeMb = reader.IsDBNull(3) ? 0.0 : Convert.ToDouble(reader.GetValue(3)); + var growthPct = reader.IsDBNull(4) ? 0 : Convert.ToInt32(reader.GetValue(4)); + if (string.IsNullOrEmpty(database) || string.IsNullOrEmpty(logical)) continue; + + var growthMb = FactRemediation.RecommendedGrowthMbFor(sizeMb); + items.Add(new + { + database, + logical_file_name = logical, + file_type = fileType, + total_size_mb = sizeMb, + growth_pct = growthPct, + issue = $"{growthPct}% autogrowth on {sizeMb / 1024.0:N1} GB {fileType} file", + alter_statement = FactRemediation.BuildModifyFileStatement(database, logical, growthMb) + }); + } + + if (items.Count > 0) + finding.DrillDown!["autogrowth_percent_files"] = items; + } + private async Task CollectTempDbBreakdown(AnalysisFinding finding, AnalysisContext context) { using var readLock = _duckDb.AcquireReadLock(); diff --git a/Lite/Analysis/DuckDbFactCollector.cs b/Lite/Analysis/DuckDbFactCollector.cs index bfb40d22..ba9efa15 100644 --- a/Lite/Analysis/DuckDbFactCollector.cs +++ b/Lite/Analysis/DuckDbFactCollector.cs @@ -47,6 +47,7 @@ public async Task> CollectFactsAsync(AnalysisContext context) await CollectPerfmonFactsAsync(context, facts); await CollectMemoryClerkFactsAsync(context, facts); await CollectDatabaseConfigFactsAsync(context, facts); + await CollectFileAutogrowthFactsAsync(context, facts); await CollectProcedureStatsFactsAsync(context, facts); await CollectActiveQueryFactsAsync(context, facts); await CollectRunningJobFactsAsync(context, facts); @@ -1443,6 +1444,65 @@ FROM database_config catch { /* Table may not exist or have no data */ } } + /// + /// Collects the percent-autogrowth-on-large-files config fact (WS3): data/log files set + /// to grow in PERCENTAGE steps that are also large (>= 10 GB), where a single growth is a + /// huge, stalling allocation. Reads the latest snapshot per file from database_size_stats, + /// excludes system databases, and emits ONE aggregate FILE_AUTOGROWTH_PERCENT fact carrying + /// the offending-file/database counts (the per-file detail + copy-paste fix is attached + /// later by the drill-down collector). + /// + private async Task CollectFileAutogrowthFactsAsync(AnalysisContext context, List facts) + { + try + { + using var readLock = _duckDb.AcquireReadLock(); + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +WITH latest AS ( + SELECT database_name, file_id, total_size_mb, is_percent_growth, + ROW_NUMBER() OVER (PARTITION BY database_name, file_id ORDER BY collection_time DESC) AS rn + FROM database_size_stats + WHERE server_id = $1 +) +SELECT + COUNT(*) AS file_count, + COUNT(DISTINCT database_name) AS database_count +FROM latest +WHERE rn = 1 +AND is_percent_growth = true +AND total_size_mb >= 10240 +AND database_name NOT IN ('master', 'msdb', 'model', 'tempdb')"; + + cmd.Parameters.Add(new DuckDBParameter { Value = context.ServerId }); + + using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return; + + var fileCount = reader.IsDBNull(0) ? 0L : ToInt64(reader.GetValue(0)); + if (fileCount == 0) return; + + var databaseCount = reader.IsDBNull(1) ? 0L : ToInt64(reader.GetValue(1)); + + facts.Add(new Fact + { + Source = "config", + Key = "FILE_AUTOGROWTH_PERCENT", + Value = fileCount, + ServerId = context.ServerId, + Metadata = new Dictionary + { + ["file_count"] = fileCount, + ["database_count"] = databaseCount + } + }); + } + catch { /* Table may not exist or have no data */ } + } + /// /// Collects procedure stats: top procedure by delta CPU time in the period. /// diff --git a/Lite/Analysis/TestDataSeeder.cs b/Lite/Analysis/TestDataSeeder.cs index 66725824..aa45deb1 100644 --- a/Lite/Analysis/TestDataSeeder.cs +++ b/Lite/Analysis/TestDataSeeder.cs @@ -1816,6 +1816,46 @@ INSERT INTO database_size_stats } } + /// + /// WS3: seeds database_size_stats file rows for the percent-autogrowth-on-large-files + /// fact. Each tuple is (database, logicalName, fileType, totalSizeMb, isPercentGrowth, + /// growthPct). Only large (>= 10 GB) percent-growth files in NON-system databases should + /// drive the FILE_AUTOGROWTH_PERCENT fact. + /// + internal async Task SeedPercentAutogrowthFilesAsync( + params (string database, string logicalName, string fileType, double totalSizeMb, bool isPercentGrowth, int growthPct)[] files) + { + using var readLock = _duckDb.AcquireReadLock(); + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + + var fileId = 1; + foreach (var (database, logicalName, fileType, totalSizeMb, isPercentGrowth, growthPct) in files) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +INSERT INTO database_size_stats + (collection_id, collection_time, server_id, server_name, + database_name, database_id, file_id, file_type_desc, file_name, physical_name, + total_size_mb, used_size_mb, is_percent_growth, growth_pct) +VALUES ($1, $2, $3, $4, $5, 7, $6, $7, $8, 'X:\Data\file.mdf', $9, NULL, $10, $11)"; + + cmd.Parameters.Add(new DuckDBParameter { Value = _nextId-- }); + cmd.Parameters.Add(new DuckDBParameter { Value = TestPeriodEnd }); + cmd.Parameters.Add(new DuckDBParameter { Value = TestServerId }); + cmd.Parameters.Add(new DuckDBParameter { Value = TestServerName }); + cmd.Parameters.Add(new DuckDBParameter { Value = database }); + cmd.Parameters.Add(new DuckDBParameter { Value = fileId++ }); + cmd.Parameters.Add(new DuckDBParameter { Value = fileType }); + cmd.Parameters.Add(new DuckDBParameter { Value = logicalName }); + cmd.Parameters.Add(new DuckDBParameter { Value = totalSizeMb }); + cmd.Parameters.Add(new DuckDBParameter { Value = isPercentGrowth }); + cmd.Parameters.Add(new DuckDBParameter { Value = growthPct }); + + await cmd.ExecuteNonQueryAsync(); + } + } + // ============================================ // FinOps Test Scenarios // ============================================ diff --git a/Lite/Mcp/McpAnalysisTools.cs b/Lite/Mcp/McpAnalysisTools.cs index 435bfc55..2785b735 100644 --- a/Lite/Mcp/McpAnalysisTools.cs +++ b/Lite/Mcp/McpAnalysisTools.cs @@ -789,6 +789,11 @@ internal static class ToolRecommendations new("audit_config", "Check server-level configuration"), new("get_blocked_process_reports", "Check if RCSI-off databases have blocking") ], + ["FILE_AUTOGROWTH_PERCENT"] = + [ + new("get_database_sizes", "See per-file sizes and autogrowth settings"), + new("get_file_io_stats", "Check per-file growth and latency") + ], ["RUNNING_JOBS"] = [ new("get_running_jobs", "See currently running jobs with duration vs historical"), diff --git a/PerformanceMonitor.Analysis/FactAdvice.cs b/PerformanceMonitor.Analysis/FactAdvice.cs index 4c8d8186..5b2a74b4 100644 --- a/PerformanceMonitor.Analysis/FactAdvice.cs +++ b/PerformanceMonitor.Analysis/FactAdvice.cs @@ -425,6 +425,13 @@ private static Dictionary BuildAdviceTable() Remediation: "Mechanical fixes for the unambiguous ones: `ALTER DATABASE SET AUTO_SHRINK OFF` — always. `ALTER DATABASE SET AUTO_CLOSE OFF` — always for any database with real workload. `ALTER DATABASE SET PAGE_VERIFY CHECKSUM` — always on modern storage. RCSI is the only nuanced one: enabling it eliminates reader/writer blocking but adds version-store overhead to writes — test on a copy first if the application uses NOLOCK hints or relies on default isolation semantics in surprising ways. The amplifier on this finding boosts severity when LCK_M_S/LCK_M_IS are also high; that combination is the strong signal RCSI would actually help."); + t["FILE_AUTOGROWTH_PERCENT"] = new AdviceBlock( + Headline: + "Large file(s) growing in percentage steps", + Investigation: + "The drill-down `autogrowth_percent_files` is already attached: per offending file the `database`, `logical_file_name`, `file_type` (ROWS/LOG), current size (GB), the configured percent, and a copy-paste `ALTER DATABASE ... MODIFY FILE` fix. A percentage growth on a large file is a single huge allocation — a 10% growth on a 200 GB file is a 20 GB extend in one event, and every transaction that triggers the growth waits for the whole allocation to finish. Instant file initialization makes DATA-file growths near-instant, but it does NOT apply to LOG files: a log autogrowth is always physically zeroed, so a large percentage log growth stalls every writer until it completes. Open File I/O -> File I/O Latency (or the FinOps storage view) to see which files grew, when, and how long the growth blocked. `get_database_sizes` (Lite) returns the per-file sizes and growth settings programmatically.", + Remediation: + "Switch the flagged files to a FIXED MB autogrowth sized to the workload, so each growth is a bounded, predictable allocation instead of an ever-larger percentage step. The attached `alter_statement` per file is a STARTING POINT, not a prescription — it suggests a fixed step from the current file size (256 MB for 10-50 GB files, 512 MB for 50-200 GB, 1024 MB for >200 GB); tune it to how fast the file actually grows and how much free space the volume has. Better still, pre-size the file to its expected steady-state so autogrowth is a safety net rather than a routine event, confirm Instant File Initialization is granted to the service account (data files only), and keep log growths modest to avoid creating excessive VLFs. This is a metadata-only change and is safe to run online."); // ───────────────────────────────────────────────────────────────── // Jobs / disk / bad actors // ───────────────────────────────────────────────────────────────── diff --git a/PerformanceMonitor.Analysis/FactRemediation.cs b/PerformanceMonitor.Analysis/FactRemediation.cs index 037bad93..c5111ad6 100644 --- a/PerformanceMonitor.Analysis/FactRemediation.cs +++ b/PerformanceMonitor.Analysis/FactRemediation.cs @@ -39,6 +39,7 @@ public static class FactRemediation { "PLAN_REGRESSION" => GenerateForPlanRegression(finding), "DB_CONFIG" => GenerateForDbConfig(finding), + "FILE_AUTOGROWTH_PERCENT" => GenerateForFileAutogrowth(finding), _ => null }; } @@ -73,6 +74,26 @@ public static class FactRemediation } } + /// + /// Builds the ADVISORY percent-autogrowth action for a FILE_AUTOGROWTH_PERCENT finding, + /// or null when no offending file is present (WS3). Parallel to + /// — a SEPARATE entry point so neither switch grows. The action carries FactKey + /// "FILE_AUTOGROWTH_PERCENT", for which NO handler is registered, so it produces NO Apply + /// button: it exists purely to carry the per-file s through + /// the persisted-action round-trip so the Recommendations reader can render the copy-paste + /// MODIFY FILE statements on read (the drill-down the targets come from is ephemeral). + /// + public static RemediationAction? BuildFileAutogrowthAction(AnalysisFinding finding) + { + if (finding is null || !string.Equals(finding.RootFactKey, "FILE_AUTOGROWTH_PERCENT", StringComparison.Ordinal)) + return null; + + var fileTargets = ExtractFileGrowthTargets(finding); + return fileTargets.Count == 0 + ? null + : new RemediationAction("FILE_AUTOGROWTH_PERCENT", "advise", Array.Empty(), + FileGrowthTargets: fileTargets); + } /// /// Builds the DESTRUCTIVE RCSI remediation action for a DB_CONFIG finding, or null /// when it does not apply (B3 Phase 3). Parallel to , which @@ -579,6 +600,105 @@ private static string StatementFor(DbConfigSetting setting, string database) return $"ALTER DATABASE {QuoteName(database)} {setClause};"; } + /// + /// Extracts the percent-autogrowth file targets from a FILE_AUTOGROWTH_PERCENT + /// finding's drill-down autogrowth_percent_files array (WS3). For each row with a + /// non-empty database and logical_file_name, emits one + /// carrying the structured fields (total_size_mb, + /// growth_pct) — never parsing the human issue/alter_statement + /// strings. The recommended fixed-MB step is computed once here via + /// so the collector's rendered statement and the + /// reader's rendered statement agree. A defensive cap of 50 mirrors the other extractors. + /// + public static IReadOnlyList ExtractFileGrowthTargets(AnalysisFinding finding) + { + var targets = new List(); + + if (finding?.DrillDown is null || + !finding.DrillDown.TryGetValue("autogrowth_percent_files", out var raw) || + raw is null) + return targets; + + JsonElement element; + try + { + element = JsonSerializer.SerializeToElement(raw); + } + catch + { + return targets; + } + + if (element.ValueKind != JsonValueKind.Array) + return targets; + + foreach (var row in element.EnumerateArray()) + { + if (targets.Count >= 50) break; + if (row.ValueKind != JsonValueKind.Object) continue; + + var database = GetString(row, "database"); + var logical = GetString(row, "logical_file_name"); + if (string.IsNullOrEmpty(database) || string.IsNullOrEmpty(logical)) + continue; + + var sizeMb = GetDouble(row, "total_size_mb"); + var growthPct = GetInt(row, "growth_pct"); + targets.Add(new FileGrowthTarget( + database, logical, sizeMb, growthPct, RecommendedGrowthMbFor(sizeMb))); + } + + return targets; + } + + /// + /// Thin renderer over for the read-only surfaces + /// (email / webhook / MCP): the exact copy-paste ALTER DATABASE ... MODIFY FILE + /// statements, one per file, with a "was N% on X GB" comment. Nothing executes this — it + /// is advisory text (there is no handler for the fact key). Null when no file applies. + /// + private static string? GenerateForFileAutogrowth(AnalysisFinding finding) + { + var targets = ExtractFileGrowthTargets(finding); + if (targets.Count == 0) + return null; + + var sb = new StringBuilder(); + foreach (var t in targets) + { + var gb = t.CurrentSizeMb / 1024.0; + sb.AppendLine($"-- {QuoteName(t.Database)}.{QuoteName(t.LogicalFileName)}: was {t.CurrentGrowthPercent}% growth on {gb:N1} GB"); + sb.AppendLine(BuildModifyFileStatement(t.Database, t.LogicalFileName, t.RecommendedGrowthMb)); + } + + return sb.ToString().TrimEnd(); + } + + /// + /// The size-tiered fixed-MB FILEGROWTH suggested for a file of : + /// 256 MB for 10-50 GB, 512 MB for 50-200 GB, 1024 MB for > 200 GB. A STARTING POINT the + /// advice tells the operator to tune. Single source of truth so the drill-down collector and + /// the Recommendations reader render byte-identical statements. + /// + public static int RecommendedGrowthMbFor(double totalSizeMb) + { + const double GB = 1024.0; + if (totalSizeMb > 200 * GB) return 1024; + if (totalSizeMb >= 50 * GB) return 512; + return 256; + } + + /// + /// Renders one copy-paste ALTER DATABASE [db] MODIFY FILE (NAME = [logical], + /// FILEGROWTH = NMB); statement with both identifiers QUOTENAME-bracketed. Shared by + /// the drill-down collectors (the per-file alter_statement) and the reader's + /// copy-paste rebuild so they never drift. Advisory text only — nothing executes it. + /// + public static string BuildModifyFileStatement(string database, string logicalFileName, int growthMb) + { + return $"ALTER DATABASE {QuoteName(database)} MODIFY FILE (NAME = {QuoteName(logicalFileName)}, FILEGROWTH = {growthMb}MB);"; + } + /// /// QUOTENAME-equivalent: wrap an identifier in square brackets and double /// any embedded close-bracket. The database name comes from diff --git a/PerformanceMonitor.Analysis/FactScorer.cs b/PerformanceMonitor.Analysis/FactScorer.cs index a847fa9b..2416191e 100644 --- a/PerformanceMonitor.Analysis/FactScorer.cs +++ b/PerformanceMonitor.Analysis/FactScorer.cs @@ -33,6 +33,7 @@ public void ScoreAll(List facts) "memory" => ScoreMemoryFact(fact), "queries" => ScoreQueryFact(fact), "perfmon" => ScorePerfmonFact(fact), + "config" => ScoreConfigFact(fact), "database_config" => ScoreDatabaseConfigFact(fact), "jobs" => ScoreJobFact(fact), "disk" => ScoreDiskFact(fact), @@ -231,6 +232,25 @@ private static double ScorePerfmonFact(Fact fact) }; } + /// + /// Scores config-source advisory facts. Today the only scored key is + /// FILE_AUTOGROWTH_PERCENT (WS3): large data/log files set to grow in PERCENTAGE + /// steps. It is an advisory at base 0.3 (mirrors DB_CONFIG's single-misconfig base) — + /// below the 0.5 incident threshold, so it only surfaces because it is a + /// config-advisory root key (see InferenceEngine.ConfigAdvisoryRootKeys). Every other + /// "config"-source fact (CONFIG_MAXDOP / CONFIG_CTFP / SERVER_* / DATABASE_TOTAL_SIZE_MB + /// / SERVER_HARDWARE) is a leaf/amplifier with no base severity of its own and scores 0 + /// here, exactly as it did before this arm existed (it contributes only via amplifiers). + /// + private static double ScoreConfigFact(Fact fact) + { + if (fact.Key != "FILE_AUTOGROWTH_PERCENT") return 0.0; + + // Base 0.3 when at least one large percent-growth file was found; 0 otherwise. + var fileCount = fact.Metadata.GetValueOrDefault("file_count"); + return fileCount > 0 ? 0.3 : 0.0; + } + /// /// Scores database configuration facts. /// Auto-shrink and auto-close are always bad. diff --git a/PerformanceMonitor.Analysis/InferenceEngine.cs b/PerformanceMonitor.Analysis/InferenceEngine.cs index 5d52a299..6b4ba943 100644 --- a/PerformanceMonitor.Analysis/InferenceEngine.cs +++ b/PerformanceMonitor.Analysis/InferenceEngine.cs @@ -37,6 +37,7 @@ public class InferenceEngine { "DB_CONFIG", "SERVER_CONFIG", + "FILE_AUTOGROWTH_PERCENT", }; private readonly RelationshipGraph _graph; diff --git a/PerformanceMonitor.Analysis/RemediationAction.cs b/PerformanceMonitor.Analysis/RemediationAction.cs index f6a2a59e..49405456 100644 --- a/PerformanceMonitor.Analysis/RemediationAction.cs +++ b/PerformanceMonitor.Analysis/RemediationAction.cs @@ -26,13 +26,14 @@ namespace PerformanceMonitor.Analysis; /// /// public sealed record RemediationAction( - string FactKey, // "PLAN_REGRESSION" | "DB_CONFIG" | "RCSI" | "CLEAR_PLAN" — handler-registry key + string FactKey, // "PLAN_REGRESSION" | "DB_CONFIG" | "RCSI" | "CLEAR_PLAN" | "FILE_AUTOGROWTH_PERCENT" — handler-registry key string Action, // "force" (plan regression) | "set" (db config) | "clear" (clear cached plan). Un-apply derives "unforce". IReadOnlyList Targets, // force-plan targets (empty list for DB_CONFIG / CLEAR_PLAN) IReadOnlyList? DbConfigTargets = null, // DB-config targets (null for force-plan) RcsiInactionFigures? RcsiFigures = null, // B3 Phase 3: RCSI risk-of-not-changing figures carried on the persisted action IReadOnlyList? ClearPlanTargets = null, // clear-cached-plan targets (null for the other fact keys) - ClearPlanFigures? ClearPlanFigures = null); // clear-cached-plan risk-of-not-changing figures carried on the persisted action + ClearPlanFigures? ClearPlanFigures = null, // clear-cached-plan risk-of-not-changing figures carried on the persisted action + IReadOnlyList? FileGrowthTargets = null); // WS3: percent-autogrowth files — advise/copy-paste ONLY (no registered handler, no Apply) /// /// The risk-of-NOT-changing monitoring figures for a destructive CLEAR_PLAN action @@ -142,6 +143,24 @@ public sealed record DbConfigTarget( DbConfigSetting Setting, string? CurrentValue = null); +/// +/// One percent-autogrowth file target (WS3): a large data/log file set to grow in +/// PERCENTAGE steps, which on a big file is a single huge allocation that stalls +/// writes. This is an ADVISORY/copy-paste payload only — there is NO registered +/// handler for the "FILE_AUTOGROWTH_PERCENT" fact key, so it never produces an Apply +/// button ( by contrast drives the always-safe Apply). All +/// members are display/copy-paste inputs: the reader renders one +/// ALTER DATABASE [db] MODIFY FILE (NAME = [logical], FILEGROWTH = NMB); per +/// target. is the fixed-MB step suggested from the +/// observed file size (a starting point, not a prescription). +/// +public sealed record FileGrowthTarget( + string Database, // user DB name (bracketed by the renderer; never executed) + string LogicalFileName, // sys.database_files.name (bracketed by the renderer) + double CurrentSizeMb, // total_size_mb — display only + int CurrentGrowthPercent, // growth_pct — display only + int RecommendedGrowthMb); // suggested fixed-MB FILEGROWTH (size-tiered) + /// /// One force-plan target. , and /// are the only execution inputs (database is applied solely diff --git a/PerformanceMonitor.Notifications/AlertContext.cs b/PerformanceMonitor.Notifications/AlertContext.cs index d949d012..8312e459 100644 --- a/PerformanceMonitor.Notifications/AlertContext.cs +++ b/PerformanceMonitor.Notifications/AlertContext.cs @@ -83,7 +83,8 @@ public record RemediationActionDto( List? DbConfigTargets = null, RcsiInactionFiguresDto? RcsiFigures = null, List? ClearPlanTargets = null, - ClearPlanFiguresDto? ClearPlanFigures = null); + ClearPlanFiguresDto? ClearPlanFigures = null, + List? FileGrowthTargets = null); /// /// JSON mirror of (clear-cached-plan, PR-B). The @@ -147,6 +148,21 @@ public record DbConfigTargetDto( int Setting, string? CurrentValue); +/// +/// JSON mirror of (WS3 percent-autogrowth advisory). The +/// trailing optional FileGrowthTargets member on +/// keeps the round-trip backward-compatible: legacy/non-autogrowth contextJson without it +/// deserializes to null. Carried so the copy-paste MODIFY FILE statements survive the +/// persisted-action round-trip the Recommendations reader renders from (the drill-down is +/// ephemeral). Advisory only — there is no handler, so it never drives Apply. +/// +public record FileGrowthTargetDto( + string Database, + string LogicalFileName, + double CurrentSizeMb, + int CurrentGrowthPercent, + int RecommendedGrowthMb); + /// /// Maps to/from the JSON projection /// persisted alongside the flat detail_text. Centralizes the DTO mapping so the persistence @@ -286,8 +302,20 @@ public static bool TryDeserialize(string? json, out AlertContext context) cf.CpuPercent, cf.PlanRegressionCoFired, cf.ParameterSensitivityCoFired) : null; + // WS3 (percent-autogrowth advisory): persist the file targets so the Recommendations + // reader can render the copy-paste MODIFY FILE statements on read (the drill-down is + // ephemeral). Null for every other fact key -> backward-compatible. + List? fileGrowthTargets = null; + if (action.FileGrowthTargets is not null) + { + fileGrowthTargets = new List(action.FileGrowthTargets.Count); + foreach (var t in action.FileGrowthTargets) + fileGrowthTargets.Add(new FileGrowthTargetDto( + t.Database, t.LogicalFileName, t.CurrentSizeMb, t.CurrentGrowthPercent, t.RecommendedGrowthMb)); + } + return new RemediationActionDto(action.FactKey, action.Action, targets, dbConfigTargets, - rcsiFigures, clearPlanTargets, clearPlanFigures); + rcsiFigures, clearPlanTargets, clearPlanFigures, fileGrowthTargets); } private static RemediationAction? FromDto(RemediationActionDto? dto) @@ -352,7 +380,19 @@ public static bool TryDeserialize(string? json, out AlertContext context) cf.CpuPercent, cf.PlanRegressionCoFired, cf.ParameterSensitivityCoFired) : null; + // WS3: rebuild the percent-autogrowth file targets from the DTO and PASS them to the + // ctor (the trailing FileGrowthTargets member defaults to null, so a short call would + // silently drop them on the round-trip). Legacy JSON without the field -> null. + List? fileGrowthTargets = null; + if (dto.FileGrowthTargets is not null) + { + fileGrowthTargets = new List(dto.FileGrowthTargets.Count); + foreach (var t in dto.FileGrowthTargets) + fileGrowthTargets.Add(new FileGrowthTarget( + t.Database, t.LogicalFileName, t.CurrentSizeMb, t.CurrentGrowthPercent, t.RecommendedGrowthMb)); + } + return new RemediationAction(dto.FactKey, dto.Action, targets, dbConfigTargets, rcsiFigures, - clearPlanTargets, clearPlanFigures); + clearPlanTargets, clearPlanFigures, fileGrowthTargets); } }