Files
knowledge-governance/internal/platform/postgres/library_maintenance_integration_test.go
T
姚章浩 a42b101a7b
build / build (push) Successful in 1m25s
fix(library): 基线崩溃续作与维护原状态保留
父审发现三处会让目标卡死或状态对不上实际入口,逐条修:

1. 登记成功但没来得及收尾:崩溃重入会撞上"已有生效版本"直接失败,
   目标留在维护里、占用也不释放。现在按「本作业+同一快照」识别为自己的
   版本,只做收尾(恢复服务/开入口/释放两把占用),不重新捕获、不重写;
   别人的版本仍然拒绝。捕获完成但登记失败则明确失败并写"未对目标写入"。
2. 重入覆盖原运行状态:崩溃时服务已被停住,再读一次只会读到 stopped,
   写回去会让恢复阶段永远不启动写入者。AcquireInstance/LibraryMaintenance
   在 owner 相同的重入时保留已持久化原状态;新作业接管已释放窗口用自己的观察。
   真实 PostgreSQL 集成用例覆盖(先红后绿)。
3. 版本登记移进维护窗口:捕获/校验/登记要么一起成立、要么目标原样恢复;
   记录失败时窗口已开放,治理状态同步释放,不再"显示维护中、实际已开"。
   索引映射读不到时拒绝登记,不再用空摘要写证据。

顺带:
- 基线窗口统一收尾抽成 settleBaselineWindow,四种结局各有测试(成功/健康失败/
  held 保持维护/已恢复则释放),并用真实 DB 用例证明 baseline 与 sync/restore
  共用写互斥、幂等重试不产生第二个作业。
- 新增 cmd/librarydrill:维护隔离 → 整库预备份 → 一致性校验 → canary 漂移检测
  → 按预备份恢复 → 逐类复核 → 按原状态恢复服务。用与 worker 相同的 LIBRARY_*
  配置,绝不触碰封存源。
- 目标/预备份/维护栈装配收成 internal/librarystack 一份(worker 与演练共用)。

验证:go build/vet/test 全过;TEST_DATABASE_URL 指向临时 PostgreSQL 跑
TestMaintenanceReentryKeepsOriginalServiceState(修复前失败:got app=stopped)
与 TestLibraryWriteMutexCoversBaselineJobs;本地门禁 15/15。
2026-10-29 19:08:58 +08:00

196 lines
8.0 KiB
Go

package postgres
import (
"context"
"errors"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// 维护占用的生命周期必须只有一条规则:
// - 正在持有的窗口只允许原作业重入,别的作业一律拒绝;
// - 已经释放的窗口必须能被新作业接管,否则"第一次同步之后谁也再进不去"。
func TestLibraryMaintenanceOwnershipAcrossRelease(t *testing.T) {
databaseURL := isolatedIntegrationDatabaseURL(t, os.Getenv("TEST_DATABASE_URL"))
if databaseURL == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
if err := ApplyMigrations(ctx, pool); err != nil {
t.Fatalf("migrate: %v", err)
}
store := NewStore(pool)
now := time.Now().UTC()
first, err := store.AcquireLibraryMaintenance(ctx, LibraryMaintenance{
Scope: "internal", OwnerJobID: "job-1", TargetIdentity: "instance=kgmaint",
Level: "instance", TargetFingerprint: "fp", Services: []string{"app"},
IngressState: "unknown", State: "acquired", OriginalServiceState: "app=running",
Note: "test",
}, now)
if err != nil {
t.Fatalf("first acquire: %v", err)
}
if first.Level != "instance" || len(first.Services) != 1 || first.Services[0] != "app" {
t.Fatalf("acquired row lost identity: %+v", first)
}
// 同一作业重入(接管)必须成功。
if _, err := store.AcquireLibraryMaintenance(ctx, LibraryMaintenance{
Scope: "internal", OwnerJobID: "job-1", State: "applying", Level: "instance",
}, now.Add(time.Second)); err != nil {
t.Fatalf("same owner re-entry rejected: %v", err)
}
// 另一个作业在持有期间必须被拒。
if _, err := store.AcquireLibraryMaintenance(ctx, LibraryMaintenance{
Scope: "internal", OwnerJobID: "job-2", State: "acquired", Level: "instance",
}, now.Add(time.Second)); !errors.Is(err, ErrMaintenanceHeld) {
t.Fatalf("second owner during hold: err=%v", err)
}
if err := store.ReleaseLibraryMaintenance(ctx, "internal", "job-1", now.Add(2*time.Second)); err != nil {
t.Fatalf("release: %v", err)
}
second, err := store.AcquireLibraryMaintenance(ctx, LibraryMaintenance{
Scope: "internal", OwnerJobID: "job-2", TargetIdentity: "instance=kgmaint",
Level: "instance", TargetFingerprint: "fp2", Services: []string{"app"},
IngressState: "unknown", State: "acquired", Note: "second",
}, now.Add(3*time.Second))
if err != nil {
t.Fatalf("acquire after release: %v", err)
}
if second.OwnerJobID != "job-2" || second.ReleasedAt != nil || second.TargetFingerprint != "fp2" {
t.Fatalf("released window not re-acquired cleanly: %+v", second)
}
}
// 同一作业重入(崩溃后的接管)必须保留第一次记下的"原运行状态":
// 重入时目标已经被停住,此时再去读一次只会读到"已停",把它写回去会让恢复阶段
// 以为写入者本来就没跑,于是永远不启动它 —— 目标就这么一直停着。
func TestMaintenanceReentryKeepsOriginalServiceState(t *testing.T) {
databaseURL := isolatedIntegrationDatabaseURL(t, os.Getenv("TEST_DATABASE_URL"))
if databaseURL == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
if err := ApplyMigrations(ctx, pool); err != nil {
t.Fatalf("migrate: %v", err)
}
store := NewStore(pool)
now := time.Now().UTC()
first, err := store.AcquireLibraryMaintenance(ctx, LibraryMaintenance{
Scope: "internal", OwnerJobID: "job-reentry", TargetIdentity: "instance=kgseal",
Level: "instance", TargetFingerprint: "fp", Services: []string{"app"},
State: "acquired", OriginalServiceState: "app=running", Note: "first",
}, now)
if err != nil {
t.Fatalf("first acquire: %v", err)
}
if first.OriginalServiceState != "app=running" {
t.Fatalf("first original state = %q", first.OriginalServiceState)
}
reentered, err := store.AcquireLibraryMaintenance(ctx, LibraryMaintenance{
Scope: "internal", OwnerJobID: "job-reentry", Level: "instance",
State: "applying", OriginalServiceState: "app=stopped", Note: "re-entry after crash",
}, now.Add(time.Second))
if err != nil {
t.Fatalf("same-owner re-entry: %v", err)
}
if reentered.OriginalServiceState != "app=running" {
t.Fatalf("re-entry overwrote the original state: got %q, want app=running", reentered.OriginalServiceState)
}
instFirst, err := store.AcquireInstanceMaintenance(ctx, LibraryInstanceMaintenance{
Instance: "kgseal-instance-reentry", OwnerJobID: "job-reentry", OwnerScope: "internal",
TargetFingerprint: "fp", Level: "instance", Services: []string{"app"},
State: "acquired", OriginalServiceState: "app=running", Note: "first",
}, now)
if err != nil {
t.Fatalf("instance acquire: %v", err)
}
if instFirst.OriginalServiceState != "app=running" {
t.Fatalf("instance first original state = %q", instFirst.OriginalServiceState)
}
instReentered, err := store.AcquireInstanceMaintenance(ctx, LibraryInstanceMaintenance{
Instance: "kgseal-instance-reentry", OwnerJobID: "job-reentry", OwnerScope: "internal",
Level: "instance", State: "applying", OriginalServiceState: "app=stopped",
}, now.Add(2*time.Second))
if err != nil {
t.Fatalf("instance same-owner re-entry: %v", err)
}
if instReentered.OriginalServiceState != "app=running" {
t.Fatalf("instance re-entry overwrote the original state: got %q", instReentered.OriginalServiceState)
}
// 新作业接管已释放的窗口时,必须接受它自己读到的当前状态(不能沿用上一个作业的)。
if err := store.ReleaseInstanceMaintenance(ctx, "kgseal-instance-reentry", "job-reentry", now.Add(3*time.Second)); err != nil {
t.Fatalf("release instance: %v", err)
}
next, err := store.AcquireInstanceMaintenance(ctx, LibraryInstanceMaintenance{
Instance: "kgseal-instance-reentry", OwnerJobID: "job-next", OwnerScope: "internal",
Level: "instance", State: "acquired", OriginalServiceState: "app=stopped",
}, now.Add(4*time.Second))
if err != nil {
t.Fatalf("next owner acquire: %v", err)
}
if next.OriginalServiceState != "app=stopped" {
t.Fatalf("new owner must record its own observation: got %q", next.OriginalServiceState)
}
}
// 实例级占用必须跨 scope 互斥:internal 持有期间 external 也进不来。
// (旧实现按 scope 建行,两个 scope 各持一个锁,会同时停/起同一个实例。)
func TestInstanceMaintenanceExcludesAcrossScopes(t *testing.T) {
databaseURL := isolatedIntegrationDatabaseURL(t, os.Getenv("TEST_DATABASE_URL"))
if databaseURL == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
if err := ApplyMigrations(ctx, pool); err != nil {
t.Fatalf("migrate: %v", err)
}
store := NewStore(pool)
now := time.Now().UTC()
base := LibraryInstanceMaintenance{Instance: "kgmaint-test", OwnerJobID: "job-internal",
OwnerScope: "internal", TargetFingerprint: "fp", Level: "instance", Services: []string{"app"},
State: "acquired", Note: "test"}
if _, err := store.AcquireInstanceMaintenance(ctx, base, now); err != nil {
t.Fatalf("first acquire: %v", err)
}
external := base
external.OwnerJobID = "job-external"
external.OwnerScope = "external"
if _, err := store.AcquireInstanceMaintenance(ctx, external, now.Add(time.Second)); !errors.Is(err, ErrMaintenanceHeld) {
t.Fatalf("external scope entered while internal held the instance: err=%v", err)
}
if err := store.ReleaseInstanceMaintenance(ctx, "kgmaint-test", "job-internal", now.Add(2*time.Second)); err != nil {
t.Fatalf("release: %v", err)
}
if _, err := store.AcquireInstanceMaintenance(ctx, external, now.Add(3*time.Second)); err != nil {
t.Fatalf("external scope could not take over the released instance: %v", err)
}
held, err := store.HeldInstanceMaintenance(ctx)
if err != nil || len(held) != 1 || held[0].OwnerScope != "external" {
t.Fatalf("held=%+v err=%v", held, err)
}
}