Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4ac521d
Free failed parts without the GC and abort copies at loop shutdown
laughingman7743 Oct 15, 2026
779e213
Document the loop shutdown of async copies and raise_failure
laughingman7743 Oct 15, 2026
3c94685
Free chained errors, multipart writes, and failed creations too
laughingman7743 Oct 15, 2026
1cce639
Submit parts from a method and use raise_failure for copied parts
laughingman7743 Oct 15, 2026
8dc6ae9
Clear grouped errors and retrieve every error in raise_failure
laughingman7743 Oct 15, 2026
1fc62be
Run the cleanup of an async multipart copy in a filesystem thread
laughingman7743 Oct 15, 2026
30d45e6
Wait for shielded requests without logging their late failures
laughingman7743 Oct 16, 2026
6b9253a
Correct the Python version in the comment of wait_shielded
laughingman7743 Oct 16, 2026
395cb07
Check the late failure logs after the threads have run their callbacks
laughingman7743 Oct 16, 2026
0975505
Wait until the task waits for the call before cancelling it
laughingman7743 Oct 16, 2026
d9a9a8c
Bound the wait of the shielded test and always release its call
laughingman7743 Oct 16, 2026
0c2f5ea
Free failed reads and retried API errors without the GC
laughingman7743 Oct 16, 2026
658b2f9
Drop the parts before an abort and the buffer of a failed commit
laughingman7743 Oct 16, 2026
a8761d2
Keep rows of a failed fetch, skip empty Arrow batches, move the clamp
laughingman7743 Oct 16, 2026
e466248
Narrow the cleanup to the data of failed requests
laughingman7743 Oct 16, 2026
a333e17
Describe how a cancelled copy leaves its requests to the cleanup
laughingman7743 Oct 16, 2026
14b7ab3
Cancel interrupted reads and reuse _close_without_commit()
laughingman7743 Oct 16, 2026
5fe9176
Clear the wait frames of interrupted reads and synchronize their tests
laughingman7743 Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions docs/filesystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,11 @@ from that version, which needs `s3:GetObjectVersion` and, to copy the tags,
upload completes, so the destination exists without them until the last one is
written. If an annotation fails to copy, the error is raised and the destination is
kept. A failed part copy aborts the multipart upload. So does an interrupt, or the
cancellation of an `AioS3FileSystem` copy, unless the upload has already completed. A
cancellation of an `AioS3FileSystem` copy, including one by the shutdown of the event
loop at the end of `asyncio.run()`, unless the upload has already completed. A

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 1, finding 7 (own pass): the copy cancellation paragraph did not cover the shutdown of the event loop, which this PR now handles; added (b9bf983). raise_failure() is public, so it is listed in docs/api/filesystem.rst with the other executor symbols, and its summary line now states that it empties the set (39b5300).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 2, documentation reader: docs/filesystem.md (this paragraph) and docs/aio.md (threads section, comparison table, async file handles) still match the behavior: part copies in flight finish first, queued ones do not start, and the copy runs one asyncio task per part. No other page describes garbage collection of failed writes or traceback contents.

CreateMultipartUpload request and the part copies in flight finish first, and so does
the CompleteMultipartUpload request of an `AioS3FileSystem` copy. A further interrupt
the CompleteMultipartUpload request of an `AioS3FileSystem` copy; the requests of an
`AioS3FileSystem` copy that have not started are cancelled. A further interrupt
while the CreateMultipartUpload request is waited for is ignored. One while the part
copies are waited for or the upload is aborted stops the cleanup, and the upload ID is
logged.
Expand Down Expand Up @@ -540,6 +542,7 @@ It drops the buffer and closes the file before waiting, so a later close or defe
It suppresses Ctrl-C during the blocking recovery wait and re-raises the original error when cleanup finishes.
An additional interruption outside that wait can stop cleanup; once the upload identity is known, `discard()` can retry the abort.
An abort failure retains the upload identity for `discard()` to retry.
A failed multipart `commit()` also closes the file without its buffered data, so a later `commit()` neither completes that upload nor uploads the last part alone with PutObject.
Cancelling an async buffered write through `asyncio.to_thread` still allows its writer thread to finish.

### Multipart requests
Expand Down
55 changes: 36 additions & 19 deletions pyathena/filesystem/s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import mimetypes
import os.path
import time
import traceback
from collections.abc import Callable, Mapping
from concurrent.futures import FIRST_COMPLETED, FIRST_EXCEPTION, Future, as_completed, wait
from copy import deepcopy
Expand Down Expand Up @@ -3280,7 +3281,8 @@ def commit(self) -> None:
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.
for a file whose failed write or multipart commit dropped the written
data.

Raises:
FileExistsError: If an object was created at the path after the
Expand All @@ -3289,9 +3291,9 @@ def commit(self) -> None:
initialized.
"""
if self.buffer is None:
# _close_without_commit() dropped the written data. A multipart
# upload that it failed to abort is kept for discard(), not
# completed.
# _close_without_commit() or a failed commit dropped the written
# data. A multipart upload that they failed to abort is kept for
# discard(), not completed.
return
if self.tell() == 0:
self.discard()
Expand All @@ -3307,7 +3309,6 @@ def commit(self) -> None:
if not self.multipart_upload:
raise RuntimeError("Multipart upload is not initialized.")

upload_id = cast(str, self.multipart_upload.upload_id)
try:
done, _ = wait(self.multipart_upload_parts, return_when=FIRST_EXCEPTION)
for future in done:
Expand All @@ -3320,17 +3321,13 @@ def commit(self) -> None:
parts, **self._get_request_kwargs("complete_multipart_upload")
)
except BaseException:
# discard() keeps the upload if the abort fails or is
# interrupted, so that a later discard(), such as a
# transaction rollback, retries the abort. An abort failure
# Closed without the buffer, which holds only the data of the
# last part that a later commit() would otherwise upload alone
# with PutObject. discard() keeps the upload if the abort
# fails or is interrupted, so that a later discard(), such as
# a transaction rollback, retries the abort. An abort failure
# is logged so that it does not mask the original error.
try:
self.discard()
except Exception:
_logger.exception(
f"Failed to abort multipart upload {upload_id} "
f"to s3://{self.bucket}/{self.key}."
)
self._close_without_commit()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 1, /code-review cleanup: the failed-commit branch repeated _close_without_commit() (drop the buffer, close, discard, log, shut down the executor) with small differences. Repaired in 14b7ab3 by calling _close_without_commit(); test_commit_failure_and_discard covers the closed file and the no-op second commit.

raise

self.fs.invalidate_cache(self.path)
Expand Down Expand Up @@ -3414,7 +3411,9 @@ def _fetch_range(self, start: int, end: int) -> bytes:
The range is clamped to the size of the object, since fsspec caches
may request a range that is empty or reaches past the end of the
object. S3 would answer the former with the whole object and a range
starting past the end with an ``InvalidRange`` error.
starting past the end with an ``InvalidRange`` error. A range larger
than the block size is read with parallel requests; when one fails,
those that have not started are cancelled and its error is raised.

Args:
start: The offset of the first byte to read.
Expand All @@ -3436,9 +3435,27 @@ def _fetch_range(self, start: int, end: int) -> bytes:
self._executor.submit(self.fs.core.get_object, path, r, **request_kwargs)
for r in ranges
]
for future in as_completed(futures):
# The failure of the first read to finish is raised.
future.result()
try:
done = wait(futures, return_when=FIRST_EXCEPTION).done

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 1, /code-review finding (P2-like): S3File._fetch_range() cancelled and dropped the other ranges only on a failure, not when the wait was interrupted (Ctrl-C), so the interrupt's traceback (e.g. sys.last_exc in a REPL) kept every range's data. Repaired in 14b7ab3 with the same except BaseException as AioS3StreamedFile._fetch_range(). Covered by test_interrupted_read_frees_ranges, which fails before the repair.

Rejected: the finding that a cancelled AioS3StreamedFile._fetch_range() leaves the errors of failed read tasks unretrieved. Future.cancel() clears the log flag even on a finished future (checked on 3.11 and 3.15: an unretrieved failed task logs "Task exception was never retrieved" unless it is cancelled), so cancelling them already suppresses the log; the comments now say so.

except BaseException as e:
# The reads are cancelled with the read of the range, as below,
# and the frames of the wait, which keep their futures, are
# cleared; clear_frames() skips this frame, which is executing.
traceback.clear_frames(e.__traceback__)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent review of the narrowed PR (relayed results). Reviewed snapshot 14b7ab38cecb88e53a6b8874dda97b83c9112d72 against base b2d7aa24b00a204e9e150952c708e3bb1126f9e2, prompt without PR text or prior findings, read-only, static. (1) Codex CLI 0.162.1, model gpt-6-astra, codex exec -s read-only, session 01a14584-c91a-7ce0-a6b0-ffee5901751b: FINDINGS (4 × P2). (2) Claude Code claude-fable-5-1, profile max, effort high, --permission-mode plan with read-only tools (requested by the maintainer as an additional reviewer): FINDINGS (2 × Low, 2 notes; "high・medium はなし。意図された 3 つの挙動は成立している"). Fable verified _S3WorkItem claim ordering on all three executors, S3Core.call clearing, the copy cleanup ordering (no pool wait on queued requests), commit() → _close_without_commit(), and that the regression tests fail on the old code.

Codex P2 #1: "Interrupted waits retain range results. If some ranges finish before a KeyboardInterrupt or task cancellation interrupts the wait, emptying futures/reads leaves the wait helpers' own collections reachable through the exception's traceback ... The synchronous regression test replaces wait entirely, so it misses this path."

Verified (concurrent.futures.wait() and asyncio.wait() keep the futures in a set in their frames). Repaired in 5fe9176: the interrupted paths also clear the frames below _fetch_range() in the traceback; the tests now wait as those functions do, with a set of the finished reads in the frame, and fail before the repair (test_interrupted_read_frees_ranges, test_cancelled_read_frees_ranges).

while futures:
futures.pop().cancel()
raise
failed = next(
(f for f in done if not f.cancelled() and f.exception() is not None), None
)
if failed is not None:
# The reads that have not started are cancelled, and the
# futures of the others are dropped, so that this frame, which
# the traceback of the error keeps, does not keep their data.
while futures:
futures.pop().cancel()
done.clear()
failed.result()
return b"".join(f.result() for f in futures)
return self.fs.core.get_object(path, ranges[0], **request_kwargs)

Expand Down
138 changes: 89 additions & 49 deletions pyathena/filesystem/s3_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import logging
import mimetypes
import os
import traceback
import warnings
from concurrent.futures import FIRST_EXCEPTION, Future, wait
from io import BytesIO
Expand Down Expand Up @@ -53,8 +54,6 @@
from pyathena.connection import Connection

_logger = logging.getLogger(__name__)
# The running cleanups of cancelled multipart copies.
_cleanup_tasks: set[asyncio.Future[None]] = set()


class AioS3FileSystem(AsyncFileSystem):
Expand Down Expand Up @@ -579,11 +578,14 @@ async def _copy_object_with_multipart_upload(
See :meth:`S3FileSystem._copy_object_with_multipart_upload`. The part
and annotation copies run in parallel as asyncio tasks, at most
``max_workers`` at once, in the threads of the filesystem, which bound
the requests of all its copies together. On a cancellation, the creation of the upload,
the running part copies and the completion are waited for, the
upload is aborted if it was created and has not completed, and the
cancellation is re-raised. A repeated cancellation returns without
stopping this cleanup.
the requests of all its copies together. On a cancellation, the
requests that have not started are cancelled, the running ones are
waited for in a thread of the filesystem, the upload is aborted if it
was created and has not completed, and the cancellation is re-raised.
This also holds when the shutdown of the event loop, such as at the
end of ``asyncio.run()``, cancels the copy, also after its cleanup has
started. A repeated cancellation returns without stopping this
cleanup.

Args:
source: Source S3 path, with the version ID to copy, if any.
Expand Down Expand Up @@ -612,6 +614,9 @@ async def _copy_object_with_multipart_upload(

semaphore = asyncio.Semaphore(max_workers)
failed = False
# The requests of the parts, which keep running when their tasks are
# cancelled, such as by the shutdown of the event loop.
part_copies: list[Future[S3MultipartUploadPart]] = []

async def _upload_part(i: int, range_: tuple[int, int]) -> S3MultipartUploadPart | None:
nonlocal failed
Expand All @@ -620,55 +625,61 @@ async def _upload_part(i: int, range_: tuple[int, int]) -> S3MultipartUploadPart
# The upload is being aborted; do not start more parts.
return None
try:
return await self._thread_pool.run(
part_copy = self._thread_pool.submit(
self.core.upload_part_copy,
upload=multipart_upload,
part_number=i + 1,
source=plan.source,
range_=range_,
**plan.part_params,
)
part_copies.append(part_copy)
copied = asyncio.wrap_future(part_copy)
return await copied
except Exception:
# Set before the semaphore lets a waiting part start.
failed = True
raise

tasks: list[asyncio.Future[S3MultipartUploadPart | None]] = []
completion: asyncio.Future[S3CompleteMultipartUpload] | None = None
completion: Future[S3CompleteMultipartUpload] | None = None

async def _abort() -> None:
await asyncio.wait([creation])
def _abort() -> None:
"""Abort the upload, if any, once its requests have ended.

Runs in a thread of the filesystem, so that neither a repeated
cancellation nor the shutdown of the event loop stops it.
"""
wait([creation])
if creation.cancelled() or creation.exception() is not None:
# No upload was created.
return
# A part that is still copying when the upload is aborted may be
# stored after the abort, so wait for the running parts first.
await asyncio.gather(*tasks, return_exceptions=True)
# stored after the abort.
wait(part_copies)
if completion is not None:
await asyncio.wait([completion])
wait([completion])
if not completion.cancelled() and completion.exception() is None:
# The upload completed despite the cancellation, so
# there is nothing to abort.
return
await self._thread_pool.run(
self._sync_fs._abort_multipart_upload,
creation.result(),
plan.abort_params,
)
self._sync_fs._abort_multipart_upload(creation.result(), plan.abort_params)

# A task, so that _abort() can wait for an upload that is created
# after a cancellation; scheduled right before the try, so that
# The requests run in the threads of the filesystem, not as tasks,
# which the shutdown of the event loop would cancel while the
# requests keep running; scheduled right before the try, so that
# nothing can fail between the two.
creation = asyncio.ensure_future(
self._thread_pool.run(
self.core.create_multipart_upload, plan.destination, **plan.create_params
)
creation = self._thread_pool.submit(
self.core.create_multipart_upload, plan.destination, **plan.create_params
)
try:
# shield keeps a cancellation from cancelling the creation, whose
# thread would keep running, so that _abort() can wait for it.
multipart_upload = await asyncio.shield(creation)
# A cancellation of this task leaves the creation to the cleanup,
# which waits for an upload that a started creation creates.
multipart_upload = await self._thread_pool.wait_shielded(creation)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent follow-up review (relayed Codex result). Reviewer: Codex CLI 0.162.1, model gpt-6-astra, codex exec -s read-only, session 01a141e7-6db0-7d02-a814-848a94f42b25. Static review of a clean detached snapshot of 8b69b92034cd1acaa2dfa49913d87f9737c9dcc1: the new commits 39b5300b..8b69b920, with the range-diff showing the earlier commits unchanged (base 7dd8ee410262f37fc1c6edd441625a624caa65a3). Covered: exception-group clearing, raise_failure() and its callers, shared-pool ordering ("No pool dependency cycle found: cleanup waits only for earlier submissions, and failed prevents further part submissions before cleanup starts"), cancellation, creation/part/completion/annotation failures, loop shutdown/stop/close, pool shutdown and fork, Python 3.11-3.15 APIs, both docs pages, and the changed tests. Result: FINDINGS (1 × P2). Limits stated by the reviewer: runtime behavior, fork/interpreter exit, and scheduling under load unverified; the cleanup survives loop stop/close once submitted, not if the loop stops before the cancellation handler runs.

Finding (P2): "Creation and completion errors can become unretrieved asyncio exceptions after cancellation. Cancel a copy while creation or completion is running, let cancellation callbacks execute, then have that request fail while the loop remains running. Cancellation detaches shield() from the pending asyncio wrapper. Cleanup now calls .exception() on the underlying concurrent future; that does not retrieve the exception subsequently stored in the asyncio wrapper."

Verified: reproduced on 3.11/3.13 ("Future exception was never retrieved") and 3.15 ("OSError exception in shielded future"); master logs nothing, so this was a regression of 8b69b92. Repaired in a5f4d5e/ef7bfd65 with the public S3AioThreadPool.wait_shielded(future), which waits with asyncio.wait() and retrieves the outcome; shield() on Python 3.14+ logs such a failure even when it is retrieved, so it cannot be used. The copy and run_shielded() use it. Covered by test_wait_shielded_cancelled and new log assertions in test_copy_object_with_multipart_upload_cancelled_creation|completion[True], which fail before the repair on 3.11 and 3.15.

tasks = [asyncio.ensure_future(_upload_part(i, r)) for i, r in enumerate(plan.ranges)]
for task in tasks:
# Retrieved, so that asyncio does not log the failure of a
# part that ends after this copy as never retrieved.
task.add_done_callback(lambda t: t.cancelled() or t.exception())
# Unlike gather, wait does not cancel the parts when this task is
# cancelled; their threads would keep copying, so they are waited
# for in _abort().
Expand All @@ -678,27 +689,29 @@ async def _abort() -> None:
raise error
# The tasks are in part-number order.
parts = [task.result() for task in tasks]
completion = asyncio.ensure_future(
self._thread_pool.run(
self.core.complete_multipart_upload,
multipart_upload,
cast(list[S3MultipartUploadPart], parts),
**plan.complete_params,
)
completion = self._thread_pool.submit(
self.core.complete_multipart_upload,
multipart_upload,
cast(list[S3MultipartUploadPart], parts),
**plan.complete_params,
)
# 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)
# A cancellation of this task leaves the completion to the
# cleanup, which waits for a started completion.
completed = await self._thread_pool.wait_shielded(completion)
except BaseException:
# Also on cancellation, as S3FileSystem._finish_multipart_upload
# does on an interrupt.
failed = True
cleanup = asyncio.ensure_future(_abort())
# The requests that have not started are cancelled here, before
# the cleanup waits for a thread after them.
creation.cancel()
for part_copy in part_copies:
part_copy.cancel()
if completion is not None:
completion.cancel()
# A repeated cancellation of this task returns without stopping
# 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)
# the cleanup.
await self._thread_pool.run_shielded(_abort)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent review (relayed Codex result). Reviewer: Codex CLI 0.162.1, model gpt-6-astra, codex exec -s read-only, session 01a14035-f02c-7e43-bb77-9d5ebe34f6b0. Static review of a clean detached snapshot of 39b5300bd882aa050e1c21d6e8f72adbb14bd664 against base 7dd8ee410262f37fc1c6edd441625a624caa65a3; prompt without PR text or prior findings; no edits, builds, tests, or network. Covered: the diff, surrounding source, conventions, docs; submit_to()/settle() ordering and reference lifetimes across the three executors; raise_failure(); creation, uploads, append copies, commit, and abort of the three file types; async copy cancellation, repeated cancellation, loop shutdown; test sensitivity and Python declarations. Result: FINDINGS (4 × P2, 1 × P3). Snapshot and PR worktree were unchanged afterwards.

Finding 1 (P2): "Shutdown can cancel cleanup that has already started. Cancel a copy while a request is running, let _abort() start waiting, then return from main(). asyncio.run() cancels the existing cleanup task directly; shield(cleanup) only prevents cancellation propagated through its waiter. Cleanup exits while the request continues, leaving the upload without an abort."

Verified: reproduced (['create'], no abort); master behaves the same. Repaired in 8b69b92 by the maintainer's choice: the cleanup runs in a thread of the filesystem through run_shielded(), waiting for the requests as concurrent futures, so the event loop cannot stop it (_cleanup_tasks removed); the creation and the part copies that have not started are cancelled before the cleanup is scheduled. Reproduction now ['create', 'abort']; an abort that ends after asyncio.run() returns still completes before the interpreter exits. Covered by test_copy_object_with_multipart_upload_loop_shutdown[True-create|part], which fail before the repair.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review of the independent-review repair (rounds 1 and 2 perspectives), git range-diff 7dd8ee41..39b5300b 7dd8ee41..8b69b92034cd1acaa2dfa49913d87f9737c9dcc1: commits 1-4 unchanged, new 01d870a and 8b69b92; base unchanged (7dd8ee410262f37fc1c6edd441625a624caa65a3). Result: CLEAN after the repairs above.

Behavior (round 1):

  • Thread cleanup cannot deadlock the filesystem pool: S3AioThreadPool starts calls in submission order, and the cleanup waits only for its copy's requests, which were submitted before it; the queued ones are cancelled on the event loop before the cleanup is submitted, and no part task submits after failed = True, which is set in the same step without an await.
  • Errors stay retrieved: the shield's inner wrappers of the creation and completion are retrieved by shield, the part tasks by their own done callbacks (previously by the cleanup's gather), and _abort_multipart_upload() logs instead of raising.
  • A creation that fails, a part that fails, a completion that fails, and the plain and repeated cancellations keep their previous outcomes: the existing copy tests pass (-k copy_object_with_multipart_upload, 40 passed), and the full filesystem suite passes (1295 passed, 9 skipped).
  • The creation that has not started is now cancelled, as S3FileSystem._copy_object_with_multipart_upload() does with creation.cancel().

Claims (round 2):

  • The description now states that a copy cancelled before the shutdown lets asyncio.run() return while the abort runs; checked that the abort still completes before the interpreter exits (concurrent.futures joins the pool threads at exit).
  • docs/filesystem.md ("the part copies in flight finish first") and the method docstring were checked against the new behavior; the docstring now says the cleanup runs in a thread and cancels the requests that have not started.
  • Python 3.11 and 3.13: 109 related tests passed each; the new tests 5 times on 3.15.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 1, /code-review findings deferred with reasons:

  • If the filesystem's pool is shut down during a copy, run_shielded(_abort) raises RuntimeError in the except block, hiding the original error, and the upload is not aborted. This is the same on master (its cleanup task also submitted the abort to the pool). An inline fallback would block the event loop for as long as the running part copies take, so it is not taken here.
  • The cleanup holds a pool thread while it waits for the running part copies, which reduces the threads available to other calls during a mass cancellation. It cannot deadlock (it waits only for requests submitted before it; queued ones are cancelled first). An event-driven cleanup chained on the request futures would avoid holding a thread; it belongs with the request ownership of Give multipart writes a single owner of their parts and an explicit state #1211.

raise

failed = False
Expand Down Expand Up @@ -1612,7 +1625,9 @@ async def _fetch_range(self, start: int, end: int) -> bytes:
"""Read a byte range of the object with parallel range requests.

The range is clamped to the size of the object, and split into ranges
of the block size, read in the threads of the file.
of the block size, read in the threads of the file. When a read fails,
the reads that have not finished are cancelled and its error is
raised, as :meth:`S3File._fetch_range` does.

Args:
start: The offset of the first byte to read.
Expand All @@ -1629,7 +1644,32 @@ async def _fetch_range(self, start: int, end: int) -> bytes:
)
path = S3Path(self.bucket, self.key, self.version_id)
request_kwargs = self._get_request_kwargs("get_object")
results = await asyncio.gather(
*[self._pool.run(self.fs.core.get_object, path, r, **request_kwargs) for r in ranges]
)
return b"".join(results)
reads = [
asyncio.ensure_future(
self._pool.run(self.fs.core.get_object, path, r, **request_kwargs)
)
for r in ranges
]
try:
done = (await asyncio.wait(reads, return_when=asyncio.FIRST_EXCEPTION))[0]
except BaseException as e:
# The reads are cancelled with the read of the range, and the
# frames of the wait, which keep their tasks, are cleared;
# clear_frames() skips this frame, which is executing. Cancelling a

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fable Low #1, rejected: "割り込み経路で、既に失敗済みの read タスクの例外が取得されない ... cancel() を呼ぶが、これは no-op なので例外は未取得のまま残り". Future.cancel()/Task.cancel() clear the log flag before checking the state, so cancelling a finished failed task suppresses "Task exception was never retrieved"; checked on Python 3.11 and 3.15 (an unretrieved failed task logs unless it is cancelled). The comment states this.

Fable note #4 (unverified boundary of shield() logging on 3.14): checked the 3.14.5 stdlib, which has the same _log_on_exception as 3.15; 3.13 does not.

# failed read also keeps asyncio from logging its error.
traceback.clear_frames(e.__traceback__)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review of the repair and independent follow-up for 14b7ab38..5fe9176a6dd59f64b3c1eb60c66f1ba54e07f50a (base unchanged, b2d7aa24).

Self-review (both perspectives): the new clear_frames() calls run in except BaseException as e of _fetch_range(), whose own frame is executing and skipped; only the finished frames of the wait below are cleared, so the interrupt or cancellation keeps its type, identity, and traceback lines. The docstring of submit_to() now matches what _S3WorkItem and S3Core.call() clear. Validation: the full filesystem suite on 3.15 passed (1287 passed, 9 skipped); the related tests passed on 3.11, 3.13, and 3.14 (95 each); the new interruption tests fail before the repair. The description's counts are updated.

Independent follow-up (relayed): Codex CLI 0.162.1, model gpt-6-astra, codex exec -s read-only, session 01a14704-69ac-72e0-9120-ff240409a4e1, static review of a clean snapshot of this head for the diff 14b7ab38..5fe9176a: CLEAN. "At both new clear_frames() calls, the wait frames have unwound and _fetch_range remains executing, so it is skipped. Caller frames above it are not traversed. ... The revised tests establish the relevant completion boundaries before asserting release or cancelling the queued completion."

while reads:
reads.pop().cancel()
raise
# A comprehension, whose variable does not keep a read in this frame.
failures = [read for read in done if not read.cancelled() and read.exception() is not None]
if failures:
# The reads that have not finished are cancelled, and the tasks of
# the others are dropped, so that this frame, which the traceback
# of the error keeps, does not keep their data. Cancelling a failed
# read also keeps asyncio from logging its error.
while reads:
reads.pop().cancel()
done.clear()
failures[0].result()
return b"".join(read.result() for read in reads)
15 changes: 13 additions & 2 deletions pyathena/filesystem/s3_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import contextlib
import logging
import math
import traceback
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
Expand Down Expand Up @@ -631,14 +632,24 @@ def call(self, method: str | Callable[..., Any], **request) -> dict[str, Any]:
Raises:
OSError: The translation of an S3 error response (see
:class:`~pyathena.filesystem.s3_errors.S3ClientError`).

Note:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review round 2 of the narrowed PR (claims, callers, AWS operation, docs, evidence). Base b2d7aa24b00a204e9e150952c708e3bb1126f9e2, head 14b7ab38cecb88e53a6b8874dda97b83c9112d72. Result: FINDINGS in the description, corrected; no code change.

Claims checked against measurements at this head (Python 3.15, without gc.collect()): an upload_part answered with AccessDenied by a botocore Stubber keeps 0.1 MiB while its error is held (master 8.1 MiB); a parallel read of 4 × 8 MiB ranges whose first range fails keeps 0.2 MiB (master 24 MiB). The description's statement that files and caller buffers are left to the garbage collector matches: the #1173 reproduction still keeps 16 MiB (the buffer and the next part) until gc.collect().

Corrections: the release note and the description of the read path now include an interrupted wait (14b7ab3), the full suite count is 1286, the related tests are 94 on 3.11, 3.13, and 3.14, and test_interrupted_read_frees_ranges is listed.

Callers and operation: submit_to()/submit() keep their signatures and outcomes; S3Core.call() raises the same exception types; no request is added (reads that have not started are cancelled, and an AioS3FileSystem copy cancels its queued requests). docs/filesystem.md states the copy cleanup at loop shutdown, the cancellation of requests that have not started, and the closed file after a failed multipart commit; the docstrings of S3Core.call(), submit_to(), _fetch_range(), commit(), and the async copy match the code. Not verified: behavior under real S3 errors other than the stubbed AccessDenied; the AWS jobs run when the PR is Ready.

The frames of a raised error below this method, those of the
retries and of botocore, have no local variables, so that they
keep neither the request, such as the body of a part, nor the
retry state, which keeps the error in a reference cycle.
"""
func = getattr(self._client, method) if isinstance(method, str) else method
if self._request_kwargs:
request = {**self.operation_params(func.__name__, self._request_kwargs), **request}
try:
response = retry_api_call(func, config=self._retry_config, logger=_logger, **request)
except botocore.exceptions.ClientError as e:
raise S3ClientError(e).os_error from e
except BaseException as e:
# clear_frames() skips this frame, which is still executing.
traceback.clear_frames(e.__traceback__)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 1, /code-review finding deferred: clearing the frames below call() on every error also runs for expected misses (404 from exists()/info()) and removes the locals of request frames from every S3 error, also without an executor. This is the approved design: call() is the single boundary of S3 requests, which covers direct requests (PutObject of small files, single-range reads) and breaks tenacity's retry-state cycle at its source; the cost is a walk of the frames of an error that already made a network request. The release notes state the debugging change.

if isinstance(e, botocore.exceptions.ClientError):
raise S3ClientError(e).os_error from e
raise
return cast(dict[str, Any], response)

def head_object(self, path: S3Path, **params) -> S3Metadata:
Expand Down
Loading
Loading