98294b9583
build / build (push) Failing after 24s
why:审计 19 项(12 P1 / 7 P2)来自同一批根因,逐点试错会把协议撕成十几份:
· 意图与执行混为一谈(F01/F15):`library.sync:<scope>:<快照>` 是终身幂等键,
A→B→A 的第三次发布拿到第一次的历史成功作业,什么都不做;
· 版本行既是实体又是事件(F02/F09):主键只由 (kind,scope,快照) 决定、写入是
upsert,第二个作业覆盖 `source_job_id`/`evidence`/`created_at`;
· 接管没有代次(F13):两个 worker 都能认领同一 pending 作业,失租的旧执行还能
写业务状态、释放别人的维护锁;
· 早退与终态没有统一政策(F03/F04/F05/F06):半写窗口的前置检查失败会重新开门、
服务起不来也照样开入口、journal 终态后收尾不可重建、恢复成功不回写 current;
· 数据身份与准入各写一套(F07/F08/F14/F17):接管丢了复用语义、全库搬运不改
location_hash、通用入口可造 library.* 作业、目标模型从没被问过;
· 存储/容量/UI/CI 判据各自为政(F10/F11/F12/F16/F18/F19)。
what:按批准的一套协议集中实施(详见 handoffs/release-consolidated-implementation/
current-contract.md):
· operation(一次明确意图)/ attempt(epoch 代次)/ 不可变版本事件 / current 指针;
治理库迁移 76,只动治理库,不碰 WeKnora Schema/源码/镜像;
· 所有业务状态、阶段、版本事件、维护锁写入带 epoch 守卫(失租代次写不进去);
心跳失租立刻取消 handler;
· 半写窗口早退保持关闭;服务未全部健康绝不开入口;journal 终态可从持久事实重建
收尾;恢复成功登记 recovery 事件并推进 current;
· worker 在动维护前核对业务行/kind/scope/队列关联;通用入口拒绝 library.*;
· 全库搬运重算 location_hash 并与文档级导入共用实现;接管消费写集合的复用决定;
发布前核目标实时模型契约、容量门禁前置;
· 存储保护闭包改为全量依赖、类型只认显式登记、保护未知则不出可删候选;
· 页面核 HTTP 状态与结构、恢复纳入轮询、Wiki 差异覆盖标题/摘要/父级。
how:`make verify` rc=0(16 项必需检查全绿,含 go test 全量与前端 485 例);
`go vet ./...` rc=0;`npx tsc -p tsconfig.json --noEmit` rc=0;逐项负例见各包测试
(F03/F04/F08/F12/F13/F14/F16/F18/F19 都有先红后绿记录)。
未执行:治理库迁移到任何已有库、真实 PG/ES/S3/WeKnora 集成、故障注入、部署。
311 lines
14 KiB
Go
311 lines
14 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"sort"
|
||
"time"
|
||
|
||
"knowledge-governance/internal/governance"
|
||
"knowledge-governance/internal/libraryrelease"
|
||
v071 "knowledge-governance/internal/releaseadapter/v071"
|
||
)
|
||
|
||
// 写集合台账在治理侧的锚点:摘要登记在"这次作业的预备份版本"证据里。
|
||
// 为什么不用新表:版本记录本来就绑定 scope + snapshot + 作业,且 id 由这三者
|
||
// 决定;把摘要挂上去,重启后按同一身份就能取回,不需要改 schema。
|
||
|
||
// libraryVersionLookup 只暴露"按身份取一条版本记录"这一件事。
|
||
//
|
||
// 为什么不是"按 scope 列表":列表是有排序窗口的(真实 Store 上限 100),
|
||
// 历史超过窗口后登记/接管/恢复会把"存在"读成"不存在"。身份查询必须是精确条件,
|
||
// 而 found=false 只表示"这个身份没有记录",冲突由 store 返回错误(不许挑一条)。
|
||
type libraryVersionLookup interface {
|
||
LibraryVersionByRef(ctx context.Context, scope string, kind governance.LibraryVersionKind,
|
||
sourceJobID, snapshotID string) (governance.LibraryVersion, bool, error)
|
||
}
|
||
|
||
// libraryVersionLedger 是写集合锚点需要的两件事:按身份取一条、登记一条。
|
||
// 分开声明是为了让"身份不符/摘要冲突就失败"能在没有真实库时也被证明。
|
||
type libraryVersionLedger interface {
|
||
libraryVersionLookup
|
||
RecordLibraryVersion(ctx context.Context, version governance.LibraryVersion) error
|
||
}
|
||
|
||
// writeSetDigestOf 读某次作业/某份预备份登记的写集合摘要。
|
||
// 读不到就返回错误:缺失必须让调用方失败,不能降级成"没有记录就当没写过"。
|
||
func writeSetDigestOf(ctx context.Context, store libraryVersionLookup, scope, jobID, snapshotID string) (string, error) {
|
||
version, found, err := store.LibraryVersionByRef(ctx, scope, governance.LibraryVersionProductionPrebackup, jobID, snapshotID)
|
||
if err != nil {
|
||
return "", fmt.Errorf("读不到版本记录:%w", err)
|
||
}
|
||
if !found {
|
||
return "", fmt.Errorf("找不到作业 %s 的预备份版本记录(快照 %s)", jobID, snapshotID)
|
||
}
|
||
raw, _ := version.Evidence["writeset_digest"].(string)
|
||
if raw == "" {
|
||
return "", fmt.Errorf("预备份 %s 没有登记写集合摘要(作业 %s)", snapshotID, jobID)
|
||
}
|
||
return raw, nil
|
||
}
|
||
|
||
// loadWriteSetForJob 按"作业 + 目标 + 预备份"取回写集合并逐项核对绑定。
|
||
// 任何一步不符都返回错误:恢复宁可保持维护,也不许用一份对不上的记录去删目标数据。
|
||
func loadWriteSetForJob(ctx context.Context, store libraryVersionLookup, archiveRoot, scope, jobID, snapshotID string) (*libraryrelease.WriteSet, error) {
|
||
digest, err := writeSetDigestOf(ctx, store, scope, jobID, snapshotID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
set, err := v071.LoadWriteSet(archiveRoot, digest)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if set.JobID != jobID {
|
||
return nil, fmt.Errorf("写集合记录属于作业 %s,不是 %s", set.JobID, jobID)
|
||
}
|
||
if set.Target != scope {
|
||
return nil, fmt.Errorf("写集合记录属于目标 %s,不是 %s", set.Target, scope)
|
||
}
|
||
if set.BackupSnapshotID != snapshotID {
|
||
return nil, fmt.Errorf("写集合记录绑定预备份 %s,不是 %s", set.BackupSnapshotID, snapshotID)
|
||
}
|
||
return set, nil
|
||
}
|
||
|
||
// evidenceOwnerMatches 核对一份已持久化的证据是不是"这个作业 + 这份预备份"的。
|
||
//
|
||
// 为什么必须核这两维:版本行的 id 只由 (kind, scope, 快照) 决定,登记走 upsert。
|
||
// 只要调用方手里有一份"看起来对"的证据,就能把它盖到别人的行上;反过来,
|
||
// 合并证据时不核身份,就会把别的作业的写集合/字节数带进这一行。
|
||
func evidenceOwnerMatches(existing map[string]any, jobID, snapshotID string) error {
|
||
if job, ok := existing["job"].(string); ok && job != "" && job != jobID {
|
||
return fmt.Errorf("版本证据属于作业 %s,不是 %s:拒绝合并/覆盖", job, jobID)
|
||
}
|
||
if snapshot, ok := existing["backup_snapshot"].(string); ok && snapshot != "" && snapshot != snapshotID {
|
||
return fmt.Errorf("版本证据绑定预备份 %s,不是 %s:拒绝合并/覆盖", snapshot, snapshotID)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// evidenceBytes 取证据里的备份字节数。从库里读回来的是 float64(JSON 数字),
|
||
// 进程内构造的是 int64 —— 两种形状都要认,否则"字节数缺失"会被读成 0。
|
||
func evidenceBytes(evidence map[string]any) int64 {
|
||
switch value := evidence["backup_bytes"].(type) {
|
||
case int64:
|
||
return value
|
||
case int:
|
||
return int64(value)
|
||
case float64:
|
||
return int64(value)
|
||
case json.Number:
|
||
if parsed, err := value.Int64(); err == nil {
|
||
return parsed
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// prebackupBytesOf 读某次作业/某份预备份已登记的备份字节数。
|
||
//
|
||
// 接管的预备份是"复用"的,ledgerBackupStore 手里只有快照 id 与写集合;
|
||
// 字节数只能来自首次登记的那条证据。读不到或非正数一律失败:把 0 写进
|
||
// 生效版本证据,事后就分不清"空备份"和"没记下来"(现场正是 backup_bytes:0)。
|
||
func prebackupBytesOf(ctx context.Context, store libraryVersionLookup, scope, jobID, snapshotID string) (int64, error) {
|
||
version, found, err := store.LibraryVersionByRef(ctx, scope, governance.LibraryVersionProductionPrebackup, jobID, snapshotID)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("读不到预备份版本证据:%w", err)
|
||
}
|
||
if !found {
|
||
return 0, fmt.Errorf("找不到作业 %s 的预备份版本记录(快照 %s)", jobID, snapshotID)
|
||
}
|
||
if err := evidenceOwnerMatches(version.Evidence, jobID, snapshotID); err != nil {
|
||
return 0, err
|
||
}
|
||
bytes := evidenceBytes(version.Evidence)
|
||
if bytes <= 0 {
|
||
return 0, fmt.Errorf("预备份 %s 没有可信的字节数(作业 %s):拒绝把 0 字节当恢复基线", snapshotID, jobID)
|
||
}
|
||
return bytes, nil
|
||
}
|
||
|
||
// writeSetEvidence 在预备份原有证据上追加写集合锚点(纯函数,便于单独验证)。
|
||
// 不能整份替换:备份字节、索引映射这些字段是页面与恢复都在用的。
|
||
func writeSetEvidence(existing map[string]any, set libraryrelease.WriteSet, mappingDigest string) map[string]any {
|
||
evidence := map[string]any{}
|
||
for key, value := range existing {
|
||
evidence[key] = value
|
||
}
|
||
evidence["backup_snapshot"] = set.BackupSnapshotID
|
||
evidence["job"] = set.JobID
|
||
if mappingDigest != "" {
|
||
evidence["index_mapping_sha256"] = mappingDigest
|
||
}
|
||
evidence["writeset_digest"] = set.Digest
|
||
evidence["writeset_universe_digest"] = set.UniverseDigest()
|
||
evidence["writeset_target_snapshot"] = set.TargetSnapshotID
|
||
evidence["writeset_rows"] = len(set.Rows)
|
||
evidence["writeset_reused_rows"] = len(set.ReusedIDs("resources"))
|
||
return evidence
|
||
}
|
||
|
||
// prebackupEvidence 把"这一轮预备份"的字段合并进已持久化的那份证据。
|
||
//
|
||
// 为什么必须是合并而不是覆盖:接管复用同一份预备份时,BackupRef 手里只有快照 id
|
||
// (字节数、写集合摘要都是首次那一轮登记的事实)。每次造新 map 就等于用
|
||
// "这一轮知道的东西"替换"上一次已经证明过的东西" —— 而 writeset_* 只会在
|
||
// 首轮规划时写入,接管路径根本不会重算,丢了就再也补不回来。
|
||
//
|
||
// 为什么不能无条件合并所有字段:版本行 id 只由 (kind, scope, 快照) 决定,
|
||
// 那一行可能属于另一个作业。所以先核身份(job/backup_snapshot),再只保留
|
||
// 与本作业、本预备份一致的部分;字节数出现两个不同真值也直接失败。
|
||
func prebackupEvidence(existing map[string]any, backup libraryrelease.BackupRef, jobID, mappingDigest string) (map[string]any, error) {
|
||
evidence := make(map[string]any, len(existing)+4)
|
||
for key, value := range existing {
|
||
evidence[key] = value
|
||
}
|
||
if err := evidenceOwnerMatches(evidence, jobID, backup.SnapshotID); err != nil {
|
||
return nil, err
|
||
}
|
||
bytes := backup.Bytes
|
||
if backup.Reused {
|
||
persisted := evidenceBytes(existing)
|
||
if bytes > 0 && persisted > 0 && bytes != persisted {
|
||
return nil, fmt.Errorf("复用预备份字节数不一致(本次 %d,已登记 %d):拒绝把两份不同的备份当成一份",
|
||
bytes, persisted)
|
||
}
|
||
if bytes <= 0 {
|
||
bytes = persisted
|
||
}
|
||
if bytes <= 0 {
|
||
return nil, fmt.Errorf("预备份 %s 复用登记缺少可信字节数:拒绝把 0 字节写进证据", backup.SnapshotID)
|
||
}
|
||
evidence["backup_reused"] = true
|
||
}
|
||
if bytes > 0 {
|
||
evidence["backup_bytes"] = bytes
|
||
}
|
||
evidence["backup_snapshot"] = backup.SnapshotID
|
||
evidence["job"] = jobID
|
||
if mappingDigest != "" {
|
||
evidence["index_mapping_sha256"] = mappingDigest
|
||
}
|
||
return evidence, nil
|
||
}
|
||
|
||
// WriteSetPlanned 把写集合摘要登记进"这次作业的预备份版本"证据。
|
||
// 它在第一笔写入之前被调用;失败即中止(调用方据此不写目标)。
|
||
//
|
||
// 这里读的是"这一条预备份记录"(scope+kind+作业+快照),不是"最近 N 条":
|
||
// 现场正是历史上限把这条记录顶出窗口,才在预备份已经落盘之后登记失败。
|
||
// 记录不存在同样拒绝——把锚点写到一个不存在的证据行上,会让后续接管/恢复
|
||
// 读不到备份字节与索引映射,等于伪造一份台账。
|
||
func (r libraryStageRecorder) WriteSetPlanned(ctx context.Context, set libraryrelease.WriteSet) error {
|
||
return anchorWriteSet(ctx, r.store, r.operationID, r.scope, r.jobID, r.mappingDigest, set)
|
||
}
|
||
|
||
// anchorWriteSet 是登记的实现:与 loadWriteSetForJob 一样只依赖"按身份取一条版本"
|
||
// 这一件事,便于在没有真实库时也能证明"身份不符/摘要冲突就失败"。
|
||
func anchorWriteSet(ctx context.Context, store libraryVersionLedger, operationID, scope, jobID, mappingDigest string,
|
||
set libraryrelease.WriteSet) error {
|
||
version, found, err := store.LibraryVersionByRef(ctx, scope, governance.LibraryVersionProductionPrebackup,
|
||
jobID, set.BackupSnapshotID)
|
||
if err != nil {
|
||
return fmt.Errorf("读不到现有版本证据:%w", err)
|
||
}
|
||
if !found {
|
||
return fmt.Errorf("预备份 %s 的版本证据不存在(作业 %s):拒绝把写集合挂到不存在的记录上",
|
||
set.BackupSnapshotID, jobID)
|
||
}
|
||
// 同一作业的写集合只能落定一次:第二次预检出不同摘要,说明被登记的台账
|
||
// 不再描述"实际写了什么",恢复就无从按它清理——宁可停在写入之前。
|
||
if err := evidenceOwnerMatches(version.Evidence, jobID, set.BackupSnapshotID); err != nil {
|
||
return err
|
||
}
|
||
if recorded, ok := version.Evidence["writeset_digest"].(string); ok && recorded != "" && recorded != set.Digest {
|
||
return fmt.Errorf("预备份 %s 已登记写集合 %s,本次预检得到 %s:同一作业的写集合只能落定一次",
|
||
set.BackupSnapshotID, recorded, set.Digest)
|
||
}
|
||
evidence := writeSetEvidence(version.Evidence, set, mappingDigest)
|
||
return store.RecordLibraryVersion(ctx, governance.LibraryVersion{
|
||
ID: governance.LibraryVersionEventID(governance.LibraryVersionProductionPrebackup, scope, set.BackupSnapshotID, operationID),
|
||
Scope: scope, Kind: governance.LibraryVersionProductionPrebackup, SnapshotID: set.BackupSnapshotID,
|
||
SourceJobID: jobID, CreatedBy: "worker", Evidence: evidence, CreatedAt: time.Now().UTC(),
|
||
})
|
||
}
|
||
|
||
// ledgerBackupStore 是"接管/恢复"用的预备份来源:只认已经持久化的那一份,
|
||
// 并把它绑定的写集合一起带出来。
|
||
//
|
||
// 为什么不能像以前那样只返回 snapshot id:那一刻目标已经被写过,恢复若拿不到
|
||
// 写集合就只能按目标现状推断残渣——那正是会误删历史资源的路径。所以这里取不到
|
||
// 记录就直接失败,而不是返回一个"没有 Universe 的引用"。
|
||
type ledgerBackupStore struct {
|
||
ref libraryrelease.BackupRef
|
||
write *libraryrelease.WriteSet
|
||
}
|
||
|
||
func (s ledgerBackupStore) Prepare(context.Context, string, libraryrelease.CurrentState) (libraryrelease.BackupRef, error) {
|
||
if s.write == nil {
|
||
return libraryrelease.BackupRef{}, errors.New("恢复缺少持久化的写集合记录:拒绝在没有可追溯写集合的情况下继续")
|
||
}
|
||
// 字节数必须来自首次登记的证据:复用路径不重做备份,手上没有别的真值。
|
||
// 拿 0 继续会把"备份有多大"从生效版本证据里洗掉(现场 backup_bytes:0)。
|
||
if s.ref.Bytes <= 0 {
|
||
return libraryrelease.BackupRef{}, errors.New("恢复缺少已登记的真实备份字节数:拒绝用 0 字节当基线")
|
||
}
|
||
ref := s.ref
|
||
ref.WriteSet = s.write
|
||
// 明确的"复用":调用方据此把 backup_reused 落进版本证据,而不是让外部
|
||
// 从 created_at 变了没变去猜(那正是被同快照收尾伪造过的地方)。
|
||
ref.Reused = true
|
||
return ref, nil
|
||
}
|
||
|
||
// 能力必须在编译期就成立:Run 用的是类型断言(BackupObserver / WriteSetObserver),
|
||
// 少实现一个方法不会报错,只会让"预备份证据/写集合锚点"静默不落库(trap 14 的同一族)。
|
||
var (
|
||
_ libraryrelease.Recorder = libraryStageRecorder{}
|
||
_ libraryrelease.BackupObserver = libraryStageRecorder{}
|
||
_ libraryrelease.WriteSetObserver = libraryStageRecorder{}
|
||
_ libraryrelease.BackupStore = ledgerBackupStore{}
|
||
)
|
||
|
||
// effectiveEvidence 是"生产生效版本"证据的唯一构造点。
|
||
//
|
||
// 为什么单独成函数:生效版本证据里的 backup_bytes 是"这次恢复/同步用的是多大一份
|
||
// 基线"的唯一记录。接管复用路径上 BackupRef 若没带真实字节(历史缺陷),这里会
|
||
// 写进 0,事后与"空备份"无法区分(现场 production_effective 就是 backup_bytes:0)。
|
||
func effectiveEvidence(backup libraryrelease.BackupRef, action, mappingDigest string) map[string]any {
|
||
evidence := map[string]any{"backup_snapshot": backup.SnapshotID, "action": action}
|
||
if backup.Bytes > 0 {
|
||
evidence["backup_bytes"] = backup.Bytes
|
||
}
|
||
if mappingDigest != "" {
|
||
evidence["index_mapping_sha256"] = mappingDigest
|
||
}
|
||
return evidence
|
||
}
|
||
|
||
// sortedWriteSetSummary 只用于日志/证据:把本次写集合按表汇总成稳定的短摘要。
|
||
func sortedWriteSetSummary(set libraryrelease.WriteSet) string {
|
||
counts := map[string]int{}
|
||
for _, row := range set.Rows {
|
||
counts[row.Table]++
|
||
}
|
||
tables := make([]string, 0, len(counts))
|
||
for table := range counts {
|
||
tables = append(tables, table)
|
||
}
|
||
sort.Strings(tables)
|
||
summary := ""
|
||
for _, table := range tables {
|
||
if summary != "" {
|
||
summary += ","
|
||
}
|
||
summary += fmt.Sprintf("%s=%d", table, counts[table])
|
||
}
|
||
return summary
|
||
}
|