Repository navigation
Make the catalog sweep honor the #1680 coverage gate (#1784) - #1793
Merged
Merged
Conversation
Two purges drop the same raw chunks and only one was gated. The tiered policy is created paused and armed once the hourly aggregate covers the table; the catalog sweep then dropped those same chunks at the per-collector 30-day horizon with no coverage check, destroying exactly the rollup-uncovered history the gate was holding the policy to protect. Field fuse: raw from ~Jul 9, coverage from ~Jul 24, uncovered slice dying from ~Aug 8. Both paths now consult the same predicate through one shared raw-tier map, so they cannot judge the same drop differently. BINARY rather than a clamped cutoff, deliberately: drop_chunks removes only the OLDEST chunks, and when coverage lags those ARE the uncovered ones, so no cutoff drops the covered tail while sparing the uncovered head. The ruled min(catalog horizon, coverage floor) clamp loses Jul 9-21 at Aug 20, and in the healthy case clamps to the coverage floor and never drops anything at all. Tiered tables are not excluded outright either -- the normal horizon applies again the moment coverage reaches back, so a never-armed policy cannot mean unbounded growth. The skip also sets dimFeedingPurgeFailed: a skipped table's rows persist past their horizon, which is precisely the dimension-GC hazard #1782 guards. Both halves pinned and watched red -- guard removed drops the uncovered row, guard forced always-skip stops the backstop backstopping. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI caught two real defects a local run could not, both of my own making. 1) The coverage test called EnsureContinuousAggregatesAsync on the SHARED live store. Creating aggregates is global state -- it changes compose's tier routing and makes EnsureBaselineFallbackViewsAsync a no-op -- which broke DarlingAnomalyBaselineTests (expected 9 views created, got 0). That test's own comment warns about exactly this and points at the snapshot/restore machinery TimescaleSupportTests owns; the coverage test now uses the same pattern, dropping only the aggregates it created. 2) PurgeAsync is fleet-wide and destructive, so a bare call in a test deletes any sibling class's rows past their horizon. Both new tests now pass a resolver that hands every other collector a decade of retention, making the purge surgical while still exercising the real sweep. Also fixes the #1782 test's control phase for this branch: since the sweep now SKIPS an uncovered raw table, and that skip sets the same dim-feeding-purge- failed flag, a healthy purge means covered as well as succeeding -- otherwise the GC defers for the other reason and the control proves nothing. The resolver needed a second form, and the first one was a trap worth naming: handing ONE dim-feeding table a decade pushes the dimension GC's cutoff (the WIDEST of them plus a margin) out by a decade too, so the GC prunes nothing and the test passes for entirely the wrong reason. PurgeDimFeeding keeps every dim-feeding table on the same horizon. Mutation re-verified after isolation -- guard removed still drops the uncovered row (Expected 1, Actual 0), so the isolation did not weaken the pin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…clamp # Conflicts: # CHANGELOG.md
…clamp # Conflicts: # CHANGELOG.md
…uard' into feature/1784-coverage-clamp # Conflicts: # CHANGELOG.md
…uard' into feature/1784-coverage-clamp
…uard' into feature/1784-coverage-clamp
Review found a real isolation leak in both new tests: the shared-fixture mutation happened where a throw would skip the restore. - CatalogSweep_: EnsureContinuousAggregatesAsync ran BEFORE the try whose finally restores it. - DimensionGc_: coverageSnapshot was assigned INSIDE the try, from a helper that captured the snapshot itself -- so the restore was conditional on that helper RETURNING, which is exactly the window in which it creates aggregates. Either way a mid-setup failure leaks all eight rollup aggregates into the shared store, and the damage lands on OTHER test classes: creating aggregates changes compose's tier routing and makes EnsureBaselineFallbackViewsAsync a no-op, so they fail looking like regressions in code that never changed. The review chased two of those before finding the source. Both now snapshot BEFORE the try and restore unconditionally; the coverage helper no longer owns the snapshot, because owning it is what made the restore conditional. Restoring against a snapshot when nothing was created is a no-op, so the unconditional form is strictly safer. Also: IsTieredDropSafeAsync opened a pooled connection for EVERY catalog table, including the thirty-odd non-tiered ones whose answer is an unconditional true. It now tests RawTierCoverage membership first, through a shared helper so the rule is not duplicated. Verified by running the full suite TWICE against the same database: 3,663 then 3,665 passing, no residue. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Jul 28, 2026
erikdarlingdata
added a commit
that referenced
this pull request
Jul 31, 2026
…in the CAGG chain (#1849) The hourly/daily Query Store rollups materialized sum(execution_count) from un-deduped raw rows, so one interval's work was baked in once per COLLECTION. Tier 2 (#1853) fixed every raw-tier read and could not reach these: the duplicates are gone once materialized and the rollup output carries no interval identity to key on. A Custom Views panel inside the raw tier read corrected numbers; past it, inflated ones, with a visible step at the routing boundary. Three NEW aggregates carry the corrected numbers, alongside the old pair rather than replacing it (a CAGG cannot be reshaped in place, and rebuilding would destroy 21 days of hourly and all daily history - #1759/#1793): query_store_stats_interval_hourly L1, interval grain, last(x, collection_time) query_store_stats_corrected_hourly composer grain, identity-width off L1 query_store_stats_corrected_daily composer grain, 1-day bucket off L1 Column names are identical to the old pair, so ComposeCaggValueMapper and every composed panel read them unchanged. Measured on a seeded store: one interval re-collected 496 times reports 506 where the old rollup reports 123,311. The shape is forced by what TimescaleDB accepts, live-probed on PG 18.4 / TimescaleDB 2.28.1. An identity-width hierarchical CAGG is a LEAF - nothing can be built on a child whose bucket equals its parent's width, though a plain 1h -> 1d -> 7d chain is fine - so the corrected daily is a SIBLING of the corrected hourly sourced from L1, where every other daily here is built on its hourly. That constraint is not in #1849; it was found building this. Arming: query_store_stats now has two rollup families, so RawTierCoverage's coverage became a LIST and the #1680 gate an AND over all of them. Raw stays paused until both reach back over everything it holds. Evaluated in the reader, not folded into GREATEST, which skips NULLs and would let an empty new rollup vanish from the comparison. Routing: ComposeCaggInfo gains the superseded pair. Corrected wins wherever its coverage reaches; beyond it reads fall back to the old pair, comparatively (only where it measurably reaches further back), so a backfilled store never reads inflated. Availability is settled before coverage - a store whose service predates this routes to the old pair exactly as before, which is why this needs no migration and no viewer version gate. Backfill: RollupViews now carries BucketWidth explicitly. "Hierarchical" and "daily" used to be the same fact and the backfill inferred grain from the source column; the corrected hourly is the first hierarchical rollup that is not a daily, and inferring would have given it a 24x-too-wide bucket - an under-estimate, the one direction the disk preflight exists to prevent. Capacity settled before shipping (#1581): the interval layer is near-raw cardinality, so it takes a short 7-day horizon rather than the 21-day one - 79 MB against 238 MB projected on a 600-query store at the default cadence. Verified against live PostgreSQL 18.4 + TimescaleDB 2.28.1, watched red first: removing L1 from the arming gate arms a purge over history nothing holds; last() -> sum() in L1 reports 123,256; dropping or inverting the legacy fallback each fails a named routing test. Darling 3848 passed / 0 failed, Lite 1728 / 0, full-solution rebuild 0 warnings. Filed rather than guessed: #1869, the daily tier's ~2x hour-straddle residual and the fourth near-raw-cardinality aggregate that would remove it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ianwalkeruk
pushed a commit
to ianwalkeruk/PerformanceMonitor
that referenced
this pull request
Jul 31, 2026
…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 erikdarlingdata#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 erikdarlingdata#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: erikdarlingdata#1905 (the horizon ordering is asserted by hand, not derived from the policy list) and erikdarlingdata#1906 (a re-hold reads the same as a first-time hold in the log). Closes erikdarlingdata#1877 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 #1784. Stacked on #1789 — see "Stacking" below.
Two purges drop the same raw chunks, and only one honored the #1680 coverage gate.
DarlingRetention.PurgeAsync's catalog sweep then dropped those very chunks at the per-collector 30-day horizon, with no coverage check at all.So on a store where the gate is deliberately holding the policy — because the rollup does not reach back over raw's history — the sweep destroyed exactly the uncovered history the gate exists to protect. The gate was not bypassed by a bug in the gate; it was bypassed by an older path that never learned the invariant.
Field fuse on a production instance: raw from ~Jul 9, coverage from ~Jul 24, so the uncovered Jul 9–24 slice begins dying ~Aug 8 unless a backfill extends coverage first — and the product must not depend on an operator running a verb in time.
Both purges now consult the SAME predicate, reached through one shared map of raw tier → covering aggregate (
TimescaleSupport.RawTierCoverage), so they cannot judge the same drop differently. The policy setup was refactored onto that map rather than keeping its own copy.The ruled clamp shape was superseded — accepted, and why
The originally-ruled fix was "clamp the tiered-table cutoff to
min(catalog horizon, rollup coverage floor)". That ruling has been reviewed and withdrawn in favour of the binary gate shipped here; this section records the argument so the decision is auditable rather than folklore.The choice is binary by structure, not by preference.
drop_chunksremoves the OLDEST chunks first. When rollup coverage lags, the uncovered prefix IS the oldest data. So there is no cutoff value that drops the covered tail while sparing the uncovered head — the operation is all-or-nothing, and any clamp on the cutoff is expressing a choice the mechanism cannot make.That is why
min(horizon, floor)fails in BOTH directions, which is the tell that the shape was wrong rather than the value:The ruled fix was "clamp the tiered-table cutoff to
min(catalog horizon, rollup coverage floor)". I implemented a binary gate instead, because the clamp loses data in one direction and disables the backstop in the other. Worked against the field numbers:min(catalog horizon, coverage floor)min(Jul 21, Jul 24)= Jul 21min(now−30d, coverage_oldest)= coverage_oldest, which is ≤ the oldest rowThe binary rule is the one the arming gate already uses (
coverage_oldest <= source_oldest), and it satisfies every requirement that was set: no-op while coverage lags, self-healing the moment a backfill lands, tiered tables never permanently excluded. Reusing the identical predicate through the ONE sharedRawTierCoveragemap is also what makes the two purge paths constitutionally unable to judge the same drop differently — a clamp would have been a second, differently-shaped rule for the same decision, free to drift from the gate it is supposed to honour.Not an outright exclusion of tiered tables, deliberately: a never-armed policy would then mean genuinely unbounded growth. The moment coverage reaches back, the normal 30-day horizon applies again.
Verification — both halves, both watched red
A guard that never drops anything would satisfy the "uncovered survives" half on its own, so the backstop is pinned too. Against a live PostgreSQL 18.4 + TimescaleDB store: a 45-day-old row with the aggregate refreshed only over the recent window (the field state — a rollup that starts later than raw), then the same row with the aggregate force-refreshed back over it.
Assert.Equal() Failure: Expected: 1, Actual: 0. The uncovered 45-day row was dropped: the #1784 data loss, reproduced.Assert.Equal() Failure: Expected: 0, Actual: 1. The row survived when coverage was sufficient: the backstop stopped backstopping.Data assertions come before log assertions in both halves, so a mutation lands on the data rather than on missing log text.
Field signature
While the sweep defers on an uncovered tiered table, per cycle, per affected table, at Warning:
Because a skipped table's rows persist past their horizon, this also trips #1789's guard in the same sweep, so the box shows both lines together:
That second line names the observable STATE rather than any cause, precisely because of this PR: it now has two trigger classes — a failed purge statement, and this PR's deliberate coverage SKIP — and naming either would be a lie in the other case. The per-table line directly above it carries which table and which of the two.
After a backfill extends coverage past raw's oldest row, the operator-visible change is a DISAPPEARANCE, not an announcement — worth stating plainly in the client prompt so their agent does not hunt for a success line that does not exist. On the next cycle: both warnings above stop;
Retention purge: {Tables} table(s) purged, …, 0 failedshows a higher table count; and the startupRetention policy for query_stats created but HELD PAUSED …line stops for that relation as the policy arms itself with no manual step.Coupling to #1789, wired here rather than deferred
A coverage-skipped table's rows persist past their horizon — which is exactly the dimension-GC hazard #1789 guards, where the GC's cutoff assumes they are gone. The skip therefore also sets
dimFeedingPurgeFailed, so the dimension GC defers in the same cycle. That interaction is why this is stacked rather than branched fromdev: ondevthe flag does not exist yet, and shipping the skip without it would open the dangling-digest window #1789 exists to close.Stacking: branched from
feature/1782-dim-gc-held-purge-guard. Reviewgit diff origin/feature/1782-dim-gc-held-purge-guard..HEAD— four files, the same set listed below. If the gauntlet would rather have these independent, I can rebase ontodevand move the one-line coupling into whichever merges second; I chose correctness-on-both-branches over review independence and am flagging the choice rather than assuming it.Two defects found in my own work by the repo's own guard
DocCommentHygieneTests.NoMemberCarriesTwoStackedSummaryBlocks(the #1751 pin) went red twice on this branch. Both times my insertion pushed an existing<summary>block away from the member it documented —IsSafeToArmRetentionAsyncinTimescaleSupport.cs, andPurgeOneAsyncinDarlingRetention.cs— leaving each member undocumented and its doc stranded above my new one. Per that test's own failure text, both were fixed by moving the block back to its member, not by deleting it. Worth noting because it is the exact class the pin was written for, caught on the first run after the edit.Known interaction — ruled SHIP-WITH-ISSUE, tracked as #1795
query_statsandprocedure_statsare both coverage-gated tiers (#1784) and dim-feeding tables (#1782), so those two facts compose: the clamp's skip sets the deferral flag, and on a coverage-lagging store — the field box today — the dimension GC defers on every sweep until a backfill lands. Proven by execution, not inference: a 400-day-old orphan dimension row survives the sweep with nothing failed anywhere.Shipping it is the right call. The deferral is logically correct — the clamp deliberately preserves those fact rows, so pruning their content would dangle the digests and serve NULL payload for data that is present; the GC's premise is genuinely false while the clamp holds. The cost is recoverable disk that self-corrects at backfill (#1788 extends coverage, the clamp opens, the GC resumes with no operator step), dimensions grow by distinct content only so the accretion is small, and the alternative was the ~Aug 8 data loss this PR exists to prevent. Data loss beats disk growth.
The proper closure — pruning against the oldest SURVIVING digest-carrying fact rather than an assumed horizon, which stays bounded even while the clamp holds — is filed as #1795, non-release-gating.
Build and test
-t:Rebuild, 0 Warning(s) 0 Error(s):PerformanceMonitor.Darling.Storage,PerformanceMonitor.Darling.Service,Darling.Tests.Unarmed pending the gauntlet.
Generated with Claude Code