Skip to content

Commit e3658da

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 9775996 commit e3658da

2 files changed

Lines changed: 42 additions & 1 deletion

File tree

src/databricks/sql/client.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1759,9 +1759,18 @@ def cancel(self) -> None:
17591759
def close(self) -> None:
17601760
"""Close cursor"""
17611761
self.open = False
1762-
self.active_command_id = None
17631762
if self.active_result_set:
17641763
self._close_and_clear_active_result_set()
1764+
elif self.active_command_id is not None:
1765+
# Async submission whose result was never fetched (no
1766+
# get_execution_result call), so the result-set close path never
1767+
# fired. Issue an explicit close_command to free the server-side
1768+
# statement handle instead of leaking it until session close.
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
17651774

17661775
@property
17671776
def query_id(self) -> Optional[str]:

tests/e2e/test_driver.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,38 @@ 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_execute_async__close_without_fetch_frees_handle(self, extra_params):
352+
"""Closing a cursor whose async command result was never fetched must free
353+
the server-side statement handle (issue #791). Otherwise the handle leaks
354+
until the session closes."""
355+
with self.cursor(extra_params) as cursor:
356+
cursor.execute_async("SELECT 1")
357+
358+
# Capture the server-side command id before we close the cursor.
359+
command_id = cursor.active_command_id
360+
assert command_id is not None
361+
362+
backend = cursor.backend
363+
364+
# Sanity: the handle is live and pollable before close.
365+
backend.get_query_state(command_id)
366+
367+
# User decides not to wait for the result and closes the cursor
368+
# without ever calling get_async_execution_result().
369+
cursor.close()
370+
371+
# After close, the server-side handle must have been freed, so a
372+
# re-poll of the saved command id should raise a server error.
373+
# Pre-fix (leak): this poll succeeds. Post-fix: it raises.
374+
with pytest.raises((RequestError, OperationalError, DatabaseError)):
375+
backend.get_query_state(command_id)
376+
345377

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

0 commit comments

Comments
 (0)