This is an automated email from the ASF dual-hosted git repository.
liaoxin01 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 1ff9059a46c [fix](routine-load) Persist and replay cancel reason, and
fix cleanup stall from corrupted final jobs (#66173)
1ff9059a46c is described below
commit 1ff9059a46c688d4bcaadbc7458497aedb58b54a
Author: re20052 <[email protected]>
AuthorDate: Mon Sep 21 16:21:29 2026 +0800
[fix](routine-load) Persist and replay cancel reason, and fix cleanup stall
from corrupted final jobs (#66173)
### What problem does this PR solve?
Problem Summary:
There are several related issues around how a routine load job's
state-change reason
is persisted and how a corrupted job is cleaned up:
1. **`pauseReason` / `cancelReason` are not persisted in the Image.**
Both fields lack the
`@SerializedName` annotation, so `GsonUtils.GSON` (which applies
`HiddenAnnotationExclusionStrategy`) strips them during Image
serialization. After an FE
restart, `SHOW ALL ROUTINE LOAD` shows an empty `ReasonOfStateChanged`
for any job that was
`PAUSED` or `CANCELLED`, hiding the real failure cause. This aligns with
how
`LoadJob#failMsg` is already persisted (`@SerializedName("fm")`).
2. **The incremental EditLog path drops the reason for CANCELLED.** In
`unprotectUpdateState`,
only `PAUSED` logged a `RoutineLoadOperation` carrying the reason;
`CANCELLED` logged one
without it. On follower replay / failover before the next checkpoint,
the cancel reason was
lost even though the Image annotation above is now present.
3. **Replay parsing failure produced a corrupted final job.** When
`gsonPostProcess` fails to
parse the original SQL (e.g. the table was dropped, syntax
incompatibility after upgrade),
the job was force-transitioned to `CANCELLED` without setting
`endTimestamp`, leaving it at
the default `-1`. `isExpired()` then hits
`Preconditions.checkState(endTimestamp != -1)` and
throws, which breaks the cleanup loop and effectively leaks these jobs
in memory (they are
never recycled).
This PR:
- Adds `@SerializedName("pauseReason")` /
`@SerializedName("cancelReason")` so both reasons
survive FE restarts, consistent with BrokerLoad's `FailMsg`.
- In `unprotectUpdateState`, also carries the reason in the
`RoutineLoadOperation` for
`CANCELLED` (not just `PAUSED`), so followers/replay keep it.
- In the `gsonPostProcess` catch, terminalizes the unusable job as
`CANCELLED`, sets
`endTimestamp` only when it was still unset (so repeated image loads
before the state is
checkpointed don't keep refreshing it and postponing expiration), and
fills `cancelReason`
only when it was previously null (avoid overwriting a real historical
reason) with a message
like `FE restart deserialize failed at <time>: <exception>`.
- Makes `isExpired()` tolerate a missing `endTimestamp`: instead of
throwing and stalling the
cleanup thread, it logs a warning and treats the corrupted final job as
expired so it can be
recycled. This also drains any such jobs already accumulated on running
clusters.
- Uses `Optional#orElseThrow` on `getDb(dbId)` to surface a clear
`Database <id> does not exist`
message instead of a bare `NoSuchElementException: No value present`.
---
.../doris/load/routineload/RoutineLoadJob.java | 32 ++++++--
.../doris/load/routineload/RoutineLoadJobTest.java | 85 ++++++++++++++++++++++
2 files changed, 112 insertions(+), 5 deletions(-)
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 9873368f405..4cd4434a73e 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
@@ -242,7 +242,9 @@ public abstract class RoutineLoadJob
protected long autoResumeCount;
// some other msg which need to show to user;
protected String otherMsg = "";
+ @SerializedName("pauseReason")
protected ErrorReason pauseReason;
+ @SerializedName("cancelReason")
protected ErrorReason cancelReason;
@SerializedName("cts")
@@ -1513,7 +1515,7 @@ public abstract class RoutineLoadJob
}
if (!isReplay && jobState != JobState.RUNNING) {
- if (jobState == JobState.PAUSED) {
+ if (jobState == JobState.PAUSED || jobState == JobState.CANCELLED)
{
Env.getCurrentEnv().getEditLog().logOpRoutineLoadJob(new
RoutineLoadOperation(id, jobState, reason));
} else {
Env.getCurrentEnv().getEditLog().logOpRoutineLoadJob(new
RoutineLoadOperation(id, jobState));
@@ -1943,8 +1945,15 @@ public abstract class RoutineLoadJob
if (!isFinal()) {
return false;
}
- Preconditions.checkState(endTimestamp != -1, endTimestamp);
- return (System.currentTimeMillis() - endTimestamp) >
Config.label_keep_max_second * 1000;
+ try {
+ Preconditions.checkState(endTimestamp != -1, endTimestamp);
+ return (System.currentTimeMillis() - endTimestamp) >
Config.label_keep_max_second * 1000;
+ } catch (Exception e) {
+ LOG.warn("routine load job {} is in final state {} but has no
endTimestamp, "
+ + "skip expiring it this round (may race with an
in-progress cancel/stop).",
+ id, state, e);
+ return false;
+ }
}
public boolean isFinal() {
@@ -2000,7 +2009,8 @@ public abstract class RoutineLoadJob
});
try {
ConnectContext ctx = new ConnectContext();
-
ctx.setDatabase(Env.getCurrentEnv().getInternalCatalog().getDb(dbId).get().getName());
+
ctx.setDatabase(Env.getCurrentEnv().getInternalCatalog().getDb(dbId)
+ .orElseThrow(() -> new Exception("Database " + dbId + "
does not exist")).getName());
StatementContext statementContext = new StatementContext();
statementContext.setConnectContext(ctx);
ctx.setStatementContext(statementContext);
@@ -2032,7 +2042,19 @@ public abstract class RoutineLoadJob
ctx.cleanup();
}
} catch (Exception e) {
- this.state = JobState.CANCELLED;
+ // Terminalize this unusable job as CANCELLED. Set endTimestamp
only if unset (avoid
+ // refreshing on every image load) and keep the existing cancel
reason if present.
+ state = JobState.CANCELLED;
+ routineLoadTaskInfoList.clear();
+ long failureTimestamp = System.currentTimeMillis();
+ if (endTimestamp == -1) {
+ endTimestamp = failureTimestamp;
+ }
+ if (cancelReason == null) {
+ cancelReason = new ErrorReason(InternalErrorCode.INTERNAL_ERR,
+ "FE restart deserialize failed at " +
TimeUtils.longToTimeString(failureTimestamp)
+ + ": " + e.getMessage());
+ }
LOG.warn("error happens when parsing create routine load stmt: " +
origStmt.originStmt, e);
}
if (userIdentity != null) {
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 1ce644f8ab4..e1f25b14dd0 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
@@ -34,6 +34,8 @@ import
org.apache.doris.load.routineload.kafka.KafkaRoutineLoadJob;
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.persist.RoutineLoadOperation;
+import org.apache.doris.qe.OriginStatement;
import org.apache.doris.thrift.TKafkaRLTaskProgress;
import org.apache.doris.thrift.TLoadSourceType;
import org.apache.doris.thrift.TRLTaskTxnCommitAttachment;
@@ -50,6 +52,7 @@ import com.google.common.collect.Maps;
import org.apache.kafka.common.PartitionInfo;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
@@ -499,4 +502,86 @@ public class RoutineLoadJobTest {
Assertions.assertFalse(isPartialUpdate);
}
+ // When a job is cancelled, the CANCELLED operation persisted to edit log
must carry the error reason,
+ // so that followers replaying the log (and a promoted new master) keep
the cancel reason.
+ @Test
+ public void testCancelledOperationCarriesReason() throws UserException {
+ Env env = Mockito.mock(Env.class);
+ InternalCatalog catalog = Mockito.mock(InternalCatalog.class);
+ EditLog editLog = Mockito.mock(EditLog.class);
+ GlobalTransactionMgrIface globalTxnMgr =
Mockito.mock(GlobalTransactionMgrIface.class);
+ TxnStateCallbackFactory callbackFactory =
Mockito.mock(TxnStateCallbackFactory.class);
+
+ try (MockedStatic<Env> envStatic = Mockito.mockStatic(Env.class)) {
+ envStatic.when(Env::getCurrentEnv).thenReturn(env);
+ envStatic.when(Env::getCurrentInternalCatalog).thenReturn(catalog);
+
envStatic.when(Env::getCurrentGlobalTransactionMgr).thenReturn(globalTxnMgr);
+ Mockito.when(env.getInternalCatalog()).thenReturn(catalog);
+ Mockito.when(env.getEditLog()).thenReturn(editLog);
+
Mockito.when(globalTxnMgr.getCallbackFactory()).thenReturn(callbackFactory);
+ // db has been deleted, update() will cancel the job with a DB_ERR
reason
+
Mockito.doReturn(null).when(catalog).getDbNullable(Mockito.anyLong());
+
+ RoutineLoadJob routineLoadJob = new KafkaRoutineLoadJob();
+ routineLoadJob.update();
+
+ Assertions.assertEquals(RoutineLoadJob.JobState.CANCELLED,
routineLoadJob.getState());
+
+ ArgumentCaptor<RoutineLoadOperation> captor =
ArgumentCaptor.forClass(RoutineLoadOperation.class);
+ Mockito.verify(editLog).logOpRoutineLoadJob(captor.capture());
+ RoutineLoadOperation operation = captor.getValue();
+ Assertions.assertEquals(RoutineLoadJob.JobState.CANCELLED,
operation.getJobState());
+ Assertions.assertNotNull(operation.getErrorReason(),
+ "cancel reason must be carried in the edit log operation");
+ }
+ }
+
+ // On deserialize failure in gsonPostProcess, an active job should be
terminalized via executeCancel:
+ // state becomes CANCELLED, endTimestamp gets set (not -1) and a cancel
reason is recorded.
+ @Test
+ public void testGsonPostProcessCancelOnDeserializeFailure() throws
Exception {
+ Env env = Mockito.mock(Env.class);
+ InternalCatalog catalog = Mockito.mock(InternalCatalog.class);
+
+ try (MockedStatic<Env> envStatic = Mockito.mockStatic(Env.class)) {
+ envStatic.when(Env::getCurrentEnv).thenReturn(env);
+ Mockito.when(env.getInternalCatalog()).thenReturn(catalog);
+ // return empty so the orElseThrow inside gsonPostProcess fires
and drops into the catch block
+
Mockito.doReturn(java.util.Optional.empty()).when(catalog).getDb(Mockito.anyLong());
+
+ RoutineLoadJob routineLoadJob = new KafkaRoutineLoadJob();
+ Deencapsulation.setField(routineLoadJob, "state",
RoutineLoadJob.JobState.RUNNING);
+ // a non-parsable origin statement guarantees the parsing fails
and falls into the catch block,
+ // and also keeps origStmt non-null so the catch block's logging
does not NPE
+ Deencapsulation.setField(routineLoadJob, "origStmt", new
OriginStatement("invalid stmt", 0));
+ routineLoadJob.gsonPostProcess();
+
+ Assertions.assertEquals(RoutineLoadJob.JobState.CANCELLED,
routineLoadJob.getState());
+ Assertions.assertTrue(routineLoadJob.getEndTimestamp() != -1,
+ "endTimestamp must be set when terminalizing the job");
+ Assertions.assertNotNull(Deencapsulation.getField(routineLoadJob,
"cancelReason"));
+ }
+ }
+
+ // A final job whose endTimestamp is not yet set (e.g. cleanup racing with
an in-progress
+ // cancel/stop) must not be expired this round, and must not throw and
break cleanup.
+ @Test
+ public void testIsExpiredSkipsWhenEndTimestampMissing() {
+ RoutineLoadJob missingEndTs = new KafkaRoutineLoadJob();
+ Deencapsulation.setField(missingEndTs, "state",
RoutineLoadJob.JobState.CANCELLED);
+ // endTimestamp keeps its default value -1
+ Assertions.assertFalse(missingEndTs.isExpired());
+
+ // a non-final job is never expired
+ RoutineLoadJob running = new KafkaRoutineLoadJob();
+ Deencapsulation.setField(running, "state",
RoutineLoadJob.JobState.RUNNING);
+ Assertions.assertFalse(running.isExpired());
+
+ // a final job that ended long ago (epoch) is expired via the normal
path
+ RoutineLoadJob oldJob = new KafkaRoutineLoadJob();
+ Deencapsulation.setField(oldJob, "state",
RoutineLoadJob.JobState.CANCELLED);
+ Deencapsulation.setField(oldJob, "endTimestamp", 0L);
+ Assertions.assertTrue(oldJob.isExpired());
+ }
+
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]