From f7145513984e870659bef0e37a439608e606cc32 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 26 Sep 2026 08:26:56 -0400 Subject: [PATCH 1/2] Route get_top_procedures_by_cpu and the Top Procedures grid to the hourly rollup (#4231) Refs #4231. Copies stage 3a's routing pattern (#4396) for procedure_stats: TopProceduresHourlySql over procedure_stats_hourly/procedure_stats_interval_hourly via RollupCoverage.StitchedRelationSql, GetTopProceduresByCpuRoutedAsync exposing the tier, the MCP tool's tier_used/precision_note, and the Viewer's tiered read plus grid header suffix. --- .../TopProceduresHourlyRoutingLiveTests.cs | 277 ++++++++++++++++++ .../TopProceduresHourlyRoutingTests.cs | 79 +++++ .../Mcp/DarlingDataReader.cs | 135 +++++++++ .../Mcp/DarlingMcpDataTools.cs | 34 ++- .../ViewerDataService.ProcedureStats.cs | 103 +++++++ .../ViewerServerTab.Queries.cs | 10 +- 6 files changed, 628 insertions(+), 10 deletions(-) create mode 100644 Darling/Darling.Tests/TopProceduresHourlyRoutingLiveTests.cs create mode 100644 Darling/Darling.Tests/TopProceduresHourlyRoutingTests.cs diff --git a/Darling/Darling.Tests/TopProceduresHourlyRoutingLiveTests.cs b/Darling/Darling.Tests/TopProceduresHourlyRoutingLiveTests.cs new file mode 100644 index 0000000000..73d7fd7951 --- /dev/null +++ b/Darling/Darling.Tests/TopProceduresHourlyRoutingLiveTests.cs @@ -0,0 +1,277 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Service.Mcp; +using PerformanceMonitor.Darling.Storage; +using Xunit; + +namespace Darling.Tests; + +/// +/// #4231 stage 3b live pins: DarlingDataReader.GetTopProceduresByCpuRoutedAsync actually routes to +/// the hourly rollup once raw's floor ages past the window, THROUGH the product's own routed read (never by +/// running the rollup SQL directly). Raw and hourly must agree on the (database_name, schema_name, +/// object_name) totals for the same window, once rolled up. +/// +/// #1776 own-store — mints a scratch database (it materializes a continuous aggregate the +/// shared fixture must never inherit), so it is deliberately NOT in the live-postgres collection. +/// +public sealed class TopProceduresHourlyRoutingLiveTests +{ + private const int ServerId = -943822; + private const string ServerName = "a4231-3b-topn-hourly-routing"; + private const string Db = "TopNHourlyProcDb"; + + private static readonly DateTime WindowStart = new(2026, 1, 5, 0, 0, 0, DateTimeKind.Unspecified); + + [Fact] + public async Task GetTopProceduresByCpuRoutedAsync_RoutesToHourlyOnceRawIsPurged_AndTotalsAgree() + { + var baseConnectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(baseConnectionString), + "Set DARLING_TEST_PG to a Postgres connection string (with TimescaleDB installed) to run the live #4231 stage-3b routing test."); + + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString!, ct); + await using var connection = new NpgsqlConnection(scratch.ConnectionString); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + + var timescaleEnabled = await TimescaleSupport.TryEnableAsync(connection, null, ct); + Assert.SkipWhen(!timescaleEnabled, "The live #4231 stage-3b routing test needs TimescaleDB."); + await TimescaleSupport.ConvertToHypertablesAsync(connection, null, ct); + Assert.True(await TimescaleSupport.EnsureCollectionLogHypertableAsync(connection, null, ct)); + + await using (var stop = new NpgsqlCommand("SELECT _timescaledb_functions.stop_background_workers()", connection)) + { + await stop.ExecuteNonQueryAsync(ct); + } + + await DarlingMcpTestData.RegisterServerAsync(connection, ServerId, ServerName, ct); + await TimescaleSupport.EnsureContinuousAggregatesAsync(connection, null, ct); + + var windowEnd = WindowStart.AddDays(1); + var bodySucceeded = false; + try + { + /* seed: three (schema, object) groups, every row a real nonzero sample_interval_seconds so the + hourly rollup admits them all — the zero-interval exclusion is #4231's own finding, already + pinned in TopQueriesHourlyRoutingLiveTests; not re-litigated here. */ + await PlantAsync(connection, ct, WindowStart.AddHours(1), "usp_ProcA", 500_000L, 400_000L, 10L, 3600); + await PlantAsync(connection, ct, WindowStart.AddHours(2), "usp_ProcB", 200_000L, 180_000L, 5L, 3600); + await PlantAsync(connection, ct, WindowStart.AddHours(3), "usp_ProcC", 50_000L, 40_000L, 2L, 3600); + + await using var dataSource = NpgsqlDataSource.Create(scratch.ConnectionString); + + var rawResult = await DarlingDataReader.GetTopProceduresByCpuRoutedAsync( + dataSource, ServerId, WindowStart, windowEnd, top: 10, databaseName: null, cancellationToken: ct); + Assert.Equal(RetentionTier.Raw, rawResult.Tier); + + var rawTotalsByKey = RollUpByObject(rawResult.Rows); + Assert.True(rawTotalsByKey.ContainsKey("usp_ProcA"), "seed group usp_ProcA must be present in the raw read"); + + /* Refresh the hourly successor over the window BEFORE deleting raw — the product's own + backfill/refresh path, not a hand-built rollup row. */ + await RefreshAsync(connection, TimescaleSupport.ProcedureStatsIntervalHourlyView, WindowStart, windowEnd.AddHours(1), ct); + + var floors = await TimescaleSupport.DetectRollupCoverageAsync(dataSource, await TimescaleSupport.DetectRollupsAsync(dataSource, ct), ct); + Assert.Equal(WindowStart.AddHours(1), floors.FloorOf(TimescaleSupport.ProcedureStatsIntervalHourlyView)); + + /* move raw's floor past the window: delete W's raw rows, as retention would. */ + await using (var purge = new NpgsqlCommand( + "DELETE FROM collect.procedure_stats WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3", connection)) + { + purge.Parameters.AddWithValue(ServerId); + purge.Parameters.AddWithValue(WindowStart); + purge.Parameters.AddWithValue(windowEnd); + await purge.ExecuteNonQueryAsync(ct); + } + + /* FRESH data source — #4231 3a's own cache-freshness finding applies here too: + ComposeStoreAvailability caches coverage per NpgsqlDataSource for 5 minutes; reusing the first + data source would replay the stale null hourly floor its probe measured before RefreshAsync + ran, and the router's fallback would land back on Raw. */ + await using var hourlyDataSource = NpgsqlDataSource.Create(scratch.ConnectionString); + var hourlyResult = await DarlingDataReader.GetTopProceduresByCpuRoutedAsync( + hourlyDataSource, ServerId, WindowStart, windowEnd, top: 10, databaseName: null, cancellationToken: ct); + Assert.Equal(RetentionTier.Hourly, hourlyResult.Tier); + Assert.NotEmpty(hourlyResult.Rows); + + var hourlyTotalsByKey = RollUpByObject(hourlyResult.Rows); + + foreach (var name in new[] { "usp_ProcA", "usp_ProcB", "usp_ProcC" }) + { + Assert.True(rawTotalsByKey.TryGetValue(name, out var rawTotal), $"{name} missing from raw's totals"); + Assert.True(hourlyTotalsByKey.TryGetValue(name, out var hourlyTotal), $"{name} missing from the hourly-routed totals"); + Assert.Equal(rawTotal.CpuUs, hourlyTotal.CpuUs); + Assert.Equal(rawTotal.Executions, hourlyTotal.Executions); + } + + /* the disclosed precision loss: object_type is unavailable at rollup grain. */ + Assert.All(hourlyResult.Rows, r => Assert.Equal("", r.ObjectType)); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, async (cleanup, cleanupCt) => + { + await using var probe = new NpgsqlCommand( + "SELECT count(*) FROM pg_catalog.pg_stat_activity WHERE datname = pg_catalog.current_database() " + + "AND backend_type LIKE 'TimescaleDB Background Worker Scheduler%'", cleanup); + var schedulers = Convert.ToInt64(await probe.ExecuteScalarAsync(cleanupCt)); + Assert.Equal(0L, schedulers); + }); + } + } + + /// + /// #4231 stage 3b live pin: an HOURLY-routed get_top_procedures_by_cpu MCP payload carries a + /// tier_used = "hourly" and a precision_note saying object_type is unavailable at + /// that tier (the rollup has no object_type column). A RAW-routed payload for the same request has + /// tier_used = "raw" and no such note. RED on dev: no tier_used/precision_note field + /// exists on dev's payload at all. + /// + [Fact] + public async Task GetTopProceduresByCpu_HourlyRouted_CarriesTierUsedAndPrecisionNote() + { + var baseConnectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(baseConnectionString), + "Set DARLING_TEST_PG to a Postgres connection string (with TimescaleDB installed) to run the live #4231 stage-3b routing test."); + + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString!, ct); + await using var connection = new NpgsqlConnection(scratch.ConnectionString); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + + var timescaleEnabled = await TimescaleSupport.TryEnableAsync(connection, null, ct); + Assert.SkipWhen(!timescaleEnabled, "The live #4231 stage-3b routing test needs TimescaleDB."); + await TimescaleSupport.ConvertToHypertablesAsync(connection, null, ct); + Assert.True(await TimescaleSupport.EnsureCollectionLogHypertableAsync(connection, null, ct)); + + await using (var stop = new NpgsqlCommand("SELECT _timescaledb_functions.stop_background_workers()", connection)) + { + await stop.ExecuteNonQueryAsync(ct); + } + + await DarlingMcpTestData.RegisterServerAsync(connection, ServerId, ServerName, ct); + await TimescaleSupport.EnsureContinuousAggregatesAsync(connection, null, ct); + + var windowEnd = WindowStart.AddDays(1); + var bodySucceeded = false; + try + { + await PlantAsync(connection, ct, WindowStart.AddHours(1), "usp_ProcA", 500_000L, 400_000L, 10L, 3600); + await PlantAsync(connection, ct, WindowStart.AddHours(2), "usp_ProcB", 200_000L, 180_000L, 5L, 3600); + + await using var dataSource = NpgsqlDataSource.Create(scratch.ConnectionString); + + var hoursBack = (int)Math.Ceiling((windowEnd - WindowStart).TotalHours); + var asOf = windowEnd.ToString("o"); + + var rawJson = await DarlingMcpDataTools.GetTopProceduresByCpu( + dataSource, ServerName, hours_back: hoursBack, top: 10, as_of: asOf); + using var rawDoc = System.Text.Json.JsonDocument.Parse(rawJson); + Assert.True(rawDoc.RootElement.TryGetProperty("tier_used", out var rawTier)); + Assert.Equal("raw", rawTier.GetString()); + Assert.True(rawDoc.RootElement.TryGetProperty("precision_note", out var rawNote)); + Assert.Equal(System.Text.Json.JsonValueKind.Null, rawNote.ValueKind); + + await RefreshAsync(connection, TimescaleSupport.ProcedureStatsIntervalHourlyView, WindowStart, windowEnd.AddHours(1), ct); + + await using (var purge = new NpgsqlCommand( + "DELETE FROM collect.procedure_stats WHERE server_id = $1 AND collection_time >= $2 AND collection_time < $3", connection)) + { + purge.Parameters.AddWithValue(ServerId); + purge.Parameters.AddWithValue(WindowStart); + purge.Parameters.AddWithValue(windowEnd); + await purge.ExecuteNonQueryAsync(ct); + } + + await using var hourlyDataSource = NpgsqlDataSource.Create(scratch.ConnectionString); + var hourlyJson = await DarlingMcpDataTools.GetTopProceduresByCpu( + hourlyDataSource, ServerName, hours_back: hoursBack, top: 10, as_of: asOf); + using var hourlyDoc = System.Text.Json.JsonDocument.Parse(hourlyJson); + Assert.True(hourlyDoc.RootElement.TryGetProperty("tier_used", out var hourlyTier)); + Assert.Equal("hourly", hourlyTier.GetString()); + Assert.True(hourlyDoc.RootElement.TryGetProperty("precision_note", out var hourlyNote)); + Assert.Equal(System.Text.Json.JsonValueKind.String, hourlyNote.ValueKind); + Assert.False(string.IsNullOrEmpty(hourlyNote.GetString())); + + /* the disclosed precision loss on the payload: object_type is null at the hourly tier. */ + var procedures = hourlyDoc.RootElement.GetProperty("procedures").EnumerateArray().ToList(); + Assert.NotEmpty(procedures); + Assert.All(procedures, p => Assert.Equal(System.Text.Json.JsonValueKind.Null, p.GetProperty("object_type").ValueKind)); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, async (cleanup, cleanupCt) => + { + await using var probe = new NpgsqlCommand( + "SELECT count(*) FROM pg_catalog.pg_stat_activity WHERE datname = pg_catalog.current_database() " + + "AND backend_type LIKE 'TimescaleDB Background Worker Scheduler%'", cleanup); + var schedulers = Convert.ToInt64(await probe.ExecuteScalarAsync(cleanupCt)); + Assert.Equal(0L, schedulers); + }); + } + } + + private static Dictionary RollUpByObject(IEnumerable rows) + { + var totals = new Dictionary(StringComparer.Ordinal); + foreach (var row in rows) + { + totals.TryGetValue(row.ObjectName, out var running); + totals[row.ObjectName] = (running.CpuUs + row.TotalCpuUs, running.Executions + row.TotalExecutions); + } + return totals; + } + + private static async Task PlantAsync( + NpgsqlConnection connection, CancellationToken ct, DateTime at, string objectName, + long cpuUs, long elapsedUs, long executions, int intervalSeconds) + { + await using var insert = new NpgsqlCommand(@" +INSERT INTO collect.procedure_stats + (collection_id, collection_time, server_id, server_name, database_name, schema_name, object_name, sql_handle, + delta_worker_time, delta_elapsed_time, delta_execution_count, sample_interval_seconds) +VALUES ($1, $2, $3, $4, $5, 'dbo', $6, $7, $8, $9, $10, $11)", connection); + insert.Parameters.AddWithValue(CollectionIdGenerator.Next()); + insert.Parameters.AddWithValue(DarlingMcpTestData.TruncateToSeconds(at)); + insert.Parameters.AddWithValue(ServerId); + insert.Parameters.AddWithValue(ServerName); + insert.Parameters.AddWithValue(Db); + insert.Parameters.AddWithValue(objectName); + insert.Parameters.AddWithValue("0x" + objectName); + insert.Parameters.AddWithValue(cpuUs); + insert.Parameters.AddWithValue(elapsedUs); + insert.Parameters.AddWithValue(executions); + insert.Parameters.AddWithValue(intervalSeconds); + await insert.ExecuteNonQueryAsync(ct); + } + + private static async Task RefreshAsync(NpgsqlConnection connection, string view, DateTime from, DateTime to, CancellationToken ct) + { + await using var refresh = new NpgsqlCommand($"CALL refresh_continuous_aggregate('collect.{view}'::regclass, $1::timestamp, $2::timestamp)", connection); + refresh.Parameters.AddWithValue(from); + refresh.Parameters.AddWithValue(to); + await refresh.ExecuteNonQueryAsync(ct); + } +} diff --git a/Darling/Darling.Tests/TopProceduresHourlyRoutingTests.cs b/Darling/Darling.Tests/TopProceduresHourlyRoutingTests.cs new file mode 100644 index 0000000000..2a229b945d --- /dev/null +++ b/Darling/Darling.Tests/TopProceduresHourlyRoutingTests.cs @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.IO; +using System.Runtime.CompilerServices; +using PerformanceMonitor.Darling.Service.Mcp; +using Xunit; + +namespace Darling.Tests; + +/// +/// #4231 stage 3b pins for and the routed +/// GetTopProceduresByCpuRoutedAsync's hourly arm. Purely source-level (no live store): the +/// SQL-shape pin RED before this lane (the const did not exist); the source pin RED if a future edit ever +/// names procedure_stats_interval_hourly / procedure_stats_hourly directly in the hourly path +/// instead of going through . +/// +public sealed class TopProceduresHourlyRoutingTests +{ + /// + /// SQL-shape pin: groups by + /// (database_name, schema_name, object_name) only — no object_type, the rollup has none — and ranks by + /// SUM(worker_time_sum) DESC, mirroring TopProceduresSql's CPU-ranking promise. RED before this lane: the + /// const did not exist, so this test would not compile. + /// + [Fact] + public void TopProceduresHourlySql_GroupsByDatabaseSchemaObject_RanksByWorkerTimeSum() + { + var sql = DarlingDataReader.TopProceduresHourlySql; + + Assert.Contains("GROUP BY database_name, schema_name, object_name", sql, StringComparison.Ordinal); + Assert.DoesNotContain("object_type", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY SUM(worker_time_sum) DESC", sql, StringComparison.Ordinal); + Assert.Contains(DarlingDataReader.TopProceduresHourlyFromPlaceholder, sql, StringComparison.Ordinal); + } + + /// + /// Source pin (the standing gate rule): reads its + /// hourly-tier FROM clause ONLY through the $FROM$ placeholder — the constant text itself never + /// names procedure_stats_interval_hourly or procedure_stats_hourly literally, and the + /// reader source file's GetTopProceduresByCpuHourlyAsync method builds that placeholder's + /// substitution ONLY via RollupCoverage.StitchedRelationSql. RED if a future edit inlines either + /// relation name instead. + /// + [Fact] + public void TopProceduresHourlySql_NeverNamesRollupRelationDirectly() + { + var sql = DarlingDataReader.TopProceduresHourlySql; + Assert.DoesNotContain("procedure_stats_interval_hourly", sql, StringComparison.Ordinal); + Assert.DoesNotContain("procedure_stats_hourly", sql, StringComparison.Ordinal); + + var readerPath = FindReaderSourcePath(); + var source = File.ReadAllText(readerPath); + var methodStart = source.IndexOf("private static async Task> GetTopProceduresByCpuHourlyAsync", StringComparison.Ordinal); + Assert.True(methodStart >= 0, "GetTopProceduresByCpuHourlyAsync not found in DarlingDataReader.cs — the brief's method name may have changed."); + + // Bound the scan to roughly this one method's body (the next top-level member, or end of file). + var nextMemberStart = source.IndexOf("\n /* ─────────────────────────── ", methodStart + 1, StringComparison.Ordinal); + var searchEnd = nextMemberStart >= 0 ? nextMemberStart : source.Length; + + var methodBody = source.Substring(methodStart, (searchEnd > methodStart ? searchEnd : source.Length) - methodStart); + Assert.Contains("coverage.StitchedRelationSql(", methodBody, StringComparison.Ordinal); + Assert.DoesNotContain("\"procedure_stats_interval_hourly\"", methodBody, StringComparison.Ordinal); + Assert.DoesNotContain("FROM procedure_stats_interval_hourly", methodBody, StringComparison.Ordinal); + Assert.DoesNotContain("FROM procedure_stats_hourly", methodBody, StringComparison.Ordinal); + } + + private static string FindReaderSourcePath() + => Path.Combine(RepoRoot(), "Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingDataReader.cs"); + + private static string RepoRoot([CallerFilePath] string thisFile = "") + => Path.GetFullPath(Path.Combine(Path.GetDirectoryName(thisFile)!, "..", "..")); +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs index 14e490ff79..b5fc836cea 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs @@ -1475,9 +1475,99 @@ ORDER BY SUM(delta_worker_time) DESC LIMIT $4 """; + /// The FROM-clause placeholder carries — replaced with + /// 's answer at call time. Never hardcode + /// procedure_stats_interval_hourly or procedure_stats_hourly in its place; see + /// . + public const string TopProceduresHourlyFromPlaceholder = "$FROM$"; + + /// + /// #4231 stage 3b: the hourly-tier twin of , over procedure_stats_hourly / + /// procedure_stats_interval_hourly — routed here ONLY through + /// (never by naming either relation directly). The rollup carries neither object_type nor + /// sql_handle/plan_handle (see s_stitchColumnsByLegacy[ProcedureStatsHourlyView], + /// TimescaleSupport.cs), so this groups by (database_name, schema_name, object_name) only — + /// object_type is disclosed as null by the MCP tool's precision_note, not hidden. Ranks by + /// SUM(worker_time_sum) DESC — the same CPU promise makes, over the + /// rollup's pre-summed bucket columns rather than per-collection deltas. $FROM$ is a PLACEHOLDER, + /// substituted (string.Replace, not string.Format) with the FROM-clause item + /// returns for this window at call time — never a literal + /// relation name. $1 server_id, $2/$3 window (naive UTC), $4 top, $5 database filter (NULL = all). + /// + public const string TopProceduresHourlySql = """ + WITH ranked AS ( + SELECT + database_name, + schema_name, + object_name, + CAST(SUM(execution_count_sum) AS bigint) AS total_executions, + CAST(SUM(worker_time_sum) AS bigint) AS total_cpu_us, + CAST(SUM(elapsed_time_sum) AS bigint) AS total_elapsed_us, + MIN(worker_time_min) AS min_worker_time, + MAX(worker_time_max) AS max_worker_time, + MIN(elapsed_time_min) AS min_elapsed_time, + MAX(elapsed_time_max) AS max_elapsed_time + FROM $FROM$ + WHERE server_id = $1 + AND bucket >= $2 + AND bucket <= $3 + AND ($5::text IS NULL OR database_name = $5) + GROUP BY database_name, schema_name, object_name + HAVING (SUM(execution_count_sum) > 0 OR SUM(elapsed_time_sum) > 0) + ORDER BY SUM(worker_time_sum) DESC + LIMIT $4 + ) + SELECT + r.database_name, + r.schema_name, + r.object_name, + r.total_executions, + r.total_cpu_us, + r.total_elapsed_us, + r.min_worker_time, + r.max_worker_time, + r.min_elapsed_time, + r.max_elapsed_time + FROM ranked AS r + ORDER BY r.total_cpu_us DESC + """; + + /// + /// #4231 stage 3b: which tier actually read — + /// or (Daily is clamped to Hourly, this + /// lane's scope); the MCP tool's tier_used comes from here. + public sealed record TopProceduresReadResult(List Rows, RetentionTier Tier); + public static async Task> GetTopProceduresByCpuAsync( NpgsqlDataSource postgres, int serverId, DateTime startUtc, DateTime endUtc, int top, string? databaseName, CancellationToken cancellationToken = default) { + var result = await GetTopProceduresByCpuRoutedAsync(postgres, serverId, startUtc, endUtc, top, databaseName, cancellationToken); + return result.Rows; + } + + /// + /// #4231 stage 3b: 's routed form, exposing the tier it read so a + /// caller can disclose it. Tier is decided over the LEGACY pair's coverage (); + /// Daily is out of scope for this lane and is clamped to Hourly. + /// + public static async Task GetTopProceduresByCpuRoutedAsync( + NpgsqlDataSource postgres, int serverId, DateTime startUtc, DateTime endUtc, int top, string? databaseName, CancellationToken cancellationToken = default) + { + var (rollups, coverage) = await ComposeStoreAvailability.GetRollupsAsync(postgres, cancellationToken); + var tier = RetentionTierRouter.Resolve( + DateTime.UtcNow, startUtc, rollups.ProcedureGrainHourly, dailyAvailable: false, + coverage.For(TimescaleSupport.ProcedureStatsHourlyView, TimescaleSupport.ProcedureStatsDailyView)); + if (tier == RetentionTier.Daily) + { + tier = RetentionTier.Hourly; + } + + if (tier == RetentionTier.Hourly) + { + var hourlyRows = await GetTopProceduresByCpuHourlyAsync(postgres, coverage, serverId, startUtc, endUtc, top, databaseName, cancellationToken); + return new TopProceduresReadResult(hourlyRows, RetentionTier.Hourly); + } + var rows = new List(); await using var command = postgres.CreateCommand(TopProceduresSql); command.CommandTimeout = McpCommandDeadlines.ReadSeconds; @@ -1507,6 +1597,51 @@ public static async Task> GetTopProceduresByCpuAsync( reader.IsDBNull(16) ? 0 : reader.GetInt64(16))); } + return new TopProceduresReadResult(rows, RetentionTier.Raw); + } + + /// + /// #4231 stage 3b: the hourly-rollup arm of — builds + /// 's FROM clause ONLY through + /// (the standing gate: a raw-vs-rollup reader never names procedure_stats_interval_hourly or + /// procedure_stats_hourly directly). Rows carry ObjectType = "", SqlHandle = "" and + /// PlanHandle = "" — the rollup has none of those columns, and the MCP tool's precision_note + /// says so. + /// + private static async Task> GetTopProceduresByCpuHourlyAsync( + NpgsqlDataSource postgres, RollupCoverage coverage, int serverId, DateTime startUtc, DateTime endUtc, + int top, string? databaseName, CancellationToken cancellationToken) + { + var fromClause = coverage.StitchedRelationSql( + TimescaleSupport.ProcedureStatsHourlyView, "f", startUtc, RollupCoverage.StitchTier.Hourly); + var sql = TopProceduresHourlySql.Replace(TopProceduresHourlyFromPlaceholder, fromClause, StringComparison.Ordinal); + + var rows = new List(); + await using var command = postgres.CreateCommand(sql); + command.CommandTimeout = McpCommandDeadlines.ReadSeconds; + AddWindow(command, serverId, startUtc, endUtc); + AddInt(command, top); + AddNullableText(command, databaseName); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new TopProcedureRow( + DatabaseName: reader.IsDBNull(0) ? "" : reader.GetString(0), + SchemaName: reader.IsDBNull(1) ? "" : reader.GetString(1), + ObjectName: reader.IsDBNull(2) ? "" : reader.GetString(2), + /* #4231 stage 3b: the rollup has no object_type column. */ + ObjectType: "", + SqlHandle: "", PlanHandle: "", + TotalExecutions: reader.IsDBNull(3) ? 0 : reader.GetInt64(3), + TotalCpuUs: reader.IsDBNull(4) ? 0 : reader.GetInt64(4), + TotalElapsedUs: reader.IsDBNull(5) ? 0 : reader.GetInt64(5), + TotalLogicalReads: 0, TotalLogicalWrites: 0, TotalPhysicalReads: 0, TotalSpills: 0, + MinCpuUs: reader.IsDBNull(6) ? 0 : reader.GetInt64(6), + MaxCpuUs: reader.IsDBNull(7) ? 0 : reader.GetInt64(7), + MinElapsedUs: reader.IsDBNull(8) ? 0 : reader.GetInt64(8), + MaxElapsedUs: reader.IsDBNull(9) ? 0 : reader.GetInt64(9))); + } + return rows; } diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs index 6d94a285b5..365ed4bfe9 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs @@ -745,7 +745,16 @@ public static async Task GetTopProceduresByCpu( { var now = windowEnd; var requestedStart = now.AddHours(-hours_back); - var rows = await DarlingDataReader.GetTopProceduresByCpuAsync(postgres, resolved.ServerId, requestedStart, now, top, database_name, cancellationToken); + var routed = await DarlingDataReader.GetTopProceduresByCpuRoutedAsync(postgres, resolved.ServerId, requestedStart, now, top, database_name, cancellationToken); + var rows = routed.Rows; + var tierUsed = routed.Tier == RetentionTier.Hourly ? "hourly" : "raw"; + + /* #4231 stage 3b: hourly-routed rows have no object_type, sql_handle or plan_handle — stated once + here rather than per row, since it is a property of the tier, not the row. */ + var precisionNote = tierUsed == "hourly" + ? "hourly-rollup rows have no object_type, sql_handle, or plan_handle" + : null; + if (rows.Count == 0) return await DarlingEngineCapability.NotCollectedStatusAsync(postgres, resolved.ServerId, resolved.ServerName, "procedure_stats", cancellationToken) ?? McpHelpers.Status( @@ -753,10 +762,15 @@ public static async Task GetTopProceduresByCpu( "No procedure stats available. Delta-based collection requires at least two collection cycles (~30 minutes) to produce non-zero values."); /* #4231: what the raw tier actually held, beside what was asked for — same probe and disclosure as - get_top_queries_by_cpu and get_query_store_top (#2364), over procedure_stats. */ - var floor = await DarlingDataReader.GetProcedureStatsWindowFloorAsync(postgres, resolved.ServerId, requestedStart, now, cancellationToken); - var effectiveStart = RawWindowFloor.EffectiveStart(floor, requestedStart); - var windowTruncated = RawWindowFloor.IsTruncated(floor, requestedStart); + get_top_queries_by_cpu and get_query_store_top (#2364), over procedure_stats. + #4231 stage 3b: only meaningful for the RAW tier — an hourly-routed read did not touch + procedure_stats at all, so the raw floor probe would answer a fact about a table this read + never consulted; skip it and report window_truncated = false there. */ + var floor = tierUsed == "raw" + ? await DarlingDataReader.GetProcedureStatsWindowFloorAsync(postgres, resolved.ServerId, requestedStart, now, cancellationToken) + : null; + var effectiveStart = tierUsed == "raw" ? RawWindowFloor.EffectiveStart(floor, requestedStart) : requestedStart; + var windowTruncated = tierUsed == "raw" && RawWindowFloor.IsTruncated(floor, requestedStart); /* #2320: same attributed-CPU disclosure as the queries tool — one shared computation, same concurrent independent reads. */ @@ -775,7 +789,10 @@ same concurrent independent reads. */ { database_name = r.DatabaseName, full_name = string.IsNullOrEmpty(r.SchemaName) ? r.ObjectName : $"{r.SchemaName}.{r.ObjectName}", - object_type = r.ObjectType, + /* #4231 stage 3b: procedure_stats_hourly carries no object_type column, so an hourly-routed + row reports null here rather than the empty string the reader's default carries — + precision_note says so. */ + object_type = tierUsed == "hourly" ? null : r.ObjectType, sql_handle = r.SqlHandle, plan_handle = r.PlanHandle, execution_count = r.TotalExecutions, @@ -804,6 +821,11 @@ same concurrent independent reads. */ /* #4231: what was served, beside what was asked for. */ effective_start = effectiveStart.ToString("o"), effective_hours_back = Math.Round((now - effectiveStart).TotalHours, 1), + /* #4231 stage 3b: which tier answered — "raw" or "hourly" (never bare truncated/degraded + vocabulary; see McpHelpers.WindowTruncatedDescription's own rule). precision_note explains + what an hourly-routed row is missing versus what raw would have returned. */ + tier_used = tierUsed, + precision_note = precisionNote, cpu_attribution = new { ranked_cpu_seconds = attribution.RankedCpuSeconds, diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ProcedureStats.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ProcedureStats.cs index ad5378d4fc..1317b295cf 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ProcedureStats.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ProcedureStats.cs @@ -141,9 +141,112 @@ ORDER BY SUM(delta_elapsed_time) DESC /// /// The top procedure-stats groups for one server over [, /// ], pre-sorted by total elapsed time descending (the grid's default sort). + /// + /// #4231 stage 3b: routes to Raw or the hourly rollup exactly as the MCP reader's + /// DarlingDataReader.GetTopProceduresByCpuRoutedAsync does — the tier decided over + /// 's legacy pair, Daily clamped to Hourly (out of scope for this lane). + /// An hourly-routed page carries only what the rollup has: object_type/sql_handle/ + /// plan_handle/reads/writes/spills columns are unavailable and read as their defaults, exactly the + /// same disclosure the MCP payload's tier_used/precision_note make. Use + /// to also learn which tier answered. /// public async Task> GetTopProceduresByCpuAsync( int serverId, DateTime startUtc, DateTime endUtc, int top = TopQueriesPageSize, IReadOnlyList? databaseNames = null, CancellationToken cancellationToken = default) + { + var (rows, _) = await GetTopProceduresByCpuTierAsync(serverId, startUtc, endUtc, top, databaseNames, cancellationToken); + return rows; + } + + /// #4231 stage 3b: 's routed form, also returning which + /// tier answered ("raw" or "hourly") — the Queries-tab header suffix reads this. + public async Task<(List Rows, string Tier)> GetTopProceduresByCpuTierAsync( + int serverId, DateTime startUtc, DateTime endUtc, int top = TopQueriesPageSize, IReadOnlyList? databaseNames = null, CancellationToken cancellationToken = default) + { + var (rollups, coverage) = await GetRollupAvailabilityAsync(cancellationToken); + var routedTier = RetentionTierRouter.Resolve( + DateTime.UtcNow, startUtc, rollups.ProcedureGrainHourly, dailyAvailable: false, + coverage.For(TimescaleSupport.ProcedureStatsHourlyView, TimescaleSupport.ProcedureStatsDailyView)); + if (routedTier == RetentionTier.Daily) + { + routedTier = RetentionTier.Hourly; + } + + if (routedTier == RetentionTier.Hourly) + { + var hourlyRows = await GetTopProceduresByCpuHourlyAsync(coverage, serverId, startUtc, endUtc, top, databaseNames, cancellationToken); + return (hourlyRows, "hourly"); + } + + return (await GetTopProceduresByCpuRawAsync(serverId, startUtc, endUtc, top, databaseNames, cancellationToken), "raw"); + } + + /// #4231 stage 3b: the hourly-rollup arm — builds its FROM clause ONLY through + /// (never a literal rollup name), groups by + /// (database_name, schema_name, object_name) (the rollup has no object_type), and leaves + /// object_type/sql_handle/plan_handle/reads/writes/spills at their defaults — the + /// rollup has none of those columns. + private async Task> GetTopProceduresByCpuHourlyAsync( + RollupCoverage coverage, int serverId, DateTime startUtc, DateTime endUtc, int top, + IReadOnlyList? databaseNames, CancellationToken cancellationToken) + { + var fromClause = coverage.StitchedRelationSql( + TimescaleSupport.ProcedureStatsHourlyView, "f", startUtc, RollupCoverage.StitchTier.Hourly); + var sql = $""" + SELECT + database_name, + schema_name, + object_name, + CAST(SUM(execution_count_sum) AS bigint) AS total_executions, + CAST(SUM(worker_time_sum) AS bigint) AS total_cpu_us, + CAST(SUM(elapsed_time_sum) AS bigint) AS total_elapsed_us, + MIN(worker_time_min) AS min_worker_time, + MAX(worker_time_max) AS max_worker_time, + MIN(elapsed_time_min) AS min_elapsed_time, + MAX(elapsed_time_max) AS max_elapsed_time + FROM {fromClause} + WHERE server_id = $1 + AND bucket >= $2 + AND bucket <= $3 + AND ($5::text[] IS NULL OR database_name = ANY($5)) + GROUP BY database_name, schema_name, object_name + HAVING (SUM(execution_count_sum) > 0 OR SUM(elapsed_time_sum) > 0) + ORDER BY SUM(worker_time_sum) DESC + LIMIT $4 + """; + + var rows = new List(); + await using var command = _dataSource.CreateCommand(sql); + command.CommandTimeout = ViewerCommandDeadlines.CurrentInteractiveReadSeconds; + AddServerWindowParameters(command, serverId, startUtc, endUtc); + command.Parameters.Add(new Npgsql.NpgsqlParameter { TypedValue = top }); + command.Parameters.Add(DatabaseFilterParameter(databaseNames)); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new ViewerProcedureStatsRow + { + DatabaseName = reader.IsDBNull(0) ? "" : reader.GetString(0), + SchemaName = reader.IsDBNull(1) ? "" : reader.GetString(1), + ObjectName = reader.IsDBNull(2) ? "" : reader.GetString(2), + /* #4231 stage 3b: the rollup has no object_type column. */ + ObjectType = "", + TotalExecutions = reader.IsDBNull(3) ? 0 : reader.GetInt64(3), + TotalCpuUs = reader.IsDBNull(4) ? 0 : reader.GetInt64(4), + TotalElapsedUs = reader.IsDBNull(5) ? 0 : reader.GetInt64(5), + MinWorkerTimeUs = reader.IsDBNull(6) ? 0 : reader.GetInt64(6), + MaxWorkerTimeUs = reader.IsDBNull(7) ? 0 : reader.GetInt64(7), + MinElapsedTimeUs = reader.IsDBNull(8) ? 0 : reader.GetInt64(8), + MaxElapsedTimeUs = reader.IsDBNull(9) ? 0 : reader.GetInt64(9), + }); + } + + return rows; + } + + /// The Raw-tier read, unchanged — what ran before #4231 + /// stage 3b added the hourly arm. + private async Task> GetTopProceduresByCpuRawAsync( + int serverId, DateTime startUtc, DateTime endUtc, int top, IReadOnlyList? databaseNames, CancellationToken cancellationToken) { var rows = new List(); diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs index cecfaafb58..a1db02d822 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs @@ -193,10 +193,12 @@ private async Task LoadTopQueriesAsync(DateTime startUtc, DateTime endUtc) private async Task LoadTopProceduresAsync(DateTime startUtc, DateTime endUtc) { var floorTask = _dataService.GetProcedureStatsWindowFloorAsync(_server.ServerId, startUtc, endUtc); - var rows = await _dataService.GetTopProceduresByCpuAsync(_server.ServerId, startUtc, endUtc, databaseNames: SelectedDatabaseFilter); + var (rows, tier) = await _dataService.GetTopProceduresByCpuTierAsync(_server.ServerId, startUtc, endUtc, databaseNames: SelectedDatabaseFilter); _procStatsFilterMgr!.UpdateData(rows); SetDefaultSortIfNone(ProcedureStatsGrid, "TotalElapsedMs", ListSortDirection.Descending); - UpdateTruncationBanner(ProcStatsTruncationBanner, await floorTask, startUtc); + /* #4231 stage 3b: an hourly-routed page holds no object_type/sql_handle/plan_handle — the raw-floor + banner and this tier disclosure are independent facts, same reasoning as the Queries sub-tab. */ + UpdateTruncationBanner(ProcStatsTruncationBanner, await floorTask, startUtc, tier == "hourly" ? HourlyTierSuffix : null); await LoadProcStatsSlicerAsync(startUtc, endUtc); await RefreshProcStatsComparisonAsync(startUtc, endUtc); } @@ -312,9 +314,9 @@ private async void OnProcStatsSlicerChanged(object? sender, SlicerRangeEventArgs try { var floorTask = _dataService.GetProcedureStatsWindowFloorAsync(_server.ServerId, e.StartUtc, e.EndUtc); - var rows = await _dataService.GetTopProceduresByCpuAsync(_server.ServerId, e.StartUtc, e.EndUtc, databaseNames: SelectedDatabaseFilter); + var (rows, tier) = await _dataService.GetTopProceduresByCpuTierAsync(_server.ServerId, e.StartUtc, e.EndUtc, databaseNames: SelectedDatabaseFilter); _procStatsFilterMgr!.UpdateData(rows); - UpdateTruncationBanner(ProcStatsTruncationBanner, await floorTask, e.StartUtc); + UpdateTruncationBanner(ProcStatsTruncationBanner, await floorTask, e.StartUtc, tier == "hourly" ? HourlyTierSuffix : null); await RefreshProcStatsComparisonAsync(e.StartUtc, e.EndUtc); } catch (Exception ex) From 40ef2ba2a424b4e2e2bc4047ff1958e3cf76a37f Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 26 Sep 2026 08:59:40 -0400 Subject: [PATCH 2/2] Plain comments in the top-N routing change (#4231) --- .../Darling.Tests/TopProceduresHourlyRoutingTests.cs | 6 +++--- .../Mcp/DarlingDataReader.cs | 10 +++++----- .../ViewerDataService.ProcedureStats.cs | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Darling/Darling.Tests/TopProceduresHourlyRoutingTests.cs b/Darling/Darling.Tests/TopProceduresHourlyRoutingTests.cs index 2a229b945d..8f6d128d60 100644 --- a/Darling/Darling.Tests/TopProceduresHourlyRoutingTests.cs +++ b/Darling/Darling.Tests/TopProceduresHourlyRoutingTests.cs @@ -17,7 +17,7 @@ namespace Darling.Tests; /// /// #4231 stage 3b pins for and the routed /// GetTopProceduresByCpuRoutedAsync's hourly arm. Purely source-level (no live store): the -/// SQL-shape pin RED before this lane (the const did not exist); the source pin RED if a future edit ever +/// SQL-shape pin RED before this change (the const did not exist); the source pin RED if a future edit ever /// names procedure_stats_interval_hourly / procedure_stats_hourly directly in the hourly path /// instead of going through . /// @@ -26,7 +26,7 @@ public sealed class TopProceduresHourlyRoutingTests /// /// SQL-shape pin: groups by /// (database_name, schema_name, object_name) only — no object_type, the rollup has none — and ranks by - /// SUM(worker_time_sum) DESC, mirroring TopProceduresSql's CPU-ranking promise. RED before this lane: the + /// SUM(worker_time_sum) DESC, mirroring TopProceduresSql's CPU-ranking promise. RED before this change: the /// const did not exist, so this test would not compile. /// [Fact] @@ -58,7 +58,7 @@ public void TopProceduresHourlySql_NeverNamesRollupRelationDirectly() var readerPath = FindReaderSourcePath(); var source = File.ReadAllText(readerPath); var methodStart = source.IndexOf("private static async Task> GetTopProceduresByCpuHourlyAsync", StringComparison.Ordinal); - Assert.True(methodStart >= 0, "GetTopProceduresByCpuHourlyAsync not found in DarlingDataReader.cs — the brief's method name may have changed."); + Assert.True(methodStart >= 0, "GetTopProceduresByCpuHourlyAsync not found in DarlingDataReader.cs — the method name may have changed."); // Bound the scan to roughly this one method's body (the next top-level member, or end of file). var nextMemberStart = source.IndexOf("\n /* ─────────────────────────── ", methodStart + 1, StringComparison.Ordinal); diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs index b5fc836cea..b2754afe8d 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs @@ -1287,8 +1287,8 @@ public static async Task> GetTopQueriesByCpuAsync( } /// #4231 stage 3: which tier actually read — - /// or (Daily is clamped to Hourly, this - /// lane's scope); the MCP tool's tier_used comes from here. + /// or (Daily is clamped to Hourly); + /// the MCP tool's tier_used comes from here. public sealed record TopQueriesReadResult(List Rows, RetentionTier Tier); /// @@ -1534,8 +1534,8 @@ ORDER BY r.total_cpu_us DESC /// /// #4231 stage 3b: which tier actually read — - /// or (Daily is clamped to Hourly, this - /// lane's scope); the MCP tool's tier_used comes from here. + /// or (Daily is clamped to Hourly); + /// the MCP tool's tier_used comes from here. public sealed record TopProceduresReadResult(List Rows, RetentionTier Tier); public static async Task> GetTopProceduresByCpuAsync( @@ -1548,7 +1548,7 @@ public static async Task> GetTopProceduresByCpuAsync( /// /// #4231 stage 3b: 's routed form, exposing the tier it read so a /// caller can disclose it. Tier is decided over the LEGACY pair's coverage (); - /// Daily is out of scope for this lane and is clamped to Hourly. + /// Daily is clamped to Hourly (#4231). /// public static async Task GetTopProceduresByCpuRoutedAsync( NpgsqlDataSource postgres, int serverId, DateTime startUtc, DateTime endUtc, int top, string? databaseName, CancellationToken cancellationToken = default) diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ProcedureStats.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ProcedureStats.cs index 1317b295cf..b4e7ed6833 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ProcedureStats.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ProcedureStats.cs @@ -144,7 +144,7 @@ ORDER BY SUM(delta_elapsed_time) DESC /// /// #4231 stage 3b: routes to Raw or the hourly rollup exactly as the MCP reader's /// DarlingDataReader.GetTopProceduresByCpuRoutedAsync does — the tier decided over - /// 's legacy pair, Daily clamped to Hourly (out of scope for this lane). + /// 's legacy pair, Daily clamped to Hourly (#4231). /// An hourly-routed page carries only what the rollup has: object_type/sql_handle/ /// plan_handle/reads/writes/spills columns are unavailable and read as their defaults, exactly the /// same disclosure the MCP payload's tier_used/precision_note make. Use