Files
knowledge-governance/cmd/worker/library_baseline.go
T
姚章浩 a7d643635a
build / build (push) Successful in 1m27s
fix(library): 守卫按行取作业身份、同作业多代次、原作业续作与执行域准入
R1 维护/发布守卫硬编码 j.id=$1:维护类入口的 $1 是 scope/instance,作业号在 $2,
导致 update/release/ingress 六个入口正常路径全部匹配不到作业。改为按被更新行自己的
owner_job_id 生成条件,并同时要求该代次在 library_attempts 里仍活跃。

R2 认领只改 jobs.attempt_epoch 不改 attempts.epoch,第二次接管即错位;唯一索引
(job_id) 与"每次接管新 attempt 且历史不可变"矛盾。改为同作业多代次:锁作业行与
operation 行、同 owner 幂等续租、活跃未过期者让路、过期/本作业旧代次标 abandoned、
新代次 epoch=max+1,并清同 operation 其它作业行租约。迟到 write/renew/finish 一律
ErrLibraryEpochStale(finish 补了"只能在活跃态进来一次")。

R3 续作不再新建 -resume 作业:PrepareLibraryJobResume 让原业务作业回 pending 并显式
结束上一代,维护窗口/journal/预备份/写集合仍按原作业 id 归属,代次由 worker 起手递增。

R4 执行域准入改为注册 handler 前读事实:目标实况(KB/租户/schema/索引)、旧 release
路由绑定的 KB、未释放维护窗口持有者;未知或重叠即拒绝执行(不做维护动作、不停既有服务)。

同时自纠 5 项设计偏离:current 次序键取意图创建时间(旧意图迟到事实被拒)、首次锚点
改事务 FOR UPDATE + 回读且 NULL≠0、版本事件冲突只做加法且 xmax=0 才算新事实、
恢复作为新 kind=recover 意图推进 current、隔离测试加测试库护栏并清理残留 schema。

测试:真实 PG(kg-pgtest-writeset,一用例一 schema)新增 8 条集成用例覆盖上述正负例;
go test ./... 全绿(50 包)、go vet/build rc=0、web tsc + vitest 485 通过、make verify 16 项通过。
2026-10-30 17:20:40 +08:00

310 lines
15 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 main
import (
"context"
"encoding/json"
"errors"
"log"
"time"
"knowledge-governance/internal/appconfig"
"knowledge-governance/internal/governance"
"knowledge-governance/internal/jobs"
"knowledge-governance/internal/libraryfreeze"
"knowledge-governance/internal/librarymaintenance"
"knowledge-governance/internal/libraryrelease"
postgresstore "knowledge-governance/internal/platform/postgres"
)
// libraryBaselinePayload 是"建立目标权威基线"的队列载荷。
type libraryBaselinePayload struct {
LibraryJobID string `json:"library_job_id"`
Scope string `json:"scope"`
}
// classifyBaselineVersions 回答"这个 scope 现在有没有生效版本、是不是本作业登记的"。
//
// 输入是**当前生效版本**这一个精确查询的结果(每 scope 至多一行),不是"最近 N 条":
// 窗口一满,已登记过的基线会被读成"没有",于是重复捕获、覆盖别人的版本。
//
// 三种情况必须分开,否则崩溃续作会被自己的版本挡住:
// - own:本作业 + 同一快照已经登记过 → 只收尾,绝不重复捕获或重写版本;
// - foreign:别人登记的生效版本 → 一律拒绝(不能用基线覆盖别人的版本);
// - none:还没有生效版本 → 正常建基线。
type baselineVersionState struct {
Own bool
Foreign *governance.LibraryVersion
}
func classifyBaselineVersions(current governance.LibraryVersion, hasCurrent bool, jobID, snapshotID string) baselineVersionState {
if !hasCurrent {
return baselineVersionState{}
}
if effectiveOwnedBy(current, jobID, snapshotID) {
return baselineVersionState{Own: true}
}
foreign := current
return baselineVersionState{Foreign: &foreign}
}
// settleBaselineWindow 是基线窗口的**唯一**收尾入口,负责让治理状态与实际入口一致:
//
// - 版本已登记(本次刚写,或崩溃前由本作业写过)→ 走与同步一样的成功边界:
// 目标健康、入口开放、两把维护锁释放之后才暴露 succeeded;
// - 没有登记且窗口仍是 held(服务没回来)→ 保持维护并标 needs_restore,
// 只有"按预备份恢复"能把它收掉;
// - 没有登记但窗口已按原状态恢复 → 释放占用并把作业标失败,明确写"未对目标写入",
// 绝不留着"显示维护中、实际已开放"的状态。
func settleBaselineWindow(ctx context.Context, store libraryMaintenanceStore, health healthChecker,
jobID, scope, instance, baselineID string, recorded bool, windowState string, windowErr error) error {
if recorded {
finishedAt := time.Now().UTC()
return finishLibrarySuccess(ctx, store, health, instance, scope, jobID,
successBoundary{Ready: true, Backup: baselineID, FinishedAt: finishedAt})
}
now := time.Now().UTC()
message := "未建立基线:维护窗口没有完成捕获与校验"
if windowErr != nil {
message += ":" + windowErr.Error()
}
if windowState == "held" {
note := message + "(目标仍被停住、入口未恢复,保持维护)"
_ = store.UpdateLibraryMaintenanceState(ctx, scope, jobID, "needs_restore", note, now)
_ = store.UpdateInstanceMaintenanceState(ctx, instance, jobID, "needs_restore", note, now)
_ = store.AdvanceLibraryJob(ctx, jobID, governance.LibraryJobFailed, "capture", note,
"目标仍在维护:先按预备份恢复并校验", baselineID, "", now)
return errors.New(note)
}
// 窗口已按原状态开放(本流程对目标内容只读):占用必须跟着释放,
// 不能留下"界面说维护中、实际入口已开"的记录。
if releaseErr := releaseWindowLocks(ctx, store, instance, scope, jobID,
"未登记基线,目标内容未被写入,窗口已按原状态恢复", now); releaseErr != nil {
note := message + ";维护占用释放失败:" + releaseErr.Error()
_ = store.AdvanceLibraryJob(ctx, jobID, governance.LibraryJobFailed, "capture", note,
"维护记录与实际入口不一致:先人工核对再重试", baselineID, "", now)
return errors.New(note)
}
_ = store.AdvanceLibraryJob(ctx, jobID, governance.LibraryJobFailed, "capture", message,
"未对目标执行任何写入;可直接重试建立基线", baselineID, "", now)
return errors.New(message)
}
// runLibraryBaseline 给"非空但没有权威版本"的目标建立基线。
//
// 真实链路(全部在同一个维护窗口内):关入口/停写入者/证明排空 → 把目标整库捕获成
// 归档快照 → 用同一套 Verify 证明目标与这份快照逐类一致 → 读目标索引映射 →
// 登记 production_effective → 关窗口(恢复服务、开入口)→ 释放两把占用 → 作业成功。
//
// 崩溃语义:
// - 捕获后、登记前被杀:作业行里已有快照 ID,重入时**不重新捕获**,直接在窗口内
// 复核这份快照再登记;
// - 登记后、收尾前被杀:重入识别出"本作业+同一快照"已生效,只做收尾
// (恢复服务/开入口/释放占用),不重复捕获、不重写版本;别人的版本仍然拒绝。
func runLibraryBaseline(ctx context.Context, base *postgresstore.Store, config appconfig.Config, job jobs.Job) error {
var payload libraryBaselinePayload
if err := json.Unmarshal(job.Payload, &payload); err != nil || payload.LibraryJobID == "" ||
(payload.Scope != "internal" && payload.Scope != "external") {
return errors.New("invalid library baseline payload")
}
// 准入与代次:基线也会停写入者、取整库快照,同样是"动目标"的执行,
// 必须和 sync/restore 用同一套准入与 fencing(F14/F13)。
if _, admitErr := admitLibraryJob(ctx, base, job, payload.LibraryJobID, payload.Scope, governance.LibraryJobBaseline, true); admitErr != nil {
return admitErr
}
writer, attemptErr := beginLibraryWriter(ctx, base, payload.LibraryJobID, libraryQueueOwner(config.WorkerID, job, payload.LibraryJobID))
if attemptErr != nil {
return attemptErr
}
store := writer
now := time.Now().UTC()
recorder := libraryStageRecorder{store: store, jobID: payload.LibraryJobID, scope: payload.Scope,
operationID: writer.attempt.OperationID}
fail := func(message, stage string) error {
return failLibraryJob(ctx, store, recorder, payload.LibraryJobID, message, stage)
}
existing, jobErr := store.LibraryJob(ctx, payload.LibraryJobID)
actor := ""
persistedSnapshot := ""
if jobErr == nil {
actor = existing.Actor
persistedSnapshot = existing.SnapshotID
}
_ = store.AdvanceLibraryJob(ctx, payload.LibraryJobID, governance.LibraryJobRunning, "capture", "", "", "", "", now)
// 版本判定:别人的生效版本一律拒绝;本作业已登记过的快照只收尾。
// 用精确查询(当前生效版本),不用"最近 N 条":窗口一满会把已登记读成没登记。
current, hasCurrent, versionErr := store.LibraryCurrentVersion(ctx, payload.Scope)
if versionErr != nil {
return fail("读不到生效版本记录,无法确认是否需要建立基线;未对目标执行任何操作", "capture")
}
state := classifyBaselineVersions(current, hasCurrent, payload.LibraryJobID, persistedSnapshot)
if state.Foreign != nil {
return fail("该目标已有生效版本 "+shortSnapshotID(state.Foreign.SnapshotID)+
"(作业 "+state.Foreign.SourceJobID+"),不需要再建基线;换版本请走同步或恢复", "capture")
}
finalizeOnly := state.Own
if finalizeOnly {
recorder.Stage(ctx, "finalize", "running", "续作:本作业已登记同一份基线快照,只做收尾(不重新捕获)")
}
if !finalizeOnly && !config.ProductionReleaseRequestsEnabled {
return fail("生产写入闸口关闭(PRODUCTION_RELEASE_REQUESTS_ENABLED=false):建立基线要动目标运行状态,闸口关闭时不执行", "capture")
}
target, backups, wireErr := buildLibraryBaselineStack(ctx, config, payload.Scope)
if wireErr != nil {
return fail(wireErr.Error(), "capture")
}
defer target.Close()
manager, managerErr := openLibraryMaintenance(config)
if managerErr != nil {
return fail("目标维护未接线,拒绝在挡不住入口/后台写入的情况下捕获目标:"+managerErr.Error(), "capture")
}
defer manager.Close()
if topoErr := manager.VerifyTopology(ctx); topoErr != nil {
return fail("目标拓扑不满足唯一入口约束,拒绝开维护窗口:"+topoErr.Error(), "capture")
}
originalState, stateErr := manager.OriginalState(ctx)
if stateErr != nil {
return fail("读不到目标写入者的原运行状态,不能开维护窗口:"+stateErr.Error(), "capture")
}
identity := manager.Target()
// 实例级占用:internal/external 动的是同一批容器,必须按实例互斥。
// 同一作业重入时 store 保留第一次记下的原运行状态(否则会把"已停"当原状)。
if _, instanceErr := store.AcquireInstanceMaintenance(ctx, postgresstore.LibraryInstanceMaintenance{
Instance: identity.Instance, OwnerJobID: payload.LibraryJobID, OwnerScope: payload.Scope,
TargetFingerprint: identity.Fingerprint, Level: librarymaintenance.LevelInstance,
Services: identity.Writers, IngressState: "unknown", State: "acquired",
OriginalServiceState: originalState,
Note: "实例级维护:为建立权威基线捕获目标整库,捕获并校验后按原状态恢复",
}, now); instanceErr != nil {
return fail("目标实例正在维护中(跨对内/对外互斥):"+instanceErr.Error(), "capture")
}
if _, acquireErr := store.AcquireLibraryMaintenance(ctx, postgresstore.LibraryMaintenance{
Scope: payload.Scope, OwnerJobID: payload.LibraryJobID,
TargetIdentity: identity.Fingerprint,
Level: librarymaintenance.LevelInstance,
TargetFingerprint: identity.Fingerprint,
Services: identity.Writers,
IngressState: "unknown",
State: "acquired",
OriginalServiceState: originalState,
Note: "实例级维护:为建立权威基线捕获目标整库,捕获并校验后按原状态恢复",
}, now); acquireErr != nil {
_ = store.UpdateInstanceMaintenanceState(ctx, identity.Instance, payload.LibraryJobID, "needs_restore",
"scope 占用申请失败,实例状态未确认:"+acquireErr.Error(), now)
return fail("目标正在维护中:"+acquireErr.Error(), "capture")
}
coordinator, control, openErr := manager.Open(config.LibraryTargetJournalRoot)
if openErr != nil {
_ = releaseWindowLocks(ctx, store, identity.Instance, payload.Scope, payload.LibraryJobID,
"未进入维护窗口(协调器不可用),目标未被停用", time.Now().UTC())
return fail("维护协调器不可用:"+openErr.Error(), "capture")
}
defer control.Close()
defer coordinator.Close()
var (
baselineID = persistedSnapshot
counts map[string]int64
recorded = finalizeOnly
)
work := func(ctx context.Context, window *libraryfreeze.Window) (string, error) {
if finalizeOnly {
// 版本已在窗口内校验并登记过:不再捕获、不再重写,收尾由下方统一处理。
return baselineID, nil
}
if baselineID == "" {
current, currentErr := target.Current(ctx)
if currentErr != nil {
return "", errors.New("读取目标当前整库状态失败:" + currentErr.Error())
}
counts = map[string]int64{}
for table, count := range current.Counts {
counts[table] = count
}
// 预备份存储在导出后用同一份指纹复核"导出期间内容没变",
// 所以这里把窗口内读到的 state 交进去,而不是事后再读一次。
backup, backupErr := backups.Prepare(ctx, "", current)
if backupErr != nil {
return "", errors.New("捕获目标整库失败:" + backupErr.Error())
}
baselineID = backup.SnapshotID
// 先把"这次窗口的基线快照"落盘:进程在这里被杀时,恢复器知道该回到哪一份,
// 重入也据此跳过重新捕获。
if markErr := window.SetBackupSnapshot(ctx, baselineID); markErr != nil {
return "", errors.New("记录基线快照身份失败:" + markErr.Error())
}
if persistErr := store.AdvanceLibraryJob(ctx, payload.LibraryJobID, governance.LibraryJobRunning, "",
"", "", baselineID, "", time.Now().UTC()); persistErr != nil {
return "", errors.New("持久化基线快照引用失败:" + persistErr.Error())
}
recorder.Stage(ctx, "capture", "succeeded", "已封存目标整库快照 "+baselineID)
} else {
recorder.Stage(ctx, "capture", "skipped", "复用作业已持久化的基线快照 "+baselineID)
}
if baselineID == "" {
return "", errors.New("没有可校验的基线快照")
}
recorder.Stage(ctx, "verify", "running", "逐表/索引/资源比对目标与快照 "+shortSnapshotID(baselineID))
if verifyErr := target.Verify(ctx, libraryrelease.Expectation{SnapshotID: baselineID}); verifyErr != nil {
return "", errors.New("目标内容与基线快照不一致,拒绝登记基线:" + verifyErr.Error())
}
recorder.Stage(ctx, "verify", "succeeded", "行身份/逐行内容/索引文档/资源摘要与快照一致")
// 索引映射是基线证据的一部分:读不到就不登记,绝不用空摘要冒充。
mapping, mapErr := target.Search().MappingSHA256(ctx)
if mapErr != nil {
return "", errors.New("读不到目标索引映射,拒绝用空摘要登记基线:" + mapErr.Error())
}
if counts == nil {
if current, currentErr := target.Current(ctx); currentErr == nil {
counts = current.Counts
}
}
// 版本登记放在窗口内:捕获、校验、登记三件事要么一起成立,要么目标原样恢复。
recorder.Stage(ctx, "finalize", "running", "登记目标权威基线")
if recordErr := store.RecordLibraryVersion(ctx, governance.LibraryVersion{
ID: governance.LibraryVersionEventID(governance.LibraryVersionProductionEffective, payload.Scope, baselineID, writer.attempt.OperationID),
Scope: payload.Scope, Kind: governance.LibraryVersionProductionEffective,
SnapshotID: baselineID, SourceJobID: payload.LibraryJobID, CreatedBy: actor,
Evidence: map[string]any{
"action": "target_baseline_init",
"origin": "target_baseline",
"baseline_snapshot": baselineID,
"index_mapping_sha256": mapping,
"scope_content_counts": counts,
"verification": "目标与快照逐表行身份/逐行内容/索引文档/资源摘要一致",
"note": "基线是目标在维护窗口内的真实整库捕获,不是手写版本;发布仍需独立 sync 授权",
},
CreatedAt: time.Now().UTC(),
}); recordErr != nil {
return "", errors.New("登记基线版本失败:" + recordErr.Error())
}
recorded = true
recorder.Stage(ctx, "finalize", "succeeded", "已登记目标权威基线 "+shortSnapshotID(baselineID))
return baselineID, nil
}
window, windowErr := coordinator.RunWindow(ctx, payload.LibraryJobID,
func(ctx context.Context) error { return manager.VerifyRunning(ctx) }, work,
// 本流程对目标内容只读:失败时按原状态重新开放永远安全。
func(error) bool { return true })
if settleErr := settleBaselineWindow(ctx, store, manager, payload.LibraryJobID, payload.Scope,
identity.Instance, baselineID, recorded, window.State, windowErr); settleErr != nil {
return settleErr
}
log.Printf("library baseline succeeded job=%s scope=%s snapshot=%s actor=%s finalize_only=%v",
payload.LibraryJobID, payload.Scope, baselineID, actor, finalizeOnly)
return nil
}
// shortSnapshotID 只用于人看的日志/阶段说明:完整摘要留在版本记录与接口里。
func shortSnapshotID(id string) string {
if len(id) <= 12 {
return id
}
return id[:12]
}