Skip to content

feat(state_tool): assemble the merged state of a subnet merge - #11469

Open
mraszyk wants to merge 14 commits into
masterfrom
mraszyk/state-tool-merge
Open

feat(state_tool): assemble the merged state of a subnet merge#11469
mraszyk wants to merge 14 commits into
masterfrom
mraszyk/state-tool-merge

Conversation

@mraszyk

@mraszyk mraszyk commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

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.

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 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 under
source's canister or snapshot directory, the linking creates the output before
listing 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 source the linking never reaches the output and the merge
succeeds, 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 exists on
the 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-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, 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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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 merge CLI 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.

Comment thread rs/state_tool/src/commands/merge.rs Outdated
Comment thread rs/state_tool/src/commands/merge.rs Outdated
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>
@mraszyk
mraszyk force-pushed the mraszyk/state-tool-merge branch from 0535208 to a1e4ff5 Compare September 7, 2026 10:00
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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 Err at line 67 after this has already linked the entire base checkpoint, leaving an incomplete output that blocks retries via the output.exists() check and could be mistaken for a checkpoint. Assemble under a temporary sibling and rename it to output only after marking/syncing succeeds, with cleanup on every error (or at least perform all collision checks before creating output).
    link_tree(&base, &output)?;
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread rs/state_tool/src/commands/merge.rs Outdated
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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

Comment thread rs/state_tool/src/commands/merge.rs
Comment thread rs/state_tool/src/commands/merge.rs Outdated
Comment thread rs/state_tool/src/commands/merge.rs Outdated
…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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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

Comment thread rs/state_tool/src/commands/merge.rs Outdated
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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

@mraszyk
mraszyk marked this pull request as ready for review September 7, 2026 16:43
@mraszyk
mraszyk requested a review from a team as a code owner September 7, 2026 16:43
@zeropath-ai

zeropath-ai Bot commented Sep 7, 2026

Copy link
Copy Markdown

No security or compliance issues detected. Reviewed everything up to 0460989.

Security Overview
Detected Code Changes
Change Type Relevant files
Enhancement ► rs/state_tool/src/commands.rs
       Add merge module to commands (new pub mod merge)
Enhancement ► rs/state_tool/src/commands/merge.rs
       Implement merge functionality to assemble merged subnet state (new file)
Enhancement ► rs/state_tool/src/main.rs
       Introduce CLI merge command with base, source, and output arguments and dispatch to merge functionality

mraszyk and others added 2 commits September 8, 2026 12:50
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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>/merged makes the staging directory a child of base; 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

Comment thread rs/state_tool/src/commands/merge.rs Outdated
mraszyk and others added 2 commits September 8, 2026 13:30
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>
@mraszyk

mraszyk commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

On the suppressed comment about merge.rs:43, and the summary line about nested outputs recursing indefinitely: that check was removed deliberately in 471f184, so this is a decision rather than an oversight, and it seems worth answering in the open since the point is in the review summary.

The mechanism is exactly as described, and it is the one that was fixed at your prompting in the first place: link_tree creates its destination before listing its source, so a staging directory under an input is discovered by that very listing and linked into itself, one level deeper each time, with nothing bounding the recursion.

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 do_merge, the help text of --output, and the PR description.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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 output paths that resolve beneath either input before creating the staging directory. With --output <base>/merged, <base>/merged.merging is created first; link_tree(base, staging) then discovers that staging directory while traversing base and 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 schneiderstefan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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?

Comment thread rs/state_tool/src/commands/merge.rs Outdated
let result = (|| -> Result<(), String> {
assemble(&base, &source, &staging)?;

// `rename` replaces an empty destination directory, and the check that

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This feels unnecessarily complicated for a silly edge case where we somehow run multiple scripts in parallel.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread rs/state_tool/src/commands/merge.rs Outdated
Ok(())
})();
if result.is_err() {
// Whichever of the two the work is sitting in: a merge that reports a

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is an example of a comment that doesn't add much and is unnecessary. In fact most comments in this file are.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread rs/state_tool/src/commands/merge.rs Outdated
/// 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Keep in mind that as long as the staging path exists, the replica would crash on startup.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread rs/state_tool/src/commands/merge.rs Outdated

/// 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: This is only used at one place, plus one test, I'd inline it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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

Comment thread rs/state_tool/src/commands/merge.rs Outdated
mraszyk and others added 2 commits September 8, 2026 14:32
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>
@mraszyk

mraszyk commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

@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 — merge_subnets records both subnets being halted with all streams to and from the source empty as a precondition of the mutation rather than something it verifies, and only checks what it can see in the registry, namely that no canister migration is in flight for the ranges being rerouted.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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_all makes the command return only the assembly error while leaving output behind. 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 writes snapshots/<canister>/snapshot.pbuf. As a result, the snapshot tests never exercise the nested snapshot-ID level, and CompleteCheckpointLayout::snapshot_ids() would reject their merged output. Build snapshots with real CanisterId/SnapshotId paths 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>
@mraszyk

mraszyk commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

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 already exists on the retry. The original error is still the one it needs, so the cleanup failure and the path are appended to it rather than replacing it. No test — reaching that path needs remove_dir_all to fail on a directory this command created and made writable, which nothing in a unit test can arrange, the same as the sync path.

Worth recording that the first version of this fix was wrong in a way the existing tests caught: I wrote the guard as if let (Err(err), Err(cleanup)) = (&result, fs::remove_dir_all(&output)), and both elements of a tuple are evaluated before it is matched, so the cleanup ran on the success path too and deleted every merged output. Six tests failed. It is if let Err(err) = &result && let Err(cleanup) = ... now.

The snapshot fixture. Right, and the reference is right: the layout is snapshots/<hex(canister_id)>/<hex(snapshot_id)>/snapshot.pbuf, so the fixture was a level short and nothing exercised the deepest thing the merge links. It has that level now, and merge_unions_canisters_and_snapshots reads a snapshot of a canister from either input through it.

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 snapshots is the canister ID, so comparing the entries of the two directories compares canisters, which is what a merge needs to be disjoint. And the merge itself never depended on the depth — link_tree recurses, so a snapshot nested one level deeper was always linked correctly. This was a gap in what the tests demonstrated, not in what the code did.

I did not switch the fixture to real CanisterId/SnapshotId values, so snapshot_ids() still cannot be called on it. The names are readable placeholders throughout these tests, canisters included, and the merge treats them as opaque directory names; making them real hex ids would buy the enumeration assertion at the cost of every path in the tests becoming unreadable. Happy to do it if a reviewer wants that assertion.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Checkpoint integrity and recovery semantics warrant final human validation despite comprehensive unit coverage.

Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants