- 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]>
80 lines
2.8 KiB
Rust
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();
|
|
}
|
|
}
|