# How ai-memory Handles Read Operations Under Its Single-Writer Architecture

> Discover how ai-memory's single-writer architecture achieves high-throughput concurrent reads using a dedicated reader pool and SQLite WAL mode. Learn how writes remain serialized without blocking reads.

- Repository: [Fabio Akita/ai-memory](https://github.com/akitaonrails/ai-memory)
- Tags: internals
- Published: 2026-09-06

---

**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`](https://github.com/akitaonrails/ai-memory/blob/main/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`](https://github.com/akitaonrails/ai-memory/blob/main/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`](https://github.com/akitaonrails/ai-memory/blob/main/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`](https://github.com/akitaonrails/ai-memory/blob/main/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:

```rust
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 `WriterHandle` in [`writer.rs`](https://github.com/akitaonrails/ai-memory/blob/main/writer.rs), ensuring serialized write access to the SQLite database.
- **WAL mode concurrency**: Configuration in [`lib.rs`](https://github.com/akitaonrails/ai-memory/blob/main/lib.rs) (lines 24-30) enables simultaneous readers and writers without lock contention.
- **Bounded resource usage**: The `ReaderPool` enforces a soft cap of four connections (configured in [`lib.rs`](https://github.com/akitaonrails/ai-memory/blob/main/lib.rs) lines 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_expired` function and `now_us()` helper filter expired records for searches, while exact-path reads expose the `expired` flag in `PageMeta` for 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`](https://github.com/akitaonrails/ai-memory/blob/main/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`](https://github.com/akitaonrails/ai-memory/blob/main/lib.rs) lines 53-56, ensures the system remains responsive under load without complex backpressure mechanisms.