517d5f862d
目标非空又没有生效版本时,首次发布以前只能停在"权威版本未知"。现在有了 真实入口:管理员发起 baseline 作业,worker 在维护隔离下把目标整库捕获成 归档快照,再用同一套 Verify 证明目标与快照逐类一致,最后登记 production_effective(证据写明 origin=target_baseline、快照 ID、逐表行数、 目标索引映射)。不写目标内容、不清空目标、不手写版本。 同时修两处会误导人的判定: - productionScopeEmpty 原来只数 knowledges,"文档删了但分块/Wiki/资源还在" 的目标会被当成空库;改用适配层同一份表清单与范围过滤逐表计数。 - 概览里的"测试封存候选"原来取该 scope 最新一份归档,而归档里还有生产 预备份与目标基线快照;改为只认版本登记里 kind=test_sealed 的那一份。 迁移 075 放开 kind='baseline' 并把它并入 sync/restore 的写互斥索引; worker 重启时用同一作业 id 接管未完成的基线窗口。维护接口补 capability, 页面才能区分"未运行维护"与"维护能力没接线"。 验证:go build ./... / go vet ./... 通过;新增用例覆盖管理员校验、已有生效 版本拒绝、目标读不到时零作业零版本、候选来自登记、缺项文案指向基线入口、 capability 逐项缺失;反向自检(把候选改回按最新归档取、把文案改回旧句、 去掉管理员判断)三项均按预期变红后复原。
228 lines
12 KiB
Go
228 lines
12 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"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"`
|
||
}
|
||
|
||
// runLibraryBaseline 给"非空但没有权威版本"的目标建立基线。
|
||
//
|
||
// 真实链路:维护隔离(关入口/停写入者/证明排空)→ 把目标整库捕获成归档快照
|
||
// → 用同一套 Verify 证明目标与这份快照逐类一致 → 登记 production_effective。
|
||
// 它不写目标内容、不清空目标、不猜版本:基线只是"目标现在是什么"的忠实记录,
|
||
// 因此失败时重新开放目标永远是安全的(allowRestore 恒 true)。
|
||
func runLibraryBaseline(ctx context.Context, store *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")
|
||
}
|
||
now := time.Now().UTC()
|
||
recorder := libraryStageRecorder{store: store, jobID: payload.LibraryJobID, scope: payload.Scope}
|
||
fail := func(message, stage string) error {
|
||
return failLibraryJob(ctx, store, recorder, payload.LibraryJobID, message, stage)
|
||
}
|
||
_ = store.AdvanceLibraryJob(ctx, payload.LibraryJobID, governance.LibraryJobRunning, "capture", "", "", "", "", now)
|
||
|
||
// 已有生效版本时不再建基线:基线只解决"目标非空但无权威版本"的首次发布,
|
||
// 版本切换必须走同步/恢复(它们有预备份、回滚与授权)。
|
||
versions, versionErr := store.LibraryVersions(ctx, payload.Scope, 50)
|
||
if versionErr != nil {
|
||
return fail("读不到版本记录,无法确认是否需要建立基线;未对目标执行任何操作", "capture")
|
||
}
|
||
for _, item := range versions {
|
||
if item.Kind == governance.LibraryVersionProductionEffective && item.SupersededAt == nil {
|
||
return fail("该目标已有生效版本 "+item.SnapshotID+",不需要再建基线;换版本请走同步或恢复", "capture")
|
||
}
|
||
}
|
||
|
||
if !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 动的是同一批容器,必须按实例互斥。
|
||
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: "instance",
|
||
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 string
|
||
var counts map[string]int64
|
||
verified := false
|
||
work := func(ctx context.Context, window *libraryfreeze.Window) (string, error) {
|
||
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())
|
||
}
|
||
// 作业行也要立刻带上这份快照身份:worker 被杀后接管/按预备份恢复都靠它,
|
||
// 否则"目标停着、却不知道拿哪一份核对"会让目标一直卡在维护里。
|
||
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)
|
||
recorder.Stage(ctx, "verify", "running", "逐表/索引/资源比对目标与刚封存的快照")
|
||
if verifyErr := target.Verify(ctx, libraryrelease.Expectation{SnapshotID: baselineID}); verifyErr != nil {
|
||
return "", errors.New("目标内容与刚封存的快照不一致,拒绝登记基线:" + verifyErr.Error())
|
||
}
|
||
verified = true
|
||
recorder.Stage(ctx, "verify", "succeeded", "行身份/逐行内容/索引文档/资源摘要与快照一致")
|
||
return baselineID, nil
|
||
}
|
||
|
||
actor := ""
|
||
if libJob, jobErr := store.LibraryJob(ctx, payload.LibraryJobID); jobErr == nil {
|
||
actor = libJob.Actor
|
||
}
|
||
window, windowErr := coordinator.RunWindow(ctx, payload.LibraryJobID,
|
||
func(ctx context.Context) error { return manager.VerifyRunning(ctx) }, work,
|
||
func(error) bool { return true })
|
||
|
||
now2 := time.Now().UTC()
|
||
if !verified || window.State != "succeeded" {
|
||
message := "未建立基线:维护窗口没有完成捕获与校验"
|
||
if windowErr != nil {
|
||
message += ":" + windowErr.Error()
|
||
}
|
||
if window.State == "held" {
|
||
// 目标仍被占用:内容状态未知只能按维护保持,等"按预备份恢复"收尾。
|
||
note := message + "(目标保持维护)"
|
||
_ = store.UpdateLibraryMaintenanceState(ctx, payload.Scope, payload.LibraryJobID, "needs_restore", note, now2)
|
||
_ = store.UpdateInstanceMaintenanceState(ctx, identity.Instance, payload.LibraryJobID, "needs_restore", note, now2)
|
||
recorder.Stage(ctx, "capture", "failed", note)
|
||
_ = store.AdvanceLibraryJob(ctx, payload.LibraryJobID, governance.LibraryJobFailed, "capture", note,
|
||
"按预备份恢复并校验后再重试", baselineID, "", now2)
|
||
return errors.New(note)
|
||
}
|
||
// 没有写过目标:重新开放占用是安全的,窗口也已按原状态恢复。
|
||
if releaseErr := releaseWindowLocks(ctx, store, identity.Instance, payload.Scope, payload.LibraryJobID,
|
||
"未建立基线,目标未被改动", now2); releaseErr != nil {
|
||
return fmt.Errorf("%s;维护占用释放失败: %w", message, releaseErr)
|
||
}
|
||
recorder.Stage(ctx, "capture", "failed", message)
|
||
_ = store.AdvanceLibraryJob(ctx, payload.LibraryJobID, governance.LibraryJobFailed, "capture", message,
|
||
"未对目标执行任何写入,可直接重试", baselineID, "", now2)
|
||
return errors.New(message)
|
||
}
|
||
|
||
// 校验通过 → 登记权威基线。证据里记的是这份快照本身、逐表行数与目标索引映射;
|
||
// created_by 用发起人(管理员),不是 worker 自封。
|
||
recorder.Stage(ctx, "finalize", "running", "登记目标权威基线")
|
||
mapping, _ := target.Search().MappingSHA256(ctx)
|
||
versionErr = store.RecordLibraryVersion(ctx, governance.LibraryVersion{
|
||
ID: governance.LibraryVersionID(governance.LibraryVersionProductionEffective, payload.Scope, baselineID),
|
||
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: now2,
|
||
})
|
||
if versionErr != nil {
|
||
message := "目标已捕获并校验,但基线版本登记失败:" + versionErr.Error()
|
||
recorder.Stage(ctx, "finalize", "failed", message)
|
||
_ = store.UpdateLibraryMaintenanceState(ctx, payload.Scope, payload.LibraryJobID, "needs_restore", message, now2)
|
||
_ = store.UpdateInstanceMaintenanceState(ctx, identity.Instance, payload.LibraryJobID, "needs_restore", message, now2)
|
||
_ = store.AdvanceLibraryJob(ctx, payload.LibraryJobID, governance.LibraryJobFailed, "finalize", message,
|
||
"基线快照 "+baselineID+" 保留;修好治理库后可续作登记", baselineID, "", time.Now().UTC())
|
||
return errors.New(message)
|
||
}
|
||
recorder.Stage(ctx, "finalize", "succeeded", "已登记目标权威基线 "+baselineID)
|
||
|
||
// 成功要等目标服务、入口与两把维护锁都落定才暴露。
|
||
if finishErr := finishLibrarySuccess(ctx, store, manager, identity.Instance, payload.Scope,
|
||
payload.LibraryJobID, successBoundary{Ready: true, Backup: baselineID, FinishedAt: time.Now().UTC()}); finishErr != nil {
|
||
return finishErr
|
||
}
|
||
log.Printf("library baseline succeeded job=%s scope=%s snapshot=%s actor=%s",
|
||
payload.LibraryJobID, payload.Scope, baselineID, actor)
|
||
return nil
|
||
}
|