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
33 changes: 33 additions & 0 deletions docs/filesystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,39 @@ In a forked child process, create a new filesystem. A file that the parent opene
`RuntimeError` there from its reads, writes, and flushes, `commit()`, and `discard()`,
and closing it only marks it closed, without uploading or aborting anything.

Close a file returned by `open` explicitly or with a `with` block. A file that is not
closed is closed when it is finalized, which commits its written data unless
`autocommit` is False:

- When it is finalized during a pass of the cyclic garbage collector, it is closed in a
new thread that starts once the collection has ended. When it is finalized in a
thread of an `S3ThreadPool`, such as the request pool of a filesystem, it is closed
in a new thread. The interpreter waits for these closes when it exits. If no thread
can be started, the file is left unclosed and its multipart upload incomplete.
- When its last reference is dropped elsewhere, it is closed before that returns.
- During interpreter finalization, or after the exit has waited for the closes in new
threads, it is closed in the finalizing thread instead of a new thread. The request
pools take no more calls then, so a file with data left to upload as a part is not
committed, and its multipart upload is aborted.

If the garbage collector finalizes the write buffer of a file before the file, the data
of the file is not committed, and its multipart upload is aborted.

`close()` called during a pass of the cyclic garbage collector, such as by another
finalizer, closes the file in the same way, in a new thread that starts once the
collection has ended, and returns before the file is closed. A later `close()`,
`commit()`, or `discard()` of the file waits for that close to end. During a collection
or in a thread of an `S3ThreadPool`, `close()` returns without waiting instead, and
`commit()` and `discard()` raise `RuntimeError`. If no thread could be started for that
close, a later `close()`, `commit()`, or `discard()` of the file elsewhere closes it
first.

A file opened in text mode is closed by its text wrapper when the wrapper is finalized.

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.

Reviewer finding 3 (P1, pre-existing at the base): text-wrapper finalization in a pool thread can still deadlock. If the last reference to a TextIOWrapper is dropped in the only request-pool thread outside a collection while a part of its file is queued behind that thread, the wrapper calls S3File.close() inline, which does not check in_pool_thread().

Verification: confirmed as a code path, present at d03e034. Deferred, with reason: close() cannot hand over every call in a pool thread, since the batch operations (pipe, put) open and close their files in pool threads and must commit before returning; their parts run inline there (T1), so they do not wait. The path needs a pool call that holds the last reference to a user's text wrapper, which no PyAthena call does; a collection in a pool thread is handed over. The docs paragraph here now states that the wrapper closes the file in the thread that finalizes it, outside a collection.

Other reviewer conclusions, with dispositions:

  • The collection flag is thread-local and not nesting-counted: no change; CPython does not run a collection inside another, or two at once (gcstate->collecting).
  • Fork does not reset the thread-local markers: no change; no scenario found (a file inherited by a child is only marked closed).
  • Exceptions of a synchronous finalizer are not contained, as at the base: no change. Thread creation failures other than RuntimeError are now also contained, so that a queued close that cannot start is never waited for forever.
  • Shutdown behavior was not verifiable statically: exit scripts with a stubbed client on CPython 3.11, 3.12, 3.13, 3.14, and 3.15 at 5c0cbe7 all exit; a file dropped by a pool thread during threading._shutdown and a file collected at module teardown with no part left to upload are committed, and one with a part left to upload is not committed and its upload is aborted.
  • test_finalized_unclosed_file_without_thread[True] checks branch selection, not real interpreter finalization: agreed; the exit scripts are the evidence for that, as stated in the PR body.
  • The log-text assertions: kept, since the warning is the only signal to the caller that the data was not committed.

The wrapper writes its remaining text to the file in the thread that finalizes it, and
then closes the file there, or as above during a collection. That write waits as any
write does, so in a thread of an `S3ThreadPool` it can wait forever for a part of the
file that is queued behind that thread.

An interrupt, such as Ctrl+C, or a failure while a file reads ranges in parallel
cancels the range reads that have not started, and the running ones make no further
attempt. The interrupt or the error is raised once they have ended, and their data is
Expand Down
274 changes: 268 additions & 6 deletions pyathena/filesystem/s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@

from __future__ import annotations

import _thread
import atexit
import contextlib
import contextvars
import functools
import gc
import hashlib
import logging
import mimetypes
Expand Down Expand Up @@ -79,6 +83,15 @@
"client=connection.s3_client and retry_config=connection.retry_config."
)

# Whether the cyclic garbage collector runs in a thread, and the closes of
# files that it handed over (S3File._track_collection).
_collecting = threading.local()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rebase onto 758adee (after #1378 and #1394), head e5eabc4e69c5b00c5c3e3b7f8209867276322b26, base 758adee9; previous head d7e926057384566f0567f9679121a445658133e5 on d03e034f947b9ab6ad5696ec2bd942f8a63e7b5d.


# The ends of the closes that run in threads of the _thread module, which an
# exit waits for (S3File._wait_for_handed_over_closes), and whether it has.
_handed_over_closes: set[threading.Event] = set()
_handed_over_closes_waited = False

# The request parameters on which the authorization of a lookup (HeadObject,
# HeadBucket, or ListObjectsV2) depends.
_LOOKUP_REQUEST_PARAMETERS = frozenset(
Expand Down Expand Up @@ -4310,17 +4323,245 @@ def close(self) -> None:

In a forked child process, a file that the parent opened is only
marked closed: its data is neither flushed nor committed, and its
upload is not aborted, since they belong to the parent.
upload is not aborted, since they belong to the parent. Called by
another finalizer while the cyclic garbage collector runs in this
thread, such as that of a ``TextIOWrapper``, the file is closed in a
new thread once the collection has ended (see :meth:`__del__`), and
this returns before that. A close handed over to another thread is
waited for, except during a collection or in a thread of an
:class:`S3ThreadPool`, where this returns without waiting.
"""
if self._inherited():
self.closed = True
return
if not self.closed and S3File._in_collection():
self._close_later()
return
if self._wait_for_close_later():
self._close()

def _close(self) -> None:
"""Close the file in this thread, flushing any written data, and shut down its executor."""
try:
super().close()
finally:
# The executor is shut down even if the final flush fails.
self._executor.shutdown()

def __del__(self) -> None:
"""Close the file when it is finalized without having been closed.

A file whose last reference is dropped in a user thread is closed
here. A file that the cyclic garbage collector finalizes, or that is
finalized in a thread of an :class:`S3ThreadPool`, is closed in a new
thread instead, since a wait here could last forever (T1): the
requests of the file may be queued behind this thread or include the
one that runs the collection, and this thread may hold a lock that
the close takes. During a collection, the thread starts once the
collection has ended, after the other finalizers of the collection,
such as that of a ``TextIOWrapper``, have written to the file.
"""
if getattr(self, "closed", True) or self._inherited():
# The upload of a file inherited across a fork is the parent's.
return
if S3ThreadPool.in_pool_thread():

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review round 1 (behavior and implementation): CLEAN

Base 68fc3befd2ea9595d7df0effe462d845f7500351, head 63bede7dd1ab0d9610055abdc42cc410ba14952b.

Covered: S3File.close() / __del__ / _close_later() / _close_collected() / _track_collection() / _in_collection(), the gc.callbacks registration, S3ThreadPool.in_pool_thread(), AioS3File (inherits S3File unchanged; AioS3StreamedFile keeps its own finalizer), the new tests, and docs/filesystem.md.

Checked:

  • Only finalizers can call close() while the collection flag is set. The flag is per thread, set by the start phase and cleared by the stop phase, both of which CPython runs in the collecting thread. Explicit closes in normal code, including with blocks and the task-pool threads of put/pipe, are unchanged.
  • Objects freed by refcount while a collection runs, such as a wrapper held only by a cycle, are covered by the same flag.
  • Resurrection: the finalizer runs once per object, both in a collection and for a refcount drop, because Python classes are GC-tracked. The handed-over thread holds the file until it ends.
  • Buffer: getvalue() followed by BytesIO(bytes) shares the bytes, so at most one copy is made. A closed buffer leads to _close_without_commit() (discard and abort), and a read file has no buffer.
  • Partially initialized files: closed defaults to True through getattr, as before. Inherited files return without a request.
  • Interpreter shutdown: if Thread.start raises, it raises RuntimeError or PythonFinalizationError, a subclass, and the file is left unclosed.
  • Mutation check: all 5 guards are killed by the 8 tests.

Pre-existing and out of scope, recorded in the #1364 design comment: a file collected together with its filesystem and pool, a last reference dropped in a thread of an executor passed with executor=, and a text wrapper's flush during a collection.

self._close_later()
else:
# Hands the close over itself during a collection.
self.close()

@staticmethod
def _track_collection(phase: str, info: dict[str, int]) -> None:
"""Record whether the cyclic garbage collector runs in this thread.

Registered in ``gc.callbacks``, which the collector calls in the
thread that runs it. When the collection stops, the closes that it
handed over start.

Args:
phase: ``"start"`` or ``"stop"``.
info: The details of the collection, which are not used.
"""
_collecting.active = phase == "start"
pending = getattr(_collecting, "pending", None)
if not _collecting.active and pending:
_collecting.pending = []
for file, complete in pending:

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.

Reviewer conclusion (docs): "closed in a new thread after the finalization returns" is not established, since Thread.start() gives no ordering, and the text-mode case of test_finalized_unclosed_file passes only because the helper holds the close until the collection returned, a synchronization that production lacks.

Verification: confirmed, and it was a defect, not only a wording issue. A trace at 2cc12e8 without the hold shows that the collector runs the finalizer of the S3File before that of its TextIOWrapper: the new thread started and flushed while the wrapper was still writing its text into the file in the collecting thread.

Repair (5c0cbe7): a close handed over during a collection is queued per thread and started by _track_collection when the collection stops (here), after every finalizer of the collection. The helper no longer holds the close, and it records whether each close was started inside a collection. Starting immediately again fails 6 of the 13 finalization tests; not starting the queued closes fails 10. A production-ordering script (no patches) now commits the text of a collected text-mode file in 30 of 30 runs. docs/filesystem.md now says that the thread starts once the collection has ended, and makes no ordering claim for pool threads.

file._start_close(complete)

@staticmethod
def _in_collection() -> bool:
"""Whether the cyclic garbage collector runs in this thread.

Returns:
True while a collection runs in the calling thread.
"""
return getattr(_collecting, "active", False)

def _close_later(self) -> None:
"""Close the file in a new thread, once.

The written data is copied first: the collector finalizes every
object that it found unreachable before it checks whether a
finalizer has made it reachable again, and finalizing the buffer
closes it. If the buffer is already closed, the data is incomplete,
so the write is discarded instead of committed. During a collection,
the thread starts once the collection has ended, so that the other
finalizers of the collection can still write to the file.
"""
if getattr(self, "_closing_later", False):
return
self._closing_later = True
self._closed_later = threading.Event()
complete = True
if self.mode != "rb" and self.buffer is not None:
if self.buffer.closed:
complete = False
else:
buffer = BytesIO(self.buffer.getvalue())
buffer.seek(self.buffer.tell())
self.buffer = buffer
_handed_over_closes.add(self._closed_later)
if S3File._in_collection():
if not hasattr(_collecting, "pending"):
_collecting.pending = []
_collecting.pending.append((self, complete))
else:
self._start_close(complete)

def _start_close(self, complete: bool) -> None:
"""Start the close that :meth:`_close_later` handed over.

The file is closed in a thread of the ``_thread`` module, since
``Thread.start()`` takes locks of the ``threading`` module, which this
thread, in which a finalizer or the collector runs, may hold, and
waits for the new thread, which may take another. The interpreter
does not join such a thread, so an exit waits for the close instead
(see :meth:`_wait_for_handed_over_closes`). The thread runs with an
empty context, so that the stop scopes of the finalizing thread do
not stop the requests of the file. During interpreter finalization,
and once the exit has waited for the handed-over closes, the file is
closed in this thread without starting one, since no thread started
during finalization runs, and none started afterwards is waited for.
No other thread runs requests then, and the pools take no more
calls, so the close does not wait for a part queued behind it. If no
thread can be started otherwise, the file is left unclosed rather
than closed in this thread, where it could wait forever, until a
later :meth:`close`, :meth:`commit`, or :meth:`discard` closes it
(see :meth:`_wait_for_close_later`).

Args:
complete: Whether the written data is complete.
"""
if self._inherited():
# Handed over in the parent, which forked before the collection

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.

Finding 4 (P2): closes queued in a collection and inherited across a fork bypass the process guard. A fork between the hand-over and the end of the collection would run the parent's close in the child, where _close() shuts down an inherited scope that can wait forever.

Verification: confirmed as a code path; it needs a fork from a finalizer or a gc callback. Repair (00c894f), here: _start_close only marks an inherited file closed, and os.register_at_fork clears the set of closes that an exit waits for in the child. No test: it needs a fork during a collection.

Other reviewer conclusions: the log-text assertions are kept, since the warning is the only signal that the data was not committed; test_finalized_unclosed_file_without_thread[True] checks branch selection, and the exit scripts are the evidence for real finalization; the pre-existing text-wrapper path in a pool thread outside a collection stays as recorded in the first review.

# ended. Its event, whose lock the fork may have copied held, is

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.

Finding 2 (P2): a forked child can block on an inherited event lock. If the collecting thread forks while another thread holds the condition lock of the event of a handed-over close, _start_close in the child set that event and could block forever.

Verification: confirmed as a code path (a fork from a finalizer or a gc callback). Repair (here): for an inherited file, _start_close only marks it closed and leaves the event alone; no thread of the parent waits in the child, a later close() returns at the fork check, and commit()/discard() raise there. No test, as before.

Finding 3 (P2): the exit waited for one snapshot of the handed-over closes. A close handed over while the exit waits, such as by a collection in a closing thread, was not waited for. Verification: confirmed. Repair (here): _wait_for_handed_over_closes waits until the set is empty, and _end_close_later removes a close before it sets its event. Not covered by a unit test; the exit scripts cover the single-close case.

# left alone.
self.closed = True
return
if sys.is_finalizing() or _handed_over_closes_waited:
self._close_collected(complete)
return
try:
_thread.start_new_thread(contextvars.Context().run, (self._close_collected, complete))

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.

Finding 3 (P1): starting a thread inside the gc callback can deadlock on Python 3.11/3.12. A collection can run while the interrupted code holds threading._shutdown_locks_lock; Thread.start() waits for the new thread, whose bootstrap takes that lock.

Verification: confirmed by reading threading.py of 3.11 and 3.12 (_set_tstate_lock takes _shutdown_locks_lock before _started.set(), and _maintain_shutdown_locks allocates under it). Every version also takes the non-reentrant _active_limbo_lock in Thread.start(), which threading.enumerate() holds while it allocates lists. Not reproduced at runtime.

Repair (00c894f), here: a handed-over close runs in a thread of the _thread module, whose start takes no lock of threading and does not wait for the new thread. The interpreter does not join such threads, so an atexit handler waits for the handed-over closes (_wait_for_handed_over_closes). A first variant that started a threading.Thread from a _thread trampoline lost the close of a file dropped by a pool thread during threading._shutdown on 3.11, 3.12, and 3.14 in exit scripts, since the join of non-daemon threads ended before the trampoline started the thread; it was not committed. With the atexit wait, exit scripts on 3.11, 3.12, 3.13, 3.14, and 3.15 all commit that file, and removing the atexit registration loses it again on 3.11. The test helper now records any threading.Thread.start() by a finalizing thread, except a worker of the request pool that the wrapper of a text-mode file starts when it writes; starting the close with threading.Thread fails 7 tests.

except Exception:
# Such as RuntimeError, when no more threads can be started.
_logger.warning(
f"s3://{self.bucket}/{self.key} was not closed, and no thread could be "
"started to close it when it was finalized; its data was not committed."
)
self._unstarted_close: bool | None = complete
_handed_over_closes.discard(self._closed_later)
# Wakes the callers that wait for it, which then close the file.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent follow-up review 3 (relayed): Codex CLI 0.166.0, model gpt-6-astra, reasoning effort high, sandbox read-only, session 01a1d0fe-c25a-7011-84b6-9b6a98cea1fa. Static review only, of a clean snapshot of 4b63a6634f2746373523a74e6fc2c100b2d41a72 against base d03e034f947b9ab6ad5696ec2bd942f8a63e7b5d (full diff), without the PR number, description, commit messages, or prior findings. Result: FINDINGS (4, all P2). Covered: the finalization and waiting methods, buffer preservation, write-session completion and abort, scopes, collection ordering, thread-local state, fork hooks, explicit close, with, transactions, autocommit=False, text wrappers, reads, both aio file types, shutdown ordering, thread creation, docs, and all added tests.

Finding 2: a failed start strands a caller that already waits. A caller that started waiting before the start of the handed-over close failed was never woken, since the failure recorded the close for a later call without setting the event. Verification: confirmed. Repair (aac7cf8, here): the failure sets the event, and a woken caller runs the close where it can wait; during a collection or in a pool thread, close() returns and commit()/discard() raise as before. test_close_handed_over_not_started[commit-True] starts the caller before the failure; [close in pool-False] checks that a pool thread leaves the file open and commit() raises there. Not waking the caller fails 4 tests; running the close in a pool thread fails 1.

self._closed_later.set()

def _close_collected(self, complete: bool) -> None:
"""Close a finalized file, logging a failure instead of raising it.

Args:
complete: Whether the written data is complete. If not, the
write is discarded instead of committed.
"""
self._closer_ident = threading.get_ident()
try:
if complete:
self._close()
else:
_logger.warning(
f"s3://{self.bucket}/{self.key} was not closed, and its buffer was "
"finalized before it; its data was not committed."
)
self._close_without_commit()
if getattr(self, "_discard_later", False):
self.discard()
except Exception:
_logger.exception(f"Failed to close s3://{self.bucket}/{self.key}.")
finally:
self._end_close_later()

def _end_close_later(self) -> None:
"""Mark the close that :meth:`_close_later` handed over as ended."""
_handed_over_closes.discard(self._closed_later)
self._closed_later.set()

@staticmethod
def _wait_for_handed_over_closes() -> None:
"""Wait for the closes that run in threads of the ``_thread`` module.

Registered with :mod:`atexit`, since the interpreter does not join
these threads when it exits. Also waits for the closes that are
handed over while it waits; later ones, such as in an exit handler
that runs after it, are closed in the finalizing thread.
"""
global _handed_over_closes_waited
while _handed_over_closes:
for closed in list(_handed_over_closes):
closed.wait()
_handed_over_closes_waited = True

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.

Finding 3: the exit misses closes handed over by exit handlers that run after its wait. An atexit handler registered before PyAthena is imported runs after the wait, so a close that it hands over started a thread that nothing waited for. Verification: confirmed. Repair (here): once the exit has waited, a handed-over close is run in the finalizing thread, as during interpreter finalization (the pools take no more calls by then). test_finalized_unclosed_file_without_thread[exiting] runs the wait, then collects a file and checks that the collecting thread commits it; removing the flag or the check fails it. An exit script on 3.11 and 3.15 with such a handler commits the file before the handler returns.


def _wait_for_close_later(self) -> bool:

@laughingman7743 laughingman7743 Nov 12, 2026 •

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 of the repairs after the independent reviews (2cc12e8f..d7e92605; base d03e034f947b9ab6ad5696ec2bd942f8a63e7b5d, head d7e926057384566f0567f9679121a445658133e5), both perspectives, run after each repair commit (5c0cbe7, 00c894f, 4b63a66, aac7cf8, d7e9260) and recorded together.

Round 1, behavior: FINDINGS, each repaired in the commit that followed.

  • Every state of a handed-over close and the calls that meet it: queued in a collection (started by the stop callback, never in the collecting frame); running in a _thread thread (callers outside a collection and the pool threads wait; inside, close() returns and commit()/discard() raise; the closing thread itself never waits, through _closer_ident); ended (event set, removed from the exit set before it is set); not started (event set to wake waiters, _unstarted_close run by the next safe caller, removed from the exit set); inherited across a fork (only marked closed); after the exit wait or during finalization (run inline).
  • Found in self-review, not by the reviewers: a test that replaced the event left four events that the atexit wait waited for, which made each test process exit about 9 s late (repaired in 4b63a66, with a fixture that fails a test whose close does not end); the event was registered for the exit before the buffer copy, so an exception in the copy would have left the exit waiting forever (moved after the copy); a _thread trampoline that started a threading.Thread lost the close of a file dropped by a pool thread during threading._shutdown on 3.11, 3.12, and 3.14 (replaced by closing in the _thread thread with the atexit wait, 00c894f); a thread-creation exception other than RuntimeError in the stop callback would have skipped the remaining queued closes (contained per file).
  • Tests: -k "finalized_unclosed or handed_over": 21 passed twice before aac7cf8 was committed, and once at d7e9260. Mutation check: 29 mutants at aac7cf8 plus one at d7e9260 (close() raising when it cannot wait), each fails at least one test; the fork guard of a queued close and the repeated pass of the exit wait are not covered by a test.

Round 2, claims and operations: CLEAN after the docs changes in each repair.

  • Callers: close() during a collection now returns before the file is closed, and commit()/discard() of a file whose handed-over close runs raise RuntimeError during a collection or in a pool thread; both are in the docs and the release note. Outside a collection and the pool threads, a with block and an explicit close() are unchanged.
  • Operations: an exit waits for every handed-over close that runs, as it waited for the non-daemon thread of the first design; closes handed over after that wait run in the finalizing thread. Exit scripts with a stubbed client on CPython 3.11–3.15 at aac7cf8 (no code change since): a file dropped by a pool thread during threading._shutdown, files collected at teardown with no part left, a file collected with its filesystem and pool, and a file collected by an exit handler registered before PyAthena are committed; a file collected at teardown with a part left is aborted. Without the atexit wait, the first is lost on 3.11. A script without patches commits the text of a collected text-mode file in 30 of 30 runs.
  • tests/pyathena/filesystem/ with S3 integration: 1652 passed, 6 skipped at aac7cf8; d7e9260 changes only one test and the docs, and its finalization tests pass.
  • Claims: each docs sentence of the finalization section was checked against the code paths above; the Python-version claims (3.11 Thread.start() waits forever during finalization; 3.12 and later raise; _shutdown_locks_lock on 3.11/3.12; _active_limbo_lock on every version) come from the exit scripts and the threading sources of each version.

"""Wait for a close that :meth:`_close_later` handed over to end.

It is not waited for in the thread that runs it, during a
collection, whose end starts it, or in a thread of an
:class:`S3ThreadPool`, since it may wait for a request queued behind
that thread. A close for which no thread could be started is run
here instead, where it can be waited for.

Returns:
False if a handed-over close has not ended and cannot be waited
for in this thread; otherwise True.
"""
closed_later = getattr(self, "_closed_later", None)
if closed_later is None or getattr(self, "_closer_ident", None) == threading.get_ident():
return True
unsafe = S3File._in_collection() or S3ThreadPool.in_pool_thread()
if not closed_later.is_set():
if unsafe:
return False
closed_later.wait()
if getattr(self, "_unstarted_close", None) is not None:
if unsafe:
return False
unstarted, self._unstarted_close = self._unstarted_close, 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.

Independent follow-up review 4 (relayed): Codex CLI 0.166.0, model gpt-6-astra, reasoning effort high, sandbox read-only, session 01a1d138-c23b-7252-bcf8-2190012cc9d2. Static review only, of a clean snapshot of aac7cf8385a8a2c765fe48563f45117895ccebf8 against base d03e034f947b9ab6ad5696ec2bd942f8a63e7b5d (full diff), without the PR number, description, commit messages, or prior findings. Result: FINDINGS (3). Covered: the finalization, hand-over, waiting, commit/discard, and collection-tracking methods, S3ThreadPool, _S3PoolScope, the write session, fsspec's __del__/close/flush/text-wrapper paths, explicit close, transactions, read mode, both aio file types, fork guards, the 3.11–3.15 shutdown paths, docs, and every added test.

Finding 1 (P1): a fallback close is advertised as finished before it runs. After a failed start, caller A claims the close of the file and runs it, while caller B's commit() sees the event set and no unstarted close, and completes the upload with the sent parts only.

Rejected, with reason: the scenario needs two threads that call close() and commit() of the same file at once. PyAthena's threadsafety is 2, and a file object is not safe for concurrent use from several threads, as in fsspec; the PR keeps that contract rather than adding a lock for it. With one caller, as in test_close_handed_over_not_started, the close ends before the call returns. The waits added in this PR serve the thread that PyAthena itself starts for the close, not concurrent callers.

self._close_collected(cast(bool, unstarted))
return True

def _raise_unless_closed_later(self) -> None:
"""Wait for a handed-over close to end, or raise if it cannot be waited for here.

Raises:
RuntimeError: If a close of the file was handed over to another
thread and runs, and this is called during a collection or
in a thread of an :class:`S3ThreadPool`.
"""
if not self._wait_for_close_later():
raise RuntimeError(
f"s3://{self.bucket}/{self.key} is being closed in another thread, which "
"cannot be waited for during a garbage collection or in a thread of an "
"S3ThreadPool."
)

def read(self, length: int = -1) -> bytes:
"""Read bytes from the file.

Expand Down Expand Up @@ -4422,7 +4663,12 @@ def _close_without_commit(self) -> None:
self.buffer = None
self.closed = True
try:
self.discard()
if self._wait_for_close_later():
self.discard()
else:
# The handed-over close, which cannot be waited for here,

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.

Finding 1: a write that fails during the collection loses its abort. After the finalizer of the file hands over its close, a text wrapper writes; the write finds a failed part and calls _close_without_commit(), whose discard() the collection refused, so the abort was only logged, and the handed-over close then saw a closed file. Verification: confirmed. Repair (here): when the discard cannot wait for the handed-over close, _close_without_commit() leaves it to that close, which discards the write once it ends. test_close_handed_over_failed_write_is_discarded fails a part, writes from a finalizer after the hand-over, and checks one abort and no completion; not deferring, or not running the deferred discard, each fails it.

# discards the write once it ends.
self._discard_later = True
except Exception:
upload_id = cast(S3MultipartUpload, self.multipart_upload).upload_id
_logger.exception(
Expand Down Expand Up @@ -4575,17 +4821,21 @@ def commit(self) -> None:
if the abort also fails, the upload is kept so that :meth:`discard`
can abort it. Committing again after a successful commit sends no
request. Invalidates the cache of the path afterwards. Does nothing
for a file opened for reading.
for a file opened for reading. Waits first for a close of the file
that was handed over to another thread (see :meth:`close`).

Raises:
FileExistsError: If an object was created at the path after the
file was opened in exclusive-create mode.
RuntimeError: In a forked child process of the process that
opened the file.
opened the file, or if a close of the file that was handed
over to another thread runs and this is called during a
garbage collection or in a thread of an :class:`S3ThreadPool`.
S3MultipartStateError: If the write was discarded, or the write
or an earlier commit failed.
"""
self._check_process()
self._raise_unless_closed_later()
if self._session is None:
# A file opened for reading has nothing to commit.
return
Expand Down Expand Up @@ -4617,13 +4867,18 @@ def discard(self) -> None:
interrupted, the upload is kept, and calling this again retries the
abort. Afterwards, :meth:`commit` raises
:class:`~pyathena.filesystem.s3_writer.S3MultipartStateError`. Does
nothing for a committed file or a file opened for reading.
nothing for a committed file or a file opened for reading. Waits
first for a close of the file that was handed over to another thread
(see :meth:`close`).

Raises:
RuntimeError: In a forked child process of the process that
opened the file.
opened the file, or if a close of the file that was handed
over to another thread runs and this is called during a
garbage collection or in a thread of an :class:`S3ThreadPool`.
"""
self._check_process()
self._raise_unless_closed_later()
if self._session is not None:
self._session.abort()

Expand Down Expand Up @@ -4823,3 +5078,10 @@ def _get_ranges(
else:
ranges.append((start, end))
return ranges


gc.callbacks.append(S3File._track_collection)
atexit.register(S3File._wait_for_handed_over_closes)
if hasattr(os, "register_at_fork"):
# The closes of the parent do not run in a child.
os.register_at_fork(after_in_child=_handed_over_closes.clear)
Loading
Loading