@@ -706,7 +706,8 @@ def test_a_lost_rollback_still_closes_the_transaction(self):
706706 """The request may not have landed, so the server may still hold the
707707 transaction until the idle sweep. What is certain is that no commit was
708708 ever sent, so nothing can apply: the discard promise holds and the
709- handle must not stay usable."""
709+ handle must not stay usable — though it stays RETRIABLE for rollback,
710+ so the closed state reads as aborted, not rolled back."""
710711 from unittest .mock import AsyncMock
711712
712713 async def _inner () -> None :
@@ -717,7 +718,7 @@ async def _inner() -> None:
717718 with pytest .raises (_TransportError ):
718719 await tx .rollback ()
719720 assert tx .is_open is False
720- with pytest .raises (RuntimeError , match = "already rolled back " ):
721+ with pytest .raises (RuntimeError , match = "earlier failure closed it " ):
721722 await tx .cypher ("CREATE (:Person)" )
722723
723724 asyncio .run (_inner ())
@@ -1826,3 +1827,136 @@ async def hang(req, timeout=None):
18261827 await client .close ()
18271828
18281829 asyncio .run (_inner ())
1830+
1831+
1832+ class TestCancelledNormalExitCleanupPropagates :
1833+ """Cancellation landing while the normal-exit indeterminate cleanup is
1834+ awaiting the server must propagate, not be swallowed into a
1835+ successful-looking exit — and the interrupted cleanup must be retried
1836+ detached so the server transaction is still freed."""
1837+
1838+ def test_cancellation_mid_cleanup_is_not_swallowed (self ):
1839+ from contextlib import suppress as ctx_suppress
1840+ from unittest .mock import AsyncMock
1841+
1842+ async def _inner () -> None :
1843+ rollback_started = asyncio .Event ()
1844+ calls = {"n" : 0 }
1845+
1846+ async def rollback (req , timeout = None ):
1847+ calls ["n" ] += 1
1848+ if calls ["n" ] == 1 :
1849+ rollback_started .set ()
1850+ await asyncio .sleep (10 )
1851+ return cypher_pb2 .RollbackTransactionResponse ()
1852+
1853+ client = _async_client (
1854+ CommitTransaction = AsyncMock (side_effect = _TransportError (grpc .StatusCode .DEADLINE_EXCEEDED )),
1855+ RollbackTransaction = AsyncMock (side_effect = rollback ),
1856+ )
1857+
1858+ async def run_block () -> None :
1859+ async with client .transaction () as tx :
1860+ await tx .cypher ("CREATE (:A)" )
1861+ with ctx_suppress (grpc .RpcError ):
1862+ await tx .commit ()
1863+
1864+ t = asyncio .create_task (run_block ())
1865+ await rollback_started .wait ()
1866+ t .cancel ()
1867+ with pytest .raises (asyncio .CancelledError ):
1868+ await t
1869+ await client .close () # drains the detached retry
1870+ assert calls ["n" ] == 2 , "the interrupted cleanup was never retried"
1871+
1872+ asyncio .run (_inner ())
1873+
1874+
1875+ class TestAbandonedInFlightCommitIsContested :
1876+ """A block that starts commit() in a background task and then raises has
1877+ asked for a rollback it can no longer perform itself: the exit must send
1878+ the best-effort rollback to CONTEST the in-flight commit, so the server
1879+ race decides the outcome instead of the commit silently applying despite
1880+ the exception."""
1881+
1882+ def test_exceptional_exit_sends_a_contesting_rollback (self ):
1883+ from unittest .mock import AsyncMock
1884+
1885+ async def _inner () -> None :
1886+ commit_in_flight = asyncio .Event ()
1887+ release_commit = asyncio .Event ()
1888+
1889+ async def gated_commit (req , timeout = None ):
1890+ commit_in_flight .set ()
1891+ await release_commit .wait ()
1892+ return cypher_pb2 .CommitTransactionResponse (applied_index = 7 )
1893+
1894+ client = _async_client (CommitTransaction = AsyncMock (side_effect = gated_commit ))
1895+ bg = None
1896+ with pytest .raises (ValueError ):
1897+ async with client .transaction () as tx :
1898+ await tx .cypher ("CREATE (:A)" )
1899+ bg = asyncio .create_task (tx .commit ())
1900+ await commit_in_flight .wait ()
1901+ raise ValueError ("boom" )
1902+ await asyncio .sleep (0.02 ) # the detached contesting rollback runs
1903+ assert client ._cypher_stub .RollbackTransaction .await_count == 1 , (
1904+ "an abandoned in-flight commit was left uncontested"
1905+ )
1906+ release_commit .set ()
1907+ await bg # the fake server lets the commit win; that is its call
1908+ await client .close ()
1909+
1910+ asyncio .run (_inner ())
1911+
1912+
1913+ class TestFailedExplicitRollbackStaysRetriable :
1914+ """An explicit rollback() whose request is lost in transit must not leave
1915+ the handle permanently rolled_back: the server may still hold the
1916+ transaction, so once connectivity recovers a later rollback() has to be
1917+ able to retry instead of being rejected as already finished."""
1918+
1919+ def test_rollback_can_be_retried_after_a_transport_failure (self ):
1920+ from unittest .mock import AsyncMock
1921+
1922+ async def _inner () -> None :
1923+ rollback = AsyncMock (
1924+ side_effect = [
1925+ _TransportError (grpc .StatusCode .UNAVAILABLE ),
1926+ cypher_pb2 .RollbackTransactionResponse (),
1927+ ]
1928+ )
1929+ client = _async_client (RollbackTransaction = rollback )
1930+ tx = await client .begin_transaction ()
1931+ with pytest .raises (_TransportError ):
1932+ await tx .rollback ()
1933+ assert tx .is_open is False , "a failed rollback left the handle usable"
1934+ await tx .rollback () # connectivity recovered; the retry must go through
1935+ assert client ._cypher_stub .RollbackTransaction .await_count == 2
1936+ await client .close ()
1937+
1938+ asyncio .run (_inner ())
1939+
1940+
1941+ class TestSyncStatementInterruptedInsideTheTask :
1942+ """KeyboardInterrupt raised while the statement coroutine itself is
1943+ stepping completes the task before _run() can cancel-and-drain it, so no
1944+ async handler closes the handle; the sync wrapper must then settle the
1945+ outcome conservatively instead of leaving the handle parked in
1946+ "executing" forever."""
1947+
1948+ def test_interrupted_statement_closes_the_handle (self ):
1949+ from unittest .mock import AsyncMock
1950+
1951+ async def ki (req , timeout = None ):
1952+ raise KeyboardInterrupt
1953+
1954+ client = _sync_client (ExecuteCypher = AsyncMock (side_effect = ki ))
1955+ tx = client .begin_transaction ()
1956+ with pytest .raises (KeyboardInterrupt ):
1957+ tx .cypher ("CREATE (:A)" )
1958+ assert tx ._inner ._state == "aborted" , "the interruption left the handle parked in-flight"
1959+ # The server may still hold the transaction; an explicit rollback
1960+ # must send the cleanup rather than trusting one that never ran.
1961+ tx .rollback ()
1962+ assert client ._async ._cypher_stub .RollbackTransaction .await_count == 1
0 commit comments