fix: auto-size multipart parts for Dir.from_local_sync uploads - #1462
Merged
Conversation
danielsalvador
approved these changes
Aug 21, 2026
mjurkus
force-pushed
the
fix/large_file_sync
branch
from
August 21, 2026 11:29
233b742 to
4ccc4ff
Compare
Signed-off-by: Martynas Jurkus <martynas@vinted.com>
mjurkus
force-pushed
the
fix/large_file_sync
branch
from
August 21, 2026 13:17
4ccc4ff to
ab06a5b
Compare
kumare3
reviewed
Aug 25, 2026
| 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 |
Contributor
There was a problem hiding this comment.
this looks like a great fix. I am looking if file/dataframe also need this fix
Contributor
|
can you fix the lint and then +1. looks right. found similar issue in file and dataframe. |
Contributor
|
@mjurkus / @danielsalvador fixed in file too - #1480 (not really a fix, but improvement) thank you |
Signed-off-by: Martynas Jurkus <martynas@vinted.com>
Contributor
Author
|
I think clippy version was bumpped and all |
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
approved these changes
Sep 4, 2026
kumare3
enabled auto-merge (squash)
September 4, 2026 06:21
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Bug:
Dir.from_local_syncskips the multipart auto-sizer → large directory uploads fail on S3/Ceph with416 InvalidRangeSummary
Dir.from_local_syncuploads via raw fsspec (fs.put(..., recursive=True)), which pins every multipart part to obstore's 5 MiB default. The asyncDir.from_localinstead routes throughstorage.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
@env.taskreturning aDirthat contains a large file (observed: a single 160 GiB file) fails at upload.POST .../file?uploadId=... → 416 Range Not Satisfiable, <Code>InvalidRange</Code>, rejected in ~1 ms (theCompleteMultipartUploadmanifest exceeds the 10,000-part limit).Root cause
src/flyte/io/_dir.py,from_local_sync:fsspec's obstore backend calls
store.put_asyncwithoutchunk_size, leaving it at obstore's 5 MiB default — the exact scenarioflyte/storage/_storage.pydocuments (its_put_obstore_bypass/_compute_upload_chunk_sizemachinery exists specifically to avoid this, noting the "~48.8 GiB hard ceiling (10,000 parts)").The async sibling
from_localdoes it correctly:So this is a straight asymmetry:
from_local_syncnever got the obstore-aware path. Notably,Dir.download_syncalready fixed the download side of this same class of bug usingsyncify:related: #1320