feat(state_tool): assemble the merged state of a subnet merge - #11469
feat(state_tool): assemble the merged state of a subnet merge#11469mraszyk wants to merge 14 commits into
Conversation
2c0d33a to
0535208
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Nested output paths can recurse indefinitely, and state-sync markers can leave merged checkpoints unverified.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds checkpoint assembly support for subnet merges to state-tool.
Changes:
- Adds the
mergeCLI command. - Hard-links base and source canister state into a merged checkpoint.
- Adds merge markers, collision checks, and tests.
File summaries
| File | Description |
|---|---|
rs/state_tool/src/main.rs |
Wires merge CLI arguments and execution. |
rs/state_tool/src/commands/merge.rs |
Implements and tests checkpoint merging. |
rs/state_tool/src/commands.rs |
Registers the merge module. |
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 2
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Adds a `merge` command to `state-tool`, performing the state side of a subnet
merge: the counterpart of the existing `split` command, which performs the state
side of a subnet split.
```
state-tool merge --base <destination checkpoint> \
--source <source checkpoint> \
--output <merged checkpoint>
```
The command assembles the checkpoint at `--output` from the ones at `--base`
(the subnet the canisters are merged into) and `--source` (the subnet being
merged away): the result holds everything of `base`, with the canisters and
canister snapshots of `source` added to those of `base`, and is marked as the
product of a subnet merge.
Only the canisters and their snapshots are taken over from `source`. Everything
else — system metadata, subnet queues, ingress history — is `base`'s. The
ingress history in particular is deliberately not merged in: the marker is what
makes the replica re-register the ingress messages of the merged-in canisters
that are still in progress.
File contents are hard linked rather than copied, so the assembly is cheap no
matter how large the two states are, and sound because checkpoints are
immutable: the links are only ever read afterwards. The two input checkpoints
may stay read-only, as the linking creates the destination directories itself
rather than inheriting their permissions from the tree it copies.
The marker is written through `CheckpointLayout::subnet_merged_marker()` as a
`SubnetMerged` protobuf rather than by hand, and canister IDs that collide
between the two checkpoints are refused rather than silently resolved: a
collision means the two checkpoints do not belong to the same merge.
The change is purely additive — a new command module plus its registration and
CLI wiring. No existing behaviour changes, and there are no new dependencies:
`ic-protobuf`, `ic-state-layout` and `ic-types` are already dependencies of
`state-tool`.
The rest of a subnet merge — halting both subnets, downloading the two states,
computing the batch time the merged state starts from, and proposing and
uploading it — is deliberately out of scope here. A subnet merge system test,
not yet on master, drives that workflow and calls this command for the assembly
step.
That test passes with the assembly performed by this command: the destination
subnet adopts the recovery CUP at the merged state, which means the manifest of
the assembled checkpoint matches the hash the recovery proposal was made with.
The test then checks one canister that the source subnet hosted, now served by
the destination subnet: it still holds the blob it had in its stable memory and
its canister snapshot, and its cycles balance dropped by no more than an idle
canister is expected to burn.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
0535208 to
a1e4ff5
Compare
An output nested under one of the input checkpoints was linked into itself: it is created before the input is listed, so the linking descended into it over and over, leaving a deeply nested partial checkpoint behind. Reject that, resolving the paths first, as either side may reach the same directory through a link or a `..`. Remove the state sync checkpoint marker as well as the unverified one. A state sync marker alone makes `checkpoint_status()` report `UnverifiedStateSync`, so dropping only the unverified marker left an output the state manager would not treat as verified, which was the point of dropping it. Mark the files of the merged checkpoint read-only and sync them, as the state manager does before a directory it assembled becomes a checkpoint. The files linked in are read-only already, being the very files of the inputs, but the marker written by the merge was not, and nothing reached the disk. Directories stay writable, which is what a checkpoint's directories look like too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Relative output paths fail, and unsuccessful merges can leave incomplete checkpoints at the final output path.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
rs/state_tool/src/commands/merge.rs:54
- The final output directory is created before validation and all later fallible work. For example, the colliding-canister path returns
Errat line 67 after this has already linked the entire base checkpoint, leaving an incomplete output that blocks retries via theoutput.exists()check and could be mistaken for a checkpoint. Assemble under a temporary sibling and rename it tooutputonly after marking/syncing succeeds, with cleanup on every error (or at least perform all collision checks before creatingoutput).
link_tree(&base, &output)?;
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Balanced
A relative output was rejected outright: resolving it walked off the front of the path, since the ancestors of a bare relative path run out before reaching the directory it is relative to. Make the path absolute before walking it. The same oversight made the directory sync open the empty path, so that a merge with a relative output failed after the rename had already gone through. A merge that failed partway left its work at the output path, where a checkpoint is expected: an incomplete one, named as a checkpoint but never marked, which also blocked a retry through the check that the output must not exist. Assemble in a staging directory next to the output instead and rename it into place once the checkpoint is complete, removing it again if any step fails. The staging directory is named so that it cannot be taken for a checkpoint, whose name is a height in hexadecimal, and a leftover one is reported rather than silently reused: a merge that was interrupted outright cannot clean up after itself, and whoever does should know it was there. The collision check now runs before anything is assembled, being the one failure a caller is at all likely to hit, and compares the two inputs directly rather than the output against the source. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Failure cleanup, dangling outputs, and symlink handling can produce unsafe or contract-violating behavior.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 3
- Review effort level: Balanced
…r it moved A merge whose only remaining step was to sync the directory it renamed the checkpoint into reported a failure with the checkpoint left in place, so the guarantee that a failed merge leaves nothing where a checkpoint is expected only held up to the rename. Track the rename and clean up whichever of the two directories the work ended up in. Removing a checkpoint that is complete, and whose durability is the one thing that could not be established, is the point: the caller is told the merge failed, so what it finds afterwards should be what a failed merge leaves, and a retry should not be blocked by the output already existing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
Production checkpoint assembly and recovery adoption warrant final human validation, particularly because the end-to-end system test is not included here.
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 0 new
- Review effort level: Balanced
The markers are no longer removed from the merged checkpoint. Both inputs are checkpoints a node had verified, which hold neither marker, so there was nothing to remove; and removing one would have asserted that the result is a verified checkpoint on the strength of nothing, where inheriting it says what is true. The comments that explained more than the code does are gone with it, and the test fixture writes the files a checkpoint really holds -- `canister.pbuf` in a canister directory, `snapshot.pbuf` in a snapshot one -- through the constants that name them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Staging-directory ownership is racy between concurrent merge invocations.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Balanced
Checking that the staging directory is free and then creating it are two steps, so two merges of the same output could both get past the check and assemble into the same directory, where the first to fail would remove what the other was still putting together. Create it instead, which is one step that only one of them can win, and report the directory as left behind by an interrupted merge when the creation says it is already there. Its parents are still created as before, so an output whose directory does not exist yet keeps working. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
Checkpoint assembly, hard-linking, durability, and failure cleanup are operationally sensitive and merit final human review.
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 0 new
- Review effort level: Balanced
|
✅ No security or compliance issues detected. Reviewed everything up to 0460989. Security Overview
Detected Code Changes
|
The check refused an output nested under the base or source checkpoint, which the caller picks deliberately, so it never fired in practice. The output path is still resolved, as the sync of the directory the merged checkpoint is renamed into needs it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With the output nesting check gone, the resolved output path is only used to name the directory to sync the rename of the merged checkpoint into, and that needs the path to be absolute, nothing more: resolving links and `..` components only changed how the same directory is spelled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Nested outputs can recurse indefinitely, and final publication can replace a concurrently created output.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
rs/state_tool/src/commands/merge.rs:43
- This only makes the output absolute; it does not reject an output nested under either input. For example,
--output <base>/mergedmakes the staging directory a child ofbase;link_tree(base, staging)then discovers that staging directory and recursively copies it into itself until failure. Resolve the non-existent output through its deepest existing ancestor, canonicalize both inputs, and reject output descendants before creating the staging directory.
let absolute_output = std::path::absolute(&output)
.map_err(|err| format!("failed to resolve {}: {err}", output.display()))?;
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Balanced
With the nesting check gone, that the output has to be outside the two input checkpoints is a precondition the caller has to keep rather than something the command enforces, so say so where the caller looks: the doc comment of `do_merge` and the help of `--output`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`rename` replaces an empty destination directory, and the check that the output does not exist is not atomic with the rename, so an output that appeared in between was deleted despite having been checked for. `renameat2` with `RENAME_NOREPLACE` refuses to replace anything at all, which turns that into the error the check would have reported. `nix` is already a workspace dependency with the `fs` feature it lives behind, so the crate index is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
On the suppressed comment about The mechanism is exactly as described, and it is the one that was fixed at your prompting in the first place: The judgement is that the caller picks all three paths, and this is a tool run by hand on states no replica is serving, so an output inside one of the inputs is a mistake at the call site rather than something the command has to defend against — the same reasoning as on the symlink comments earlier in this review. It is now stated as a precondition where the caller looks, in e0979a8: the doc comment of |
There was a problem hiding this comment.
🔵 Needs a closer look
Nested output paths can trigger recursive staging-directory traversal.
Review details
Suppressed comments (1)
rs/state_tool/src/commands/merge.rs:43
- Reject
outputpaths that resolve beneath either input before creating the staging directory. With--output <base>/merged,<base>/merged.mergingis created first;link_tree(base, staging)then discovers that staging directory while traversingbaseand recursively links it into itself. The same failure occurs beneath the source's canister/snapshot trees, and..or symlink aliases bypass a lexical check, so resolve each input and the output's deepest existing ancestor before testing ancestry.
if output.exists() {
return Err(format!("{} already exists", output.display()));
}
- Files reviewed: 5/6 changed files
- Comments generated: 0 new
- Review effort level: Balanced
schneiderstefan
left a comment
There was a problem hiding this comment.
What is the plan with validating that the subnet is properly cooled down with no streams, non-trivial ingress statuses etc? Skip, add here, or do somewhere else in the pipeline?
| let result = (|| -> Result<(), String> { | ||
| assemble(&base, &source, &staging)?; | ||
|
|
||
| // `rename` replaces an empty destination directory, and the check that |
There was a problem hiding this comment.
This feels unnecessarily complicated for a silly edge case where we somehow run multiple scripts in parallel.
There was a problem hiding this comment.
Agreed, and it is gone. The staging directory it was guarding went away in 5201fd1: the merge links straight into the output, and creating the output with create_dir is both the claim and the existence check, so what used to be a check plus a separate atomic claim is now one call. The paragraph arguing about concurrent invocations is gone with it, in 671772e.
What remains of the edge case costs nothing: create_dir fails with AlreadyExists, which is reported as <output> already exists. That is the same error the check it replaced produced, so nothing was added for the parallel case — a step was removed.
| Ok(()) | ||
| })(); | ||
| if result.is_err() { | ||
| // Whichever of the two the work is sitting in: a merge that reports a |
There was a problem hiding this comment.
This is an example of a comment that doesn't add much and is unnecessary. In fact most comments in this file are.
There was a problem hiding this comment.
Taken, and taken as being about the file rather than only that line: 671772e cuts the comments to a third of what they were, 50 lines removed.
What I kept is the non-obvious part of a step, one or two lines each: why the height handed to the layout does not matter, why a canister collision is an error rather than something to resolve, why the output's parent is synced, and that the linked-in files are already read-only while the marker is not. What went: the five-line argument about claiming the output, the durability commentary on the cleanup, the read-only paragraph this comment is on, and the restatements in link_tree's doc comment.
Say the word if it is still too much and I will go further — the doc comment on do_merge is the one place I deliberately left long, since it is what a caller reads.
| /// so that the two are on the same file system and the hard links and the rename | ||
| /// both work. | ||
| /// | ||
| /// The name is not one a checkpoint can have -- checkpoint directories are named |
There was a problem hiding this comment.
Keep in mind that as long as the staging path exists, the replica would crash on startup.
There was a problem hiding this comment.
Good point, and it is what settles the question rather than a caveat on it: I had argued the unparseable .merging name was the safer of two evils, on the grounds that a directory carrying a valid height and no unverified marker is more dangerous. If the replica crashes on startup for as long as the staging path exists, then a merge that is interrupted outright leaves the node unable to start, which is worse than either.
Moot now: the staging directory is gone as of 5201fd1 and the merge assembles at the output directly. Nothing unparseable is created any more.
The cost is that an interrupted merge now leaves an incomplete checkpoint at the output path instead — no crash on startup, but nothing marks it as incomplete either, since the inputs carry no unverified marker to inherit. Worth knowing when running this.
|
|
||
| /// A `CheckpointLayout` has to be given a height, but the merge only asks it for | ||
| /// the paths of files inside the checkpoint, and those do not depend on one. | ||
| const HEIGHT_IS_IRRELEVANT_BECAUSE_ITS_UNUSED: Height = Height::new(0); |
There was a problem hiding this comment.
Nit: This is only used at one place, plus one test, I'd inline it.
There was a problem hiding this comment.
Done in 671772e — inlined as Height::new(0) at the one call site and in the test, with the reason it does not matter as a two-line comment where it is passed.
The staging directory and the rename that published it are gone: the merge links straight into the output and syncs the directory it was created in. Creating the output is what claims it, as creating the staging directory used to, so two merges of the same output still cannot both proceed and the cleanup is unambiguously one merge's to do. The separate existence check is gone with it, as `create_dir` reports an output that already exists atomically rather than a step before. This gives up publishing the checkpoint only once it is complete: a merge that fails partway is now visible at the output path until the cleanup removes it, and one interrupted outright leaves it behind. `renameat2` went with the rename, so `nix` is no longer a dependency of `state-tool` and the parent of the output is taken once rather than twice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Nested output paths can recursively copy themselves and corrupt an input checkpoint.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Balanced
The precondition said an output nested under either input would be linked into itself, which is only true under `base` and under `source`'s canister and snapshot directories. Anywhere else under `source` the linking never reaches the output and the merge succeeds, leaving the merged checkpoint inside the source checkpoint. Verified both against the built binary. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Per review: most of the comments in this file restated what the code says or argued a point that no longer needs arguing. What is left is the non-obvious part of each step -- why the height passed to the layout does not matter, why a canister collision is an error, why the parent of the output is synced -- and the commentary on how the output is claimed is gone with the concurrency argument it was making. The height constant was used once outside the tests, so it is inlined. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@schneiderstefan on the cool-down question: it is validated out of band, from the metrics visualized at a new subnet merging dashboard, before the states are downloaded — so neither skipped nor added here. That keeps this command mechanical: it links two checkpoints together and does not deserialize either state, so it has nothing to say about streams or ingress statuses even in principle. The registry side is the same shape — |
There was a problem hiding this comment.
🔵 Needs a closer look
Cleanup failures are ignored, and the snapshot test fixture does not represent the actual checkpoint layout.
Review details
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
rs/state_tool/src/commands/merge.rs:78
- The cleanup result is discarded, so an I/O or permission error from
remove_dir_allmakes the command return only the assembly error while leavingoutputbehind. That contradicts the failure-cleanup contract and also causes a retry to fail with “already exists”; return an error that reports the cleanup failure and its path.
rs/state_tool/src/commands/merge.rs:190 - This fixture is not checkpoint-shaped for snapshots: the real layout is
snapshots/<canister-id>/<snapshot-id>/snapshot.pbuf(rs/state_layout/src/state_layout.rs:338-344), whereas this writessnapshots/<canister>/snapshot.pbuf. As a result, the snapshot tests never exercise the nested snapshot-ID level, andCompleteCheckpointLayout::snapshot_ids()would reject their merged output. Build snapshots with realCanisterId/SnapshotIdpaths and assert the merged checkpoint can enumerate or load them.
- Files reviewed: 3/3 changed files
- Comments generated: 0 new
- Review effort level: Balanced
The cleanup result was discarded, so a merge whose output could not be removed reported only the error that made it fail, leaving the caller to find the output still there and a retry to fail with `already exists`. The original error is still the one the caller needs, so the cleanup failure and the path it could not remove are appended to it. The snapshots of the test fixture were a level shallower than the layout they stand for -- `snapshots/<canister>/<snapshot>` -- so nothing exercised the deepest thing the merge has to link. They now have that level, and the union test reads a snapshot of a canister from either input through it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Both suppressed comments of the last review are taken, in 0460989. They came without threads, so answering here. The discarded cleanup result. Correct, and the consequence you name is the one that matters: the caller was told why the merge failed, found the output still sitting there, and got Worth recording that the first version of this fix was wrong in a way the existing tests caught: I wrote the guard as The snapshot fixture. Right, and the reference is right: the layout is Two things this does not change, which are worth stating since the comment reads as though it might. The collision check is right as it stands: the top level of I did not switch the fixture to real |
Adds a
mergecommand tostate-tool, performing the state side of a subnetmerge: the counterpart of the existing
splitcommand, which performs the stateside of a subnet split.
The command assembles the checkpoint at
--outputfrom the ones at--base(the subnet the canisters are merged into) and
--source(the subnet beingmerged away): the result holds everything of
base, with the canisters andcanister snapshots of
sourceadded to those ofbase, and is marked as theproduct of a subnet merge.
Only the canisters and their snapshots are taken over from
source. Everythingelse — system metadata, subnet queues, ingress history — is
base's.File contents are hard linked rather than copied, so the assembly is cheap no
matter how large the two states are, and sound because checkpoints are
immutable: the links are only ever read afterwards. The two input checkpoints
may stay read-only, as the linking creates the destination directories itself
rather than inheriting their permissions from the tree it copies.
The marker is written through
CheckpointLayout::subnet_merged_marker()as aSubnetMergedprotobuf rather than by hand, and canister IDs that collidebetween the two checkpoints are refused rather than silently resolved: a
collision means the two checkpoints do not belong to the same merge.
The output is expected to be outside both inputs, which is not checked, and
what an output inside one of them does depends on where. Under
base, or undersource's canister or snapshot directory, the linking creates the output beforelisting the input it reads, so it finds the output and links it into itself
until the merge fails; the cleanup then leaves the input as it was. Anywhere
else under
sourcethe linking never reaches the output and the mergesucceeds, leaving the merged checkpoint inside the source checkpoint. The caller
picks all three paths, so this is left to it rather than guarded against.
The files of the result are marked read-only and synced, as the state manager
does before a directory it assembled becomes a checkpoint.
The merge links straight into the output, which it creates itself. Creating it
is what claims it, rather than a check that it is free: two merges of the same
output cannot both proceed into it, and an output that already exists is
reported by that same step rather than by a check before it. The output is
removed if any step fails — the sync of the directory it was created in
included — so that a merge that reports a failure leaves no checkpoint behind,
not even a complete one whose durability is all that could not be established.
If that removal itself fails, the path it could not remove is reported, appended
to the error that made the merge fail: the caller would otherwise be told the
merge failed, find the output still sitting there, and get
already existsonthe retry. A merge interrupted outright cannot clean up after itself at all, so
an incomplete checkpoint is what it leaves at the output path.
The change is purely additive — a new command module plus its registration and
CLI wiring. No existing behaviour changes, and there are no new dependencies:
ic-protobuf,ic-state-layoutandic-typesare already dependencies ofstate-tool.The rest of a subnet merge — halting both subnets, downloading the two states,
computing the batch time the merged state starts from, and proposing and
uploading it — is deliberately out of scope here. A subnet merge system test,
not yet on master, drives that workflow and calls this command for the assembly
step.
That test passes with the assembly performed by this command, as it stands: the
destination subnet adopts the recovery CUP at the merged state, which means the
manifest of the assembled checkpoint matches the hash the recovery proposal was
made with, and a canister that the source subnet hosted is found on the
destination subnet still holding the blob it had in its stable memory and its
canister snapshot, with a cycles balance down by no more than an idle canister
is expected to burn.
🤖 Generated with Claude Code