Repository navigation
Share one request pool per filesystem among its files and operations (GH-1332) - #1359
laughingman7743 wants to merge 22 commits into
Conversation
…H-1332) S3FileSystem owns a request pool of max_workers threads, which defaults to the max_pool_connections of its S3 client; a client that it builds takes max_pool_connections from max_workers unless config_kwargs sets it. _create_executor() returns a scope on that pool: at most max_workers of its calls are in the pool at once, the others wait in the scope with the context of their submitter, and shutting it down waits for its own calls only, so files no longer own or shut down a pool. S3FileSystem gets close() and with support, which release the idle threads of the pool. AioS3FileSystem sizes its pool of blocking calls from the internal S3FileSystem, and runs the requests that it fans out (part copies, annotation copies, DeleteObjects batches) and the requests of its streamed files in the request pool, while the calls that wait for them (completions, commits, aborts) stay in its own pool. Its close() also releases the request pool. S3Executor gains run(), run_shielded(), and wait_shielded(), which used to be S3ThreadPool's. In a forked child process, a file that the parent opened raises RuntimeError from its reads, writes, flushes, commit, and discard, and closing it only marks it closed, since its upload belongs to the parent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…se (GH-1332) A call of a scope hands its place to the next waiting call in the pool thread, before it ends there, so that an interrupt of the submitter cannot keep a place, a release of the pool waits for the waiting calls, and a rejected call no longer recurses. An interrupt before a call reaches the pool cancels it and frees its place. Calls cancelled while they wait are settled without entering the pool. The DeleteObjects batches of AioS3FileSystem._rm() run through a scope of max_workers, as those of S3FileSystem.rm() do, so that a large deletion does not queue ahead of the requests of the files. The pandas and s3fs result sets release the threads of their filesystem when they close. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…elism (GH-1332) The annotation copies of AioS3FileSystem's multipart copy run through a scope of max_workers in the request pool, as the DeleteObjects batches do, instead of an asyncio semaphore around the pool; a failed copy keeps the waiting ones from starting through its stop event, as before. The docs say how to raise the parallel requests now that max_workers defaults to the client's connection pool. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| return False | ||
| return True | ||
|
|
||
| def _run(self, context: contextvars.Context, item: _S3WorkItem[Any]) -> None: |
There was a problem hiding this comment.
Self-review round 1 (implementation behavior): FINDINGS, repaired in acfe321 and 404490b.
Scope: git diff 892b213773e2abe09668e55b53b3795930d0fb1b..a0d908e1 (base = merge-base with master), all 12 files: s3_executor.py, s3.py, s3_async.py, the docs, the benchmark notes, and the tests. Perspectives: behavior and failure paths (interrupts, cancellation, rejection, release, transactions, fork, GC), resource bounds (threads, places, queued arguments), the callers of _create_executor and of the moved run helpers, simplicity and test quality (/code-review high, then /simplify with four angles).
Repaired here (acfe321):
- An interrupt after a call reserved its place and before it reached the pool, or between
pool.submitand the place's callback, kept the place for good and left a future thatshutdown(wait=True)waited for forever. A call now hands its place on in its pool thread (_run,_next), and an interrupt before the pool takes it cancels it and frees its place.test_interrupted_submitinterrupts at the scope's first callback and fails against a0d908e. S3ThreadPool.release()could end the threads between two calls of a scope, after which the next waiting call started threads that nothing released; the next call now enters before the current one ends in the pool.test_release_waits_for_waiting_callsfails against a0d908e.- A rejected call recursed through
_leave/_enteronce per waiting call;_nextis a loop. Cancelled waiting calls are settled without entering the pool.
Kept: one more copy_context() per call (a few µs against ms-scale requests); the duplicated _inherited/_check_process of S3File and AioS3StreamedFile (a shared mixin would be a class restructuring); the max_workers check of S3File, which is the only check when a caller passes an executor of its own; rm(max_workers=) reaches _delete_objects through **kwargs, so that parameter is not dead.
| # At most max_workers requests at once, as S3FileSystem.rm() sends, | ||
| # so that a large deletion does not queue ahead of the requests of | ||
| # the files that share the request pool. | ||
| requests = self._sync_fs._create_executor(max_workers=self._sync_fs.max_workers) |
There was a problem hiding this comment.
Self-review round 1: repaired in acfe321. _rm() put every DeleteObjects batch straight into the shared request pool, so a large deletion (2,000 batches for 2,000,000 keys) queued ahead of the range reads and parts of every open file. It now runs through a scope of the filesystem's max_workers, as S3FileSystem.rm() does. In 404490b the annotation copies of a multipart copy also run through a scope (of the copy's max_workers) instead of an asyncio semaphore around the pool; a failed copy still keeps the waiting ones from starting through its stop event.
| self._df_iter.close() | ||
| # Ends the idle threads of the request pool of the filesystem, which | ||
| # a read that is still running keeps until it ends. | ||
| if (fs := getattr(self, "_fs", None)) is not None: |
There was a problem hiding this comment.
Self-review round 1: repaired in acfe321. A result set's filesystem (skip_instance_cache=True) was never closed, so its request-pool threads stayed for the life of the result set, where the per-file pools used to end with each file. The pandas and s3fs result sets now call close() on close, which releases the idle threads; a read that still runs keeps its threads until it ends.
Deferred (in the PR body): the cursors pass an explicit s3_max_workers of cpu_count() * 5 (pandas/result_set.py:871, polars/cursor.py:84, async_cursor.py:77), which decision 3 uses as given, so their pools can exceed the client's connection pool, as their per-file pools did. A follow-up issue will propose the cursors' default.
| self.max_workers = max_workers | ||
| # The requests of the files and parallel operations of the | ||
| # filesystem run in this pool, at most max_workers at once. | ||
| self._request_pool = S3ThreadPool( |
There was a problem hiding this comment.
Self-review round 2 (claims, callers, operations, evidence): one correction to the PR body; no code change.
Scope: git diff 892b213773e2abe09668e55b53b3795930d0fb1b..404490b7850b9fa83ac3d9630b1eaada97727d95, the PR body, docstrings, comments, docs/filesystem.md, docs/aio.md, the benchmark notes, and the commit messages.
Claims checked:
- Sizing: the default is
client.meta.config.max_pool_connections(botocore's default 10); a built client getsmax_pool_connections = max_workersunlessconfig_kwargssets it (test_request_pool_size, four combinations;test_request_pool_size_of_connection). The old defaults werecpu_count() * 5in both constructors, as the release note says. - The documented members of the request pool match the code: sync part copies, recursive
chmod,rm,clear_multipart_uploads, and files take scopes from_create_executor; aio part and annotation copies,_rm()batches, and streamed-file reads and parts use scopes on the same pool; aio completions, aborts, and streamed-file commits and discards stay in the task pool. - Before this change, closing an inherited write file in a child ran
flush(force=True)and the commit, which completes or puts the parent's upload; the release note's statement holds. - Correction: "No pool runs a call that waits on that same pool" was too broad: a copy's
_abortwaits in the task pool for a completion that already started there (unchanged behavior). The PR body now says the request pool runs no call that waits for it, which is what the design requires.
Existing callers: max_workers stays an int attribute; _create_executor(max_workers) keeps its signature; S3File(executor=None) still builds a pool of its own; S3ThreadPool.run() and the other helpers remain available through S3Executor. Removed private attributes: AioS3StreamedFile._pool.
AWS operator: fewer threads by default, and requests of concurrent files and operations of one filesystem now stay within its client's connection pool; no retry or request shape changes.
Evidence limits: only offline runs (--noconftest), since the AWS login session expired; the AWS integration suite, just benchmark test, and CI are still to run before Ready.
A call of a scope takes a place only once the pool has accepted it, and frees it when it ends, so that an interrupt of the submitter at any point leaves no place taken and none freed twice; an interrupt before the pool takes a call cancels the call. Once the pool takes no more calls, as when the interpreter exits, the thread of a call that ends runs the calls that wait in its scope. AioS3StreamedFile.read() rejects a file inherited across a fork also when it reads nothing. The queued-commit test of the streamed file holds the thread of the filesystem that runs the commit, and the commit tests wait for that pool. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| self._futures.add(future) | ||
| enter = len(self._entered) < self.max_workers | ||
| if enter: | ||
| # Counted only once the pool has accepted it. |
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 01a1a775-7d13-7563-a1bd-b631cc33d045), static review of a detached snapshot of 404490b7850b9fa83ac3d9630b1eaada97727d95 against merge base 892b213773e2abe09668e55b53b3795930d0fb1b, 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: request scheduling, write-session cancellation, transactions, custom executors, close/discard, fork, GC, interpreter exit, result sets, docs, and the changed tests. Verdict: FINDINGS (six).
Findings 1 (P1), 2 (P2), and 4 (P2): the interrupt windows of _S3PoolScope.submit().
- An interrupt after
_futures.add()and before thetryleft a pending future, possibly holding a place, andshutdown()hung. - An interrupt after the pool accepted a call and before
enteredwas set freed its place twice (_runningcould go negative), and untracked a running call. - A submitter paused between reserving a place and submitting let a waiting call return, and a release then missed it.
Verified by reading the bytecode-level order. All three came from reserving a place before the pool accepted the call. Repaired in fd7a6b2: a call counts toward max_workers only once pool.submit has returned (it joins _entered under the lock), and leaves when it ends, so an interrupt at any point leaves no place taken and none freed twice. At worst, one more call runs at once for a while. The interrupt handler only cancels a call that has not started. test_interrupted_submit now interrupts pool.submit and fails without that cancellation (the shutdown hangs).
There was a problem hiding this comment.
Repair and independent follow-up 1 (relayed).
Self-review of the fd7a6b2 repair, both perspectives: each changed path was traced in _S3PoolScope (submit, _run, _next) against interrupts between bytecodes of the submitter, rejection, and release. The docstrings and the PR body describe the bound, the exit drain, and the queued-commit test as changed.
Follow-up 1: Codex gpt-6-astra, read-only, static, 404490b7..fd7a6b24 with the whole scope, the pool, the work item, the write session, and the changed tests. The first attempt (session 01a1a785-5d00-7480-b0b3-b6920ebd033f) failed with "Selected model is at capacity"; the retry is session 01a1a78a-ba13-7a60-ba44-63d08716be74. Findings 3, 5, and 6 were resolved, and no lock-order deadlock was found from calling pool.submit() under the scope lock. FINDINGS:
- (P1) An interrupt after the pool accepted a call that had not started cancelled it and marked it notified directly. When the pool ran it,
_S3WorkItem.run()calledset_running_or_notify_cancel()again, which raises on a notified future, so_run()skipped_next(). The place stayed taken, the next call waited forever, andshutdown()hung. Verified against CPython'sFuture.set_running_or_notify_cancel. Repaired in ddc302b: the handler settles the cancelled call through its work item (call.item.run()), which claims it once, so the pool's later run is a no-op, and_run()frees the place in afinally.test_interrupted_submit_after_acceptanceinterrupts right after acceptance while the pool's only thread is held; it fails at fd7a6b2. - (P2) Repeated interrupts after acceptance of calls that have started let one uncounted call run per interrupt, so "at worst, one more call" overpromised. Repaired in ddc302b: the docstring states that each such interrupt can let one more call run at once.
- (P2, pre-existing)
S3ThreadPool.submit()interrupted between tracking a future and adding its callback keeps the future in_futures, so a laterrelease()waits for it forever and idle threads stay until another release or shutdown. This is the pool's own window since Own the AioS3FileSystem pool and keep aio DataFrame reads off the event loop #1209 (release()drops only futures that are done). The scope reaches it as files did before. Deferred: it is not introduced here and needs its own fix inS3ThreadPool; it will be listed in a follow-up issue.
A second follow-up review covers ddc302b.
There was a problem hiding this comment.
Repair and independent follow-up 2 (relayed).
Self-review of the ddc302b repair, both perspectives: the handler calls call.item.run() only after a successful future.cancel(). So it either loses the work item's claim, or finds the future cancelled and only notifies it. It never runs the user's callable in the submitter's thread. The docstrings state the per-interrupt allowance, and the offline suites pass twice in a row.
Follow-up 2 (Codex gpt-6-astra, read-only, session 01a1a794-616d-7af2-a5fc-468392da66df, static, fd7a6b24..ddc302bd with the whole scope, _S3WorkItem, and the new test): CLEAN.
- P1 is resolved for later runs by the pool and for draining the deque;
_run()frees the place infinally. - No callable runs in the submitter's thread.
- The docstrings are accurate.
- The test interrupts after acceptance while the only worker is held, and fails at fd7a6b2 because the duplicate notification raises and the next call times out.
| try: | ||
| self._pool.submit(self._run, call) | ||
| except RuntimeError: | ||
| # The pool takes no more calls. |
There was a problem hiding this comment.
Independent review (relayed), finding 3 (P2): at interpreter exit ThreadPoolExecutor.submit() raises, so the calls waiting in a scope failed with RuntimeError instead of draining as the calls queued in a pool of their own used to.
Verified (concurrent.futures.thread._python_exit sets the shutdown flag before joining the workers). Repaired in fd7a6b2: when the pool rejects a waiting call, the thread of the call that ended runs it. test_waiting_calls_run_after_pool_shutdown fails without this.
| await f.write(b"a" * block_size) | ||
| assert await asyncio.to_thread(started.wait, EVENT_TIMEOUT) | ||
| # The commit waits for the only thread of the filesystem, | ||
| # which this call holds. |
There was a problem hiding this comment.
Independent review (relayed), finding 5 (P2): test_flush_cancelled_while_queued_then_exit_with_exception blocked a part in the request pool, but the commit now runs in the filesystem's pool, so it was not queued, and the tests waited on the file's scope, which does not cover the commit or the abort.
Verified: holding the thread before open_async() even blocked the open, which runs in that pool. Repaired in fd7a6b2: the test replaces the filesystem's pool with one thread and holds it after the write, so the commit really waits for a thread. Both commit tests wait for fs._thread_pool. Without the commit's reservation before it is queued, the test fails (the upload is aborted).
| """ | ||
| return self.fs.core.operation_params(method, self.s3_additional_kwargs) | ||
|
|
||
| async def read(self, length: int = -1) -> bytes: |
There was a problem hiding this comment.
Independent review (relayed), finding 6 (P3): an inherited streamed file's read(0) and a read at EOF returned before _fetch_range(), bypassing the RuntimeError.
Verified against fsspec's AbstractAsyncStreamedFile.read. Repaired in fd7a6b2: the public read() checks the process, and the fork test calls read(0), which fails without the check.
… item (GH-1332) An interrupt after the pool accepted a call that had not started marked its future notified directly, so that the work item raised when the pool ran it, and the call kept its place in the scope for good. The work item now settles the cancelled future once, and the place is freed even if the call raises. The docstring states that each such interrupt can let one more call run at once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nject interrupts (GH-1332) Per the specification on #1332: - S3: a submitter queues behind any waiting call, so a call submitted while a place frees does not pass an older waiting call. - S8: a call cancelled while it waits leaves the queue at once, so that its arguments and context are not kept until a place frees. tests/pyathena/util.py gains interrupt_each_opcode(), which raises a KeyboardInterrupt at each point of a target where a signal can raise it: function entry, after a call returns, a backward jump target, and a with statement. It uses sys.monitoring on Python 3.12 and later, where a trace function gets no opcode events on the first call of a code object, and sys.settrace on 3.11. The submit() of the pool and the scope, and submit_to() through a scope, are interrupted at each of their points, after which nothing waits forever and the next call runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Per F2 of the specification on #1332, AioS3StreamedFile.close() and the close without commit of an async with block that raises wait, without blocking the event loop, for the requests of the file and a scheduled commit, also after a read, flush, or the close itself was cancelled. They used to return while a cancelled read's request or a queued commit still ran. _S3PoolScope.pending() returns the futures to wait for. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Per F5 of the specification on #1332, the cleanup of a parallel read of S3File also covers the code after its wait: an interrupt there, such as while the results are joined, used to leave their futures in the frame that the traceback of the interrupt keeps. The interrupt injection of the tests raises an interrupt that a signal checked after a call would raise at the call itself before the call when the next instruction is outside the handler that covers the call, as at a return that ends a try block; the read is interrupted at each of its points. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…H-1332) Per decision 1 on #1332, the pandas and Polars cursors, their async and aio variants, and their result sets default s3_max_workers to None, so that the filesystem that reads the result files sizes its request pool from the max_pool_connections of the connection's S3 client (10 unless set with s3_config). They used to default to cpu_count() * 5, which ran more requests at once than the client keeps connections for. An explicit value is used as given. docs/usage.md also says that Polars uses it for UNLOAD results read in chunks through PyArrow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Per §6.2 of the specification on #1332, sigint_when() in the test helpers delivers a real SIGINT to the main thread once a condition holds, so that a blocking wait is interrupted inside its C call. - A shutdown of a scope interrupted while it waits for a running call leaves the call running, and a later shutdown waits for it (I1, S5). - A parallel read of S3File interrupted while it waits for its ranges cancels those that have not started and frees the data of the others (F5). concurrent.futures.wait() leaves its waiter on the futures when it is interrupted, which forms a reference cycle with them, so the data is freed by the cyclic garbage collector rather than when the reads end; the test collects before it checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…H-1332) The pandas result set closes the filesystem that it created, to release its idle threads. The tests that read a local result file through LocalBucketFileSystem in place of S3FileSystem need the same close(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he pool (GH-1332) From the review of #1359 against the specification on #1332: - T1: a call submitted to a scope from a thread of its own pool, as when garbage collection there closes a file, runs in that thread instead of waiting for a place in the pool that it holds. S3ThreadPool marks its threads (owns_current_thread()). - S5: a waiting call that the pool fails to take for a reason other than its shutdown, such as a thread that cannot start, fails instead of being lost with its place. - S8: a call cancelled while it waits is dropped by its future in O(1), and its arguments and context are freed at once; the entry left in the queue is skipped. - F1: S3File and AioS3StreamedFile default max_workers to that of the filesystem, and S3File without an executor takes a scope of the filesystem's request pool instead of a pool of its own. _create_executor() is typed to return the scope. - F4: files tell that they crossed a fork by a fork count that an at-fork hook bumps, instead of calling os.getpid() on every read and write. The tests check the scope through what a caller observes; the mock filesystem of the TestS3File helpers gets a request pool. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tests that wait for the data of reads to be freed use poll_until(), and the interrupted-creation test of the multipart copy uses sigint_when() instead of its own copy of the SIGINT handling. interrupt_each_opcode() loops without unreachable code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| call = _S3ScopeCall( | ||
| contextvars.copy_context(), _S3WorkItem(future, fn, args, kwargs), future | ||
| ) | ||
| if self._pool.owns_current_thread(): |
There was a problem hiding this comment.
Self-review round 1 after the specification (ddc302bd..80ad4f2b): FINDINGS, repaired.
Scope: the changes made against the specification and its decisions, across all changed files. The changes cover S3, S8, F2, F5, the cursor default, the injection helper, and the barrier cases. Perspectives: behavior under the failure matrix, consumers, and test quality. Ran /code-review (high) and /simplify (two agents).
/code-review (10 findings), all repaired in cebbe07:
- T1: a file closed by garbage collection on a request-pool thread waited for a place in the pool it held. A pool thread's call to its own pool's scope now runs inline (
S3ThreadPool.owns_current_thread()). - S5: a waiting call that the pool failed to take for a reason other than shutdown was lost; it now fails.
- S8: cancellation was O(n) per call under the lock; it is now O(1), and the arguments are freed at once.
- F1:
S3FileandAioS3StreamedFilebuilt directly keptcpu_count() * 5, andS3Filekept its own pool.max_workers=Noneraised in the streamed file. _create_executor()returns the scope type, so_drain()needs no cast.- The fork check called
os.getpid()on every read and write; it now compares a fork counter. - The tests asserted private attributes; they now check observable behavior, except the documented barrier hook of S3.
Kept with reasons:
- Idle threads of a cached filesystem stay until close or GC (L3, L4).
- The polars chunked-read filesystem is collected (L4).
- No
_ForkGuardmixin (a class restructuring).
/simplify: sigint_when/poll_until replace duplicated SIGINT and polling code (80ad4f2). A dict-as-FIFO and an int counter were rejected: popping the front of a dict degrades, and an int cannot skip uncounted calls.
Found while validating:
- the mock filesystem of the TestS3File helpers lacked a request pool, and
test_s3.pyhung (fixed); LocalBucketFileSystemlackedclose()for the pandas result set (fixed, 7b77f37).
My own misjudgments, corrected before commit:
- the injection model first injected inside
withstatements, then at the next instruction past atryboundary; both gave false positives; - I called the
concurrent.futures.wait()waiter behaviour a CPython bug before verifying it. It only delays freeing until the cyclic GC, so it is recorded as such and not reported upstream.
| The default is `(cpu_count() or 1) * 5`. | ||
| Polars uses it only for CSV results read without `chunksize`. | ||
| Chunked CSV reads and UNLOAD results use Polars' native readers, which do not use this setting. | ||
| The default is None, which takes the `max_pool_connections` of the connection's S3 client: 10, unless set with `s3_config`. |
There was a problem hiding this comment.
Self-review round 2 after the specification (ddc302bd..80ad4f2b): CLEAN after the corrections below.
Claims checked:
docs/usage.md: Polars usess3_max_workersfor CSV results read withoutchunksizeand for UNLOAD results read in chunks through PyArrow without read options (polars/result_set.py:1237-1240,:874). The old text said UNLOAD results do not use it, which was wrong before this change.- The default None reaches
S3FileSystem(max_workers=None), which takes the connection client'smax_pool_connections.test_filesystem_request_pool_sizechecks the default path, andtest_s3_max_workers_defaultchecks the six cursors. - F2: a cancelled
close()now raisesCancelledErroronly after the commit or the read requests end (test_commit_cancelled,test_close_waits_for_cancelled_read). This is a behaviour change for callers, listed in the PR body. - F5: on a real Ctrl-C during the wait,
concurrent.futures.wait()leaves its waiter on the futures, so the data is freed by the cyclic GC (verified with CPython alone). This is stated in the test and will be added to the spec. - S9: the final
returnof_S3PoolScope.submit()is not a point where CPython checks signals, so the gap listed in the spec cannot occur. The spec will be updated.
Evidence: offline suites stable (961 passed and the two AWS-only failures); the affected AWS suites passed at 7b77f37 (2435 passed, 14 skipped); every new test fails under its mutation.
From the independent review against the specification: - A streamed file's close drains its requests, commit, and abort also when its task is cancelled, repeatedly, and raises the cancellation after they end. The commit and abort are tracked before they are queued, so an interrupt of the queuing or a cancellation of the abort no longer leaves them untracked (F2, I1). - A call that a pool thread runs itself is tracked by the scope, whose shutdown waits for it, and runs in the captured context (S4, S5). - A place that frees passes to the oldest waiting call under the lock that submitters take (S3); the entries of cancelled waiting calls are compacted so that they do not accumulate behind a long call (S8). - A failed or interrupted parallel read drops its futures in a finally, so a second interrupt during the cleanup cannot keep their data (F5). - Files compare the process ID again: an at-fork hook registered before this module's could close an inherited file before a counter updates. - The opcode injector reads jump targets from argval, which Python 3.12 supports, and the scope and pool tests assert the bounds of S9 and P6 without notifying futures themselves or relying on a timed barrier. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| call = _S3ScopeCall( | ||
| contextvars.copy_context(), _S3WorkItem(future, fn, args, kwargs), future | ||
| ) | ||
| if self._pool.owns_current_thread(): |
There was a problem hiding this comment.
Independent review (relayed): Codex CLI (codex exec -s read-only, default model), session 01a1aac8-b428-7872-86be-272f100cc2a9. Static review of ddc302bd668b8990982656889251a631efc2c38a..80ad4f2b4641abc948978faa01ff01f0df11316c against the specification on #1332, from a detached snapshot (left clean). Coverage: pool and scope lifecycle, cancellation, interrupts, rejection, exit, fork, GC, file cleanup, defaults and callers, test helpers, tests, docs. Verdict: FINDINGS (13).
Repaired in dee243f:
- F2/I1: a cancelled
close()returned while a read request ran. The drain now waits through repeated cancellations and then raises (test_close_waits_for_cancelled_readcancels the close). - F2/I3: an abort running after a cancelled exit, and a commit started by an interrupted
submit(), were untracked. Both are now futures tracked before queuing (submit_to). New test:test_exit_cancelled_while_aborting. - S1/S3/S5: an inline call was not tracked by the scope's shutdown. It is now tracked;
test_shutdown_waits_for_call_from_pool_thread. - F4: an at-fork hook registered earlier could close an inherited file before the counter moved. Reverted to the PID comparison.
- F5: a second interrupt during the read cleanup kept the futures. They are now dropped in a
finally, and popped so no frame variable holds one. - S4: an inline call now runs in the captured context.
- S8: tombstones are compacted;
test_cancelled_waiting_calls_do_not_accumulate. Instruction.jump_targetis 3.13+; the injector usesargval, and the injection tests pass on 3.12.- The
submit_toinjection test no longer notifies the future itself. - The pool injection test now bounds overlap at
max_workers + 1(I2), and the scope test requires cancellation of an interrupted waiting call (S9). - The timed barrier test was replaced by a deterministic FIFO test; a place now passes to the oldest waiting call under the submitters' lock.
Each new or changed test fails under its mutation.
Deferred:
- 4 (T1): a garbage-collected unclosed multipart file, finalized on the only pool thread while one of its parts is queued in that pool, waits for the part. Inline execution covers new submissions only. This needs the commit to stop waiting on its own pool from a pool thread, which belongs to the fan-out primitive (step 3, B1-B6). It will be recorded on Consolidate the filesystem executors before parallel batch operations #1332.
- 10: limits of the injection model, which stay limits and go into the spec. It approximates CPython's signal checks: interrupts at a
return f()inside atryare injected before the call, and backward-jump targets also fire on their first forward entry (extra points, which only add cases).
There was a problem hiding this comment.
Independent follow-up (relayed): Codex CLI (codex exec -s read-only), session 01a1aae6-8fe1-7b22-891b-4b231270bf05, static review of 80ad4f2b..dee243f7. Verdict: FINDINGS (1 P1, 5 P2). Repaired in db9c9e0:
- P1 (F2/I1): an abort whose queuing raised left
_discard_futurepending, and the drain waited forever. It is now cancelled; new testtest_exit_interrupted_while_queuing_abort, run in a thread so that a regression fails instead of hanging. - P2 (F5): a second interrupt during the cancellation kept the first error, as its
__context__, together with the futures of the wait.clear_frames()now runs first in afinally. New testtest_read_interrupted_twice_frees_ranges: a wait that holds its own set of the futures, as CPython's does, is interrupted, then a second interrupt is injected at each point. - P2 (P6): the interrupted pool call now runs while the others overlap it. The test fails with a pool of
max_workers + 1threads. - P2 (S5): the test waits until the shutdown has begun.
- P2 (F2/I3): new test
test_close_interrupted_after_queuing_commitinterrupts after the commit has started. It fails when the commit is tracked after its queuing. - P2 (S3): accepted, and my earlier claim is corrected. The place was already handed on under one lock, and no submitter enters while a call waits, so the old code was FIFO too. Freeing the place under the same lock only tidies the code. There is never a free place while a call waits, so the concurrent-submitter rule holds by construction and cannot be raced in a test.
test_waiting_calls_enter_in_orderchecks the order of waiting calls (it fails under LIFO), and its comment no longer claims more.
Each new or changed test fails under its mutation. Offline suites: 967 passed; the 2 failures need AWS.
There was a problem hiding this comment.
Independent follow-ups 2 and 3 (relayed): Codex CLI (codex exec -s read-only), static review.
Follow-up 2: dee243f7..db9c9e0f, session 01a1aae6-… second run. Verdict FINDINGS: 2 nested-interrupt items and 3 timing-dependent tests.
- The tests were made independent of timing in 770541b.
- The nested-interrupt items led to the I1 amendment, decided by the maintainer. Guarantees hold for one interrupt at a time; under further interrupts, only "no call waits forever" holds. The amendment also compares other libraries.
- 9898f17: the streamed drain waits only for a commit or abort whose queuing returned or that has started. The docs state the guarantees.
- The
Future.cancel()nested-interrupt retention is a documented limit.
Follow-up 3: db9c9e0f..9898f176. Verdict FINDINGS. Repaired in b9763f8:
- P1 (I1/S5): a pool-rejected submission, interrupted before its cleanup cancelled it, left a pending future that the scope's shutdown and the drain waited for forever. The scope now waits only for calls that have entered the pool, wait for a place, run inline, or have started, and cancels any other. New test
test_rejected_submit_interrupted_at_each_opcode; under the mutation it fails by timeout. - Docs: the cancellation on interrupt is now narrowed to parallel range reads.
rm's DeleteObjects batches are not cancelled; that belongs to the step 3 primitive (B3).docs/aio.mdnow states the limit for an interrupt during the cleanup of an interrupted commit.
Not changed, with the reason: three test findings say a scheduling stall of 100 ms or more could let a mutant pass. They cannot make a test fail spuriously, and every test fails under its mutation in normal runs. Making them deterministic would need hooks into private state.
There was a problem hiding this comment.
Independent follow-ups 4–7 (relayed): Codex CLI (codex exec -s read-only), static reviews against the amended I1.
- Follow-up 4 (
9898f176..b9763f80): FINDINGS. Repaired in 6a6e737:- S7, no interrupt needed: a waiting call that a pool thread runs inline after the pool rejected it was untracked, so a concurrent shutdown could cancel it. It is now tracked in
_inline. New testtest_shutdown_during_hand_on_keeps_waiting_calluses a newOpcodeInterrupter(action=...)to interleave a shutdown at each point of_next(); it fails under the mutation. - I1: a cancelled future left unnotified by a nested interrupt is no longer waited for.
- S7, no interrupt needed: a waiting call that a pool thread runs inline after the pool rejected it was untracked, so a concurrent shutdown could cancel it. It is now tracked in
- Follow-up 5 (
b9763f80..6a6e7371): FINDINGS. A shutdown already waiting inconcurrent.futures.wait()still waited for such a future. Repaired in 48fef9a: the shutdown waits through done callbacks, whichcancel()runs at once. New testtest_shutdown_returns_once_calls_are_cancelled: a shutdown no longer waits for the pool to reach a cancelled call. The SIGINT test now signals once the shutdown has added its callback. - Follow-up 6 (
6a6e7371..48fef9af): FINDINGS. An interrupt inside CPython'sFuture.cancel(), between setting the state and running the callbacks, skips them. Repaired in 524b22b: the scope shutdown and the streamed drain also recheckdone()every second, as fsspec'ssync()does. New testtest_shutdown_after_interrupted_cancelinjects at each point ofFuture.cancel(); it times out under the mutation. The drain uses the same mechanism without a file-level test. - Follow-up 7 (
48fef9af..524b22b0): the repair holds; no lost wake-up beyond the recheck. The two remaining items are intended behaviour: an interrupt of the wait inclose()orshutdown()stops the wait, while running requests finish, astest_shutdown_interrupted_while_waitingchecks. f18ee10 documents this indocs/filesystem.md. The review loop ends here.
…-1332) From the follow-up review of the repairs: - An abort of a streamed file whose queuing fails or is interrupted is cancelled, so the drain of the close no longer waits for it forever. - The cleanup of a failed or interrupted parallel read clears the frames of the error first, in a finally, so a second interrupt during the cancellation, whose error keeps the first as its context, cannot keep the futures of the wait. - Tests: an interrupt after the commit has started, an abort whose queuing is interrupted, a second interrupt during the read cleanup, an interrupted pool call that runs while others overlap it, and a shutdown that has begun before the inline call ends. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pool's interrupt test waits for a third overlapping call, which only a pool exceeding its bound starts; the scope's shutdown test releases the inline call from another thread while the test thread shuts the scope down; and the commit test holds the commit until the interrupt has been raised. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A streamed file marks its commit and abort as queued once their queuing returns, and its drain cancels one that is neither queued nor started, so the close does not wait forever for one that was never queued, also when a second interrupt stops the cleanup of the first. The abort no longer needs a cancellation of its own when its queuing fails. The docs state the interrupt guarantees: the cleanup of one interrupt at a time cancels the requests that have not started and releases the data of the others; under further interrupts during that cleanup, a call still does not wait forever, but the data can stay referenced until the garbage collector frees it and a multipart upload can be left incomplete. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A submission that the pool rejected and that an interrupt then stopped before its cleanup cancelled it left a pending future that a shutdown of the scope, or the drain of a streamed file, waited for forever. The scope now waits only for calls that have entered the pool, wait for a place, run in a thread of the pool, or have started, and cancels any other, so that no placement of interrupts makes it wait forever (I1). The docs narrow the cancellation on interrupt to parallel range reads, which is where it applies, and state the limit for an interrupt during the cleanup of an interrupted commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A waiting call that the ending call's thread runs because the pool takes no more calls is tracked as an inline call, so that a concurrent shutdown of the scope waits for it instead of cancelling it (S7). The scope also leaves cancelled calls out of the calls it waits for, since they never run, also if a second interrupt stopped their notification (I1). OpcodeInterrupter can run an action at an interrupt point instead of raising, which the test uses to interleave a shutdown at each point of the hand-on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The shutdown of a scope waited with concurrent.futures.wait(), which counts a cancelled future as done only once the pool reaches its call and marks it as notified. A cancelled call kept the shutdown waiting for unrelated calls ahead of it in the pool, and forever if a further interrupt stopped that notification. The shutdown now waits through done callbacks, which a cancellation runs at once (S5, I1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An interrupt of Future.cancel() after it changes the state and before it runs the callbacks skips them, so a wait that relies only on done callbacks never ends. The shutdown of a scope and the drain of a streamed file now also check the states of the futures every second, as fsspec's sync() does, and end once all of them are done (I1, S5). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WHAT
Each filesystem has one request pool, which its files and parallel operations share:
S3FileSystemowns a request pool ofmax_workersthreads.max_workersnow defaults to themax_pool_connectionsof the S3 client (10 unless configured, or that of the connection's client), instead ofcpu_count() * 5. A client that the filesystem builds takesmax_pool_connectionsfrom an explicitmax_workersunlessconfig_kwargssets it.S3FileSystem._create_executor(max_workers)returns a scope on that pool instead of a new pool. At mostmax_workerscalls of a scope are in the pool at once; the others wait in the scope, in submission order, with the context variables of their submitter, A call counts towardmax_workersonly once the pool has accepted it, so an interrupt of the submitter at any point leaves no place taken and none freed twice; an interrupt cancels the call unless it has started, and each interrupt after the pool accepted a call that has started can let one more call run at once. A call that ends lets the next waiting calls enter from its pool thread, before it ends there, so a release of the pool waits for them; once the pool takes no more calls, as at interpreter exit, that thread runs them itself. Calls cancelled while they wait never enter the pool. Shutting a scope down waits only for its own calls, so a file that is closed drains its own requests, also inside a transaction, and leaves the pool to the others. The files ofopen(),rm, recursivechmod,clear_multipart_uploads, and multipart copies take their scopes from it, so themax_workersof a file or call still bounds its requests, and the pool bounds all of them together.S3FileSystem.close()and itswithblock release the idle threads of the request pool; the filesystem stays usable, as withAioS3FileSystem.AioS3FileSystem:S3FileSystem, so itsmax_workersalso defaults to the client's connection pool (it used to default tocpu_count() * 5).max_workersat once), and the DeleteObjects batches of_rm()(at most the filesystem'smax_workers, asS3FileSystem.rm()sends, so that a large deletion does not queue ahead of the files' requests). So do the range reads and parts ofopen_async()files, at most theirmax_workersat once.close()also releases the request pool.S3Executorgainsrun(),run_shielded(), andwait_shielded(), moved fromS3ThreadPool; they use onlysubmit().S3File,AioS3File,AioS3StreamedFile) raisesRuntimeErrorfrom its reads, writes, flushes,commit(), anddiscard(). Closing it, also at garbage collection, only marks it closed, without flushing, committing, or aborting, since the upload belongs to the parent.S3FileandAioS3StreamedFilebuilt directly takemax_workersfrom the filesystem (max_workers=None), and anS3Filewithout an executor takes a scope of the request pool instead of a pool of its own.s3_max_workers(pandas and Polars, sync, async, and aio) defaults toNone, so their result sets' filesystems size the request pool from the client's connection pool.AioS3StreamedFile.close(), and leaving itsasync withblock, wait for its range reads, commit, and abort also when the task is cancelled, any number of times, and raise the cancellation after they end; they used to return while a read request or an abort still ran.docs/filesystem.mdanddocs/aio.mdstate the interrupt guarantees. They hold for one interrupt at a time. If a further interrupt arrives during a cleanup, calls still do not wait forever, but data can stay referenced until garbage collection and a multipart upload can be left incomplete (I1 amendment).docs/filesystem.md,docs/aio.md, the benchmark notes, and the docstrings describe the request pool, the sizing,close(), and the files inherited across a fork.Release note (behavior changes):
The default
max_workersofS3FileSystemandAioS3FileSystemis themax_pool_connectionsof the S3 client (10 by default) instead ofcpu_count() * 5, and a client that the filesystem builds getsmax_pool_connections = max_workerswhenmax_workersis given andconfig_kwargsdoes not set it.The files and operations of a filesystem share its request pool of
max_workersthreads: concurrent files and operations together run at most that many requests, where each used to have threads of its own. With the defaults, a single large read or write runs 10 requests at once instead ofcpu_count() * 5;docs/filesystem.mdsays how to raisemax_workersand the client'smax_pool_connections.S3FileSystemhasclose()and supportswith.In a forked child process, files opened in the parent raise
RuntimeErrorwhen used, and closing them does nothing else; before, a write there could commit or abort the parent's upload, and a file with requests in flight at the fork hung.The default
s3_max_workersof the pandas and Polars cursors isNoneinstead ofcpu_count() * 5: their result sets read with the client's connection pool size (10 by default) unless it is given.S3FileandAioS3StreamedFileconstructed directly default to the filesystem'smax_workers, and anS3Filewithoutexecutorshares the filesystem's request pool.A cancelled
AioS3StreamedFile.close()waits for its requests, commit, and abort before it raisesCancelledError.WHY
Part of #1332: step 2b of the implementation decisions, decisions 2, 3, and 6 of the design, after the pool types were merged in #1357.
Pools per file and per call left the threads unbounded across concurrent files, and
max_workerswas not reconciled with the client's connection pool of 10 (#1322 question 5). One request pool per filesystem bounds them, and the scopes keep the per-file and per-call bounds that callers rely on (GH-954). It also gives the batch operations of #1322 (step 4) one pool to fan out into. The rejection of files inherited across a fork also covers a write session with parts in flight at the fork, which the review of #1353 found to hang.TEST
Tested commit: f18ee10.
The design changes after ddc302b follow the specification, its decisions, its update, and the I1 amendment.
just lintandjust docs lintpassed.uv run --env-file .env pytest -n 8 tests/pyathena/filesystem/ tests/pyathena/s3fs/ tests/pyathena/pandas/ tests/pyathena/polars/ tests/pyathena/aio/, including the AWS integration tests: 2449 passed, 14 skipped at f18ee10. An earlier run at f18ee10 had 4 errors from an expired local credential refresh, and one at 48fef9a failedtest_info_file, which compares the local clock with S3'sLastModifiedand passed in three reruns.--noconftest: the unit tests oftest_s3_executor.py,test_s3.py,test_s3_async.py,test_s3_writer.py, and the s3fs and pandas result sets pass (971 passed). The only failures are 2 pandas tests that need AWS. The interrupt-injection tests also pass on Python 3.12.tests/pyathena/util.py:OpcodeInterrupter,interrupt_each_opcode) raisesKeyboardInterruptat each point where CPython checks for signals inS3ThreadPool.submit(),submit_to(),_S3PoolScope.submit()(entering and waiting),S3File._fetch_range(), and CPython'sFuture.cancel(). A second interrupt is also tested in the read cleanup, after a first interrupt of the wait.OpcodeInterrupter(action=...)interleaves a shutdown at each point of the scope's hand-on. After each interrupt the tests check the outcome, the bounds, and that the data is freed. Real-SIGINT tests usesigint_when().TestS3PoolScope:test_window,test_waiting_call_context,test_shutdown_waits_for_own_calls,test_cancel_waiting_call,test_interrupted_submit,test_interrupted_submit_after_acceptance,test_waiting_calls_run_after_pool_shutdown,test_release_waits_for_waiting_calls,test_waiting_calls_enter_in_order,test_shutdown_waits_for_call_from_pool_thread,test_cancelled_waiting_call_frees_arguments,test_cancelled_waiting_calls_do_not_accumulate,test_shutdown_interrupted_while_waiting,test_submit_from_pool_thread,test_waiting_call_rejected_by_pool,test_submit_interrupted_at_each_opcode,test_rejected_submit_interrupted_at_each_opcode,test_shutdown_during_hand_on_keeps_waiting_call,test_shutdown_returns_once_calls_are_cancelled,test_shutdown_after_interrupted_cancel;TestS3ThreadPool:test_submit_interrupted_at_each_opcode,test_submit_to_interrupted_at_each_opcode.TestS3FileSystem:test_request_pool_size,test_request_pool_size_of_connection,test_files_share_request_pool,test_file_without_executor_shares_request_pool,test_close_releases_threads,test_file_inherited_across_fork,test_read_interrupted_at_each_opcode_frees_ranges,test_read_interrupted_by_signal_frees_ranges,test_read_interrupted_twice_frees_ranges.TestAioS3FileSystem::test_close_releases_request_threads;TestAioS3StreamedFile:test_file_inherited_across_fork,test_close_waits_for_cancelled_read(also cancels the close),test_exit_cancelled_while_aborting,test_close_interrupted_after_queuing_commit,test_exit_interrupted_while_queuing_abort,test_default_max_workers.TestAthenaPandasResultSetMetadataReader::test_filesystem_request_pool_size;test_s3_max_workers_defaultfor the pandas and Polars cursors.submit_to. The mock connections carry amax_pool_connections(mock_connection()).test_copy_object_with_multipart_upload_interrupted_creationusessigint_when(), with the same assertions.Deferred (recorded on #1332):
S3ThreadPool.submit()between tracking a future and adding its callback keeps the future in the pool's set until another release or shutdown. This is within I2.Future.cancel()during the read cleanup can keep one future's data until the traceback is dropped. This is a documented limit.test_shutdown_after_interrupted_canceltests the same mechanism in the scope.Not run:
just benchmark testlocally (Three test_fleet.py tests fail locally while passing in CI #1354 fixed the local failures; the Benchmark tooling workflow runs it).🤖 Generated with Claude Code