@@ -129,6 +129,15 @@ def __init__( |
129 | 129 | # Guarded by ``_async_handles_lock`` so concurrent cursors on the |
130 | 130 | # same connection don't race on submit / close / close-session. |
131 | 131 | self._async_handles: Dict[str, Any] = {} |
| 132 | + # Parent ``Statement`` objects kept alive alongside async handles. |
| 133 | + # On the kernel, ``Statement.close()`` flips the validity flag on |
| 134 | + # the produced executed handle (see kernel |
| 135 | + # ``statement::mutable::close``), so we cannot close the |
| 136 | + # Statement immediately after ``submit()`` as we do for sync |
| 137 | + # ``execute()``. Instead retain it here and close it in |
| 138 | + # ``close_command`` / ``close_session`` after the async handle |
| 139 | + # has finished its work. |
| 140 | + self._async_statements: Dict[str, Any] = {} |
132 | 141 | # CommandId.guids of async commands that have already been |
133 | 142 | # closed (via ``close_command`` or ``close_session``). Lets |
134 | 143 | # ``get_query_state`` report ``CLOSED`` for them rather than |
@@ -167,6 +176,16 @@ def open_session( |
167 | 176 | schema=schema or self._schema, |
168 | 177 | session_conf=session_conf, |
169 | 178 | complex_types_as_json=not self._use_arrow_native_complex_types, |
| 179 | + # Pyarrow's Python bindings cannot decode Arrow's |
| 180 | + # ``month_interval`` type at all (id 21 — raises |
| 181 | + # ``KeyError`` from ``.as_py``, ``to_pylist``, |
| 182 | + # ``cast(string)``, and ``to_pandas``). Ask the kernel |
| 183 | + # to stringify INTERVAL / DURATION columns server-side |
| 184 | + # so result sets containing interval columns are |
| 185 | + # decodable on the Python side. Matches the Thrift |
| 186 | + # backend's surface (interval columns arrive as |
| 187 | + # strings). |
| 188 | + intervals_as_string=True, |
170 | 189 | **auth_kwargs, |
171 | 190 | ) |
172 | 191 | except Exception as exc: |
@@ -197,7 +216,9 @@ def close_session(self, session_id: SessionId) -> None: |
197 | 216 | # server-side CloseStatement before the session goes away. |
198 | 217 | with self._async_handles_lock: |
199 | 218 | tracked = list(self._async_handles.items()) |
| 219 | + tracked_stmts = list(self._async_statements.items()) |
200 | 220 | self._async_handles.clear() |
| 221 | + self._async_statements.clear() |
201 | 222 | for guid, _ in tracked: |
202 | 223 | self._closed_commands.add(guid) |
203 | 224 | for _, handle in tracked: |
@@ -211,6 +232,16 @@ def close_session(self, session_id: SessionId) -> None: |
211 | 232 | logger.warning( |
212 | 233 | "Error closing async handle during session close: %s", exc |
213 | 234 | ) |
| 235 | + # Now drop the parent Statements that were keeping those handles |
| 236 | + # alive. Same non-fatal close semantics — close errors are not |
| 237 | + # actionable at session-close time. |
| 238 | + for _, stmt in tracked_stmts: |
| 239 | + try: |
| 240 | + stmt.close() |
| 241 | + except Exception as exc: |
| 242 | + logger.warning( |
| 243 | + "Error closing async statement during session close: %s", exc |
| 244 | + ) |
214 | 245 | try: |
215 | 246 | self._kernel_session.close() |
216 | 247 | except Exception as exc: |
@@ -249,6 +280,11 @@ def execute_command( |
249 | 280 | stmt = self._kernel_session.statement() |
250 | 281 | except Exception as exc: |
251 | 282 | raise _wrap_kernel_exception("execute_command", exc) from exc |
| 283 | + # ``async_op`` keeps ``stmt`` alive (tracked in |
| 284 | + # ``_async_statements`` and closed by ``close_command``); the sync |
| 285 | + # path drops it in finally. ``close_stmt`` is the post-success |
| 286 | + # decision flag — it stays True on sync, flips to False on async. |
| 287 | + close_stmt = True |
252 | 288 | try: |
253 | 289 | try: |
254 | 290 | stmt.set_sql(operation) |
@@ -262,21 +298,26 @@ def execute_command( |
262 | 298 | cursor.active_command_id = command_id |
263 | 299 | with self._async_handles_lock: |
264 | 300 | self._async_handles[command_id.guid] = async_exec |
| 301 | + # Closing the kernel ``Statement`` invalidates the |
| 302 | + # async handle (see kernel validity flag). Retain |
| 303 | + # the Statement here and close it on |
| 304 | + # ``close_command`` / ``close_session``. |
| 305 | + self._async_statements[command_id.guid] = stmt |
| 306 | + close_stmt = False |
265 | 307 | return None |
266 | 308 | executed = stmt.execute() |
267 | 309 | except Exception as exc: |
268 | 310 | raise _wrap_kernel_exception("execute_command", exc) from exc |
269 | 311 | finally: |
270 | | - # ``Statement`` is a lifecycle owner separate from the |
271 | | - # executed handle it produces. Drop it here so the |
272 | | - # parent doesn't keep the handle alive longer than the |
273 | | - # caller expects. Swallow all close errors (including |
274 | | - # PyO3 native exceptions) — a failed stmt.close() is |
275 | | - # not actionable for the caller. |
276 | | - try: |
277 | | - stmt.close() |
278 | | - except Exception: |
279 | | - pass |
| 312 | + if close_stmt: |
| 313 | + # Sync path: ``Statement`` is a lifecycle owner separate |
| 314 | + # from the executed handle. Drop it here so the parent |
| 315 | + # doesn't outlive its caller. Swallow close errors — |
| 316 | + # they're not actionable. |
| 317 | + try: |
| 318 | + stmt.close() |
| 319 | + except Exception: |
| 320 | + pass |
280 | 321 |
|
281 | 322 | command_id = CommandId.from_sea_statement_id(executed.statement_id) |
282 | 323 | cursor.active_command_id = command_id |
@@ -307,17 +348,34 @@ def cancel_command(self, command_id: CommandId) -> None: |
307 | 348 | def close_command(self, command_id: CommandId) -> None: |
308 | 349 | with self._async_handles_lock: |
309 | 350 | handle = self._async_handles.pop(command_id.guid, None) |
| 351 | + stmt = self._async_statements.pop(command_id.guid, None) |
310 | 352 | if handle is not None: |
311 | 353 | # Record the close so ``get_query_state`` can report |
312 | 354 | # ``CLOSED`` (not ``SUCCEEDED``) for this command. |
313 | 355 | self._closed_commands.add(command_id.guid) |
314 | 356 | if handle is None: |
315 | 357 | logger.debug("close_command: no tracked handle for %s", command_id) |
| 358 | + # Still drop the parent Statement if somehow tracked without |
| 359 | + # the handle — keeps the invariant clean even on bookkeeping |
| 360 | + # races. |
| 361 | + if stmt is not None: |
| 362 | + try: |
| 363 | + stmt.close() |
| 364 | + except Exception: |
| 365 | + pass |
316 | 366 | return |
317 | 367 | try: |
318 | 368 | handle.close() |
319 | 369 | except Exception as exc: |
320 | 370 | raise _wrap_kernel_exception("close_command", exc) from exc |
| 371 | + finally: |
| 372 | + # Now safe to close the parent Statement — the executed |
| 373 | + # handle has finished its lifecycle. |
| 374 | + if stmt is not None: |
| 375 | + try: |
| 376 | + stmt.close() |
| 377 | + except Exception: |
| 378 | + pass |
321 | 379 |
|
322 | 380 | def get_query_state(self, command_id: CommandId) -> CommandState: |
323 | 381 | with self._async_handles_lock: |
@@ -378,6 +436,7 @@ def get_execution_result( |
378 | 436 | # it wraps. Drop tracking and fire-and-forget the close. |
379 | 437 | with self._async_handles_lock: |
380 | 438 | self._async_handles.pop(command_id.guid, None) |
| 439 | + stmt = self._async_statements.pop(command_id.guid, None) |
381 | 440 | self._closed_commands.add(command_id.guid) |
382 | 441 | try: |
383 | 442 | async_exec.close() |
@@ -387,6 +446,18 @@ def get_execution_result( |
387 | 446 | command_id, |
388 | 447 | exc, |
389 | 448 | ) |
| 449 | + # The parent Statement is no longer needed once the async handle |
| 450 | + # has produced its ResultStream. Close to release server-side |
| 451 | + # tracking; matches the sync path's eager Statement close. |
| 452 | + if stmt is not None: |
| 453 | + try: |
| 454 | + stmt.close() |
| 455 | + except Exception as exc: |
| 456 | + logger.warning( |
| 457 | + "Error closing async statement after await_result for %s: %s", |
| 458 | + command_id, |
| 459 | + exc, |
| 460 | + ) |
390 | 461 | # ``KernelResultSet.__init__`` calls ``arrow_schema()`` which |
391 | 462 | # can raise — map that to PEP 249 too. |
392 | 463 | try: |
|