Repository navigation
Complete Volume file operations through the kernel backend #1057
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
3a92da2
c54d091
edd7608
4fc9bf6
07dbcc4
dd8bc9c
56ff1a0
ea8e8a9
1e271bb
ab1efce
ccc5b46
64c9838
f4292d3
07538ed
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1 @@ | ||
| 0b5d0edfed32c0745d5abc2468fc3070e2eb2a17 | ||
| 855847bdca71e07285cfc60332fb2f33f8e47786 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -35,6 +33,7 @@ | |
| Optional, | ||
| Set, | ||
| TYPE_CHECKING, | ||
| Tuple, | ||
| Union, | ||
| ) | ||
|
|
||
|
|
@@ -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 / | ||
|
|
@@ -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] | ||
|
|
@@ -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 ────────────────────────────────────────── | ||
|
|
||
|
|
@@ -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: | ||
|
|
@@ -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: | ||
|
|
@@ -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: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 High — |
||
| 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:: | ||
|
|
@@ -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: | ||
|
|
@@ -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) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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: | ||
|
|
@@ -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, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 High —
You can either bump the floor in the same change to the first release that ships There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 High — 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 High — There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 High — Every It also requires Fix options:
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.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| lz4_compressed=False, | ||
| arrow_schema_bytes=None, | ||
| ) | ||
|
|
@@ -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) | ||
|
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: | ||
|
|
@@ -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: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 Low — When an unfinished staging result set is closed,
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| # 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: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟠 High —
pyproject.toml:73still allowsdatabricks-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 inKernelResultSet.__init__for every result, including metadataResultStreams), andfetch_result()in place ofawait_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 everyget_async_execution_result()raisesAttributeError, so ordinarySELECT 1breaks onuse_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, readingis_volume_operationwithgetattr(..., False)would at least keep non-volume queries working.There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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:73still saysdatabricks-sql-kernel ^1.4.0. Line 641 still readsexecuted.is_volume_operationwith no guard.KernelResultSet.__init__still readskernel_handle.is_volume_operationthe same way. So if a published wheel in that range lacks the marker, ordinary sync executes and metadata calls fail withAttributeErrorbefore the code ever checks for a Volume op. A source build pinned to22f96facdoesn'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.