use pagedb::vfs::memory::MemVfs;
use pagedb::{Db, OpenOptions, RealmId, SegmentKind, SegmentPageKind};
const PAGE: usize = 4096;
const REALM: RealmId = RealmId::new([0xAB; 16]);
const KEK: [u8; 32] = [0x11; 32];
async fn fresh_db() -> Db<MemVfs> {
Db::open(MemVfs::new(), KEK, PAGE, REALM, OpenOptions::default())
.await
.unwrap()
}
async fn main_db_pages(db: &Db<MemVfs>) -> u64 {
db.main_db_byte_size().await.unwrap() / PAGE as u64
}
#[tokio::test(flavor = "current_thread")]
async fn free_list_reuse_across_txns() {
let db = fresh_db().await;
{
let mut w = db.begin_write().await.unwrap();
for i in 0u32..50 {
let key = format!("key-{i:04}");
w.put(key.as_bytes(), &[i as u8; 64]).await.unwrap();
}
w.commit().await.unwrap();
}
let pages_after_write = main_db_pages(&db).await;
{
let mut w = db.begin_write().await.unwrap();
for i in 0u32..50 {
let key = format!("key-{i:04}");
w.delete(key.as_bytes()).await.unwrap();
}
w.commit().await.unwrap();
}
{
let mut w = db.begin_write().await.unwrap();
for i in 50u32..100 {
let key = format!("key-{i:04}");
w.put(key.as_bytes(), &[i as u8; 64]).await.unwrap();
}
w.commit().await.unwrap();
}
let pages_after_rewrite = main_db_pages(&db).await;
assert!(
pages_after_rewrite <= pages_after_write * 2,
"expected reuse: pages_after_write={pages_after_write}, pages_after_rewrite={pages_after_rewrite}"
);
}
#[tokio::test(flavor = "current_thread")]
async fn compact_truncates_main_db() {
let db = fresh_db().await;
{
let mut w = db.begin_write().await.unwrap();
for i in 0u32..200 {
let key = format!("key-{i:06}");
w.put(key.as_bytes(), &[0u8; 128]).await.unwrap();
}
w.commit().await.unwrap();
}
let pages_before = main_db_pages(&db).await;
{
let mut w = db.begin_write().await.unwrap();
for i in 0u32..190 {
let key = format!("key-{i:06}");
w.delete(key.as_bytes()).await.unwrap();
}
w.commit().await.unwrap();
}
let stats = db.compact_now().await.unwrap();
let pages_after = main_db_pages(&db).await;
assert!(
pages_after < pages_before,
"expected file to shrink: before={pages_before}, after={pages_after}"
);
assert!(
stats.bytes_truncated > 0,
"expected bytes_truncated > 0, got {stats:?}"
);
let r = db.begin_read().await.unwrap();
for i in 190u32..200 {
let key = format!("key-{i:06}");
let v = r.get(key.as_bytes()).await.unwrap();
assert!(v.is_some(), "key {key} should still exist after compaction");
}
}
#[tokio::test(flavor = "current_thread")]
async fn compact_now_preserves_top_of_keyspace_keys() {
let vfs = MemVfs::new();
let db = Db::open(vfs.clone(), KEK, PAGE, REALM, OpenOptions::default())
.await
.unwrap();
let high_key = [0xFF; 256];
let mut higher_key = vec![0xFFu8; 256];
higher_key.push(0x00);
let high_value = b"high-key-value";
let ordinary_value = [0x2A; 128];
{
let mut txn = db.begin_write().await.unwrap();
txn.put(&high_key, high_value).await.unwrap();
txn.put(&higher_key, high_value).await.unwrap();
for i in 0u32..240 {
txn.put(format!("ordinary-{i:06}").as_bytes(), &ordinary_value)
.await
.unwrap();
}
txn.commit().await.unwrap();
}
{
let mut txn = db.begin_write().await.unwrap();
for i in 0u32..230 {
txn.delete(format!("ordinary-{i:06}").as_bytes())
.await
.unwrap();
}
txn.commit().await.unwrap();
}
let stats = db.compact_now().await.unwrap();
assert!(
stats.main_db_pages_reclaimed > 0,
"setup did not enter dense repack: {stats:?}"
);
async fn assert_all_present(
db: &Db<MemVfs>,
high_key: &[u8],
higher_key: &[u8],
high_value: &[u8],
ordinary_value: &[u8],
when: &str,
) {
let read = db.begin_read().await.unwrap();
assert_eq!(
read.get(high_key).await.unwrap().as_deref(),
Some(high_value),
"[0xFF; 256] key lost {when}"
);
assert_eq!(
read.get(higher_key).await.unwrap().as_deref(),
Some(high_value),
"key extending [0xFF; 256] lost {when}"
);
assert_eq!(
read.get(b"ordinary-000239").await.unwrap().as_deref(),
Some(ordinary_value),
"ordinary survivor lost {when}"
);
}
assert_all_present(
&db,
&high_key,
&higher_key,
high_value,
&ordinary_value,
"after repack",
)
.await;
drop(db);
let reopened = Db::open(vfs, KEK, PAGE, REALM, OpenOptions::default())
.await
.unwrap();
assert_all_present(
&reopened,
&high_key,
&higher_key,
high_value,
&ordinary_value,
"after reopen",
)
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn compact_now_preserves_large_overflow_values() {
let vfs = MemVfs::new();
let db = Db::open(vfs.clone(), KEK, PAGE, REALM, OpenOptions::default())
.await
.unwrap();
let big = vec![0xCDu8; 4096]; let small = vec![0x07u8; 48];
let n = 120u32;
{
let mut w = db.begin_write().await.unwrap();
for i in 0..n {
let key = format!("k-{i:05}");
let val = if i % 2 == 0 {
big.as_slice()
} else {
small.as_slice()
};
w.put(key.as_bytes(), val).await.unwrap();
}
w.commit().await.unwrap();
}
db.compact_now().await.unwrap();
{
let r = db.begin_read().await.unwrap();
for i in 0..n {
let key = format!("k-{i:05}");
let want = if i % 2 == 0 { &big } else { &small };
assert_eq!(
r.get(key.as_bytes()).await.unwrap().as_deref(),
Some(want.as_slice()),
"value mismatch at {key} after compaction"
);
}
}
drop(db);
let db2 = Db::open(vfs, KEK, PAGE, REALM, OpenOptions::default())
.await
.unwrap();
let r = db2.begin_read().await.unwrap();
let k0 = format!("k-{:05}", 0);
assert_eq!(
r.get(k0.as_bytes()).await.unwrap().as_deref(),
Some(big.as_slice()),
"large value lost after reopen"
);
}
#[tokio::test(flavor = "current_thread")]
async fn compaction_then_commit_keeps_large_values_readable_on_reopen() {
let vfs = MemVfs::new();
let db = Db::open(vfs.clone(), KEK, PAGE, REALM, OpenOptions::default())
.await
.unwrap();
let big = vec![0x5Au8; 4096];
let small = vec![0x11u8; 48];
let n_small = 300u32;
let n_big = 5u32;
{
let mut w = db.begin_write().await.unwrap();
for i in 0..n_small {
w.put(format!("a-{i:05}").as_bytes(), &small).await.unwrap();
}
for i in 0..n_big {
w.put(format!("z-{i:05}").as_bytes(), &big).await.unwrap();
}
w.commit().await.unwrap();
}
let _ = db.compact_now().await;
{
let mut w = db.begin_write().await.unwrap();
w.put(b"sentinel", b"ok").await.unwrap();
w.commit().await.unwrap();
}
drop(db);
let db2 = Db::open(vfs, KEK, PAGE, REALM, OpenOptions::default())
.await
.unwrap();
let r = db2.begin_read().await.unwrap();
for i in 0..n_small {
let key = format!("a-{i:05}");
assert_eq!(
r.get(key.as_bytes()).await.unwrap().as_deref(),
Some(small.as_slice()),
"small value {key} lost/corrupted after compaction + commit + reopen"
);
}
for i in 0..n_big {
let key = format!("z-{i:05}");
assert_eq!(
r.get(key.as_bytes()).await.unwrap().as_deref(),
Some(big.as_slice()),
"large value {key} lost/corrupted after compaction + commit + reopen"
);
}
assert_eq!(
r.get(b"sentinel").await.unwrap().as_deref(),
Some(b"ok".as_slice())
);
}
#[tokio::test(flavor = "current_thread")]
async fn compact_now_round_trips_every_record_in_order() {
let vfs = MemVfs::new();
let db = Db::open(vfs.clone(), KEK, PAGE, REALM, OpenOptions::default())
.await
.unwrap();
let inline = vec![0x6Du8; 200];
let spilled = vec![0x9Fu8; 3000]; let n = 600u32;
{
let mut w = db.begin_write().await.unwrap();
for i in 0..n {
let value = if i % 4 == 0 { &spilled } else { &inline };
w.put(format!("rt-{i:05}").as_bytes(), value).await.unwrap();
}
w.commit().await.unwrap();
}
{
let mut w = db.begin_write().await.unwrap();
for i in 0..n {
if i % 3 == 0 {
w.delete(format!("rt-{i:05}").as_bytes()).await.unwrap();
}
}
w.commit().await.unwrap();
}
let before = {
let r = db.begin_read().await.unwrap();
r.scan_prefix(b"rt-").await.unwrap()
};
assert!(!before.is_empty());
let stats = db.compact_now().await.unwrap();
assert!(
stats.main_db_pages_reclaimed > 0,
"setup did not enter the dense repack: {stats:?}"
);
let after = {
let r = db.begin_read().await.unwrap();
r.scan_prefix(b"rt-").await.unwrap()
};
assert_eq!(
after, before,
"repack must not add, drop, or reorder records"
);
drop(db);
let reopened = Db::open(vfs, KEK, PAGE, REALM, OpenOptions::default())
.await
.unwrap();
let after_reopen = {
let r = reopened.begin_read().await.unwrap();
r.scan_prefix(b"rt-").await.unwrap()
};
assert_eq!(
after_reopen, before,
"the published compacted store must round-trip across a reopen"
);
}
#[tokio::test(flavor = "current_thread")]
async fn compact_repacks_segments() {
let db = fresh_db().await;
let meta = {
let mut seg = db
.create_segment(REALM, SegmentKind::Unspecified)
.await
.unwrap();
for _i in 0..5 {
seg.append_page(SegmentPageKind::Data, &[0xAA; 512])
.await
.unwrap();
}
seg.seal().await.unwrap()
};
let logical_bytes = meta.total_bytes;
let page_count = meta.page_count;
{
let mut w = db.begin_write().await.unwrap();
w.link_segment("engine.idx", &meta).await.unwrap();
w.commit().await.unwrap();
}
let stats_no_garbage = db.compact_now().await.unwrap();
assert_eq!(
stats_no_garbage.segments_repacked, 0,
"segment with no garbage should not be repacked"
);
let _ = (logical_bytes, page_count); }
#[tokio::test(flavor = "current_thread")]
async fn compact_respects_reader_pins() {
let db = fresh_db().await;
{
let mut w = db.begin_write().await.unwrap();
for i in 0u32..50 {
let key = format!("pin-key-{i:04}");
w.put(key.as_bytes(), &[i as u8; 32]).await.unwrap();
}
w.commit().await.unwrap();
}
let reader = db.begin_read().await.unwrap();
{
let mut w = db.begin_write().await.unwrap();
for i in 0u32..50 {
let key = format!("pin-key-{i:04}");
w.delete(key.as_bytes()).await.unwrap();
}
w.commit().await.unwrap();
}
let pages_before = main_db_pages(&db).await;
let stats = db.compact_now().await.unwrap();
let pages_after = main_db_pages(&db).await;
assert!(
pages_after >= pages_before || stats.bytes_truncated == 0,
"file should not be truncated while a reader is pinned: before={pages_before}, after={pages_after}"
);
for i in 0u32..50 {
let key = format!("pin-key-{i:04}");
let v = reader.get(key.as_bytes()).await.unwrap();
assert!(v.is_some(), "pinned reader lost key {key} after compaction");
}
drop(reader);
}
#[tokio::test(flavor = "current_thread")]
async fn compact_idempotent() {
let db = fresh_db().await;
{
let mut w = db.begin_write().await.unwrap();
for i in 0u32..30 {
let key = format!("idem-{i:04}");
w.put(key.as_bytes(), &[1u8; 48]).await.unwrap();
}
w.commit().await.unwrap();
}
let stats1 = db.compact_now().await.unwrap();
let stats2 = db.compact_now().await.unwrap();
assert_eq!(
stats2.main_db_pages_reclaimed, 0,
"second compact should reclaim nothing: first={stats1:?} second={stats2:?}"
);
assert_eq!(
stats2.segments_repacked, 0,
"second compact should repack nothing"
);
assert_eq!(
stats2.bytes_truncated, 0,
"second compact should not truncate"
);
}
#[tokio::test(flavor = "current_thread")]
async fn free_list_persists_across_reopen() {
let vfs = MemVfs::new();
{
let db = Db::open(vfs.clone(), KEK, PAGE, REALM, OpenOptions::default())
.await
.unwrap();
let mut w = db.begin_write().await.unwrap();
for i in 0u32..30 {
let key = format!("persist-{i:04}");
w.put(key.as_bytes(), &[9u8; 64]).await.unwrap();
}
w.commit().await.unwrap();
let mut w2 = db.begin_write().await.unwrap();
for i in 0u32..30 {
let key = format!("persist-{i:04}");
w2.delete(key.as_bytes()).await.unwrap();
}
w2.commit().await.unwrap();
}
let db2 = Db::open(vfs.clone(), KEK, PAGE, REALM, OpenOptions::default())
.await
.unwrap();
let _stats = db2.compact_now().await.unwrap();
let pages_after_compact = main_db_pages(&db2).await;
{
let mut w = db2.begin_write().await.unwrap();
for i in 0u32..30 {
let key = format!("new-{i:04}");
w.put(key.as_bytes(), &[7u8; 64]).await.unwrap();
}
w.commit().await.unwrap();
}
let pages_after_rewrite = main_db_pages(&db2).await;
assert!(
pages_after_rewrite <= pages_after_compact + pages_after_compact / 2 + 4,
"next_page_id advanced too much after reopen; \
pages_after_compact={pages_after_compact}, \
pages_after_rewrite={pages_after_rewrite}"
);
}