carlos / platform Public

hostbox: reconcile takes the relocation row decision only for a relocating claim, so every other operation keeps main's path with region HA on, and Install acts on one answer from the fence observer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev e3e9e0c72b7cb26a3b4dd7c2ff338a0580c88c57 parent 1d11d95
2 files changed, +96 −7
  • internal/hostbox/reconcile.go +13 −6
  • internal/hostbox/reconcile_relocation_test.go +83 −1
diff --git a/internal/hostbox/reconcile.go b/internal/hostbox/reconcile.go
index cacefbc03..409cee474 100644
--- a/internal/hostbox/reconcile.go
+++ b/internal/hostbox/reconcile.go
@@ -46,6 +46,13 @@ type Reconciler struct {
// serving executor alone (Serving), and no hold-only path.
func (r Reconciler) relocationAware() bool { return r.FenceClosed != nil || r.HoldRow != nil }
+// relocates is the row decision's scope: a relocating claim with region HA
+// on. Every other operation, moving included, keeps main's path (Serving),
+// HA on or off: moves owns its holds, in provision's pass.
+func (r Reconciler) relocates(c *hostclaim.Claim) bool {
+ return r.relocationAware() && c != nil && c.Op.Kind == hostclaim.KindRelocating
+}
+
func (r Reconciler) fenceClosed(ctx context.Context, obs hostclaim.Observed) (bool, error) {
if r.FenceClosed == nil {
return false, nil
@@ -88,7 +95,7 @@ func (r Reconciler) check(ctx context.Context, obs hostclaim.Observed, selected
if obs.Host != selected.Host || h.Key != selected.Key || h.Incarnation != selected.Incarnation || h.Family != selected.Family || h.Stamp == 0 {
return ErrChanged
}
- if !r.relocationAware() {
+ if !r.relocates(obs.Claim) {
if err := Serving(obs.Claim, Selection{Box: r.Box}); err != nil {
return err
}
@@ -143,13 +150,13 @@ func (r Reconciler) Install(ctx context.Context, selected RecordSelection, write
if selected.DryRun {
return nil
}
- if r.relocationAware() {
- d, err := r.rowDecision(ctx, obs)
+ if r.relocates(obs.Claim) {
+ // One answer from the observer, for the decision and the path.
+ closed, err := r.fenceClosed(ctx, obs)
if err != nil {
return err
}
- closed, _ := r.fenceClosed(ctx, obs)
- if d.Case == RowHeld && (obs.Claim.Op.Exec != r.Box || closed) {
+ if d := RelocationRow(obs.Claim, r.Box, true, closed); d.Case == RowHeld && (obs.Claim.Op.Exec != r.Box || closed) {
// No installation of ours to activate, or one whose admission
// fence is closed (the source in releasing, still op.exec,
// which With refuses): a kept row's hold only, under the
@@ -205,7 +212,7 @@ func (r Reconciler) holdOnly(ctx context.Context, selected RecordSelection, d Ro
if err != nil {
return err
}
- if fresh.Absent || fresh.Claim.Holder == nil || fresh.Claim.Holder.Incarnation != selected.Incarnation {
+ if fresh.Absent || fresh.Claim == nil || fresh.Claim.Holder == nil || fresh.Claim.Holder.Incarnation != selected.Incarnation || !r.relocates(fresh.Claim) {
return ErrChanged
}
again, err := r.rowDecision(ctx, fresh)
diff --git a/internal/hostbox/reconcile_relocation_test.go b/internal/hostbox/reconcile_relocation_test.go
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()