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 = @"