pnpm pnpr 共享构建产物 Slot 防重叠机制:从“仅约束精确匹配不可变“到“所有可达消费者不可变“
2026/9/19 23:30:27 网站建设 项目流程

pnpm pnpr 共享构建产物 Slot 防重叠机制:从"仅约束精确匹配不可变"到"所有可达消费者不可变"

【免费下载链接】pnpmFast, disk space efficient package manager项目地址: https://gitcode.com/gh_mirrors/pn/pnpm

导读

本文围绕 pnpm 仓库中@pnpm/pnpr的一次 patch 级行为变更(见 .changeset/artifact-slots-refuse-overlap.md),深入讲解 pnpr 共享构建产物(shared build artifact)存储的"不可变(immutable)"语义升级:同一输入 key 下,产物不仅对其声明的精确兼容约束不可变,对任何与之共享消费者的兼容约束集合也一律拒绝被覆盖(返回409 Conflict)。读完本文,你将理解产物"槽位(slot)"与"兼容作用域(scope)"的底层建模、universal与平台矩阵的共存规则,以及这套语义在源码与测试中的实现与验证方式。

变更背景:pnpr 的共享构建产物是什么

pnpr(pnpmpnpr/目录下的子项目)在 pnpr/crates/shared-artifacts 中实现了一个共享构建产物存储:多个生态(npm、Cargo、PyPI 等)的构建产物可以按"输入 key + 兼容约束"被发布、检索并复用,从而避免同一平台重复构建。该 crate 的定位在 pnpr/crates/shared-artifacts/Cargo.toml 中写得很清楚:

The shared build-artifact store: reservations, blob storage, and the usage accounting that bounds it

即:产物预留(reservation)、blob 对象存储,以及约束存储上限的使用量记账。

每个产物发布请求包含三要素(见 lib.rs 中的prepare_publication):

  • key:输入 key(如ci/firstci/second),标识"这条流水线构建了什么";
  • subject:产物主题(如包名与版本等语义信息);
  • compatibility:兼容约束,决定该产物会到达哪些机器

entry_digest(key, subject)compatibility_slot(compatibility)共同决定了产物在对象存储中的落点:前者定位到某个 entry 目录,后者定位到该 entry 内的具体"槽位"文件{owner}/entries/{entry}/{slot}.json(见 artifact_identity.rs)。slot 是对兼容约束集合做 SHA-256 哈希得到的 64 位十六进制名,且 tag 集合先排序再哈希,保证"标签顺序无关"的同一约束只会得到一个槽位。

核心变更:产物对其"可达的每一个消费者"都不可变

此前的语义是:一个已发布产物只对其精确声明的兼容约束不可变——只要后发布的产物声明的约束与已存在产物不完全相同,就被允许共存。本次变更(artifact-slots-refuse-overlap.md)把这条边界推进了一步:

A published build artifact is now immutable for every consumer it can reach, not only for the exact compatibility constraints it declares.

即:只要新产物会到达某个已被现有产物覆盖的机器(consumer),发布就会被拒绝,返回409 Conflict。效果是:

  • 后发布的universal(全平台通用)产物,不能再压过已发布的、覆盖部分平台的产物;
  • "更宽(broader)"的约束集合,不能再抢占已发布窄约束产物覆盖到的机器;
  • "更高 floor(higher-floor)"的构建(例如 glibc 2.31 覆盖 glibc 2.17 的兼容范围)同样被拒绝。

反过来说,平台矩阵(platform matrix)不受影响:不同操作系统、不同 CPU 架构、不同 Node 主版本对应的产物,因为各自覆盖的机器集合互不相交,仍然可以像以前一样共存。

为什么"精确约束不重叠"还不够

从代码注释可以精确还原这一动机。在 artifact_identity.rs 中:

The slot an artifact claims within its entry: one per set of compatibility constraints, so auniversalbuild and a glibc-2.31 build coexist while two builds advertising the same constraints do not.

即:按约束集合划分槽位,能让universal构建和 glibc-2.31 构建各自占位;但这同时意味着,一个"更宽"或"更高 floor"的产物,与已存在产物处于不同槽位——如果只检查槽位是否被占用,它就会被错误地放行,从而在消费者侧"覆盖"掉已发布产物。这就是本次变更要堵住的洞:不可变性不能只落在槽位级别,而必须落在作用域(scope)级别

实现机制:作用域标记(Scope Marker)与预留

作用域(scope)是什么

代码把"产物会到达的机器集合"建模为兼容作用域(CompatibilityScopes),见 scopes.rs:

  • Tagged scopescompatibility_scopes(&payload.compatibility)These(scopes)时,作用域集合来自约束里的标签集合(如pnpm:v1:linux-x64-node22-glibc2.17这样的标签);
  • Universal scope:约束为"所有机器"时,作用域是唯一的保留键universal(lib.rs),因为它无法枚举所有标签。

每个 entry 下有一个scopes/目录({owner}/entries/{entry}/scopes/,见 artifact_identity.rs)。每个作用域对应一个标记对象(scope marker),其内容为占有该作用域的产物的 envelope digest。标记对象通过"条件创建"(conditional create)写入——创建成功者即为占有者。

预留算法:两个产物在共享作用域上竞争

claim_scopes(scopes.rs)负责为一次发布预留其覆盖的全部作用域,核心思想是:

Each scope is its own conditional create, and that is what orders two publications whose constraints merely overlap: they contend on the scope they share, rather than on a path that only identical constraints agree on.

也就是说,两个约束"仅仅重叠"的发布,会在它们共享的那个作用域上互相竞争,而不是在只有完全相同约束才会命中的槽位路径上竞争。工作量与本次产物的标签数成正比(最多 64 个,实际通常 1 个),而不是与 entry 已有内容成正比。

针对两种约束形态:

  • Universal 产物(claim_universal_scope):先条件创建保留键universal,随后检查是否存在已被占用的 tagged scope——只要发现任何一个,就拒绝;
  • Tagged 产物(claim_tagged_scopes):逐个条件创建自己的标签作用域,然后读一次universal标记:若它已被别的产物持有,则拒绝(ScopeMarker::Another);没人持有则是普通情况而非冲突。

标记的取值有三种(lib.rs):Gone(无人持有)、Ours(本次发布的产物持有)、Another(其他产物持有)。scope_marker的判定(scopes.rs)特别强调:"absence 本身不能作为答案"——因为另一个发布可能在条件创建失败后释放了标记,把"已消失"误判为"自己的"会把产物存进一个什么都没预留的槽位。

新发布被拒绝时的返回码:409 Conflict

claim_scopes得出HeldByAnother(槽位被别的产物持有)时,发布流程返回RegistryError::ArtifactAlreadyPublished(见 publication.rs 的claim_publication_scopes)。该错误在 pnpr/crates/error/src/lib.rs 中的定义与name@version重复发布保持同一语义:

Cannot publish over the artifact already published for this input key and platform

并且与VersionAlreadyPublishedCannot publish over the previously published version {package}@{version},npm 与 verdaccio 对重复发布同样回409 Conflict)一起映射为StatusCode::CONFLICT(见 pnpr/crates/error/src/lib.rs)。这正是 changeset 中"the same as a re-publishedname@version"的出处——产物不可变与 npm 版本不可变在错误语义上完全对齐。

哪些发布会被拒绝,哪些不受影响

被拒绝:universal、broader、higher-floor

用 changeset 原文与源码对照:

场景为何被拒绝源码依据
已有 tagged 产物,后发布universal产物universal 产物会到达已存在产物覆盖的每台机器;claim_universal_scope在创建universal标记后发现 tagged scope 被占用即失败scopes.rs
已有窄约束产物,后发布更宽约束产物两者在共享的 tagged scope 上竞争,条件创建失败scopes.rs
已有低 floor 产物(如 glibc 2.17),后发布高 floor 产物(如 glibc 2.31)高 floor 约束覆盖低 floor 的全部范围,两者映射到同一 scope测试 behavior.rs 中glibc2.17glibc2.31被判定为"reach the same scope"

测试 behavior.rs 专门构造了这样一个重叠场景:

Two artifacts stored before markers existed, whose tags differ only in a floor — so they reach the same scope, which is the overlap markers stop.

pnpm:v1:linux-x64-node22-glibc2.17pnpm:v1:linux-x64-node22-glibc2.31

不受影响:平台矩阵(platform matrix)

changeset 明确说明:

Publishing across a platform matrix is unaffected: artifacts for different operating systems, architectures, or Node majors reach no machine in common and coexist as before.

这是因为不同 OS / 架构 / Node 主版本的标签所对应的作用域互不相交,每个产物在自己的槽位与作用域上创建标记,不会互相冲突。测试 behavior.rs 中linux-x64linux-arm64两个标签(pnpm:v1:linux-x64-node22-glibc2.17pnpm:v1:linux-arm64-node22-glibc2.17)即为典型的共存矩阵。

幂等性与升级安全:旧数据不会被"洗掉"

相同产物重新发布仍然成功

不可变不等于拒绝重试。claim_scopes在条件创建失败后会再读一次标记:若标记内容正是本次发布的 envelope digestScopeMarker::Ours),说明这个作用域本来就是自己占的,于是继续走"检查槽位里存的是否就是本次 envelope"的路径,最终返回SlotClaim::Held——重新发布同一产物被当作一次幂等重试(scopes.rs)。publication_is_complete(scopes.rs)进一步验证:槽位里存的就是本 envelope,且自己持有全部作用域,才判定为"已发布完成"。

但注意一个关键细节(scopes.rs):

Holding its own scopes is not enough: an entry can hold an artifact reaching the same machines from the other side of the vocabulary, and a retry into one of those is refused like any other publication rather than reported as already published.

即:仅持有自己的作用域还不够——如果 entry 从"词汇表的另一侧"(如 universal 与 tagged 之间)已经存了一个覆盖相同机器的产物,重试会像普通发布一样被拒绝,而不是被误报为"已发布"。这正是 behavior.rs 中"losing a race for a slot is not reported as idempotent"测试的语义:两个发布者都可能读到槽位为空,输掉条件创建的一方不能把失败当成幂等成功。

升级已有 registry 不破坏存量

changeset 的收尾句(并在 artifact-slots-are-write-once.md 中重申):

Artifacts already stored by an earlier version keep their slot, so upgrading a populated registry does not leave them replaceable.

存量产物不会因为升级而变得可覆盖。这依赖于backfill 机制backfill_scopes,见 scopes.rs):旧版本存储的产物没有作用域标记,升级后的第一次发布会扫描该 entry 已有的所有 variant,为它们各自可达的作用域补写标记;随后写入一个哨兵标记backfilled,表示"该 entry 的所有产物都已被标记描述"。每次 entry 只 backfill 一次(有标记即跳过),且带attempted集合去重,避免拥挤的 entry 反复预留/释放。

配套的测试 behavior.rs(an_artifact_stored_under_the_older_name_still_claims_its_slot)验证:即使产物是以旧的 envelope-digest 命名方式存储的,backfill 后它仍然占据自己的槽位,后发布者依旧收到ArtifactAlreadyPublished

与发布生命周期机制的协同

防重叠语义不是孤立实现,它与共享产物存储的发布生命周期管理深度耦合:

  • 发布注册与心跳续约:每次发布开始时登记(begin_publication),期间每 5 分钟续约一次(PUBLICATION_RENEWAL_INTERVAL,见 lib.rs),超过 1 小时(ACTIVE_PUBLICATION_EXPIRY)未续约的注册会被视为失联而注销。这保证"bookkeeping 写失败、无法自行注销"的发布不会永远卡住回收流程(见 artifact-publications-expire.md);
  • 作用域回收:当发布失败且已创建标记时,作用域不会就地释放(因为无法与同 envelope 的并发发布排序),而是置reclamation_needed,由"无发布在途"时才运行的回收器(collector)判定某个作用域是否真的无产物持有,再安全回收(publication.rs)。

这些机制共同保证了:冲突判定是并发安全的——条件创建保证原子性,续约与回收保证失败发布不会无限期占住作用域,backfill 保证旧数据升级后仍被标记覆盖。

实战结论与迁移建议

  • 发布端:如果 CI 流水线会为同一key + subject先后产出不同约束的产物,请注意顺序——先发布宽约束/universal 产物,再发布窄约束产物是安全的;反向则会在窄约束产物已存在时收到409 Conflict。期望"以新换旧"的发布模式(例如先用 glibc 2.17 再用 glibc 2.31 覆盖)不再可行,需要改为发布到新输入 key或接受旧产物继续服务。
  • 语义对齐:产物槽位的不可变性与 npmname@version的不可变性现在遵循同一套409 Conflict语义(pnpr/crates/error/src/lib.rs),错误信息为Cannot publish over the artifact already published for this input key and platform
  • 幂等重试仍可用:重新发布完全相同的产物(相同 envelope)不会触发冲突,可安全重试;但"部分重叠"的任何变体都会被拒绝。
  • 平台矩阵不受影响:按 OS / 架构 / Node 主版本拆分的构建矩阵可以照常发布与共存,这是该语义刻意保留的并发面。

如需深入实现细节,建议从 pnpr/crates/shared-artifacts/src/lib.rs 的SharedArtifactStore::publish入口开始,依次阅读 publication.rs(发布流水线)、scopes.rs(作用域预留与 backfill)、artifact_identity.rs(entry/slot 命名),并以 behavior.rs 中的冲突与幂等测试作为行为规范(spec)来对照理解。

【免费下载链接】pnpmFast, disk space efficient package manager项目地址: https://gitcode.com/gh_mirrors/pn/pnpm

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询