region ha: a delete or purge that verifies the completion box's termination fence passes it to the completion barrier as its exempt box (ruling 86).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
27 files changed,
+397
−42
internal/console/testdata/claim-authority-sites.txt+1 −1internal/host/claimparticipation_stale_test.go+1 −1internal/host/claimretirerecovery_test.go+1 −1internal/host/movesabort_test.go+1 −1internal/host/movesparks_test.go+1 −1internal/host/plan12_census_test.go+1 −1internal/host/relocationfinish_unix_test.go+1 −1internal/host/relocationfirstwake_unix_test.go+1 −1internal/hostbox/work_test.go+1 −1internal/hostclaim/adopt_test.go+3 −3internal/hostclaim/create_test.go+1 −1internal/hostclaim/delete.go+5 −2internal/hostclaim/delete_test.go+3 −3internal/hostclaim/generation_test.go+2 −2internal/hostclaim/move.go+2 −2internal/hostclaim/ops.go+8 −3internal/hostclaim/ops_test.go+4 −4internal/hostclaim/relocation_probestop_test.go+3 −3internal/hostclaim/rev713_test.go+1 −1internal/hostops/completion_fence.go+65 −0internal/hostops/completion_fence_test.go+266 −0internal/hostops/operations.go+1 −1internal/hostops/operations_test.go+2 −2internal/hostops/production_delete.go+6 −1internal/hostops/production_held_start.go+8 −1internal/hostops/purge.go+7 −3internal/hostops/purge_retirement_test.go+1 −1
CI cancelled — superseded by a newer push — run details
diff --git a/internal/console/testdata/claim-authority-sites.txt b/internal/console/testdata/claim-authority-sites.txt| index 9e1fbf855..81c14d677 100644 |
| --- a/internal/console/testdata/claim-authority-sites.txt |
| +++ b/internal/console/testdata/claim-authority-sites.txt |
| @@ -21,7 +21,7 @@ |
| ../hostops/move_recovery_grant.go|ResolveStart|runner.resolveUncertain(ctx, e, result) |
| ../hostops/move_start_resolve.go|ResolveMoveStart|h.Runner(h.w.now).resolveUncertain(ctx, e, result) |
| ../hostops/mutation_boundary.go|NewMutationBoundary|NewObservedMutationBoundary(enabled, raw, check, nil) |
| -../hostops/production_held_start.go|startAppLifecycle|BeginPurge(id, by, a.Now(), regions, boxes) |
| +../hostops/production_held_start.go|startAppLifecycle|BeginPurge(id, by, a.Now(), regions, boxes, fenced) |
| ../hostops/purge_control.go|DrainPurgeRegistrations|w.SavePurgeJournal(ctx, j, tag) |
| ../hostops/purge_control.go|DrainPurgeRegistrations|w.SavePurgeJournal(ctx, j, tag) |
| ../hostops/purge_control.go|DrainPurgeRegistrations|w.SavePurgeJournal(ctx, j, tag) |
diff --git a/internal/host/claimparticipation_stale_test.go b/internal/host/claimparticipation_stale_test.go| index 97c142c10..452b1c16f 100644 |
| --- a/internal/host/claimparticipation_stale_test.go |
| +++ b/internal/host/claimparticipation_stale_test.go |
| @@ -32,7 +32,7 @@ func TestPlan9PreviousAgentRetiredParticipation(t *testing.T) { |
| if mode == "launch" { |
| f.controller("boot-old", "controller-old", 901, "101").admit(t, f) |
| } |
| - o, err := hostclaim.Update(ctx, f.claims, f.read(), hostclaim.BeginDelete("delete", "console", "record-etag", f.now, nil, nil)) |
| + o, err := hostclaim.Update(ctx, f.claims, f.read(), hostclaim.BeginDelete("delete", "console", "record-etag", f.now, nil, nil, "")) |
| if err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/host/claimretirerecovery_test.go b/internal/host/claimretirerecovery_test.go| index 56dc467bb..081b76e29 100644 |
| --- a/internal/host/claimretirerecovery_test.go |
| +++ b/internal/host/claimretirerecovery_test.go |
| @@ -21,7 +21,7 @@ func retirementRecoveryRow(t *testing.T, f *recoveryFixture) registry.Route { |
| t.Helper() |
| o := f.read() |
| var err error |
| - o, err = hostclaim.Update(t.Context(), f.claims, o, hostclaim.BeginDelete("delete-1", "console", `"record-etag"`, f.now, nil, nil)) |
| + o, err = hostclaim.Update(t.Context(), f.claims, o, hostclaim.BeginDelete("delete-1", "console", `"record-etag"`, f.now, nil, nil, "")) |
| if err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/host/movesabort_test.go b/internal/host/movesabort_test.go| index 871feb728..b2c47db75 100644 |
| --- a/internal/host/movesabort_test.go |
| +++ b/internal/host/movesabort_test.go |
| @@ -354,7 +354,7 @@ func TestAbortWaitsForAnUnreachableTargetRegion(t *testing.T) { |
| } |
| } |
| c := h.claim(t) |
| - if err := hostclaim.StartGuard(c, []string{"ie"}, []string{"ie-box"}); err == nil { |
| + if err := hostclaim.StartGuard(c, []string{"ie"}, []string{"ie-box"}, ""); err == nil { |
| t.Fatal("a new operation could start over the waiting abort") |
| } |
| // An unarmed target box parks nothing either: the abort's lane park goes |
diff --git a/internal/host/movesparks_test.go b/internal/host/movesparks_test.go| index 3f2051ac1..7eb9ff6de 100644 |
| --- a/internal/host/movesparks_test.go |
| +++ b/internal/host/movesparks_test.go |
| @@ -248,7 +248,7 @@ func TestCloseIsRecordFirst(t *testing.T) { |
| if c.Op.Kind != hostclaim.KindLanding || c.Op.Phase != hostclaim.PhaseCommitted || c.Op.ID != p1ID { |
| t.Fatalf("the claim at the crash point: %+v", c.Op) |
| } |
| - if err := hostclaim.StartGuard(c, []string{"uk"}, []string{"uk-box"}); err == nil { |
| + if err := hostclaim.StartGuard(c, []string{"uk"}, []string{"uk-box"}, ""); err == nil { |
| t.Fatal("a new operation could start over the unreleased claim") |
| } |
| gen := c.Gen |
diff --git a/internal/host/plan12_census_test.go b/internal/host/plan12_census_test.go| index ec06b09d1..dcc769be8 100644 |
| --- a/internal/host/plan12_census_test.go |
| +++ b/internal/host/plan12_census_test.go |
| @@ -19,7 +19,7 @@ func plan12Retired(t *testing.T) hostclaim.Observed { |
| o, err = hostclaim.Update(t.Context(), f.claims, o, func(c *hostclaim.Claim) error { |
| c.Holder.Location = "instances/" + o.Host |
| c.Touched = hostclaim.Touched{Complete: true} |
| - return hostclaim.BeginDelete("delete", "console", "record", f.now, nil, nil)(c) |
| + return hostclaim.BeginDelete("delete", "console", "record", f.now, nil, nil, "")(c) |
| }) |
| if err != nil { |
| t.Fatal(err) |
diff --git a/internal/host/relocationfinish_unix_test.go b/internal/host/relocationfinish_unix_test.go| index 93b550ab2..0943ab79a 100644 |
| --- a/internal/host/relocationfinish_unix_test.go |
| +++ b/internal/host/relocationfinish_unix_test.go |
| @@ -128,7 +128,7 @@ func (w *finishWorld) marked() (bool, error) { |
| if err != nil { |
| w.t.Fatal(err) |
| } |
| - return ok && done.Discharged, hostclaim.StartGuard(obs.Claim, []string{"uk"}, []string{"spa"}) |
| + return ok && done.Discharged, hostclaim.StartGuard(obs.Claim, []string{"uk"}, []string{"spa"}, "") |
| } |
| // done asserts the finished state: discharged as many times in all as |
diff --git a/internal/host/relocationfirstwake_unix_test.go b/internal/host/relocationfirstwake_unix_test.go| index f81f377d7..ef675b09f 100644 |
| --- a/internal/host/relocationfirstwake_unix_test.go |
| +++ b/internal/host/relocationfirstwake_unix_test.go |
| @@ -132,7 +132,7 @@ func TestCompletionPassThenFirstServingWakeWithRegionHAOff(t *testing.T) { |
| if act.svc.Fenced(recoveryHost) { |
| t.Fatal("the probe's owned fence was not lifted") |
| } |
| - if err := hostclaim.StartGuard(prior.Claim, []string{"uk"}, []string{"spa"}); err != nil { |
| + if err := hostclaim.StartGuard(prior.Claim, []string{"uk"}, []string{"spa"}, ""); err != nil { |
| t.Fatalf("the barrier did not open: %v", err) |
| } |
diff --git a/internal/hostbox/work_test.go b/internal/hostbox/work_test.go| index cf3fa7cc4..080a1078c 100644 |
| --- a/internal/hostbox/work_test.go |
| +++ b/internal/hostbox/work_test.go |
| @@ -74,7 +74,7 @@ func TestSelectedWorkRejectsChangesAndNeverRecaptures(t *testing.T) { |
| } |
| switch mode { |
| case "phase": |
| - _, err = hostclaim.Update(ctx, store, obs, hostclaim.BeginDelete("delete", "test", "etag", time.Unix(1, 0), []string{"ie"}, []string{"box"})) |
| + _, err = hostclaim.Update(ctx, store, obs, hostclaim.BeginDelete("delete", "test", "etag", time.Unix(1, 0), []string{"ie"}, []string{"box"}, "")) |
| case "executor": |
| _, err = hostclaim.Update(ctx, store, obs, func(c *hostclaim.Claim) error { c.Op.Exec = "other"; c.Op.Participants = []string{"other"}; return nil }) |
| case "round-trip": |
diff --git a/internal/hostclaim/adopt_test.go b/internal/hostclaim/adopt_test.go| index 0ff95d3af..bcf740114 100644 |
| --- a/internal/hostclaim/adopt_test.go |
| +++ b/internal/hostclaim/adopt_test.go |
| @@ -31,7 +31,7 @@ func TestAdoptAndDiscoveryCompletion(t *testing.T) { |
| t.Fatalf("adopt = %+v", c) |
| } |
| // A delete is refused while discovery chores are pending in its write set (CC11). |
| - if _, err := Update(ctx, s, o, BeginDelete("op_d", "console", `"e"`, time.Now(), []string{"ie", "uk"}, []string{"adi-knitting", "b2"})); !errors.Is(err, ErrPendingChore) { |
| + if _, err := Update(ctx, s, o, BeginDelete("op_d", "console", `"e"`, time.Now(), []string{"ie", "uk"}, []string{"adi-knitting", "b2"}, "")); !errors.Is(err, ErrPendingChore) { |
| t.Fatalf("delete during discovery = %v", err) |
| } |
| for _, ob := range c.Obligations { |
| @@ -45,7 +45,7 @@ func TestAdoptAndDiscoveryCompletion(t *testing.T) { |
| if _, err := Update(ctx, s, o, Adopt(nh, "adi-knitting", nil, nil, "op_adopt2", counter())); !errors.Is(err, ErrWrongState) { |
| t.Fatalf("re-adopt = %v", err) |
| } |
| - o, err = Update(ctx, s, o, BeginDelete("op_d", "console", `"e"`, time.Now(), []string{"ie", "uk"}, []string{"adi-knitting", "b2"})) |
| + o, err = Update(ctx, s, o, BeginDelete("op_d", "console", `"e"`, time.Now(), []string{"ie", "uk"}, []string{"adi-knitting", "b2"}, "")) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -92,7 +92,7 @@ func TestAdoptWithoutDiscoveryTargets(t *testing.T) { |
| if !reflect.DeepEqual(o.Claim.Touched, want) || len(o.Claim.Obligations) != 0 { |
| t.Fatalf("adopt without discovery targets = %+v", o.Claim) |
| } |
| - o, err = Update(ctx, s, o, BeginDelete("op_d", "console", `"e"`, time.Now(), nil, nil)) |
| + o, err = Update(ctx, s, o, BeginDelete("op_d", "console", `"e"`, time.Now(), nil, nil, "")) |
| if err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/hostclaim/create_test.go b/internal/hostclaim/create_test.go| index e12a3bdaf..1d308ab91 100644 |
| --- a/internal/hostclaim/create_test.go |
| +++ b/internal/hostclaim/create_test.go |
| @@ -190,7 +190,7 @@ func TestCreateHomeRegionParksEffectiveRegion(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - o, err = Update(ctx, s, o, BeginDelete("op_d", "console", `"e"`, time.Now(), nil, nil)) |
| + o, err = Update(ctx, s, o, BeginDelete("op_d", "console", `"e"`, time.Now(), nil, nil, "")) |
| if err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/hostclaim/delete.go b/internal/hostclaim/delete.go| index b0a3e9625..e5757972f 100644 |
| --- a/internal/hostclaim/delete.go |
| +++ b/internal/hostclaim/delete.go |
| @@ -28,10 +28,13 @@ func union(known, supplied []string) []string { |
| return result |
| } |
| -func BeginDelete(opID, by, recordETag string, now time.Time, allRegions, allBoxes []string) func(*Claim) error { |
| +// BeginDelete starts a delete from serving. fencedCompletion is |
| +// StartGuard's (ruling 86): the completion box whose termination fence the |
| +// caller verified, or "". |
| +func BeginDelete(opID, by, recordETag string, now time.Time, allRegions, allBoxes []string, fencedCompletion string) func(*Claim) error { |
| return func(c *Claim) error { |
| regions, boxes := WriteSet(c, allRegions, allBoxes) |
| - if err := StartGuard(c, regions, boxes); err != nil { |
| + if err := StartGuard(c, regions, boxes, fencedCompletion); err != nil { |
| return err |
| } |
| if opID == "" || recordETag == "" { |
diff --git a/internal/hostclaim/delete_test.go b/internal/hostclaim/delete_test.go| index a16192032..a86a345d0 100644 |
| --- a/internal/hostclaim/delete_test.go |
| +++ b/internal/hostclaim/delete_test.go |
| @@ -39,7 +39,7 @@ func TestDeleteParkFree(t *testing.T) { |
| now := time.Date(2026, 10, 4, 2, 0, 0, 0, time.UTC) |
| o := servingClaim(t, ctx, s) |
| inc, stamp := o.Claim.Holder.Incarnation, o.Claim.Holder.Stamp |
| - o, err := Update(ctx, s, o, BeginDelete("op_d1", "console", `"etag1"`, now, nil, nil)) |
| + o, err := Update(ctx, s, o, BeginDelete("op_d1", "console", `"etag1"`, now, nil, nil, "")) |
| if err != nil || o.Claim.Op.Kind != KindDeleting || o.Claim.Op.Intent["etag"] != `"etag1"` { |
| t.Fatalf("begin delete = %+v, %v", o.Claim, err) |
| } |
| @@ -77,7 +77,7 @@ func TestIncompleteTouchedParksEverywhere(t *testing.T) { |
| t.Fatal(err) |
| } |
| regions, boxes := []string{"ie", "uk", "us"}, []string{"b1", "b2"} |
| - o, err = Update(ctx, s, o, BeginDelete("op_d1", "console", `"e"`, now, regions, boxes)) |
| + o, err = Update(ctx, s, o, BeginDelete("op_d1", "console", `"e"`, now, regions, boxes, "")) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -170,7 +170,7 @@ func TestIncompleteAdoptionPreservesParkingTargets(t *testing.T) { |
| t.Fatal(err) |
| } |
| regions := []string{"ie"} |
| - o, err = Update(ctx, s, o, BeginDelete("op_d", "console", `"e"`, time.Now(), regions, nil)) |
| + o, err = Update(ctx, s, o, BeginDelete("op_d", "console", `"e"`, time.Now(), regions, nil, "")) |
| if err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/hostclaim/generation_test.go b/internal/hostclaim/generation_test.go| index 81994ce8c..aa3c396b9 100644 |
| --- a/internal/hostclaim/generation_test.go |
| +++ b/internal/hostclaim/generation_test.go |
| @@ -249,7 +249,7 @@ func TestAdmitWriterBoundToOperationAndPhase(t *testing.T) { |
| stale := AdmitWriter(Admission{Region: "ie", PredecessorRegion: "ie", Box: "adi-knitting", Mode: "cold", NewGeneration: "gen_a"}) |
| var err error |
| if change == "delete" { |
| - o, err = Update(ctx, s, o, BeginDelete("d1", "console", "etag", time.Now(), nil, nil)) |
| + o, err = Update(ctx, s, o, BeginDelete("d1", "console", "etag", time.Now(), nil, nil, "")) |
| } else { |
| o, err = Update(ctx, s, o, func(c *Claim) error { |
| if change == "operation" { |
| @@ -302,7 +302,7 @@ func TestAdmitWriterSurvivesOperationReplacement(t *testing.T) { |
| t.Fatalf("admitted holder = %+v", writer) |
| } |
| if transition == "delete" { |
| - o, err = Update(ctx, s, o, BeginDelete("d1", "console", "etag", time.Now(), nil, nil)) |
| + o, err = Update(ctx, s, o, BeginDelete("d1", "console", "etag", time.Now(), nil, nil, "")) |
| } else { |
| o, err = Update(ctx, s, o, func(c *Claim) error { |
| c.Op = Op{Kind: KindMoving, ID: "m1", Phase: "probe", Exec: "boxT"} |
diff --git a/internal/hostclaim/move.go b/internal/hostclaim/move.go| index c8b86380b..ce06845ea 100644 |
| --- a/internal/hostclaim/move.go |
| +++ b/internal/hostclaim/move.go |
| @@ -46,7 +46,7 @@ const journalReleasedMove = "released_move" |
| // found. |
| func BeginMove(opID, by, sourceBox, targetBox, targetRegion string, now time.Time) func(*Claim) error { |
| return func(c *Claim) error { |
| - if err := StartGuard(c, []string{targetRegion}, []string{targetBox}); err != nil { |
| + if err := StartGuard(c, []string{targetRegion}, []string{targetBox}, ""); err != nil { |
| return err |
| } |
| if opID == "" || sourceBox == "" || targetBox == "" || targetRegion == "" { |
| @@ -90,7 +90,7 @@ func BeginAdmittedMove(opID, by, sourceBox, targetBox, targetRegion string, now |
| if regionname.SameRegion(a.HomeRegion, targetRegion, regionname.HomeRegion) { |
| regions = append(regions, a.HomeRegion, regionname.HomeRegion) |
| } |
| - if err := StartGuard(c, regions, []string{targetBox}); err != nil { |
| + if err := StartGuard(c, regions, []string{targetBox}, ""); err != nil { |
| return err |
| } |
| if err := BeginMove(opID, by, sourceBox, targetBox, targetRegion, now)(c); err != nil { |
diff --git a/internal/hostclaim/ops.go b/internal/hostclaim/ops.go| index d645eb840..62a6a921f 100644 |
| --- a/internal/hostclaim/ops.go |
| +++ b/internal/hostclaim/ops.go |
| @@ -29,12 +29,17 @@ func RequireOp(id string) func(*Claim) error { |
| // StartGuard is §4: one operation at a time, started from serving, and |
| // CC11: never over a chore in a region or box the operation will write. |
| // Region HA N13 (ruling 77): nor while a completed relocation's box has |
| -// not discharged its participation (completionBarrier). |
| -func StartGuard(c *Claim, regions, boxes []string) error { |
| +// not discharged its participation (completionBarrier). fencedCompletion |
| +// is completionBarrier's fencedSource (ruling 86): a box whose termination |
| +// fence the caller has verified (VerifyFence, under a FenceAuthority) for |
| +// this start, as a failover's start passes its source. "" when there is |
| +// no verified fence: never a box the caller has not verified, and never |
| +// one inferred from a missing fence. |
| +func StartGuard(c *Claim, regions, boxes []string, fencedCompletion string) error { |
| if c.Holder == nil || c.Op.Kind != KindServing { |
| return fmt.Errorf("%w: kind %q", ErrWrongState, c.Op.Kind) |
| } |
| - if err := completionBarrier(c, ""); err != nil { |
| + if err := completionBarrier(c, fencedCompletion); err != nil { |
| return err |
| } |
| inR, inB := set(regions), set(boxes) |
diff --git a/internal/hostclaim/ops_test.go b/internal/hostclaim/ops_test.go| index 53fbcea17..adedf0077 100644 |
| --- a/internal/hostclaim/ops_test.go |
| +++ b/internal/hostclaim/ops_test.go |
| @@ -20,14 +20,14 @@ func TestRequireOpRefusesStale(t *testing.T) { |
| func TestStartGuard(t *testing.T) { |
| h := &Holder{Incarnation: "i"} |
| - if err := StartGuard(&Claim{Holder: h, Op: Op{Kind: KindDeleting, ID: "x"}}, nil, nil); !errors.Is(err, ErrWrongState) { |
| + if err := StartGuard(&Claim{Holder: h, Op: Op{Kind: KindDeleting, ID: "x"}}, nil, nil, ""); !errors.Is(err, ErrWrongState) { |
| t.Fatalf("start over a running op = %v", err) |
| } |
| busy := &Claim{Holder: h, Op: Op{Kind: KindServing}, Obligations: []Obligation{{Kind: "park-replica", Region: "uk"}}} |
| - if err := StartGuard(busy, []string{"uk"}, nil); !errors.Is(err, ErrPendingChore) { |
| + if err := StartGuard(busy, []string{"uk"}, nil, ""); !errors.Is(err, ErrPendingChore) { |
| t.Fatalf("start over a pending chore = %v", err) |
| } |
| - if err := StartGuard(busy, []string{"ie"}, nil); err != nil { |
| + if err := StartGuard(busy, []string{"ie"}, nil, ""); err != nil { |
| t.Fatalf("start elsewhere = %v", err) |
| } |
| } |
| @@ -45,7 +45,7 @@ func TestStartGuardLocalBox(t *testing.T) { |
| {name: "no boxes"}, |
| } { |
| t.Run(tc.name, func(t *testing.T) { |
| - if err := StartGuard(busy, nil, tc.boxes); !errors.Is(err, tc.want) { |
| + if err := StartGuard(busy, nil, tc.boxes, ""); !errors.Is(err, tc.want) { |
| t.Fatalf("start with boxes %v = %v, want %v", tc.boxes, err, tc.want) |
| } |
| }) |
diff --git a/internal/hostclaim/relocation_probestop_test.go b/internal/hostclaim/relocation_probestop_test.go| index 04940d2f5..a89ba6fda 100644 |
| --- a/internal/hostclaim/relocation_probestop_test.go |
| +++ b/internal/hostclaim/relocation_probestop_test.go |
| @@ -245,7 +245,7 @@ func TestMoveSuccessorWaitsForTheCompletedDischarge(t *testing.T) { |
| if _, err := Update(ctx, s, o, move); !errors.Is(err, ErrPendingChore) || !errors.Is(err, ErrRelocationFinishing) { |
| t.Fatalf("a move started before B's discharge: %v", err) |
| } |
| - if _, err := Update(ctx, s, o, BeginDelete("del-1", "console", "etag-1", relObserved.Add(time.Hour), []string{"ie"}, []string{relTarget})); !errors.Is(err, ErrPendingChore) { |
| + if _, err := Update(ctx, s, o, BeginDelete("del-1", "console", "etag-1", relObserved.Add(time.Hour), []string{"ie"}, []string{relTarget}, "")); !errors.Is(err, ErrPendingChore) { |
| t.Fatalf("a delete started before B's discharge: %v", err) |
| } |
| if now, err := Read(ctx, s, o.Claim.Host); err != nil || now.ETag != o.ETag { |
| @@ -253,7 +253,7 @@ func TestMoveSuccessorWaitsForTheCompletedDischarge(t *testing.T) { |
| } |
| lost := copyOf(t, o.Claim) |
| lost.Lost = append(lost.Lost, relTarget) |
| - if err := StartGuard(lost, []string{"uk"}, []string{relSpare}); err != nil { |
| + if err := StartGuard(lost, []string{"uk"}, []string{relSpare}, ""); err != nil { |
| t.Fatalf("a lost completion box still holds the barrier: %v", err) |
| } |
| o = step(t, s, o, MarkCompletedDischarged("rel-1")) |
| @@ -298,7 +298,7 @@ func TestAnUnreadableCompletedRelocationRefusesEveryStart(t *testing.T) { |
| if _, err := StartRelocation(ctx, s, o, failover, relAuthority{}); !errors.Is(err, ErrRelocation) { |
| t.Fatalf("a failover started over an unreadable completion: %v", err) |
| } |
| - if err := StartGuard(copyOf(t, o.Claim), []string{"uk"}, []string{relSpare}); !errors.Is(err, ErrRelocation) { |
| + if err := StartGuard(copyOf(t, o.Claim), []string{"uk"}, []string{relSpare}, ""); !errors.Is(err, ErrRelocation) { |
| t.Fatalf("StartGuard: %v", err) |
| } |
| if err := MarkCompletedDischarged("rel-1")(copyOf(t, o.Claim)); !errors.Is(err, ErrRelocation) { |
diff --git a/internal/hostclaim/rev713_test.go b/internal/hostclaim/rev713_test.go| index 481064dd4..235654f11 100644 |
| --- a/internal/hostclaim/rev713_test.go |
| +++ b/internal/hostclaim/rev713_test.go |
| @@ -368,7 +368,7 @@ func TestAdoptLostTargetReturnDeclaresLegacySource(t *testing.T) { |
| t.Fatalf("legacy cut = %+v", cut) |
| } |
| o = updateGenerationTest(t, ctx, s, o, func(c *Claim) error { |
| - if err := StartGuard(c, []string{"ie", "uk"}, []string{"boxS", "boxT"}); err != nil { |
| + if err := StartGuard(c, []string{"ie", "uk"}, []string{"boxS", "boxT"}, ""); err != nil { |
| return err |
| } |
| c.Op = Op{Kind: KindMoving, ID: "move", Phase: "cut", Exec: "boxS", Participants: []string{"boxS", "boxT"}, Journal: map[string]string{"src_covered": "false"}} |
diff --git a/internal/hostops/completion_fence.go b/internal/hostops/completion_fence.go| new file mode 100644 |
| index 000000000..46cc99aea |
| --- /dev/null |
| +++ b/internal/hostops/completion_fence.go |
| @@ -0,0 +1,65 @@ |
| +package hostops |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "errors" |
| + "os" |
| + "slices" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostfence" |
| +) |
| + |
| +// completionFence is ruling 86: the box a serving claim's undischarged |
| +// completed relocation names, when that box's termination fence verifies, |
| +// for a delete or purge to pass to the completion barrier as its exempt |
| +// box (hostclaim.StartGuard's fencedCompletion), as a failover passes its |
| +// fenced source. A dead box's participation slot binds nothing on another |
| +// box; without the exemption a purge that has closed the app's admission |
| +// waits on a failover that admission refuses (the two-box model, seed |
| +// 2224). |
| +// |
| +// The fence is verified as the failover's start verifies it: the record |
| +// at the box's current roster enrollment, by its exact digest, through |
| +// hostclaim.VerifyFence under the claim fence authority, and bound to the |
| +// box (VerifyFenceForBox). Only that evidence exempts. "" with no error |
| +// when there is nothing to exempt (no completion, one already discharged, |
| +// or its box already on lost, which the barrier exempts itself) or no |
| +// fence to verify (a box not in the roster, or no fence record): the |
| +// barrier then refuses as before, since absence is never evidence. A fence |
| +// that cannot be read, or does not verify, is its error and the start is |
| +// refused. A completion record that cannot be read is left to the |
| +// barrier, which refuses over it. |
| +func (a ProductionAuthority) completionFence(ctx context.Context, c *hostclaim.Claim) (string, error) { |
| + done, ok, err := hostclaim.ReadCompletedRelocation(c) |
| + if err != nil || !ok || done.Discharged || slices.Contains(c.Lost, done.Stop.Box) { |
| + return "", nil |
| + } |
| + if a.Store == nil || a.Now == nil { |
| + return "", ErrContract |
| + } |
| + o, err := a.Directory.Read(ctx) |
| + if err != nil { |
| + return "", err |
| + } |
| + b, ok := o.Roster.Boxes[done.Stop.Box] |
| + if !ok { |
| + return "", nil |
| + } |
| + key := hostfence.FenceKey(b.Enrollment) |
| + raw, _, err := a.Store.Get(ctx, key) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return "", nil |
| + } |
| + if err != nil { |
| + return "", err |
| + } |
| + sum := sha256.Sum256(raw) |
| + ref := hostclaim.FenceRef{Enrollment: b.Enrollment, Key: key, SHA256: hex.EncodeToString(sum[:])} |
| + if err := hostclaim.VerifyFenceForBox(ctx, a.Store, hostfence.ClaimFenceAuthority{Directory: a.Directory, Now: a.Now}, ref, done.Stop.Box); err != nil { |
| + return "", err |
| + } |
| + return done.Stop.Box, nil |
| +} |
diff --git a/internal/hostops/completion_fence_test.go b/internal/hostops/completion_fence_test.go| new file mode 100644 |
| index 000000000..f1dde18ec |
| --- /dev/null |
| +++ b/internal/hostops/completion_fence_test.go |
| @@ -0,0 +1,266 @@ |
| +package hostops |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostactors" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/termfence" |
| +) |
| + |
| +const completionHost = "shop.example" |
| + |
| +// completionWorld is ruling 86's schedule (the two-box model, seed 2224): |
| +// a relocation completed on box-a, which is terminated before it |
| +// discharges, so the serving op's completed relocation stays undischarged |
| +// and box-a is not on lost. The signed roster holds box-a and the worker |
| +// box-w that writes box-a's fence; the authority is production's |
| +// (ProductionAuthority over that roster, ClaimFenceAuthority). |
| +type completionWorld struct { |
| + s release.Store |
| + a ProductionAuthority |
| +} |
| + |
| +func newCompletionWorld(t *testing.T, change func(*hostclaim.Claim)) *completionWorld { |
| + t.Helper() |
| + ctx := context.Background() |
| + x, s, _, _ := chooserFixture(t, "box-a", "box-w") |
| + w := &completionWorld{s: s, a: ProductionAuthority{Store: s, Directory: x.Directory, Namespace: "platform", Now: x.Now}} |
| + obs, err := hostclaim.Read(ctx, s, completionHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + obs, err = hostclaim.Update(ctx, s, obs, hostclaim.Adopt(hostclaim.NewHolder{Family: "instance", Key: "apps/acct/app/instances/" + completionHost + ".json", Account: "acct", App: "app", Host: completionHost, Region: "ie"}, "box-a", nil, nil, "adopt", func() string { return "id" })) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + done, err := json.Marshal(hostclaim.CompletedRelocation{Op: "rel-1", Stop: hostclaim.ProbeStop{Box: "box-a", Session: 7, Receipt: "probe-fence-1", ReceiptSHA256: strings.Repeat("a", 64)}, Phase: hostclaim.PhaseRelocatingProbing}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err = hostclaim.Update(ctx, s, obs, func(c *hostclaim.Claim) error { |
| + c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: "box-a", Participants: []string{"box-a"}, Journal: map[string]string{hostclaim.JournalCompleted: string(done)}} |
| + if change != nil { |
| + change(c) |
| + } |
| + return nil |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + return w |
| +} |
| + |
| +// fence writes raw at box-a's fence key (its roster enrollment). |
| +func (w *completionWorld) fence(t *testing.T, raw []byte) { |
| + t.Helper() |
| + if _, err := w.s.Put(context.Background(), "control/box-fences/"+strings.Repeat("a", 32)+".json", raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// terminated is box-w's termination fence of box-a, valid under the |
| +// roster; edit changes it before it is written. |
| +func (w *completionWorld) terminated(t *testing.T, edit func(*termfence.Record)) { |
| + t.Helper() |
| + f := termfence.Record{V: 1, Box: "box-a", Enrollment: strings.Repeat("a", 32), State: "terminated", |
| + Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "eu-west-1", InstanceID: "i-box-a"}, |
| + ObservedAt: "2026-10-07T08:00:00Z", Via: termfence.ViaDescribe, RequestID: "req", |
| + WrittenBy: termfence.WrittenBy{Box: "box-w", Enrollment: strings.Repeat("b", 32)}} |
| + if edit != nil { |
| + edit(&f) |
| + } |
| + raw, err := json.Marshal(f) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + w.fence(t, raw) |
| +} |
| + |
| +// purge is the purge start as startAppLifecycle makes it: the verified |
| +// completion fence, then BeginPurge's CAS with it. delete is the same |
| +// for StartDeleteClaim and BeginDelete. |
| +func (w *completionWorld) purge(t *testing.T) (hostclaim.Observed, error) { |
| + t.Helper() |
| + ctx := context.Background() |
| + obs, err := hostclaim.Read(ctx, w.s, completionHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + fenced, err := w.a.completionFence(ctx, obs.Claim) |
| + if err != nil { |
| + return obs, err |
| + } |
| + return hostclaim.Update(ctx, w.s, obs, BeginPurge("purge-1", "console", time.Unix(200, 0), []string{"ie"}, []string{"box-a", "box-w"}, fenced)) |
| +} |
| + |
| +func (w *completionWorld) delete(t *testing.T) (hostclaim.Observed, error) { |
| + t.Helper() |
| + ctx := context.Background() |
| + obs, err := hostclaim.Read(ctx, w.s, completionHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + fenced, err := w.a.completionFence(ctx, obs.Claim) |
| + if err != nil { |
| + return obs, err |
| + } |
| + return hostclaim.Update(ctx, w.s, obs, hostclaim.BeginDelete("delete-1", "console", "record-etag", time.Unix(200, 0), []string{"ie"}, []string{"box-a", "box-w"}, fenced)) |
| +} |
| + |
| +func (w *completionWorld) unchanged(t *testing.T, what string) { |
| + t.Helper() |
| + obs, err := hostclaim.Read(context.Background(), w.s, completionHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if obs.Claim.Op.Kind != hostclaim.KindServing || obs.Claim.Op.ID != "" { |
| + t.Fatalf("%s: the claim was started: %+v", what, obs.Claim.Op) |
| + } |
| +} |
| + |
| +// Ruling 86, the deadlock schedule: the completion box is terminated before |
| +// it discharged, and a purge (or a delete) starts. With box-a's termination |
| +// fence verified under the roster, the start passes the completion barrier |
| +// as a failover's would; without the exemption StartGuard's barrier |
| +// refuses it, which is the deadlock the model found. |
| +func TestPurgeStartsOverATerminatedCompletionBox(t *testing.T) { |
| + w := newCompletionWorld(t, nil) |
| + w.terminated(t, nil) |
| + ctx := context.Background() |
| + obs, err := hostclaim.Read(ctx, w.s, completionHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := hostclaim.Update(ctx, w.s, obs, BeginPurge("purge-1", "console", time.Unix(200, 0), []string{"ie"}, []string{"box-a"}, "")); !errors.Is(err, hostclaim.ErrRelocationFinishing) { |
| + t.Fatalf("the barrier without the exemption: %v (the deadlock's refusal)", err) |
| + } |
| + if fenced, err := w.a.completionFence(ctx, obs.Claim); err != nil || fenced != "box-a" { |
| + t.Fatalf("the verified completion fence: %q %v", fenced, err) |
| + } |
| + started, err := w.purge(t) |
| + if err != nil || started.Claim.Op.Kind != hostclaim.KindPurging || started.Claim.Op.ID != "purge-1" { |
| + t.Fatalf("the purge over a terminated completion box: %v %+v", err, started.Claim) |
| + } |
| + d := newCompletionWorld(t, nil) |
| + d.terminated(t, nil) |
| + if started, err := d.delete(t); err != nil || started.Claim.Op.Kind != hostclaim.KindDeleting { |
| + t.Fatalf("the delete over a terminated completion box: %v", err) |
| + } |
| +} |
| + |
| +// Ruling 86's guards: only a verified fence exempts. A live completion box |
| +// (no fence), a fence that does not verify (another instance, written by |
| +// the box itself, not terminated, another box's record at this key), an |
| +// unreadable fence and a completion box the roster does not hold all leave |
| +// the purge refused; an unreadable or unverifiable fence is its error. A |
| +// completion already discharged, or whose box is on lost, needs no fence: |
| +// the barrier passes it and an unreadable fence is never read. |
| +func TestPurgeOverACompletionNeedsAVerifiedFence(t *testing.T) { |
| + ctx := context.Background() |
| + t.Run("a live completion box", func(t *testing.T) { |
| + w := newCompletionWorld(t, nil) |
| + if _, err := w.purge(t); !errors.Is(err, hostclaim.ErrRelocationFinishing) { |
| + t.Fatalf("a live completion box: %v", err) |
| + } |
| + if _, err := w.delete(t); !errors.Is(err, hostclaim.ErrRelocationFinishing) { |
| + t.Fatalf("a live completion box (delete): %v", err) |
| + } |
| + w.unchanged(t, "a live completion box") |
| + }) |
| + for name, edit := range map[string]func(*termfence.Record){ |
| + "another instance": func(f *termfence.Record) { f.Cloud.InstanceID = "i-other" }, |
| + "written by the box": func(f *termfence.Record) { |
| + f.WrittenBy = termfence.WrittenBy{Box: "box-a", Enrollment: strings.Repeat("a", 32)} |
| + }, |
| + "not terminated": func(f *termfence.Record) { f.State = "running" }, |
| + "another box's record": func(f *termfence.Record) { f.Box = "box-w" }, |
| + "written by an unenrolled": func(f *termfence.Record) { f.WrittenBy.Box = "box-x" }, |
| + } { |
| + t.Run("a fence that does not verify: "+name, func(t *testing.T) { |
| + w := newCompletionWorld(t, nil) |
| + w.terminated(t, edit) |
| + if _, err := w.purge(t); err == nil { |
| + t.Fatalf("an unverified fence exempted the purge") |
| + } |
| + if _, err := w.delete(t); err == nil { |
| + t.Fatalf("an unverified fence exempted the delete") |
| + } |
| + w.unchanged(t, name) |
| + }) |
| + } |
| + t.Run("an unreadable fence", func(t *testing.T) { |
| + w := newCompletionWorld(t, nil) |
| + w.fence(t, []byte("{")) |
| + if _, err := w.purge(t); err == nil || errors.Is(err, hostclaim.ErrRelocationFinishing) { |
| + t.Fatalf("an unreadable fence: %v (want its error)", err) |
| + } |
| + w.unchanged(t, "an unreadable fence") |
| + }) |
| + t.Run("a fence read that fails", func(t *testing.T) { |
| + w := newCompletionWorld(t, nil) |
| + w.terminated(t, nil) |
| + w.a.Store = &faultStore{Store: w.s, failGet: "control/box-fences/" + strings.Repeat("a", 32) + ".json"} |
| + if fenced, err := w.a.completionFence(ctx, mustClaim(t, w)); fenced != "" || err == nil { |
| + t.Fatalf("a failed fence read: %q %v (want its error)", fenced, err) |
| + } |
| + }) |
| + t.Run("a completion box the roster does not hold", func(t *testing.T) { |
| + w := newCompletionWorld(t, func(c *hostclaim.Claim) { |
| + done, _, _ := hostclaim.ReadCompletedRelocation(c) |
| + done.Stop.Box = "box-gone" |
| + raw, _ := json.Marshal(done) |
| + c.Op.Exec, c.Op.Participants, c.Op.Journal[hostclaim.JournalCompleted] = "box-gone", []string{"box-gone"}, string(raw) |
| + }) |
| + w.terminated(t, nil) |
| + if fenced, err := w.a.completionFence(ctx, mustClaim(t, w)); fenced != "" || err != nil { |
| + t.Fatalf("an unrostered box: %q %v", fenced, err) |
| + } |
| + if _, err := w.purge(t); !errors.Is(err, hostclaim.ErrRelocationFinishing) { |
| + t.Fatalf("an unrostered box: %v", err) |
| + } |
| + }) |
| + t.Run("a completion record that cannot be read", func(t *testing.T) { |
| + w := newCompletionWorld(t, func(c *hostclaim.Claim) { c.Op.Journal[hostclaim.JournalCompleted] = "{" }) |
| + w.terminated(t, nil) |
| + if _, err := w.purge(t); !errors.Is(err, hostclaim.ErrRelocation) { |
| + t.Fatalf("an unreadable completion record: %v", err) |
| + } |
| + w.unchanged(t, "an unreadable completion record") |
| + }) |
| + for name, change := range map[string]func(*hostclaim.Claim){ |
| + "already discharged": func(c *hostclaim.Claim) { |
| + done, _, _ := hostclaim.ReadCompletedRelocation(c) |
| + done.Discharged = true |
| + raw, _ := json.Marshal(done) |
| + c.Op.Journal[hostclaim.JournalCompleted] = string(raw) |
| + }, |
| + "its box on lost": func(c *hostclaim.Claim) { c.Lost = append(c.Lost, "box-a") }, |
| + "no completion": func(c *hostclaim.Claim) { c.Op.Journal = nil }, |
| + } { |
| + t.Run("nothing to exempt: "+name, func(t *testing.T) { |
| + w := newCompletionWorld(t, change) |
| + w.fence(t, []byte("{")) |
| + if fenced, err := w.a.completionFence(ctx, mustClaim(t, w)); fenced != "" || err != nil { |
| + t.Fatalf("%s: %q %v (the fence must not be read)", name, fenced, err) |
| + } |
| + if started, err := w.purge(t); err != nil || started.Claim.Op.Kind != hostclaim.KindPurging { |
| + t.Fatalf("%s: %v", name, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func mustClaim(t *testing.T, w *completionWorld) *hostclaim.Claim { |
| + t.Helper() |
| + obs, err := hostclaim.Read(context.Background(), w.s, completionHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return obs.Claim |
| +} |
diff --git a/internal/hostops/operations.go b/internal/hostops/operations.go| index c7a95d985..079b13f2b 100644 |
| --- a/internal/hostops/operations.go |
| +++ b/internal/hostops/operations.go |
| @@ -278,7 +278,7 @@ func (c Controller) BeginRetire(ctx context.Context, key, by, expectedInc string |
| }); ok { |
| return production.StartDeleteClaim(ctx, obs, r, by) |
| } |
| - return hostclaim.Update(ctx, c.Store, obs, hostclaim.BeginDelete(c.NewID(), by, r.ETag, c.Now(), regions, boxes)) |
| + return hostclaim.Update(ctx, c.Store, obs, hostclaim.BeginDelete(c.NewID(), by, r.ETag, c.Now(), regions, boxes, "")) |
| } |
| func (c Controller) Retire(ctx context.Context, host, id string) error { |
diff --git a/internal/hostops/operations_test.go b/internal/hostops/operations_test.go| index b1ee10380..9cfeb5fa1 100644 |
| --- a/internal/hostops/operations_test.go |
| +++ b/internal/hostops/operations_test.go |
| @@ -367,7 +367,7 @@ func TestPurgeRetiresUnderOwnOperation(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - obs, err = hostclaim.Update(f.ctx, f.store, obs, BeginPurge("purge", "test", f.now, []string{"ie"}, []string{"box-a"})) |
| + obs, err = hostclaim.Update(f.ctx, f.store, obs, BeginPurge("purge", "test", f.now, []string{"ie"}, []string{"box-a"}, "")) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -568,7 +568,7 @@ func TestPlacementStartKeepsInstallationAndExcludesDelete(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - if _, err := hostclaim.Update(f.ctx, f.store, placed, hostclaim.BeginDelete("delete", "test", rec.ETag, f.now, nil, nil)); err == nil { |
| + if _, err := hostclaim.Update(f.ctx, f.store, placed, hostclaim.BeginDelete("delete", "test", rec.ETag, f.now, nil, nil, "")); err == nil { |
| t.Fatal("delete overlapped placement") |
| } |
| } |
diff --git a/internal/hostops/production_delete.go b/internal/hostops/production_delete.go| index 5c0fd72ef..5efe2f3c1 100644 |
| --- a/internal/hostops/production_delete.go |
| +++ b/internal/hostops/production_delete.go |
| @@ -43,6 +43,11 @@ func (a ProductionAuthority) StartDeleteClaim(ctx context.Context, before hostcl |
| if err = a.StartDelete(ctx, before, r); err != nil { |
| return hostclaim.Observed{}, err |
| } |
| + // Ruling 86: a delete passes the completion box it verified dead. |
| + fenced, err := a.completionFence(ctx, before.Claim) |
| + if err != nil { |
| + return hostclaim.Observed{}, err |
| + } |
| return a.startHeldClaim(ctx, before, id, scope, func() error { |
| if err := a.StartDelete(ctx, before, r); err != nil { |
| return err |
| @@ -55,5 +60,5 @@ func (a ProductionAuthority) StartDeleteClaim(ctx context.Context, before hostcl |
| return ErrPending |
| } |
| return nil |
| - }, hostclaim.BeginDelete(id, by, r.ETag, a.Now(), regions, boxes)) |
| + }, hostclaim.BeginDelete(id, by, r.ETag, a.Now(), regions, boxes, fenced)) |
| } |
diff --git a/internal/hostops/production_held_start.go b/internal/hostops/production_held_start.go| index b667a92de..44f5dca5e 100644 |
| --- a/internal/hostops/production_held_start.go |
| +++ b/internal/hostops/production_held_start.go |
| @@ -115,8 +115,15 @@ func (a ProductionAuthority) startAppLifecycle(ctx context.Context, before hostc |
| if err != nil { |
| return hostclaim.Observed{}, err |
| } |
| + fenced := "" |
| + if !placing { |
| + // Ruling 86: a purge passes the completion box it verified dead. |
| + if fenced, err = a.completionFence(ctx, before.Claim); err != nil { |
| + return hostclaim.Observed{}, err |
| + } |
| + } |
| return a.startHeldClaim(ctx, before, id, scope, check, func(claim *hostclaim.Claim) error { |
| - transition := BeginPurge(id, by, a.Now(), regions, boxes) |
| + transition := BeginPurge(id, by, a.Now(), regions, boxes, fenced) |
| if placing { |
| transition = BeginPlacement(id, by, target, a.Now(), regions, boxes) |
| } |
diff --git a/internal/hostops/purge.go b/internal/hostops/purge.go| index 4e1924937..823b9eb5f 100644 |
| --- a/internal/hostops/purge.go |
| +++ b/internal/hostops/purge.go |
| @@ -152,12 +152,16 @@ func ReleasePurgeFence(id string) func(*hostclaim.Claim) error { |
| return nil |
| } |
| } |
| -func BeginPurge(id, by string, now time.Time, regions, boxes []string) func(*hostclaim.Claim) error { |
| + |
| +// BeginPurge starts one host's purge from serving. fencedCompletion is |
| +// hostclaim.StartGuard's (ruling 86): the completion box whose termination |
| +// fence the caller verified (ProductionAuthority.completionFence), or "". |
| +func BeginPurge(id, by string, now time.Time, regions, boxes []string, fencedCompletion string) func(*hostclaim.Claim) error { |
| return func(c *hostclaim.Claim) error { |
| if id == "" || now.IsZero() { |
| return ErrIdentity |
| } |
| - if err := hostclaim.StartGuard(c, regions, boxes); err != nil { |
| + if err := hostclaim.StartGuard(c, regions, boxes, fencedCompletion); err != nil { |
| return err |
| } |
| c.Op = hostclaim.Op{Kind: hostclaim.KindPurging, ID: id, By: by, StartedAt: now.UTC(), Exec: c.Op.Exec, Participants: c.Op.Participants} |
| @@ -187,7 +191,7 @@ func BeginPlacement(id, by, target string, now time.Time, regions, boxes []strin |
| if id == "" || now.IsZero() { |
| return ErrIdentity |
| } |
| - if err := hostclaim.StartGuard(c, regions, boxes); err != nil { |
| + if err := hostclaim.StartGuard(c, regions, boxes, ""); err != nil { |
| return err |
| } |
| c.Op = hostclaim.Op{Kind: hostclaim.KindPlacing, ID: id, By: by, StartedAt: now.UTC(), Exec: c.Op.Exec, Participants: c.Op.Participants, Intent: map[string]string{"target": target}} |
diff --git a/internal/hostops/purge_retirement_test.go b/internal/hostops/purge_retirement_test.go| index 1d6d29652..ed5315c8e 100644 |
| --- a/internal/hostops/purge_retirement_test.go |
| +++ b/internal/hostops/purge_retirement_test.go |
| @@ -52,7 +52,7 @@ func TestRetirePurgeHostsDoesNotCompleteParking(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - obs, err = hostclaim.Update(f.ctx, f.store, obs, hostclaim.BeginDelete("purge", "test", "record-tag", f.now, nil, nil)) |
| + obs, err = hostclaim.Update(f.ctx, f.store, obs, hostclaim.BeginDelete("purge", "test", "record-tag", f.now, nil, nil, "")) |
| if err != nil { |
| t.Fatal(err) |
| } |