use std::collections::HashMap;
use std::future::Future;
use std::path::PathBuf;
use std::sync::{Arc, LazyLock};
use std::time::Duration;
use parking_lot::Mutex;
use tokio::sync::{Mutex as AsyncMutex, Notify};
use tokio::time::timeout;
use crate::error::OxenError;
use crate::model::LocalRepository;
const DEFAULT_DRAIN_TIMEOUT: Duration = Duration::from_secs(30);
struct GateState {
exclusive: bool,
active_writes: usize,
}
struct RepoGate {
state: Mutex<GateState>,
drained: Notify,
exclusive_slot: AsyncMutex<()>,
}
static REGISTRY: LazyLock<Mutex<HashMap<PathBuf, Arc<RepoGate>>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
fn gate_for(repo: &LocalRepository) -> Arc<RepoGate> {
REGISTRY
.lock()
.entry(repo.path.clone())
.or_insert_with(|| {
Arc::new(RepoGate {
state: Mutex::new(GateState {
exclusive: false,
active_writes: 0,
}),
drained: Notify::new(),
exclusive_slot: AsyncMutex::new(()),
})
})
.clone()
}
fn lock_timeout() -> OxenError {
OxenError::LockTimeout("The repository is locked for maintenance. Try again later.".into())
}
pub struct RepoWriteGuard {
gate: Arc<RepoGate>,
}
impl Drop for RepoWriteGuard {
fn drop(&mut self) {
let mut state = self.gate.state.lock();
state.active_writes -= 1;
if state.active_writes == 0 {
self.gate.drained.notify_waiters();
}
}
}
pub fn acquire_write(repo: &LocalRepository) -> Result<RepoWriteGuard, OxenError> {
let gate = gate_for(repo);
let mut state = gate.state.lock();
if state.exclusive {
return Err(lock_timeout());
}
state.active_writes += 1;
drop(state);
Ok(RepoWriteGuard { gate })
}
struct ExclusiveMarker {
gate: Arc<RepoGate>,
}
impl ExclusiveMarker {
fn set(gate: Arc<RepoGate>) -> Self {
gate.state.lock().exclusive = true;
Self { gate }
}
}
impl Drop for ExclusiveMarker {
fn drop(&mut self) {
self.gate.state.lock().exclusive = false;
}
}
pub async fn with_repo_exclusive<T>(
repo: &LocalRepository,
work: impl Future<Output = Result<T, OxenError>>,
) -> Result<T, OxenError> {
with_repo_exclusive_with_timeout(repo, DEFAULT_DRAIN_TIMEOUT, work).await
}
pub(crate) async fn with_repo_exclusive_with_timeout<T>(
repo: &LocalRepository,
drain_timeout: Duration,
work: impl Future<Output = Result<T, OxenError>>,
) -> Result<T, OxenError> {
let gate = gate_for(repo);
let _slot = gate.exclusive_slot.lock().await;
let _marker = ExclusiveMarker::set(gate.clone());
timeout(drain_timeout, drain(&gate))
.await
.map_err(|_| lock_timeout())?;
work.await
}
async fn drain(gate: &RepoGate) {
loop {
let notified = gate.drained.notified();
tokio::pin!(notified);
notified.as_mut().enable();
if gate.state.lock().active_writes == 0 {
return;
}
notified.await;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test;
use tokio::sync::oneshot;
#[tokio::test]
async fn test_exclusive_rejects_new_writers() -> Result<(), OxenError> {
test::run_empty_local_repo_test_async(|repo| async move {
let (held_tx, held_rx) = oneshot::channel();
let (release_tx, release_rx) = oneshot::channel();
let repo_excl = repo.clone();
let handle = tokio::spawn(async move {
with_repo_exclusive(&repo_excl, async move {
held_tx.send(()).expect("signal exclusive held");
release_rx.await.expect("await release");
Ok::<(), OxenError>(())
})
.await
});
held_rx.await.expect("exclusive should be held");
assert!(
matches!(acquire_write(&repo), Err(OxenError::LockTimeout(_))),
"a write must be rejected while the exclusive lock is held"
);
release_tx.send(()).expect("release the exclusive op");
handle.await.expect("join exclusive task")?;
let _guard = acquire_write(&repo)?;
Ok(())
})
.await
}
#[tokio::test]
async fn test_exclusive_waits_for_in_flight_writes_to_drain() -> Result<(), OxenError> {
test::run_empty_local_repo_test_async(|repo| async move {
let guard = acquire_write(&repo)?;
let repo_excl = repo.clone();
let (done_tx, mut done_rx) = oneshot::channel();
let handle = tokio::spawn(async move {
with_repo_exclusive(&repo_excl, async { Ok::<(), OxenError>(()) })
.await
.expect("exclusive op");
done_tx.send(()).expect("signal done");
});
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(
done_rx.try_recv().is_err(),
"exclusive proceeded before the in-flight write drained"
);
drop(guard);
done_rx.await.expect("exclusive should proceed after drain");
handle.await.expect("join exclusive task");
Ok(())
})
.await
}
#[tokio::test]
async fn test_exclusive_times_out_when_writes_never_drain() -> Result<(), OxenError> {
test::run_empty_local_repo_test_async(|repo| async move {
let guard = acquire_write(&repo)?;
let result =
with_repo_exclusive_with_timeout(&repo, Duration::from_millis(100), async {
Ok::<(), OxenError>(())
})
.await;
assert!(
matches!(result, Err(OxenError::LockTimeout(_))),
"an undrained write must time out the exclusive acquire"
);
drop(guard);
assert!(
acquire_write(&repo).is_ok(),
"a drain timeout must leave the repo unwedged (exclusive marker cleared)"
);
Ok(())
})
.await
}
}