use std::{
path::PathBuf,
sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
};
use foyer::{
BlockEngineConfig, DeviceBuilder, FsDeviceBuilder, HybridCacheBuilder, PsyncIoEngineConfig,
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;
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) {
let budget = self.memory.unwrap_or(DEFAULT_CACHE_MEMORY).max(2);
let meta = (budget / META_SHARE_DIVISOR).max(1);
(meta, budget.saturating_sub(meta).max(1))
}
}
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,
}
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)]
struct CacheCounters {
metadata_hits: AtomicU64,
metadata_misses: AtomicU64,
block_hits: AtomicU64,
block_misses: AtomicU64,
errors: AtomicU64,
}
impl CacheCounters {
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),
}
}
}
struct Slot {
counters: Arc<CacheCounters>,
which: Option<Which>,
}
#[derive(Clone, Copy)]
enum Which {
MetadataHit,
MetadataMiss,
BlockHit,
BlockMiss,
Error,
}
impl CounterFn for Slot {
fn increment(&self, value: u64) {
let counter = match self.which {
Some(Which::MetadataHit) => &self.counters.metadata_hits,
Some(Which::MetadataMiss) => &self.counters.metadata_misses,
Some(Which::BlockHit) => &self.counters.block_hits,
Some(Which::BlockMiss) => &self.counters.block_misses,
Some(Which::Error) => &self.counters.errors,
None => return,
};
counter.fetch_add(value, Ordering::Relaxed);
}
}
#[derive(Debug)]
struct CacheRecorder {
counters: 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 {
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 {
counters: Arc::clone(&self.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,
_: &str,
_: &str,
_: &[(&str, &str)],
_: &[f64],
) -> Arc<dyn HistogramFn> {
Arc::new(Sink)
}
}
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 recorder() -> Arc<dyn MetricsRecorder> {
Arc::new(CacheRecorder {
counters: Arc::clone(&COUNTERS),
})
}
#[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, block_bytes) = config.slots();
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,
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_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::*;
#[test]
fn the_budget_splits_across_both_slots() {
let config = CacheConfig {
memory: Some(1_000),
..CacheConfig::default()
};
let (meta, block) = config.slots();
assert_eq!(meta + block, 1_000);
assert!(meta < block, "meta {meta} should be the smaller slot");
}
#[test]
fn an_unset_budget_matches_one_stores_defaults() {
let (meta, block) = CacheConfig::default().slots();
assert_eq!(meta + block, DEFAULT_CACHE_MEMORY);
assert_eq!(meta, 128 * 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 {
counters: 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 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, block) = config.slots();
assert!(meta >= 1 && block >= 1);
}
}