Keep the existing object when appending within a larger block size - #929
Conversation
An append to an existing object of at least 5 MiB copies the object with UploadPartCopy, but both _initiate_upload() and _upload_chunk() took the one-shot PutObject path whenever the file stayed smaller than the block size. With a block size above 5 MiB the object was replaced by the appended bytes alone, or truncated to zero bytes when nothing was appended. append_block was also set for objects smaller than 5 MiB, which are read into the buffer. An append that crossed the block size then copied the object as well, duplicating its bytes in a part smaller than the minimum part size, and CompleteMultipartUpload failed with EntityTooSmall. Set append_block only for objects copied with UploadPartCopy, and always use a multipart upload for them, whatever the block size. Fixes #921 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| # object for small files. Mid-stream chunks (final=False) return True so | ||
| # fsspec clears the already-uploaded buffer between parts. | ||
| if self.tell() < self.blocksize: | ||
| if not self.append_block and self.tell() < self.blocksize: |
There was a problem hiding this comment.
Self-review round 1 (implementation behavior): CLEAN
Scope: git diff 9b74767cf65e0ef513338fb2bb2da678cdfbcd2a..e26b3980ee627d23c71fbc6a80b34b5a08be64b7 (pyathena/filesystem/s3.py, tests/pyathena/filesystem/test_s3.py).
Covered:
- Behavior and failure paths. fsspec
flush()calls_initiate_upload()once, and the non-final_upload_chunk()runs only withbuffer >= blocksize. Soappend_blockchanges only the final flush below the block size. Autocommit: CreateMultipartUpload + UploadPartCopy (part 1) + UploadPart (last, any size) + Complete. Transaction (autocommit=False): parts are uploaded andcommit()completes the upload;discard()aborts it (it previously did a PutObject of the appended bytes only). An empty append to an object ≥ 5 MiB now rewrites it via the copy part instead of truncating it, which matches the existing default-block-size behavior. Failures in create/copy/part keep the existing paths (fsspec closes the file;_finish_multipart_uploadaborts). - Data boundaries. Existing size == 5 MiB → copy part of exactly the minimum (valid). Existing < 5 MiB → buffered, never copied, so no duplication. 0-byte existing + empty append →
touch()path unchanged. Part numbering continues fromlen(multipart_upload_parts), which includes the copy parts. - Callers.
append_blockis read only inS3File(s3.py:2216,:2226,:2269).AioS3Fileinherits it unchanged._open(block_size=None)resolves todefault_block_size(s3.py:1925). - Tests.
TestS3File::test_appenddrives the real__init__→write→close→ fsspecflushand rebuilds the object from the mocked S3 calls. It failed on 9b74767 for the three defective cases. The integration test failed on real S3 before the fix (5 == 6291461,EntityTooSmall).
Out of scope (pre-existing, unchanged): the ranged copy for existing objects > 5 GiB (s3.py:2227) can leave a trailing copy part < 5 MiB, and _upload_chunk can emit a non-last part < 5 MiB after a single write larger than two blocks. Both are to be verified separately before filing issues.
| once a multipart upload starts. | ||
| read into the write buffer; a larger one is copied with | ||
| ``UploadPartCopy`` as the first parts of a multipart upload, whatever | ||
| the block size. |
There was a problem hiding this comment.
Self-review round 2 (claims, callers, operations): FINDINGS → repaired (PR body only)
Scope: git diff 9b74767cf65e0ef513338fb2bb2da678cdfbcd2a..e26b3980ee627d23c71fbc6a80b34b5a08be64b7, plus the PR body and the commit message.
Claims checked:
- "Dates from Support for writing with s3 file system #539 (v3.8.x)":
append_blockwas introduced by 4fbced8 (Support for writing with s3 file system #539); the first containing tag is v3.8.0. ✔ - Docstring (this line): an object ≥
MULTIPART_UPLOAD_MIN_PART_SIZEis copied withUploadPartCopyas the first parts, whatever the block size. This matchess3.py:2200/:2216/:2269. ✔ (It replaces Document every public API in pyathena/ and check docstrings with ruff #919's "once a multipart upload starts", which described the old behavior.) - "Truncated to zero bytes when nothing was appended" and "duplicated …
EntityTooSmall": measured. Offline, the fake givesb''andb'aaaa…'; on real S3,CompleteMultipartUploadreturnedEntityTooSmall. ✔ - "Defect 3 reachable with the default 5 MiB block size": the integration case uses
block_size=None→default_block_size(s3.py:1925). ✔ - "
AioS3FileSystemgets the same fix":AioS3File(s3_async.py:547) overrides no methods. ✔ - Finding: the PR body said the defects were reproduced on 9b74767, but the reproduction ran on 775874c, before the rebase onto Document every public API in pyathena/ and check docstrings with ruff #919. Repaired: I reran the offline reproduction with 9b74767's
s3.py(3 of 4 cases fail as described) and the append tests on e26b398 (114 passed). The body now states which commit each result comes from.
Caller and operator view:
append_blockis an unprefixed attribute, but no caller in the repository reads it. It now means "copied server-side", the same meaning s3fs gives it.- AWS requests: the affected case (an existing object ≥ 5 MiB and a block size above 5 MiB) now issues Create + UploadPartCopy + UploadPart + Complete instead of one PutObject. The copy is server-side, there is no download, and this is the request pattern the default block size already used. botocore/application retries are unchanged.
- Concurrency (pre-existing, not widened in kind): UploadPartCopy copies the latest version without
x-amz-copy-source-if-match, so a concurrent overwrite betweeninfo()and the copy is not detected. Recorded, not changed here. - Docs: no user documentation mentions append mode (
docs/searched).
S3File.discard() passed s3_additional_kwargs to AbortMultipartUpload. In append mode those hold the existing object's metadata (StorageClass, ContentType, ...), which botocore rejects, so rolling back a transaction that appended to an object of at least 5 MiB raised ParamValidationError and left the multipart upload behind. Appending within a larger block size now also takes this path. Abort with only the upload's bucket, key and upload ID, as the other abort paths do; RequestPayer is still added by _call(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
||
| def _initiate_upload(self) -> None: | ||
| if self.tell() < self.blocksize: | ||
| if not self.append_block and self.tell() < self.blocksize: |
There was a problem hiding this comment.
Independent review (relayed): FINDINGS
Reviewer: OpenAI Codex CLI 0.160.0, model gpt-6-astra, reasoning effort max, --sandbox read-only, session 01a0ffc3-775d-7563-8d4e-8633fdd8ef6e. Static review only; no tests, builds, or network. The snapshot was a detached checkout of e26b398. The scope was git diff 9b74767cf65e0ef513338fb2bb2da678cdfbcd2a..e26b3980ee627d23c71fbc6a80b34b5a08be64b7. The prompt left out the PR, the commit messages, and the self-review records. The snapshot and the PR worktree were unchanged afterwards.
Covered: existing object absent / 0 / < 5 MiB / == 5 MiB / > 5 MiB / > 5 GiB; empty, short, threshold-crossing, and successive appends; block-size boundaries; part ordering; autocommit, deferred commit, discard, metadata forwarding; AioS3File; whether the tests fail on the base; the accuracy of the changed comments and docstring.
Regression (P2): "Existing object 6 MiB, append 1 byte, block size 16 MiB, autocommit=False, then close and discard → the new condition creates a multipart upload. discard() (s3.py:2375 at e26b398) forwards object metadata, including the automatically populated StorageClass, to AbortMultipartUpload. Botocore rejects these unsupported parameters, leaving the upload un-aborted … Previously this combination created no multipart upload, so discard succeeded."
Pre-existing, not made worse (P2 each):
- A ranged copy of an existing object of 5 GiB + 1 B leaves an intermediate 1-byte copy part (
s3.py:2230). - With
max_workers=1,_get_ranges()returns a single 6 GiB copy range above the 5 GiB part limit (s3.py:2473). - After a single write of 10 MiB + 1 B with a 5 MiB block,
_upload_chunkuploads parts of 5 MiB, 5 MiB, and 1 B, and a later part makes the 1-byte part invalid (s3.py:2280). - A block size above 5 GiB is accepted and produces a part above the 5 GiB limit (
s3.py:2280).
Test notes: "both added integration cases and three of the four unit cases would fail before the change … The unit reconstruction does not validate S3 part limits or completion contents. Append transactions and discard remain uncovered."
There was a problem hiding this comment.
Repair: 33907e1 (git range-diff: e26b398 unchanged + 1 new commit on the same merge-base 9b74767)
Regression: verified and fixed. Botocore rejects the extra parameters (Unknown parameter in input: "StorageClass" … must be one of: Bucket, Key, UploadId, RequestPayer, ExpectedBucketOwner, IfMatchInitiatedTime). S3File.discard() now aborts with only Bucket/Key/UploadId, like _finish_multipart_upload() (s3.py:1339) and clear_multipart_uploads() (s3.py:1835). RequestPayer is still merged by _call() (s3.py:2078). This failure was pre-existing with the default block size (an existing object ≥ 5 MiB always took the multipart path) and was widened by this PR, so the fix is folded in here.
New tests:
TestS3File::test_append_discardchecks the exact abort request offline.TestS3FileSystem::test_append_transaction_rollbackruns on real S3 with a 6 MiB object and block sizesNone/ 16 MiB. It asserts the object is unchanged and thatlist_multipart_uploads(path)is empty. Without the repair, both integration cases fail withParamValidationError; I aborted the 2 uploads that run leaked. With it, 120 passed (append/transaction/TestS3File, sync+async).
Self-review of the repair:
- Behavior:
discard()is reached fromTransaction.complete(commit=False)and fromcommit()whentell() == 0; no multipart upload exists in the second case. In "wb" mode, a users3_additional_kwargssuch asServerSideEncryptionwould have made the abort fail the same way, and it now succeeds.ExpectedBucketOwnerins3_additional_kwargsis no longer sent on abort, matching the other abort paths. An abort is still scoped by the upload ID. - Claims: the commit message, the PR body (WHAT/TEST updated), and the code comment state only the measured behavior above.
Pre-existing items 1–4: deferred. None of them is made worse by this PR, and each needs its own reproduction; items 1, 2, and 4 involve objects or parts ≥ 5 GiB. I will verify them and show them to the maintainer before filing issues.
There was a problem hiding this comment.
Independent follow-up review (relayed): FINDINGS
Reviewer: OpenAI Codex CLI 0.160.0, model gpt-6-astra, reasoning effort max, --sandbox read-only, session 01a0ffce-37fc-7161-a1b8-a88bde11c58f. Static only. Scope: git diff e26b3980ee627d23c71fbc6a80b34b5a08be64b7..33907e1a90674d5227578eb0425bd705e0660b71 on a detached checkout of 33907e1; the checkout was unchanged afterwards.
Covered: append metadata, multipart init, close, and discard for 5 MiB and 16 MiB blocks; all three abort call sites and _call; both new tests (both fail without the repair).
- Introduced regression (P2),
s3.py:2372: "A non-owner using\"wb\",autocommit=False, ands3_additional_kwargs={\"RequestPayer\": \"requester\"}without filesystem-levelrequester_pays=Truecan create a multipart upload … Previously,discard()also forwarded the payer acknowledgement; now its abort omits it, receives an authorization error, and leaves the upload behind." - Pre-existing (P2),
s3.py:2359: if a part or completion fails and the helper's abort also fails,commit()clearsmultipart_upload, so a laterdiscard()cannot abort the upload.
Repair: 2f0a1c3.
- (1) Verified and fixed.
discard()now forwards only the parameters thatAbortMultipartUploadaccepts froms3_additional_kwargs(RequestPayer,ExpectedBucketOwner) and still excludes object metadata.test_append_discardnow passes both parameters plus the append metadata and asserts the exact request. It fails on e26b398 (metadata forwarded) and on 33907e1 (RequestPayerdropped), and passes on 2f0a1c3.just lintpassed, and the append/transaction/TestS3File tests passed (120, sync+async, real S3). - (2) Deferred as pre-existing: this PR doesn't change
commit()'s failure path. It will be verified separately with the other out-of-scope items.
Self-review of the repair. Behavior: this restores the pre-PR forwarding of RequestPayer/ExpectedBucketOwner, and both are valid AbortMultipartUpload members per botocore. If RequestPayer is set both per file and via requester_pays=True, _call receives a duplicate keyword; that is pre-existing and also affects every other request that forwards s3_additional_kwargs. Claims: the PR body (WHAT, TEST) and the code comment were updated to match.
There was a problem hiding this comment.
Independent follow-up review (relayed): CLEAN
Reviewer: OpenAI Codex CLI 0.160.0, model gpt-6-astra, reasoning effort max, --sandbox read-only, session 01a0ffd7-dfdf-7410-bfcd-4d0e9cb40be4. Static only. Scope: git diff 33907e1a90674d5227578eb0425bd705e0660b71..2f0a1c3ac26bb6a9bddefabc0dbf63c541f60cd4 on a detached checkout of 2f0a1c3, with the full PR diff for reference; the checkout was unchanged afterwards.
Covered: discard() filtering and its comment against the AbortMultipartUpload input shape; append metadata, sync/async file creation, multipart init, close/rollback, and commit-failure cleanup; _call() requester-pays handling; the updated unit test against both earlier implementations.
Result: "no actionable regression … The changed call supplies Bucket, Key, and UploadId, preserves RequestPayer and ExpectedBucketOwner, and excludes rejected object parameters … The code comment is accurate." The test fails on 33907e1 and on the original discard().
Pre-existing, unchanged by this PR (deferred, to be verified separately):
s3.py:2078: filesystemrequester_pays=Trueplus a per-fileRequestPayergives duplicate keywords →TypeError, already at multipart initiation.s3.py:1339:_finish_multipart_upload()'s cleanup abort omits a per-fileRequestPayer.
Dropping s3_additional_kwargs from the abort in discard() also dropped a per-file RequestPayer (and ExpectedBucketOwner), so rolling back a multipart upload on a requester-pays bucket without the filesystem-level requester_pays option failed authorization and left the upload behind. Forward those two parameters and keep excluding the object parameters. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The append integration tests read every object back in full, about 25 MiB per run from S3 to the CI runner. Check the size and the bytes at the ends and around the boundary instead, compare the ETag, last-modified time and size after a rollback, and use a 1 KiB existing object for the case that is read into the buffer. Each run now reads only a few bytes plus that 1 KiB object; the same three cases still fail without the fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| fs.pipe_file(path, data) | ||
| with fs.open(path, "ab", block_size=block_size) as f: | ||
| f.write(extra) | ||
| # Check the size and the bytes at the ends and around the boundary |
There was a problem hiding this comment.
Self-review of 09f34c3 (test transfer reduction), rounds 1 + 2: CLEAN
Scope: git diff 2f0a1c3ac26bb6a9bddefabc0dbf63c541f60cd4..09f34c39c6306a4fcd919499f4b9ed701d62776a (tests only). Reason: the maintainer asked to keep S3 cost down. The full read-back moved about 25 MiB per run from S3 to the GitHub runner, outside AWS, which is billed as egress.
Behavior and test quality:
test_append_with_block_sizeasserts the HEAD size plus[0,1) == b"a",[size-1, size+1) == b"ab", and the last byte== b"b". Truncation (S3File append mode drops an existing object of 5 MiB or more when block_size is larger #921, size 5) and duplication (size mismatch) are caught by the size check. Wrong order is caught by the boundary check. The case with the existing object read into the buffer uses 1 KiB (<MULTIPART_UPLOAD_MIN_PART_SIZE) + 5 MiB, which still crosses the default 5 MiB block.test_append_transaction_rollbackcompares ETag, last-modified time, and size before and after. A committed append changes the size, and a multipart-completed object gets a-NETag.- Measured on 9b74767's
s3.py: 3 of 4 cases fail (assert 5 == (6291456 + 5),EntityTooSmall,ParamValidationError). The 16 MiB rollback case passes there because the base never starts a multipart upload. On 09f34c3, 120 passed (append/transaction/TestS3File, sync+async). I aborted the 1 upload that the base run leaked.
Claims and operations: the PR body's TEST section now states the per-run transfer: about 23 MiB uploaded (free), a few bytes + 1 KiB read, server-side copies, and the 1-day lifecycle expiry of objects and incomplete uploads (cloudformation, AbortIncompleteMultipartUpload: 1). The comments say only what the assertions check.
There was a problem hiding this comment.
Independent follow-up review (relayed): CLEAN
Reviewer: OpenAI Codex CLI 0.160.0, model gpt-6-astra, reasoning effort max, --sandbox read-only, session 01a0fff1-f877-7a60-8083-9f4372282ca1. Static only. Scope: git diff 2f0a1c3ac26bb6a9bddefabc0dbf63c541f60cd4..09f34c39c6306a4fcd919499f4b9ed701d62776a on a detached checkout of 09f34c3; the checkout was unchanged afterwards.
Covered: both changed tests and the append/commit/discard paths; detection of the original defects (replacement, duplication, rollback ParamValidationError and leftover uploads); ordering at the boundary and the ends; info(refresh=True) bypassing the cache, and the ETag/last_modified/size mappings; cat_file exclusive end and start=-1; multipart listing under the unique prefix; the 1 KiB case still taking the buffered path; the accuracy of the comments.
Result: "All three named regressions remain detectable." Stated limit: "The append samples do not prove full interior equality: same-size corruption confined to unsampled bytes could escape."
Author note on the limit: accepted as the cost trade-off. Full-content equality is still asserted offline by TestS3File::test_append, which rebuilds the stored object from every PutObject / UploadPart / UploadPartCopy call.
WHAT
Appending to an existing S3 object with
S3FileSystem.open(path, "ab", block_size=...)now always keeps the existing bytes.S3File.append_blockis set only when the existing object is at leastMULTIPART_UPLOAD_MIN_PART_SIZE(5 MiB), that is, when the object is copied withUploadPartCopy. Smaller objects are read into the write buffer as before and are no longer also copied.append_blockis set,_initiate_upload()and_upload_chunk()always use a multipart upload, whatever the block size. The copied object becomes part 1 (≥ 5 MiB), and the appended bytes become the last part, which may be any size.S3File.__init__docstring now describes this.S3File.discard()aborts the multipart upload withBucket,Key,UploadId, and only the abort-compatibleRequestPayer/ExpectedBucketOwnerfroms3_additional_kwargs. Previously it forwardeds3_additional_kwargs, which in append mode holds the existing object's metadata (StorageClass,ContentType, …). Botocore rejects those forAbortMultipartUpload, so rolling back a transaction that appended to an object ≥ 5 MiB raisedParamValidationErrorand left the upload behind. That was already the case with the default block size, and the routing change above extends it to larger block sizes.AioS3FileinheritsS3File, soAioS3FileSystemgets the same fix.WHY
Fixes #921.
Before this change, the append paths had three defects. All three were reproduced before the fix (see TEST):
block_sizeabove 5 MiB, and a final size belowblock_size. The one-shotPutObjectpath ran with a buffer that held only the appended bytes, so the existing content was lost without an error.CompleteMultipartUploadfailed withEntityTooSmallbecause part 1 was below the minimum part size.The append logic dates from #539 (v3.8.x), so released versions are affected. It will be backported to the 3.x maintenance branch together with other fixes later.
TEST
Tested commit: 09f34c3 (merge-base 9b74767).
just lint: passed (ruff, ruff format, mypy, cfn-lint, license headers).TestS3File::test_append(mocked filesystem, 4-byte minimum part size). They open a realS3Fileinabmode, write, and close through fsspec'sflush(), then rebuild the stored object from the mockedPutObject/UploadPart/UploadPartCopycalls. With 9b74767'ss3.py, 3 of 4 cases fail (b'bb' != b'aaaaaabb',b'' != b'aaaaaa',b'aaaabbbb…' != b'aabbbb…'); with the fix, they pass.TestS3File::test_append_discard: the abort request carries a per-fileRequestPayer/ExpectedBucketOwnerbut no object metadata. It fails on 9b74767/e26b3980 (metadata forwarded) and on 33907e1 (RequestPayerdropped).TestS3FileSystem::test_append_with_block_sizeagainst real S3, with two cases: a 6 MiB object + 5 B withblock_size=16 MiB, and a 1 KiB object + 5 MiB with the default block size. It checks the size and the bytes at the ends and around the boundary instead of reading the object back. On 9b74767'ss3.py:assert 5 == (6291456 + 5)andEntityTooSmall.TestS3FileSystem::test_append_transaction_rollbackagainst real S3: a 6 MiB object, then a 5-byte append inside a rolled-back transaction, with the default and 16 MiB block sizes. It asserts that the ETag, last-modified time, and size are unchanged and that no multipart upload is left. With the routing change but without thediscard()change, both cases fail withParamValidationError. On 9b74767 only the default-block case fails, because the base never starts a multipart upload for the 16 MiB case. The uploads leaked by those runs were aborted afterwards.uv run --env-file .env pytest -n 1 tests/pyathena/filesystem/test_s3.py tests/pyathena/filesystem/test_s3_async.py -k "append or TestS3File or transaction": 120 passed (the new tests, the existing sync/asynctest_append, and the write transaction/rollback tests).just test pyathenasuite; AWS CI runs it when the PR is marked Ready. No async-specific integration case was added, becauseAioS3Fileoverrides none of the changed methods.🤖 Generated with Claude Code