referrerpolicy=no-referrer-when-downgrade

cumulus_client_resubmission_store/
lib.rs

1// Copyright (C) Parity Technologies (UK) Ltd.
2// This file is part of Cumulus.
3// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0
4
5// Cumulus is free software: you can redistribute it and/or modify
6// it under the terms of the GNU General Public License as published by
7// the Free Software Foundation, either version 3 of the License, or
8// (at your option) any later version.
9
10// Cumulus is distributed in the hope that it will be useful,
11// but WITHOUT ANY WARRANTY; without even the implied warranty of
12// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13// GNU General Public License for more details.
14
15// You should have received a copy of the GNU General Public License
16// along with Cumulus. If not, see <https://www.gnu.org/licenses/>.
17
18//! Per-block resubmission store for the unincluded segment.
19//!
20//! Persists a [`StoredEntry`] per imported parablock, keyed by parablock hash, holding everything
21//! needed to reconstruct and resubmit the collation for an unincluded block without assuming the
22//! relay parent is still available: the storage proof, the relay parent header and the relay-parent
23//! session.
24//!
25//! Entries are pruned on parachain finality via [`prune_finalized_entries`], which is meant to run
26//! on the same task that records entries so a recorded entry is always observed by a subsequent
27//! finality notification.
28
29use codec::{Decode, Encode};
30use cumulus_primitives_core::relay_chain::{Header as RelayHeader, SessionIndex};
31use sc_client_api::{
32	backend::AuxStore,
33	client::{AuxDataOperations, FinalityNotification},
34	HeaderBackend,
35};
36use sp_blockchain::{Error as ClientError, Result as ClientResult};
37use sp_runtime::traits::{Block as BlockT, Header as HeaderT, Zero};
38use sp_trie::StorageProof;
39use std::{marker::PhantomData, sync::Arc};
40
41const STORE_VERSION_KEY: &[u8] = b"cumulus_resubmission_store_version";
42const STORE_CURRENT_VERSION: u32 = 2;
43const STORE_ENTRY_PREFIX: &[u8] = b"cumulus_resubmission_store";
44
45/// Entry stored in aux storage for each unincluded parablock.
46#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
47pub struct StoredEntry {
48	/// The storage proof captured at block import/build.
49	pub proof: Arc<StorageProof>,
50	/// The relay parent header the block was built against, used to determine the relay slot.
51	pub relay_parent_header: RelayHeader,
52	/// Relay parent's `session_index_for_child`.
53	pub relay_parent_session: SessionIndex,
54	/// The relay chain's `max_pov_size` at the relay parent, used to rebuild the block's persisted
55	/// validation data at resubmission time.
56	pub max_pov_size: u32,
57}
58
59fn entry_key<H: Encode>(block_hash: H) -> Vec<u8> {
60	(STORE_ENTRY_PREFIX, block_hash).encode()
61}
62
63/// Per-block proof store backed by `AuxStore`.
64pub struct ResubmissionStore<Block: BlockT, B> {
65	backend: Arc<B>,
66	_marker: PhantomData<fn() -> Block>,
67}
68
69impl<Block: BlockT, B> Clone for ResubmissionStore<Block, B> {
70	fn clone(&self) -> Self {
71		Self { backend: self.backend.clone(), _marker: PhantomData }
72	}
73}
74
75impl<Block: BlockT, B> ResubmissionStore<Block, B> {
76	/// Create a new store over `backend`.
77	pub fn new(backend: Arc<B>) -> Self {
78		Self { backend, _marker: PhantomData }
79	}
80}
81
82/// Build the aux-data key/value pairs to commit alongside a block.
83///
84/// The caller should push these into `BlockImportParams::auxiliary` so they commit in the
85/// same DB transaction as the block. Stateless — no backend access required.
86pub fn prepare_resubmission_aux_data<Block: BlockT>(
87	block_hash: Block::Hash,
88	proof: Arc<StorageProof>,
89	relay_parent_header: RelayHeader,
90	relay_parent_session: SessionIndex,
91	max_pov_size: u32,
92) -> impl Iterator<Item = (Vec<u8>, Vec<u8>)> {
93	let encoded_entry =
94		(&proof, &relay_parent_header, &relay_parent_session, &max_pov_size).encode();
95	let encoded_version = STORE_CURRENT_VERSION.encode();
96
97	[(entry_key(block_hash), encoded_entry), (STORE_VERSION_KEY.to_vec(), encoded_version)]
98		.into_iter()
99}
100
101impl<Block: BlockT, B: AuxStore> ResubmissionStore<Block, B> {
102	/// Load the entry stored for `block_hash`, if any.
103	pub fn load(&self, block_hash: Block::Hash) -> ClientResult<Option<StoredEntry>> {
104		let version = self.decode_aux::<u32>(STORE_VERSION_KEY)?;
105
106		match version {
107			None => Ok(None),
108			Some(STORE_CURRENT_VERSION) => self.decode_aux(entry_key(block_hash).as_slice()),
109			Some(other) => Err(ClientError::Backend(format!(
110				"Unsupported resubmission store DB version: {:?}",
111				other
112			))),
113		}
114	}
115
116	fn decode_aux<T: Decode>(&self, key: &[u8]) -> ClientResult<Option<T>> {
117		match self.backend.get_aux(key)? {
118			None => Ok(None),
119			Some(t) => T::decode(&mut &t[..]).map(Some).map_err(|e| {
120				ClientError::Backend(format!(
121					"Resubmission store DB is corrupted. Decode error: {}",
122					e
123				))
124			}),
125		}
126	}
127}
128
129/// Delete entries for the just-finalized chain, the tree route, and stale forks — once
130/// finalized, a block is no longer in any unincluded segment.
131pub fn prune_finalized_entries<Block, B>(
132	backend: &B,
133	notification: &FinalityNotification<Block>,
134) -> ClientResult<()>
135where
136	Block: BlockT,
137	B: AuxStore + HeaderBackend<Block>,
138{
139	let ops = finality_cleanup_ops::<Block>(
140		notification.hash,
141		&notification.tree_route,
142		notification.stale_blocks.iter().map(|b| b.hash),
143	);
144
145	let deletes: Vec<_> =
146		ops.iter().filter_map(|(k, v)| v.is_none().then_some(k.as_slice())).collect();
147
148	backend.insert_aux(&[], &deletes)
149}
150
151/// Delete entries for blocks that are already finalized, reclaiming any whose prune was never
152/// observed — e.g. blocks finalized while the node was down, which the freshly-subscribed
153/// notification stream never sees. Run once at startup, before the backfill loop.
154pub fn prune_missed_finalized_entries<Block, B>(backend: &B) -> ClientResult<()>
155where
156	Block: BlockT,
157	B: AuxStore + HeaderBackend<Block>,
158{
159	let mut hash = backend.info().finalized_hash;
160	let mut deletes: Vec<Vec<u8>> = Vec::new();
161
162	while let Some(header) = backend.header(hash)? {
163		let key = entry_key(hash);
164		// First finalized block without an entry: everything below is already pruned.
165		if backend.get_aux(&key)?.is_none() {
166			break;
167		}
168		deletes.push(key);
169		if header.number().is_zero() {
170			break;
171		}
172		hash = *header.parent_hash();
173	}
174
175	if !deletes.is_empty() {
176		let delete_refs: Vec<&[u8]> = deletes.iter().map(|k| k.as_slice()).collect();
177		backend.insert_aux(&[], &delete_refs)?;
178	}
179
180	Ok(())
181}
182
183/// Compute aux storage cleanup operations.
184///
185/// Emits deletes for stale-fork blocks, intermediate tree-route blocks, and the just-finalized
186/// block itself. Once a block is finalized it is no longer in any unincluded segment, so its
187/// proof entry is dead weight.
188fn finality_cleanup_ops<Block: BlockT>(
189	just_finalized_hash: Block::Hash,
190	tree_route: &[Block::Hash],
191	stale_block_hashes: impl IntoIterator<Item = Block::Hash>,
192) -> AuxDataOperations {
193	let stale_iter = stale_block_hashes.into_iter();
194
195	let mut ops = Vec::with_capacity(stale_iter.size_hint().0 + tree_route.len() + 1);
196	ops.extend(stale_iter.map(|hash| (entry_key(hash), None)));
197	ops.extend(tree_route.iter().map(|hash| (entry_key(hash), None)));
198	ops.push((entry_key(just_finalized_hash), None));
199
200	ops
201}
202
203#[cfg(test)]
204mod tests {
205	use super::*;
206	use sc_client_api::backend::AuxStore;
207
208	type Block = substrate_test_runtime::Block;
209	type Hash = <Block as BlockT>::Hash;
210	type TestBackend = sc_client_api::in_mem::Backend<Block>;
211	type Store = ResubmissionStore<Block, TestBackend>;
212
213	fn test_relay_header(number: u32) -> RelayHeader {
214		RelayHeader {
215			parent_hash: Default::default(),
216			number,
217			state_root: Default::default(),
218			extrinsics_root: Default::default(),
219			digest: Default::default(),
220		}
221	}
222
223	fn create_test_entry() -> StoredEntry {
224		StoredEntry {
225			proof: Arc::new(StorageProof::new(vec![vec![1, 2, 3], vec![4, 5, 6]])),
226			relay_parent_header: test_relay_header(7),
227			relay_parent_session: 1,
228			max_pov_size: 5_242_880,
229		}
230	}
231
232	fn new_store() -> (Arc<TestBackend>, Store) {
233		let backend = Arc::new(TestBackend::new());
234		let store = Store::new(backend.clone());
235		(backend, store)
236	}
237
238	fn write_via_store(backend: &Arc<TestBackend>, hash: Hash, entry: &StoredEntry) {
239		let pairs: Vec<_> = prepare_resubmission_aux_data::<Block>(
240			hash,
241			entry.proof.clone(),
242			entry.relay_parent_header.clone(),
243			entry.relay_parent_session,
244			entry.max_pov_size,
245		)
246		.collect();
247		let insert_pairs: Vec<_> =
248			pairs.iter().map(|(k, v)| (k.as_slice(), v.as_slice())).collect();
249		AuxStore::insert_aux(&**backend, &insert_pairs, &[]).expect("aux insert should succeed");
250	}
251
252	#[test]
253	fn prepare_produces_expected_key_value_pairs() {
254		let hash = Hash::repeat_byte(0xAB);
255		let proof = Arc::new(StorageProof::new(vec![vec![10, 20, 30]]));
256		let relay_parent_header = test_relay_header(42);
257		let relay_parent_session = 42;
258		let max_pov_size = 1_048_576;
259		let pairs: Vec<_> = prepare_resubmission_aux_data::<Block>(
260			hash,
261			proof.clone(),
262			relay_parent_header.clone(),
263			relay_parent_session,
264			max_pov_size,
265		)
266		.collect();
267
268		assert_eq!(pairs.len(), 2);
269
270		let expected_key = (STORE_ENTRY_PREFIX, hash).encode();
271		assert_eq!(pairs[0].0, expected_key);
272
273		let decoded_entry =
274			StoredEntry::decode(&mut pairs[0].1.as_slice()).expect("entry should decode");
275		assert_eq!(decoded_entry.proof, proof);
276		assert_eq!(decoded_entry.relay_parent_header, relay_parent_header);
277		assert_eq!(decoded_entry.relay_parent_session, relay_parent_session);
278		assert_eq!(decoded_entry.max_pov_size, max_pov_size);
279
280		assert_eq!(pairs[1].0, STORE_VERSION_KEY.to_vec());
281		let decoded_version =
282			u32::decode(&mut pairs[1].1.as_slice()).expect("version should decode");
283		assert_eq!(decoded_version, STORE_CURRENT_VERSION);
284	}
285
286	#[test]
287	fn load_returns_none_when_no_entry_exists() {
288		let (_backend, store) = new_store();
289		let hash = Hash::repeat_byte(0xEF);
290
291		assert_eq!(store.load(hash).expect("load should succeed"), None);
292	}
293
294	#[test]
295	fn cleanup_combines_all_categories() {
296		let stale_1 = Hash::repeat_byte(0xAA);
297		let stale_2 = Hash::repeat_byte(0xBB);
298		let route_1 = Hash::repeat_byte(0xC1);
299		let route_2 = Hash::repeat_byte(0xC2);
300		let just_finalized = Hash::repeat_byte(0xFF);
301
302		let ops =
303			finality_cleanup_ops::<Block>(just_finalized, &[route_1, route_2], [stale_1, stale_2]);
304
305		let keys: Vec<_> = ops.iter().map(|(k, _)| k.clone()).collect();
306
307		assert!(keys.contains(&entry_key(stale_1)));
308		assert!(keys.contains(&entry_key(stale_2)));
309		assert!(keys.contains(&entry_key(route_1)));
310		assert!(keys.contains(&entry_key(route_2)));
311		assert!(keys.contains(&entry_key(just_finalized)));
312
313		assert!(ops.iter().all(|(_, v)| v.is_none()));
314	}
315
316	#[test]
317	fn cleanup_handles_empty_inputs() {
318		let just_finalized = Hash::repeat_byte(0xFF);
319
320		let ops = finality_cleanup_ops::<Block>(just_finalized, &[], std::iter::empty::<Hash>());
321
322		assert_eq!(ops.len(), 1);
323		assert!(ops.iter().all(|(_, v)| v.is_none()));
324	}
325
326	#[test]
327	fn stored_entry_round_trips() {
328		// The on-disk format of `StoredEntry` is versioned by `STORE_CURRENT_VERSION`. If this
329		// struct's encoding changes, existing aux entries written by older builds will fail to
330		// decode — bump `STORE_CURRENT_VERSION` and add a migration.
331		let entry = create_test_entry();
332
333		let encoded = entry.encode();
334		let decoded = StoredEntry::decode(&mut encoded.as_slice()).expect("decode should succeed");
335		assert_eq!(entry, decoded);
336	}
337
338	#[test]
339	fn decode_corrupted_entry_body() {
340		let (backend, store) = new_store();
341		let hash = Hash::repeat_byte(0xAB);
342
343		// Write correct version.
344		let version_encoded = STORE_CURRENT_VERSION.encode();
345		AuxStore::insert_aux(&*backend, &[(STORE_VERSION_KEY, version_encoded.as_slice())], &[])
346			.expect("aux insert should succeed");
347
348		// Write bogus entry body.
349		let key = entry_key(hash);
350		let bogus_data = vec![0xFF, 0xAA, 0xBB];
351		AuxStore::insert_aux(&*backend, &[(&key[..], bogus_data.as_slice())], &[])
352			.expect("aux insert should succeed");
353
354		let result = store.load(hash);
355		assert!(result.is_err());
356		let err_msg = result.unwrap_err().to_string();
357		assert!(
358			err_msg.contains("DB is corrupted") && err_msg.contains("Decode error"),
359			"unexpected error: {}",
360			err_msg
361		);
362	}
363
364	#[test]
365	fn end_to_end_write_cleanup_load() {
366		let (backend, store) = new_store();
367
368		let hash1 = Hash::repeat_byte(0x01);
369		let hash2 = Hash::repeat_byte(0x02);
370		let hash3 = Hash::repeat_byte(0x03);
371
372		let entry1 = create_test_entry();
373		let entry2 = create_test_entry();
374		let entry3 = create_test_entry();
375
376		write_via_store(&backend, hash1, &entry1);
377		write_via_store(&backend, hash2, &entry2);
378		write_via_store(&backend, hash3, &entry3);
379
380		assert_eq!(store.load(hash1).expect("load"), Some(entry1));
381		assert_eq!(store.load(hash2).expect("load"), Some(entry2));
382		assert_eq!(store.load(hash3).expect("load"), Some(entry3.clone()));
383
384		// Generate cleanup that deletes hash1 (just-finalized) and hash2 (in tree route).
385		let ops = finality_cleanup_ops::<Block>(hash1, &[hash2], std::iter::empty::<Hash>());
386		let delete_keys: Vec<_> =
387			ops.iter().filter_map(|(k, v)| v.is_none().then(|| k.as_slice())).collect();
388
389		AuxStore::insert_aux(&*backend, &[], &delete_keys).expect("delete should succeed");
390
391		assert_eq!(store.load(hash1).expect("load"), None, "hash1 should be deleted");
392		assert_eq!(store.load(hash2).expect("load"), None, "hash2 should be deleted");
393		assert_eq!(store.load(hash3).expect("load"), Some(entry3), "hash3 should survive");
394	}
395
396	#[test]
397	fn entries_survive_disk_restart() {
398		use sc_client_db::{
399			Backend as DbBackend, BlocksPruning, DatabaseSettings, DatabaseSource, PruningMode,
400		};
401
402		fn with_backend<R>(
403			path: &std::path::Path,
404			f: impl FnOnce(&Arc<DbBackend<Block>>) -> R,
405		) -> R {
406			let backend = Arc::new(
407				DbBackend::<Block>::new(
408					DatabaseSettings {
409						trie_cache_maximum_size: Some(16 * 1024 * 1024),
410						state_pruning: Some(PruningMode::ArchiveAll),
411						blocks_pruning: BlocksPruning::KeepAll,
412						pruning_filters: Default::default(),
413						source: DatabaseSource::ParityDb { path: path.to_path_buf() },
414						metrics_registry: None,
415					},
416					0,
417				)
418				.expect("open backend"),
419			);
420			let result = f(&backend);
421			// `backend` (and any clones held by the closure) drop here, closing parity-db.
422			result
423		}
424
425		let tmp = tempfile::tempdir().expect("tempdir");
426		let path = tmp.path();
427
428		let hash_a = Hash::repeat_byte(0x10);
429		let hash_b = Hash::repeat_byte(0x20);
430		let entry_a = create_test_entry();
431		let entry_b = create_test_entry();
432
433		// Write `a` and `b` via the same path block-import uses, then close.
434		with_backend(path, |backend| {
435			let pairs: Vec<_> = prepare_resubmission_aux_data::<Block>(
436				hash_a,
437				entry_a.proof.clone(),
438				entry_a.relay_parent_header.clone(),
439				entry_a.relay_parent_session,
440				entry_a.max_pov_size,
441			)
442			.chain(prepare_resubmission_aux_data::<Block>(
443				hash_b,
444				entry_b.proof.clone(),
445				entry_b.relay_parent_header.clone(),
446				entry_b.relay_parent_session,
447				entry_b.max_pov_size,
448			))
449			.collect();
450			let refs: Vec<_> = pairs.iter().map(|(k, v)| (k.as_slice(), v.as_slice())).collect();
451			AuxStore::insert_aux(&**backend, &refs, &[]).expect("aux insert");
452		});
453
454		// Restart: confirm both entries survived, then apply a finality-style delete of `a`.
455		with_backend(path, |backend| {
456			let store = ResubmissionStore::<Block, _>::new(backend.clone());
457			assert_eq!(store.load(hash_a).expect("load a"), Some(entry_a.clone()));
458			assert_eq!(store.load(hash_b).expect("load b"), Some(entry_b.clone()));
459
460			let ops = finality_cleanup_ops::<Block>(hash_a, &[], std::iter::empty::<Hash>());
461			let delete_keys: Vec<_> =
462				ops.iter().filter_map(|(k, v)| v.is_none().then(|| k.as_slice())).collect();
463			AuxStore::insert_aux(&**backend, &[], &delete_keys).expect("delete");
464		});
465
466		// Restart: the delete must have persisted; `b` must still be there.
467		with_backend(path, |backend| {
468			let store = ResubmissionStore::<Block, _>::new(backend.clone());
469			assert_eq!(store.load(hash_a).expect("load a"), None, "hash_a delete must persist");
470			assert_eq!(store.load(hash_b).expect("load b"), Some(entry_b));
471		});
472
473		// `tmp` drops here, recursively removing the parity-db directory.
474	}
475
476	#[test]
477	fn load_returns_error_on_unsupported_version() {
478		let (backend, store) = new_store();
479		let hash = Hash::repeat_byte(0xAB);
480
481		// Write an unsupported version number to the version key.
482		let unsupported_version = 99u32;
483		let encoded_version = unsupported_version.encode();
484		AuxStore::insert_aux(&*backend, &[(STORE_VERSION_KEY, encoded_version.as_slice())], &[])
485			.expect("aux insert should succeed");
486
487		let result = store.load(hash);
488		assert!(result.is_err());
489		let err_msg = result.unwrap_err().to_string();
490		assert!(
491			err_msg.contains("Unsupported") && err_msg.contains("version"),
492			"unexpected error: {}",
493			err_msg
494		);
495	}
496
497	/// A minimal in-memory backend — a finalized chain plus an aux key-value store. Enough to drive
498	/// [`prune_missed_finalized_entries`] (which only reads `info()`/`header()` and writes aux)
499	/// without a runtime or on-disk DB.
500	#[derive(Default)]
501	struct MockBackend {
502		headers: std::collections::HashMap<Hash, <Block as BlockT>::Header>,
503		by_number: std::collections::HashMap<u64, Hash>,
504		finalized: (Hash, u64),
505		aux: std::sync::Mutex<std::collections::HashMap<Vec<u8>, Vec<u8>>>,
506	}
507
508	impl MockBackend {
509		/// Append a block on top of `parent`, returning its hash.
510		fn push(&mut self, number: u64, parent: Hash) -> Hash {
511			let header = <<Block as BlockT>::Header as HeaderT>::new(
512				number,
513				Default::default(),
514				Default::default(),
515				parent,
516				Default::default(),
517			);
518			let hash = header.hash();
519			self.headers.insert(hash, header);
520			self.by_number.insert(number, hash);
521			hash
522		}
523
524		fn write_entry(&self, hash: Hash, entry: StoredEntry) {
525			let pairs: Vec<_> = prepare_resubmission_aux_data::<Block>(
526				hash,
527				entry.proof,
528				entry.relay_parent_header,
529				entry.relay_parent_session,
530				entry.max_pov_size,
531			)
532			.collect();
533			let refs: Vec<_> = pairs.iter().map(|(k, v)| (k.as_slice(), v.as_slice())).collect();
534			self.insert_aux(&refs, &[]).unwrap();
535		}
536
537		fn has_entry(&self, hash: Hash) -> bool {
538			self.get_aux(&entry_key(hash)).unwrap().is_some()
539		}
540	}
541
542	impl AuxStore for MockBackend {
543		fn insert_aux<
544			'a,
545			'b: 'a,
546			'c: 'a,
547			I: IntoIterator<Item = &'a (&'c [u8], &'c [u8])>,
548			D: IntoIterator<Item = &'a &'b [u8]>,
549		>(
550			&self,
551			insert: I,
552			delete: D,
553		) -> ClientResult<()> {
554			let mut aux = self.aux.lock().unwrap();
555			for (k, v) in insert {
556				aux.insert(k.to_vec(), v.to_vec());
557			}
558			for k in delete {
559				aux.remove(*k);
560			}
561			Ok(())
562		}
563
564		fn get_aux(&self, key: &[u8]) -> ClientResult<Option<Vec<u8>>> {
565			Ok(self.aux.lock().unwrap().get(key).cloned())
566		}
567	}
568
569	impl HeaderBackend<Block> for MockBackend {
570		fn header(&self, hash: Hash) -> ClientResult<Option<<Block as BlockT>::Header>> {
571			Ok(self.headers.get(&hash).cloned())
572		}
573
574		fn info(&self) -> sp_blockchain::Info<Block> {
575			sp_blockchain::Info {
576				best_hash: self.finalized.0,
577				best_number: self.finalized.1,
578				genesis_hash: self.by_number.get(&0).copied().unwrap_or_default(),
579				finalized_hash: self.finalized.0,
580				finalized_number: self.finalized.1,
581				finalized_state: None,
582				number_leaves: 1,
583				block_gap: None,
584			}
585		}
586
587		fn status(&self, hash: Hash) -> ClientResult<sp_blockchain::BlockStatus> {
588			Ok(if self.headers.contains_key(&hash) {
589				sp_blockchain::BlockStatus::InChain
590			} else {
591				sp_blockchain::BlockStatus::Unknown
592			})
593		}
594
595		fn number(&self, hash: Hash) -> ClientResult<Option<u64>> {
596			Ok(self.headers.get(&hash).map(|h| *h.number()))
597		}
598
599		fn hash(&self, number: u64) -> ClientResult<Option<Hash>> {
600			Ok(self.by_number.get(&number).cloned())
601		}
602	}
603
604	#[test]
605	fn prune_missed_reclaims_entries_finalized_while_down() {
606		let mut backend = MockBackend::default();
607
608		// Genesis plus a 5-block chain; record an entry for each non-genesis block.
609		let mut parent = backend.push(0, Default::default());
610		let mut hashes = Vec::new();
611		for number in 1..=5u64 {
612			parent = backend.push(number, parent);
613			hashes.push(parent);
614		}
615		for hash in hashes.iter() {
616			backend.write_entry(*hash, create_test_entry());
617		}
618
619		// Finalize up to block 3 with nothing pruning (stands in for finalization observed while
620		// the node was down): entries for the now-finalized blocks 1..=3 leak, they are still
621		// present.
622		backend.finalized = (hashes[2], 3);
623		assert!(
624			backend.has_entry(hashes[0]) &&
625				backend.has_entry(hashes[1]) &&
626				backend.has_entry(hashes[2]),
627			"finalized entries leaked",
628		);
629
630		// Startup pruning reclaims the finalized entries and spares the still-unincluded
631		// ones. The downward walk stops at genesis (no entry there).
632		prune_missed_finalized_entries::<Block, _>(&backend).unwrap();
633		assert!(
634			!backend.has_entry(hashes[0]) &&
635				!backend.has_entry(hashes[1]) &&
636				!backend.has_entry(hashes[2]),
637			"finalized entries reclaimed",
638		);
639		assert!(
640			backend.has_entry(hashes[3]) && backend.has_entry(hashes[4]),
641			"unincluded entries kept",
642		);
643	}
644}