Skip to content

Commit 3ea3787

Browse files
authored
Make v1 migration idempotent (#2965)
* Make migrations idempotent * Update CHANGELOG
1 parent 9b7f0b6 commit 3ea3787

3 files changed

Lines changed: 129 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ Temporary section for maintaining breaking changes from individual PRs, which wi
8787
Receipt `effectiveGasPrice` and projected `gasUsed` are now unconditionally derived from the actual metered fee.
8888
Forks that had set this to a non-zero future activation height must migrate; the new behavior is mandatory.
8989
- #2934 **Breaking Change**: Removes the `CHANGE_GAS_LIMIT_AFTER_HEIGHT` gas-limit fork. The `INITIAL_GAS_LIMIT` and `UPDATED_GAS_LIMIT` constants are replaced by a single `BLOCK_GAS_LIMIT` constant (test override env var `SOV_TEST_CONST_OVERRIDE_INITIAL_GAS_LIMIT``SOV_TEST_CONST_OVERRIDE_BLOCK_GAS_LIMIT`). Removes the `sequencer.height_for_gas_limit_computation` rate-limiter config field and the `ChainStateCapability::block_gas_limit` / `ChainState::block_gas_limit{,_at}` methods (use `<S as GasSpec>::block_gas_limit()` instead). The block gas limit is now constant for all heights; the per-block sequencer safeguards (preferred-sequencer pre-exec escrow, zero-bond preferred-sequencer penalization, and the slot-gas-limit pre-check) remain unconditionally enabled.
90+
- #2965 sov-migrations: the v1 (state_version 0->1) migration is now idempotent. Re-running it against an already-migrated DB (state_version >= 1) logs a message and exits Ok instead of erroring, so a restarted rollup that re-invokes the migration binary no longer fails.
9091

9192
# 2026-04-01
9293
- #2670 **Breaking change** Remove `InnerVm` and `OuterVm` generic type parameters from `StateTransitionFunction` trait and all downstream types.

crates/utils/sov-migrations/src/v1.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,39 @@ where
182182
let pre_state_root = storage
183183
.get_root_hash(head_slot_number)
184184
.context("failed to read pre-migration state root")?;
185+
186+
// Idempotency guard: if the migration has already been applied (state_version is at or
187+
// beyond the target), do nothing and exit successfully. This makes the migration safe to
188+
// re-run, e.g. when a node restarts and re-invokes the migration binary.
189+
let from_state_version = {
190+
let value = state_version_value(chain_state);
191+
match storage.get_accessory_unbound(value.slot_key(), Some(head_slot_number)) {
192+
Some(slot_value) => value.decode_unwrap(&slot_value),
193+
// Absent ⇒ version 0, mirroring `ChainState::state_version`'s `unwrap_or(0)`.
194+
None => 0,
195+
}
196+
};
197+
if from_state_version >= TARGET_STATE_VERSION {
198+
eprintln!(
199+
"v1 migration already applied: on-disk state_version is {from_state_version} \
200+
(target {TARGET_STATE_VERSION}); nothing to do"
201+
);
202+
return Ok(crate::MigrationOutcome {
203+
dry_run: options.dry_run,
204+
db_path: db_path.display().to_string(),
205+
pre_state_root: pre_state_root.to_string(),
206+
post_state_root: pre_state_root.to_string(),
207+
head_rollup_slot: head_slot_number.get(),
208+
migration: MigrationReport {
209+
from_state_version,
210+
to_state_version: from_state_version,
211+
accounts: sov_accounts::migrations::MigrationReport {
212+
entries_migrated: 0,
213+
},
214+
},
215+
});
216+
}
217+
185218
let migration_data = collect(accounts, chain_state, &storage)?;
186219

187220
let mut checkpoint = {

examples/demo-rollup/tests/migrations/mod.rs

Lines changed: 95 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,91 @@ struct KernelRoundtrip {
3939

4040
#[tokio::test(flavor = "multi_thread")]
4141
async fn v1_migration_rewrites_live_demo_state() -> anyhow::Result<()> {
42+
let (builder, storage_config, credential_id, account_address) =
43+
build_and_force_v0_demo_state().await?;
44+
45+
let report = run_v1_migration(storage_config.clone())?;
46+
47+
assert!(!report.dry_run);
48+
assert!(report.head_rollup_slot > 0);
49+
assert_ne!(report.pre_state_root, report.post_state_root);
50+
assert_eq!(
51+
report.migration.from_state_version,
52+
sov_migrations::v1::SOURCE_STATE_VERSION
53+
);
54+
assert_eq!(
55+
report.migration.to_state_version,
56+
sov_migrations::v1::TARGET_STATE_VERSION
57+
);
58+
assert_eq!(report.migration.accounts.entries_migrated, 1);
59+
60+
assert_migrated_state(storage_config, credential_id, account_address)?;
61+
62+
let restarted_rollup = builder.start().await?;
63+
restarted_rollup.wait_for_node_synced().await?;
64+
restarted_rollup.shutdown().await?;
65+
66+
Ok(())
67+
}
68+
69+
/// Re-running the migration against an already-migrated database must be a graceful no-op: it
70+
/// exits `Ok` without changing state, so a restarted node that re-invokes the migration binary
71+
/// does not fail.
72+
#[tokio::test(flavor = "multi_thread")]
73+
async fn v1_migration_is_idempotent() -> anyhow::Result<()> {
74+
let (_builder, storage_config, credential_id, account_address) =
75+
build_and_force_v0_demo_state().await?;
76+
77+
// First run actually applies the migration, establishing the already-migrated precondition.
78+
let first = run_v1_migration(storage_config.clone())?;
79+
assert_eq!(
80+
first.migration.accounts.entries_migrated, 1,
81+
"first run should migrate the seeded legacy account"
82+
);
83+
assert_ne!(
84+
first.pre_state_root, first.post_state_root,
85+
"first run should change the state root"
86+
);
87+
88+
// Second run sees state_version already at the target and must do nothing.
89+
let second = run_v1_migration(storage_config.clone())?;
90+
assert_eq!(
91+
second.migration.from_state_version,
92+
sov_migrations::v1::TARGET_STATE_VERSION,
93+
"second run should observe the already-migrated state version"
94+
);
95+
assert_eq!(
96+
second.migration.to_state_version,
97+
sov_migrations::v1::TARGET_STATE_VERSION,
98+
"second run should leave the state version at the target"
99+
);
100+
assert_eq!(
101+
second.migration.accounts.entries_migrated, 0,
102+
"second run must not migrate any accounts"
103+
);
104+
assert_eq!(
105+
second.pre_state_root, second.post_state_root,
106+
"second run must not change the state root"
107+
);
108+
assert_eq!(
109+
second.pre_state_root, first.post_state_root,
110+
"state must be unchanged between the first and second runs"
111+
);
112+
113+
assert_migrated_state(storage_config, credential_id, account_address)?;
114+
115+
Ok(())
116+
}
117+
118+
/// Builds a live demo rollup, advances it past genesis with one transfer, shuts it down, and
119+
/// rewrites its head into a state-version-0 database carrying a legacy account entry. Returns the
120+
/// shutdown builder (for an optional restart), the storage config, and the seeded account ids.
121+
async fn build_and_force_v0_demo_state() -> anyhow::Result<(
122+
RollupBuilder<Rollup>,
123+
RollupDbConfig,
124+
CredentialId,
125+
<DemoRollupSpec as Spec>::Address,
126+
)> {
42127
let credential_id = credential_id(0x11);
43128
let account_address = account_address(0x22);
44129

@@ -74,37 +159,23 @@ async fn v1_migration_rewrites_live_demo_state() -> anyhow::Result<()> {
74159

75160
prepare_v0_state_with_legacy_account(storage_config.clone(), credential_id, account_address)?;
76161

162+
Ok((builder, storage_config, credential_id, account_address))
163+
}
164+
165+
/// Runs the v1 migration against `storage_config` with a fresh runtime, returning the outcome.
166+
fn run_v1_migration(
167+
storage_config: RollupDbConfig,
168+
) -> anyhow::Result<sov_migrations::MigrationOutcome<sov_migrations::v1::MigrationReport>> {
77169
let mut runtime = DemoRuntime::<DemoRollupSpec>::default();
78170
let runtime_inner = &mut *runtime;
79-
let report = sov_migrations::v1::run_with_options::<DemoRollupSpec, Hasher>(
171+
sov_migrations::v1::run_with_options::<DemoRollupSpec, Hasher>(
80172
sov_migrations::MigrationOptions {
81-
storage: storage_config.clone(),
173+
storage: storage_config,
82174
dry_run: false,
83175
},
84176
&mut runtime_inner.accounts,
85177
&mut runtime_inner.chain_state,
86-
)?;
87-
88-
assert!(!report.dry_run);
89-
assert!(report.head_rollup_slot > 0);
90-
assert_ne!(report.pre_state_root, report.post_state_root);
91-
assert_eq!(
92-
report.migration.from_state_version,
93-
sov_migrations::v1::SOURCE_STATE_VERSION
94-
);
95-
assert_eq!(
96-
report.migration.to_state_version,
97-
sov_migrations::v1::TARGET_STATE_VERSION
98-
);
99-
assert_eq!(report.migration.accounts.entries_migrated, 1);
100-
101-
assert_migrated_state(storage_config, credential_id, account_address)?;
102-
103-
let restarted_rollup = builder.start().await?;
104-
restarted_rollup.wait_for_node_synced().await?;
105-
restarted_rollup.shutdown().await?;
106-
107-
Ok(())
178+
)
108179
}
109180

110181
fn prepare_v0_state_with_legacy_account(

0 commit comments

Comments
 (0)