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 30a13d69e6 [flink] Support storing BlobDescriptor in lookup join for
BLOB tables (#8391)
30a13d69e6 is described below
commit 30a13d69e6947419b2914276686d7c079454cf42
Author: wangwj <[email protected]>
AuthorDate: Fri Jul 3 13:27:39 2026 +0800
[flink] Support storing BlobDescriptor in lookup join for BLOB tables
(#8391)
When performing lookup join against a Paimon BLOB table (e.g., storing
images/videos), the full blob content is materialized via
`BlobSerializer.serialize() → blob.toData()` and written into local
RocksDB during bootstrap. For tables with large blob fields, this
causes:
- Extremely high local disk usage (e.g., 2 billion images × 200KB =
~400TB total, ~400GB per subtask)
- Page-size overflow errors (single records exceeding the 64KB default
page)
- Prolonged bootstrap time leading to TaskManager heartbeat timeouts
This PR introduces a new table option `lookup.blob-as-descriptor`
(default `false`). When enabled:
1. BLOB fields are stored as their lightweight `BlobDescriptor` bytes
(~130 bytes containing file URI, offset, and length) instead of the full
blob content.
2. The RowType used for value serialization replaces BLOB with
VARBINARY, so `InternalSerializers.create()` produces `BinarySerializer`
(calls `getBinary()`) instead of `BlobSerializer` (calls
`getBlob().toData()`).
3. `BlobRef.toDescriptor()` is a pure in-memory operation (no I/O), so
bootstrap performance is not affected.
The downstream consumer receives the serialized BlobDescriptor bytes and
can resolve the actual blob content on demand via a UDF reading from
DFS.
Impact: Per-subtask RocksDB storage drops from ~400GB to ~3.6GB (over
100x reduction), making lookup join feasible for tables with large BLOB
columns.
Tests
Existing unit tests pass (no behavioral change when the option is
disabled).
TODO: Add integration test for NoPrimaryKeyLookupTable with
lookup.blob-as-descriptor = true to verify BlobDescriptor bytes are
correctly stored and returned.
**Example usage:**
```sql
CREATE TABLE dim_images (
url STRING,
image BLOB
) WITH (
'lookup.blob-as-descriptor' = 'true'
);
SELECT s.*, resolve_blob(d.image) AS image_data
FROM stream_table s
LEFT JOIN dim_images FOR SYSTEM_TIME AS OF s.proc_time AS d
ON s.url = d.url;
---
docs/generated/core_configuration.html | 6 +
.../main/java/org/apache/paimon/CoreOptions.java | 13 ++
.../paimon/flink/lookup/BlobAsDescriptorRow.java | 215 +++++++++++++++++++++
.../paimon/flink/lookup/FullCacheLookupTable.java | 56 +++++-
.../flink/lookup/NoPrimaryKeyLookupTable.java | 4 +-
.../org/apache/paimon/flink/LookupJoinITCase.java | 58 ++++++
6 files changed, 347 insertions(+), 5 deletions(-)
diff --git a/docs/generated/core_configuration.html
b/docs/generated/core_configuration.html
index 33d038ba43..f75de298d6 100644
--- a/docs/generated/core_configuration.html
+++ b/docs/generated/core_configuration.html
@@ -849,6 +849,12 @@ Mainly to resolve data skew on primary keys. We recommend
starting with 64 mb wh
<td>Boolean</td>
<td>When need to lookup, commit will wait for compaction by
lookup.</td>
</tr>
+ <tr>
+ <td><h5>lookup.blob-as-descriptor</h5></td>
+ <td style="word-wrap: break-word;">false</td>
+ <td>Boolean</td>
+ <td>When enabled, the lookup join stores only the BlobDescriptor
(a lightweight reference containing file URI, offset, and length) for BLOB
fields instead of the full blob bytes. This dramatically reduces local disk and
memory usage for tables with large BLOB columns (e.g., images, videos). The
downstream consumer receives the serialized BlobDescriptor bytes and can
resolve the actual blob content on demand.</td>
+ </tr>
<tr>
<td><h5>lookup.cache-file-retention</h5></td>
<td style="word-wrap: break-word;">1 h</td>
diff --git a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
index 55c25b409b..ee1db88285 100644
--- a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
+++ b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
@@ -1422,6 +1422,19 @@ public class CoreOptions implements Serializable {
.defaultValue(MemorySize.parse("256 mb"))
.withDescription("Max memory size for lookup cache.");
+ public static final ConfigOption<Boolean> LOOKUP_CACHE_BLOB_DESCRIPTOR =
+ key("lookup.blob-as-descriptor")
+ .booleanType()
+ .defaultValue(false)
+ .withDescription(
+ "When enabled, the lookup join stores only the
BlobDescriptor "
+ + "(a lightweight reference containing
file URI, offset, and length) "
+ + "for BLOB fields instead of the full
blob bytes. This dramatically "
+ + "reduces local disk and memory usage for
tables with large BLOB "
+ + "columns (e.g., images, videos). The
downstream consumer receives "
+ + "the serialized BlobDescriptor bytes and
can resolve the actual "
+ + "blob content on demand.");
+
public static final ConfigOption<Double> LOOKUP_CACHE_HIGH_PRIO_POOL_RATIO
=
key("lookup.cache.high-priority-pool-ratio")
.doubleType()
diff --git
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/lookup/BlobAsDescriptorRow.java
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/lookup/BlobAsDescriptorRow.java
new file mode 100644
index 0000000000..b79e46d4f8
--- /dev/null
+++
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/lookup/BlobAsDescriptorRow.java
@@ -0,0 +1,215 @@
+/*
+ * 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.flink.lookup;
+
+import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.data.Blob;
+import org.apache.paimon.data.Decimal;
+import org.apache.paimon.data.InternalArray;
+import org.apache.paimon.data.InternalMap;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.data.InternalVector;
+import org.apache.paimon.data.Timestamp;
+import org.apache.paimon.data.variant.Variant;
+import org.apache.paimon.types.DataType;
+import org.apache.paimon.types.DataTypeRoot;
+import org.apache.paimon.types.RowKind;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.types.VarBinaryType;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * An {@link InternalRow} wrapper that converts BLOB fields to their
BlobDescriptor serialized
+ * bytes.
+ *
+ * <p>When the lookup cache stores blob descriptors instead of full blob data,
this wrapper
+ * intercepts access to BLOB fields and returns the serialized BlobDescriptor
bytes via {@link
+ * #getBinary(int)}. This allows the lookup cache to store only lightweight
references (~100-150
+ * bytes) instead of the full blob content (which can be megabytes per record).
+ *
+ * <p>Non-blob fields are delegated to the wrapped row unchanged.
+ */
+public class BlobAsDescriptorRow implements InternalRow {
+
+ private final InternalRow wrapped;
+ private final Set<Integer> blobFieldPositions;
+
+ public BlobAsDescriptorRow(InternalRow wrapped, Set<Integer>
blobFieldPositions) {
+ this.wrapped = wrapped;
+ this.blobFieldPositions = blobFieldPositions;
+ }
+
+ /**
+ * Returns the set of field positions that are BLOB type in the given row
type.
+ *
+ * @param rowType the row type to inspect
+ * @return set of positions with BLOB type fields, empty if none
+ */
+ public static Set<Integer> blobFieldPositions(RowType rowType) {
+ Set<Integer> positions = new HashSet<>();
+ List<DataType> fieldTypes = rowType.getFieldTypes();
+ for (int i = 0; i < fieldTypes.size(); i++) {
+ if (fieldTypes.get(i).getTypeRoot() == DataTypeRoot.BLOB) {
+ positions.add(i);
+ }
+ }
+ return positions;
+ }
+
+ /**
+ * Creates a new RowType where BLOB fields are replaced with VARBINARY.
This is used to create
+ * the correct serializer that uses BinarySerializer instead of
BlobSerializer for cached
+ * values.
+ *
+ * @param rowType the original row type
+ * @param blobPositions the positions of BLOB fields
+ * @return a new RowType with BLOB fields replaced by VARBINARY
+ */
+ public static RowType replaceBlobWithVarBinary(RowType rowType,
Set<Integer> blobPositions) {
+ if (blobPositions.isEmpty()) {
+ return rowType;
+ }
+ List<DataType> newTypes = new ArrayList<>(rowType.getFieldTypes());
+ for (int pos : blobPositions) {
+ newTypes.set(pos, new VarBinaryType(VarBinaryType.MAX_LENGTH));
+ }
+ RowType.Builder builder = RowType.builder();
+ for (int i = 0; i < rowType.getFieldCount(); i++) {
+ builder.field(rowType.getFields().get(i).name(), newTypes.get(i));
+ }
+ return builder.build();
+ }
+
+ @Override
+ public int getFieldCount() {
+ return wrapped.getFieldCount();
+ }
+
+ @Override
+ public RowKind getRowKind() {
+ return wrapped.getRowKind();
+ }
+
+ @Override
+ public void setRowKind(RowKind kind) {
+ wrapped.setRowKind(kind);
+ }
+
+ @Override
+ public boolean isNullAt(int pos) {
+ return wrapped.isNullAt(pos);
+ }
+
+ @Override
+ public boolean getBoolean(int pos) {
+ return wrapped.getBoolean(pos);
+ }
+
+ @Override
+ public byte getByte(int pos) {
+ return wrapped.getByte(pos);
+ }
+
+ @Override
+ public short getShort(int pos) {
+ return wrapped.getShort(pos);
+ }
+
+ @Override
+ public int getInt(int pos) {
+ return wrapped.getInt(pos);
+ }
+
+ @Override
+ public long getLong(int pos) {
+ return wrapped.getLong(pos);
+ }
+
+ @Override
+ public float getFloat(int pos) {
+ return wrapped.getFloat(pos);
+ }
+
+ @Override
+ public double getDouble(int pos) {
+ return wrapped.getDouble(pos);
+ }
+
+ @Override
+ public BinaryString getString(int pos) {
+ return wrapped.getString(pos);
+ }
+
+ @Override
+ public Decimal getDecimal(int pos, int precision, int scale) {
+ return wrapped.getDecimal(pos, precision, scale);
+ }
+
+ @Override
+ public Timestamp getTimestamp(int pos, int precision) {
+ return wrapped.getTimestamp(pos, precision);
+ }
+
+ @Override
+ public byte[] getBinary(int pos) {
+ if (blobFieldPositions.contains(pos)) {
+ // Convert blob to descriptor bytes for caching
+ Blob blob = wrapped.getBlob(pos);
+ if (blob == null) {
+ return null;
+ }
+ return blob.toDescriptor().serialize();
+ }
+ return wrapped.getBinary(pos);
+ }
+
+ @Override
+ public InternalArray getArray(int pos) {
+ return wrapped.getArray(pos);
+ }
+
+ @Override
+ public InternalMap getMap(int pos) {
+ return wrapped.getMap(pos);
+ }
+
+ @Override
+ public InternalRow getRow(int pos, int numFields) {
+ return wrapped.getRow(pos, numFields);
+ }
+
+ @Override
+ public Blob getBlob(int pos) {
+ return wrapped.getBlob(pos);
+ }
+
+ @Override
+ public Variant getVariant(int pos) {
+ return wrapped.getVariant(pos);
+ }
+
+ @Override
+ public InternalVector getVector(int pos) {
+ return wrapped.getVector(pos);
+ }
+}
diff --git
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/lookup/FullCacheLookupTable.java
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/lookup/FullCacheLookupTable.java
index 6b02084d8a..520021fa4d 100644
---
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/lookup/FullCacheLookupTable.java
+++
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/lookup/FullCacheLookupTable.java
@@ -55,6 +55,7 @@ import javax.annotation.Nullable;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
@@ -81,6 +82,8 @@ public abstract class FullCacheLookupTable implements
LookupTable {
protected final Context context;
protected final RowType projectedType;
protected final boolean refreshAsync;
+ protected final boolean blobAsDescriptor;
+ protected final Set<Integer> blobFieldPositions;
@Nullable protected final FieldsComparator userDefinedSeqComparator;
protected final int appendUdsFieldNumber;
@@ -135,6 +138,8 @@ public abstract class FullCacheLookupTable implements
LookupTable {
Options options = Options.fromMap(context.table.options());
this.projectedType = projectedType;
this.refreshAsync = options.get(LOOKUP_REFRESH_ASYNC);
+ this.blobAsDescriptor =
options.get(CoreOptions.LOOKUP_CACHE_BLOB_DESCRIPTOR);
+ this.blobFieldPositions =
BlobAsDescriptorRow.blobFieldPositions(projectedType);
this.cachedException = new AtomicReference<>();
this.maxPendingSnapshotCount =
options.get(LOOKUP_REFRESH_ASYNC_PENDING_SNAPSHOT_COUNT);
}
@@ -181,9 +186,23 @@ public abstract class FullCacheLookupTable implements
LookupTable {
protected void bootstrap() throws Exception {
Predicate scanPredicate =
PredicateBuilder.andNullable(context.tablePredicate,
partitionFilter);
+
+ // When lookup.blob-as-descriptor is enabled. Force the format reader
to return
+ // BlobRef (descriptor-backed) instead of BlobData for BLOB fields.
This ensures
+ // blob.toDescriptor() succeeds during cache serialization, even when
the table
+ // was not originally written with blob-as-descriptor=true.
+ LookupFileStoreTable readerTable = context.table;
+ if (blobAsDescriptor && !blobFieldPositions.isEmpty()) {
+ readerTable =
+ (LookupFileStoreTable)
+ context.table.copy(
+ Collections.singletonMap(
+
CoreOptions.BLOB_AS_DESCRIPTOR.key(), "true"));
+ }
+
this.reader =
new LookupStreamingReader(
- context.table,
+ readerTable,
context.projection,
scanPredicate,
context.requiredCachedBucketIds,
@@ -194,16 +213,22 @@ public abstract class FullCacheLookupTable implements
LookupTable {
return;
}
+ // Parallel bootstrap read serializes rows with BlobSerializer, which
materializes
+ // BlobRef into BlobData. Disable parallelism when caching blob
descriptors.
+ boolean useParallelBootstrapRead = !(blobAsDescriptor &&
!blobFieldPositions.isEmpty());
+
BinaryExternalSortBuffer bulkLoadSorter =
RocksDBState.createBulkLoadSorter(
IOManager.create(context.tempPath.toString()),
context.table.coreOptions());
Predicate predicate = projectedPredicate();
try (RecordReaderIterator<InternalRow> batch =
- new
RecordReaderIterator<>(reader.toRecordReader(reader.nextSplits(), true))) {
+ new RecordReaderIterator<>(
+ reader.toRecordReader(reader.nextSplits(),
useParallelBootstrapRead))) {
while (batch.hasNext()) {
InternalRow row = batch.next();
if (predicate == null || predicate.test(row)) {
- bulkLoadSorter.write(GenericRow.of(toKeyBytes(row),
toValueBytes(row)));
+ InternalRow valueRow = wrapForCache(row);
+ bulkLoadSorter.write(GenericRow.of(toKeyBytes(row),
toValueBytes(valueRow)));
}
}
}
@@ -332,6 +357,31 @@ public abstract class FullCacheLookupTable implements
LookupTable {
return context.projectedPredicate;
}
+ /**
+ * Wraps the given row for caching. When {@code cacheBlobDescriptor} is
enabled and the row
+ * contains BLOB fields, wraps it with {@link BlobAsDescriptorRow} so that
BLOB fields are
+ * stored as lightweight BlobDescriptor bytes instead of full blob content.
+ */
+ protected InternalRow wrapForCache(InternalRow row) {
+ if (blobAsDescriptor && !blobFieldPositions.isEmpty()) {
+ return new BlobAsDescriptorRow(row, blobFieldPositions);
+ }
+ return row;
+ }
+
+ /**
+ * Returns the RowType used for value serialization in the cache. When
{@code
+ * cacheBlobDescriptor} is enabled, BLOB fields are replaced with
VARBINARY so that the
+ * serializer uses {@code BinarySerializer} (calls {@code getBinary()})
instead of {@code
+ * BlobSerializer} (calls {@code getBlob().toData()}).
+ */
+ protected RowType cacheValueRowType() {
+ if (blobAsDescriptor && !blobFieldPositions.isEmpty()) {
+ return BlobAsDescriptorRow.replaceBlobWithVarBinary(projectedType,
blobFieldPositions);
+ }
+ return projectedType;
+ }
+
public abstract byte[] toKeyBytes(InternalRow row) throws IOException;
public abstract byte[] toValueBytes(InternalRow row) throws IOException;
diff --git
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/lookup/NoPrimaryKeyLookupTable.java
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/lookup/NoPrimaryKeyLookupTable.java
index 546bc5a60a..4fd1dc1ae0 100644
---
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/lookup/NoPrimaryKeyLookupTable.java
+++
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/lookup/NoPrimaryKeyLookupTable.java
@@ -59,7 +59,7 @@ public class NoPrimaryKeyLookupTable extends
FullCacheLookupTable {
"join-key-index",
InternalSerializers.create(
TypeUtils.project(projectedType,
joinKeyRow.indexMapping())),
- InternalSerializers.create(projectedType),
+ InternalSerializers.create(cacheValueRowType()),
lruCacheSize);
bootstrap();
}
@@ -83,7 +83,7 @@ public class NoPrimaryKeyLookupTable extends
FullCacheLookupTable {
joinKeyRow.replaceRow(row);
if (row.getRowKind() == RowKind.INSERT || row.getRowKind() ==
RowKind.UPDATE_AFTER) {
if (predicate == null || predicate.test(row)) {
- state.add(joinKeyRow, row);
+ state.add(joinKeyRow, wrapForCache(row));
}
} else {
throw new RuntimeException(
diff --git
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/LookupJoinITCase.java
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/LookupJoinITCase.java
index 32a4948501..ebdec9bc3e 100644
---
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/LookupJoinITCase.java
+++
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/LookupJoinITCase.java
@@ -18,6 +18,7 @@
package org.apache.paimon.flink;
+import org.apache.paimon.data.BlobDescriptor;
import org.apache.paimon.flink.FlinkConnectorOptions.LookupCacheMode;
import org.apache.paimon.utils.BlockingIterator;
@@ -1563,4 +1564,61 @@ public class LookupJoinITCase extends CatalogITCaseBase {
iterator.close();
}
+
+ @ParameterizedTest
+ @EnumSource(
+ value = LookupCacheMode.class,
+ names = {"FULL", "MEMORY"})
+ public void testLookupBlobAsDescriptorOnNormalBlobTable(LookupCacheMode
mode) throws Exception {
+ // Test that lookup.blob-as-descriptor works correctly even when the
table was NOT
+ // written with blob-as-descriptor=true. Previously this would fail
with
+ // "Blob data can not convert to descriptor" because BlobFormatReader
returned BlobData
+ // which cannot be converted to a descriptor. Blob tables cannot
define primary keys
+ // (row-tracking requirement), so use an append-only table with id as
the lookup key.
+ sql(
+ "CREATE TABLE BLOB_DIM (id INT, name STRING, picture BYTES)
WITH ("
+ + "'row-tracking.enabled'='true', "
+ + "'data-evolution.enabled'='true', "
+ + "'blob-field'='picture', "
+ + "'lookup.blob-as-descriptor'='true', "
+ + "'lookup.cache'='%s', "
+ + "'continuous.discovery-interval'='1 ms')",
+ mode);
+
+ // Write raw blob data (NOT as descriptor) — this is the normal write
path.
+ sql("INSERT INTO BLOB_DIM VALUES (1, 'cat', X'48656C6C6F'), (2, 'dog',
X'576F726C64')");
+
+ // Lookup join with lookup.blob-as-descriptor=true.
+ // The fix forces the reader to use blob-as-descriptor mode so that
BlobFormatReader
+ // returns BlobRef (which supports toDescriptor()) instead of BlobData.
+ String query =
+ "SELECT T.i, D.name, D.picture FROM T LEFT JOIN BLOB_DIM "
+ + "for system_time as of T.proctime AS D ON T.i =
D.id";
+ BlockingIterator<Row, Row> iterator =
BlockingIterator.of(sEnv.executeSql(query).collect());
+
+ sql("INSERT INTO T VALUES (1), (2), (3)");
+ List<Row> result = iterator.collect(3);
+
+ // With lookup.blob-as-descriptor=true, the BLOB fields should be
returned as
+ // serialized BlobDescriptor bytes (not the original raw data).
+ assertThat(result).hasSize(3);
+
+ // Verify non-blob fields are correct
+ assertThat(result.stream().map(r -> r.getField(1)))
+ .containsExactlyInAnyOrder("cat", "dog", null);
+
+ // For matched rows, the picture field should contain valid
BlobDescriptor bytes
+ for (Row row : result) {
+ if (row.getField(1) != null) {
+ byte[] descriptorBytes = (byte[]) row.getField(2);
+ assertThat(descriptorBytes).isNotNull();
+ // Verify it's a valid BlobDescriptor by deserializing
+ BlobDescriptor descriptor =
BlobDescriptor.deserialize(descriptorBytes);
+ assertThat(descriptor.uri()).isNotEmpty();
+ assertThat(descriptor.length()).isGreaterThan(0);
+ }
+ }
+
+ iterator.close();
+ }
}