Skip to content

0053. SQLiteSnapshotStore Owns One Connection And Closes It

Every operation opened its own aiosqlite.connect(). Nothing could release the store, and ":memory:" silently did not work.

Status

Accepted.

ADR Impact

ADR Status
0037 Stands, and this is a new implementer of it. The ownership contract it states — close() releases only what the object itself created — is what makes the store's own connection closeable and would forbid closing a caller-injected one.
0036 Stands. SupportsClose is a third optional capability the store satisfies structurally, alongside SnapshotStore and SnapshotTypeInvalidation.
0039 Stands. The store applies the bundled sqlite snapshots DDL rather than carrying its own copy.

Context

SQLiteEventStore opens one connection lazily and keeps it — required for ":memory:", whose contents live only as long as the connection that created them — and implements close(). SQLiteSnapshotStore, written earlier, opened a connection per operation instead.

Two consequences followed. ":memory:" was accepted by the constructor and reported by database_path but could never work: each call saw a different, empty database, so saves vanished and every load missed. The guide documented this as a limitation.

The second is the expensive one. aiosqlite backs each connection with a non-daemon thread. A per-operation connection closes its thread on exit, so nothing leaked — but the store had no close(), no ownership of anything, and therefore no way for a caller to say "I am done with this". A downstream consumer took the only remaining option: thread a single store instance through every call site so a second is never constructed, and document at each site why the parameter exists. Three call sites and roughly thirty lines of justification existed to work around a missing method.

Decision

SQLiteSnapshotStore opens one connection lazily on first use and reuses it for its lifetime, guarding setup with double-checked locking on a dedicated _init_lock — without which two concurrent first-callers each open a connection, and the loser's thread leaks for the process lifetime. Every statement runs under a separate _lock, so no commit() lands part-way through another operation.

The store implements SupportsClose: close() releases the connection it opened, is idempotent, and a later operation reopens.

On first connect it applies the bundled sqlite snapshots schema, which is IF NOT EXISTS throughout, and sets busy_timeout — a snapshot store commonly shares a file with a SQLiteEventStore holding its own connection.

This mirrors SQLiteEventStore deliberately. Two SQLite adapters with two different connection disciplines is the shape defect #1 takes before it becomes a defect.

Consequences

":memory:" works, which makes the store usable in tests without a temporary directory — the conformance fixture no longer needs one, nor the hand-applied schema it used to set up.

Callers must close the store. Nothing in the library closes a snapshot store for them, and an unclosed one now holds a non-daemon thread that keeps the interpreter alive at shutdown. This is the trade the ownership contract makes explicit: the resource is releasable precisely because the store owns it.

Consumers no longer need to route one instance through every call site to avoid constructing a second, and can drop the parameters and properties that existed only to do so.

Pointing the store at a database whose snapshots table was created elsewhere still works; the DDL is idempotent.