diff --git a/Lite/Analysis/TestDataSeeder.cs b/Lite/Analysis/TestDataSeeder.cs
index f47b82f7..4623e2a0 100644
--- a/Lite/Analysis/TestDataSeeder.cs
+++ b/Lite/Analysis/TestDataSeeder.cs
@@ -1397,4 +1397,372 @@ INSERT INTO database_size_stats
await cmd.ExecuteNonQueryAsync();
}
}
+
+ // ============================================
+ // FinOps Test Scenarios
+ // ============================================
+
+ ///
+ /// Scenario 1: Over-provisioned Enterprise server.
+ /// 32 cores, 256GB RAM, but avg CPU 8%, buffer pool only 40GB of 256GB.
+ ///
+ /// Expected recommendations:
+ /// - CPU right-sizing (P95 < 30%, many cores)
+ /// - Memory right-sizing (buffer pool < 50% of physical RAM)
+ ///
+ public async Task SeedOverProvisionedEnterpriseAsync()
+ {
+ await ClearTestDataAsync();
+ await SeedTestServerAsync();
+
+ // 32 cores, 256GB RAM, but avg CPU 8%, buffer pool only 40GB of 256GB
+ await SeedCpuUtilizationAsync(8, 2);
+ await SeedMemoryStatsAsync(totalPhysicalMb: 262_144, bufferPoolMb: 40_960, targetMb: 245_760);
+ await SeedServerPropertiesAsync(cpuCount: 32, htRatio: 2, physicalMemMb: 262_144,
+ edition: "Enterprise Edition");
+ await SeedFileSizeAsync(totalDataSizeMb: 51_200); // 50GB — tiny for 256GB RAM
+ }
+
+ ///
+ /// Scenario 2: Idle databases with cost impact.
+ /// 3 databases seeded — only 1 has query activity, the other 2 are idle.
+ ///
+ /// Expected recommendations:
+ /// - Dormant database detection (2 idle databases)
+ ///
+ public async Task SeedIdleDatabasesAsync()
+ {
+ await ClearTestDataAsync();
+ await SeedTestServerAsync();
+
+ // Seed database sizes for 3 databases + query activity for only 1
+ await SeedDatabaseSizesForIdleTestAsync();
+ await SeedQueryStatsForDatabaseAsync("ActiveDB", executions: 5000, cpuMs: 100_000);
+ }
+
+ ///
+ /// Scenario 3: High impact query skew — one query consuming 80%+ of CPU.
+ ///
+ /// Expected: HighImpactScorer.Score() returns query "AAAA" with dominant CpuShare.
+ ///
+ public async Task SeedHighImpactQuerySkewAsync()
+ {
+ await ClearTestDataAsync();
+ await SeedTestServerAsync();
+
+ // 5 queries: one uses 80% CPU, rest split the remaining 20%
+ await SeedQueryStatsForHighImpactAsync();
+ }
+
+ ///
+ /// Scenario 4: Dev/test databases on a production server.
+ /// Seeds database_size_stats with databases named "staging_app", "dev_analytics", "test_warehouse".
+ ///
+ /// NOTE: The recommendation engine detects dev/test databases via a LIVE SQL query
+ /// (sys.databases WHERE name LIKE '%dev%'). This won't fire against DuckDB test data.
+ /// The scenario documents the expected behavior but the live check will silently fail.
+ /// Use this scenario to test the idle-database detection path instead.
+ ///
+ public async Task SeedDevTestDatabasesAsync()
+ {
+ await ClearTestDataAsync();
+ await SeedTestServerAsync();
+
+ await SeedDatabaseSizesWithNamesAsync("staging_app", "dev_analytics", "test_warehouse", "ProductionDB");
+ }
+
+ ///
+ /// Scenario 5: Long-running maintenance jobs.
+ /// Seeds running_jobs with a job that ran long 5+ times in 7 days.
+ ///
+ /// Expected recommendations:
+ /// - Maintenance window efficiency warning
+ ///
+ public async Task SeedLongRunningJobsAsync()
+ {
+ await ClearTestDataAsync();
+ await SeedTestServerAsync();
+
+ await SeedRunningJobsForMaintenanceTestAsync();
+ }
+
+ ///
+ /// Scenario 6: Clean FinOps server — no recommendations expected.
+ /// Healthy CPU (50%), good buffer pool ratio (75%), no idle databases.
+ ///
+ /// Expected: empty or minimal recommendation list.
+ ///
+ public async Task SeedCleanFinOpsServerAsync()
+ {
+ await ClearTestDataAsync();
+ await SeedTestServerAsync();
+
+ // Healthy: 50% CPU, 75% buffer pool ratio, no idle databases
+ await SeedCpuUtilizationAsync(50, 5);
+ await SeedMemoryStatsAsync(totalPhysicalMb: 65_536, bufferPoolMb: 49_152, targetMb: 57_344);
+ await SeedServerPropertiesAsync(cpuCount: 8, htRatio: 2, physicalMemMb: 65_536,
+ edition: "Developer Edition");
+ await SeedFileSizeAsync(totalDataSizeMb: 204_800); // 200GB
+ }
+
+ // ============================================
+ // FinOps Test Runner Methods
+ // ============================================
+
+ ///
+ /// Runs the FinOps recommendation engine against test data.
+ /// Pass empty strings for connectionString/utilityConnectionString to skip live SQL checks.
+ ///
+ public async Task> RunFinOpsRecommendationsAsync(
+ PerformanceMonitorLite.Services.LocalDataService dataService, decimal monthlyCost = 10000m)
+ {
+ return await dataService.GetRecommendationsAsync(TestServerId, "", "", monthlyCost);
+ }
+
+ ///
+ /// Runs the High Impact scorer against test data.
+ ///
+ public async Task> RunHighImpactAnalysisAsync(
+ PerformanceMonitorLite.Services.LocalDataService dataService, int hoursBack = 24)
+ {
+ return await dataService.GetHighImpactQueriesAsync(TestServerId, hoursBack);
+ }
+
+ // ============================================
+ // FinOps Seed Helpers
+ // ============================================
+
+ ///
+ /// Seeds database_size_stats with 3 databases for idle-database testing.
+ /// "ActiveDB" will have query_stats activity (seeded separately).
+ /// "OldReportsDB" (50GB) and "ArchiveDB" (100GB) have no activity — should be detected as idle.
+ ///
+ internal async Task SeedDatabaseSizesForIdleTestAsync()
+ {
+ using var readLock = _duckDb.AcquireReadLock();
+ using var connection = _duckDb.CreateConnection();
+ await connection.OpenAsync();
+
+ var databases = new (string name, int dbId, decimal totalSizeMb)[]
+ {
+ ("ActiveDB", 10, 20_480), // 20GB — active
+ ("OldReportsDB", 11, 51_200), // 50GB — idle
+ ("ArchiveDB", 12, 102_400), // 100GB — idle
+ };
+
+ foreach (var (name, dbId, totalSizeMb) in databases)
+ {
+ 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)
+VALUES ($1, $2, $3, $4, $5, $6, 1, 'ROWS', $7, $8, $9, $10)";
+
+ 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 = name });
+ cmd.Parameters.Add(new DuckDBParameter { Value = dbId });
+ cmd.Parameters.Add(new DuckDBParameter { Value = $"{name}.mdf" });
+ cmd.Parameters.Add(new DuckDBParameter { Value = $"D:\\Data\\{name}.mdf" });
+ cmd.Parameters.Add(new DuckDBParameter { Value = totalSizeMb });
+ cmd.Parameters.Add(new DuckDBParameter { Value = totalSizeMb * 0.8m }); // 80% used
+
+ await cmd.ExecuteNonQueryAsync();
+ }
+ }
+
+ ///
+ /// Seeds query_stats with activity for a specific database.
+ /// Used to mark a database as "active" so it's excluded from idle detection.
+ ///
+ internal async Task SeedQueryStatsForDatabaseAsync(string databaseName, long executions, long cpuMs)
+ {
+ using var readLock = _duckDb.AcquireReadLock();
+ using var connection = _duckDb.CreateConnection();
+ await connection.OpenAsync();
+
+ // Spread across 16 collection points so it falls within time-range queries
+ var execsPerPoint = executions / 16;
+ var cpuPerPoint = cpuMs * 1000 / 16; // convert ms to microseconds for delta_worker_time
+
+ for (var i = 0; i < 16; i++)
+ {
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = @"
+INSERT INTO query_stats
+ (collection_id, collection_time, server_id, server_name,
+ database_name, query_hash, delta_execution_count,
+ delta_worker_time, delta_elapsed_time, delta_logical_reads)
+VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)";
+
+ var t = TestPeriodStart.AddMinutes(i * 15);
+ cmd.Parameters.Add(new DuckDBParameter { Value = _nextId-- });
+ cmd.Parameters.Add(new DuckDBParameter { Value = t });
+ cmd.Parameters.Add(new DuckDBParameter { Value = TestServerId });
+ cmd.Parameters.Add(new DuckDBParameter { Value = TestServerName });
+ cmd.Parameters.Add(new DuckDBParameter { Value = databaseName });
+ cmd.Parameters.Add(new DuckDBParameter { Value = $"0xACTIVE{i:D4}" });
+ cmd.Parameters.Add(new DuckDBParameter { Value = execsPerPoint });
+ cmd.Parameters.Add(new DuckDBParameter { Value = cpuPerPoint });
+ cmd.Parameters.Add(new DuckDBParameter { Value = cpuPerPoint * 2 });
+ cmd.Parameters.Add(new DuckDBParameter { Value = execsPerPoint * 500L });
+
+ await cmd.ExecuteNonQueryAsync();
+ }
+ }
+
+ ///
+ /// Seeds query_stats for high-impact skew testing.
+ /// 5 queries with one dominant (80% CPU):
+ /// AAAA — 800,000ms CPU, 10,000 executions (the monster)
+ /// BBBB — 50,000ms CPU, 5,000 executions
+ /// CCCC — 50,000ms CPU, 2,000 executions
+ /// DDDD — 50,000ms CPU, 1,000 executions
+ /// EEEE — 50,000ms CPU, 500 executions
+ ///
+ internal async Task SeedQueryStatsForHighImpactAsync()
+ {
+ using var readLock = _duckDb.AcquireReadLock();
+ using var connection = _duckDb.CreateConnection();
+ await connection.OpenAsync();
+
+ var queries = new (string hash, long cpuMs, long executions, long reads, long writes, long memoryKb)[]
+ {
+ ("AAAA", 800_000, 10_000, 50_000_000, 1_000_000, 512_000), // The monster
+ ("BBBB", 50_000, 5_000, 5_000_000, 100_000, 64_000),
+ ("CCCC", 50_000, 2_000, 3_000_000, 50_000, 32_000),
+ ("DDDD", 50_000, 1_000, 2_000_000, 25_000, 16_000),
+ ("EEEE", 50_000, 500, 1_000_000, 10_000, 8_000),
+ };
+
+ foreach (var (hash, cpuMs, executions, reads, writes, memoryKb) in queries)
+ {
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = @"
+INSERT INTO query_stats
+ (collection_id, collection_time, server_id, server_name,
+ database_name, query_hash, query_text,
+ delta_execution_count, delta_worker_time, delta_elapsed_time,
+ delta_logical_reads, delta_logical_writes, max_grant_kb)
+VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)";
+
+ cmd.Parameters.Add(new DuckDBParameter { Value = _nextId-- });
+ cmd.Parameters.Add(new DuckDBParameter { Value = TestPeriodEnd.AddMinutes(-30) }); // recent
+ cmd.Parameters.Add(new DuckDBParameter { Value = TestServerId });
+ cmd.Parameters.Add(new DuckDBParameter { Value = TestServerName });
+ cmd.Parameters.Add(new DuckDBParameter { Value = "UserDB" });
+ cmd.Parameters.Add(new DuckDBParameter { Value = hash });
+ cmd.Parameters.Add(new DuckDBParameter { Value = $"SELECT /* {hash} */ * FROM dbo.SomeTable" });
+ cmd.Parameters.Add(new DuckDBParameter { Value = executions });
+ cmd.Parameters.Add(new DuckDBParameter { Value = cpuMs * 1000L }); // microseconds
+ cmd.Parameters.Add(new DuckDBParameter { Value = cpuMs * 2000L }); // elapsed ~2x CPU
+ cmd.Parameters.Add(new DuckDBParameter { Value = reads });
+ cmd.Parameters.Add(new DuckDBParameter { Value = writes });
+ cmd.Parameters.Add(new DuckDBParameter { Value = memoryKb });
+
+ await cmd.ExecuteNonQueryAsync();
+ }
+ }
+
+ ///
+ /// Seeds database_size_stats with named databases.
+ /// Used for dev/test detection testing and general size seeding.
+ ///
+ internal async Task SeedDatabaseSizesWithNamesAsync(params string[] databaseNames)
+ {
+ using var readLock = _duckDb.AcquireReadLock();
+ using var connection = _duckDb.CreateConnection();
+ await connection.OpenAsync();
+
+ for (var i = 0; i < databaseNames.Length; i++)
+ {
+ var name = databaseNames[i];
+ var sizeMb = 10_240m + (i * 5_120m); // 10GB, 15GB, 20GB, 25GB, ...
+
+ 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)
+VALUES ($1, $2, $3, $4, $5, $6, 1, 'ROWS', $7, $8, $9, $10)";
+
+ 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 = name });
+ cmd.Parameters.Add(new DuckDBParameter { Value = 10 + i }); // database_id
+ cmd.Parameters.Add(new DuckDBParameter { Value = $"{name}.mdf" });
+ cmd.Parameters.Add(new DuckDBParameter { Value = $"D:\\Data\\{name}.mdf" });
+ cmd.Parameters.Add(new DuckDBParameter { Value = sizeMb });
+ cmd.Parameters.Add(new DuckDBParameter { Value = sizeMb * 0.7m }); // 70% used
+
+ await cmd.ExecuteNonQueryAsync();
+ }
+ }
+
+ ///
+ /// Seeds running_jobs for maintenance window testing.
+ /// Creates a "Weekly Index Rebuild" job that ran long 5 times in 7 days,
+ /// and a normal "Stats Update" job for contrast.
+ ///
+ internal async Task SeedRunningJobsForMaintenanceTestAsync()
+ {
+ using var readLock = _duckDb.AcquireReadLock();
+ using var connection = _duckDb.CreateConnection();
+ await connection.OpenAsync();
+
+ // "Weekly Index Rebuild" — ran long 5 times
+ var jobId = Guid.NewGuid().ToString();
+ for (var i = 0; i < 5; i++)
+ {
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText = @"
+INSERT INTO running_jobs
+ (collection_time, server_id, server_name, job_name, job_id,
+ job_enabled, start_time, current_duration_seconds,
+ avg_duration_seconds, p95_duration_seconds, successful_run_count,
+ is_running_long, percent_of_average)
+VALUES ($1, $2, $3, $4, $5, true, $6, $7, $8, $9, 50, true, $10)";
+
+ // Spread collections across the 7-day window the recommendation engine queries
+ var collectionTime = DateTime.UtcNow.AddDays(-6).AddDays(i * 1.2);
+ cmd.Parameters.Add(new DuckDBParameter { Value = collectionTime });
+ cmd.Parameters.Add(new DuckDBParameter { Value = TestServerId });
+ cmd.Parameters.Add(new DuckDBParameter { Value = TestServerName });
+ cmd.Parameters.Add(new DuckDBParameter { Value = "Weekly Index Rebuild" });
+ cmd.Parameters.Add(new DuckDBParameter { Value = jobId });
+ cmd.Parameters.Add(new DuckDBParameter { Value = collectionTime.AddSeconds(-900) }); // started 15min ago
+ cmd.Parameters.Add(new DuckDBParameter { Value = 900L }); // current_duration_seconds (15min)
+ cmd.Parameters.Add(new DuckDBParameter { Value = 300L }); // avg_duration_seconds (5min historical)
+ cmd.Parameters.Add(new DuckDBParameter { Value = 450L }); // p95_duration_seconds
+ cmd.Parameters.Add(new DuckDBParameter { Value = 300.0 }); // percent_of_average = 300%
+
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ // "Stats Update" — normal job, not running long
+ using var normalCmd = connection.CreateCommand();
+ normalCmd.CommandText = @"
+INSERT INTO running_jobs
+ (collection_time, server_id, server_name, job_name, job_id,
+ job_enabled, start_time, current_duration_seconds,
+ avg_duration_seconds, p95_duration_seconds, successful_run_count,
+ is_running_long, percent_of_average)
+VALUES ($1, $2, $3, $4, $5, true, $6, 120, 100, 130, 200, false, 120.0)";
+
+ normalCmd.Parameters.Add(new DuckDBParameter { Value = TestPeriodEnd.AddMinutes(-5) });
+ normalCmd.Parameters.Add(new DuckDBParameter { Value = TestServerId });
+ normalCmd.Parameters.Add(new DuckDBParameter { Value = TestServerName });
+ normalCmd.Parameters.Add(new DuckDBParameter { Value = "Stats Update" });
+ normalCmd.Parameters.Add(new DuckDBParameter { Value = Guid.NewGuid().ToString() });
+ normalCmd.Parameters.Add(new DuckDBParameter { Value = TestPeriodEnd.AddMinutes(-7) });
+
+ await normalCmd.ExecuteNonQueryAsync();
+ }
}
diff --git a/Lite/Services/LocalDataService.FinOps.cs b/Lite/Services/LocalDataService.FinOps.cs
index 07dc5a1e..ad921923 100644
--- a/Lite/Services/LocalDataService.FinOps.cs
+++ b/Lite/Services/LocalDataService.FinOps.cs
@@ -1307,64 +1307,7 @@ HAVING SUM(delta_execution_count) > 0
});
}
- if (allRows.Count == 0) return allRows;
-
- // Step 2: Find "interesting" hashes — top 10 per dimension via UNION
- var interesting = new HashSet();
- foreach (var hash in allRows.OrderByDescending(r => r.TotalCpuMs).Take(10).Select(r => r.QueryHash)) interesting.Add(hash);
- foreach (var hash in allRows.OrderByDescending(r => r.TotalDurationMs).Take(10).Select(r => r.QueryHash)) interesting.Add(hash);
- foreach (var hash in allRows.OrderByDescending(r => r.TotalReads).Take(10).Select(r => r.QueryHash)) interesting.Add(hash);
- foreach (var hash in allRows.OrderByDescending(r => r.TotalWrites).Take(10).Select(r => r.QueryHash)) interesting.Add(hash);
- foreach (var hash in allRows.OrderByDescending(r => r.TotalMemoryMb).Take(10).Select(r => r.QueryHash)) interesting.Add(hash);
- foreach (var hash in allRows.OrderByDescending(r => r.TotalExecutions).Take(10).Select(r => r.QueryHash)) interesting.Add(hash);
-
- var filtered = allRows.Where(r => interesting.Contains(r.QueryHash)).ToList();
-
- if (filtered.Count == 0) return filtered;
-
- // Step 3: Compute PERCENT_RANK and share for the interesting set
- var cpuValues = filtered.Select(r => r.TotalCpuMs).OrderBy(v => v).ToList();
- var durationValues = filtered.Select(r => r.TotalDurationMs).OrderBy(v => v).ToList();
- var readsValues = filtered.Select(r => (decimal)r.TotalReads).OrderBy(v => v).ToList();
- var writesValues = filtered.Select(r => (decimal)r.TotalWrites).OrderBy(v => v).ToList();
- var memoryValues = filtered.Select(r => r.TotalMemoryMb).OrderBy(v => v).ToList();
- var execValues = filtered.Select(r => (decimal)r.TotalExecutions).OrderBy(v => v).ToList();
-
- var totalCpu = filtered.Sum(r => r.TotalCpuMs);
- var totalDuration = filtered.Sum(r => r.TotalDurationMs);
- var totalReads = filtered.Sum(r => (decimal)r.TotalReads);
- var totalWrites = filtered.Sum(r => (decimal)r.TotalWrites);
- var totalMemory = filtered.Sum(r => r.TotalMemoryMb);
- var totalExecs = filtered.Sum(r => (decimal)r.TotalExecutions);
-
- foreach (var row in filtered)
- {
- var cpuPctl = PercentRank(cpuValues, row.TotalCpuMs);
- var durationPctl = PercentRank(durationValues, row.TotalDurationMs);
- var readsPctl = PercentRank(readsValues, (decimal)row.TotalReads);
- var writesPctl = PercentRank(writesValues, (decimal)row.TotalWrites);
- var memoryPctl = PercentRank(memoryValues, row.TotalMemoryMb);
- var execsPctl = PercentRank(execValues, (decimal)row.TotalExecutions);
-
- row.CpuShare = totalCpu > 0 ? Math.Round(100m * row.TotalCpuMs / totalCpu, 1) : 0;
- row.DurationShare = totalDuration > 0 ? Math.Round(100m * row.TotalDurationMs / totalDuration, 1) : 0;
- row.ReadsShare = totalReads > 0 ? Math.Round(100m * row.TotalReads / totalReads, 1) : 0;
- row.WritesShare = totalWrites > 0 ? Math.Round(100m * row.TotalWrites / totalWrites, 1) : 0;
- row.MemoryShare = totalMemory > 0 ? Math.Round(100m * row.TotalMemoryMb / totalMemory, 1) : 0;
- row.ExecutionsShare = totalExecs > 0 ? Math.Round(100m * row.TotalExecutions / totalExecs, 1) : 0;
-
- var pctlSum = cpuPctl + durationPctl + readsPctl + writesPctl + memoryPctl + execsPctl;
- row.ImpactScore = (int)(pctlSum / 6m * 100m);
- }
-
- return filtered.OrderByDescending(r => r.ImpactScore).ToList();
- }
-
- private static decimal PercentRank(List sortedValues, decimal value)
- {
- if (sortedValues.Count <= 1) return 0;
- int rank = sortedValues.Count(v => v < value);
- return (decimal)rank / (sortedValues.Count - 1);
+ return HighImpactScorer.Score(allRows);
}
///
@@ -2314,6 +2257,81 @@ public class RecommendationRow
public string EstMonthlySavingsDisplay => EstMonthlySavings.HasValue ? $"${EstMonthlySavings.Value:N0}" : "";
}
+///
+/// Identifies top-N queries per resource dimension, computes PERCENT_RANK
+/// and share percentages, and returns the "interesting" set sorted by impact score.
+/// Extracted from GetHighImpactQueriesAsync for testability.
+///
+public static class HighImpactScorer
+{
+ ///
+ /// Scores a list of query rows by identifying top-N per resource dimension,
+ /// computing PERCENT_RANK and share percentages, and returning the interesting
+ /// set sorted by impact score descending.
+ ///
+ public static List Score(List allRows, int topN = 10)
+ {
+ if (allRows.Count == 0) return allRows;
+
+ // Step 1: Find "interesting" hashes — top N per dimension via UNION
+ var interesting = new HashSet();
+ foreach (var hash in allRows.OrderByDescending(r => r.TotalCpuMs).Take(topN).Select(r => r.QueryHash)) interesting.Add(hash);
+ foreach (var hash in allRows.OrderByDescending(r => r.TotalDurationMs).Take(topN).Select(r => r.QueryHash)) interesting.Add(hash);
+ foreach (var hash in allRows.OrderByDescending(r => r.TotalReads).Take(topN).Select(r => r.QueryHash)) interesting.Add(hash);
+ foreach (var hash in allRows.OrderByDescending(r => r.TotalWrites).Take(topN).Select(r => r.QueryHash)) interesting.Add(hash);
+ foreach (var hash in allRows.OrderByDescending(r => r.TotalMemoryMb).Take(topN).Select(r => r.QueryHash)) interesting.Add(hash);
+ foreach (var hash in allRows.OrderByDescending(r => r.TotalExecutions).Take(topN).Select(r => r.QueryHash)) interesting.Add(hash);
+
+ var filtered = allRows.Where(r => interesting.Contains(r.QueryHash)).ToList();
+
+ if (filtered.Count == 0) return filtered;
+
+ // Step 2: Compute PERCENT_RANK and share for the interesting set
+ var cpuValues = filtered.Select(r => r.TotalCpuMs).OrderBy(v => v).ToList();
+ var durationValues = filtered.Select(r => r.TotalDurationMs).OrderBy(v => v).ToList();
+ var readsValues = filtered.Select(r => (decimal)r.TotalReads).OrderBy(v => v).ToList();
+ var writesValues = filtered.Select(r => (decimal)r.TotalWrites).OrderBy(v => v).ToList();
+ var memoryValues = filtered.Select(r => r.TotalMemoryMb).OrderBy(v => v).ToList();
+ var execValues = filtered.Select(r => (decimal)r.TotalExecutions).OrderBy(v => v).ToList();
+
+ var totalCpu = filtered.Sum(r => r.TotalCpuMs);
+ var totalDuration = filtered.Sum(r => r.TotalDurationMs);
+ var totalReads = filtered.Sum(r => (decimal)r.TotalReads);
+ var totalWrites = filtered.Sum(r => (decimal)r.TotalWrites);
+ var totalMemory = filtered.Sum(r => r.TotalMemoryMb);
+ var totalExecs = filtered.Sum(r => (decimal)r.TotalExecutions);
+
+ foreach (var row in filtered)
+ {
+ var cpuPctl = PercentRank(cpuValues, row.TotalCpuMs);
+ var durationPctl = PercentRank(durationValues, row.TotalDurationMs);
+ var readsPctl = PercentRank(readsValues, (decimal)row.TotalReads);
+ var writesPctl = PercentRank(writesValues, (decimal)row.TotalWrites);
+ var memoryPctl = PercentRank(memoryValues, row.TotalMemoryMb);
+ var execsPctl = PercentRank(execValues, (decimal)row.TotalExecutions);
+
+ row.CpuShare = totalCpu > 0 ? Math.Round(100m * row.TotalCpuMs / totalCpu, 1) : 0;
+ row.DurationShare = totalDuration > 0 ? Math.Round(100m * row.TotalDurationMs / totalDuration, 1) : 0;
+ row.ReadsShare = totalReads > 0 ? Math.Round(100m * row.TotalReads / totalReads, 1) : 0;
+ row.WritesShare = totalWrites > 0 ? Math.Round(100m * row.TotalWrites / totalWrites, 1) : 0;
+ row.MemoryShare = totalMemory > 0 ? Math.Round(100m * row.TotalMemoryMb / totalMemory, 1) : 0;
+ row.ExecutionsShare = totalExecs > 0 ? Math.Round(100m * row.TotalExecutions / totalExecs, 1) : 0;
+
+ var pctlSum = cpuPctl + durationPctl + readsPctl + writesPctl + memoryPctl + execsPctl;
+ row.ImpactScore = (int)(pctlSum / 6m * 100m);
+ }
+
+ return filtered.OrderByDescending(r => r.ImpactScore).ToList();
+ }
+
+ internal static decimal PercentRank(List sortedValues, decimal value)
+ {
+ if (sortedValues.Count <= 1) return 0;
+ int rank = sortedValues.Count(v => v < value);
+ return (decimal)rank / (sortedValues.Count - 1);
+ }
+}
+
public class HighImpactQueryRow
{
public string QueryHash { get; set; } = "";