This is an automated email from the ASF dual-hosted git repository.

fresh-borzoni 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 0f5909a94 [flink] Add union read support to datastream (#3432)
0f5909a94 is described below

commit 0f5909a947bc416abe2f8efa300dbe03c5663c2b
Author: Giannis Polyzos <[email protected]>
AuthorDate: Mon Jun 8 14:21:32 2026 +0300

    [flink] Add union read support to datastream (#3432)
    
    * [flink] add union read support to datastream
    
    * [flink] support bounded mode in ds
    
    * [flink] address comments
---
 .../org/apache/fluss/flink/source/FlussSource.java |  12 +-
 .../fluss/flink/source/FlussSourceBuilder.java     |  61 ++-
 .../flink/FlinkUnionReadDataStreamITCase.java      | 570 +++++++++++++++++++++
 3 files changed, 639 insertions(+), 4 deletions(-)

diff --git 
a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/source/FlussSource.java
 
b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/source/FlussSource.java
index 795e85082..328052273 100644
--- 
a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/source/FlussSource.java
+++ 
b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/source/FlussSource.java
@@ -23,6 +23,8 @@ import org.apache.fluss.config.Configuration;
 import org.apache.fluss.flink.FlinkConnectorOptions;
 import org.apache.fluss.flink.source.deserializer.FlussDeserializationSchema;
 import org.apache.fluss.flink.source.reader.LeaseContext;
+import org.apache.fluss.lake.source.LakeSource;
+import org.apache.fluss.lake.source.LakeSplit;
 import org.apache.fluss.metadata.TablePath;
 import org.apache.fluss.predicate.Predicate;
 import org.apache.fluss.types.RowType;
@@ -71,7 +73,8 @@ public class FlussSource<OUT> extends FlinkSource<OUT> {
             OffsetsInitializer offsetsInitializer,
             long scanPartitionDiscoveryIntervalMs,
             FlussDeserializationSchema<OUT> deserializationSchema,
-            boolean streaming) {
+            boolean streaming,
+            @Nullable LakeSource<LakeSplit> lakeSource) {
         this(
                 flussConf,
                 tablePath,
@@ -84,7 +87,8 @@ public class FlussSource<OUT> extends FlinkSource<OUT> {
                 scanPartitionDiscoveryIntervalMs,
                 
FlinkConnectorOptions.SCAN_SPLIT_ASSIGNMENT_BATCH_SIZE.defaultValue(),
                 deserializationSchema,
-                streaming);
+                streaming,
+                lakeSource);
     }
 
     FlussSource(
@@ -99,7 +103,8 @@ public class FlussSource<OUT> extends FlinkSource<OUT> {
             long scanPartitionDiscoveryIntervalMs,
             int splitPerAssignmentBatchSize,
             FlussDeserializationSchema<OUT> deserializationSchema,
-            boolean streaming) {
+            boolean streaming,
+            @Nullable LakeSource<LakeSplit> lakeSource) {
         // TODO: Support partition pushDown in datastream
         super(
                 flussConf,
@@ -115,6 +120,7 @@ public class FlussSource<OUT> extends FlinkSource<OUT> {
                 deserializationSchema,
                 streaming,
                 null,
+                lakeSource,
                 LeaseContext.DEFAULT);
     }
 
diff --git 
a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/source/FlussSourceBuilder.java
 
b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/source/FlussSourceBuilder.java
index 1e6549134..2e80067fe 100644
--- 
a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/source/FlussSourceBuilder.java
+++ 
b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/source/FlussSourceBuilder.java
@@ -21,10 +21,14 @@ import org.apache.fluss.client.Connection;
 import org.apache.fluss.client.ConnectionFactory;
 import org.apache.fluss.client.admin.Admin;
 import org.apache.fluss.client.initializer.OffsetsInitializer;
+import org.apache.fluss.client.initializer.SnapshotOffsetsInitializer;
 import org.apache.fluss.config.ConfigOptions;
 import org.apache.fluss.config.Configuration;
 import org.apache.fluss.flink.FlinkConnectorOptions;
 import org.apache.fluss.flink.source.deserializer.FlussDeserializationSchema;
+import org.apache.fluss.flink.utils.LakeSourceUtils;
+import org.apache.fluss.lake.source.LakeSource;
+import org.apache.fluss.lake.source.LakeSplit;
 import org.apache.fluss.metadata.TableInfo;
 import org.apache.fluss.metadata.TablePath;
 import org.apache.fluss.predicate.Predicate;
@@ -33,6 +37,7 @@ import org.apache.fluss.types.RowType;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -60,6 +65,11 @@ import static 
org.apache.flink.util.Preconditions.checkNotNull;
  *     .build();
  * }</pre>
  *
+ * <p>When the target table has datalake enabled and the source starts in full 
mode (the default,
+ * {@link OffsetsInitializer#full()}), the built source performs a union read: 
it reads the
+ * historical data tiered to the lake (e.g. Iceberg, Paimon) together with the 
real-time data still
+ * in Fluss. Other startup modes (earliest/latest/timestamp) read data from 
Fluss only.
+ *
  * @param <OUT> The type of records produced by the source being built
  */
 public class FlussSourceBuilder<OUT> {
@@ -73,6 +83,7 @@ public class FlussSourceBuilder<OUT> {
     private Long scanPartitionDiscoveryIntervalMs;
     private Integer splitPerAssignmentBatchSize;
     private OffsetsInitializer offsetsInitializer;
+    private boolean bounded;
     private FlussDeserializationSchema<OUT> deserializationSchema;
 
     private String bootstrapServers;
@@ -161,6 +172,19 @@ public class FlussSourceBuilder<OUT> {
         return this;
     }
 
+    /**
+     * Builds a bounded source for batch execution. The source reads up to the 
latest offsets at job
+     * startup and then finishes; combined with the default {@link 
OffsetsInitializer#full()} on a
+     * datalake-enabled table this performs a bounded union read of the lake 
snapshot and the Fluss
+     * log. If not called, the source is unbounded (streaming).
+     *
+     * @return this builder
+     */
+    public FlussSourceBuilder<OUT> setBounded() {
+        this.bounded = true;
+        return this;
+    }
+
     /**
      * Sets the deserialization schema for converting Fluss records to output 
records.
      *
@@ -324,6 +348,40 @@ public class FlussSourceBuilder<OUT> {
                         ? tableInfo.getRowType().project(projectedFields)
                         : tableInfo.getRowType();
 
+        // union read (lake historical + Fluss) only applies to full startup 
mode, like the SQL
+        // connector; other startup modes read Fluss only.
+        boolean lakeEnabled = tableInfo.getTableConfig().isDataLakeEnabled();
+        boolean fullStartup = offsetsInitializer instanceof 
SnapshotOffsetsInitializer;
+
+        if (bounded && !(lakeEnabled && fullStartup)) {
+            throw new IllegalArgumentException(
+                    String.format(
+                            "Bounded (batch) read requires a datalake-enabled 
table started in "
+                                    + "full mode (OffsetsInitializer.full()), 
but table '%s' has "
+                                    + "datalake enabled=%s and full startup 
mode=%s.",
+                            tablePath, lakeEnabled, fullStartup));
+        }
+
+        LakeSource<LakeSplit> lakeSource = null;
+        if (lakeEnabled && fullStartup) {
+            lakeSource =
+                    LakeSourceUtils.createLakeSource(tablePath, 
tableInfo.getProperties().toMap());
+            if (lakeSource != null) {
+                if (projectedFields != null) {
+                    int[][] nestedProjectedFields = new 
int[projectedFields.length][];
+                    for (int i = 0; i < projectedFields.length; i++) {
+                        nestedProjectedFields[i] = new int[] 
{projectedFields[i]};
+                    }
+                    lakeSource.withProject(nestedProjectedFields);
+                }
+                // push the record-batch filter to the lake side as well,
+                // so the historical lake scan is filtered consistently with 
Fluss.
+                if (logRecordBatchFilter != null) {
+                    
lakeSource.withFilters(Collections.singletonList(logRecordBatchFilter));
+                }
+            }
+        }
+
         LOG.info("Creating Fluss Source with Configuration: {}", flussConf);
 
         return new FlussSource<>(
@@ -338,6 +396,7 @@ public class FlussSourceBuilder<OUT> {
                 scanPartitionDiscoveryIntervalMs,
                 splitPerAssignmentBatchSize,
                 deserializationSchema,
-                true);
+                !bounded,
+                lakeSource);
     }
 }
diff --git 
a/fluss-lake/fluss-lake-iceberg/src/test/java/org/apache/fluss/lake/iceberg/flink/FlinkUnionReadDataStreamITCase.java
 
b/fluss-lake/fluss-lake-iceberg/src/test/java/org/apache/fluss/lake/iceberg/flink/FlinkUnionReadDataStreamITCase.java
new file mode 100644
index 000000000..6fe9ef3ff
--- /dev/null
+++ 
b/fluss-lake/fluss-lake-iceberg/src/test/java/org/apache/fluss/lake/iceberg/flink/FlinkUnionReadDataStreamITCase.java
@@ -0,0 +1,570 @@
+/*
+ * 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.iceberg.flink;
+
+import org.apache.fluss.client.initializer.OffsetsInitializer;
+import org.apache.fluss.config.AutoPartitionTimeUnit;
+import org.apache.fluss.config.ConfigOptions;
+import org.apache.fluss.flink.source.FlussSource;
+import org.apache.fluss.flink.source.FlussSourceBuilder;
+import org.apache.fluss.flink.source.deserializer.RowDataDeserializationSchema;
+import org.apache.fluss.metadata.Schema;
+import org.apache.fluss.metadata.TableDescriptor;
+import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.predicate.Predicate;
+import org.apache.fluss.predicate.PredicateBuilder;
+import org.apache.fluss.row.InternalRow;
+import org.apache.fluss.types.DataTypes;
+import org.apache.fluss.types.RowType;
+
+import org.apache.flink.api.common.RuntimeExecutionMode;
+import org.apache.flink.api.common.eventtime.WatermarkStrategy;
+import org.apache.flink.core.execution.JobClient;
+import org.apache.flink.streaming.api.datastream.DataStreamSource;
+import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.types.Row;
+import org.apache.flink.types.RowKind;
+import org.apache.flink.util.CloseableIterator;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.data.IcebergGenerics;
+import org.apache.iceberg.data.Record;
+import org.apache.iceberg.io.CloseableIterable;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import javax.annotation.Nullable;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static org.apache.fluss.lake.iceberg.utils.IcebergConversions.toIceberg;
+import static org.apache.fluss.testutils.DataTestUtils.row;
+import static org.apache.fluss.testutils.common.CommonTestUtils.waitUntil;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Integration tests for union read through the DataStream {@link FlussSource}.
+ *
+ * <p>These tests mirror the Flink SQL union-read coverage ({@code 
FlinkUnionReadLogTableITCase} and
+ * {@code FlinkUnionReadPrimaryKeyTableITCase}) but exercise the programmatic 
DataStream source.
+ * Each test asserts the three properties that make a union read meaningful:
+ *
+ * <ul>
+ *   <li>data tiered to the lake before tiering stopped is read back from the 
lake snapshot;
+ *   <li>data written to Fluss after tiering stopped is read from the live 
Fluss log;
+ *   <li>the union of both is returned exactly once (for PK tables the Fluss 
changelog is applied as
+ *       -U/+U on top of the lake snapshot read as +I).
+ * </ul>
+ */
+public class FlinkUnionReadDataStreamITCase extends FlinkUnionReadTestBase {
+
+    private static final int[] FULL_COLS = new int[] {0, 1, 2};
+    private static final int[] FULL_PARTITIONED_COLS = new int[] {0, 1, 2, 3};
+    private static final int[] PROJECTED_ID_AMOUNT_COLS = new int[] {0, 2};
+
+    @BeforeAll
+    protected static void beforeAll() {
+        FlinkUnionReadTestBase.beforeAll();
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void testUnionReadLogTable(boolean isPartitioned) throws Exception {
+        JobClient tieringJob = buildTieringJob(execEnv);
+
+        String tableName = "ds_union_log_" + (isPartitioned ? "partitioned" : 
"non_partitioned");
+        TablePath tablePath = TablePath.of(DEFAULT_DB, tableName);
+        long tableId = createLogTable(tablePath, isPartitioned);
+        List<String> partitions = partitionsOf(tablePath, isPartitioned);
+
+        List<Row> firstBatch = new ArrayList<>();
+        for (String partition : partitions) {
+            firstBatch.addAll(appendLogRows(tablePath, 0, 5, partition));
+        }
+        waitUntilBucketSynced(tablePath, tableId, DEFAULT_BUCKET_NUM, 
isPartitioned);
+        assertIcebergContainsExactly(tablePath, isPartitioned, firstBatch);
+
+        // written after tiering stops, so this batch lives only in Fluss
+        tieringJob.cancel().get();
+        List<Row> secondBatch = new ArrayList<>();
+        for (String partition : partitions) {
+            secondBatch.addAll(appendLogRows(tablePath, 100, 3, partition));
+        }
+        assertIcebergContainsExactly(tablePath, isPartitioned, firstBatch);
+
+        List<Row> expected = new ArrayList<>(firstBatch);
+        expected.addAll(secondBatch);
+        FlussSource<RowData> source = buildSource(tableName);
+        List<Row> actual =
+                collect(source, expected.size(), isPartitioned ? 
FULL_PARTITIONED_COLS : FULL_COLS);
+
+        assertRowsIgnoreOrder(actual, expected);
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void testUnionReadPrimaryKeyTable(boolean isPartitioned) throws Exception {
+        JobClient tieringJob = buildTieringJob(execEnv);
+
+        String tableName = "ds_union_pk_" + (isPartitioned ? "partitioned" : 
"non_partitioned");
+        TablePath tablePath = TablePath.of(DEFAULT_DB, tableName);
+        long tableId = createPkTable(tablePath, isPartitioned);
+        List<String> partitions = partitionsOf(tablePath, isPartitioned);
+
+        List<Row> snapshotRows = new ArrayList<>();
+        for (String partition : partitions) {
+            snapshotRows.addAll(upsertRows(tablePath, 1, 3, partition));
+        }
+        waitUntilBucketSynced(tablePath, tableId, DEFAULT_BUCKET_NUM, 
isPartitioned);
+        assertIcebergContainsExactly(tablePath, isPartitioned, snapshotRows);
+
+        // update after tiering stops: lives only in the Fluss changelog, read 
as -U/+U on top of
+        // the Iceberg snapshot read as +I
+        tieringJob.cancel().get();
+        List<Row> changelog = new ArrayList<>();
+        for (String partition : partitions) {
+            changelog.addAll(updateRow(tablePath, 1, partition));
+        }
+        assertIcebergContainsExactly(tablePath, isPartitioned, snapshotRows);
+
+        List<Row> expected = new ArrayList<>(snapshotRows);
+        expected.addAll(changelog);
+        FlussSource<RowData> source = buildSource(tableName);
+        List<Row> actual =
+                collect(source, expected.size(), isPartitioned ? 
FULL_PARTITIONED_COLS : FULL_COLS);
+
+        assertRowsIgnoreOrder(actual, expected);
+    }
+
+    // projection, filtering and boundedness are independent of partitioning, 
which the log/PK tests
+    // above already cover, so these run on a single non-partitioned table
+    @Test
+    void testUnionReadWithProjectionPushdown() throws Exception {
+        JobClient tieringJob = buildTieringJob(execEnv);
+
+        String tableName = "ds_union_projection";
+        TablePath tablePath = TablePath.of(DEFAULT_DB, tableName);
+        long tableId = createLogTable(tablePath, false);
+
+        List<Row> firstBatch = appendLogRows(tablePath, 0, 5, null);
+        waitUntilBucketSynced(tablePath, tableId, DEFAULT_BUCKET_NUM, false);
+        assertIcebergContainsExactly(tablePath, false, firstBatch);
+
+        tieringJob.cancel().get();
+        List<Row> secondBatch = appendLogRows(tablePath, 100, 3, null);
+        assertIcebergContainsExactly(tablePath, false, firstBatch);
+
+        List<Row> written = new ArrayList<>(firstBatch);
+        written.addAll(secondBatch);
+        FlussSource<RowData> source = buildSource(tableName, "id", "amount");
+        List<Row> actual = collect(source, written.size(), 
PROJECTED_ID_AMOUNT_COLS);
+
+        List<Row> expected =
+                written.stream()
+                        .map(r -> Row.ofKind(RowKind.INSERT, r.getField(0), 
r.getField(2)))
+                        .collect(Collectors.toList());
+
+        assertRowsIgnoreOrder(actual, expected);
+    }
+
+    @Test
+    void testUnionReadWithFilterPushdown() throws Exception {
+        JobClient tieringJob = buildTieringJob(execEnv);
+
+        String tableName = "ds_union_filter";
+        TablePath tablePath = TablePath.of(DEFAULT_DB, tableName);
+        long tableId = createLogTable(tablePath, false);
+
+        // amounts 0..40, tiered to the lake snapshot
+        List<Row> firstBatch = appendLogRows(tablePath, 0, 5, null);
+        waitUntilBucketSynced(tablePath, tableId, DEFAULT_BUCKET_NUM, false);
+        assertIcebergContainsExactly(tablePath, false, firstBatch);
+
+        // amounts 1000..1020, written after tiering stops so they live only 
in the Fluss log
+        tieringJob.cancel().get();
+        List<Row> secondBatch = appendLogRows(tablePath, 100, 3, null);
+        assertIcebergContainsExactly(tablePath, false, firstBatch);
+
+        // 'amount >= 100' prunes the lake snapshot file (max amount 40) 
during Iceberg planning and
+        // keeps the Fluss batch (min amount 1000), so the union read must 
return exactly the Fluss
+        // batch. If the filter were not pushed to the lake side, the snapshot 
rows would leak in.
+        RowType rowType =
+                RowType.builder()
+                        .field("id", DataTypes.INT())
+                        .field("name", DataTypes.STRING())
+                        .field("amount", DataTypes.BIGINT())
+                        .build();
+        Predicate filter = new PredicateBuilder(rowType).greaterOrEqual(2, 
100L);
+
+        FlussSource<RowData> source =
+                FlussSource.<RowData>builder()
+                        .setBootstrapServers(bootstrapServers())
+                        .setDatabase(DEFAULT_DB)
+                        .setTable(tableName)
+                        .setStartingOffsets(OffsetsInitializer.full())
+                        .setScanPartitionDiscoveryIntervalMs(100L)
+                        .setFilter(filter)
+                        .setDeserializationSchema(new 
RowDataDeserializationSchema())
+                        .build();
+        List<Row> actual = collect(source, secondBatch.size(), FULL_COLS);
+
+        assertRowsIgnoreOrder(actual, secondBatch);
+    }
+
+    @Test
+    void testBoundedRequiresFullStartupModeFailsFast() throws Exception {
+        String tableName = "ds_bounded_invalid_startup";
+        TablePath tablePath = TablePath.of(DEFAULT_DB, tableName);
+        createLogTable(tablePath, false);
+
+        FlussSourceBuilder<RowData> builder =
+                FlussSource.<RowData>builder()
+                        .setBootstrapServers(bootstrapServers())
+                        .setDatabase(DEFAULT_DB)
+                        .setTable(tableName)
+                        .setStartingOffsets(OffsetsInitializer.earliest())
+                        .setBounded()
+                        .setDeserializationSchema(new 
RowDataDeserializationSchema());
+
+        assertThatThrownBy(builder::build)
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("Bounded (batch) read requires");
+    }
+
+    @Test
+    void testBoundedRequiresDataLakeEnabledFailsFast() throws Exception {
+        String tableName = "ds_bounded_no_lake";
+        TablePath tablePath = TablePath.of(DEFAULT_DB, tableName);
+        createNonLakeLogTable(tablePath);
+
+        FlussSourceBuilder<RowData> builder =
+                FlussSource.<RowData>builder()
+                        .setBootstrapServers(bootstrapServers())
+                        .setDatabase(DEFAULT_DB)
+                        .setTable(tableName)
+                        .setStartingOffsets(OffsetsInitializer.full())
+                        .setBounded()
+                        .setDeserializationSchema(new 
RowDataDeserializationSchema());
+
+        assertThatThrownBy(builder::build)
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("Bounded (batch) read requires");
+    }
+
+    @Test
+    void testBatchUnionReadLogTable() throws Exception {
+        JobClient tieringJob = buildTieringJob(execEnv);
+
+        String tableName = "ds_batch_union_log";
+        TablePath tablePath = TablePath.of(DEFAULT_DB, tableName);
+        long tableId = createLogTable(tablePath, false);
+
+        List<Row> firstBatch = appendLogRows(tablePath, 0, 5, null);
+        waitUntilBucketSynced(tablePath, tableId, DEFAULT_BUCKET_NUM, false);
+        assertIcebergContainsExactly(tablePath, false, firstBatch);
+
+        tieringJob.cancel().get();
+        List<Row> secondBatch = appendLogRows(tablePath, 100, 3, null);
+        assertIcebergContainsExactly(tablePath, false, firstBatch);
+
+        List<Row> expected = new ArrayList<>(firstBatch);
+        expected.addAll(secondBatch);
+
+        StreamExecutionEnvironment batchEnv = 
StreamExecutionEnvironment.getExecutionEnvironment();
+        batchEnv.setRuntimeMode(RuntimeExecutionMode.BATCH);
+        batchEnv.setParallelism(2);
+        FlussSource<RowData> source =
+                FlussSource.<RowData>builder()
+                        .setBootstrapServers(bootstrapServers())
+                        .setDatabase(DEFAULT_DB)
+                        .setTable(tableName)
+                        .setStartingOffsets(OffsetsInitializer.full())
+                        .setBounded()
+                        .setDeserializationSchema(new 
RowDataDeserializationSchema())
+                        .build();
+        List<Row> actual = collectBounded(batchEnv, source, FULL_COLS);
+
+        assertRowsIgnoreOrder(actual, expected);
+    }
+
+    // 
------------------------------------------------------------------------------------------
+    // helpers
+    // 
------------------------------------------------------------------------------------------
+
+    private FlussSource<RowData> buildSource(String tableName, String... 
projectedFields) {
+        FlussSourceBuilder<RowData> builder =
+                FlussSource.<RowData>builder()
+                        .setBootstrapServers(bootstrapServers())
+                        .setDatabase(DEFAULT_DB)
+                        .setTable(tableName)
+                        .setStartingOffsets(OffsetsInitializer.full())
+                        .setScanPartitionDiscoveryIntervalMs(100L)
+                        .setDeserializationSchema(new 
RowDataDeserializationSchema());
+        if (projectedFields.length > 0) {
+            builder.setProjectedFields(projectedFields);
+        }
+        return builder.build();
+    }
+
+    private List<Row> collect(FlussSource<RowData> source, int expectedCount, 
int[] cols)
+            throws Exception {
+        DataStreamSource<RowData> stream =
+                execEnv.fromSource(source, WatermarkStrategy.noWatermarks(), 
"Fluss Union Source");
+        return stream.executeAndCollect(expectedCount).stream()
+                .map(rowData -> toRow(rowData, cols))
+                .collect(Collectors.toList());
+    }
+
+    private List<Row> collectBounded(
+            StreamExecutionEnvironment env, FlussSource<RowData> source, int[] 
cols)
+            throws Exception {
+        DataStreamSource<RowData> stream =
+                env.fromSource(
+                        source, WatermarkStrategy.noWatermarks(), "Fluss Union 
Source (batch)");
+        List<Row> rows = new ArrayList<>();
+        try (CloseableIterator<RowData> iterator = stream.executeAndCollect()) 
{
+            while (iterator.hasNext()) {
+                rows.add(toRow(iterator.next(), cols));
+            }
+        }
+        return rows;
+    }
+
+    private List<String> partitionsOf(TablePath tablePath, boolean 
isPartitioned) {
+        if (!isPartitioned) {
+            return Collections.singletonList(null);
+        }
+        return new ArrayList<>(waitUntilPartitions(tablePath).values());
+    }
+
+    private List<Row> appendLogRows(
+            TablePath tablePath, int startId, int count, @Nullable String 
partition)
+            throws Exception {
+        List<InternalRow> rows = new ArrayList<>();
+        List<Row> expected = new ArrayList<>();
+        for (int id = startId; id < startId + count; id++) {
+            String name = "name_" + id;
+            long amount = id * 10L;
+            rows.add(internalRow(id, name, amount, partition));
+            expected.add(expectedRow(RowKind.INSERT, id, name, amount, 
partition));
+        }
+        writeRows(tablePath, rows, true);
+        return expected;
+    }
+
+    private List<Row> upsertRows(
+            TablePath tablePath, int startId, int count, @Nullable String 
partition)
+            throws Exception {
+        List<InternalRow> rows = new ArrayList<>();
+        List<Row> expected = new ArrayList<>();
+        for (int id = startId; id < startId + count; id++) {
+            String name = "name_" + id;
+            long amount = id * 10L;
+            rows.add(internalRow(id, name, amount, partition));
+            expected.add(expectedRow(RowKind.INSERT, id, name, amount, 
partition));
+        }
+        writeRows(tablePath, rows, false);
+        return expected;
+    }
+
+    private List<Row> updateRow(TablePath tablePath, int id, @Nullable String 
partition)
+            throws Exception {
+        String newName = "updated_" + id;
+        long newAmount = id * 100L;
+        writeRows(
+                tablePath,
+                Collections.singletonList(internalRow(id, newName, newAmount, 
partition)),
+                false);
+
+        List<Row> expected = new ArrayList<>();
+        expected.add(expectedRow(RowKind.UPDATE_BEFORE, id, "name_" + id, id * 
10L, partition));
+        expected.add(expectedRow(RowKind.UPDATE_AFTER, id, newName, newAmount, 
partition));
+        return expected;
+    }
+
+    private static InternalRow internalRow(
+            int id, String name, long amount, @Nullable String partition) {
+        return partition == null ? row(id, name, amount) : row(id, name, 
amount, partition);
+    }
+
+    private static Row expectedRow(
+            RowKind kind, int id, String name, long amount, @Nullable String 
partition) {
+        return partition == null
+                ? Row.ofKind(kind, id, name, amount)
+                : Row.ofKind(kind, id, name, amount, partition);
+    }
+
+    private static Row toRow(RowData rowData, int[] cols) {
+        Object[] fields = new Object[cols.length];
+        for (int i = 0; i < cols.length; i++) {
+            if (rowData.isNullAt(i)) {
+                fields[i] = null;
+                continue;
+            }
+            switch (cols[i]) {
+                case 0:
+                    fields[i] = rowData.getInt(i);
+                    break;
+                case 1:
+                    fields[i] = rowData.getString(i).toString();
+                    break;
+                case 2:
+                    fields[i] = rowData.getLong(i);
+                    break;
+                case 3:
+                    fields[i] = rowData.getString(i).toString();
+                    break;
+                default:
+                    throw new IllegalArgumentException("Unexpected column 
index: " + cols[i]);
+            }
+        }
+        return Row.ofKind(rowData.getRowKind(), fields);
+    }
+
+    private static void assertRowsIgnoreOrder(List<Row> actual, List<Row> 
expected) {
+        
assertThat(actual.stream().map(Row::toString).collect(Collectors.toList()))
+                .containsExactlyInAnyOrderElementsOf(
+                        
expected.stream().map(Row::toString).collect(Collectors.toList()));
+    }
+
+    private void assertIcebergContainsExactly(
+            TablePath tablePath, boolean isPartitioned, List<Row> 
expectedValues) throws Exception {
+        // tiering is asynchronous; poll once per second (a tight loop would 
exhaust file
+        // descriptors) until the expected rows are visible, then assert exact 
contents
+        waitUntil(
+                () -> readIcebergUserRows(tablePath, isPartitioned).size() == 
expectedValues.size(),
+                Duration.ofMinutes(2),
+                Duration.ofSeconds(1),
+                "Iceberg did not contain the expected number of rows for " + 
tablePath);
+        List<Row> icebergValues = readIcebergUserRows(tablePath, 
isPartitioned);
+        
assertThat(icebergValues.stream().map(Row::toString).collect(Collectors.toList()))
+                .containsExactlyInAnyOrderElementsOf(
+                        expectedValues.stream()
+                                .map(r -> Row.of(toArray(r)).toString())
+                                .collect(Collectors.toList()));
+    }
+
+    private List<Row> readIcebergUserRows(TablePath tablePath, boolean 
isPartitioned)
+            throws Exception {
+        // full generic scan reads all partitions and applies PK deletes; 
avoids the shared util's
+        // log-table reader which drops same-offset data files from different 
partitions
+        Table table = icebergCatalog.loadTable(toIceberg(tablePath));
+        List<Row> rows = new ArrayList<>();
+        try (CloseableIterable<Record> records = 
IcebergGenerics.read(table).build()) {
+            for (Record record : records) {
+                int id = ((Number) record.getField("id")).intValue();
+                Object nameField = record.getField("name");
+                String name = nameField == null ? null : nameField.toString();
+                long amount = ((Number) record.getField("amount")).longValue();
+                if (isPartitioned) {
+                    Object partitionField = record.getField("p");
+                    String partition = partitionField == null ? null : 
partitionField.toString();
+                    rows.add(Row.of(id, name, amount, partition));
+                } else {
+                    rows.add(Row.of(id, name, amount));
+                }
+            }
+        }
+        return rows;
+    }
+
+    private static Object[] toArray(Row row) {
+        Object[] fields = new Object[row.getArity()];
+        for (int i = 0; i < fields.length; i++) {
+            fields[i] = row.getField(i);
+        }
+        return fields;
+    }
+
+    private long createLogTable(TablePath tablePath, boolean isPartitioned) 
throws Exception {
+        Schema.Builder schemaBuilder =
+                Schema.newBuilder()
+                        .column("id", DataTypes.INT())
+                        .column("name", DataTypes.STRING())
+                        .column("amount", DataTypes.BIGINT());
+        TableDescriptor.Builder tableBuilder =
+                TableDescriptor.builder()
+                        .property(ConfigOptions.TABLE_DATALAKE_ENABLED.key(), 
"true")
+                        .property(ConfigOptions.TABLE_DATALAKE_FRESHNESS, 
Duration.ofMillis(500));
+        if (isPartitioned) {
+            schemaBuilder.column("p", DataTypes.STRING());
+            tableBuilder
+                    .partitionedBy("p")
+                    .property(ConfigOptions.TABLE_AUTO_PARTITION_ENABLED, true)
+                    .property(
+                            ConfigOptions.TABLE_AUTO_PARTITION_TIME_UNIT,
+                            AutoPartitionTimeUnit.YEAR);
+        }
+        tableBuilder.distributedBy(DEFAULT_BUCKET_NUM, 
"id").schema(schemaBuilder.build());
+        return createTable(tablePath, tableBuilder.build());
+    }
+
+    private long createNonLakeLogTable(TablePath tablePath) throws Exception {
+        Schema schema =
+                Schema.newBuilder()
+                        .column("id", DataTypes.INT())
+                        .column("name", DataTypes.STRING())
+                        .column("amount", DataTypes.BIGINT())
+                        .build();
+        TableDescriptor descriptor =
+                TableDescriptor.builder()
+                        .distributedBy(DEFAULT_BUCKET_NUM, "id")
+                        .schema(schema)
+                        .build();
+        return createTable(tablePath, descriptor);
+    }
+
+    private long createPkTable(TablePath tablePath, boolean isPartitioned) 
throws Exception {
+        Schema.Builder schemaBuilder =
+                Schema.newBuilder()
+                        .column("id", DataTypes.INT())
+                        .column("name", DataTypes.STRING())
+                        .column("amount", DataTypes.BIGINT());
+        TableDescriptor.Builder tableBuilder =
+                TableDescriptor.builder()
+                        .distributedBy(DEFAULT_BUCKET_NUM)
+                        .property(ConfigOptions.TABLE_DATALAKE_ENABLED.key(), 
"true")
+                        .property(ConfigOptions.TABLE_DATALAKE_FRESHNESS, 
Duration.ofMillis(500));
+        if (isPartitioned) {
+            schemaBuilder.column("p", DataTypes.STRING());
+            schemaBuilder.primaryKey("id", "p");
+            tableBuilder
+                    .partitionedBy("p")
+                    .property(ConfigOptions.TABLE_AUTO_PARTITION_ENABLED, true)
+                    .property(
+                            ConfigOptions.TABLE_AUTO_PARTITION_TIME_UNIT,
+                            AutoPartitionTimeUnit.YEAR);
+        } else {
+            schemaBuilder.primaryKey("id");
+        }
+        tableBuilder.schema(schemaBuilder.build());
+        return createTable(tablePath, tableBuilder.build());
+    }
+
+    private static String bootstrapServers() {
+        return String.join(",", 
clientConf.get(ConfigOptions.BOOTSTRAP_SERVERS));
+    }
+}


Reply via email to