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 .db file (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 .db file. 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/-shm siblings next to the .db.
  • Point database.path in your engrava.yaml at the mounted volume — that’s the setting from_config reads. (ENGRAVA_DB is a CLI-only fallback for the engrava --db flag; it does not configure from_config, so application code should set database.path, not rely on ENGRAVA_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 full EngravaConfig (not just config.services) so each replica’s store gets the same journal, hygiene policy, TTL, hooks and dreaming settings a single-database from_config open 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 raises StaleDataError instead 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 EngravaManager when you need independent writers. Build it from the full EngravaConfig (not just config.services) to apply the rest of engrava.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_config opens and owns the connection. Leaving the async with block closes it for you; equivalently, call await 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 connection

    A bare async with aiosqlite.connect(...) as conn: looks like a shortcut for this, but aiosqlite.Connection.__aexit__ is an unconditional await close() — if the block above raised, a close failure there replaces the real error instead of the caller ever seeing it. The explicit try/except/else shape 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 Exception does not catch asyncio.CancelledError, so a cancellation there can still replace the original error, and can stop conn.close() from running at all if it lands on the store.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 second close() reuses that same physical-close task and waits up to its own bound. Unless that call is itself cancelled, it raises ConnectionQuarantinedError if 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.