Skip to content
Open
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
4 changes: 3 additions & 1 deletion docs/aio.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,9 @@ coroutines of their objects as tasks, `batch_size` of them at a time as in fsspe
`_mv()`, which runs all its copies at once. Cancelling one of them cancels those tasks
and waits for them before the cancellation is raised, also when the shutdown of the
event loop cancels them together with the coroutine.
A recursive `_put()` sends no request for the local directories. `_rm()` sends
Each download of `_get()` gets `max(1, max_workers // n)` range reads, as with
`S3FileSystem.get`, `n` being the number of files, or `batch_size` when it is positive and
smaller. A recursive `_put()` sends no request for the local directories. `_rm()` sends
`batch_size` DeleteObjects requests at a time, `max_workers` by default and with `-1`; its
`max_workers` argument is a deprecated alias of `batch_size`.

Expand Down
17 changes: 15 additions & 2 deletions docs/filesystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,7 @@ and the error is raised once they have ended. A running multipart upload or copy
no further part and is aborted instead of being completed, and a running download reads
and writes no further block. A request already sent, such as the PutObject of a small
object, runs to its end; a multipart copy that has been completed still copies its
annotations. With `cache_type="background"`, a block that fsspec's cache prefetches in
its own thread may still be read after the stop. The transfers that ended before the failure stay done, and their number is
annotations. The transfers that ended before the failure stay done, and their number is
not bounded by `batch_size`. `mv` deletes the sources only once every copy has
succeeded. `on_error` of `cat`, `cat_ranges`, and `cp` keeps its fsspec meaning; with
`on_error="omit"` or `"return"` of `cat`, `"return"` of `cat_ranges`, or `"ignore"` of
Expand All @@ -154,6 +153,20 @@ into it one after another, in order.
`rm` deletes up to 1,000 objects per DeleteObjects request, `batch_size` requests at a
time. Its `max_workers` argument is a deprecated alias of `batch_size`.

`get` and `get_file` download each object in waves of parallel range reads of the block
size: at most `max_workers` ranges at a time, and no more than the request pool runs at
once. The ranges of a wave are written in order before the next wave is read, so a
download holds at most one wave of data, `max_workers` × the block size, apart from a
range that a thread of the request pool has just read until its call returns. `cache_type`
and `cache_options` do not apply: no data cache reads the object. The range reads are
conditional on the ETag, and the version, if any, of the object looked up when the
download starts: an object replaced during the download fails it with `OSError`, unless
the download reads a version other than `null`, which a replacement does not change. A
failed download leaves the part written so far in the local file, as fsspec's
`get_file` does. With `compression`, the object is decompressed into the destination,
read in order through a block cache, which reads ahead of the decompressor; a block size

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 1, finding 2. P3 — The compression cache is incorrectly documented as one block (reviewed at filesystem.md:146, s3.py:2912). BytesCache reads ahead beyond the requested bytes, and gzip requests 128 KiB of compressed input even with block_size=1024.

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.

Verified and fixed in 7065102: the docs and the docstring say the object is read in order through a block cache, which reads ahead of the decompressor. They no longer say one block at a time.

that is not positive takes the filesystem's.

Writes with `pipe`/`pipe_file` issue a single PutObject request for data up to the
block size (5 MiB by default); larger data is uploaded as a parallel multipart upload
through the buffered file path. Inside an
Expand Down
127 changes: 121 additions & 6 deletions pyathena/filesystem/s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -3147,8 +3147,20 @@ def get_file(
):
"""Download an S3 file to local filesystem.

Downloads a file from S3 to the local filesystem with progress tracking.
Reads the file in chunks to handle large files efficiently.
The object is read in waves of parallel range requests of the block
size, at most ``max_workers`` of them at once, but no more than the
request pool of the filesystem runs, and the bodies of each wave are
written in order before the next wave is read, so that the download
holds at most one wave of bodies; a thread of the pool that has just

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 1, finding 3. P3 — The weaker guarantee of finding 4 is not stated consistently (reviewed at s3.py:2860, filesystem.md:139). get_file() still says at most one wave "is held", and the guide omits the body that a returning pool thread keeps.

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.

Verified and fixed in 7065102: the get_file() docstring and docs/filesystem.md now say that the download holds at most one wave, apart from a range that a thread of the request pool has just read, until its call returns. This matches S3File._download().

read a range drops its body when its call returns (see
:meth:`S3File._download`). The reads are
conditional on the version and the ETag of the object looked up when
the download starts, as with ``open()``, so that an object replaced
during the download fails it instead of mixing two objects, unless
the download reads a version other than ``null``, which a
replacement does not change. A failed
download leaves the part written so far in a local file ``lpath``,
as fsspec's ``AbstractFileSystem.get_file()`` does.

As with fsspec's ``AbstractFileSystem.get_file()``, a directory
``rpath`` creates the local directory ``lpath``, and the parent
Expand All @@ -3158,9 +3170,18 @@ def get_file(
rpath: S3 source path (s3://bucket/key).
lpath: Local destination path, or a file-like object to write to.
Not needed when ``outfile`` is given.
callback: Progress callback for tracking download progress.
callback: Progress callback for tracking download progress, with
the size of the object.
outfile: A file-like object to write to instead of ``lpath``.
**kwargs: Additional S3 parameters passed to open().
**kwargs: The arguments of ``open()``, such as ``block_size``,
``max_workers``, ``version_id``, and ``s3_additional_kwargs``,
and additional S3 parameters. ``cache_type`` and
``cache_options`` are not used. With ``compression``, the
object is decompressed into the destination, read in order
through ``open()`` with a block cache, whose block size, if
not positive, is that of the filesystem, or
``DEFAULT_BLOCK_SIZE`` if that is not positive either, and the
size of the callback is unknown.
"""
path_version_id = S3Path.parse(self._strip_protocol(rpath)).version_id
if outfile is None and isfilelike(lpath):
Expand All @@ -3176,17 +3197,44 @@ def get_file(
os.makedirs(lpath, exist_ok=True)
return

# The codec, if any, as fsspec's open() resolves it, with "infer"
# from the key without the version.
codec = get_compression(
S3Path.parse(self._strip_protocol(rpath)).key or "", kwargs.pop("compression", None)
)
# Not the caller's cache: one such as "all" would read the whole
# object when the file is opened, and "background" reads outside
# the stop scopes of the caller. Without a codec, the object is read
# in ranges without a cache; with one, the decompressor's small reads
# go through a block cache, which needs a positive block size.
kwargs.pop("cache_options", None)
kwargs["cache_type"] = "none" if codec is None else "bytes"

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 3. P2 — Compression bypasses the promised no-cache behavior (reviewed at s3.py:2908). compression="gzip", cache_type="all" constructs AllBytes, fetching and retaining the entire compressed object before writing; cache_options also remains active, and "background" can issue reads outside the enclosing stop context. This contradicts the docs.

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.

Verified and fixed in 3a5ea80: get_file() drops the caller's cache_options and sets cache_type on both paths: "none" for the ranged download, and "bytes" for the decompressing path, whose small reads need a block cache. So neither "all" nor "background" applies. test_get_file_compression_ignores_cache_type checks that no request reads the whole compressed object when cache_type="all" is passed, and is killed when the override is limited to the uncompressed path. The docs state it.

if codec is not None and not (
isinstance(kwargs.get("block_size"), int) and kwargs["block_size"] > 0
):
kwargs["block_size"] = (
self.default_block_size if self.default_block_size > 0 else self.DEFAULT_BLOCK_SIZE

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 2 (relayed): Codex CLI codex exec -s read-only (model gpt-6-astra, session 01a1d094-f808-7e70-9256-f60cfbeac7b1), static review only, repair 3a5ea80a86ea4aba5383934790a632f809da3768..706510292721e2312d15716e521b0586ce605f33 (same base d03e034f947b9ab6ad5696ec2bd942f8a63e7b5d), detached snapshot unchanged.

Covered: the repair diff, block-size propagation through sync/batch/async downloads, fsspec caches, wave-body lifetime, interrupt/cancellation cleanup, documentation, the changed tests. The read-ahead and returning-thread documentation repairs are correct; the new compressed cases fail without the normalization, and the one-worker cache test detects keeping "all"; no assertion depends on thread scheduling. Result: FINDINGS (1).

1. P2 — The fallback block size can still be negative (reviewed at s3.py:2921). S3FileSystem(default_block_size=-1) is accepted, so get_file(..., compression="gzip", block_size=0, max_workers=1) replaces 0 with -1 and still raises ZeroDivisionError in BytesCache; with several workers, _get_ranges() keeps generating decreasing offsets.

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.

Verified and fixed in 7c9bc98: the fallback is the filesystem's default_block_size when it is positive, and DEFAULT_BLOCK_SIZE otherwise. test_get_file_non_positive_block_size sets default_block_size = -1 and is killed when the fallback is the raw default. Validating the constructor argument itself is outside this PR.

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 3 (relayed): Codex CLI codex exec -s read-only (model gpt-6-astra, session 01a1d09a-2ff1-7550-92eb-abdffdb87087), static review only, repair 706510292721e2312d15716e521b0586ce605f33..7c9bc988867daa687c91850d3c3244ea0d8694cf (same base d03e034f947b9ab6ad5696ec2bd942f8a63e7b5d), detached snapshot unchanged.

Covered: the repair diff; constructor defaults; compressed get_file() → open() → fsspec BytesCache; uncompressed downloads; the changed test and its fixture isolation.

CLEAN. The repair falls back to the positive 5 MiB constant; positive explicit sizes, positive filesystem defaults, and uncompressed behavior are unchanged. Both compressed parameterizations would fail without the repair (propagating -1 divides by zero in fsspec's cache trimming), and the test is deterministic: a fresh mocked filesystem, fixed content, one worker, no timing-sensitive assertion.

)
# The remote file is opened first so that no local file is created
# when open() finds no object at the path.
with contextlib.ExitStack() as stack:
remote = stack.enter_context(self.open(rpath, "rb", **kwargs))
reader: Any = None
if codec is not None:
# As fsspec's open() wraps the file, but closed with it.
reader = stack.enter_context(compr[codec](remote, mode="r"))
if outfile is None:
# Not abspath(), which would resolve ".." before symlinks.
if parent := os.path.dirname(lpath):
os.makedirs(parent, exist_ok=True)
outfile = stack.enter_context(open(lpath, "wb"))
callback.set_size(remote.size)
while data := remote.read(remote.blocksize):
if reader is None:
callback.set_size(remote.size)
remote._download(outfile, callback)
return
# The size of the decompressed data is unknown.
callback.set_size(None)
while data := reader.read(remote.blocksize):
# A block read when the stop came is not written.
raise_if_stopped(f"write the contents of {rpath}")
outfile.write(data)
Expand Down Expand Up @@ -4803,6 +4851,73 @@ def _get_range(self, path: S3Path, range_: tuple[int, int], **kwargs: Any) -> by
raise_if_stopped(f"read {path.uri}")
return self.fs.core.get_object(path, range_, **kwargs)

def _download(self, outfile: Any, callback: Callback) -> None:

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 (implementation behavior): base d03e034f947b9ab6ad5696ec2bd942f8a63e7b5d, head 0f5f21e72ea385e8d995c773ceebb42a8056946f (initial pass at 25d09949).

Covered: S3File._download() (wave partition for every max_workers including 1, the pool cap, the range read through _get_range() and its stop check, the write-time stop check, the per-body drop, the try scope over the assignment of a wave, S3MapResult.release() with chained errors and cycles); get_file() (the directory and missing-object paths, unchanged; cache_type="none" and the dropped cache_options; the codec resolution and the decompressing path; the order of opening the remote and the local file); callers: synchronous get() (task threads, _batch_kwargs() window, outfile in the calling thread), AioS3FileSystem._get_file() (a thread of the filesystem waits on the request pool, T1), and AioS3FileSystem._get() (pairing through _copy_pairs(), check_contained, the window, fsspec popping batch_size); the new and rewritten tests and their mutants.

FINDINGS, all repaired:

  1. The codec was resolved from remote.path, which carries a ?versionId= suffix, so compression="infer" found no codec for a versioned .gz key. It is now resolved from the key, before the file is opened, so that "infer" without a codec takes the parallel path (25d0994, before the PR); test_get_file_compression covers the three cases.
  2. The docstring of get_file() said that a replacement always fails the download; a download of a version other than null is unaffected. Corrected (25d0994).
  3. A bucket path with compression="infer" reached infer_compression(None) and raised AttributeError; the key defaults to "" (0f5f21e).

Not changed: an empty pairing in AioS3FileSystem._get() returns without callback.set_size(0), which fsspec's _get() called; a lpath that is a single file-like object fails in the pairing, as it failed in fsspec's _get() before.

"""Write the whole object into a writable file with parallel range reads.

The object is read in waves of ranges of the block size, or in one
range if the block size is not positive, one wave of at most
``max_workers`` ranges at a time, but no more than the request pool
of the filesystem runs at once, since the others would only wait
with their bodies held. Each wave is read with :meth:`S3Executor.map()
<pyathena.filesystem.s3_executor.S3Executor.map>`, and its bodies are
written in order, each dropped once written, before the next wave is
read, so that the download holds at most one wave of bodies; a thread
of the pool that has just returned a body drops it when its call

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 4. P2 — Completed workers can retain bodies across waves (reviewed at s3.py:4592). Clearing bodies[i] does not clear _S3WorkItem.run()'s local result; its future becomes done before that worker frame returns. A worker paused after publishing its result can still hold the body while the download writes that wave and reads the next, exceeding the strict one-wave bound and making test_get_file_holds_one_wave scheduling-dependent.

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.

Verified; documented rather than changed. A pool thread holds the value it returned until _S3WorkItem.run() returns, after the future is set. In CPython the reference is on the worker's frame or stack while set_result() wakes the waiter, so no reordering inside run() can close the window. The bound is therefore stated for what the download holds: S3File._download() says that a pool thread that has just returned a body drops it when its call returns. test_get_file_holds_one_wave now waits, with a 2 s bound, for the first wave's bodies to die when the second wave is read, instead of requiring it at once. It still fails when the download keeps its bodies (killed by the no-drop mutant) and no longer depends on the scheduling. Five repeated runs passed.

returns. The reads carry the version and the conditions of the file,
and each checks the stop scopes when it starts; a body read when a
stop came is not written.

A failure or an interrupt while a wave is read is raised as
:meth:`S3Executor.map()` raises it. One while its bodies are written,
including a failure of the write or of the callback, drops the bodies
first, also from the completed frames of its traceback (see
:meth:`S3MapResult.release()
<pyathena.filesystem.s3_executor.S3MapResult.release>`).

Args:
outfile: The writable file-like object.
callback: Progress callback, updated with the size of each body
once it is written.

Raises:
concurrent.futures.CancelledError: If an enclosing stop scope has
been stopped.
"""
size = cast(int, self.size)
# A block size that is not positive reads the rest of the object,
# as read() does.
block = self.blocksize if self.blocksize > 0 else max(size, 1)

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 CLI codex exec -s read-only (model gpt-6-astra, session 01a1d07a-27e5-7b92-a922-57e29bec2157), static review only, base d03e034f947b9ab6ad5696ec2bd942f8a63e7b5d, head 0f5f21e72ea385e8d995c773ceebb42a8056946f, detached snapshot unchanged. Prompt: the diff, the agreed design's contract, repository conventions and docs/testing.md; no PR text, commits, or prior findings.

Covered: the exact diff; range partitioning, waves, worker caps, ordered writes, stop scopes, map draining, F5 retention; open() arguments, versions/IfMatch, compression inference, closing, callbacks, partial files; aio pairing, allocation and cancellation against fsspec 2026.9.0; callers in pyathena/ and tests/, docs, docstrings, and the ten added or rewritten tests (each judged sensitive to its change; determinism issues in findings 4 and 5). Result: FINDINGS (5).

1. P2 — Negative block size silently produces an empty download (reviewed at s3.py:4574). For a nonempty object, get_file(..., block_size=-1, max_workers=1, cache_type="none") opens/truncates the destination, then range(0, size, -1) performs no iterations and returns successfully. The previous read loop downloaded the object for these arguments.

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.

Verified and fixed in 3a5ea80: a block size that is not positive reads the object in one range, as read() reads the rest; with 0, range() raised ValueError. test_get_file_non_positive_block_size[0, -1] checks the contents and the single range; killed without the fix.

window = min(self.max_workers, self.fs._request_pool.max_workers)
path = S3Path(self.bucket, self.key, self.version_id)
read = functools.partial(self._get_range, path, **self._get_request_kwargs("get_object"))
wave = window * block
result: S3MapResult[bytes] | None = None
try:
for wave_start in range(0, size, wave):
wave_end = min(size, wave_start + wave)
# Assigned within the try block, so that an interrupt right
# after the wave was read still drops its bodies.
result = self._executor.map(
read,
[
(start, min(start + block, wave_end))
for start in range(wave_start, wave_end, block)
],
)
bodies = result.outcomes
for i in range(len(bodies)):
# A body read when the stop came is not written.
raise_if_stopped(f"write the contents of {self.path}")
length = len(cast(bytes, bodies[i]))
outfile.write(bodies[i])
# Not kept while the next one is written or read.
bodies[i] = b""
callback.relative_update(length)
except BaseException as e:
if result is not None:
result.release(e)
raise

@staticmethod
def _get_ranges(
start: int, end: int, max_workers: int, worker_block_size: int
Expand Down
58 changes: 39 additions & 19 deletions pyathena/filesystem/s3_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -861,9 +861,13 @@ async def _get(
) -> None:
"""Copy files from S3 to the local filesystem.

See :meth:`S3FileSystem.get`. The downloads run as in fsspec's
``_get()``, and a cancellation cancels them and waits for them, as
in :meth:`_copy`.
See :meth:`S3FileSystem.get`. The paths are paired as there, and the
downloads run as in fsspec's ``_get()``; a cancellation cancels them
and waits for them, as in :meth:`_copy`. As in
:meth:`S3FileSystem.get`, each download gets ``max(1, max_workers //
n)`` parallel range reads, or the caller's smaller ``max_workers``,
where ``n`` is the number of files, or ``batch_size`` when it is
positive and smaller.

Args:
rpath: Source S3 path, glob pattern, or list of them.
Expand All @@ -872,28 +876,44 @@ async def _get(
recursive: Whether to copy the directories with their contents.
callback: Progress callback.
maxdepth: Maximum depth of a recursive copy.
**kwargs: Additional parameters passed to ``_get_file()``.
**kwargs: Additional parameters passed to ``_get_file()``, and
``batch_size``, the number of downloads that fsspec runs at
once.

Raises:
ValueError: If a source with a version ID is paired, and a
destination lies outside ``lpath``.
ValueError: If a destination named after a source lies outside
``lpath``.
"""
rpath = self._sync_fs._stringify_paths(rpath)

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.

Rebase onto 758adee9eb1b12d5bd939611f56e0ff5e433a743 (#1394, #1378): the patch series is unchanged except for one conflict resolution in AioS3FileSystem._get(), which keeps upstream's rpath = self._sync_fs._stringify_paths(rpath) before the new pairing (git range-diff d03e034f..7c9bc988 758adee9..9ff12b21). Force-pushed with --force-with-lease against 7c9bc988.

Independent rebase review (relayed): Codex CLI codex exec -s read-only (model gpt-6-astra, session 01a1d0aa-2b6f-7012-a9bc-3e5b32221a75), static review only, on the range-diff and the upstream diff of s3.py, s3_async.py, and s3_executor.py (d03e034f..758adee9), snapshot at 9ff12b211ee498fa05d8f241f5f47800fede4fcc, unchanged.

Covered: the conflict resolution; path-like sources and destinations through get_file(), get(), aio _get_file() and _get() (scalar/list conversion, versioned pairing, absolute destination-list entries); client=, retry_config=, deprecated connection=, and s3_filesystem(), with the request pool sized by max_workers or the injected client's max_pool_connections, which the waves and the batch windows use; the waves, writes, body release, cache exclusion, and the compression path; the upstream tests of path-like get() and of the client and pool size, and the conformance fixtures switched to s3_filesystem().

CLEAN. The conflict resolution is correct, and no rebase regression was found.

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.

The local AWS run at 9ff12b2 (1747 passed, 8 failed, 6 skipped) found what the static rebase review missed. #1394 moved the _serve_keys helper of TestAioS3FileSystem to tests.pyathena.util.serve_s3_keys(). Its existing callers were converted upstream, but the two tests added here (test_get_window, test_get_relative_destination_list) still called the removed method. They are fixed in 05eb56b (test-only), and tests/pyathena/filesystem/test_s3_async.py passes in full (223 passed, 3 skipped). The other 1747 tests passed at 9ff12b2, with just benchmark test (340 passed).

sources = [rpath] if isinstance(rpath, str) else rpath
if isinstance(lpath, (str, os.PathLike)) and any(S3Path.has_version_id(p) for p in sources):
root = make_path_posix(lpath)
pairs = await self._thread_pool.run(
self._sync_fs._copy_pairs,
S3PathPairing(rpath, root, recursive=recursive, maxdepth=maxdepth),
isdir=LocalFileSystem().isdir,
)
rpath, lpath = [p1 for p1, _ in pairs], [p2 for _, p2 in pairs]
check_contained(root, lpath)
if not rpath:
return
# Local paths are made absolute, as fsspec's _get() does, so that
# it can create their parent directories.
root = (
make_path_posix(lpath)
if isinstance(lpath, (str, os.PathLike))
else [make_path_posix(p) if isinstance(p, (str, os.PathLike)) else p for p in lpath]

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 2. P2 — Aio pairing loses destination-list normalization (reviewed at s3_async.py:821). _get("s3://bucket/key", ["download.txt"]) now passes two lists to fsspec, which bypasses its make_path_posix() step, so its os.makedirs(os.path.dirname("download.txt")) raises FileNotFoundError. Previously this input used an absolute destination and succeeded.

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.

Verified and fixed in 3a5ea80: _get() makes the str and path-like entries of a destination list absolute with make_path_posix, as fsspec's _get() does for one source. File-like entries are left as given. test_get_relative_destination_list covers a scalar source and a list source, each with a relative destination, and is killed without the normalization. The list-to-list case also failed before this PR, in fsspec's makedirs("").

)
pairs = await self._thread_pool.run(
self._sync_fs._copy_pairs,
S3PathPairing(rpath, root, recursive=recursive, maxdepth=maxdepth),
isdir=LocalFileSystem().isdir,
)
if isinstance(root, str):
check_contained(root, [lp for _, lp in pairs])
if not pairs:
return
# fsspec's default batch size, when none is given, is not counted:
# at most max_workers downloads run at once in the threads of the
# filesystem, and more than max_workers get one range each anyway.
concurrency = len(pairs)

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, compatibility, operations): base d03e034f947b9ab6ad5696ec2bd942f8a63e7b5d, head 0f5f21e72ea385e8d995c773ceebb42a8056946f.

Claims checked:

  • "At most one wave of bodies is held": bodies[i] = b"" after each write, and the list is replaced only by the next map(); test_get_file_holds_one_wave sees no body of the first wave alive when the second is read (killed without the drop).
  • "No data cache": cache_type="none" makes fsspec construct BaseCache, which reads nothing; test_get_file_constructs_no_data_cache with cache_type="all" sees each range once.
  • "The version, IfMatch, and parameters of open()": get_file() still opens through open(), so S3FileTarget.resolve() and the precedence of _open()/S3File.__init__() apply unchanged; test_get_file_request_parameters.
  • "compression raised AttributeError": remote.size on fsspec's decompressor (GzipFile) at d03e034.
  • Docs: the removed cache_type="background" note concerned the block prefetched by the cache of a download, which no longer exists; the other statements match the code. The AioS3FileSystem._get() window omits the thread term and fsspec's default batch size, as the PR body explains.

Existing callers: get_file() keeps its signature; cache_type/cache_options are now ignored instead of applied; AioS3FileSystem._get() now pairs non-version paths with S3PathPairing, the pairing of S3FileSystem.get(), which the fsspec conformance tests of get cover for both filesystems (AWS, passed).

Operator: the number of GetObject requests per object stays one per block (the old first read requested two blocks); up to window of them now run at once per download, within the request pool. Memory per download is window × block_size (50 MiB with the defaults); a synchronous or aio batch gives each download max(1, pool // n). Throughput is not measured here; that is step 5.

Evidence: local AWS filesystem suite and just benchmark test (rerun at the reviewed head pending); mutants per change. FINDINGS: the PR body stated a tested commit whose full run predated the last codec change; corrected in the body.

batch_size = kwargs.get("batch_size", self.batch_size)
if batch_size is not None and batch_size > 0:
concurrency = min(concurrency, batch_size)
await _S3BatchTasks().run(
super()._get(
rpath, lpath, recursive=recursive, callback=callback, maxdepth=maxdepth, **kwargs
[rp for rp, _ in pairs],
[lp for _, lp in pairs],
callback=callback,
**self._sync_fs._batch_kwargs(concurrency, kwargs),
)
)

Expand Down
24 changes: 24 additions & 0 deletions pyathena/filesystem/s3_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,30 @@ class S3MapResult(Generic[T]):
outcomes: list[T | BaseException]
failure: BaseException | None

def release(self, error: BaseException) -> None:
"""Drop the outcomes when the processing of the result fails, such as the bodies of reads.

The outcomes are cleared, and so are the completed frames of the
traceback of the error and of the errors chained to it
(``__cause__`` and ``__context__``), which can keep an outcome, such
as the argument of a write that wrapped its own failure;
:func:`traceback.clear_frames` skips the frames that are executing.
The caller drops its own references to the outcomes.

Args:
error: The error that the processing raises.
"""
self.outcomes.clear()
seen: set[int] = set()
errors: list[BaseException] = [error]
while errors:
current = errors.pop()
if id(current) in seen:
continue
seen.add(id(current))
traceback.clear_frames(current.__traceback__)
errors.extend(e for e in (current.__cause__, current.__context__) if e is not None)


class _S3Batch(Generic[ItemT, T]):
"""The calls of one map of :class:`S3Executor`, and the state that they share.
Expand Down
Loading
Loading