5defe238e0
build / build (push) Successful in 1m34s
真实事故:源 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 不同 + 目标非空 + 资源阶段 失败",断言回滚后无来源范围残留、其它租户行未动、恢复后内容=预备份;正向用例断言 行落在目标范围且资源定位符指向目标存储。
251 lines
10 KiB
Go
251 lines
10 KiB
Go
// 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
|
||
}
|