Skip to content

Commit 11b04f1

Browse files
committed
fix(client): never swallow cancellation in cleanups, narrow sync interrupt settlement
Three fixes, each with a regression test seen failing first. Both inline indeterminate cleanups (after a failed automatic commit, and on the exceptional exit path) now let a cancellation that lands mid-RPC propagate — asyncio does not re-inject a swallowed one — while handing the interrupted cleanup to a detached retry. The context manager's cancellation handler gains the aborted-with-unconfirmed-cleanup branch, so a manual rollback() cancelled mid-RPC still gets its detached retry before the scope is gone. And the sync statement wrapper's conservative interrupt settlement is narrowed to real interruptions (non-Exception): a local encoding failure sends no RPC and leaves the handle open, matching the async contract.
1 parent 4a8ce8a commit 11b04f1

2 files changed

Lines changed: 126 additions & 5 deletions

File tree

coordinode/coordinode/client.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -926,6 +926,11 @@ async def transaction(self) -> AsyncIterator[AsyncTransaction]:
926926
tx._abandoned = True
927927
if tx._state == "committing":
928928
tx._spawn_cleanup()
929+
elif tx._state == "aborted" and not tx._cleanup_confirmed:
930+
# A manual rollback() cancelled mid-RPC: the request was
931+
# interrupted, not answered, so the server may still hold the
932+
# transaction. Retry detached before the scope is gone.
933+
tx._spawn_cleanup()
929934
raise
930935
except BaseException:
931936
if tx.is_open:
@@ -942,8 +947,15 @@ async def transaction(self) -> AsyncIterator[AsyncTransaction]:
942947
# best-effort request frees it in that case; if the commit
943948
# applied, the server answers "unknown id" and nothing
944949
# changes. The verdict stays indeterminate either way.
945-
with suppress(asyncio.CancelledError):
950+
try:
946951
await tx._best_effort_rollback()
952+
except asyncio.CancelledError:
953+
# Suppressing this would re-raise the block's error and
954+
# LOSE the cancellation — asyncio does not re-inject a
955+
# swallowed one. Spawn the detached retry and let it
956+
# propagate.
957+
tx._spawn_cleanup()
958+
raise
947959
elif tx._state in ("executing", "committing"):
948960
# The block raised while an operation it started (in a
949961
# background task) is still in flight. Raising over the
@@ -992,8 +1004,15 @@ async def transaction(self) -> AsyncIterator[AsyncTransaction]:
9921004
except BaseException:
9931005
if tx._state == "indeterminate":
9941006
# Same reasoning as above, for the automatic commit.
995-
with suppress(asyncio.CancelledError):
1007+
try:
9961008
await tx._best_effort_rollback()
1009+
except asyncio.CancelledError:
1010+
# Suppressing this would re-raise the commit
1011+
# error and LOSE the cancellation — asyncio does
1012+
# not re-inject a swallowed one. Spawn the
1013+
# detached retry and let it propagate.
1014+
tx._spawn_cleanup()
1015+
raise
9971016
raise
9981017
elif tx._state == "indeterminate":
9991018
# A manual commit() inside the block failed ambiguously and
@@ -1586,15 +1605,19 @@ def cypher(
15861605
"""Run one statement inside this transaction. See :meth:`AsyncTransaction.cypher`."""
15871606
try:
15881607
return self._client._run(self._inner.cypher(query, params)) # type: ignore[no-any-return]
1589-
except BaseException:
1608+
except BaseException as exc:
15901609
# An interruption raised INSIDE the stepping coroutine (Ctrl-C
15911610
# delivered mid-call) completes the task before _run() can
15921611
# cancel-and-drain it, so no async handler closes the handle.
15931612
# Mirror commit()'s conservative settlement: no commit was sent,
15941613
# so nothing can apply — the handle closes as aborted, with the
15951614
# cleanup unconfirmed so a later rollback() frees the server
1596-
# side. An outcome the inner handler already decided is kept.
1597-
if self._inner._state in ("open", "executing"):
1615+
# side. Only for REAL interruptions (non-Exception): a local
1616+
# failure such as an unsupported parameter type raises an
1617+
# ordinary Exception before any RPC and must leave the handle
1618+
# open, exactly as the async path does. An outcome the inner
1619+
# handler already decided is kept either way.
1620+
if not isinstance(exc, Exception) and self._inner._state in ("open", "executing"):
15981621
self._inner._state = "aborted"
15991622
self._inner._cleanup_confirmed = False
16001623
raise

tests/unit/test_transactions.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1960,3 +1960,101 @@ async def ki(req, timeout=None):
19601960
# must send the cleanup rather than trusting one that never ran.
19611961
tx.rollback()
19621962
assert client._async._cypher_stub.RollbackTransaction.await_count == 1
1963+
1964+
1965+
class TestCancelledFailedCommitCleanupPropagates:
1966+
"""When the automatic commit fails ambiguously and cancellation then
1967+
lands during the inline cleanup, the cancellation must propagate (a
1968+
swallowed one is simply lost — asyncio does not re-inject it) and the
1969+
interrupted cleanup must be retried detached."""
1970+
1971+
def test_cancellation_mid_failed_commit_cleanup_is_not_swallowed(self):
1972+
from unittest.mock import AsyncMock
1973+
1974+
async def _inner() -> None:
1975+
rollback_started = asyncio.Event()
1976+
calls = {"n": 0}
1977+
1978+
async def rollback(req, timeout=None):
1979+
calls["n"] += 1
1980+
if calls["n"] == 1:
1981+
rollback_started.set()
1982+
await asyncio.sleep(10)
1983+
return cypher_pb2.RollbackTransactionResponse()
1984+
1985+
client = _async_client(
1986+
CommitTransaction=AsyncMock(side_effect=_TransportError(grpc.StatusCode.DEADLINE_EXCEEDED)),
1987+
RollbackTransaction=AsyncMock(side_effect=rollback),
1988+
)
1989+
1990+
async def run_block() -> None:
1991+
async with client.transaction() as tx:
1992+
await tx.cypher("CREATE (:A)")
1993+
1994+
t = asyncio.create_task(run_block())
1995+
await rollback_started.wait()
1996+
t.cancel()
1997+
with pytest.raises(asyncio.CancelledError):
1998+
await t
1999+
await client.close() # drains the detached retry
2000+
assert calls["n"] == 2, "the interrupted cleanup was never retried"
2001+
2002+
asyncio.run(_inner())
2003+
2004+
2005+
class TestCancelledManualRollbackGetsDetachedRetry:
2006+
"""A manual rollback() cancelled mid-RPC leaves the handle aborted with
2007+
the cleanup unconfirmed; the context exit must register the detached
2008+
retry, or closing the client strands the server-side transaction until
2009+
the idle sweep."""
2010+
2011+
def test_cancelled_rollback_in_context_spawns_cleanup(self):
2012+
from unittest.mock import AsyncMock
2013+
2014+
async def _inner() -> None:
2015+
rollback_started = asyncio.Event()
2016+
calls = {"n": 0}
2017+
2018+
async def rollback(req, timeout=None):
2019+
calls["n"] += 1
2020+
if calls["n"] == 1:
2021+
rollback_started.set()
2022+
await asyncio.sleep(10)
2023+
return cypher_pb2.RollbackTransactionResponse()
2024+
2025+
client = _async_client(RollbackTransaction=AsyncMock(side_effect=rollback))
2026+
2027+
async def run_block() -> None:
2028+
async with client.transaction() as tx:
2029+
await tx.cypher("CREATE (:A)")
2030+
await tx.rollback()
2031+
2032+
t = asyncio.create_task(run_block())
2033+
await rollback_started.wait()
2034+
t.cancel()
2035+
with pytest.raises(asyncio.CancelledError):
2036+
await t
2037+
await client.close() # drains the detached retry
2038+
assert calls["n"] == 2, "a cancelled manual rollback got no detached retry"
2039+
2040+
asyncio.run(_inner())
2041+
2042+
2043+
class TestSyncLocalEncodingFailureKeepsTheHandleUsable:
2044+
"""Sync mirror of the async encoding-failure contract: a parameter the
2045+
encoder rejects fails locally before any RPC, so the handle must stay
2046+
open — the interrupt settlement is for real interruptions only, not for
2047+
ordinary local exceptions."""
2048+
2049+
def test_a_bad_parameter_leaves_the_sync_transaction_open(self):
2050+
client = _sync_client()
2051+
tx = client.begin_transaction()
2052+
tx.cypher("CREATE (:A)")
2053+
with pytest.raises(Exception):
2054+
tx.cypher("CREATE (:B {v: $v})", {"v": object()})
2055+
assert client._async._cypher_stub.ExecuteCypher.await_count == 1, (
2056+
"the failing statement must not have reached the wire"
2057+
)
2058+
assert tx.is_open is True, "a local encoding failure aborted a usable transaction"
2059+
tx.rollback()
2060+
assert client._async._cypher_stub.RollbackTransaction.await_count == 1

0 commit comments

Comments
 (0)