Skip to content

[core] Take the stream position outside the finally so close always runs - #9233

Open
PDGGK wants to merge 2 commits into
apache:masterfrom
PDGGK:fix-objectsfile-getpos
Open

[core] Take the stream position outside the finally so close always runs#9233
PDGGK wants to merge 2 commits into
apache:masterfrom
PDGGK:fix-objectsfile-getpos

Conversation

@PDGGK

@PDGGK PDGGK commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Purpose

ObjectsFile#writeWithoutRolling reads the stream position inside the finally, ahead of the close:

PositionOutputStream out = fileIO.newOutputStream(path, false);
long pos;
try {
    try (FormatWriter writer = writerFactory.create(out, compression)) {
        while (records.hasNext()) {
            writer.addElement(serializer.toRow(records.next()));
        }
    }
} finally {
    pos = out.getPos();   // <- declared `throws IOException`
    out.close();          // <- skipped if it does
}

PositionOutputStream#getPos is public abstract long getPos() throws IOException. When it throws, out.close() is never reached and the stream leaks, and the finally's exception replaces whatever the try body was throwing — a finally that 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 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.

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:

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();
}
return Pair.of(path.getName(), pos);

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 pos onto the success path but left the finally { out.close(); } in place, and claimed that made the original exception propagate. It did not — @JingsongLi pointed out that a throwing out.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 stub FileIO and FormatWriter:

  • both fail — writer close throws writer close failed, stream close throws stream close failed. The propagated cause must be the writer failure, with the stream failure attached as suppressed.
  • success path — the stream is still closed, and the returned position is the one getPos() reported.

The first case is what the previous revision got wrong; run against that revision it fails exactly as predicted:

Expecting message to be:
  "writer close failed"
but was:
  "stream close failed"

ObjectsFileWriteFailureTest and BinaryIndexManifestEntryTest: 4 tests, 0 failures. spotless:apply and checkstyle:check on paimon-core are 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 (deleteQuietlydeleteQuietlyIgnoringInterrupt). Different lines and a different concern, but worth flagging since they are close together.

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.
@JingsongLi

Copy link
Copy Markdown
Contributor

In ObjectsFile.writeWithoutRolling, if writer.close() (or the write operation itself) throws an exception and the subsequent out.close() in the finally block also throws an exception, the latter will still overwrite the original exception; thus, the claim in the PR that the "original exception propagates" is incorrect. It is recommended to use try-with-resources in the outer scope so that any exception thrown during closing is added to the list of suppressed exceptions.

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.
@PDGGK

PDGGK commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

You're right, and the claim in my description was wrong. A finally that throws discards the exception in flight, so out.close() failing would still have replaced the write failure — the very thing the PR said it fixed. Pushed a876331 using try-with-resources as you suggest.

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 getPos().

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:

Expecting message to be:
  "writer close failed"
but was:
  "stream close failed"

That is the old try/finally under the new test — your case, reproduced. With a876331 the cause is writer close failed and stream close failed is attached to it as suppressed. A second case covers the success path, asserting the stream is still closed and the position is the one returned.

ObjectsFileWriteFailureTest and BinaryIndexManifestEntryTest: 4 tests, 0 failures. spotless:apply and checkstyle:check on paimon-core are clean.

Thanks for catching it — and for #9227.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants