-
Notifications
You must be signed in to change notification settings - Fork 1.4k
[flink] Support storing BlobDescriptor in lookup join for BLOB tables #8391
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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(); | ||
| } | ||
| 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 |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -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 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); | ||
| } | ||
|
|
@@ -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, | ||
|
|
@@ -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))); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 But I agree it's better to keep the code paths consistent. I've updated the PR to apply |
||
| } | ||
| } | ||
| } | ||
|
|
@@ -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; | ||
|
|
||
There was a problem hiding this comment.
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-descriptoroption, while this newlookup.blob-as-descriptoroption only changes the cache serializer/wrapper. For a normal BLOB table withblob-as-descriptor=false,BlobFormatReaderreturnsBlobData; then this call toblob.toDescriptor()throwsBlob data can not convert to descriptorduring 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?There was a problem hiding this comment.
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.