c72b71d78a
build / build (push) Failing after 17s
为什么:父审指出预备份引用持久化失败仍继续写目标、接管依赖启动扫描且漏掉租约未过期、 恢复失败被标成"无需恢复"、版本登记与作业状态分叉。 - BackupPrepared 返回 error,持久化失败即零写入;预备份同时登记 production_prebackup 版本。 - 接管改为按队列租约判定 + 运行期 30s 扫描 + 原子抢占 + 固定幂等键 + attempt 归零。 - 接管场景保留预备份、不再宣称"未写无需恢复"。 - 恢复/重放同一快照重新激活 production_effective(清 superseded_at)。 - finalize 续作只做校验+登记(finalize_only),不重写生产。 - 索引映射基线缺失时采用首次观察值并登记;索引校验加孤儿文档检查(按 knowledge_id)。 真实验收(候选 API :18089 + 真实 PG/ES/MinIO): A0 首次发布、A1 撤销、B 按版本恢复、C 写阶段 SIGKILL 接管、 D 跨 kind 互斥与多 worker 单次执行、E1 登记失败、E2 finalize 续作、E3 预备份失败零写入。
232 lines
9.0 KiB
Go
232 lines
9.0 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
|
||
}
|
||
|
||
// BackupRef 指向一份可独立恢复的完整预备份。
|
||
type BackupRef struct {
|
||
ID string `json:"id"`
|
||
// SnapshotID 是预备份在归档里的快照身份:恢复必须按它回放,
|
||
// 不能只记"备份路径"却不知道恢复到哪一份内容。
|
||
SnapshotID string `json:"snapshot_id"`
|
||
Bytes int64 `json:"bytes"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
}
|
||
|
||
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
|
||
}
|
||
outcome.State = "rolled_back"
|
||
outcome.Rollback = "已恢复到预备份 " + outcome.Backup.ID
|
||
recorder.Stage(ctx, "rollback", "succeeded", outcome.Rollback)
|
||
return outcome
|
||
}
|