Files
knowledge-governance/internal/libraryrelease/release.go
T
姚章浩 c72b71d78a
build / build (push) Failing after 17s
fix(library): 预备份硬门禁 + 崩溃接管 + 五项入口验收
为什么:父审指出预备份引用持久化失败仍继续写目标、接管依赖启动扫描且漏掉租约未过期、
恢复失败被标成"无需恢复"、版本登记与作业状态分叉。

- 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 预备份失败零写入。
2026-10-28 19:11:37 +08:00

232 lines
9.0 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
}
// 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
}