a42b101a7b
build / build (push) Successful in 1m25s
父审发现三处会让目标卡死或状态对不上实际入口,逐条修: 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。
196 lines
8.0 KiB
Go
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)
|
|
}
|
|
}
|