Repository navigation
Close an unclosed S3File outside the pools when it is finalized (GH-1364) #1396
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
543b88c
fcdd202
35db977
6a4a5bb
01b49c3
aba5b65
8ac2ef1
e5eabc4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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() | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Rebase onto 758adee (after #1378 and #1394), head
|
||
|
|
||
| # 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( | ||
|
|
@@ -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(): | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Self-review round 1 (behavior and implementation): CLEAN Base Covered: Checked:
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 |
||
| 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: | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 Repair (5c0cbe7): a close handed over during a collection is queued per thread and started by |
||
| 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 | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Verification: confirmed as a code path; it needs a fork from a finalizer or a Other reviewer conclusions: the log-text assertions are kept, since the warning is the only signal that the data was not committed; |
||
| # ended. Its event, whose lock the fork may have copied held, is | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, Verification: confirmed as a code path (a fork from a finalizer or a 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): |
||
| # 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)) | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Finding 3 (P1): starting a thread inside the Verification: confirmed by reading Repair (00c894f), here: a handed-over close runs in a thread of the |
||
| 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. | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Independent follow-up review 3 (relayed): Codex CLI 0.166.0, model 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, |
||
| 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 | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
|
||
| def _wait_for_close_later(self) -> bool: | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Self-review of the repairs after the independent reviews ( Round 1, behavior:
Round 2, claims and operations:
|
||
| """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 | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Independent follow-up review 4 (relayed): Codex CLI 0.166.0, model 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 Rejected, with reason: the scenario needs two threads that call |
||
| 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. | ||
|
|
||
|
|
@@ -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, | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| # discards the write once it ends. | ||
| self._discard_later = True | ||
| except Exception: | ||
| upload_id = cast(S3MultipartUpload, self.multipart_upload).upload_id | ||
| _logger.exception( | ||
|
|
@@ -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 | ||
|
|
@@ -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() | ||
|
|
||
|
|
@@ -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) | ||
There was a problem hiding this comment.
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
TextIOWrapperis dropped in the only request-pool thread outside a collection while a part of its file is queued behind that thread, the wrapper callsS3File.close()inline, which does not checkin_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:
gcstate->collecting).RuntimeErrorare now also contained, so that a queued close that cannot start is never waited for forever.threading._shutdownand 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.