use std::{
path::PathBuf,
sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
time::Duration,
};
use foyer::{
BlockEngineConfig, DeviceBuilder, FsDeviceBuilder, HybridCacheBuilder, PsyncIoEngineConfig,
RecoverMode, Spawner,
};
use slatedb::db_cache::{
CachedEntry, CachedKey, DbCache, SplitCache,
foyer::{FoyerCache, FoyerCacheOptions},
foyer_hybrid::FoyerHybridCache,
stats,
};
use slatedb_common::metrics::{CounterFn, GaugeFn, HistogramFn, MetricsRecorder, UpDownCounterFn};
use tokio::sync::OnceCell;
use tracing::{info, warn};
const DEFAULT_CACHE_MEMORY: u64 = 640 * 1024 * 1024;
const META_SHARE_DIVISOR: u64 = 5;
const MAX_AUXILIARY_METADATA_MEMORY: u64 = 8 * 1024 * 1024;
const AUXILIARY_METADATA_SHARE_DIVISOR: u64 = 16;
pub(crate) const DEFAULT_CACHE_DISK: u64 = 16 * 1024 * 1024 * 1024;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct CacheConfig {
pub(crate) memory: Option<u64>,
pub(crate) dir: Option<PathBuf>,
pub(crate) disk_size: Option<u64>,
}
impl CacheConfig {
fn slots(&self) -> (u64, u64, u64) {
let budget = self.memory.unwrap_or(DEFAULT_CACHE_MEMORY).max(2);
let metadata = (budget / META_SHARE_DIVISOR).max(1);
let auxiliary_metadata =
(metadata / AUXILIARY_METADATA_SHARE_DIVISOR).min(MAX_AUXILIARY_METADATA_MEMORY);
let slatedb_metadata = metadata.saturating_sub(auxiliary_metadata).max(1);
let blocks = budget.saturating_sub(metadata).max(1);
(slatedb_metadata, auxiliary_metadata, blocks)
}
}
static AUXILIARY_METADATA_MEMORY: AtomicU64 = AtomicU64::new(MAX_AUXILIARY_METADATA_MEMORY);
pub(crate) fn auxiliary_metadata_memory() -> usize {
usize::try_from(AUXILIARY_METADATA_MEMORY.load(Ordering::Relaxed)).unwrap_or(usize::MAX)
}
static SHARED: OnceCell<Option<Arc<dyn DbCache>>> = OnceCell::const_new();
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct CacheTally {
pub metadata_hits: u64,
pub metadata_misses: u64,
pub block_hits: u64,
pub block_misses: u64,
pub errors: u64,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct ObjectStoreTally {
pub main_gets: u64,
pub main_get_duration: Duration,
pub main_puts: u64,
pub main_put_duration: Duration,
pub main_deletes: u64,
pub main_delete_duration: Duration,
pub wal_gets: u64,
pub wal_get_duration: Duration,
pub wal_puts: u64,
pub wal_put_duration: Duration,
pub wal_deletes: u64,
pub wal_delete_duration: Duration,
pub errors: u64,
}
impl CacheTally {
#[must_use]
pub fn metadata_hit_rate(&self) -> Option<f64> {
rate(self.metadata_hits, self.metadata_misses)
}
#[must_use]
pub fn block_hit_rate(&self) -> Option<f64> {
rate(self.block_hits, self.block_misses)
}
}
#[expect(
clippy::cast_precision_loss,
reason = "a hit rate is a ratio; f64 holds counts far past any real one exactly"
)]
fn rate(hits: u64, misses: u64) -> Option<f64> {
let total = hits.checked_add(misses).filter(|total| *total > 0)?;
Some(hits as f64 / total as f64)
}
#[derive(Debug, Default)]
pub(crate) struct CacheCounters {
metadata_hits: AtomicU64,
metadata_misses: AtomicU64,
block_hits: AtomicU64,
block_misses: AtomicU64,
errors: AtomicU64,
main_gets: AtomicU64,
main_get_nanoseconds: AtomicU64,
main_puts: AtomicU64,
main_put_nanoseconds: AtomicU64,
main_deletes: AtomicU64,
main_delete_nanoseconds: AtomicU64,
wal_gets: AtomicU64,
wal_get_nanoseconds: AtomicU64,
wal_puts: AtomicU64,
wal_put_nanoseconds: AtomicU64,
wal_deletes: AtomicU64,
wal_delete_nanoseconds: AtomicU64,
object_store_errors: AtomicU64,
}
impl CacheCounters {
pub(crate) fn tally(&self) -> CacheTally {
CacheTally {
metadata_hits: self.metadata_hits.load(Ordering::Relaxed),
metadata_misses: self.metadata_misses.load(Ordering::Relaxed),
block_hits: self.block_hits.load(Ordering::Relaxed),
block_misses: self.block_misses.load(Ordering::Relaxed),
errors: self.errors.load(Ordering::Relaxed),
}
}
pub(crate) fn object_store_tally(&self) -> ObjectStoreTally {
let load = |counter: &AtomicU64| counter.load(Ordering::Relaxed);
let duration = |counter: &AtomicU64| Duration::from_nanos(load(counter));
ObjectStoreTally {
main_gets: load(&self.main_gets),
main_get_duration: duration(&self.main_get_nanoseconds),
main_puts: load(&self.main_puts),
main_put_duration: duration(&self.main_put_nanoseconds),
main_deletes: load(&self.main_deletes),
main_delete_duration: duration(&self.main_delete_nanoseconds),
wal_gets: load(&self.wal_gets),
wal_get_duration: duration(&self.wal_get_nanoseconds),
wal_puts: load(&self.wal_puts),
wal_put_duration: duration(&self.wal_put_nanoseconds),
wal_deletes: load(&self.wal_deletes),
wal_delete_duration: duration(&self.wal_delete_nanoseconds),
errors: load(&self.object_store_errors),
}
}
fn of(&self, which: Which) -> &AtomicU64 {
match which {
Which::MetadataHit => &self.metadata_hits,
Which::MetadataMiss => &self.metadata_misses,
Which::BlockHit => &self.block_hits,
Which::BlockMiss => &self.block_misses,
Which::Error => &self.errors,
Which::MainGet => &self.main_gets,
Which::MainPut => &self.main_puts,
Which::MainDelete => &self.main_deletes,
Which::WalGet => &self.wal_gets,
Which::WalPut => &self.wal_puts,
Which::WalDelete => &self.wal_deletes,
Which::ObjectStoreError => &self.object_store_errors,
Which::MainGetDuration => &self.main_get_nanoseconds,
Which::MainPutDuration => &self.main_put_nanoseconds,
Which::MainDeleteDuration => &self.main_delete_nanoseconds,
Which::WalGetDuration => &self.wal_get_nanoseconds,
Which::WalPutDuration => &self.wal_put_nanoseconds,
Which::WalDeleteDuration => &self.wal_delete_nanoseconds,
}
}
}
struct Slot {
store: Arc<CacheCounters>,
process: Arc<CacheCounters>,
which: Option<Which>,
}
#[derive(Clone, Copy)]
enum Which {
MetadataHit,
MetadataMiss,
BlockHit,
BlockMiss,
Error,
MainGet,
MainPut,
MainDelete,
WalGet,
WalPut,
WalDelete,
ObjectStoreError,
MainGetDuration,
MainPutDuration,
MainDeleteDuration,
WalGetDuration,
WalPutDuration,
WalDeleteDuration,
}
impl CounterFn for Slot {
fn increment(&self, value: u64) {
let Some(which) = self.which else {
return;
};
for counters in [&self.store, &self.process] {
counters.of(which).fetch_add(value, Ordering::Relaxed);
}
}
}
impl HistogramFn for Slot {
fn record(&self, value: f64) {
let Some(which) = self.which else {
return;
};
let Ok(duration) = Duration::try_from_secs_f64(value) else {
return;
};
let nanoseconds = u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX);
for counters in [&self.store, &self.process] {
let counter = counters.of(which);
let _ = counter.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
Some(current.saturating_add(nanoseconds))
});
}
}
}
const OBJECT_STORE_REQUEST_COUNT: &str = "slatedb.object_store.request_count";
const OBJECT_STORE_ERROR_COUNT: &str = "slatedb.object_store.error_count";
const OBJECT_STORE_REQUEST_DURATION: &str = "slatedb.object_store.request_duration_seconds";
fn object_store_metric(labels: &[(&str, &str)], duration: bool) -> Option<Which> {
let label = |key: &str| {
labels
.iter()
.find_map(|(name, value)| (*name == key).then_some(*value))
};
match (label("store_type"), label("op"), duration) {
(Some("main"), Some("get"), false) => Some(Which::MainGet),
(Some("main"), Some("put"), false) => Some(Which::MainPut),
(Some("main"), Some("delete"), false) => Some(Which::MainDelete),
(Some("wal"), Some("get"), false) => Some(Which::WalGet),
(Some("wal"), Some("put"), false) => Some(Which::WalPut),
(Some("wal"), Some("delete"), false) => Some(Which::WalDelete),
(Some("main"), Some("get"), true) => Some(Which::MainGetDuration),
(Some("main"), Some("put"), true) => Some(Which::MainPutDuration),
(Some("main"), Some("delete"), true) => Some(Which::MainDeleteDuration),
(Some("wal"), Some("get"), true) => Some(Which::WalGetDuration),
(Some("wal"), Some("put"), true) => Some(Which::WalPutDuration),
(Some("wal"), Some("delete"), true) => Some(Which::WalDeleteDuration),
_ => None,
}
}
#[derive(Debug)]
struct CacheRecorder {
store: Arc<CacheCounters>,
}
impl MetricsRecorder for CacheRecorder {
fn register_counter(
&self,
name: &str,
_description: &str,
labels: &[(&str, &str)],
) -> Arc<dyn CounterFn> {
let label = |key: &str| {
labels
.iter()
.find_map(|(name, value)| (*name == key).then_some(*value))
};
let which = match name {
OBJECT_STORE_REQUEST_COUNT => object_store_metric(labels, false),
OBJECT_STORE_ERROR_COUNT => Some(Which::ObjectStoreError),
stats::ERROR_COUNT => Some(Which::Error),
stats::ACCESS_COUNT => match (label("entry_kind"), label("result")) {
(Some("filter" | "index" | "stats"), Some("hit")) => Some(Which::MetadataHit),
(Some("filter" | "index" | "stats"), Some("miss")) => Some(Which::MetadataMiss),
(Some("data_block"), Some("hit")) => Some(Which::BlockHit),
(Some("data_block"), Some("miss")) => Some(Which::BlockMiss),
_ => None,
},
_ => None,
};
Arc::new(Slot {
store: Arc::clone(&self.store),
process: Arc::clone(&COUNTERS),
which,
})
}
fn register_gauge(&self, _: &str, _: &str, _: &[(&str, &str)]) -> Arc<dyn GaugeFn> {
Arc::new(Sink)
}
fn register_up_down_counter(
&self,
_: &str,
_: &str,
_: &[(&str, &str)],
) -> Arc<dyn UpDownCounterFn> {
Arc::new(Sink)
}
fn register_histogram(
&self,
name: &str,
_: &str,
labels: &[(&str, &str)],
_: &[f64],
) -> Arc<dyn HistogramFn> {
Arc::new(Slot {
store: Arc::clone(&self.store),
process: Arc::clone(&COUNTERS),
which: (name == OBJECT_STORE_REQUEST_DURATION)
.then(|| object_store_metric(labels, true))
.flatten(),
})
}
}
struct Sink;
impl GaugeFn for Sink {
fn set(&self, _: i64) {}
}
impl UpDownCounterFn for Sink {
fn increment(&self, _: i64) {}
}
impl HistogramFn for Sink {
fn record(&self, _: f64) {}
}
static COUNTERS: std::sync::LazyLock<Arc<CacheCounters>> =
std::sync::LazyLock::new(|| Arc::new(CacheCounters::default()));
pub(crate) fn store_counters() -> Arc<CacheCounters> {
Arc::new(CacheCounters::default())
}
pub(crate) fn recorder(store: Arc<CacheCounters>) -> Arc<dyn MetricsRecorder> {
Arc::new(CacheRecorder { store })
}
#[must_use]
pub fn cache_tally() -> CacheTally {
COUNTERS.tally()
}
pub(crate) async fn shared(config: &CacheConfig) -> Option<Arc<dyn DbCache>> {
let cache = SHARED
.get_or_init(|| async {
let built = build(config).await;
let mut settled = BUILT_WITH
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*settled = Some(config.clone());
built
})
.await
.clone();
let settled = BUILT_WITH
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(settled) = settled.as_ref()
&& settled != config
{
warn!(
requested_memory = config.memory,
requested_disk_size = config.disk_size,
requested_dir = ?config.dir,
in_force_memory = settled.memory,
in_force_disk_size = settled.disk_size,
in_force_dir = ?settled.dir,
"the block cache is process-wide and already built; this attach's cache options are ignored. Set them on the first attach in the process."
);
}
cache
}
static BUILT_WITH: std::sync::Mutex<Option<CacheConfig>> = std::sync::Mutex::new(None);
async fn build(config: &CacheConfig) -> Option<Arc<dyn DbCache>> {
let (meta_bytes, auxiliary_metadata_bytes, block_bytes) = config.slots();
AUXILIARY_METADATA_MEMORY.store(auxiliary_metadata_bytes, Ordering::Relaxed);
let meta: Arc<dyn DbCache> = Arc::new(FoyerCache::new_with_opts(FoyerCacheOptions {
max_capacity: meta_bytes,
..FoyerCacheOptions::default()
}));
let block: Arc<dyn DbCache> = match &config.dir {
Some(dir) => match hybrid(dir, block_bytes, config.disk_size).await {
Some(cache) => cache,
None => Arc::new(FoyerCache::new_with_opts(FoyerCacheOptions {
max_capacity: block_bytes,
..FoyerCacheOptions::default()
})),
},
None => Arc::new(FoyerCache::new_with_opts(FoyerCacheOptions {
max_capacity: block_bytes,
..FoyerCacheOptions::default()
})),
};
info!(
meta_bytes,
auxiliary_metadata_bytes,
block_bytes,
disk = config.dir.is_some(),
"built the shared block cache"
);
Some(Arc::new(
SplitCache::new()
.with_meta_cache(Some(meta))
.with_block_cache(Some(block))
.build(),
) as Arc<dyn DbCache>)
}
fn cache_runtime() -> Option<tokio::runtime::Runtime> {
match tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.thread_name("moraine-cache")
.enable_all()
.build()
{
Ok(runtime) => Some(runtime),
Err(error) => {
warn!(
%error,
"could not start the cache runtime; the block cache stays in memory"
);
None
}
}
}
async fn hybrid(dir: &PathBuf, memory: u64, disk: Option<u64>) -> Option<Arc<dyn DbCache>> {
if let Err(error) = std::fs::create_dir_all(dir) {
warn!(
directory = %dir.display(),
%error,
"could not create the cache directory; the block cache stays in memory"
);
return None;
}
let capacity = usize::try_from(disk.unwrap_or(DEFAULT_CACHE_DISK)).unwrap_or(usize::MAX);
let device = match FsDeviceBuilder::new(dir).with_capacity(capacity).build() {
Ok(device) => device,
Err(error) => {
warn!(
directory = %dir.display(),
%error,
"could not open the cache device; the block cache stays in memory"
);
return None;
}
};
let built = HybridCacheBuilder::new()
.memory(usize::try_from(memory).unwrap_or(usize::MAX))
.with_weighter(|_: &CachedKey, value: &CachedEntry| value.size())
.storage()
.with_recover_mode(RecoverMode::None)
.with_spawner(Spawner::from(cache_runtime()?))
.with_io_engine_config(PsyncIoEngineConfig::new())
.with_engine_config(BlockEngineConfig::new(device))
.build()
.await;
match built {
Ok(cache) => Some(Arc::new(FoyerHybridCache::new_with_cache(cache))),
Err(error) => {
warn!(
directory = %dir.display(),
%error,
"could not build the hybrid block cache; it stays in memory"
);
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const RESTART_PHASE: &str = "MORAINE_CACHE_RESTART_PHASE";
const RESTART_ROOT: &str = "MORAINE_CACHE_RESTART_ROOT";
async fn run_restart_phase(phase: &str, root: &std::path::Path) {
use object_store::local::LocalFileSystem;
use slatedb::{
Db, DbReader,
config::{FlushOptions, FlushType, PutOptions, Settings, WriteOptions},
};
let store_root = root.join("store");
let cache_root = root.join("cache");
std::fs::create_dir_all(&store_root).unwrap();
let object_store = Arc::new(LocalFileSystem::new_with_prefix(&store_root).unwrap());
if phase == "seed" {
let settings = Settings {
flush_interval: None,
..Settings::default()
};
for (path, value) in [("a", b"catalog-a"), ("b", b"catalog-b")] {
let db = Db::builder(path, object_store.clone())
.with_settings(settings.clone())
.build()
.await
.unwrap();
db.put_with_options(
b"key",
value,
&PutOptions::default(),
&WriteOptions {
await_durable: false,
..WriteOptions::default()
},
)
.await
.unwrap();
db.flush_with_options(FlushOptions {
flush_type: FlushType::Wal,
})
.await
.unwrap();
drop(db);
}
std::process::exit(0);
}
let cache = hybrid(&cache_root, 4 * 1024 * 1024, Some(64 * 1024 * 1024))
.await
.unwrap();
let order = if phase == "warm" {
[("a", b"catalog-a"), ("b", b"catalog-b")]
} else {
[("b", b"catalog-b"), ("a", b"catalog-a")]
};
for (path, expected) in order {
let reader = DbReader::builder(path, object_store.clone())
.with_db_cache(cache.clone())
.build()
.await
.unwrap();
let found = reader.get(b"key").await.unwrap().unwrap();
assert_eq!(found.as_ref(), expected, "cache scope crossed into {path}");
reader.close().await.unwrap();
}
cache.close().await.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn recovered_disk_entries_do_not_cross_catalog_scopes() {
if let Ok(phase) = std::env::var(RESTART_PHASE) {
let root = PathBuf::from(std::env::var_os(RESTART_ROOT).unwrap());
run_restart_phase(&phase, &root).await;
return;
}
let root = std::env::temp_dir().join(format!(
"moraine-cache-restart-{}-{}",
std::process::id(),
uuid::Uuid::new_v4()
));
let test_name = "store::cache::tests::recovered_disk_entries_do_not_cross_catalog_scopes";
for phase in ["seed", "warm", "probe"] {
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", test_name, "--nocapture"])
.env(RESTART_PHASE, phase)
.env(RESTART_ROOT, &root)
.output()
.unwrap();
assert!(
output.status.success(),
"{phase} subprocess failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn the_budget_includes_auxiliary_metadata() {
let config = CacheConfig {
memory: Some(1_000),
..CacheConfig::default()
};
let (meta, auxiliary, block) = config.slots();
assert_eq!(meta + auxiliary + block, 1_000);
assert!(meta + auxiliary < block);
}
#[test]
fn an_unset_budget_matches_one_stores_defaults() {
let (meta, auxiliary, block) = CacheConfig::default().slots();
assert_eq!(meta + auxiliary + block, DEFAULT_CACHE_MEMORY);
assert_eq!(meta, 120 * 1024 * 1024);
assert_eq!(auxiliary, 8 * 1024 * 1024);
assert_eq!(block, 512 * 1024 * 1024);
}
#[tokio::test]
async fn a_later_attachs_cache_options_are_reported_as_ignored() {
let first = CacheConfig {
memory: Some(4 * 1024 * 1024),
..CacheConfig::default()
};
let built = shared(&first).await;
let second = CacheConfig {
memory: Some(64 * 1024 * 1024),
dir: Some(std::path::PathBuf::from("/tmp/moraine-ignored")),
disk_size: Some(1),
};
let served = shared(&second).await;
assert_eq!(built.is_some(), served.is_some());
assert!(
BUILT_WITH
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_ref()
.is_some_and(|settled| settled.memory != second.memory),
"the first config must be the one in force"
);
}
#[test]
fn the_recorder_routes_counters_by_entry_kind() {
let counters = Arc::new(CacheCounters::default());
let recorder = CacheRecorder {
store: Arc::clone(&counters),
};
let counter = |kind: &str, result: &str| {
recorder.register_counter(
stats::ACCESS_COUNT,
"",
&[("entry_kind", kind), ("result", result)],
)
};
counter("filter", "hit").increment(1);
counter("index", "hit").increment(2);
counter("stats", "miss").increment(3);
counter("data_block", "hit").increment(4);
counter("data_block", "miss").increment(5);
counter("something_new", "hit").increment(99);
recorder
.register_counter(stats::ERROR_COUNT, "", &[])
.increment(6);
let tally = counters.tally();
assert_eq!(tally.metadata_hits, 3);
assert_eq!(tally.metadata_misses, 3);
assert_eq!(tally.block_hits, 4);
assert_eq!(tally.block_misses, 5);
assert_eq!(tally.errors, 6);
}
#[test]
fn the_recorder_routes_object_store_requests_and_time() {
let counters = Arc::new(CacheCounters::default());
let recorder = CacheRecorder {
store: Arc::clone(&counters),
};
let labels = [
("component", "db"),
("store_type", "main"),
("op", "get"),
("api", "get_range"),
];
recorder
.register_counter("slatedb.object_store.request_count", "", &labels)
.increment(3);
recorder
.register_histogram(
"slatedb.object_store.request_duration_seconds",
"",
&labels,
&[],
)
.record(0.025);
let tally = counters.object_store_tally();
assert_eq!(tally.main_gets, 3);
assert_eq!(tally.main_get_duration, Duration::from_millis(25));
assert_eq!(tally.wal_puts, 0);
}
#[test]
fn a_stores_counters_are_its_own() {
let first = store_counters();
let second = store_counters();
let before = cache_tally().block_hits;
recorder(Arc::clone(&first))
.register_counter(
stats::ACCESS_COUNT,
"",
&[("entry_kind", "data_block"), ("result", "hit")],
)
.increment(4);
assert_eq!(first.tally().block_hits, 4);
assert_eq!(second.tally().block_hits, 0, "one store's hits are its own");
assert_eq!(
cache_tally().block_hits - before,
4,
"and the process still counts them"
);
}
#[test]
fn hit_rates_need_a_lookup_to_report() {
assert_eq!(CacheTally::default().metadata_hit_rate(), None);
assert_eq!(CacheTally::default().block_hit_rate(), None);
let tally = CacheTally {
metadata_hits: 3,
metadata_misses: 1,
block_hits: 1,
block_misses: 3,
errors: 0,
};
assert!((tally.metadata_hit_rate().unwrap() - 0.75).abs() < f64::EPSILON);
assert!((tally.block_hit_rate().unwrap() - 0.25).abs() < f64::EPSILON);
}
#[test]
fn a_tiny_budget_still_splits() {
let config = CacheConfig {
memory: Some(1),
..CacheConfig::default()
};
let (meta, auxiliary, block) = config.slots();
assert!(meta >= 1 && block >= 1);
assert_eq!(auxiliary, 0);
}
}