Skip to content

Commit a072527

Browse files
committed
fix(client): begin on real grpc.aio stubs, settle interrupts, confirm unknown-id cleanup
Four fixes, each with a regression test seen failing first. The shielded begin RPC is now wrapped in a coroutine: a real grpc.aio stub returns a UnaryUnaryCall awaitable, not a coroutine object, and create_task() rejected it with TypeError on every real begin_transaction() — AsyncMock had hidden this, so the new test wires a call-like awaitable the way the transport does. A KeyboardInterrupt or SystemExit raised from inside an awaited statement or commit now settles the handle (aborted with detached cleanup, or indeterminate) instead of parking it in-flight forever. And a cleanup rollback the server answers with NOT_FOUND (unknown transaction id) now reads as confirmed — nothing is held under that id — so no redundant retries follow and an explicit rollback() can settle the handle, while lost requests stay retriable.
1 parent 2b383cf commit a072527

2 files changed

Lines changed: 139 additions & 4 deletions

File tree

coordinode/coordinode/client.py

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -399,7 +399,7 @@ async def _best_effort_rollback(self) -> None:
399399
)
400400

401401
self._cleanup_confirmed = False
402-
with suppress(Exception):
402+
try:
403403
await self._client._cypher_stub.RollbackTransaction(
404404
RollbackTransactionRequest(transaction_id=self._id),
405405
# Capped by the client's own timeout too: a caller who
@@ -408,6 +408,16 @@ async def _best_effort_rollback(self) -> None:
408408
timeout=min(_CLEANUP_TIMEOUT_SECS, self._client._timeout),
409409
)
410410
self._cleanup_confirmed = True
411+
except grpc.RpcError as exc:
412+
# NOT_FOUND is the server's "unknown transaction id": a definitive
413+
# statement that nothing is held under this id any more — as
414+
# settled as a successful rollback. Every other failure (a lost
415+
# request most of all) leaves the cleanup unconfirmed, retriable.
416+
with suppress(Exception):
417+
if exc.code() == grpc.StatusCode.NOT_FOUND:
418+
self._cleanup_confirmed = True
419+
except Exception:
420+
pass
411421

412422
async def cypher(
413423
self,
@@ -489,6 +499,16 @@ async def cypher(
489499
self._state = "aborted"
490500
self._spawn_cleanup()
491501
raise
502+
except BaseException:
503+
# KeyboardInterrupt / SystemExit raised from inside the awaited
504+
# call (or any failure that is neither gRPC nor cancellation)
505+
# matches none of the handlers above and would park the handle
506+
# in "executing" forever. Settle conservatively: no commit was
507+
# sent, so nothing can apply — close as aborted with a detached
508+
# bounded cleanup, and let the exception propagate.
509+
self._state = "aborted"
510+
self._spawn_cleanup()
511+
raise
492512
if self._abandoned:
493513
# The owning context exited while this statement was still in
494514
# flight: nobody is left to commit or roll back, so the buffered
@@ -560,6 +580,16 @@ async def commit(self) -> int:
560580
# indeterminate cleanup, so it goes detached from here.
561581
self._spawn_cleanup()
562582
raise
583+
except BaseException:
584+
# KeyboardInterrupt / SystemExit raised from inside the awaited
585+
# call matches neither handler above and would park the handle in
586+
# "committing" forever. The request may already have applied, so
587+
# the only honest settlement is indeterminate — as the sync
588+
# wrapper already does for interrupts at the loop boundary.
589+
self._state = "indeterminate"
590+
if self._abandoned:
591+
self._spawn_cleanup()
592+
raise
563593
self._state = "committed"
564594
return int(resp.applied_index)
565595

@@ -924,9 +954,13 @@ async def begin_transaction(self) -> AsyncTransaction:
924954
# no handle, nothing for close() to drain, a pinned snapshot until
925955
# the idle sweep. On cancellation the task keeps running detached and
926956
# its late reply is handed straight to a rollback.
927-
begin = asyncio.get_running_loop().create_task(
928-
self._cypher_stub.BeginTransaction(BeginTransactionRequest(), timeout=self._timeout)
929-
)
957+
# Wrapped in a coroutine: a real grpc.aio stub returns a
958+
# UnaryUnaryCall — an awaitable, NOT a coroutine object — and
959+
# create_task() accepts only the latter.
960+
async def _begin() -> Any:
961+
return await self._cypher_stub.BeginTransaction(BeginTransactionRequest(), timeout=self._timeout)
962+
963+
begin = asyncio.get_running_loop().create_task(_begin())
930964
try:
931965
resp = await asyncio.shield(begin)
932966
except asyncio.CancelledError:

tests/unit/test_transactions.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2455,3 +2455,104 @@ async def rollback(req, timeout=None):
24552455
assert calls["n"] == 3, "a cancelled aborted-branch retry got no detached cleanup"
24562456

24572457
asyncio.run(_inner())
2458+
2459+
2460+
class TestBeginWorksWithGrpcStyleAwaitables:
2461+
"""A real grpc.aio unary stub returns a UnaryUnaryCall — an AWAITABLE,
2462+
not a coroutine object — and loop.create_task() accepts only coroutines.
2463+
AsyncMock hides this by returning coroutines, so this test wires a
2464+
call-like awaitable the way the real transport does."""
2465+
2466+
def test_begin_accepts_a_non_coroutine_awaitable(self):
2467+
from unittest.mock import MagicMock
2468+
2469+
class _CallLike:
2470+
def __init__(self, resp):
2471+
self._resp = resp
2472+
2473+
def __await__(self):
2474+
async def _deliver():
2475+
return self._resp
2476+
2477+
return _deliver().__await__()
2478+
2479+
async def _inner() -> None:
2480+
client = _async_client(
2481+
BeginTransaction=MagicMock(
2482+
side_effect=lambda req, timeout=None: _CallLike(
2483+
cypher_pb2.BeginTransactionResponse(transaction_id=42)
2484+
)
2485+
)
2486+
)
2487+
tx = await client.begin_transaction()
2488+
assert tx.transaction_id == 42
2489+
2490+
asyncio.run(_inner())
2491+
2492+
2493+
class TestAsyncStatementSettlesAfterProcessControlException:
2494+
"""KeyboardInterrupt or SystemExit raised from inside the awaited
2495+
statement matches neither the gRPC nor the cancellation handler; the
2496+
handle must still close conservatively (aborted, cleanup unconfirmed)
2497+
instead of staying "executing" forever."""
2498+
2499+
def test_interrupted_statement_closes_the_handle(self):
2500+
from unittest.mock import AsyncMock
2501+
2502+
async def _inner() -> None:
2503+
client = _async_client(ExecuteCypher=AsyncMock(side_effect=KeyboardInterrupt))
2504+
tx = await client.begin_transaction()
2505+
with pytest.raises(KeyboardInterrupt):
2506+
await tx.cypher("CREATE (:A)")
2507+
assert tx._state == "aborted", "the interruption left the handle parked in-flight"
2508+
await tx.rollback() # must be able to send the cleanup
2509+
await client.close()
2510+
assert client._cypher_stub.RollbackTransaction.await_count >= 1
2511+
2512+
asyncio.run(_inner())
2513+
2514+
2515+
class TestAsyncCommitSettlesAfterProcessControlException:
2516+
"""KeyboardInterrupt or SystemExit raised from inside the awaited commit
2517+
leaves the outcome unknowable — the request may already have applied —
2518+
so the handle must settle as indeterminate, not stay "committing"."""
2519+
2520+
def test_interrupted_commit_is_indeterminate(self):
2521+
from unittest.mock import AsyncMock
2522+
2523+
async def _inner() -> None:
2524+
client = _async_client(CommitTransaction=AsyncMock(side_effect=KeyboardInterrupt))
2525+
tx = await client.begin_transaction()
2526+
with pytest.raises(KeyboardInterrupt):
2527+
await tx.commit()
2528+
assert tx._state == "indeterminate"
2529+
with pytest.raises(RuntimeError, match="outcome is unknown"):
2530+
await tx.cypher("RETURN 1")
2531+
2532+
asyncio.run(_inner())
2533+
2534+
2535+
class TestUnknownTransactionAnswerConfirmsCleanup:
2536+
"""The server answering a cleanup rollback with NOT_FOUND ("unknown
2537+
transaction id") is a definitive statement that nothing is held: the
2538+
cleanup must read as confirmed, so no redundant retries follow and an
2539+
explicit rollback() can settle the handle."""
2540+
2541+
def test_not_found_confirms_the_cleanup(self):
2542+
from contextlib import suppress as ctx_suppress
2543+
from unittest.mock import AsyncMock
2544+
2545+
async def _inner() -> None:
2546+
client = _async_client(
2547+
ExecuteCypher=AsyncMock(side_effect=_ServerRejected()),
2548+
RollbackTransaction=AsyncMock(side_effect=_TransportError(grpc.StatusCode.NOT_FOUND)),
2549+
)
2550+
tx = await client.begin_transaction()
2551+
with ctx_suppress(grpc.RpcError):
2552+
await tx.cypher("CREATE (:A)") # aborts; cleanup answered NOT_FOUND
2553+
assert tx._cleanup_confirmed is True, "a definitive unknown-id answer read as a lost request"
2554+
await tx.rollback()
2555+
assert tx._state == "rolled_back"
2556+
assert client._cypher_stub.RollbackTransaction.await_count == 1, "redundant cleanup retry"
2557+
2558+
asyncio.run(_inner())

0 commit comments

Comments
 (0)