Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 18 additions & 12 deletions docs/generated/core_configuration.html
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,18 @@
<td>Boolean</td>
<td>Whether enable data evolution for row tracking table.</td>
</tr>
<tr>
<td><h5>data-evolution.merge-into.file-pruning</h5></td>
<td style="word-wrap: break-word;">true</td>
<td>Boolean</td>
<td>If true, enables the file-level pruning step for MergeInto partial column update on data-evolution tables. Set this to false when most files in the target partition are expected to be updated, so that the overhead of collecting touched file IDs outweighs the benefit of pruning untouched files.</td>
</tr>
<tr>
<td><h5>data-evolution.merge-into.source-persist</h5></td>
<td style="word-wrap: break-word;">false</td>
<td>Boolean</td>
<td>Whether to persist source when process merge into action on data evolution table.</td>
</tr>
<tr>
<td><h5>data-evolution.row-sidecar.enabled</h5></td>
<td style="word-wrap: break-word;">false</td>
Expand All @@ -494,18 +506,6 @@
<td>Double</td>
<td>Maximum selected row ratio for reading a row-store sidecar file. The value must be in (0, 1]. The sidecar is used only when the selected row ratio is no more than this value and the selected row count is no more than data-evolution.row-sidecar.max-selected-rows.</td>
</tr>
<tr>
<td><h5>data-evolution.merge-into.file-pruning</h5></td>
<td style="word-wrap: break-word;">true</td>
<td>Boolean</td>
<td>If true, enables the file-level pruning step for MergeInto partial column update on data-evolution tables. Set this to false when most files in the target partition are expected to be updated, so that the overhead of collecting touched file IDs outweighs the benefit of pruning untouched files.</td>
</tr>
<tr>
<td><h5>data-evolution.merge-into.source-persist</h5></td>
<td style="word-wrap: break-word;">false</td>
<td>Boolean</td>
<td>Whether to persist source when process merge into action on data evolution table.</td>
</tr>
<tr>
<td><h5>data-file.external-paths</h5></td>
<td style="word-wrap: break-word;">(none)</td>
Expand Down Expand Up @@ -861,6 +861,12 @@
<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>
Expand Down
13 changes: 13 additions & 0 deletions paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
Original file line number Diff line number Diff line change
Expand Up @@ -1422,6 +1422,19 @@ public String toString() {
.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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assumes the row entering the lookup cache already carries descriptor-backed BLOB values. The lookup reader still reads table data using the normal blob-as-descriptor option, while this new lookup.blob-as-descriptor option only changes the cache serializer/wrapper. For a normal BLOB table with blob-as-descriptor=false, BlobFormatReader returns BlobData; then this call to blob.toDescriptor() throws Blob data can not convert to descriptor during bootstrap/refresh. Could we either make the lookup reader read BLOB fields in descriptor mode when this option is enabled, or reject/document the unsupported combination before the cache starts loading?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the excellent catch @JingsongLi!

You're absolutely right — for a normal BLOB table with blob-as-descriptor=false, BlobFormatReader reads inline data and returns BlobData, and calling toDescriptor() on it throws "Blob data can not convert to descriptor".

I've chosen Option A (making the lookup reader read in descriptor mode): In FullCacheLookupTable.bootstrap(), when lookup.blob-as-descriptor=true, I now copy the table with blob-as-descriptor=true forced in the options before passing it to LookupStreamingReader. This makes BlobFormatReaderFactory close the input stream and pass in=null to BlobFormatReader, which then uses Blob.fromFile(fileIO, path, offset, length) → creates BlobRef → toDescriptor() works correctly.

This approach supports both scenarios:
1、Table written with blob-as-descriptor=true: works as before (reader already in descriptor mode)
2、Table written with blob-as-descriptor=false: now also works — the reader returns BlobRef pointing to the data location in the blob file, and the serialized descriptor enables downstream consumers to range-read the actual content on demand.

}
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
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;
Expand All @@ -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;
Expand Down Expand Up @@ -135,6 +138,8 @@ public FullCacheLookupTable(Context context) {
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);
}
Expand Down Expand Up @@ -181,9 +186,23 @@ private StateFactory createStateFactory() throws IOException {
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,
Expand All @@ -194,16 +213,22 @@ protected void bootstrap() throws Exception {
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)));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This common bulk-load path now wraps the value row, but the primary-key cache still creates tableState with InternalSerializers.create(projectedType) and its incremental refreshRow stores the raw row. With that serializer, BlobSerializer calls getBlob().toData(), so lookup.blob-as-descriptor=true still materializes and caches the full blob for primary-key and secondary-index lookup tables. Please apply cacheValueRowType() and wrapForCache(row) to the primary-key table state path as well.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review! You're right — for code consistency and defensive completeness, I should apply cacheValueRowType() and wrapForCache(row) to the primary-key and secondary-index paths as well.

That said, I want to note that in practice, BLOB fields can only exist on append-only tables (the schema validation enforces: BLOB → data-evolution → row-tracking → no primary key + bucket=-1), so PrimaryKeyLookupTable and SecondaryIndexLookupTable will never encounter BLOB fields at runtime — blobFieldPositions will always be empty and these calls become no-ops.

But I agree it's better to keep the code paths consistent. I've updated the PR to apply cacheValueRowType() in createTableState() and wrapForCache(row) in refreshRow() for both PrimaryKeyLookupTable and SecondaryIndexLookupTable. Thanks!

}
}
}
Expand Down Expand Up @@ -332,6 +357,31 @@ public Predicate projectedPredicate() {
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;
Expand Down
Loading
Loading