This is an automated email from the ASF dual-hosted git repository.
luoyuxia pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fluss.git
The following commit(s) were added to refs/heads/main by this push:
new fbde73cbb [lake/hudi] Add Hudi Flink union read log table IT (#3528)
fbde73cbb is described below
commit fbde73cbb48772d4be333c1aa1fb3637c037d5f5
Author: fhan <[email protected]>
AuthorDate: Thu Jun 25 22:36:49 2026 +0800
[lake/hudi] Add Hudi Flink union read log table IT (#3528)
---
.../hudi/flink/FlinkUnionReadLogTableITCase.java | 374 +++++++++++++++++++++
1 file changed, 374 insertions(+)
diff --git
a/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/flink/FlinkUnionReadLogTableITCase.java
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/flink/FlinkUnionReadLogTableITCase.java
new file mode 100644
index 000000000..d19799cc3
--- /dev/null
+++
b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/flink/FlinkUnionReadLogTableITCase.java
@@ -0,0 +1,374 @@
+/*
+ * 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.fluss.lake.hudi.flink;
+
+import org.apache.fluss.config.AutoPartitionTimeUnit;
+import org.apache.fluss.config.ConfigOptions;
+import org.apache.fluss.metadata.Schema;
+import org.apache.fluss.metadata.TableDescriptor;
+import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.row.Decimal;
+import org.apache.fluss.row.InternalRow;
+import org.apache.fluss.row.TimestampLtz;
+import org.apache.fluss.row.TimestampNtz;
+import org.apache.fluss.types.DataTypes;
+
+import org.apache.flink.core.execution.JobClient;
+import org.apache.flink.core.execution.SavepointFormatType;
+import org.apache.flink.table.api.TableResult;
+import org.apache.flink.table.api.bridge.java.StreamTableEnvironment;
+import org.apache.flink.types.Row;
+import org.apache.flink.util.CloseableIterator;
+import org.apache.flink.util.CollectionUtil;
+import org.apache.hudi.configuration.FlinkOptions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import javax.annotation.Nullable;
+
+import java.io.File;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import static
org.apache.fluss.flink.source.testutils.FlinkRowAssertionsUtils.assertResultsExactOrder;
+import static
org.apache.fluss.flink.source.testutils.FlinkRowAssertionsUtils.assertResultsIgnoreOrder;
+import static
org.apache.fluss.flink.source.testutils.FlinkRowAssertionsUtils.assertRowResultsIgnoreOrder;
+import static org.apache.fluss.testutils.DataTestUtils.row;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Test union read log table with full type. */
+class FlinkUnionReadLogTableITCase extends FlinkUnionReadTestBase {
+
+ @TempDir public static File savepointDir;
+
+ @BeforeAll
+ protected static void beforeAll() {
+ FlinkUnionReadTestBase.beforeAll();
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testReadLogTableFullType(boolean isPartitioned) throws Exception {
+ JobClient jobClient = buildCheckpointedTieringJob();
+
+ String tableName = "logTable_" + (isPartitioned ? "partitioned" :
"non_partitioned");
+
+ TablePath tablePath = TablePath.of(DEFAULT_DB, tableName);
+ List<Row> writtenRows = new ArrayList<>();
+ long tableId = prepareLogTable(tablePath, DEFAULT_BUCKET_NUM,
isPartitioned, writtenRows);
+ waitUntilBucketSynced(tablePath, tableId, DEFAULT_BUCKET_NUM,
isPartitioned);
+
+ List<Row> actual =
+ CollectionUtil.iteratorToList(
+ batchTEnv.executeSql("select * from " +
tableName).collect());
+ assertThat(actual).containsExactlyInAnyOrderElementsOf(writtenRows);
+
+ jobClient.cancel().get();
+
+ writtenRows.addAll(writeRows(tablePath, 3, 3, isPartitioned));
+
+ actual =
+ CollectionUtil.iteratorToList(
+ batchTEnv.executeSql("select * from " +
tableName).collect());
+ assertThat(actual).containsExactlyInAnyOrderElementsOf(writtenRows);
+
+ actual =
+ CollectionUtil.iteratorToList(
+ batchTEnv.executeSql("select f_int from " +
tableName).collect());
+ List<Row> expected =
+ writtenRows.stream()
+ .map(row -> Row.of(row.getField(1)))
+ .collect(Collectors.toList());
+ assertThat(actual).containsExactlyInAnyOrderElementsOf(expected);
+
+ if (isPartitioned) {
+ String partition =
waitUntilPartitions(tablePath).values().iterator().next();
+ String sqlWithPartitionFilter =
+ "select * FROM " + tableName + " WHERE p = '" + partition
+ "'";
+
+ String plan = batchTEnv.explainSql(sqlWithPartitionFilter);
+ assertThat(plan)
+ .contains("TableSourceScan(")
+ .contains("filter=[=(p, _UTF-16LE'" + partition + "'");
+
+ List<Row> expectedFiltered =
+ writtenRows.stream()
+ .filter(row -> partition.equals(row.getField(13)))
+ .collect(Collectors.toList());
+
+ List<Row> actualFiltered =
+ CollectionUtil.iteratorToList(
+
batchTEnv.executeSql(sqlWithPartitionFilter).collect());
+
+
assertThat(actualFiltered).containsExactlyInAnyOrderElementsOf(expectedFiltered);
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testReadLogTableInStreamMode(boolean isPartitioned) throws Exception {
+ JobClient jobClient = buildCheckpointedTieringJob();
+
+ String tableName = "stream_logTable_" + (isPartitioned ? "partitioned"
: "non_partitioned");
+
+ TablePath tablePath = TablePath.of(DEFAULT_DB, tableName);
+ List<Row> writtenRows = new LinkedList<>();
+ long tableId = prepareLogTable(tablePath, DEFAULT_BUCKET_NUM,
isPartitioned, writtenRows);
+ waitUntilBucketSynced(tablePath, tableId, DEFAULT_BUCKET_NUM,
isPartitioned);
+
+ CloseableIterator<Row> actual =
+ streamTEnv.executeSql("select * from " + tableName).collect();
+ assertResultsIgnoreOrder(
+ actual,
writtenRows.stream().map(Row::toString).collect(Collectors.toList()), true);
+
+ jobClient.cancel().get();
+
+ writtenRows.addAll(writeRows(tablePath, 3, 3, isPartitioned));
+
+ actual =
+ streamTEnv
+ .executeSql(
+ "select * from "
+ + tableName
+ + " /*+
OPTIONS('scan.partition.discovery.interval'='100ms') */")
+ .collect();
+ if (isPartitioned) {
+ writtenRows.addAll(writeFullTypeRows(tablePath, 10, 10, "3027"));
+ }
+ assertResultsIgnoreOrder(
+ actual,
writtenRows.stream().map(Row::toString).collect(Collectors.toList()), true);
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testUnionReadLogTableFailover(boolean isPartitioned) throws Exception
{
+ JobClient jobClient = buildCheckpointedTieringJob();
+
+ String tableName =
+ "restore_logTable_" + (isPartitioned ? "partitioned" :
"non_partitioned");
+ String resultTableName =
+ "result_table_" + (isPartitioned ? "partitioned" :
"non_partitioned");
+
+ TablePath tablePath = TablePath.of(DEFAULT_DB, tableName);
+ TablePath resultTablePath = TablePath.of(DEFAULT_DB, resultTableName);
+ List<Row> writtenRows = new LinkedList<>();
+ long tableId = prepareLogTable(tablePath, DEFAULT_BUCKET_NUM,
isPartitioned, writtenRows);
+ waitUntilBucketSynced(tablePath, tableId, DEFAULT_BUCKET_NUM,
isPartitioned);
+
+ StreamTableEnvironment streamTEnv = buildStreamTEnv(null);
+ createFullTypeLogTable(resultTablePath, DEFAULT_BUCKET_NUM,
isPartitioned, false);
+ TableResult insertResult =
+ streamTEnv.executeSql(
+ "insert into " + resultTableName + " select * from " +
tableName);
+
+ CloseableIterator<Row> actual =
+ streamTEnv.executeSql("select * from " +
resultTableName).collect();
+ assertRowResultsIgnoreOrder(actual, writtenRows, false);
+
+ String savepointPath =
+ insertResult
+ .getJobClient()
+ .get()
+ .stopWithSavepoint(
+ false,
+ savepointDir.getAbsolutePath(),
+ SavepointFormatType.CANONICAL)
+ .get();
+
+ streamTEnv = buildStreamTEnv(savepointPath);
+ insertResult =
+ streamTEnv.executeSql(
+ "insert into " + resultTableName + " select * from " +
tableName);
+
+ List<Row> rows = writeRows(tablePath, 3, 3, isPartitioned);
+ if (isPartitioned) {
+ assertRowResultsIgnoreOrder(actual, rows, true);
+ } else {
+ assertResultsExactOrder(actual, rows, true);
+ }
+
+ insertResult.getJobClient().get().cancel().get();
+ jobClient.cancel().get();
+ }
+
+ private JobClient buildCheckpointedTieringJob() throws Exception {
+ execEnv.enableCheckpointing(1000);
+ return buildTieringJob(execEnv);
+ }
+
+ private long prepareLogTable(
+ TablePath tablePath, int bucketNum, boolean isPartitioned,
List<Row> flinkRows)
+ throws Exception {
+ long tableId = createFullTypeLogTable(tablePath, bucketNum,
isPartitioned);
+ if (isPartitioned) {
+ Map<Long, String> partitionNameById =
waitUntilPartitions(tablePath);
+ for (String partition :
+
partitionNameById.values().stream().sorted().collect(Collectors.toList())) {
+ for (int batchIndex = 0; batchIndex < 3; batchIndex++) {
+ flinkRows.addAll(writeFullTypeRows(tablePath, batchIndex,
10, partition));
+ }
+ }
+ } else {
+ for (int batchIndex = 0; batchIndex < 3; batchIndex++) {
+ flinkRows.addAll(writeFullTypeRows(tablePath, batchIndex, 10,
null));
+ }
+ }
+ return tableId;
+ }
+
+ private long createFullTypeLogTable(TablePath tablePath, int bucketNum,
boolean isPartitioned)
+ throws Exception {
+ return createFullTypeLogTable(tablePath, bucketNum, isPartitioned,
true);
+ }
+
+ private long createFullTypeLogTable(
+ TablePath tablePath, int bucketNum, boolean isPartitioned, boolean
lakeEnabled)
+ throws Exception {
+ Schema.Builder schemaBuilder =
+ Schema.newBuilder()
+ .column("f_boolean", DataTypes.BOOLEAN())
+ .column("f_int", DataTypes.INT())
+ .column("f_long", DataTypes.BIGINT())
+ .column("f_float", DataTypes.FLOAT())
+ .column("f_double", DataTypes.DOUBLE())
+ .column("f_string", DataTypes.STRING())
+ .column("f_decimal1", DataTypes.DECIMAL(5, 2))
+ .column("f_decimal2", DataTypes.DECIMAL(20, 0))
+ .column("f_timestamp_ltz1", DataTypes.TIMESTAMP_LTZ(3))
+ .column("f_timestamp_ltz2", DataTypes.TIMESTAMP_LTZ(6))
+ .column("f_timestamp_ntz1", DataTypes.TIMESTAMP(3))
+ .column("f_timestamp_ntz2", DataTypes.TIMESTAMP(6))
+ .column("f_binary", DataTypes.BINARY(4));
+
+ TableDescriptor.Builder tableBuilder =
+ TableDescriptor.builder().distributedBy(bucketNum, "f_string");
+ if (lakeEnabled) {
+ tableBuilder
+ .property(ConfigOptions.TABLE_DATALAKE_ENABLED.key(),
"true")
+ .property(ConfigOptions.TABLE_DATALAKE_FRESHNESS,
Duration.ofMillis(500))
+ .customProperty(HUDI_CONF_PREFIX + "precombine.field",
"f_string");
+ }
+ tableBuilder.customProperty(
+ HUDI_CONF_PREFIX + FlinkOptions.RECORD_KEY_FIELD.key(),
+ isPartitioned ? "f_string,p" : "f_string");
+
+ if (isPartitioned) {
+ schemaBuilder.column("p", DataTypes.STRING());
+ tableBuilder.property(ConfigOptions.TABLE_AUTO_PARTITION_ENABLED,
true);
+ tableBuilder.partitionedBy("p");
+ tableBuilder.property(
+ ConfigOptions.TABLE_AUTO_PARTITION_TIME_UNIT,
AutoPartitionTimeUnit.YEAR);
+ }
+ tableBuilder.schema(schemaBuilder.build());
+ return createTable(tablePath, tableBuilder.build());
+ }
+
+ private List<Row> writeFullTypeRows(
+ TablePath tablePath, int batchIndex, int rowCount, @Nullable
String partition)
+ throws Exception {
+ List<InternalRow> rows = new ArrayList<>();
+ List<Row> flinkRows = new ArrayList<>();
+ for (int rowIndex = 0; rowIndex < rowCount; rowIndex++) {
+ long timestamp = 1698235273400L + batchIndex * 1000L + rowIndex;
+ Object[] internalRowValues =
+ createInternalFullTypeRowValues(batchIndex, rowIndex,
rowCount, timestamp);
+ Object[] flinkRowValues =
+ createFlinkFullTypeRowValues(batchIndex, rowIndex,
rowCount, timestamp);
+ if (partition == null) {
+ rows.add(row(internalRowValues));
+ flinkRows.add(Row.of(flinkRowValues));
+ } else {
+ rows.add(row(appendPartition(internalRowValues, partition)));
+ flinkRows.add(Row.of(appendPartition(flinkRowValues,
partition)));
+ }
+ }
+ writeRows(tablePath, rows, true);
+ return flinkRows;
+ }
+
+ private Object[] createInternalFullTypeRowValues(
+ int batchIndex, int rowIndex, int rowCount, long timestamp) {
+ String stringValue = "another_string_" + batchIndex + "_" + rowIndex;
+ return new Object[] {
+ true,
+ 30,
+ 400L,
+ rowCount == 3 ? 234.1f : 500.1f,
+ 600.0d,
+ stringValue,
+ Decimal.fromUnscaledLong(900, 5, 2),
+ Decimal.fromBigDecimal(new java.math.BigDecimal(1000), 20, 0),
+ TimestampLtz.fromEpochMillis(timestamp),
+ TimestampLtz.fromEpochMillis(1698235273400L, 7000),
+ TimestampNtz.fromMillis(1698235273501L),
+ TimestampNtz.fromMillis(1698235273501L, 8000),
+ new byte[] {5, 6, 7, 8}
+ };
+ }
+
+ private Object[] createFlinkFullTypeRowValues(
+ int batchIndex, int rowIndex, int rowCount, long timestamp) {
+ String stringValue = "another_string_" + batchIndex + "_" + rowIndex;
+ return new Object[] {
+ true,
+ 30,
+ 400L,
+ rowCount == 3 ? 234.1f : 500.1f,
+ 600.0d,
+ stringValue,
+ new java.math.BigDecimal("9.00"),
+ new java.math.BigDecimal("1000"),
+ Instant.ofEpochMilli(timestamp),
+ Instant.ofEpochMilli(1698235273400L).plusNanos(7000),
+ LocalDateTime.ofInstant(Instant.ofEpochMilli(1698235273501L),
ZoneId.of("UTC")),
+ LocalDateTime.ofInstant(Instant.ofEpochMilli(1698235273501L),
ZoneId.of("UTC"))
+ .plusNanos(8000),
+ new byte[] {5, 6, 7, 8}
+ };
+ }
+
+ private Object[] appendPartition(Object[] rowValues, String partition) {
+ Object[] partitionedRowValues = new Object[rowValues.length + 1];
+ System.arraycopy(rowValues, 0, partitionedRowValues, 0,
rowValues.length);
+ partitionedRowValues[rowValues.length] = partition;
+ return partitionedRowValues;
+ }
+
+ private List<Row> writeRows(
+ TablePath tablePath, int batchIndex, int rowCount, boolean
isPartitioned)
+ throws Exception {
+ if (isPartitioned) {
+ List<Row> rows = new ArrayList<>();
+ for (String partition : waitUntilPartitions(tablePath).values()) {
+ rows.addAll(writeFullTypeRows(tablePath, batchIndex, rowCount,
partition));
+ }
+ return rows;
+ } else {
+ return writeFullTypeRows(tablePath, batchIndex, rowCount, null);
+ }
+ }
+}