Repository navigation
Retention gate: re-hold a policy whose coverage list grew, on a measured shortfall only (#1877) - #1909
Merged
Conversation
…hortfall only The arming gate could only ARM. add_retention_policy(if_not_exists => true) returns -1 for a policy the store already has, so nothing paused one whose coverage list had GROWN under it -- and #1869 was the first build to hand a new consumer to a gate stores had already armed. Such a store logged HELD PAUSED for the interval layer on every start and kept purging it anyway, capping how deep the new day-grain daily could ever be backfilled. The probe now answers Covered / Short / Unknown instead of a bool, and only a positive Short measurement may stop a running policy. Unknown -- a throw, a timeout, an empty result -- leaves the policy exactly as it was: a new one stays paused as before, an armed one keeps purging. That is what makes this shippable where "unsafe implies disarm" was not: the probe is deliberately fail-closed, so a disarm-capable gate reading the old bool would let one bad probe stop purging across every tier and grow disk without bound. A consumer that exists and holds no rows is a measurement, not an unknown, which is exactly the state a newly-added consumer is born in. Release is the existing arming path, unchanged, so this is a gate and not a latch. IsRawTierDropSafeAsync collapses Short and Unknown to "not safe" exactly as before, keeping the #1793 shared-predicate property -- and for the three raw tiers this removes a case where the two purge paths could already disagree, since an armed policy used to keep dropping chunks the catalog sweep was refusing to touch. Proved live on PostgreSQL 18.4 / TimescaleDB 2.28.1: a store with L1 armed and every consumer caught up, handed a rebuilt empty consumer, re-holds on the next sweep and re-arms itself once the consumer is backfilled; and a genuinely missing relation neither disarms the armed policy nor changes any other policy's verdict in the same sweep. Filed rather than folded in: #1905 (the horizon ordering is asserted by hand, not derived from the policy list) and #1906 (a re-hold reads the same as a first-time hold in the log). Closes #1877 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ow-hold # Conflicts: # CHANGELOG.md
ReviewScope: Darling-only (TimescaleDB retention gate in Correctness
Security
Tests / docs
Nothing blocking found. Nice, thorough piece of work — the fail-closed/measured-shortfall distinction is the right fix for the described problem, and the tests actually pin the failure modes the PR claims to guard against. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1877.
The gap
EnsureRetentionPoliciesAsynccould only ARM. It creates each policy paused, evaluates coverage, and arms when the verdict is safe; when the verdict is unsafe it logsHELD PAUSEDand moves on — butadd_retention_policy(if_not_exists => true)returned-1for the already-existing policy, so nothing paused it and an already-armed policy stayed armed.That is right for a restart (#1680/#1759: re-pausing on every start would undo an operator's backfill). It was wrong for a policy whose coverage list GREW, which did not exist as a case until #1869 added a third consumer to a gate stores had already armed under #1849. Those stores log the hold every start and keep purging, which caps how deep the new day-grain daily can ever be backfilled at the interval layer's 7 days rather than at the store's own history.
Option chosen, and why
The issue offered three. This implements the first — disarm only on a positive measurement, never on an indeterminate verdict — because it is the only one of the three that satisfies all of the decision rule. The third (WARN at startup naming
--backfill-rollups) does not re-hold at all, so the purge keeps outrunning the new tier while the operator reads the warning. The second (record the coverage list a policy was armed against) re-holds, but needs schema/state, and re-holds on a list CHANGE rather than on a coverage gap — so it would fire on a store where the new consumer was already caught up, and would miss a gap that arose any other way.Option 1 needs no new state. The probe already had all three answers in it; they were being collapsed into a bool at the return statement.
RetentionCoverageisCovered/Short/Unknown:That third value is the whole fix. The issue's own argument against "unsafe implies disarm" is that the probe is deliberately fail-closed, so one bad probe on a busy store would stop purging across every tier at once and grow disk until someone noticed. Separating the failed measurement from the measured shortfall is what removes that, and nothing else in the change would work without it.
The release path is the existing arming path, untouched — a re-held policy arms itself on the first start after the backfill reaches, with no manual step, exactly as a first-time hold has always released. So it is a gate, not a latch.
What it does to the #1793 property
Nothing, by construction:
IsRawTierDropSafeAsynccollapsesShortandUnknownback to "not safe", which is byte-for-byte the old behaviour, so both purge paths still judge the same drop through the same map. It actually removes a case where they could disagree — for the three raw tiers, an armed policy used to keep dropping the very chunks the catalog sweep was refusing to touch.Verification (live, PostgreSQL 18.4 / TimescaleDB 2.28.1)
Two new live tests in
QueryStoreCorrectedRollupLiveTests, both against a scratch store.The #1877 scenario end to end. Nine days of raw Query Store history, every consumer materialized over all of it, sweep → L1's purge armed and running (the state the arm-only gate could not reach). Then the coverage list grows the way #1869 grew it: the consumer is DROPped and rebuilt
WITH NO DATA, CASCADEing to the day-grain daily, and the ensure sweep recreates both — the real upgrade shape, not a simulation of it. Next sweep: re-held. Backfill the consumer past L1's oldest bucket, next sweep: armed again, by itself.The fail-closed property. A genuinely missing relation makes the probe throw 42P01 rather than measure — the strongest available stand-in for the timeout the rule exists for. Three assertions, because a hold-nothing implementation would pass the first alone: the armed policy whose probe threw stays armed, a different policy (
query_stats, an unrelated rollup family) is still judged on its own merits in the same sweep, and a policy that is not yet armed when its probe throws stays paused — the original fail-closed direction, which the tristate must not have inverted. Plus a direct assertion thatIsRawTierDropSafeAsyncstill answers "not safe" on an unreadable state.Watched-red, per mutation:
catchreturnsShortinstead ofUnknownThree consecutive clean runs of the class after restoring, so the new tests are not load-sensitive.
Suites: Darling 3988 passed / 0 failed / 9 skipped with the live rig on; Lite 1849 passed / 0 failed.
-t:Rebuildclean on Darling.Tests and Lite, 0 warnings each. No Installer.Tests.One thing measured rather than assumed
The re-hold makes the horizon ordering load-bearing in a new way: an inverted pair would now stop a purge on a healthy store instead of merely failing to arm it. Steady state is safe by construction and both halves were checked rather than recalled — every consumer outlives its source (pinned), and materialization hypertables measured at a 10-day chunk interval against the raw tables' 1 day, so a consumer never retains less than its source at a chunk boundary. (My recollection had been "10x the bucket width", which the measurement contradicted for the hourly shape.) The test that pins the ordering now says what it is protecting, and gained the raw/hourly leg it was missing.
Filed rather than folded in
🤖 Generated with Claude Code