From 6275f9d1b4e291107ec2bf9782e75b04d76d5a75 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Mon, 28 Sep 2026 12:53:19 -0400
Subject: [PATCH 1/5] Expose a measured materialization ceiling on
RollupCoverage
---
.../TimescaleSupport.cs | 132 +++++++++++++++++-
1 file changed, 126 insertions(+), 6 deletions(-)
diff --git a/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs b/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs
index ecdb4ae97f..f460f03aaa 100644
--- a/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs
+++ b/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs
@@ -9346,7 +9346,12 @@ private sealed class RollupFloorCache
/// the cached floor no longer describes the relation actually being read. backs
/// .
///
- internal readonly record struct RollupFloorCacheEntry(string? ChunkName, string? MaterializationHypertable, DateTime Floor, DateTime MeasuredAtUtc);
+ /// (#4605 part 2, LA-1) is the materialization ceiling measured in the SAME
+ /// cycle as — re-read whenever the floor is (the ceiling moves every refresh, so
+ /// piggybacking on the floor's re-measure cadence, rather than a separate TTL, is the cheap answer: a
+ /// view whose floor is trusted from cache almost never has a stale-enough ceiling to matter, and a
+ /// dedicated per-request re-read would be the expensive scan #4539 already exists to avoid).
+ internal readonly record struct RollupFloorCacheEntry(string? ChunkName, string? MaterializationHypertable, DateTime Floor, DateTime MeasuredAtUtc, DateTime? Ceiling = null);
/// The oldest-chunk catalog read's per-view identity, before any floor has been attached to it.
internal readonly record struct RollupChunkIdentity(string? ChunkName, string? MaterializationHypertable);
@@ -9416,6 +9421,21 @@ internal static (IReadOnlyDictionary Floors, IReadOnlyDictiona
IReadOnlyDictionary oldestNow,
RollupAvailability availability,
DateTime now)
+ => MergeRollupFloors(measuredThisCycle, cached, oldestNow, availability, now, ceilingsMeasuredThisCycle: null);
+
+ ///
+ /// #4605 part 2 (LA-1): the same merge, with carrying the
+ /// freshly-read for every view re-measured THIS cycle —
+ /// null for the two-argument overload (every pre-#4605 caller and test), which leaves every new
+ /// entry's ceiling unset (the safe "unknown" default) rather than guessing one.
+ ///
+ internal static (IReadOnlyDictionary Floors, IReadOnlyDictionary NewCacheEntries) MergeRollupFloors(
+ IReadOnlyDictionary measuredThisCycle,
+ IReadOnlyDictionary cached,
+ IReadOnlyDictionary oldestNow,
+ RollupAvailability availability,
+ DateTime now,
+ IReadOnlyDictionary? ceilingsMeasuredThisCycle)
{
var floors = new Dictionary(StringComparer.Ordinal);
var newEntries = new Dictionary(StringComparer.Ordinal);
@@ -9427,7 +9447,10 @@ internal static (IReadOnlyDictionary Floors, IReadOnlyDictiona
floors[view] = floor;
if (oldestNow.TryGetValue(view, out var identity))
{
- newEntries[view] = new RollupFloorCacheEntry(identity.ChunkName, identity.MaterializationHypertable, floor, now);
+ DateTime? ceiling = ceilingsMeasuredThisCycle is not null && ceilingsMeasuredThisCycle.TryGetValue(view, out var freshCeiling)
+ ? freshCeiling
+ : null;
+ newEntries[view] = new RollupFloorCacheEntry(identity.ChunkName, identity.MaterializationHypertable, floor, now, ceiling);
}
}
@@ -9578,7 +9601,8 @@ when this cache has no evidence either way. */
}
}
- return new RollupCoverage(floorsWithoutCache, rawOldest, availability);
+ var ceilingsWithoutCache = await MeasureRollupCeilingsAsync(dataSource, floorsWithoutCache.Keys, availability, cancellationToken);
+ return new RollupCoverage(floorsWithoutCache, rawOldest, availability, ceilingsWithoutCache);
}
var measuredThisCycle = new Dictionary(StringComparer.Ordinal);
@@ -9593,7 +9617,22 @@ when this cache has no evidence either way. */
measuredThisCycle[view] = await reader.IsDBNullAsync(i, cancellationToken) ? (DateTime?)null : reader.GetDateTime(i);
}
- var (floors, newEntries) = MergeRollupFloors(measuredThisCycle, cachedSnapshot, oldestNow, availability, now);
+ /* #4605 part 2 (LA-1): the ceiling is re-read for exactly the views re-measured this cycle (a view
+ whose floor came from cache keeps its cached ceiling below, on the same reuse cadence #4539/#4553
+ already give the floor — no per-request max(bucket)/cagg_watermark scan). */
+ var ceilingsMeasuredThisCycle = new Dictionary(StringComparer.Ordinal);
+ foreach (var view in measuredThisCycle.Keys)
+ {
+ if (measuredThisCycle[view] is null)
+ {
+ continue;
+ }
+
+ var bucketWidth = RollupViews.First(r => r.View == view).BucketWidth;
+ ceilingsMeasuredThisCycle[view] = await RollupMaterializationWatermark.GetAsync(dataSource, view, bucketWidth, JobCatalogReadTimeoutSeconds, cancellationToken);
+ }
+
+ var (floors, newEntries) = MergeRollupFloors(measuredThisCycle, cachedSnapshot, oldestNow, availability, now, ceilingsMeasuredThisCycle);
lock (cache.Lock)
{
@@ -9604,7 +9643,45 @@ when this cache has no evidence either way. */
}
}
- return new RollupCoverage(floors, rawOldest, availability);
+ var ceilings = new Dictionary(StringComparer.Ordinal);
+ foreach (var (view, entry) in newEntries)
+ {
+ if (entry.Ceiling is DateTime ceiling)
+ {
+ ceilings[view] = ceiling;
+ }
+ }
+
+ return new RollupCoverage(floors, rawOldest, availability, ceilings);
+ }
+
+ ///
+ /// #4605 part 2 (LA-1): the ceiling read for the catalog-read-failed / no-rollups-present fallback path,
+ /// where every present view was just measured for its floor and has no cache entry to carry a ceiling
+ /// forward from. One per view named in
+ /// — bounded by the same small view list the floor probe itself named, so this
+ /// is no more expensive than the floor read it accompanies.
+ ///
+ private static async Task> MeasureRollupCeilingsAsync(
+ NpgsqlDataSource dataSource, IEnumerable views, RollupAvailability availability, CancellationToken cancellationToken)
+ {
+ var ceilings = new Dictionary(StringComparer.Ordinal);
+ foreach (var view in views)
+ {
+ var match = RollupViews.FirstOrDefault(r => r.View == view);
+ if (match.View is null || !availability.Has(view))
+ {
+ continue;
+ }
+
+ var ceiling = await RollupMaterializationWatermark.GetAsync(dataSource, view, match.BucketWidth, JobCatalogReadTimeoutSeconds, cancellationToken);
+ if (ceiling is DateTime c)
+ {
+ ceilings[view] = c;
+ }
+ }
+
+ return ceilings;
}
///
@@ -13205,6 +13282,7 @@ public sealed class RollupCoverage
private readonly IReadOnlyDictionary _floorsByView;
private readonly IReadOnlyDictionary _oldestByRawTable;
private readonly RollupAvailability _availability;
+ private readonly IReadOnlyDictionary _ceilingsByView;
public RollupCoverage(
IReadOnlyDictionary floorsByView,
@@ -13225,12 +13303,53 @@ public RollupCoverage(
IReadOnlyDictionary floorsByView,
IReadOnlyDictionary oldestByRawTable,
RollupAvailability availability)
+ : this(floorsByView, oldestByRawTable, availability, new Dictionary(StringComparer.Ordinal))
+ {
+ }
+
+ ///
+ /// #4605 part 2 (LA-1): the per-view MATERIALIZATION CEILING — how far forward each rollup has actually
+ /// materialized, read from in the same probe cycle that
+ /// measures the floor () and cached the same way.
+ /// A view absent from answers null from — the
+ /// safe "unknown" reading a hybrid raw/rollup route must treat as "do not use the rollup here".
+ ///
+ public RollupCoverage(
+ IReadOnlyDictionary floorsByView,
+ IReadOnlyDictionary oldestByRawTable,
+ RollupAvailability availability,
+ IReadOnlyDictionary ceilingsByView)
{
_floorsByView = floorsByView ?? throw new ArgumentNullException(nameof(floorsByView));
_oldestByRawTable = oldestByRawTable ?? throw new ArgumentNullException(nameof(oldestByRawTable));
_availability = availability;
+ _ceilingsByView = ceilingsByView ?? throw new ArgumentNullException(nameof(ceilingsByView));
}
+ ///
+ /// #4605 part 2 (LA-1): 's MEASURED materialization ceiling — the instant AT
+ /// OR ABOVE which the rollup holds nothing ('s
+ /// engine watermark, or its max(bucket) + bucketWidth fallback) — or null when unknown (no
+ /// rollup by this name was probed, or the probe found nothing to measure). A hybrid raw/rollup route
+ /// (#4605 part 2 §2) reads the rollup ONLY for whole buckets strictly below this instant; everything at
+ /// or above it must come from raw, because nothing has materialized there yet.
+ ///
+ /// Contiguity below the ceiling is NOT guaranteed by this alone. A ceiling says how far
+ /// materialization has REACHED, not that every bucket beneath it is filled — an outage longer than
+ /// HourlyRefreshStartOffset before a refresh, or a seam below a successor's floor, can leave a hole
+ /// strictly BELOW the ceiling and above the floor
+ /// (, MaterializationHoles.cs lines 27–40,
+ /// 505–531; the #4301 seam ruling at line 74 states the property a caller needs: contiguity from the
+ /// FLOOR upward is what the repair pass (,
+ /// ) works to hold, not something this probe
+ /// measures on every read). A caller that needs a hole-free span checked, rather than assumed, calls
+ /// for that exact range — this coverage snapshot does
+ /// not attempt to answer it, because doing so per read would be the same expensive scan
+ /// already exists to avoid for the floor.
+ ///
+ public DateTime? CeilingOf(string caggView) =>
+ _ceilingsByView.TryGetValue(caggView, out var ceiling) ? ceiling : null;
+
///
/// THE HOURLY-TIER RELATION for a read whose window starts at (#3653, Q12):
/// 's interval-honest successor when it exists on this store and reaches at
@@ -13290,7 +13409,8 @@ public string HourlyRelationNameFor(string legacyHourly, DateTime windowStartUtc
public static RollupCoverage Unknown { get; } = new(
new Dictionary(StringComparer.Ordinal),
new Dictionary(StringComparer.Ordinal),
- RollupAvailability.None);
+ RollupAvailability.None,
+ new Dictionary(StringComparer.Ordinal));
/// The oldest bucket has materialized, or null when it holds nothing
/// (or was never probed). Mirrors : an unknown name answers null.
From ba6c6572ad140c6790206400e3742d554292cc45 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Mon, 28 Sep 2026 13:10:20 -0400
Subject: [PATCH 2/5] Route the Query Store trend from cached rollup coverage
instead of a fresh min/max(bucket) read
The trend router (get_query_store_duration_trend and the desktop Query
Store chart) used to run min(bucket)/max(bucket) over the corrected
hourly rollup on every call to decide the raw/rollup boundary. Both
callers already hold a RollupCoverage from the coverage probe they
share with every other reader of the store, so the resolver now reads
its floor and measured ceiling from that cache first, and falls back
to the live probe-and-read only when the coverage carries no ceiling
for the view (cold or unknown coverage).
Routing decisions and the raw-only fallback are unchanged; the ceiling
IS the same exclusive boundary the pure decision computes from
max(bucket) + bucket width, so no adjustment is needed when reading it
from the cache.
Refs #4611. Builds on the rollup-ceiling change from #4605.
---
.../QueryStoreTrendRoutingTests.cs | 86 +++++++++++++++++++
.../Mcp/DarlingMcpTrendTools.cs | 7 +-
.../QueryStoreTrendRouting.cs | 34 ++++++++
.../ViewerDataService.QueryTrends.cs | 3 +-
4 files changed, 128 insertions(+), 2 deletions(-)
diff --git a/Darling/Darling.Tests/QueryStoreTrendRoutingTests.cs b/Darling/Darling.Tests/QueryStoreTrendRoutingTests.cs
index b5cecb7e14..856a7c79a3 100644
--- a/Darling/Darling.Tests/QueryStoreTrendRoutingTests.cs
+++ b/Darling/Darling.Tests/QueryStoreTrendRoutingTests.cs
@@ -7,6 +7,7 @@
*/
using System;
+using System.Threading.Tasks;
using PerformanceMonitor.Darling.Service.Mcp;
using PerformanceMonitor.Darling.Storage;
using PerformanceMonitor.Darling.Viewer;
@@ -63,6 +64,91 @@ double count or a dropped hour. */
Assert.Equal(oldest, route.RollupFloorUtc);
}
+ /* ── #4611: the cached-coverage route ── */
+
+ ///
+ /// THE #4611 pin: with a warm coverage (a non-null ceiling for the corrected hourly), the resolver never
+ /// touches the store at all — no min(bucket)/max(bucket) statement is issued. A null
+ /// proves it directly: the coverage-taking overload returns from the
+ /// cache alone when the ceiling is present, so passing null crashes only if it tries to probe.
+ ///
+ /// RED on the pre-#4611 code: this overload does not exist there — the call is a compile
+ /// failure against the base branch, which the pre-fix code cannot serve at all: every call re-ran
+ /// regardless of what the caller already knew.
+ ///
+ [Fact]
+ public async Task ResolveAsync_WithWarmCoverage_IssuesNoProbeStatement()
+ {
+ var floor = new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Unspecified);
+ var ceiling = new DateTime(2026, 3, 4, 12, 0, 0, DateTimeKind.Unspecified);
+ var coverage = new RollupCoverage(
+ new System.Collections.Generic.Dictionary(System.StringComparer.Ordinal)
+ {
+ [TimescaleSupport.QueryStoreStatsCorrectedHourlyView] = floor,
+ },
+ new System.Collections.Generic.Dictionary(System.StringComparer.Ordinal),
+ RollupAvailability.None,
+ new System.Collections.Generic.Dictionary(System.StringComparer.Ordinal)
+ {
+ [TimescaleSupport.QueryStoreStatsCorrectedHourlyView] = ceiling,
+ });
+
+ var route = await QueryStoreTrendRouting.ResolveAsync(coverage, dataSource: null!);
+
+ Assert.True(route.UseRollup);
+ Assert.Equal(ceiling, route.RawStartUtc);
+ Assert.Equal(floor, route.RollupFloorUtc);
+ }
+
+ ///
+ /// The cached-coverage route and 's pure decision must agree
+ /// for the same bounds — the ceiling from IS the same exclusive
+ /// boundary computes as newest + bucketWidth, so
+ /// there is no separate off-by-one to apply when reading it from the cache instead of from a fresh
+ /// max(bucket) read.
+ ///
+ [Fact]
+ public async Task ResolveAsync_CachedRoute_AgreesWithThePureDecision_ForTheSameBounds()
+ {
+ var floor = new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Unspecified);
+ var newest = new DateTime(2026, 3, 4, 11, 0, 0, DateTimeKind.Unspecified);
+ var ceiling = newest.AddHours(1);
+
+ var coverage = new RollupCoverage(
+ new System.Collections.Generic.Dictionary(System.StringComparer.Ordinal)
+ {
+ [TimescaleSupport.QueryStoreStatsCorrectedHourlyView] = floor,
+ },
+ new System.Collections.Generic.Dictionary(System.StringComparer.Ordinal),
+ RollupAvailability.None,
+ new System.Collections.Generic.Dictionary(System.StringComparer.Ordinal)
+ {
+ [TimescaleSupport.QueryStoreStatsCorrectedHourlyView] = ceiling,
+ });
+
+ var cachedRoute = await QueryStoreTrendRouting.ResolveAsync(coverage, dataSource: null!);
+ var pureRoute = QueryStoreTrendRouting.Resolve(rollupExists: true, oldestBucketUtc: floor, newestBucketUtc: newest);
+
+ Assert.Equal(pureRoute.UseRollup, cachedRoute.UseRollup);
+ Assert.Equal(pureRoute.RawStartUtc, cachedRoute.RawStartUtc);
+ Assert.Equal(pureRoute.RollupFloorUtc, cachedRoute.RollupFloorUtc);
+ }
+
+ ///
+ /// #4611: a cold or unknown coverage (no ceiling for this view — never probed, absent, or nothing
+ /// materialized) is never LESS correct than before this change — the resolver falls back to the live
+ /// probe-and-read path unchanged. carries no ceilings at all, so
+ /// this exercises the fallback with a real (if unreachable) data source shape by asserting it is the
+ /// SAME overload the pre-#4611 code always called: a null coverage throws, proving the coverage is
+ /// consulted before anything else.
+ ///
+ [Fact]
+ public async Task ResolveAsync_NullCoverage_Throws()
+ {
+ await Assert.ThrowsAsync(
+ () => QueryStoreTrendRouting.ResolveAsync(coverage: null!, dataSource: null!));
+ }
+
/* ── the probes ── */
///
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs
index 85c9a8f3dd..adce2f9221 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs
@@ -656,8 +656,13 @@ as one estimator. The same route is what keeps this sibling honest about DEPTH (
raw query_store_stats is dropped at four days only once its rollups cover it (the #1680
arming gate), so wherever raw is short the rollup is the tier holding the history, and a
raw-only route means raw is complete.
+
+ #4611: the bounds come from the coverage this call already caches (ComposeStoreAvailability),
+ not a fresh min/max(bucket) read — the probe below only runs when that coverage carries no
+ ceiling for this view.
*/
- var route = await QueryStoreTrendRouting.ResolveAsync(postgres, cancellationToken);
+ var (_, coverage) = await ComposeStoreAvailability.GetRollupsAsync(postgres, cancellationToken);
+ var route = await QueryStoreTrendRouting.ResolveAsync(coverage, postgres, cancellationToken);
var points = await DarlingTrendReader.GetQueryStoreDurationTrendAsync(
postgres, resolved.ServerId, startUtc, now, route, cancellationToken);
var disclosure = DescribeQueryStoreRoute(route, points, startUtc, now);
diff --git a/Darling/PerformanceMonitor.Darling.Storage/QueryStoreTrendRouting.cs b/Darling/PerformanceMonitor.Darling.Storage/QueryStoreTrendRouting.cs
index d5e59cc740..a696eefe46 100644
--- a/Darling/PerformanceMonitor.Darling.Storage/QueryStoreTrendRouting.cs
+++ b/Darling/PerformanceMonitor.Darling.Storage/QueryStoreTrendRouting.cs
@@ -134,12 +134,46 @@ public static QueryStoreTrendRoute Resolve(bool rollupExists, DateTime? oldestBu
return new QueryStoreTrendRoute(true, newest.Add(TimescaleSupport.HourlyBucket), oldestBucketUtc);
}
+ ///
+ /// #4611: the cached-first route. Both callers already hold a — probed on a
+ /// timer and shared with every other reader of the same store — so this reads its floor and ceiling for
+ /// query_store_stats_corrected_hourly instead of running again on
+ /// every trend load. is already the exact instant
+ /// computes from max(bucket) — the boundary at or above which the rollup
+ /// serves nothing — so it becomes directly, with no bucket
+ /// adjustment: both name the same exclusive edge, one measured live, one read from the cache.
+ ///
+ /// When the coverage carries no ceiling for this view — the rollup was never probed, does not
+ /// exist, or has materialized nothing (a null floor and a null ceiling arrive together) — this falls back
+ /// to the live probe-and-read below, unchanged, so a cold or unknown coverage is never LESS correct than
+ /// before #4611.
+ ///
+ public static Task ResolveAsync(
+ RollupCoverage coverage, NpgsqlDataSource dataSource, CancellationToken cancellationToken = default)
+ {
+ if (coverage is null)
+ {
+ throw new ArgumentNullException(nameof(coverage));
+ }
+
+ if (coverage.CeilingOf(TimescaleSupport.QueryStoreStatsCorrectedHourlyView) is DateTime ceilingUtc)
+ {
+ return Task.FromResult(new QueryStoreTrendRoute(
+ true, ceilingUtc, coverage.FloorOf(TimescaleSupport.QueryStoreStatsCorrectedHourlyView)));
+ }
+
+ return ResolveAsync(dataSource, cancellationToken);
+ }
+
///
/// Probes availability () then coverage () and
/// hands the result to . Two statements by necessity, not laziness: the bounds
/// statement names the view, so it may only be issued once the probe proved the name resolves.
/// A probe failure propagates rather than degrading to raw-only — silently falling back would
/// reintroduce the #2736 timeout wearing a different error.
+ ///
+ /// #4611: this is now the FALLBACK path, kept byte-for-byte for the cold/unknown coverage case —
+ /// see the coverage-taking overload above, which every caller now calls first.
///
public static async Task ResolveAsync(
NpgsqlDataSource dataSource, CancellationToken cancellationToken = default)
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryTrends.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryTrends.cs
index 4565726076..47925968e6 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryTrends.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryTrends.cs
@@ -526,7 +526,8 @@ public async Task GetQueryStoreDurationTrendAsync(
int serverId, DateTime startUtc, DateTime endUtc, IReadOnlyList? databaseNames = null,
DateTime? literalEndUtc = null, CancellationToken cancellationToken = default)
{
- var route = await QueryStoreTrendRouting.ResolveAsync(_dataSource, cancellationToken);
+ var (_, coverage) = await GetRollupAvailabilityAsync(cancellationToken);
+ var route = await QueryStoreTrendRouting.ResolveAsync(coverage, _dataSource, cancellationToken);
List points;
if (route.UseRollup)
{
From 9f692c9f29567b3834a74c76c564e2c940e83a2f Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Mon, 28 Sep 2026 13:13:52 -0400
Subject: [PATCH 3/5] Add ceiling pins: unknown view, cache merge, and a live
TimescaleDB proof
Pure pins for RollupCoverage.CeilingOf: an unknown view answers null, the
six-argument MergeRollupFloors overload replaces a re-measured view's
cached ceiling while a view reused from cache keeps its own, and the
five-argument overload keeps leaving every new entry's ceiling unset.
A live pin against a real TimescaleDB seeds six hours of raw query_store_stats,
refreshes the hourly rollup to now minus two hours, and checks CeilingOf
against the engine's own materialization watermark. A further refresh and a
forced re-measure show the ceiling advancing; an immediate follow-up call
inside the cache's reuse window shows it holding steady.
Reworded a comment that used 'ruling' to 'decision' for plain wording.
---
.../RollupCoverageCeilingLiveTests.cs | 172 ++++++++++++++++++
.../Darling.Tests/RollupFloorCacheTests.cs | 81 +++++++++
.../TimescaleSupport.cs | 2 +-
3 files changed, 254 insertions(+), 1 deletion(-)
create mode 100644 Darling/Darling.Tests/RollupCoverageCeilingLiveTests.cs
diff --git a/Darling/Darling.Tests/RollupCoverageCeilingLiveTests.cs b/Darling/Darling.Tests/RollupCoverageCeilingLiveTests.cs
new file mode 100644
index 0000000000..f0c64811aa
--- /dev/null
+++ b/Darling/Darling.Tests/RollupCoverageCeilingLiveTests.cs
@@ -0,0 +1,172 @@
+/*
+ * 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.Threading;
+using System.Threading.Tasks;
+using Npgsql;
+using PerformanceMonitor.Darling.Storage;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #4605 part 2 (LA-1b) end-to-end against a REAL TimescaleDB:
+/// matches the engine's own materialization watermark, advances once the rollup is refreshed further, and
+/// stays put for a cycle where the floor (and therefore the ceiling) is reused from cache rather than
+/// re-read.
+///
+/// #1776 own-store — mints its own scratch database through
+/// rather than sharing the live fixture.
+///
+public sealed class RollupCoverageCeilingLiveTests
+{
+ /// Distinctive fake id — a real server_id is a storage-name hash, never this.
+ private const int TestServerId = -460560;
+
+ private const int QueriesPerBucket = 3;
+
+ [Fact]
+ public async Task DetectRollupCoverageAsync_CeilingOf_MatchesTheEngineWatermark_AndAdvancesOnRefresh_AndHoldsWhileCached()
+ {
+ 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 #4605 ceiling test (it mints its own scratch database).");
+
+ 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 LiveTimescaleProbe.TryEnableAsync(scratch.ConnectionString, ct);
+ Assert.True(timescaleEnabled, "TimescaleDB must be available on CI for the live #4605 ceiling test");
+
+ await TimescaleSupport.ConvertToHypertablesAsync(connection, null, ct);
+
+ /* No background worker racing the fixture's own refresh calls below. */
+ await using (var stop = new NpgsqlCommand("SELECT _timescaledb_functions.stop_background_workers()", connection))
+ {
+ await stop.ExecuteNonQueryAsync(ct);
+ }
+
+ var now = DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified);
+ var oldest = now.AddHours(-6);
+ var view = TimescaleSupport.QueryStoreStatsIntervalHourlyView;
+
+ /* ── 1. Plant 6 hours of raw query_store_stats and materialize the hourly rollup only up to
+ now - 2h, the way the product's own refresh cadence leaves the newest buckets unmaterialized. ── */
+ await SeedHourlyQueryStoreStatsAsync(connection, oldest, now, ct);
+ await TimescaleSupport.EnsureContinuousAggregatesAsync(connection, null, ct);
+
+ var refreshTo = now.AddHours(-2);
+ await using (var setInterval = new NpgsqlCommand(TimescaleSupport.SetMaterializationChunkIntervalSql(view), connection))
+ {
+ await setInterval.ExecuteNonQueryAsync(ct);
+ }
+
+ await using (var refresh = new NpgsqlCommand(
+ $"CALL refresh_continuous_aggregate('collect.{view}'::regclass, $1::timestamp, $2::timestamp, true)", connection))
+ {
+ refresh.Parameters.AddWithValue(oldest);
+ refresh.Parameters.AddWithValue(refreshTo);
+ await refresh.ExecuteNonQueryAsync(ct);
+ }
+
+ var rowsAfterFirstRefresh = await CountAsync(connection, $"SELECT count(*) FROM collect.{view}", ct);
+ Assert.True(rowsAfterFirstRefresh > 0, "the rollup must actually hold materialized rows or nothing below tests anything");
+
+ await using var dataSource = new NpgsqlDataSourceBuilder(scratch.ConnectionString).Build();
+ var availability = await TimescaleSupport.DetectRollupsAsync(dataSource, ct);
+ Assert.True(availability.Has(view), "the interval-honest hourly rollup should exist after the ensure sweep");
+
+ /* ── 2. The product's own watermark read, for comparison against what the coverage probe reports. ── */
+ var engineWatermark = await RollupMaterializationWatermark.GetAsync(
+ dataSource, view, TimescaleSupport.HourlyBucket, commandTimeoutSeconds: 30, ct);
+ Assert.NotNull(engineWatermark);
+
+ var firstCall = await TimescaleSupport.DetectRollupCoverageAsync(dataSource, availability, now, ct);
+ Assert.Equal(engineWatermark, firstCall.CeilingOf(view));
+
+ /* ── 3. Refresh one more hour and force a re-measure the way the #4539/#4553 cache decides one is
+ needed: the oldest chunk identity is unchanged here, but crossing the cache's TTL forces a
+ fresh read regardless — the same "identity alone is not always enough" safety net
+ RollupFloorMaxReuse documents. ── */
+ var refreshToLater = now.AddHours(-1);
+ await using (var refreshAgain = new NpgsqlCommand(
+ $"CALL refresh_continuous_aggregate('collect.{view}'::regclass, $1::timestamp, $2::timestamp, true)", connection))
+ {
+ refreshAgain.Parameters.AddWithValue(oldest);
+ refreshAgain.Parameters.AddWithValue(refreshToLater);
+ await refreshAgain.ExecuteNonQueryAsync(ct);
+ }
+
+ var laterWatermark = await RollupMaterializationWatermark.GetAsync(
+ dataSource, view, TimescaleSupport.HourlyBucket, commandTimeoutSeconds: 30, ct);
+ Assert.NotNull(laterWatermark);
+ Assert.True(laterWatermark > engineWatermark, "the second refresh must move the engine's own watermark later");
+
+ var pastTtl = now + TimescaleSupport.RollupFloorMaxReuse + TimeSpan.FromMinutes(1);
+ var secondCall = await TimescaleSupport.DetectRollupCoverageAsync(dataSource, availability, pastTtl, ct);
+ Assert.Equal(laterWatermark, secondCall.CeilingOf(view));
+ Assert.True(secondCall.CeilingOf(view) > firstCall.CeilingOf(view), "the ceiling must advance once the rollup materializes further");
+
+ /* ── 4. A cycle immediately after (well inside the TTL, same chunk identity) reuses the cached floor
+ — and therefore must answer the SAME ceiling as the call that measured it, with no fresh
+ watermark read for this view. ── */
+ var thirdCall = await TimescaleSupport.DetectRollupCoverageAsync(dataSource, availability, pastTtl + TimeSpan.FromSeconds(1), ct);
+ Assert.Equal(secondCall.CeilingOf(view), thirdCall.CeilingOf(view));
+ }
+
+ private static async Task SeedHourlyQueryStoreStatsAsync(
+ NpgsqlConnection connection, DateTime from, DateTime to, CancellationToken cancellationToken)
+ {
+ await using var insert = new NpgsqlCommand(@"
+INSERT INTO collect.query_store_stats
+ (collection_id, collection_time, server_id, server_name, database_name, module_name, query_hash,
+ query_id, plan_id, execution_type_desc, replica_role,
+ runtime_stats_interval_id, interval_start_time_utc, first_execution_time,
+ execution_count, avg_duration_us, avg_cpu_time_us, max_duration_us, max_cpu_time_us)
+SELECT
+ (extract(epoch FROM g)::bigint * 100 + q),
+ g,
+ $3,
+ 'ceiling-e2e',
+ 'TestDb',
+ 'dbo.Proc' || q::text,
+ '0x' || lpad(to_hex(q), 16, '0'),
+ q,
+ q + 1000,
+ 'Regular',
+ 'PRIMARY',
+ extract(epoch FROM date_trunc('hour', g))::bigint,
+ date_trunc('hour', g),
+ date_trunc('hour', g),
+ q * 10,
+ 1200,
+ 600,
+ 9000,
+ 4000
+FROM generate_series($1::timestamp, $2::timestamp, INTERVAL '1 hour') AS g
+CROSS JOIN generate_series(1, $4::int) AS q", connection);
+
+ insert.Parameters.AddWithValue(from);
+ insert.Parameters.AddWithValue(to);
+ insert.Parameters.AddWithValue(TestServerId);
+ insert.Parameters.AddWithValue(QueriesPerBucket);
+ await insert.ExecuteNonQueryAsync(cancellationToken);
+ }
+
+ private static async Task CountAsync(NpgsqlConnection connection, string sql, CancellationToken ct)
+ {
+ await using var command = new NpgsqlCommand(sql, connection);
+ var result = await command.ExecuteScalarAsync(ct);
+ return Convert.ToInt64(result);
+ }
+}
diff --git a/Darling/Darling.Tests/RollupFloorCacheTests.cs b/Darling/Darling.Tests/RollupFloorCacheTests.cs
index f3f124f4a3..ab25fa780b 100644
--- a/Darling/Darling.Tests/RollupFloorCacheTests.cs
+++ b/Darling/Darling.Tests/RollupFloorCacheTests.cs
@@ -254,6 +254,87 @@ public void MergeRollupFloors_ViewNoLongerPresent_DropsTheStaleEntry()
Assert.DoesNotContain(view, newEntries.Keys);
}
+ /* ─────────────────────────── #4605 part 2 (LA-1b): the ceiling ─────────────────────────── */
+
+ [Fact]
+ public void CeilingOf_UnknownView_IsNull()
+ {
+ var coverage = new RollupCoverage(
+ new Dictionary(StringComparer.Ordinal),
+ new Dictionary(StringComparer.Ordinal),
+ RollupAvailability.All,
+ new Dictionary(StringComparer.Ordinal));
+
+ Assert.Null(coverage.CeilingOf(TimescaleSupport.QueryStoreStatsHourlyView));
+ }
+
+ [Fact]
+ public void MergeRollupFloors_SixArgument_ReMeasuredView_ReplacesTheCachedCeiling()
+ {
+ var view = TimescaleSupport.QueryStoreStatsHourlyView;
+ var freshFloor = SomeFloor.AddDays(1);
+ var staleCeiling = SomeFloor.AddHours(1);
+ var freshCeiling = freshFloor.AddHours(1);
+ var measuredThisCycle = new Dictionary(StringComparer.Ordinal) { [view] = freshFloor };
+ var cached = new Dictionary(StringComparer.Ordinal)
+ {
+ [view] = new TimescaleSupport.RollupFloorCacheEntry("_hyper_1_1_chunk", SomeHypertable, SomeFloor, Now, staleCeiling),
+ };
+ var oldestNow = new Dictionary(StringComparer.Ordinal)
+ {
+ [view] = new("_hyper_1_2_chunk", SomeHypertable),
+ };
+ var ceilingsMeasuredThisCycle = new Dictionary(StringComparer.Ordinal) { [view] = freshCeiling };
+
+ var (_, newEntries) = TimescaleSupport.MergeRollupFloors(
+ measuredThisCycle, cached, oldestNow, RollupAvailability.All, Now, ceilingsMeasuredThisCycle);
+
+ Assert.Equal(freshCeiling, newEntries[view].Ceiling);
+ }
+
+ [Fact]
+ public void MergeRollupFloors_SixArgument_NotMeasuredThisCycle_KeepsTheCachedCeiling()
+ {
+ var view = TimescaleSupport.QueryStoreStatsHourlyView;
+ var cachedCeiling = SomeFloor.AddHours(1);
+ var measuredThisCycle = new Dictionary(StringComparer.Ordinal);
+ var cached = new Dictionary(StringComparer.Ordinal)
+ {
+ [view] = new TimescaleSupport.RollupFloorCacheEntry("_hyper_1_1_chunk", SomeHypertable, SomeFloor, Now, cachedCeiling),
+ };
+ var oldestNow = new Dictionary(StringComparer.Ordinal)
+ {
+ [view] = new("_hyper_1_1_chunk", SomeHypertable),
+ };
+ var ceilingsMeasuredThisCycle = new Dictionary(StringComparer.Ordinal);
+
+ var (_, newEntries) = TimescaleSupport.MergeRollupFloors(
+ measuredThisCycle, cached, oldestNow, RollupAvailability.All, Now, ceilingsMeasuredThisCycle);
+
+ Assert.Equal(cachedCeiling, newEntries[view].Ceiling);
+ }
+
+ [Fact]
+ public void MergeRollupFloors_FiveArgument_BehavesExactlyAsBefore_LeavingTheCeilingUnset()
+ {
+ var view = TimescaleSupport.QueryStoreStatsHourlyView;
+ var freshFloor = SomeFloor.AddDays(1);
+ var measuredThisCycle = new Dictionary(StringComparer.Ordinal) { [view] = freshFloor };
+ var cached = new Dictionary(StringComparer.Ordinal)
+ {
+ [view] = Entry("_hyper_1_1_chunk", SomeFloor),
+ };
+ var oldestNow = new Dictionary(StringComparer.Ordinal)
+ {
+ [view] = new("_hyper_1_2_chunk", SomeHypertable),
+ };
+
+ var (floors, newEntries) = TimescaleSupport.MergeRollupFloors(measuredThisCycle, cached, oldestNow, RollupAvailability.All, Now);
+
+ Assert.Equal(freshFloor, floors[view]);
+ Assert.Null(newEntries[view].Ceiling);
+ }
+
/* ─────────────────────────── the two-argument SQL overload ─────────────────────────── */
[Fact]
diff --git a/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs b/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs
index f460f03aaa..5a6dbbe938 100644
--- a/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs
+++ b/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs
@@ -13339,7 +13339,7 @@ public RollupCoverage(
/// HourlyRefreshStartOffset before a refresh, or a seam below a successor's floor, can leave a hole
/// strictly BELOW the ceiling and above the floor
/// (, MaterializationHoles.cs lines 27–40,
- /// 505–531; the #4301 seam ruling at line 74 states the property a caller needs: contiguity from the
+ /// 505–531; the #4301 seam decision at line 74 states the property a caller needs: contiguity from the
/// FLOOR upward is what the repair pass (,
/// ) works to hold, not something this probe
/// measures on every read). A caller that needs a hole-free span checked, rather than assumed, calls
From 1b19a2fa18ef0549878cccd77316eb0c31966d19 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Mon, 28 Sep 2026 13:15:13 -0400
Subject: [PATCH 4/5] Drop internal step labels from the ceiling comments;
plain #4605 references only
---
.../Darling.Tests/RollupCoverageCeilingLiveTests.cs | 2 +-
Darling/Darling.Tests/RollupFloorCacheTests.cs | 2 +-
.../TimescaleSupport.cs | 12 ++++++------
3 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/Darling/Darling.Tests/RollupCoverageCeilingLiveTests.cs b/Darling/Darling.Tests/RollupCoverageCeilingLiveTests.cs
index f0c64811aa..c3846eaed9 100644
--- a/Darling/Darling.Tests/RollupCoverageCeilingLiveTests.cs
+++ b/Darling/Darling.Tests/RollupCoverageCeilingLiveTests.cs
@@ -16,7 +16,7 @@
namespace Darling.Tests;
///
-/// #4605 part 2 (LA-1b) end-to-end against a REAL TimescaleDB:
+/// #4605 end-to-end against a REAL TimescaleDB:
/// matches the engine's own materialization watermark, advances once the rollup is refreshed further, and
/// stays put for a cycle where the floor (and therefore the ceiling) is reused from cache rather than
/// re-read.
diff --git a/Darling/Darling.Tests/RollupFloorCacheTests.cs b/Darling/Darling.Tests/RollupFloorCacheTests.cs
index ab25fa780b..8bd7bd0d2b 100644
--- a/Darling/Darling.Tests/RollupFloorCacheTests.cs
+++ b/Darling/Darling.Tests/RollupFloorCacheTests.cs
@@ -254,7 +254,7 @@ public void MergeRollupFloors_ViewNoLongerPresent_DropsTheStaleEntry()
Assert.DoesNotContain(view, newEntries.Keys);
}
- /* ─────────────────────────── #4605 part 2 (LA-1b): the ceiling ─────────────────────────── */
+ /* ─────────────────────────── #4605: the ceiling ─────────────────────────── */
[Fact]
public void CeilingOf_UnknownView_IsNull()
diff --git a/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs b/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs
index 5a6dbbe938..3b3dd05b32 100644
--- a/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs
+++ b/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs
@@ -9346,7 +9346,7 @@ private sealed class RollupFloorCache
/// the cached floor no longer describes the relation actually being read. backs
/// .
///
- /// (#4605 part 2, LA-1) is the materialization ceiling measured in the SAME
+ /// (#4605) is the materialization ceiling measured in the SAME
/// cycle as — re-read whenever the floor is (the ceiling moves every refresh, so
/// piggybacking on the floor's re-measure cadence, rather than a separate TTL, is the cheap answer: a
/// view whose floor is trusted from cache almost never has a stale-enough ceiling to matter, and a
@@ -9424,7 +9424,7 @@ internal static (IReadOnlyDictionary Floors, IReadOnlyDictiona
=> MergeRollupFloors(measuredThisCycle, cached, oldestNow, availability, now, ceilingsMeasuredThisCycle: null);
///
- /// #4605 part 2 (LA-1): the same merge, with carrying the
+ /// #4605: the same merge, with carrying the
/// freshly-read for every view re-measured THIS cycle —
/// null for the two-argument overload (every pre-#4605 caller and test), which leaves every new
/// entry's ceiling unset (the safe "unknown" default) rather than guessing one.
@@ -9617,7 +9617,7 @@ when this cache has no evidence either way. */
measuredThisCycle[view] = await reader.IsDBNullAsync(i, cancellationToken) ? (DateTime?)null : reader.GetDateTime(i);
}
- /* #4605 part 2 (LA-1): the ceiling is re-read for exactly the views re-measured this cycle (a view
+ /* #4605: the ceiling is re-read for exactly the views re-measured this cycle (a view
whose floor came from cache keeps its cached ceiling below, on the same reuse cadence #4539/#4553
already give the floor — no per-request max(bucket)/cagg_watermark scan). */
var ceilingsMeasuredThisCycle = new Dictionary(StringComparer.Ordinal);
@@ -9656,7 +9656,7 @@ when this cache has no evidence either way. */
}
///
- /// #4605 part 2 (LA-1): the ceiling read for the catalog-read-failed / no-rollups-present fallback path,
+ /// #4605: the ceiling read for the catalog-read-failed / no-rollups-present fallback path,
/// where every present view was just measured for its floor and has no cache entry to carry a ceiling
/// forward from. One per view named in
/// — bounded by the same small view list the floor probe itself named, so this
@@ -13308,7 +13308,7 @@ public RollupCoverage(
}
///
- /// #4605 part 2 (LA-1): the per-view MATERIALIZATION CEILING — how far forward each rollup has actually
+ /// #4605: the per-view MATERIALIZATION CEILING — how far forward each rollup has actually
/// materialized, read from in the same probe cycle that
/// measures the floor () and cached the same way.
/// A view absent from answers null from — the
@@ -13327,7 +13327,7 @@ public RollupCoverage(
}
///
- /// #4605 part 2 (LA-1): 's MEASURED materialization ceiling — the instant AT
+ /// #4605: 's MEASURED materialization ceiling — the instant AT
/// OR ABOVE which the rollup holds nothing ('s
/// engine watermark, or its max(bucket) + bucketWidth fallback) — or null when unknown (no
/// rollup by this name was probed, or the probe found nothing to measure). A hybrid raw/rollup route
From 70264d26d4067fb8b468909696d27d38065fad59 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Mon, 28 Sep 2026 13:28:05 -0400
Subject: [PATCH 5/5] Add a live pin for the cached-coverage trend route
(#4611)
QueryStoreTrendRoutingCachedLiveTests proves the coverage-taking
overload of QueryStoreTrendRouting.ResolveAsync issues no fresh
min(bucket)/max(bucket) probe against query_store_stats_corrected_hourly
once a caller already holds a RollupCoverage with a measured ceiling
for that view, and that it reaches the same routing decision the
uncached probe-and-read overload does for the same store. Counted
through pg_stat_statements against the actual call, the same
discipline StoreSizeCacheLiveTests uses.
---
.../QueryStoreTrendRoutingCachedLiveTests.cs | 175 ++++++++++++++++++
1 file changed, 175 insertions(+)
create mode 100644 Darling/Darling.Tests/QueryStoreTrendRoutingCachedLiveTests.cs
diff --git a/Darling/Darling.Tests/QueryStoreTrendRoutingCachedLiveTests.cs b/Darling/Darling.Tests/QueryStoreTrendRoutingCachedLiveTests.cs
new file mode 100644
index 0000000000..45a2a01882
--- /dev/null
+++ b/Darling/Darling.Tests/QueryStoreTrendRoutingCachedLiveTests.cs
@@ -0,0 +1,175 @@
+/*
+ * 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.Linq;
+using System.Threading.Tasks;
+using Npgsql;
+using NpgsqlTypes;
+using PerformanceMonitor.Darling.Storage;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #4611 end-to-end against a REAL TimescaleDB: once a caller already holds a
+/// with a ceiling for the Query Store corrected hourly view,
+/// issues NO fresh min(bucket)/max(bucket) read against that view, and it reaches the same
+/// routing decision the uncached probe-and-read overload would for the same store.
+///
+/// Counted through pg_stat_statements against the actual product call path, the same
+/// discipline StoreSizeCacheLiveTests uses, rather than a mock standing in for the store.
+///
+/// #1776 own-store — mints its own scratch database through .
+///
+public sealed class QueryStoreTrendRoutingCachedLiveTests
+{
+ /// Distinctive fake id — a real server_id is a storage-name hash, never this.
+ private const int TestServerId = -461100;
+
+ [Fact]
+ public async Task ResolveAsync_WithACachedCeiling_IssuesNoBoundsProbe_AndMatchesTheFallbackRoute()
+ {
+ 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 #4611 cached-route test (it mints its own scratch database).");
+
+ var ct = TestContext.Current.CancellationToken;
+
+ await using (var probe = new NpgsqlConnection(baseConnectionString))
+ {
+ await probe.OpenAsync(ct);
+ await using var preloadCmd = new NpgsqlCommand("SELECT current_setting('shared_preload_libraries')", probe);
+ var preload = (string)(await preloadCmd.ExecuteScalarAsync(ct))!;
+ var loaded = preload.Split(',').Select(s => s.Trim()).Contains("pg_stat_statements");
+ Assert.SkipWhen(!loaded,
+ $"pg_stat_statements is not in shared_preload_libraries ('{preload}') - this rig's postgresql.conf must carry it in shared_preload_libraries to run this pin.");
+ }
+
+ 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);
+
+ await using (var extCmd = new NpgsqlCommand("CREATE EXTENSION IF NOT EXISTS pg_stat_statements", connection))
+ {
+ await extCmd.ExecuteNonQueryAsync(ct);
+ }
+
+ Assert.True(await TimescaleSupport.TryEnableAsync(connection, null, ct),
+ "the dev fixture is expected to have TimescaleDB installed");
+ await TimescaleSupport.ConvertToHypertablesAsync(connection, null, ct);
+ await DarlingMcpTestData.RegisterServerAsync(connection, TestServerId, "qs-trend-cached-e2e", ct);
+
+ var hour10 = new DateTime(2026, 3, 4, 10, 0, 0, DateTimeKind.Unspecified);
+ var hour11 = hour10.AddHours(1);
+ var hour12 = hour10.AddHours(2);
+
+ await SeedSnapshotAsync(connection, intervalId: 4200, queryId: 80, when: hour10.AddMinutes(5), executionCount: 5L, ct);
+ await SeedSnapshotAsync(connection, intervalId: 4201, queryId: 81, when: hour11.AddMinutes(5), executionCount: 7L, ct);
+
+ await TimescaleSupport.EnsureContinuousAggregatesAsync(connection, null, ct);
+ foreach (var (view, _, _, _, _) in TimescaleSupport.RollupViews)
+ {
+ await using var remove = new NpgsqlCommand(
+ $"SELECT remove_continuous_aggregate_policy('collect.{view}', if_exists => true)", connection);
+ await remove.ExecuteNonQueryAsync(ct);
+ }
+
+ await using (var refreshInterval = new NpgsqlCommand(
+ $"CALL refresh_continuous_aggregate('collect.{TimescaleSupport.QueryStoreStatsIntervalHourlyView}', $1::timestamp, $2::timestamp)", connection))
+ {
+ refreshInterval.Parameters.AddWithValue(hour10);
+ refreshInterval.Parameters.AddWithValue(hour12);
+ await refreshInterval.ExecuteNonQueryAsync(ct);
+ }
+
+ await using (var refreshCorrected = new NpgsqlCommand(
+ $"CALL refresh_continuous_aggregate('collect.{TimescaleSupport.QueryStoreStatsCorrectedHourlyView}', $1::timestamp, $2::timestamp)", connection))
+ {
+ refreshCorrected.Parameters.AddWithValue(hour10);
+ refreshCorrected.Parameters.AddWithValue(hour12);
+ await refreshCorrected.ExecuteNonQueryAsync(ct);
+ }
+
+ await using var postgres = NpgsqlDataSource.Create(scratch.ConnectionString);
+
+ /* ── the fallback route, measured first as the reference answer ── */
+ var fallbackRoute = await QueryStoreTrendRouting.ResolveAsync(postgres, ct);
+ Assert.True(fallbackRoute.UseRollup);
+
+ /* ── a coverage snapshot already carrying the ceiling, the way both real callers hold one ── */
+ var availability = await TimescaleSupport.DetectRollupsAsync(postgres, ct);
+ var coverage = await TimescaleSupport.DetectRollupCoverageAsync(postgres, availability, ct);
+ Assert.NotNull(coverage.CeilingOf(TimescaleSupport.QueryStoreStatsCorrectedHourlyView));
+
+ long dbId;
+ await using (var oidConn = new NpgsqlConnection(scratch.ConnectionString))
+ {
+ await oidConn.OpenAsync(ct);
+ using var oidCmd = new NpgsqlCommand("SELECT oid FROM pg_database WHERE datname = current_database()", oidConn);
+ dbId = (uint)(await oidCmd.ExecuteScalarAsync(ct))!;
+
+ using var resetCmd = new NpgsqlCommand("SELECT pg_stat_statements_reset(0, $1, 0)", oidConn);
+ resetCmd.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Oid, Value = (uint)dbId });
+ await resetCmd.ExecuteNonQueryAsync(ct);
+ }
+
+ var bodySucceeded = false;
+ try
+ {
+ /* ── THE PIN: the cached-first overload, called the same way the MCP reader and the desktop
+ viewer call it ── */
+ var cachedRoute = await QueryStoreTrendRouting.ResolveAsync(coverage, postgres, ct);
+
+ Assert.Equal(fallbackRoute.UseRollup, cachedRoute.UseRollup);
+ Assert.Equal(fallbackRoute.RawStartUtc, cachedRoute.RawStartUtc);
+ Assert.Equal(fallbackRoute.RollupFloorUtc, cachedRoute.RollupFloorUtc);
+
+ long boundsCalls;
+ await using (var countConn = new NpgsqlConnection(scratch.ConnectionString))
+ {
+ await countConn.OpenAsync(ct);
+ using var countCmd = new NpgsqlCommand(
+ "SELECT COALESCE(SUM(calls), 0)::bigint FROM pg_stat_statements WHERE dbid = $1 AND query LIKE '%min(bucket)%max(bucket)%'",
+ countConn);
+ countCmd.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Oid, Value = (uint)dbId });
+ boundsCalls = (long)(await countCmd.ExecuteScalarAsync(ct))!;
+ }
+
+ Assert.Equal(0, boundsCalls);
+
+ bodySucceeded = true;
+ }
+ finally
+ {
+ await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, (_, _) => Task.CompletedTask);
+ }
+ }
+
+ private static async Task SeedSnapshotAsync(
+ NpgsqlConnection connection, long intervalId, long queryId, DateTime when, long executionCount, System.Threading.CancellationToken ct)
+ {
+ const string sql = @"
+INSERT INTO collect.query_store_stats
+ (collection_id, collection_time, server_id, server_name, database_name, module_name, query_hash,
+ query_id, plan_id, execution_type_desc, replica_role,
+ runtime_stats_interval_id, interval_start_time_utc, first_execution_time,
+ execution_count, avg_duration_us, avg_cpu_time_us, max_duration_us, max_cpu_time_us)
+VALUES
+ ((extract(epoch FROM $1)::bigint * 100000) + $2, $1, $3, 'qs-trend-cached-e2e', 'RoutingDb', 'dbo.GetOrders', '0xROUTE',
+ $2, $2, 'Regular', 'PRIMARY', $2, $1, $1, $4, 100, 100, 900, 400)";
+
+ await using var command = new NpgsqlCommand(sql, connection);
+ command.Parameters.AddWithValue(when);
+ command.Parameters.AddWithValue(queryId);
+ command.Parameters.AddWithValue(TestServerId);
+ command.Parameters.AddWithValue(executionCount);
+ await command.ExecuteNonQueryAsync(ct);
+ }
+}