How ai-memory Handles Read Operations Under Its Single-Writer Architecture
ai-memory enables high-throughput concurrent reads through a dedicated reader pool while maintaining strict write serialization via SQLite's WAL mode, ensuring readers never block the single writer.
The akitaonrails/ai-memory repository implements a specialized memory storage system that enforces a single-writer architecture to prevent race conditions and corruption. While writes are strictly serialized through a dedicated actor, the system achieves read scalability by leveraging SQLite's Write-Ahead Log (WAL) mode combined with a managed pool of read-only connections.
The Single-Writer Constraint and Read Concurrency
All mutations in ai-memory funnel through a single WriterHandle actor that maintains the only mutable database connection. This design eliminates write-write conflicts by guaranteeing serialized access to the SQLite file at <data_dir>/db/memory.sqlite.
Read operations, however, bypass the writer entirely. Instead, they execute against a separate ReaderPool that maintains multiple read-only connections. This separation ensures that long-running queries cannot stall write operations, and bulk writes do not freeze the read path.
SQLite WAL Mode Enables Non-Blocking Reads
The database is explicitly opened in WAL (Write-Ahead Log) mode during initialization. In crates/ai-memory-store/src/lib.rs (lines 24-30), the Store::open method configures the connection with PRAGMA journal_mode = WAL.
WAL mode provides the critical capability for the single-writer architecture: it allows any number of readers to access the database while the writer holds an exclusive lock only on the WAL file itself. Consequently, reads never block writes, and writes never block reads, though the writer maintains exclusive access to mutation operations.
The ReaderPool Architecture
To prevent file-descriptor exhaustion, ai-memory bounds concurrent reads through READER_POOL_SOFT_CAP = 4. The pool initialization occurs in Store::open at lines 53-56 of lib.rs, where ReaderPool::new creates the connection pool with this soft limit.
When a read operation completes and returns its connection to the pool, the system checks current capacity. If the pool already contains four connections, the returned connection is dropped rather than retained. This lightweight eviction strategy keeps resource usage predictable without complex connection management logic.
Read-Only API Methods
All read operations are implemented in crates/ai-memory-store/src/reader.rs through the ReaderPool struct. The primary query methods include:
ReaderPool::search_pages
Executes multi-stream searches combining FTS5 full-text, vector similarity, graph traversal, and entity matching. It returns ranked hit results with relevance scores.
ReaderPool::page_body_by_ids
Retrieves the complete markdown body and metadata for specific page paths. This powers exact-path lookups when the full content of a known document is required.
ReaderPool::status_counts
Aggregates global statistics including total pages, active sessions, and observation counts for dashboard or monitoring purposes.
Each method operates against an immutable snapshot of the database captured at the moment the connection is acquired from the pool.
Snapshot Isolation and TTL Handling
Reader connections provide snapshot isolation—they see the database state as it existed when the connection was opened, unaffected by concurrent writes. This guarantees consistent query results even if the writer commits new data mid-query.
The system implements time-to-live (TTL) expiration through the helper now_us() (defined in reader.rs lines 54-58), which generates a wall-clock timestamp for each query. The not_expired function (lines 50-53) appends TTL checks to search queries to filter out expired entries. However, for exact-path reads via page_body_by_ids, expired pages are still returned; callers must inspect the expired boolean flag in the returned PageMeta struct to determine record status.
Practical Implementation Example
The following Rust example demonstrates opening the store and executing various read operations against the reader pool:
use ai_memory_store::{Store, ops::PagesMode};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Open the store (creates DB, spawns writer, builds read pool)
let data_dir = std::path::Path::new("/tmp/ai-memory-data");
let store = Store::open(data_dir)?;
// Example 1: Get the full body of a specific page (read-only)
let ws = store.writer.get_or_create_workspace("default").await?;
let proj = store.writer.get_or_create_project(ws, "my-app", None).await?;
let page = store
.reader
.page_body_by_ids(ws, proj, "notes/todo.md")
.await?;
println!("Title: {}", page.title);
println!("Body:\n{}", page.body);
// Example 2: Perform a search across the current project
let hits = store
.reader
.search_pages(
ws,
proj,
"vector embeddings".into(),
10, // limit
None, // no explicit scopes => current project
)
.await?;
for hit in hits {
println!("{} – {} (rank {:.3})", hit.path, hit.title, hit.rank);
}
// Example 3: Retrieve global status counts (pages, sessions, observations)
let counts = store.reader.status_counts().await?;
println!(
"Pages: {}, Sessions: {}, Observations: {}",
counts.pages_latest, counts.sessions, counts.observations
);
Ok(())
}
Summary
- Single-writer guarantee: All mutations route through
WriterHandleinwriter.rs, ensuring serialized write access to the SQLite database. - WAL mode concurrency: Configuration in
lib.rs(lines 24-30) enables simultaneous readers and writers without lock contention. - Bounded resource usage: The
ReaderPoolenforces a soft cap of four connections (configured inlib.rslines 53-56) to limit file descriptor consumption. - Snapshot reads: Each reader connection operates against an immutable database snapshot, providing consistent query results.
- Selective TTL enforcement: The
not_expiredfunction andnow_us()helper filter expired records for searches, while exact-path reads expose theexpiredflag inPageMetafor caller inspection.
Frequently Asked Questions
Can read operations block write operations in ai-memory?
No. Because the system uses SQLite's WAL mode as implemented in Store::open, readers hold shared locks on the main database file while the writer exclusively locks only the WAL file. This architecture ensures that read operations never block the single writer, and writes never stall reads.
How many concurrent read connections does ai-memory support?
By default, ai-memory maintains a soft cap of four concurrent read connections defined by READER_POOL_SOFT_CAP. When this limit is reached, subsequent connection returns are dropped rather than pooled, preventing file-descriptor exhaustion while allowing temporary bursts beyond the cap.
How does ai-memory handle time-to-live (TTL) checks during reads?
For search operations, ai-memory appends TTL filters using the not_expired function in reader.rs combined with the now_us() timestamp helper. However, exact-path reads via page_body_by_ids deliberately return expired pages and rely on the caller to check the expired boolean field in the returned PageMeta struct.
What happens if the reader pool exceeds its capacity?
When a read operation completes and returns its connection to a full pool (at the soft cap of four), the connection is closed and dropped immediately. This lightweight eviction strategy, implemented in the pool management logic referenced in lib.rs lines 53-56, ensures the system remains responsive under load without complex backpressure mechanisms.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →