Deployment
How to run Engrava in production: opening the store, the database files on disk, multi-worker setups, and shutting down cleanly. Engrava is an in-process library; “deployment” means how your process opens and owns the database.
For the concurrency model behind these recommendations, see Concurrency. For backups, see Backup & Recovery.
One store per process, opened at startup
Open the store once at process startup and reuse it for the process’s
lifetime. from_config opens and owns the connection (it applies the schema
and the right PRAGMAs), so use it as an async context manager that spans your
app’s life:
from engrava import SqliteEngravaCore
async def main() -> None:
async with await SqliteEngravaCore.from_config("engrava.yaml") as store:
# Hold this store for the lifetime of the process / app.
await run_app(store)
- Do not open a new store per request. Opening a store applies schema checks and PRAGMAs; doing it per request is wasteful and multiplies open handles to the same file.
- Do not share one store across event loops. The store holds
asyncio.Locks that bind to one loop the first time they have to wait, and reject a waiter from any other loop — see Concurrency. One store belongs to one running loop. - Share that one store across the tasks in the loop. You do not need a pool of stores for in-process concurrency. engrava’s own guarded writes on that store are serialised by a task-reentrant write lock. Two tasks editing the same field of the same row leave only the later one’s value in place. What this does not cover includes a read-modify-write your own code spans across two separate calls. See Concurrency for the exact guarantees and the idioms that close that gap.
The database files on disk
In WAL mode (the default for file databases opened via from_config), SQLite
keeps three files side by side:
| File | Purpose |
|---|---|
engrava.db |
The main database. |
engrava.db-wal |
The write-ahead log — uncommitted and recently-committed data lives here until checkpointed. |
engrava.db-shm |
Shared-memory index for the WAL. |
Operational consequences:
- Use a WAL-safe backup method — copying only the
.dbfile (or copying the three files non-atomically while writes continue) can capture inconsistent state. See Backup & Recovery for the live-vs-stopped options. - Put them on a real local filesystem. SQLite + WAL on networked filesystems (NFS, some container overlay mounts) can corrupt or fail locking. Use a local disk or a properly-configured volume.
- Permissions. The process needs read/write on the directory (SQLite creates
and deletes
-wal/-shm), not just the.dbfile. Lock the directory down to the service user.
Containers
- Mount a volume for the database directory, not just the file — SQLite needs
to create the
-wal/-shmsiblings next to the.db. - Point
database.pathin yourengrava.yamlat the mounted volume — that’s the settingfrom_configreads. (ENGRAVA_DBis a CLI-only fallback for theengrava --dbflag; it does not configurefrom_config, so application code should setdatabase.path, not rely onENGRAVA_DB.) - One container instance = one writer. If you scale to multiple replicas, they
must not all write the same database file — that is unsupported, not merely
contended (see
multiple stores, one file).
Either run a single writer replica, or give each replica its own database via
EngravaManager, built from the fullEngravaConfig(not justconfig.services) so each replica’s store gets the same journal, hygiene policy, TTL, hooks and dreaming settings a single-databasefrom_configopen would.
Multiple workers
Engrava supports one writing store per database file. For multi-worker app servers (Gunicorn/Uvicorn workers, etc.):
- WAL lets many readers and one writer coexist, across processes as well as within one. Reads issued through one store instance still serialise on that store’s single connection (see Concurrency); it is separate store instances, including across processes, that read concurrently.
- Route every write to one process. Two workers writing one file is not a
contention trade-off you can tune with
busy_timeout. A guarded update whose read-to-write window another writer’s guarded write lands in raisesStaleDataErrorinstead of silently overwriting that write (the write that landed first succeeds; the stale one raises). Except when caller code has already opened a transaction directly on the connection, deduplicated writes (create_thought(deduplicate=True),get_or_create(),upsert_by_hash(),bulk_store(deduplicate=True)) no longer duplicate across stores either. Other cross-store races remain, so one writer per file is still the contract. See Concurrency → Multiple stores, one database file. - Per-tenant or per-worker isolation: give each its own database file via
EngravaManagerwhen you need independent writers. Build it from the fullEngravaConfig(not justconfig.services) to apply the rest ofengrava.yaml(the journal, hygiene policy, TTL, hooks and dreaming) to every service it builds.
Graceful shutdown
Who closes the connection depends on how you opened the store — because the store only closes a connection it owns:
-
from_config(owned connection).from_configopens and owns the connection. Leaving theasync withblock closes it for you; equivalently, callawait store.close(), which closes and releases the owned connection cleanly. (It does not issue an explicit WAL checkpoint — that is a backup/maintenance step,PRAGMA wal_checkpoint(TRUNCATE), covered in Backup & Recovery.)async with await SqliteEngravaCore.from_config("engrava.yaml") as store: ... # connection closed here # or, if you hold the store yourself: await store.close() -
Manual
SqliteEngravaCore(conn)(caller-managed connection). For a caller-owned connection,store.close()does not close the connection — you must close the connection you created — but if access tracking is enabled it may first flush and commit buffered access telemetry:import aiosqlite from engrava import SqliteEngravaCore async def run_with_manual_connection() -> None: conn = await aiosqlite.connect("engrava.db") conn.row_factory = aiosqlite.Row store = SqliteEngravaCore(conn) try: ... except BaseException: # A failure above is what the caller needs to see. An ordinary # exception from this cleanup is logged here instead of raised, so # the original error below still propagates; a cancellation # landing in this block is not covered the same way (see the note # after this example). try: await store.close() # flushes/commits buffered access telemetry await conn.close() except Exception as exc: # noqa: BLE001 - logged, not raised print(f"warning: failed to close the database connection: {exc}") raise else: await store.close() # flushes/commits buffered access telemetry; does not close conn await conn.close() # the caller owns and closes the connectionA bare
async with aiosqlite.connect(...) as conn:looks like a shortcut for this, butaiosqlite.Connection.__aexit__is an unconditionalawait close()— if the block above raised, a close failure there replaces the real error instead of the caller ever seeing it. The explicittry/except/elseshape above reports an ordinary close failure instead of letting it replace the real error. It does not cover a cancellation landing during that cleanup:except Exceptiondoes not catchasyncio.CancelledError, so a cancellation there can still replace the original error, and can stopconn.close()from running at all if it lands on thestore.close()call just above it.
Wire whichever applies into your framework’s shutdown hook (e.g. FastAPI
lifespan, a signal handler) so an interrupted process still closes cleanly.
If the worker never answers
store.close() (the from_config path) waits for the background worker to
finish whatever it was doing before it can close the connection — including
flushing buffered access-tracking data first, when that feature is on. Those
are two separate waits, each bounded on its own by close_timeout_seconds
(30 seconds by default; tune it on from_config() or the constructor) —
not one shared budget, and not the same consequence if either one
expires:
- The flush wait’s bound expiring is an ordinary flush failure — the
buffered access-tracking counts are best-effort telemetry that self-heals
from a lost flush — so it quarantines nothing by itself.
close()still goes on to attempt the physical close afterwards. - The physical-close wait’s bound expiring has a lasting consequence: the
store is left permanently unusable — every further operation that reaches
the connection raises
ConnectionQuarantinedError, because the worker’s last operation never reported and the connection’s true state can no longer be trusted. A secondclose()reuses that same physical-close task and waits up to its own bound. Unless that call is itself cancelled, it raisesConnectionQuarantinedErrorif the task is still pending when the bound expires, and returns normally if the task had already finished or finishes during the wait. A cancellation of the second call is held until that bounded wait ends, and then propagates.
A worker that never answers at all can therefore make one close() call on
an owned connection wait up to twice close_timeout_seconds (60 seconds at
the default) before returning. Either way, a caller closing a store whose
worker has stopped responding still gets control back instead of hanging
forever — just not within a single close_timeout_seconds window.
For the exhaustive package reference, see the OSS deployment.md docs.