Copy metadata, tags and annotations in multipart copies - #1036
Conversation
| } | ||
| return {k: v for k, v in source_kwargs.items() if v is not None} | ||
|
|
||
| def _get_multipart_copy_kwargs( |
There was a problem hiding this comment.
Self-review round 1 (implementation behavior): base de8cc52ac2a43cdba72bb4571c185883287741fc, head e8d3f669e218d26b19915ca6b17d9ad13ae1a221 (plus a42788c8, docstring only). Result: FINDINGS. One wording finding is repaired; the other two are pre-existing or deferred.
Covered: _copy_object_with_multipart_upload (sync/async), _get_multipart_copy_kwargs, _get_copy_source_kwargs, _copies_annotations, _list_object_annotations, _copy_object_annotation, _abort_multipart_upload and its caller _finish_multipart_upload, the cp_file/_cp_file callers through _copy_file, the docs, and the tests.
- Failure paths: an invalid directive raises before any request. A failure in HeadObject or GetObjectTagging happens before CreateMultipartUpload, so there is nothing to abort. A failed part or completion aborts, sync and async. On the async side the flag is set inside the failing part before it releases the semaphore, so no queued part starts (verified by the
start 3race that the first version of the test caught). After the completion, a failure to list, read or write an annotation raises, and the destination is kept (as specified). - Parameter routing: CopyObject parameters that CreateMultipartUpload does not accept are dropped. Unknown parameters still pass through, so botocore's validation error is preserved (
_unknown_parameter). Source lookups get the source's owner and SSE-C key; destination requests get the destination's. - Finding (repaired in
a42788c8): the docstring said thatObjectIfMatchkeeps an annotation off "an object written over the copy". It only guards against a different ETag, and the wording now says so. - Deferred, listed as limits in the PR body: (a) annotations are listed after the completion, so an unpinned source replaced during the copy can supply the new object's annotations; (b) a caller without
s3:ListObjectAnnotationsnow fails after the completion even when the source has no annotations. The maintainer specified the ordering after the completion, so I did not change it. - Pre-existing, not changed: an
Expiresvalue that botocore cannot parse is lost (as inS3Metadata).
| source are copied, and the values given for them are ignored, as CopyObject does. A | ||
| `REPLACE` directive uses the given values instead, and `AnnotationDirective="EXCLUDE"` | ||
| skips the annotations. Copying the tags needs `s3:GetObjectTagging` on the source, and | ||
| copying the annotations needs `s3:ListObjectAnnotations` and `s3:GetObjectAnnotation` on |
There was a problem hiding this comment.
Self-review round 2 (claims, callers, AWS effects): base de8cc52ac2a43cdba72bb4571c185883287741fc, head a42788c8ad454869c9cf24a17b33b2520f025ba8. Result: CLEAN after the PR-body corrections (the ObjectIfMatch wording and the added Limits).
Claims checked:
- "CopyObject ignores given ContentType/Metadata/Tagging under COPY, copies content headers/metadata/tags/annotations, not WebsiteRedirectLocation/storage class/SSE": measured live in the CI bucket on 2026-10-03 (default, explicit
COPY,COPYwithContentType/Metadata/Tagging,REPLACE,TaggingDirective=REPLACEwithoutTagging). - "Sync and async multipart copies now match CopyObject": measured live on a 10 MiB + 1 B source with 5 MiB parts, compared with a CopyObject of the same source. Headers, metadata, tags and annotation payloads matched. A failed
CopySourceIfMatchleft no multipart upload. - "Before, CreateMultipartUpload rejected
CopySourceIf*/CopySourceSSECustomer*/ExpectedSourceBucketOwner/directives": in the botocore 1.43.102 model these are not members of CreateMultipartUpload; the maintainer's comment on Multipart copies drop the source metadata, and the async multipart copy does not abort on failure #973 reported the same. - Existing callers: a
ContentType/Metadata/Tagginggiven for a copy over 5 GiB is now ignored by default. This is a behavior change, release-noted.mv()raises before removing anything if the copy fails, so a failed annotation copy keeps both source and destination. - AWS operator: extra requests per copy over 5 GiB are 1 HeadObject + 1 GetObjectTagging + ≥1 ListObjectAnnotations + 2 per annotation, all through
_call's retries. Async annotation copies are bounded bymax_workers. The new permission requirements are release-noted in the PR body and stated indocs/filesystem.md. - Evidence: the offline Stubber tests and the live measurement are kept separate in the PR body, and >5 GiB, SSE-C, directory and requester-pays cases are marked as not tested live.
a42788c to
f0e374e
Compare
| "AnnotationName": name, | ||
| "AnnotationPayload": response["AnnotationPayload"].read(), | ||
| } | ||
| if completed.version_id: |
There was a problem hiding this comment.
Independent review (relayed): reviewer Codex CLI 0.160.0, model gpt-6-astra, reasoning effort max (local config), codex exec -s read-only --ephemeral on a detached snapshot at head a42788c8ad454869c9cf24a17b33b2520f025ba8, base de8cc52ac2a43cdba72bb4571c185883287741fc. Static review only; the snapshot was unchanged. The prompt had no PR number, PR description or author conclusions.
Covered: the full diff, the sync/async multipart paths, the cp_file/_cp_file/mv/copy callers, directive semantics, parameter routing, SSE-C, owner/payer headers, botocore 1.43.102 shapes, annotation pagination/versioning/failure handling, gather/semaphore behavior, aborts, cancellation, the tests and the docs.
Result: FINDINGS. Introduced by this change:
- P1:
pyproject.tomlallowsbotocore>=1.41.2, but the annotation operations first appear in botocore 1.43.31 (bisected locally: 1.43.30 lackslist_object_annotations, 1.43.31 has it). With an older botocore, a default copy over 5 GiB completes and then raisesAttributeError. Open, needs a maintainer decision (raise the floor, or skip annotation copying when the client lacks the operation). - P2: the annotations of an unpinned versioned source are listed after the copy, so they can come from a newer version. Deferred; it is in the PR's Limits. Pinning would need the source version from HeadObject for the parts too, which is beyond Multipart copies drop the source metadata, and the async multipart copy does not abort on failure #973's scope; this needs a maintainer decision.
- P2:
ObjectIfMatchalone could annotate a newer destination version with the same ETag. Fixed: PutObjectAnnotation now also gets the completion'sVersionId. - P2: when an annotation copy fails, the destination's cache entries are left stale. Fixed:
_copy_fileinvalidatespath2in afinally(sync and async). - P2: in the async annotation-failure test,
gather(return_exceptions=True)still rana2and hid itsUnStubbedResponseError. Fixed: async annotation copies stop starting after one fails (the same flag pattern as the parts, matching the sync loop), so the stubbed sequence is now complete.
Pre-existing (reported by the reviewer):
- Async cancellation bypasses the multipart cleanup. After the rebase onto
fe21250a, sync_finish_multipart_uploadcatchesBaseException(Leave the existing object unchanged when put_file() fails #1017), so async parity for interrupts is now a question. Open, needs a maintainer decision. - The public
cp_filepath callsinfo(path1)without the source's SSE-C key, so a cold-cache SSE-C source fails before the multipart helper runs. Deferred, pre-existing; this is the same class of gap as S3File does not send its request parameters with the lookups made while opening #1004.
There was a problem hiding this comment.
Repair (f0e374eb, rebased onto fe21250a; the only conflict was the typing/urllib.parse import lines, kept from both sides):
- PutObjectAnnotation gets
VersionId=completed.version_idwhen present, as well asObjectIfMatch(finding 3).stub_multipart_copyexpects both. S3FileSystem._copy_fileandAioS3FileSystem._copy_fileinvalidatepath2in afinally(finding 4), with new teststest_cp_file_failed_multipart_copy_invalidates_cache(sync and async).- Async annotation copies stop starting after a failure (finding 5).
- Self-review of the repair. Round 1:
_finish_multipart_uploadafter the rebase catchesBaseExceptionand calls the extracted_abort_multipart_upload, so behavior matches master. Invalidating on a failure before completion only evicts cache entries and sends no request. Round 2: the PR body claims are unchanged apart from the open items. - Validation:
just lintpasses; the offline copy tests pass (99; the 5 failures are live-only tests run without credentials); livepytest -n 4 tests/pyathena/filesystem/→ 546 passed. - Still open: findings 1 and 2 and async cancellation parity. The PR stays Draft until they are decided, and an independent follow-up review of the repairs is still pending.
There was a problem hiding this comment.
Repairs for the maintainer's decisions (794b62c1..0e755234, rebased onto master 8446e872):
794b62c1:botocore>=1.43.31andboto3>=1.43.31, withuv lockchanging only the two specifiers in[package.metadata]. botocore 1.43.31 is the first release with the annotation operations (bisected: 1.43.30 lackslist_object_annotations). PyPI metadata: boto3 1.43.31 requiresbotocore<1.44.0,>=1.43.31, and 1.43.30 requires>=1.43.30. There are nohasattrbranches.a879b6ef: the annotations are listed before CreateMultipartUpload (new_failed_listingtests, sync and async: nothing is written). HeadObject is always sent. Without a given version, the reported version pins the part copies (CopySourceVersionId), the tag read and the annotation reads.f0c189d9: the ranges use HeadObject'sContentLengthfor the pinned version, not a possibly cachedinfo()size. Anullversion is not pinned. The async failed-annotation test now spies on_copy_object_annotationand asserts["a1"]; it fails with the stop guard removed (verified).32f6e3b4,0e755234: the docstring and docs wording for pinning.
Self-review of the repairs (affected scope). Round 1: sync and async paths are in parity; the directive check still runs before any request; REPLACE copies now send one HeadObject (release-noted); a null, absent or given version is not overridden. Round 2: the permission claims were checked against the S3 User Guide's "Required permissions for Amazon S3 API operations": UploadPartCopy with versionId needs s3:GetObjectVersion, and GetObjectTagging with versionId needs s3:GetObjectVersionTagging.
Independent follow-ups (Codex CLI, same configuration, read-only, static):
f0e374eb..9ab9caf3: FINDINGS. Anullversion is mutable, so the docs overclaimed (fixed inf0c189d9/32f6e3b4). The cached size could truncate the pinned version (fixed inf0c189d9). The async failure test did not prove the stop (fixed inf0c189d9). SSE-C on the publiccp_filepath is pre-existing and left alone by instruction (S3File does not send its request parameters with the lookups made while opening #1004/Send the lookup parameters of a file with its lookups #1024).9ab9caf3..64dfddae: FINDINGS. The wording promised pinning whenever versioning is enabled, but objects from before versioning keepnull(fixed in32f6e3b4). A replacement withContentLength=0makes_get_copy_ranges(0)raiseValueErrorbefore anything is written. Deferred: this is an error, not silent corruption, and it would need a new zero-byte path; it is listed in Limits.64dfddae..eaa7c825: FINDINGS, P3: "a write replaces a null version" was not true with versioning enabled (fixed in0e755234).eaa7c825..c7f83b36: CLEAN.- Rebase
c7f83b36→0e755234(range-diff; only the constants conflicted, both kept; upstream Keep system metadata in setxattr() and reject version paths #1016/Use the compression table properties that Athena applies on write #1025 checked for interaction): CLEAN.
Validation on 0e755234: just lint, just docs lint and uv lock --check pass. Offline copy tests pass. Live pytest -n 4 tests/pyathena/filesystem/ → 553 passed in 3 of 4 runs. The first run after the rebase had one failure of the offline test_copy_object_with_multipart_upload_failed_annotation (sync), with an unexpected abort logged. It did not reproduce in 30 serial runs of the copy tests, 15 parallel runs of the copy tests (-n 4) or 3 more full live runs. The cause is not identified; recorded in the PR body.
There was a problem hiding this comment.
Flaky test root cause and the 0-byte race (598edd8d, 13e45c4d, rebased onto master abc99b09):
- Root cause of the one unexplained failure of
test_copy_object_with_multipart_upload_failed_annotation(sync): after the rebase onto8446e872,TestS3FileSystemdefined_stubbed_fs()twice. The copy tests' helper (max_workers=1) came first; Keep system metadata in setxattr() and reject version paths #1016's setxattr helper, with the same name further down the class, overrode it. With that one,fs.max_workerswas 50 (checked), so the two part copies ran in parallel and could reach the Stubber out of order. A part then failed withStubAssertionError, and_finish_multipart_uploadattempted an unstubbed abort, which produced the logged "Failed to abort multipart upload u to s3://bucket/dst". I reproduced it deterministically by delaying part 1 with a botocoreprovide-client-paramshook: the overridden helper givesStubAssertionErrorplus the abort log, the renamed_stubbed_copy_fs()(max_workers=1) gives the expectedPermissionErrorwith no pending responses. The fix is test-only: the helper is renamed. An AST check finds no other duplicate method names in either test class. - 0-byte race: if HeadObject reports a size of at most
MULTIPART_UPLOAD_MAX_PART_SIZE(a stale cached size over 5 GiB chose the multipart path), the reported version (non-nullVersionIdpinned) is copied with_copy_objectand the same kwargs as the small-object path, sync and async._get_multipart_copy_kwargsreturns before reading the tags in that case, because CopyObject applies the directives itself. New teststest_copy_object_with_multipart_upload_small_head_object_size[0, 10](sync and async) fail without the fix. The stubbed multipart copy is nowMAX_PART_SIZE + MIN_PART_SIZEbytes in two parts, so it stays on the multipart path. - Self-review (affected scope). Round 1: directive validation still runs first; the fallback sends exactly CopyObject's kwargs, as
cp_filedoes for small objects; cache invalidation still runs through_copy_file'sfinally. Round 2: the docs sentence matches the code. - Independent follow-up (Codex CLI, same configuration, read-only, static) on both commits plus the rebase range-diff and the interacting upstream changes: CLEAN.
- Validation on
13e45c4d:just lintandjust docs lintpass; the offline copy tests pass (75 before the rebase); livepytest -n 4 tests/pyathena/filesystem/→ 639 passed.
There was a problem hiding this comment.
Rebased 13e45c4d → 2154780a onto master ec5323ea (#1033). The only conflict was the import lines of tests/pyathena/util.py (master added timedelta, timezone and dateutil.tz.gettz; this branch adds UTC and botocore.response.StreamingBody), and both sides are kept. The range-diff shows the other nine commits unchanged. Upstream changes are in the converters and their tests only, not in pyathena/filesystem. On 2154780a: just lint passes; live pytest -n 4 tests/pyathena/filesystem/ → 639 passed; tests/pyathena/test_util.py and test_converter.py offline → 195 passed.
c7f83b3 to
0e75523
Compare
0e75523 to
13e45c4
Compare
A copy of an object larger than 5 GiB goes through a multipart upload, which started without the source's content headers, user-defined metadata and tags, and never got its annotations. Implement CopyObject's directives for it (default COPY): read the content headers and metadata with HeadObject and the tags with GetObjectTagging for CreateMultipartUpload, ignoring the values of the copy as CopyObject does, and copy the annotations after the completion with ListObjectAnnotations, GetObjectAnnotation and PutObjectAnnotation. REPLACE and EXCLUDE use the values of the copy or skip the annotations, and an invalid directive raises ValueError. CreateMultipartUpload now receives only the CopyObject parameters that it accepts, so source conditions reach the part copies instead of failing validation, and the source's lookups get the source's SSE-C key and expected bucket owner. A failed part or completion of the async multipart copy now aborts the upload after the running parts finish, as the sync one does. Closes #973 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Write each annotation to the version that the multipart copy created, remove the cached entries of the destination when a copy fails after the completion, and stop starting async annotation copies after one fails, as the sync loop does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The multipart copy uses ListObjectAnnotations, GetObjectAnnotation and PutObjectAnnotation, which botocore has since 1.43.31. boto3 1.43.31 is the first release that requires it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The multipart copy reads the source with HeadObject in any case, and without a given version it copies the parts, the tags and the annotations from the version that HeadObject reports in a versioned bucket, so a source replaced during the copy is not mixed in. The annotations are listed before CreateMultipartUpload, so a caller that cannot list them fails before anything is written. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The ranges of a multipart copy now cover the size of the version that HeadObject reports instead of a size from a cached listing, and the mutable null version of a bucket with versioning suspended is not pinned. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The copy tests used the _stubbed_fs() helper of the setxattr tests, which master added under the same name in the same class after the copy tests were written. It overrode theirs and dropped max_workers=1, so the part copies could reach the Stubber out of order and fail, which aborted the upload in a test that expects no abort. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A multipart copy chosen from a stale size over 5 GiB now copies the version that HeadObject reports with a single CopyObject request when its size fits, including an empty object, which no multipart copy can split into ranges. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
13e45c4 to
2154780
Compare
WHAT
A copy of an object larger than 5 GiB (
cp_file()/_cp_file()→_copy_object_with_multipart_upload(), sync and async) now gives the same result as CopyObject:S3FileSystem._get_multipart_copy_kwargs()(shared withAioS3FileSystemthroughasyncio.to_thread):MetadataDirective(defaultCOPY): HeadObject on the source suppliesCacheControl,ContentDisposition,ContentEncoding,ContentLanguage,ContentType,Expiresand the user-definedMetadatafor CreateMultipartUpload. Values given to the copy for these are ignored, as CopyObject ignores them.REPLACEuses the given values.TaggingDirective(defaultCOPY): GetObjectTagging on the source suppliesTagging. A givenTaggingis ignored.REPLACEuses the givenTagging.AnnotationDirective(defaultCOPY): ListObjectAnnotations (all pages) on the source before CreateMultipartUpload, so a caller who cannot list them fails before anything is written; after CompleteMultipartUpload, GetObjectAnnotation + PutObjectAnnotation for each annotation onto the destination. Each write targets the completion'sVersionId(in a versioned bucket) and hasObjectIfMatchset to the completed ETag, so it fails instead of attaching an annotation to an object written over the copy. Sync copies the annotations one by one; async usesasyncio.gatherunder themax_workerssemaphore.EXCLUDEskips them.ValueErrorbefore any request.CopySourceSSECustomerAlgorithm), and tags and annotations for a source in a directory bucket (name ending in--x-s3), which supports neither API.CopySourceIf*),CopySourceSSECustomer*andExpectedSourceBucketOwnerused to fail CreateMultipartUpload's validation; now they reach only the part copies, which already received the parameters they accept.null. The part copies (CopySourceVersionId), the tag read and the annotation reads all use it, and the part ranges use HeadObject's size, so a source replaced during the copy is not mixed in.RequestPayer,ExpectedSourceBucketOwnerasExpectedBucketOwner, and (HeadObject only)CopySourceSSECustomer*asSSECustomer*. The destination'sExpectedBucketOwnergoes only to the destination requests.AioS3FileSystemnow waits for the running parts and aborts the upload, asS3FileSystemdoes.S3FileSystem._abort_multipart_upload(), extracted from_finish_multipart_upload().cp_file()still invalidates the destination's cache entries.docs/filesystem.mdand thecp_file()docstring describe the behavior.Release notes
botocore>=1.43.31andboto3>=1.43.31. botocore 1.43.31 is the first release with the annotation operations (bisected: 1.43.30 lackslist_object_annotations), and boto3 1.43.31 is the first release that requiresbotocore>=1.43.31(PyPI metadata).ContentType/Metadata/Taggingvalues are now ignored unless the matching directive isREPLACE; before, they were used.s3:GetObjectTaggingon the source, pluss3:ListObjectAnnotationsands3:GetObjectAnnotationon the source ands3:PutObjectAnnotationon the destination. UseTaggingDirective="REPLACE"orAnnotationDirective="EXCLUDE"to skip these.nullversion ID, these copies read that specific source version, so they needs3:GetObjectVersionands3:GetObjectVersionTagginginstead ofs3:GetObjectands3:GetObjectTagging(per the S3 User Guide's "Required permissions for Amazon S3 API operations" for UploadPartCopy and GetObjectTagging).CopySourceIf*,CopySourceSSECustomer*,ExpectedSourceBucketOwnerand the directives now work for copies over 5 GiB. Before, CreateMultipartUpload rejected them.MetadataDirective/TaggingDirective/AnnotationDirectiveraisesValueErrorfor copies over 5 GiB.WHY
Closes #973. Before this PR, whether a copy kept its metadata depended on the object's size, and a failed async multipart copy left an incomplete upload behind.
TEST
Tested commit e8d3f66, then 2154780 after the review repairs, the maintainer's decisions and the rebase onto master ec5323e. On 2154780:
just lint,just docs lintanduv lock --checkpass, the offline copy tests pass, and livepytest -n 4 tests/pyathena/filesystem/→ 639 passed. An earlier one-off failure of the sync failed-annotation test was traced to a test helper overridden by a same-named one from #1016 (parallel part copies against the Stubber) and fixed in the tests (see the review thread).just format,just lint,just docs lint: pass.Stubberwith dummy credentials,max_workers=1for a deterministic request order), intests/pyathena/filesystem/test_s3.pyandtest_s3_async.py:test_copy_object_with_multipart_upload_copies_source(sync + async): every request of a 2-part copy, using the shared helpertests/pyathena/util.py:stub_multipart_copy. It covers metadata and tags from the source with the givenContentType/Taggingignored,StorageClassfrom the call rather than the source, the source condition only on the parts, source and destination bucket owners, two pages of annotations listed before the upload, the version from HeadObject on the parts, tags and annotation reads, andVersionId+ObjectIfMatchon the annotation writes._small_head_object_size[0, 10](sync + async): a stale size over 5 GiB with HeadObject reporting 0 or 10 bytes copies the pinned version with CopyObject, without reading tags._head_object_size(sync): the ranges follow HeadObject's size, not a cached size, and anullversion is not pinned._failed_listing(sync + async): an AccessDenied from ListObjectAnnotations happens before CreateMultipartUpload; nothing is written._failed_part(sync + async): abort, no completion._failed_annotation(sync + async):PermissionError, with no abort or delete._waits_for_running_parts: the abort waits for the running part, and the queued part never starts. Passed 6 of 6 repeated runs._replace_directives,_invalid_directive(3 cases),_unknown_parameter(botocore still rejectsContentTyp),_sse_c_source,_directory_bucket_source.pyathena/reverted, 15 of the 26 selected multipart/copy tests fail.REPLACE/EXCLUDEdirectives, so they still check part sizes and parameter routing without reading the source.uv run --env-file .env pytest -n 4 tests/pyathena/filesystem/→ 531 passed._copy_object_with_multipart_upload()directly on a 10 MiB + 1 B source (5 MiB parts) with content headers, metadata, two tags and two annotations. Sync and async copies matched a plain CopyObject of the same source: headers, metadata, tags and annotation payloads, withContentType="text/plain"ignored.REPLACE/EXCLUDEgave the given values and no annotations. A failingCopySourceIfMatchraisedOSErrorand left no multipart upload. The objects were deleted.WebsiteRedirectLocation, storage class or encryption. UnderCOPYit ignores a givenContentType,MetadataandTagging. The annotation APIs andObjectIfMatchwork in the CI bucket.urlencodetags with spaces round-trip through CreateMultipartUpload.Limits:
nullversion (an object from before versioning, or with versioning suspended) is not pinned.Expiresvalue that botocore cannot parse is not copied.🤖 Generated with Claude Code