Files
knowledge-governance/internal/libraryrelease/release.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

313 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 libraryrelease 是生产同步的执行编排:预备份 → 四类写入 → 验证 → 失败恢复。
//
// 它只编排顺序与责任,不自己连生产:目标读写由 Target 注入,备份由 BackupStore 注入,
// 授权由 Grant 描述。任何一步失败都不得标记成功;失败后必须尝试恢复原生产版本,
// 恢复失败要作为独立的、更严重的结论上报。
package libraryrelease
import (
"context"
"errors"
"fmt"
"time"
)
// Stage 是四类需要真实落地的数据;顺序即写入顺序。
type Stage string
const (
StageDatabase Stage = "database"
StageResources Stage = "resources"
StageIndex Stage = "index"
StageWiki Stage = "wiki"
)
func Stages() []Stage { return []Stage{StageDatabase, StageResources, StageIndex, StageWiki} }
// Grant 是一次生产发布的授权:绑定确切目标与快照,带有效期与参数摘要。
// 参数一旦变化(例如换了目标快照)就必须重新授权,不能复用旧 grant。
type Grant struct {
Target string `json:"target"`
SnapshotID string `json:"snapshot_id"`
ExpiresAt time.Time `json:"expires_at"`
ParamsDigest string `json:"params_digest"`
AuthorizedBy string `json:"authorized_by"`
}
// Request 是一次待执行的生产同步。
type Request struct {
// JobID 是这次发布的作业身份:写集合记录与预备份引用都绑定它,
// worker 重启后凭它找回"这次写了什么"。
JobID string
Target string
SnapshotID string
// ParamsDigest 覆盖会影响生产结果的全部参数(目标、快照、排除范围等)。
ParamsDigest string
Grant Grant
Now time.Time
// PriorSnapshots 是这个目标的持久来源(成功同步过的快照 + 自己的预备份),
// 由治理库提供。资源复用只认它作证据;为空时"目标里已有但不在预备份范围"
// 的资源一律按冲突拒绝。
PriorSnapshots []PriorSnapshot
}
// CurrentState 是生产当前整库的指纹,用于预备份与最终验证。
type CurrentState struct {
Revision string `json:"revision"`
Counts map[string]int64 `json:"counts"`
Digests map[string]string `json:"digests"`
CapturedAt time.Time `json:"captured_at"`
}
// Expectation 是同步完成后生产应当具备的状态(来自测试封存)。
type Expectation struct {
SnapshotID string
Counts map[string]int64
Digests map[string]string
}
// TableFingerprint 是一张知识域表在**整实例**上的行数与身份集合摘要(不限租户/
// 知识库)。恢复后必须与预备份时刻逐表一致:只比目标范围会让"写到范围外"的
// 残留被当成已恢复(真实事故:恢复报成功,目标仍多 67 行)。
type TableFingerprint struct {
Count int64 `json:"count"`
Digest string `json:"digest"`
}
// BackupRef 指向一份可独立恢复的完整预备份。
type BackupRef struct {
ID string `json:"id"`
// SnapshotID 是预备份在归档里的快照身份:恢复必须按它回放,
// 不能只记"备份路径"却不知道恢复到哪一份内容。
SnapshotID string `json:"snapshot_id"`
Bytes int64 `json:"bytes"`
CreatedAt time.Time `json:"created_at"`
// Universe 是预备份时刻的整实例指纹,恢复后逐表比对。
Universe map[string]TableFingerprint `json:"universe,omitempty"`
// WriteSet 是本次发布的写集合与整实例内容证据。它在写第一笔之前就落盘并
// 登记摘要;恢复必须按它清理,缺了它恢复要失败(不许降级成"跳过比对")。
WriteSet *WriteSet `json:"write_set,omitempty"`
// Reused 表示"这是接管时复用上一次的预备份,没有重新备份"。
// 崩溃接管绝不允许重新备份:那一刻目标已被写过,新备份会把半写状态当成
// 可回滚基线,覆盖真正干净的那一份。外部要能区分"复用"与"重做",
// 所以复用必须显式落到版本证据里。
Reused bool `json:"reused,omitempty"`
}
type Target interface {
// Current 读取生产当前整库指纹;读不到就不能开始写入。
Current(ctx context.Context) (CurrentState, error)
// Apply 把一类数据写入生产;实现必须幂等或可回滚。
Apply(ctx context.Context, stage Stage, snapshotID string) error
// Verify 校验生产是否与期望一致。
Verify(ctx context.Context, expect Expectation) error
// Restore 把生产恢复到给定预备份。
Restore(ctx context.Context, backup BackupRef) error
}
type BackupStore interface {
// Prepare 创建独立完整预备份,并在返回前自检可恢复性。
Prepare(ctx context.Context, snapshotID string, current CurrentState) (BackupRef, error)
}
// Recorder 把阶段状态写进持久作业,供页面恢复展示。
type Recorder interface {
Stage(ctx context.Context, stage string, state string, detail string)
}
// BackupObserver 让调用方在预备份完成时立刻持久化快照身份:
// worker 被杀后重启必须能接管这份预备份,而不是重新备份覆盖它。
type BackupObserver interface {
// 必须返回错误:预备份引用没能持久化时,worker 被杀就没有恢复基线,
// 这种情况下一个字节都不许写目标。调用方据此直接失败。
BackupPrepared(ctx context.Context, backup BackupRef) error
}
// FinalizeStages 是"写入完成之后的收尾":先登记权威版本,再把作业标成功。
// 收尾失败不能报告整体成功,否则版本登记与作业状态会分叉。
func FinalizeStages() []string { return []string{"finalize"} }
type recorderFunc func(ctx context.Context, stage, state, detail string)
func (f recorderFunc) Stage(ctx context.Context, stage, state, detail string) {
f(ctx, stage, state, detail)
}
// NoopRecorder 用于测试或不需要落库的调用。
type NoopRecorder struct{}
func (NoopRecorder) Stage(context.Context, string, string, string) {}
type Outcome struct {
State string `json:"state"` // succeeded | failed | rolled_back | rollback_failed
Backup BackupRef `json:"backup"`
FailedAt Stage `json:"failed_at,omitempty"`
Error string `json:"error,omitempty"`
Rollback string `json:"rollback,omitempty"`
Recovery string `json:"recovery,omitempty"`
Finished time.Time `json:"finished"`
}
var (
ErrGrantExpired = errors.New("发布授权已过期")
ErrGrantTarget = errors.New("发布授权与目标不匹配")
ErrGrantSnapshot = errors.New("发布授权与快照不匹配")
ErrGrantParams = errors.New("发布参数与授权不一致")
ErrBackupUnavailable = errors.New("无法创建可独立恢复的生产预备份")
)
// ValidateGrant 在动生产之前把授权核对清楚:目标、快照、参数摘要、有效期,
// 任一项不符都直接拒绝,不做"先写一半再说"。
func ValidateGrant(request Request) error {
if request.Grant.Target != request.Target {
return fmt.Errorf("%w:授权 %q,请求 %q", ErrGrantTarget, request.Grant.Target, request.Target)
}
if request.Grant.SnapshotID != request.SnapshotID {
return fmt.Errorf("%w:授权 %q,请求 %q", ErrGrantSnapshot, request.Grant.SnapshotID, request.SnapshotID)
}
if request.Grant.ParamsDigest != request.ParamsDigest {
return ErrGrantParams
}
now := request.Now
if now.IsZero() {
now = time.Now()
}
if request.Grant.ExpiresAt.IsZero() || !now.Before(request.Grant.ExpiresAt) {
return ErrGrantExpired
}
return nil
}
// Run 执行完整链路。任何阶段失败都会尝试恢复;只有全部成功才返回 succeeded。
func Run(ctx context.Context, request Request, target Target, backups BackupStore, recorder Recorder) Outcome {
outcome := Outcome{State: "failed", Finished: time.Now().UTC()}
if recorder == nil {
recorder = NoopRecorder{}
}
if err := ValidateGrant(request); err != nil {
outcome.Error = err.Error()
recorder.Stage(ctx, "prebackup", "failed", err.Error())
return outcome
}
recorder.Stage(ctx, "prebackup", "running", "读取生产当前状态并创建预备份")
current, err := target.Current(ctx)
if err != nil {
// 读不到生产当前状态就不能写:否则回滚目标都没有。
outcome.Error = "读取生产当前状态失败:" + err.Error()
recorder.Stage(ctx, "prebackup", "failed", outcome.Error)
return outcome
}
backup, err := backups.Prepare(ctx, request.SnapshotID, current)
if err != nil {
outcome.Error = fmt.Sprintf("%v:%v", ErrBackupUnavailable, err)
recorder.Stage(ctx, "prebackup", "failed", outcome.Error)
return outcome
}
outcome.Backup = backup
if observer, ok := recorder.(BackupObserver); ok {
if err := observer.BackupPrepared(ctx, backup); err != nil {
// 硬门禁:持久化失败 = 没有可恢复基线 = 绝不写目标。
outcome.State = "failed"
outcome.Error = "预备份已生成但引用无法持久化,未对生产执行任何写入:" + err.Error()
outcome.Recovery = "预备份 " + backup.SnapshotID + " 已落盘,先修复治理库再重试"
recorder.Stage(ctx, "prebackup", "failed", outcome.Error)
return outcome
}
}
recorder.Stage(ctx, "prebackup", "succeeded", "预备份 "+backup.ID)
// 写集合必须在第一笔写入之前落盘并登记:恢复只按"这次明确写了什么"清理,
// 不按目标现状推断残渣(那会把历史孤儿/共享资源当成本次残留删掉)。
existingPlan := backup.WriteSet != nil
if backup.WriteSet == nil {
if planner, ok := target.(WriteSetPlanner); ok {
set, planErr := planner.PlanWriteSet(ctx, WriteSetPlan{JobID: request.JobID, Target: request.Target,
BackupSnapshotID: backup.SnapshotID, TargetSnapshotID: request.SnapshotID, Universe: backup.Universe,
PriorSnapshots: request.PriorSnapshots})
if planErr != nil {
outcome.State = "failed"
outcome.Error = "写集合/冲突预检失败,未对生产执行任何写入:" + planErr.Error()
recorder.Stage(ctx, "prebackup", "failed", outcome.Error)
return outcome
}
backup.WriteSet = &set
outcome.Backup = backup
if observer, ok := recorder.(WriteSetObserver); ok {
if err := observer.WriteSetPlanned(ctx, set); err != nil {
// 硬门禁:记录没落库 = 恢复时不知道写集合 = 一笔都不许写。
outcome.State = "failed"
outcome.Error = "写集合已落盘但引用无法持久化,未对生产执行任何写入:" + err.Error()
recorder.Stage(ctx, "prebackup", "failed", outcome.Error)
return outcome
}
}
}
}
// 计划已经存在(接管/恢复)时,执行目标必须**消费**它:把"这些资源已证明可复用"
// 的决定装进去,写入阶段才会跳过共享对象。不能重新计划——那时目标已被写过,
// 重新计划等于把半写现状当新基线(F07)。
if existingPlan {
if adopter, ok := target.(WriteSetAdopter); ok {
if err := adopter.AdoptWriteSet(*backup.WriteSet); err != nil {
outcome.State = "failed"
outcome.Error = "无法接管已持久化的写集合,拒绝继续写目标:" + err.Error()
recorder.Stage(ctx, "prebackup", "failed", outcome.Error)
return outcome
}
} else if _, plans := target.(WriteSetPlanner); plans {
// 会写资源、却读不懂计划:继续写就会重写共享行/对象。
outcome.State = "failed"
outcome.Error = "目标实现无法消费已持久化的写集合,拒绝在失去复用语义的情况下写目标"
recorder.Stage(ctx, "prebackup", "failed", outcome.Error)
return outcome
}
}
for _, stage := range Stages() {
recorder.Stage(ctx, string(stage), "running", "")
if err = target.Apply(ctx, stage, request.SnapshotID); err != nil {
return failAndRestore(ctx, outcome, target, recorder, stage, err)
}
recorder.Stage(ctx, string(stage), "succeeded", "")
}
recorder.Stage(ctx, "verify", "running", "")
expect := Expectation{SnapshotID: request.SnapshotID, Counts: current.Counts, Digests: current.Digests}
if err = target.Verify(ctx, expect); err != nil {
return failAndRestore(ctx, outcome, target, recorder, "verify", err)
}
recorder.Stage(ctx, "verify", "succeeded", "")
outcome.State = "succeeded"
outcome.Finished = time.Now().UTC()
return outcome
}
func failAndRestore(ctx context.Context, outcome Outcome, target Target, recorder Recorder, stage Stage, cause error) Outcome {
outcome.FailedAt = stage
outcome.Error = cause.Error()
outcome.Finished = time.Now().UTC()
recorder.Stage(ctx, string(stage), "failed", cause.Error())
recorder.Stage(ctx, "rollback", "running", "恢复到预备份 "+outcome.Backup.ID)
if restoreErr := target.Restore(ctx, outcome.Backup); restoreErr != nil {
// 恢复失败比同步失败更严重:明确标出,不能当作普通失败吞掉。
outcome.State = "rollback_failed"
outcome.Rollback = "恢复失败:" + restoreErr.Error()
recorder.Stage(ctx, "rollback", "failed", outcome.Rollback)
return outcome
}
// "恢复工具跑完"不是"恢复完成":必须再校验一次目标内容与预备份一致,
// 否则页面会把半恢复状态报成已恢复(真实事故就是这样放开了维护)。
if verifyErr := target.Verify(ctx, Expectation{SnapshotID: outcome.Backup.SnapshotID}); verifyErr != nil {
outcome.State = "rollback_failed"
outcome.Rollback = "恢复后校验失败(目标可能仍是半写状态):" + verifyErr.Error()
recorder.Stage(ctx, "rollback", "failed", outcome.Rollback)
return outcome
}
outcome.State = "rolled_back"
outcome.Rollback = "已恢复到预备份 " + outcome.Backup.ID + ",并逐表/索引/资源复核一致"
recorder.Stage(ctx, "rollback", "succeeded", outcome.Rollback)
return outcome
}