Repository navigation
Hold MLOps artifacts on Unity Catalog volumes through the Files API #114
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
Open
Open
Changes from all commits
Commits
Show all changes
36 commits
Select commit
Hold shift + click to select a range
fe8b8ed
Hold MLOps artifacts on Unity Catalog volumes through the Files API
Vedaant-J fdd6dc9
Expect volume chunks under a generation directory
Vedaant-J 1c8e82a
Merge #112's review fixes into #114
Vedaant-J 85b8b06
Refuse an occupied volume directory whatever the write mode, through …
Vedaant-J cceb78e
Drop the unused write_files options passthrough
Vedaant-J dc1405f
Shorten the volume sink docstrings
Vedaant-J a7fdac9
Tidy the volume handler: one-line docs, top-level imports, no custom …
Vedaant-J 973dc8a
Show the chunk generation directory in the volume README
Vedaant-J 53d23fc
Cancel a volume upload whose write failed instead of leaving it open
Vedaant-J ea03c6d
Drop a trailing blank line flake8 flags in the volume tests
Vedaant-J 619fe19
Pass the write mode to the shared preflight for volume directories
Vedaant-J f6e4b3a
Merge #112 into #114
Vedaant-J 524ee75
Merge #112's public-image fix into #114
Vedaant-J a02c647
Route volume model publish, fetch and preflight through FileStoreRouting
Vedaant-J 85fdff0
Share one bounded put loop between a volume upload's writes and abort
Vedaant-J 4685a33
Drop the fake volume's unused directory set, bad-request branch and s…
Vedaant-J 5403e1d
Merge r10/112 into r10/114
Vedaant-J 76acbea
Let FileStoreRouting send Databricks volume paths to file writers; ke…
Vedaant-J 0d3acd1
Merge #112's file-only sink error into #114
Vedaant-J 01d52b3
Cut the Databricks sink's class docstring to a summary and its argument
Vedaant-J a467ceb
Merge main into #114 through #112
Vedaant-J dda58f5
Drop duplicate dockerignore entries the base already has
Vedaant-J c10be5a
Share one upload-finish path between write and close
Vedaant-J df21902
Share the fake volume sink setup across volume tests
Vedaant-J eb15c6d
Reflow the volume README paragraph
Vedaant-J d25b9ac
Merge #112's re-review cleanups into #114
Vedaant-J 9125de4
Merge #112 into #114
Vedaant-J 4e22957
Merge main through #112 into #114
Vedaant-J 746cedd
Merge #112's chunked-writer fixes and ADLS test into #114
Vedaant-J 5fb2a8d
Reopen a volume download after a failed read
Vedaant-J 086c97f
Keep unsized volume exports as plain objects up to a part
Vedaant-J 2bc3351
Document that volume exports need READ VOLUME too
Vedaant-J adad4f8
Trim volume and sink docstrings and redundant path stripping
Vedaant-J 50ddd18
Tighten volume README and hoist test imports
Vedaant-J 0603c08
Merge #112's quality pass into #114
Vedaant-J 0c7d76c
Merge mlops-filestore-sink into mlops-dbx-volume
Vedaant-J File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,237 @@ | ||
| # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. | ||
| # All rights reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| """Unity Catalog volumes as a pyarrow filesystem.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import io | ||
| import posixpath | ||
| import queue | ||
| import threading | ||
| from contextlib import contextmanager | ||
| from typing import Any, BinaryIO, Dict, Iterator, Optional | ||
| from urllib.parse import quote | ||
|
|
||
| from databricks.sdk import WorkspaceClient | ||
| from databricks.sdk.errors import DatabricksError, NotFound, PermissionDenied | ||
| from kumo_core.chunked_fs import ChunkedFileHandler | ||
| from kumo_core.sources.diagnostics import safe_summary | ||
| from kumo_databricks.staging import clear_volume | ||
| from pyarrow.fs import FileSystem, PyFileSystem | ||
|
|
||
| _FILES = '/api/2.0/fs/files' | ||
| _BUFFER = 8 << 20 | ||
|
|
||
|
|
||
| def is_volume_path(path: str) -> bool: | ||
| parts = path.rstrip('/').split('/') | ||
| return (path.startswith('/Volumes/') and len(parts) >= 5 | ||
| and all(parts[1:]) and posixpath.normpath(path) == path.rstrip('/')) | ||
|
|
||
|
|
||
| def volume_filesystem(client: WorkspaceClient) -> FileSystem: | ||
| return PyFileSystem(VolumeHandler(client)) | ||
|
|
||
|
|
||
| @contextmanager | ||
| def _translated(path: str) -> Iterator[None]: | ||
| try: | ||
| yield | ||
| except NotFound as error: | ||
| raise FileNotFoundError(f'{path}: {error}') from None | ||
| except PermissionDenied as error: | ||
| raise PermissionError(f'{path}: {error}') from None | ||
| except DatabricksError as error: | ||
| raise OSError(f'{path}: {error}') from None | ||
| except Exception as error: | ||
| raise OSError(f'{path}: {safe_summary(error)}') from None | ||
|
|
||
|
|
||
| class VolumeHandler(ChunkedFileHandler): | ||
| part_bytes = 4 << 30 # under the Files API's 5 GiB upload cap | ||
| plain_bytes = part_bytes | ||
|
|
||
| def __init__(self, client: WorkspaceClient) -> None: | ||
| self._client = client | ||
|
|
||
| def get_type_name(self) -> str: | ||
| return 'databricks-volume' | ||
|
|
||
| def _size(self, path: str) -> Optional[int]: | ||
| with _translated(path): | ||
| try: | ||
| return int(self._client.files.get_metadata(path).content_length) | ||
| except NotFound: | ||
| return None | ||
|
|
||
| def _list(self, directory: str) -> Dict[str, int]: | ||
| with _translated(directory): | ||
| try: | ||
| entries = list( | ||
| self._client.files.list_directory_contents(directory)) | ||
| except NotFound: | ||
| return {} | ||
| found: Dict[str, int] = {} | ||
| for entry in entries: | ||
| path = entry.path.rstrip('/') | ||
| if entry.is_directory: | ||
| found.update(self._list(path)) | ||
| else: | ||
| found[path] = int(entry.file_size) | ||
| return found | ||
|
|
||
| def _put(self, local: str, path: str) -> None: | ||
| with _translated(path), open(local, 'rb') as contents: | ||
| self._client.files.upload(path, contents, overwrite=True) | ||
|
|
||
| def _stream(self, path: str) -> BinaryIO: | ||
| return _Upload(self._client, path) | ||
|
|
||
| def _part(self, path: str, size: int) -> _Part: | ||
| return _Part(self._client, path) | ||
|
|
||
| def _remove(self, path: str, *, tree: bool) -> None: | ||
| with _translated(path): | ||
| if tree: | ||
| clear_volume(self._client, path) | ||
| return | ||
| try: | ||
| self._client.files.delete(path) | ||
| except NotFound: | ||
| pass | ||
|
|
||
|
|
||
| class _Part: | ||
| def __init__(self, client: WorkspaceClient, path: str) -> None: | ||
| self._client, self._path = client, path | ||
| self._stream = None | ||
| self._at = 0 | ||
|
|
||
| def read_at(self, offset: int, length: int) -> bytes: | ||
| if length <= 0: | ||
| return b'' | ||
| if self._stream is None or offset != self._at: | ||
| self._open(offset) | ||
| try: | ||
| with _translated(self._path): | ||
| pieces, got = [], 0 | ||
| while got < length: | ||
| piece = self._stream.read(min(_BUFFER, length - got)) | ||
| if not piece: | ||
| break | ||
| pieces.append(piece) | ||
| got += len(piece) | ||
| except BaseException: | ||
| self.close() | ||
| raise | ||
| self._at += got | ||
| return b''.join(pieces) | ||
|
|
||
| def _open(self, offset: int) -> None: | ||
| self.close() | ||
| with _translated(self._path): | ||
| response = self._client.api_client.do( | ||
| 'GET', f'{_FILES}{quote(self._path)}', raw=True, | ||
| response_headers=['content-range'], headers={ | ||
| 'Accept': 'application/octet-stream', | ||
| 'Range': f'bytes={offset}-' | ||
| }) | ||
| self._stream = response['contents'] | ||
| served = response.get('content-range') or '' | ||
| if not served.startswith(f'bytes {offset}-'): | ||
| self.close() | ||
| raise OSError(f'{self._path}: asked for bytes from {offset}, ' | ||
| f'got {served or "the whole file"}') | ||
| self._stream.set_chunk_size(_BUFFER) | ||
| self._at = offset | ||
|
|
||
| def close(self) -> None: | ||
| if self._stream is not None: | ||
| self._stream.close() | ||
| self._stream = None | ||
|
|
||
|
|
||
| _CANCEL = object() | ||
|
|
||
|
|
||
| class _Feed(io.RawIOBase): | ||
| def __init__(self) -> None: | ||
| self.pieces: queue.Queue[Any] = queue.Queue(maxsize=4) | ||
| self._pending = b'' | ||
| self._done = False | ||
|
|
||
| def readable(self) -> bool: | ||
| return True | ||
|
|
||
| def readinto(self, buffer) -> int: | ||
| while not self._pending and not self._done: | ||
| piece = self.pieces.get() | ||
| if piece is _CANCEL: | ||
| raise OSError('upload cancelled') | ||
| if piece is None: | ||
| self._done = True | ||
| else: | ||
| self._pending = piece | ||
| size = min(len(buffer), len(self._pending)) | ||
| buffer[:size] = self._pending[:size] | ||
| self._pending = self._pending[size:] | ||
| return size | ||
|
|
||
|
|
||
| class _Upload(io.RawIOBase): | ||
| def __init__(self, client: WorkspaceClient, path: str) -> None: | ||
| self._path = path | ||
| self._feed = _Feed() | ||
| self._error: Optional[BaseException] = None | ||
| source = io.BufferedReader(self._feed, _BUFFER) | ||
|
|
||
| def upload() -> None: | ||
| try: | ||
| with _translated(path): | ||
| client.files.upload(path, source, overwrite=True) | ||
| except BaseException as error: | ||
| self._error = error | ||
|
|
||
| self._thread = threading.Thread(target=upload, daemon=True) | ||
| self._thread.start() | ||
|
|
||
| def writable(self) -> bool: | ||
| return True | ||
|
|
||
| def write(self, data) -> int: | ||
| piece = bytes(data) | ||
| if not self._offer(piece): | ||
| self._finish() | ||
| raise OSError(f'{self._path}: the upload stopped reading') | ||
| return len(piece) | ||
|
|
||
| def _offer(self, piece: Any) -> bool: | ||
| while self._thread.is_alive(): | ||
| try: | ||
| self._feed.pieces.put(piece, timeout=1) | ||
| return True | ||
| except queue.Full: | ||
| continue | ||
| return False | ||
|
|
||
| def _finish(self) -> None: | ||
| self._thread.join() | ||
| if self._error is not None: | ||
| raise self._error | ||
|
|
||
| def abort(self) -> None: | ||
| if self.closed: | ||
| return | ||
| self._offer(_CANCEL) | ||
| self._thread.join() | ||
| super().close() | ||
|
|
||
| def close(self) -> None: | ||
| if self.closed: | ||
| return | ||
| try: | ||
| self._offer(None) | ||
| self._finish() | ||
| finally: | ||
| super().close() | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
Script executed:
Repository: NVIDIA/kumo-engine
Length of output: 15014
Script executed:
Repository: NVIDIA/kumo-engine
Length of output: 20259
Web query:
Databricks SDK for Python v0.72.0 files.upload implementation use_parallel part_size max_concurrency queue seekable file bufferingResult:
Web query:
Databricks SDK Python v0.73.0 files.py _UploadContext part_size parallelism files.upload defaultResult:
Script executed:
Repository: NVIDIA/kumo-engine
Length of output: 26614
Script executed:
Repository: NVIDIA/kumo-engine
Length of output: 26887
Script executed:
Repository: NVIDIA/kumo-engine
Length of output: 14409
Script executed:
Repository: NVIDIA/kumo-engine
Length of output: 679
Bound memory used by plain-object uploads.
A 3 GiB unsized CSV reaches
_putas a seekable spool. With the locked Databricks SDK 0.73.0 defaults,files.uploadcan retain over 1 GiB across its multipart workers, queue, producer, and read-ahead buffer. This is materially more memory than the bounded chunked stream path and can terminate a memory-constrained scoring process.Bound the SDK upload buffering by disabling parallel upload or by passing explicit
part_sizeandparallelismsettings. Preserve compatibility with the declareddatabricks-sdk>=0.49range and keep one remote object.