From ce67ce750093920b13f7b369f06489df72128fc3 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:28:38 -0400 Subject: [PATCH 1/8] issue-3653 A6 lane LA-8a step 1: daily stitch reads the real registry, gated on availability StitchedRelationSql and StitchFloor's daily branches used LA's placeholder s_supersededDailyRollupsStub (name lookup only) instead of LB's real TimescaleSupport.SupersededDailyRollups (#4181), and never checked RollupAvailability.Has() for the successor daily the way the hourly branch already does. Delete the stub and route both methods through the real registry, gating on Has() as well as the floor so an absent successor daily is never named in SQL even when a stale floor happens to be cached for it. Add pure tests for the three daily states: absent from availability (even with both successor floors cached) gives legacy-only text byte-identical to today; present but empty gives legacy-only; present with a floor already gives the F_d stitch (existing test). All 12 StitchedRelationSqlTests green. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- .../Darling.Tests/StitchedRelationSqlTests.cs | 31 +++++++++++++++-- .../TimescaleSupport.cs | 33 ++++++------------- 2 files changed, 39 insertions(+), 25 deletions(-) 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.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; } From d93eca62eb331b5a7ef40d942f784a85d0d77bf1 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:30:49 -0400 Subject: [PATCH 2/8] issue-3653 A6 lane LA-8a step 2: route ComposeSourceRouter's daily arm through the stitch builder The daily tier's ResolveFamily arm only ever set ComposeRoute.CaggRelation (the by-name answer); CaggFromClause stayed null, so ComposeCompiler always fell back to "collect. AS f" even once a successor daily exists. Set CaggFromClause the same way the hourly arm already does, via RollupCoverage.StitchedRelationSql(dailyView, FactAlias, windowStartUtc, StitchTier.Daily). The tier CHOICE is untouched (still coverage.For on the legacy pair); only the relation within the chosen daily tier can stitch. With no successor daily this is byte-identical to today: 306 Compose tests green (ComposeSourceRouterTests, DarlingComposeTests). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- .../Compose/ComposeSourceRouter.cs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) 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), }; } } From b770640e53cfafe74ea25ceb91d1b50ac9f836e7 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:32:09 -0400 Subject: [PATCH 3/8] issue-3653 A6 lane LA-8a step 2: route FinOps workload's daily splices through the stitch builder DatabaseResourceUsageSqlFor/TopResourceConsumersByTotalSqlFor/ TopResourceConsumersByAvgSqlFor's coverage-aware overloads stitched the hourly branch but spliced the daily rollup by bare name ("collect. AS f"). Route the daily branch through RollupCoverage.StitchedRelationSql(..., StitchTier.Daily) the same way, so a successor daily gets read once LB backfills it. With no successor daily this is byte-identical to today: 31 tests green (ViewerFinOpsTests, RetentionTierRouterTests). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- .../ViewerDataService.FinOps.Workload.cs | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Workload.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Workload.cs index f07a72cb96..88417deacc 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Workload.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Workload.cs @@ -120,13 +120,13 @@ public static string DatabaseResourceUsageSqlFor(RetentionTier tier) => "database resource usage"); /// - /// 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,7 +136,7 @@ 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. @@ -148,7 +148,7 @@ public static string TopResourceConsumersByTotalSqlFor(RetentionTier tier) => ConsumerCteForCagg($"collect.{(tier == RetentionTier.Hourly ? TimescaleSupport.QueryStatsHourlyView : TimescaleSupport.QueryStatsDailyView)} AS f"), "top consumers by total"); - /// 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,7 +158,7 @@ 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. @@ -170,7 +170,7 @@ public static string TopResourceConsumersByAvgSqlFor(RetentionTier tier) => ConsumerCteForCagg($"collect.{(tier == RetentionTier.Hourly ? TimescaleSupport.QueryStatsHourlyView : TimescaleSupport.QueryStatsDailyView)} AS f"), "top consumers by average"); - /// 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 +180,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 = @" From f93b16674d86d5fde2f0881d69c811169f91c597 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:38:30 -0400 Subject: [PATCH 4/8] issue-3653 A6 lane LA-8a step 2: daily-summary RangeSqlFor stitches the daily tier at F_d MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DailySummarySql.RangeSqlFor(tier, coverage, windowStartUtc) only ever stitched the hourly tier; the daily tier fell through to the plain, unstitched form even once a successor daily exists. Extend it to both tiers: StitchFloor is asked on the tier's own legacy/StitchTier pair, and a genuine stitch runs QueriesCteForStitchedCagg's not-carried probe twice, split at F_d, with each side's own MaterializationHoleTargets source (the successor daily's source is the successor hourly, per RollupViews/#4181). Also fixes a latent bug the extension surfaced: the legacy-only/ successor-only branch used to redirect through the two-argument RangeSqlFor(tier, relation) overload, whose OWN daily branch always names the legacy regardless of what is passed — so a "successor already covers the window" daily answer would have been silently downgraded back to the legacy. Route through QueriesCteForCagg directly with the name the builder actually chose, for both tiers. Replace the stale DailyTier_IgnoresTheStitch pin (no longer true) with five daily-tier tests mirroring the hourly ones: absent from availability, empty, successor-only, and the F_d stitch itself. All 8 DailySummaryStitchedRangeTests green, 14 more in the surrounding classes unaffected. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- .../DailySummaryStitchedRangeTests.cs | 90 ++++++++++++++++++- .../DailySummarySql.cs | 65 +++++++++----- 2 files changed, 131 insertions(+), 24 deletions(-) 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/PerformanceMonitor.Darling.Storage/DailySummarySql.cs b/Darling/PerformanceMonitor.Darling.Storage/DailySummarySql.cs index 44897fb314..6e604ac1d7 100644 --- a/Darling/PerformanceMonitor.Darling.Storage/DailySummarySql.cs +++ b/Darling/PerformanceMonitor.Darling.Storage/DailySummarySql.cs @@ -545,10 +545,12 @@ 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 calls this one only with the legacy-only splice's name (never a daily successor). 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 +584,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 legacyOnlySplice = 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 relationName = legacyOnlySplice[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); From f17b6df1341f112c110b9600e1b45d5508fc9d2c Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:42:58 -0400 Subject: [PATCH 5/8] issue-3653 A6 lane LA-8a step 3: scan test guards against a bare daily-view splice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add NoReaderOutsideTheBuilder_SplicesADailyViewNameBySubstitution: a source scan (same carve-out rule as NoReaderOutsideTheBuilder_CallsHourlyRelationForDirectly) that fails on a literal collect.{...DailyView} interpolation outside TimescaleSupport.cs — the exact shape the three FinOps splices and ComposeSourceRouter's daily arm were in before this lane routed them, and the shape that can never grow a stitch once a successor daily exists. Proved red by reverting ViewerDataService.FinOps.Workload.cs:139 to its old bare splice: the scan caught it at the exact line, then restored. 41 RollupCoverageRoutingTests green. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- .../RollupCoverageRoutingTests.cs | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/Darling/Darling.Tests/RollupCoverageRoutingTests.cs b/Darling/Darling.Tests/RollupCoverageRoutingTests.cs index 15d87a5d25..26f78d329d 100644 --- a/Darling/Darling.Tests/RollupCoverageRoutingTests.cs +++ b/Darling/Darling.Tests/RollupCoverageRoutingTests.cs @@ -476,6 +476,78 @@ 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, on the literal collect.{TimescaleSupport.<X>DailyView} + /// substring for each of the three superseded legacy dailies (), + /// the same shape the fixed call sites used to write. 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."); + + var patterns = new[] + { + "collect.{TimescaleSupport.QueryStatsDailyView}", + "collect.{TimescaleSupport.ProcedureStatsDailyView}", + "collect.{TimescaleSupport.QueryStatsDbDailyView}", + /* The unqualified spellings, for a file that opens with "using static TimescaleSupport;" or that + already holds a local named identically via a using alias — belt and suspenders over the + qualified form above, which is what every real call site actually wrote before this lane. */ + "collect.{QueryStatsDailyView}", + "collect.{ProcedureStatsDailyView}", + "collect.{QueryStatsDbDailyView}", + }; + + 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) + { + var index = 0; + while ((index = text.IndexOf(pattern, index, StringComparison.Ordinal)) >= 0) + { + var line = text.AsSpan(0, index).Count('\n') + 1; + offenders.Add($"{relative}:{line} ({pattern})"); + index += pattern.Length; + } + } + } + + 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 From 529153a003e395bc510bc16a28d87ffac3dfe223 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:01:52 -0400 Subject: [PATCH 6/8] issue-3653 A6 lane LA-8b1 step 1+2: close the scan's ternary blind spot, route the 3 FinOps daily splices through it The daily-splice scan (NoReaderOutsideTheBuilder_SplicesADailyViewNameBySubstitution) matched only a literal collect.{TimescaleSupport.XDailyView} substring, so it missed the three FinOps one-argument overloads, which splice the same constant from inside a ternary: collect.{(tier == RetentionTier.Hourly ? ...HourlyView : ...DailyView)} AS f. Replaced the literal patterns with three regexes over the same comment-stripped text: an interpolation hole naming the constant anywhere in its expression (catches the ternary), a "collect." + constant concatenation, and the bare view name after collect. Confirmed red on exactly the three FinOps sites (FinOps.Workload.cs:119, 148, 170) before routing them. Routed DatabaseResourceUsageSqlFor(tier), TopResourceConsumersByTotalSqlFor(tier) and TopResourceConsumersByAvgSqlFor(tier) through their three-argument stitch-builder overloads with RollupCoverage.Unknown (RollupAvailability.None), which makes StitchedRelationSql answer collect. AS f for both tiers -- the same bytes the bare splice produced. Fixed their doc comments, which said the one-argument form "reads the legacy hourly" even on the daily tier. Scan now green; RetentionTierRouterTests pins (FinOpsReaders_RouteWithoutTrippingTheirDriftGuards) pass unchanged. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- .../RollupCoverageRoutingTests.cs | 45 ++++++++++--------- .../ViewerDataService.FinOps.Workload.cs | 35 ++++++--------- 2 files changed, 37 insertions(+), 43 deletions(-) diff --git a/Darling/Darling.Tests/RollupCoverageRoutingTests.cs b/Darling/Darling.Tests/RollupCoverageRoutingTests.cs index 26f78d329d..dcfc12356d 100644 --- a/Darling/Darling.Tests/RollupCoverageRoutingTests.cs +++ b/Darling/Darling.Tests/RollupCoverageRoutingTests.cs @@ -484,13 +484,22 @@ public void NoReaderOutsideTheBuilder_CallsHourlyRelationForDirectly() /// 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, on the literal collect.{TimescaleSupport.<X>DailyView} - /// substring for each of the three superseded legacy dailies (), - /// the same shape the fixed call sites used to write. 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. + /// 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() @@ -502,17 +511,13 @@ public void NoReaderOutsideTheBuilder_SplicesADailyViewNameBySubstitution() "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[] { - "collect.{TimescaleSupport.QueryStatsDailyView}", - "collect.{TimescaleSupport.ProcedureStatsDailyView}", - "collect.{TimescaleSupport.QueryStatsDbDailyView}", - /* The unqualified spellings, for a file that opens with "using static TimescaleSupport;" or that - already holds a local named identically via a using alias — belt and suspenders over the - qualified form above, which is what every real call site actually wrote before this lane. */ - "collect.{QueryStatsDailyView}", - "collect.{ProcedureStatsDailyView}", - "collect.{QueryStatsDbDailyView}", + 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(); @@ -528,12 +533,10 @@ public void NoReaderOutsideTheBuilder_SplicesADailyViewNameBySubstitution() var text = StripComments(File.ReadAllText(file)); foreach (var pattern in patterns) { - var index = 0; - while ((index = text.IndexOf(pattern, index, StringComparison.Ordinal)) >= 0) + foreach (Match match in pattern.Matches(text)) { - var line = text.AsSpan(0, index).Count('\n') + 1; - offenders.Add($"{relative}:{line} ({pattern})"); - index += pattern.Length; + var line = text.AsSpan(0, match.Index).Count('\n') + 1; + offenders.Add($"{relative}:{line} ({match.Value})"); } } } diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Workload.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Workload.cs index 88417deacc..edebb9ac19 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Workload.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Workload.cs @@ -109,15 +109,12 @@ 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 relation @@ -139,14 +136,11 @@ public static string DatabaseResourceUsageSqlFor(RetentionTier tier, RollupCover : 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 relation (#3653 A6) — see /// . @@ -161,14 +155,11 @@ public static string TopResourceConsumersByTotalSqlFor(RetentionTier tier, Rollu : 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 relation (#3653 A6) — see /// . From 8879d55f404fce0e2fb37c1152351a8a751bafd3 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:05:10 -0400 Subject: [PATCH 7/8] issue-3653 A6 lane LA-8b1 step 3: DailySummarySql doc/naming fixes RangeSqlFor(tier, hourlyRelation)'s doc comment said the three-argument overload "calls this one only with the legacy-only splice's name (never a daily successor)". Since LA-8a that's no longer true: the three-argument overload builds its own queries CTE directly off QueriesCteForCagg rather than calling this one, so a successor-only daily keeps its own name. Corrected the doc comment. Renamed the three-argument overload's local legacyOnlySplice to singleSplice: the surrounding comment already says the builder can answer either the legacy OR the successor here (a single relation, not always the legacy), so the old name was misleading. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- .../DailySummarySql.cs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Darling/PerformanceMonitor.Darling.Storage/DailySummarySql.cs b/Darling/PerformanceMonitor.Darling.Storage/DailySummarySql.cs index 6e604ac1d7..f2c3f3dfc8 100644 --- a/Darling/PerformanceMonitor.Darling.Storage/DailySummarySql.cs +++ b/Darling/PerformanceMonitor.Darling.Storage/DailySummarySql.cs @@ -548,9 +548,10 @@ public static string RangeSqlFor(RetentionTier tier) /// 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 calls this one only with the legacy-only splice's name (never a daily successor). The - /// one-argument form keeps the legacy for callers that have not probed, which is what every pre-#3653 pin - /// reads. + /// 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 @@ -620,10 +621,10 @@ splice on the one path (decision 1): this call and StitchedRelationSql agree by 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 legacyOnlySplice = coverage.StitchedRelationSql(legacy, "f", windowStartUtc, stitchTier); + var singleSplice = coverage.StitchedRelationSql(legacy, "f", windowStartUtc, stitchTier); var relationStart = "collect.".Length; - var relationEnd = legacyOnlySplice.IndexOf(" AS ", relationStart, StringComparison.Ordinal); - var relationName = 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); From de5b6fb5e6318fb3abebd8afdbf9fc316e433882 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:17:20 -0400 Subject: [PATCH 8/8] issue-3653 A6 lane LA-8b2: live proof of the daily-tier stitch across FinOps, Compose, daily summary and all six pairs New DailyStitchLiveTests.cs, own scratch TimescaleDB store. One seeded server over six fixed-anchor calendar days (D0..D5) with a per-day row the interval-honest successors exclude and the legacy counts, so legacy/successor totals and unique_queries differ every day. Legacy refreshed for all six days; successor refreshed from D3 (F_d), with D4's successor DAILY deliberately left unrefreshed (while its successor HOURLY is) to exercise the not-carried NULL path. Proves with real numbers: FinOps' DatabaseResourceUsageSqlFor at the daily tier (39.0 ms / 183 executions, neither all-legacy 60.0/186 nor all-successor 18.0/180); a Compose panel through ComposeSourceRouter + ComposeCompiler (one point per day the stitched relation holds, D4 absent); DailySummarySql's not-carried probe (3/3/3/2/NULL/2 across the six days, D4 NULL because its source is the refreshed successor hourly); and a re-probe with RollupAvailability.WithoutIntervalDailies showing the pre-A6-daily numbers (D3=3, D4=3) the real probe replaced. A second fact forces all six stitch pairs (three hourly, three daily) into their UNION ALL form with a hand-built RollupCoverage and confirms SELECT * LIMIT 0 succeeds on each. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3 --- Darling/Darling.Tests/DailyStitchLiveTests.cs | 374 ++++++++++++++++++ 1 file changed, 374 insertions(+) create mode 100644 Darling/Darling.Tests/DailyStitchLiveTests.cs 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; + } +}