Skip to content

fix(replay): don't execute the replay target height as a checkpoint round - #11203

Draft
mraszyk wants to merge 5 commits into
masterfrom
mraszyk/replay-target-height-round-type
Draft

fix(replay): don't execute the replay target height as a checkpoint round#11203
mraszyk wants to merge 5 commits into
masterfrom
mraszyk/replay-target-height-round-type

Conversation

@mraszyk

@mraszyk mraszyk commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

deliver_batches() derived requires_full_state_hash partly from its max_batch_height_to_deliver argument, so the last batch of a bounded delivery was always flagged as requiring a full state hash:

let persist_batch = Some(height) == max_batch_height_to_deliver;
let requires_full_state_hash = block.payload.is_summary() || persist_batch;

That flag does not only decide whether a checkpoint is written: it also selects ExecutionRoundType::CheckpointRound, which changes execution. A checkpoint round charges every canister for resource allocation and usage, bypassing the CHARGE_INTERVAL_ROUNDS gate, and aborts all paused executions instead of only those above a limit.

Only ic-replay passes Some(..) here, and when replaying a consensus pool it always does -- even without --replay-until-height, it passes Some(finalized_height). So the last replayed height was executed differently from the way the subnet executed that very same height, and the resulting state differed in the canisters' cycle balances and consumed cycles.

That difference used to be invisible to the certified state. If the current certification version was bumped to V29, /subnet/<subnet_id>/metrics includes CanisterStates::total_consumed_cycles(), so it changes the certification hash, and ic-replay reports

Hash mismatch! State divergence detected for outstanding shares!

against the subnet's certification shares at that height, refusing to proceed without manual inspection. Subnet recoveries replay to the highest certification share height, which is essentially never a summary height, so every recovery is affected.

Derive requires_full_state_hash from the block alone, and have ic-replay create the checkpoint it needs by delivering an extra batch at the end, one height above the last replayed block. The replayed heights are then executed exactly as the subnet executed them, and the checkpoint round happens at a height no node ever certified. A unit test in batch_delivery.rs pins this down: the batch at the delivery bound must not be a checkpoint round.

A new BatchContent::Checkpointing batch carries that extra round. It executes nothing: it only aborts all paused executions and resets heap_delta_estimate and expected_compiled_wasms in SystemMetadata, analogously to a round after subnet splitting. Like the splitting round, it does still advance batch_time (to one nanosecond past the last replayed block) and refresh network_topology / own_subnet_info, so the checkpointed state is not byte-identical to the state at the last replayed height -- but no canister is executed and no message is inducted, timed out or routed.

The extra batch is delivered whenever a consensus pool was replayed, with two exceptions:

  • No consensus pool: no batches were replayed, so the on-disk checkpoint is untouched and there is nothing to persist. Delivering a batch anyway would mutate the state based on the wall clock time, producing a non-deterministic state hash.
  • The latest state height is already above the replay target height: such a state can only come from the extra batch of a previous invocation over the same data directory, i.e. the checkpoint to persist already exists. Delivering another extra batch would move the checkpoint (and thereby the state hash) one height further on every re-run, making the hash depend on how many times the replay was run rather than only on the replayed data.

blockmaker_metrics becomes Option<BlockmakerMetrics>, None for the extra batches, which do not correspond to any block. This removes a second source of divergence: the extra batch used to observe BlockmakerMetrics::new_for_test() -- crediting node 0 -- into SystemMetadata::blockmaker_metrics_time_series, and to bump blocks_proposed_total.

restore-from-backup delivers no extra batches. With --replay-until-height at a height that is not a CUP height, it therefore no longer creates a checkpoint at that height and makes no persistent progress beyond the latest CUP at or below it. The --replay-until-height prompt is restricted to this subcommand and reworded accordingly (the consensus pool replay needs no warning any more), and the restore now prints which checkpoint the reported state corresponds to.

The replayed height is one above the subnet's; account for it in ValidateReplayStep, which already models this via extra_batches: 1 for the app subnet and NNS failover recoveries, and 1 + upgrade for the NNS same-nodes recovery, which delivers a further extra batch to update the registry local store.

ReplayStep now fails loudly when the work directory contains no consensus pool. Without one, ic-replay silently replays no blocks and creates no checkpoint at all, so a missing pool means the state was downloaded incorrectly.

…ound

`deliver_batches()` derived `requires_full_state_hash` partly from its
`max_batch_height_to_deliver` argument, so the last batch of a bounded
delivery was always flagged as requiring a full state hash:

    let persist_batch = Some(height) == max_batch_height_to_deliver;
    let requires_full_state_hash = block.payload.is_summary() || persist_batch;

That flag does not only decide whether a checkpoint is written: it also
selects `ExecutionRoundType::CheckpointRound`, which *changes execution*.
A checkpoint round charges every canister for resource allocation and
usage, bypassing the `CHARGE_INTERVAL_ROUNDS` gate, and aborts all paused
executions instead of only those above a limit.

Only `ic-replay` passes `Some(..)` here, and it always does -- even
without `--replay-until-height`, it passes `Some(finalized_height)`. So
the last replayed height was executed differently from the way the subnet
executed that very same height, and the resulting state differed in the
canisters' cycle balances and consumed cycles.

That difference used to be invisible to the certified state. Since the
current certification version was bumped to `V29`, `/subnet/<subnet_id>/metrics`
includes `CanisterStates::total_consumed_cycles()`, so it now changes the
certification hash, and `ic-replay` reports

    Hash mismatch! State divergence detected for outstanding shares!

against the subnet's certification shares at that height, refusing to
proceed without manual inspection. Subnet recoveries replay to the highest
certification share height, which is essentially never a summary height,
so every recovery is affected.

Derive `requires_full_state_hash` from the block alone, and have
`ic-replay` create the checkpoint it needs by always delivering an extra
batch at the end, one height above the last replayed block. The replayed
heights are then executed exactly as the subnet executed them, and the
checkpoint round happens at a height no node ever certified.

The replayed height is therefore one above the subnet's; account for it in
`ValidateReplayStep`, which already models this via `extra_batches`.

Both added tests fail without the corresponding change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mraszyk mraszyk added the CI_ALL_BAZEL_TARGETS Runs all bazel targets label Aug 18, 2026
@github-actions github-actions Bot added the fix label Aug 18, 2026
@mraszyk mraszyk closed this Aug 19, 2026
@mraszyk mraszyk reopened this Aug 19, 2026
mraszyk and others added 4 commits August 19, 2026 08:46
Without a consensus pool no batches are replayed, so the on-disk
checkpoint is untouched and needs no extra batch to persist it;
delivering one anyway would mutate the state using the wall clock time,
producing a non-deterministic state hash. This restores the pre-existing
no-op behavior of a plain `ic-replay` invocation over a state directory
without a consensus pool (e.g. a state-only backup snapshot).

Recovery flows, on the other hand, always download the consensus pool,
so there a missing pool means the state was downloaded incorrectly:
make ic-recovery's replay step fail loudly in that case instead of
silently replaying no blocks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Make re-running the replay over the same data directory idempotent:
  once a previous invocation has delivered the extra batch and persisted
  the checkpoint above the replay target height, deliver no further
  extra batch. Otherwise every re-run would move the checkpoint (and
  thereby change the state hash) one height further, and only a run
  over pristine data would reproduce the recovery checkpoint.
- Rework the --replay-until-height consent prompt: replaying a
  consensus pool now creates a deterministic checkpoint via the extra
  batch, so warn only when restoring from a backup, where a non-CUP
  target height yields no persistent progress beyond the latest CUP.
- When restoring from a backup reaches a target height without a
  checkpoint, say so explicitly instead of silently reporting the
  state params of the latest CUP.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The extra batch that `ic-replay` delivers to persist the state it
replayed does not correspond to any block, so its round should have no
effect beyond what creating a checkpoint requires. Yet it used to be an
ordinary data batch, i.e. its round inducted and executed messages
(heartbeats, global timers, leftover queue traffic) that the subnet
itself never executed at that point, and charged all canisters for
resource allocation.

Introduce `BatchContent::Checkpointing`, handled like
`BatchContent::Splitting` in that message routing skips induction,
execution and routing altogether and only calls
`checkpoint_round_with_no_execution()`, which aborts paused executions
and wipes the `SystemMetadata` caches. The resulting checkpoint contains
exactly the state the subnet computed for the last replayed height.

Note that not charging for resource allocation in this round loses
nothing: charging is duration-based, so the first charging round after
the subnet resumes covers the same interval.

Also stop attributing the extra batches to a test blockmaker: their
`blockmaker_metrics` are `None` now, so that no blockmaker is credited
for a batch that no node ever proposed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drop `round_type_decides_whether_a_non_charging_round_charges`: it only
characterizes pre-existing scheduler behaviour and passes without the fix,
so `requires_full_state_hash_ignores_max_batch_height_to_deliver` in
`batch_delivery.rs` is the actual regression test.

Fix two comments in `player.rs`:

- delivering an extra batch on every re-run makes the state hash depend on
  how many times the replay was run; it is a run over *pristine* data whose
  hash would then no longer be reproduced (the claim was inverted).
- the replayed height is executed the way the subnet executed it so that the
  resulting certified state is *identical* to the one the subnet certified,
  not merely comparable to its certification shares.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mraszyk
mraszyk force-pushed the mraszyk/replay-target-height-round-type branch from 860b318 to a6c4a15 Compare August 19, 2026 11:26
@mraszyk
mraszyk marked this pull request as ready for review August 19, 2026 13:30
@mraszyk
mraszyk requested a review from a team as a code owner August 19, 2026 13:30
@zeropath-ai

zeropath-ai Bot commented Aug 19, 2026

Copy link
Copy Markdown

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

Security Overview
Detected Code Changes
Change Type Relevant files
Enhancement ► rs/consensus/src/consensus/batch_delivery.rs
    Derive requires_full_state_hash from block payload only; adjust logic and related tests
► rs/consensus/src/consensus/batch_delivery.rs
    Use Some(blockmaker_metrics) in blockmaker metrics setup
► rs/messaging/src/message_routing.rs
    Handle optional blockmaker_metrics and record metrics only when present
► rs/messaging/src/state_machine.rs
    Add handling for BatchContent::Checkpointing in state machine
► rs/messaging/src/state_machine/tests.rs
    Update tests to account for Checkpointing and optional metrics
► rs/recovery/src/app_subnet_recovery.rs
    Adjust replay validation step height to account for extra batch
► rs/recovery/src/nns_recovery_failover_nodes.rs
    Adjust replay validation step height to account for extra batch
► rs/recovery/src/nns_recovery_same_nodes.rs
    Update replay validation step to include extra batch behavior
► rs/recovery/src/steps.rs
    Fail when no consensus pool exists; ensure proper checkpoint handling
► rs/replay/src/lib.rs
    Commentary updates about extra batch and CUP-related checkpoint creation
► rs/replay/src/player.rs
    Introduce target_height handling; ensure extra batch yields checkpointing batch when appropriate
► rs/types/types/src/batch.rs
    Batch supports Optional blockmaker_metrics and new Checkpointing content; update related logic
► rs/test_utilities/types/src/batch/batch_builder.rs
    Default builder now uses Some(BlockmakerMetrics) for batch creation

@pierugo-dfinity pierugo-dfinity 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.

In ic-replay, instead of predicting the operator's thought process and behave as what we think makes more sense today (i.e. execute an extra checkpointing batch, but actually not if there's no consensus pool, and actually not if we replayed beforehand), what about leaving the decision of adding an extra checkpointing batch as a CLI argument?
By default, we wouldn't include an extra batch and thus not create a checkpoint. The operator could safely replay as many times as they want without "committing" to/persisting anything. When they want to commit, they would pass the flag.
I think it would simplify the ic-replay implementation

During recoveries, I guess we can always enable that flag, even when no consensus pool was downloaded (maybe we weren't able to SSH in? See next comment), still displaying a confirmation prompt maybe.

Comment on lines +620 to +628
let node_ids = [node_test_id(0)];
let record = SubnetRecordBuilder::from(&node_ids)
.with_dkg_interval_length(dkg_interval_length)
.build();
let subnet_id = subnet_test_id(0);
let Dependencies {
registry, mut pool, ..
} = DependenciesBuilder::single_subnet(pool_config, subnet_id, vec![(1, record)])
.build();

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.

Suggested change
let node_ids = [node_test_id(0)];
let record = SubnetRecordBuilder::from(&node_ids)
.with_dkg_interval_length(dkg_interval_length)
.build();
let subnet_id = subnet_test_id(0);
let Dependencies {
registry, mut pool, ..
} = DependenciesBuilder::single_subnet(pool_config, subnet_id, vec![(1, record)])
.build();
let Dependencies {
registry, mut pool, replica_config, ..
} = DependenciesBuilder::new(pool_config, 1)
.with_dkg_interval_length(dkg_interval_length)
.build();

and use replica_config.subnet_id below

// Get only ingress out of the batch_messages
let signed_ingress_msgs = match batch.content {
BatchContent::Data { batch_messages, .. } => batch_messages.signed_ingress_msgs,
BatchContent::Checkpointing => Vec::new(),

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.

I'd also fire unimplemented! as we probably should not expect this variant here for the moment

// height is therefore one above the subnet's.
self.recovery
.get_validate_replay_step(self.params.subnet_id, 0),
.get_validate_replay_step(self.params.subnet_id, 1),

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.

I'd probably leave those arguments unchanged (to not make these files bear with the implementation detail of ic-replay) and instead add 1 to the argument in get_validate_replay_step

Comment thread rs/recovery/src/steps.rs
let consensus_pool_path = self.work_dir.join("data").join(IC_CONSENSUS_POOL_PATH);
if !consensus_pool_path.exists() {
return Err(RecoveryError::UnexpectedError(format!(
"No consensus pool found at {}",

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.

Suggested change
"No consensus pool found at {}",
"No consensus pool found at {}. You should execute the `DownloadConsenusPool` step prior to `ICReplay`.",

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.

I guess I'm still a bit reluctant to have to download a consensus pool before replaying. In the case of NNS Recovery where we'd like to execute an extra batch, maybe we wouldn't have downloaded a consensus pool (because the orchestrators are broken and we cannot SSH in) and we'd want to execute the extra batch on top of whatever latest state we have locally.

Comment thread rs/replay/src/player.rs
return (time, None);
let no_extra_msgs = extra_msgs.is_empty();
if no_extra_msgs {
let Some(target_height) = target_height else {

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.

style nit: This isn't obvious that target_height being a Some is equivalent to having a consensus pool without reading what's above (which could change and thus become inconsistent)

Comment thread rs/replay/src/player.rs
let Some(target_height) = target_height else {
// Without a consensus pool no batches were replayed, so the on-disk
// checkpoint is untouched and there is nothing to persist. Delivering a
// batch anyway would mutate the state based on the wall clock time from

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.

Suggested change
// batch anyway would mutate the state based on the wall clock time from
// batch anyway would mutate the state based on the latest registry version and wall clock time from

Comment thread rs/replay/src/player.rs
BatchContent::Checkpointing
};
extra_batch.batch_number = message_routing.expected_batch_height();
extra_batch.time += Duration::from_nanos(1);

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.

I think this made sense before because when delivering a "proper" extra batch, it should indeed increase its batch time. But now, when delivering the a Checkpointing batch, this time is artificially increased, which mutates the system metadata, even though the subnet never did so. I think we could keep it the same as the previous batch and we would ignore it in the DSM implementation. Not sure if having a non-increasing batch time could have other undesirable consequences though.

Comment thread rs/replay/src/player.rs
println!("Target height {height} reached.");
return Ok(self.get_latest_state_params(None, invalid_artifacts));
let state_params = self.get_latest_state_params(None, invalid_artifacts);
if state_params.height < last_batch_height {

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.

When do we expect this condition to be true?

@mraszyk
mraszyk marked this pull request as draft August 26, 2026 15:13
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.

2 participants