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