fix(replay): don't execute the replay target height as a checkpoint round - #11203
fix(replay): don't execute the replay target height as a checkpoint round#11203mraszyk wants to merge 5 commits into
Conversation
…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>
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>
860b318 to
a6c4a15
Compare
|
✅ No security or compliance issues detected. Reviewed everything up to a6c4a15. Security Overview
Detected Code Changes
|
pierugo-dfinity
left a comment
There was a problem hiding this comment.
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.
| 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(); |
There was a problem hiding this comment.
| 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(), |
There was a problem hiding this comment.
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), |
There was a problem hiding this comment.
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
| 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 {}", |
There was a problem hiding this comment.
| "No consensus pool found at {}", | |
| "No consensus pool found at {}. You should execute the `DownloadConsenusPool` step prior to `ICReplay`.", |
There was a problem hiding this comment.
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.
| return (time, None); | ||
| let no_extra_msgs = extra_msgs.is_empty(); | ||
| if no_extra_msgs { | ||
| let Some(target_height) = target_height else { |
There was a problem hiding this comment.
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)
| 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 |
There was a problem hiding this comment.
| // 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 |
| BatchContent::Checkpointing | ||
| }; | ||
| extra_batch.batch_number = message_routing.expected_batch_height(); | ||
| extra_batch.time += Duration::from_nanos(1); |
There was a problem hiding this comment.
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.
| 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 { |
There was a problem hiding this comment.
When do we expect this condition to be true?
deliver_batches()derivedrequires_full_state_hashpartly from itsmax_batch_height_to_deliverargument, so the last batch of a bounded delivery was always flagged as requiring a full state hash: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 theCHARGE_INTERVAL_ROUNDSgate, and aborts all paused executions instead of only those above a limit.Only
ic-replaypassesSome(..)here, and when replaying a consensus pool it always does -- even without--replay-until-height, it passesSome(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>/metricsincludesCanisterStates::total_consumed_cycles(), so it changes the certification hash, andic-replayreportsagainst 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_hashfrom the block alone, and haveic-replaycreate 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 inbatch_delivery.rspins this down: the batch at the delivery bound must not be a checkpoint round.A new
BatchContent::Checkpointingbatch carries that extra round. It executes nothing: it only aborts all paused executions and resetsheap_delta_estimateandexpected_compiled_wasmsinSystemMetadata, analogously to a round after subnet splitting. Like the splitting round, it does still advancebatch_time(to one nanosecond past the last replayed block) and refreshnetwork_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:
blockmaker_metricsbecomesOption<BlockmakerMetrics>,Nonefor the extra batches, which do not correspond to any block. This removes a second source of divergence: the extra batch used to observeBlockmakerMetrics::new_for_test()-- crediting node 0 -- intoSystemMetadata::blockmaker_metrics_time_series, and to bumpblocks_proposed_total.restore-from-backupdelivers no extra batches. With--replay-until-heightat 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-heightprompt 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 viaextra_batches:1for the app subnet and NNS failover recoveries, and1 + upgradefor the NNS same-nodes recovery, which delivers a further extra batch to update the registry local store.ReplayStepnow fails loudly when the work directory contains no consensus pool. Without one,ic-replaysilently replays no blocks and creates no checkpoint at all, so a missing pool means the state was downloaded incorrectly.