Files
knowledge-governance/cmd/worker/library_writeset.go
T
姚章浩 98294b9583
build / build (push) Failing after 24s
fix(library): 统一执行身份、终态收尾、准入与数据身份
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 集成、故障注入、部署。
2026-10-30 16:14:54 +08:00

311 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}