carlos / platform Public

park: Parked; hostclaim: park queue helpers for move retirements on the existing kinds (§6.4, D6)

Engine.Parked reports the manifest present and every listed version gone.
QueueLocationPark, QueueStagingPark, QueueLocalPark and QueueResidueParks
queue park-replica, park-local and park-generation chores for a move; no new
kinds. InventoryHasStamp and CompleteLocalPark carry G5's first-landing rule.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 015da00400d3d161c74460551b67ecdfe4416393 parent c23d174
4 files changed, +793 −0
  • internal/hostclaim/move.go +208 −0
  • internal/hostclaim/move_test.go +366 −0
  • internal/park/remote.go +54 −0
  • internal/park/remote_test.go +165 −0
diff --git a/internal/hostclaim/move.go b/internal/hostclaim/move.go
index fdcb41036..98956e59e 100644
--- a/internal/hostclaim/move.go
+++ b/internal/hostclaim/move.go
@@ -3,6 +3,7 @@ package hostclaim
import (
"fmt"
"strconv"
+ "strings"
"time"
)
@@ -302,3 +303,210 @@ func Revert(opID, sourceBox, targetRegion string, stagingScope *ParkScope, newID
func sameParkScope(a, b ParkScope) bool {
return a.Location == b.Location && a.Incarnation == b.Incarnation && a.EvidenceAt.Equal(b.EvidenceAt) && a.RequestLimit == b.RequestLimit
}
+
+// Park queue helpers for move retirements (moves spec §6.4, index D6). A
+// move adds no obligation kind: a staging lane is a park-replica scoped to
+// moves/<host>/<op>; a whole location retired where it is no longer
+// authoritative is a park-replica scoped to instances/<host>[/<inc>]; a
+// landing's earlier-stay residue is one park-generation per generation
+// (RootGeneration for the origin); the box's own artifacts are a park-local.
+// Claim.Validate accepts all of them unchanged, and the park engine archives
+// them under parked/<incarnation>/<host>/<retirement>/ with the op in the
+// manifest.
+//
+// Each helper changes obligations only, is idempotent per its key (a replay
+// allocates no ids), and refuses a replay that names the same chore with a
+// different scope rather than overwriting it.
+
+// parkQueueGuard is the check every helper shares: the op is current, the
+// claim has its holder, the scope is valid for this host and names this
+// holder's incarnation.
+func parkQueueGuard(c *Claim, opID, region string, scope ParkScope, newID func() string) error {
+ if err := RequireOp(opID)(c); err != nil {
+ return err
+ }
+ if c.Holder == nil || newID == nil || region == "" {
+ return fmt.Errorf("%w: a move's park names its region and holder and has a way to mint ids", ErrMalformed)
+ }
+ if err := scope.Validate(c.Host); err != nil {
+ return err
+ }
+ if scope.Incarnation != c.Holder.Incarnation {
+ return fmt.Errorf("%w: the park's incarnation is not this holder's", ErrMalformed)
+ }
+ return nil
+}
+
+func isStagingLocation(location string) bool { return strings.HasPrefix(location, "moves/") }
+
+// queueReplicaPark appends a park-replica for the region unless this op
+// already owes one of the same class (a lane or a location) there. It
+// reports whether it appended.
+func queueReplicaPark(c *Claim, opID, region string, scope ParkScope, newID func() string) (bool, error) {
+ for _, o := range c.Obligations {
+ if o.Op == opID && o.Kind == "park-replica" && o.Region == region && o.Park != nil && isStagingLocation(o.Park.Location) == isStagingLocation(scope.Location) {
+ if !sameParkScope(*o.Park, scope) {
+ return false, fmt.Errorf("%w: %s already owes a park in %s with another scope", ErrWrongState, opID, region)
+ }
+ return false, nil
+ }
+ }
+ s := scope
+ c.Obligations = append(c.Obligations, Obligation{ID: newID(), Op: opID, Kind: "park-replica", Region: region, Retirement: newID(), Owner: "region:" + region, Park: &s})
+ return true, nil
+}
+
+// queueLocalPark appends a park-local for the box unless this op has one.
+func queueLocalPark(c *Claim, opID, box string, newID func() string) {
+ for _, o := range c.Obligations {
+ if o.Op == opID && o.Kind == "park-local" && o.Box == box {
+ return
+ }
+ }
+ c.Obligations = append(c.Obligations, Obligation{ID: newID(), Op: opID, Kind: "park-local", Box: box, Retirement: newID(), Owner: "box:" + box})
+}
+
+// QueueLocationPark queues the park of a whole location in a region where it
+// is no longer authoritative: the source's after the commit, a rolled-back
+// target's at cleaning. The scope is instances/<host> or
+// instances/<host>/<incarnation>. With a box, the box's park-local is queued
+// in the same write. Idempotent per (op, region) and (op, box).
+func QueueLocationPark(opID, region, box string, scope ParkScope, newID func() string) func(*Claim) error {
+ return func(c *Claim) error {
+ if err := parkQueueGuard(c, opID, region, scope, newID); err != nil {
+ return err
+ }
+ if isStagingLocation(scope.Location) {
+ return fmt.Errorf("%w: a location park is not a staging lane", ErrMalformed)
+ }
+ if _, err := queueReplicaPark(c, opID, region, scope, newID); err != nil {
+ return err
+ }
+ if box != "" {
+ queueLocalPark(c, opID, box, newID)
+ }
+ return nil
+ }
+}
+
+// QueueStagingPark queues the park of this move's own staging lane in a
+// region. The scope's location must be exactly moves/<host>/<op>: not the
+// live location, not the host-wide staging prefix, not another move's lane.
+// A lane park a revert already queued under the op is the same chore.
+// Idempotent per (op, region). EvidenceAt is the recorded writers' bound
+// (G7); the caller computes it.
+func QueueStagingPark(opID, region string, scope ParkScope, newID func() string) func(*Claim) error {
+ return func(c *Claim) error {
+ if err := parkQueueGuard(c, opID, region, scope, newID); err != nil {
+ return err
+ }
+ // Restates moves.ScopeLocation(host, op); moves imports hostclaim, so
+ // it cannot be called from here. Keep the two together.
+ if scope.Location != "moves/"+c.Host+"/"+opID {
+ return fmt.Errorf("%w: a staging park is this move's own lane, moves/%s/%s", ErrMalformed, c.Host, opID)
+ }
+ _, err := queueReplicaPark(c, opID, region, scope, newID)
+ return err
+ }
+}
+
+// QueueLocalPark queues a park-local for the box alone, for the landing's
+// local phase: park.Local runs only against a persisted park-local chore of
+// the claim and treats an unknown id as already completed, so the chore must
+// be in the claim before the call. Idempotent per (op, box). Queue it only
+// when InventoryHasStamp says the box has paths of the earlier stay (G5).
+func QueueLocalPark(opID, box string, newID func() string) func(*Claim) error {
+ return func(c *Claim) error {
+ if err := RequireOp(opID)(c); err != nil {
+ return err
+ }
+ if box == "" || newID == nil {
+ return fmt.Errorf("%w: a local park names its box and has a way to mint ids", ErrMalformed)
+ }
+ queueLocalPark(c, opID, box, newID)
+ return nil
+ }
+}
+
+// InventoryHasStamp reports whether the box's inventory lists any path
+// tagged with the stamp: the landing's earlier stay S' (G5). A first landing
+// has none, and then no park-local is queued and none is retired.
+func InventoryHasStamp(c *Claim, box string, stamp uint64) bool {
+ if c == nil || stamp == 0 {
+ return false
+ }
+ for _, s := range c.Inventory[box].Paths {
+ if s == stamp {
+ return true
+ }
+ }
+ return false
+}
+
+// CompleteLocalPark is the replay of a local phase that finds the box's
+// park-local chore and no paths of the earlier stamp left: there is nothing
+// to retire, so the chore is completed under the op (CompleteObligation).
+// With paths of that stamp still listed it refuses (ErrWrongState): the
+// retirement has to run. No chore is a no-op.
+func CompleteLocalPark(opID, box string, earlierStamp uint64) func(*Claim) error {
+ return func(c *Claim) error {
+ if err := RequireOp(opID)(c); err != nil {
+ return err
+ }
+ if box == "" {
+ return fmt.Errorf("%w: a local park names its box", ErrMalformed)
+ }
+ for _, o := range c.Obligations {
+ if o.Op == opID && o.Kind == "park-local" && o.Box == box {
+ if InventoryHasStamp(c, box, earlierStamp) {
+ return fmt.Errorf("%w: %s still lists paths of stamp %d", ErrWrongState, box, earlierStamp)
+ }
+ return CompleteObligation(opID, o.ID)(c)
+ }
+ }
+ return nil
+ }
+}
+
+// QueueResidueParks queues one park-generation per listed generation for a
+// landing's earlier-stay residue in the target region: RootGeneration for
+// the origin, the generation name for the rest, all scoped to the same
+// location. Deduplicated by (op, region, generation); a generation already
+// queued with another scope is refused, and nothing is queued then.
+func QueueResidueParks(opID, region string, location ParkScope, generations []string, newID func() string) func(*Claim) error {
+ return func(c *Claim) error {
+ if err := parkQueueGuard(c, opID, region, location, newID); err != nil {
+ return err
+ }
+ if isStagingLocation(location.Location) {
+ return fmt.Errorf("%w: residue is parked from a location, not a staging lane", ErrMalformed)
+ }
+ for _, g := range generations {
+ if g == "" {
+ return fmt.Errorf("%w: a residue generation has a name", ErrMalformed)
+ }
+ }
+ queued := map[string]bool{}
+ for _, o := range c.Obligations {
+ if o.Op == opID && o.Kind == "park-generation" && o.Region == region {
+ if o.Park == nil || !sameParkScope(*o.Park, location) {
+ for _, g := range generations {
+ if g == o.Generation {
+ return fmt.Errorf("%w: %s already owes generation %q in %s with another scope", ErrWrongState, opID, g, region)
+ }
+ }
+ }
+ queued[o.Generation] = true
+ }
+ }
+ for _, g := range generations {
+ if queued[g] {
+ continue
+ }
+ queued[g] = true
+ s := location
+ c.Obligations = append(c.Obligations, Obligation{ID: newID(), Op: opID, Kind: "park-generation", Region: region, Generation: g, Retirement: newID(), Owner: "region:" + region, Park: &s})
+ }
+ return nil
+ }
+}
diff --git a/internal/hostclaim/move_test.go b/internal/hostclaim/move_test.go
index e42c65c47..baa2eb24a 100644
--- a/internal/hostclaim/move_test.go
+++ b/internal/hostclaim/move_test.go
@@ -680,3 +680,369 @@ func TestRevertReplayIsIdempotent(t *testing.T) {
t.Fatalf("replay after the revert moved on: %v", err)
}
}
+
+// parkFixture is a valid landing-shaped claim for the park queue helpers:
+// op moveA, a holder with an incarnation, and the scope helpers an op needs.
+func parkFixture(t *testing.T) (*Claim, func(location string) ParkScope) {
+ t.Helper()
+ c := movingTestClaim(t)
+ if err := BeginMove(moveA, "ops", "ie-box", "enr-ie", "uk-box", "uk", time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC))(c); err != nil {
+ t.Fatal(err)
+ }
+ scope := func(location string) ParkScope {
+ return ParkScope{Location: location, Incarnation: c.Holder.Incarnation, EvidenceAt: time.Date(2026, 10, 6, 9, 5, 0, 0, time.UTC), RequestLimit: 2 * time.Minute}
+ }
+ return c, scope
+}
+
+// The move's retirements ride the engine's existing kinds. A real store
+// round trip: Validate accepts what the helper wrote and it reads back.
+func TestLocationParkIsAParkReplicaTheStoreAccepts(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ o := servingClaim(t, ctx, s)
+ now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC)
+ o, err := Update(ctx, s, o, BeginMove(moveA, "ops", "adi-knitting", "enr-ie", "uk-box", "uk", now))
+ if err != nil {
+ t.Fatal(err)
+ }
+ inc := o.Claim.Holder.Incarnation
+ scope := ParkScope{Location: "instances/" + testHost + "/" + inc, Incarnation: inc, EvidenceAt: now, RequestLimit: 2 * time.Minute}
+ o, err = Update(ctx, s, o, QueueLocationPark(moveA, "ie", "adi-knitting", scope, counter()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ o, err = Read(ctx, s, testHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := o.Claim.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ if len(o.Claim.Obligations) != 2 {
+ t.Fatalf("obligations: %+v", o.Claim.Obligations)
+ }
+ r, l := o.Claim.Obligations[0], o.Claim.Obligations[1]
+ if r.Kind != "park-replica" || r.Op != moveA || r.Region != "ie" || r.Owner != "region:ie" || r.Park == nil || !sameParkScope(*r.Park, scope) || r.Retirement == "" || r.ID == "" || r.ID == r.Retirement {
+ t.Fatalf("replica park: %+v", r)
+ }
+ if l.Kind != "park-local" || l.Op != moveA || l.Box != "adi-knitting" || l.Owner != "box:adi-knitting" || l.Park != nil || l.Region != "" || l.Retirement == "" {
+ t.Fatalf("local park: %+v", l)
+ }
+ // The same call again, across the store: nothing new, no ids spent.
+ ids := 0
+ again := func() string { ids++; return fmt.Sprintf("late_%d", ids) }
+ if o, err = Update(ctx, s, o, QueueLocationPark(moveA, "ie", "adi-knitting", scope, again)); err != nil || ids != 0 || len(o.Claim.Obligations) != 2 {
+ t.Fatalf("replay: ids=%d obligations=%d err=%v", ids, len(o.Claim.Obligations), err)
+ }
+ // Completing both by the existing chore completion ends them.
+ for _, ob := range o.Claim.Obligations {
+ if o, err = Update(ctx, s, o, CompleteObligation(ob.Op, ob.ID)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if len(o.Claim.Obligations) != 0 {
+ t.Fatal("chores left")
+ }
+}
+
+func TestStagingParkScopeIsTheLane(t *testing.T) {
+ c, scope := parkFixture(t)
+ lane := scope("moves/" + c.Host + "/" + moveA)
+ if err := QueueStagingPark(moveA, "uk", lane, counter())(c); err != nil {
+ t.Fatal(err)
+ }
+ if len(c.Obligations) != 1 {
+ t.Fatalf("obligations: %+v", c.Obligations)
+ }
+ o := c.Obligations[0]
+ if o.Kind != "park-replica" || o.Region != "uk" || o.Owner != "region:uk" || o.Op != moveA || o.Park.Location != "moves/"+c.Host+"/"+moveA {
+ t.Fatalf("staging park: %+v", o)
+ }
+ c.Gen++
+ if err := c.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ // Anything but this move's own lane is refused, and nothing is queued.
+ for name, location := range map[string]string{
+ "the live location": "instances/" + c.Host + "/" + c.Holder.Incarnation,
+ "the legacy prefix": "instances/" + c.Host,
+ "the staging prefix": "moves/" + c.Host,
+ "another move": "moves/" + c.Host + "/" + moveB,
+ "another host": "moves/other.example.com/" + moveA,
+ "below the lane": "moves/" + c.Host + "/" + moveA + "/x",
+ } {
+ d, _ := parkFixture(t)
+ bad := scope(location)
+ ids := 0
+ if err := QueueStagingPark(moveA, "uk", bad, func() string { ids++; return "x" })(d); err == nil || len(d.Obligations) != 0 || ids != 0 {
+ t.Errorf("%s: accepted (%v), obligations %d", name, err, len(d.Obligations))
+ }
+ }
+ // Another holder's incarnation is refused.
+ d, _ := parkFixture(t)
+ foreign := scope("moves/" + d.Host + "/" + moveA)
+ foreign.Incarnation = strings.Repeat("a", 64)
+ if err := QueueStagingPark(moveA, "uk", foreign, counter())(d); !errors.Is(err, ErrMalformed) {
+ t.Errorf("foreign incarnation: %v", err)
+ }
+ // A stale op is refused.
+ if err := QueueStagingPark(moveB, "uk", lane, counter())(d); !errors.Is(err, ErrStaleOp) {
+ t.Errorf("stale op: %v", err)
+ }
+}
+
+// A revert already queued the lane's park: queueing it again is a no-op,
+// and a different scope for the same lane is refused, not overwritten.
+func TestStagingParkAfterRevertIsTheSameChore(t *testing.T) {
+ c, scope := parkFixture(t)
+ if err := ExecToNone(moveA, "ie-box", stoppedSource, JournalEvidenceSource)(c); err != nil {
+ t.Fatal(err)
+ }
+ lane := scope("moves/" + c.Host + "/" + moveA)
+ if err := Revert(moveA, "ie-box", "uk", &lane, counter())(c); err != nil {
+ t.Fatal(err)
+ }
+ ids := 0
+ newID := func() string { ids++; return "z" }
+ if err := QueueStagingPark(moveA, "uk", lane, newID)(c); err != nil || ids != 0 || len(c.Obligations) != 1 {
+ t.Fatalf("same lane: %v ids=%d obligations=%d", err, ids, len(c.Obligations))
+ }
+ later := lane
+ later.EvidenceAt = lane.EvidenceAt.Add(time.Second)
+ if err := QueueStagingPark(moveA, "uk", later, newID)(c); !errors.Is(err, ErrWrongState) || ids != 0 || len(c.Obligations) != 1 {
+ t.Fatalf("changed scope: %v ids=%d", err, ids)
+ }
+}
+
+func TestLocationParkAndStagingParkCoexistInOneRegion(t *testing.T) {
+ // A rolled-back target at cleaning owes the lane and the location in the
+ // same region under one op.
+ c, scope := parkFixture(t)
+ lane := scope("moves/" + c.Host + "/" + moveA)
+ loc := scope("instances/" + c.Host + "/" + c.Holder.Incarnation)
+ n := counter()
+ for i := 0; i < 2; i++ {
+ if err := QueueStagingPark(moveA, "uk", lane, n)(c); err != nil {
+ t.Fatal(err)
+ }
+ if err := QueueLocationPark(moveA, "uk", "", loc, n)(c); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if len(c.Obligations) != 2 {
+ t.Fatalf("obligations: %+v", c.Obligations)
+ }
+ c.Gen++
+ if err := c.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ // The location's local chore is per box, not per region.
+ if err := QueueLocationPark(moveA, "uk", "uk-box", loc, n)(c); err != nil || len(c.Obligations) != 3 {
+ t.Fatalf("add the box: %v %+v", err, c.Obligations)
+ }
+ if err := QueueLocationPark(moveA, "uk", "uk-box", loc, n)(c); err != nil || len(c.Obligations) != 3 {
+ t.Fatalf("box twice: %v %+v", err, c.Obligations)
+ }
+ // Another region is another chore.
+ if err := QueueLocationPark(moveA, "ie", "", loc, n)(c); err != nil || len(c.Obligations) != 4 {
+ t.Fatalf("another region: %v %+v", err, c.Obligations)
+ }
+}
+
+func TestLocationParkRefusals(t *testing.T) {
+ c, scope := parkFixture(t)
+ loc := scope("instances/" + c.Host + "/" + c.Holder.Incarnation)
+ lane := scope("moves/" + c.Host + "/" + moveA)
+ bad := loc
+ bad.RequestLimit = 0
+ changed := loc
+ changed.EvidenceAt = loc.EvidenceAt.Add(time.Hour)
+ if err := QueueLocationPark(moveA, "ie", "", loc, counter())(c); err != nil {
+ t.Fatal(err)
+ }
+ for name, fn := range map[string]func(*Claim) error{
+ "a lane through the location door": QueueLocationPark(moveA, "ie", "", lane, counter()),
+ "no region": QueueLocationPark(moveA, "", "", loc, counter()),
+ "an invalid scope": QueueLocationPark(moveA, "uk", "", bad, counter()),
+ "another scope, same chore": QueueLocationPark(moveA, "ie", "", changed, counter()),
+ "another op": QueueLocationPark(moveB, "ie", "", loc, counter()),
+ "no id source": QueueLocationPark(moveA, "uk", "", loc, nil),
+ } {
+ before := mustJSON(t, c)
+ if err := fn(c); err == nil {
+ t.Errorf("%s: accepted", name)
+ }
+ if string(mustJSON(t, c)) != string(before) {
+ t.Errorf("%s: changed the claim", name)
+ }
+ }
+}
+
+func TestResidueParksArePerGeneration(t *testing.T) {
+ c, scope := parkFixture(t)
+ loc := scope("instances/" + c.Host + "/" + c.Holder.Incarnation)
+ n := counter()
+ gens := []string{"gen-4", RootGeneration, "gen-9"}
+ if err := QueueResidueParks(moveA, "uk", loc, gens, n)(c); err != nil {
+ t.Fatal(err)
+ }
+ if len(c.Obligations) != 3 {
+ t.Fatalf("obligations: %+v", c.Obligations)
+ }
+ seen := map[string]bool{}
+ for _, o := range c.Obligations {
+ if o.Kind != "park-generation" || o.Op != moveA || o.Region != "uk" || o.Owner != "region:uk" || o.Park == nil || !sameParkScope(*o.Park, loc) || o.Retirement == "" || o.Box != "" {
+ t.Fatalf("residue park: %+v", o)
+ }
+ seen[o.Generation] = true
+ }
+ if !seen["gen-4"] || !seen["gen-9"] || !seen["@root"] {
+ t.Fatalf("generations: %v", seen)
+ }
+ c.Gen++
+ if err := c.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ // Each chore owns its scope: editing one does not touch another.
+ c.Obligations[0].Park.RequestLimit = time.Hour
+ if c.Obligations[1].Park.RequestLimit == time.Hour {
+ t.Fatal("chores share one scope")
+ }
+ // A double call, a repeated generation in the list and a superset add
+ // only what is missing, and spend ids only for that.
+ ids := 0
+ next := func() string { ids++; return fmt.Sprintf("n%d", ids) }
+ loc.RequestLimit = 2 * time.Minute
+ c.Obligations[0].Park.RequestLimit = 2 * time.Minute
+ if err := QueueResidueParks(moveA, "uk", loc, append(gens, "gen-4", "gen-11", "gen-11"), next)(c); err != nil {
+ t.Fatal(err)
+ }
+ if len(c.Obligations) != 4 || ids != 2 || c.Obligations[3].Generation != "gen-11" {
+ t.Fatalf("superset: %d obligations, %d ids", len(c.Obligations), ids)
+ }
+ // No generations is nothing to do; an empty name is malformed.
+ if err := QueueResidueParks(moveA, "uk", loc, nil, next)(c); err != nil || len(c.Obligations) != 4 {
+ t.Fatalf("none: %v", err)
+ }
+ if err := QueueResidueParks(moveA, "uk", loc, []string{"gen-12", ""}, next)(c); !errors.Is(err, ErrMalformed) {
+ t.Fatalf("empty generation: %v", err)
+ }
+ // The staging lane is not a residue location.
+ if err := QueueResidueParks(moveA, "uk", scope("moves/"+c.Host+"/"+moveA), []string{"gen-1"}, next)(c); err == nil {
+ t.Fatal("residue parked a lane")
+ }
+}
+
+func TestLocalParkAndTheEarlierStampsPaths(t *testing.T) {
+ c, _ := parkFixture(t)
+ c.Inventory = map[string]InventoryEntry{"uk-box": {Layout: "nested", Paths: map[string]uint64{"/data/a": 4, "/data/b": 7}}}
+ if !InventoryHasStamp(c, "uk-box", 4) || InventoryHasStamp(c, "uk-box", 5) || InventoryHasStamp(c, "ie-box", 4) || InventoryHasStamp(c, "uk-box", 0) {
+ t.Fatal("InventoryHasStamp")
+ }
+ ids := 0
+ newID := func() string { ids++; return fmt.Sprintf("l%d", ids) }
+ if err := QueueLocalPark(moveA, "uk-box", newID)(c); err != nil {
+ t.Fatal(err)
+ }
+ if err := QueueLocalPark(moveA, "uk-box", newID)(c); err != nil || ids != 2 || len(c.Obligations) != 1 {
+ t.Fatalf("double call: %v ids=%d obligations=%d", err, ids, len(c.Obligations))
+ }
+ o := c.Obligations[0]
+ if o.Kind != "park-local" || o.Box != "uk-box" || o.Owner != "box:uk-box" || o.Op != moveA || o.Region != "" || o.Park != nil {
+ t.Fatalf("local park: %+v", o)
+ }
+ c.Gen++
+ if err := c.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ // The replay with S' paths still there must not complete the chore.
+ if err := CompleteLocalPark(moveA, "uk-box", 4)(c); !errors.Is(err, ErrWrongState) || len(c.Obligations) != 1 {
+ t.Fatalf("complete with paths left: %v", err)
+ }
+ // With no S' paths the chore is completed under the op; a second time is a no-op.
+ c.Inventory["uk-box"] = InventoryEntry{Layout: "nested", Paths: map[string]uint64{"/data/b": 7}}
+ if err := CompleteLocalPark(moveA, "uk-box", 4)(c); err != nil || len(c.Obligations) != 0 {
+ t.Fatalf("complete: %v %+v", err, c.Obligations)
+ }
+ if err := CompleteLocalPark(moveA, "uk-box", 4)(c); err != nil {
+ t.Fatalf("replay: %v", err)
+ }
+ // It is the op's own chore only.
+ if err := QueueLocalPark(moveA, "uk-box", newID)(c); err != nil {
+ t.Fatal(err)
+ }
+ if err := CompleteLocalPark(moveB, "uk-box", 4)(c); !errors.Is(err, ErrStaleOp) || len(c.Obligations) != 1 {
+ t.Fatalf("another op: %v", err)
+ }
+ if err := QueueLocalPark(moveA, "", newID)(c); err == nil {
+ t.Fatal("a local park with no box")
+ }
+}
+
+// The park queue helpers own only the obligations they append. Every
+// other claim field, and every other op's chore, is carried through.
+func TestParkQueueHelpersPreserveTheRestOfTheClaim(t *testing.T) {
+ now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC)
+ build := func() *Claim {
+ c := movingTestClaim(t)
+ c.Holder.Generation = "gen-7"
+ c.Holder.Session = 9
+ c.Holder.WriterBox = "ie-box"
+ c.Holder.Admissions = []AdmissionRecord{{Region: "ie", Session: 9, Box: "ie-box", Generation: "gen-7", Mode: "admitted", Coverage: "covered", Covered: true,
+ WritesLost: &WritesLost{Box: "ie-box", Generation: "gen-6", Session: 8, RemoteMaxTXID: 41},
+ AdditionalWritesLost: []WritesLost{{Box: "ie-box", Generation: "gen-5", Session: 7, RemoteMaxTXID: 12}}}}
+ c.Obligations = []Obligation{{ID: "o1", Op: "old", Kind: "park-replica", Region: "se", Retirement: "r", Owner: "region:se"}}
+ c.Lost = []string{"se"}
+ c.RetiredIncarnations = []string{"abc"}
+ c.QuietAfter = map[string]time.Time{"se": now}
+ c.Touched = Touched{Complete: true, Regions: []string{"ie", "uk"}, Boxes: []string{"ie-box", "uk-box"}}
+ c.Inventory = map[string]InventoryEntry{"ie-box": {Layout: "nested", Paths: map[string]uint64{"/data/x": 3}}}
+ if err := BeginMove(moveA, "ops", "ie-box", "enr-ie", "uk-box", "uk", now)(c); err != nil {
+ t.Fatal(err)
+ }
+ if err := c.Validate(); err != nil {
+ t.Fatalf("fixture: %v", err)
+ }
+ return c
+ }
+ base := build()
+ scope := func(loc string) ParkScope {
+ return ParkScope{Location: loc, Incarnation: base.Holder.Incarnation, EvidenceAt: now, RequestLimit: time.Minute}
+ }
+ loc := scope("instances/" + base.Host + "/" + base.Holder.Incarnation)
+ lane := scope("moves/" + base.Host + "/" + moveA)
+ cases := map[string]struct {
+ fn func(*Claim) error
+ want int
+ }{
+ "location+box": {QueueLocationPark(moveA, "ie", "ie-box", loc, counter()), 2},
+ "staging": {QueueStagingPark(moveA, "uk", lane, counter()), 1},
+ "local": {QueueLocalPark(moveA, "uk-box", counter()), 1},
+ "residue": {QueueResidueParks(moveA, "uk", loc, []string{"gen-1", RootGeneration}, counter()), 2},
+ }
+ for name, tc := range cases {
+ t.Run(name, func(t *testing.T) {
+ c := build()
+ before := mustJSON(t, c)
+ if err := tc.fn(c); err != nil {
+ t.Fatal(err)
+ }
+ if len(c.Obligations) != 1+tc.want || !reflect.DeepEqual(c.Obligations[0], build().Obligations[0]) {
+ t.Fatalf("obligations: %+v", c.Obligations)
+ }
+ var round Claim
+ if err := json.Unmarshal(before, &round); err != nil {
+ t.Fatal(err)
+ }
+ round.Obligations = c.Obligations
+ if !reflect.DeepEqual(round, *c) {
+ t.Fatalf("changed more than the obligations:\nbefore %s\nafter %s", before, mustJSON(t, c))
+ }
+ c.Gen++
+ if err := c.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ })
+ }
+}
diff --git a/internal/park/remote.go b/internal/park/remote.go
index d30560c92..5ca78e372 100644
--- a/internal/park/remote.go
+++ b/internal/park/remote.go
@@ -571,3 +571,57 @@ func (e Engine) Run(ctx context.Context, w Work) error {
}
return held.Release(ctx)
}
+
+// Parked reports whether a retirement's archive is complete: its manifest
+// exists and every version in the manifest's deletion set is gone. This is
+// what the landing's parking phase waits for (moves spec §7.6); it is not the
+// acknowledgement, which still waits for the horizon and a clean listing in
+// Run. It takes no lease and writes nothing.
+//
+// Anything it cannot read, or a manifest that does not match the chore, is an
+// error and never true. A chore that is no longer in the claim returns
+// ErrChanged: it has been acknowledged (or never existed), and the caller
+// decides which from the claim it already holds.
+func (e Engine) Parked(ctx context.Context, w Work) (bool, error) {
+ if e.Store == nil || e.Claims == nil || e.Bucket == "" {
+ return false, ErrUnsafe
+ }
+ obs, err := hostclaim.Read(ctx, e.Claims, w.Host)
+ if err != nil {
+ return false, err
+ }
+ if obs.Absent {
+ return false, ErrChanged
+ }
+ o, ok := find(obs.Claim, w)
+ if !ok {
+ return false, ErrChanged
+ }
+ if o.Park == nil {
+ return false, ErrUnsafe
+ }
+ if err := validateScope(w.Host, o); err != nil {
+ return false, err
+ }
+ m, err := e.manifest(ctx, w, o)
+ if err != nil || m == nil {
+ return false, err
+ }
+ // Each listed key is listed on its own: a version that is still there
+ // shows up under its key, and a listing that fails is an error.
+ for _, d := range m.Delete {
+ vs, err := e.Store.ListVersions(ctx, e.Bucket, d.Key)
+ if err != nil {
+ return false, err
+ }
+ for _, v := range vs {
+ if v.Key == d.Key && v.VersionID == "" {
+ return false, ErrUnsafe
+ }
+ if v.Key == d.Key && v.VersionID == d.VersionID {
+ return false, nil
+ }
+ }
+ }
+ return true, nil
+}
diff --git a/internal/park/remote_test.go b/internal/park/remote_test.go
index 9d4f4c4a4..0e408cd34 100644
--- a/internal/park/remote_test.go
+++ b/internal/park/remote_test.go
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
+ "net/url"
"reflect"
"strings"
"testing"
@@ -572,3 +573,167 @@ func TestMalformedManifestNeverDeletes(t *testing.T) {
t.Fatal("deleted from bad manifest")
}
}
+
+// Parked is what the landing's parking phase waits for: the manifest is
+// there and every version it lists is gone. It is not the acknowledgement,
+// which still waits for the horizon in Run.
+func TestParkedReportsManifestAndDeletions(t *testing.T) {
+ f := newFixture(t)
+ f.seed()
+ parked := func() bool {
+ t.Helper()
+ ok, err := f.e.Parked(t.Context(), f.w)
+ if err != nil {
+ t.Fatalf("Parked: %v", err)
+ }
+ return ok
+ }
+ if parked() {
+ t.Fatal("parked before any work")
+ }
+ stop := errors.New("crash")
+ crashAt := func(step string, n int) {
+ t.Helper()
+ seen := 0
+ f.e.After = func(s string) error {
+ if s == step {
+ seen++
+ if seen == n {
+ return stop
+ }
+ }
+ return nil
+ }
+ if err := f.e.Run(t.Context(), f.w); !errors.Is(err, stop) {
+ t.Fatalf("crash at %s: %v", step, err)
+ }
+ f.e.After = nil
+ }
+ crashAt("manifest", 1)
+ if parked() {
+ t.Fatal("parked with a manifest and nothing deleted")
+ }
+ crashAt("delete", 1)
+ if parked() {
+ t.Fatal("parked with versions left to delete")
+ }
+ // Resume through every delete, stopping at the verification: the
+ // manifest is there and everything in it is gone, but the obligation
+ // is not acknowledged.
+ crashAt("verify", 1)
+ if !parked() {
+ t.Fatal("not parked after every listed version is gone")
+ }
+ obs, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host)
+ if err != nil || len(obs.Claim.Obligations) != 1 {
+ t.Fatalf("Parked must not acknowledge: %v %v", obs.Claim.Obligations, err)
+ }
+ // A version the manifest does not list is not in the deletion set: the
+ // retirement's own verification pass handles it, Parked only reports on
+ // what the manifest promised.
+ f.b.Put("data", f.ob.Park.Location+"/0000/late", []byte("late"))
+ if !parked() {
+ t.Fatal("an unlisted late version changed the manifest's answer")
+ }
+}
+
+func TestParkedNeverGuesses(t *testing.T) {
+ f := newFixture(t)
+ f.seed()
+ stop := errors.New("crash")
+ f.e.After = func(s string) error {
+ if s == "verify" {
+ return stop
+ }
+ return nil
+ }
+ if err := f.e.Run(t.Context(), f.w); !errors.Is(err, stop) {
+ t.Fatal(err)
+ }
+ f.e.After = nil
+ boom := errors.New("offline")
+ for _, fail := range []string{"list", "get"} {
+ f.b.FailOn = func(op, key string) error {
+ if op == fail {
+ return boom
+ }
+ return nil
+ }
+ ok, err := f.e.Parked(t.Context(), f.w)
+ if ok || err == nil {
+ t.Fatalf("%s failure answered %v, %v", fail, ok, err)
+ }
+ }
+ f.b.FailOn = nil
+ if ok, err := f.e.Parked(t.Context(), f.w); !ok || err != nil {
+ t.Fatalf("recovered read: %v %v", ok, err)
+ }
+ // A chore that is not in the claim cannot be verified from its manifest.
+ if ok, err := f.e.Parked(t.Context(), Work{f.w.Host, "nope", f.w.Op}); ok || !errors.Is(err, ErrChanged) {
+ t.Fatalf("unknown chore: %v %v", ok, err)
+ }
+ // A rotated retirement has no manifest yet: not parked, and not an error.
+ obs, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = hostclaim.Update(t.Context(), f.e.Claims, obs, func(c *hostclaim.Claim) error {
+ c.Obligations[0].Retirement = "rt-other"
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if ok, err := f.e.Parked(t.Context(), f.w); ok || err != nil {
+ t.Fatalf("a retirement with no manifest answered %v, %v", ok, err)
+ }
+ if ok, err := (Engine{}).Parked(t.Context(), f.w); ok || !errors.Is(err, ErrUnsafe) {
+ t.Fatalf("unconfigured engine: %v %v", ok, err)
+ }
+}
+
+// A move's retirement goes through the same engine as any other: queued by
+// hostclaim.QueueLocationPark on the existing park-replica kind, archived
+// under parked/<incarnation>/<host>/<retirement>/, and the manifest's Op is
+// the move id (F3 reads it).
+func TestEngineArchivesAMoveRetirementUnderParked(t *testing.T) {
+ f := newFixture(t)
+ const move = "mv_abcdefghijklmnopqrstuvwxyz"
+ scope := *f.ob.Park
+ n := 0
+ newID := func() string { n++; return fmt.Sprintf("q%d", n) }
+ obs, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = hostclaim.Update(t.Context(), f.e.Claims, obs, func(c *hostclaim.Claim) error {
+ c.Op.ID = move
+ c.Obligations = nil
+ return hostclaim.QueueLocationPark(move, "ie", "", scope, newID)(c)
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ obs, err = hostclaim.Read(t.Context(), f.e.Claims, f.w.Host)
+ if err != nil || len(obs.Claim.Obligations) != 1 {
+ t.Fatal(obs.Claim.Obligations, err)
+ }
+ ob := obs.Claim.Obligations[0]
+ f.ob, f.w = ob, Work{f.w.Host, ob.ID, move}
+ id := f.b.Put("data", scope.Location+"/0000/x.ltx", []byte("ltx"))
+ if err := f.e.Run(t.Context(), f.w); err != nil {
+ t.Fatal(err)
+ }
+ m, err := f.e.manifest(t.Context(), f.w, ob)
+ if err != nil || m == nil {
+ t.Fatalf("manifest: %v %v", m, err)
+ }
+ if m.Op != move || m.Retirement != ob.Retirement || m.Location != scope.Location {
+ t.Fatalf("manifest: %+v", m)
+ }
+ want := "parked/" + scope.Incarnation + "/" + f.w.Host + "/" + ob.Retirement + "/instances/0000/x.ltx@" + url.QueryEscape(id)
+ if len(m.Archive) != 1 || m.Archive[0].Destination.Key != want {
+ t.Fatalf("archive %+v, want %s", m.Archive, want)
+ }
+ f.done(t)
+}