Skip to content

Commit 7deecf2

Browse files
committed
Fix kernel async statement telemetry handle
1 parent b4828fb commit 7deecf2

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
@@ -654,6 +659,7 @@ def close_command(self, command_id: CommandId) -> None:
654659
with self._async_handles_lock:
655660
handle = self._async_handles.pop(command_id.guid, None)
656661
stmt = self._async_statements.pop(command_id.guid, None)
662+
self._async_result_stream_started.discard(command_id.guid)
657663
# Closing the handle below fires the server-side CloseStatement.
658664
# A subsequent ``get_query_state`` re-attaches by id and reads
659665
# ``CLOSED`` straight from the server — no connector-side
@@ -740,25 +746,39 @@ def get_execution_result(
740746
command_id: CommandId,
741747
cursor: "Cursor",
742748
) -> "ResultSet":
743-
# Re-attach to the statement by id and await its result. SEA keys
744-
# GetStatementResult on the id, so this works whether or not the
745-
# connector still holds the submitting handle — and it's
746-
# inherently re-callable (each call attaches a fresh handle and
747-
# re-materialises the result stream), matching the Thrift backend
748-
# where the operation handle stays re-fetchable until an explicit
749-
# close. No connector-side handle lookup, so no
750-
# ``unknown command_id`` failure on a second call.
749+
# Prefer the original owning async handle for the first
750+
# in-process result stream. The kernel attaches the real
751+
# ExecuteStatementAsync telemetry to that handle; attached
752+
# handles intentionally use no-op telemetry, so always
753+
# re-attaching loses the SEA async statement row when the result
754+
# is drained. After the owning result stream has been started,
755+
# attach by id for re-fetch. This preserves the Thrift-parity
756+
# behavior where results remain re-callable until explicit close.
751757
#
752-
# ``attach_async_statement`` issues a GetStatementStatus to seed
753-
# the handle; a 404 (unknown / aged-out id) surfaces as a
754-
# NotFound KernelError mapped to ``ProgrammingError`` below via
755-
# ``_wrap_kernel_exception``.
758+
# If this process does not hold the owning handle (fresh cursor,
759+
# restarted process, already re-fetched), ``attach_async_statement``
760+
# issues a GetStatementStatus to seed the handle; a 404 (unknown
761+
# / aged-out id) surfaces as a NotFound KernelError mapped to
762+
# ``ProgrammingError`` below via ``_wrap_kernel_exception``.
756763
if self._kernel_session is None:
757764
raise InterfaceError("get_execution_result requires an open session.")
765+
with self._async_handles_lock:
766+
handle = (
767+
None
768+
if command_id.guid in self._async_result_stream_started
769+
else self._async_handles.get(command_id.guid)
770+
)
771+
uses_owning_handle = handle is not None
772+
if uses_owning_handle:
773+
self._async_result_stream_started.add(command_id.guid)
758774
try:
759-
handle = self._kernel_session.attach_async_statement(command_id.guid)
775+
if handle is None:
776+
handle = self._kernel_session.attach_async_statement(command_id.guid)
760777
stream = handle.await_result()
761778
except Exception as exc:
779+
if uses_owning_handle:
780+
with self._async_handles_lock:
781+
self._async_result_stream_started.discard(command_id.guid)
762782
raise _wrap_kernel_exception("get_execution_result", exc) from exc
763783
# ``KernelResultSet.__init__`` calls ``arrow_schema()`` which
764784
# 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
@@ -252,13 +252,10 @@ def close(self) -> None:
252252
# connection close path stays clean.
253253
logger.warning("Error closing kernel handle: %s", exc)
254254
# Honor the base ``ResultSet`` contract: notify the backend.
255-
# ``backend.close_command`` also drops the ``_async_handles``
256-
# entry and records the guid in ``_closed_commands`` — no
257-
# separate pop needed here. Sync-execute and metadata paths
258-
# never registered in ``_async_handles`` to begin with, and
259-
# ``get_execution_result`` pops the async path before the
260-
# result set is even constructed (see the M1 fix), so this
261-
# call is the single bookkeeping seam.
255+
# For async results, ``backend.close_command`` drops the
256+
# retained owning handle and parent Statement. Sync-execute and
257+
# metadata paths never registered in ``_async_handles`` to begin
258+
# with, so this call is tolerant bookkeeping for them.
262259
backend = cast("KernelDatabricksClient", self.backend)
263260
try:
264261
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
@@ -422,9 +422,10 @@ def test_dml_rowcount_wiring_does_not_break_dml(conn):
422422

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

438439

439440
def test_async_get_execution_result_is_re_callable(conn):
440-
"""``get_async_execution_result`` re-attaches by id on each call,
441-
so fetching the same async command twice both succeed — the
442-
connector never relied on a one-shot retained handle (Thrift-parity
443-
re-fetch)."""
441+
"""Fetching the same async command twice succeeds: the first
442+
in-process result fetch can use the owning handle, and later
443+
re-fetches attach by id (Thrift-parity re-fetch)."""
444444
with conn.cursor() as cur:
445445
cur.execute_async("SELECT 11 AS n")
446446
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())])
@@ -814,10 +838,27 @@ def test_get_execution_result_attaches_by_id():
814838
rs = c.get_execution_result(cid, cursor=cursor)
815839

816840
assert rs is not None
817-
c._kernel_session.attach_async_statement.assert_called_with("async-1")
841+
c._kernel_session.attach_async_statement.assert_called_once_with("async-1")
818842
handle.await_result.assert_called_once_with()
819843

820844

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

10501091

10511092
def test_get_execution_result_is_re_callable():
1052-
"""``get_execution_result`` re-attaches by id on every call, so a
1053-
second fetch for the same async command succeeds (Thrift-parity
1054-
re-fetch). Each call attaches a fresh handle and awaits its result;
1055-
neither raises, and the connector never depended on a retained
1056-
handle. The kernel's ``await_result()`` is idempotent server-side."""
1093+
"""The first result fetch uses the owning handle for telemetry; a
1094+
second fetch for the same async command re-attaches by id so
1095+
Thrift-parity re-fetch still works."""
10571096
c = _make_client()
10581097
c._kernel_session = MagicMock()
10591098
fake_stream = MagicMock()
10601099
fake_stream.arrow_schema.return_value = pa.schema([("n", pa.int64())])
1061-
handle = MagicMock()
1062-
handle.await_result.return_value = fake_stream
1063-
c._kernel_session.attach_async_statement.return_value = handle
1100+
owning_handle = MagicMock()
1101+
owning_handle.await_result.return_value = fake_stream
1102+
attached_handle = MagicMock()
1103+
attached_handle.await_result.return_value = fake_stream
1104+
c._kernel_session.attach_async_statement.return_value = attached_handle
10641105
cid = CommandId.from_sea_statement_id("async-recall-twice")
1106+
c._async_handles[cid.guid] = owning_handle
10651107
cursor = MagicMock()
10661108
cursor.arraysize = 100
10671109
cursor.buffer_size_bytes = 1024
@@ -1070,10 +1112,11 @@ def test_get_execution_result_is_re_callable():
10701112
rs2 = c.get_execution_result(cid, cursor=cursor)
10711113

10721114
assert rs1 is not None and rs2 is not None
1073-
# Two calls -> two attaches -> two await_results. No reliance on a
1074-
# connector-tracked handle.
1075-
assert c._kernel_session.attach_async_statement.call_count == 2
1076-
assert handle.await_result.call_count == 2
1115+
owning_handle.await_result.assert_called_once_with()
1116+
c._kernel_session.attach_async_statement.assert_called_once_with(
1117+
"async-recall-twice"
1118+
)
1119+
attached_handle.await_result.assert_called_once_with()
10771120

10781121

10791122
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)