use lru::LruCache;
use parking_lot::{Mutex, RwLock};
use rocksdb::DB;
use std::num::NonZeroUsize;
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock};
use crate::core::db;
use crate::core::db::data_frames::DataFrameError;
use crate::error::OxenError;
const CHANGES_DB_CACHE_SIZE: NonZeroUsize = NonZeroUsize::new(100).unwrap();
static CHANGES_DB_INSTANCES: LazyLock<Mutex<LruCache<PathBuf, Arc<RwLock<DB>>>>> =
LazyLock::new(|| Mutex::new(LruCache::new(CHANGES_DB_CACHE_SIZE)));
pub fn remove_from_cache(db_path: impl AsRef<Path>) -> Result<(), OxenError> {
let mut instances = CHANGES_DB_INSTANCES.lock();
let _ = instances.pop(&db_path.as_ref().to_path_buf());
Ok(())
}
pub fn remove_from_cache_with_children(prefix: impl AsRef<Path>) -> Result<(), OxenError> {
let prefix = prefix.as_ref();
let mut instances = CHANGES_DB_INSTANCES.lock();
let to_remove: Vec<PathBuf> = instances
.iter()
.map(|(key, _)| key.clone())
.filter(|key| key.starts_with(prefix))
.collect();
for key in to_remove {
let _ = instances.pop(&key);
}
Ok(())
}
pub fn get_changes_db(db_path: &Path) -> Result<Arc<RwLock<DB>>, DataFrameError> {
if let Some(db) = lookup_cached(db_path) {
return Ok(db);
}
open_and_cache(db_path)
}
pub fn try_get_changes_db(db_path: &Path) -> Result<Option<Arc<RwLock<DB>>>, DataFrameError> {
if let Some(db) = lookup_cached(db_path) {
return Ok(Some(db));
}
if !db_path.join("CURRENT").exists() {
return Ok(None);
}
open_and_cache(db_path).map(Some)
}
fn lookup_cached(db_path: &Path) -> Option<Arc<RwLock<DB>>> {
let mut cache = CHANGES_DB_INSTANCES.lock();
cache.get(&db_path.to_path_buf()).cloned()
}
fn open_and_cache(db_path: &Path) -> Result<Arc<RwLock<DB>>, DataFrameError> {
let key = db_path.to_path_buf();
let mut cache = CHANGES_DB_INSTANCES.lock();
if let Some(db) = cache.get(&key) {
return Ok(db.clone());
}
if !db_path.exists() {
std::fs::create_dir_all(db_path).map_err(DataFrameError::FailCreateDfDbDir)?;
}
let can_cache = make_room_for_new_entry(&mut cache);
let opts = db::key_val::opts::default();
let handle = Arc::new(RwLock::new(DB::open(&opts, dunce::simplified(db_path))?));
if can_cache {
cache.put(key, handle.clone());
}
Ok(handle)
}
fn make_room_for_new_entry(cache: &mut LruCache<PathBuf, Arc<RwLock<DB>>>) -> bool {
if cache.len() < cache.cap().get() {
return true;
}
let victim = cache
.iter()
.rev()
.find_map(|(k, v)| (Arc::strong_count(v) == 1).then(|| k.clone()));
match victim {
Some(key) => {
cache.pop(&key);
true
}
None => {
log::warn!(
"changes_db cache is at capacity ({}) with every entry still in use; \
opening a new handle uncached",
cache.cap().get()
);
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test;
use std::thread;
#[test]
fn test_get_changes_db_shares_one_handle_per_path() -> Result<(), OxenError> {
test::run_empty_dir_test(|data_dir| {
let db_path = data_dir.join("row_changes");
let a = get_changes_db(&db_path)?;
let b = get_changes_db(&db_path)?;
assert!(
Arc::ptr_eq(&a, &b),
"repeated opens of the same path must reuse one cached handle"
);
remove_from_cache(&db_path)?;
Ok(())
})
}
#[test]
fn test_concurrent_opens_do_not_hit_the_lock_error() -> Result<(), OxenError> {
const NUM_THREADS: usize = CHANGES_DB_CACHE_SIZE.get() + 8;
test::run_empty_dir_test(|data_dir| {
let db_path = data_dir.join("row_changes");
let _held = get_changes_db(&db_path)?;
thread::scope(|scope| {
let handles: Vec<_> = (0..NUM_THREADS)
.map(|i| {
let db_path = db_path.clone();
scope.spawn(move || -> Result<(), OxenError> {
let handle = get_changes_db(&db_path)?;
handle.write().put(format!("key-{i}"), format!("val-{i}"))?;
Ok(())
})
})
.collect();
for handle in handles {
handle
.join()
.expect("worker thread panicked")
.expect("concurrent open/write must not hit the RocksDB lock error");
}
});
let handle = get_changes_db(&db_path)?;
let db = handle.read();
for i in 0..NUM_THREADS {
let value = db.get(format!("key-{i}"))?;
assert_eq!(value.as_deref(), Some(format!("val-{i}").as_bytes()));
}
drop(db);
remove_from_cache(&db_path)?;
Ok(())
})
}
#[test]
fn test_try_get_changes_db_does_not_create_missing_db() -> Result<(), OxenError> {
test::run_empty_dir_test(|data_dir| {
let db_path = data_dir.join("row_changes");
assert!(try_get_changes_db(&db_path)?.is_none());
assert!(
!db_path.exists(),
"try_get_changes_db must not create the db directory on a read miss"
);
std::fs::create_dir_all(&db_path)?;
assert!(try_get_changes_db(&db_path)?.is_none());
assert!(
!db_path.join("CURRENT").exists(),
"try_get_changes_db must not initialize an empty leftover directory"
);
get_changes_db(&db_path)?.write().put("k", "v")?;
let handle = try_get_changes_db(&db_path)?.expect("db exists after write");
assert_eq!(handle.read().get("k")?.as_deref(), Some(b"v".as_ref()));
remove_from_cache(&db_path)?;
Ok(())
})
}
#[test]
fn test_open_uncached_when_cache_is_fully_pinned() -> Result<(), OxenError> {
test::run_empty_dir_test(|data_dir| {
let mut pinned = Vec::with_capacity(CHANGES_DB_CACHE_SIZE.get());
for i in 0..CHANGES_DB_CACHE_SIZE.get() {
pinned.push(get_changes_db(&data_dir.join(format!("pinned-{i}")))?);
}
let extra_path = data_dir.join("extra");
let extra = get_changes_db(&extra_path)?;
extra.write().put("k", "v")?;
assert_eq!(extra.read().get("k")?.as_deref(), Some(b"v".as_ref()));
drop(extra);
let reopened = get_changes_db(&extra_path)?;
assert_eq!(reopened.read().get("k")?.as_deref(), Some(b"v".as_ref()));
drop(reopened);
drop(pinned);
remove_from_cache_with_children(data_dir)?;
Ok(())
})
}
#[test]
fn test_eviction_skips_still_held_entries() -> Result<(), OxenError> {
test::run_empty_dir_test(|data_dir| {
let held_path = data_dir.join("held");
let held = get_changes_db(&held_path)?;
held.write().put("k", "v")?;
let pressure = CHANGES_DB_CACHE_SIZE.get() * 2;
for i in 0..pressure {
let other = get_changes_db(&data_dir.join(format!("other-{i}")))?;
drop(other);
}
let reopened = get_changes_db(&held_path)?;
assert!(
Arc::ptr_eq(&held, &reopened),
"the held handle must survive eviction pressure",
);
assert_eq!(reopened.read().get("k")?.as_deref(), Some(b"v".as_ref()));
drop(held);
drop(reopened);
remove_from_cache_with_children(data_dir)?;
Ok(())
})
}
#[test]
fn test_concurrent_compound_writes_serialize() -> Result<(), OxenError> {
const NUM_THREADS: usize = 16;
test::run_empty_dir_test(|data_dir| {
let db_path = data_dir.join("row_changes");
let handle = get_changes_db(&db_path)?;
thread::scope(|scope| {
let workers: Vec<_> = (0..NUM_THREADS)
.map(|i| {
let handle = handle.clone();
scope.spawn(move || -> Result<(), OxenError> {
let db = handle.write();
db.delete("shared")?;
db.put("shared", format!("value-{i}"))?;
Ok(())
})
})
.collect();
for w in workers {
w.join().expect("worker panicked").expect("compound write");
}
});
let db = handle.read();
let value = db.get("shared")?.expect("some writer's value persisted");
let value = std::str::from_utf8(&value).expect("utf8");
assert!(
(0..NUM_THREADS).any(|i| value == format!("value-{i}")),
"stored value should match one of the writers, got {value:?}",
);
drop(db);
remove_from_cache(&db_path)?;
Ok(())
})
}
}