use parking_lot::{Mutex, RwLock};
use rocksdb::DB;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock, Weak};
use std::thread::sleep;
use std::time::Duration;
use crate::core::db;
use crate::core::db::data_frames::DataFrameError;
use crate::error::OxenError;
static CHANGES_DB_INSTANCES: LazyLock<Mutex<HashMap<PathBuf, Weak<RwLock<DB>>>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
const OPEN_RETRIES: u32 = 100;
const OPEN_RETRY_INTERVAL: Duration = Duration::from_millis(2);
pub fn remove_from_cache(db_path: impl AsRef<Path>) -> Result<(), OxenError> {
let mut instances = CHANGES_DB_INSTANCES.lock();
instances.remove(db_path.as_ref());
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();
instances.retain(|key, _| !key.starts_with(prefix));
Ok(())
}
pub fn get_changes_db(db_path: &Path) -> Result<Arc<RwLock<DB>>, DataFrameError> {
if let Some(strong) = lookup_live(db_path) {
return Ok(strong);
}
std::fs::create_dir_all(db_path).map_err(DataFrameError::FailCreateDfDbDir)?;
let opts = db::key_val::opts::default();
let mut attempts = 0;
loop {
let mut instances = CHANGES_DB_INSTANCES.lock();
if let Some(weak) = instances.get(db_path)
&& let Some(strong) = weak.upgrade()
{
return Ok(strong);
}
match DB::open(&opts, dunce::simplified(db_path)) {
Ok(db) => {
let handle = Arc::new(RwLock::new(db));
instances.insert(db_path.to_path_buf(), Arc::downgrade(&handle));
instances.retain(|_, weak| weak.strong_count() > 0);
return Ok(handle);
}
Err(err) if is_lock_collision(&err) => {
drop(instances);
attempts += 1;
if attempts >= OPEN_RETRIES {
return Err(err.into());
}
sleep(OPEN_RETRY_INTERVAL);
}
Err(err) => return Err(err.into()),
}
}
}
fn lookup_live(db_path: &Path) -> Option<Arc<RwLock<DB>>> {
let instances = CHANGES_DB_INSTANCES.lock();
instances.get(db_path)?.upgrade()
}
fn is_lock_collision(err: &rocksdb::Error) -> bool {
let msg = err.to_string();
msg.contains("LOCK") || msg.contains("lock file")
}
pub fn try_get_changes_db(db_path: &Path) -> Result<Option<Arc<RwLock<DB>>>, DataFrameError> {
if let Some(strong) = lookup_live(db_path) {
return Ok(Some(strong));
}
if !db_path.join("CURRENT").exists() {
return Ok(None);
}
get_changes_db(db_path).map(Some)
}
#[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 = 16;
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_repeated_opens_return_same_arc_under_pressure() -> 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")?;
for i in 0..256 {
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 registry 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(())
})
}
}