use std::{path::PathBuf, sync::Arc, time::Duration};
use object_store::ObjectStore;
use slatedb::{
Db, DbReader,
admin::AdminBuilder,
config::{
CheckpointOptions, CheckpointScope, DbReaderOptions, ObjectStoreCacheOptions, PreloadLevel,
Settings,
},
};
use uuid::Uuid;
use crate::{
catalog::CachePreload,
error::{Error, Result},
store::segment::TagSegmentExtractor,
};
const DEFAULT_FLUSH_INTERVAL: Duration = Duration::from_millis(100);
const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(10);
pub(crate) async fn create_checkpoint(db: &Db, lifetime: Option<Duration>) -> Result<Uuid> {
let created = db
.create_checkpoint(
CheckpointScope::All,
&CheckpointOptions {
lifetime,
..CheckpointOptions::default()
},
)
.await
.map_err(Error::from)?;
Ok(created.id)
}
pub(crate) fn preload_shortfall(store_bytes: u64, cache_size: Option<u64>) -> Option<u64> {
let cap = cache_size.or_else(|| {
ObjectStoreCacheOptions::default()
.max_cache_size_bytes
.and_then(|bytes| u64::try_from(bytes).ok())
})?;
store_bytes
.checked_sub(cap)
.filter(|shortfall| *shortfall > 0)
}
pub(crate) struct StoreBuilder<'a> {
path: &'a str,
object_store: Arc<dyn ObjectStore>,
flush_interval: Duration,
poll_interval: Duration,
cache_dir: Option<PathBuf>,
cache_size: Option<u64>,
cache_preload: Option<CachePreload>,
cache_puts: bool,
checkpoint: Option<Uuid>,
}
impl<'a> StoreBuilder<'a> {
pub(crate) fn new(path: &'a str, object_store: Arc<dyn ObjectStore>) -> Self {
Self {
path,
object_store,
flush_interval: DEFAULT_FLUSH_INTERVAL,
poll_interval: DEFAULT_POLL_INTERVAL,
cache_dir: None,
cache_size: None,
cache_preload: None,
cache_puts: false,
checkpoint: None,
}
}
pub(crate) fn poll_interval(mut self, poll_interval: Duration) -> Self {
self.poll_interval = poll_interval;
self
}
pub(crate) fn flush_interval(mut self, flush_interval: Duration) -> Self {
self.flush_interval = flush_interval;
self
}
pub(crate) fn cache_dir(mut self, cache_dir: Option<PathBuf>) -> Self {
self.cache_dir = cache_dir;
self
}
pub(crate) fn cache_size(mut self, cache_size: Option<u64>) -> Self {
self.cache_size = cache_size;
self
}
pub(crate) fn cache_preload(mut self, cache_preload: Option<CachePreload>) -> Self {
self.cache_preload = cache_preload;
self
}
pub(crate) fn cache_puts(mut self, cache_puts: bool) -> Self {
self.cache_puts = cache_puts;
self
}
pub(crate) fn checkpoint(mut self, checkpoint: Option<Uuid>) -> Self {
self.checkpoint = checkpoint;
self
}
pub(crate) async fn open_writer(&self) -> Result<Db> {
let settings = self.settings();
Db::builder(self.path, Arc::clone(&self.object_store))
.with_settings(settings)
.with_segment_extractor(Arc::new(TagSegmentExtractor))
.build()
.await
.map_err(Error::from)
}
pub(crate) async fn open_reader(&self) -> Result<DbReader> {
let options = DbReaderOptions {
object_store_cache_options: self.cache_options(),
manifest_poll_interval: self.poll_interval,
..Default::default()
};
let mut builder = DbReader::builder(self.path, Arc::clone(&self.object_store))
.with_segment_extractor(Arc::new(TagSegmentExtractor))
.with_options(options);
if let Some(checkpoint) = self.checkpoint {
builder = builder.with_checkpoint_id(checkpoint);
}
builder.build().await.map_err(Error::from)
}
pub(crate) async fn delete_checkpoint(&self, checkpoint: Uuid) -> Result<()> {
AdminBuilder::new(self.path, Arc::clone(&self.object_store))
.build()
.delete_checkpoint(checkpoint)
.await
.map_err(Error::from)
}
pub(crate) async fn list_checkpoints(&self) -> Result<Vec<Uuid>> {
let checkpoints = AdminBuilder::new(self.path, Arc::clone(&self.object_store))
.build()
.list_checkpoints(None)
.await
.map_err(Error::from)?;
Ok(checkpoints.into_iter().map(|c| c.id).collect())
}
fn settings(&self) -> Settings {
Settings {
flush_interval: Some(self.flush_interval),
object_store_cache_options: self.cache_options(),
..Default::default()
}
}
fn cache_options(&self) -> ObjectStoreCacheOptions {
let defaults = ObjectStoreCacheOptions::default();
ObjectStoreCacheOptions {
root_folder: self.cache_dir.clone(),
max_cache_size_bytes: match self.cache_size {
Some(bytes) => Some(usize::try_from(bytes).unwrap_or(usize::MAX)),
None => defaults.max_cache_size_bytes,
},
cache_puts: self.cache_puts,
preload_disk_cache_on_startup: self.cache_preload.map(|preload| match preload {
CachePreload::L0 => PreloadLevel::L0Sst,
CachePreload::All => PreloadLevel::AllSst,
}),
..defaults
}
}
}
#[cfg(test)]
mod tests {
use object_store::memory::InMemory;
use slatedb::{IsolationLevel, config::WriteOptions};
use super::*;
use crate::store::key::{self, Key, SysKey};
fn memory_store() -> Arc<dyn ObjectStore> {
Arc::new(InMemory::new())
}
#[tokio::test]
async fn multi_subspace_transaction_and_prefix_scans() {
let db = StoreBuilder::new("test/store", memory_store())
.open_writer()
.await
.unwrap();
let head = Key::Sys(SysKey::Head).encode();
let snapshot = Key::Snapshot { snapshot_id: 1 }.encode();
let table = Key::current(key::EntityKey::Table { table_id: 7 }).encode();
let tx = db.begin(IsolationLevel::Snapshot).await.unwrap();
tx.put(&head, b"head").unwrap();
tx.put(&snapshot, b"snap").unwrap();
tx.put(&table, b"table").unwrap();
tx.commit_with_options(&WriteOptions {
await_durable: true,
..Default::default()
})
.await
.unwrap();
assert_eq!(db.get(&head).await.unwrap().unwrap().as_ref(), b"head");
let mut iter = db
.scan_prefix(key::subspace_prefix(key::Subspace::Current), ..)
.await
.unwrap();
let entry = iter.next().await.unwrap().unwrap();
assert_eq!(entry.key.as_ref(), table.as_slice());
assert!(iter.next().await.unwrap().is_none());
let mut iter = db
.scan_prefix(key::subspace_prefix(key::Subspace::Snapshot), ..)
.await
.unwrap();
let entry = iter.next().await.unwrap().unwrap();
assert_eq!(entry.key.as_ref(), snapshot.as_slice());
assert!(iter.next().await.unwrap().is_none());
db.close().await.unwrap();
}
#[tokio::test]
async fn zero_flush_interval_opens_a_working_store() {
let db = StoreBuilder::new("test/store", memory_store())
.flush_interval(Duration::ZERO)
.open_writer()
.await
.unwrap();
let head = Key::Sys(SysKey::Head).encode();
db.put(&head, b"head").await.unwrap();
assert_eq!(db.get(&head).await.unwrap().unwrap().as_ref(), b"head");
db.close().await.unwrap();
}
#[tokio::test]
async fn explicit_flush_interval_opens_a_working_store() {
let db = StoreBuilder::new("test/store", memory_store())
.flush_interval(Duration::from_millis(1))
.open_writer()
.await
.unwrap();
let head = Key::Sys(SysKey::Head).encode();
db.put(&head, b"head").await.unwrap();
assert_eq!(db.get(&head).await.unwrap().unwrap().as_ref(), b"head");
db.close().await.unwrap();
}
#[tokio::test]
async fn reopen_without_extractor_is_refused() {
let object_store = memory_store();
let db = StoreBuilder::new("test/store", object_store.clone())
.open_writer()
.await
.unwrap();
db.put(&Key::Sys(SysKey::Head).encode(), b"head")
.await
.unwrap();
db.close().await.unwrap();
let bare = Db::builder("test/store", object_store).build().await;
assert!(bare.is_err(), "unsegmented reopen must be refused");
}
#[tokio::test]
async fn cache_dir_backs_the_reader_with_an_on_disk_cache() {
let object_store = memory_store();
let cache = std::env::temp_dir().join(format!("moraine-cache-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&cache);
let head = Key::Sys(SysKey::Head).encode();
let db = StoreBuilder::new("s", object_store.clone())
.flush_interval(Duration::from_millis(1))
.cache_dir(Some(cache.clone()))
.open_writer()
.await
.unwrap();
db.put(&head, b"head").await.unwrap();
db.close().await.unwrap();
let reader = StoreBuilder::new("s", object_store)
.cache_dir(Some(cache.clone()))
.open_reader()
.await
.unwrap();
assert_eq!(reader.get(head).await.unwrap().unwrap().as_ref(), b"head");
reader.close().await.unwrap();
let populated = std::fs::read_dir(&cache).is_ok_and(|mut entries| entries.next().is_some());
assert!(populated, "expected an object cache under {cache:?}");
let _ = std::fs::remove_dir_all(&cache);
}
#[test]
fn cache_size_caps_the_on_disk_cache() {
let object_store = memory_store();
let unset = StoreBuilder::new("s", Arc::clone(&object_store));
assert_eq!(
unset.cache_options().max_cache_size_bytes,
ObjectStoreCacheOptions::default().max_cache_size_bytes
);
let capped = StoreBuilder::new("s", object_store).cache_size(Some(64 * 1024 * 1024));
assert_eq!(
capped.cache_options().max_cache_size_bytes,
Some(64 * 1024 * 1024)
);
assert_eq!(
capped
.settings()
.object_store_cache_options
.max_cache_size_bytes,
Some(64 * 1024 * 1024)
);
}
#[test]
fn cache_size_saturates_at_the_addressable_maximum() {
let capped = StoreBuilder::new("s", memory_store()).cache_size(Some(u64::MAX));
assert_eq!(
capped.cache_options().max_cache_size_bytes,
Some(usize::MAX)
);
}
#[test]
fn a_preload_larger_than_the_cap_reports_its_shortfall() {
assert_eq!(preload_shortfall(3_400_000_000, Some(8_000_000_000)), None);
assert_eq!(preload_shortfall(64, Some(64)), None);
assert_eq!(preload_shortfall(100, Some(64)), Some(36));
let default_cap = ObjectStoreCacheOptions::default()
.max_cache_size_bytes
.and_then(|bytes| u64::try_from(bytes).ok())
.expect("a default cap");
assert_eq!(preload_shortfall(default_cap, None), None);
assert_eq!(preload_shortfall(default_cap + 1, None), Some(1));
}
#[test]
fn cache_preload_is_off_until_requested() {
let object_store = memory_store();
let unset = StoreBuilder::new("s", Arc::clone(&object_store));
assert_eq!(unset.cache_options().preload_disk_cache_on_startup, None);
let l0 =
StoreBuilder::new("s", Arc::clone(&object_store)).cache_preload(Some(CachePreload::L0));
assert_eq!(
l0.cache_options().preload_disk_cache_on_startup,
Some(PreloadLevel::L0Sst)
);
let all = StoreBuilder::new("s", object_store).cache_preload(Some(CachePreload::All));
assert_eq!(
all.settings()
.object_store_cache_options
.preload_disk_cache_on_startup,
Some(PreloadLevel::AllSst)
);
}
#[tokio::test]
async fn cache_preload_fills_the_cache_as_the_store_opens() {
let object_store = memory_store();
let root = std::env::temp_dir().join(format!("moraine-preload-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let db = StoreBuilder::new("s", Arc::clone(&object_store))
.flush_interval(Duration::from_millis(1))
.open_writer()
.await
.unwrap();
for index in 0..64u64 {
let key = Key::Snapshot { snapshot_id: index }.encode();
db.put(&key, &vec![b'v'; 16 * 1024]).await.unwrap();
}
db.flush().await.unwrap();
db.close().await.unwrap();
let mut cached = Vec::new();
for (tag, preload) in [("off", None), ("on", Some(CachePreload::All))] {
let cache = root.join(tag);
let reader = StoreBuilder::new("s", Arc::clone(&object_store))
.cache_dir(Some(cache.clone()))
.cache_preload(preload)
.open_reader()
.await
.unwrap();
reader.close().await.unwrap();
cached.push(cached_bytes(&cache));
}
assert!(
cached[1] > cached[0],
"preloading cached {} bytes against {} without it: the open is not filling the cache",
cached[1],
cached[0]
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn cache_puts_is_off_until_requested() {
let object_store = memory_store();
let unset = StoreBuilder::new("s", Arc::clone(&object_store));
assert!(!unset.cache_options().cache_puts);
let caching = StoreBuilder::new("s", object_store).cache_puts(true);
assert!(caching.cache_options().cache_puts);
assert!(caching.settings().object_store_cache_options.cache_puts);
}
#[tokio::test]
async fn cache_puts_fills_the_cache_from_the_write_path() {
let object_store = memory_store();
let root = std::env::temp_dir().join(format!("moraine-put-cache-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let mut sizes = Vec::new();
for (tag, cache_puts) in [("off", false), ("on", true)] {
let cache = root.join(tag);
let db = StoreBuilder::new(tag, Arc::clone(&object_store))
.flush_interval(Duration::from_millis(1))
.cache_dir(Some(cache.clone()))
.cache_puts(cache_puts)
.open_writer()
.await
.unwrap();
for index in 0..64u64 {
let key = Key::Snapshot { snapshot_id: index }.encode();
db.put(&key, &vec![b'v'; 16 * 1024]).await.unwrap();
}
db.flush().await.unwrap();
db.close().await.unwrap();
sizes.push(cached_bytes(&cache));
}
assert!(
sizes[1] > sizes[0],
"caching writes left {} bytes cached against {} without it: the flush is not \
reaching the cache",
sizes[1],
sizes[0]
);
let _ = std::fs::remove_dir_all(&root);
}
fn cached_bytes(dir: &std::path::Path) -> u64 {
let Ok(entries) = std::fs::read_dir(dir) else {
return 0;
};
entries.flatten().fold(0, |total, entry| {
let Ok(kind) = entry.file_type() else {
return total;
};
if kind.is_dir() {
return total + cached_bytes(&entry.path());
}
total + entry.metadata().map_or(0, |meta| meta.len())
})
}
}