ADR-0047: Cancel-safe storage¶
Status: Accepted Date: 2026-09-29 Deciders: Alex Nodeland
Context¶
Storage calls are cancelled from many places:
Runner.stop, which can land inside a tool's commit or the capability'srecord- a WebSocket that disconnects, whose tasks are cancelled, mid-replay among others
- the capability's run watcher, and a thread claim's renewal inside
acquire_lease, cancelled as their run or claim ends - the MCP SDK's anyio cancel scopes, which cancel their task again until it ends
ArtifactrMcp.aclose, and a feedback mirror stopped with its application
SQLAlchemy takes a statement cancelled part-way for a lost connection. A SQLite engine keeps one pooled connection and takes the database's write lock with BEGIN IMMEDIATE, so a cancelled statement could drop the connection still holding the lock, or never return it to the pool, and lock the process out of its own database. PostgreSQL loses the connection too. Cancelling a commit, a read, a lease and a subscription's page 100 times each, in three ways, broke 9 or 10 of the 12 cases on SQLite and 6 of the 12 on PostgreSQL.
reflexr makes the same decision, with the same helper (reflexr ADR-0042).
Decision¶
- Cancel-safety is part of the
Storageport's contract. A cancelled caller leaves no lock or connection behind; cancellation may be deferred until the current statement ends. A transaction cancelled before it commits rolls back. The protocol says so, and the behaviour suite checks every storage by cancelling a transaction after each number of yields in turn. InMemoryStoragekeeps it as it is. A transaction stages its writes and holds anasyncio.Lock; a cancellation drops the writes and releases the lock.SqlStorageawaits every database call to its end._to_the_end(call)runs the call in a task of its own and waits for it, catching every cancellation that comes meanwhile and raising it once the call has ended. It wraps each_Transactionmethod, through a decorator; the transaction's lock, commit and close; and the reads, leases and cursors outside transactions. Closing the session rolls back whatever it did not commit, and returns its connection to the pool.Runner.aclose()stops the process's runs, waits for them, and starts no more, so an application stops its runs before the event loop ends and its storage closes.
Options considered¶
| Option | Covers | Cost |
|---|---|---|
| In each storage adapter, as a contract of the port (chosen) | Every canceller | One helper and a decorator in artifactr.sql; each adapter keeps the contract |
asyncio.shield around each call |
Every canceller | A shielded call outlives its cancelled task, so the task's next statement can race it |
| Callers never cancel mid-call | Nothing it can enforce | anyio's cancel scopes, Runner.stop and a server's cancelled tasks cancel a task wherever it is |
Consequences¶
- Easier: anything can cancel anything, on every storage, and
Runner.stopcancels a run wherever it is. - Harder: a cancellation waits for the statement in flight, and one waiting for a lock waits as long as the database lets it: on SQLite, the driver's
timeout; on PostgreSQL,lock_timeoutif the application sets one. - Harder: a cancelled call may still have taken effect. A transaction may have committed, or
acquire_leasetaken the lease, before the cancellation is raised. - Harder: a wait for the pool's connection cannot be interrupted either. A task that holds a transaction must not await a task it cancelled, which may be waiting for that transaction's connection.
- Harder: an anyio cancel scope cancels its task again at every turn of the loop until the task ends, so the caller spins the loop for the whole statement. On Linux that starves aiosqlite's thread of the GIL, and the statement takes far longer to end.
- Harder: the event loop's shutdown is not covered.
asyncio.runcancels every task left,_to_the_end's own task too, so the statement is cut off after all. Stop runs withRunner.aclose()before the loop ends. - Harder: a storage adapter must keep the contract. The behaviour suite's cancellation case checks it.
Action items¶
- Cancel-safe
SqlStorage, the contract onStorage, andRunner.aclose.