a7d643635a
build / build (push) Successful in 1m27s
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 项通过。
235 lines
11 KiB
Go
235 lines
11 KiB
Go
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))
|
||
}
|
||
}
|