Skip to content

test(dst): reach the dead-staging sweep window - #1034

Closed
ragnorc wants to merge 5 commits into
ModernRelay:mainfrom
ragnorc:test/dst-dead-staging-sweep-window
Closed

ragnorc wants to merge 5 commits into
ModernRelay:mainfrom
ragnorc:test/dst-dead-staging-sweep-window

Conversation

@ragnorc

@ragnorc ragnorc commented Oct 17, 2026

Copy link
Copy Markdown
Contributor

Stacked on #1027, which adds the seam cleanup.sweep_pre_dead_manifest_delete. Until #1027 merges this diff includes its commits; the change here is the last commit.

Summary

  • Registers cleanup.sweep_pre_dead_manifest_delete in DST's crash-window catalog. It is appended, so the index-derived census seeds of the other windows stay put (52 → 53 windows; README counts corrected from a stale 72).
  • Gives the window a milestone recipe that reaches it: insert, index build, insert, cleanup. census_setup crashes the index build at ensure_indices.post_phase_b_pre_manifest_commit. That strands a detached staging, index files included. The second insert moves main's head past the head the staging's witness recorded, so the recipe's cleanup judges the staging dead and crosses the window.
  • Fixes a harness gap the window exposed. A window inside cleanup's per-table pass does not fail cleanup: the engine records the table's error in its row and returns Ok. crash_op treated that as a plain success, so the harness ran no retry and the report kept no trace of the window. Now crash_op returns CleanupDeferred when the op's deferrals name the scheduled window. The universe records deferred:<window>@op<i> (verdict Deferred, channel cleanup) and runs the obligation pass a crashed cleanup gets: a clean rerun that defers nothing, the collector oracle (retained images and paths, no published garbage or dead staging left) and the world read. This applies to cleanup.table_gc and the three earlier sweep windows too.
  • Adds the pin dst_dead_staging_sweep_window_is_reached_and_retried. One universe probes the window. The other stops the recipe's cleanup at the window (after the dead staging's files, before its manifest) and requires the owed table's retry to converge. Both replay strictly. dst_sensitivity_maintenance_rerun_failure_is_red gains a deferred-cleanup case that must go red from the owed table's pass.
  • The detached-only-tables RFC's evidence line names the recipe and the deferral rule.

Reach census

cd crates/omnigraph-dst && cargo test --locked --test scenarios dst_window_reach_probe -- --ignored --nocapture

REACH CENSUS: 41/53 windows crossed in ONE seeded universe each
still dark (12): ["blob_read.post_capture", "branch_merge.adopt_after_append_pre_upsert", "branch_merge.adopt_after_upsert_pre_delete", "branch_merge.adopt_between_insert_chunks", "branch_merge.between_delete_chunks", "branch_merge.post_table_effect", "classify.fresh_read", "cleanup.resolve_branch_snapshot", "ensure_indices.post_table_effect", "init.after_coordinator_init", "init.post_manifest_create", "mutation.post_no_effect_pre_gate"]
errored (milestone tripped an oracle — separate finding) (0): []

cleanup.sweep_pre_dead_manifest_delete is in the crossed list. All three of its census seeds cross (40052, 50052, 60052). In seed 40052 the recipe lands on op2 (insert), op4 (index build, setup crash), op6 (insert) and op8 (the cleanup that crosses).

The same census at the base commit 96d8b30a reports 40/52 with the identical 12 dark windows, so this change adds exactly the new window. The instrument still fails its floor, crossed.len() >= 48, on both: that floor was set when the catalog had 66 windows. This PR leaves the floor alone.

The deferral gap, measured

Scheduled crashes on cleanup.table_gc, cleanup.sweep_pre_manifest_delete, cleanup.sweep_between_manifests and cleanup.sweep_pre_file_delete (seeds 7 and 8, 24 ops, each window's recipe): all 8 universes reported crashes = 0, because the window became a deferred table. Before this change none of them ran a retry obligation. A later cleanup checked convergence only if the sampler happened to emit one. The final audit checks safety and trace completion, not that dead stagings were reclaimed. With the change, each universe records its deferred: verdict, runs the obligation pass and stays green.

Tests

  • cd crates/omnigraph-dst && cargo test --locked: lib 53 passed; integration 2 passed, 3 ignored; scenarios 52 passed, 28 ignored.
  • Red check: with the routing forced off (cleanup_deferred_by returning false), the pin fails ("the stopped cleanup must leave the table owed") and the sensitivity proof fails ("a failing rerun MUST redden the universe").
  • cargo fmt --all --check, cargo clippy -p omnigraph-dst --all-targets --locked -- -D warnings -W clippy::dbg_macro, typos, python3 scripts/check-docs.py.

Not covered

…x directory

The collector deletes a swept version's files before its manifest, so a
pass that stops in between leaves the manifest for the next pass. On a
local store that next pass failed for the table: `delete_paths` called
`remove_dir_all` again on the `_indices/<uuid>` directory the stopped pass
had removed, and Lance's local provider reports an absent directory as
`NotFound`. The table kept the error and deleted nothing on every later
run. Object stores list the prefix empty and succeed, so S3, Azure and
the DST memory store converged already.

An absent index directory is now already deleted, as an absent file
already was. The files-before-manifest order is unchanged, and the
deletion set still holds only paths no retained root marks.

The failpoints test stops a pass at `cleanup.sweep_pre_manifest_delete`
after it removed an index directory only the pruned pin names, then
proves the next pass removes that manifest, reports no error, and leaves
every retained root's paths in place.
Reword the maintenance guide's sentence on an interrupted cleanup so the
deletion order and the next run's outcome read as two steps, and point
the release note at the guide's Cleanup section.
…staging manifests

Cover the directory-touching windows of the collector's sweep on a local
store, where Lance reports removing an absent directory as NotFound:

- a pass stopped between two manifests of the published sweep, with an
  index directory among the files it already deleted;
- a pass stopped after the dead stagings' files and before their
  manifests, through the new seam cleanup.sweep_pre_dead_manifest_delete.

Each retry reports no error, removes the remaining manifests and keeps
every retained root's paths.
…anup-sweep-crash-windows

# Conflicts:
#	crates/omnigraph/tests/failpoints.rs
Register cleanup.sweep_pre_dead_manifest_delete in the crash-window
catalog (appended, 53 windows) and give it a milestone recipe: insert,
index build, insert, cleanup. The reach census's setup crash stops the
index build before its manifest commit, stranding a detached staging; the
second insert moves main's head past the head the staging recorded, so
the recipe's cleanup judges it dead and crosses the window.

A window inside cleanup's per-table pass does not fail the op: cleanup
records the table's error and returns Ok. The harness treated that as a
plain success, so no retry was judged and the report kept no trace of
the window. crash_op now returns CleanupDeferred when the op's deferrals
name the scheduled window; the universe records deferred:<window>@op<i>
and runs the obligation pass a crashed cleanup gets (a clean rerun that
defers nothing, the collector oracle, the world read). This covers
cleanup.table_gc and the three earlier sweep windows as well.

The pin dst_dead_staging_sweep_window_is_reached_and_retried probes the
window and stops the recipe's cleanup at it; the sensitivity proof gains
a deferred-cleanup case. Both go red with the routing reverted.
@cursor

cursor Bot commented Oct 17, 2026

Copy link
Copy Markdown

Review of fbba88d1 — test(dst): reach the dead-staging sweep window

Recommendation: approve. I found no blocking defects. This account can post only comments, so this review is a COMMENT that recommends approval.

Scope. I reviewed the last commit, fbba88d1, against #1027's head 96d8b30a. The #1027 commits in this diff belong to #1027's review. Merge this PR after #1027.

Why, what, how, impact

  • Why. cleanup puts a table's error into that table's row and returns Ok (crates/omnigraph/src/db/omnigraph/optimize.rs, cleanup_detached_only, lines 815–850). Before this change, crash_op read that Ok as "the window was not on this op's path." So a scheduled stop inside the per-table pass left no verdict and ran no retry obligation. That applied to cleanup.table_gc, to the three sweep windows already in the catalog, and to the new window.
  • What. The PR appends cleanup.sweep_pre_dead_manifest_delete to CRASH_WINDOWS. Because the entry is appended, the census seeds of the other windows stay the same. A new recipe makes a dead staging: insert, index build stopped before its manifest commit, insert, cleanup. A new CrashOutcome::CleanupDeferred holds the new outcome.
  • How. cleanup_deferred_by looks for the scheduled seam's injected text in the per-table errors that the op recorded. On a match, the universe records deferred:<window>@op<i> (verdict Deferred, channel cleanup). It then runs the same obligation pass that a crashed cleanup gets: a clean rerun that defers nothing, the collector image and path checks, no published garbage or dead staging left, and the world read.
  • Impact. The change is test-only. It changes no engine code, format or API. Hunt and fleet universes that schedule a per-table cleanup window now run a retry obligation; before this change they ran none.

Contract and invariant

The contract under test is invariant 5, crash convergence. It has two parts: the collector's progress property (a dead staging is gone after a run that retains nothing else), and convergence when a pass stops partway and is retried. The defect class is "a scheduled window that the operation absorbs into its result." Of the maintenance operations, only cleanup absorbs per-table errors into returned rows (DatasetCleanupStats.error); optimize and ensure_indices return Err. The fix is in the harness's classification, and the harness owns this oracle. The fix is generic: every per-table cleanup window gets the route with no per-window code. The layer and the width are correct.

Findings

Blocking: none.

Optional (four have inline comments):

  1. The hunt cannot see the new outcome (inline, harness.rs 5706). The hunt counts a hit only when crashes > 0. So it files deferred cleanup windows under "crossed-but-absorbed (self-healing verified live)", which is not true for a deferral.
  2. Two cleanup windows are absorbed with no row (inline, harness.rs 4821). The failures of cleanup.reconcile_fork and cleanup.resolve_branch_snapshot go only to tracing::warn!. The new route cannot see them. This is pre-existing, and that work is not owed table-version work, so a follow-up is enough.
  3. README count wording (inline, README.md 122).
  4. The sensitivity panic does not name its case (inline, scenarios.rs 1983).
  5. RFC paragraph overlap with test(cleanup): run the sweep crash windows on S3 and Azure #1033. Both PRs rewrite the same evidence paragraph in docs/rfcs/2026-09-21-detached-only-tables.md (around lines 857–870). Whichever PR merges second must merge the two texts by hand. Keep this PR's recipe and deferral sentences and test(cleanup): run the sweep crash windows on S3 and Azure #1033's file/S3/Azure sentences.
  6. Census floor. dst_window_reach_probe asserts crossed.len() >= 48. It fails at the base (40/52) and at the head (41/53), as the PR description says. The floor comes from a 66-window catalog. Until a separate change resets it to the measured value, the instrument is red on every run, which hides a real reach regression.

Tradeoffs and software liability

  • Removed: a silent-success path for five per-table cleanup windows. Each one now leaves a verdict in the report and gets a retry obligation.
  • Added: one enum variant, one helper of about 20 lines, one catalog entry, one recipe arm, one census_setup arm, one pin test and one sensitivity case.
  • Text coupling. The helper matches the seam error text injected failpoint triggered: <name>. The suite already depends on that text (assert_collector_invariants, and about 50 assertions in crates/omnigraph/tests/failpoints.rs), so the coupling is not new. If the text changes, the pin and the deferred-cleanup case go red, so the route cannot fail silently. A typed record of which seam fired would be cleaner, but with one consumer it would be a speculative abstraction.
  • Five more changes like this. A new per-table cleanup window needs no routing code. It still needs one arm in milestone_steps (harness.rs) and one in census_setup (scenarios.rs). Those are two tables to keep in step, but that is the existing pattern.
  • Seed binding. DEAD_STAGING_CLEANUP_OP = 8 ties the pin to seed 40052's op stream. If the sampler changes, the pin fails loudly, not falsely green, but the message will look like a routing regression. That is acceptable.
  • What this deprioritizes: a little nightly run time. Each deferred universe now does one more cleanup and collector plan. That cost is small, and the stronger convergence evidence is worth it.

Lance alignment

This commit does not call Lance. The window is in OmniGraph's own collector (collector.rs sweep_table: files first, then the _versions/ manifest). Lance 11's cleanup_old_versions does not trace detached manifests, and the engine never calls it on a graph table. A Lance bump does not change this harness logic. If a later Lance traces detached manifests and the collector moves to stock cleanup, these sweep windows and recipes go away with it.

Regression proof

GQT cannot express this contract: it is a harness classification and a collector crash window inside DST, not the rows, counts or errors of a query. The DST scenario suite is the existing owner.

  • Before-fix variant: 96d8b30a's crates/omnigraph-dst/src/harness.rs, which has no deferral route, plus only this PR's new milestone_steps arm. This PR's catalog and tests stay in place. I built a variant because the pin cannot run at 96d8b30a: that commit has neither the catalog entry nor the recipe.
  • Reviewed head: fbba88d1d3e81380acb4b637c605e673e1496bfe.

All commands ran from crates/omnigraph-dst with --locked (Lance 11.0.0).

Command Before-fix variant Head fbba88d1
cargo test --locked --test scenarios -- --exact dst_dead_staging_sweep_window_is_reached_and_retried dst_sensitivity_maintenance_rerun_failure_is_red 0 passed, 2 failed (reasons below) 2 passed
cargo test --locked (whole crate) not run lib 53 passed; integration 2 passed, 3 ignored; scenarios 52 passed, 28 ignored
dst_window_reach_probe --ignored --nocapture not run 41/53 crossed, including cleanup.sweep_pre_dead_manifest_delete; the 12 dark windows match the PR list; floor assert panics with 41 crossed (finding 6)
cargo clippy -p omnigraph-dst --all-targets --locked -- -D warnings -W clippy::dbg_macro not run clean
cargo fmt --all --check; python3 scripts/check-docs.py not run clean; Documentation OK (308 Markdown files checked)

Why the two tests fail on the before-fix variant. Both are the predicted behavioral reasons, not setup failures:

  • The pin fails with the stopped cleanup must leave the table owed: [("crash:ensure_indices.post_phase_b_pre_manifest_commit@op4", "Applied", "query")]. Only the setup crash has a verdict. The stop at op 8 resolved as a plain success.
  • The sensitivity test fails with a failing rerun MUST redden the universe. The deferred-cleanup case stayed green. The four earlier cases are unchanged and passed first.

CI at fbba88d1 (merge ref onto 0b5e17fa): every check passed. DST pinned suite ran dst_dead_staging_sweep_window_is_reached_and_retried ... ok and dst_sensitivity_maintenance_rerun_failure_is_red ... ok, with the same crate counts as my local run.

Limits

}));
// A cleanup the window left owing a table is judged like a crashed one.
cases.push((
"deferred-cleanup".to_string(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: if this case stays green, the panic below (line 1999) says only s20 sensitivity: a failing rerun MUST redden the universe. It does not name the case. In my before-fix run (no deferral route), I had to infer deferred-cleanup from the list order.

Suggested: panic!("s20 sensitivity [{case}]: a failing rerun MUST redden the universe").

.lock()
.expect("cleanup deferral map")
.get(db.uri())
.is_some_and(|deferred| deferred.values().any(|error| error.contains(&injected)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional, can be a follow-up: this route sees only the errors that cleanup puts in its returned rows.

Two cleanup windows fail outside the rows:

  • cleanup.resolve_branch_snapshot (optimize.rs, reconcile_frozen_native_forks, lines 1378–1384)
  • cleanup.reconcile_fork (optimize.rs, lines 1480–1494)

Their failures go to reconciled.failures, and cleanup_detached_only only logs that list with tracing::warn! (lines 915–921). So a stop scheduled at either window still resolves as OpSucceeded, with no verdict and no retry obligation. The census crosses cleanup.reconcile_fork.

That work is not owed table-version work ("will retry next cleanup", and safety holds), so I do not ask for it in this PR. Do one of these:

  • State this limit in the doc comment of cleanup_deferred_by.
  • Open a follow-up so that reconcile failures appear in the cleanup result (invariant 11: failures are observable). The same route would then cover them.

## Fault axes

- **Crash windows**: `Scenario::crash_at` arms any of the engine's 72 named
- **Crash windows**: `Scenario::crash_at` arms any of the engine's 53 named

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the engine declares 82 seams (crates/omnigraph/src/seams/catalog.rs). crash_at accepts any of them through seams::catalog::decide. catalog::CRASH_WINDOWS holds the 53 that the hunt sweeps, so "the engine's 53 named failpoints" is not accurate.

Suggested wording: "Scenario::crash_at arms a named engine failpoint at a seeded op index. The ignored dst_hunt_crash_window_sweep test sweeps the 53 in catalog::CRASH_WINDOWS." Line 106 already states the count correctly.

legal_rejections += 1;
None
}
CrashOutcome::CleanupDeferred => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: the hunt cannot see this outcome.

dst_hunt_crash_window_sweep counts a window as hit only when report.crashes > 0 (tests/scenarios.rs, line 175), and it prints verdicts only for hits. This arm adds nothing to crashes; the new pin asserts this with a.crashes == 1, where the one crash is the setup crash. So a deferred cleanup window falls through to the probe pass, and the hunt files it under "crossed-but-absorbed (self-healing verified live)". For a deferral that label is wrong. The table is owed, and only the obligation pass proves the retry. Also, the Deferred row never appears in the hunt's output, so this route leaves no trace in the hunt report.

Suggested fix: make the hunt count a deferred: verdict as a hit. Alternatively, add a deferrals counter to UniverseReport and test crashes + deferrals > 0. With either fix, the hunt prints the Deferred row and stops at the first hit.

@ragnorc

ragnorc commented Oct 18, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #1036 (merged as 366be88), which consolidated #1027, #1033 and #1034 on current main and kept their commits. Closing without merging.

@ragnorc ragnorc closed this Oct 18, 2026
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