Skip to content

feat(stelae): refuse a run that cannot fit its staging, before it starts - #1192

Merged
scarmuega merged 2 commits into
mainfrom
feat/stelae-staging-preflight
Aug 12, 2026
Merged

feat(stelae): refuse a run that cannot fit its staging, before it starts#1192
scarmuega merged 2 commits into
mainfrom
feat/stelae-staging-preflight

Conversation

@scarmuega

@scarmuega scarmuega commented Aug 12, 2026

Copy link
Copy Markdown
Member

Plan: plans/dolos-stelae-publisher-operability-preflight.md — the second piece of the stelae-operability family, behind #1191. Base: main at 7286c48d, exactly as the plan's Approach names it.

What was wrong

A publish holds sixteen staged shards at once — all sixteen sink writers stay open across a single walk of the store, because the alternative is sixteen full scans — plus whatever epoch layer is in flight. A restore holds one layer at a time. Neither command asked whether the volume could hold that, so the operator found out as No space left on device hours in.

The restore side was half built: Plan::preflight checked free space at storage.path against the selected layers' uncompressed size, but never asked about the scratch volume that #1191 had just given it. The publish side had no check at all.

What changed

One policy, in one placecrates/snapshot/src/preflight.rs, new. A measured shortfall refuses, naming the volume and how far short it is; free space that will not read, or a need nothing could size, warns and proceeds. There is no override flag — --scratch-dir pointed at a bigger volume is the escape hatch, and a better one than a flag that turns the check off. (org/founder, 2026-08-11, the umbrella's fourth escalation.)

Needs that land on one volume are summed rather than each compared against the whole pool. That is the ordinary case, not the exotic one: --scratch-dir defaults to <storage.path>/scratch, so a restore's destination and its staging are two claims on the same free bytes. Same-volume is the filesystem device id on Unix — exact in both directions, so a dedicated mount under the storage path correctly reads as separate, and --scratch-dir elsewhere on the same disk correctly reads as shared. Stable Rust exposes no device id off Unix, so there the test is containment, which is right for the default and over-states rather than under-states everywhere else.

RestorePlan::preflight extended, not duplicated. It now takes an optional Staging { dir, largest_layer } beside the destination and hands both to the shared check. The need is the largest layer the run will actually pull, taken as a max in the pass Remaining::of already makes to sum the download (Remaining::largest_compressed), so a resumed run sizes what it still has to stage rather than what a fresh one would have.

The plan flagged the ordering: preflight ran above the sizes. It now runs below them, after Checkpoint::open and blob_index — both of which only read — so ADR-004 step 2's promise, refuse before anything is written, is intact while the check can see the resume.

A directory restore stages nothing (a SteleDir reads its blobs where they are) and passes None. Which directory a registry restore stages in comes from the transport — a new stelae::oci::Registry::scratch_dir() — not from the caller re-deriving the path it handed to open. Two derivations of one directory is one more than can be kept in step.

Publishregistry::staging_peak sizes the sixteen-shard-plus-largest-other peak off the predecessor's manifest: no per-layer HEAD, keeping registry::preview's promise. registry::preflight applies the same policy to it, and runs beside standing — before the dry run too, for the reason already written there: a publisher rehearsing with --dry-run should get the same answer the publish gives. A first publish has no predecessor, cannot be sized, and warns.

The peak is a proxy — last epoch's layer sizes, not this publish's — and the refuse/warn split is what keeps it honest: only what was actually measured can refuse.

Done criterion

1. Restore preflight extended, refuses a scratch volume that cannot hold the largest selected layer; unmeasurable warns; shared filesystem sums. Covered by a test. met
2. Publish preflight sizes the peak from the predecessor manifest, same refuse/warn policy, no-predecessor warns. Covered by a test including the no-predecessor path. met
3. cargo test, clippy, fmt, cargo deny check advisories, and the stelae dependency boundary. met

Verification

Run, not asserted. Both CI test legs:

cargo test --workspace --all-targets                                    → green
cargo test --workspace --all-targets --all-features \
  --exclude dolos-minibf --exclude dolos-minikupo --exclude dolos-trp   → green
cargo clippy --all-targets --all-features -- -D warnings                → clean
cargo +nightly fmt --all -- --check                                     → clean
cargo deny check advisories                                             → advisories ok
cargo tree -p stelae -e normal --all-features                           → 0 matches for dolos(-|$)

The #[ignore]d registry suites were executed against a spawned registry:2 — this change edits publish.rs and the shared fixture, so an unrun ignored test would prove nothing:

stelae      --test oci               10 passed
snapshot    --test publish            8 passed   (incl. the new staging test)
snapshot    --test restore_registry   5 passed
snapshot    --test snapshot_verify    6 passed

The new suite's own output, from the fixture's stele:

staging peak: 5769 bytes (4276 across sixteen shards, 1493 for the largest
other layer), against 7666 compressed bytes in the repository

— which is the point the check exists for: what a publish holds at once is not what the repository holds.

Mutation-checked, because a preflight that never fires looks exactly like one that always passes:

  • Remaining::largest_compressed stuck at Nonethe_remaining_download_excludes_what_is_already_done fails (None vs Some(1490)).
  • the volume grouping made to never match, so needs are compared separately → needs_sharing_a_volume_are_summed fails, and only that one.
  • staging_peak summing the non-state layers instead of taking the max → the registry test fails (3390 vs 1493).

Review pass (debbccbb)

Three of CodeRabbit's four applied, all in the same commit:

  • staging_peak summed sixteen manifest-stated sizes with +. They are another document's numbers, and a wrapped total is a small need — the one direction a refusal must never be wrong in. Saturating now, like the policy's own sum already was.
  • The non-Unix same_volume tested containment, which answers <storage.path>/scratch and misses two siblings on one drive. It compares the canonical path's prefix — the drive letter or UNC share — and the summing test now runs both shapes, with the sibling directory created so it is measured as itself rather than through a shared ancestor. The Windows CI leg is what validates it.
  • A restore staging need with some layers sized and some not took the known maximum and said nothing about it. It is a floor and now says so, the way registry::staging_need already did.

The fourth is right about the defect and is deliberately not fixed here. The destination need is Plan::uncompressed_size(), which is not resume-aware, so a resumed restore is charged again for layers its own checkpoint already wrote. That predates this change — the comparison has read the whole plan since it was written — and what this PR did was make it visible, by moving the check below the resume where the two needs sit side by side and disagree. Narrowing it is not arithmetic: a redone layer is rewritten rather than appended, except in the redb archive, which leaves dead space bounded by one layer, so the honest need carries slack whose size is a decision the plan did not make. Filed as its own plan rather than widened into this one.

What is covered structurally rather than end to end

A test cannot shrink a filesystem, so no test drives a real registry restore into a real refusal. The chain is covered in pieces instead, and each piece is a real assertion: Registry::scratch_dir() returns what open was given (asserted against the fixture's own directory, in the registry suite); Remaining::largest_compressed is the max over the layers that remain; Plan::preflight refuses a staging need it cannot fit and warns at one it cannot size; and the policy's summing and refusal are unit-tested against the volume the test is running on. What no test covers is restore_stele passing the transport's directory into that preflight — one line, and the accessor assertion is what stands behind it.

Scope held

Reporting only, never repairing: a preflight that says a first publish cannot be sized is in scope; making a publish cheap is [dolos-stelae-publish-cost]. No resumption, no progress reporting. snapshot verify and snapshot inspect also stage through the scratch directory and are not preflighted here — neither is a publish or a restore, and widening to them is not this plan's.

Escalations

None.

On the test level, given #1191's review

#1191 landed with 69f8007d, "drop the CLI staging suite" — a container-spawning suite and its CI leg traded away for a unit test on stele_scratch_dir plus the transport's own staging test, on the grounds that what it guarded was a PathBuf argument on two commands. This change follows that line rather than re-opening it: the new coverage is unit tests on the policy and on each side's need, plus one assertion inside the existing registry suite where a real manifest is the only thing that can state the sizes. No new suite, no new CI leg.

🤖 Generated with Claude Code

A publish holds all sixteen shard sinks open across one walk of the store; a
restore stages one layer at a time. Neither asked whether the volume could hold
it, so the shortfall arrived as `No space left on device` hours in.

One policy for both directions, in `dolos_snapshot::preflight`: a measured
shortfall refuses, naming the volume and how far short it is, and what cannot be
measured warns and proceeds. Needs that land on one volume are summed rather
than each compared against the whole pool — which is the ordinary case, since
`--scratch-dir` defaults to `<storage.path>/scratch`.

Restore: `Plan::preflight` takes the staging directory alongside the
destination. The need is the largest layer the run will actually pull, taken as
a `max` in the pass `Remaining::of` already makes to sum the download, so a
resumed run sizes what it still has to stage rather than what a fresh one would.

Publish: sized off the predecessor's manifest — sixteen state-shard compressed
sizes plus its largest other layer — with no per-layer HEAD. A first publish has
no predecessor, cannot be sized, and warns.

There is no override flag: `--scratch-dir` pointed at a bigger volume is the
escape hatch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d58184f1-d4dc-42df-a557-2803ba07f300

📥 Commits

Reviewing files that changed from the base of the PR and between 72cdffb and debbccb.

📒 Files selected for processing (3)
  • crates/snapshot/src/preflight.rs
  • crates/snapshot/src/registry.rs
  • crates/snapshot/src/restore.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • crates/snapshot/src/preflight.rs
  • crates/snapshot/src/registry.rs

📝 Walkthrough

Walkthrough

The snapshot crate adds shared filesystem preflight checks and a NotEnoughSpace error. Publish operations estimate scratch-space peaks from layer sizes. Restore operations account for destination and resume-aware staging requirements.

Changes

Snapshot staging preflight

Layer / File(s) Summary
Shared preflight policy
crates/snapshot/src/lib.rs, crates/snapshot/src/preflight.rs
The snapshot crate exposes Need and check. Checks group measurable needs by volume, warn for unknown sizing, and return detailed NotEnoughSpace errors.
Publish staging estimation and enforcement
crates/snapshot/src/registry.rs, crates/stelae/src/oci.rs, crates/snapshot/tests/registry_fixture/mod.rs, crates/snapshot/tests/publish.rs, src/bin/dolos/snapshot/publish.rs
Publish staging peaks sum concurrent state layers and retain the largest other layer. The configured scratch directory is preflighted before preview or publish operations.
Restore staging estimation and enforcement
crates/stelae/src/plan.rs, crates/snapshot/src/restore.rs, crates/snapshot/tests/restore.rs
Restore preflight checks destination and optional staging volumes. Resume-aware calculations use the largest remaining compressed layer, including unknown and zero-sized staging cases.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PublishCommand
  participant RegistryPreflight
  participant Filesystem
  PublishCommand->>RegistryPreflight: Preflight repository staging capacity
  RegistryPreflight->>Filesystem: Check scratch-volume requirement
  Filesystem-->>RegistryPreflight: Return available space
  RegistryPreflight-->>PublishCommand: Continue or report NotEnoughSpace
Loading
sequenceDiagram
  participant RestoreOperation
  participant restore_stele
  participant PlanPreflight
  participant Filesystem
  RestoreOperation->>restore_stele: Pass scratch directory and target
  restore_stele->>PlanPreflight: Submit destination and staging needs
  PlanPreflight->>Filesystem: Check required volumes
  Filesystem-->>PlanPreflight: Return available space
  PlanPreflight-->>restore_stele: Allow or refuse restore
Loading

Possibly related PRs

  • txpipe/dolos#1168: Introduces the publish flow extended here with staging-space preflight.
  • txpipe/dolos#1169: Provides the restore preflight flow extended here for staging requirements.
  • txpipe/dolos#1176: Relates to restore staging propagation, compressed-layer sizing, and resume-aware checks.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the primary change: refusing publish or restore runs when staging capacity is insufficient before execution begins.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/stelae-staging-preflight

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/snapshot/src/preflight.rs`:
- Around line 254-262: Update the non-Unix same_volume function to compare
platform-specific volume identities rather than ancestor relationships, so
sibling paths on the same Windows drive or share are grouped together while
paths on different volumes remain separate. Add coverage in the relevant
preflight tests for sibling paths sharing one volume and verify check combines
their requirements.

In `@crates/snapshot/src/registry.rs`:
- Around line 551-558: Update the STATE branch in the loop over
inscription.layers to accumulate state_bytes with saturating addition instead of
allowing integer overflow. Preserve the existing handling for unsized layers and
largest_other_bytes, while ensuring StagingPeak::bytes() receives a non-wrapped
state total.

In `@crates/snapshot/src/restore.rs`:
- Around line 857-860: Update the staging construction around Staging and
outlook.remaining to retain the unsized layer count alongside
largest_compressed, while continuing to use the known lower-bound estimate.
Ensure staging_need emits the established warning when unsized layers remain,
matching the behavior in registry::staging_need.
- Around line 282-298: Update Plan::preflight to accept and use the resume-aware
remaining destination size calculated by restore_stele instead of
self.uncompressed_size(). Thread that value through the preflight call while
preserving staging checks, and add coverage for a resumed restore where
completed epoch layers exceed available space collectively but the remaining
layers fit.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e5773626-8617-478c-83b2-372c8b7de674

📥 Commits

Reviewing files that changed from the base of the PR and between 7286c48 and 72cdffb.

📒 Files selected for processing (10)
  • crates/snapshot/src/lib.rs
  • crates/snapshot/src/preflight.rs
  • crates/snapshot/src/registry.rs
  • crates/snapshot/src/restore.rs
  • crates/snapshot/tests/publish.rs
  • crates/snapshot/tests/registry_fixture/mod.rs
  • crates/snapshot/tests/restore.rs
  • crates/stelae/src/oci.rs
  • crates/stelae/src/plan.rs
  • src/bin/dolos/snapshot/publish.rs

Comment on lines +254 to +262
#[cfg(not(unix))]
fn same_volume(a: &Path, b: &Path) -> bool {
let (a, b) = match (a.canonicalize(), b.canonicalize()) {
(Ok(a), Ok(b)) => (a, b),
_ => return false,
};

a.starts_with(&b) || b.starts_with(&a)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Group sibling paths on non-Unix volumes.

On non-Unix platforms, two sibling paths on the same Windows drive or share are not ancestors of each other. same_volume returns false, so check compares both requirements against the same free-space pool separately. The combined requirement can exceed free space and still pass.

Use a platform-specific volume identity for non-Unix paths. Add a test with sibling paths on one volume.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/snapshot/src/preflight.rs` around lines 254 - 262, Update the non-Unix
same_volume function to compare platform-specific volume identities rather than
ancestor relationships, so sibling paths on the same Windows drive or share are
grouped together while paths on different volumes remain separate. Add coverage
in the relevant preflight tests for sibling paths sharing one volume and verify
check combines their requirements.

Comment thread crates/snapshot/src/registry.rs
Comment on lines +282 to +298
pub fn preflight(&self, path: &Path, staging: Option<Staging<'_>>) -> Result<(), Error> {
let mut needs = vec![preflight::Need::of(
"restoring it",
path,
self.uncompressed_size(),
)];

if let Some(staging) = staging {
needs.push(preflight::Need::or_unsized(
"staging the layers it pulls",
staging.dir,
staging.largest_layer,
"this stele's transport states no compressed size for the layers it would pull",
));
}

Ok(())
preflight::check(&needs)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Make the destination requirement resume-aware.

restore_stele calculates resume-aware remaining layers at Line 846. Plan::preflight still uses self.uncompressed_size(), which includes immutable layers that the checkpoint already committed.

Completed layers already reduce current free space. Counting them again can refuse a resumed restore that has enough space to finish. Pass a resume-aware destination requirement into preflight.

Add a test where completed epoch layers make the full plan exceed free space but the remaining layers fit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/snapshot/src/restore.rs` around lines 282 - 298, Update
Plan::preflight to accept and use the resume-aware remaining destination size
calculated by restore_stele instead of self.uncompressed_size(). Thread that
value through the preflight call while preserving staging checks, and add
coverage for a resumed restore where completed epoch layers exceed available
space collectively but the remaining layers fit.

Comment thread crates/snapshot/src/restore.rs
`staging_peak` summed sixteen manifest-stated sizes with `+`. They are another
document's numbers, and a wrapped total is a *small* need, which is the one
direction a refusal must never be wrong in.

The non-Unix `same_volume` tested containment, which answers
`<storage.path>/scratch` and misses two siblings on one drive. It now compares
the canonical path's prefix — the drive letter or UNC share — and the summing
test covers both shapes, with the sibling directory created so it is measured as
itself rather than through a shared ancestor.

A restore staging need where some layers are sized and others are not took the
known maximum and said nothing. It is a floor, and now says so, the way
`registry::staging_need` already did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@scarmuega
scarmuega merged commit 7a5a659 into main Aug 12, 2026
17 checks passed
@scarmuega
scarmuega deleted the feat/stelae-staging-preflight branch August 12, 2026 12:35
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