region ha: a source whose reconcile is delayed past the completion holds its row for the completed op until its park-local retires it (L18 finding 3).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+493
−22
internal/host/relocationsourcehold.go+7 −4internal/host/relocationsourcehold_test.go+155 −10internal/hostbox/reconcile.go+78 −7internal/hostbox/reconcile_relocation_catchup_test.go+251 −0internal/hostbox/relocationsource.go+2 −1
diff --git a/internal/host/relocationsourcehold.go b/internal/host/relocationsourcehold.go| index fedbbf187..2e3d01855 100644 |
| --- a/internal/host/relocationsourcehold.go |
| +++ b/internal/host/relocationsourcehold.go |
| @@ -12,10 +12,13 @@ import ( |
| // (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 |
| +// an existing row held. A source whose reconcile is delayed past the |
| +// completion is held then, for the completed op, by reconcile's catch-up |
| +// hold while it owes that op's park-local (L18 joint round, finding 3). |
| +// 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) { |
diff --git a/internal/host/relocationsourcehold_test.go b/internal/host/relocationsourcehold_test.go| index 92abfcc54..030e67331 100644 |
| --- a/internal/host/relocationsourcehold_test.go |
| +++ b/internal/host/relocationsourcehold_test.go |
| @@ -194,6 +194,16 @@ func regionHA(on bool) func(string) string { |
| // 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() |
| + obs := b.completeOwing() |
| + if !hostclaim.OwesLocal(obs.Claim, "box") { |
| + b.t.Fatalf("after the completion: obligations %+v", obs.Claim.Obligations) |
| + } |
| + return obs |
| +} |
| + |
| +// completeOwing is complete without asking which boxes owe a park-local. |
| +func (b *sourceHoldBox) completeOwing() hostclaim.Observed { |
| b.t.Helper() |
| b.update(func(c *hostclaim.Claim) { |
| h := c.Holder |
| @@ -214,7 +224,7 @@ func (b *sourceHoldBox) complete() hostclaim.Observed { |
| 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") { |
| + if obs.Claim.Op.Kind != hostclaim.KindServing || obs.Claim.Op.Exec != "dst" { |
| b.t.Fatalf("after the completion: op %+v obligations %+v", obs.Claim.Op, obs.Claim.Obligations) |
| } |
| return obs |
| @@ -236,12 +246,28 @@ func (b *sourceHoldBox) leaves(what string, before registry.Route, record []byte |
| } |
| } |
| +// replays asserts reconcile, dry run or not, answers the catch-up hold's |
| +// replay (L18 finding 3): nil, with the row exactly as before, the fence |
| +// not asked, nothing activated or written, and the guard record as it was. |
| +func (b *sourceHoldBox) replays(what string, before registry.Route, record []byte) { |
| + b.t.Helper() |
| + asked, writes := b.asked, b.writes |
| + for _, dry := range []bool{true, false} { |
| + if err := b.install(dry); err != nil { |
| + b.t.Fatalf("%s (dry run %v): %v", what, dry, err) |
| + } |
| + } |
| + if got := b.row(); got == nil || *got != before || b.asked != asked || b.writes != writes || !bytes.Equal(b.guardRecord(), record) { |
| + b.t.Fatalf("%s: row %+v (was %+v) fence asked %d writes %d", what, got, before, b.asked-asked, 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 |
| +// close, stays held through the real serving CAS. Reconcile never |
| +// releases it: under the serving claim the source owes the completed op's |
| +// park-local, so reconcile's catch-up hold (L18 finding 3) replays the |
| +// hold and writes nothing. The park-local's 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 |
| @@ -260,11 +286,10 @@ func TestRelocationSourceHoldEndsWithTheCompletionOrTheParkLocal(t *testing.T) { |
| t.Fatalf("held row %+v holds %d", held, b.holds) |
| } |
| b.complete() |
| - b.leaves("the source after the completion", held, b.guardRecord()) |
| + b.replays("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) |
| + if err := b.install(false); !errors.Is(err, hostbox.ErrChanged) || b.row() != nil || b.writes != 0 { |
| + t.Fatalf("after the park-local: %v row %+v writes %d", err, b.row(), b.writes) |
| } |
| }) |
| for _, fencedHeld := range []bool{false, true} { |
| @@ -355,3 +380,123 @@ func TestRelocationSourceHoldFollowsTheFlag(t *testing.T) { |
| }) |
| } |
| } |
| + |
| +// L18 joint round, finding 3: the source releases and its fence closes, |
| +// its reconcile is delayed, the target completes, and only then does the |
| +// source reconcile. The claim serves on another box, so Task 10b's |
| +// relocating scope no longer reaches it; the catch-up hold does. Through |
| +// the production hooks (relocationSourceRows) and the real reconcile: the |
| +// row ends held for the completed op, never ready, its ownership and |
| +// every other column as they were, the fence not asked, nothing activated |
| +// and the claim and the guard record unchanged; a replay changes nothing; |
| +// the park-local's retirement then ends it and nothing is minted. |
| +// |
| +// The guards: a row another op holds is never taken; with region HA off |
| +// reconcile is main's (refused, the row ready and unchanged); a box that |
| +// was not the relocation's source, or owes a park-local for another op |
| +// only, does nothing; a box on lost is left to its park-local (Task 10b |
| +// decision 3, the failover test above); a completion record that cannot |
| +// be read is its error, never "no completion", with the row unchanged. |
| +func TestRelocationSourceCatchUpHoldAfterTheCompletion(t *testing.T) { |
| + t.Run("delayed source reconcile", func(t *testing.T) { |
| + b := newSourceHoldBox(t, regionHA(true)) |
| + minted := *b.row() |
| + b.releasing() |
| + b.closeFence() |
| + done := b.complete() |
| + record := b.guardRecord() |
| + if err := b.install(false); err != nil { |
| + t.Fatalf("the source's first reconcile after the completion: %v", err) |
| + } |
| + got := *b.row() |
| + if got.Status != "relocating" || got.HoldOp != "rel-1" || b.holds != 1 || b.asked != 0 || b.writes != 0 { |
| + t.Fatalf("the source's row %+v holds %d fence asked %d writes %d", got, b.holds, b.asked, b.writes) |
| + } |
| + held := got |
| + held.Status, held.HoldOp = minted.Status, minted.HoldOp |
| + if held != minted { |
| + t.Fatalf("the hold changed more than status and hold_op: %+v (minted %+v)", got, minted) |
| + } |
| + if now, err := hostclaim.Read(t.Context(), b.store, sourceHoldHost); err != nil || now.ETag != done.ETag || !bytes.Equal(b.guardRecord(), record) { |
| + t.Fatalf("the catch-up hold wrote the claim or the guard record: %v", err) |
| + } |
| + b.replays("the replay", got, record) |
| + b.retireRow() |
| + if err := b.install(false); !errors.Is(err, hostbox.ErrChanged) || b.row() != nil || b.writes != 0 { |
| + t.Fatalf("after the park-local: %v row %+v writes %d", err, b.row(), b.writes) |
| + } |
| + }) |
| + t.Run("another op's row is never taken", func(t *testing.T) { |
| + b := newSourceHoldBox(t, regionHA(true)) |
| + b.releasing() |
| + b.closeFence() |
| + if ok, err := b.reg.SetHoldIf(sourceHoldHost, "moving", "mv-9", ""); err != nil || !ok { |
| + t.Fatalf("holding the row for another op: %v %v", ok, err) |
| + } |
| + before := *b.row() |
| + b.complete() |
| + for _, dry := range []bool{true, false} { |
| + if err := b.install(dry); !dry && !errors.Is(err, hostbox.ErrChanged) { |
| + t.Fatalf("another op's row: %v", err) |
| + } |
| + } |
| + if got := b.row(); *got != before || b.writes != 0 { |
| + t.Fatalf("another op's row was taken: %+v (was %+v) writes %d", got, before, b.writes) |
| + } |
| + }) |
| + t.Run("region HA off", func(t *testing.T) { |
| + b := newSourceHoldBox(t, regionHA(false)) |
| + b.releasing() |
| + b.closeFence() |
| + b.complete() |
| + b.leaves("region HA off after the completion", *b.row(), b.guardRecord()) |
| + if got := b.row(); got.Status != "ready" || got.HoldOp != "" { |
| + t.Fatalf("region HA off: row %+v", got) |
| + } |
| + }) |
| + t.Run("not the source", func(t *testing.T) { |
| + b := newSourceHoldBox(t, regionHA(true)) |
| + before, record := *b.row(), b.guardRecord() |
| + // A drain of the host from "other" to "dst": this box holds a |
| + // stale local record and row, but was never a participant. |
| + b.update(func(c *hostclaim.Claim) { |
| + c.Op = hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "rel-1", Phase: hostclaim.PhaseReleasing, Exec: "other", Participants: []string{"other", "dst"}, |
| + Intent: map[string]string{"region": "ie", "reason": "drain", "source_box": "other", "target_box": "dst"}, Journal: map[string]string{"exec_ack.other": "{}", "exec_ack.dst": "{}"}} |
| + }) |
| + done := b.completeOwing() |
| + if hostclaim.OwesLocal(done.Claim, "box") || !hostclaim.OwesLocal(done.Claim, "other") { |
| + t.Fatalf("the completion's park-locals: %+v", done.Claim.Obligations) |
| + } |
| + b.leaves("a box that was not the source", before, record) |
| + }) |
| + t.Run("a park-local for another op only", func(t *testing.T) { |
| + b := newSourceHoldBox(t, regionHA(true)) |
| + b.releasing() |
| + b.closeFence() |
| + b.complete() |
| + b.update(func(c *hostclaim.Claim) { |
| + for i := range c.Obligations { |
| + if c.Obligations[i].Kind == "park-local" && c.Obligations[i].Box == "box" { |
| + c.Obligations[i].Op = "rel-0" |
| + } |
| + } |
| + }) |
| + b.leaves("a park-local for another op", *b.row(), b.guardRecord()) |
| + }) |
| + t.Run("an unreadable completion record", func(t *testing.T) { |
| + b := newSourceHoldBox(t, regionHA(true)) |
| + b.releasing() |
| + b.closeFence() |
| + b.complete() |
| + b.update(func(c *hostclaim.Claim) { c.Op.Journal[hostclaim.JournalCompleted] = "{" }) |
| + before, record := *b.row(), b.guardRecord() |
| + for _, dry := range []bool{true, false} { |
| + if err := b.install(dry); err == nil || errors.Is(err, hostbox.ErrChanged) || !errors.Is(err, hostclaim.ErrRelocation) { |
| + t.Fatalf("an unreadable completion record (dry run %v): %v (want the read's error)", dry, err) |
| + } |
| + } |
| + if got := b.row(); *got != before || b.holds != 0 || b.writes != 0 || !bytes.Equal(b.guardRecord(), record) { |
| + t.Fatalf("an unreadable completion record changed something: row %+v holds %d writes %d", got, b.holds, b.writes) |
| + } |
| + }) |
| +} |
diff --git a/internal/hostbox/reconcile.go b/internal/hostbox/reconcile.go| index 7412a9d3e..e07700293 100644 |
| --- a/internal/hostbox/reconcile.go |
| +++ b/internal/hostbox/reconcile.go |
| @@ -2,6 +2,7 @@ package hostbox |
| import ( |
| "context" |
| + "slices" |
| "time" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| @@ -54,16 +55,57 @@ type Reconciler struct { |
| // 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. |
| +// hold-only path, the completed relocation's catch-up hold included. |
| 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. |
| +// HA on or off: moves owns its holds, in provision's pass. The one |
| +// exception is a serving claim's completed relocation, whose catch-up hold |
| +// (completedHold) is decided apart from this scope. |
| func (r Reconciler) relocates(c *hostclaim.Claim) bool { |
| return r.relocationAware() && c != nil && c.Op.Kind == hostclaim.KindRelocating |
| } |
| +// completedHold is the catch-up hold (L18 joint round, finding 3): the |
| +// source's fence closed in releasing, but its next reconcile ran only once |
| +// the target had completed, so the claim serves on another box and |
| +// rowDecision's scope (relocates) no longer reaches it, and main's Serving |
| +// would refuse with the row left ready, which the edge serves, until the |
| +// park-local retires it. Completion waits on no acknowledgement of the |
| +// source's hold (ruling 80), so reconcile catches up here: the row is held |
| +// for the completed op, a RowRetiring hold, until the park-local retires |
| +// it (Task 10b decision 3: nothing un-holds). |
| +// |
| +// In scope only with region HA on, and only on positive evidence on the |
| +// fresh claim: a serving op on another box (not this one, which serves |
| +// through Serving) that journals a completed relocation (not completed on |
| +// this box) and a park-local this box owes for exactly that op, which |
| +// CompleteRelocation queues for the source and every installed box but |
| +// the target. A box on lost is left to its park-local (decision 3). A |
| +// park-local for another op, or none, is out of scope and main's path |
| +// runs. A completion record that cannot be read is its error, never "no |
| +// completion": the row is kept and nothing is minted. The path is hold |
| +// only (Install, checkOn): no activation, no inventory, no execution. |
| +func (r Reconciler) completedHold(c *hostclaim.Claim) (RowDecision, bool, error) { |
| + if !r.relocationAware() || c == nil || c.Holder == nil || r.Box == "" || c.Op.Kind != hostclaim.KindServing || c.Op.Exec == r.Box || slices.Contains(c.Lost, r.Box) { |
| + return RowDecision{}, false, nil |
| + } |
| + done, ok, err := hostclaim.ReadCompletedRelocation(c) |
| + if err != nil { |
| + return RowDecision{}, false, err |
| + } |
| + if !ok || done.Stop.Box == r.Box { |
| + return RowDecision{}, false, nil |
| + } |
| + for _, o := range c.Obligations { |
| + if o.Kind == "park-local" && o.Box == r.Box && o.Op == done.Op { |
| + return RowDecision{Case: RowRetiring, HoldOp: done.Op}, true, nil |
| + } |
| + } |
| + return RowDecision{}, false, nil |
| +} |
| + |
| func (r Reconciler) fenceClosed(ctx context.Context, obs hostclaim.Observed) (bool, error) { |
| if r.FenceClosed == nil { |
| return false, nil |
| @@ -135,11 +177,24 @@ func (r Reconciler) checkOn(ctx context.Context, obs hostclaim.Observed, selecte |
| return ErrChanged |
| } |
| holdOnly := false |
| - if !r.relocates(obs.Claim) { |
| + caught, catchUp, err := r.completedHold(obs.Claim) |
| + if err != nil { |
| + return err |
| + } |
| + switch { |
| + case catchUp: |
| + // The catch-up hold is hold only, on Install's own decision: the |
| + // activation reader, the inventory callback and any caller |
| + // without that decision are refused before anything is written. |
| + if dec == nil || !dec.holdOnly || dec.row != caught { |
| + return ErrChanged |
| + } |
| + holdOnly = true |
| + case !r.relocates(obs.Claim): |
| if err := Serving(obs.Claim, Selection{Box: r.Box}); err != nil { |
| return err |
| } |
| - } else { |
| + default: |
| d := RowDecision{} |
| if dec != nil { |
| d, holdOnly = dec.row, dec.holdOnly |
| @@ -222,6 +277,11 @@ func (r Reconciler) Install(ctx context.Context, selected RecordSelection, write |
| // 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)} |
| + } else if d, ok, err := r.completedHold(obs.Claim); err != nil { |
| + return err |
| + } else if ok { |
| + // A completed relocation's catch-up hold: always hold only. |
| + dec = &decided{row: d, holdOnly: true} |
| } |
| if err := r.checkOn(ctx, obs, selected, dec); err != nil { |
| return err |
| @@ -278,12 +338,23 @@ func (r Reconciler) holdOnly(ctx context.Context, selected RecordSelection, d Ro |
| if err != nil { |
| return err |
| } |
| - if fresh.Absent || fresh.Claim == nil || fresh.Claim.Holder == nil || fresh.Claim.Holder.Incarnation != selected.Incarnation || !r.relocates(fresh.Claim) { |
| + if fresh.Absent || fresh.Claim == nil || fresh.Claim.Holder == nil || fresh.Claim.Holder.Incarnation != selected.Incarnation { |
| return ErrChanged |
| } |
| - again, _, err := r.rowDecision(ctx, fresh) |
| - if err != nil { |
| + // The decision is remade in whichever scope the fresh read is in: |
| + // the catch-up decision (RowRetiring) and rowDecision's held one |
| + // (RowHeld) never compare equal, so a scope change is ErrChanged. |
| + again, catchUp, err := r.completedHold(fresh.Claim) |
| + switch { |
| + case err != nil: |
| return err |
| + case catchUp: |
| + case r.relocates(fresh.Claim): |
| + if again, _, err = r.rowDecision(ctx, fresh); err != nil { |
| + return err |
| + } |
| + default: |
| + return ErrChanged |
| } |
| if again != d { |
| return ErrChanged |
diff --git a/internal/hostbox/reconcile_relocation_catchup_test.go b/internal/hostbox/reconcile_relocation_catchup_test.go| new file mode 100644 |
| index 000000000..14d381b95 |
| --- /dev/null |
| +++ b/internal/hostbox/reconcile_relocation_catchup_test.go |
| @@ -0,0 +1,251 @@ |
| +//go:build unix |
| + |
| +package hostbox |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "slices" |
| + "strconv" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| +) |
| + |
| +// completeFromInstalling is the target finishing after the source's |
| +// fence closed: its probe passes on "dst", root stops the probe session, |
| +// and the real serving CAS (hostclaim.CompleteRelocation) queues the |
| +// source's park-local. No reconcile runs on the source meanwhile. |
| +func (s *sourceReconcile) completeFromInstalling(t *testing.T) hostclaim.Observed { |
| + t.Helper() |
| + s.update(t, func(c *hostclaim.Claim) error { |
| + h := c.Holder |
| + h.Session, h.Generation, h.WriterBox = 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 = hostclaim.PhaseRelocatingProbing |
| + c.Op.Journal["probe"], c.Op.Journal["probe_stop"] = string(probe), string(stop) |
| + return nil |
| + }) |
| + n := 0 |
| + o := s.update(t, hostclaim.CompleteRelocation("rel-1", func() string { n++; return "id-" + strconv.Itoa(n) })) |
| + if o.Claim.Op.Kind != hostclaim.KindServing || o.Claim.Op.Exec != "dst" || !hostclaim.OwesLocal(o.Claim, "box") { |
| + t.Fatalf("after the completion: op %+v obligations %+v", o.Claim.Op, o.Claim.Obligations) |
| + } |
| + return o |
| +} |
| + |
| +// L18 joint round, finding 3, through the production ReconcileAdapter, |
| +// the real census (CheckHeld), park.Local and provision's pass: the |
| +// source's fence closes in releasing, its reconcile is delayed past |
| +// installing and the target's completion, and only then does it run. The |
| +// claim now serves on "dst", so Task 10b's relocating scope is gone; the |
| +// catch-up hold keeps the row held for the completed op, never ready, |
| +// with its ownership and every other column as minted, the guard record |
| +// and the claim unchanged. A replay changes nothing. |
| +func TestReconcileCatchUpHoldsTheSourceAfterTheTargetCompleted(t *testing.T) { |
| + s := newSourceReconcile(t) |
| + minted := *s.row(t) |
| + s.releasingThenInstalling(t) |
| + o := s.completeFromInstalling(t) |
| + 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" { |
| + t.Fatalf("pass %d after the completion: 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) |
| + } |
| + if after, err := hostclaim.Read(t.Context(), s.f.store, s.f.host); err != nil || after.ETag != o.ETag { |
| + t.Fatalf("pass %d: the claim was written: %v", i, err) |
| + } |
| + } |
| +} |
| + |
| +// The catch-up hold through the pass: a row another op holds is never |
| +// taken, and with region HA off (neither hook set) the pass is main's, |
| +// the row ready and exactly as minted. |
| +func TestReconcileCatchUpHoldGuardsThroughThePass(t *testing.T) { |
| + t.Run("another op's row", func(t *testing.T) { |
| + s := newSourceReconcile(t) |
| + if ok, err := s.reg.SetHoldIf(s.f.host, "moving", "mv-9", ""); err != nil || !ok { |
| + t.Fatalf("holding for another op: %v %v", ok, err) |
| + } |
| + before := *s.row(t) |
| + s.releasingThenInstalling(t) |
| + s.completeFromInstalling(t) |
| + s.pass(t) |
| + if got := s.row(t); *got != before { |
| + t.Fatalf("another op's row was taken: %+v (was %+v)", got, before) |
| + } |
| + }) |
| + t.Run("region HA off", func(t *testing.T) { |
| + s := newSourceReconcile(t) |
| + s.adapter.Reconciler.FenceClosed, s.adapter.Reconciler.HoldRow = nil, nil |
| + s.adapter.Configure(&s.cfg) |
| + minted := *s.row(t) |
| + s.releasingThenInstalling(t) |
| + s.completeFromInstalling(t) |
| + s.pass(t) |
| + if got := s.row(t); *got != minted { |
| + t.Fatalf("region HA off: the row is %+v (was %+v)", got, minted) |
| + } |
| + }) |
| +} |
| + |
| +// completedServing rewrites the fixture's claim as a completed relocation's |
| +// serving op: exec on exec, the completion recorded on stopBox, and the |
| +// park-local obligations given. |
| +func completedServing(t *testing.T, r Reconciler, host, exec, stopBox string, owed ...hostclaim.Obligation) hostclaim.Observed { |
| + t.Helper() |
| + ctx := context.Background() |
| + obs, err := hostclaim.Read(ctx, r.Claims, host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + done, err := json.Marshal(hostclaim.CompletedRelocation{Op: "rel-1", Stop: hostclaim.ProbeStop{Box: stopBox, Session: 7, Receipt: "probe-fence-1", ReceiptSHA256: strings.Repeat("a", 64)}, Phase: hostclaim.PhaseRelocatingProbing}) |
| + 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.KindServing, Exec: exec, Participants: []string{exec}, Journal: map[string]string{hostclaim.JournalCompleted: string(done)}} |
| + c.Obligations = append(c.Obligations, owed...) |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return obs |
| +} |
| + |
| +func parkLocal(box, op string) hostclaim.Obligation { |
| + return hostclaim.Obligation{ID: "ob-" + box + "-" + op, Op: op, Kind: "park-local", Box: box, Retirement: "rt-" + box + "-" + op, Owner: "box:" + box} |
| +} |
| + |
| +// The catch-up hold's scope, guard by guard, on the stubbed reconciler |
| +// (relocationReconciler, its HoldRow recording): held for the completed |
| +// op on positive evidence only, hold only, and nowhere else. |
| +func TestReconcileCatchUpHoldScope(t *testing.T) { |
| + ctx := context.Background() |
| + install := func(r Reconciler, selected RecordSelection, writes *int) error { |
| + return r.Install(ctx, selected, func() error { *writes++; return nil }) |
| + } |
| + t.Run("the source owing the completed op's park-local is held, hold only", func(t *testing.T) { |
| + r, selected, holds, _ := relocationReconciler(t) |
| + obs := completedServing(t, r, selected.Host, "dst", "dst", parkLocal("box", "rel-1")) |
| + writes := 0 |
| + if err := install(r, selected, &writes); err != nil || writes != 0 || !slices.Equal(*holds, []string{"rel-1"}) { |
| + t.Fatalf("the catch-up hold: %v writes %d holds %v", err, writes, *holds) |
| + } |
| + // No installation authority: the activation reader's and the |
| + // inventory callback's check, which carry no Install decision, and |
| + // an activation decision, are all refused. |
| + if err := r.check(ctx, obs, selected); !errors.Is(err, ErrChanged) { |
| + t.Fatalf("the activation path's check under the catch-up: %v", err) |
| + } |
| + d, ok, err := r.completedHold(obs.Claim) |
| + if err != nil || !ok { |
| + t.Fatalf("completedHold: %v %v", ok, err) |
| + } |
| + if err := r.checkOn(ctx, obs, selected, &decided{row: d}); !errors.Is(err, ErrChanged) { |
| + t.Fatalf("an activation decision under the catch-up: %v", err) |
| + } |
| + if err := r.checkOn(ctx, obs, selected, &decided{row: RowDecision{Case: RowHeld, HoldOp: "rel-1"}, holdOnly: true}); !errors.Is(err, ErrChanged) { |
| + t.Fatalf("a hold decision the catch-up did not make: %v", err) |
| + } |
| + }) |
| + for name, tc := range map[string]struct { |
| + exec, stop string |
| + owed []hostclaim.Obligation |
| + lost bool |
| + wantWrites int // main's serving executor writes through activation |
| + }{ |
| + "this box serves": {exec: "box", stop: "dst", owed: []hostclaim.Obligation{parkLocal("box", "rel-1")}, wantWrites: 1}, |
| + "completed on this box": {exec: "dst", stop: "box", owed: []hostclaim.Obligation{parkLocal("box", "rel-1")}}, |
| + "no park-local for this box": {exec: "dst", stop: "dst", owed: []hostclaim.Obligation{parkLocal("other", "rel-1")}}, |
| + "a park-local for another op": {exec: "dst", stop: "dst", owed: []hostclaim.Obligation{parkLocal("box", "rel-0")}}, |
| + "this box on lost": {exec: "dst", stop: "dst", owed: []hostclaim.Obligation{parkLocal("box", "rel-1")}, lost: true}, |
| + "another kind of obligation for box": {exec: "dst", stop: "dst", owed: []hostclaim.Obligation{{ID: "ob-1", Op: "rel-1", Kind: "relocation-release", Box: "box", Region: "ie", Retirement: "rt-1", Owner: "region:ie"}}}, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + r, selected, holds, _ := relocationReconciler(t) |
| + completedServing(t, r, selected.Host, tc.exec, tc.stop, tc.owed...) |
| + if tc.lost { |
| + obs, err := hostclaim.Read(ctx, r.Claims, selected.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := hostclaim.Update(ctx, r.Claims, obs, func(c *hostclaim.Claim) error { c.Lost = append(c.Lost, "box"); return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + writes := 0 |
| + err := install(r, selected, &writes) |
| + if len(*holds) != 0 || writes != tc.wantWrites { |
| + t.Fatalf("%v: holds %v writes %d (want %d)", err, *holds, writes, tc.wantWrites) |
| + } |
| + if tc.wantWrites == 0 && !errors.Is(err, ErrChanged) { |
| + t.Fatalf("main's Serving refusal: %v", err) |
| + } |
| + }) |
| + } |
| + t.Run("region HA off", func(t *testing.T) { |
| + r, selected, holds, _ := relocationReconciler(t) |
| + r.FenceClosed, r.HoldRow = nil, nil |
| + completedServing(t, r, selected.Host, "dst", "dst", parkLocal("box", "rel-1")) |
| + writes := 0 |
| + if err := install(r, selected, &writes); !errors.Is(err, ErrChanged) || writes != 0 || len(*holds) != 0 { |
| + t.Fatalf("region HA off: %v writes %d holds %v", err, writes, *holds) |
| + } |
| + }) |
| + t.Run("the decision is remade under the lock", func(t *testing.T) { |
| + r, selected, _, _ := relocationReconciler(t) |
| + completedServing(t, r, selected.Host, "dst", "dst", parkLocal("box", "rel-1")) |
| + obs, err := hostclaim.Read(ctx, r.Claims, selected.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + d, ok, err := r.completedHold(obs.Claim) |
| + if err != nil || !ok { |
| + t.Fatalf("completedHold: %v %v", ok, err) |
| + } |
| + // The park-local is discharged between the decision and the lock. |
| + if _, err := hostclaim.Update(ctx, r.Claims, obs, func(c *hostclaim.Claim) error { c.Obligations = nil; return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := r.holdOnly(ctx, selected, d); !errors.Is(err, ErrChanged) { |
| + t.Fatalf("a catch-up decision the fresh read no longer makes: %v", err) |
| + } |
| + }) |
| +} |
| + |
| +// RegistryHoldRow's guards hold for the catch-up as they do for Task 10b: |
| +// a held row for the completed op is a replay with nothing written. |
| +func TestReconcileCatchUpHoldReplayWritesNothing(t *testing.T) { |
| + r, selected, reg, _ := sourceRows(t) |
| + completedServing(t, r, selected.Host, "dst", "dst", parkLocal("box", "rel-1")) |
| + if err := r.Install(context.Background(), selected, func() error { return errors.New("activated") }); err != nil { |
| + t.Fatalf("the catch-up hold: %v", err) |
| + } |
| + held := *rowOf(t, reg, selected.Host) |
| + if held.Status != "relocating" || held.HoldOp != "rel-1" { |
| + t.Fatalf("held row %+v", held) |
| + } |
| + if err := r.Install(context.Background(), selected, func() error { return errors.New("activated") }); err != nil { |
| + t.Fatalf("the replay: %v", err) |
| + } |
| + if got := rowOf(t, reg, selected.Host); *got != held { |
| + t.Fatalf("the replay changed the row: %+v (was %+v)", got, held) |
| + } |
| +} |
diff --git a/internal/hostbox/relocationsource.go b/internal/hostbox/relocationsource.go| index 6794d9434..1afa278bc 100644 |
| --- a/internal/hostbox/relocationsource.go |
| +++ b/internal/hostbox/relocationsource.go |
| @@ -35,7 +35,8 @@ func AdmissionFenceClosed(g hostguard.Guard) func(context.Context, hostclaim.Obs |
| } |
| } |
| -// RegistryHoldRow is Reconciler.HoldRow over this box's registry (R5-3): |
| +// RegistryHoldRow is Reconciler.HoldRow over this box's registry (R5-3, |
| +// and the completed relocation's catch-up hold, L18 finding 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 |