Skip to content

Converge each rollup to its SOURCE, not to raw (#1798) - #1799

Merged
erikdarlingdata merged 5 commits into
devfrom
feature/1798-source-relative-convergence
Jul 28, 2026
Merged

erikdarlingdata merged 5 commits into
devfrom
feature/1798-source-relative-convergence

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Closes #1798. Stacked on #1797 — based on its branch, so the diff shows only this change; GitHub retargets to dev automatically when #1797 merges and its branch deletes. Not based on dev because both touch RollupBackfill.

The defect

A fully-DONE --backfill-rollups left two hourly-tier retention policies HELD, against a contract line promising zero. The gate was right; the verb's completion claim was wrong.

Two notions of "covered" had drifted apart. The #1680 arming gate for an hourly-tier policy is source-relative — the daily must cover what the hourly holds — while the verb converged every rollup to raw's oldest row. All the dailies are hierarchical (time_bucket('1 day', bucket) FROM collect.*_hourly), so on a store whose raw purges are armed, raw is days deep while the hourlies hold weeks. A daily converged "to raw" stops short, the gate correctly refuses, and DONE prints over it.

The original #1759 profile hid this: on a held-purge store the hourlies are a subset of raw's span, so converging both tiers to raw satisfies the gate by construction.

The sharp edge is worse than the symptom. A hierarchical daily added after its hourly on a healthy store enters a hold nothing in the product can clear — the pre-raw region exists only in the hourly, and a verb aiming at raw never targets it.

The fix

RollupViews now carries each rollup's source and source time column alongside its raw table, and RollupBackfill.Targets is derived from that list rather than restating it — a second hand-kept copy is exactly the drift surface this issue is about.

RawTableFor stays raw for every rollup, dailies included. It answers a read question — where a window falls back to when this rollup cannot serve it — and raw remains the right answer. Only the backfill's convergence target moved.

The issue names three hierarchical dailies; there are four. query_stats_daily reads collect.query_stats_hourly too (verified in its DDL), and would have been left behind by a fix that took the list literally.

The harder half, found by the tests rather than by reading

The daily's plan was computed from a pre-backfill snapshot of its source. All plans are taken up front — necessary, since the disk preflight must total the whole job before committing to any of it — but a daily's source is backfilled earlier in the same loop. So the daily was aimed at where its hourly used to start and then judged against where the hourly now starts: SHORT on every daily, every time.

The run loop now re-probes and re-plans each rollup immediately before running it — the same measured-floor idiom that makes resume work, one level up. The up-front pass sizes the job; this one aims it.

I did not find this by reading. The verb's own end-to-end test went red, and I confirmed it was mine rather than pre-existing by re-running it on the parent commit, where it passes.

Contract

DONE now promises coverage of each rollup's own source, which is what each gate measures — made true rather than reworded.

Testing

Full solution rebuild: 0 warnings, 0 errors. All 37 rollup-backfill tests pass.

Behaviour pin: a hierarchical daily whose hourly out-reaches raw (raw purged to 4 days, hourly holding 12) must converge to the hourly's oldest bucket, and the hourly-tier policy must then arm. No-policy fixture — the ensure sweep's own refresh policy materializing contiguously already made a pin in this file vacuous twice.

Mutation (point the daily's source back at raw) verified RED on a compiling build, caught at two independent points — the source pin and the fixture-validity guard — each naming the cause.

One unrelated live test (DarlingAnomalyBaselineTests.EndToEnd_RestartExclusion…) fails on my local rig and fails identically on the parent with this change stashed, so it is pre-existing rig state. CI is the authority.

Field acceptance

Not run by me: the lab box is deliberately left in its 2-held state as the live reproduction, and the post-merge rehearsal there is the coordinator's.

🤖 Generated with Claude Code

A fully-DONE --backfill-rollups left two hourly-tier retention policies HELD on
a lab box, against a contract line that promises zero. The gate was right and
the verb's completion claim was wrong.

Two notions of "covered" had drifted apart. The #1680 arming gate for an
HOURLY-tier policy is SOURCE-relative -- the daily must cover what the HOURLY
holds -- while the verb converged every rollup to RAW's oldest row. All four
dailies are hierarchical (time_bucket('1 day', bucket) FROM collect.*_hourly),
so on a store whose raw purges are ARMED, raw is days deep while the hourlies
hold weeks: a daily converged "to raw" stops short, the gate correctly refuses,
and DONE is printed over it. The original #1759 profile hid this, because on a
held-purge store the hourlies are a subset of raw's span and converging both
tiers to raw satisfies the gate by construction.

The sharp edge is worse than the symptom: a hierarchical daily added AFTER its
hourly on a healthy store enters a hold NOTHING can clear. The pre-raw region
exists only in the hourly, and a verb aiming at raw never targets it.

RollupViews now carries each rollup's SOURCE and source time column alongside
its raw table, and RollupBackfill.Targets is DERIVED from that list rather than
restating it -- a second hand-kept copy is exactly the drift surface this issue
is about. RawTableFor stays raw for every rollup, dailies included: that answers
a READ question (where does a window fall back to) and raw is still the right
answer. Only the backfill's convergence target moved.

NOTE the issue names three hierarchical dailies; there are FOUR.
query_stats_daily reads collect.query_stats_hourly too, verified in its DDL, and
would have been left behind by a fix that took the list literally.

Found while fixing it, and it is the harder half: the daily's plan was computed
from a PRE-BACKFILL snapshot of its source. All plans are taken up front --
necessary, since the disk preflight has to total the whole job before committing
to any of it -- but a daily's source is backfilled EARLIER IN THE SAME LOOP, so
planning from that snapshot aims the daily at where its hourly USED to start and
then judges it against where the hourly NOW starts: SHORT on every daily, every
time. The run loop re-probes and re-plans each rollup immediately before running
it. Not caught by reading: caught by the verb's own end-to-end test going red,
and confirmed as mine by re-running it on the parent commit, where it passes.

The DONE contract text is now true rather than reworded: it promises coverage of
each rollup's own source, which is what each gate measures.

Behaviour pin: a hierarchical daily whose hourly out-reaches raw (raw purged to
4 days, hourly holding 12) must converge to the HOURLY's oldest bucket and the
hourly-tier policy must then ARM. No-policy fixture, since the ensure sweep's
own refresh policy materializing contiguously already made a pin in this file
vacuous twice. Mutation -- point the daily's source back at raw -- verified RED
on a COMPILING build, caught both by the source pin and by the fixture-validity
guard, each naming the cause.

Full solution rebuild 0 warnings / 0 errors. All 37 rollup-backfill tests pass.
One unrelated live test (DarlingAnomalyBaselineTests EndToEnd_RestartExclusion...)
fails on this local rig and fails IDENTICALLY on the parent with this change
stashed -- pre-existing rig state, not this change. CI is the authority.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
erikdarlingdata and others added 2 commits July 28, 2026 04:01
Answer to the review question: (b) — it did NOT bound it, now it does.

The preflight totalled up-front estimates (DarlingCliCommands.cs, work.Sum of
Plan.EstimatedBytes) while the run re-planned each rollup from live floors. For
a hierarchical daily whose hourly is deepened EARLIER IN THE SAME RUN, the
re-planned slice set is larger than what the preflight counted, so the run could
write more than the number the operator was shown — on exactly the store shape
(deep raw, purges held) where free space is tightest. Same invariant class as
the rows-per-bucket under-estimate: a refuse/proceed gate has to bound the WHOLE
job, and the printed numbers are the commitment.

EventualSourceFloor(source_now, root_raw_oldest) takes the DEEPER of the two,
because the run converges to whatever the source ends up holding: its floor as
it stands, or raw's oldest row, which is as far as backfilling that source can
take it. The preflight plans against that; the run keeps re-planning against the
live floor. A no-op for the hourlies, whose source IS raw, and it only ever
moves the bound LARGER, which is the safe direction for a disk gate.

On the estimator's blind spot raised in review: it does NOT count source buckets,
so a span the source has not materialized yet cannot read zero. bucketsToAdd is
ceil(span / bucketWidth) — pure arithmetic over the planned range — and the
bytes-per-bucket calibration comes from the ROLLUP's own materialized sample,
never the source's. Extending the range therefore raises the estimate whatever
the source currently holds.

CLAMP, second half of the same question: it was enforced on the re-planned set
(the run calls Plan again, which applies MaxPlannedSlices) but the outcome was
MISREPORTED, and that bug was mine from the re-plan commit. RollupBackfillPlan
.Absurd sets IsComplete as well as Refusal, and the re-plan branch tested
IsComplete first — so a clamp firing mid-run, after the preflight had cleared it
under the ceiling, printed "already covers" and counted as success. Refusal is
now tested first and reported as a shortfall with a non-zero exit.

Pin: printed bound >= actual work, asserted on buckets AND bytes since the
operator is shown both, plus a non-vacuity assertion that the pre-backfill floor
really does under-count (or the fixture proves nothing) and the hourly/null
degradations. Mutation — preflight back to the naive pre-backfill floor —
verified RED on a COMPILING build.

Full solution rebuild 0 warnings / 0 errors. All 38 rollup-backfill tests pass.
Two unrelated live tests fail on this local rig; the PARENT fails two as well,
overlapping on one and differing on the other, so the failing set ROTATES
between runs on both trees — a degraded local rig, not a deterministic
regression. Deliberately not claiming "identical failure" here, because it was
not. CI is the authority.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… explicitly

Both from the coordinator's independent read of the diff, which reached the
same verdict (b) I had.

THE PIN, stated their way and it is the better statement. My assertion compared
two plans and required the preflight's to be the larger; theirs compares the
daily's up-front plan against the HOURLY's up-front plan and requires the daily
to start no later. That says the property directly -- the daily must be planned
to its source's TARGET, not to where its source currently stops -- instead of
inferring it from two numbers. Added alongside the existing ones.

Also recorded why the bytes assertion is safe despite their warning against
live byte comparisons: it asserts the ESTIMATE, which is deterministic
arithmetic over the planned span times a per-bucket sample, not bytes measured
off the store. A measured comparison would be flaky exactly as they say --
compression and page slack make the same span occupy different amounts.

THE NIT: a refused RE-plan is guarded, not merely improbable. Their transitive
argument is right on today's call graph -- a corrupt timestamp refuses the
hourly's up-front plan, and since a daily is now planned against its source's
eventual floor it refuses up front too, so neither reaches the loop. But that
is a property of the current call graph rather than of the code, and what it
protects against is slicing a window the planner already rejected as garbage.
The guard is three lines and the argument would need re-deriving by every
future reader; the comment now records both.

Local rig: the rotating shared-store failures were accumulated state, so I
rebuilt the test database rather than keep reasoning around them, as promised.
That took the suite from two failures to one, and the remaining one
(PayloadDimensionLiveTests DimensionGc_Defers...) PASSES IN ISOLATION on the
rebuilt rig -- matching what verb-reviewer independently observed on its own
box. Shared-store contention, not this change.

Full solution rebuild 0 warnings / 0 errors. All 64 rollup backfill + coverage
routing tests pass. Suite 3739 passed / 1 failed as described.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@erikdarlingdata
erikdarlingdata changed the base branch from feature/1759-restore-explicit-refresh-options to dev July 28, 2026 10:16
@erikdarlingdata
erikdarlingdata merged commit 18c12c7 into dev Jul 28, 2026
4 checks passed
@erikdarlingdata
erikdarlingdata deleted the feature/1798-source-relative-convergence branch July 28, 2026 10:24
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