perf(ci): Optimize MySQL integration verification (#15200)

Close Spring contexts before fixture database cleanup, run integration classes in isolated reusable forks, and remove redundant fixture validation and duplicate load-test execution. Document bounded local validation and preserve full CI gates.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
jinye 2026-10-22 20:03:13 +08:00 • committed by GitHub
parent e9011d21c3
commit 288e4c481d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 709 additions and 237 deletions

View file

@ -637,7 +637,8 @@ jobs:
# instead, with native AIO disabled and its datadir on tmpfs. The MySQL
# leg also binary-logs by default into that uncapped tmpfs, and this
# disposable container has no replica and nothing reads the log.
# The only schema is the one the JDBC URL creates on first connect.
# Each fork needs its own instance: lock tests assert server-wide
# InnoDB deadlock counters, which separate schemas cannot isolate.
- name: 'Start the database without native AIO'
shell: 'bash'
run: |-
@ -646,46 +647,51 @@ jobs:
if [[ -n "${DATABASE_ENGINE_ARGS}" ]]; then
read -r -a engine_args <<< "${DATABASE_ENGINE_ARGS}"
fi
docker run --detach --name "${MYSQL_CONTAINER}" \
--publish '127.0.0.1::3306' \
--mount type=tmpfs,destination=/var/lib/mysql \
--env MYSQL_ROOT_PASSWORD='runtime-broker' \
"${DATABASE_IMAGE}" "${engine_args[@]}"
# MariaDB ships healthcheck.sh; the MySQL image does not, so each
# leg waits on the readiness signal its own image provides.
if [[ "${DATABASE_CLIENT}" == 'mariadb' ]]; then
health=(docker exec "${MYSQL_CONTAINER}" healthcheck.sh --connect --innodb_initialized)
else
health=(docker exec "${MYSQL_CONTAINER}" mysqladmin ping --host=127.0.0.1 --user=root --password=runtime-broker --silent)
fi
# A probe that is itself broken (image bump, renamed healthcheck.sh)
# is otherwise indistinguishable from a server still starting.
probe_out="${RUNNER_TEMP:-/tmp}/readiness-probe.out"
for attempt in {1..60}; do
if "${health[@]}" >"${probe_out}" 2>&1; then
break
for fork in 1 2; do
container="${MYSQL_CONTAINER}-${fork}"
docker run --detach --name "${container}" \
--publish '127.0.0.1::3306' \
--mount type=tmpfs,destination=/var/lib/mysql \
--env MYSQL_ROOT_PASSWORD='runtime-broker' \
"${DATABASE_IMAGE}" "${engine_args[@]}"
# MariaDB ships healthcheck.sh; the MySQL image does not, so each
# leg waits on the readiness signal its own image provides.
if [[ "${DATABASE_CLIENT}" == 'mariadb' ]]; then
health=(docker exec "${container}" healthcheck.sh --connect --innodb_initialized)
else
health=(docker exec "${container}" mysqladmin ping --host=127.0.0.1 --user=root --password=runtime-broker --silent)
fi
if ! docker inspect --format '{{.State.Running}}' "${MYSQL_CONTAINER}" | grep -qx true; then
docker logs "${MYSQL_CONTAINER}" || true
echo "::error::${DATABASE_IMAGE} container stopped during startup; last ${DATABASE_CLIENT} readiness probe said: $(cat "${probe_out}")"
exit 1
fi
if [[ "${attempt}" == '60' ]]; then
docker logs "${MYSQL_CONTAINER}" || true
echo "::error::${DATABASE_IMAGE} not ready after 60 probes; last ${DATABASE_CLIENT} readiness probe said: $(cat "${probe_out}")"
exit 1
fi
sleep 2
# A probe that is itself broken (image bump, renamed healthcheck.sh)
# is otherwise indistinguishable from a server still starting.
probe_out="${RUNNER_TEMP:-/tmp}/readiness-probe-${fork}.out"
for attempt in {1..60}; do
if "${health[@]}" >"${probe_out}" 2>&1; then
break
fi
if ! docker inspect --format '{{.State.Running}}' "${container}" | grep -qx true; then
docker logs "${container}" || true
echo "::error::${DATABASE_IMAGE} container stopped during startup; last ${DATABASE_CLIENT} readiness probe said: $(cat "${probe_out}")"
exit 1
fi
if [[ "${attempt}" == '60' ]]; then
docker logs "${container}" || true
echo "::error::${DATABASE_IMAGE} not ready after 60 probes; last ${DATABASE_CLIENT} readiness probe said: $(cat "${probe_out}")"
exit 1
fi
sleep 2
done
port="$(docker inspect --format '{{(index (index .NetworkSettings.Ports "3306/tcp") 0).HostPort}}' "${container}")"
echo "MYSQL_PORT_${fork}=${port}" >> "${GITHUB_ENV}"
done
MYSQL_PORT="$(docker inspect --format '{{(index (index .NetworkSettings.Ports "3306/tcp") 0).HostPort}}' "${MYSQL_CONTAINER}")"
echo "MYSQL_PORT=${MYSQL_PORT}" >> "${GITHUB_ENV}"
- name: 'Configure the database packet budget for large-object tests'
run: |-
# Connector/J hex-encodes the 32 MiB LONGBLOB fixture into a ~67 MiB
# query, so the budget must sit above it: the inventory's byte-limit
# gate, not the server packet limit, is what the test exercises.
# Set this job's disposable container before its first JDBC connection.
docker exec "${MYSQL_CONTAINER}" sh -c 'MYSQL_PWD="${MYSQL_ROOT_PASSWORD}" '"${DATABASE_CLIENT}"' --user=root --execute="SET GLOBAL max_allowed_packet=134217728; SELECT @@GLOBAL.max_allowed_packet"'
# Set both disposable containers before their first JDBC connections.
for fork in 1 2; do
docker exec "${MYSQL_CONTAINER}-${fork}" sh -c 'MYSQL_PWD="${MYSQL_ROOT_PASSWORD}" '"${DATABASE_CLIENT}"' --user=root --execute="SET GLOBAL max_allowed_packet=134217728; SELECT @@GLOBAL.max_allowed_packet"'
done
- name: 'Set up Node.js 22 (hosted)'
if: "${{ runner.environment == 'github-hosted' }}"
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
@ -754,8 +760,12 @@ jobs:
# The shell owns the lock; Maven children must not extend its lifetime.
mvn --batch-mode --no-transfer-progress -f packages/sdk-java/qwencode/pom.xml -DskipTests -Dgpg.skip=true -Dmaven.javadoc.skip=true install 9>&-
cd packages/managed-agent-server
# Keep mysql.url for release runs that check out an older tag.
mvn --batch-mode --no-transfer-progress -Pmysql-integration \
-Dmysql.url="jdbc:mysql://127.0.0.1:${MYSQL_PORT}/managed_agent_test?createDatabaseIfNotExist=true&allowPublicKeyRetrieval=true&useSSL=false" \
-Dmysql.integration.forks=2 \
-Dmysql.url="jdbc:mysql://127.0.0.1:${MYSQL_PORT_1}/managed_agent_test?createDatabaseIfNotExist=true&allowPublicKeyRetrieval=true&useSSL=false" \
-Dmysql.url.1="jdbc:mysql://127.0.0.1:${MYSQL_PORT_1}/managed_agent_test?createDatabaseIfNotExist=true&allowPublicKeyRetrieval=true&useSSL=false" \
-Dmysql.url.2="jdbc:mysql://127.0.0.1:${MYSQL_PORT_2}/managed_agent_test?createDatabaseIfNotExist=true&allowPublicKeyRetrieval=true&useSSL=false" \
-Dmysql.user=root -Dmysql.password=runtime-broker clean verify checkstyle:check 9>&-
# Close the cache loop inside the same lock: the one-way seed
# above alone does not recapture the Server dependency tree after
@ -792,15 +802,18 @@ jobs:
# The host's docker cleanup service fires once a day and reaps qwen-code-*
# containers only past a strict 24h age gate, so a cancelled job can strand
# a running database server on the shared pool for up to ~48h.
- name: 'Remove the database container'
- name: 'Remove the database containers'
if: '${{ always() }}'
shell: 'bash'
run: |-
set -uo pipefail
if docker inspect "${MYSQL_CONTAINER}" >/dev/null 2>&1; then
docker rm --force "${MYSQL_CONTAINER}" ||
echo "::warning::could not remove ${MYSQL_CONTAINER}; the host cleanup service reaps it within ~48h"
fi
for fork in 1 2; do
container="${MYSQL_CONTAINER}-${fork}"
if docker inspect "${container}" >/dev/null 2>&1; then
docker rm --force "${container}" ||
echo "::warning::could not remove ${container}; the host cleanup service reaps it within ~48h"
fi
done
hosted-harness-mysql:
name: 'Hosted process fault gates / MySQL 8.4 / Java 21'

View file

@ -0,0 +1,56 @@
# MySQL integration CI optimization
[English](2026-10-22-mysql-ci-optimization.md) | [简体中文](2026-10-22-mysql-ci-optimization.zh-CN.md)
## Problem and evidence
The main nightly [run 39664846210](https://github.com/QwenLM/qwen-code/actions/runs/39664846210) spent 33 minutes 41 seconds executing 1,060 integration tests in 52 classes. Their reported class times sum to 33 minutes 39 seconds, so execution was effectively serial. This excludes unit tests, Maven cache work, and the runner's Java lock wait.
`EventTransportConsumerMySqlIT` drops its database while its cached Spring context remains active. The same run then emitted 7,320 unknown-database exceptions over the following 30 minutes. A separate Spring/JUnit/MySQL probe reproduced the lifecycle error and verified closing the context through Spring's test lifecycle before dropping its database.
The SSH MySQL fixture also validates the same current schema three consecutive times without an intervening database mutation: its version assertion validates the catalog, its explicit validation repeats it, and its parent's initialization validates it again.
The local full-run reports also show that `ManagedSchemaLoadMySqlIT` inherits and repeats all 21 cases from `ManagedSchemaSessionMySqlIT`, in addition to its one unique load test. The two classes therefore execute 43 cases for 22 distinct scenarios.
## Changes
1. Mark the event-consumer MySQL context dirty after its class, and drop the owned database from a test listener after Spring has closed and evicted that context. Do not close the context prematurely inside JUnit's `@AfterAll`, because Spring's remaining listeners still use it.
2. Give only the `mysql-integration` Failsafe profile a fork-count property, defaulting to one for local runs. Each existing database matrix leg selects two reusable JVM forks and two independent instances of its own engine. The MySQL 8.4 and MariaDB 10.11 legs retain their respective images, startup arguments, readiness probes and clients. A test launcher listener resolves `${surefire.forkNumber}` to the corresponding database URL before test discovery and Spring initialization. Existing per-test UUID databases and method-level serial execution remain intact. Missing fork mappings fail the run instead of sharing the first database.
3. Have the SSH fixture's source factory return an initialized source. The H2 factory initializes its new database; the MySQL factory retains both migration stages and version assertions, and removes the redundant final validation. The parent setup no longer initializes it a second time.
4. Move the unique load test and its two private helpers unchanged into `ManagedSchemaSessionMySqlIT`, and remove `ManagedSchemaLoadMySqlIT`. The existing per-test database initialization and cleanup remain unchanged. The 21 inherited session cases and the load case now each execute once, without exclusions or a new fixture abstraction.
## Decisions and boundaries
Each database matrix leg still holds the existing host lock once, seeds one job-local Maven repository, builds once, runs unit tests once, and writes its dependency cache back once. Failsafe parallelizes integration classes inside that leg without introducing an extra workflow job and host-lock wait per fork. Surefire unit-test concurrency is unchanged. The existing complete-source-tree report check consumes the combined Failsafe reports, without a static class list or weakened coverage requirement.
Separate JVMs isolate tests that change `mysql.url` and the default time zone. Separate MySQL instances isolate both application tables and instance-wide InnoDB deadlock counters: `ToolPublicationLockOrderMySqlIT` deliberately causes a deadlock, while other tests assert that this counter does not increase. Separate schemas on one server would not satisfy that contract. Classes and methods must not run concurrently inside a fork. Schema mutation, migration, locking, recovery, and native-worker assertions remain enabled. Production schema validation is unchanged; fixtures that deliberately alter or drop schema objects continue to create a fresh database for each test.
The affected files are the event-consumer MySQL fixture, the two SSH fixtures, the session/load MySQL fixtures, the test launcher listener and its tests/service registration, the server Maven profile, the Java workflow, and its applicable workflow meta-tests. The workflow also supplies the original `mysql.url`, so releases checking out an older tag can still run serially. This change does not alter product behavior or artifact publishing steps.
Local Java verification guidance is centralized in the server's [Build and Verification rules](../../packages/managed-agent-server/AGENTS.md#build-and-verification). Local work selects affected behavior, reuses applicable evidence and has a bounded budget; full module acceptance remains with CI. This replaces the former unconditional local full-Maven requirement without weakening CI gates. The root AGENTS.md is unchanged: it already delegates Java-only verification to this module, while cross-language changes retain the root build/typecheck requirements. Skills, subagents and automation prompts working on this module reference its rules instead of maintaining separate copies. Documentation-only follow-ups do not rerun the unchanged Java suite.
## Verification and acceptance
- Reproduce the context leak, then verify that the real fixture's context closes before its database is deleted and that subsequent tests emit no unknown-database errors from that context.
- Compare the SSH MySQL class before and after on the same disposable MySQL 8.4 server; preserve both migration/version checks and all H2/MySQL business tests. Record class execution time separately from Maven build time.
- For the session/load consolidation, compare the selected real MySQL run with the deduplicated method identities in the two archived baseline reports: all 22 cases must pass without skips. Remove stale compiled output for the deleted class before the focused run. Relative to that archived baseline, consolidation removes one class and 21 duplicate executions, yielding 53 classes and 1,054 cases before any further upstream additions; leave the current full-suite confirmation to CI.
- Run the complete MySQL integration suite with two forks using distinct MySQL instances. Preserve all test selectors, compare the changed fixtures' test identities with their baseline, record full-suite counts, failures, skips and Failsafe time, and run the complete-report guard. Verify URL selection before Spring initialization, default serial behavior, and refusal of missing fork mappings. No speedup is accepted at the expense of missing tests.
- Run module verification, applicable workflow meta-tests, repository build/typecheck, formatting, and independent review. The global CLI cannot execute Maven test-fixture lifecycles, so the regression uses the actual Java test suite and a test-script reproduction rather than model-driven CLI prompts.
The historical class timings imply an ideal two-fork integration time near 17 minutes, but this is a planning estimate. Measured results must distinguish local macOS validation from Linux CI execution and disclose database, CPU, and runner contention differences.
The unchanged local SSH baseline took 71.379, 104.812 and 178.274 seconds for the same 17 passing tests. The shared 12-core host had load averages around 40–50. An additional full serial baseline would not produce a reliable before/after speed comparison under this changing contention, so it was cancelled before launch. Actual CI speedup must be measured on CI.
On JDK 21 and a disposable MySQL 8.4.11 instance, the actual event-consumer fixture passed all 12 original tests. At the real database-close call, its Spring context was inactive and absent from the cache while the schema still existed; the schema disappeared after the real DROP. Three more seconds in the same JVM produced no unknown-database or scheduled-task errors. The SSH fixture passed all 17 original test identities in each of three optimized runs: 44.342, 77.844 and 96.098 seconds. Host load had also fallen, so these measurements establish passing coverage and observed duration, not a precise CI speedup. The code removes exactly two redundant full schema validations per SSH test, or 34 per class run.
Before the session/load consolidation, the complete, unfiltered `clean verify checkstyle:check` run passed on 2026-10-22: Surefire reported 5,304 passing unit/H2 tests and 18 platform or environment conditional skips; Failsafe reported all 54 non-Hosted classes and 1,075 passing cases with no failures, errors or skips. The complete-source-tree report guard passed, as did Checkstyle and SpotBugs. All report properties selected the correct independent MySQL instance: 25 classes on fork 1 and 29 on fork 2. The complete log contained no unknown-database or scheduled-task errors. Build, bundle, typecheck and applicable CI script regressions also passed, with platform-conditional script skips recorded in the test report.
Local MySQL test execution took 1,419.755 seconds (23 minutes 40 seconds); the complete Maven command took 2,301.433 seconds. This used two native MySQL 8.4.11 instances with disk-backed data directories on shared macOS hardware. CI uses MySQL 8.4.6 and MariaDB 10.11.18 containers with tmpfs on Linux. These timings validate the implementation under the recorded local conditions; they are not a measured improvement over the historical CI run.
The subsequent session/load consolidation passed a focused real MySQL 8.4.11 run: all 22 distinct cases passed exactly once, with zero failures, errors or skips. Their method identities match the union of the two archived baseline reports, and Checkstyle reported zero violations. The moved test and helpers, inherited session tests, and per-test database lifecycle are unchanged. This selected run does not replace the pending full CI confirmation.
The PR also preserves the database matrix restored on main by #15003: shell regressions cover both engines, both fork ports, engine-specific readiness and client commands, and startup or cleanup failure of either instance. After rebasing, all nine workflow-consumer test files passed with 545 successful cases and 11 platform/capability skips. Repository build and typecheck, formatting, ESLint, workflow-size and local-reference checks passed. Real Linux container execution and the full rebased suite remain CI acceptance work.
## Status
Implementation and the recorded local checks are complete; full acceptance of the rebased revision remains pending CI. Results and the earlier failed verification attempt are preserved in `.qwen/e2e-tests/mysql-ci-optimization.md`. Online CI timing remains to be measured; no CI speedup percentage is claimed.

View file

@ -0,0 +1,56 @@
# MySQL 集成 CI 优化
[English](2026-10-22-mysql-ci-optimization.md) | [简体中文](2026-10-22-mysql-ci-optimization.zh-CN.md)
## 问题与证据
main 夜间[运行 39664846210](https://github.com/QwenLM/qwen-code/actions/runs/39664846210) 执行 52 个类、1,060 个集成用例用了 33 分 41 秒。各类报告耗时累计为 33 分 39 秒,说明实际基本串行。这个时间不包含单元测试、Maven 缓存操作和 runner 的 Java 锁等待。
`EventTransportConsumerMySqlIT` 在缓存的 Spring context 仍然运行时删除数据库。同一次运行在后续 30 分钟产生了 7,320 次数据库不存在异常。独立的 Spring/JUnit/MySQL 脚本复现了这个生命周期问题,并验证了通过 Spring 测试生命周期关闭 context 后再删库的顺序。
SSH MySQL fixture 还会在数据库没有发生修改的情况下连续三次校验同一个当前版本 schema:版本断言会校验 catalog,显式校验再次执行,父类的初始化又会校验一次。
本地全量报告还显示,`ManagedSchemaLoadMySqlIT` 除了自身唯一的负载用例,还继承并重复执行 `ManagedSchemaSessionMySqlIT` 的全部 21 个用例。因此两个类针对 22 个独立场景共执行了 43 次。
## 改动
1. 在事件消费者 MySQL 测试类结束后标记 context 为 dirty,并通过测试监听器在 Spring 关闭和驱逐 context 后删除测试独占的数据库。不要在 JUnit 的 `@AfterAll` 中提前直接关闭 context,因为 Spring 后续监听器仍会使用它。
2. 仅为 `mysql-integration` 的 Failsafe profile 增加 fork 数量属性,本地默认仍为一个。每条现有数据库矩阵分支选择两个可复用的 JVM fork 和两个使用自身引擎的独立实例。MySQL 8.4 和 MariaDB 10.11 分支保留各自的镜像、启动参数、就绪探针和客户端。测试 launcher 监听器在测试发现和 Spring 初始化之前,将 `${surefire.forkNumber}` 解析为对应数据库 URL。现有逐用例 UUID 数据库和方法串行执行方式继续保留。缺少 fork 映射时直接失败,不能共用第一个数据库。
3. 让 SSH fixture 的数据源工厂返回已经初始化的数据源。H2 工厂初始化新数据库;MySQL 工厂保留两个迁移阶段和版本断言,删除末尾的重复校验。父类 setup 不再重复初始化。
4. 将唯一的负载用例及两个私有帮助方法原样移入 `ManagedSchemaSessionMySqlIT`,删除 `ManagedSchemaLoadMySqlIT`。原有逐用例数据库初始化和清理逻辑保持不变,21 个继承的会话用例和负载用例各执行一次,无需排除测试或新增 fixture 抽象。
## 决策与边界
每条数据库矩阵分支仍然只获取一次现有主机锁、准备一个 job 独立的 Maven 仓库、构建一次、执行一次单元测试并回写一次依赖缓存。Failsafe 在该分支内并行执行集成测试类,无需为每个 fork 新增 workflow job 和主机锁等待。Surefire 单元测试并发方式不变。现有完整源码树报告检查消费合并的 Failsafe 报告,无需静态测试类列表,也不削弱覆盖要求。
独立 JVM 隔离修改 `mysql.url` 和默认时区的测试;独立 MySQL 实例同时隔离应用表和实例级 InnoDB 死锁计数。`ToolPublicationLockOrderMySqlIT` 会故意制造死锁,其他测试则会断言计数没有增长,同一个服务上的独立 schema 无法满足这个契约。每个 fork 内的类和方法不得并行执行。保留 schema 修改、迁移、锁、恢复和原生 worker 断言。生产 schema 校验不变;故意修改或删除 schema 对象的 fixture 继续为每个用例创建新数据库。
涉及文件包括事件消费者 MySQL fixture、两个 SSH fixture、会话和负载 MySQL fixture、测试 launcher 监听器及其测试和服务注册、服务端 Maven profile、Java workflow 及适用的 workflow 元测试。workflow 同时提供原有 `mysql.url`,让检出旧 tag 的发布仍可串行执行。本改动不改变产品行为或产物发布步骤。
本地 Java 验证规范统一写入服务端的[构建与验证规则](../../packages/managed-agent-server/AGENTS.md#build-and-verification)。本地按受影响行为选择检查、复用仍然有效的证据,并遵守时间预算;模块完整验收仍由 CI 承担。这取代原来无条件在本地执行全量 Maven 的要求,不削弱 CI 门禁。根目录 AGENTS.md 保持不变:它已将纯 Java 改动的验证交给模块规则,跨语言改动仍保留根目录要求的 build/typecheck。处理该模块的技能、子代理和自动化提示引用模块规则,避免维护多份副本。后续仅修改文档时,不重跑未变化的 Java 套件。
## 验证与验收
- 复现 context 泄漏,再验证实际 fixture 在删除数据库前关闭 context,后续测试不再产生来自该 context 的数据库不存在异常。
- 在同一个临时 MySQL 8.4 服务上对比 SSH MySQL 测试类的改动前后耗时;保留两次迁移和版本校验,以及全部 H2/MySQL 业务测试。单独记录测试类执行耗时和 Maven 构建耗时。
- 对会话与负载测试合并,比较定向真实 MySQL 运行与归档的两个基线报告去重后的方法身份:22 个用例必须全部通过且零跳过。定向运行前移除已删除类的陈旧编译产物。相对该归档基线,合并减少一个类和 21 次重复执行,在不计后续上游新增用例时为 53 个类、1,054 个用例;当前全量套件的确认交由 CI。
- 使用两个独立 MySQL 实例的双 fork 执行完整 MySQL 集成套件。保留所有测试选择规则,将改动 fixture 的用例身份与基线对齐,记录全量类数、用例数、失败、跳过和 Failsafe 耗时,并执行完整报告检查。验证 Spring 初始化前完成 URL 选择、本地默认串行,以及缺少 fork 映射时拒绝执行。不得以漏跑测试换取加速。
- 执行模块验证、适用的 workflow 元测试、仓库 build/typecheck、格式检查和独立审查。全局 CLI 无法执行 Maven 测试 fixture 生命周期,因此回归采用实际 Java 测试套件和脚本复现,不使用模型驱动的 CLI 提示词。
历史测试类耗时推算的双 fork 理想集成执行时间约为 17 分钟,但这只是规划估算。实测结果必须区分本地 macOS 验证与 Linux CI 执行,并说明数据库、CPU 和 runner 资源竞争差异。
本地未改动的 SSH 基线对相同的 17 个通过用例分别用了 71.379、104.812 和 178.274 秒。共享的 12 核主机负载均值约为 40–50。在这种不断变化的资源竞争下,额外执行完整串行基线也不能产生可靠的改动前后速度对比,因此在启动前取消。实际 CI 提速需在 CI 上测量。
在 JDK 21 和临时 MySQL 8.4.11 实例上,实际事件消费者 fixture 的 12 个原用例全部通过。真实 database-close 调用发生时,Spring context 已停止并移出缓存,而 schema 仍存在;执行真实 DROP 后 schema 消失。在同一 JVM 再观察 3 秒,没有数据库不存在或调度任务错误。SSH fixture 优化后三轮都通过相同的 17 个原用例,耗时分别为 44.342、77.844 和 96.098 秒。主机负载也同时下降,因此这些数据用于证明测试覆盖通过并记录观测时长,不能作为精确 CI 提速比例。代码确定减少每个 SSH 用例两次全量 schema 校验,即每次执行该类减少 34 次。
在会话与负载测试合并之前,2026-10-22 完整、未筛选的 `clean verify checkstyle:check` 验证通过:Surefire 的 5,304 个单元/H2 用例通过,18 个因平台或环境条件跳过;Failsafe 覆盖全部 54 个非 Hosted 类,1,075 个用例全部通过,零失败、错误和跳过。完整源码树报告检查、Checkstyle 和 SpotBugs 均通过。所有报告属性均选择了正确的独立 MySQL 实例:fork 1 执行 25 个类,fork 2 执行 29 个类。完整日志没有数据库不存在或调度任务错误。仓库 build、bundle、typecheck 和适用的 CI 脚本回归也已通过,脚本中因平台条件跳过的项目记录在测试报告中。
本地 MySQL 测试执行耗时 1,419.755 秒(23 分 40 秒),完整 Maven 命令耗时 2,301.433 秒。此次使用共享 macOS 主机上两个原生 MySQL 8.4.11 实例,数据目录在磁盘上;CI 使用 Linux 上的 MySQL 8.4.6、MariaDB 10.11.18 容器和 tmpfs。这些时间证明实现已经在记录的本地条件下运行通过,不能作为相对历史 CI 的实测提速。
随后完成的会话与负载测试合并通过了真实 MySQL 8.4.11 定向验证:22 个独立用例各通过一次,零失败、错误和跳过。方法身份与两个归档基线报告的并集完全一致,Checkstyle 零违规。迁移的测试及帮助方法、继承的会话测试和逐用例数据库生命周期保持不变。此次定向运行不替代尚待执行的全量 CI 确认。
本 PR 同时保留 main 通过 #15003 恢复的数据库矩阵:Shell 回归覆盖两种引擎、两个 fork 的端口、引擎各自的就绪探针和客户端命令,以及任一实例启动或清理失败的路径。同步 main 后,九个工作流调用方测试文件全部通过:545 个用例通过,11 个因平台或能力条件跳过。仓库 build、typecheck、格式、ESLint、工作流大小和本地引用检查通过。真实 Linux 容器执行和 rebase 后的完整套件仍待 CI 验收。
## 状态
实现和已记录的本地检查已完成;rebase 后版本的完整验收仍等待 CI。结果及之前失败的验证轮次保留在 `.qwen/e2e-tests/mysql-ci-optimization.md`。线上 CI 耗时仍待测量,不宣称 CI 提速比例。

View file

@ -41,42 +41,108 @@ profiles. Keep detailed procedures there rather than duplicating them here.
## Build and Verification
Use JDK 21 and Maven 3.8.9 or newer. Run the following commands from this
directory. On a fresh checkout, install the sibling dependencies first:
The repository's General workflow delegates Java-only verification to this
file. The policy below governs this module's local verification, including
invoked skills, subagents, automations and PR follow-ups. Reference this
section instead of copying its rules into prompts or monitoring skills such
as `babysit-pr`. Changes in other packages still follow their applicable root
and module rules, including the root build/typecheck requirement for
cross-language changes.
### Local validation scope and budget
1. **Select by changed behavior.** Cover the current increment and affected
callers, contracts and consumers. A new commit SHA, rebase or review round
alone does not require repeating tests; inspect what actually changed.
2. **Reuse valid evidence.** Do not repeat a successful check while its tested
source, tests, dependencies, toolchain, generated inputs and runtime
configuration remain applicable. Record the command, tested state and
outcome. When an input changes, rerun only checks whose evidence it
invalidates. Preserve failed attempts; an isolated pass does not clear an
unexplained wider failure.
3. **Preflight the environment.** Check required toolchain versions, build
prerequisites and service configuration before expensive tests. Fix an
environment mismatch before retrying the affected check; a full suite is
not an environment diagnostic.
4. **Share one local budget.** Unless the user explicitly sets another budget,
spend at most 10 minutes on this module's local verification per requested
change, including prerequisite setup, builds, tests and retries. Subagents
and review rounds share that budget; a new SHA or continuation message does
not reset it. Give commands a timeout bounded by the remaining budget. At
exhaustion, stop local checks, clean up task-owned resources and record
incomplete checks for CI. Do not silently extend the budget or keep adding
checks. This budget does not waive checks required for other changed packages.
5. **Keep full gates in CI.** Local validation prioritizes focused regression
and the minimum real integration path. CI owns this module's required full
suites and profiles; do not weaken their selectors or coverage guards.
Report local passes, failures, skips and CI gaps separately. Until the
required CI gates pass on the revision being accepted, say acceptance is
pending; if CI does not cover a required check, keep that gap explicit.
6. **Expand only with evidence.** Name a concrete failure, changed shared
contract or specific unresolved risk and the additional check that would
resolve it. "More confidence" alone is insufficient. Expansion remains
within the local budget; otherwise hand it to CI. Self-audit checks the
validity of evidence without automatically rerunning it.
### Maven and MySQL checks
Use JDK 21 and Maven 3.8.9 or newer; run Maven from this directory.
Before testing, check the toolchain and that the installed sibling SDK matches
the source being tested. Reuse a valid build; otherwise install it once into
the task's isolated Maven repository to avoid another checkout's same-version
artifact. Use the same task-specific `-Dmaven.repo.local` setting for installation
and subsequent Maven commands (it may be supplied through `MAVEN_ARGS`):
```bash
mvn -f ../sdk-java/qwencode/pom.xml -DskipTests -Dgpg.skip=true install
```
The native CSI conformance tests also load built Core and CLI JavaScript. On a
fresh worktree, follow the root dependency setup and run the root build once
to prepare `packages/core/dist` and `packages/cli/dist` before the full Maven
suite. Maven does not build those prerequisites.
Native CSI conformance tests also load built Core and CLI JavaScript. When
selecting those tests, check `packages/core/dist` and `packages/cli/dist` and
build missing or stale prerequisites under the root dependency instructions.
Maven does not build them. Setup and build time count toward the local budget.
For Java changes, run the affected tests first, then the module verification:
For local Java changes, select the affected classes and their consumers:
```bash
mvn -Dtest=ChangedClassTest test
mvn verify
mvn -Dtest=ChangedClassTest test checkstyle:check
```
Replace `ChangedClassTest` with the affected test classes. `mvn verify` runs the
unit/H2 tests, Checkstyle, and the SpotBugs high-confidence gate. Java formatting
and style follow the existing
Replace `ChangedClassTest` with the affected test classes. Do not follow a
successful focused run with unconditional local `mvn verify`. CI runs the full
unit/H2 suite, Checkstyle and SpotBugs through `mvn verify`; broader local checks
need the concrete evidence and remaining budget required by this module's policy.
Java formatting and style follow the existing
[Checkstyle rules](../sdk-java/qwencode/qwen-code-checks.xml). Justify any new SpotBugs
exclusion in the PR; do not disable a gate to make verification pass.
SQL, transaction, concurrency, or recovery changes also require the
`mysql-integration` profile against a disposable MySQL 8.4 database. Changes to
Hosted execution or failure recovery require the matching Hosted/fault profiles
in [pom.xml](pom.xml). Use the README and the repository's
SQL, transaction, concurrency, or recovery changes require real MySQL evidence:
locally select the affected `mysql-integration` cases against a disposable
MySQL 8.4 database within budget, and leave the complete profile to CI. Hosted
execution or failure recovery changes similarly need the relevant real-process
path locally and the matching full Hosted/fault profiles in CI. Profiles are in
[pom.xml](pom.xml). Focused local reports prove only their selected cases; the
complete-source-tree report guard belongs to unfiltered full runs. Use the README and the repository's
[Java workflow](../../.github/workflows/sdk-java.yml) for setup and profile
parameters. H2 tests alone do not establish MySQL or process-recovery behavior.
Report skipped or unavailable checks explicitly.
If the required real path cannot run locally within budget, record it as pending
CI rather than substituting H2 or claiming acceptance.
For changes to shared contracts or TypeScript consumers, also run the affected
consumer tests and the root build/typecheck requirements. Documentation-only
changes need formatting and reference checks.
Before MySQL tests, verify the effective server version, task-owned database
URL/credentials and required permissions. Check settings needed by the selected
cases: large-object tests require `max_allowed_packet >= 134217728`; the
`mysql-integration` Linux CI containers disable native AIO with `--innodb-use-native-aio=0`; the MySQL leg also uses `--skip-log-bin`. Follow the workflow's
actual startup settings and record local platform deviations. Multi-fork runs
need a distinct MySQL instance per fork to isolate server-wide deadlock counters.
Repair an invalid environment before running tests; report unavailable checks
and remaining CI work explicitly.
For shared-contract or TypeScript-consumer changes, also follow the root
build/typecheck requirements and run affected consumer tests. The local Maven
work follows this module's budget; CI retains the full cross-language gates.
Documentation-only changes need formatting and reference checks and do not
invalidate unchanged Java test results.
Maven report output under `target/` (javadoc `reports/apidocs`, JaCoCo
`site/jacoco`) contains generated JavaScript that the repository-root ESLint

View file

@ -293,6 +293,9 @@
</profile>
<profile>
<id>mysql-integration</id>
<properties>
<mysql.integration.forks>1</mysql.integration.forks>
</properties>
<build>
<plugins>
<plugin>
@ -303,6 +306,18 @@
<excludes>
<exclude>**/Hosted*IT.java</exclude>
</excludes>
<forkCount>${mysql.integration.forks}</forkCount>
<reuseForks>true</reuseForks>
<systemPropertyVariables>
<qwen.mysql.forkCount>${mysql.integration.forks}</qwen.mysql.forkCount>
<!-- A literal prefix preserves the late fork placeholder through Maven interpolation. -->
<qwen.mysql.forkNumber>0${surefire.forkNumber}</qwen.mysql.forkNumber>
</systemPropertyVariables>
<properties>
<configurationParameters>
junit.jupiter.execution.parallel.enabled = false
</configurationParameters>
</properties>
</configuration>
<executions>
<execution>

View file

@ -0,0 +1,24 @@
package com.alibaba.qwen.code.managedagent;
import org.junit.platform.launcher.LauncherSession;
import org.junit.platform.launcher.LauncherSessionListener;
public final class MySqlForkRoutingListener implements LauncherSessionListener {
@Override
public void launcherSessionOpened(LauncherSession session) {
int forks = Integer.parseInt(System.getProperty("qwen.mysql.forkCount", "1"));
if (forks <= 1) {
return;
}
int fork = Integer.parseInt(System.getProperty("qwen.mysql.forkNumber", "0"));
if (fork < 1 || fork > forks) {
throw new IllegalArgumentException("qwen.mysql.forkNumber must be between 1 and " + forks);
}
String property = "mysql.url." + fork;
String url = System.getProperty(property);
if (url == null || !url.startsWith("jdbc:mysql://")) {
throw new IllegalArgumentException(property + " must identify a disposable MySQL database");
}
System.setProperty("mysql.url", url);
}
}

View file

@ -0,0 +1,159 @@
package com.alibaba.qwen.code.managedagent;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
import org.junit.platform.launcher.core.LauncherFactory;
import org.junit.platform.launcher.listeners.SummaryGeneratingListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass;
class MySqlForkRoutingListenerTest {
private static final List<String> PROPERTIES = List.of("qwen.mysql.forkCount", "qwen.mysql.forkNumber",
"mysql.url", "mysql.url.1", "mysql.url.2");
private final Map<String, String> originalProperties = new HashMap<>();
@BeforeEach
void saveProperties() {
for (String property : PROPERTIES) {
originalProperties.put(property, System.getProperty(property));
System.clearProperty(property);
}
System.setProperty("mysql.url", "jdbc:mysql://legacy/disposable");
}
@AfterEach
void restoreProperties() {
originalProperties.forEach((property, value) -> {
if (value == null) {
System.clearProperty(property);
} else {
System.setProperty(property, value);
}
});
}
@ParameterizedTest
@NullSource
@ValueSource(strings = "1")
void leavesOrdinaryAndSingleForkLaunchersUnchanged(String forks) {
if (forks != null) {
System.setProperty("qwen.mysql.forkCount", forks);
}
try (var session = LauncherFactory.openSession()) {
assertThat(session.getLauncher()).isNotNull();
assertThat(System.getProperty("mysql.url")).isEqualTo("jdbc:mysql://legacy/disposable");
}
}
@ParameterizedTest
@ValueSource(ints = {1, 2})
void routesBeforePerClassConstructionAndSpringInitialization(int fork) {
System.setProperty("qwen.mysql.forkCount", "2");
System.setProperty("qwen.mysql.forkNumber", "0" + fork);
System.setProperty("mysql.url.1", "jdbc:mysql://127.0.0.1:33061/first");
System.setProperty("mysql.url.2", "jdbc:mysql://127.0.0.1:33062/second");
try (var session = LauncherFactory.openSession()) {
assertThat(System.getProperty("mysql.url")).isEqualTo(selectedUrl());
var request = LauncherDiscoveryRequestBuilder.request().selectors(selectClass(SpringProbe.class)).build();
var summary = new SummaryGeneratingListener();
session.getLauncher().execute(request, summary);
assertThat(summary.getSummary().getFailures()).isEmpty();
assertThat(summary.getSummary().getTestsSucceededCount()).isEqualTo(2);
}
}
@Test
void refusesAMissingMappingInsteadOfUsingTheLegacyUrl() {
System.setProperty("qwen.mysql.forkCount", "2");
System.setProperty("qwen.mysql.forkNumber", "2");
System.setProperty("mysql.url.1", "jdbc:mysql://127.0.0.1:33061/first");
assertThatThrownBy(LauncherFactory::openSession).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("mysql.url.2");
assertThat(System.getProperty("mysql.url")).isEqualTo("jdbc:mysql://legacy/disposable");
}
@Test
void refusesANonMySqlMapping() {
System.setProperty("qwen.mysql.forkCount", "2");
System.setProperty("qwen.mysql.forkNumber", "1");
System.setProperty("mysql.url.1", "jdbc:h2:mem:shared");
assertThatThrownBy(LauncherFactory::openSession).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("mysql.url.1");
}
@Test
void refusesAForkOutsideTheConfiguredCount() {
System.setProperty("qwen.mysql.forkCount", "2");
System.setProperty("qwen.mysql.forkNumber", "3");
assertThatThrownBy(LauncherFactory::openSession).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("qwen.mysql.forkNumber");
}
private static String selectedUrl() {
return System.getProperty("mysql.url." + Integer.parseInt(System.getProperty("qwen.mysql.forkNumber")));
}
@SpringJUnitConfig(ProbeConfiguration.class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
static class SpringProbe {
private final String constructorUrl = System.getProperty("mysql.url");
@Autowired private String routedUrl;
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
String url = System.getProperty("mysql.url");
registry.add("test.mysql.url", () -> url);
}
@BeforeAll
void verifyStartupOrder() {
assertThat(constructorUrl).isEqualTo(selectedUrl());
assertThat(routedUrl).isEqualTo(selectedUrl());
}
@Test
void usesTheSelectedDatabase() {
assertThat(routedUrl).isEqualTo(selectedUrl());
}
@Nested
class NestedProbe {
@Test
void keepsTheSelectedDatabase() {
assertThat(System.getProperty("mysql.url")).isEqualTo(selectedUrl());
assertThat(routedUrl).isEqualTo(selectedUrl());
}
}
}
@Configuration(proxyBeanMethods = false)
static class ProbeConfiguration {
@Bean
String routedUrl(Environment environment) {
return environment.getRequiredProperty("test.mysql.url");
}
}
}

View file

@ -1,11 +1,18 @@
package com.alibaba.qwen.code.managedagent.event;
import com.alibaba.qwen.code.managedagent.persistence.IsolatedManagedSchemaMySqlTestDatabase;
import org.junit.jupiter.api.AfterAll;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.context.TestContext;
import org.springframework.test.context.TestExecutionListeners;
import org.springframework.test.context.support.AbstractTestExecutionListener;
import org.springframework.test.context.support.DirtiesContextTestExecutionListener;
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
@TestExecutionListeners(listeners = EventTransportConsumerMySqlIT.OwnedDatabaseCleanup.class,
mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS)
class EventTransportConsumerMySqlIT extends EventTransportConsumerSqlTest {
private static IsolatedManagedSchemaMySqlTestDatabase database;
@ -19,10 +26,18 @@ class EventTransportConsumerMySqlIT extends EventTransportConsumerSqlTest {
registry.add("spring.datasource.driver-class-name", () -> "com.mysql.cj.jdbc.Driver");
}
@AfterAll
static void removeOwnedDatabase() {
if (database != null) {
database.close();
static class OwnedDatabaseCleanup extends AbstractTestExecutionListener {
@Override
public int getOrder() {
// After-class listeners run in reverse order: stop Spring before dropping its database.
return DirtiesContextTestExecutionListener.ORDER - 1;
}
@Override
public void afterTestClass(TestContext testContext) {
if (database != null) {
database.close();
}
}
}
}

View file

@ -1,118 +0,0 @@
package com.alibaba.qwen.code.managedagent.persistence;
import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaKeys;
import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaTime;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class ManagedSchemaLoadMySqlIT extends ManagedSchemaSessionMySqlIT {
@Test
void tenfoldHistoryKeepsDueClaimsAndSnapshotBodyReadsBounded() {
var session =
jdbc.queryForMap(
"SELECT tenant_id, session_id FROM ma_session WHERE session_kind ="
+ " 'PUBLIC'");
String tenant = (String) session.get("tenant_id");
String id = (String) session.get("session_id");
tx.executeWithoutResult(
status -> {
store.beginSessionRename(tenant, "load-rename", "digest", id, "renamed");
store.appendPublicEventIfAbsent(
tenant,
id,
"turn",
"item.output_text.delta",
Map.of("itemId", "item", "text", "snapshot"),
false,
UUID.randomUUID().toString());
store.materializeNextBatch(tenant, id, 100);
});
String resource =
jdbc.queryForObject(
"SELECT manifest_resource_key FROM ma_snapshot WHERE tenant_id = ? AND"
+ " session_id = ?",
String.class,
tenant,
id);
seedHistory(tenant, id, 0, 1000);
String due =
"SELECT tenant_id, session_id, operation_id FROM ma_operation WHERE operation_kind"
+ " = 'RENAME' AND delivery_state = 'PENDING' AND state = 'PENDING' AND"
+ " available_at <= ? ORDER BY available_at LIMIT 100";
String body =
"SELECT inline_bytes, byte_length, sha256 FROM ma_resource WHERE resource_key = ?"
+ " AND state = 'REFERENCED' AND deletion_operation_key IS NULL";
var claims = scanned(due, Long.MAX_VALUE);
assertThat(claims.scannedRows()).isPositive();
var reads = scanned(body, resource);
seedHistory(tenant, id, 1000, 10_000);
assertThat(scanned(due, Long.MAX_VALUE).scannedRows()).isEqualTo(claims.scannedRows());
assertThat(scanned(body, resource).scannedRows()).isEqualTo(reads.scannedRows());
assertThat(store.deliverableRenames(Long.MAX_VALUE)).hasSize(1);
assertThat(store.findSnapshotPage(tenant, id, null, 0, 10).items()).hasSize(1);
}
private ExecutedQueryPlan.Plan scanned(String sql, Object argument) {
var plan = ExecutedQueryPlan.explain(jdbc, sql, argument);
assertThat(plan.tableScan()).as(plan.text()).isFalse();
assertThat(plan.scannedRows()).as(plan.text()).isBetween(0L, 10L);
assertThat(plan.preExecutionPageReads()).as(plan.text()).isBetween(0L, 10L);
assertThat(plan.scannedRows() > 0 || plan.preExecutionPageReads() > 0)
.as("Actual row or optimization-stage page reads: %s", plan.text()).isTrue();
return plan;
}
private void seedHistory(String tenant, String session, int first, int last) {
String scope = ManagedSessionStore.sessionScopeKey(tenant, session);
List<Object[]> operations = new ArrayList<>();
List<Object[]> resources = new ArrayList<>();
byte[] bytes = "history".getBytes(StandardCharsets.UTF_8);
String digest = WorkspaceEvidenceResources.digest(bytes);
for (int i = first; i < last; i++) {
String identity = "history-" + i;
operations.add(
new Object[] {
ManagedSchemaKeys.key(scope, identity),
scope,
scope,
identity,
tenant,
session,
ManagedSchemaKeys.key("SESSION", tenant, session),
"digest",
"{}",
1,
1
});
resources.add(
new Object[] {
ManagedSchemaKeys.key(scope, identity),
scope,
tenant,
session,
identity,
bytes.length,
digest,
bytes,
identity
});
}
jdbc.batchUpdate(
"INSERT INTO ma_operation (operation_key, scope_key, session_scope_key,"
+ " operation_id, operation_kind, tenant_id, session_id, target_kind,"
+ " target_key, request_digest, request_json, state, phase, delivery_state,"
+ " gmt_create, gmt_modified) VALUES (?, ?, ?, ?, 'RENAME', ?, ?, 'SESSION', ?,"
+ " ?, ?, 'COMPLETED', 'COMPLETED', 'COMPLETED', ?, ?)",
operations);
jdbc.batchUpdate(
"INSERT INTO ma_resource (resource_key, session_scope_key, tenant_id, session_id, resource_id, kind, schema_version, byte_length, sha256, storage_kind, inline_bytes, publish_command_id, state, gmt_create) VALUES (?, ?, ?, ?, ?, 'managed-context', 1, ?, ?, 'MYSQL_INLINE', ?, ?, 'REFERENCED', " + ManagedSchemaTime.SQL_NOW + ")",
resources);
}
}

View file

@ -1,9 +1,20 @@
package com.alibaba.qwen.code.managedagent.persistence;
import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaKeys;
import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaTime;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import javax.sql.DataSource;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
public class ManagedSchemaSessionMySqlIT extends ManagedSchemaSessionStoreTest {
private IsolatedManagedSchemaMySqlTestDatabase database;
@ -19,4 +30,107 @@ public class ManagedSchemaSessionMySqlIT extends ManagedSchemaSessionStoreTest {
database.close();
}
}
@Test
void tenfoldHistoryKeepsDueClaimsAndSnapshotBodyReadsBounded() {
var session =
jdbc.queryForMap(
"SELECT tenant_id, session_id FROM ma_session WHERE session_kind ="
+ " 'PUBLIC'");
String tenant = (String) session.get("tenant_id");
String id = (String) session.get("session_id");
tx.executeWithoutResult(
status -> {
store.beginSessionRename(tenant, "load-rename", "digest", id, "renamed");
store.appendPublicEventIfAbsent(
tenant,
id,
"turn",
"item.output_text.delta",
Map.of("itemId", "item", "text", "snapshot"),
false,
UUID.randomUUID().toString());
store.materializeNextBatch(tenant, id, 100);
});
String resource =
jdbc.queryForObject(
"SELECT manifest_resource_key FROM ma_snapshot WHERE tenant_id = ? AND"
+ " session_id = ?",
String.class,
tenant,
id);
seedHistory(tenant, id, 0, 1000);
String due =
"SELECT tenant_id, session_id, operation_id FROM ma_operation WHERE operation_kind"
+ " = 'RENAME' AND delivery_state = 'PENDING' AND state = 'PENDING' AND"
+ " available_at <= ? ORDER BY available_at LIMIT 100";
String body =
"SELECT inline_bytes, byte_length, sha256 FROM ma_resource WHERE resource_key = ?"
+ " AND state = 'REFERENCED' AND deletion_operation_key IS NULL";
var claims = scanned(due, Long.MAX_VALUE);
assertThat(claims.scannedRows()).isPositive();
var reads = scanned(body, resource);
seedHistory(tenant, id, 1000, 10_000);
assertThat(scanned(due, Long.MAX_VALUE).scannedRows()).isEqualTo(claims.scannedRows());
assertThat(scanned(body, resource).scannedRows()).isEqualTo(reads.scannedRows());
assertThat(store.deliverableRenames(Long.MAX_VALUE)).hasSize(1);
assertThat(store.findSnapshotPage(tenant, id, null, 0, 10).items()).hasSize(1);
}
private ExecutedQueryPlan.Plan scanned(String sql, Object argument) {
var plan = ExecutedQueryPlan.explain(jdbc, sql, argument);
assertThat(plan.tableScan()).as(plan.text()).isFalse();
assertThat(plan.scannedRows()).as(plan.text()).isBetween(0L, 10L);
assertThat(plan.preExecutionPageReads()).as(plan.text()).isBetween(0L, 10L);
assertThat(plan.scannedRows() > 0 || plan.preExecutionPageReads() > 0)
.as("Actual row or optimization-stage page reads: %s", plan.text()).isTrue();
return plan;
}
private void seedHistory(String tenant, String session, int first, int last) {
String scope = ManagedSessionStore.sessionScopeKey(tenant, session);
List<Object[]> operations = new ArrayList<>();
List<Object[]> resources = new ArrayList<>();
byte[] bytes = "history".getBytes(StandardCharsets.UTF_8);
String digest = WorkspaceEvidenceResources.digest(bytes);
for (int i = first; i < last; i++) {
String identity = "history-" + i;
operations.add(
new Object[] {
ManagedSchemaKeys.key(scope, identity),
scope,
scope,
identity,
tenant,
session,
ManagedSchemaKeys.key("SESSION", tenant, session),
"digest",
"{}",
1,
1
});
resources.add(
new Object[] {
ManagedSchemaKeys.key(scope, identity),
scope,
tenant,
session,
identity,
bytes.length,
digest,
bytes,
identity
});
}
jdbc.batchUpdate(
"INSERT INTO ma_operation (operation_key, scope_key, session_scope_key,"
+ " operation_id, operation_kind, tenant_id, session_id, target_kind,"
+ " target_key, request_digest, request_json, state, phase, delivery_state,"
+ " gmt_create, gmt_modified) VALUES (?, ?, ?, ?, 'RENAME', ?, ?, 'SESSION', ?,"
+ " ?, ?, 'COMPLETED', 'COMPLETED', 'COMPLETED', ?, ?)",
operations);
jdbc.batchUpdate(
"INSERT INTO ma_resource (resource_key, session_scope_key, tenant_id, session_id, resource_id, kind, schema_version, byte_length, sha256, storage_kind, inline_bytes, publish_command_id, state, gmt_create) VALUES (?, ?, ?, ?, ?, 'managed-context', 1, ?, ?, 'MYSQL_INLINE', ?, ?, 'REFERENCED', " + ManagedSchemaTime.SQL_NOW + ")",
resources);
}
}

View file

@ -20,7 +20,6 @@ class SshWorkspaceMySqlIT extends SshWorkspaceStoreTest {
assertThat(ManagedSchema.schemaVersion(source)).isEqualTo(3);
Flyway.configure().dataSource(source).locations(ManagedSchema.FLYWAY_LOCATION).load().migrate();
assertThat(ManagedSchema.schemaVersion(source)).isEqualTo(16);
ManagedSchema.validate(source);
return source;
}

View file

@ -47,14 +47,15 @@ class SshWorkspaceStoreTest {
@BeforeEach
void setup() {
var source = source();
ManagedSchema.initialize(source);
jdbc = new ManagedSchemaJdbcTemplate(source);
store = new SshWorkspaceStore(jdbc, new DataSourceTransactionManager(source));
}
javax.sql.DataSource source() {
return new DriverManagerDataSource("jdbc:h2:mem:ssh-" + UUID.randomUUID()
var source = new DriverManagerDataSource("jdbc:h2:mem:ssh-" + UUID.randomUUID()
+ ";MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", "sa", "");
ManagedSchema.initialize(source);
return source;
}
@Test

View file

@ -0,0 +1 @@
com.alibaba.qwen.code.managedagent.MySqlForkRoutingListener

View file

@ -378,6 +378,37 @@ describe('Hosted real-process gates', () => {
expect(profile(id), id).toMatch(skipsUnits);
}
expect(profile('mysql-integration')).not.toContain('maven-surefire-plugin');
const mysqlProfile = profile('mysql-integration');
expect(mysqlProfile).toContain(
'<mysql.integration.forks>1</mysql.integration.forks>',
);
const mysqlFailsafe = mysqlProfile
.split('<artifactId>maven-failsafe-plugin</artifactId>')[1]
.split('</plugin>')[0];
expect(mysqlFailsafe).toContain(
'<forkCount>${mysql.integration.forks}</forkCount>',
);
expect(mysqlFailsafe).toContain('<reuseForks>true</reuseForks>');
expect(mysqlFailsafe).toContain(
'junit.jupiter.execution.parallel.enabled = false',
);
const routingProperties = mysqlFailsafe
.split('<systemPropertyVariables>')[1]
.split('</systemPropertyVariables>')[0];
expect(routingProperties).toContain(
'<qwen.mysql.forkCount>${mysql.integration.forks}</qwen.mysql.forkCount>',
);
expect(routingProperties).toContain(
'<qwen.mysql.forkNumber>0${surefire.forkNumber}</qwen.mysql.forkNumber>',
);
expect(pom.match(/<qwen\.mysql\.forkCount>/g)).toHaveLength(1);
expect(pom.match(/<qwen\.mysql\.forkNumber>/g)).toHaveLength(1);
expect(
read(
'packages/managed-agent-server/src/test/resources/META-INF/services/org.junit.platform.launcher.LauncherSessionListener',
).trim(),
).toBe('com.alibaba.qwen.code.managedagent.MySqlForkRoutingListener');
expect(mysqlRun.run).not.toMatch(/-DforkCount\b/);
// The step stages the SDK with -DskipTests before the MySQL run; only
// the integration command itself must keep its tests.
const mysqlVerify = mysqlRun.run.slice(

View file

@ -802,6 +802,7 @@ describe('SDK Java MySQL lane on the ECS pool', () => {
// here would invite dropping that flag and dying on first connect.
expect(start.run).not.toContain('MYSQL_DATABASE');
expect(start.run).toContain("--env MYSQL_ROOT_PASSWORD='runtime-broker'");
expect(start.run).toContain('for fork in 1 2');
expect(start.run).toContain('for attempt in {1..60}');
expect(start.run).toContain('sleep 2');
// MariaDB ships healthcheck.sh; MySQL does not. Each leg must wait on
@ -816,25 +817,34 @@ describe('SDK Java MySQL lane on the ECS pool', () => {
'mysqladmin ping --host=127.0.0.1 --user=root --password=runtime-broker --silent',
);
expect(start.run).toContain(
'echo "MYSQL_PORT=${MYSQL_PORT}" >> "${GITHUB_ENV}"',
'echo "MYSQL_PORT_${fork}=${port}" >> "${GITHUB_ENV}"',
);
expect(configure.env?.MYSQL_CONTAINER).toBeUndefined();
expect(configure.run).toContain('docker exec "${MYSQL_CONTAINER}" sh -c');
expect(configure.run).toContain('for fork in 1 2');
expect(configure.run).toContain(
'docker exec "${MYSQL_CONTAINER}-${fork}" sh -c',
);
expect(configure.run).toContain(
'MYSQL_PWD="${MYSQL_ROOT_PASSWORD}" \'"${DATABASE_CLIENT}"\'',
);
expect(configure.run).toContain('SET GLOBAL max_allowed_packet=134217728');
expect(verify.env.MYSQL_PORT).toBeUndefined();
expect(verify.run).toContain(
'-Dmysql.url="jdbc:mysql://127.0.0.1:${MYSQL_PORT}/managed_agent_test?createDatabaseIfNotExist=true&allowPublicKeyRetrieval=true&useSSL=false"',
'-Dmysql.url="jdbc:mysql://127.0.0.1:${MYSQL_PORT_1}/managed_agent_test?createDatabaseIfNotExist=true&allowPublicKeyRetrieval=true&useSSL=false"',
);
expect(verify.run).toContain('-Dmysql.integration.forks=2');
for (const fork of [1, 2]) {
expect(verify.run).toContain(
`-Dmysql.url.${fork}="jdbc:mysql://127.0.0.1:\${MYSQL_PORT_${fork}}/managed_agent_test?createDatabaseIfNotExist=true&allowPublicKeyRetrieval=true&useSSL=false"`,
);
}
expect(mysql.steps.indexOf(start)).toBeLessThan(
mysql.steps.indexOf(configure),
);
expect(mysql.steps.indexOf(configure)).toBeLessThan(
mysql.steps.indexOf(verify),
);
expect(cleanup.name).toBe('Remove the database container');
expect(cleanup.name).toBe('Remove the database containers');
// The shell probe below cannot establish Actions cancellation scheduling.
expect(cleanup.if).toBe('${{ always() }}');
expect(cleanup.shell).toBe('bash');
@ -861,7 +871,9 @@ describe('SDK Java MySQL lane on the ECS pool', () => {
.skipIf(process.platform === 'win32')
.each(
databaseLegs.flatMap((leg) =>
['ready', 'dead', 'timeout'].map((mode) => ({ ...leg, mode })),
['ready', 'dead', 'timeout', 'dead-second', 'timeout-second'].map(
(mode) => ({ ...leg, mode }),
),
),
)(
'executes $database startup and consumers with a $mode container',
@ -899,11 +911,11 @@ describe('SDK Java MySQL lane on the ECS pool', () => {
printf 'DOCKER<%s>\\n' "$*" >> "$TRACE"
printf 'ARGC<%s>\\n' "$#" >> "$TRACE"
case "$1" in
run) return 0 ;;
run) attempts=0; return 0 ;;
exec)
if [[ "$3" == "$READINESS" ]]; then
attempts=$((attempts + 1))
if [[ "$MODE" == ready && "$attempts" -eq 3 ]]; then
if [[ ( "$MODE" == ready || ( "$MODE" == *-second && "$2" == *-1 ) ) && "$attempts" -eq 3 ]]; then
return 0
fi
echo 'PROBE_SAYS_NO' >&2
@ -912,9 +924,9 @@ describe('SDK Java MySQL lane on the ECS pool', () => {
[[ "$3" == sh && "$5" == *'SET GLOBAL max_allowed_packet=134217728'* ]] ;;
inspect)
if [[ "$3" == *State.Running* ]]; then
[[ "$MODE" == dead ]] && echo false || echo true
[[ "$MODE" == dead || ( "$MODE" == dead-second && "$4" == *-2 ) ]] && echo false || echo true
else
echo 43219
[[ "$4" == *-1 ]] && echo 43219 || echo 43220
fi ;;
logs) echo MYSQL_FAILURE_LOG ;;
*) return 92 ;;
@ -950,7 +962,7 @@ describe('SDK Java MySQL lane on the ECS pool', () => {
expect(result.error).toBeUndefined();
const trace = readFileSync(join(dir, 'trace'), 'utf8');
expect(trace).toContain(
`run --detach --name ${container} --publish 127.0.0.1::3306 --mount type=tmpfs,destination=/var/lib/mysql --env MYSQL_ROOT_PASSWORD=runtime-broker ${image} ${engineArgs}`,
`run --detach --name ${container}-1 --publish 127.0.0.1::3306 --mount type=tmpfs,destination=/var/lib/mysql --env MYSQL_ROOT_PASSWORD=runtime-broker ${image} ${engineArgs}`,
);
// `"$*"` joins argv, so the trace above cannot tell N correctly split
// engine args from one argument containing spaces. The arg count can:
@ -958,16 +970,29 @@ describe('SDK Java MySQL lane on the ECS pool', () => {
// reaches that many. A joined element would report one fewer.
expect(trace).toContain(`ARGC<${11 + engineArgs.split(' ').length}>`);
// Each leg must wait on the readiness signal its own image ships.
expect(trace).toContain(`exec ${container} ${readiness}`);
expect(trace).toContain(`exec ${container}-1 ${readiness}`);
if (mode === 'ready' || mode.endsWith('-second')) {
expect(trace).toContain(
`run --detach --name ${container}-2 --publish 127.0.0.1::3306 --mount type=tmpfs,destination=/var/lib/mysql --env MYSQL_ROOT_PASSWORD=runtime-broker ${image} ${engineArgs}`,
);
expect(trace).toContain(`exec ${container}-2 ${readiness}`);
}
if (mode === 'ready') {
expect(result.status, result.stderr).toBe(0);
expect(readFileSync(join(dir, 'env'), 'utf8')).toBe(
'MYSQL_PORT=43219\n',
'MYSQL_PORT_1=43219\nMYSQL_PORT_2=43220\n',
);
expect(trace.match(/SLEEP<2>/g)).toHaveLength(2);
expect(trace.match(/SLEEP<2>/g)).toHaveLength(4);
expect(result.stdout).toContain(
'MAVEN<-Dmysql.url=jdbc:mysql://127.0.0.1:43219/managed_agent_test?createDatabaseIfNotExist=true&allowPublicKeyRetrieval=true&useSSL=false>',
);
expect(result.stdout).toContain('MAVEN<-Dmysql.integration.forks=2>');
for (const fork of [1, 2]) {
expect(result.stdout).toContain(
`MAVEN<-Dmysql.url.${fork}=jdbc:mysql://127.0.0.1:${43218 + fork}/managed_agent_test?createDatabaseIfNotExist=true&allowPublicKeyRetrieval=true&useSSL=false>`,
);
expect(trace).toContain(`exec ${container}-${fork} sh -c`);
}
expect(result.stdout).not.toContain('MYSQL_FAILURE_LOG');
expect(result.stdout).not.toContain('PROBE_SAYS_NO');
} else {
@ -976,12 +1001,19 @@ describe('SDK Java MySQL lane on the ECS pool', () => {
// The probe's own output must reach the log: a broken probe is
// otherwise indistinguishable from a server still starting.
expect(result.stdout).toContain('PROBE_SAYS_NO');
expect(existsSync(join(dir, 'env'))).toBe(false);
const second = mode.endsWith('-second');
if (second) {
expect(readFileSync(join(dir, 'env'), 'utf8')).toBe(
'MYSQL_PORT_1=43219\n',
);
} else {
expect(existsSync(join(dir, 'env'))).toBe(false);
}
expect(
trace.match(new RegExp(`exec \\S+ ${readiness}`, 'g')),
).toHaveLength(mode === 'dead' ? 1 : 60);
).toHaveLength((second ? 3 : 0) + (mode.startsWith('dead') ? 1 : 60));
expect(trace.match(/SLEEP<2>/g) ?? []).toHaveLength(
mode === 'dead' ? 0 : 59,
(second ? 2 : 0) + (mode.startsWith('dead') ? 0 : 59),
);
expect(result.stdout).not.toContain('MAVEN<');
}
@ -993,49 +1025,57 @@ describe('SDK Java MySQL lane on the ECS pool', () => {
it
.skipIf(process.platform === 'win32')
.each(['exists', 'missing', 'removal-fails'])(
'executes owned MySQL cleanup when the container %s',
(mode) => {
const cleanup = parse(workflow).jobs['mysql-integration'].steps.at(-1);
const result = spawnSync(
'bash',
[
'-e',
'-c',
`
.each([
'exists',
'missing',
'removal-fails',
'first-missing',
'first-removal-fails',
])('executes owned MySQL cleanup when the container %s', (mode) => {
const cleanup = parse(workflow).jobs['mysql-integration'].steps.at(-1);
const result = spawnSync(
'bash',
[
'-e',
'-c',
`
docker() {
printf 'DOCKER<%s>\\n' "$*"
if [[ "$1" == inspect ]]; then [[ "$MODE" != missing ]];
elif [[ "$1" == rm ]]; then [[ "$MODE" != removal-fails ]];
if [[ "$1" == inspect ]]; then [[ "$MODE" != missing && ! ( "$MODE" == first-missing && "$2" == *-1 ) ]];
elif [[ "$1" == rm ]]; then [[ "$MODE" != removal-fails && ! ( "$MODE" == first-removal-fails && "$3" == *-1 ) ]];
else return 92; fi
}
${cleanup.run}
`,
],
{
encoding: 'utf8',
timeout: 5000,
env: {
...process.env,
MODE: mode,
MYSQL_CONTAINER: 'qwen-code-sdk-java-mysql-123-2',
},
],
{
encoding: 'utf8',
timeout: 5000,
env: {
...process.env,
MODE: mode,
MYSQL_CONTAINER: 'qwen-code-sdk-java-mysql-123-2',
},
},
);
expect(result.error).toBeUndefined();
expect(result.status, result.stderr).toBe(0);
for (const fork of [1, 2]) {
expect(
result.stdout.includes(
`DOCKER<rm --force qwen-code-sdk-java-mysql-123-2-${fork}>`,
),
).toBe(mode !== 'missing' && !(mode === 'first-missing' && fork === 1));
expect(
result.stdout.includes(
`::warning::could not remove qwen-code-sdk-java-mysql-123-2-${fork}; the host cleanup service reaps it within ~48h`,
),
).toBe(
mode === 'removal-fails' ||
(mode === 'first-removal-fails' && fork === 1),
);
expect(result.error).toBeUndefined();
expect(result.status, result.stderr).toBe(0);
expect(
result.stdout.includes(
'DOCKER<rm --force qwen-code-sdk-java-mysql-123-2>',
),
).toBe(mode !== 'missing');
expect(
result.stdout.includes(
'::warning::could not remove qwen-code-sdk-java-mysql-123-2; the host cleanup service reaps it within ~48h',
),
).toBe(mode === 'removal-fails');
},
);
}
});
it('holds the per-host sdk-java lock once around both Maven runs', () => {
// Derived from the parsed steps, not a string count: the install and