From 11b82ab1290e1be334bbdbdfea098b4f47c443ca Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 26 Sep 2026 15:11:02 -0400 Subject: [PATCH] Pin that the seam probe stops at the successor's first bucket above the legacy's end (#4406) --- .../Darling.Tests/FrozenRollupLiveTests.cs | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/Darling/Darling.Tests/FrozenRollupLiveTests.cs b/Darling/Darling.Tests/FrozenRollupLiveTests.cs index 364b71a72f..f75570318b 100644 --- a/Darling/Darling.Tests/FrozenRollupLiveTests.cs +++ b/Darling/Darling.Tests/FrozenRollupLiveTests.cs @@ -1794,6 +1794,102 @@ await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, async ( } } + /// + /// PIN (#4406): a NON-empty successor with a hole inside its OWN span — above its first bucket past the + /// legacy's end — must not hold the raw purge. The seam probe ( + /// via the coverage stitch) walks only (l.mx, the successor's first bucket above l.mx); buckets from + /// that first bucket upward belong to the successor's own span, which the walk's ordinary window repairs, so + /// the gate leaves them out on purpose. The legacy freezes a few hours back; the successor materializes its + /// very first bucket immediately above the legacy (so the seam itself is gap-free) and a LATER bucket too, + /// but skips a bucket in between — a real hole, just one the seam probe was never meant to see. + /// must still read true. RED if the coverage + /// stitch's toExpr stopped honoring the successor's own first bucket and instead always fell back to + /// now() - HourlyRefreshStartOffset: the probe would then walk past the successor's floor and into + /// this test's deliberate hole, reporting Short. + /// + [Fact] + public async Task Upgrade_NonEmptySuccessor_HoleInItsOwnSpan_SeamProbeStopsAtItsFirstBucket_RawPurgeStaysCovered() + { + 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 freeze 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 freeze test needs TimescaleDB."); + await TimescaleSupport.ConvertToHypertablesAsync(connection, null, ct); + Assert.True(await TimescaleSupport.EnsureCollectionLogHypertableAsync(connection, null, ct)); + + 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 + { + /* now() anchor, truncated to the hour. The legacy froze 3 days back (H-72) — well past + HourlyRefreshStartOffset (1 day), so the fallback bound (used only when the successor holds + nothing above the legacy) would sit far ABOVE the seam if it were ever consulted here. The + successor's first bucket past the legacy, H-71 (T1), is materialized right away, so the seam + itself (H-72, H-71) holds no hole. The successor also materializes a much later bucket, H-2, + but skips H-30 (T2) in between — a real hole, entirely inside the successor's own span above + T1, which the seam probe must stop short of and never reach. */ + var now = DarlingMcpTestData.TruncateToSeconds(DateTime.UtcNow); + var h = new DateTime(now.Year, now.Month, now.Day, now.Hour, 0, 0, DateTimeKind.Unspecified); + + await InsertProcedureStatsAsync(connection, h.AddHours(-72), "seam_proc_legacy_floor", 900, 9, 3600, ct); + await InsertProcedureStatsAsync(connection, h.AddHours(-71), "seam_proc_t1", 900, 9, 3600, ct); + await InsertProcedureStatsAsync(connection, h.AddHours(-30), "seam_proc_hole", 900, 9, 3600, ct); + await InsertProcedureStatsAsync(connection, h.AddHours(-2), "seam_proc_successor_span", 900, 9, 3600, ct); + + await RefreshAsync(connection, TimescaleSupport.ProcedureStatsHourlyView, h.AddHours(-72), h.AddHours(-71), ct); + await RefreshAsync(connection, TimescaleSupport.ProcedureStatsIntervalHourlyView, h.AddHours(-71), h.AddHours(-70), ct); + await RefreshAsync(connection, TimescaleSupport.ProcedureStatsIntervalHourlyView, h.AddHours(-2), h.AddHours(-1), ct); + + /* The successor is non-empty (H-71 and H-2 are both materialized) and its own span holds a real + hole at H-30: raw admits a row for that hour, but neither the legacy nor the successor ever + materialized it. Proven directly so the pin cannot pass vacuously. */ + await using (var holeCheck = new NpgsqlCommand( + $"SELECT (SELECT count(*) FROM collect.procedure_stats WHERE collection_time >= $1 AND collection_time < $2), " + + $" (SELECT count(*) FROM collect.{TimescaleSupport.ProcedureStatsIntervalHourlyView} WHERE bucket = $1), " + + $" (SELECT count(*) FROM collect.{TimescaleSupport.ProcedureStatsHourlyView} WHERE bucket = $1)", connection)) + { + holeCheck.Parameters.AddWithValue(h.AddHours(-30)); + holeCheck.Parameters.AddWithValue(h.AddHours(-29)); + await using var reader = await holeCheck.ExecuteReaderAsync(ct); + Assert.True(await reader.ReadAsync(ct)); + Assert.True(reader.GetInt64(0) > 0, "raw must admit a row for the hour this test deliberately leaves unmaterialized"); + Assert.Equal(0L, reader.GetInt64(1)); + Assert.Equal(0L, reader.GetInt64(2)); + } + + Assert.True(await TimescaleSupport.IsRawTierDropSafeAsync(connection, "procedure_stats", ct)); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, async (cleanup, cleanupCt) => + { + 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); + }); + } + } + /// /// PIN (#4300): a real outage must still be caught, even inside the new fallback's window. The legacy's /// last bucket is 3 days ago; raw carries rows in the hours between 3 days ago and 1 day ago, but neither