carlos / platform Public

host: with region HA on, the source's row is held for its relocation once this box's admission fence for the host closes, checked against the source's own installation, and only the source's park-local ends that hold (R5-3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 0eec5d7880b8294e5c0ee48ab068076cbca49b92 parent 1a1aeba
12 files changed, +1108 −9
  • internal/host/claimconstruction.go +4 −0
  • internal/host/relocationsourcehold.go +31 −0
  • internal/host/relocationsourcehold_linux_test.go +34 −0
  • internal/host/relocationsourcehold_test.go +357 −0
  • internal/hostbox/empty_census.go +61 −0
  • internal/hostbox/reconcile.go +27 −9
  • internal/hostbox/reconcile_adapter.go +4 −0
  • internal/hostbox/reconcile_relocation_source_census_test.go +251 −0
  • internal/hostbox/reconcile_relocation_source_test.go +200 −0
  • internal/hostbox/reconcile_relocation_test.go +1 −0
  • internal/hostbox/relocationsource.go +81 −0
  • internal/registry/relocating_test.go +57 −0
diff --git a/internal/host/claimconstruction.go b/internal/host/claimconstruction.go
index ea264a84b..b363b5056 100644
--- a/internal/host/claimconstruction.go
+++ b/internal/host/claimconstruction.go
@@ -330,6 +330,10 @@ func OpenProductionClaimAssembly(ctx context.Context, store release.Store, r Cla
// ClaimAssembly.Build binds Retries.StopAttempt to the attempt's persisted
// boot and controller tuple; a wake runtime cannot be wrapped without it.
reconcile := &hostbox.ReconcileAdapter{Registry: r.Registry, Inventory: inventory, Retirement: hostbox.TypedRetirement{JournalDir: claimRetireJournalDir(r.Mode, r.DataDir)}}
+ // Region HA R5-3 (Task 10b): the source's row held at its fence's
+ // close, with CARLOS_REGION_HA on only. Build copies the adapter and
+ // sets the reconciler's other fields; it keeps these two.
+ relocationSourceRows(claimEntryEnv(ctx), &reconcile.Reconciler, guard, r.Registry)
stop.Guard = guard
stop.Claims = hostguard.StoreReader{Store: store}
stop.Inventory = inventory
diff --git a/internal/host/relocationsourcehold.go b/internal/host/relocationsourcehold.go
new file mode 100644
index 000000000..fedbbf187
--- /dev/null
+++ b/internal/host/relocationsourcehold.go
@@ -0,0 +1,31 @@
+package host
+
+import (
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// relocationSourceRows sets the box's Reconciler hooks for region HA
+// §5.3 (R5-3, Task 10b), with CARLOS_REGION_HA on only: FenceClosed reads
+// this box's admission fence for the host from the host guard
+// (hostbox.AdmissionFenceClosed), and HoldRow holds the row for the op in
+// "relocating" (hostbox.RegistryHoldRow). The source in releasing is then
+// held at its fence's close, and every other relocating participant keeps
+// an existing row held. Nothing here releases a source's hold: the
+// fenced installation never serves again, so the completion queues the
+// source's park-local (hostclaim.CompleteRelocation), whose retirement
+// deletes the row by ownership, held or not. With the flag off both hooks
+// stay nil and reconcile is main's: Serving alone, which refuses every
+// relocating claim with every row unchanged.
+func relocationSourceRows(getenv func(string) string, r *hostbox.Reconciler, guard hostguard.Guard, reg *registry.Registry) {
+ if r == nil {
+ return
+ }
+ r.FenceClosed, r.HoldRow = nil, nil
+ if getenv == nil || !regionHAEnabled(getenv) {
+ return
+ }
+ r.FenceClosed = hostbox.AdmissionFenceClosed(guard)
+ r.HoldRow = hostbox.RegistryHoldRow(reg)
+}
diff --git a/internal/host/relocationsourcehold_linux_test.go b/internal/host/relocationsourcehold_linux_test.go
new file mode 100644
index 000000000..acd30ff27
--- /dev/null
+++ b/internal/host/relocationsourcehold_linux_test.go
@@ -0,0 +1,34 @@
+//go:build linux
+
+package host
+
+import (
+ "os"
+ "testing"
+)
+
+// Task 10b: the box's claim assembly is where its Reconciler is built
+// (OpenProductionClaimAssembly, whose adapter ClaimAssembly.Build copies).
+// It sets FenceClosed and HoldRow with CARLOS_REGION_HA on, and neither
+// with it off, so reconcile there is main's.
+func TestProductionClaimAssemblySetsTheSourceHoldOnlyWithRegionHA(t *testing.T) {
+ for _, v := range []string{"", "0", "1"} {
+ on := v == "1"
+ t.Run("CARLOS_REGION_HA="+v, func(t *testing.T) {
+ t.Setenv("CARLOS_REGION_HA", v)
+ store, resources, owners, legacy := fix4JournalFixture(t)
+ if err := os.Remove(legacy); err != nil {
+ t.Fatal(err)
+ }
+ assembly, err := OpenProductionClaimAssembly(t.Context(), store, resources, owners)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer assembly.Close(t.Context())
+ r := assembly.Reconcile.Reconciler
+ if (r.FenceClosed != nil) != on || (r.HoldRow != nil) != on {
+ t.Fatalf("CARLOS_REGION_HA=%q: FenceClosed set %v, HoldRow set %v", v, r.FenceClosed != nil, r.HoldRow != nil)
+ }
+ })
+ }
+}
diff --git a/internal/host/relocationsourcehold_test.go b/internal/host/relocationsourcehold_test.go
new file mode 100644
index 000000000..92abfcc54
--- /dev/null
+++ b/internal/host/relocationsourcehold_test.go
@@ -0,0 +1,357 @@
+//go:build unix
+
+package host
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/park"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+const sourceHoldHost = "shop.example"
+
+// sourceHoldBox is the relocation's source "box" with its installation
+// active in the host guard, one cold writer session admitted, a ready row
+// owned by that installation, and the reconciler the claim assembly
+// builds (the WorkSource selector, park.Local), its hooks set by
+// relocationSourceRows under getenv and counted.
+type sourceHoldBox struct {
+ t *testing.T
+ store release.Store
+ reg *registry.Registry
+ guard hostguard.Guard
+ r hostbox.Reconciler
+ selected hostbox.RecordSelection
+ owner registry.ClaimOwnership
+ asked int // FenceClosed calls
+ holds int // HoldRow calls
+ writes int // the activation path's row writes
+}
+
+func newSourceHoldBox(t *testing.T, getenv func(string) string) *sourceHoldBox {
+ t.Helper()
+ ctx := t.Context()
+ b := &sourceHoldBox{t: t, store: release.NewDirStore(t.TempDir())}
+ dir := t.TempDir()
+ locks := filepath.Join(dir, "locks")
+ for _, p := range []string{locks, filepath.Join(dir, "hostguard")} {
+ if err := os.Mkdir(p, 0770); err != nil {
+ t.Fatal(err)
+ }
+ }
+ obs, err := hostclaim.Read(ctx, b.store, sourceHoldHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ obs, err = hostclaim.Update(ctx, b.store, obs, hostclaim.Adopt(hostclaim.NewHolder{Family: "instance", Key: "apps/acct/app/instances/" + sourceHoldHost + ".json", Account: "acct", App: "app", Host: sourceHoldHost, Region: "ie"}, "box", nil, nil, "adopt", func() string { return "id" }))
+ if err != nil {
+ t.Fatal(err)
+ }
+ b.guard = hostguard.Guard{DataDir: dir, LockDir: locks, Claims: hostguard.StoreReader{Store: b.store}}
+ if err := b.guard.Activate(ctx, sourceHoldHost, "box", nil); err != nil {
+ t.Fatal(err)
+ }
+ obs, err = hostclaim.Update(ctx, b.store, obs, hostclaim.AdmitWriter(hostclaim.Admission{Box: "box", Mode: "cold", NewGeneration: "g1", Region: "ie", KeepStamp: true}))
+ if err != nil {
+ t.Fatal(err)
+ }
+ captured, err := hostguard.Capture(dir, sourceHoldHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if b.reg, err = registry.Open(filepath.Join(t.TempDir(), "registry.db")); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { b.reg.Close() })
+ b.owner = registry.ClaimOwnership{ClaimIncarnation: captured.Incarnation, ClaimStamp: captured.Stamp, ClaimOwner: "owner-1", ClaimRowID: "row-1"}
+ if err := b.reg.Upsert(registry.Route{Host: sourceHoldHost, Status: "ready", Socket: "/run/shop.sock", ClaimOwnership: b.owner}); err != nil {
+ t.Fatal(err)
+ }
+ now := func() time.Time { return time.Unix(100, 0) }
+ source := hostbox.WorkSource{Guard: b.guard, Box: "box", Region: "ie", Now: now,
+ Policy: hostbox.Policy{Held: func(context.Context, string) (string, error) { return "", nil }, Relocation: func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error { return nil }}.Check,
+ Local: func(context.Context, *hostclaim.Claim, hostbox.Selection) error { return nil }}
+ h := obs.Claim.Holder
+ b.selected = hostbox.RecordSelection{Host: sourceHoldHost, Key: h.Key, ETag: "record-etag", Incarnation: h.Incarnation, Family: h.Family, Layout: "flat", Paths: []string{"shop.example.db"}}
+ b.r = hostbox.Reconciler{Claims: b.store, Guard: b.guard, Local: park.Local{Guard: b.guard, Claims: b.store, DataDir: dir, Box: "box"}, Writes: hostwrite.Config{Enabled: true, Selector: source}, Box: "box", Region: "ie", Now: now,
+ CheckRecord: func(context.Context, hostbox.RecordSelection) error { return nil },
+ CheckLocal: func(context.Context, hostclaim.Observed, hostbox.RecordSelection) error { return nil },
+ CheckHeld: func(context.Context, hostclaim.Observed, hostbox.RecordSelection) error { return nil }}
+ relocationSourceRows(getenv, &b.r, b.guard, b.reg)
+ if fence := b.r.FenceClosed; fence != nil {
+ b.r.FenceClosed = func(ctx context.Context, o hostclaim.Observed) (bool, error) { b.asked++; return fence(ctx, o) }
+ }
+ if hold := b.r.HoldRow; hold != nil {
+ b.r.HoldRow = func(ctx context.Context, host, op string) error { b.holds++; return hold(ctx, host, op) }
+ }
+ return b
+}
+
+func (b *sourceHoldBox) update(change func(*hostclaim.Claim)) hostclaim.Observed {
+ b.t.Helper()
+ ctx := b.t.Context()
+ obs, err := hostclaim.Read(ctx, b.store, sourceHoldHost)
+ if err != nil {
+ b.t.Fatal(err)
+ }
+ obs, err = hostclaim.Update(ctx, b.store, obs, func(c *hostclaim.Claim) error { change(c); return nil })
+ if err != nil {
+ b.t.Fatal(err)
+ }
+ return obs
+}
+
+// releasing is a drain from "box" to "dst" in releasing, both
+// acknowledged.
+func (b *sourceHoldBox) releasing() hostclaim.Observed {
+ return b.update(func(c *hostclaim.Claim) {
+ c.Op = hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "rel-1", Phase: hostclaim.PhaseReleasing, Exec: "box", Participants: []string{"box", "dst"},
+ Intent: map[string]string{"region": "ie", "reason": "drain", "source_box": "box", "target_box": "dst"}, Journal: map[string]string{"exec_ack.box": "{}", "exec_ack.dst": "{}"}}
+ })
+}
+
+// closeFence is root beginning the source's release stop: the durable
+// admission fence in the guard record (hostguard.Guard.BeginFence).
+func (b *sourceHoldBox) closeFence() {
+ b.t.Helper()
+ ctx := b.t.Context()
+ captured, err := hostguard.Capture(b.guard.DataDir, sourceHoldHost)
+ if err != nil {
+ b.t.Fatal(err)
+ }
+ obs, err := hostclaim.Read(ctx, b.store, sourceHoldHost)
+ if err != nil {
+ b.t.Fatal(err)
+ }
+ target := hostguard.FenceTarget{FenceID: "fence-1", Host: sourceHoldHost, 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 := b.guard.BeginFence(ctx, target); err != nil {
+ b.t.Fatal(err)
+ }
+}
+
+func (b *sourceHoldBox) install(dry bool) error {
+ sel := b.selected
+ sel.DryRun = dry
+ return b.r.Install(b.t.Context(), sel, func() error { b.writes++; return nil })
+}
+
+func (b *sourceHoldBox) row() *registry.Route {
+ b.t.Helper()
+ row, err := b.reg.Get(sourceHoldHost)
+ if err != nil {
+ b.t.Fatal(err)
+ }
+ return row
+}
+
+func (b *sourceHoldBox) guardRecord() []byte {
+ b.t.Helper()
+ raw, err := os.ReadFile(filepath.Join(b.guard.DataDir, "hostguard", sourceHoldHost+".json"))
+ if err != nil {
+ b.t.Fatal(err)
+ }
+ return raw
+}
+
+// retireRow is the source's park-local retiring its row: the typed
+// retirement's registry step (hostbox.RegistryRows, the one park-local
+// row write), by the row's claim ownership, held or not.
+func (b *sourceHoldBox) retireRow() {
+ b.t.Helper()
+ rows := hostbox.RegistryRows{Registry: b.reg, Inventory: hostbox.RetirementInventory{Host: sourceHoldHost, Captured: hostguard.Captured{Incarnation: b.owner.ClaimIncarnation, Stamp: b.owner.ClaimStamp}, Ownership: b.owner.ClaimOwner}}
+ route := hostbox.Artifact{Kind: hostbox.ArtifactRoute, Root: "registry", Name: sourceHoldHost, Owner: b.owner.ClaimOwner, RowID: b.owner.ClaimRowID}
+ if err := rows.RetireRowsJournal(b.t.Context(), "retire-1", b.owner.ClaimOwner, []hostbox.Artifact{route}, func(json.RawMessage) error { return nil }); err != nil {
+ b.t.Fatalf("the park-local's row retirement: %v", err)
+ }
+}
+
+func regionHA(on bool) func(string) string {
+ return func(k string) string {
+ if k == "CARLOS_REGION_HA" && on {
+ return "1"
+ }
+ return ""
+ }
+}
+
+// complete is the target's probe passing and root's stop of the probe
+// session, then the real serving CAS (hostclaim.CompleteRelocation),
+// which queues the source's park-local unless a fence discharged it.
+func (b *sourceHoldBox) complete() hostclaim.Observed {
+ b.t.Helper()
+ b.update(func(c *hostclaim.Claim) {
+ h := c.Holder
+ h.Stamp, h.Session, h.Generation, h.WriterBox = h.Stamp+5, h.Session+1, "P1", "dst"
+ h.Admissions = append(h.Admissions, hostclaim.AdmissionRecord{Session: h.Session, Box: "dst", Generation: "P1", Predecessor: "g1", Mode: "cold", Region: "ie"})
+ probe, _ := json.Marshal(hostclaim.Probe{Box: "dst", Session: h.Session, At: time.Unix(90, 0).UTC()})
+ stop, _ := json.Marshal(hostclaim.ProbeStop{Box: "dst", Session: h.Session, Receipt: "probe-fence-1", ReceiptSHA256: strings.Repeat("a", 64)})
+ c.Op.Phase, c.Op.Exec = hostclaim.PhaseRelocatingProbing, "dst"
+ c.Op.Journal["probe"], c.Op.Journal["probe_stop"] = string(probe), string(stop)
+ })
+ ctx := b.t.Context()
+ obs, err := hostclaim.Read(ctx, b.store, sourceHoldHost)
+ if err != nil {
+ b.t.Fatal(err)
+ }
+ n := 0
+ obs, err = hostclaim.Update(ctx, b.store, obs, hostclaim.CompleteRelocation("rel-1", func() string { n++; return "id-" + strconv.Itoa(n) }))
+ if err != nil {
+ b.t.Fatalf("the serving CAS: %v", err)
+ }
+ if obs.Claim.Op.Kind != hostclaim.KindServing || obs.Claim.Op.Exec != "dst" || !hostclaim.OwesLocal(obs.Claim, "box") {
+ b.t.Fatalf("after the completion: op %+v obligations %+v", obs.Claim.Op, obs.Claim.Obligations)
+ }
+ return obs
+}
+
+// leaves asserts reconcile, dry run or not, refuses with nothing done:
+// the row exactly as before, the fence not asked, nothing held or
+// written, and the guard record as it was.
+func (b *sourceHoldBox) leaves(what string, before registry.Route, record []byte) {
+ b.t.Helper()
+ asked, holds, writes := b.asked, b.holds, b.writes
+ for _, dry := range []bool{true, false} {
+ if err := b.install(dry); !errors.Is(err, hostbox.ErrChanged) {
+ b.t.Fatalf("%s (dry run %v): %v", what, dry, err)
+ }
+ }
+ if got := b.row(); got == nil || *got != before || b.asked != asked || b.holds != holds || b.writes != writes || !bytes.Equal(b.guardRecord(), record) {
+ b.t.Fatalf("%s: row %+v (was %+v) fence asked %d holds %d writes %d", what, got, before, b.asked-asked, b.holds-holds, b.writes-writes)
+ }
+}
+
+// Task 10b test 3. The completion: the source's row, held at its fence's
+// close, stays held through the real serving CAS. Reconcile neither
+// renews nor releases it (the source is no longer a participant, and
+// reconcile under a serving claim is main's), and the completion queues
+// the source's park-local, whose retirement deletes the held row by its
+// ownership: that ends the hold, and nothing is minted after. A failover
+// from a fenced source: the source is on lost, so reconcile neither asks
+// its fence nor holds its row, whether the row was ready or already held
+// when the box was fenced, both while the failover runs and after its
+// completion queues the park-local; the row is left exactly as it was,
+// and the park-local's retirement removes it.
+func TestRelocationSourceHoldEndsWithTheCompletionOrTheParkLocal(t *testing.T) {
+ t.Run("completion", func(t *testing.T) {
+ b := newSourceHoldBox(t, regionHA(true))
+ b.releasing()
+ b.closeFence()
+ if err := b.install(false); err != nil {
+ t.Fatalf("the fenced source's hold: %v", err)
+ }
+ held := *b.row()
+ if held.Status != "relocating" || held.HoldOp != "rel-1" || b.holds != 1 {
+ t.Fatalf("held row %+v holds %d", held, b.holds)
+ }
+ b.complete()
+ b.leaves("the source after the completion", held, b.guardRecord())
+ b.retireRow()
+ holds := b.holds
+ if err := b.install(false); !errors.Is(err, hostbox.ErrChanged) || b.row() != nil || b.holds != holds {
+ t.Fatalf("after the park-local: %v row %+v holds %d", err, b.row(), b.holds-holds)
+ }
+ })
+ for _, fencedHeld := range []bool{false, true} {
+ name := "failover, row ready when fenced"
+ if fencedHeld {
+ name = "failover, row already held when fenced"
+ }
+ t.Run(name, func(t *testing.T) {
+ b := newSourceHoldBox(t, regionHA(true))
+ b.releasing()
+ if fencedHeld {
+ b.closeFence()
+ if err := b.install(false); err != nil {
+ t.Fatalf("the fenced source's hold: %v", err)
+ }
+ }
+ before, record := *b.row(), b.guardRecord()
+ // hostclaim.FenceSource on a source terminated before its
+ // release is journaled: a failover in pinning, the source off
+ // the participants and on lost, its fence journaled.
+ b.update(func(c *hostclaim.Claim) {
+ c.Op.Phase, c.Op.Exec, c.Op.Participants = hostclaim.PhasePinning, "none", []string{"dst"}
+ c.Op.Journal["reason"], c.Op.Journal["fence.box"] = hostclaim.ReasonFailover, `{"key":"fences/box.json"}`
+ c.Lost = append(c.Lost, "box")
+ })
+ b.leaves("the fenced source during the failover", before, record)
+ // No qualified discharge: the completion queues its park-local.
+ b.complete()
+ b.leaves("the fenced source after the failover's completion", before, record)
+ b.retireRow()
+ if got := b.row(); got != nil {
+ t.Fatalf("the park-local left the row: %+v", got)
+ }
+ })
+ }
+}
+
+// Task 10b test 4. With CARLOS_REGION_HA off, or any value that is not
+// true, relocationSourceRows sets neither hook and reconcile is main's:
+// the serving executor writes its row through the activation path, and a
+// relocating claim is refused by Serving with the row, the claim and the
+// guard record unchanged, even once the source's fence has closed. With
+// it on, the same box holds the row at the fence's close.
+func TestRelocationSourceHoldFollowsTheFlag(t *testing.T) {
+ for _, v := range []string{"", "0", "false", "off", "yes please", "1", "true"} {
+ on := v == "1" || v == "true"
+ t.Run("CARLOS_REGION_HA="+v, func(t *testing.T) {
+ b := newSourceHoldBox(t, func(k string) string {
+ if k == "CARLOS_REGION_HA" {
+ return v
+ }
+ return ""
+ })
+ if (b.r.FenceClosed != nil) != on || (b.r.HoldRow != nil) != on {
+ t.Fatalf("hooks set %v %v with the flag %q", b.r.FenceClosed != nil, b.r.HoldRow != nil, v)
+ }
+ if err := b.install(false); err != nil || b.writes != 1 {
+ t.Fatalf("the serving executor: %v writes %d", err, b.writes)
+ }
+ b.releasing()
+ b.closeFence()
+ before, record := *b.row(), b.guardRecord()
+ obs, err := hostclaim.Read(t.Context(), b.store, sourceHoldHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if on {
+ if err := b.install(false); err != nil {
+ t.Fatalf("region HA on: %v", err)
+ }
+ if got := b.row(); got.Status != "relocating" || got.HoldOp != "rel-1" {
+ t.Fatalf("region HA on: row %+v", got)
+ }
+ return
+ }
+ for _, dry := range []bool{true, false} {
+ if err := b.install(dry); !errors.Is(err, hostbox.ErrChanged) {
+ t.Fatalf("region HA off (dry run %v): %v", dry, err)
+ }
+ }
+ now, err := hostclaim.Read(t.Context(), b.store, sourceHoldHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := b.row(); *got != before || b.writes != 1 || now.ETag != obs.ETag || !bytes.Equal(b.guardRecord(), record) {
+ t.Fatalf("region HA off changed something: row %+v (was %+v) writes %d claim %v", got, before, b.writes, now.ETag != obs.ETag)
+ }
+ })
+ }
+}
diff --git a/internal/hostbox/empty_census.go b/internal/hostbox/empty_census.go
index b1486d1fe..26b709e9d 100644
--- a/internal/hostbox/empty_census.go
+++ b/internal/hostbox/empty_census.go
@@ -30,6 +30,67 @@ type EmptyInstallationCensus struct {
SharedPaths map[string][]string
}
+// CheckHeld is the hold-only path's local check (region HA R5-3; Astra
+// Task 10b round 1): the row this box would hold for a relocation must be
+// its own existing installation's, for the record's incarnation. It reads
+// the host's row and that installation's inventory at the row's own stamp,
+// never at the claim holder's: a relocation's source is held after
+// ToInstalling has moved holder.stamp to the target's install stamp, which
+// this box never had, and CheckLocal would ask for that. It writes nothing
+// and grants nothing: holding is not installing. The directory revision is
+// not compared: it says which roster admitted the installation, and a hold
+// needs only that the installation is this box's. Absence is never
+// evidence: no row is ErrChanged (there is nothing to hold); a row with no
+// valid ownership, no complete inventory of this box and enrollment at its
+// stamp, or a route entry that does not name exactly its owner and row
+// identity, is ErrTypedInventory; a read failure is its error.
+func (c EmptyInstallationCensus) CheckHeld(ctx context.Context, obs hostclaim.Observed, selected RecordSelection) error {
+ if c.Inventory == nil || c.Registry == nil || obs.Claim == nil || obs.Claim.Holder == nil || c.Box == "" || c.Enrollment == "" {
+ return ErrUnsupported
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ if obs.Host != selected.Host || selected.Incarnation == "" || obs.Claim.Holder.Incarnation != selected.Incarnation {
+ return ErrChanged
+ }
+ row, err := c.Registry.Get(selected.Host)
+ if err != nil {
+ return err
+ }
+ if row == nil {
+ return ErrChanged
+ }
+ own := row.ClaimOwnership
+ if !own.Valid() || own.ClaimIncarnation != selected.Incarnation {
+ return ErrTypedInventory
+ }
+ in, err := c.Inventory.ReadInstallation(ctx, selected.Host, own.ClaimIncarnation, own.ClaimStamp)
+ if errors.Is(err, sql.ErrNoRows) {
+ return ErrTypedInventory
+ }
+ if err != nil {
+ return err
+ }
+ if !in.Complete || in.Host != selected.Host || in.Incarnation != own.ClaimIncarnation || in.InstallationStamp != own.ClaimStamp || in.Box != c.Box || in.Enrollment != c.Enrollment {
+ return ErrTypedInventory
+ }
+ routes := 0
+ for _, e := range in.Entries {
+ if e.Kind != "route" || e.RelativePath != selected.Host {
+ continue
+ }
+ routes++
+ if e.Ownership != own.ClaimOwner || e.RowIdentity != own.ClaimRowID || e.Materialization != "materialized" {
+ return ErrTypedInventory
+ }
+ }
+ if routes != 1 {
+ return ErrTypedInventory
+ }
+ return nil
+}
+
func (c EmptyInstallationCensus) CheckLocal(ctx context.Context, obs hostclaim.Observed, selected RecordSelection) error {
if c.Inventory == nil || c.Registry == nil || c.Launches == nil || obs.Claim == nil || len(c.Roots) == 0 || c.Box == "" || c.Enrollment == "" || c.DirectoryRevision == "" {
return ErrUnsupported
diff --git a/internal/hostbox/reconcile.go b/internal/hostbox/reconcile.go
index cd8023ec6..c3f661c4f 100644
--- a/internal/hostbox/reconcile.go
+++ b/internal/hostbox/reconcile.go
@@ -28,6 +28,12 @@ type Reconciler struct {
CheckRecord func(context.Context, RecordSelection) error
// CheckLocal includes complete census and journal recovery before initialization.
CheckLocal func(context.Context, hostclaim.Observed, RecordSelection) error
+ // CheckHeld is the hold-only path's local check, in CheckLocal's place
+ // there: this box's own existing installation for the row it would
+ // hold, never the claim holder's stamp (Task 10b, Astra round 1).
+ // Production: EmptyInstallationCensus.CheckHeld, which
+ // ReconcileAdapter.Install sets from its census. nil refuses the hold.
+ CheckHeld 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
@@ -36,15 +42,18 @@ type Reconciler struct {
// decision depends on it (rowDecision). 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).
+ // Production: AdmissionFenceClosed, region HA on only (Task 10b).
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.
+ // installation authority (ruling 17). Production: RegistryHoldRow,
+ // region HA on only (Task 10b).
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.
+// relocationAware is region HA on: Task 10b sets FenceClosed and HoldRow
+// only with CARLOS_REGION_HA (host's relocationSourceRows). 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 }
// relocates is the row decision's scope: a relocating claim with region HA
@@ -161,6 +170,16 @@ func (r Reconciler) checkOn(ctx context.Context, obs hostclaim.Observed, selecte
if err := r.CheckRecord(ctx, selected); err != nil {
return err
}
+ if holdOnly {
+ // Holding is not installing (Task 10b, Astra round 1): the source
+ // at installing or later is held against its own installation,
+ // which CheckHeld checks; CheckLocal would ask for the holder's
+ // stamp, the target's, and refuse for good.
+ if r.CheckHeld == nil {
+ return ErrUnsupported
+ }
+ return r.CheckHeld(ctx, obs, selected)
+ }
return r.CheckLocal(ctx, obs, selected)
}
func (r Reconciler) Install(ctx context.Context, selected RecordSelection, write func() error) error {
@@ -245,11 +264,10 @@ func (r Reconciler) Install(ctx context.Context, selected RecordSelection, 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.
+// installation authority: the write is conditional on the row's observed
+// hold (registry.SetHoldIf, through RegistryHoldRow), 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
diff --git a/internal/hostbox/reconcile_adapter.go b/internal/hostbox/reconcile_adapter.go
index 60a92d6d2..fae1dbb9a 100644
--- a/internal/hostbox/reconcile_adapter.go
+++ b/internal/hostbox/reconcile_adapter.go
@@ -78,6 +78,10 @@ func (a ReconcileAdapter) Install(ctx context.Context, rec provision.ClaimRecord
}
return nil
}
+ if reconciler.CheckHeld == nil && a.Census != nil {
+ // The hold-only path's local check is the census's own (Task 10b).
+ reconciler.CheckHeld = a.Census.CheckHeld
+ }
return reconciler.Install(ctx, selected, func() error {
captured, err := hostguard.Capture(a.Reconciler.Guard.DataDir, rec.Host)
if err != nil {
diff --git a/internal/hostbox/reconcile_relocation_source_census_test.go b/internal/hostbox/reconcile_relocation_source_census_test.go
new file mode 100644
index 000000000..017d46595
--- /dev/null
+++ b/internal/hostbox/reconcile_relocation_source_census_test.go
@@ -0,0 +1,251 @@
+//go:build unix
+
+package hostbox
+
+import (
+ "context"
+ "errors"
+ "path/filepath"
+ "strconv"
+ "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"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// sourceReconcile is the box "box" as a relocation's source, built as the
+// claim assembly builds it: the production ReconcileAdapter with the real
+// census and park.Local, provision's pass, and Task 10b's production
+// hooks. Its row is minted by a serving pass, then one cold writer session
+// is admitted at the same stamp.
+type sourceReconcile struct {
+ f admissionFixture
+ reg *registry.Registry
+ inv *StopInventoryDB
+ census *EmptyInstallationCensus
+ guard hostguard.Guard
+ adapter ReconcileAdapter
+ cfg provision.ReconcileConfig
+}
+
+func newSourceReconcile(t *testing.T) *sourceReconcile {
+ t.Helper()
+ s := &sourceReconcile{f: newAdmissionFixture(t, false)}
+ var err error
+ if s.reg, err = registry.Open(filepath.Join(t.TempDir(), "registry.db")); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { s.reg.Close() })
+ if s.inv, err = OpenStopInventoryDB(filepath.Join(t.TempDir(), "inventory.db")); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { s.inv.Close() })
+ launches, err := OpenLaunchJournal(filepath.Join(t.TempDir(), "launches.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { launches.Close() })
+ s.census = &EmptyInstallationCensus{Inventory: s.inv, Registry: s.reg, Launches: launches, Box: "box", Enrollment: "en", DirectoryRevision: "1",
+ Roots: map[string]string{"data": s.f.data, "opt": t.TempDir(), "units": t.TempDir()}, SharedPaths: map[string][]string{"data": {"hostguard"}}}
+ s.guard = hostguard.Guard{DataDir: s.f.data, LockDir: t.TempDir(), Claims: hostguard.StoreReader{Store: s.f.store}}
+ source := WorkSource{Guard: s.guard, Box: "box", Region: "ie", Now: time.Now,
+ Policy: func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error { return nil },
+ Local: func(context.Context, *hostclaim.Claim, Selection) error { return nil }}
+ s.adapter = ReconcileAdapter{Registry: s.reg, Inventory: s.inv, Census: s.census, Box: "box", Enrollment: "en", DirectoryRevision: "1",
+ Reconciler: Reconciler{Claims: s.f.store, Box: "box", Region: "ie", Guard: s.guard, Writes: hostwrite.Config{Enabled: true, Selector: source},
+ Local: park.Local{Guard: s.guard, Claims: s.f.store, DataDir: s.f.data, Box: "box"},
+ Now: func() time.Time { return time.Unix(100, 0) }, CheckRecord: func(context.Context, RecordSelection) error { return nil }, CheckLocal: s.census.CheckLocal,
+ FenceClosed: AdmissionFenceClosed(s.guard), HoldRow: RegistryHoldRow(s.reg)}}
+ s.cfg = provision.ReconcileConfig{DataDir: s.f.data, Region: "ie"}
+ s.cfg.Layout = func(context.Context, release.Store) (string, error) { return release.LayoutModeNested, nil }
+ s.adapter.Configure(&s.cfg)
+ s.pass(t)
+ if row := s.row(t); row == nil || row.Status != "ready" || row.HoldOp != "" || !row.ClaimOwnership.Valid() {
+ t.Fatalf("the serving pass minted %+v", row)
+ }
+ s.update(t, func(c *hostclaim.Claim) error {
+ return hostclaim.AdmitWriter(hostclaim.Admission{Box: "box", Mode: "cold", NewGeneration: "g1", Region: "ie", KeepStamp: true})(c)
+ })
+ return s
+}
+
+func (s *sourceReconcile) pass(t *testing.T) {
+ t.Helper()
+ if err := s.f.pass(t.Context(), s.f.store, s.reg, s.cfg); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func (s *sourceReconcile) row(t *testing.T) *registry.Route {
+ t.Helper()
+ row, err := s.reg.Get(s.f.host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return row
+}
+
+func (s *sourceReconcile) update(t *testing.T, change func(*hostclaim.Claim) error) hostclaim.Observed {
+ t.Helper()
+ o, err := hostclaim.Read(t.Context(), s.f.store, s.f.host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if o, err = hostclaim.Update(t.Context(), s.f.store, o, change); err != nil {
+ t.Fatal(err)
+ }
+ return o
+}
+
+// releasingThenInstalling moves the claim through a drain from "box" to
+// "dst": releasing, root's release stop fencing the source's admission
+// (BeginFence), then the installing CAS, which advances holder.stamp to
+// the target's install stamp. No reconcile runs on the source in between.
+func (s *sourceReconcile) releasingThenInstalling(t *testing.T) hostclaim.Observed {
+ t.Helper()
+ ctx := t.Context()
+ o := s.update(t, func(c *hostclaim.Claim) error {
+ c.Op = hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "rel-1", Phase: hostclaim.PhaseReleasing, Exec: "box", Participants: []string{"box", "dst"},
+ Intent: map[string]string{"region": "ie", "reason": "drain", "source_box": "box", "target_box": "dst"}, Journal: map[string]string{"exec_ack.box": "{}", "exec_ack.dst": "{}"}}
+ return nil
+ })
+ captured, err := hostguard.Capture(s.f.data, s.f.host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ target := hostguard.FenceTarget{FenceID: "fence-1", Host: s.f.host, Incarnation: captured.Incarnation, Stamp: captured.Stamp, Session: o.Claim.Holder.Session,
+ Op: "rel-1", Phase: hostclaim.PhaseReleasing, Box: "box", Enrollment: "en", BootID: "boot", ControllerID: "ctl", ClaimETag: o.ETag, DirectoryRevision: "1"}
+ if _, err := s.guard.BeginFence(ctx, target); err != nil {
+ t.Fatal(err)
+ }
+ return s.update(t, func(c *hostclaim.Claim) error {
+ c.Holder.Stamp = c.Gen + 1
+ c.Op.Phase, c.Op.Exec = hostclaim.PhaseInstalling, "dst"
+ c.Op.Journal["install_stamp"] = strconv.FormatUint(c.Holder.Stamp, 10)
+ return nil
+ })
+}
+
+// Astra Task 10b round 1 (P2; rev 5.4.1): the source's fence closes in
+// releasing, but its first reconcile after that runs only once the claim
+// is at installing, with holder.stamp at the target's install stamp, which
+// this box never had. Through the production adapter, census and
+// provision pass, the source's row is held all the same, its ownership
+// unchanged: the hold-only path checks the source's own installation (the
+// row's stamp and its inventory), never the holder's stamp, and grants
+// nothing, so the guard record and the claim are unchanged. A replay
+// changes nothing.
+func TestReconcileHoldsTheSourceWhenItsFirstPassAfterTheFenceIsAtInstalling(t *testing.T) {
+ s := newSourceReconcile(t)
+ minted := *s.row(t)
+ o := s.releasingThenInstalling(t)
+ if o.Claim.Holder.Stamp == minted.ClaimStamp {
+ t.Fatal("the installing CAS kept the source's stamp")
+ }
+ guard := targetGuardFiles(t, s.f.data)
+ for i := 0; i < 2; i++ {
+ s.pass(t)
+ row := s.row(t)
+ if row == nil || row.Status != "relocating" || row.HoldOp != "rel-1" || row.ClaimOwnership != minted.ClaimOwnership {
+ t.Fatalf("pass %d at installing: the source's row is %+v (minted %+v)", i, row, minted)
+ }
+ held := *row
+ held.Status, held.HoldOp = minted.Status, minted.HoldOp
+ if held != minted {
+ t.Fatalf("pass %d: the hold changed more than status and hold_op: %+v", i, row)
+ }
+ if got := targetGuardFiles(t, s.f.data); got[s.f.host+".json"] != guard[s.f.host+".json"] {
+ t.Fatalf("pass %d: the guard record changed", i)
+ }
+ after, err := hostclaim.Read(t.Context(), s.f.store, s.f.host)
+ if err != nil || after.ETag != o.ETag {
+ t.Fatalf("pass %d: the claim was written: %v", i, err)
+ }
+ }
+}
+
+// The hold-only check (EmptyInstallationCensus.CheckHeld) accepts the
+// source's own existing installation at installing, where main's
+// CheckLocal refuses (it asks for the target's stamp). Absence is never
+// evidence: an inventory of another box or enrollment, no inventory, a
+// row owned by another row identity, another incarnation, no row and an
+// unreadable registry all refuse. Through the pass, a contradicting
+// inventory leaves the row exactly as it was.
+func TestCensusCheckHeldRefusesAContradictingOrMissingSourceInventory(t *testing.T) {
+ ctx := t.Context()
+ s := newSourceReconcile(t)
+ minted := *s.row(t)
+ o := s.releasingThenInstalling(t)
+ selected := RecordSelection{Host: s.f.host, Incarnation: o.Claim.Holder.Incarnation}
+ if err := s.census.CheckHeld(ctx, o, selected); err != nil {
+ t.Fatalf("the source's own installation: %v", err)
+ }
+ if err := s.census.CheckLocal(ctx, o, selected); !errors.Is(err, ErrTypedInventory) {
+ t.Fatalf("main's CheckLocal at installing: %v (it asks for the target's stamp)", err)
+ }
+ empty, err := OpenStopInventoryDB(filepath.Join(t.TempDir(), "inventory.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { empty.Close() })
+ otherRows, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { otherRows.Close() })
+ stranger := minted
+ stranger.ClaimRowID = "another-row"
+ if err := otherRows.Upsert(stranger); err != nil {
+ t.Fatal(err)
+ }
+ for name, tc := range map[string]struct {
+ change func(*EmptyInstallationCensus, *RecordSelection)
+ want error
+ }{
+ "another box": {func(c *EmptyInstallationCensus, _ *RecordSelection) { c.Box = "dst" }, ErrTypedInventory},
+ "another enrollment": {func(c *EmptyInstallationCensus, _ *RecordSelection) { c.Enrollment = "en-2" }, ErrTypedInventory},
+ "no inventory": {func(c *EmptyInstallationCensus, _ *RecordSelection) { c.Inventory = empty }, ErrTypedInventory},
+ "another row id": {func(c *EmptyInstallationCensus, _ *RecordSelection) { c.Registry = otherRows }, ErrTypedInventory},
+ "another incarnation": {func(_ *EmptyInstallationCensus, sel *RecordSelection) { sel.Incarnation = "inc-other" }, ErrChanged},
+ } {
+ c, sel := *s.census, selected
+ tc.change(&c, &sel)
+ if err := c.CheckHeld(ctx, o, sel); !errors.Is(err, tc.want) {
+ t.Fatalf("%s: %v, want %v", name, err, tc.want)
+ }
+ }
+ noRow, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { noRow.Close() })
+ c := *s.census
+ c.Registry = noRow
+ if err := c.CheckHeld(ctx, o, selected); !errors.Is(err, ErrChanged) {
+ t.Fatalf("no row: %v", err)
+ }
+ closed, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ closed.Close()
+ c.Registry = closed
+ if err := c.CheckHeld(ctx, o, selected); err == nil || errors.Is(err, ErrChanged) || errors.Is(err, ErrTypedInventory) {
+ t.Fatalf("an unreadable registry: %v (want the read's error)", err)
+ }
+ // Through the pass: a contradicting inventory refuses the hold.
+ contradicting := *s.census
+ contradicting.Enrollment = "en-2"
+ s.adapter.Census = &contradicting
+ s.adapter.Configure(&s.cfg)
+ s.pass(t)
+ if row := s.row(t); *row != minted {
+ t.Fatalf("a contradicting inventory: the row is %+v (was %+v)", row, minted)
+ }
+}
diff --git a/internal/hostbox/reconcile_relocation_source_test.go b/internal/hostbox/reconcile_relocation_source_test.go
new file mode 100644
index 000000000..d6bd84468
--- /dev/null
+++ b/internal/hostbox/reconcile_relocation_source_test.go
@@ -0,0 +1,200 @@
+//go:build unix
+
+package hostbox
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// sourceRows is relocationReconciler with Task 10b's production hooks: the
+// host guard's admission fence (AdmissionFenceClosed) and the registry
+// hold write (RegistryHoldRow), over a registry holding the source's ready
+// row, owned by the installation the guard has active.
+func sourceRows(t *testing.T) (Reconciler, RecordSelection, *registry.Registry, hostguard.Guard) {
+ t.Helper()
+ r, selected, _, guard := relocationReconciler(t)
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { reg.Close() })
+ captured, err := hostguard.Capture(guard.DataDir, selected.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ owner := registry.ClaimOwnership{ClaimIncarnation: captured.Incarnation, ClaimStamp: captured.Stamp, ClaimOwner: "owner-1", ClaimRowID: "row-1"}
+ if err := reg.Upsert(registry.Route{Host: selected.Host, Status: "ready", Socket: "/run/shop.sock", ClaimOwnership: owner}); err != nil {
+ t.Fatal(err)
+ }
+ r.FenceClosed, r.HoldRow = AdmissionFenceClosed(guard), RegistryHoldRow(reg)
+ return r, selected, reg, guard
+}
+
+// closeSourceFence is root beginning the source's stop for the op: the
+// durable admission fence in the guard record (BeginFence).
+func closeSourceFence(t *testing.T, r Reconciler, selected RecordSelection, guard hostguard.Guard) {
+ t.Helper()
+ ctx := context.Background()
+ 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)
+ }
+}
+
+func rowOf(t *testing.T, reg *registry.Registry, host string) *registry.Route {
+ t.Helper()
+ row, err := reg.Get(host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return row
+}
+
+// Task 10b test 1 (R5-3): during releasing the source's row is unchanged
+// until this box's admission fence for the host closes; reconcile then
+// holds it for the op in "relocating", a status the edge never serves,
+// with every other column as it was, its claim ownership included. A dry
+// run writes nothing, an unreadable fence is an error with the row left
+// as it was, never "open", and with no hold-only local check (CheckHeld)
+// nothing is held.
+func TestReconcileHoldsTheSourcesRowAtItsFencesClose(t *testing.T) {
+ r, selected, reg, guard := sourceRows(t)
+ ctx := context.Background()
+ obs := relocate(t, r, selected.Host, hostclaim.PhaseReleasing)
+ before := *rowOf(t, reg, selected.Host)
+ 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("the source before its fence closed (dry run %v): %v", dry, err)
+ }
+ }
+ if got := rowOf(t, reg, selected.Host); writes != 0 || *got != before {
+ t.Fatalf("before the fence closed: writes %d row %+v (want %+v)", writes, got, before)
+ }
+ if now, err := hostclaim.Read(ctx, r.Claims, selected.Host); err != nil || now.ETag != obs.ETag {
+ t.Fatalf("the claim was written before the fence closed: %v", err)
+ }
+ // An unreadable fence is an error, never "open": the row stays.
+ record := filepath.Join(guard.DataDir, "hostguard", selected.Host+".json")
+ if err := os.Rename(record, record+".away"); err != nil {
+ t.Fatal(err)
+ }
+ if err := r.Install(ctx, selected, func() error { writes++; return nil }); err == nil || errors.Is(err, ErrChanged) {
+ t.Fatalf("an unreadable fence: %v (want the read's error)", err)
+ }
+ if err := os.Rename(record+".away", record); err != nil {
+ t.Fatal(err)
+ }
+ if got := rowOf(t, reg, selected.Host); writes != 0 || *got != before {
+ t.Fatalf("an unreadable fence changed the row: %+v", got)
+ }
+ closeSourceFence(t, r, selected, guard)
+ dry := selected
+ dry.DryRun = true
+ if err := r.Install(ctx, dry, func() error { writes++; return nil }); err != nil {
+ t.Fatalf("the fenced source's dry run: %v", err)
+ }
+ if got := rowOf(t, reg, selected.Host); *got != before {
+ t.Fatalf("a dry run held the row: %+v", got)
+ }
+ // The hold-only path's local check is required: without it nothing
+ // is held.
+ checkHeld := r.CheckHeld
+ r.CheckHeld = nil
+ if err := r.Install(ctx, selected, func() error { writes++; return nil }); !errors.Is(err, ErrUnsupported) {
+ t.Fatalf("no hold-only check: %v", err)
+ }
+ if got := rowOf(t, reg, selected.Host); *got != before {
+ t.Fatalf("a hold without its local check: %+v", got)
+ }
+ r.CheckHeld = checkHeld
+ if err := r.Install(ctx, selected, func() error { writes++; return nil }); err != nil {
+ t.Fatalf("the fenced source's hold: %v", err)
+ }
+ got := rowOf(t, reg, selected.Host)
+ if writes != 0 || got.Status != "relocating" || got.HoldOp != "rel-1" || !registry.IsHold(got.Status) {
+ t.Fatalf("after the fence closed: writes %d row %+v", writes, got)
+ }
+ unheld := *got
+ unheld.Status, unheld.HoldOp = before.Status, before.HoldOp
+ if unheld != before {
+ t.Fatalf("the hold changed more than status and hold_op: %+v (was %+v)", got, before)
+ }
+}
+
+// Task 10b test 2: the hold write never takes a row another op holds, a
+// replay changes nothing, a missing row is not minted, and an unreadable
+// row is an error, never "held". Through the reconciler, a fenced source
+// whose row another op holds is refused with the row as it was.
+func TestReconcileSourceHoldNeverTakesAnotherOpsRow(t *testing.T) {
+ r, selected, reg, guard := sourceRows(t)
+ ctx := context.Background()
+ hold := RegistryHoldRow(reg)
+ relocate(t, r, selected.Host, hostclaim.PhaseReleasing)
+ closeSourceFence(t, r, selected, guard)
+
+ if ok, err := reg.SetHoldIf(selected.Host, "moving", "mv-9", ""); err != nil || !ok {
+ t.Fatalf("another op's hold: %v %v", ok, err)
+ }
+ other := *rowOf(t, reg, selected.Host)
+ if err := hold(ctx, selected.Host, "rel-1"); !errors.Is(err, ErrChanged) {
+ t.Fatalf("another op's row: %v", err)
+ }
+ if err := r.Install(ctx, selected, func() error { return nil }); !errors.Is(err, ErrChanged) {
+ t.Fatalf("reconcile over another op's row: %v", err)
+ }
+ if got := rowOf(t, reg, selected.Host); *got != other {
+ t.Fatalf("another op's row was taken: %+v (was %+v)", got, other)
+ }
+ if ok, err := reg.ReleaseHold(selected.Host, "mv-9"); err != nil || !ok {
+ t.Fatalf("release the other op's hold: %v %v", ok, err)
+ }
+
+ if err := r.Install(ctx, selected, func() error { return nil }); err != nil {
+ t.Fatalf("the hold: %v", err)
+ }
+ held := *rowOf(t, reg, selected.Host)
+ for i := 0; i < 2; i++ {
+ if err := hold(ctx, selected.Host, "rel-1"); err != nil {
+ t.Fatalf("a replay of the write: %v", err)
+ }
+ if err := r.Install(ctx, selected, func() error { return nil }); err != nil {
+ t.Fatalf("a replay through reconcile: %v", err)
+ }
+ if got := rowOf(t, reg, selected.Host); *got != held {
+ t.Fatalf("a replay changed the row: %+v (was %+v)", got, held)
+ }
+ }
+
+ if err := hold(ctx, "absent.example", "rel-1"); !errors.Is(err, ErrChanged) {
+ t.Fatalf("no row: %v", err)
+ }
+ if got := rowOf(t, reg, "absent.example"); got != nil {
+ t.Fatalf("the hold minted a row: %+v", got)
+ }
+ if err := reg.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if err := hold(ctx, selected.Host, "rel-1"); err == nil || errors.Is(err, ErrChanged) {
+ t.Fatalf("an unreadable row: %v (want the read's error)", err)
+ }
+}
diff --git a/internal/hostbox/reconcile_relocation_test.go b/internal/hostbox/reconcile_relocation_test.go
index a54e33338..a6a27aa71 100644
--- a/internal/hostbox/reconcile_relocation_test.go
+++ b/internal/hostbox/reconcile_relocation_test.go
@@ -36,6 +36,7 @@ func relocationReconciler(t *testing.T) (Reconciler, RecordSelection, *[]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 },
+ CheckHeld: 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)
diff --git a/internal/hostbox/relocationsource.go b/internal/hostbox/relocationsource.go
new file mode 100644
index 000000000..6794d9434
--- /dev/null
+++ b/internal/hostbox/relocationsource.go
@@ -0,0 +1,81 @@
+package hostbox
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// AdmissionFenceClosed is Reconciler.FenceClosed over this box's host
+// guard (region HA §5.3, R5-3): whether the host's admission is closed
+// for the installation the guard record has active, on the claim
+// observed, read exactly as With reads it (hostguard.Guard.AdmissionFenced).
+// Root writes that fence durably when it begins the source's stop
+// (hostguard.Guard.BeginFence, before any privileged stop work), so it
+// survives an agent restart and a completed stop still reads closed. It
+// is not the activator's in-process fence (FenceAndStopAs), which a
+// restart empties. A record that cannot be read, or a claim for another
+// host or incarnation, is an error, never "open": absence is never
+// evidence, and the Reconciler then keeps every row and mints none. It
+// takes no lock: Install asks it outside the host lock and holdOnly
+// under it, and the record is replaced atomically.
+func AdmissionFenceClosed(g hostguard.Guard) func(context.Context, hostclaim.Observed) (bool, error) {
+ return func(ctx context.Context, o hostclaim.Observed) (bool, error) {
+ if err := ctx.Err(); err != nil {
+ return false, err
+ }
+ captured, err := hostguard.Capture(g.DataDir, o.Host)
+ if err != nil {
+ return false, err
+ }
+ return g.AdmissionFenced(o.Host, o.Claim, captured)
+ }
+}
+
+// RegistryHoldRow is Reconciler.HoldRow over this box's registry (R5-3):
+// it keeps the host's existing row held for op in "relocating", a status
+// the edge never serves (registry.HoldStatuses), with registry.SetHoldIf
+// expecting the row unheld, the conditional write moves' holdExisting
+// makes. A row already held for op is a replay and is left exactly as it
+// is: nothing is written. A row another op holds is never taken, and
+// neither is one whose hold changed between the read and the write
+// (SetHoldIf matches only an unheld row): both are ErrChanged with
+// nothing written, and the next pass judges afresh. No row is ErrChanged
+// and nothing is minted (ruling 17); a row that cannot be read is its
+// error. Neither is ever "held". The write sets status and hold_op only,
+// so the row's claim ownership stays as it was and the park-local's
+// retirement, which deletes by ownership whatever the hold, still finds
+// it.
+func RegistryHoldRow(reg *registry.Registry) func(context.Context, string, string) error {
+ return func(ctx context.Context, host, op string) error {
+ if reg == nil || host == "" || op == "" {
+ return ErrUnsupported
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ row, err := reg.Get(host)
+ if err != nil {
+ return err
+ }
+ switch {
+ case row == nil:
+ return fmt.Errorf("%w: %s has no row to hold for %s", ErrChanged, host, op)
+ case row.HoldOp == op && registry.IsHold(row.Status):
+ return nil
+ case row.HoldOp != "":
+ return fmt.Errorf("%w: %s's row is held by %q, not %s", ErrChanged, host, row.HoldOp, op)
+ }
+ held, err := reg.SetHoldIf(host, "relocating", op, "")
+ if err != nil {
+ return err
+ }
+ if !held {
+ return fmt.Errorf("%w: %s's row changed before it was held for %s", ErrChanged, host, op)
+ }
+ return nil
+ }
+}
diff --git a/internal/registry/relocating_test.go b/internal/registry/relocating_test.go
index 7cd528e88..1fc6f84de 100644
--- a/internal/registry/relocating_test.go
+++ b/internal/registry/relocating_test.go
@@ -1,6 +1,7 @@
package registry
import (
+ "context"
"path/filepath"
"testing"
)
@@ -37,3 +38,59 @@ func TestRelocatingIsAHoldStatus(t *testing.T) {
t.Fatalf("after release: %+v", row)
}
}
+
+// Region HA Task 10b (R5-3): the source's row is held with SetHoldIf
+// expecting it unheld. That write never takes a row another op holds, a
+// replay of it matches nothing and changes nothing, it mints no row, and
+// it leaves the row's claim ownership as it was, so the source's
+// park-local retires the held row by ownership (RetireClaimRows), which
+// ends the hold.
+func TestSetHoldIfRelocatingNeverTakesAnotherOpsRow(t *testing.T) {
+ ctx := context.Background()
+ reg, err := Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer reg.Close()
+ owner := ClaimOwnership{ClaimIncarnation: "inc-1", ClaimStamp: 7, ClaimOwner: "owner-1", ClaimRowID: "row-1"}
+ if err := reg.Upsert(Route{Host: "shop.example", Status: "ready", Socket: "/run/shop.sock", ClaimOwnership: owner}); err != nil {
+ t.Fatal(err)
+ }
+ if held, err := reg.SetHoldIf("shop.example", "relocating", "rel-1", ""); err != nil || !held {
+ t.Fatalf("the hold: %v %v", held, err)
+ }
+ held, err := reg.Get("shop.example")
+ if err != nil || held.Status != "relocating" || held.HoldOp != "rel-1" || held.ClaimOwnership != owner {
+ t.Fatalf("held row %+v %v", held, err)
+ }
+ // A replay matches nothing and changes nothing.
+ if again, err := reg.SetHoldIf("shop.example", "relocating", "rel-1", ""); err != nil || again {
+ t.Fatalf("a replay wrote: %v %v", again, err)
+ }
+ // Another op neither takes the hold expecting an unheld row nor by
+ // naming the holder.
+ if took, err := reg.SetHoldIf("shop.example", "relocating", "rel-2", ""); err != nil || took {
+ t.Fatalf("another op took the row: %v %v", took, err)
+ }
+ if took, err := reg.SetHoldIf("shop.example", "relocating", "rel-2", "rel-1"); err == nil || took {
+ t.Fatalf("a takeover was accepted: %v %v", took, err)
+ }
+ if now, err := reg.Get("shop.example"); err != nil || *now != *held {
+ t.Fatalf("the row changed: %+v (was %+v) %v", now, held, err)
+ }
+ // No row: nothing matches and nothing is minted.
+ if minted, err := reg.SetHoldIf("absent.example", "relocating", "rel-1", ""); err != nil || minted {
+ t.Fatalf("a missing row: %v %v", minted, err)
+ }
+ if row, err := reg.Get("absent.example"); err != nil || row != nil {
+ t.Fatalf("a row was minted: %+v %v", row, err)
+ }
+ // The park-local's retirement deletes the held row by its ownership.
+ target := []ClaimRow{{Kind: "route", Host: "shop.example", Ownership: owner}}
+ if err := reg.RetireClaimRows(ctx, "retire-1", target, func([]ClaimRowArchive) error { return nil }); err != nil {
+ t.Fatalf("the park-local's retirement of the held row: %v", err)
+ }
+ if row, err := reg.Get("shop.example"); err != nil || row != nil {
+ t.Fatalf("after the retirement: %+v %v", row, err)
+ }
+}