Skip to content

Commit 9a5e4f8

Browse files
mraszykclaude
andcommitted
fix(replay): don't execute the replay target height as a checkpoint round
`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>
1 parent aae9fb3 commit 9a5e4f8

6 files changed

Lines changed: 162 additions & 38 deletions

File tree

rs/consensus/src/consensus/batch_delivery.rs

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -219,10 +219,15 @@ pub(crate) fn deliver_batches_with_result_processor(
219219
};
220220
let (consensus_responses, canister_http_spent) =
221221
generate_responses_to_subnet_calls(&block, &mut batch_stats, log);
222-
// This flag can only be true, if we've called deliver_batches with a height
223-
// limit. In this case we also want to have a checkpoint for that last height.
224-
let persist_batch = Some(height) == max_batch_height_to_deliver;
225-
let requires_full_state_hash = block.payload.is_summary() || persist_batch;
222+
// Must be derived from the block alone, never from how far the caller chose
223+
// to deliver: this flag also selects `ExecutionRoundType::CheckpointRound`
224+
// (see `StateMachineImpl::execute_round`), which changes execution -- it
225+
// forces resource allocation charging and aborts *all* paused executions.
226+
// Deriving it from `max_batch_height_to_deliver` would make the last
227+
// replayed round diverge from the round the subnet actually executed at
228+
// that height. `ic-replay` creates the checkpoint it needs by delivering an
229+
// extra batch instead, see `Player::deliver_extra_batch`.
230+
let requires_full_state_hash = block.payload.is_summary();
226231
let batch_content = match block.payload.as_ref() {
227232
BlockPayload::Summary(_summary_payload) => BatchContent::Data {
228233
batch_messages: BatchMessages::default(),
@@ -577,10 +582,13 @@ mod tests {
577582
//! Finalizer unit tests
578583
use super::*;
579584
use crate::consensus::batch_delivery::generate_responses_to_remote_dkgs;
585+
use ic_consensus_mocks::{Dependencies, DependenciesBuilder};
580586
use ic_crypto_test_utils_ni_dkg::dummy_transcript_for_tests;
581587
use ic_logger::replica_logger::no_op_logger;
582588
use ic_management_canister_types_private::{SetupInitialDKGResponse, VetKdCurve, VetKdKeyId};
583-
use ic_test_utilities_types::ids::subnet_test_id;
589+
use ic_test_utilities::message_routing::FakeMessageRouting;
590+
use ic_test_utilities_registry::SubnetRecordBuilder;
591+
use ic_test_utilities_types::ids::{node_test_id, subnet_test_id};
584592
use ic_types::{
585593
PrincipalId, RegistryVersion, SubnetId,
586594
batch::{BatchPayload, ValidationContext},
@@ -601,6 +609,54 @@ mod tests {
601609

602610
const TARGET_ID: NiDkgTargetId = NiDkgTargetId::new([8; 32]);
603611

612+
/// `requires_full_state_hash` selects `ExecutionRoundType::CheckpointRound`,
613+
/// which changes execution. It must therefore depend only on the block, so
614+
/// that a caller bounding the delivery (i.e. `ic-replay`) still executes each
615+
/// round exactly the way the subnet executed it.
616+
#[test]
617+
fn requires_full_state_hash_ignores_max_batch_height_to_deliver() {
618+
ic_test_utilities::artifact_pool_config::with_test_pool_config(|pool_config| {
619+
let dkg_interval_length = 9;
620+
let node_ids = [node_test_id(0)];
621+
let record = SubnetRecordBuilder::from(&node_ids)
622+
.with_dkg_interval_length(dkg_interval_length)
623+
.build();
624+
let subnet_id = subnet_test_id(0);
625+
let Dependencies {
626+
registry, mut pool, ..
627+
} = DependenciesBuilder::single_subnet(pool_config, subnet_id, vec![(1, record)])
628+
.build();
629+
630+
// Summary blocks are at heights 0, 10, ...; finalize a few rounds and
631+
// stop short of the next summary height.
632+
let target_height = Height::from(5);
633+
pool.advance_round_normal_operation_n(target_height.get());
634+
635+
let membership = Membership::new(pool.get_cache(), registry.clone(), subnet_id);
636+
let message_routing = FakeMessageRouting::new();
637+
638+
let last_delivered = deliver_batches(
639+
&message_routing,
640+
&membership,
641+
&PoolReader::new(&pool),
642+
registry.as_ref(),
643+
subnet_id,
644+
&no_op_logger(),
645+
Some(target_height),
646+
)
647+
.expect("failed to deliver batches");
648+
assert_eq!(last_delivered, target_height);
649+
650+
let batches = message_routing.batches.read().unwrap();
651+
let last_batch = batches.last().expect("no batch was delivered");
652+
assert_eq!(last_batch.batch_number, target_height);
653+
assert!(
654+
!last_batch.requires_full_state_hash(),
655+
"the batch at the delivery bound must not be a checkpoint round"
656+
);
657+
})
658+
}
659+
604660
const EXPECTED_FRESH_SUBNET_ID_STR: &str =
605661
"icdrs-3sfmz-hm6r3-cdzf5-cfroa-3cddh-aght7-azz25-eo34b-4strl-wae";
606662

rs/execution_environment/src/scheduler/tests/charging.rs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,59 @@ fn charging_happens_on_average_once_every_charge_interval_rounds() {
118118
assert_eq!(num_charges, NUM_ROUNDS / CHARGE_INTERVAL_ROUNDS);
119119
}
120120

121+
/// The round type is observable in the canisters' cycle state: on a round that is
122+
/// not a multiple of `CHARGE_INTERVAL_ROUNDS`, a `CheckpointRound` charges for
123+
/// resource allocation while an `OrdinaryRound` does not.
124+
///
125+
/// This is why `requires_full_state_hash` -- which selects the round type -- must
126+
/// be derived from the block alone and never from how far a caller chose to
127+
/// deliver batches (see `deliver_batches()` in `ic_consensus`). Replaying a round
128+
/// with the wrong round type produces a state that differs from the one the
129+
/// subnet computed for that same height.
130+
#[test]
131+
fn round_type_decides_whether_a_non_charging_round_charges() {
132+
// A round on which charging is not otherwise due.
133+
const ROUND: u64 = CHARGE_INTERVAL_ROUNDS + 1;
134+
assert!(!ROUND.is_multiple_of(CHARGE_INTERVAL_ROUNDS));
135+
136+
let mut test = SchedulerTestBuilder::new().build();
137+
138+
// Charging handles time=0 as a special case, so it should be set to some
139+
// non-zero time.
140+
let initial_time = Time::from_nanos_since_unix_epoch(1_000_000_000_000);
141+
test.set_time(initial_time);
142+
143+
let canister = test.create_canister_with(
144+
Cycles::new(1_000_000_000_000_000),
145+
ComputeAllocation::zero(),
146+
MemoryAllocation::from(NumBytes::from(1 << 30)),
147+
None,
148+
Some(initial_time),
149+
None,
150+
);
151+
152+
// Enough time has passed that a charge is due whenever charging is attempted.
153+
test.advance_time(test.duration_between_allocation_charges());
154+
155+
// An ordinary round at `ROUND` does not charge...
156+
let balance_before = test.canister_state(canister).system_state.balance();
157+
test.advance_to_round(ExecutionRound::new(ROUND));
158+
test.execute_round(ExecutionRoundType::OrdinaryRound);
159+
assert_eq!(
160+
test.canister_state(canister).system_state.balance(),
161+
balance_before,
162+
"an ordinary round charged for resource allocation"
163+
);
164+
165+
// ...while a checkpoint round at the very same round does.
166+
test.advance_to_round(ExecutionRound::new(ROUND));
167+
test.execute_round(ExecutionRoundType::CheckpointRound);
168+
assert!(
169+
test.canister_state(canister).system_state.balance() < balance_before,
170+
"a checkpoint round did not charge for resource allocation"
171+
);
172+
}
173+
121174
#[test]
122175
fn charging_for_message_memory_works() {
123176
let mut test = SchedulerTestBuilder::new()

rs/recovery/src/app_subnet_recovery.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -522,8 +522,11 @@ impl RecoveryIterator<StepType, StepTypeIter> for AppSubnetRecovery {
522522
))),
523523

524524
StepType::ValidateReplayOutput => Ok(Box::new(
525+
// `ic-replay` always delivers one extra batch at the end, to
526+
// create the checkpoint that is uploaded below; the replayed
527+
// height is therefore one above the subnet's.
525528
self.recovery
526-
.get_validate_replay_step(self.params.subnet_id, 0),
529+
.get_validate_replay_step(self.params.subnet_id, 1),
527530
)),
528531

529532
StepType::UploadState => {

rs/recovery/src/nns_recovery_failover_nodes.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -363,8 +363,11 @@ impl RecoveryIterator<StepType, StepTypeIter> for NNSRecoveryFailoverNodes {
363363
)),
364364

365365
StepType::ValidateReplayOutput => Ok(Box::new(
366+
// `ic-replay` always delivers one extra batch at the end, to
367+
// create the checkpoint that is uploaded below; the replayed
368+
// height is therefore one above the subnet's.
366369
self.recovery
367-
.get_validate_replay_step(self.params.subnet_id, 0),
370+
.get_validate_replay_step(self.params.subnet_id, 1),
368371
)),
369372

370373
StepType::UpdateRegistryLocalStore => Ok(Box::new(

rs/recovery/src/nns_recovery_same_nodes.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -449,7 +449,10 @@ impl RecoveryIterator<StepType, StepTypeIter> for NNSRecoverySameNodes {
449449
}
450450
StepType::ValidateReplayOutput => Ok(Box::new(self.recovery.get_validate_replay_step(
451451
self.params.subnet_id,
452-
u64::from(self.params.upgrade_version.is_some()),
452+
// `ic-replay` always delivers one extra batch at the end, to create
453+
// the checkpoint that is uploaded below, on top of the one delivered
454+
// to update the registry local store during an upgrade.
455+
1 + u64::from(self.params.upgrade_version.is_some()),
453456
))),
454457

455458
StepType::UpdateRegistryLocalStore => {

rs/replay/src/player.rs

Lines changed: 36 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -420,34 +420,32 @@ impl Player {
420420
Default::default()
421421
};
422422

423-
let (latest_context_time, extra_batch_delivery) = self.deliver_extra_batch(
423+
let (latest_context_time, last_batch_height, msgs) = self.deliver_extra_batch(
424424
self.message_routing.as_ref(),
425425
self.consensus_pool.as_ref(),
426426
extra,
427427
);
428428

429-
if let Some((last_batch_height, msgs)) = extra_batch_delivery {
430-
self.wait_for_state(last_batch_height);
431-
// We only want to persist the checkpoint after the latest batch.
432-
self.state_manager.remove_states_below(last_batch_height);
433-
434-
// check if the extra messages have been delivered successfully
435-
let get_latest_status = self.ingress_history_reader.get_latest_status();
436-
for msg in msgs {
437-
match get_latest_status(&msg.ingress.id()) {
438-
IngressStatus::Known {
439-
state: IngressState::Completed(WasmResult::Reply(bytes)),
440-
..
441-
} => match msg.print {
442-
Some(printer) => printer(bytes),
443-
_ => println!(
444-
"Ingress id={} response={}",
445-
msg.ingress.id(),
446-
hex::encode(bytes)
447-
),
448-
},
449-
status => panic!("Execution of {} has failed: {:?}", msg.ingress.id(), status),
450-
}
429+
self.wait_for_state(last_batch_height);
430+
// We only want to persist the checkpoint after the latest batch.
431+
self.state_manager.remove_states_below(last_batch_height);
432+
433+
// check if the extra messages have been delivered successfully
434+
let get_latest_status = self.ingress_history_reader.get_latest_status();
435+
for msg in msgs {
436+
match get_latest_status(&msg.ingress.id()) {
437+
IngressStatus::Known {
438+
state: IngressState::Completed(WasmResult::Reply(bytes)),
439+
..
440+
} => match msg.print {
441+
Some(printer) => printer(bytes),
442+
_ => println!(
443+
"Ingress id={} response={}",
444+
msg.ingress.id(),
445+
hex::encode(bytes)
446+
),
447+
},
448+
status => panic!("Execution of {} has failed: {:?}", msg.ingress.id(), status),
451449
}
452450
}
453451

@@ -736,7 +734,7 @@ impl Player {
736734
message_routing: &dyn MessageRouting,
737735
pool: Option<&ConsensusPoolImpl>,
738736
mut extra: F,
739-
) -> (Time, Option<(Height, Vec<IngressWithPrinter>)>) {
737+
) -> (Time, Height, Vec<IngressWithPrinter>) {
740738
let (registry_version, time, randomness, replica_version) = match pool {
741739
None => (
742740
self.registry.get_latest_version(),
@@ -766,9 +764,13 @@ impl Player {
766764
};
767765

768766
let extra_msgs = extra(self, time);
769-
if extra_msgs.is_empty() {
770-
return (time, None);
771-
}
767+
// `deliver_batches()` deliberately does not force a checkpoint at the replay
768+
// target height: that height has to be executed exactly the way the subnet
769+
// executed it, so that the resulting certified state can be compared against
770+
// the subnet's certification shares (see `redeliver_certifications`).
771+
// Therefore we always deliver at least one extra batch here, whose (final)
772+
// round is the one that creates the checkpoint.
773+
let no_extra_msgs = extra_msgs.is_empty();
772774

773775
let extra_ingresses = extra_msgs
774776
.iter()
@@ -786,7 +788,7 @@ impl Player {
786788
chain_key_data: Default::default(),
787789
consensus_responses: Vec::new(),
788790
canister_http_spent: Default::default(),
789-
requires_full_state_hash: false,
791+
requires_full_state_hash: no_extra_msgs,
790792
},
791793
// Use a fake randomness here since we don't have random tape for extra messages
792794
randomness,
@@ -796,7 +798,11 @@ impl Player {
796798
replica_version,
797799
};
798800

799-
println!("extra_batch created with new ingress");
801+
if no_extra_msgs {
802+
println!("extra_batch created to trigger checkpoint creation");
803+
} else {
804+
println!("extra_batch created with new ingress");
805+
}
800806

801807
loop {
802808
match message_routing.deliver_batch(extra_batch.clone()) {
@@ -841,7 +847,7 @@ impl Player {
841847
}
842848
}
843849
}
844-
(time, Some((extra_batch.batch_number, extra_msgs)))
850+
(time, extra_batch.batch_number, extra_msgs)
845851
}
846852

847853
fn certify_state_with_dummy_certification(&self) {

0 commit comments

Comments
 (0)