@@ -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
0 commit comments