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
75 changes: 52 additions & 23 deletions pyathena/filesystem/s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@
from io import BytesIO
from multiprocessing import cpu_count
from re import Pattern
from typing import Any, cast
from typing import Any, BinaryIO, cast
from urllib.parse import unquote_plus

import botocore.exceptions
from boto3 import Session
from botocore import UNSIGNED
from botocore.client import BaseClient, Config
from fsspec import AbstractFileSystem
from fsspec.callbacks import _DEFAULT_CALLBACK
from fsspec.callbacks import _DEFAULT_CALLBACK, Callback
from fsspec.implementations.local import trailing_sep
from fsspec.spec import AbstractBufferedFile
from fsspec.utils import isfilelike, other_paths, tokenize
Expand Down Expand Up @@ -1716,6 +1716,28 @@ def _write_and_close(f: S3File, value: bytes | bytearray | memoryview) -> None:
raise
f.close()

@staticmethod
def _write_file_and_close(f: S3File, local: BinaryIO, callback: Callback) -> None:
"""Write the rest of a local file to a file opened for writing and close it.

Unlike a ``with`` block, a failed read, write, or progress update
closes the file without committing it, so the existing object is
left unchanged.

Args:
f: The file to write to.
local: The local file to read from.
callback: Progress callback, updated with the size of each block.
"""
try:
while data := local.read(f.blocksize):
f.write(data)
callback.relative_update(len(data))
except BaseException:

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 1 (behavior and implementation): CLEAN

Base 16aef64d10dce92cbf8b8d957c178bef9bd494b6 (merge-base with master), head 5027eba79c4238e0541d7c48deef3de8df0dbf83.

Covered: _write_file_and_close(), put_file(), _finish_multipart_upload(), S3File.commit() (pyathena/filesystem/s3.py), AioS3FileSystem._put_file_in_transaction() (pyathena/filesystem/s3_async.py), and the changed tests.

Checked:

  • Failure paths: a failure in the read, write, or callback closes the file with _close_without_commit(); with autocommit=False the file stays in the transaction, and its later commit() sees buffer=None, no parts, and no multipart upload, so it sends nothing. A part-limit ValueError from _upload_chunk() reaches this handler after _upload_chunk() already called _close_without_commit(); the second call is a no-op apart from the executor shutdown, which is idempotent (same as pipe_file()'s _write_and_close() since Write pipe_file() data without committing a failed write聽#1003).
  • mode="create": FileExistsError from self.open() is raised inside with open(lpath), which only closes the local file; test_put_file_create_existing and the aio transaction test still pass.
  • Success path: unchanged sequence (read f.blocksize, write, callback, then close()); the transaction still defers the commit.
  • _finish_multipart_upload() on BaseException: also covers the multipart copy caller (_copy_object_with_multipart_upload), where aborting on an interrupt is equally correct. After an interrupt it waits for the parts that could not be cancelled before the abort; a second interrupt escapes that wait, and interpreter exit already joins the executor threads.
  • commit() widened to BaseException so a later discard() does not abort the already-aborted upload again.
  • Tests: each new case was confirmed to fail with the source changes reverted. The multipart test relies on a real write flushing exactly one part before the callback fails; the unreadable-file cases use a real chmod(0) (skipped as root; CI runs on Linux).

Limitations (pre-existing, not changed): an interrupt during close() before _finish_multipart_upload() is entered (e.g. while CreateMultipartUpload or the final part submission runs) can still leave an upload behind; this window also exists for pipe_file() and open() and is outside #1014.

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.

Rebased onto master de8cc52a (#1006, #1012, #1013, #1015 merged since the review base) as 49edeea3e292405f4c9aec69cfba2090d7c2bff0; pushed with --force-with-lease from d59f880b.

  • Conflict: only the fsspec import lines in pyathena/filesystem/s3.py; resolved to keep both (Callback from this PR, trailing_sep from master).
  • git range-diff 16aef64d..d59f880b de8cc52a..49edeea3: the first commit differs only in that import line; the second is identical.
  • Upstream changes checked against this PR's contracts: none touch put_file(), _write_and_close(), _finish_multipart_upload(), S3File.commit()/discard()/_close_without_commit(), or AioS3FileSystem._put_file_in_transaction(). The S3File hunk changes only append mode, and cp_file() now returns True.
  • Validation on 49edeea3: just lint passed; tests/pyathena/filesystem/ 530 passed against live S3. AWS CI is rerunning on this head.

f._close_without_commit()
raise
f.close()

def pipe_file(
self, path: str, value: bytes | bytearray | memoryview, mode: str = "overwrite", **kwargs
) -> None:
Expand Down Expand Up @@ -1795,10 +1817,11 @@ def _finish_multipart_upload(
) -> S3CompleteMultipartUpload:
"""Collect the uploaded parts and complete the multipart upload.

When any part or the completion fails, 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. The original error is then re-raised.
When any part or the completion fails, or the wait for them is
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. The original error
is then re-raised.

Args:
bucket: S3 bucket name.
Expand All @@ -1824,7 +1847,7 @@ def _finish_multipart_upload(
parts=parts,
**self._get_operation_kwargs("complete_multipart_upload", request_kwargs),
)
except Exception:
except BaseException:
# A part that is still uploading when the upload is aborted may
# be stored after the abort, so wait for the parts that could not
# be cancelled first.
Expand Down Expand Up @@ -1937,7 +1960,9 @@ def put_file(

Uploads a file from the local filesystem to an S3 location. Supports
automatic content type detection based on file extension and provides
progress callback functionality.
progress callback functionality. An upload that fails before it is
completed, including one of a local file that cannot be read, leaves
the existing object unchanged.

Args:
lpath: Local file path to upload.
Expand Down Expand Up @@ -1984,19 +2009,20 @@ def put_file(
if content_type is not None:
s3_additional_kwargs["ContentType"] = content_type

with (
self.open(
rpath,
"xb" if mode == "create" else "wb",
block_size=block_size,
max_workers=max_workers,
s3_additional_kwargs=s3_additional_kwargs,
) as remote,
open(lpath, "rb") as local,
):
while data := local.read(remote.blocksize):
remote.write(data)
callback.relative_update(len(data))
# The local file is opened first, so that an unreadable one fails
# before the remote file is opened.
with open(lpath, "rb") as local:

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 (claims, callers, operations): FINDINGS (PR text only, repaired)

Base 16aef64d10dce92cbf8b8d957c178bef9bd494b6, head 5027eba79c4238e0541d7c48deef3de8df0dbf83.

Claims checked:

  • "AbstractBufferedFile.__exit__() closes the file whatever the exception": fsspec 2026.9.0 spec.py:2337, __exit__ calls self.close() unconditionally.
  • "commit() clears its multipart state on BaseException so a later discard() does not abort it again": fsspec 2026.9.0 transaction.py:42-64, Transaction.complete() puts back a file whose commit() raised and then calls discard() on it, so that later discard() does happen.
  • 5 MiB of 11 MiB (WHY): matches the issue output, 5242880 of 11534336 bytes.
  • "All 11 new test cases fail with the source reverted": 6 + 2 + 1 sync cases and 2 aio cases, run locally on this head.
  • Docstrings (put_file(), _write_file_and_close(), _finish_multipart_upload(), commit()) and the inline comment here: consistent with the code. No user docs mention put_file() (searched docs/, README).

Finding (PR text, repaired): the behavior-change note said that a failure before the first block was flushed replaced the object with an empty one. A small file whose callback fails after its write had its written data uploaded, not an empty object. The note now says "the data written so far, or an empty object when nothing was written". It also states that the S3FileSystem transaction case is fixed, which test_put_file_failed_write[*-True] covers.

Callers: the put_file()/_put_file_in_transaction() signatures and returns are unchanged; fsspec put() reaches both. Operationally, a failed multipart put_file() now sends AbortMultipartUpload instead of CompleteMultipartUpload. An interrupt in _finish_multipart_upload() now waits for the parts that could not be cancelled and sends one abort request.

Evidence limits: live S3 run (494 passed) covers the success paths only; the failure and interrupt paths are offline tests with mocked requests.

self._write_file_and_close(
self.open(
rpath,
"xb" if mode == "create" else "wb",
block_size=block_size,
max_workers=max_workers,
s3_additional_kwargs=s3_additional_kwargs,
),
local,
callback,
)

self.invalidate_cache(rpath)

Expand Down Expand Up @@ -3160,7 +3186,8 @@ 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. Invalidates the cache of the path afterwards.
completion fails or is interrupted. Invalidates the cache of the path
afterwards.

Raises:
FileExistsError: If an object was created at the path after the
Expand Down Expand Up @@ -3197,7 +3224,9 @@ def commit(self) -> None:
)
except Exception:
# The multipart upload has been aborted by the helper;
# prevent discard() from aborting it again.
# prevent discard() from aborting it again. An interrupt may
# have stopped the helper before the abort, so the upload is
# kept for discard() then.
self.multipart_upload = None
self.multipart_upload_parts = []
raise
Expand Down
26 changes: 13 additions & 13 deletions pyathena/filesystem/s3_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,19 +264,19 @@ def _put_file_in_transaction(
if content_type is not None:
s3_additional_kwargs["ContentType"] = content_type

with (
self.open(
rpath,
"xb" if mode == "create" else "wb",
block_size=block_size,
max_workers=max_workers,
s3_additional_kwargs=s3_additional_kwargs,
) as remote,
open(lpath, "rb") as local,
):
while data := local.read(remote.blocksize):
remote.write(data)
callback.relative_update(len(data))
# See S3FileSystem.put_file.
with open(lpath, "rb") as local:

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): pre-existing limitation (reviewer and scope: see the comment on pyathena/filesystem/s3.py:3053)

s3_async.py:218 delegates to asyncio.to_thread; cancelling the awaiting coroutine does not stop an already-running upload, which can still commit. The new handlers cover exceptions inside the worker.

By source tracing, the new tests would detect the original empty/partial-commit defect through observable upload calls. The interrupt test covers an exceptional future, but not interrupted cleanup through S3File.commit().

Author: deferred as pre-existing and out of scope for #1014. Every asyncio.to_thread delegation in AioS3FileSystem behaves this way, not only put_file().

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.

No change. Recorded as pre-existing and outside #1014. The interrupted-cleanup-through-commit() gap is now covered by TestS3File.test_commit_failure_and_discard (d59f880).

self._sync_fs._write_file_and_close(
self.open(
rpath,
"xb" if mode == "create" else "wb",
block_size=block_size,
max_workers=max_workers,
s3_additional_kwargs=s3_additional_kwargs,
),
local,
callback,
)
self.invalidate_cache(rpath)

async def _get_file(self, rpath: str, lpath: str, callback=_DEFAULT_CALLBACK, **kwargs) -> None:
Expand Down
90 changes: 85 additions & 5 deletions tests/pyathena/filesystem/test_s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -1558,6 +1558,67 @@ def test_pipe_file_failed_write_aborts_multipart_upload(self):
)
executor.shutdown.assert_called_once()

@pytest.mark.parametrize("intrans", [False, True])
@pytest.mark.parametrize("error", [RuntimeError, KeyboardInterrupt, PermissionError])
def test_put_file_failed_write(self, tmp_path, intrans, error):
# GH-1014: a failure inside the write loop, or a local file that
# cannot be read, leaves the existing object unchanged. The remote
# file used to be committed when the failure left the with block,
# which replaced the object with the data written so far or with an
# empty one, also later in a transaction.
fs = self._make_fs()
fs.default_cache_type = "bytes"
fs._transaction = None
fs._put_object = mock.MagicMock()
lpath = tmp_path / "data"
lpath.write_bytes(b"a")
callback = Callback()
if error is PermissionError:
if os.geteuid() == 0:
pytest.skip("root can read a file without read permission.")
lpath.chmod(0)
else:
callback.relative_update = mock.MagicMock(side_effect=error("callback failed"))

with (
fs.transaction if intrans else contextlib.nullcontext(),
pytest.raises(error),
):
fs.put_file(str(lpath), "s3://bucket/key", callback=callback)

fs._put_object.assert_not_called()
fs._call.assert_not_called()

@pytest.mark.parametrize("intrans", [False, True])
def test_put_file_failed_write_aborts_multipart_upload(self, tmp_path, intrans):
# GH-1014: a failure after the first block was uploaded aborts the
# multipart upload instead of completing it with that block only.
fs = self._make_fs()
fs.default_cache_type = "bytes"
fs._transaction = None
fs._create_multipart_upload = mock.MagicMock(
return_value=SimpleNamespace(upload_id="uploadid")
)
fs._upload_part = mock.MagicMock(
side_effect=lambda **kw: SimpleNamespace(etag='"e"', part_number=kw["part_number"])
)
fs._finish_multipart_upload = mock.MagicMock()
callback = Callback()
callback.relative_update = mock.MagicMock(side_effect=RuntimeError("callback failed"))
lpath = tmp_path / "data"
lpath.write_bytes(b"a" * (2 * S3FileSystem.DEFAULT_BLOCK_SIZE + 1))

with (
fs.transaction if intrans else contextlib.nullcontext(),
pytest.raises(RuntimeError, match="callback failed"),
):
fs.put_file(str(lpath), "s3://bucket/key", callback=callback)

fs._finish_multipart_upload.assert_not_called()
fs._call.assert_called_once_with(
"abort_multipart_upload", Bucket="bucket", Key="key", UploadId="uploadid"
)

@pytest.mark.parametrize(
("size", "block_size", "min_block_size"),
[
Expand Down Expand Up @@ -1601,7 +1662,7 @@ def test_put_file_block_size(self, tmp_path):
# API.
fs = self._make_fs()
fs.open = mock.MagicMock()
fs.open.return_value.__enter__.return_value.blocksize = 8
fs.open.return_value.blocksize = 8
lpath = tmp_path / "data"
lpath.write_bytes(b"a" * 13)

Expand Down Expand Up @@ -1629,7 +1690,7 @@ def test_put_file_content_type(self, tmp_path, filesystem_kwargs, kwargs, expect
fs = self._make_fs()
fs.s3_additional_kwargs = filesystem_kwargs
fs.open = mock.MagicMock()
fs.open.return_value.__enter__.return_value.blocksize = 8
fs.open.return_value.blocksize = 8
lpath = tmp_path / "data.csv"
lpath.write_bytes(b"a")

Expand Down Expand Up @@ -2138,13 +2199,16 @@ def test_finish_multipart_upload(self):
)
fs._call.assert_not_called()

def test_finish_multipart_upload_aborts_on_failure(self):
# GH-1014: an interrupt while waiting for the parts used to leave the
# multipart upload behind.
@pytest.mark.parametrize("error", [RuntimeError, KeyboardInterrupt])
def test_finish_multipart_upload_aborts_on_failure(self, error):
fs = self._make_fs()
fs._complete_multipart_upload = mock.MagicMock()
future: Future[SimpleNamespace] = Future()
future.set_exception(RuntimeError("upload failed"))
future.set_exception(error("upload failed"))

with pytest.raises(RuntimeError, match="upload failed"):
with pytest.raises(error, match="upload failed"):
fs._finish_multipart_upload(
bucket="bucket", key="key", upload_id="uploadid", futures=[future]
)
Expand Down Expand Up @@ -4415,6 +4479,22 @@ def wait_parts(futures):
waited.assert_called_once_with([running])
assert pending.cancelled()

@pytest.mark.parametrize(("error", "aborts"), [(RuntimeError, 0), (KeyboardInterrupt, 1)])
def test_commit_failure_and_discard(self, error, aborts):
# GH-1014: an error from _finish_multipart_upload() follows its
# abort, so a later discard(), as a transaction calls after a failed
# commit(), does not abort the upload again. An interrupt may have
# stopped it before the abort, so the upload is kept for discard().
file = self._make_multipart_write_file(b"x" * 16, autocommit=False)
file._upload_chunk(final=True)
file.fs._finish_multipart_upload.side_effect = error("failed")

with pytest.raises(error):
file.commit()
file.discard()

assert file.fs._call.call_count == aborts

def test_discard_on_event_loop_thread(self):
# GH-976: the parts that have not started are cancelled and not
# waited for, so a rollback on the thread of the event loop that
Expand Down
35 changes: 33 additions & 2 deletions tests/pyathena/filesystem/test_s3_async.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import asyncio
import contextlib
import os
import tempfile
import threading
Expand Down Expand Up @@ -287,6 +288,36 @@ def test_transaction_pipe_file_write(self):
("k2", b"aaaa")
]

@pytest.mark.parametrize("error", [RuntimeError, PermissionError])
def test_transaction_put_file_failed_write(self, tmp_path, error):
# GH-1014: in a transaction, a failed write or a local file that
# cannot be read does not replace the object with the data written
# so far, or with an empty one, when the transaction commits.
fs = AioS3FileSystem(connection=mock.MagicMock(), skip_instance_cache=True)
put_object = fs._sync_fs._put_object = mock.MagicMock()
local = tmp_path / "local"
local.write_bytes(b"a")
failing = tmp_path / "failing"
failing.write_bytes(b"b")
if error is PermissionError:
if os.geteuid() == 0:
pytest.skip("root can read a file without read permission.")
failing.chmod(0)

with fs.transaction:
with (
mock.patch.object(AioS3File, "write", side_effect=RuntimeError("write failed"))
if error is RuntimeError
else contextlib.nullcontext(),
pytest.raises(error),
):
fs.put_file(str(failing), "s3://bucket/k1")
fs.put_file(str(local), "s3://bucket/k2")

assert [(c.kwargs["key"], c.kwargs["body"]) for c in put_object.call_args_list] == [
("k2", b"a")
]

@pytest.mark.parametrize("kwargs", [{"block_size": 4}, {}])
def test_transaction_pipe_put_file_exceeding_max_parts(self, tmp_path, kwargs):
# GH-953: in a transaction, as outside one, pipe_file() and put_file()
Expand All @@ -313,7 +344,7 @@ def test_transaction_put_file_block_size(self, tmp_path):
# open() instead of the S3 API, as outside one.
fs = AioS3FileSystem(connection=mock.MagicMock(), skip_instance_cache=True)
fs.open = mock.MagicMock()
fs.open.return_value.__enter__.return_value.blocksize = 8
fs.open.return_value.blocksize = 8
local = tmp_path / "local"
local.write_bytes(b"a" * 13)

Expand Down Expand Up @@ -462,7 +493,7 @@ def test_put_file_in_transaction_open_parameters(self, tmp_path, mode, open_mode
# GH-972: fsspec's mode argument selects the mode of the file.
fs = AioS3FileSystem(connection=mock.MagicMock(), skip_instance_cache=True)
fs.open = mock.MagicMock()
fs.open.return_value.__enter__.return_value.blocksize = 4
fs.open.return_value.blocksize = 4
lpath = tmp_path / "data.csv"
lpath.write_bytes(b"a")

Expand Down
Loading