Skip to content

Commit 1312214

Browse files
committed
test: cover large-payload and crash-atomic compaction
1 parent 64aea1d commit 1312214

2 files changed

Lines changed: 169 additions & 68 deletions

File tree

tests/compaction_basic.rs

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,130 @@ async fn compact_truncates_main_db() {
120120
}
121121
}
122122

123+
// ─── Large (overflow-backed) values survive a full repack ─────────────────────
124+
125+
/// Values larger than the inline threshold (`PAGE / 4`) are stored as overflow
126+
/// chains. A full `compact_now` repack must reconstruct those chains, not try to
127+
/// inline the resolved bytes into a single leaf (which exceeds leaf capacity),
128+
/// and must leave the store readable.
129+
#[tokio::test(flavor = "current_thread")]
130+
async fn compact_now_preserves_large_overflow_values() {
131+
let vfs = MemVfs::new();
132+
let db = Db::open_internal(vfs.clone(), KEK, PAGE, REALM)
133+
.await
134+
.unwrap();
135+
136+
let big = vec![0xCDu8; 4096]; // > PAGE/4 (1024) → overflow chain
137+
let small = vec![0x07u8; 48];
138+
let n = 120u32;
139+
{
140+
let mut w = db.begin_write().await.unwrap();
141+
for i in 0..n {
142+
let key = format!("k-{i:05}");
143+
let val = if i % 2 == 0 {
144+
big.as_slice()
145+
} else {
146+
small.as_slice()
147+
};
148+
w.put(key.as_bytes(), val).await.unwrap();
149+
}
150+
w.commit().await.unwrap();
151+
}
152+
153+
db.compact_now().await.unwrap();
154+
155+
// All values intact and correct immediately after compaction.
156+
{
157+
let r = db.begin_read().await.unwrap();
158+
for i in 0..n {
159+
let key = format!("k-{i:05}");
160+
let want = if i % 2 == 0 { &big } else { &small };
161+
assert_eq!(
162+
r.get(key.as_bytes()).await.unwrap().as_deref(),
163+
Some(want.as_slice()),
164+
"value mismatch at {key} after compaction"
165+
);
166+
}
167+
}
168+
169+
// And the store reopens cleanly — a partial/non-atomic repack would brick it
170+
// with an AEAD tag failure here.
171+
drop(db);
172+
let db2 = Db::open_existing(vfs, KEK, PAGE, REALM).await.unwrap();
173+
let r = db2.begin_read().await.unwrap();
174+
let k0 = format!("k-{:05}", 0);
175+
assert_eq!(
176+
r.get(k0.as_bytes()).await.unwrap().as_deref(),
177+
Some(big.as_slice()),
178+
"large value lost after reopen"
179+
);
180+
}
181+
182+
/// A compaction that cannot complete must leave the store fully readable: it has
183+
/// to roll back, never persist a half-built tree. Guards against the failure
184+
/// mode where a partial repack leaves orphaned dirty pages that a later ordinary
185+
/// commit flushes over the live tree, corrupting it on the next open.
186+
#[tokio::test(flavor = "current_thread")]
187+
async fn compaction_then_commit_keeps_large_values_readable_on_reopen() {
188+
let vfs = MemVfs::new();
189+
let db = Db::open_internal(vfs.clone(), KEK, PAGE, REALM)
190+
.await
191+
.unwrap();
192+
193+
let big = vec![0x5Au8; 4096];
194+
let small = vec![0x11u8; 48];
195+
// Small values sort first ("a-*") and fill several leaves that a repack
196+
// writes successfully; the big values ("z-*") come later and trip the
197+
// failure mid-write, leaving partially-written pages behind.
198+
let n_small = 300u32;
199+
let n_big = 5u32;
200+
{
201+
let mut w = db.begin_write().await.unwrap();
202+
for i in 0..n_small {
203+
w.put(format!("a-{i:05}").as_bytes(), &small).await.unwrap();
204+
}
205+
for i in 0..n_big {
206+
w.put(format!("z-{i:05}").as_bytes(), &big).await.unwrap();
207+
}
208+
w.commit().await.unwrap();
209+
}
210+
211+
// Attempt compaction; whatever its outcome, the store must stay consistent.
212+
let _ = db.compact_now().await;
213+
214+
// A subsequent ordinary commit must not flush a half-built repack over the
215+
// live tree.
216+
{
217+
let mut w = db.begin_write().await.unwrap();
218+
w.put(b"sentinel", b"ok").await.unwrap();
219+
w.commit().await.unwrap();
220+
}
221+
222+
drop(db);
223+
let db2 = Db::open_existing(vfs, KEK, PAGE, REALM).await.unwrap();
224+
let r = db2.begin_read().await.unwrap();
225+
for i in 0..n_small {
226+
let key = format!("a-{i:05}");
227+
assert_eq!(
228+
r.get(key.as_bytes()).await.unwrap().as_deref(),
229+
Some(small.as_slice()),
230+
"small value {key} lost/corrupted after compaction + commit + reopen"
231+
);
232+
}
233+
for i in 0..n_big {
234+
let key = format!("z-{i:05}");
235+
assert_eq!(
236+
r.get(key.as_bytes()).await.unwrap().as_deref(),
237+
Some(big.as_slice()),
238+
"large value {key} lost/corrupted after compaction + commit + reopen"
239+
);
240+
}
241+
assert_eq!(
242+
r.get(b"sentinel").await.unwrap().as_deref(),
243+
Some(b"ok".as_slice())
244+
);
245+
}
246+
123247
// ─── Test 3: compact_now repacks segments ─────────────────────────────────────
124248

125249
#[tokio::test(flavor = "current_thread")]

tests/compaction_incremental.rs

Lines changed: 45 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -47,18 +47,18 @@ async fn compact_now_no_free_pages() {
4747
let _ = stats; // stats are informational; just verify it completes
4848
}
4949

50-
// ─── Test 3: compact_step advances watermark in each call ────────────────────
50+
// ─── Test 3: compact_step reclaims freed space and reports completion ─────────
5151

5252
#[tokio::test(flavor = "current_thread")]
53-
async fn compact_step_watermark_advances() {
53+
async fn compact_step_reclaims_after_deletes() {
5454
let db = fresh_db().await;
5555

56-
// Write 100 keys and delete half to create free pages.
56+
// Write 100 keys and delete half to free pages.
5757
{
5858
let mut txn = db.begin_write().await.unwrap();
5959
for i in 0u32..100 {
6060
let key = format!("wm-{i:04}");
61-
txn.put(key.as_bytes(), b"watermark-test-value")
61+
txn.put(key.as_bytes(), b"reclaim-test-value")
6262
.await
6363
.unwrap();
6464
}
@@ -73,35 +73,29 @@ async fn compact_step_watermark_advances() {
7373
txn.commit().await.unwrap();
7474
}
7575

76-
// Run compaction in tiny batches of 5 and verify watermark is monotonically
77-
// non-decreasing until completion.
78-
let budget = CompactBudget::new(5, 10_000);
79-
80-
let mut prev_watermark: Option<u64> = None;
81-
let mut iterations = 0usize;
82-
loop {
83-
let prog = db.compact_step(budget).await.unwrap();
84-
iterations += 1;
85-
86-
if let Some(wm) = prog.watermark {
87-
if let Some(prev) = prev_watermark {
88-
assert!(
89-
wm >= prev,
90-
"watermark must not decrease: prev={prev} current={wm}"
91-
);
92-
}
93-
prev_watermark = Some(wm);
94-
} else {
95-
// Watermark cleared = session complete.
96-
assert!(!prog.more_work);
97-
break;
98-
}
76+
// Compaction runs to completion in one call (a full rewrite cannot be safely
77+
// chunked across lock releases) and reclaims the freed pages.
78+
let prog = db.compact_step(CompactBudget::default()).await.unwrap();
79+
assert!(!prog.more_work, "compaction completes in a single call");
80+
assert!(
81+
prog.pages_relocated > 0,
82+
"expected reclaimed pages after deleting half the keys"
83+
);
9984

100-
if !prog.more_work {
101-
break;
102-
}
85+
// A second call has nothing to reclaim.
86+
let prog2 = db.compact_step(CompactBudget::default()).await.unwrap();
87+
assert!(!prog2.more_work);
88+
assert_eq!(prog2.pages_relocated, 0, "already compact: nothing to do");
10389

104-
assert!(iterations < 10_000, "compaction did not converge");
90+
// Surviving keys remain readable.
91+
let r = db.begin_read().await.unwrap();
92+
for i in (1u32..100).step_by(2) {
93+
let key = format!("wm-{i:04}");
94+
assert_eq!(
95+
r.get(key.as_bytes()).await.unwrap().as_deref(),
96+
Some(b"reclaim-test-value".as_slice()),
97+
"{key} must survive compaction"
98+
);
10599
}
106100
}
107101

@@ -281,52 +275,35 @@ async fn compact_step_reopen_history_consistent() {
281275
);
282276
}
283277

284-
// ─── Test: an intermediate compact_step preserves the durable free-list ──────
278+
/// `compact_step` runs the same atomic repack as `compact_now`, so it must
279+
/// preserve overflow (large) values rather than failing or corrupting the store.
285280
#[tokio::test(flavor = "current_thread")]
286-
async fn compact_step_preserves_free_list() {
287-
// No commit history, so freed pages are immediately reclaimable and tracked
288-
// in the durable free-list with no reader/history pinning them.
289-
let opts = pagedb::options::OpenOptions::default()
290-
.with_commit_history_retain(pagedb::options::RetainPolicy::Disabled);
291-
let db = Db::open_internal_with_options(MemVfs::new(), KEK, PAGE, REALM, opts)
281+
async fn compact_step_preserves_large_overflow_values() {
282+
let vfs = MemVfs::new();
283+
let db = Db::open_internal(vfs.clone(), KEK, PAGE, REALM)
292284
.await
293285
.unwrap();
294286

295-
// Build a working set, then delete most of it to populate the free-list.
287+
let big = vec![0x3Cu8; 4096]; // > PAGE/4 → overflow chain
288+
let n = 60u32;
296289
{
297290
let mut w = db.begin_write().await.unwrap();
298-
for i in 0u32..300 {
299-
w.put(format!("k{i:05}").as_bytes(), &[1u8; 128])
300-
.await
301-
.unwrap();
291+
for i in 0..n {
292+
w.put(format!("k-{i:05}").as_bytes(), &big).await.unwrap();
302293
}
303294
w.commit().await.unwrap();
304295
}
305-
{
306-
let mut w = db.begin_write().await.unwrap();
307-
for i in 0u32..250 {
308-
w.delete(format!("k{i:05}").as_bytes()).await.unwrap();
309-
}
310-
w.commit().await.unwrap();
311-
}
312-
let pending_before = db.stats().await.unwrap().free_list_pending_entries;
313-
assert!(
314-
pending_before > 0,
315-
"setup should have populated the free-list; got {pending_before}"
316-
);
317296

318-
// One intermediate step (small budget so it is NOT the final batch).
319-
let prog = db
320-
.compact_step(CompactBudget::new(5, 10_000))
321-
.await
322-
.unwrap();
323-
assert!(prog.more_work, "small budget should leave more work");
297+
let prog = db.compact_step(CompactBudget::default()).await.unwrap();
298+
assert!(!prog.more_work, "compaction completes in a single call");
324299

325-
// The pre-existing free-list must survive the intermediate step, not be
326-
// wiped — those pages are still reusable by ordinary writes.
327-
let pending_after = db.stats().await.unwrap().free_list_pending_entries;
328-
assert!(
329-
pending_after >= pending_before,
330-
"intermediate compact_step wiped the durable free-list: {pending_before} -> {pending_after}"
331-
);
300+
let r = db.begin_read().await.unwrap();
301+
for i in 0..n {
302+
let key = format!("k-{i:05}");
303+
assert_eq!(
304+
r.get(key.as_bytes()).await.unwrap().as_deref(),
305+
Some(big.as_slice()),
306+
"large value {key} lost after compact_step repack"
307+
);
308+
}
332309
}

0 commit comments

Comments
 (0)