carlos / platform Public

hostbox: reconcile decides a relocation row on a fresh read, observes the fence inside its lock, and keeps a fenced source's row held without installation authority

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 1d11d95681a05fb6986fc769dd5a2884295189f8 parent 9890727
2 files changed, +281 −2
  • internal/hostbox/reconcile.go +89 −2
  • internal/hostbox/reconcile_relocation_test.go +192 −0
diff --git a/internal/hostbox/reconcile.go b/internal/hostbox/reconcile.go
index 7cf5d4a59..cacefbc03 100644
--- a/internal/hostbox/reconcile.go
+++ b/internal/hostbox/reconcile.go
@@ -30,7 +30,38 @@ type Reconciler struct {
CheckLocal func(context.Context, hostclaim.Observed, RecordSelection) error
// Adopt is Plan 5's #232 backfill coordinator, never a second adoption rule.
Adopt func(context.Context, RecordSelection) (hostclaim.Observed, error)
+ // FenceClosed reports whether this box's admission fence for the host
+ // is closed on the claim given (region HA §5.3: the source in
+ // releasing is ready until then). It is called under the host lock
+ // and must not take it (hostguard.Guard.AdmissionFenced). nil: never
+ // closed, today's behaviour. An error keeps rows and mints none (C6).
+ FenceClosed func(context.Context, hostclaim.Observed) (bool, error)
+ // HoldRow keeps an existing row held for an operation without
+ // installation authority (ruling 17); 6b's adapter supplies it.
+ HoldRow func(ctx context.Context, host, holdOp string) error
}
+
+// relocationAware is region HA on: Task 10 sets FenceClosed and HoldRow
+// only with CARLOS_REGION_HA. With neither, reconcile is today's: the
+// serving executor alone (Serving), and no hold-only path.
+func (r Reconciler) relocationAware() bool { return r.FenceClosed != nil || r.HoldRow != nil }
+
+func (r Reconciler) fenceClosed(ctx context.Context, obs hostclaim.Observed) (bool, error) {
+ if r.FenceClosed == nil {
+ return false, nil
+ }
+ return r.FenceClosed(ctx, obs)
+}
+
+// rowDecision is §5.3's decision for this box on a fresh read.
+func (r Reconciler) rowDecision(ctx context.Context, obs hostclaim.Observed) (RowDecision, error) {
+ closed, err := r.fenceClosed(ctx, obs)
+ if err != nil {
+ return RowDecision{}, err
+ }
+ return RelocationRow(obs.Claim, r.Box, true, closed), nil
+}
+
type activationReader struct {
r Reconciler
selected RecordSelection
@@ -57,8 +88,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
}
- if err := Serving(obs.Claim, Selection{Box: r.Box}); err != nil {
- return err
+ if !r.relocationAware() {
+ 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
+ }
+ switch d.Case {
+ case RowReady, RowHeld:
+ default:
+ return ErrChanged
+ }
}
if until, ok := obs.Claim.QuietAfter[r.Region]; ok && !r.Now().After(until) {
return ErrChanged
@@ -100,6 +143,21 @@ 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 err != nil {
+ return err
+ }
+ closed, _ := r.fenceClosed(ctx, obs)
+ 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)
+ }
+ }
// Activate's participant callback is not an admission validator for executors.
// The reader validates EVERY fresh read while Activate holds hostguard.
guard := r.Guard
@@ -131,3 +189,32 @@ func (r Reconciler) Install(ctx context.Context, selected RecordSelection, write
}
return work.With(ctx, hostwrite.Identity{}, write)
}
+
+// holdOnly keeps an existing row held for the operation, with no
+// installation authority: the write is conditional on the row's recorded
+// ownership and its observed hold (6b's registry.SetClaimHold, through
+// HoldRow), under the host's flock. The decision is remade on a fresh
+// read under that lock. ErrChanged when there is no row: nothing is
+// minted.
+func (r Reconciler) holdOnly(ctx context.Context, selected RecordSelection, d RowDecision) error {
+ if r.HoldRow == nil {
+ return ErrUnsupported
+ }
+ return r.Guard.WithLocal(ctx, selected.Host, func() error {
+ fresh, err := hostclaim.Read(ctx, r.Claims, selected.Host)
+ if err != nil {
+ return err
+ }
+ if fresh.Absent || fresh.Claim.Holder == nil || fresh.Claim.Holder.Incarnation != selected.Incarnation {
+ return ErrChanged
+ }
+ again, err := r.rowDecision(ctx, fresh)
+ if err != nil {
+ return err
+ }
+ if again != d {
+ return ErrChanged
+ }
+ return r.HoldRow(ctx, selected.Host, d.HoldOp)
+ })
+}
diff --git a/internal/hostbox/reconcile_relocation_test.go b/internal/hostbox/reconcile_relocation_test.go
new file mode 100644
index 000000000..1ef35717e
--- /dev/null
+++ b/internal/hostbox/reconcile_relocation_test.go
@@ -0,0 +1,192 @@
+//go:build unix
+
+package hostbox
+
+import (
+ "context"
+ "errors"
+ "io/fs"
+ "maps"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/park"
+)
+
+// relocationReconciler is the reconciler over workFixture's guard and
+// store, with the real fence observer (Guard.AdmissionFenced) and a
+// recording hold writer. The claim relocates the host off "box".
+func relocationReconciler(t *testing.T) (Reconciler, RecordSelection, *[]string, hostguard.Guard) {
+ t.Helper()
+ source, store, obs := workFixture(t)
+ source.Policy = Policy{Held: func(context.Context, string) (string, error) { return "", nil }, Relocation: RelocationPolicy("box")}.Check
+ // One cold writer session, so the source has a session a stop can fence.
+ obs, err := hostclaim.Update(context.Background(), store, obs, hostclaim.AdmitWriter(hostclaim.Admission{Box: "box", Mode: "cold", NewGeneration: "g1", Region: "ie", KeepStamp: true}))
+ if err != nil {
+ t.Fatal(err)
+ }
+ h := obs.Claim.Holder
+ selected := RecordSelection{Host: obs.Host, Key: h.Key, ETag: "record-etag", Incarnation: h.Incarnation, Family: h.Family, Layout: "flat", Paths: []string{"shop.example.db"}}
+ holds := &[]string{}
+ r := Reconciler{Claims: store, Guard: source.Guard, Local: park.Local{Guard: source.Guard, Claims: store, DataDir: source.Guard.DataDir, Box: "box"}, Writes: hostwrite.Config{Enabled: true, Selector: source}, Box: "box", Region: "ie", Now: func() time.Time { return time.Unix(100, 0) },
+ CheckRecord: func(context.Context, RecordSelection) error { return nil },
+ CheckLocal: func(context.Context, hostclaim.Observed, RecordSelection) error { return nil },
+ HoldRow: func(_ context.Context, _ string, op string) error { *holds = append(*holds, op); return nil }}
+ r.FenceClosed = func(_ context.Context, o hostclaim.Observed) (bool, error) {
+ captured, err := hostguard.Capture(source.Guard.DataDir, o.Host)
+ if err != nil {
+ return false, err
+ }
+ return source.Guard.AdmissionFenced(o.Host, o.Claim, captured)
+ }
+ return r, selected, holds, source.Guard
+}
+
+func relocate(t *testing.T, r Reconciler, host, phase string) hostclaim.Observed {
+ t.Helper()
+ ctx := context.Background()
+ obs, err := hostclaim.Read(ctx, r.Claims, 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.KindRelocating, ID: "rel-1", Phase: phase, Exec: "box", Participants: []string{"box", "dst"},
+ Intent: map[string]string{"region": "ie", "source_box": "box", "target_box": "dst"}, Journal: map[string]string{"exec_ack.box": "{}", "exec_ack.dst": "{}"}}
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return obs
+}
+
+// The source in releasing takes the activation path (ready) until root
+// begins its stop; then the hold-only path, under the flock, with the
+// observer read inside the existing locked operation: a bounded context
+// finishes both, so neither reacquires the host lock.
+func TestReconcileRelocationSourceReadyThenHeldWithTheRealFenceObserver(t *testing.T) {
+ r, selected, holds, guard := relocationReconciler(t)
+ obs := relocate(t, r, selected.Host, hostclaim.PhaseReleasing)
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ writes := 0
+ if err := r.Install(ctx, selected, func() error { writes++; return nil }); err != nil {
+ t.Fatalf("the source before its fence closed: %v", err)
+ }
+ if writes != 1 || len(*holds) != 0 {
+ t.Fatalf("writes %d holds %v", writes, *holds)
+ }
+ // Root begins the source's stop: the durable admission fence closes.
+ // (The inventory write above moved the claim: read it fresh.)
+ captured, err := hostguard.Capture(guard.DataDir, selected.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ obs, err = hostclaim.Read(ctx, r.Claims, 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)
+ }
+ if err := r.Install(ctx, selected, func() error { writes++; return nil }); err != nil {
+ t.Fatalf("the fenced source's hold: %v", err)
+ }
+ if writes != 1 || len(*holds) != 1 || (*holds)[0] != "rel-1" {
+ t.Fatalf("after the fence: writes %d holds %v (the guarded path must not run)", writes, *holds)
+ }
+ // A box with no role mints nothing; an observer error keeps rows and
+ // mints none.
+ r.Box = "zzz"
+ if err := r.Install(ctx, selected, func() error { writes++; return nil }); !errors.Is(err, ErrChanged) || writes != 1 {
+ t.Fatalf("no role: %v writes %d", err, writes)
+ }
+ r.Box = "box"
+ broken := errors.New("record unreadable")
+ r.FenceClosed = func(context.Context, hostclaim.Observed) (bool, error) { return false, broken }
+ if err := r.Install(ctx, selected, func() error { writes++; return nil }); !errors.Is(err, broken) || writes != 1 {
+ t.Fatalf("an observer error: %v writes %d", err, writes)
+ }
+}
+
+// With no observer (region HA off) the reconciler is today's: the serving
+// executor is ready; an executor of any other operation is refused by the
+// first check, before the guard is activated or inventory recorded, dry
+// run or not (round 5 finding 3).
+func TestReconcileWithoutAFenceObserverIsTodays(t *testing.T) {
+ r, selected, _, guard := relocationReconciler(t)
+ r.FenceClosed, r.HoldRow = nil, nil
+ ctx := context.Background()
+ writes := 0
+ if err := r.Install(ctx, selected, func() error { writes++; return nil }); err != nil || writes != 1 {
+ t.Fatalf("serving: %v writes %d", err, writes)
+ }
+ moving := func() hostclaim.Observed {
+ 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)
+ }
+ return obs
+ }
+ 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)
+ } else {
+ obs = relocate(t, r, selected.Host, phase) // ready with HA on (RowReady)
+ }
+ before := snapshotDir(t, guard.DataDir)
+ 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) || writes != 1 {
+ t.Fatalf("%s (dry run %v) with HA off: %v writes %d", phase, dry, err, writes)
+ }
+ }
+ 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 with HA off", phase)
+ }
+ if got := snapshotDir(t, guard.DataDir); !maps.Equal(got, before) {
+ t.Fatalf("%s: the guard's local state changed with HA off", phase)
+ }
+ }
+}
+
+// snapshotDir is every regular file under dir, by relative path.
+func snapshotDir(t *testing.T, dir string) map[string]string {
+ t.Helper()
+ out := map[string]string{}
+ err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
+ if err != nil || !d.Type().IsRegular() {
+ return err
+ }
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ out[path] = string(raw)
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return out
+}