Skip to content

Make the catalog sweep honor the #1680 coverage gate (#1784) - #1793

Merged
erikdarlingdata merged 11 commits into
devfrom
feature/1784-coverage-clamp
Jul 28, 2026
Merged

erikdarlingdata merged 11 commits into
devfrom
feature/1784-coverage-clamp

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Jul 28, 2026 •

Copy link
Copy Markdown
Owner

Closes #1784. Stacked on #1789 — see "Stacking" below.

Two purges drop the same raw chunks, and only one honored the #1680 coverage gate.

  1. The tiered policy is created paused and armed only once the hourly aggregate covers everything the table holds.
  2. 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_chunks removes 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:

Case min(catalog horizon, coverage floor) Outcome
Coverage lagging, Aug 20 min(Jul 21, Jul 24) = Jul 21 drops Jul 9–21 — every row uncovered
Fully covered (healthy) min(now−30d, coverage_oldest) = coverage_oldest, which is ≤ the oldest row drops nothing, ever — the backstop stops backstopping

The 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 shared RawTierCoverage map 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.

Mutation Result
Coverage guard removed RED — Assert.Equal() Failure: Expected: 1, Actual: 0. The uncovered 45-day row was dropped: the #1784 data loss, reproduced.
Guard forced always-skip RED — 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:

Retention purge SKIPPED for query_stats: its rollup does not yet cover the oldest rows, so dropping would delete history no aggregate holds. Resumes by itself once a backfill extends coverage.

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:

dimension GC deferred: a dim-feeding table kept rows past its horizon this cycle; dimension content is retained until its purge completes

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 failed shows a higher table count; and the startup Retention 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 from dev: on dev the 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. Review git 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 onto dev and 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 — IsSafeToArmRetentionAsync in TimescaleSupport.cs, and PurgeOneAsync in DarlingRetention.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_stats and procedure_stats are 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.
  • Darling.Tests against live PostgreSQL 18.4 + TimescaleDB 2.28.1 on a fresh database: 3,650 passed / 0 failed / 10 skipped.

Unarmed pending the gauntlet.

Generated with Claude Code

erikdarlingdata and others added 3 commits July 28, 2026 00:23
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>
erikdarlingdata and others added 8 commits July 28, 2026 00:41
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…uard' into feature/1784-coverage-clamp

# Conflicts:
#	CHANGELOG.md
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>
@erikdarlingdata
erikdarlingdata merged commit bb53a0e into dev Jul 28, 2026
4 checks passed
@erikdarlingdata
erikdarlingdata deleted the feature/1784-coverage-clamp branch July 28, 2026 05:31
erikdarlingdata added a commit that referenced this pull request Jul 28, 2026
dev's #1793/#1789 moved TimescaleSupport.cs only in the retention/arming
region (~1290-1425); verified no touch to the compression, job_stats, or
last_run_started_at paths, so unlike the previous merge there is no seam to
reconcile. CHANGELOG resolved keep-both with dev's ordering untouched.
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant