Files
knowledge-governance/cmd/worker/library_handoff_integration_test.go
T
姚章浩 8a68ed1819 fix(library): 交接所有权 CAS、回传游标跨轮与快照原产地
父审查确认四项是原 finding 未闭环,本包按真实调用链修完(先 RED 再修)。

R4a 正常流程必现阻断:API 先入 handoff 队列作业并关联,交接却只接受"未关联",
每次正常发布都被自己的所有权检查挡住。新增 governance.LibraryHandoffQueueJobID
(jobs.id 为 varchar(36),原实现拼后缀超长,插入即失败);EnqueueAndAttachLibraryJob
增加"声明的前驱队列作业号",只允许当前无人持有或正是该 handoff 时替换,任意替换拒绝。
测试从 API 真实前驱(已关联)开始,覆盖正常交接、错误前驱拒绝、交接后重放、
以及 release 与 handoff 两条队列行都存在。

R4b 迟到 handoff 污染已交接作业:失败出口(含 Push 错误)改为所有权 CAS
(FailLibraryHandoffIfOwner),只在业务作业仍由本次 handoff 持有时写 state/stage/recovery;
已交接/已终态零修改。所有权判定改为只看 library_jobs.queue_job_id(阶段行是否存在
与所有权无关,原实现会误判)。阶段写入也带所有权。用测试屏障确定性地复现"失败出口
到来时所有权已切给 release",验证 release running 与 succeeded 两种状态下零修改。

R2 回传游标每轮重置:改为跨轮保留(单轮仍 20 页预算),读到不足一页才回到起点;
新增 pullCursor 与跨轮覆盖测试(2100 条引用、每轮 5 页仍全部覆盖,不重复不漏取)。

R3 原产地误判:封存成功后会再登记 production_effective(同一快照),原实现把
sealed+target 判成冲突,正常再发布/历史恢复被误拒。现在原产地只由捕获事件决定
(test_sealed=源 / production_prebackup=目标),effective/recovery_verified 只是引用;
二者冲突或都没有捕获事件仍拒绝。测试覆盖正常成功链、真冲突、无捕获事件三种。

验证:隔离 fixture PostgreSQL 实跑 cmd/worker 全包通过(含上述四条真实调用链);
libraryhandoff/librarysnapshot/httpapi/governance/platform-postgres/libraryctl 全绿;
完整 ci_checks 见提交时日志。未部署、未推送、未构建镜像;真实跨主机 SSH 链路仍未验证。
2026-10-30 19:22:48 +08:00

210 lines
7.6 KiB
Go

package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"knowledge-governance/internal/governance"
"knowledge-governance/internal/libraryhandoff"
"knowledge-governance/internal/librarysnapshot"
postgresstore "knowledge-governance/internal/platform/postgres"
)
// 这条测试回答一个业务问题:搬运没成功时,发布作业到底存不存在。
// 用隔离 fixture 库(KG_TEST_DSN 指向专门的测试库),不碰任何业务库。
func TestHandoffChainGatesReleaseJobOnVerifiedTransfer(t *testing.T) {
dsn := os.Getenv("KG_TEST_DSN")
if dsn == "" {
t.Skip("KG_TEST_DSN 未设置:跳过隔离 fixture 库上的搬运链测试")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
if err := postgresstore.ApplyMigrations(ctx, pool); err != nil {
t.Fatalf("migrate fixture db: %v", err)
}
store := postgresstore.NewStore(pool)
resetFixtureJobs(t, pool)
fixture := newHandoffFixture(t)
config := appconfigFixture(fixture)
snapshot := fixture.seal(t)
recordSealedVersion(t, store, snapshot, "internal")
job := createFixtureJob(t, store, "internal", snapshot)
handoffQueueID := bindHandoffQueue(t, nil, store, job, "internal", snapshot)
payload := libraryHandoffPayload{LibraryJobID: job.ID, Scope: "internal",
TargetSnapshotID: snapshot, NextType: librarySyncJobType}
if err := runLibraryHandoffForQueue(ctx, store, config, fixture.handoff, payload, handoffQueueID); err != nil {
t.Fatalf("runLibraryHandoff() error = %v", err)
}
record, err := store.LibraryJob(ctx, job.ID)
if err != nil {
t.Fatal(err)
}
if record.State == governance.LibraryJobFailed {
t.Fatalf("verified handoff left the job failed: %s", record.Error)
}
releaseID := governance.QueueJobIDFor(job.ID)
var queueType string
if err := pool.QueryRow(ctx, `SELECT type FROM jobs WHERE id=$1`, releaseID).Scan(&queueType); err != nil {
t.Fatalf("release queue job missing after verified handoff: %v", err)
}
if queueType != librarySyncJobType {
t.Fatalf("release queue type = %q", queueType)
}
if record.QueueJobID != releaseID {
t.Fatalf("library job not attached to release queue job: %q", record.QueueJobID)
}
// 云端确实拿到了可校验的快照。
if _, err := os.Stat(filepath.Join(fixture.remoteArchive, "versions", snapshot+".json")); err != nil {
t.Fatalf("executor archive lacks the handoff snapshot: %v", err)
}
// 负例:快照根本不存在 → 发布作业不得入队,作业必须失败。
// 用 external scope:同一 (scope, kind) 只允许一个在飞作业,负例不能挤在 internal 上。
unknown := fixture.unknownSnapshotID()
badJob := createFixtureJob(t, store, "external", unknown)
badQueueID := bindHandoffQueue(t, nil, store, badJob, "external", unknown)
badPayload := libraryHandoffPayload{LibraryJobID: badJob.ID, Scope: "external",
TargetSnapshotID: unknown, NextType: librarySyncJobType}
if err := runLibraryHandoffForQueue(ctx, store, config, fixture.handoff, badPayload, badQueueID); err == nil {
t.Fatal("runLibraryHandoff() reported success for a snapshot that does not exist")
}
badRecord, err := store.LibraryJob(ctx, badJob.ID)
if err != nil {
t.Fatal(err)
}
if badRecord.State != governance.LibraryJobFailed {
t.Fatalf("failed handoff left job state = %s", badRecord.State)
}
var queued int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM jobs WHERE id=$1`,
governance.QueueJobIDFor(badJob.ID)).Scan(&queued); err != nil {
t.Fatal(err)
}
if queued != 0 {
t.Fatal("a failed handoff still enqueued the release job: the target could be put into maintenance")
}
}
func createFixtureJob(t *testing.T, store *postgresstore.Store, scope, snapshot string) governance.LibraryJob {
t.Helper()
now := time.Now().UTC()
// 每次运行用唯一后缀:fixture 库会被反复使用,固定 ID 会撞上上一次的行。
unique := fmt.Sprintf("%d", now.UnixNano())
job, reused, err := store.CreateLibraryJob(context.Background(), governance.LibraryJob{
ID: "libsync-handoff-" + unique, Scope: scope, Kind: governance.LibraryJobSync,
IdempotencyKey: "handoff-fixture:" + unique, RequestDigest: "fixture-" + unique,
OperationID: "op-" + unique, TargetSnapshotID: snapshot, Actor: "fixture",
}, governance.SyncStages(), now)
if err != nil {
t.Fatalf("create fixture job: %v", err)
}
if reused {
t.Fatalf("fixture job unexpectedly reused")
}
return job
}
type handoffFixture struct {
root string
remoteArchive string
staging string
archiveRoot string
tenant uint64
kb string
handoff *libraryhandoff.Handoff
}
func newHandoffFixture(t *testing.T) handoffFixture {
t.Helper()
root := t.TempDir()
remoteArchive := filepath.Join(root, "remote-archive")
staging := filepath.Join(root, "remote-staging")
bundles := filepath.Join(root, "bundles")
localArchive := filepath.Join(root, "local-archive")
for _, dir := range []string{remoteArchive, staging, bundles, localArchive} {
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatal(err)
}
}
binary := os.Getenv("KG_TEST_LIBRARYCTL")
if binary == "" {
t.Skip("KG_TEST_LIBRARYCTL 未设置:搬运链测试需要真实 libraryctl 二进制")
}
t.Setenv("LIBRARY_HANDOFF_LIBRARYCTL", binary)
handoff, err := libraryhandoff.New(libraryhandoff.Config{
Transport: libraryhandoff.TransportLocal, LocalRoot: root, LocalArchive: localArchive,
ExecutorContainer: "fixture-executor", BundleDir: bundles, RemoteArchive: remoteArchive,
StagingDir: filepath.Join(root, "staging"), MaxBundleBytes: 8 << 20, OpTimeout: time.Minute,
}, nil)
if err != nil {
t.Fatal(err)
}
return handoffFixture{root: root, remoteArchive: remoteArchive, staging: staging,
archiveRoot: localArchive, tenant: 10000, kb: "kb-handoff-fixture", handoff: handoff}
}
func (f handoffFixture) unknownSnapshotID() string {
value := bytes.Repeat([]byte{9}, 32)
return hex.EncodeToString(value)
}
// seal 在本机归档里造一份合法的封存快照(身份与 fixture 的 tenant/kb 一致)。
func (f handoffFixture) seal(t *testing.T) string {
t.Helper()
archive, err := librarysnapshot.Open(f.archiveRoot, 256*1024*1024)
if err != nil {
t.Fatal(err)
}
defer archive.Close()
part, err := archive.Put(bytes.NewBufferString("[]"))
if err != nil {
t.Fatal(err)
}
mapping := sha256.Sum256([]byte("fixture-mapping"))
manifest := librarysnapshot.Manifest{
Format: librarysnapshot.Format, Scope: "internal", TenantID: f.tenant,
KnowledgeBaseID: f.kb, CreatedAt: time.Date(2026, 10, 30, 0, 0, 0, 0, time.UTC),
Compatibility: librarysnapshot.Compatibility{
WeKnoraVersion: "v0.7.1", SchemaVersion: 74, RetrieverEngine: "elasticsearch_v8",
EmbeddingModel: "fixture-model", EmbeddingDimension: 1024,
IndexMappingSHA256: hex.EncodeToString(mapping[:]),
},
}
for _, name := range []string{"knowledges", "chunks", "resources", "resource_bindings", "knowledge_tags", "knowledge_tag_relations"} {
current := part
current.Kind, current.Name = "database", name
manifest.Parts = append(manifest.Parts, current)
}
for _, name := range []string{"wiki_pages", "wiki_folders", "wiki_page_issues", "wiki_log_entries"} {
current := part
current.Kind, current.Name = "wiki", name
manifest.Parts = append(manifest.Parts, current)
}
index := part
index.Kind, index.Name = "index", "chunks"
manifest.Parts = append(manifest.Parts, index)
id, err := archive.Seal(manifest)
if err != nil {
t.Fatal(err)
}
return id
}
// 保持 encoding/json 参与(payload 结构与 API 一致),避免测试与线上 struct 漂移。
var _ = json.Marshal