You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@doris.apache.org by he...@apache.org on 2026/04/16 02:55:40 UTC
(doris) branch master updated: [regression](routine_load_p0)add routine load docker cases (#62481)
This is an automated email from the ASF dual-hosted git repository.
hellostephen 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 7a7e70795de [regression](routine_load_p0)add routine load docker cases (#62481)
7a7e70795de is described below
commit 7a7e70795de3f9e7be1e84a2e1a5a8a608921888
Author: MoanasDaddyXu <xu...@selectdb.com>
AuthorDate: Thu Apr 16 10:55:32 2026 +0800
[regression](routine_load_p0)add routine load docker cases (#62481)
---
.../load/routineload/RoutineLoadTaskScheduler.java | 7 +
..._routine_load_master_switch_exactly_once.groovy | 194 +++++++++++++++++++
...mem_limit_exceeded_recovery_exactly_once.groovy | 192 ++++++++++++++++++
...mow_partial_update_concurrent_compaction.groovy | 196 +++++++++++++++++++
..._load_mow_partial_update_with_compaction.groovy | 211 ++++++++++++++++++++
...d_partial_update_new_key_error_atomicity.groovy | 214 ++++++++++++++++++++
...ne_load_partition_expansion_exactly_once.groovy | 156 +++++++++++++++
...ad_same_job_offset_recovery_exactly_once.groovy | 180 +++++++++++++++++
...load_same_job_schema_replay_exactly_once.groovy | 215 +++++++++++++++++++++
...oad_too_many_tasks_recovery_exactly_once.groovy | 177 +++++++++++++++++
10 files changed, 1742 insertions(+)
diff --git a/fe/fe-core/src/main/java/org/apache/doris/load/routineload/RoutineLoadTaskScheduler.java b/fe/fe-core/src/main/java/org/apache/doris/load/routineload/RoutineLoadTaskScheduler.java
index 4156410ef86..4de5f22c026 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/load/routineload/RoutineLoadTaskScheduler.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/load/routineload/RoutineLoadTaskScheduler.java
@@ -322,6 +322,13 @@ public class RoutineLoadTaskScheduler extends MasterDaemon {
throw new LoadException("MEM_LIMIT_EXCEEDED");
}
+ if (DebugPointUtil.isEnable("FE.ROUTINE_LOAD_TASK_SUBMIT_FAILED.TOO_MANY_TASKS")) {
+ LOG.warn("debug point FE.ROUTINE_LOAD_TASK_SUBMIT_FAILED.TOO_MANY_TASKS,"
+ + "routine load task submit failed");
+ tStatus = new TStatus(TStatusCode.TOO_MANY_TASKS);
+ tStatus.addToErrorMsgs("debug point: too many tasks");
+ }
+
if (tStatus.getStatusCode() != TStatusCode.OK) {
throw new LoadException("failed to submit task. error code: " + tStatus.getStatusCode()
+ ", msg: " + (tStatus.getErrorMsgsSize() > 0 ? tStatus.getErrorMsgs().get(0) : "NaN"));
diff --git a/regression-test/suites/load_p0/routine_load/test_routine_load_master_switch_exactly_once.groovy b/regression-test/suites/load_p0/routine_load/test_routine_load_master_switch_exactly_once.groovy
new file mode 100644
index 00000000000..94748cc650d
--- /dev/null
+++ b/regression-test/suites/load_p0/routine_load/test_routine_load_master_switch_exactly_once.groovy
@@ -0,0 +1,194 @@
+// 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.Config
+import org.apache.doris.regression.suite.ClusterOptions
+import org.apache.doris.regression.util.RoutineLoadTestUtils
+import org.apache.kafka.clients.producer.ProducerRecord
+import org.junit.Assert
+
+suite("test_routine_load_master_switch_exactly_once", "docker") {
+ def topicSuffix = System.currentTimeMillis()
+ def kafkaCsvTopic = "test_rl_master_switch_${topicSuffix}"
+
+ if (!RoutineLoadTestUtils.isKafkaTestEnabled(context)) {
+ return
+ }
+
+ def kafkaBroker = RoutineLoadTestUtils.getKafkaBroker(context)
+
+ def options = new ClusterOptions()
+ options.setFeNum(3)
+ options.setBeNum(1)
+ options.cloudMode = false
+
+ docker(options) {
+ def producer = RoutineLoadTestUtils.createKafkaProducer(kafkaBroker)
+
+ def tableName = "test_rl_master_switch_exactly_once"
+ def job = "test_rl_master_switch_job_${topicSuffix}"
+ Integer oldMasterIndex = null
+
+ def firstBatch = (1..30).collect { i ->
+ "${i},payload_${i},2026-04-${String.format('%02d', (i % 28) + 1)}".toString()
+ }
+ def secondBatch = (31..40).collect { i ->
+ "${i},payload_${i},2026-04-${String.format('%02d', (i % 28) + 1)}".toString()
+ }
+
+ def waitForRowCountAtLeast = { long expectedMinRows, int maxWait = 120 ->
+ def waited = 0
+ while (waited < maxWait) {
+ def rowCount = sql "SELECT count(*) FROM ${tableName}"
+ long count = (rowCount[0][0] as Number).longValue()
+ if (count >= expectedMinRows) {
+ return count
+ }
+ sleep(1000)
+ waited++
+ }
+ Assert.fail("Row count did not reach ${expectedMinRows} within timeout")
+ }
+
+ def waitForExactRowCount = { long expected, int maxWait = 180 ->
+ def waited = 0
+ while (waited < maxWait) {
+ def state = sql "SHOW ROUTINE LOAD FOR ${job}"
+ def rowCount = sql "SELECT count(*) FROM ${tableName}"
+ long count = (rowCount[0][0] as Number).longValue()
+ logger.info("waitForExactRowCount: state={}, rows={}", state[0][8].toString(), count)
+ if (state[0][8].toString() == "RUNNING" && count == expected) {
+ return
+ }
+ sleep(1000)
+ waited++
+ }
+ Assert.fail("Row count did not reach ${expected} within timeout")
+ }
+
+ def reconnectToCurrentMaster = {
+ def currentMaster = cluster.getMasterFe()
+ Assert.assertNotNull("Current master FE should exist", currentMaster)
+ def masterJdbcUrl = Config.buildUrlWithDb(currentMaster.host, currentMaster.queryPort, context.dbName)
+ context.connectTo(masterJdbcUrl, context.config.jdbcUser, context.config.jdbcPassword)
+ }
+
+ sql """ DROP TABLE IF EXISTS ${tableName} """
+ sql """
+ CREATE TABLE IF NOT EXISTS ${tableName} (
+ `id` int NOT NULL,
+ `payload` string NULL,
+ `ts` string NULL
+ ) ENGINE=OLAP
+ DUPLICATE KEY(`id`)
+ DISTRIBUTED BY HASH(`id`) BUCKETS 1
+ PROPERTIES ("replication_allocation" = "tag.location.default: 1");
+ """
+ sql "sync"
+
+ try {
+ firstBatch.each { line ->
+ producer.send(new ProducerRecord<>(kafkaCsvTopic, null, line)).get()
+ }
+ producer.flush()
+
+ sql """
+ CREATE ROUTINE LOAD ${job} ON ${tableName}
+ COLUMNS TERMINATED BY ","
+ PROPERTIES
+ (
+ "max_batch_interval" = "5"
+ )
+ FROM KAFKA
+ (
+ "kafka_broker_list" = "${kafkaBroker}",
+ "kafka_topic" = "${kafkaCsvTopic}",
+ "property.kafka_default_offsets" = "OFFSET_BEGINNING"
+ );
+ """
+
+ waitForRowCountAtLeast(1L)
+
+ def oldMaster = cluster.getMasterFe()
+ Assert.assertNotNull("Master FE should exist before switch", oldMaster)
+ oldMasterIndex = oldMaster.index
+ cluster.stopFrontends(oldMasterIndex)
+
+ def waited = 0
+ while (waited < 180) {
+ def newMaster = cluster.getMasterFe()
+ if (newMaster != null && newMaster.index != oldMasterIndex) {
+ logger.info("Master switched from {} to {}", oldMasterIndex, newMaster.index)
+ break
+ }
+ sleep(1000)
+ waited++
+ }
+ Assert.assertTrue("A new master FE should be elected after old master stops",
+ cluster.getMasterFe() != null && cluster.getMasterFe().index != oldMasterIndex)
+
+ reconnectToCurrentMaster()
+
+ secondBatch.each { line ->
+ producer.send(new ProducerRecord<>(kafkaCsvTopic, null, line)).get()
+ }
+ producer.flush()
+
+ waitForExactRowCount(40L)
+
+ def finalCount = sql "SELECT count(*), count(distinct id), sum(id) FROM ${tableName}"
+ Assert.assertEquals(40L, (finalCount[0][0] as Number).longValue())
+ Assert.assertEquals(40L, (finalCount[0][1] as Number).longValue())
+ Assert.assertEquals(820L, (finalCount[0][2] as Number).longValue())
+
+ def boundaryRows = sql "SELECT id, payload FROM ${tableName} WHERE id IN (1, 30, 31, 40) ORDER BY id"
+ Assert.assertEquals(4, boundaryRows.size())
+ Assert.assertEquals(1, boundaryRows[0][0])
+ Assert.assertEquals("payload_1", boundaryRows[0][1])
+ Assert.assertEquals(30, boundaryRows[1][0])
+ Assert.assertEquals("payload_30", boundaryRows[1][1])
+ Assert.assertEquals(31, boundaryRows[2][0])
+ Assert.assertEquals("payload_31", boundaryRows[2][1])
+ Assert.assertEquals(40, boundaryRows[3][0])
+ Assert.assertEquals("payload_40", boundaryRows[3][1])
+
+ def finalState = sql "SHOW ROUTINE LOAD FOR ${job}"
+ Assert.assertEquals("RUNNING", finalState[0][8].toString())
+ } finally {
+ if (oldMasterIndex != null) {
+ try {
+ cluster.startFrontends(oldMasterIndex)
+ sleep(5000)
+ } catch (Exception e) {
+ logger.warn("Failed to restart old master FE {}: {}", oldMasterIndex, e.message)
+ }
+ }
+ try {
+ reconnectToCurrentMaster()
+ } catch (Exception e) {
+ logger.warn("Failed to reconnect FE during cleanup: {}", e.message)
+ }
+ try {
+ sql "STOP ROUTINE LOAD FOR ${job}"
+ } catch (Exception e) {
+ logger.warn("Failed to stop routine load: {}", e.message)
+ }
+ producer.close()
+ sql "DROP TABLE IF EXISTS ${tableName}"
+ }
+ }
+}
diff --git a/regression-test/suites/load_p0/routine_load/test_routine_load_mem_limit_exceeded_recovery_exactly_once.groovy b/regression-test/suites/load_p0/routine_load/test_routine_load_mem_limit_exceeded_recovery_exactly_once.groovy
new file mode 100644
index 00000000000..b079a4db034
--- /dev/null
+++ b/regression-test/suites/load_p0/routine_load/test_routine_load_mem_limit_exceeded_recovery_exactly_once.groovy
@@ -0,0 +1,192 @@
+// 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.suite.ClusterOptions
+import org.apache.doris.regression.util.RoutineLoadTestUtils
+import org.apache.kafka.clients.producer.ProducerRecord
+import org.junit.Assert
+
+suite("test_routine_load_mem_limit_exceeded_recovery_exactly_once", "docker") {
+ // 使用时间戳后缀确保每次运行使用不同 topic,避免历史消息污染精确计数断言
+ def topicSuffix = System.currentTimeMillis()
+ def kafkaCsvTopic = "test_rl_mem_limit_recovery_${topicSuffix}"
+
+ if (!RoutineLoadTestUtils.isKafkaTestEnabled(context)) {
+ return
+ }
+
+ def kafkaPort = context.config.otherConfigs.get("kafka_port")
+ Assert.assertNotNull("kafka_port must be configured in regression-conf.groovy", kafkaPort)
+ def kafka_broker = "127.0.0.1:${kafkaPort}"
+
+ def options = new ClusterOptions()
+ options.setFeNum(1)
+ options.setBeNum(1)
+ options.cloudMode = false
+ options.enableDebugPoints()
+
+ docker(options) {
+ def producer = RoutineLoadTestUtils.createKafkaProducer(kafka_broker)
+
+ def tableName = "test_rl_mem_limit_recovery"
+ def job = "test_mem_limit_recovery_job_${topicSuffix}"
+ def debugPoint = "FE.ROUTINE_LOAD_TASK_SUBMIT_FAILED.MEM_LIMIT_EXCEEDED"
+
+ sql """ DROP TABLE IF EXISTS ${tableName} """
+ sql """
+ CREATE TABLE IF NOT EXISTS ${tableName} (
+ `id` int NOT NULL,
+ `payload` string NULL,
+ `ts` string NULL
+ ) ENGINE=OLAP
+ DUPLICATE KEY(`id`)
+ DISTRIBUTED BY HASH(`id`) BUCKETS 1
+ PROPERTIES ("replication_allocation" = "tag.location.default: 1");
+ """
+ sql "sync"
+
+ // 构造确定性测试数据:id=1..20
+ List<String> testData = []
+ for (int i = 1; i <= 20; i++) {
+ testData.add("${i},payload_${i},2026-01-${String.format('%02d', i)}".toString())
+ }
+
+ // 先发送数据到 Kafka
+ testData.each { line ->
+ logger.info("Sending to Kafka: ${line}")
+ def record = new ProducerRecord<>(kafkaCsvTopic, null, line.toString())
+ producer.send(record).get()
+ }
+ producer.flush()
+ logger.info("All 20 records sent to Kafka")
+
+ try {
+ // 第一阶段: 开启 debug point,模拟 MEM_LIMIT_EXCEEDED
+ // 该 debug point 在 RoutineLoadTaskScheduler.java (FE),必须用 enableDebugPointForAllFEs
+ GetDebugPoint().enableDebugPointForAllFEs(debugPoint)
+ logger.info("Debug point enabled: ${debugPoint}")
+
+ sql """
+ CREATE ROUTINE LOAD ${job} ON ${tableName}
+ COLUMNS TERMINATED BY ","
+ PROPERTIES
+ (
+ "max_batch_interval" = "5"
+ )
+ FROM KAFKA
+ (
+ "kafka_broker_list" = "${kafka_broker}",
+ "kafka_topic" = "${kafkaCsvTopic}",
+ "property.kafka_default_offsets" = "OFFSET_BEGINNING"
+ );
+ """
+
+ // 等待一段时间让任务提交失败
+ def waitCount = 0
+ def sawFailure = false
+ while (waitCount < 30) {
+ sleep(1000)
+ def state = sql "show routine load for ${job}"
+ def routineLoadState = state[0][8].toString()
+ def statistic = state[0][14].toString()
+ def otherMsg = state[0][19].toString()
+ logger.info("State: ${routineLoadState}, stats: ${statistic}, msg: ${otherMsg}")
+
+ // 解析 JSON 检查 abortedTaskNum 是否 > 0,这才是真实失败信号
+ // statistic.contains("abortedTaskNum") 始终为 true,不能作为判断依据
+ def statJson = new groovy.json.JsonSlurper().parseText(statistic)
+ long abortedCount = statJson.abortedTaskNum as long
+ if (abortedCount > 0 || otherMsg.contains("MEM_LIMIT_EXCEEDED")) {
+ sawFailure = true
+ logger.info("Detected MEM_LIMIT_EXCEEDED failure: abortedTaskNum=${abortedCount}, msg=${otherMsg}")
+ break
+ }
+ waitCount++
+ }
+ // 断言确实观察到了 debug point 生效引起的失败,防止 debug point 未命中时 case 悄悄通过
+ Assert.assertTrue("应观察到 MEM_LIMIT_EXCEEDED 失败(abortedTaskNum>0 或 otherMsg 含相关信息)", sawFailure)
+
+ // 检查失败阶段数据状态:应该没有完整消费
+ def failPhaseCount = sql "select count(*) from ${tableName}"
+ logger.info("Row count during failure phase: ${failPhaseCount[0][0]}")
+
+ // 第二阶段: 关闭 debug point,让 job 自动恢复
+ GetDebugPoint().disableDebugPointForAllFEs(debugPoint)
+ logger.info("Debug point disabled, waiting for recovery...")
+
+ // 等待 job 恢复并消费完成
+ def maxWaitCount = 120
+ def count = 0
+ while (true) {
+ def state = sql "show routine load for ${job}"
+ def routineLoadState = state[0][8].toString()
+ def statistic = state[0][14].toString()
+ logger.info("Recovery phase - State: ${routineLoadState}, stats: ${statistic}")
+
+ def rowCount = sql "select count(*) from ${tableName}"
+ if (routineLoadState == "RUNNING" && rowCount[0][0] == 20) {
+ logger.info("Job recovered and all 20 rows consumed")
+ break
+ }
+ if (count > maxWaitCount) {
+ Assert.fail("Routine load did not recover within timeout. " +
+ "State: ${routineLoadState}, rows: ${rowCount[0][0]}")
+ }
+ sleep(1000)
+ count++
+ }
+
+ // 核心断言: 精确不重不漏
+ def finalCount = sql "select count(*) from ${tableName}"
+ Assert.assertEquals("应精确消费 20 行", 20L, finalCount[0][0])
+
+ def distinctCount = sql "select count(distinct id) from ${tableName}"
+ Assert.assertEquals("不应有重复 id", 20L, distinctCount[0][0])
+
+ def sumId = sql "select sum(id) from ${tableName}"
+ Assert.assertEquals("sum(id) 应为 1+2+...+20=210", 210L, sumId[0][0])
+
+ def minId = sql "select min(id) from ${tableName}"
+ Assert.assertEquals("min(id) 应为 1", 1, minId[0][0])
+
+ def maxId = sql "select max(id) from ${tableName}"
+ Assert.assertEquals("max(id) 应为 20", 20, maxId[0][0])
+
+ // 验证 job 仍然是通过自然恢复的,而不是人工重建
+ def finalState = sql "show routine load for ${job}"
+ Assert.assertEquals("最终状态应为 RUNNING", "RUNNING", finalState[0][8].toString())
+ logger.info("All exactly-once assertions passed after MEM_LIMIT_EXCEEDED recovery")
+
+ } catch (Exception e) {
+ logger.error("Test failed: ${e.getMessage()}")
+ throw e
+ } finally {
+ try {
+ GetDebugPoint().disableDebugPointForAllFEs(debugPoint)
+ } catch (Exception e) {
+ logger.warn("Failed to disable debug point in cleanup: ${e.message}")
+ }
+ try {
+ sql "stop routine load for ${job}"
+ } catch (Exception e) {
+ logger.warn("Failed to stop routine load: ${e.message}")
+ }
+ producer.close()
+ sql "DROP TABLE IF EXISTS ${tableName}"
+ }
+ }
+}
diff --git a/regression-test/suites/load_p0/routine_load/test_routine_load_mow_partial_update_concurrent_compaction.groovy b/regression-test/suites/load_p0/routine_load/test_routine_load_mow_partial_update_concurrent_compaction.groovy
new file mode 100644
index 00000000000..e9d36528501
--- /dev/null
+++ b/regression-test/suites/load_p0/routine_load/test_routine_load_mow_partial_update_concurrent_compaction.groovy
@@ -0,0 +1,196 @@
+// 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 groovy.json.JsonSlurper
+import org.apache.doris.regression.suite.ClusterOptions
+import org.apache.doris.regression.util.RoutineLoadTestUtils
+import org.apache.kafka.clients.producer.ProducerRecord
+import org.junit.Assert
+
+suite("test_routine_load_mow_partial_update_concurrent_compaction", "docker") {
+ def topicSuffix = System.currentTimeMillis()
+ def kafkaCsvTopic = "test_rl_mow_pu_concurrent_compaction_${topicSuffix}"
+
+ if (!RoutineLoadTestUtils.isKafkaTestEnabled(context)) {
+ return
+ }
+
+ def kafkaBroker = RoutineLoadTestUtils.getKafkaBroker(context)
+
+ def options = new ClusterOptions()
+ options.setFeNum(1)
+ options.setBeNum(1)
+ options.cloudMode = false
+ options.enableDebugPoints()
+
+ docker(options) {
+ def producer = RoutineLoadTestUtils.createKafkaProducer(kafkaBroker)
+
+ def tableName = "test_rl_mow_pu_concurrent_compaction"
+ def job = "test_mow_pu_concurrent_compaction_job_${topicSuffix}"
+ def compactionDebugPoint = "FullCompaction.modify_rowsets.sleep"
+
+ def waitForLoadedRows = { long expectedLoadedRows, int maxWait = 120 ->
+ def waited = 0
+ while (waited < maxWait) {
+ def res = sql "SHOW ROUTINE LOAD FOR ${job}"
+ def state = res[0][8].toString()
+ def statJson = new JsonSlurper().parseText(res[0][14].toString())
+ long loaded = statJson.loadedRows as long
+ logger.info("waitForLoadedRows: state={}, loadedRows={}", state, loaded)
+ if (state == "RUNNING" && loaded >= expectedLoadedRows) {
+ return
+ }
+ sleep(1000)
+ waited++
+ }
+ Assert.fail("loadedRows did not reach ${expectedLoadedRows} within timeout")
+ }
+
+ sql """ DROP TABLE IF EXISTS ${tableName} force """
+ sql """
+ CREATE TABLE IF NOT EXISTS ${tableName} (
+ `k` int NOT NULL,
+ `c1` int,
+ `c2` int,
+ `c3` int
+ ) ENGINE=OLAP
+ UNIQUE KEY(`k`)
+ DISTRIBUTED BY HASH(`k`) BUCKETS 1
+ PROPERTIES (
+ "replication_allocation" = "tag.location.default: 1",
+ "enable_unique_key_merge_on_write" = "true",
+ "disable_auto_compaction" = "true"
+ );
+ """
+
+ sql """
+ INSERT INTO ${tableName} VALUES
+ (1, 1, 11, 111),
+ (2, 2, 22, 222),
+ (3, 3, 33, 333)
+ """
+ sql "sync"
+
+ def batch1 = [
+ "1,10,110",
+ "2,20,220"
+ ]
+ def batch2 = [
+ "1,11,1110",
+ "3,30,330"
+ ]
+ def batch3 = [
+ "2,21,2210",
+ "3,31,3310"
+ ]
+
+ def compactionError = null
+ Thread compactionThread = null
+
+ try {
+ sql """
+ CREATE ROUTINE LOAD ${job} ON ${tableName}
+ COLUMNS TERMINATED BY ",",
+ COLUMNS (k, c1, c2)
+ PROPERTIES
+ (
+ "max_batch_interval" = "1",
+ "partial_columns" = "true"
+ )
+ FROM KAFKA
+ (
+ "kafka_broker_list" = "${kafkaBroker}",
+ "kafka_topic" = "${kafkaCsvTopic}",
+ "property.kafka_default_offsets" = "OFFSET_BEGINNING"
+ );
+ """
+
+ batch1.each { line ->
+ producer.send(new ProducerRecord<>(kafkaCsvTopic, null, line)).get()
+ }
+ producer.flush()
+ waitForLoadedRows(2L)
+
+ GetDebugPoint().enableDebugPointForAllBEs(compactionDebugPoint)
+ compactionThread = Thread.start {
+ try {
+ trigger_and_wait_compaction(tableName, "full")
+ } catch (Throwable t) {
+ compactionError = t
+ }
+ }
+
+ sleep(2000)
+
+ batch2.each { line ->
+ producer.send(new ProducerRecord<>(kafkaCsvTopic, null, line)).get()
+ }
+ producer.flush()
+
+ sleep(1500)
+
+ batch3.each { line ->
+ producer.send(new ProducerRecord<>(kafkaCsvTopic, null, line)).get()
+ }
+ producer.flush()
+
+ waitForLoadedRows(6L, 180)
+
+ compactionThread.join(120000)
+ Assert.assertFalse("Compaction thread should finish within timeout", compactionThread.isAlive())
+ if (compactionError != null) {
+ throw compactionError
+ }
+
+ def finalRows = sql "SELECT k, c1, c2, c3 FROM ${tableName} ORDER BY k"
+ Assert.assertEquals(3, finalRows.size())
+ Assert.assertEquals(1, finalRows[0][0])
+ Assert.assertEquals(11, finalRows[0][1])
+ Assert.assertEquals(1110, finalRows[0][2])
+ Assert.assertEquals(111, finalRows[0][3])
+
+ Assert.assertEquals(2, finalRows[1][0])
+ Assert.assertEquals(21, finalRows[1][1])
+ Assert.assertEquals(2210, finalRows[1][2])
+ Assert.assertEquals(222, finalRows[1][3])
+
+ Assert.assertEquals(3, finalRows[2][0])
+ Assert.assertEquals(31, finalRows[2][1])
+ Assert.assertEquals(3310, finalRows[2][2])
+ Assert.assertEquals(333, finalRows[2][3])
+
+ def finalCount = sql "SELECT count(*), count(distinct k), sum(k) FROM ${tableName}"
+ Assert.assertEquals(3L, (finalCount[0][0] as Number).longValue())
+ Assert.assertEquals(3L, (finalCount[0][1] as Number).longValue())
+ Assert.assertEquals(6L, (finalCount[0][2] as Number).longValue())
+ } finally {
+ try {
+ GetDebugPoint().disableDebugPointForAllBEs(compactionDebugPoint)
+ } catch (Exception e) {
+ logger.warn("Failed to disable compaction debug point: {}", e.message)
+ }
+ try {
+ sql "STOP ROUTINE LOAD FOR ${job}"
+ } catch (Exception e) {
+ logger.warn("Failed to stop routine load: {}", e.message)
+ }
+ producer.close()
+ sql "DROP TABLE IF EXISTS ${tableName} force"
+ }
+ }
+}
diff --git a/regression-test/suites/load_p0/routine_load/test_routine_load_mow_partial_update_with_compaction.groovy b/regression-test/suites/load_p0/routine_load/test_routine_load_mow_partial_update_with_compaction.groovy
new file mode 100644
index 00000000000..f88a8eae8b8
--- /dev/null
+++ b/regression-test/suites/load_p0/routine_load/test_routine_load_mow_partial_update_with_compaction.groovy
@@ -0,0 +1,211 @@
+// 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.producer.KafkaProducer
+import org.apache.kafka.clients.producer.ProducerRecord
+import org.junit.Assert
+
+suite("test_routine_load_mow_partial_update_with_compaction", "nonConcurrent") {
+ // 使用时间戳后缀确保每次运行使用不同 topic,避免历史消息污染精确计数断言
+ def topicSuffix = System.currentTimeMillis()
+ def kafkaCsvTopic = "test_rl_mow_pu_compaction_${topicSuffix}"
+
+ if (RoutineLoadTestUtils.isKafkaTestEnabled(context)) {
+ def runSql = { String q -> sql q }
+ def kafka_broker = RoutineLoadTestUtils.getKafkaBroker(context)
+ def producer = RoutineLoadTestUtils.createKafkaProducer(kafka_broker)
+
+ def tableName = "test_rl_mow_pu_compaction"
+ def job = "test_mow_pu_compaction_job_${topicSuffix}"
+
+ // 基于 loadedRows 统计的等待 helper,适用于 MOW partial update(行数不增加的场景)
+ def waitForLoadedRows = { String jobName, long expectedLoadedRows, int maxWait = 60 ->
+ def waited = 0
+ while (waited < maxWait) {
+ def res = sql "show routine load for ${jobName}"
+ def state = res[0][8].toString()
+ def statJson = new groovy.json.JsonSlurper().parseText(res[0][14].toString())
+ long loaded = statJson.loadedRows as long
+ logger.info("waitForLoadedRows: state=${state}, loadedRows=${loaded}, expected>=${expectedLoadedRows}")
+ if (state == "RUNNING" && loaded >= expectedLoadedRows) break
+ if (waited >= maxWait - 1) {
+ Assert.fail("Timeout waiting for loadedRows >= ${expectedLoadedRows}, got ${loaded}")
+ }
+ sleep(1000)
+ waited++
+ }
+ }
+
+ sql """ DROP TABLE IF EXISTS ${tableName} force;"""
+ sql """
+ CREATE TABLE IF NOT EXISTS ${tableName} (
+ `k` int NOT NULL,
+ `c1` int,
+ `c2` int,
+ `c3` int
+ ) ENGINE=OLAP
+ UNIQUE KEY(`k`)
+ DISTRIBUTED BY HASH(`k`) BUCKETS 1
+ PROPERTIES (
+ "replication_allocation" = "tag.location.default: 1",
+ "enable_unique_key_merge_on_write" = "true",
+ "disable_auto_compaction" = "true"
+ );
+ """
+
+ // 插入基线数据
+ sql """
+ INSERT INTO ${tableName} VALUES
+ (1, 1, 11, 111),
+ (2, 2, 22, 222),
+ (3, 3, 33, 333)
+ """
+ sql "sync"
+
+ // 验证基线
+ def baselineRows = sql "SELECT k, c1, c2, c3 FROM ${tableName} ORDER BY k"
+ Assert.assertEquals(3, baselineRows.size())
+ logger.info("Baseline data: ${baselineRows}")
+
+ try {
+ sql """
+ CREATE ROUTINE LOAD ${job} ON ${tableName}
+ COLUMNS TERMINATED BY ",",
+ COLUMNS (k, c1, c2)
+ PROPERTIES
+ (
+ "max_batch_interval" = "10",
+ "partial_columns" = "true"
+ )
+ FROM KAFKA
+ (
+ "kafka_broker_list" = "${kafka_broker}",
+ "kafka_topic" = "${kafkaCsvTopic}",
+ "property.kafka_default_offsets" = "OFFSET_BEGINNING"
+ );
+ """
+
+ // 第一批 partial update: 更新 key=1,2 的 c1,c2
+ def batch1 = [
+ "1,10,110",
+ "2,20,220"
+ ]
+ batch1.each { line ->
+ logger.info("Sending batch1: ${line}")
+ def record = new ProducerRecord<>(kafkaCsvTopic, null, line)
+ producer.send(record).get()
+ }
+ producer.flush()
+
+ // 等待第一批消费完成(batch1 共 2 条,累计 loadedRows >= 2)
+ // 不用 waitForTaskFinish(count>3),因为基线已有 3 行,行数不会增加
+ waitForLoadedRows(job, 2)
+ logger.info("Batch1 consumed, triggering full compaction...")
+
+ // 暂停 routine load,避免 compaction 和消费并行引发不确定性
+ sql "PAUSE ROUTINE LOAD FOR ${job}"
+
+ // 等待 job 进入 PAUSED
+ def pauseWait = 0
+ while (pauseWait < 30) {
+ def state = sql "show routine load for ${job}"
+ if (state[0][8].toString() == "PAUSED") break
+ sleep(1000)
+ pauseWait++
+ }
+ def pausedState = sql "show routine load for ${job}"
+ Assert.assertEquals("Routine load 应成功进入 PAUSED 再触发 compaction", "PAUSED", pausedState[0][8].toString())
+
+ // 触发 full compaction
+ trigger_and_wait_compaction(tableName, "full")
+ logger.info("Full compaction completed after batch1")
+
+ // 恢复 routine load
+ sql "RESUME ROUTINE LOAD FOR ${job}"
+
+ // 第二批 partial update: 更新 key=1,3 的 c1,c2
+ def batch2 = [
+ "1,11,1110",
+ "3,30,330"
+ ]
+ batch2.each { line ->
+ logger.info("Sending batch2: ${line}")
+ def record = new ProducerRecord<>(kafkaCsvTopic, null, line)
+ producer.send(record).get()
+ }
+ producer.flush()
+
+ // 等待第二批消费完成(batch1+batch2 累计 loadedRows >= 4)
+ // 同上不用行数判断
+ waitForLoadedRows(job, 4)
+
+ // 核心断言 1: 最终行数
+ def finalCount = sql "SELECT count(*) FROM ${tableName}"
+ Assert.assertEquals("应有 3 行", 3L, finalCount[0][0])
+
+ def distinctCount = sql "SELECT count(distinct k) FROM ${tableName}"
+ Assert.assertEquals("不应有重复 key", 3L, distinctCount[0][0])
+
+ // 核心断言 2: 按主键排序后的精确行集
+ // key=1: c1 被第二批覆盖为 11, c2 被覆盖为 1110, c3 不变 111
+ // key=2: c1 被第一批更新为 20, c2 更新为 220, c3 不变 222
+ // key=3: c1 被第二批更新为 30, c2 更新为 330, c3 不变 333
+ def finalRows = sql "SELECT k, c1, c2, c3 FROM ${tableName} ORDER BY k"
+ logger.info("Final rows: ${finalRows}")
+
+ Assert.assertEquals(3, finalRows.size())
+
+ // key=1
+ Assert.assertEquals(1, finalRows[0][0])
+ Assert.assertEquals(11, finalRows[0][1])
+ Assert.assertEquals(1110, finalRows[0][2])
+ Assert.assertEquals(111, finalRows[0][3])
+
+ // key=2
+ Assert.assertEquals(2, finalRows[1][0])
+ Assert.assertEquals(20, finalRows[1][1])
+ Assert.assertEquals(220, finalRows[1][2])
+ Assert.assertEquals(222, finalRows[1][3])
+
+ // key=3
+ Assert.assertEquals(3, finalRows[2][0])
+ Assert.assertEquals(30, finalRows[2][1])
+ Assert.assertEquals(330, finalRows[2][2])
+ Assert.assertEquals(333, finalRows[2][3])
+
+ // 核心断言 3: 聚合校验
+ def aggResult = sql "SELECT count(*), sum(k) FROM ${tableName}"
+ Assert.assertEquals(3L, aggResult[0][0])
+ Assert.assertEquals(6L, aggResult[0][1])
+
+ logger.info("All assertions passed: MOW partial update + compaction correctness verified")
+
+ } catch (Exception e) {
+ logger.error("Test failed: ${e.getMessage()}")
+ throw e
+ } finally {
+ try {
+ sql "STOP ROUTINE LOAD FOR ${job}"
+ } catch (Exception e) {
+ logger.warn("Failed to stop routine load: ${e.message}")
+ }
+ producer.close()
+ sql "DROP TABLE IF EXISTS ${tableName} force"
+ }
+ }
+}
diff --git a/regression-test/suites/load_p0/routine_load/test_routine_load_partial_update_new_key_error_atomicity.groovy b/regression-test/suites/load_p0/routine_load/test_routine_load_partial_update_new_key_error_atomicity.groovy
new file mode 100644
index 00000000000..060c6fd0563
--- /dev/null
+++ b/regression-test/suites/load_p0/routine_load/test_routine_load_partial_update_new_key_error_atomicity.groovy
@@ -0,0 +1,214 @@
+// 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.producer.KafkaProducer
+import org.apache.kafka.clients.producer.ProducerRecord
+import org.junit.Assert
+
+suite("test_routine_load_partial_update_new_key_error_atomicity", "nonConcurrent") {
+ // 使用时间戳后缀确保每次运行使用不同 topic,避免历史消息污染
+ def topicSuffix = System.currentTimeMillis()
+ // Phase-1: 原子性测试 topic(含新 key 的坏批次)
+ def kafkaBadTopic = "test_rl_pu_new_key_error_bad_${topicSuffix}"
+ // Phase-2: 恢复测试 topic(仅老 key 的合法数据)
+ def kafkaGoodTopic = "test_rl_pu_new_key_error_good_${topicSuffix}"
+
+ if (RoutineLoadTestUtils.isKafkaTestEnabled(context)) {
+ def runSql = { String q -> sql q }
+ def kafka_broker = RoutineLoadTestUtils.getKafkaBroker(context)
+ def producer = RoutineLoadTestUtils.createKafkaProducer(kafka_broker)
+
+ def tableName = "test_rl_pu_new_key_error_atomicity"
+ def badJob = "test_pu_new_key_error_atomicity_job_${topicSuffix}"
+ def recoveryJob = "test_pu_new_key_error_recovery_job_${topicSuffix}"
+
+ // 基于 loadedRows 的等待 helper
+ def waitForLoadedRows = { String jobName, long expectedLoadedRows, int maxWait = 60 ->
+ def waited = 0
+ while (waited < maxWait) {
+ def res = sql "show routine load for ${jobName}"
+ def state = res[0][8].toString()
+ def statJson = new groovy.json.JsonSlurper().parseText(res[0][14].toString())
+ long loaded = statJson.loadedRows as long
+ logger.info("waitForLoadedRows[${jobName}]: state=${state}, loadedRows=${loaded}, expected>=${expectedLoadedRows}")
+ if (state == "RUNNING" && loaded >= expectedLoadedRows) break
+ if (waited >= maxWait - 1) {
+ Assert.fail("Timeout waiting for loadedRows >= ${expectedLoadedRows}, got ${loaded}")
+ }
+ sleep(1000)
+ waited++
+ }
+ }
+
+ sql """ DROP TABLE IF EXISTS ${tableName} force;"""
+ sql """
+ CREATE TABLE IF NOT EXISTS ${tableName} (
+ `k` int NOT NULL,
+ `c1` int,
+ `c2` int
+ ) ENGINE=OLAP
+ UNIQUE KEY(`k`)
+ DISTRIBUTED BY HASH(`k`) BUCKETS 1
+ PROPERTIES (
+ "replication_allocation" = "tag.location.default: 1",
+ "enable_unique_key_merge_on_write" = "true"
+ );
+ """
+
+ // 插入基线数据
+ sql """
+ INSERT INTO ${tableName} VALUES
+ (1, 1, 1),
+ (2, 2, 2),
+ (3, 3, 3)
+ """
+ sql "sync"
+
+ // 验证基线数据
+ def baselineRows = sql "SELECT * FROM ${tableName} ORDER BY k"
+ Assert.assertEquals(3, baselineRows.size())
+ logger.info("Baseline data verified: ${baselineRows}")
+
+ try {
+ sql """
+ CREATE ROUTINE LOAD ${badJob} ON ${tableName}
+ COLUMNS TERMINATED BY ",",
+ COLUMNS (k, c1)
+ PROPERTIES
+ (
+ "max_batch_interval" = "10",
+ "partial_columns" = "true",
+ "partial_update_new_key_behavior" = "error"
+ )
+ FROM KAFKA
+ (
+ "kafka_broker_list" = "${kafka_broker}",
+ "kafka_topic" = "${kafkaBadTopic}",
+ "property.kafka_default_offsets" = "OFFSET_BEGINNING"
+ );
+ """
+
+ // 发送包含老 key 和新 key 的混合数据
+ // 在 new_key_behavior=error 模式下,新 key 应导致整批失败
+ def mixedData = [
+ "1,100", // 老 key — 合法的 partial update
+ "2,200", // 老 key — 合法的 partial update
+ "10,1000", // 新 key — 非法,应触发 error
+ "11,1100" // 新 key — 非法,应触发 error
+ ]
+
+ mixedData.each { line ->
+ logger.info("Sending mixed data to Kafka bad topic: ${line}")
+ def record = new ProducerRecord<>(kafkaBadTopic, null, line)
+ producer.send(record).get()
+ }
+ producer.flush()
+
+ // 等待任务失败(task abort)
+ RoutineLoadTestUtils.waitForTaskAbort(runSql, badJob)
+ def state = sql "show routine load for ${badJob}"
+ logger.info("Job state after mixed batch: ${state[0][8].toString()}")
+ logger.info("Job error info: ${state[0][17].toString()}")
+
+ // 核心断言 1: 坏批次整体失败,老 key 不应被局部更新
+ // 表数据应仍为基线值
+ def afterFailRows = sql "SELECT * FROM ${tableName} ORDER BY k"
+ Assert.assertEquals("基线数据行数应不变", 3, afterFailRows.size())
+ Assert.assertEquals("key=1 的 c1 应仍为基线值 1", 1, afterFailRows[0][1])
+ Assert.assertEquals("key=2 的 c1 应仍为基线值 2", 2, afterFailRows[1][1])
+ Assert.assertEquals("key=3 的 c1 应仍为基线值 3", 3, afterFailRows[2][1])
+ // 不应有新 key 10, 11
+ def newKeyCount = sql "SELECT count(*) FROM ${tableName} WHERE k IN (10, 11)"
+ Assert.assertEquals("不应存在新 key", 0L, newKeyCount[0][0])
+ logger.info("Atomicity assertion passed: mixed batch fully rejected")
+
+ // Phase-2: 恢复阶段
+ // 原 job (badJob) 会不断重试同一坏 offset,不能直接 resume。
+ // 方案:停掉 badJob,用干净的 recoveryTopic 创建新 job 验证恢复能力。
+ sql "STOP ROUTINE LOAD FOR ${badJob}"
+
+ // 发送只包含老 key 的合法数据到恢复 topic
+ def goodData = [
+ "1,101",
+ "2,202"
+ ]
+ goodData.each { line ->
+ logger.info("Sending good data to recovery topic: ${line}")
+ def record = new ProducerRecord<>(kafkaGoodTopic, null, line)
+ producer.send(record).get()
+ }
+ producer.flush()
+
+ // 创建新 job,指向干净的 recoveryTopic
+ sql """
+ CREATE ROUTINE LOAD ${recoveryJob} ON ${tableName}
+ COLUMNS TERMINATED BY ",",
+ COLUMNS (k, c1)
+ PROPERTIES
+ (
+ "max_batch_interval" = "5",
+ "partial_columns" = "true",
+ "partial_update_new_key_behavior" = "error"
+ )
+ FROM KAFKA
+ (
+ "kafka_broker_list" = "${kafka_broker}",
+ "kafka_topic" = "${kafkaGoodTopic}",
+ "property.kafka_default_offsets" = "OFFSET_BEGINNING"
+ );
+ """
+
+ // 等待 recovery job 消费 2 条合法数据(MOW partial update 行数不增加,用 loadedRows)
+ waitForLoadedRows(recoveryJob, 2)
+
+ // 核心断言 2: 恢复 job 成功写入合法数据。
+ // 这里检查的是用户可见的最终结果,不应开启 skip_delete_bitmap。
+ def finalRows = sql "SELECT k, c1, c2 FROM ${tableName} ORDER BY k"
+ logger.info("Final rows after recovery: ${finalRows}")
+ Assert.assertEquals("最终应有 3 行", 3, finalRows.size())
+ // key=1: c1 应更新为 101,c2 保持 1
+ Assert.assertEquals(1, finalRows[0][0])
+ Assert.assertEquals(101, finalRows[0][1])
+ Assert.assertEquals(1, finalRows[0][2])
+ // key=2: c1 应更新为 202,c2 保持 2
+ Assert.assertEquals(2, finalRows[1][0])
+ Assert.assertEquals(202, finalRows[1][1])
+ Assert.assertEquals(2, finalRows[1][2])
+ // key=3: 不变
+ Assert.assertEquals(3, finalRows[2][0])
+ Assert.assertEquals(3, finalRows[2][1])
+ Assert.assertEquals(3, finalRows[2][2])
+
+ // 聚合断言
+ def aggResult = sql "SELECT count(*), count(distinct k), sum(k) FROM ${tableName}"
+ Assert.assertEquals(3L, aggResult[0][0])
+ Assert.assertEquals(3L, aggResult[0][1])
+ Assert.assertEquals(6L, aggResult[0][2])
+ logger.info("Recovery assertion passed: good batch consumed correctly via recovery job")
+
+ } catch (Exception e) {
+ logger.error("Test failed: ${e.getMessage()}")
+ throw e
+ } finally {
+ try { sql "STOP ROUTINE LOAD FOR ${badJob}" } catch (Exception e) { logger.warn("stop badJob: ${e.message}") }
+ try { sql "STOP ROUTINE LOAD FOR ${recoveryJob}" } catch (Exception e) { logger.warn("stop recoveryJob: ${e.message}") }
+ producer.close()
+ sql "DROP TABLE IF EXISTS ${tableName} force"
+ }
+ }
+}
diff --git a/regression-test/suites/load_p0/routine_load/test_routine_load_partition_expansion_exactly_once.groovy b/regression-test/suites/load_p0/routine_load/test_routine_load_partition_expansion_exactly_once.groovy
new file mode 100644
index 00000000000..90db3bd0039
--- /dev/null
+++ b/regression-test/suites/load_p0/routine_load/test_routine_load_partition_expansion_exactly_once.groovy
@@ -0,0 +1,156 @@
+// 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.suite.ClusterOptions
+import org.apache.doris.regression.util.RoutineLoadTestUtils
+import org.apache.kafka.clients.admin.AdminClient
+import org.apache.kafka.clients.admin.NewPartitions
+import org.apache.kafka.clients.admin.NewTopic
+import org.apache.kafka.clients.producer.ProducerConfig
+import org.apache.kafka.clients.producer.ProducerRecord
+import org.junit.Assert
+import java.util.Collections
+
+suite("test_routine_load_partition_expansion_exactly_once", "docker") {
+ def topicSuffix = System.currentTimeMillis()
+ String kafkaCsvTopic = "test_rl_partition_expansion_${topicSuffix}"
+
+ if (!RoutineLoadTestUtils.isKafkaTestEnabled(context)) {
+ return
+ }
+
+ def kafkaBroker = RoutineLoadTestUtils.getKafkaBroker(context)
+
+ def options = new ClusterOptions()
+ options.setFeNum(1)
+ options.setBeNum(1)
+ options.cloudMode = false
+
+ docker(options) {
+ def producer = RoutineLoadTestUtils.createKafkaProducer(kafkaBroker)
+
+ def adminProps = new Properties()
+ adminProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, kafkaBroker)
+ def adminClient = AdminClient.create(adminProps)
+
+ def tableName = "test_rl_partition_expansion"
+ def job = "test_rl_partition_expansion_job_${topicSuffix}"
+
+ def waitForExactRowCount = { long expected, int maxWait = 180 ->
+ def waited = 0
+ while (waited < maxWait) {
+ def state = sql "SHOW ROUTINE LOAD FOR ${job}"
+ def rowCount = sql "SELECT count(*) FROM ${tableName}"
+ long count = (rowCount[0][0] as Number).longValue()
+ logger.info("waitForExactRowCount: state={}, rows={}", state[0][8].toString(), count)
+ if (state[0][8].toString() == "RUNNING" && count == expected) {
+ return
+ }
+ sleep(1000)
+ waited++
+ }
+ Assert.fail("Row count did not reach ${expected} within timeout")
+ }
+
+ def waitForPartitionCount = { int expected, int maxWait = 60 ->
+ def waited = 0
+ while (waited < maxWait) {
+ def topicDesc = adminClient.describeTopics([kafkaCsvTopic]).values().get(kafkaCsvTopic).get()
+ if (topicDesc.partitions().size() == expected) {
+ return
+ }
+ sleep(1000)
+ waited++
+ }
+ Assert.fail("Kafka topic partition count did not reach ${expected}")
+ }
+
+ sql """ DROP TABLE IF EXISTS ${tableName} """
+ sql """
+ CREATE TABLE IF NOT EXISTS ${tableName} (
+ `id` int NOT NULL,
+ `payload` string NULL
+ ) ENGINE=OLAP
+ DUPLICATE KEY(`id`)
+ DISTRIBUTED BY HASH(`id`) BUCKETS 1
+ PROPERTIES ("replication_allocation" = "tag.location.default: 1");
+ """
+ sql "sync"
+
+ try {
+ adminClient.createTopics([new NewTopic(kafkaCsvTopic, 1, (short) 1)]).all().get()
+ waitForPartitionCount(1)
+
+ sql """
+ CREATE ROUTINE LOAD ${job} ON ${tableName}
+ COLUMNS TERMINATED BY ","
+ PROPERTIES
+ (
+ "max_batch_interval" = "5"
+ )
+ FROM KAFKA
+ (
+ "kafka_broker_list" = "${kafkaBroker}",
+ "kafka_topic" = "${kafkaCsvTopic}",
+ "property.kafka_default_offsets" = "OFFSET_BEGINNING"
+ );
+ """
+
+ (1..4).each { i ->
+ producer.send(new ProducerRecord<>(kafkaCsvTopic, 0, null, "${i},payload_${i}".toString())).get()
+ }
+ producer.flush()
+ waitForExactRowCount(4L)
+
+ adminClient.createPartitions(Collections.singletonMap(kafkaCsvTopic, NewPartitions.increaseTo(3))).all().get()
+ waitForPartitionCount(3)
+
+ producer.send(new ProducerRecord<>(kafkaCsvTopic, 1, null, "5,payload_5")).get()
+ producer.send(new ProducerRecord<>(kafkaCsvTopic, 2, null, "6,payload_6")).get()
+ producer.send(new ProducerRecord<>(kafkaCsvTopic, 1, null, "7,payload_7")).get()
+ producer.send(new ProducerRecord<>(kafkaCsvTopic, 2, null, "8,payload_8")).get()
+ producer.send(new ProducerRecord<>(kafkaCsvTopic, 0, null, "9,payload_9")).get()
+ producer.flush()
+
+ waitForExactRowCount(9L, 240)
+
+ def finalRows = sql "SELECT id, payload FROM ${tableName} ORDER BY id"
+ Assert.assertEquals(9, finalRows.size())
+ for (int i = 0; i < 9; i++) {
+ Assert.assertEquals(i + 1, finalRows[i][0])
+ Assert.assertEquals("payload_${i + 1}".toString(), finalRows[i][1])
+ }
+
+ def aggregate = sql "SELECT count(*), count(distinct id), sum(id) FROM ${tableName}"
+ Assert.assertEquals(9L, (aggregate[0][0] as Number).longValue())
+ Assert.assertEquals(9L, (aggregate[0][1] as Number).longValue())
+ Assert.assertEquals(45L, (aggregate[0][2] as Number).longValue())
+
+ def finalState = sql "SHOW ROUTINE LOAD FOR ${job}"
+ Assert.assertEquals("RUNNING", finalState[0][8].toString())
+ } finally {
+ try {
+ sql "STOP ROUTINE LOAD FOR ${job}"
+ } catch (Exception e) {
+ logger.warn("Failed to stop routine load: {}", e.message)
+ }
+ producer.close()
+ adminClient.close()
+ sql "DROP TABLE IF EXISTS ${tableName}"
+ }
+ }
+}
diff --git a/regression-test/suites/load_p0/routine_load/test_routine_load_same_job_offset_recovery_exactly_once.groovy b/regression-test/suites/load_p0/routine_load/test_routine_load_same_job_offset_recovery_exactly_once.groovy
new file mode 100644
index 00000000000..2f6cd068696
--- /dev/null
+++ b/regression-test/suites/load_p0/routine_load/test_routine_load_same_job_offset_recovery_exactly_once.groovy
@@ -0,0 +1,180 @@
+// 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.suite.ClusterOptions
+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.apache.kafka.clients.producer.ProducerRecord
+import org.junit.Assert
+
+suite("test_routine_load_same_job_offset_recovery_exactly_once", "docker") {
+ def topicSuffix = System.currentTimeMillis()
+ def kafkaCsvTopic = "test_rl_same_job_offset_recovery_${topicSuffix}"
+
+ if (!RoutineLoadTestUtils.isKafkaTestEnabled(context)) {
+ return
+ }
+
+ def kafkaBroker = RoutineLoadTestUtils.getKafkaBroker(context)
+
+ def options = new ClusterOptions()
+ options.setFeNum(1)
+ options.setBeNum(1)
+ options.cloudMode = false
+
+ docker(options) {
+ def producer = RoutineLoadTestUtils.createKafkaProducer(kafkaBroker)
+
+ def adminProps = new Properties()
+ adminProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, kafkaBroker)
+ def adminClient = AdminClient.create(adminProps)
+
+ def tableName = "test_rl_same_job_offset_recovery"
+ def job = "test_rl_same_job_offset_recovery_job_${topicSuffix}"
+
+ def firstBatch = [
+ "1,payload_1,2026-03-01",
+ "2,payload_2,2026-03-02"
+ ]
+ def secondBatch = [
+ "3,payload_3,2026-03-03",
+ "4,payload_4,2026-03-04",
+ "5,payload_5,2026-03-05"
+ ]
+
+ def waitForExactRowCount = { long expected, int maxWait = 120 ->
+ def waited = 0
+ while (waited < maxWait) {
+ def state = sql "SHOW ROUTINE LOAD FOR ${job}"
+ def rowCount = sql "SELECT count(*) FROM ${tableName}"
+ long count = (rowCount[0][0] as Number).longValue()
+ logger.info("waitForExactRowCount: state={}, rows={}", state[0][8].toString(), count)
+ if (state[0][8].toString() == "RUNNING" && count == expected) {
+ return
+ }
+ sleep(1000)
+ waited++
+ }
+ Assert.fail("Row count did not reach ${expected} within timeout")
+ }
+
+ def waitForPausedByOffsetError = { int maxWait = 120 ->
+ def waited = 0
+ while (waited < maxWait) {
+ def res = sql "SHOW ROUTINE LOAD FOR ${job}"
+ def state = res[0][8].toString()
+ def reason = res[0][17].toString()
+ def otherMsg = res[0][19].toString()
+ def combinedMsg = "${reason} ${otherMsg}".toLowerCase()
+ logger.info("waitForPausedByOffsetError: state={}, reason={}, msg={}", state, reason, otherMsg)
+ if (state == "PAUSED" && (
+ combinedMsg.contains("offset out of range")
+ || combinedMsg.contains("greater than kafka latest offset")
+ )) {
+ return
+ }
+ sleep(1000)
+ waited++
+ }
+ Assert.fail("Routine load job did not pause on bad offset within timeout")
+ }
+
+ sql """ DROP TABLE IF EXISTS ${tableName} """
+ sql """
+ CREATE TABLE IF NOT EXISTS ${tableName} (
+ `id` int NOT NULL,
+ `payload` string NULL,
+ `ts` string NULL
+ ) ENGINE=OLAP
+ DUPLICATE KEY(`id`)
+ DISTRIBUTED BY HASH(`id`) BUCKETS 1
+ PROPERTIES ("replication_allocation" = "tag.location.default: 1");
+ """
+ sql "sync"
+
+ try {
+ adminClient.createTopics([new NewTopic(kafkaCsvTopic, 1, (short) 1)]).all().get()
+
+ sql """
+ CREATE ROUTINE LOAD ${job} ON ${tableName}
+ COLUMNS TERMINATED BY ","
+ PROPERTIES
+ (
+ "max_batch_interval" = "5"
+ )
+ FROM KAFKA
+ (
+ "kafka_broker_list" = "${kafkaBroker}",
+ "kafka_topic" = "${kafkaCsvTopic}",
+ "kafka_partitions" = "0",
+ "kafka_offsets" = "OFFSET_BEGINNING"
+ );
+ """
+
+ firstBatch.each { line ->
+ producer.send(new ProducerRecord<>(kafkaCsvTopic, 0, null, line)).get()
+ }
+ producer.flush()
+ waitForExactRowCount(2L)
+
+ sql "PAUSE ROUTINE LOAD FOR ${job}"
+ sql "ALTER ROUTINE LOAD FOR ${job} FROM KAFKA(\"kafka_partitions\" = \"0\", \"kafka_offsets\" = \"999999\");"
+ sql "RESUME ROUTINE LOAD FOR ${job}"
+
+ waitForPausedByOffsetError()
+
+ def pausedCount = sql "SELECT count(*) FROM ${tableName}"
+ Assert.assertEquals(2L, (pausedCount[0][0] as Number).longValue())
+
+ sql "ALTER ROUTINE LOAD FOR ${job} FROM KAFKA(\"kafka_partitions\" = \"0\", \"kafka_offsets\" = \"2\");"
+ secondBatch.each { line ->
+ producer.send(new ProducerRecord<>(kafkaCsvTopic, 0, null, line)).get()
+ }
+ producer.flush()
+ sql "RESUME ROUTINE LOAD FOR ${job}"
+
+ waitForExactRowCount(5L)
+
+ def finalRows = sql "SELECT id, payload FROM ${tableName} ORDER BY id"
+ Assert.assertEquals(5, finalRows.size())
+ for (int i = 0; i < 5; i++) {
+ Assert.assertEquals(i + 1, finalRows[i][0])
+ Assert.assertEquals("payload_${i + 1}".toString(), finalRows[i][1])
+ }
+
+ def distinctCount = sql "SELECT count(distinct id) FROM ${tableName}"
+ Assert.assertEquals(5L, (distinctCount[0][0] as Number).longValue())
+
+ def sumId = sql "SELECT sum(id) FROM ${tableName}"
+ Assert.assertEquals(15L, (sumId[0][0] as Number).longValue())
+
+ def finalState = sql "SHOW ROUTINE LOAD FOR ${job}"
+ Assert.assertEquals("RUNNING", finalState[0][8].toString())
+ } finally {
+ try {
+ sql "STOP ROUTINE LOAD FOR ${job}"
+ } catch (Exception e) {
+ logger.warn("Failed to stop routine load: {}", e.message)
+ }
+ producer.close()
+ adminClient.close()
+ sql "DROP TABLE IF EXISTS ${tableName}"
+ }
+ }
+}
diff --git a/regression-test/suites/load_p0/routine_load/test_routine_load_same_job_schema_replay_exactly_once.groovy b/regression-test/suites/load_p0/routine_load/test_routine_load_same_job_schema_replay_exactly_once.groovy
new file mode 100644
index 00000000000..e6b148dcbf6
--- /dev/null
+++ b/regression-test/suites/load_p0/routine_load/test_routine_load_same_job_schema_replay_exactly_once.groovy
@@ -0,0 +1,215 @@
+// 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.suite.ClusterOptions
+import org.apache.doris.regression.util.RoutineLoadTestUtils
+import org.apache.kafka.clients.producer.ProducerRecord
+import org.junit.Assert
+
+// 语义说明:
+// Routine Load 因 strict_mode/schema mismatch 暂停后,RESUME 不会回放历史坏数据,
+// 只会继续消费恢复后追加的新数据。
+suite("test_routine_load_resume_without_replay_after_schema_pause", "docker") {
+ def topicSuffix = System.currentTimeMillis()
+ def kafkaCsvTopic = "test_rl_resume_no_replay_${topicSuffix}"
+
+ if (!RoutineLoadTestUtils.isKafkaTestEnabled(context)) {
+ return
+ }
+
+ def kafkaBroker = RoutineLoadTestUtils.getKafkaBroker(context)
+
+ def options = new ClusterOptions()
+ options.setFeNum(1)
+ options.setBeNum(1)
+ options.cloudMode = false
+
+ docker(options) {
+ def producer = RoutineLoadTestUtils.createKafkaProducer(kafkaBroker)
+
+ def tableName = "test_rl_resume_no_replay"
+ def job = "test_rl_resume_no_replay_job_${topicSuffix}"
+
+ def schemaRejectedData = [
+ "1,aaa,2023-01-01,val1,2023-01-01 10:00:00,abcdef",
+ "2,bbb,2023-01-02,val2,2023-01-02 11:00:00,ghijkl"
+ ]
+ def postResumeData = [
+ "3,ccc,2023-01-03,val3,2023-01-03 12:00:00,hello1",
+ "4,ddd,2023-01-04,val4,2023-01-04 13:00:00,world2"
+ ]
+
+ def waitForPaused = { int maxWait = 60 ->
+ def waited = 0
+ while (waited < maxWait) {
+ def res = sql "SHOW ROUTINE LOAD FOR ${job}"
+ def state = res[0][8].toString()
+ if (state == "PAUSED") {
+ logger.info("Job paused as expected. reason={}", res[0][17].toString())
+ return
+ }
+ sleep(1000)
+ waited++
+ }
+ Assert.fail("Routine load job did not enter PAUSED within timeout")
+ }
+
+ def waitForSchemaChangeFinished = { int maxWait = 120 ->
+ def waited = 0
+ while (waited < maxWait) {
+ def alterRes = sql "SHOW ALTER TABLE COLUMN WHERE TableName='${tableName}' ORDER BY CreateTime DESC LIMIT 1"
+ if (alterRes.size() > 0 && alterRes[0][9].toString() == "FINISHED") {
+ return
+ }
+ sleep(1000)
+ waited++
+ }
+ Assert.fail("Schema change did not finish within timeout")
+ }
+
+ def waitForExactRowCount = { long expected, int maxWait = 120 ->
+ def waited = 0
+ while (waited < maxWait) {
+ def jobRes = sql "SHOW ROUTINE LOAD FOR ${job}"
+ def state = jobRes[0][8].toString()
+ def countRes = sql "SELECT count(*) FROM ${tableName}"
+ long rowCount = (countRes[0][0] as Number).longValue()
+ logger.info("waitForExactRowCount: state={}, rows={}", state, rowCount)
+ if (state == "RUNNING" && rowCount == expected) {
+ return
+ }
+ sleep(1000)
+ waited++
+ }
+ Assert.fail("Row count did not reach ${expected} within timeout")
+ }
+
+ def waitForRunning = { int maxWait = 60 ->
+ def waited = 0
+ while (waited < maxWait) {
+ def jobRes = sql "SHOW ROUTINE LOAD FOR ${job}"
+ def state = jobRes[0][8].toString()
+ if (state == "RUNNING") {
+ return
+ }
+ sleep(1000)
+ waited++
+ }
+ Assert.fail("Routine load job did not enter RUNNING within timeout")
+ }
+
+ def assertRowCountKeeps = { long expected, int observeSeconds = 5 ->
+ for (int i = 0; i < observeSeconds; i++) {
+ def countRes = sql "SELECT count(*) FROM ${tableName}"
+ long rowCount = (countRes[0][0] as Number).longValue()
+ logger.info("assertRowCountKeeps: second={}, rows={}", i, rowCount)
+ Assert.assertEquals("Observed row count should stay at ${expected}", expected, rowCount)
+ sleep(1000)
+ }
+ }
+
+ sql """ DROP TABLE IF EXISTS ${tableName} """
+ sql """
+ CREATE TABLE IF NOT EXISTS ${tableName} (
+ `k1` int(20) NULL,
+ `k2` string NULL,
+ `v1` date NULL,
+ `v2` string NULL,
+ `v3` datetime NULL,
+ `v4` varchar(5) NULL
+ ) ENGINE=OLAP
+ DUPLICATE KEY(`k1`)
+ DISTRIBUTED BY HASH(`k1`) BUCKETS 1
+ PROPERTIES ("replication_allocation" = "tag.location.default: 1");
+ """
+ sql "sync"
+
+ schemaRejectedData.each { line ->
+ logger.info("Sending schema-blocked row to Kafka: {}", line)
+ producer.send(new ProducerRecord<>(kafkaCsvTopic, null, line)).get()
+ }
+ producer.flush()
+
+ try {
+ sql """
+ CREATE ROUTINE LOAD ${job} ON ${tableName}
+ COLUMNS TERMINATED BY ","
+ PROPERTIES
+ (
+ "strict_mode" = "true",
+ "max_batch_interval" = "5",
+ "max_batch_rows" = "300000",
+ "max_batch_size" = "209715200"
+ )
+ FROM KAFKA
+ (
+ "kafka_broker_list" = "${kafkaBroker}",
+ "kafka_topic" = "${kafkaCsvTopic}",
+ "property.kafka_default_offsets" = "OFFSET_BEGINNING"
+ );
+ """
+ sql "sync"
+
+ waitForPaused()
+
+ def pausedCount = sql "SELECT count(*) FROM ${tableName}"
+ Assert.assertEquals("PAUSED 期间不应写入任何行", 0L, (pausedCount[0][0] as Number).longValue())
+
+ sql "ALTER TABLE ${tableName} MODIFY COLUMN v4 VARCHAR(10)"
+ waitForSchemaChangeFinished()
+
+ sql "RESUME ROUTINE LOAD FOR ${job}"
+ waitForRunning()
+ assertRowCountKeeps(0L)
+
+ // schema 放宽并 RESUME 后,同一个 job 不会补消费旧坏数据,
+ // 但应继续消费恢复后追加的新数据。
+ postResumeData.each { line ->
+ logger.info("Sending post-resume row to Kafka: {}", line)
+ producer.send(new ProducerRecord<>(kafkaCsvTopic, null, line)).get()
+ }
+ producer.flush()
+ waitForExactRowCount(2L)
+
+ def finalRows = sql "SELECT k1, k2, v4 FROM ${tableName} ORDER BY k1"
+ Assert.assertEquals("resume 后应只消费 schema change 之后追加的 2 行", 2, finalRows.size())
+ Assert.assertEquals(3, finalRows[0][0])
+ Assert.assertEquals("ccc", finalRows[0][1])
+ Assert.assertEquals("hello1", finalRows[0][2])
+ Assert.assertEquals(4, finalRows[1][0])
+ Assert.assertEquals("ddd", finalRows[1][1])
+ Assert.assertEquals("world2", finalRows[1][2])
+
+ def oldRows = sql "SELECT count(*) FROM ${tableName} WHERE k1 IN (1, 2)"
+ Assert.assertEquals("resume 后不应回放历史 schema-failed 数据", 0L, (oldRows[0][0] as Number).longValue())
+
+ def sumKey = sql "SELECT sum(k1) FROM ${tableName}"
+ Assert.assertEquals(7L, (sumKey[0][0] as Number).longValue())
+
+ def finalState = sql "SHOW ROUTINE LOAD FOR ${job}"
+ Assert.assertEquals("RUNNING", finalState[0][8].toString())
+ } finally {
+ try {
+ sql "STOP ROUTINE LOAD FOR ${job}"
+ } catch (Exception e) {
+ logger.warn("Failed to stop routine load: {}", e.message)
+ }
+ producer.close()
+ sql "DROP TABLE IF EXISTS ${tableName}"
+ }
+ }
+}
diff --git a/regression-test/suites/load_p0/routine_load/test_routine_load_too_many_tasks_recovery_exactly_once.groovy b/regression-test/suites/load_p0/routine_load/test_routine_load_too_many_tasks_recovery_exactly_once.groovy
new file mode 100644
index 00000000000..312f0e794eb
--- /dev/null
+++ b/regression-test/suites/load_p0/routine_load/test_routine_load_too_many_tasks_recovery_exactly_once.groovy
@@ -0,0 +1,177 @@
+// 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.suite.ClusterOptions
+import org.apache.doris.regression.util.RoutineLoadTestUtils
+import org.apache.kafka.clients.producer.ProducerRecord
+import org.junit.Assert
+
+suite("test_routine_load_too_many_tasks_recovery_exactly_once", "docker") {
+ def topicSuffix = System.currentTimeMillis()
+ def kafkaCsvTopic = "test_rl_too_many_tasks_recovery_${topicSuffix}"
+
+ if (!RoutineLoadTestUtils.isKafkaTestEnabled(context)) {
+ return
+ }
+
+ def kafkaPort = context.config.otherConfigs.get("kafka_port")
+ Assert.assertNotNull("kafka_port must be configured in regression-conf.groovy", kafkaPort)
+ def kafka_broker = "127.0.0.1:${kafkaPort}"
+
+ def options = new ClusterOptions()
+ options.setFeNum(1)
+ options.setBeNum(1)
+ options.cloudMode = false
+ options.enableDebugPoints()
+
+ docker(options) {
+ def producer = RoutineLoadTestUtils.createKafkaProducer(kafka_broker)
+
+ def tableName = "test_rl_too_many_tasks_recovery"
+ def job = "test_too_many_tasks_recovery_job_${topicSuffix}"
+ def debugPoint = "FE.ROUTINE_LOAD_TASK_SUBMIT_FAILED.TOO_MANY_TASKS"
+
+ sql """ DROP TABLE IF EXISTS ${tableName} """
+ sql """
+ CREATE TABLE IF NOT EXISTS ${tableName} (
+ `id` int NOT NULL,
+ `payload` string NULL,
+ `ts` string NULL
+ ) ENGINE=OLAP
+ DUPLICATE KEY(`id`)
+ DISTRIBUTED BY HASH(`id`) BUCKETS 1
+ PROPERTIES ("replication_allocation" = "tag.location.default: 1");
+ """
+ sql "sync"
+
+ List<String> testData = []
+ for (int i = 1; i <= 20; i++) {
+ testData.add("${i},payload_${i},2026-02-${String.format('%02d', i)}".toString())
+ }
+
+ testData.each { line ->
+ logger.info("Sending to Kafka: ${line}")
+ def record = new ProducerRecord<>(kafkaCsvTopic, null, line.toString())
+ producer.send(record).get()
+ }
+ producer.flush()
+ logger.info("All 20 records sent to Kafka")
+
+ try {
+ GetDebugPoint().enableDebugPointForAllFEs(debugPoint)
+ logger.info("Debug point enabled: ${debugPoint}")
+
+ sql """
+ CREATE ROUTINE LOAD ${job} ON ${tableName}
+ COLUMNS TERMINATED BY ","
+ PROPERTIES
+ (
+ "max_batch_interval" = "5"
+ )
+ FROM KAFKA
+ (
+ "kafka_broker_list" = "${kafka_broker}",
+ "kafka_topic" = "${kafkaCsvTopic}",
+ "property.kafka_default_offsets" = "OFFSET_BEGINNING"
+ );
+ """
+
+ def waitCount = 0
+ def sawFailure = false
+ while (waitCount < 30) {
+ sleep(1000)
+ def state = sql "show routine load for ${job}"
+ def routineLoadState = state[0][8].toString()
+ def statistic = state[0][14].toString()
+ def otherMsg = state[0][19].toString()
+ logger.info("State: ${routineLoadState}, stats: ${statistic}, msg: ${otherMsg}")
+
+ def statJson = new groovy.json.JsonSlurper().parseText(statistic)
+ long abortedCount = statJson.abortedTaskNum as long
+ if (abortedCount > 0 || otherMsg.contains("TOO_MANY_TASKS")) {
+ sawFailure = true
+ logger.info("Detected TOO_MANY_TASKS failure: abortedTaskNum=${abortedCount}, msg=${otherMsg}")
+ break
+ }
+ waitCount++
+ }
+ Assert.assertTrue("应观察到 TOO_MANY_TASKS 失败(abortedTaskNum>0 或 otherMsg 含相关信息)", sawFailure)
+
+ def failPhaseCount = sql "select count(*) from ${tableName}"
+ logger.info("Row count during failure phase: ${failPhaseCount[0][0]}")
+
+ GetDebugPoint().disableDebugPointForAllFEs(debugPoint)
+ logger.info("Debug point disabled, waiting for recovery...")
+
+ def maxWaitCount = 120
+ def count = 0
+ while (true) {
+ def state = sql "show routine load for ${job}"
+ def routineLoadState = state[0][8].toString()
+ def statistic = state[0][14].toString()
+ logger.info("Recovery phase - State: ${routineLoadState}, stats: ${statistic}")
+
+ def rowCount = sql "select count(*) from ${tableName}"
+ if (routineLoadState == "RUNNING" && rowCount[0][0] == 20) {
+ logger.info("Job recovered and all 20 rows consumed")
+ break
+ }
+ if (count > maxWaitCount) {
+ Assert.fail("Routine load did not recover within timeout. "
+ + "State: ${routineLoadState}, rows: ${rowCount[0][0]}")
+ }
+ sleep(1000)
+ count++
+ }
+
+ def finalCount = sql "select count(*) from ${tableName}"
+ Assert.assertEquals("应精确消费 20 行", 20L, finalCount[0][0])
+
+ def distinctCount = sql "select count(distinct id) from ${tableName}"
+ Assert.assertEquals("不应有重复 id", 20L, distinctCount[0][0])
+
+ def sumId = sql "select sum(id) from ${tableName}"
+ Assert.assertEquals("sum(id) 应为 1+2+...+20=210", 210L, sumId[0][0])
+
+ def minId = sql "select min(id) from ${tableName}"
+ Assert.assertEquals("min(id) 应为 1", 1, minId[0][0])
+
+ def maxId = sql "select max(id) from ${tableName}"
+ Assert.assertEquals("max(id) 应为 20", 20, maxId[0][0])
+
+ def finalState = sql "show routine load for ${job}"
+ Assert.assertEquals("最终状态应为 RUNNING", "RUNNING", finalState[0][8].toString())
+ logger.info("All exactly-once assertions passed after TOO_MANY_TASKS recovery")
+ } catch (Exception e) {
+ logger.error("Test failed: ${e.getMessage()}")
+ throw e
+ } finally {
+ try {
+ GetDebugPoint().disableDebugPointForAllFEs(debugPoint)
+ } catch (Exception e) {
+ logger.warn("Failed to disable debug point in cleanup: ${e.message}")
+ }
+ try {
+ sql "stop routine load for ${job}"
+ } catch (Exception e) {
+ logger.warn("Failed to stop routine load: ${e.message}")
+ }
+ producer.close()
+ sql "DROP TABLE IF EXISTS ${tableName}"
+ }
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@doris.apache.org
For additional commands, e-mail: commits-help@doris.apache.org