Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 6 additions & 5 deletions docs/filesystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,12 @@ this section apply to the compressed data.
A multipart write keeps at most `max_workers` parts of the written data in flight and
waits for one of them to finish before it uploads another. The parts take about
`max_workers` × the block size of memory, in addition to the buffered data of the file.
If one of these parts has failed when the write waits, the write raises its error
instead of uploading the rest of the data, and aborts the multipart upload after the
parts being uploaded finish. A part that fails after the last wait raises its error
when the file is closed, or when the transaction commits, and the multipart upload is
aborted.
If one of these parts has failed when the write waits, or a part copied from the

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 two (claims, callers, operations, evidence)

  • Base: 3a5018225e536ba5b0a920465e3f141dae0447e4, head: f135e16b99c518a08bd17a489e03c5fa8105c6c8.
  • Scope: a full pass over the PR title and body, the commit messages, the changed docstrings and comments, this docs paragraph, and the premises of Failed part copies do not stop an append or a sync multipart copy #1178.
  • Result: CLEAN after one PR-text correction.

Claims checked:

  1. "The check does not depend on the write waiting for a part." The copy check runs at the top of every iteration of iter_parts(), outside the max_workers branch. test_append_failed_part_copy covers max_workers=4, where the second write never waits.
  2. "Queued part copies are cancelled before they start; running ones are waited for before the abort." On the raise, _finish_multipart_upload() and commit() (through discard()) 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.
  3. "AioS3FileSystem._copy_object_with_multipart_upload() already stops at the first failure." It uses asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION) (pyathena/filesystem/s3_async.py:641).
  4. "AioS3File shares _upload_chunk(), _initiate_upload(), and commit()." AioS3File (pyathena/filesystem/s3_async.py:1030) overrides none of them.
  5. Docs: "before the write uploads another part" matches claim 1. "A part that fails later raises its error when the file is closed, or when the transaction commits" matches commit(). The cp paragraph ("A failed part copy aborts the multipart upload ... the part copies in flight finish first") is still accurate. No other text in docs/ describes the order of part failures.

Existing callers and operations:

  • Interrupts during the new waits go through the same except BaseException cleanup as before.
  • A future cancelled by S3AioExecutor's loop shutdown is skipped by the new scans, and still raises CancelledError from the in-order collection, as before.
  • wait() over up to 10,000 futures adds one waiter per future, which is O(n) like the previous result() loop.
  • Requests after a failed part copy drop from all the remaining parts or copies to the ones already running.

Evidence: local only, Python 3.13.1 on macOS.

  • The full tests/pyathena/filesystem/ suite: 1221 passed, 9 skipped, with live S3, on the code of f135e16b.
  • The 4 new test cases fail on master.
  • The tests that replace wait() passed 5 runs in a row.
  • AWS CI has not run (Draft).

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".

existing object in an append has failed before the write uploads another part, the
write raises its error instead of uploading the rest of the data, and aborts the
multipart upload after the parts being uploaded or copied finish. A part that fails
later raises its error when the file is closed, or when the transaction commits, and
the multipart upload is aborted.
`max_workers` is an argument of `S3FileSystem`, `AioS3FileSystem`, `open`, `put`, and
`pipe`.

Expand Down
45 changes: 36 additions & 9 deletions pyathena/filesystem/s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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:

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 one (behavior and implementation)

  • Base: 3a5018225e536ba5b0a920465e3f141dae0447e4, head: f135e16b99c518a08bd17a489e03c5fa8105c6c8 (round one started at 8cf11bf0; repairs are in f135e16b).
  • Covered:
    • S3File._upload_chunk(), _initiate_upload(), commit(), and discard();
    • S3FileSystem._finish_multipart_upload() and its caller _copy_object_with_multipart_upload();
    • AioS3File, which inherits all of them;
    • the tests that replace wait(), the new tests, and the docs.
  • Inputs: /code-review (high) and a /simplify pass (reuse, simplification, efficiency, altitude).
  • Result: FINDINGS, repaired or deferred as below.

Repairs in f135e16b:

  1. The part copies were checked only inside the max_workers wait branch, so an append that never filled max_workers (default cpu_count() * 5) still uploaded all its data after a failed copy. They are now checked before each part is submitted, and before the wait, so a copy that has already failed does not wait on an upload either (this line).
  2. S3File.commit() collected multipart_upload_parts in part-number order. A failed later part, such as one of the many part copies of a large append, waited for the parts before it while the queued ones started. It now waits with FIRST_EXCEPTION as _finish_multipart_upload() does.
  3. _finish_multipart_upload() rescanned all futures behind an if pending guard. It now iterates the futures returned as done, as the aio copy does.
  4. The done | copied union raised an arbitrary one of two failures. The two checks are now separate.
  5. Tests:
    • _make_write_file did not set _copies_in_flight;
    • test_finish_multipart_upload_waits_for_running_parts had been weakened to its last call, and now asserts both waits;
    • test_write_parts_in_flight/test_open_parts_in_flight now steer and count only FIRST_COMPLETED waits, because commit() also waits now.

Deferred or rejected:

  • A done callback per part, which would check without polling, was rejected. A callback bound to the file puts the file in a reference cycle, which delays fsspec's close-and-commit of a file dropped without close() (measured for Stop a multipart write at a part found failed while it waits #1176).
  • The set comprehension costs O(running copies) per part. The set empties as the copies finish, and a copy part is up to 5 GiB, so the set is small. Not changed.
  • Test assertions that overlap the neighbouring uploaded-part test were kept, for symmetry.

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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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).
Expand Down
155 changes: 149 additions & 6 deletions tests/pyathena/filesystem/test_s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import uuid
import weakref
from base64 import b64encode
from concurrent.futures import Future, ThreadPoolExecutor, wait
from concurrent.futures import FIRST_COMPLETED, FIRST_EXCEPTION, Future, ThreadPoolExecutor, wait
from datetime import UTC, datetime
from itertools import chain
from pathlib import Path
Expand Down Expand Up @@ -3532,9 +3532,11 @@ def upload_part():
release.wait(5)
events.append("part 2 stored")

def wait_parts(futures):
release.set()
return wait(futures)
def wait_parts(futures, **kwargs):
if not kwargs:
# The cleanup waits for the running parts.
release.set()
return wait(futures, **kwargs)

with (
ThreadPoolExecutor(max_workers=1) as executor,
Expand All @@ -3550,9 +3552,55 @@ def wait_parts(futures):
)

assert events == ["part 2 stored", "abort"]
waited.assert_called_once_with([failed, running])
assert waited.call_args_list == [
mock.call([failed, running, pending], return_when=FIRST_EXCEPTION),
mock.call([failed, running]),
]
assert pending.cancelled()

def test_finish_multipart_upload_stops_at_failed_part(self):
# GH-1178: a failed part is handled without waiting for the parts
# before it, so that the parts queued after it are cancelled. The
# parts used to be collected in part-number order.
fs = self._make_fs()
upload = S3MultipartUpload({"Bucket": "bucket", "Key": "key", "UploadId": "uploadid"})
fs.core.complete_multipart_upload = mock.MagicMock()
started = threading.Event()
release = threading.Event()
cleanup = []

def copy_part():
started.set()
# Copying until the cleanup waits for it.
release.wait(5)

def wait_parts(futures, **kwargs):
if not kwargs:
# Whether the earlier part was still copying when the cleanup
# started.
cleanup.append(running.done())
release.set()
return wait(futures, **kwargs)

failed: Future[SimpleNamespace] = Future()
failed.set_exception(PermissionError("Access Denied"))
with (
ThreadPoolExecutor(max_workers=1) as executor,
mock.patch("pyathena.filesystem.s3.wait", side_effect=wait_parts),
):
running = executor.submit(copy_part)
pending = executor.submit(SimpleNamespace)
assert started.wait(5)
with pytest.raises(PermissionError, match="Access Denied"):
fs._finish_multipart_upload(upload=upload, futures=[running, failed, pending])

assert cleanup == [False]
assert pending.cancelled()
fs.core.complete_multipart_upload.assert_not_called()
fs._call.assert_called_once_with(
fs._client.abort_multipart_upload, Bucket="bucket", Key="key", UploadId="uploadid"
)

def test_finish_multipart_upload_does_not_wait_for_cancelled_parts(self):
# GH-976: a cancelled part is not waited for, as nothing may
# acknowledge its cancellation, e.g., an event loop blocked by the
Expand Down Expand Up @@ -6668,6 +6716,7 @@ def _make_write_file(data: bytes, autocommit: bool):
# a part.
file.max_workers = S3Core.MULTIPART_UPLOAD_MAX_PARTS
file._uploads_in_flight = set()
file._copies_in_flight = set()
file._executor = mock.MagicMock()
return file

Expand Down Expand Up @@ -7027,6 +7076,9 @@ def record_upload(future, fn, *args, **kwargs):
executor.submit_to = record_upload # type: ignore[method-assign]

def release_newest(futures, **kwargs):
if kwargs.get("return_when") != FIRST_COMPLETED:
# The wait of commit() for the parts.
return wait(futures, **kwargs)
# Only the newest part in flight finishes, so a write that waits
# for the oldest one would time out.
gates[max(n for n, upload in uploads.items() if not upload.done())].set()
Expand All @@ -7050,7 +7102,9 @@ def release_newest(futures, **kwargs):
assert self._uploaded_object(fs, existing) == existing + b"".join(writes)
# Each part was submitted once fewer than max_workers were in flight.
assert in_flight == [min(n, max_workers) for n in range(1, len(uploads) + 1)]
assert waited.call_count == len(uploads) - max_workers
assert [c.kwargs.get("return_when") for c in waited.call_args_list].count(
FIRST_COMPLETED
) == len(uploads) - max_workers

def test_write_interrupted_while_waiting_for_part(self):
# GH-1158: an interrupt while a write waits for a part closes the file
Expand Down Expand Up @@ -7190,6 +7244,51 @@ def upload_part(**kwargs):
fs.core.complete_multipart_upload.assert_not_called()
assert f.multipart_upload is None

@pytest.mark.parametrize("max_workers", [1, 4])
def test_append_failed_part_copy(self, max_workers):
# GH-1178: a failed part copy of an append stops the write before it
# uploads another part, whether or not the write waits for a part.
# The part copies are not counted toward max_workers, and the write
# used to upload the rest of the data.
fs = self._make_append_fs(b"a" * 6)
release = threading.Event()

def upload_part_copy(**kwargs):
# Copying until the first write has returned. The test always
# sets the event, so the wait needs no timeout.
release.wait()
raise PermissionError("Access Denied")

fs.core.upload_part_copy.side_effect = upload_part_copy
executor = mock.MagicMock(wraps=S3ThreadPoolExecutor(max_workers=max_workers))
f = S3File(
fs,
"s3://bucket/key.txt",
mode="ab",
block_size=4,
max_workers=max_workers,
executor=executor,
)
try:
f.write(b"b" * 4)
release.set()
# The part copy has failed before the next write.
assert wait(f.multipart_upload_parts[:1], timeout=5).done
with pytest.raises(PermissionError, match="Access Denied"):
f.write(b"b" * 4)
finally:
release.set()
executor.shutdown()

assert [
c.kwargs["part_number"] for c in executor.submit_to.call_args_list if "body" in c.kwargs
] == [2]
fs._call.assert_called_once_with(
S3_CLIENT.abort_multipart_upload, Bucket="bucket", Key="key.txt", UploadId="uploadid"
)
fs.core.complete_multipart_upload.assert_not_called()
assert f.closed

def test_multipart_write_request_parameters(self):
# GH-946: the parts receive the parameters of the file that they
# accept, such as RequestPayer and SSE-C, as does the completion.
Expand Down Expand Up @@ -7424,6 +7523,50 @@ def test_commit_failure_and_discard(self, caplog, error, abort_fails):
assert file.multipart_upload is None
assert file.multipart_upload_parts == []

def test_commit_stops_at_failed_part(self):
# GH-1178: commit() handles a failed part without waiting for the
# parts before it, so that the parts queued after it, such as the
# part copies of a large append, are cancelled. The parts used to be
# collected in part-number order.
file = self._make_multipart_write_file(b"x" * 16, autocommit=False)
started = threading.Event()
release = threading.Event()
cleanup = []

def copy_part():
started.set()
# Copying until the cleanup waits for it.
release.wait(5)

def wait_parts(futures, **kwargs):
if not kwargs:
# Whether the earlier part was still copying when the cleanup
# started.
cleanup.append(running.done())
release.set()
return wait(futures, **kwargs)

failed: Future[SimpleNamespace] = Future()
failed.set_exception(PermissionError("Access Denied"))
with (
ThreadPoolExecutor(max_workers=1) as executor,
mock.patch("pyathena.filesystem.s3.wait", side_effect=wait_parts),
):
running = executor.submit(copy_part)
pending = executor.submit(SimpleNamespace)
assert started.wait(5)
file.multipart_upload_parts = [running, failed, pending]
with pytest.raises(PermissionError, match="Access Denied"):
file.commit()

assert cleanup == [False]
assert pending.cancelled()
file.fs.core.complete_multipart_upload.assert_not_called()
file.fs._call.assert_called_once_with(
S3_CLIENT.abort_multipart_upload, Bucket="bucket", Key="key.txt", UploadId="uploadid"
)
assert file.multipart_upload is None

def test_commit_failure_and_interrupted_abort(self):
# GH-945: if the abort after a failed completion is interrupted, the
# interrupt propagates and the upload is kept so that discard()
Expand Down
5 changes: 4 additions & 1 deletion tests/pyathena/filesystem/test_s3_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import urllib.parse
import urllib.request
import uuid
from concurrent.futures import wait
from concurrent.futures import FIRST_COMPLETED, wait
from datetime import UTC, datetime
from itertools import chain
from pathlib import Path
Expand Down Expand Up @@ -2405,6 +2405,9 @@ def record_upload(executor, future, fn, *args, **kwargs):
submit_to(executor, future, fn, *args, **kwargs)

def release_newest(futures, **kwargs):
if kwargs.get("return_when") != FIRST_COMPLETED:
# The wait of commit() for the parts.
return wait(futures, **kwargs)
# Only the newest part in flight finishes, so a write that waits
# for the oldest one would time out.
gates[max(n for n, upload in uploads.items() if not upload.done())].set()
Expand Down
Loading