carlos / platform Public

hostbox: the quiet window gates reconcile's execution paths but not the hold-only write, and Install's check, decision and path act on one observer answer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 67adf7eda1534b321d8f44cc3c80eeac13a5bddb parent 3cf1a68
2 files changed, +93 −33
  • internal/hostbox/reconcile.go +43 −20
  • internal/hostbox/reconcile_relocation_test.go +50 −13
diff --git a/internal/hostbox/reconcile.go b/internal/hostbox/reconcile.go
index 2b308aa01..766e2cb9a 100644
--- a/internal/hostbox/reconcile.go
+++ b/internal/hostbox/reconcile.go
@@ -100,7 +100,23 @@ func (a activationReader) Read(ctx context.Context, host string) (hostclaim.Obse
}
return obs, nil
}
+
+// decided is Install's one row decision for a relocating claim and the
+// path it chose: holdOnly is a kept row's hold write, with no execution.
+type decided struct {
+ row RowDecision
+ holdOnly bool
+}
+
func (r Reconciler) check(ctx context.Context, obs hostclaim.Observed, selected RecordSelection) error {
+ return r.checkOn(ctx, obs, selected, nil)
+}
+
+// checkOn is check on the row decision dec when the caller already made it
+// (Install), or on its own fresh one (dec nil). The hold-only write is not
+// execution, so the region's quiet window does not gate it; every other
+// path, the activation reader's and inventory's included, is gated.
+func (r Reconciler) checkOn(ctx context.Context, obs hostclaim.Observed, selected RecordSelection, dec *decided) error {
if r.Claims == nil || r.CheckRecord == nil || r.CheckLocal == nil || r.Now == nil || r.Box == "" || r.Region == "" || selected.ETag == "" || obs.Absent || obs.Claim == nil || obs.Claim.Holder == nil {
return ErrUnsupported
}
@@ -108,14 +124,20 @@ 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
}
+ holdOnly := false
if !r.relocates(obs.Claim) {
if err := Serving(obs.Claim, Selection{Box: r.Box}); err != nil {
return err
}
} else {
- d, _, err := r.rowDecision(ctx, obs)
- if err != nil {
- return err
+ d := RowDecision{}
+ if dec != nil {
+ d, holdOnly = dec.row, dec.holdOnly
+ } else {
+ var err error
+ if d, _, err = r.rowDecision(ctx, obs); err != nil {
+ return err
+ }
}
switch d.Case {
case RowReady, RowHeld:
@@ -123,7 +145,7 @@ func (r Reconciler) check(ctx context.Context, obs hostclaim.Observed, selected
return ErrChanged
}
}
- if until, ok := obs.Claim.QuietAfter[r.Region]; ok && !r.Now().After(until) {
+ if until, ok := obs.Claim.QuietAfter[r.Region]; ok && !holdOnly && !r.Now().After(until) {
return ErrChanged
}
if err := r.CheckRecord(ctx, selected); err != nil {
@@ -157,27 +179,28 @@ func (r Reconciler) Install(ctx context.Context, selected RecordSelection, write
return err
}
}
- if err := r.check(ctx, obs, selected); err != nil {
- return err
- }
- if selected.DryRun {
- return nil
- }
+ var dec *decided
if r.relocates(obs.Claim) {
- // One answer from the observer, for the decision and the path,
- // asked only when the decision depends on it.
+ // One answer from the observer, for the check, the decision and
+ // the path, asked only when the decision depends on it.
d, closed, err := r.rowDecision(ctx, obs)
if err != nil {
return err
}
- if 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
- // flock. A target before installing has no row and mints none
- // (ruling 17).
- return r.holdOnly(ctx, selected, d)
- }
+ // 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 flock. A
+ // target before installing has no row and mints none (ruling 17).
+ dec = &decided{row: d, holdOnly: d.Case == RowHeld && (obs.Claim.Op.Exec != r.Box || closed)}
+ }
+ if err := r.checkOn(ctx, obs, selected, dec); err != nil {
+ return err
+ }
+ if selected.DryRun {
+ return nil
+ }
+ if dec != nil && dec.holdOnly {
+ return r.holdOnly(ctx, selected, dec.row)
}
// Activate's participant callback is not an admission validator for executors.
// The reader validates EVERY fresh read while Activate holds hostguard.
diff --git a/internal/hostbox/reconcile_relocation_test.go b/internal/hostbox/reconcile_relocation_test.go
index 7c8e36d6c..048122928 100644
--- a/internal/hostbox/reconcile_relocation_test.go
+++ b/internal/hostbox/reconcile_relocation_test.go
@@ -224,21 +224,22 @@ func TestReconcileWithAFenceObserverLeavesMovesToMain(t *testing.T) {
}
}
-// Install asks the fence observer once for its own decision: the hold-only
-// path acts on the answer the decision was made from. The source in
-// releasing is the one row case that depends on the observer, so it is
-// asked here three times (check, Install, holdOnly), as before the final
-// review's fix; a box whose case does not depend on it is never asked
-// (TestReconcileRelocationTargetWithNoLocalRecordMintsItsRow).
+// Install asks the fence observer once for its own decision, and its
+// check and its path both act on that answer: the hold-only path acts on
+// the answer the decision was made from. The source in releasing is the
+// one row case that depends on the observer. Before the final review's
+// fixes it was asked three times here (check, Install, holdOnly); now
+// Install's one answer serves its check too, so it is asked twice
+// (Install, holdOnly under the flock). A box whose case does not depend on
+// it is never asked (TestReconcileRelocationTargetWithNoLocalRecordMintsItsRow).
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}
+ // Install's answer 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{true}
calls := 0
r.FenceClosed = func(context.Context, hostclaim.Observed) (bool, error) {
calls++
@@ -251,8 +252,44 @@ func TestReconcileInstallAsksTheFenceObserverOnce(t *testing.T) {
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)
+ if calls != 2 || writes != 0 || len(*holds) != 0 {
+ t.Fatalf("observer calls %d (Install, holdOnly) writes %d holds %v", calls, writes, *holds)
+ }
+}
+
+// Final review: the hold-only write is not execution, so the region's
+// quiet window does not gate it. The activation path still waits for it.
+func TestReconcileQuietWindowGatesExecutionNotTheHold(t *testing.T) {
+ r, selected, holds, guard := relocationReconciler(t)
+ obs := relocate(t, r, selected.Host, hostclaim.PhaseReleasing)
+ ctx := context.Background()
+ obs, err := hostclaim.Update(ctx, r.Claims, obs, func(c *hostclaim.Claim) error {
+ c.QuietAfter = map[string]time.Time{"ie": r.Now().Add(time.Minute)}
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ writes := 0
+ // The source before its fence closes takes the activation path: quiet.
+ if err := r.Install(ctx, selected, func() error { writes++; return nil }); !errors.Is(err, ErrChanged) || writes != 0 || len(*holds) != 0 {
+ t.Fatalf("activation in the quiet window: %v writes %d holds %v", err, writes, *holds)
+ }
+ captured, err := hostguard.Capture(guard.DataDir, selected.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ target := hostguard.FenceTarget{FenceID: "fence-1", Host: selected.Host, Incarnation: captured.Incarnation, Stamp: captured.Stamp, Session: obs.Claim.Holder.Session,
+ Op: "rel-1", Phase: hostclaim.PhaseReleasing, Box: "box", Enrollment: "enrollment-1", BootID: "boot", ControllerID: "ctl", ClaimETag: obs.ETag, DirectoryRevision: "4"}
+ if _, err := guard.BeginFence(ctx, target); err != nil {
+ t.Fatal(err)
+ }
+ // The fenced source's row is held at once, inside the quiet window.
+ if err := r.Install(ctx, selected, func() error { writes++; return nil }); err != nil {
+ t.Fatalf("the fenced source's hold in the quiet window: %v", err)
+ }
+ if writes != 0 || len(*holds) != 1 || (*holds)[0] != "rel-1" {
+ t.Fatalf("writes %d holds %v", writes, *holds)
}
}