Skip to content

Commit 28f251b

Browse files
committed
fix: isolate cursor results per execution and stop cancelling completed queries (WBC-922)
1 parent 109538b commit 28f251b

2 files changed

Lines changed: 137 additions & 8 deletions

File tree

tests/test_cursor.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,19 @@
66
2. Pyformat parameter substitution (%(name)s) works correctly with
77
type-aware SQL quoting.
88
3. Unknown parameter keys raise ProgrammingError.
9+
4. Fetches only ever observe the most recent execution's result set, and
10+
re-executing never cancels an already-completed statement (WBC-922).
911
"""
1012

1113
from datetime import date
1214

15+
import pandas
1316
import pytest
1417
from unittest.mock import MagicMock
1518

1619
from wherobots.db.cursor import Cursor, _substitute_parameters, _quote_value
1720
from wherobots.db.errors import ProgrammingError
21+
from wherobots.db.models import ExecutionResult
1822

1923

2024
def _make_cursor():
@@ -211,6 +215,114 @@ def test_unknown_parameter_raises(self):
211215
cursor.execute(sql, parameters={"id": 42})
212216

213217

218+
# ---------------------------------------------------------------------------
219+
# Result isolation and cancellation tests (WBC-922)
220+
# ---------------------------------------------------------------------------
221+
222+
223+
def _make_async_cursor():
224+
"""Create a Cursor whose exec_fn records each execution's handler.
225+
226+
Tests deliver results by invoking a recorded handler, mimicking the
227+
connection's asynchronous result callbacks.
228+
"""
229+
handlers = []
230+
231+
def exec_fn(sql, handler, store):
232+
handlers.append(handler)
233+
return f"exec-{len(handlers)}"
234+
235+
cancel_fn = MagicMock()
236+
return Cursor(exec_fn, cancel_fn), handlers, cancel_fn
237+
238+
239+
class TestCursorResultIsolation:
240+
"""Fetches must only observe the most recent execution's result set."""
241+
242+
def test_unfetched_result_does_not_leak_into_next_execute(self):
243+
cursor, handlers, _ = _make_async_cursor()
244+
245+
cursor.execute("SELECT 1")
246+
handlers[0](ExecutionResult(results=pandas.DataFrame({"x": [1]})))
247+
248+
# Re-execute without fetching the first result.
249+
cursor.execute("SELECT 2")
250+
handlers[1](ExecutionResult(results=pandas.DataFrame({"x": [2]})))
251+
252+
assert cursor.fetchall()["x"].tolist() == [2]
253+
254+
def test_late_result_from_superseded_execution_is_ignored(self):
255+
cursor, handlers, _ = _make_async_cursor()
256+
257+
cursor.execute("SELECT 1")
258+
# First query still in flight when the second is executed.
259+
cursor.execute("SELECT 2")
260+
261+
# The first query's result arrives late (e.g. the empty result the
262+
# connection delivers for a cancelled query), then the second's.
263+
handlers[0](ExecutionResult(results=pandas.DataFrame()))
264+
handlers[1](ExecutionResult(results=pandas.DataFrame({"x": [2]})))
265+
266+
assert cursor.fetchall()["x"].tolist() == [2]
267+
268+
def test_fetch_after_fetch_returns_same_results(self):
269+
cursor, handlers, _ = _make_async_cursor()
270+
271+
cursor.execute("SELECT 1")
272+
handlers[0](ExecutionResult(results=pandas.DataFrame({"x": [1]})))
273+
274+
assert cursor.fetchall()["x"].tolist() == [1]
275+
assert cursor.fetchall()["x"].tolist() == [1]
276+
277+
278+
class TestCursorCancellation:
279+
"""Only genuinely in-flight executions may be cancelled."""
280+
281+
def test_execute_cancels_in_flight_previous_query(self):
282+
cursor, _, cancel_fn = _make_async_cursor()
283+
284+
cursor.execute("SELECT 1")
285+
cursor.execute("SELECT 2")
286+
287+
cancel_fn.assert_called_once_with("exec-1")
288+
289+
def test_execute_does_not_cancel_completed_previous_query(self):
290+
cursor, handlers, cancel_fn = _make_async_cursor()
291+
292+
cursor.execute("MERGE INTO t USING s ON t.id = s.id ...")
293+
handlers[0](ExecutionResult(results=pandas.DataFrame()))
294+
295+
# The DML completed (result queued, not fetched); executing another
296+
# statement must not attempt to cancel it.
297+
cursor.execute("SELECT 1")
298+
299+
cancel_fn.assert_not_called()
300+
301+
def test_close_cancels_in_flight_query(self):
302+
cursor, _, cancel_fn = _make_async_cursor()
303+
304+
cursor.execute("SELECT 1")
305+
cursor.close()
306+
307+
cancel_fn.assert_called_once_with("exec-1")
308+
309+
def test_close_does_not_cancel_completed_query(self):
310+
cursor, handlers, cancel_fn = _make_async_cursor()
311+
312+
cursor.execute("SELECT 1")
313+
handlers[0](ExecutionResult(results=pandas.DataFrame({"x": [1]})))
314+
cursor.close()
315+
316+
cancel_fn.assert_not_called()
317+
318+
def test_close_without_execute_does_not_cancel(self):
319+
cursor, _, cancel_fn = _make_async_cursor()
320+
321+
cursor.close()
322+
323+
cancel_fn.assert_not_called()
324+
325+
214326
# ---------------------------------------------------------------------------
215327
# _substitute_parameters unit tests
216328
# ---------------------------------------------------------------------------

wherobots/db/cursor.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,19 @@ def description(self) -> List[Tuple] | None:
9393
def rowcount(self) -> int:
9494
return self.__rowcount
9595

96-
def __on_execution_result(self, result) -> None:
97-
self.__queue.put(result)
96+
def __in_flight_execution_id(self) -> str | None:
97+
"""The current execution's id if its result has not yet arrived.
98+
99+
Once a result has been fetched (or is waiting in the queue), the
100+
execution is complete and must not be cancelled.
101+
"""
102+
if (
103+
self.__current_execution_id is not None
104+
and self.__results is None
105+
and self.__queue.empty()
106+
):
107+
return self.__current_execution_id
108+
return None
98109

99110
def __get_results(self) -> List[Tuple[Any, ...]] | None:
100111
if not self.__current_execution_id:
@@ -140,9 +151,14 @@ def execute(
140151
parameters: Dict[str, Any] | None = None,
141152
store: Store | None = None,
142153
) -> None:
143-
if self.__current_execution_id:
144-
self.__cancel_fn(self.__current_execution_id)
145-
154+
in_flight = self.__in_flight_execution_id()
155+
if in_flight:
156+
self.__cancel_fn(in_flight)
157+
158+
# Each execution gets its own queue, and the handler closes over it:
159+
# a late result from a superseded execution lands in the orphaned
160+
# queue and can never be observed by fetches of the current one.
161+
self.__queue = queue.Queue()
146162
self.__results = None
147163
self.__store_result = None
148164
self.__current_row = 0
@@ -151,7 +167,7 @@ def execute(
151167

152168
self.__current_execution_id = self.__exec_fn(
153169
_substitute_parameters(operation, parameters),
154-
self.__on_execution_result,
170+
self.__queue.put,
155171
store,
156172
)
157173

@@ -193,8 +209,9 @@ def fetchall(self) -> List[Any]:
193209

194210
def close(self) -> None:
195211
"""Close the cursor."""
196-
if self.__results is None and self.__current_execution_id:
197-
self.__cancel_fn(self.__current_execution_id)
212+
in_flight = self.__in_flight_execution_id()
213+
if in_flight:
214+
self.__cancel_fn(in_flight)
198215

199216
def __iter__(self):
200217
return self

0 commit comments

Comments
 (0)