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]>
This commit is contained in:
osobh
2026-09-19 06:07:21 -07:00
co-authored by Claude Fable 5.1
parent 4f2975d7e3
commit 99b907be04
7 changed files with 334 additions and 18 deletions
+8 -8
View File
@@ -196,7 +196,7 @@ fn test_migration_round_trip() {
mem.add_relation(e1, e2, "discusses", 0.8).unwrap();
// Verify all data transferred by reopening
let reopened = HDF5Memory::open(&path).unwrap();
let reopened = HDF5Memory::open_read_only(&path).unwrap();
assert_eq!(reopened.count(), 500);
// Verify sessions
@@ -266,7 +266,7 @@ fn test_knowledge_graph_workflow() {
assert_eq!(entity.entity_type, "library");
// Persistence
let reopened = HDF5Memory::open(&path).unwrap();
let reopened = HDF5Memory::open_read_only(&path).unwrap();
assert_eq!(reopened.knowledge().entities.len(), 4);
assert_eq!(reopened.knowledge().relations.len(), 4);
@@ -316,7 +316,7 @@ fn test_multi_session_workflow() {
assert_eq!(mem.count(), 100); // 5 sessions * 20 entries
// Reopen and verify sessions
let reopened = HDF5Memory::open(&path).unwrap();
let reopened = HDF5Memory::open_read_only(&path).unwrap();
for sess in 0..5 {
let summary = reopened
.get_session_summary(&format!("sess_{sess}"))
@@ -460,7 +460,7 @@ fn test_snapshot_and_continue() {
assert_eq!(snap_mem.count(), 50);
// Original should have 100
let orig_mem = HDF5Memory::open(&path).unwrap();
let orig_mem = HDF5Memory::open_read_only(&path).unwrap();
assert_eq!(orig_mem.count(), 100);
}
@@ -483,7 +483,7 @@ fn test_config_persistence_across_ops() {
mem.add_session("s1", 0, 0, "ch", "summary").unwrap();
mem.add_entity("Entity", "type", -1).unwrap();
let reopened = HDF5Memory::open(&path).unwrap();
let reopened = HDF5Memory::open_read_only(&path).unwrap();
assert_eq!(reopened.config().embedding_dim, 128);
assert_eq!(reopened.config().embedder, "custom:my-embedder-v2");
assert_eq!(reopened.config().chunk_size, 2048);
@@ -695,7 +695,7 @@ fn test_large_text_chunks() {
mem.save_batch(entries).unwrap();
// Reopen and verify
let reopened = HDF5Memory::open(&path).unwrap();
let reopened = HDF5Memory::open_read_only(&path).unwrap();
assert_eq!(reopened.count(), 10);
let (_, cache, _, _) = read_cache(&path);
@@ -752,7 +752,7 @@ fn test_interleaved_sessions_entries() {
mem.flush_wal().unwrap();
// Verify
let reopened = HDF5Memory::open(&path).unwrap();
let reopened = HDF5Memory::open_read_only(&path).unwrap();
assert_eq!(reopened.count(), 6);
assert_eq!(
reopened.get_session_summary("s1").unwrap().as_deref(),
@@ -806,7 +806,7 @@ fn test_knowledge_graph_with_embeddings() {
mem.add_relation(e_python, e_hdf5, "reads", 0.9).unwrap();
// Verify entity-embedding linkage persists
let reopened = HDF5Memory::open(&path).unwrap();
let reopened = HDF5Memory::open_read_only(&path).unwrap();
let rust_entity = reopened.knowledge().get_entity(e_rust).unwrap();
assert_eq!(rust_entity.embedding_idx, idx0 as i64);
+5 -5
View File
@@ -105,7 +105,7 @@ fn test_heavy_tombstoning() {
assert_eq!(mem.count_active(), 5000);
// Verify persistence
let reopened = HDF5Memory::open(&path).unwrap();
let reopened = HDF5Memory::open_read_only(&path).unwrap();
assert_eq!(reopened.count(), 5000);
}
@@ -163,7 +163,7 @@ fn test_large_embeddings_1536() {
assert_eq!(mem.count(), 10_000);
// Verify persistence
let reopened = HDF5Memory::open(&path).unwrap();
let reopened = HDF5Memory::open_read_only(&path).unwrap();
assert_eq!(reopened.count(), 10_000);
// Verify search works on large dims
@@ -545,7 +545,7 @@ fn test_delete_all_entries() {
assert_eq!(mem.count(), 0);
// Verify persistence
let reopened = HDF5Memory::open(&path).unwrap();
let reopened = HDF5Memory::open_read_only(&path).unwrap();
assert_eq!(reopened.count(), 0);
}
@@ -639,7 +639,7 @@ fn test_unicode_content() {
];
mem.save_batch(entries).unwrap();
let reopened = HDF5Memory::open(&path).unwrap();
let reopened = HDF5Memory::open_read_only(&path).unwrap();
assert_eq!(reopened.count(), 3);
let (_, cache, _, _) = clawhdf5_agent::storage::read_from_disk(&path).unwrap();
@@ -685,6 +685,6 @@ fn test_rapid_save_delete_cycles() {
assert_eq!(removed, 250);
assert_eq!(mem.count(), 250);
let reopened = HDF5Memory::open(&path).unwrap();
let reopened = HDF5Memory::open_read_only(&path).unwrap();
assert_eq!(reopened.count(), 250);
}