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

lvyanquan pushed a commit to branch codex/pr-4540
in repository https://gitbox.apache.org/repos/asf/flink-cdc.git

commit 469dda0cb3a7e1f59c4b36464fc117dcfea799b1
Author: lvyanquan <[email protected]>
AuthorDate: Tue Sep 22 22:34:36 2026 +0800

    [FLINK-40647][runtime] Scope schema expansion to streaming mode and harden 
its failure handling
    
    Restrict the new existing-table schema expansion option to streaming
    pipelines and fix several correctness and test-harness issues found while
    validating it end to end.
    
    * Batch mode: revert the BatchSchemaOperator wiring added by this feature,
      so batch pipelines keep the pre-existing sink behavior. The translator now
      warns when the option is set in batch mode, and the docs state that
      expansion is streaming only.
    * Rename the default mode OFF to DISABLED. A bare `OFF` YAML scalar is
      parsed as the boolean `false`, so the option now rejects non-textual
      values instead of coercing them, which also drops the parser workaround.
    * Fail fast when a sink lacks ExistingTableSchemaExpansionSupport instead of
      degrading to the sink's own handling under TRY_EXPAND, which previously
      hid a connector misconfiguration.
    * Split TRY_EXPAND into a probing phase (delegating to the sink when the
      table cannot be expanded safely) and an applying phase (propagating
      failures). Before this change a failure while applying or verifying
      derived DDL fell back to applying the original CreateTableEvent to an
      already existing table, silently dropping the missing columns.
    * Centralize the lazy Paimon catalog creation in getCatalog() and reset the
      cached instance on close so the applier stays reusable.
    * E2E: the SQL client creates Paimon artifacts as the JobManager user while
      the sink runs in the TaskManager container, so widening the shared volume
      permissions is required for applyCreateTable/applyAddColumn to work. The
      Fluss source table insert additionally needs the Fluss jar shipped through
      pipeline.jars, because copyJarToFlinkLib only reaches the JobManager and
      sql-client.sh has no --jar option; the read-back loop now surfaces a
      failed insert job instead of timing out silently.
    * Drop the batch expansion E2E case, whose logic is shared with the
      streaming cases and already covered by unit tests.
    
    Co-Authored-By: Codex <[email protected]>
    AI-Model: gpt-5.6-sol
    AI-Contributed/Feature: 314/330
    AI-Contributed/UT: 367/374
---
 .../docs/core-concept/schema-evolution.md          |  23 ++-
 docs/content/docs/core-concept/schema-evolution.md |  23 ++-
 .../cli/parser/YamlPipelineDefinitionParser.java   |  17 +--
 .../parser/YamlPipelineDefinitionParserTest.java   |  37 +++--
 .../pipeline/ExistingTableSchemaExpansionMode.java |   6 +-
 .../flink/cdc/composer/definition/SinkDef.java     |   4 +-
 .../flink/translator/SchemaOperatorTranslator.java |  24 +++-
 .../fluss/sink/FlussMetadataApplierTest.java       |   7 +-
 .../paimon/sink/PaimonMetadataApplier.java         |  28 ++--
 .../paimon/sink/PaimonMetadataApplierTest.java     |  39 +++++-
 .../ExistingTableSchemaExpansionE2eITCase.java     | 103 +++++++-------
 .../schema/common/ExistingTableSchemaExpander.java |  99 +++++++++----
 .../operators/schema/common/SchemaRegistry.java    |  17 +--
 .../schema/distributed/SchemaCoordinator.java      |   2 +-
 .../distributed/SchemaCoordinatorProvider.java     |   2 +-
 .../schema/distributed/SchemaOperatorFactory.java  |   2 +-
 .../schema/regular/BatchSchemaOperator.java        |  77 +---------
 .../schema/regular/SchemaCoordinator.java          |   2 +-
 .../schema/regular/SchemaCoordinatorProvider.java  |   2 +-
 .../schema/regular/SchemaOperatorFactory.java      |   2 +-
 .../common/ExistingTableSchemaExpanderTest.java    | 155 +++++++++++++++------
 .../schema/regular/SchemaCoordinatorTest.java      |  33 +++++
 22 files changed, 413 insertions(+), 291 deletions(-)

diff --git a/docs/content.zh/docs/core-concept/schema-evolution.md 
b/docs/content.zh/docs/core-concept/schema-evolution.md
index 1789b9a96..c08d1323d 100644
--- a/docs/content.zh/docs/core-concept/schema-evolution.md
+++ b/docs/content.zh/docs/core-concept/schema-evolution.md
@@ -77,18 +77,29 @@ pipeline:
 
 ## 已有目标表的安全 Schema 扩展
 
-设置 Sink 选项 `existing-table.schema-expansion.mode` 来控制框架在初始 `CreateTableEvent` 
遇到已有目标表时的处理方式,默认值为 `OFF`。对于实现了该能力的 Sink,框架可能将缺失的普通非键物理列按 nullable 
补充,并安全拓宽普通非键列类型;派生的 DDL 事件会记录在日志中。
+设置 Sink 选项 `existing-table.schema-expansion.mode` 来控制框架在初始 `CreateTableEvent` 
遇到已有目标表时的处理方式,默认值为 `DISABLED`。对于实现了该能力的 Sink,框架可能将缺失的普通非键物理列按 nullable 
补充,并安全拓宽普通非键列类型;派生的 DDL 事件会记录在日志中。
+
+这是由 Flink CDC 框架直接消费的 `sink` 级选项。连接器创建前,框架会从 Sink 配置中移除该选项,因此它不会作为配置属性传递到连接器的 
`MetadataApplier`。与 `catalog.properties.*` 不同,该选项不能嵌套在连接器专属属性下;将其写入 
`catalog.properties.existing-table.schema-expansion.mode` 不会生效。
+
+| 选项 | 作用范围 | 是否传递给连接器 |
+|---|---|---|
+| `existing-table.schema-expansion.mode` | Flink CDC 框架 | 否 |
+| `catalog.properties.*` | 连接器专属 Catalog 选项 | 是 |
+
+只有其 `MetadataApplier` 实现了 `ExistingTableSchemaExpansionSupport` 的 Sink 
才支持扩展。当前实现该能力的 pipeline connector 是 Paimon 和 Fluss。显式配置任何非 `DISABLED` 模式时,如果当前 
connector 缺少扩展能力,作业都会立即失败。
+
+该能力仅对流式作业生效。当 `execution.runtime-mode` 为 `BATCH` 时,该选项会被忽略并打印告警日志,此时沿用 Sink 
原生的 Schema 处理。
 
 | 模式 | 已有目标表 | 目标表不存在 | 失败处理 |
 |---|---|---|---|
-| `OFF` | 不检查、不扩展,保持 Sink 原行为 | Sink 原生建表 | 不适用 |
+| `DISABLED` | 不检查、不扩展,保持 Sink 原行为 | Sink 原生建表 | 不适用 |
 | `CHECK` | 只校验每个上游列能否被目标表容纳,不执行任何 DDL | 作业失败,目标表需由外部创建 | 
任何不兼容、读取失败或能力缺失都会以聚合错误使作业失败 |
-| `TRY_EXPAND` | 检查并尽力执行安全 DDL,随后读回目标 schema 验证 | Sink 原生建表 | 本机制的失败仅记录日志并交给 
Sink 原行为 |
+| `TRY_EXPAND` | 检查并尽力执行安全 DDL,随后读回目标 schema 验证 | Sink 原生建表 | 
缺少扩展能力时作业失败;本机制的其他失败仅记录日志并交给 Sink 原行为 |
 | `EXPAND` | 检查并执行安全 DDL,随后读回目标 schema 验证 | Sink 原生建表 | 任何不兼容、DDL 
不支持、执行或验证失败都会使作业失败 |
 
-`CHECK` 不执行任何 DDL,因此不受 `include.schema.changes` 和 Sink DDL 能力的影响。它守护初始表状态,**不受 
`schema.change.behavior` 控制**(包括 `IGNORE` 和 `EXCEPTION` 也会执行检查);`TRY_EXPAND` 和 
`EXPAND` 在 `schema.change.behavior` 为 `IGNORE` 或 `EXCEPTION` 
时跳过框架侧初始处理。注意:`CHECK` 只约束已有目标表的初始处理,后续源端 Schema 变更仍由 `schema.change.behavior` 
控制,因此它不是全作业级别的“永不执行 DDL”开关。检查失败时,聚合错误会列出每处差异(表、列、上游类型与目标类型),并附建议的 `ALTER TABLE` 
修复语句,可按目标系统方言调整后人工执行。
+`CHECK` 不执行任何 DDL,因此不受 `include.schema.changes` 和 Sink DDL 能力的影响。它守护初始表状态,**不受 
`schema.change.behavior` 控制**(包括 `IGNORE` 和 `EXCEPTION` 也会执行检查);`TRY_EXPAND` 和 
`EXPAND` 在 `schema.change.behavior` 为 `IGNORE` 或 `EXCEPTION` 
时跳过框架侧初始处理。注意:`CHECK` 只约束已有目标表的初始处理,后续源端 Schema 变更仍由 `schema.change.behavior` 
控制,因此它不是全作业级别的“永不执行 DDL”开关。检查失败时,聚合错误会列出每处差异(表、列、上游类型与目标类型),并附方言无关的 `ALTER 
TABLE` 修复 SQL 模板,需按目标连接器方言调整并人工确认后执行。
 
-`TRY_EXPAND` 只吞掉本机制自身的失败:它既不屏蔽 Sink 自身 Schema 处理抛出的错误,也不保证扩展失败后所有上游列都能落入目标表。
+`TRY_EXPAND` 仅在确认 connector 支持该能力后吞掉本机制自身的失败:它既不屏蔽 Sink 自身 Schema 
处理抛出的错误,也不保证扩展失败后所有上游列都能落入目标表。
 
 ```yaml
 sink:
@@ -96,7 +107,7 @@ sink:
   existing-table.schema-expansion.mode: "EXPAND"
 ```
 
-建议给模式值加引号(例如 `"OFF"`),避免裸写 `OFF` 被 YAML 解析为布尔值。
+模式值必须使用字符串(例如 `"DISABLED"` 或 `"EXPAND"`);布尔值将不被接受。
 
 ## 按类型配置行为
 
diff --git a/docs/content/docs/core-concept/schema-evolution.md 
b/docs/content/docs/core-concept/schema-evolution.md
index 6ab021c8f..41e0608e6 100644
--- a/docs/content/docs/core-concept/schema-evolution.md
+++ b/docs/content/docs/core-concept/schema-evolution.md
@@ -75,18 +75,29 @@ This is useful when your downstream sink is unready for any 
schema changes, but
 
 ## Existing Table Schema Expansion
 
-Set the sink option `existing-table.schema-expansion.mode` to control how the 
framework handles the initial `CreateTableEvent` when the target table already 
exists. The default is `OFF`. For sinks that implement this capability, the 
framework may add missing non-key physical columns as nullable columns and 
safely widen non-key column types. Derived DDL events are logged.
+Set the sink option `existing-table.schema-expansion.mode` to control how the 
framework handles the initial `CreateTableEvent` when the target table already 
exists. The default is `DISABLED`. For sinks that implement this capability, 
the framework may add missing non-key physical columns as nullable columns and 
safely widen non-key column types. Derived DDL events are logged.
+
+This is a framework-level option consumed directly by Flink CDC's `sink` 
block. It is removed from the sink configuration before the connector is 
created, so it never reaches the connector's `MetadataApplier` as a 
configuration property. Unlike `catalog.properties.*`, it cannot be nested 
under connector-specific properties; placing it under 
`catalog.properties.existing-table.schema-expansion.mode` has no effect.
+
+| Option | Scope | Passed to connector |
+|---|---|---|
+| `existing-table.schema-expansion.mode` | Flink CDC framework | No |
+| `catalog.properties.*` | Connector-specific catalog options | Yes |
+
+Expansion is only available for sinks whose `MetadataApplier` implements 
`ExistingTableSchemaExpansionSupport`. The pipeline connectors currently 
implementing this capability are Paimon and Fluss. Any other explicitly 
configured non-`DISABLED` mode fails fast because the required expansion 
capability is unavailable.
+
+Expansion applies to streaming pipelines only. When `execution.runtime-mode` 
is `BATCH`, the option is ignored, a warning is logged, and the sink's original 
schema handling applies.
 
 | Mode | Behavior on an existing target table | Behavior when the target table 
is missing | Failure handling |
 |---|---|---|---|
-| `OFF` | No check or expansion; the sink's original behavior applies | Sink 
creates the table | N/A |
+| `DISABLED` | No check or expansion; the sink's original behavior applies | 
Sink creates the table | N/A |
 | `CHECK` | Validate that every upstream column can be contained by the target 
table, without issuing any DDL | Fails the job; the table must be created 
externally | Any incompatibility, read failure, or missing capability fails the 
job with an aggregated error |
-| `TRY_EXPAND` | Check and best-effort apply safe DDL, then verify the result 
by reading the target schema back | Sink creates the table | Failures of this 
mechanism are logged and delegated to the sink's original behavior |
+| `TRY_EXPAND` | Check and best-effort apply safe DDL, then verify the result 
by reading the target schema back | Sink creates the table | A missing 
expansion capability fails the job; other failures of this mechanism are logged 
and delegated to the sink's original behavior |
 | `EXPAND` | Check and apply safe DDL, then verify the result by reading the 
target schema back | Sink creates the table | Any incompatibility, unsupported 
DDL, execution or verification failure fails the job |
 
-`CHECK` never issues DDL, so it is independent of `include.schema.changes` and 
of the sink's DDL capabilities. It guards the initial table state and runs 
**regardless of `schema.change.behavior`** (including `IGNORE` and 
`EXCEPTION`); `TRY_EXPAND` and `EXPAND` skip the framework-side initial 
handling when `schema.change.behavior` is `IGNORE` or `EXCEPTION`. Note that 
`CHECK` only constrains the initial table handling: subsequent source schema 
changes are still controlled by `schema.chang [...]
+`CHECK` never issues DDL, so it is independent of `include.schema.changes` and 
of the sink's DDL capabilities. It guards the initial table state and runs 
**regardless of `schema.change.behavior`** (including `IGNORE` and 
`EXCEPTION`); `TRY_EXPAND` and `EXPAND` skip the framework-side initial 
handling when `schema.change.behavior` is `IGNORE` or `EXCEPTION`. Note that 
`CHECK` only constrains the initial table handling: subsequent source schema 
changes are still controlled by `schema.chang [...]
 
-`TRY_EXPAND` swallows failures of this mechanism only; it neither hides errors 
from the sink's own schema handling nor guarantees that all upstream columns 
end up in the target table after a failed expansion.
+`TRY_EXPAND` swallows expansion-mechanism failures only after the connector is 
confirmed to support the capability; it neither hides errors from the sink's 
own schema handling nor guarantees that all upstream columns end up in the 
target table after a failed expansion.
 
 ```yaml
 sink:
@@ -94,7 +105,7 @@ sink:
   existing-table.schema-expansion.mode: "EXPAND"
 ```
 
-Quote the mode value (for example `"OFF"`) to avoid the bare `OFF` scalar 
being parsed as a YAML boolean.
+The mode value must be quoted as a string (for example `"DISABLED"` or 
`"EXPAND"`); boolean values are not accepted.
 
 ## Per-Event Type Control
 
diff --git 
a/flink-cdc-cli/src/main/java/org/apache/flink/cdc/cli/parser/YamlPipelineDefinitionParser.java
 
b/flink-cdc-cli/src/main/java/org/apache/flink/cdc/cli/parser/YamlPipelineDefinitionParser.java
index 9692cba80..70d3a4615 100644
--- 
a/flink-cdc-cli/src/main/java/org/apache/flink/cdc/cli/parser/YamlPipelineDefinitionParser.java
+++ 
b/flink-cdc-cli/src/main/java/org/apache/flink/cdc/cli/parser/YamlPipelineDefinitionParser.java
@@ -297,23 +297,12 @@ public class YamlPipelineDefinitionParser implements 
PipelineDefinitionParser {
 
     private static ExistingTableSchemaExpansionMode 
parseExpansionMode(JsonNode optionValue) {
         if (optionValue == null) {
-            return ExistingTableSchemaExpansionMode.OFF;
-        }
-        // A bare `OFF` YAML scalar is parsed by Jackson YAML into a boolean 
`false`; accept it as
-        // OFF for ergonomics. Any other boolean is ambiguous and rejected.
-        if (optionValue.isBoolean()) {
-            if (!optionValue.booleanValue()) {
-                return ExistingTableSchemaExpansionMode.OFF;
-            }
-            throw new IllegalArgumentException(
-                    String.format(
-                            "Option \"%s\" must be one of OFF, CHECK, 
TRY_EXPAND, EXPAND, but was \"%s\".",
-                            EXISTING_TABLE_SCHEMA_EXPANSION_MODE, 
optionValue));
+            return ExistingTableSchemaExpansionMode.DISABLED;
         }
         if (!optionValue.isTextual()) {
             throw new IllegalArgumentException(
                     String.format(
-                            "Option \"%s\" must be one of OFF, CHECK, 
TRY_EXPAND, EXPAND, but was \"%s\".",
+                            "Option \"%s\" must be one of DISABLED, CHECK, 
TRY_EXPAND, EXPAND, but was \"%s\".",
                             EXISTING_TABLE_SCHEMA_EXPANSION_MODE, 
optionValue));
         }
         String value = optionValue.asText();
@@ -324,7 +313,7 @@ public class YamlPipelineDefinitionParser implements 
PipelineDefinitionParser {
         }
         throw new IllegalArgumentException(
                 String.format(
-                        "Option \"%s\" must be one of OFF, CHECK, TRY_EXPAND, 
EXPAND, but was \"%s\".",
+                        "Option \"%s\" must be one of DISABLED, CHECK, 
TRY_EXPAND, EXPAND, but was \"%s\".",
                         EXISTING_TABLE_SCHEMA_EXPANSION_MODE, value));
     }
 
diff --git 
a/flink-cdc-cli/src/test/java/org/apache/flink/cdc/cli/parser/YamlPipelineDefinitionParserTest.java
 
b/flink-cdc-cli/src/test/java/org/apache/flink/cdc/cli/parser/YamlPipelineDefinitionParserTest.java
index 621646e13..ee5d10b74 100644
--- 
a/flink-cdc-cli/src/test/java/org/apache/flink/cdc/cli/parser/YamlPipelineDefinitionParserTest.java
+++ 
b/flink-cdc-cli/src/test/java/org/apache/flink/cdc/cli/parser/YamlPipelineDefinitionParserTest.java
@@ -534,28 +534,12 @@ class YamlPipelineDefinitionParserTest {
     }
 
     @Test
-    void testExistingTableSchemaExpansionModeDefaultsToOff() throws Exception {
+    void testExistingTableSchemaExpansionModeDefaultsToDisabled() throws 
Exception {
         PipelineDef defaultPipeline =
                 new YamlPipelineDefinitionParser()
                         .parse("source:\n  type: foo\nsink:\n  type: bar\n", 
new Configuration());
         
assertThat(defaultPipeline.getSink().getExistingTableSchemaExpansionMode())
-                .isEqualTo(ExistingTableSchemaExpansionMode.OFF);
-    }
-
-    @Test
-    void testBareOffParsesAsExpansionModeOff() throws Exception {
-        // A bare OFF scalar is parsed into a boolean false by Jackson YAML 
and must be accepted.
-        PipelineDef parsed =
-                new YamlPipelineDefinitionParser()
-                        .parse(
-                                "source:\n"
-                                        + "  type: foo\n"
-                                        + "sink:\n"
-                                        + "  type: bar\n"
-                                        + "  
existing-table.schema-expansion.mode: OFF\n",
-                                new Configuration());
-        assertThat(parsed.getSink().getExistingTableSchemaExpansionMode())
-                .isEqualTo(ExistingTableSchemaExpansionMode.OFF);
+                .isEqualTo(ExistingTableSchemaExpansionMode.DISABLED);
     }
 
     @Test
@@ -571,7 +555,7 @@ class YamlPipelineDefinitionParserTest {
                                                         + "  
existing-table.schema-expansion.mode: INVALID\n",
                                                 new Configuration()))
                 .isInstanceOf(IllegalArgumentException.class)
-                .hasMessageContaining("OFF, CHECK, TRY_EXPAND, EXPAND");
+                .hasMessageContaining("DISABLED, CHECK, TRY_EXPAND, EXPAND");
 
         assertThatThrownBy(
                         () ->
@@ -584,7 +568,20 @@ class YamlPipelineDefinitionParserTest {
                                                         + "  
existing-table.schema-expansion.mode: true\n",
                                                 new Configuration()))
                 .isInstanceOf(IllegalArgumentException.class)
-                .hasMessageContaining("OFF, CHECK, TRY_EXPAND, EXPAND");
+                .hasMessageContaining("DISABLED, CHECK, TRY_EXPAND, EXPAND");
+
+        assertThatThrownBy(
+                        () ->
+                                new YamlPipelineDefinitionParser()
+                                        .parse(
+                                                "source:\n"
+                                                        + "  type: foo\n"
+                                                        + "sink:\n"
+                                                        + "  type: bar\n"
+                                                        + "  
existing-table.schema-expansion.mode: false\n",
+                                                new Configuration()))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("DISABLED, CHECK, TRY_EXPAND, EXPAND");
     }
 
     @Test
diff --git 
a/flink-cdc-common/src/main/java/org/apache/flink/cdc/common/pipeline/ExistingTableSchemaExpansionMode.java
 
b/flink-cdc-common/src/main/java/org/apache/flink/cdc/common/pipeline/ExistingTableSchemaExpansionMode.java
index 68d16dfa9..b24a497be 100644
--- 
a/flink-cdc-common/src/main/java/org/apache/flink/cdc/common/pipeline/ExistingTableSchemaExpansionMode.java
+++ 
b/flink-cdc-common/src/main/java/org/apache/flink/cdc/common/pipeline/ExistingTableSchemaExpansionMode.java
@@ -30,7 +30,7 @@ import org.apache.flink.cdc.common.annotation.Experimental;
 @Experimental
 public enum ExistingTableSchemaExpansionMode {
     /** Do not check or expand the existing target table; keep the sink's 
original behavior. */
-    OFF,
+    DISABLED,
 
     /**
      * Validate that the existing target table can contain every upstream 
column without executing
@@ -43,6 +43,10 @@ public enum ExistingTableSchemaExpansionMode {
     /**
      * Check and best-effort expand the existing target table with safe DDL. 
Failures of this
      * mechanism are logged and delegated to the sink's original behavior.
+     *
+     * <p>A connector that does not implement {@link
+     * org.apache.flink.cdc.common.sink.ExistingTableSchemaExpansionSupport} 
is a configuration
+     * error and fails the job rather than silently degrading.
      */
     TRY_EXPAND,
 
diff --git 
a/flink-cdc-composer/src/main/java/org/apache/flink/cdc/composer/definition/SinkDef.java
 
b/flink-cdc-composer/src/main/java/org/apache/flink/cdc/composer/definition/SinkDef.java
index a39c3e560..d160085c2 100644
--- 
a/flink-cdc-composer/src/main/java/org/apache/flink/cdc/composer/definition/SinkDef.java
+++ 
b/flink-cdc-composer/src/main/java/org/apache/flink/cdc/composer/definition/SinkDef.java
@@ -55,7 +55,7 @@ public class SinkDef {
         this.config = config;
         this.includedSchemaEvolutionTypes =
                 
Arrays.stream(SchemaChangeEventTypeFamily.ALL).collect(Collectors.toSet());
-        this.existingTableSchemaExpansionMode = 
ExistingTableSchemaExpansionMode.OFF;
+        this.existingTableSchemaExpansionMode = 
ExistingTableSchemaExpansionMode.DISABLED;
     }
 
     public SinkDef(
@@ -68,7 +68,7 @@ public class SinkDef {
                 name,
                 config,
                 includedSchemaEvolutionTypes,
-                ExistingTableSchemaExpansionMode.OFF);
+                ExistingTableSchemaExpansionMode.DISABLED);
     }
 
     public SinkDef(
diff --git 
a/flink-cdc-composer/src/main/java/org/apache/flink/cdc/composer/flink/translator/SchemaOperatorTranslator.java
 
b/flink-cdc-composer/src/main/java/org/apache/flink/cdc/composer/flink/translator/SchemaOperatorTranslator.java
index 106c057a7..4a98fc033 100644
--- 
a/flink-cdc-composer/src/main/java/org/apache/flink/cdc/composer/flink/translator/SchemaOperatorTranslator.java
+++ 
b/flink-cdc-composer/src/main/java/org/apache/flink/cdc/composer/flink/translator/SchemaOperatorTranslator.java
@@ -35,6 +35,9 @@ import org.apache.flink.cdc.runtime.typeutils.EventTypeInfo;
 import org.apache.flink.streaming.api.datastream.DataStream;
 import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
 
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
 import java.time.Duration;
 import java.util.ArrayList;
 import java.util.List;
@@ -42,6 +45,9 @@ import java.util.List;
 /** Translator used to build {@link SchemaOperator} for schema event process. 
*/
 @Internal
 public class SchemaOperatorTranslator {
+
+    private static final Logger LOG = 
LoggerFactory.getLogger(SchemaOperatorTranslator.class);
+
     private final SchemaChangeBehavior schemaChangeBehavior;
     private final ExistingTableSchemaExpansionMode 
existingTableSchemaExpansionMode;
     private final String schemaOperatorUid;
@@ -55,7 +61,7 @@ public class SchemaOperatorTranslator {
             String timezone) {
         this(
                 schemaChangeBehavior,
-                ExistingTableSchemaExpansionMode.OFF,
+                ExistingTableSchemaExpansionMode.DISABLED,
                 schemaOperatorUid,
                 rpcTimeOut,
                 timezone);
@@ -176,6 +182,15 @@ public class SchemaOperatorTranslator {
             List<RouteDef> routes,
             RouteMode routeMode,
             String timezone) {
+        // Existing target table schema expansion is only wired into the 
streaming schema
+        // operators, so a batch pipeline keeps the sink's original schema 
handling.
+        if (existingTableSchemaExpansionMode != 
ExistingTableSchemaExpansionMode.DISABLED) {
+            LOG.warn(
+                    "Existing target table schema expansion is not supported 
in batch mode, but "
+                            + "mode {} was configured. The option is ignored 
and the sink's "
+                            + "original schema handling applies.",
+                    existingTableSchemaExpansionMode);
+        }
         List<RouteRule> routingRules = new ArrayList<>();
         for (RouteDef route : routes) {
             routingRules.add(
@@ -189,12 +204,7 @@ public class SchemaOperatorTranslator {
                         "SchemaBatchOperator",
                         new EventTypeInfo(),
                         new BatchSchemaOperator(
-                                routingRules,
-                                routeMode,
-                                metadataApplier,
-                                schemaChangeBehavior,
-                                existingTableSchemaExpansionMode,
-                                timezone));
+                                routingRules, routeMode, metadataApplier, 
timezone));
         stream.uid(schemaOperatorUid).setParallelism(parallelism);
         return stream;
     }
diff --git 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/test/java/org/apache/flink/cdc/connectors/fluss/sink/FlussMetadataApplierTest.java
 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/test/java/org/apache/flink/cdc/connectors/fluss/sink/FlussMetadataApplierTest.java
index 55e08c2ac..5a2f82dad 100644
--- 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/test/java/org/apache/flink/cdc/connectors/fluss/sink/FlussMetadataApplierTest.java
+++ 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/test/java/org/apache/flink/cdc/connectors/fluss/sink/FlussMetadataApplierTest.java
@@ -264,9 +264,9 @@ public class FlussMetadataApplierTest {
             ExistingTableSchemaExpander expander =
                     new ExistingTableSchemaExpander(applier, applier, 
SchemaChangeBehavior.LENIENT);
 
-            expander.expand(new CreateTableEvent(tableId, desiredSchema));
+            expander.handleExistingTableCreation(new CreateTableEvent(tableId, 
desiredSchema));
             
assertThat(applier.getExistingTableSchema(tableId)).contains(expectedSchema);
-            expander.expand(new CreateTableEvent(tableId, desiredSchema));
+            expander.handleExistingTableCreation(new CreateTableEvent(tableId, 
desiredSchema));
             
assertThat(applier.getExistingTableSchema(tableId)).contains(expectedSchema);
 
             assertThat(
@@ -282,7 +282,8 @@ public class FlussMetadataApplierTest {
                             .primaryKey("id")
                             .build();
 
-            expander.expand(new CreateTableEvent(tableId, 
schemaWithDifferentExistingColumnType));
+            expander.handleExistingTableCreation(
+                    new CreateTableEvent(tableId, 
schemaWithDifferentExistingColumnType));
             
assertThat(applier.getExistingTableSchema(tableId)).contains(expectedSchema);
         }
     }
diff --git 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/PaimonMetadataApplier.java
 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/PaimonMetadataApplier.java
index 6711a8890..82114ff7a 100644
--- 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/PaimonMetadataApplier.java
+++ 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/main/java/org/apache/flink/cdc/connectors/paimon/sink/PaimonMetadataApplier.java
@@ -126,9 +126,7 @@ public class PaimonMetadataApplier implements 
MetadataApplier, ExistingTableSche
 
     @Override
     public Optional<Schema> getExistingTableSchema(TableId tableId) {
-        if (catalog == null) {
-            catalog = FlinkCatalogFactory.createPaimonCatalog(catalogOptions);
-        }
+        Catalog catalog = getCatalog();
         try {
             Table table =
                     catalog.getTable(
@@ -164,18 +162,14 @@ public class PaimonMetadataApplier implements 
MetadataApplier, ExistingTableSche
 
     @Override
     public boolean isColumnNameCaseSensitive() {
-        if (catalog == null) {
-            catalog = FlinkCatalogFactory.createPaimonCatalog(catalogOptions);
-        }
-        return catalog.caseSensitive();
+        return getCatalog().caseSensitive();
     }
 
     @Override
     public void applySchemaChange(SchemaChangeEvent schemaChangeEvent)
             throws SchemaEvolveException {
-        if (catalog == null) {
-            catalog = FlinkCatalogFactory.createPaimonCatalog(catalogOptions);
-        }
+        // Eagerly initialize the catalog: the visitor methods below use the 
cached field directly.
+        getCatalog();
         SchemaChangeEventVisitor.voidVisit(
                 schemaChangeEvent,
                 this::applyAddColumn,
@@ -192,7 +186,21 @@ public class PaimonMetadataApplier implements 
MetadataApplier, ExistingTableSche
     public void close() throws Exception {
         if (catalog != null) {
             catalog.close();
+            catalog = null;
+        }
+    }
+
+    /**
+     * Lazily creates and caches the Paimon {@link Catalog}. All catalog 
access must go through this
+     * method so the lifecycle stays centralized: the catalog is created once 
on first use and
+     * released by {@link #close()}, which also resets the cached instance so 
the applier stays
+     * reusable after close.
+     */
+    private Catalog getCatalog() {
+        if (catalog == null) {
+            catalog = FlinkCatalogFactory.createPaimonCatalog(catalogOptions);
         }
+        return catalog;
     }
 
     private void applyCreateTable(CreateTableEvent event) throws 
SchemaEvolveException {
diff --git 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/test/java/org/apache/flink/cdc/connectors/paimon/sink/PaimonMetadataApplierTest.java
 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/test/java/org/apache/flink/cdc/connectors/paimon/sink/PaimonMetadataApplierTest.java
index ec62a846a..5c4665f1e 100644
--- 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/test/java/org/apache/flink/cdc/connectors/paimon/sink/PaimonMetadataApplierTest.java
+++ 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-paimon/src/test/java/org/apache/flink/cdc/connectors/paimon/sink/PaimonMetadataApplierTest.java
@@ -303,9 +303,9 @@ class PaimonMetadataApplierTest {
                 new ExistingTableSchemaExpander(
                         metadataApplier, metadataApplier, 
SchemaChangeBehavior.LENIENT);
 
-        expander.expand(createTableEvent);
+        expander.handleExistingTableCreation(createTableEvent);
         metadataApplier.applySchemaChange(createTableEvent);
-        expander.expand(createTableEvent);
+        expander.handleExistingTableCreation(createTableEvent);
         Assertions.assertThat(metadataApplier.getExistingTableSchema(tableId))
                 .get()
                 .satisfies(
@@ -788,4 +788,39 @@ class PaimonMetadataApplierTest {
 
         Assertions.assertThat(table).isNotNull();
     }
+
+    @Test
+    void testCloseIsIdempotentAndRecreatesCatalogOnReuse()
+            throws Catalog.DatabaseNotEmptyException, 
Catalog.DatabaseNotExistException, Exception {
+        initialize("filesystem");
+        PaimonMetadataApplier metadataApplier = new 
PaimonMetadataApplier(catalogOptions);
+
+        // Trigger lazy catalog creation.
+        metadataApplier.applySchemaChange(
+                new CreateTableEvent(
+                        TableId.parse("test.close_idempotent"),
+                        Schema.newBuilder()
+                                .physicalColumn(
+                                        "id", 
org.apache.flink.cdc.common.types.DataTypes.INT())
+                                .primaryKey("id")
+                                .build()));
+
+        // close() must be idempotent and must not fail when called repeatedly.
+        metadataApplier.close();
+        metadataApplier.close();
+
+        // After close the cached catalog is reset; the applier stays reusable 
and recreates it.
+        metadataApplier.applySchemaChange(
+                new CreateTableEvent(
+                        TableId.parse("test.close_reuse"),
+                        Schema.newBuilder()
+                                .physicalColumn(
+                                        "id", 
org.apache.flink.cdc.common.types.DataTypes.INT())
+                                .primaryKey("id")
+                                .build()));
+
+        
Assertions.assertThat(catalog.getTable(Identifier.fromString("test.close_reuse")))
+                .isNotNull();
+        metadataApplier.close();
+    }
 }
diff --git 
a/flink-cdc-e2e-tests/flink-cdc-pipeline-e2e-tests/src/test/java/org/apache/flink/cdc/pipeline/tests/stage2/ExistingTableSchemaExpansionE2eITCase.java
 
b/flink-cdc-e2e-tests/flink-cdc-pipeline-e2e-tests/src/test/java/org/apache/flink/cdc/pipeline/tests/stage2/ExistingTableSchemaExpansionE2eITCase.java
index aba76229f..a49ddb39f 100644
--- 
a/flink-cdc-e2e-tests/flink-cdc-pipeline-e2e-tests/src/test/java/org/apache/flink/cdc/pipeline/tests/stage2/ExistingTableSchemaExpansionE2eITCase.java
+++ 
b/flink-cdc-e2e-tests/flink-cdc-pipeline-e2e-tests/src/test/java/org/apache/flink/cdc/pipeline/tests/stage2/ExistingTableSchemaExpansionE2eITCase.java
@@ -21,6 +21,7 @@ import org.apache.flink.api.common.JobStatus;
 import org.apache.flink.cdc.common.test.utils.TestUtils;
 import org.apache.flink.cdc.connectors.mysql.testutils.UniqueDatabase;
 import org.apache.flink.cdc.pipeline.tests.utils.PipelineTestEnvironment;
+import org.apache.flink.runtime.client.JobStatusMessage;
 
 import org.apache.flink.shaded.guava31.com.google.common.collect.ImmutableMap;
 
@@ -48,14 +49,15 @@ import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
 import java.util.UUID;
+import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
 /**
  * End-to-end tests verifying that the {@code 
existing-table.schema-expansion.mode} sink option
  * reaches the schema expander through the whole wiring path (YAML option, 
Composer, schema operator
- * factory, schema registry or batch schema operator, metadata applier) in all 
three execution
- * topologies: regular streaming, batch, and distributed streaming.
+ * factory, schema registry, metadata applier) in the streaming and 
distributed execution
+ * topologies.
  *
  * <p>Every case pre-creates the target table with a strict subset of the 
source columns, so a flag
  * lost on any wiring hop leaves the missing columns unwritten and fails the 
assertions.
@@ -218,52 +220,6 @@ class ExistingTableSchemaExpansionE2eITCase extends 
PipelineTestEnvironment {
         validatePaimonSinkResult(warehouse, database, "customers", 
expectedCustomersRows());
     }
 
-    /**
-     * Batch runtime mode makes the Composer wire a batch schema operator 
instead of the
-     * coordinator-based one. MySQL accepts the {@code snapshot} startup mode 
only in batch
-     * pipelines, which also bounds the source so that the job reaches a 
terminal state.
-     */
-    @Test
-    void testBatchPath() throws Exception {
-        String warehouse = sharedVolume.toString() + "/" + "paimon_" + 
UUID.randomUUID();
-        String database = inventoryDatabase.getDatabaseName();
-        preCreatePaimonProductsTable(warehouse, database);
-
-        String pipelineJob =
-                String.format(
-                        "source:\n"
-                                + "  type: mysql\n"
-                                + "  hostname: mysql\n"
-                                + "  port: 3306\n"
-                                + "  username: %s\n"
-                                + "  password: %s\n"
-                                + "  tables: %s.\\.*\n"
-                                + "  server-id: 5400-5404\n"
-                                + "  server-time-zone: UTC\n"
-                                + "  scan.startup.mode: snapshot\n"
-                                + "\n"
-                                + "sink:\n"
-                                + "  type: paimon\n"
-                                + "  catalog.properties.warehouse: %s\n"
-                                + "  catalog.properties.metastore: 
filesystem\n"
-                                + "  catalog.properties.cache-enabled: false\n"
-                                + "  existing-table.schema-expansion.mode: 
EXPAND\n"
-                                + "\n"
-                                + "pipeline:\n"
-                                + "  schema.change.behavior: evolve\n"
-                                + "  parallelism: %s\n"
-                                + "  execution.runtime-mode: BATCH",
-                        MYSQL_TEST_USER, MYSQL_TEST_PASSWORD, database, 
warehouse, parallelism);
-        Path paimonCdcConnector = 
TestUtils.getResource("paimon-cdc-pipeline-connector.jar");
-        Path hadoopJar = TestUtils.getResource("flink-shade-hadoop.jar");
-        submitPipelineJob(pipelineJob, paimonCdcConnector, hadoopJar);
-        waitUntilJobFinished(EXPANSION_TESTCASE_TIMEOUT);
-        LOG.info("Batch pipeline job has finished");
-
-        validatePaimonSinkResult(warehouse, database, "products", 
expectedProductsRows());
-        validatePaimonSinkResult(warehouse, database, "customers", 
expectedCustomersRows());
-    }
-
     /**
      * Fluss reports parallel metadata, so the Composer wires the distributed 
schema operator backed
      * by the distributed schema coordinator. That topology only accepts 
{@code LENIENT}, {@code
@@ -637,9 +593,21 @@ class ExistingTableSchemaExpansionE2eITCase extends 
PipelineTestEnvironment {
      * table holding a strict subset of the source columns.
      */
     private void prepareFlussTables(String sourceDatabase, String 
sinkDatabase) throws Exception {
+        // copyJarToFlinkLib only reaches the JobManager container and the SQL 
client has no --jar
+        // option, so the TaskManager would lack the Fluss sink classes (such 
as
+        // FlinkStreamPartitioner) that the insert job needs. Publish the jar 
on the shared volume
+        // and hand it to the job through pipeline.jars, which ships it to 
every node. (Pointing
+        // pipeline.jars straight at /opt/flink/lib does not work: that 
directory is not part of the
+        // TaskManager image, and the jar never reaches the writers.)
+        String flussJar = String.format("fluss-flink-%s.jar", flinkVersion);
+        String containerJar = sharedVolume.toString() + "/" + flussJar;
+        jobManager.copyFileToContainer(
+                MountableFile.forHostPath(TestUtils.getResource(flussJar)), 
containerJar);
+
         String sql =
                 String.format(
-                        "SET 'execution.runtime-mode' = 'batch';\n"
+                        "SET 'pipeline.jars' = 'file://%s';\n"
+                                + "SET 'execution.runtime-mode' = 'batch';\n"
                                 + "CREATE CATALOG fluss_catalog WITH (\n"
                                 + "  'type' = 'fluss',\n"
                                 + "  'bootstrap.servers' = 
'coordinator-server:9123',\n"
@@ -665,15 +633,22 @@ class ExistingTableSchemaExpansionE2eITCase extends 
PipelineTestEnvironment {
                                 + "  (101, 'One', 'Alice'),\n"
                                 + "  (102, 'Two', 'Bob'),\n"
                                 + "  (103, 'Three', 'Cecily');",
-                        sourceDatabase, sinkDatabase, sourceDatabase, 
sinkDatabase, sourceDatabase);
+                        containerJar,
+                        sourceDatabase,
+                        sinkDatabase,
+                        sourceDatabase,
+                        sinkDatabase,
+                        sourceDatabase);
         executeFlussSql(sql, "prepare_fluss");
 
-        // The SQL client may return before the inserted rows are visible to 
new Fluss readers.
-        // Verify that the records can actually be read before starting the 
CDC pipeline.
+        // The SQL client only submits the batch insert and returns, so the 
rows may not be visible
+        // to new Fluss readers yet. Verify that they can actually be read 
before starting the
+        // pipeline, and fail fast if the detached insert job died.
         List<String> expectedRows =
                 Arrays.asList("101, One, Alice", "102, Two, Bob", "103, Three, 
Cecily");
         long deadline = System.currentTimeMillis() + 
Duration.ofMinutes(2).toMillis();
         while (System.currentTimeMillis() < deadline) {
+            assertFlussSourceInsertNotFailed();
             try {
                 List<String> actualRows = fetchFlussTableRows(sourceDatabase, 
"products", 3);
                 if (actualRows.containsAll(expectedRows)
@@ -689,6 +664,23 @@ class ExistingTableSchemaExpansionE2eITCase extends 
PipelineTestEnvironment {
                 "Source Fluss table was not populated with the expected rows 
within 2 minutes.");
     }
 
+    /**
+     * The SQL client submits batch DML and returns right away, so a job that 
dies afterwards stays
+     * invisible to {@code execInContainer}'s exit code. Surface it here 
instead of letting the
+     * read-back loop below time out with a misleading message.
+     */
+    private void assertFlussSourceInsertNotFailed() throws Exception {
+        for (JobStatusMessage job : getRestClusterClient().listJobs().get(10, 
TimeUnit.SECONDS)) {
+            if (job.getJobState() == JobStatus.FAILED) {
+                throw new IllegalStateException(
+                        String.format(
+                                "Populating the Fluss source table failed: job 
%s (%s) ended with "
+                                        + "status FAILED.",
+                                job.getJobName(), job.getJobId()));
+            }
+        }
+    }
+
     /** Runs a script against the Paimon catalog, whose connector must be 
passed explicitly. */
     private void executePaimonSql(String sql, String scriptName) throws 
Exception {
         String containerSqlPath = sharedVolume.toString() + "/" + scriptName + 
".sql";
@@ -703,6 +695,13 @@ class ExistingTableSchemaExpansionE2eITCase extends 
PipelineTestEnvironment {
                         "-f",
                         containerSqlPath);
         checkSqlResult(result, scriptName);
+        // The SQL client creates catalog artifacts (databases, tables, schema 
files) as the
+        // JobManager container's user, while the pipeline's sink and schema 
operator run in the
+        // TaskManager container and must create or alter files under the same 
shared volume.
+        // Widen permissions so the TaskManager can write those artifacts 
atomically; otherwise
+        // applyCreateTable/applyAddColumn fails with "Mkdirs failed" or 
"Permission denied" and
+        // the job dies without writing any record.
+        runInContainerAsRoot(jobManager, "chmod", "-R", "a+rwX", 
sharedVolume.toString());
     }
 
     /** Runs a script against the Fluss catalog, whose connector already sits 
in Flink's lib. */
diff --git 
a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/common/ExistingTableSchemaExpander.java
 
b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/common/ExistingTableSchemaExpander.java
index 1e8ee4b37..a91044997 100644
--- 
a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/common/ExistingTableSchemaExpander.java
+++ 
b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/common/ExistingTableSchemaExpander.java
@@ -98,11 +98,17 @@ public class ExistingTableSchemaExpander {
     /**
      * Handles the initial {@link CreateTableEvent} for an existing target 
table.
      *
-     * @return whether the caller should proceed to apply the original {@link 
CreateTableEvent} to
-     *     the sink. {@code CHECK} mode returns {@code false} after a 
successful check so that no
-     *     external DDL is issued by the pipeline.
+     * <p>This is a two-phase contract: the expander first tries to 
check/expand the existing target
+     * table, then signals whether the sink should still apply the original 
{@link
+     * CreateTableEvent}.
+     *
+     * @return {@code true} if the caller should proceed to apply the original 
{@link
+     *     CreateTableEvent} to the sink; {@code false} means the framework 
has fully handled this
+     *     event and the original {@link CreateTableEvent} must not be 
applied. {@code CHECK} mode
+     *     returns {@code false} after a successful check so that no external 
DDL is issued by the
+     *     pipeline.
      */
-    public boolean expand(CreateTableEvent createTableEvent) {
+    public boolean handleExistingTableCreation(CreateTableEvent 
createTableEvent) {
         if (mode == ExistingTableSchemaExpansionMode.CHECK) {
             // CHECK guards the initial table state and runs regardless of 
schema.change.behavior.
             checkCompatibility(createTableEvent);
@@ -120,7 +126,7 @@ public class ExistingTableSchemaExpander {
             case EXPAND:
                 expandStrictly(createTableEvent);
                 return true;
-            case OFF:
+            case DISABLED:
             default:
                 return true;
         }
@@ -150,35 +156,70 @@ public class ExistingTableSchemaExpander {
     }
 
     private void tryExpand(CreateTableEvent createTableEvent) {
+        // Phase 1 - probe the target table. Failures here are "cannot expand 
safely" cases: the
+        // connector lacks DDL capability, or the target table does not exist 
yet. Delegating to the
+        // sink's original behavior is safe because no derived DDL has been 
issued.
+        if (!supportsAnyExpansionDdl()) {
+            LOG.info(
+                    "Neither ADD_COLUMN nor ALTER_COLUMN_TYPE is enabled or 
supported for target table {}. Delegating schema handling to the sink.",
+                    createTableEvent.tableId());
+            return;
+        }
+        Optional<Schema> targetSchema;
         try {
-            if (!supportsAnyExpansionDdl()) {
-                LOG.info(
-                        "Neither ADD_COLUMN nor ALTER_COLUMN_TYPE is enabled 
or supported for target table {}. Delegating schema handling to the sink.",
-                        createTableEvent.tableId());
-                return;
-            }
-            Optional<Schema> targetSchema = 
queryTargetSchema(createTableEvent.tableId());
-            if (!targetSchema.isPresent()) {
-                LOG.info(
-                        "Target table {} does not exist. Delegating table 
creation to the sink.",
-                        createTableEvent.tableId());
-                return;
-            }
-            ExpansionPlan plan = analyze(createTableEvent, targetSchema.get());
-            for (String incompatibility : plan.incompatibilities) {
-                LOG.warn(
-                        "Target table {} has an unsupported difference: {}. 
Delegating it to the sink.",
-                        createTableEvent.tableId(),
-                        incompatibility);
-            }
-            applyPlan(createTableEvent, plan);
-            verifyExpansion(createTableEvent, plan, false);
+            targetSchema = queryTargetSchema(createTableEvent.tableId());
         } catch (Exception e) {
             LOG.warn(
-                    "Best-effort schema expansion failed for existing target 
table {}. Delegating schema handling to the sink.",
+                    "Failed to read the existing target table {} before 
expansion. Delegating schema handling to the sink.",
                     createTableEvent.tableId(),
                     e);
+            return;
+        }
+        if (!targetSchema.isPresent()) {
+            LOG.info(
+                    "Target table {} does not exist. Delegating table creation 
to the sink.",
+                    createTableEvent.tableId());
+            return;
         }
+
+        ExpansionPlan plan = analyze(createTableEvent, targetSchema.get());
+        for (String incompatibility : plan.incompatibilities) {
+            LOG.warn(
+                    "Target table {} has an unsupported difference: {}. 
Delegating it to the sink.",
+                    createTableEvent.tableId(),
+                    incompatibility);
+        }
+
+        // The plan needs repair DDL, but the sink cannot perform the specific 
required event type.
+        // This is a capability gap, not a transient failure, so delegate to 
the sink instead of
+        // failing the job (mirrors applyPlan's capability checks, which throw 
for EXPAND mode).
+        if (!plan.columnsToAdd.isEmpty()
+                && 
!supportsSchemaEvolutionType(SchemaChangeEventType.ADD_COLUMN)) {
+            LOG.warn(
+                    "Target table {} is missing columns {}, but ADD_COLUMN is 
not enabled or supported by the sink. Delegating schema handling to the sink.",
+                    createTableEvent.tableId(),
+                    getColumnNames(plan.columnsToAdd));
+            return;
+        }
+        if (!plan.columnsToWiden.isEmpty()
+                && 
!supportsSchemaEvolutionType(SchemaChangeEventType.ALTER_COLUMN_TYPE)) {
+            LOG.warn(
+                    "Target table {} has narrow columns {}, but 
ALTER_COLUMN_TYPE is not enabled or supported by the sink. Delegating schema 
handling to the sink.",
+                    createTableEvent.tableId(),
+                    plan.columnsToWiden.keySet());
+            return;
+        }
+
+        // Phase 2 - apply and verify. At this point the expander has 
identified supportable
+        // differences and is about to issue derived DDL. A failure here 
(transient network/catalog
+        // error, DDL execution failure, or read-back verification failure) 
must propagate so the
+        // job fails over and the idempotent expander retries; otherwise the 
original
+        // CreateTableEvent
+        // would be applied to an existing table (typically a no-op) and the 
missing columns would
+        // be
+        // silently dropped forever.
+        applyPlan(createTableEvent, plan);
+        verifyExpansion(createTableEvent, plan, false);
     }
 
     private void expandStrictly(CreateTableEvent createTableEvent) {
@@ -231,7 +272,7 @@ public class ExistingTableSchemaExpander {
         if (!repairSuggestions.isEmpty()) {
             message +=
                     String.format(
-                            "\nSuggested repair statements (adjust to the 
target system's DDL dialect):%s",
+                            "\nSuggested repair SQL templates (review and 
adjust to the target connector's DDL dialect before execution):%s",
                             repairSuggestions);
         }
         return new SchemaEvolveException(createTableEvent, message);
diff --git 
a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/common/SchemaRegistry.java
 
b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/common/SchemaRegistry.java
index d8ea2f345..35fceefd6 100644
--- 
a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/common/SchemaRegistry.java
+++ 
b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/common/SchemaRegistry.java
@@ -156,7 +156,7 @@ public abstract class SchemaRegistry implements 
OperatorCoordinator, Coordinatio
         this.router = new TableIdRouter(routingRules, routeMode);
         // TRY_EXPAND/EXPAND never run under IGNORE/EXCEPTION, so skip their 
initialization there.
         // CHECK guards the initial table state and initializes regardless of 
the behavior.
-        if (existingTableSchemaExpansionMode != 
ExistingTableSchemaExpansionMode.OFF
+        if (existingTableSchemaExpansionMode != 
ExistingTableSchemaExpansionMode.DISABLED
                 && (existingTableSchemaExpansionMode == 
ExistingTableSchemaExpansionMode.CHECK
                         || (behavior != SchemaChangeBehavior.IGNORE
                                 && behavior != 
SchemaChangeBehavior.EXCEPTION))) {
@@ -184,11 +184,7 @@ public abstract class SchemaRegistry implements 
OperatorCoordinator, Coordinatio
                 String.format(
                         "Existing target table schema expansion is enabled 
with mode %s, but MetadataApplier %s does not support it.",
                         existingTableSchemaExpansionMode, 
metadataApplier.getClass().getName());
-        if (existingTableSchemaExpansionMode == 
ExistingTableSchemaExpansionMode.TRY_EXPAND) {
-            LOG.warn("{}. The sink's original schema handling will be used.", 
message);
-        } else {
-            throw new FlinkRuntimeException(message);
-        }
+        throw new FlinkRuntimeException(message);
     }
 
     private void handleExpansionInitFailure(Exception cause) {
@@ -196,11 +192,7 @@ public abstract class SchemaRegistry implements 
OperatorCoordinator, Coordinatio
                 String.format(
                         "Failed to initialize existing target table schema 
expansion with mode %s.",
                         existingTableSchemaExpansionMode);
-        if (existingTableSchemaExpansionMode == 
ExistingTableSchemaExpansionMode.TRY_EXPAND) {
-            LOG.warn("{}. The sink's original schema handling will be used.", 
message, cause);
-        } else {
-            throw new FlinkRuntimeException(message, cause);
-        }
+        throw new FlinkRuntimeException(message, cause);
     }
 
     /**
@@ -211,7 +203,8 @@ public abstract class SchemaRegistry implements 
OperatorCoordinator, Coordinatio
      */
     protected boolean expandExistingTableSchemaIfNeeded(SchemaChangeEvent 
schemaChangeEvent) {
         if (existingTableSchemaExpander != null && schemaChangeEvent 
instanceof CreateTableEvent) {
-            return existingTableSchemaExpander.expand((CreateTableEvent) 
schemaChangeEvent);
+            return existingTableSchemaExpander.handleExistingTableCreation(
+                    (CreateTableEvent) schemaChangeEvent);
         }
         return true;
     }
diff --git 
a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/distributed/SchemaCoordinator.java
 
b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/distributed/SchemaCoordinator.java
index 0f3822355..732f3ac39 100755
--- 
a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/distributed/SchemaCoordinator.java
+++ 
b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/distributed/SchemaCoordinator.java
@@ -123,7 +123,7 @@ public class SchemaCoordinator extends SchemaRegistry {
                 routingRules,
                 routeMode,
                 schemaChangeBehavior,
-                ExistingTableSchemaExpansionMode.OFF,
+                ExistingTableSchemaExpansionMode.DISABLED,
                 rpcTimeout);
     }
 
diff --git 
a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/distributed/SchemaCoordinatorProvider.java
 
b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/distributed/SchemaCoordinatorProvider.java
index 2834c0fd2..3dd95930f 100755
--- 
a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/distributed/SchemaCoordinatorProvider.java
+++ 
b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/distributed/SchemaCoordinatorProvider.java
@@ -61,7 +61,7 @@ public class SchemaCoordinatorProvider implements 
OperatorCoordinator.Provider {
                 routingRules,
                 routeMode,
                 schemaChangeBehavior,
-                ExistingTableSchemaExpansionMode.OFF,
+                ExistingTableSchemaExpansionMode.DISABLED,
                 rpcTimeout);
     }
 
diff --git 
a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/distributed/SchemaOperatorFactory.java
 
b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/distributed/SchemaOperatorFactory.java
index a17f3da72..1169bb469 100755
--- 
a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/distributed/SchemaOperatorFactory.java
+++ 
b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/distributed/SchemaOperatorFactory.java
@@ -61,7 +61,7 @@ public class SchemaOperatorFactory extends 
SimpleOperatorFactory<Event>
                 routeMode,
                 rpcTimeout,
                 schemaChangeBehavior,
-                ExistingTableSchemaExpansionMode.OFF,
+                ExistingTableSchemaExpansionMode.DISABLED,
                 timezone);
     }
 
diff --git 
a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/regular/BatchSchemaOperator.java
 
b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/regular/BatchSchemaOperator.java
index 1545d1950..e830b1f2a 100644
--- 
a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/regular/BatchSchemaOperator.java
+++ 
b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/regular/BatchSchemaOperator.java
@@ -23,16 +23,13 @@ import org.apache.flink.cdc.common.event.DataChangeEvent;
 import org.apache.flink.cdc.common.event.Event;
 import org.apache.flink.cdc.common.event.SchemaChangeEvent;
 import org.apache.flink.cdc.common.event.TableId;
-import org.apache.flink.cdc.common.pipeline.ExistingTableSchemaExpansionMode;
 import org.apache.flink.cdc.common.pipeline.RouteMode;
 import org.apache.flink.cdc.common.pipeline.SchemaChangeBehavior;
 import org.apache.flink.cdc.common.route.RouteRule;
 import org.apache.flink.cdc.common.route.TableIdRouter;
 import org.apache.flink.cdc.common.schema.Schema;
-import org.apache.flink.cdc.common.sink.ExistingTableSchemaExpansionSupport;
 import org.apache.flink.cdc.common.sink.MetadataApplier;
 import org.apache.flink.cdc.runtime.operators.AbstractStreamOperatorAdapter;
-import 
org.apache.flink.cdc.runtime.operators.schema.common.ExistingTableSchemaExpander;
 import org.apache.flink.cdc.runtime.operators.schema.common.SchemaDerivator;
 import org.apache.flink.cdc.runtime.operators.schema.common.SchemaManager;
 import org.apache.flink.streaming.api.graph.StreamConfig;
@@ -41,7 +38,6 @@ import 
org.apache.flink.streaming.api.operators.OneInputStreamOperator;
 import org.apache.flink.streaming.api.operators.Output;
 import org.apache.flink.streaming.runtime.streamrecord.StreamRecord;
 import org.apache.flink.streaming.runtime.tasks.StreamTask;
-import org.apache.flink.util.FlinkRuntimeException;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -50,7 +46,6 @@ import java.io.Serializable;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Optional;
 import java.util.stream.Collectors;
 
 /** The operator will apply create table event and router mapper in batch 
mode. */
@@ -65,15 +60,12 @@ public class BatchSchemaOperator extends 
AbstractStreamOperatorAdapter<Event>
     private final String timezone;
     private final List<RouteRule> routingRules;
     private final RouteMode routeMode;
-    private final SchemaChangeBehavior schemaChangeBehavior;
-    private final ExistingTableSchemaExpansionMode 
existingTableSchemaExpansionMode;
 
     // Transient fields that are set during open()
     private transient volatile Map<TableId, Schema> originalSchemaMap;
     private transient volatile Map<TableId, Schema> evolvedSchemaMap;
     private transient TableIdRouter router;
     private transient SchemaDerivator derivator;
-    private transient ExistingTableSchemaExpander existingTableSchemaExpander;
     protected transient SchemaManager schemaManager;
     protected MetadataApplier metadataApplier;
     private boolean alreadyMergedCreateTableTables = false;
@@ -83,29 +75,11 @@ public class BatchSchemaOperator extends 
AbstractStreamOperatorAdapter<Event>
             RouteMode routeMode,
             MetadataApplier metadataApplier,
             String timezone) {
-        this(
-                routingRules,
-                routeMode,
-                metadataApplier,
-                SchemaChangeBehavior.IGNORE,
-                ExistingTableSchemaExpansionMode.OFF,
-                timezone);
-    }
-
-    public BatchSchemaOperator(
-            List<RouteRule> routingRules,
-            RouteMode routeMode,
-            MetadataApplier metadataApplier,
-            SchemaChangeBehavior schemaChangeBehavior,
-            ExistingTableSchemaExpansionMode existingTableSchemaExpansionMode,
-            String timezone) {
         this.chainingStrategy = ChainingStrategy.ALWAYS;
         this.timezone = timezone;
         this.routingRules = routingRules;
         this.routeMode = routeMode;
         this.metadataApplier = metadataApplier;
-        this.schemaChangeBehavior = schemaChangeBehavior;
-        this.existingTableSchemaExpansionMode = 
existingTableSchemaExpansionMode;
     }
 
     @Override
@@ -124,48 +98,6 @@ public class BatchSchemaOperator extends 
AbstractStreamOperatorAdapter<Event>
         this.router = new TableIdRouter(routingRules, routeMode);
         this.derivator = new SchemaDerivator();
         this.schemaManager = new SchemaManager(SchemaChangeBehavior.IGNORE);
-        // TRY_EXPAND/EXPAND never run under IGNORE/EXCEPTION, so skip their 
initialization there.
-        // CHECK guards the initial table state and initializes regardless of 
the behavior.
-        if (existingTableSchemaExpansionMode != 
ExistingTableSchemaExpansionMode.OFF
-                && (existingTableSchemaExpansionMode == 
ExistingTableSchemaExpansionMode.CHECK
-                        || (schemaChangeBehavior != SchemaChangeBehavior.IGNORE
-                                && schemaChangeBehavior != 
SchemaChangeBehavior.EXCEPTION))) {
-            try {
-                Optional<ExistingTableSchemaExpansionSupport> supportOptional =
-                        
metadataApplier.getExistingTableSchemaExpansionSupport();
-                if (supportOptional.isPresent()) {
-                    this.existingTableSchemaExpander =
-                            new ExistingTableSchemaExpander(
-                                    metadataApplier,
-                                    supportOptional.get(),
-                                    schemaChangeBehavior,
-                                    existingTableSchemaExpansionMode);
-                } else {
-                    String message =
-                            String.format(
-                                    "Existing target table schema expansion is 
enabled with mode %s, but MetadataApplier %s does not support it.",
-                                    existingTableSchemaExpansionMode,
-                                    metadataApplier.getClass().getName());
-                    if (existingTableSchemaExpansionMode
-                            == ExistingTableSchemaExpansionMode.TRY_EXPAND) {
-                        LOG.warn("{}. The sink's original schema handling will 
be used.", message);
-                    } else {
-                        throw new FlinkRuntimeException(message);
-                    }
-                }
-            } catch (Exception e) {
-                String message =
-                        String.format(
-                                "Failed to initialize existing target table 
schema expansion with mode %s.",
-                                existingTableSchemaExpansionMode);
-                if (existingTableSchemaExpansionMode
-                        == ExistingTableSchemaExpansionMode.TRY_EXPAND) {
-                    LOG.warn("{}. The sink's original schema handling will be 
used.", message, e);
-                } else {
-                    throw new FlinkRuntimeException(message, e);
-                }
-            }
-        }
     }
 
     /**
@@ -237,15 +169,8 @@ public class BatchSchemaOperator extends 
AbstractStreamOperatorAdapter<Event>
     }
 
     private boolean applyAndUpdateEvolvedSchemaChange(SchemaChangeEvent 
schemaChangeEvent) {
-        boolean shouldApplyOriginalCreateTable = true;
-        if (existingTableSchemaExpander != null && schemaChangeEvent 
instanceof CreateTableEvent) {
-            shouldApplyOriginalCreateTable =
-                    existingTableSchemaExpander.expand((CreateTableEvent) 
schemaChangeEvent);
-        }
         try {
-            if (shouldApplyOriginalCreateTable) {
-                metadataApplier.applySchemaChange(schemaChangeEvent);
-            }
+            metadataApplier.applySchemaChange(schemaChangeEvent);
             schemaManager.applyEvolvedSchemaChange(schemaChangeEvent);
             LOG.info(
                     "Successfully applied schema change event {} to external 
system.",
diff --git 
a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/regular/SchemaCoordinator.java
 
b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/regular/SchemaCoordinator.java
index e82117748..c50fbc894 100644
--- 
a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/regular/SchemaCoordinator.java
+++ 
b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/regular/SchemaCoordinator.java
@@ -114,7 +114,7 @@ public class SchemaCoordinator extends SchemaRegistry {
                 routes,
                 routeMode,
                 schemaChangeBehavior,
-                ExistingTableSchemaExpansionMode.OFF,
+                ExistingTableSchemaExpansionMode.DISABLED,
                 rpcTimeout);
     }
 
diff --git 
a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/regular/SchemaCoordinatorProvider.java
 
b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/regular/SchemaCoordinatorProvider.java
index 5c3df0450..2b9abbc68 100644
--- 
a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/regular/SchemaCoordinatorProvider.java
+++ 
b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/regular/SchemaCoordinatorProvider.java
@@ -61,7 +61,7 @@ public class SchemaCoordinatorProvider implements 
OperatorCoordinator.Provider {
                 routingRules,
                 routeMode,
                 schemaChangeBehavior,
-                ExistingTableSchemaExpansionMode.OFF,
+                ExistingTableSchemaExpansionMode.DISABLED,
                 rpcTimeout);
     }
 
diff --git 
a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/regular/SchemaOperatorFactory.java
 
b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/regular/SchemaOperatorFactory.java
index c57d159e2..a023ddd92 100644
--- 
a/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/regular/SchemaOperatorFactory.java
+++ 
b/flink-cdc-runtime/src/main/java/org/apache/flink/cdc/runtime/operators/schema/regular/SchemaOperatorFactory.java
@@ -60,7 +60,7 @@ public class SchemaOperatorFactory extends 
SimpleOperatorFactory<Event>
                 routeMode,
                 rpcTimeout,
                 schemaChangeBehavior,
-                ExistingTableSchemaExpansionMode.OFF,
+                ExistingTableSchemaExpansionMode.DISABLED,
                 timezone);
     }
 
diff --git 
a/flink-cdc-runtime/src/test/java/org/apache/flink/cdc/runtime/operators/schema/common/ExistingTableSchemaExpanderTest.java
 
b/flink-cdc-runtime/src/test/java/org/apache/flink/cdc/runtime/operators/schema/common/ExistingTableSchemaExpanderTest.java
index 7584d9307..e0f30eba4 100644
--- 
a/flink-cdc-runtime/src/test/java/org/apache/flink/cdc/runtime/operators/schema/common/ExistingTableSchemaExpanderTest.java
+++ 
b/flink-cdc-runtime/src/test/java/org/apache/flink/cdc/runtime/operators/schema/common/ExistingTableSchemaExpanderTest.java
@@ -69,7 +69,7 @@ class ExistingTableSchemaExpanderTest {
                 new TestingExistingTableSchemaExpansionSupport(null);
 
         new ExistingTableSchemaExpander(applier, applier, 
SchemaChangeBehavior.LENIENT)
-                .expand(eventWithMissingColumn());
+                .handleExistingTableCreation(eventWithMissingColumn());
         assertThat(applier.queryCalls).isOne();
         assertThat(applier.appliedEvents).isEmpty();
     }
@@ -86,8 +86,8 @@ class ExistingTableSchemaExpanderTest {
                         Column.physicalColumn("id", DataTypes.INT()),
                         Column.physicalColumn("name", 
DataTypes.STRING().notNull()));
 
-        expander.expand(event);
-        expander.expand(event);
+        expander.handleExistingTableCreation(event);
+        expander.handleExistingTableCreation(event);
 
         assertThat(applier.appliedEvents).hasSize(1);
         AddColumnEvent addColumnEvent = (AddColumnEvent) 
applier.appliedEvents.get(0);
@@ -111,7 +111,7 @@ class ExistingTableSchemaExpanderTest {
                         .build();
 
         new ExistingTableSchemaExpander(applier, applier, 
SchemaChangeBehavior.LENIENT)
-                .expand(new CreateTableEvent(TABLE_ID, pipelineSchema));
+                .handleExistingTableCreation(new CreateTableEvent(TABLE_ID, 
pipelineSchema));
         assertThat(applier.appliedEvents).isEmpty();
     }
 
@@ -122,7 +122,8 @@ class ExistingTableSchemaExpanderTest {
                         schema(Column.physicalColumn("value", 
DataTypes.BIGINT())));
 
         new ExistingTableSchemaExpander(applier, applier, 
SchemaChangeBehavior.LENIENT)
-                .expand(createTableEvent(Column.physicalColumn("value", 
DataTypes.INT())));
+                .handleExistingTableCreation(
+                        createTableEvent(Column.physicalColumn("value", 
DataTypes.INT())));
         assertThat(applier.appliedEvents).isEmpty();
     }
 
@@ -136,8 +137,8 @@ class ExistingTableSchemaExpanderTest {
         CreateTableEvent event =
                 createTableEvent(Column.physicalColumn("value", 
DataTypes.BIGINT()));
 
-        expander.expand(event);
-        expander.expand(event);
+        expander.handleExistingTableCreation(event);
+        expander.handleExistingTableCreation(event);
 
         
assertThat(applier.appliedEvents).singleElement().isInstanceOf(AlterColumnTypeEvent.class);
         AlterColumnTypeEvent alterColumnTypeEvent =
@@ -164,7 +165,7 @@ class ExistingTableSchemaExpanderTest {
                 new TestingExistingTableSchemaExpansionSupport(targetSchema);
 
         new ExistingTableSchemaExpander(applier, applier, 
SchemaChangeBehavior.LENIENT)
-                .expand(new CreateTableEvent(TABLE_ID, pipelineSchema));
+                .handleExistingTableCreation(new CreateTableEvent(TABLE_ID, 
pipelineSchema));
         assertThat(applier.appliedEvents).isEmpty();
     }
 
@@ -175,7 +176,7 @@ class ExistingTableSchemaExpanderTest {
                         schema(Column.physicalColumn("amount", 
DataTypes.DECIMAL(12, 2))));
 
         new ExistingTableSchemaExpander(applier, applier, 
SchemaChangeBehavior.LENIENT)
-                .expand(
+                .handleExistingTableCreation(
                         createTableEvent(
                                 Column.physicalColumn("amount", 
DataTypes.DECIMAL(12, 4))));
 
@@ -193,7 +194,8 @@ class ExistingTableSchemaExpanderTest {
                         schema(Column.physicalColumn("value", targetType)));
 
         new ExistingTableSchemaExpander(applier, applier, 
SchemaChangeBehavior.LENIENT)
-                .expand(createTableEvent(Column.physicalColumn("value", 
pipelineType)));
+                .handleExistingTableCreation(
+                        createTableEvent(Column.physicalColumn("value", 
pipelineType)));
 
         AlterColumnTypeEvent event = (AlterColumnTypeEvent) 
applier.appliedEvents.get(0);
         assertThat(event.getTypeMapping()).containsExactly(Map.entry("value", 
expectedWidenedType));
@@ -207,7 +209,8 @@ class ExistingTableSchemaExpanderTest {
                         schema(Column.physicalColumn("value", targetType)));
 
         new ExistingTableSchemaExpander(applier, applier, 
SchemaChangeBehavior.LENIENT)
-                .expand(createTableEvent(Column.physicalColumn("value", 
pipelineType)));
+                .handleExistingTableCreation(
+                        createTableEvent(Column.physicalColumn("value", 
pipelineType)));
         assertThat(applier.appliedEvents).isEmpty();
     }
 
@@ -218,7 +221,8 @@ class ExistingTableSchemaExpanderTest {
                         schema(Column.physicalColumn("value", 
DataTypes.BOOLEAN())));
 
         new ExistingTableSchemaExpander(applier, applier, 
SchemaChangeBehavior.LENIENT)
-                .expand(createTableEvent(Column.physicalColumn("value", 
DataTypes.TIMESTAMP())));
+                .handleExistingTableCreation(
+                        createTableEvent(Column.physicalColumn("value", 
DataTypes.TIMESTAMP())));
         assertThat(applier.appliedEvents).isEmpty();
     }
 
@@ -238,7 +242,7 @@ class ExistingTableSchemaExpanderTest {
                 };
 
         new ExistingTableSchemaExpander(applier, applier, 
SchemaChangeBehavior.LENIENT)
-                .expand(
+                .handleExistingTableCreation(
                         createTableEvent(
                                 Column.physicalColumn("event_time", 
DataTypes.TIMESTAMP(9))));
         assertThat(applier.appliedEvents).isEmpty();
@@ -261,7 +265,8 @@ class ExistingTableSchemaExpanderTest {
                                 : type;
 
         new ExistingTableSchemaExpander(applier, applier, 
SchemaChangeBehavior.LENIENT)
-                .expand(createTableEvent(Column.physicalColumn("id", 
DataTypes.BIGINT())));
+                .handleExistingTableCreation(
+                        createTableEvent(Column.physicalColumn("id", 
DataTypes.BIGINT())));
 
         assertThat(applier.appliedEvents).isEmpty();
         assertThat(applier.queryCalls).isOne();
@@ -277,7 +282,7 @@ class ExistingTableSchemaExpanderTest {
         applier.normalizationFailureColumns.add("broken");
 
         new ExistingTableSchemaExpander(applier, applier, 
SchemaChangeBehavior.LENIENT)
-                .expand(
+                .handleExistingTableCreation(
                         createTableEvent(
                                 Column.physicalColumn("broken", 
DataTypes.BIGINT()),
                                 Column.physicalColumn("id", 
DataTypes.BIGINT()),
@@ -300,7 +305,8 @@ class ExistingTableSchemaExpanderTest {
         applier.columnNameCaseSensitive = false;
 
         new ExistingTableSchemaExpander(applier, applier, 
SchemaChangeBehavior.LENIENT)
-                .expand(createTableEvent(Column.physicalColumn("value", 
DataTypes.BIGINT())));
+                .handleExistingTableCreation(
+                        createTableEvent(Column.physicalColumn("value", 
DataTypes.BIGINT())));
 
         AlterColumnTypeEvent alterColumnTypeEvent =
                 (AlterColumnTypeEvent) applier.appliedEvents.get(0);
@@ -315,7 +321,8 @@ class ExistingTableSchemaExpanderTest {
                         schema(Column.physicalColumn("VALUE", 
DataTypes.INT())));
 
         new ExistingTableSchemaExpander(applier, applier, 
SchemaChangeBehavior.LENIENT)
-                .expand(createTableEvent(Column.physicalColumn("value", 
DataTypes.BIGINT())));
+                .handleExistingTableCreation(
+                        createTableEvent(Column.physicalColumn("value", 
DataTypes.BIGINT())));
         
assertThat(applier.appliedEvents).singleElement().isInstanceOf(AddColumnEvent.class);
     }
 
@@ -329,7 +336,8 @@ class ExistingTableSchemaExpanderTest {
         applier.columnNameCaseSensitive = false;
 
         new ExistingTableSchemaExpander(applier, applier, 
SchemaChangeBehavior.LENIENT)
-                .expand(createTableEvent(Column.physicalColumn("value", 
DataTypes.BIGINT())));
+                .handleExistingTableCreation(
+                        createTableEvent(Column.physicalColumn("value", 
DataTypes.BIGINT())));
         assertThat(applier.appliedEvents).isEmpty();
     }
 
@@ -340,7 +348,8 @@ class ExistingTableSchemaExpanderTest {
                         schema(Column.physicalColumn("value", 
DataTypes.BIGINT().notNull())));
 
         new ExistingTableSchemaExpander(applier, applier, 
SchemaChangeBehavior.LENIENT)
-                .expand(createTableEvent(Column.physicalColumn("value", 
DataTypes.BIGINT())));
+                .handleExistingTableCreation(
+                        createTableEvent(Column.physicalColumn("value", 
DataTypes.BIGINT())));
         assertThat(applier.appliedEvents).isEmpty();
     }
 
@@ -354,7 +363,7 @@ class ExistingTableSchemaExpanderTest {
 
         new ExistingTableSchemaExpander(
                         addUnavailableApplier, addUnavailableApplier, 
SchemaChangeBehavior.LENIENT)
-                .expand(eventWithMissingColumn());
+                .handleExistingTableCreation(eventWithMissingColumn());
         assertThat(addUnavailableApplier.appliedEvents).isEmpty();
         assertThat(addUnavailableApplier.queryCalls).isZero();
 
@@ -368,7 +377,8 @@ class ExistingTableSchemaExpanderTest {
                         alterUnavailableApplier,
                         alterUnavailableApplier,
                         SchemaChangeBehavior.LENIENT)
-                .expand(createTableEvent(Column.physicalColumn("id", 
DataTypes.BIGINT())));
+                .handleExistingTableCreation(
+                        createTableEvent(Column.physicalColumn("id", 
DataTypes.BIGINT())));
         assertThat(alterUnavailableApplier.appliedEvents).isEmpty();
         assertThat(alterUnavailableApplier.queryCalls).isOne();
     }
@@ -381,7 +391,7 @@ class ExistingTableSchemaExpanderTest {
         applier.supportedEventTypes = 
Collections.singleton(SchemaChangeEventType.CREATE_TABLE);
 
         new ExistingTableSchemaExpander(applier, applier, 
SchemaChangeBehavior.LENIENT)
-                .expand(eventWithMissingColumn());
+                .handleExistingTableCreation(eventWithMissingColumn());
 
         assertThat(applier.queryCalls).isZero();
         assertThat(applier.appliedEvents).isEmpty();
@@ -401,7 +411,8 @@ class ExistingTableSchemaExpanderTest {
                         Column.physicalColumn("id", DataTypes.BIGINT()),
                         Column.physicalColumn("name", DataTypes.STRING()));
 
-        new ExistingTableSchemaExpander(applier, applier, 
schemaChangeBehavior).expand(event);
+        new ExistingTableSchemaExpander(applier, applier, schemaChangeBehavior)
+                .handleExistingTableCreation(event);
         assertThat(applier.appliedEvents).isEmpty();
         assertThat(applier.queryCalls).isZero();
     }
@@ -421,7 +432,7 @@ class ExistingTableSchemaExpanderTest {
                                                 normalizationFailureApplier,
                                                 normalizationFailureApplier,
                                                 SchemaChangeBehavior.LENIENT)
-                                        .expand(
+                                        .handleExistingTableCreation(
                                                 createTableEvent(
                                                         Column.physicalColumn(
                                                                 "id", 
DataTypes.BIGINT()))))
@@ -441,7 +452,10 @@ class ExistingTableSchemaExpanderTest {
                         queryFailureApplier,
                         SchemaChangeBehavior.LENIENT,
                         ExistingTableSchemaExpansionMode.EXPAND);
-        assertThatThrownBy(() -> 
queryFailureExpander.expand(eventWithMissingColumn()))
+        assertThatThrownBy(
+                        () ->
+                                
queryFailureExpander.handleExistingTableCreation(
+                                        eventWithMissingColumn()))
                 .isInstanceOf(FlinkRuntimeException.class);
         assertThat(queryFailureApplier.appliedEvents).isEmpty();
     }
@@ -459,28 +473,71 @@ class ExistingTableSchemaExpanderTest {
                                                 ddlFailureApplier,
                                                 SchemaChangeBehavior.LENIENT,
                                                 
ExistingTableSchemaExpansionMode.EXPAND)
-                                        .expand(eventWithMissingColumn()))
+                                        
.handleExistingTableCreation(eventWithMissingColumn()))
                 .isInstanceOf(FlinkRuntimeException.class);
         assertThat(ddlFailureApplier.queryCalls).isOne();
     }
 
     @Test
-    void testDelegatesToSinkOnUnsupportedDdlFailure() {
+    void testTryExpandFailsFastOnUnsupportedDdlFailure() {
         TestingExistingTableSchemaExpansionSupport unsupportedApplier =
                 new TestingExistingTableSchemaExpansionSupport(
                         schema(Column.physicalColumn("id", DataTypes.INT())));
         
unsupportedApplier.unsupportedEventTypes.add(SchemaChangeEventType.ADD_COLUMN);
-        assertThatCode(
+        assertThatThrownBy(
                         () ->
                                 new ExistingTableSchemaExpander(
                                                 unsupportedApplier,
                                                 unsupportedApplier,
                                                 SchemaChangeBehavior.LENIENT)
-                                        .expand(eventWithMissingColumn()))
-                .doesNotThrowAnyException();
+                                        
.handleExistingTableCreation(eventWithMissingColumn()))
+                .isInstanceOf(UnsupportedSchemaChangeEventException.class);
         assertThat(unsupportedApplier.appliedEvents).isEmpty();
     }
 
+    @Test
+    void testTryExpandFailsFastOnTransientDdlFailure() {
+        // After supportable differences are identified, a transient DDL 
failure must propagate so
+        // the job fails over and retries, instead of silently delegating to 
the sink and dropping
+        // the missing columns forever.
+        TestingExistingTableSchemaExpansionSupport ddlFailureApplier =
+                new TestingExistingTableSchemaExpansionSupport(
+                        schema(Column.physicalColumn("id", DataTypes.INT())));
+        
ddlFailureApplier.failedEventTypes.add(SchemaChangeEventType.ADD_COLUMN);
+
+        assertThatThrownBy(
+                        () ->
+                                new ExistingTableSchemaExpander(
+                                                ddlFailureApplier,
+                                                ddlFailureApplier,
+                                                SchemaChangeBehavior.LENIENT,
+                                                
ExistingTableSchemaExpansionMode.TRY_EXPAND)
+                                        
.handleExistingTableCreation(eventWithMissingColumn()))
+                .isInstanceOf(SchemaEvolveException.class);
+        assertThat(ddlFailureApplier.appliedEvents).isEmpty();
+    }
+
+    @Test
+    void testTryExpandFailsFastOnReadBackQueryFailure() {
+        // The initial read succeeds and derived DDL is issued, but the 
read-back verification query
+        // fails transiently. This must propagate for failover instead of 
being swallowed.
+        TestingExistingTableSchemaExpansionSupport readBackFailureApplier =
+                new TestingExistingTableSchemaExpansionSupport(
+                        schema(Column.physicalColumn("id", DataTypes.INT())));
+        readBackFailureApplier.queryFailureAfterCalls = 1;
+
+        assertThatThrownBy(
+                        () ->
+                                new ExistingTableSchemaExpander(
+                                                readBackFailureApplier,
+                                                readBackFailureApplier,
+                                                SchemaChangeBehavior.LENIENT,
+                                                
ExistingTableSchemaExpansionMode.TRY_EXPAND)
+                                        
.handleExistingTableCreation(eventWithMissingColumn()))
+                .isInstanceOf(FlinkRuntimeException.class);
+        assertThat(readBackFailureApplier.appliedEvents).hasSize(1);
+    }
+
     @Test
     void testVerifiesExpansionByReadingBackTargetSchema() {
         TestingExistingTableSchemaExpansionSupport unchangedTargetApplier =
@@ -492,7 +549,7 @@ class ExistingTableSchemaExpanderTest {
                         unchangedTargetApplier,
                         unchangedTargetApplier,
                         SchemaChangeBehavior.LENIENT)
-                .expand(eventWithMissingColumn());
+                .handleExistingTableCreation(eventWithMissingColumn());
         assertThat(unchangedTargetApplier.appliedEvents)
                 .singleElement()
                 .isInstanceOf(AddColumnEvent.class);
@@ -508,7 +565,8 @@ class ExistingTableSchemaExpanderTest {
                         unchangedTargetTypeApplier,
                         unchangedTargetTypeApplier,
                         SchemaChangeBehavior.LENIENT)
-                .expand(createTableEvent(Column.physicalColumn("id", 
DataTypes.BIGINT())));
+                .handleExistingTableCreation(
+                        createTableEvent(Column.physicalColumn("id", 
DataTypes.BIGINT())));
         assertThat(unchangedTargetTypeApplier.appliedEvents)
                 .singleElement()
                 .isInstanceOf(AlterColumnTypeEvent.class);
@@ -534,7 +592,9 @@ class ExistingTableSchemaExpanderTest {
                         applier,
                         SchemaChangeBehavior.LENIENT,
                         ExistingTableSchemaExpansionMode.CHECK);
-        
assertThat(expander.expand(createTableEvent(Column.physicalColumn("id", 
DataTypes.INT()))))
+        assertThat(
+                        expander.handleExistingTableCreation(
+                                createTableEvent(Column.physicalColumn("id", 
DataTypes.INT()))))
                 .isFalse();
         assertThat(applier.appliedEvents).isEmpty();
         assertThat(applier.queryCalls).isOne();
@@ -552,7 +612,10 @@ class ExistingTableSchemaExpanderTest {
                         missingColumnApplier,
                         SchemaChangeBehavior.LENIENT,
                         ExistingTableSchemaExpansionMode.CHECK);
-        assertThatThrownBy(() -> 
missingColumnExpander.expand(eventWithMissingColumn()))
+        assertThatThrownBy(
+                        () ->
+                                
missingColumnExpander.handleExistingTableCreation(
+                                        eventWithMissingColumn()))
                 .isInstanceOfSatisfying(
                         SchemaEvolveException.class,
                         e ->
@@ -574,7 +637,7 @@ class ExistingTableSchemaExpanderTest {
                         ExistingTableSchemaExpansionMode.CHECK);
         assertThatThrownBy(
                         () ->
-                                narrowColumnExpander.expand(
+                                
narrowColumnExpander.handleExistingTableCreation(
                                         createTableEvent(
                                                 Column.physicalColumn("id", 
DataTypes.INT()))))
                 .isInstanceOfSatisfying(
@@ -599,7 +662,7 @@ class ExistingTableSchemaExpanderTest {
                         applier,
                         SchemaChangeBehavior.LENIENT,
                         ExistingTableSchemaExpansionMode.EXPAND);
-        assertThatThrownBy(() -> expander.expand(eventWithMissingColumn()))
+        assertThatThrownBy(() -> 
expander.handleExistingTableCreation(eventWithMissingColumn()))
                 .isInstanceOfSatisfying(
                         SchemaEvolveException.class,
                         e -> 
assertThat(e.getExceptionMessage()).contains("ADD_COLUMN"));
@@ -618,7 +681,9 @@ class ExistingTableSchemaExpanderTest {
                         applier,
                         SchemaChangeBehavior.LENIENT,
                         ExistingTableSchemaExpansionMode.EXPAND);
-        
assertThat(expander.expand(createTableEvent(Column.physicalColumn("id", 
DataTypes.INT()))))
+        assertThat(
+                        expander.handleExistingTableCreation(
+                                createTableEvent(Column.physicalColumn("id", 
DataTypes.INT()))))
                 .isTrue();
         assertThat(applier.appliedEvents).isEmpty();
         assertThat(applier.queryCalls).isOne();
@@ -637,7 +702,7 @@ class ExistingTableSchemaExpanderTest {
                             applier, applier, behavior, 
ExistingTableSchemaExpansionMode.CHECK);
             assertThatThrownBy(
                             () ->
-                                    expander.expand(
+                                    expander.handleExistingTableCreation(
                                             createTableEvent(
                                                     
Column.physicalColumn("id", DataTypes.INT()))))
                     .isInstanceOf(SchemaEvolveException.class);
@@ -654,7 +719,7 @@ class ExistingTableSchemaExpanderTest {
                         applier,
                         SchemaChangeBehavior.LENIENT,
                         ExistingTableSchemaExpansionMode.CHECK);
-        assertThatThrownBy(() -> expander.expand(eventWithMissingColumn()))
+        assertThatThrownBy(() -> 
expander.handleExistingTableCreation(eventWithMissingColumn()))
                 .isInstanceOfSatisfying(
                         SchemaEvolveException.class,
                         e ->
@@ -679,7 +744,7 @@ class ExistingTableSchemaExpanderTest {
                         ExistingTableSchemaExpansionMode.CHECK);
         assertThatThrownBy(
                         () ->
-                                expander.expand(
+                                expander.handleExistingTableCreation(
                                         createTableEvent(
                                                 Column.physicalColumn("id", 
DataTypes.INT()),
                                                 Column.physicalColumn(
@@ -705,7 +770,7 @@ class ExistingTableSchemaExpanderTest {
                         applier,
                         SchemaChangeBehavior.LENIENT,
                         ExistingTableSchemaExpansionMode.EXPAND);
-        assertThatThrownBy(() -> expander.expand(eventWithMissingColumn()))
+        assertThatThrownBy(() -> 
expander.handleExistingTableCreation(eventWithMissingColumn()))
                 .isInstanceOfSatisfying(
                         SchemaEvolveException.class,
                         e ->
@@ -728,7 +793,7 @@ class ExistingTableSchemaExpanderTest {
                         applier,
                         SchemaChangeBehavior.LENIENT,
                         ExistingTableSchemaExpansionMode.EXPAND);
-        assertThatThrownBy(() -> expander.expand(eventWithMissingColumn()))
+        assertThatThrownBy(() -> 
expander.handleExistingTableCreation(eventWithMissingColumn()))
                 .isInstanceOfSatisfying(
                         SchemaEvolveException.class,
                         e -> 
assertThat(e.getExceptionMessage()).contains("after expansion"));
@@ -745,8 +810,8 @@ class ExistingTableSchemaExpanderTest {
                         applier,
                         applier,
                         SchemaChangeBehavior.LENIENT,
-                        ExistingTableSchemaExpansionMode.OFF);
-        assertThat(expander.expand(eventWithMissingColumn())).isTrue();
+                        ExistingTableSchemaExpansionMode.DISABLED);
+        
assertThat(expander.handleExistingTableCreation(eventWithMissingColumn())).isTrue();
         assertThat(applier.queryCalls).isZero();
         assertThat(applier.appliedEvents).isEmpty();
     }
@@ -758,7 +823,7 @@ class ExistingTableSchemaExpanderTest {
                         schema(Column.physicalColumn("id", DataTypes.INT())));
 
         new ExistingTableSchemaExpander(applier, applier, 
SchemaChangeBehavior.LENIENT)
-                .expand(
+                .handleExistingTableCreation(
                         createTableEvent(
                                 Column.physicalColumn("id", 
DataTypes.BIGINT()),
                                 Column.physicalColumn(
diff --git 
a/flink-cdc-runtime/src/test/java/org/apache/flink/cdc/runtime/operators/schema/regular/SchemaCoordinatorTest.java
 
b/flink-cdc-runtime/src/test/java/org/apache/flink/cdc/runtime/operators/schema/regular/SchemaCoordinatorTest.java
index e644b0800..c3d9e2be1 100644
--- 
a/flink-cdc-runtime/src/test/java/org/apache/flink/cdc/runtime/operators/schema/regular/SchemaCoordinatorTest.java
+++ 
b/flink-cdc-runtime/src/test/java/org/apache/flink/cdc/runtime/operators/schema/regular/SchemaCoordinatorTest.java
@@ -37,6 +37,7 @@ import 
org.apache.flink.cdc.runtime.operators.schema.regular.event.SchemaChangeR
 import 
org.apache.flink.cdc.runtime.testutils.operators.MockedOperatorCoordinatorContext;
 import org.apache.flink.runtime.jobgraph.OperatorID;
 import org.apache.flink.runtime.operators.coordination.CoordinationResponse;
+import org.apache.flink.util.FlinkRuntimeException;
 
 import org.junit.jupiter.api.Test;
 
@@ -164,6 +165,38 @@ class SchemaCoordinatorTest {
         }
     }
 
+    @Test
+    void tryExpandModeFailsWhenConnectorLacksExpansionSupport() {
+        UnsupportedExpansionMetadataApplier metadataApplier =
+                new UnsupportedExpansionMetadataApplier();
+
+        SchemaCoordinator coordinator =
+                new SchemaCoordinator(
+                        "regular-schema-coordinator",
+                        new MockedOperatorCoordinatorContext(
+                                new OperatorID(), 
Thread.currentThread().getContextClassLoader()),
+                        Executors.newSingleThreadExecutor(),
+                        metadataApplier,
+                        Collections.emptyList(),
+                        RouteMode.ALL_MATCH,
+                        SchemaChangeBehavior.LENIENT,
+                        ExistingTableSchemaExpansionMode.TRY_EXPAND,
+                        Duration.ofSeconds(10));
+
+        assertThatThrownBy(coordinator::start)
+                .isInstanceOf(FlinkRuntimeException.class)
+                .hasCauseInstanceOf(FlinkRuntimeException.class)
+                .hasRootCauseMessage(
+                        "Existing target table schema expansion is enabled 
with mode TRY_EXPAND, but MetadataApplier "
+                                + 
UnsupportedExpansionMetadataApplier.class.getName()
+                                + " does not support it.");
+    }
+
+    private static final class UnsupportedExpansionMetadataApplier implements 
MetadataApplier {
+        @Override
+        public void applySchemaChange(SchemaChangeEvent schemaChangeEvent) {}
+    }
+
     private static final class ExpandingMetadataApplier
             implements MetadataApplier, ExistingTableSchemaExpansionSupport {
         private final Schema targetSchema;

Reply via email to