Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions Dashboard.Tests/AnalysisNotificationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, object>
{
["autogrowth_percent_files"] = new List<object>
{
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));
}
}
51 changes: 51 additions & 0 deletions Dashboard.Tests/FactScorerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Fact>
{
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<Fact>
{
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));
}
}
18 changes: 18 additions & 0 deletions Dashboard.Tests/InferenceEngineTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Fact>
{
new() { Key = "FILE_AUTOGROWTH_PERCENT", Source = "config", Value = 2, Severity = 0.3,
Metadata = new Dictionary<string, double> { ["file_count"] = 2 } }
};

var stories = engine.BuildStories(facts);

Assert.Contains(stories, s => s.RootFactKey == "FILE_AUTOGROWTH_PERCENT");
}
}
54 changes: 54 additions & 0 deletions Dashboard.Tests/RecommendationDeduperTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ForcePlanTarget>(),
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<ForcePlanTarget>(),
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()
{
Expand Down
3 changes: 2 additions & 1 deletion Dashboard/Analysis/AnalysisService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,8 @@ public async Task<List<AnalysisFinding>> 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
Expand Down
80 changes: 80 additions & 0 deletions Dashboard/Analysis/SqlServerDrillDownCollector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -803,6 +810,79 @@ FROM pivoted
finding.DrillDown!["config_issues"] = items;
}

/// <summary>
/// Lists the large (&gt;= 10 GB) data/log files on PERCENTAGE autogrowth (WS3), latest
/// snapshot per file, excluding system databases — and attaches a copy-paste
/// <c>ALTER DATABASE ... MODIFY FILE</c> fix per file (FILEGROWTH set to a size-tiered
/// fixed MB). The structured fields (<c>database</c>, <c>logical_file_name</c>,
/// <c>total_size_mb</c>, <c>growth_pct</c>) are what the shared extractor
/// (FactRemediation.ExtractFileGrowthTargets) reads; the rendered <c>alter_statement</c>
/// uses the SHARED renderer so it is byte-identical to the reader's copy-paste rebuild.
/// Advisory only — no Apply.
/// </summary>
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<object>();
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 = "";
Expand Down
67 changes: 67 additions & 0 deletions Dashboard/Analysis/SqlServerFactCollector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ public async Task<List<Fact>> 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);
Expand Down Expand Up @@ -1548,6 +1549,72 @@ GROUP BY database_name
}
}

/// <summary>
/// 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).
/// </summary>
private async Task CollectFileAutogrowthFactsAsync(AnalysisContext context, List<Fact> 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<string, double>
{
["file_count"] = fileCount,
["database_count"] = databaseCount
}
});
}
catch (Exception ex)
{
Logger.Error("SqlServerFactCollector.CollectFileAutogrowthFactsAsync failed", ex);
}
}

/// <summary>
/// Collects procedure stats: top procedure by delta CPU time in the period.
/// </summary>
Expand Down
1 change: 1 addition & 0 deletions Dashboard/Mcp/McpAnalysisTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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")],
Expand Down
Loading
Loading