版本比较、latest 重算与并发
本章讲什么: 同一个 server 可以发很多版本。哪个算「最新」?多版本并发上传怎么不打架?删了 latest 怎么补位?
1. 版本比较的三条规则
- 要解决的小问题: 作者可以写任意版本字符串(项目故意不强制 semver,
validateVersion注释internal/validators/validators.go:334)。那1.2.0、v3、2025-01-release谁更新? - 思路: 能按 semver 比就按 semver,比 不了就退而用发布时间。
规则定在 CompareVersions(internal/service/versioning.go:62):
| 情况 | 怎么比 |
|---|---|
| 两个都是合法 semver | 按语义版本比大小(compareSemanticVersions) |
| 两个都不是 semver | 按发布时间戳比(晚的更新) |
| 一个是 semver、一个不是 | semver 的那个永远更大 |
// internal/service/versioning.go:62 CompareVersions(节选)
if isSemver1 && isSemver2 {
return compareSemanticVersions(version1, version2) // 都 semver:语义比较
}
if !isSemver1 && !isSemver2 {
// 都不是 semver:用时间戳
if timestamp1.Before(timestamp2) { return -1 } else if timestamp1.After(timestamp2) { return 1 }
return 0
}
// 混合:semver 胜
if isSemver1 && !isSemver2 { return 1 }
return -1
semver 判定有个坑(注释 versioning.go:21): Go 的 golang.org/x/mod/semver 会把 v1、v1.2 也当合法。本项目额外要求恰好三段 major.minor.patch,见 IsSemanticVersion(versioning.go:13)里剥掉前后缀后 len(parts) == 3 的检查。所以 1.2 不算 semver,会落到时间戳分支。
这个比较函数在两处用:发布时决定新版是不是 latest(registry_service.go:211);以及下面的 latest 重算。
2. is_latest 的两种维护方式
is_latest 是 registry 自己维护的标记(在 RegistryExtensions.IsLatest,pkg/api/v0/types.go:15)。维护分两条路径:
(a) 发布时增量维护(快路径): 新版若胜过当前 latest,就 unmark_latest 清旧标记,新行带 IsLatest:true 插入。见第 2 章 §3.3。
(b) 状态变更后全量重算(慢路径): 当某版本被删/弃用/恢复,当前 latest 可能不再合适,得重选。recalculateLatest(internal/service/registry_service.go:252):
recalculateLatest(serverName):
取该 server 所有版本(含 deleted)
winner = 在「非 deleted」里选版本最高的 (pickLatestVersion allowDeleted=false)
若没有非 deleted 版本:
winner = 在所有版本(含 deleted)里选最高的 ← 兜底
SetLatestVersion(winner) // 给 winner 置 true,其余全 false
兜底很重要(注释 registry_service.go:248): 如果一个 server 的版本全被删了,仍让「最高的那个 deleted 版本」保留 latest 标记——这样管理员用 includeDeleted=true 还能查到这个 server,不会凭空消失。
选最高版本的 pickLatestVersion(registry_service.go:277)就是拿 CompareVersions 在切片里跑一遍擂台,可选跳过 deleted。
3. 并发 publish:per-server advisory lock
- 要解决的小问题: 两个进程同时发
io.github.alice/weather的两个版本,可能同时读到「当前 latest 是 X」,各自都以为自己该当 latest,结果两行都is_latest=true,或版本计数错乱。 - 思路: 对同一个 server 名串行化。用 PostgreSQL 的事务级 advisory lock。
// internal/database/postgres.go:765 AcquirePublishLock(节选)
lockID := hashServerName(serverName)
// pg_advisory_xact_lock:事务结束自动释放,无需手动 unlock
if _, err := db.getExecutor(tx).Exec(ctx, "SELECT pg_advisory_xact_lock($1)", lockID); err != nil {
return fmt.Errorf("failed to acquire publish lock: %w", err)
}
两个细节:
- 锁键是 server 名的 FNV-1a 哈希(
hashServerName,postgres.go:781):Postgres advisory lock 的键是bigint,所以把字符串名字哈希成 63 位整数(& 0x7FFFFFFFFFFFFFFF去掉符号位)。不同 server 名几乎不会撞键,于是只串行化同名发布,不互相阻塞。 - 事务级(
pg_advisory_xact_lock):事务一结束(提交或回滚)锁自动释放,不会因为忘记 unlock 而泄漏。
取锁是发布事务的 acquire_lock 相位(registry_service.go:163),在版本检查之前——确保「读当前 latest → 决定 → 写」这整段是同名串行的。
4. 读路径与增量同步
写讲完了,顺带看读。RegisterServersEndpoints(internal/api/handlers/v0/servers.go:85)提供:
GET /v0/servers—— 游标分页列表,支持search(名字子串)、version=latest、updated_since。GET /v0/servers/{name}/versions/{version}—— 单版本,version=latest是特殊值。GET /v0/servers/{name}/versions—— 某 server 全部版本。
为子注册表设计的增量同步: updated_since 让 Smithery/PulseMCP 这类 ETL 只拉「上次同步后变化的」。一个强约束(resolveIncludeDeleted,servers.go:44):用 updated_since 时必须 include deleted——否则增量方拿不到「某 server 被删了」的事件,本地副本会残留幽灵条目。代码里 include_deleted=false 与 updated_since 同用直接 400。
这些读端点前面挂 CDN(设计文档 docs/design/tech-architecture.md「CDN Layer」),面向「客户端每天轮询」的模式。