Merge pull request 'docs(clawhdf5): document DType variants, fix unresolved doc links' (#17) from sdlc-docs/clawhdf5-types-20260514-165210 into main

This commit is contained in:
redclawsystems
2026-05-14 23:54:48 +00:00
commit 3f222f6956
3030 changed files with 89917 additions and 0 deletions
+923
View File
@@ -0,0 +1,923 @@
//! Async wrapper for [`HDF5Memory`] with background flush support.
//!
//! Provides non-blocking access to the synchronous HDF5Memory core by
//! offloading all I/O and CPU-bound work to `spawn_blocking`. Includes
//! a background flush task that:
//!
//! - **Batches saves** through an mpsc channel (avoids per-entry disk writes)
//! - **Auto-flushes** on a configurable interval (default 5s)
//! - **Threshold-flushes** when pending WAL entries exceed a limit
//! - **Tracks dirty state** to skip no-op flushes
//!
//! # Architecture
//!
//! ```text
//! ┌───────────────┐ ┌──────────────┐
//! │ AsyncHDF5 │ spawn_blocking │ HDF5Memory │
//! │ Memory │ ─────────────────── │ (sync core) │
//! └───────┬───────┘ └──────────────┘
//! │
//! │ mpsc channel (saves + commands)
//! ▼
//! ┌───────────────┐
//! │ Background │ interval timer + threshold check
//! │ Writer Task │ batch save → WAL → periodic .h5 merge
//! └───────────────┘
//! ```
//!
//! # Usage
//!
//! ```ignore
//! use clawhdf5_agent::async_memory::{AsyncHDF5Memory, AsyncConfig};
//!
//! let config = AsyncConfig {
//! flush_interval: Duration::from_secs(10),
//! flush_threshold: 100,
//! };
//! let mem = AsyncHDF5Memory::open_with(path, config).await?;
//! mem.save(entry).await?; // buffered → background writer
//! mem.save_batch(entries).await?; // also buffered
//! let results = mem.hybrid_search(emb, "query".into(), 0.7, 0.3, 5).await;
//! mem.shutdown().await?; // final flush + stop
//! ```
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{Mutex, mpsc, oneshot};
use tokio::task::spawn_blocking;
use crate::memory_strategy::{Exchange, StrategyOutput};
use crate::{
AgentMemory, HDF5Memory, MemoryConfig, MemoryEntry, MemoryError, Result, SearchResult,
};
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
/// Configuration for the async background writer.
#[derive(Debug, Clone)]
pub struct AsyncConfig {
/// How often the background task auto-flushes WAL → .h5.
/// Set to `Duration::ZERO` to disable periodic flush (threshold-only).
pub flush_interval: Duration,
/// Flush WAL → .h5 when pending WAL entries reach this count.
/// Set to `0` to disable threshold-based flush (interval-only).
pub flush_threshold: usize,
/// Channel capacity for buffered save commands.
/// Higher = more batching, more memory. Default 256.
pub channel_capacity: usize,
}
impl Default for AsyncConfig {
fn default() -> Self {
Self {
flush_interval: Duration::from_secs(5),
flush_threshold: 200,
channel_capacity: 256,
}
}
}
// ---------------------------------------------------------------------------
// Background writer commands
// ---------------------------------------------------------------------------
enum WriteCmd {
/// Buffer one or more entries for saving.
Save {
entries: Vec<MemoryEntry>,
reply: oneshot::Sender<Result<Vec<usize>>>,
},
/// Flush WAL → .h5 now.
FlushNow(oneshot::Sender<Result<()>>),
/// Tick session (decay + flush).
TickSession(oneshot::Sender<Result<()>>),
/// Shut down the background task.
Shutdown(oneshot::Sender<()>),
}
// ---------------------------------------------------------------------------
// AsyncHDF5Memory
// ---------------------------------------------------------------------------
/// Async wrapper around [`HDF5Memory`].
///
/// Saves are buffered through a channel and batched by the background
/// writer task. Reads/searches use `spawn_blocking` directly (they need
/// the latest state, so they acquire the lock and run immediately).
pub struct AsyncHDF5Memory {
inner: Arc<Mutex<HDF5Memory>>,
write_tx: mpsc::Sender<WriteCmd>,
}
impl AsyncHDF5Memory {
// -- Construction -------------------------------------------------------
/// Create a new HDF5 memory file with default async config.
pub async fn create(config: MemoryConfig) -> Result<Self> {
Self::create_with(config, AsyncConfig::default()).await
}
/// Create a new HDF5 memory file with custom async config.
pub async fn create_with(config: MemoryConfig, async_config: AsyncConfig) -> Result<Self> {
let mem = spawn_blocking(move || HDF5Memory::create(config))
.await
.map_err(join_err)??;
Ok(Self::wrap_with(mem, async_config))
}
/// Open an existing HDF5 memory file with default async config.
pub async fn open(path: impl AsRef<Path>) -> Result<Self> {
Self::open_with(path, AsyncConfig::default()).await
}
/// Open an existing HDF5 memory file with custom async config.
pub async fn open_with(path: impl AsRef<Path>, async_config: AsyncConfig) -> Result<Self> {
let path = path.as_ref().to_path_buf();
let mem = spawn_blocking(move || HDF5Memory::open(&path))
.await
.map_err(join_err)??;
Ok(Self::wrap_with(mem, async_config))
}
/// Wrap a sync `HDF5Memory` with default async config.
pub fn wrap(mem: HDF5Memory) -> Self {
Self::wrap_with(mem, AsyncConfig::default())
}
/// Wrap a sync `HDF5Memory` with custom async config.
pub fn wrap_with(mem: HDF5Memory, async_config: AsyncConfig) -> Self {
let inner = Arc::new(Mutex::new(mem));
let (write_tx, write_rx) = mpsc::channel(async_config.channel_capacity);
let bg_inner = Arc::clone(&inner);
tokio::spawn(background_writer(bg_inner, write_rx, async_config));
Self { inner, write_tx }
}
// -- Buffered save ops --------------------------------------------------
/// Save a single memory entry. Buffered through the background writer.
///
/// Returns the entry index once the background writer has applied it
/// to the in-memory cache (does NOT wait for .h5 flush).
pub async fn save(&self, entry: MemoryEntry) -> Result<usize> {
let (tx, rx) = oneshot::channel();
self.write_tx
.send(WriteCmd::Save {
entries: vec![entry],
reply: tx,
})
.await
.map_err(|_| channel_gone())?;
let indices = rx.await.map_err(|_| channel_gone())??;
Ok(indices[0])
}
/// Save a batch of entries. Buffered through the background writer.
pub async fn save_batch(&self, entries: Vec<MemoryEntry>) -> Result<Vec<usize>> {
let (tx, rx) = oneshot::channel();
self.write_tx
.send(WriteCmd::Save { entries, reply: tx })
.await
.map_err(|_| channel_gone())?;
rx.await.map_err(|_| channel_gone())?
}
// -- Direct mutating ops (not buffered — need immediate consistency) ----
/// Delete a memory entry by index.
pub async fn delete(&self, id: usize) -> Result<()> {
let inner = Arc::clone(&self.inner);
spawn_blocking(move || {
let mut mem = inner.blocking_lock();
mem.delete(id)
})
.await
.map_err(join_err)?
}
/// Compact tombstoned entries.
pub async fn compact(&self) -> Result<usize> {
let inner = Arc::clone(&self.inner);
spawn_blocking(move || {
let mut mem = inner.blocking_lock();
mem.compact()
})
.await
.map_err(join_err)?
}
/// Record an exchange using the configured memory strategy.
pub async fn record(&self, exchange: Exchange) -> Result<StrategyOutput> {
let inner = Arc::clone(&self.inner);
spawn_blocking(move || {
let mut mem = inner.blocking_lock();
mem.record(exchange)
})
.await
.map_err(join_err)?
}
// -- Search ops ---------------------------------------------------------
/// Hybrid vector + BM25 search.
pub async fn hybrid_search(
&self,
query_embedding: Vec<f32>,
query_text: String,
vector_weight: f32,
keyword_weight: f32,
k: usize,
) -> Vec<SearchResult> {
let inner = Arc::clone(&self.inner);
spawn_blocking(move || {
let mut mem = inner.blocking_lock();
mem.hybrid_search(
&query_embedding,
&query_text,
vector_weight,
keyword_weight,
k,
)
})
.await
.unwrap_or_default()
}
// -- Read ops -----------------------------------------------------------
/// Number of entries (including tombstoned).
pub async fn count(&self) -> usize {
self.inner.lock().await.count()
}
/// Number of active (non-tombstoned) entries.
pub async fn count_active(&self) -> usize {
self.inner.lock().await.count_active()
}
/// Get a chunk by index.
pub async fn get_chunk(&self, index: usize) -> Option<String> {
self.inner.lock().await.get_chunk(index).map(String::from)
}
/// Number of pending WAL entries.
pub async fn wal_pending_count(&self) -> usize {
self.inner.lock().await.wal_pending_count()
}
/// Get a clone of the config.
pub async fn config(&self) -> MemoryConfig {
self.inner.lock().await.config().clone()
}
/// Generate AGENTS.md content.
pub async fn generate_agents_md(&self) -> String {
let inner = Arc::clone(&self.inner);
spawn_blocking(move || {
let mem = inner.blocking_lock();
mem.generate_agents_md()
})
.await
.unwrap_or_default()
}
// -- Session ops --------------------------------------------------------
/// Add a session record.
pub async fn add_session(
&self,
id: String,
start: usize,
end: usize,
channel: String,
summary: String,
) -> Result<()> {
let inner = Arc::clone(&self.inner);
spawn_blocking(move || {
let mut mem = inner.blocking_lock();
mem.add_session(&id, start, end, &channel, &summary)
})
.await
.map_err(join_err)?
}
/// Get a session summary by ID.
pub async fn get_session_summary(&self, session_id: String) -> Result<Option<String>> {
let inner = Arc::clone(&self.inner);
spawn_blocking(move || {
let mem = inner.blocking_lock();
mem.get_session_summary(&session_id)
})
.await
.map_err(join_err)?
}
// -- Knowledge graph ops ------------------------------------------------
/// Add an entity to the knowledge graph.
pub async fn add_entity(
&self,
name: String,
entity_type: String,
embedding_idx: i64,
) -> Result<u64> {
let inner = Arc::clone(&self.inner);
spawn_blocking(move || {
let mut mem = inner.blocking_lock();
mem.add_entity(&name, &entity_type, embedding_idx)
})
.await
.map_err(join_err)?
}
/// Add an entity alias.
pub async fn add_entity_alias(&self, alias: String, entity_id: i64) -> Result<()> {
let inner = Arc::clone(&self.inner);
spawn_blocking(move || {
let mut mem = inner.blocking_lock();
mem.add_entity_alias(&alias, entity_id)
})
.await
.map_err(join_err)?
}
/// Add a relation to the knowledge graph.
pub async fn add_relation(
&self,
src: u64,
tgt: u64,
relation: String,
weight: f32,
) -> Result<()> {
let inner = Arc::clone(&self.inner);
spawn_blocking(move || {
let mut mem = inner.blocking_lock();
mem.add_relation(src, tgt, &relation, weight)
})
.await
.map_err(join_err)?
}
// -- Snapshot -----------------------------------------------------------
/// Snapshot the memory file to a destination path.
pub async fn snapshot(&self, dest: PathBuf) -> Result<PathBuf> {
let inner = Arc::clone(&self.inner);
spawn_blocking(move || {
let mem = inner.blocking_lock();
mem.snapshot(&dest)
})
.await
.map_err(join_err)?
}
// -- Flush / lifecycle --------------------------------------------------
/// Request an immediate WAL → .h5 flush.
pub async fn flush(&self) -> Result<()> {
let (tx, rx) = oneshot::channel();
self.write_tx
.send(WriteCmd::FlushNow(tx))
.await
.map_err(|_| channel_gone())?;
rx.await.map_err(|_| channel_gone())?
}
/// Tick the session (decay activations + flush).
pub async fn tick_session(&self) -> Result<()> {
let (tx, rx) = oneshot::channel();
self.write_tx
.send(WriteCmd::TickSession(tx))
.await
.map_err(|_| channel_gone())?;
rx.await.map_err(|_| channel_gone())?
}
/// Gracefully shut down the background writer.
///
/// Performs a final flush before stopping. Call before drop to
/// ensure all buffered data is persisted.
pub async fn shutdown(&self) -> Result<()> {
self.flush().await?;
let (tx, rx) = oneshot::channel();
let _ = self.write_tx.send(WriteCmd::Shutdown(tx)).await;
let _ = rx.await;
Ok(())
}
}
// ---------------------------------------------------------------------------
// Background writer task
// ---------------------------------------------------------------------------
/// The background writer loop. Handles:
/// 1. Batched saves from the channel
/// 2. Periodic auto-flush on a timer
/// 3. Threshold-based flush when WAL grows too large
async fn background_writer(
inner: Arc<Mutex<HDF5Memory>>,
mut rx: mpsc::Receiver<WriteCmd>,
config: AsyncConfig,
) {
let use_interval = config.flush_interval > Duration::ZERO;
let use_threshold = config.flush_threshold > 0;
// Dirty flag: true when cache has unsaved changes that haven't been
// flushed to .h5 yet. Saves always go through WAL first, so data
// is durable — this just tracks whether we need a full .h5 rewrite.
let mut dirty = false;
let mut interval = tokio::time::interval(if use_interval {
config.flush_interval
} else {
// If disabled, set a very long interval so it never fires
Duration::from_secs(86400)
});
// Don't fire immediately on creation
interval.tick().await;
loop {
tokio::select! {
// --- Channel commands ---
cmd = rx.recv() => {
match cmd {
Some(WriteCmd::Save { entries, reply }) => {
let mem = Arc::clone(&inner);
let result = spawn_blocking(move || {
let mut m = mem.blocking_lock();
let mut indices = Vec::with_capacity(entries.len());
for entry in entries {
// Push to cache + WAL only (no .h5 rewrite).
// We use the existing save() which handles
// WAL append + auto-merge at wal_max_entries.
match m.save(entry) {
Ok(idx) => indices.push(idx),
Err(e) => return Err(e),
}
}
Ok(indices)
})
.await
.unwrap_or_else(|e| Err(MemoryError::Io(std::io::Error::new(
std::io::ErrorKind::Other, e,
))));
dirty = result.is_ok();
let _ = reply.send(result);
// Check threshold
if use_threshold && dirty {
let mem = Arc::clone(&inner);
let threshold = config.flush_threshold;
let pending = spawn_blocking(move || {
let m = mem.blocking_lock();
m.wal_pending_count()
}).await.unwrap_or(0);
if pending >= threshold {
let mem = Arc::clone(&inner);
let _ = spawn_blocking(move || {
let mut m = mem.blocking_lock();
m.flush_wal()
}).await;
dirty = false;
}
}
}
Some(WriteCmd::FlushNow(reply)) => {
if dirty {
let mem = Arc::clone(&inner);
let result = spawn_blocking(move || {
let mut m = mem.blocking_lock();
m.flush_wal()
})
.await
.unwrap_or_else(|e| Err(MemoryError::Io(std::io::Error::new(
std::io::ErrorKind::Other, e,
))));
if result.is_ok() { dirty = false; }
let _ = reply.send(result);
} else {
let _ = reply.send(Ok(()));
}
}
Some(WriteCmd::TickSession(reply)) => {
let mem = Arc::clone(&inner);
let result = spawn_blocking(move || {
let mut m = mem.blocking_lock();
m.tick_session()
})
.await
.unwrap_or_else(|e| Err(MemoryError::Io(std::io::Error::new(
std::io::ErrorKind::Other, e,
))));
if result.is_ok() { dirty = false; }
let _ = reply.send(result);
}
Some(WriteCmd::Shutdown(reply)) => {
let _ = reply.send(());
break;
}
None => break, // channel closed
}
}
// --- Periodic auto-flush ---
_ = interval.tick(), if use_interval && dirty => {
let mem = Arc::clone(&inner);
let ok = spawn_blocking(move || {
let mut m = mem.blocking_lock();
m.flush_wal()
}).await;
if ok.is_ok() { dirty = false; }
}
}
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn join_err(e: tokio::task::JoinError) -> MemoryError {
MemoryError::Io(std::io::Error::new(std::io::ErrorKind::Other, e))
}
fn channel_gone() -> MemoryError {
MemoryError::Io(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"background writer task gone",
))
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn make_config(dir: &tempfile::TempDir) -> MemoryConfig {
let mut c = MemoryConfig::new(dir.path().join("async_test.h5"), "async-agent", 4);
c.wal_enabled = true;
c
}
fn fast_async_config() -> AsyncConfig {
AsyncConfig {
flush_interval: Duration::from_millis(100),
flush_threshold: 50,
channel_capacity: 64,
}
}
fn make_entry(chunk: &str, embedding: &[f32]) -> MemoryEntry {
MemoryEntry {
chunk: chunk.to_string(),
embedding: embedding.to_vec(),
source_channel: "test".to_string(),
timestamp: 1000000.0,
session_id: "session-1".to_string(),
tags: "".to_string(),
}
}
#[tokio::test]
async fn create_and_count() {
let dir = tempfile::TempDir::new().unwrap();
let config = make_config(&dir);
let mem = AsyncHDF5Memory::create_with(config, fast_async_config())
.await
.unwrap();
assert_eq!(mem.count().await, 0);
assert_eq!(mem.count_active().await, 0);
mem.shutdown().await.unwrap();
}
#[tokio::test]
async fn save_and_search() {
let dir = tempfile::TempDir::new().unwrap();
let config = make_config(&dir);
let mem = AsyncHDF5Memory::create_with(config, fast_async_config())
.await
.unwrap();
let idx = mem
.save(make_entry("hello async world", &[1.0, 0.0, 0.0, 0.0]))
.await
.unwrap();
assert_eq!(idx, 0);
assert_eq!(mem.count().await, 1);
let results = mem
.hybrid_search(vec![1.0, 0.0, 0.0, 0.0], String::new(), 1.0, 0.0, 5)
.await;
assert!(!results.is_empty());
assert_eq!(results[0].index, 0);
mem.shutdown().await.unwrap();
}
#[tokio::test]
async fn save_batch_async() {
let dir = tempfile::TempDir::new().unwrap();
let config = make_config(&dir);
let mem = AsyncHDF5Memory::create_with(config, fast_async_config())
.await
.unwrap();
let entries = vec![
make_entry("a", &[1.0, 0.0, 0.0, 0.0]),
make_entry("b", &[0.0, 1.0, 0.0, 0.0]),
make_entry("c", &[0.0, 0.0, 1.0, 0.0]),
];
let indices = mem.save_batch(entries).await.unwrap();
assert_eq!(indices, vec![0, 1, 2]);
assert_eq!(mem.count().await, 3);
mem.shutdown().await.unwrap();
}
#[tokio::test]
async fn delete_and_compact() {
let dir = tempfile::TempDir::new().unwrap();
let mut config = make_config(&dir);
config.compact_threshold = 0.0;
let mem = AsyncHDF5Memory::create_with(config, fast_async_config())
.await
.unwrap();
mem.save(make_entry("a", &[1.0, 0.0, 0.0, 0.0]))
.await
.unwrap();
mem.save(make_entry("b", &[0.0, 1.0, 0.0, 0.0]))
.await
.unwrap();
mem.save(make_entry("c", &[0.0, 0.0, 1.0, 0.0]))
.await
.unwrap();
mem.delete(1).await.unwrap();
assert_eq!(mem.count_active().await, 2);
let removed = mem.compact().await.unwrap();
assert_eq!(removed, 1);
assert_eq!(mem.count().await, 2);
mem.shutdown().await.unwrap();
}
#[tokio::test]
async fn flush_and_reopen() {
let dir = tempfile::TempDir::new().unwrap();
let config = make_config(&dir);
let path = config.path.clone();
{
let mem = AsyncHDF5Memory::create_with(config, fast_async_config())
.await
.unwrap();
mem.save(make_entry("persist me", &[1.0, 0.0, 0.0, 0.0]))
.await
.unwrap();
mem.shutdown().await.unwrap();
}
let mem = AsyncHDF5Memory::open_with(&path, fast_async_config())
.await
.unwrap();
assert_eq!(mem.count().await, 1);
let chunk = mem.get_chunk(0).await;
assert_eq!(chunk.as_deref(), Some("persist me"));
mem.shutdown().await.unwrap();
}
#[tokio::test]
async fn session_tracking_async() {
let dir = tempfile::TempDir::new().unwrap();
let config = make_config(&dir);
let mem = AsyncHDF5Memory::create_with(config, fast_async_config())
.await
.unwrap();
mem.add_session(
"s1".into(),
0,
5,
"discord".into(),
"talked about rust".into(),
)
.await
.unwrap();
let summary = mem.get_session_summary("s1".into()).await.unwrap();
assert_eq!(summary.as_deref(), Some("talked about rust"));
mem.shutdown().await.unwrap();
}
#[tokio::test]
async fn knowledge_graph_async() {
let dir = tempfile::TempDir::new().unwrap();
let config = make_config(&dir);
let mem = AsyncHDF5Memory::create_with(config, fast_async_config())
.await
.unwrap();
let id1 = mem
.add_entity("Rust".into(), "language".into(), -1)
.await
.unwrap();
let id2 = mem
.add_entity("HDF5".into(), "format".into(), -1)
.await
.unwrap();
mem.add_relation(id1, id2, "uses".into(), 1.0)
.await
.unwrap();
mem.add_entity_alias("rustlang".into(), id1 as i64)
.await
.unwrap();
mem.shutdown().await.unwrap();
}
#[tokio::test]
async fn tick_session_async() {
let dir = tempfile::TempDir::new().unwrap();
let config = make_config(&dir);
let mem = AsyncHDF5Memory::create_with(config, fast_async_config())
.await
.unwrap();
mem.save(make_entry("decay test", &[1.0, 0.0, 0.0, 0.0]))
.await
.unwrap();
mem.tick_session().await.unwrap();
mem.shutdown().await.unwrap();
}
#[tokio::test]
async fn snapshot_async() {
let dir = tempfile::TempDir::new().unwrap();
let config = make_config(&dir);
let mem = AsyncHDF5Memory::create_with(config, fast_async_config())
.await
.unwrap();
mem.save(make_entry("snap", &[1.0, 0.0, 0.0, 0.0]))
.await
.unwrap();
let snap_dest = dir.path().join("snapshot.h5");
let snap_path = mem.snapshot(snap_dest).await.unwrap();
assert!(snap_path.exists());
mem.shutdown().await.unwrap();
}
#[tokio::test]
async fn concurrent_saves() {
let dir = tempfile::TempDir::new().unwrap();
let config = make_config(&dir);
let mem = Arc::new(
AsyncHDF5Memory::create_with(config, fast_async_config())
.await
.unwrap(),
);
let mut handles = Vec::new();
for i in 0..20 {
let m = Arc::clone(&mem);
handles.push(tokio::spawn(async move {
m.save(make_entry(
&format!("concurrent-{i}"),
&[i as f32, 0.0, 0.0, 0.0],
))
.await
.unwrap()
}));
}
for h in handles {
h.await.unwrap();
}
assert_eq!(mem.count().await, 20);
mem.shutdown().await.unwrap();
}
#[tokio::test]
async fn periodic_auto_flush() {
let dir = tempfile::TempDir::new().unwrap();
let config = make_config(&dir);
let path = config.path.clone();
let async_config = AsyncConfig {
flush_interval: Duration::from_millis(50),
flush_threshold: 0, // disable threshold
channel_capacity: 64,
};
let mem = AsyncHDF5Memory::create_with(config, async_config)
.await
.unwrap();
mem.save(make_entry("auto-flush", &[1.0, 0.0, 0.0, 0.0]))
.await
.unwrap();
// Wait for the periodic flush to fire
tokio::time::sleep(Duration::from_millis(150)).await;
// Verify data is on disk by reopening without explicit flush
drop(mem);
let mem2 = AsyncHDF5Memory::open(&path).await.unwrap();
assert_eq!(mem2.count().await, 1);
mem2.shutdown().await.unwrap();
}
#[tokio::test]
async fn threshold_flush() {
let dir = tempfile::TempDir::new().unwrap();
let mut config = make_config(&dir);
config.wal_max_entries = 1000; // high so sync auto-merge doesn't trigger
let path = config.path.clone();
let async_config = AsyncConfig {
flush_interval: Duration::ZERO, // disable periodic
flush_threshold: 5,
channel_capacity: 64,
};
let mem = AsyncHDF5Memory::create_with(config, async_config)
.await
.unwrap();
// Save enough entries to cross the threshold
for i in 0..6 {
mem.save(make_entry(
&format!("thresh-{i}"),
&[i as f32, 0.0, 0.0, 0.0],
))
.await
.unwrap();
}
// Give background task a moment to process the threshold flush
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(mem.count().await, 6);
mem.shutdown().await.unwrap();
// Verify persistence
let mem2 = AsyncHDF5Memory::open(&path).await.unwrap();
assert_eq!(mem2.count().await, 6);
mem2.shutdown().await.unwrap();
}
#[tokio::test]
async fn dirty_flag_skips_noop_flush() {
let dir = tempfile::TempDir::new().unwrap();
let config = make_config(&dir);
let mem = AsyncHDF5Memory::create_with(config, fast_async_config())
.await
.unwrap();
// Flush with nothing dirty — should be instant no-op
mem.flush().await.unwrap();
mem.flush().await.unwrap();
mem.flush().await.unwrap();
// Save something, flush, then flush again (second should be no-op)
mem.save(make_entry("dirty", &[1.0, 0.0, 0.0, 0.0]))
.await
.unwrap();
mem.flush().await.unwrap();
mem.flush().await.unwrap(); // no-op
mem.shutdown().await.unwrap();
}
#[tokio::test]
async fn default_config_works() {
let dir = tempfile::TempDir::new().unwrap();
let config = make_config(&dir);
// Use default AsyncConfig (no _with variant)
let mem = AsyncHDF5Memory::create(config).await.unwrap();
mem.save(make_entry("default", &[1.0, 0.0, 0.0, 0.0]))
.await
.unwrap();
assert_eq!(mem.count().await, 1);
mem.shutdown().await.unwrap();
}
}