diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 000000000..0893b368d --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,266 @@ +name: coverage +permissions: + contents: read + actions: write +on: + pull_request: + branches: [ "main" ] + workflow_dispatch: + inputs: + runs-on: + description: "Runner type" + type: choice + options: + - ubuntu-24.04-arm + - ubuntu-latest + - aliyun-ecs-x64 + default: ubuntu-latest + rebuildDiskCache: + description: "Rebuild disk cache" + type: boolean + default: false +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-${{ inputs.runs-on || vars.COVERAGE_RUNS_ON || 'ubuntu-latest' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} +jobs: + coverage-github: + outputs: + coverage-artifact-url: ${{ steps.upload_coverage_artifacts.outputs.artifact-url }} + if: ${{ (inputs.runs-on || vars.COVERAGE_RUNS_ON || 'ubuntu-latest') == 'ubuntu-latest' || (inputs.runs-on || vars.COVERAGE_RUNS_ON || 'ubuntu-latest') == 'ubuntu-24.04-arm' }} + name: coverage + runs-on: ${{ inputs.runs-on || vars.COVERAGE_RUNS_ON || 'ubuntu-latest' }} + container: + image: ghcr.io/alibaba/tair-kvcache-kvcm-dev:2026_02_13_12_03_24230b1 + volumes: + - /:/host_root/ + options: --privileged + steps: + # https://github.com/actions/runner-images/issues/2840 + - name: Free up disk space + run: | + echo "Disk space before cleanup:" + df -h + rm -rf /host_root/usr/share/dotnet + rm -rf /host_root/usr/local/lib/android + rm -rf /host_root/opt/ghc + echo "Disk space after cleanup:" + df -h + + - &checkout_step + name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - &setup_bazel_step + name: setup_bazel + uses: bazel-contrib/setup-bazel@0.18.0 + with: + bazelisk-cache: true + disk-cache: ${{ runner.os }}-${{ github.workflow }} + repository-cache: true + cache-save: ${{ inputs.rebuildDiskCache || false }} + + - &clean_disk_cache_step + name: clean_disk_cache + if: ${{ inputs.rebuildDiskCache || false }} + run: | + rm -rf ~/.cache/bazel-disk + + - &install_coverage_tools_step + name: install_coverage_tools + run: | + set -e + set -x + if ! command -v genhtml >/dev/null 2>&1; then + if command -v apt-get >/dev/null 2>&1; then + apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq lcov + elif command -v yum >/dev/null 2>&1; then + yum install -y -q lcov + else + echo "No supported package manager found for installing lcov/genhtml" >&2 + exit 1 + fi + fi + genhtml --version + + - &bazel_coverage_step + name: bazel_coverage + env: + COVERAGE_BASE_REF: ${{ github.event.pull_request.base.sha || 'origin/main' }} + COVERAGE_XTRACE: "1" + run: | + set -e + tools/coverage/run_coverage.sh \ + --base-ref "${COVERAGE_BASE_REF:-origin/main}" \ + --head-ref HEAD \ + --output-dir coverage \ + --include-prefix kv_cache_manager/ \ + --jobs 8 \ + --local-test-jobs 8 \ + --test-timeout 900 \ + --test-output errors \ + --fetch-main \ + -- \ + //kv_cache_manager/... \ + //integration_test/... + + - &delete_old_disk_cache_step + name: delete_old_disk_cache + if: ${{ inputs.rebuildDiskCache || false }} + env: + GH_TOKEN: ${{ github.token }} + run: | + set -x + ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') + curl -sLo /usr/local/bin/jq "https://github.com/jqlang/jq/releases/download/jq-1.8.1/jq-linux-${ARCH}" && chmod +x /usr/local/bin/jq + GH_VER=2.89.0 + curl -sL "https://github.com/cli/cli/releases/download/v${GH_VER}/gh_${GH_VER}_linux_${ARCH}.tar.gz" | tar xz --strip-components=1 -C /usr/local + RUNNER_ARCH_LOWER=$(echo "${{ runner.arch }}" | tr '[:upper:]' '[:lower:]') + DISK_CACHE_NAME="disk-${{ runner.os }}-${{ github.workflow }}" + gh cache list --repo ${{ github.repository }} --ref "${{ github.ref }}" --json id,key --limit 100 | \ + jq -r --arg arch "$RUNNER_ARCH_LOWER" --arg name "$DISK_CACHE_NAME" \ + '.[] | select((.key | contains($arch)) and (.key | contains($name))) | .id' | \ + xargs -I {} gh cache delete {} --repo ${{ github.repository }} || true + + - &append_coverage_summary_step + name: Append coverage summary + if: ${{ always() && hashFiles('coverage/coverage-summary.md') != '' }} + run: | + cat coverage/coverage-summary.md >> "${GITHUB_STEP_SUMMARY}" + if [ -f coverage/html/index.html ]; then + { + echo "" + echo "HTML coverage report: \`coverage/html/index.html\` in the uploaded artifact." + } >> "${GITHUB_STEP_SUMMARY}" + fi + + - &upload_coverage_artifacts_step + name: Upload coverage artifacts + id: upload_coverage_artifacts + uses: actions/upload-artifact@v6 + if: always() + with: + name: coverage-report + path: | + coverage/** + bazel-testlogs/**/*.xml + if-no-files-found: warn + overwrite: true + + coverage-aliyun: + outputs: + coverage-artifact-url: ${{ steps.upload_coverage_artifacts.outputs.artifact-url }} + if: ${{ (inputs.runs-on || vars.COVERAGE_RUNS_ON || 'ubuntu-latest') == 'aliyun-ecs-x64' }} + name: coverage + runs-on: aliyun-ecs-x64 + container: + image: ghcr.io/alibaba/tair-kvcache-kvcm-dev:2026_02_13_12_03_24230b1 + options: --privileged + steps: + - *checkout_step + - *setup_bazel_step + - *clean_disk_cache_step + - *install_coverage_tools_step + - *bazel_coverage_step + - *delete_old_disk_cache_step + - *append_coverage_summary_step + - *upload_coverage_artifacts_step + + comment-pr-coverage: + if: ${{ always() && github.event_name == 'pull_request' && (needs.coverage-github.outputs.coverage-artifact-url != '' || needs.coverage-aliyun.outputs.coverage-artifact-url != '') }} + needs: + - coverage-github + - coverage-aliyun + name: comment_pr_coverage + runs-on: ubuntu-latest + permissions: + actions: read + issues: write + steps: + - name: Download coverage artifact + uses: actions/download-artifact@v6 + continue-on-error: true + with: + name: coverage-report + path: coverage-artifact + + - name: Comment PR coverage + uses: actions/github-script@v8 + env: + COVERAGE_ARTIFACT_URL: ${{ needs.coverage-github.outputs.coverage-artifact-url || needs.coverage-aliyun.outputs.coverage-artifact-url }} + with: + script: | + const fs = require('fs'); + + const marker = ''; + const summaryPath = 'coverage-artifact/coverage/coverage-summary.md'; + if (!fs.existsSync(summaryPath)) { + core.warning(`Coverage summary not found at ${summaryPath}; skip coverage comment.`); + return; + } + + let summary = fs.readFileSync(summaryPath, 'utf8').trim(); + const maxSummaryLength = 60000; + if (summary.length > maxSummaryLength) { + summary = `${summary.slice(0, maxSummaryLength)}\n\n_Comment truncated; see the uploaded artifact for the full report._`; + } + + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const artifactUrl = process.env.COVERAGE_ARTIFACT_URL || ''; + const links = artifactUrl + ? `HTML report and raw LCOV: [coverage-report](${artifactUrl})` + : `Workflow run: [${context.runId}](${runUrl})`; + const body = [ + marker, + summary, + '', + links, + `Workflow run: [${context.runId}](${runUrl})`, + ].join('\n'); + + const issue_number = context.payload.pull_request?.number; + if (!issue_number) { + core.warning('No pull request number found; skip coverage comment.'); + return; + } + + try { + const { owner, repo } = context.repo; + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number, + per_page: 100, + }); + const existing = comments.find((comment) => comment.body?.includes(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body, + }); + } + } catch (error) { + core.warning(`Failed to write PR coverage comment: ${error.message}`); + } + + validate-runner-selection: + if: ${{ (inputs.runs-on || vars.COVERAGE_RUNS_ON || 'ubuntu-latest') != 'ubuntu-latest' && (inputs.runs-on || vars.COVERAGE_RUNS_ON || 'ubuntu-latest') != 'ubuntu-24.04-arm' && (inputs.runs-on || vars.COVERAGE_RUNS_ON || 'ubuntu-latest') != 'aliyun-ecs-x64' }} + name: validate_runner_selection + runs-on: ubuntu-latest + steps: + - name: fail_invalid_runner_selection + run: | + echo "::error::Invalid runner selection. Set runs-on or COVERAGE_RUNS_ON to one of: ubuntu-latest, ubuntu-24.04-arm, aliyun-ecs-x64." + exit 1 diff --git a/.github/workflows/scheduled-cache-rebuild.yml b/.github/workflows/scheduled-cache-rebuild.yml index c7c84960d..4e727ec81 100644 --- a/.github/workflows/scheduled-cache-rebuild.yml +++ b/.github/workflows/scheduled-cache-rebuild.yml @@ -23,3 +23,13 @@ jobs: -f rebuildDiskCache=true env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Trigger coverage workflow with cache rebuild + run: | + gh workflow run coverage.yml \ + --repo ${{ github.repository }} \ + --ref main \ + -f runs-on=aliyun-ecs-x64 \ + -f rebuildDiskCache=true + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 2ae43f3f6..4deefa00b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ bin/* lib/* var/* logs/* +/coverage/ kv_cache_manager/optimizer/analysis/result/* kv_cache_manager/optimizer/logs/* __pycache__/ @@ -15,4 +16,4 @@ __pycache__/ !.bazeliskrc !.aoneci compile_commands.json -stub_source \ No newline at end of file +stub_source diff --git a/docs/develop/README.md b/docs/develop/README.md index 545ef869a..c448d5d1c 100644 --- a/docs/develop/README.md +++ b/docs/develop/README.md @@ -51,6 +51,17 @@ bazelisk info --announce_rc repository_cache - 需要本地启动一个Redis或Valkey。 - ```bazelisk test //kv_cache_manager/common/test:redis_client_real_service_test //kv_cache_manager/meta/test:meta_redis_backend_real_service_test //kv_cache_manager/meta/test:meta_storage_backend_manager_real_redis_test //kv_cache_manager/meta/test:meta_indexer_redis_test //kv_cache_manager/manager/test:MetaSearcherRedisTest //kv_cache_manager/config/test:registry_manager_redis_backend_test --test_tag_filters=redis``` - 启用ASAN:上述命令后添加 ```--config=debug --config=asan --test_env ASAN_OPTIONS=detect_odr_violation=0``` +- 覆盖率: + - ```tools/coverage/run_coverage.sh --base-ref origin/main --head-ref HEAD --output-dir coverage --include-prefix kv_cache_manager/ --jobs 8 --local-test-jobs 8 -- //kv_cache_manager/... //integration_test/...``` + - 脚本会执行 Bazel LCOV 采集、归一化负 hit count、生成全量/增量覆盖率摘要,并在安装了 lcov/genhtml 时生成 HTML 报告。 + - 输出位于 ```coverage/```,包含 ```lcov.info```、```coverage-summary.md```、```coverage-summary.json``` 和 ```html/index.html```。 + - 如本地未安装 ```genhtml``` 且只需要 LCOV/摘要,可添加 ```--no-html```。 + - ```gcov_json_isolated.sh``` 用于规避 Bazel 6.4 C++ coverage collector 在并发 gcov json 采集时共享 ```*.gcov.json.gz``` 中间文件导致的竞态。 + - Bazel 6.4 默认不向 gcov 传 ```-b```;当前开发镜像的 GCC 10 支持通过 ```COVERAGE_GCOV_OPTIONS=-b``` 生成 ```BRDA/BRF/BRH``` 分支覆盖率记录。 + - CI 会上传 ```coverage/lcov.info```、```coverage/coverage-summary.md```、```coverage/coverage-summary.json``` 和 ```coverage/html/```。 + - PR 触发的 coverage 会更新同一条固定评论;评论 job 仅下载 coverage artifact,不检出或执行 PR 代码。 + - ```coverage``` workflow 使用 setup-bazel 的 Bazelisk cache、repository cache 和独立 disk cache。PR 只读 cache;仅 nightly cache rebuild 或手动以 ```rebuildDiskCache=true``` 触发时保存并替换 main 分支的旧 disk cache。 + - 可通过 workflow_dispatch 的 ```runs-on``` 或仓库变量 ```COVERAGE_RUNS_ON``` 选择 ```ubuntu-latest```、```ubuntu-24.04-arm``` 或 ```aliyun-ecs-x64```。 ### 测试资源清理 测试结束后会自动清理资源。测试工作目录位于 bazel runfiles 目录中,不会污染源代码目录。如果测试异常退出,可能需要手动清理: @@ -158,4 +169,4 @@ githooks中已经添加了C++等语言的格式化脚本,请确保开发环境 提交前检查和 commit message 格式见 [Commit 要求](commit_requirements.md)。 ## CI -可参考```.github/workflows```目录下的配置。```test-opensrc``` 在一个 ```normal_test``` job 中运行普通单元测试和集成测试(包含默认配置下的客户端测试目标),ASAN 测试使用独立 job。 +可参考```.github/workflows```目录下的配置。```test-opensrc``` 在一个 ```normal_test``` job 中运行普通单元测试和集成测试(包含默认配置下的客户端测试目标),ASAN 测试使用独立 job。```coverage``` 负责全量 LCOV、增量覆盖率和 HTML 报告。 diff --git a/tools/coverage/coverage_report.py b/tools/coverage/coverage_report.py new file mode 100644 index 000000000..26ab7cbe2 --- /dev/null +++ b/tools/coverage/coverage_report.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +"""Generate overall and diff line coverage summaries from an LCOV report.""" + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path + + +DIFF_HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@") + + +def normalize_source_path(source_path, workspace): + path = Path(source_path) + workspace = workspace.resolve() + + if path.is_absolute(): + resolved = path.resolve(strict=False) + try: + return resolved.relative_to(workspace).as_posix() + except ValueError: + text = resolved.as_posix() + for prefix in ("kv_cache_manager/", "integration_test/", "tools/"): + marker = "/" + prefix + index = text.rfind(marker) + if index >= 0: + return text[index + 1 :] + return text + + return path.as_posix().lstrip("./") + + +def should_include(path, include_prefixes): + if not include_prefixes: + return True + return any(path.startswith(prefix) for prefix in include_prefixes) + + +def parse_lcov(lcov_path, workspace, include_prefixes): + coverage = {} + current_file = None + current_lines = {} + + def flush_record(): + if current_file and should_include(current_file, include_prefixes): + file_coverage = coverage.setdefault(current_file, {}) + for line_number, hits in current_lines.items(): + file_coverage[line_number] = file_coverage.get(line_number, 0) + hits + + with lcov_path.open("r", encoding="utf-8") as lcov: + for raw_line in lcov: + line = raw_line.strip() + if line.startswith("SF:"): + flush_record() + current_file = normalize_source_path(line[3:], workspace) + current_lines = {} + elif line.startswith("DA:") and current_file: + fields = line[3:].split(",", 2) + if len(fields) >= 2: + current_lines[int(fields[0])] = max(int(fields[1]), 0) + elif line == "end_of_record": + flush_record() + current_file = None + current_lines = {} + flush_record() + return coverage + + +def parse_unified_diff(diff_text, include_prefixes): + changed_lines = {} + current_file = None + + for line in diff_text.splitlines(): + if line.startswith("+++ "): + target = line[4:].strip() + if target == "/dev/null": + current_file = None + elif target.startswith("b/"): + current_file = target[2:] + else: + current_file = target + + if current_file and not should_include(current_file, include_prefixes): + current_file = None + continue + + if not current_file: + continue + + match = DIFF_HUNK_RE.match(line) + if not match: + continue + + start = int(match.group(1)) + count = int(match.group(2) or "1") + if count == 0: + continue + changed_lines.setdefault(current_file, set()).update(range(start, start + count)) + + return changed_lines + + +def git_diff(base_ref, head_ref): + if not base_ref: + return "" + + ranges = [f"{base_ref}...{head_ref}", f"{base_ref}..{head_ref}"] + last_error = None + for rev_range in ranges: + result = subprocess.run( + ["git", "diff", "--unified=0", "--no-ext-diff", rev_range, "--"], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + if result.returncode == 0: + return result.stdout + last_error = result.stderr.strip() + + raise RuntimeError(f"failed to diff {base_ref} against {head_ref}: {last_error}") + + +def coverage_rate(covered, coverable): + if coverable == 0: + return None + return covered / coverable + + +def format_rate(rate): + if rate is None: + return "N/A" + return f"{rate * 100:.2f}%" + + +def format_line_ranges(lines): + if not lines: + return "" + + ranges = [] + start = previous = None + for line in sorted(lines): + if start is None: + start = previous = line + elif line == previous + 1: + previous = line + else: + ranges.append((start, previous)) + start = previous = line + ranges.append((start, previous)) + + return ", ".join(str(s) if s == e else f"{s}-{e}" for s, e in ranges) + + +def summarize_coverage(coverage): + coverable = 0 + covered = 0 + for lines in coverage.values(): + coverable += len(lines) + covered += sum(1 for hits in lines.values() if hits > 0) + return { + "covered_lines": covered, + "coverable_lines": coverable, + "line_rate": coverage_rate(covered, coverable), + } + + +def summarize_diff_coverage(coverage, changed_lines): + coverable = 0 + covered = 0 + changed = 0 + uncovered = {} + + for source_file, lines in sorted(changed_lines.items()): + changed += len(lines) + file_coverage = coverage.get(source_file) + if not file_coverage: + continue + + for line in sorted(lines): + if line not in file_coverage: + continue + coverable += 1 + if file_coverage[line] > 0: + covered += 1 + else: + uncovered.setdefault(source_file, []).append(line) + + return { + "changed_lines": changed, + "covered_lines": covered, + "coverable_lines": coverable, + "line_rate": coverage_rate(covered, coverable), + "uncovered_lines": uncovered, + } + + +def render_markdown(overall, diff, base_ref, head_ref): + lines = [ + "# Coverage Summary", + "", + "| Scope | Covered lines | Coverable lines | Line coverage |", + "| --- | ---: | ---: | ---: |", + ( + f"| Overall | {overall['covered_lines']} | " + f"{overall['coverable_lines']} | {format_rate(overall['line_rate'])} |" + ), + ( + f"| Changed lines | {diff['covered_lines']} | " + f"{diff['coverable_lines']} | {format_rate(diff['line_rate'])} |" + ), + "", + f"Diff base: `{base_ref or 'N/A'}`", + f"Diff head: `{head_ref}`", + f"Changed lines in diff: `{diff['changed_lines']}`", + ] + + non_coverable = diff["changed_lines"] - diff["coverable_lines"] + lines.append(f"Changed lines without LCOV data: `{non_coverable}`") + + if diff["uncovered_lines"]: + lines.extend(["", "## Uncovered Changed Lines", ""]) + for source_file, uncovered_lines in sorted(diff["uncovered_lines"].items()): + lines.append(f"- `{source_file}`: {format_line_ranges(uncovered_lines)}") + + lines.append("") + return "\n".join(lines) + + +def write_outputs(output_dir, overall, diff, base_ref, head_ref): + output_dir.mkdir(parents=True, exist_ok=True) + payload = { + "overall": overall, + "diff": { + "base_ref": base_ref, + "head_ref": head_ref, + **diff, + }, + } + (output_dir / "coverage-summary.json").write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + (output_dir / "coverage-summary.md").write_text( + render_markdown(overall, diff, base_ref, head_ref), + encoding="utf-8", + ) + + +def parse_args(argv): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--lcov", required=True, type=Path, help="Path to LCOV .dat/.info file") + parser.add_argument("--workspace", type=Path, default=Path.cwd(), help="Repository root") + parser.add_argument("--base-ref", default="", help="Base ref for incremental coverage") + parser.add_argument("--head-ref", default="HEAD", help="Head ref for incremental coverage") + parser.add_argument("--output-dir", type=Path, default=Path("coverage"), help="Output directory") + parser.add_argument( + "--include-prefix", + action="append", + default=[], + help="Only include files whose repository-relative path starts with this prefix", + ) + parser.add_argument( + "--fail-under-overall", + type=float, + default=None, + help="Fail if overall line coverage is below this percentage", + ) + parser.add_argument( + "--fail-under-diff", + type=float, + default=None, + help="Fail if changed-line coverage is below this percentage", + ) + return parser.parse_args(argv) + + +def check_threshold(name, rate, threshold): + if threshold is None or rate is None: + return True + return rate * 100 >= threshold + + +def main(argv): + args = parse_args(argv) + include_prefixes = [prefix.lstrip("./") for prefix in args.include_prefix] + + coverage = parse_lcov(args.lcov, args.workspace, include_prefixes) + overall = summarize_coverage(coverage) + + diff_text = git_diff(args.base_ref, args.head_ref) if args.base_ref else "" + changed_lines = parse_unified_diff(diff_text, include_prefixes) + diff = summarize_diff_coverage(coverage, changed_lines) + + write_outputs(args.output_dir, overall, diff, args.base_ref, args.head_ref) + + print(render_markdown(overall, diff, args.base_ref, args.head_ref)) + + ok = True + if not check_threshold("overall", overall["line_rate"], args.fail_under_overall): + print( + f"overall coverage {format_rate(overall['line_rate'])} is below " + f"{args.fail_under_overall:.2f}%", + file=sys.stderr, + ) + ok = False + if not check_threshold("diff", diff["line_rate"], args.fail_under_diff): + print( + f"diff coverage {format_rate(diff['line_rate'])} is below " + f"{args.fail_under_diff:.2f}%", + file=sys.stderr, + ) + ok = False + + return 0 if ok else 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tools/coverage/coverage_report_test.py b/tools/coverage/coverage_report_test.py new file mode 100644 index 000000000..486932ee3 --- /dev/null +++ b/tools/coverage/coverage_report_test.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 + +import tempfile +import textwrap +import unittest +from pathlib import Path + +import coverage_report + + +class CoverageReportTest(unittest.TestCase): + def test_lcov_and_diff_summary(self): + with tempfile.TemporaryDirectory() as tmpdir: + workspace = Path(tmpdir) + source = workspace / "kv_cache_manager" / "manager" / "cache_manager.cc" + source.parent.mkdir(parents=True) + source.write_text("placeholder\n", encoding="utf-8") + + lcov = workspace / "coverage.dat" + lcov.write_text( + textwrap.dedent( + f"""\ + TN: + SF:{source} + DA:10,1 + DA:11,0 + DA:12,3 + end_of_record + """ + ), + encoding="utf-8", + ) + + coverage = coverage_report.parse_lcov( + lcov, + workspace, + ["kv_cache_manager/"], + ) + self.assertEqual( + coverage, + {"kv_cache_manager/manager/cache_manager.cc": {10: 1, 11: 0, 12: 3}}, + ) + + diff = coverage_report.parse_unified_diff( + textwrap.dedent( + """\ + diff --git a/kv_cache_manager/manager/cache_manager.cc b/kv_cache_manager/manager/cache_manager.cc + --- a/kv_cache_manager/manager/cache_manager.cc + +++ b/kv_cache_manager/manager/cache_manager.cc + @@ -9,0 +10,3 @@ + +line 10 + +line 11 + +line 12 + diff --git a/docs/develop/README.md b/docs/develop/README.md + --- a/docs/develop/README.md + +++ b/docs/develop/README.md + @@ -1,0 +2,1 @@ + +ignored + """ + ), + ["kv_cache_manager/"], + ) + self.assertEqual( + diff, + {"kv_cache_manager/manager/cache_manager.cc": {10, 11, 12}}, + ) + + overall = coverage_report.summarize_coverage(coverage) + self.assertEqual(overall["covered_lines"], 2) + self.assertEqual(overall["coverable_lines"], 3) + + diff_summary = coverage_report.summarize_diff_coverage(coverage, diff) + self.assertEqual(diff_summary["covered_lines"], 2) + self.assertEqual(diff_summary["coverable_lines"], 3) + self.assertEqual( + diff_summary["uncovered_lines"], + {"kv_cache_manager/manager/cache_manager.cc": [11]}, + ) + + def test_empty_diff_rate_is_not_applicable(self): + diff_summary = coverage_report.summarize_diff_coverage({}, {}) + self.assertIsNone(diff_summary["line_rate"]) + self.assertEqual(diff_summary["changed_lines"], 0) + + def test_lcov_duplicate_source_records_are_accumulated(self): + with tempfile.TemporaryDirectory() as tmpdir: + workspace = Path(tmpdir) + lcov = workspace / "coverage.dat" + lcov.write_text( + textwrap.dedent( + """\ + TN: + SF:kv_cache_manager/common/env_util.cc + DA:7,1 + DA:8,0 + end_of_record + SF:kv_cache_manager/common/env_util.cc + DA:7,2 + DA:8,3 + end_of_record + """ + ), + encoding="utf-8", + ) + + coverage = coverage_report.parse_lcov(lcov, workspace, ["kv_cache_manager/"]) + + self.assertEqual(coverage["kv_cache_manager/common/env_util.cc"], {7: 3, 8: 3}) + + def test_lcov_negative_line_hits_are_clamped_to_zero(self): + with tempfile.TemporaryDirectory() as tmpdir: + workspace = Path(tmpdir) + lcov = workspace / "coverage.dat" + lcov.write_text( + textwrap.dedent( + """\ + TN: + SF:kv_cache_manager/common/env_util.cc + DA:7,-7 + end_of_record + """ + ), + encoding="utf-8", + ) + + coverage = coverage_report.parse_lcov(lcov, workspace, ["kv_cache_manager/"]) + + self.assertEqual(coverage["kv_cache_manager/common/env_util.cc"], {7: 0}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/coverage/gcov_json_isolated.sh b/tools/coverage/gcov_json_isolated.sh new file mode 100755 index 000000000..1668cf569 --- /dev/null +++ b/tools/coverage/gcov_json_isolated.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash + +set -u + +real_gcov="${KVCM_REAL_GCOV:-}" +if [[ -z "${real_gcov}" ]]; then + echo "KVCM_REAL_GCOV is not set" >&2 + exit 1 +fi + +if [[ -z "${COVERAGE_DIR:-}" ]]; then + exec "${real_gcov}" "$@" +fi + +caller_pwd="${PWD}" +tmpdir="$(mktemp -d "${COVERAGE_DIR}/gcov-json.XXXXXX")" +status=0 + +( + cd "${tmpdir}" || exit 1 + "${real_gcov}" "$@" +) || status=$? + +gcda="" +for arg in "$@"; do + if [[ "${arg}" == *.gcda ]]; then + gcda="${arg}" + fi +done + +if [[ -n "${gcda}" && "${gcda}" == "${COVERAGE_DIR}/"* ]]; then + relative_gcda="${gcda#${COVERAGE_DIR}/}" + dest_dir="${COVERAGE_DIR}/$(dirname "${relative_gcda}")" + mkdir -p "${dest_dir}" +else + dest_dir="${caller_pwd}" +fi + +shopt -s nullglob +json_files=("${tmpdir}"/*.gcov.json.gz) +if ((${#json_files[@]} > 0)); then + mv -- "${json_files[@]}" "${dest_dir}/" +fi + +text_files=("${tmpdir}"/*.gcov) +if ((${#text_files[@]} > 0)); then + mv -- "${text_files[@]}" "${caller_pwd}/" +fi +shopt -u nullglob + +rm -rf "${tmpdir}" +exit "${status}" diff --git a/tools/coverage/run_coverage.sh b/tools/coverage/run_coverage.sh new file mode 100755 index 000000000..7a897d027 --- /dev/null +++ b/tools/coverage/run_coverage.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ "${COVERAGE_XTRACE:-0}" == "1" ]]; then + set -x +fi + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +DEFAULT_WORKSPACE="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +WORKSPACE="${DEFAULT_WORKSPACE}" +BASE_REF="origin/main" +HEAD_REF="HEAD" +OUTPUT_DIR="coverage" +INCLUDE_PREFIX="kv_cache_manager/" +JOBS="8" +LOCAL_TEST_JOBS="8" +TEST_TIMEOUT="900" +TEST_OUTPUT="errors" +FETCH_MAIN="0" +GENERATE_HTML="1" +TARGETS=() + +usage() { + cat <<'EOF' +Usage: tools/coverage/run_coverage.sh [options] -- + +Options: + --workspace PATH Repository workspace. Defaults to the repo root. + --base-ref REF Base ref for incremental coverage. Defaults to origin/main. + --head-ref REF Head ref for incremental coverage. Defaults to HEAD. + --output-dir DIR Output directory. Defaults to coverage. + --include-prefix PATH Include files under this path in summaries. Defaults to kv_cache_manager/. + --jobs N Bazel build jobs. Defaults to 8. + --local-test-jobs N Bazel local test jobs. Defaults to 8. + --test-timeout SECONDS Bazel test timeout. Defaults to 900. + --test-output MODE Bazel test output mode. Defaults to errors. + --fetch-main Run "git fetch origin main --no-tags --prune" before coverage. + --no-html Skip genhtml output. + -h, --help Show this help. + +Environment: + KVCM_REAL_GCOV Real gcov binary. Auto-detected when unset. + GCOV gcov wrapper. Defaults to tools/coverage/gcov_json_isolated.sh. + COVERAGE_GCOV_OPTIONS Extra gcov options. Defaults to -b for branch coverage. + COVERAGE_XTRACE=1 Enable shell xtrace. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --workspace) + WORKSPACE="$2" + shift 2 + ;; + --base-ref) + BASE_REF="$2" + shift 2 + ;; + --head-ref) + HEAD_REF="$2" + shift 2 + ;; + --output-dir) + OUTPUT_DIR="$2" + shift 2 + ;; + --include-prefix) + INCLUDE_PREFIX="$2" + shift 2 + ;; + --jobs) + JOBS="$2" + shift 2 + ;; + --local-test-jobs) + LOCAL_TEST_JOBS="$2" + shift 2 + ;; + --test-timeout) + TEST_TIMEOUT="$2" + shift 2 + ;; + --test-output) + TEST_OUTPUT="$2" + shift 2 + ;; + --fetch-main) + FETCH_MAIN="1" + shift + ;; + --no-html) + GENERATE_HTML="0" + shift + ;; + -h|--help) + usage + exit 0 + ;; + --) + shift + TARGETS=("$@") + break + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ ${#TARGETS[@]} -eq 0 ]]; then + TARGETS=(//kv_cache_manager/... //integration_test/...) +fi + +cd "${WORKSPACE}" + +if [[ -z "${BASE_REF}" || "${BASE_REF}" =~ ^0+$ ]]; then + BASE_REF="origin/main" +fi + +if [[ -n "${GITHUB_WORKSPACE:-}" ]]; then + git config --global --add safe.directory "${GITHUB_WORKSPACE}" +fi + +if [[ "${FETCH_MAIN}" == "1" ]]; then + git fetch origin main --no-tags --prune +fi + +if [[ -z "${KVCM_REAL_GCOV:-}" ]]; then + GCC_MAJOR="$(gcc -dumpversion | cut -d. -f1)" + KVCM_REAL_GCOV="$(command -v "gcov-${GCC_MAJOR}" || command -v gcov)" +fi + +GCOV="${GCOV:-${WORKSPACE}/tools/coverage/gcov_json_isolated.sh}" +COVERAGE_GCOV_OPTIONS="${COVERAGE_GCOV_OPTIONS:--b}" + +echo "gcc: $(gcc --version | head -1)" +echo "gcov: $(${KVCM_REAL_GCOV} --version | head -1)" +echo "gcov wrapper: ${GCOV}" +echo "coverage base: ${BASE_REF}" +echo "coverage head: ${HEAD_REF}" + +rm -rf "${OUTPUT_DIR}" bazel-testlogs + +bazelisk coverage \ + --config=debug \ + --config=ci_fast \ + --combined_report=lcov \ + --instrumentation_filter="^//kv_cache_manager" \ + --action_env=GCOV="${GCOV}" \ + --test_env=GCOV="${GCOV}" \ + --action_env=KVCM_REAL_GCOV="${KVCM_REAL_GCOV}" \ + --test_env=KVCM_REAL_GCOV="${KVCM_REAL_GCOV}" \ + --test_env=COVERAGE_GCOV_OPTIONS="${COVERAGE_GCOV_OPTIONS}" \ + --test_timeout="${TEST_TIMEOUT}" \ + --jobs="${JOBS}" \ + --local_test_jobs="${LOCAL_TEST_JOBS}" \ + --cache_test_results=no \ + --test_output="${TEST_OUTPUT}" \ + "${TARGETS[@]}" + +mkdir -p "${OUTPUT_DIR}" +LCOV_INFO="${OUTPUT_DIR}/lcov.info" +cp bazel-out/_coverage/_coverage_report.dat "${LCOV_INFO}" +sed -E -i \ + -e 's/^(DA:[0-9]+),-[0-9]+/\1,0/' \ + -e 's/^(BRDA:[^,]+,[^,]+,[^,]+),-[0-9]+/\1,0/' \ + "${LCOV_INFO}" + +python3 "${SCRIPT_DIR}/coverage_report.py" \ + --lcov "${LCOV_INFO}" \ + --workspace "${WORKSPACE}" \ + --base-ref "${BASE_REF}" \ + --head-ref "${HEAD_REF}" \ + --include-prefix "${INCLUDE_PREFIX}" \ + --output-dir "${OUTPUT_DIR}" + +if [[ "${GENERATE_HTML}" == "1" ]]; then + if ! command -v genhtml >/dev/null 2>&1; then + echo "genhtml not found; install lcov or rerun with --no-html" >&2 + exit 1 + fi + GENHTML_ARGS=( + "${LCOV_INFO}" + --output-directory "${OUTPUT_DIR}/html" + --title "tair-kvcache coverage" + --legend + --show-details + --ignore-errors source + ) + if grep -q '^BRDA:' "${LCOV_INFO}"; then + GENHTML_ARGS+=(--branch-coverage) + fi + genhtml "${GENHTML_ARGS[@]}" +fi