This is an automated email from the ASF dual-hosted git repository.
yujun777 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 a565aca4478 [fix](regression) Stop MTMV task waits from latching onto
the previous task (#67710)
a565aca4478 is described below
commit a565aca4478ee22bb5f9332fd0ba69668dcb001e
Author: yujun <[email protected]>
AuthorDate: Thu Sep 10 11:32:59 2026 +0800
[fix](regression) Stop MTMV task waits from latching onto the previous task
(#67710)
The shared MTMV task waits (`waitingMTMVTaskFinishedByMvName`,
`waitingMTMVTaskFinishedByMvNameAllowCancel`, `waitingMTMVTaskFinished`,
`waitingMTMVTaskFinishedWithoutAnalyze`,
`waitingMTMVTaskFinishedNotNeedSuccess`) read the newest row of
`tasks('type'='mv')` with `order by CreateTime DESC limit 1` and trusted
it as the task they had just submitted. A task that just finished is
briefly missing from that list: the job removes it from its running list
before the MV history gets it, so a poll that lands in that window reads
the previous task of the same MV, whose status is already terminal. The
committed-not-visible regression hit this on CI (expected SUCCESS but
read the previous FAILED task).
Key changes:
- Add `pollMTMVTaskTerminal()`: a terminal row is trusted only when the
same task was seen running by this wait, or is seen terminal twice in a
row and no earlier wait in the suite returned it (so a wait cannot latch
onto an already-reported task).
- The five `waitingMTMVTaskFinished*` helpers now share that wait
instead of five copies of the polling loop; their SQL, logging,
assertions and analyze step are unchanged.
- Add `SuiteMTMVTaskWaitTest` covering a task seen running, a terminal
row that needs confirmation, the previous task showing through the gap,
and a repeated wait for a task that already finished (bounded fallback).
---
.../org/apache/doris/regression/suite/Suite.groovy | 212 +++++++++------------
.../regression/suite/SuiteMTMVTaskWaitTest.groovy | 76 ++++++++
2 files changed, 168 insertions(+), 120 deletions(-)
diff --git
a/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/Suite.groovy
b/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/Suite.groovy
index 875029ec84f..d906c610ed8 100644
---
a/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/Suite.groovy
+++
b/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/Suite.groovy
@@ -113,6 +113,10 @@ class Suite implements GroovyInterceptable {
private AmazonS3 s3Client = null
private FileSystem fs = null
+ // MTMV task ids that a wait in this suite already returned as terminal. A
task that just
+ // finished is briefly absent from tasks(), so a later wait can fall back
to one of these.
+ private final Set<String> finishedMTMVTaskIds =
Collections.synchronizedSet(new HashSet<>())
+
Suite(String name, String group, SuiteContext context, SuiteCluster
cluster) {
this.name = name
this.group = group
@@ -2019,39 +2023,99 @@ class Suite implements GroovyInterceptable {
return debugPoint
}
- def waitingMTMVTaskFinishedByMvName = { mvName, dbName = context.dbName ->
- // Wait for the newly submitted MTMV task to become visible in tasks().
- Thread.sleep(2000);
- String showTasks = """
- select TaskId, Status, MvName, MvDatabaseName from
tasks('type'='mv')
- where MvDatabaseName = '${dbName}' and MvName = '${mvName}'
- order by CreateTime DESC limit 1
- """
+ /**
+ * Poll a tasks('type'='mv') query until the newest row is terminal and
return that row.
+ * A task that just finished is briefly missing from tasks(): the job
removes it from its
+ * running list before the MV history gets it, so while that window is
open the newest row
+ * is the previous task of the same MV. A terminal row is therefore
trusted only when the
+ * same task was already seen running by this wait, or is seen terminal
twice in a row and
+ * no earlier wait returned it; the window lasts tens of milliseconds, far
less than the
+ * poll interval, so one extra poll is enough to tell the new task from
the previous one
+ * even when the previous task was not waited for before.
+ * poll() runs the query once, log() receives the progress messages, and
the two intervals
+ * exist so tests can shorten them. The last row seen is returned when the
timeout expires,
+ * so callers can report a status.
+ */
+ static List<Object> pollMTMVTaskTerminal(String showTasks, String caller,
Set<String> finishedTaskIds,
+ Closure<List<List<Object>>> poll, Closure<String> log, long
pollIntervalMs, long skipFinishedMs) {
String status = "NULL"
- List<List<Object>> result
- long timeoutTimestamp = System.currentTimeMillis() + 5 * 60 * 1000 //
5 min
+ String runningTaskId = null
+ String confirmedTaskId = null
String lastLoggedStatus = null
- List<Object> toCheckTaskRow = null
- while (timeoutTimestamp > System.currentTimeMillis() && (status ==
'PENDING' || status == 'RUNNING' || status == 'NULL')) {
- result = sql(showTasks)
+ List<Object> taskRow = null
+ long skipFinishedDeadline = 0
+ long timeoutTimestamp = System.currentTimeMillis() + 5 * 60 * 1000 //
5 min
+ while (timeoutTimestamp > System.currentTimeMillis()) {
+ List<List<Object>> result = poll()
if (result.isEmpty()) {
if (lastLoggedStatus != "NULL") {
- logger.info("waitingMTMVTaskFinishedByMvName
toCheckTaskRow is empty")
+ log("${caller} task row is empty")
lastLoggedStatus = "NULL"
}
- Thread.sleep(500);
+ confirmedTaskId = null
+ Thread.sleep(pollIntervalMs);
continue;
}
- toCheckTaskRow = result[0]
- status = toCheckTaskRow.get(1).toString()
+ taskRow = result[0]
+ String taskId = taskRow.get(0).toString()
+ status = taskRow.get(1).toString()
if (lastLoggedStatus != status) {
- logger.info("The state of ${showTasks} is ${status}, taskId is
${toCheckTaskRow.get(0)}")
+ log("The state of ${showTasks} is ${status}, taskId is
${taskId}")
lastLoggedStatus = status
}
- if (status == 'PENDING' || status == 'RUNNING' || status ==
'NULL') {
- Thread.sleep(500);
+ if (status == 'PENDING' || status == 'RUNNING') {
+ runningTaskId = taskId
+ confirmedTaskId = null
+ Thread.sleep(pollIntervalMs);
+ continue;
}
+ if (finishedTaskIds.contains(taskId)) {
+ // A wait already returned this task, so it is the previous
task and the one
+ // being waited for is still in the gap. Skip it for as long
as any real gap
+ // could last; a caller that waits twice for the same task
(nothing new was
+ // submitted) then falls back to it instead of hitting the 5
minute timeout.
+ if (skipFinishedDeadline == 0) {
+ skipFinishedDeadline = System.currentTimeMillis() +
skipFinishedMs
+ }
+ if (System.currentTimeMillis() < skipFinishedDeadline) {
+ confirmedTaskId = null
+ Thread.sleep(pollIntervalMs);
+ continue;
+ }
+ }
+ if (taskId == runningTaskId || taskId == confirmedTaskId) {
+ finishedTaskIds.add(taskId)
+ return taskRow
+ }
+ // The first sighting of a terminal task may be the previous task
seen through the
+ // gap described above; confirm the same task once more before
trusting it.
+ confirmedTaskId = taskId
+ Thread.sleep(pollIntervalMs);
}
+ return taskRow
+ }
+
+ /**
+ * Wait for the newest MTMV task matching showTasks to reach a terminal
state and return
+ * its row. See pollMTMVTaskTerminal for why a terminal row needs to be
confirmed.
+ */
+ List<Object> waitMTMVTaskTerminal(String showTasks, String caller,
+ long pollIntervalMs = 500, long skipFinishedMs = 30 * 1000) {
+ return pollMTMVTaskTerminal(showTasks, caller, finishedMTMVTaskIds,
+ { -> sql(showTasks) }, { String message ->
logger.info(message) },
+ pollIntervalMs, skipFinishedMs)
+ }
+
+ def waitingMTMVTaskFinishedByMvName = { mvName, dbName = context.dbName ->
+ // Wait for the newly submitted MTMV task to become visible in tasks().
+ Thread.sleep(2000);
+ String showTasks = """
+ select TaskId, Status, MvName, MvDatabaseName from
tasks('type'='mv')
+ where MvDatabaseName = '${dbName}' and MvName = '${mvName}'
+ order by CreateTime DESC limit 1
+ """
+ List<Object> toCheckTaskRow = waitMTMVTaskTerminal(showTasks,
"waitingMTMVTaskFinishedByMvName")
+ String status = toCheckTaskRow == null ? "NULL" :
toCheckTaskRow.get(1).toString()
if (status != "SUCCESS") {
logger.info("status is ${status}")
}
@@ -2071,31 +2135,8 @@ class Suite implements GroovyInterceptable {
order by CreateTime DESC limit 1
"""
- String status = "NULL"
- List<List<Object>> result
- long timeoutTimestamp = System.currentTimeMillis() + 5 * 60 * 1000 //
5 min
- String lastLoggedStatus = null
- List<Object> toCheckTaskRow = null
- while (timeoutTimestamp > System.currentTimeMillis() && (status ==
'PENDING' || status == 'RUNNING' || status == 'NULL')) {
- result = sql(showTasks)
- if (result.isEmpty()) {
- if (lastLoggedStatus != "NULL") {
- logger.info("waitingMTMVTaskFinishedByMvName
toCheckTaskRow is empty")
- lastLoggedStatus = "NULL"
- }
- Thread.sleep(500);
- continue;
- }
- toCheckTaskRow = result[0]
- status = toCheckTaskRow.get(1).toString()
- if (lastLoggedStatus != status) {
- logger.info("The state of ${showTasks} is ${status}, taskId is
${toCheckTaskRow.get(0)}")
- lastLoggedStatus = status
- }
- if (status == 'PENDING' || status == 'RUNNING' || status == 'NULL'
|| status == 'CANCELED') {
- Thread.sleep(500);
- }
- }
+ List<Object> toCheckTaskRow = waitMTMVTaskTerminal(showTasks,
"waitingMTMVTaskFinishedByMvNameAllowCancel")
+ String status = toCheckTaskRow == null ? "NULL" :
toCheckTaskRow.get(1).toString()
if (status != "SUCCESS") {
logger.info("status is not success")
Assert.assertNotNull(toCheckTaskRow)
@@ -2183,31 +2224,8 @@ class Suite implements GroovyInterceptable {
select TaskId, Status, MvName, MvDatabaseName from
tasks('type'='mv')
where JobName = '${jobName}' order by CreateTime DESC limit 1
"""
- String status = "NULL"
- List<List<Object>> result
- long timeoutTimestamp = System.currentTimeMillis() + 5 * 60 * 1000 //
5 min
- String lastLoggedStatus = null
- List<Object> taskRow = null
- do {
- result = sql(showTasks)
- if (result.isEmpty()) {
- if (lastLoggedStatus != "NULL") {
- logger.info("waitingMTMVTaskFinished task row is empty")
- lastLoggedStatus = "NULL"
- }
- status = "NULL"
- } else {
- taskRow = result[0]
- status = taskRow.get(1).toString()
- if (lastLoggedStatus != status) {
- logger.info("The state of ${showTasks} is ${status},
taskId is ${taskRow.get(0)}")
- lastLoggedStatus = status
- }
- }
- if (status == 'PENDING' || status == 'RUNNING' || status ==
'NULL') {
- Thread.sleep(500);
- }
- } while (timeoutTimestamp > System.currentTimeMillis() && (status ==
'PENDING' || status == 'RUNNING' || status == 'NULL'))
+ List<Object> taskRow = waitMTMVTaskTerminal(showTasks,
"waitingMTMVTaskFinished")
+ String status = taskRow == null ? "NULL" : taskRow.get(1).toString()
if (status != "SUCCESS") {
logger.info("status is ${status}")
}
@@ -2226,31 +2244,8 @@ class Suite implements GroovyInterceptable {
select TaskId, Status from tasks('type'='mv')
where JobName = '${jobName}' order by CreateTime DESC limit 1
"""
- String status = "NULL"
- List<List<Object>> result
- long timeoutTimestamp = System.currentTimeMillis() + 5 * 60 * 1000 //
5 min
- String lastLoggedStatus = null
- List<Object> taskRow = null
- do {
- result = sql(showTasks)
- if (result.isEmpty()) {
- if (lastLoggedStatus != "NULL") {
- logger.info("waitingMTMVTaskFinishedWithoutAnalyze task
row is empty")
- lastLoggedStatus = "NULL"
- }
- status = "NULL"
- } else {
- taskRow = result[0]
- status = taskRow.get(1).toString()
- if (lastLoggedStatus != status) {
- logger.info("The state of ${showTasks} is ${status},
taskId is ${taskRow.get(0)}")
- lastLoggedStatus = status
- }
- }
- if (status == 'PENDING' || status == 'RUNNING' || status ==
'NULL') {
- Thread.sleep(500);
- }
- } while (timeoutTimestamp > System.currentTimeMillis() && (status ==
'PENDING' || status == 'RUNNING' || status == 'NULL'))
+ List<Object> taskRow = waitMTMVTaskTerminal(showTasks,
"waitingMTMVTaskFinishedWithoutAnalyze")
+ String status = taskRow == null ? "NULL" : taskRow.get(1).toString()
if (status != "SUCCESS") {
logger.info("status is not success")
}
@@ -2264,31 +2259,8 @@ class Suite implements GroovyInterceptable {
select TaskId, Status from tasks('type'='mv')
where JobName = '${jobName}' order by CreateTime DESC limit 1
"""
- String status = "NULL"
- List<List<Object>> result
- long timeoutTimestamp = System.currentTimeMillis() + 5 * 60 * 1000 //
5 min
- String lastLoggedStatus = null
- List<Object> taskRow = null
- do {
- result = sql(showTasks)
- if (result.isEmpty()) {
- if (lastLoggedStatus != "NULL") {
- logger.info("waitingMTMVTaskFinishedNotNeedSuccess task
row is empty")
- lastLoggedStatus = "NULL"
- }
- status = "NULL"
- } else {
- taskRow = result[0]
- status = taskRow.get(1).toString()
- if (lastLoggedStatus != status) {
- logger.info("The state of ${showTasks} is ${status},
taskId is ${taskRow.get(0)}")
- lastLoggedStatus = status
- }
- }
- if (status == 'PENDING' || status == 'RUNNING' || status ==
'NULL') {
- Thread.sleep(500);
- }
- } while (timeoutTimestamp > System.currentTimeMillis() && (status ==
'PENDING' || status == 'RUNNING' || status == 'NULL'))
+ List<Object> taskRow = waitMTMVTaskTerminal(showTasks,
"waitingMTMVTaskFinishedNotNeedSuccess")
+ String status = taskRow == null ? "NULL" : taskRow.get(1).toString()
if (status != "SUCCESS") {
logger.info("status is not success")
}
diff --git
a/regression-test/framework/src/test/groovy/org/apache/doris/regression/suite/SuiteMTMVTaskWaitTest.groovy
b/regression-test/framework/src/test/groovy/org/apache/doris/regression/suite/SuiteMTMVTaskWaitTest.groovy
new file mode 100644
index 00000000000..39e5a7d8ea6
--- /dev/null
+++
b/regression-test/framework/src/test/groovy/org/apache/doris/regression/suite/SuiteMTMVTaskWaitTest.groovy
@@ -0,0 +1,76 @@
+// 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.regression.suite
+
+import org.junit.jupiter.api.Test
+
+import static org.junit.jupiter.api.Assertions.assertEquals
+
+class SuiteMTMVTaskWaitTest {
+ private static final String SHOW_TASKS = "select TaskId, Status from
tasks('type'='mv')"
+ private static final Closure<String> IGNORE_LOG = { String message -> }
+
+ private static List<Object> task(String taskId, String status) {
+ return [taskId, status] as List<Object>
+ }
+
+ private static Closure<List<List<Object>>> polls(List<List<List<Object>>>
script) {
+ Deque<List<List<Object>>> queue = new ArrayDeque<>(script)
+ return { ->
+ if (queue.isEmpty()) {
+ throw new IllegalStateException("poll script exhausted, the
wait did not converge")
+ }
+ return queue.poll()
+ }
+ }
+
+ private static String waitFor(Set<String> finished,
List<List<List<Object>>> script) {
+ return Suite.pollMTMVTaskTerminal(SHOW_TASKS, "test", finished,
polls(script), IGNORE_LOG, 1, 5)
+ .get(0).toString()
+ }
+
+ @Test
+ void followsATaskThatWasSeenRunning() {
+ assertEquals("X", waitFor(new HashSet<>(), [[task("X", "RUNNING")],
[task("X", "SUCCESS")]]))
+ }
+
+ @Test
+ void confirmsATerminalRowThatWasNeverSeenRunning() {
+ assertEquals("X", waitFor(new HashSet<>(), [[task("X", "SUCCESS")],
[task("X", "SUCCESS")]]))
+ }
+
+ @Test
+ void ignoresThePreviousTaskWhileTheNewTaskIsMissing() {
+ Set<String> finished = new HashSet<>()
+ assertEquals("P", waitFor(finished, [[task("P", "RUNNING")],
[task("P", "SUCCESS")]]))
+
+ // X runs and finishes; while it moves from the running list to the MV
history the
+ // newest row of the MV is P again, and P must not be returned a
second time.
+ assertEquals("X", waitFor(finished, [[task("X", "RUNNING")],
[task("P", "SUCCESS")],
+ [task("X", "SUCCESS")]]))
+ }
+
+ @Test
+ void fallsBackToTheSameTaskWhenNothingNewIsSubmitted() {
+ Set<String> finished = new HashSet<>()
+ assertEquals("P", waitFor(finished, [[task("P", "RUNNING")],
[task("P", "SUCCESS")]]))
+
+ // Nothing new was submitted: the wait must return P instead of
polling to the timeout.
+ assertEquals("P", waitFor(finished, (1..100).collect { [task("P",
"SUCCESS")] }))
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]