Repository navigation
Record a storage schema version, and repair the history encoding #3408
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
3e105da
feat(ledger-store): record a storage schema version, and repair histo…
cbeck88 66ba0d6
fix(ledger-store): remove the legacy history read and write paths
cbeck88 cdc3cf9
fix(ledger-store): handle mixed encodings, and make the cursor atomic…
cbeck88 eb8e1bc
refactor(ledger-store): retire the heights map, and close the review'…
cbeck88 f23a6ea
fix(ledger-store): record undecidable entries and fail naming them
cbeck88 00c25a1
feat(ledger-store): add a read-only history schema checker
cbeck88 bcc5b90
refactor(ledger-store): migrate from a standalone tool, deciding befo…
cbeck88 f811e58
fix(ledger-store): a palindromic height needs no decision
cbeck88 ffd339b
refactor(ledger-store): fold the checker into migrate-db as --check
cbeck88 2e7edf8
fix(ledger-store): bound the open files the migration tool holds
cbeck88 1055c3e
fix(ledger-store): read the chain tip, and record the schema version
cbeck88 3a7042d
style(ledger-store): order imports std, external, crate-local
cbeck88 0858472
docs(ledger-store): record why the version gate is on data, not features
cbeck88 b67adbc
feat(ledger-store): report what a repair decision actually needs
cbeck88 1b243ca
fix(ledger-store): drop a needless mut, restoring a clean clippy
cbeck88 29021f2
fix(ledger-store): judge a lone ambiguous entry against the ledger
cbeck88 28e8fb2
fix(ledger-store): correct the runtime estimate, drop an inference th…
cbeck88 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,212 @@ | ||
| // Copyright (c) 2019-2026 Provable Inc. | ||
| // This file is part of the snarkVM library. | ||
|
|
||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at: | ||
|
|
||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
|
|
||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| //! Brings a ledger's storage schema up to the version this build requires. | ||
| //! | ||
| //! Separate from the node deliberately. The work can rewrite billions of entries and take hours, | ||
| //! which is not something a node should do as a side effect of starting: an operator wants to run | ||
| //! it when they choose, against a copy first, watching it, able to stop and resume it. The node | ||
| //! only checks the version and refuses to run against a schema it does not understand. | ||
| //! | ||
| //! ```text | ||
| //! snarkvm-migrate-db --check ~/.aleo/storage/ledger-0 # read-only: what would this do? | ||
| //! snarkvm-migrate-db ~/.aleo/storage/ledger-0 # do it | ||
| //! ``` | ||
| //! | ||
| //! `--check` opens the database read-only and writes nothing, so it is safe against a copy of a | ||
| //! production ledger. Without it the node must be stopped: RocksDB permits a single writer, so a | ||
| //! migration will refuse to open a ledger a node still holds. | ||
|
|
||
| use anyhow::{Result, bail}; | ||
| use snarkvm_ledger_store::helpers::rocksdb::{PREFIX_LEN, STORAGE_VERSION, is_resuming, migrate, plan, schema_version}; | ||
|
|
||
| /// Entries per second, measured migrating a 234-million-entry ledger end to end. | ||
| /// | ||
| /// An earlier value of 300,000 came from synthetic benchmarks on a freshly built database and was | ||
| /// roughly seven times optimistic: it advertised thirteen minutes for a run that took ninety. An | ||
| /// operator plans a maintenance window around this number, so it is taken from a real migration | ||
| /// (m7i.4xlarge, gp3) rather than from a microbenchmark, and rounded down. | ||
| const ENTRIES_PER_SECOND: f64 = 40_000.0; | ||
|
|
||
| /// The ledger to act on, and whether to only report. | ||
| struct Args { | ||
| path: String, | ||
| network_id: u16, | ||
| check: bool, | ||
| } | ||
|
|
||
| const USAGE: &str = "\ | ||
| usage: snarkvm-migrate-db [--check] <ledger-dir> [network-id] | ||
|
|
||
| snarkvm-migrate-db --check ~/.aleo/storage/ledger-0 report what a migration would do | ||
| snarkvm-migrate-db ~/.aleo/storage/ledger-0 perform it | ||
|
|
||
| --check opens the ledger read-only and writes nothing. Without it, stop the node first. | ||
| The network is inferred from a directory name ending in `-<id>` (0 = mainnet, 1 = testnet, | ||
| 2 = canary), or given as the final argument."; | ||
|
|
||
| /// Returns the network id encoded in a ledger directory name, if it has one. | ||
| /// | ||
| /// Production ledgers are `ledger-{network}`; development ones are `.ledger-{network}-{id}`, so a | ||
| /// naive split from the right yields the dev id instead. Getting this wrong is quiet rather than | ||
| /// loud -- the tool finds no history under the wrong prefix, reports nothing to do, and leaves the | ||
| /// real history untouched -- so an id outside the known range is refused rather than assumed. | ||
| fn network_from_name(path: &str) -> Option<u16> { | ||
| let name = std::path::Path::new(path).file_name()?.to_str()?; | ||
| let rest = name.strip_prefix('.').unwrap_or(name).strip_prefix("ledger-")?; | ||
| let network = rest.split('-').next()?.parse().ok()?; | ||
| // 0 = mainnet, 1 = testnet, 2 = canary. Anything else is a misread name. | ||
| (network <= 2).then_some(network) | ||
| } | ||
|
|
||
| fn parse() -> Result<Args> { | ||
| let mut check = false; | ||
| let mut positional = Vec::new(); | ||
| for arg in std::env::args().skip(1) { | ||
| match arg.as_str() { | ||
| "--check" | "-c" => check = true, | ||
| // Asking for help is not an error. | ||
| "--help" | "-h" => { | ||
| println!("{USAGE}"); | ||
| std::process::exit(0); | ||
| } | ||
| flag if flag.starts_with('-') => bail!("Unrecognized option {flag:?}\n\n{USAGE}"), | ||
| _ => positional.push(arg), | ||
| } | ||
| } | ||
|
|
||
| let Some(path) = positional.first().cloned() else { bail!("{USAGE}") }; | ||
| let network_id = match positional.get(1) { | ||
| Some(explicit) => explicit.parse()?, | ||
| None => match network_from_name(&path) { | ||
| Some(id) => id, | ||
| None => bail!("Could not infer the network from {path:?}.\n\n{USAGE}"), | ||
| }, | ||
| }; | ||
| Ok(Args { path, network_id, check }) | ||
| } | ||
|
|
||
| /// How many SST files RocksDB may hold open at once. | ||
| /// | ||
| /// Deliberately far below the node's own limit. Left at RocksDB's default of unlimited, a tool | ||
| /// scanning a multi-terabyte ledger opens every SST and keeps each one's index and filter blocks | ||
| /// resident: on a 1.7 TiB ledger that exhausted the default 1024 file-descriptor limit outright, | ||
| /// and reached 4.6 GB of resident memory where the work itself needs a few hundred megabytes. | ||
| /// | ||
| /// A node wants a high limit because it serves random reads. This tool makes one sequential pass, | ||
| /// so it gains almost nothing from keeping files open, and a small bound keeps it inside the | ||
| /// default `ulimit -n` that an operator's shell will have. | ||
| const MAX_OPEN_FILES: i32 = 256; | ||
|
|
||
| /// The options a node opens the ledger with, save for the open-file bound. | ||
| /// | ||
| /// The prefix extractor matters: it puts iteration into prefix-seek mode, so inspecting or | ||
| /// migrating under different options would not be operating on the database as the node sees it. | ||
| fn options() -> rocksdb::Options { | ||
| let mut options = rocksdb::Options::default(); | ||
| options.set_compression_type(rocksdb::DBCompressionType::Lz4); | ||
| options.set_prefix_extractor(rocksdb::SliceTransform::create_fixed_prefix(PREFIX_LEN)); | ||
| options.set_max_open_files(MAX_OPEN_FILES); | ||
| options | ||
| } | ||
|
|
||
| fn main() -> Result<()> { | ||
| let args = parse()?; | ||
|
|
||
| // Read-only for --check, so that inspecting a ledger cannot modify it. Note this may read a | ||
| // database whose write-ahead log has not been replayed, so on a ledger left by an unclean | ||
| // shutdown the very latest entries may not be visible. | ||
| let database = match args.check { | ||
| true => rocksdb::DB::open_for_read_only(&options(), &args.path, false)?, | ||
| false => rocksdb::DB::open(&options(), &args.path)?, | ||
| }; | ||
|
|
||
| // Two point lookups before any scan: an already-migrated ledger should not be walked end to | ||
| // end just to print a status line, and neither should one being resumed. | ||
| // Compared against the version this build needs, not against zero: a tool that treats "any | ||
| // version at all" as done would refuse to run every future migration, telling an operator | ||
| // there is nothing to do about the very thing their node is refusing to start over. | ||
| if schema_version(&database, args.network_id)? >= STORAGE_VERSION { | ||
| println!("{} is already at storage schema v{STORAGE_VERSION}. Nothing to do.", args.path); | ||
| return Ok(()); | ||
| } | ||
| if is_resuming(&database, args.network_id)? { | ||
| // A half-migrated map holds both encodings on the keys it had begun, so classifying it | ||
| // would report a ledger as unrepairable that would in fact finish perfectly well. | ||
| if args.check { | ||
| println!("A migration of {} was interrupted and has not finished.\n", args.path); | ||
| println!("Its state cannot be assessed while partly migrated -- the keys it had begun"); | ||
| println!("hold both encodings. Re-run without --check to finish it:\n"); | ||
| println!(" snarkvm-migrate-db {}", args.path); | ||
| return Ok(()); | ||
| } | ||
| println!("Resuming an interrupted migration of {}\n", args.path); | ||
| tracing_subscriber::fmt().with_env_filter("info").with_target(false).init(); | ||
| migrate(&database, args.network_id, None)?; | ||
| println!("\nMigration complete. The node can now be started."); | ||
| return Ok(()); | ||
| } | ||
|
|
||
| let report = plan(&database, args.network_id)?; | ||
|
|
||
| let total = report.little_endian + report.big_endian; | ||
| println!("{} (network {})\n", args.path, args.network_id); | ||
| let span = |range: Option<(u32, u32)>| match range { | ||
| Some((low, high)) => format!("{low}..{high}"), | ||
| None => "none".to_string(), | ||
| }; | ||
| println!(" chain tip {}", report.tip); | ||
| println!(" mapping keys {}", report.keys); | ||
| println!(" entries {total}"); | ||
| println!(" to migrate {} over heights {}", report.little_endian, span(report.little_endian_range)); | ||
| println!(" already done {} over heights {}", report.big_endian, span(report.big_endian_range)); | ||
| println!(" mixed keys {} (hold both encodings; only these can be undecidable)", report.mixed_keys); | ||
| println!(" undecidable {} across {} keys\n", report.undecidable.len(), report.undecidable_keys); | ||
|
|
||
| // Deliberately no inference from the big-endian span to "a build writing that format ran for N | ||
| // blocks". On a ledger written only by v4.8.0 that sentence reported a 2.3-million-block window | ||
| // for a build that had never touched it: entries whose two readings are identical are counted | ||
| // as already migrated, since moving them is a no-op, and they carry heights of their own. The | ||
| // spans above are facts; that reading of them was not. | ||
|
|
||
| if !report.undecidable.is_empty() { | ||
| println!("This ledger CANNOT be migrated and must be resynced from genesis.\n"); | ||
| println!("{} entries cannot be attributed to a block height. Each reads as a", report.undecidable.len()); | ||
| println!("plausible height under either encoding, on a key that holds both:"); | ||
| for entry in report.undecidable.iter().take(10) { | ||
| println!(" height {} (little-endian) or {} (big-endian)", entry.little, entry.big); | ||
| } | ||
| if report.undecidable.len() > 10 { | ||
| println!(" ... and {} more", report.undecidable.len() - 10); | ||
| } | ||
| // A refusal is a refusal in either mode; nothing has been modified. | ||
| bail!("{} entries cannot be attributed to a block height", report.undecidable.len()); | ||
| } | ||
|
|
||
| let minutes = report.little_endian as f64 / ENTRIES_PER_SECOND / 60.0; | ||
| if args.check { | ||
| println!("This ledger can be migrated in place. Estimated {minutes:.1} minutes.\n"); | ||
| println!(" snarkvm-migrate-db {}", args.path); | ||
| return Ok(()); | ||
| } | ||
|
|
||
| // The migration reports progress through `tracing`; without a subscriber a multi-hour | ||
| // run would print nothing at all. | ||
| tracing_subscriber::fmt().with_env_filter("info").with_target(false).init(); | ||
| println!("Migrating. Estimated {minutes:.1} minutes; safe to interrupt and resume.\n"); | ||
| migrate(&database, args.network_id, Some(&report))?; | ||
| println!("\nMigration complete. The node can now be started."); | ||
| Ok(()) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
New dependency (and a new
[[bin]]above). Per AGENTS.md both need explicit approval; flagging so it is a conscious decision rather than an implicit one.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
true, and we should discuss whether we want
snarkvm-db-migrateto be a standalone binary (so that the operator explicitly runs migration) or something that runs when the node starts up unconditionally.usually in webapps you want to have a human watching when migration happens, because if migration goes wrong you can get corrupted data base or data loss. for a blockchain it might not be important to do that because all this data is highly replicated anyways by design, and backed up on CDNs etc.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Signed off on both the
[[bin]]and thetracing-subscriberdependency — thank you for flagging them rather than letting them pass implicitly.The larger question they point at is genuinely open, and worth settling before this merges: should the migration be a standalone binary at all, or a
snarkos ledger migratesubcommand?The case for standalone, which is what is here now:
snarkvm-ledger-storeand works on raw prefixes, so a binary beside it has no dependencies a node would drag in.The case for a subcommand, which I now think is stronger than I judged when I built this:
snarkvm-ledger-storewith--features rocks, and a binary that lives in this repo has no route to them without one being invented.snarkos --helpis where someone looks after the node tells them to migrate. A tool named in an error message but absent from the binary they have is a poor handoff.ledger migratesubcommand is precisely what they can run. So that argument does not hold, and I should not have leaned on it.What does not change either way: the work belongs in
snarkvm-ledger-storewhere the storage layout is, and the node refuses to start on a schema it does not understand rather than migrating implicitly. The question is only which binary the entry point hangs off.I have no strong preference and would rather defer to whoever owns the release and packaging story. If the answer is a subcommand, the change is small — the two entry points are thin wrappers over
plan()andmigrate().Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In general, I think it makes sense for migrations not to be run automatically/unconditionally:
As an exception, the design in #3329 is run automatically/unconditionally but it's a special case: because it only prunes data there is no interference with consensus. And anyhow we can decide later on the final architecture for that PR.
That leaves the question of snarkVM binary v.s. snarkOS migrate. One more advantage of the
snarkOS migrateapproach is that this could potentially also cover migrations ofBFTPersistentStorage. IfBFTPersistentStoragewould be moved into snarkVM this benefit would be void, but that would be yet another huge work package.So I'm in favour of a
snarkOS migratecommand - for which most logic can live in snarkVM, "close" to the db logic.