diff --git a/Darling/Darling.Tests/DailyStitchLiveTests.cs b/Darling/Darling.Tests/DailyStitchLiveTests.cs
new file mode 100644
index 0000000000..bfe4705e88
--- /dev/null
+++ b/Darling/Darling.Tests/DailyStitchLiveTests.cs
@@ -0,0 +1,374 @@
+/*
+ * 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.Text.Json.Nodes;
+using System.Threading;
+using System.Threading.Tasks;
+using Npgsql;
+using PerformanceMonitor.Collectors;
+using PerformanceMonitor.Darling.Service;
+using PerformanceMonitor.Darling.Service.Mcp;
+using PerformanceMonitor.Darling.Storage;
+using PerformanceMonitor.Darling.Viewer;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #3653 (A6, lane LA-8b2): the live proof the daily-tier stitch (#4184) shipped without — one seeded
+/// server, six real calendar days, on a real TimescaleDB store, read through every daily-tier caller
+/// now serves: FinOps' DatabaseResourceUsageSqlFor,
+/// a Compose panel through and , and
+/// 's not-carried probe —
+/// plus a smoke check that all six stitched pairs (three hourly, three daily) produce SQL that actually
+/// executes. Trend/query-history reads are deliberately absent: DurationTrendRouting.ResolveTier can
+/// only return Raw or Hourly, so no trend reader ever reaches a daily FROM clause.
+///
+/* #1776 own-store: deliberately NOT [Collection("live-postgres")]. Every test here goes through
+ ScratchPostgres.CreateAsync, which reaches DARLING_TEST_PG only to CREATE and DROP its own database and then
+ works entirely inside it. It never touches the shared database's tables, so it cannot race the live
+ collection, and serializing it would be pure slowdown. Leave it out; this comment is here so the next sweep
+ does not "fix" it. */
+public sealed class DailyStitchLiveTests
+{
+ /// Distinctive fake id — a real server_id is a storage-name hash, never in this range.
+ private const int ServerId = -936553;
+ private const string ServerName = "a6-la8b2-daily-stitch";
+ private const string Db = "StitchDb";
+
+ /// Fixed anchor date (never wall clock, per #3653 A6 ruling): a Monday, chosen only so the six
+ /// calendar days it seeds are unambiguous. Every seeded timestamp below is already whole-second (#4155).
+ private static readonly DateTime D0 = new(2026, 1, 5, 0, 0, 0, DateTimeKind.Unspecified);
+
+ [Fact]
+ public async Task DailyTierReads_StitchAtF_d_AcrossFinOps_Compose_AndDailySummary_WithANotCarriedHole()
+ {
+ 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 A6 daily-stitch 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 A6 daily-stitch test needs TimescaleDB.");
+ await TimescaleSupport.ConvertToHypertablesAsync(connection, null, ct);
+ /* #3653 A6 lane LB-6 / SuccessorDailyLiveTests: the worker's real start order converts collection_log
+ to a hypertable BEFORE the ensure sweep; collection_health_hourly's CREATE selects FROM it, so
+ skipping this on the rig-rule store rejects the ensure sweep with 0A000. */
+ Assert.True(await TimescaleSupport.EnsureCollectionLogHypertableAsync(connection, null, ct));
+
+ /* Stop the scratch database's own scheduler before the ensure sweep creates refresh policies (#3986):
+ this test deliberately leaves the successor daily unrefreshed for one day in the middle of its own
+ refreshed span, and an auto-fired policy run (no initial_start) would refresh it out from under the
+ seed before the assertions run. */
+ 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 bodySucceeded = false;
+ try
+ {
+ /* Six whole UTC days, D0..D5. Each day gets three query_stats rows: two ordinary ones
+ (QH1, QH2 — sample_interval_seconds = 3600, admitted by every successor's WHERE) and one
+ "restart" row (RESTARTQ — sample_interval_seconds = 0, admitted only by the LEGACY, since every
+ interval-honest successor's CREATE carries "sample_interval_seconds IS DISTINCT FROM 0"). RESTARTQ
+ is the query_hash that appears ONLY in such rows, so every day's legacy total, successor total
+ and unique-query count differ by exactly this one row. QH1's row lands on the hour (:00) so the
+ successor hourly's materialized floor, once refreshed from D3, is exactly D3 00:00 — day-aligned,
+ which keeps F_d pinned at D3 rather than sliding to D4 through StitchedRelationSql's ceilDay rule. */
+ async Task PlantDayAsync(DateTime day)
+ {
+ async Task PlantAsync(DateTime at, string hash, long workerUs, long executions, int intervalSeconds)
+ {
+ await using var insert = new NpgsqlCommand(@"
+INSERT INTO collect.query_stats
+ (collection_id, collection_time, server_id, server_name, database_name, query_hash, sql_handle,
+ delta_worker_time, delta_elapsed_time, delta_execution_count, sample_interval_seconds)
+VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8, $9, $10)", 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(hash);
+ insert.Parameters.AddWithValue("0x" + hash);
+ insert.Parameters.AddWithValue(workerUs);
+ insert.Parameters.AddWithValue(executions);
+ insert.Parameters.AddWithValue(intervalSeconds);
+ await insert.ExecuteNonQueryAsync(ct);
+ }
+
+ await PlantAsync(day, "A6DAILYQ1", 1000, 10, 3600);
+ await PlantAsync(day.AddHours(1).AddMinutes(5), "A6DAILYQ2", 2000, 20, 3600);
+ await PlantAsync(day.AddHours(2).AddMinutes(5), "A6DAILYRESTART", 7000, 1, 0);
+ }
+
+ for (var i = 0; i < 6; i++)
+ {
+ await PlantDayAsync(D0.AddDays(i));
+ }
+
+ var d6 = D0.AddDays(6);
+ var d3 = D0.AddDays(3);
+ var d4 = D0.AddDays(4);
+ var d5 = D0.AddDays(5);
+
+ /* Legacy (query-grain and db-grain): refreshed for all six days, both tiers — the frozen trio's
+ steady state. */
+ foreach (var view in new[] { TimescaleSupport.QueryStatsHourlyView, TimescaleSupport.QueryStatsDbHourlyView })
+ {
+ await RefreshAsync(connection, view, D0, d6, ct);
+ }
+ foreach (var view in new[] { TimescaleSupport.QueryStatsDailyView, TimescaleSupport.QueryStatsDbDailyView })
+ {
+ await RefreshAsync(connection, view, D0, d6, ct);
+ }
+
+ /* Successor hourlies: refreshed from D3 onward for both families, so the query-grain successor's
+ floor lands exactly on D3 00:00 (F_d's ceilDay operand). */
+ foreach (var view in new[] { TimescaleSupport.QueryStatsIntervalHourlyView, TimescaleSupport.QueryStatsDbIntervalHourlyView })
+ {
+ await RefreshAsync(connection, view, d3, d6, ct);
+ }
+
+ /* Successor dailies. The DB-grain pair (FinOps' own source) is refreshed cleanly across D3-D5, no
+ hole, so the FinOps window total is an exact, gap-free sum. The QUERY-grain pair (Compose's and
+ the daily summary's source) is refreshed for D3 and D5 but DELIBERATELY NOT D4: D4 is below the
+ successor's last materialized day (D5), so its hole registers as "not carried at this tier" (a
+ NULL unique_queries) rather than "not reached yet" (which reads from raw instead). */
+ await RefreshAsync(connection, TimescaleSupport.QueryStatsDbIntervalDailyView, d3, d6, ct);
+ await RefreshAsync(connection, TimescaleSupport.QueryStatsIntervalDailyView, d3, d4, ct);
+ await RefreshAsync(connection, TimescaleSupport.QueryStatsIntervalDailyView, d5, d6, ct);
+
+ await using var dataSource = NpgsqlDataSource.Create(scratch.ConnectionString);
+ var rollups = await TimescaleSupport.DetectRollupsAsync(dataSource, ct);
+ Assert.True(rollups.QueryGrainIntervalDaily);
+ Assert.True(rollups.DbGrainIntervalDaily);
+ var coverage = await TimescaleSupport.DetectRollupCoverageAsync(dataSource, rollups, ct);
+
+ /* F_d, read back off the product's own boundary function: exactly D3, inside the [D0, D6) window. */
+ var fd = coverage.StitchFloor(TimescaleSupport.QueryStatsDailyView, RollupCoverage.StitchTier.Daily, D0);
+ Assert.Equal(d3, fd);
+
+ /* ── FinOps: DatabaseResourceUsageSqlFor at the daily tier, over the whole window ──
+ Window total = legacy (D0, D1, D2: 10,000 each) + successor (D3, D4, D5: 3,000 each, no hole on
+ this pair) = 39,000 worker-time units / 1,000 = 39.0 ms; 93 + 90 = 183 executions. Neither
+ all-legacy (60.0 ms / 186 executions) nor all-successor (18.0 ms / 180 executions). */
+ var finOpsSql = ViewerDataService.DatabaseResourceUsageSqlFor(RetentionTier.Daily, coverage, D0);
+ await using (var finOps = new NpgsqlCommand(finOpsSql, connection))
+ {
+ finOps.Parameters.AddWithValue(ServerId);
+ finOps.Parameters.AddWithValue(D0);
+ await using var reader = await finOps.ExecuteReaderAsync(ct);
+ Assert.True(await reader.ReadAsync(ct), "FinOps database-resource read returned no row for the seeded database");
+ Assert.Equal(Db, reader.GetString(0));
+ Assert.Equal(39.0, Convert.ToDouble(reader.GetValue(1)), 3);
+ Assert.Equal(183L, Convert.ToInt64(reader.GetValue(5)));
+ Assert.False(await reader.ReadAsync(ct), "only one database was seeded");
+ }
+
+ /* ── Compose: a daily-tier panel through ComposeSourceRouter + ComposeCompiler ──
+ One point per day the stitched relation actually holds a row for: D0-D2 (legacy), D3 and D5
+ (successor) — five points, D4 absent (the successor daily has no row there, and Compose has no
+ not-carried concept; that hole is what the daily summary's own probe exists to describe). */
+ var json = JsonNode.Parse(
+ "{\"source\":\"query_stats\",\"measure\":\"query_worker_us\",\"aggregate\":\"sum\",\"timeBucket\":\"day\",\"viz\":\"line\"}")!;
+ var (plan, parseError) = ComposeSpec.TryParsePanel((JsonObject)json, Array.Empty());
+ Assert.True(parseError is null, parseError);
+
+ var context = new ComposeRunContext(
+ new[] { ServerName }, D0, d6, ComposeRunContext.NoVariables, rollups, D0.AddDays(400), coverage);
+
+ var (compiled, compileError) = ComposeCompiler.Compile(plan!, context);
+ Assert.True(compileError is null, compileError);
+ Assert.NotNull(compiled);
+ Assert.Contains("UNION ALL", compiled!.Sql, StringComparison.Ordinal);
+ Assert.Contains($"FROM collect.{TimescaleSupport.QueryStatsDailyView}", compiled.Sql, StringComparison.Ordinal);
+ Assert.Contains($"FROM collect.{TimescaleSupport.QueryStatsIntervalDailyView}", compiled.Sql, StringComparison.Ordinal);
+
+ var totalsByDay = new Dictionary();
+ await using (var composeCommand = new NpgsqlCommand(compiled.Sql, connection))
+ {
+ foreach (var p in compiled.Parameters)
+ {
+ composeCommand.Parameters.Add(p);
+ }
+
+ await using var composeReader = await composeCommand.ExecuteReaderAsync(ct);
+ while (await composeReader.ReadAsync(ct))
+ {
+ totalsByDay[composeReader.GetDateTime(0)] = Convert.ToDouble(composeReader.GetValue(1));
+ }
+ }
+
+ Assert.Equal(5, totalsByDay.Count);
+ Assert.False(totalsByDay.ContainsKey(d4), "D4's successor daily was deliberately left unrefreshed");
+ var scale = totalsByDay[D0];
+ Assert.True(scale > 0, "D0 must have carried a nonzero total");
+ Assert.Equal(scale, totalsByDay[D0.AddDays(1)], 3);
+ Assert.Equal(scale, totalsByDay[D0.AddDays(2)], 3);
+ Assert.Equal(0.3 * scale, totalsByDay[d3], 3);
+ Assert.Equal(0.3 * scale, totalsByDay[d5], 3);
+
+ /* ── Daily summary: RangeSqlFor(Daily, coverage, windowStart), the not-carried probe ──
+ unique_queries: legacy's 3 (QH1, QH2, RESTARTQ) below F_d, successor's 2 (QH1, QH2 — RESTARTQ
+ filtered out) at/after F_d, one row per day, no gap, no overlap; D4 reads NULL (not carried —
+ the successor-side probe's source is the successor HOURLY, which IS refreshed for D4). */
+ var dailySummarySql = DailySummarySql.RangeSqlFor(RetentionTier.Daily, coverage, D0);
+ var dailySummaryRows = await ReadDailySummaryAsync(connection, dailySummarySql, ServerId, D0, d6, ct);
+
+ Assert.Equal(6, dailySummaryRows.Count);
+ Assert.Equal(new[] { D0, D0.AddDays(1), D0.AddDays(2), d3, d4, d5 }, dailySummaryRows.Select(r => r.Day).OrderBy(d => d).ToArray());
+ var byDay = dailySummaryRows.ToDictionary(r => r.Day, r => r.Unique);
+ Assert.Equal(3L, byDay[D0]);
+ Assert.Equal(3L, byDay[D0.AddDays(1)]);
+ Assert.Equal(3L, byDay[D0.AddDays(2)]);
+ Assert.Equal(2L, byDay[d3]);
+ Assert.Null(byDay[d4]);
+ Assert.Equal(2L, byDay[d5]);
+
+ /* ── Red first (#3653 A6): re-probe the SAME store with RollupAvailability.WithoutIntervalDailies —
+ the product's own probe, forced back to the pre-A6-daily shape. With no successor daily
+ available, StitchFloor answers null and the whole window reads the legacy alone: D3 goes back to
+ 3 (not 2) and D4 — which the real coverage reads NULL — goes back to 3 as well (not carried
+ never triggers over an unstitched legacy that actually holds every day). This is the assertion
+ that would have failed before this PR wired the daily stitch up; it is kept green here, pinned
+ against the OLD coverage rather than reverted product code, because the shape it protects (a
+ store whose service predates the successor dailies) still has to degrade this exact way. */
+ var legacyOnlyCoverage = await TimescaleSupport.DetectRollupCoverageAsync(dataSource, RollupAvailability.WithoutIntervalDailies, ct);
+ Assert.Null(legacyOnlyCoverage.StitchFloor(TimescaleSupport.QueryStatsDailyView, RollupCoverage.StitchTier.Daily, D0));
+ var legacyOnlySql = DailySummarySql.RangeSqlFor(RetentionTier.Daily, legacyOnlyCoverage, D0);
+ var legacyOnlyRows = await ReadDailySummaryAsync(connection, legacyOnlySql, ServerId, D0, d6, ct);
+ var legacyOnlyByDay = legacyOnlyRows.ToDictionary(r => r.Day, r => r.Unique);
+ Assert.Equal(6, legacyOnlyRows.Count);
+ Assert.Equal(3L, legacyOnlyByDay[d3]);
+ Assert.Equal(3L, legacyOnlyByDay[d4]);
+
+ bodySucceeded = true;
+ }
+ finally
+ {
+ await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, async (cleanup, cleanupCt) =>
+ {
+ /* Confirms nothing woke the scratch database's scheduler mid-test and refreshed the successor
+ daily's deliberate D4 hole out from under the seed (#3986); only asserted when the body
+ succeeded, per LiveStoreCleanup's own discipline (#1794) — asserting here on a failed body
+ would replace the real failure with this one. Nothing else needs restoring: the scratch
+ database is dropped whole, below. */
+ 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);
+ });
+ }
+ }
+
+ ///
+ /// All six stitch pairs (#3653 A6 design v2 §2) — the three
+ /// plus the three — produce a
+ /// that actually executes against the relations the ensure
+ /// sweep creates. A hand-built (fixed floors, )
+ /// forces every pair into its genuine UNION ALL stitch form rather than a single-relation degrade, so this
+ /// proves each pair's s_stitchColumnsByLegacy list is a real subset of both sides' columns — no seeded
+ /// rows needed, since LIMIT 0 never reads one.
+ ///
+ [Fact]
+ public async Task AllSixStitchPairs_SelectStarLimit0_SucceedsOnAForcedStitch()
+ {
+ 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 six-pairs stitch smoke 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 six-pairs stitch smoke test needs TimescaleDB.");
+ await TimescaleSupport.ConvertToHypertablesAsync(connection, null, ct);
+ Assert.True(await TimescaleSupport.EnsureCollectionLogHypertableAsync(connection, null, ct));
+ await TimescaleSupport.EnsureContinuousAggregatesAsync(connection, null, ct);
+
+ var legacyFloor = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Unspecified);
+ var successorDailyFloor = legacyFloor.AddDays(3);
+ var farWindowStart = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Unspecified);
+
+ var floors = new Dictionary(StringComparer.Ordinal);
+ foreach (var (legacy, successor, _) in TimescaleSupport.SupersededHourlyRollups)
+ {
+ floors[legacy] = legacyFloor;
+ floors[successor] = legacyFloor;
+ }
+ foreach (var (legacyDaily, successorDaily, successorHourly) in TimescaleSupport.SupersededDailyRollups)
+ {
+ floors[legacyDaily] = legacyFloor;
+ floors[successorDaily] = successorDailyFloor;
+ floors[successorHourly] = legacyFloor;
+ }
+
+ var coverage = new RollupCoverage(floors, new Dictionary(StringComparer.Ordinal), RollupAvailability.All);
+
+ foreach (var (legacy, _, _) in TimescaleSupport.SupersededHourlyRollups)
+ {
+ var sql = coverage.StitchedRelationSql(legacy, "a", farWindowStart, RollupCoverage.StitchTier.Hourly);
+ Assert.Contains("UNION ALL", sql, StringComparison.Ordinal);
+ await using var command = new NpgsqlCommand($"SELECT * FROM {sql} LIMIT 0", connection);
+ await using var reader = await command.ExecuteReaderAsync(ct);
+ Assert.False(await reader.ReadAsync(ct));
+ }
+
+ foreach (var (legacyDaily, _, _) in TimescaleSupport.SupersededDailyRollups)
+ {
+ var sql = coverage.StitchedRelationSql(legacyDaily, "a", farWindowStart, RollupCoverage.StitchTier.Daily);
+ Assert.Contains("UNION ALL", sql, StringComparison.Ordinal);
+ await using var command = new NpgsqlCommand($"SELECT * FROM {sql} LIMIT 0", connection);
+ await using var reader = await command.ExecuteReaderAsync(ct);
+ Assert.False(await reader.ReadAsync(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);
+ }
+
+ private static async Task> ReadDailySummaryAsync(
+ NpgsqlConnection connection, string sql, int serverId, DateTime start, DateTime end, CancellationToken ct)
+ {
+ await using var read = new NpgsqlCommand(sql, connection);
+ read.Parameters.Add(new NpgsqlParameter { TypedValue = serverId });
+ read.Parameters.Add(new NpgsqlParameter { TypedValue = start });
+ read.Parameters.Add(new NpgsqlParameter { TypedValue = end });
+ var rows = new List<(DateTime Day, long? Unique)>();
+ await using var reader = await read.ExecuteReaderAsync(ct);
+ while (await reader.ReadAsync(ct))
+ {
+ rows.Add((reader.GetDateTime(0), reader.IsDBNull(3) ? (long?)null : reader.GetInt64(3)));
+ }
+ return rows;
+ }
+}
diff --git a/Darling/Darling.Tests/DailySummaryStitchedRangeTests.cs b/Darling/Darling.Tests/DailySummaryStitchedRangeTests.cs
index ea76ad06b3..0ebb930cd6 100644
--- a/Darling/Darling.Tests/DailySummaryStitchedRangeTests.cs
+++ b/Darling/Darling.Tests/DailySummaryStitchedRangeTests.cs
@@ -118,11 +118,42 @@ the literals are identical. */
Assert.Equal(boundaryDay, successorFloor.Date.AddDays(1));
}
+ /* ─────────────────────────── daily tier (#3653 A6, lane LA-8) ─────────────────────────── */
+
+ private const string DailyLegacy = TimescaleSupport.QueryStatsDailyView;
+ private const string DailySuccessor = "query_stats_interval_daily";
+
[Fact]
- public void DailyTier_IgnoresTheStitch_ReadsExactlyAsTheTwoArgumentFormDoes()
+ public void DailyTier_NoSuccessorInAvailability_IsByteIdenticalToTheTwoArgumentForm()
{
+ /* Pre-LB shape: the successor daily is not in RollupAvailability at all, even with hourly floors
+ cached for the OTHER (hourly-tier) pair — the daily tier's own StitchFloor must gate on its own
+ registry/availability, never leak the hourly tier's state. */
var coverage = new RollupCoverage(
- new Dictionary(StringComparer.Ordinal) { [Legacy] = DaysAgo(80), [Successor] = DaysAgo(5) },
+ new Dictionary(StringComparer.Ordinal)
+ {
+ [DailyLegacy] = DaysAgo(200),
+ [Legacy] = DaysAgo(80),
+ [Successor] = DaysAgo(5),
+ },
+ new Dictionary(StringComparer.Ordinal),
+ RollupAvailability.WithoutIntervalDailies);
+
+ var stitched = DailySummarySql.RangeSqlFor(RetentionTier.Daily, coverage, DaysAgo(10));
+ var plain = DailySummarySql.RangeSqlFor(RetentionTier.Daily);
+
+ Assert.Equal(plain, stitched);
+ Assert.Contains("queries_ceiling AS (", stitched, StringComparison.Ordinal);
+ Assert.DoesNotContain("queries_ceiling_legacy", stitched, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void DailyTier_SuccessorEmpty_IsByteIdenticalToTheTwoArgumentForm()
+ {
+ /* The successor daily is registered and available but has never refreshed (no floor) — legacy-only,
+ same text as today. */
+ var coverage = new RollupCoverage(
+ new Dictionary(StringComparer.Ordinal) { [DailyLegacy] = DaysAgo(200) },
new Dictionary(StringComparer.Ordinal),
RollupAvailability.All);
@@ -132,6 +163,61 @@ public void DailyTier_IgnoresTheStitch_ReadsExactlyAsTheTwoArgumentFormDoes()
Assert.Equal(plain, stitched);
}
+ [Fact]
+ public void DailyTier_SuccessorReachesBeforeWindowStart_RoutesSuccessorOnly_NeverNamesTheLegacy()
+ {
+ var successorHourlyFloor = DaysAgo(90);
+ var coverage = new RollupCoverage(
+ new Dictionary(StringComparer.Ordinal)
+ {
+ [DailyLegacy] = DaysAgo(200),
+ [DailySuccessor] = DaysAgo(90),
+ [TimescaleSupport.QueryStatsIntervalHourlyView] = successorHourlyFloor,
+ },
+ new Dictionary(StringComparer.Ordinal),
+ RollupAvailability.All);
+
+ var sql = DailySummarySql.RangeSqlFor(RetentionTier.Daily, coverage, DaysAgo(10));
+
+ Assert.Contains($"FROM collect.{DailySuccessor}", sql, StringComparison.Ordinal);
+ Assert.DoesNotContain(DailyLegacy, sql, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void DailyTier_StitchedPair_SplitsTheNotCarriedProbeOnceEachSide_AtF_d()
+ {
+ /* Successor hourly's first bucket is mid-day, so F_d (ceiling-of-day) lands on the NEXT calendar day —
+ the same day-alignment rule QueriesCteForStitchedCagg documents for the hourly pair. */
+ var successorHourlyFloor = new DateTime(2026, 9, 19, 14, 0, 0, DateTimeKind.Utc);
+ var successorDailyFloor = new DateTime(2026, 9, 19, 0, 0, 0, DateTimeKind.Utc);
+ var coverage = new RollupCoverage(
+ new Dictionary(StringComparer.Ordinal)
+ {
+ [DailyLegacy] = DaysAgo(200),
+ [DailySuccessor] = successorDailyFloor,
+ [TimescaleSupport.QueryStatsIntervalHourlyView] = successorHourlyFloor,
+ },
+ new Dictionary(StringComparer.Ordinal),
+ RollupAvailability.All);
+
+ var sql = DailySummarySql.RangeSqlFor(RetentionTier.Daily, coverage, DaysAgo(60));
+
+ var boundaryDay = new DateTime(2026, 9, 20, 0, 0, 0, DateTimeKind.Unspecified);
+ var literal = $"TIMESTAMP '{boundaryDay:yyyy-MM-dd HH:mm:ss.ffffff}'";
+
+ Assert.Contains($"FROM collect.{DailyLegacy}", sql, StringComparison.Ordinal);
+ Assert.Contains($"FROM collect.{DailySuccessor}", sql, StringComparison.Ordinal);
+ Assert.Contains($"bucket >= $2 AND bucket < $3 AND bucket < {literal}", sql, StringComparison.Ordinal);
+ Assert.Contains($"bucket >= $2 AND bucket < $3 AND bucket >= {literal}", sql, StringComparison.Ordinal);
+ Assert.Equal(2, System.Text.RegularExpressions.Regex.Matches(sql, "NOT EXISTS").Count);
+ Assert.Contains("queries_ceiling_legacy", sql, StringComparison.Ordinal);
+ Assert.Contains("queries_ceiling_successor", sql, StringComparison.Ordinal);
+
+ /* The not-carried probe's source for the successor SIDE is the successor HOURLY (MaterializationHoleTargets,
+ RollupViews), not raw query_stats directly — the daily's hierarchical source. */
+ Assert.Contains($"SELECT 1 FROM collect.{TimescaleSupport.QueryStatsIntervalHourlyView} AS s", sql, StringComparison.Ordinal);
+ }
+
[Fact]
public void RawTier_IgnoresTheStitch_ReadsExactlyAsTheTwoArgumentFormDoes()
{
diff --git a/Darling/Darling.Tests/RollupCoverageRoutingTests.cs b/Darling/Darling.Tests/RollupCoverageRoutingTests.cs
index 15d87a5d25..dcfc12356d 100644
--- a/Darling/Darling.Tests/RollupCoverageRoutingTests.cs
+++ b/Darling/Darling.Tests/RollupCoverageRoutingTests.cs
@@ -476,6 +476,81 @@ public void NoReaderOutsideTheBuilder_CallsHourlyRelationForDirectly()
string.Join("\n", offenders));
}
+ ///
+ /// #3653 A6, lane LA-8: no reader outside TimescaleSupport.cs (the builder) may splice a DAILY
+ /// rollup's name directly into a FROM clause by string interpolation — $"collect.{...DailyView} AS f"
+ /// or similar — instead of going through . A bare splice
+ /// like that can never grow a stitch: it will read only the legacy daily forever, even once a successor
+ /// daily exists and is backfilled, which is exactly the shape the three FinOps splices and
+ /// ComposeSourceRouter's daily arm were in before this lane routed them.
+ ///
+ /// Matched over comment-stripped text against three regex shapes, so a splice cannot dodge the scan
+ /// merely by moving the constant reference inside a ternary or a string concatenation:
+ ///
+ /// - an interpolation hole naming a legacy daily constant ANYWHERE in its expression — so
+ /// collect.{(tier == RetentionTier.Hourly ? ... : TimescaleSupport.QueryStatsDbDailyView)} AS f is
+ /// caught, not just the bare collect.{TimescaleSupport.X} AS f shape;
+ /// - a "collect." + TimescaleSupport.<X>DailyView (or unqualified) concatenation; and
+ /// - the legacy view NAME itself written straight after collect., for a caller that already
+ /// resolved the constant into a local.
+ ///
+ ///
+ /// keeps the test project and build output out of the walk, same
+ /// carve-out as above;
+ /// TimescaleSupport.cs is excluded the same way — it is the builder, and
+ /// s_stitchColumnsByLegacy's doc comments and the registry itself necessarily say these names beside
+ /// collect. text.
+ ///
+ [Fact]
+ public void NoReaderOutsideTheBuilder_SplicesADailyViewNameBySubstitution()
+ {
+ var root = FindRepoRoot();
+
+ Assert.True(root is not null,
+ "Could not locate the repository root (walked up from the test binary looking for " +
+ "PerformanceMonitor.sln). This test scans the source tree, so it cannot run without it — fix the " +
+ "walk-up rather than skipping.");
+
+ const string constants = @"(?:QueryStatsDailyView|ProcedureStatsDailyView|QueryStatsDbDailyView)";
+ var viewNames = "(?:" + string.Join("|", TimescaleSupport.SupersededDailyRollups.Select(p => Regex.Escape(p.LegacyDaily))) + ")";
+ var patterns = new[]
+ {
+ new Regex(@"collect\.\{[^{}]*\b" + constants + @"\b[^{}]*\}", RegexOptions.CultureInvariant),
+ new Regex(@"collect\.""\s*\+\s*(?:TimescaleSupport\.)?" + constants + @"\b", RegexOptions.CultureInvariant),
+ new Regex(@"collect\." + viewNames + @"\b", RegexOptions.CultureInvariant),
+ };
+
+ var offenders = new List();
+ foreach (var file in Directory.EnumerateFiles(Path.Combine(root!, "Darling"), "*.cs", SearchOption.AllDirectories))
+ {
+ var relative = Path.GetRelativePath(root!, file);
+ if (IsExcludedFromScan(relative)
+ || string.Equals(Path.GetFileName(relative), "TimescaleSupport.cs", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ var text = StripComments(File.ReadAllText(file));
+ foreach (var pattern in patterns)
+ {
+ foreach (Match match in pattern.Matches(text))
+ {
+ var line = text.AsSpan(0, match.Index).Count('\n') + 1;
+ offenders.Add($"{relative}:{line} ({match.Value})");
+ }
+ }
+ }
+
+ Assert.True(offenders.Count == 0,
+ "These call sites splice a legacy DAILY rollup's name directly into a FROM clause by string " +
+ "interpolation instead of routing through RollupCoverage.StitchedRelationSql (#3653 A6, lane " +
+ "LA-8). A reader built this way can never read an interval-honest successor daily once one exists " +
+ "and is backfilled — it is stuck on the frozen legacy forever, invisibly. Route through " +
+ "StitchedRelationSql(legacyDaily, alias, windowStartUtc, RollupCoverage.StitchTier.Daily) instead; " +
+ "the daily view constants stay reserved for probes, logs, and the registry itself.\n\n" +
+ string.Join("\n", offenders));
+ }
+
///
/// True when a path is generated build output, or the test project's own source. Compared as whole
/// path SEGMENTS against BOTH separator characters, so the scanned set is the same wherever this runs
diff --git a/Darling/Darling.Tests/StitchedRelationSqlTests.cs b/Darling/Darling.Tests/StitchedRelationSqlTests.cs
index 13f661c159..d9d04a5027 100644
--- a/Darling/Darling.Tests/StitchedRelationSqlTests.cs
+++ b/Darling/Darling.Tests/StitchedRelationSqlTests.cs
@@ -152,9 +152,36 @@ public void EveryPair_HasAColumnListRegistered()
/* ─────────────────────────── daily tier ─────────────────────────── */
[Fact]
- public void DailyStitch_IsInertWhenSuccessorDailyIsAbsent()
+ public void DailyStitch_SuccessorAbsentFromAvailability_IsLegacyOnly_ByteIdenticalToToday()
{
- /* No successor daily registered/available at all (today's shipped shape, before LB): legacy-only. */
+ /* Pre-LB shape (RollupAvailability.WithoutIntervalDailies): the successor daily's name is not in
+ RollupAvailability at all, so Has() answers false — legacy-only, even though BOTH the successor
+ daily's and its successor hourly's floors happen to be cached (a store whose store-schema probe
+ predates LB but whose floor dictionary was seeded anyway; Has() is the authoritative gate, never the
+ floor). Both floors are seeded so that, absent the gate, StitchedRelationSql would have enough to
+ build a stitch instead of falling through some other null check — this proves the gate itself, not a
+ side effect of a missing floor. Byte-identical to today's un-stitched single-relation text
+ (#3653 A6 design v2 §2). */
+ var coverage = new RollupCoverage(
+ new Dictionary(StringComparer.Ordinal)
+ {
+ [TimescaleSupport.QueryStatsDailyView] = DaysAgo(200),
+ ["query_stats_interval_daily"] = DaysAgo(5),
+ [TimescaleSupport.QueryStatsIntervalHourlyView] = DaysAgo(5),
+ },
+ new Dictionary(StringComparer.Ordinal),
+ RollupAvailability.WithoutIntervalDailies);
+
+ var sql = coverage.StitchedRelationSql(TimescaleSupport.QueryStatsDailyView, "f", DaysAgo(10), RollupCoverage.StitchTier.Daily);
+
+ Assert.Equal($"collect.{TimescaleSupport.QueryStatsDailyView} AS f", sql);
+ }
+
+ [Fact]
+ public void DailyStitch_SuccessorPresentButEmpty_IsLegacyOnly()
+ {
+ /* The successor daily is registered and available (Has() true) but has never been refreshed or
+ backfilled — no floor. Empty never wins while the legacy holds rows (#3653 A6 design v2 §2). */
var coverage = new RollupCoverage(
new Dictionary(StringComparer.Ordinal) { [TimescaleSupport.QueryStatsDailyView] = DaysAgo(200) },
new Dictionary(StringComparer.Ordinal),
diff --git a/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeSourceRouter.cs b/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeSourceRouter.cs
index f6f045842f..9a29ad3542 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeSourceRouter.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeSourceRouter.cs
@@ -335,8 +335,10 @@ a guard that stays honest. Do not "simplify" it back. */
into the window as the legacy does, the legacy otherwise. Same dimensions, same column names on both,
so the compiled SQL and ComposeCaggValueMapper are identical either way — the #1849 arrangement, one
relation name swapped. The Query Store family has no successor here and comes back unchanged. The
- daily tier is untouched: the dailies are hierarchical from the LEGACY hourly and have no successor of
- their own yet (TimescaleSupport.SupersededHourlyRollups states why). */
+ daily tier's RELATION is chosen the same way as the hourly's, below (#3653 A6): CaggRelation is still
+ the by-name answer for probes/logs/registry lookups, and CaggFromClause may stitch the legacy daily to
+ its own interval-honest successor daily at F_d — the tier CHOICE itself stays on the legacy pair
+ (coverage.For above), only the RELATION within the chosen tier can be a stitch. */
return tier switch
{
RetentionTier.Raw => (ComposeRoute.Raw, null),
@@ -350,7 +352,14 @@ compiler appended " AS f" itself. */
coverage.HourlyRelationNameFor(hourlyView, windowStartUtc),
coverage.StitchedRelationSql(hourlyView, ComposeRoute.FactAlias, windowStartUtc, RollupCoverage.StitchTier.Hourly)),
tierCoverage.HourlyFloorUtc),
- _ => (new ComposeRoute(ComposeSourceTier.Daily, dailyView!), tierCoverage.DailyFloorUtc),
+ /* #3653 A6: same shape as the hourly arm above, but StitchTier.Daily — the successor daily's own
+ floor and its successor hourly's ceiling-of-day decide F_d (RollupCoverage.StitchedRelationSql).
+ With no successor daily this is byte-identical to "collect. AS f", same as today. */
+ _ => (new ComposeRoute(
+ ComposeSourceTier.Daily,
+ dailyView!,
+ coverage.StitchedRelationSql(dailyView!, ComposeRoute.FactAlias, windowStartUtc, RollupCoverage.StitchTier.Daily)),
+ tierCoverage.DailyFloorUtc),
};
}
}
diff --git a/Darling/PerformanceMonitor.Darling.Storage/DailySummarySql.cs b/Darling/PerformanceMonitor.Darling.Storage/DailySummarySql.cs
index 44897fb314..f2c3f3dfc8 100644
--- a/Darling/PerformanceMonitor.Darling.Storage/DailySummarySql.cs
+++ b/Darling/PerformanceMonitor.Darling.Storage/DailySummarySql.cs
@@ -545,10 +545,13 @@ public static string RangeSqlFor(RetentionTier tier)
/// is query_stats_hourly or its interval-honest successor
/// query_stats_interval_hourly, by over the window's
/// start. The successor carries the same query_hash and bucket the queries CTE reads, and
- /// leaves out the restart row the legacy counted as a query seen that day. The daily tier is not
- /// parameterised: the daily has no successor (it is hierarchical from the legacy hourly —
- /// ). The one-argument form keeps the legacy for
- /// callers that have not probed, which is what every pre-#3653 pin reads.
+ /// leaves out the restart row the legacy counted as a query seen that day. This overload's OWN daily tier is
+ /// not parameterised — it always reads the legacy query_stats_daily unstitched, whatever
+ /// carries; the daily's own stitch lives in the three-argument overload
+ /// below, which does not call this one for that case at all — it builds its own queries CTE directly off
+ /// , so a successor-only daily keeps its own name instead of being downgraded
+ /// back to the legacy here. The one-argument form keeps the legacy for callers that have not probed, which
+ /// is what every pre-#3653 pin reads.
///
/// #3653 A6: the routed text differs between the legacy and the successor in the relation name AND in
/// the not-carried probe's source filter — the successor's own WHERE, read off its CREATE — so the
@@ -582,42 +585,61 @@ public static string RangeSqlFor(RetentionTier tier, string hourlyRelation)
}
///
- /// #3653 A6, lane LA-3b2: , stitch-aware. When
- /// reports a stitch boundary for the legacy hourly rollup over
- /// (), the not-carried probe runs
- /// TWICE, once per relation name split at that boundary (decision 3); with no boundary — no successor, an
+ /// #3653 A6, lane LA-3b2 (hourly) / lane LA-8 (daily): ,
+ /// stitch-aware, on EITHER rollup tier. When reports a stitch boundary for the
+ /// tier's own legacy rollup over (
+ /// — F for hourly, F_d for daily), the not-carried probe runs TWICE, once per relation name split at that
+ /// boundary (decision 3, ); with no boundary — no successor, an
/// empty one, or one that already covers the whole window — this is byte-identical to
/// over the legacy name, because StitchFloor answers
- /// null in exactly the cases would also answer legacy. The
- /// daily tier ignores the stitch (LB's daily pair registry is a separate lane) and reads exactly as
- /// does today.
+ /// null in exactly the cases would also answer legacy-only.
+ /// The daily tier's successor name comes off , the same
+ /// registry reads, rather than a second lookup that could
+ /// drift from it.
///
public static string RangeSqlFor(RetentionTier tier, RollupCoverage coverage, DateTime windowStartUtc)
{
ArgumentNullException.ThrowIfNull(coverage);
- if (tier != RetentionTier.Hourly)
+ if (tier != RetentionTier.Hourly && tier != RetentionTier.Daily)
{
return RangeSqlFor(tier);
}
- var legacy = TimescaleSupport.QueryStatsHourlyView;
- var successorFloor = coverage.StitchFloor(legacy, RollupCoverage.StitchTier.Hourly, windowStartUtc);
+ var stitchTier = tier == RetentionTier.Hourly ? RollupCoverage.StitchTier.Hourly : RollupCoverage.StitchTier.Daily;
+ var legacy = tier == RetentionTier.Hourly ? TimescaleSupport.QueryStatsHourlyView : TimescaleSupport.QueryStatsDailyView;
+
+ var successorFloor = coverage.StitchFloor(legacy, stitchTier, windowStartUtc);
if (successorFloor is null)
{
/* #3653 A6, lane LA-6 (coordinator ruling on #4182): this branch already runs only when
- StitchFloor answered null, which per StitchedRelationSql's own remarks is exactly the case where
- it would splice "collect.{legacy} AS f" — legacy-only, the SAME name HourlyRelationFor would
- have answered here. Reading the name back off the builder's own splice (rather than calling
- HourlyRelationFor directly) keeps every SQL splice on the one path (decision 1) with no change
- to the byte-identical pins: the two calls agree on this branch by construction. */
- var legacyOnlySplice = coverage.StitchedRelationSql(legacy, "f", windowStartUtc, RollupCoverage.StitchTier.Hourly);
+ StitchFloor answered null, which per StitchedRelationSql's own remarks means it would splice a
+ SINGLE relation — "collect.{legacy} AS f" (no successor, empty successor) or
+ "collect.{successor} AS f" (successor covers the whole window) — never a stitch. Reading the name
+ back off the builder's own splice (rather than re-deriving "which one won" here) keeps every SQL
+ splice on the one path (decision 1): this call and StitchedRelationSql agree by construction.
+ Built with QueriesCteForCagg directly (not the two-argument overload above, whose OWN daily
+ branch always names the legacy — it exists for callers with no coverage to probe a successor
+ with) so a successor-only daily answer is not silently downgraded back to the legacy. */
+ var singleSplice = coverage.StitchedRelationSql(legacy, "f", windowStartUtc, stitchTier);
var relationStart = "collect.".Length;
- var relationEnd = legacyOnlySplice.IndexOf(" AS ", relationStart, StringComparison.Ordinal);
- return RangeSqlFor(tier, legacyOnlySplice[relationStart..relationEnd]);
+ var relationEnd = singleSplice.IndexOf(" AS ", relationStart, StringComparison.Ordinal);
+ var relationName = singleSplice[relationStart..relationEnd];
+
+ var singleRouted = RangeSql.Replace(
+ QueriesCteRaw, QueriesCteForCagg(relationName), StringComparison.Ordinal);
+ if (string.Equals(singleRouted, RangeSql, StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException(
+ "Daily-summary CAGG routing found no queries CTE to replace — QueriesCteRaw has drifted from RangeSql (#1661).");
+ }
+
+ return singleRouted;
}
- var successor = TimescaleSupport.SuccessorOf(legacy)!;
+ var successor = tier == RetentionTier.Hourly
+ ? TimescaleSupport.SuccessorOf(legacy)!
+ : TimescaleSupport.SupersededDailyRollups.First(p => string.Equals(p.LegacyDaily, legacy, StringComparison.Ordinal)).SuccessorDaily;
var routed = RangeSql.Replace(
QueriesCteRaw, QueriesCteForStitchedCagg(legacy, successor, successorFloor.Value), StringComparison.Ordinal);
diff --git a/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs b/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs
index b0d6c1bfa8..ee07a4a0dd 100644
--- a/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs
+++ b/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs
@@ -11450,20 +11450,6 @@ public TierCoverage For(string hourlyView, string? dailyView)
/// for .
public enum StitchTier { Hourly, Daily }
- ///
- /// LA's local stub of the daily-pair registry LB owns (#3653 A6 design v2 §2): each superseded LEGACY daily,
- /// its interval-honest successor daily, and the successor HOURLY it is hierarchical from (needed for F_d).
- /// LB replaces this with TimescaleSupport.SupersededDailyRollups. Until then, on any store built
- /// before LB ships, the successor daily name is absent from and
- /// answers legacy-only — inert, as designed.
- ///
- private static readonly (string LegacyDaily, string SuccessorDaily, string SuccessorHourly)[] s_supersededDailyRollupsStub =
- {
- (TimescaleSupport.QueryStatsDailyView, "query_stats_interval_daily", TimescaleSupport.QueryStatsIntervalHourlyView),
- (TimescaleSupport.ProcedureStatsDailyView, "procedure_stats_interval_daily", TimescaleSupport.ProcedureStatsIntervalHourlyView),
- (TimescaleSupport.QueryStatsDbDailyView, "query_stats_db_interval_daily", TimescaleSupport.QueryStatsDbIntervalHourlyView),
- };
-
/// Each stitched pair's explicit column list (#3653 A6 design v2 §2): the LEGACY's own columns,
/// with no sample_interval_seconds_sum — no reader selects that column, and the legacy side of the
/// UNION does not carry it. Pinned as a subset of both sides' CREATE text by
@@ -11576,16 +11562,17 @@ public string StitchedRelationSql(string legacy, string alias, DateTime windowSt
}
else
{
- /* Daily names are LB's registry (SupersededDailyRollups); this is LA's local stub, and
- RollupAvailability has no field for them yet. "Does the store have the relation" therefore
- collapses to "did the probe report a floor for it" — the SAME null-floor test that "empty"
- already uses below, which is exactly the design's daily rule: absent or empty is legacy-only,
- either way (lane-3653-A6-LA.md). */
- var pair = s_supersededDailyRollupsStub.FirstOrDefault(p => string.Equals(p.LegacyDaily, legacy, StringComparison.Ordinal));
+ /* Daily names are LB's registry (SupersededDailyRollups, #4181), and RollupAvailability now maps
+ them (Has), so the daily branch gates on availability exactly like the hourly branch above — an
+ absent successor daily must NEVER be named in SQL, even when a stale floor happens to be cached
+ for it (a store whose probe ran before the successor daily was created, then the successor
+ daily's name got reused — belt and suspenders, since Has() is the authoritative "does this store
+ have the relation" answer, not the floor dictionary). */
+ var pair = TimescaleSupport.SupersededDailyRollups.FirstOrDefault(p => string.Equals(p.LegacyDaily, legacy, StringComparison.Ordinal));
successor = pair.SuccessorDaily;
successorHourly = pair.SuccessorHourly;
- if (successor is null)
+ if (successor is null || !_availability.Has(successor))
{
return legacyOnly;
}
@@ -11676,10 +11663,10 @@ either way (lane-3653-A6-LA.md). */
}
else
{
- var pair = s_supersededDailyRollupsStub.FirstOrDefault(p => string.Equals(p.LegacyDaily, legacy, StringComparison.Ordinal));
+ var pair = TimescaleSupport.SupersededDailyRollups.FirstOrDefault(p => string.Equals(p.LegacyDaily, legacy, StringComparison.Ordinal));
successor = pair.SuccessorDaily;
successorHourly = pair.SuccessorHourly;
- if (successor is null)
+ if (successor is null || !_availability.Has(successor))
{
return null;
}
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Workload.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Workload.cs
index f07a72cb96..edebb9ac19 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Workload.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Workload.cs
@@ -109,24 +109,21 @@ private static string RouteOrThrow(string sql, string from, string to, string wh
}
/// Database resource usage for . Raw returns the constant untouched. The
- /// one-argument form reads the legacy hourly, unstitched; the callers below pass the coverage/window they
- /// resolved (#3653 A6) so a successor can be stitched in.
+ /// one-argument form reads the legacy relation for that tier, unstitched (
+ /// carries , so names
+ /// the legacy alone — the same bytes as before #3653 A6 routed this through the builder); the callers below
+ /// pass the coverage/window they resolved so a successor can be stitched in instead.
public static string DatabaseResourceUsageSqlFor(RetentionTier tier) =>
- tier == RetentionTier.Raw
- ? DatabaseResourceUsageSql
- : RouteOrThrow(
- DatabaseResourceUsageSql, WorkloadCteRaw,
- WorkloadCteForCagg($"collect.{(tier == RetentionTier.Hourly ? TimescaleSupport.QueryStatsDbHourlyView : TimescaleSupport.QueryStatsDbDailyView)} AS f"),
- "database resource usage");
+ DatabaseResourceUsageSqlFor(tier, RollupCoverage.Unknown, DateTime.MinValue);
///
- /// over the HOURLY relation
+ /// over the relation
/// resolves for (#3653 A6):
- /// stitches the legacy query_stats_db_hourly to its interval-honest successor
- /// query_stats_db_interval_hourly at the successor's floor, or names the legacy alone where there is no
- /// successor — the SAME single-relation text the one-argument overload builds, with an AS f alias the
- /// stitch needs (harmless: neither CTE qualifies a column, so the alias changes no result). The daily is not
- /// stitched because it has no successor yet ().
+ /// stitches the legacy query_stats_db_hourly/query_stats_db_daily to its interval-honest
+ /// successor at the successor's floor (F for hourly, F_d for daily), or names the legacy alone where there is
+ /// no successor — the SAME single-relation text the one-argument overload builds, with an AS f alias
+ /// the stitch needs (harmless: neither CTE qualifies a column, so the alias changes no result). The tier
+ /// CHOICE stays on the legacy pair; only the relation within the chosen tier can stitch.
///
public static string DatabaseResourceUsageSqlFor(RetentionTier tier, RollupCoverage coverage, DateTime windowStartUtc) =>
tier == RetentionTier.Raw
@@ -136,19 +133,16 @@ public static string DatabaseResourceUsageSqlFor(RetentionTier tier, RollupCover
WorkloadCteRaw,
WorkloadCteForCagg(tier == RetentionTier.Hourly
? coverage.StitchedRelationSql(TimescaleSupport.QueryStatsDbHourlyView, "f", windowStartUtc, RollupCoverage.StitchTier.Hourly)
- : $"collect.{TimescaleSupport.QueryStatsDbDailyView} AS f"),
+ : coverage.StitchedRelationSql(TimescaleSupport.QueryStatsDbDailyView, "f", windowStartUtc, RollupCoverage.StitchTier.Daily)),
"database resource usage");
- /// Top consumers (by total) for , over the legacy hourly, unstitched.
+ /// Top consumers (by total) for , over the legacy relation for that tier,
+ /// unstitched (see for why routing this through the
+ /// stitch builder with reproduces that).
public static string TopResourceConsumersByTotalSqlFor(RetentionTier tier) =>
- tier == RetentionTier.Raw
- ? TopResourceConsumersByTotalSql
- : RouteOrThrow(
- TopResourceConsumersByTotalSql, ConsumerCteRaw,
- ConsumerCteForCagg($"collect.{(tier == RetentionTier.Hourly ? TimescaleSupport.QueryStatsHourlyView : TimescaleSupport.QueryStatsDailyView)} AS f"),
- "top consumers by total");
+ TopResourceConsumersByTotalSqlFor(tier, RollupCoverage.Unknown, DateTime.MinValue);
- /// Top consumers (by total) over the stitched hourly relation (#3653 A6) — see
+ /// Top consumers (by total) over the stitched relation (#3653 A6) — see
/// .
public static string TopResourceConsumersByTotalSqlFor(RetentionTier tier, RollupCoverage coverage, DateTime windowStartUtc) =>
tier == RetentionTier.Raw
@@ -158,19 +152,16 @@ public static string TopResourceConsumersByTotalSqlFor(RetentionTier tier, Rollu
ConsumerCteRaw,
ConsumerCteForCagg(tier == RetentionTier.Hourly
? coverage.StitchedRelationSql(TimescaleSupport.QueryStatsHourlyView, "f", windowStartUtc, RollupCoverage.StitchTier.Hourly)
- : $"collect.{TimescaleSupport.QueryStatsDailyView} AS f"),
+ : coverage.StitchedRelationSql(TimescaleSupport.QueryStatsDailyView, "f", windowStartUtc, RollupCoverage.StitchTier.Daily)),
"top consumers by total");
- /// Top consumers (by average) for , over the legacy hourly, unstitched.
+ /// Top consumers (by average) for , over the legacy relation for that tier,
+ /// unstitched (see for why routing this through the
+ /// stitch builder with reproduces that).
public static string TopResourceConsumersByAvgSqlFor(RetentionTier tier) =>
- tier == RetentionTier.Raw
- ? TopResourceConsumersByAvgSql
- : RouteOrThrow(
- TopResourceConsumersByAvgSql, ConsumerCteRaw,
- ConsumerCteForCagg($"collect.{(tier == RetentionTier.Hourly ? TimescaleSupport.QueryStatsHourlyView : TimescaleSupport.QueryStatsDailyView)} AS f"),
- "top consumers by average");
+ TopResourceConsumersByAvgSqlFor(tier, RollupCoverage.Unknown, DateTime.MinValue);
- /// Top consumers (by average) over the stitched hourly relation (#3653 A6) — see
+ /// Top consumers (by average) over the stitched relation (#3653 A6) — see
/// .
public static string TopResourceConsumersByAvgSqlFor(RetentionTier tier, RollupCoverage coverage, DateTime windowStartUtc) =>
tier == RetentionTier.Raw
@@ -180,7 +171,7 @@ public static string TopResourceConsumersByAvgSqlFor(RetentionTier tier, RollupC
ConsumerCteRaw,
ConsumerCteForCagg(tier == RetentionTier.Hourly
? coverage.StitchedRelationSql(TimescaleSupport.QueryStatsHourlyView, "f", windowStartUtc, RollupCoverage.StitchTier.Hourly)
- : $"collect.{TimescaleSupport.QueryStatsDailyView} AS f"),
+ : coverage.StitchedRelationSql(TimescaleSupport.QueryStatsDailyView, "f", windowStartUtc, RollupCoverage.StitchTier.Daily)),
"top consumers by average");
public const string DatabaseResourceUsageSql = @"