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