diff --git a/Darling/Darling.Tests/AwsPerServerRoleRungTests.cs b/Darling/Darling.Tests/AwsPerServerRoleRungTests.cs
index 887f79281f..8a9caffffb 100644
--- a/Darling/Darling.Tests/AwsPerServerRoleRungTests.cs
+++ b/Darling/Darling.Tests/AwsPerServerRoleRungTests.cs
@@ -107,7 +107,8 @@ public void TheProbeMapsAFullyMigratedStoreToThisTopRung()
Assert.Contains($"reader.GetBoolean({ProbeOrdinal + 4})", viewer, StringComparison.Ordinal);
Assert.Contains($"reader.GetBoolean({ProbeOrdinal + 5})", viewer, StringComparison.Ordinal);
Assert.Contains($"reader.GetBoolean({ProbeOrdinal + 6})", viewer, StringComparison.Ordinal);
- Assert.DoesNotContain($"reader.GetBoolean({ProbeOrdinal + 7})", viewer, StringComparison.Ordinal);
+ Assert.Contains($"reader.GetBoolean({ProbeOrdinal + 7})", viewer, StringComparison.Ordinal);
+ Assert.DoesNotContain($"reader.GetBoolean({ProbeOrdinal + 8})", viewer, StringComparison.Ordinal);
Assert.Equal(StorageVersion.SchemaVersion, ViewerDataService.RequiredStoreSchemaVersion);
@@ -115,7 +116,7 @@ public void TheProbeMapsAFullyMigratedStoreToThisTopRung()
var arity = method.GetParameters().Length;
/* A newer rung's sentinel (V175's, #5631) is the last argument. */
- Assert.Equal(ProbeOrdinal + 6, arity - 1);
+ Assert.Equal(ProbeOrdinal + 7, arity - 1);
var all = Enumerable.Repeat((object)true, arity).ToArray();
Assert.Equal(StorageVersion.SchemaVersion, (int)method.Invoke(null, all)!);
diff --git a/Darling/Darling.Tests/CaggGroupIndexDropLiveTests.cs b/Darling/Darling.Tests/CaggGroupIndexDropLiveTests.cs
index 7a07dc30e6..948950ae90 100644
--- a/Darling/Darling.Tests/CaggGroupIndexDropLiveTests.cs
+++ b/Darling/Darling.Tests/CaggGroupIndexDropLiveTests.cs
@@ -318,6 +318,13 @@ the group index still present — the same fixture shape CaggGroupIndexDropUpgra
/// A future rung that adds a new probe sentinel must add its artifact's removal here.
private static async Task DropArtifactsOfLaterRungsAsync(NpgsqlConnection connection, int simulatedVersion, CancellationToken ct)
{
+ if (simulatedVersion < ProcedureStatsTotals.RungVersion)
+ {
+ /* V176 (#5638) - the per-collection procedure totals table is the probe's sentinel. */
+ await using var dropTotals = new NpgsqlCommand("DROP TABLE IF EXISTS collect.procedure_stats_totals", connection);
+ await dropTotals.ExecuteNonQueryAsync(ct);
+ }
+
if (simulatedVersion < 175)
{
/* V175 (#5631) - the rows-per-call column of the daily plan regression totals is the probe's sentinel. */
diff --git a/Darling/Darling.Tests/DarlingMcpTrendToolsTests.cs b/Darling/Darling.Tests/DarlingMcpTrendToolsTests.cs
index ec90607516..505ef4037b 100644
--- a/Darling/Darling.Tests/DarlingMcpTrendToolsTests.cs
+++ b/Darling/Darling.Tests/DarlingMcpTrendToolsTests.cs
@@ -361,12 +361,14 @@ public void ProcedureDurationTrendSql_PrefersTheStoredInterval_NeverFabricatesZe
Assert.Contains("CASE WHEN is_stored AND max_interval_seconds = 0 THEN NULL", sql, StringComparison.Ordinal);
Assert.Contains($"CASE WHEN gap_seconds <= {CollectorDeltaCalculator.DefaultMaxGapSeconds} THEN gap_seconds END", sql, StringComparison.Ordinal);
Assert.Contains("WHEN is_stored THEN NULLIF(max_interval_seconds, 0)", sql, StringComparison.Ordinal);
- Assert.Equal(2, System.Text.RegularExpressions.Regex.Matches(sql, @"collection_time >= \$2 - INTERVAL '3600 seconds'").Count);
+ /* #5638: the stored collections come from two arms (the rows below the server's totals floor, the totals from it), each bounded
+ from $2 less the gap, and the runs are the third. */
+ Assert.Equal(3, System.Text.RegularExpressions.Regex.Matches(sql, @"collection_time >= \$2 - INTERVAL '3600 seconds'").Count);
Assert.Contains("FROM gapped\nWHERE collection_time >= $2", string.Join('\n', Lines(sql)), StringComparison.Ordinal);
/* A run is logged after it finishes, so one that began inside the window can be logged just past its end: the runs are read
- an hour past $3 (once), the stored rows and the final points stop at $3 (twice). */
+ an hour past $3 (once), the stored rows, the stored totals and the final points stop at $3 (three times, #5638). */
Assert.Single(System.Text.RegularExpressions.Regex.Matches(sql, @"collection_time <= \$3 \+ INTERVAL '3600 seconds'"));
- Assert.Equal(2, System.Text.RegularExpressions.Regex.Matches(sql, @"collection_time <= \$3\r?\n").Count);
+ Assert.Equal(3, System.Text.RegularExpressions.Regex.Matches(sql, @"collection_time <= \$3\r?\n").Count);
Assert.DoesNotContain("COALESCE(MAX(", sql, StringComparison.Ordinal);
Assert.DoesNotContain("series_first", sql, StringComparison.Ordinal);
Assert.Contains("CASE WHEN interval_seconds > 0 THEN total_elapsed_ms / interval_seconds END AS elapsed_ms_per_second", sql, StringComparison.Ordinal);
@@ -379,13 +381,15 @@ public void ProcedureDurationTrendSql_PrefersTheStoredInterval_NeverFabricatesZe
carries NO database term, so the interval is the collection's), is the same text. */
static bool IsFilteredSumLine(string l) =>
l.Contains("delta_elapsed_time", StringComparison.Ordinal) || l.Contains("delta_execution_count", StringComparison.Ordinal)
+ || l.Contains("total_elapsed_time", StringComparison.Ordinal) || l.Contains("total_execution_count", StringComparison.Ordinal)
|| l.Contains("matched_rows", StringComparison.Ordinal);
foreach (var cte in new[] { "stored AS\n(", "runs AS\n(", "events AS\n(", "sequenced AS\n(", "points AS\n(", "gapped AS\n(", "raw AS\n(" })
{
var viewerCte = Cte(Lf(ViewerDataService.ProcedureDurationTrendSql), cte);
if (cte == "stored AS\n(")
{
- Assert.Equal(3, viewerCte.Split('\n').Count(l => l.Contains("FILTER (WHERE $4::text[] IS NULL OR database_name = ANY($4))", StringComparison.Ordinal)));
+ /* #5638: three filtered sums on the rows arm and three on the totals arm. */
+ Assert.Equal(6, viewerCte.Split('\n').Count(l => l.Contains("FILTER (WHERE $4::text[] IS NULL OR database_name = ANY($4))", StringComparison.Ordinal)));
}
var viewer = string.Join('\n', viewerCte.Split('\n').Where(l => !IsFilteredSumLine(l)).Select(l => l.Trim()));
var mcp = string.Join('\n', Cte(Lf(sql), cte).Split('\n').Where(l => !IsFilteredSumLine(l)).Select(l => l.Trim()));
diff --git a/Darling/Darling.Tests/DurationTrendDatabaseFilterLiveTests.cs b/Darling/Darling.Tests/DurationTrendDatabaseFilterLiveTests.cs
index 8c57ccbe39..274099d63c 100644
--- a/Darling/Darling.Tests/DurationTrendDatabaseFilterLiveTests.cs
+++ b/Darling/Darling.Tests/DurationTrendDatabaseFilterLiveTests.cs
@@ -362,7 +362,9 @@ every database. */
/* Raw: the per-collection CTE, the filter at $4 and the bucket width at $5. */
Assert.Equal(3, Count(DarlingTrendReader.QueryDurationTrendFilteredSql, filterPredicate4));
- Assert.Equal(3, Count(DarlingTrendReader.ProcedureDurationTrendFilteredSql, filterPredicate4));
+ /* #5638: the procedure statement's stored CTE has two arms, the rows below the totals floor and the per-database totals from
+ it, and each carries the three FILTERs (elapsed, executions, matched rows), so six. */
+ Assert.Equal(6, Count(DarlingTrendReader.ProcedureDurationTrendFilteredSql, filterPredicate4));
Assert.Equal(0, Count(DarlingTrendReader.QueryDurationTrendFilteredSql, predicate4));
Assert.Equal(0, Count(DarlingTrendReader.ProcedureDurationTrendFilteredSql, predicate4));
/* #5414 round 2: empty is decided once for the whole window, never per bucket (a per-bucket HAVING dropped the
diff --git a/Darling/Darling.Tests/LegacyPinCandidateRungTests.cs b/Darling/Darling.Tests/LegacyPinCandidateRungTests.cs
index 34d6458c4e..4fcec18a8d 100644
--- a/Darling/Darling.Tests/LegacyPinCandidateRungTests.cs
+++ b/Darling/Darling.Tests/LegacyPinCandidateRungTests.cs
@@ -138,11 +138,12 @@ public void TheProbeCarriesTheTable_AtItsOwnOrdinal()
Assert.Contains($"reader.GetBoolean({ProbeOrdinal + 6})", viewer, StringComparison.Ordinal);
Assert.Contains($"reader.GetBoolean({ProbeOrdinal + 7})", viewer, StringComparison.Ordinal);
Assert.Contains($"reader.GetBoolean({ProbeOrdinal + 8})", viewer, StringComparison.Ordinal);
- Assert.DoesNotContain($"reader.GetBoolean({ProbeOrdinal + 9})", viewer, StringComparison.Ordinal);
+ Assert.Contains($"reader.GetBoolean({ProbeOrdinal + 9})", viewer, StringComparison.Ordinal);
+ Assert.DoesNotContain($"reader.GetBoolean({ProbeOrdinal + 10})", viewer, StringComparison.Ordinal);
var method = typeof(ViewerDataService).GetMethod("MapProbedSchemaVersion", BindingFlags.NonPublic | BindingFlags.Static)!;
var parameters = method.GetParameters();
- Assert.Equal(ProbeOrdinal + 8, parameters.Length - 1);
+ Assert.Equal(ProbeOrdinal + 9, parameters.Length - 1);
Assert.Equal("hasLegacyPinCandidates", parameters[ProbeOrdinal].Name);
Assert.Equal("hasPagerDutyAutoResolve", parameters[ProbeOrdinal - 1].Name);
diff --git a/Darling/Darling.Tests/PlanRegressionDailyRungTests.cs b/Darling/Darling.Tests/PlanRegressionDailyRungTests.cs
index acb131fe46..c9a523ffcd 100644
--- a/Darling/Darling.Tests/PlanRegressionDailyRungTests.cs
+++ b/Darling/Darling.Tests/PlanRegressionDailyRungTests.cs
@@ -336,7 +336,8 @@ public void TheProbeCarriesTheBuiltTableAtItsOwnOrdinal_AndMapsItsRung()
Assert.Contains($"reader.GetBoolean({ProbeOrdinal + 5})", viewer, StringComparison.Ordinal);
Assert.Contains($"reader.GetBoolean({ProbeOrdinal + 6})", viewer, StringComparison.Ordinal);
Assert.Contains($"reader.GetBoolean({ProbeOrdinal + 7})", viewer, StringComparison.Ordinal);
- Assert.DoesNotContain($"reader.GetBoolean({ProbeOrdinal + 8})", viewer, StringComparison.Ordinal);
+ Assert.Contains($"reader.GetBoolean({ProbeOrdinal + 8})", viewer, StringComparison.Ordinal);
+ Assert.DoesNotContain($"reader.GetBoolean({ProbeOrdinal + 9})", viewer, StringComparison.Ordinal);
Assert.Equal(StorageVersion.SchemaVersion, ViewerDataService.RequiredStoreSchemaVersion);
@@ -344,7 +345,7 @@ public void TheProbeCarriesTheBuiltTableAtItsOwnOrdinal_AndMapsItsRung()
var parameters = method.GetParameters();
/* A newer rung's sentinel (V175's, #5631) is the last argument. */
- Assert.Equal(ProbeOrdinal + 7, parameters.Length - 1);
+ Assert.Equal(ProbeOrdinal + 8, parameters.Length - 1);
Assert.Equal("hasPlanRegressionDaily", parameters[ProbeOrdinal].Name);
/* Every sentinel true is a fully-migrated store, which maps to exactly this build's version. */
diff --git a/Darling/Darling.Tests/PlanRegressionRowcountRungTests.cs b/Darling/Darling.Tests/PlanRegressionRowcountRungTests.cs
index 9fbf04d60b..a71461bb3a 100644
--- a/Darling/Darling.Tests/PlanRegressionRowcountRungTests.cs
+++ b/Darling/Darling.Tests/PlanRegressionRowcountRungTests.cs
@@ -120,21 +120,26 @@ public void TheProbeNamesTheColumn_AndMapsAFullyMigratedStoreToThisTopRung()
var viewer = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.cs");
Assert.Contains($"reader.GetBoolean({ProbeOrdinal})", viewer, StringComparison.Ordinal);
- Assert.DoesNotContain($"reader.GetBoolean({ProbeOrdinal + 1})", viewer, StringComparison.Ordinal);
+ Assert.Contains($"reader.GetBoolean({ProbeOrdinal + 1})", viewer, StringComparison.Ordinal);
+ Assert.DoesNotContain($"reader.GetBoolean({ProbeOrdinal + 2})", viewer, StringComparison.Ordinal);
var method = typeof(ViewerDataService).GetMethod("MapProbedSchemaVersion", BindingFlags.NonPublic | BindingFlags.Static)!;
var parameters = method.GetParameters();
Assert.Equal("hasPlanRegressionDailyRowcount", parameters[ProbeOrdinal].Name);
Assert.Equal("hasServiceVersion", parameters[ProbeOrdinal - 1].Name);
- Assert.Equal(ProbeOrdinal, parameters.Length - 1);
+ Assert.Equal(ProbeOrdinal + 1, parameters.Length - 1);
/* Every sentinel present maps to exactly this build's version; this sentinel absent maps one rung back. */
var all = Enumerable.Repeat((object)true, parameters.Length).ToArray();
Assert.Equal(StorageVersion.SchemaVersion, (int)method.Invoke(null, all)!);
- Assert.Equal(RungVersion, (int)method.Invoke(null, all)!);
+ /* V176 (#5638) sits above this rung: with only this sentinel and those below it the store maps to this rung. */
+ var topAbsent = (object[])all.Clone();
+ topAbsent[ProbeOrdinal + 1] = false;
+ Assert.Equal(RungVersion, (int)method.Invoke(null, topAbsent)!);
var behind = (object[])all.Clone();
behind[ProbeOrdinal] = false;
+ behind[ProbeOrdinal + 1] = false;
Assert.Equal(RungVersion - 1, (int)method.Invoke(null, behind)!);
/* In the source the arm sits ABOVE V174's and returns this rung's version. */
diff --git a/Darling/Darling.Tests/ProcedureStatsTotalsTests.cs b/Darling/Darling.Tests/ProcedureStatsTotalsTests.cs
new file mode 100644
index 0000000000..a54d3ef548
--- /dev/null
+++ b/Darling/Darling.Tests/ProcedureStatsTotalsTests.cs
@@ -0,0 +1,340 @@
+/*
+ * 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.IO;
+using System.Linq;
+using PerformanceMonitor.Collectors;
+using PerformanceMonitor.Darling.Service;
+using PerformanceMonitor.Darling.Service.Mcp;
+using PerformanceMonitor.Darling.Storage;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// Pins for the per-collection procedure totals (#5638, V176) that need no store: the rung and its text, the upsert statement, the
+/// runner's call site, the retention registration, and the MCP duration trends' tier clock. The live behavior is in
+/// .
+///
+public sealed class ProcedureStatsTotalsTests
+{
+ private static string Normalize(string text) => text.Replace("\r\n", "\n", StringComparison.Ordinal);
+
+ /// Review of #5706: the gap fill is bounded by the server's newest earlier totals row and the batch, and recomputes like the upsert.
+ [Fact]
+ public void TheGapFill_TotalsTheCollectionsBetweenTheNewestEarlierTotalsAndTheBatch_AndRecomputes()
+ {
+ var sql = Normalize(ProcedureStatsTotals.GapFillSql);
+
+ Assert.Contains("FROM collect.procedure_stats AS s\nWHERE s.server_id = $1::integer\nAND s.collection_time < $2::timestamp\nAND s.collection_time > $3::timestamp\nGROUP BY", sql, StringComparison.Ordinal);
+
+ /* Round 2 (L2): the anchor is a bound parameter, so chunk exclusion sees both bounds; no subquery recomputes it. */
+ Assert.DoesNotContain("SELECT max(", sql, StringComparison.Ordinal);
+ Assert.Contains("ON CONFLICT (server_id, collection_time, database_name) DO UPDATE SET", sql, StringComparison.Ordinal);
+ Assert.DoesNotContain("t.row_count +", sql, StringComparison.Ordinal);
+ }
+
+ /// Review of #5706: the retention predicate carries the floor as a parameter and never scans procedure_stats itself (#1815).
+ [Fact]
+ public void ThePrunePredicate_TakesTheFloorAsAParameter_AndNeverScansProcedureStats()
+ {
+ Assert.DoesNotContain("procedure_stats", ProcedureStatsTotals.PrunePredicate.Replace("procedure_stats_totals", string.Empty, StringComparison.Ordinal), StringComparison.Ordinal);
+ Assert.Contains("$2", ProcedureStatsTotals.PrunePredicate, StringComparison.Ordinal);
+
+ var delete = DarlingRetention.TimeSlicedDeleteSql(ProcedureStatsTotals.Table, "collection_time", ProcedureStatsTotals.PrunePredicate);
+ Assert.DoesNotContain("FROM collect.procedure_stats)", delete, StringComparison.Ordinal);
+ Assert.DoesNotContain("FROM collect.procedure_stats ", delete, StringComparison.Ordinal);
+
+ var retention = Normalize(RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "DarlingRetention.cs"));
+ Assert.Contains("secondParameterFactory: async (floorConnection, ct) =>", retention, StringComparison.Ordinal);
+ Assert.Single(System.Text.RegularExpressions.Regex.Matches(retention, "ProcedureStatsTotals\\.MeasureRawFloorAsync\\("));
+
+ /* Round 2 (L4): the floor is measured inside PurgeOneAsync, after its wall-budget gate and on the purge's own connection. */
+ var purgeOne = retention[retention.IndexOf("private static async Task PurgeOneAsync(", StringComparison.Ordinal)..];
+ var gate = purgeOne.IndexOf("SkipTableOnBudget(tableName)", StringComparison.Ordinal);
+ var open = purgeOne.IndexOf("postgres.OpenConnectionAsync(", StringComparison.Ordinal);
+ var factory = purgeOne.IndexOf("await secondParameterFactory(connection, cancellationToken)", StringComparison.Ordinal);
+ Assert.True(gate > 0 && open > gate && factory > open, "the budget gate, then the connection, then the floor measured on it");
+ }
+
+ /// Round 2 (L3): the plain-store floor is one server-leading index probe per server with totals, never a global min().
+ [Fact]
+ public void ThePlainFloor_IsAnIndexProbePerServerWithTotals_NotAGlobalMin()
+ {
+ var sql = Normalize(ProcedureStatsTotals.PlainRawFloorSql);
+
+ Assert.Contains("CROSS JOIN LATERAL", sql, StringComparison.Ordinal);
+ Assert.Contains("WHERE p.server_id = servers.server_id\n ORDER BY p.collection_time\n LIMIT 1", sql, StringComparison.Ordinal);
+ Assert.Contains("FROM collect.procedure_stats_totals AS t", sql, StringComparison.Ordinal);
+ Assert.DoesNotContain("min(collection_time)", sql, StringComparison.Ordinal);
+ Assert.Contains("min(oldest.collection_time)", sql, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void TheRung_IsV176_AndEmbedsTheCreateText()
+ {
+ Assert.Equal(176, ProcedureStatsTotals.RungVersion);
+ Assert.True(StorageVersion.SchemaVersion >= ProcedureStatsTotals.RungVersion);
+
+ var migrations = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Storage", "PgMigrations.cs");
+ Assert.Contains("new Migration(176, \"procedure-stats-totals\", V176Sql)", migrations, StringComparison.Ordinal);
+ Assert.Contains("private const string V176Sql = ProcedureStatsTotals.CreateSql;", migrations, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void TheCreateText_PinsTheShape_NoBackfill_AndTheNullsNotDistinctKey()
+ {
+ var sql = Normalize(ProcedureStatsTotals.CreateSql);
+
+ Assert.Contains("CREATE TABLE IF NOT EXISTS collect.procedure_stats_totals", sql, StringComparison.Ordinal);
+ Assert.Contains("server_id integer NOT NULL,", sql, StringComparison.Ordinal);
+ Assert.Contains("collection_time timestamp NOT NULL,", sql, StringComparison.Ordinal);
+ Assert.Contains("database_name text,", sql, StringComparison.Ordinal);
+ Assert.Contains("total_elapsed_time bigint,", sql, StringComparison.Ordinal);
+ Assert.Contains("total_execution_count bigint,", sql, StringComparison.Ordinal);
+ Assert.Contains("row_count bigint NOT NULL,", sql, StringComparison.Ordinal);
+ Assert.Contains("max_sample_interval_seconds integer", sql, StringComparison.Ordinal);
+ Assert.Contains(
+ "CREATE UNIQUE INDEX IF NOT EXISTS ux_procedure_stats_totals ON collect.procedure_stats_totals (server_id, collection_time, database_name) NULLS NOT DISTINCT;",
+ sql, StringComparison.Ordinal);
+
+ /* No backfill: the rung moves no existing row. */
+ Assert.DoesNotContain("INSERT", sql, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("UPDATE", sql, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("create_hypertable", sql, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void TheUpsert_ReadsBackTheStoredRows_AndRecomputesEveryMeasureOnConflict()
+ {
+ var sql = Normalize(ProcedureStatsTotals.UpsertSql);
+
+ Assert.Contains("FROM collect.procedure_stats\nWHERE server_id = $1::integer\nAND collection_time = $2::timestamp\nAND ($3::text[] IS NULL OR database_name = ANY($3::text[]) OR ($4::boolean AND database_name IS NULL))\nGROUP BY server_id, collection_time, database_name", sql, StringComparison.Ordinal);
+ Assert.Contains("SUM(delta_elapsed_time)::bigint,", sql, StringComparison.Ordinal);
+ Assert.Contains("SUM(delta_execution_count)::bigint,", sql, StringComparison.Ordinal);
+ Assert.Contains("COUNT(*),", sql, StringComparison.Ordinal);
+ Assert.Contains("MAX(sample_interval_seconds)", sql, StringComparison.Ordinal);
+ Assert.Contains("ON CONFLICT (server_id, collection_time, database_name) DO UPDATE SET", sql, StringComparison.Ordinal);
+
+ /* Recompute, never add: every measure is set from EXCLUDED, and no measure is added to its old value. */
+ foreach (var measure in new[] { "total_elapsed_time", "total_execution_count", "row_count", "max_sample_interval_seconds" })
+ {
+ Assert.Contains($"{measure} = EXCLUDED.{measure}", sql, StringComparison.Ordinal);
+ }
+
+ Assert.DoesNotContain("t.row_count +", sql, StringComparison.Ordinal);
+ Assert.DoesNotContain("COALESCE(", sql, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void TheRunner_WritesTheTotalsOnTheCopysTransaction_AfterTheCopy_BeforeTheCommit()
+ {
+ var runner = Normalize(RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "DarlingCollectorRunner.cs"));
+ var start = runner.IndexOf("private async Task CopyBatchOnceAsync(", StringComparison.Ordinal);
+ Assert.True(start > 0);
+ var body = runner[start..];
+
+ var call = body.IndexOf("ProcedureStatsTotalsWriter.UpsertBatchAsync(", StringComparison.Ordinal);
+ var commit = body.IndexOf("await transaction.CommitAsync(cancellationToken);", StringComparison.Ordinal);
+ var ledger = body.IndexOf("QueryStatsHourLedgerWriter.AddBatchAsync(", StringComparison.Ordinal);
+ Assert.True(ledger > 0 && call > ledger && commit > call, "the totals upsert sits after the COPY block and before the commit");
+
+ /* On the COPY's transaction, for procedure_stats batches only, with no savepoint or catch around it. */
+ Assert.Contains("pgConnection, transaction, server.ServerId, storedCollectionTime,\n rowsWritten", body, StringComparison.Ordinal);
+ Assert.Contains("var totalsBatch = string.Equals(definition.TargetTable, ProcedureStatsCollector.Instance.TargetTable", body, StringComparison.Ordinal);
+ Assert.Contains("|| ledgerBatch || totalsBatch", body, StringComparison.Ordinal);
+ var between = body[(call - 400)..call];
+ Assert.DoesNotContain("catch", between, StringComparison.Ordinal);
+ Assert.DoesNotContain("SAVEPOINT", between, StringComparison.OrdinalIgnoreCase);
+
+ /* collect.procedure_stats has exactly one writer, the runner's COPY (built from TargetTable, so it has no literal): no other Darling source file names an INSERT or COPY into it. */
+ var scanned = 0;
+ foreach (var file in Directory.EnumerateFiles(RepoFile.PathTo("Darling"), "*.cs", SearchOption.AllDirectories))
+ {
+ var relative = Path.GetRelativePath(RepoFile.Root, file).Replace('\\', '/');
+ if (relative.Contains("/obj/", StringComparison.Ordinal) || relative.Contains("/bin/", StringComparison.Ordinal)
+ || relative.StartsWith("Darling/Darling.Tests/", StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ scanned++;
+ var text = File.ReadAllText(file);
+ Assert.False(text.Contains("INSERT INTO collect.procedure_stats ", StringComparison.Ordinal), relative + " writes collect.procedure_stats");
+ Assert.False(text.Contains("COPY collect.procedure_stats ", StringComparison.Ordinal), relative + " COPYs into collect.procedure_stats");
+ }
+
+ Assert.True(scanned > 300, "the scan saw " + scanned + " Darling source files");
+ }
+
+ ///
+ /// The read-back limit's type check (seat review of #5706). Every write goes through CopyBatchOnceAsync<TRow>(... List<TRow> rows ...)
+ /// and the catalog's only procedure_stats definition is a CollectorDefinitionBase<Row>, so the server, Azure
+ /// per-database and import paths all hand the writer a List<Row>; the helper accepts any IEnumerable<Row>.
+ ///
+ [Fact]
+ public void TheReadBackLimit_MatchesEveryRowCollectionShape_AndEveryProcedureStatsPathCarriesRows()
+ {
+ var rows = new List { Named("A"), Named("B"), Named("A"), Named(null!) };
+ var expected = new string?[] { "A", "B", null };
+
+ Assert.Equal(expected, ProcedureStatsTotalsWriter.DatabaseNamesOf(rows));
+ Assert.Equal(expected, ProcedureStatsTotalsWriter.DatabaseNamesOf(rows.ToArray()));
+ Assert.Equal(expected, ProcedureStatsTotalsWriter.DatabaseNamesOf(rows.AsEnumerable()));
+ Assert.Equal(expected, ProcedureStatsTotalsWriter.DatabaseNamesOf(new System.Collections.ObjectModel.ReadOnlyCollection(rows)));
+ Assert.Empty(ProcedureStatsTotalsWriter.DatabaseNamesOf(new List())!);
+
+ /* Rows of another type (or none) read the whole collection. */
+ Assert.Null(ProcedureStatsTotalsWriter.DatabaseNamesOf(new List { "A" }));
+ Assert.Null(ProcedureStatsTotalsWriter.DatabaseNamesOf(null));
+
+ /* The call-site trace: one writer call, fed by the helper, from the one generic COPY whose rows are a List... */
+ var runner = Normalize(RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "DarlingCollectorRunner.cs"));
+ var start = runner.IndexOf("private async Task CopyBatchOnceAsync(", StringComparison.Ordinal);
+ Assert.True(start > 0);
+ var signature = runner[start..(start + 400)];
+ Assert.Contains("List rows,", signature, StringComparison.Ordinal);
+ Assert.Contains("ProcedureStatsTotalsWriter.DatabaseNamesOf(rows)", runner[start..], StringComparison.Ordinal);
+ Assert.DoesNotContain("procedureRows", runner[start..], StringComparison.Ordinal);
+ Assert.Single(System.Text.RegularExpressions.Regex.Matches(runner, "ProcedureStatsTotalsWriter\\.UpsertBatchAsync\\("));
+
+ /* ...and every catalog definition that writes procedure_stats carries Row (so TRow = Row on every path that totals). */
+ var writers = PerformanceMonitor.Collectors.CollectorCatalog.All
+ .Where(c => string.Equals(c.TargetTable, ProcedureStatsCollector.Instance.TargetTable, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+ Assert.NotEmpty(writers);
+ Assert.All(writers, c => Assert.IsAssignableFrom>(c));
+ }
+
+ private static ProcedureStatsCollector.Row Named(string database) => new(
+ database, "dbo", "p", "P ", null, null, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, null, null, null, null);
+
+ ///
+ /// The gap fill runs behind a savepoint and a server-side statement_timeout, restores the timeout on success, and rolls back to the
+ /// savepoint on a fault; the runner's call site stays free of any catch (the batch's own upsert is still fatal to it).
+ ///
+ [Fact]
+ public void TheGapFill_RunsBehindASavepointAndAServerSideTimeout_AndNeverFailsTheBatch()
+ {
+ var writer = Normalize(RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Storage", "ProcedureStatsTotalsWriter.cs"));
+ var gap = writer[writer.IndexOf("private static async Task TryGapFillAsync(", StringComparison.Ordinal)..];
+
+ var save = gap.IndexOf("transaction.SaveAsync(GapSavepointName", StringComparison.Ordinal);
+ var setTimeout = gap.IndexOf("SetStatementTimeoutAsync(", StringComparison.Ordinal);
+ var run = gap.IndexOf("ProcedureStatsTotals.GapFillSql", StringComparison.Ordinal);
+ var restore = gap.IndexOf("SetStatementTimeoutAsync(connection, transaction, previousTimeout", StringComparison.Ordinal);
+ var release = gap.IndexOf("transaction.ReleaseAsync(GapSavepointName", StringComparison.Ordinal);
+ var rollback = gap.IndexOf("transaction.RollbackAsync(GapSavepointName", StringComparison.Ordinal);
+ Assert.True(save > 0 && setTimeout > save && run > setTimeout && restore > run && release > restore && rollback > release, "save, bound, run, restore, release, then the rollback arm");
+ Assert.Contains("catch (Exception ex) when (!cancellationToken.IsCancellationRequested)", gap, StringComparison.Ordinal);
+ Assert.Contains("set_config('statement_timeout', $1, true)", writer, StringComparison.Ordinal);
+
+ /* Round 2 (L1): the anchor and the gap test are ONE statement that runs before the savepoint; with no anchor or no gap the
+ method returns before the savepoint, the timeout change and the gap statement, so the normal batch pays that one statement. */
+ var probe = gap.IndexOf("ProcedureStatsTotals.GapProbeSql", StringComparison.Ordinal);
+ var early = gap.IndexOf("if (reader.IsDBNull(1) || !reader.GetBoolean(2))", StringComparison.Ordinal);
+ Assert.True(probe > 0 && early > probe && save > early, "probe, then the early return, then the savepoint");
+ Assert.Single(System.Text.RegularExpressions.Regex.Matches(gap, "ExecuteReaderAsync"));
+ var probeSql = Normalize(ProcedureStatsTotals.GapProbeSql);
+ Assert.Contains("current_setting('statement_timeout')", probeSql, StringComparison.Ordinal);
+ Assert.Contains("p.collection_time <= $2::timestamp", probeSql, StringComparison.Ordinal);
+ Assert.DoesNotContain("p.collection_time < $2", probeSql, StringComparison.Ordinal);
+ Assert.Contains("s.collection_time > a.anchor", probeSql, StringComparison.Ordinal);
+ Assert.Contains("s.collection_time < $2::timestamp", probeSql, StringComparison.Ordinal);
+ Assert.Contains("EXISTS", probeSql, StringComparison.Ordinal);
+
+ /* The gap fill is the only statement behind the savepoint: the batch's own upsert stays outside it. */
+ var upsert = writer.IndexOf("ProcedureStatsTotals.UpsertSql", StringComparison.Ordinal);
+ Assert.True(upsert > 0 && upsert < writer.IndexOf("private static async Task TryGapFillAsync(", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void Retention_PurgesTheTotals_AtProcedureStatsDays_NeverBeforeTheRawRows()
+ {
+ var retention = Normalize(RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "DarlingRetention.cs"));
+
+ Assert.Contains("TimeSlicedDeleteSql(ProcedureStatsTotals.Table, \"collection_time\", ProcedureStatsTotals.PrunePredicate)", retention, StringComparison.Ordinal);
+ Assert.Contains("EffectivePurgeRetentionDays(\n \"procedure_stats\",", retention, StringComparison.Ordinal);
+
+ var delete = DarlingRetention.TimeSlicedDeleteSql(ProcedureStatsTotals.Table, "collection_time", ProcedureStatsTotals.PrunePredicate);
+ Assert.StartsWith("DELETE FROM collect.procedure_stats_totals WHERE collection_time < $1 AND collection_time < COALESCE($2::timestamp, $1)", delete, StringComparison.Ordinal);
+
+ /* The totals table is a plain table outside the collector catalog: no hypertable conversion, no collector schedule entry. */
+ Assert.DoesNotContain(CollectorCatalogTables(), t => string.Equals(t, "procedure_stats_totals", StringComparison.Ordinal));
+ }
+
+ private static string[] CollectorCatalogTables() =>
+ PerformanceMonitor.Collectors.CollectorCatalog.All.Select(c => c.TargetTable).ToArray();
+
+ /* ---- the MCP duration trends' tier clock (#5638) ---- */
+
+ private static readonly DateTime Now = new(2026, 10, 12, 12, 0, 0, DateTimeKind.Utc);
+
+ [Fact]
+ public void TierClock_WithNoAsOf_IsTheWindowEndItself()
+ {
+ Assert.Equal(Now, DarlingMcpTrendTools.TierClock(null, Now, () => Now.AddMinutes(5)));
+ Assert.Equal(Now, DarlingMcpTrendTools.TierClock(" ", Now, () => Now.AddMinutes(5)));
+ }
+
+ [Fact]
+ public void TierClock_WithAnAsOf_IsOneWallClockReading_NeverTheAnchor()
+ {
+ var anchor = Now.AddDays(-30);
+ var wall = Now.AddSeconds(3);
+ var reads = 0;
+
+ var clock = DarlingMcpTrendTools.TierClock("2026-09-12T12:00:00Z", anchor, () => { reads++; return wall; });
+
+ Assert.Equal(wall, clock);
+ Assert.NotEqual(anchor, clock);
+ Assert.Equal(1, reads);
+ }
+
+ ///
+ /// The regression: a 95 h window (start exactly on the raw boundary, 96 h less the 1 h margin) with no as_of routes Raw every time
+ /// when the tier is decided against the instant the window was computed. Deciding it against a clock read a few milliseconds LATER
+ /// (the old default) flips it to the hourly tier. Both procedure and query routes.
+ ///
+ [Theory]
+ [InlineData(0)]
+ [InlineData(1)]
+ [InlineData(7)]
+ [InlineData(250)]
+ public void A95HourWindowWithNoAsOf_RoutesRawEveryTime(int millisecondsLater)
+ {
+ var windowEnd = Now;
+ var start = windowEnd.AddHours(-95);
+ var coverage = RollupCoverage.Unknown;
+
+ Assert.Equal(TimescaleSupport.RawRetentionSpan - DarlingTrendReader.RawTierMargin, TimeSpan.FromHours(95));
+
+ var pinned = DarlingMcpTrendTools.TierClock(null, windowEnd, () => windowEnd.AddMilliseconds(millisecondsLater));
+ Assert.Equal(RetentionTier.Raw, DarlingTrendReader.ResolveProcedureDurationTrendRoute(start, RollupAvailability.All, coverage, nowUtc: pinned, windowEndUtc: windowEnd).Tier);
+ Assert.Equal(RetentionTier.Raw, DarlingTrendReader.ResolveQueryDurationTrendRoute(start, RollupAvailability.All, coverage, nowUtc: pinned, windowEndUtc: windowEnd).Tier);
+
+ /* The old behaviour, for contrast: a clock read after the window flips the same window to the hourly tier. */
+ if (millisecondsLater > 0)
+ {
+ var late = windowEnd.AddMilliseconds(millisecondsLater);
+ Assert.Equal(RetentionTier.Hourly, DarlingTrendReader.ResolveProcedureDurationTrendRoute(start, RollupAvailability.All, coverage, nowUtc: late, windowEndUtc: windowEnd).Tier);
+ }
+ }
+
+ [Fact]
+ public void TheDurationTrendTools_PassTheirOwnClock_ToBothRoutes()
+ {
+ var tools = Normalize(RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpTrendTools.cs"));
+
+ Assert.Contains("ResolveQueryDurationTrendRoute(startUtc, rollups, coverage, nowUtc: TierClock(as_of, now), windowEndUtc: now)", tools, StringComparison.Ordinal);
+ Assert.Contains("ResolveProcedureDurationTrendRoute(startUtc, rollups, coverage, nowUtc: TierClock(as_of, now), windowEndUtc: now)", tools, StringComparison.Ordinal);
+ Assert.DoesNotContain("coverage, windowEndUtc: now)", tools, StringComparison.Ordinal);
+ }
+}
diff --git a/Darling/Darling.Tests/ProcedureStatsTotalsWriterLiveTests.cs b/Darling/Darling.Tests/ProcedureStatsTotalsWriterLiveTests.cs
new file mode 100644
index 0000000000..bb844b319e
--- /dev/null
+++ b/Darling/Darling.Tests/ProcedureStatsTotalsWriterLiveTests.cs
@@ -0,0 +1,1159 @@
+/*
+ * 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.Globalization;
+using System.Linq;
+using System.Reflection;
+using System.Threading;
+using System.Threading.Tasks;
+using Npgsql;
+using PerformanceMonitor.Collectors;
+using PerformanceMonitor.Darling.Service;
+using PerformanceMonitor.Darling.Storage;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// Live pins for the per-collection procedure totals (#5638, V176): the procedure_stats batch's totals are written inside
+/// the COPY's own transaction by DarlingCollectorRunner.CopyBatchOnceAsync, read back from the stored rows, so rows and
+/// totals commit together or not at all. Every batch goes through the product's own WriteBatchAsync with
+/// deltas, so the rows carry exactly the deltas and intervals a fact
+/// names (a restart collection is interval 0 everywhere). The oracle in every fact is a GROUP BY over the stored rows, the
+/// aggregate the trend's CTE takes.
+///
+/// #1776 own-store: every test here creates and drops its own scratch database through
+/// , so it is not in the live-postgres collection.
+///
+public sealed class ProcedureStatsTotalsWriterLiveTests
+{
+ private static string? BaseConnectionString => Environment.GetEnvironmentVariable("DARLING_TEST_PG");
+
+ private const string SkipReason = "Set DARLING_TEST_PG to run the procedure totals writer's live pins (each mints its own scratch database).";
+
+ private static readonly DateTime T0 = new(2026, 1, 5, 10, 0, 0, DateTimeKind.Unspecified);
+
+ private static readonly MethodInfo WriteBatchMethod = typeof(DarlingCollectorRunner)
+ .GetMethod("WriteBatchAsync", BindingFlags.NonPublic | BindingFlags.Instance)!;
+
+ private sealed class Rig : IAsyncDisposable
+ {
+ public required ScratchPostgres Scratch { get; init; }
+
+ public required NpgsqlDataSource DataSource { get; init; }
+
+ public required NpgsqlConnection Connection { get; init; }
+
+ public required DarlingCollectorRunner Runner { get; init; }
+
+ public required CollectorDeltaCalculator Deltas { get; init; }
+
+ public required ServerRuntime Server { get; init; }
+
+ public static async Task OpenAsync(int serverId, string serverName, CancellationToken ct, bool timescale = false)
+ {
+ var scratch = await ScratchPostgres.CreateAsync(BaseConnectionString!, ct);
+ var connection = new NpgsqlConnection(scratch.ConnectionString);
+ await connection.OpenAsync(ct);
+ await PgMigrations.MigrateAsync(connection, ct);
+ if (timescale)
+ {
+ /* TimescaleDB must be installed where this runs (CI's darling-pg job has it): the store's real hypertable conversion, then the
+ background jobs stopped so no policy compresses or drops a chunk under the test. */
+ Assert.True(await LiveTimescaleProbe.TryEnableAsync(scratch.ConnectionString, ct), "TimescaleDB must be available for the compressed-chunk retention pin");
+ await TimescaleSupport.ConvertToHypertablesAsync(connection, null, ct);
+ await ExecAsync(connection, "SELECT _timescaledb_functions.stop_background_workers()", ct);
+ }
+
+ var deltas = new CollectorDeltaCalculator();
+ var dataSource = NpgsqlDataSource.Create(scratch.ConnectionString);
+ return new Rig
+ {
+ Scratch = scratch,
+ DataSource = dataSource,
+ Connection = connection,
+ Deltas = deltas,
+ Runner = new DarlingCollectorRunner(dataSource, deltas),
+ Server = new ServerRuntime
+ {
+ Config = new MonitoredServer { Name = serverName, Host = serverName },
+ ConnectionString = "Server=" + serverName,
+ Target = new CollectorTargetInfo { SqlMajorVersion = 16 },
+ StorageName = serverName,
+ ServerId = serverId,
+ EngineEdition = 3,
+ },
+ };
+ }
+
+ /// Another monitored server on the same store, for the tests that need several servers' rows.
+ public ServerRuntime OtherServer(int serverId, string serverName) => new()
+ {
+ Config = new MonitoredServer { Name = serverName, Host = serverName },
+ ConnectionString = "Server=" + serverName,
+ Target = Server.Target,
+ StorageName = serverName,
+ ServerId = serverId,
+ EngineEdition = 3,
+ };
+
+ /// One batch through the product's WriteBatchAsync; a fault surfaces as the store's own exception.
+ public async Task WriteAsync(DateTime collectionTime, List rows, CancellationToken ct, ServerRuntime? server = null)
+ {
+ var target = server ?? Server;
+ var context = new CollectorContext
+ {
+ ServerId = target.ServerId,
+ ServerName = target.StorageName,
+ CollectionTime = collectionTime,
+ Deltas = Deltas,
+ Target = target.Target,
+ };
+
+ var task = (Task)WriteBatchMethod.MakeGenericMethod(typeof(ProcedureStatsCollector.Row)).Invoke(
+ Runner,
+ new object?[] { Connection, ProcedureStatsCollector.Instance, rows, target, collectionTime, context, ct })!;
+ await task;
+ return (int)task.GetType().GetProperty("Result")!.GetValue(task)!;
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await Connection.DisposeAsync();
+ await DataSource.DisposeAsync();
+ await Scratch.DisposeAsync();
+ }
+ }
+
+ /// One procedure row whose deltas are given, so the interval (0 = restart) is the fact's, not the calculator's.
+ private static ProcedureStatsCollector.Row Row(string database, string name, long elapsed, long executions, int interval) => new(
+ database, "dbo", name, "P ", T0, T0, executions, elapsed, elapsed, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0,
+ "0x" + name, "0xP" + name, null, null)
+ {
+ Precomputed = new ProcedureStatsCollector.ReadTimeDeltas(executions, elapsed, elapsed, 1, 1, 1, 0, interval),
+ };
+
+ private static async Task