Skip to content

Commit 18cee40

Browse files
Discriminate deterministic rollback codes in transaction commit-arm
The transaction() async ctxmgr's commit-attempted arm invalidated the connection on EVERY exception class, including OperationalError codes whose SQLite contract is "the engine has already rolled the transaction back" — _TX_AUTO_ROLLBACK_PRIMARY_CODES (NOMEM / IOERR / INTERRUPT / CORRUPT / FULL / ABORT) and SQLITE_CONSTRAINT primary 19 on COMMIT (deferred-FK violation). In both cases execute() and _run_protocol have already cleared the local tx flags atomically before raising, so the connection is healthy and reusable — invalidating discards a healthy slot, forcing a fresh-connect round-trip on every commit-failure under retry storms. Mirror the rollback-arm discrimination at lines 2284-2298 (already- fixed sibling per done/tx-013): only invalidate when the server-side state is genuinely ambiguous (transport / cancellation / non-rollback codes). Pin against regression with parametrised codes (7/10/9/11/13/4/19), an extended-IOERR (10250 → primary 10) shape, ambiguous OperationalError (code=1) shape, and CancelledError shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 778e10b commit 18cee40

2 files changed

Lines changed: 184 additions & 9 deletions

File tree

src/dqliteclient/connection.py

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2240,15 +2240,36 @@ async def transaction(self) -> AsyncIterator[None]:
22402240
await self.execute(_TRANSACTION_COMMIT_SQL)
22412241
except BaseException as exc:
22422242
if commit_attempted:
2243-
# COMMIT was sent but failed. Server-side state is ambiguous
2244-
# (maybe committed, maybe still open, maybe rolled back). We
2245-
# cannot safely reuse this connection — invalidate so the
2246-
# pool discards it instead of recycling an unknown state.
2247-
# Pass the in-flight exception as the cause so subsequent
2248-
# ``_ensure_connected`` raises chain back to the cancel /
2249-
# OperationalError that triggered the invalidation, instead
2250-
# of dropping ``__cause__`` on the floor.
2251-
self._invalidate(exc)
2243+
# COMMIT was sent but failed. Discriminate by exception
2244+
# class / SQLite code: deterministic rollback codes
2245+
# (``_TX_AUTO_ROLLBACK_PRIMARY_CODES`` — SQLITE_NOMEM /
2246+
# IOERR / INTERRUPT / CORRUPT / FULL / ABORT, all
2247+
# documented as engine-rolled-back) and SQLITE_CONSTRAINT
2248+
# primary 19 on COMMIT (deferred-FK violation, also
2249+
# engine-rolled-back per the SQLite savepoint
2250+
# specification) leave the server in a known no-tx
2251+
# state — ``execute`` / ``_run_protocol`` already
2252+
# cleared the local tx flags atomically before raising,
2253+
# so the connection is healthy and reusable. Invalidate
2254+
# only when the server-side state is genuinely
2255+
# ambiguous (transport / cancellation / non-rollback
2256+
# codes). Mirrors the rollback-arm discrimination
2257+
# below at lines 2284-2298 (already-fixed sibling).
2258+
deterministic_rollback = (
2259+
isinstance(exc, OperationalError)
2260+
and exc.code is not None
2261+
and (
2262+
_primary_sqlite_code(exc.code) in _TX_AUTO_ROLLBACK_PRIMARY_CODES
2263+
or _primary_sqlite_code(exc.code) == 19 # SQLITE_CONSTRAINT
2264+
)
2265+
)
2266+
if not deterministic_rollback:
2267+
# Pass the in-flight exception as the cause so
2268+
# subsequent ``_ensure_connected`` raises chain
2269+
# back to the cancel / OperationalError that
2270+
# triggered the invalidation, instead of
2271+
# dropping ``__cause__`` on the floor.
2272+
self._invalidate(exc)
22522273
else:
22532274
# Body raised before COMMIT; try to roll back.
22542275
#
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""Pin: ``DqliteConnection.transaction()`` does NOT invalidate the
2+
connection on a COMMIT-arm failure whose SQLite primary code is a
3+
deterministic-rollback shape (``_TX_AUTO_ROLLBACK_PRIMARY_CODES`` or
4+
``SQLITE_CONSTRAINT`` = 19).
5+
6+
Both code classes leave the server in a known no-tx state — the
7+
local tx flags were already cleared by ``execute`` /
8+
``_run_protocol`` before the exception propagated. Invalidating
9+
discards a healthy connection, forcing a fresh-connect round-trip
10+
under retry storms. Mirrors the rollback-arm discrimination already
11+
in place at the sibling no-tx-rollback path (done/tx-013).
12+
"""
13+
14+
from __future__ import annotations
15+
16+
from unittest.mock import MagicMock
17+
18+
import pytest
19+
20+
from dqliteclient.connection import DqliteConnection
21+
from dqliteclient.exceptions import OperationalError
22+
23+
24+
def _make_connection_in_transaction() -> DqliteConnection:
25+
from dqliteclient import connection as _conn_mod
26+
27+
conn = DqliteConnection.__new__(DqliteConnection)
28+
conn._address = "host:9001"
29+
conn._in_use = False
30+
conn._in_transaction = False
31+
conn._tx_owner = None
32+
conn._savepoint_stack = []
33+
conn._savepoint_implicit_begin = False
34+
conn._has_untracked_savepoint = False
35+
conn._invalidation_cause = None
36+
conn._bound_loop = None
37+
conn._pending_drain = None
38+
conn._creator_pid = _conn_mod._current_pid
39+
conn._pool_released = False
40+
conn._database = "main"
41+
# Mark as connected so _check_connected does not raise.
42+
conn._protocol = MagicMock()
43+
conn._db_id = 1
44+
# Suppress thread/loop binding by short-circuiting _check_in_use.
45+
conn._check_in_use = lambda: None
46+
return conn
47+
48+
49+
@pytest.mark.asyncio
50+
@pytest.mark.parametrize(
51+
"code",
52+
[
53+
7, # SQLITE_NOMEM
54+
10, # SQLITE_IOERR
55+
9, # SQLITE_INTERRUPT
56+
11, # SQLITE_CORRUPT
57+
13, # SQLITE_FULL
58+
4, # SQLITE_ABORT
59+
19, # SQLITE_CONSTRAINT (deferred-FK on COMMIT)
60+
],
61+
)
62+
async def test_transaction_commit_arm_does_not_invalidate_on_deterministic_rollback(
63+
code: int,
64+
) -> None:
65+
"""Drive the transaction ctxmgr to the commit-arm failure path
66+
with a deterministic-rollback OperationalError code. The
67+
invalidate path must NOT fire."""
68+
conn = _make_connection_in_transaction()
69+
invalidate_called = MagicMock()
70+
conn._invalidate = invalidate_called
71+
72+
# Mock execute so BEGIN succeeds, COMMIT raises with the code.
73+
async def _execute(sql: str) -> None:
74+
if "COMMIT" in sql.upper() or sql.strip().upper() == "END":
75+
raise OperationalError(code, "auto-rollback shape")
76+
77+
conn.execute = _execute # type: ignore[assignment]
78+
79+
with pytest.raises(OperationalError):
80+
async with conn.transaction():
81+
pass
82+
83+
invalidate_called.assert_not_called()
84+
85+
86+
@pytest.mark.asyncio
87+
async def test_transaction_commit_arm_invalidates_on_ambiguous_failure() -> None:
88+
"""Defence pin: a commit-arm failure with an ambiguous shape
89+
(e.g. CancelledError, transport error, non-rollback OperationalError)
90+
MUST still invalidate. The discrimination is narrow."""
91+
conn = _make_connection_in_transaction()
92+
invalidate_called = MagicMock()
93+
conn._invalidate = invalidate_called
94+
95+
async def _execute(sql: str) -> None:
96+
if "COMMIT" in sql.upper() or sql.strip().upper() == "END":
97+
# Code 1 (SQLITE_ERROR) is NOT in the deterministic-
98+
# rollback set; server-side state is ambiguous.
99+
raise OperationalError(1, "ambiguous error")
100+
101+
conn.execute = _execute # type: ignore[assignment]
102+
103+
with pytest.raises(OperationalError):
104+
async with conn.transaction():
105+
pass
106+
107+
invalidate_called.assert_called_once()
108+
109+
110+
@pytest.mark.asyncio
111+
async def test_transaction_commit_arm_invalidates_on_cancelled_error() -> None:
112+
"""CancelledError mid-COMMIT keeps server-side state ambiguous —
113+
invalidation is still mandatory."""
114+
import asyncio
115+
116+
conn = _make_connection_in_transaction()
117+
invalidate_called = MagicMock()
118+
conn._invalidate = invalidate_called
119+
120+
async def _execute(sql: str) -> None:
121+
if "COMMIT" in sql.upper() or sql.strip().upper() == "END":
122+
raise asyncio.CancelledError()
123+
124+
conn.execute = _execute # type: ignore[assignment]
125+
126+
with pytest.raises(asyncio.CancelledError):
127+
async with conn.transaction():
128+
pass
129+
130+
invalidate_called.assert_called_once()
131+
132+
133+
@pytest.mark.asyncio
134+
async def test_transaction_commit_arm_invalidates_on_extended_ioerr() -> None:
135+
"""An extended IOERR code (10250 = SQLITE_IOERR_NOT_LEADER) maps
136+
to primary IOERR (10) which IS in the rollback set; pin that the
137+
extended-code arithmetic doesn't trip the discrimination."""
138+
conn = _make_connection_in_transaction()
139+
invalidate_called = MagicMock()
140+
conn._invalidate = invalidate_called
141+
142+
async def _execute(sql: str) -> None:
143+
if "COMMIT" in sql.upper() or sql.strip().upper() == "END":
144+
raise OperationalError(10250, "leader changed mid-commit")
145+
146+
conn.execute = _execute # type: ignore[assignment]
147+
148+
with pytest.raises(OperationalError):
149+
async with conn.transaction():
150+
pass
151+
152+
# IOERR's primary is in the deterministic-rollback set; the
153+
# connection should NOT be invalidated.
154+
invalidate_called.assert_not_called()

0 commit comments

Comments
 (0)