Don't upload a partial file when the writer fails - #95
Open
i18n-tribe wants to merge 1 commit into
Open
Conversation
S3File buffers writes into a temporary file and uploads the whole thing in a single call when the file is closed. __exit__ closed the file unconditionally, so an exception raised part way through writing still uploaded whatever had been buffered so far. That publishes a truncated object. Because the upload itself succeeds, S3 stores it as a complete object of that length, and nothing downstream can tell it apart from a file that was written in full. A reader either consumes silently incomplete data or, for a compressed format, fails on every subsequent read of an object that will never become valid. The zero-byte case is the same bug at its limit: a writer that raises before writing anything at all leaves an empty object where callers expect either a whole file or none. __exit__ now discards the buffer when it is unwinding an exception, so a failed write leaves no object behind, and only a block that completes publishes one. This makes the context manager all-or-nothing, which is the guarantee the buffer-then-upload design already offers everywhere else. discard() also clears the on-close callback, because io.IOBase.__del__ calls close(); without that the abandoned buffer would be uploaded anyway as soon as the object was garbage collected. Callers that do want a partial upload can still get one by calling close() explicitly instead of relying on the context manager.
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
S3Filebuffers writes into atempfile.TemporaryFile()and uploads the whole thing in oneupload_fileobjcall when the file is closed.__exit__closes unconditionally:So an exception raised part way through writing still uploads whatever happened to be buffered at that point.
Why this is worse than it looks
The upload itself succeeds, so S3 stores a perfectly valid object that simply happens to be short. There is no failed multipart to abort and no marker of any kind — nothing downstream can distinguish it from a file that was written in full.
That matters because the usual protections don't apply. An interrupted
PUTcreates no object, and an abandoned multipart upload leaves no visible object either. Here the failure happens while writing to the local buffer, long before S3 is involved, and then the library goes on to publish it.Depending on the format, a consumer either reads silently incomplete data, or — for anything compressed or otherwise checksummed — fails on every single read of an object that will never become valid, because the write that would have completed it is long gone.
The zero-byte case is the same bug at its limit: a writer that raises before writing anything leaves an empty object where callers expect either a whole file or nothing at all. That one is particularly easy to miss, because a pipeline that decides what still needs fetching by listing keys will treat the empty object as "already done" and never retry it.
The change
__exit__now discards the buffer when it is unwinding an exception, and uploads only when the block completed:This makes the context manager all-or-nothing, which is the guarantee the buffer-then-upload design already offers everywhere else — the object appears in one step, at close, or not at all.
discard()also clears_on_close, becauseio.IOBase.__del__callsclose(). Without that, the abandoned buffer would be uploaded anyway as soon as the object was garbage collected, which is a nastier version of the original bug since it happens at an arbitrary later moment.Compatibility
This is a behaviour change, so worth being explicit about who it affects.
Anyone relying on a partial upload surviving a failed write will no longer get one. I'd argue that is the point rather than a regression, but the capability is still there for anyone who wants it — calling
close()explicitly, rather than relying on the context manager, uploads exactly as before.Local file semantics are the obvious counter-argument:
with open(path, "wb")does leave a partial file behind. The difference is that a local partial file is written incrementally and is inspectable at a path the caller already knows, whereas here the library buffers the entire write and then makes a deliberate choice to publish it to a remote store, typically into a pipeline that will consume it as complete.Tests
Three unit tests in
TestS3FileClose, using a stubon_closeso they need no bucket or network:close()afterdiscard()is a no-op, covering the__del__path above