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

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


The following commit(s) were added to refs/heads/master by this push:
     new 82e90efae65 [feat](job) show first error message for routine load 
(#65553)
82e90efae65 is described below

commit 82e90efae653496e10a5e20100215bcda25a2132
Author: hui lai <[email protected]>
AuthorDate: Wed Jul 15 14:41:29 2026 +0800

    [feat](job) show first error message for routine load (#65553)
    
    ### What problem does this PR solve?
    
    PR #55666 added the first data quality error to load responses, but
    Routine Load was not covered. Routine Load transaction attachments only
    propagated the error log URL, so users still had to open the URL to
    inspect the actual error.
    
    ### Design
    This PR:
    - Captures the first data quality error for both successful and failed
    Routine Load tasks.
    - Propagates `first_error_msg` through the Routine Load Thrift
    transaction attachment and cloud transaction attachment.
    - Limits the message using `first_error_msg_max_length` when
    constructing the FE transaction attachment.
    - Forwards Routine Load attachments through the live cloud abort
    transaction path.
    - Adds a separate `FirstErrorMsg` column to `SHOW ROUTINE LOAD`.
    - Appends `FIRST_ERROR_MSG` to `information_schema.routine_load_jobs`
    without changing existing column ordinals.
    - Keeps `ERROR_LOG_URLS` unchanged instead of embedding the message in
    the URL field.
    - Treats Error URLs and the first error message as transient
    diagnostics:
    - They are not persisted in Routine Load job metadata or cloud progress.
      - They are not restored during transaction replay.
    - They are cleared together when the current error-statistics window is
    reset.
    - Keeps the behavior of successful ordinary Stream Load unchanged.
    
    ### Example
    
    `first_error_msg` output:
    ```text
    first_error_msg: no partition for this tuple. Src line: 100|bad_row
    ```
    
    The first error can be queried directly:
    ```
    SHOW ROUTINE LOAD FOR job_name;
    ```
    or
    ```
    SELECT ERROR_LOG_URLS, FIRST_ERROR_MSG
    FROM information_schema.routine_load_jobs
    WHERE JOB_NAME = 'job_name';
    ```
---
 .../schema_routine_load_job_scanner.cpp            |   4 +
 be/src/load/stream_load/stream_load_executor.cpp   |   7 +-
 .../java/org/apache/doris/catalog/SchemaTable.java |   1 +
 .../transaction/CloudGlobalTransactionMgr.java     |   6 +-
 .../apache/doris/cloud/transaction/TxnUtil.java    |   3 +
 .../routineload/RLTaskTxnCommitAttachment.java     |  15 +++
 .../doris/load/routineload/RoutineLoadJob.java     |  35 ++++--
 .../plans/commands/ShowRoutineLoadCommand.java     |   1 +
 .../apache/doris/service/FrontendServiceImpl.java  |   1 +
 .../transaction/CloudGlobalTransactionMgrTest.java |  50 ++++++++
 .../doris/load/routineload/RoutineLoadJobTest.java |  39 +++++++
 .../plans/commands/ShowRoutineLoadCommandTest.java |   2 +
 gensrc/proto/cloud.proto                           |   2 +
 gensrc/thrift/FrontendService.thrift               |   2 +
 .../test_routine_load_first_error_msg.groovy       | 127 +++++++++++++++++++++
 15 files changed, 282 insertions(+), 13 deletions(-)

diff --git a/be/src/information_schema/schema_routine_load_job_scanner.cpp 
b/be/src/information_schema/schema_routine_load_job_scanner.cpp
index 9e94a507d6c..0e954de7646 100644
--- a/be/src/information_schema/schema_routine_load_job_scanner.cpp
+++ b/be/src/information_schema/schema_routine_load_job_scanner.cpp
@@ -55,6 +55,7 @@ std::vector<SchemaScanner::ColumnDesc> 
SchemaRoutineLoadJobScanner::_s_tbls_colu
         {"CURRENT_ABORT_TASK_NUM", TYPE_INT, sizeof(int32_t), true},
         {"IS_ABNORMAL_PAUSE", TYPE_BOOLEAN, sizeof(int8_t), true},
         {"COMPUTE_GROUP", TYPE_STRING, sizeof(StringRef), true},
+        {"FIRST_ERROR_MSG", TYPE_STRING, sizeof(StringRef), true},
 };
 
 SchemaRoutineLoadJobScanner::SchemaRoutineLoadJobScanner()
@@ -175,6 +176,9 @@ Status SchemaRoutineLoadJobScanner::_fill_block_impl(Block* 
block) {
                 case 20: // COMPUTE_GROUP
                     column_value = job_info.__isset.compute_group ? 
job_info.compute_group : "";
                     break;
+                case 21: // FIRST_ERROR_MSG
+                    column_value = job_info.__isset.first_error_msg ? 
job_info.first_error_msg : "";
+                    break;
                 }
 
                 str_refs[row_idx] =
diff --git a/be/src/load/stream_load/stream_load_executor.cpp 
b/be/src/load/stream_load/stream_load_executor.cpp
index 478ed22755d..9b307c932d4 100644
--- a/be/src/load/stream_load/stream_load_executor.cpp
+++ b/be/src/load/stream_load/stream_load_executor.cpp
@@ -110,6 +110,9 @@ Status StreamLoadExecutor::execute_plan_fragment(
                 *status = Status::DataQualityError("too many filtered rows");
             }
         }
+        if (ctx->load_type == TLoadType::ROUTINE_LOAD || !status->ok()) {
+            ctx->first_error_msg = state->get_first_error_msg();
+        }
 
         if (status->ok()) {
             
DorisMetrics::instance()->stream_receive_bytes_total->increment(ctx->receive_bytes);
@@ -118,7 +121,6 @@ Status StreamLoadExecutor::execute_plan_fragment(
             LOG(WARNING) << "fragment execute failed"
                          << ", err_msg=" << status->to_string() << ", " << 
ctx->brief();
             ctx->number_loaded_rows = 0;
-            ctx->first_error_msg = state->get_first_error_msg();
             // cancel body_sink, make sender known it
             if (ctx->body_sink != nullptr) {
                 ctx->body_sink->cancel(status->to_string());
@@ -428,6 +430,9 @@ bool 
StreamLoadExecutor::collect_load_stat(StreamLoadContext* ctx, TTxnCommitAtt
         rl_attach.__set_receivedBytes(ctx->receive_bytes);
         rl_attach.__set_loadedBytes(ctx->loaded_bytes);
         rl_attach.__set_loadCostMs(ctx->load_cost_millis);
+        if (!ctx->first_error_msg.empty()) {
+            rl_attach.__set_firstErrorMsg(ctx->first_error_msg);
+        }
 
         attach->rlTaskTxnCommitAttachment = rl_attach;
         attach->__isset.rlTaskTxnCommitAttachment = true;
diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/SchemaTable.java 
b/fe/fe-core/src/main/java/org/apache/doris/catalog/SchemaTable.java
index fdcb838ef6f..bc20f860d16 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/catalog/SchemaTable.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/SchemaTable.java
@@ -706,6 +706,7 @@ public class SchemaTable extends Table {
                                     .column("CURRENT_ABORT_TASK_NUM", 
ScalarType.createType(PrimitiveType.INT))
                                     .column("IS_ABNORMAL_PAUSE", 
ScalarType.createType(PrimitiveType.BOOLEAN))
                                     .column("COMPUTE_GROUP", 
ScalarType.createStringType())
+                                    .column("FIRST_ERROR_MSG", 
ScalarType.createStringType())
                                     .build())
             )
             .put("load_jobs",
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/cloud/transaction/CloudGlobalTransactionMgr.java
 
b/fe/fe-core/src/main/java/org/apache/doris/cloud/transaction/CloudGlobalTransactionMgr.java
index 0749d38f311..1cf69114d09 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/cloud/transaction/CloudGlobalTransactionMgr.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/cloud/transaction/CloudGlobalTransactionMgr.java
@@ -1841,7 +1841,7 @@ public class CloudGlobalTransactionMgr implements 
GlobalTransactionMgrIface {
 
         AbortTxnResponse abortTxnResponse = null;
         try {
-            abortTxnResponse = abortTransactionImpl(dbId, transactionId, 
reason, null);
+            abortTxnResponse = abortTransactionImpl(dbId, transactionId, 
reason, txnCommitAttachment);
         } finally {
             handleAfterAbort(abortTxnResponse, txnCommitAttachment, 
transactionId,
                     callbackInfo.first, callbackInfo.second);
@@ -1905,6 +1905,10 @@ public class CloudGlobalTransactionMgr implements 
GlobalTransactionMgrIface {
         builder.setTxnId(transactionId);
         builder.setReason(reason);
         builder.setCloudUniqueId(Config.cloud_unique_id);
+        if (txnCommitAttachment instanceof RLTaskTxnCommitAttachment) {
+            builder.setCommitAttachment(TxnUtil.rlTaskTxnCommitAttachmentToPb(
+                    (RLTaskTxnCommitAttachment) txnCommitAttachment));
+        }
 
         final AbortTxnRequest abortTxnRequest = builder.build();
         AbortTxnResponse abortTxnResponse = null;
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/cloud/transaction/TxnUtil.java 
b/fe/fe-core/src/main/java/org/apache/doris/cloud/transaction/TxnUtil.java
index f29aa5294b3..fceb16d91ad 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/cloud/transaction/TxnUtil.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/cloud/transaction/TxnUtil.java
@@ -256,6 +256,9 @@ public class TxnUtil {
         if (rtTaskTxnCommitAttachment.getErrorLogUrl() != null) {
             builder.setErrorLogUrl(rtTaskTxnCommitAttachment.getErrorLogUrl());
         }
+        if (rtTaskTxnCommitAttachment.getFirstErrorMsg() != null) {
+            
builder.setFirstErrorMsg(rtTaskTxnCommitAttachment.getFirstErrorMsg());
+        }
 
         attachementBuilder.setRlTaskTxnCommitAttachment(builder.build());
         return attachementBuilder.build();
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/load/routineload/RLTaskTxnCommitAttachment.java
 
b/fe/fe-core/src/main/java/org/apache/doris/load/routineload/RLTaskTxnCommitAttachment.java
index dd9c4ed85ce..48607266075 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/load/routineload/RLTaskTxnCommitAttachment.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/load/routineload/RLTaskTxnCommitAttachment.java
@@ -18,6 +18,7 @@
 package org.apache.doris.load.routineload;
 
 import org.apache.doris.cloud.proto.Cloud.RLTaskTxnCommitAttachmentPB;
+import org.apache.doris.common.Config;
 import org.apache.doris.load.routineload.kafka.KafkaProgress;
 import org.apache.doris.load.routineload.kinesis.KinesisProgress;
 import org.apache.doris.thrift.TRLTaskTxnCommitAttachment;
@@ -26,6 +27,7 @@ import org.apache.doris.transaction.TransactionState;
 import org.apache.doris.transaction.TxnCommitAttachment;
 
 import com.google.gson.annotations.SerializedName;
+import org.apache.commons.lang3.StringUtils;
 
 // {"progress": "", "backendId": "", "taskSignature": "", "numOfErrorData": "",
 // "numOfTotalData": "", "taskId": "", "jobId": ""}
@@ -46,6 +48,7 @@ public class RLTaskTxnCommitAttachment extends 
TxnCommitAttachment {
     @SerializedName(value = "pro")
     private RoutineLoadProgress progress;
     private String errorLogUrl;
+    private String firstErrorMsg;
 
     public RLTaskTxnCommitAttachment() {
         super(TransactionState.LoadJobSourceType.ROUTINE_LOAD_TASK);
@@ -75,6 +78,9 @@ public class RLTaskTxnCommitAttachment extends 
TxnCommitAttachment {
         if (rlTaskTxnCommitAttachment.isSetErrorLogUrl()) {
             this.errorLogUrl = rlTaskTxnCommitAttachment.getErrorLogUrl();
         }
+        if (rlTaskTxnCommitAttachment.isSetFirstErrorMsg()) {
+            this.firstErrorMsg = 
abbreviateFirstErrorMsg(rlTaskTxnCommitAttachment.getFirstErrorMsg());
+        }
     }
 
     public RLTaskTxnCommitAttachment(RLTaskTxnCommitAttachmentPB 
rlTaskTxnCommitAttachment) {
@@ -92,6 +98,11 @@ public class RLTaskTxnCommitAttachment extends 
TxnCommitAttachment {
 
         this.progress = progress;
         this.errorLogUrl = rlTaskTxnCommitAttachment.getErrorLogUrl();
+        this.firstErrorMsg = 
abbreviateFirstErrorMsg(rlTaskTxnCommitAttachment.getFirstErrorMsg());
+    }
+
+    private static String abbreviateFirstErrorMsg(String firstErrorMsg) {
+        return StringUtils.abbreviate(firstErrorMsg, 
Config.first_error_msg_max_length);
     }
 
     public long getJobId() {
@@ -134,6 +145,10 @@ public class RLTaskTxnCommitAttachment extends 
TxnCommitAttachment {
         return errorLogUrl;
     }
 
+    public String getFirstErrorMsg() {
+        return firstErrorMsg;
+    }
+
     @Override
     public String toString() {
         return "RLTaskTxnCommitAttachment [filteredRows=" + filteredRows
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/load/routineload/RoutineLoadJob.java
 
b/fe/fe-core/src/main/java/org/apache/doris/load/routineload/RoutineLoadJob.java
index 3223ff91359..9873368f405 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/load/routineload/RoutineLoadJob.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/load/routineload/RoutineLoadJob.java
@@ -274,8 +274,10 @@ public abstract class RoutineLoadJob
     protected Expr deleteCondition;
     // TODO(ml): error sample
 
-    // save the latest 3 error log urls
-    private Queue<String> errorLogUrls = EvictingQueue.create(3);
+    // Save the latest 3 error log URLs in memory. The corresponding first 
error message
+    // uses the same lifecycle and should not be persisted with the job.
+    private transient Queue<String> errorLogUrls = EvictingQueue.create(3);
+    private transient String firstErrorMsg = "";
 
     @SerializedName("ccid")
     private String cloudClusterId;
@@ -809,6 +811,10 @@ public abstract class RoutineLoadJob
         return errorLogUrls;
     }
 
+    public String getFirstErrorMsg() {
+        return Strings.nullToEmpty(firstErrorMsg);
+    }
+
     // RoutineLoadScheduler will run this method at fixed interval, and renew 
the timeout tasks
     public void processTimeoutTasks() {
         writeLock();
@@ -954,10 +960,7 @@ public abstract class RoutineLoadJob
                                 + "when current total rows is more than base 
or the filter ratio is more than the max")
                         .build());
             }
-            // reset currentTotalNum, currentErrorNum and otherMsg
-            this.jobStatistic.currentErrorRows = 0;
-            this.jobStatistic.currentTotalRows = 0;
-            this.otherMsg = "";
+            resetCurrentErrorStatistics();
             this.jobStatistic.currentAbortedTaskNum = 0;
         } else if (this.jobStatistic.currentErrorRows > maxErrorNum
                 || (this.jobStatistic.currentTotalRows > 0
@@ -977,13 +980,18 @@ public abstract class RoutineLoadJob
                         "current error rows is more than max_error_number "
                             + "or the max_filter_ratio is more than the value 
set"), isReplay);
             }
-            // reset currentTotalNum, currentErrorNum and otherMsg
-            this.jobStatistic.currentErrorRows = 0;
-            this.jobStatistic.currentTotalRows = 0;
-            this.otherMsg = "";
+            resetCurrentErrorStatistics();
         }
     }
 
+    private void resetCurrentErrorStatistics() {
+        this.jobStatistic.currentErrorRows = 0;
+        this.jobStatistic.currentTotalRows = 0;
+        this.otherMsg = "";
+        this.errorLogUrls.clear();
+        this.firstErrorMsg = "";
+    }
+
     protected void replayUpdateProgress(RLTaskTxnCommitAttachment attachment) {
         try {
             updateNumOfData(attachment.getTotalRows(), 
attachment.getFilteredRows(), attachment.getUnselectedRows(),
@@ -1408,8 +1416,10 @@ public abstract class RoutineLoadJob
             
routineLoadTaskInfo.handleTaskByTxnCommitAttachment(rlTaskTxnCommitAttachment);
         }
 
-        if (rlTaskTxnCommitAttachment != null && 
!Strings.isNullOrEmpty(rlTaskTxnCommitAttachment.getErrorLogUrl())) {
+        if (rlTaskTxnCommitAttachment != null
+                && 
!Strings.isNullOrEmpty(rlTaskTxnCommitAttachment.getErrorLogUrl())) {
             errorLogUrls.add(rlTaskTxnCommitAttachment.getErrorLogUrl());
+            firstErrorMsg = 
Strings.nullToEmpty(rlTaskTxnCommitAttachment.getFirstErrorMsg());
         }
 
         routineLoadTaskInfo.setTxnStatus(txnStatus);
@@ -1712,6 +1722,7 @@ public abstract class RoutineLoadJob
             row.add(userIdentity.getQualifiedUser());
             row.add(comment);
             row.add(getClusterInfo());
+            row.add(getFirstErrorMsg());
             return row;
         } finally {
             readUnlock();
@@ -1951,6 +1962,8 @@ public abstract class RoutineLoadJob
 
     @Override
     public void gsonPostProcess() throws IOException {
+        errorLogUrls = EvictingQueue.create(3);
+        firstErrorMsg = "";
         if (tableId == 0) {
             isMultiTable = true;
         }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowRoutineLoadCommand.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowRoutineLoadCommand.java
index fe5c1441ebc..42737314470 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowRoutineLoadCommand.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowRoutineLoadCommand.java
@@ -110,6 +110,7 @@ public class ShowRoutineLoadCommand extends ShowCommand {
             .add("User")
             .add("Comment")
             .add("ComputeGroup")
+            .add("FirstErrorMsg")
             .build();
 
     private final LabelNameInfo labelNameInfo;
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/service/FrontendServiceImpl.java 
b/fe/fe-core/src/main/java/org/apache/doris/service/FrontendServiceImpl.java
index f76b687e77f..afd0ed46b2a 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/service/FrontendServiceImpl.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/service/FrontendServiceImpl.java
@@ -5582,6 +5582,7 @@ public class FrontendServiceImpl implements 
FrontendService.Iface {
             jobInfo.setLag(job.getLag());
             jobInfo.setReasonOfStateChanged(job.getStateReason());
             jobInfo.setErrorLogUrls(Joiner.on(", 
").join(job.getErrorLogUrls()));
+            jobInfo.setFirstErrorMsg(job.getFirstErrorMsg());
             jobInfo.setUserName(job.getUserIdentity().getQualifiedUser());
             
jobInfo.setCurrentAbortTaskNum(job.getJobStatistic().currentAbortedTaskNum);
             jobInfo.setIsAbnormalPause(job.isAbnormalPause());
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/cloud/transaction/CloudGlobalTransactionMgrTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/cloud/transaction/CloudGlobalTransactionMgrTest.java
index bcc5b41c3e8..a6db5409e89 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/cloud/transaction/CloudGlobalTransactionMgrTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/cloud/transaction/CloudGlobalTransactionMgrTest.java
@@ -36,8 +36,10 @@ import org.apache.doris.common.DuplicatedRequestException;
 import org.apache.doris.common.FeMetaVersion;
 import org.apache.doris.common.LabelAlreadyUsedException;
 import org.apache.doris.common.UserException;
+import org.apache.doris.load.routineload.RLTaskTxnCommitAttachment;
 import org.apache.doris.transaction.GlobalTransactionMgrIface;
 import org.apache.doris.transaction.TransactionState;
+import org.apache.doris.transaction.TxnStateChangeCallback;
 
 import com.google.common.collect.Lists;
 import org.junit.After;
@@ -45,6 +47,7 @@ import org.junit.Assert;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.jupiter.api.Assertions;
+import org.mockito.ArgumentCaptor;
 import org.mockito.MockedStatic;
 import org.mockito.Mockito;
 
@@ -306,6 +309,53 @@ public class CloudGlobalTransactionMgrTest {
         }
     }
 
+    @Test
+    public void testAbortRoutineLoadTransactionWithAttachment() throws 
Exception {
+        long transactionId = 123534;
+        long jobId = 1001;
+        RLTaskTxnCommitAttachment attachment = new RLTaskTxnCommitAttachment(
+                Cloud.RLTaskTxnCommitAttachmentPB.newBuilder()
+                        .setJobId(jobId)
+                        
.setTaskId(Cloud.UniqueIdPB.newBuilder().setHi(1).setLo(2))
+                        .setProgress(Cloud.RoutineLoadProgressPB.newBuilder())
+                        .setFirstErrorMsg("invalid source row")
+                        .build());
+        TxnStateChangeCallback callback = 
Mockito.mock(TxnStateChangeCallback.class);
+        Mockito.when(callback.getId()).thenReturn(jobId);
+        masterTransMgr.getCallbackFactory().addCallback(callback);
+
+        MetaServiceProxy mockProxy = Mockito.mock(MetaServiceProxy.class);
+        try (MockedStatic<MetaServiceProxy> mockedStatic = 
Mockito.mockStatic(MetaServiceProxy.class)) {
+            
mockedStatic.when(MetaServiceProxy::getInstance).thenReturn(mockProxy);
+            Mockito.doAnswer(invocation -> {
+                Cloud.AbortTxnRequest request = invocation.getArgument(0);
+                Assert.assertTrue(request.hasCommitAttachment());
+                Assert.assertEquals("invalid source row", 
request.getCommitAttachment()
+                        .getRlTaskTxnCommitAttachment().getFirstErrorMsg());
+                return AbortTxnResponse.newBuilder()
+                        .setStatus(Cloud.MetaServiceResponseStatus.newBuilder()
+                                .setCode(MetaServiceCode.OK).setMsg("OK"))
+                        .setTxnInfo(buildTxnInfo(transactionId).toBuilder()
+                                
.setStatus(Cloud.TxnStatusPB.TXN_STATUS_ABORTED)
+                                .setReason("data quality error")
+                                
.setCommitAttachment(request.getCommitAttachment()))
+                        .build();
+            }).when(mockProxy).abortTxn(Mockito.any());
+
+            masterTransMgr.abortTransaction(CatalogTestUtil.testDbId1, 
transactionId,
+                    "data quality error", attachment, Lists.newArrayList());
+
+            ArgumentCaptor<TransactionState> txnStateCaptor = 
ArgumentCaptor.forClass(TransactionState.class);
+            Mockito.verify(callback).afterAborted(txnStateCaptor.capture(), 
Mockito.eq(true),
+                    Mockito.eq("data quality error"));
+            RLTaskTxnCommitAttachment callbackAttachment =
+                    (RLTaskTxnCommitAttachment) 
txnStateCaptor.getValue().getTxnCommitAttachment();
+            Assert.assertEquals("invalid source row", 
callbackAttachment.getFirstErrorMsg());
+        } finally {
+            masterTransMgr.getCallbackFactory().removeCallback(jobId);
+        }
+    }
+
     @Test
     public void testAbortTransactionByLabel() throws Exception {
         MetaServiceProxy mockProxy = Mockito.mock(MetaServiceProxy.class);
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/load/routineload/RoutineLoadJobTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/load/routineload/RoutineLoadJobTest.java
index 8876c6a8aea..26351597543 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/load/routineload/RoutineLoadJobTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/load/routineload/RoutineLoadJobTest.java
@@ -21,6 +21,8 @@ import org.apache.doris.analysis.UserIdentity;
 import org.apache.doris.catalog.Database;
 import org.apache.doris.catalog.Env;
 import org.apache.doris.catalog.Table;
+import org.apache.doris.cloud.transaction.TxnUtil;
+import org.apache.doris.common.Config;
 import org.apache.doris.common.InternalErrorCode;
 import org.apache.doris.common.Pair;
 import org.apache.doris.common.UserException;
@@ -33,6 +35,9 @@ import org.apache.doris.load.routineload.kafka.KafkaTaskInfo;
 import 
org.apache.doris.nereids.trees.plans.commands.info.CreateRoutineLoadInfo;
 import org.apache.doris.persist.EditLog;
 import org.apache.doris.thrift.TKafkaRLTaskProgress;
+import org.apache.doris.thrift.TLoadSourceType;
+import org.apache.doris.thrift.TRLTaskTxnCommitAttachment;
+import org.apache.doris.thrift.TUniqueId;
 import org.apache.doris.thrift.TUniqueKeyUpdateMode;
 import org.apache.doris.transaction.GlobalTransactionMgrIface;
 import org.apache.doris.transaction.TransactionException;
@@ -53,6 +58,27 @@ import java.util.List;
 import java.util.Map;
 
 public class RoutineLoadJobTest {
+    @Test
+    public void testFirstErrorMsgInTxnCommitAttachment() {
+        String overlongFirstErrorMsg = Strings.repeat("x", 
Config.first_error_msg_max_length + 1);
+        TRLTaskTxnCommitAttachment thriftAttachment = new 
TRLTaskTxnCommitAttachment();
+        thriftAttachment.setLoadSourceType(TLoadSourceType.KAFKA);
+        thriftAttachment.setId(new TUniqueId(1, 2));
+        thriftAttachment.setJobId(3);
+        thriftAttachment.setKafkaRLTaskProgress(new 
TKafkaRLTaskProgress(Maps.newHashMap()));
+        thriftAttachment.setErrorLogUrl("http://127.0.0.1/error_log";);
+        thriftAttachment.setFirstErrorMsg(overlongFirstErrorMsg);
+
+        RLTaskTxnCommitAttachment attachment = new 
RLTaskTxnCommitAttachment(thriftAttachment);
+        Assert.assertEquals(Config.first_error_msg_max_length, 
attachment.getFirstErrorMsg().length());
+        Assert.assertTrue(attachment.getFirstErrorMsg().endsWith("..."));
+
+        RLTaskTxnCommitAttachment cloudAttachment = 
TxnUtil.rtTaskTxnCommitAttachmentFromPb(
+                TxnUtil.rlTaskTxnCommitAttachmentToPb(attachment));
+        Assert.assertEquals("http://127.0.0.1/error_log";, 
cloudAttachment.getErrorLogUrl());
+        Assert.assertEquals(attachment.getFirstErrorMsg(), 
cloudAttachment.getFirstErrorMsg());
+    }
+
     @Test
     public void testAfterAbortedReasonOffsetOutOfRange() throws UserException {
         Env env = Mockito.mock(Env.class);
@@ -95,6 +121,8 @@ public class RoutineLoadJobTest {
         tKafkaRLTaskProgress.partitionCmtOffset = Maps.newHashMap();
         KafkaProgress kafkaProgress = new KafkaProgress(tKafkaRLTaskProgress);
         Deencapsulation.setField(attachment, "progress", kafkaProgress);
+        Deencapsulation.setField(attachment, "errorLogUrl", 
"http://127.0.0.1/error_log";);
+        Deencapsulation.setField(attachment, "firstErrorMsg", "invalid source 
row");
 
         KafkaProgress currentProgress = new 
KafkaProgress(tKafkaRLTaskProgress);
 
@@ -114,6 +142,8 @@ public class RoutineLoadJobTest {
 
         Assert.assertEquals(RoutineLoadJob.JobState.RUNNING, 
routineLoadJob.getState());
         Assert.assertEquals(new Long(1), 
Deencapsulation.getField(jobStatistic, "abortedTaskNum"));
+        Assert.assertEquals("http://127.0.0.1/error_log";, 
routineLoadJob.getErrorLogUrls().peek());
+        Assert.assertEquals("invalid source row", 
routineLoadJob.getFirstErrorMsg());
     }
 
     @Test
@@ -153,10 +183,13 @@ public class RoutineLoadJobTest {
         Deencapsulation.setField(routineLoadJob, "pauseReason", errorReason);
         Deencapsulation.setField(routineLoadJob, "progress", kafkaProgress);
         Deencapsulation.setField(routineLoadJob, "userIdentity", userIdentity);
+        Deencapsulation.setField(routineLoadJob, "firstErrorMsg", "invalid 
source row");
 
         List<String> showInfo = routineLoadJob.getShowInfo();
         Assert.assertEquals(true, showInfo.stream().filter(entity -> 
!Strings.isNullOrEmpty(entity))
                 .anyMatch(entity -> entity.equals(errorReason.toString())));
+        Assert.assertEquals(24, showInfo.size());
+        Assert.assertEquals("invalid source row", showInfo.get(23));
     }
 
     @Test
@@ -276,11 +309,17 @@ public class RoutineLoadJobTest {
         RoutineLoadStatistic jobStatistic = 
Deencapsulation.getField(routineLoadJob, "jobStatistic");
         Deencapsulation.setField(jobStatistic, "currentErrorRows", 1);
         Deencapsulation.setField(jobStatistic, "currentTotalRows", 99);
+        Deencapsulation.setField(routineLoadJob, "otherMsg", "previous 
warning");
+        routineLoadJob.getErrorLogUrls().add("http://127.0.0.1/error_log";);
+        Deencapsulation.setField(routineLoadJob, "firstErrorMsg", "invalid 
source row");
         Deencapsulation.invoke(routineLoadJob, "updateNumOfData", 2L, 0L, 0L, 
1L, 1L, false);
 
         Assert.assertEquals(RoutineLoadJob.JobState.RUNNING, 
Deencapsulation.getField(routineLoadJob, "state"));
         Assert.assertEquals(new Long(0), 
Deencapsulation.getField(jobStatistic, "currentErrorRows"));
         Assert.assertEquals(new Long(0), 
Deencapsulation.getField(jobStatistic, "currentTotalRows"));
+        Assert.assertEquals("", Deencapsulation.getField(routineLoadJob, 
"otherMsg"));
+        Assert.assertTrue(routineLoadJob.getErrorLogUrls().isEmpty());
+        Assert.assertEquals("", routineLoadJob.getFirstErrorMsg());
 
     }
 
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/ShowRoutineLoadCommandTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/ShowRoutineLoadCommandTest.java
index 4bbe2722030..ce933be3dec 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/ShowRoutineLoadCommandTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/commands/ShowRoutineLoadCommandTest.java
@@ -35,5 +35,7 @@ public class ShowRoutineLoadCommandTest extends 
TestWithFeService {
         LabelNameInfo labelNameInfo = new LabelNameInfo("test_db", 
"test_label");
         ShowRoutineLoadCommand command = new 
ShowRoutineLoadCommand(labelNameInfo, null, false);
         Assertions.assertDoesNotThrow(() -> command.validate(connectContext));
+        Assertions.assertEquals(24, command.getMetaData().getColumnCount());
+        Assertions.assertEquals("FirstErrorMsg", 
command.getMetaData().getColumn(23).getName());
     }
 }
diff --git a/gensrc/proto/cloud.proto b/gensrc/proto/cloud.proto
index bff85eee942..e5c458132a9 100644
--- a/gensrc/proto/cloud.proto
+++ b/gensrc/proto/cloud.proto
@@ -394,6 +394,7 @@ message TxnCoordinatorPB {
 message RoutineLoadProgressPB {
     map<int32, int64> partition_to_offset = 1;
     optional RoutineLoadJobStatisticPB stat = 2;
+    // Error log URLs and first error messages are transient diagnostics and 
are not stored here.
 }
 
 message RLTaskTxnCommitAttachmentPB {
@@ -406,6 +407,7 @@ message RLTaskTxnCommitAttachmentPB {
     optional int64 task_execution_time_ms = 7;
     optional RoutineLoadProgressPB progress = 8;
     optional string error_log_url = 9;
+    optional string first_error_msg = 10;
 }
 
 message RoutineLoadJobStatisticPB {
diff --git a/gensrc/thrift/FrontendService.thrift 
b/gensrc/thrift/FrontendService.thrift
index d87d7df56ed..2bc093da9b9 100644
--- a/gensrc/thrift/FrontendService.thrift
+++ b/gensrc/thrift/FrontendService.thrift
@@ -675,6 +675,7 @@ struct TRLTaskTxnCommitAttachment {
     10: optional TKafkaRLTaskProgress kafkaRLTaskProgress
     11: optional string errorLogUrl
     12: optional TKinesisRLTaskProgress kinesisRLTaskProgress
+    13: optional string firstErrorMsg
 }
 
 struct TTxnCommitAttachment {
@@ -1668,6 +1669,7 @@ struct TRoutineLoadJob {
     19: optional i32 current_abort_task_num
     20: optional bool is_abnormal_pause
     21: optional string compute_group
+    22: optional string first_error_msg
 }
 
 struct TFetchRoutineLoadJobResult {
diff --git 
a/regression-test/suites/load_p0/routine_load/test_routine_load_first_error_msg.groovy
 
b/regression-test/suites/load_p0/routine_load/test_routine_load_first_error_msg.groovy
new file mode 100644
index 00000000000..a41c84d2ce0
--- /dev/null
+++ 
b/regression-test/suites/load_p0/routine_load/test_routine_load_first_error_msg.groovy
@@ -0,0 +1,127 @@
+// 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.
+
+import org.apache.doris.regression.util.RoutineLoadTestUtils
+import org.apache.kafka.clients.admin.AdminClient
+import org.apache.kafka.clients.admin.NewTopic
+import org.apache.kafka.clients.producer.ProducerConfig
+import org.junit.Assert
+
+suite("test_routine_load_first_error_msg", "p0") {
+    if (!RoutineLoadTestUtils.isKafkaTestEnabled(context)) {
+        return
+    }
+
+    def kafkaBroker = RoutineLoadTestUtils.getKafkaBroker(context)
+    def kafkaTopic = 
"test_routine_load_first_error_msg_${System.currentTimeMillis()}"
+    def jobName = "test_routine_load_first_error_msg"
+
+    def adminProps = new Properties()
+    adminProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, kafkaBroker)
+    def adminClient = AdminClient.create(adminProps)
+    try {
+        adminClient.createTopics([new NewTopic(kafkaTopic, 1, (short) 
1)]).all().get()
+    } finally {
+        adminClient.close()
+    }
+
+    try {
+        sql "DROP TABLE IF EXISTS test_routine_load_first_error_msg"
+        sql """
+            CREATE TABLE test_routine_load_first_error_msg (
+                id INT,
+                name STRING
+            )
+            PARTITION BY RANGE(id) (
+                PARTITION p0 VALUES LESS THAN ("10")
+            )
+            DISTRIBUTED BY HASH(id) BUCKETS 1
+            PROPERTIES ("replication_num" = "1")
+        """
+
+        sql """
+            CREATE ROUTINE LOAD ${jobName} ON test_routine_load_first_error_msg
+            COLUMNS TERMINATED BY "|"
+            PROPERTIES (
+                "max_error_number" = "10",
+                "max_filter_ratio" = "1.0",
+                "max_batch_interval" = "5"
+            )
+            FROM KAFKA (
+                "kafka_broker_list" = "${kafkaBroker}",
+                "kafka_topic" = "${kafkaTopic}",
+                "property.kafka_default_offsets" = "OFFSET_END"
+            )
+        """
+
+        def count = 0
+        while (true) {
+            def showResult = sql "SHOW ROUTINE LOAD FOR ${jobName}"
+            if (showResult[0][8].toString() == "RUNNING") {
+                break
+            }
+            if (count++ > 60) {
+                Assert.fail("Routine Load job did not enter RUNNING state")
+            }
+            sleep(1000)
+        }
+
+        def producer = RoutineLoadTestUtils.createKafkaProducer(kafkaBroker)
+        try {
+            RoutineLoadTestUtils.sendTestDataToKafka(
+                    producer, [kafkaTopic], ["100|bad_row", "1|valid_row"])
+            producer.flush()
+        } finally {
+            producer.close()
+        }
+
+        count = 0
+        while (true) {
+            def jobInfo = sql """
+                SELECT ERROR_LOG_URLS, FIRST_ERROR_MSG
+                FROM information_schema.routine_load_jobs
+                WHERE JOB_NAME = '${jobName}'
+            """
+            def showResult = sql "SHOW ROUTINE LOAD FOR ${jobName}"
+            def loadedRows = sql "SELECT COUNT(*) FROM 
test_routine_load_first_error_msg"
+            def informationSchemaErrorLogUrls = jobInfo[0][0].toString()
+            def firstErrorMsg = jobInfo[0][1]
+            def showErrorLogUrls = showResult[0][18].toString()
+            def showFirstErrorMsg = showResult[0][23]
+            if (loadedRows[0][0] == 1 && firstErrorMsg != null
+                    && firstErrorMsg.toString().contains("bad_row")
+                    && showFirstErrorMsg != null
+                    && showFirstErrorMsg.toString().contains("bad_row")) {
+                assertEquals(informationSchemaErrorLogUrls, showErrorLogUrls)
+                
assertFalse(informationSchemaErrorLogUrls.contains("first_error_msg:"))
+                assertFalse(showErrorLogUrls.contains("first_error_msg:"))
+                assertEquals(firstErrorMsg.toString(), 
showFirstErrorMsg.toString())
+                break
+            }
+            if (count++ > 60) {
+                Assert.fail("First error message was not reported by 
routine_load_jobs and SHOW ROUTINE LOAD: "
+                        + "loadedRows=${loadedRows[0][0]}, 
firstErrorMsg=${firstErrorMsg}, "
+                        + "showFirstErrorMsg=${showFirstErrorMsg}, "
+                        + 
"informationSchemaErrorLogUrlsEmpty=${informationSchemaErrorLogUrls.isEmpty()}, 
"
+                        + 
"showErrorLogUrlsEmpty=${showErrorLogUrls.isEmpty()}")
+            }
+            sleep(1000)
+        }
+    } finally {
+        try_sql "STOP ROUTINE LOAD FOR ${jobName}"
+    }
+}


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


Reply via email to