98294b9583
build / build (push) Failing after 24s
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 集成、故障注入、部署。
313 lines
14 KiB
Go
313 lines
14 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 {
|
||
// 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
|
||
}
|