Skip to content

Commit 410999a

Browse files
committed
Fix kernel async statement telemetry handle
1 parent 0a8f1d2 commit 410999a

4 files changed

Lines changed: 109 additions & 49 deletions

File tree

src/databricks/sql/backend/kernel/client.py

Lines changed: 39 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
import logging
2626
import threading
2727
import uuid
28-
from typing import Any, Dict, List, Optional, TYPE_CHECKING, Union
28+
from typing import Any, Dict, List, Optional, Set, TYPE_CHECKING, Union
2929

3030
from databricks.sql.backend.databricks_client import DatabricksClient
3131
from databricks.sql.backend.kernel._errors import (
@@ -251,16 +251,20 @@ def __init__(
251251
# concurrent cursors on the same connection don't race on submit /
252252
# close / close-session.
253253
#
254-
# This is a KEEP-ALIVE registry, not a state/result lookup: the
254+
# This is primarily a KEEP-ALIVE registry: the
255255
# submitting ``ExecutedAsyncStatement``'s ``Drop`` fires a
256256
# fire-and-forget ``close_statement``, which would kill the
257257
# still-running async query the moment the handle is dropped. We
258258
# retain it (and its parent ``Statement``) here so the live query
259-
# survives until an explicit close. ``get_query_state`` /
260-
# ``get_execution_result`` do NOT consult this map — they
261-
# re-attach to the statement by id (the server is the source of
262-
# truth for async state), so they work even cross-process.
259+
# survives until an explicit close. ``get_query_state`` still
260+
# re-attaches to the statement by id (the server is the source
261+
# of truth for async state). ``get_execution_result`` uses this
262+
# owning handle for the first in-process result stream so kernel
263+
# async statement telemetry is finalized on the original
264+
# ``ExecuteStatementAsync`` telemetry object, then falls back to
265+
# attach-by-id for re-fetch / cross-process cases.
263266
self._async_handles: Dict[str, Any] = {}
267+
self._async_result_stream_started: Set[str] = set()
264268
# Parent ``Statement`` objects kept alive alongside async handles.
265269
# On the kernel, ``Statement.close()`` flips the validity flag on
266270
# the produced executed handle (see kernel
@@ -403,6 +407,7 @@ def close_session(self, session_id: SessionId) -> None:
403407
tracked_stmts = list(self._async_statements.items())
404408
self._async_handles.clear()
405409
self._async_statements.clear()
410+
self._async_result_stream_started.clear()
406411
for _, handle in tracked:
407412
# Per-handle close errors are non-fatal — PEP 249
408413
# discourages raising from session close — so log and
@@ -655,6 +660,7 @@ def close_command(self, command_id: CommandId) -> None:
655660
with self._async_handles_lock:
656661
handle = self._async_handles.pop(command_id.guid, None)
657662
stmt = self._async_statements.pop(command_id.guid, None)
663+
self._async_result_stream_started.discard(command_id.guid)
658664
# Closing the handle below fires the server-side CloseStatement.
659665
# A subsequent ``get_query_state`` re-attaches by id and reads
660666
# ``CLOSED`` straight from the server — no connector-side
@@ -741,25 +747,39 @@ def get_execution_result(
741747
command_id: CommandId,
742748
cursor: "Cursor",
743749
) -> "ResultSet":
744-
# Re-attach to the statement by id and await its result. SEA keys
745-
# GetStatementResult on the id, so this works whether or not the
746-
# connector still holds the submitting handle — and it's
747-
# inherently re-callable (each call attaches a fresh handle and
748-
# re-materialises the result stream), matching the Thrift backend
749-
# where the operation handle stays re-fetchable until an explicit
750-
# close. No connector-side handle lookup, so no
751-
# ``unknown command_id`` failure on a second call.
750+
# Prefer the original owning async handle for the first
751+
# in-process result stream. The kernel attaches the real
752+
# ExecuteStatementAsync telemetry to that handle; attached
753+
# handles intentionally use no-op telemetry, so always
754+
# re-attaching loses the SEA async statement row when the result
755+
# is drained. After the owning result stream has been started,
756+
# attach by id for re-fetch. This preserves the Thrift-parity
757+
# behavior where results remain re-callable until explicit close.
752758
#
753-
# ``attach_async_statement`` issues a GetStatementStatus to seed
754-
# the handle; a 404 (unknown / aged-out id) surfaces as a
755-
# NotFound KernelError mapped to ``ProgrammingError`` below via
756-
# ``_wrap_kernel_exception``.
759+
# If this process does not hold the owning handle (fresh cursor,
760+
# restarted process, already re-fetched), ``attach_async_statement``
761+
# issues a GetStatementStatus to seed the handle; a 404 (unknown
762+
# / aged-out id) surfaces as a NotFound KernelError mapped to
763+
# ``ProgrammingError`` below via ``_wrap_kernel_exception``.
757764
if self._kernel_session is None:
758765
raise InterfaceError("get_execution_result requires an open session.")
766+
with self._async_handles_lock:
767+
handle = (
768+
None
769+
if command_id.guid in self._async_result_stream_started
770+
else self._async_handles.get(command_id.guid)
771+
)
772+
uses_owning_handle = handle is not None
773+
if uses_owning_handle:
774+
self._async_result_stream_started.add(command_id.guid)
759775
try:
760-
handle = self._kernel_session.attach_async_statement(command_id.guid)
776+
if handle is None:
777+
handle = self._kernel_session.attach_async_statement(command_id.guid)
761778
stream = handle.await_result()
762779
except Exception as exc:
780+
if uses_owning_handle:
781+
with self._async_handles_lock:
782+
self._async_result_stream_started.discard(command_id.guid)
763783
raise _wrap_kernel_exception("get_execution_result", exc) from exc
764784
# ``KernelResultSet.__init__`` calls ``arrow_schema()`` which
765785
# can raise — map that to PEP 249 too.

src/databricks/sql/backend/kernel/result_set.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -258,13 +258,10 @@ def close(self) -> None:
258258
# connection close path stays clean.
259259
logger.warning("Error closing kernel handle: %s", exc)
260260
# Honor the base ``ResultSet`` contract: notify the backend.
261-
# ``backend.close_command`` also drops the ``_async_handles``
262-
# entry and records the guid in ``_closed_commands`` — no
263-
# separate pop needed here. Sync-execute and metadata paths
264-
# never registered in ``_async_handles`` to begin with, and
265-
# ``get_execution_result`` pops the async path before the
266-
# result set is even constructed (see the M1 fix), so this
267-
# call is the single bookkeeping seam.
261+
# For async results, ``backend.close_command`` drops the
262+
# retained owning handle and parent Statement. Sync-execute and
263+
# metadata paths never registered in ``_async_handles`` to begin
264+
# with, so this call is tolerant bookkeeping for them.
268265
backend = cast("KernelDatabricksClient", self.backend)
269266
try:
270267
backend.close_command(self.command_id)

tests/e2e/test_kernel_backend.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -421,9 +421,10 @@ def test_dml_rowcount_wiring_does_not_break_dml(conn):
421421

422422
def test_async_execute_polls_and_fetches_result(conn):
423423
"""The full async CUJ: ``execute_async`` → poll
424-
``get_query_state`` → ``get_async_execution_result``. State and
425-
result are read from the server by re-attaching to the statement
426-
id (no connector-side state)."""
424+
``get_query_state`` → ``get_async_execution_result``. State comes
425+
from the server by re-attaching to the statement id; first
426+
in-process result fetch uses the retained owning handle so kernel
427+
async telemetry is finalized."""
427428
with conn.cursor() as cur:
428429
cur.execute_async("SELECT 7 AS n")
429430
cur.get_async_execution_result() # polls to terminal, fetches
@@ -436,10 +437,9 @@ def test_async_execute_polls_and_fetches_result(conn):
436437

437438

438439
def test_async_get_execution_result_is_re_callable(conn):
439-
"""``get_async_execution_result`` re-attaches by id on each call,
440-
so fetching the same async command twice both succeed — the
441-
connector never relied on a one-shot retained handle (Thrift-parity
442-
re-fetch)."""
440+
"""Fetching the same async command twice succeeds: the first
441+
in-process result fetch can use the owning handle, and later
442+
re-fetches attach by id (Thrift-parity re-fetch)."""
443443
with conn.cursor() as cur:
444444
cur.execute_async("SELECT 11 AS n")
445445
cur.get_async_execution_result()

tests/unit/test_kernel_client.py

Lines changed: 59 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -799,9 +799,33 @@ def test_get_query_state_propagates_non_not_found_error():
799799
c.get_query_state(cid)
800800

801801

802-
def test_get_execution_result_attaches_by_id():
803-
"""``get_execution_result`` re-attaches to the statement by id and
804-
awaits its result — no connector-side handle lookup."""
802+
def test_get_execution_result_uses_retained_owning_handle_first():
803+
"""The first in-process result fetch uses the retained submitting
804+
handle so the kernel finalizes the original async statement telemetry."""
805+
c = _make_client()
806+
fake_stream = MagicMock()
807+
fake_stream.arrow_schema.return_value = pa.schema([("n", pa.int64())])
808+
handle = MagicMock()
809+
handle.await_result.return_value = fake_stream
810+
cursor = MagicMock()
811+
cursor.arraysize = 100
812+
cursor.buffer_size_bytes = 1024
813+
cursor.row_limit = 5
814+
cid = CommandId.from_sea_statement_id("async-1")
815+
c._async_handles[cid.guid] = handle
816+
817+
rs = c.get_execution_result(cid, cursor=cursor)
818+
819+
assert rs is not None
820+
assert rs._row_limit == 5
821+
c._kernel_session.attach_async_statement.assert_not_called()
822+
handle.await_result.assert_called_once_with()
823+
assert cid.guid in c._async_result_stream_started
824+
825+
826+
def test_get_execution_result_attaches_by_id_when_no_retained_handle():
827+
"""Fallback by statement id keeps cross-process / fresh-cursor
828+
result retrieval working when this connector lacks the owning handle."""
805829
c = _make_client()
806830
fake_stream = MagicMock()
807831
fake_stream.arrow_schema.return_value = pa.schema([("n", pa.int64())])
@@ -816,10 +840,27 @@ def test_get_execution_result_attaches_by_id():
816840

817841
assert rs is not None
818842
assert rs._row_limit == 5
819-
c._kernel_session.attach_async_statement.assert_called_with("async-1")
843+
c._kernel_session.attach_async_statement.assert_called_once_with("async-1")
820844
handle.await_result.assert_called_once_with()
821845

822846

847+
def test_get_execution_result_owning_handle_failure_can_retry_owning_handle():
848+
"""If the owning handle's await fails before producing a result
849+
stream, clear the claimed marker so a retry can still use the
850+
telemetry-bearing owning handle."""
851+
c = _make_client()
852+
handle = MagicMock()
853+
handle.await_result.side_effect = _FakeKernelError(code="Unavailable")
854+
cid = CommandId.from_sea_statement_id("async-retry-owning")
855+
c._async_handles[cid.guid] = handle
856+
857+
with pytest.raises(OperationalError):
858+
c.get_execution_result(cid, cursor=MagicMock())
859+
860+
assert cid.guid not in c._async_result_stream_started
861+
c._kernel_session.attach_async_statement.assert_not_called()
862+
863+
823864
def test_get_execution_result_maps_not_found_to_programming_error():
824865
"""An unknown / aged-out id surfaces the kernel's NotFound as a
825866
mapped PEP 249 exception rather than a raw error."""
@@ -1051,19 +1092,20 @@ def test_kernel_error_during_result_set_construction_is_mapped():
10511092

10521093

10531094
def test_get_execution_result_is_re_callable():
1054-
"""``get_execution_result`` re-attaches by id on every call, so a
1055-
second fetch for the same async command succeeds (Thrift-parity
1056-
re-fetch). Each call attaches a fresh handle and awaits its result;
1057-
neither raises, and the connector never depended on a retained
1058-
handle. The kernel's ``await_result()`` is idempotent server-side."""
1095+
"""The first result fetch uses the owning handle for telemetry; a
1096+
second fetch for the same async command re-attaches by id so
1097+
Thrift-parity re-fetch still works."""
10591098
c = _make_client()
10601099
c._kernel_session = MagicMock()
10611100
fake_stream = MagicMock()
10621101
fake_stream.arrow_schema.return_value = pa.schema([("n", pa.int64())])
1063-
handle = MagicMock()
1064-
handle.await_result.return_value = fake_stream
1065-
c._kernel_session.attach_async_statement.return_value = handle
1102+
owning_handle = MagicMock()
1103+
owning_handle.await_result.return_value = fake_stream
1104+
attached_handle = MagicMock()
1105+
attached_handle.await_result.return_value = fake_stream
1106+
c._kernel_session.attach_async_statement.return_value = attached_handle
10661107
cid = CommandId.from_sea_statement_id("async-recall-twice")
1108+
c._async_handles[cid.guid] = owning_handle
10671109
cursor = MagicMock()
10681110
cursor.arraysize = 100
10691111
cursor.buffer_size_bytes = 1024
@@ -1073,10 +1115,11 @@ def test_get_execution_result_is_re_callable():
10731115
rs2 = c.get_execution_result(cid, cursor=cursor)
10741116

10751117
assert rs1 is not None and rs2 is not None
1076-
# Two calls -> two attaches -> two await_results. No reliance on a
1077-
# connector-tracked handle.
1078-
assert c._kernel_session.attach_async_statement.call_count == 2
1079-
assert handle.await_result.call_count == 2
1118+
owning_handle.await_result.assert_called_once_with()
1119+
c._kernel_session.attach_async_statement.assert_called_once_with(
1120+
"async-recall-twice"
1121+
)
1122+
attached_handle.await_result.assert_called_once_with()
10801123

10811124

10821125
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)