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

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


The following commit(s) were added to refs/heads/branch-4.1 by this push:
     new 128dfd9583a branch-4.1: [improvement](streaming) Improve streaming job 
error messages #68241 (#68867)
128dfd9583a is described below

commit 128dfd9583abcd33631d7eb4bcf7f882bbaec9ba
Author: wudi <[email protected]>
AuthorDate: Mon Oct 12 13:57:12 2026 +0800

    branch-4.1: [improvement](streaming) Improve streaming job error messages 
#68241 (#68867)
    
    Cherry-picked from #68241
    
    Conflict resolution: preserve branch-4.1's existing CDC type conversion
    and method structure in `StreamingJobUtils`, and adapt the three FE
    tests to JUnit 4.
    
    Validation: FE Checkstyle passed with 0 violations; all 3
    `DorisBatchStreamLoadTest` cases passed; `git diff --check` passed. FE
    unit tests were blocked by sandbox permissions when writing the Maven
    cache. Streaming regression suites were not run.
---
 .../doris/httpv2/rest/StreamingJobAction.java      |  2 +-
 .../streaming/DataSourceConfigValidator.java       | 24 ++++++-
 .../insert/streaming/StreamingInsertJob.java       | 10 ++-
 .../insert/streaming/StreamingJobProperties.java   | 10 ++-
 .../streaming/StreamingJobSchedulerTask.java       |  3 +-
 .../insert/streaming/StreamingMultiTblTask.java    |  7 +-
 .../job/offset/jdbc/JdbcSourceOffsetProvider.java  | 20 ++++--
 .../apache/doris/job/util/StreamingJobUtils.java   | 11 +--
 .../trees/plans/commands/AlterJobCommand.java      |  2 +-
 .../trees/plans/commands/info/CreateJobInfo.java   |  3 +-
 .../CdcStreamTableValuedFunction.java              | 13 ++--
 .../StreamingInsertJobCheckDataQualityTest.java    |  6 ++
 .../streaming/StreamingJobPropertiesTest.java      |  3 +-
 .../JdbcSourceOffsetProviderErrorHandlingTest.java | 18 +++--
 .../doris/cdcclient/sink/DorisBatchStreamLoad.java | 11 +++
 .../cdcclient/sink/DorisBatchStreamLoadTest.java   | 84 ++++++++++++++++++++++
 .../test_streaming_mysql_job_create_alter.groovy   |  2 +-
 .../cdc/test_streaming_mysql_job_dup.groovy        |  2 +-
 .../cdc/test_streaming_postgres_job_dup.groovy     |  2 +-
 .../test_streaming_insert_job_crud.groovy          |  8 +--
 .../test_streaming_job_max_retry.groovy            |  2 +
 21 files changed, 202 insertions(+), 41 deletions(-)

diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/StreamingJobAction.java 
b/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/StreamingJobAction.java
index 42d243b5754..7eac63ddb77 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/StreamingJobAction.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/httpv2/rest/StreamingJobAction.java
@@ -113,7 +113,7 @@ public class StreamingJobAction extends RestBaseController {
     private void checkAuth(HttpServletRequest request) {
         String authToken = request.getHeader("token");
         if (Strings.isNullOrEmpty(authToken)) {
-            throw new UnauthorizedException("Miss token");
+            throw new UnauthorizedException("Missing token");
         }
         if (!checkClusterToken(authToken)) {
             throw new UnauthorizedException("Invalid token");
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/DataSourceConfigValidator.java
 
b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/DataSourceConfigValidator.java
index f75bf03c56e..f74feb336ed 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/DataSourceConfigValidator.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/DataSourceConfigValidator.java
@@ -127,7 +127,10 @@ public class DataSourceConfigValidator {
             }
 
             if (!isValidValue(key, value, dataSourceType)) {
-                throw new IllegalArgumentException("Invalid value for key '" + 
key + "': " + value);
+                if (DataSourceConfigKeys.SERVER_ID.equals(key)) {
+                    validateServerIdConfig(input);
+                }
+                throw new IllegalArgumentException(invalidValueMessage(key, 
value));
             }
         }
 
@@ -290,6 +293,25 @@ public class DataSourceConfigValidator {
         return true;
     }
 
+    public static String invalidValueMessage(String key, String value) {
+        String message = "Invalid value for key '" + key + "': " + value + ".";
+        if (DataSourceConfigKeys.SLOT_NAME.equals(key)
+                || DataSourceConfigKeys.PUBLICATION_NAME.equals(key)) {
+            return message + " Must match [a-z_][a-z0-9_]* (max 63 
characters).";
+        }
+        if (DataSourceConfigKeys.SSL_MODE.equals(key)) {
+            return message + " Use disable, require, or verify-ca.";
+        }
+        if (DataSourceConfigKeys.SNAPSHOT_SPLIT_SIZE.equals(key)
+                || DataSourceConfigKeys.SNAPSHOT_PARALLELISM.equals(key)) {
+            return message + " Use a positive integer.";
+        }
+        if (DataSourceConfigKeys.SKIP_SNAPSHOT_BACKFILL.equals(key)) {
+            return message + " Use true or false.";
+        }
+        return message;
+    }
+
     // Strict boolean: only "true"/"false" (case-insensitive); 
Boolean.parseBoolean would
     // silently coerce typos like "yes" to false.
     public static boolean isValidBoolean(String value) {
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 185aa720c4a..13db794b4ca 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
@@ -1212,7 +1212,7 @@ public class StreamingInsertJob extends 
AbstractJob<StreamingJobSchedulerTask, M
     private static boolean checkPrivilege(ConnectContext ctx, String  sql) 
throws AnalysisException {
         LogicalPlan logicalPlan = new NereidsParser().parseSingle(sql);
         if (!(logicalPlan instanceof InsertIntoTableCommand)) {
-            throw new AnalysisException("Only support insert command");
+            throw new AnalysisException("Streaming jobs only support INSERT 
statements");
         }
         LogicalPlan logicalQuery = ((InsertIntoTableCommand) 
logicalPlan).getLogicalQuery();
         List<String> targetTable = 
InsertUtils.getTargetTableQualified(logicalQuery, ctx);
@@ -1538,7 +1538,8 @@ public class StreamingInsertJob extends 
AbstractJob<StreamingJobSchedulerTask, M
                     && runningMultiTask.isTimeout(status)) {
                 String timeoutReason = status == null ? "" : 
status.getFailReason();
                 if (StringUtils.isEmpty(timeoutReason)) {
-                    timeoutReason = "task failed cause timeout";
+                    timeoutReason = "Streaming task " + 
runningMultiTask.getTaskId()
+                            + " timed out because no progress was reported.";
                 }
                 runningMultiTask.onFail(timeoutReason);
                 // renew streaming task by auto resume
@@ -1636,7 +1637,10 @@ public class StreamingInsertJob extends 
AbstractJob<StreamingJobSchedulerTask, M
                     getJobId(), ratio, maxFilterRatio, 
sampleWindowFilteredRows, sampleWindowScannedRows);
             log.error(msg);
             FailureReason failureReason = new 
FailureReason(InternalErrorCode.TOO_MANY_FAILURE_ROWS_ERR,
-                    "too many filtered rows exceeded max_filter_ratio " + 
maxFilterRatio);
+                    String.format(
+                            "too many filtered rows: ratio %s exceeds 
load.max_filter_ratio %s. "
+                                    + "Fix the source data or adjust the 
limit, then run RESUME JOB.",
+                            ratio, maxFilterRatio));
             this.setFailureReason(failureReason);
             this.updateJobStatus(JobStatus.PAUSED);
             throw new JobException(failureReason.getMsg());
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingJobProperties.java
 
b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingJobProperties.java
index 3db3aecaf8b..1bde1bf6205 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingJobProperties.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingJobProperties.java
@@ -106,18 +106,22 @@ public class StreamingJobProperties implements 
JobProperties {
         this.maxIntervalSecond = Util.getLongPropertyOrDefault(
                         
properties.get(StreamingJobProperties.MAX_INTERVAL_SECOND_PROPERTY),
                         StreamingJobProperties.DEFAULT_MAX_INTERVAL_SECOND, 
(v) -> v >= 1,
-                StreamingJobProperties.MAX_INTERVAL_SECOND_PROPERTY + " should 
> 1");
+                StreamingJobProperties.MAX_INTERVAL_SECOND_PROPERTY + " must 
be at least 1 second, but was "
+                        + 
properties.get(StreamingJobProperties.MAX_INTERVAL_SECOND_PROPERTY));
 
         this.s3BatchFiles = Util.getLongPropertyOrDefault(
                         
properties.get(StreamingJobProperties.S3_MAX_BATCH_FILES_PROPERTY),
                         StreamingJobProperties.DEFAULT_MAX_S3_BATCH_FILES, (v) 
-> v >= 1,
-                StreamingJobProperties.S3_MAX_BATCH_FILES_PROPERTY + " should 
>=1 ");
+                StreamingJobProperties.S3_MAX_BATCH_FILES_PROPERTY + " must be 
at least 1, but was "
+                        + 
properties.get(StreamingJobProperties.S3_MAX_BATCH_FILES_PROPERTY));
 
         this.s3BatchBytes = Util.getLongPropertyOrDefault(
                         
properties.get(StreamingJobProperties.S3_MAX_BATCH_BYTES_PROPERTY),
                         StreamingJobProperties.DEFAULT_MAX_S3_BATCH_BYTES, (v) 
-> v >= 100 * 1024 * 1024
                         && v <= (long) (1024 * 1024 * 1024) * 10,
-                StreamingJobProperties.S3_MAX_BATCH_BYTES_PROPERTY + " should 
between 100MB and 10GB");
+                StreamingJobProperties.S3_MAX_BATCH_BYTES_PROPERTY
+                        + " must be between 100 MB and 10 GB, but was "
+                        + 
properties.get(StreamingJobProperties.S3_MAX_BATCH_BYTES_PROPERTY));
 
         // validate session variables
         try {
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingJobSchedulerTask.java
 
b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingJobSchedulerTask.java
index 030c3bb2b8b..a017b0bb921 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingJobSchedulerTask.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingJobSchedulerTask.java
@@ -108,7 +108,8 @@ public class StreamingJobSchedulerTask extends AbstractTask 
{
             streamingInsertJob.setFailureReason(new FailureReason(
                     InternalErrorCode.CANNOT_RESUME_ERR,
                     "Auto resume failed after " + autoResumeCount
-                            + " attempts. Last error: " + 
failureReason.getMsg()));
+                            + " attempts. Last error: " + 
failureReason.getMsg()
+                            + ". Run RESUME JOB to retry."));
             return;
         }
 
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingMultiTblTask.java
 
b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingMultiTblTask.java
index e652f7c9e1e..f141762bdf8 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingMultiTblTask.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/insert/streaming/StreamingMultiTblTask.java
@@ -163,18 +163,21 @@ public class StreamingMultiTblTask extends 
AbstractStreamingTask {
                     log.info("Send write records request successfully, 
response: {}", responseObj.getData());
                     return;
                 }
+                String errorMessage = 
StringUtils.defaultIfBlank(responseObj.getData(), responseObj.getMsg());
+                throw new JobException(StringUtils.defaultIfBlank(
+                        errorMessage, "cdc_client failed to start streaming 
write"));
             } catch (JsonProcessingException e) {
                 log.warn("Failed to parse write records response: {}", 
response);
                 throw new JobException("Failed to parse write records 
response: " + response);
             }
-            throw new JobException("Failed to send write records request , 
error message: " + response);
         } catch (TimeoutException te) {
             log.warn("cdc_client RPC timeout api=/api/writeRecords taskId={} 
jobId={} backend={}:{} timeout_sec={}",
                     taskId, getJobId(), backend.getHost(), 
backend.getBrpcPort(),
                     Config.streaming_cdc_heavy_rpc_timeout_sec);
             // the request may have been dispatched and still running remotely
             noRetry = true;
-            throw new JobException("cdc_client RPC timeout: /api/writeRecords 
taskId=" + taskId);
+            throw new JobException("cdc_client RPC timeout: /api/writeRecords 
jobId="
+                    + getJobId() + " taskId=" + taskId);
         } catch (ExecutionException | InterruptedException ex) {
             if (ex instanceof InterruptedException) {
                 Thread.currentThread().interrupt();
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/job/offset/jdbc/JdbcSourceOffsetProvider.java
 
b/fe/fe-core/src/main/java/org/apache/doris/job/offset/jdbc/JdbcSourceOffsetProvider.java
index 64752144853..16e4d109159 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/job/offset/jdbc/JdbcSourceOffsetProvider.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/job/offset/jdbc/JdbcSourceOffsetProvider.java
@@ -59,6 +59,7 @@ import lombok.Setter;
 import lombok.extern.log4j.Log4j2;
 import org.apache.commons.collections4.CollectionUtils;
 import org.apache.commons.collections4.MapUtils;
+import org.apache.commons.lang3.StringUtils;
 
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -960,9 +961,9 @@ public class JdbcSourceOffsetProvider implements 
SourceOffsetProvider {
     /**
      * Decode a remote response envelope. A failure is returned as {@code 
{code:1, data:"<message>"}}
      * over HTTP 200, while success carries the typed payload in {@code data}. 
Decode the envelope
-     * with a lenient {@link JsonNode} data field first so a failure throws 
the raw response (which
-     * carries the real error in {@code data}) instead of a misleading 
type-mismatch from forcing the
-     * success type onto an error string. Package-private for unit testing.
+     * with a lenient {@link JsonNode} data field first so a failure surfaces 
the error in
+     * {@code data} instead of a misleading type-mismatch from forcing the 
success type onto an
+     * error string. Package-private for unit testing.
      */
     <T> T parseCdcResponseData(String response, TypeReference<T> dataType) 
throws JobException {
         ResponseBody<JsonNode> body;
@@ -972,6 +973,13 @@ public class JdbcSourceOffsetProvider implements 
SourceOffsetProvider {
             throw new JobException(response);
         }
         if (body.getCode() != RestApiStatusCode.OK.code) {
+            JsonNode data = body.getData();
+            if (data != null && data.isTextual() && 
StringUtils.isNotBlank(data.asText())) {
+                throw new JobException(data.asText());
+            }
+            if (StringUtils.isNotBlank(body.getMsg())) {
+                throw new JobException(body.getMsg());
+            }
             throw new JobException(response);
         }
         try {
@@ -1102,12 +1110,12 @@ public class JdbcSourceOffsetProvider implements 
SourceOffsetProvider {
                 if (responseObj.getCode() == RestApiStatusCode.OK.code) {
                     log.info("Init {} source reader successfully, response: 
{}", getJobId(), responseObj.getData());
                     return;
-                } else {
-                    throw new JobException("Failed to init source reader, 
error: " + responseObj.getData());
                 }
+                String errorMessage = 
StringUtils.defaultIfBlank(responseObj.getData(), responseObj.getMsg());
+                throw new JobException("Failed to init source reader, error: " 
+ errorMessage);
             } catch (JobException jobex) {
                 log.warn("Failed to init {} source reader, {}", getJobId(), 
response);
-                throw new JobException(jobex.getMessage());
+                throw jobex;
             } catch (Exception e) {
                 log.warn("Failed to init {} source reader, {}", getJobId(), 
response);
                 throw new JobException("Failed to init source reader, cause " 
+ e.getMessage());
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/job/util/StreamingJobUtils.java 
b/fe/fe-core/src/main/java/org/apache/doris/job/util/StreamingJobUtils.java
index 30cc98d3caf..af830fa77d3 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/job/util/StreamingJobUtils.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/job/util/StreamingJobUtils.java
@@ -383,9 +383,10 @@ public class StreamingJobUtils {
 
         JdbcClient jdbcClient = getJdbcClient(sourceType, properties);
         String database = getRemoteDbName(sourceType, properties);
+        String sourceLocation = sourceType == DataSourceType.POSTGRES ? 
"schema" : "database";
         List<String> tablesNameList = jdbcClient.getTablesNameList(database);
         if (tablesNameList.isEmpty()) {
-            throw new JobException("No tables found in database " + database);
+            throw new JobException("No source tables found in " + 
sourceLocation + " '" + database + "'");
         }
         Database targetDatabase = 
Env.getCurrentEnv().getInternalCatalog().getDbNullable(targetDb);
         Preconditions.checkNotNull(targetDatabase, "target database %s does 
not exist", targetDb);
@@ -484,8 +485,9 @@ public class StreamingJobUtils {
         }
 
         if (!noPrimaryKeyTables.isEmpty()) {
-            throw new JobException("The following tables do not have primary 
key defined: "
-                    + String.join(", ", noPrimaryKeyTables));
+            throw new JobException("Source tables require primary keys: "
+                    + String.join(", ", noPrimaryKeyTables)
+                    + ". Add a primary key or exclude these tables.");
         }
         return createtblCmds;
     }
@@ -514,7 +516,8 @@ public class StreamingJobUtils {
         columns.forEach(col -> {
             col.setType(getCdcTransportType(col.getType()));
             Preconditions.checkArgument(!col.getType().isUnsupported(),
-                    "Unsupported column type, table:[%s], column:[%s]", table, 
col.getName());
+                    "Unsupported column type for source column '%s.%s.%s'",
+                    database, table, col.getName());
             if (col.getType().isVarchar()) {
                 // The length of varchar needs to be multiplied by 3.
                 int len = col.getType().getLength() * 3;
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AlterJobCommand.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AlterJobCommand.java
index d75be51c457..0a529d55424 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AlterJobCommand.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/AlterJobCommand.java
@@ -338,7 +338,7 @@ public class AlterJobCommand extends AlterCommand 
implements ForwardWithSync, Ne
     private Pair<List<String>, UnboundTVFRelation> getTargetTableAndTvf(String 
sql) throws AnalysisException {
         LogicalPlan logicalPlan = new NereidsParser().parseSingle(sql);
         if (!(logicalPlan instanceof InsertIntoTableCommand)) {
-            throw new AnalysisException("Only support insert command");
+            throw new AnalysisException("Streaming jobs only support INSERT 
statements");
         }
         LogicalPlan logicalQuery = ((InsertIntoTableCommand) 
logicalPlan).getLogicalQuery();
         List<String> targetTable = 
InsertUtils.getTargetTableQualified(logicalQuery, ConnectContext.get());
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/CreateJobInfo.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/CreateJobInfo.java
index 544e649059b..bc0540e5064 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/CreateJobInfo.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/CreateJobInfo.java
@@ -343,8 +343,7 @@ public class CreateJobInfo {
                 throw new AnalysisException(e.getMessage());
             }
         } else {
-            throw new AnalysisException("Only " + 
logicalPlan.getClass().getName()
-                    + " is supported to use with streaming job together");
+            throw new AnalysisException("Streaming jobs only support INSERT 
statements");
         }
     }
 
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/CdcStreamTableValuedFunction.java
 
b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/CdcStreamTableValuedFunction.java
index 15f7de54a21..2c22dd7828b 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/tablefunction/CdcStreamTableValuedFunction.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/tablefunction/CdcStreamTableValuedFunction.java
@@ -154,11 +154,13 @@ public class CdcStreamTableValuedFunction extends 
ExternalFileTableValuedFunctio
         }
         String offset = properties.get(DataSourceConfigKeys.OFFSET);
         if (!DataSourceConfigValidator.isValidOffset(offset, 
sourceType.name())) {
-            throw new AnalysisException("Invalid value for key 'offset': " + 
offset);
+            throw new 
AnalysisException(DataSourceConfigValidator.invalidValueMessage(
+                    DataSourceConfigKeys.OFFSET, offset));
         }
         String sslMode = properties.get(DataSourceConfigKeys.SSL_MODE);
         if (sslMode != null && 
!DataSourceConfigValidator.isValidSslMode(sslMode)) {
-            throw new AnalysisException("Invalid value for key 'ssl_mode': " + 
sslMode);
+            throw new 
AnalysisException(DataSourceConfigValidator.invalidValueMessage(
+                    DataSourceConfigKeys.SSL_MODE, sslMode));
         }
         try {
             DataSourceConfigValidator.validateSslVerifyCaPair(properties);
@@ -184,7 +186,7 @@ public class CdcStreamTableValuedFunction extends 
ExternalFileTableValuedFunctio
             return;
         }
         if (!DataSourceConfigValidator.isPositiveInt(value)) {
-            throw new AnalysisException("Invalid value for key '" + key + "': 
" + value);
+            throw new 
AnalysisException(DataSourceConfigValidator.invalidValueMessage(key, value));
         }
     }
 
@@ -195,7 +197,7 @@ public class CdcStreamTableValuedFunction extends 
ExternalFileTableValuedFunctio
             return;
         }
         if (!DataSourceConfigValidator.isValidPgIdentifier(value)) {
-            throw new AnalysisException("Invalid value for key '" + key + "': 
" + value);
+            throw new 
AnalysisException(DataSourceConfigValidator.invalidValueMessage(key, value));
         }
     }
 
@@ -206,7 +208,8 @@ public class CdcStreamTableValuedFunction extends 
ExternalFileTableValuedFunctio
             return;
         }
         if (!DataSourceConfigValidator.isValidBoolean(value)) {
-            throw new AnalysisException("Invalid value for key '" + key + "': 
" + value);
+            throw new AnalysisException("Invalid value for key '" + key + "': 
" + value
+                    + ". Expected true or false.");
         }
     }
 
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJobCheckDataQualityTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJobCheckDataQualityTest.java
index 06c5e1a5c75..8ca602f7527 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJobCheckDataQualityTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingInsertJobCheckDataQualityTest.java
@@ -17,10 +17,12 @@
 
 package org.apache.doris.job.extensions.insert.streaming;
 
+import org.apache.doris.common.InternalErrorCode;
 import org.apache.doris.common.jmockit.Deencapsulation;
 import org.apache.doris.job.base.JobExecutionConfiguration;
 import org.apache.doris.job.base.TimerDefinition;
 import org.apache.doris.job.cdc.request.CommitOffsetRequest;
+import org.apache.doris.job.common.FailureReason;
 import org.apache.doris.job.common.JobStatus;
 import org.apache.doris.job.exception.JobException;
 
@@ -93,6 +95,10 @@ public class StreamingInsertJobCheckDataQualityTest {
             thrown = e;
         }
         Assert.assertNotNull("expected pause when combined ratio exceeds 
threshold", thrown);
+        
Assert.assertTrue(thrown.getMessage().contains("load.max_filter_ratio"));
+        Assert.assertTrue(thrown.getMessage().contains("RESUME JOB"));
+        Assert.assertEquals(InternalErrorCode.TOO_MANY_FAILURE_ROWS_ERR,
+                new FailureReason(thrown.getMessage()).getCode());
         Assert.assertEquals(JobStatus.PAUSED, job.getJobStatus());
     }
 
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingJobPropertiesTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingJobPropertiesTest.java
index 97a98031dde..ca5098e1b24 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingJobPropertiesTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/job/extensions/insert/streaming/StreamingJobPropertiesTest.java
@@ -104,7 +104,8 @@ public class StreamingJobPropertiesTest {
         Assert.assertEquals(StreamingJobProperties.DEFAULT_MAX_INTERVAL_SECOND,
                 p.getMaxIntervalSecond());
         // but validate() should throw
-        Assert.assertThrows(AnalysisException.class, p::validate);
+        AnalysisException exception = 
Assert.assertThrows(AnalysisException.class, p::validate);
+        Assert.assertTrue(exception.getMessage().contains("max_interval must 
be at least 1 second"));
     }
 
     @Test
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/job/offset/jdbc/JdbcSourceOffsetProviderErrorHandlingTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/job/offset/jdbc/JdbcSourceOffsetProviderErrorHandlingTest.java
index 1da12b3c97a..e0d6827300b 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/job/offset/jdbc/JdbcSourceOffsetProviderErrorHandlingTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/job/offset/jdbc/JdbcSourceOffsetProviderErrorHandlingTest.java
@@ -56,8 +56,19 @@ public class JdbcSourceOffsetProviderErrorHandlingTest {
             provider.parseCdcResponseData(response, new 
TypeReference<List<SnapshotSplit>>() {});
             Assert.fail("a failed envelope must throw");
         } catch (JobException e) {
-            Assert.assertTrue("the real remote error must be surfaced, got: " 
+ e.getMessage(),
-                    e.getMessage().contains(realError));
+            Assert.assertEquals(realError, e.getMessage());
+        }
+    }
+
+    @Test
+    public void testParseFailureEnvelopeFallsBackToMessageWithoutTextData() {
+        JdbcSourceOffsetProvider provider = new JdbcSourceOffsetProvider();
+        String response = "{\"code\":1,\"msg\":\"cdc_client is 
unavailable\",\"data\":null}";
+        try {
+            provider.parseCdcResponseData(response, new 
TypeReference<List<SnapshotSplit>>() {});
+            Assert.fail("a failed envelope must throw");
+        } catch (JobException e) {
+            Assert.assertEquals("cdc_client is unavailable", e.getMessage());
         }
     }
 
@@ -83,8 +94,7 @@ public class JdbcSourceOffsetProviderErrorHandlingTest {
             provider.parseCdcResponseData(response, new 
TypeReference<Integer>() {});
             Assert.fail("an unparseable response must throw");
         } catch (JobException e) {
-            Assert.assertTrue("the raw response must be surfaced, got: " + 
e.getMessage(),
-                    e.getMessage().contains("502"));
+            Assert.assertEquals(response, e.getMessage());
         }
     }
 
diff --git 
a/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/sink/DorisBatchStreamLoad.java
 
b/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/sink/DorisBatchStreamLoad.java
index dbc68c680a9..370e3c30ee9 100644
--- 
a/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/sink/DorisBatchStreamLoad.java
+++ 
b/fs_brokers/cdc_client/src/main/java/org/apache/doris/cdcclient/sink/DorisBatchStreamLoad.java
@@ -572,6 +572,17 @@ public class DorisBatchStreamLoad implements Serializable {
                                         taskId);
                                 return;
                             }
+                            JsonNode data = root.get("data");
+                            JsonNode msg = root.get("msg");
+                            if (data != null
+                                    && data.isTextual()
+                                    && StringUtils.isNotBlank(data.asText())) {
+                                reason = data.asText();
+                            } else if (msg != null
+                                    && msg.isTextual()
+                                    && StringUtils.isNotBlank(msg.asText())) {
+                                reason = msg.asText();
+                            }
                         }
                         LOG.error(
                                 "commit offset failed with {}, reason {}, to 
retry",
diff --git 
a/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/sink/DorisBatchStreamLoadTest.java
 
b/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/sink/DorisBatchStreamLoadTest.java
new file mode 100644
index 00000000000..7c271f046ad
--- /dev/null
+++ 
b/fs_brokers/cdc_client/src/test/java/org/apache/doris/cdcclient/sink/DorisBatchStreamLoadTest.java
@@ -0,0 +1,84 @@
+// 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.sink;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.apache.doris.cdcclient.exception.StreamLoadException;
+
+import org.apache.commons.lang3.exception.ExceptionUtils;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import com.sun.net.httpserver.HttpServer;
+
+class DorisBatchStreamLoadTest {
+
+    @ParameterizedTest
+    @CsvSource(
+            delimiter = '|',
+            value = {
+                "{\"code\":1,\"msg\":\"Error\",\"data\":\"Offset commit 
rejected\"}|Offset commit rejected",
+                "{\"code\":1,\"msg\":\"Offset commit 
rejected\",\"data\":null}|Offset commit rejected",
+                "{\"code\":1,\"msg\":\"\",\"data\":null}|HTTP/1.1 200 OK"
+            })
+    void commitOffsetPreservesFailureReasonAfterRetries(String response, 
String expectedReason)
+            throws Exception {
+        HttpServer server = HttpServer.create(new 
InetSocketAddress("127.0.0.1", 0), 0);
+        AtomicInteger requests = new AtomicInteger();
+        server.createContext(
+                "/api/streaming/commit_offset",
+                exchange -> {
+                    try {
+                        exchange.getRequestBody().readAllBytes();
+                        requests.incrementAndGet();
+                        byte[] body = 
response.getBytes(StandardCharsets.UTF_8);
+                        exchange.sendResponseHeaders(200, body.length);
+                        exchange.getResponseBody().write(body);
+                    } finally {
+                        exchange.close();
+                    }
+                });
+        server.start();
+        try {
+            DorisBatchStreamLoad streamLoad = new DorisBatchStreamLoad("1", 
"test_db");
+            try {
+                streamLoad.setFrontendAddress("127.0.0.1:" + 
server.getAddress().getPort());
+                streamLoad.setToken("test-token");
+                StreamLoadException error = assertThrows(
+                        StreamLoadException.class,
+                        () -> streamLoad.commitOffset(
+                                "2", Collections.emptyList(), 0, new 
LoadStatistic(), null));
+                assertEquals(
+                        "StreamLoadException: commit offset failed with: " + 
expectedReason,
+                        ExceptionUtils.getRootCauseMessage(error));
+                assertEquals(4, requests.get());
+            } finally {
+                streamLoad.close();
+            }
+        } finally {
+            server.stop(0);
+        }
+    }
+}
diff --git 
a/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_mysql_job_create_alter.groovy
 
b/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_mysql_job_create_alter.groovy
index 2e6443db633..30e3af99afe 100644
--- 
a/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_mysql_job_create_alter.groovy
+++ 
b/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_mysql_job_create_alter.groovy
@@ -181,7 +181,7 @@ suite("test_streaming_mysql_job_create_alter", 
"p0,external,mysql,external_docke
                   "table.create.properties.replication_num" = "1"
                 )
             """
-            exception "No tables found in database"
+            exception "No source tables found in database"
         }
 
         // no match table
diff --git 
a/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_mysql_job_dup.groovy
 
b/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_mysql_job_dup.groovy
index 2ddacebde16..ac9c8631280 100644
--- 
a/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_mysql_job_dup.groovy
+++ 
b/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_mysql_job_dup.groovy
@@ -61,7 +61,7 @@ suite("test_streaming_mysql_job_dup", 
"p0,external,mysql,external_docker,externa
                   "table.create.properties.replication_num" = "1"
                 )
             """
-            exception "The following tables do not have primary key defined: 
${table1}"
+            exception "Source tables require primary keys: ${table1}"
         }
 
         def jobInfo = sql """
diff --git 
a/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_postgres_job_dup.groovy
 
b/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_postgres_job_dup.groovy
index 1bec6cd3a25..cbaab4f153a 100644
--- 
a/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_postgres_job_dup.groovy
+++ 
b/regression-test/suites/job_p0/streaming_job/cdc/test_streaming_postgres_job_dup.groovy
@@ -64,7 +64,7 @@ suite("test_streaming_postgres_job_dup", 
"p0,external,pg,external_docker,externa
                   "table.create.properties.replication_num" = "1"
                 )
             """
-            exception "The following tables do not have primary key defined: 
${table1}"
+            exception "Source tables require primary keys: ${table1}"
         }
 
         def jobInfo = sql """
diff --git 
a/regression-test/suites/job_p0/streaming_job/test_streaming_insert_job_crud.groovy
 
b/regression-test/suites/job_p0/streaming_job/test_streaming_insert_job_crud.groovy
index f648767fdaf..8de7f1b2748 100644
--- 
a/regression-test/suites/job_p0/streaming_job/test_streaming_insert_job_crud.groovy
+++ 
b/regression-test/suites/job_p0/streaming_job/test_streaming_insert_job_crud.groovy
@@ -112,7 +112,7 @@ suite("test_streaming_insert_job_crud") {
                 "s3.secret_key" = "${getS3SK()}"
             );
         """
-    }, "s3.max_batch_files should >=1")
+    }, "s3.max_batch_files must be at least 1")
 
     jobCount = sql """ select count(1) from jobs("type"="insert") where Name = 
'${jobNameError}' and ExecuteType='STREAMING' """
     assert jobCount.get(0).get(0) == 0
@@ -136,7 +136,7 @@ suite("test_streaming_insert_job_crud") {
                 "s3.secret_key" = "${getS3SK()}"
             );
         """
-    }, "s3.max_batch_bytes should between 100MB and 10GB")
+    }, "s3.max_batch_bytes must be between 100 MB and 10 GB")
 
     jobCount = sql """ select count(1) from jobs("type"="insert") where Name = 
'${jobNameError}' and ExecuteType='STREAMING' """
     assert jobCount.get(0).get(0) == 0
@@ -160,7 +160,7 @@ suite("test_streaming_insert_job_crud") {
                 "s3.secret_key" = "${getS3SK()}"
             );
         """
-    }, "max_interval should > 1")
+    }, "max_interval must be at least 1 second")
 
     jobCount = sql """ select count(1) from jobs("type"="insert") where Name = 
'${jobNameError}' and ExecuteType='STREAMING' """
     assert jobCount.get(0).get(0) == 0
@@ -409,7 +409,7 @@ suite("test_streaming_insert_job_crud") {
                 "s3.secret_key" = "${getS3SK()}"
             );
         """
-        exception "s3.max_batch_files should >=1"
+        exception "s3.max_batch_files must be at least 1"
     }
 
     // alter session var
diff --git 
a/regression-test/suites/job_p0/streaming_job/test_streaming_job_max_retry.groovy
 
b/regression-test/suites/job_p0/streaming_job/test_streaming_job_max_retry.groovy
index 30fc6eba1c1..b137198e174 100644
--- 
a/regression-test/suites/job_p0/streaming_job/test_streaming_job_max_retry.groovy
+++ 
b/regression-test/suites/job_p0/streaming_job/test_streaming_job_max_retry.groovy
@@ -101,6 +101,8 @@ suite("test_streaming_job_max_retry", "nonConcurrent") {
                 "ErrorMsg should contain CANNOT_RESUME_ERR code, got: " + 
errorMsgJson
         assert errorMsgJson.contains("Auto resume failed after"),
                 "ErrorMsg should contain the burn-out message, got: " + 
errorMsgJson
+        assert errorMsgJson.contains("RESUME JOB"),
+                "ErrorMsg should explain the manual recovery action, got: " + 
errorMsgJson
 
     } finally {
         
GetDebugPoint().disableDebugPointForAllFEs('StreamingJob.scheduleTask.exception')


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

Reply via email to