Fix empty object write for small files in fsspec transactions#721
Merged
Conversation
b2f8920 to
58ef007
Compare
Small files (< block size) written inside an fsspec transaction (autocommit=False) were committed as empty objects. S3File._upload_chunk returned True unconditionally, so fsspec's flush() always reset self.buffer after the final flush. For a small file the upload is deferred to commit(); in the transaction case commit() runs after the reset and reads an empty buffer, sending body=b"" to PutObject. fsspec only resets the buffer when _upload_chunk returns a value other than False (spec.py: `if self._upload_chunk(...) is not False:`). Return `not final` instead: mid-stream chunks (final=False) still return True so fsspec clears the already-uploaded buffer between parts, but the final flush returns False, leaving the buffer intact for the deferred commit() to read. This mirrors s3fs, which returns False to keep the buffer; commit() is unchanged and reads the buffer as before. AioS3File inherits _upload_chunk/commit from S3File, so the async filesystem benefits from the same fix. Tests: - Unit (no AWS): _upload_chunk/commit return-value contract with a mocked _put_object, covering the small-file transaction regression, autocommit, empty-file touch, small-file discard (rollback), and the multipart mid-stream `not final` invariant (non-final -> True so fsspec resets the buffer between parts; final -> False). - Integration (sync and async): write inside a transaction round-trips, and a raise inside the transaction leaves no object. Parametrized over a small (one-shot PutObject) and a 10 MiB (multipart) size so both the CompleteMultipartUpload and the discard()/AbortMultipartUpload paths are exercised. Large cases are kept to three uploads total and live at the filesystem layer (not multiplied across cursor types) to bound AWS cost. Fixes #719 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
58ef007 to
a6e6b52
Compare
12 tasks
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.
WHAT
Fixes #719 — small files (smaller than the block size, default 5 MiB) written inside an fsspec transaction (
autocommit=False) were committed to S3 as empty objects.The fix in
pyathena/filesystem/s3.pyis a one-liner (twice):S3File._upload_chunknow returnsnot finalinstead ofTrue.commit()and__init__are unchanged.WHY
S3File.close()triggers fsspec'sflush(force=True), and fsspec resetsself.bufferafter the upload only when_upload_chunkreturns a value other thanFalse:_upload_chunkpreviously returnedTrueunconditionally. For a small file the actual upload is deferred tocommit():open()):commit()runs synchronously inside_upload_chunk, before the reset — works.autocommit=False):commit()is deferred toTransaction.complete(), which runs after fsspec reset the buffer, so it reads an empty buffer and sendsbody=b""toPutObject.Returning
not finalfixes this:final=False) →True: fsspec clears the already-uploaded buffer between multipart parts (PyAthena delegates mid-stream buffer management to fsspec).final=True) →False: fsspec leaves the buffer intact, so the deferredcommit()reads the real bytes.This mirrors how s3fs solves the same problem — s3fs returns
Falsefrom_upload_chunkto keep the buffer, andcommit()reads from it. (s3fs returnsFalseunconditionally because it does its own mid-stream buffer bookkeeping; PyAthena usesnot finalbecause it relies on fsspec for that.) The large-file/multipart path'scommit()completes the upload from the already-uploaded parts and never reads the buffer.AioS3Fileinherits_upload_chunk/commitfromS3File, so the async filesystem benefits from the same fix.This keeps PyAthena's custom
S3FileSystem(which intentionally avoids thes3fs/aiobotocoredependency) and supports fsspec transactions properly rather than handing filesystem ownership back tos3fs.Tests
Unit (no AWS) —
tests/pyathena/filesystem/test_s3.py::TestS3FileDrive
S3File._upload_chunk/commit/discardwith a mocked filesystem (no AWS), asserting the fsspec return-value contract. Parametrized to cover the full matrix ofautocommit(transaction vs not) × single vs multipart × commit vs rollback:test_upload_chunk_small_file[True]PutObject, committed in-linetest_upload_chunk_small_file[False]test_upload_chunk_multipart[True]CompleteMultipartUploadin-linetest_upload_chunk_multipart[False]CompleteMultipartUploaddeferred to committest_discard[False]test_discard[True]AbortMultipartUploadtest_upload_chunk_empty_file_touchestouch(neverPutObject)The multipart tests also assert the mid-stream invariant (non-final →
Trueso fsspec resets the buffer between parts; final →False), which guards against a future "simplification" to a plainreturn Falsethat would corrupt multipart uploads.Integration (real AWS) — sync (
test_s3.py) and async (test_s3_async.py)test_write_transaction/test_write_transaction_rollback, parametrized over a small (one-shotPutObject) and a 10 MiB (multipart) size:with fs.transaction:round-trips with the real content;CompleteMultipartUploadvsAbortMultipartUploadpaths exercised end-to-end, incl. the async executor).AWS cost. The large (10 MiB) cases are bounded to three uploads total (sync write, sync rollback, async write; the async rollback stays small since
AioS3Fileinheritsdiscard()). These live at the filesystem layer, so they run once each and are not multiplied across cursor types (pandas/arrow/polars). No multi-GiB cases are added.Verification
make lint— passes (ruff + format check + mypy).[False]unit cases fail when the source fix is reverted (regression guard), and the multipart mid-stream assertion fails under a plainreturn False(anti-simplification guard).🤖 Generated with Claude Code