读懂 Scion:用一份 CI 账单分清查询、文件物化与网络流量

先核对作业真正需要的文件,再计算搬运预算。附原创离线核算器和误分类修正案例,区分逻辑字节、请求次数与实测网络。

1. Scion 留给 CI 团队的一道核算题

一个检查提交关系的作业,为什么要准备整个工作目录?如果一天有很多短任务,启动阶段反复准备文件,可能比任务本身更值得调查。Scion 的新闻提供了观察这个问题的切口:把“需要知道仓库里的什么”和“必须把哪些文件放到磁盘上”分别记录。

Anthropic 于 2026 年 10 月 13 日发布的工程文章介绍,团队用五个月把内部单体仓库从 Git 迁到自研 Scion。它面向在线客户端,提供服务端仓库查询,并支持稀疏和按需物化工作副本。官方明确说它不适合普遍替代 Git;该文没有提供公开安装入口。

下面是一份独立设计的 CI 核算练习。所有作业数量、字节数和调用次数都由本文假设,不是 Anthropic 的运行数据。练习只计算需求量,不复现 Scion,也不预测更换版本控制系统后的速度。读者用现有 CI 的数据也能填写这份账;是否迁移工具,需要另一组兼容性、可靠性与维护成本证据。

2. 先固定计量对象,再分类作业

本篇把文件物化定义为:为一个作业准备可读取的工作目录文件。逻辑字节数按这些文件的内容长度相加,不包含版本历史,也不把同样内容跨作业去重。若一个 120 MB 目录为两个作业各准备一次,就记 240 MB。即便底层通过共享缓存、写时复制或链接复用文件,这个逻辑需求量仍然按两个作业计数;它不代表物理磁盘新增写入量。

元数据 RPC 则按请求次数记账。例如询问两个固定提交是否有祖先关系,可以返回一个判断;查询变更路径可以返回路径清单。一次调用可能返回很少或很多数据,服务端计算量也可能不同,所以调用次数不能直接换算成网络字节或 CPU 时间。需要文件的作业同样可能发起元数据请求,两类账可以同时非零。

先用五类虚构作业练习判断。每天 200 次 ancestry_gate,只查询提交祖先关系,每次 1 个元数据请求;200 次 changed_path_router,只按两个请求返回的变更路径分配后续检查;300 次 unit_tests、200 次 lint、100 次 package_check 需要读取文件。此处假定后三类没有额外元数据 RPC。任务名称不能证明分类正确,必须核对实际读取行为。

把工具运行环境与仓库文件分开。本例假定运行器镜像、解释器、依赖包和测试数据已就绪,且不计入仓库物化量。每类文件作业的全部仓库依赖恰好落在同样大小的 120 MB 范围内。根目录配置、生成器输入、共享测试夹具也必须包含在这个范围;漏掉它们会让数字好看,却使任务结果失去可比性。

同一千个 CI 作业的三种逻辑物化预算假设全部物化两 GB 是两千 GB 每日,全部缩至零点一二 GB 是一百二十 GB,每日四百个作业不需要文件后是七十二 GB。元数据调用另计六百次,实际网络字节未知。 假设:1,000 个作业 / 日,任务结果要求保持一致 完整工作树统一稀疏范围按需求分类 1,000 × 2 GB1,000 × 0.12 GB600 × 0.12 GB400 个作业不物化文件 2,000 GB / 日120 GB / 日72 GB / 日 另一本账:分类方案的元数据 RPC = 600 次 / 日 实际网络字节:未知;需按链路、缓存状态和失败重试实测 原创算例 · 十进制 GB · 框体大小不代表数量比例 · 无性能预测
原创工作负载示意。三个框比较同一组作业的逻辑文件需求;元数据与网络采用不同计量口径。

3. 2,000、120 和 72 GB 是怎样算出来的

约定 1 GB = 1,000,000,000 字节、1 MB = 1,000,000 字节,每天固定 1,000 个作业,每次完整工作树为 2 GB。完整物化基线为 1,000 × 2 = 2,000 GB/日。若暂时不改变作业分类,所有作业统一改为准备 0.12 GB 的稀疏范围,得到 1,000 × 0.12 = 120 GB/日。这里连两类查询作业也准备文件,只为了单独核算缩小范围这一项变化。

随后取消 400 个查询作业的文件准备,保留 600 个文件作业:600 × 0.12 = 72 GB/日。第二步少物化的逻辑量为 400 × 0.12 = 48 GB/日。分类方案中的元数据请求为 200 × 1 + 200 × 2 = 600 次/日;这是另一个合计,不能从 72 GB 里相减。基线的元数据请求没有在此建模,因此也不能据此声称请求次数减少。

三种方案必须完成相同的有效检查,使用相同提交和相同作业次数。此处没有用“少运行一些测试”来压低数字,也没有把失败重跑混入某个方案而从另一个方案排除。数字表示一次逻辑准备/作业的日预算。如果实际一天重跑了 80 个作业,或同一作业内反复准备不同工作树,应把这些尝试或准备次数另列,再重新汇总。

不要从 2,000 与 72 的比值推导耗时改善。即使逻辑需求量降低,网络瓶颈也可能原本不存在;服务端查询、调度排队或依赖安装可能成为主要等待。缓存命中会改变真实传输,压缩会改变线上载荷,重试还会增加字节。上述算式没有这些输入,因而不能输出它们的结果。

4. 一个可复制的离线核算器

把下列完整代码保存为 workload.py,把输入保存为 jobs.csv,用 Python 3 执行 python3 workload.py jobs.csv。脚本仅使用标准库读取本地 CSV,并向标准输出打印 JSON;它没有仓库操作、网络请求或 Scion 客户端实现。

每行是一个任务类别,jobs_per_day 为每天执行次数;full_tree_bytes 与 sparse_tree_bytes 是每次在完整与统一稀疏方案中准备的逻辑字节;needs_files 为分类方案是否准备文件,只有 0、1 两种值;metadata_rpcs_per_job 为分类方案每次任务的元数据请求数。即便 needs_files 为 0,稀疏字段仍保留 120 MB,用于计算“尚未取消查询作业文件准备”的中间方案。

完整代码:workload.py
#!/usr/bin/env python3
"""Fictional CI logical materialization accounting; no network or repository access."""
import csv
import json
import sys

FIELDS = ['job', 'jobs_per_day', 'full_tree_bytes', 'sparse_tree_bytes',
          'needs_files', 'metadata_rpcs_per_job']


def natural(value, field):
    if not value or not value.isascii() or not value.isdigit():
        raise ValueError(f'{field} must be a nonnegative integer')
    return int(value)


def exact_gb(value):
    whole, remainder = divmod(value, 10**9)
    return str(whole) + (('.' + f'{remainder:09d}'.rstrip('0')) if remainder else '')


def calculate(path):
    totals = dict(jobs_per_day=0, file_jobs_per_day=0,
                  full_tree_logical_bytes_per_day=0,
                  sparse_only_logical_bytes_per_day=0,
                  classified_logical_bytes_per_day=0,
                  classified_metadata_rpcs_per_day=0)
    seen = set()
    with open(path, newline='', encoding='utf-8') as f:
        reader = csv.DictReader(f)
        if reader.fieldnames != FIELDS:
            raise ValueError('CSV header must exactly match: ' + ','.join(FIELDS))
        for row in reader:
            if set(row) != set(FIELDS) or any(v is None for v in row.values()):
                raise ValueError(f'line {reader.line_num}: wrong column count')
            job = row['job'].strip()
            if not job or job in seen:
                raise ValueError('job names must be nonempty and unique')
            seen.add(job)
            n, full, sparse, rpcs = [natural(row[k], k) for k in
                ['jobs_per_day', 'full_tree_bytes', 'sparse_tree_bytes',
                 'metadata_rpcs_per_job']]
            if full <= 0 or sparse > full:
                raise ValueError('full_tree_bytes must be positive; sparse cannot exceed full')
            if row['needs_files'] not in ('0', '1'):
                raise ValueError('needs_files must be 0 or 1')
            needs = int(row['needs_files'])
            if needs and sparse == 0:
                raise ValueError('file jobs need positive sparse_tree_bytes')
            totals['jobs_per_day'] += n
            totals['file_jobs_per_day'] += n * needs
            totals['full_tree_logical_bytes_per_day'] += n * full
            totals['sparse_only_logical_bytes_per_day'] += n * sparse
            totals['classified_logical_bytes_per_day'] += n * needs * sparse
            totals['classified_metadata_rpcs_per_day'] += n * rpcs
    if not seen:
        raise ValueError('CSV must contain at least one job row')
    totals['logical_gb_per_day'] = {
        key: exact_gb(totals[key + '_logical_bytes_per_day'])
        for key in ('full_tree', 'sparse_only', 'classified')}
    totals['actual_network_bytes_per_day'] = None
    return totals


def main():
    if len(sys.argv) != 2:
        print('usage: python3 workload.py jobs.csv', file=sys.stderr)
        return 2
    try:
        result = calculate(sys.argv[1])
        output = json.dumps(result, ensure_ascii=False, indent=2)
    except (OSError, ValueError, csv.Error) as exc:
        print(f'error: {exc}', file=sys.stderr)
        return 2
    print(output)
    return 0


if __name__ == '__main__':
    sys.exit(main())
完整输入:jobs.csv
job,jobs_per_day,full_tree_bytes,sparse_tree_bytes,needs_files,metadata_rpcs_per_job
ancestry_gate,200,2000000000,120000000,0,1
changed_path_router,200,2000000000,120000000,0,2
unit_tests,300,2000000000,120000000,1,0
lint,200,2000000000,120000000,1,0
package_check,100,2000000000,120000000,1,0
对应的完整输出
{
  "jobs_per_day": 1000,
  "file_jobs_per_day": 600,
  "full_tree_logical_bytes_per_day": 2000000000000,
  "sparse_only_logical_bytes_per_day": 120000000000,
  "classified_logical_bytes_per_day": 72000000000,
  "classified_metadata_rpcs_per_day": 600,
  "logical_gb_per_day": {
    "full_tree": "2000",
    "sparse_only": "120",
    "classified": "72"
  },
  "actual_network_bytes_per_day": null
}

输出中的 actual_network_bytes_per_day 固定为 null,含义是没有测量输入,不是零网络流量。GB 字段用十进制精确换算并输出字符串,原始字节字段保留整数。所有数量字段要求非负整数;完整工作树必须大于零,稀疏范围不能超过完整范围,需要文件的类别不能填写零字节。作业名称须非空且唯一,列名与顺序须完全匹配,空文件、缺列或多列均会报错。极端大整数若超出 Python 运行时的整数与字符串转换限制,也会报错退出,不返回舍入结果。出错信息写到标准错误,退出码为 2。

5. 误分类时,先修账,再讨论优化

现在发现 changed_path_router 不只读路径:它还执行仓库内的规则生成器,读取一个原先未核对的共享配置。先暂停“零文件”的判断,检查依赖闭包。如果这些文件全部能纳入原定 120 MB 范围,就把该行的 needs_files 从 0 改成 1。下面给出完整修正输入及输出;保留原来的两个元数据请求,因为仍然需要查询路径。

完整修正输入:jobs-corrected.csv
job,jobs_per_day,full_tree_bytes,sparse_tree_bytes,needs_files,metadata_rpcs_per_job
ancestry_gate,200,2000000000,120000000,0,1
changed_path_router,200,2000000000,120000000,1,2
unit_tests,300,2000000000,120000000,1,0
lint,200,2000000000,120000000,1,0
package_check,100,2000000000,120000000,1,0
运行 python3 workload.py jobs-corrected.csv 的完整输出
{
  "jobs_per_day": 1000,
  "file_jobs_per_day": 800,
  "full_tree_logical_bytes_per_day": 2000000000000,
  "sparse_only_logical_bytes_per_day": 120000000000,
  "classified_logical_bytes_per_day": 96000000000,
  "classified_metadata_rpcs_per_day": 600,
  "logical_gb_per_day": {
    "full_tree": "2000",
    "sparse_only": "120",
    "classified": "96"
  },
  "actual_network_bytes_per_day": null
}

修正后有 800 个文件作业,预算为 800 × 0.12 = 96 GB/日,元数据仍为 600 次/日。基线和统一稀疏方案分别保持 2,000 与 120 GB/日。72 GB 这个结果不再适用于修正后的任务事实。

如果依赖闭包实际需要 180 MB,仅将该行的稀疏字段改为 180,000,000,同时保留 needs_files=1。统一稀疏方案应为 800 × 0.12 + 200 × 0.18 = 132 GB/日;分类方案应为 600 × 0.12 + 200 × 0.18 = 108 GB/日。这个变化揭示两处原假设失效:既低估了文件作业数,也低估了该类别需要的文件范围。没有依赖证据时,应把分类标记为待核实,不要随意选择较小的数字。

脚本能拦住负数和列错误,但无法知道测试是否偷偷读取范围外文件,也不会验证两种方案的结果是否相同。验收时应比对同一提交上的检查输出与文件访问记录;缺失文件、规则未加载或检查被跳过,都应作为失败保留,而不能当成“作业更快完成”。

6. 把预算变成可验证的下一步

实际采样时,至少给每次尝试保留提交标识、任务类别、文件范围、逻辑字节、成功或失败、是否重试、节点缓存冷热、文件准备开始结束时间、任务开始结束时间,以及分链路传输字节。网络统计应写明观测点:节点到区域缓存的流量和缓存到上游的流量是两本账,同一份数据可能依次经过两段;只想计算节点入站时不能把两段相加。共享节点计数器还要避免把其他并发作业的流量误记到当前任务。

查询请求也要记录响应字节、延迟与失败率。把文件准备移到远端服务之后,客户端少准备文件并不等于工作消失,应继续观察服务端承受的查询负载。若只有温缓存样本,暂时只能说明那个缓存状态下的表现;冷启动、缓存失效和服务不可达需要各自保留记录。

评估顺序可以从改动最小的实验开始:挑一类依赖明确、输出容易核对的短任务,固定提交与输入,比较当前完整范围和更小范围的结果,再检查是否适合完全取消文件准备。只有确认有效检查没有减少,才比较实测字节和耗时。是否值得改变仓库基础设施,则还要核对审查流程、权限、故障恢复、离线需求和迁移维护责任。

本文验证范围截至 2026 年 10 月 14 日:核对上述官方文章,执行原创脚本的算术与输入校验测试。没有部署 Scion,没有访问真实项目或测量 CI、网络与性能。计算器可用于整理假设和暴露漏项,采用决策仍需要目标环境的实测证据。