Skip to content

fix(store): a cancelled call returned a pooled SQL Server connection mid-transaction (BACKLOG #348, ADR 0159) - #154

Open
wshallwshall wants to merge 5 commits into
mainfrom
claude/upbeat-mendel-af0254
Open

fix(store): a cancelled call returned a pooled SQL Server connection mid-transaction (BACKLOG #348, ADR 0159)#154
wshallwshall wants to merge 5 commits into
mainfrom
claude/upbeat-mendel-af0254

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

CancelledError is a BaseException, so the except Exception: rollback() idiom never fires. The pooled connection goes back to the free deque still holding its locks — and holds them for as long as it sits there unclaimed.

Measured on a live SQL Server, locks still held on queue after a cancelled call:

call locks
release_claimed 7
reschedule_claimed 7
mark_done 9
enqueue_ingress (the pre-ACK ingress commit) 11
claim_fifo_heads (control) 0

A subsequent real claim_fifo_heads then returned EMPTY-all and a raw writer took native 1222.

In-source gap independently confirmed by the BACKLOG #344 session: 90 rollback sites guarded by except Exception, zero using BaseException or finally.

How this relates to #344 instance 2 (PR #153), stated precisely because it would be easy to overclaim: this is a new source of that same 1222 chain. #344's analysis assumes momentary producer contention; a poisoned connection makes the contention persistent — the 1222 can repeat across successive sweep ticks instead of clearing on the next. Production still recovers. Neither session escalated severity on the other's finding, and this PR does not either.

Fix is a _release_dirty plus a try around _acquire's yield — the acquire chokepoint, so it covers every call site rather than 90 of them.

Authored by the upbeat-mendel session; pushed and opened by the coordinator at the owner's instruction, so pushes are serialised through one place. Merge remains the owner's call.

🤖 Generated with Claude Code

…mid-transaction (BACKLOG #348, ADR 0159)

SqlServerStore's write idiom is `except Exception: await conn.rollback(); raise`, used at 90 of
the 91 self._acquire() sites. CancelledError derives from BaseException, so on a cancellation no
rollback runs, and aioodbc does not compensate: Pool.release() appends a non-closed connection
straight back onto the free deque with no rollback, reset or transaction check (0.5.0
pool.py:196-205), and _ContextManager.__aexit__ uses the SAME release on the exception path
(utils.py:60-62). The next borrower inherited an open transaction still holding X locks.

Measured on a live SQL Server 2022 before the fix: cancelling release_claimed left 7 X locks on
queue, reschedule_claimed 7, mark_done 9, enqueue_ingress 11 (the pre-ACK ingress commit) --
against 0 for the claim_fifo_heads control. The connection went back on the free list, a raw
writer got error 1222, and a real second claim_fifo_heads returned EMPTY-all. Under ADR 0066 §9
that 1222 is a sanctioned yield, so the symptom was silence, not an error.

Fix at the _acquire chokepoint, on a non-Exception BaseException only: synchronously drop the
driver handle (conn._conn = None -- aioodbc derives `closed` from it and re-adds only
`if not conn.closed`) with NO await in front of it, then close the raw handle off-loop under a
5s bound. The ordering IS the guarantee: a cleanup that awaits first is defeated by the second
cancellation that shutdown's cancel-then-gather delivers.

A plain `await conn.rollback()` was built and rejected on measurement -- it runs in the default
executor bounded only by command_timeout (30s) with no upstream timeout; a cancel measured 1.005s
against a 1.0s rollback, stalling exactly the demotion path (_stop_graph cancels but does NOT
close the store, so the poisoned connection is re-borrowed there).

Corrects the lead that found this on two points: it is not a two-method asymmetry (90 of 91 sites
share the idiom; mark_done and enqueue_ingress leak identically), and claim_fifo_heads does NOT
shield against it -- its guard is a SET LOCK_TIMEOUT reset guard, and ADR 0114 §2 plus a frozen
test record that no rollback runs on its cancellation path. It ends clean because the guard COMMITS.

Ordinary errors keep today's rollback-and-recycle behaviour, pinned by controls that pass before
and after. SQL Server only: Postgres is safe twice over (asyncpg rolls back on any BaseException
and its pool resets under shield); SQLite has no pool.

Gate: 6/12 failing pre-fix (both cancellation properties x all three methods, controls already
green), 12/12 after. Live repro 7 locks -> 0. Live SQL Server suite 182 passed (15 sqlserver arms
verified executed, not skipped).
… NOT claim

Both items independently reached the same 1222. #344 instance 2 traces the far end --
contended head -> native 1222 -> the store swallows it as a normal EMPTY -> the
dispatcher goes to phase IDLE with no timer armed -- and correctly concludes that is a
TEST-RIG GAP, not an engine defect, because production's periodic sweep re-readies such
a lane and the ADR 0070 tests disable that sweep on purpose.

Nothing in #348/ADR 0159 contradicts that, and this commit deliberately does NOT
escalate #348's severity on the strength of it. What it adds is a DURATION PROFILE:
#344 assumes momentary producer contention, whereas a connection poisoned by #348 holds
its queue X locks for as long as it sits unclaimed in the pool's free deque, so the 1222
it manufactures can repeat across successive sweep ticks rather than clearing on the
next one. Production still recovers; the mechanism just supplies a PERSISTENT contention
source where a momentary one was assumed.

Cited by ledger number, not SHA: that work is unpushed and may be rebased. (A peer
message named 6644309 for it; that SHA is a one-line #344 banner edit touching no code.
The work is on claude/keen-mclaren-31648a.)

Docs only -- no code, no test, no behaviour change.
@wshallwshall
wshallwshall enabled auto-merge (squash) August 2, 2026 18:29
…af0254

# Conflicts:
#	docs/BACKLOG.md
#	docs/adr/README.md
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant