Always end the transaction, even when a commit or discard raises - #2108
Merged
martindurant merged 1 commit intoAug 28, 2026
Merged
Conversation
Transaction.complete() drained the file deque with no try/finally. If any
commit() or discard() raised, the loop aborted: files still queued were
neither committed nor discarded, so their temporary files leaked, and the
lines resetting _intrans/_transaction/fs never ran. Transaction.__exit__
could not compensate either, since its reset block sits after the
complete() call that raised.
Leaving _intrans set is the damaging part. Filesystem instances are
cached, so fsspec.filesystem("file") keeps handing back the poisoned
object for the rest of the process, and every later write is deferred
into a temporary file that nothing will ever commit - no exception, no
warning, the data is simply gone.
The docs promise that on an uncaught exception "the files are discarded
and the file target locations untouched" (docs/source/features.rst:137).
Discard whatever is still queued in a finally block, including the file
that was in flight when the error was raised, and always reset the
transaction state. Cleanup failures are logged rather than raised so they
cannot mask the original error. DaskTransaction.complete had the same
hole and additionally never cleared _transaction.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
Transaction.complete()drains the file deque with notry/finally:If any
commit()ordiscard()raises, the loop aborts. Files still queued are neither committed nor discarded, so their temporary files leak, and the three reset lines never run.__exit__cannot compensate — itsif self.fs:reset block sits after thecomplete()call that raised.Leaving
_intransset is the damaging part. Filesystem instances are cached, so the poisoned object is handed back for the rest of the process and every later write silently disappears:Verbatim, before this change:
and after:
The contract
That sentence is unqualified. The partial commit itself is arguably covered by "semi-atomic" a few lines above, so this change does not attempt to roll back files that already committed — but leaked temporaries and a filesystem stuck in transaction mode are not covered by anything.
The change
Drain in a
try, and in thefinallydiscard whatever is still queued — including the file that was in flight when the error was raised, since it is already off the deque — then always reset_intrans/_transaction/fs. Cleanup failures are logged rather than raised, so a failingos.removecannot mask the user's original exception (which is what happens today on the discard path: aFileNotFoundErrorfrom reaping/tmpreplaces the caller's own error).DaskTransaction.completehad the same hole and additionally never cleared_transaction; it gets the samefinally.Tests
test_transaction_ends_when_a_commit_failsinfsspec/implementations/tests/test_local.pypatchesLocalFileOpener.committo fail on one of three files, then asserts the transaction state is cleared, no temporaries are left in the temp directory, and a subsequent write on the same instance actually lands.Nothing existing covered this:
grep -rn "_intrans" fsspec/tests/ fsspec/implementations/tests/only finds two SMB/SFTP happy-path assertions.Verified by reverting only
fsspec/transaction.pyand keeping the test — it fails onassert fs._intrans is False. Full suite: 867 passed, 117 skipped, 2 xfailed.Disclosure: this patch was prepared with AI assistance. The reproduction, the red/green check and the suite run above were executed against this branch; happy to adjust anything on request.