Files
clawhdf5/crates/clawhdf5-agent/src/store_lock.rs
T
osobhandClaude Fable 5.1 99b907be04 feat(agent): single-writer lock, read-only open, recoverable WAL
- HDF5Memory::create/open take an exclusive advisory lock on <store>.h5.lock
  (std File::try_lock, no new dependency). The store lives in memory and is
  rewritten wholesale at each checkpoint, so two handles on one store used to
  silently destroy each other's data; a second writer now gets
  MemoryError::Locked. The OS drops the lock with the descriptor, so a crash
  never leaves a stale lock. Acquisition retries for ~250 ms to absorb a
  previous owner that is mid-teardown; AsyncHDF5Memory::shutdown releases the
  lock once its writer task has stopped.
- HDF5Memory::open_read_only: a lock-free, point-in-time view (checkpoint +
  current WAL contents, replayed in memory) that never writes — it does not
  repair, upgrade or move the WAL, and anything that would persist returns an
  error. The CLI's recall/stats/agents-md/export use it, so a store can be
  inspected while an agent has it open. Tests that reopened a store purely to
  verify on-disk state now use it.
- open() no longer fails on a WAL that cannot possibly be replayed (torn
  header, bad magic): it is moved to <store>.h5.wal.corrupt-<ts>, reported via
  HDF5Memory::quarantined_wal(), and the healthy .h5 opens from its last
  checkpoint. A well-formed header with an unknown version still fails and is
  left untouched — most likely a newer build's WAL, which must not be
  discarded.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-19 06:07:21 -07:00

80 lines
2.8 KiB
Rust

//! Single-writer guard for a memory store.
//!
//! `HDF5Memory` keeps the whole store in memory and rewrites the `.h5` file at
//! every checkpoint, so two handles on one store (two processes, or two opens
//! in one process) silently destroy each other's data: whoever checkpoints
//! last wins, and both append to the same WAL with independent CRC chains.
//! The lock turns that into an immediate, explicit error.
use std::fs::{File, OpenOptions, TryLockError};
use std::path::{Path, PathBuf};
use crate::MemoryError;
const LOCK_RETRIES: u32 = 25;
const LOCK_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(10);
/// An exclusive advisory lock on `<store>.h5.lock`, held for the lifetime of
/// the owning `HDF5Memory` and released when it is dropped (or when the
/// process dies — the OS drops the lock with the file descriptor, so a crash
/// never leaves a stale lock behind; the empty lock file itself is harmless).
#[derive(Debug)]
pub(crate) struct StoreLock {
_file: File,
}
impl StoreLock {
pub(crate) fn lock_path(store: &Path) -> PathBuf {
store.with_extension("h5.lock")
}
pub(crate) fn acquire(store: &Path) -> Result<Self, MemoryError> {
let path = Self::lock_path(store);
let file = OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&path)?;
// A previous owner may be mid-teardown (e.g. an `AsyncHDF5Memory`
// dropped without `shutdown()`: its background task releases the
// store a moment later), so give the lock a short, bounded grace
// period before reporting a genuine second writer.
let mut attempts_left = LOCK_RETRIES;
loop {
match file.try_lock() {
Ok(()) => return Ok(Self { _file: file }),
Err(TryLockError::WouldBlock) if attempts_left > 0 => {
attempts_left -= 1;
std::thread::sleep(LOCK_RETRY_DELAY);
}
Err(TryLockError::WouldBlock) => {
return Err(MemoryError::Locked(format!(
"{} is already open in this or another process (lock file {})",
store.display(),
path.display()
)));
}
Err(TryLockError::Error(e)) => return Err(MemoryError::Io(e)),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn second_acquire_fails_until_first_is_dropped() {
let dir = tempfile::TempDir::new().unwrap();
let store = dir.path().join("s.h5");
let first = StoreLock::acquire(&store).unwrap();
assert!(matches!(
StoreLock::acquire(&store),
Err(MemoryError::Locked(_))
));
drop(first);
StoreLock::acquire(&store).unwrap();
}
}