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 476ca43e6f2 [chore](test) Add explicit snapshot recycling checks to
cloud Docker suites (#69057)
476ca43e6f2 is described below
commit 476ca43e6f2c0d7871f6db7ebe58afbfa2574df3
Author: Yixuan Wang <[email protected]>
AuthorDate: Fri Oct 16 18:50:12 2026 +0800
[chore](test) Add explicit snapshot recycling checks to cloud Docker suites
(#69057)
---
.../suite/SnapshotRecyclerPluginTest.groovy | 233 +++++++++++++++++++++
.../plugins/snapshot_recycler_plugin.groovy | 170 +++++++++++++++
2 files changed, 403 insertions(+)
diff --git
a/regression-test/framework/src/test/groovy/org/apache/doris/regression/suite/SnapshotRecyclerPluginTest.groovy
b/regression-test/framework/src/test/groovy/org/apache/doris/regression/suite/SnapshotRecyclerPluginTest.groovy
new file mode 100644
index 00000000000..f2658c85b77
--- /dev/null
+++
b/regression-test/framework/src/test/groovy/org/apache/doris/regression/suite/SnapshotRecyclerPluginTest.groovy
@@ -0,0 +1,233 @@
+// 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 groovy.json.JsonOutput
+import org.apache.doris.regression.Config
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Test
+import org.slf4j.LoggerFactory
+
+import static org.junit.jupiter.api.Assertions.*
+
+class SnapshotRecyclerPluginTest {
+ private Suite loadPlugin(Closure request) {
+ def plugin = [new File("../plugins/snapshot_recycler_plugin.groovy"),
+ new
File("regression-test/plugins/snapshot_recycler_plugin.groovy")].find {
it.isFile() }
+ assertNotNull(plugin)
+ new GroovyShell(getClass().classLoader,
+ new Binding(logger:
LoggerFactory.getLogger(getClass()))).evaluate(plugin)
+ Suite.metaClass.httpTest = { Closure action ->
+ def httpCall = [method: 'POST']
+ def http = new Expando(
+ endpoint: { String value -> httpCall.endpoint = value },
+ uri: { String value -> httpCall.uri = value },
+ op: { String value -> httpCall.method =
value.toUpperCase() },
+ body: { String value -> httpCall.body = value },
+ check: { Closure value -> httpCall.check = value })
+ action.delegate = http
+ action.resolveStrategy = Closure.DELEGATE_FIRST
+ action.call(http)
+ def (code, response) = request(httpCall.method,
+ "http://${httpCall.endpoint}${httpCall.uri}",
httpCall.body)
+ httpCall.check.call(code, response)
+ }
+ Suite.metaClass.sleep = { long millis -> }
+ def config = new Config()
+ config.suitePath = new File('regression-test/suites').absolutePath
+ config.dataPath = new File('regression-test/data').absolutePath
+ config.realDataPath = new
File('regression-test/real-data').absolutePath
+ config.defaultDb = 'regression_test'
+ def file = new File(config.suitePath, 'snapshot_recycler_test.groovy')
+ def scriptContext = new ScriptContext(file, null, null, config, [], {
true })
+ def cluster = new SuiteCluster('snapshot_recycler_test', config)
+ def context = new SuiteContext(file, 'snapshot_recycler_test', 'p0',
scriptContext,
+ cluster, null, null, config)
+ return new Suite('snapshot_recycler_test', 'p0', context, cluster)
+ }
+
+ @AfterEach
+ void cleanup() {
+ GroovySystem.metaClassRegistry.removeMetaClass(Suite)
+ GroovySystem.metaClassRegistry.removeMetaClass(SuiteCluster)
+ }
+
+ private static String job(String status, long finish, long success) {
+ JsonOutput.toJson([status: status, last_ctime_ms: "1",
+ last_finish_time_ms: "${finish}",
last_success_time_ms: "${success}"])
+ }
+
+ @Test
+ void waitsForIdleAndRetriesTheWholeRoundUntilBothJobsSucceed() {
+ def triggers = []
+ def polls = [recycle: 0, check: 0]
+ def runs = [recycle: 0, check: 0]
+ def suite = loadPlugin { method, url, body ->
+ String kind = url.contains('/recycle_') ? 'recycle' : 'check'
+ if (url.contains('_job_info?')) {
+ polls[kind]++
+ // Both tasks are initially busy. An old successful round is
insufficient.
+ if (polls[kind] == 1) return [200, job('BUSY', 10, 10), '']
+ long finish = 10 + runs[kind]
+ // First recycle fails, second check fails, third round
succeeds.
+ long success = ((kind == 'recycle' && runs[kind] == 1)
+ || (kind == 'check' && runs[kind] == 2)) ? finish - 1
: finish
+ return [200, job('IDLE', finish, success), '']
+ }
+ assertTrue(polls[kind] >= 2, 'must wait for IDLE before
triggering')
+ assertEquals(kind == 'recycle' ? 'POST' : 'GET', method)
+ if (kind == 'recycle')
assertEquals('{"instance_ids":["test_instance"]}', body)
+ runs[kind]++
+ triggers.add(kind)
+ return [200, 'OK', '']
+ }
+ suite.recycleAndCheckSnapshot('127.0.0.1:5000', 'test_instance', 3, 3)
+ assertEquals(['recycle', 'check', 'recycle', 'check', 'recycle',
'check'], triggers)
+ }
+
+ @Test
+ void busyJobTimesOutWithoutTriggering() {
+ int triggers = 0
+ def suite = loadPlugin { method, url, body ->
+ if (!url.contains('_job_info?')) triggers++
+ [200, job('BUSY', 10, 10), '']
+ }
+ def error = assertThrows(AssertionError) {
+ suite.recycleAndCheckSnapshot('127.0.0.1:5000', 'busy_instance',
2, 2)
+ }
+ assertTrue(error.message.contains('busy_instance'))
+ assertEquals(0, triggers)
+ }
+
+ @Test
+ void staleSuccessDoesNotCountAsCompletion() {
+ int triggers = 0
+ def suite = loadPlugin { method, url, body ->
+ if (url.contains('_job_info?')) return [200, job('IDLE', 10, 10),
'']
+ triggers++
+ [200, 'OK', '']
+ }
+ assertThrows(AssertionError) {
+ suite.recycleAndCheckSnapshot('127.0.0.1:5000', 'stale_instance',
2, 2)
+ }
+ assertEquals(1, triggers, 'must not retrigger while awaiting
completion')
+ }
+
+ @Test
+ void failsAfterTheRoundLimit() {
+ def runs = [recycle: 0, check: 0]
+ def suite = loadPlugin { method, url, body ->
+ String kind = url.contains('/recycle_') ? 'recycle' : 'check'
+ if (url.contains('_job_info?')) return [200, job('IDLE', 10 +
runs[kind], 0), '']
+ runs[kind]++
+ [200, 'OK', '']
+ }
+ def error = assertThrows(AssertionError) {
+ suite.recycleAndCheckSnapshot('127.0.0.1:5000', 'failed_instance',
2, 2)
+ }
+ assertTrue(error.message.contains('2 rounds'))
+ assertEquals([recycle: 2, check: 2], runs)
+ }
+
+ @Test
+ void rejectsFailedTriggerResponse() {
+ def suite = loadPlugin { method, url, body ->
+ url.contains('_job_info?') ? [200, job('IDLE', 0, 0), ''] : [200,
'checker not enabled', '']
+ }
+ assertThrows(AssertionError) {
+ suite.recycleAndCheckSnapshot('127.0.0.1:5000', 'test_instance',
2, 2)
+ }
+ }
+
+ @Test
+ void expiryAndDeletionWaitsOnlyTriggerIdleJobsOutsideTheInterval() {
+ def info = [status: 'BUSY', last_ctime_ms: '0']
+ int triggers = 0
+ def suite = loadPlugin { method, url, body ->
+ if (url.contains('_job_info?')) return [200,
JsonOutput.toJson(info), '']
+ triggers++
+ assertEquals('POST', method)
+ assertEquals('{"instance_ids":["test_instance"]}', body)
+ return [200, 'OK', '']
+ }
+ assertFalse(suite.triggerSnapshotRecycleWhenIdle('127.0.0.1:5000',
'test_instance'))
+ info = [status: 'IDLE', last_ctime_ms:
System.currentTimeMillis().toString()]
+ assertFalse(suite.triggerSnapshotRecycleWhenIdle('127.0.0.1:5000',
'test_instance'))
+ assertEquals(0, triggers)
+ info = [status: 'IDLE', last_ctime_ms: '0']
+ assertTrue(suite.triggerSnapshotRecycleWhenIdle('127.0.0.1:5000',
'test_instance'))
+ assertEquals(1, triggers)
+ }
+
+ private Suite dataSuite(List afterRows, boolean dropTable = false) {
+ def suite = loadPlugin { method, url, body -> throw new
AssertionError('unexpected HTTP call') }
+ boolean recycled = false
+ suite.cluster.metaClass.getAllRecyclers = { boolean alive ->
+ [new Recycler(host: '127.0.0.1', httpPort: 5000, alive: true)]
+ }
+ Suite.metaClass.connectWithDockerCluster = { SuiteCluster cluster,
Closure action -> action() }
+ Suite.metaClass.recycleAndCheckSnapshot = { String address, String id
->
+ assertEquals('test_instance', id)
+ recycled = true
+ }
+ Suite.metaClass.sql = { String query ->
+ if (query.contains('information_schema.tables')) {
+ assertTrue(query.contains("ENGINE = 'Doris'"))
+ return recycled && dropTable ? [] : [['test_db', 'test_table']]
+ }
+ assertTrue(query.contains('enable_sql_cache=false'))
+ assertTrue(query.contains('enable_query_cache=false'))
+ assertTrue(query.contains('enable_file_cache=false'))
+ assertTrue(query.contains('enable_page_cache=false'))
+ assertTrue(query.contains('`test_db`.`test_table`'))
+ return recycled ? afterRows : [[1, 'a'], [1, 'a'], [2, 'b']]
+ }
+ return suite
+ }
+
+ @Test
+ void dataComparisonIgnoresOrderButPreservesDuplicates() {
+ def suite = dataSuite([[2, 'b'], [1, 'a'], [1, 'a']])
+ suite.checkSnapshotRecycle(suite.cluster, [test_instance:
suite.cluster])
+ }
+
+ @Test
+ void dataComparisonRejectsMissingExtraAndChangedRows() {
+ for (rows in [[[1, 'a'], [2, 'b']],
+ [[1, 'a'], [1, 'a'], [1, 'a'], [2, 'b']],
+ [[1, 'a'], [1, 'a'], [2, 'changed']]]) {
+ try {
+ def suite = dataSuite(rows)
+ def error = assertThrows(AssertionError) {
+ suite.checkSnapshotRecycle(suite.cluster, [test_instance:
suite.cluster])
+ }
+ assertTrue(error.message.contains('Data changed after
recycling'))
+ } finally {
+ cleanup()
+ }
+ }
+ }
+
+ @Test
+ void dataComparisonRejectsMissingTable() {
+ def suite = dataSuite([], true)
+ assertThrows(AssertionError) {
+ suite.checkSnapshotRecycle(suite.cluster, [test_instance:
suite.cluster])
+ }
+ }
+
+}
diff --git a/regression-test/plugins/snapshot_recycler_plugin.groovy
b/regression-test/plugins/snapshot_recycler_plugin.groovy
new file mode 100644
index 00000000000..702e29043cc
--- /dev/null
+++ b/regression-test/plugins/snapshot_recycler_plugin.groovy
@@ -0,0 +1,170 @@
+// 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.JsonOutput
+import groovy.json.JsonSlurper
+import org.apache.doris.regression.suite.Suite
+import org.apache.doris.regression.suite.SuiteCluster
+
+def recyclerRequest = { Suite suite, String address, String method, String
path, String payload = null ->
+ String out
+ suite.httpTest {
+ endpoint address
+ uri "/RecyclerService/http/${path}".toString()
+ if (method == 'GET') {
+ op 'get'
+ } else {
+ body payload
+ }
+ check { code, response ->
+ assert code == 200 : "Recycler request failed: path=${path},
status=${code}, body=${response}"
+ assert response?.trim() : "Empty recycler response: path=${path}"
+ out = response.trim()
+ }
+ }
+ return out
+}
+
+// The snapshot Docker suites set both scheduling intervals to 10 seconds.
+Suite.metaClass.recycleAndCheckSnapshot = { String recyclerAddress, String
instanceId,
+ int maxRounds = 10, int maxPolls =
60,
+ boolean requireCheck = true ->
+ Suite suite = delegate as Suite
+ assert maxRounds > 0 && maxPolls > 0
+ String token = "greedisgood9999"
+ String encodedInstanceId = URLEncoder.encode(instanceId, 'UTF-8')
+ def lastJobs = [:]
+
+ def request = { String method, String path, String payload = null ->
+ // A successful trigger only enqueues a job.
+ return recyclerRequest(suite, recyclerAddress, method, path, payload)
+ }
+ def waitJob = { String kind, Closure ready ->
+ for (int poll = 0; poll < maxPolls; poll++) {
+ def info = new JsonSlurper().parseText(request('GET',
+
"${kind}_job_info?token=${token}&instance_id=${encodedInstanceId}".toString()))
+ assert info.status in ['IDLE', 'BUSY'] : "Invalid ${kind} job:
instance=${instanceId}, ${info}"
+ lastJobs[kind] = info
+ if (ready(info)) return info
+ suite.sleep(1000L)
+ }
+ assert false : "Timed out waiting for ${kind}: instance=${instanceId},
jobs=${lastJobs}"
+ }
+ def runJob = { String kind ->
+ def before = waitJob(kind) { info ->
+ // Manual requests also obey the scheduling interval. These suites
disable automatic
+ // scheduling with recycle_whitelist=dummy, so a rejected trigger
would never run.
+ info.status == 'IDLE' && System.currentTimeMillis() -
+ (info.last_ctime_ms ?: '0').toString().toLong() > 10000L
+ }
+ long previousFinish = (before.last_finish_time_ms ?:
'0').toString().toLong()
+ String response = kind == 'recycle'
+ ? request('POST', "recycle_instance?token=${token}".toString(),
+ JsonOutput.toJson([instance_ids: [instanceId]]))
+ : request('GET',
"check_instance?token=${token}&instance_id=${encodedInstanceId}".toString())
+ assert response.equalsIgnoreCase('OK') : "Failed to trigger ${kind}:
instance=${instanceId}, ${response}"
+ def finished = waitJob(kind) { info ->
+ info.status == 'IDLE' &&
+ (info.last_finish_time_ms ?: '0').toString().toLong() >
previousFinish
+ }
+ return finished.last_success_time_ms != null &&
+ finished.last_finish_time_ms.toString().toLong() ==
finished.last_success_time_ms.toString().toLong()
+ }
+
+ for (int round = 1; round <= maxRounds; round++) {
+ suite.getLogger().info("Starting snapshot recycle/check:
instance=${instanceId}, round=${round}/${maxRounds}")
+ boolean recycled = runJob('recycle')
+ // Check cloned instances through SQL below: their inherited snapshot
files do not
+ // have snapshot keys in the derived instance, so the recycler checker
rejects them.
+ boolean checked = !requireCheck || runJob('check')
+ suite.getLogger().info("Snapshot recycle/check:
instance=${instanceId}, round=${round}/${maxRounds}, "
+ + "recycled=${recycled}, checked=${checked},
checkRequired=${requireCheck}, jobs=${lastJobs}")
+ if (recycled && checked) return
+ }
+ assert false : "Recycle/check failed after ${maxRounds} rounds:
instance=${instanceId}, jobs=${lastJobs}"
+}
+
+// Used only inside bounded waits for snapshot expiry or complete instance
deletion.
+// A fully deleted instance has no checker job to run; callers assert its
metadata disappears.
+Suite.metaClass.triggerSnapshotRecycleWhenIdle = { String recyclerAddress,
String instanceId ->
+ Suite suite = delegate as Suite
+ String token = "greedisgood9999"
+ String encodedInstanceId = URLEncoder.encode(instanceId, 'UTF-8')
+ String out = recyclerRequest(suite, recyclerAddress, 'GET',
+
"recycle_job_info?token=${token}&instance_id=${encodedInstanceId}".toString())
+ def info = new JsonSlurper().parseText(out)
+ assert info.status in ['IDLE', 'BUSY'] : "Invalid recycle job for
${instanceId}: ${info}"
+ if (info.status != 'IDLE' || System.currentTimeMillis() -
+ (info.last_ctime_ms ?: '0').toString().toLong() <= 10000L) return
false
+ String triggerOut = recyclerRequest(suite, recyclerAddress, 'POST',
+ "recycle_instance?token=${token}".toString(),
JsonOutput.toJson([instance_ids: [instanceId]]))
+ assert triggerOut.equalsIgnoreCase('OK') : "Failed to trigger recycle for
${instanceId}: ${triggerOut}"
+ return true
+}
+
+// Record all remaining OLAP rows before recycling, then read them again
without caches.
+// Callers pass the current instance IDs after rollback and omit clusters
whose creation failed.
+// A null cluster allows checking a predecessor that still owns snapshots but
has no running FE.
+Suite.metaClass.checkSnapshotRecycle = { SuiteCluster recyclerCluster, Map
instanceClusters ->
+ Suite suite = delegate as Suite
+ assert !instanceClusters.isEmpty()
+ def recyclers = recyclerCluster.getAllRecyclers(true)
+ assert !recyclers.isEmpty() : "No live recycler in ${recyclerCluster.name}"
+ def (ip, port) = recyclers[0].getHttpAddress()
+ String recyclerAddress = "${ip}:${port}"
+ def readData = { SuiteCluster dataCluster ->
+ def data = [:]
+ suite.connectWithDockerCluster(dataCluster) {
+ def tables = suite.sql("""
+ SELECT TABLE_SCHEMA, TABLE_NAME FROM information_schema.tables
+ WHERE ENGINE = 'Doris' AND TABLE_SCHEMA NOT IN
('information_schema', 'mysql', '__internal_schema')
+ ORDER BY TABLE_SCHEMA, TABLE_NAME
+ """)
+ tables.each { table ->
+ String name = table.collect { '`' + it.toString().replace('`',
'``') + '`' }.join('.')
+ def rows = suite.sql("""SELECT /*+
SET_VAR(enable_sql_cache=false,
+ enable_query_cache=false, enable_file_cache=false,
enable_page_cache=false) */ * FROM ${name}""")
+ // Sort serialized rows instead of using a set: duplicate
multiplicities must survive.
+ data[name] = rows.collect { JsonOutput.toJson(it) }.sort()
+ }
+ }
+ return data
+ }
+ def expected = [:]
+ instanceClusters.each { instanceId, dataCluster ->
+ if (dataCluster != null) {
+ expected[instanceId] = readData(dataCluster)
+ suite.getLogger().info("Snapshot data baseline:
instance=${instanceId}, tables=${expected[instanceId].size()}, "
+ + "rows=${expected[instanceId].values().sum { it.size() }
?: 0}")
+ }
+ }
+ instanceClusters.each { instanceId, dataCluster ->
+ if (dataCluster != null && dataCluster != recyclerCluster) {
+ suite.recycleAndCheckSnapshot(recyclerAddress,
instanceId.toString(), 10, 60, false)
+ } else {
+ suite.recycleAndCheckSnapshot(recyclerAddress,
instanceId.toString())
+ }
+ }
+ instanceClusters.each { instanceId, dataCluster ->
+ if (dataCluster != null) {
+ def actual = readData(dataCluster)
+ assert actual == expected[instanceId] : "Data changed after
recycling: instance=${instanceId}, " +
+ "expected=${expected[instanceId]}, actual=${actual}"
+ }
+ }
+}
+
+logger.info("Added snapshot recycle/check helpers to Suite")
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]