Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 55 additions & 26 deletions src/databricks/sql/backend/kernel/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
import logging
import threading
import uuid
from typing import Any, Dict, List, Optional, TYPE_CHECKING, Union
from typing import Any, Dict, List, Optional, Set, TYPE_CHECKING, Union

from databricks.sql.backend.databricks_client import DatabricksClient
from databricks.sql.backend.kernel._errors import (
Expand Down Expand Up @@ -251,16 +251,19 @@ def __init__(
# concurrent cursors on the same connection don't race on submit /
# close / close-session.
#
# This is a KEEP-ALIVE registry, not a state/result lookup: the
# This is primarily a KEEP-ALIVE registry: the
# submitting ``ExecutedAsyncStatement``'s ``Drop`` fires a
# fire-and-forget ``close_statement``, which would kill the
# still-running async query the moment the handle is dropped. We
# retain it (and its parent ``Statement``) here so the live query
# survives until an explicit close. ``get_query_state`` /
# ``get_execution_result`` do NOT consult this map — they
# re-attach to the statement by id (the server is the source of
# truth for async state), so they work even cross-process.
# survives until an explicit close. ``get_query_state`` and
# ``get_execution_result`` use this owning handle before result
# streaming starts so kernel async statement telemetry is
# finalized on the original ``ExecuteStatementAsync`` telemetry
# object, then fall back to attach-by-id for re-fetch /
# cross-process cases.
self._async_handles: Dict[str, Any] = {}
self._async_result_stream_started: Set[str] = set()
# Parent ``Statement`` objects kept alive alongside async handles.
# On the kernel, ``Statement.close()`` flips the validity flag on
# the produced executed handle (see kernel
Expand Down Expand Up @@ -403,6 +406,7 @@ def close_session(self, session_id: SessionId) -> None:
tracked_stmts = list(self._async_statements.items())
self._async_handles.clear()
self._async_statements.clear()
self._async_result_stream_started.clear()
for _, handle in tracked:
# Per-handle close errors are non-fatal — PEP 249
# discourages raising from session close — so log and
Expand Down Expand Up @@ -654,6 +658,7 @@ def close_command(self, command_id: CommandId) -> None:
with self._async_handles_lock:
handle = self._async_handles.pop(command_id.guid, None)
stmt = self._async_statements.pop(command_id.guid, None)
self._async_result_stream_started.discard(command_id.guid)
# Closing the handle below fires the server-side CloseStatement.
# A subsequent ``get_query_state`` re-attaches by id and reads
# ``CLOSED`` straight from the server — no connector-side
Expand Down Expand Up @@ -683,18 +688,28 @@ def close_command(self, command_id: CommandId) -> None:
pass

def get_query_state(self, command_id: CommandId) -> CommandState:
# Server is the source of truth for async command state. Re-attach
# to the statement by its id and read the state the server reports
# — no connector-side state to drift. SEA keys GetStatementStatus
# purely on the id, so a statement the connector no longer holds a
# handle for (or never held — a different process) is still
# queryable. CLOSED comes straight from the server: after a
# Server is the source of truth for async command state. Use the
# retained owning handle before result streaming starts so kernel
# async statement telemetry is finalized on the original
# ExecuteStatementAsync telemetry object. Once result streaming
# has been claimed (or when this connector never held the handle
# — cross-process / fresh-cursor cases), re-attach to the
# statement by id. SEA keys GetStatementStatus purely on the id,
# so a statement the connector no longer holds a handle for is
# still queryable. CLOSED comes straight from the server: after a
# statement is closed (DELETE) the server still returns 200
# state=CLOSED until the result TTL elapses.
if self._kernel_session is None:
raise InterfaceError("get_query_state requires an open session.")
with self._async_handles_lock:
handle = (
None
if command_id.guid in self._async_result_stream_started
else self._async_handles.get(command_id.guid)
)
try:
handle = self._kernel_session.attach_async_statement(command_id.guid)
if handle is None:
handle = self._kernel_session.attach_async_statement(command_id.guid)
state, failure = handle.status()
except Exception as exc:
if _is_not_found(exc):
Expand Down Expand Up @@ -740,25 +755,39 @@ def get_execution_result(
command_id: CommandId,
cursor: "Cursor",
) -> "ResultSet":
# Re-attach to the statement by id and await its result. SEA keys
# GetStatementResult on the id, so this works whether or not the
# connector still holds the submitting handle — and it's
# inherently re-callable (each call attaches a fresh handle and
# re-materialises the result stream), matching the Thrift backend
# where the operation handle stays re-fetchable until an explicit
# close. No connector-side handle lookup, so no
# ``unknown command_id`` failure on a second call.
# Prefer the original owning async handle for the first
# in-process result stream. The kernel attaches the real
# ExecuteStatementAsync telemetry to that handle; attached
# handles intentionally use no-op telemetry, so always
# re-attaching loses the SEA async statement row when the result
# is drained. After the owning result stream has been started,
# attach by id for re-fetch. This preserves the Thrift-parity
# behavior where results remain re-callable until explicit close.
#
# ``attach_async_statement`` issues a GetStatementStatus to seed
# the handle; a 404 (unknown / aged-out id) surfaces as a
# NotFound KernelError mapped to ``ProgrammingError`` below via
# ``_wrap_kernel_exception``.
# If this process does not hold the owning handle (fresh cursor,
# restarted process, already re-fetched), ``attach_async_statement``
# issues a GetStatementStatus to seed the handle; a 404 (unknown
# / aged-out id) surfaces as a NotFound KernelError mapped to
# ``ProgrammingError`` below via ``_wrap_kernel_exception``.
if self._kernel_session is None:
raise InterfaceError("get_execution_result requires an open session.")
with self._async_handles_lock:
handle = (
None
if command_id.guid in self._async_result_stream_started
else self._async_handles.get(command_id.guid)
)
uses_owning_handle = handle is not None
if uses_owning_handle:
self._async_result_stream_started.add(command_id.guid)
try:
handle = self._kernel_session.attach_async_statement(command_id.guid)
if handle is None:
handle = self._kernel_session.attach_async_statement(command_id.guid)
stream = handle.await_result()
except Exception as exc:
if uses_owning_handle:
with self._async_handles_lock:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low — The owning-handle failure path only discards _async_result_stream_started when await_result() raises. If await_result() succeeds (marker stays set) but the subsequent KernelResultSet.__init__arrow_schema() raises and is re-wrapped, the guid remains marked as started. A later retry then takes the attach-by-id (no-op telemetry) branch.

Whether this loses the ExecuteStatementAsync telemetry row depends on when the kernel finalizes it: if finalization happens when await_result() returns, this is harmless (telemetry already committed). If finalization only completes once the result stream is drained, the telemetry is lost on this retry because the owning handle is never reused. The PR's own comments ("first in-process result stream", "clear the claimed marker so a retry can still use the telemetry-bearing owning handle") are ambiguous on this point, and the added test_get_execution_result_owning_handle_failure_can_retry_owning_handle only exercises the await_result()-raises case, not the construct-failure-after-await case. Worth confirming the finalization semantics and, if drain-based, discarding the marker on the construction-failure path too.

self._async_result_stream_started.discard(command_id.guid)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low — The retry-preservation logic is asymmetric. _async_result_stream_started is added under the lock before await_result() runs, and the first except (await failure) correctly discards the marker so a retry can reuse the telemetry-bearing owning handle. But if await_result() succeeds and the subsequent KernelResultSet(...) construction raises (the arrow_schema() path this second except exists to map), the marker stays set and the owning handle stays in _async_handles. A retry then takes the attach-by-id fallback — the exact telemetry-loss case this PR fixes — because the owning result stream was never actually drained on the failed attempt.

This is narrow (only when arrow_schema()/result-set construction fails after a successful await_result), and resetting here is itself debatable since re-awaiting the same owning handle may not be safe. Worth a comment noting the intentional gap, or handling it consistently with the first except.

(Anchored to the nearest changed line — see the description for the exact location.)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low — Asymmetric cleanup of the _async_result_stream_started marker on the owning-handle path.

In get_execution_result, when the owning handle is used, the guid is added to _async_result_stream_started under the lock (line 782). If handle.await_result() raises, the marker is correctly discarded (lines 789-790) so a retry can still use the telemetry-bearing owning handle — this is exactly the behavior the new test_get_execution_result_owning_handle_failure_can_retry_owning_handle locks in.

However, if await_result() succeeds but the following KernelResultSet construction / arrow_schema() call raises (the try/except at 794-797 that this PR leaves in place), the marker is not discarded. Per this PR's own reasoning, kernel async telemetry is finalized when the result is drained — which happens during fetch, after the result set is constructed. So on a construction failure the drain never runs, yet the marker is now sticky: a subsequent get_async_execution_result() retry falls back to attach-by-id (no-op telemetry), losing the ExecuteStatementAsync telemetry row this change exists to preserve.

This is a narrow edge (arrow_schema raising after await succeeds) and telemetry is best-effort, hence Low. If the intent is that a post-await construction failure should still allow a telemetry-preserving retry, discard the marker in the 796-797 handler too (symmetric with the await handler).

(Anchored to the nearest changed line — see the description for the exact location.)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium — The result-set construction failure path leaves _async_result_stream_started set, defeating the retry-with-owning-handle guarantee this PR establishes.

In get_execution_result, the owning handle is claimed by adding command_id.guid to _async_result_stream_started (L782) before the result is materialized. There are then two try blocks:

  • await_result() (L783–791): on failure, if uses_owning_handle: discards the marker (L788–790), so a retry re-selects the owning handle and preserves telemetry.
  • _make_result_set(...)KernelResultSet.__init__arrow_schema() (L794–797): on failure, the marker is not discarded.

Per the method's own comment, kernel async telemetry is finalized on the owning handle only when the result stream is drained (during later fetch), not at await_result() time. So if await_result() succeeds but arrow_schema() raises during construction, the caller never receives a usable ResultSet, yet the marker stays set (and the owning handle is still in _async_handles, since this method uses .get() not .pop()). A subsequent retry then hits the attach-by-id branch and loses the SEA async-statement telemetry row — exactly the regression this PR aims to prevent, and inconsistent with the sibling await_result handling.

Consider discarding the marker on this path too (mirroring L788–790), e.g. wrap the construction failure with the same if uses_owning_handle: cleanup. Note test_get_execution_result_owning_handle_failure_can_retry_owning_handle only covers the await_result failure path; the construction-failure retry case is untested.

(Anchored to the nearest changed line — see the description for the exact location.)

raise _wrap_kernel_exception("get_execution_result", exc) from exc
# ``KernelResultSet.__init__`` calls ``arrow_schema()`` which
# can raise — map that to PEP 249 too.
Expand Down
11 changes: 4 additions & 7 deletions src/databricks/sql/backend/kernel/result_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,13 +252,10 @@ def close(self) -> None:
# connection close path stays clean.
logger.warning("Error closing kernel handle: %s", exc)
# Honor the base ``ResultSet`` contract: notify the backend.
# ``backend.close_command`` also drops the ``_async_handles``
# entry and records the guid in ``_closed_commands`` — no
# separate pop needed here. Sync-execute and metadata paths
# never registered in ``_async_handles`` to begin with, and
# ``get_execution_result`` pops the async path before the
# result set is even constructed (see the M1 fix), so this
# call is the single bookkeeping seam.
# For async results, ``backend.close_command`` drops the
# retained owning handle and parent Statement. Sync-execute and
# metadata paths never registered in ``_async_handles`` to begin
# with, so this call is tolerant bookkeeping for them.
backend = cast("KernelDatabricksClient", self.backend)
try:
backend.close_command(self.command_id)
Expand Down
15 changes: 7 additions & 8 deletions tests/e2e/test_kernel_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -417,14 +417,14 @@ def test_dml_rowcount_wiring_does_not_break_dml(conn):
cur.execute(f"DROP TABLE IF EXISTS {tbl}")


# ── Async execution: state + result come from the server (attach-by-id) ──
# ── Async execution: owning handle first, attach-by-id for re-fetch/resume ──


def test_async_execute_polls_and_fetches_result(conn):
"""The full async CUJ: ``execute_async`` → poll
``get_query_state`` → ``get_async_execution_result``. State and
result are read from the server by re-attaching to the statement
id (no connector-side state)."""
``get_query_state`` → ``get_async_execution_result``. The first
in-process flow uses the retained owning handle so kernel async
telemetry is finalized."""
with conn.cursor() as cur:
cur.execute_async("SELECT 7 AS n")
cur.get_async_execution_result() # polls to terminal, fetches
Expand All @@ -437,10 +437,9 @@ def test_async_execute_polls_and_fetches_result(conn):


def test_async_get_execution_result_is_re_callable(conn):
"""``get_async_execution_result`` re-attaches by id on each call,
so fetching the same async command twice both succeed — the
connector never relied on a one-shot retained handle (Thrift-parity
re-fetch)."""
"""Fetching the same async command twice succeeds: the first
in-process result fetch can use the owning handle, and later
re-fetches attach by id (Thrift-parity re-fetch)."""
with conn.cursor() as cur:
cur.execute_async("SELECT 11 AS n")
cur.get_async_execution_result()
Expand Down
117 changes: 100 additions & 17 deletions tests/unit/test_kernel_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -799,9 +799,72 @@ def test_get_query_state_propagates_non_not_found_error():
c.get_query_state(cid)


def test_get_execution_result_attaches_by_id():
"""``get_execution_result`` re-attaches to the statement by id and
awaits its result — no connector-side handle lookup."""
def test_get_query_state_uses_retained_owning_handle_before_result_stream():
"""In-process status polling uses the retained submitting handle so
kernel async statement telemetry stays attached to the original
ExecuteStatementAsync telemetry object."""
c = _make_client()
c._kernel_session = MagicMock()
handle = MagicMock()
handle.status.return_value = ("Running", None)
cid = CommandId.from_sea_statement_id("async-status-owning")
c._async_handles[cid.guid] = handle

assert c.get_query_state(cid) == CommandState.RUNNING

c._kernel_session.attach_async_statement.assert_not_called()
handle.status.assert_called_once_with()


def test_get_query_state_attaches_by_id_after_result_stream_started():
"""Once get_execution_result has claimed the owning handle for result
streaming, status polling falls back to attach-by-id."""
c = _make_client()
c._kernel_session = MagicMock()
owning_handle = MagicMock()
attached_handle = MagicMock()
attached_handle.status.return_value = ("Succeeded", None)
c._kernel_session.attach_async_statement.return_value = attached_handle
cid = CommandId.from_sea_statement_id("async-status-attached")
c._async_handles[cid.guid] = owning_handle
c._async_result_stream_started.add(cid.guid)

assert c.get_query_state(cid) == CommandState.SUCCEEDED

owning_handle.status.assert_not_called()
c._kernel_session.attach_async_statement.assert_called_once_with(
"async-status-attached"
)
attached_handle.status.assert_called_once_with()


def test_get_execution_result_uses_retained_owning_handle_first():
"""The first in-process result fetch uses the retained submitting
handle so the kernel finalizes the original async statement telemetry."""
c = _make_client()
c._kernel_session = MagicMock()
fake_stream = MagicMock()
fake_stream.arrow_schema.return_value = pa.schema([("n", pa.int64())])
handle = MagicMock()
handle.await_result.return_value = fake_stream
cursor = MagicMock()
cursor.arraysize = 100
cursor.buffer_size_bytes = 1024
cursor.row_limit = 5
cid = CommandId.from_sea_statement_id("async-1")
c._async_handles[cid.guid] = handle

rs = c.get_execution_result(cid, cursor=cursor)

assert rs is not None
c._kernel_session.attach_async_statement.assert_not_called()
handle.await_result.assert_called_once_with()
assert cid.guid in c._async_result_stream_started


def test_get_execution_result_attaches_by_id_when_no_retained_handle():
"""Fallback by statement id keeps cross-process / fresh-cursor
result retrieval working when this connector lacks the owning handle."""
c = _make_client()
fake_stream = MagicMock()
fake_stream.arrow_schema.return_value = pa.schema([("n", pa.int64())])
Expand All @@ -814,10 +877,28 @@ def test_get_execution_result_attaches_by_id():
rs = c.get_execution_result(cid, cursor=cursor)

assert rs is not None
c._kernel_session.attach_async_statement.assert_called_with("async-1")
c._kernel_session.attach_async_statement.assert_called_once_with("async-1")
handle.await_result.assert_called_once_with()


def test_get_execution_result_owning_handle_failure_can_retry_owning_handle():
"""If the owning handle's await fails before producing a result
stream, clear the claimed marker so a retry can still use the
telemetry-bearing owning handle."""
c = _make_client()
c._kernel_session = MagicMock()
handle = MagicMock()
handle.await_result.side_effect = _FakeKernelError(code="Unavailable")
cid = CommandId.from_sea_statement_id("async-retry-owning")
c._async_handles[cid.guid] = handle

with pytest.raises(OperationalError):
c.get_execution_result(cid, cursor=MagicMock())

assert cid.guid not in c._async_result_stream_started
c._kernel_session.attach_async_statement.assert_not_called()


def test_get_execution_result_maps_not_found_to_programming_error():
"""An unknown / aged-out id surfaces the kernel's NotFound as a
mapped PEP 249 exception rather than a raw error."""
Expand Down Expand Up @@ -1044,24 +1125,25 @@ def test_kernel_error_during_result_set_construction_is_mapped():


# ---------------------------------------------------------------------------
# get_execution_result is re-callable via attach-by-id
# get_execution_result uses the owning handle once, then attach-by-id
# ---------------------------------------------------------------------------


def test_get_execution_result_is_re_callable():
"""``get_execution_result`` re-attaches by id on every call, so a
second fetch for the same async command succeeds (Thrift-parity
re-fetch). Each call attaches a fresh handle and awaits its result;
neither raises, and the connector never depended on a retained
handle. The kernel's ``await_result()`` is idempotent server-side."""
"""The first result fetch uses the owning handle for telemetry; a
second fetch for the same async command re-attaches by id so
Thrift-parity re-fetch still works."""
c = _make_client()
c._kernel_session = MagicMock()
fake_stream = MagicMock()
fake_stream.arrow_schema.return_value = pa.schema([("n", pa.int64())])
handle = MagicMock()
handle.await_result.return_value = fake_stream
c._kernel_session.attach_async_statement.return_value = handle
owning_handle = MagicMock()
owning_handle.await_result.return_value = fake_stream
attached_handle = MagicMock()
attached_handle.await_result.return_value = fake_stream
c._kernel_session.attach_async_statement.return_value = attached_handle
cid = CommandId.from_sea_statement_id("async-recall-twice")
c._async_handles[cid.guid] = owning_handle
cursor = MagicMock()
cursor.arraysize = 100
cursor.buffer_size_bytes = 1024
Expand All @@ -1070,10 +1152,11 @@ def test_get_execution_result_is_re_callable():
rs2 = c.get_execution_result(cid, cursor=cursor)

assert rs1 is not None and rs2 is not None
# Two calls -> two attaches -> two await_results. No reliance on a
# connector-tracked handle.
assert c._kernel_session.attach_async_statement.call_count == 2
assert handle.await_result.call_count == 2
owning_handle.await_result.assert_called_once_with()
c._kernel_session.attach_async_statement.assert_called_once_with(
"async-recall-twice"
)
attached_handle.await_result.assert_called_once_with()


# ---------------------------------------------------------------------------
Expand Down
Loading