Concurrency
Engrava is built on SQLite, so it inherits SQLite’s concurrency model: many concurrent readers, one writer at a time. This page states what a single store guarantees concurrent callers, what it deliberately does not guarantee, and which writer topologies are supported.
WAL: many readers, one writer
File databases opened via from_config use WAL (write-ahead logging) mode
by default — it is the database.wal_mode setting. Setting it to false
skips Engrava’s WAL pragma on that call; a newly-created database then normally
uses SQLite’s rollback journal, but an existing database that was already in WAL
mode retains it, since the mode is stored in the file itself. Outside WAL, a
writer and an open read can block each other, so the first guarantee below is
WAL-specific; the second — only one writer at a time — is an ordinary SQLite
property and holds either way. Under WAL:
- Readers don’t block the writer and the writer doesn’t block readers. A read sees a consistent snapshot while a write is in progress.
- There is still only one writer at a time. SQLite permits one writer at a
time; a competing Engrava-owned connection waits up to five seconds — the
configured busy timeout — and then raises
OperationalErrorif the lock is still held, unless the call is one of the guarded write paths that convert that intoWriteContentionErrorinstead (see Busy timeout).
This is ideal for read-heavy agent-memory workloads: retrieval (the hot path) avoids per-result database writes, but calls sharing one store serialize their SQLite statements, and with access tracking enabled they can also buffer telemetry for a later write; ordinary writes are comparatively infrequent.
Many async tasks, one store
Share one store instance across the asyncio tasks in your event loop. You do
not need a connection pool or multiple stores for in-process concurrency.
Every guarded write method on a store instance shares one in-process,
task-reentrant write lock for its own write: create_thought,
get_or_create, upsert_by_hash, bulk_store, update_thought,
restore_thought, delete_thought, create_edge, update_edge,
delete_edge, create_action, update_action, store_embedding,
record_access, cleanup_expired, run_hygiene, and
suspend_auto_commit(). Once a call holds the lock, its own guarded read and
write run as one critical section: a different task’s call to any of
these blocks until the one in progress has committed (or rolled back) rather
than landing in the middle of it. run_hygiene() is the exception on the
read side — it collects and scores its candidates before acquiring the
lock, and only its archive/GC phase runs under it. The lock is
task-reentrant, not call-reentrant — the same task may freely nest
calls, notably a write issued from inside its own suspend_auto_commit()
window, but a genuinely different task waits.
get_or_create and upsert_by_hash still have a real gap, on a miss —
when the call is not itself nested inside the caller’s own
suspend_auto_commit() window. Each runs an exploratory probe under the
lock; when that probe finds nothing, the call releases the lock, runs the
pre-insert preparation seam with nothing this call itself took still held,
and only then reacquires the lock for a decisive probe before it inserts. A
different task’s write can start and finish inside that gap. The decisive
probe exists to catch exactly that: it is re-run before the insert, so a
write that landed in the gap turns this call’s outcome into a confirmation
bump instead of a duplicate row. Nested inside the caller’s own open window,
none of that applies: the outer window’s hold of the lock never actually
drops, so it stays held — blocking every other task — for the whole seam
call too.
What this does not do: it does not make a read-modify-write your own
code performs across two separate calls atomic —
thought = await store.get_thought(id) followed later by
await store.update_thought(id, field=thought.field + 1) is still two
independent critical sections with a gap between them (see
the safe idioms below). It also does not reach a second
store on the same database file — see
Multiple stores, one database file — and
it does not help across event loops, since the lock itself is bound to one
loop (below).
What is guaranteed
- Statements do not run concurrently. aiosqlite runs the actual SQLite calls on a dedicated background thread and marshals every call onto it, so two tasks’ statements are serialised rather than executed at once — and SQLite applies each statement atomically, so no query observes a half-written row.
- An edit writes only the columns it owns.
update_thoughtwrites the fields the call gave a new value to, plus theupdated_atstamp and therevisionbump — not the whole record. A field another task changed since this call read the row is preserved rather than rolled back. That includes the fields engrava maintains for you:access_count/last_accessed_atfromrecord_access(), andconfirmation_count. - A deduplication sighting counts.
create_thought(deduplicate=True)andget_or_create()bumpconfirmation_countrelative to what is stored (confirmation_count + 1, evaluated by SQLite), so a bump made by another writer since the row was read is added to, never overwritten. - Deduplication is serialised against itself, and now against every other
guarded write on this instance too.
create_thought(deduplicate=True),get_or_create()andupsert_by_hash()share an internalasyncio.Lockcovering the whole “probe the content hash, then insert or bump” window, and that window is also held under the write lock above, so an unrelated guarded write from a different task cannot land inside it either. Except when caller code has already opened a transaction directly on the connection, these writes no longer duplicate across stores either — see Busy timeout. suspend_auto_commit()is a real, exclusive window. It holds the write lock for its entire duration, so a different task’s call to any guarded write path now waits for the window to close instead of joining its transaction. A nested call on the same task shares the outer transaction. Only the outermost call attempts the final commit or rollback; an already-quarantined connection is not rolled back, and cancellation during commit leaves the outcome indeterminate (see Error handling).
What is not guaranteed
- A caller-level read-modify-write across two separate calls is still not
atomic. The lock above covers one call’s own internal read and write; it
does not, and cannot, cover a gap between two calls your own code makes
(
get_thoughtthen, later,update_thought). See the safe idioms for the caller-owned-lock pattern that closes that gap. get_or_create/upsert_by_hashhave their own internal gap, on a miss. See above for what can land there and why the decisive probe exists.- Same-field edits still resolve last-write-wins. Two tasks legitimately editing the same field is not a bug the lock removes — it removes the silent corruption of a call’s own read or write being torn by another call landing mid-operation, not the ordinary fact that two edits to the same field can only leave one final value. Whichever call’s critical section runs second determines the field’s value, deterministically and without error.
StaleDataErrordoes not detect “someone else wrote it,” in general. See Optimistic concurrency below for what the guard actually rejects.- A same-task nested call is not blocked by its own lock. A
caller-owned hook or callback that itself issues a write, invoked
synchronously from inside another operation’s read-modify-write span (not a
second
asynciotask — a genuinely nested call on the same task), still runs freely: the lock is task-reentrant by design, so it does not — and structurally cannot — protect against this shape. - A second store on the same database file is unaffected. See Multiple stores, one database file.
- One store belongs to one event loop. Not because of the connection —
aiosqlite creates each operation’s future on the calling loop, so a plain
read from a second loop works. It is the store’s own synchronisation: the
write lock, the dedup lock, and the audit journal’s per-connection lock are
all
asyncio-bound locks that bind to the first loop that has to wait on one and then raiseRuntimeErrorfor a waiter from any other loop. An uncontended lock never binds, so a store shared across loops can look healthy right up to the first time two callers actually contend. Use one store per loop; within that loop, share it freely. (See Known Limitations.)
A deadlock this store cannot resolve raises, it does not hang
The write lock is task-reentrant, so a task that opened a
suspend_auto_commit() window may issue writes of its own inside it freely —
but a different task cannot reuse that grant. If the window’s own task
spawns a task and awaits it before the window closes, neither can make
progress: the spawned task can never get the lock the window’s task holds,
and the window’s task can never release it while still awaiting the spawned
task. This is already out of the documented contract (drive every write
inside an open window from the one task that opened it), so past a bound, the
acquiring call raises WriteLockTimeoutError instead of waiting forever —
which also ends the deadlock, since the spawned task’s failure lets whatever
awaited it unwind.
That bound has to clear a legitimate case, not just ordinary contention:
bulk_store’s batch embedding call runs inside its own suspend_auto_commit()
window, holding the lock for a genuine network round trip to an embedding
provider that can take minutes for a large batch. The default —
write_lock_acquire_timeout_seconds=600.0 — is sized with headroom over the
shipped default provider’s own worst-case retry-exhaustion time; raise it for
a slower provider or larger batches via
SqliteEngravaCore(..., write_lock_acquire_timeout_seconds=...) or the
equivalent from_config(...) argument.
The dedup lock’s own re-entry failure is narrower and has no legitimate
case at all. Unlike the write lock, the in-process lock guarding the dedup
probe-and-insert window (create_thought(deduplicate=True), get_or_create,
upsert_by_hash) is not task-reentrant, because it has no legitimate
reentrant use: a second acquisition by the same task — the concrete shape is
upsert_by_hash()’s hit branch calling an overridden update_thought that
calls back into a dedup entry point on the same task — raises
DedupLockReentryError immediately instead of blocking on itself forever.
The safe idioms
-
Hold one store for the process lifetime and share it across tasks — see Deployment.
-
Let one task own a row, or partition edits so that no two tasks write the same field of the same row. This needs no locking at all.
-
Or serialise the read-modify-write yourself when tasks genuinely compete for one field:
# engrava does not make read-modify-write atomic; a caller-owned lock does. # edit_lock is one asyncio.Lock shared by every task that writes this way. async with edit_lock: thought = await store.get_thought(thought_id) if thought is not None: await store.update_thought( thought_id, confidence=min(1.0, (thought.confidence or 0.0) + 0.1), )This closes the window within one process only — see Multiple stores, one database file.
-
suspend_auto_commit()no longer needs this discipline enforced by convention — a different task’s write now simply waits rather than racing in. It remains true that only the task that opened the window (or a write nested inside it) should be the one issuing writes inside it; interleaving unrelated writes through a long-running window just serialises them for no benefit.
Optimistic concurrency and StaleDataError
update_thought, restore_thought, update_edge and update_action each
carry a revision guard, checked and incremented in the same atomic
UPDATE; a changed upsert_by_hash hit goes through update_thought
rather than a guard of its own. Every
core row (thought, edge, action) has a revision column. No caller
action arms it — the
engine bumps revision on every guarded write to a row, automatically.
update_edge and update_action carry this guard for the first time; before
0.7 neither could ever raise StaleDataError.
StaleDataError means the guarded UPDATE matched no row. Two different
situations produce that, and the error does not tell them apart:
- the row’s
revisionis no longer the value this call read — another guarded write landed on the row in between, whatever field it touched; or - the row no longer exists — a competing writer deleted it between this
call’s read and its write. (
ThoughtNotFoundErrorcovers a row that was already missing when the call started, not one that vanished mid-call.)
The consequences are worth stating plainly:
- An ordinary concurrent edit now raises
StaleDataError, whatever field it touched, the moment it lands on a row between this call’s own read and write. The rejected update then writes nothing at all — no field of it reaches storage. Recover by re-reading the record and recomputing the change; do not replay the originalchanges. - In the ordinary case — two genuinely concurrent tasks on this store
instance — this almost never fires, because the write lock above already
serialises them end to end: the second call’s own read happens strictly
after the first call’s write has committed, so its guard is captured
against the post-first-call
revision. - What it does catch is exactly what the write lock cannot reach: a
same-task nested write issued from inside a caller-owned hook (the lock is
task-reentrant, so it does not block this), and a write from a second
store on the same database file — a second connection in this process, or
a second process. Both used to be silently lost; both now raise
StaleDataErrorbefore anything is written. See Multiple stores, one database file for the cross-connection case in full.
StaleDataError is still not a general semantic-conflict detector: it is
“this write found no row to apply to”. Do not read its absence as proof
nobody else wrote. If your application needs caller-level staleness
detection, keep your own version value in metadata and compare it
yourself, or serialise the edits with the lock idiom above.
Busy timeout
When a connection can’t immediately get the lock it needs (another writer holds
it), SQLite waits up to the busy timeout before giving up with
database is locked.
How the timeout is set depends on how the store opened its connection:
from_configandEngravaManager(engrava owns the connection) open it withPRAGMA busy_timeout=5000explicitly. When SQLite invokes the busy handler,busy_timeout=5000permits a wait of up to five seconds; a content-changingupdate_thought()can instead fail immediately during its FTS5-trigger lock upgrade. These paths also setPRAGMA synchronous=NORMAL, the documented-safe companion to WAL (durable across an application crash; only the most recent transactions are at risk on an OS crash or power loss).- The manual
SqliteEngravaCore(conn)constructor (you own the connection) changes none of these pragmas. The connection keeps whatever it was opened with; Python’ssqlite3/aiosqlitedefaultbusy_timeoutalready happens to be 5000 ms, and the defaultsynchronousisFULL.
For workloads with more write contention you can raise the timeout on your own
connection before handing it to the store, or after from_config via the store’s
connection:
import aiosqlite
from engrava import SqliteEngravaCore
conn = await aiosqlite.connect("engrava.db")
conn.row_factory = aiosqlite.Row
await conn.execute("PRAGMA busy_timeout = 15000") # wait up to 15s for a lock
store = SqliteEngravaCore(conn)
await store.ensure_schema()
A longer busy timeout trades latency-on-contention for fewer database is locked
errors; tune it to your write pattern.
Two kinds of write convert lock contention into WriteContentionError
instead of leaking a raw database is locked. create_thought(deduplicate=True),
get_or_create(), upsert_by_hash() and bulk_store(deduplicate=True) open
their “check existing, then insert or bump” window with BEGIN IMMEDIATE —
unless your own code already opened a transaction directly on the
connection — instead of an implicit deferred transaction, so lock contention surfaces at
the start of the window rather than only once the eventual write needs the
lock — closing the classic embedded-SQLite deadlock shape a deferred
transaction invites. The busy timeout above still applies to each attempt;
on top of it, the call retries up to three times total with a short backoff
and, only once that is exhausted, raises WriteContentionError. The
revision-guarded updates (update_thought, restore_thought,
update_edge, update_action) convert the same busy/locked failure on
their own single UPDATE, with no retry at this layer. Retrying either
kind outright is safe — the failed call wrote nothing.
Multiple stores, one database file
Engrava does not currently support more than one store writing the same database file — whether those stores are in two processes or are two connections inside one process.
That is a statement about engrava, not about SQLite. WAL (by default) and
PRAGMA busy_timeout=5000 are configured, and they do exactly what they say: the
file tolerates several connections, readers do not block the writer, and SQLite
serialises the writers so the database itself is never corrupted. In-process
locks stop at the store instance, but two of the three cross-connection gaps
this used to leave open are now closed at the database level instead:
-
An update landing in the window is now rejected, not silently discarded. No in-process lock can reach across a connection boundary, but the
revisionguard lives in the database itself: the guardedUPDATE’sWHERE revision = ?clause carries the value read before the write, so a second store’s committed bump in between makes it match no row, and the first store’s write raisesStaleDataErrorinstead of overwriting it. This is a correctness improvement, not a support statement — multiple writers on one file remain outside what this store is built and tested for. -
Except when caller code has already opened a transaction directly on the connection,
deduplicate=True,get_or_create(),upsert_by_hash()andbulk_store(deduplicate=True)no longer duplicate across stores. The probe-and-row window now opens withBEGIN IMMEDIATE(above). When Engrava can openBEGIN IMMEDIATEitself, exhaustion of its bounded retries raisesWriteContentionError. -
The audit journal’s sequence numbers can still collide. When journaling is enabled, appends are serialised by an
asyncio.Lockkeyed on the connection. A second store has a second connection and therefore a different lock — in another process, where no lock could be shared at all, but equally in this one. Two stores journaling the same database can race the journal’s monotonicsequence_number. The writer makes up to 5 total attempts (the initial attempt plus at most 4 retries) on the resultingUNIQUEcollision; if contention persists it raises:RuntimeError: Failed to append journal entry after 5 retries due to sequence contentionThat error is the loud symptom of this remaining topology.
Two things do survive a second connection, and they are worth knowing so the
rule is not read as broader than it is: reading (any number of processes may
read one file under WAL) and the relative confirmation_count bump described
above, which is evaluated by SQLite against the stored row rather than derived
from a prior read.
Making every read-modify-write path correct across connections needs a transaction-level mechanism engrava does not have yet. It is deferred, not ruled out; until it ships, treat one writer per database file as the contract. If you need multiple independent writers, give each its own database (next section).
Per-service isolation
EngravaManager runs one database file per named service, each with its own
connection and its own lock. This is the supported way to isolate writers (per
tenant, per worker, per logical partition):
from engrava import EngravaManager, load_config
config = load_config("engrava.yaml")
async with await EngravaManager.from_config(config) as mgr:
store_a = await mgr.get_store("tenant_a") # tenant_a.db
store_b = await mgr.get_store("tenant_b") # tenant_b.db
Passing the whole config (its services section must be set) gives every
service’s store the rest of engrava.yaml too: the journal, hygiene policy, TTL,
hooks and dreaming. from_config(config.services) still works and wires only
the PRAGMAs, the embedding provider and the vector backend; see
Configuration → Multi-Service Usage.
Because each service is a separate file, writes to tenant_a never contend with
writes to tenant_b, and each can be backed up or deleted independently. See the
scoping section
for when to choose per-service isolation over in-store filtering.
Summary
| Scenario | Supported? | Notes |
|---|---|---|
| Many async tasks, one store, one loop | ✅ | The normal case — share the store. The rows below qualify it. |
| Many readers (WAL) | ✅ | Readers never block the writer. |
| One writer at a time | ✅ | SQLite serialises writes. |
| Two tasks editing different fields of one row | ✅ | An update writes only the columns it owns, and the write lock serialises the two calls end to end. |
| A competing guarded write landing between one call’s own read and write | ✅ (rejected) | revision bumps on every guarded write automatically; a write that lands in that window makes the guard match no row, and the call raises StaleDataError rather than landing partially or silently. |
| Two tasks editing the same field of one row | ✅ | Still last write wins — the correct outcome for two genuine edits — but each call’s own read and write can no longer be corrupted by the other landing mid-operation. |
| Two tasks moving one row through its state machine | ✅ | Each transition is validated against the row the previous, now-completed call actually left — a forbidden composite move is rejected instead of silently landing. |
suspend_auto_commit() with another writer on that store |
✅ | The window holds the write lock for its duration; a different task’s write waits instead of joining the window’s transaction. |
| One store across multiple event loops | ❌ | The store’s locks bind to one loop; one store per loop. |
| Many processes reading the same file | ✅ | WAL supports concurrent readers. |
| Many stores/processes writing the same file | ❌ | Not supported — one writer per file; use EngravaManager. |
For the exhaustive package reference, see the OSS concurrency.md docs.