The defect
projects/fal_client/src/fal_client/client.py, sync MultipartUpload.save: the per-part slice is assigned back onto data itself.
for part_number in range(1, parts + 1):
start = (part_number - 1) * multipart.chunk_size
data = data[start : start + multipart.chunk_size]
futures.append(
executor.submit(multipart.upload_part, part_number, data)
)
Iteration 1 replaces the full buffer with its first chunk, so iteration 2 computes start = chunk_size and slices the now chunk-sized buffer, yielding b"". Every part from 2 to N is uploaded as zero bytes, while the part count and ETag bookkeeping stay well formed, so nothing raises and the stored object is silently truncated to the first chunk.
The async twin, AsyncMultipartUpload.save, does the same operation correctly with a separate variable: chunk = data[start : start + multipart.chunk_size]. Both save_file variants are also correct. Only the sync bytes path reuses the name data.
Repro
Upload a payload larger than MULTIPART_THRESHOLD (100MB) through fal_client.SyncClient.upload as bytes, download it back, compare hashes: the object is truncated to the first 10MB chunk. Present since the multipart feature landed (#413, Feb 2025). Existing unit tests mock MultipartUpload.save itself, so the loop has no coverage.
Fix, ready on a fork
Two-line fix mirroring the async implementation plus a regression test that captures (part_number, data) pairs across a 3-part payload. Against the current code the test fails with parts 2 and 3 empty; with the fix the full fal_client unit suite passes (99 passed).
Branch: https://github.com/arthi-arumugam-git/fal/tree/fix/sync-multipart-empty-chunks (commit f55e790, diff: main...arthi-arumugam-git:fal:fix/sync-multipart-empty-chunks)
Pull request creation on this repository is limited to collaborators, so the fix is linked here instead. Happy to open the PR if access is enabled, or maintainers are welcome to cherry-pick the commit.
The defect
projects/fal_client/src/fal_client/client.py, syncMultipartUpload.save: the per-part slice is assigned back ontodataitself.Iteration 1 replaces the full buffer with its first chunk, so iteration 2 computes
start = chunk_sizeand slices the now chunk-sized buffer, yieldingb"". Every part from 2 to N is uploaded as zero bytes, while the part count and ETag bookkeeping stay well formed, so nothing raises and the stored object is silently truncated to the first chunk.The async twin,
AsyncMultipartUpload.save, does the same operation correctly with a separate variable:chunk = data[start : start + multipart.chunk_size]. Bothsave_filevariants are also correct. Only the sync bytes path reuses the namedata.Repro
Upload a payload larger than
MULTIPART_THRESHOLD(100MB) throughfal_client.SyncClient.uploadas bytes, download it back, compare hashes: the object is truncated to the first 10MB chunk. Present since the multipart feature landed (#413, Feb 2025). Existing unit tests mockMultipartUpload.saveitself, so the loop has no coverage.Fix, ready on a fork
Two-line fix mirroring the async implementation plus a regression test that captures
(part_number, data)pairs across a 3-part payload. Against the current code the test fails with parts 2 and 3 empty; with the fix the full fal_client unit suite passes (99 passed).Branch: https://github.com/arthi-arumugam-git/fal/tree/fix/sync-multipart-empty-chunks (commit f55e790, diff: main...arthi-arumugam-git:fal:fix/sync-multipart-empty-chunks)
Pull request creation on this repository is limited to collaborators, so the fix is linked here instead. Happy to open the PR if access is enabled, or maintainers are welcome to cherry-pick the commit.