package registry import ( "context" "errors" "path/filepath" "testing" ) func TestClaimRowsArchiveBeforeDeleteAndReplayPreservesSuccessor(t *testing.T) { t.Setenv("CARLOS_HOST_CLAIM", "1") ctx := context.Background() path := filepath.Join(t.TempDir(), "registry.db") r, err := Open(path) if err != nil { t.Fatal(err) } owner := ClaimOwnership{"inc", 7, "installation-7", "route-7"} route := Route{Host: "shop.example", App: "app", ClaimOwnership: owner} if err = r.Upsert(route); err != nil { t.Fatal(err) } if err = r.UpsertTimer(Timer{Host: route.Host, Name: "daily", Kind: "cron", Spec: "0 0 * * *", NextAt: 1}); err != nil { t.Fatal(err) } timer, err := r.GetTimer(route.Host, "daily") if err != nil { t.Fatal(err) } if timer.ClaimOwner != owner.ClaimOwner || timer.ClaimRowID != timer.Registration { t.Fatalf("timer ownership = %+v", timer) } targets := []ClaimRow{{Kind: "route", Host: route.Host, Ownership: owner}, {Kind: "timer", Host: route.Host, Name: timer.Name, Registration: timer.Registration, Ownership: timer.ClaimOwnership}} crash := errors.New("archive fsync failed") if err = r.RetireClaimRows(ctx, "retire-7", targets, func([]ClaimRowArchive) error { return crash }); !errors.Is(err, crash) { t.Fatal(err) } if rt, e := r.Get(route.Host); e != nil || rt == nil { t.Fatalf("deleted before archive: %v", e) } var archived []ClaimRowArchive if err = r.RetireClaimRows(ctx, "retire-7", targets, func(a []ClaimRowArchive) error { archived = a; return nil }); err != nil { t.Fatal(err) } if len(archived) != 2 || len(archived[0].Contents) == 0 || len(archived[1].Contents) == 0 { t.Fatal("incomplete archive") } if err = r.Close(); err != nil { t.Fatal(err) } r, err = Open(path) if err != nil { t.Fatal(err) } defer r.Close() successor := route successor.ClaimOwnership = ClaimOwnership{"inc", 8, "installation-8", "route-8"} if err = r.Upsert(successor); err != nil { t.Fatal(err) } if err = r.UpsertTimer(Timer{Host: route.Host, Name: "daily", Kind: "cron", Spec: "0 0 * * *", NextAt: 2}); err != nil { t.Fatal(err) } if err = r.RetireClaimRows(ctx, "retire-7", targets, func(a []ClaimRowArchive) error { if len(a) != 2 { t.Fatal("missing replay archive") } return nil }); err != nil { t.Fatal(err) } got, err := r.Get(route.Host) if err != nil || got.ClaimOwnership != successor.ClaimOwnership { t.Fatalf("successor removed: %v", err) } gotTimer, err := r.GetTimer(route.Host, "daily") if err != nil || gotTimer.ClaimOwner != successor.ClaimOwner { t.Fatalf("successor timer removed: %v", err) } if err = r.RetireClaimRows(ctx, "another-retirement", targets, func([]ClaimRowArchive) error { return nil }); !errors.Is(err, ErrClaimOwnership) { t.Fatalf("unknown predecessor disposition accepted: %v", err) } } func TestClaimRowsRefusesChangedRegistrationAtomically(t *testing.T) { t.Setenv("CARLOS_HOST_CLAIM", "1") r, err := Open(filepath.Join(t.TempDir(), "registry.db")) if err != nil { t.Fatal(err) } defer r.Close() owner := ClaimOwnership{"inc", 1, "owner", "route"} route := Route{Host: "shop.example", ClaimOwnership: owner} if err = r.Upsert(route); err != nil { t.Fatal(err) } if err = r.UpsertTimer(Timer{Host: route.Host, Name: "once", Kind: "once", NextAt: 1}); err != nil { t.Fatal(err) } old, err := r.GetTimer(route.Host, "once") if err != nil { t.Fatal(err) } if err = r.FireTimerNow(route.Host, "once", 2); err != nil { t.Fatal(err) } targets := []ClaimRow{{Kind: "route", Host: route.Host, Ownership: owner}, {Kind: "timer", Host: route.Host, Name: "once", Registration: old.Registration, Ownership: old.ClaimOwnership}} called := false err = r.RetireClaimRows(context.Background(), "retire", targets, func([]ClaimRowArchive) error { called = true; return nil }) if !errors.Is(err, ErrClaimOwnership) || called { t.Fatalf("stale timer accepted: %v", err) } if got, e := r.Get(route.Host); e != nil || got == nil { t.Fatal("partial route deletion") } if err = r.Upsert(Route{Host: route.Host}); !errors.Is(err, ErrClaimOwnership) { t.Fatalf("unowned writer replaced claim row: %v", err) } }