Skip to content

Commit 64aea1d

Browse files
committed
fix(compaction): atomic scratch-file repack; honest single-shot compact_step
1 parent 13ae82d commit 64aea1d

11 files changed

Lines changed: 383 additions & 609 deletions

File tree

src/catalog/codec.rs

Lines changed: 4 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -42,46 +42,13 @@ pub enum CatalogRowKind {
4242
/// catalog, reader pins are maintained in-memory only and the writer process
4343
/// must be trusted to honor the catalog pins.
4444
ReaderPin = 0x06,
45-
/// Incremental compaction watermark. Singleton row; key is `[0x07]`.
46-
/// Value: `target_root[8] || frontier_page_id[8] || started_at_commit_id[8] ||
47-
/// total_pages_estimate[8]` = 32 bytes.
48-
///
49-
/// Present only while an incremental compaction is in progress. Cleared
50-
/// atomically with the final compaction commit. On `Db::open`, a present
51-
/// row means a prior compaction was interrupted; the embedder must call
52-
/// `compact_step` again to resume — `Db::open` does NOT auto-resume.
45+
/// Reserved (`0x07`). Older builds wrote an incremental-compaction watermark
46+
/// here; compaction is now a single atomic operation and never writes it.
47+
/// Retained as a row-kind boundary and so any legacy row is recognised and
48+
/// dropped during compaction.
5349
CompactionState = 0x07,
5450
}
5551

56-
/// Incremental compaction watermark persisted in the catalog.
57-
///
58-
/// Present while a `compact_step` session is in progress. The embedder must
59-
/// call `compact_step` again to resume after a crash; `Db::open` does not
60-
/// auto-resume.
61-
///
62-
/// Encoding: `target_root[8] || frontier_page_id[8] || started_at_commit_id[8] ||
63-
/// total_pages_estimate[8] || frontier_key_len[4] || frontier_key[frontier_key_len]`
64-
/// (minimum 32 bytes; variable total).
65-
#[derive(Debug, Clone, PartialEq, Eq)]
66-
pub struct CompactionStateRow {
67-
/// Root page id of the main B+ tree at the time compaction started.
68-
/// Used as the source snapshot to read from during each step.
69-
pub target_root: u64,
70-
/// The `next_page_id` of the partially-built compacted tree after the last
71-
/// committed batch. Used to estimate progress and as the initial `next_page_id`
72-
/// when no free-list pages are available.
73-
pub frontier_page_id: u64,
74-
/// The `commit_id` at which the compaction session began.
75-
pub started_at_commit_id: u64,
76-
/// Estimated total pages in the source tree (for progress reporting).
77-
pub total_pages_estimate: u64,
78-
/// The key (exclusive lower bound) from which the next step should begin
79-
/// reading pairs. Empty means start from the beginning.
80-
pub frontier_key: Vec<u8>,
81-
}
82-
83-
pub const COMPACTION_STATE_FIXED_LEN: usize = 36; // 4 × u64 + 1 × u32 length prefix
84-
8552
/// Rekey watermark persisted in the catalog during an online rekey operation.
8653
/// A present row means a rekey is in flight or was interrupted by a crash.
8754
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -246,61 +213,6 @@ impl Catalog {
246213
[CatalogRowKind::CompactionState as u8]
247214
}
248215

249-
/// Compaction-state row key: `[0x07]` (singleton).
250-
#[must_use]
251-
pub fn compaction_state_key() -> [u8; 1] {
252-
[CatalogRowKind::CompactionState as u8]
253-
}
254-
255-
/// Encode a `CompactionStateRow`. Returns a variable-length `Vec<u8>`.
256-
pub fn encode_compaction_state(r: &CompactionStateRow) -> Result<Vec<u8>> {
257-
let key_len = r.frontier_key.len();
258-
let klen32 = u32::try_from(key_len).map_err(|_| PagedbError::ManifestTooLarge)?;
259-
let mut o = Vec::with_capacity(COMPACTION_STATE_FIXED_LEN + key_len);
260-
o.extend_from_slice(&r.target_root.to_le_bytes());
261-
o.extend_from_slice(&r.frontier_page_id.to_le_bytes());
262-
o.extend_from_slice(&r.started_at_commit_id.to_le_bytes());
263-
o.extend_from_slice(&r.total_pages_estimate.to_le_bytes());
264-
o.extend_from_slice(&klen32.to_le_bytes());
265-
o.extend_from_slice(&r.frontier_key);
266-
Ok(o)
267-
}
268-
269-
/// Decode a `CompactionStateRow` from bytes.
270-
pub fn decode_compaction_state(bytes: &[u8]) -> Result<CompactionStateRow> {
271-
if bytes.len() < COMPACTION_STATE_FIXED_LEN {
272-
return Err(PagedbError::corruption(
273-
crate::errors::CorruptionDetail::HeaderUnverifiable,
274-
));
275-
}
276-
let read_u64 = |off: usize| {
277-
let mut b = [0u8; 8];
278-
b.copy_from_slice(&bytes[off..off + 8]);
279-
u64::from_le_bytes(b)
280-
};
281-
let target_root = read_u64(0);
282-
let frontier_page_id = read_u64(8);
283-
let started_at_commit_id = read_u64(16);
284-
let total_pages_estimate = read_u64(24);
285-
let mut klen_buf = [0u8; 4];
286-
klen_buf.copy_from_slice(&bytes[32..36]);
287-
let key_len = u32::from_le_bytes(klen_buf) as usize;
288-
if bytes.len() < COMPACTION_STATE_FIXED_LEN + key_len {
289-
return Err(PagedbError::corruption(
290-
crate::errors::CorruptionDetail::HeaderUnverifiable,
291-
));
292-
}
293-
let frontier_key =
294-
bytes[COMPACTION_STATE_FIXED_LEN..COMPACTION_STATE_FIXED_LEN + key_len].to_vec();
295-
Ok(CompactionStateRow {
296-
target_root,
297-
frontier_page_id,
298-
started_at_commit_id,
299-
total_pages_estimate,
300-
frontier_key,
301-
})
302-
}
303-
304216
/// Encode a `ReaderPinValue` as 41 bytes.
305217
#[must_use]
306218
pub fn encode_reader_pin(v: &ReaderPinValue) -> [u8; READER_PIN_VALUE_LEN] {

src/catalog/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44
55
pub mod codec;
66

7-
pub use codec::{Catalog, CatalogRowKind, CompactionStateRow, ReaderPinValue, RekeyStateRow};
7+
pub use codec::{Catalog, CatalogRowKind, ReaderPinValue, RekeyStateRow};

src/compaction/full.rs

Lines changed: 19 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,20 @@
11
//! Full one-shot compaction (entry point: [`compact_now`]):
22
//!
3-
//! 1. Collects all live data from main and catalog trees into memory.
4-
//! 2. Drains eligible deferred-free pages (now eligible since no pinned reader
5-
//! can observe them).
6-
//! 3. Writes fresh compacted trees starting at page 4, producing a dense layout.
7-
//! 4. Commits a new header with the updated roots and reduced `next_page_id`.
8-
//! 5. Truncates main.db if no reader pins the old high-water-mark range.
9-
//! 6. Repacks segment files whose garbage ratio exceeds 5%.
3+
//! 1. Refuses while any reader pins the page range (relocation/truncation is
4+
//! unsafe under a pinned reader).
5+
//! 2. Crash-atomically repacks the main + catalog trees into a dense low-address
6+
//! layout and truncates the reclaimed tail (see [`super::repack`]).
7+
//! 3. Repacks segment files whose garbage ratio exceeds 5%.
108
11-
use crate::btree::BTree;
9+
use crate::Result;
1210
use crate::errors::PagedbError;
13-
use crate::pager::header::commit_header;
14-
use crate::pager::structural_header::MainDbHeaderFields;
1511
use crate::segment::reader::SegmentReader;
1612
use crate::segment::types::SegmentPageKind;
1713
use crate::segment::writer::SegmentWriter;
1814
use crate::txn::db::Db;
1915
use crate::vfs::{Vfs, VfsFile};
20-
use crate::{CommitId, Result};
2116

22-
use super::helpers::{
23-
collect_all_pairs, collect_catalog_split, find_segment_name_inner, list_all_segments_inner,
24-
page_size_log2, replace_segment_compact,
25-
};
17+
use super::helpers::{find_segment_name_inner, list_all_segments_inner, replace_segment_compact};
2618
use super::types::CompactStats;
2719

2820
/// Full online compaction. See module-level docs for the staged flow.
@@ -31,7 +23,7 @@ use super::types::CompactStats;
3123
/// `EnteredSpan` guard: an entered span guard is `!Send` and would be held
3224
/// across the many `.await` points below, making the returned future `!Send`
3325
/// and thus uncallable from `Send` async contexts (e.g. the nodedb-lite
34-
/// `#[async_trait]` StorageEngine impl, which requires `Send` futures).
26+
/// `#[async_trait]` `StorageEngine` impl, which requires `Send` futures).
3527
pub async fn compact_now<V: Vfs + Clone>(db: &Db<V>) -> Result<CompactStats> {
3628
use tracing::Instrument;
3729
compact_now_inner(db)
@@ -55,8 +47,6 @@ async fn compact_now_inner<V: Vfs + Clone>(db: &Db<V>) -> Result<CompactStats> {
5547
// commit can't recycle a page the repack now uses for live data.
5648
db.free_page_cache.lock().clear();
5749

58-
let old_next_page_id = state.next_page_id;
59-
6050
// ── 1. Refuse while readers are pinned ───────────────────────────────────
6151
// A dense repack relocates the current tree and truncates the file; pinned
6252
// readers (in-process or cross-process durable) still reference the old
@@ -81,124 +71,19 @@ async fn compact_now_inner<V: Vfs + Clone>(db: &Db<V>) -> Result<CompactStats> {
8171
return Ok(result);
8272
}
8373

84-
// ── 2. Full repack (no readers pinned) ───────────────────────────────────
85-
86-
// Collect all live data in memory BEFORE any writes.
87-
let main_pairs = if state.root_page_id != 0 {
88-
let old_tree = BTree::open(
89-
db.pager.clone(),
90-
db.realm_id,
91-
state.root_page_id,
92-
old_next_page_id,
93-
db.page_size,
94-
);
95-
collect_all_pairs(&old_tree).await?
96-
} else {
97-
Vec::new()
98-
};
99-
100-
// Collect catalog rows (housekeeping free-list rows dropped).
101-
let cat_rows = collect_catalog_split(&db.pager, db.realm_id, &state).await?;
102-
103-
// ── 3. Write fresh compacted trees starting at page 4 ────────────────────
104-
// Pages 0–3 are reserved (header slots A/B + two spares); never allocated.
105-
let mut new_main = BTree::open(
106-
db.pager.clone(),
107-
db.realm_id,
108-
0,
109-
4, // first data page (pages 0-3 are reserved header slots)
110-
db.page_size,
111-
);
112-
new_main.bulk_load(main_pairs).await?;
113-
new_main.flush().await?;
114-
let new_root = new_main.root_page_id();
115-
let after_main = new_main.next_page_id();
116-
117-
let mut new_cat = BTree::open(db.pager.clone(), db.realm_id, 0, after_main, db.page_size);
118-
new_cat.bulk_load(cat_rows).await?;
119-
new_cat.flush().await?;
120-
let new_cat_root = new_cat.root_page_id();
121-
let new_next = new_cat.next_page_id();
122-
123-
// Pages reclaimed = reduction in next_page_id (the dense layout is contiguous,
124-
// and the durable free-list is reset to empty below).
125-
result.main_db_pages_reclaimed = old_next_page_id.saturating_sub(new_next);
126-
127-
// ── 4. Commit new header ─────────────────────────────────────────────────
128-
let new_commit_id = state.latest_commit_id + 1;
129-
let new_seq = state.seq + 1;
130-
let counter_anchor = db.pager.pending_anchor();
131-
132-
let mut catalog_root_bytes = [0u8; 16];
133-
catalog_root_bytes[..8].copy_from_slice(&new_cat_root.to_le_bytes());
134-
catalog_root_bytes[8..].copy_from_slice(&new_commit_id.to_le_bytes());
135-
136-
let fields = MainDbHeaderFields {
137-
format_version: 1,
138-
cipher_id: db.cipher_id.as_byte(),
139-
page_size_log2: page_size_log2(db.page_size)?,
140-
flags: 0,
141-
file_id: db.file_id,
142-
kek_salt: db.kek_salt,
143-
mk_epoch: db.mk_epoch.load(std::sync::atomic::Ordering::SeqCst),
144-
seq: new_seq,
145-
active_root_page_id: new_root,
146-
active_root_txn_id: new_commit_id,
147-
counter_anchor,
148-
commit_id: CommitId(new_commit_id),
149-
free_list_root: [0u8; 16],
150-
catalog_root: catalog_root_bytes,
151-
apply_journal_root_page_id: 0,
152-
apply_journal_root_version: 0,
153-
commit_history_root_page_id: 0,
154-
commit_history_root_version: 0,
155-
restore_mode: 0,
156-
next_page_id: new_next,
157-
commit_retain_policy_tag: 0,
158-
commit_retain_policy_value: 0,
159-
};
160-
161-
let hk_clone = { db.hk.read().clone() };
162-
let new_slot = commit_header(
163-
&*db.vfs,
164-
&db.main_db_path,
165-
&hk_clone,
166-
&fields,
167-
state.active_slot,
168-
db.page_size,
169-
)
170-
.await?;
171-
db.pager.commit_anchor(counter_anchor)?;
172-
173-
state.root_page_id = new_root;
174-
state.catalog_root_page_id = new_cat_root;
175-
state.next_page_id = new_next;
176-
state.active_slot = new_slot;
177-
state.seq = new_seq;
178-
state.latest_commit_id = new_commit_id;
179-
state.commit_history_root_page_id = 0;
180-
state.commit_history_root_version = 0;
181-
// The dense repack relocates/truncates every page, so the old free-list is
182-
// gone; the new layout starts with an empty free-list.
183-
state.free_list_root_page_id = 0;
184-
db.latest_commit
185-
.store(new_commit_id, std::sync::atomic::Ordering::SeqCst);
186-
187-
// ── 5. Truncate if no readers pin the old high-water range ───────────────
188-
// (No readers are pinned at this point — checked above — so truncation is safe.)
189-
if new_next < old_next_page_id {
190-
let new_size = new_next.saturating_mul(db.page_size as u64);
191-
let old_size = old_next_page_id.saturating_mul(db.page_size as u64);
192-
let mut f = db
193-
.vfs
194-
.open(&db.main_db_path, crate::vfs::types::OpenMode::ReadWrite)
195-
.await?;
196-
f.set_len(new_size).await?;
197-
f.sync().await?;
198-
result.bytes_truncated = old_size.saturating_sub(new_size);
74+
// ── 2. Crash-atomic dense repack of the main + catalog trees ─────────────
75+
// An empty free-list means every page below the high-water mark is live —
76+
// the store is already dense, so there is nothing to reclaim and we skip the
77+
// repack entirely (no wasted rewrite). Otherwise repack via a scratch file +
78+
// atomic rename; main.db is never modified until the rename (see
79+
// `super::repack`).
80+
if state.free_list_root_page_id != 0 {
81+
let repack = super::repack::atomic_dense_repack(db, &mut state).await?;
82+
result.main_db_pages_reclaimed = repack.pages_reclaimed;
83+
result.bytes_truncated = repack.bytes_truncated;
19984
}
20085

201-
// ── 6. Repack segments ────────────────────────────────────────────────────
86+
// ── 3. Repack segments ────────────────────────────────────────────────────
20287
let all_segments = list_all_segments_inner(&db.pager, db.realm_id, &state).await?;
20388
for meta in all_segments {
20489
let live = crate::segment::writer::live_path(&meta.segment_id);

src/compaction/helpers.rs

Lines changed: 1 addition & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
use std::sync::Arc;
55

66
use crate::btree::BTree;
7-
use crate::catalog::codec::{Catalog, CatalogRowKind, CompactionStateRow, SegmentMeta};
7+
use crate::catalog::codec::{Catalog, CatalogRowKind, SegmentMeta};
88
use crate::errors::PagedbError;
99
use crate::pager::header::commit_header;
1010
use crate::pager::structural_header::MainDbHeaderFields;
@@ -195,72 +195,6 @@ pub(super) async fn replace_segment_compact<V: Vfs + Clone>(
195195
Ok(())
196196
}
197197

198-
/// Return the "next key strictly greater than `key`" for range scanning.
199-
/// Appends a 0x00 byte, which gives the lexicographically smallest key
200-
/// greater than `key`. If `key` is empty, returns empty (scan from start).
201-
pub(super) fn next_key_after(key: &[u8]) -> Vec<u8> {
202-
if key.is_empty() {
203-
Vec::new()
204-
} else {
205-
let mut v = key.to_vec();
206-
v.push(0x00);
207-
v
208-
}
209-
}
210-
211-
/// Collect at most `limit` key-value pairs from `[start..end)` in `tree`.
212-
pub(super) async fn collect_range_limited<V: Vfs + Clone>(
213-
tree: &BTree<V>,
214-
start: &[u8],
215-
end: &[u8],
216-
limit: u64,
217-
) -> Result<Vec<(Vec<u8>, Vec<u8>)>> {
218-
let mut pairs = tree.collect_range(start, end).await?;
219-
#[allow(clippy::cast_possible_truncation)]
220-
if pairs.len() as u64 > limit {
221-
pairs.truncate(limit as usize);
222-
}
223-
Ok(pairs)
224-
}
225-
226-
/// Load the compaction watermark from the catalog, if present.
227-
pub(super) async fn load_compaction_state<V: Vfs + Clone>(
228-
pager: &Arc<crate::pager::Pager<V>>,
229-
realm_id: crate::RealmId,
230-
state: &WriterState,
231-
) -> Result<Option<CompactionStateRow>> {
232-
if state.catalog_root_page_id == 0 {
233-
return Ok(None);
234-
}
235-
let tree = BTree::open(
236-
pager.clone(),
237-
realm_id,
238-
state.catalog_root_page_id,
239-
state.next_page_id,
240-
pager.page_size(),
241-
);
242-
let key = Catalog::compaction_state_key();
243-
match tree.get(&key).await? {
244-
Some(bytes) => {
245-
let cs = Catalog::decode_compaction_state(&bytes)?;
246-
Ok(Some(cs))
247-
}
248-
None => Ok(None),
249-
}
250-
}
251-
252-
/// Load just the `frontier_page_id` from the watermark (for progress reporting).
253-
pub(super) async fn load_frontier_page_id<V: Vfs + Clone>(
254-
pager: &Arc<crate::pager::Pager<V>>,
255-
realm_id: crate::RealmId,
256-
state: &WriterState,
257-
) -> Option<u64> {
258-
match load_compaction_state(pager, realm_id, state).await {
259-
Ok(Some(cs)) => Some(cs.frontier_page_id),
260-
_ => None,
261-
}
262-
}
263-
264198
/// Build [`MainDbHeaderFields`] for a compaction commit.
265199
///
266200
/// Compaction relocates/truncates pages, so it discards the commit-history

src/compaction/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
1010
mod full;
1111
mod helpers;
12+
mod repack;
1213
mod step;
1314
mod types;
1415

0 commit comments

Comments
 (0)