hostops: the recovery test exercises fresh claim reads and dispatch (Plan 5 review)
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+83
−7
internal/hostops/operations_test.go+83 −7
CI passed — run details
diff --git a/internal/hostops/operations_test.go b/internal/hostops/operations_test.go| index 0732fd7f7..e9526e34c 100644 |
| --- a/internal/hostops/operations_test.go |
| +++ b/internal/hostops/operations_test.go |
| @@ -5,6 +5,7 @@ import ( |
| "errors" |
| "fmt" |
| "os" |
| + "reflect" |
| "testing" |
| "time" |
| @@ -32,11 +33,20 @@ func (a *authority) PurgeRetirement(context.Context, hostclaim.Observed) error { |
| type faultStore struct { |
| release.Store |
| failGet string |
| + afterList func(string, []string) error |
| beforePut func(string) error |
| beforeDelete func(string, string) error |
| bareDeletes int |
| } |
| +func (s *faultStore) List(ctx context.Context, prefix string) ([]string, error) { |
| + keys, err := s.Store.List(ctx, prefix) |
| + if err == nil && s.afterList != nil { |
| + err = s.afterList(prefix, keys) |
| + } |
| + return keys, err |
| +} |
| + |
| func (s *faultStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| if key == s.failGet { |
| return nil, "", errors.New("read failed") |
| @@ -337,13 +347,79 @@ func TestRecoveryFreshReadsAndRequiredHooks(t *testing.T) { |
| if err := (Recovery{Controller: f.c}).Pass(f.ctx); !errors.Is(err, ErrContract) { |
| t.Fatal(err) |
| } |
| - calls := 0 |
| - r := Recovery{Controller: f.c, Purge: func(context.Context, hostclaim.Observed) error { return nil }, Placement: func(context.Context, hostclaim.Observed) error { return nil }, SweepRecords: func(context.Context) error { calls++; return nil }, SweepPurges: func(context.Context) error { calls++; return nil }, SweepAdmissions: func(context.Context) error { return nil }} |
| - if err := r.Pass(f.ctx); err != nil { |
| - t.Fatal(err) |
| - } |
| - if calls != 2 { |
| - t.Fatal(calls) |
| + for _, tc := range []struct { |
| + name string |
| + replace bool |
| + wantCalls int |
| + }{ |
| + {name: "advanced purge", replace: true, wantCalls: 1}, |
| + {name: "released purge", wantCalls: 0}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + f := newFixture(t) |
| + host := "shop.bab.example.com" |
| + before, err := hostclaim.Read(f.ctx, f.store, host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + before, err = hostclaim.Update(f.ctx, f.store, before, FencePurge("purge-old", f.now)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + startedAt := f.now |
| + f.now = f.now.Add(6 * time.Minute) |
| + key, err := hostclaim.Key(host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var fresh hostclaim.Observed |
| + listCalls := 0 |
| + f.store.afterList = func(prefix string, keys []string) error { |
| + listCalls++ |
| + if prefix != "control/hosts/" || len(keys) != 1 || keys[0] != key { |
| + t.Fatalf("discovery = %q %v, want the populated claim", prefix, keys) |
| + } |
| + var err error |
| + fresh, err = hostclaim.Update(f.ctx, f.store.Store, before, ReleasePurgeFence("purge-old")) |
| + if err == nil && tc.replace { |
| + fresh, err = hostclaim.Update(f.ctx, f.store.Store, fresh, FencePurge("purge-new", startedAt)) |
| + } |
| + return err |
| + } |
| + purgeCalls, recordSweeps, purgeSweeps, admissionSweeps := 0, 0, 0, 0 |
| + r := Recovery{ |
| + Controller: f.c, |
| + Purge: func(_ context.Context, got hostclaim.Observed) error { |
| + purgeCalls++ |
| + if !tc.replace { |
| + t.Fatal("dispatched a purge released after discovery") |
| + } |
| + if !reflect.DeepEqual(got, fresh) { |
| + t.Fatalf("dispatched stale claim: gen=%d op=%q etag=%q; want gen=%d op=%q etag=%q", got.Claim.Gen, got.Claim.Op.ID, got.ETag, fresh.Claim.Gen, fresh.Claim.Op.ID, fresh.ETag) |
| + } |
| + return nil |
| + }, |
| + Placement: func(context.Context, hostclaim.Observed) error { |
| + t.Fatal("dispatched placement for a purge claim") |
| + return nil |
| + }, |
| + SweepRecords: func(context.Context) error { recordSweeps++; return nil }, |
| + SweepPurges: func(context.Context) error { purgeSweeps++; return nil }, |
| + SweepAdmissions: func(context.Context) error { admissionSweeps++; return nil }, |
| + } |
| + if err := r.Pass(f.ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + if listCalls != 1 || fresh.Claim.Gen <= before.Claim.Gen || fresh.ETag == before.ETag { |
| + t.Fatal("claim did not advance between discovery and recovery") |
| + } |
| + if purgeCalls != tc.wantCalls { |
| + t.Fatalf("purge calls = %d, want %d", purgeCalls, tc.wantCalls) |
| + } |
| + if recordSweeps != 1 || purgeSweeps != 1 || admissionSweeps != 1 { |
| + t.Fatalf("sweeps: records=%d purges=%d admissions=%d; want one each", recordSweeps, purgeSweeps, admissionSweeps) |
| + } |
| + }) |
| } |
| } |