Files
knowledge-governance/cmd/worker/library_takeover_integration_test.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

235 lines
11 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"
"fmt"
"strings"
"testing"
"time"
"knowledge-governance/internal/governance"
postgresstore "knowledge-governance/internal/platform/postgres"
)
// 同一个快照被别的作业登记成生效版本时,接管必须拒绝:快照相等不是本作业的完成证据。
//
// 这条只能在真实 PostgreSQL 上证明:版本行 id 由 (kind, scope, 快照) 决定,登记走
// upsert 并会改写 source_job_id,所以"生产上有这个快照、但它不是本作业发的"是存储层
// 才能造出的真实形状。内存夹具只能造出调用方相信的那个版本。
func TestTakeoverRefusesSameSnapshotRegisteredByAnotherJob(t *testing.T) {
store, pool := openLedgerStore(t)
ctx := context.Background()
now := time.Now().UTC()
const scope = "internal"
const shared = "snap-shared-identical"
const otherJob = "libsync-internal-publisher"
const thisJob = "libsync-internal-resumed"
record := func(source string, created time.Time) {
t.Helper()
opID := testLibraryOperation(t, store, scope, governance.LibraryJobSync, "shared:"+source, created)
recordVersionEvent(t, store, scope, governance.LibraryVersionProductionEffective, shared,
source, opID, created,
map[string]any{"action": "sync", "index_mapping_sha256": "mapping-live"})
}
record(otherJob, now.Add(-time.Hour))
// 本作业被杀在写入链路的第一阶段:resources/index/wiki/verify 都还没开始。
job := governance.LibraryJob{ID: thisJob, Scope: scope, Kind: governance.LibraryJobSync,
TargetSnapshotID: shared, SnapshotID: "snap-prebackup-shared",
Stages: []governance.LibraryStage{{Stage: "database", State: "running"}}}
facts := loadLibraryVersionFacts(ctx, store, scope, shared)
if facts.Err != nil {
t.Fatalf("read facts: %v", facts.Err)
}
if !facts.HasCurrent || !facts.HasSnapshot || facts.Current.SourceJobID != otherJob {
t.Fatalf("夹具失效:生效登记不属于别的作业:%+v", facts)
}
if got := classifyTakeover(job, facts, takeoverResumeFacts{}); got != takeoverForeignEffective {
t.Fatalf("同快照由别的作业登记时的接管分类 = %q, want %q(按同快照收尾 = 假完成)",
got, takeoverForeignEffective)
}
// 另一条**独立事实**:本作业用自己的意图登记同一快照(归属不会被改写,
// 而是新增一条属于本作业的事件,current 指向最新的那条)。
record(thisJob, now)
facts = loadLibraryVersionFacts(ctx, store, scope, shared)
if facts.Err != nil {
t.Fatalf("read facts after own record: %v", facts.Err)
}
if facts.Current.SourceJobID != thisJob {
t.Fatalf("本作业的新意图没有成为当前生效事实:%+v", facts.Current)
}
// 旧事件必须原样保留:新意图不许改写别人的登记(F02 的反面证据)。
var otherOwner string
if err := pool.QueryRow(ctx,
`SELECT source_job_id FROM library_versions WHERE scope=$1 AND kind='production_effective'
AND snapshot_id=$2 ORDER BY created_at ASC LIMIT 1`, scope, shared).Scan(&otherOwner); err != nil {
t.Fatalf("read original event: %v", err)
}
if otherOwner != otherJob {
t.Fatalf("别人的历史事件被改写成 %q(必须保持 %q)", otherOwner, otherJob)
}
if got := classifyTakeover(job, facts, takeoverResumeFacts{}); got != takeoverPendingFinal {
t.Fatalf("本作业登记的同一快照分类 = %q, want %q", got, takeoverPendingFinal)
}
}
// "拒绝接管"的落库效果:作业终态失败、留预备份基线、**维护不释放**。
//
// 这条只能对真实 PostgreSQL 断言:三件事分别落在 library_jobs(state/recovery/
// snapshot_id)、library_job_stages(prebackup failed)与 library_maintenance
// (released_at 仍为 NULL、state=needs_restore)。假 store 只能证明"调用了函数"。
func TestFailLibraryTakeoverKeepsMaintenanceAndNamesBackup(t *testing.T) {
store, pool := openLedgerStore(t)
ctx := context.Background()
now := time.Now().UTC()
const scope = "internal"
const jobID = "libsync-internal-foreign-refused"
const backup = "snap-prebackup-refused"
if _, _, err := store.CreateLibraryJob(ctx, governance.LibraryJob{
ID: jobID, Scope: scope, Kind: governance.LibraryJobSync, TargetSnapshotID: "snap-foreign",
RequestDigest: "digest-foreign", IdempotencyKey: "library.sync:foreign",
}, governance.SyncStages(), now); err != nil {
t.Fatalf("create job: %v", err)
}
if _, err := store.AcquireLibraryMaintenance(ctx, postgresstore.LibraryMaintenance{
Scope: scope, OwnerJobID: jobID, TargetIdentity: "test-target", Level: "instance",
TargetFingerprint: "fingerprint", Services: []string{"app"}, IngressState: "closed",
State: "acquired", OriginalServiceState: "running", Note: "test",
}, now); err != nil {
t.Fatalf("acquire maintenance: %v", err)
}
recorder := libraryStageRecorder{store: store, jobID: jobID, scope: scope}
if err := failLibraryTakeover(ctx, store, recorder, scope, jobID, backup,
"接管时目标快照的生效版本由作业 other 登记"); err == nil {
t.Fatal("拒绝接管必须返回错误(不能安静地成功)")
}
job, err := store.LibraryJob(ctx, jobID)
if err != nil {
t.Fatalf("read job: %v", err)
}
if job.State != governance.LibraryJobFailed {
t.Fatalf("作业状态 = %s, want failed", job.State)
}
if job.SnapshotID != backup {
t.Fatalf("作业留存的预备份 = %q, want %q", job.SnapshotID, backup)
}
if !strings.Contains(job.Recovery, backup) {
t.Fatalf("恢复指引里没有预备份基线:%q", job.Recovery)
}
if strings.Contains(job.Recovery, "无需恢复") {
t.Fatalf("拒绝接管时不许写\"无需恢复\":%q", job.Recovery)
}
var released *time.Time
var state string
if err := pool.QueryRow(ctx,
`SELECT released_at, state FROM library_maintenance WHERE scope=$1`, scope).Scan(&released, &state); err != nil {
t.Fatalf("read maintenance: %v", err)
}
if released != nil {
t.Fatalf("维护被释放了(released_at=%s):目标可能半写,释放等于谎称已恢复", released)
}
if state != "needs_restore" {
t.Fatalf("维护状态 = %q, want needs_restore", state)
}
}
// 接管/基线的版本判定必须走"当前生效版本"的精确查询:历史一长,窗口列表就看不到
// 那唯一一行未顶替的生效版本,于是判定会退回"没有证据"——接管就会把已经生效的
// 目标再写一遍(重写风险最高的一条)。这里用真实 PostgreSQL 把窗口挤满,证明判定不变。
func TestTakeoverClassificationUsesExactCurrentVersionBeyondHistoryWindow(t *testing.T) {
store, _ := openLedgerStore(t)
ctx := context.Background()
now := time.Now().UTC()
const scope = "internal"
const targetSnapshot = "snap-effective"
const jobID = "libsync-internal-takeover"
job := governance.LibraryJob{ID: jobID, Scope: scope, Kind: governance.LibraryJobSync,
TargetSnapshotID: targetSnapshot, SnapshotID: "snap-prebackup"}
// 每次登记都是一个**独立意图**(不同 snapshot/job 就是不同 operation):
// current 的次序键取自意图创建时间,测试要证明的正是"按意图时间单调"。
recordEffective := func(sourceJob, snapshot string, created time.Time) {
t.Helper()
opID := testLibraryOperation(t, store, scope, governance.LibraryJobSync,
string(governance.LibraryJobSync)+":"+snapshot+":"+sourceJob, created)
recordVersionEvent(t, store, scope, governance.LibraryVersionProductionEffective, snapshot,
sourceJob, opID, created,
map[string]any{"action": "sync", "index_mapping_sha256": "mapping-live"})
}
// 生效版本登记在 48 小时前,然后 250 条更新的历史记录把它挤出任何窗口。
recordEffective(jobID, targetSnapshot, now.Add(-48*time.Hour))
seedVersionHistory(t, ctx, store, scope, now.Add(-time.Hour), 250)
window, err := store.LibraryVersions(ctx, scope, 100)
if err != nil {
t.Fatalf("list versions: %v", err)
}
if len(window) != 100 {
t.Fatalf("窗口条数 = %d, want 100", len(window))
}
if containsSnapshot(window, targetSnapshot) {
t.Fatal("窗口里仍能看到生效版本:本用例证明不了窗口之外仍可判定(夹具失效)")
}
// 本作业的目标快照就是当前生效版本 ⇒ 只差收尾,不许重写。
facts := loadLibraryVersionFacts(ctx, store, scope, targetSnapshot)
if facts.Err != nil {
t.Fatalf("窗口之外必须读得到生效事实:%v", facts.Err)
}
if !facts.HasCurrent || facts.Current.SnapshotID != targetSnapshot {
t.Fatalf("当前生效版本读取错误:%+v", facts)
}
if got := classifyTakeover(job, facts, takeoverResumeFacts{}); got != takeoverPendingFinal {
t.Fatalf("取手接管分类 = %q, want %q(窗口一满就重写 = 本缺陷)", got, takeoverPendingFinal)
}
// 索引映射基线也走同一条精确查询:窗口之外仍要拿到生产实际用的映射。
if got := knownIndexMapping(ctx, store, scope); got != "mapping-live" {
t.Fatalf("索引映射基线 = %q, want mapping-live", got)
}
// 本快照被更新的快照顶替:接管必须拒绝用旧快照覆盖,而不是重写。
recordEffective("libsync-internal-newer", "snap-newer", now)
facts = loadLibraryVersionFacts(ctx, store, scope, targetSnapshot)
if facts.Err != nil {
t.Fatalf("顶替后仍必须读得到事实:%v", facts.Err)
}
if got := classifyTakeover(job, facts, takeoverResumeFacts{}); got != takeoverSuperseded {
t.Fatalf("被顶替后的接管分类 = %q, want %q", got, takeoverSuperseded)
}
if facts.Current.SnapshotID != "snap-newer" {
t.Fatalf("当前生效版本 = %s, want snap-newer", facts.Current.SnapshotID)
}
// 一个从没生效过的快照:才是 fresh(正常流程 + 预检)。
unknown := governance.LibraryJob{ID: "libsync-internal-fresh", Scope: scope,
Kind: governance.LibraryJobSync, TargetSnapshotID: "snap-never", SnapshotID: "snap-prebackup-2"}
if got := classifyTakeover(unknown, loadLibraryVersionFacts(ctx, store, scope, "snap-never"), takeoverResumeFacts{}); got != takeoverFresh {
t.Fatalf("未生效过的快照分类 = %q, want %q", got, takeoverFresh)
}
// 基线侧同一个事实:别人的生效版本必须挡住建基线;本作业登记的同一快照只收尾。
current, hasCurrent, err := store.LibraryCurrentVersion(ctx, scope)
if err != nil || !hasCurrent {
t.Fatalf("当前生效版本读取失败:found=%v err=%v", hasCurrent, err)
}
if state := classifyBaselineVersions(current, hasCurrent, "libbase-internal-x", "snap-newer"); state.Own || state.Foreign == nil {
t.Fatalf("别人的生效版本必须挡住基线:%+v", state)
}
// 新的意图必须真的更新:晚一分钟,而不是与上一条同刻(同刻只能靠 id 字典序,不是语义)。
recordEffective("libbase-internal-y", "snap-baseline", now.Add(time.Minute))
current, hasCurrent, err = store.LibraryCurrentVersion(ctx, scope)
if err != nil || !hasCurrent {
t.Fatalf("再读当前生效版本失败:found=%v err=%v", hasCurrent, err)
}
if state := classifyBaselineVersions(current, hasCurrent, "libbase-internal-y", "snap-baseline"); !state.Own {
t.Fatalf("本作业同一快照必须判成只收尾:%+v (%s)", state, fmt.Sprint(current))
}
}