From 79be48d53e7b6771c76e7a8c31be9e593ce529c7 Mon Sep 17 00:00:00 2001 From: Eli Ma Date: Thu, 8 Oct 2026 06:09:10 +0800 Subject: [PATCH] v0.42.24: add frozen history projection benchmark Signed-off-by: Eli Ma --- Cargo.lock | 2 +- Cargo.toml | 2 +- docs/plan/plan-20261002.md | 56 +- docs/refactoring/history-projection.md | 20 +- examples/view_bench.rs | 1426 ++++++++++++++++++++++++ src/lib.rs | 144 +++ 6 files changed, 1624 insertions(+), 26 deletions(-) create mode 100644 examples/view_bench.rs diff --git a/Cargo.lock b/Cargo.lock index 4e990bf7..5193b602 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4172,7 +4172,7 @@ checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" [[package]] name = "mega2" -version = "0.42.23" +version = "0.42.24" dependencies = [ "anyhow", "async-recursion", diff --git a/Cargo.toml b/Cargo.toml index e67221d3..9b5c5f6e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mega2" -version = "0.42.23" +version = "0.42.24" edition = "2024" license = "MIT OR Apache-2.0" description = "Monorepo engine and Git hosting server for AI agents: trunk-based storage, Git Smart HTTP, Git LFS, OCI registry, and Agent Session Capture" diff --git a/docs/plan/plan-20261002.md b/docs/plan/plan-20261002.md index 9cce7846..f6f1361f 100644 --- a/docs/plan/plan-20261002.md +++ b/docs/plan/plan-20261002.md @@ -2,7 +2,7 @@ **模板版本:** v2.1;依据 [`plan-template.md`](plan-template.md)。源码核对日期 2026-10-02;核对时 tip 为 `c9ae6df`(`v0.41.34`)。成稿时另一会话已发布 `fb5ac71`(`v0.41.35: BB-43 ART input validation case`),只改了 `Cargo.toml`、`Cargo.lock`、`docs/deploy-trunk.md`、`docs/plan/plan-20261001.md`、`scripts/artifacts_smoke_storage_only.sh`,不影响本文的源码锚点。工作区仍有该会话的未提交改动,本计划不触碰;每张卡开工时按 ER-01、ER-12 重新读取权威版本。 -**计划状态:** **执行中**。主链已完成 HP-32 与 HP-34;HP-30 承载发布为 `v0.42.7`;HP-13 承载发布为 `v0.42.8`;HP-31 承载发布为 `v0.42.9`;HP-14 由 `v0.42.10` 承载;HP-16 由 `v0.42.11` 承载;HP-17 由 `v0.42.12` 承载;HP-34 由 `v0.42.13` 承载;HP-32 由 `v0.42.14` 承载;HP-15 由 `v0.42.15` 承载;HP-33 由 `v0.42.16` 承载;HP-18 由 `v0.42.17` 承载;HP-19 由 `v0.42.18` 承载;HP-20 由 `v0.42.19` 承载;HP-21 由 `v0.42.20` 承载;HP-22 由 `v0.42.21` 承载;HP-23 由 `v0.42.22` 承载。HP-24 冻结参数提交为 `7acc2b5`,首次有效运行判据 (c)(d) FAIL,现为 blocked;先执行 FIX-HP-01。Docker Build / Actions 按发布者指令不等待、不查询。 +**计划状态:** **执行中**。主链已完成 HP-32 与 HP-34;HP-30 承载发布为 `v0.42.7`;HP-13 承载发布为 `v0.42.8`;HP-31 承载发布为 `v0.42.9`;HP-14 由 `v0.42.10` 承载;HP-16 由 `v0.42.11` 承载;HP-17 由 `v0.42.12` 承载;HP-34 由 `v0.42.13` 承载;HP-32 由 `v0.42.14` 承载;HP-15 由 `v0.42.15` 承载;HP-33 由 `v0.42.16` 承载;HP-18 由 `v0.42.17` 承载;HP-19 由 `v0.42.18` 承载;HP-20 由 `v0.42.19` 承载;HP-21 由 `v0.42.20` 承载;HP-22 由 `v0.42.21` 承载;HP-23 由 `v0.42.22` 承载。HP-24 冻结参数提交为 `7acc2b5`;首次有效运行 (c)(d) FAIL 后,FIX-HP-01 已由 `v0.42.23` 发布,原工作树同参数的正式重跑 (a)–(d) 与顶格预算均 PASS。HP-24 的 8/8 验收、8/8 Verification、双审及 C 组本地门通过,`v0.42.24` 承载发布;Docker Build / Actions 按发布者指令不等待、不查询。 **设计契约:** [`docs/refactoring/history-projection.md`](../refactoring/history-projection.md)(下称「设计」,v0.2,经 Codex 五轮审计,见设计附录 F)。本文用「设计 §x.y」引用其条款;设计节号冻结(ADR-HP-01)。 @@ -188,6 +188,7 @@ | 2026-10-08 | HP-23 发布 | HP-23 已完成并由 `v0.42.22` 承载;发布后从 HP-24 继续。 | 无拆卡 | HP-23、发布窗口、Review log | | 2026-10-08 | HP-24 冻结基准 FAIL 与 ER-10 修复卡 | 参数冻结提交 `7acc2b5` 已推送;ER-03 冻结前因 v2.0.0 首父链存在非 UTF-8 作者而改选 git.git v0.99.5,已在 HP-24 Performance budget 登记范围与局限;首次有效运行的 (c)(d) FAIL,随后诊断把增量耗时拆为根链与视图两段;HP-24 置 blocked,新增 FIX-HP-01 并立即加前置依赖;先修订设计 §3.3、§4.4,再实施根链批写、无预算单提交快路与末批终态。所有 `run-*.json` 与 `failed-*.json` 保留。 | HP-24 → FIX-HP-01、HP-24 | 实施顺序、发布顺序、共享文件、ER-05、HP-24、审计表、设计 §3.3/§4.4、Review log | | 2026-10-08 | FIX-HP-01 实施、双审与发布 | 根链批写、无预算单提交快路和追赶末批终态由 `v0.42.23` 的 `9685337` 承载;Claude 与独立 Codex 均 `VERDICT: PASS`。定向测试、nightly 格式、全目标全特性 Clippy、第二轮完整测试 19 个目标与两个零警告构建门通过;首轮完整测试中既有端口占用用例的日志时序波动,精确复跑和第二轮完整运行通过。推送前三次隔离冻结诊断的 (a)–(d) 与规模上限均 PASS;标签与 GitHub Release 已发布。Docker Build Action 按发布者指令不等待、不查询。 | 无拆卡 | FIX-HP-01、设计 §3.3/§4.4、Review log、ER-04 C/D | +| 2026-10-08 | HP-24 正式重跑、双审与发布门 | FIX-HP-01 发布后在原工作树按冻结参数重新编译并运行基准;(a)–(d) 与顶格预算全部 PASS。§7.2 登记完整实测汇总,任务卡列出三份有效运行、两份失败记录与隔离目录的诊断失败;8 项验收与 8 项精确 Verification、Claude 与 Codex 双审、nightly 格式、Clippy、19 个目标的完整测试及两个零警告构建门通过。版本 `v0.42.24` 承载本卡;Docker Build Action 按发布者指令不等待、不查询。 | 无拆卡 | HP-24、设计 §7.2、Review log、ER-04 C/D | ## 已决议设计决策 @@ -5269,7 +5270,7 @@ ER-04 C 组五条精确命令和两个零警告构建门另执行,不计入本 **Task type:** `implementation` -**Lifecycle / Acceptance:** `blocked` / 空(冻结运行的判据 (c)(d) FAIL;待 `FIX-HP-01` 发布后以同一参数重跑) +**Lifecycle / Acceptance:** `in-progress` / `remote-pending`(FIX-HP-01 发布后按同一冻结参数重跑;8/8 验收与 8/8 精确验证、Claude 与 Codex 双审 PASS;`v0.42.24` 的 C 组全量门和两个零警告构建门通过;D 组按发布者指令不等待、不查询) **Description:** 新增 opt-in 基准 `examples/view_bench.rs` 与冻结参数文件 `examples/view_bench.params.json`(ADR-HP-07)。基准按设计 §7.4「基准」的冻结协议,计量根链扩展与 `catch_up` 的冷启动和单提交增量,判定设计 §7.5 P0 验收 11 的四项判据,并复核设计 §6.5 的过滤器规模上限。唯一的行为轴是「P0 性能门」,G-02 允许独立的性能门单独成卡。范围: - 接缝:`src/lib.rs` 新增 `#[doc(hidden)] pub mod view_bench_ops`,只做转发、包装与装配,不新增写库代码,也不改被测代码。它提供以下能力: @@ -5397,14 +5398,14 @@ ER-04 C 组五条精确命令和两个零警告构建门另执行,不计入本 **Acceptance criteria:** -- [ ] `target/tmp/view_bench/` 顶层的每份证据 JSON(`latest.json` 与全部带时间戳的副本)的 `params`,都与 ER-03 冻结提交中的 `examples/view_bench.params.json` 逐字段相等。 -- [ ] 判据 (a):每份计入判定的证据(定义见 Performance budget「计入判定的运行」)中,同一视图、同一探针在三档 H 下,单提交增量执行的 SQL 语句数逐一相等(设计 §7.5 P0 验收 11(a))。 -- [ ] 判据 (b):每份计入判定的证据中,同一视图、同一类探针在三档 H 下的单提交增量 p95,最大值与最小值之比不超过 k_H(设计 §7.5 P0 验收 11(b))。 -- [ ] 判据 (c):每份计入判定的证据中,H_max 档每个视图、每类探针的单提交增量 p95 不超过 k_B 乘以该视图冷启动投影段的平均每批耗时,平均每批耗时为投影段总耗时除以 ⌈H_max/B⌉(设计 §7.5 P0 验收 11(c))。 -- [ ] 判据 (d):每份计入判定的证据中,H_max 档每个视图的冷启动(根链构建加该视图的投影,口径见 Performance budget「判据 (d) 的口径」)执行的 SQL 语句总数不超过 c_cold × ⌈H_max/B⌉(设计 §7.5 P0 验收 11(d))。 -- [ ] `target/tmp/view_bench/` 顶层的每份证据 JSON 都符合 Performance budget「证据字段」(设计 §7.4「证据」)。 -- [ ] 每份计入判定的证据中,顶格过滤器(规范化后的三个计数等于冻结参数 `scale_limits.caps`,定义见 Performance budget「规模上限复核」)在 H_max 档的单提交增量 p95 不超过冻结预算 `scale_limits.p95_budget_us`(设计 §6.5「过滤器规模上限」的基准复核)。 -- [ ] 基准自建的 schema 在正常退出与出错退出两条路径上都被删除(ER-13)。 +- [x] `target/tmp/view_bench/` 顶层的每份证据 JSON(`latest.json` 与全部带时间戳的副本)的 `params`,都与 ER-03 冻结提交中的 `examples/view_bench.params.json` 逐字段相等。 +- [x] 判据 (a):每份计入判定的证据(定义见 Performance budget「计入判定的运行」)中,同一视图、同一探针在三档 H 下,单提交增量执行的 SQL 语句数逐一相等(设计 §7.5 P0 验收 11(a))。 +- [x] 判据 (b):每份计入判定的证据中,同一视图、同一类探针在三档 H 下的单提交增量 p95,最大值与最小值之比不超过 k_H(设计 §7.5 P0 验收 11(b))。 +- [x] 判据 (c):每份计入判定的证据中,H_max 档每个视图、每类探针的单提交增量 p95 不超过 k_B 乘以该视图冷启动投影段的平均每批耗时,平均每批耗时为投影段总耗时除以 ⌈H_max/B⌉(设计 §7.5 P0 验收 11(c))。 +- [x] 判据 (d):每份计入判定的证据中,H_max 档每个视图的冷启动(根链构建加该视图的投影,口径见 Performance budget「判据 (d) 的口径」)执行的 SQL 语句总数不超过 c_cold × ⌈H_max/B⌉(设计 §7.5 P0 验收 11(d))。 +- [x] `target/tmp/view_bench/` 顶层的每份证据 JSON 都符合 Performance budget「证据字段」(设计 §7.4「证据」)。 +- [x] 每份计入判定的证据中,顶格过滤器(规范化后的三个计数等于冻结参数 `scale_limits.caps`,定义见 Performance budget「规模上限复核」)在 H_max 档的单提交增量 p95 不超过冻结预算 `scale_limits.p95_budget_us`(设计 §6.5「过滤器规模上限」的基准复核)。 +- [x] 基准自建的 schema 在正常退出与出错退出两条路径上都被删除(ER-13)。 **Verification:** @@ -5418,14 +5419,14 @@ ER-04 C 组五条精确命令和两个零警告构建门另执行,不计入本 以下各行按顺序执行。第 2–5 行读第 1 行写出的证据:第 2、4 行读 `target/tmp/view_bench/` 顶层的全部证据,第 3、5 行读其中计入判定的证据(Performance budget「计入判定的运行」)。第 6 行在第 1 行之后执行:基准启动时发现残留 schema 会以 2 退出,且不打印注入标记,所以第 6 行通过也说明第 1 行没有留下 schema。第 7 行在第 6 行之后执行。 -- [ ] `source .env.test && cargo run --release --example view_bench -- --params examples/view_bench.params.json`(new):退出码 0。完成三档计量与规模上限复核,写出 `target/tmp/view_bench/latest.json` 与带时间戳的副本;stdout 打印四项判据与规模上限的 PASS / FAIL 及汇总。判据结论不影响退出码,由第 3、5 行判定。 -- [ ] `source .env.test && bash -c 'f=examples/view_bench.params.json; st=$(libra status --short "$f") || exit 2; if [ ! -f "$f" ] || [ -n "$st" ]; then echo "FAIL: $f is missing, untracked or modified: $st"; exit 1; fi; jq -e -s --slurpfile p "$f" "all(.[]; .params == \$p[0])" target/tmp/view_bench/latest.json target/tmp/view_bench/run-*.json'`:退出码 0 且输出 `true`(AC 1)。先核对参数文件存在、已被跟踪,且与最近一次改动它的提交相同:`libra status --short` 对未跟踪或改动过的文件都有输出(见 Current evidence),判为 FAIL。重新冻结只能经新的冻结提交,所以这次提交就是当前的冻结提交。再核对顶层每份证据的 `params` 都与它相同。当前冻结之后的每次运行,证据都留在该目录顶层;重新冻结时,旧证据整体移入 `superseded-*` 目录(见 Performance budget)。没有任何副本时,`jq` 先为 `latest.json` 输出 `true`,再因打不开未展开的 glob 以 2 退出,退出码不为 0,本行不通过。 -- [ ] `source .env.test && jq -e -s --slurpfile p examples/view_bench.params.json 'def crit($q): $q.data.h_max as $h | (($h + $q.views_config.batch_size - 1) / $q.views_config.batch_size | floor) as $nb | (.cold_start[] | select(.h == $h)) as $c | [(.incremental | group_by([.view, .probe_class])[] | map(.samples | map(.statements)) | unique | length == 1), (.incremental | group_by([.view, .probe_class])[] | (map(.p95_us) | max / min) <= $q.thresholds.k_h), (.incremental[] | select(.h == $h) | .view as $v | .p95_us <= $q.thresholds.k_b * (($c.views[] | select(.view == $v) | .elapsed_us) / $nb)), ($c.views[] | .statements + $c.root_chain.statements <= $q.thresholds.c_cold * $nb), .criteria.a.pass, .criteria.b.pass, .criteria.c.pass, .criteria.d.pass] | all; $p[0] as $q | .[0].env.code as $v | all(.[] | select(.env.code == $v); crit($q))' target/tmp/view_bench/latest.json target/tmp/view_bench/run-*.json`:退出码 0 且输出 `true`(AC 2–5)。读 `latest.json` 与顶层全部带时间戳的副本,取 `env.code` 与 `latest.json` 相同的每一份,即计入判定的证据。对其中每一份,四项判据都由 `jq` 从原始样本独立重算,同时要求基准自己的 `.criteria..pass` 都为真。所以同一代码版本下只要有一次运行 FAIL,本行就不通过,之后重跑出 PASS 也不能改变(设计 §7.4「证据」:不得挑选)。(a) 按「视图 × 探针类」分组,三档第 i 次计量的语句数序列完全相同;(b) 按「视图 × 探针类」取三档 p95 的最大值除以最小值,与 `thresholds.k_h` 比较;(c) 按「视图 × 探针类」比较 H_max 档 p95 与 `k_b` × 该视图投影段总耗时 ÷ ⌈H_max/B⌉;(d) 按视图把 H_max 档根链构建与该视图投影的语句数相加,与 `c_cold` × ⌈H_max/B⌉ 比较。 -- [ ] `source .env.test && jq -e -s --slurpfile p examples/view_bench.params.json 'def pct(q): map(.elapsed_us) | sort | .[(q * length | ceil) - 1]; def nb($l; $b): ($l + $b - 1) / $b | floor; def ok($q): $q.data.h_max as $h | $q.views_config.batch_size as $b | (.cold_start | map(.h)) as $hs | (.env.code | (.revision | test("^([0-9a-f]{40}|[0-9a-f]{64})$")) and (.worktree | type) == "array" and all(.worktree[]; .[3:] | IN("src/lib.rs", "examples/view_bench.rs")) and (.sha256 | keys) == ["examples/view_bench.rs", "src/lib.rs"]) and $hs == [($h / 4 | floor), ($h / 2 | floor), $h] and all(.cold_start[]; .h as $x | (.root_chain | has("elapsed_us") and has("statements") and .batches == nb($x; $b)) and (.views | map(.view)) == $q.views and all(.views[]; has("elapsed_us") and has("statements") and .batches == nb($x; $b) and .commits_per_sec > 0) and (.relation_sizes | keys) == ["mega_view_commit_map", "mega_view_object", "mega_view_object_ref", "mega_view_root_chain"]) and (.incremental | length) == 18 and (.incremental | map([.h, .view, .probe_class]) | unique | length) == 18 and all(.incremental[]; (.h | IN($hs[])) and (.view | IN($q.views[])) and (.probe_class | IN($q.probes[0] | keys[])) and (.warmup | length) == $q.runs.w and (.samples | length) == $q.runs.n and all((.warmup + .samples)[]; has("elapsed_us") and has("statements")) and .p50_us == (.samples | pct(0.5)) and .p95_us == (.samples | pct(0.95))) and all(.criteria.a, .criteria.b, .criteria.c, .criteria.d; has("value") and has("threshold") and (.violations | type) == "array" and .pass == (.violations | length == 0)) and (.scale_limits | (.cold_start | has("elapsed_us") and has("statements") and .batches == nb($h + 6 * ($q.runs.w + $q.runs.n); $b) and has("spine_trees")) and (.warmup | length) == $q.runs.w and (.samples | length) == $q.runs.n and all((.warmup + .samples)[]; has("elapsed_us") and has("statements") and has("spine_trees")) and .p50_us == (.samples | pct(0.5)) and .p95_us == (.samples | pct(0.95))); $p[0] as $q | .[0] as $l | any(.[1:][]; . == $l) and all(.[]; ok($q))' target/tmp/view_bench/latest.json target/tmp/view_bench/run-*.json`:退出码 0 且输出 `true`(AC 6)。`latest.json` 有内容相同的带时间戳副本;`latest.json` 与顶层每一份副本都逐项符合 Performance budget「证据字段」:`env.code` 齐全,`worktree` 只涉及 `src/lib.rs`、`examples/view_bench.rs` 两条路径;`cold_start[]` 为三档,H 值为 {⌊H_max/4⌋, ⌊H_max/2⌋, H_max},每档都有 `root_chain` 段、按参数文件顺序的两个视图投影段(吞吐大于 0)与四张表的 `relation_sizes`,各段 `batches` 等于名义批数;`incremental[]` 恰有 18 项,且是 3 档 × 2 个视图 × 3 类探针的 18 个不同的组,重复的组判为不通过;每组 w 次预热与 n 次计量的逐次样本都带 `elapsed_us` 与 `statements`,`p50_us`、`p95_us` 等于按最近秩法的重算值;每个 `criteria.` 都有 `value`、`threshold`、`violations` 数组与 `pass`,`pass` 为真当且仅当 `violations` 为空;`scale_limits` 有带 `spine_trees` 与名义批数的 `cold_start` 段、逐次带 `spine_trees` 的样本,以及等于重算值的 p50 与 p95。 -- [ ] `source .env.test && jq -e -s --slurpfile p examples/view_bench.params.json '$p[0].scale_limits as $s | .[0].env.code as $v | all(.[] | select(.env.code == $v) | .scale_limits; .compose_members == $s.caps.compose_members and .exclude_selectors == $s.caps.exclude_selectors and .k == $s.caps.k and .p95_us <= $s.p95_budget_us)' target/tmp/view_bench/latest.json target/tmp/view_bench/run-*.json`:退出码 0 且输出 `true`(AC 7)。与第 3 行相同,对每份计入判定的证据判定,同一代码版本下一次超出预算即不通过。三个计数由基准从规范化后的过滤器算出,与冻结参数 `scale_limits.caps` 逐项比较,不写死数值;基准在校验参数时已核对 `caps` 等于代码常量 `REGISTER_SCALE_LIMITS`,不等时以 2 退出,不写 `latest.json`。预算取自冻结参数;`p95_us` 与样本的一致性由第 4 行核对。 -- [ ] `source .env.test && bash -c 'out=$(cargo run --release --example view_bench -- --params examples/view_bench.params.json --inject-failure after-migrate 2>&1); rc=$?; if [ "$rc" -eq 2 ] && printf "%s" "$out" | grep -qF "view_bench: injected failure after-migrate"; then echo OK; else echo "FAIL: exit $rc"; exit 1; fi'`(new):退出码 0 且输出 `OK`(AC 8 的出错路径)。退出码为 2 且输出含注入标记,才说明第一个 schema 确已建好并迁移完成,失败发生在注入点。参数、数据或残留检查失败时也以 2 退出,但没有标记,判为 FAIL;编译失败时退出码为 101,同样判为 FAIL。本次运行写出的 `failed-*.json` 照样列入执行记录。 -- [ ] `source .env.test && test "$(docker compose -p mega2-it -f docker/docker-compose.test.yml exec -T postgres psql -U mega2 -d mega2 -Atc "select count(*) from pg_namespace where starts_with(nspname, 'view_bench_')")" = 0`:退出码 0(AC 8)。第 6 行之后没有残留的 `view_bench_` schema。`psql` 在容器内运行,不依赖宿主客户端,连接串不进证据。 -- [ ] `source .env.test && rg -U --pcre2 -n '^### 7\.2 规模估算(纯算术推导,P0 实测见本节末条)$(?:\n(?!### ).*)*\n- \*\*P0 基准实测(HP-24)。\*\*.*(?:\n(?!### |- ).*)*\n### ' docs/refactoring/history-projection.md`:有命中,退出码 0。本行核对 §7.2 的标题已改,实测条目位于 §7.2 内且是该节最后一个顶层条目;`rg` 执行失败时退出码大于 1,同样不通过。由发布者在发布窗口写入设计 §7.2 之后执行。 +- [x] `source .env.test && cargo run --release --example view_bench -- --params examples/view_bench.params.json`(new):退出码 0。完成三档计量与规模上限复核,写出 `target/tmp/view_bench/latest.json` 与带时间戳的副本;stdout 打印四项判据与规模上限的 PASS / FAIL 及汇总。判据结论不影响退出码,由第 3、5 行判定。 +- [x] `source .env.test && bash -c 'f=examples/view_bench.params.json; st=$(libra status --short "$f") || exit 2; if [ ! -f "$f" ] || [ -n "$st" ]; then echo "FAIL: $f is missing, untracked or modified: $st"; exit 1; fi; jq -e -s --slurpfile p "$f" "all(.[]; .params == \$p[0])" target/tmp/view_bench/latest.json target/tmp/view_bench/run-*.json'`:退出码 0 且输出 `true`(AC 1)。先核对参数文件存在、已被跟踪,且与最近一次改动它的提交相同:`libra status --short` 对未跟踪或改动过的文件都有输出(见 Current evidence),判为 FAIL。重新冻结只能经新的冻结提交,所以这次提交就是当前的冻结提交。再核对顶层每份证据的 `params` 都与它相同。当前冻结之后的每次运行,证据都留在该目录顶层;重新冻结时,旧证据整体移入 `superseded-*` 目录(见 Performance budget)。没有任何副本时,`jq` 先为 `latest.json` 输出 `true`,再因打不开未展开的 glob 以 2 退出,退出码不为 0,本行不通过。 +- [x] `source .env.test && jq -e -s --slurpfile p examples/view_bench.params.json 'def crit($q): $q.data.h_max as $h | (($h + $q.views_config.batch_size - 1) / $q.views_config.batch_size | floor) as $nb | (.cold_start[] | select(.h == $h)) as $c | [(.incremental | group_by([.view, .probe_class])[] | map(.samples | map(.statements)) | unique | length == 1), (.incremental | group_by([.view, .probe_class])[] | (map(.p95_us) | max / min) <= $q.thresholds.k_h), (.incremental[] | select(.h == $h) | .view as $v | .p95_us <= $q.thresholds.k_b * (($c.views[] | select(.view == $v) | .elapsed_us) / $nb)), ($c.views[] | .statements + $c.root_chain.statements <= $q.thresholds.c_cold * $nb), .criteria.a.pass, .criteria.b.pass, .criteria.c.pass, .criteria.d.pass] | all; $p[0] as $q | .[0].env.code as $v | all(.[] | select(.env.code == $v); crit($q))' target/tmp/view_bench/latest.json target/tmp/view_bench/run-*.json`:退出码 0 且输出 `true`(AC 2–5)。读 `latest.json` 与顶层全部带时间戳的副本,取 `env.code` 与 `latest.json` 相同的每一份,即计入判定的证据。对其中每一份,四项判据都由 `jq` 从原始样本独立重算,同时要求基准自己的 `.criteria..pass` 都为真。所以同一代码版本下只要有一次运行 FAIL,本行就不通过,之后重跑出 PASS 也不能改变(设计 §7.4「证据」:不得挑选)。(a) 按「视图 × 探针类」分组,三档第 i 次计量的语句数序列完全相同;(b) 按「视图 × 探针类」取三档 p95 的最大值除以最小值,与 `thresholds.k_h` 比较;(c) 按「视图 × 探针类」比较 H_max 档 p95 与 `k_b` × 该视图投影段总耗时 ÷ ⌈H_max/B⌉;(d) 按视图把 H_max 档根链构建与该视图投影的语句数相加,与 `c_cold` × ⌈H_max/B⌉ 比较。 +- [x] `source .env.test && jq -e -s --slurpfile p examples/view_bench.params.json 'def pct(q): map(.elapsed_us) | sort | .[(q * length | ceil) - 1]; def nb($l; $b): ($l + $b - 1) / $b | floor; def ok($q): $q.data.h_max as $h | $q.views_config.batch_size as $b | (.cold_start | map(.h)) as $hs | (.env.code | (.revision | test("^([0-9a-f]{40}|[0-9a-f]{64})$")) and (.worktree | type) == "array" and all(.worktree[]; .[3:] | IN("src/lib.rs", "examples/view_bench.rs")) and (.sha256 | keys) == ["examples/view_bench.rs", "src/lib.rs"]) and $hs == [($h / 4 | floor), ($h / 2 | floor), $h] and all(.cold_start[]; .h as $x | (.root_chain | has("elapsed_us") and has("statements") and .batches == nb($x; $b)) and (.views | map(.view)) == $q.views and all(.views[]; has("elapsed_us") and has("statements") and .batches == nb($x; $b) and .commits_per_sec > 0) and (.relation_sizes | keys) == ["mega_view_commit_map", "mega_view_object", "mega_view_object_ref", "mega_view_root_chain"]) and (.incremental | length) == 18 and (.incremental | map([.h, .view, .probe_class]) | unique | length) == 18 and all(.incremental[]; (.h | IN($hs[])) and (.view | IN($q.views[])) and (.probe_class | IN($q.probes[0] | keys[])) and (.warmup | length) == $q.runs.w and (.samples | length) == $q.runs.n and all((.warmup + .samples)[]; has("elapsed_us") and has("statements")) and .p50_us == (.samples | pct(0.5)) and .p95_us == (.samples | pct(0.95))) and all(.criteria.a, .criteria.b, .criteria.c, .criteria.d; has("value") and has("threshold") and (.violations | type) == "array" and .pass == (.violations | length == 0)) and (.scale_limits | (.cold_start | has("elapsed_us") and has("statements") and .batches == nb($h + 6 * ($q.runs.w + $q.runs.n); $b) and has("spine_trees")) and (.warmup | length) == $q.runs.w and (.samples | length) == $q.runs.n and all((.warmup + .samples)[]; has("elapsed_us") and has("statements") and has("spine_trees")) and .p50_us == (.samples | pct(0.5)) and .p95_us == (.samples | pct(0.95))); $p[0] as $q | .[0] as $l | any(.[1:][]; . == $l) and all(.[]; ok($q))' target/tmp/view_bench/latest.json target/tmp/view_bench/run-*.json`:退出码 0 且输出 `true`(AC 6)。`latest.json` 有内容相同的带时间戳副本;`latest.json` 与顶层每一份副本都逐项符合 Performance budget「证据字段」:`env.code` 齐全,`worktree` 只涉及 `src/lib.rs`、`examples/view_bench.rs` 两条路径;`cold_start[]` 为三档,H 值为 {⌊H_max/4⌋, ⌊H_max/2⌋, H_max},每档都有 `root_chain` 段、按参数文件顺序的两个视图投影段(吞吐大于 0)与四张表的 `relation_sizes`,各段 `batches` 等于名义批数;`incremental[]` 恰有 18 项,且是 3 档 × 2 个视图 × 3 类探针的 18 个不同的组,重复的组判为不通过;每组 w 次预热与 n 次计量的逐次样本都带 `elapsed_us` 与 `statements`,`p50_us`、`p95_us` 等于按最近秩法的重算值;每个 `criteria.` 都有 `value`、`threshold`、`violations` 数组与 `pass`,`pass` 为真当且仅当 `violations` 为空;`scale_limits` 有带 `spine_trees` 与名义批数的 `cold_start` 段、逐次带 `spine_trees` 的样本,以及等于重算值的 p50 与 p95。 +- [x] `source .env.test && jq -e -s --slurpfile p examples/view_bench.params.json '$p[0].scale_limits as $s | .[0].env.code as $v | all(.[] | select(.env.code == $v) | .scale_limits; .compose_members == $s.caps.compose_members and .exclude_selectors == $s.caps.exclude_selectors and .k == $s.caps.k and .p95_us <= $s.p95_budget_us)' target/tmp/view_bench/latest.json target/tmp/view_bench/run-*.json`:退出码 0 且输出 `true`(AC 7)。与第 3 行相同,对每份计入判定的证据判定,同一代码版本下一次超出预算即不通过。三个计数由基准从规范化后的过滤器算出,与冻结参数 `scale_limits.caps` 逐项比较,不写死数值;基准在校验参数时已核对 `caps` 等于代码常量 `REGISTER_SCALE_LIMITS`,不等时以 2 退出,不写 `latest.json`。预算取自冻结参数;`p95_us` 与样本的一致性由第 4 行核对。 +- [x] `source .env.test && bash -c 'out=$(cargo run --release --example view_bench -- --params examples/view_bench.params.json --inject-failure after-migrate 2>&1); rc=$?; if [ "$rc" -eq 2 ] && printf "%s" "$out" | grep -qF "view_bench: injected failure after-migrate"; then echo OK; else echo "FAIL: exit $rc"; exit 1; fi'`(new):退出码 0 且输出 `OK`(AC 8 的出错路径)。退出码为 2 且输出含注入标记,才说明第一个 schema 确已建好并迁移完成,失败发生在注入点。参数、数据或残留检查失败时也以 2 退出,但没有标记,判为 FAIL;编译失败时退出码为 101,同样判为 FAIL。本次运行写出的 `failed-*.json` 照样列入执行记录。 +- [x] `source .env.test && test "$(docker compose -p mega2-it -f docker/docker-compose.test.yml exec -T postgres psql -U mega2 -d mega2 -Atc "select count(*) from pg_namespace where starts_with(nspname, 'view_bench_')")" = 0`:退出码 0(AC 8)。第 6 行之后没有残留的 `view_bench_` schema。`psql` 在容器内运行,不依赖宿主客户端,连接串不进证据。 +- [x] `source .env.test && rg -U --pcre2 -n '^### 7\.2 规模估算(纯算术推导,P0 实测见本节末条)$(?:\n(?!### ).*)*\n- \*\*P0 基准实测(HP-24)。\*\*.*(?:\n(?!### |- ).*)*\n### ' docs/refactoring/history-projection.md`:有命中,退出码 0。本行核对 §7.2 的标题已改,实测条目位于 §7.2 内且是该节最后一个顶层条目;`rg` 执行失败时退出码大于 1,同样不通过。由发布者在发布窗口写入设计 §7.2 之后执行。 **Dependencies:** `HP-12`(消费 `catch_up` 的定型版本:批事务、部分推进与「停止」结果,基准把「已就绪」以外的结果当作出错,以 2 退出;经其上游链,消费 `ViewProjectionService::new(storage, metrics)` 与 `ViewMetrics`、`catch_up` 的结果枚举、`ViewsConfig` 的 `batch_size`、过滤器的解析与规范化(canonical AST、canonical 文本与 `filter_id`)、注册期校验与 `RegistrationCheck`、规模上限常量 `REGISTER_SCALE_LIMITS`,以及 `mega_view_filter` 实体)、`HP-28`(`src/jupiter/storage/view_root_chain.rs` 在 HP-28 之后定型,HP-28 可能就地修正根链扩展在不连续之后的性质;基准计量定型后的 `extend_root_chain` 及其三态结果;经其上游链,消费 `budget = None` 与试锁模式,以及视图表迁移)、`FIX-HP-01`(ER-10 前置:冻结运行的 (c)(d) FAIL;修复根链批写、单提交快路与追赶末批终态后,本卡才能用同一冻结参数重跑)、`DEP-HP-02`(git.git 数据:只用「归档文件 + sha256」方式,数据文件为原始 pack;开工时写入参数文件的 tag、peeled 提交 id、路径、sha256 与生成所用的 `git --version`) @@ -5471,23 +5472,30 @@ ER-04 C 组五条精确命令和两个零警告构建门另执行,不计入本 | 项 | 取值 | |---|---| -| 数据 | git.git 的 tag 与 peeled 提交 id(待定,DEP-HP-02)。数据文件为原始 pack(`data.format = "pack"`),在 git.git 的克隆中以 `printf '%s\n' \| git pack-objects --revs --filter=blob:none --no-reuse-delta --no-reuse-object --threads=1 --window=10 --depth=50 --stdout` 生成:不复用源克隆中已有的 delta 与对象,单线程搜索 delta,窗口与深度显式给出,使输出不受源克隆的 pack 布局与本机 `pack.*` 配置影响。生成时 `git --version` 的输出写入 `data.git_version`;同一 git 版本下重新生成可望得到相同字节,但不作保证,丢失时按「保管」处理。文件放在 `target/tmp/view_bench/data/` 下;路径与 sha256 待定。不用本地镜像方式。取该提交的首父链,按 Description 线性化,链长为 H_max(待定,由数据得出,写入 `data.h_max`) | +| 数据 | 冻结提交 `7acc2b569475c137f905496438027fa174b002f0`;git.git tag `v0.99.5` 的 peeled 提交 `3857284f7b892f855edffc5b9c196a0dd74b1b7d`。数据文件为原始 pack(`data.format = "pack"`),在 git.git 的克隆中以 `printf '%s\n' \| git pack-objects --revs --filter=blob:none --no-reuse-delta --no-reuse-object --threads=1 --window=10 --depth=50 --stdout` 生成:不复用源克隆中已有的 delta 与对象,单线程搜索 delta,窗口与深度显式给出,使输出不受源克隆的 pack 布局与本机 `pack.*` 配置影响。生成工具为 `git version 2.54.0 (Apple Git-157)`;同一 git 版本下重新生成可望得到相同字节,但不作保证,丢失时按「保管」处理。文件 `target/tmp/view_bench/data/git-v0.99.5.pack`,SHA-256 `812723cd7e7c540101e3cb297a7dc4f14f18f407cd68d76452e97321e2992003`。不用本地镜像方式。取该提交的首父链,按 Description 线性化,H_max = 1231 | | H 三档 | {⌊H_max/4⌋, ⌊H_max/2⌋, H_max},取同一条链的前缀,每档一个 schema | | `[views]` | `batch_size` B = 1000,其余取设计 §6.9 的缺省值 | | 视图 | `:/Documentation`、`:exclude[::t/]`;`views` 数组中的下标 v(0、1)即探针的视图下标 | -| 探针 | 每个视图各有一组三类探针,`probes[v].[i]` 是视图 v 第 i 次(i = 0…w + n − 1,前 w 次为预热)的探针,每条写明 `path` 与 `content`(待定),三档相同。`in_view` 只改视图内文件,路径都在 `Documentation/` 下,所以同时改变两个视图的 tree;`outside` 只改两个视图之外的文件;`t_only` 只改 `t/` 下的文件。`:exclude[::t/]` 之外的路径只在 `t/` 下,所以 `outside` 也落在 `t/` 下,`outside` 与 `t_only` 用不同的文件。全部 `(path, content)` 两两不同。基准校验这些规则,不符按参数错误以 2 退出;写入每个探针提交时还核对新的根 tree 与父提交的 tree 不同。探针只写新的 tree 与提交 | +| 探针 | 每个视图各有一组三类探针,`probes[v].[i]` 是视图 v 第 i 次(i = 0…54,前 5 次为预热)的探针,每条 `path` 与 `content` 的 330 组冻结值逐条见 `examples/view_bench.params.json`。路径分别按 `Documentation/view-bench-in_view-v{v}-{i:02}.txt`、`t/view-bench-outside-v{v}-{i:02}.txt`、`t/view-bench-t_only-v{v}-{i:02}.txt`,内容分别按 `view benchmark view sample \n` 冻结;三档相同。`in_view` 同时改变两个视图的 tree;`outside` 与 `t_only` 都落在 `t/`,用不同文件。全部 `(path, content)` 两两不同。基准校验这些规则,不符按参数错误以 2 退出;写入每个探针提交时还核对新的根 tree 与父提交的 tree 不同。探针只写新的 tree 与提交 | | 计量顺序 | 每档依次执行:① 根链冷启动;② 按 `views` 顺序逐个视图冷启动;③ 常规探针:外层按探针类 `in_view`、`outside`、`t_only`,中层按 i,内层按视图下标 v。每一步先不计时地写入 `probes[v].[i]` 的探针根提交并 CAS `main@/`,再计时调用根链扩展与视图 v 的 `catch_up`,最后不计时地对另一个视图调用 `catch_up`。所以每个探针提交只为一个视图计时,每次计时的根链扩展都恰好接入一个新的根提交,计时前两个视图都已追平到探针的父提交;④ 只在 H_max 档执行规模上限复核(见该行)。三档用同一顺序与同一份探针 | | 次数 | 每档、每个视图、每类探针预热 w = 5 次,计量 n = 50 次 | -| 环境 | CPU 型号与核数、内存、存储类型、操作系统(待定)。Postgres 为 `postgres:18.6-alpine3.24`,参数来源 `docker/docker-compose.test.yml:2-15`(`shm_size: 1gb`、`max_connections=500`,其余为镜像缺省)。不建立 Redis 连接,独占运行 | +| 环境 | Apple M4 Max、16 核、128 GiB、USB SSD、macOS 27.0.1。Postgres 为 `postgres:18.6-alpine3.24`,实测 server_version `18.6`;参数来源 `docker/docker-compose.test.yml:2-15`(`shm_size: 1gb`、`max_connections=500`,其余为镜像缺省)。不建立 Redis 连接,独占运行 | | 阈值 | k_H = 1.5,k_B = 1,c_cold = 50 | | 判据 (d) 的口径 | 按视图计:每个视图的冷启动语句数取根链构建段与该视图投影段之和,与 c_cold × ⌈H_max/B⌉ 比较。理由:设计 §7.4 的冷启动按视图分别计时,一次注册只触发一个视图的冷启动;设计 §7.5 P0 验收 11(d) 括号中的「视图投影」指该视图的投影段。根链每档只建一次,它的语句数计入每个视图,比只计投影段更严 | -| 规模上限复核 | 顶格过滤器:顶层为 Compose;规范化后的 Compose 成员总数、Exclude 选择器总数与 k 分别等于参数文件 `scale_limits.caps` 的 `compose_members`、`exclude_selectors`、`k`。`caps` 冻结时取代码常量 `REGISTER_SCALE_LIMITS` 的现行值(核对日的设计值为 64、256、64,设计 §6.5);基准在校验参数时核对 `caps` 与该常量逐项相等,不等按参数错误以 2 退出。各成员的源路径与输出路径两两不相交,规范化后的文本不超过 16 KiB,并通过注册期校验;文本待定,写入参数文件。`probe_paths` 共 `caps.k` 条,每个源路径之下一条。只在 H_max 档、常规探针全部结束之后执行,此时根链长为 H_max + 6 × (w + n)。先插入该过滤器行并冷启动,写入 `scale_limits.cold_start`,不进入 `cold_start[]`。再按 i = 0…w + n − 1 计量探针:每步先不计时地写入在每条 `probe_paths` 上各改一个文件的探针根提交(内容取模板 `content`,`{i}` 替换为 i)并 CAS,再计时调用根链扩展与该视图的 `catch_up`;两个常规视图不再追赶。预算:单提交增量 p95 ≤ 1 秒(`p95_budget_us = 1000000`,ER-03 时确认后冻结)。脊柱 tree 数只记录:冷启动与每个样本各记一个 `spine_trees`,取该次调用前后 `mega_view_object` 中 `kind = 2` 的行数之差,查询在计时区间之外 | +| 规模上限复核 | 顶格过滤器:顶层为 Compose;规范化后的 Compose 成员总数、Exclude 选择器总数与 k 分别等于参数文件 `scale_limits.caps` 的 `compose_members`、`exclude_selectors`、`k`。`caps` 冻结时取代码常量 `REGISTER_SCALE_LIMITS` 的现行值(核对日的设计值为 64、256、64,设计 §6.5);基准在校验参数时核对 `caps` 与该常量逐项相等,不等按参数错误以 2 退出。各成员的源路径与输出路径两两不相交,规范化后的文本不超过 16 KiB,并通过注册期校验;冻结文本为 `examples/view_bench.params.json` 的 `scale_limits.filter`(5122 字符,64 个 `benchsrc00`…`benchsrc63` 源目录、每成员 4 个 Exclude 选择器);`probe_paths` 为 `benchsrc00/benchmark.txt`…`benchsrc63/benchmark.txt` 共 64 条,每个源路径之下一条,`content` 模板为 `view benchmark scale sample {i}\n`。只在 H_max 档、常规探针全部结束之后执行,此时根链长为 H_max + 6 × (w + n)。先插入该过滤器行并冷启动,写入 `scale_limits.cold_start`,不进入 `cold_start[]`。再按 i = 0…w + n − 1 计量探针:每步先不计时地写入在每条 `probe_paths` 上各改一个文件的探针根提交(内容取模板 `content`,`{i}` 替换为 i)并 CAS,再计时调用根链扩展与该视图的 `catch_up`;两个常规视图不再追赶。预算:单提交增量 p95 ≤ 1 秒(`p95_budget_us = 1000000`,ER-03 时确认后冻结)。脊柱 tree 数只记录:冷启动与每个样本各记一个 `spine_trees`,取该次调用前后 `mega_view_object` 中 `kind = 2` 的行数之差,查询在计时区间之外 | | 调用方式 | `extend_root_chain` 按后台 worker 的方式调用:`budget = None`,`batch_size` = B,试锁模式;独占运行时只可能返回「已追平」或「不连续」。不以 `max_append_walk` 为预算。`catch_up(filter_pk)` 应返回「已就绪」 | | 计量口径 | 设计 §7.4「计量口径」:直接调用根链扩展与 `catch_up`,不启动 worker,不经信号。单提交增量从调用根链扩展开始计时,到该视图的 `catch_up` 事务提交为止。冷启动先单独计时根链构建,再分别计时每个视图从空的派生表投影到 `ready_seq` 非空。计时只用 `std::time::Instant` | | 百分位 | p50、p95 取 `samples[]` 的 `elapsed_us` 升序排列后的第 ⌈q × n⌉ 个(最近秩法,q 为 0.5 或 0.95,从 1 数起);`warmup[]` 不参与 | | 参数文件结构 | 顶层键:`data`(`tag`、`commit`、`format`、`path`、`sha256`、`h_max`、`git_version`);`views_config`(`batch_size`);`views`(两个过滤器文本,数组顺序即证据中的视图顺序);`probes`(与 `views` 等长的数组,第 v 项含 `in_view`、`outside`、`t_only` 三个键,各为 w + n 个 `{path, content}`,第 i 个即第 i 次);`runs`(`w`、`n`);`thresholds`(`k_h`、`k_b`、`c_cold`);`scale_limits`(`caps`(`compose_members`、`exclude_selectors`、`k`)、`filter`、`caps.k` 条 `probe_paths`、含 `{i}` 的内容模板 `content`、`p95_budget_us`);`env`(`cpu`、`cores`、`memory`、`storage`、`os`、`postgres_image`)。冻结之前,待定项写作字符串 `TBD`。`data.path` 是仓库相对路径,位于 `target/tmp/view_bench/data/` 之下 | | 证据字段 | `params`:参数文件原样。`env`:硬件项、`server_version`、`params_path`(见 Security),以及 `code`:`revision` 为 `libra rev-parse HEAD` 的输出;`worktree` 为 `libra status --short src examples Cargo.toml Cargo.lock` 的输出行组成的数组,可以为空;`sha256` 是以 `src/lib.rs`、`examples/view_bench.rs` 为键的对象,值为文件内容的 sha256 小写 hex。`batches`:名义批数 ⌈L/B⌉,L 为该段处理的根链提交数。`cold_start[]` 的根链段与视图段都取该档的 h,`scale_limits.cold_start` 取 H_max + 6 × (w + n)。它由参数算出,不实测:`extend_root_chain`(`budget = None`)与 `catch_up` 都在内部逐批循环,`ViewMetrics` 也没有批次计数,经接缝测不到实际批数。它不参与判据,Verification 第 3 行从参数文件独立计算 ⌈H_max/B⌉,第 4 行核对它等于上述算式。`cold_start[]`:按 H 升序三项,每项含 `h`、`root_chain`(`elapsed_us`、`statements`、`batches`)、按参数文件视图顺序的 `views[]`(`view`、`elapsed_us`、`statements`、`batches`、`commits_per_sec`),以及 `relation_sizes`(恰为 `mega_view_root_chain`、`mega_view_commit_map`、`mega_view_object`、`mega_view_object_ref` 四张表冷启动后的 `pg_total_relation_size`)。`incremental[]`:恰 18 组(3 档 × 2 个视图 × 3 类),每组含 `h`、`view`、`probe_class`、按 i 顺序的 `warmup[]` 与 `samples[]`(逐次 `elapsed_us` 与 `statements`)、`p50_us`、`p95_us`。`criteria.{a,b,c,d}`:各含 `value`、`threshold`、`pass` 与违例分组的数组 `violations`,`pass` 为真当且仅当 `violations` 为空。`scale_limits`:`compose_members`、`exclude_selectors`、`k`、`cold_start`(`elapsed_us`、`statements`、`batches`、`spine_trees`)、`warmup[]` 与 `samples[]`(逐次 `elapsed_us`、`statements`、`spine_trees`)、`p50_us`、`p95_us`。`latest.json` 与同次运行的带时间戳副本内容相同。以 2 退出的运行只写失败记录 `failed-YYYYMMDDTHHMMSSZ.json`,含 `exit_code`(2)与 `reason`,不含计量数据 | +**执行记录(2026-10-08):** 冻结参数的提交与时间:`7acc2b569475c137f905496438027fa174b002f0 2026-10-08T03:16:49+08:00`(`libra log -n 1 --format='%H %cI' examples/view_bench.params.json`);H=307/615/1231,B=1000,w=5,n=50,阈值 k_H=1.5、k_B=1、c_cold=50,顶格上限 64/256/64 与 p95 预算 1000000 µs。冻结参数中的数据、探针及环境取值见上表与文件本身;设计 §7.2 登记各档冷启动、18 组增量、表大小与顶格过滤器的实测汇总。 + +- 顶层全部有效运行:`target/tmp/view_bench/run-20261007T192312Z.json`(`env.code.revision=7acc2b569475c137f905496438027fa174b002f0`,a/b PASS、c/d FAIL,顶格 p95 60059 µs);`target/tmp/view_bench/run-20261007T193500Z.json`(同一 revision,a/b PASS、c/d FAIL,顶格 p95 65713 µs);`target/tmp/view_bench/run-20261007T213147Z.json`(`env.code.revision=d2ac0beb0effa5fa1744d0c4cf1d8865ad39a5a7`,FIX-HP-01 发布后,同一冻结参数,a/b/c/d 与顶格预算全部 PASS,顶格 p95 27295 µs)。`target/tmp/view_bench/latest.json` 与最后这份带时间戳的副本内容相同。按同一 `env.code` 的全部运行计入判定,没有挑选旧失败中的某次成功。 +- 顶层全部失败记录:`target/tmp/view_bench/failed-20261007T192015Z.json`(初始 root CAS 未命中,以 2 退出,无计量数据);`target/tmp/view_bench/failed-20261007T213236Z.json`(`after-migrate` 注入,以 2 退出,schema 清理成功)。没有 `superseded-*` 目录。两份失败记录及三份有效运行的 UTC 文件时间均晚于冻结提交的 UTC 时间。 +- 首次有效运行的 (c)(d) FAIL 按 ER-10 新建并发布 FIX-HP-01(`v0.42.23`,代码提交 `96853378beaee2f2ed79e644f75257e39f01da00`);其后同一参数在原工作树、正确的 `CARGO_MANIFEST_DIR` 下重跑,`env.code` 绑定 `d2ac0be` 与 `src/lib.rs`、`examples/view_bench.rs` 两个 SHA-256。隔离诊断工作树曾误用 Cargo 缓存的 example 可执行文件,留下 `run-20261007T212735Z.json` 在隔离目录;它的 (c) FAIL(`:/Documentation` 三类探针比值约 1.35),与原工作树顶层集合及被测 revision 不同,未用于本卡判定。正式 PASS 的 (c) 最大比值为 0.88864;该项依赖 H_max 档的一次冷启动、按两批取均值,不能外推成稳定的生产余量。正式重编译日志为 `target/tmp/view_bench/formal-rebuild-r3.log`,正式运行日志为 `target/tmp/view_bench/formal-fix-r2.log`。 +- 第 1 项正式运行的 `target/tmp/view_bench/formal-fix-r2.rc` 与第 2–8 项的 `target/tmp/view_bench/preflight/hp24-ver-{2,3,4,5,6,7,8}.rc` 均为 0;第 3、5 项只按 `latest.json` 的 `env.code` 计入判定,第 2、4 项覆盖顶层全部有效运行,第 6 项注入与第 7 项 schema 清理通过,第 8 项设计 §7.2 结构命中。Claude HP24-R1 与 Codex HP24-R2 双审 `VERDICT: PASS`。版本 `0.42.24` 树的 `cargo +nightly fmt --all --check`、`cargo clippy --all-targets --all-features -- -D warnings`、`source .env.test && cargo test --all` 均退出 0;全量测试 19 个目标全部通过,lib 2043 passed、1 项既有 ignored。`RUSTFLAGS=-C link-arg=-Wl,-no_warn_eh_frame_too_large` 下的 `cargo build`、`cargo build --tests` 均退出 0 且无警告;日志与 rc 均在 `target/tmp/view_bench/preflight/hp24-*`。 + **Estimated scope:** `M`(G-04:落点 2 个,即 `examples/` 与 `src/lib.rs`;生产文件 3 个:`examples/view_bench.rs`、`examples/view_bench.params.json`、`src/lib.rs`。计数在 S 的上限内,但 `pub mod view_bench_ops` 是 lib 的新 pub 项,虽为 `#[doc(hidden)]`,仍按一处公开接口变化计,所以取 M。HTTP、CLI、配置与协议都不变) **Version increment:** `patch` @@ -5496,7 +5504,7 @@ ER-04 C 组五条精确命令和两个零警告构建门另执行,不计入本 **C/D coverage from:** `self`。D 组为 `.github/workflows/docker.yml` 的 `docker` job,由 `v` tag 推送触发。 -**Granularity:** `type=implementation; axis=P0 性能门(冻结协议下的投影计量与判定); recovery=前滚新 patch(example 不进 mega2 二进制,接缝只转发、包装与装配。判据或预算不满足时按 ER-10 新建 FIX-HP-*:修复不改 REGISTER_SCALE_LIMITS 时用同一份冻结参数重跑,下调该常量时本卡经 ER-03 修订、在新上限处重新冻结并把旧证据移入 superseded-*); complete=yes; self-contained=yes; AC=8/8; VER=8/8; landing=2; prod-files=3; scope=M; deps=HP-12,HP-28,DEP-HP-02; writeset=no-overlap; release=independent; split-from=N/A; exception=N/A` +**Granularity:** `type=implementation; axis=P0 性能门(冻结协议下的投影计量与判定); recovery=前滚新 patch(example 不进 mega2 二进制,接缝只转发、包装与装配。判据或预算不满足时按 ER-10 新建 FIX-HP-*:修复不改 REGISTER_SCALE_LIMITS 时用同一份冻结参数重跑,下调该常量时本卡经 ER-03 修订、在新上限处重新冻结并把旧证据移入 superseded-*); complete=yes; self-contained=yes; AC=8/8; VER=8/8; landing=2; prod-files=3; scope=M; deps=HP-12,HP-28,FIX-HP-01,DEP-HP-02; writeset=no-overlap; release=independent; split-from=N/A; exception=N/A` ### Task HP-25: Libra 客户端访问视图 URL 的 smoke @@ -6187,6 +6195,8 @@ Result 只允许 `PASS` 或 `FAIL`。`FAIL` 必须列出 P0/P1 条目,并在 | FIXHP01-CODE-R1 | FIX-HP-01 根链批写、单提交快路与追赶末批终态,Claude CLI 只读审查 | PASS | P0 0;P1 0 | 指出缺失链尾提交行时快路可能掩盖 MissingFirstParent;已加 EXISTS 条件、回退用例与设计说明,R2 复核关闭 | `target/tmp/view_bench/preflight/fix-hp01-code-review.txt` | | FIXHP01-CODE-R2 | FIX-HP-01 修订实现,Claude CLI 只读复审 | PASS | P0 0;P1 0 | 缺失链尾提交行的回退与暂存证据已核对;根链 16/16、投影 15/15 定向测试在最终源码上通过 | `target/tmp/view_bench/preflight/fix-hp01-code-review-r2.txt`、`fix-root-tests-r3.log`、`fix-projection-tests-final.log` | | FIXHP01-CODE-R3 | FIX-HP-01 提交 `9685337`,Codex CLI 独立只读复核 | PASS | P0 0;P1 0 | 锁内快路、预算保留、批写冲突回退和末批提交后返回均无阻断问题;第二 Claude 视角也 PASS | `target/tmp/view_bench/preflight/fix-codex-review.txt`、`fix-second-review.txt`;`VERDICT: PASS` | +| HP24-R1 | HP-24 冻结基准、正式证据、设计 §7.2 与任务卡,Claude CLI 独立只读复核 | PASS | P0 0;P1 0 | P2:隔离诊断目录的 (c) FAIL 已在执行记录明示;(c) 对单次冷启动的敏感性已记录。探针校验只需满足卡片约束,冻结文件的具体模板已登记;卡状态在发布窗口更新 | `target/tmp/view_bench/preflight/hp24-claude-review-r1.txt`;`VERDICT: PASS` | +| HP24-R2 | HP-24 计量口径、证据来源、四项判据、顶格上限与清理,Codex CLI 独立只读复核 | PASS | P0 0;P1 0 | 卡状态与验收勾选留待本卡发布窗口;正式证据的 (b) 1.24145、(c) 0.88864、(d) 61/100 与顶格 p95 27295/1000000 已独立核算 | `target/tmp/view_bench/preflight/hp24-codex-review-r1.txt`;`VERDICT: PASS` | ## 非目标与延后项 diff --git a/docs/refactoring/history-projection.md b/docs/refactoring/history-projection.md index 7edfb06f..e131fa9d 100644 --- a/docs/refactoring/history-projection.md +++ b/docs/refactoring/history-projection.md @@ -1365,7 +1365,7 @@ P1 的清扫周期,以及闲置阈值 `idle_recycle_secs`(下界为 2T = 720 - 批量写入的块大小为 `BATCH_CHUNK_SIZE = 1000`,带死锁重试(`base_storage.rs`)。 - **Josh【代码】:** `walk2` 用 `known()` 剪枝;`history.rs` 中有一个单槽的"最近父提交树"缓存,专门优化线性历史。 -### 7.2 规模估算(纯算术推导,未实测) +### 7.2 规模估算(纯算术推导,P0 实测见本节末条) - **根链表。** H 行,每行约 8 + 41 + 41 B,加上 Postgres 元组头、行指针和对齐约 30 B;再算上主键与 `commit_id` 唯一索引,约 250 B/行。H=10⁶ 时约 250 MB。回走暂存表只在扫描期间有行:冷启动时峰值约 H 行,与根链表同一量级,接入后删除。 - **commit_map。** 每个视图的行数 = 该视图的 NEW 提交数 ≤ H。按 hex 文本 id 计,堆约 136 B/行;两个索引合计约 140 B(叶页填充率按 70%)。合计约 280–300 B/行。 - **mega_view_object + mega_view_object_ref。** 每个 NEW 视图提交对应: @@ -1376,6 +1376,24 @@ P1 的清扫周期,以及闲置阈值 `idle_recycle_secs`(下界为 2T = 720 - **示例**(H=10⁶,V=100,每个视图的 NEW 提交平均占 5%,即 5×10⁶ 个 NEW 提交):派生表合计约 4.7–8.3 GB,主要来自对象表。最坏情况是每个视图都覆盖几乎全部改动,此时 NEW 提交达 10⁸ 个,约 95–165 GB。如果采用 3.5 的"不持久化视图提交字节"优化,提交对象这部分可以基本消除。 - **单个新根提交对一个 Subdir 视图的开销。** 按层批量读 d 次 tree(d 为路径深度),最多新增一行映射。 - **冷启动。** 共 H/B 批,每批 O(1) 次提交查询,加 O(深度) 次批量 tree 读取。吞吐在 P0 基准中实测。 +- **P0 基准实测(HP-24)。** 运行 `source .env.test && cargo run --release --example view_bench -- --params examples/view_bench.params.json`;参数冻结于 `7acc2b569475c137f905496438027fa174b002f0`,数据是 git.git v0.99.5 的 `3857284f7b892f855edffc5b9c196a0dd74b1b7d` 原始 pack(SHA-256 `812723cd7e7c540101e3cb297a7dc4f14f18f407cd68d76452e97321e2992003`)。计入判定的运行是 `target/tmp/view_bench/run-20261007T213147Z.json`,被测 HEAD `d2ac0beb0effa5fa1744d0c4cf1d8865ad39a5a7`,接缝与 example 的 SHA-256 见该证据 `env.code`。环境:Apple M4 Max 16 核、128 GiB、USB SSD、macOS 27.0.1、Postgres 18.6。H 三档为 307 / 615 / 1231,B=1000;每组 5 次预热、50 次样本;以下时间单位为 µs,大小单位为字节。 + | H | 根链冷启动耗时 / SQL | `:/Documentation` 冷启动耗时 / SQL / commits/s | `:exclude[::t/]` 冷启动耗时 / SQL / commits/s | 根链 / commit_map / object / object_ref 大小 | + |---:|---:|---:|---:|---:| + | 307 | 60082 / 19 | 34300 / 14 / 8950 | 53589 / 17 / 5729 | 139264 / 172032 / 262144 / 147456 | + | 615 | 41728 / 19 | 31397 / 17 / 19588 | 75839 / 17 / 8109 | 221184 / 253952 / 1466368 / 294912 | + | 1231 | 77556 / 27 | 93874 / 31 / 13113 | 217212 / 34 / 5667 | 368640 / 442368 / 5545984 / 704512 | + + 单提交增量每格为 `p50 / p95 / SQL`,SQL 是该组 50 个样本共同的语句数。视图 0 为 `:/Documentation`,视图 1 为 `:exclude[::t/]`。 + | H | 视图 | in_view | outside | t_only | + |---:|---:|---:|---:|---:| + | 307 | 0 | 23443 / 31431 / 23 | 22242 / 43777 / 19 | 21036 / 37593 / 19 | + | 307 | 1 | 24617 / 36936 / 23 | 21687 / 42749 / 19 | 20525 / 38388 / 19 | + | 615 | 0 | 22700 / 38126 / 23 | 21902 / 39527 / 19 | 21402 / 41113 / 19 | + | 615 | 1 | 22974 / 40108 / 23 | 22496 / 44565 / 19 | 20740 / 41269 / 19 | + | 1231 | 0 | 24739 / 39020 / 23 | 26077 / 41710 / 19 | 24760 / 38769 / 19 | + | 1231 | 1 | 24171 / 38809 / 23 | 27341 / 39856 / 19 | 23692 / 42684 / 19 | + + 判据 (a) PASS(跨档逐样本 SQL 相等);(b) PASS(最大 p95 比 1.24145 ≤ 1.5);(c) PASS(最大投影批均耗时比 0.88864 ≤ 1);(d) PASS(每视图最多 61 ≤ 100 条 SQL)。顶格过滤器上限 Compose 64、Exclude 256、k=64;冷启动 950462 µs / 24 SQL / 0 个脊柱 tree;增量 p50=23893 µs、p95=27295 µs ≤ 1000000 µs,样本均为 25 SQL、1 个脊柱 tree。H=307 时 `Documentation/` 尚未出现,顶格源路径在该历史中也未命中;这些数据只支持冻结夹具下的 P0 判据,不外推至更长历史或真实顶格命中负载。 ### 7.3 风险 | 风险 | 说明 | 缓解 | diff --git a/examples/view_bench.rs b/examples/view_bench.rs new file mode 100644 index 00000000..517a4fc6 --- /dev/null +++ b/examples/view_bench.rs @@ -0,0 +1,1426 @@ +//! Opt-in history projection benchmark. Run only with a frozen params file. + +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + fs, + io::{BufReader, ErrorKind, Read, Write}, + path::{Path, PathBuf}, + process::Command, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicU64, Ordering}, + }, + time::Instant, +}; + +use anyhow::{Context, Result, bail, ensure}; +use chrono::Utc; +use git_internal::{ + hash::{HashKind, ObjectHash}, + internal::{ + object::{ + ObjectTrait, + blob::Blob, + commit::Commit, + tree::{Tree, TreeItem, TreeItemMode}, + types::ObjectType, + }, + pack::Pack, + }, +}; +use mega2_core::{ + mega_refs, mega_view_filter, + view_bench_ops::{ + BenchHarness, Filter, REGISTER_SCALE_LIMITS, RootChainOutcome, database_connection, + db_config, generate_id, parse_for_registration, sort_git_tree_items, + validate_for_registration, + }, +}; +use regex::Regex; +use sea_orm::{ + ActiveModelTrait, ConnectionTrait, Database, DatabaseConnection, DbBackend, Set, Statement, + TransactionTrait, Value, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value as Json, json}; +use sha2::{Digest, Sha256}; +use url::Url; + +const OUTPUT_DIR: &str = "target/tmp/view_bench"; +const WORK_DIR: &str = "target/tmp/view_bench/work"; +const CLASSES: [&str; 3] = ["in_view", "outside", "t_only"]; +const VIEWS: [&str; 2] = [":/Documentation", ":exclude[::t/]"]; +const FAILURE_MARKER: &str = "view_bench: injected failure after-migrate (schema created)"; + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct Params { + data: DataParams, + views_config: ViewsConfigParams, + views: Vec, + probes: Vec, + runs: RunParams, + thresholds: Thresholds, + scale_limits: ScaleParams, + env: EnvParams, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct DataParams { + tag: String, + commit: String, + format: String, + path: String, + sha256: String, + h_max: usize, + git_version: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct ViewsConfigParams { + batch_size: usize, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct Probe { + path: String, + content: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct ProbeClasses { + in_view: Vec, + outside: Vec, + t_only: Vec, +} + +impl ProbeClasses { + fn get(&self, class: &str) -> &[Probe] { + match class { + "in_view" => &self.in_view, + "outside" => &self.outside, + "t_only" => &self.t_only, + _ => unreachable!(), + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct RunParams { + w: usize, + n: usize, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct Thresholds { + k_h: f64, + k_b: f64, + c_cold: u64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct ScaleCaps { + compose_members: usize, + exclude_selectors: usize, + k: usize, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct ScaleParams { + caps: ScaleCaps, + filter: String, + probe_paths: Vec, + content: String, + p95_budget_us: u64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct EnvParams { + cpu: String, + cores: usize, + memory: String, + storage: String, + os: String, + postgres_image: String, +} + +#[derive(Clone, Copy, Debug, Serialize)] +struct Sample { + elapsed_us: u64, + statements: u64, +} + +#[derive(Clone)] +struct MetricCounter { + active: Arc, + statements: Arc, +} + +impl MetricCounter { + fn install(connection: &mut DatabaseConnection) -> Self { + let counter = Self { + active: Arc::new(AtomicBool::new(false)), + statements: Arc::new(AtomicU64::new(0)), + }; + let callback = counter.clone(); + connection.set_metric_callback(move |_| { + if callback.active.load(Ordering::Relaxed) { + callback.statements.fetch_add(1, Ordering::Relaxed); + } + }); + counter + } + + async fn measure(&self, future: F) -> Result<(T, Sample)> + where + F: std::future::Future>, + { + self.statements.store(0, Ordering::Relaxed); + self.active.store(true, Ordering::Relaxed); + let start = Instant::now(); + let result = future.await; + let elapsed_us = start.elapsed().as_micros() as u64; + self.active.store(false, Ordering::Relaxed); + let statements = self.statements.load(Ordering::Relaxed); + Ok(( + result?, + Sample { + elapsed_us, + statements, + }, + )) + } +} + +fn main() { + if std::env::set_current_dir(env!("CARGO_MANIFEST_DIR")).is_err() { + record_failure("cannot enter manifest directory"); + std::process::exit(2); + } + std::panic::set_hook(Box::new(|_| eprintln!("view_bench: benchmark panic"))); + let args: Vec = std::env::args().collect(); + let mut params_path = None; + let mut inject_failure = false; + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--params" if i + 1 < args.len() => { + params_path = Some(args[i + 1].clone()); + i += 2; + } + "--inject-failure" if args.get(i + 1).is_some_and(|s| s == "after-migrate") => { + inject_failure = true; + i += 2; + } + _ => { + record_failure("invalid command line"); + std::process::exit(2); + } + } + } + let result = params_path + .as_deref() + .context("missing --params") + .and_then(|path| { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .context("runtime") + .and_then(|runtime| { + let path = path.to_owned(); + runtime.block_on(async move { + tokio::spawn(async move { run(&path, inject_failure).await }) + .await + .map_err(|_| anyhow::anyhow!("benchmark task panic"))? + }) + }) + }); + if let Err(error) = result { + let reason = redact_error(&error); + eprintln!("view_bench: {reason}"); + record_failure(&reason); + std::process::exit(2); + } +} + +fn redact_error(error: &anyhow::Error) -> String { + let mut reason = format!("{error:#}"); + if let Ok(secret) = std::env::var("MEGA_DATABASE__DB_URL") { + reason = reason.replace(&secret, "[redacted database URL]"); + if let Ok(url) = Url::parse(&secret) + && let Some(password) = url.password() + { + reason = reason.replace(password, "[redacted password]"); + } + } + if let Ok(url_pattern) = Regex::new(r#"(?i)postgres(?:ql)?://[^\s'"<>]+"#) { + reason = url_pattern + .replace_all(&reason, "[redacted database URL]") + .into_owned(); + } + reason +} + +fn record_failure(reason: &str) { + let _ = fs::create_dir_all(OUTPUT_DIR); + let bytes = json!({"exit_code": 2, "reason": reason}).to_string(); + loop { + let path = format!( + "{OUTPUT_DIR}/failed-{}.json", + Utc::now().format("%Y%m%dT%H%M%SZ") + ); + match fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + { + Ok(mut file) => { + let _ = file.write_all(bytes.as_bytes()); + break; + } + Err(error) if error.kind() == ErrorKind::AlreadyExists => { + std::thread::sleep(std::time::Duration::from_secs(1)); + } + Err(_) => break, + } + } +} + +async fn run(params_path: &str, inject_failure: bool) -> Result<()> { + let params_bytes = fs::read(params_path).context("read params")?; + ensure!( + !params_bytes.windows(3).any(|w| w == b"TBD"), + "params contain TBD" + ); + let params_json: Json = serde_json::from_slice(¶ms_bytes).context("parse params JSON")?; + let params: Params = serde_json::from_value(params_json.clone()).context("params shape")?; + validate_params(¶ms, params_path)?; + let code = code_version()?; + verify_pack(¶ms.data)?; + let admin_url = + std::env::var("MEGA_DATABASE__DB_URL").context("MEGA_DATABASE__DB_URL is required")?; + let admin = Database::connect(&admin_url) + .await + .context("connect admin database")?; + check_no_leftover_schemas(&admin).await?; + let (source_commits, trees) = decode_pack(¶ms.data)?; + let chain = linearize_first_parent(¶ms.data, &source_commits)?; + + let tiers = [ + params.data.h_max / 4, + params.data.h_max / 2, + params.data.h_max, + ]; + let mut cold_start = Vec::new(); + let mut incremental = Vec::new(); + let mut scale_limits = Json::Null; + let mut server_version = None; + for (tier_index, h) in tiers.into_iter().enumerate() { + let schema = format!("view_bench_{}_{}", std::process::id(), tier_index + 1); + admin + .execute_unprepared(&format!("CREATE SCHEMA {schema}")) + .await + .context("create benchmark schema")?; + let params_copy = params.clone(); + let chain_copy = chain.clone(); + let trees_copy = trees.clone(); + let schema_copy = schema.clone(); + let admin_url_copy = admin_url.clone(); + let task = tokio::spawn(async move { + run_tier( + ¶ms_copy, + h, + &schema_copy, + &admin_url_copy, + &chain_copy, + &trees_copy, + inject_failure && tier_index == 0, + ) + .await + }); + let tier_result = task + .await + .map_err(|_| anyhow::anyhow!("benchmark task panic")); + let cleanup_result = cleanup_schema(&admin, &schema).await; + cleanup_result.context("cleanup benchmark schema")?; + let (cold, groups, scale, version) = tier_result??; + server_version.get_or_insert(version); + cold_start.push(cold); + incremental.extend(groups); + if h == params.data.h_max { + scale_limits = scale; + } + } + admin.close().await?; + + let criteria = evaluate_criteria(¶ms, &cold_start, &incremental)?; + let evidence = json!({ + "params": params_json, + "env": { + "cpu": params.env.cpu, + "cores": params.env.cores, + "memory": params.env.memory, + "storage": params.env.storage, + "os": params.env.os, + "postgres_image": params.env.postgres_image, + "server_version": server_version.context("server version missing")?, + "params_path": params_path, + "code": code, + }, + "cold_start": cold_start, + "incremental": incremental, + "criteria": criteria, + "scale_limits": scale_limits, + }); + fs::create_dir_all(OUTPUT_DIR)?; + let bytes = serde_json::to_vec_pretty(&evidence)?; + let copy = format!( + "{OUTPUT_DIR}/run-{}.json", + Utc::now().format("%Y%m%dT%H%M%SZ") + ); + fs::write(©, &bytes)?; + fs::write(format!("{OUTPUT_DIR}/latest.json"), &bytes)?; + for key in ["a", "b", "c", "d"] { + let verdict = if evidence["criteria"][key]["pass"] == true { + "PASS" + } else { + "FAIL" + }; + println!("criterion {key}: {verdict}"); + } + println!( + "scale limit: {} (p95 {} us)", + if evidence["scale_limits"]["p95_us"] + .as_u64() + .unwrap_or(u64::MAX) + <= params.scale_limits.p95_budget_us + { + "PASS" + } else { + "FAIL" + }, + evidence["scale_limits"]["p95_us"] + ); + println!("evidence: {copy}"); + println!( + "summary: H_max={} groups={} scale_p95_us={}", + params.data.h_max, + evidence["incremental"].as_array().map_or(0, Vec::len), + evidence["scale_limits"]["p95_us"] + ); + Ok(()) +} + +fn validate_params(params: &Params, params_path: &str) -> Result<()> { + ensure!( + params_path == "examples/view_bench.params.json", + "params path is not frozen" + ); + ensure!(params.data.format == "pack", "data.format must be pack"); + ensure!(params.data.tag.starts_with('v'), "data tag is missing"); + ensure!(params.data.commit.len() == 40, "data commit must be SHA-1"); + ensure!(params.data.h_max >= 4, "h_max is too small"); + ensure!( + params.data.git_version.starts_with("git version "), + "git version missing" + ); + ensure!( + params.data.path.starts_with("target/tmp/view_bench/data/") + && !params.data.path.split('/').any(|part| part == "..") + && Path::new(¶ms.data.path).is_relative(), + "data.path must be inside the ignored benchmark data directory" + ); + ensure!( + params.views_config.batch_size == 1000, + "batch_size must be 1000" + ); + ensure!( + params.views == VIEWS, + "views differ from the frozen protocol" + ); + ensure!( + params.probes.len() == params.views.len(), + "probe/view count mismatch" + ); + ensure!( + params.runs.w == 5 && params.runs.n == 50, + "run counts differ from protocol" + ); + ensure!( + params.thresholds.k_h == 1.5 + && params.thresholds.k_b == 1.0 + && params.thresholds.c_cold == 50, + "thresholds differ from protocol" + ); + let mut unique = HashSet::new(); + let mut outside_paths = HashSet::new(); + let mut t_only_paths = HashSet::new(); + for view_probes in ¶ms.probes { + outside_paths.extend(view_probes.outside.iter().map(|probe| &probe.path)); + t_only_paths.extend(view_probes.t_only.iter().map(|probe| &probe.path)); + for class in CLASSES { + let entries = view_probes.get(class); + ensure!( + entries.len() == params.runs.w + params.runs.n, + "probe count mismatch" + ); + for probe in entries { + let valid_path = match class { + "in_view" => probe.path.starts_with("Documentation/"), + "outside" | "t_only" => probe.path.starts_with("t/"), + _ => false, + }; + ensure!(valid_path, "probe path outside its class"); + ensure!( + unique.insert((probe.path.clone(), probe.content.clone())), + "duplicate probe pair" + ); + } + } + } + ensure!( + outside_paths.is_disjoint(&t_only_paths), + "outside and t_only share a file" + ); + let caps = ¶ms.scale_limits.caps; + ensure!( + caps.compose_members == REGISTER_SCALE_LIMITS.members + && caps.exclude_selectors == REGISTER_SCALE_LIMITS.selectors + && caps.k == REGISTER_SCALE_LIMITS.k, + "scale caps differ from registration limits" + ); + ensure!( + params.scale_limits.probe_paths.len() == caps.k, + "scale probe path count" + ); + ensure!( + params.scale_limits.content.contains("{i}"), + "scale content template" + ); + ensure!( + params.scale_limits.p95_budget_us == 1_000_000, + "scale p95 budget" + ); + let config = mega2_core::config::testing::isolated_config(WORK_DIR); + for view in ¶ms.views { + let parsed = parse_for_registration(view)?; + validate_for_registration(&parsed.filter, &config.monorepo)?; + } + let scale = parse_for_registration(¶ms.scale_limits.filter)?; + let registration = validate_for_registration(&scale.filter, &config.monorepo)?; + let (members, selectors) = count_filter_scale(&scale.filter); + ensure!( + members == caps.compose_members + && selectors == caps.exclude_selectors + && registration.src_paths.len() == caps.k, + "scale filter normalized counts differ from caps" + ); + let unique_paths: HashSet<_> = params.scale_limits.probe_paths.iter().collect(); + ensure!(unique_paths.len() == caps.k, "duplicate scale probe paths"); + for (path, src) in params + .scale_limits + .probe_paths + .iter() + .zip(®istration.src_paths) + { + let prefix = format!("{}/", src.trim_start_matches('/')); + ensure!(path.starts_with(&prefix), "scale probe not under source"); + } + ensure!(params.env.cores > 0, "environment cores missing"); + for text in [ + ¶ms.env.cpu, + ¶ms.env.memory, + ¶ms.env.storage, + ¶ms.env.os, + ¶ms.env.postgres_image, + ] { + ensure!(!text.trim().is_empty(), "environment field is empty"); + } + Ok(()) +} + +fn count_filter_scale(filter: &Filter) -> (usize, usize) { + match filter { + Filter::Exclude(selectors) => (0, selectors.len()), + Filter::Compose(members) => members.iter().fold((members.len(), 0), |sum, member| { + let nested = count_filter_scale(member); + (sum.0 + nested.0, sum.1 + nested.1) + }), + Filter::Chain(ops) => ops.iter().fold((0, 0), |sum, op| { + let nested = count_filter_scale(op); + (sum.0 + nested.0, sum.1 + nested.1) + }), + _ => (0, 0), + } +} + +fn code_version() -> Result { + let revision = command_output("libra", &["rev-parse", "HEAD"])?; + ensure!( + revision.len() == 40 || revision.len() == 64, + "invalid HEAD revision" + ); + let status = command_output( + "libra", + &[ + "status", + "--short", + "src", + "examples", + "Cargo.toml", + "Cargo.lock", + ], + )?; + let worktree: Vec<_> = status.lines().map(str::to_owned).collect(); + ensure!( + worktree.iter().all(|line| { + line.len() >= 4 && matches!(&line[3..], "src/lib.rs" | "examples/view_bench.rs") + }), + "code version has changes outside the benchmark seam and example" + ); + let mut sha256 = BTreeMap::new(); + for file in ["src/lib.rs", "examples/view_bench.rs"] { + sha256.insert(file, sha256_file(Path::new(file))?); + } + Ok(json!({"revision": revision, "worktree": worktree, "sha256": sha256})) +} + +fn command_output(program: &str, args: &[&str]) -> Result { + let output = Command::new(program) + .args(args) + .output() + .context("libra execution failed")?; + ensure!( + output.status.success(), + "libra execution failed: {}", + output.status + ); + Ok(String::from_utf8(output.stdout)?.trim_end().to_owned()) +} + +fn sha256_file(path: &Path) -> Result { + let mut file = fs::File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 65536]; + loop { + let size = file.read(&mut buffer)?; + if size == 0 { + break; + } + hasher.update(&buffer[..size]); + } + Ok(hex::encode(hasher.finalize())) +} + +fn verify_pack(data: &DataParams) -> Result<()> { + let path = Path::new(&data.path); + ensure!(sha256_file(path)? == data.sha256, "data sha256 mismatch"); + let mut file = fs::File::open(path)?; + let mut magic = [0_u8; 4]; + file.read_exact(&mut magic)?; + ensure!(&magic == b"PACK", "data is not a raw pack"); + Ok(()) +} + +type Decoded = (HashMap, HashMap); + +fn decode_pack(data: &DataParams) -> Result { + let commits = Arc::new(Mutex::new(HashMap::new())); + let trees = Arc::new(Mutex::new(HashMap::new())); + let parse_errors = Arc::new(Mutex::new(Vec::new())); + let commits_sink = commits.clone(); + let trees_sink = trees.clone(); + let errors_sink = parse_errors.clone(); + let mut pack = Pack::new_with_hash_kind( + HashKind::Sha1, + Some(4), + Some(512 * 1024 * 1024), + Some(PathBuf::from(OUTPUT_DIR).join("pack-cache")), + true, + ); + let file = fs::File::open(&data.path)?; + pack.decode( + &mut BufReader::new(file), + move |entry| { + let id = entry.inner.hash.to_string(); + match entry.inner.obj_type { + ObjectType::Commit => match Commit::from_bytes(&entry.inner.data, entry.inner.hash) + { + Ok(commit) => { + commits_sink + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(id, commit); + } + Err(error) => { + errors_sink + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(format!("commit {id}: {error}")); + } + }, + ObjectType::Tree => match Tree::from_bytes(&entry.inner.data, entry.inner.hash) { + Ok(tree) => { + trees_sink + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(id, tree); + } + Err(error) => { + errors_sink + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(format!("tree {id}: {error}")); + } + }, + _ => {} + } + }, + None::, + ) + .context("decode raw pack")?; + let errors = parse_errors + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(first) = errors.first() { + bail!( + "pack object parse failures ({} total), first: {first}", + errors.len() + ); + } + drop(errors); + let commits = Arc::try_unwrap(commits) + .map_err(|_| anyhow::anyhow!("commit decoder still referenced"))? + .into_inner() + .map_err(|_| anyhow::anyhow!("commit decoder poisoned"))?; + let trees = Arc::try_unwrap(trees) + .map_err(|_| anyhow::anyhow!("tree decoder still referenced"))? + .into_inner() + .map_err(|_| anyhow::anyhow!("tree decoder poisoned"))?; + Ok((commits, trees)) +} + +fn linearize_first_parent( + data: &DataParams, + originals: &HashMap, +) -> Result> { + let mut old_chain = Vec::new(); + let mut next = Some(data.commit.clone()); + while let Some(id) = next { + let commit = originals + .get(&id) + .with_context(|| format!("first-parent commit missing: {id}"))?; + old_chain.push(commit); + next = commit.parent_commit_ids.first().map(ToString::to_string); + } + old_chain.reverse(); + ensure!( + old_chain.len() == data.h_max, + "first-parent chain length differs from h_max" + ); + let mut result = Vec::with_capacity(old_chain.len()); + for original in old_chain { + let parent = result + .last() + .map(|commit: &Commit| commit.id) + .into_iter() + .collect(); + result.push(Commit::new_with_kind( + HashKind::Sha1, + original.author.clone(), + original.committer.clone(), + original.tree_id, + parent, + &original.message, + )?); + } + Ok(result) +} + +async fn check_no_leftover_schemas(admin: &DatabaseConnection) -> Result<()> { + let rows = admin + .query_all_raw(Statement::from_string( + DbBackend::Postgres, + "SELECT nspname FROM pg_namespace WHERE starts_with(nspname, 'view_bench_') ORDER BY nspname".to_owned(), + )) + .await?; + if !rows.is_empty() { + for row in rows { + let schema: String = row.try_get("", "nspname")?; + eprintln!("view_bench: leftover schema {schema}"); + } + bail!("leftover benchmark schemas exist"); + } + Ok(()) +} + +async fn cleanup_schema(admin: &DatabaseConnection, schema: &str) -> Result<()> { + admin + .execute_raw(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity \ + WHERE application_name = $1 AND pid <> pg_backend_pid()", + [Value::from(schema.to_owned())], + )) + .await?; + admin + .execute_unprepared(&format!( + "SET lock_timeout = '120s'; DROP SCHEMA IF EXISTS {schema} CASCADE" + )) + .await?; + Ok(()) +} + +fn schema_url(admin_url: &str, schema: &str) -> Result { + let mut url = Url::parse(admin_url)?; + url.query_pairs_mut() + .append_pair("options", &format!("-csearch_path={schema}")) + .append_pair("application_name", schema); + Ok(url.to_string()) +} + +async fn run_tier( + params: &Params, + h: usize, + schema: &str, + admin_url: &str, + chain: &[Commit], + source_trees: &HashMap, + inject_failure: bool, +) -> Result<(Json, Vec, Json, String)> { + let mut connection = database_connection(&db_config(schema_url(admin_url, schema)?)) + .await + .context("migrate isolated benchmark schema")?; + let server_version: String = connection + .query_one_raw(Statement::from_string( + DbBackend::Postgres, + "SHOW server_version".to_owned(), + )) + .await? + .context("server_version row")? + .try_get("", "server_version")?; + if inject_failure { + eprintln!("{FAILURE_MARKER}"); + bail!("injected failure after-migrate"); + } + let counter = MetricCounter::install(&mut connection); + let connection = Arc::new(connection); + let harness = BenchHarness::new( + connection.clone(), + params.views_config.batch_size, + Path::new(WORK_DIR), + ) + .await + .context("assemble benchmark storage")?; + let config = harness.config(); + ensure!( + config.views.batch_size as usize == params.views_config.batch_size, + "batch size mismatch" + ); + let mut trees = source_trees.clone(); + persist_source_history(&harness, &connection, &chain[..h], &trees).await?; + + let ((), root_sample) = counter + .measure(async { + ensure!( + harness + .extend_root_chain(params.views_config.batch_size) + .await? + == RootChainOutcome::CaughtUp, + "root chain did not catch up" + ); + Ok(()) + }) + .await?; + let mut cold_views = Vec::new(); + let mut filter_pks = Vec::new(); + for view in ¶ms.views { + let filter_pk = insert_filter(&connection, &config, view).await?; + filter_pks.push(filter_pk); + let ((), sample) = counter + .measure(async { + ensure!( + harness.catch_up(filter_pk).await?, + "view did not reach ready" + ); + Ok(()) + }) + .await?; + cold_views.push(json!({ + "view": view, + "elapsed_us": sample.elapsed_us, + "statements": sample.statements, + "batches": batches(h, params.views_config.batch_size), + "commits_per_sec": h as f64 * 1_000_000.0 / sample.elapsed_us.max(1) as f64, + })); + } + let relation_sizes = relation_sizes(&connection).await?; + let cold = json!({ + "h": h, + "root_chain": { + "elapsed_us": root_sample.elapsed_us, + "statements": root_sample.statements, + "batches": batches(h, params.views_config.batch_size), + }, + "views": cold_views, + "relation_sizes": relation_sizes, + }); + + let mut current = chain[h - 1].clone(); + let mut groups: HashMap<(usize, String), (Vec, Vec)> = HashMap::new(); + for (class_index, class) in CLASSES.into_iter().enumerate() { + for i in 0..params.runs.w + params.runs.n { + for view_index in 0..params.views.len() { + let probe = ¶ms.probes[view_index].get(class)[i]; + current = append_probe( + &harness, + &connection, + &mut trees, + ¤t, + &[(probe.path.clone(), probe.content.clone())], + &format!("view benchmark h={h} class={class} i={i} v={view_index}"), + ) + .await?; + let ((), sample) = counter + .measure(async { + ensure!( + harness + .extend_root_chain(params.views_config.batch_size) + .await? + == RootChainOutcome::CaughtUp, + "incremental root chain did not catch up" + ); + ensure!( + harness.catch_up(filter_pks[view_index]).await?, + "incremental view not ready" + ); + Ok(()) + }) + .await?; + for (other, filter_pk) in filter_pks.iter().enumerate() { + if other != view_index { + ensure!(harness.catch_up(*filter_pk).await?, "other view not ready"); + } + } + let slot = groups + .entry((view_index, class.to_owned())) + .or_insert_with(|| (Vec::new(), Vec::new())); + if i < params.runs.w { + slot.0.push(sample); + } else { + slot.1.push(sample); + } + } + } + ensure!(class_index < 3, "invalid class index"); + } + let mut incremental = Vec::new(); + for class in CLASSES { + for (view_index, view) in params.views.iter().enumerate() { + let (warmup, samples) = groups + .remove(&(view_index, class.to_owned())) + .context("sample group missing")?; + incremental.push(json!({ + "h": h, + "view": view, + "probe_class": class, + "warmup": warmup, + "samples": samples, + "p50_us": percentile(&samples, 50), + "p95_us": percentile(&samples, 95), + })); + } + } + let scale = if h == params.data.h_max { + run_scale_limits( + params, + &harness, + &connection, + &counter, + &config, + &mut trees, + &mut current, + ) + .await? + } else { + Json::Null + }; + Ok((cold, incremental, scale, server_version)) +} + +async fn persist_source_history( + harness: &BenchHarness, + connection: &DatabaseConnection, + commits: &[Commit], + source_trees: &HashMap, +) -> Result<()> { + let mut needed = HashSet::new(); + for commit in commits { + collect_tree_ids(commit.tree_id, source_trees, &mut needed)?; + } + let trees: Vec<_> = needed + .iter() + .map(|id| { + source_trees + .get(id) + .cloned() + .context("required tree missing") + }) + .collect::>()?; + for chunk in trees.chunks(1000) { + harness.save_trees(chunk.to_vec(), commits[0].id).await?; + } + for chunk in commits.chunks(1000) { + harness.save_commits(chunk.to_vec()).await?; + } + let first = &commits[0]; + let tip = commits.last().context("source history is empty")?; + let now = Utc::now().naive_utc(); + let transaction = connection.begin().await?; + harness + .insert_root_ref( + &transaction, + mega_refs::Model { + id: generate_id(), + path: "/".to_owned(), + ref_name: "refs/heads/main".to_owned(), + ref_commit_hash: first.id.to_string(), + ref_tree_hash: first.tree_id.to_string(), + created_at: now, + updated_at: now, + is_cl: false, + }, + ) + .await?; + ensure!( + harness + .cas_root_ref( + &transaction, + &first.id.to_string(), + &first.tree_id.to_string(), + &tip.id.to_string(), + &tip.tree_id.to_string(), + ) + .await?, + "seed root CAS missed" + ); + transaction.commit().await?; + Ok(()) +} + +fn collect_tree_ids( + id: ObjectHash, + trees: &HashMap, + needed: &mut HashSet, +) -> Result<()> { + let key = id.to_string(); + if !needed.insert(key.clone()) { + return Ok(()); + } + let tree = trees + .get(&key) + .with_context(|| format!("tree missing: {key}"))?; + for item in &tree.tree_items { + if item.is_tree() { + collect_tree_ids(item.id, trees, needed)?; + } + } + Ok(()) +} + +async fn insert_filter( + connection: &DatabaseConnection, + config: &mega2_core::config::Config, + spec: &str, +) -> Result { + let parsed = parse_for_registration(spec)?; + let registration = validate_for_registration(&parsed.filter, &config.monorepo)?; + let id = generate_id(); + let now = Utc::now().naive_utc(); + mega_view_filter::ActiveModel { + id: Set(id), + filter_id: Set(parsed.filter_id), + canonical_spec: Set(parsed.canonical_text), + algo_version: Set(1), + object_format: Set(config.monorepo.object_format.as_str().to_owned()), + src_paths: Set(json!(registration.src_paths)), + push_enabled: Set(registration.push_enabled), + projected_seq: Set(0), + ready_seq: Set(None), + warming_since: Set(Some(now)), + last_access_at: Set(None), + created_at: Set(now), + } + .insert(connection) + .await?; + Ok(id) +} + +fn batches(length: usize, batch_size: usize) -> usize { + length.div_ceil(batch_size) +} + +fn percentile(samples: &[Sample], q: usize) -> u64 { + let mut sorted: Vec<_> = samples.iter().map(|sample| sample.elapsed_us).collect(); + sorted.sort_unstable(); + let rank = (q * sorted.len()).div_ceil(100); + sorted[rank.saturating_sub(1)] +} + +async fn relation_sizes(connection: &DatabaseConnection) -> Result { + let mut sizes = BTreeMap::new(); + for name in [ + "mega_view_root_chain", + "mega_view_commit_map", + "mega_view_object", + "mega_view_object_ref", + ] { + let row = connection + .query_one_raw(Statement::from_sql_and_values( + DbBackend::Postgres, + "SELECT pg_total_relation_size($1::regclass) AS bytes", + [Value::from(name.to_owned())], + )) + .await? + .context("relation size row missing")?; + sizes.insert(name, row.try_get::("", "bytes")?); + } + Ok(json!(sizes)) +} + +async fn append_probe( + harness: &BenchHarness, + connection: &DatabaseConnection, + trees: &mut HashMap, + parent: &Commit, + modifications: &[(String, String)], + message: &str, +) -> Result { + let mut root = parent.tree_id; + let mut new_trees = Vec::new(); + for (path, content) in modifications { + let parts: Vec<_> = path.split('/').collect(); + ensure!( + parts + .iter() + .all(|part| !part.is_empty() && *part != "." && *part != ".."), + "invalid probe path" + ); + root = update_tree(root, &parts, content.as_bytes(), trees, &mut new_trees)?; + for tree in &new_trees { + trees.insert(tree.id.to_string(), tree.clone()); + } + } + ensure!( + root != parent.tree_id, + "probe root tree equals parent root tree" + ); + let commit = Commit::from_tree_id_with_kind(HashKind::Sha1, root, vec![parent.id], message)?; + for tree in &new_trees { + trees.insert(tree.id.to_string(), tree.clone()); + } + harness.save_trees(new_trees, commit.id).await?; + harness.save_commits(vec![commit.clone()]).await?; + let transaction = connection.begin().await?; + ensure!( + harness + .cas_root_ref( + &transaction, + &parent.id.to_string(), + &parent.tree_id.to_string(), + &commit.id.to_string(), + &commit.tree_id.to_string(), + ) + .await?, + "probe root CAS missed" + ); + transaction.commit().await?; + Ok(commit) +} + +async fn spine_tree_count(connection: &DatabaseConnection) -> Result { + let row = connection + .query_one_raw(Statement::from_string( + DbBackend::Postgres, + "SELECT count(*) AS n FROM mega_view_object WHERE kind = 2".to_owned(), + )) + .await? + .context("spine tree count row missing")?; + Ok(row.try_get("", "n")?) +} + +async fn run_scale_limits( + params: &Params, + harness: &BenchHarness, + connection: &DatabaseConnection, + counter: &MetricCounter, + config: &mega2_core::config::Config, + trees: &mut HashMap, + current: &mut Commit, +) -> Result { + let parsed = parse_for_registration(¶ms.scale_limits.filter)?; + let registration = validate_for_registration(&parsed.filter, &config.monorepo)?; + let (compose_members, exclude_selectors) = count_filter_scale(&parsed.filter); + let k = registration.src_paths.len(); + let filter_pk = insert_filter(connection, config, ¶ms.scale_limits.filter).await?; + let before = spine_tree_count(connection).await?; + let ((), cold) = counter + .measure(async { + ensure!( + harness.catch_up(filter_pk).await?, + "scale cold start not ready" + ); + Ok(()) + }) + .await?; + let cold_spines = spine_tree_count(connection).await? - before; + let mut warmup = Vec::new(); + let mut samples = Vec::new(); + for i in 0..params.runs.w + params.runs.n { + let changes: Vec<_> = params + .scale_limits + .probe_paths + .iter() + .map(|path| { + ( + path.clone(), + params.scale_limits.content.replace("{i}", &i.to_string()), + ) + }) + .collect(); + *current = append_probe( + harness, + connection, + trees, + current, + &changes, + &format!("view benchmark scale i={i}"), + ) + .await?; + let before = spine_tree_count(connection).await?; + let ((), sample) = counter + .measure(async { + ensure!( + harness + .extend_root_chain(params.views_config.batch_size) + .await? + == RootChainOutcome::CaughtUp, + "scale root chain did not catch up" + ); + ensure!(harness.catch_up(filter_pk).await?, "scale view not ready"); + Ok(()) + }) + .await?; + let spine_trees = spine_tree_count(connection).await? - before; + let item = json!({ + "elapsed_us": sample.elapsed_us, + "statements": sample.statements, + "spine_trees": spine_trees, + }); + if i < params.runs.w { + warmup.push(item); + } else { + samples.push(item); + } + } + let p50_us = percentile_json(&samples, 50)?; + let p95_us = percentile_json(&samples, 95)?; + Ok(json!({ + "compose_members": compose_members, + "exclude_selectors": exclude_selectors, + "k": k, + "cold_start": { + "elapsed_us": cold.elapsed_us, + "statements": cold.statements, + "batches": batches(params.data.h_max + 6 * (params.runs.w + params.runs.n), params.views_config.batch_size), + "spine_trees": cold_spines, + }, + "warmup": warmup, + "samples": samples, + "p50_us": p50_us, + "p95_us": p95_us, + })) +} + +fn percentile_json(samples: &[Json], q: usize) -> Result { + let mut values: Vec<_> = samples + .iter() + .map(|sample| { + sample["elapsed_us"] + .as_u64() + .context("sample elapsed_us missing") + }) + .collect::>()?; + ensure!(!values.is_empty(), "empty samples"); + values.sort_unstable(); + let rank = (q * values.len()).div_ceil(100); + Ok(values[rank - 1]) +} + +fn evaluate_criteria(params: &Params, cold: &[Json], incremental: &[Json]) -> Result { + let mut grouped: BTreeMap<(String, String), Vec<&Json>> = BTreeMap::new(); + for group in incremental { + let view = group["view"].as_str().context("group view missing")?; + let class = group["probe_class"] + .as_str() + .context("group probe class missing")?; + grouped + .entry((view.to_owned(), class.to_owned())) + .or_default() + .push(group); + } + let mut a_violations = Vec::new(); + let mut b_violations = Vec::new(); + let mut c_violations = Vec::new(); + let mut d_violations = Vec::new(); + let mut largest_ratio: f64 = 0.0; + let mut largest_c_ratio: f64 = 0.0; + let h_max = params.data.h_max as u64; + let cold_max = cold + .iter() + .find(|item| item["h"].as_u64() == Some(h_max)) + .context("H_max cold result missing")?; + let n_batches = batches(params.data.h_max, params.views_config.batch_size) as f64; + for ((view, class), mut tiers) in grouped { + tiers.sort_by_key(|group| group["h"].as_u64().unwrap_or(0)); + ensure!( + tiers.len() == 3, + "incremental tier count differs from three" + ); + let series: Vec> = tiers + .iter() + .map(|group| { + group["samples"] + .as_array() + .context("sample array missing")? + .iter() + .map(|sample| { + sample["statements"] + .as_u64() + .context("sample statements missing") + }) + .collect::>() + }) + .collect::>()?; + if series[0] != series[1] || series[1] != series[2] { + a_violations.push(format!("{view}/{class}")); + } + let p95: Vec = tiers + .iter() + .map(|group| group["p95_us"].as_u64().context("p95 missing")) + .collect::>()?; + let min = *p95.iter().min().context("p95 minimum missing")?; + let max = *p95.iter().max().context("p95 maximum missing")?; + let ratio = max as f64 / min.max(1) as f64; + largest_ratio = largest_ratio.max(ratio); + if ratio > params.thresholds.k_h { + b_violations.push(format!("{view}/{class}: {ratio:.4}")); + } + let cold_view = cold_max["views"] + .as_array() + .context("cold views missing")? + .iter() + .find(|item| item["view"].as_str() == Some(view.as_str())) + .context("cold view missing")?; + let cold_per_batch = cold_view["elapsed_us"] + .as_f64() + .context("cold elapsed missing")? + / n_batches; + largest_c_ratio = largest_c_ratio.max(p95[2] as f64 / cold_per_batch.max(1.0)); + if p95[2] as f64 > params.thresholds.k_b * cold_per_batch { + c_violations.push(format!("{view}/{class}")); + } + } + let root_statements = cold_max["root_chain"]["statements"] + .as_u64() + .context("root statements missing")?; + let allowed = params.thresholds.c_cold * n_batches as u64; + let mut largest_cold_statements = 0_u64; + for view in cold_max["views"].as_array().context("cold views missing")? { + let statements = root_statements + + view["statements"] + .as_u64() + .context("view statements missing")?; + largest_cold_statements = largest_cold_statements.max(statements); + if statements > allowed { + d_violations.push(view["view"].as_str().unwrap_or("unknown").to_owned()); + } + } + Ok(json!({ + "a": { + "value": a_violations.is_empty(), + "threshold": true, + "pass": a_violations.is_empty(), + "violations": a_violations, + }, + "b": { + "value": largest_ratio, + "threshold": params.thresholds.k_h, + "pass": b_violations.is_empty(), + "violations": b_violations, + }, + "c": { + "value": largest_c_ratio, + "threshold": params.thresholds.k_b, + "pass": c_violations.is_empty(), + "violations": c_violations, + }, + "d": { + "value": largest_cold_statements, + "threshold": allowed, + "pass": d_violations.is_empty(), + "violations": d_violations, + }, + })) +} + +fn update_tree( + old_id: ObjectHash, + path: &[&str], + content: &[u8], + trees: &HashMap, + new_trees: &mut Vec, +) -> Result { + let old = trees + .get(&old_id.to_string()) + .context("probe parent tree missing")?; + let mut items = old.tree_items.clone(); + let name = path[0]; + let new_item = if path.len() == 1 { + let blob = Blob::from_content_bytes_with_kind(HashKind::Sha1, content.to_vec())?; + TreeItem::new(TreeItemMode::Blob, blob.id, name.to_owned()) + } else { + let child = items.iter().find(|item| item.name == name); + let child_id = if let Some(child) = child { + ensure!(child.is_tree(), "probe path crosses a file"); + update_tree(child.id, &path[1..], content, trees, new_trees)? + } else { + build_missing_tree(&path[1..], content, new_trees)? + }; + TreeItem::new(TreeItemMode::Tree, child_id, name.to_owned()) + }; + items.retain(|item| item.name != name); + items.push(new_item); + sort_git_tree_items(&mut items); + let tree = Tree::from_tree_items_with_kind(HashKind::Sha1, items)?; + let id = tree.id; + new_trees.push(tree); + Ok(id) +} + +fn build_missing_tree( + path: &[&str], + content: &[u8], + new_trees: &mut Vec, +) -> Result { + let item = if path.len() == 1 { + let blob = Blob::from_content_bytes_with_kind(HashKind::Sha1, content.to_vec())?; + TreeItem::new(TreeItemMode::Blob, blob.id, path[0].to_owned()) + } else { + let id = build_missing_tree(&path[1..], content, new_trees)?; + TreeItem::new(TreeItemMode::Tree, id, path[0].to_owned()) + }; + let tree = Tree::from_tree_items_with_kind(HashKind::Sha1, vec![item])?; + let id = tree.id; + new_trees.push(tree); + Ok(id) +} diff --git a/src/lib.rs b/src/lib.rs index d45de4a4..334f5790 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -55,6 +55,150 @@ pub mod authz_audit_ops { }; } +/// Internal assembly seam for the opt-in history projection benchmark (HP-24). +/// Not a supported API. +#[doc(hidden)] +pub mod view_bench_ops { + use std::{path::Path, sync::Arc}; + + use git_internal::{ + hash::ObjectHash, + internal::object::{commit::Commit, tree::Tree}, + }; + use sea_orm::{DatabaseConnection, DatabaseTransaction}; + + use crate::{ + MegaError, + config::{Config, DbConfig, testing::isolated_config}, + jupiter::{ + service::{ + view_metrics::ViewMetrics, + view_projection_service::{CatchUpOutcome, ViewProjectionService}, + }, + storage::{Storage, object_storage::mock_object_storage}, + }, + }; + pub use crate::{ + ceres::view::filter::{ + CanonicalFilter, Filter, parse_for_registration, + validate::{ + REGISTER_SCALE_LIMITS, RegistrationCheck, ScaleLimits, validate_for_registration, + }, + }, + common::utils::generate_id, + jupiter::{ + storage::{ + init::database_connection, view_root_chain::RootChainOutcome, + view_storage::ViewLockMode, + }, + utils::converter::sort_git_tree_items, + }, + }; + + pub struct BenchHarness { + storage: Storage, + projection: ViewProjectionService, + } + + impl BenchHarness { + pub async fn new( + connection: Arc, + batch_size: usize, + base_dir: &Path, + ) -> Result { + let mut config = isolated_config(base_dir); + config.views.batch_size = u64::try_from(batch_size) + .map_err(|_| MegaError::Other("benchmark batch size is too large".into()))?; + let storage = + Storage::new_with_connection(Arc::new(config), connection, mock_object_storage()) + .await?; + let projection = ViewProjectionService::new(storage.clone(), ViewMetrics::default()); + Ok(Self { + storage, + projection, + }) + } + + pub fn config(&self) -> Arc { + self.storage.config() + } + + pub async fn extend_root_chain( + &self, + batch_size: usize, + ) -> Result { + self.storage + .view_storage() + .extend_root_chain(None, batch_size, ViewLockMode::Try) + .await + } + + pub async fn catch_up(&self, filter_pk: i64) -> Result { + Ok(matches!( + self.projection.catch_up(filter_pk).await?, + CatchUpOutcome::Ready + )) + } + + pub async fn save_commits(&self, commits: Vec) -> Result<(), MegaError> { + self.storage + .mono_storage() + .save_mega_commits(commits, None) + .await + } + + pub async fn save_trees( + &self, + trees: Vec, + commit_id: ObjectHash, + ) -> Result<(), MegaError> { + self.storage + .mono_storage() + .save_mega_trees(trees, commit_id, None) + .await + } + + pub async fn insert_root_ref( + &self, + transaction: &DatabaseTransaction, + model: crate::mega_refs::Model, + ) -> Result<(), MegaError> { + self.storage + .mono_storage() + .insert_ref_if_not_exists_in_txn(transaction, model) + .await?; + Ok(()) + } + + pub async fn cas_root_ref( + &self, + transaction: &DatabaseTransaction, + expected_commit: &str, + expected_tree: &str, + next_commit: &str, + next_tree: &str, + ) -> Result { + self.storage + .mono_storage() + .cas_update_root_main_ref_in_txn( + transaction, + Some(expected_commit), + Some(expected_tree), + next_commit, + next_tree, + ) + .await + } + } + + pub fn db_config(db_url: String) -> DbConfig { + DbConfig { + db_url, + ..DbConfig::default() + } + } +} + // Public entry points for the thin `mega2` binary (composition root). pub use cli::parse; pub use common::errors::MegaError;