This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new 5f3c5387d2 [format] Reuse one source stream for consecutive BlobRef
copies (#8725)
5f3c5387d2 is described below
commit 5f3c5387d242449a60bbbddb66604126ac59b31e
Author: XiaoHongbo <[email protected]>
AuthorDate: Thu Jul 30 19:46:29 2026 +0800
[format] Reuse one source stream for consecutive BlobRef copies (#8725)
---
.../main/java/org/apache/paimon/data/BlobRef.java | 5 +
.../paimon/data/ReusingBlobRefStreamProvider.java | 220 ++++++++
.../paimon/fs/OffsetSeekableInputStream.java | 10 +-
.../data/ReusingBlobRefStreamProviderTest.java | 347 ++++++++++++
.../paimon/fs/OffsetSeekableInputStreamTest.java | 9 +
.../format/blob/AbstractBlobElementReader.java | 9 +-
.../format/blob/AbstractBlobElementWriter.java | 125 ++++-
.../format/blob/ArrayBlobElementSerializer.java | 6 +-
.../paimon/format/blob/BlobElementSerializer.java | 2 +-
.../paimon/format/blob/BlobFormatWriter.java | 25 +-
.../format/blob/MapBlobElementSerializer.java | 6 +-
.../format/blob/RawBlobElementSerializer.java | 6 +-
.../paimon/format/blob/BlobFormatWriterTest.java | 581 ++++++++++++++++++++-
13 files changed, 1320 insertions(+), 31 deletions(-)
diff --git a/paimon-common/src/main/java/org/apache/paimon/data/BlobRef.java
b/paimon-common/src/main/java/org/apache/paimon/data/BlobRef.java
index 7e8a1ddc68..26721936c6 100644
--- a/paimon-common/src/main/java/org/apache/paimon/data/BlobRef.java
+++ b/paimon-common/src/main/java/org/apache/paimon/data/BlobRef.java
@@ -82,6 +82,11 @@ public class BlobRef implements Blob {
descriptor.length());
}
+ /** Package-private, see {@link ReusingBlobRefStreamProvider}. */
+ UriReader uriReader() {
+ return uriReader;
+ }
+
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) {
diff --git
a/paimon-common/src/main/java/org/apache/paimon/data/ReusingBlobRefStreamProvider.java
b/paimon-common/src/main/java/org/apache/paimon/data/ReusingBlobRefStreamProvider.java
new file mode 100644
index 0000000000..34c5d4a3b3
--- /dev/null
+++
b/paimon-common/src/main/java/org/apache/paimon/data/ReusingBlobRefStreamProvider.java
@@ -0,0 +1,220 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.data;
+
+import org.apache.paimon.fs.SeekableInputStream;
+import org.apache.paimon.utils.UriReader;
+
+import javax.annotation.Nullable;
+
+import java.io.Closeable;
+import java.io.IOException;
+
+/**
+ * Reuses one underlying source stream across consecutive {@link BlobRef}s
that read from the same
+ * file, so writing N such references opens the source once instead of once
per reference. Callers
+ * only ever receive a stream bounded to a reference's descriptor window; the
underlying {@link
+ * UriReader} never leaves this class, so a caller cannot read past the blob
bounds or open another
+ * URI. Not a public API.
+ */
+public final class ReusingBlobRefStreamProvider implements Closeable {
+
+ @Nullable private UriReader reader;
+ @Nullable private String uri;
+ @Nullable private SeekableInputStream underlying;
+ private long position;
+ // Bumped on every open/close so a previously handed-out view can't read
the new source.
+ private long epoch;
+
+ /**
+ * Positions the open source for {@code ref}, or closes it (so {@link
#openBounded} reopens a
+ * fresh one) when it reads a different file or can't rewind. Its close
error surfaces here
+ * rather than inside {@code openBounded}, so cleanup of a previous,
already-copied source is
+ * never mistaken for {@code ref}'s fetch failure and turned into a NULL
write. Call before
+ * {@code openBounded}.
+ */
+ public void prepareFor(BlobRef ref) throws IOException {
+ epoch++; // invalidate any view handed out for the previous reference
+ if (underlying == null) {
+ return;
+ }
+ long offset = ref.toDescriptor().offset();
+ boolean sameSource = reader == ref.uriReader() &&
ref.toDescriptor().uri().equals(uri);
+ // Different file, or same file that can't rewind: drop it so
openBounded reopens fresh.
+ if (!sameSource || (position != offset && !trySeek(offset))) {
+ close();
+ }
+ }
+
+ /**
+ * Returns a stream bounded to {@code ref}'s descriptor, reusing the
source left open by {@link
+ * #prepareFor} or opening a fresh one, seeking only when not already
positioned. Open/seek
+ * errors propagate so the caller can apply its write-null policy; on such
an error the source
+ * is dropped.
+ */
+ public SeekableInputStream openBounded(BlobRef ref) throws IOException {
+ BlobDescriptor descriptor = ref.toDescriptor();
+ // Fail closed: a mismatched ref would silently read the wrong or
unbounded bytes.
+ if (ref.getClass() != BlobRef.class) {
+ throw new IllegalArgumentException(
+ "ReusingBlobRefStreamProvider does not support BlobRef
subclasses.");
+ }
+ if (descriptor.length() < 0) {
+ throw new IllegalArgumentException(
+ "ReusingBlobRefStreamProvider requires a non-negative
length.");
+ }
+ if (underlying != null && (reader != ref.uriReader() ||
!descriptor.uri().equals(uri))) {
+ throw new IllegalStateException(
+ "openBounded ref differs from prepareFor; call prepareFor
first.");
+ }
+ epoch++; // invalidate any view handed out for the previous reference
+ long offset = descriptor.offset();
+ try {
+ if (underlying == null) {
+ underlying = ref.uriReader().newInputStream(descriptor.uri());
+ reader = ref.uriReader();
+ uri = descriptor.uri();
+ position = 0;
+ }
+ if (position != offset) {
+ underlying.seek(offset);
+ position = offset;
+ }
+ } catch (IOException | RuntimeException | Error e) {
+ discardQuietly();
+ throw e;
+ }
+ return new BoundedSource(epoch, descriptor.length());
+ }
+
+ /** Seeks the open source, returning false instead of throwing when it
can't reposition. */
+ private boolean trySeek(long offset) {
+ try {
+ underlying.seek(offset);
+ position = offset;
+ return true;
+ } catch (IOException | RuntimeException e) {
+ return false;
+ }
+ }
+
+ /** Drops the underlying source, swallowing any close error (used after a
copy failure). */
+ public void discardQuietly() {
+ try {
+ close();
+ } catch (RuntimeException | Error | IOException ignored) {
+ // Swallow so the triggering open/seek/read error isn't masked.
+ }
+ }
+
+ @Override
+ public void close() throws IOException {
+ epoch++; // invalidate any outstanding view
+ closeUnderlying();
+ }
+
+ private void closeUnderlying() throws IOException {
+ SeekableInputStream toClose = underlying;
+ underlying = null;
+ reader = null;
+ uri = null;
+ position = 0;
+ if (toClose != null) {
+ toClose.close();
+ }
+ }
+
+ /**
+ * A read-only view over the shared underlying stream, capped at the
descriptor length. Reads
+ * advance the shared position; {@link #close()} keeps the underlying open
for the next blob. It
+ * is only valid until the next {@code openBounded}/{@code close}; using
it afterwards throws
+ * rather than reading the new source.
+ */
+ private final class BoundedSource extends SeekableInputStream {
+
+ private final long viewEpoch;
+ private final long length;
+ private long remaining;
+
+ private BoundedSource(long viewEpoch, long length) {
+ this.viewEpoch = viewEpoch;
+ this.length = length;
+ this.remaining = length;
+ }
+
+ private void checkValid() {
+ if (viewEpoch != epoch) {
+ throw new IllegalStateException(
+ "Stale ReusingBlobRefStreamProvider stream: a newer
reference was opened or it was closed.");
+ }
+ }
+
+ @Override
+ public int read() throws IOException {
+ checkValid();
+ if (remaining <= 0) {
+ return -1;
+ }
+ int b = underlying.read();
+ if (b >= 0) {
+ remaining--;
+ position++;
+ }
+ return b;
+ }
+
+ @Override
+ public int read(byte[] b, int off, int len) throws IOException {
+ checkValid();
+ if (b == null) {
+ throw new NullPointerException();
+ } else if (off < 0 || len < 0 || len > b.length - off) {
+ throw new IndexOutOfBoundsException();
+ } else if (len == 0) {
+ return 0;
+ }
+ if (remaining <= 0) {
+ return -1;
+ }
+ int n = underlying.read(b, off, (int) Math.min(len, remaining));
+ if (n > 0) {
+ remaining -= n;
+ position += n;
+ }
+ return n;
+ }
+
+ @Override
+ public void seek(long desired) {
+ throw new UnsupportedOperationException(
+ "ReusingBlobRefStreamProvider stream is read-only.");
+ }
+
+ @Override
+ public long getPos() {
+ checkValid();
+ return length - remaining;
+ }
+
+ @Override
+ public void close() {
+ remaining = 0;
+ }
+ }
+}
diff --git
a/paimon-common/src/main/java/org/apache/paimon/fs/OffsetSeekableInputStream.java
b/paimon-common/src/main/java/org/apache/paimon/fs/OffsetSeekableInputStream.java
index 66f7f08c0d..d32d7f48b5 100644
---
a/paimon-common/src/main/java/org/apache/paimon/fs/OffsetSeekableInputStream.java
+++
b/paimon-common/src/main/java/org/apache/paimon/fs/OffsetSeekableInputStream.java
@@ -18,6 +18,8 @@
package org.apache.paimon.fs;
+import org.apache.paimon.utils.IOUtils;
+
import java.io.IOException;
/**
@@ -36,7 +38,13 @@ public class OffsetSeekableInputStream extends
SeekableInputStream {
this.offset = offset;
this.length = length;
if (offset != 0) {
- wrapped.seek(offset);
+ try {
+ wrapped.seek(offset);
+ } catch (IOException | RuntimeException | Error e) {
+ // Constructor failed: close the wrapped stream so it doesn't
leak.
+ IOUtils.closeQuietly(wrapped);
+ throw e;
+ }
}
}
diff --git
a/paimon-common/src/test/java/org/apache/paimon/data/ReusingBlobRefStreamProviderTest.java
b/paimon-common/src/test/java/org/apache/paimon/data/ReusingBlobRefStreamProviderTest.java
new file mode 100644
index 0000000000..c5deaaaaa7
--- /dev/null
+++
b/paimon-common/src/test/java/org/apache/paimon/data/ReusingBlobRefStreamProviderTest.java
@@ -0,0 +1,347 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.data;
+
+import org.apache.paimon.fs.ByteArraySeekableStream;
+import org.apache.paimon.fs.SeekableInputStream;
+import org.apache.paimon.utils.IOUtils;
+import org.apache.paimon.utils.UriReader;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link ReusingBlobRefStreamProvider}. */
+public class ReusingBlobRefStreamProviderTest {
+
+ /** A UriReader over in-memory files that counts how many streams it
opened. */
+ private static final class CountingUriReader implements UriReader {
+ private final Map<String, byte[]> files;
+ int openCount;
+
+ CountingUriReader(Map<String, byte[]> files) {
+ this.files = files;
+ }
+
+ @Override
+ public SeekableInputStream newInputStream(String uri) {
+ openCount++;
+ return new ByteArraySeekableStream(files.get(uri));
+ }
+ }
+
+ private static byte[] range(int from, int to) {
+ byte[] b = new byte[to - from];
+ for (int i = from; i < to; i++) {
+ b[i - from] = (byte) i;
+ }
+ return b;
+ }
+
+ /** A forward-only stream: reads and getPos work, but seek is unsupported.
*/
+ private static class ForwardOnlyStream extends SeekableInputStream {
+ private final byte[] data;
+ private int pos;
+
+ ForwardOnlyStream(byte[] data) {
+ this.data = data;
+ }
+
+ @Override
+ public void seek(long desired) {
+ throw new UnsupportedOperationException("forward-only");
+ }
+
+ @Override
+ public long getPos() {
+ return pos;
+ }
+
+ @Override
+ public int read() {
+ return pos < data.length ? data[pos++] & 0xff : -1;
+ }
+
+ @Override
+ public int read(byte[] b, int off, int len) {
+ if (pos >= data.length) {
+ return -1;
+ }
+ int n = Math.min(len, data.length - pos);
+ System.arraycopy(data, pos, b, off, n);
+ pos += n;
+ return n;
+ }
+
+ @Override
+ public void close() {}
+ }
+
+ @Test
+ public void testBoundedToDescriptorWindow() throws IOException {
+ Map<String, byte[]> files = new HashMap<>();
+ files.put("mem://f", range(0, 20));
+ CountingUriReader reader = new CountingUriReader(files);
+ ReusingBlobRefStreamProvider reuse = new
ReusingBlobRefStreamProvider();
+
+ // A window [5, 5) exposes only bytes 5..9 and then EOF -- not the
rest of the file.
+ BlobRef ref = new BlobRef(reader, new BlobDescriptor("mem://f", 5, 5));
+ try (SeekableInputStream in = reuse.openBounded(ref)) {
+ assertThat(IOUtils.readFully(in, false)).containsExactly(range(5,
10));
+ assertThat(in.read()).isEqualTo(-1);
+ }
+ reuse.close();
+ }
+
+ @Test
+ public void testReusesOneOpenAcrossWindowsAndSeeksWithin() throws
IOException {
+ Map<String, byte[]> files = new HashMap<>();
+ files.put("mem://f", range(0, 20));
+ CountingUriReader reader = new CountingUriReader(files);
+ ReusingBlobRefStreamProvider reuse = new
ReusingBlobRefStreamProvider();
+
+ byte[] first = new byte[5];
+ IOUtils.readFully(
+ reuse.openBounded(new BlobRef(reader, new
BlobDescriptor("mem://f", 0, 5))), first);
+ byte[] second = new byte[3];
+ IOUtils.readFully(
+ reuse.openBounded(new BlobRef(reader, new
BlobDescriptor("mem://f", 12, 3))),
+ second);
+ reuse.close();
+
+ assertThat(first).containsExactly(range(0, 5));
+ assertThat(second).containsExactly(range(12, 15));
+ // Same reader + uri across the two windows: opened once, not per
reference.
+ assertThat(reader.openCount).isEqualTo(1);
+ }
+
+ @Test
+ public void testReopensWhenSourceUriChanges() throws IOException {
+ Map<String, byte[]> files = new HashMap<>();
+ files.put("mem://a", range(0, 10));
+ files.put("mem://b", range(100, 110));
+ CountingUriReader reader = new CountingUriReader(files);
+ ReusingBlobRefStreamProvider reuse = new
ReusingBlobRefStreamProvider();
+
+ BlobRef a = new BlobRef(reader, new BlobDescriptor("mem://a", 0, 4));
+ BlobRef b = new BlobRef(reader, new BlobDescriptor("mem://b", 0, 4));
+ reuse.prepareFor(a);
+ reuse.openBounded(a);
+ reuse.prepareFor(b);
+ reuse.openBounded(b);
+ reuse.close();
+
+ assertThat(reader.openCount).isEqualTo(2);
+ }
+
+ @Test
+ public void testPrepareForDifferentSourcePropagatesCloseError() throws
IOException {
+ UriReader reader =
+ u ->
+ u.equals("mem://a")
+ ? new ByteArraySeekableStream(range(0, 4)) {
+ @Override
+ public void close() {
+ throw new RuntimeException("close
failure");
+ }
+ }
+ : new ByteArraySeekableStream(range(0, 4));
+ ReusingBlobRefStreamProvider reuse = new
ReusingBlobRefStreamProvider();
+ reuse.openBounded(new BlobRef(reader, new BlobDescriptor("mem://a", 0,
4)));
+ // Switching away from a surfaces its close error rather than
swallowing it.
+ assertThatThrownBy(
+ () ->
+ reuse.prepareFor(
+ new BlobRef(reader, new
BlobDescriptor("mem://b", 0, 4))))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessage("close failure");
+ }
+
+ @Test
+ public void testForwardOnlyRewindCloseErrorSurfaces() throws IOException {
+ // Forward-only source can't rewind and also fails to close: the close
error must surface,
+ // not be swallowed by the reopen.
+ UriReader reader =
+ u ->
+ new ForwardOnlyStream(range(0, 5)) {
+ @Override
+ public void close() {
+ throw new RuntimeException("close failure");
+ }
+ };
+ ReusingBlobRefStreamProvider reuse = new
ReusingBlobRefStreamProvider();
+ BlobRef ref = new BlobRef(reader, new BlobDescriptor("mem://f", 0, 5));
+ reuse.prepareFor(ref);
+ IOUtils.readFully(reuse.openBounded(ref), new byte[5]);
+ // Second same-uri ref needs a rewind the source can't do; its failing
close surfaces.
+ assertThatThrownBy(() -> reuse.prepareFor(ref))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessage("close failure");
+ }
+
+ @Test
+ public void testStaleViewCannotReadNewSource() throws IOException {
+ Map<String, byte[]> files = new HashMap<>();
+ files.put("mem://a", range(0, 20));
+ files.put("mem://b", range(100, 110));
+ CountingUriReader reader = new CountingUriReader(files);
+ ReusingBlobRefStreamProvider reuse = new
ReusingBlobRefStreamProvider();
+
+ // A view over a wide window of file a, then open a second ref into
file b.
+ SeekableInputStream stale =
+ reuse.openBounded(new BlobRef(reader, new
BlobDescriptor("mem://a", 0, 20)));
+ BlobRef b = new BlobRef(reader, new BlobDescriptor("mem://b", 0, 4));
+ reuse.prepareFor(b);
+ reuse.openBounded(b);
+
+ // The stale view must not read file b's bytes with the old (larger)
length.
+ assertThatThrownBy(() -> stale.read(new byte[20], 0, 20))
+ .isInstanceOf(IllegalStateException.class);
+ reuse.close();
+ }
+
+ @Test
+ public void testViewIsStaleAfterClose() throws IOException {
+ Map<String, byte[]> files = new HashMap<>();
+ files.put("mem://f", range(0, 20));
+ ReusingBlobRefStreamProvider reuse = new
ReusingBlobRefStreamProvider();
+ SeekableInputStream view =
+ reuse.openBounded(
+ new BlobRef(
+ new CountingUriReader(files), new
BlobDescriptor("mem://f", 0, 5)));
+ reuse.close();
+
assertThatThrownBy(view::read).isInstanceOf(IllegalStateException.class);
+ }
+
+ @Test
+ public void testSeekErrorNotMaskedByCloseError() {
+ // seek fails (IOException); dropping the source then fails to close
(RuntimeException).
+ UriReader reader =
+ u ->
+ new ByteArraySeekableStream(range(0, 20)) {
+ @Override
+ public void seek(long desired) throws IOException {
+ throw new IOException("seek failure");
+ }
+
+ @Override
+ public void close() {
+ throw new RuntimeException("close failure");
+ }
+ };
+ ReusingBlobRefStreamProvider reuse = new
ReusingBlobRefStreamProvider();
+ // The original seek error must surface, not the follow-on close error.
+ assertThatThrownBy(
+ () ->
+ reuse.openBounded(
+ new BlobRef(reader, new
BlobDescriptor("mem://f", 2, 3))))
+ .isInstanceOf(IOException.class)
+ .hasMessage("seek failure");
+ }
+
+ @Test
+ public void testForwardOnlySourceReopensToRewind() throws IOException {
+ // Two same-uri offset-0 refs: the second needs a rewind the
forward-only stream can't do,
+ // so the source is reopened rather than failing.
+ int[] opens = {0};
+ UriReader reader =
+ u -> {
+ opens[0]++;
+ return new ForwardOnlyStream(range(0, 5));
+ };
+ ReusingBlobRefStreamProvider reuse = new
ReusingBlobRefStreamProvider();
+
+ BlobRef ref = new BlobRef(reader, new BlobDescriptor("mem://f", 0, 5));
+ byte[] first = new byte[5];
+ reuse.prepareFor(ref);
+ IOUtils.readFully(reuse.openBounded(ref), first);
+ byte[] second = new byte[5];
+ reuse.prepareFor(ref);
+ IOUtils.readFully(reuse.openBounded(ref), second);
+ reuse.close();
+
+ assertThat(first).containsExactly(range(0, 5));
+ assertThat(second).containsExactly(range(0, 5));
+ assertThat(opens[0]).isEqualTo(2); // reopened to rewind
+ }
+
+ @Test
+ public void testReadArrayHonorsInputStreamContract() throws IOException {
+ Map<String, byte[]> files = new HashMap<>();
+ files.put("mem://f", range(0, 20));
+ ReusingBlobRefStreamProvider reuse = new
ReusingBlobRefStreamProvider();
+ SeekableInputStream in =
+ reuse.openBounded(
+ new BlobRef(
+ new CountingUriReader(files), new
BlobDescriptor("mem://f", 0, 5)));
+ assertThat(in.read(new byte[4], 0, 0)).isZero(); // len 0 -> 0, not -1
+ assertThatThrownBy(() -> in.read(null, 0,
1)).isInstanceOf(NullPointerException.class);
+ assertThatThrownBy(() -> in.read(new byte[4], -1, 1))
+ .isInstanceOf(IndexOutOfBoundsException.class);
+ assertThatThrownBy(() -> in.read(new byte[4], 0, 5))
+ .isInstanceOf(IndexOutOfBoundsException.class);
+ reuse.close();
+ }
+
+ @Test
+ public void testOpenBoundedRejectsMisuse() throws IOException {
+ Map<String, byte[]> files = new HashMap<>();
+ files.put("mem://a", range(0, 10));
+ files.put("mem://b", range(0, 10));
+ CountingUriReader reader = new CountingUriReader(files);
+ ReusingBlobRefStreamProvider reuse = new
ReusingBlobRefStreamProvider();
+
+ // Subclass (may override newInputStream()) and unknown length are
rejected.
+ BlobRef subclass = new BlobRef(reader, new BlobDescriptor("mem://a",
0, 4)) {};
+ assertThatThrownBy(() -> reuse.openBounded(subclass))
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(
+ () ->
+ reuse.openBounded(
+ new BlobRef(reader, new
BlobDescriptor("mem://a", 0, -1))))
+ .isInstanceOf(IllegalArgumentException.class);
+
+ // A ref not matching the open source (prepareFor skipped) is
rejected, not silently reused.
+ reuse.openBounded(new BlobRef(reader, new BlobDescriptor("mem://a", 0,
4)));
+ assertThatThrownBy(
+ () ->
+ reuse.openBounded(
+ new BlobRef(reader, new
BlobDescriptor("mem://b", 0, 4))))
+ .isInstanceOf(IllegalStateException.class);
+ reuse.close();
+ }
+
+ @Test
+ public void testReturnedStreamIsReadOnly() throws IOException {
+ Map<String, byte[]> files = new HashMap<>();
+ files.put("mem://f", range(0, 20));
+ ReusingBlobRefStreamProvider reuse = new
ReusingBlobRefStreamProvider();
+ SeekableInputStream in =
+ reuse.openBounded(
+ new BlobRef(
+ new CountingUriReader(files), new
BlobDescriptor("mem://f", 5, 5)));
+ assertThatThrownBy(() ->
in.seek(0)).isInstanceOf(UnsupportedOperationException.class);
+ reuse.close();
+ }
+}
diff --git
a/paimon-common/src/test/java/org/apache/paimon/fs/OffsetSeekableInputStreamTest.java
b/paimon-common/src/test/java/org/apache/paimon/fs/OffsetSeekableInputStreamTest.java
index 16a5e3924c..8a62fff520 100644
---
a/paimon-common/src/test/java/org/apache/paimon/fs/OffsetSeekableInputStreamTest.java
+++
b/paimon-common/src/test/java/org/apache/paimon/fs/OffsetSeekableInputStreamTest.java
@@ -209,6 +209,15 @@ public class OffsetSeekableInputStreamTest {
verify(mockStream, times(1)).close();
}
+ @Test
+ public void testConstructorSeekFailureClosesWrapped() throws IOException {
+ SeekableInputStream mockStream = mock(SeekableInputStream.class);
+ org.mockito.Mockito.doThrow(new IOException("seek
failed")).when(mockStream).seek(5);
+ assertThatThrownBy(() -> new OffsetSeekableInputStream(mockStream, 5,
10))
+ .isInstanceOf(IOException.class);
+ verify(mockStream, times(1)).close();
+ }
+
@Test
public void testReadWithUnlimitedLength() throws IOException {
long offset = 5;
diff --git
a/paimon-format/src/main/java/org/apache/paimon/format/blob/AbstractBlobElementReader.java
b/paimon-format/src/main/java/org/apache/paimon/format/blob/AbstractBlobElementReader.java
index 51af6d0dc4..e183be8936 100644
---
a/paimon-format/src/main/java/org/apache/paimon/format/blob/AbstractBlobElementReader.java
+++
b/paimon-format/src/main/java/org/apache/paimon/format/blob/AbstractBlobElementReader.java
@@ -19,11 +19,14 @@
package org.apache.paimon.format.blob;
import org.apache.paimon.data.Blob;
+import org.apache.paimon.data.BlobDescriptor;
+import org.apache.paimon.data.BlobRef;
import org.apache.paimon.fs.FileIO;
import org.apache.paimon.fs.Path;
import org.apache.paimon.fs.SeekableInputStream;
import org.apache.paimon.utils.IOUtils;
import org.apache.paimon.utils.Preconditions;
+import org.apache.paimon.utils.UriReader;
import javax.annotation.Nullable;
@@ -32,7 +35,7 @@ import java.io.IOException;
/** Common reader functionality shared by blob element formats. */
abstract class AbstractBlobElementReader implements
BlobElementSerializer.Reader {
- private final FileIO fileIO;
+ private final UriReader uriReader;
private final String filePath;
private final @Nullable SeekableInputStream in;
private final boolean blobAsDescriptor;
@@ -42,7 +45,7 @@ abstract class AbstractBlobElementReader implements
BlobElementSerializer.Reader
Path filePath,
@Nullable SeekableInputStream in,
boolean blobAsDescriptor) {
- this.fileIO = fileIO;
+ this.uriReader = UriReader.fromFile(fileIO);
this.filePath = filePath.toString();
this.in = in;
this.blobAsDescriptor = blobAsDescriptor;
@@ -58,7 +61,7 @@ abstract class AbstractBlobElementReader implements
BlobElementSerializer.Reader
protected final Blob readBlob(long position, long length) {
if (blobAsDescriptor) {
- return Blob.fromFile(fileIO, filePath, position, length);
+ return new BlobRef(uriReader, new BlobDescriptor(filePath,
position, length));
}
return Blob.fromData(readInlineBlob(position, length));
}
diff --git
a/paimon-format/src/main/java/org/apache/paimon/format/blob/AbstractBlobElementWriter.java
b/paimon-format/src/main/java/org/apache/paimon/format/blob/AbstractBlobElementWriter.java
index 3a2293cbc9..a79114e8b1 100644
---
a/paimon-format/src/main/java/org/apache/paimon/format/blob/AbstractBlobElementWriter.java
+++
b/paimon-format/src/main/java/org/apache/paimon/format/blob/AbstractBlobElementWriter.java
@@ -24,6 +24,7 @@ import org.apache.paimon.data.BlobDescriptor;
import org.apache.paimon.data.BlobFetchMetricReporter;
import org.apache.paimon.data.BlobRef;
import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.data.ReusingBlobRefStreamProvider;
import org.apache.paimon.fs.Path;
import org.apache.paimon.fs.PositionOutputStream;
import org.apache.paimon.fs.SeekableInputStream;
@@ -35,6 +36,7 @@ import org.slf4j.LoggerFactory;
import javax.annotation.Nullable;
+import java.io.EOFException;
import java.io.IOException;
import java.util.zip.CRC32;
@@ -55,6 +57,7 @@ abstract class AbstractBlobElementWriter implements
BlobElementSerializer.Writer
private final BlobFetchMetricReporter blobFetchMetricReporter;
private final CRC32 crc32;
private final byte[] copyBuffer;
+ private final ReusingBlobRefStreamProvider reuseSource;
private String pathString;
@@ -78,6 +81,7 @@ abstract class AbstractBlobElementWriter implements
BlobElementSerializer.Writer
this.blobFetchMetricReporter = blobFetchMetricReporter;
this.crc32 = new CRC32();
this.copyBuffer = new byte[copyBufferSize];
+ this.reuseSource = new ReusingBlobRefStreamProvider();
}
@Override
@@ -124,10 +128,39 @@ abstract class AbstractBlobElementWriter implements
BlobElementSerializer.Writer
}
}
- protected final @Nullable SeekableInputStream openBlobInputStream(Blob
blob)
- throws IOException {
+ /**
+ * Prepares the byte source for a blob, or returns {@code null} to write
it as NULL (missing
+ * file / fetch failure). An exact {@link BlobRef} with known length
reuses one source stream
+ * (bounded to the descriptor); other blobs open their own stream and read
until EOF.
+ */
+ protected final @Nullable BlobCopySource prepareBlobSource(Blob blob)
throws IOException {
+ // Exact class only: subclasses may override newInputStream() and must
not be bypassed.
+ if (blob != null && blob.getClass() == BlobRef.class) {
+ BlobRef ref = (BlobRef) blob;
+ long length = ref.toDescriptor().length();
+ if (length >= 0) {
+ // Position/release the previous source first; its cleanup
error is not this blob's
+ // fetch failure and must not be turned into a NULL write.
+ reuseSource.prepareFor(ref);
+ SeekableInputStream source = openStream(ref, () ->
reuseSource.openBounded(ref));
+ if (source == null) {
+ return null;
+ }
+ return new BlobCopySource(source, length);
+ }
+ }
+
+ SeekableInputStream source = openStream(blob, blob::newInputStream);
+ if (source == null) {
+ return null;
+ }
+ return new BlobCopySource(source, -1L);
+ }
+
+ @Nullable
+ private SeekableInputStream openStream(Blob blob, StreamOpener opener)
throws IOException {
try {
- return blob.newInputStream();
+ return opener.open();
} catch (IOException | RuntimeException e) {
if (writeNullOnMissingFile && HttpClientUtils.isNotFoundError(e)) {
LOG.warn(
@@ -148,21 +181,55 @@ abstract class AbstractBlobElementWriter implements
BlobElementSerializer.Writer
}
}
- protected final BlobDescriptor writeBlobData(SeekableInputStream in)
throws IOException {
+ protected final BlobDescriptor writeBlobData(BlobCopySource source) throws
IOException {
long blobPosition = out.getPos();
- try (SeekableInputStream stream = in) {
- int bytesRead = stream.read(copyBuffer);
- while (bytesRead >= 0) {
- write(copyBuffer, bytesRead);
- bytesRead = stream.read(copyBuffer);
+ if (source.reused()) {
+ try {
+ copyExactly(source.stream(), source.length());
+ } catch (IOException | RuntimeException e) {
+ blobFetchMetricReporter.recordFetchFailure(e);
+ // Source is at an unknown position now; drop it so the next
blob reopens.
+ reuseSource.discardQuietly();
+ throw e;
+ }
+ } else {
+ try (SeekableInputStream stream = source.stream()) {
+ int bytesRead = stream.read(copyBuffer);
+ while (bytesRead >= 0) {
+ write(copyBuffer, bytesRead);
+ bytesRead = stream.read(copyBuffer);
+ }
+ } catch (IOException | RuntimeException e) {
+ blobFetchMetricReporter.recordFetchFailure(e);
+ throw e;
}
- } catch (IOException | RuntimeException e) {
- blobFetchMetricReporter.recordFetchFailure(e);
- throw e;
}
return new BlobDescriptor(pathString, blobPosition, out.getPos() -
blobPosition);
}
+ /** Copies exactly {@code length} bytes from {@code stream}, throwing on
premature EOF. */
+ private void copyExactly(SeekableInputStream stream, long length) throws
IOException {
+ long remaining = length;
+ while (remaining > 0) {
+ int toRead = (int) Math.min(copyBuffer.length, remaining);
+ int bytesRead = stream.read(copyBuffer, 0, toRead);
+ if (bytesRead < 0) {
+ throw new EOFException(
+ String.format(
+ "Unexpected EOF while copying BLOB payload for
field %s: expected %d "
+ + "bytes but source ended %d bytes
early.",
+ blobFieldName, length, remaining));
+ }
+ if (bytesRead == 0) {
+ throw new IOException(
+ "Source returned 0 bytes while copying BLOB payload
for field "
+ + blobFieldName);
+ }
+ write(copyBuffer, bytesRead);
+ remaining -= bytesRead;
+ }
+ }
+
protected final boolean accept(BlobDescriptor descriptor) throws
IOException {
return writeConsumer != null && writeConsumer.accept(blobFieldName,
descriptor);
}
@@ -236,6 +303,40 @@ abstract class AbstractBlobElementWriter implements
BlobElementSerializer.Writer
|| uri.regionMatches(true, 0, "https://", 0,
"https://".length());
}
+ @Override
+ public final void close() throws IOException {
+ reuseSource.close();
+ }
+
+ @FunctionalInterface
+ private interface StreamOpener {
+ SeekableInputStream open() throws IOException;
+ }
+
+ /** The byte source of a single blob payload to be copied into the blob
file. */
+ protected static final class BlobCopySource {
+
+ private final SeekableInputStream stream;
+ private final long length;
+
+ private BlobCopySource(SeekableInputStream stream, long length) {
+ this.stream = stream;
+ this.length = length;
+ }
+
+ private boolean reused() {
+ return length >= 0;
+ }
+
+ private SeekableInputStream stream() {
+ return stream;
+ }
+
+ private long length() {
+ return length;
+ }
+ }
+
/** Lazily gets a Blob from a row or array. */
interface BlobGetter {
Blob get();
diff --git
a/paimon-format/src/main/java/org/apache/paimon/format/blob/ArrayBlobElementSerializer.java
b/paimon-format/src/main/java/org/apache/paimon/format/blob/ArrayBlobElementSerializer.java
index d6e673ea45..265f0883f2 100644
---
a/paimon-format/src/main/java/org/apache/paimon/format/blob/ArrayBlobElementSerializer.java
+++
b/paimon-format/src/main/java/org/apache/paimon/format/blob/ArrayBlobElementSerializer.java
@@ -138,12 +138,12 @@ final class ArrayBlobElementSerializer implements
BlobElementSerializer {
elementLengths[i] = NULL_ELEMENT_LENGTH;
continue;
}
- SeekableInputStream in = openBlobInputStream(blob);
- if (in == null) {
+ BlobCopySource source = prepareBlobSource(blob);
+ if (source == null) {
elementLengths[i] = NULL_ELEMENT_LENGTH;
continue;
}
- BlobDescriptor descriptor = writeBlobData(in);
+ BlobDescriptor descriptor = writeBlobData(source);
elementLengths[i] = descriptor.length();
flush |= accept(descriptor);
recordSuccess(descriptor.length());
diff --git
a/paimon-format/src/main/java/org/apache/paimon/format/blob/BlobElementSerializer.java
b/paimon-format/src/main/java/org/apache/paimon/format/blob/BlobElementSerializer.java
index ca98461053..e1499a89f5 100644
---
a/paimon-format/src/main/java/org/apache/paimon/format/blob/BlobElementSerializer.java
+++
b/paimon-format/src/main/java/org/apache/paimon/format/blob/BlobElementSerializer.java
@@ -71,7 +71,7 @@ interface BlobElementSerializer {
}
/** Writer used for the lifetime of one output file. */
- interface Writer {
+ interface Writer extends Closeable {
long write(InternalRow row) throws IOException;
diff --git
a/paimon-format/src/main/java/org/apache/paimon/format/blob/BlobFormatWriter.java
b/paimon-format/src/main/java/org/apache/paimon/format/blob/BlobFormatWriter.java
index 018b75421c..e95722c2f5 100644
---
a/paimon-format/src/main/java/org/apache/paimon/format/blob/BlobFormatWriter.java
+++
b/paimon-format/src/main/java/org/apache/paimon/format/blob/BlobFormatWriter.java
@@ -98,10 +98,27 @@ public class BlobFormatWriter implements
FileAwareFormatWriter {
@Override
public void close() throws IOException {
- byte[] indexBytes = DeltaVarintCompressor.compress(lengths.toArray());
- out.write(indexBytes);
- out.write(intToLittleEndian(indexBytes.length));
- out.write(VERSION);
+ Throwable primary = null;
+ try {
+ byte[] indexBytes =
DeltaVarintCompressor.compress(lengths.toArray());
+ out.write(indexBytes);
+ out.write(intToLittleEndian(indexBytes.length));
+ out.write(VERSION);
+ } catch (RuntimeException | Error | IOException e) {
+ primary = e;
+ throw e;
+ } finally {
+ // Surface the footer error as primary and attach a source-close
error as suppressed.
+ if (primary == null) {
+ elementWriter.close();
+ } else {
+ try {
+ elementWriter.close();
+ } catch (RuntimeException | Error | IOException suppressed) {
+ primary.addSuppressed(suppressed);
+ }
+ }
+ }
}
private static BlobElementSerializer.Writer createElementWriter(
diff --git
a/paimon-format/src/main/java/org/apache/paimon/format/blob/MapBlobElementSerializer.java
b/paimon-format/src/main/java/org/apache/paimon/format/blob/MapBlobElementSerializer.java
index 8c96edca27..1a4b922552 100644
---
a/paimon-format/src/main/java/org/apache/paimon/format/blob/MapBlobElementSerializer.java
+++
b/paimon-format/src/main/java/org/apache/paimon/format/blob/MapBlobElementSerializer.java
@@ -208,12 +208,12 @@ final class MapBlobElementSerializer implements
BlobElementSerializer {
valueLengths[i] = NULL_VALUE_LENGTH;
continue;
}
- SeekableInputStream in = openBlobInputStream(blob);
- if (in == null) {
+ BlobCopySource source = prepareBlobSource(blob);
+ if (source == null) {
valueLengths[i] = NULL_VALUE_LENGTH;
continue;
}
- BlobDescriptor descriptor = writeBlobData(in);
+ BlobDescriptor descriptor = writeBlobData(source);
valueLengths[i] = descriptor.length();
flush |= accept(descriptor);
recordSuccess(descriptor.length());
diff --git
a/paimon-format/src/main/java/org/apache/paimon/format/blob/RawBlobElementSerializer.java
b/paimon-format/src/main/java/org/apache/paimon/format/blob/RawBlobElementSerializer.java
index 2a73b0947a..d649c61e0b 100644
---
a/paimon-format/src/main/java/org/apache/paimon/format/blob/RawBlobElementSerializer.java
+++
b/paimon-format/src/main/java/org/apache/paimon/format/blob/RawBlobElementSerializer.java
@@ -106,13 +106,13 @@ final class RawBlobElementSerializer implements
BlobElementSerializer {
return writePlaceholderElement();
}
- SeekableInputStream in = openBlobInputStream(blob);
- if (in == null) {
+ BlobCopySource source = prepareBlobSource(blob);
+ if (source == null) {
return writeNullElement();
}
long recordPosition = startRecord();
- BlobDescriptor descriptor = writeBlobData(in);
+ BlobDescriptor descriptor = writeBlobData(source);
long recordLength = finishRecord(recordPosition);
if (accept(descriptor)) {
flush();
diff --git
a/paimon-format/src/test/java/org/apache/paimon/format/blob/BlobFormatWriterTest.java
b/paimon-format/src/test/java/org/apache/paimon/format/blob/BlobFormatWriterTest.java
index cf96d40e06..0537c70951 100644
---
a/paimon-format/src/test/java/org/apache/paimon/format/blob/BlobFormatWriterTest.java
+++
b/paimon-format/src/test/java/org/apache/paimon/format/blob/BlobFormatWriterTest.java
@@ -49,8 +49,13 @@ import org.apache.paimon.utils.UriReaderFactory;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
+import java.io.ByteArrayInputStream;
+import java.io.EOFException;
+import java.io.IOException;
import java.nio.file.Files;
import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -656,6 +661,7 @@ public class BlobFormatWriterTest {
private final Map<String, byte[]> files;
private final List<CountingSeekableInputStream> opened = new
ArrayList<>();
+ private int openCount;
private RecordingUriReader(Map<String, byte[]> files) {
this.files = files;
@@ -667,6 +673,7 @@ public class BlobFormatWriterTest {
if (data == null) {
throw new IllegalArgumentException("Unknown uri: " + uri);
}
+ openCount++;
CountingSeekableInputStream stream = new
CountingSeekableInputStream(data);
opened.add(stream);
return stream;
@@ -679,6 +686,7 @@ public class BlobFormatWriterTest {
private final byte[] data;
private int pos;
private int maxReadRequest;
+ private int closeCount;
private CountingSeekableInputStream(byte[] data) {
this.data = data;
@@ -719,7 +727,9 @@ public class BlobFormatWriterTest {
}
@Override
- public void close() {}
+ public void close() {
+ closeCount++;
+ }
}
@Test
@@ -1019,4 +1029,573 @@ public class BlobFormatWriterTest {
return Blob.fromBytes(descriptorBytes, uriReaderFactory, null);
}
}
+
+ @Test
+ public void testArrayBlobRefsReuseSource(@TempDir java.nio.file.Path
tempDir) throws Exception {
+ String uri = "mem://file";
+ byte[] source = sequentialBytes(30);
+ RecordingUriReader reader = new RecordingUriReader(singleFile(uri,
source));
+ java.nio.file.Path outputFile = tempDir.resolve("blob.out");
+
+ BlobFormatWriter writer =
+ newWriter(outputFile,
RowType.of(DataTypes.ARRAY(DataTypes.BLOB())));
+ writer.addElement(
+ GenericRow.of(
+ new GenericArray(
+ new Object[] {
+ new BlobRef(reader, new
BlobDescriptor(uri, 0, 10)),
+ new BlobRef(reader, new
BlobDescriptor(uri, 10, 10)),
+ new BlobRef(reader, new
BlobDescriptor(uri, 20, 10))
+ })));
+ writer.close();
+
+ assertThat(reader.openCount).isEqualTo(1);
+
+ LocalFileIO fileIO = new LocalFileIO();
+ Path filePath = new Path(outputFile.toUri());
+ long fileSize = Files.size(outputFile);
+ try (SeekableInputStream in = fileIO.newInputStream(filePath)) {
+ BlobFileMeta fileMeta = new BlobFileMeta(in, fileSize, null);
+ assertThat(fileMeta.recordNumber()).isEqualTo(1);
+ BlobFormatReader arrayReader =
+ new BlobFormatReader(
+ fileIO,
+ filePath,
+ fileMeta,
+ in,
+ 1,
+ 0,
+ DataTypes.ARRAY(DataTypes.BLOB()),
+ false);
+ InternalArray array = arrayReader.readBatch().next().getArray(0);
+ assertThat(array.size()).isEqualTo(3);
+
assertThat(readAll(array.getBlob(0))).isEqualTo(Arrays.copyOfRange(source, 0,
10));
+
assertThat(readAll(array.getBlob(1))).isEqualTo(Arrays.copyOfRange(source, 10,
20));
+
assertThat(readAll(array.getBlob(2))).isEqualTo(Arrays.copyOfRange(source, 20,
30));
+ }
+ }
+
+ @Test
+ public void testMapBlobRefsReuseSource(@TempDir java.nio.file.Path
tempDir) throws Exception {
+ String uri = "mem://file";
+ byte[] source = sequentialBytes(30);
+ RecordingUriReader reader = new RecordingUriReader(singleFile(uri,
source));
+ java.nio.file.Path outputFile = tempDir.resolve("blob.out");
+
+ Map<Object, Object> entries = new LinkedHashMap<>();
+ entries.put(
+ BinaryString.fromString("a"), new BlobRef(reader, new
BlobDescriptor(uri, 0, 10)));
+ entries.put(
+ BinaryString.fromString("b"), new BlobRef(reader, new
BlobDescriptor(uri, 10, 10)));
+ entries.put(
+ BinaryString.fromString("c"), new BlobRef(reader, new
BlobDescriptor(uri, 20, 10)));
+
+ BlobFormatWriter writer =
+ newWriter(
+ outputFile,
+ RowType.of(DataTypes.MAP(DataTypes.STRING(),
DataTypes.BLOB())));
+ writer.addElement(GenericRow.of(new GenericMap(entries)));
+ writer.close();
+
+ assertThat(reader.openCount).isEqualTo(1);
+ }
+
+ @Test
+ public void testBlobRefEqualityContractUnchanged() {
+ String uri = "mem://file";
+ RecordingUriReader reader = new RecordingUriReader(singleFile(uri, new
byte[] {1, 2, 3}));
+ BlobDescriptor descriptor = new BlobDescriptor(uri, 0, 3);
+ BlobRef plain = new BlobRef(reader, descriptor);
+ BlobRef sameDescriptor = new BlobRef(reader, descriptor);
+ BlobRef subclass = new BlobRef(reader, descriptor) {};
+
+ // Exact-class equality: plain refs equal, subclasses unequal both
ways.
+ assertThat(plain).isEqualTo(sameDescriptor);
+ assertThat(plain.hashCode()).isEqualTo(sameDescriptor.hashCode());
+ assertThat(plain).isNotEqualTo(subclass);
+ assertThat(subclass).isNotEqualTo(plain);
+ assertThat(new java.util.HashSet<>(Arrays.asList(plain,
subclass))).hasSize(2);
+ assertThat(new java.util.HashSet<>(Arrays.asList(subclass,
plain))).hasSize(2);
+ }
+
+ @Test
+ public void testBlobRefSubclassIsNotBypassed(@TempDir java.nio.file.Path
tempDir)
+ throws Exception {
+ String uri = "mem://file";
+ // Raw source holds [1,2,3]; the subclass overrides newInputStream()
to yield [9,9,9].
+ RecordingUriReader reader = new RecordingUriReader(singleFile(uri, new
byte[] {1, 2, 3}));
+ BlobRef transforming =
+ new BlobRef(reader, new BlobDescriptor(uri, 0, 3)) {
+ @Override
+ public SeekableInputStream newInputStream() {
+ return new CountingSeekableInputStream(new byte[] {9,
9, 9});
+ }
+ };
+ java.nio.file.Path outputFile = tempDir.resolve("blob.out");
+
+ BlobFormatWriter writer = newWriter(outputFile,
RowType.of(DataTypes.BLOB()));
+ writer.addElement(GenericRow.of(transforming));
+ writer.close();
+
+ // The override is honored (slow path); the raw-source fast path did
not bypass it.
+ assertThat(readBackBlobs(outputFile, 1)).containsExactly(new byte[]
{9, 9, 9});
+ assertThat(reader.openCount).isEqualTo(0);
+ }
+
+ @Test
+ public void testConsecutiveBlobRefsShareSingleOpenSource(@TempDir
java.nio.file.Path tempDir)
+ throws Exception {
+ String uri = "mem://file";
+ byte[] source = sequentialBytes(1000);
+ RecordingUriReader reader = new RecordingUriReader(singleFile(uri,
source));
+ java.nio.file.Path outputFile = tempDir.resolve("blob.out");
+
+ BlobFormatWriter writer = newWriter(outputFile,
RowType.of(DataTypes.BLOB()));
+ List<byte[]> expected = new ArrayList<>();
+ for (int i = 0; i < 100; i++) {
+ int offset = i * 10;
+ writer.addElement(
+ GenericRow.of(new BlobRef(reader, new BlobDescriptor(uri,
offset, 10))));
+ expected.add(Arrays.copyOfRange(source, offset, offset + 10));
+ }
+ writer.close();
+
+ // 100 references into the same source only opened it once.
+ assertThat(reader.openCount).isEqualTo(1);
+ assertThat(reader.opened.get(0).closeCount).isEqualTo(1);
+ assertThat(readBackBlobs(outputFile,
100)).containsExactlyElementsOf(expected);
+ }
+
+ @Test
+ public void testEmptyBlobRefsReuseWithoutFailing(@TempDir
java.nio.file.Path tempDir)
+ throws Exception {
+ String uri = "mem://empty";
+ RecordingUriReader reader = new RecordingUriReader(singleFile(uri, new
byte[0]));
+ java.nio.file.Path outputFile = tempDir.resolve("blob.out");
+
+ BlobFormatWriter writer = newWriter(outputFile,
RowType.of(DataTypes.BLOB()));
+ writer.addElement(GenericRow.of(new BlobRef(reader, new
BlobDescriptor(uri, 0, 0))));
+ writer.addElement(GenericRow.of(new BlobRef(reader, new
BlobDescriptor(uri, 0, 0))));
+ writer.close();
+
+ // Two consecutive empty refs reuse one open source and write empty
(non-NULL) blobs.
+ assertThat(reader.openCount).isEqualTo(1);
+ assertThat(readBackBlobs(outputFile, 2)).containsExactly(new byte[0],
new byte[0]);
+ }
+
+ @Test
+ public void testForwardOnlyRewindCloseErrorNotWrittenNull(@TempDir
java.nio.file.Path tempDir)
+ throws Exception {
+ // First source is forward-only and fails to close; the second
same-uri ref needs a rewind.
+ // Even with writeNullOnFetchFailure, the close error must abort, not
become a NULL.
+ boolean[] first = {true};
+ UriReader reader =
+ u -> {
+ if (first[0]) {
+ first[0] = false;
+ return new ForwardOnlyCloseFailingStream(new byte[]
{1, 2, 3});
+ }
+ return new CountingSeekableInputStream(new byte[] {1, 2,
3});
+ };
+ BlobFormatWriter writer =
+ newWriter(tempDir.resolve("blob.out"),
RowType.of(DataTypes.BLOB()), false, true);
+ BlobDescriptor descriptor = new BlobDescriptor("mem://f", 0, 3);
+ writer.addElement(GenericRow.of(new BlobRef(reader, descriptor)));
+ assertThatThrownBy(() -> writer.addElement(GenericRow.of(new
BlobRef(reader, descriptor))))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("close failed");
+ }
+
+ @Test
+ public void testKnownLengthNonSeekableSourceStillWrites(@TempDir
java.nio.file.Path tempDir)
+ throws Exception {
+ byte[] payload = {1, 2, 3};
+ // A non-seekable source (like a wrapped HTTP stream) with known
length and offset 0.
+ UriReader nonSeekable = u -> SeekableInputStream.wrap(new
ByteArrayInputStream(payload));
+ java.nio.file.Path outputFile = tempDir.resolve("blob.out");
+
+ BlobFormatWriter writer = newWriter(outputFile,
RowType.of(DataTypes.BLOB()));
+ writer.addElement(
+ GenericRow.of(new BlobRef(nonSeekable, new
BlobDescriptor("mem://x", 0, 3))));
+ writer.close();
+
+ // Fast-path seek fails, so it falls back to the per-blob stream and
still writes the blob.
+ assertThat(readBackBlobs(outputFile, 1)).containsExactly(payload);
+ }
+
+ @Test
+ public void testNonBlobRefDoesNotDisturbReusableSource(@TempDir
java.nio.file.Path tempDir)
+ throws Exception {
+ String uri = "mem://file";
+ byte[] source = sequentialBytes(30);
+ RecordingUriReader reader = new RecordingUriReader(singleFile(uri,
source));
+ byte[] inlinePayload = "inline-blob".getBytes();
+ java.nio.file.Path outputFile = tempDir.resolve("blob.out");
+
+ BlobFormatWriter writer = newWriter(outputFile,
RowType.of(DataTypes.BLOB()));
+ writer.addElement(GenericRow.of(new BlobRef(reader, new
BlobDescriptor(uri, 0, 5))));
+ // a non-BlobRef blob in between must not close/reopen the reusable
source.
+ writer.addElement(GenericRow.of(Blob.fromData(inlinePayload)));
+ writer.addElement(GenericRow.of(new BlobRef(reader, new
BlobDescriptor(uri, 5, 5))));
+ writer.close();
+
+ assertThat(reader.openCount).isEqualTo(1);
+ assertThat(readBackBlobs(outputFile, 3))
+ .containsExactly(
+ Arrays.copyOfRange(source, 0, 5),
+ inlinePayload,
+ Arrays.copyOfRange(source, 5, 10));
+ }
+
+ @Test
+ public void testOutOfOrderDescriptorsReadViaSeek(@TempDir
java.nio.file.Path tempDir)
+ throws Exception {
+ String uri = "mem://file";
+ byte[] source = sequentialBytes(30);
+ RecordingUriReader reader = new RecordingUriReader(singleFile(uri,
source));
+ java.nio.file.Path outputFile = tempDir.resolve("blob.out");
+
+ BlobFormatWriter writer = newWriter(outputFile,
RowType.of(DataTypes.BLOB()));
+ writer.addElement(GenericRow.of(new BlobRef(reader, new
BlobDescriptor(uri, 20, 10))));
+ writer.addElement(GenericRow.of(new BlobRef(reader, new
BlobDescriptor(uri, 0, 10))));
+ writer.addElement(GenericRow.of(new BlobRef(reader, new
BlobDescriptor(uri, 10, 10))));
+ writer.close();
+
+ assertThat(reader.openCount).isEqualTo(1);
+ assertThat(readBackBlobs(outputFile, 3))
+ .containsExactly(
+ Arrays.copyOfRange(source, 20, 30),
+ Arrays.copyOfRange(source, 0, 10),
+ Arrays.copyOfRange(source, 10, 20));
+ }
+
+ @Test
+ public void testReaderProducedBlobRefsReuseSingleOpen(@TempDir
java.nio.file.Path tempDir)
+ throws Exception {
+ // Write a source blob file with several payloads.
+ byte[][] payloads = {
+ "blob-0".getBytes(),
+ "blob-1".getBytes(),
+ "blob-2-longer-payload".getBytes(),
+ "blob-3".getBytes(),
+ "blob-4".getBytes()
+ };
+ java.nio.file.Path sourceFile = tempDir.resolve("source.blob");
+ BlobFormatWriter sourceWriter = newWriter(sourceFile,
RowType.of(DataTypes.BLOB()));
+ for (byte[] payload : payloads) {
+ sourceWriter.addElement(GenericRow.of(Blob.fromData(payload)));
+ }
+ sourceWriter.close();
+
+ // Read it back as descriptors through the real BlobFormatReader path.
+ CountingLocalFileIO fileIO = new CountingLocalFileIO();
+ Path sourcePath = new Path(sourceFile.toUri());
+ long sourceSize = Files.size(sourceFile);
+ List<InternalRow> rows = new ArrayList<>();
+ try (SeekableInputStream in = fileIO.newInputStream(sourcePath)) {
+ BlobFileMeta fileMeta = new BlobFileMeta(in, sourceSize, null);
+ BlobFormatReader reader =
+ new BlobFormatReader(
+ fileIO, sourcePath, fileMeta, in, 1, 0,
DataTypes.BLOB(), true);
+ FileRecordIterator<InternalRow> iterator = reader.readBatch();
+ for (int i = 0; i < payloads.length; i++) {
+ InternalRow row = iterator.next();
+ // Plain BlobRef: reader refs keep the public equality
contract.
+ assertThat(row.getBlob(0).getClass()).isEqualTo(BlobRef.class);
+ rows.add(GenericRow.of(row.getBlob(0)));
+ }
+ }
+ // Reading only opened the source once (for the reader's own stream).
+ assertThat(fileIO.openCount(sourcePath)).isEqualTo(1);
+
+ // Rewrite the descriptor BlobRefs; the writer must reuse one source
stream, not one per
+ // blob.
+ java.nio.file.Path outputFile = tempDir.resolve("out.blob");
+ BlobFormatWriter writer = newWriter(outputFile,
RowType.of(DataTypes.BLOB()));
+ for (InternalRow row : rows) {
+ writer.addElement(row);
+ }
+ writer.close();
+
+ // Exactly one additional open for all rewritten blobs.
+ assertThat(fileIO.openCount(sourcePath)).isEqualTo(2);
+ assertThat(readBackBlobs(outputFile,
payloads.length)).containsExactly(payloads);
+ }
+
+ @Test
+ public void testSameUriDifferentOffsetAndLength(@TempDir
java.nio.file.Path tempDir)
+ throws Exception {
+ String uri = "mem://file";
+ byte[] source = sequentialBytes(30);
+ RecordingUriReader reader = new RecordingUriReader(singleFile(uri,
source));
+ java.nio.file.Path outputFile = tempDir.resolve("blob.out");
+
+ BlobFormatWriter writer = newWriter(outputFile,
RowType.of(DataTypes.BLOB()));
+ writer.addElement(GenericRow.of(new BlobRef(reader, new
BlobDescriptor(uri, 0, 4))));
+ writer.addElement(GenericRow.of(new BlobRef(reader, new
BlobDescriptor(uri, 4, 11))));
+ writer.addElement(GenericRow.of(new BlobRef(reader, new
BlobDescriptor(uri, 15, 15))));
+ writer.close();
+
+ assertThat(reader.openCount).isEqualTo(1);
+ assertThat(readBackBlobs(outputFile, 3))
+ .containsExactly(
+ Arrays.copyOfRange(source, 0, 4),
+ Arrays.copyOfRange(source, 4, 15),
+ Arrays.copyOfRange(source, 15, 30));
+ }
+
+ @Test
+ public void testSeekFailureRespectsWriteNullConfig(@TempDir
java.nio.file.Path tempDir)
+ throws Exception {
+ RowType rowType = RowType.of(DataTypes.BLOB());
+ UriReader seekFailing = u -> new SeekFailingStream();
+ BlobDescriptor descriptor = new BlobDescriptor("mem://x", 2, 3);
+
+ // writeNullOnFetchFailure=true: a seek failure writes NULL rather
than aborting.
+ java.nio.file.Path nullFile = tempDir.resolve("null.blob");
+ BlobFormatWriter nullWriter = newWriter(nullFile, rowType, false,
true);
+ nullWriter.addElement(GenericRow.of(new BlobRef(seekFailing,
descriptor)));
+ nullWriter.close();
+ LocalFileIO fileIO = new LocalFileIO();
+ try (SeekableInputStream in = fileIO.newInputStream(new
Path(nullFile.toUri()))) {
+ BlobFileMeta meta = new BlobFileMeta(in, Files.size(nullFile),
null);
+ assertThat(meta.recordNumber()).isEqualTo(1);
+ assertThat(meta.isNull(0)).isTrue();
+ }
+
+ // writeNullOnFetchFailure=false: the seek failure propagates.
+ BlobFormatWriter failWriter =
+ newWriter(tempDir.resolve("fail.blob"), rowType, false, false);
+ assertThatThrownBy(
+ () ->
+ failWriter.addElement(
+ GenericRow.of(new BlobRef(seekFailing,
descriptor))))
+ .isInstanceOf(IOException.class);
+ }
+
+ @Test
+ public void testShortSourceThrowsEof(@TempDir java.nio.file.Path tempDir)
throws Exception {
+ String uri = "mem://file";
+ byte[] source = sequentialBytes(5);
+ RecordingUriReader reader = new RecordingUriReader(singleFile(uri,
source));
+ java.nio.file.Path outputFile = tempDir.resolve("blob.out");
+
+ BlobFormatWriter writer = newWriter(outputFile,
RowType.of(DataTypes.BLOB()));
+ // descriptor claims 100 bytes but source only has 5.
+ assertThatThrownBy(
+ () ->
+ writer.addElement(
+ GenericRow.of(
+ new BlobRef(
+ reader, new
BlobDescriptor(uri, 0, 100)))))
+ .isInstanceOf(EOFException.class);
+ // the broken source stream is closed on failure.
+ assertThat(reader.opened.get(0).closeCount).isEqualTo(1);
+ }
+
+ @Test
+ public void testSourceSwitchCloseErrorDoesNotWriteNull(@TempDir
java.nio.file.Path tempDir)
+ throws Exception {
+ // First source fails to close; switching to a second source must not
turn that into a NULL.
+ UriReader reader =
+ u ->
+ u.equals("mem://a")
+ ? new CloseFailingStream(new byte[] {1, 2, 3})
+ : new CountingSeekableInputStream(new byte[]
{4, 5, 6});
+ BlobFormatWriter writer =
+ newWriter(tempDir.resolve("blob.out"),
RowType.of(DataTypes.BLOB()), false, true);
+ writer.addElement(GenericRow.of(new BlobRef(reader, new
BlobDescriptor("mem://a", 0, 3))));
+ // Even with writeNullOnFetchFailure, the switch-close error aborts
rather than losing data.
+ assertThatThrownBy(
+ () ->
+ writer.addElement(
+ GenericRow.of(
+ new BlobRef(
+ reader,
+ new
BlobDescriptor("mem://b", 0, 3)))))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("close failed");
+ }
+
+ @Test
+ public void testSourceSwitchClosesPreviousStream(@TempDir
java.nio.file.Path tempDir)
+ throws Exception {
+ String uriA = "mem://a";
+ String uriB = "mem://b";
+ byte[] sourceA = sequentialBytes(5);
+ byte[] sourceB = sequentialBytes(7);
+ Map<String, byte[]> files = new LinkedHashMap<>();
+ files.put(uriA, sourceA);
+ files.put(uriB, sourceB);
+ RecordingUriReader reader = new RecordingUriReader(files);
+ java.nio.file.Path outputFile = tempDir.resolve("blob.out");
+
+ BlobFormatWriter writer = newWriter(outputFile,
RowType.of(DataTypes.BLOB()));
+ writer.addElement(GenericRow.of(new BlobRef(reader, new
BlobDescriptor(uriA, 0, 5))));
+ writer.addElement(GenericRow.of(new BlobRef(reader, new
BlobDescriptor(uriB, 0, 7))));
+
+ // Switching sources opened a new stream and closed the previous one
immediately.
+ assertThat(reader.openCount).isEqualTo(2);
+ assertThat(reader.opened.get(0).closeCount).isEqualTo(1);
+ assertThat(reader.opened.get(1).closeCount).isEqualTo(0);
+
+ writer.close();
+ assertThat(reader.opened.get(0).closeCount).isEqualTo(1);
+ assertThat(reader.opened.get(1).closeCount).isEqualTo(1);
+ assertThat(readBackBlobs(outputFile, 2)).containsExactly(sourceA,
sourceB);
+ }
+
+ @Test
+ public void testWriterCloseClosesSourceOnce(@TempDir java.nio.file.Path
tempDir)
+ throws Exception {
+ String uri = "mem://file";
+ byte[] source = sequentialBytes(30);
+ RecordingUriReader reader = new RecordingUriReader(singleFile(uri,
source));
+ java.nio.file.Path outputFile = tempDir.resolve("blob.out");
+
+ BlobFormatWriter writer = newWriter(outputFile,
RowType.of(DataTypes.BLOB()));
+ writer.addElement(GenericRow.of(new BlobRef(reader, new
BlobDescriptor(uri, 0, 10))));
+ writer.addElement(GenericRow.of(new BlobRef(reader, new
BlobDescriptor(uri, 10, 10))));
+ assertThat(reader.opened.get(0).closeCount).isEqualTo(0);
+
+ writer.close();
+ assertThat(reader.opened.get(0).closeCount).isEqualTo(1);
+ }
+
+ @Test
+ public void testWriterCloseSurfacesSourceCloseError(@TempDir
java.nio.file.Path tempDir)
+ throws Exception {
+ UriReader closeFailing = u -> new CloseFailingStream(new byte[] {1, 2,
3});
+ BlobFormatWriter writer =
+ newWriter(tempDir.resolve("blob.out"),
RowType.of(DataTypes.BLOB()));
+ writer.addElement(
+ GenericRow.of(new BlobRef(closeFailing, new
BlobDescriptor("mem://x", 0, 3))));
+ // The reusable source's close error propagates from writer.close().
+ assertThatThrownBy(writer::close)
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("close failed");
+ }
+
+ private static final class CloseFailingStream extends SeekableInputStream {
+
+ private final byte[] data;
+ private int pos;
+
+ private CloseFailingStream(byte[] data) {
+ this.data = data;
+ }
+
+ @Override
+ public void seek(long desired) {
+ this.pos = (int) desired;
+ }
+
+ @Override
+ public long getPos() {
+ return pos;
+ }
+
+ @Override
+ public int read() {
+ return pos < data.length ? data[pos++] & 0xFF : -1;
+ }
+
+ @Override
+ public int read(byte[] b, int off, int len) {
+ if (pos >= data.length) {
+ return -1;
+ }
+ int n = Math.min(len, data.length - pos);
+ System.arraycopy(data, pos, b, off, n);
+ pos += n;
+ return n;
+ }
+
+ @Override
+ public void close() throws IOException {
+ throw new IOException("close failed");
+ }
+ }
+
+ private static final class CountingLocalFileIO extends LocalFileIO {
+
+ private final Map<String, Integer> opens = new HashMap<>();
+
+ @Override
+ public SeekableInputStream newInputStream(Path path) throws
IOException {
+ opens.merge(path.toString(), 1, Integer::sum);
+ return super.newInputStream(path);
+ }
+
+ private int openCount(Path path) {
+ return opens.getOrDefault(path.toString(), 0);
+ }
+ }
+
+ private static final class ForwardOnlyCloseFailingStream extends
SeekableInputStream {
+
+ private final byte[] data;
+ private int pos;
+
+ private ForwardOnlyCloseFailingStream(byte[] data) {
+ this.data = data;
+ }
+
+ @Override
+ public void seek(long desired) {
+ throw new UnsupportedOperationException("forward-only");
+ }
+
+ @Override
+ public long getPos() {
+ return pos;
+ }
+
+ @Override
+ public int read() {
+ return pos < data.length ? data[pos++] & 0xFF : -1;
+ }
+
+ @Override
+ public int read(byte[] b, int off, int len) {
+ if (pos >= data.length) {
+ return -1;
+ }
+ int n = Math.min(len, data.length - pos);
+ System.arraycopy(data, pos, b, off, n);
+ pos += n;
+ return n;
+ }
+
+ @Override
+ public void close() throws IOException {
+ throw new IOException("close failed");
+ }
+ }
+
+ private static final class SeekFailingStream extends SeekableInputStream {
+
+ @Override
+ public void seek(long desired) throws IOException {
+ throw new IOException("seek failed");
+ }
+
+ @Override
+ public long getPos() {
+ return 0;
+ }
+
+ @Override
+ public int read() {
+ return -1;
+ }
+
+ @Override
+ public int read(byte[] b, int off, int len) {
+ return -1;
+ }
+
+ @Override
+ public void close() {}
+ }
}