use super::{create_compaction_stream, may_break_chain, pick_run_indexes};
use crate::{
AbstractTree, Config, KvSeparationOptions, SequenceNumberCounter, Table, TableId,
compaction::{Choice, CompactionStrategy, Input, state::CompactionState},
config::BlockSizePolicy,
version::Version,
};
use std::sync::Arc;
use test_log::test;
mod chain_completeness {
use super::may_break_chain;
use crate::{KeyRange, UserKey};
use test_log::test;
fn range(min: &str, max: &str) -> KeyRange {
KeyRange::new((UserKey::from(min.as_bytes()), UserKey::from(max.as_bytes())))
}
#[test]
fn a_table_older_than_the_chain_cannot_break_it() {
let cmp = crate::comparator::default_comparator();
assert!(!may_break_chain(
Some((1, 4)),
&range("a", "z"),
cmp.as_ref(),
b"m",
(10, 20),
|| panic!("the seqno range alone must settle this"),
));
}
#[test]
fn a_table_newer_than_the_chain_cannot_break_it() {
let cmp = crate::comparator::default_comparator();
assert!(!may_break_chain(
Some((30, 40)),
&range("a", "z"),
cmp.as_ref(),
b"m",
(10, 20),
|| panic!("the seqno range alone must settle this"),
));
}
#[test]
fn a_table_beside_the_key_cannot_break_the_chain() {
let cmp = crate::comparator::default_comparator();
assert!(!may_break_chain(
Some((10, 20)),
&range("a", "f"),
cmp.as_ref(),
b"m",
(10, 20),
|| panic!("the key range alone must settle this"),
));
}
#[test]
fn a_table_spanning_the_key_defers_to_its_filter() {
let cmp = crate::comparator::default_comparator();
assert!(!may_break_chain(
Some((10, 20)),
&range("a", "z"),
cmp.as_ref(),
b"m",
(10, 20),
|| false,
));
assert!(may_break_chain(
Some((10, 20)),
&range("a", "z"),
cmp.as_ref(),
b"m",
(10, 20),
|| true,
));
}
#[test]
fn an_unknown_seqno_range_is_assumed_to_overlap() {
let cmp = crate::comparator::default_comparator();
assert!(may_break_chain(
None,
&range("a", "z"),
cmp.as_ref(),
b"m",
(10, 20),
|| true,
));
}
}
#[test]
fn interrupted_serial_merge_keeps_every_key() -> crate::Result<()> {
use core::sync::atomic::Ordering;
const N: u64 = 64;
let key = |i: u64| format!("key_{i:04}");
let dir = tempfile::tempdir()?;
let config = Config::new(
&dir,
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.compaction_threads(1);
let failpoint = config.stop_serial_merge_after_first_item.clone();
let tree = config.open()?;
for i in 0..N {
tree.insert(key(i), "v", i);
}
tree.flush_active_memtable(0)?;
for i in N..(2 * N) {
tree.insert(key(i), "v", i);
}
tree.flush_active_memtable(0)?;
for i in 0..(2 * N) {
assert!(
tree.get(key(i), crate::SeqNo::MAX)?.is_some(),
"precondition: {} is readable before the compaction",
key(i),
);
}
failpoint.store(true, Ordering::SeqCst);
let result = tree.major_compact(u64::MAX, 0);
assert!(
result.is_err(),
"an interrupted merge must refuse, not commit a truncated output",
);
for i in 0..(2 * N) {
assert!(
tree.get(key(i), crate::SeqNo::MAX)?.is_some(),
"{} was lost by the interrupted compaction",
key(i),
);
}
drop(tree);
let reopened = Config::new(
&dir,
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.open()?;
for i in 0..(2 * N) {
assert!(
reopened.get(key(i), crate::SeqNo::MAX)?.is_some(),
"{} was lost across the reopen after an interrupted compaction",
key(i),
);
}
Ok(())
}
const TIGHT_SPACE_KEYS: u64 = 2_000;
fn tight_space_key(i: u64) -> String {
format!("key{i:08}")
}
struct FirstByteComparator;
impl crate::comparator::UserComparator for FirstByteComparator {
fn name(&self) -> &'static str {
"test-first-byte"
}
fn compare(&self, a: &[u8], b: &[u8]) -> core::cmp::Ordering {
a.first().cmp(&b.first())
}
}
#[test]
fn boundary_candidates_dedups_comparator_equal_keys() {
let cmp: crate::comparator::SharedComparator = Arc::new(FirstByteComparator);
let keys = vec![
crate::UserKey::from("a1"),
crate::UserKey::from("a2"),
crate::UserKey::from("b1"),
];
let out = super::boundary_candidates(keys, &cmp);
assert_eq!(
out.len(),
1,
"comparator-equal keys must collapse to a single boundary candidate",
);
assert_eq!(
out.first().and_then(|k| k.first()),
Some(&b'a'),
"the surviving boundary should be from the deduped a-group",
);
}
#[cfg(feature = "parallel")]
#[test]
fn failed_subcompaction_rolls_back_and_restores_inputs() -> crate::Result<()> {
use core::sync::atomic::Ordering;
const N: u64 = 4_000;
let key = |i: u64| format!("key_{i:08}");
let val = |i: u64, generation: u64| format!("g{generation}-{i}-{}", "x".repeat(40));
let dir = tempfile::tempdir()?;
let config = Config::new(
&dir,
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.data_block_size_policy(BlockSizePolicy::all(512))
.compaction_threads(4)
.subcompaction_min_bytes(0)
.with_kv_separation(Some(
KvSeparationOptions::default().separation_threshold(16),
));
let failpoint = config.fail_one_subcompaction.clone();
let tree = config.open()?;
for i in 0..N {
tree.insert(key(i), val(i, 0), i);
}
tree.flush_active_memtable(0)?;
tree.major_compact(4_096, 0)?;
for i in 0..N {
tree.insert(key(i), val(i, 1), N + i);
}
tree.flush_active_memtable(0)?;
let tables_before = tree.table_count();
failpoint.store(true, Ordering::SeqCst);
let result = tree.major_compact(u64::MAX, 0);
assert!(
result.is_err(),
"a failing sub-compaction range must abort the compaction",
);
assert!(
!failpoint.load(Ordering::SeqCst),
"the failpoint should have fired and disarmed itself",
);
assert_eq!(
tree.table_count(),
tables_before,
"rollback must leave nothing partially installed",
);
for i in 0..N {
assert_eq!(
tree.get(key(i), crate::MAX_SEQNO)?.as_deref(),
Some(val(i, 1).as_bytes()),
"value for {} must survive the rolled-back compaction",
key(i),
);
}
Ok(())
}
#[test]
fn tight_space_crash_after_first_slice_recovers_all_keys_on_reopen() -> crate::Result<()> {
let dir = tempfile::tempdir()?;
let mem = crate::fs::MemFs::with_capacity(u64::MAX);
let reopened = tight_space_crash_and_reopen(
dir.path(),
Arc::new(mem.clone()),
|used| mem.set_capacity(used + used / 4),
|| mem.punched_bytes(),
)?;
for i in 0..TIGHT_SPACE_KEYS {
assert!(
reopened
.get(tight_space_key(i).as_bytes(), crate::MAX_SEQNO)?
.is_some(),
"key {i} lost after a crash mid tight-space compaction + reopen",
);
}
Ok(())
}
#[test]
fn open_rewrites_a_sidecar_that_disagrees_with_the_manifest() -> crate::Result<()> {
use crate::fs::Fs;
let dir = tempfile::tempdir()?;
let mem = crate::fs::MemFs::with_capacity(u64::MAX);
let fs: Arc<dyn Fs> = Arc::new(mem.clone());
let reopened = tight_space_crash_and_reopen(
dir.path(),
Arc::clone(&fs),
|used| mem.set_capacity(used + used / 4),
|| mem.punched_bytes(),
)?;
let version = reopened.current_version();
let Some(restricted) = version
.iter_tables()
.find(|t| t.restrict_lower_bound().is_some())
else {
panic!("the crashed tight-space slice must leave a restricted table");
};
let path = restricted.path.clone();
let table_id = restricted.id();
let Some(bound) = restricted.restrict_lower_bound().cloned() else {
panic!("the table was selected by having a restriction bound");
};
let mut stale = bound.to_vec();
stale.truncate(bound.len().saturating_sub(1));
crate::restrict_bound::write(
&*fs,
&path,
None,
table_id,
&stale,
crate::fs::SyncMode::Normal,
)?;
drop(version);
drop(reopened);
let _tree = Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.with_shared_fs(Arc::new(mem))
.open()?;
let crate::restrict_bound::SidecarRead::Present(id, recorded) =
crate::restrict_bound::read(&*fs, &path, None)?
else {
panic!("the sidecar must be present after the open");
};
assert_eq!(id, table_id, "the sidecar names its own table");
assert_eq!(
recorded,
bound.to_vec(),
"an existing sidecar that disagrees with the manifest is republished \
from the manifest, which is the authority on the bound",
);
Ok(())
}
#[test]
fn open_rewrites_a_missing_restriction_sidecar() -> crate::Result<()> {
use crate::fs::Fs;
let dir = tempfile::tempdir()?;
let mem = crate::fs::MemFs::with_capacity(u64::MAX);
let fs: Arc<dyn Fs> = Arc::new(mem.clone());
let reopened = tight_space_crash_and_reopen(
dir.path(),
Arc::clone(&fs),
|used| mem.set_capacity(used + used / 4),
|| mem.punched_bytes(),
)?;
let version = reopened.current_version();
let Some(restricted) = version
.iter_tables()
.find(|t| t.restrict_lower_bound().is_some())
else {
panic!("the crashed tight-space slice must leave a restricted table");
};
let sidecar = crate::restrict_bound::sidecar_path(&restricted.path);
fs.remove_file(&sidecar)?;
assert!(
fs.metadata(&sidecar).is_err(),
"the fixture must start from a MISSING sidecar",
);
drop(version);
drop(reopened);
let _tree = Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.with_shared_fs(Arc::new(mem))
.open()?;
assert!(
fs.metadata(&sidecar).is_ok(),
"the open must republish the sidecar from the manifest'\u{2019}s own \
restriction, or a later manifest-loss repair republishes the whole \
input beside the slice output",
);
Ok(())
}
#[test]
fn an_open_whose_install_fails_leaves_no_sidecar_it_derived() -> crate::Result<()> {
use crate::fs::{Fault, FaultFs, FaultOp, FaultRule, Fs};
let dir = tempfile::tempdir()?;
let mem = crate::fs::MemFs::with_capacity(u64::MAX);
let fs: Arc<dyn Fs> = Arc::new(mem.clone());
let reopened = tight_space_crash_and_reopen(
dir.path(),
Arc::clone(&fs),
|used| mem.set_capacity(used + used / 4),
|| mem.punched_bytes(),
)?;
let version = reopened.current_version();
let Some(restricted) = version
.iter_tables()
.find(|t| t.restrict_lower_bound().is_some())
else {
panic!("the crashed tight-space slice must leave a restricted table");
};
let sidecar = crate::restrict_bound::sidecar_path(&restricted.path);
fs.remove_file(&sidecar)?;
drop(version);
drop(reopened);
let faulty = FaultFs::new(mem.clone());
faulty.injector().arm(
FaultRule::new(FaultOp::Rename, Fault::Error(crate::io::ErrorKind::Other))
.on_path(dir.path().join("v").to_string_lossy())
.once(),
);
let failed = Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.with_shared_fs(Arc::new(faulty))
.open();
assert!(failed.is_err(), "self-check: the install failed");
assert!(
fs.metadata(&sidecar).is_err(),
"no sidecar is published for a manifest the open did not install",
);
let _tree = Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.with_shared_fs(Arc::new(mem))
.open()?;
assert!(
fs.metadata(&sidecar).is_ok(),
"the open that installs the manifest republishes it",
);
Ok(())
}
mod capfs {
use crate::fs::{Fs, FsCapabilities, FsDirEntry, FsFile, FsMetadata, FsOpenOptions, StdFs};
use crate::io;
use core::sync::atomic::{AtomicU64, Ordering};
use std::io::{Read as _, Seek as _, SeekFrom};
use std::path::Path;
use std::sync::Arc;
#[derive(Clone)]
pub(super) struct CapacityFs {
available: Arc<AtomicU64>,
punched: Arc<AtomicU64>,
link_count: Arc<AtomicU64>,
}
impl CapacityFs {
pub(super) fn new() -> Self {
Self {
available: Arc::new(AtomicU64::new(u64::MAX)),
punched: Arc::new(AtomicU64::new(0)),
link_count: Arc::new(AtomicU64::new(1)),
}
}
pub(super) fn set_link_count(&self, n: u64) {
self.link_count.store(n, Ordering::Relaxed);
}
pub(super) fn set_available_space(&self, bytes: u64) {
self.available.store(bytes, Ordering::Relaxed);
}
pub(super) fn punched_bytes(&self) -> u64 {
self.punched.load(Ordering::Relaxed)
}
}
impl Fs for CapacityFs {
fn open(&self, path: &Path, opts: &FsOpenOptions) -> io::Result<Box<dyn FsFile>> {
StdFs.open(path, opts)
}
fn create_dir_all(&self, path: &Path) -> io::Result<()> {
StdFs.create_dir_all(path)
}
fn read_dir(&self, path: &Path) -> io::Result<Vec<FsDirEntry>> {
StdFs.read_dir(path)
}
fn remove_file(&self, path: &Path) -> io::Result<()> {
StdFs.remove_file(path)
}
fn remove_dir_all(&self, path: &Path) -> io::Result<()> {
StdFs.remove_dir_all(path)
}
fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
StdFs.rename(from, to)
}
fn metadata(&self, path: &Path) -> io::Result<FsMetadata> {
StdFs.metadata(path)
}
fn sync_directory(&self, path: &Path) -> io::Result<()> {
StdFs.sync_directory(path)
}
fn exists(&self, path: &Path) -> io::Result<bool> {
StdFs.exists(path)
}
fn hard_link_count(&self, _path: &Path) -> io::Result<u64> {
Ok(self.link_count.load(Ordering::Relaxed))
}
fn backend_id(&self) -> Option<u64> {
StdFs.backend_id()
}
fn volume_id(&self, path: &Path) -> Option<u64> {
StdFs.volume_id(path)
}
fn available_space(&self, _path: &Path) -> io::Result<u64> {
Ok(self.available.load(Ordering::Relaxed))
}
fn capabilities(&self, path: &Path) -> FsCapabilities {
FsCapabilities {
punch_hole: true,
..StdFs.capabilities(path)
}
}
fn punch_hole(&self, path: &Path, offset: u64, len: u64) -> io::Result<()> {
if len == 0 {
return Ok(());
}
let mut f = StdFs.open(path, &FsOpenOptions::new().write(true))?;
let file_len = f.metadata()?.len;
let punch_len = len.min(file_len.saturating_sub(offset));
if punch_len == 0 {
return Ok(());
}
f.seek(SeekFrom::Start(offset))?;
std::io::copy(&mut std::io::repeat(0u8).take(punch_len), &mut f)?;
f.sync_all()?;
self.punched.fetch_add(punch_len, Ordering::Relaxed);
Ok(())
}
}
}
fn tight_space_crash_and_reopen(
dir: &std::path::Path,
shared_fs: Arc<dyn crate::fs::Fs>,
set_capacity: impl FnOnce(u64),
punched_bytes: impl Fn() -> u64,
) -> crate::Result<crate::AnyTree> {
use core::sync::atomic::Ordering;
let config = Config::new(
dir,
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.data_block_size_policy(BlockSizePolicy::all(512))
.with_shared_fs(Arc::clone(&shared_fs));
let failpoint = config.fail_tight_after_first_slice.clone();
let tree = match config.open()? {
crate::AnyTree::Standard(t) => t,
crate::AnyTree::Blob(_) => panic!("expected Standard tree"),
};
for i in 0..TIGHT_SPACE_KEYS {
tree.insert(tight_space_key(i).as_bytes(), vec![0xCDu8; 64], i);
}
tree.flush_active_memtable(0)?;
let used = tree.storage_stats()?.used_bytes;
set_capacity(used);
tree.update_runtime_config(|c| {
c.storage_admission_check = true;
c.tight_space_compaction = true;
})?;
failpoint.store(true, Ordering::SeqCst);
assert!(
tree.major_compact(64 * 1024 * 1024, 0).is_err(),
"the crash failpoint must abort the tight-space compaction",
);
assert!(
!failpoint.load(Ordering::SeqCst),
"the crash failpoint must have fired and disarmed",
);
assert!(
punched_bytes() > 0,
"the first slice must have punched before the crash",
);
drop(tree);
Config::new(
dir,
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.with_shared_fs(shared_fs)
.open()
}
#[cfg(feature = "metrics")]
#[test]
fn a_failed_tight_space_pass_counts_the_work_of_the_slices_it_installed() -> crate::Result<()> {
use core::sync::atomic::Ordering;
let dir = tempfile::tempdir()?;
let mem = crate::fs::MemFs::with_capacity(u64::MAX);
let config = Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.data_block_size_policy(BlockSizePolicy::all(512))
.with_shared_fs(Arc::new(mem.clone()));
let failpoint = config.fail_tight_after_first_slice.clone();
let crate::AnyTree::Standard(tree) = config.open()? else {
panic!("expected Standard tree");
};
for i in 0..TIGHT_SPACE_KEYS {
tree.insert(tight_space_key(i).as_bytes(), vec![0xCDu8; 64], i);
}
tree.flush_active_memtable(0)?;
let used = tree.storage_stats()?.used_bytes;
mem.set_capacity(used + used / 8);
tree.update_runtime_config(|c| {
c.storage_admission_check = true;
c.tight_space_compaction = true;
})?;
failpoint.store(true, Ordering::SeqCst);
assert!(tree.major_compact(64 * 1024 * 1024, 0).is_err());
assert!(
!failpoint.load(Ordering::SeqCst),
"the failure came after the first slice"
);
assert!(
tree.metrics().compaction_work_measured().written > 0,
"the installed slice's tables count as written",
);
Ok(())
}
#[test]
fn tight_space_pass_stops_while_a_reader_holds_what_a_slice_consumed() -> crate::Result<()> {
for (part, of) in [(1, 4), (3, 5)] {
reader_holds_a_slices_inputs((part, of))?;
}
Ok(())
}
fn reader_holds_a_slices_inputs((part, of): (u64, u64)) -> crate::Result<()> {
let dir = tempfile::tempdir()?;
let mem = crate::fs::MemFs::with_capacity(u64::MAX);
let crate::AnyTree::Standard(tree) = Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.data_block_size_policy(BlockSizePolicy::all(512))
.with_shared_fs(Arc::new(mem.clone()))
.open()?
else {
panic!("expected Standard tree");
};
for i in 0..TIGHT_SPACE_KEYS {
tree.insert(tight_space_key(i).as_bytes(), vec![0xCDu8; 64], i);
}
tree.flush_active_memtable(0)?;
let used = tree.storage_stats()?.used_bytes;
mem.set_capacity(used + used * part / of);
tree.update_runtime_config(|c| {
c.storage_admission_check = true;
c.tight_space_compaction = true;
})?;
let restricted = |tree: &crate::Tree| {
tree.current_version()
.iter_tables()
.any(|table| table.restrict_lower_bound().is_some())
};
let reader = tree.current_version();
tree.major_compact(64 * 1024 * 1024, 0)?;
assert!(
restricted(&tree),
"the pass stopped after its first slice, {part}/{of} free"
);
for i in 0..TIGHT_SPACE_KEYS {
assert!(
tree.get(tight_space_key(i).as_bytes(), crate::MAX_SEQNO)?
.is_some(),
"key {i}"
);
}
drop(reader);
tree.major_compact(64 * 1024 * 1024, 0)?;
assert!(
!restricted(&tree),
"the next pass finishes the merge, {part}/{of} free"
);
Ok(())
}
#[test]
fn tight_space_slice_with_its_room_taken_meanwhile_does_not_run() -> crate::Result<()> {
use core::sync::atomic::Ordering;
let dir = tempfile::tempdir()?;
let mem = crate::fs::MemFs::with_capacity(u64::MAX);
let config = Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.data_block_size_policy(BlockSizePolicy::all(512))
.with_shared_fs(Arc::new(mem.clone()));
let squeeze = Arc::clone(&config.tight_slice_squeeze);
let crate::AnyTree::Standard(tree) = config.open()? else {
panic!("expected Standard tree");
};
for i in 0..TIGHT_SPACE_KEYS {
tree.insert(tight_space_key(i).as_bytes(), vec![0xCDu8; 64], i);
}
tree.flush_active_memtable(0)?;
mem.set_capacity(tree.storage_stats()?.used_bytes + 8 * 1_024);
tree.update_runtime_config(|c| {
c.storage_admission_check = true;
c.tight_space_compaction = true;
})?;
squeeze.store(u64::MAX / 4, Ordering::SeqCst);
let result = tree.major_compact(64 * 1024 * 1024, 0)?;
assert_eq!(
result.action,
crate::compaction::CompactionAction::Nothing,
"{result:?}"
);
assert_eq!(mem.punched_bytes(), 0, "no slice ran");
for i in 0..TIGHT_SPACE_KEYS {
assert!(
tree.get(tight_space_key(i).as_bytes(), crate::MAX_SEQNO)?
.is_some()
);
}
Ok(())
}
#[test]
fn manifest_loss_in_the_unwritten_sidecar_window_resolves_to_one_history() -> crate::Result<()> {
use crate::fs::{Fault, FaultFs, FaultOp, FaultRule, Fs};
use crate::io::ErrorKind;
use core::sync::atomic::Ordering;
let dir = tempfile::tempdir()?;
let mem = crate::fs::MemFs::with_capacity(u64::MAX);
let fault = FaultFs::new(mem.clone());
fault.injector().arm(
FaultRule::new(FaultOp::Open, Fault::Error(ErrorKind::PermissionDenied))
.on_path(".restrict-bound"),
);
let config = Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.data_block_size_policy(BlockSizePolicy::all(512))
.with_fs(fault);
let failpoint = config.fail_tight_after_first_slice.clone();
let tree = match config.open()? {
crate::AnyTree::Standard(t) => t,
crate::AnyTree::Blob(_) => panic!("expected Standard tree"),
};
for i in 0..TIGHT_SPACE_KEYS {
tree.insert(tight_space_key(i).as_bytes(), vec![0xCDu8; 64], i);
}
tree.flush_active_memtable(0)?;
let used = tree.storage_stats()?.used_bytes;
mem.set_capacity(used + used / 4);
tree.update_runtime_config(|c| {
c.storage_admission_check = true;
c.tight_space_compaction = true;
})?;
failpoint.store(true, Ordering::SeqCst);
assert!(
tree.major_compact(64 * 1024 * 1024, 0).is_err(),
"the crash failpoint must abort the tight-space compaction",
);
assert_eq!(
mem.punched_bytes(),
0,
"with the sidecar write refused, no punch may arm",
);
drop(tree);
for e in mem.read_dir(dir.path())? {
let is_version = e
.file_name
.strip_prefix('v')
.or_else(|| e.file_name.strip_prefix("edits-"))
.is_some_and(|rest| rest.parse::<u64>().is_ok());
if is_version {
mem.remove_file(&e.path)?;
}
}
let report = Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.with_shared_fs(Arc::new(mem.clone()))
.repair()?;
assert!(
report
.excluded_files
.iter()
.any(|(_, reason)| reason.contains("derived output")),
"the slice outputs must be excluded as derived — publishing both \
histories would double-apply merge operands: {report:?}",
);
assert_eq!(
report.unreadable, 0,
"a healthy redundancy exclusion is not an unreadable file: {report:?}",
);
let reopened = Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.with_shared_fs(Arc::new(mem))
.open()?;
for i in 0..TIGHT_SPACE_KEYS {
assert!(
reopened
.get(tight_space_key(i).as_bytes(), crate::MAX_SEQNO)?
.is_some(),
"key {i} lost across the sidecar window + manifest loss",
);
}
Ok(())
}
#[test]
fn restricted_view_passes_every_reconcile_gate() -> crate::Result<()> {
let dir = tempfile::tempdir()?;
let fs = capfs::CapacityFs::new();
let reopened = tight_space_crash_and_reopen(
dir.path(),
Arc::new(fs.clone()),
|used| fs.set_available_space(used / 4),
|| fs.punched_bytes(),
)?;
let integrity = crate::verify::verify_integrity(&reopened);
assert!(
integrity.is_ok(),
"verify_integrity must pass on a legitimately restricted table, got {:?}",
integrity.errors,
);
let version = reopened.current_version();
let Some(restricted) = version
.iter_tables()
.find(|t| t.restrict_lower_bound().is_some())
else {
panic!("the punched input must reopen as a restricted table");
};
restricted.verify_blob_links()?;
restricted.verify_tli_mirrors()?;
restricted.verify_block_layout()?;
if let Err((gate, e)) = restricted.verify_reconcile_gates(None, false) {
panic!("the healthy restricted suffix must pass every gate, {gate:?} refused it: {e}");
}
Ok(())
}
#[cfg(feature = "page_ecc")]
#[test]
fn unshare_for_heal_reproduces_the_source_faithfully() -> crate::Result<()> {
use crate::fs::{Fs, FsOpenOptions, SyncMode};
let dir = tempfile::tempdir()?;
let fs = capfs::CapacityFs::new();
let reopened = tight_space_crash_and_reopen(
dir.path(),
Arc::new(fs.clone()),
|used| fs.set_available_space(used / 4),
|| fs.punched_bytes(),
)?;
let version = reopened.current_version();
let Some(restricted) = version
.iter_tables()
.find(|t| t.restrict_lower_bound().is_some())
else {
panic!("the punched input must reopen as a restricted table");
};
assert!(
restricted.punch_offset()? > 0,
"the restricted table has a punched prefix",
);
let shared: Arc<dyn Fs> = Arc::new(fs);
let read_all = |path: &std::path::Path| -> crate::Result<alloc::vec::Vec<u8>> {
let f = shared.open(path, &FsOpenOptions::new().read(true))?;
let len = usize::try_from(f.metadata()?.len).unwrap_or(usize::MAX);
let mut buf = alloc::vec![0u8; len];
let mut off = 0usize;
while off < len {
let got = f.read_at(
buf.get_mut(off..).unwrap_or(&mut []),
u64::try_from(off).unwrap_or(u64::MAX),
)?;
if got == 0 {
break;
}
off += got;
}
Ok(buf)
};
let src = read_all(&restricted.path)?;
assert!(src.contains(&0), "the source has punched data blocks");
assert!(src.iter().any(|&b| b != 0), "the source has live blocks");
let source = shared.open(&restricted.path, &FsOpenOptions::new().read(true))?;
let _copy = match restricted.unshare_for_heal(source.as_ref(), SyncMode::Normal) {
Ok(copy) => copy,
Err(e) => panic!("unshare_for_heal must succeed on a restricted table: {e}"),
};
let copy = read_all(&restricted.path)?;
assert_eq!(
copy, src,
"the detached heal copy must reproduce the source byte-for-byte",
);
Ok(())
}
#[test]
fn tight_space_writes_a_restrict_bound_sidecar_matching_the_manifest_bound() -> crate::Result<()> {
let dir = tempfile::tempdir()?;
let fs = capfs::CapacityFs::new();
let reopened = tight_space_crash_and_reopen(
dir.path(),
Arc::new(fs.clone()),
|used| fs.set_available_space(used / 4),
|| fs.punched_bytes(),
)?;
let version = reopened.current_version();
let Some(restricted) = version
.iter_tables()
.find(|t| t.restrict_lower_bound().is_some())
else {
panic!("the punched input must reopen as a restricted table");
};
let Some(manifest_bound) = restricted.restrict_lower_bound().cloned() else {
panic!("restricted table has a manifest bound");
};
match crate::restrict_bound::read(&fs, &restricted.path, None)? {
crate::restrict_bound::SidecarRead::Present(_id, bound) => {
assert_eq!(
bound.as_slice(),
manifest_bound.as_ref(),
"the sidecar bound must equal the manifest restriction bound",
);
}
_ => panic!("a punched table must carry a valid .restrict-bound sidecar"),
}
Ok(())
}
#[test]
fn tight_space_sidecar_write_fault_is_nonfatal_and_recovers() -> crate::Result<()> {
use crate::fs::{Fault, FaultFs, FaultOp, FaultRule};
use crate::io::ErrorKind;
let dir = tempfile::tempdir()?;
let capfs = capfs::CapacityFs::new();
let fault = FaultFs::new(capfs.clone());
let injector = fault.injector();
let shared: Arc<dyn crate::fs::Fs> = Arc::new(fault);
let config = Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.data_block_size_policy(BlockSizePolicy::all(512))
.with_shared_fs(Arc::clone(&shared));
let tree = match config.open()? {
crate::AnyTree::Standard(t) => t,
crate::AnyTree::Blob(_) => panic!("expected Standard tree"),
};
for i in 0..TIGHT_SPACE_KEYS {
tree.insert(tight_space_key(i).as_bytes(), vec![0xCDu8; 64], i);
}
tree.flush_active_memtable(0)?;
let used = tree.storage_stats()?.used_bytes;
capfs.set_available_space(used / 4);
tree.update_runtime_config(|c| {
c.storage_admission_check = true;
c.tight_space_compaction = true;
})?;
injector.arm(
FaultRule::new(FaultOp::Open, Fault::Error(ErrorKind::Other)).on_path("restrict-bound"),
);
let result = match tree.major_compact(64 * 1024 * 1024, 0) {
Ok(r) => r,
Err(e) => panic!("a post-commit sidecar-write fault must not fail the compaction: {e:?}"),
};
assert_eq!(
result.action,
crate::compaction::CompactionAction::Merged,
"tight-space must have engaged and merged, got {result:?}",
);
assert_eq!(
capfs.punched_bytes(),
0,
"an input whose restrict-bound sidecar failed to land must stay unpunched",
);
drop(tree);
let reopened = match Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.with_shared_fs(Arc::clone(&shared))
.open()?
{
crate::AnyTree::Standard(t) => t,
crate::AnyTree::Blob(_) => panic!("expected Standard tree"),
};
for i in 0..TIGHT_SPACE_KEYS {
let got = reopened.get(tight_space_key(i).as_bytes(), crate::MAX_SEQNO)?;
assert_eq!(
got.as_deref(),
Some(&[0xCDu8; 64][..]),
"key {i} must read its latest value after the sidecar-fault reopen",
);
}
Ok(())
}
#[test]
fn tight_space_slice_defers_the_compaction_filter() -> crate::Result<()> {
use crate::compaction::filter::{
CompactionFilter, Context as FilterContext, Factory, ItemAccessor, Verdict,
};
let victim = tight_space_key(100);
struct DropVictim(Vec<u8>);
impl CompactionFilter for DropVictim {
fn filter_item(
&mut self,
item: ItemAccessor<'_>,
_ctx: &FilterContext,
) -> crate::Result<Verdict> {
if item.key().as_ref() == self.0.as_slice() {
Ok(Verdict::Destroy)
} else {
Ok(Verdict::Keep)
}
}
}
struct DropVictimFactory(Vec<u8>);
impl Factory for DropVictimFactory {
fn name(&self) -> &'static str {
"drop-victim"
}
fn make_filter(&self, _ctx: &FilterContext) -> Box<dyn CompactionFilter> {
Box::new(DropVictim(self.0.clone()))
}
}
let dir = tempfile::tempdir()?;
let capfs = capfs::CapacityFs::new();
let shared: Arc<dyn crate::fs::Fs> = Arc::new(capfs.clone());
let config = Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.data_block_size_policy(BlockSizePolicy::all(512))
.with_compaction_filter_factory(Some(Arc::new(DropVictimFactory(
victim.clone().into_bytes(),
))))
.with_shared_fs(Arc::clone(&shared));
let tree = match config.open()? {
crate::AnyTree::Standard(t) => t,
crate::AnyTree::Blob(_) => panic!("expected Standard tree"),
};
for i in 0..TIGHT_SPACE_KEYS {
tree.insert(tight_space_key(i).as_bytes(), vec![0xCDu8; 64], i);
}
tree.flush_active_memtable(0)?;
let used = tree.storage_stats()?.used_bytes;
capfs.set_available_space(used / 4);
tree.update_runtime_config(|c| {
c.storage_admission_check = true;
c.tight_space_compaction = true;
})?;
let result = tree.major_compact(64 * 1024 * 1024, 0)?;
assert_eq!(
result.action,
crate::compaction::CompactionAction::Merged,
"tight-space must have engaged and merged, got {result:?}",
);
assert!(
tree.get(victim.as_bytes(), crate::MAX_SEQNO)?.is_some(),
"the tight-space slice must DEFER the compaction filter (its output \
must be a superset shadowing any surviving input prefix); the filter \
applies on the next normal compaction instead",
);
Ok(())
}
#[test]
fn tight_space_sidecar_fault_must_not_resurrect_evicted_deletes() -> crate::Result<()> {
use crate::AbstractTree;
use crate::fs::{Fault, FaultFs, FaultOp, FaultRule};
use crate::io::ErrorKind;
let dir = tempfile::tempdir()?;
let capfs = capfs::CapacityFs::new();
let fault = FaultFs::new(capfs.clone());
let injector = fault.injector();
let shared: Arc<dyn crate::fs::Fs> = Arc::new(fault);
let config = Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.data_block_size_policy(BlockSizePolicy::all(512))
.with_shared_fs(Arc::clone(&shared));
let tree = match config.open()? {
crate::AnyTree::Standard(t) => t,
crate::AnyTree::Blob(_) => panic!("expected Standard tree"),
};
let victim = tight_space_key(100);
for i in 0..TIGHT_SPACE_KEYS {
tree.insert(tight_space_key(i).as_bytes(), vec![0xCDu8; 64], i);
}
tree.flush_active_memtable(0)?;
tree.remove(victim.as_bytes(), TIGHT_SPACE_KEYS + 1);
tree.flush_active_memtable(0)?;
let used = tree.storage_stats()?.used_bytes;
capfs.set_available_space(used / 4);
tree.update_runtime_config(|c| {
c.storage_admission_check = true;
c.tight_space_compaction = true;
})?;
injector.arm(
FaultRule::new(FaultOp::Open, Fault::Error(ErrorKind::Other)).on_path("restrict-bound"),
);
let result = tree.major_compact(64 * 1024 * 1024, TIGHT_SPACE_KEYS + 2)?;
assert_eq!(
result.action,
crate::compaction::CompactionAction::Merged,
"tight-space must have engaged and merged, got {result:?}",
);
injector.clear();
assert_eq!(
tree.get(victim.as_bytes(), crate::MAX_SEQNO)?,
None,
"the tombstoned key is gone on the live tree",
);
drop(tree);
for e in shared.read_dir(dir.path())? {
let is_version = e
.file_name
.strip_prefix('v')
.or_else(|| e.file_name.strip_prefix("edits-"))
.is_some_and(|rest| rest.parse::<u64>().is_ok());
if is_version {
shared.remove_file(&e.path)?;
}
}
Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.with_shared_fs(Arc::clone(&shared))
.repair()?;
let reopened = match Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.with_shared_fs(Arc::clone(&shared))
.open()?
{
crate::AnyTree::Standard(t) => t,
crate::AnyTree::Blob(_) => panic!("expected Standard tree"),
};
assert_eq!(
reopened.get(victim.as_bytes(), crate::MAX_SEQNO)?,
None,
"a tombstone evicted by the last-level slice must never resurrect \
through an unpunched, sidecarless input after a manifest-loss repair",
);
Ok(())
}
#[test]
fn tight_space_install_failure_rolls_back_outputs_and_leaves_no_sidecar() -> crate::Result<()> {
use crate::fs::{Fault, FaultFs, FaultOp, FaultRule};
use crate::io::ErrorKind;
let dir = tempfile::tempdir()?;
let capfs = capfs::CapacityFs::new();
let fault = FaultFs::new(capfs.clone());
let injector = fault.injector();
let shared: Arc<dyn crate::fs::Fs> = Arc::new(fault);
let config = Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.data_block_size_policy(BlockSizePolicy::all(512))
.with_shared_fs(Arc::clone(&shared));
let tree = match config.open()? {
crate::AnyTree::Standard(t) => t,
crate::AnyTree::Blob(_) => panic!("expected Standard tree"),
};
for i in 0..TIGHT_SPACE_KEYS {
tree.insert(tight_space_key(i).as_bytes(), vec![0xCDu8; 64], i);
}
tree.flush_active_memtable(0)?;
let used = tree.storage_stats()?.used_bytes;
let tables_dir = dir.path().join(crate::file::TABLES_FOLDER);
let numeric_files = || -> Vec<(std::ffi::OsString, u64)> {
std::fs::read_dir(&tables_dir)
.into_iter()
.flatten()
.flatten()
.filter(|e| {
e.file_name()
.to_string_lossy()
.bytes()
.all(|b| b.is_ascii_digit())
})
.filter_map(|e| e.metadata().ok().map(|m| (e.file_name(), m.len())))
.collect()
};
let inputs: crate::HashSet<std::ffi::OsString> =
numeric_files().into_iter().map(|(name, _)| name).collect();
assert!(
!inputs.is_empty(),
"the flush produced at least one input SST"
);
let sidecar_count = || -> usize {
std::fs::read_dir(&tables_dir)
.into_iter()
.flatten()
.flatten()
.filter(|e| e.file_name().to_string_lossy().ends_with(".restrict-bound"))
.count()
};
capfs.set_available_space(used / 4);
tree.update_runtime_config(|c| {
c.storage_admission_check = true;
c.tight_space_compaction = true;
})?;
injector.arm(
FaultRule::new(FaultOp::Write, Fault::Error(ErrorKind::Other))
.on_path("edits")
.once(),
);
let result = tree.major_compact(64 * 1024 * 1024, 0);
assert!(
matches!(&result, Err(crate::Error::Io(e)) if e.kind() == ErrorKind::Other),
"the tight-space compaction must abort with the injected install fault, got {result:?}",
);
for (name, len) in numeric_files() {
assert!(
len == 0 || inputs.contains(&name),
"a finalized slice output ({name:?}, {len} bytes) was orphaned instead of \
rolled back after the install failed",
);
}
assert_eq!(
sidecar_count(),
0,
"an install failure must leave no `.restrict-bound` sidecar (it is written \
only after the install commits)",
);
Ok(())
}
#[cfg(zstd_any)]
#[test]
fn tight_space_slice_holds_its_filter_budget_until_the_install() -> crate::Result<()> {
let dir = tempfile::tempdir()?;
let capfs = capfs::CapacityFs::new();
let shared: Arc<dyn crate::fs::Fs> = Arc::new(capfs.clone());
let config = Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.data_block_size_policy(BlockSizePolicy::all(512))
.filter_advisor(Some(crate::config::FilterAdvisor::new(u64::MAX)))
.with_shared_fs(Arc::clone(&shared));
let tree = match config.open()? {
crate::AnyTree::Standard(t) => t,
crate::AnyTree::Blob(_) => panic!("expected Standard tree"),
};
for i in 0..TIGHT_SPACE_KEYS {
tree.insert(tight_space_key(i).as_bytes(), vec![0xCDu8; 64], i);
}
tree.flush_active_memtable(0)?;
let used = tree.storage_stats()?.used_bytes;
capfs.set_available_space(used / 4);
tree.update_runtime_config(|c| {
c.storage_admission_check = true;
c.tight_space_compaction = true;
})?;
let seen: Arc<parking_lot::Mutex<Option<(u64, u64)>>> = Arc::default();
{
let budget = Arc::clone(&tree.filter_budget);
let seen = Arc::clone(&seen);
tree.config.arm_before_output_install(move || {
*seen.lock() = Some((budget.held(), budget.published()));
});
}
tree.major_compact(64 * 1024 * 1024, 0)?;
let Some((held, published)) = *seen.lock() else {
panic!("the tight-space path installed a slice");
};
assert!(
held > published,
"held {held} bytes before the first slice's install, the published filters \
alone take {published}"
);
Ok(())
}
#[test]
fn tight_space_slice_retains_a_tombstone_for_an_unrestricted_repair_survivor() -> crate::Result<()>
{
use crate::fs::{Fault, FaultFs, FaultOp, FaultRule};
use crate::io::ErrorKind;
use core::sync::atomic::Ordering;
let dir = tempfile::tempdir()?;
let capfs = capfs::CapacityFs::new();
let fault = FaultFs::new(capfs.clone());
let injector = fault.injector();
let shared: Arc<dyn crate::fs::Fs> = Arc::new(fault);
let config = Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.data_block_size_policy(BlockSizePolicy::all(512))
.with_shared_fs(Arc::clone(&shared));
let failpoint = config.fail_tight_after_first_slice.clone();
let tree = match config.open()? {
crate::AnyTree::Standard(t) => t,
crate::AnyTree::Blob(_) => panic!("expected Standard tree"),
};
for i in 0..TIGHT_SPACE_KEYS {
tree.insert(tight_space_key(i).as_bytes(), vec![0xCDu8; 64], i);
}
tree.flush_active_memtable(0)?;
let deleted = tight_space_key(0);
tree.remove(deleted.as_bytes(), TIGHT_SPACE_KEYS);
tree.flush_active_memtable(0)?;
let used = tree.storage_stats()?.used_bytes;
capfs.set_available_space(used / 4);
tree.update_runtime_config(|c| {
c.storage_admission_check = true;
c.tight_space_compaction = true;
})?;
injector.arm(
FaultRule::new(FaultOp::Open, Fault::Error(ErrorKind::Other)).on_path("restrict-bound"),
);
failpoint.store(true, Ordering::SeqCst);
assert!(
tree.major_compact(64 * 1024 * 1024, TIGHT_SPACE_KEYS + 1)
.is_err(),
"the crash failpoint must abort the tight-space compaction",
);
assert!(
!failpoint.load(Ordering::SeqCst),
"the failpoint must have fired after the first slice",
);
assert_eq!(
capfs.punched_bytes(),
0,
"the faulted-sidecar survivor must be left unpunched",
);
injector.clear();
drop(tree);
Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.with_shared_fs(Arc::clone(&shared))
.repair_with_salvage(true)?;
let reopened = match Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.with_shared_fs(shared)
.open()?
{
crate::AnyTree::Standard(t) => t,
crate::AnyTree::Blob(_) => panic!("expected Standard tree"),
};
assert!(
reopened
.get(deleted.as_bytes(), crate::MAX_SEQNO)?
.is_none(),
"a tombstone consumed by a tight-space slice must not be GC'd away, or manifest \
repair of the unrestricted survivor resurrects the deleted key",
);
Ok(())
}
const BLOB_RELOC_KEYS: u64 = 4_000;
const BLOB_RELOC_WATERMARK: u64 = 4 * BLOB_RELOC_KEYS;
fn blob_reloc_key(i: u64) -> String {
format!("key{i:08}")
}
fn blob_reloc_value(i: u64, generation: u8) -> Vec<u8> {
let mut s = (i + 1).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ (u64::from(generation) << 1);
(0..200u32)
.map(|_| {
s ^= s << 13;
s ^= s >> 7;
s ^= s << 17;
#[expect(
clippy::cast_possible_truncation,
reason = "xorshift byte extraction; the high bits are intentionally dropped"
)]
let byte = (s >> 24) as u8;
byte
})
.collect()
}
fn blob_reloc_kv_options() -> KvSeparationOptions {
KvSeparationOptions::default()
.separation_threshold(64)
.age_cutoff(1.0)
.staleness_threshold(0.1)
.file_target_size(48 * 1024)
}
fn blob_relocation_crash_and_reopen(
dir: &std::path::Path,
mem: &crate::fs::MemFs,
) -> crate::Result<crate::blob_tree::BlobTree> {
use core::sync::atomic::Ordering;
let config = Config::new(
dir,
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.data_block_size_policy(BlockSizePolicy::all(512))
.with_shared_fs(Arc::new(mem.clone()))
.with_kv_separation(Some(blob_reloc_kv_options()));
let failpoint = config.fail_tight_after_first_slice.clone();
let tree = match config.open()? {
crate::AnyTree::Blob(t) => t,
crate::AnyTree::Standard(_) => panic!("expected Blob tree"),
};
for i in 0..BLOB_RELOC_KEYS {
tree.insert(blob_reloc_key(i).as_bytes(), blob_reloc_value(i, 1), i);
}
tree.flush_active_memtable(0)?;
for i in (0..BLOB_RELOC_KEYS).step_by(2) {
tree.insert(
blob_reloc_key(i).as_bytes(),
blob_reloc_value(i, 2),
BLOB_RELOC_KEYS + i,
);
}
tree.flush_active_memtable(0)?;
tree.index.update_runtime_config(|c| {
c.storage_admission_check = true;
c.storage_limit_bytes = None;
})?;
tree.major_compact(64 * 1024 * 1024, BLOB_RELOC_WATERMARK)?;
let used = tree.storage_stats()?.used_bytes;
mem.set_capacity(used + used / 4);
tree.index.update_runtime_config(|c| {
c.tight_space_compaction = true;
})?;
failpoint.store(true, Ordering::SeqCst);
assert!(
tree.major_compact(64 * 1024 * 1024, BLOB_RELOC_WATERMARK)
.is_err(),
"the crash failpoint must abort the relocating tight-space compaction",
);
assert!(
!failpoint.load(Ordering::SeqCst),
"the failpoint should have fired and disarmed",
);
assert!(
mem.punched_bytes() > 0,
"the first relocated slice must have punched a stale blob prefix",
);
drop(tree);
match Config::new(
dir,
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.with_kv_separation(Some(blob_reloc_kv_options()))
.with_shared_fs(Arc::new(mem.clone()))
.open()?
{
crate::AnyTree::Blob(t) => Ok(t),
crate::AnyTree::Standard(_) => panic!("expected Blob tree"),
}
}
#[test]
fn tight_space_blob_relocation_crash_after_first_slice_recovers_all_keys() -> crate::Result<()> {
let dir = tempfile::tempdir()?;
let mem = crate::fs::MemFs::with_capacity(u64::MAX);
let reopened = blob_relocation_crash_and_reopen(dir.path(), &mem)?;
for i in 0..BLOB_RELOC_KEYS {
let expected = blob_reloc_value(i, u8::from(i % 2 == 0) + 1);
assert_eq!(
reopened
.get(blob_reloc_key(i).as_bytes(), crate::MAX_SEQNO)?
.as_deref(),
Some(expected.as_slice()),
"key {i} wrong/lost after a crash mid blob-relocation + reopen",
);
}
Ok(())
}
#[test]
#[expect(clippy::expect_used, reason = "test assertion")]
fn a_tight_space_slice_reclaims_stale_frames_no_pointer_claims() -> crate::Result<()> {
use core::sync::atomic::Ordering;
let dir = tempfile::tempdir()?;
let mem = crate::fs::MemFs::with_capacity(u64::MAX);
let kv_options = || blob_reloc_kv_options().file_target_size(64 * 1024 * 1024);
let config = Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.data_block_size_policy(BlockSizePolicy::all(512))
.with_shared_fs(Arc::new(mem.clone()))
.with_kv_separation(Some(kv_options()));
let failpoint = config.fail_tight_after_first_slice.clone();
let tree = match config.open()? {
crate::AnyTree::Blob(t) => t,
crate::AnyTree::Standard(_) => panic!("expected Blob tree"),
};
for i in 0..BLOB_RELOC_KEYS {
tree.insert(blob_reloc_key(i).as_bytes(), blob_reloc_value(i, 1), i);
}
tree.flush_active_memtable(0)?;
let overwritten = BLOB_RELOC_KEYS * 3 / 4;
for i in 0..overwritten {
tree.insert(
blob_reloc_key(i).as_bytes(),
blob_reloc_value(i, 2),
BLOB_RELOC_KEYS + i,
);
}
tree.flush_active_memtable(0)?;
tree.index.update_runtime_config(|c| {
c.storage_admission_check = true;
c.storage_limit_bytes = None;
})?;
tree.major_compact(64 * 1024 * 1024, BLOB_RELOC_WATERMARK)?;
let stale = {
let version = tree.index.current_version();
let mut gen1: Vec<_> = version
.blob_files
.iter()
.map(crate::vlog::BlobFile::id)
.collect();
gen1.sort_unstable();
*gen1.first().expect("the first generation's blob file")
};
let used = tree.storage_stats()?.used_bytes;
mem.set_capacity(used + used / 16);
tree.index.update_runtime_config(|c| {
c.tight_space_compaction = true;
})?;
failpoint.store(true, Ordering::SeqCst);
assert!(
tree.major_compact(64 * 1024 * 1024, BLOB_RELOC_WATERMARK)
.is_err(),
"the crash failpoint must stop the pass after its first slice",
);
assert!(
!failpoint.load(Ordering::SeqCst),
"the failpoint should have fired",
);
let version = tree.index.current_version();
let file = version
.blob_files
.get(stale)
.expect("the stale file still holds the live last quarter");
assert!(
file.live_data_start() > 0,
"the first slice must advance the stale file's frontier past the dead \
frames of its keys",
);
drop(version);
for i in 0..BLOB_RELOC_KEYS {
let expected = blob_reloc_value(i, u8::from(i < overwritten) + 1);
assert_eq!(
tree.get(blob_reloc_key(i).as_bytes(), crate::MAX_SEQNO)?
.as_deref(),
Some(expected.as_slice()),
"key {i} after the first slice",
);
}
Ok(())
}
#[test]
fn a_relocation_retry_resumes_at_the_committed_blob_frontier() -> crate::Result<()> {
let dir = tempfile::tempdir()?;
let mem = crate::fs::MemFs::with_capacity(u64::MAX);
let tree = blob_relocation_crash_and_reopen(dir.path(), &mem)?;
{
let version = tree.index.current_version();
assert!(
version.blob_files.iter().any(|bf| bf.live_data_start() > 0),
"the crashed relocation must leave a blob file with a committed frontier",
);
}
let punched_before = mem.punched_bytes();
let used = tree.storage_stats()?.used_bytes;
mem.set_capacity(used + used / 16);
tree.index.update_runtime_config(|c| {
c.storage_admission_check = true;
c.tight_space_compaction = true;
})?;
tree.major_compact(64 * 1024 * 1024, BLOB_RELOC_WATERMARK)?;
assert!(
mem.punched_bytes() > punched_before,
"the retry must relocate further and punch what it consumed",
);
for i in 0..BLOB_RELOC_KEYS {
let expected = blob_reloc_value(i, u8::from(i % 2 == 0) + 1);
assert_eq!(
tree.get(blob_reloc_key(i).as_bytes(), crate::MAX_SEQNO)?
.as_deref(),
Some(expected.as_slice()),
"key {i} wrong/lost after the relocation retry",
);
}
Ok(())
}
#[test]
fn an_ordinary_merge_resumes_a_restricted_blob_file_at_its_frontier() -> crate::Result<()> {
let dir = tempfile::tempdir()?;
let mem = crate::fs::MemFs::with_capacity(u64::MAX);
let tree = blob_relocation_crash_and_reopen(dir.path(), &mem)?;
let restricted: Vec<_> = tree
.index
.current_version()
.blob_files
.iter()
.filter(|bf| bf.live_data_start() > 0)
.map(crate::vlog::BlobFile::id)
.collect();
assert!(
!restricted.is_empty(),
"the crashed relocation must leave a blob file with a committed frontier",
);
mem.set_capacity(u64::MAX);
tree.index.update_runtime_config(|c| {
c.tight_space_compaction = false;
})?;
tree.major_compact(64 * 1024 * 1024, BLOB_RELOC_WATERMARK)?;
let version = tree.index.current_version();
assert!(
version
.blob_files
.iter()
.all(|bf| !restricted.contains(&bf.id())),
"the ordinary merge must relocate the restricted blob file",
);
for i in 0..BLOB_RELOC_KEYS {
let expected = blob_reloc_value(i, u8::from(i % 2 == 0) + 1);
assert_eq!(
tree.get(blob_reloc_key(i).as_bytes(), crate::MAX_SEQNO)?
.as_deref(),
Some(expected.as_slice()),
"key {i} wrong/lost after the ordinary merge",
);
}
Ok(())
}
#[test]
fn tight_space_blob_reopen_failure_rolls_back_the_slice_outputs() -> crate::Result<()> {
use core::sync::atomic::Ordering;
const N: u64 = 4_000;
let k = |i: u64| format!("key{i:08}");
let val = |i: u64, generation: u8| -> Vec<u8> {
let mut s = (i + 1).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ (u64::from(generation) << 1);
(0..200u32)
.map(|_| {
s ^= s << 13;
s ^= s >> 7;
s ^= s << 17;
#[expect(
clippy::cast_possible_truncation,
reason = "xorshift byte extraction; the high bits are intentionally dropped"
)]
let byte = (s >> 24) as u8;
byte
})
.collect()
};
let dir = tempfile::tempdir()?;
let mem = crate::fs::MemFs::with_capacity(u64::MAX);
let shared: Arc<dyn crate::fs::Fs> = Arc::new(mem.clone());
let config = Config::new(
&dir,
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.data_block_size_policy(BlockSizePolicy::all(512))
.with_shared_fs(Arc::clone(&shared))
.with_kv_separation(Some(
KvSeparationOptions::default()
.separation_threshold(64)
.age_cutoff(1.0)
.staleness_threshold(0.1)
.file_target_size(48 * 1024),
));
let failpoint = config.fail_tight_blob_reopen.clone();
let tree = match config.open()? {
crate::AnyTree::Blob(t) => t,
crate::AnyTree::Standard(_) => panic!("expected Blob tree"),
};
for i in 0..N {
tree.insert(k(i).as_bytes(), val(i, 1), i);
}
tree.flush_active_memtable(0)?;
for i in (0..N).step_by(2) {
tree.insert(k(i).as_bytes(), val(i, 2), N + i);
}
tree.flush_active_memtable(0)?;
let gc_watermark = 4 * N;
tree.index.update_runtime_config(|c| {
c.storage_admission_check = true;
c.storage_limit_bytes = None;
})?;
tree.major_compact(64 * 1024 * 1024, gc_watermark)?;
let used = tree.storage_stats()?.used_bytes;
mem.set_capacity(used + used / 4);
tree.index.update_runtime_config(|c| {
c.tight_space_compaction = true;
})?;
let list_names = |folder: &std::path::Path| -> crate::Result<Vec<String>> {
let mut names: Vec<String> = if shared.exists(folder)? {
shared
.read_dir(folder)?
.into_iter()
.map(|e| e.file_name)
.collect()
} else {
Vec::new()
};
names.sort();
Ok(names)
};
let tables_before = list_names(&dir.path().join("tables"))?;
let blobs_before = list_names(&dir.path().join("blobs"))?;
failpoint.store(true, Ordering::SeqCst);
assert!(
tree.major_compact(64 * 1024 * 1024, gc_watermark).is_err(),
"the injected blob-reopen failure must abort the compaction",
);
assert!(
!failpoint.load(Ordering::SeqCst),
"the failpoint should have fired and disarmed",
);
let version = tree.current_version();
let referenced_tables: Vec<String> =
version.iter_tables().map(|t| t.id().to_string()).collect();
let referenced_blobs: Vec<String> = version
.blob_files
.iter()
.map(|bf| bf.id().to_string())
.collect();
for name in list_names(&dir.path().join("tables"))? {
assert!(
tables_before.contains(&name) || referenced_tables.contains(&name),
"leaked table file {name}: neither pre-existing nor referenced by \
the current version (rollback missed it)",
);
}
for name in list_names(&dir.path().join("blobs"))? {
assert!(
blobs_before.contains(&name) || referenced_blobs.contains(&name),
"leaked blob file {name}: neither pre-existing nor referenced by \
the current version (rollback missed it)",
);
}
Ok(())
}
#[test]
fn last_level_applies_and_gcs_below_watermark_range_tombstone() -> crate::Result<()> {
let dir = tempfile::tempdir()?;
let tree = Config::new(
&dir,
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.data_block_size_policy(BlockSizePolicy::all(512))
.compaction_threads(4)
.subcompaction_min_bytes(0)
.open()?;
let key = |i: u64| format!("k{i:04}");
let val = |i: u64| format!("v{i}-{}", "x".repeat(40));
for i in 0..200u64 {
tree.insert(key(i), val(i), i);
}
tree.flush_active_memtable(0)?;
tree.major_compact(4_096, 0)?;
tree.remove_range(
crate::UserKey::from("k0000"),
crate::UserKey::from("k0050"),
1000,
);
for i in 50..200u64 {
tree.insert(key(i), val(i), 1001 + i);
}
tree.flush_active_memtable(0)?;
tree.major_compact(u64::MAX, 5000)?;
for i in 0..50u64 {
assert_eq!(
tree.get(key(i), crate::MAX_SEQNO)?,
None,
"covered key {} must be physically gone after GC",
key(i),
);
}
for i in 50..200u64 {
assert!(
tree.get(key(i), crate::MAX_SEQNO)?.is_some(),
"uncovered key {} must survive",
key(i),
);
}
let remaining = super::collect_version_tombstones(&tree.current_version());
assert!(
remaining.is_empty(),
"a fully-applied below-watermark tombstone must be GC'd, found {remaining:?}",
);
Ok(())
}
#[test]
fn above_watermark_range_tombstone_is_retained() -> crate::Result<()> {
let dir = tempfile::tempdir()?;
let tree = Config::new(
&dir,
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.open()?;
let key = |i: u64| format!("k{i:04}");
for i in 0..50u64 {
tree.insert(key(i), "v", i);
}
tree.flush_active_memtable(0)?;
tree.remove_range(
crate::UserKey::from("k0000"),
crate::UserKey::from("k0025"),
100,
);
tree.flush_active_memtable(0)?;
tree.major_compact(u64::MAX, 50)?;
let remaining = super::collect_version_tombstones(&tree.current_version());
assert!(
!remaining.is_empty(),
"an above-watermark tombstone must be retained, not GC'd",
);
Ok(())
}
#[test]
fn range_tombstone_at_exact_watermark_is_not_applied_or_gced() -> crate::Result<()> {
let dir = tempfile::tempdir()?;
let tree = Config::new(
&dir,
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.data_block_size_policy(BlockSizePolicy::all(512))
.compaction_threads(4)
.subcompaction_min_bytes(0)
.open()?;
let key = |i: u64| format!("k{i:04}");
let val = |i: u64| format!("v{i}-{}", "x".repeat(40));
for i in 0..200u64 {
tree.insert(key(i), val(i), i);
}
tree.flush_active_memtable(0)?;
tree.major_compact(4_096, 0)?;
tree.remove_range(
crate::UserKey::from("k0000"),
crate::UserKey::from("k0050"),
1000,
);
for i in 50..200u64 {
tree.insert(key(i), val(i), 1001 + i);
}
tree.flush_active_memtable(0)?;
tree.major_compact(u64::MAX, 1000)?;
for i in 0..50u64 {
assert_eq!(
tree.get(key(i), 1000)?.as_deref(),
Some(val(i).as_bytes()),
"covered key {} must survive: RT@watermark is invisible at read==watermark",
key(i),
);
}
let remaining = super::collect_version_tombstones(&tree.current_version());
assert!(
!remaining.is_empty(),
"a tombstone at the exact watermark must be retained, not GC'd",
);
Ok(())
}
#[test]
fn compaction_stream_run_not_found() -> crate::Result<()> {
let folder = tempfile::tempdir()?;
let tree = crate::Config::new(
folder,
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.open()?;
tree.insert("a", "a", 0);
tree.flush_active_memtable(0)?;
assert!(
create_compaction_stream(
&tree.current_version(),
&[666],
0,
None,
crate::comparator::default_comparator(),
None,
#[cfg(feature = "columnar")]
None,
)?
.is_none()
);
Ok(())
}
#[test]
#[expect(clippy::unwrap_used)]
fn compaction_stream_run() -> crate::Result<()> {
let folder = tempfile::tempdir()?;
let tree = crate::Config::new(
folder,
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.open()?;
tree.insert("a", "a", 0);
tree.flush_active_memtable(0)?;
tree.insert("b", "b", 0);
tree.flush_active_memtable(0)?;
tree.insert("c", "c", 0);
tree.flush_active_memtable(0)?;
assert_eq!(
Some((0, 2)),
pick_run_indexes(
tree.current_version()
.level(0)
.unwrap()
.iter()
.next()
.unwrap(),
&[0, 1, 2],
)
);
Ok(())
}
#[test]
#[expect(clippy::unwrap_used)]
fn compaction_stream_run_2() -> crate::Result<()> {
let folder = tempfile::tempdir()?;
let tree = crate::Config::new(
folder,
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.open()?;
tree.insert("a", "a", 0);
tree.flush_active_memtable(0)?;
tree.insert("b", "b", 0);
tree.flush_active_memtable(0)?;
tree.insert("c", "c", 0);
tree.flush_active_memtable(0)?;
assert_eq!(
Some((0, 0)),
pick_run_indexes(
tree.current_version()
.level(0)
.unwrap()
.iter()
.next()
.unwrap(),
&[0],
)
);
Ok(())
}
#[test]
#[expect(clippy::unwrap_used)]
fn compaction_stream_run_3() -> crate::Result<()> {
let folder = tempfile::tempdir()?;
let tree = crate::Config::new(
folder,
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.open()?;
tree.insert("a", "a", 0);
tree.flush_active_memtable(0)?;
tree.insert("b", "b", 0);
tree.flush_active_memtable(0)?;
tree.insert("c", "c", 0);
tree.flush_active_memtable(0)?;
assert_eq!(
Some((2, 2)),
pick_run_indexes(
tree.current_version()
.level(0)
.unwrap()
.iter()
.next()
.unwrap(),
&[2],
)
);
Ok(())
}
#[test]
#[expect(clippy::unwrap_used)]
fn compaction_stream_run_4() -> crate::Result<()> {
let folder = tempfile::tempdir()?;
let tree = crate::Config::new(
folder,
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.open()?;
tree.insert("a", "a", 0);
tree.flush_active_memtable(0)?;
tree.insert("b", "b", 0);
tree.flush_active_memtable(0)?;
tree.insert("c", "c", 0);
tree.flush_active_memtable(0)?;
assert_eq!(
None,
pick_run_indexes(
tree.current_version()
.level(0)
.unwrap()
.iter()
.next()
.unwrap(),
&[4],
)
);
Ok(())
}
#[test]
fn compaction_drop_tables() -> crate::Result<()> {
let folder = tempfile::tempdir()?;
let tree = crate::Config::new(
folder,
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.open()?;
tree.insert("a", "a", 0);
tree.flush_active_memtable(0)?;
assert_eq!(1, tree.approximate_len());
assert_eq!(0, tree.sealed_memtable_count());
tree.insert("b", "a", 1);
tree.flush_active_memtable(0)?;
assert_eq!(2, tree.approximate_len());
assert_eq!(0, tree.sealed_memtable_count());
tree.insert("c", "a", 2);
tree.flush_active_memtable(0)?;
assert_eq!(3, tree.approximate_len());
assert_eq!(0, tree.sealed_memtable_count());
tree.compact(Arc::new(crate::compaction::Fifo::new(1, None)), 3)?;
assert_eq!(0, tree.table_count());
Ok(())
}
#[test]
fn compaction_reclaim_then_strategy_drop_reports_both_input_counts() -> crate::Result<()> {
use crate::version::run::Ranged as _;
struct DropZ;
impl CompactionStrategy for DropZ {
fn get_name(&self) -> &'static str {
"DropZ"
}
fn choose(&self, version: &Version, _: &Config, _: &CompactionState) -> Choice {
Choice::Drop(
version
.iter_tables()
.filter(|t| t.key_range().min().as_ref() == b"z")
.map(crate::Table::id)
.collect(),
)
}
}
let folder = tempfile::tempdir()?;
let tree = crate::Config::new(
folder,
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.open()?;
tree.insert("k", "v", 1);
tree.flush_active_memtable(0)?;
tree.remove_range("a", "m", 2);
tree.flush_active_memtable(0)?;
tree.insert("z", "v", 3);
tree.flush_active_memtable(0)?;
let result = tree.compact(Arc::new(DropZ), 3)?;
assert_eq!(super::CompactionAction::Dropped, result.action);
assert_eq!(2, result.tables_in, "the table of k and the table of z");
Ok(())
}
#[test]
fn blob_file_picking_simple() -> crate::Result<()> {
struct DropStrategy(Vec<TableId>);
impl CompactionStrategy for DropStrategy {
fn get_name(&self) -> &'static str {
"DropCompaction"
}
fn choose(&self, _: &Version, _: &Config, _: &CompactionState) -> Choice {
Choice::Drop(self.0.iter().copied().collect())
}
}
struct InPlaceStrategy(Vec<TableId>);
impl CompactionStrategy for InPlaceStrategy {
fn get_name(&self) -> &'static str {
"InPlaceCompaction"
}
fn choose(&self, _: &Version, _: &Config, _: &CompactionState) -> Choice {
Choice::Merge(Input {
table_ids: self.0.iter().copied().collect(),
dest_level: 6,
target_size: 64_000_000,
canonical_level: 6, })
}
}
let folder = tempfile::tempdir()?;
let tree = crate::Config::new(
folder,
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.data_block_size_policy(BlockSizePolicy::all(1))
.with_kv_separation(Some(
KvSeparationOptions::default()
.separation_threshold(1)
.age_cutoff(1.0)
.staleness_threshold(0.01),
))
.blob_compression(crate::CompressionType::None)
.open()?;
tree.insert("a", "a", 0);
tree.insert("b", "b", 0);
tree.insert("c", "c", 0);
tree.flush_active_memtable(1_000)?;
assert_eq!(0, tree.sealed_memtable_count());
assert_eq!(1, tree.table_count());
assert_eq!(1, tree.blob_file_count());
tree.major_compact(1, 1_000)?;
assert_eq!(3, tree.table_count());
assert_eq!(1, tree.blob_file_count());
tree.compact(Arc::new(DropStrategy(vec![1])), 1_000)?;
assert_eq!(2, tree.table_count());
assert_eq!(1, tree.blob_file_count());
{
assert_eq!(
&{
let mut map = crate::HashMap::default();
map.insert(0, crate::blob_tree::FragmentationEntry::new(1, 1, 1));
map
},
&**tree.current_version().gc_stats(),
);
}
tree.compact(Arc::new(InPlaceStrategy(vec![2])), 1_000)?;
assert_eq!(2, tree.table_count());
assert_eq!(1, tree.blob_file_count());
{
assert_eq!(
&{
let mut map = crate::HashMap::default();
map.insert(0, crate::blob_tree::FragmentationEntry::new(1, 1, 1));
map
},
&**tree.current_version().gc_stats(),
);
}
tree.compact(Arc::new(InPlaceStrategy(vec![3, 4])), 1_000)?;
assert_eq!(1, tree.table_count());
assert_eq!(1, tree.blob_file_count());
{
assert_eq!(
crate::HashMap::default(),
**tree.current_version().gc_stats(),
);
}
Ok(())
}
#[expect(
clippy::expect_used,
clippy::indexing_slicing,
reason = "test asserts over known-good fixtures; failure surfaces via panic"
)]
#[test]
fn narrow_merge_candidates_for_full_run_are_adjacent_pairs_sorted_ascending() -> crate::Result<()> {
let dir = tempfile::tempdir()?;
let tree = Config::new(
&dir,
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.data_block_size_policy(BlockSizePolicy::all(512))
.open()?;
for i in 0..3_000u64 {
tree.insert(format!("k{i:08}"), "v".repeat(40), i);
}
tree.flush_active_memtable(0)?;
tree.major_compact(16 * 1024, 0)?;
let version = tree.current_version();
let run = version
.iter_levels()
.flat_map(|level| level.iter())
.find(|run| run.len() >= 3)
.expect("a bottom-level run with >= 3 tables");
let ordered: Vec<(TableId, u64)> = run
.iter()
.map(|t| Ok((t.id(), t.live_file_size()?)))
.collect::<crate::Result<_>>()?;
let payload = Input {
table_ids: ordered.iter().map(|(id, _)| *id).collect(),
dest_level: 6,
canonical_level: 6,
target_size: 64 * 1024 * 1024,
};
let candidates = super::narrow_merge_candidates(&version, &payload)?;
assert_eq!(
candidates.len(),
ordered.len() - 1,
"one candidate per run-adjacent pair"
);
for c in &candidates {
assert_eq!(c.table_ids.len(), 2, "each candidate is an adjacent pair");
assert_eq!(c.dest_level, 6, "destination preserved");
}
let combined = |c: &Input| -> crate::Result<u64> {
c.table_ids
.iter()
.filter_map(|id| version.get_table(*id))
.try_fold(0u64, |acc, t| t.live_file_size().map(|size| acc + size))
};
let sums: Vec<u64> = candidates
.iter()
.map(combined)
.collect::<crate::Result<_>>()?;
let mut sorted = sums.clone();
sorted.sort_unstable();
assert_eq!(sums, sorted, "candidates sorted ascending by SST size");
let smallest_pair = ordered
.windows(2)
.map(|w| w[0].1 + w[1].1)
.min()
.expect(">= 2 tables");
assert_eq!(sums[0], smallest_pair, "smallest-Σ pair is tried first");
Ok(())
}
#[test]
fn merge_would_fit_combines_shared_volume_outputs_and_separates_routed_ones() -> crate::Result<()> {
use crate::fs::MemFs;
const MIB: u64 = 1024 * 1024;
let dir = tempfile::tempdir()?;
let pool = crate::CapacityPool::default();
let fits = |cfg: &Config, quota: u64, sst: u64, blob: u64| {
super::merge_would_fit(&pool, 0, cfg, quota, (sst, blob), 6)
};
let cfg = Config::new(
&dir,
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.with_shared_fs(Arc::new(MemFs::with_capacity(100 * MIB)));
assert!(
!fits(&cfg, u64::MAX, 60 * MIB, 60 * MIB),
"shared-volume outputs must be summed, not checked independently"
);
assert!(fits(&cfg, u64::MAX, 60 * MIB, 30 * MIB));
assert!(!fits(&cfg, 80 * MIB, 50 * MIB, 40 * MIB));
let routed = Config::new(
&dir,
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.with_shared_fs(Arc::new(MemFs::with_capacity(100 * MIB)))
.level_routes(vec![crate::config::LevelRoute {
levels: 6..7,
path: crate::path::PathBuf::from("/cold-tier"),
fs: Arc::new(MemFs::with_capacity(100 * MIB)),
}]);
assert!(
fits(&routed, u64::MAX, 60 * MIB, 60 * MIB),
"proven-independent volumes are checked independently"
);
assert!(!fits(&routed, u64::MAX, 60 * MIB, 130 * MIB));
let shared: Arc<dyn crate::fs::Fs> = Arc::new(MemFs::with_capacity(100 * MIB));
let routed_same_mount = Config::new(
&dir,
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.with_shared_fs(Arc::clone(&shared))
.level_routes(vec![crate::config::LevelRoute {
levels: 6..7,
path: crate::path::PathBuf::from("/same-mount-subdir"),
fs: Arc::clone(&shared),
}]);
assert!(
!fits(&routed_same_mount, u64::MAX, 60 * MIB, 60 * MIB),
"a route on the same volume must combine budgets, not admit each independently"
);
Ok(())
}
#[expect(
clippy::expect_used,
reason = "test asserts over known-good fixtures; failure surfaces via panic"
)]
#[test]
fn space_gate_for_merge_narrows_a_full_run_that_exceeds_free() -> crate::Result<()> {
use crate::fs::MemFs;
let dir = tempfile::tempdir()?;
let mem = MemFs::with_capacity(u64::MAX);
let any = Config::new(
&dir,
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.with_shared_fs(Arc::new(mem.clone()))
.data_block_size_policy(BlockSizePolicy::all(512))
.open()?;
let crate::AnyTree::Standard(tree) = any else {
panic!("expected Standard tree");
};
for i in 0..3_000u64 {
tree.insert(format!("k{i:08}"), "v".repeat(40), i);
}
tree.flush_active_memtable(0)?;
tree.major_compact(16 * 1024, 0)?;
let version = tree.current_version();
let run = version
.iter_levels()
.flat_map(|level| level.iter())
.find(|run| run.len() >= 3)
.expect("a bottom-level run with >= 3 tables");
let run_sigma: u64 = run.iter().map(Table::file_len).sum();
let payload = Input {
table_ids: run.iter().map(Table::id).collect(),
dest_level: 6,
canonical_level: 6,
target_size: 64 * 1024 * 1024,
};
let probe_capacity = 1u64 << 40;
mem.set_capacity(probe_capacity);
let stored =
probe_capacity - crate::fs::Fs::available_space(&mem, dir.path()).unwrap_or(probe_capacity);
mem.set_capacity(stored + run_sigma - 1);
tree.update_runtime_config(|c| {
c.storage_admission_check = true;
c.storage_limit_bytes = None;
})?;
let opts = super::Options::from_tree(
&tree,
Arc::new(crate::compaction::major::Strategy::new(64 * 1024 * 1024)),
);
match super::space_gate_for_merge(&version, &opts, &payload)? {
super::SpaceGate::Narrowed(narrowed, _) => {
assert_eq!(narrowed.table_ids.len(), 2, "narrowed to an adjacent pair");
}
super::SpaceGate::Run(_) => {
panic!("expected Narrowed, got Run (full run wrongly admitted)")
}
super::SpaceGate::Skip => panic!("expected Narrowed, got Skip (no pair admitted)"),
}
struct Fixed(Input);
impl crate::compaction::CompactionStrategy for Fixed {
fn get_name(&self) -> &'static str {
"FixedTest"
}
fn choose(&self, _: &Version, _: &Config, _: &CompactionState) -> Choice {
Choice::Merge(self.0.clone())
}
}
let ran = tree.compact(Arc::new(Fixed(payload.clone())), 0)?;
assert!(ran.work.measured.read > 0, "a merge ran");
let after = tree.current_version();
let kept = payload
.table_ids
.iter()
.filter(|id| after.iter_tables().any(|table| table.id() == **id))
.count();
assert_eq!(
kept,
payload.table_ids.len() - 2,
"only the pair was merged"
);
for i in (0..3_000u64).step_by(97) {
assert!(tree.get(format!("k{i:08}"), crate::SeqNo::MAX)?.is_some());
}
Ok(())
}
#[test]
fn a_retry_never_lowers_an_existing_restriction() -> crate::Result<()> {
use core::sync::atomic::Ordering;
let dir = tempfile::tempdir()?;
let mem = crate::fs::MemFs::with_capacity(u64::MAX);
let reopened = tight_space_crash_and_reopen(
dir.path(),
Arc::new(mem.clone()),
|used| mem.set_capacity(used + used / 4),
|| mem.punched_bytes(),
)?;
let (restricted_id, committed_bound) = {
let version = reopened.current_version();
let Some(table) = version
.iter_tables()
.find(|t| t.restrict_lower_bound().is_some())
else {
panic!("the crashed tight-space slice must leave a restricted table");
};
let Some(bound) = table.restrict_lower_bound().cloned() else {
panic!("the table was selected by having a restriction bound");
};
(table.id(), bound)
};
drop(reopened);
let config = Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.data_block_size_policy(BlockSizePolicy::all(512))
.with_shared_fs(Arc::new(mem));
let failpoint = config.fail_tight_after_first_slice.clone();
let tree = match config.open()? {
crate::AnyTree::Standard(t) => t,
crate::AnyTree::Blob(_) => panic!("expected Standard tree"),
};
tree.update_runtime_config(|c| {
c.storage_admission_check = true;
c.tight_space_compaction = true;
})?;
failpoint.store(true, Ordering::SeqCst);
let _ = tree.major_compact(64 * 1024 * 1024, 0);
let version = tree.current_version();
let Some(after) = version.get_table(restricted_id) else {
panic!("the retry must still hold the restricted input, not consume it");
};
let Some(bound) = after.restrict_lower_bound() else {
panic!("a table that was restricted can never come back unrestricted");
};
assert!(
bound.as_ref() >= committed_bound.as_ref(),
"a retry must not serve a prefix an earlier slice already punched: \
bound went from {committed_bound:?} to {bound:?}",
);
let crate::restrict_bound::SidecarRead::Present(_, recorded) =
crate::restrict_bound::read(&*after.fs, &after.path, after.encryption.as_deref())?
else {
panic!("the committed restriction must have published its sidecar");
};
assert_eq!(
recorded,
bound.to_vec(),
"the sidecar must record the bound the manifest committed",
);
Ok(())
}
#[test]
fn tight_space_slices_when_only_the_quota_constrains() -> crate::Result<()> {
use crate::fs::{Fault, FaultFs, FaultOp, FaultRule};
let dir = tempfile::tempdir()?;
let mem = crate::fs::MemFs::with_capacity(u64::MAX);
let faulty = FaultFs::new(mem.clone());
faulty.injector().arm(FaultRule::new(
FaultOp::AvailableSpace,
Fault::Error(crate::io::ErrorKind::Unsupported),
));
let tree = match Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.data_block_size_policy(BlockSizePolicy::all(512))
.with_shared_fs(Arc::new(faulty))
.open()?
{
crate::AnyTree::Standard(t) => t,
crate::AnyTree::Blob(_) => panic!("expected Standard tree"),
};
for i in 0..TIGHT_SPACE_KEYS {
tree.insert(tight_space_key(i).as_bytes(), vec![0xCDu8; 64], i);
}
tree.flush_active_memtable(0)?;
let used = crate::storage_stats::compute_used_bytes(&tree.current_version())?;
tree.update_runtime_config(|c| {
c.storage_admission_check = true;
c.tight_space_compaction = true;
c.storage_limit_bytes = Some(used + used / 4);
})?;
tree.major_compact(64 * 1024 * 1024, 0)?;
assert!(
mem.punched_bytes() > 0,
"the slices must reclaim a prefix; a quota-only tree has no other way \
back from read-only",
);
Ok(())
}
#[test]
fn full_range_selectivity_is_one_over_a_restricted_view() -> crate::Result<()> {
let dir = tempfile::tempdir()?;
let mem = crate::fs::MemFs::with_capacity(u64::MAX);
let reopened = tight_space_crash_and_reopen(
dir.path(),
Arc::new(mem.clone()),
|used| mem.set_capacity(used + used / 4),
|| mem.punched_bytes(),
)?;
{
let version = reopened.current_version();
assert!(
version
.iter_tables()
.any(|t| t.restrict_lower_bound().is_some()),
"the crashed tight-space slice must leave a restricted table",
);
}
let card = reopened.approximate_range_cardinality::<&[u8], _>(.., crate::SeqNo::MAX)?;
assert!(
(card.selectivity - 1.0).abs() < 1e-9,
"a full-keyspace query selects everything the snapshot sees, got {}",
card.selectivity,
);
Ok(())
}
#[test]
fn space_gate_sizes_a_restricted_input_by_its_live_suffix() -> crate::Result<()> {
let dir = tempfile::tempdir()?;
let mem = crate::fs::MemFs::with_capacity(u64::MAX);
let reopened = tight_space_crash_and_reopen(
dir.path(),
Arc::new(mem.clone()),
|used| mem.set_capacity(used + used / 4),
|| mem.punched_bytes(),
)?;
let crate::AnyTree::Standard(tree) = reopened else {
panic!("expected Standard tree");
};
mem.set_capacity(u64::MAX);
let version = tree.current_version();
let Some(restricted) = version
.iter_tables()
.find(|t| t.restrict_lower_bound().is_some())
else {
panic!("the crashed tight-space slice must leave a restricted table");
};
let live = restricted.live_file_size()?;
assert!(
live < restricted.file_len(),
"the fixture's input must carry a punched, superseded prefix",
);
let payload = Input {
table_ids: core::iter::once(restricted.id()).collect(),
dest_level: 6,
canonical_level: 6,
target_size: 64 * 1024 * 1024,
};
let used = crate::storage_stats::compute_used_bytes(&version)?;
tree.update_runtime_config(|c| {
c.storage_admission_check = true;
c.tight_space_compaction = false;
c.storage_limit_bytes = Some(used + live);
})?;
let opts = super::Options::from_tree(
&tree,
Arc::new(crate::compaction::major::Strategy::new(64 * 1024 * 1024)),
);
match super::space_gate_for_merge(&version, &opts, &payload)? {
super::SpaceGate::Run(_) => Ok(()),
super::SpaceGate::Narrowed(..) => {
panic!("expected Run, got Narrowed (a single-table merge cannot narrow)")
}
super::SpaceGate::Skip => {
panic!("expected Run, got Skip (the punched prefix was charged to the output)")
}
}
}
#[test]
fn compaction_outputs_inherit_the_deletion_pause() -> crate::Result<()> {
let dir = tempfile::tempdir()?;
let tree = match Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.open()?
{
crate::AnyTree::Standard(t) => t,
crate::AnyTree::Blob(_) => panic!("expected Standard tree"),
};
for i in 0..100u64 {
tree.insert(format!("k{i:05}").as_bytes(), vec![1u8; 16], i);
}
tree.flush_active_memtable(0)?;
for i in 100..200u64 {
tree.insert(format!("k{i:05}").as_bytes(), vec![1u8; 16], i);
}
tree.flush_active_memtable(0)?;
tree.major_compact(64 * 1024 * 1024, 0)?;
let version = tree.current_version();
let tables: Vec<_> = version.iter_tables().collect();
assert!(
!tables.is_empty(),
"the major compaction produced an output"
);
for t in &tables {
assert!(
t.deletion_pause.get().is_some(),
"compaction output table {} must inherit the deletion pause",
t.id(),
);
}
Ok(())
}
fn punch_blob_prefix_on_drop(
capfs: &capfs::CapacityFs,
link_count: u64,
pause_active: bool,
) -> crate::Result<(u64, u64)> {
use crate::{Config, KvSeparationOptions, SequenceNumberCounter};
let dir = tempfile::tempdir()?;
let tree = match Config::new(
&dir,
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.with_shared_fs(Arc::new(capfs.clone()))
.with_kv_separation(Some(
KvSeparationOptions::default().separation_threshold(64),
))
.open()?
{
crate::AnyTree::Blob(t) => t,
crate::AnyTree::Standard(_) => panic!("expected Blob tree"),
};
for i in 0..64u64 {
tree.insert(format!("k{i:05}").as_bytes(), vec![b'v'; 256], i);
}
tree.flush_active_memtable(0)?;
let blob = {
let version = tree.index.current_version();
let Some(bf) = version.blob_files.iter().next().cloned() else {
panic!("the flush spilled at least one blob file");
};
bf
};
let physical = blob.physical_size()?;
assert!(physical > 0, "the blob file has bytes to reclaim");
capfs.set_link_count(link_count);
let pause_guard = if pause_active {
Some(tree.index.deletion_pause.acquire())
} else {
None
};
blob.mark_punch_on_drop(physical);
drop(blob);
drop(tree);
let during_pause = capfs.punched_bytes();
drop(pause_guard);
Ok((during_pause, capfs.punched_bytes()))
}
#[test]
fn reclaimed_blob_file_verifies_against_its_live_suffix() -> crate::Result<()> {
use crate::fs::Fs as _;
let dir = tempfile::tempdir()?;
let capfs = capfs::CapacityFs::new();
let tree = match Config::new(
&dir,
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.with_shared_fs(Arc::new(capfs.clone()))
.with_kv_separation(Some(
KvSeparationOptions::default().separation_threshold(64),
))
.open()?
{
crate::AnyTree::Blob(t) => t,
crate::AnyTree::Standard(_) => panic!("expected Blob tree"),
};
for i in 0..64u64 {
tree.insert(format!("k{i:05}").as_bytes(), vec![b'v'; 256], i);
}
tree.flush_active_memtable(0)?;
let blob = {
let version = tree.index.current_version();
let Some(bf) = version.blob_files.iter().next().cloned() else {
panic!("the flush spilled at least one blob file");
};
bf
};
let frontier = blob.physical_size()? / 2;
let restricted = blob.reopen_restricted(frontier)?;
capfs.punch_hole(blob.path(), 0, frontier)?;
let got = crate::verify::stream_checksum_from(restricted.path(), restricted.live_data_start())?;
assert_eq!(
got,
restricted.checksum(),
"a reclaimed blob file must verify against its live suffix, not the \
punched prefix",
);
Ok(())
}
#[test]
fn blob_prefix_reclaim_skips_a_hard_linked_inode() -> crate::Result<()> {
let (owned, _) = punch_blob_prefix_on_drop(&capfs::CapacityFs::new(), 1, false)?;
assert!(
owned > 0,
"control: an exclusively-owned blob file must have its prefix reclaimed",
);
let (shared, _) = punch_blob_prefix_on_drop(&capfs::CapacityFs::new(), 2, false)?;
assert_eq!(
shared, 0,
"a blob file shared with a checkpoint must not be punched: the snapshot \
still references values in the reclaimed prefix",
);
Ok(())
}
#[test]
fn blob_prefix_reclaim_defers_across_a_deletion_pause() -> crate::Result<()> {
let (during, after) = punch_blob_prefix_on_drop(&capfs::CapacityFs::new(), 1, true)?;
assert_eq!(
during, 0,
"an active checkpoint pause must defer the reclaim, closing the \
probe-to-punch race against the checkpoint's link pass",
);
assert!(
after > 0,
"releasing the pause must carry out the deferred reclaim, not drop it",
);
Ok(())
}
#[test]
fn a_reopened_blob_view_needs_binding_to_carry_the_deletion_pause() -> crate::Result<()> {
let dir = tempfile::tempdir()?;
let tree = match Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.with_kv_separation(Some(
KvSeparationOptions::default().separation_threshold(16),
))
.open()?
{
crate::AnyTree::Blob(t) => t,
crate::AnyTree::Standard(_) => panic!("expected a blob tree"),
};
for i in 0..32u64 {
tree.insert(format!("key{i:06}").as_bytes(), vec![b'v'; 64], i);
}
tree.flush_active_memtable(0)?;
let original = {
let binding = tree.index.version_history.read().latest_version();
binding
.version
.blob_files
.iter()
.next()
.cloned()
.ok_or(crate::Error::Unrecoverable)?
};
assert!(
original.deletion_pause_for_test().is_some(),
"a flush-published blob file is bound when it is registered",
);
let reopened = original.reopen_restricted(0)?;
assert!(
reopened.deletion_pause_for_test().is_none(),
"the re-open starts from the file on disk, so it inherits nothing — \
the path that publishes it is what has to bind it",
);
reopened.bind_to_tree(&crate::table::TableSinks {
deletion_pause: &tree.index.deletion_pause,
heal_hints: &tree.index.heal_hints,
read_budget: tree.index.config.columnar_read_budget,
#[cfg(feature = "std")]
background_deleter: &tree.index.background_deleter,
track_filter_probes: false,
});
assert!(
reopened.deletion_pause_for_test().is_some(),
"binding is what makes a re-opened view safe to publish",
);
Ok(())
}
#[test]
fn tight_space_declines_when_an_input_backend_cannot_punch() -> crate::Result<()> {
use crate::config::LevelRoute;
use crate::fs::{Fs, MemFs};
let dir = tempfile::tempdir()?;
let hot_dir = dir.path().join("hot");
let hot = Arc::new(MemFs::with_capacity(u64::MAX));
hot.set_punch_hole_supported(false);
hot.create_dir_all(&hot_dir)?;
let main = Arc::new(MemFs::with_capacity(u64::MAX));
let hot_fs: Arc<dyn Fs> = hot.clone();
let tree = match Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.data_block_size_policy(BlockSizePolicy::all(512))
.with_shared_fs(main.clone())
.level_routes(vec![LevelRoute {
levels: 0..1,
path: hot_dir.clone(),
fs: hot_fs,
}])
.open()?
{
crate::AnyTree::Standard(t) => t,
crate::AnyTree::Blob(_) => panic!("expected Standard tree"),
};
for i in 0..TIGHT_SPACE_KEYS {
tree.insert(tight_space_key(i).as_bytes(), vec![0xCDu8; 64], i);
}
tree.flush_active_memtable(0)?;
main.set_capacity(64 * 1024);
tree.update_runtime_config(|c| {
c.storage_admission_check = true;
c.tight_space_compaction = true;
})?;
tree.major_compact(64 * 1024 * 1024, 0)?;
let sidecars = hot
.read_dir(&hot_dir.join("tables"))?
.into_iter()
.filter(|e| e.file_name.contains("restrict-bound"))
.count();
assert_eq!(
sidecars, 0,
"no slice may commit a restriction against an input whose backend \
cannot reclaim the prefix that restriction declares consumed",
);
for i in 0..TIGHT_SPACE_KEYS {
assert!(
tree.get(tight_space_key(i).as_bytes(), crate::MAX_SEQNO)?
.is_some(),
"key {i} lost",
);
}
Ok(())
}
#[test]
fn tight_space_budgets_blob_relocation_on_the_blob_volume() -> crate::Result<()> {
let (_dir, tree, mem) = relocating_tree_on_a_tight_blob_volume()?;
tree.major_compact(64 * 1024 * 1024, 4 * RELOC_KEYS)?;
assert!(
mem.punched_bytes() > 0,
"the relocation must slice against the blob volume's free space and \
reclaim stale blob prefixes there",
);
assert_relocated_values(&tree)
}
#[test]
fn tight_slice_on_a_shared_volume_counts_both_demands_against_its_room() -> crate::Result<()> {
use crate::fs::Fs;
use core::sync::atomic::Ordering;
let dir = tempfile::tempdir()?;
let mem = crate::fs::MemFs::with_capacity(u64::MAX);
let config = Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.data_block_size_policy(BlockSizePolicy::all(512))
.with_shared_fs(Arc::new(mem.clone()))
.with_kv_separation(Some(blob_reloc_kv_options()));
let squeeze = Arc::clone(&config.tight_slice_squeeze);
let tree = match config.open()? {
crate::AnyTree::Blob(t) => t,
crate::AnyTree::Standard(_) => panic!("expected Blob tree"),
};
for i in 0..BLOB_RELOC_KEYS {
tree.insert(blob_reloc_key(i).as_bytes(), blob_reloc_value(i, 1), i);
}
tree.flush_active_memtable(0)?;
for i in (0..BLOB_RELOC_KEYS).step_by(2) {
tree.insert(
blob_reloc_key(i).as_bytes(),
blob_reloc_value(i, 2),
BLOB_RELOC_KEYS + i,
);
}
tree.flush_active_memtable(0)?;
tree.index.update_runtime_config(|c| {
c.storage_admission_check = true;
c.storage_limit_bytes = None;
})?;
tree.major_compact(64 * 1024 * 1024, BLOB_RELOC_WATERMARK)?;
let used = tree.storage_stats()?.used_bytes;
mem.set_capacity(used + used / 4);
tree.index.update_runtime_config(|c| {
c.tight_space_compaction = true;
})?;
let version = tree.index.current_version();
let inputs: Vec<Table> = version.iter_tables().cloned().collect();
let ids: crate::HashSet<TableId> = inputs.iter().map(Table::id).collect();
let stale_files = super::pick_blob_files_to_rewrite(&ids, &version, &blob_reloc_kv_options())?;
let stale: crate::HashSet<_> = stale_files.iter().map(crate::vlog::BlobFile::id).collect();
let mut codecs = Vec::new();
for file in &stale_files {
if !codecs.contains(&file.compression()) {
codecs.push(file.compression());
}
}
let free = mem.available_space(dir.path())?;
let relocated = super::Relocated {
stale: &stale,
target_size: 48 * 1024,
file_groups: codecs.len() as u64 * u64::from(blob_reloc_kv_options().lifetime_groups.get()),
};
let slices = super::tight_slices(
&inputs,
super::SliceCaps {
tables: u64::MAX,
blobs: u64::MAX,
total: free,
},
&super::SliceOutputs {
target_size: 64 * 1024 * 1024,
cut_keys: &[],
},
Some(&relocated),
&tree.index.config.comparator,
&super::input_pacer(&super::Options::from_tree(
&tree.index,
Arc::new(crate::compaction::major::Strategy::new(64 * 1024 * 1024)),
)),
)?;
drop(version);
let Some(first) = slices.first() else {
panic!("a pass cuts at least one slice");
};
let (tables, blobs) = (first.tables, first.blobs);
assert!(
slices.len() > 1 && tables.min(blobs) >= 2,
"the fixture needs a pass in slices writing both: {slices:?}"
);
squeeze.store(
free - (tables + blobs) + tables.min(blobs) / 2,
Ordering::SeqCst,
);
let punched = mem.punched_bytes();
let result = tree.major_compact(64 * 1024 * 1024, BLOB_RELOC_WATERMARK)?;
assert_eq!(
result.action,
crate::compaction::CompactionAction::Nothing,
"{result:?}"
);
assert_eq!(mem.punched_bytes(), punched, "no slice ran");
Ok(())
}
#[test]
fn merge_of_a_missing_table_in_a_blob_tree_is_declined() -> crate::Result<()> {
use crate::compaction::{Choice, CompactionStrategy, Input, state::CompactionState};
struct MergeMissing(Vec<TableId>);
impl CompactionStrategy for MergeMissing {
fn get_name(&self) -> &'static str {
"MergeMissingTest"
}
fn choose(&self, _: &crate::version::Version, _: &Config, _: &CompactionState) -> Choice {
Choice::Merge(Input {
table_ids: self.0.iter().copied().collect(),
dest_level: 1,
canonical_level: 1,
target_size: u64::MAX,
})
}
}
let dir = tempfile::tempdir()?;
let tree = match Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.with_kv_separation(Some(blob_reloc_kv_options()))
.open()?
{
crate::AnyTree::Blob(t) => t,
crate::AnyTree::Standard(_) => panic!("expected Blob tree"),
};
for i in 0..10 {
tree.insert(blob_reloc_key(i).as_bytes(), blob_reloc_value(i, 1), i);
}
tree.flush_active_memtable(0)?;
let mut ids = tree.index.current_version().l0().list_ids();
ids.insert(TableId::MAX);
let result = tree.compact(Arc::new(MergeMissing(ids.into_iter().collect())), 0)?;
assert_eq!(result.action, crate::compaction::CompactionAction::Nothing);
Ok(())
}
#[test]
fn relocating_merge_counts_its_blob_files_whole_as_written() -> crate::Result<()> {
let dir = tempfile::tempdir()?;
let tree = match Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.data_block_size_policy(BlockSizePolicy::all(512))
.with_kv_separation(Some(blob_reloc_kv_options()))
.open()?
{
crate::AnyTree::Blob(t) => t,
crate::AnyTree::Standard(_) => panic!("expected Blob tree"),
};
for i in 0..BLOB_RELOC_KEYS {
tree.insert(blob_reloc_key(i).as_bytes(), blob_reloc_value(i, 1), i);
}
tree.flush_active_memtable(0)?;
for i in (0..BLOB_RELOC_KEYS).step_by(2) {
tree.insert(
blob_reloc_key(i).as_bytes(),
blob_reloc_value(i, 2),
BLOB_RELOC_KEYS + i,
);
}
tree.flush_active_memtable(0)?;
tree.major_compact(64 * 1024 * 1024, BLOB_RELOC_WATERMARK)?;
let before: crate::HashSet<_> = tree
.index
.current_version()
.blob_files
.iter()
.map(crate::vlog::BlobFile::id)
.collect();
let result = tree.major_compact(64 * 1024 * 1024, BLOB_RELOC_WATERMARK)?;
let version = tree.index.current_version();
let mut created = 0;
let mut relocated = false;
for file in version.blob_files.iter() {
if !before.contains(&file.id()) {
relocated = true;
created += file.physical_size()?;
}
}
assert!(relocated, "the fixture needs the merge to relocate");
let tables: u64 = version.iter_tables().map(Table::file_len).sum();
assert_eq!(result.work.measured.written, tables + created);
Ok(())
}
#[test]
fn tight_space_pass_stops_before_a_slice_past_its_room() -> crate::Result<()> {
const ROOM: u64 = 48 * 1_024;
let big = |i: u64| -> Vec<u8> {
let mut s = (i + 1).wrapping_mul(0x9E37_79B9_7F4A_7C15);
(0..4 * ROOM)
.map(|_| {
s ^= s << 13;
s ^= s >> 7;
s ^= s << 17;
s.to_le_bytes()[3]
})
.collect()
};
let dir = tempfile::tempdir()?;
let mem = crate::fs::MemFs::with_capacity(u64::MAX);
let crate::AnyTree::Standard(tree) = Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.data_block_size_policy(BlockSizePolicy::all(512))
.with_shared_fs(Arc::new(mem.clone()))
.open()?
else {
panic!("expected Standard tree");
};
for round in 0..2u64 {
for i in 0..TIGHT_SPACE_KEYS {
let value = if i == TIGHT_SPACE_KEYS / 2 {
big(round)
} else {
vec![0xCD; 64]
};
tree.insert(
tight_space_key(i).as_bytes(),
value,
round * TIGHT_SPACE_KEYS + i,
);
}
tree.flush_active_memtable(0)?;
}
mem.set_capacity(tree.storage_stats()?.used_bytes + ROOM);
tree.update_runtime_config(|c| {
c.storage_admission_check = true;
c.tight_space_compaction = true;
})?;
tree.major_compact(64 * 1024 * 1024, 0)?;
assert!(mem.punched_bytes() > 0, "the slices before it ran");
assert!(
tree.current_version()
.level(0)
.is_some_and(|level| !level.is_empty()),
"the inputs from the slice past the room on stay in place",
);
for i in 0..TIGHT_SPACE_KEYS {
let expected = if i == TIGHT_SPACE_KEYS / 2 {
big(1)
} else {
vec![0xCD; 64]
};
assert_eq!(
tree.get(tight_space_key(i).as_bytes(), crate::MAX_SEQNO)?
.as_deref(),
Some(expected.as_slice()),
"key {i}",
);
}
Ok(())
}
#[test]
fn tight_space_relocation_of_values_gathered_on_few_keys_fits_each_slice() -> crate::Result<()> {
use crate::config::LevelRoute;
use crate::fs::{Fs, MemFs};
const N: u64 = 4_000;
let value = |i: u64, generation: u8| {
let len = if i < N / 10 { 4_000 } else { 70 };
let mut s = (i + 1).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ (u64::from(generation) << 1);
(0..len)
.map(|_| {
s ^= s << 13;
s ^= s >> 7;
s ^= s << 17;
s.to_le_bytes()[3]
})
.collect::<Vec<u8>>()
};
let dir = tempfile::tempdir()?;
let cold_dir = dir.path().join("cold");
let cold = Arc::new(MemFs::with_capacity(u64::MAX));
cold.create_dir_all(&cold_dir)?;
let mem = Arc::new(MemFs::with_capacity(u64::MAX));
let cold_fs: Arc<dyn Fs> = cold;
let pool = Arc::new(crate::CapacityPool::default());
let tree = match Config::new(
&dir,
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.data_block_size_policy(BlockSizePolicy::all(512))
.compaction_capacity(Arc::clone(&pool))
.with_shared_fs(mem.clone())
.level_routes(vec![LevelRoute {
levels: 0..7,
path: cold_dir,
fs: cold_fs,
}])
.with_kv_separation(Some(
KvSeparationOptions::default()
.separation_threshold(64)
.age_cutoff(1.0)
.staleness_threshold(0.1)
.file_target_size(48 * 1024),
))
.open()?
{
crate::AnyTree::Blob(t) => t,
crate::AnyTree::Standard(_) => panic!("expected Blob tree"),
};
for i in 0..N {
tree.insert(reloc_key(i).as_bytes(), value(i, 1), i);
}
tree.flush_active_memtable(0)?;
for i in (0..N).step_by(2) {
tree.insert(reloc_key(i).as_bytes(), value(i, 2), N + i);
}
tree.flush_active_memtable(0)?;
let gc_watermark = 4 * N;
tree.index.update_runtime_config(|c| {
c.storage_admission_check = true;
c.storage_limit_bytes = None;
})?;
tree.major_compact(64 * 1024 * 1024, gc_watermark)?;
const PROBE: u64 = 1 << 40;
mem.set_capacity(PROBE);
let blob_stored = PROBE - mem.available_space(dir.path())?;
mem.set_capacity(blob_stored + blob_stored / 4);
tree.index.update_runtime_config(|c| {
c.tight_space_compaction = true;
})?;
let blob_dir = dir.path().join(crate::file::BLOBS_FOLDER);
let before = pool.excess_on(&*mem, &blob_dir);
tree.major_compact(64 * 1024 * 1024, gc_watermark)?;
assert!(mem.punched_bytes() > 0, "the pass reclaimed in slices");
assert_eq!(
pool.excess_on(&*mem, &blob_dir) - before,
0,
"a slice relocated past the room it held on the blob volume",
);
for i in 0..N {
let generation = if i % 2 == 0 { 2 } else { 1 };
assert_eq!(
tree.get(reloc_key(i).as_bytes(), crate::MAX_SEQNO)?
.as_deref(),
Some(value(i, generation).as_slice()),
"key {i}",
);
}
Ok(())
}
#[test]
fn tight_space_declines_when_the_blob_backend_cannot_punch() -> crate::Result<()> {
let (_dir, tree, mem) = relocating_tree_on_a_tight_blob_volume()?;
mem.set_punch_hole_supported(false);
let result = tree.major_compact(64 * 1024 * 1024, 4 * RELOC_KEYS)?;
assert_eq!(
result.action,
crate::compaction::CompactionAction::Nothing,
"{result:?}"
);
assert_eq!(mem.punched_bytes(), 0);
assert_relocated_values(&tree)
}
const RELOC_KEYS: u64 = 4_000;
fn reloc_key(i: u64) -> String {
format!("key{i:08}")
}
fn reloc_value(i: u64, generation: u8) -> Vec<u8> {
let mut s = (i + 1).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ (u64::from(generation) << 1);
(0..200u32)
.map(|_| {
s ^= s << 13;
s ^= s >> 7;
s ^= s << 17;
#[expect(
clippy::cast_possible_truncation,
reason = "xorshift byte extraction; the high bits are intentionally dropped"
)]
let byte = (s >> 24) as u8;
byte
})
.collect()
}
fn assert_relocated_values(tree: &crate::BlobTree) -> crate::Result<()> {
for i in 0..RELOC_KEYS {
let expected = reloc_value(i, if i % 2 == 0 { 2 } else { 1 });
assert_eq!(
tree.get(reloc_key(i).as_bytes(), crate::MAX_SEQNO)?
.as_deref(),
Some(expected.as_slice()),
"key {i} wrong/lost after the relocating tight-space merge",
);
}
Ok(())
}
fn relocating_tree_on_a_tight_blob_volume()
-> crate::Result<(tempfile::TempDir, crate::BlobTree, Arc<crate::fs::MemFs>)> {
use crate::config::LevelRoute;
use crate::fs::{Fs, MemFs};
const N: u64 = RELOC_KEYS;
let (k, val) = (reloc_key, reloc_value);
let dir = tempfile::tempdir()?;
let cold_dir = dir.path().join("cold");
let cold = Arc::new(MemFs::with_capacity(u64::MAX));
cold.create_dir_all(&cold_dir)?;
let mem = Arc::new(MemFs::with_capacity(u64::MAX));
let cold_fs: Arc<dyn Fs> = cold;
let config = Config::new(
&dir,
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.data_block_size_policy(BlockSizePolicy::all(512))
.with_shared_fs(mem.clone())
.level_routes(vec![LevelRoute {
levels: 0..7,
path: cold_dir,
fs: cold_fs,
}])
.with_kv_separation(Some(
KvSeparationOptions::default()
.separation_threshold(64)
.age_cutoff(1.0)
.staleness_threshold(0.1)
.file_target_size(48 * 1024),
));
let tree = match config.open()? {
crate::AnyTree::Blob(t) => t,
crate::AnyTree::Standard(_) => panic!("expected Blob tree"),
};
for i in 0..N {
tree.insert(k(i).as_bytes(), val(i, 1), i);
}
tree.flush_active_memtable(0)?;
for i in (0..N).step_by(2) {
tree.insert(k(i).as_bytes(), val(i, 2), N + i);
}
tree.flush_active_memtable(0)?;
let gc_watermark = 4 * N;
tree.index.update_runtime_config(|c| {
c.storage_admission_check = true;
c.storage_limit_bytes = None;
})?;
tree.major_compact(64 * 1024 * 1024, gc_watermark)?;
const PROBE: u64 = 1 << 40;
mem.set_capacity(PROBE);
let blob_stored = PROBE - mem.available_space(dir.path())?;
mem.set_capacity(blob_stored + blob_stored / 4);
tree.index.update_runtime_config(|c| {
c.tight_space_compaction = true;
})?;
Ok((dir, tree, mem))
}
#[test]
fn tight_space_declines_when_punching_inputs_frees_the_wrong_volume() -> crate::Result<()> {
use crate::config::LevelRoute;
use crate::fs::{Fs, MemFs};
let dir = tempfile::tempdir()?;
let hot_dir = dir.path().join("hot");
let hot = Arc::new(MemFs::with_capacity(u64::MAX));
hot.create_dir_all(&hot_dir)?;
let main = Arc::new(MemFs::with_capacity(u64::MAX));
let hot_fs: Arc<dyn Fs> = hot.clone();
let tree = match Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.data_block_size_policy(BlockSizePolicy::all(512))
.with_shared_fs(main.clone())
.level_routes(vec![LevelRoute {
levels: 0..1,
path: hot_dir.clone(),
fs: hot_fs,
}])
.open()?
{
crate::AnyTree::Standard(t) => t,
crate::AnyTree::Blob(_) => panic!("expected Standard tree"),
};
for i in 0..TIGHT_SPACE_KEYS {
tree.insert(tight_space_key(i).as_bytes(), vec![0xCDu8; 64], i);
}
tree.flush_active_memtable(0)?;
main.set_capacity(64 * 1024);
tree.update_runtime_config(|c| {
c.storage_admission_check = true;
c.tight_space_compaction = true;
})?;
tree.major_compact(64 * 1024 * 1024, 0)?;
assert_eq!(
hot.punched_bytes(),
0,
"punching the routed inputs would free the wrong volume: the constrained \
destination gains every slice output and loses nothing",
);
let sidecars = hot
.read_dir(&hot_dir.join("tables"))?
.into_iter()
.filter(|e| e.file_name.contains("restrict-bound"))
.count();
assert_eq!(sidecars, 0, "no slice may commit against such an input");
for i in 0..TIGHT_SPACE_KEYS {
assert!(
tree.get(tight_space_key(i).as_bytes(), crate::MAX_SEQNO)?
.is_some(),
"key {i} lost",
);
}
Ok(())
}
#[test]
fn a_refused_sidecar_read_is_reported_not_walked_as_corruption() -> crate::Result<()> {
use crate::fs::{Fault, FaultFs, FaultOp, FaultRule, Fs};
use crate::io::ErrorKind;
let dir = tempfile::tempdir()?;
let capacity = capfs::CapacityFs::new();
let fault = FaultFs::new(capacity.clone());
let injector = fault.injector();
let fs: Arc<dyn Fs> = Arc::new(fault);
let reopened = tight_space_crash_and_reopen(
dir.path(),
Arc::clone(&fs),
|used| capacity.set_available_space(used / 4),
|| capacity.punched_bytes(),
)?;
let version = reopened.current_version();
let Some(restricted) = version
.iter_tables()
.find(|t| t.restrict_lower_bound().is_some())
else {
panic!("the punched input must reopen as a restricted table");
};
let path = (*restricted.path).clone();
let clean = crate::verify::verify_sst_file_with_fs(&fs, &path);
assert!(
clean.errors.is_empty(),
"a legitimately punched table verifies clean: {:?}",
clean.errors,
);
injector.arm(
FaultRule::new(FaultOp::Open, Fault::Error(ErrorKind::PermissionDenied))
.on_path(".restrict-bound"),
);
let report = crate::verify::verify_sst_file_with_fs(&fs, &path);
injector.clear();
assert!(
!report.errors.is_empty(),
"the refused read must be reported at all: {report:?}",
);
assert!(
report
.errors
.iter()
.all(|e| matches!(e, crate::verify::BlockVerifyError::SstFileUnreadable { .. })),
"a refused sidecar read must be reported, not turned into corruption \
findings over the punched prefix: {:?}",
report.errors,
);
Ok(())
}
#[test]
fn tight_space_engages_when_the_local_share_covers_the_output() -> crate::Result<()> {
use crate::config::LevelRoute;
use crate::fs::{Fs, MemFs};
let dir = tempfile::tempdir()?;
let hot_dir = dir.path().join("hot");
let hot = Arc::new(MemFs::with_capacity(u64::MAX));
hot.create_dir_all(&hot_dir)?;
let main = Arc::new(MemFs::with_capacity(u64::MAX));
let hot_fs: Arc<dyn Fs> = hot;
let tree = match Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.data_block_size_policy(BlockSizePolicy::all(512))
.with_shared_fs(main.clone())
.level_routes(vec![LevelRoute {
levels: 0..1,
path: hot_dir.clone(),
fs: hot_fs,
}])
.open()?
{
crate::AnyTree::Standard(t) => t,
crate::AnyTree::Blob(_) => panic!("expected Standard tree"),
};
for i in 0..TIGHT_SPACE_KEYS {
tree.insert(tight_space_key(i).as_bytes(), vec![0xCDu8; 64], i);
}
tree.flush_active_memtable(0)?;
tree.major_compact(64 * 1024 * 1024, 0)?;
let local_bytes: u64 = tree
.current_version()
.iter_tables()
.filter(|t| t.path.starts_with(dir.path().join("tables")))
.map(crate::table::Table::file_len)
.sum();
assert!(
local_bytes > 0,
"the first generation is on the destination"
);
for i in 0..64u64 {
tree.insert(
tight_space_key(i).as_bytes(),
vec![0xEFu8; 64],
TIGHT_SPACE_KEYS + i,
);
}
tree.flush_active_memtable(0)?;
let remote_bytes: u64 = tree
.current_version()
.iter_tables()
.filter(|t| t.path.starts_with(&hot_dir))
.map(crate::table::Table::file_len)
.sum();
assert!(
remote_bytes > 0 && remote_bytes < local_bytes,
"the routed share must be the smaller one ({remote_bytes} vs {local_bytes})",
);
let used = tree.storage_stats()?.used_bytes;
main.set_capacity(used + remote_bytes * 2);
tree.update_runtime_config(|c| {
c.storage_admission_check = true;
c.tight_space_compaction = true;
})?;
tree.major_compact(64 * 1024 * 1024, 0)?;
assert!(
main.punched_bytes() > 0,
"the destination-local inputs must be reclaimed rather than the pass \
declining over the routed remainder",
);
for i in 0..64u64 {
assert_eq!(
tree.get(tight_space_key(i).as_bytes(), crate::MAX_SEQNO)?
.as_deref(),
Some(&[0xEFu8; 64][..]),
"the newest generation survives the rewrite",
);
}
Ok(())
}
struct SetMerge;
impl crate::MergeOperator for SetMerge {
fn merge(
&self,
_key: &[u8],
base_value: Option<&[u8]>,
operands: &[&[u8]],
) -> crate::Result<crate::UserValue> {
let mut set: Vec<u8> = base_value.map(<[u8]>::to_vec).unwrap_or_default();
for operand in operands {
match operand.split_first() {
Some((0x02, rest)) => set.retain(|m| !rest.contains(m)),
_ => {
for m in *operand {
if !set.contains(m) {
set.push(*m);
}
}
}
}
}
set.sort_unstable();
Ok(set.into())
}
}
struct RewriteOneTable(TableId, u8);
impl CompactionStrategy for RewriteOneTable {
fn get_name(&self) -> &'static str {
"RewriteOneTableTest"
}
fn choose(&self, _: &Version, _: &Config, _: &CompactionState) -> Choice {
Choice::Merge(Input {
table_ids: core::iter::once(self.0).collect(),
dest_level: self.1,
canonical_level: self.1,
target_size: u64::MAX,
})
}
}
struct MoveTables(Vec<TableId>, u8);
impl CompactionStrategy for MoveTables {
fn get_name(&self) -> &'static str {
"MoveTablesTest"
}
fn choose(&self, _: &Version, _: &Config, _: &CompactionState) -> Choice {
Choice::Move(Input {
table_ids: self.0.iter().copied().collect(),
dest_level: self.1,
canonical_level: self.1,
target_size: u64::MAX,
})
}
}
fn table_ids_on_level(tree: &crate::Tree, level: usize) -> Vec<TableId> {
let version = tree.version_history.read().latest_version();
let mut ids: Vec<TableId> = version
.version
.level(level)
.map(|level| {
level
.iter()
.flat_map(|run| run.iter())
.map(Table::id)
.collect()
})
.unwrap_or_default();
ids.sort_unstable_by(|a, b| b.cmp(a));
ids
}
#[test]
fn a_move_of_part_of_l0_does_not_leave_an_older_peer_above_it() -> crate::Result<()> {
let dir = tempfile::tempdir()?;
let seqno = SequenceNumberCounter::default();
let config = Config::new(&dir, seqno.clone(), SequenceNumberCounter::default());
let last_level = config.level_count - 1;
let tree = config.open()?;
let crate::AnyTree::Standard(tree) = tree else {
unreachable!("standard tree configured (no kv separation)");
};
tree.insert("k", "old", seqno.next());
tree.flush_active_memtable(0)?;
tree.insert("k", "new", seqno.next());
tree.flush_active_memtable(0)?;
let ids = table_ids_on_level(&tree, 0);
assert_eq!(2, ids.len(), "the recipe needs two L0 tables");
#[expect(clippy::indexing_slicing, reason = "length asserted right above")]
let newer = ids[0];
tree.compact(Arc::new(MoveTables(vec![newer], last_level)), seqno.get())?;
assert_eq!(
Some(&b"new"[..]),
tree.get("k", crate::MAX_SEQNO)?.as_deref(),
"the moved newer version must not be masked by the peer left in L0",
);
Ok(())
}
#[test]
fn a_move_of_overlapping_tables_keeps_them_in_separate_runs() -> crate::Result<()> {
let dir = tempfile::tempdir()?;
let seqno = SequenceNumberCounter::default();
let config = Config::new(&dir, seqno.clone(), SequenceNumberCounter::default());
let last_level = config.level_count - 1;
let tree = config.open()?;
let crate::AnyTree::Standard(tree) = tree else {
unreachable!("standard tree configured (no kv separation)");
};
tree.insert("a", "1", seqno.next());
tree.insert("z", "1", seqno.next());
tree.flush_active_memtable(0)?;
tree.insert("m", "1", seqno.next());
tree.flush_active_memtable(0)?;
let ids = table_ids_on_level(&tree, 0);
assert_eq!(2, ids.len(), "the recipe needs two overlapping L0 tables");
let result = tree.compact(Arc::new(MoveTables(ids, last_level)), seqno.get())?;
assert_eq!(crate::compaction::CompactionAction::Moved, result.action);
assert_eq!(
2,
tree.current_version()
.level(usize::from(last_level))
.map_or(0, |level| level.run_count()),
"the two overlapping tables are kept in runs of their own",
);
for key in ["a", "m", "z"] {
assert_eq!(
Some(&b"1"[..]),
tree.get(key, crate::MAX_SEQNO)?.as_deref(),
"key {key} must still be readable after the move",
);
}
Ok(())
}
#[test]
fn a_move_from_two_levels_sorts_the_run_it_lands_in() -> crate::Result<()> {
let dir = tempfile::tempdir()?;
let seqno = SequenceNumberCounter::default();
let config = Config::new(&dir, seqno.clone(), SequenceNumberCounter::default());
let last_level = config.level_count - 1;
let tree = config.open()?;
let crate::AnyTree::Standard(tree) = tree else {
unreachable!("standard tree configured (no kv separation)");
};
tree.insert("a", "1", seqno.next());
tree.flush_active_memtable(0)?;
tree.compact(Arc::new(crate::compaction::MoveDown(0, 1)), seqno.get())?;
tree.insert("z", "1", seqno.next());
tree.flush_active_memtable(0)?;
let mut ids = table_ids_on_level(&tree, 0);
ids.extend(table_ids_on_level(&tree, 1));
assert_eq!(2, ids.len(), "the recipe needs one table on each level");
let result = tree.compact(Arc::new(MoveTables(ids, last_level)), seqno.get())?;
assert_eq!(
crate::compaction::CompactionAction::Moved,
result.action,
"the tables are disjoint, so the move itself is sound",
);
for key in ["a", "z"] {
assert_eq!(
Some(&b"1"[..]),
tree.get(key, crate::MAX_SEQNO)?.as_deref(),
"key {key} must survive landing in a run built from two levels",
);
}
Ok(())
}
#[test]
fn a_cross_folder_move_of_overlapping_tables_is_rewritten() -> crate::Result<()> {
use crate::config::LevelRoute;
use crate::fs::{Fs, MemFs};
let dir = tempfile::tempdir()?;
let seqno = SequenceNumberCounter::default();
let hot_dir = crate::path::PathBuf::from("/hot-tier");
let hot = Arc::new(MemFs::with_capacity(u64::MAX));
hot.create_dir_all(&hot_dir)?;
let hot_fs: Arc<dyn Fs> = hot;
let config = Config::new(dir.path(), seqno.clone(), SequenceNumberCounter::default())
.with_shared_fs(Arc::new(MemFs::with_capacity(u64::MAX)))
.level_routes(vec![LevelRoute {
levels: 0..1,
path: hot_dir,
fs: hot_fs,
}]);
let last_level = config.level_count - 1;
let crate::AnyTree::Standard(tree) = config.open()? else {
unreachable!("standard tree configured (no kv separation)");
};
tree.insert("a", "1", seqno.next());
tree.insert("z", "1", seqno.next());
tree.flush_active_memtable(0)?;
tree.insert("m", "1", seqno.next());
tree.flush_active_memtable(0)?;
let ids = table_ids_on_level(&tree, 0);
assert_eq!(2, ids.len(), "the recipe needs two overlapping L0 tables");
let result = tree.compact(Arc::new(MoveTables(ids, last_level)), seqno.get())?;
assert_eq!(
crate::compaction::CompactionAction::Merged,
result.action,
"routing rewrites the inputs, which consolidates them instead of \
leaving them in one unreadable run",
);
for key in ["a", "m", "z"] {
assert_eq!(
Some(&b"1"[..]),
tree.get(key, crate::MAX_SEQNO)?.as_deref(),
"key {key} must survive the rewrite",
);
}
Ok(())
}
#[test]
fn a_move_out_of_l0_weighs_every_overlapping_peer_not_just_the_first() -> crate::Result<()> {
let dir = tempfile::tempdir()?;
let seqno = SequenceNumberCounter::default();
let config = Config::new(&dir, seqno.clone(), SequenceNumberCounter::default());
let last_level = config.level_count - 1;
let tree = config.open()?;
let crate::AnyTree::Standard(tree) = tree else {
unreachable!("standard tree configured (no kv separation)");
};
tree.insert("a", "1", seqno.next());
tree.insert("m", "mover", seqno.next());
tree.flush_active_memtable(0)?;
let mover = {
let ids = table_ids_on_level(&tree, 0);
assert_eq!(1, ids.len(), "the mover is the only table so far");
#[expect(clippy::indexing_slicing, reason = "length asserted right above")]
let id = ids[0];
id
};
tree.insert("a", "newer", seqno.next());
tree.flush_active_memtable(0)?;
tree.insert("m", "older-peer", 0);
tree.flush_active_memtable(0)?;
let result = tree.compact(Arc::new(MoveTables(vec![mover], last_level)), seqno.get())?;
assert_eq!(
crate::compaction::CompactionAction::Nothing,
result.action,
"the second overlapping peer is not strictly newer, so the move hides a version",
);
assert_eq!(
Some(&b"newer"[..]),
tree.get("a", crate::MAX_SEQNO)?.as_deref(),
"key a still reads the newest version",
);
assert_eq!(
Some(&b"mover"[..]),
tree.get("m", crate::MAX_SEQNO)?.as_deref(),
"and key m still reads the version the move would have hidden",
);
Ok(())
}
#[test]
fn a_move_of_an_older_l0_table_under_a_newer_peer_still_runs() -> crate::Result<()> {
let dir = tempfile::tempdir()?;
let seqno = SequenceNumberCounter::default();
let config = Config::new(&dir, seqno.clone(), SequenceNumberCounter::default());
let last_level = config.level_count - 1;
let tree = config.open()?;
let crate::AnyTree::Standard(tree) = tree else {
unreachable!("standard tree configured (no kv separation)");
};
let old_seqno = seqno.next();
tree.insert("k", "old", old_seqno);
tree.flush_active_memtable(0)?;
let new_seqno = seqno.next();
tree.insert("k", "new", new_seqno);
tree.flush_active_memtable(0)?;
let ids = table_ids_on_level(&tree, 0);
assert_eq!(2, ids.len(), "the recipe needs two L0 tables");
#[expect(clippy::indexing_slicing, reason = "length asserted right above")]
let older = ids[1];
let result = tree.compact(Arc::new(MoveTables(vec![older], last_level)), seqno.get())?;
assert_eq!(
crate::compaction::CompactionAction::Moved,
result.action,
"an overlap with a strictly newer L0 peer does not hide anything",
);
assert_eq!(
Some(&b"new"[..]),
tree.get("k", crate::MAX_SEQNO)?.as_deref(),
"the newer version still answers the current read",
);
assert_eq!(
Some(&b"old"[..]),
tree.get("k", new_seqno)?.as_deref(),
"and a snapshot predating it still reaches the moved older version",
);
Ok(())
}
#[test]
fn a_move_up_under_a_newer_table_is_refused() -> crate::Result<()> {
let dir = tempfile::tempdir()?;
let seqno = SequenceNumberCounter::default();
let config = Config::new(&dir, seqno.clone(), SequenceNumberCounter::default());
let tree = config.open()?;
let crate::AnyTree::Standard(tree) = tree else {
unreachable!("standard tree configured (no kv separation)");
};
tree.insert("k", "old", seqno.next());
tree.flush_active_memtable(0)?;
tree.compact(Arc::new(crate::compaction::MoveDown(0, 2)), seqno.get())?;
tree.insert("k", "new", seqno.next());
tree.flush_active_memtable(0)?;
tree.compact(Arc::new(crate::compaction::MoveDown(0, 1)), seqno.get())?;
assert_eq!(
Some(&b"new"[..]),
tree.get("k", crate::MAX_SEQNO)?.as_deref(),
"the recipe needs the newer copy to win before the move under test",
);
let ids = table_ids_on_level(&tree, 2);
assert_eq!(1, ids.len(), "the recipe needs exactly one table on L2");
let result = tree.compact(Arc::new(MoveTables(ids, 1)), seqno.get())?;
assert_eq!(
crate::compaction::CompactionAction::Nothing,
result.action,
"a move that lifts an older table over a newer one must be declined",
);
assert_eq!(
Some(&b"new"[..]),
tree.get("k", crate::MAX_SEQNO)?.as_deref(),
"and the newer version still answers the read",
);
Ok(())
}
#[test]
fn a_move_under_a_level_that_already_outranked_it_still_runs() -> crate::Result<()> {
let dir = tempfile::tempdir()?;
let seqno = SequenceNumberCounter::default();
let config = Config::new(&dir, seqno.clone(), SequenceNumberCounter::default());
let tree = config.open()?;
let crate::AnyTree::Standard(tree) = tree else {
unreachable!("standard tree configured (no kv separation)");
};
tree.insert("k", "old", seqno.next());
tree.flush_active_memtable(0)?;
tree.compact(Arc::new(crate::compaction::MoveDown(0, 2)), seqno.get())?;
tree.insert("k", "new", seqno.next());
tree.flush_active_memtable(0)?;
tree.compact(Arc::new(crate::compaction::MoveDown(0, 1)), seqno.get())?;
assert_eq!(
Some(&b"new"[..]),
tree.get("k", crate::MAX_SEQNO)?.as_deref(),
"the recipe needs the newer copy to win before the move under test",
);
let result = tree.compact(Arc::new(crate::compaction::MoveDown(2, 3)), seqno.get())?;
assert_eq!(
crate::compaction::CompactionAction::Moved,
result.action,
"a move that changes no precedence must not be declined",
);
assert_eq!(
Some(&b"new"[..]),
tree.get("k", crate::MAX_SEQNO)?.as_deref(),
"and the answer is unchanged by it",
);
Ok(())
}
#[test]
fn a_rewrite_of_one_last_level_table_does_not_prove_absence() -> crate::Result<()> {
let dir = tempfile::tempdir()?;
let seqno = SequenceNumberCounter::default();
let config = Config::new(&dir, seqno.clone(), SequenceNumberCounter::default())
.with_merge_operator(Some(Arc::new(SetMerge)));
let last_level = config.level_count - 1;
let tree = config.open()?;
let crate::AnyTree::Standard(tree) = tree else {
unreachable!("standard tree configured (no kv separation)");
};
tree.insert("k", [1u8, 2, 3], seqno.next());
tree.flush_active_memtable(0)?;
tree.compact(
Arc::new(crate::compaction::MoveDown(0, last_level)),
seqno.get(),
)?;
tree.merge("k", [0x02u8, 2], seqno.next());
tree.flush_active_memtable(0)?;
let fresh = {
let version = tree.version_history.read().latest_version();
version
.version
.level(0)
.and_then(|level| level.iter().flat_map(|run| run.iter()).map(Table::id).max())
.ok_or_else(|| crate::Error::from(crate::io::Error::other("no L0 table")))?
};
tree.compact(Arc::new(RewriteOneTable(fresh, last_level)), seqno.get())?;
let version = tree.version_history.read().latest_version();
let level = version
.version
.level(last_level.into())
.ok_or_else(|| crate::Error::from(crate::io::Error::other("last level is missing")))?;
assert!(
level.iter().count() >= 2,
"the recipe needs two overlapping runs at the last level, else the \
rewrite below would hold the base after all",
);
let operand_table = version
.version
.iter_tables()
.map(Table::id)
.max()
.ok_or_else(|| crate::Error::from(crate::io::Error::other("no tables")))?;
drop(version);
tree.compact(
Arc::new(RewriteOneTable(operand_table, last_level)),
seqno.get(),
)?;
assert_eq!(
Some(vec![1u8, 3]),
tree.get("k", crate::MAX_SEQNO)?.map(|v| v.to_vec()),
"the removal must still apply to the base the rewrite never read",
);
Ok(())
}
#[test]
fn a_move_beneath_an_older_overlapping_table_does_not_stale_the_read() -> crate::Result<()> {
let dir = tempfile::tempdir()?;
let seqno = SequenceNumberCounter::default();
let config = Config::new(&dir, seqno.clone(), SequenceNumberCounter::default());
let last_level = config.level_count - 1;
let tree = config.open()?;
tree.insert("k", "old", seqno.next());
tree.flush_active_memtable(0)?;
tree.compact(Arc::new(crate::compaction::MoveDown(0, 1)), seqno.get())?;
tree.insert("k", "new", seqno.next());
tree.flush_active_memtable(0)?;
tree.compact(
Arc::new(crate::compaction::MoveDown(0, last_level)),
seqno.get(),
)?;
assert_eq!(
Some(&b"new"[..]),
tree.get("k", crate::MAX_SEQNO)?.as_deref(),
"the newer version must win wherever the compaction put it",
);
Ok(())
}
#[test]
fn compactions_of_one_tree_draw_on_one_budget() -> crate::Result<()> {
use core::time::Duration;
let dir = tempfile::tempdir()?;
let rate = 1_024 * 1_024; let tree = match Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.compaction_rate_limit(rate)
.open()?
{
crate::AnyTree::Standard(t) => t,
crate::AnyTree::Blob(_) => panic!("expected Standard tree"),
};
let strategy: Arc<dyn CompactionStrategy> =
Arc::new(crate::compaction::major::Strategy::new(64 * 1024 * 1024));
let first = super::Options::from_tree(&tree, Arc::clone(&strategy));
let owed = first.rate_limiter.acquire_wait(rate * 4, Duration::ZERO);
assert!(
!owed.is_zero(),
"spending four seconds of budget at once must leave a debt to wait out",
);
let second = super::Options::from_tree(&tree, strategy);
let next = second.rate_limiter.acquire_wait(1, Duration::ZERO);
assert!(
!next.is_zero(),
"the budget the first compaction spent is still spent: a second \
compaction starting at the same instant must wait, not find a full \
bucket (waited {next:?})",
);
Ok(())
}
#[test]
fn a_second_compaction_does_not_re_earn_the_burst() -> crate::Result<()> {
use core::time::Duration;
let dir = tempfile::tempdir()?;
let rate = 1_024 * 1_024;
let tree = match Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.compaction_rate_limit(rate)
.open()?
{
crate::AnyTree::Standard(t) => t,
crate::AnyTree::Blob(_) => panic!("expected Standard tree"),
};
let strategy: Arc<dyn CompactionStrategy> =
Arc::new(crate::compaction::major::Strategy::new(64 * 1024 * 1024));
let first = super::Options::from_tree(&tree, Arc::clone(&strategy));
assert!(
first
.rate_limiter
.acquire_wait(rate, Duration::ZERO)
.is_zero(),
"the seeded burst covers the first second of work",
);
let second = super::Options::from_tree(&tree, strategy);
let after = second.rate_limiter.acquire_wait(rate, Duration::ZERO);
assert!(
!after.is_zero(),
"the burst is earned by idling, not granted per compaction: a second \
compaction at the same instant must wait for the refill (waited \
{after:?})",
);
Ok(())
}
#[test]
fn admitted_work_stays_within_the_burst_plus_the_rate() -> crate::Result<()> {
use core::time::Duration;
let dir = tempfile::tempdir()?;
let rate: u64 = 1_024 * 1_024;
let tree = match Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.compaction_rate_limit(rate)
.open()?
{
crate::AnyTree::Standard(t) => t,
crate::AnyTree::Blob(_) => panic!("expected Standard tree"),
};
let chunk = 64 * 1_024u64;
let mut now = Duration::ZERO;
let mut admitted = 0u64;
for _ in 0..10 {
let opts = super::Options::from_tree(
&tree,
Arc::new(crate::compaction::major::Strategy::new(64 * 1024 * 1024)),
);
for _ in 0..4 {
let wait = opts.rate_limiter.acquire_wait(chunk, now);
now += wait;
admitted += chunk;
}
}
let ceiling =
rate + rate * now.as_secs() + rate * u64::from(now.subsec_nanos()) / 1_000_000_000;
assert!(
admitted <= ceiling,
"admitted {admitted} B over {now:?} exceeds burst + rate × elapsed = \
{ceiling} B",
);
assert!(
!now.is_zero(),
"asking for more than the burst must have forced a wait",
);
Ok(())
}
#[test]
fn a_blob_tree_shares_the_index_trees_budget() -> crate::Result<()> {
use core::time::Duration;
let dir = tempfile::tempdir()?;
let rate = 1_024 * 1_024;
let tree = match Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.compaction_rate_limit(rate)
.with_kv_separation(Some(KvSeparationOptions::default()))
.open()?
{
crate::AnyTree::Blob(t) => t,
crate::AnyTree::Standard(_) => panic!("expected Blob tree"),
};
let strategy: Arc<dyn CompactionStrategy> =
Arc::new(crate::compaction::major::Strategy::new(64 * 1024 * 1024));
let first = super::Options::from_tree(&tree.index, Arc::clone(&strategy));
assert!(
!first
.rate_limiter
.acquire_wait(rate * 4, Duration::ZERO)
.is_zero(),
);
let second = super::Options::from_tree(&tree.index, strategy);
assert!(
!second
.rate_limiter
.acquire_wait(1, Duration::ZERO)
.is_zero(),
"the blob tree's compactions run through the index tree, so they meet \
the debt an earlier one left",
);
Ok(())
}
#[test]
fn an_unlimited_tree_never_makes_a_compaction_wait() -> crate::Result<()> {
use core::time::Duration;
let dir = tempfile::tempdir()?;
let tree = match Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.open()?
{
crate::AnyTree::Standard(t) => t,
crate::AnyTree::Blob(_) => panic!("expected Standard tree"),
};
assert_eq!(
tree.config.compaction_rate_limit, 0,
"0 is the default, and this test is about that default",
);
let opts = super::Options::from_tree(
&tree,
Arc::new(crate::compaction::major::Strategy::new(64 * 1024 * 1024)),
);
for bytes in [1u64, 1 << 20, u64::MAX] {
assert!(
opts.rate_limiter
.acquire_wait(bytes, Duration::ZERO)
.is_zero(),
"an unlimited tree must admit {bytes} B immediately",
);
}
assert!(
opts.rate_limiter
.acquire_wait(u64::MAX, Duration::from_secs(1))
.is_zero(),
);
Ok(())
}
#[test]
fn a_reopened_tree_starts_with_a_fresh_budget() -> crate::Result<()> {
use core::time::Duration;
let dir = tempfile::tempdir()?;
let rate = 1_024 * 1_024;
let open = || -> crate::Result<crate::Tree> {
match Config::new(
dir.path(),
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.compaction_rate_limit(rate)
.open()?
{
crate::AnyTree::Standard(t) => Ok(t),
crate::AnyTree::Blob(_) => panic!("expected Standard tree"),
}
};
let strategy: Arc<dyn CompactionStrategy> =
Arc::new(crate::compaction::major::Strategy::new(64 * 1024 * 1024));
{
let tree = open()?;
let opts = super::Options::from_tree(&tree, Arc::clone(&strategy));
assert!(
!opts
.rate_limiter
.acquire_wait(rate * 4, Duration::ZERO)
.is_zero(),
);
}
let tree = open()?;
let opts = super::Options::from_tree(&tree, strategy);
assert!(
opts.rate_limiter.acquire_wait(1, Duration::ZERO).is_zero(),
"a new process gets a new bucket; the debt does not outlive the tree",
);
Ok(())
}
fn open_standard(
dir: &std::path::Path,
configure: impl FnOnce(Config) -> Config,
) -> crate::Result<crate::Tree> {
match configure(Config::new(
dir,
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
))
.open()?
{
crate::AnyTree::Standard(t) => Ok(t),
crate::AnyTree::Blob(_) => panic!("expected Standard tree"),
}
}
#[test]
fn stale_frame_bytes_of_a_cell_row_are_its_references_into_stale_files() -> crate::Result<()> {
use crate::blob_tree::field_row::{RowCell, RowField, encode_row};
use crate::blob_tree::handle::BlobIndirection;
use crate::vlog::ValueHandle;
use crate::{InternalValue, vlog::BlobFileId};
let reference = |blob_file_id: u64, size: u32| RowCell::Ref {
indirection: BlobIndirection {
vhandle: ValueHandle {
blob_file_id,
offset: 0,
on_disk_size: size,
},
size,
},
owner: true,
};
let row = encode_row(&[
RowField::bytes(3, RowCell::Value(b"inline")),
RowField::bytes(4, reference(7, 100)),
RowField::bytes(5, reference(8, 300)),
RowField::bytes(6, reference(7, 40)),
])?;
let stale: crate::HashSet<BlobFileId> = core::iter::once(7).collect();
let key = b"row-key";
let item = InternalValue::from_components(*key, row, 1, crate::ValueType::CellRow);
assert_eq!(
super::stale_frame_bytes(&item, &stale)?,
super::frame_bytes(key, 100) + super::frame_bytes(key, 40),
);
let plain = InternalValue::from_components(*key, b"v".to_vec(), 2, crate::ValueType::Value);
assert_eq!(super::stale_frame_bytes(&plain, &stale)?, 0);
Ok(())
}
#[test]
fn tight_slices_count_the_blocks_they_read_in_the_runs_work() -> crate::Result<()> {
use crate::AbstractTree;
let dir = tempfile::tempdir()?;
let tree = open_standard(dir.path(), |c| {
c.use_cache(Arc::new(crate::Cache::with_capacity_bytes(0)))
.data_block_size_policy(BlockSizePolicy::all(512))
})?;
for i in 0..2_000u32 {
tree.insert(format!("k{i:06}"), vec![b'v'; 64], u64::from(i));
}
tree.flush_active_memtable(0)?;
let version = tree.current_version();
let inputs: Vec<Table> = version.iter_tables().cloned().collect();
let total: u64 = inputs.iter().map(Table::file_len).sum();
let opts = super::Options::from_tree(
&tree,
Arc::new(crate::compaction::major::Strategy::new(64 * 1024 * 1024)),
);
let slices = super::tight_slices(
&inputs,
super::SliceCaps {
tables: u64::MAX,
blobs: u64::MAX,
total: total / 4,
},
&super::SliceOutputs {
target_size: 64 * 1024 * 1024,
cut_keys: &[],
},
None,
&tree.config.comparator,
&super::input_pacer(&opts),
)?;
assert!(slices.len() > 1, "the fixture needs a pass in slices");
assert!(
opts.work.report().measured.read > 0,
"the blocks read to cut the slices are counted"
);
Ok(())
}
#[test]
fn trees_sharing_a_limiter_draw_on_one_budget() -> crate::Result<()> {
use core::time::Duration;
let rate = 1_024 * 1_024;
let shared = Arc::new(crate::rate_limiter::RateLimiter::new(rate));
let (a_dir, b_dir) = (tempfile::tempdir()?, tempfile::tempdir()?);
let a = open_standard(a_dir.path(), |c| {
c.compaction_rate_limit(rate * 100)
.compaction_rate_limiter(Arc::clone(&shared))
})?;
let b = open_standard(b_dir.path(), |c| {
c.compaction_rate_limiter(Arc::clone(&shared))
})?;
let strategy: Arc<dyn CompactionStrategy> =
Arc::new(crate::compaction::major::Strategy::new(64 * 1024 * 1024));
let on_a = super::Options::from_tree(&a, Arc::clone(&strategy));
assert!(
!on_a
.rate_limiter
.acquire_wait(rate * 4, Duration::ZERO)
.is_zero(),
"four seconds of budget at once leaves a debt",
);
let on_b = super::Options::from_tree(&b, strategy);
assert!(
!on_b.rate_limiter.acquire_wait(1, Duration::ZERO).is_zero(),
"the other tree's compaction meets the debt the first one left",
);
Ok(())
}
#[test]
fn trees_without_a_shared_limiter_keep_their_own_budget() -> crate::Result<()> {
use core::time::Duration;
let rate = 1_024 * 1_024;
let (a_dir, b_dir) = (tempfile::tempdir()?, tempfile::tempdir()?);
let a = open_standard(a_dir.path(), |c| c.compaction_rate_limit(rate))?;
let b = open_standard(b_dir.path(), |c| c.compaction_rate_limit(rate))?;
let strategy: Arc<dyn CompactionStrategy> =
Arc::new(crate::compaction::major::Strategy::new(64 * 1024 * 1024));
let on_a = super::Options::from_tree(&a, Arc::clone(&strategy));
assert!(
!on_a
.rate_limiter
.acquire_wait(rate * 4, Duration::ZERO)
.is_zero()
);
let on_b = super::Options::from_tree(&b, strategy);
assert!(
on_b.rate_limiter.acquire_wait(1, Duration::ZERO).is_zero(),
"a tree's own budget is untouched by another tree's debt",
);
Ok(())
}
#[test]
fn a_retune_through_one_tree_binds_every_tree_sharing_the_limiter() -> crate::Result<()> {
use crate::AbstractTree;
use core::time::Duration;
let shared = Arc::new(crate::rate_limiter::RateLimiter::new(1_000));
let (a_dir, b_dir) = (tempfile::tempdir()?, tempfile::tempdir()?);
let a = open_standard(a_dir.path(), |c| {
c.compaction_rate_limit(1_000_000)
.compaction_rate_limiter(Arc::clone(&shared))
})?;
let b = open_standard(b_dir.path(), |c| {
c.compaction_rate_limit(5)
.compaction_rate_limiter(Arc::clone(&shared))
})?;
assert_eq!(a.compaction_rate_limiter().rate(), 1_000);
assert_eq!(b.compaction_rate_limiter().rate(), 1_000);
a.compaction_rate_limiter()
.set_rate_at(2_000, Duration::ZERO);
assert_eq!(b.compaction_rate_limiter().rate(), 2_000);
let on_b = super::Options::from_tree(
&b,
Arc::new(crate::compaction::major::Strategy::new(64 * 1024 * 1024)),
);
assert_eq!(
on_b.rate_limiter.acquire_wait(3_000, Duration::ZERO),
Duration::from_secs(1),
);
Ok(())
}
#[test]
fn a_copy_of_a_trees_config_opens_with_a_limiter_of_its_own() -> crate::Result<()> {
use crate::AbstractTree;
use core::time::Duration;
let (a_dir, b_dir) = (tempfile::tempdir()?, tempfile::tempdir()?);
let a = open_standard(a_dir.path(), |c| c.compaction_rate_limit(1_000))?;
let mut copy = a.tree_config().clone();
copy.path = b_dir.path().to_path_buf();
let b = copy.compaction_rate_limit(5_000).open()?;
assert_eq!(a.compaction_rate_limiter().rate(), 1_000);
assert_eq!(
b.compaction_rate_limiter().rate(),
5_000,
"the copy's own rate"
);
a.compaction_rate_limiter()
.set_rate_at(2_000, Duration::ZERO);
assert_eq!(b.compaction_rate_limiter().rate(), 5_000, "not shared");
Ok(())
}
#[test]
fn compaction_reads_lift_a_backoff_a_scan_left_low() -> crate::Result<()> {
use crate::AbstractTree;
use crate::rate_limiter::{LatencyBackoff, RateLimiter};
use core::time::Duration;
let shared = Arc::new(RateLimiter::new(1 << 30));
shared.set_latency_backoff(Some(
LatencyBackoff::new(Duration::from_secs(10)).with_period(Duration::ZERO),
));
for _ in 0..20 {
shared.record_read_latency(Duration::from_secs(100));
}
let left_by_the_scan = shared.effective_rate();
assert!(
left_by_the_scan < shared.rate(),
"the scan lowered the rate"
);
let dir = tempfile::tempdir()?;
let tree = open_standard(dir.path(), |c| {
c.compaction_rate_limiter(Arc::clone(&shared))
})?;
let mut noise = 0x9E37_79B9_7F4A_7C15u64;
for flush in 0..4u64 {
for key in 0..256u64 {
let value: Vec<u8> = (0..512)
.flat_map(|_| {
noise = noise
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
noise.to_le_bytes()
})
.collect();
tree.insert(key.to_be_bytes(), value, flush * 256 + key);
}
tree.flush_active_memtable(0)?;
}
tree.major_compact(u64::MAX, 0)?;
assert!(
shared.effective_rate() > left_by_the_scan,
"the compaction's fast reads did not lift the backoff: {} of {}",
shared.effective_rate(),
shared.rate(),
);
Ok(())
}
#[cfg(all(feature = "columnar", feature = "lz4"))]
#[test]
fn columnar_range_merge_reads_lift_a_backoff_a_scan_left_low() -> crate::Result<()> {
use crate::AbstractTree;
use crate::rate_limiter::{LatencyBackoff, RateLimiter};
use core::time::Duration;
let shared = Arc::new(RateLimiter::new(1 << 30));
shared.set_latency_backoff(Some(
LatencyBackoff::new(Duration::from_secs(10)).with_period(Duration::ZERO),
));
let dir = tempfile::tempdir()?;
let tree = open_standard(dir.path(), |c| {
c.compaction_rate_limiter(Arc::clone(&shared))
.compaction_threads(4)
.subcompaction_min_bytes(0)
})?;
tree.update_runtime_config(|c| {
c.columnar = true;
c.data_block_compression_policy =
crate::config::CompressionPolicy::all(crate::CompressionType::None);
})?;
let write = |flush: u64| -> crate::Result<()> {
for i in 0..4_000u64 {
tree.insert(
format!("k{i:06}"),
format!("value-{flush}-{i}-payload"),
flush * 10_000 + i,
);
}
tree.flush_active_memtable(0)
};
write(0)?;
tree.major_compact(32 * 1024, 0)?;
assert!(tree.current_version().iter_tables().count() > 2);
write(1)?;
tree.update_runtime_config(|c| {
c.data_block_compression_policy =
crate::config::CompressionPolicy::all(crate::CompressionType::Lz4);
})?;
for _ in 0..20 {
shared.record_read_latency(Duration::from_secs(100));
}
let left_by_the_scan = shared.effective_rate();
tree.major_compact(32 * 1024, 0)?;
assert!(
shared.effective_rate() > left_by_the_scan,
"the merge's reads did not lift the backoff: {} of {}",
shared.effective_rate(),
shared.rate(),
);
for i in (0..2_000u64).step_by(101) {
assert_eq!(
tree.get(format!("k{i:06}"), crate::SeqNo::MAX)?.as_deref(),
Some(format!("value-1-{i}-payload").as_bytes()),
);
}
Ok(())
}
#[cfg(feature = "columnar")]
#[test]
fn merge_on_read_reads_lift_a_backoff_a_scan_left_low() -> crate::Result<()> {
use crate::AbstractTree;
use crate::config::{DeleteStrategy, DeleteStrategyPolicy};
use crate::rate_limiter::{LatencyBackoff, RateLimiter};
use core::time::Duration;
let shared = Arc::new(RateLimiter::new(1 << 30));
shared.set_latency_backoff(Some(
LatencyBackoff::new(Duration::from_secs(10)).with_period(Duration::ZERO),
));
for _ in 0..20 {
shared.record_read_latency(Duration::from_secs(100));
}
let left_by_the_scan = shared.effective_rate();
assert!(
left_by_the_scan < shared.rate(),
"the scan lowered the rate"
);
let dir = tempfile::tempdir()?;
let tree = open_standard(dir.path(), |c| {
c.compaction_rate_limiter(Arc::clone(&shared))
})?;
tree.update_runtime_config(|c| {
c.columnar = true;
c.zone_map = true;
c.delete_strategy = DeleteStrategyPolicy::all(DeleteStrategy::MergeOnRead);
})?;
for i in 0..2_000u64 {
tree.insert(format!("k{i:06}"), format!("value-{i}-payload"), i);
}
tree.remove_range("k000000", "k000050", 10_000);
tree.flush_active_memtable(0)?;
tree.major_compact(64 * 1024 * 1024, 20_000)?;
assert!(
tree.current_version()
.iter_tables()
.any(|table| !table.delete_bitmap().is_empty()),
"the segment was relocated with a delete bitmap",
);
assert!(
shared.effective_rate() > left_by_the_scan,
"the fast path's reads did not lift the backoff: {} of {}",
shared.effective_rate(),
shared.rate(),
);
Ok(())
}
mod locality_relocation {
use super::RewriteOneTable;
use crate::{AbstractTree, AnyTree, Config, KvSeparationOptions, SequenceNumberCounter};
use core::num::NonZeroU64;
use std::collections::BTreeSet;
use std::sync::Arc;
use test_log::test;
const FILES: u64 = 16;
const KEYS_PER_FILE: u64 = 16;
fn open(path: &std::path::Path, blob_opts: KvSeparationOptions) -> crate::Result<AnyTree> {
Config::new(
path,
SequenceNumberCounter::default(),
SequenceNumberCounter::default(),
)
.blob_compression(crate::CompressionType::None)
.with_kv_separation(Some(blob_opts))
.open()
}
fn relocating() -> KvSeparationOptions {
let Some(max_depth) = NonZeroU64::new(2) else {
panic!("two is not zero");
};
KvSeparationOptions::default().relocate_for_locality(max_depth, f32::MAX)
}
fn fill_interleaved(tree: &AnyTree) -> crate::Result<()> {
let mut seqno = 0;
for file in 0..FILES {
for i in 0..KEYS_PER_FILE {
let k = i * FILES + file;
tree.insert(format!("key{k:06}"), vec![b'v'; 2_048], seqno);
seqno += 1;
}
tree.flush_active_memtable(0)?;
}
Ok(())
}
fn blob_ids(tree: &AnyTree) -> BTreeSet<u64> {
tree.current_version()
.blob_files
.iter()
.map(crate::vlog::BlobFile::id)
.collect()
}
#[test]
fn a_blob_file_a_table_outside_the_merge_references_stays() -> crate::Result<()> {
let folder = tempfile::tempdir()?;
{
let tree = open(folder.path(), KvSeparationOptions::default())?;
fill_interleaved(&tree)?;
tree.major_compact(1_024, u64::MAX)?;
assert!(tree.table_count() > 1, "the layout needs several tables");
}
let tree = open(folder.path(), relocating())?;
let version = tree.current_version();
let mut tables = version.iter_tables();
let Some(table) = tables.next().cloned() else {
panic!("the split left no table");
};
let mut shared = BTreeSet::new();
for other in tables {
shared.extend(other.blob_links()?.iter().map(|link| link.blob_file_id));
}
let own: BTreeSet<u64> = table
.blob_links()?
.iter()
.map(|link| link.blob_file_id)
.collect();
let shared: BTreeSet<u64> = own.intersection(&shared).copied().collect();
assert!(!shared.is_empty(), "the merged table must share some files");
let table_count = tree.table_count();
drop(version);
tree.compact(Arc::new(RewriteOneTable(table.id(), 6)), u64::MAX)?;
assert_eq!(
tree.table_count(),
table_count,
"the one table was rewritten"
);
let after = blob_ids(&tree);
assert!(
shared.is_subset(&after),
"shared files {shared:?} must all survive, left {after:?}",
);
Ok(())
}
struct MergeInto(Vec<crate::TableId>, u8);
impl crate::compaction::CompactionStrategy for MergeInto {
fn get_name(&self) -> &'static str {
"MergeIntoTest"
}
fn choose(
&self,
_: &crate::version::Version,
_: &Config,
_: &crate::compaction::state::CompactionState,
) -> crate::compaction::Choice {
crate::compaction::Choice::Merge(crate::compaction::Input {
table_ids: self.0.iter().copied().collect(),
dest_level: self.1,
canonical_level: self.1,
target_size: u64::MAX,
})
}
}
#[test]
fn a_move_of_a_deeply_interleaved_table_relocates_it() -> crate::Result<()> {
let folder = tempfile::tempdir()?;
{
let tree = open(folder.path(), KvSeparationOptions::default())?;
fill_interleaved(&tree)?;
let ids: Vec<_> = tree
.current_version()
.iter_tables()
.map(crate::Table::id)
.collect();
tree.compact(Arc::new(MergeInto(ids, 1)), u64::MAX)?;
assert_eq!(tree.table_count(), 1);
assert_eq!(
tree.storage_stats()?.blob_references.depth,
FILES,
"the merge left the files interleaved",
);
}
let tree = open(folder.path(), relocating())?;
let Some(table) = tree
.current_version()
.iter_tables()
.next()
.map(crate::Table::id)
else {
panic!("the merge left one table");
};
let before = blob_ids(&tree);
tree.compact(Arc::new(super::MoveTables(vec![table], 6)), u64::MAX)?;
assert!(
before.is_disjoint(&blob_ids(&tree)),
"the interleaved files were rewritten on the way down",
);
assert_eq!(tree.storage_stats()?.blob_references.depth, 1);
Ok(())
}
#[cfg(feature = "columnar")]
#[test]
fn locality_relocation_takes_precedence_over_merge_on_read() -> crate::Result<()> {
use crate::config::{DeleteStrategy, DeleteStrategyPolicy};
let merge_on_read = |tree: &AnyTree| -> crate::Result<()> {
let AnyTree::Blob(blob) = tree else {
panic!("a tree that separates values");
};
blob.update_runtime_config(|c| {
c.columnar = true;
c.zone_map = true;
c.delete_strategy = DeleteStrategyPolicy::all(DeleteStrategy::MergeOnRead);
})
};
let folder = tempfile::tempdir()?;
{
let tree = open(folder.path(), KvSeparationOptions::default())?;
merge_on_read(&tree)?;
fill_interleaved(&tree)?;
tree.remove_range("key000000", "key000010", 10_000);
tree.flush_active_memtable(0)?;
let ids: Vec<_> = tree
.current_version()
.iter_tables()
.map(crate::Table::id)
.collect();
tree.compact(Arc::new(MergeInto(ids, 1)), 0)?;
assert_eq!(tree.table_count(), 1);
assert_eq!(tree.storage_stats()?.blob_references.depth, FILES);
}
let tree = open(folder.path(), relocating())?;
merge_on_read(&tree)?;
let Some(table) = tree
.current_version()
.iter_tables()
.next()
.map(crate::Table::id)
else {
panic!("the merge left one table");
};
let before = blob_ids(&tree);
tree.compact(Arc::new(RewriteOneTable(table, 1)), u64::MAX)?;
let after = blob_ids(&tree);
assert!(
before.intersection(&after).count() < before.len(),
"some interleaved files were rewritten: before {before:?}, after {after:?}",
);
assert_eq!(tree.storage_stats()?.blob_references.depth, 1);
Ok(())
}
#[test]
fn a_locality_pick_without_an_overlapping_partner_stays() -> crate::Result<()> {
let folder = tempfile::tempdir()?;
let staleness = || {
KvSeparationOptions::default()
.staleness_threshold(0.01)
.age_cutoff(1.0)
};
let interleaved: BTreeSet<u64>;
{
let tree = open(folder.path(), staleness())?;
fill_interleaved(&tree)?;
interleaved = blob_ids(&tree);
for i in 0..64u64 {
tree.insert(
format!("key{:06}", 100_000 + i),
vec![b's'; 2_048],
1_000 + i,
);
}
tree.flush_active_memtable(0)?;
for i in 0..48u64 {
tree.insert(format!("key{:06}", 100_000 + i), "inline", 2_000 + i);
}
tree.flush_active_memtable(0)?;
tree.major_compact(u64::MAX, u64::MAX)?;
assert!(tree.stale_blob_bytes() > 0, "the separate file is stale");
}
let budget = {
let tree = open(folder.path(), staleness())?;
let version = tree.current_version();
let Some(table) = version.iter_tables().next() else {
panic!("the merge left one table");
};
let table_len = table.fs.metadata(&table.path)?.len;
let mut stale_written = 0;
let mut smallest = u64::MAX;
for link in table.blob_links()? {
if interleaved.contains(&link.blob_file_id) {
let Some(bf) = version.blob_files.get(link.blob_file_id) else {
panic!("a linked file exists");
};
smallest = smallest.min(bf.physical_size()?);
} else {
stale_written += link.on_disk_bytes;
}
}
#[expect(clippy::cast_precision_loss, reason = "test budget from byte counts")]
let ratio = (3 * smallest) as f32 / (table_len + stale_written) as f32;
ratio
};
let Some(max_depth) = NonZeroU64::new(2) else {
panic!("two is not zero");
};
let tree = open(
folder.path(),
staleness().relocate_for_locality(max_depth, budget),
)?;
let before = blob_ids(&tree);
tree.major_compact(u64::MAX, u64::MAX)?;
let after = blob_ids(&tree);
assert!(
interleaved.is_subset(&after),
"no interleaved file moved without a partner: before {before:?}, after {after:?}",
);
assert_eq!(tree.stale_blob_bytes(), 0, "the stale file was rewritten");
Ok(())
}
#[test]
fn a_stale_file_adds_only_its_live_values_to_the_budget() -> crate::Result<()> {
let folder = tempfile::tempdir()?;
let staleness = || {
KvSeparationOptions::default()
.staleness_threshold(0.01)
.age_cutoff(1.0)
};
let interleaved: BTreeSet<u64>;
{
let tree = open(folder.path(), staleness())?;
fill_interleaved(&tree)?;
interleaved = blob_ids(&tree);
for i in 0..256u64 {
tree.insert(
format!("key{:06}", 100_000 + i),
vec![b's'; 2_048],
1_000 + i,
);
}
tree.flush_active_memtable(0)?;
for i in 0..250u64 {
tree.insert(format!("key{:06}", 100_000 + i), "inline", 2_000 + i);
}
tree.flush_active_memtable(0)?;
tree.major_compact(u64::MAX, u64::MAX)?;
assert!(tree.stale_blob_bytes() > 0, "the separate file is stale");
}
let (budget, allowance, sizes) = {
let tree = open(folder.path(), staleness())?;
let version = tree.current_version();
let Some(table) = version.iter_tables().next() else {
panic!("the merge left one table");
};
let table_len = table.fs.metadata(&table.path)?.len;
let mut stale_written = 0;
let mut stale_len = 0;
let mut sizes = std::collections::BTreeMap::new();
for link in table.blob_links()? {
let Some(bf) = version.blob_files.get(link.blob_file_id) else {
panic!("a linked file exists");
};
if interleaved.contains(&link.blob_file_id) {
sizes.insert(link.blob_file_id, bf.physical_size()?);
} else {
stale_written += link.on_disk_bytes;
stale_len += bf.physical_size()?;
}
}
let all: u64 = sizes.values().sum();
#[expect(clippy::cast_precision_loss, reason = "test budget from byte counts")]
let ratio = (2 * all) as f32 / (table_len + stale_len) as f32;
#[expect(clippy::cast_precision_loss, reason = "test budget from byte counts")]
let allowance = f64::from(ratio) * (table_len + stale_written) as f64;
(ratio, allowance, sizes)
};
let Some(max_depth) = NonZeroU64::new(2) else {
panic!("two is not zero");
};
let tree = open(
folder.path(),
staleness().relocate_for_locality(max_depth, budget),
)?;
tree.major_compact(u64::MAX, u64::MAX)?;
let after = blob_ids(&tree);
let relocated: u64 = sizes
.iter()
.filter(|(id, _)| !after.contains(id))
.map(|(_, size)| size)
.sum();
#[expect(clippy::cast_precision_loss, reason = "byte count to float")]
let spent = (2 * relocated) as f64;
assert!(
spent <= allowance,
"locality spent {spent} B against an allowance of {allowance} B",
);
Ok(())
}
#[test]
fn a_partnerless_pick_leaves_its_budget_to_an_overlapping_pair() -> crate::Result<()> {
let folder = tempfile::tempdir()?;
let flush_file = |tree: &AnyTree, keys: &[String], seqno: &mut u64| -> crate::Result<()> {
for key in keys {
tree.insert(key.as_str(), vec![b'v'; 2_048], *seqno);
*seqno += 1;
}
tree.flush_active_memtable(0)?;
Ok(())
};
let (small, pair) = {
let tree = open(folder.path(), KvSeparationOptions::default())?;
let mut seqno = 0;
let x = |slots: &[u64]| -> Vec<String> {
(0..80u64)
.filter(|k| slots.contains(&(k % 5)))
.map(|k| format!("x{k:06}"))
.collect()
};
flush_file(&tree, &x(&[0]), &mut seqno)?;
let small = blob_ids(&tree);
flush_file(&tree, &x(&[1, 2]), &mut seqno)?;
flush_file(&tree, &x(&[3, 4]), &mut seqno)?;
let y = |parity: u64| -> Vec<String> {
(0..32u64)
.filter(|k| k % 2 == parity)
.map(|k| format!("y{k:06}"))
.collect()
};
let before_pair = blob_ids(&tree);
flush_file(&tree, &y(0), &mut seqno)?;
flush_file(&tree, &y(1), &mut seqno)?;
let pair: BTreeSet<u64> = blob_ids(&tree).difference(&before_pair).copied().collect();
(small, pair)
};
assert_eq!(small.len(), 1);
assert_eq!(pair.len(), 2);
let budget = {
let tree = open(folder.path(), KvSeparationOptions::default())?;
let version = tree.current_version();
let size = |id: u64| -> crate::Result<u64> {
let Some(bf) = version.blob_files.get(id) else {
panic!("blob file {id} exists");
};
bf.physical_size()
};
let a = small
.iter()
.map(|&id| size(id))
.sum::<crate::Result<u64>>()?;
let mut pair_sizes = pair.iter().map(|&id| size(id));
let (Some(b), Some(c)) = (pair_sizes.next(), pair_sizes.next()) else {
panic!("two files in the pair");
};
let (b, c) = (b?, c?);
let allowance = 2 * (a + b) + c / 2;
assert!(2 * (b + c) <= allowance, "the pair alone must fit");
let mut table_bytes = 0;
for table in version.iter_tables() {
table_bytes += table.fs.metadata(&table.path)?.len;
}
#[expect(clippy::cast_precision_loss, reason = "test budget from byte counts")]
let ratio = allowance as f32 / table_bytes as f32;
ratio
};
let Some(max_depth) = NonZeroU64::new(1) else {
panic!("one is not zero");
};
let tree = open(
folder.path(),
KvSeparationOptions::default().relocate_for_locality(max_depth, budget),
)?;
let before = blob_ids(&tree);
tree.major_compact(u64::MAX, u64::MAX)?;
let after = blob_ids(&tree);
assert!(
pair.is_disjoint(&after),
"the pair was relocated: before {before:?}, after {after:?}",
);
assert!(
small.is_subset(&after),
"the small file stays: before {before:?}, after {after:?}",
);
Ok(())
}
#[cfg(feature = "lz4")]
#[test]
fn locality_relocation_stops_at_one_file_per_codec() -> crate::Result<()> {
let folder = tempfile::tempdir()?;
let Some(max_depth) = NonZeroU64::new(1) else {
panic!("one is not zero");
};
let AnyTree::Blob(blob) = open(
folder.path(),
KvSeparationOptions::default().relocate_for_locality(max_depth, f32::MAX),
)?
else {
panic!("a tree that separates values");
};
let tree = AnyTree::Blob(blob.clone());
let mut seqno = 0;
for file in 0..FILES {
let codec = if file % 2 == 0 {
crate::CompressionType::None
} else {
crate::CompressionType::Lz4
};
blob.update_runtime_config(|c| c.blob_compression = codec)?;
for i in 0..KEYS_PER_FILE {
let k = i * FILES + file;
tree.insert(format!("key{k:06}"), vec![b'v'; 2_048], seqno);
seqno += 1;
}
tree.flush_active_memtable(0)?;
}
tree.major_compact(u64::MAX, u64::MAX)?;
let collapsed = blob_ids(&tree);
assert_eq!(collapsed.len(), 2, "one file per codec: {collapsed:?}");
assert_eq!(tree.storage_stats()?.blob_references.depth, 2);
tree.major_compact(u64::MAX, u64::MAX)?;
assert_eq!(blob_ids(&tree), collapsed, "nothing left to gain");
Ok(())
}
#[test]
fn locality_relocation_is_skipped_when_the_quota_leaves_no_room() -> crate::Result<()> {
let folder = tempfile::tempdir()?;
let AnyTree::Blob(tree) = open(folder.path(), relocating())? else {
panic!("a tree that separates values");
};
let tree = AnyTree::Blob(tree);
fill_interleaved(&tree)?;
let before = blob_ids(&tree);
let used = tree.storage_stats()?.used_bytes;
let AnyTree::Blob(blob_tree) = &tree else {
panic!("a tree that separates values");
};
blob_tree.update_runtime_config(|c| c.storage_limit_bytes = Some(used + 128 * 1_024))?;
tree.major_compact(u64::MAX, u64::MAX)?;
assert_eq!(tree.table_count(), 1, "the merge itself must still run");
assert_eq!(blob_ids(&tree), before);
Ok(())
}
}