Skip to content

Commit 927ff37

Browse files
author
bitcoin-rs
committed
feat(pruning): block + undo pruner + utreexo-only mode coordinator
Block-file pruner with Core 288-block reorg-safety floor; undo-file pruner symmetric. Utreexo-only coordinator deletes block bodies after indexing while retaining headers. PrunePolicy mirrors Core -prune semantics with full/minimal/ utreexo-only presets. Op: extend
1 parent 2b056a1 commit 927ff37

9 files changed

Lines changed: 1509 additions & 9 deletions

File tree

Cargo.lock

Lines changed: 657 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/pruning/Cargo.toml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,24 @@ description = "bitcoin-rs :: pruning"
1212
workspace = true
1313

1414
[dependencies]
15+
bitcoin-rs-primitives.workspace = true
16+
bitcoin-rs-storage = { workspace = true, default-features = false }
17+
bitcoin-rs-utxo.workspace = true
18+
bitcoin-rs-chain.workspace = true
19+
bitcoin-rs-utreexo.workspace = true
20+
bitcoin.workspace = true
21+
parking_lot.workspace = true
22+
thiserror.workspace = true
23+
tracing.workspace = true
24+
25+
[features]
26+
default = []
27+
rocksdb = ["bitcoin-rs-storage/rocksdb"]
28+
fjall = ["bitcoin-rs-storage/fjall"]
29+
redb = ["bitcoin-rs-storage/redb"]
30+
mdbx = ["bitcoin-rs-storage/mdbx"]
31+
32+
[dev-dependencies]
33+
proptest.workspace = true
34+
serde_json.workspace = true
35+
tempfile = "3"

crates/pruning/src/block_pruner.rs

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
use alloc::sync::Arc;
2+
3+
use bitcoin_rs_primitives::Hash256;
4+
use bitcoin_rs_storage::{ColumnFamily, KvStore, WriteBatch};
5+
6+
use crate::{PruneError, PruneOutcome, PrunePolicy, row_len_u64};
7+
8+
const BLOCK_BODY_PREFIX: u8 = b'b';
9+
const BLOCK_BODY_PREFIX_BYTES: &[u8] = b"b";
10+
const HEIGHT_START: usize = 1;
11+
const HEIGHT_END: usize = 5;
12+
const KEY_LEN: usize = 37;
13+
14+
/// Column family used for block-body rows until storage gains a dedicated block-file CF.
15+
pub const BLOCK_DATA_CF: ColumnFamily = ColumnFamily::BlockTree;
16+
17+
/// Builds the canonical pruning key for a stored block body.
18+
#[must_use]
19+
pub fn block_body_key(height: u32, hash: Hash256) -> [u8; KEY_LEN] {
20+
let mut key = [0_u8; KEY_LEN];
21+
key[0] = BLOCK_BODY_PREFIX;
22+
key[HEIGHT_START..HEIGHT_END].copy_from_slice(&height.to_be_bytes());
23+
key[HEIGHT_END..].copy_from_slice(hash.as_byte_array());
24+
key
25+
}
26+
27+
/// Prunes persisted block-body rows according to a [`PrunePolicy`].
28+
pub struct BlockPruner<S: KvStore> {
29+
store: Arc<S>,
30+
policy: PrunePolicy,
31+
}
32+
33+
impl<S: KvStore> BlockPruner<S> {
34+
/// Creates a block pruner over `store`.
35+
#[must_use]
36+
pub const fn new(store: Arc<S>, policy: PrunePolicy) -> Self {
37+
Self { store, policy }
38+
}
39+
40+
/// Returns this pruner's policy.
41+
#[must_use]
42+
pub const fn policy(&self) -> PrunePolicy {
43+
self.policy
44+
}
45+
46+
/// Deletes block-body rows below the effective reorg-safety horizon until the target is met.
47+
pub fn prune_step(&mut self, current_tip_height: u32) -> Result<PruneOutcome, PruneError> {
48+
if self.policy.is_full_node() {
49+
return Ok(PruneOutcome::default());
50+
}
51+
52+
prune_prefixed_rows(
53+
&*self.store,
54+
BLOCK_BODY_PREFIX_BYTES,
55+
current_tip_height,
56+
self.policy,
57+
)
58+
}
59+
}
60+
61+
pub(crate) fn prune_prefixed_rows<S: KvStore>(
62+
store: &S,
63+
prefix: &[u8],
64+
current_tip_height: u32,
65+
policy: PrunePolicy,
66+
) -> Result<PruneOutcome, PruneError> {
67+
let target_bytes = policy.target_size_bytes();
68+
let prune_below_height = current_tip_height.saturating_sub(policy.retention_depth());
69+
let mut total_bytes = 0_u64;
70+
let mut candidates = Vec::new();
71+
72+
for row in store.iter_prefix(BLOCK_DATA_CF, prefix)? {
73+
let (key, value) = row?;
74+
let row_bytes = row_len_u64(&value)?;
75+
total_bytes = total_bytes.saturating_add(row_bytes);
76+
77+
if let Some(height) = row_height(&key, prefix) {
78+
if height < prune_below_height {
79+
candidates.push((key, row_bytes));
80+
}
81+
}
82+
}
83+
84+
if total_bytes <= target_bytes || candidates.is_empty() {
85+
return Ok(PruneOutcome::default());
86+
}
87+
88+
let mut remaining_bytes = total_bytes;
89+
let mut outcome = PruneOutcome::default();
90+
let mut batch = store.new_batch();
91+
92+
for (key, row_bytes) in candidates {
93+
if remaining_bytes <= target_bytes {
94+
break;
95+
}
96+
97+
batch.delete(BLOCK_DATA_CF, &key);
98+
remaining_bytes = remaining_bytes.saturating_sub(row_bytes);
99+
outcome.record_removed(row_bytes);
100+
}
101+
102+
if !outcome.is_empty() {
103+
store.write(batch)?;
104+
tracing::debug!(
105+
bytes_freed = outcome.bytes_freed,
106+
blocks_removed = outcome.blocks_removed,
107+
"pruned block storage rows"
108+
);
109+
}
110+
111+
Ok(outcome)
112+
}
113+
114+
fn row_height(key: &[u8], prefix: &[u8]) -> Option<u32> {
115+
if key.len() != KEY_LEN || !key.starts_with(prefix) {
116+
return None;
117+
}
118+
119+
let mut bytes = [0_u8; 4];
120+
bytes.copy_from_slice(&key[HEIGHT_START..HEIGHT_END]);
121+
Some(u32::from_be_bytes(bytes))
122+
}

crates/pruning/src/lib.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,62 @@
11
#![doc = include_str!("../README.md")]
22
#![forbid(unsafe_op_in_unsafe_fn)]
3+
4+
extern crate alloc;
5+
6+
/// Block-body pruning over persisted block rows.
7+
pub mod block_pruner;
8+
/// Pruning policy shapes matching Bitcoin Core semantics.
9+
pub mod policy;
10+
/// Undo-data pruning over persisted undo rows.
11+
pub mod undo_pruner;
12+
/// Utreexo-only block body deletion coordinator.
13+
pub mod utreexo_only;
14+
15+
pub use block_pruner::{BlockPruner, block_body_key};
16+
pub use policy::PrunePolicy;
17+
pub use undo_pruner::{UndoPruner, block_undo_key};
18+
pub use utreexo_only::{BlockProcessed, UtreexoOnlyCoordinator};
19+
20+
use bitcoin_rs_storage::StorageError;
21+
use thiserror::Error;
22+
23+
/// Result of one pruning pass.
24+
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
25+
pub struct PruneOutcome {
26+
/// Number of payload bytes deleted from storage.
27+
pub bytes_freed: u64,
28+
/// Number of block or undo rows deleted from storage.
29+
pub blocks_removed: u64,
30+
}
31+
32+
impl PruneOutcome {
33+
/// Adds one deleted row to the outcome.
34+
pub(crate) fn record_removed(&mut self, bytes: u64) {
35+
self.bytes_freed = self.bytes_freed.saturating_add(bytes);
36+
self.blocks_removed = self.blocks_removed.saturating_add(1);
37+
}
38+
39+
/// Returns true when no rows were deleted.
40+
#[must_use]
41+
pub const fn is_empty(self) -> bool {
42+
self.blocks_removed == 0
43+
}
44+
}
45+
46+
/// Errors returned while pruning persisted block or undo rows.
47+
#[derive(Debug, Error)]
48+
pub enum PruneError {
49+
/// A storage backend operation failed.
50+
#[error(transparent)]
51+
Storage(#[from] StorageError),
52+
/// A storage row length could not fit in the pruning byte counter.
53+
#[error("storage row length {size} does not fit in u64")]
54+
RowSizeOverflow {
55+
/// Row length returned by the storage backend.
56+
size: usize,
57+
},
58+
}
59+
60+
pub(crate) fn row_len_u64(value: &[u8]) -> Result<u64, PruneError> {
61+
u64::try_from(value.len()).map_err(|_| PruneError::RowSizeOverflow { size: value.len() })
62+
}

crates/pruning/src/policy.rs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/// Minimum number of blocks kept below the active tip for Core-compatible reorg safety.
2+
pub const CORE_REORG_SAFETY_MARGIN: u32 = 288;
3+
4+
const BYTES_PER_MIB: u64 = 1024 * 1024;
5+
6+
/// Block and undo pruning policy.
7+
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
8+
pub struct PrunePolicy {
9+
/// Target serialized block-data footprint in mebibytes.
10+
pub target_size_mb: u64,
11+
/// Caller-requested number of blocks retained below the active tip.
12+
pub keep_below_tip: u32,
13+
}
14+
15+
impl PrunePolicy {
16+
/// Returns a policy that disables pruning.
17+
#[must_use]
18+
pub const fn full_node() -> Self {
19+
Self {
20+
target_size_mb: u64::MAX,
21+
keep_below_tip: u32::MAX,
22+
}
23+
}
24+
25+
/// Returns Bitcoin Core's minimal pruning shape: 550 MiB and 288-block reorg margin.
26+
#[must_use]
27+
pub const fn minimal() -> Self {
28+
Self {
29+
target_size_mb: 550,
30+
keep_below_tip: CORE_REORG_SAFETY_MARGIN,
31+
}
32+
}
33+
34+
/// Returns a Utreexo-only policy that discards block bodies immediately.
35+
#[must_use]
36+
pub const fn utreexo_only() -> Self {
37+
Self {
38+
target_size_mb: 0,
39+
keep_below_tip: 0,
40+
}
41+
}
42+
43+
/// Returns true when this policy disables pruning.
44+
#[must_use]
45+
pub const fn is_full_node(self) -> bool {
46+
self.target_size_mb == u64::MAX
47+
}
48+
49+
/// Returns true when this policy requests immediate Utreexo-only block deletion.
50+
#[must_use]
51+
pub const fn is_utreexo_only(self) -> bool {
52+
self.target_size_mb == 0 && self.keep_below_tip == 0
53+
}
54+
55+
/// Returns the byte target used by pruning passes.
56+
#[must_use]
57+
pub const fn target_size_bytes(self) -> u64 {
58+
self.target_size_mb.saturating_mul(BYTES_PER_MIB)
59+
}
60+
61+
/// Returns the effective retention depth below tip.
62+
#[must_use]
63+
pub fn retention_depth(self) -> u32 {
64+
// SPEC: Core's reorg-safety margin is 288 blocks.
65+
self.keep_below_tip.max(CORE_REORG_SAFETY_MARGIN)
66+
}
67+
}
68+
69+
impl Default for PrunePolicy {
70+
fn default() -> Self {
71+
Self::full_node()
72+
}
73+
}

crates/pruning/src/undo_pruner.rs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
use alloc::sync::Arc;
2+
3+
use bitcoin_rs_primitives::Hash256;
4+
use bitcoin_rs_storage::KvStore;
5+
6+
use crate::block_pruner::prune_prefixed_rows;
7+
use crate::{PruneError, PruneOutcome, PrunePolicy};
8+
9+
const BLOCK_UNDO_PREFIX: u8 = b'u';
10+
const BLOCK_UNDO_PREFIX_BYTES: &[u8] = b"u";
11+
const HEIGHT_START: usize = 1;
12+
const HEIGHT_END: usize = 5;
13+
const KEY_LEN: usize = 37;
14+
15+
/// Builds the canonical pruning key for stored undo data for one block.
16+
#[must_use]
17+
pub fn block_undo_key(height: u32, hash: Hash256) -> [u8; KEY_LEN] {
18+
let mut key = [0_u8; KEY_LEN];
19+
key[0] = BLOCK_UNDO_PREFIX;
20+
key[HEIGHT_START..HEIGHT_END].copy_from_slice(&height.to_be_bytes());
21+
key[HEIGHT_END..].copy_from_slice(hash.as_byte_array());
22+
key
23+
}
24+
25+
/// Prunes persisted undo rows according to a [`PrunePolicy`].
26+
pub struct UndoPruner<S: KvStore> {
27+
store: Arc<S>,
28+
policy: PrunePolicy,
29+
}
30+
31+
impl<S: KvStore> UndoPruner<S> {
32+
/// Creates an undo pruner over `store`.
33+
#[must_use]
34+
pub const fn new(store: Arc<S>, policy: PrunePolicy) -> Self {
35+
Self { store, policy }
36+
}
37+
38+
/// Returns this pruner's policy.
39+
#[must_use]
40+
pub const fn policy(&self) -> PrunePolicy {
41+
self.policy
42+
}
43+
44+
/// Deletes undo rows below the effective reorg-safety horizon until the target is met.
45+
pub fn prune_step(&mut self, current_tip_height: u32) -> Result<PruneOutcome, PruneError> {
46+
if self.policy.is_full_node() {
47+
return Ok(PruneOutcome::default());
48+
}
49+
50+
prune_prefixed_rows(
51+
&*self.store,
52+
BLOCK_UNDO_PREFIX_BYTES,
53+
current_tip_height,
54+
self.policy,
55+
)
56+
}
57+
}

0 commit comments

Comments
 (0)