Skip to content

fix: auto-size multipart parts for Dir.from_local_sync uploads - #1462

Merged
kumare3 merged 5 commits into
flyteorg:mainfrom
mjurkus:fix/large_file_sync
Sep 4, 2026
Merged

fix: auto-size multipart parts for Dir.from_local_sync uploads#1462
kumare3 merged 5 commits into
flyteorg:mainfrom
mjurkus:fix/large_file_sync

Conversation

@mjurkus

@mjurkus mjurkus commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Bug: Dir.from_local_sync skips the multipart auto-sizer → large directory uploads fail on S3/Ceph with 416 InvalidRange

Summary

Dir.from_local_sync uploads via raw fsspec (fs.put(..., recursive=True)), which pins every multipart part to obstore's 5 MiB default. The async Dir.from_local instead routes through storage.put, which auto-sizes the part size. Any single file larger than ~48.8 GiB (10,000 parts × 5 MiB) therefore fails from a sync task but succeeds from an async task.

Impact

  • A sync @env.task returning a Dir that contains a large file (observed: a single 160 GiB file) fails at upload.
  • Real error from Ceph RadosGW (S3): POST .../file?uploadId=... → 416 Range Not Satisfiable, <Code>InvalidRange</Code>, rejected in ~1 ms (the CompleteMultipartUpload manifest exceeds the 10,000-part limit).
  • 160 GiB ÷ 5 MiB = 32,768 parts → over the 10,000 hard cap. The same output from an async task produces ~9000 parts (~18 MiB each) and succeeds.

Root cause

src/flyte/io/_dir.py, from_local_sync:

fs = storage.get_underlying_filesystem(path=resolved_remote_path)
fs.put(local_path_str, resolved_remote_path, recursive=True)
return cls(path=resolved_remote_path, name=dirname, hash=dir_cache_key)

fsspec's obstore backend calls store.put_async without chunk_size, leaving it at obstore's 5 MiB default — the exact scenario flyte/storage/_storage.py documents (its _put_obstore_bypass/_compute_upload_chunk_size machinery exists specifically to avoid this, noting the "~48.8 GiB hard ceiling (10,000 parts)").

The async sibling from_local does it correctly:

output_path = await storage.put(
    from_path=local_path_str, to_path=resolved_remote_path, recursive=True, batch_size=batch_size
)

So this is a straight asymmetry: from_local_sync never got the obstore-aware path. Notably, Dir.download_sync already fixed the download side of this same class of bug using syncify:

# Route through the obstore-aware storage.get (via syncify) rather than the raw fsspec ...
return syncify(storage.get)(self.path, local_dest, recursive=True)

related: #1320

@mjurkus
mjurkus force-pushed the fix/large_file_sync branch from 233b742 to 4ccc4ff Compare August 21, 2026 11:29
Signed-off-by: Martynas Jurkus <martynas@vinted.com>
@mjurkus
mjurkus force-pushed the fix/large_file_sync branch from 4ccc4ff to ab06a5b Compare August 21, 2026 13:17
Comment thread src/flyte/io/_dir.py
fs = storage.get_underlying_filesystem(path=resolved_remote_path)
fs.put(local_path_str, resolved_remote_path, recursive=True)
return cls(path=resolved_remote_path, name=dirname, hash=dir_cache_key)
# Route through the obstore-aware storage.put (via syncify) rather than the raw fsspec

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this looks like a great fix. I am looking if file/dataframe also need this fix

@kumare3

kumare3 commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

can you fix the lint and then +1. looks right. found similar issue in file and dataframe.

@kumare3

kumare3 commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

@mjurkus / @danielsalvador fixed in file too - #1480 (not really a fix, but improvement) thank you

Signed-off-by: Martynas Jurkus <martynas@vinted.com>
@mjurkus

mjurkus commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

I think clippy version was bumpped and all .github/workflows/lint.yml runs should fail. On other PRs too.

cosmicBboy pushed a commit that referenced this pull request Sep 3, 2026
…shed from_local) (#1480)

Follow-up to #1462, which fixed `Dir.from_local_sync`. `File` had the
same class of bug in two places.

### Summary
On obstore-backed filesystems (s3/gs/abfs), fsspec's
`BufferedFile("wb")` and our `_open_obstore_bypass` both write with a
fixed 10 MiB buffer, and obstore uploads one multipart part per buffer.
That caps a single object at ~97.6 GiB (10,000 parts × 10 MiB) — 2× the
`Dir` threshold from #1462, but the same failure (`InvalidRange` /
part-limit rejection at `CompleteMultipartUpload`). The 160 GiB file in
#1462 would also fail through `File.from_local_sync`.

Two paths were affected:
1. **`File.from_local_sync`** (non-hash and precomputed-hash branches)
uploaded via raw `fs.open(remote_path, "wb")` + `shutil.copyfileobj` —
never touched the obstore-aware `storage.put` that auto-sizes parts.
2. **`File.from_local` / `from_local_sync` with a `HashMethod`**
streamed through `storage.put_stream`, which had no way to know the
total size and so kept the fixed 10 MiB part. This one affected the
**async** path too.

### Changes
- `File.from_local_sync`: route through `syncify(storage.put)`,
mirroring `Dir.from_local_sync` (#1462) and `Dir.download_sync`.
- `storage.put_stream(..., size_hint=)`: new optional kwarg. If the
hinted size would blow past `_MAX_SAFE_PARTS` at the default buffer,
`chunk_size` is scaled up via the existing `_compute_upload_chunk_size`.
Streams below ~88 GiB are byte-for-byte unchanged (no override is
passed). Explicit `chunk_size=`/`buffer_size=` kwargs still win.
- New module-level `_upload_hashed()` helper in `_file.py`, shared by
`from_local` and `from_local_sync` (via `syncify`), passing
`os.path.getsize(local_path)` as `size_hint`. This also collapses the
two duplicated hashed-upload implementations into one.
- Factored the `10 * 2**20` literal into `_STREAM_WRITE_BUFFER_SIZE`.

### Not changed (on purpose)
- **`DataFrame`**: encoders write via `df.to_parquet(storage_options=…)`
/ `pq.write_table(filesystem=…)`, i.e. pandas/pyarrow drive an fsspec
file-like with no size known up front, so there's no clean place to
plumb a hint. A single >97 GiB parquet file written from one in-memory
frame isn't a realistic path today, so leaving it.
- **`File.download_sync`**: still raw `fs.get`. Not a correctness issue
(downloads have no part cap); it just skips the parallel reader. Can
follow up separately if wanted.
- `File.open_sync("wb")`: stream API with no size knowledge; callers can
pass `buffer_size=`.

### Tests
- Rewrote the three `from_local_sync` remote tests (they mocked
`fs.open`) to assert `storage.put` / `storage.put_stream(size_hint=…)`
are used and the hash is still computed from the streamed bytes.
- Added an async `from_local` hashed test asserting `size_hint` is
passed.
- Added a parametrized `put_stream` test: no hint / small hint → no
`chunk_size` override; 200 GiB hint → `chunk_size` > default and
`ceil(size / chunk_size) <= _MAX_SAFE_PARTS`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Ketan Umare <kumare3@users.noreply.github.com>
Co-authored-by: Ketan Umare <kumare3@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@kumare3
kumare3 enabled auto-merge (squash) September 4, 2026 06:21
@kumare3
kumare3 merged commit d6aeabb into flyteorg:main Sep 4, 2026
65 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants