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

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 92c22603b2 [flink][cdc] Fix bucket-aware state recovery for 
multi-table CDC sink (#9417)
92c22603b2 is described below

commit 92c22603b217923652a5f6d126bcd19548d78173
Author: Wenchao Wu <[email protected]>
AuthorDate: Fri Aug 28 16:22:48 2026 +0800

    [flink][cdc] Fix bucket-aware state recovery for multi-table CDC sink 
(#9417)
---
 .../cdc/CdcMultiplexRecordChannelComputer.java     | 108 ++++++++---
 .../sink/cdc/CdcRecordStoreMultiWriteOperator.java |  85 +++++++-
 .../flink/sink/cdc/FlinkCdcMultiTableSink.java     |  89 ++++++++-
 .../sink/cdc/FlinkCdcSyncDatabaseSinkBuilder.java  |  10 +-
 .../cdc/CdcMultiplexRecordChannelComputerTest.java |  85 +++++++-
 .../cdc/CdcRecordStoreMultiWriteOperatorTest.java  | 215 +++++++++++++++++++--
 .../flink/sink/cdc/FlinkCdcMultiTableSinkTest.java |  66 ++++++-
 7 files changed, 579 insertions(+), 79 deletions(-)

diff --git 
a/paimon-flink/paimon-flink-cdc/src/main/java/org/apache/paimon/flink/sink/cdc/CdcMultiplexRecordChannelComputer.java
 
b/paimon-flink/paimon-flink-cdc/src/main/java/org/apache/paimon/flink/sink/cdc/CdcMultiplexRecordChannelComputer.java
index 2858b2d4eb..053108f1cf 100644
--- 
a/paimon-flink/paimon-flink-cdc/src/main/java/org/apache/paimon/flink/sink/cdc/CdcMultiplexRecordChannelComputer.java
+++ 
b/paimon-flink/paimon-flink-cdc/src/main/java/org/apache/paimon/flink/sink/cdc/CdcMultiplexRecordChannelComputer.java
@@ -21,13 +21,11 @@ package org.apache.paimon.flink.sink.cdc;
 import org.apache.paimon.catalog.Catalog;
 import org.apache.paimon.catalog.CatalogLoader;
 import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.table.BucketMode;
 import org.apache.paimon.table.FileStoreTable;
 import org.apache.paimon.table.sink.ChannelComputer;
 
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Objects;
@@ -35,10 +33,11 @@ import java.util.Objects;
 /** {@link ChannelComputer} for {@link CdcMultiplexRecord}. */
 public class CdcMultiplexRecordChannelComputer implements 
ChannelComputer<CdcMultiplexRecord> {
 
-    private static final Logger LOG =
-            LoggerFactory.getLogger(CdcMultiplexRecordChannelComputer.class);
-
     private static final long serialVersionUID = 1L;
+
+    private static final int TABLE_LOOKUP_MAX_RETRIES = 10;
+    private static final long TABLE_LOOKUP_RETRY_INTERVAL_MILLIS = 500L;
+
     private final CatalogLoader catalogLoader;
 
     private transient int numChannels;
@@ -58,42 +57,95 @@ public class CdcMultiplexRecordChannelComputer implements 
ChannelComputer<CdcMul
     @Override
     public int channel(CdcMultiplexRecord multiplexRecord) {
         ChannelComputer<CdcRecord> channelComputer = 
computeChannelComputer(multiplexRecord);
-        int recordChannel =
-                channelComputer != null ? 
channelComputer.channel(multiplexRecord.record()) : 0;
-        return Math.floorMod(
-                Objects.hash(multiplexRecord.databaseName(), 
multiplexRecord.tableName())
-                        + recordChannel,
+        int recordChannel = channelComputer.channel(multiplexRecord.record());
+        return mixTableIntoChannel(
+                multiplexRecord.databaseName(),
+                multiplexRecord.tableName(),
+                recordChannel,
+                numChannels);
+    }
+
+    /**
+     * Computes the channel a given bucket is routed to, without needing a 
record. This mirrors
+     * {@link #channel}, so that {@link CdcRecordStoreMultiWriteOperator} can 
decide which subtask
+     * owns the state of a bucket.
+     */
+    static int computeChannel(
+            String databaseName,
+            String tableName,
+            BinaryRow partition,
+            int bucket,
+            int numChannels) {
+        return mixTableIntoChannel(
+                databaseName,
+                tableName,
+                ChannelComputer.select(partition, bucket, numChannels),
                 numChannels);
     }
 
+    /** Offsets the per-table channel by the table identity, so that tables 
are spread out. */
+    private static int mixTableIntoChannel(
+            String databaseName, String tableName, int recordChannel, int 
numChannels) {
+        return Math.floorMod(Objects.hash(databaseName, tableName) + 
recordChannel, numChannels);
+    }
+
     private ChannelComputer<CdcRecord> 
computeChannelComputer(CdcMultiplexRecord record) {
         return channelComputers.computeIfAbsent(
                 Identifier.create(record.databaseName(), record.tableName()),
                 id -> {
-                    FileStoreTable table;
                     try (Catalog catalog = catalogLoader.load()) {
-                        table = (FileStoreTable) catalog.getTable(id);
-                    } catch (Catalog.TableNotExistException e) {
-                        LOG.error("Failed to get table {}", id.getFullName(), 
e);
-                        return null;
+                        FileStoreTable table = getTable(catalog, id);
+                        if (table.bucketMode() != BucketMode.HASH_FIXED) {
+                            throw new UnsupportedOperationException(
+                                    String.format(
+                                            "Combine mode Sink only supports 
FIXED bucket mode, but %s is %s",
+                                            table.name(), table.bucketMode()));
+                        }
+
+                        CdcRecordChannelComputer channelComputer =
+                                new CdcRecordChannelComputer(table.schema());
+                        channelComputer.setup(numChannels);
+                        return channelComputer;
+                    } catch (RuntimeException e) {
+                        throw e;
                     } catch (Exception e) {
                         throw new RuntimeException(e);
                     }
-
-                    if (table.bucketMode() != BucketMode.HASH_FIXED) {
-                        throw new UnsupportedOperationException(
-                                String.format(
-                                        "Combine mode Sink only supports FIXED 
bucket mode, but %s is %s",
-                                        table.name(), table.bucketMode()));
-                    }
-
-                    CdcRecordChannelComputer channelComputer =
-                            new CdcRecordChannelComputer(table.schema());
-                    channelComputer.setup(numChannels);
-                    return channelComputer;
                 });
     }
 
+    private FileStoreTable getTable(Catalog catalog, Identifier tableId) {
+        Catalog.TableNotExistException lastException = null;
+        for (int retry = 0; retry <= TABLE_LOOKUP_MAX_RETRIES; retry++) {
+            try {
+                return (FileStoreTable) catalog.getTable(tableId);
+            } catch (Catalog.TableNotExistException e) {
+                lastException = e;
+                // Records of a newly added table can arrive before the table 
is visible here. Do
+                // not use a temporary channel: the writer-state restore 
filter must calculate the
+                // exact same owner from the real partition and bucket.
+                if (retry == TABLE_LOOKUP_MAX_RETRIES) {
+                    break;
+                }
+                try {
+                    Thread.sleep(TABLE_LOOKUP_RETRY_INTERVAL_MILLIS);
+                } catch (InterruptedException interrupted) {
+                    Thread.currentThread().interrupt();
+                    throw new RuntimeException(
+                            "Interrupted while waiting for table " + 
tableId.getFullName(),
+                            interrupted);
+                }
+            }
+        }
+        throw new RuntimeException(
+                String.format(
+                        "Table %s is still unavailable after %s retries (%s ms 
total wait).",
+                        tableId.getFullName(),
+                        TABLE_LOOKUP_MAX_RETRIES,
+                        TABLE_LOOKUP_MAX_RETRIES * 
TABLE_LOOKUP_RETRY_INTERVAL_MILLIS),
+                lastException);
+    }
+
     @Override
     public String toString() {
         return "shuffle by bucket";
diff --git 
a/paimon-flink/paimon-flink-cdc/src/main/java/org/apache/paimon/flink/sink/cdc/CdcRecordStoreMultiWriteOperator.java
 
b/paimon-flink/paimon-flink-cdc/src/main/java/org/apache/paimon/flink/sink/cdc/CdcRecordStoreMultiWriteOperator.java
index da612bd337..20dd9847c4 100644
--- 
a/paimon-flink/paimon-flink-cdc/src/main/java/org/apache/paimon/flink/sink/cdc/CdcRecordStoreMultiWriteOperator.java
+++ 
b/paimon-flink/paimon-flink-cdc/src/main/java/org/apache/paimon/flink/sink/cdc/CdcRecordStoreMultiWriteOperator.java
@@ -35,6 +35,7 @@ import org.apache.paimon.options.Options;
 import org.apache.paimon.table.BucketMode;
 import org.apache.paimon.table.FileStoreTable;
 import org.apache.paimon.utils.ExecutorThreadFactory;
+import org.apache.paimon.utils.Preconditions;
 
 import org.apache.flink.runtime.state.StateInitializationContext;
 import org.apache.flink.runtime.state.StateSnapshotContext;
@@ -43,6 +44,8 @@ import 
org.apache.flink.streaming.api.operators.StreamOperatorFactory;
 import org.apache.flink.streaming.api.operators.StreamOperatorParameters;
 import org.apache.flink.streaming.runtime.streamrecord.StreamRecord;
 
+import javax.annotation.Nullable;
+
 import java.io.IOException;
 import java.util.HashMap;
 import java.util.LinkedList;
@@ -62,6 +65,17 @@ import static 
org.apache.paimon.flink.sink.cdc.CdcRecordUtils.toGenericRow;
 /**
  * A {@link PrepareCommitOperator} to write {@link CdcRecord}. Record schema 
may change. If current
  * known schema does not fit record schema, this operator will wait for schema 
changes.
+ *
+ * <p>When {@code stateDatabaseName} is given, this operator assumes its input 
is partitioned by
+ * {@link CdcMultiplexRecordChannelComputer} and that every incoming record 
belongs to that
+ * database. {@link FlinkCdcMultiTableSink} guarantees both by applying the 
partitioner itself. The
+ * {@link StoreSinkWriteState} filter below then distributes state values 
among subtasks with
+ * exactly the same formula as the channel computer, so a record and the state 
of the bucket it
+ * belongs to always end up in the same subtask.
+ *
+ * <p>The compatibility constructor does not have a database name and 
therefore keeps the legacy
+ * behavior of restoring all union state values into every subtask. It does 
not guarantee unique
+ * bucket-state ownership after a restore.
  */
 public class CdcRecordStoreMultiWriteOperator
         extends PrepareCommitOperator<CdcMultiplexRecord, 
MultiTableCommittable> {
@@ -71,6 +85,7 @@ public class CdcRecordStoreMultiWriteOperator
     private final StoreSinkWrite.Provider storeSinkWriteProvider;
     private final String initialCommitUser;
     private final CatalogLoader catalogLoader;
+    @Nullable private final String stateDatabaseName;
 
     private Catalog catalog;
     private Map<Identifier, FileStoreTable> tables;
@@ -84,11 +99,13 @@ public class CdcRecordStoreMultiWriteOperator
             CatalogLoader catalogLoader,
             StoreSinkWrite.Provider storeSinkWriteProvider,
             String initialCommitUser,
+            @Nullable String stateDatabaseName,
             Options options) {
         super(parameters, options);
         this.catalogLoader = catalogLoader;
         this.storeSinkWriteProvider = storeSinkWriteProvider;
         this.initialCommitUser = initialCommitUser;
+        this.stateDatabaseName = stateDatabaseName;
     }
 
     @Override
@@ -104,12 +121,28 @@ public class CdcRecordStoreMultiWriteOperator
                 StateUtils.getSingleValueFromState(
                         context, "commit_user_state", String.class, 
initialCommitUser);
 
-        // TODO: should use CdcRecordMultiChannelComputer to filter
-        state =
-                new StoreSinkWriteStateImpl(
-                        
RuntimeContextUtils.getIndexOfThisSubtask(getRuntimeContext()),
-                        context,
-                        (tableName, partition, bucket) -> true);
+        int numTasks = 
RuntimeContextUtils.getNumberOfParallelSubtasks(getRuntimeContext());
+        int subtaskId = 
RuntimeContextUtils.getIndexOfThisSubtask(getRuntimeContext());
+        StoreSinkWriteState.StateValueFilter stateFilter;
+        if (stateDatabaseName == null) {
+            // Preserve the behavior of the old constructor for compatibility. 
Without a database
+            // name, every subtask restores all union state and bucket 
ownership is not guaranteed.
+            stateFilter = (tableName, partition, bucket) -> true;
+        } else {
+            // Keep this filter in sync with 
CdcMultiplexRecordChannelComputer, which partitions the
+            // input of this operator. Otherwise state values would be 
restored into a subtask which
+            // never writes the corresponding bucket.
+            stateFilter =
+                    (tableName, partition, bucket) ->
+                            subtaskId
+                                    == 
CdcMultiplexRecordChannelComputer.computeChannel(
+                                            stateDatabaseName,
+                                            tableName,
+                                            partition,
+                                            bucket,
+                                            numTasks);
+        }
+        state = new StoreSinkWriteStateImpl(subtaskId, context, stateFilter);
         tables = new HashMap<>();
         writes = new HashMap<>();
         compactExecutor =
@@ -123,6 +156,11 @@ public class CdcRecordStoreMultiWriteOperator
         CdcMultiplexRecord record = element.getValue();
 
         String databaseName = record.databaseName();
+        Preconditions.checkArgument(
+                stateDatabaseName == null || 
stateDatabaseName.equals(databaseName),
+                "This writer only accepts records from database %s, but 
received a record from %s.",
+                stateDatabaseName,
+                databaseName);
         String tableName = record.tableName();
         Identifier tableId = Identifier.create(databaseName, tableName);
 
@@ -226,8 +264,12 @@ public class CdcRecordStoreMultiWriteOperator
     @Override
     public void close() throws Exception {
         super.close();
-        for (StoreSinkWrite write : writes.values()) {
-            write.close();
+        // initializeState may have failed before these were assigned, and 
Flink still closes the
+        // operator. Do not mask the original failure with a 
NullPointerException.
+        if (writes != null) {
+            for (StoreSinkWrite write : writes.values()) {
+                write.close();
+            }
         }
         if (compactExecutor != null) {
             compactExecutor.shutdownNow();
@@ -275,22 +317,48 @@ public class CdcRecordStoreMultiWriteOperator
         return commitUser;
     }
 
+    @VisibleForTesting
+    public StoreSinkWriteState state() {
+        return state;
+    }
+
     /** {@link StreamOperatorFactory} of {@link 
CdcRecordStoreMultiWriteOperator}. */
     public static class Factory
             extends PrepareCommitOperator.Factory<CdcMultiplexRecord, 
MultiTableCommittable> {
         private final StoreSinkWrite.Provider storeSinkWriteProvider;
         private final String initialCommitUser;
         private final CatalogLoader catalogLoader;
+        @Nullable private final String stateDatabaseName;
+
+        /**
+         * @deprecated Use {@link #Factory(CatalogLoader, 
StoreSinkWrite.Provider, String, String,
+         *     Options)} instead. Without a database name, every subtask 
restores all union writer
+         *     state and unique bucket-state ownership is not guaranteed.
+         */
+        @Deprecated
+        public Factory(
+                CatalogLoader catalogLoader,
+                StoreSinkWrite.Provider storeSinkWriteProvider,
+                String initialCommitUser,
+                Options options) {
+            super(options);
+            this.catalogLoader = catalogLoader;
+            this.storeSinkWriteProvider = storeSinkWriteProvider;
+            this.initialCommitUser = initialCommitUser;
+            this.stateDatabaseName = null;
+        }
 
         public Factory(
                 CatalogLoader catalogLoader,
                 StoreSinkWrite.Provider storeSinkWriteProvider,
                 String initialCommitUser,
+                String stateDatabaseName,
                 Options options) {
             super(options);
             this.catalogLoader = catalogLoader;
             this.storeSinkWriteProvider = storeSinkWriteProvider;
             this.initialCommitUser = initialCommitUser;
+            this.stateDatabaseName = 
Preconditions.checkNotNull(stateDatabaseName);
         }
 
         @Override
@@ -303,6 +371,7 @@ public class CdcRecordStoreMultiWriteOperator
                             catalogLoader,
                             storeSinkWriteProvider,
                             initialCommitUser,
+                            stateDatabaseName,
                             options);
         }
 
diff --git 
a/paimon-flink/paimon-flink-cdc/src/main/java/org/apache/paimon/flink/sink/cdc/FlinkCdcMultiTableSink.java
 
b/paimon-flink/paimon-flink-cdc/src/main/java/org/apache/paimon/flink/sink/cdc/FlinkCdcMultiTableSink.java
index cdf619725d..b560920a91 100644
--- 
a/paimon-flink/paimon-flink-cdc/src/main/java/org/apache/paimon/flink/sink/cdc/FlinkCdcMultiTableSink.java
+++ 
b/paimon-flink/paimon-flink-cdc/src/main/java/org/apache/paimon/flink/sink/cdc/FlinkCdcMultiTableSink.java
@@ -36,6 +36,7 @@ import 
org.apache.paimon.flink.sink.WrappedManifestCommittableSerializer;
 import org.apache.paimon.manifest.WrappedManifestCommittable;
 import org.apache.paimon.options.MemorySize;
 import org.apache.paimon.options.Options;
+import org.apache.paimon.utils.Preconditions;
 
 import org.apache.flink.streaming.api.datastream.DataStream;
 import org.apache.flink.streaming.api.datastream.DataStreamSink;
@@ -55,6 +56,11 @@ import static 
org.apache.paimon.flink.utils.ParallelismUtils.forwardParallelism;
 
 /**
  * A {@link FlinkSink} which accepts {@link CdcRecord} and waits for a schema 
change if necessary.
+ *
+ * <p>When created with a database name, this sink partitions its input with 
{@link
+ * CdcMultiplexRecordChannelComputer} itself, so that records and the writer 
states of their buckets
+ * are routed to the same subtask. The compatibility constructor preserves the 
legacy behavior and
+ * expects its input to have already been partitioned.
  */
 public class FlinkCdcMultiTableSink implements Serializable {
 
@@ -69,9 +75,17 @@ public class FlinkCdcMultiTableSink implements Serializable {
     private final double commitCpuCores;
     @Nullable private final MemorySize commitHeapMemory;
     private final String commitUser;
+    @Nullable private final String databaseName;
     private boolean eagerInit = false;
     private TableFilter tableFilter;
 
+    /**
+     * @deprecated Use {@link #FlinkCdcMultiTableSink(CatalogLoader, String, 
double, MemorySize,
+     *     double, MemorySize, String, boolean, TableFilter)} instead. Without 
a database name,
+     *     every subtask restores all union writer state, unique bucket-state 
ownership is not
+     *     guaranteed after a restore, and the input must be partitioned by 
the caller.
+     */
+    @Deprecated
     public FlinkCdcMultiTableSink(
             CatalogLoader catalogLoader,
             double writeCpuCores,
@@ -82,6 +96,28 @@ public class FlinkCdcMultiTableSink implements Serializable {
             boolean eagerInit,
             TableFilter tableFilter) {
         this.catalogLoader = catalogLoader;
+        this.databaseName = null;
+        this.writeCpuCores = writeCpuCores;
+        this.writeHeapMemory = writeHeapMemory;
+        this.commitCpuCores = commitCpuCores;
+        this.commitHeapMemory = commitHeapMemory;
+        this.commitUser = commitUser;
+        this.eagerInit = eagerInit;
+        this.tableFilter = tableFilter;
+    }
+
+    public FlinkCdcMultiTableSink(
+            CatalogLoader catalogLoader,
+            String databaseName,
+            double writeCpuCores,
+            @Nullable MemorySize writeHeapMemory,
+            double commitCpuCores,
+            @Nullable MemorySize commitHeapMemory,
+            String commitUser,
+            boolean eagerInit,
+            TableFilter tableFilter) {
+        this.catalogLoader = catalogLoader;
+        this.databaseName = Preconditions.checkNotNull(databaseName);
         this.writeCpuCores = writeCpuCores;
         this.writeHeapMemory = writeHeapMemory;
         this.commitCpuCores = commitCpuCores;
@@ -107,25 +143,58 @@ public class FlinkCdcMultiTableSink implements 
Serializable {
     }
 
     public DataStreamSink<?> sinkFrom(DataStream<CdcMultiplexRecord> input) {
+        return sinkFrom(input, null);
+    }
+
+    /**
+     * @param parallelism parallelism of the writer and committer operators, 
or null to forward the
+     *     parallelism of {@code input}.
+     */
+    public DataStreamSink<?> sinkFrom(
+            DataStream<CdcMultiplexRecord> input, @Nullable Integer 
parallelism) {
         // This commitUser is valid only for new jobs.
         // After the job starts, this commitUser will be recorded into the 
states of write and
         // commit operators.
         // When the job restarts, commitUser will be recovered from states and 
this value is
         // ignored.
-        return sinkFrom(input, commitUser, createWriteProvider());
+        return sinkFrom(input, parallelism, commitUser, createWriteProvider());
+    }
+
+    public DataStreamSink<?> sinkFrom(
+            DataStream<CdcMultiplexRecord> input,
+            String commitUser,
+            StoreSinkWrite.Provider sinkProvider) {
+        return sinkFrom(input, null, commitUser, sinkProvider);
     }
 
     public DataStreamSink<?> sinkFrom(
             DataStream<CdcMultiplexRecord> input,
+            @Nullable Integer parallelism,
             String commitUser,
             StoreSinkWrite.Provider sinkProvider) {
         StreamExecutionEnvironment env = input.getExecutionEnvironment();
         assertStreamingConfiguration(env);
+        Preconditions.checkArgument(
+                databaseName != null || parallelism == null,
+                "Explicit parallelism is only supported by the constructor 
which takes a "
+                        + "database name.");
+
+        // Keep the old constructor's topology unchanged for compatibility. 
The database-aware
+        // constructor can shuffle by bucket itself and use the same formula 
to redistribute writer
+        // state on restore.
+        DataStream<CdcMultiplexRecord> shuffled =
+                databaseName == null
+                        ? input
+                        : FlinkStreamPartitioner.partition(
+                                input,
+                                new 
CdcMultiplexRecordChannelComputer(catalogLoader),
+                                parallelism);
+
         MultiTableCommittableTypeInfo typeInfo = new 
MultiTableCommittableTypeInfo();
         SingleOutputStreamOperator<MultiTableCommittable> written =
-                input.transform(
+                shuffled.transform(
                         WRITER_NAME, typeInfo, 
createWriteOperator(sinkProvider, commitUser));
-        forwardParallelism(written, input);
+        forwardParallelism(written, shuffled);
         configureSlotSharingGroup(written, writeCpuCores, writeHeapMemory);
 
         // shuffle committables by table
@@ -133,7 +202,7 @@ public class FlinkCdcMultiTableSink implements Serializable 
{
                 FlinkStreamPartitioner.partition(
                         written,
                         new MultiTableCommittableChannelComputer(),
-                        input.getParallelism());
+                        shuffled.getParallelism());
 
         SingleOutputStreamOperator<?> committed =
                 partitioned.transform(
@@ -145,13 +214,23 @@ public class FlinkCdcMultiTableSink implements 
Serializable {
                                 commitUser,
                                 createCommitterFactory(tableFilter),
                                 createCommittableStateManager()));
-        forwardParallelism(committed, input);
+        forwardParallelism(committed, shuffled);
         configureSlotSharingGroup(committed, commitCpuCores, commitHeapMemory);
         return committed.sinkTo(new 
DiscardingSink<>()).name("end").setParallelism(1);
     }
 
     protected OneInputStreamOperatorFactory<CdcMultiplexRecord, 
MultiTableCommittable>
             createWriteOperator(StoreSinkWrite.Provider writeProvider, String 
commitUser) {
+        return databaseName == null
+                ? createCompatibilityWriteOperator(writeProvider, commitUser)
+                : new CdcRecordStoreMultiWriteOperator.Factory(
+                        catalogLoader, writeProvider, commitUser, 
databaseName, new Options());
+    }
+
+    @SuppressWarnings("deprecation")
+    private OneInputStreamOperatorFactory<CdcMultiplexRecord, 
MultiTableCommittable>
+            createCompatibilityWriteOperator(
+                    StoreSinkWrite.Provider writeProvider, String commitUser) {
         return new CdcRecordStoreMultiWriteOperator.Factory(
                 catalogLoader, writeProvider, commitUser, new Options());
     }
diff --git 
a/paimon-flink/paimon-flink-cdc/src/main/java/org/apache/paimon/flink/sink/cdc/FlinkCdcSyncDatabaseSinkBuilder.java
 
b/paimon-flink/paimon-flink-cdc/src/main/java/org/apache/paimon/flink/sink/cdc/FlinkCdcSyncDatabaseSinkBuilder.java
index f5cd33f019..592d7aee14 100644
--- 
a/paimon-flink/paimon-flink-cdc/src/main/java/org/apache/paimon/flink/sink/cdc/FlinkCdcSyncDatabaseSinkBuilder.java
+++ 
b/paimon-flink/paimon-flink-cdc/src/main/java/org/apache/paimon/flink/sink/cdc/FlinkCdcSyncDatabaseSinkBuilder.java
@@ -185,15 +185,10 @@ public class FlinkCdcSyncDatabaseSinkBuilder<T> {
         DataStream<CdcMultiplexRecord> converted =
                 CaseSensitiveUtils.cdcMultiplexRecordConvert(catalogLoader, 
newlyAddedTableStream);
 
-        DataStream<CdcMultiplexRecord> partitioned =
-                partition(
-                        converted,
-                        new CdcMultiplexRecordChannelComputer(catalogLoader),
-                        parallelism);
-
         FlinkCdcMultiTableSink sink =
                 new FlinkCdcMultiTableSink(
                         catalogLoader,
+                        database,
                         writerCpu,
                         writerMemory,
                         committerCpu,
@@ -201,7 +196,8 @@ public class FlinkCdcSyncDatabaseSinkBuilder<T> {
                         commitUser,
                         eagerInit,
                         tableFilter);
-        sink.sinkFrom(partitioned);
+        // the sink shuffles by bucket itself
+        sink.sinkFrom(converted, parallelism);
     }
 
     private void buildForFixedBucket(FileStoreTable table, 
DataStream<CdcRecord> parsed) {
diff --git 
a/paimon-flink/paimon-flink-cdc/src/test/java/org/apache/paimon/flink/sink/cdc/CdcMultiplexRecordChannelComputerTest.java
 
b/paimon-flink/paimon-flink-cdc/src/test/java/org/apache/paimon/flink/sink/cdc/CdcMultiplexRecordChannelComputerTest.java
index 43b7d2ba63..5ad0ef9357 100644
--- 
a/paimon-flink/paimon-flink-cdc/src/test/java/org/apache/paimon/flink/sink/cdc/CdcMultiplexRecordChannelComputerTest.java
+++ 
b/paimon-flink/paimon-flink-cdc/src/test/java/org/apache/paimon/flink/sink/cdc/CdcMultiplexRecordChannelComputerTest.java
@@ -28,6 +28,7 @@ import org.apache.paimon.fs.Path;
 import org.apache.paimon.options.CatalogOptions;
 import org.apache.paimon.options.Options;
 import org.apache.paimon.schema.Schema;
+import org.apache.paimon.table.FileStoreTable;
 import org.apache.paimon.types.DataType;
 import org.apache.paimon.types.DataTypes;
 import org.apache.paimon.types.RowKind;
@@ -47,7 +48,9 @@ import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.TimeUnit;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -59,6 +62,7 @@ public class CdcMultiplexRecordChannelComputerTest {
     private Path warehouse;
     private String databaseName;
     private Identifier tableWithPartition;
+    private Schema tableWithPartitionSchema;
     private Catalog catalog;
     private Identifier tableWithoutPartition;
 
@@ -92,16 +96,16 @@ public class CdcMultiplexRecordChannelComputerTest {
                         },
                         new String[] {"k", "v"});
 
+        tableWithPartitionSchema =
+                new Schema(
+                        rowTypeWithPartition.getFields(),
+                        Collections.singletonList("pt"),
+                        Arrays.asList("pt", "k"),
+                        conf.toMap(),
+                        "");
         List<Tuple2<Identifier, Schema>> tables =
                 Arrays.asList(
-                        Tuple2.of(
-                                tableWithPartition,
-                                new Schema(
-                                        rowTypeWithPartition.getFields(),
-                                        Collections.singletonList("pt"),
-                                        Arrays.asList("pt", "k"),
-                                        conf.toMap(),
-                                        "")),
+                        Tuple2.of(tableWithPartition, 
tableWithPartitionSchema),
                         Tuple2.of(
                                 tableWithoutPartition,
                                 new Schema(
@@ -140,7 +144,7 @@ public class CdcMultiplexRecordChannelComputerTest {
     }
 
     @Test
-    public void testSchemaNoPartition() {
+    public void testSchemaNoPartition() throws Exception {
         ThreadLocalRandom random = ThreadLocalRandom.current();
         int numInputs = random.nextInt(1000) + 1;
         List<Map<String, String>> input = new ArrayList<>();
@@ -154,13 +158,59 @@ public class CdcMultiplexRecordChannelComputerTest {
         testImpl(tableWithoutPartition, input);
     }
 
-    private void testImpl(Identifier tableId, List<Map<String, String>> input) 
{
+    @Test
+    public void testWaitForTableBeforeComputingChannel() throws Exception {
+        int numChannels = 3;
+        Map<String, String> data = new HashMap<>();
+        data.put("pt", "1");
+        data.put("k", "2");
+        data.put("v", "3");
+        CdcRecord record = new CdcRecord(RowKind.INSERT, data);
+
+        FileStoreTable table = (FileStoreTable) 
catalog.getTable(tableWithPartition);
+        CdcRecordKeyAndBucketExtractor extractor =
+                new CdcRecordKeyAndBucketExtractor(table.schema());
+        extractor.setRecord(record);
+        int expectedChannel =
+                CdcMultiplexRecordChannelComputer.computeChannel(
+                        databaseName,
+                        tableWithPartition.getObjectName(),
+                        extractor.partition(),
+                        extractor.bucket(),
+                        numChannels);
+
+        catalog.dropTable(tableWithPartition, false);
+        CdcMultiplexRecordChannelComputer channelComputer =
+                new CdcMultiplexRecordChannelComputer(catalogLoader);
+        channelComputer.setup(numChannels);
+        CompletableFuture<Integer> channelFuture =
+                CompletableFuture.supplyAsync(
+                        () ->
+                                channelComputer.channel(
+                                        CdcMultiplexRecord.fromCdcRecord(
+                                                databaseName,
+                                                
tableWithPartition.getObjectName(),
+                                                record)));
+
+        try {
+            Thread.sleep(50);
+            assertThat(channelFuture.isDone()).isFalse();
+        } finally {
+            catalog.createTable(tableWithPartition, tableWithPartitionSchema, 
false);
+        }
+        assertThat(channelFuture.get(5, 
TimeUnit.SECONDS)).isEqualTo(expectedChannel);
+    }
+
+    private void testImpl(Identifier tableId, List<Map<String, String>> input) 
throws Exception {
         ThreadLocalRandom random = ThreadLocalRandom.current();
 
         int numChannels = random.nextInt(10) + 1;
         CdcMultiplexRecordChannelComputer channelComputer =
                 new CdcMultiplexRecordChannelComputer(catalogLoader);
         channelComputer.setup(numChannels);
+        FileStoreTable table = (FileStoreTable) catalog.getTable(tableId);
+        CdcRecordKeyAndBucketExtractor extractor =
+                new CdcRecordKeyAndBucketExtractor(table.schema());
 
         // assert that insert and delete records are routed into same channel
 
@@ -168,6 +218,21 @@ public class CdcMultiplexRecordChannelComputerTest {
             CdcRecord insertRecord = new CdcRecord(RowKind.INSERT, data);
             CdcRecord deleteRecord = new CdcRecord(RowKind.DELETE, data);
 
+            extractor.setRecord(insertRecord);
+            assertThat(
+                            channelComputer.channel(
+                                    CdcMultiplexRecord.fromCdcRecord(
+                                            tableId.getDatabaseName(),
+                                            tableId.getObjectName(),
+                                            insertRecord)))
+                    .isEqualTo(
+                            CdcMultiplexRecordChannelComputer.computeChannel(
+                                    tableId.getDatabaseName(),
+                                    tableId.getObjectName(),
+                                    extractor.partition(),
+                                    extractor.bucket(),
+                                    numChannels));
+
             assertThat(
                             channelComputer.channel(
                                     CdcMultiplexRecord.fromCdcRecord(
diff --git 
a/paimon-flink/paimon-flink-cdc/src/test/java/org/apache/paimon/flink/sink/cdc/CdcRecordStoreMultiWriteOperatorTest.java
 
b/paimon-flink/paimon-flink-cdc/src/test/java/org/apache/paimon/flink/sink/cdc/CdcRecordStoreMultiWriteOperatorTest.java
index ee64162ad9..6d0a0d7c8a 100644
--- 
a/paimon-flink/paimon-flink-cdc/src/test/java/org/apache/paimon/flink/sink/cdc/CdcRecordStoreMultiWriteOperatorTest.java
+++ 
b/paimon-flink/paimon-flink-cdc/src/test/java/org/apache/paimon/flink/sink/cdc/CdcRecordStoreMultiWriteOperatorTest.java
@@ -24,10 +24,12 @@ import org.apache.paimon.catalog.CatalogContext;
 import org.apache.paimon.catalog.CatalogFactory;
 import org.apache.paimon.catalog.CatalogLoader;
 import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.flink.sink.MultiTableCommittable;
 import org.apache.paimon.flink.sink.MultiTableCommittableTypeInfo;
 import org.apache.paimon.flink.sink.StoreSinkWrite;
 import org.apache.paimon.flink.sink.StoreSinkWriteImpl;
+import org.apache.paimon.flink.sink.StoreSinkWriteState;
 import org.apache.paimon.fs.Path;
 import org.apache.paimon.operation.AbstractFileStoreWrite;
 import org.apache.paimon.options.CatalogOptions;
@@ -48,6 +50,7 @@ import org.apache.flink.api.common.typeutils.TypeSerializer;
 import org.apache.flink.api.java.tuple.Tuple2;
 import org.apache.flink.runtime.checkpoint.OperatorSubtaskState;
 import org.apache.flink.runtime.state.JavaSerializer;
+import org.apache.flink.streaming.util.AbstractStreamOperatorTestHarness;
 import org.apache.flink.streaming.util.OneInputStreamOperatorTestHarness;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
@@ -71,10 +74,13 @@ import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.function.Predicate;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 /** Tests for {@link CdcRecordStoreMultiWriteOperator}. */
 public class CdcRecordStoreMultiWriteOperatorTest {
 
+    private static final String STATE_NAME = "paimon_test_state";
+
     @TempDir java.nio.file.Path tempDir;
 
     private String commitUser;
@@ -689,33 +695,202 @@ public class CdcRecordStoreMultiWriteOperatorTest {
         harness.close();
     }
 
+    @Test
+    @Timeout(30)
+    public void testWriterStateIsPartitionedOnRestore() throws Exception {
+        // Write a state value for every bucket of both tables, from a single 
subtask.
+        int numBuckets = 4;
+        List<StoreSinkWriteState.StateValue> stateValues = new ArrayList<>();
+        for (int bucket = 0; bucket < numBuckets; bucket++) {
+            stateValues.add(
+                    new StoreSinkWriteState.StateValue(
+                            BinaryRow.EMPTY_ROW, bucket, new byte[] {(byte) 
bucket}));
+        }
+
+        OneInputStreamOperatorTestHarness<CdcMultiplexRecord, 
MultiTableCommittable> harness =
+                createTestHarness(catalogLoader, 1, 0);
+        harness.open();
+        CdcRecordStoreMultiWriteOperator operator =
+                (CdcRecordStoreMultiWriteOperator) harness.getOperator();
+        operator.state().put(firstTable.getObjectName(), STATE_NAME, 
stateValues);
+        operator.state().put(secondTable.getObjectName(), STATE_NAME, 
stateValues);
+        OperatorSubtaskState snapshot = harness.snapshot(0, 1);
+        harness.close();
+
+        // Restore with a scaled up parallelism. Every state value must be 
restored into exactly
+        // one subtask, and that subtask must be the one the channel computer 
routes the
+        // corresponding bucket to -- otherwise the subtask owning the bucket 
would silently lose
+        // its state.
+        int numTasks = 3;
+        for (String tableName :
+                Arrays.asList(firstTable.getObjectName(), 
secondTable.getObjectName())) {
+            for (int bucket = 0; bucket < numBuckets; bucket++) {
+                int currentBucket = bucket;
+                List<Integer> owners = new ArrayList<>();
+                for (int subtaskId = 0; subtaskId < numTasks; subtaskId++) {
+                    OneInputStreamOperatorTestHarness<CdcMultiplexRecord, 
MultiTableCommittable>
+                            restored = createTestHarness(catalogLoader, 
numTasks, subtaskId);
+                    restored.initializeState(snapshot);
+                    restored.open();
+                    List<StoreSinkWriteState.StateValue> restoredValues =
+                            ((CdcRecordStoreMultiWriteOperator) 
restored.getOperator())
+                                    .state()
+                                    .get(tableName, STATE_NAME);
+                    if (restoredValues != null
+                            && restoredValues.stream().anyMatch(v -> 
v.bucket() == currentBucket)) {
+                        owners.add(subtaskId);
+                    }
+                    restored.close();
+                }
+
+                int expected =
+                        CdcMultiplexRecordChannelComputer.computeChannel(
+                                databaseName, tableName, BinaryRow.EMPTY_ROW, 
bucket, numTasks);
+                assertThat(owners)
+                        .as(
+                                "state of %s bucket %s must be owned by 
exactly one subtask",
+                                tableName, bucket)
+                        .containsExactly(expected);
+            }
+        }
+    }
+
+    @Test
+    public void testRejectRecordFromDifferentDatabase() throws Exception {
+        OneInputStreamOperatorTestHarness<CdcMultiplexRecord, 
MultiTableCommittable> harness =
+                createTestHarness(catalogLoader);
+        harness.open();
+
+        Map<String, String> data = new HashMap<>();
+        data.put("pt", "0");
+        data.put("k", "1");
+        data.put("v", "10");
+        CdcMultiplexRecord record =
+                CdcMultiplexRecord.fromCdcRecord(
+                        "another_database",
+                        firstTable.getObjectName(),
+                        new CdcRecord(RowKind.INSERT, data));
+
+        assertThatThrownBy(() -> harness.processElement(record, 1))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("only accepts records from database " + 
databaseName)
+                .hasMessageContaining("another_database");
+        harness.close();
+    }
+
+    @Test
+    @Timeout(30)
+    public void testRestoreWithoutDatabaseName() throws Exception {
+        // Preserve the exact legacy union-state behavior: every restored 
subtask receives every
+        // state value. This is compatible with old savepoints, but does not 
provide unique bucket
+        // ownership for stateful writers.
+        int numTasks = 2;
+        List<OperatorSubtaskState> snapshots = new ArrayList<>();
+        for (int subtaskId = 0; subtaskId < numTasks; subtaskId++) {
+            OneInputStreamOperatorTestHarness<CdcMultiplexRecord, 
MultiTableCommittable> harness =
+                    createTestHarness(
+                            createOperatorFactoryWithoutDatabaseName(), 
numTasks, subtaskId);
+            harness.open();
+            CdcRecordStoreMultiWriteOperator operator =
+                    (CdcRecordStoreMultiWriteOperator) harness.getOperator();
+            StoreSinkWriteState.StateValue stateValue =
+                    new StoreSinkWriteState.StateValue(
+                            BinaryRow.EMPTY_ROW, subtaskId, new byte[] {(byte) 
subtaskId});
+            operator.state()
+                    .put(
+                            firstTable.getObjectName(),
+                            STATE_NAME,
+                            Collections.singletonList(stateValue));
+            snapshots.add(harness.snapshot(0, 1));
+            harness.close();
+        }
+
+        OperatorSubtaskState unionState =
+                AbstractStreamOperatorTestHarness.repackageState(
+                        snapshots.toArray(new OperatorSubtaskState[0]));
+        for (int subtaskId = 0; subtaskId < numTasks; subtaskId++) {
+            OneInputStreamOperatorTestHarness<CdcMultiplexRecord, 
MultiTableCommittable> restored =
+                    createTestHarness(
+                            createOperatorFactoryWithoutDatabaseName(), 
numTasks, subtaskId);
+            restored.initializeState(unionState);
+            restored.open();
+            List<StoreSinkWriteState.StateValue> restoredValues =
+                    ((CdcRecordStoreMultiWriteOperator) restored.getOperator())
+                            .state()
+                            .get(firstTable.getObjectName(), STATE_NAME);
+            assertThat(restoredValues)
+                    .extracting(StoreSinkWriteState.StateValue::bucket)
+                    .containsExactlyInAnyOrder(0, 1);
+            restored.close();
+        }
+    }
+
     private OneInputStreamOperatorTestHarness<CdcMultiplexRecord, 
MultiTableCommittable>
             createTestHarness(CatalogLoader catalogLoader) throws Exception {
-        CdcRecordStoreMultiWriteOperator.Factory operatorFactory =
-                new CdcRecordStoreMultiWriteOperator.Factory(
-                        catalogLoader,
-                        (t, commitUser, state, ioManager, memoryPoolFactory, 
metricGroup) ->
-                                new StoreSinkWriteImpl(
-                                        t,
-                                        commitUser,
-                                        state,
-                                        ioManager,
-                                        false,
-                                        false,
-                                        true,
-                                        memoryPoolFactory,
-                                        metricGroup),
-                        commitUser,
-                        Options.fromMap(new HashMap<>()));
         TypeSerializer<CdcMultiplexRecord> inputSerializer = new 
JavaSerializer<>();
-        TypeSerializer<MultiTableCommittable> outputSerializer =
-                new MultiTableCommittableTypeInfo().createSerializer(new 
ExecutionConfig());
         OneInputStreamOperatorTestHarness<CdcMultiplexRecord, 
MultiTableCommittable> harness =
-                new OneInputStreamOperatorTestHarness<>(operatorFactory, 
inputSerializer);
-        harness.setup(outputSerializer);
+                new OneInputStreamOperatorTestHarness<>(
+                        createOperatorFactory(catalogLoader), inputSerializer);
+        harness.setup(outputSerializer());
         return harness;
     }
 
+    private OneInputStreamOperatorTestHarness<CdcMultiplexRecord, 
MultiTableCommittable>
+            createTestHarness(CatalogLoader catalogLoader, int numTasks, int 
subtaskId)
+                    throws Exception {
+        return createTestHarness(createOperatorFactory(catalogLoader), 
numTasks, subtaskId);
+    }
+
+    private OneInputStreamOperatorTestHarness<CdcMultiplexRecord, 
MultiTableCommittable>
+            createTestHarness(
+                    CdcRecordStoreMultiWriteOperator.Factory operatorFactory,
+                    int numTasks,
+                    int subtaskId)
+                    throws Exception {
+        OneInputStreamOperatorTestHarness<CdcMultiplexRecord, 
MultiTableCommittable> harness =
+                new OneInputStreamOperatorTestHarness<>(
+                        operatorFactory, numTasks, numTasks, subtaskId);
+        harness.setup(outputSerializer());
+        return harness;
+    }
+
+    private TypeSerializer<MultiTableCommittable> outputSerializer() {
+        return new MultiTableCommittableTypeInfo().createSerializer(new 
ExecutionConfig());
+    }
+
+    private CdcRecordStoreMultiWriteOperator.Factory createOperatorFactory(
+            CatalogLoader catalogLoader) {
+        return new CdcRecordStoreMultiWriteOperator.Factory(
+                catalogLoader,
+                storeSinkWriteProvider(),
+                commitUser,
+                databaseName,
+                Options.fromMap(new HashMap<>()));
+    }
+
+    @SuppressWarnings("deprecation")
+    private CdcRecordStoreMultiWriteOperator.Factory 
createOperatorFactoryWithoutDatabaseName() {
+        return new CdcRecordStoreMultiWriteOperator.Factory(
+                catalogLoader,
+                storeSinkWriteProvider(),
+                commitUser,
+                Options.fromMap(new HashMap<>()));
+    }
+
+    private StoreSinkWrite.Provider storeSinkWriteProvider() {
+        return (t, commitUser, state, ioManager, memoryPoolFactory, 
metricGroup) ->
+                new StoreSinkWriteImpl(
+                        t,
+                        commitUser,
+                        state,
+                        ioManager,
+                        false,
+                        false,
+                        true,
+                        memoryPoolFactory,
+                        metricGroup);
+    }
+
     private static class Runner implements Runnable {
 
         private final OneInputStreamOperatorTestHarness<CdcMultiplexRecord, 
MultiTableCommittable>
diff --git 
a/paimon-flink/paimon-flink-cdc/src/test/java/org/apache/paimon/flink/sink/cdc/FlinkCdcMultiTableSinkTest.java
 
b/paimon-flink/paimon-flink-cdc/src/test/java/org/apache/paimon/flink/sink/cdc/FlinkCdcMultiTableSinkTest.java
index 8ed54fac67..741a0fb9d1 100644
--- 
a/paimon-flink/paimon-flink-cdc/src/test/java/org/apache/paimon/flink/sink/cdc/FlinkCdcMultiTableSinkTest.java
+++ 
b/paimon-flink/paimon-flink-cdc/src/test/java/org/apache/paimon/flink/sink/cdc/FlinkCdcMultiTableSinkTest.java
@@ -20,6 +20,7 @@ package org.apache.paimon.flink.sink.cdc;
 
 import org.apache.paimon.flink.FlinkCatalogFactory;
 import org.apache.paimon.flink.FlinkConnectorOptions;
+import org.apache.paimon.flink.sink.FlinkStreamPartitioner;
 import org.apache.paimon.options.Options;
 
 import org.apache.flink.api.dag.Transformation;
@@ -34,12 +35,13 @@ import java.util.UUID;
 import java.util.concurrent.ThreadLocalRandom;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 /** Test for {@link FlinkCdcMultiTableSink}. */
 public class FlinkCdcMultiTableSinkTest {
 
     @Test
-    public void testTransformationParallelism() {
+    public void testTransformationParallelismAndShuffle() {
         StreamExecutionEnvironment env = 
StreamExecutionEnvironment.getExecutionEnvironment();
         env.setParallelism(8);
         int inputParallelism = ThreadLocalRandom.current().nextInt(8) + 1;
@@ -50,6 +52,7 @@ public class FlinkCdcMultiTableSinkTest {
         FlinkCdcMultiTableSink sink =
                 new FlinkCdcMultiTableSink(
                         () -> FlinkCatalogFactory.createPaimonCatalog(new 
Options()),
+                        "test_db",
                         FlinkConnectorOptions.SINK_WRITER_CPU.defaultValue(),
                         null,
                         
FlinkConnectorOptions.SINK_COMMITTER_CPU.defaultValue(),
@@ -76,5 +79,66 @@ public class FlinkCdcMultiTableSinkTest {
                 (OneInputTransformation<?, ?>) partitioner.getInputs().get(0);
         assertThat(writer.getName()).isEqualTo("CDC MultiplexWriter");
         assertThat(writer.getParallelism()).isEqualTo(inputParallelism);
+
+        // The sink must shuffle its input by bucket itself, otherwise the 
writer states restored by
+        // CdcRecordStoreMultiWriteOperator would land in subtasks which never 
write the
+        // corresponding buckets. Do not drop this shuffle.
+        PartitionTransformation<?> writerInput =
+                (PartitionTransformation<?>) writer.getInputs().get(0);
+        
assertThat(writerInput.getPartitioner()).isInstanceOf(FlinkStreamPartitioner.class);
+        assertThat(writerInput.getPartitioner()).hasToString("shuffle by 
bucket");
+        assertThat(writerInput.getParallelism()).isEqualTo(inputParallelism);
+        
assertThat(writerInput.getInputs().get(0)).isSameAs(input.getTransformation());
+    }
+
+    @Test
+    @SuppressWarnings("deprecation")
+    public void testCompatibilityConstructorPreservesInputTopology() {
+        StreamExecutionEnvironment env = 
StreamExecutionEnvironment.getExecutionEnvironment();
+        DataStreamSource<CdcMultiplexRecord> input =
+                env.fromData(CdcMultiplexRecord.class, new 
CdcMultiplexRecord("", "", null));
+
+        FlinkCdcMultiTableSink sink =
+                new FlinkCdcMultiTableSink(
+                        () -> FlinkCatalogFactory.createPaimonCatalog(new 
Options()),
+                        FlinkConnectorOptions.SINK_WRITER_CPU.defaultValue(),
+                        null,
+                        
FlinkConnectorOptions.SINK_COMMITTER_CPU.defaultValue(),
+                        null,
+                        UUID.randomUUID().toString(),
+                        false,
+                        null);
+        Transformation<?> end = sink.sinkFrom(input).getTransformation();
+        OneInputTransformation<?, ?> committer =
+                (OneInputTransformation<?, ?>) end.getInputs().get(0);
+        PartitionTransformation<?> committablePartitioner =
+                (PartitionTransformation<?>) committer.getInputs().get(0);
+        OneInputTransformation<?, ?> writer =
+                (OneInputTransformation<?, ?>) 
committablePartitioner.getInputs().get(0);
+
+        
assertThat(writer.getInputs().get(0)).isSameAs(input.getTransformation());
+        assertThatThrownBy(() -> sink.sinkFrom(input, 4))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("Explicit parallelism")
+                .hasMessageContaining("database name");
+    }
+
+    @Test
+    public void testDatabaseAwareConstructorRejectsNullDatabase() {
+        assertThatThrownBy(
+                        () ->
+                                new FlinkCdcMultiTableSink(
+                                        () ->
+                                                
FlinkCatalogFactory.createPaimonCatalog(
+                                                        new Options()),
+                                        null,
+                                        
FlinkConnectorOptions.SINK_WRITER_CPU.defaultValue(),
+                                        null,
+                                        
FlinkConnectorOptions.SINK_COMMITTER_CPU.defaultValue(),
+                                        null,
+                                        UUID.randomUUID().toString(),
+                                        false,
+                                        null))
+                .isInstanceOf(NullPointerException.class);
     }
 }

Reply via email to