use std::path::Path;
use crate::engine::CheckpointSnapshot;
use crate::env::{Env, WriteMode};
use crate::{Db, Error, Result};
pub struct Checkpoint<'db> {
db: &'db Db,
}
impl<'db> Checkpoint<'db> {
pub fn new(db: &'db Db) -> Result<Self> {
Ok(Self { db })
}
pub fn create<P: AsRef<Path>>(&self, target_dir: P) -> Result<()> {
self.create_inner(target_dir.as_ref(), |_| {})
}
#[cfg(test)]
pub(crate) fn create_between<P: AsRef<Path>>(
&self,
target_dir: P,
after_capture: impl FnOnce(&Db),
) -> Result<()> {
self.create_inner(target_dir.as_ref(), after_capture)
}
fn create_inner(&self, target_dir: &Path, after_capture: impl FnOnce(&Db)) -> Result<()> {
let target_sst = target_dir.join("sst");
let target_wal = target_dir.join("wal");
let env = self.db.engine().env();
env.create_dir_all(&target_sst).map_err(Error::from)?;
env.create_dir_all(&target_wal).map_err(Error::from)?;
if !env.read_dir(&target_sst).map_err(Error::from)?.is_empty() {
return Err(Error::Io(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"checkpoint target sst directory is not empty",
)));
}
let snapshot = self.db.engine().checkpoint_capture().map_err(Error::from)?;
after_capture(self.db);
for level in &snapshot.version.levels {
for file in level {
let name = CheckpointSnapshot::sst_filename(file.meta.file_id);
let src = snapshot.sst_dir.join(&name);
let dst = target_sst.join(&name);
if env.capabilities().hard_link {
env.hard_link(&src, &dst).map_err(Error::from)?;
} else {
let len = env.metadata(&src).map_err(Error::from)?.len;
copy_truncated(&**env, &src, &dst, len).map_err(Error::from)?;
}
}
}
let target_manifest = target_dir.join("MANIFEST");
let mut manifest = env
.open_write(&target_manifest, WriteMode::Truncate)
.map_err(Error::from)?;
manifest
.write_all(&snapshot.manifest_bytes)
.map_err(Error::from)?;
manifest.sync_all().map_err(Error::from)?;
Ok(())
}
}
fn copy_truncated(env: &dyn Env, src: &Path, dst: &Path, len: u64) -> std::io::Result<()> {
let reader = env.open_read(src)?;
let mut writer = env.open_write(dst, WriteMode::Truncate)?;
let mut buf = [0u8; 16 * 1024];
let available = reader.len()?.min(len);
let mut offset = 0u64;
while offset < available {
let want = (available - offset).min(buf.len() as u64) as usize;
reader.read_exact_at(offset, &mut buf[..want])?;
writer.write_all(&buf[..want])?;
offset += want as u64;
}
writer.sync_all()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Options, WriteBatch};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use tempfile::TempDir;
fn tiny_flush_opts() -> Options {
Options {
write_buffer_size: 4 * 1024,
..Options::default()
}
}
fn force_flush(db: &Db, tag: &str) {
let payload = vec![0u8; 512];
for i in 0..32 {
let key = format!("__flush_{}_{:04}", tag, i);
db.put(key.as_bytes(), &payload).unwrap();
}
}
#[test]
fn checkpoint_empty_db() {
let src_dir = TempDir::new().unwrap();
let tgt_dir = TempDir::new().unwrap();
let db = Db::open(src_dir.path(), Options::default()).unwrap();
let cp = Checkpoint::new(&db).unwrap();
cp.create(tgt_dir.path()).unwrap();
drop(db);
let reopened = Db::open(tgt_dir.path(), Options::default()).unwrap();
assert_eq!(reopened.get(b"missing").unwrap(), None);
}
#[test]
fn checkpoint_memtable_only() {
let src_dir = TempDir::new().unwrap();
let tgt_dir = TempDir::new().unwrap();
let db = Db::open(src_dir.path(), Options::default()).unwrap();
db.put(b"a", b"1").unwrap();
db.put(b"b", b"2").unwrap();
db.put(b"c", b"3").unwrap();
let cp = Checkpoint::new(&db).unwrap();
cp.create(tgt_dir.path()).unwrap();
let reopened = Db::open(tgt_dir.path(), Options::default()).unwrap();
assert_eq!(reopened.get(b"a").unwrap(), Some(b"1".to_vec()));
assert_eq!(reopened.get(b"b").unwrap(), Some(b"2".to_vec()));
assert_eq!(reopened.get(b"c").unwrap(), Some(b"3".to_vec()));
drop(reopened);
drop(db);
}
#[test]
fn checkpoint_flushed_and_compacted() {
let src_dir = TempDir::new().unwrap();
let tgt_dir = TempDir::new().unwrap();
let db = Db::open(src_dir.path(), tiny_flush_opts()).unwrap();
for i in 0..200 {
let k = format!("key_{:05}", i);
db.put(k.as_bytes(), k.as_bytes()).unwrap();
}
force_flush(&db, "a");
db.compact_range(None, None).unwrap();
db.put(b"post_1", b"pv1").unwrap();
db.put(b"post_2", b"pv2").unwrap();
let cp = Checkpoint::new(&db).unwrap();
cp.create(tgt_dir.path()).unwrap();
let reopened = Db::open(tgt_dir.path(), Options::default()).unwrap();
for i in 0..200 {
let k = format!("key_{:05}", i);
assert_eq!(reopened.get(k.as_bytes()).unwrap(), Some(k.into_bytes()));
}
assert_eq!(reopened.get(b"post_1").unwrap(), Some(b"pv1".to_vec()));
assert_eq!(reopened.get(b"post_2").unwrap(), Some(b"pv2".to_vec()));
}
fn no_background_flush() -> Options {
Options {
max_background_compactions: 0,
..Default::default()
}
}
#[test]
fn a_checkpoint_captures_data_that_never_reached_an_sstable() {
let src_dir = TempDir::new().unwrap();
let tgt_dir = TempDir::new().unwrap();
let db = Db::open(src_dir.path(), no_background_flush()).unwrap();
for i in 0..200u64 {
let k = format!("unflushed_{i:04}");
db.put(k.as_bytes(), k.as_bytes()).unwrap();
}
let cp = Checkpoint::new(&db).unwrap();
cp.create(tgt_dir.path()).unwrap();
let reopened = Db::open(tgt_dir.path(), no_background_flush()).unwrap();
for i in 0..200u64 {
let k = format!("unflushed_{i:04}");
assert_eq!(
reopened.get(k.as_bytes()).unwrap(),
Some(k.clone().into_bytes()),
"{k} was acknowledged before the checkpoint but is not in it"
);
}
}
#[test]
fn a_checkpoint_captures_unflushed_range_deletes() {
let src_dir = TempDir::new().unwrap();
let tgt_dir = TempDir::new().unwrap();
let db = Db::open(src_dir.path(), no_background_flush()).unwrap();
for i in 0..50u64 {
let k = format!("k_{i:04}");
db.put(k.as_bytes(), b"v").unwrap();
}
db.delete_range(b"k_0010", b"k_0020").unwrap();
let cp = Checkpoint::new(&db).unwrap();
cp.create(tgt_dir.path()).unwrap();
let reopened = Db::open(tgt_dir.path(), no_background_flush()).unwrap();
for i in 0..50u64 {
let k = format!("k_{i:04}");
let deleted = (10..20).contains(&i);
assert_eq!(
reopened.get(k.as_bytes()).unwrap().is_none(),
deleted,
"{k}: range delete did not survive the checkpoint"
);
}
}
#[test]
fn capturing_a_checkpoint_leaves_no_memtable_holding_data() {
let dir = TempDir::new().unwrap();
let db = Db::open(dir.path(), no_background_flush()).unwrap();
for i in 0..200u64 {
db.put(format!("k{i:04}").as_bytes(), b"v").unwrap();
}
let snapshot = db.engine().checkpoint_capture().unwrap();
drop(snapshot);
assert!(
db.engine().memtables_hold_no_data(),
"a memtable still held data after the capture, so the checkpoint \
names SSTables that do not contain it"
);
}
#[test]
fn a_checkpoint_manifest_survives_a_rewrite_racing_the_capture() {
let src_dir = TempDir::new().unwrap();
let db = Arc::new(Db::open(src_dir.path(), Options::default()).unwrap());
for i in 0..300u64 {
let k = format!("seed_{i:04}");
db.put(k.as_bytes(), k.as_bytes()).unwrap();
}
let stop = Arc::new(AtomicBool::new(false));
let writer = {
let db = Arc::clone(&db);
let stop = Arc::clone(&stop);
thread::spawn(move || {
let mut i = 0u64;
while !stop.load(Ordering::Relaxed) {
let k = format!("churn_{i:08}");
let _ = db.put(k.as_bytes(), &vec![b'x'; 512]);
i += 1;
}
i
})
};
for _ in 0..8 {
let tgt = TempDir::new().unwrap();
let cp = Checkpoint::new(&db).unwrap();
cp.create(tgt.path()).unwrap();
let reopened = Db::open(tgt.path(), Options::default()).unwrap();
for i in 0..300u64 {
let k = format!("seed_{i:04}");
assert_eq!(
reopened.get(k.as_bytes()).unwrap(),
Some(k.clone().into_bytes()),
"{k} is missing from a checkpoint taken under a concurrent writer"
);
}
}
stop.store(true, Ordering::Relaxed);
let _ = writer.join().unwrap();
}
#[test]
fn a_manifest_rewritten_between_capture_and_copy_does_not_corrupt_the_checkpoint() {
let src_dir = TempDir::new().unwrap();
let tgt_dir = TempDir::new().unwrap();
let db = Db::open(src_dir.path(), Options::default()).unwrap();
for i in 0..400u64 {
let k = format!("seed_{i:04}");
db.put(k.as_bytes(), k.as_bytes()).unwrap();
}
db.flush().unwrap();
let cp = Checkpoint::new(&db).unwrap();
cp.create_between(tgt_dir.path(), |db| {
for i in 0..400u64 {
let k = format!("after_{i:04}");
db.put(k.as_bytes(), &vec![b'z'; 256]).unwrap();
}
db.flush().unwrap();
db.engine().force_manifest_rewrite().unwrap();
})
.unwrap();
let reopened = Db::open(tgt_dir.path(), Options::default()).unwrap();
for i in 0..400u64 {
let k = format!("seed_{i:04}");
assert_eq!(
reopened.get(k.as_bytes()).unwrap(),
Some(k.clone().into_bytes()),
"{k} was captured but is not in the checkpoint"
);
}
assert_eq!(
reopened.get(b"after_0000").unwrap(),
None,
"the checkpoint picked up a write that happened after its capture"
);
}
#[test]
fn a_checkpoint_names_only_files_it_copied() {
let src_dir = TempDir::new().unwrap();
let tgt_dir = TempDir::new().unwrap();
let db = Db::open(src_dir.path(), Options::default()).unwrap();
for i in 0..500u64 {
db.put(format!("k{i:05}").as_bytes(), &vec![b'v'; 256])
.unwrap();
}
db.flush().unwrap();
for i in 500..1000u64 {
db.put(format!("k{i:05}").as_bytes(), &vec![b'v'; 256])
.unwrap();
}
let cp = Checkpoint::new(&db).unwrap();
cp.create(tgt_dir.path()).unwrap();
let copied: std::collections::HashSet<_> = std::fs::read_dir(tgt_dir.path().join("sst"))
.unwrap()
.filter_map(|e| e.ok().map(|e| e.file_name()))
.collect();
let reopened = Db::open(tgt_dir.path(), Options::default()).unwrap();
for i in 0..1000u64 {
let k = format!("k{i:05}");
assert!(
reopened.get(k.as_bytes()).unwrap().is_some(),
"{k} missing from the checkpoint"
);
}
assert!(
!copied.is_empty(),
"the checkpoint hardlinked no SSTable at all"
);
}
#[test]
fn checkpoint_with_concurrent_writer() {
let src_dir = TempDir::new().unwrap();
let tgt_dir = TempDir::new().unwrap();
let db = Arc::new(Db::open(src_dir.path(), tiny_flush_opts()).unwrap());
for i in 0..50 {
let k = format!("seed_{:03}", i);
db.put(k.as_bytes(), k.as_bytes()).unwrap();
}
let stop = Arc::new(AtomicBool::new(false));
let writer_db = Arc::clone(&db);
let writer_stop = Arc::clone(&stop);
let writer = thread::spawn(move || {
let mut i = 0u64;
while !writer_stop.load(Ordering::Relaxed) {
let mut batch = WriteBatch::new();
let k = format!("live_{:06}", i);
batch.put(k.as_bytes(), k.as_bytes());
writer_db.write(batch).unwrap();
i += 1;
}
i
});
for _ in 0..5 {
let cp = Checkpoint::new(&db).unwrap();
cp.create(tgt_dir.path()).unwrap();
std::fs::remove_dir_all(tgt_dir.path()).unwrap();
std::fs::create_dir_all(tgt_dir.path()).unwrap();
}
let cp = Checkpoint::new(&db).unwrap();
cp.create(tgt_dir.path()).unwrap();
stop.store(true, Ordering::Relaxed);
let _total_writes = writer.join().unwrap();
db.put(b"after_checkpoint", b"ok").unwrap();
assert_eq!(db.get(b"after_checkpoint").unwrap(), Some(b"ok".to_vec()));
let reopened = Db::open(tgt_dir.path(), Options::default()).unwrap();
for i in 0..50 {
let k = format!("seed_{:03}", i);
assert_eq!(reopened.get(k.as_bytes()).unwrap(), Some(k.into_bytes()));
}
}
#[test]
fn source_can_be_dropped_after_checkpoint() {
let src_dir = TempDir::new().unwrap();
let tgt_dir = TempDir::new().unwrap();
let db = Db::open(src_dir.path(), tiny_flush_opts()).unwrap();
for i in 0..100 {
let k = format!("dur_{:04}", i);
db.put(k.as_bytes(), k.as_bytes()).unwrap();
}
force_flush(&db, "x");
let cp = Checkpoint::new(&db).unwrap();
cp.create(tgt_dir.path()).unwrap();
db.close().unwrap();
drop(db);
std::fs::remove_dir_all(src_dir.path()).unwrap();
let reopened = Db::open(tgt_dir.path(), Options::default()).unwrap();
for i in 0..100 {
let k = format!("dur_{:04}", i);
assert_eq!(reopened.get(k.as_bytes()).unwrap(), Some(k.into_bytes()));
}
}
}