Repository navigation
Merge the filesystem thread pools into one S3ThreadPool type (GH-1332) - #1357
Conversation
S3AioThreadPool becomes S3ThreadPool, a neutral name, and S3ThreadPoolExecutor and S3AioThreadPool remain as aliases of it. S3AioExecutor remains as a subclass for compatibility: it no longer dispatches the calls through an asyncio event loop, and ignores its loop argument with a DeprecationWarning. The files of AioS3FileSystem run their range reads and parts in an S3ThreadPool of their own, as those of S3FileSystem do, so they need no running loop and also work in a forked child process. The tests of the loop dispatch are removed; the contract of submit_to() for a callable that an executor drops or fails is tested with an executor that holds the calls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…H-1332) In a forked child, only fsspec's synchronous methods of an AioS3FileSystem created in the parent raise; its coroutines and open() work. The files of AioS3FileSystem.open() take the executor that the wrapped S3FileSystem creates, without an override that only forwarded to it. The fork read test counts the requests of the child's read, and the docstring of S3ThreadPool names both aliases. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ons (GH-1332) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
||
| class S3AioExecutor(S3Executor): | ||
| """Executor that schedules work on an asyncio event loop. | ||
| class S3AioExecutor(S3ThreadPool): |
There was a problem hiding this comment.
Self-review round 1 (implementation behavior): FINDINGS, repaired in 5d56657 and f855e7f.
Scope: git diff c135e214da966bf8788dfd122d6a94a7722468e2..79129d7a (base = merge-base with master), all 14 files: s3_executor.py, s3_async.py, s3.py, s3_writer.py, docs/aio.md, docs/api/filesystem.rst, the benchmark notes, and the tests. Perspectives: behavior and failure paths (calls without a running loop, loop shutdown, fork, close on the loop thread, submit_to() with executors that drop calls), compatibility of the aliases and of S3AioExecutor, simplicity and test quality (/code-review high, then /simplify with four angles), and the removed tests.
Repaired:
- The class docstring of
S3ThreadPoolnamed itself as an alias (my global rename); it namesS3AioThreadPoolnow. docs/aio.mdsaid the coroutines of a filesystem created in the parent raise in a forked child. Verified: they work there (asyncio.run(fs._info(...))in a child returned), and only fsspec's synchronous methods (fs.info()) raise "This class is not fork-safe". Corrected.AioS3FileSystem._create_executor()only forwarded to the wrapped filesystem, and its docstring said it returned that filesystem's executor, which is created per call; removed, and_open()callsself._sync_fs._create_executor().S3FileSystem._create_executor()'s docstring invited asyncio-based overrides; it now says which files and operations take their executors from it.- The
isinstance()change of the aliases is in the release note.
Kept: S3AioExecutor as a subclass, not an alias, keeps S3AioExecutor(loop, max_workers) positional calls and its cpu_count() * 5 default (S3AioExecutor(8) binds loop as before). _S3WorkItem's claim lock stays: an executor passed to S3File(executor=) may cancel its returned future while the call runs, as the old S3AioExecutor did. The alias sentence in the class docstring stays next to the #: entries.
| with fs.open("s3://bucket/key", "rb", block_size=block_size, cache_type="none") as f: | ||
| data = f.read() | ||
| # The ranges of the read, each a request of its own. | ||
| return data == bytes(size) and fs.core.get_object.call_count - calls > 1 |
There was a problem hiding this comment.
Self-review round 1: repaired in 5d56657. The test counted requests in the parent only; the child now checks that its own read sent more than one ranged request. Mutations: a call to self.loop in the executor path fails this test; removing the pool's after-fork reset fails only TestS3ThreadPool::test_fork, because each file still creates its own pool in the child (step 2b changes that).
| return await asyncio.to_thread(cancel, executor) | ||
|
|
||
| running, pending = asyncio.run(main()) | ||
| class _DeferringExecutor(S3Executor): |
There was a problem hiding this comment.
Self-review round 1: repaired in 5d56657: the overrides of this double carry @override. Reuse check: _DroppingExecutor (test_s3_writer.py) overrides submit_to itself, and ImmediateExecutor runs calls at once, so neither exercises S3Executor.submit_to()'s handling of a dropped or failed inner future, which these three tests protect. Each fails under its mutation of _S3WorkItem.settle().
|
|
||
| class S3ThreadPoolExecutor(S3Executor): | ||
| """Executor that runs the submitted calls in a pool of threads. | ||
| class S3ThreadPool(S3Executor): |
There was a problem hiding this comment.
Self-review round 2 (claims, callers, evidence): CLEAN after one addition to the release note; no code change.
Scope: git diff c135e214da966bf8788dfd122d6a94a7722468e2..f855e7fe11c7e12368a345b6b692b82f791b00ec, the PR body, the docstrings, comments, docs/aio.md, docs/api/filesystem.rst, the benchmark notes, and the commit messages.
Claims checked:
- Without a running loop,
S3AioExecutor.submit()runs the call (test_loop_ignored); queued calls run to their end when a loop stops, since nothing dispatches them through it any more. - Forked child: a file of a parent-created
AioS3FileSystemreads in parallel (test_read_in_forked_child); its coroutines work (asyncio.run(fs._info(...))in a child), and only fsspec's synchronous wrappers raise; the onlyself.loopuse ins3_async.pyis the synchronousmv()wrapper (s3_async.py:445). S3ThreadPooldefaults tomin(32, CPUs + 4)and starts calls in submission order (oneThreadPoolExecutorqueue); the API reference renders both aliases with their own line (built withsphinx-buildfrom the working tree, sincejust docs buildrenders only master and tags).- No reference to the removed loop dispatch remains in
pyathena/ordocs/*.mdoutside the compatibility notes; the benchmark history files describe past measurements and are unchanged.
Existing callers: positional S3AioExecutor(loop, max_workers) and its default keep working; code that passes loop gets a DeprecationWarning (an error under -W error); isinstance() distinctions between the three names are gone (in the release note).
Addition to the release note: S3AioExecutor.shutdown(wait=True) now waits for its calls also on the event loop's thread, including when an AioS3File is closed there, where it used to return at once.
Evidence limits: the full suite ran at 79129d7; the later commits change docstrings, docs, comments, the removed override, and tests whose modules were rerun. No AWS request path changes.
A filesystem created in the parent keeps the parent's boto3 client, so the docs say to create a new filesystem in a forked child instead of promising that the parent's works there. S3ThreadPool queues the calls in submission order; with several threads it does not guarantee the order in which they start. The asyncio variant of the failed-read memory test ran the same S3File code as the synchronous one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| The file handles of `AioS3FileSystem.open()` run their multipart uploads and parallel | ||
| range reads in a pool of `max_workers` threads of each file, as `S3File` does. | ||
|
|
||
| In a forked child process, create a new filesystem. A filesystem created in the parent |
There was a problem hiding this comment.
Independent review (relayed): Codex CLI 0.166.0, model gpt-6-astra, codex exec -s read-only (session 01a1a230-b700-7310-b686-af873c062562), static review of a detached snapshot of f855e7fe11c7e12368a345b6b692b82f791b00ec against merge base c135e214da966bf8788dfd122d6a94a7722468e2, with the diff and the repository conventions, without the PR text or the self-review conclusions; no edits, tests, or network; both worktrees unchanged afterwards. Covered: constructor signatures, defaults and aliases; submission, shutdown, GC, interpreter exit and fork; file and write-session cancellation and settling; docs and API reference; added, rewritten, and removed tests. Verdict: FINDINGS (three).
Finding 1 (P2, pre-existing client limitation exposed by the docs): a filesystem created in the parent keeps the parent's boto3 client; if a parent thread held, for example, the credential-refresh lock at the fork, a child request that refreshes blocks. The docs promised that the parent's filesystem's open() works in the child, and the mocked fork test does not reach the client.
Verified: only the executors are reset after a fork; S3Core.client is the parent's object. Repaired in dbb262c: the docs tell to create a new filesystem in a forked child and give both reasons (the parent's boto3 client, and fsspec's synchronous methods raising). The release note now says only that the files no longer hang on their executor. test_read_in_forked_child keeps protecting that executor contract with a mocked client.
Finding 3 (P3): TestAioS3File::test_failed_read_frees_other_ranges now ran the same S3File._fetch_range and pool as TestS3FileSystem::test_failed_read_frees_other_ranges, with the same assertions. Verified by comparing both tests; removed in dbb262c, and the synchronous test keeps the coverage.
| that, unlike ``asyncio.to_thread()``, which runs a call in the default | ||
| executor of the event loop, neither the size nor the load of that | ||
| executor limits or blocks the calls of asyncio code. The calls are | ||
| queued in the order they were submitted. The threads are created as the calls need |
There was a problem hiding this comment.
Independent review (relayed), finding 2 (P3): with several threads, a worker can take the first queued call and be descheduled before it runs it, so a later call may start first; "the calls start in the order they were submitted" overstated it, also in docs/aio.md.
Verified against ThreadPoolExecutor's worker loop (one shared queue, no ordering of starts). Repaired in dbb262c here and in docs/aio.md: the calls are queued in submission order. The same wording in the submit() docstring is pre-existing; its purpose, that a call waiting for earlier ones does not wait for a thread it holds, follows from the queue order, and it is left for step 2b, which rewrites the pool's use.
There was a problem hiding this comment.
Repairs and independent follow-ups (relayed).
Self-review of the dbb262c repair, both perspectives: the docs' new reason for creating a filesystem in a forked child matches boto3's guide ("While clients are thread-safe, they cannot be shared across processes due to their networking implementation", Boto3 guide, Low-level clients, "Multithreading or multiprocessing with clients"); the removed test's behavior stays covered by TestS3FileSystem::test_failed_read_frees_other_ranges, which runs the same S3File read on the same pool type.
Follow-up 1 (Codex gpt-6-astra, read-only, session 01a1a23d-3514-7983-811d-ab1f428aaeec, static, f855e7fe..dbb262c1 with docs/aio.md, s3_executor.py, and both test files): findings 1 and 3 resolved; FINDINGS (P2): the submit() docstring (s3_executor.py:258) still promised a start order. I had left it as pre-existing; repaired in 06ff663 instead: the calls are queued in submission order, so the calls submitted before one that waits for them are taken by threads before it.
Follow-up 2 (Codex gpt-6-astra, read-only, session 01a1a23f-b542-7482-929c-041842928510, static, dbb262c1..06ff6632, checked against CPython 3.12's ThreadPoolExecutor worker loop): CLEAN: the statement is accurate, and no sentence in s3_executor.py or docs/aio.md promises a start order any more.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WHAT
The filesystem's thread pools become one type:
S3AioThreadPoolis renamedS3ThreadPool.S3ThreadPoolExecutorandS3AioThreadPoolremain as aliases of it, documented as such in the API reference.S3AioExecutorremains as a subclass ofS3ThreadPoolfor compatibility. It no longer dispatches the calls through an asyncio event loop: they run directly in the pool. Itsloopargument is ignored, with aDeprecationWarningif one is given, and its defaultmax_workersis unchanged.AioS3FileSystem._create_executor(), which created theS3AioExecutorof each file, is removed:AioS3FileSystem.open()takes the executor from the wrappedS3FileSystem._create_executor(), so its files run their range reads and parts in anS3ThreadPoolofmax_workersthreads of the file, asS3Filedoes.docs/aio.md, the API reference, the benchmark notes, and the docstrings and comments that described the loop dispatch are updated.Release note (behavior changes):
AioS3FileSystem.open(), andS3AioExecutor, no longer need fsspec's event loop to run: a call runs to its end when the loop stops, instead of being cancelled before it starts, andS3AioExecutor.submit()no longer raisesRuntimeErrorwithout a running loop.AioS3FileSystemcreated in the parent no longer hang on their executor, which used the parent's event loop. The docs say to create a new filesystem in the child: one created in the parent keeps the parent's boto3 client, and fsspec's synchronous methods of anAioS3FileSystemcreated in the parent, such asls(), raiseRuntimeErrorthere, as before.S3ThreadPoolExecutoris nowS3ThreadPool: itsmax_workersis optional (defaultmin(32, CPUs + 4)), and it hasrun(),run_shielded(),wait_shielded(), andrelease().S3ThreadPoolExecutor,S3AioThreadPool, andS3AioExecutorare no longer distinct forisinstance(): anS3AioExecutoris an instance of the other two.looptoS3AioExecutoremits aDeprecationWarning.S3AioExecutor.shutdown(wait=True)waits for the submitted calls also on the thread of the event loop, where it used to return at once because the loop dispatched them; this includes closing anAioS3File, such as at garbage collection, on that thread.WHY
Part of #1332: step 2a of the implementation decisions, decision 6 of the design.
The loop dispatch of
S3AioExecutoradded failure modes and no function: it needed a running loop, hung after a fork (#1336), could not be waited for on the loop's thread, and left futures pending when the loop stopped while the write session waited for them. One pool type lets step 2b give each filesystem one request pool that its files share.TEST
Tested commit: 06ff663 (the full suite at 79129d7; the later commits change docstrings, docs, comments, the removed
AioS3FileSystem._create_executor(), and the tests, whose modules were rerun).just lint,just benchmark lint, andjust docs lint(0 errors): passed. The API reference, rendered from the working tree withsphinx-build, showsS3ThreadPool, the two aliases with their own one-line description, andS3AioExecutor.uv run --env-file .env pytest -n 8 tests/pyathena/filesystem/, including the AWS integration tests: 1410 passed, 6 skipped at 79129d7. The unit modules of the filesystem also pass with-W error::DeprecationWarning(379 passed, 3 skipped at 79129d7;test_s3_executor.py,test_s3_async.py,test_s3_writer.py: 201 passed, 3 skipped at 5d56657;test_s3_executor.py,test_s3_async.py: 168 passed, 3 skipped at dbb262c).TestS3AioExecutor::test_loop_ignored(the warning, and a call without a running loop) andTestAioS3FileSystem::test_read_in_forked_child(a file opened in a child by a filesystem created in the parent reads its ranges in parallel, counted in the child); it replacestest_open_in_forked_child, which asserted theRuntimeErrorof Make files usable after fork with either filesystem (GH-1336) #1353.submit_to()tests of a callable that the executor drops, fails, or fails after the caller cancelled it, which usedS3AioExecutor's loop, now use an executor that holds the submitted calls.S3AioExecutor's loop (ten tests),TestS3ThreadPoolExecutor::test_fork(the same class asTestS3ThreadPool::test_fork), andtest_discard_on_event_loop_thread(the cancellation of parts that have not started is still covered bytest_discard_waits_for_running_parts), andTestAioS3File::test_failed_read_frees_other_ranges, which now ran the sameS3Filecode and pool asTestS3FileSystem::test_failed_read_frees_other_ranges. The parametrizations ofTestS3Executorover the two classes, now one, are dropped.TestS3AioThreadPoolis renamedTestS3ThreadPool, which changes the IDs of its tests.loop, a call toself.loopin_create_executor, and three changes to_S3WorkItem.settle()each fail the corresponding new or rewritten test. Without the after-fork reset of the pool,TestS3ThreadPool::test_forkfails;test_read_in_forked_childstill passes because files still create their own pools.just benchmark test: not run locally (Three test_fleet.py tests fail locally while passing in CI #1354); the Benchmark tooling workflow runs it.Not run yet: the AWS CI jobs, which start when the PR is marked Ready.
🤖 Generated with Claude Code