Skip to content

Commit 2bb0242

Browse files
ai: apply changes for #923 (1 review thread)
Addresses: - #3836923314 at src/databricks/sql/backend/kernel/client.py:729 Signed-off-by: peco-engineer-bot[bot] <peco-engineer-bot[bot]@users.noreply.github.com>
1 parent 23b4330 commit 2bb0242

2 files changed

Lines changed: 77 additions & 10 deletions

File tree

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

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,14 @@ def __init__(
264264
# cross-process cases.
265265
self._async_handles: Dict[str, Any] = {}
266266
self._async_result_stream_started: Set[str] = set()
267+
# Async ids whose owning-handle ``status()`` poll is currently in
268+
# flight. A second concurrent poll of the same id (before result
269+
# streaming is claimed) is routed to the attach-by-id fallback so
270+
# it gets a fresh kernel handle instead of racing ``status()`` on
271+
# the shared owning handle. Guarded by ``_async_handles_lock``;
272+
# each entry is transient (added before the poll, discarded in a
273+
# ``finally``).
274+
self._async_status_in_flight: Set[str] = set()
267275
# Parent ``Statement`` objects kept alive alongside async handles.
268276
# On the kernel, ``Statement.close()`` flips the validity flag on
269277
# the produced executed handle (see kernel
@@ -410,6 +418,7 @@ def close_session(self, session_id: SessionId) -> None:
410418
self._async_handles.clear()
411419
self._async_statements.clear()
412420
self._async_result_stream_started.clear()
421+
self._async_status_in_flight.clear()
413422
for _, handle in tracked:
414423
# Per-handle close errors are non-fatal — PEP 249
415424
# discourages raising from session close — so log and
@@ -662,6 +671,7 @@ def close_command(self, command_id: CommandId) -> None:
662671
handle = self._async_handles.pop(command_id.guid, None)
663672
stmt = self._async_statements.pop(command_id.guid, None)
664673
self._async_result_stream_started.discard(command_id.guid)
674+
self._async_status_in_flight.discard(command_id.guid)
665675
# Closing the handle below fires the server-side CloseStatement.
666676
# A subsequent ``get_query_state`` re-attaches by id and reads
667677
# ``CLOSED`` straight from the server — no connector-side
@@ -709,21 +719,32 @@ def get_query_state(self, command_id: CommandId) -> CommandState:
709719
# state=CLOSED until the result TTL elapses.
710720
if self._kernel_session is None:
711721
raise InterfaceError("get_query_state requires an open session.")
712-
# Concurrency note: the lock guards the _async_handles / _async_result_stream_started
713-
# bookkeeping only. The retained owning handle it returns is a shared object, and
714-
# handle.status() below runs OUTSIDE the lock. Concurrent in-process polling of a
715-
# single async id from two cursors (before result streaming is claimed) therefore
716-
# invokes status() on the same underlying kernel handle concurrently; the connector
717-
# does not serialise that and does not assume the kernel handle is safe for it. Such
718-
# concurrent polling of one async id is unsupported — the supported cross-cursor
719-
# resume path re-attaches by id (the attach-by-id fallback below) once result
720-
# streaming has been claimed.
722+
# Concurrency note: the lock guards the _async_handles /
723+
# _async_result_stream_started / _async_status_in_flight bookkeeping only.
724+
# The retained owning handle it returns is a shared object, and
725+
# handle.status() below runs OUTSIDE the lock, so it is not safe to invoke
726+
# status() on one owning handle from two threads at once. Rather than leave
727+
# concurrent in-process polling of a single async id "unsupported" and
728+
# undefined, we reserve the owning handle for the first poller via
729+
# _async_status_in_flight: a second concurrent poll of the same id (before
730+
# result streaming is claimed) sees the id already in flight and falls
731+
# through to the attach-by-id path, getting its own fresh kernel handle —
732+
# preserving the pre-change behaviour where every caller re-attached by id
733+
# and status() ran on distinct objects. The reservation is transient
734+
# (discarded in the finally below), so serial polls still take the
735+
# telemetry-preserving owning-handle path.
721736
with self._async_handles_lock:
722737
handle = (
723738
None
724-
if command_id.guid in self._async_result_stream_started
739+
if (
740+
command_id.guid in self._async_result_stream_started
741+
or command_id.guid in self._async_status_in_flight
742+
)
725743
else self._async_handles.get(command_id.guid)
726744
)
745+
reserved_owning_handle = handle is not None
746+
if reserved_owning_handle:
747+
self._async_status_in_flight.add(command_id.guid)
727748
try:
728749
if handle is None:
729750
handle = self._kernel_session.attach_async_statement(command_id.guid)
@@ -750,6 +771,14 @@ def get_query_state(self, command_id: CommandId) -> CommandState:
750771
# sync-fall-through behaviour.
751772
return CommandState.SUCCEEDED
752773
raise _wrap_kernel_exception("get_query_state", exc) from exc
774+
finally:
775+
# Release the owning-handle reservation once this poll's
776+
# status() has completed (or raised). Only the reserver clears
777+
# it, so a concurrent poll that fell through to attach-by-id
778+
# never touches another poller's reservation.
779+
if reserved_owning_handle:
780+
with self._async_handles_lock:
781+
self._async_status_in_flight.discard(command_id.guid)
753782
if state == "Failed" and failure is not None:
754783
# Surface server-reported failure as a database error so
755784
# the cursor's polling loop terminates with the right

tests/unit/test_kernel_client.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -816,6 +816,44 @@ def test_get_query_state_uses_retained_owning_handle_before_result_stream():
816816
handle.status.assert_called_once_with()
817817

818818

819+
def test_get_query_state_concurrent_poll_routes_to_attach_by_id():
820+
"""A second concurrent poll of the same async id (while the first
821+
poll's owning-handle status() is in flight, before result streaming
822+
is claimed) falls back to attach-by-id rather than racing status()
823+
on the shared owning handle. The owning-handle reservation is
824+
released once the first poll returns, so a later serial poll takes
825+
the owning-handle path again."""
826+
c = _make_client()
827+
c._kernel_session = MagicMock()
828+
owning_handle = MagicMock()
829+
attached_handle = MagicMock()
830+
attached_handle.status.return_value = ("Running", None)
831+
c._kernel_session.attach_async_statement.return_value = attached_handle
832+
cid = CommandId.from_sea_statement_id("async-status-concurrent")
833+
c._async_handles[cid.guid] = owning_handle
834+
835+
# Simulate the first poll being mid-flight: its reservation is set.
836+
reentrant_state = {}
837+
838+
def owning_status():
839+
# A concurrent poll arriving while this one holds the reservation
840+
# must not touch the owning handle.
841+
reentrant_state["state"] = c.get_query_state(cid)
842+
return ("Running", None)
843+
844+
owning_handle.status.side_effect = owning_status
845+
846+
assert c.get_query_state(cid) == CommandState.RUNNING
847+
# The re-entrant (concurrent) poll fell back to attach-by-id.
848+
assert reentrant_state["state"] == CommandState.RUNNING
849+
c._kernel_session.attach_async_statement.assert_called_once_with(
850+
"async-status-concurrent"
851+
)
852+
owning_handle.status.assert_called_once_with()
853+
# Reservation released after the first poll returns.
854+
assert cid.guid not in c._async_status_in_flight
855+
856+
819857
def test_get_query_state_attaches_by_id_after_result_stream_started():
820858
"""Once get_execution_result has claimed the owning handle for result
821859
streaming, status polling falls back to attach-by-id."""

0 commit comments

Comments
 (0)