[core] Take the stream position outside the finally so close always runs - #9233
[core] Take the stream position outside the finally so close always runs#9233PDGGK wants to merge 2 commits into
Conversation
writeWithoutRolling reads the position inside the finally, ahead of the
close:
} finally {
pos = out.getPos();
out.close();
}
PositionOutputStream#getPos is declared to throw IOException. When it
does, out.close() is never reached and the stream leaks; and because a
throw from a finally replaces whatever the try body was throwing --
these are not suppressed, unlike try-with-resources -- the original
write failure is discarded and the caller sees the getPos error instead.
The two failures are not independent. The try body usually throws
because the underlying stream is already broken -- disk full, an object
store rejecting the upload -- and getPos on that same stream is then
likely to throw as well. So the case where the position read fails is
largely the same case where losing the write error hurts most.
pos is only used by the success-path return, so it moves there. On a
successful write the value is identical: it is still read after the
FormatWriter has been closed and its buffers flushed. On a failing
write, close now runs and the original exception propagates.
No test. Reaching the branch needs a FileIO whose streams fail on
getPos, and the existing tests build their manifest files through
table.store()...Factory().create(), which supplies the catalog's own
FileIO with no seam to substitute one. Happy to add a test with a
hand-built ObjectsFile if you would like it. BinaryIndexManifestEntryTest
covers the success path through this method and still passes.
|
In |
Review feedback from JingsongLi: the finally block was still able to replace the original failure, because a finally that throws discards the exception in flight. try-with-resources attaches the close failure as a suppressed exception instead. The two try blocks stay nested rather than becoming one resource list: the position must be read after the writer has flushed and before the stream is closed. Adds a test that pins the exception plumbing, which is what the earlier claim got wrong.
|
You're right, and the claim in my description was wrong. A long pos;
try (PositionOutputStream out = fileIO.newOutputStream(path, false)) {
try (FormatWriter writer = writerFactory.create(out, compression)) {
while (records.hasNext()) {
writer.addElement(serializer.toRow(records.next()));
}
}
pos = out.getPos();
}I kept the two blocks nested rather than folding them into one resource list, because the position has to be read after the writer has flushed and before the stream closes; a single list would close the writer only on the way out, after I also added a test, since the thing I got wrong was exactly the exception plumbing and I would rather it be pinned than argued. It drives a stub whose writer close and stream close both throw: That is the old
Thanks for catching it — and for #9227. |
Purpose
ObjectsFile#writeWithoutRollingreads the stream position inside thefinally, ahead of the close:PositionOutputStream#getPosispublic abstract long getPos() throws IOException. When it throws,out.close()is never reached and the stream leaks, and thefinally's exception replaces whatever the try body was throwing — afinallythat throws discards the exception in flight, and nothing is attached as suppressed.The two failures are not independent. The try body usually throws because the underlying stream is already broken — a full disk, an object store rejecting the upload — and
getPoson that same stream is then likely to throw as well. So the case where the position read fails is largely the same case where losing the write error hurts most.What changes
The whole block becomes try-with-resources, so the stream is closed on every path and a close failure is attached to the original failure rather than promoted over it:
The two blocks stay nested rather than folding into a single resource list: the position has to be read after the writer has flushed and before the stream itself closes, and one list would close the writer only on the way out, after
getPos().On a successful write the returned value is identical to before. On a failing write, the stream is closed and the write failure is the one that propagates, with any close failure among its suppressed exceptions.
An earlier revision of this PR moved
posonto the success path but left thefinally { out.close(); }in place, and claimed that made the original exception propagate. It did not — @JingsongLi pointed out that a throwingout.close()would still replace it, which is the same defect the first paragraph above describes. The test below exists because of that.Tests
ObjectsFileWriteFailureTest, two cases, driving a stubFileIOandFormatWriter:writer close failed, stream close throwsstream close failed. The propagated cause must be the writer failure, with the stream failure attached as suppressed.getPos()reported.The first case is what the previous revision got wrong; run against that revision it fails exactly as predicted:
ObjectsFileWriteFailureTestandBinaryIndexManifestEntryTest: 4 tests, 0 failures.spotless:applyandcheckstyle:checkonpaimon-coreare clean.API and Format
No change to any public signature, option, or on-disk format. Behaviour differs only on the failure path, where the stream is now closed and the write failure is the one that reaches the caller.
Note
#8927 also touches this method, in the
catch (Throwable e)block below (deleteQuietly→deleteQuietlyIgnoringInterrupt). Different lines and a different concern, but worth flagging since they are close together.