Skip to content

Commit 5856064

Browse files
author
peco-engineer-bot[bot]
committed
Cursor.close() leaks server-side handle for async commands that were never fetched (#791)
Signed-off-by: peco-engineer-bot[bot] <3815206+peco-engineer-bot[bot]@users.noreply.github.com>
1 parent 9f81d35 commit 5856064

3 files changed

Lines changed: 99 additions & 1 deletion

File tree

src/databricks/sql/client.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1757,9 +1757,20 @@ def cancel(self) -> None:
17571757
def close(self) -> None:
17581758
"""Close cursor"""
17591759
self.open = False
1760-
self.active_command_id = None
17611760
if self.active_result_set:
17621761
self._close_and_clear_active_result_set()
1762+
elif self.active_command_id is not None:
1763+
# Async submission that never had get_async_execution_result called:
1764+
# the result-set close path didn't fire, so the server-side handle
1765+
# would leak until the session closes. Issue an explicit
1766+
# close_command to free it. Safe across all backends (Thrift sends
1767+
# TCloseOperation, SEA sends a close/cancel, kernel no-ops on
1768+
# unknown ids).
1769+
try:
1770+
self.backend.close_command(self.active_command_id)
1771+
except Exception as exc:
1772+
logger.warning("close_command on cursor close failed: %s", exc)
1773+
self.active_command_id = None
17631774

17641775
@property
17651776
def query_id(self) -> Optional[str]:

tests/e2e/test_driver.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,59 @@ def test_execute_async__large_result(self, extra_params):
342342

343343
assert len(result) == x_dimension * y_dimension
344344

345+
@pytest.mark.parametrize(
346+
"extra_params",
347+
[
348+
{},
349+
],
350+
)
351+
def test_close_frees_async_handle_without_fetch(self, extra_params):
352+
"""Regression test for #791.
353+
354+
Cursor.close() must free the server-side statement handle for an
355+
async-submitted command even when get_async_execution_result() was
356+
never called. Before the fix, close() only nulled active_command_id
357+
and (for the never-fetched case) never issued close_command, so the
358+
handle leaked until the session closed.
359+
"""
360+
long_running_query = "SELECT COUNT(*) FROM RANGE(10000 * 16) x JOIN RANGE(10000) y ON FROM_UNIXTIME(x.id * y.id, 'yyyy-MM-dd') LIKE '%not%a%date%'"
361+
with self.cursor(extra_params) as cursor:
362+
cursor.execute_async(long_running_query)
363+
364+
command_id = cursor.active_command_id
365+
backend = cursor.backend
366+
assert command_id is not None
367+
368+
# The handle is live server-side right after async submission.
369+
state = backend.get_query_state(command_id)
370+
assert state in (
371+
CommandState.PENDING,
372+
CommandState.RUNNING,
373+
CommandState.SUCCEEDED,
374+
)
375+
376+
# Close without ever fetching the result.
377+
cursor.close()
378+
379+
# The server must no longer report the handle as live. Polling the
380+
# same command id should either report a terminal CLOSED/CANCELLED
381+
# state, or raise signalling the command was closed / no longer
382+
# exists server side (DatabaseError "closed server side" or a
383+
# RESOURCE_DOES_NOT_EXIST RequestError). Any of these proves the
384+
# server-side handle was freed by close().
385+
try:
386+
post_close_state = backend.get_query_state(command_id)
387+
assert post_close_state in (
388+
CommandState.CLOSED,
389+
CommandState.CANCELLED,
390+
), f"Handle still live after close(): {post_close_state}"
391+
except (DatabaseError, RequestError) as e:
392+
assert (
393+
"closed server side" in str(e)
394+
or "does not exist" in str(e)
395+
or "RESOURCE_DOES_NOT_EXIST" in str(e)
396+
)
397+
345398

346399
# Exclude Retry tests because they require specific setups, and LargeQueries too slow for core
347400
# tests

tests/unit/test_client.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,40 @@ def test_context_manager_closes_cursor(self):
330330
cursor.close = mock_close
331331
mock_close.assert_called_once_with()
332332

333+
def test_close_frees_async_command_without_result_set(self):
334+
"""Regression test for #791.
335+
336+
When a command was submitted asynchronously and never fetched,
337+
active_result_set is None but active_command_id is set. close() must
338+
still issue backend.close_command() to free the server-side handle.
339+
"""
340+
mock_backend = Mock()
341+
cursor = client.Cursor(Mock(), mock_backend)
342+
mock_command_id = Mock()
343+
cursor.active_command_id = mock_command_id
344+
cursor.active_result_set = None
345+
346+
cursor.close()
347+
348+
mock_backend.close_command.assert_called_once_with(mock_command_id)
349+
self.assertIsNone(cursor.active_command_id)
350+
self.assertFalse(cursor.open)
351+
352+
def test_close_swallows_close_command_errors(self):
353+
"""close() must not raise if backend.close_command fails for an
354+
unfetched async command (best-effort cleanup)."""
355+
mock_backend = Mock()
356+
mock_backend.close_command.side_effect = Exception("boom")
357+
cursor = client.Cursor(Mock(), mock_backend)
358+
cursor.active_command_id = Mock()
359+
cursor.active_result_set = None
360+
361+
# Should not raise.
362+
cursor.close()
363+
364+
self.assertIsNone(cursor.active_command_id)
365+
self.assertFalse(cursor.open)
366+
333367
def dict_product(self, dicts):
334368
"""
335369
Generate cartesion product of values in input dictionary, outputting a dictionary

0 commit comments

Comments
 (0)