Repository navigation
Stop appends, sync multipart copies, and commits at a failed part #1183
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
8cf11bf
Stop an append and a sync multipart copy at a failed part copy
laughingman7743 f135e16
Check part copies before each part and stop commit() at a failed part
laughingman7743 cfd6d3b
Hold the part copy until the first write returns in the append test
laughingman7743 5063729
Wait for the test's event without a timeout and shut down its executor
laughingman7743 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,7 +9,7 @@ | |
| import os.path | ||
| import time | ||
| from collections.abc import Callable, Mapping | ||
| from concurrent.futures import FIRST_COMPLETED, Future, as_completed, wait | ||
| from concurrent.futures import FIRST_COMPLETED, FIRST_EXCEPTION, Future, as_completed, wait | ||
| from copy import deepcopy | ||
| from datetime import datetime | ||
| from io import BytesIO | ||
|
|
@@ -1831,7 +1831,8 @@ def _finish_multipart_upload( | |
| interrupted, the parts that have not started are cancelled, the | ||
| running ones are waited for, and the multipart upload is aborted so | ||
| that no incomplete upload or part is left behind, unless ``abort`` is | ||
| false. The original error is then re-raised. | ||
| false. The original error is then re-raised. A failed part is | ||
| handled as soon as it fails, without waiting for the parts before it. | ||
|
|
||
| Args: | ||
| upload: The multipart upload returned by creation. | ||
|
|
@@ -1847,6 +1848,12 @@ def _finish_multipart_upload( | |
| """ | ||
| request_kwargs = request_kwargs or {} | ||
| try: | ||
| done, _ = wait(futures, return_when=FIRST_EXCEPTION) | ||
| for future in done: | ||
| if not future.cancelled() and future.exception() is not None: | ||
| # Raised without waiting for the parts before it, so that | ||
| # the parts queued after it are cancelled. | ||
| future.result() | ||
| # The futures are in part-number order. | ||
| parts = [future.result() for future in futures] | ||
| return self.core.complete_multipart_upload( | ||
|
|
@@ -2898,6 +2905,8 @@ def __init__( | |
| # The parts uploaded from the buffer that may not have finished. The | ||
| # part copies of an append hold no data and are not counted. | ||
| self._uploads_in_flight: set[Future[S3MultipartUploadPart]] = set() | ||
| # The part copies of an append that may not have finished. | ||
| self._copies_in_flight: set[Future[S3MultipartUploadPart]] = set() | ||
| if append_info is not None: | ||
| if append_data is not None: | ||
| self.write(append_data) | ||
|
|
@@ -3056,6 +3065,7 @@ def _initiate_upload(self) -> None: | |
| # cancel it or wait for it before the abort. | ||
| part: Future[S3MultipartUploadPart] = Future() | ||
| self.multipart_upload_parts.append(part) | ||
| self._copies_in_flight.add(part) | ||
| self._executor.submit_to( | ||
| part, | ||
| writer.upload_part_copy, | ||
|
|
@@ -3078,7 +3088,8 @@ def _upload_chunk(self, final: bool = False) -> bool: | |
| which uploads it with PutObject. Otherwise, the buffered data is | ||
| uploaded as parts, and a part is submitted only while fewer than | ||
| ``max_workers`` uploaded parts are in flight; the write waits for | ||
| one of them to finish first. A part found failed by that wait stops | ||
| one of them to finish first. A part found failed by that wait, or a | ||
| part copy of an append found failed before a part is submitted, stops | ||
| the write with its error; one that fails afterwards is reported by | ||
| :meth:`commit`. If the upload of the chunk fails or is interrupted, | ||
| the file is closed without committing it. | ||
|
|
@@ -3093,7 +3104,8 @@ def _upload_chunk(self, final: bool = False) -> bool: | |
|
|
||
| Raises: | ||
| BaseException: The error of an uploaded part that the write found | ||
| failed while it waited for a part in flight. | ||
| failed while it waited for a part in flight, or of a part copy | ||
| of an append found failed before a part is submitted. | ||
| RuntimeError: If no multipart upload is initialized. | ||
| ValueError: If the data needs more parts than a multipart upload | ||
| allows. | ||
|
|
@@ -3121,6 +3133,13 @@ def _upload_chunk(self, final: bool = False) -> bool: | |
| for part_number, data in writer.iter_parts( | ||
| buffer, first_part_number=len(self.multipart_upload_parts) + 1 | ||
| ): | ||
| if self._copies_in_flight: | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Self-review round one (behavior and implementation)
Repairs in
Deferred or rejected:
|
||
| copied = {copy for copy in self._copies_in_flight if copy.done()} | ||
| self._copies_in_flight -= copied | ||
| for copy in copied: | ||
| # Otherwise the rest of the data is uploaded after a | ||
| # failed part copy. | ||
| copy.result() | ||
| if len(self._uploads_in_flight) >= self.max_workers: | ||
| # Otherwise the parts waiting for a worker pile up in memory. | ||
| done, self._uploads_in_flight = wait( | ||
|
|
@@ -3156,11 +3175,12 @@ def commit(self) -> None: | |
|
|
||
| Creates an empty object if nothing was written, uploads the buffered | ||
| data with PutObject if no multipart upload part was submitted, and | ||
| otherwise completes the multipart upload, which is aborted if the | ||
| completion fails or is interrupted. If the abort also fails, the | ||
| upload is kept so that :meth:`discard` can abort it. Invalidates the | ||
| cache of the path afterwards. Does nothing for a file whose failed | ||
| write dropped the written data. | ||
| otherwise completes the multipart upload, which is aborted if a part | ||
| or the completion fails or is interrupted. A failed part is handled | ||
| as soon as it fails, without waiting for the parts before it. If the | ||
| abort also fails, the upload is kept so that :meth:`discard` can | ||
| abort it. Invalidates the cache of the path afterwards. Does nothing | ||
| for a file whose failed write dropped the written data. | ||
|
|
||
| Raises: | ||
| FileExistsError: If an object was created at the path after the | ||
|
|
@@ -3189,6 +3209,12 @@ def commit(self) -> None: | |
|
|
||
| upload_id = cast(str, self.multipart_upload.upload_id) | ||
| try: | ||
| done, _ = wait(self.multipart_upload_parts, return_when=FIRST_EXCEPTION) | ||
| for future in done: | ||
| if not future.cancelled() and future.exception() is not None: | ||
| # Raised without waiting for the parts before it, so | ||
| # that the parts queued after it are cancelled. | ||
| future.result() | ||
| parts = [future.result() for future in self.multipart_upload_parts] | ||
| self._get_multipart_writer().complete( | ||
| parts, **self._get_request_kwargs("complete_multipart_upload") | ||
|
|
@@ -3225,6 +3251,7 @@ def discard(self) -> None: | |
| self.multipart_upload = None | ||
| self.multipart_upload_parts = [] | ||
| self._uploads_in_flight = set() | ||
| self._copies_in_flight = set() | ||
|
|
||
| def url(self, expiration: int = 3600, **kwargs) -> str: | ||
| """Generate a presigned HTTP URL to read this file (if it already exists). | ||
|
|
||
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
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
Oops, something went wrong.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Self-review round two (claims, callers, operations, evidence)
3a5018225e536ba5b0a920465e3f141dae0447e4, head:f135e16b99c518a08bd17a489e03c5fa8105c6c8.Claims checked:
iter_parts(), outside themax_workersbranch.test_append_failed_part_copycoversmax_workers=4, where the second write never waits._finish_multipart_upload()andcommit()(throughdiscard()) cancel the futures they can and wait for the rest. Both new tests assert the cleanup started before part 1 finished and that part 3 was cancelled.AioS3FileSystem._copy_object_with_multipart_upload()already stops at the first failure." It usesasyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION)(pyathena/filesystem/s3_async.py:641).AioS3Fileshares_upload_chunk(),_initiate_upload(), andcommit()."AioS3File(pyathena/filesystem/s3_async.py:1030) overrides none of them.commit(). Thecpparagraph ("A failed part copy aborts the multipart upload ... the part copies in flight finish first") is still accurate. No other text indocs/describes the order of part failures.Existing callers and operations:
except BaseExceptioncleanup as before.S3AioExecutor's loop shutdown is skipped by the new scans, and still raisesCancelledErrorfrom the in-order collection, as before.wait()over up to 10,000 futures adds one waiter per future, which is O(n) like the previousresult()loop.Evidence: local only, Python 3.13.1 on macOS.
tests/pyathena/filesystem/suite: 1221 passed, 9 skipped, with live S3, on the code off135e16b.master.wait()passed 5 runs in a row.Correction: the PR title said "commits at a failed part copy", but
commit()stops at any failed part. It now reads "Stop appends, sync multipart copies, and commits at a failed part".