From 7dc372be8b4bb105ce104ce749a449df430e7600 Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Fri, 20 Feb 2026 21:56:45 +0100 Subject: [PATCH 1/9] Fix warp block leaf import --- substrate/client/api/src/backend.rs | 8 ++++++-- substrate/client/api/src/in_mem.rs | 4 +++- substrate/client/db/src/lib.rs | 8 +++++++- substrate/client/service/src/client/client.rs | 6 ++++++ 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/substrate/client/api/src/backend.rs b/substrate/client/api/src/backend.rs index 189264b22ccfd..d6a56c79096c3 100644 --- a/substrate/client/api/src/backend.rs +++ b/substrate/client/api/src/backend.rs @@ -142,6 +142,10 @@ pub enum NewBlockState { Best, /// Newly finalized block (implicitly best). Final, + /// Block whose parent is not present in the DB. Stored for ancestry lookups + /// but not tracked as a leaf — adding it would create an orphan leaf with no + /// connection to the rest of the tree. + Disconnected, } impl NewBlockState { @@ -149,7 +153,7 @@ impl NewBlockState { pub fn is_best(self) -> bool { match self { NewBlockState::Best | NewBlockState::Final => true, - NewBlockState::Normal => false, + NewBlockState::Normal | NewBlockState::Disconnected => false, } } @@ -157,7 +161,7 @@ impl NewBlockState { pub fn is_final(self) -> bool { match self { NewBlockState::Final => true, - NewBlockState::Best | NewBlockState::Normal => false, + NewBlockState::Best | NewBlockState::Normal | NewBlockState::Disconnected => false, } } } diff --git a/substrate/client/api/src/in_mem.rs b/substrate/client/api/src/in_mem.rs index 798495de9d507..f5e3671a05df1 100644 --- a/substrate/client/api/src/in_mem.rs +++ b/substrate/client/api/src/in_mem.rs @@ -167,7 +167,9 @@ impl Blockchain { { let mut storage = self.storage.write(); - storage.leaves.import(hash, number, *header.parent_hash()); + if !matches!(new_state, NewBlockState::Disconnected) { + storage.leaves.import(hash, number, *header.parent_hash()); + } storage.blocks.insert(hash, StoredBlock::new(header, body, justifications)); if let NewBlockState::Final = new_state { diff --git a/substrate/client/db/src/lib.rs b/substrate/client/db/src/lib.rs index fbf4c482a114e..39994b93aee66 100644 --- a/substrate/client/db/src/lib.rs +++ b/substrate/client/db/src/lib.rs @@ -1753,7 +1753,13 @@ impl Backend { if !header_exists_in_db { // Add a new leaf if the block has the potential to be finalized. - if number > last_finalized_num || last_finalized_num.is_zero() { + // Skip for disconnected blocks (e.g. warp sync proof blocks): their parent is not + // in the DB, so importing them would create orphan leaves. These persist until the + // chain gap closes, at which point displaced_leaves_after_finalizing must walk the + // entire finalized chain for each orphan leaf, causing a multi-minute stall. + if !matches!(pending_block.leaf_state, NewBlockState::Disconnected) && + (number > last_finalized_num || last_finalized_num.is_zero()) + { let mut leaves = self.blockchain.leaves.write(); leaves.import(hash, number, parent_hash); leaves.prepare_transaction( diff --git a/substrate/client/service/src/client/client.rs b/substrate/client/service/src/client/client.rs index 0886322263394..581e564b3a490 100644 --- a/substrate/client/service/src/client/client.rs +++ b/substrate/client/service/src/client/client.rs @@ -696,6 +696,12 @@ where let leaf_state = if finalized { NewBlockState::Final + } else if origin == BlockOrigin::WarpSync { + // Warp sync proof blocks have no parent in the DB, so adding them as leaves would + // create orphan leaves that are never cleaned up until the gap closes. At that point + // displaced_leaves_after_finalizing would have to walk the entire chain for each + // orphan leaf, causing a multi-minute stall under the import lock. + NewBlockState::Disconnected } else if is_new_best { NewBlockState::Best } else { From c1284d6c42af49bb09cc095d27210f3640fe5b15 Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Sat, 21 Feb 2026 10:57:23 +0100 Subject: [PATCH 2/9] Add test --- substrate/client/db/src/lib.rs | 137 +++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/substrate/client/db/src/lib.rs b/substrate/client/db/src/lib.rs index 39994b93aee66..83b1208062cbd 100644 --- a/substrate/client/db/src/lib.rs +++ b/substrate/client/db/src/lib.rs @@ -3514,6 +3514,143 @@ pub(crate) mod tests { assert_eq!(displaced.displaced_blocks, vec![c4_hash]); } } + + #[test] + fn disconnected_blocks_do_not_become_leaves_and_warp_sync_scenario() { + // Simulate a realistic case: + // + // 1. Import genesis (block #0) normally — becomes a leaf. + // 2. Import warp sync proof blocks at #5, #10, #15 with Disconnected state. + // Their parents are NOT in the DB. They must NOT appear as leaves. + // 3. Import block #20 as Final. Its parent + // (#19) is not in the DB. Being Final, it updates finalized number to 20. + // 4. Import blocks #1..#19 with Normal state (gap sync). Since + // last_finalized_num is now 20 and each block number < 20, the leaf + // condition (number > last_finalized_num || last_finalized_num.is_zero()) + // is FALSE — they must NOT become leaves. + // 5. Assert throughout and verify displaced_leaves_after_finalizing works + // cleanly with no disconnected proof blocks in the displaced list. + + let backend = Backend::::new_test(1000, 100); + let blockchain = backend.blockchain(); + + let insert_block_raw = + |number: u64, parent_hash: H256, ext_root: H256, state: NewBlockState| -> H256 { + use sp_runtime::testing::Digest; + let digest = Digest::default(); + let header = Header { + number, + parent_hash, + state_root: Default::default(), + digest, + extrinsics_root: ext_root, + }; + let mut op = backend.begin_operation().unwrap(); + op.set_block_data(header.clone(), Some(vec![]), None, None, state).unwrap(); + backend.commit_operation(op).unwrap(); + header.hash() + }; + + // --- Step 1: import genesis --- + let genesis_hash = + insert_header(&backend, 0, Default::default(), None, Default::default()); + assert_eq!(blockchain.leaves().unwrap(), vec![genesis_hash]); + + // --- Step 2: import disconnected warp sync proof blocks --- + // These simulate authority-set-change blocks from the warp sync proof. + // Their parents are NOT in the DB. + let _proof5_hash = + insert_block_raw(5, H256::from([5; 32]), H256::from([50; 32]), NewBlockState::Disconnected); + let _proof10_hash = + insert_block_raw(10, H256::from([10; 32]), H256::from([100; 32]), NewBlockState::Disconnected); + let _proof15_hash = + insert_block_raw(15, H256::from([15; 32]), H256::from([150; 32]), NewBlockState::Disconnected); + + // Leaves must still only contain genesis. + assert_eq!(blockchain.leaves().unwrap(), vec![genesis_hash]); + + // The disconnected blocks should still be retrievable from the DB. + assert!(blockchain.header(_proof5_hash).unwrap().is_some()); + assert!(blockchain.header(_proof10_hash).unwrap().is_some()); + assert!(blockchain.header(_proof15_hash).unwrap().is_some()); + + // --- Step 3: import warp sync target block #20 as Final --- + // Parent (#19) is not in the DB. Use the same low-level approach but with + // NewBlockState::Final. Being Final, it will be set as best + finalized. + let block20_hash = + insert_block_raw(20, H256::from([19; 32]), H256::from([200; 32]), NewBlockState::Final); + + // Block #20 should now be a leaf (it's best and finalized). + let leaves = blockchain.leaves().unwrap(); + assert!(leaves.contains(&block20_hash)); + // Verify finalized number was updated to 20. + assert_eq!(blockchain.info().finalized_number, 20); + assert_eq!(blockchain.info().finalized_hash, block20_hash); + // Disconnected proof blocks must still not be leaves. + assert!(!leaves.contains(&_proof5_hash)); + assert!(!leaves.contains(&_proof10_hash)); + assert!(!leaves.contains(&_proof15_hash)); + + // --- Step 4: import gap sync blocks #1..#19 with Normal state --- + // Since last_finalized_num is 20, each block with number < 20 should NOT + // become a leaf (the condition `number > last_finalized_num` is false). + // Build the chain: genesis -> #1 -> #2 -> ... -> #19. + let mut prev_hash = genesis_hash; + let mut gap_hashes = Vec::new(); + for n in 1..=19 { + let h = insert_disconnected_header(&backend, n, prev_hash, Default::default(), false); + gap_hashes.push(h); + prev_hash = h; + } + + // Verify gap sync blocks did NOT create new leaves. + let leaves = blockchain.leaves().unwrap(); + for (i, gap_hash) in gap_hashes.iter().enumerate() { + assert!( + !leaves.contains(gap_hash), + "Gap sync block #{} should not be a leaf, but it is", + i + 1, + ); + } + // Block #20 should still be a leaf. + assert!(leaves.contains(&block20_hash)); + // Disconnected proof blocks must still not be leaves. + assert!(!leaves.contains(&_proof5_hash)); + assert!(!leaves.contains(&_proof10_hash)); + assert!(!leaves.contains(&_proof15_hash)); + + // --- Step 5: verify displaced_leaves_after_finalizing works cleanly --- + // Call it for block #20 to verify no disconnected proof blocks appear + // in the displaced list and it completes without errors. + { + let displaced = blockchain + .displaced_leaves_after_finalizing( + block20_hash, + 20, + H256::from([19; 32]), // parent hash of block #20 + ) + .unwrap(); + // Disconnected proof blocks were never leaves, so they must not + // appear in displaced_leaves. + assert!( + !displaced.displaced_leaves.iter().any(|(_, h)| *h == _proof5_hash), + ); + assert!( + !displaced.displaced_leaves.iter().any(|(_, h)| *h == _proof10_hash), + ); + assert!( + !displaced.displaced_leaves.iter().any(|(_, h)| *h == _proof15_hash), + ); + // None of the gap sync blocks should be displaced leaves either + // (they were never added as leaves). + for gap_hash in &gap_hashes { + assert!( + !displaced.displaced_leaves.iter().any(|(_, h)| h == gap_hash), + ); + } + } + } + #[test] fn displaced_leaves_after_finalizing_works() { let backend = Backend::::new_test(1000, 100); From ce83bdb7337206ce86e22af8a17cc5be53b73e1d Mon Sep 17 00:00:00 2001 From: "cmd[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 08:02:21 +0000 Subject: [PATCH 3/9] Update from github-actions[bot] running command 'prdoc --audience node_dev --bump major' --- prdoc/pr_11152.prdoc | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 prdoc/pr_11152.prdoc diff --git a/prdoc/pr_11152.prdoc b/prdoc/pr_11152.prdoc new file mode 100644 index 0000000000000..d53578a27e51f --- /dev/null +++ b/prdoc/pr_11152.prdoc @@ -0,0 +1,18 @@ +title: 'Warp sync: Warp proof block import should not mark as leaf' +doc: +- audience: Node Dev + description: |- + While warp syncing a node, I saw some huge stalls. What happens: + + - During warp sync we store warp proofs + - After warp sync we start gap sync + - Problem: Once gap sync finishes, node starts looking for displaced leaves from all the warp proof blocks, which was over 2500 in my observed case. This led to a 30minutes stall. + + In this PR I propose to import the blocks during warp sync with a Disconnected state, which does not add them as leaves. This fixes the downtime. +crates: +- name: sc-client-api + bump: major +- name: sc-client-db + bump: major +- name: sc-service + bump: major From 405b9c99d1daadf856122adf65f1958f57b75fc7 Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Tue, 24 Feb 2026 10:05:57 +0200 Subject: [PATCH 4/9] Make comments more concise --- substrate/client/api/src/backend.rs | 6 +++--- substrate/client/db/src/lib.rs | 4 ---- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/substrate/client/api/src/backend.rs b/substrate/client/api/src/backend.rs index d6a56c79096c3..760f43e094eb0 100644 --- a/substrate/client/api/src/backend.rs +++ b/substrate/client/api/src/backend.rs @@ -142,9 +142,9 @@ pub enum NewBlockState { Best, /// Newly finalized block (implicitly best). Final, - /// Block whose parent is not present in the DB. Stored for ancestry lookups - /// but not tracked as a leaf — adding it would create an orphan leaf with no - /// connection to the rest of the tree. + /// Block whose parent is not present in the DB. + /// Used for blocks which are needed in the DB but should not be registered as + /// leaf. Disconnected, } diff --git a/substrate/client/db/src/lib.rs b/substrate/client/db/src/lib.rs index 83b1208062cbd..ec7fa1ec6e424 100644 --- a/substrate/client/db/src/lib.rs +++ b/substrate/client/db/src/lib.rs @@ -1753,10 +1753,6 @@ impl Backend { if !header_exists_in_db { // Add a new leaf if the block has the potential to be finalized. - // Skip for disconnected blocks (e.g. warp sync proof blocks): their parent is not - // in the DB, so importing them would create orphan leaves. These persist until the - // chain gap closes, at which point displaced_leaves_after_finalizing must walk the - // entire finalized chain for each orphan leaf, causing a multi-minute stall. if !matches!(pending_block.leaf_state, NewBlockState::Disconnected) && (number > last_finalized_num || last_finalized_num.is_zero()) { From 1145ed990c5c1724eaa274e459f77a89ea7a0841 Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Tue, 24 Feb 2026 10:55:08 +0200 Subject: [PATCH 5/9] Separate NewBlockState from leaf registration --- substrate/client/api/src/backend.rs | 9 +-- substrate/client/api/src/in_mem.rs | 15 ++-- substrate/client/db/benches/state_access.rs | 4 +- substrate/client/db/src/lib.rs | 69 ++++++++++--------- substrate/client/service/src/client/client.rs | 13 ++-- 5 files changed, 58 insertions(+), 52 deletions(-) diff --git a/substrate/client/api/src/backend.rs b/substrate/client/api/src/backend.rs index 760f43e094eb0..07913970125ee 100644 --- a/substrate/client/api/src/backend.rs +++ b/substrate/client/api/src/backend.rs @@ -142,10 +142,6 @@ pub enum NewBlockState { Best, /// Newly finalized block (implicitly best). Final, - /// Block whose parent is not present in the DB. - /// Used for blocks which are needed in the DB but should not be registered as - /// leaf. - Disconnected, } impl NewBlockState { @@ -153,7 +149,7 @@ impl NewBlockState { pub fn is_best(self) -> bool { match self { NewBlockState::Best | NewBlockState::Final => true, - NewBlockState::Normal | NewBlockState::Disconnected => false, + NewBlockState::Normal => false, } } @@ -161,7 +157,7 @@ impl NewBlockState { pub fn is_final(self) -> bool { match self { NewBlockState::Final => true, - NewBlockState::Best | NewBlockState::Normal | NewBlockState::Disconnected => false, + NewBlockState::Best | NewBlockState::Normal => false, } } } @@ -186,6 +182,7 @@ pub trait BlockImportOperation { indexed_body: Option>>, justifications: Option, state: NewBlockState, + register_as_leaf: bool, ) -> sp_blockchain::Result<()>; /// Inject storage data into the database. diff --git a/substrate/client/api/src/in_mem.rs b/substrate/client/api/src/in_mem.rs index f5e3671a05df1..f6a26c69a76ba 100644 --- a/substrate/client/api/src/in_mem.rs +++ b/substrate/client/api/src/in_mem.rs @@ -48,6 +48,7 @@ use crate::{ struct PendingBlock { block: StoredBlock, state: NewBlockState, + register_as_leaf: bool, } #[derive(PartialEq, Eq, Clone)] @@ -159,6 +160,7 @@ impl Blockchain { justifications: Option, body: Option::Extrinsic>>, new_state: NewBlockState, + register_as_leaf: bool, ) -> sp_blockchain::Result<()> { let number = *header.number(); if new_state.is_best() { @@ -167,7 +169,7 @@ impl Blockchain { { let mut storage = self.storage.write(); - if !matches!(new_state, NewBlockState::Disconnected) { + if register_as_leaf { storage.leaves.import(hash, number, *header.parent_hash()); } storage.blocks.insert(hash, StoredBlock::new(header, body, justifications)); @@ -517,10 +519,14 @@ impl backend::BlockImportOperation for BlockImportOperatio _indexed_body: Option>>, justifications: Option, state: NewBlockState, + register_as_leaf: bool, ) -> sp_blockchain::Result<()> { assert!(self.pending_block.is_none(), "Only one block per operation is allowed"); - self.pending_block = - Some(PendingBlock { block: StoredBlock::new(header, body, justifications), state }); + self.pending_block = Some(PendingBlock { + block: StoredBlock::new(header, body, justifications), + state, + register_as_leaf, + }); Ok(()) } @@ -694,7 +700,8 @@ impl backend::Backend for Backend { self.states.write().insert(hash, new_state); - self.blockchain.insert(hash, header, justification, body, pending_block.state)?; + self.blockchain + .insert(hash, header, justification, body, pending_block.state, pending_block.register_as_leaf)?; } if !operation.aux.is_empty() { diff --git a/substrate/client/db/benches/state_access.rs b/substrate/client/db/benches/state_access.rs index 7ea5b17a321ff..9b44feb86ce00 100644 --- a/substrate/client/db/benches/state_access.rs +++ b/substrate/client/db/benches/state_access.rs @@ -57,7 +57,7 @@ fn insert_blocks(db: &Backend, storage: Vec<(Vec, Vec)>) -> H256 ) .unwrap(); - op.set_block_data(header.clone(), Some(vec![]), None, None, NewBlockState::Best) + op.set_block_data(header.clone(), Some(vec![]), None, None, NewBlockState::Best, true) .unwrap(); db.commit_operation(op).unwrap(); @@ -95,7 +95,7 @@ fn insert_blocks(db: &Backend, storage: Vec<(Vec, Vec)>) -> H256 op.update_db_storage(tx).unwrap(); op.update_storage(changes.clone(), Default::default()).unwrap(); - op.set_block_data(header.clone(), Some(vec![]), None, None, NewBlockState::Best) + op.set_block_data(header.clone(), Some(vec![]), None, None, NewBlockState::Best, true) .unwrap(); db.commit_operation(op).unwrap(); diff --git a/substrate/client/db/src/lib.rs b/substrate/client/db/src/lib.rs index ec7fa1ec6e424..5e99f1bdef16c 100644 --- a/substrate/client/db/src/lib.rs +++ b/substrate/client/db/src/lib.rs @@ -475,6 +475,7 @@ struct PendingBlock { body: Option>, indexed_body: Option>>, leaf_state: NewBlockState, + register_as_leaf: bool, } // wrapper that implements trait required for state_db @@ -947,10 +948,11 @@ impl sc_client_api::backend::BlockImportOperation indexed_body: Option>>, justifications: Option, leaf_state: NewBlockState, + register_as_leaf: bool, ) -> ClientResult<()> { assert!(self.pending_block.is_none(), "Only one block per operation is allowed"); self.pending_block = - Some(PendingBlock { header, body, indexed_body, justifications, leaf_state }); + Some(PendingBlock { header, body, indexed_body, justifications, leaf_state, register_as_leaf }); Ok(()) } @@ -1753,7 +1755,7 @@ impl Backend { if !header_exists_in_db { // Add a new leaf if the block has the potential to be finalized. - if !matches!(pending_block.leaf_state, NewBlockState::Disconnected) && + if pending_block.register_as_leaf && (number > last_finalized_num || last_finalized_num.is_zero()) { let mut leaves = self.blockchain.leaves.write(); @@ -2849,7 +2851,7 @@ pub(crate) mod tests { op.update_db_storage(overlay).unwrap(); header.state_root = root.into(); - op.set_block_data(header.clone(), Some(body), None, None, NewBlockState::Best) + op.set_block_data(header.clone(), Some(body), None, None, NewBlockState::Best, true) .unwrap(); backend.commit_operation(op)?; @@ -2878,6 +2880,7 @@ pub(crate) mod tests { None, None, if best { NewBlockState::Best } else { NewBlockState::Normal }, + true, ) .unwrap(); @@ -2915,7 +2918,7 @@ pub(crate) mod tests { .0; header.state_root = root.into(); - op.set_block_data(header.clone(), None, None, None, NewBlockState::Normal) + op.set_block_data(header.clone(), None, None, None, NewBlockState::Normal, true) .unwrap(); backend.commit_operation(op).unwrap(); @@ -2946,7 +2949,7 @@ pub(crate) mod tests { extrinsics_root: Default::default(), }; - op.set_block_data(header, Some(vec![]), None, None, NewBlockState::Best) + op.set_block_data(header, Some(vec![]), None, None, NewBlockState::Best, true) .unwrap(); db.commit_operation(op).unwrap(); } @@ -3008,7 +3011,7 @@ pub(crate) mod tests { state_version, ) .unwrap(); - op.set_block_data(header.clone(), Some(vec![]), None, None, NewBlockState::Best) + op.set_block_data(header.clone(), Some(vec![]), None, None, NewBlockState::Best, true) .unwrap(); db.commit_operation(op).unwrap(); @@ -3043,7 +3046,7 @@ pub(crate) mod tests { header.state_root = root.into(); op.update_storage(storage, Vec::new()).unwrap(); - op.set_block_data(header.clone(), Some(vec![]), None, None, NewBlockState::Best) + op.set_block_data(header.clone(), Some(vec![]), None, None, NewBlockState::Best, true) .unwrap(); db.commit_operation(op).unwrap(); @@ -3085,7 +3088,7 @@ pub(crate) mod tests { .unwrap(); key = op.db_updates.insert(EMPTY_PREFIX, b"hello"); - op.set_block_data(header, Some(vec![]), None, None, NewBlockState::Best) + op.set_block_data(header, Some(vec![]), None, None, NewBlockState::Best, true) .unwrap(); backend.commit_operation(op).unwrap(); @@ -3122,7 +3125,7 @@ pub(crate) mod tests { op.db_updates.insert(EMPTY_PREFIX, b"hello"); op.db_updates.remove(&key, EMPTY_PREFIX); - op.set_block_data(header, Some(vec![]), None, None, NewBlockState::Best) + op.set_block_data(header, Some(vec![]), None, None, NewBlockState::Best, true) .unwrap(); backend.commit_operation(op).unwrap(); @@ -3158,7 +3161,7 @@ pub(crate) mod tests { let hash = header.hash(); op.db_updates.remove(&key, EMPTY_PREFIX); - op.set_block_data(header, Some(vec![]), None, None, NewBlockState::Best) + op.set_block_data(header, Some(vec![]), None, None, NewBlockState::Best, true) .unwrap(); backend.commit_operation(op).unwrap(); @@ -3191,7 +3194,7 @@ pub(crate) mod tests { .into(); let hash = header.hash(); - op.set_block_data(header, Some(vec![]), None, None, NewBlockState::Best) + op.set_block_data(header, Some(vec![]), None, None, NewBlockState::Best, true) .unwrap(); backend.commit_operation(op).unwrap(); @@ -3218,7 +3221,7 @@ pub(crate) mod tests { .into(); let hash = header.hash(); - op.set_block_data(header, Some(vec![]), None, None, NewBlockState::Best) + op.set_block_data(header, Some(vec![]), None, None, NewBlockState::Best, true) .unwrap(); backend.commit_operation(op).unwrap(); @@ -3516,7 +3519,7 @@ pub(crate) mod tests { // Simulate a realistic case: // // 1. Import genesis (block #0) normally — becomes a leaf. - // 2. Import warp sync proof blocks at #5, #10, #15 with Disconnected state. + // 2. Import warp sync proof blocks at #5, #10, #15 without leaf registration. // Their parents are NOT in the DB. They must NOT appear as leaves. // 3. Import block #20 as Final. Its parent // (#19) is not in the DB. Being Final, it updates finalized number to 20. @@ -3531,7 +3534,7 @@ pub(crate) mod tests { let blockchain = backend.blockchain(); let insert_block_raw = - |number: u64, parent_hash: H256, ext_root: H256, state: NewBlockState| -> H256 { + |number: u64, parent_hash: H256, ext_root: H256, state: NewBlockState, register_as_leaf: bool| -> H256 { use sp_runtime::testing::Digest; let digest = Digest::default(); let header = Header { @@ -3542,7 +3545,7 @@ pub(crate) mod tests { extrinsics_root: ext_root, }; let mut op = backend.begin_operation().unwrap(); - op.set_block_data(header.clone(), Some(vec![]), None, None, state).unwrap(); + op.set_block_data(header.clone(), Some(vec![]), None, None, state, register_as_leaf).unwrap(); backend.commit_operation(op).unwrap(); header.hash() }; @@ -3552,15 +3555,15 @@ pub(crate) mod tests { insert_header(&backend, 0, Default::default(), None, Default::default()); assert_eq!(blockchain.leaves().unwrap(), vec![genesis_hash]); - // --- Step 2: import disconnected warp sync proof blocks --- + // --- Step 2: import warp sync proof blocks without leaf registration --- // These simulate authority-set-change blocks from the warp sync proof. // Their parents are NOT in the DB. let _proof5_hash = - insert_block_raw(5, H256::from([5; 32]), H256::from([50; 32]), NewBlockState::Disconnected); + insert_block_raw(5, H256::from([5; 32]), H256::from([50; 32]), NewBlockState::Normal, false); let _proof10_hash = - insert_block_raw(10, H256::from([10; 32]), H256::from([100; 32]), NewBlockState::Disconnected); + insert_block_raw(10, H256::from([10; 32]), H256::from([100; 32]), NewBlockState::Normal, false); let _proof15_hash = - insert_block_raw(15, H256::from([15; 32]), H256::from([150; 32]), NewBlockState::Disconnected); + insert_block_raw(15, H256::from([15; 32]), H256::from([150; 32]), NewBlockState::Normal, false); // Leaves must still only contain genesis. assert_eq!(blockchain.leaves().unwrap(), vec![genesis_hash]); @@ -3574,7 +3577,7 @@ pub(crate) mod tests { // Parent (#19) is not in the DB. Use the same low-level approach but with // NewBlockState::Final. Being Final, it will be set as best + finalized. let block20_hash = - insert_block_raw(20, H256::from([19; 32]), H256::from([200; 32]), NewBlockState::Final); + insert_block_raw(20, H256::from([19; 32]), H256::from([200; 32]), NewBlockState::Final, true); // Block #20 should now be a leaf (it's best and finalized). let leaves = blockchain.leaves().unwrap(); @@ -3983,7 +3986,7 @@ pub(crate) mod tests { state_version, ) .unwrap(); - op.set_block_data(header.clone(), Some(vec![]), None, None, NewBlockState::Best) + op.set_block_data(header.clone(), Some(vec![]), None, None, NewBlockState::Best, true) .unwrap(); backend.commit_operation(op).unwrap(); @@ -4019,7 +4022,7 @@ pub(crate) mod tests { let hash = header.hash(); op.update_storage(storage, Vec::new()).unwrap(); - op.set_block_data(header, Some(vec![]), None, None, NewBlockState::Normal) + op.set_block_data(header, Some(vec![]), None, None, NewBlockState::Normal, true) .unwrap(); backend.commit_operation(op).unwrap(); @@ -4030,7 +4033,7 @@ pub(crate) mod tests { { let header = backend.blockchain().header(hash1).unwrap().unwrap(); let mut op = backend.begin_operation().unwrap(); - op.set_block_data(header, None, None, None, NewBlockState::Best).unwrap(); + op.set_block_data(header, None, None, None, NewBlockState::Best, true).unwrap(); backend.commit_operation(op).unwrap(); } @@ -4516,13 +4519,13 @@ pub(crate) mod tests { extrinsics_root: Default::default(), }; let mut op = backend.begin_operation().unwrap(); - op.set_block_data(header, None, None, None, NewBlockState::Best).unwrap(); + op.set_block_data(header, None, None, None, NewBlockState::Best, true).unwrap(); assert!(matches!(backend.commit_operation(op), Err(sp_blockchain::Error::SetHeadTooOld))); // Insert 2 as best again. let header = backend.blockchain().header(block2).unwrap().unwrap(); let mut op = backend.begin_operation().unwrap(); - op.set_block_data(header, None, None, None, NewBlockState::Best).unwrap(); + op.set_block_data(header, None, None, None, NewBlockState::Best, true).unwrap(); backend.commit_operation(op).unwrap(); assert_eq!(backend.blockchain().info().best_hash, block2); } @@ -4540,7 +4543,7 @@ pub(crate) mod tests { let header = backend.blockchain().header(block1).unwrap().unwrap(); let mut op = backend.begin_operation().unwrap(); - op.set_block_data(header, None, None, None, NewBlockState::Final).unwrap(); + op.set_block_data(header, None, None, None, NewBlockState::Final, true).unwrap(); backend.commit_operation(op).unwrap(); assert_eq!(backend.blockchain().info().finalized_hash, block1); @@ -4603,7 +4606,7 @@ pub(crate) mod tests { extrinsics_root: Default::default(), }; - op.set_block_data(header.clone(), Some(Vec::new()), None, None, NewBlockState::Normal) + op.set_block_data(header.clone(), Some(Vec::new()), None, None, NewBlockState::Normal, true) .unwrap(); backend.commit_operation(op).unwrap(); @@ -4622,7 +4625,7 @@ pub(crate) mod tests { extrinsics_root: Default::default(), }; - op.set_block_data(header.clone(), Some(Vec::new()), None, None, NewBlockState::Normal) + op.set_block_data(header.clone(), Some(Vec::new()), None, None, NewBlockState::Normal, true) .unwrap(); backend.commit_operation(op).unwrap(); @@ -4641,7 +4644,7 @@ pub(crate) mod tests { extrinsics_root: H256::from_low_u64_le(42), }; - op.set_block_data(header.clone(), Some(Vec::new()), None, None, NewBlockState::Normal) + op.set_block_data(header.clone(), Some(Vec::new()), None, None, NewBlockState::Normal, true) .unwrap(); backend.commit_operation(op).unwrap(); @@ -4791,7 +4794,7 @@ pub(crate) mod tests { Some(Vec::new()), None, None, - NewBlockState::Normal, + NewBlockState::Normal, true, ) .unwrap(); @@ -4836,7 +4839,7 @@ pub(crate) mod tests { Some(Vec::new()), None, None, - NewBlockState::Normal, + NewBlockState::Normal, true, ) .unwrap(); @@ -4881,7 +4884,7 @@ pub(crate) mod tests { Some(Vec::new()), None, None, - NewBlockState::Best, + NewBlockState::Best, true, ) .unwrap(); @@ -4918,7 +4921,7 @@ pub(crate) mod tests { Some(Vec::new()), None, None, - NewBlockState::Best, + NewBlockState::Best, true, ) .unwrap(); diff --git a/substrate/client/service/src/client/client.rs b/substrate/client/service/src/client/client.rs index 581e564b3a490..c0628f551e034 100644 --- a/substrate/client/service/src/client/client.rs +++ b/substrate/client/service/src/client/client.rs @@ -394,7 +394,7 @@ where NewBlockState::Normal }; let (header, body) = genesis_block.deconstruct(); - op.set_block_data(header, Some(body), None, None, block_state)?; + op.set_block_data(header, Some(body), None, None, block_state, true)?; backend.commit_operation(op)?; } @@ -696,18 +696,16 @@ where let leaf_state = if finalized { NewBlockState::Final - } else if origin == BlockOrigin::WarpSync { - // Warp sync proof blocks have no parent in the DB, so adding them as leaves would - // create orphan leaves that are never cleaned up until the gap closes. At that point - // displaced_leaves_after_finalizing would have to walk the entire chain for each - // orphan leaf, causing a multi-minute stall under the import lock. - NewBlockState::Disconnected } else if is_new_best { NewBlockState::Best } else { NewBlockState::Normal }; + // Warp sync imported blocks shall be stored in the DB, but they should not be registered + // as leaves. + let register_as_leaf = origin != BlockOrigin::WarpSync; + let tree_route = if is_new_best && info.best_hash != parent_hash && parent_exists { let route_from_best = sp_blockchain::tree_route(self.backend.blockchain(), info.best_hash, parent_hash)?; @@ -730,6 +728,7 @@ where indexed_body, justifications, leaf_state, + register_as_leaf, )?; operation.op.insert_aux(aux)?; From 1fd1492393a72e840105021495863efdad4b7769 Mon Sep 17 00:00:00 2001 From: "cmd[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 09:01:52 +0000 Subject: [PATCH 6/9] Update from github-actions[bot] running command 'fmt' --- substrate/client/api/src/in_mem.rs | 10 +- substrate/client/db/src/lib.rs | 164 ++++++++++++++++++----------- 2 files changed, 113 insertions(+), 61 deletions(-) diff --git a/substrate/client/api/src/in_mem.rs b/substrate/client/api/src/in_mem.rs index f6a26c69a76ba..cc25869914069 100644 --- a/substrate/client/api/src/in_mem.rs +++ b/substrate/client/api/src/in_mem.rs @@ -700,8 +700,14 @@ impl backend::Backend for Backend { self.states.write().insert(hash, new_state); - self.blockchain - .insert(hash, header, justification, body, pending_block.state, pending_block.register_as_leaf)?; + self.blockchain.insert( + hash, + header, + justification, + body, + pending_block.state, + pending_block.register_as_leaf, + )?; } if !operation.aux.is_empty() { diff --git a/substrate/client/db/src/lib.rs b/substrate/client/db/src/lib.rs index 5e99f1bdef16c..6d5b4dc97b1fc 100644 --- a/substrate/client/db/src/lib.rs +++ b/substrate/client/db/src/lib.rs @@ -951,8 +951,14 @@ impl sc_client_api::backend::BlockImportOperation register_as_leaf: bool, ) -> ClientResult<()> { assert!(self.pending_block.is_none(), "Only one block per operation is allowed"); - self.pending_block = - Some(PendingBlock { header, body, indexed_body, justifications, leaf_state, register_as_leaf }); + self.pending_block = Some(PendingBlock { + header, + body, + indexed_body, + justifications, + leaf_state, + register_as_leaf, + }); Ok(()) } @@ -3519,51 +3525,69 @@ pub(crate) mod tests { // Simulate a realistic case: // // 1. Import genesis (block #0) normally — becomes a leaf. - // 2. Import warp sync proof blocks at #5, #10, #15 without leaf registration. - // Their parents are NOT in the DB. They must NOT appear as leaves. - // 3. Import block #20 as Final. Its parent - // (#19) is not in the DB. Being Final, it updates finalized number to 20. - // 4. Import blocks #1..#19 with Normal state (gap sync). Since - // last_finalized_num is now 20 and each block number < 20, the leaf - // condition (number > last_finalized_num || last_finalized_num.is_zero()) - // is FALSE — they must NOT become leaves. - // 5. Assert throughout and verify displaced_leaves_after_finalizing works - // cleanly with no disconnected proof blocks in the displaced list. + // 2. Import warp sync proof blocks at #5, #10, #15 without leaf registration. Their parents + // are NOT in the DB. They must NOT appear as leaves. + // 3. Import block #20 as Final. Its parent (#19) is not in the DB. Being Final, it updates + // finalized number to 20. + // 4. Import blocks #1..#19 with Normal state (gap sync). Since last_finalized_num is now 20 + // and each block number < 20, the leaf condition (number > last_finalized_num || + // last_finalized_num.is_zero()) is FALSE — they must NOT become leaves. + // 5. Assert throughout and verify displaced_leaves_after_finalizing works cleanly with no + // disconnected proof blocks in the displaced list. let backend = Backend::::new_test(1000, 100); let blockchain = backend.blockchain(); - let insert_block_raw = - |number: u64, parent_hash: H256, ext_root: H256, state: NewBlockState, register_as_leaf: bool| -> H256 { - use sp_runtime::testing::Digest; - let digest = Digest::default(); - let header = Header { - number, - parent_hash, - state_root: Default::default(), - digest, - extrinsics_root: ext_root, - }; - let mut op = backend.begin_operation().unwrap(); - op.set_block_data(header.clone(), Some(vec![]), None, None, state, register_as_leaf).unwrap(); - backend.commit_operation(op).unwrap(); - header.hash() + let insert_block_raw = |number: u64, + parent_hash: H256, + ext_root: H256, + state: NewBlockState, + register_as_leaf: bool| + -> H256 { + use sp_runtime::testing::Digest; + let digest = Digest::default(); + let header = Header { + number, + parent_hash, + state_root: Default::default(), + digest, + extrinsics_root: ext_root, }; + let mut op = backend.begin_operation().unwrap(); + op.set_block_data(header.clone(), Some(vec![]), None, None, state, register_as_leaf) + .unwrap(); + backend.commit_operation(op).unwrap(); + header.hash() + }; // --- Step 1: import genesis --- - let genesis_hash = - insert_header(&backend, 0, Default::default(), None, Default::default()); + let genesis_hash = insert_header(&backend, 0, Default::default(), None, Default::default()); assert_eq!(blockchain.leaves().unwrap(), vec![genesis_hash]); // --- Step 2: import warp sync proof blocks without leaf registration --- // These simulate authority-set-change blocks from the warp sync proof. // Their parents are NOT in the DB. - let _proof5_hash = - insert_block_raw(5, H256::from([5; 32]), H256::from([50; 32]), NewBlockState::Normal, false); - let _proof10_hash = - insert_block_raw(10, H256::from([10; 32]), H256::from([100; 32]), NewBlockState::Normal, false); - let _proof15_hash = - insert_block_raw(15, H256::from([15; 32]), H256::from([150; 32]), NewBlockState::Normal, false); + let _proof5_hash = insert_block_raw( + 5, + H256::from([5; 32]), + H256::from([50; 32]), + NewBlockState::Normal, + false, + ); + let _proof10_hash = insert_block_raw( + 10, + H256::from([10; 32]), + H256::from([100; 32]), + NewBlockState::Normal, + false, + ); + let _proof15_hash = insert_block_raw( + 15, + H256::from([15; 32]), + H256::from([150; 32]), + NewBlockState::Normal, + false, + ); // Leaves must still only contain genesis. assert_eq!(blockchain.leaves().unwrap(), vec![genesis_hash]); @@ -3576,8 +3600,13 @@ pub(crate) mod tests { // --- Step 3: import warp sync target block #20 as Final --- // Parent (#19) is not in the DB. Use the same low-level approach but with // NewBlockState::Final. Being Final, it will be set as best + finalized. - let block20_hash = - insert_block_raw(20, H256::from([19; 32]), H256::from([200; 32]), NewBlockState::Final, true); + let block20_hash = insert_block_raw( + 20, + H256::from([19; 32]), + H256::from([200; 32]), + NewBlockState::Final, + true, + ); // Block #20 should now be a leaf (it's best and finalized). let leaves = blockchain.leaves().unwrap(); @@ -3631,21 +3660,13 @@ pub(crate) mod tests { .unwrap(); // Disconnected proof blocks were never leaves, so they must not // appear in displaced_leaves. - assert!( - !displaced.displaced_leaves.iter().any(|(_, h)| *h == _proof5_hash), - ); - assert!( - !displaced.displaced_leaves.iter().any(|(_, h)| *h == _proof10_hash), - ); - assert!( - !displaced.displaced_leaves.iter().any(|(_, h)| *h == _proof15_hash), - ); + assert!(!displaced.displaced_leaves.iter().any(|(_, h)| *h == _proof5_hash),); + assert!(!displaced.displaced_leaves.iter().any(|(_, h)| *h == _proof10_hash),); + assert!(!displaced.displaced_leaves.iter().any(|(_, h)| *h == _proof15_hash),); // None of the gap sync blocks should be displaced leaves either // (they were never added as leaves). for gap_hash in &gap_hashes { - assert!( - !displaced.displaced_leaves.iter().any(|(_, h)| h == gap_hash), - ); + assert!(!displaced.displaced_leaves.iter().any(|(_, h)| h == gap_hash),); } } } @@ -4606,8 +4627,15 @@ pub(crate) mod tests { extrinsics_root: Default::default(), }; - op.set_block_data(header.clone(), Some(Vec::new()), None, None, NewBlockState::Normal, true) - .unwrap(); + op.set_block_data( + header.clone(), + Some(Vec::new()), + None, + None, + NewBlockState::Normal, + true, + ) + .unwrap(); backend.commit_operation(op).unwrap(); @@ -4625,8 +4653,15 @@ pub(crate) mod tests { extrinsics_root: Default::default(), }; - op.set_block_data(header.clone(), Some(Vec::new()), None, None, NewBlockState::Normal, true) - .unwrap(); + op.set_block_data( + header.clone(), + Some(Vec::new()), + None, + None, + NewBlockState::Normal, + true, + ) + .unwrap(); backend.commit_operation(op).unwrap(); @@ -4644,8 +4679,15 @@ pub(crate) mod tests { extrinsics_root: H256::from_low_u64_le(42), }; - op.set_block_data(header.clone(), Some(Vec::new()), None, None, NewBlockState::Normal, true) - .unwrap(); + op.set_block_data( + header.clone(), + Some(Vec::new()), + None, + None, + NewBlockState::Normal, + true, + ) + .unwrap(); backend.commit_operation(op).unwrap(); @@ -4794,7 +4836,8 @@ pub(crate) mod tests { Some(Vec::new()), None, None, - NewBlockState::Normal, true, + NewBlockState::Normal, + true, ) .unwrap(); @@ -4839,7 +4882,8 @@ pub(crate) mod tests { Some(Vec::new()), None, None, - NewBlockState::Normal, true, + NewBlockState::Normal, + true, ) .unwrap(); @@ -4884,7 +4928,8 @@ pub(crate) mod tests { Some(Vec::new()), None, None, - NewBlockState::Best, true, + NewBlockState::Best, + true, ) .unwrap(); @@ -4921,7 +4966,8 @@ pub(crate) mod tests { Some(Vec::new()), None, None, - NewBlockState::Best, true, + NewBlockState::Best, + true, ) .unwrap(); From 194da08f71d36bc78345fe9ad47e530791fd678e Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Tue, 24 Feb 2026 11:48:00 +0200 Subject: [PATCH 7/9] Make tests compile --- substrate/client/api/src/in_mem.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/substrate/client/api/src/in_mem.rs b/substrate/client/api/src/in_mem.rs index cc25869914069..a8826209051d8 100644 --- a/substrate/client/api/src/in_mem.rs +++ b/substrate/client/api/src/in_mem.rs @@ -847,16 +847,16 @@ mod tests { let just2 = None; let just3 = Some(Justifications::from((ID1, vec![3]))); blockchain - .insert(header(0).hash(), header(0), just0, None, NewBlockState::Final) + .insert(header(0).hash(), header(0), just0, None, NewBlockState::Final, true) .unwrap(); blockchain - .insert(header(1).hash(), header(1), just1, None, NewBlockState::Final) + .insert(header(1).hash(), header(1), just1, None, NewBlockState::Final, true) .unwrap(); blockchain - .insert(header(2).hash(), header(2), just2, None, NewBlockState::Best) + .insert(header(2).hash(), header(2), just2, None, NewBlockState::Best, true) .unwrap(); blockchain - .insert(header(3).hash(), header(3), just3, None, NewBlockState::Final) + .insert(header(3).hash(), header(3), just3, None, NewBlockState::Final, true) .unwrap(); blockchain } From 2de735248447874a8f7bac48893746c48d1f67de Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Thu, 26 Feb 2026 16:31:34 +0200 Subject: [PATCH 8/9] Add comment --- substrate/client/api/src/backend.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/substrate/client/api/src/backend.rs b/substrate/client/api/src/backend.rs index 07913970125ee..f44c07e6d11f4 100644 --- a/substrate/client/api/src/backend.rs +++ b/substrate/client/api/src/backend.rs @@ -175,6 +175,16 @@ pub trait BlockImportOperation { fn state(&self) -> sp_blockchain::Result>; /// Append block data to the transaction. + /// + /// - `header`: The block header. + /// - `body`: The block body (extrinsics), if available. + /// - `indexed_body`: Raw extrinsic data to be stored in the transaction index, keyed by their + /// hash. + /// - `justifications`: Block justifications, e.g. finality proofs. + /// - `state`: Whether this is a normal block, the new best block, or a newly finalized block. + /// - `register_as_leaf`: Whether to add the block to the leaf set. Blocks imported during warp + /// sync are stored in the database but should not be registered as leaves, since they are + /// historical blocks and not candidates for chain progression. fn set_block_data( &mut self, header: Block::Header, From 65a5d8292194cfc6d446f8fe0c15f62788ecae77 Mon Sep 17 00:00:00 2001 From: Sebastian Kunert Date: Fri, 27 Feb 2026 00:07:55 +0200 Subject: [PATCH 9/9] Fix test path --- .github/zombienet-tests/zombienet_cumulus_tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/zombienet-tests/zombienet_cumulus_tests.yml b/.github/zombienet-tests/zombienet_cumulus_tests.yml index a991d4930450d..68b8e20a9a7c3 100644 --- a/.github/zombienet-tests/zombienet_cumulus_tests.yml +++ b/.github/zombienet-tests/zombienet_cumulus_tests.yml @@ -82,7 +82,7 @@ needs-wasm-binary: true - job-name: "zombienet-cumulus-0015-parachain-runtime-upgrade" - test-filter: "functional::parachain_runtime_upgrade::parachain_runtime_upgrade_test" + test-filter: "zombie_ci::parachain_runtime_upgrade_slot_duration_18s::parachain_runtime_upgrade_slot_duration_18s" runner-type: "default" cumulus-image: "test-parachain" use-zombienet-sdk: true