Files
knowledge-governance/cmd/worker/library_handoff.go
T
姚章浩 06c2db19fc
build / build (push) Successful in 1m31s
fix(library): 删除 handoff 路径残留的无条件 stage 写
BeginLibraryHandoff 事务里已经把 handoff 阶段推进为 running,函数返回后又补了一次
无条件 UpdateLibraryStage:既重复事务里的写,又在 Begin 返回与这次写之间给交接留出
竞态窗口(此时所有权可能已被 release 拿走)。删掉这次调用,handoff 路径的
running/succeeded/failed 三类阶段写只发生在 Begin/Complete/Fail 三个持有 job 行锁的
事务方法里。

回归:新增源码契约测试(library_handoff.go 里不得再出现 store.UpdateLibraryStage,
且必须走三个事务方法),以及行为测试(所有权不属于本次 handoff 时零阶段行、作业零改动;
用真实 handoff ID 重放则幂等成功且同样零写入)。既有事务行为测试保持不变。

验证:隔离 fixture PostgreSQL + 本 SHA libraryctl 实跑 cmd/worker 全包通过;完整门禁见提交日志。
未部署、未推送;云 SSH 与 UI 仍未验证。
2026-10-30 19:42:13 +08:00

358 lines
16 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"
"fmt"
"log"
"time"
"knowledge-governance/internal/appconfig"
"knowledge-governance/internal/governance"
"knowledge-governance/internal/jobs"
"knowledge-governance/internal/libraryhandoff"
"knowledge-governance/internal/librarysnapshot"
postgresstore "knowledge-governance/internal/platform/postgres"
)
// libraryHandoffJobType 与 API 侧常量同值:API 用它把"发布/恢复"拆成
// "先搬运、后发布"。搬运作业由 capture 角色执行(只有本机有源归档与 SSH 凭据),
// release-executor 永不注册它——云端没有源归档,也没有回本机的通道。
const libraryHandoffJobType = "library.snapshot.handoff"
// 回传分页:每次 100 条、最多 20 页(单轮最多核对 2000 份引用),
// 游标按 (created_at, snapshot_id) 推进,下一轮从最新处重新开始。
const (
pullPageSize = 100
// pullMaxPages 只是**单轮**工作量预算:一轮最多翻 20 页,避免一次扫描拖住 worker。
// 游标跨轮保留,所以预算不会变成"永远只取前 2000 条"。
pullMaxPages = 20
)
// pullCursor 是回传枚举的位置。跨轮保留:每轮结束把游标留在这一轮读到的最后一项,
// 下一轮从那里继续;读到不足一页说明这一圈走完了,游标回到起点(从最新处重新核对)。
type pullCursor struct {
created time.Time
id string
}
func (c pullCursor) advance(last postgresstore.SnapshotCandidate, count, pageSize int) pullCursor {
if count < pageSize {
return pullCursor{}
}
return pullCursor{created: last.CreatedAt, id: last.SnapshotID}
}
// libraryHandoffStage 是搬运在作业阶段表里的名字(页面能看出卡在搬运还是发布)。
const libraryHandoffStage = "handoff"
type libraryHandoffPayload struct {
LibraryJobID string `json:"library_job_id"`
Scope string `json:"scope"`
TargetSnapshotID string `json:"target_snapshot_id"`
NextType string `json:"next_type"`
}
// registerLibraryHandoffHandlers 注册"先把快照送到执行器"的作业。
//
// 顺序是这条链的安全边界:push 成功(云端逐 blob 校验通过、清单摘要自证、身份一致)
// 之后才投递真正的发布作业。push 失败时发布作业根本不存在,executor 不会开维护窗口、
// 不会写任何知识数据。
func registerLibraryHandoffHandlers(handlers map[string]jobs.Handler, store *postgresstore.Store,
config appconfig.Config, handoff *libraryhandoff.Handoff) {
handlers[libraryHandoffJobType] = func(ctx context.Context, job jobs.Job) error {
var payload libraryHandoffPayload
if err := json.Unmarshal(job.Payload, &payload); err != nil ||
payload.LibraryJobID == "" || payload.TargetSnapshotID == "" ||
(payload.Scope != "internal" && payload.Scope != "external") {
return fmt.Errorf("invalid library handoff payload")
}
return runLibraryHandoffForQueue(ctx, store, config, handoff, payload, job.ID)
}
}
// runLibraryHandoffForQueue 是 handoff 的唯一入口。queueJobID 是这次执行的**所有权凭据**:
// 业务作业的 queue_job_id 必须还是它,才允许写阶段/终态;交接给 release 之后,
// 迟到的旧 handoff 一次写入都不该落下去。
func runLibraryHandoffForQueue(ctx context.Context, store *postgresstore.Store, config appconfig.Config,
handoff *libraryhandoff.Handoff, payload libraryHandoffPayload, queueJobID string) error {
now := time.Now().UTC()
// 作业身份必须与载荷逐项一致:载荷来自队列,业务作业才是治理库的事实。
record, err := store.LibraryJob(ctx, payload.LibraryJobID)
if err != nil {
return fmt.Errorf("读不到业务作业 %s:%w", payload.LibraryJobID, err)
}
if record.Scope != payload.Scope || record.TargetSnapshotID != payload.TargetSnapshotID {
return failLibraryHandoffOwned(ctx, store, payload, queueJobID, "队列载荷与业务作业不一致(scope/快照),拒绝搬运")
}
switch payload.NextType {
case librarySyncJobType:
if record.Kind != governance.LibraryJobSync {
return failLibraryHandoffOwned(ctx, store, payload, queueJobID, "作业类型是恢复,但搬运要求投递同步,拒绝")
}
case libraryRestoreJobType:
if record.Kind != governance.LibraryJobRestore {
return failLibraryHandoffOwned(ctx, store, payload, queueJobID, "作业类型是同步,但搬运要求投递恢复,拒绝")
}
default:
return failLibraryHandoffOwned(ctx, store, payload, queueJobID, "未知的后续作业类型,拒绝搬运")
}
if record.State == governance.LibraryJobFailed || record.State == governance.LibraryJobCancelled ||
record.State == governance.LibraryJobSucceeded {
return failLibraryHandoffOwned(ctx, store, payload, queueJobID, "业务作业已终态,不再搬运")
}
// 期望身份来自**独立注入的绑定** + 版本台账的登记类型,绝不来自快照自报:
// 封存快照的清单写的是源 tenant/KB,目标知识库本来就不是它(云测试验收用专用 KB)。
want, identityErr := expectedSnapshotIdentity(ctx, store, config, payload.Scope, payload.TargetSnapshotID)
if identityErr != nil {
return failLibraryHandoffOwned(ctx, store, payload, queueJobID, "无法确定快照的可信归属,拒绝搬运:"+identityErr.Error())
}
if record.QueueJobID == governance.QueueJobIDFor(payload.LibraryJobID) &&
queueJobID == governance.LibraryHandoffQueueJobID(payload.LibraryJobID) {
log.Printf("library handoff replay job=%s: 已交接给 release,幂等跳过", payload.LibraryJobID)
return nil
}
// 阶段与所有权在同一事务里建立:不存在"先读 owner 再写 stage"的竞态窗口。
// 阶段写一律只发生在 Begin/Complete/Fail 三个持有 job 行锁的事务方法里:
// 这里若再补一次无条件 UpdateLibraryStage,既重复事务里的写,又会在
// Begin 返回与这次写之间被交接抢走——那正是上一轮退回的原因。
started, beginErr := store.BeginLibraryHandoff(ctx, payload.LibraryJobID, queueJobID,
"正在把快照送到发布执行器并校验", now)
if beginErr != nil {
return fmt.Errorf("开始搬运阶段失败:%w", beginErr)
}
if !started {
return fmt.Errorf("业务作业 %s 当前不由本次 handoff 持有(%q),未做任何修改",
payload.LibraryJobID, record.QueueJobID)
}
evidence, err := handoff.Push(ctx, want)
if err != nil {
message := "快照未送到发布执行器:" + err.Error()
return failLibraryHandoffOwned(ctx, store, payload, queueJobID, message)
}
// 只有搬运校验通过后,发布作业才存在。
payloadRaw, marshalErr := json.Marshal(map[string]string{
"library_job_id": payload.LibraryJobID, "scope": payload.Scope, "target_snapshot_id": payload.TargetSnapshotID,
})
if marshalErr != nil {
return failLibraryHandoffOwned(ctx, store, payload, queueJobID, "无法构造发布作业载荷")
}
// 投递、交接、阶段成功三件事在同一个事务里完成:不会出现"作业已交给 release
// 但阶段还停在 running",也不会出现"阶段成功却没交接"。
detail := fmt.Sprintf("快照已在校验后送达执行器(parts=%d bytes=%d reused=%t)",
evidence.Parts, evidence.Bytes, evidence.Reused)
handed, handErr := store.CompleteLibraryHandoff(ctx, payload.LibraryJobID, queueJobID, jobs.EnqueueRequest{
ID: governance.QueueJobIDFor(payload.LibraryJobID), Type: payload.NextType, Payload: payloadRaw,
AvailableAt: time.Now().UTC(), MaxAttempts: 1,
IdempotencyKey: payload.NextType + ".queue:" + payload.LibraryJobID,
}, detail, time.Now().UTC())
if handErr != nil {
// 事务已回滚:所有权仍在本 handoff 手里,失败文案可信。
return failLibraryHandoffOwned(ctx, store, payload, queueJobID,
"发布作业投递或关联失败(同一事务,未产生半完成状态):"+handErr.Error())
}
if !handed {
log.Printf("library handoff skip-handover job=%s: 所有权在搬运期间已转移,未做任何修改", payload.LibraryJobID)
return nil
}
log.Printf("library handoff completed job=%s scope=%s snapshot=%s parts=%d reused=%t",
payload.LibraryJobID, payload.Scope, payload.TargetSnapshotID, evidence.Parts, evidence.Reused)
return nil
}
// handoffFailureBarrier 只给测试用:在失败出口写库之前被调用,让测试能把所有权
// 确定地切给 release,从而复现"失败出口到来时所有权已交接"的竞态。生产恒为 nil。
var handoffFailureBarrier func()
func failLibraryHandoffOwned(ctx context.Context, store *postgresstore.Store, payload libraryHandoffPayload,
queueJobID, message string) error {
if handoffFailureBarrier != nil {
handoffFailureBarrier()
}
// 阶段失败与作业失败同一个事务:要么都写,要么都不写。
changed, err := store.FailLibraryHandoff(ctx, payload.LibraryJobID, queueJobID, message, time.Now().UTC())
if err != nil {
return fmt.Errorf("%s(写失败状态出错:%v)", message, err)
}
if !changed {
log.Printf("library handoff fail-skipped job=%s: 所有权已交接或作业已终态,未修改任何状态", payload.LibraryJobID)
}
return fmt.Errorf("%s", message)
}
// runLibraryArchivePullLoop 把云端产生的快照(预备份等)取回本机归档。
//
// 为什么要回传而不是"页面直接读云端":API 的所有归档消费点(列表/详情/差异/存储/
// 容量/恢复寻址)都读本机 LIBRARY_ARCHIVE_ROOT,回传后它们读到的就是真实字节,
// 不需要给每条读路径加远端分支,也不会把两端路径当成全局身份(身份始终是 snapshot ID)。
//
// 失败只记日志:本机没有这份版本时页面按"无此版本/不可读"展示,不会被误报成可恢复。
func runLibraryArchivePullLoop(ctx context.Context, store *postgresstore.Store, handoff *libraryhandoff.Handoff,
archiveRoot string, interval time.Duration, report func(error)) {
if interval <= 0 {
interval = 5 * time.Minute
}
cursors := map[string]pullCursor{}
pull := func() {
for _, scope := range []string{"internal", "external"} {
next, fetched, err := pullRound(ctx, store, func(id string) bool {
archive, openErr := librarysnapshot.Open(archiveRoot, 256*1024*1024)
if openErr != nil {
return false
}
defer archive.Close()
_, loadErr := archive.Load(id)
return loadErr == nil
}, func(fetchCtx context.Context, id string) (libraryhandoff.Evidence, error) {
return handoff.Fetch(fetchCtx, id)
}, scope, cursors[scope])
if err != nil {
report(err)
}
cursors[scope] = next
if fetched > 0 {
log.Printf("library archive pull round scope=%s fetched=%d", scope, fetched)
}
}
}
pull()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
pull()
}
}
}
// expectedSnapshotIdentity 决定"这份快照的清单身份应该等于谁"。
//
// 两类快照的身份来源不同,混用会把合法的跨 KB 发布判成非法(或反过来放行错数据):
// - 封存版本(test_sealed):清单里是**源**的 tenant/KB → 用部署注入的源绑定核对;
// - 目标状态版本(预备份/生效/恢复):清单里是**目标**的 tenant/KB → 用目标绑定核对。
//
// 没有登记过版本、或两类同时出现,一律拒绝:来源不明的快照不允许进入发布链路。
func expectedSnapshotIdentity(ctx context.Context, store *postgresstore.Store, config appconfig.Config,
scope, snapshotID string) (libraryhandoff.Identity, error) {
rows, err := store.LibraryVersionsBySnapshot(ctx, scope, snapshotID)
if err != nil {
return libraryhandoff.Identity{}, err
}
if len(rows) == 0 {
return libraryhandoff.Identity{}, fmt.Errorf("快照 %s 在该 scope 下没有登记过任何版本", snapshotID)
}
// 原产地只由**捕获**决定,且必须读证据:
// - test_sealed:源端封存 → 源绑定;
// - production_prebackup:目标端预备份 → 目标绑定;
// - production_effective 且证据 action=target_baseline_init / origin=target_baseline:
// 目标权威基线(library_baseline 的真实整库捕获)→ 目标绑定;
// - 普通 production_effective(发布成功后的引用)与 recovery_verified 不是捕获,不决定原产地。
sourceOrigin, targetOrigin := false, false
for _, row := range rows {
switch row.Kind {
case governance.LibraryVersionTestSealed:
sourceOrigin = true
case governance.LibraryVersionProductionPrebackup:
targetOrigin = true
case governance.LibraryVersionProductionEffective:
if isTargetBaselineEvidence(row.Evidence) {
targetOrigin = true
}
}
}
if sourceOrigin && targetOrigin {
return libraryhandoff.Identity{}, fmt.Errorf("快照 %s 既由源封存、又由目标捕获(预备份/基线):原产地冲突", snapshotID)
}
if !sourceOrigin && !targetOrigin {
return libraryhandoff.Identity{}, fmt.Errorf("快照 %s 没有捕获证据(封存/预备份/目标基线),来源不明", snapshotID)
}
sealedState := sourceOrigin
tenant, knowledgeBase := config.LibrarySourceTenantID, config.LibrarySourceInternalKB
label := "源绑定"
if !sealedState {
tenant, knowledgeBase = config.LibraryProductionTenantID, config.LibraryProductionInternalKB
label = "目标绑定"
}
if scope == "external" {
if sealedState {
knowledgeBase = config.LibrarySourceExternalKB
} else {
knowledgeBase = config.LibraryProductionExternalKB
}
}
if tenant == 0 || knowledgeBase == "" {
return libraryhandoff.Identity{}, fmt.Errorf("未配置%s(tenant/知识库),无法核对快照身份", label)
}
return libraryhandoff.Identity{SnapshotID: snapshotID, Scope: scope, TenantID: tenant, KnowledgeBaseID: knowledgeBase}, nil
}
// snapshotLister 是回传需要的存储面(便于用真实分页语义做单测)。
type snapshotLister interface {
LibrarySnapshotCandidates(ctx context.Context, scope string, afterCreated time.Time, afterID string,
limit int) ([]postgresstore.SnapshotCandidate, error)
}
// pullRound 是**单轮回传**:最多 pullMaxPages 页,返回下一轮的游标与取回条数。
//
// 两个细节都是真实缺陷的教训:
// - 空页必须把游标**重置**(否则引用数正好是 pageSize 整数倍时,游标永远停在最老处,
// 下一轮还是空,之后新增的快照永远取不到);
// - 游标跨轮保留(单轮预算不等于总量上限)。
func pullRound(ctx context.Context, source snapshotLister, isLocal func(string) bool,
fetch func(context.Context, string) (libraryhandoff.Evidence, error), scope string,
cursor pullCursor) (pullCursor, int, error) {
fetched := 0
for page := 0; page < pullMaxPages; page++ {
items, err := source.LibrarySnapshotCandidates(ctx, scope, cursor.created, cursor.id, pullPageSize)
if err != nil {
return cursor, fetched, fmt.Errorf("读不到 %s 的快照引用:%w", scope, err)
}
if len(items) == 0 {
// 这一圈走完了:回到起点,下一轮从最新处重新核对(新增引用才会被看到)。
return pullCursor{}, fetched, nil
}
for _, item := range items {
if err := ctx.Err(); err != nil {
return cursor, fetched, err
}
if isLocal(item.SnapshotID) {
continue
}
evidence, fetchErr := fetch(ctx, item.SnapshotID)
if fetchErr != nil {
// 单份失败不阻断整轮:下一轮会再试(游标仍推进,靠的是跨轮再核对)。
continue
}
fetched++
log.Printf("library archive pull snapshot=%s scope=%s parts=%d bytes=%d reused=%t",
evidence.Snapshot, evidence.Scope, evidence.Parts, evidence.Bytes, evidence.Reused)
}
cursor = cursor.advance(items[len(items)-1], len(items), pullPageSize)
if cursor == (pullCursor{}) {
return cursor, fetched, nil
}
}
return cursor, fetched, nil
}
// isTargetBaselineEvidence 判定一条 production_effective 是不是"目标权威基线"的真实捕获。
// 只认基线登记时写下的字段;普通发布成功的 effective 事件没有这些字段,不算原产地。
func isTargetBaselineEvidence(evidence map[string]any) bool {
if len(evidence) == 0 {
return false
}
if action, _ := evidence["action"].(string); action == "target_baseline_init" {
return true
}
if origin, _ := evidence["origin"].(string); origin == "target_baseline" {
return true
}
return false
}