diff --git a/Darling/Darling.Tests/BaselineSupplyTests.cs b/Darling/Darling.Tests/BaselineSupplyTests.cs index cef47be22d..afb9a011dc 100644 --- a/Darling/Darling.Tests/BaselineSupplyTests.cs +++ b/Darling/Darling.Tests/BaselineSupplyTests.cs @@ -556,10 +556,11 @@ public void SupersededRelations_AreNeitherLiving_NorRetiredOnSight_AndTheProvide } /// - /// THE RETIREMENT CONDITION walked across time (#3653): a legacy continuous aggregate releases only once the - /// successor's oldest bucket reaches the tier horizon; an empty successor never releases it; a legacy plain - /// view releases at once. Day 0 after a backfill from a 30-day raw horizon is the case the brief asked to see - /// evaluate FALSE; day 5 is where it turns. + /// THE RETIREMENT CONDITION walked across time (#3653): a legacy continuous aggregate WITH ROWS releases only + /// once the successor's oldest bucket reaches the tier horizon; an empty successor never releases it; a legacy + /// plain view releases at once. Day 0 after a backfill from a 30-day raw horizon is the case the brief asked to + /// see evaluate FALSE; day 5 is where it turns. Every call here passes legacyHoldsRows: true — the + /// empty-legacy short-circuit is . /// [Fact] public void RetirementCondition_HoldsOnlyOnceTheSuccessorCoversTheTier() @@ -567,20 +568,42 @@ public void RetirementCondition_HoldsOnlyOnceTheSuccessorCoversTheTier() var firstStart = new DateTime(2026, 9, 20, 4, 0, 0, DateTimeKind.Unspecified); var successorOldest = firstStart.AddDays(-30); // backfilled from raw's default 30-day horizon - Assert.False(TimescaleSupport.SupersededBaselineRelationDropsAt(true, successorOldest, firstStart)); - Assert.False(TimescaleSupport.SupersededBaselineRelationDropsAt(true, successorOldest, firstStart.AddDays(4).AddHours(23))); - Assert.True(TimescaleSupport.SupersededBaselineRelationDropsAt(true, successorOldest, firstStart.AddDays(5))); - Assert.True(TimescaleSupport.SupersededBaselineRelationDropsAt(true, successorOldest, firstStart.AddDays(40))); + Assert.False(TimescaleSupport.SupersededBaselineRelationDropsAt(true, true, successorOldest, firstStart)); + Assert.False(TimescaleSupport.SupersededBaselineRelationDropsAt(true, true, successorOldest, firstStart.AddDays(4).AddHours(23))); + Assert.True(TimescaleSupport.SupersededBaselineRelationDropsAt(true, true, successorOldest, firstStart.AddDays(5))); + Assert.True(TimescaleSupport.SupersededBaselineRelationDropsAt(true, true, successorOldest, firstStart.AddDays(40))); /* Exactly on the horizon counts as coverage (<=). */ - Assert.True(TimescaleSupport.SupersededBaselineRelationDropsAt(true, firstStart - TimescaleSupport.BaselineRetentionSpan, firstStart)); + Assert.True(TimescaleSupport.SupersededBaselineRelationDropsAt(true, true, firstStart - TimescaleSupport.BaselineRetentionSpan, firstStart)); /* An un-backfilled successor covers nothing, whatever the clock says. */ - Assert.False(TimescaleSupport.SupersededBaselineRelationDropsAt(true, null, firstStart.AddDays(400))); + Assert.False(TimescaleSupport.SupersededBaselineRelationDropsAt(true, true, null, firstStart.AddDays(400))); + + /* A legacy PLAIN VIEW has nothing of its own to lose: drops as soon as a successor exists, whether or + not it "holds rows" is even asked (it never is, in JudgeSupersededBaselineRelationAsync, but the pure + predicate must not depend on that arg for this branch either). */ + Assert.True(TimescaleSupport.SupersededBaselineRelationDropsAt(false, true, null, firstStart)); + Assert.True(TimescaleSupport.SupersededBaselineRelationDropsAt(false, true, successorOldest, firstStart)); + Assert.True(TimescaleSupport.SupersededBaselineRelationDropsAt(false, false, null, firstStart)); + } + + /// + /// THE #4289 SHORT-CIRCUIT, pure: a legacy CONTINUOUS AGGREGATE that holds no rows of its own drops on + /// sight, whatever the successor holds — an empty successor, a short one, or one that fully covers the tier + /// all give the same verdict, because an empty legacy aggregate has no history to protect regardless. Same + /// clock and successor-oldest fixtures as , + /// with legacyHoldsRows: false flipping every one of them to Drop. + /// + [Fact] + public void RetirementCondition_EmptyLegacyAggregateDropsOnSight_RegardlessOfSuccessor() + { + var firstStart = new DateTime(2026, 9, 20, 4, 0, 0, DateTimeKind.Unspecified); + var successorOldest = firstStart.AddDays(-30); - /* A legacy PLAIN VIEW has nothing of its own to lose: drops as soon as a successor exists. */ - Assert.True(TimescaleSupport.SupersededBaselineRelationDropsAt(false, null, firstStart)); - Assert.True(TimescaleSupport.SupersededBaselineRelationDropsAt(false, successorOldest, firstStart)); + Assert.True(TimescaleSupport.SupersededBaselineRelationDropsAt(true, false, null, firstStart)); + Assert.True(TimescaleSupport.SupersededBaselineRelationDropsAt(true, false, successorOldest, firstStart)); + Assert.True(TimescaleSupport.SupersededBaselineRelationDropsAt(true, false, firstStart - TimescaleSupport.BaselineRetentionSpan, firstStart)); + Assert.True(TimescaleSupport.SupersededBaselineRelationDropsAt(true, false, null, firstStart.AddDays(400))); } /// diff --git a/Darling/Darling.Tests/RetiredBaselineAggregateTests.cs b/Darling/Darling.Tests/RetiredBaselineAggregateTests.cs index cf51e7a0c9..fa4a01ecd6 100644 --- a/Darling/Darling.Tests/RetiredBaselineAggregateTests.cs +++ b/Darling/Darling.Tests/RetiredBaselineAggregateTests.cs @@ -301,6 +301,10 @@ and all (the policy's presence is asserted here so the cascade below has a befor var verdict = await TimescaleSupport.JudgeSupersededBaselineRelationAsync(connection, legacy, successor, DateTime.UtcNow, ct); Assert.Equal(TimescaleSupport.SupersededBaselineDecision.SuccessorShort, verdict.Decision); Assert.True(verdict.LegacyIsContinuousAggregate); + /* #4289: this legacy aggregate has the restart-zero row refreshed into it (line ~267), so it HOLDS + rows — the state that still waits for the successor's coverage, unlike + EmptySupersededBaselineLiveTests.EmptyLegacyAggregate_DropsOnSight_EvenWithAnEmptySuccessor_AgainstDevPostgres. */ + Assert.True(verdict.LegacyHoldsRows); Assert.Equal(hour2, verdict.SuccessorOldest); await TimescaleSupport.DropRetiredBaselineAggregatesAsync(connection, null, DateTime.UtcNow, ct); Assert.True(await ScalarAsync(connection, TimescaleSupport.BaselineRelationExistsSql(legacy), null, ct), "the legacy aggregate must survive a day-one sweep"); @@ -337,8 +341,9 @@ zero is gone from the statistic. */ /* ---- day forty: the clock says the successor covers the tier; the legacy drops with its policy. */ var dayForty = DateTime.UtcNow.AddDays(40); - Assert.Equal(TimescaleSupport.SupersededBaselineDecision.Drop, - (await TimescaleSupport.JudgeSupersededBaselineRelationAsync(connection, legacy, successor, dayForty, ct)).Decision); + var dayFortyVerdict = await TimescaleSupport.JudgeSupersededBaselineRelationAsync(connection, legacy, successor, dayForty, ct); + Assert.Equal(TimescaleSupport.SupersededBaselineDecision.Drop, dayFortyVerdict.Decision); + Assert.True(dayFortyVerdict.LegacyHoldsRows, "this legacy still HOLDS rows at day forty — it drops on coverage, not on #4289's empty-aggregate rule"); Assert.True(await TimescaleSupport.DropRetiredBaselineAggregatesAsync(connection, null, dayForty, ct) >= 1); Assert.False(await ScalarAsync(connection, TimescaleSupport.BaselineRelationExistsSql(legacy), null, ct), "the legacy aggregate must be gone"); Assert.True(await ScalarAsync(connection, TimescaleSupport.BaselineRelationExistsSql(successor), null, ct), "the successor must stay"); @@ -409,3 +414,169 @@ private static async Task ExecuteAsync(NpgsqlConnection connection, string sql, await command.ExecuteNonQueryAsync(ct); } } + +/// +/// #4289: the field-store defect, reproduced. A legacy CONTINUOUS AGGREGATE with nothing ever feeding its +/// source (no wait_stats rows at all) leaves it, and its interval-honest successor backfilled from +/// that same empty source, BOTH empty — the state 's +/// coverage rule could never resolve, because a NULL successor oldest bucket never compares <= +/// any horizon. Before #4289 that verdict was SuccessorShort forever; the legacy relation, and its +/// refresh/retention/compression jobs, would outlive the store. +/// +/// #1776 own-store, not [Collection("live-postgres")]: +/// reads through the legacy view's real-time aggregation (every legacy CREATE carries +/// timescaledb.materialized_only = false), which unions in EVERY row of the raw source table — +/// unscoped by server_id. On the shared darlingtest store, other live tests' own +/// wait_stats rows would make the legacy view non-empty regardless of what this test seeds or deletes, +/// so "empty" can only be asserted truthfully on a database nothing else ever writes to. +/// +public sealed class EmptySupersededBaselineLiveTests +{ + [Fact] + public async Task EmptyLegacyAggregate_DropsOnSight_EvenWithAnEmptySuccessor_AgainstDevPostgres() + { + 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 #4289 empty-legacy 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 #4289 empty-legacy test needs TimescaleDB."); + await TimescaleSupport.ConvertToHypertablesAsync(connection, null, ct); + /* Same start order as the real service (DailyStitchLiveTests, SuccessorDailyLiveTests): collection_log + becomes a hypertable BEFORE the ensure sweep, because collection_health_hourly (one of the ordinary + HourlyAggregates the sweep also creates) selects FROM it. */ + Assert.True(await TimescaleSupport.EnsureCollectionLogHypertableAsync(connection, null, ct)); + + /* No refresh policy on this scratch database is ever exercised — nothing here calls run_job or waits on + the scheduler — but stopped anyway, matching every other own-store fixture in this file, so a policy + firing mid-test can never be the explanation for an unexpected row. */ + await using (var stop = new NpgsqlCommand("SELECT _timescaledb_functions.stop_background_workers()", connection)) + { + await stop.ExecuteNonQueryAsync(ct); + } + + /* The successor (wait_stats_interval_baseline) comes from the ordinary ensure sweep, WITH NO DATA — an + empty successor, half of this test's state. Nothing ever inserts into collect.wait_stats on this + scratch database, so the LEGACY's real-time aggregation stays empty too, once created below. */ + await TimescaleSupport.EnsureContinuousAggregatesAsync(connection, null, ct); + + const string legacy = TimescaleSupport.LegacyWaitStatsBaselineView; + const string successor = TimescaleSupport.WaitStatsIntervalBaselineView; + + await using (var create = new NpgsqlCommand(TimescaleSupport.LegacyCreateWaitStatsBaselineSql, connection) { CommandTimeout = 120 }) + { + await create.ExecuteNonQueryAsync(ct); + } + + Assert.False(await HasRowsAsync(connection, legacy, ct), "the legacy aggregate must be empty for this test to mean anything"); + Assert.False(await HasRowsAsync(connection, successor, ct), "the successor must be empty for this test to mean anything"); + + var verdict = await TimescaleSupport.JudgeSupersededBaselineRelationAsync(connection, legacy, successor, DateTime.UtcNow, ct); + Assert.Equal(TimescaleSupport.SupersededBaselineDecision.Drop, verdict.Decision); + Assert.True(verdict.LegacyIsContinuousAggregate); + Assert.False(verdict.LegacyHoldsRows); + Assert.Null(verdict.SuccessorOldest); + + Assert.Equal(1, await TimescaleSupport.DropRetiredBaselineAggregatesAsync(connection, null, DateTime.UtcNow, ct)); + Assert.False(await RelationExistsAsync(connection, legacy, ct), "the empty legacy aggregate must be gone"); + Assert.True(await RelationExistsAsync(connection, successor, ct), "the successor must stay, empty or not"); + } + + /// #4292 Low 1: when the has-rows probe itself fails, the legacy must survive, not read as empty. + /// has no catch of its own, so the + /// exception reaches 's + /// per-relation catch, which logs and skips the drop. Forced with a lock timeout (a plain REVOKE SELECT + /// does not work here — connects as a superuser, which bypasses grants): + /// a second connection holds ACCESS EXCLUSIVE on the legacy relation in an open transaction, and the + /// judged connection carries a 1-second lock_timeout, so its EXISTS read against the legacy + /// view times out server-side. + [Fact] + public async Task LegacyProbeFails_LegacySurvives_AgainstDevPostgres() + { + 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 #4292 probe-error 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 #4292 probe-error test needs TimescaleDB."); + await TimescaleSupport.ConvertToHypertablesAsync(connection, null, ct); + Assert.True(await TimescaleSupport.EnsureCollectionLogHypertableAsync(connection, null, ct)); + + /* Stopped for the same reason as the sibling test above: a policy firing mid-test must never be able + to explain an unexpected lock wait or row. */ + await using (var stop = new NpgsqlCommand("SELECT _timescaledb_functions.stop_background_workers()", connection)) + { + await stop.ExecuteNonQueryAsync(ct); + } + + await TimescaleSupport.EnsureContinuousAggregatesAsync(connection, null, ct); + + const string legacy = TimescaleSupport.LegacyWaitStatsBaselineView; + const string successor = TimescaleSupport.WaitStatsIntervalBaselineView; + + await using (var create = new NpgsqlCommand(TimescaleSupport.LegacyCreateWaitStatsBaselineSql, connection) { CommandTimeout = 120 }) + { + await create.ExecuteNonQueryAsync(ct); + } + + await using var locker = new NpgsqlConnection(scratch.ConnectionString); + await locker.OpenAsync(ct); + await using var lockTransaction = await locker.BeginTransactionAsync(ct); + await using (var lockCommand = new NpgsqlCommand($"LOCK TABLE collect.{legacy} IN ACCESS EXCLUSIVE MODE", locker, lockTransaction)) + { + await lockCommand.ExecuteNonQueryAsync(ct); + } + + await using (var setLockTimeout = new NpgsqlCommand("SET lock_timeout = '1s'", connection)) + { + await setLockTimeout.ExecuteNonQueryAsync(ct); + } + + /* The lock blocks the has-rows probe, which times out at 1s and (correctly, today) propagates all the + way out of DropRetiredBaselineAggregatesAsync's per-relation try, so the drop statement it guards is + never attempted and this returns almost immediately -- dropped stays 0. Released 1.5s in regardless, + on its own timer: a REGRESSION this test must still be able to catch -- a catch inside + JudgeSupersededBaselineRelationAsync that swallows the probe error and answers "empty" -- reaches + that same drop statement, which would otherwise sit behind the SAME lock and time out too, hiding the + regression behind a false "dropped stayed 0". Releasing on a fixed timer, not after the drop + completes, lets a wrongly-attempted drop actually succeed once unblocked, so it shows up as dropped=1. */ + var dropTask = TimescaleSupport.DropRetiredBaselineAggregatesAsync(connection, null, DateTime.UtcNow, ct); + var releaseTask = Task.Run(async () => + { + await Task.Delay(TimeSpan.FromSeconds(1.5), ct); + await lockTransaction.RollbackAsync(ct); + }, ct); + + Assert.Equal(0, await dropTask); + await releaseTask; + + Assert.True(await RelationExistsAsync(connection, legacy, ct), "the legacy aggregate must survive a failed probe"); + Assert.True(await RelationExistsAsync(connection, successor, ct), "the successor must stay regardless"); + } + + private static async Task HasRowsAsync(NpgsqlConnection connection, string view, System.Threading.CancellationToken ct) + { + using var probe = new NpgsqlCommand(TimescaleSupport.BaselineRelationHasRowsSql(view), connection) { CommandTimeout = 120 }; + return await probe.ExecuteScalarAsync(ct) is true; + } + + private static async Task RelationExistsAsync(NpgsqlConnection connection, string view, System.Threading.CancellationToken ct) + { + using var probe = new NpgsqlCommand(TimescaleSupport.BaselineRelationExistsSql(view), connection) { CommandTimeout = 120 }; + return await probe.ExecuteScalarAsync(ct) is true; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs b/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs index 5c9ff8e64e..84c1cfda58 100644 --- a/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs +++ b/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs @@ -436,8 +436,8 @@ reconstruct exactly. */ /// /// Baseline relations SUPERSEDED by an interval-honest successor (#3653, A6): each legacy aggregate is - /// dropped only once its successor COVERS the baseline tier, and - /// evaluates that condition on every service start until it holds. + /// dropped only once its successor COVERS the baseline tier, or it holds no rows (#4289), and + /// evaluates that condition on every service start until it holds. /// /// Why a condition and not the #2007 list. drops on /// sight, which was right for aggregates nothing read. These two ARE read: they hold up to @@ -477,6 +477,15 @@ reconstruct exactly. */ /// Once the fleet has passed the condition these names can move to /// as pure hygiene (so no future aggregate reuses them); nothing depends on that move, because a dropped /// legacy relation is never re-created — the ensure sweep no longer knows its name. + /// + /// The wait is for HISTORY, and an empty legacy aggregate has none (#4289). A store where + /// nothing ever fed the legacy aggregate's source (no wait_stats or perfmon_stats rows for + /// this collector) leaves it holding zero rows forever, and its successor — backfilled from the same empty + /// source — empty too. The coverage condition above can never resolve against a NULL successor + /// oldest bucket, so without a separate check the legacy relation, its refresh job, its retention job and + /// its compression job would all outlive every store that has data. + /// therefore asks whether the legacy relation holds any rows of its own BEFORE it asks about coverage: an + /// empty one drops on sight, whatever the successor holds, because there is nothing in it to protect. /// public static readonly (string Legacy, string Successor)[] SupersededBaselineRelations = { @@ -1088,8 +1097,8 @@ IF is_continuous_aggregate /// Returns how many relations were actually dropped. /// /// Since #3653 the same pass also walks , dropping each - /// legacy relation ONLY when its successor covers the baseline tier (see - /// ). The two lists ride one sweep because they need the + /// legacy relation once its successor covers the baseline tier, or once the legacy holds no rows of its + /// own (#4289) — see . The two lists ride one sweep because they need the /// same connection, the same ordering against the ensure (before it) and the same failure isolation; the /// worker's call site is unchanged. This overload takes the host clock itself, which is what the /// production call site wants; the tests pass a clock to walk the condition across time. @@ -1140,11 +1149,13 @@ public static async Task DropRetiredBaselineAggregatesAsync( dropped++; logger?.LogInformation( - "Dropped superseded baseline relation {Legacy} (#3653) — its interval-honest successor {Successor} {Reason}, so nothing reads it any more; its refresh, retention and compression jobs went with it.", - legacy, successor, + "Dropped superseded baseline relation {Legacy} (#3653) — {Reason}, so nothing reads it any more; its refresh, retention and compression jobs went with it.", + legacy, verdict.LegacyIsContinuousAggregate - ? $"covers the baseline tier (from {verdict.SuccessorOldest:O}, horizon {verdict.Horizon:O})" - : "exists and, like this plain view, reads raw directly"); + ? verdict.LegacyHoldsRows + ? $"its interval-honest successor {successor} covers the baseline tier (from {verdict.SuccessorOldest:O}, horizon {verdict.Horizon:O})" + : "it held no rows through its own view (#4289) — an empty aggregate has no baseline history to protect, whatever its successor holds" + : $"its interval-honest successor {successor} exists and, like this plain view, reads raw directly"); } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -1201,30 +1212,40 @@ public enum SupersededBaselineDecision /// so this is the expected verdict of that one start; the next start finds the successor. SuccessorAbsent, - /// The legacy relation is a continuous aggregate and the successor's oldest bucket is later than - /// the tier horizon (or the successor is empty) — the legacy still holds history the successor lacks. + /// The legacy relation is a continuous aggregate that holds rows of its own, and the successor's + /// oldest bucket is later than the tier horizon (or the successor is empty) — the legacy still holds + /// history the successor lacks. SuccessorShort, - /// Drop: the successor covers the tier, or the legacy relation is a plain view (nothing of its - /// own to lose). + /// Drop: the successor covers the tier, the legacy relation is a plain view (nothing of its + /// own to lose), or the legacy is a continuous aggregate that holds no rows of its own (#4289 — it has + /// no history to protect, whatever the successor holds). Drop, } - /// The superseded pass's evidence for one legacy relation: the decision plus the three facts it - /// rests on, so the log line can show its working and the live test can assert each fact. + /// The superseded pass's evidence for one legacy relation: the decision plus the four facts it + /// rests on, so the log line can show its working and the live test can assert each fact. + /// is meaningful only when is + /// true (#4289) — a plain view is never asked, so it reads true (the neutral, non-triggering + /// value) on that path and on / + /// , where nothing asked it either. public readonly record struct SupersededBaselineVerdict( SupersededBaselineDecision Decision, bool LegacyIsContinuousAggregate, + bool LegacyHoldsRows, DateTime? SuccessorOldest, DateTime Horizon); /// - /// THE RETIREMENT CONDITION (#3653), as a pure predicate so the tests can walk it across time without a - /// store: a legacy CONTINUOUS AGGREGATE drops once its successor's oldest bucket is at or before the tier - /// horizon ( minus ); a legacy PLAIN VIEW drops as - /// soon as a successor exists ( is not consulted — a raw-backed view has - /// nothing of its own to hand over). An EMPTY successor (null oldest) never releases a legacy - /// aggregate: an un-backfilled successor covers nothing, whatever the clock says. + /// THE RETIREMENT CONDITION (#3653, #4289), as a pure predicate so the tests can walk it across time without + /// a store: a legacy CONTINUOUS AGGREGATE that holds no rows of its own ( + /// false) drops on sight, whatever the successor holds — it has no history to protect. A legacy CONTINUOUS + /// AGGREGATE that DOES hold rows drops once its successor's oldest bucket is at or before the tier horizon + /// ( minus ); an EMPTY successor (null + /// oldest) never releases it: an un-backfilled successor covers nothing, whatever the clock says. A legacy + /// PLAIN VIEW drops as soon as a successor exists ( and + /// are both ignored — a raw-backed view has nothing of its own to hand + /// over, empty or not). /// /// Why the TIER horizon and not the provider's 30-day window. The window is the weaker sufficient /// condition — once the successor reaches it, nothing reads the legacy relation — but "covers the tier" is @@ -1240,21 +1261,28 @@ public readonly record struct SupersededBaselineVerdict( /// on any start is enough. <= rather than < so a bucket that lands exactly on the horizon /// counts as coverage. /// - public static bool SupersededBaselineRelationDropsAt(bool legacyIsContinuousAggregate, DateTime? successorOldest, DateTime utcNow) + public static bool SupersededBaselineRelationDropsAt(bool legacyIsContinuousAggregate, bool legacyHoldsRows, DateTime? successorOldest, DateTime utcNow) { if (!legacyIsContinuousAggregate) { return true; } + if (!legacyHoldsRows) + { + return true; + } + return successorOldest is DateTime oldest && oldest <= utcNow - BaselineRetentionSpan; } /// - /// Reads the three facts needs off the store and returns the - /// verdict. Four small catalog reads at most, once per start; the only one that touches data is the - /// successor's min(bucket), the same probe already runs for - /// every registered aggregate on every start. + /// Reads the four facts needs off the store and returns the + /// verdict. Five small catalog reads at most, once per start; the two that touch data are the legacy's own + /// EXISTS probe (, #4289) and the successor's min(bucket) + /// — the same probe already runs for every registered aggregate on + /// every start — and the second runs ONLY when the first found rows: an empty legacy aggregate drops before + /// the coverage read, so that read (and the query plan it costs) never fires for it. /// /// The continuous-aggregate question is asked in two statements on purpose: /// timescaledb_information does not exist on a store that never created the extension, and a @@ -1277,7 +1305,7 @@ public static async Task JudgeSupersededBaselineRelat { if (await probe.ExecuteScalarAsync(cancellationToken) is not true) { - return new SupersededBaselineVerdict(SupersededBaselineDecision.LegacyAbsent, false, null, horizon); + return new SupersededBaselineVerdict(SupersededBaselineDecision.LegacyAbsent, false, true, null, horizon); } } @@ -1285,7 +1313,7 @@ public static async Task JudgeSupersededBaselineRelat { if (await probe.ExecuteScalarAsync(cancellationToken) is not true) { - return new SupersededBaselineVerdict(SupersededBaselineDecision.SuccessorAbsent, false, null, horizon); + return new SupersededBaselineVerdict(SupersededBaselineDecision.SuccessorAbsent, false, true, null, horizon); } } @@ -1296,18 +1324,30 @@ public static async Task JudgeSupersededBaselineRelat legacyIsContinuousAggregate = await probe.ExecuteScalarAsync(cancellationToken) is true; } - DateTime? successorOldest = null; + /* #4289: asked BEFORE the coverage read, and only for a CAGG — a plain view has nothing of its own to + lose either way, so SupersededBaselineRelationDropsAt never consults this for one. */ + var legacyHoldsRows = true; if (legacyIsContinuousAggregate) + { + using var probe = new NpgsqlCommand(BaselineRelationHasRowsSql(legacy), connection) { CommandTimeout = SetupTimeoutSeconds }; + /* is not false, not is true: only a definite false answer means empty. A later edit that lets this + read return null (a swallowed error, or SQL changed to return no row) must NOT read as "empty" + and drop a legacy that holds rows -- the safe default for an unrecognized answer is "holds rows". */ + legacyHoldsRows = await probe.ExecuteScalarAsync(cancellationToken) is not false; + } + + DateTime? successorOldest = null; + if (legacyIsContinuousAggregate && legacyHoldsRows) { using var probe = new NpgsqlCommand(BaselineCoverageOldestSql(successor), connection) { CommandTimeout = SetupTimeoutSeconds }; successorOldest = await probe.ExecuteScalarAsync(cancellationToken) is DateTime oldest ? oldest : null; } - var decision = SupersededBaselineRelationDropsAt(legacyIsContinuousAggregate, successorOldest, utcNow) + var decision = SupersededBaselineRelationDropsAt(legacyIsContinuousAggregate, legacyHoldsRows, successorOldest, utcNow) ? SupersededBaselineDecision.Drop : SupersededBaselineDecision.SuccessorShort; - return new SupersededBaselineVerdict(decision, legacyIsContinuousAggregate, successorOldest, horizon); + return new SupersededBaselineVerdict(decision, legacyIsContinuousAggregate, legacyHoldsRows, successorOldest, horizon); } /// Is this baseline relation a CONTINUOUS AGGREGATE (as opposed to the plain fallback view a @@ -1322,6 +1362,16 @@ public static string BaselineRelationIsContinuousAggregateSql(string view) public static string BaselineCoverageOldestSql(string view) => $"SELECT min(bucket) FROM collect.{view}"; + /// Does this baseline relation hold any rows through its own view (#4289)? Read through the VIEW + /// rather than the materialization hypertable, so on a legacy aggregate's timescaledb.materialized_only = + /// false shape this sees the same real-time-aggregated rows a reader — + /// included — would see, not just what has materialized so far. + /// asks this ONLY of a legacy relation already known to be a CONTINUOUS AGGREGATE, before it ever reads the + /// successor's coverage: an empty legacy aggregate holds no history to protect, so the successor's coverage + /// is moot and the drop does not wait on it. + public static string BaselineRelationHasRowsSql(string view) + => $"SELECT EXISTS (SELECT 1 FROM collect.{view})"; + /// The raw table each baseline aggregate is sourced from, for the backfill's coverage probe. public static string SourceTableFor(string view) => view switch {