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

yiguolei pushed a commit to branch branch-4.2
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/branch-4.2 by this push:
     new dec5a9c68d6 branch-4.2: [improvement](fe) Improve streaming job schema 
change error messages #69725 (#70167)
dec5a9c68d6 is described below

commit dec5a9c68d6a5abda0803f1f05844584ea846fab
Author: wudi <[email protected]>
AuthorDate: Wed Nov 4 17:36:03 2026 +0800

    branch-4.2: [improvement](fe) Improve streaming job schema change error 
messages #69725 (#70167)
    
    Cherry-picked from #69725
---
 .../insert/streaming/StreamingInsertJob.java       |   4 +-
 .../StreamingInsertJobStatusTransitionTest.java    |  45 +++++++
 .../cdcclient/service/PipelineCoordinator.java     |  10 +-
 .../source/deserialize/DeserializeResult.java      |  17 ++-
 .../deserialize/MySqlDebeziumJsonDeserializer.java |  20 ++-
 .../PostgresDebeziumJsonDeserializer.java          |  10 +-
 .../PipelineCoordinatorSchemaChangeTest.java       | 135 +++++++++++++++++++++
 .../MySqlSchemaChangeDeserializeTest.java          |  43 ++++++-
 .../PostgresSchemaChangeDeserializeTest.java       |  11 +-
 .../test_streaming_mysql_job_sc_advanced.groovy    |  34 ++++--
 .../test_streaming_postgres_job_sc_advanced.groovy |  20 ++-
 11 files changed, 315 insertions(+), 34 deletions(-)

diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJob.java
 
b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJob.java
index 58296c78ec8..ed6c6bcb5d7 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJob.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJob.java
@@ -86,6 +86,7 @@ import org.apache.doris.transaction.TransactionState;
 import org.apache.doris.transaction.TxnStateChangeCallback;
 
 import com.google.common.base.Preconditions;
+import com.google.gson.Gson;
 import com.google.gson.JsonObject;
 import com.google.gson.annotations.SerializedName;
 import lombok.Getter;
@@ -114,6 +115,7 @@ import java.util.concurrent.locks.ReentrantReadWriteLock;
 public class StreamingInsertJob extends AbstractJob<StreamingJobSchedulerTask, 
Map<Object, Object>> implements
         TxnStateChangeCallback, GsonPostProcessable {
     public static final String JOB_FILE_CATALOG = "streaming_job";
+    private static final Gson ERROR_MSG_GSON = 
GsonUtils.GSON.newBuilder().disableHtmlEscaping().create();
     private long dbId;
     // Streaming job statistics, all persisted in txn attachment
     // when checkpoint image replay need Serialized
@@ -1273,7 +1275,7 @@ public class StreamingInsertJob extends 
AbstractJob<StreamingJobSchedulerTask, M
         // returning the plain FailMsg.msg string — they use a different error 
taxonomy
         // (FailMsg/CancelType) that is out of scope for this PR.
         trow.addToColumnValue(new TCell().setStringVal(failureReason == null
-                ? "" : GsonUtils.GSON.toJson(failureReason)));
+                ? "" : ERROR_MSG_GSON.toJson(failureReason)));
         trow.addToColumnValue(new TCell().setStringVal(jobRuntimeMsg == null
                 ? "" : jobRuntimeMsg));
         trow.addToColumnValue(new TCell().setStringVal(getLag()));
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJobStatusTransitionTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJobStatusTransitionTest.java
index 0cfb837bf91..096bc1ffae6 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJobStatusTransitionTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJobStatusTransitionTest.java
@@ -17,17 +17,23 @@
 
 package org.apache.doris.job.extensions.insert.streaming;
 
+import org.apache.doris.analysis.UserIdentity;
 import org.apache.doris.catalog.Env;
 import org.apache.doris.common.jmockit.Deencapsulation;
+import org.apache.doris.job.base.JobExecuteType;
+import org.apache.doris.job.base.JobExecutionConfiguration;
 import org.apache.doris.job.cdc.request.TaskFailureRequest;
 import org.apache.doris.job.common.FailureReason;
 import org.apache.doris.job.common.JobStatus;
 import org.apache.doris.job.exception.JobException;
+import org.apache.doris.job.extensions.insert.InsertJob;
 import org.apache.doris.job.offset.SourceOffsetProvider;
 import org.apache.doris.job.offset.s3.S3EventSourceOffsetProvider;
 import org.apache.doris.transaction.GlobalTransactionMgrIface;
 import org.apache.doris.transaction.TxnStateCallbackFactory;
 
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 import org.mockito.MockedStatic;
@@ -35,6 +41,7 @@ import org.mockito.Mockito;
 
 import java.util.Collections;
 import java.util.concurrent.locks.ReentrantReadWriteLock;
+import java.util.stream.Collectors;
 
 public class StreamingInsertJobStatusTransitionTest {
 
@@ -47,6 +54,44 @@ public class StreamingInsertJobStatusTransitionTest {
         return job;
     }
 
+    @Test
+    public void testErrorMsgKeepsReadableCharactersAndValidJson() {
+        String message = TaskFailureRequest.SCHEMA_CHANGE_UNSUPPORTED
+                + " Table: source.orders -> target.orders; columns 
added=[city]."
+                + " Source DDL: [ALTER TABLE orders ADD city VARCHAR(30) 
COMMENT 'city']"
+                + "\nDetails: \"quoted\", path=C:\\data";
+        String json = visibleErrorMsg(new FailureReason(message));
+
+        Assertions.assertTrue(json.contains("source.orders -> target.orders"), 
json);
+        Assertions.assertTrue(json.contains("columns added=[city]"), json);
+        Assertions.assertTrue(json.contains("COMMENT 'city'"), json);
+        Assertions.assertFalse(json.contains("\\" + "u003e"), json);
+        Assertions.assertFalse(json.contains("\\" + "u003d"), json);
+        Assertions.assertFalse(json.contains("\\" + "u0027"), json);
+        JsonObject parsed = JsonParser.parseString(json).getAsJsonObject();
+        Assertions.assertEquals("CANNOT_RESUME_ERR", 
parsed.get("code").getAsString());
+        Assertions.assertEquals(message, parsed.get("msg").getAsString());
+    }
+
+    @Test
+    public void testErrorMsgIsEmptyWithoutFailure() {
+        Assertions.assertEquals("", visibleErrorMsg(null));
+    }
+
+    private static String visibleErrorMsg(FailureReason reason) {
+        StreamingInsertJob job = newJob(JobStatus.PAUSED);
+        job.setCreateUser(UserIdentity.ROOT);
+        JobExecutionConfiguration config = new JobExecutionConfiguration();
+        config.setExecuteType(JobExecuteType.STREAMING);
+        job.setJobConfig(config);
+        job.setExecuteSql("SELECT 1");
+        job.setEncryptedSql("SELECT 1");
+        job.setFailureReason(reason);
+        int index = InsertJob.SCHEMA.stream().map(column -> column.getName())
+                .collect(Collectors.toList()).indexOf("ErrorMsg");
+        return job.getTvfInfo().getColumnValue().get(index).getStringVal();
+    }
+
 
     @Test
     public void testManualResumeToleratesSchemaChangesUntilSuccess() throws 
Exception {
diff --git 
a/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/service/PipelineCoordinator.java
 
b/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/service/PipelineCoordinator.java
index a8439409c3d..a422012b63c 100644
--- 
a/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/service/PipelineCoordinator.java
+++ 
b/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/service/PipelineCoordinator.java
@@ -79,6 +79,9 @@ import 
org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBo
 public class PipelineCoordinator {
     private static final Logger LOG = 
LoggerFactory.getLogger(PipelineCoordinator.class);
     private static final String SPLIT_ID = "splitId";
+    private static final String SCHEMA_CHANGE_RECOVERY_HINT =
+            "Review and adjust the target schema as needed. "
+                    + "RESUME confirms acceptance of unsupported changes for 
this recovery round.";
     // jobId
     private final Map<String, DorisBatchStreamLoad> batchStreamLoadMap = new 
ConcurrentHashMap<>();
     // taskId -> list of split offsets (accumulates all splits processed in 
one task)
@@ -745,8 +748,8 @@ public class PipelineCoordinator {
                     TaskFailureRequest.SCHEMA_CHANGE_UNSUPPORTED
                             + " "
                             + result.getUnsupportedReason()
-                            + ". Review the target schema. RESUME confirms 
acceptance of unsupported"
-                            + " changes for this recovery round.");
+                            + ". "
+                            + SCHEMA_CHANGE_RECOVERY_HINT);
         }
         try {
             SchemaChangeManager.validateTargetSchemas(
@@ -764,7 +767,8 @@ public class PipelineCoordinator {
                                     + result.getUnsupportedReason()
                                     + ". Doris target: "
                                     + e.getMessage()
-                                    + ". Fix the target schema, then RESUME 
the job.");
+                                    + ". "
+                                    + SCHEMA_CHANGE_RECOVERY_HINT);
             // The root message drives FE's pause classification.
             failure.addSuppressed(e);
             throw failure;
diff --git 
a/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/deserialize/DeserializeResult.java
 
b/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/deserialize/DeserializeResult.java
index c982e57a863..95aaa1e299a 100644
--- 
a/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/deserialize/DeserializeResult.java
+++ 
b/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/deserialize/DeserializeResult.java
@@ -87,7 +87,10 @@ public class DeserializeResult {
     public static DeserializeResult unsupportedSchemaChange(
             String reason,
             Map<TableId, TableChanges.TableChange> previousSchemas,
-            Map<TableId, TableChanges.TableChange> updatedSchemas) {
+            Map<TableId, TableChanges.TableChange> updatedSchemas,
+            String targetDb,
+            Map<String, String> targetTableMappings,
+            String sourceDdl) {
         StringBuilder message = new StringBuilder(reason);
         for (Map.Entry<TableId, TableChanges.TableChange> entry : 
updatedSchemas.entrySet()) {
             Table before = previousSchemas.get(entry.getKey()).getTable();
@@ -108,7 +111,14 @@ public class DeserializeResult {
                             .map(Column::name)
                             .filter(name -> after.columnWithName(name) == null)
                             .collect(Collectors.toList());
-            message.append(". Table: ").append(entry.getKey().identifier());
+            String sourceTable = entry.getKey().table();
+            String targetTable = targetTableMappings.getOrDefault(sourceTable, 
sourceTable);
+            message.append(". Table: ")
+                    .append(entry.getKey().identifier())
+                    .append(" -> ")
+                    .append(targetDb)
+                    .append(".")
+                    .append(targetTable);
             if (!added.isEmpty()) {
                 message.append("; columns added=").append(added);
             }
@@ -116,6 +126,9 @@ public class DeserializeResult {
                 message.append("; removed=").append(removed);
             }
         }
+        if (sourceDdl != null) {
+            message.append(". Source DDL: [").append(sourceDdl).append("]");
+        }
         return new DeserializeResult(
                 Type.SCHEMA_CHANGE,
                 Collections.emptyList(),
diff --git 
a/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/deserialize/MySqlDebeziumJsonDeserializer.java
 
b/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/deserialize/MySqlDebeziumJsonDeserializer.java
index a7c63e43e30..6ce826f13a4 100644
--- 
a/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/deserialize/MySqlDebeziumJsonDeserializer.java
+++ 
b/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/deserialize/MySqlDebeziumJsonDeserializer.java
@@ -124,6 +124,7 @@ public class MySqlDebeziumJsonDeserializer extends 
DebeziumJsonDeserializer {
             return DeserializeResult.schemaChange(Collections.emptyList(), 
freshSchemas);
         }
 
+        String db = context.get(Constants.DORIS_TARGET_DB);
         for (Map.Entry<TableId, TableChanges.TableChange> entry : 
freshSchemas.entrySet()) {
             if (entry.getValue().getType() != 
TableChanges.TableChangeType.ALTER) {
                 continue;
@@ -135,7 +136,7 @@ public class MySqlDebeziumJsonDeserializer extends 
DebeziumJsonDeserializer {
                     .primaryKeyColumnNames()
                     
.equals(entry.getValue().getTable().primaryKeyColumnNames())) {
                 return unsupportedSchemaChange(
-                        record, ddl, "Primary key changes are not supported", 
freshSchemas);
+                        db, record, ddl, "Primary key changes are not 
supported", freshSchemas);
             }
         }
 
@@ -148,7 +149,8 @@ public class MySqlDebeziumJsonDeserializer extends 
DebeziumJsonDeserializer {
             changes = parser.getAndClearParsedChanges();
         } catch (Exception e) {
             LOG.warn("Failed to parse MySQL schema change DDL: {}", ddl, e);
-            return unsupportedSchemaChange(record, ddl, "Cannot parse schema 
change", freshSchemas);
+            return unsupportedSchemaChange(
+                    db, record, ddl, "Cannot parse schema change", 
freshSchemas);
         }
         if (changes.isEmpty()) {
             for (Map.Entry<TableId, TableChanges.TableChange> entry : 
freshSchemas.entrySet()) {
@@ -170,7 +172,7 @@ public class MySqlDebeziumJsonDeserializer extends 
DebeziumJsonDeserializer {
                                 .collect(Collectors.toSet());
                 if (!before.equals(after)) {
                     return unsupportedSchemaChange(
-                            record, ddl, "Unrecognized column change", 
freshSchemas);
+                            db, record, ddl, "Unrecognized column change", 
freshSchemas);
                 }
             }
             return DeserializeResult.schemaChange(Collections.emptyList(), 
freshSchemas);
@@ -192,10 +194,15 @@ public class MySqlDebeziumJsonDeserializer extends 
DebeziumJsonDeserializer {
             if (excluded.contains(columnName) && 
excluded.contains(newColumnName)) {
                 continue;
             }
+            String columnChange =
+                    columnName.equals(newColumnName)
+                            ? columnName
+                            : columnName + " -> " + newColumnName;
             return unsupportedSchemaChange(
+                    db,
                     record,
                     ddl,
-                    type + " COLUMN is not supported (" + columnName + " -> " 
+ newColumnName + ")",
+                    type + " COLUMN is not supported (" + columnChange + ")",
                     freshSchemas);
         }
         if (supportedChanges.isEmpty()) {
@@ -220,7 +227,6 @@ public class MySqlDebeziumJsonDeserializer extends 
DebeziumJsonDeserializer {
             Set<String> excludedCols =
                     excludeColumnsCache.getOrDefault(tableId.table(), 
Collections.emptySet());
             String targetTable = resolveTargetTable(tableId.table());
-            String db = context.get(Constants.DORIS_TARGET_DB);
             if (change.getType() == MySqlSchemaChange.Type.ADD) {
                 String columnName = change.getColumn().name();
                 if (excludedCols.contains(columnName)) {
@@ -285,6 +291,7 @@ public class MySqlDebeziumJsonDeserializer extends 
DebeziumJsonDeserializer {
     }
 
     private DeserializeResult unsupportedSchemaChange(
+            String targetDb,
             SourceRecord record,
             String ddl,
             String reason,
@@ -295,7 +302,8 @@ public class MySqlDebeziumJsonDeserializer extends 
DebeziumJsonDeserializer {
                 reason,
                 ddl,
                 record.sourceOffset());
-        return DeserializeResult.unsupportedSchemaChange(reason, tableSchemas, 
freshSchemas);
+        return DeserializeResult.unsupportedSchemaChange(
+                reason, tableSchemas, freshSchemas, targetDb, 
targetTableMappingsCache, ddl);
     }
 
     private Tables toTables(Map<TableId, TableChanges.TableChange> schemas) {
diff --git 
a/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/deserialize/PostgresDebeziumJsonDeserializer.java
 
b/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/deserialize/PostgresDebeziumJsonDeserializer.java
index f0cc6dbaa2c..5e81cd0f23c 100644
--- 
a/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/deserialize/PostgresDebeziumJsonDeserializer.java
+++ 
b/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/source/deserialize/PostgresDebeziumJsonDeserializer.java
@@ -141,12 +141,13 @@ public class PostgresDebeziumJsonDeserializer extends 
DebeziumJsonDeserializer {
             return DeserializeResult.schemaChange(Collections.emptyList(), 
updatedSchemas);
         }
 
+        String db = context.get(Constants.DORIS_TARGET_DB);
         Set<String> excludedCols =
                 excludeColumnsCache.getOrDefault(tableId.table(), 
Collections.emptySet());
         String unsupportedReason =
                 unsupportedChangeReason(stored.getTable(), freshTable, 
excludedCols);
         if (unsupportedReason != null) {
-            return unsupportedSchemaChange(updatedSchemas, unsupportedReason);
+            return unsupportedSchemaChange(db, updatedSchemas, 
unsupportedReason);
         }
 
         List<Column> added = new ArrayList<>();
@@ -169,11 +170,11 @@ public class PostgresDebeziumJsonDeserializer extends 
DebeziumJsonDeserializer {
         dropped.removeIf(excludedCols::contains);
         if (hasAddedAndDropped && (!added.isEmpty() || !dropped.isEmpty())) {
             return unsupportedSchemaChange(
+                    db,
                     updatedSchemas,
                     "Cannot distinguish a column rename from simultaneous 
ADD/DROP");
         }
 
-        String db = context.get(Constants.DORIS_TARGET_DB);
         List<SchemaChangeOperation> schemaChanges = new ArrayList<>();
         String targetTable = resolveTargetTable(tableId.table());
 
@@ -212,8 +213,9 @@ public class PostgresDebeziumJsonDeserializer extends 
DebeziumJsonDeserializer {
     }
 
     private DeserializeResult unsupportedSchemaChange(
-            Map<TableId, TableChanges.TableChange> updatedSchemas, String 
reason) {
-        return DeserializeResult.unsupportedSchemaChange(reason, tableSchemas, 
updatedSchemas);
+            String targetDb, Map<TableId, TableChanges.TableChange> 
updatedSchemas, String reason) {
+        return DeserializeResult.unsupportedSchemaChange(
+                reason, tableSchemas, updatedSchemas, targetDb, 
targetTableMappingsCache, null);
     }
 
     private static String unsupportedChangeReason(
diff --git 
a/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/service/PipelineCoordinatorSchemaChangeTest.java
 
b/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/service/PipelineCoordinatorSchemaChangeTest.java
new file mode 100644
index 00000000000..d64e7f62961
--- /dev/null
+++ 
b/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/service/PipelineCoordinatorSchemaChangeTest.java
@@ -0,0 +1,135 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.cdcclient.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.apache.doris.cdcclient.source.deserialize.DeserializeResult;
+import org.apache.doris.job.cdc.request.WriteRecordRequest;
+
+import org.apache.commons.lang3.exception.ExceptionUtils;
+
+import com.sun.net.httpserver.HttpServer;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.sql.Types;
+import java.util.Map;
+
+import io.debezium.relational.Column;
+import io.debezium.relational.Table;
+import io.debezium.relational.TableId;
+import io.debezium.relational.history.TableChanges;
+
+class PipelineCoordinatorSchemaChangeTest {
+    private static final String RECOVERY_HINT =
+            "Review and adjust the target schema as needed. "
+                    + "RESUME confirms acceptance of unsupported changes for 
this recovery round.";
+
+    @Test
+    void initialFailureKeepsReasonAndExplainsResumeConfirmation() throws 
Exception {
+        DeserializeResult result = unsupportedChange();
+        IOException failure = validateFailure(new WriteRecordRequest(), 
result);
+
+        assertThat(ExceptionUtils.getRootCauseMessage(failure))
+                .contains("[SCHEMA_CHANGE_UNSUPPORTED]", 
result.getUnsupportedReason())
+                .endsWith(RECOVERY_HINT);
+        assertThat(failure.getSuppressed()).isEmpty();
+    }
+
+    @Test
+    void resumedFailureKeepsMissingColumnsAndConfirmation() throws Exception {
+        assertResumeFailure(
+                
"{\"code\":0,\"data\":{\"status\":200,\"properties\":[{\"name\":\"id\"}]}}",
+                "target_db.mapped_t1: missing columns [city]");
+    }
+
+    @Test
+    void resumedFailureKeepsSchemaQueryErrorAndConfirmation() throws Exception 
{
+        assertResumeFailure(
+                "{\"code\":1,\"msg\":\"schema unavailable\"}", "schema 
unavailable");
+    }
+
+    private static void assertResumeFailure(String responseBody, String 
detail) throws Exception {
+        HttpServer server = HttpServer.create(new 
InetSocketAddress("127.0.0.1", 0), 0);
+        server.createContext(
+                "/api/streaming/schema/target_db/mapped_t1",
+                exchange -> {
+                    byte[] bytes = 
responseBody.getBytes(StandardCharsets.UTF_8);
+                    exchange.sendResponseHeaders(200, bytes.length);
+                    exchange.getResponseBody().write(bytes);
+                    exchange.close();
+                });
+        server.start();
+        try {
+            WriteRecordRequest request = new WriteRecordRequest();
+            request.setFrontendAddress("127.0.0.1:" + 
server.getAddress().getPort());
+            request.setTargetDb("target_db");
+            request.setToken("token");
+            request.setJobId("123");
+            request.setConfig(Map.of("table.t1.target_table", "mapped_t1"));
+            request.setTolerateSchemaChange(true);
+            DeserializeResult result = unsupportedChange();
+
+            IOException failure = validateFailure(request, result);
+
+            // FE classifies the root message, so a nested cause must not hide 
the marker.
+            assertThat(ExceptionUtils.getRootCauseMessage(failure))
+                    .contains("[SCHEMA_CHANGE_UNSUPPORTED]", 
result.getUnsupportedReason(),
+                            "Doris target:", detail)
+                    .endsWith(RECOVERY_HINT);
+            assertThat(failure.getSuppressed()).hasSize(1);
+            
assertThat(failure.getSuppressed()[0]).hasMessageContaining(detail);
+        } finally {
+            server.stop(0);
+        }
+    }
+
+    private static IOException validateFailure(WriteRecordRequest request, 
DeserializeResult result)
+            throws Exception {
+        Method validate = PipelineCoordinator.class.getDeclaredMethod(
+                "validateUnsupportedSchemaChange", WriteRecordRequest.class, 
DeserializeResult.class);
+        validate.setAccessible(true);
+        InvocationTargetException failure = assertThrows(
+                InvocationTargetException.class,
+                () -> validate.invoke(new PipelineCoordinator(1), request, 
result));
+        assertThat(failure.getCause()).isInstanceOf(IOException.class);
+        return (IOException) failure.getCause();
+    }
+
+    private static DeserializeResult unsupportedChange() {
+        TableId tableId = new TableId("source_db", null, "t1");
+        Table before = Table.editor().tableId(tableId)
+                
.addColumn(Column.editor().name("id").type("INT").jdbcType(Types.INTEGER).create())
+                .create();
+        Table after = before.edit()
+                
.addColumn(Column.editor().name("city").type("INT").jdbcType(Types.INTEGER).create())
+                .create();
+        return DeserializeResult.unsupportedSchemaChange(
+                "MODIFY COLUMN is not supported (id)",
+                Map.of(tableId, new 
TableChanges.TableChange(TableChanges.TableChangeType.ALTER, before)),
+                Map.of(tableId, new 
TableChanges.TableChange(TableChanges.TableChangeType.ALTER, after)),
+                "target_db", Map.of("t1", "mapped_t1"),
+                "ALTER TABLE t1 ADD COLUMN city INT, MODIFY COLUMN id BIGINT");
+    }
+}
diff --git 
a/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/source/deserialize/MySqlSchemaChangeDeserializeTest.java
 
b/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/source/deserialize/MySqlSchemaChangeDeserializeTest.java
index e9655e1b8da..bde64b7246e 100644
--- 
a/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/source/deserialize/MySqlSchemaChangeDeserializeTest.java
+++ 
b/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/source/deserialize/MySqlSchemaChangeDeserializeTest.java
@@ -19,6 +19,7 @@ package org.apache.doris.cdcclient.source.deserialize;
 
 import static 
org.apache.flink.cdc.connectors.mysql.debezium.dispatcher.EventDispatcherImpl.HISTORY_RECORD_FIELD;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -60,12 +61,14 @@ class MySqlSchemaChangeDeserializeTest {
 
         DeserializeResult result =
                 deserializer.deserialize(
-                        Collections.emptyMap(),
+                        CONTEXT,
                         schemaChangeRecord(null, table("id", "city")));
 
         assertEquals(DeserializeResult.Type.SCHEMA_CHANGE, result.getType());
         assertNotNull(result.getUnsupportedReason());
         assertTrue(result.getUnsupportedReason().contains("Cannot parse schema 
change"));
+        assertTrue(result.getUnsupportedReason().contains("Table: db1.t1 -> 
doris_db.t1"));
+        assertFalse(result.getUnsupportedReason().contains("Source DDL:"));
         assertTrue(result.getSchemaChanges().isEmpty());
         assertEquals(1, result.getUpdatedSchemas().size());
         assertEquals(table("id", "city"), 
result.getUpdatedSchemas().get(TABLE).getTable());
@@ -190,7 +193,8 @@ class MySqlSchemaChangeDeserializeTest {
             assertEquals(DeserializeResult.Type.SCHEMA_CHANGE, 
result.getType());
             if ("evolve".equals(behavior)) {
                 assertNotNull(result.getUnsupportedReason());
-                assertTrue(result.getUnsupportedReason().contains("MODIFY 
COLUMN"));
+                assertTrue(result.getUnsupportedReason().contains("MODIFY 
COLUMN is not supported (city)"));
+                assertFalse(result.getUnsupportedReason().contains("city -> 
city"));
             } else {
                 assertNull(result.getUnsupportedReason());
             }
@@ -207,23 +211,52 @@ class MySqlSchemaChangeDeserializeTest {
 
     @Test
     void schemaChangeMixedAddAndSameNameChangeRequiresConfirmation() throws 
Exception {
-        Table baseline = table("id", "age");
-        Table fresh = table("id", "age", "city");
+        Table baseline = table("id", "age", "old_col");
+        Table fresh = table("id", "age", "city", "note");
         for (String alter : new String[] {"MODIFY COLUMN age INT", "CHANGE 
COLUMN age age INT"}) {
             MySqlDebeziumJsonDeserializer deserializer = 
newDeserializer(baseline);
+            deserializer.targetTableMappingsCache = Map.of("t1", "mapped_t1");
+            String ddl = "ALTER TABLE t1 ADD COLUMN city INT, " + alter
+                    + ", DROP COLUMN old_col, ADD COLUMN note INT;";
             DeserializeResult result =
                     deserializer.deserialize(
                             CONTEXT,
-                            schemaChangeRecord("ALTER TABLE t1 ADD COLUMN city 
INT, " + alter, fresh));
+                            schemaChangeRecord(ddl, fresh));
 
             assertEquals(DeserializeResult.Type.SCHEMA_CHANGE, 
result.getType());
             assertNotNull(result.getUnsupportedReason());
+            String reason = result.getUnsupportedReason();
+            assertTrue(reason.contains("COLUMN is not supported (age)"));
+            assertFalse(reason.contains("age -> age"));
+            assertTrue(reason.contains("Table: db1.t1 -> doris_db.mapped_t1"));
+            assertTrue(reason.contains("columns added=[city, note]"));
+            assertTrue(reason.contains("removed=[old_col]"));
+            assertTrue(reason.contains("Source DDL: [" + ddl + "]"));
             assertTrue(result.getSchemaChanges().isEmpty(), "mixed DDL must 
not be partially applied");
             assertEquals(fresh, 
result.getUpdatedSchemas().get(TABLE).getTable());
             assertEquals(baseline, 
deserializer.getTableSchemas().get(TABLE).getTable());
         }
     }
 
+    @Test
+    void renamedColumnRetainsBothNamesAndSourceDdl() throws Exception {
+        Table baseline = table("id", "age");
+        Table fresh = table("id", "age2");
+        for (String alter : new String[] {"CHANGE COLUMN age age2 INT", 
"RENAME COLUMN age TO age2"}) {
+            MySqlDebeziumJsonDeserializer deserializer = 
newDeserializer(baseline);
+            String ddl = "ALTER TABLE t1 " + alter;
+
+            DeserializeResult result = deserializer.deserialize(CONTEXT, 
schemaChangeRecord(ddl, fresh));
+
+            assertNotNull(result.getUnsupportedReason());
+            assertTrue(result.getUnsupportedReason().contains("COLUMN is not 
supported (age -> age2)"));
+            assertTrue(result.getUnsupportedReason().contains("Table: db1.t1 
-> doris_db.t1"));
+            assertTrue(result.getUnsupportedReason().contains("Source DDL: [" 
+ ddl + "]"));
+            assertTrue(result.getSchemaChanges().isEmpty());
+            assertEquals(baseline, 
deserializer.getTableSchemas().get(TABLE).getTable());
+        }
+    }
+
     @Test
     void ignoreReturnsUpdatedSchemasWithoutDdlOrConfirmation() throws 
Exception {
         Table baseline = table("id", "name");
diff --git 
a/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/source/deserialize/PostgresSchemaChangeDeserializeTest.java
 
b/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/source/deserialize/PostgresSchemaChangeDeserializeTest.java
index 12efc56c6d0..a3844777150 100644
--- 
a/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/source/deserialize/PostgresSchemaChangeDeserializeTest.java
+++ 
b/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/source/deserialize/PostgresSchemaChangeDeserializeTest.java
@@ -114,12 +114,15 @@ class PostgresSchemaChangeDeserializeTest {
                 newDeserializer(
                         tableWith(column("id", "int4", true, 
null).edit().nativeType(23).create()));
         Table fresh = tableWith(column("id", "int8", true, 
null).edit().nativeType(20).create());
+        deserializer.targetTableMappingsCache = Map.of("t1", "mapped_t1");
 
         DeserializeResult result = deserializer.deserialize(CONTEXT, 
schemaRecord(fresh));
 
         assertEquals(DeserializeResult.Type.SCHEMA_CHANGE, result.getType());
         assertNotNull(result.getUnsupportedReason());
-        assertTrue(result.getUnsupportedReason().contains("int4 to int8"));
+        assertTrue(result.getUnsupportedReason().contains("int4 to int8 (OID 
23 -> 20)"));
+        assertTrue(result.getUnsupportedReason().contains("Table: public.t1 -> 
doris_db.mapped_t1"));
+        assertFalse(result.getUnsupportedReason().contains("Source DDL:"));
         assertTrue(result.getDdls().isEmpty(), "type change must not emit 
DDL");
         assertEquals(fresh, result.getUpdatedSchemas().get(TABLE).getTable());
     }
@@ -276,7 +279,7 @@ class PostgresSchemaChangeDeserializeTest {
 
     @Test
     void 
relationSimultaneousAddAndDropRequiresConfirmationWithoutApplyingBaseline() 
throws Exception {
-        // stored [id,name]; Relation [id,nick] -> name dropped + nick added 
-> treated as RENAME.
+        // Relation reports the final columns, not the original DDL or a 
confirmed rename.
         Table baseline = storedTable("id", "name");
         Table fresh = storedTable("id", "nick");
         PostgresDebeziumJsonDeserializer deserializer = 
newDeserializer(baseline);
@@ -287,6 +290,10 @@ class PostgresSchemaChangeDeserializeTest {
         assertEquals(DeserializeResult.Type.SCHEMA_CHANGE, result.getType());
         assertNotNull(result.getUnsupportedReason());
         assertTrue(result.getUnsupportedReason().contains("rename"));
+        assertTrue(result.getUnsupportedReason().contains("Table: public.t1 -> 
doris_db.t1"));
+        assertTrue(result.getUnsupportedReason().contains("columns 
added=[nick]; removed=[name]"));
+        assertFalse(result.getUnsupportedReason().contains("Source DDL:"));
+        assertFalse(result.getUnsupportedReason().contains("name -> nick"));
         assertTrue(result.getDdls().isEmpty(), "rename must not emit DDL");
         assertEquals(fresh, result.getUpdatedSchemas().get(TABLE).getTable());
         assertEquals(baseline, 
deserializer.getTableSchemas().get(TABLE).getTable());
diff --git 
a/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_mysql_job_sc_advanced.groovy
 
b/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_mysql_job_sc_advanced.groovy
index eabd23de1ea..748ea77fae1 100644
--- 
a/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_mysql_job_sc_advanced.groovy
+++ 
b/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_mysql_job_sc_advanced.groovy
@@ -15,6 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
+import groovy.json.JsonSlurper
 import org.awaitility.Awaitility
 
 import static java.util.concurrent.TimeUnit.SECONDS
@@ -62,10 +63,24 @@ suite("test_streaming_mysql_job_sc_advanced", 
"p0,external,mysql,external_docker
         def waitForCommit = { long previous ->
             Awaitility.await().atMost(180, SECONDS).pollInterval(1, 
SECONDS).until({ succeedCount() > previous })
         }
-        def waitForPause = { String reason ->
+        def waitForPause = { String reason, List<String> details ->
             Awaitility.await().atMost(180, SECONDS).pollInterval(1, 
SECONDS).until({
                 def rows = sql """SELECT Status, ErrorMsg FROM 
jobs("type"="insert") WHERE Name='${jobName}'"""
-                rows.size() == 1 && rows[0][0] == "PAUSED" && 
rows[0][1].toString().contains(reason)
+                if (rows.size() != 1 || rows[0][0] != "PAUSED") {
+                    return false
+                }
+                def rawError = rows[0][1].toString()
+                def error = new JsonSlurper().parseText(rawError)
+                return (error.code == "CANNOT_RESUME_ERR"
+                        && rawError.contains(" -> ")
+                        && error.msg.contains("[SCHEMA_CHANGE_UNSUPPORTED]")
+                        && error.msg.contains(reason)
+                        && error.msg.contains("Table: ${mysqlDb}.${table1} -> 
${currentDb}.${table1}")
+                        && error.msg.contains("Source DDL: [ALTER TABLE")
+                        && error.msg.contains("Review and adjust the target 
schema as needed.")
+                        && error.msg.contains("RESUME confirms acceptance of 
unsupported changes for this recovery round.")
+                        && !error.msg.contains("age -> age)")
+                        && details.every { error.msg.contains(it) })
             })
         }
 
@@ -161,13 +176,15 @@ suite("test_streaming_mysql_job_sc_advanced", 
"p0,external,mysql,external_docker
                    (first_col, name, after_name, age, city, mixed_col) VALUES 
('X', 'E1', 9, 5, 'sh', 100)"""
         }
 
-        waitForPause("SCHEMA_CHANGE_UNSUPPORTED")
+        def mixedDdlDetails = ["MODIFY COLUMN is not supported (age)",
+                "ADD COLUMN mixed_col INT", "MODIFY COLUMN age BIGINT"]
+        waitForPause("SCHEMA_CHANGE_UNSUPPORTED", mixedDdlDetails)
         qt_mixed_paused """SELECT Status FROM jobs("type"="insert") WHERE 
Name='${jobName}'"""
         qt_mixed_not_applied """SELECT COUNT(*) FROM information_schema.columns
             WHERE table_schema='${currentDb}' AND table_name='${table1}' AND 
column_name='mixed_col'"""
         qt_mixed_row_not_written "SELECT COUNT(*) FROM ${table1} WHERE 
name='E1'"
         sql "RESUME JOB WHERE jobname='${jobName}'"
-        waitForPause("missing columns")
+        waitForPause("Doris target:", mixedDdlDetails + ["missing columns 
[mixed_col]"])
         qt_missing_column_pause """SELECT Status FROM jobs("type"="insert") 
WHERE Name='${jobName}'"""
         sql "ALTER TABLE ${table1} ADD COLUMN mixed_col INT NULL"
         long beforeMixedResume = succeedCount()
@@ -191,7 +208,8 @@ suite("test_streaming_mysql_job_sc_advanced", 
"p0,external,mysql,external_docker
                    (first_col, name, after_name, age, city, mixed_col) VALUES 
('M', 'F1', 10, 6, 'sz', 200)"""
         }
 
-        waitForPause("SCHEMA_CHANGE_UNSUPPORTED")
+        waitForPause("SCHEMA_CHANGE_UNSUPPORTED",
+                ["MODIFY COLUMN is not supported (age)", "MODIFY COLUMN age 
SMALLINT"])
         qt_modify_paused """SELECT Status FROM jobs("type"="insert") WHERE 
Name='${jobName}'"""
         long beforeModifyResume = succeedCount()
         sql "RESUME JOB WHERE jobname='${jobName}'"
@@ -213,7 +231,8 @@ suite("test_streaming_mysql_job_sc_advanced", 
"p0,external,mysql,external_docker
                    (first_col, name, after_name, age2, city, mixed_col) VALUES 
('R', 'G1', 11, 7, 'bj', 300)"""
         }
 
-        waitForPause("SCHEMA_CHANGE_UNSUPPORTED")
+        waitForPause("SCHEMA_CHANGE_UNSUPPORTED",
+                ["CHANGE COLUMN is not supported (age -> age2)", "CHANGE 
COLUMN age age2 INT"])
         qt_rename_paused """SELECT Status FROM jobs("type"="insert") WHERE 
Name='${jobName}'"""
         // Keep age available for DML replayed from the last committed offset.
         sql "ALTER TABLE ${table1} ADD COLUMN age2 INT NULL"
@@ -236,7 +255,8 @@ suite("test_streaming_mysql_job_sc_advanced", 
"p0,external,mysql,external_docker
             sql """INSERT INTO ${mysqlDb}.${table1}
                 (first_col, name, after_name, age2, city, mixed_col) VALUES 
('S', 'H1', 12, 8, 'cd', 400)"""
         }
-        waitForPause("SCHEMA_CHANGE_UNSUPPORTED")
+        waitForPause("SCHEMA_CHANGE_UNSUPPORTED",
+                ["MODIFY COLUMN is not supported (age2)", "COMMENT 'comment 
only'"])
         qt_column_comment_paused """SELECT Status FROM jobs("type"="insert") 
WHERE Name='${jobName}'"""
         long beforeCommentResume = succeedCount()
         sql "RESUME JOB WHERE jobname='${jobName}'"
diff --git 
a/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_postgres_job_sc_advanced.groovy
 
b/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_postgres_job_sc_advanced.groovy
index 33aac91d5cb..fd30dbb5d85 100644
--- 
a/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_postgres_job_sc_advanced.groovy
+++ 
b/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_postgres_job_sc_advanced.groovy
@@ -15,6 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
+import groovy.json.JsonSlurper
 import org.awaitility.Awaitility
 
 import static java.util.concurrent.TimeUnit.SECONDS
@@ -109,10 +110,21 @@ suite("test_streaming_postgres_job_sc_advanced",
         def waitForSchemaPause = { boolean missingColumn ->
             Awaitility.await().atMost(180, SECONDS).pollInterval(1, 
SECONDS).until({
                 def rows = sql """select Status, ErrorMsg from 
jobs("type"="insert") where Name='${jobName}'"""
-                (rows.size() == 1 && rows[0][0] == 'PAUSED'
-                        && 
rows[0][1].toString().contains('[SCHEMA_CHANGE_UNSUPPORTED]')
-                        && (!missingColumn || 
(rows[0][1].toString().contains('missing columns')
-                                && rows[0][1].toString().contains('c3'))))
+                if (rows.size() != 1 || rows[0][0] != 'PAUSED') {
+                    return false
+                }
+                def rawError = rows[0][1].toString()
+                def error = new JsonSlurper().parseText(rawError)
+                return (error.code == 'CANNOT_RESUME_ERR'
+                        && rawError.contains(" -> ")
+                        && error.msg.contains('[SCHEMA_CHANGE_UNSUPPORTED]')
+                        && error.msg.contains("Table: ${pgSchema}.${table1} -> 
${currentDb}.${table1}")
+                        && error.msg.contains('columns added=[c3]; 
removed=[c1]')
+                        && !error.msg.contains('Source DDL:')
+                        && error.msg.contains('Review and adjust the target 
schema as needed.')
+                        && error.msg.contains('RESUME confirms acceptance of 
unsupported changes for this recovery round.')
+                        && (!missingColumn || (error.msg.contains('Doris 
target:')
+                                && error.msg.contains('missing columns 
[c3]'))))
             })
         }
 


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to