Skip to content

Give multipart writes a single owner of their parts and an explicit state #1211

Closed
4 of 4 issues completed

Description

@laughingman7743

Use case

Multipart writes have no single owner of their parts. S3File (also used by AioS3File) and AioS3StreamedFile each track the part futures, the parts and copies in flight, the failure checks, the cancellation and waiting before an abort, and the abort itself, with S3MultipartWriter owning only the upload identity and the requests. The write lifecycle has no explicit state either: it is inferred from buffer, closed, forced, multipart_upload, multipart_upload_parts, and _committing.

Consequences found during #1186 (PR #1192):

  • Every fix to part handling is written twice, and the two files already differ: S3File.discard() keeps the parts when the abort fails so that a later discard() retries it, while AioS3StreamedFile._discard() logs the failure.
  • Several defects come from implicit states. A retried commit() after a failed multipart commit uploaded only the last part with PutObject (fixed in Free the data of failed S3 requests and clean up async copies at loop shutdown #1192 by closing the file), and discard() followed by commit() still uploads the buffered data of the last part alone; after a discard() whose abort failed, a commit() waits for the kept parts and may complete the discarded upload.
  • The futures of failed parts stay referenced from the files and from the frames that raise their errors, forming reference cycles that only the garbage collector frees. Free the data of failed S3 requests and clean up async copies at loop shutdown #1192 releases the request data (part bodies, read results) at the executor and request boundaries, but a per-call-site attempt to remove the cycles themselves needed an invariant that every caller had to keep by hand, and kept introducing new bugs.

Proposed change

A write session in pyathena/filesystem/s3_writer.py, composed with S3MultipartWriter, that exclusively owns one multipart write:

  • Ownership: the creation request, the part uploads and part copies, bounded concurrency (max_workers parts in flight), ordered part metadata, the first failure, and completion and abort. Request futures are consumed as they finish: a finished part becomes its part metadata or a recorded failure, and the future is dropped, so neither the files nor the frames that raise a failure hold part futures.
  • Explicit state: for example BUFFERING, CREATING, ACTIVE, COMPLETING, COMPLETED, ABORTING, ABORTED, and ABORT_FAILED (keeps the upload identity for a retried abort). After a failure or a discard, completing is impossible; later operations raise a clear state error instead of re-raising an earlier error or uploading the buffer.
  • API sketch: start(), submit_part(body), submit_copy(range), a readiness handle that resolves when another part may be submitted (ready()), check() to raise a recorded failure, complete(), and abort(). The synchronous file blocks on the readiness handle; the asyncio file awaits it (asyncio.wrap_future), so the event loop keeps running and a one-thread pool never waits for itself.
  • Adapters: S3File and AioS3StreamedFile keep only the buffering and the fsspec protocol (flush, commit, discard, transactions), and delegate part handling to the session.

Risks: callback re-entrancy and locking; Future.cancel() runs callbacks on the cancelling thread; the "tracked before scheduled" invariant that lets an interrupt still cancel or wait for a part; completion racing an abort; pool starvation; and the public S3File.multipart_upload_parts list, which would become derived from the session. Append (part copies of the existing object), exclusive create (IfNoneMatch), and transactions must keep their behavior.

Migration: the session and its unit tests first; then S3File/AioS3File; then AioS3StreamedFile, removing the duplicated tracking; each in its own PR with the behavior changes listed for the release notes.

Validation plan (if implementing)

Unit tests of the session's state transitions and of interrupt, cancellation, failure, and abort-failure paths without AWS; the existing S3File and AioS3StreamedFile tests, which must keep passing; tests that a failed part's body and the parts of a failed write are released while the error is held; and the filesystem integration tests (tests/pyathena/filesystem/) against the CI bucket.

Activity

  1. laughingman7743 commented on Oct 17, 2026

    @laughingman7743
    MemberAuthor

    Design decisions (2026-10-17)

    An audit found four owners of the multipart part lifecycle, not two, and they diverge in abort-failure handling, in-flight bounding, stopping after a failure, recovery of an interrupted creation, and the completion-versus-abort race:

    1. S3File (also AioS3File): _initiate_upload, _upload_chunk, commit, discard, _close_without_commit.
    2. AioS3StreamedFile: _initiate_upload, _upload_chunk, _complete_upload, _discard, _close_without_commit.
    3. S3FileSystem._copy_object_with_multipart_upload, with _finish_multipart_upload and _abort_multipart_upload.
    4. AioS3FileSystem._copy_object_with_multipart_upload: a semaphore, a failed flag, and _abort() in a filesystem thread.

    The draft design was reviewed with Codex before these decisions.

    Scope and delivery

    All four owners move to one session, one PR per sub-issue, in order:

    1. Move S3File writes onto a multipart write session #1227: the session, with S3File and AioS3File as its first consumer, so that the hardest contracts (deferred commit after the executor shutdown, empty writes, fsspec rollback) shape the session before it is reused.
    2. Move AioS3StreamedFile writes onto the multipart write session #1228: AioS3StreamedFile.
    3. Move the S3FileSystem multipart copy onto the multipart write session #1229: S3FileSystem multipart copy.
    4. Move the AioS3FileSystem multipart copy onto the multipart write session #1230: AioS3FileSystem multipart copy.

    This issue is closed by the PR of the last sub-issue.

    Contract

    • Owner: an internal _S3WriteSession in pyathena/filesystem/s3_writer.py, composed with S3MultipartWriter, whose public contract is unchanged. The session exclusively owns its writer. Making the session public is reconsidered after the migration.
    • One logical write: the session also covers the single-request path (PutObject and empty writes), so that no second lifecycle stays in the adapters. Buffering, the fsspec protocol, filesystem validation, and cache invalidation stay in the adapters.
    • States: IDLE, CREATING, ACTIVE, COMPLETING, COMPLETED, FAILED, ABORTING, ABORTED, ABORT_FAILED. FAILED means that completing is prohibited, not that an upload exists. Late request callbacks never move the session out of ABORTING or back to ACTIVE.
    • Abort by state: IDLE → ABORTED without a request; CREATING cancels a queued creation or recovers a running one, then aborts the resulting upload; ACTIVE/FAILED seal submissions, cancel queued requests, wait for running ones, and abort; COMPLETING follows the completion policy and never sends a concurrent abort; COMPLETED and ABORTED do nothing; ABORTING joins the running cleanup; ABORT_FAILED retries against the retained upload and never reopens writing.
    • Completion reservation: a non-blocking begin_complete() enters COMPLETING before the blocking finalizer is queued (what _committing does today), and is serialized with part submission, so that a cancellation cannot abort a queued commit and a finalizer cannot overtake a part that is registered but not yet scheduled. The completion policy distinguishes a queued finalizer from a running CompleteMultipartUpload request: the streamed file protects a queued commit, while the asynchronous copy may still cancel a completion that has not started.
    • Finalizers: the completion and abort requests run inline in the finalizer, as today, never submitted back to the pool that the finalizer waits on (a one-worker pool would deadlock), and they keep working after S3File.close() has shut the executor down before a deferred transaction commit. Done callbacks never block.
    • Admission and readiness: each request future is registered with its done callback before submit_to() (tracked before scheduled); a scheduling failure cancels the future if it can, and otherwise keeps it until it settles. Uploads and copies both count against max_workers; submit_*() checks the state and reserves capacity atomically, and readiness is only a hint. Each waiter gets its own readiness future, resolved outside the lock, and a cancelled waiter is dropped. A creation in progress keeps readiness pending. Cancelled futures that may never have been scheduled are never passed to wait().
    • Numbering: the session allocates part numbers at admission, for uploads and copies alike, and checks the limit before scheduling. Planning (iter_parts, iter_copy_parts) stays in S3MultipartWriter. An empty final flush at the part limit stays valid.
    • Failures: a done callback consumes its future (exception() after cancelled(), result() only on success) and drops it. The first failure is handed to the caller once: it is removed from the session under the lock and raised outside it. The session keeps a permanent failure marker and a summary without a traceback, and later operations raise S3MultipartStateError(RuntimeError).
    • Memory guarantee: the session releases the request futures and buffers that it owns promptly. It does not promise that no reference cycle remains anywhere: for example, the future that S3AioThreadPool.wait_shielded() waits on still holds a failed completion.
    • Interrupts: the current policy is kept: repeated KeyboardInterrupts are suppressed while an interrupted creation is recovered, and an interrupt during later cleanup escapes and keeps the upload for a retry.
    • Remote outcome: NoSuchUpload is treated narrowly as an upload that no longer exists. A failed or lost completion does not promise that the destination is unchanged.

    User-visible changes (4.2.0 release notes)

    • S3File.multipart_upload becomes read-only (the setter is removed).
    • S3File.multipart_upload_parts (a list of futures) is removed; a read-only snapshot of the completed parts in part-number order (for example completed_parts: tuple[S3MultipartUploadPart, ...]) replaces it.
    • commit() after discard(), or after a failed write or commit, raises S3MultipartStateError instead of doing nothing or uploading the buffered last part. Inside an fsspec transaction, a caller that catches a write error and continues makes the transaction completion fail, and fsspec then discards the remaining deferred files. discard() stays repeatable after a failed commit or abort.
    • A write after a failure raises the state error before buffering more data.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions