ClickHouse 关联测试选择器深入解析:基于逐测试行级覆盖率的 find_tests.py 架构与实现
【免费下载链接】ClickHouseClickHouse® is a real-time analytics database management system项目地址: https://gitcode.com/GitHub_Trending/cli/ClickHouse
导读
在 ClickHouse 的 CI 体系中,ci/jobs/scripts/find_tests.py扮演着“定向测试选择器”的角色:它对每个 PR 的 diff 做行级分析,再通过查询记录“每个测试覆盖了哪些源码行”的覆盖率数据库(CIDB),找出最可能回归该改动的高价值 stateless 测试。本文从 关联测试选择开发指南 出发,梳理 NightlyCoverage 数据流水线、CIDB 表结构、find_tests.py 六阶段选择算法与评分公式、本地运行方式、已知局限与质量度量,并对照 find_tests.py、export_coverage.py、coverage.cpp、CoverageCollection.cpp 等源码给出实现级印证。读完本文,你将理解这套系统如何从“每个测试跑到过哪些代码行”推导出“哪个测试最可能挡住本次回归”,并能本地复跑该选择器、核查覆盖率数据的时效性。
一、系统定位与总体架构
1.1 它解决什么问题
ClickHouse 的 PR 往往只改动src/、programs/等目录中的若干 C++ 文件,而回归测试全量执行成本极高。与其跑完整套 stateless 测试,不如利用历史运行中积累的逐测试覆盖率反查:如果某个测试曾经覆盖过本次改动的源码行,那么它极有可能在本次改动后回归。
这一选择逻辑全部集中在Targeting类(find_tests.py 中定义),由get_all_relevant_tests_with_info()作为统一入口输出最终的关联测试清单。
1.2 两条关键前提
从源码结构与文档可以提炼出两个核心设计前提:
- 覆盖数据必须按“单个测试”粒度收集。默认的 LLVM 覆盖率计数是进程级累加的,只有做到“跑一个测试 → 记录 → 清零”,才能回答“这个测试覆盖了哪些行”。为此 CI 在服务端逐测试执行
SYSTEM SET COVERAGE TEST 'test_name'(测试前)与SYSTEM SET COVERAGE TEST ''(测试后冲洗并重置计数器),见 tests/clickhouse-test 与 CoverageCollection.cpp 中的system.coverage_log/system.coverage_indirect_calls写入逻辑。 - 覆盖行与 diff 行必须可比对。源码构建时使用
-ffile-prefix-map=/ClickHouse=.,CIDB 中路径统一存为./src/...;find_tests.py 把 diff 中的路径规整后再补回./前缀(见find_tests.py的_strip_path_prefix/路径归一化逻辑),从而保证 join 正确。
1.3 数据流全景
文档给出的端到端数据流如下,夜间的 NightlyCoverage 任务是数据源头:
NightlyCoverage CI job(UTC 02:13 每日运行) └─ 构建 amd_per_test_coverage 二进制 (WITH_COVERAGE=ON -DWITH_COVERAGE_DEPTH=ON -finstrument-functions-after-inlining) └─ 运行 stateless 测试并携带 --long(clickhouse-test --collect-per-test-coverage) ├─ SYSTEM SET COVERAGE TEST 'test_name' (每个测试前) └─ SYSTEM SET COVERAGE TEST '' (测试后:flush + 重置计数器) └─ export_coverage.py(读取本地服务器表,写入 CIDB) ├─ system.coverage_log → checks_coverage_lines └─ system.coverage_indirect_calls → checks_coverage_indirect_calls这一流程可通过对源码的逐一印证得到确认:
- 工作流定义在 nightly_coverage.py,其中
cron_schedules=["13 2 * * *"](UTC 02:13),使用的构建任务是coverage_build_jobs[1],即带WITH_COVERAGE + depth instrumentation的Build (amd_llvm_coverage_per_test)。 - 逐测试覆盖收集开关为
--collect-per-test-coverage(clickhouse-test 命令行参数,见 tests/clickhouse-test),脚本会校验“启用收集时二进制必须以WITH_COVERAGE_DEPTH=ON构建”,不满足则给出明确报错。 - 端到端导出由 export_coverage.py 完成:它通过
remoteSecure(...)以INSERT INTO FUNCTION的方式把本地system.coverage_log与system.coverage_indirect_calls分别灌入远端 CIDB 的default.checks_coverage_lines与default.checks_coverage_indirect_calls表。
值得注意:长测试(--long)已被纳入覆盖收集(夜间运行不再传--no-long),因此00900_long_parquet、02340_parts_refcnt_mergetree这类长测试也会出现在覆盖率数据中。
二、CIDB:覆盖率数据库的表结构与访问方式
CIDB 是一个 ClickHouse 集群上的库,以只读play用户对外暴露查询。文档列出的三张核心表如下:
| 表 | 内容 | 用途 |
|---|---|---|
checks_coverage_lines | 每个测试的文件/行区间覆盖——主表 | find_tests 直接查询 |
checks_coverage_indirect_calls | 每个测试的虚函数/函数指针被调方偏移(callee offsets) | Pass 3 间接调用协同 |
checks_coverage_inverted | 旧的符号→测试倒排索引 | find_tests 不再使用 |
checks_coverage_lines的建表语义(文档 + 代码交叉印证):
file LowCardinality(String), line_start UInt32, line_end UInt32, check_start_time DateTime('UTC'), check_name LowCardinality(String), test_name LowCardinality(String), min_depth UInt8, branch_flag UInt8 -- ORDER BY (check_start_time, file, line_start, check_name, test_name) -- PARTITION BY toYYYYMM(check_start_time) -- Indexes: bloom_filter on file, minmax on line_start字段要点:
min_depth:该覆盖区间内最浅的调用深度(255 表示深度未跟踪,按“深调用”处理,见评分章节);branch_flag:覆盖类型标记;file列带 bloom_filter、line_start带 minmax 索引,服务于“按文件+行号快速命中区间”的查询形态;- 按月分区便于按保留窗口裁剪。
访问 CIDB 的示例
from ci.praktika.cidb import CIDB from ci.praktika.settings import Settings cidb = CIDB(url=Settings.CI_DB_READ_URL, user="play", passwd="") result = cidb.query("SELECT count() FROM checks_coverage_lines WHERE ...", log_level="")注意源码层面的一个细节:Targeting._ci_db()在 CI 生产环境中使用的是特权 CI 账号(从 secretSECRET_CI_DB_CONNECTION读取连接串,见 find_tests.py),因为play用户有限流与行数限制,仅适合生成供人类点击的统计链接。本地调试与文档中的“freshness check”才直接使用play。
三、find_tests.py 主流程:三层来源的合并
get_all_relevant_tests_with_info()把三类来源去重后按固定优先级并入结果列表(顺序即优先级):
get_all_relevant_tests_with_info() 1. get_changed_tests() — PR diff 中直接改动的测试文件(最高优先级) 2. get_previously_failed_tests() — 该 PR 在 CI 中近期失败过的测试 3. get_most_relevant_tests() — 基于覆盖率的六阶段选择(详见下一节)几点由源码确认的语义:
- changed/new tests:PR 自身新增或修改的测试文件一定入选。处理逻辑见
get_changed_or_new_tests_with_info():它把tests/queries/0_stateless/下改动文件映射为测试名,还会识别.reference、.tsv等“支撑文件”并回溯到同名测试源(例如.reference.j2更新会命中00172_hits_joins.sql.j2),同时避免把孤立的纯数据文件(如02995_settings_26_4_1.tsv)误判成测试。 - harness smoke tests:当 PR 改动的是测试框架本身(harness),例如 pull_request.yml、functional_tests.py、find_tests.py、clickhouse-test 等
_STATELESS_HARNESS_PATHS中的文件时,会注入STATELESS_HARNESS_SMOKE_TESTS(00001_select_1.、01109_exchange_tables.)等兜底测试,避免选择结果为空。 - coverage 阶段只对 stateless job 生效:代码中显式注释“TODO: Add coverage support for Integration tests”,即 integration 相关任务当前仅有 changed/previously-failed 两层来源,且 coverage 阶段异常会被捕获并以 best-effort 降级处理。
3.1 曾失败测试(Previously-failed)的召回逻辑
get_previously_failed_tests()对 CIDB 的checks表执行如下形态的查询(文档提供,与 find_tests.py 内嵌 SQL 一致):
SELECT test_name FROM checks WHERE pull_request_number = {PR_NUMBER} AND check_name LIKE '{JOB_TYPE}%' AND check_status = 'failure' AND test_status = 'FAIL' AND check_start_time >= now() - interval 30 day GROUP BY test_name ORDER BY count() * exp(-dateDiff('day', max(check_start_time), now()) / 7.) DESC LIMIT 100特征:
- 按近因加权失败次数降序排序,权重使用 7 天半衰期的指数衰减(
exp(-days/7)); - 30 天时间窗覆盖 PR 的完整开发周期;
- 上限 100 个测试;
- 支持的 job 类型模式:Stateless(测试名形如
^[0-9]{5}_)与 Integration(^test_)。
四、覆盖率选择算法:六个 pass + 评分 + 分层
这是整个系统的核心。get_most_relevant_tests()首先取得 PR 的 changed lines 与 hunk 边界,再对每个 changed line 汇总候选测试并打分排序。
4.1 Pass 概述(以文档骨架为准,数值以当前仓库源码为准)
| Pass | 名称 | 机制 | 权重(源码当前值) |
|---|---|---|---|
| 1 | 直接行覆盖get_tests_by_changed_lines | 用gh pr diff得到(file, line_no),过滤到src/、programs/、utils/、base/(COVERAGE_TRACKED_PREFIXES),查询checks_coverage_lines精确覆盖这些行的测试;跳过被 >MAX_TESTS_PER_LINE个测试覆盖的区间 | PASS_WEIGHT_DIRECT = 1.0 |
| 1b | Hunk 上下文 | 同一 hunk 内紧邻改动行的上下文行;权重低于直接命中但高于间接/同级 | PASS_WEIGHT_HUNK_CONTEXT = 0.50 |
| 2 | 同级目录_query_sibling_dir_tests | 对改动文件名做 CamelCase 拆分、去常见词得到领域关键词(如CHColumnToArrowColumn.cpp → ["Arrow"]、MergeTreeIndexConditionText.cpp → ["Index","Text"]),找覆盖同目录“兄弟文件”的测试,以及覆盖这些兄弟文件的其他测试;赋予合成宽度SIBLING_DIR_WIDTH = 3000使排名低于直接命中 | PASS_WEIGHT_SIBLING = 0.25 |
| 3 | 间接调用被调方协同_query_indirect_call_tests | 对checks_coverage_indirect_calls按callee_offset自连接,找与 primary 测试共享虚函数/函数指针被调方的测试;自适应 Jaccard 阈值max(1, 70 - (200 - min_seed_rc) × 0.5)%(特定文件低至 15%,宽文件高达 70%);自适应上限max(50, min(200, 200 × min_seed_rc / 40));在 ≥300 个测试中出现的被调方视为无处不在(日志、malloc)而排除 | PASS_WEIGHT_INDIRECT = 0.50 |
| 4 | Broad-tier2 | 通过很宽区间(rc 2001–8000)覆盖改动文件的测试;按cov_regions × files_covered排序(覆盖改动面更多的优先) | PASS_WEIGHT_BROAD2 = 0.40 |
| 5 | 稀疏文件扩展 | 当改动文件直接命中极少(最大 rc ≤ 8)时触发,取该文件全部窄区间(rc ≤MAX_TESTS_PER_LINE)为间接种子补充;SPARSE_FILE_WIDTH = 600 | PASS_WEIGHT_SPARSE_FILE = 0.30 |
| 兜底 | 关键词匹配 | 覆盖率结果极少/为空时,用改动文件领域关键词匹配测试文件名,取关键词特异性 Top-30 | PASS_WEIGHT_KEYWORD = 0.20 |
4.2 对几处源码关键差异的说明
对照 find_tests.py 中常量区(约 520–580 行),文档所列数值部分已过时,本文以仓库当前代码为准:
MAX_TESTS_PER_LINE当前为150(注释举例:SignalHandlers.cpp、Context.cpp、Settings.cpp这类被几乎所有测试触碰的基础设施文件,改动行没有诊断价值,必须跳过以免淹没primary_tests并触发 HTTP 表单过长错误);- 直接查询的 server 端 HAVING 上界
BROAD_REGION_HARD_CAP当前为3000;真正丢弃全知区间的上界VERY_BROAD_REGION_CAP为8000(rc 落在 3000–8000 区间属于 Pass 4 的 broad-tier2,>8000 被彻底排除); - 该文件顶部还有一批针对极端情况的常量,例如
SHARED_REGISTRY_FILES集合:ProfileEvents.cpp/h、CurrentMetrics.cpp/h、ErrorCodes.cpp/h、SettingsChanges.cpp、Settings.cpp、SettingsChangesHistory.*这类“共享注册表文件”的改动几乎总是新增枚举项,任何测试都会覆盖,因此从 coverage、sibling、indirect、keyword 各阶段整体跳过,真实信号寄托于同一 PR 改动的其他文件。
4.3 间接调用(indirect-call)收集的实现细节
文档专门辟出一节讲 LLVM value profiling 的坑,源码 coverage.cpp 与之完全对应:
- 构建期开启
-enable-value-profiling=true,LLVM 在每次虚调用/函数指针调用点把运行时被调方地址记录到ValueProfNode链表中(源码中定义了一个与 compiler-rt 布局一致的结构体ValueProfNode,含value、count、next字段); - 关键坑:
__llvm_profile_reset_counters()并不会重置这些节点的计数。若不处理,每个测试都会累积之前所有测试的间接调用; - 修复:
coverage.cpp在读取每个ValueProfNode后把node->count = 0,保证每个测试从零开始; callee_offset = callee_address - load_base对同一构建的二进制在 ASLR 重启之间保持稳定,从而可作为 join 键。
对应的 CIDB 自连接查询形态(节选):
SELECT DISTINCT ic2.test_name FROM checks_coverage_indirect_calls ic1 JOIN checks_coverage_indirect_calls ic2 ON ic1.callee_offset = ic2.callee_offset WHERE ic1.test_name IN ({primary_tests}) AND ic2.test_name NOT IN ({primary_tests}) AND (ic2.test_name, ic1.callee_offset) IN ( SELECT test_name, callee_offset FROM checks_coverage_indirect_calls GROUP BY test_name, callee_offset HAVING uniqExact(test_name) < 300 -- 排除无处不在的被调方 ) AND count(DISTINCT ic1.callee_offset) * 100.0 / ic2_tot.tot_callees >= {JACCARD_MIN_PCT} LIMIT {INDIRECT_LIMIT}4.4 评分公式与分层排序
每个测试的分数是“所有命中的改动行”上的累加:
score(test) = Σ(所有匹配到的改动行上)pass_weight / (region_width × region_test_count)信号语义(源码注释中表述为 four signals):
pass_weight:按 pass 来源乘折扣,保证即使最弱的直接命中也能压过最强的间接/兄弟命中(1.0 > 0.5 = 0.5 > 0.40 > 0.30 > 0.25 > 0.20);region_width:覆盖区间越窄越精确,取倒数加权;region_test_count:覆盖该区间的测试越少越特异,取倒数加权;min_depth:测试触达该路径的最浅调用深度——浅意味着该测试直接走通了这条路径。
排序前先套用**分层(tier)**规则:
| 层 | 条件 |
|---|---|
| A(最佳) | 窄区间(width ≤ NARROW_REGION_MAX_LINES = 40)且浅调用(depth ≤ DIRECT_CALL_MAX_DEPTH) |
| B | 窄区间但深调用链 |
| C | 仅宽区间命中 |
每层内部按width_score = Σ 1/region_width排序(奖励通过窄区间覆盖更多改动行的测试)。其中 255 = 深度未跟踪,按深调用(B 层)处理。
输出过滤:
MIN_SCORE = 1e-8—— 绝对地板;MAX_SCORE_RATIO = 3000—— 丢弃分数低于最高直接命中 1/3000 的测试;effective_min = min(1e-6, max(1e-8, top_score / 3000));MAX_OUTPUT_TESTS = 300—— 最终输出硬上限。
关键词补充 pass 的细节:即使某个 C++ 文件已有直接覆盖率命中,仍会对它运行补充关键词匹配(例如CHColumnToArrowColumn.cpp的改动会命中01273_arrow.sh这类通过高层调用链回归同一领域、行覆盖抓不到的宽回归测试),并注入_keyword_guarantee保证其在排序后仍留在输出中。这些测试因PASS_WEIGHT_KEYWORD最低,只排在所有覆盖率命中之后。
五、已知局限(文档)
设计者明确记录了下述退化场景,理解它们有助于判断选择结果的可靠边界:
| 改动类型 | 结果 | 原因 |
|---|---|---|
声明处的constexpr/static const | 0 个测试 | 编译期常量,无运行时计数器 |
超宽基础设施文件(IMergeTreeDataPart、Context) | 被截断 | rc > 8000 被排除;MAX_OUTPUT=300会裁掉大量候选 |
| 小众 C++(LDAP、CLI、crash handlers 等) | 0 个或仅关键词命中 | stateless 套件根本覆盖不到这些文件 |
| PR 年龄 > 30 天 | 质量下降 | CIDB 保留窗口限制 |
| 长测试 | 已覆盖 | 逐测试覆盖运行已去掉--no-long |
| 同 PR 新增测试 | 正确检出 | 由get_changed_tests()直接拾取(此时尚未进入 CIDB) |
另外,共享注册表文件(ProfileEvents/CurrentMetrics/ErrorCodes/Settings 系列)因几乎被每个测试触碰,在四个 pass 中都被整体跳过。
六、质量度量与漏检归因(截至 2026-03-30,100 个 PR)
针对 2026-03-25 至 2026-03-28 合并、含src/改动的 100 个 PR,与旧版基于 DWARF 符号的目标运行对照,结果如下:
| 指标 | 值 |
|---|---|
| 相对旧算法的平均召回 | 57.8% |
| 中位召回 | 65.2% |
| 加权召回 | 49.2% |
| 完美召回(100%) | 10/28(有 targeted 数据的 PR) |
| 小 PR(旧算法 ≤10 个测试) | 平均 68.9% |
| 大 PR(旧算法 >10 个测试) | 平均 45.0% |
漏检根因分解:
- ~40% 为 previously-failed 类测试(只在 PR 活跃开发期出现,合并后不可复现);
- ~25% 来自 rc > 8000 的基础设施文件(被
VERY_BROAD_REGION_CAP排除); - ~15% 被
MAX_OUTPUT=300上限裁掉(已排序但被截断); - ~14% 实为旧算法 DWARF 假阳性(旧算法经内联链找到了错误测试,新算法跳过是正确行为);
- ~2% 为同 PR 新增测试文件(尚未入库);
- ~4% 为其他真实漏检。
同时文档强调:81% 的“漏检测试”(333/411 已核对对)确实存在于checks_coverage_lines中改动文件的记录里——新算法对宽文件找到的是“不同但同样有效的子集”,并非完全丢失。对比方法学细节参见 compare_find_tests_algos.md。
七、本地运行 find_tests.py
在仓库根目录下执行(需要ci/可被PYTHONPATH找到并具备 CIDB 访问权限):
cd /path/to/ClickHouse # 为某个 PR 获取关联测试 PYTHONPATH=./ci:. python3 ci/jobs/scripts/find_tests.py <PR_NUMBER> # 跳过 previously-failed 阶段(更快,仅覆盖率) PYTHONPATH=./ci:. python3 ci/jobs/scripts/find_tests.py <PR_NUMBER> --coverage-only # 使用预拉取的 diff(规避 GitHub API 限流) gh pr diff <PR_NUMBER> > /tmp/pr.diff PYTHONPATH=./ci:. python3 ci/jobs/scripts/find_tests.py <PR_NUMBER> --diff-file /tmp/pr.diff命令行入口定义于 find_tests.py 的__main__块:提供--coverage-only(只走get_most_relevant_tests(),省一次 GitHub API 调用,适合评测)与--diff-file(注入_diff_text,让get_changed_lines_from_diff与覆盖率阶段都从本地 diff 读取)。运行形态是“本地模式”,job 名固定为Stateless。
典型输出示例:
[find_tests] sibling-dir query: 0.15s, response=175 bytes [find_tests] sibling-dir: 4 additional test candidates [find_tests] indirect-call query: 1.88s, 200 additional test candidates (top jaccard=100%) [find_tests] done in 2.66s: 6/6 lines matched, 275 unique tests selected All selected tests (275): 00900_long_parquet.sh ... Found 275 relevant tests八、NightlyCoverage 工作流与手动触发
工作流定义在 nightly_coverage.py,cron 为13 2 * * *(UTC 每日 02:13)在 master 上运行,使用coverage_build_jobs[1]=Build (amd_llvm_coverage_per_test),构建参数为WITH_COVERAGE=ON -DWITH_COVERAGE_DEPTH=ON -finstrument-functions-after-inlining。长测试已纳入覆盖收集(functional_tests.py中已从逐测试覆盖运行移除--no-long),因此00900_long_parquet、02340_parts_refcnt_mergetree等长测试会出现在checks_coverage_lines。
在分支上手动触发:
gh workflow run NightlyCoverage --repo ClickHouse/ClickHouse --ref <branch> gh run list --repo ClickHouse/ClickHouse --workflow=NightlyCoverage --limit 5九、运行时与导出链路的关键文件速查
| 文件 | 职责 |
|---|---|
| find_tests.py | 主算法,Targeting类 |
| functional_tests.py | 运行测试;调用get_all_relevant_tests_with_info() |
| export_coverage.py | 本地覆盖表导出到 CIDB |
| nightly_coverage.py | NightlyCoverage 工作流定义 |
| coverage.cpp | 运行时:读取 LLVM profile 数据、按测试重置、收集间接调用 |
| CoverageCollection.cpp | 服务端:计数器映射到源码区间并写入 system 表 |
| LLVMCoverageMapping.cpp | 启动时解析 ELF 的__llvm_covmap/__llvm_covfun段 |
| clickhouse-test | 创建system.coverage_log与system.coverage_indirect_calls表,封装逐测试采集开关 |
服务端写入细节值得展开:CoverageCollection.cpp在收到SYSTEM SET COVERAGE TEST '<name>'后,把当前计数器的区间结果以INSERT INTO system.coverage_log (time, test_name, file, line_start, line_end, min_depth, branch_flag) VALUES ...落库,并把间接调用观测写入system.coverage_indirect_calls;插入显式关闭async_insert(async_insert=0),保证每个测试的覆盖记录可被立即查询。
十、CIDB 数据新鲜度自查
选择器的质量直接取决于夜间数据是否及时入库。可用如下脚本核查(play用户,仅限人工查阅):
PYTHONPATH=./ci:. python3 - << 'EOF' from ci.praktika.cidb import CIDB from ci.praktika.settings import Settings cidb = CIDB(url=Settings.CI_DB_READ_URL, user="play", passwd="") # 检查各 check 的新鲜度与长测试覆盖 print(cidb.query(""" SELECT check_name, max(toDate(check_start_time)) AS last_run, uniqExact(test_name) AS tests, count() AS rows FROM checks_coverage_lines WHERE check_start_time > now() - interval 7 days GROUP BY check_name ORDER BY last_run DESC LIMIT 10 """, log_level="")) # 验证某个长测试确实覆盖了指定文件 print(cidb.query(""" SELECT test_name, count() AS regions FROM checks_coverage_lines WHERE file = './src/Storages/MergeTree/MergeTreeRangeReader.cpp' AND check_start_time > now() - interval 7 day AND check_name LIKE 'Stateless%' AND test_name LIKE '02340%' GROUP BY test_name """, log_level="")) EOF结语:从“跑全量”到“跑对量”
ClickHouse 的关联测试选择器把“哪个测试保护了这行代码”这一历史事实沉淀为可查询的覆盖数据,再以 diff 为输入、以“直接行覆盖 → hunk 上下文 → 兄弟目录 → 间接调用协同 → 宽区间 → 稀疏文件 → 关键词兜底”的多级策略逼近最优子集,最后通过pass_weight/(width×rc)评分与 A/B/C 分层排序输出上限 300 个的定向测试清单。它并非完美——超宽基础设施文件、编译期改动与小众模块仍会退化——但结合质量度量的诚实归因、30 天窗口内此前失败的强召回,以及逐测试间接调用计数的正确重置,构成了 ClickHouse CI 面向海量回归测试的一种高性价比工程实践。若要深入调试或扩展该算法,入口都在ci/jobs/scripts/find_tests.py的Targeting类与ci/workflows/nightly_coverage.py的数据流水线中。
【免费下载链接】ClickHouseClickHouse® is a real-time analytics database management system项目地址: https://gitcode.com/GitHub_Trending/cli/ClickHouse
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考