Abort a cancelled async multipart copy after its running parts - #1056
Conversation
| See :meth:`S3FileSystem._copy_object_with_multipart_upload`. The part | ||
| and annotation copies run in parallel with ``asyncio.gather`` and | ||
| ``asyncio.to_thread``. | ||
| and annotation copies run in parallel as asyncio tasks with |
There was a problem hiding this comment.
Self-review round one (behavior and implementation): FINDINGS, repaired.
Scope: base e49d576ce1e8cb60d74e1fde56de9c72ff3cd8e1 (merge-base with master), head 43b3fe471fdcf4ccf657a2383ea6df284e18e8c4, both changed files: pyathena/filesystem/s3_async.py and tests/pyathena/filesystem/test_s3_async.py.
Finding: this docstring still said that the part copies run in parallel with asyncio.gather. After this PR they run as tasks that are awaited with asyncio.wait, so the docstring no longer matched the code. It also did not mention the new behavior on cancellation.
Repair in 185115d: the docstring now says the copies run as asyncio tasks with asyncio.to_thread. It also states that a cancellation while the parts are copied or the upload is completed waits for the running part copies, aborts the upload, and is re-raised. The wording leaves out a cancellation during CreateMultipartUpload, which this PR does not handle (see the PR description). just lint passed.
| # Unlike gather, wait does not cancel the parts when this task is | ||
| # cancelled; their threads would keep copying, so they are waited | ||
| # for below. | ||
| done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION) |
There was a problem hiding this comment.
Self-review round one, checked: CLEAN.
asyncio.wait()neither cancels the part tasks when the awaiting task is cancelled nor creates an outer future that could hold an unretrieved exception. An earlierasyncio.shield(asyncio.gather(...))version logged_GatheringFuture exception was never retrievedwhen a cancellation and a failed part combined. This version logs nothing, checked withasyncio.run(..., debug=True).- Failure path:
FIRST_EXCEPTIONreturns at the first failed part. Its error is raised, and the existing cleanup then waits for the running parts and aborts.test_copy_object_with_multipart_upload_failed_partandtest_copy_object_with_multipart_upload_waits_for_running_partspass unchanged. One difference: if two parts fail within the same event-loop step, either error may be raised, whereasgatherraised the first one to complete. Both are part-copy errors, so this is not actionable. - An empty
rangeswould makeasyncio.wait([])raiseValueErrorwheregather()returned[]. This cannot happen: the only caller,_copy_file, passessize1 > MULTIPART_UPLOAD_MAX_PART_SIZE, and_get_copy_ranges(0, ...)already raises before this point. asyncio.wait_for()on Python 3.11, the floor inrequires-python, waits for the cancelled inner task to finish. 3.12+ implements it withasyncio.timeout(), which lets the cleanup run before the timeout is raised. The 3.13 local check coverswait_for; 3.11 was not run locally.
| sync_fs._complete_multipart_upload.assert_not_called() | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_copy_object_with_multipart_upload_cancelled(self): |
There was a problem hiding this comment.
Self-review round one, test quality: CLEAN.
The test cancels the copy only after both running parts have signalled that they started. The CancelledError reaches the task on the next loop step, about 200 ms before the part threads finish. The test then checks the observable order (both end events before abort), that part 3 never starts, that no CompleteMultipartUpload is sent, and that CancelledError is raised. With the pyathena/ change reverted, it fails: no abort is called. It reuses the mocking style of test_copy_object_with_multipart_upload_waits_for_running_parts and adds no new fake framework.
| **self._sync_fs._get_operation_kwargs("complete_multipart_upload", kwargs), | ||
| ) | ||
| except Exception: | ||
| except BaseException: |
There was a problem hiding this comment.
Self-review round two (claims, callers, and operations): FINDINGS, repaired in the PR description.
Scope: full pass over base e49d576ce1e8cb60d74e1fde56de9c72ff3cd8e1, head 185115db8963dabe413396d3c71a883e0516acb6. It covers the PR description, both commit messages, the changed docstring, the code and test comments, and the premises of #1046.
Finding: the description listed "a cancellation during CompleteMultipartUpload sends the abort while the completion may still run" under Unchanged. That is false. Before this PR, except Exception did not catch CancelledError, so the completion thread finished and created the object without an abort. With except BaseException here, the abort is now sent while the completion request may still run in its thread. Either the abort wins and no object is created, or the completion wins and the failed abort is logged by S3FileSystem._abort_multipart_upload() (pyathena/filesystem/s3.py:2342). The sync _finish_multipart_upload() also aborts after an interrupt during completion, so this matches the issue's expectation. The description now lists it as a behavior change for the release notes.
Also added to the description: a cancelled copy now returns only after the running part copies finish, which can take up to the slowest running part copy (max_workers parts of up to block_size). This matters for asyncio.wait_for() callers. "The first part error found is raised" became "A failed part's error is raised"; see the round-one note on simultaneous failures.
Claims checked and held:
gather()cancels its children when cancelled, while theto_thread()copies keep running (the issue's reproduction, rerun on master behavior);wait()does not cancel them (new test).- Affected entry points:
_copy_file()reaches the multipart path only forsize1 > MULTIPART_UPLOAD_MAX_PART_SIZE, and it is called from_cp_file()(s3_async.py:409) and_mv()(s3_async.py:370). - The cleanup runs under
asyncio.wait_for()on Python 3.11.11, therequires-pythonfloor, and on 3.13.1: the local script and the 12 offline-k multipart_uploadtests on 3.11. - "Second cancellation" and "cancellation during CreateMultipartUpload" are left unhandled in both the async and the sync paths.
Evidence limits, as stated in the PR: the AWS test run belongs to 43b3fe4 (185115d changes only the docstring). No real-S3 cancellation and no test for cancellation during completion were run.
AioS3FileSystem._copy_object_with_multipart_upload() aborted the upload only on an Exception, and gather() cancelled the part tasks when the copy was cancelled while their threads kept copying. A cancelled copy therefore left the multipart upload and the parts stored after the cancellation behind. Wait for the parts with asyncio.wait(), which does not cancel them, and run the cleanup on BaseException, so that a cancellation waits for the running parts, aborts the upload, and is re-raised, as S3FileSystem._finish_multipart_upload() does on an interrupt. Fixes #1046 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cancel A cancellation during CompleteMultipartUpload no longer races the abort against the completion that keeps running in its thread: the completion is awaited through shield(), and the cleanup waits for it and aborts the upload only if it did not complete. The cleanup runs in its own task awaited through shield(), so a repeated cancellation of the copy returns without skipping the abort. A module set keeps the cleanup task referenced while it runs. The cancellation test now holds the parts with an event instead of a sleep, and covers a repeated cancellation and a cancellation during the completion. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fe1b559 to
50fe7cb
Compare
| # the cleanup; the event loop keeps only weak references to tasks. | ||
| _cleanup_tasks.add(cleanup) | ||
| cleanup.add_done_callback(_cleanup_tasks.discard) | ||
| await asyncio.shield(cleanup) |
There was a problem hiding this comment.
Independent review (relayed), static review only. Reviewer: Codex CLI 0.160.0, model gpt-6-astra, reasoning effort max, --sandbox read-only, session 01a10457-ec39-7471-aaef-d09f81f11567. Scope: base e49d576ce1e8cb60d74e1fde56de9c72ff3cd8e1, head 185115db8963dabe413396d3c71a883e0516acb6, detached snapshot plus the literal diff. The prompt left out the PR number, the description, the commit messages, and the self-review results. The reviewer ran no edits, builds, or tests and had no GitHub access, and the snapshot and the PR worktree were unchanged afterwards. Covered: async multipart copy and its _copy_file/_cp_file/_mv callers, sync completion/abort handling, tests; success, failures, cancellation propagation, exception retrieval, docs, Python 3.11-3.14. Verdict: FINDINGS (3, all P2).
Finding 1 (P2): a repeated cancellation can skip the abort. Reviewer's scenario: two part copies are running. Cancel the copy, then cancel it again while the cleanup awaits gather. The second cancellation cancels the part tasks and interrupts the cleanup before _abort_multipart_upload, the threads continue, and the upload is never aborted. The same happens for a cancellation during the cleanup after an ordinary part failure. Reviewer's classification: an incomplete repair of the pre-existing leak, not a new one.
Verified. The 185115d version fails the new test_copy_object_with_multipart_upload_cancelled[2]. Repaired in 50fe7cb (maintainer chose to fix it in this PR): the cleanup is the _abort() task, awaited through asyncio.shield() here and kept referenced in _cleanup_tasks. A repeated cancellation returns at once, and the cleanup still waits for the parts and aborts. Remaining limit, stated in the PR: an event-loop shutdown that cancels all remaining tasks also cancels the cleanup.
| ) | ||
| # shield keeps a cancellation from cancelling the completion, whose | ||
| # thread would keep running, so that _abort() can wait for it. | ||
| completed = await asyncio.shield(completion) |
There was a problem hiding this comment.
Independent review (relayed), static review only. Reviewer: Codex CLI 0.160.0, model gpt-6-astra, reasoning effort max, --sandbox read-only, session 01a10457-ec39-7471-aaef-d09f81f11567. Scope: base e49d576ce1e8cb60d74e1fde56de9c72ff3cd8e1, head 185115db8963dabe413396d3c71a883e0516acb6, detached snapshot plus the literal diff. The prompt left out the PR number, the description, the commit messages, and the self-review results. The reviewer ran no edits, builds, or tests and had no GitHub access, and the snapshot and the PR worktree were unchanged afterwards. Covered: async multipart copy and its _copy_file/_cp_file/_mv callers, sync completion/abort handling, tests; success, failures, cancellation propagation, exception retrieval, docs, Python 3.11-3.14. Verdict: FINDINGS (3, all P2).
Finding 2 (P2): the completion-cancellation guarantee was too strong. Reviewer's scenario: cancel while _complete_multipart_upload runs in its thread. The cleanup waited only for the parts and then raced the abort against the completion. If the completion won, the object existed, the abort could fail with NoSuchUpload, and the docstring's "aborts the upload" did not hold. Classification: background completion predates the diff; the concurrent abort and the docstring claim were new.
Verified. The 185115d version fails the new test_copy_object_with_multipart_upload_cancelled_completion[False]. Repaired in 50fe7cb (maintainer's choice: wait for the completion, then decide). The completion is a task awaited through shield() here. _abort() (line 600) waits for it and returns without aborting if it succeeded. If it failed, the upload is aborted. The docstring now says "the upload is aborted unless it has completed".
|
|
||
| @pytest.mark.parametrize("cancellations", [1, 2]) | ||
| @pytest.mark.asyncio | ||
| async def test_copy_object_with_multipart_upload_cancelled(self, cancellations): |
There was a problem hiding this comment.
Independent review (relayed), static review only. Reviewer: Codex CLI 0.160.0, model gpt-6-astra, reasoning effort max, --sandbox read-only, session 01a10457-ec39-7471-aaef-d09f81f11567. Scope: base e49d576ce1e8cb60d74e1fde56de9c72ff3cd8e1, head 185115db8963dabe413396d3c71a883e0516acb6, detached snapshot plus the literal diff. The prompt left out the PR number, the description, the commit messages, and the self-review results. The reviewer ran no edits, builds, or tests and had no GitHub access, and the snapshot and the PR worktree were unchanged afterwards. Covered: async multipart copy and its _copy_file/_cp_file/_mv callers, sync completion/abort handling, tests; success, failures, cancellation propagation, exception retrieval, docs, Python 3.11-3.14. Verdict: FINDINGS (3, all P2).
Finding 3 (P2): the new test could fail with correct code. The semaphore proved that two parts had started, but a 200 ms sleep was all that kept them running until the cancellation. Under a scheduling delay, the parts could finish, part 3 could start, or the copy could complete before task.cancel(). The reviewer asked for explicit start/release synchronization and draining in finally.
Verified. Repaired in 50fe7cb. The parts block on a threading.Event that is released only after the cancellation, and an except cancels the copy if the setup fails. A finally always releases the threads. No timing assumption is left beyond asyncio's FIFO scheduling of the sleep(0) that lets the copy enter its cleanup. The test passed 20 consecutive runs with -W error, and it still fails on master (assert [] == ['abort']).
| failed = True | ||
| completion: asyncio.Task[S3CompleteMultipartUpload] | None = None | ||
|
|
||
| async def _abort() -> None: |
There was a problem hiding this comment.
Self-review of the repair (both perspectives), base a4cf60427f3027db084a51883a59cfea871475f4 (merge-base after rebasing on master), head 50fe7cb0fc2676fc02af422935ce37ae51b87f8b. Full pass, because the repair expands the contract (completion and repeated cancellation). The rebase brought #1052 (S3Path, _copy_file path parsing only) and #1047 (commit() abort handling). Neither touches this method or _abort_multipart_upload().
Round one (behavior): CLEAN.
_abort()readscompletionthrough its closure when it runs, so it sees the task assigned intry.- On a part failure,
completionisNoneand the cleanup aborts, as before. - When the completion raises,
shield()re-raises the error,completion.exception()is notNone, and the cleanup aborts and re-raises. completion.exception()andgather(return_exceptions=True)retrieve every task exception, so no "never retrieved" warning is logged (-W errorruns)._abort_multipart_upload()logs and swallowsException, so the background cleanup does not raise after a repeated cancellation._cleanup_tasksmay hold tasks of several loops (fsspec's IO thread and the caller's loop).set.addandset.discardare atomic under the GIL.
Round two (claims and operations): the docstring claims hold within the stated limits, and the PR description is rewritten for the final behavior. A cancelled completion that succeeded leaves the object without its annotations; the description now states this. The cancellation can take as long as the slowest running part or the completion. The loop-shutdown and CreateMultipartUpload limits are listed. Evidence: just lint; 116 passed in test_s3_async.py with S3, on 50fe7cb; offline multipart tests with -W error on Python 3.13.1 and 3.11.11. Both the master and 185115d behavior fail the new tests, as described in the PR.
Also limit the cancellation described in the docstring to the one after the upload is created. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| cleanup = asyncio.ensure_future(_abort()) | ||
| # A repeated cancellation of this task returns without stopping | ||
| # the cleanup; the event loop keeps only weak references to tasks. | ||
| _cleanup_tasks.add(cleanup) |
There was a problem hiding this comment.
Independent follow-up review (relayed), static review only. Reviewer: Codex CLI 0.160.0, model gpt-6-astra, reasoning effort max, --sandbox read-only, session 01a1046c-4083-7dc1-9a09-e5542d4bd974. This was a full review of base a4cf60427f3027db084a51883a59cfea871475f4 to head 50fe7cb0fc2676fc02af422935ce37ae51b87f8b, because the repair expanded the contract. The reviewer saw a detached snapshot and the literal diff, without the PR text, the commit messages, or prior findings. The snapshot and the PR worktree were unchanged afterwards. Verdict: FINDINGS (2 P2, 2 P3).
Finding 1 (P2): shutdown can abort before the worker threads finish. At event-loop shutdown, the part and completion tasks are cancelled directly, and shield() does not prevent that. The cleanup's gather (line 603) then sees them as finished and may abort while an UploadPartCopy thread is still running, so that part can be stored after the abort. Shutdown can also cancel the cleanup itself and skip the abort. Reviewer's classification: the premature abort is new, and the shutdown leak was pre-existing.
Deferred, documented. On master, shutdown during a multipart copy never aborts, so the whole upload and all its parts are left behind. With this PR, the cleanup that runs aborts the upload, and only a part still copying at that moment can remain. That is no worse than master. Tracking the worker threads apart from their asyncio wrappers would need a different executor design, which this cancellation fix does not call for. The PR description's Not handled list now describes both shutdown outcomes.
Finding 2 (P2): exceptions of a detached cleanup are never retrieved. After a repeated cancellation detaches the cleanup, a manually managed loop may shut down its default executor. The abort's to_thread then raises RuntimeError on submission, and nothing retrieves it.
Deferred, documented. This needs a loop that shuts down its default executor while a detached cleanup still runs. Even then, the error is not silent: asyncio logs it as "Task exception was never retrieved" with its traceback. The PR description lists it under Not handled.
| # Lets the copy enter its cleanup. | ||
| await asyncio.sleep(0) | ||
| # The copy waits for the completion, which is still held. | ||
| assert not task.done() |
There was a problem hiding this comment.
Independent follow-up review (relayed), static review only. Reviewer: Codex CLI 0.160.0, model gpt-6-astra, reasoning effort max, --sandbox read-only, session 01a1046c-4083-7dc1-9a09-e5542d4bd974. This was a full review of base a4cf60427f3027db084a51883a59cfea871475f4 to head 50fe7cb0fc2676fc02af422935ce37ae51b87f8b, because the repair expanded the contract. The reviewer saw a detached snapshot and the literal diff, without the PR text, the commit messages, or prior findings. The snapshot and the PR worktree were unchanged afterwards. Verdict: FINDINGS (2 P2, 2 P3).
Finding 3 (P3): the successful-completion case did not prove that the copy waits. The test released the completion right after cancel(). A version that returned at once could still see "complete" appended before the assertion, so [False] could pass without the fix.
Repaired in 28f0b6a. The completion stays held while the test cancels the copy, yields once so that the cancellation is processed, and asserts not task.done() and events == []. Only then is the completion released. Checked by swapping in older pyathena/ sources: with master (a4cf604), all 4 cancelled* cases fail; with 185115d, cancelled[2] and both cancelled_completion cases fail. At 28f0b6a the cases passed 20 consecutive runs with -W error, and the offline -k multipart_upload tests passed on Python 3.13.1 and 3.11.11.
| ``asyncio.to_thread``. | ||
| and annotation copies run in parallel as asyncio tasks with | ||
| ``asyncio.to_thread``. On a cancellation after the upload is | ||
| created, the running part copies and the completion are waited for, |
There was a problem hiding this comment.
Independent follow-up review (relayed), static review only. Reviewer: Codex CLI 0.160.0, model gpt-6-astra, reasoning effort max, --sandbox read-only, session 01a1046c-4083-7dc1-9a09-e5542d4bd974. This was a full review of base a4cf60427f3027db084a51883a59cfea871475f4 to head 50fe7cb0fc2676fc02af422935ce37ae51b87f8b, because the repair expanded the contract. The reviewer saw a detached snapshot and the literal diff, without the PR text, the commit messages, or prior findings. The snapshot and the PR worktree were unchanged afterwards. Verdict: FINDINGS (2 P2, 2 P3).
Finding 4 (P3): the docstring overstated the guarantee. A cancellation during the unshielded CreateMultipartUpload await can let the worker create an upload whose ID is never kept, and no cleanup runs. That leak is pre-existing, but the blanket claim was new.
Repaired in 28f0b6a: the docstring now says "On a cancellation after the upload is created". The PR description already lists the creation window under Not handled.
Self-review of these repairs: they change one test and one docstring, and add no new behavior. Both rounds: CLEAN. The new assertions observe the documented wait, and the docstring and the PR text now state the same scope.
…behave Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| task.cancel() | ||
| # Gives the cleanup time to abort early or to return, which it | ||
| # must not do while the completion is held. | ||
| await asyncio.sleep(0.1) |
There was a problem hiding this comment.
Independent narrow follow-up (relayed), static review only. Reviewer: Codex CLI 0.160.0, model gpt-6-astra, reasoning effort max, --sandbox read-only, session 01a10477-5f3b-70f2-9166-62387628c16c. Scope: patch 50fe7cb0fc2676fc02af422935ce37ae51b87f8b..28f0b6af9d25b2f667a424146491de6dfbdb7d74 (same base a4cf6042): the docstring and test_copy_object_with_multipart_upload_cancelled_completion. The snapshot and the PR worktree were unchanged afterwards. Verdict: FINDINGS. The docstring now matches the protected phase, and the task.done() assertion catches an implementation that returns at once without aborting, for both parameters.
Finding (P2): the cleanup was not synchronized before the completion was released. A single sleep(0) lets the cancelled copy schedule _abort(), but the test can resume before _abort() runs. A broken cleanup could then pass both parameters if the completion won that race. Such a cleanup would skip the wait and abort unless the completion had already succeeded.
Repaired in cd0c4a4. The completion stays held for asyncio.sleep(0.1) after the cancellation, and the test asserts that the copy has neither returned nor aborted (not task.done(), events == []). A cleanup that aborts early or returns would do so during that window. Correct code cannot fail because of the window's length, since the completion is held until it is released. Checked by swapping in older pyathena/ sources: master fails all 4 cancelled* cases, and 185115d fails 3.
There was a problem hiding this comment.
Independent narrow follow-up of the repair (relayed), static review only: CLEAN. Reviewer: Codex CLI 0.160.0, model gpt-6-astra, reasoning effort max, --sandbox read-only, session 01a1047e-aa1e-78c1-a298-919900d318a0. Scope: patch 28f0b6af9d25b2f667a424146491de6dfbdb7d74..cd0c4a42ccad8f2ff64df135845208ce3f0e5241 (base a4cf6042), both cancellation tests and all their parameters. The snapshot was unchanged afterwards.
Reviewer's notes: both finally blocks release the blocked workers on every failing path. Once the workers have started, a scheduling delay cannot release them early. For both completion parameters, an early abort fails the event assertions, and returning while the completion is held fails assert not task.done(). The 100 ms wait is a bounded observation window, not deterministic synchronization with the cleanup. The existing 5 s start and abort waits can still time out under severe scheduling delays. These waits only bound a hang, and they fail loudly; not actionable.
| def complete_multipart_upload(**kw): | ||
| started.set() | ||
| # The finally blocks of the test always release it. | ||
| release.wait() |
There was a problem hiding this comment.
Independent narrow follow-up (relayed), static review only. Reviewer: Codex CLI 0.160.0, model gpt-6-astra, reasoning effort max, --sandbox read-only, session 01a10477-5f3b-70f2-9166-62387628c16c. Scope: patch 50fe7cb0fc2676fc02af422935ce37ae51b87f8b..28f0b6af9d25b2f667a424146491de6dfbdb7d74 (same base a4cf6042): the docstring and test_copy_object_with_multipart_upload_cancelled_completion. The snapshot and the PR worktree were unchanged afterwards. Verdict: FINDINGS. The docstring now matches the protected phase, and the task.done() assertion catches an implementation that returns at once without aborting, for both parameters.
Finding (P3): the hold depended on elapsed time. The mock ignored the result of release.wait(5). If the loop thread were delayed by 5 s, the completion would go ahead unreleased and fail the assertion with correct code.
Repaired in cd0c4a4, in both cancellation tests. The mocks call release.wait() with no timeout. Every test path reaches release.set() in a finally, so a failing test cannot leave a thread blocked. Self-review of the repair, both perspectives: CLEAN. The change is test-only; it passed 20 consecutive runs with -W error, and the offline multipart tests passed on Python 3.13.1 and 3.11.11. With the older sources the tests failed as described instead of hanging.
WHAT
AioS3FileSystem._copy_object_with_multipart_upload()now cleans up a cancelled copy. It waits for the part copies and the completion that are still running in threads, aborts the multipart upload unless it has completed, and re-raises the cancellation. This matches whatS3FileSystem._finish_multipart_upload()does on an interrupt.asyncio.wait(..., return_when=FIRST_EXCEPTION)instead ofasyncio.gather(). When the awaiting task is cancelled,gather()cancels the part tasks while theirasyncio.to_thread()copies keep running, so they could no longer be waited for.wait()leaves the part tasks running. A failed part's error is raised, as before.asyncio.shield(), so a cancellation does not detach it from its thread.BaseExceptioninstead ofException, so it also runs onasyncio.CancelledError(for exampletask.cancel(), anasyncio.wait_for()timeout, or a cancelledgather()orTaskGroupof copies). It first waits for the running parts. Parts that have not started do not start after the cancellation, as after a failed part. It then waits for the completion, if it was sent, and aborts the upload unless the completion succeeded.asyncio.shield(). A repeated cancellation of the copy therefore returns at once, while the cleanup still runs to the abort. A module-level set keeps the cleanup task referenced while it runs, because the event loop keeps only weak references to tasks.Behavior changes for the release notes, for
AioS3FileSystemcopies of objects over 5 GiB (_cp_file()/cp_file()/copy()and_mv()/mv()):max_workersparts of up toblock_sizeeach). Before, the cancellation returned at once and left the multipart upload, and the parts stored after the cancellation, in the destination bucket.Not handled:
asyncio.run()cancelling the remaining tasks at exit. Shutdown cancels the part, completion, and cleanup tasks directly, andshield()does not protect against that. A cleanup that is cancelled skips the abort, as master always did. A cleanup that is still running can abort while a part thread is still copying, so that part may be stored after the abort; master did not abort at all in this case.WHY
Fixes #1046. #973 (PR #1036) added the abort for failed parts and completions and deliberately left cancellation out.
TEST
Tested head: cd0c4a4 (rebased on master a4cf604). The S3 run below was at 50fe7cb; the later commits change only a docstring and the cancellation tests, whose offline runs below are at cd0c4a4.
just formatandjust lint: passed.TestAioS3FileSystem.test_copy_object_with_multipart_upload_cancelled[1|2](offline, mocked requests) holds two of three parts with an event and cancels the copy once or twice. It checks that both parts finish before the abort, that part 3 never starts, that the completion is not sent, and thatCancelledErroris raised. With two cancellations, it also checks that the copy returns while the parts still run and that the abort follows.TestAioS3FileSystem.test_copy_object_with_multipart_upload_cancelled_completion[False|True]holds the completion and cancels the copy. It keeps the completion held for 0.1 s after the cancellation and checks that the copy neither returns nor aborts meanwhile, that a successful completion is not aborted, and that a failed one is aborted after it finishes.pyathena/change (master behavior), all 4 new cases fail. With the first version of this PR (single cancellation only),cancelled[2]and bothcancelled_completioncases fail.uv run --env-file .env pytest -n 1 tests/pyathena/filesystem/test_s3_async.py: 116 passed (includes the S3 integration tests).-k multipart_uploadtests with-W error: 15 passed on Python 3.13.1 and 3.11.11 (therequires-pythonfloor). Thecancelledtests passed 20 consecutive runs.asyncio.run(..., debug=True), for three cases:task.cancel(),task.cancel()with a part that then fails, and anasyncio.wait_for()timeout. In each case the abort ran after parts 1 and 2 finished, no part finished afterwards, and nothing was logged.Not run: cancellation against real S3, because a multipart copy needs a source object over 5 GiB.
🤖 Generated with Claude Code