Skip to content

Let any sink hold files: FileStoreSink protocol and a chunked filesystem - #112

Open
Vedaant-J wants to merge 1 commit into
vedaant/mlops-holdout-fit-modefrom
vedaant/mlops-filestore-sink
Open

Vedaant-J wants to merge 1 commit into
vedaant/mlops-holdout-fit-modefrom
vedaant/mlops-filestore-sink

Conversation

@Vedaant-J

@Vedaant-J Vedaant-J commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Series: kumo-engine MLOps series: every snapshot, training table, model and prediction run carries a self-contained _provenance.json; with kumo.mlops(...) stores each artifact under one artifact root, tracks fits and prediction runs in MLflow or Snowflake and registers models; volumes and stages can hold files; prediction runs are monitored for drift and realized performance.
Stack: #53 → #112 → #50 → #60 → #64; #114 on #112; #115 on #64
Merge order: one linear stack; merge bottom to top (#53 first); #114 sits on #112 and merges any time after it; #115 (draft) sits on #64 and merges after it
This PR: Adds a sink capability for holding files (FileStoreSink) and a chunked pyarrow filesystem for stores that can only put, list, read and remove whole objects.

Summary

This PR adds a sink capability for holding files (FileStoreSink) and a chunked pyarrow filesystem for stores that can only put, list, read and remove whole objects. With these, Databricks volumes and Snowflake stages can hold the same exports, bundles and manifests that object stores hold today. The volume and stage sinks themselves are #114 and #115, which build on this one.

Problem. Teams that keep everything inside Databricks or Snowflake ask:

  • Can the snapshot, model and prediction files live on our Unity Catalog volume or Snowflake stage, next to the tables, instead of a separate bucket?
  • Can PredictionOutputConfig(output_paths=...) write Parquet to a /Volumes/... or @stage/... path?
  • Will a 20 GB snapshot file upload to a store whose single upload is capped at 5 GiB (Databricks Files API) or has no multipart at all (a stage PUT)?

Today the answer to all three is no.

Cause.

  • The engine decides "can this connector hold files" by type: connector.source_type in {'S3', 'GCS', 'ADLS', 'LOCAL_FILE'} in the output configs. A Databricks or Snowflake connector is always a table destination.
  • Volumes and stages have no pyarrow filesystem, no rename, no append, and size caps on single objects.

Change.

  • kumoapi.plugin.FileStoreSink is an optional protocol any sink can implement:

    def holds_files(self, connector, path) -> bool          # from the path alone: file location or table name
    def filesystem(self, connector, path) -> (pyarrow.fs.FileSystem, str)

    ObjectStoreSinkBackend implements it, with holds_files always True.

  • kumo.engine.connector.is_file_destination(connector, path) and file_store(connector, path) are the engine's single file-vs-table check.

    • PredictionOutputConfig(output_paths=) accepts paths the sink holds as files.
    • TrainingTableOutputConfig adds the directory slash to such paths.
    • For object stores both behave exactly as before.
  • kumo_core.object_store_sink:

    • FilesystemSink(sink) runs the existing object-store write_files, write_table, publish_model(path=) and fetch_model(path=) over any sink's filesystem(). A volume or stage sink delegates its file paths to it and keeps its table code.
    • upload_bundle/download_bundle are the publish/fetch bodies, now shared; the manifest file goes last.
    • open_sized(fs, path, size) tells a chunked filesystem a file's size.
    • kumo_core.artifacts.PROVENANCE_FILE = '_provenance.json' is the one manifest file name. The leading underscore keeps Spark/Hive Parquet readers off it.
  • kumo_core.chunked_fs.ChunkedFileHandler is a pyarrow.fs.FileSystemHandler over five store primitives (size, list, put, ranged part read, remove), plus an optional streaming upload. A file up to part_bytes is one plain object; a larger one is stored as numbered parts committed by a trailing _SUCCESS:

    @stage/kumo/snapshots/<graph>/<version>/index/
      col_stats.json                       # plain object, <= part_bytes
      nodes/events/col_3.bin.chunks/       # one logical 9.3 GiB file
        00000  00001  00002                # part_bytes each, the last shorter
        _SUCCESS                           # written last; without it the file does not exist
    
    • Readers see one logical file. Ranged reads are byte-exact across part boundaries.
    • A write removes the other form. If a crash leaves both, the plain object wins.
    • A writer told the size (all engine writers are) streams a file up to part_bytes as one plain object, readable by Spark, and a larger one as streamed parts, with no local disk. Frames written through write_table go via a local spool, so they stay plain too.
    • Spooling happens under the engine scratch directory, read at each write. Resolved layouts are cached per filesystem and dropped on any write or delete. Reads copy each part slice once, into the caller's buffer.
  • Type check. kumoapi.json_serde.from_json passes cast(Any, cls) to TypeAdapter (no behaviour change): this PR's kumo-api change runs the Kumo API ty job, and ty 0.0.77 rejects the existing Union[Type[T], object] value as a type expression.

Back-compat

Record and expose, never enforce: object-store and local connectors take the same paths as before, and nothing new raises on them.

Situation Behaviour
S3/GCS/local export, publish, fetch unchanged (same code, now shared through upload_bundle/download_bundle)
PredictionOutputConfig(output_paths=) on an object store unchanged
output_paths on a warehouse connector whose sink does not hold the path raises ValueError, as today (message unchanged at the start)
A sink without FileStoreSink treated as a table destination, as today
Chunked file read while a write is in flight the uncommitted .chunks directory is invisible. A write that stays plain leaves the previous plain object readable until it lands; a write that becomes chunked deletes the previous object first, so a concurrent reader can get FileNotFoundError until _SUCCESS is written

Known limitations

Testing Done

  • kumo-core/tests/test_chunked_fs.py, 26 tests:
    • files at 0, partial, exactly one and exactly three part sizes;
    • a range spanning two parts, and a buffer read spanning parts;
    • a write removing the other form; a crash leaving both forms (plain wins);
    • listings and deletes;
    • streamed parts; a size-hinted plain stream; spool location;
    • 20 threads rewriting while the layout cache invalidates.
  • kumo-engine/test/test_filestore_sink.py:
    • the protocol on object-store and filesystem-only sinks;
    • output configs taking stage paths;
    • a bundle round trip and Parquet writes through FilesystemSink over a chunked in-memory store.
  • Existing suites:
    • engine: file connector (77), prediction completion, output preflight, trainer registry;
    • kumo-core: artifact export and prediction output scalability;
    • kumo-s3/kumo-gcs: sink tests.

Checklist

  • Tests added
  • Docs: none (no user-visible docs change here)

🤖 Generated with Claude Code

@copy-pr-bot

copy-pr-bot Bot commented Oct 24, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@Vedaant-J
Vedaant-J temporarily deployed to gha-kumo-workflow-tests October 24, 2026 17:17 — with GitHub Actions Inactive
@coderabbitai

coderabbitai Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Walkthrough

The changes add a file-store sink protocol and a PyArrow filesystem handler that exposes plain objects and committed chunk directories as logical files. Sink operations use file sizes for chunked writes and upload provenance files last during bundle transfers. Connector helpers identify file-backed destinations, and output configuration uses them to validate and normalize paths. Tests cover chunked storage, sink transfers, and destination checks.

Sequence Diagram(s)

sequenceDiagram
  participant ObjectStoreSinkBackend
  participant upload_bundle
  participant PyArrowFileSystem
  participant ChunkedFileHandler
  ObjectStoreSinkBackend->>upload_bundle: Upload model bundle
  upload_bundle->>PyArrowFileSystem: Open sized output streams
  PyArrowFileSystem->>ChunkedFileHandler: Pass declared file sizes
  ChunkedFileHandler-->>PyArrowFileSystem: Commit plain object or chunks
  upload_bundle->>PyArrowFileSystem: Write provenance file last
Loading

Priority: Normal

Merge Risk: Moderate · up to fc4d7

File-capable sinks can be accepted but fail during training-table export, model operations, or prediction export. Resolve these integration gaps and the outstanding chunked-file risks before merging.

Pre-merge checks: Passed 2 · Inconclusive 1

Failed Failed checks: 1 inconclusive

Check name Status Explanation Resolution
Change Map Design Review Inconclusive The required conversation comment beginning <!-- kumo-pr-change-map --> is not present in the supplied context or repository checkout. The available summary describes the change map but does not pro… Provide the complete PR change-map comment, including its file/function scope, linked problem evidence, existing behavior, smallest sufficient change, focused verification, and stopping condition. Then reassess the implementation against th…
Passed Passed checks: 2 passed
Check name Status Explanation
Pr Size Passed The pull request changes 11 files. The limit is 40 files, so the PR passes this check.
Public Resource Access Passed The pull request adds no external URL, repository location, or package source location. The only URI-like added text is code that strips a sink scheme, not a resource reference. The @stage/... value…
Full details: Public Resource Access

Explanation

The pull request adds no external URL, repository location, or package source location. The only URI-like added text is code that strips a sink scheme, not a resource reference. The @stage/... values occur in an in-memory test fixture and illustrative test paths, which the check excludes. No dependency or package-source manifest changed.

Full details: Change Map Design Review

Explanation

The required conversation comment beginning &lt;!-- kumo-pr-change-map --&gt; is not present in the supplied context or repository checkout. The available summary describes the change map but does not provide its required rationale, observed failure, verification, or stopping condition, so the design cannot be assessed against that comment.

Resolution

Provide the complete PR change-map comment, including its file/function scope, linked problem evidence, existing behavior, smallest sufficient change, focused verification, and stopping condition. Then reassess the implementation against that source.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@Vedaant-J

Vedaant-J commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor Author

Change map

File Function / scope What changed Why
packages/kumo-api/kumoapi/json_serde.py from_json TypeAdapter(cast(Any, cls)); no behaviour change ty 0.0.77 rejects a Union[Type[T], object] value as a type expression; this PR's kumo-api change runs that check
packages/kumo-api/kumoapi/plugin.py FileStoreSink new optional protocol: holds_files, filesystem lets any sink offer file paths, not only object stores
packages/kumo-core/src/kumo_core/artifacts.py PROVENANCE_FILE one manifest file name, _provenance.json one constant for every artifact; Parquet readers skip _ files
packages/kumo-core/src/kumo_core/chunked_fs.py ChunkedFileHandler, _Reader, _Writer, spool_root, set_spool_dir new pyarrow handler over put/list/read/remove stores: plain or .chunks parts, size-hinted streaming, layout cache, one-copy reads volumes and stages have no filesystem, rename or append and cap single objects
packages/kumo-core/src/kumo_core/object_store_sink.py upload_bundle, download_bundle, open_sized, FilesystemSink, ObjectStoreSinkBackend.holds_files/filesystem, _write_one shared bundle bodies; size hints; object-store writers over any filesystem(); frames spooled on chunked filesystems one write path for every file store; files up to one part stay plain
packages/kumo-core/tests/test_chunked_fs.py tests layouts, boundary reads, crash rule, streaming, spool, thread safety behaviour of the chunked filesystem
packages/kumo-engine/kumo/engine/_scratch.py _spool_dir, _spool_parts chunked writers spool under the scratch directory, read at each write keep large parts off small /tmp disks
packages/kumo-engine/kumo/engine/connector/__init__.py is_file_destination, file_store the engine's file-vs-table check one gate instead of type checks
packages/kumo-engine/kumo/engine/trainer/output_config.py PredictionOutputConfig.__post_init__, TrainingTableOutputConfig.__post_init__ gate output_paths and the directory slash on is_file_destination volume and stage paths become file destinations; object stores unchanged
packages/kumo-engine/test/test_filestore_sink.py tests protocol, output configs, bundles and Parquet over a filesystem-only sink the capability end to end without a cloud
packages/kumo-engine/test/test_prediction_completion.py test fake sink the fake file sink implements the protocol the output-config gate now asks the sink

Vedaant-J added a commit that referenced this pull request Oct 24, 2026
…cope

Snapshot export took (connector, path), gated on object-store sinks,
and was the only place a snapshot reached durable storage.

- GraphSnapshot.export(SnapshotOutputConfig(connector, path)), in the
  style of the other output configs; any sink that holds files at the
  path (FileStoreSink, #112) can take it, and every upload tells a
  chunked store its size so files up to one part stay plain.
- Graph.snapshot(mlops=): with MLOps on, the snapshot is exported under
  <root>/snapshots/ unless a complete export of the same format and id
  is there; failures warn.
- ConcurrentExportError is a RuntimeError (no retry role).
  GraphSnapshot.graph_key/data_version are private. The README section
  moves to docs/mlops.md.

Verified: test_snapshot_export.py (30: config form, a filesystem-only
chunked sink round trip, stored once in a block, existing export,
retry and repair cases), snapshot manifest and training-table suites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Vedaant-J
Vedaant-J added this pull request to stack #113 October 24, 2026 19:06

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Note

Quiet mode is enabled, so only the most important comments were posted inline. Other review comments are grouped below.

Other comments: 2
packages/kumo-core/src/kumo_core/chunked_fs.py: 2

104-114: Minor · Stability & Availability · Quick win

Fix the stale-layout race between _layout and _forget.

_layout computes a layout without a lock and then stores it in the cache. A writer can finish and call _forget(path) between the compute step and the store step. The reader then stores the old layout after the invalidation.

Nothing removes that entry afterward. The _Reader uses the cached sizes. If the new file is larger, a read returns truncated data without an error. If the sizes differ in another way, the read raises OSError.

The race needs a reader and a writer on the same path in one process. test_layout_cache_survives_concurrent_opens_and_writes does not cover it. In that test, each thread reads and writes only its own file.

Use an invalidation counter. Store the layout only if no _forget call ran during the compute step.

Proposed fix
     def _layout(self, path: str) -> Optional[List[Tuple[str, int]]]:
         cache = self.__dict__.setdefault('_layouts', {})
         layout = cache.get(path)
         if layout is not None:
             return layout
+        epoch = self.__dict__.get('_epoch', 0)
         size = self._size(path)
         layout = ([(path, size)] if size is not None else _logical_parts(
             self._list(path + CHUNKS), path + CHUNKS))
-        if layout is not None:
+        if layout is not None and self.__dict__.get('_epoch', 0) == epoch:
             cache[path] = layout
         return layout

In _forget, increment the counter before it removes entries:

        self.__dict__['_epoch'] = self.__dict__.get('_epoch', 0) + 1

If you need exact atomicity across threads, guard the counter update and the cache store with one threading.Lock.

Add a test in which one thread writes a path while other threads read the same path.


321-341: Minor · Data Integrity & Integration · Quick win

Guard failed _Writer construction from finalization.

When _open_part() fails after _clear(), _Writer remains partially initialized. io.RawIOBase finalization calls _Writer.close(), whose final branch uploads the empty spool as the plain object. _clear() has already removed the old chunked representation.

super().close() directly calls the inherited close method and does not re-enter _Writer.close(). Keep the resource setup inside the guard:

Suggested fix
-        self._dir = tempfile.mkdtemp(prefix='kumo-chunked-',
-                                     dir=spool_root(handler))
-        self._spool = open(os.path.join(self._dir, 'spool'), 'wb')
-        self._filled = 0
-        self._parts = 0
-        self._written = 0
-        self._part: Optional[BinaryIO] = None
-        self._streams = type(handler)._stream is not ChunkedFileHandler._stream
-        self._limit = (min(handler.plain_bytes, handler.part_bytes)
-                       if self._streams else handler.part_bytes)
-        self._plain: Optional[BinaryIO] = None
-        if self._streams and size is not None:
-            if size <= handler.part_bytes:
-                self._plain = handler._stream(path)
-            else:
-                self._clear()
-                self._part = self._open_part()
+        directory: Optional[str] = None
+        spool: Optional[BinaryIO] = None
+        try:
+            directory = tempfile.mkdtemp(prefix='kumo-chunked-',
+                                         dir=spool_root(handler))
+            self._dir = directory
+            spool = open(os.path.join(directory, 'spool'), 'wb')
+            self._spool = spool
+            self._filled = 0
+            self._parts = 0
+            self._written = 0
+            self._part: Optional[BinaryIO] = None
+            self._streams = type(handler)._stream is not ChunkedFileHandler._stream
+            self._limit = (min(handler.plain_bytes, handler.part_bytes)
+                           if self._streams else handler.part_bytes)
+            self._plain: Optional[BinaryIO] = None
+            if self._streams and size is not None:
+                if size <= handler.part_bytes:
+                    self._plain = handler._stream(path)
+                else:
+                    self._clear()
+                    self._part = self._open_part()
+        except BaseException:
+            if spool is not None:
+                spool.close()
+            if directory is not None:
+                shutil.rmtree(directory, ignore_errors=True)
+            super().close()
+            raise

This prevents the empty-object replacement. It does not restore data already removed by _clear().


Review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 893be81 and d2153fe.

Files selected for processing: 10
  • packages/kumo-api/kumoapi/plugin.py
  • packages/kumo-core/src/kumo_core/artifacts.py
  • packages/kumo-core/src/kumo_core/chunked_fs.py
  • packages/kumo-core/src/kumo_core/object_store_sink.py
  • packages/kumo-core/tests/test_chunked_fs.py
  • packages/kumo-engine/kumo/engine/_scratch.py
  • packages/kumo-engine/kumo/engine/connector/__init__.py
  • packages/kumo-engine/kumo/engine/trainer/output_config.py
  • packages/kumo-engine/test/test_filestore_sink.py
  • packages/kumo-engine/test/test_prediction_completion.py

Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 6 remain after this review.

Comment on lines +386 to +389
def _clear(self) -> None:
self._handler._remove(self._path + CHUNKS, tree=True)
if self._handler._size(self._path) is not None:
self._handler._remove(self._path, tree=False)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Major · Data Integrity & Integration · Heavy lift

🔎 Supported by static analysis

Script executed:

set -eu
printf '%s\n' '--- writer implementation ---'
nl -ba packages/kumo-core/src/kumo_core/chunked_fs.py | sed -n '180,455p'
printf '%s\n' '--- commit/overwrite/failure tests ---'
rg -n -F --glob 'test_chunked_fs.py' -e '_SUCCESS' -e 'overwrite' -e 'crash' -e 'fail' -e 'stream' -e 'spool' packages/kumo-core/tests || test "$?" -eq 1
printf '%s\n' '--- relevant test blocks ---'
nl -ba packages/kumo-core/tests/test_chunked_fs.py | sed -n '1,520p'
printf '%s\n' '--- PR diff for chunked_fs.py ---'
git diff 893be81a8ee4aecad9f2dd4f183bb20bb82589af d2153fe2d59e6e47a1ac81a214f3cf634ec72ded -- packages/kumo-core/src/kumo_core/chunked_fs.py | sed -n '1,520p'

Repository: NVIDIA/kumo-engine

Length of output: 43353


Do not clear the committed representation before chunk commit.

_start_stream() and _flush_part() remove the old representation before writing the replacement, while _SUCCESS is uploaded only during close(). If the stream, part upload, or later write fails, readers find neither the old file nor a committed new file. Stage each replacement separately and remove the old representation only after the replacement is committed. Apply this shared correction to sized, unsized-streaming, and spooled writes.

Comment on lines +41 to +43
if not all(
is_file_destination(self.connector, path)
for path in paths.values()):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Major · Functional Correctness · Heavy lift

🔎 Supported by static analysis

Script executed:

sed -n '35,65p' packages/kumo-engine/kumo/engine/trainer/output_config.py
sed -n '145,175p' packages/kumo-engine/kumo/engine/trainer/trainer.py
sed -n '650,675p' packages/kumo-engine/kumo/engine/trainer/trainer.py
sed -n '390,430p' packages/kumo-core/src/kumo_core/object_store_sink.py

Repository: NVIDIA/kumo-engine

Length of output: 5767


Script executed:

rg -n -F --glob '*.py' -e 'FilesystemSink' -e 'sink_backend' -e 'class FileStoreSink' -e 'class FileArtifactSink' packages/kumo-api packages/kumo-core packages/kumo-engine/kumo/engine | head -220
printf '\n--- connector capability boundary ---\n'
sed -n '1,120p' packages/kumo-engine/kumo/engine/connector/__init__.py
printf '\n--- protocol declarations ---\n'
sed -n '570,635p' packages/kumo-api/kumoapi/plugin.py
printf '\n--- FilesystemSink surrounding implementation ---\n'
sed -n '360,425p' packages/kumo-core/src/kumo_core/object_store_sink.py

Repository: NVIDIA/kumo-engine

Length of output: 10786


Adapt FileStoreSink before prediction export.

PredictionOutputConfig only validates the destination. It does not invoke write_files. In both prediction setup and PredictionResult._export, the code reads write_files directly from the connector's raw sink_backend. Wrap a FileStoreSink that lacks FileArtifactSink with FilesystemSink at this prediction export boundary, and use the adapter for the file writes.

Snapshot exports, model bundles and export manifests could only go to
object stores: the engine checked for ObjectStoreSinkBackend, and
output configs accepted output_paths only for S3, GCS, ADLS and local
connectors. A store that is not an object store (a Databricks volume, a
Snowflake stage) had no way in, even with a pyarrow filesystem to offer.

kumoapi.plugin.FileStoreSink is an optional sink capability:
holds_files(connector, path) says from the path alone whether it is a
file location rather than a table, and filesystem(connector, path)
returns the pyarrow filesystem. ObjectStoreSinkBackend implements it.
kumo.engine.connector.is_file_destination/file_store are the engine's
one file-vs-table gate; PredictionOutputConfig(output_paths=) and the
TrainingTableOutputConfig directory slash use it, so object stores
behave as before. kumo_core gains FilesystemSink (the object-store file
writers over any filesystem()), upload_bundle/download_bundle (the
manifest file last), open_sized (tells a chunked filesystem the size)
and PROVENANCE_FILE. The chunked filesystem streams a sized file of up
to one part as one plain object, writes frames through a local spool so
they stay plain too, spools under the engine scratch directory read at
each write, and reads parts into the caller's buffer with one copy.

Verified: test_chunked_fs.py (26), test_filestore_sink.py (protocol,
output configs, bundles and Parquet over a filesystem-only sink), file
connector, completion, preflight, registry and kumo-core export suites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Vedaant-J
Vedaant-J force-pushed the vedaant/mlops-filestore-sink branch from db74c6c to fc4d709 Compare October 28, 2026 16:25
@Vedaant-J
Vedaant-J deployed to gha-kumo-workflow-tests October 28, 2026 16:25 — with GitHub Actions Active

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Note

Quiet mode is enabled, so only the most important comments were posted inline. Other review comments are grouped below.

Other comments: 1
packages/kumo-core/src/kumo_core/object_store_sink.py: 1

414-415: Minor · Functional Correctness · Quick win

Route file-path training-table exports through FilesystemSink when the raw sink has no write_files.

TrainingTableOutputConfig accepts a file path for a FileStoreSink. TrainingTable.export then reads write_files from the raw sink and raises NotImplementedError if it is missing. The adapter that provides write_files for file paths is not selected on this path.

Use the adapter only when the raw sink lacks write_files and the destination is a file location. This keeps the existing raw write_files path for sinks that already provide it.

🐛 Suggested fix
--- a/packages/kumo-engine/kumo/engine/pquery/training_table.py
+++ b/packages/kumo-engine/kumo/engine/pquery/training_table.py
@@ -4,6 +4,8 @@
 from pathlib import Path
 from typing import TYPE_CHECKING, Optional
 
+from kumo_core.object_store_sink import FilesystemSink
 from kumo_core.model_plan import TrainingTableGenerationPlan
 from kumoapi.model_plan import \
     TrainingTableGenerationPlan as PublicGenerationPlan
 from kumoapi.train import TrainingTableSpec
 
+from kumo.engine.connector import is_file_destination
 from kumo.engine.connector.source_table import SourceTable
@@ -54,7 +56,11 @@
         connector = output_config.connector
-        write_files = getattr(connector.sink_backend, "write_files", None)
+        sink = connector.sink_backend
+        write_files = getattr(sink, "write_files", None)
+        if not callable(write_files) and is_file_destination(
+                connector, output_config.table_name):
+            write_files = FilesystemSink(sink).write_files
         if not callable(write_files):

Review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between db74c6c and fc4d709.

Files selected for processing: 6
  • packages/kumo-api/kumoapi/json_serde.py
  • packages/kumo-api/kumoapi/plugin.py
  • packages/kumo-core/src/kumo_core/artifacts.py
  • packages/kumo-core/src/kumo_core/object_store_sink.py
  • packages/kumo-engine/kumo/engine/connector/__init__.py
  • packages/kumo-engine/kumo/engine/trainer/output_config.py

Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 11 remain after this review.

Comment on lines +417 to +419
def publish_model(self, *args, **kwargs):
published = super().publish_model(*args, **kwargs)
return replace(published, uri=self._bare(published.uri))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Major · Functional Correctness · Quick win

Use FilesystemSink for file-path model publication and loading.

If a sink provides holds_files and filesystem but no model methods, these adapter methods work only when callers invoke FilesystemSink manually. Trainer.fit checks the raw sink for publish_model, and Trainer.load_from_connector checks it for ModelFetchingSink. Both reject that sink before using the adapter. Select FilesystemSink for accepted file paths at both entrypoints. Keep registry-location operations on the raw sink. (raw.githubusercontent.com)

This branch was successfully deployed

1 active deployment
gha-kumo-workflow-tests — fc4d709c Deployed Oct 28, 2026 by Vedaant-J via import #909
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant