Skip to content

perf(effect-runtime): reuse a private source digest across processes - #7748

Open
cocolord wants to merge 1 commit into
mainfrom
codex/effect-runtime-fingerprint-memo-20261031
Open

cocolord wants to merge 1 commit into
mainfrom
codex/effect-runtime-fingerprint-memo-20261031

Conversation

@cocolord

Copy link
Copy Markdown
Collaborator

Delivery brief

  • Goal/source: make a short LoopX CLI process pay only for the work it uses. The heartbeat chain (heartbeat-prompt, quota should-run, status, refresh-state, todo) starts a fresh Python process for each step, and each step computes the managed TypeScript runtime fingerprint once.
  • Current gap: the fingerprint re-opens all ~380 packaged .ts/.json sources in every process. Fresh-process measurement on macOS shows the cost is dominated by open(), not by hashing or stat (a stat-only pass is ~1 ms). It was 0.15–0.55 s per command, and 100% of the first explore-validation cost on the measured route.
  • Observable result: after a source revision has been hashed once, later processes reuse the digest. A changed, added or removed source, a different release root, interpreter or schema, or an untrusted record still triggers the existing full-byte hash.
  • Owning boundary: loopx/control_plane/effect_runtime.py (_runtime_fingerprint_for_snapshot). This does not change TS authority, the fingerprint value, runtime locators, scheduling, settlement, defaults or output contracts.

Change

On POSIX, after a successful full hash plus the existing freshness recheck, the digest is recorded in the existing private per-user runtime namespace as source-digest-<id>.json, mode 0600, written atomically.

  • Key: schema, sys.executable, Python version, normalized release root and the complete (path, mtime_ns, ctime_ns, size) source snapshot. That snapshot is already computed for the in-process cache.
  • Reuse: only when the directory is a regular private directory (owned by the user, no group/other bits), the file is opened with O_NOFOLLOW, it is a regular 0600-style file owned by the user, and both the key and the sha256 digest validate. Otherwise the code falls back to the unchanged full read.
  • Never recorded:
    • after a read failure or a source change during hashing;
    • when any source changed within the last 2 s, since a coarse ctime clock could otherwise preserve timestamps across a rewrite;
    • when the namespace does not exist, because the memo never creates it.
  • Name choice: the file avoids runtime-*.json, the *.ts-effect.lock* names and start-*.lock. Locator retirement, fixture cleanup and lock observation are therefore unaffected.
  • Non-POSIX hosts: keep full reads in every process.

The bilingual TypeScript migration RFC replaces "still reads every admitted byte" with this trade-off and its residual risk. Another file with identical size, mtime and ctime (for example a retargeted symlink), or a filesystem that does not maintain ctime, can hide a change.

Evidence

Base 04c5799b8 and head were run alternately in fresh processes against one isolated private namespace, on macOS with a synthetic SQLite fixture and 0.6 s gaps between runs:

Measure Base median Head median
_runtime_fingerprint() in a fresh process (n=12) 298 ms 13 ms (first run 380 ms)
heartbeat-prompt --thin total (n=6) 1.34 s 0.77 s
quota should-run total (n=6) 2.40 s 1.64 s
status total (n=6) 1.13 s 0.79 s

The fingerprint value is identical on base and head. After the run, canary fixture retirement completed normally, and the namespace contained only the digest record and the existing compile cache.

Validation at head 249773887:

  • New tests in tests/control_plane/test_runtime_source_read_batching.py, checked against an independent serial reference:
    • cross-process reuse with zero source reads;
    • edit, add and remove each rotate the fingerprint;
    • a copied release root misses the cache;
    • a foreign-mode file, symlink, shared namespace, corrupt JSON, stale key or invalid digest each recompute;
    • an unsettled source, a read failure or a missing namespace records nothing.
    • The reuse test fails on base.
  • pytest over the fingerprint, readiness, status-scan, all test_effect_runtime_*, managed-fixture, telemetry-isolation, performance-diagnosis, local-delegation and goal-portfolio suites: 388 passed, 1 skipped.
  • ruff check on changed Python: passed.
  • scripts/check_rfc_bilingual.py --base origin/main --head HEAD: passed.
  • loopx canary premerge --from-git-diff --git-diff-base origin/main (standard tier):
    • 6 direct checks and 15 selected checks, 0 failures, 0 manual holds;
    • surfaces: control_plane, docs_project_content, public_boundary, python.

Not covered

  • Windows and Linux were not measured locally; Windows keeps full reads by design.
  • The open-cost driver is host-specific; on hosts without per-open scanning the gain is smaller.
  • This changes runtime behavior in loopx/**, so it needs maintainer review and merge authorization. It is not self-merged.

Every short CLI process re-opened all packaged TS/JSON sources to compute
the managed runtime fingerprint. On hosts that scan each open this cost
0.15-0.55 s per heartbeat, quota or status command.

On POSIX, record the computed digest in the existing private per-user
runtime namespace, keyed by release root, interpreter, schema and the
complete source stat snapshot. A later process with the same snapshot
reuses it; a key mismatch, symlink, shared namespace or untrusted record
falls back to the unchanged full-byte hash and freshness recheck. Sources
changed within two seconds are hashed but not recorded, and the memo never
creates the namespace. Other platforms keep full reads.

The bilingual TypeScript migration RFC records the trade-off and the
residual metadata-preserving replacement risk.

Signed-off-by: lusendong.6789 <lusendong.6789@bytedance.com>
Co-authored-by: TRAE CLI <traecli@bytedance.com>
@cocolord

Copy link
Copy Markdown
Collaborator Author

Real-state end-to-end evidence for this head (249773887), complementing the synthetic measurements in the description.

  • Workload: loopx quota should-run against a disposable copy of a long-lived local runtime and registry (no live state touched), with a private temp dir per side so the source-digest cache could not leak between base and head.
  • Method: 10 alternating base/head pairs, same interpreter and fixture.
  • Base main@04c5799b8: median 2.09 s (first run 6.48 s cold, excluded only by using the median).
  • This PR: median 1.63 s (≈ −22%).
  • All 20 runs exited 0; base and head emitted identical output lengths.
  • Profile attribution on base: reading the TypeScript sources to fingerprint the effect runtime cost ~0.41 s per process, which is the cost this PR removes.

Limits: a single machine with a warm filesystem. This is not a cross-platform result and does not cover the failing windows-powershell / browser checks, which still need attribution.

@JasonBuildAI JasonBuildAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewer: model_agent · GPT-6.1 · OpenAI · self_reported

本次评审固定于 7748@249773887ce8c0903208f8f7308a099914010105。我阅读了 4 个改动文件、完整 base-to-head diff、运行时指纹与 locator 消费路径,复跑了相关测试和文档检查,并用 base/head 对照探针验证缓存失效竞态。结论为 REQUEST_CHANGES:跨进程缓存命中路径没有在返回前再次核对源码快照,可能返回与当前源码不一致的旧指纹。

动机

受影响的是频繁启动短生命周期 LoopX CLI 的操作者和自动化链。heartbeat-prompt、quota should-run、status、refresh-state 和 todo 每一步都会启动新的 Python 进程;这些进程都要为受管 TypeScript runtime 计算源码指纹,而旧实现会在每个新进程中重新打开约 380 个已打包的 .ts/.json 文件。作者报告在 macOS 上单个命令因此增加约 0.15–0.55 秒,读取打开成本高于哈希或 stat 成本。

修复后的可观察结果是在源码元数据未变化时复用已经计算过的摘要,避免后续进程重新打开全部源码;源码变化、release root 变化、解释器或 Python 版本变化、记录不可信时仍必须回到完整字节哈希。缓存只保存指纹,不授予执行、租约、任务完成或 runtime authority,也不改变指纹本身的算法和 locator 协议。本 PR 的边界是“复用同一源码修订的摘要”,不是改写 TypeScript runtime 的状态所有权。

正确性要求不只是“正常情况下源码改变会换 key”,还包括缓存命中路径必须像完整哈希路径一样在返回前观察到并发变化;否则调用方会把旧修订当成当前修订。下面这项 P2 正是该边界没有被保持。

改动思路

入口仍是 _runtime_fingerprint():它先通过既有 _runtime_source_snapshot() 取得有序的 (path, mtime_ns, ctime_ns, size) 元数据,再交给既有的 _runtime_fingerprint_for_snapshot()。完整哈希路径仍按顺序读取每个源码字节、更新同一 SHA-256,并在返回前重新扫描快照;快照变化时由 _RuntimeSourceChanged 和现有两次尝试循环重试。新增逻辑只是在函数入口增加一个持久化 memo,命中时直接返回记录值。

memo 放在现有 runtime 私有目录中,键由 schema、解释器、Python 版本、规范化 release root 和完整快照组成。POSIX 上才启用;读取要求父目录属于当前用户且没有 group/other 权限,记录文件通过 O_NOFOLLOW 打开,同时校验 owner、权限、schema、key 和摘要格式。写入只发生在命名空间已经存在、源码至少稳定两秒且完整哈希已经复核之后,并使用同目录临时文件和原子替换。其他平台继续逐进程完整读取。这复用了现有 runtime 生命周期和文件所有权,没有创建第二个持久化 authority,也没有引入新的 CLI 配置面。

架构判断可接受:缓存是 best-effort memo,私有目录和文件校验把不可信记录降级为完整重读,原有 locator、锁、进程和指纹算法仍是唯一 owner。问题在于 memo 命中分支过早返回,绕过了完整路径末尾的 freshness recheck;它需要留在同一个 fingerprint owner 内修复,而不是由调用方补一次判断。本 PR 的边界是“复用同一源码修订的摘要”,不是改写 TypeScript runtime 的状态所有权。

具体改动

关键代码讲解

  1. loopx/control_plane/effect_runtime.py:89 新增摘要 schema、4096 字节上限和两秒 settle 窗口。settle 防止粗粒度 ctime 在短时间重写时保持旧时间戳;窗口外的源码才写入缓存,窗口内仍可计算但不会留下跨进程记录。
  2. loopx/control_plane/effect_runtime.py:355 的 _source_digest_location 先按 schema、sys.executable、Python 版本和规范化 root 生成固定记录路径,再把完整源码快照加入 key。这样不同解释器、不同 release root 或不同文件集合不会共享一个摘要。
  3. loopx/control_plane/effect_runtime.py:388 的 _read_source_digest 校验父目录和文件的 POSIX 所有权/权限,拒绝 symlink、非普通文件和超过上限的读取,并只接受匹配 schema、key 且符合裸 SHA-256 格式的指纹;任一项不满足都返回 None 并走完整哈希。
  4. loopx/control_plane/effect_runtime.py:414 的 _write_source_digest 不创建 runtime 目录;它只在既有私有目录内用 O_EXCL | O_NOFOLLOW 写 0600 临时文件并原子替换。写入失败是 best-effort,不影响本次指纹结果。
  5. loopx/control_plane/effect_runtime.py:451 的 _runtime_fingerprint_for_snapshot 保留原有 lru_cache,并在 457–461 先尝试持久化记录。这里命中后立即返回;完整哈希分支则在 484–486 重新扫描并比较快照。缓存命中没有等价的二次校验,是本次 P2 的位置。
  6. tests/control_plane/test_runtime_source_read_batching.py:114 新增跨进程缓存、源码编辑/增加/删除、root 变化、不可信记录和失败不落盘等回归。现有用例在两次完整调用之间修改源码,能证明下一次快照换 key,但没有覆盖“修改发生在第一次 snapshot 之后、cache read 返回之前”的竞态。
  7. 两份迁移 RFC 同步说明 POSIX 摘要复用、两秒 settle、不可信记录回退和 ctime/symlink 残余风险;英文与中文改动对应。

P2 finding

P2:缓存命中可能在源码变化后返回旧指纹。 触发序列是:进程取得快照 S;另一个安装/写入进程把某个源码改为 S';本进程随后读取为 S 生成的缓存记录并直接返回旧摘要。_runtime_fingerprint_for_snapshot 在缓存命中后没有再次调用 _runtime_source_snapshot,因此不会进入既有的 _RuntimeSourceChanged 重试。完整哈希路径本来会在读取全部字节后复查快照,所以 base 在同样的并发变化下会返回 S' 的摘要。

我使用确定性 base/head 对照探针复现:在首次快照返回后、缓存读取前修改一个 .ts 文件。head 只执行一次快照扫描和一次缓存读取,返回旧摘要;base 在完整哈希后的复查发现变化,重新计算并返回当前源码摘要。该窗口很短,但 release 更新、安装脚本并行运行或编辑器落盘时真实存在;错误指纹会影响 locator/runtime revision 的选择,不能仅当作性能缓存的无害误差。

最小修复是在缓存命中返回前重新扫描当前快照并与 key 对应的 snapshot 比较;不相等时抛出既有 _RuntimeSourceChanged(current),让 _runtime_fingerprint() 用新快照重试。另加一个“第一次 snapshot 后、cache read 前修改源码”的回归,要求缓存记录不得被采用。修复后应至少复跑:

python -m pytest tests/control_plane/test_runtime_source_read_batching.py tests/control_plane/test_effect_runtime_integration.py -q
uv run --extra test ruff check loopx/control_plane/effect_runtime.py tests/control_plane/test_runtime_source_read_batching.py

规格映射

规格依据是 docs/architecture/rfcs/typescript-control-plane-migration-v0.md 中“源码指纹读取已准入字节并复查新鲜度”的约束;本 PR 修改了该段并声明缓存复用边界。

criterion_id 要求 当前 head
reuse-valid-key 只有完整 key 匹配时才可复用摘要 implemented;_source_digest_location 与 _read_source_digest
freshness-recheck-on-hit 缓存命中返回前仍须确认源码快照未变化 not_met;_runtime_fingerprint_for_snapshot:457–461 直接返回
private-record 记录必须位于私有目录,拒绝 symlink/foreign mode/损坏内容 implemented;_private_directory、_read_source_digest
stable-source-only 只有稳定源码才能写入跨进程记录,失败不产生部分记录 implemented;settle 与 _write_source_digest
non-posix-parity 非 POSIX 主机保持原有逐次完整读取 implemented;_source_digest_location 在非 POSIX 返回 None

对主干的风险

最强失败形态是缓存把旧源码摘要当作当前修订,导致同一 release 的 runtime identity 与磁盘源码不一致:调用方可能复用旧 runtime,或把本应启动新修订的请求归到旧 locator。这个竞态不会损坏缓存文件,也不是权限提升;但它破坏了本 PR 用来证明优化安全的 freshness invariant,因此属于 correctness P2,而不是只补一个测试。

现有正反例覆盖较完整:正常复用、源码编辑/增加/删除、root 变化、不可信记录、失败不落盘和缺失命名空间都有断言;完整集成模块在 Windows 上为 102 passed、9 skipped。Windows 会跳过 8 个 POSIX 缓存用例,所以本次不能把该平台的测试通过当成缓存路径的独立 qualification;作者报告 macOS 上 388 passed、1 skipped,代码审查和 base/head 探针仍独立暴露了上述竞态。

CI 侧当前有 3 个失败:Windows PowerShell 作业失败在未改动的 delegation revalidation 用例,dashboard-browser (2) 失败在既有 lens-switch 预算,checks 是聚合失败;这些路径不经过本 PR 修改的缓存代码,且 Windows 仍按设计走完整读取。它们在本地 base/head 对照中没有形成本 PR 的差分证据,故作为独立 merge-readiness hold 记录,而不是把红灯说成绿色。作者报告的性能收益和文档改动未发现第二个 blocking finding。

语义与 CI 对齐

这次改动没有新增 actor、任务义务、租约或运行时权限;新增的只是私有缓存 schema 和一个清理前的发布步骤。default_off_isolation 不适用:这不是可选功能开关,而是 POSIX 上默认启用的 memo,Windows 显式保持旧路径;因此不能从“没有 feature flag”推导出隔离性,必须依赖平台分支、失败回退和现有指纹测试。缓存命中修复后,文档中“源码变化仍触发完整哈希”的承诺才与实现一致。

我的整体评价

这是一个有价值的性能切片:它针对真实的高频短进程路径,复用既有 runtime/私有目录/指纹 owner,没有把缓存变成第二套状态系统;key、私有文件校验、settle 和失败回退都与现有协议相称,代码量也主要落在可独立验证的 I/O helper 和回归上。repository_reuse 为 reused,change_proportionality 为 proportionate;不建议扩展成第二个后台缓存服务或改写 locator 协议。

但在当前 exact head,缓存命中破坏了源码指纹最重要的 freshness invariant。先加入返回前快照复查和对应竞态回归,再在同一精确 head 重跑上述命令;在修复前我不会批准。当前 CI 的其他红项仍需单独处理,APPROVE 或缓存修复都不授予 merge authority。

English verdict: REQUEST_CHANGES — 249773887ce8c0903208f8f7308a099914010105 adds the intended private POSIX digest reuse, but a cache hit can return the old fingerprint when a source changes between the initial snapshot and the cache read because the hit path skips the full-hash path's freshness recheck; add the recheck and a deterministic race regression, then rerun the focused suites and static checks. Unrelated current CI failures remain a separate merge-readiness hold.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants