Skip to content

Commit 97acee8

Browse files
zahariashclaude
authored andcommitted
fix(async): propagate cancellation and interrupt only the cancelled query
AsyncConnection.execute() caught asyncio.CancelledError, interrupted the connection and returned None. As a result: - a cancelled task looked like a normal return, so loops kept running and asyncio.wait_for()/asyncio.timeout() returned None instead of raising TimeoutError; - cancelling a task that was still queued interrupted whatever query was running on its connection, i.e. another task's query; - the connection counter was released when the awaiting task exited, while the worker could still be running the query. Now the call is submitted to the executor directly and CancelledError is re-raised. Calls on one connection run one at a time (the engine serializes them anyway), and the connection is interrupted only while the cancelled call is the one running on it. A cancelled call that has not started is skipped. The interrupt is repeated until the call returns, because an interrupt that arrives while the query is still compiling is cleared when execution starts. The counter is released from the executor future's done callback. execute() with a PreparedStatement whose connection is not part of the pool no longer raises UnboundLocalError. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent ec0be15 commit 97acee8

2 files changed

Lines changed: 178 additions & 32 deletions

File tree

‎src_py/async_connection.py‎

Lines changed: 82 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import asyncio
44
import threading
55
import warnings
6+
import weakref
67
from concurrent.futures import ThreadPoolExecutor
78
from typing import TYPE_CHECKING, Any
89

@@ -51,6 +52,14 @@ def __init__(
5152
self.connections = [Connection(database) for _ in range(max_concurrent_queries)]
5253
self.connections_counter = [0 for _ in range(max_concurrent_queries)]
5354
self.lock = threading.Lock()
55+
# Calls on one connection run one at a time (the engine serializes them
56+
# anyway), so the call holding a connection's lock is the one an
57+
# interrupt would hit. Queries run directly on a connection obtained
58+
# from acquire_connection() are not tracked.
59+
self._connection_locks: weakref.WeakKeyDictionary[
60+
Connection, threading.Lock
61+
] = weakref.WeakKeyDictionary()
62+
self._running: dict[Connection, _Call] = {}
5463

5564
for conn in self.connections:
5665
conn.init_connection()
@@ -114,10 +123,9 @@ def release_connection(self, conn: Connection) -> None:
114123
115124
116125
"""
117-
for i, existing_conn in enumerate(self.connections):
118-
if existing_conn == conn:
119-
self.__decrement_connection_counter(i)
120-
break
126+
conn_index = self.__index_of(conn)
127+
if conn_index is not None:
128+
self.__decrement_connection_counter(conn_index)
121129

122130
def set_query_timeout(self, timeout_in_ms: int) -> None:
123131
"""
@@ -153,28 +161,22 @@ async def execute(
153161
QueryResult
154162
Query result.
155163
164+
Raises
165+
------
166+
asyncio.CancelledError
167+
If the awaiting task is cancelled. A running query is interrupted; a
168+
query still waiting for a free connection is not started.
169+
156170
"""
157-
loop = asyncio.get_running_loop()
158-
# If the query is a prepared statement, use the connection associated with it
159171
if isinstance(query, PreparedStatement):
160172
conn = query._connection
161-
for i, existing_conn in enumerate(self.connections):
162-
if existing_conn == conn:
163-
conn_index = i
164-
with self.lock:
165-
self.connections_counter[conn_index] += 1
166-
break
173+
conn_index = self.__index_of(conn)
174+
if conn_index is not None:
175+
with self.lock:
176+
self.connections_counter[conn_index] += 1
167177
else:
168178
conn, conn_index = self.__get_connection_with_least_queries()
169-
170-
try:
171-
return await loop.run_in_executor(
172-
self.executor, conn.execute, query, parameters
173-
)
174-
except asyncio.CancelledError:
175-
conn.interrupt()
176-
finally:
177-
self.__decrement_connection_counter(conn_index)
179+
return await self.__submit(conn, conn_index, conn.execute, query, parameters)
178180

179181
async def _prepare(
180182
self, query: str, parameters: dict[str, Any] | None = None
@@ -183,16 +185,64 @@ async def _prepare(
183185
The only parameters supported during prepare are dataframes.
184186
Any remaining parameters will be ignored and should be passed to execute().
185187
""" # noqa: D401
186-
loop = asyncio.get_running_loop()
187188
conn, conn_index = self.__get_connection_with_least_queries()
189+
return await self.__submit(conn, conn_index, conn._prepare, query, parameters)
188190

191+
def __index_of(self, conn: Connection) -> int | None:
192+
for i, existing_conn in enumerate(self.connections):
193+
if existing_conn is conn:
194+
return i
195+
return None
196+
197+
async def __submit(
198+
self, conn: Connection, conn_index: int | None, fn: Any, *args: Any
199+
) -> Any:
200+
call = _Call()
189201
try:
190-
preparedStatement = await loop.run_in_executor(
191-
self.executor, conn.prepare, query, parameters
202+
future = self.executor.submit(self.__run, conn, call, fn, *args)
203+
except RuntimeError:
204+
if conn_index is not None:
205+
self.__decrement_connection_counter(conn_index)
206+
raise
207+
if conn_index is not None:
208+
# Release the slot when the work has actually finished (or was
209+
# cancelled before it started), not when the awaiting task exits.
210+
future.add_done_callback(
211+
lambda _: self.__decrement_connection_counter(conn_index)
192212
)
193-
return preparedStatement
194-
finally:
195-
self.__decrement_connection_counter(conn_index)
213+
try:
214+
return await asyncio.wrap_future(future)
215+
except asyncio.CancelledError:
216+
self.__cancel(conn, call)
217+
raise
218+
219+
def __run(self, conn: Connection, call: _Call, fn: Any, *args: Any) -> Any:
220+
with self.lock:
221+
connection_lock = self._connection_locks.setdefault(conn, threading.Lock())
222+
with connection_lock:
223+
with self.lock:
224+
if call.cancelled:
225+
return None
226+
self._running[conn] = call
227+
try:
228+
return fn(*args)
229+
finally:
230+
with self.lock:
231+
del self._running[conn]
232+
233+
def __cancel(self, conn: Connection, call: _Call) -> None:
234+
# Interrupting acts on whatever the connection is running, so only
235+
# interrupt while this call is the one running. A call still waiting
236+
# for its connection is skipped when its turn comes.
237+
with self.lock:
238+
call.cancelled = True
239+
running = self._running.get(conn) is call
240+
if running:
241+
conn.interrupt()
242+
if running:
243+
# An interrupt that arrives while the query is still compiling is
244+
# cleared when execution starts, so repeat it until the call returns.
245+
asyncio.get_running_loop().call_later(0.05, self.__cancel, conn, call)
196246

197247
async def prepare(
198248
self, query: str, parameters: dict[str, Any] | None = None
@@ -232,3 +282,8 @@ def close(self) -> None:
232282
conn.close()
233283

234284
self.executor.shutdown(wait=True)
285+
286+
287+
class _Call:
288+
def __init__(self) -> None:
289+
self.cancelled = False

‎test/test_async_connection.py‎

Lines changed: 96 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import asyncio
22
import time
3+
from typing import Any
34

45
import ladybug as lb
56
import pyarrow as pa
@@ -159,12 +160,102 @@ def test_acquire_connection(async_connection_readonly):
159160
assert i == 0
160161

161162

163+
# range() is bound once and the two UNWINDs multiply it, so these run for long
164+
# enough to cancel mid-flight without materializing large lists.
165+
LONG_QUERY = "WITH range(1, 30000) AS r UNWIND r AS x UNWIND r AS y RETURN sum(x * y)"
166+
167+
168+
async def _wait_until_idle(
169+
async_connection: lb.AsyncConnection, timeout: float = 5.0
170+
) -> None:
171+
deadline = time.monotonic() + timeout
172+
while any(async_connection.connections_counter):
173+
assert time.monotonic() < deadline, async_connection.connections_counter
174+
await asyncio.sleep(0.01)
175+
176+
162177
@pytest.mark.asyncio
163-
async def test_async_connection_interrupt(async_connection_readonly) -> None:
164-
query = "UNWIND RANGE(1,1000000) AS x UNWIND RANGE(1, 1000000) AS y RETURN COUNT(x + y);"
165-
async_connection_readonly.set_query_timeout(100 * 1000)
166-
task = asyncio.create_task(async_connection_readonly.execute(query))
167-
time.sleep(5)
178+
async def test_async_cancel_running_query_raises(
179+
async_connection_readonly: lb.AsyncConnection,
180+
) -> None:
181+
task = asyncio.create_task(async_connection_readonly.execute(LONG_QUERY))
182+
await asyncio.sleep(0.3)
168183
task.cancel()
169184
with pytest.raises(asyncio.CancelledError):
170185
await task
186+
assert task.cancelled()
187+
# The query was interrupted, so its connection frees up long before the
188+
# query could have finished on its own.
189+
await _wait_until_idle(async_connection_readonly)
190+
191+
192+
@pytest.mark.asyncio
193+
async def test_async_wait_for_timeout_raises(
194+
async_connection_readonly: lb.AsyncConnection,
195+
) -> None:
196+
with pytest.raises(asyncio.TimeoutError):
197+
await asyncio.wait_for(async_connection_readonly.execute(LONG_QUERY), 0.3)
198+
await _wait_until_idle(async_connection_readonly)
199+
200+
201+
@pytest.mark.asyncio
202+
async def test_async_cancel_queued_query_spares_running_query(
203+
async_connection_readonly: lb.AsyncConnection,
204+
) -> None:
205+
async_connection = lb.AsyncConnection(
206+
async_connection_readonly.database,
207+
max_concurrent_queries=1,
208+
max_threads_per_query=4,
209+
)
210+
try:
211+
running = asyncio.create_task(async_connection.execute(LONG_QUERY))
212+
queued = asyncio.create_task(async_connection.execute("RETURN 1;"))
213+
await asyncio.sleep(0.1)
214+
queued.cancel()
215+
await asyncio.sleep(0.1)
216+
running_alive = not running.done()
217+
running.cancel()
218+
outcomes = await asyncio.gather(queued, running, return_exceptions=True)
219+
assert all(isinstance(o, asyncio.CancelledError) for o in outcomes), outcomes
220+
assert running_alive
221+
await _wait_until_idle(async_connection)
222+
finally:
223+
async_connection.close()
224+
225+
226+
@pytest.mark.asyncio
227+
async def test_async_cancel_during_compile_still_interrupts(
228+
async_connection_readonly: lb.AsyncConnection,
229+
) -> None:
230+
# An interrupt sent before execution starts is cleared by the engine, so a
231+
# cancel that lands in that window must be repeated.
232+
conn = async_connection_readonly.connections[0]
233+
execute = conn.execute
234+
235+
def slow_start(*args: Any, **kwargs: Any) -> Any:
236+
time.sleep(0.2)
237+
return execute(*args, **kwargs)
238+
239+
conn.execute = slow_start
240+
task = asyncio.create_task(async_connection_readonly.execute(LONG_QUERY))
241+
await asyncio.sleep(0.1)
242+
task.cancel()
243+
with pytest.raises(asyncio.CancelledError):
244+
await task
245+
started = time.monotonic()
246+
await _wait_until_idle(async_connection_readonly)
247+
assert time.monotonic() - started < 2
248+
249+
250+
@pytest.mark.asyncio
251+
async def test_async_execute_prepared_statement_from_other_connection(
252+
async_connection_readonly: lb.AsyncConnection,
253+
) -> None:
254+
conn = lb.Connection(async_connection_readonly.database)
255+
prepared_statement = conn._prepare("RETURN $x;")
256+
result = await async_connection_readonly.execute(prepared_statement, {"x": 7})
257+
assert result.get_next() == [7]
258+
result.close()
259+
for i in async_connection_readonly.connections_counter:
260+
assert i == 0
261+
conn.close()

0 commit comments

Comments
 (0)