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
{