Skip to content

Commit 8f46a60

Browse files
committed
Core: Coalesce nearby blob reads in PuffinReader.readAll
Resolve the TODO from #4537 by reading contiguous blobs in a single request, handed back as no-copy ByteBuffer views. A contiguous run is split into bounded reads (capped at MANIFEST_TARGET_SIZE, 8 MiB) so a large blob section never allocates one oversized buffer.
1 parent 23b5ce8 commit 8f46a60

2 files changed

Lines changed: 133 additions & 21 deletions

File tree

core/src/main/java/org/apache/iceberg/puffin/PuffinReader.java

Lines changed: 59 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,17 @@
3535
import org.apache.iceberg.puffin.PuffinFormat.Flag;
3636
import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
3737
import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList;
38+
import org.apache.iceberg.relocated.com.google.common.collect.Lists;
3839
import org.apache.iceberg.relocated.com.google.common.io.ByteStreams;
3940
import org.apache.iceberg.util.Pair;
4041

4142
public class PuffinReader implements Closeable {
4243
// Must not be modified
4344
private static final byte[] MAGIC = PuffinFormat.getMagic();
4445

46+
// Bound on a single coalesced read, so a long contiguous run is split into bounded buffers.
47+
private static final long MAX_COALESCED_READ_SIZE = 8 * 1024 * 1024;
48+
4549
private final long fileSize;
4650
private final SeekableInputStream input;
4751
private Integer knownFooterSize;
@@ -125,27 +129,61 @@ public Iterable<Pair<BlobMetadata, ByteBuffer>> readAll(List<BlobMetadata> blobs
125129
return ImmutableList.of();
126130
}
127131

128-
// TODO inspect blob offsets and coalesce read regions close to each other
129-
130-
return () ->
131-
blobs.stream()
132-
.sorted(Comparator.comparingLong(BlobMetadata::offset))
133-
.map(
134-
(BlobMetadata blobMetadata) -> {
135-
try {
136-
input.seek(blobMetadata.offset());
137-
byte[] bytes = new byte[Math.toIntExact(blobMetadata.length())];
138-
ByteStreams.readFully(input, bytes);
139-
ByteBuffer rawData = ByteBuffer.wrap(bytes);
140-
PuffinCompressionCodec codec =
141-
PuffinCompressionCodec.forName(blobMetadata.compressionCodec());
142-
ByteBuffer data = PuffinFormat.decompress(codec, rawData);
143-
return Pair.of(blobMetadata, data);
144-
} catch (IOException e) {
145-
throw new UncheckedIOException(e);
146-
}
147-
})
148-
.iterator();
132+
List<BlobMetadata> ordered = Lists.newArrayList(blobs);
133+
ordered.sort(Comparator.comparingLong(BlobMetadata::offset));
134+
List<List<BlobMetadata>> groups = coalesce(ordered, MAX_COALESCED_READ_SIZE);
135+
136+
return () -> groups.stream().flatMap(group -> readGroup(group).stream()).iterator();
137+
}
138+
139+
private List<Pair<BlobMetadata, ByteBuffer>> readGroup(List<BlobMetadata> group) {
140+
long start = group.get(0).offset();
141+
long end = start;
142+
for (BlobMetadata blob : group) {
143+
end = Math.max(end, blob.offset() + blob.length());
144+
}
145+
146+
byte[] region;
147+
try {
148+
region = readInput(start, Math.toIntExact(end - start));
149+
} catch (IOException e) {
150+
throw new UncheckedIOException(e);
151+
}
152+
153+
List<Pair<BlobMetadata, ByteBuffer>> data = Lists.newArrayListWithCapacity(group.size());
154+
for (BlobMetadata blob : group) {
155+
int position = Math.toIntExact(blob.offset() - start);
156+
// slice so the blob's bytes align to the buffer's array offset (needed by decompress)
157+
ByteBuffer rawData =
158+
ByteBuffer.wrap(region, position, Math.toIntExact(blob.length())).slice();
159+
PuffinCompressionCodec codec = PuffinCompressionCodec.forName(blob.compressionCodec());
160+
data.add(Pair.of(blob, PuffinFormat.decompress(codec, rawData)));
161+
}
162+
163+
return data;
164+
}
165+
166+
static List<List<BlobMetadata>> coalesce(List<BlobMetadata> orderedByOffset, long maxReadSize) {
167+
List<List<BlobMetadata>> groups = Lists.newArrayList();
168+
List<BlobMetadata> current = null;
169+
long currentStart = 0;
170+
long currentEnd = 0;
171+
for (BlobMetadata blob : orderedByOffset) {
172+
long blobEnd = blob.offset() + blob.length();
173+
boolean gap = current != null && blob.offset() > currentEnd;
174+
boolean tooLarge = current != null && blobEnd - currentStart > maxReadSize;
175+
if (current == null || gap || tooLarge) {
176+
current = Lists.newArrayList();
177+
groups.add(current);
178+
currentStart = blob.offset();
179+
currentEnd = blob.offset();
180+
}
181+
182+
current.add(blob);
183+
currentEnd = Math.max(currentEnd, blobEnd);
184+
}
185+
186+
return groups;
149187
}
150188

151189
private static void checkMagic(byte[] data, int offset) {

core/src/test/java/org/apache/iceberg/puffin/TestPuffinReader.java

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import static org.assertj.core.api.Assertions.assertThat;
2929
import static org.assertj.core.api.Assertions.assertThatThrownBy;
3030

31+
import java.util.List;
3132
import java.util.Map;
3233
import javax.annotation.Nullable;
3334
import org.apache.iceberg.inmemory.InMemoryInputFile;
@@ -154,4 +155,77 @@ public void testValidateFooterSizeValue() throws Exception {
154155
.isEqualTo(ImmutableMap.of("created-by", "Test 1234"));
155156
}
156157
}
158+
159+
@Test
160+
void coalescesContiguousBlobsAndSplitsOnGaps() {
161+
BlobMetadata a = blob("a", 0, 10);
162+
BlobMetadata b = blob("b", 10, 8);
163+
BlobMetadata c = blob("c", 15, 10); // overlaps b: same read
164+
BlobMetadata d = blob("d", 100, 4); // gap: separate read
165+
166+
List<List<BlobMetadata>> groups =
167+
PuffinReader.coalesce(ImmutableList.of(a, b, c, d), Long.MAX_VALUE);
168+
169+
assertThat(groups).hasSize(2);
170+
assertThat(groups.get(0)).containsExactly(a, b, c);
171+
assertThat(groups.get(1)).containsExactly(d);
172+
}
173+
174+
@Test
175+
void splitsContiguousRunThatExceedsMaxReadSize() {
176+
BlobMetadata a = blob("a", 0, 10);
177+
BlobMetadata b = blob("b", 10, 10);
178+
BlobMetadata c = blob("c", 20, 10); // would exceed the 25-byte cap: separate read
179+
180+
List<List<BlobMetadata>> groups = PuffinReader.coalesce(ImmutableList.of(a, b, c), 25);
181+
182+
assertThat(groups).hasSize(2);
183+
assertThat(groups.get(0)).containsExactly(a, b);
184+
assertThat(groups.get(1)).containsExactly(c);
185+
}
186+
187+
@Test
188+
void keepsBlobLargerThanMaxReadSizeInItsOwnGroup() {
189+
BlobMetadata big = blob("big", 0, 100); // exceeds the cap on its own
190+
BlobMetadata next = blob("next", 100, 10);
191+
192+
List<List<BlobMetadata>> groups = PuffinReader.coalesce(ImmutableList.of(big, next), 25);
193+
194+
assertThat(groups).hasSize(2);
195+
assertThat(groups.get(0)).containsExactly(big);
196+
assertThat(groups.get(1)).containsExactly(next);
197+
}
198+
199+
@Test
200+
void readAllReadsFarApartBlobsInSeparateRegions() throws Exception {
201+
byte[] first = "first blob".getBytes(UTF_8);
202+
byte[] second = "second blob far away".getBytes(UTF_8);
203+
int secondOffset = 2_000_000; // far from the first blob: separate read
204+
205+
// build the raw layout directly; the writer always packs blobs contiguously
206+
byte[] bytes = new byte[secondOffset + second.length];
207+
System.arraycopy(first, 0, bytes, 0, first.length);
208+
System.arraycopy(second, 0, bytes, secondOffset, second.length);
209+
210+
BlobMetadata firstBlob = blob("first", 0, first.length);
211+
BlobMetadata secondBlob = blob("second", secondOffset, second.length);
212+
213+
InMemoryInputFile inputFile = new InMemoryInputFile(bytes);
214+
try (PuffinReader reader = Puffin.read(inputFile).withFileSize(bytes.length).build()) {
215+
Map<BlobMetadata, byte[]> read =
216+
Streams.stream(reader.readAll(ImmutableList.of(firstBlob, secondBlob)))
217+
.collect(toImmutableMap(Pair::first, pair -> ByteBuffers.toByteArray(pair.second())));
218+
219+
assertThat(read)
220+
.as("read")
221+
.containsOnlyKeys(firstBlob, secondBlob)
222+
.containsEntry(firstBlob, first)
223+
.containsEntry(secondBlob, second);
224+
}
225+
}
226+
227+
private static BlobMetadata blob(String type, long offset, long length) {
228+
return new BlobMetadata(
229+
type, ImmutableList.of(1), 1L, 1L, offset, length, null, ImmutableMap.of());
230+
}
157231
}

0 commit comments

Comments
 (0)