use super::freemap::take_structural_reuse_log;
use super::*;
use crate::page_io::{Fault, PageIo};
use tempfile::{NamedTempFile, TempDir};
fn fresh_manager() -> TransactionManager {
let file = NamedTempFile::new().unwrap();
let io = PageIo::open(file.path(), false).unwrap();
let cache = PageCache::new(
io,
1024 * PAGE_SIZE as u64,
0,
crate::DrainInsertion::LruTail,
crate::SpillwayLocation::InMemory,
);
let mut tm = TransactionManager::create_new(cache, 2, None, None).unwrap();
tm.begin().unwrap();
tm.commit().unwrap();
tm
}
fn assert_no_reachable_page_is_free(tm: &TransactionManager) {
let mut reachable = Vec::new();
{
let mut cache = tm.cache.borrow_mut();
tm.handle_table
.collect_page_ids(
&mut cache,
tm.committed_roots.handle_table_page,
&mut reachable,
)
.unwrap();
tm.membership_index
.collect_page_ids(
&mut cache,
tm.committed_roots.membership_index_page,
&mut reachable,
)
.unwrap();
if tm.committed_roots.handle_table_page != PAGE_ID_NONE {
let live = tm
.handle_table
.iter_live(&mut cache, tm.committed_roots.handle_table_page)
.unwrap();
for (_handle, entry) in live {
match entry.flags {
HandleFlags::Live => reachable.push(entry.page_id),
HandleFlags::Overflow => {
let chain =
Overflow::collect_chain_pages(&mut cache, entry.page_id).unwrap();
reachable.extend(chain);
}
HandleFlags::Deleted => {}
}
}
}
}
let mut cache = tm.cache.borrow_mut();
let tree = FreeMapTree::from_roots(
tm.committed_roots.freemap_page,
tm.committed_roots.freemap_depth,
);
for id in reachable {
assert!(
!tree.is_free(&mut cache, id).unwrap(),
"page {id} is reachable from committed_roots but marked FREE in \
the committed freemap — a still-referenced page was freed (C1 violation)"
);
}
}
#[test]
fn reclamation_never_frees_a_reachable_page_after_grow() {
let mut tm = fresh_manager();
let tag = 9u32;
let mut handles = Vec::new();
let mut v: u32 = 0;
for _ in 0..12 {
tm.begin().unwrap();
for _ in 0..100 {
let h = tm.allocate_tagged(&v.to_le_bytes(), tag).unwrap();
handles.push(h);
v += 1;
}
tm.commit().unwrap();
assert_no_reachable_page_is_free(&tm);
}
assert!(
handles.len() > 1021,
"workload must exceed one membership leaf to force an inner grow"
);
for round in 0..12u32 {
for (chunk_idx, chunk) in handles.chunks(100).enumerate() {
tm.begin().unwrap();
for (j, h) in chunk.iter().enumerate() {
if (round as usize + chunk_idx + j) % 2 == 0 {
tm.set_client_byte(*h, round as u8).unwrap();
} else {
tm.update(*h, &round.to_le_bytes()).unwrap();
}
}
tm.commit().unwrap();
assert_no_reachable_page_is_free(&tm);
}
}
for h in &handles {
assert_eq!(tm.tag(*h).unwrap(), tag);
}
assert_eq!(tm.handles_with_tag(tag).unwrap().len(), handles.len());
}
#[test]
fn tagged_membership_survives_rolled_back_outer_grow() {
let mut tm = fresh_manager();
tm.begin().unwrap();
let h = tm.allocate_tagged(b"keep", 3).unwrap();
tm.commit().unwrap();
assert_eq!(tm.handles_with_tag(3).unwrap(), vec![h]);
tm.begin().unwrap();
let _ = tm.allocate_tagged(b"discard", 5000).unwrap();
tm.rollback().unwrap();
assert_eq!(
tm.handles_with_tag(3).unwrap(),
vec![h],
"rolled-back outer grow corrupted committed membership"
);
assert_eq!(tm.tag(h).unwrap(), 3);
assert_eq!(tm.handles_with_tag(5000).unwrap(), Vec::<u64>::new());
}
#[test]
fn tagged_membership_survives_rollback_to_savepoint() {
let mut tm = fresh_manager();
tm.begin().unwrap();
let h = tm.allocate_tagged(b"keep", 7).unwrap();
tm.savepoint("sp").unwrap();
let _ = tm.allocate_tagged(b"discard", 6000).unwrap();
tm.rollback_to("sp").unwrap();
assert_eq!(tm.handles_with_tag(7).unwrap(), vec![h]);
assert_eq!(tm.handles_with_tag(6000).unwrap(), Vec::<u64>::new());
tm.commit().unwrap();
assert_eq!(tm.handles_with_tag(7).unwrap(), vec![h]);
}
#[test]
fn poisoned_manager_rejects_every_public_entry_point() {
let mut tm = fresh_manager();
tm.force_poison_for_test();
assert!(tm.is_poisoned());
assert!(matches!(tm.begin(), Err(ChiselError::Poisoned)));
assert!(matches!(tm.commit(), Err(ChiselError::Poisoned)));
assert!(matches!(tm.rollback(), Err(ChiselError::Poisoned)));
assert!(matches!(tm.savepoint("x"), Err(ChiselError::Poisoned)));
assert!(matches!(tm.rollback_to("x"), Err(ChiselError::Poisoned)));
assert!(matches!(tm.release("x"), Err(ChiselError::Poisoned)));
assert!(matches!(tm.allocate(b"v"), Err(ChiselError::Poisoned)));
assert!(matches!(tm.read(0), Err(ChiselError::Poisoned)));
assert!(matches!(tm.update(0, b"v"), Err(ChiselError::Poisoned)));
assert!(matches!(tm.delete(0), Err(ChiselError::Poisoned)));
assert!(matches!(tm.handles(), Err(ChiselError::Poisoned)));
assert!(matches!(tm.file_page_count(), Err(ChiselError::Poisoned)));
assert!(matches!(
tm.allocate_tagged(b"v", 1),
Err(ChiselError::Poisoned)
));
assert!(matches!(tm.tag(0), Err(ChiselError::Poisoned)));
assert!(matches!(tm.handles_with_tag(1), Err(ChiselError::Poisoned)));
assert!(matches!(tm.delete_tagged(0, 1), Err(ChiselError::Poisoned)));
assert!(matches!(
tm.delete_with_tag(1, 10),
Err(ChiselError::Poisoned)
));
assert!(matches!(tm.client_byte(0), Err(ChiselError::Poisoned)));
assert!(matches!(
tm.set_client_byte(0, 1),
Err(ChiselError::Poisoned)
));
}
#[test]
fn fatal_error_outside_commit_also_poisons() {
let file = NamedTempFile::new().unwrap();
let h;
let pid;
{
let io = PageIo::open(file.path(), false).unwrap();
let cache = PageCache::new(
io,
1024 * PAGE_SIZE as u64,
0,
crate::DrainInsertion::LruTail,
crate::SpillwayLocation::InMemory,
);
let mut tm = TransactionManager::create_new(cache, 2, None, None).unwrap();
tm.begin().unwrap();
h = tm.allocate(b"durable").unwrap();
tm.commit().unwrap();
pid = tm.handle_live_page_id(h).unwrap().expect("live data page");
}
let io = PageIo::open(file.path(), false).unwrap();
let cache = PageCache::new(
io,
1024 * PAGE_SIZE as u64,
0,
crate::DrainInsertion::LruTail,
crate::SpillwayLocation::InMemory,
);
let tm = TransactionManager::open_existing(cache, None).unwrap();
tm.cache.borrow().io().arm_fault(Fault::FailReadPage(pid));
let result = tm.read(h);
assert!(
matches!(result, Err(ChiselError::IoError(_))),
"cold read fault must surface IoError, got {result:?}"
);
assert!(
tm.is_poisoned(),
"a fatal read error outside commit must poison"
);
}
#[test]
fn rollback_truncates_cache_and_file_to_pre_txn_watermark() {
let mut tm = fresh_manager();
let pre_watermark = tm.cache.borrow().next_page_id();
let pre_file_pages = tm.cache.borrow_mut().file_page_count().unwrap();
tm.begin().unwrap();
tm.allocate(b"seed").unwrap();
for _ in 0..510 {
tm.allocate(b"f").unwrap();
}
let mid_watermark = tm.cache.borrow().next_page_id();
assert!(
mid_watermark > pre_watermark + 510,
"expected the transaction to allocate many pages beyond {pre_watermark}, got {mid_watermark}"
);
tm.rollback().unwrap();
let post_watermark = tm.cache.borrow().next_page_id();
let post_file_pages = tm.cache.borrow_mut().file_page_count().unwrap();
assert_eq!(
post_watermark, pre_watermark,
"rollback must rewind next_page_id to the pre-transaction watermark"
);
assert_eq!(
post_file_pages, pre_file_pages,
"rollback must truncate the file back to its pre-transaction page count"
);
}
#[test]
fn rollback_to_savepoint_truncates_to_savepoint_watermark() {
let mut tm = fresh_manager();
tm.begin().unwrap();
let h1 = tm.allocate(b"before").unwrap();
tm.savepoint("sp").unwrap();
let savepoint_watermark = tm.cache.borrow().next_page_id();
let _h2 = tm.allocate(b"after").unwrap();
let _h3 = tm.allocate(b"after-2").unwrap();
assert!(tm.cache.borrow().next_page_id() > savepoint_watermark);
tm.rollback_to("sp").unwrap();
assert_eq!(
tm.cache.borrow().next_page_id(),
savepoint_watermark,
"rollback_to must rewind to the savepoint's watermark"
);
assert_eq!(tm.read(h1).unwrap(), b"before");
tm.commit().unwrap();
}
#[test]
fn operational_error_does_not_poison() {
let mut tm = fresh_manager();
assert!(matches!(tm.commit(), Err(ChiselError::NoActiveTransaction)));
assert!(!tm.is_poisoned());
assert!(matches!(
tm.allocate(b"v"),
Err(ChiselError::NoActiveTransaction)
));
assert!(!tm.is_poisoned());
tm.begin().unwrap();
tm.savepoint("a").unwrap();
assert!(matches!(
tm.savepoint("a"),
Err(ChiselError::DuplicateSavepoint(_))
));
assert!(!tm.is_poisoned());
assert!(matches!(tm.read(999), Err(ChiselError::InvalidHandle(_))));
assert!(!tm.is_poisoned());
}
#[test]
fn persist_freemap_does_not_reuse_committed_live_pages() {
let mut tm = fresh_manager();
let big: Vec<u8> = vec![0xAB; MAX_INLINE_VALUE + 32];
tm.begin().unwrap();
let h_throwaway = tm.allocate(&big).unwrap();
let h_live_a = tm.allocate(&big).unwrap();
let h_live_b = tm.allocate(&big).unwrap();
let h_live_c = tm.allocate(&big).unwrap();
tm.delete(h_throwaway).unwrap();
tm.commit().unwrap();
let committed_freemap_page = tm.committed_roots.freemap_page;
assert_ne!(
committed_freemap_page, PAGE_ID_NONE,
"test precondition: commit 1 should have established a freemap page"
);
tm.begin().unwrap();
tm.delete(h_live_a).unwrap();
tm.delete(h_live_b).unwrap();
let frozen_txn_freed: Vec<u64> = tm.txn_freed_pages.clone();
assert!(
!frozen_txn_freed.is_empty(),
"test precondition: deletes should have populated txn_freed_pages"
);
tm.commit().unwrap();
let mut still_live_pre_commit = frozen_txn_freed.clone();
still_live_pre_commit.push(committed_freemap_page);
let new_freemap_page = tm.committed_roots.freemap_page;
assert!(
!still_live_pre_commit.contains(&new_freemap_page),
"I18: persist_freemap allocated the new freemap page at an id \
that was still referenced by the last-durable superblock. \
new_freemap_page={new_freemap_page}, \
committed_freemap_page was {committed_freemap_page}, \
txn_freed_pages at commit time = {frozen_txn_freed:?}"
);
assert_eq!(tm.read(h_live_c).unwrap(), big);
}
#[test]
fn commit_with_active_savepoint_returns_freed_pages_to_freemap() {
let mut tm = fresh_manager();
let big: Vec<u8> = vec![0xCD; MAX_INLINE_VALUE + 32];
tm.begin().unwrap();
let h_a = tm.allocate(&big).unwrap();
let h_b = tm.allocate(&big).unwrap();
let h_keepalive = tm.allocate(&big).unwrap();
tm.commit().unwrap();
tm.begin().unwrap();
tm.delete(h_a).unwrap();
tm.delete(h_b).unwrap();
let frozen_txn_freed: Vec<u64> = tm.txn_freed_pages.clone();
assert!(
!frozen_txn_freed.is_empty(),
"test precondition: overflow-sized deletes should free at least one page"
);
tm.savepoint("s").unwrap();
assert!(
tm.txn_freed_pages.is_empty(),
"savepoint_inner should have moved txn_freed_pages into the savepoint"
);
tm.commit().unwrap();
{
let mut cache = tm.cache.borrow_mut();
let tree = FreeMapTree::from_roots(
tm.committed_roots.freemap_page,
tm.committed_roots.freemap_depth,
);
for id in &frozen_txn_freed {
assert!(
tree.is_free(&mut cache, *id).unwrap(),
"I27: freed page {id} should be marked free in the committed \
freemap tree after commit-with-active-savepoint; \
frozen_txn_freed={frozen_txn_freed:?}"
);
}
}
assert_eq!(tm.read(h_keepalive).unwrap(), big);
}
fn structural_churn(tm: &mut TransactionManager, big: &[u8], n: usize, del: usize) -> Vec<u64> {
tm.begin().unwrap();
let mut handles: Vec<u64> = (0..n).map(|_| tm.allocate(big).unwrap()).collect();
tm.commit().unwrap();
tm.begin().unwrap();
for h in handles.drain(..del) {
tm.delete(h).unwrap();
}
tm.commit().unwrap();
handles
}
#[test]
fn structural_recycle_one_commit_defer() {
let mut tm = fresh_manager();
let big: Vec<u8> = vec![0xAB; MAX_INLINE_VALUE + 32];
let mut live: Vec<u64> = Vec::new();
tm.begin().unwrap();
for _ in 0..16 {
live.push(tm.allocate(&big).unwrap());
}
tm.commit().unwrap();
for _ in 0..6 {
tm.begin().unwrap();
let recycled: Vec<u64> = live.drain(..8).collect();
for h in recycled {
tm.delete(h).unwrap();
}
for _ in 0..8 {
live.push(tm.allocate(&big).unwrap());
}
tm.commit().unwrap();
}
let promoted: std::collections::HashSet<u64> = tm
.freemap
.pending_structural_frees()
.iter()
.copied()
.collect();
assert!(
!promoted.is_empty(),
"precondition: warm-up must leave a non-empty deferred recycle"
);
let _ = take_structural_reuse_log();
tm.begin().unwrap();
for _ in 0..6 {
let recycled: Vec<u64> = live.drain(..4).collect();
for h in recycled {
tm.delete(h).unwrap();
}
for _ in 0..4 {
live.push(tm.allocate(&big).unwrap());
}
}
let superseded_in_t1: std::collections::HashSet<u64> =
tm.freemap.structural_superseded().iter().copied().collect();
tm.commit().unwrap();
let reused_in_t1 = take_structural_reuse_log();
assert!(
!reused_in_t1.is_empty(),
"precondition: T+1 must actually reuse at least one deferred page \
(else the defer is untested)"
);
for id in &reused_in_t1 {
assert!(
promoted.contains(id),
"one-commit-defer VIOLATION: T+1 reused freemap page {id} that was \
NOT in the promoted recycle set {promoted:?} — it was minted or \
superseded within T+1, so a pre-commit crash would corrupt the page \
the last-durable superblock still references"
);
assert!(
!superseded_in_t1.contains(id),
"one-commit-defer VIOLATION: T+1 reused page {id} that T+1 itself \
superseded this transaction (still live under the last-durable \
superblock) — reusing it pre-commit is a crash-safety bug"
);
}
let reachable: std::collections::HashSet<u64> = {
let mut cache = tm.cache.borrow_mut();
let committed = FreeMapTree::from_roots(
tm.committed_roots.freemap_page,
tm.committed_roots.freemap_depth,
);
committed
.reachable_pages(&mut cache)
.unwrap()
.into_iter()
.collect()
};
for id in tm.freemap.pending_structural_frees() {
assert!(
!reachable.contains(id),
"one-commit-defer VIOLATION: page {id} is queued for reuse in T+2 but is \
still reachable in the committed freemap tree — a same-transaction \
supersede leaked into the reuse pool while still live"
);
}
}
#[test]
fn structural_recycle_rollback_resets_pools() {
let mut tm = fresh_manager();
let big: Vec<u8> = vec![0xAB; MAX_INLINE_VALUE + 32];
let survivors = structural_churn(&mut tm, &big, 8, 4);
let committed_recycle: Vec<u64> = tm.freemap.pending_structural_frees().to_vec();
assert!(
!committed_recycle.is_empty(),
"precondition: a prior commit must leave a non-empty deferred recycle"
);
tm.begin().unwrap();
let _fresh: Vec<u64> = (0..6).map(|_| tm.allocate(&big).unwrap()).collect();
for h in &survivors {
tm.delete(*h).unwrap();
}
assert!(
!tm.freemap.session_owned().is_empty() || !tm.freemap.structural_superseded().is_empty(),
"precondition: the pre-rollback churn must have mutated freemap session/supersede state"
);
tm.rollback().unwrap();
assert_eq!(
tm.freemap.pending_structural_frees(),
committed_recycle.as_slice(),
"rollback must leave the committed deferred recycle intact"
);
let recycle_after: std::collections::HashSet<u64> = tm
.freemap
.pending_structural_frees()
.iter()
.copied()
.collect();
let committed_set: std::collections::HashSet<u64> = committed_recycle.iter().copied().collect();
assert_eq!(
recycle_after, committed_set,
"the post-rollback recycle (what the next begin will seed structural_reuse from) \
must equal the committed recycle state"
);
assert!(
tm.freemap.structural_superseded().is_empty(),
"rollback must clear structural_superseded — those committed-tree pages are \
still referenced and must never be recycled"
);
assert!(
tm.freemap.session_owned().is_empty(),
"rollback must clear freemap_session_owned — leaking it would suppress a needed \
COW and mutate a live committed page in place next transaction"
);
let _ = take_structural_reuse_log();
tm.begin().unwrap();
let mut next: Vec<u64> = (0..8).map(|_| tm.allocate(&big).unwrap()).collect();
for h in survivors {
tm.delete(h).unwrap();
}
tm.commit().unwrap();
for id in take_structural_reuse_log() {
assert!(
committed_set.contains(&id),
"post-rollback transaction reused freemap page {id} not in the committed \
recycle {committed_set:?} — rollback leaked structural pool state"
);
}
for h in next.drain(..) {
tm.begin().unwrap();
tm.delete(h).unwrap();
tm.commit().unwrap();
}
}
#[test]
fn orphan_sweep_skipped_under_savepoint_preserves_committed_freemap() {
let mut tm = fresh_manager();
let big: Vec<u8> = vec![0xAB; MAX_INLINE_VALUE + 32];
let survivors = structural_churn(&mut tm, &big, 8, 4);
let committed_root = tm.committed_roots.freemap_page;
let committed_depth = tm.committed_roots.freemap_depth;
assert_ne!(
committed_root, PAGE_ID_NONE,
"precondition: a committed freemap tree must exist"
);
let (free_before, reachable_before): (
std::collections::BTreeSet<u64>,
std::collections::HashSet<u64>,
) = {
let mut cache = tm.cache.borrow_mut();
let tree = FreeMapTree::from_roots(committed_root, committed_depth);
let reachable: std::collections::HashSet<u64> = tree
.reachable_pages(&mut cache)
.unwrap()
.into_iter()
.collect();
let total = cache.next_page_id();
let mut free = std::collections::BTreeSet::new();
for id in 0..total {
if tree.is_free(&mut cache, id).unwrap() {
free.insert(id);
}
}
(free, reachable)
};
tm.begin().unwrap();
tm.savepoint("sp").unwrap();
let _orphan = tm.test_forge_freemap_orphan().unwrap();
let reclaimed = tm.reclaim_freemap_orphans().unwrap();
assert_eq!(
reclaimed, 0,
"the orphan sweep must be a no-op under an active savepoint (got {reclaimed})"
);
assert!(
tm.freemap.structural_superseded().is_empty(),
"sweep under savepoint leaked into structural_superseded: {:?}",
tm.freemap.structural_superseded()
);
tm.rollback_to("sp").unwrap();
tm.commit().unwrap();
tm.begin().unwrap();
tm.delete(survivors[0]).unwrap();
tm.commit().unwrap();
let (free_after, reachable_after): (
std::collections::BTreeSet<u64>,
std::collections::HashSet<u64>,
) = {
let mut cache = tm.cache.borrow_mut();
let tree = FreeMapTree::from_roots(committed_root, committed_depth);
let reachable: std::collections::HashSet<u64> = tree
.reachable_pages(&mut cache)
.unwrap()
.into_iter()
.collect();
let total = cache.next_page_id();
let mut free = std::collections::BTreeSet::new();
for id in 0..total {
if tree.is_free(&mut cache, id).unwrap() {
free.insert(id);
}
}
(free, reachable)
};
assert_eq!(
reachable_after, reachable_before,
"the original committed freemap tree's node set changed — a committed \
freemap page was reused-and-overwritten (savepoint guard regression)"
);
assert_eq!(
free_after, free_before,
"the original committed freemap tree's free-set changed — its on-disk \
bitmap pages were overwritten by a COW into a reused committed page"
);
}
#[test]
fn corrupt_dead_page_does_not_poison_orphan_sweep() {
let mut tm = fresh_manager();
let big: Vec<u8> = vec![0xAB; MAX_INLINE_VALUE + 32];
let _survivors = structural_churn(&mut tm, &big, 8, 4);
tm.begin().unwrap();
let real_orphan = tm.test_forge_freemap_orphan().unwrap();
let corrupt = tm.test_forge_corrupt_dead_page().unwrap();
assert_ne!(real_orphan, corrupt);
let reclaimed = tm.reclaim_freemap_orphans().unwrap();
assert!(
reclaimed >= 1,
"sweep must skip the corrupt dead page yet still reclaim the readable \
orphan (reclaimed={reclaimed})"
);
assert!(
!tm.is_poisoned(),
"a corrupt DEAD page must not poison the orphan sweep"
);
tm.commit().unwrap();
let live_root = tm.committed_roots.freemap_page;
let live_depth = tm.committed_roots.freemap_depth;
{
let mut cache = tm.cache.borrow_mut();
let tree = FreeMapTree::from_roots(live_root, live_depth);
let reachable: Vec<u64> = tree
.reachable_pages(&mut cache)
.unwrap()
.into_iter()
.collect();
let victim = reachable
.iter()
.copied()
.find(|&id| id != live_root)
.unwrap_or(live_root);
{
let buf = cache.get_mut(victim).unwrap();
let wrong = if buf[0] == crate::page::PageType::FreeMap as u8 {
crate::page::PageType::FreeMapInterior as u8
} else {
crate::page::PageType::FreeMap as u8
};
buf[0] = wrong;
page::stamp_checksum(buf);
}
let err = tree.reachable_pages(&mut cache).unwrap_err();
assert!(
matches!(err, ChiselError::CorruptPage { .. }),
"reachable_pages must surface fatal CorruptPage on a corrupt LIVE \
node — the dead-page softening must not weaken the live walk \
(got {err:?})"
);
}
}
#[test]
fn structural_recycle_no_lost_or_double_free() {
let mut tm = fresh_manager();
let big: Vec<u8> = vec![0xAB; MAX_INLINE_VALUE + 32];
let mut live: Vec<u64> = Vec::new();
tm.begin().unwrap();
for _ in 0..10 {
live.push(tm.allocate(&big).unwrap());
}
tm.commit().unwrap();
for round in 0..8u32 {
let to_delete: Vec<u64> = live.drain(..5).collect();
tm.begin().unwrap();
for h in &to_delete {
tm.delete(*h).unwrap();
}
let freed_this_commit: Vec<u64> = tm.txn_freed_pages.clone();
for _ in 0..5 {
live.push(tm.allocate(&big).unwrap());
}
tm.commit().unwrap();
assert!(
!freed_this_commit.is_empty(),
"round {round}: deletes must free at least one whole page"
);
let live_set: std::collections::HashSet<u64> = live.iter().copied().collect();
{
let mut cache = tm.cache.borrow_mut();
let committed = FreeMapTree::from_roots(
tm.committed_roots.freemap_page,
tm.committed_roots.freemap_depth,
);
for id in &freed_this_commit {
if live_set.contains(id) {
continue;
}
assert!(
committed.is_free(&mut cache, *id).unwrap(),
"round {round}: page {id} was freed this commit but is NOT free in \
the committed freemap tree — a lost free"
);
}
}
let reachable: std::collections::HashSet<u64> = {
let mut cache = tm.cache.borrow_mut();
let committed = FreeMapTree::from_roots(
tm.committed_roots.freemap_page,
tm.committed_roots.freemap_depth,
);
committed
.reachable_pages(&mut cache)
.unwrap()
.into_iter()
.collect()
};
for id in tm.freemap.pending_structural_frees() {
assert!(
!reachable.contains(id),
"round {round}: freemap page {id} is in the structural reuse pool AND \
still reachable in the live committed freemap tree — a reuse-pool page \
must be dead (handing it out as a COW target would double-allocate it)"
);
}
{
let mut cache = tm.cache.borrow_mut();
let committed = FreeMapTree::from_roots(
tm.committed_roots.freemap_page,
tm.committed_roots.freemap_depth,
);
for id in tm.freemap.pending_structural_frees() {
assert!(
!committed.is_free(&mut cache, *id).unwrap(),
"round {round}: structural-reuse page {id} is ALSO marked free in the \
bitmap — the two reclamation channels overlap, risking a double hand-out"
);
}
}
}
}
#[test]
fn reclaim_freemap_orphans_marks_lost_freemap_pages_free() {
let mut tm = fresh_manager();
let big: Vec<u8> = vec![0xCD; MAX_INLINE_VALUE + 32];
tm.begin().unwrap();
let mut hs = Vec::new();
for _ in 0..40 {
hs.push(tm.allocate(&big).unwrap());
}
tm.commit().unwrap();
tm.begin().unwrap();
for h in hs.iter().step_by(2) {
tm.delete(*h).unwrap();
}
tm.commit().unwrap();
let orphan = tm.test_forge_freemap_orphan().unwrap();
tm.begin().unwrap();
let reclaimed = tm.reclaim_freemap_orphans().unwrap();
assert!(reclaimed >= 1, "the forged orphan must be reclaimed");
tm.commit().unwrap();
let mut cache = tm.cache.borrow_mut();
let tree = FreeMapTree::from_roots(
tm.committed_roots.freemap_page,
tm.committed_roots.freemap_depth,
);
assert!(
tree.is_free(&mut cache, orphan).unwrap(),
"reclaimed orphan {orphan} must read free in the committed freemap"
);
}
#[test]
fn reclaim_freemap_orphans_excludes_live_recycle_pool() {
let mut tm = fresh_manager();
let big: Vec<u8> = vec![0xCD; MAX_INLINE_VALUE + 32];
tm.begin().unwrap();
let h = tm.allocate(&big).unwrap();
tm.commit().unwrap();
tm.begin().unwrap();
tm.delete(h).unwrap();
tm.commit().unwrap();
let pooled = tm.test_forge_freemap_orphan().unwrap();
tm.begin().unwrap();
tm.freemap.push_structural_reuse_for_test(pooled);
let reclaimed = tm.reclaim_freemap_orphans().unwrap();
assert_eq!(
reclaimed, 0,
"a page in the live recycle pool must NOT be reclaimed as an orphan"
);
assert!(
tm.freemap.structural_reuse().contains(&pooled),
"the sweep must leave the live recycle pool untouched"
);
tm.rollback().unwrap();
}
#[test]
fn commit_does_not_poison_when_cache_is_at_strict_cap() {
let _dir = TempDir::new().unwrap();
let db_path = _dir.path().join("test.chisel");
let io = PageIo::open(&db_path, false).unwrap();
let cache = PageCache::new(
io,
16 * PAGE_SIZE as u64,
0,
crate::DrainInsertion::LruTail,
crate::SpillwayLocation::InMemory,
);
let mut tm = TransactionManager::create_new(cache, 2, None, None).unwrap();
tm.begin().unwrap();
tm.commit().unwrap();
let big: Vec<u8> = vec![0x99; MAX_INLINE_VALUE + 32];
tm.begin().unwrap();
let victim = tm.allocate(&big).unwrap();
tm.commit().unwrap();
tm.begin().unwrap();
tm.delete(victim).unwrap();
let mut saturated = false;
for _ in 0..200 {
match tm.allocate(&big) {
Ok(_) => continue,
Err(ChiselError::CacheFull { .. }) => {
saturated = true;
break;
}
Err(e) => panic!("unexpected error during cache-fill setup: {e:?}"),
}
}
assert!(
saturated,
"test precondition: cache did not reach CacheFull in 200 allocations"
);
assert!(
!tm.is_poisoned(),
"precondition: CacheFull from allocate() is operational and must not poison"
);
let result = tm.commit();
assert!(
result.is_ok(),
"I28: commit over a saturated cache should succeed; got {result:?}"
);
assert!(
!tm.is_poisoned(),
"I28: CacheFull during commit must not poison — it's operational by design"
);
}
#[test]
fn allocate_membership_failure_leaves_maps_consistent() {
let mut tm = fresh_manager();
tm.begin().unwrap();
let ghost = tm.current_roots.next_handle;
tm.fault.fail_next_membership_op.set(true);
let err = tm.allocate_tagged(b"payload", 7).unwrap_err();
assert!(
matches!(err, ChiselError::CacheFull { .. }),
"expected the injected CacheFull, got {err:?}"
);
assert!(!tm.is_poisoned(), "a non-fatal CacheFull must not poison");
let in_reverse = tm.handles_with_tag(7).unwrap().contains(&ghost);
let in_forward = matches!(tm.tag(ghost), Ok(7));
assert_eq!(
in_forward, in_reverse,
"forward/reverse tag maps diverged after a failed tagged allocate"
);
assert!(
!in_forward,
"failed allocate must not install the forward entry"
);
assert_eq!(
tm.current_roots.next_handle, ghost,
"failed allocate must not burn the handle id"
);
assert!(
tm.packer.is_current_empty(),
"failed allocate left a phantom live-slot count: {:?}",
tm.packer.current_live_slots()
);
assert_eq!(
tm.packer.insert_cursor(),
None,
"failed allocate left a ghost insert cursor"
);
let h = tm.allocate_tagged(b"payload", 7).unwrap();
assert_eq!(h, ghost, "retry should reuse the un-burned handle id");
assert_eq!(tm.tag(h).unwrap(), 7);
assert!(tm.handles_with_tag(7).unwrap().contains(&h));
tm.commit().unwrap();
assert!(tm.handles_with_tag(7).unwrap().contains(&h));
assert_no_reachable_page_is_free(&tm);
}
#[test]
fn allocate_handle_table_failure_leaves_maps_consistent() {
let mut tm = fresh_manager();
tm.begin().unwrap();
let saved_root = tm.current_roots.handle_table_page;
assert_eq!(saved_root, PAGE_ID_NONE, "precondition: empty handle table");
let ghost = tm.current_roots.next_handle;
tm.fault.fail_next_handle_table_op.set(true);
let err = tm.allocate_tagged(b"payload", 7).unwrap_err();
assert!(
matches!(err, ChiselError::CacheFull { .. }),
"expected the injected CacheFull, got {err:?}"
);
assert!(!tm.is_poisoned(), "a non-fatal CacheFull must not poison");
assert_eq!(
tm.current_roots.handle_table_page, saved_root,
"failed forward step left a lazily-materialized handle-table root installed"
);
assert_eq!(tm.current_roots.next_handle, ghost);
assert!(
matches!(tm.tag(ghost), Err(ChiselError::InvalidHandle(_))),
"ghost handle after failed allocate_tagged must be InvalidHandle"
);
assert!(tm.handles_with_tag(7).unwrap().is_empty());
assert!(
tm.packer.is_current_empty(),
"failed forward step left a phantom live-slot count: {:?}",
tm.packer.current_live_slots()
);
assert_eq!(tm.packer.insert_cursor(), None);
let h = tm.allocate_tagged(b"payload", 7).unwrap();
assert_eq!(h, ghost, "retry should reuse the un-burned handle id");
assert_eq!(tm.tag(h).unwrap(), 7);
assert!(tm.handles_with_tag(7).unwrap().contains(&h));
tm.commit().unwrap();
assert_no_reachable_page_is_free(&tm);
}
#[test]
fn delete_membership_failure_leaves_maps_consistent() {
let mut tm = fresh_manager();
tm.begin().unwrap();
let h = tm.allocate_tagged(b"payload", 7).unwrap();
tm.commit().unwrap();
tm.begin().unwrap();
tm.fault.fail_next_membership_op.set(true);
let err = tm.delete(h).unwrap_err();
assert!(
matches!(err, ChiselError::CacheFull { .. }),
"expected the injected CacheFull, got {err:?}"
);
assert!(!tm.is_poisoned(), "a non-fatal CacheFull must not poison");
let in_reverse = tm.handles_with_tag(7).unwrap().contains(&h);
let still_live = tm.read(h).is_ok();
assert_eq!(
still_live, in_reverse,
"forward/reverse tag maps diverged after a failed tagged delete"
);
assert!(still_live, "failed delete must not install the tombstone");
assert!(in_reverse, "failed delete must not drop the reverse entry");
tm.delete(h).unwrap();
assert!(
matches!(tm.read(h), Err(ChiselError::InvalidHandle(_))),
"handle must be InvalidHandle after successful delete"
);
assert!(!tm.handles_with_tag(7).unwrap().contains(&h));
tm.commit().unwrap();
assert!(!tm.handles_with_tag(7).unwrap().contains(&h));
assert_no_reachable_page_is_free(&tm);
}
#[test]
fn delete_membership_failure_survives_reopen_consistently() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("bug2-del.chisel");
let open = |create: bool| -> TransactionManager {
let io = PageIo::open(&path, false).unwrap();
let cache = PageCache::new(
io,
1024 * PAGE_SIZE as u64,
0,
crate::DrainInsertion::LruTail,
crate::SpillwayLocation::InMemory,
);
if create {
TransactionManager::create_new(cache, 2, None, None).unwrap()
} else {
TransactionManager::open_existing(cache, None).unwrap()
}
};
let h = {
let mut tm = open(true);
tm.begin().unwrap();
let h = tm.allocate_tagged(b"payload", 9).unwrap();
tm.commit().unwrap();
tm.begin().unwrap();
tm.fault.fail_next_membership_op.set(true);
assert!(matches!(
tm.delete(h).unwrap_err(),
ChiselError::CacheFull { .. }
));
tm.commit().unwrap();
h
};
let tm = open(false);
let still_live = tm.read(h).is_ok();
let in_reverse = tm.handles_with_tag(9).unwrap().contains(&h);
assert_eq!(
still_live, in_reverse,
"reopened forward/reverse views diverged for the failed delete's handle"
);
assert!(
still_live && in_reverse,
"a failed tagged delete must commit nothing: h should survive in both maps \
(read ok = {still_live}, in reverse index = {in_reverse})"
);
}
#[test]
fn delete_with_tag_mid_pass_failure_is_consistent_and_drops_progress() {
let mut tm = fresh_manager();
tm.begin().unwrap();
let members = [
tm.allocate_tagged(b"m0", 5).unwrap(),
tm.allocate_tagged(b"m1", 5).unwrap(),
tm.allocate_tagged(b"m2", 5).unwrap(),
];
tm.commit().unwrap();
tm.begin().unwrap();
tm.fault.fail_membership_op_after.set(2);
let err = tm.delete_with_tag(5, 3).unwrap_err();
assert!(
matches!(err, ChiselError::CacheFull { .. }),
"expected the injected CacheFull, got {err:?}"
);
assert!(
!tm.is_poisoned(),
"a non-fatal mid-pass error must not poison"
);
let live: Vec<u64> = members
.into_iter()
.filter(|&h| tm.read(h).is_ok())
.collect();
assert_eq!(
live.len(),
2,
"exactly one member dropped before the failure"
);
let mut idx = tm.handles_with_tag(5).unwrap();
let mut live_sorted = live.clone();
idx.sort_unstable();
live_sorted.sort_unstable();
assert_eq!(
live_sorted, idx,
"forward/reverse maps consistent after a failed delete_with_tag pass"
);
tm.commit().unwrap();
assert_eq!(tm.handles_with_tag(5).unwrap().len(), 2);
assert_no_reachable_page_is_free(&tm);
tm.begin().unwrap();
let (_, complete) = tm.delete_with_tag(5, 3).unwrap();
assert!(complete, "retry must drain the tag");
tm.commit().unwrap();
assert!(tm.handles_with_tag(5).unwrap().is_empty());
assert_no_reachable_page_is_free(&tm);
}
#[test]
fn update_value_write_failure_does_not_free_old_value_pages() {
let mut tm = fresh_manager();
tm.begin().unwrap();
let big = vec![0xABu8; MAX_INLINE_VALUE * 3];
let h = tm.allocate(&big).unwrap();
tm.commit().unwrap();
assert_eq!(tm.read(h).unwrap(), big, "precondition: old value readable");
tm.begin().unwrap();
tm.fault.fail_next_update_value_write.set(true);
let err = tm.update(h, b"replacement").unwrap_err();
assert!(
matches!(err, ChiselError::CacheFull { .. }),
"expected the injected CacheFull, got {err:?}"
);
assert!(!tm.is_poisoned(), "a non-fatal CacheFull must not poison");
assert_eq!(
tm.read(h).unwrap(),
big,
"failed update lost the old value in-session"
);
tm.commit().unwrap();
assert_no_reachable_page_is_free(&tm);
assert_eq!(
tm.read(h).unwrap(),
big,
"failed update lost the old value after commit"
);
tm.begin().unwrap();
for i in 0..40u8 {
tm.allocate(&[i; 64]).unwrap();
}
tm.commit().unwrap();
assert_eq!(
tm.read(h).unwrap(),
big,
"old value corrupted after its prematurely-freed pages were reused"
);
}
#[test]
fn update_value_write_failure_does_not_free_old_inline_page() {
let mut tm = fresh_manager();
tm.begin().unwrap();
let old = b"inline-original-value";
let h = tm.allocate(old).unwrap();
tm.commit().unwrap();
tm.begin().unwrap();
tm.fault.fail_next_update_value_write.set(true);
assert!(matches!(
tm.update(h, b"replacement").unwrap_err(),
ChiselError::CacheFull { .. }
));
assert!(!tm.is_poisoned());
assert_eq!(
tm.read(h).unwrap(),
old,
"failed update lost the inline value"
);
tm.commit().unwrap();
assert_no_reachable_page_is_free(&tm);
assert_eq!(tm.read(h).unwrap(), old);
tm.begin().unwrap();
for i in 0..40u8 {
tm.allocate(&[i; 64]).unwrap();
}
tm.commit().unwrap();
assert_eq!(
tm.read(h).unwrap(),
old,
"old inline value corrupted after a prematurely-freed page was reused"
);
}
#[test]
fn update_handle_table_failure_preserves_old_value_and_releases_new_slot() {
let mut tm = fresh_manager();
tm.begin().unwrap();
let old = b"inline-original-value";
let h = tm.allocate(old).unwrap();
tm.commit().unwrap();
tm.begin().unwrap();
tm.fault.fail_next_handle_table_op.set(true);
let err = tm.update(h, b"small-new").unwrap_err();
assert!(
matches!(err, ChiselError::CacheFull { .. }),
"expected the injected CacheFull, got {err:?}"
);
assert!(!tm.is_poisoned());
assert_eq!(tm.read(h).unwrap(), old, "failed update lost the old value");
assert_eq!(
tm.packer.current_live_slots().values().sum::<u32>(),
1,
"failed update left a phantom live-slot: {:?}",
tm.packer.current_live_slots()
);
tm.commit().unwrap();
assert_no_reachable_page_is_free(&tm);
tm.begin().unwrap();
for i in 0..40u8 {
tm.allocate(&[i; 64]).unwrap();
}
tm.commit().unwrap();
assert_eq!(
tm.read(h).unwrap(),
old,
"old value corrupted after reuse following a failed update"
);
}
#[test]
fn allocate_membership_failure_survives_reopen_consistently() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("bug2.chisel");
let open = |create: bool| -> TransactionManager {
let io = PageIo::open(&path, false).unwrap();
let cache = PageCache::new(
io,
1024 * PAGE_SIZE as u64,
0,
crate::DrainInsertion::LruTail,
crate::SpillwayLocation::InMemory,
);
if create {
TransactionManager::create_new(cache, 2, None, None).unwrap()
} else {
TransactionManager::open_existing(cache, None).unwrap()
}
};
let ghost = {
let mut tm = open(true);
tm.begin().unwrap();
tm.commit().unwrap();
tm.begin().unwrap();
let ghost = tm.current_roots.next_handle;
tm.fault.fail_next_membership_op.set(true);
assert!(matches!(
tm.allocate_tagged(b"payload", 9).unwrap_err(),
ChiselError::CacheFull { .. }
));
tm.commit().unwrap();
ghost
};
let tm = open(false);
let forward = tm.tag(ghost);
assert!(
forward.is_err() || forward.as_ref().unwrap() != &9,
"reopened forward map has a ghost tag-9 entry (tag({ghost}) = {forward:?}) \
with no matching reverse-index member — the maps committed out of sync"
);
assert!(
tm.handles_with_tag(9).unwrap().is_empty(),
"tag 9 should have no committed members"
);
}
#[test]
fn aborted_tagged_allocate_with_freemap_reuse_is_consistent_and_rollback_reclaims() {
let mut tm = fresh_manager();
let big: Vec<u8> = vec![0xAB; MAX_INLINE_VALUE + 32];
let mut live_handles = Vec::new();
tm.begin().unwrap();
for _ in 0..6 {
live_handles.push(tm.allocate_tagged(&big, 5).unwrap());
}
tm.commit().unwrap();
tm.begin().unwrap();
for h in live_handles.drain(..3) {
tm.delete(h).unwrap();
}
tm.commit().unwrap();
assert_no_reachable_page_is_free(&tm);
let pre_fm_root = tm.committed_roots.freemap_page;
let pre_fm_depth = tm.committed_roots.freemap_depth;
let pre_next_handle = tm.committed_roots.next_handle;
let pre_ht_root = tm.committed_roots.handle_table_page;
let pre_mi_root = tm.committed_roots.membership_index_page;
assert_ne!(
pre_fm_root,
crate::page::PAGE_ID_NONE,
"precondition: committed freemap must be non-empty (need free bits for reuse)"
);
let committed_free_before: std::collections::BTreeSet<u64> = {
let tree = FreeMapTree::from_roots(pre_fm_root, pre_fm_depth);
let mut cache = tm.cache.borrow_mut();
let total = tm.committed_roots.total_pages;
(0..total)
.filter(|&id| tree.is_free(&mut cache, id).unwrap_or(false))
.collect()
};
assert!(
!committed_free_before.is_empty(),
"precondition: at least one free page in committed freemap for reuse path"
);
tm.begin().unwrap();
assert!(
tm.savepoints.is_empty(),
"precondition: no savepoints, so freemap reuse is enabled"
);
let ghost = tm.current_roots.next_handle;
let saved_ht_depth = tm.handle_table.depth();
tm.fault.fail_next_membership_op.set(true);
let err = tm.allocate_tagged(&big, 5).unwrap_err();
assert!(
matches!(err, ChiselError::CacheFull { .. }),
"expected injected CacheFull, got {err:?}"
);
assert!(!tm.is_poisoned(), "non-fatal CacheFull must not poison");
assert_eq!(
tm.current_roots.next_handle, ghost,
"aborted allocate must not consume the handle id"
);
assert_eq!(
tm.current_roots.handle_table_page, pre_ht_root,
"aborted allocate must not install a new handle-table root"
);
assert_eq!(
tm.current_roots.membership_index_page, pre_mi_root,
"aborted allocate must not install a new membership-index root"
);
assert_eq!(
tm.handle_table.depth(),
saved_ht_depth,
"aborted allocate must restore the eagerly-bumped handle-table depth"
);
assert!(
matches!(tm.tag(ghost), Err(ChiselError::InvalidHandle(_))),
"ghost handle must not appear in forward map"
);
assert!(
!tm.handles_with_tag(5).unwrap().contains(&ghost),
"ghost handle must not appear in reverse (membership) map"
);
assert_eq!(
tm.current_roots.next_handle, pre_next_handle,
"next_handle must equal the committed baseline (not burned)"
);
tm.rollback().unwrap();
assert_eq!(tm.committed_roots.freemap_page, pre_fm_root);
assert_eq!(tm.committed_roots.freemap_depth, pre_fm_depth);
let committed_free_after: std::collections::BTreeSet<u64> = {
let tree = FreeMapTree::from_roots(
tm.committed_roots.freemap_page,
tm.committed_roots.freemap_depth,
);
let mut cache = tm.cache.borrow_mut();
let total = tm.committed_roots.total_pages;
(0..total)
.filter(|&id| tree.is_free(&mut cache, id).unwrap_or(false))
.collect()
};
assert_eq!(
committed_free_before, committed_free_after,
"rollback must restore the committed freemap free-set: \
before={committed_free_before:?} after={committed_free_after:?}"
);
assert_no_reachable_page_is_free(&tm);
tm.begin().unwrap();
let h = tm.allocate_tagged(&big, 5).unwrap();
assert_eq!(h, ghost, "retry must reuse the un-burned handle id");
assert_eq!(tm.tag(h).unwrap(), 5);
assert!(tm.handles_with_tag(5).unwrap().contains(&h));
tm.commit().unwrap();
assert_no_reachable_page_is_free(&tm);
}
#[test]
fn format_version_gate_is_major_only() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_path_buf();
{
let io = PageIo::open(&path, false).unwrap();
let cache = PageCache::new(
io,
1024 * PAGE_SIZE as u64,
0,
crate::DrainInsertion::LruTail,
crate::SpillwayLocation::InMemory,
);
let _ = TransactionManager::create_new(cache, 2, None, None).unwrap();
}
let patch_all_slots = |version: u32, slot_count: usize| {
let mut bytes = std::fs::read(&path).unwrap();
for slot in 0..slot_count {
let offset = slot * PAGE_SIZE;
bytes[offset + 4..offset + 8].copy_from_slice(&version.to_le_bytes());
let page_arr: &mut [u8; PAGE_SIZE] =
(&mut bytes[offset..offset + PAGE_SIZE]).try_into().unwrap();
page::stamp_checksum(page_arr);
}
std::fs::write(&path, &bytes).unwrap();
};
let minor_bump = page::pack_format_version(
page::FORMAT_MAJOR_VERSION,
page::FORMAT_MINOR_VERSION.wrapping_add(42),
);
patch_all_slots(minor_bump, 2);
{
let io = PageIo::open(&path, false).unwrap();
let cache = PageCache::new(
io,
1024 * PAGE_SIZE as u64,
0,
crate::DrainInsertion::LruTail,
crate::SpillwayLocation::InMemory,
);
let tm = TransactionManager::open_existing(cache, None);
assert!(
tm.is_ok(),
"same-major / different-minor file should open cleanly; got {:?}",
tm.err()
);
}
let major_bump = page::pack_format_version(page::FORMAT_MAJOR_VERSION.wrapping_add(1), 0);
patch_all_slots(major_bump, 2);
{
let io = PageIo::open(&path, false).unwrap();
let cache = PageCache::new(
io,
1024 * PAGE_SIZE as u64,
0,
crate::DrainInsertion::LruTail,
crate::SpillwayLocation::InMemory,
);
match TransactionManager::open_existing(cache, None) {
Err(ChiselError::UnsupportedFormatVersion { .. }) => {}
Err(e) => panic!("expected UnsupportedFormatVersion, got {e:?}"),
Ok(_) => panic!("expected UnsupportedFormatVersion, got Ok"),
}
}
}
#[test]
fn file_minor_newer_than_binary_is_forced_read_only() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_path_buf();
{
let io = PageIo::open(&path, false).unwrap();
let cache = PageCache::new(
io,
1024 * PAGE_SIZE as u64,
0,
crate::DrainInsertion::LruTail,
crate::SpillwayLocation::InMemory,
);
let _ = TransactionManager::create_new(cache, 2, None, None).unwrap();
}
let newer_minor =
page::pack_format_version(page::FORMAT_MAJOR_VERSION, page::FORMAT_MINOR_VERSION + 1);
let mut bytes = std::fs::read(&path).unwrap();
for slot in 0..2 {
let offset = slot * PAGE_SIZE;
bytes[offset + 4..offset + 8].copy_from_slice(&newer_minor.to_le_bytes());
let page_arr: &mut [u8; PAGE_SIZE] =
(&mut bytes[offset..offset + PAGE_SIZE]).try_into().unwrap();
page::stamp_checksum(page_arr);
}
std::fs::write(&path, &bytes).unwrap();
let io = PageIo::open(&path, false).unwrap();
let cache = PageCache::new(
io,
1024 * PAGE_SIZE as u64,
0,
crate::DrainInsertion::LruTail,
crate::SpillwayLocation::InMemory,
);
let mut tm = TransactionManager::open_existing(cache, None)
.expect("a newer-minor file must still OPEN (reads are additive-safe)");
assert!(
matches!(tm.begin(), Err(ChiselError::ReadOnlyMode)),
"a newer-minor file must be forced read-only"
);
}
#[test]
fn allocate_tagged_then_tag_and_handles_with_tag() {
let mut tm = fresh_manager();
tm.begin().unwrap();
let h = tm.allocate_tagged(b"row", 42).unwrap();
let u = tm.allocate(b"untagged").unwrap();
tm.commit().unwrap();
assert_eq!(tm.tag(h).unwrap(), 42);
assert_eq!(tm.tag(u).unwrap(), 0);
assert_eq!(tm.handles_with_tag(42).unwrap(), vec![h]);
assert_eq!(tm.handles_with_tag(99).unwrap(), Vec::<u64>::new());
}
#[test]
fn handles_with_tag_accumulates_multiple_handles() {
let mut tm = fresh_manager();
tm.begin().unwrap();
let a = tm.allocate_tagged(b"a", 42).unwrap();
let b = tm.allocate_tagged(b"b", 42).unwrap();
let c = tm.allocate_tagged(b"c", 42).unwrap();
tm.commit().unwrap();
let mut got = tm.handles_with_tag(42).unwrap();
got.sort();
let mut want = vec![a, b, c];
want.sort();
assert_eq!(got, want);
assert_eq!(tm.tag(a).unwrap(), 42);
assert_eq!(tm.tag(c).unwrap(), 42);
}
#[test]
fn allocate_tagged_overflow_value_preserves_tag() {
let mut tm = fresh_manager();
let big = vec![0xABu8; MAX_INLINE_VALUE + 100];
tm.begin().unwrap();
let h = tm.allocate_tagged(&big, 77).unwrap();
tm.commit().unwrap();
assert_eq!(tm.tag(h).unwrap(), 77);
assert_eq!(tm.handles_with_tag(77).unwrap(), vec![h]);
assert_eq!(tm.read(h).unwrap(), big);
}
#[test]
fn update_preserves_immutable_tag() {
let mut tm = fresh_manager();
tm.begin().unwrap();
let h = tm.allocate_tagged(b"v1", 42).unwrap();
tm.commit().unwrap();
tm.begin().unwrap();
tm.update(h, b"v2").unwrap();
tm.commit().unwrap();
assert_eq!(tm.tag(h).unwrap(), 42);
assert_eq!(tm.handles_with_tag(42).unwrap(), vec![h]);
}
#[test]
fn delete_removes_tagged_chunk_from_index() {
let mut tm = fresh_manager();
tm.begin().unwrap();
let h = tm.allocate_tagged(b"row", 7).unwrap();
tm.commit().unwrap();
assert_eq!(tm.handles_with_tag(7).unwrap(), vec![h]);
tm.begin().unwrap();
tm.delete(h).unwrap();
tm.commit().unwrap();
assert_eq!(tm.handles_with_tag(7).unwrap(), Vec::<u64>::new());
}
#[test]
fn delete_tagged_rejects_wrong_tag() {
let mut tm = fresh_manager();
tm.begin().unwrap();
let h = tm.allocate_tagged(b"row", 5).unwrap();
let err = tm.delete_tagged(h, 6).unwrap_err();
assert!(
matches!(err, ChiselError::TagMismatch { handle, expected: 6, actual: 5 } if handle == h)
);
assert_eq!(tm.handles_with_tag(5).unwrap(), vec![h]);
assert!(!tm.is_poisoned());
tm.delete_tagged(h, 5).unwrap();
assert_eq!(tm.handles_with_tag(5).unwrap(), Vec::<u64>::new());
tm.commit().unwrap();
}
#[test]
fn delete_one_of_two_tagged_keeps_the_other() {
let mut tm = fresh_manager();
tm.begin().unwrap();
let a = tm.allocate_tagged(b"a", 7).unwrap();
let b = tm.allocate_tagged(b"b", 7).unwrap();
tm.commit().unwrap();
tm.begin().unwrap();
tm.delete(a).unwrap();
tm.commit().unwrap();
assert_eq!(tm.handles_with_tag(7).unwrap(), vec![b]);
assert_eq!(tm.tag(b).unwrap(), 7);
}
#[test]
fn reopen_preserves_committed_data() {
let file = NamedTempFile::new().unwrap();
let path = file.path().to_owned();
let handle;
{
let io = PageIo::open(&path, false).unwrap();
let cache = PageCache::new(
io,
64 * PAGE_SIZE as u64,
0,
crate::DrainInsertion::LruTail,
crate::SpillwayLocation::InMemory,
);
let mut txm = TransactionManager::create_new(cache, 2, None, None).unwrap();
txm.begin().unwrap();
handle = txm.allocate(b"persistent").unwrap();
txm.commit().unwrap();
}
{
let io = PageIo::open(&path, false).unwrap();
let cache = PageCache::new(
io,
64 * PAGE_SIZE as u64,
0,
crate::DrainInsertion::LruTail,
crate::SpillwayLocation::InMemory,
);
let txm = TransactionManager::open_existing(cache, None).unwrap();
let data = txm.read(handle).unwrap();
assert_eq!(data, b"persistent");
}
}
#[test]
fn delete_with_tag_drops_in_bounded_batches() {
let mut tm = fresh_manager();
tm.begin().unwrap();
let mut hs = Vec::new();
for i in 0..10u64 {
hs.push(tm.allocate_tagged(format!("row{i}").as_bytes(), 3).unwrap());
}
tm.commit().unwrap();
tm.begin().unwrap();
let (d1, c1) = tm.delete_with_tag(3, 4).unwrap();
assert_eq!(d1.len(), 4);
assert!(!c1);
let (d2, c2) = tm.delete_with_tag(3, 100).unwrap();
assert_eq!(d2.len(), 6);
assert!(c2);
tm.commit().unwrap();
assert_eq!(tm.handles_with_tag(3).unwrap(), Vec::<u64>::new());
for h in hs {
assert!(
matches!(tm.read(h), Err(ChiselError::InvalidHandle(_))),
"handle {h} must be InvalidHandle after delete_with_tag"
);
}
}
#[test]
fn delete_with_tag_exact_max_reports_complete() {
let mut tm = fresh_manager();
tm.begin().unwrap();
for i in 0..5u64 {
tm.allocate_tagged(format!("r{i}").as_bytes(), 8).unwrap();
}
tm.commit().unwrap();
tm.begin().unwrap();
let (deleted, complete) = tm.delete_with_tag(8, 5).unwrap();
assert_eq!(deleted.len(), 5);
assert!(
complete,
"deleting exactly all members in one max-sized pass must report complete"
);
tm.commit().unwrap();
assert_eq!(tm.handles_with_tag(8).unwrap(), Vec::<u64>::new());
}
#[test]
fn handle_table_depth_restored_after_rolled_back_grow() {
let mut tm = fresh_manager();
tm.begin().unwrap();
for i in 0..509u64 {
tm.allocate(format!("v{i}").as_bytes()).unwrap();
}
tm.commit().unwrap();
let baseline = tm.read(5).unwrap();
tm.begin().unwrap();
tm.allocate(b"grow").unwrap();
tm.rollback().unwrap();
assert_eq!(
tm.read(5).unwrap(),
baseline,
"committed handle lost after rolled-back grow"
);
tm.begin().unwrap();
let h = tm.allocate(b"after").unwrap();
tm.commit().unwrap();
assert_eq!(tm.read(h).unwrap(), b"after");
}
#[test]
fn handle_table_depth_restored_after_rollback_to_savepoint() {
let mut tm = fresh_manager();
tm.begin().unwrap();
for i in 0..509u64 {
tm.allocate(format!("v{i}").as_bytes()).unwrap();
}
tm.savepoint("sp").unwrap();
tm.allocate(b"grow").unwrap(); tm.rollback_to("sp").unwrap();
assert_eq!(tm.read(5).unwrap(), b"v4");
tm.commit().unwrap();
assert_eq!(tm.read(5).unwrap(), b"v4");
}
#[test]
fn commit_fsync_failure_poisons_at_each_of_the_three_fsyncs() {
for nth in 0..3u32 {
let mut tm = fresh_manager();
tm.begin().unwrap();
tm.allocate(b"v").unwrap();
tm.cache.borrow().io().arm_fault(Fault::FailFsync(nth));
let result = tm.commit();
assert!(
matches!(result, Err(ChiselError::IoError(_))),
"commit fsync #{} failure must surface IoError, got {result:?}",
nth + 1
);
assert!(tm.is_poisoned(), "fsync #{} failure must poison", nth + 1);
assert!(
matches!(tm.read(0), Err(ChiselError::Poisoned)),
"a poisoned manager rejects all further ops"
);
}
}
#[test]
fn commit_write_failure_poisons() {
let mut tm = fresh_manager();
tm.begin().unwrap();
let h = tm.allocate(b"v").unwrap();
let pid = tm
.handle_live_page_id(h)
.unwrap()
.expect("allocated value has a live data page");
tm.cache.borrow().io().arm_fault(Fault::FailWritePage(pid));
let result = tm.commit();
assert!(
matches!(result, Err(ChiselError::IoError(_))),
"commit write failure must surface IoError, got {result:?}"
);
assert!(tm.is_poisoned(), "write failure during commit must poison");
}
#[test]
fn encrypted_manager_holds_session_cipher() {
let file = NamedTempFile::new().unwrap();
let io = PageIo::open(file.path(), false).unwrap();
let cache = PageCache::new(
io,
1024 * PAGE_SIZE as u64,
0,
crate::DrainInsertion::LruTail,
crate::SpillwayLocation::InMemory,
);
let key = crate::crypto::Key::Raw(zeroize::Zeroizing::new(vec![0x5Au8; 32]));
let txm = TransactionManager::create_new(cache, 2, Some(key), None).unwrap();
assert!(
txm.cipher.is_some(),
"encrypted create must retain a session cipher"
);
}
#[test]
fn plaintext_manager_has_no_cipher() {
let file = NamedTempFile::new().unwrap();
let io = PageIo::open(file.path(), false).unwrap();
let cache = PageCache::new(
io,
1024 * PAGE_SIZE as u64,
0,
crate::DrainInsertion::LruTail,
crate::SpillwayLocation::InMemory,
);
let txm = TransactionManager::create_new(cache, 2, None, None).unwrap();
assert!(
txm.cipher.is_none(),
"plaintext create must have no session cipher"
);
}