use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Instant;
use regolith::env::{
Capabilities, DirEntry, Env, FileLock, FileMeta, JoinHandle, ReadFile, StdEnv, WriteFile,
WriteMode,
};
use regolith::{Db, Options};
use tempfile::TempDir;
const FILES: usize = 8;
const ENTRIES_PER_FILE: usize = 2000;
const OPEN_REPS: usize = 3;
#[derive(Default)]
struct ByteCounters {
sst: AtomicU64,
manifest: AtomicU64,
wal: AtomicU64,
}
impl std::fmt::Debug for ByteCounters {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ByteCounters").finish_non_exhaustive()
}
}
impl ByteCounters {
fn reset(&self) {
self.sst.store(0, Ordering::Relaxed);
self.manifest.store(0, Ordering::Relaxed);
self.wal.store(0, Ordering::Relaxed);
}
fn add(&self, path: &Path, bytes: usize) {
let bytes = bytes as u64;
if path.file_name().is_some_and(|n| n == "MANIFEST") {
self.manifest.fetch_add(bytes, Ordering::Relaxed);
} else if path.components().any(|c| c.as_os_str() == "sst") {
self.sst.fetch_add(bytes, Ordering::Relaxed);
} else if path.components().any(|c| c.as_os_str() == "wal") {
self.wal.fetch_add(bytes, Ordering::Relaxed);
}
}
}
#[derive(Debug)]
struct CountingEnv {
inner: StdEnv,
counters: Arc<ByteCounters>,
}
struct CountingRead {
inner: Box<dyn ReadFile>,
path: PathBuf,
counters: Arc<ByteCounters>,
}
impl ReadFile for CountingRead {
fn read_exact_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
self.inner.read_exact_at(offset, buf)?;
self.counters.add(&self.path, buf.len());
Ok(())
}
fn len(&self) -> io::Result<u64> {
self.inner.len()
}
}
impl Env for CountingEnv {
fn create_dir_all(&self, p: &Path) -> io::Result<()> {
self.inner.create_dir_all(p)
}
fn read_dir(&self, p: &Path) -> io::Result<Vec<DirEntry>> {
self.inner.read_dir(p)
}
fn open_read(&self, p: &Path) -> io::Result<Box<dyn ReadFile>> {
let inner = self.inner.open_read(p)?;
Ok(Box::new(CountingRead {
inner,
path: p.to_path_buf(),
counters: Arc::clone(&self.counters),
}))
}
fn open_write(&self, p: &Path, mode: WriteMode) -> io::Result<Box<dyn WriteFile>> {
self.inner.open_write(p, mode)
}
fn metadata(&self, p: &Path) -> io::Result<FileMeta> {
self.inner.metadata(p)
}
fn remove_file(&self, p: &Path) -> io::Result<()> {
self.inner.remove_file(p)
}
fn rename(&self, a: &Path, b: &Path) -> io::Result<()> {
self.inner.rename(a, b)
}
fn hard_link(&self, a: &Path, b: &Path) -> io::Result<()> {
self.inner.hard_link(a, b)
}
fn sync_dir(&self, p: &Path) -> io::Result<()> {
self.inner.sync_dir(p)
}
fn lock_file(&self, p: &Path, ex: bool) -> io::Result<Box<dyn FileLock>> {
self.inner.lock_file(p, ex)
}
fn capabilities(&self) -> Capabilities {
self.inner.capabilities()
}
fn now_micros(&self) -> Option<u64> {
self.inner.now_micros()
}
fn unix_secs(&self) -> Option<u64> {
self.inner.unix_secs()
}
fn spawn(
&self,
name: &str,
f: Box<dyn FnOnce() + Send + 'static>,
) -> io::Result<Box<dyn JoinHandle>> {
self.inner.spawn(name, f)
}
fn sleep(&self, d: std::time::Duration) {
self.inner.sleep(d)
}
}
fn bench_opts(env: Arc<dyn Env>) -> Options {
Options {
env,
max_background_compactions: 0,
l0_compaction_trigger: 1_000_000,
level0_slowdown_writes_trigger: 1_000_000,
level0_stop_writes_trigger: 1_000_000,
..Options::default()
}
}
fn seeded_bytes(seed: u64, len: usize) -> Vec<u8> {
let mut state = seed | 1;
let mut out = Vec::with_capacity(len);
while out.len() < len {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
out.extend_from_slice(&state.to_le_bytes());
}
out.truncate(len);
out
}
fn build_fixture(dir: &Path, value_len: usize) {
let opts = bench_opts(Arc::new(StdEnv));
let db = Db::open(dir, opts).unwrap();
for file in 0..FILES {
for entry in 0..ENTRIES_PER_FILE {
let key = format!("k_{file:04}_{entry:08}");
let value = seeded_bytes((file * ENTRIES_PER_FILE + entry) as u64, value_len);
db.put(key.as_bytes(), &value).unwrap();
}
db.flush().unwrap();
}
db.close().unwrap();
drop(db);
}
fn dir_bytes(dir: &Path) -> u64 {
std::fs::read_dir(dir)
.unwrap()
.flatten()
.map(|e| e.metadata().unwrap().len())
.sum()
}
fn file_count(dir: &Path) -> usize {
std::fs::read_dir(dir).unwrap().count()
}
struct Measured {
label: &'static str,
files: usize,
on_disk_bytes: u64,
sst_read_bytes: u64,
manifest_read_bytes: u64,
wal_read_bytes: u64,
open_median: std::time::Duration,
open_min: std::time::Duration,
open_max: std::time::Duration,
}
fn measure(label: &'static str, dir: &Path) -> Measured {
let files = file_count(&dir.join("sst"));
let on_disk_bytes = dir_bytes(&dir.join("sst"));
let counters = Arc::new(ByteCounters::default());
let mut times = Vec::with_capacity(OPEN_REPS);
for _ in 0..OPEN_REPS {
let env = Arc::new(CountingEnv {
inner: StdEnv,
counters: Arc::clone(&counters),
});
counters.reset();
let opts = bench_opts(env);
let t = Instant::now();
let db = Db::open(dir, opts).unwrap();
times.push(t.elapsed());
drop(db);
}
times.sort();
Measured {
label,
files,
on_disk_bytes,
sst_read_bytes: counters.sst.load(Ordering::Relaxed),
manifest_read_bytes: counters.manifest.load(Ordering::Relaxed),
wal_read_bytes: counters.wal.load(Ordering::Relaxed),
open_median: times[times.len() / 2],
open_min: times[0],
open_max: times[times.len() - 1],
}
}
fn print_row(m: &Measured) {
let pct = m.sst_read_bytes as f64 / m.on_disk_bytes as f64 * 100.0;
println!(
"{:<8} files={:<3} on_disk_sst_bytes={:<10} sst_read_bytes={:<8} \
manifest_read_bytes={:<6} wal_read_bytes={:<4} read_pct={:.3}% \
open(reported, not asserted): median={:?} min={:?} max={:?}",
m.label,
m.files,
m.on_disk_bytes,
m.sst_read_bytes,
m.manifest_read_bytes,
m.wal_read_bytes,
pct,
m.open_median,
m.open_min,
m.open_max,
);
}
#[test]
fn open_reads_far_less_than_the_data_it_has_on_disk() {
let dir_a = TempDir::new().unwrap();
build_fixture(dir_a.path(), 64);
let a = measure("DB-A(64B)", dir_a.path());
print_row(&a);
assert!(
a.sst_read_bytes > 0,
"the counting environment saw zero sst bytes read at open, so it is \
measuring nothing"
);
let pct = a.sst_read_bytes as f64 / a.on_disk_bytes as f64;
assert!(
pct < 0.25,
"DB-A: open read {} of {} on-disk sst bytes ({:.1}%), expected under \
25%; open must read metadata and at most one data block per file, \
not the file's data proper",
a.sst_read_bytes,
a.on_disk_bytes,
pct * 100.0
);
}
#[test]
fn open_cost_does_not_grow_with_the_data_bytes_behind_the_same_file_count() {
let dir_a = TempDir::new().unwrap();
let dir_b = TempDir::new().unwrap();
build_fixture(dir_a.path(), 64);
build_fixture(dir_b.path(), 512);
let a = measure("DB-A(64B)", dir_a.path());
let b = measure("DB-B(512B)", dir_b.path());
print_row(&a);
print_row(&b);
assert_eq!(
a.files, b.files,
"the fixtures must share the same file count"
);
let on_disk_ratio = b.on_disk_bytes as f64 / a.on_disk_bytes as f64;
assert!(
on_disk_ratio >= 5.0,
"premise check failed: DB-B's on-disk sst bytes ({}) are only {:.2}x \
DB-A's ({}), expected >= 5x from ~8x larger values; LZ4 may have \
collapsed the values, or the fixtures diverged",
b.on_disk_bytes,
on_disk_ratio,
a.on_disk_bytes
);
assert!(
a.sst_read_bytes > 0 && b.sst_read_bytes > 0,
"the counting environment saw zero sst bytes read at open for at \
least one database, so it is measuring nothing"
);
let read_ratio = b.sst_read_bytes as f64 / a.sst_read_bytes as f64;
const MAX_READ_RATIO: f64 = 2.1;
assert!(
read_ratio < MAX_READ_RATIO,
"open's sst bytes read grew {:.2}x from DB-A to DB-B while on-disk \
bytes grew {:.2}x; open must scale with file and key count, not \
with value bytes (bound is {MAX_READ_RATIO}x)",
read_ratio,
on_disk_ratio
);
}