| index 1ef35717e..3c3bd1965 100644 |
| --- a/internal/hostbox/reconcile_relocation_test.go |
| +++ b/internal/hostbox/reconcile_relocation_test.go |
| @@ -145,7 +145,7 @@ func TestReconcileWithoutAFenceObserverIsTodays(t *testing.T) { |
| for _, phase := range []string{hostclaim.PhaseStarting, hostclaim.PhaseReleasing, "moving"} { |
| var obs hostclaim.Observed |
| if phase == "moving" { |
| - obs = moving() // a held executor participant with HA on (RowHeld) |
| + obs = moving() // a moves executor: main's path with HA on too |
| } else { |
| obs = relocate(t, r, selected.Host, phase) // ready with HA on (RowReady) |
| } |
| @@ -170,6 +170,88 @@ func TestReconcileWithoutAFenceObserverIsTodays(t *testing.T) { |
| } |
| } |
| |
| +// With the observer set (region HA on) a moves claim still takes main's |
| +// path: Serving refuses it first. The target mints and holds nothing |
| +// here (moves owns its holds, in provision's pass), and the executor is |
| +// refused before the guard is activated or inventory recorded, dry run |
| +// or not. Only relocating claims take the row decision. |
| +func TestReconcileWithAFenceObserverLeavesMovesToMain(t *testing.T) { |
| + for _, box := range []string{"dst", "box"} { |
| + t.Run(box, func(t *testing.T) { |
| + r, selected, holds, guard := relocationReconciler(t) |
| + ctx := context.Background() |
| + observed := 0 |
| + fence := r.FenceClosed |
| + r.FenceClosed = func(ctx context.Context, o hostclaim.Observed) (bool, error) { |
| + observed++ |
| + return fence(ctx, o) |
| + } |
| + obs, err := hostclaim.Read(ctx, r.Claims, selected.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + obs, err = hostclaim.Update(ctx, r.Claims, obs, func(c *hostclaim.Claim) error { |
| + c.Op = hostclaim.Op{Kind: hostclaim.KindMoving, ID: "mv-1", Phase: "copying", Exec: "box", Participants: []string{"box", "dst"}, Intent: map[string]string{"admission_id": "adm"}} |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + r.Box = box |
| + before := snapshotDir(t, guard.DataDir) |
| + writes := 0 |
| + for _, dry := range []bool{true, false} { |
| + sel := selected |
| + sel.DryRun = dry |
| + if err := r.Install(ctx, sel, func() error { writes++; return nil }); !errors.Is(err, ErrChanged) { |
| + t.Fatalf("%s (dry run %v) under a move with HA on: %v", box, dry, err) |
| + } |
| + } |
| + if writes != 0 || len(*holds) != 0 || observed != 0 { |
| + t.Fatalf("%s under a move with HA on: writes %d holds %v observer calls %d", box, writes, *holds, observed) |
| + } |
| + after, err := hostclaim.Read(ctx, r.Claims, selected.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if after.ETag != obs.ETag { |
| + t.Fatalf("%s: the claim (inventory) was written under a move", box) |
| + } |
| + if got := snapshotDir(t, guard.DataDir); !maps.Equal(got, before) { |
| + t.Fatalf("%s: the guard's local state changed under a move", box) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// Install asks the fence observer once for its own decision: the hold-only |
| +// path acts on the answer the decision was made from. |
| +func TestReconcileInstallAsksTheFenceObserverOnce(t *testing.T) { |
| + r, selected, holds, _ := relocationReconciler(t) |
| + relocate(t, r, selected.Host, hostclaim.PhaseReleasing) |
| + ctx := context.Background() |
| + // The first answer (check's) is open, Install's own is closed, and |
| + // every later one open: acting on one answer, Install takes the hold |
| + // path, whose decision under the flock then differs, so nothing is |
| + // written and nothing held. |
| + answers := []bool{false, true} |
| + calls := 0 |
| + r.FenceClosed = func(context.Context, hostclaim.Observed) (bool, error) { |
| + calls++ |
| + if calls <= len(answers) { |
| + return answers[calls-1], nil |
| + } |
| + return false, nil |
| + } |
| + writes := 0 |
| + if err := r.Install(ctx, selected, func() error { writes++; return nil }); !errors.Is(err, ErrChanged) { |
| + t.Fatalf("a changed answer under the flock: %v", err) |
| + } |
| + if calls != 3 || writes != 0 || len(*holds) != 0 { |
| + t.Fatalf("observer calls %d (check, Install, holdOnly) writes %d holds %v", calls, writes, *holds) |
| + } |
| +} |
| + |
| // snapshotDir is every regular file under dir, by relative path. |
| func snapshotDir(t *testing.T, dir string) map[string]string { |
| t.Helper() |