feat(snapshot): export a stele from a live node - #1168
Conversation
Adds `crates/snapshot/src/export.rs`, the driver that walks a live store set and hands records to the protocol in ADR-004's order, plus the `dolos snapshot publish` command that drives it. No byte shape is invented: every record shape, ordering rule and name was already pinned by a golden in #1166, and the streaming sink from #1167 is what keeps a mainnet-sized layer out of memory. Two facts nothing else in the tree settled are settled here. `position.network.name` is now a fixed function of the magic — `Network::for_magic` is the only constructor and the fields are private, so a name read from configuration cannot put two digests on one chain. And the epoch geometry of a publish: `sequence` is `epoch_of(cursor) + 1`, layers cover `0..=epoch_of(cursor)`, and the final window is clamped to the cursor so a stele cut mid-epoch is still byte-identical between two publishers standing at the same point. `ToyDomain` gains a `ToyStores` backend parameter defaulting to the builtin memory pair, so the same harness can be pointed at fjall. That is what makes the two-backend determinism check possible without a sibling harness type; every existing consumer is untouched. Findings reported rather than absorbed, in the plan and in the PR body: one epoch layer costs a full scan of the index store, so a first publish is O(epochs²); `EntityKey::full_range()` excludes its own maximum key; and the harness fixture cannot cross an epoch boundary under `strict`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughThis PR adds live snapshot planning and export to ChangesSnapshot export and publish flow
Estimated code review effort: 5 (Critical) | ~100 minutes Sequence Diagram(s)sequenceDiagram
participant User
participant CLI as dolos snapshot publish
participant Stores as archive/state/index stores
participant Export as dolos_snapshot::export
participant Output as stele directory
User->>CLI: run publish with output_dir and optional epochs
CLI->>Stores: open configured stores and genesis data
CLI->>Export: build Plan and apply epoch restriction
Export->>Stores: read blocks, logs, indexes, state, and digests
Export->>Output: write snapshot layers and inscription
CLI-->>User: report digest, layer count, and size
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (6)
crates/snapshot/src/export.rs (1)
521-637: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd a unit test for the empty
digestsrefusal.The tests cover the epoch geometry rules well. One documented contract in this file has no test:
write_digestsrejects an empty record slice with the message "a digests layer with no records names no immutable file; omit the layer instead". A caller reaches it throughexportwithSome(&[]). A test pins that error variant so a later refactor cannot turn the empty case into a layer that names immutable file zero.Also consider a case for
restrict_epochswith bounds outside the covered range, which lines 145-146 document as selecting nothing.🤖 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/export.rs` around lines 521 - 637, Add a unit test in the existing tests module that calls export with Some(&[]) for digests and asserts write_digests rejects it with the documented error message about omitting an empty digests layer; also add coverage for restrict_epochs bounds outside the available epochs selecting no windows if supported by the existing API.crates/snapshot/tests/export.rs (1)
213-215: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueUse
u64::frominstead of anascast.
src/bin/dolos/snapshot/publish.rsline 99 calls the same API asu64::from(genesis.network_magic()). This line usesas u64. Keep the two call sites the same, and prefer the infallible conversion: ifnetwork_magic()ever returns a wider type,as u64truncates silently whileu64::fromfails to compile.♻️ Proposed change
fn plan_for<B: ToyStores>(domain: &ToyDomain<B>) -> Plan { - export::plan(domain.state(), domain.genesis().network_magic() as u64).unwrap() + export::plan(domain.state(), u64::from(domain.genesis().network_magic())).unwrap() }🤖 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/tests/export.rs` around lines 213 - 215, Update plan_for’s network_magic conversion to use the infallible u64::from form instead of an as u64 cast, matching the corresponding snapshot publish call site while leaving the export::plan invocation unchanged.tests/snapshot_publish.rs (1)
185-188: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAssert that the refused publish left the first stele intact.
The comment states the refusal prevents a second stele's blobs from layering on top of the first. The assertion checks only the exit status. A command that wrote some blobs and then failed would still pass.
Re-open and re-verify the directory after the refused run to prove the stated property.
♻️ Proposed change
let second = node.publish(&out, &[]); assert!(!second.status.success()); + + // The refused run left the first stele exactly as it was. + let reopened = SteleDir::open(&out).unwrap(); + assert_eq!(reopened.read_inscription().unwrap(), inscription); + assert_eq!(reopened.blob_index().unwrap().len(), index.len());🤖 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 `@tests/snapshot_publish.rs` around lines 185 - 188, Extend the refused republish test around node.publish so it reopens the existing output directory after the failed second publish and verifies the original stele remains intact, including its expected contents or identity. Keep the existing unsuccessful-status assertion and use the test’s established directory-opening and verification helpers where available.crates/testing/src/toy_domain.rs (1)
196-200: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueDocument that
_dirmust stay the last field.Struct fields drop in declaration order.
stateandindexesclose before_dirremoves the directory, which is the order fjall needs. If a later refactor moves_dirabove the stores,TempDir::dropruns while fjall still holds open file handles. On Windows the removal then fails silently and the temporary directory leaks.The doc comment explains why
_diris anArc. Add why it is last.♻️ Proposed change
pub struct FjallStores { state: dolos_fjall::StateStore, indexes: dolos_fjall::IndexStore, + // Last on purpose: fields drop in declaration order, so both stores close + // before the directory is removed. _dir: Arc<tempfile::TempDir>, }🤖 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/testing/src/toy_domain.rs` around lines 196 - 200, Add a doc comment to the _dir field in FjallStores documenting that it must remain the last field so state and indexes are dropped before TempDir removes the directory; preserve the existing explanation for why _dir is an Arc.src/bin/dolos/snapshot/publish.rs (1)
108-108: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueAdd
.contextto the remaining fallible calls.Lines 99 and 137 attach context to their errors. Lines 108 and 148 do not. If
plan.tag()orinscription.digest()fails, the operator sees a bare error with no indication of which step produced it.♻️ Proposed change
- let tag = plan.tag().into_diagnostic()?; + let tag = plan + .tag() + .into_diagnostic() + .context("computing the stele tag")?;- let digest = inscription.digest().into_diagnostic()?; + let digest = inscription + .digest() + .into_diagnostic() + .context("computing the inscription digest")?;Also applies to: 148-148
🤖 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 `@src/bin/dolos/snapshot/publish.rs` at line 108, Update the fallible calls in the snapshot publish flow at plan.tag() and inscription.digest() to attach descriptive .context messages before converting their errors with into_diagnostic(). Match the existing contextual error-handling style used by the nearby calls so failures identify the operation that produced them.src/bin/dolos/main.rs (1)
11-11: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueGate the
snapshotmodule with a dedicated Cargo feature.The published
Snapshotcommand and itsdolos-snapshotdependency are enabled unconditionally inCargo.toml, unlike nearby optional commands such asbootstrap,minibf, andminikupo. Gatemod snapshot, the command variant, and the dependency under asnapshotfeature sodolos-snapshotandstelaeare not included in a minimal node build unless publishing is intended.🤖 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 `@src/bin/dolos/main.rs` at line 11, Gate the snapshot functionality behind a dedicated snapshot Cargo feature: conditionally compile the mod snapshot declaration in main.rs and the corresponding Snapshot command variant, and mark the dolos-snapshot dependency and stelae dependency optional in Cargo.toml while wiring them into the feature. Preserve the existing behavior when the snapshot feature is enabled and exclude these dependencies from minimal node builds.Source: Coding guidelines
🤖 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/export.rs`:
- Around line 385-397: Update export() to use one consistent IndexStore snapshot
for all block, index, log, state, archive-tag, and exact-record reads, rather
than opening independent point-in-time views per store call. Ensure every
iterator, including iter_archive_tags and iter_exact_records, reads from that
shared snapshot; alternatively, revalidate Plan::cursor after publishing and
fail if it changed.
In `@tests/snapshot_publish.rs`:
- Around line 35-58: Update the TOML template in the snapshot_publish test to
use single-quoted literal strings for the interpolated data and genesis paths,
including storage.path, byron_path, shelley_path, alonzo_path, and conway_path.
Leave non-path string values unchanged so Windows backslashes are treated
literally during toml::from_str parsing.
---
Nitpick comments:
In `@crates/snapshot/src/export.rs`:
- Around line 521-637: Add a unit test in the existing tests module that calls
export with Some(&[]) for digests and asserts write_digests rejects it with the
documented error message about omitting an empty digests layer; also add
coverage for restrict_epochs bounds outside the available epochs selecting no
windows if supported by the existing API.
In `@crates/snapshot/tests/export.rs`:
- Around line 213-215: Update plan_for’s network_magic conversion to use the
infallible u64::from form instead of an as u64 cast, matching the corresponding
snapshot publish call site while leaving the export::plan invocation unchanged.
In `@crates/testing/src/toy_domain.rs`:
- Around line 196-200: Add a doc comment to the _dir field in FjallStores
documenting that it must remain the last field so state and indexes are dropped
before TempDir removes the directory; preserve the existing explanation for why
_dir is an Arc.
In `@src/bin/dolos/main.rs`:
- Line 11: Gate the snapshot functionality behind a dedicated snapshot Cargo
feature: conditionally compile the mod snapshot declaration in main.rs and the
corresponding Snapshot command variant, and mark the dolos-snapshot dependency
and stelae dependency optional in Cargo.toml while wiring them into the feature.
Preserve the existing behavior when the snapshot feature is enabled and exclude
these dependencies from minimal node builds.
In `@src/bin/dolos/snapshot/publish.rs`:
- Line 108: Update the fallible calls in the snapshot publish flow at plan.tag()
and inscription.digest() to attach descriptive .context messages before
converting their errors with into_diagnostic(). Match the existing contextual
error-handling style used by the nearby calls so failures identify the operation
that produced them.
In `@tests/snapshot_publish.rs`:
- Around line 185-188: Extend the refused republish test around node.publish so
it reopens the existing output directory after the failed second publish and
verifies the original stele remains intact, including its expected contents or
identity. Keep the existing unsuccessful-status assertion and use the test’s
established directory-opening and verification helpers where available.
🪄 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: 35d3ae80-921b-40d5-b34c-fda6b2ca4a4b
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (11)
Cargo.tomlcrates/snapshot/Cargo.tomlcrates/snapshot/src/export.rscrates/snapshot/src/lib.rscrates/snapshot/tests/common/mod.rscrates/snapshot/tests/export.rscrates/testing/src/toy_domain.rssrc/bin/dolos/main.rssrc/bin/dolos/snapshot/mod.rssrc/bin/dolos/snapshot/publish.rstests/snapshot_publish.rs
The publish fixture interpolated temp paths straight into TOML basic strings. A Windows temp path is `C:\Users\RUNNER~1\...`, and `\U` inside a basic string opens an eight-digit unicode escape — so the config parsed on Linux and macOS and was a syntax error on Windows, failing all three `snapshot_publish` tests there. `toml::Value`'s own `Display` is the escaping rule, so the fixture no longer has an opinion about it. The regression test builds the path a Windows runner actually produces and runs on every platform, since a backslash is a legal filename character on Unix too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan:
plans/dolos-stelae-export.mdin the TxPipe domain root (Phase 2 ofdolos-stelae-snapshots, the export half). Base ismainat2276f55a— the #1167 streaming-sink merge this plan awaited.Done criterion: met. All seven items below, with the verification run at the end.
What changed
crates/snapshot/src/export.rsis the whole of it: the driver that walks a live store set and hands records to the protocol in ADR-004's order. It invents no byte shape — every record shape, ordering rule and name was already pinned by a golden in #1166 — and it streams throughSteleDir::layer_sink, so no layer is ever materialized.blocksfromArchiveStore::get_range, decoding each block's header for the hash the codec takes as an input;logsfromiter_logsoverNAMESPACESin registry order, which is the(ns, log_key)sortedness the codec requires;indexesfromiter_archive_tagstheniter_exact_records, in the order the trait already promises.shard_ofand checked by its shard's ownOrderCheck. All sixteen shards are always written, empty ones included, so the shard count a reader sees is never data-dependent.dolos snapshot publish --output-dir DIR [--epochs RANGE] [--dry-run], a new command group wired intomain.rs. The epoch range takes Rust's own spellings (500..520,500..=520,500..,..520,500) because silently picking one meaning of..is how an operator publishes an epoch short.Two facts nothing else in the tree settled are settled here.
position.network.namehas no source, so it is now a fixed function of the magic.Network::for_magicis the only constructor and the fields are private:764824073 → mainnet,1 → preprod,2 → preview, anything else →testnet-{magic}. The name rides inside the canonical JSON, so a name read from user configuration would let two publishers on one chain produce two different digests over a spelling.Epoch geometry.
sequenceisepoch_of(cursor) + 1— ADR-004's E, the newly started epoch — and layers cover0..=epoch_of(cursor). The final window'send_slotismin(epoch_start(e+1) - 1, cursor.slot), so a stele cut mid-epoch is still byte-identical between two publishers standing at the same point. Landing on a true boundary stays the publisher pipeline's job.ToyDomaingains aToyStoresbackend parameter defaulting to the builtin memory pair, so the same harness can be pointed at fjall. That is what makes criterion 3 possible without a sibling harness type; every existing consumer compiles unchanged.Done criteria
a_harness_domain_exports_a_complete_stele.logslayer, both index record shapes, and all sixteen state shards —every_layer_reads_back_as_the_store_yields_it. Each expectation is collected from the store and sorted independently rather than by repeating the exporter's registry walk, so an ordering bug shared by both would still show.both_backends_publish_the_same_inscriptionapplies the same blocks to a fjall-backed store set and a builtin-memory-backed one and compares layer by layer, then the whole document, then the blobs. It passed on the first run — the residual risk ADR-004 named did not materialize, and no entity encoder needed a fix.an_empty_store_set_exports_the_pinned_skeleton. Deliberately over an empty store set: the layers carry only their header records, so the pinned document is a function of literals alone and freezes the network table, thesequence/position/parametersshapes, the order layers are listed in, and every header and scope encoding — without being something a ledger change re-pins as a matter of routine.tests/snapshot_publish.rsbuilds a real on-disk node from adolos.toml, runs the actual binary as a subprocess, and checksSteleDir::openplus a cleanblob_index(which decompresses and re-hashes every blob). Also covers--dry-run,--epochs, and that republishing over an existing stele is refused.iter_exact_records— below.Criterion 7: the per-epoch index cost
Measured with the harness #1165 built (
cargo test --release --test index_roundtrip -- --ignored --nocapture measure_one_epoch_iteration_cost), fjall, Apple M4 / 16 GiB:Four times the depth costs 3.7 times the time to slice one epoch, and the epoch sliced is the same size in both rows. The cost is O(store), not O(range) — exactly as the trait documents.
So a first publish is O(epochs²). Extrapolating to mainnet's ~580 epochs at the fixture's per-epoch shape: ~376M tag records and ~63M exact records, at the measured ~8.0M tags/s and ~8.8M exacts/s, is ~54 s per epoch layer and ~8.7 hours for the
indexeslayers of one publish. The fixture assumes 3 txs per block, well under mainnet's recent density, so that is a floor.This is stated, not absorbed. It is filed as
plans/dolos-stelae-publish-cost.md(draft) — band the traversal over K epochs per pass, the same move the state pass makes with sixteen shard sinks, with K sized against a memory ceiling rather than by taste. Filed together with progress reporting, which the export slice left out deliberately rather than widening its own API, and which an eight-hour silent command obviously needs.Two other findings
EntityKey::full_range()excludes its own maximum. Its end bound is[0xff; 32]anditer_entitiesis half-open, so an entity keyed with thirty-two0xffbytes is invisible to every caller, export included. A definitional limit of a fixed-width key type rather than a defect — there is no representable exclusive bound above the maximum key — and unreachable in practice since state keys are hash-derived. Noted at the call site rather than silently inherited.The harness fixture cannot cross an epoch boundary under
strict. Preview at protocol 6 raisesPParamsNotFound("drep_inactivity_period")on the epoch-0/1 transition; devnet overflows the pots. Same class as the exclusions CI already carries fordolos-minibf/minikupo/trp. Rather than joining that list, the export fixtures stay inside one epoch and multi-epoch geometry is pinned by the three-epoch skeleton golden andPlan's unit tests instead — it is arithmetic over aChainSummary, not something a ledger fixture proves.Verification
cargo test --workspace --all-targets— green.cargo test --workspace --all-targets --all-features --exclude dolos-minibf --exclude dolos-minikupo --exclude dolos-trp(CI's command) — green, 28 targets. The two new targets are not excluded.cargo clippy --all-targets --all-features -- -D warnings— clean.cargo +nightly fmt --all -- --check— clean.cargo deny check advisories— ok.cargo tree -p stelae -e normal— matches nothing^dolos(-|$).No escalation was raised. No store trait changed and
crates/stelaeis untouched.Draft until reviewed; this role does not merge its own work.
🤖 Generated with Claude Code
Summary by CodeRabbit
dolos snapshot publishcommand with epoch selection, dry-run mode, and output validation.