Files
knowledge-governance/internal/libraryrelease/release.go
T
姚章浩 5defe238e0
build / build (push) Successful in 1m34s
fix(library): 跨范围同步按目标范围改写并校验残留
真实事故:源 KB(10000/0c84a098) 同步到目标 KB(10001/e64e82b5) 时,归档行被原样
插入目标库,落在来源租户/知识库;资源阶段随即报"生产缺少资源 ... 的存储路径"
(资源查询按目标租户过滤,看不见刚写进去的行)。回滚只按目标范围回放,于是页面
报"已恢复到预备份",目标却多出 4 文件/38 分块/21 资源/4 绑定。

改:
- 写入与校验共用 retargetRow:tenant_id / knowledge_base_id / 资源定位符(后端、
  provider、桶、租户前缀) / storage_backend_id / provider / embedding_model_id
  统一映射到目标范围;行里的来源身份对不上就失败,宁可不写。
- Verify 增加"本次写集合仍以来源身份残留"判定;Restore 增加整库指纹比对(预备份
  时刻 vs 恢复后)与孤儿资源清理,回滚后用 Verify 复核,恢复工具退出 0 不再等于
  恢复完成。
- 新增 cmd/libraryresidue:按归档身份精确、可备份、可复核地清理已知的范围外残留。

验证:make verify 全绿;真实 PG+ES 用例复现"源目标 KB 不同 + 目标非空 + 资源阶段
失败",断言回滚后无来源范围残留、其它租户行未动、恢复后内容=预备份;正向用例断言
行落在目标范围且资源定位符指向目标存储。
2026-10-29 22:11:59 +08:00

251 lines
10 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 {
Target string
SnapshotID string
// ParamsDigest 覆盖会影响生产结果的全部参数(目标、快照、排除范围等)。
ParamsDigest string
Grant Grant
Now time.Time
}
// 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 是预备份时刻的整实例指纹,恢复后逐表比对。空表示这次没有采集
// (例如 worker 被杀后用已持久化的快照接管),此时恢复只能靠内容校验兜底。
Universe map[string]TableFingerprint `json:"universe,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)
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
}