Skip to content

Commit 45ff069

Browse files
authored
Merge pull request #4 from emanzx/feat/expose-compaction
feat(storage): expose compaction via StorageEngine::compact()
2 parents 560cb3d + 4d6e588 commit 45ff069

3 files changed

Lines changed: 121 additions & 1 deletion

File tree

nodedb-lite/src/nodedb/health.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
1111
use serde::Serialize;
1212

13+
use nodedb_types::error::NodeDbResult;
14+
1315
use crate::memory::{EngineId, PressureLevel};
1416
use crate::storage::engine::StorageEngine;
1517

@@ -124,6 +126,19 @@ impl<S: StorageEngine> NodeDbLite<S> {
124126
&self.storage
125127
}
126128

129+
/// Compact the backing storage engine, reclaiming dead pages and
130+
/// truncating the file to bound on-disk growth.
131+
///
132+
/// Forwards to [`StorageEngine::compact`]. For the pagedb-backed engine this
133+
/// drains the deferred-free list and truncates `main.db`; for in-memory or
134+
/// test engines it is a no-op returning a zero
135+
/// [`CompactionOutcome`](crate::storage::engine::CompactionOutcome).
136+
pub async fn compact(
137+
&self,
138+
) -> NodeDbResult<crate::storage::engine::CompactionOutcome> {
139+
Ok(self.storage.compact().await?)
140+
}
141+
127142
/// Get a structured health report.
128143
///
129144
/// This is a cheap, non-blocking call — reads atomic counters and lock-free state.

nodedb-lite/src/storage/engine.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,23 @@ use nodedb_types::Namespace;
1818
/// `StorageEngine` trait's scan interface.
1919
pub type KvPair = (Vec<u8>, Vec<u8>);
2020

21+
/// Summary of what a [`StorageEngine::compact`] call reclaimed.
22+
///
23+
/// Lite-owned (not a pagedb type) so the trait doesn't force pagedb types on
24+
/// non-pagedb impls. The pagedb-backed engine maps `pagedb::CompactStats` into
25+
/// this; other engines return the `Default` (all-zero) value from the trait's
26+
/// default no-op `compact`.
27+
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
28+
pub struct CompactionOutcome {
29+
/// Number of underlying pages reclaimed (moved to free-list or freed by
30+
/// repacking). Zero for engines with nothing to compact.
31+
pub reclaimed_pages: u64,
32+
/// Number of segment files repacked.
33+
pub segments_repacked: u32,
34+
/// Bytes truncated from the backing file by lowering the high-water mark.
35+
pub file_bytes_freed: u64,
36+
}
37+
2138
/// A write operation for batch writes.
2239
#[derive(Debug, Clone)]
2340
pub enum WriteOp {
@@ -72,6 +89,17 @@ pub trait StorageEngine: Send + Sync + 'static {
7289
/// Useful for cold-start progress reporting and memory governor decisions.
7390
async fn count(&self, ns: Namespace) -> Result<u64, LiteError>;
7491

92+
/// Compact the backing store, reclaiming dead pages and (when possible)
93+
/// truncating the file to bound on-disk growth.
94+
///
95+
/// The default implementation is a no-op returning a zero
96+
/// [`CompactionOutcome`], so engines with nothing to compact (in-memory
97+
/// stores, test doubles) need not override it. The pagedb-backed engine
98+
/// overrides this to drain the deferred-free list and truncate `main.db`.
99+
async fn compact(&self) -> Result<CompactionOutcome, LiteError> {
100+
Ok(CompactionOutcome::default())
101+
}
102+
75103
/// Range scan: return up to `limit` entries where key >= `start`.
76104
///
77105
/// Results are ordered by key (lexicographic byte order).

nodedb-lite/src/storage/pagedb_storage.rs

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ use pagedb::vfs::traits::Vfs;
2626
use pagedb::{Db, RealmId};
2727

2828
use crate::error::LiteError;
29-
use crate::storage::engine::{KvPair, StorageEngine, WriteOp};
29+
use crate::storage::engine::{CompactionOutcome, KvPair, StorageEngine, WriteOp};
3030
use nodedb_types::Namespace;
3131

3232
// ─── VFS aliases ─────────────────────────────────────────────────────────────
@@ -453,6 +453,15 @@ where
453453
.collect())
454454
}
455455

456+
async fn compact(&self) -> Result<CompactionOutcome, LiteError> {
457+
let stats = self.db.compact_now().await.map_err(LiteError::from)?;
458+
Ok(CompactionOutcome {
459+
reclaimed_pages: stats.main_db_pages_reclaimed,
460+
segments_repacked: stats.segments_repacked,
461+
file_bytes_freed: stats.bytes_truncated,
462+
})
463+
}
464+
456465
fn as_vector_segment_ext(
457466
&self,
458467
) -> Option<&dyn crate::storage::vector_segment_ext::VectorSegmentExt> {
@@ -744,6 +753,15 @@ impl<V: Vfs + Clone + 'static> StorageEngine for PagedbStorage<V> {
744753
.map(|(k, v)| (strip_prefix(&k).to_vec(), v))
745754
.collect())
746755
}
756+
757+
async fn compact(&self) -> Result<CompactionOutcome, LiteError> {
758+
let stats = self.db.compact_now().await.map_err(LiteError::from)?;
759+
Ok(CompactionOutcome {
760+
reclaimed_pages: stats.main_db_pages_reclaimed,
761+
segments_repacked: stats.segments_repacked,
762+
file_bytes_freed: stats.bytes_truncated,
763+
})
764+
}
747765
}
748766

749767
// ─── VectorSegmentExt impl ────────────────────────────────────────────────────
@@ -1227,6 +1245,65 @@ mod tests {
12271245
assert_eq!(results[2].0, &[4u8]);
12281246
}
12291247

1248+
/// In-memory engine: `compact()` is a successful no-op (nothing to reclaim).
1249+
#[tokio::test]
1250+
async fn compact_mem_is_ok_noop() {
1251+
let s = make_storage().await;
1252+
s.put(Namespace::Vector, b"v1", b"hello").await.unwrap();
1253+
s.put(Namespace::Graph, b"g1", b"world").await.unwrap();
1254+
let outcome = s.compact().await.unwrap();
1255+
// Data still readable after compaction.
1256+
assert_eq!(
1257+
s.get(Namespace::Vector, b"v1").await.unwrap().as_deref(),
1258+
Some(b"hello".as_slice())
1259+
);
1260+
// MemVfs has no file truncation, but the call must succeed regardless.
1261+
let _ = outcome.reclaimed_pages;
1262+
}
1263+
1264+
/// Disk-backed engine on a tempdir: write rows (including churn that leaves
1265+
/// dead pages), then `compact()` must succeed and report a non-negative
1266+
/// outcome. Data must remain intact afterward.
1267+
#[cfg(not(target_arch = "wasm32"))]
1268+
#[tokio::test]
1269+
async fn compact_default_disk_is_ok() {
1270+
let dir = tempfile::tempdir().unwrap();
1271+
let path = dir.path().join("compact-test.db");
1272+
let s = PagedbStorage::<DefaultVfs>::open(
1273+
&path,
1274+
crate::storage::encryption::Encryption::Plaintext,
1275+
)
1276+
.await
1277+
.unwrap();
1278+
1279+
// Churn: write then overwrite/delete a batch of keys so the
1280+
// deferred-free list has pages to reclaim.
1281+
for i in 0u32..200 {
1282+
let key = i.to_be_bytes();
1283+
s.put(Namespace::Meta, &key, &vec![0xCDu8; 512])
1284+
.await
1285+
.unwrap();
1286+
}
1287+
for i in 0u32..150 {
1288+
let key = i.to_be_bytes();
1289+
s.delete(Namespace::Meta, &key).await.unwrap();
1290+
}
1291+
1292+
let outcome = s.compact().await.unwrap();
1293+
1294+
// Surviving keys still readable.
1295+
let survivor = 175u32.to_be_bytes();
1296+
assert!(s.get(Namespace::Meta, &survivor).await.unwrap().is_some());
1297+
1298+
// Outcome fields are well-formed (u64/u32 — always >= 0); just touch
1299+
// them so the assertion documents the reported shape.
1300+
let _ = (
1301+
outcome.reclaimed_pages,
1302+
outcome.segments_repacked,
1303+
outcome.file_bytes_freed,
1304+
);
1305+
}
1306+
12301307
/// Keys in namespace N must not appear in a scan of namespace N+1, and
12311308
/// vice versa. Verifies the single-byte prefix boundary.
12321309
#[tokio::test]

0 commit comments

Comments
 (0)