TT Lab
开始
学习 学习路径 课程

CI/CD 流水线

锁文件只改了一行,缓存却把旧依赖搬了回来

在 TT Lab 中继续学习

目标

亲手构造流水线缓存的键,制造命中、未命中和前缀回退;通过删除实验确认缓存与产物的区别;重现键里不放运行时版本和分支会污染什么;最后做出命中率报告和信任门禁。

为什么重要

缓存是流水线中最容易加入、又最长时间保持错误的装置。键太窄,每次都未命中,等于没有缓存;键太宽,则会把已变更的依赖、不同的运行时、别人分支造出的内容原样恢复。后一种失败要糟糕得多,因为构建反而成功了,没有人会报告。“清掉缓存就好了”这句话不是玩笑,原因就在这里——这句话的意思是缓存已经成了隐藏的输入。所以缓存设计就是确定三件事:什么变了,键就必须变;没有精确匹配的时候,可以借用到什么程度;谁可以写缓存。把这三件事确定下来,无论工具是 GitHub Actions、GitLab CI 还是 BuildKit,只需把同样的设置照抄过去即可。

步骤

  1. 在 /root/cache-lab 下创建实验基础环境。(1)在 /root/cache-lab/env/runtime.txt 中写一行 python3.12。(2)在 /root/cache-lab/deps.lock 中按这个顺序写六行 <이름> <판>(占位符依次为名称与版本):left-pad 1.3.0、fast-json 2.1.4、tiny-http 0.9.2、color-util 4.0.1、date-fmt 3.2.2、crypto-lite 1.0.7。(3)/root/cache-lab/seed-repo.sh <작업디렉터리>(占位符为工作目录)读取锁文件,为每个软件包创建从 <작업디렉터리>/pkgrepo/<이름>/<판>/f01.txt 到 f12.txt 共 12 个文件(占位符依次为工作目录、名称、版本),每个文件只写一行 <이름> <판> <번호두자리>(占位符依次为名称、版本、两位编号)。(4)/root/cache-lab/install.sh <작업디렉터리> 读取锁文件,为每个软件包用 sleep 0.05 模拟下载成本,然后把 pkgrepo/<이름>/<판>(占位符依次为名称与版本)复制到 vendor/<이름>(占位符为名称),最后在 vendor/_built-with.txt 中写入 env/runtime.txt 的内容,并输出一行 installed=<개수> ms=<밀리초>(占位符依次为个数与毫秒数)(vendor 每次都重新创建)。(5)依次运行两个脚本之后,在 /root/cache-lab/reports/cold.json 中写入 files、bytes、ms 三个数字。files 是 find /root/cache-lab/vendor -type f | wc -l 的结果,bytes 是这些文件的大小之和,ms 是 install.sh 给出的值。
  2. 创建缓存保存与恢复的骨架。/root/cache-lab/cache-save.sh <작업디렉터리> <열쇠>(占位符依次为工作目录与键)把 vendor 目录打包成 <작업디렉터리>/cache/<열쇠>.tar.gz(tar czf ... -C <작업디렉터리> vendor),在 <작업디렉터리>/cache/index.txt 中追加一行 <시각><탭><열쇠>(占位符依次为时间、制表符、键),然后输出 saved <열쇠>。/root/cache-lab/cache-restore.sh <작업디렉터리> <열쇠> 在该 tar 存在时,删除 vendor 并解包,然后输出 hit <열쇠> 并以退出码 0 结束;不存在时不动 vendor,输出 miss <열쇠> 并以退出码 1 结束。然后用键 manual-v1 保存,把 vendor 整个删除,再用同一个键恢复。把恢复前后的指纹以三行写入 /root/cache-lab/reports/roundtrip.txt:before=<지문>、after=<지문>、same=yes(占位符为指纹)。指纹用 cd /root/cache-lab/vendor && find . -type f -name '*.txt' | LC_ALL=C sort | xargs sha256sum | sha256sum | cut -c1-16 计算。
  3. 创建 /root/cache-lab/cache-key.sh <작업디렉터리>(占位符为工作目录)。输出为一行,形式为 deps-<잠금해시12자>(占位符为 12 位锁文件哈希)。锁文件哈希是 sha256sum < <작업디렉터리>/deps.lock | cut -c1-12。键中不能含有 /(它会成为文件名),并且必须以 12 位锁文件哈希结尾——因为后面的步骤会在前面再追加条目。接着创建 /root/cache-lab/cache-run.sh <작업디렉터리>。它先生成键并尝试恢复,命中就直接结束,未命中就运行 install.sh,再用该键保存。并且在 <작업디렉터리>/reports/runs.jsonl 中追加一行 {"key":"<열쇠>","result":"hit|miss","ms":<밀리초>}(占位符依次为键与毫秒数),屏幕上输出 <결과> <열쇠> <밀리초>ms(占位符依次为结果、键、毫秒数)。在 vendor 和缓存都已删除的状态下运行两次 cache-run.sh,确认第一次未命中、第二次命中之后,在 /root/cache-lab/reports/keys.txt 中写四行:run1=<첫 결과>、run2=<두 번째 결과>、lock-sha12=<지금 잠금 해시 12자>(占位符依次为第一次结果、第二次结果、当前 12 位锁文件哈希),以及假设把 fast-json 从 2.1.4 改成 2.2.0 时的 12 位锁文件哈希,写成 bumped-lock-sha12=<값>(占位符为该值)(不修改锁文件本身,只做计算)。
  4. 给 cache-restore.sh 增加第三个参数 <접두>(占位符为前缀)。没有精确的键而给出了前缀时,从 cache/index.txt 记录的键中,取出以该前缀开头且 tar 确实存在的最后保存的那个解包,输出 partial <그열쇠>(占位符为该键)并以退出码 2 结束。没有前缀,或者没有匹配项时,和以前一样是 miss 和 1。cache-run.sh 用 "${KEY%-*}-" 计算前缀后传入,如果结果是 partial,就必须重新运行 install.sh 之后,再用精确的键保存(runs.jsonl 的 result 中会增加 partial)。现在来重现这个风险。用 cp -a /root/cache-lab /root/cache-lab-bump 创建副本,把副本的 deps.lock 中的 fast-json 2.1.4 改成 fast-json 2.2.0,再用 seed-repo.sh 填充副本的仓库。在副本里运行 cache-run.sh 的前一刻,只触发前缀回退(因为没有精确的键),确认此时 vendor/fast-json/f01.txt 中写的版本,然后把 cache-run.sh 完整运行完,确认版本被纠正。把结果写成四行存入 /root/cache-lab/reports/stale.txt:exact=miss、fallback=<접두 대체로 가져온 열쇠>、before-install=fast-json <그때 판>、after-install=fast-json <끝난 뒤 판>(占位符依次为通过前缀回退取回的键、当时的版本、结束之后的版本)。不要动原始 /root/cache-lab 的锁文件。
  5. 创建 /root/cache-lab/build.sh <작업디렉터리>(占位符为工作目录)。把 vendor 下所有 *.txt 的 sha256sum 按路径顺序汇总,写入 <작업디렉터리>/dist/bundle.txt,并把该文件 sha256 的前 16 位写入 <작업디렉터리>/dist/bundle.id(直接使用 ( cd vendor && find . -type f -name '*.txt' | LC_ALL=C sort | xargs sha256sum ) 即可)。不要混入时间和随机数。在 /root/cache-lab 中运行 cache-run.sh 和 build.sh 生成产物后,用 cp -a /root/cache-lab /root/cache-lab-nocache 创建副本,并把副本的 cache 目录以及 vendor、dist 整个删除。在副本里重新运行 cache-run.sh 和 build.sh。在 /root/cache-lab/reports/cache-drop.txt 中写四行:with-cache=<원본의 bundle.id>、without-cache=<사본의 bundle.id>、same=yes、artifact-in-cache=no(占位符依次为原件的 bundle.id、副本的 bundle.id)。最后一行是用 tar tzf 打开 /root/cache-lab/cache 中所有 tar,确认没有任何以 dist/ 开头的条目所得出的结果。
  6. 先重现事故。用 cp -a /root/cache-lab /root/cache-lab-py313 创建副本,把副本的 env/runtime.txt 改成 python3.13,然后用现在的(不含运行时信息的)键运行 cache-run.sh。结果应当是命中,并且 vendor/_built-with.txt 里是 python3.12。然后修改 /root/cache-lab/cache-key.sh,在键的前面加上操作系统、架构和运行时版本:<uname -s 소문자>-<uname -m>-<런타임을 영숫자 외에는 - 로 바꾼 값>-deps-<잠금해시12>(占位符依次为小写的 uname -s 输出、uname -m 输出、把运行时中非字母数字字符替换为 - 之后的值、12 位锁文件哈希)。(python3.12 会变成 python3-12。以 12 位锁文件哈希结尾这条规则保持不变。)把改好的脚本也复制到副本中,在副本里重新运行 cache-run.sh,确认这次未命中,并重新以 3.13 安装。在 /root/cache-lab/reports/toolver.txt 中写六行:blind-key=、blind-result=、blind-built-with=、versioned-key=、versioned-result=、versioned-built-with=。前三个是用不含运行时信息的键运行时的值,后三个是用修改后的键运行时的值(键、结果、vendor 中记录的版本)。
  7. 把 /root/cache-lab 创建为 git 仓库(git init -b main,设置用户名和邮箱,把 vendor/、cache/、dist/、reports/ 加入 .gitignore 并提交)。在 /root/cache-lab/env/default-branch.txt 中写一行 main。创建 /root/cache-lab/cache-policy.sh <작업디렉터리> <브랜치>(占位符依次为工作目录与分支)。分支与 default-branch.txt 的值相同时输出 save 并以退出码 0 结束,不同时输出 nosave 并以 2 结束,读不到默认分支时输出以 unknown 开头的行并以 1 结束(不要把分支名写死在脚本里)。cache-key.sh 在键的最前面加上分支:<브랜치를 영숫자 외에는 - 로 바꾼 값>-<uname -s 소문자>-<uname -m>-<런타임>-deps-<잠금해시12>(占位符依次为把分支中非字母数字字符替换为 - 之后的值、小写的 uname -s 输出、uname -m 输出、运行时、12 位锁文件哈希)。如果把分支作为第二个参数传入,就计算该分支的键。cache-run.sh 读取时范围放宽(我的分支键 → 我的分支前缀 → 默认分支键 → 默认分支前缀),写入时范围收窄(仅当 cache-policy.sh 为 save 时)。在 runs.jsonl 的行中增加 "saved":"yes|no"。验证在副本中进行。cp -a /root/cache-lab /root/cache-lab-feature,在其中执行 git checkout -b feature/spike,在 deps.lock 末尾增加 md5-lite 0.4.0,用 seed-repo.sh 填充仓库,然后运行 cache-run.sh。在 /root/cache-lab/reports/branch.txt 中写五行:branch=feature/spike、key=<사본의 열쇠>、result=<그 실행의 결과>、policy=nosave、feature-tarballs=<사본의 cache 에 생긴 feature- 로 시작하는 tar 개수>(占位符依次为副本的键、该次运行的结果、副本的 cache 中生成的以 feature- 开头的 tar 的个数)。
  8. 创建 /root/cache-lab/cache-report.sh <작업디렉터리>(占位符为工作目录)。读取 <작업디렉터리>/reports/runs.jsonl 和 reports/cold.json,写入 <작업디렉터리>/reports/summary.json,同时输出到屏幕。字段有六个:runs(行数)、hits、partial、misses(按 result 统计的行数)、hit_rate(hits × 100 ÷ runs 向下取整的整数)、saved_ms(对每一行 result 为 hit 的行,取 cold.json 的 ms 减去该行的 ms,再累加,负数按 0 计)。没有任何一行时,hit_rate 为 0。然后创建 /root/cache-lab/cache-trust.sh <작업디렉터리>。deps.lock、vendor、env/runtime.txt 中只要缺少任何一个,就输出以 ERROR 开头的行并以退出码 1 结束;vendor/_built-with.txt 不存在,或与 env/runtime.txt 不同,或锁文件中某个软件包的版本与 vendor/<이름>/f01.txt(占位符为名称)中的版本不同时,输出以 REBUILD 开头的行并以 2 结束;全部匹配时输出 TRUST 并以 0 结束。最后,在 /root/cache-lab 中运行这两个脚本,留下 /root/cache-lab/reports/summary.json。

参考

不用缓存,备齐一次要花多少

在 /root/cache-lab 下创建实验基础环境。(1)在 /root/cache-lab/env/runtime.txt 中写一行 python3.12。(2)在 /root/cache-lab/deps.lock 中按这个顺序写六行 <이름> <판>(占位符依次为名称与版本):left-pad 1.3.0、fast-json 2.1.4、tiny-http 0.9.2、color-util 4.0.1、date-fmt 3.2.2、crypto-lite 1.0.7。(3)/root/cache-lab/seed-repo.sh <작업디렉터리>(占位符为工作目录)读取锁文件,为每个软件包创建从 <작업디렉터리>/pkgrepo/<이름>/<판>/f01.txt 到 f12.txt 共 12 个文件(占位符依次为工作目录、名称、版本),每个文件只写一行 <이름> <판> <번호두자리>(占位符依次为名称、版本、两位编号)。(4)/root/cache-lab/install.sh <작업디렉터리> 读取锁文件,为每个软件包用 sleep 0.05 模拟下载成本,然后把 pkgrepo/<이름>/<판>(占位符依次为名称与版本)复制到 vendor/<이름>(占位符为名称),最后在 vendor/_built-with.txt 中写入 env/runtime.txt 的内容,并输出一行 installed=<개수> ms=<밀리초>(占位符依次为个数与毫秒数)(vendor 每次都重新创建)。(5)依次运行两个脚本之后,在 /root/cache-lab/reports/cold.json 中写入 files、bytes、ms 三个数字。files 是 find /root/cache-lab/vendor -type f | wc -l 的结果,bytes 是这些文件的大小之和,ms 是 install.sh 给出的值。

没有互联网,所以无法真正下载——仓库也是我们自己创建的目录,成本只是用 sleep 模拟。不过测量的值(文件数、字节数、毫秒数)是真实的。让两个脚本都把工作目录作为第一个参数。后面的步骤会创建这个实验目录的副本,并用同样的脚本运行。seq -w 1 12 会以两位数从 01 数到 12。文件大小之和可以用 find ... -printf '%s\n' 和 awk 累加(这个 Pod 里没有 bc)。

保存、删除,再恢复一遍

创建缓存保存与恢复的骨架。/root/cache-lab/cache-save.sh <작업디렉터리> <열쇠>(占位符依次为工作目录与键)把 vendor 目录打包成 <작업디렉터리>/cache/<열쇠>.tar.gz(tar czf ... -C <작업디렉터리> vendor),在 <작업디렉터리>/cache/index.txt 中追加一行 <시각><탭><열쇠>(占位符依次为时间、制表符、键),然后输出 saved <열쇠>。/root/cache-lab/cache-restore.sh <작업디렉터리> <열쇠> 在该 tar 存在时,删除 vendor 并解包,然后输出 hit <열쇠> 并以退出码 0 结束;不存在时不动 vendor,输出 miss <열쇠> 并以退出码 1 结束。然后用键 manual-v1 保存,把 vendor 整个删除,再用同一个键恢复。把恢复前后的指纹以三行写入 /root/cache-lab/reports/roundtrip.txt:before=<지문>、after=<지문>、same=yes(占位符为指纹)。指纹用 cd /root/cache-lab/vendor && find . -type f -name '*.txt' | LC_ALL=C sort | xargs sha256sum | sha256sum | cut -c1-16 计算。

缓存最好作为一整块 tar 来处理——比逐个搬运几千个文件更快,权限和空目录也会一并保留。恢复未命中时,不能把 vendor 删掉。未命中不是错误而是正常结果,这时该做的是安装,而不是破坏。请用退出码区分命中和未命中。后面的步骤会在这两个退出码之上再加上第三种情形。加上 set -e 的话,脚本会先死在未命中上。

用锁文件哈希来构造键

创建 /root/cache-lab/cache-key.sh <작업디렉터리>(占位符为工作目录)。输出为一行,形式为 deps-<잠금해시12자>(占位符为 12 位锁文件哈希)。锁文件哈希是 sha256sum < <작업디렉터리>/deps.lock | cut -c1-12。键中不能含有 /(它会成为文件名),并且必须以 12 位锁文件哈希结尾——因为后面的步骤会在前面再追加条目。接着创建 /root/cache-lab/cache-run.sh <작업디렉터리>。它先生成键并尝试恢复,命中就直接结束,未命中就运行 install.sh,再用该键保存。并且在 <작업디렉터리>/reports/runs.jsonl 中追加一行 {"key":"<열쇠>","result":"hit|miss","ms":<밀리초>}(占位符依次为键与毫秒数),屏幕上输出 <결과> <열쇠> <밀리초>ms(占位符依次为结果、键、毫秒数)。在 vendor 和缓存都已删除的状态下运行两次 cache-run.sh,确认第一次未命中、第二次命中之后,在 /root/cache-lab/reports/keys.txt 中写四行:run1=<첫 결과>、run2=<두 번째 결과>、lock-sha12=<지금 잠금 해시 12자>(占位符依次为第一次结果、第二次结果、当前 12 位锁文件哈希),以及假设把 fast-json 从 2.1.4 改成 2.2.0 时的 12 位锁文件哈希,写成 bumped-lock-sha12=<값>(占位符为该值)(不修改锁文件本身,只做计算)。

键是用一个字符串对“这个缓存里装着什么”所做的概括。锁文件是连版本都钉死的列表,所以适合作为这个概括——反过来,如果对像 package.json 这种写着范围(^1.2)的文件做哈希,即使内容相同,实际安装的东西也可能不同。把没有变化的文件放进哈希,键就会无谓地频繁变化;把会变化的文件漏掉,陈旧的缓存就会一直命中。第二个值可以像 sed 's/fast-json 2.1.4/fast-json 2.2.0/' deps.lock | sha256sum 那样,不修改文件就算出来。

没有精确的键,就取最接近的

给 cache-restore.sh 增加第三个参数 <접두>(占位符为前缀)。没有精确的键而给出了前缀时,从 cache/index.txt 记录的键中,取出以该前缀开头且 tar 确实存在的最后保存的那个解包,输出 partial <그열쇠>(占位符为该键)并以退出码 2 结束。没有前缀,或者没有匹配项时,和以前一样是 miss 和 1。cache-run.sh 用 "${KEY%-*}-" 计算前缀后传入,如果结果是 partial,就必须重新运行 install.sh 之后,再用精确的键保存(runs.jsonl 的 result 中会增加 partial)。现在来重现这个风险。用 cp -a /root/cache-lab /root/cache-lab-bump 创建副本,把副本的 deps.lock 中的 fast-json 2.1.4 改成 fast-json 2.2.0,再用 seed-repo.sh 填充副本的仓库。在副本里运行 cache-run.sh 的前一刻,只触发前缀回退(因为没有精确的键),确认此时 vendor/fast-json/f01.txt 中写的版本,然后把 cache-run.sh 完整运行完,确认版本被纠正。把结果写成四行存入 /root/cache-lab/reports/stale.txt:exact=miss、fallback=<접두 대체로 가져온 열쇠>、before-install=fast-json <그때 판>、after-install=fast-json <끝난 뒤 판>(占位符依次为通过前缀回退取回的键、当时的版本、结束之后的版本)。不要动原始 /root/cache-lab 的锁文件。

GitHub Actions 的 restore-keys、GitLab 的 fallback_keys 做的就是这件事。如果键的形式是 <접두>-<해시>(占位符依次为前缀与哈希),只留下前缀就能找到最接近的缓存——所以键必须以哈希结尾。部分恢复看起来像是白捡的好处,但那一刻 vendor 中装着锁文件并不要求的版本。如果在这里跳过安装,那个版本就会原样混入构建,缓存消失的那天结果就会改变。在副本中操作的原因,是为了保护原件的锁文件。让脚本把工作目录作为第一个参数,在这里就派上了用场。

缓存整个删掉,结果也必须一样

创建 /root/cache-lab/build.sh <작업디렉터리>(占位符为工作目录)。把 vendor 下所有 *.txt 的 sha256sum 按路径顺序汇总,写入 <작업디렉터리>/dist/bundle.txt,并把该文件 sha256 的前 16 位写入 <작업디렉터리>/dist/bundle.id(直接使用 ( cd vendor && find . -type f -name '*.txt' | LC_ALL=C sort | xargs sha256sum ) 即可)。不要混入时间和随机数。在 /root/cache-lab 中运行 cache-run.sh 和 build.sh 生成产物后,用 cp -a /root/cache-lab /root/cache-lab-nocache 创建副本,并把副本的 cache 目录以及 vendor、dist 整个删除。在副本里重新运行 cache-run.sh 和 build.sh。在 /root/cache-lab/reports/cache-drop.txt 中写四行:with-cache=<원본의 bundle.id>、without-cache=<사본의 bundle.id>、same=yes、artifact-in-cache=no(占位符依次为原件的 bundle.id、副本的 bundle.id)。最后一行是用 tar tzf 打开 /root/cache-lab/cache 中所有 tar,确认没有任何以 dist/ 开头的条目所得出的结果。

缓存和产物,区别在于删除之后会发生什么。缓存即使没有,也必须能够重新生成,如果结果变了,那就不是缓存,而是隐藏的输入。产物一旦没了,要么无法重新生成(构建机器已经消失),要么不应该重新生成(已经部署的那个文件)。所以不能把产物放进缓存——缓存会过期,被清空是正常的,如果里面放着已部署的文件,过期就成了事故。构建必须是确定性的,这个对比才能成立。只要混进一行时间戳,两个结果就永远不同。

升级了运行时版本,却带来了用旧版本安装出来的缓存

先重现事故。用 cp -a /root/cache-lab /root/cache-lab-py313 创建副本,把副本的 env/runtime.txt 改成 python3.13,然后用现在的(不含运行时信息的)键运行 cache-run.sh。结果应当是命中,并且 vendor/_built-with.txt 里是 python3.12。然后修改 /root/cache-lab/cache-key.sh,在键的前面加上操作系统、架构和运行时版本:<uname -s 소문자>-<uname -m>-<런타임을 영숫자 외에는 - 로 바꾼 값>-deps-<잠금해시12>(占位符依次为小写的 uname -s 输出、uname -m 输出、把运行时中非字母数字字符替换为 - 之后的值、12 位锁文件哈希)。(python3.12 会变成 python3-12。以 12 位锁文件哈希结尾这条规则保持不变。)把改好的脚本也复制到副本中,在副本里重新运行 cache-run.sh,确认这次未命中,并重新以 3.13 安装。在 /root/cache-lab/reports/toolver.txt 中写六行:blind-key=、blind-result=、blind-built-with=、versioned-key=、versioned-result=、versioned-built-with=。前三个是用不含运行时信息的键运行时的值,后三个是用修改后的键运行时的值(键、结果、vendor 中记录的版本)。

缓存键必须概括“造出这个缓存的所有输入”。如果只放依赖列表,即使列表相同,在不同运行时、不同发行版上构建出来的二进制文件也会被原样带过来——而且由于安装成功了,没有人会察觉。所以无论哪家 CI 文档,示例键都是以 ${{ runner.os }}-node-... 这样的形式开头的。人必须能读着键就知道“这个缓存是在哪里造出来的”。替换非字母数字字符,用 tr -c 'A-Za-z0-9' '-' 很方便。末尾残留的部分用 sed 's/-*$//' 修整。

一个短命的分支,污染了所有人的缓存

把 /root/cache-lab 创建为 git 仓库(git init -b main,设置用户名和邮箱,把 vendor/、cache/、dist/、reports/ 加入 .gitignore 并提交)。在 /root/cache-lab/env/default-branch.txt 中写一行 main。创建 /root/cache-lab/cache-policy.sh <작업디렉터리> <브랜치>(占位符依次为工作目录与分支)。分支与 default-branch.txt 的值相同时输出 save 并以退出码 0 结束,不同时输出 nosave 并以 2 结束,读不到默认分支时输出以 unknown 开头的行并以 1 结束(不要把分支名写死在脚本里)。cache-key.sh 在键的最前面加上分支:<브랜치를 영숫자 외에는 - 로 바꾼 값>-<uname -s 소문자>-<uname -m>-<런타임>-deps-<잠금해시12>(占位符依次为把分支中非字母数字字符替换为 - 之后的值、小写的 uname -s 输出、uname -m 输出、运行时、12 位锁文件哈希)。如果把分支作为第二个参数传入,就计算该分支的键。cache-run.sh 读取时范围放宽(我的分支键 → 我的分支前缀 → 默认分支键 → 默认分支前缀),写入时范围收窄(仅当 cache-policy.sh 为 save 时)。在 runs.jsonl 的行中增加 "saved":"yes|no"。验证在副本中进行。cp -a /root/cache-lab /root/cache-lab-feature,在其中执行 git checkout -b feature/spike,在 deps.lock 末尾增加 md5-lite 0.4.0,用 seed-repo.sh 填充仓库,然后运行 cache-run.sh。在 /root/cache-lab/reports/branch.txt 中写五行:branch=feature/spike、key=<사본의 열쇠>、result=<그 실행의 결과>、policy=nosave、feature-tarballs=<사본의 cache 에 생긴 feature- 로 시작하는 tar 개수>(占位符依次为副本的键、该次运行的结果、副本的 cache 中生成的以 feature- 开头的 tar 的个数)。

如果让任何分支都能保存缓存,做实验后被删掉的分支留下的依赖,就会渗入默认分支的构建。而且没有办法撤回——因为那个分支已经不存在了。所以实际工作中的规则是“读取人人可以,写入只限默认分支”。分支名里常常含有 /,而键会成为文件名。不替换的话,会像 cache/feature/spike-...tar.gz 那样多出一层目录,或者保存本身就失败。git -C <경로> rev-parse --abbrev-ref HEAD(占位符为路径)会告诉你当前分支。也可能不是 git 仓库,所以要为失败的情形做好准备。

把“这个缓存能不能信”做成门禁

创建 /root/cache-lab/cache-report.sh <작업디렉터리>(占位符为工作目录)。读取 <작업디렉터리>/reports/runs.jsonl 和 reports/cold.json,写入 <작업디렉터리>/reports/summary.json,同时输出到屏幕。字段有六个:runs(行数)、hits、partial、misses(按 result 统计的行数)、hit_rate(hits × 100 ÷ runs 向下取整的整数)、saved_ms(对每一行 result 为 hit 的行,取 cold.json 的 ms 减去该行的 ms,再累加,负数按 0 计)。没有任何一行时,hit_rate 为 0。然后创建 /root/cache-lab/cache-trust.sh <작업디렉터리>。deps.lock、vendor、env/runtime.txt 中只要缺少任何一个,就输出以 ERROR 开头的行并以退出码 1 结束;vendor/_built-with.txt 不存在,或与 env/runtime.txt 不同,或锁文件中某个软件包的版本与 vendor/<이름>/f01.txt(占位符为名称)中的版本不同时,输出以 REBUILD 开头的行并以 2 结束;全部匹配时输出 TRUST 并以 0 结束。最后,在 /root/cache-lab 中运行这两个脚本,留下 /root/cache-lab/reports/summary.json。

仅凭命中率什么也决定不了。100% 命中的缓存,也可能 100% 地恢复出错误的内容——这就是前面步骤里看到的。所以在报告旁边,另外设一个“能不能信”。门禁的判定依据必须全部是当前磁盘上的状态。看的不是上次运行写了什么,而是现在 vendor 里装着什么。jq -s 会把多行 JSON 读成一个数组。把除法结果变成整数时用 | floor。对空数组用 add 会得到 null,所以用 + [0] 来防范。