Skip to content

Hold the raw purge on any hole in the frozen legacy's span, and fill the successor down to raw's floor (#4301) - #4401

Merged
erikdarlingdata merged 9 commits into
devfrom
fix/4301-legacy-span-buckets
Sep 26, 2026
Merged

erikdarlingdata merged 9 commits into
devfrom
fix/4301-legacy-span-buckets

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Sep 26, 2026 •

Copy link
Copy Markdown
Owner

Refs #4301.

Why

A known limit of #4186. The gate (RetentionArmSafetySql) trusted the frozen
legacy's whole span as coverage; a hole INSIDE the legacy's own span, or a
seam whose lower bound sat below the legacy's max, could read Covered
forever with no self-release, and the repair walk that was supposed to fix
it stopped at the legacy's boundary instead of reaching raw's own floor.

What changes

  • The gate's probe (RetentionArmSafetySql / LegacySuccessorHoleExistsSql)
    is bucket-level over the legacy's interior AND the seam, bounded by the
    successor's first bucket strictly ABOVE the legacy's last bucket — never
    by s.mn, which an interior repair can move below the legacy's boundary
    and hide the seam behind an inverted range.
  • Each successor's own source filter (the interval-honest predicate) is
    applied per-consumer inside the probe, not just once at the top.
  • The repair walk (RepairMaterializationHolesAsync's seam block) lowers
    its seamFloor to AlignDown(raw's own filtered floor) whenever that's
    below the successor's own floor. The walk now fills the successor
    CONTIGUOUSLY downward from its own floor, newest-first, capped per pass,
    until it reaches raw's floor — not just the tail above the legacy's
    boundary.

This replaces the issue body's "a matching repair window refreshes the successor at those hours" (see the discussion on #4301).

  • Why: stitched reads (RollupCoverage.StitchedRelationSql) split at the successor's own first bucket. They read the frozen legacy below it and the successor from it upward.
  • What isolated repairs would break: refreshing the successor at one isolated hour below the legacy's last bucket would move that first bucket down to it. The stitched read would then take every hour between from the successor, which doesn't hold them, and those rows would silently drop out of every stitched read.
  • What the fill keeps true: filling contiguously downward keeps the successor gap-free from its first bucket up, so each row is still read exactly once, and the seam is covered by the same walk.

Behaviour changes

Gaps left by earlier versions' purges below the raw floor cannot be
detected or repaired; from this version the purge holds until every
detectable hole is repaired. On a store that is not A6-backfilled, the walk
now fills the successor down to raw's floor over several passes (bounded by
MaterializationHoleRepairCapBuckets per pass), rather than stopping at the
old seam-tail-only fill.

Test plan

All live tests run in-process on a local TimescaleDB 2.30.1-pg18 container
(CREATE ROLE darling LOGIN SUPERUSER; CREATE DATABASE darling OWNER darling;, Darling.Tests.dll with the Microsoft.WindowsDesktop.App
framework entry stripped from its runtimeconfig).

FrozenRollupLiveTests (19 total, 14 pre-existing + 5 new pins), all GREEN:

  • The four earlier pins (InteriorHole_BelowLegacysLastBucket_...,
    InteriorRepairMovesSuccessorFloorBelowSeam_..., NoGapAnywhere_...,
    GapBelowRawsFilteredFloor_...) — unaffected, still pass.
  • Three pre-existing Outage_Seam* tests needed their expected counts and
    floors updated for the new contiguous-downward fill (7 buckets instead of
    2 on the two 6-hour-tail tests; 36-bucket seam split 24/12 instead of
    35/24/11 on the wide-seam test) — no assertion was loosened, only the
    numbers the new fill actually produces.
  • PIN 1 ARepairedShape_SuccessorFloorAtOrBelowRawsFloor_RepairsNothing:
    GREEN. No-op on an A6-backfilled shape.
  • PIN 2 InteriorHole_RepairedByTheWalk_ThenReportsCovered: GREEN.
    Short → repeated walk → Covered.
  • PIN 3 StitchedReadThroughTheWalk_KeepsEveryRow_AndSuccessorFloorReachesRawsFloor:
    GREEN on this branch. RED on abc8ce3c4, verified in a detached
    worktree (git worktree add --detach /tmp/pmw-4301-2-red abc8ce3c4): the
    old walk leaves the successor's floor at the seam tail (b+6h) instead of
    raw's floor (b) — Assert.Equal(2026-03-24T00:00:00, newFloor) failed
    with actual 2026-03-24T06:00:00.
  • PIN 4 CapAcrossPasses_LeavesNoGapBetweenPasses: GREEN. 31 buckets,
    cap 24: pass 1 repairs 24, pass 2 repairs the remaining 6, floors adjacent
    with no gap.
  • PIN 5 ProbeFailure_UnknownSourceState_ReportsRawPurgeNotSafe: GREEN.
    Renaming the successor CAGG mid-run (ALTER MATERIALIZED VIEW ... RENAME TO) makes the probe's query fail (undefined relation), caught as Unknown,
    which answers false — fails closed, same as a measured Short.

Also re-ran (no regressions, unaffected by this change):
TimescaleContinuousAggregateTests, MaterializationHoleRepairTests,
MaterializationHoleRepairLiveTests, RollupBackfillLiveTests,
RetentionReevaluationLiveTests — 73 passed, 3 skipped, 0 failed.

Windows suite (Darling.Tests, Lite.Tests): unchecked here — macOS build is
0 errors / 0 warnings for both projects; CI decides the Windows-only run.

Measurement

EXPLAIN (ANALYZE, BUFFERS) of RetentionArmSafetySql for procedure_stats
on the PIN 2 seed (interior hole at H2, legacy through H5, successor floor
at H6): Planning Time 1.677 ms (Planning Buffers: shared hit=276),
Execution Time 1.226 ms. Small, chunk-pruned index scans throughout
(Index Only Scan, Index Scan with Chunks excluded during runtime: 0
on the relevant ChunkAppend nodes) — no sequential scan of any hypertable's
full span.

CHANGELOG entry

SECTION: Fixed
ENTRY:

Adds an optional third window kind to MaterializationHoleScanWindows,
anchored inside a frozen legacy's own materialized span (below its
last bucket), for a pre-freeze outage hole H2 identified as invisible
to both the existing seam and ordinary windows. Purely additive:
absent callers get the unchanged two-window shape.

Pin A only (pure-function unit tests). The SQL probe
(RetentionArmSafetySql), the RepairMaterializationHolesAsync branch,
and the UNKNOWN-vs-provably-unrepairable classification are NOT in
this commit -- see the handoff note.
Adds LegacySuccessorHoleExistsSql beside MaterializationHoleScanSql: one
OFFSET-0-fenced EXISTS expression a caller can drop into any query, true
when raw admits a row in a bucket that neither the legacy nor the successor
has materialized. Intended to be shared byte-identical by
RetentionArmSafetySql (the gate) and the repair walk's interior/seam branch
so the two can never disagree about what a hole is.

WIP: nothing calls this yet. RetentionArmSafetySql is NOT yet rewired to be
bucket-level over the legacy's interior and the seam (the brief's actual
ask) and no pins exist. See lane-4301-1c.md for the handoff.
…#4301)

Replaces the row-level seam-only probe in RetentionArmSafetySql's
legacy-stitch branch with the shared bucket-level hole definition
(LegacySuccessorHoleExistsSql, added on this branch's prior commit)
so the gate and the repair walk share one hole definition text-
identical. The probe now also reaches the legacy's own interior, not
only the seam above it, and is bounded on the successor's first
bucket above the legacy's last (not min(bucket)), so an interior
repair moving the successor's floor down cannot make the probe miss
the seam.

Updates the doc comments on RetentionArmSafetySql to describe the
new bucket-level probe and its bound, and updates/adds unit pins in
TimescaleContinuousAggregateTests.cs for the new SQL shape and for
filter parity between the gate and the walk.

Does not touch the repair walk's own interior/seam branch (next
lane) or the non-legacy coverage path.
Four live tests in FrozenRollupLiveTests.cs against
IsRawTierDropSafeAsync, modelled on the existing Outage_Seam* tests'
scaffolding:

- InteriorHole_BelowLegacysLastBucket_ReportsRawPurgeNotSafe (A):
  a gap inside the legacy's own span reports Short. RED on
  e970834 (the old probe never looks inside the legacy's span).
- InteriorRepairMovesSuccessorFloorBelowSeam_TrapDoesNotHideTheHole_
  ReportsRawPurgeNotSafe (B): a successor bucket below the legacy's
  last bucket (as an interior repair leaves it) must not let a seam
  hole above it hide. RED on e970834 (the old s.mn-bounded probe
  collapses to an empty range and misses the seam).
- NoGapAnywhere_ReportsRawPurgeSafe (C): passes on both.
- GapBelowRawsFilteredFloor_IsInvisibleToTheProbe_ReportsRawPurgeSafe
  (D): passes on both.

All four GREEN on this branch; A and B verified RED on e970834 by
building and running the same file in a detached worktree of that
commit.
…ot the bare constant

RetentionArmSafetySql's LegacySuccessorHoleExistsSql call now passes
MaterializationHoleSourceFilterFor(successor's own CREATE) instead of the
bare IntervalHonestSourceFilter constant -- the same filter the repair
walk reads off the same successor's CREATE. query_stats_db_interval_hourly's
own filter (delta_worker_time IS NOT NULL AND sample_interval_seconds IS
DISTINCT FROM 0) is strictly wider than the bare constant, so the two
disagreed about which raw rows count for that successor. Also applies the
per-successor filter to that slot's fromExpr floor (the successor's own
filtered raw floor), matching the walk's own floor computation.

source_oldest's own sourceWhere is left as the pre-existing, conservative
bare-constant shape -- untouched per brief.

Rewrote LegacySuccessorHoleProbe_UsesSameFilterAsTheRepairWalk_ForEverySupersededSuccessor
to exercise the actual generated gate SQL (RetentionArmSafetySql) rather
than comparing two constants -- the prior form could never fail regardless
of what the gate's code did, since it never called the gate. Pin now
passes.
…IST)

Refactored LegacySuccessorHoleExistsSql into a thin EXISTS(...) wrapper
around a new private LegacySuccessorHoleBodySql, and added
LegacySuccessorHoleScanSql -- the LIST form the repair walk needs
(SELECT hb.bucket ... ORDER BY hb.bucket), wrapping the SAME body.
TEXT-IDENTICAL body, so the gate's EXISTS probe and the walk's bucket
list can never disagree about what a hole is.

Added a unit pin
(LegacySuccessorHoleExistsSql_AndLegacySuccessorHoleScanSql_ShareTheIdenticalBody)
asserting both wrappers' generated SQL contains the identical buckets
clause verbatim.
…floor

The walk's #4186 seam-fix block lowered seamFloor to the frozen legacy's
last bucket + one bucket width. Per the ruling (#4301 comment 5844202704),
the walk now lowers it to raw's own filtered floor (AlignDown'd), the same
bound RetentionArmSafetySql's gate probes from, whenever that reaches
further back than the successor's own floor. Everything downstream (the
successor-only scan, the newest-first cap and walk, the shared cap with
the ordinary window) is unchanged and is itself the contiguous downward
fill: RollupCoverage.StitchedRelationSql splits its read at the
successor's floor, so every row below it must land as a successor bucket
for the stitch to read each row exactly once.

Removed the now-dead legacyInteriorFrom/legacyInteriorTo third-window
parameters from MaterializationHoleScanWindows and the two pins that
exercised them (ScanWindows_LegacyInterior_*, ScanWindows_NoLegacyInterior_*):
under this ruling a legacy-interior hole is repaired by the same
newest-first seam descent as everything else below the successor's floor,
so the separate oldest-first interior branch never gets built. Removed
LegacySuccessorHoleScanSql (the walk's list-form twin of the gate's
LegacySuccessorHoleExistsSql) since the walk no longer shares that hole
definition; RetentionArmSafetySql keeps LegacySuccessorHoleExistsSql for
its own probe.
Three pre-existing FrozenRollupLiveTests seam tests expected the old
seam-tail-only fill (2-bucket seam above the legacy boundary, or 11 of
35 seam buckets on the wide-seam pass). The #4301 fix lowers the walk's
seam floor to raw's own filtered floor, so the walk now fills
contiguously down to raw's floor: 7 buckets (not 2) on the two 6-hour
tail tests, and a 36-bucket seam (not 35) split 24/12 (not 24/11) on
the wide-seam test. Updated the expected counts, floors, and comments
to match; no assertions were loosened.
Five new live tests in FrozenRollupLiveTests.cs against
RepairMaterializationHolesAsync / IsRawTierDropSafeAsync / the
StitchedRelationSql read path, modelled on lane 1d's pins A-D:

- ARepairedShape_SuccessorFloorAtOrBelowRawsFloor_RepairsNothing (1):
  an A6-backfilled shape where the walk has nothing left to do.
- InteriorHole_RepairedByTheWalk_ThenReportsCovered (2): an interior
  hole reads Short, converges to Covered after enough walk passes.
- StitchedReadThroughTheWalk_KeepsEveryRow_AndSuccessorFloorReachesRawsFloor
  (3): a stitched sum read (RollupCoverage.StitchedRelationSql) over
  [raw floor, now) is identical before and after the walk, and the
  successor's floor reaches raw's floor afterward. RED on abc8ce3,
  verified in a detached worktree: the old walk leaves the floor at
  the seam tail (b+6h) instead of raw's floor (b).
- CapAcrossPasses_LeavesNoGapBetweenPasses (4): a 31-bucket range
  wider than the 24-bucket cap fills 24 then 6 with no gap between
  passes.
- ProbeFailure_UnknownSourceState_ReportsRawPurgeNotSafe (5): renaming
  the successor CAGG mid-run makes the gate's probe fail; the Unknown
  verdict answers false, same as a measured Short.

All 19 tests in the class (14 existing + 5 new) green in-process on a
local TimescaleDB 2.30.1-pg18 rig. Also re-ran TimescaleContinuousAggregateTests,
MaterializationHoleRepairTests, MaterializationHoleRepairLiveTests,
RollupBackfillLiveTests, RetentionReevaluationLiveTests: 73 passed, 3
skipped, 0 failed -- unaffected by the #4301 change.
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