This is an automated email from the ASF dual-hosted git repository.
danny0405 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git
The following commit(s) were added to refs/heads/master by this push:
new 3e3b98c461da fix(flink): support vector columns in CDC reads (#19903)
3e3b98c461da is described below
commit 3e3b98c461da972c5e788aa03b0ce72e7ebed230
Author: Shuo Cheng <[email protected]>
AuthorDate: Wed Sep 16 14:31:49 2026 +0800
fix(flink): support vector columns in CDC reads (#19903)
* fix(flink): support vector columns in CDC reads
Cover MOR before/after image reconstruction from base files and native
data logs, including vector null transitions, through both CDC readers.
Exercise single-vector and no-vector projections for Parquet and Lance.
---
.../hudi/common/table/cdc/HoodieCDCUtils.java | 17 +
.../hudi/common/table/cdc/TestHoodieCDCUtils.java | 76 ++++
.../function/HoodieCdcSplitReaderFunction.java | 31 +-
.../table/format/mor/MergeOnReadInputFormat.java | 37 +-
.../source/reader/function/TestCdcVectorRead.java | 443 +++++++++++++++++++++
.../function/TestHoodieCdcSplitReaderFunction.java | 2 +-
6 files changed, 587 insertions(+), 19 deletions(-)
diff --git
a/hudi-common/src/main/java/org/apache/hudi/common/table/cdc/HoodieCDCUtils.java
b/hudi-common/src/main/java/org/apache/hudi/common/table/cdc/HoodieCDCUtils.java
index 326c231c7ba8..9792b5adfea0 100644
---
a/hudi-common/src/main/java/org/apache/hudi/common/table/cdc/HoodieCDCUtils.java
+++
b/hudi-common/src/main/java/org/apache/hudi/common/table/cdc/HoodieCDCUtils.java
@@ -21,7 +21,9 @@ package org.apache.hudi.common.table.cdc;
import org.apache.hudi.common.schema.HoodieSchema;
import org.apache.hudi.common.schema.HoodieSchemaField;
import org.apache.hudi.common.schema.HoodieSchemaType;
+import org.apache.hudi.common.table.HoodieTableConfig;
import org.apache.hudi.exception.HoodieException;
+import org.apache.hudi.exception.HoodieNotSupportedException;
import org.apache.avro.generic.GenericData;
import org.apache.avro.generic.GenericRecord;
@@ -85,6 +87,10 @@ public class HoodieCDCUtils {
}
private static HoodieSchema createCDCSchema(HoodieSchema tableSchema,
boolean withAfterImage) {
+ HoodieCDCSupplementalLoggingMode mode = withAfterImage
+ ? HoodieCDCSupplementalLoggingMode.DATA_BEFORE_AFTER :
HoodieCDCSupplementalLoggingMode.DATA_BEFORE;
+ // Defer validation until image schema creation so table initialization
and schema changes can proceed.
+ validateCdcImageSchema(tableSchema, mode);
HoodieSchema imageSchema = HoodieSchema.createNullable(tableSchema);
HoodieSchema nullableString =
HoodieSchema.createNullable(HoodieSchemaType.STRING);
@@ -104,6 +110,17 @@ public class HoodieCDCUtils {
return HoodieSchema.createRecord("CDC",
tableSchema.getNamespace().orElse(null), "", fields);
}
+ private static void validateCdcImageSchema(HoodieSchema tableSchema,
HoodieCDCSupplementalLoggingMode mode) {
+ // CDC images nest the table schema, but VECTOR columns are only supported
at the top level.
+ for (HoodieSchemaField field : tableSchema.getNonNullType().getFields()) {
+ if (field.schema().getNonNullType().getType() ==
HoodieSchemaType.VECTOR) {
+ throw new HoodieNotSupportedException("CDC supplemental logging mode "
+ mode
+ + " is not supported for VECTOR column '" + field.name() + "'. Set
"
+ + HoodieTableConfig.CDC_SUPPLEMENTAL_LOGGING_MODE.key() +
"=OP_KEY_ONLY or disable CDC.");
+ }
+ }
+ }
+
/**
* Build the cdc record which has all the cdc fields when
`hoodie.table.cdc.supplemental.logging.mode` is {@link
HoodieCDCSupplementalLoggingMode#DATA_BEFORE_AFTER}.
*/
diff --git
a/hudi-common/src/test/java/org/apache/hudi/common/table/cdc/TestHoodieCDCUtils.java
b/hudi-common/src/test/java/org/apache/hudi/common/table/cdc/TestHoodieCDCUtils.java
new file mode 100644
index 000000000000..5bb8402a86c4
--- /dev/null
+++
b/hudi-common/src/test/java/org/apache/hudi/common/table/cdc/TestHoodieCDCUtils.java
@@ -0,0 +1,76 @@
+/*
+ * 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.hudi.common.table.cdc;
+
+import org.apache.hudi.common.schema.HoodieSchema;
+import org.apache.hudi.common.schema.HoodieSchemaField;
+import org.apache.hudi.common.schema.HoodieSchemaType;
+import org.apache.hudi.common.table.HoodieTableConfig;
+import org.apache.hudi.exception.HoodieNotSupportedException;
+
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class TestHoodieCDCUtils {
+ @ParameterizedTest
+ @CsvSource({
+ "OP_KEY_ONLY, FLOAT, true, false",
+ "OP_KEY_ONLY, DOUBLE, false, false",
+ "OP_KEY_ONLY, INT8, false, false",
+ "DATA_BEFORE, FLOAT, true, true",
+ "DATA_BEFORE, DOUBLE, false, true",
+ "DATA_BEFORE, INT8, false, true",
+ "DATA_BEFORE_AFTER, FLOAT, true, true",
+ "DATA_BEFORE_AFTER, DOUBLE, false, true",
+ "DATA_BEFORE_AFTER, INT8, false, true",
+ "DATA_BEFORE, ARRAY, true, false",
+ "DATA_BEFORE_AFTER, ARRAY, false, false"
+ })
+ void testSchemaBySupplementalLoggingMode(HoodieCDCSupplementalLoggingMode
mode,
+ String type, boolean nullable,
boolean rejected) {
+ HoodieSchema fieldSchema = type.equals("ARRAY") ?
HoodieSchema.createArray(HoodieSchema.create(HoodieSchemaType.FLOAT))
+ : HoodieSchema.createVector(2,
HoodieSchema.Vector.VectorElementType.valueOf(type));
+ HoodieSchema schema = HoodieSchema.createRecord("data", null, null,
Collections.singletonList(
+ HoodieSchemaField.of("embedding", nullable ?
HoodieSchema.createNullable(fieldSchema) : fieldSchema)));
+ if (rejected) {
+ HoodieNotSupportedException error =
assertThrows(HoodieNotSupportedException.class,
+ () -> HoodieCDCUtils.schemaBySupplementalLoggingMode(mode, schema));
+ assertTrue(error.getMessage().contains(mode.name()));
+ assertTrue(error.getMessage().contains("embedding"));
+
assertTrue(error.getMessage().contains(HoodieTableConfig.CDC_SUPPLEMENTAL_LOGGING_MODE.key()
+ "=OP_KEY_ONLY"));
+ } else {
+ HoodieSchema cdcSchema =
HoodieCDCUtils.schemaBySupplementalLoggingMode(mode, schema);
+ if (mode == HoodieCDCSupplementalLoggingMode.OP_KEY_ONLY) {
+ assertSame(HoodieCDCUtils.CDC_SCHEMA_OP_AND_RECORDKEY, cdcSchema);
+ } else {
+ assertEquals(HoodieSchema.createNullable(schema),
cdcSchema.getField(HoodieCDCUtils.CDC_BEFORE_IMAGE).get().schema());
+ if (mode == HoodieCDCSupplementalLoggingMode.DATA_BEFORE_AFTER) {
+ assertEquals(HoodieSchema.createNullable(schema),
cdcSchema.getField(HoodieCDCUtils.CDC_AFTER_IMAGE).get().schema());
+ }
+ }
+ }
+ }
+}
diff --git
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/function/HoodieCdcSplitReaderFunction.java
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/function/HoodieCdcSplitReaderFunction.java
index 4c595fbacd10..1848ab9e3e1a 100644
---
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/function/HoodieCdcSplitReaderFunction.java
+++
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/reader/function/HoodieCdcSplitReaderFunction.java
@@ -52,6 +52,7 @@ import org.apache.hudi.table.format.cdc.CdcIterators;
import org.apache.hudi.table.format.mor.MergeOnReadInputSplit;
import org.apache.hudi.table.format.mor.MergeOnReadTableState;
import org.apache.hudi.util.StreamerUtil;
+import org.apache.hudi.util.VectorConversionUtils;
import lombok.extern.slf4j.Slf4j;
import org.apache.flink.table.data.RowData;
@@ -63,6 +64,7 @@ import java.io.IOException;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -83,6 +85,9 @@ public class HoodieCdcSplitReaderFunction extends
AbstractSplitReaderFunction {
private transient HoodieTableMetaClient metaClient;
// Fallback reader for non-CDC splits (e.g. snapshot reads when
read.start-commit='earliest')
private transient HoodieSplitReaderFunction fallbackReaderFunction;
+ private transient DataType[] readFieldTypes;
+ private transient Map<Integer, HoodieSchema.Vector> vectorColumnInfo;
+ private transient HoodieSchema requiredSchema;
/**
* Creates a CDC split reader function.
@@ -280,12 +285,19 @@ public class HoodieCdcSplitReaderFunction extends
AbstractSplitReaderFunction {
/** Reads a CDC base file returning required-schema records. */
private ClosableIterator<RowData> getBaseFileIterator(String path) throws
IOException {
if (path.endsWith(HoodieFileFormat.LANCE.getFileExtension())) {
- return FormatUtils.getLanceRecordIterator(
- path, tableState.getRowType().getFieldNames(), fieldTypes,
tableState.getRequiredPositions(), getHadoopConf());
+ if (requiredSchema == null) {
+ requiredSchema =
HoodieSchemaCache.intern(HoodieSchema.parse(tableState.getRequiredSchema()));
+ }
+ return FormatUtils.getLanceRecordIterator(path, requiredSchema,
getHadoopConf());
+ }
+
+ if (readFieldTypes == null) {
+ HoodieSchema tableSchema =
HoodieSchemaCache.intern(HoodieSchema.parse(tableState.getTableSchema()));
+ String[] fullFieldNames =
tableState.getRowType().getFieldNames().toArray(new String[0]);
+ vectorColumnInfo =
VectorConversionUtils.detectVectorColumns(fullFieldNames,
tableState.getRequiredPositions(), tableSchema);
+ readFieldTypes =
VectorConversionUtils.getParquetReadFieldTypes(fullFieldNames,
fieldTypes.toArray(new DataType[0]), tableSchema);
}
- String[] fieldNames = tableState.getRowType().getFieldNames().toArray(new
String[0]);
- DataType[] fieldTypesArray = fieldTypes.toArray(new DataType[0]);
LinkedHashMap<String, Object> partObjects =
FilePathUtils.generatePartitionSpecs(
path,
tableState.getRowType().getFieldNames(),
@@ -295,20 +307,23 @@ public class HoodieCdcSplitReaderFunction extends
AbstractSplitReaderFunction {
conf.get(FlinkOptions.HIVE_STYLE_PARTITIONING)
);
- return RecordIterators.getParquetRecordIterator(
+ int[] requiredPositions = tableState.getRequiredPositions();
+ ClosableIterator<RowData> rows = RecordIterators.getParquetRecordIterator(
internalSchemaManager,
conf.get(FlinkOptions.READ_UTC_TIMEZONE),
true,
HadoopConfigurations.getParquetConf(conf, getHadoopConf()),
- fieldNames,
- fieldTypesArray,
+ tableState.getRowType().getFieldNames().toArray(new String[0]),
+ readFieldTypes,
partObjects,
- tableState.getRequiredPositions(),
+ requiredPositions,
2048,
new org.apache.flink.core.fs.Path(path),
0,
Long.MAX_VALUE,
predicates);
+ return vectorColumnInfo.isEmpty() ? rows
+ : VectorConversionUtils.wrapVectorColumnIterator(rows,
fieldTypes.toArray(new DataType[0]), requiredPositions, vectorColumnInfo);
}
private static FileSlice buildFileSlice(MergeOnReadInputSplit split) {
diff --git
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/mor/MergeOnReadInputFormat.java
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/mor/MergeOnReadInputFormat.java
index 00ab804584b4..d7cd83a745b6 100644
---
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/mor/MergeOnReadInputFormat.java
+++
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/mor/MergeOnReadInputFormat.java
@@ -40,6 +40,7 @@ import org.apache.hudi.table.format.InternalSchemaManager;
import org.apache.hudi.table.format.RecordIterators;
import org.apache.hudi.util.FlinkWriteClients;
import org.apache.hudi.util.StreamerUtil;
+import org.apache.hudi.util.VectorConversionUtils;
import lombok.Getter;
import org.apache.flink.annotation.VisibleForTesting;
@@ -52,9 +53,11 @@ import org.apache.flink.table.data.RowData;
import org.apache.flink.table.types.DataType;
import java.io.IOException;
+import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
import java.util.stream.Collectors;
/**
@@ -83,12 +86,15 @@ public class MergeOnReadInputFormat
/**
* Full table names.
*/
- private final List<String> fieldNames;
+ private final String[] fieldNames;
/**
* Full field data types.
*/
- private final List<DataType> fieldTypes;
+ private final DataType[] fieldTypes;
+ private transient DataType[] readFieldTypes;
+ private transient Map<Integer, HoodieSchema.Vector> vectorColumnInfo;
+ private transient HoodieSchema requiredSchema;
/**
* Required field positions.
@@ -149,8 +155,8 @@ public class MergeOnReadInputFormat
InternalSchemaManager internalSchemaManager) {
this.conf = conf;
this.tableState = tableState;
- this.fieldNames = tableState.getRowType().getFieldNames();
- this.fieldTypes = fieldTypes;
+ this.fieldNames = tableState.getRowType().getFieldNames().toArray(new
String[0]);
+ this.fieldTypes = fieldTypes.toArray(new DataType[0]);
// Needs improvement: this requiredPos is only suitable for parquet reader,
// because we need to
this.requiredPos = tableState.getRequiredPositions();
@@ -272,25 +278,34 @@ public class MergeOnReadInputFormat
protected ClosableIterator<RowData> getBaseFileIterator(String path) throws
IOException {
if (path.endsWith(HoodieFileFormat.LANCE.getFileExtension())) {
- return FormatUtils.getLanceRecordIterator(path, fieldNames, fieldTypes,
requiredPos, hadoopConf);
+ if (requiredSchema == null) {
+ requiredSchema =
HoodieSchemaCache.intern(HoodieSchema.parse(tableState.getRequiredSchema()));
+ }
+ return FormatUtils.getLanceRecordIterator(path, requiredSchema,
hadoopConf);
+ }
+
+ if (readFieldTypes == null) {
+ HoodieSchema tableSchema =
HoodieSchema.parse(tableState.getTableSchema());
+ vectorColumnInfo = VectorConversionUtils.detectVectorColumns(fieldNames,
requiredPos, tableSchema);
+ readFieldTypes =
VectorConversionUtils.getParquetReadFieldTypes(fieldNames, fieldTypes,
tableSchema);
}
LinkedHashMap<String, Object> partObjects =
FilePathUtils.generatePartitionSpecs(
path,
- fieldNames,
- fieldTypes,
+ Arrays.asList(fieldNames),
+ Arrays.asList(fieldTypes),
conf.get(FlinkOptions.PARTITION_DEFAULT_NAME),
conf.get(FlinkOptions.PARTITION_PATH_FIELD),
conf.get(FlinkOptions.HIVE_STYLE_PARTITIONING)
);
- return RecordIterators.getParquetRecordIterator(
+ ClosableIterator<RowData> rows = RecordIterators.getParquetRecordIterator(
internalSchemaManager,
this.conf.get(FlinkOptions.READ_UTC_TIMEZONE),
true,
HadoopConfigurations.getParquetConf(this.conf, hadoopConf),
- fieldNames.toArray(new String[0]),
- fieldTypes.toArray(new DataType[0]),
+ fieldNames,
+ readFieldTypes,
partObjects,
requiredPos,
2048,
@@ -298,6 +313,8 @@ public class MergeOnReadInputFormat
0,
Long.MAX_VALUE, // read the whole file
predicates);
+ return vectorColumnInfo.isEmpty() ? rows
+ : VectorConversionUtils.wrapVectorColumnIterator(rows, fieldTypes,
requiredPos, vectorColumnInfo);
}
/**
diff --git
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/function/TestCdcVectorRead.java
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/function/TestCdcVectorRead.java
new file mode 100644
index 000000000000..f0c6fa39d3a8
--- /dev/null
+++
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/function/TestCdcVectorRead.java
@@ -0,0 +1,443 @@
+/*
+ * 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.hudi.source.reader.function;
+
+import org.apache.hudi.common.engine.TaskContextSupplier;
+import org.apache.hudi.common.fs.FSUtils;
+import org.apache.hudi.common.model.FileSlice;
+import org.apache.hudi.common.model.HoodieBaseFile;
+import org.apache.hudi.common.model.HoodieFileFormat;
+import org.apache.hudi.common.model.HoodieLogFile;
+import org.apache.hudi.common.model.HoodieTableType;
+import org.apache.hudi.common.schema.HoodieSchema;
+import org.apache.hudi.common.schema.HoodieSchemaUtils;
+import org.apache.hudi.common.table.HoodieTableConfig;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.HoodieTableVersion;
+import org.apache.hudi.common.table.cdc.HoodieCDCFileSplit;
+import org.apache.hudi.common.table.cdc.HoodieCDCInferenceCase;
+import org.apache.hudi.common.table.cdc.HoodieCDCSupplementalLoggingMode;
+import org.apache.hudi.common.table.cdc.HoodieCDCUtils;
+import org.apache.hudi.common.table.log.NativeLogFooterMetadata;
+import
org.apache.hudi.common.table.log.block.HoodieLogBlock.HeaderMetadataType;
+import org.apache.hudi.common.testutils.HoodieTestTable;
+import org.apache.hudi.common.util.Option;
+import org.apache.hudi.configuration.FlinkOptions;
+import org.apache.hudi.io.storage.row.HoodieRowDataFileWriter;
+import org.apache.hudi.io.storage.row.HoodieRowDataFileWriterFactory;
+import org.apache.hudi.source.reader.BatchRecords;
+import org.apache.hudi.source.split.HoodieCdcSourceSplit;
+import org.apache.hudi.storage.StoragePath;
+import org.apache.hudi.table.format.InternalSchemaManager;
+import org.apache.hudi.table.format.cdc.CdcInputFormat;
+import org.apache.hudi.table.format.cdc.CdcInputSplit;
+import org.apache.hudi.table.format.mor.MergeOnReadTableState;
+import org.apache.hudi.util.DataTypeUtils;
+import org.apache.hudi.util.FlinkWriteClients;
+import org.apache.hudi.util.HoodieSchemaConverter;
+import org.apache.hudi.util.StreamerUtil;
+import org.apache.hudi.utils.TestConfigurations;
+
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.table.api.DataTypes;
+import org.apache.flink.table.data.GenericArrayData;
+import org.apache.flink.table.data.GenericRowData;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.data.StringData;
+import org.apache.flink.table.runtime.typeutils.RowDataSerializer;
+import org.apache.flink.table.types.DataType;
+import org.apache.flink.table.types.logical.RowType;
+import org.apache.flink.types.RowKind;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.EnumMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+
+/** Real base-file and CDC-log coverage for both Flink CDC source
implementations. */
+class TestCdcVectorRead {
+ private static final String INSTANT = "20260910000000000";
+ private static final DataType DATA_TYPE = DataTypes.ROW(
+ DataTypes.FIELD("id", DataTypes.STRING().notNull()),
+ DataTypes.FIELD("embedding",
DataTypes.ARRAY(DataTypes.FLOAT().notNull())),
+ DataTypes.FIELD("features",
DataTypes.ARRAY(DataTypes.DOUBLE().notNull()).notNull()),
+ DataTypes.FIELD("values", DataTypes.ARRAY(DataTypes.INT()))).notNull();
+
+ @TempDir
+ Path tempDir;
+
+ static Stream<Arguments> readCases() {
+ List<Arguments> cases = new ArrayList<>();
+ for (HoodieFileFormat format : Arrays.asList(HoodieFileFormat.PARQUET,
HoodieFileFormat.LANCE)) {
+ for (boolean sourceV2 : new boolean[] {false, true}) {
+ for (HoodieTableType tableType : HoodieTableType.values()) {
+ for (HoodieCDCInferenceCase inference : Arrays.asList(
+ HoodieCDCInferenceCase.BASE_FILE_INSERT,
HoodieCDCInferenceCase.BASE_FILE_DELETE,
+ HoodieCDCInferenceCase.REPLACE_COMMIT)) {
+ for (int vectorColumns = 0; vectorColumns <= 2; vectorColumns++) {
+ cases.add(Arguments.of(format, sourceV2, tableType, inference,
vectorColumns));
+ }
+ }
+ }
+ }
+ }
+ return cases.stream();
+ }
+
+ @ParameterizedTest
+ @MethodSource("readCases")
+ void testReadVectors(HoodieFileFormat format, boolean sourceV2,
HoodieTableType tableType,
+ HoodieCDCInferenceCase inference, int vectorColumns)
throws Exception {
+ readVectors(format, sourceV2, tableType, inference, 2, vectorColumns);
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testLanceVectorDimensionMismatch(boolean sourceV2) {
+ Exception exception = assertThrows(Exception.class, () -> readVectors(
+ HoodieFileFormat.LANCE, sourceV2, HoodieTableType.COPY_ON_WRITE,
+ HoodieCDCInferenceCase.BASE_FILE_INSERT, 3, 2));
+ Throwable cause = exception;
+ while (cause.getCause() != null) {
+ cause = cause.getCause();
+ }
+ assertTrue(cause.getMessage().contains("requested VECTOR(3)"),
cause.getMessage());
+ }
+
+ private void readVectors(HoodieFileFormat format, boolean sourceV2,
HoodieTableType tableType,
+ HoodieCDCInferenceCase inference, int
requestedDimension, int vectorColumns) throws Exception {
+ Configuration conf = TestConfigurations.getDefaultConf(tempDir.toString());
+ conf.set(FlinkOptions.TABLE_TYPE, tableType.name());
+ conf.set(FlinkOptions.CDC_ENABLED, true);
+ conf.set(FlinkOptions.SUPPLEMENTAL_LOGGING_MODE,
HoodieCDCSupplementalLoggingMode.OP_KEY_ONLY.name());
+ conf.setString(HoodieTableConfig.BASE_FILE_FORMAT.key(), format.name());
+ conf.set(FlinkOptions.PARTITION_PATH_FIELD, "");
+ conf.set(FlinkOptions.RECORD_KEY_FIELD, "id");
+ conf.set(FlinkOptions.ORDERING_FIELDS, FlinkOptions.NO_PRE_COMBINE);
+ conf.set(FlinkOptions.VECTOR_COLUMNS, "embedding:2,features:3");
+ conf.set(FlinkOptions.SOURCE_AVRO_SCHEMA,
HoodieSchemaConverter.convertToSchema(
+ DATA_TYPE.getLogicalType(), "vector_record",
conf.get(FlinkOptions.VECTOR_COLUMNS)).toString());
+ HoodieTableMetaClient metaClient = StreamerUtil.initTableIfNotExists(conf);
+ HoodieSchema schema =
HoodieSchemaUtils.addMetadataFields(HoodieSchema.parse(conf.get(FlinkOptions.SOURCE_AVRO_SCHEMA)));
+ RowType rowType = HoodieSchemaConverter.convertToRowType(schema);
+ RowType projected = projectedRowType(rowType, vectorColumns);
+ HoodieSchema requestedTableSchema =
HoodieSchemaUtils.addMetadataFields(HoodieSchemaConverter.convertToSchema(
+ DATA_TYPE.getLogicalType(), "vector_record", "embedding:" +
requestedDimension + ",features:3"));
+ MergeOnReadTableState tableState = new MergeOnReadTableState(rowType,
projected,
+ requestedTableSchema.toString(),
DataTypeUtils.toHoodieSchema(projected, requestedTableSchema).toString(),
Collections.emptyList());
+ StoragePath path = new StoragePath(tempDir.resolve("file-1_0-0-0_" +
INSTANT + format.getFileExtension()).toUri());
+ HoodieRowDataFileWriterFactory factory = new
HoodieRowDataFileWriterFactory(metaClient.getStorage());
+ try (HoodieRowDataFileWriter writer = (HoodieRowDataFileWriter) (format ==
HoodieFileFormat.PARQUET
+ ? factory.newParquetFileWriter(INSTANT, path,
FlinkWriteClients.getHoodieClientConfig(conf), schema,
mock(TaskContextSupplier.class))
+ : factory.newLanceFileWriter(INSTANT, path,
FlinkWriteClients.getHoodieClientConfig(conf), schema,
mock(TaskContextSupplier.class)))) {
+ for (int i = 0; i < 2; i++) {
+ StringData id = StringData.fromString("id" + i);
+ writer.writeRow(id.toString(), GenericRowData.of(
+ StringData.fromString(INSTANT), StringData.fromString("seq" + i),
id,
+ StringData.fromString(""), StringData.fromString(path.getName()),
id,
+ i == 0 ? new GenericArrayData(new float[] {1.25F, 2.5F}) : null,
+ new GenericArrayData(new double[] {3.5D, 4.5D, 5.5D}),
+ new GenericArrayData(new Integer[] {10 + i, null, 20 + i})));
+ }
+ }
+ FileSlice slice = new FileSlice("", INSTANT, "file-1");
+ slice.setBaseFile(new HoodieBaseFile(path.toString()));
+ HoodieCDCFileSplit change = new HoodieCDCFileSplit(INSTANT, inference,
+ Collections.singletonList(path.getName()), Option.of(slice),
Option.empty());
+ HoodieCDCFileSplit[] changes = {change};
+ RowKind kind = inference == HoodieCDCInferenceCase.BASE_FILE_INSERT ?
RowKind.INSERT : RowKind.DELETE;
+ List<DataType> fieldTypes = DataTypes.of(rowType).getChildren();
+ if (sourceV2) {
+ HoodieCdcSplitReaderFunction function = new HoodieCdcSplitReaderFunction(
+ conf, tableState, InternalSchemaManager.DISABLED, fieldTypes,
Collections.emptyList(), false);
+ HoodieCdcSourceSplit split = new HoodieCdcSourceSplit(
+ 0, tempDir.toString(), 1024 * 1024, "file-1", "", changes,
+ FlinkOptions.REALTIME_PAYLOAD_COMBINE, INSTANT);
+ try {
+ function.open(split);
+ BatchRecords<RowData> batch = function.readBatch(split, 10, () ->
false);
+ assertNotNull(batch);
+ assertRow(batch.nextRecordFromSplit().record(), 0, kind, projected);
+ assertRow(batch.nextRecordFromSplit().record(), 1, kind, projected);
+ assertNull(batch.nextRecordFromSplit());
+ assertNull(function.readBatch(split, 10, () -> false));
+ } finally {
+ function.close();
+ }
+ } else {
+ CdcInputFormat inputFormat =
CdcInputFormat.builder().config(conf).tableState(tableState)
+
.fieldTypes(fieldTypes).predicates(Collections.emptyList()).limit(-1).emitDelete(false).build();
+ try {
+ inputFormat.open(new CdcInputSplit(0, tempDir.toString(), 1024 * 1024,
"file-1", "", changes));
+ int count = 0;
+ while (!inputFormat.reachedEnd()) {
+ assertRow(inputFormat.nextRecord(null), count++, kind, projected);
+ }
+ assertEquals(2, count);
+ } finally {
+ inputFormat.close();
+ }
+ }
+ }
+
+ static Stream<Arguments> asIsCases() {
+ List<Arguments> cases = new ArrayList<>();
+ for (HoodieFileFormat format : Arrays.asList(HoodieFileFormat.PARQUET,
HoodieFileFormat.LANCE)) {
+ for (boolean sourceV2 : new boolean[] {false, true}) {
+ for (HoodieTableType tableType : HoodieTableType.values()) {
+ for (int vectorColumns = 0; vectorColumns <= 2; vectorColumns++) {
+ cases.add(Arguments.of(format, sourceV2, tableType, vectorColumns,
false));
+ if (tableType == HoodieTableType.MERGE_ON_READ) {
+ cases.add(Arguments.of(format, sourceV2, tableType,
vectorColumns, true));
+ }
+ }
+ }
+ }
+ }
+ return cases.stream();
+ }
+
+ @ParameterizedTest(name = "{0}, sourceV2={1}, {2}, vectors={3},
dataLogs={4}")
+ @MethodSource("asIsCases")
+ void testAsIsOpKeyOnlyVectors(HoodieFileFormat format, boolean sourceV2,
HoodieTableType tableType,
+ int vectorColumns, boolean withDataLogs) throws
Exception {
+ HoodieCDCSupplementalLoggingMode mode =
HoodieCDCSupplementalLoggingMode.OP_KEY_ONLY;
+ String afterInstant = "20260910000001000";
+ Configuration conf = TestConfigurations.getDefaultConf(tempDir.toString());
+ conf.set(FlinkOptions.WRITE_TABLE_VERSION,
HoodieTableVersion.TEN.versionCode());
+ conf.set(FlinkOptions.TABLE_TYPE, tableType.name());
+ conf.set(FlinkOptions.CDC_ENABLED, true);
+ conf.set(FlinkOptions.SUPPLEMENTAL_LOGGING_MODE, mode.name());
+ conf.setString(HoodieTableConfig.BASE_FILE_FORMAT.key(), format.name());
+ conf.set(FlinkOptions.PARTITION_PATH_FIELD, "");
+ conf.set(FlinkOptions.RECORD_KEY_FIELD, "id");
+ conf.set(FlinkOptions.ORDERING_FIELDS, FlinkOptions.NO_PRE_COMBINE);
+ conf.set(FlinkOptions.VECTOR_COLUMNS, "embedding:2,features:3");
+ HoodieSchema dataSchema = HoodieSchemaConverter.convertToSchema(
+ DATA_TYPE.getLogicalType(), "vector_record",
conf.get(FlinkOptions.VECTOR_COLUMNS));
+ conf.set(FlinkOptions.SOURCE_AVRO_SCHEMA, dataSchema.toString());
+ HoodieTableMetaClient metaClient = StreamerUtil.initTableIfNotExists(conf);
+ HoodieSchema tableSchema = HoodieSchemaUtils.addMetadataFields(dataSchema);
+ RowType rowType = HoodieSchemaConverter.convertToRowType(tableSchema);
+ RowType projected = projectedRowType(rowType, vectorColumns);
+ MergeOnReadTableState tableState = new MergeOnReadTableState(rowType,
projected,
+ tableSchema.toString(), DataTypeUtils.toHoodieSchema(projected,
tableSchema).toString(), Collections.emptyList());
+ GenericRowData old1 = image("id1", 1, false);
+ GenericRowData old2 = image("id2", 2, true);
+ GenericRowData deleted = image("id3", 3, false);
+ GenericRowData new1 = image("id1", 4, true);
+ GenericRowData new2 = image("id2", 5, false);
+ GenericRowData inserted1 = image("id4", 6, false);
+ GenericRowData inserted2 = image("id5", 7, true);
+ FileSlice before;
+ FileSlice after;
+ if (withDataLogs) {
+ // Both snapshots require merging: neither base file alone contains the
expected vector images.
+ before = writeImageSlice(conf, metaClient, tableSchema, format,
"20260909235959000",
+ image("id1", 0, true), image("id2", 0, false), deleted);
+ addImageDataLog(conf, metaClient, tableSchema, format, before, INSTANT,
old1, old2);
+ after = writeImageSlice(conf, metaClient, tableSchema, format,
"20260910000000500",
+ old1, old2, inserted1, inserted2);
+ addImageDataLog(conf, metaClient, tableSchema, format, after,
afterInstant, new1, new2);
+
HoodieTestTable.of(metaClient).addDeltaCommit(INSTANT).addDeltaCommit(afterInstant);
+ metaClient.reloadActiveTimeline();
+ } else {
+ before = writeImageSlice(conf, metaClient, tableSchema, format, INSTANT,
old1, old2, deleted);
+ after = writeImageSlice(conf, metaClient, tableSchema, format,
afterInstant, new1, new2, inserted1, inserted2);
+ }
+ HoodieSchema cdcSchema =
HoodieCDCUtils.schemaBySupplementalLoggingMode(mode, dataSchema);
+ List<RowData> cdcRows = Arrays.asList(
+ GenericRowData.of(StringData.fromString("u"),
StringData.fromString("id1")),
+ GenericRowData.of(StringData.fromString("u"),
StringData.fromString("id2")),
+ GenericRowData.of(StringData.fromString("d"),
StringData.fromString("id3")),
+ GenericRowData.of(StringData.fromString("i"),
StringData.fromString("id4")),
+ GenericRowData.of(StringData.fromString("i"),
StringData.fromString("id5")));
+ String cdcFile = writeCdcFile(conf, metaClient, cdcSchema, format,
afterInstant, cdcRows);
+ HoodieCDCFileSplit change = new HoodieCDCFileSplit(afterInstant,
HoodieCDCInferenceCase.AS_IS,
+ cdcFile, Option.of(before), Option.of(after));
+ List<RowData> expected = Arrays.asList(
+ projectedImage(old1, RowKind.UPDATE_BEFORE, projected),
projectedImage(new1, RowKind.UPDATE_AFTER, projected),
+ projectedImage(old2, RowKind.UPDATE_BEFORE, projected),
projectedImage(new2, RowKind.UPDATE_AFTER, projected),
+ projectedImage(deleted, RowKind.DELETE, projected),
projectedImage(inserted1, RowKind.INSERT, projected),
+ projectedImage(inserted2, RowKind.INSERT, projected));
+ RowDataSerializer serializer = new RowDataSerializer(projected);
+ List<RowData> actual = new ArrayList<>();
+ if (sourceV2) {
+ HoodieCdcSplitReaderFunction function = new
HoodieCdcSplitReaderFunction(conf, tableState,
+ InternalSchemaManager.DISABLED, DataTypes.of(rowType).getChildren(),
Collections.emptyList(), false);
+ HoodieCdcSourceSplit split = new HoodieCdcSourceSplit(0,
tempDir.toString(), 1024 * 1024,
+ "file-1", "", new HoodieCDCFileSplit[] {change},
FlinkOptions.REALTIME_PAYLOAD_COMBINE, afterInstant);
+ try {
+ function.open(split);
+ BatchRecords<RowData> batch;
+ // A one-row batch also checks that UPDATE_AFTER survives across batch
boundaries.
+ while ((batch = function.readBatch(split, 1, () -> false)) != null) {
+ actual.add(batch.nextRecordFromSplit().record());
+ assertNull(batch.nextRecordFromSplit());
+ }
+ } finally {
+ function.close();
+ }
+ } else {
+ CdcInputFormat inputFormat =
CdcInputFormat.builder().config(conf).tableState(tableState)
+
.fieldTypes(DataTypes.of(rowType).getChildren()).predicates(Collections.emptyList())
+ .limit(-1).emitDelete(false).build();
+ try {
+ inputFormat.open(new CdcInputSplit(0, tempDir.toString(), 1024 * 1024,
+ "file-1", "", new HoodieCDCFileSplit[] {change}));
+ while (!inputFormat.reachedEnd()) {
+ actual.add(serializer.copy(inputFormat.nextRecord(null)));
+ }
+ } finally {
+ inputFormat.close();
+ }
+ }
+ assertEquals(expected.size(), actual.size());
+ for (int i = 0; i < expected.size(); i++) {
+ assertEquals(expected.get(i).getRowKind(), actual.get(i).getRowKind(),
"RowKind at " + i);
+ assertEquals(serializer.toBinaryRow(expected.get(i)).copy(),
serializer.toBinaryRow(actual.get(i)).copy(), "Image at " + i);
+ }
+ }
+
+ private FileSlice writeImageSlice(Configuration conf, HoodieTableMetaClient
metaClient, HoodieSchema schema,
+ HoodieFileFormat format, String instant,
GenericRowData... images) throws Exception {
+ StoragePath path = new StoragePath(tempDir.resolve("file-1_0-0-0_" +
instant + format.getFileExtension()).toUri());
+ writeImages(conf, metaClient, schema, format, instant, path, false,
images);
+ FileSlice slice = new FileSlice("", instant, "file-1");
+ slice.setBaseFile(new HoodieBaseFile(path.toString()));
+ return slice;
+ }
+
+ private void addImageDataLog(Configuration conf, HoodieTableMetaClient
metaClient, HoodieSchema schema,
+ HoodieFileFormat format, FileSlice slice,
String instant, GenericRowData... images) throws Exception {
+ StoragePath path = new StoragePath(tempDir.toString(),
FSUtils.makeNativeLogFileName(
+ slice.getFileId(), "0-0-0", instant, 1, ".log", format));
+ writeImages(conf, metaClient, schema, format, instant, path, true, images);
+ slice.addLogFile(new HoodieLogFile(path));
+ }
+
+ private void writeImages(Configuration conf, HoodieTableMetaClient
metaClient, HoodieSchema schema,
+ HoodieFileFormat format, String instant,
StoragePath path,
+ boolean dataLog, GenericRowData... images) throws
Exception {
+ try (HoodieRowDataFileWriter writer = createWriter(conf, metaClient,
schema, format, instant, path)) {
+ if (dataLog) {
+ Map<HeaderMetadataType, String> header = new
EnumMap<>(HeaderMetadataType.class);
+ header.put(HeaderMetadataType.INSTANT_TIME, instant);
+ header.put(HeaderMetadataType.SCHEMA, schema.toString());
+
writer.addFooterMetadata(NativeLogFooterMetadata.toFooterMetadata(header));
+ }
+ for (GenericRowData image : images) {
+ StringData id = image.getString(0);
+ writer.writeRow(id.toString(),
GenericRowData.of(StringData.fromString(instant), StringData.fromString("seq"),
+ id, StringData.fromString(""),
StringData.fromString(path.getName()),
+ id, image.getField(1), image.getField(2), image.getField(3)));
+ }
+ }
+ }
+
+ private String writeCdcFile(Configuration conf, HoodieTableMetaClient
metaClient, HoodieSchema schema,
+ HoodieFileFormat format, String instant,
List<RowData> rows) throws Exception {
+ StoragePath path = new StoragePath(tempDir.toString(),
FSUtils.makeNativeLogFileName(
+ "file-1", "0-0-0", instant, 1, HoodieCDCUtils.CDC_LOGFILE_SUFFIX,
format));
+ try (HoodieRowDataFileWriter writer = createWriter(conf, metaClient,
schema, format, instant, path)) {
+ for (RowData row : rows) {
+ writer.writeRow("key", row);
+ }
+ }
+ return path.getName();
+ }
+
+ private static HoodieRowDataFileWriter createWriter(Configuration conf,
HoodieTableMetaClient metaClient,
+ HoodieSchema schema,
HoodieFileFormat format,
+ String instant,
StoragePath path) throws Exception {
+ HoodieRowDataFileWriterFactory factory = new
HoodieRowDataFileWriterFactory(metaClient.getStorage());
+ return (HoodieRowDataFileWriter) (format == HoodieFileFormat.PARQUET
+ ? factory.newParquetFileWriter(instant, path,
FlinkWriteClients.getHoodieClientConfig(conf), schema,
mock(TaskContextSupplier.class))
+ : factory.newLanceFileWriter(instant, path,
FlinkWriteClients.getHoodieClientConfig(conf), schema,
mock(TaskContextSupplier.class)));
+ }
+
+ private static GenericRowData image(String id, int value, boolean
nullVector) {
+ return GenericRowData.of(StringData.fromString(id),
+ nullVector ? null : new GenericArrayData(new float[] {value + 0.25F,
value + 0.5F}),
+ new GenericArrayData(new double[] {value + 0.5D, value + 1.5D, value +
2.5D}),
+ new GenericArrayData(new Integer[] {value, null, value + 10}));
+ }
+
+ private static RowType projectedRowType(RowType rowType, int vectorColumns) {
+ // Omit metadata, reorder fields, and optionally omit one or both VECTOR
columns.
+ List<String> names = vectorColumns == 2 ? Arrays.asList("values",
"features", "id", "embedding")
+ : vectorColumns == 1 ? Arrays.asList("values", "id", "embedding") :
Arrays.asList("id", "values");
+ return new RowType(false, names.stream().map(name ->
rowType.getFields().get(rowType.getFieldIndex(name)))
+ .collect(Collectors.toList()));
+ }
+
+ private static RowData projectedImage(GenericRowData image, RowKind kind,
RowType projectedType) {
+ List<String> dataFields = ((RowType)
DATA_TYPE.getLogicalType()).getFieldNames();
+ GenericRowData projected = new GenericRowData(kind,
projectedType.getFieldCount());
+ for (int i = 0; i < projectedType.getFieldCount(); i++) {
+ projected.setField(i,
image.getField(dataFields.indexOf(projectedType.getFieldNames().get(i))));
+ }
+ return projected;
+ }
+
+ private static void assertRow(RowData row, int index, RowKind kind, RowType
projectedType) {
+ assertEquals(kind, row.getRowKind());
+ assertEquals(projectedType.getFieldCount(), row.getArity());
+ assertEquals("id" + index,
row.getString(projectedType.getFieldIndex("id")).toString());
+ int values = projectedType.getFieldIndex("values");
+ assertEquals(3, row.getArray(values).size());
+ assertEquals(10 + index, row.getArray(values).getInt(0));
+ assertTrue(row.getArray(values).isNullAt(1));
+ assertEquals(20 + index, row.getArray(values).getInt(2));
+ int features = projectedType.getFieldIndex("features");
+ if (features >= 0) {
+ assertEquals(3, row.getArray(features).size());
+ assertEquals(3.5D, row.getArray(features).getDouble(0));
+ assertEquals(4.5D, row.getArray(features).getDouble(1));
+ assertEquals(5.5D, row.getArray(features).getDouble(2));
+ }
+ int embedding = projectedType.getFieldIndex("embedding");
+ if (embedding >= 0) {
+ if (index == 0) {
+ assertEquals(2, row.getArray(embedding).size());
+ assertEquals(1.25F, row.getArray(embedding).getFloat(0));
+ assertEquals(2.5F, row.getArray(embedding).getFloat(1));
+ } else {
+ assertTrue(row.isNullAt(embedding));
+ }
+ }
+ }
+}
diff --git
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/function/TestHoodieCdcSplitReaderFunction.java
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/function/TestHoodieCdcSplitReaderFunction.java
index b4d11e178620..0146a32e7fc7 100644
---
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/function/TestHoodieCdcSplitReaderFunction.java
+++
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/reader/function/TestHoodieCdcSplitReaderFunction.java
@@ -298,7 +298,7 @@ public class TestHoodieCdcSplitReaderFunction {
try (MockedStatic<FormatUtils> mocked = mockStatic(FormatUtils.class)) {
mocked.when(() -> FormatUtils.getLanceRecordIterator(
- anyString(), anyList(), anyList(), any(int[].class), any()))
+ anyString(), any(HoodieSchema.class), any()))
.thenReturn(nested);
try (ClosableIterator<RowData> iterator =
createFunction().createRecordIterator(split)) {
assertTrue(iterator.hasNext());