Skip to content

Commit 898211d

Browse files
Rewrap async cursor COMMIT leadership-loss as AmbiguousCommitError
The sync cursor and async Connection.commit() both re-raise a leadership-lost OperationalError as AmbiguousCommitError for an explicit COMMIT (the write is in-doubt once the entry was submitted). The async cursor's DML path lacked this, so await cursor.execute("COMMIT") on a leadership-lost code surfaced a plain OperationalError, diverging from both sibling paths. Mirror the sync cursor: remap on an explicit COMMIT with a leadership-lost code; a not-leader rejection is a clean failure and stays OperationalError. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 063ed54 commit 898211d

2 files changed

Lines changed: 90 additions & 2 deletions

File tree

src/dqlitedbapi/aio/cursor.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
_convert_params_async,
1818
_convert_rows_async,
1919
_ExecuteManyAccumulator,
20+
_is_commit_statement,
2021
_is_dml_rowcount_meaningful,
2122
_is_dml_with_returning,
2223
_is_insert_or_replace,
@@ -29,9 +30,12 @@
2930
_validate_executemany_seq_shape,
3031
)
3132
from dqlitedbapi.exceptions import (
33+
AMBIGUOUS_COMMIT_CODES,
34+
AmbiguousCommitError,
3235
DataError,
3336
InterfaceError,
3437
NotSupportedError,
38+
OperationalError,
3539
ProgrammingError,
3640
)
3741
from dqlitedbapi.types import UNKNOWN as _UNKNOWN_TYPE
@@ -328,7 +332,21 @@ async def _execute_unlocked(
328332
# -1 for ALL PRAGMA, so match that rather than len.
329333
self._rowcount = -1 if _is_pragma(operation) else len(rows)
330334
else:
331-
last_id, affected = await _call_client(conn.execute(operation, params))
335+
try:
336+
last_id, affected = await _call_client(conn.execute(operation, params))
337+
except OperationalError as e:
338+
# Mirror Connection.commit() and the sync cursor: an explicit COMMIT that
339+
# loses leadership after the entry was submitted leaves the write in doubt.
340+
# A not-leader rejection is a clean pre-apply failure and stays OperationalError.
341+
if _is_commit_statement(operation) and e.code in AMBIGUOUS_COMMIT_CODES:
342+
raise AmbiguousCommitError(
343+
"ambiguous commit: leadership lost during COMMIT; "
344+
"the write may or may not have been persisted. "
345+
f"Original: {e}",
346+
code=e.code,
347+
raw_message=getattr(e, "raw_message", None),
348+
) from e
349+
raise
332350
# Post-await close-race guard (see query branch). _lastrowid is
333351
# PRESERVED: stdlib persists it across a close-during-DML boundary.
334352
if self._closed:

tests/test_commit_leader_flip_ambiguous_commit_error.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,15 @@
1313
import asyncio
1414
import os
1515
import weakref
16-
from unittest.mock import AsyncMock, MagicMock
16+
from unittest.mock import AsyncMock, MagicMock, patch
1717

1818
import pytest
1919

2020
import dqliteclient.exceptions as _client_exc
2121
import dqlitedbapi
2222
from dqlitedbapi import Connection
2323
from dqlitedbapi.aio.connection import AsyncConnection
24+
from dqlitedbapi.aio.cursor import AsyncCursor
2425
from dqlitedbapi.exceptions import (
2526
AMBIGUOUS_COMMIT_CODES,
2627
AmbiguousCommitError,
@@ -166,6 +167,75 @@ def test_cursor_insert_leadership_lost_is_not_remapped(code: int) -> None:
166167
conn._closed = True
167168

168169

170+
def _make_async_cursor(code: int) -> AsyncCursor:
171+
"""An AsyncCursor whose underlying connection.execute raises a client leader error."""
172+
cur = AsyncCursor.__new__(AsyncCursor)
173+
cur._closed = False
174+
cur._description = None
175+
cur._rows = []
176+
cur._rowcount = -1
177+
cur._lastrowid = None
178+
cur._row_index = 0
179+
cur._arraysize = 1
180+
cur.messages = []
181+
cur._completed_iterations = 0
182+
cur._executing_task = None
183+
184+
inner = MagicMock()
185+
186+
async def _raise(_sql: str, _params: object) -> None:
187+
raise _client_exc.OperationalError("leader", code)
188+
189+
inner.execute = _raise
190+
191+
conn = MagicMock()
192+
conn._max_total_rows = None
193+
194+
async def _ensure() -> object:
195+
return inner
196+
197+
conn._ensure_connection = _ensure
198+
cur._connection = conn
199+
return cur
200+
201+
202+
@pytest.mark.parametrize("code", _IN_DOUBT_CODES)
203+
async def test_async_cursor_commit_leadership_lost_is_ambiguous(code: int) -> None:
204+
cur = _make_async_cursor(code)
205+
with (
206+
patch.object(AsyncCursor, "_check_closed", lambda self: None),
207+
pytest.raises(AmbiguousCommitError) as ei,
208+
):
209+
await cur._execute_unlocked("COMMIT", None)
210+
assert isinstance(ei.value, OperationalError)
211+
assert ei.value.code == code
212+
213+
214+
@pytest.mark.parametrize("code", _NOT_LEADER_CODES)
215+
async def test_async_cursor_commit_not_leader_is_plain_operational(code: int) -> None:
216+
cur = _make_async_cursor(code)
217+
with (
218+
patch.object(AsyncCursor, "_check_closed", lambda self: None),
219+
pytest.raises(OperationalError) as ei,
220+
):
221+
await cur._execute_unlocked("COMMIT", None)
222+
assert not isinstance(ei.value, AmbiguousCommitError)
223+
assert ei.value.code == code
224+
225+
226+
@pytest.mark.parametrize("code", _IN_DOUBT_CODES)
227+
async def test_async_cursor_insert_leadership_lost_is_not_remapped(code: int) -> None:
228+
"""The remap is scoped to explicit COMMIT; a bare DML leader-loss stays Operational."""
229+
cur = _make_async_cursor(code)
230+
with (
231+
patch.object(AsyncCursor, "_check_closed", lambda self: None),
232+
pytest.raises(OperationalError) as ei,
233+
):
234+
await cur._execute_unlocked("INSERT INTO t VALUES (1)", None)
235+
assert not isinstance(ei.value, AmbiguousCommitError)
236+
assert ei.value.code == code
237+
238+
169239
def test_ambiguous_commit_error_is_exported_at_package_level() -> None:
170240
assert dqlitedbapi.AmbiguousCommitError is AmbiguousCommitError
171241
assert "AmbiguousCommitError" in dqlitedbapi.__all__

0 commit comments

Comments
 (0)