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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Release History

# Unreleased
- Support Volume `PUT`, `GET` and `REMOVE` on the kernel backend, including existing Python stream uploads. Local file paths and resolved symlink targets must stay within configured allowed roots.
- Fix the kernel backend ignoring `use_cloud_fetch=False`, so complete results can be fetched without cloud-storage downloads.
- Fix inline parameters (`use_inline_params=True`) so a sequence directly after `IN` renders as a list again: `x IN %(ids)s` renders as `x IN (1,2,3)` instead of `x IN ARRAY(1,2,3)`, which Spark rejects. Inside the list, tuples render as rows for a multi-column `IN`, and a `set` or `frozenset` is accepted again. Sequences anywhere else still render as `ARRAY(...)` ([#629](https://github.com/databricks/databricks-sql-python/issues/629))
- Speed up Thrift response handling: read replies in 1 MiB chunks and decode them with Thrift's C-accelerated protocol, and stop stringifying every request/response for the default no-op debug logger. Wide result schemas, metadata calls with many rows, and inline Arrow results benefit most.
Expand Down
25 changes: 19 additions & 6 deletions CONNECTION_PARAMETERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ to change without notice.
| `use_inline_params` | `bool` \| `"silent"` | ✅ | ✅ | `False` | Render parameters inline (legacy) vs. native bound params (DBR 14.1+). Shared cursor code performs the rendering before dispatch, so both backends honor it identically. |
| `ignore_transactions` | `bool` | ✅ | ✅ | `True` | When `True`: `commit()` is a no-op, `rollback()` raises `NotSupportedError`, and setting `autocommit` is a no-op. |
| `fetch_autocommit_from_server`| `bool` | ✅ | ✅ | `False` | Query the server (`SET AUTOCOMMIT`) for autocommit state instead of returning the cached value. |
| `staging_allowed_local_path` | `str` \| `List[str]` | ✅ | ❌ | `None` | Local path(s) permitted for Unity Catalog Volume `PUT`/`GET`. **Thrift-only** — the kernel has no Volume API yet. |
| `staging_allowed_local_path` | `str` \| `List[str]` | ✅ | ✅ | `None` | Local path(s) permitted for Unity Catalog Volume `PUT`/`GET`. Kernel also checks resolved symlink targets; see below. |

## Telemetry

Expand Down Expand Up @@ -208,13 +208,12 @@ are forwarded when supported by the installed kernel binding.
`enable_query_result_lz4_compression`.
5. Arrow-native rendering for `_use_arrow_native_decimals` /
`_use_arrow_native_timestamps` (complex types **are** forwarded).
6. `staging_allowed_local_path` (Volume `PUT`/`GET`).
7. Retry fine-tuning beyond the four forwarded knobs: `_retry_delay_default`,
6. Retry fine-tuning beyond the four forwarded knobs: `_retry_delay_default`,
`_retry_dangerous_codes`, `_respect_server_retry_after_header`,
`_retry_max_redirects`, `_enable_v3_retries`.
8. `force_enable_telemetry`.
9. `_port` and `_connection_uri`.
10. `_pool_connections` and `_proxy_auth_method` do not configure the
7. `force_enable_telemetry`.
8. `_port` and `_connection_uri`.
9. `_pool_connections` and `_proxy_auth_method` do not configure the
kernel-owned network stack.

### Supported on Kernel, missing / ignored on Thrift
Expand Down Expand Up @@ -246,3 +245,17 @@ are forwarded when supported by the installed kernel binding.
> subject to change. Generated against connector version `4.5.0`; the source of
> truth is `Connection.__init__` (`src/databricks/sql/client.py`) and the
> per-backend clients under `src/databricks/sql/backend/`.

### Volume file paths

With `use_kernel=True`, SQL `PUT`, `GET` and `REMOVE` complete their file
operations before `execute()` or `get_async_execution_result()` returns.
`PUT` and `GET` require paths within
`staging_allowed_local_path`. Both the requested path and its resolved target
must lie within a configured root. Symlinks within those roots are permitted;
symlinks leading outside are rejected. This is stricter than the Thrift
backend, which checks the requested path only.

`execute(input_stream=stream)` retains its existing behavior: the stream is
read only when the server requests stream input. `REMOVE` requires no local
root. A completed transfer returns no instruction rows.
2 changes: 1 addition & 1 deletion KERNEL_REV
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0b5d0edfed32c0745d5abc2468fc3070e2eb2a17
855847bdca71e07285cfc60332fb2f33f8e47786
120 changes: 44 additions & 76 deletions src/databricks/sql/backend/kernel/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@

- ``query_tags`` on execute is not supported (kernel exposes
``statement_conf`` but PyO3 doesn't surface it).
- Volume PUT/GET (staging operations): kernel has no Volume API
yet. Users on Thrift-only paths.
"""

from __future__ import annotations
Expand All @@ -35,6 +33,7 @@
Optional,
Set,
TYPE_CHECKING,
Tuple,
Union,
)

Expand Down Expand Up @@ -84,41 +83,6 @@
# per-request skip-and-warn.
_KERNEL_MANAGED_HEADERS = frozenset({"authorization", "x-databricks-org-id"})

# Leading verbs of SQL volume/staging statements. Detected by the
# leading token (case-insensitive) so the kernel backend can fail loud
# on staging ops it can't service — see ``execute_command``.
_STAGING_VERBS = ("PUT", "GET", "REMOVE")


def _strip_leading_sql_comments(sql: str) -> str:
"""Strip leading whitespace and SQL comments (``-- …`` line and
``/* … */`` block, possibly several) from ``sql``, returning the
remainder.

Needed so staging detection sees the real leading verb: a
comment-prefixed staging op (``-- upload\\nPUT …`` or
``/* c */ PUT …``, common in ETL scripts) must still be classified
as staging, or it would slip past the guard into the silent-no-op
bug. Block comments do not nest in Databricks SQL, so a simple
scan-to-``*/`` is correct.
"""
i = 0
n = len(sql)
while i < n:
if sql[i].isspace():
i += 1
elif sql.startswith("--", i):
# Line comment: skip to end of line (or string).
nl = sql.find("\n", i)
i = n if nl == -1 else nl + 1
elif sql.startswith("/*", i):
# Block comment: skip to closing */ (or end if unterminated).
close = sql.find("*/", i + 2)
i = n if close == -1 else close + 2
else:
break
return sql[i:]


def _is_not_found(exc: BaseException) -> bool:
"""True iff ``exc`` is a kernel ``NotFound`` error (HTTP 404 /
Expand Down Expand Up @@ -155,21 +119,6 @@ def _catalog_or_none(value: Optional[str]) -> Optional[str]:
return value


def _is_staging_statement(operation: str) -> bool:
"""True iff ``operation`` is a volume/staging statement (PUT / GET /
REMOVE).

Strips leading whitespace + SQL comments first (so a comment-
prefixed staging op is still caught), then matches the leading token
only — so a normal query that merely *contains* the word (e.g.
``SELECT 'GET' AS x``) isn't misflagged.
"""
stripped = _strip_leading_sql_comments(operation)
# First whitespace-delimited token, uppercased.
verb = stripped.split(None, 1)[0].upper() if stripped.strip() else ""
return verb in _STAGING_VERBS


class _KernelSessionParameterSupport(NamedTuple):
session_type: Any
parameters: FrozenSet[str]
Expand Down Expand Up @@ -436,6 +385,8 @@ def __init__(
# Guarded by its own lock — cancel can race execute teardown.
self._sync_cancellers: Dict[int, Any] = {}
self._sync_cancellers_lock = threading.RLock()
# Retain the mutable owner until its transfer completes; dropping it cancels the result.
self._volume_handles: Dict[str, Tuple[Any, Any]] = {}

# ── Session lifecycle ──────────────────────────────────────────

Expand Down Expand Up @@ -565,6 +516,21 @@ def open_session(
def close_session(self, session_id: SessionId) -> None:
if self._kernel_session is None:
return
with self._sync_cancellers_lock:
cancellers = list(self._sync_cancellers.values()) + [
entry[1]
for entry in self._volume_handles.values()
if entry[1] is not None
]
self._sync_cancellers.clear()
self._volume_handles.clear()
for canceller in cancellers:
try:
canceller.cancel()
except Exception:
logger.debug(
"Statement cancellation during session close failed", exc_info=True
)
# Close any tracked async handles first so they fire their
# server-side CloseStatement before the session goes away.
with self._async_handles_lock:
Expand Down Expand Up @@ -633,24 +599,6 @@ def execute_command(
# ``_async_statements`` and closed by ``close_command``); the sync
# path drops it in finally. ``close_stmt`` is the post-success
# decision flag — it stays True on sync, flips to False on async.
# Volume/staging (PUT/GET/REMOVE) is not supported on the kernel
# path: the kernel returns the staging control row as a normal
# result set (``KernelResultSet.is_staging_operation`` is always
# False), so the connector's ``_handle_staging_operation`` never
# fires and NO file is transferred. Rather than silently no-op
# (the Thrift path performs the presigned-URL upload/download),
# fail loud at the call site so ETL scripts don't ingest
# stale/missing data. Detected by the leading SQL verb — the
# only signal available pre-execute, since the kernel exposes no
# staging marker today.
if _is_staging_statement(operation):
raise NotSupportedError(
"Volume / staging operations (PUT / GET / REMOVE) are not "
"supported on the kernel backend (use_kernel=True); the file "
"transfer would silently not happen. Use the Thrift backend "
"for staging operations."
)

close_stmt = True
try:
try:
Expand Down Expand Up @@ -692,6 +640,13 @@ def execute_command(
# Canceller is best-effort; never block execute on it.
pass
executed = stmt.execute()
if executed.is_volume_operation:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High — pyproject.toml:73 still allows databricks-sql-kernel ^1.4.0, but the code now calls three kernel APIs that don't exist yet: ExecutedStatement.is_volume_operation (read here on every sync execute), kernel_handle.is_volume_operation (read in KernelResultSet.__init__ for every result, including metadata ResultStreams), and fetch_result() in place of await_result() for async results.

If the resolver installs any wheel in the allowed range that lacks them, this isn't limited to Volume ops. Every execute(), every metadata call and every get_async_execution_result() raises AttributeError, so ordinary SELECT 1 breaks on use_kernel=True. The PR description says the floor "has not been selected". This should block merge: bump the floor in the same change. Until then, reading is_volume_operation with getattr(..., False) would at least keep non-volume queries working.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The optional-canceller defect is corrected at ab1efce1: registration uses .get, the Statement remains retained, and cleanup skips absent cancellers. All 276 affected tests pass; final native live checks preserve lazy BinaryIO and one-read empty/nonempty streams.

The new Volume marker/completion API dependency is a valid merge prerequisite. The source build pins compatible 22f96fac; a compatible published native version/floor and installed-package proof remain explicitly open. This PR is not merge-ready, and no old-native fallback or invented release version was added.

The fetch_result subclaim is separate: released 1.4.0 exposes it, but the substitution is removed from this PR. Merged native #451 provides terminal-response reuse with the original await_result boundary. Actual public-cursor owner and reader/refetch tests show identical await/fetch requests, rows and metadata on the selected native; redundant #1056 is superseded without merge.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for confirming the Volume-marker dependency is a merge prerequisite, and for dropping the fetch_result() substitution. That part of the finding is resolved. The core issue is still there, though. pyproject.toml:73 still says databricks-sql-kernel ^1.4.0. Line 641 still reads executed.is_volume_operation with no guard. KernelResultSet.__init__ still reads kernel_handle.is_volume_operation the same way. So if a published wheel in that range lacks the marker, ordinary sync executes and metadata calls fail with AttributeError before the code ever checks for a Volume op. A source build pinned to 22f96fac doesn't limit what the resolver installs. Leaving this open until the floor is raised to the first published wheel that ships the marker, or until both reads are guarded so non-volume queries keep working.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High — executed.is_volume_operation is read on every sync execute, and kernel_handle.is_volume_operation is read in KernelResultSet.__init__ (result_set.py:88) for every result set, metadata streams included. Both attributes only exist in the unreleased kernel change (#465). pyproject.toml still pins databricks-sql-kernel = ^1.4.0. A released 1.4.x wheel will raise AttributeError on every use_kernel=True query, so this breaks plain SELECTs, not just Volume ops. Before merge, either bump the floor in the same change to the first release that has the attribute, or read it with getattr(..., "is_volume_operation", False) so older wheels keep working with staging unsupported.

command_id = CommandId.from_sea_statement_id(executed.statement_id)
with self._sync_cancellers_lock:
self._volume_handles[command_id.guid] = (
stmt,
self._sync_cancellers.get(id(cursor)),
)
# Execute succeeded: the kernel now owns the statement
# lifecycle. It auto-closes the server statement when the
# result stream is fully drained (``ExecutedStatement::
Expand Down Expand Up @@ -764,16 +719,22 @@ def execute_command(
try:
return self._make_result_set(executed, cursor, command_id)
except Exception as exc:
self.finish_volume_operation(command_id)
raise _wrap_kernel_exception("execute_command", exc) from exc

def finish_volume_operation(self, command_id: CommandId) -> None:
with self._sync_cancellers_lock:
self._volume_handles.pop(command_id.guid, None)

def cancel_command(self, command_id: CommandId) -> None:
with self._async_handles_lock:
handle = self._async_handles.get(command_id.guid)
with self._sync_cancellers_lock:
volume = self._volume_handles.get(command_id.guid)
handle = volume[1] if volume is not None else None
if handle is None:
# Sync-execute paths fully materialise the result before
# ``execute_command`` returns, so by the time
# cancel_command can fire there's nothing in flight.
# Match the Thrift backend's tolerant behaviour.
with self._async_handles_lock:
handle = self._async_handles.get(command_id.guid)
if handle is None:
# Completed synchronous operations have no retained canceller.
logger.debug("cancel_command: no in-flight async handle for %s", command_id)
return
try:
Expand Down Expand Up @@ -822,6 +783,13 @@ def cancel_running_cursor(self, cursor: "Cursor") -> bool:
return True

def close_command(self, command_id: CommandId) -> None:
with self._sync_cancellers_lock:
volume = self._volume_handles.pop(command_id.guid, None)
if volume is not None and volume[1] is not None:
try:
volume[1].cancel()
except Exception:
logger.debug("Volume cancellation during close failed", exc_info=True)
with self._async_handles_lock:
handle = self._async_handles.pop(command_id.guid, None)
stmt = self._async_statements.pop(command_id.guid, None)
Expand Down
85 changes: 77 additions & 8 deletions src/databricks/sql/backend/kernel/result_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,17 @@
from __future__ import annotations

import logging
import os
from collections import deque
from typing import Any, Deque, List, Optional, TYPE_CHECKING, cast
from typing import Any, BinaryIO, Deque, List, Optional, TYPE_CHECKING, Union, cast

import pyarrow

from databricks.sql.backend.kernel._errors import wrap_kernel_exception
from databricks.sql.backend.kernel.type_mapping import description_from_arrow_schema
from databricks.sql.backend.types import CommandId, CommandState
from databricks.sql.result_set import ResultSet
from databricks.sql.exc import OperationalError, ProgrammingError
from databricks.sql.types import Row

if TYPE_CHECKING:
Expand Down Expand Up @@ -83,7 +85,7 @@ def __init__(
has_more_rows=True,
results_queue=None,
description=description_from_arrow_schema(schema),
is_staging_operation=False,
is_staging_operation=kernel_handle.is_volume_operation,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High — KernelResultSet.__init__ now reads kernel_handle.is_volume_operation without a fallback, and execute_command reads executed.is_volume_operation at client.py:641 the same way. Every result set goes through this path: sync and async execute, plus the metadata ResultStreams from get_catalogs/get_tables/etc.

pyproject.toml still allows databricks-sql-kernel ^1.4.0, and that wheel predates the shared kernel change (databricks-sql-kernel#465). With that wheel installed, every use_kernel=True query and metadata call fails with a bare AttributeError. It is not wrapped as a PEP 249 error, and it breaks plain SELECT 1 too, not just Volume operations. The PR description notes the floor hasn't been chosen yet, but merging this in its current state breaks the kernel backend for any resolver-selected in-range wheel.

You can either bump the floor in the same change to the first release that ships is_volume_operation/complete_volume_operation, or read the attribute with getattr(handle, "is_volume_operation", False) so older wheels keep working. The getattr fallback also covers ResultStream handles, if the native change only adds the attribute to ExecutedStatement.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High — KernelResultSet.__init__ now reads kernel_handle.is_volume_operation with no guard, and execute_command does the same at client.py:641 (executed.is_volume_operation). This attribute comes from the unreleased kernel change (databricks-sql-kernel#465), but pyproject.toml still allows databricks-sql-kernel ^1.4.0. That is a problem beyond PUT/GET: every KernelResultSet goes through this constructor, including plain SELECTs, metadata ResultStreams and async await_result() streams. On any 1.4.x wheel the resolver can pick, every use_kernel=True query would therefore fail with AttributeError instead of just lacking Volume support.

The PR description says the floor hasn't been selected yet. Until it is, either bump the floor in the same change, or read the attribute with getattr(handle, "is_volume_operation", False) and gate the native completion on its presence. Gating would also need a guard for complete_volume_operation. Otherwise, older wheels go from working to completely broken.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High — KernelResultSet.__init__ now reads kernel_handle.is_volume_operation unconditionally, and execute_command reads executed.is_volume_operation (client.py:643) on every sync execute. These attributes come from the unmerged kernel change (databricks-sql-kernel#465). pyproject.toml still allows databricks-sql-kernel ^1.4.0. On any wheel in that range without the attribute, every use_kernel=True result raises AttributeError, and that includes plain SELECT and metadata ResultStreams, not just Volume statements. The pre-execute NotSupportedError guard has also been removed, so an older wheel gets no clear error either. The PR description says the floor "has not been selected". Before merge, either bump the floor to the first kernel release with is_volume_operation / complete_volume_operation, or read the attribute with getattr(handle, "is_volume_operation", False). With the getattr option, keep the old fail-loud guard for wheels that lack the API, so PUT/GET does not quietly do nothing.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High — Every KernelResultSet now reads kernel_handle.is_volume_operation without a guard, and execute_command reads executed.is_volume_operation the same way (client.py ~L643). The PR description says the published 1.4.0 wheel lacks these APIs, yet pyproject.toml still allows ^1.4.0. If the resolver picks 1.4.x, this is not just missing Volume support: every use_kernel=True result breaks with AttributeError. That covers plain SELECTs, metadata ResultStreams and async await_result() streams.

It also requires ResultStream, not just ExecutedStatement, to expose is_volume_operation in the new kernel. The unit fakes add the attribute, so this is not proven.

Fix options:

  • Bump the floor in the same change to the release that ships these APIs.
  • Or read the attribute with getattr(kernel_handle, "is_volume_operation", False), and the same for executed, so older wheels keep the old non-staging behavior.

The description treats this as a merge gate, but the code as written turns it into a regression for all queries, not only a missing feature.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Valid and still open as a merge prerequisite. Published native 1.4.0 lacks these APIs; changing KERNEL_REV only selects the source build. 07538ed selects the reviewed 855847bd source for local/CI validation. The accessible package proxies still expose 1.4.0, so a compatible published version must be selected before updating the dependency floor and validating a clean installed package on ordinary queries and Volume workflows. No fallback or invented release version is being added; the PR description explicitly keeps this gate open.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for confirming. Nothing has changed in the code since the finding: line 88 still reads kernel_handle.is_volume_operation with no guard, and the ^1.4.0 floor still lets the resolver install the published 1.4.0 wheel. On that wheel, every use_kernel=True result set fails, not only Volume operations. I'm leaving this thread open as the merge gate you described. It can close once the floor points at a published release that ships is_volume_operation on both ExecutedStatement and ResultStream, and that release passes a clean-install check covering ordinary queries as well as Volume workflows. Not adding a fallback is a reasonable choice, as long as the floor bump is in the same merge.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The release plan is a coordinated native-kernel and connector version bump. The published 1.4.0 incompatibility remains valid: before connector release, the native dependency floor/lock must select the real compatible version, and clean installed ordinary queries and Volume workflows must pass. Source validation uses kernel 855847bd, which supplies the marker/completion APIs. Package availability is a release-integration prerequisite rather than a blocker to reviewing that source pair. No 1.4.0 compatibility, completed package validation, fallback or invented version is being claimed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for spelling out the release plan. Nothing in the code has changed: line 88 still reads kernel_handle.is_volume_operation with no guard, and pyproject.toml still allows ^1.4.0. I'm keeping this thread open as the merge gate. You've said it stays open too, and it shouldn't be resolved until the dependency floor/lock points at the published compatible release. That release also needs to pass a clean-install check on ordinary queries, metadata, async and Volume workflows. Testing the source pair against kernel 855847bd is fine for review, but those results don't carry over to what users get from installing the package.

lz4_compressed=False,
arrow_schema_bytes=None,
)
Expand All @@ -101,6 +103,72 @@ def __init__(
self._buffered_count: int = 0
self._exhausted: bool = False

def complete_staging_operation(
self,
allowed_paths: Union[None, str, List[str]],
input_stream: Optional[BinaryIO] = None,
) -> None:
paths = (
[allowed_paths] if isinstance(allowed_paths, str) else (allowed_paths or [])
)
consume_instructions = self.is_staging_operation
try:
outcome = self._complete_volume_operation(paths)
if outcome == "not_volume":
consume_instructions = False
return
consume_instructions = True
if outcome == "needs_input_stream":
try:
if input_stream is None:
raise ProgrammingError(
"No input stream provided for streaming operation"
)
data = input_stream.read()
except BaseException:
self.close()
raise
outcome = self._complete_volume_operation(paths, data)
if outcome != "completed":
raise ProgrammingError("Volume operation did not complete")
self.status = CommandState.SUCCEEDED
finally:
if consume_instructions:
self._buffer.clear()
self._buffer_offset = 0
self._buffered_count = 0
self._exhausted = True
self.has_more_rows = False
cast("KernelDatabricksClient", self.backend).finish_volume_operation(
self.command_id
)

def _complete_volume_operation(
self, paths: List[str], input_bytes: Optional[bytes] = None
) -> str:
try:
return self._kernel_handle.complete_volume_operation(
paths, input_bytes=input_bytes
)
except Exception as exc:
os_code = getattr(exc, "local_os_error", None)
if os_code is not None:
local_path = getattr(exc, "local_path", None)
if os.name == "nt":
raise OSError(
0, "Local file operation failed", local_path, os_code
) from exc
raise OSError(os_code, os.strerror(os_code), local_path) from exc
http_status = getattr(exc, "http_status", None)
Comment thread
peco-review-bot[bot] marked this conversation as resolved.
Resolved
if (
http_status is not None
and getattr(exc, "error_code", None) == "VOLUME_STORAGE_HTTP_ERROR"
):
raise OperationalError(
f"Staging operation over HTTP was unsuccessful: {http_status}"
) from exc
raise wrap_kernel_exception("complete_volume_operation", exc) from exc

# ----- internal helpers -----

def _pull_one_batch(self) -> bool:
Expand Down Expand Up @@ -244,19 +312,20 @@ def close(self) -> None:
self.has_been_closed_server_side = True
self.status = CommandState.CLOSED
return
backend = cast("KernelDatabricksClient", self.backend)
if self.is_staging_operation and self.status != CommandState.SUCCEEDED:

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 — When an unfinished staging result set is closed, close() first calls backend.cancel_command(command_id), which looks up the retained canceller and calls cancel(). It then calls backend.close_command(command_id), which pops the same _volume_handles entry and calls cancel() a second time. So the same canceller fires twice on every early close. This is probably harmless, but it's redundant. Calling cancel_command from close() is only needed because cancellation has to happen before the native close(). One option is to pop the entry in that first step, so close_command finds nothing left to cancel.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The possible duplicate best-effort cancellation on an unfinished close is an optional cleanup observation. Successful transfer close is already gated and sends no cancel; a pending close must cancel before native completion/cleanup so it cannot publish later. Retaining that ordering and the existing close behavior. The actual-native completed sync/async and pending-close controls pass; they do not establish a universal single-cancel guarantee for every unfinished path.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Agreed that the ordering matters and should stay: cancelling before the native close is the right call, and successful closes never send a cancel. My point was narrower. On an unfinished close, the same retained canceller is still called twice, once by cancel_command and again by close_command, and the current code still does this. It's low severity and you can defer it. If you pick it up later, one fix keeps the ordering: pop the _volume_handles entry in the first step, or have close_command skip handles that were already cancelled. Leaving this thread open in case someone wants to follow up.

# Cancellation must precede waiting for a transfer's native lock.
try:
backend.cancel_command(self.command_id)
except Exception:
logger.debug("Volume cancellation during close failed", exc_info=True)
try:
self._kernel_handle.close()
except Exception as exc:
# close() failures are not actionable at the connector
# level; log and swallow so the cursor's __del__ /
# connection close path stays clean.
logger.warning("Error closing kernel handle: %s", exc)
# Honor the base ``ResultSet`` contract: notify the backend.
# 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)
except Exception as exc:
Expand Down
3 changes: 3 additions & 0 deletions src/databricks/sql/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1284,6 +1284,9 @@ def _handle_staging_operation(
"""

assert self.active_result_set is not None
complete = getattr(self.active_result_set, "complete_staging_operation", None)
if complete is not None:
return complete(staging_allowed_local_path, input_stream)
row = self.active_result_set.fetchone()
assert row is not None

Expand Down
Loading
Loading