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
8 changes: 7 additions & 1 deletion docs/aio.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,8 @@ multipart uploads.
`S3FileSystem` and `S3File` use a pluggable executor abstraction (`S3Executor`) for
parallel operations. Two implementations are provided:

- `S3ThreadPoolExecutor` — wraps `ThreadPoolExecutor` (default for sync usage)
- `S3ThreadPoolExecutor` — runs the calls in a pool of `max_workers` threads (default
for sync usage); in a forked child process, the pool creates new threads
- `S3AioExecutor` — dispatches work via `asyncio.run_coroutine_threadsafe` to an
`S3AioThreadPool` of its own

Expand All @@ -343,6 +344,11 @@ so multipart uploads and parallel range reads are dispatched through the event l
a pool of `max_workers` threads of each file.
An instance created with `asynchronous=True` has no event loop of its own, so its file handles use `S3ThreadPoolExecutor`.

In a forked child process, the event loop of the parent does not run.
An `AioS3FileSystem` created in the parent without `asynchronous=True` raises

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: FINDING, repaired in f192c0f. The docs and the Raises section of _create_executor() said that an AioS3FileSystem created in the parent raises from open() in a child. An instance created with asynchronous=True has _loop None, gets an S3ThreadPoolExecutor, and works after the fork (now fork-safe). Both now name only instances with an event loop of their own.

`RuntimeError` from `open()` there, as fsspec's synchronous methods do, and so does
`S3AioExecutor.submit()`; a filesystem created in the child works.

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), finding 2 (P2, pre-existing, dependency): in fsspec 2026.9.0, fsspec.asyn.reset_after_fork() sets a nonexistent global lock instead of _lock, so a child whose parent forked while another thread held _lock during the first get_loop() deadlocks when it constructs a default AioS3FileSystem.

Verified in the installed fsspec 2026.9.0 (asyn.py:256-260 vs get_lock() at asyn.py:30-38). Already fixed upstream in fsspec/filesystem_spec@cca6715b8e (#2242, 2026-10-07: reset_after_fork() calls reset_lock()), which is not in a release yet (latest tag 2026.9.0). Deferred: no workaround here; the fix arrives with the next fsspec release. The race needs a fork during the first creation of fsspec's IO loop in another thread.


### Async file handles

`open_async()` returns an `AioS3StreamedFile`, whose `read()`, `write()`, and `close()`
Expand Down
7 changes: 6 additions & 1 deletion pyathena/filesystem/s3_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -848,10 +848,15 @@ def _create_executor(self, max_workers: int) -> S3Executor:
Returns:
An ``S3AioExecutor`` on the event loop of this filesystem, or an
``S3ThreadPoolExecutor`` if it has none.

Raises:
RuntimeError: If this instance has an event loop and the call is
made in a forked child process of the process that created
it, where the loop does not run.
"""
if self._loop is None:
return S3ThreadPoolExecutor(max_workers=max_workers)
return S3AioExecutor(loop=self._loop, max_workers=max_workers)
return S3AioExecutor(loop=self.loop, max_workers=max_workers)

def _open(
self,
Expand Down
48 changes: 40 additions & 8 deletions pyathena/filesystem/s3_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,28 +178,32 @@ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:


class S3ThreadPoolExecutor(S3Executor):
"""Executor that delegates to a ``ThreadPoolExecutor``.
"""Executor that runs the submitted calls in a pool of threads.

This is the default executor used by ``S3File`` and ``S3FileSystem``
for synchronous parallel operations.
for synchronous parallel operations. The calls run in an
:class:`S3AioThreadPool`, so that a forked child process, whose copy of
the executor has no threads, creates new ones.
"""

def __init__(self, max_workers: int) -> None:
"""Initialize the executor with a new ``ThreadPoolExecutor``.
"""Initialize the executor without starting any thread.

Args:
max_workers: The maximum number of threads of the thread pool.

Raises:
ValueError: If ``max_workers`` is not positive.
"""
self._executor = ThreadPoolExecutor(max_workers=max_workers)
self._pool = S3AioThreadPool(max_workers)

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: deferrals and skipped suggestions.

  • Deferred to step 2 of Consolidate the filesystem executors before parallel batch operations #1332 (files inherited across a fork are rejected): the child keeps each pool's copied ThreadPoolExecutor in S3AioThreadPool._forked_executors until it exits, now also for the executors of sync files open at the fork, with any queued part bodies; collecting it could take a queue lock held by a parent thread. Calls in flight at the fork still leave their futures unresolved; failing them in the hook could deadlock the fork on a future's lock. Both are stated in the PR body.
  • Skipped: subclassing or aliasing S3AioThreadPool instead of delegating (it would expose run()/release(), and step 2 merges the types); the per-call bookkeeping of S3AioThreadPool (about 1-3 µs per call against 10-100 ms requests); removing the S3ThreadPoolExecutor cases of the parametrized TestS3Executor tests, which protect that class's public context and argument-freeing contract independent of its implementation.

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, evidence): CLEAN after corrections to the PR body; no code change.

Scope: git diff 4a0cadb2eabc0b0831b0e0f5c01932661495ebd8..f192c0fedfa38b37be0ef3c56b2278d084b78922, the PR body, docstrings, comments, docs/aio.md, and both commit messages.

Claims checked:

  • A copied ThreadPoolExecutor starts no thread in the child: _adjust_thread_count returns once _idle_semaphore.acquire(timeout=0) succeeds, and the stdlib's at-fork hooks only reset _global_shutdown_lock and clear _threads_queues (CPython 3.12 and 3.13 sources; 3.11 and 3.14 are not installed locally, and CI covers them).
  • The interface and FIFO order of S3ThreadPoolExecutor are unchanged: the constructor still takes max_workers, a non-positive value still raises ValueError, and the calls still go through one ThreadPoolExecutor queue. Only the private _executor attribute is gone; no caller in pyathena/, tests/, or benchmarks/ uses it.
  • fsspec's loop raises RuntimeError("This class is not fork-safe") after a fork, and _Cached.__call__ clears the instance cache when the PID changes, so a filesystem created in the child is new (fsspec of the lock file; the property predates the fsspec>=2026.9.0 floor).
  • Closing an AioS3File that crossed the fork hung: S3File.close() calls self._executor.shutdown() with wait=True (s3.py:2983), the hang reproduced at the merge base.
  • asynchronous=True: verified at f192c0f in a child (new and pre-fork S3ThreadPoolExecutor both run calls).
  • The docs outside the diff (benchmarks/README.md, the benchmark history) describe S3ThreadPoolExecutor only as where sync files run parallel requests, which still holds.

Corrections: the PR body's TEST now separates the runs at each commit, and records an unexplained failure of TestS3FileSystemCopy::test_copy_directory_without_files_with_same_name_prefix in one full run at f192c0f, whose output was not kept. That test does not fork, and it passed three times alone and in a full rerun (1423 passed, 6 skipped). The refactored S3AioThreadPool fork test was checked to still fail without the pool's lock reset.

No AWS operator effect: no request, retry, or quota path changes.


@override
def submit(self, fn: Callable[..., T], *args: Any, **kwargs: Any) -> Future[T]:
context = contextvars.copy_context()
return self._executor.submit(context.run, fn, *args, **kwargs)
return self._pool.submit(fn, *args, **kwargs)

@override
def shutdown(self, wait: bool = True) -> None:
self._executor.shutdown(wait=wait)
self._pool.shutdown(wait=wait)


class S3AioThreadPool(S3Executor):
Expand Down Expand Up @@ -498,9 +502,15 @@ class S3AioExecutor(S3Executor):
max_workers: The maximum number of submitted functions that run at once.

Raises:
RuntimeError: If the event loop is not running when ``submit`` is called.
RuntimeError: If the event loop is not running when ``submit`` is
called, or ``submit`` is called in a forked child process, where
the event loop of the parent does not run.
"""

# The executors of this process, which a forked child detaches from the
# event loop of its parent.
_instances: ClassVar[weakref.WeakSet[S3AioExecutor]] = weakref.WeakSet()

def __init__(
self,
loop: asyncio.AbstractEventLoop | None = None,
Expand All @@ -522,9 +532,13 @@ def __init__(
# Orders the dispatches with the shutdown.
self._lock = threading.Lock()
self._shutdown = False
self._forked = False
S3AioExecutor._instances.add(self)

@override
def submit(self, fn: Callable[..., T], *args: Any, **kwargs: Any) -> Future[T]:
if self._forked:
raise RuntimeError("S3AioExecutor cannot be used in a forked child process.")
if self._loop is not None and self._loop.is_running():
# The future of run_coroutine_threadsafe can be cancelled while
# the function keeps running in its thread, so the returned future
Expand Down Expand Up @@ -596,3 +610,21 @@ def shutdown(self, wait: bool = True) -> None:
if not loop.is_running() or sys.is_finalizing():
break
self._pool.shutdown(wait=True)

@classmethod
def _reset_after_fork(cls) -> 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 1 (implementation behavior): FINDINGS, repaired in f192c0f.

Scope: git diff 4a0cadb2eabc0b0831b0e0f5c01932661495ebd8..2169c92d63aa8316f5d060c9504dc9086cd21048 (base = merge-base with master), every changed file: s3_executor.py, s3_async.py, docs/aio.md, and the tests in test_s3_executor.py, test_s3_async.py, util.py. Perspectives: behavior and failure paths (fork, shutdown, close of a file that crossed the fork, asynchronous=True instances), resource boundaries (threads, locks, retained executors after fork), simplicity and test quality (/code-review high and /simplify with four angles).

Repaired here: S3AioExecutor compared PIDs in both submit() and shutdown(), and the child path of shutdown() duplicated the shutdown and stepped around a lock that a thread of the parent may hold. It now resets in a register_at_fork hook, as S3AioThreadPool does (loop detached, lock replaced, marked forked), and shutdown() takes its existing path for an executor without a loop. The old comment there also claimed that no function had been dispatched to the pool in the child, which is false for calls in flight at the fork; the comment is gone with the branch. Mutation check: keeping the loop, the lock, or the unforked mark in the hook each fails TestS3AioExecutor::test_fork.

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), finding 1 (P1, pre-existing): pyathena/filesystem/s3_writer.py:920 — a multipart write session inherited across a fork with a part request in flight keeps that unfinished future in _requests, so commit()/close() and discard() in the child wait forever, through either filesystem; the executor resets neither invalidate the session nor reset its locks.

Verified by reading _S3WriteSession.commit() (waits while self._requests is non-empty). Not introduced here: it is the in-flight case listed as out of scope in the PR body. Deferred to step 2 of #1332, which rejects files inherited across a fork (decision 6); the reviewer's suggestion, rejecting inherited write sessions before taking locks or waiting, including in cleanup, belongs to that design.

"""Detach the executors that a forked child copied from the event loop of its parent.

The copy of the loop reports running, but nothing runs it, and a lock
that a thread of the parent held stays held. Each executor then
raises from :meth:`submit`, and :meth:`shutdown` only shuts down its
pool.
"""
for executor in list(cls._instances):
executor._loop = None
executor._lock = threading.Lock()
executor._forked = True


if hasattr(os, "register_at_fork"):
os.register_at_fork(after_in_child=S3AioExecutor._reset_after_fork)
19 changes: 19 additions & 0 deletions tests/pyathena/filesystem/test_s3_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,11 @@
ThrottledOperation,
await_bounded,
cancel_when_started,
forking,
overlapping_calls,
retried,
run_bounded,
run_in_forked_child,
shutdown_bounded,
stub_multipart_copy,
wait_until,
Expand Down Expand Up @@ -127,6 +129,23 @@ async def _multipart_copy(release, fs=None, **kwargs):
await asyncio.to_thread(shutdown_bounded, fs._thread_pool)
stubber.assert_no_pending_responses()

@forking
def test_open_in_forked_child(self):
# GH-1336: in a forked child, a filesystem created in the parent
# raises on open(), as fsspec's synchronous methods do there. It used
# to return a file whose reads waited forever for the event loop of
# the parent.
fs = AioS3FileSystem(
key="dummy", secret="dummy", region_name="us-east-1", skip_instance_cache=True
)

def child():
with pytest.raises(RuntimeError, match="not fork-safe"):
fs.open("s3://bucket/key", "rb")
return True

assert run_in_forked_child(child) == 0

@pytest.mark.parametrize("size", [0, 10])
@pytest.mark.asyncio
async def test_copy_object_with_multipart_upload_small_head_object_size(self, size):
Expand Down
107 changes: 79 additions & 28 deletions tests/pyathena/filesystem/test_s3_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@
import asyncio
import contextvars
import gc
import os
import signal
import threading
import time
import traceback
Expand All @@ -23,7 +21,14 @@

from pyathena.filesystem.s3_executor import S3AioExecutor, S3AioThreadPool, S3ThreadPoolExecutor
from pyathena.util import RetryConfig, retry_api_call, stop_retries_on
from tests.pyathena.util import EVENT_TIMEOUT, ThrottledOperation, overlapping_calls
from tests.pyathena.util import (
EVENT_TIMEOUT,
ThrottledOperation,
forking,
overlapping_calls,
run_in_forked_child,
shutdown_bounded,
)


class TestS3AioThreadPool:
Expand Down Expand Up @@ -275,8 +280,7 @@ def test_release_after_interrupted_submit(self):
assert not threads[0].is_alive()
pool.shutdown()

@pytest.mark.skipif(not hasattr(os, "fork"), reason="os.fork() is not available.")
@pytest.mark.filterwarnings("ignore:.*fork\\(\\) may lead to deadlocks:DeprecationWarning")
@forking
def test_fork(self):
# A forked child, whose copy of the pool has no threads, creates new
# ones, also if a thread of the parent held the lock of the pool.
Expand All @@ -289,38 +293,22 @@ def test_fork(self):
def hold_lock():
with pool._lock:
held.set()
release.wait(EVENT_TIMEOUT)
release.wait()

holder = threading.Thread(target=hold_lock)
holder = threading.Thread(target=hold_lock, daemon=True)
holder.start()
try:
assert held.wait(EVENT_TIMEOUT)
pid = os.fork()
finally:
release.set()
if pid == 0:
# The child exits here, whatever happens, instead of running pytest.
code = 1
try:
if pool.submit(sum, [3, 4]).result(timeout=EVENT_TIMEOUT) == 7:
code = 0
finally:
os._exit(code)
status = (0, 0)
try:
# A child whose pool waits for the threads of the parent would
# not exit.
deadline = time.monotonic() + 2 * EVENT_TIMEOUT
while (status := os.waitpid(pid, os.WNOHANG)) == (0, 0):
assert time.monotonic() < deadline, "the child did not exit"
time.sleep(0.05)
code = run_in_forked_child(
lambda: pool.submit(sum, [3, 4]).result(timeout=EVENT_TIMEOUT) == 7
)
finally:
if status == (0, 0):
os.kill(pid, signal.SIGKILL)
os.waitpid(pid, 0)
release.set()
holder.join(EVENT_TIMEOUT)
pool.shutdown()
assert os.waitstatus_to_exitcode(status[1]) == 0
assert code == 0


class TestS3AioExecutor:
Expand Down Expand Up @@ -502,6 +490,52 @@ def block():
loop.run_until_complete(asyncio.sleep(0))
loop.close()

@forking
def test_fork(self):
# GH-1336: in a forked child, where the event loop of the parent does
# not run, submit() raises and shutdown() returns, also if a thread
# of the parent held the lock of the executor. Both used to wait
# forever for the copy of the loop, which reports running.
loop = asyncio.new_event_loop()
thread = threading.Thread(target=loop.run_forever, daemon=True)
thread.start()
executor = S3AioExecutor(loop=loop)
held, release = threading.Event(), threading.Event()

def hold_lock():
with executor._lock:
held.set()
release.wait()

def child():
with pytest.raises(RuntimeError, match="forked child"):
executor.submit(sum, [3, 4])
executor.shutdown(wait=True)
return True

holder = threading.Thread(target=hold_lock, daemon=True)
# The executor submits only to a loop that runs.
running = threading.Event()
loop.call_soon_threadsafe(running.set)
try:
assert running.wait(EVENT_TIMEOUT)

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 0.166.0, model gpt-6-astra, codex exec -s read-only (session 01a1a19a-c316-7261-b2ce-54676f9e9e11), static review of a detached snapshot of f192c0fedfa38b37be0ef3c56b2278d084b78922 against merge base 4a0cadb2eabc0b0831b0e0f5c01932661495ebd8. The prompt held the diff and the repository conventions, without the PR text or the self-review conclusions; no edits, tests, or network. Both worktrees were unchanged afterwards. Covered: fork hooks, inherited locks and loops, new child objects, both filesystems and asynchronous=True; constructor, submit/shutdown, context, ordering, GC, retention, interpreter exit; all added or rewritten tests, docstrings, comments, and docs. Verdict: FINDINGS (three).

Finding 3 (P2): the new TestS3AioExecutor::test_fork races the start of the event loop. thread.start() can return before run_forever() marks the loop running, and the first submit() then raises RuntimeError before the fork is exercised.

Verified: with the loop's start delayed by 0.2 s, submit() raises "requires a running event loop". Repaired in 7fafe8d: the test waits for a callback scheduled on the loop before it submits (fork tests: 4 passed).

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.

Repair and independent follow-ups (relayed).

Self-review of the 7fafe8d repair, both perspectives: the readiness wait runs inside try, so the loop is still stopped if it times out; the commit message is accurate. The PR body names 7fafe8d and the fsspec limit.

Follow-up 1 (Codex gpt-6-astra, read-only, session 01a1a1c1-5c53-7091-a97c-fb728f3731a1, static, f192c0fe..7fafe8d2 in the context of the whole test): confirmed that the startup race is gone; FINDINGS on the test's robustness: (P2) the cleanup could wait forever in executor.shutdown() before loop.stop(), and the unchecked join let loop.close() raise on a running loop; (P2) the holder released the lock after 10 s, so a slow parent could fork with the lock free and pass without the lock reset (same pattern in TestS3AioThreadPool::test_fork); (P3) loop.stop() could leave a dispatch task pending that close() discards.

Repaired in e2d2496: holders keep the lock until the test releases it and run as daemons (both fork tests); the executor is shut down with shutdown_bounded(), the loop thread is a daemon and is checked to have ended, and the pending tasks are run before close(). Fork tests: 4 passed; removing the lock reset of the pool or of the executor still fails its test.

Follow-up 2 (Codex gpt-6-astra, read-only, session 01a1a1c7-39f9-7f71-b6cd-a6a6de452835, static, 7fafe8d2..e2d2496b with both whole tests, shutdown_bounded, run_in_forked_child, and the executor): CLEAN: all three resolved, no new failure or hang path.

assert executor.submit(sum, [1, 2]).result(timeout=EVENT_TIMEOUT) == 3
holder.start()
assert held.wait(EVENT_TIMEOUT)
code = run_in_forked_child(child)
finally:
release.set()
if holder.ident is not None:
holder.join(EVENT_TIMEOUT)
shutdown_bounded(executor)
loop.call_soon_threadsafe(loop.stop)
thread.join(EVENT_TIMEOUT)
assert not thread.is_alive()
# Runs the dispatch tasks that the stop left pending.
loop.run_until_complete(asyncio.sleep(0))
loop.close()
assert code == 0

def test_submit_without_running_loop(self):
with pytest.raises(RuntimeError, match="requires a running event loop"):
S3AioExecutor().submit(sum, [1, 2])
Expand Down Expand Up @@ -545,6 +579,23 @@ async def main():
assert pending.cancelled()


class TestS3ThreadPoolExecutor:
@forking
def test_fork(self):
# GH-1336: a forked child, whose copy of the executor has no threads,
# runs the calls submitted there. After the executor had run a call
# in the parent, they used to wait forever.
executor = S3ThreadPoolExecutor(max_workers=1)
try:
assert executor.submit(sum, [1, 2]).result(timeout=EVENT_TIMEOUT) == 3
code = run_in_forked_child(
lambda: executor.submit(sum, [3, 4]).result(timeout=EVENT_TIMEOUT) == 7
)
finally:
executor.shutdown()
assert code == 0


class TestS3Executor:
@pytest.mark.parametrize("executor_class", [S3ThreadPoolExecutor, S3AioThreadPool])
def test_submit_context(self, executor_class):
Expand Down
Loading
Loading