carlos / platform Public

park helpers, fix round 1: location parks only where the region is retired; scope equals holder.location; Parked lists each key once

QueueLocationPark is legal for the source's region at landing/committed and
a rolled-back target's region at restoring/released or claimed; anything else
is ErrWrongState with nothing queued. Location and residue scopes must equal
holder.location after trimming one trailing slash. Parked groups the deletion
set by key.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 7ba166f412b71bc9c28ced582ebd0c3ef8dcf93e parent 015da00
4 files changed, +250 −29
  • internal/hostclaim/move.go +71 −16
  • internal/hostclaim/move_test.go +117 −8
  • internal/park/remote.go +16 −4
  • internal/park/remote_test.go +46 −1
diff --git a/internal/hostclaim/move.go b/internal/hostclaim/move.go
index 98956e59e..0199d6a0b 100644
--- a/internal/hostclaim/move.go
+++ b/internal/hostclaim/move.go
@@ -321,18 +321,31 @@ func sameParkScope(a, b ParkScope) bool {
// 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 {
+func parkQueueGuard(c *Claim, opID, region string, scope ParkScope, newID func() string) (ParkScope, error) {
if err := RequireOp(opID)(c); err != nil {
- return err
+ return scope, 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)
+ return scope, fmt.Errorf("%w: a move's park names its region and holder and has a way to mint ids", ErrMalformed)
}
+ scope.Location = strings.TrimSuffix(scope.Location, "/")
if err := scope.Validate(c.Host); err != nil {
- return err
+ return scope, err
}
if scope.Incarnation != c.Holder.Incarnation {
- return fmt.Errorf("%w: the park's incarnation is not this holder's", ErrMalformed)
+ return scope, fmt.Errorf("%w: the park's incarnation is not this holder's", ErrMalformed)
+ }
+ return scope, nil
+}
+
+// requireHolderLocation: location and residue parks name holder.location
+// (§6.4), in whichever form the holder uses, not the other one.
+func requireHolderLocation(c *Claim, scope ParkScope) error {
+ if isStagingLocation(scope.Location) {
+ return fmt.Errorf("%w: a location park is not a staging lane", ErrMalformed)
+ }
+ if scope.Location != strings.TrimSuffix(c.Holder.Location, "/") {
+ return fmt.Errorf("%w: the park names %q, the holder's location is %q", ErrMalformed, scope.Location, c.Holder.Location)
}
return nil
}
@@ -366,18 +379,58 @@ func queueLocalPark(c *Claim, opID, box string, newID func() string) {
c.Obligations = append(c.Obligations, Obligation{ID: newID(), Op: opID, Kind: "park-local", Box: box, Retirement: newID(), Owner: "box:" + box})
}
+// Claim states in which a region's whole location is retired (§6.4, §7.8):
+// the landing after the commit, and a rollback from the target's release.
+const (
+ phaseLandingCommitted = "committed"
+ phaseRestoringReleased = "released"
+ phaseRestoringClaimed = "claimed"
+)
+
+// requireRetiredRegion is QueueLocationPark's lifecycle check. A whole
+// location is parked only where the region has stopped being authoritative:
+// - the source's region (any region but the move's target) once the op is
+// landing/committed;
+// - the target's region once the op is restoring and the target has
+// released (released, or claimed by the source).
+//
+// The source before the commit and the target after it are live, and so is
+// every other state, which is ErrWrongState. A box, when named, is the retiring
+// side's box. The claim records the target region and both boxes at the start
+// of the move (Intent), not the source's region, so a region that is neither
+// is not told apart from the source here.
+func requireRetiredRegion(c *Claim, region, box string) error {
+ to := c.Op.Intent["to"]
+ switch {
+ case c.Op.Kind == KindLanding && c.Op.Phase == phaseLandingCommitted:
+ if to != "" && region != to && (box == "" || box == c.Op.Intent["source"]) {
+ return nil
+ }
+ case c.Op.Kind == KindRestoring && (c.Op.Phase == phaseRestoringReleased || c.Op.Phase == phaseRestoringClaimed):
+ if to != "" && region == to && (box == "" || box == c.Op.Intent["target"]) {
+ return nil
+ }
+ }
+ return fmt.Errorf("%w: a location park in %s is not legal for %s/%q (the source's after the commit, a rolled-back target's after its release)", ErrWrongState, region, c.Op.Kind, c.Op.Phase)
+}
+
// 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).
+// target's at cleaning (requireRetiredRegion; any other state is
+// ErrWrongState with nothing queued). The scope is holder.location, exactly.
+// 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 {
+ var err error
+ if scope, 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 := requireHolderLocation(c, scope); err != nil {
+ return err
+ }
+ if err := requireRetiredRegion(c, region, box); err != nil {
+ return err
}
if _, err := queueReplicaPark(c, opID, region, scope, newID); err != nil {
return err
@@ -397,7 +450,8 @@ func QueueLocationPark(opID, region, box string, scope ParkScope, newID func() s
// (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 {
+ var err error
+ if scope, err = parkQueueGuard(c, opID, region, scope, newID); err != nil {
return err
}
// Restates moves.ScopeLocation(host, op); moves imports hostclaim, so
@@ -405,7 +459,7 @@ func QueueStagingPark(opID, region string, scope ParkScope, newID func() string)
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)
+ _, err = queueReplicaPark(c, opID, region, scope, newID)
return err
}
}
@@ -475,11 +529,12 @@ func CompleteLocalPark(opID, box string, earlierStamp uint64) func(*Claim) error
// 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 {
+ var err error
+ if location, 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)
+ if err := requireHolderLocation(c, location); err != nil {
+ return err
}
for _, g := range generations {
if g == "" {
diff --git a/internal/hostclaim/move_test.go b/internal/hostclaim/move_test.go
index baa2eb24a..68fc2dfb1 100644
--- a/internal/hostclaim/move_test.go
+++ b/internal/hostclaim/move_test.go
@@ -695,6 +695,12 @@ func parkFixture(t *testing.T) (*Claim, func(location string) ParkScope) {
return c, scope
}
+// inState puts a claim in the op state a park helper's lifecycle check reads:
+// the kind and phase, as the transitions that own them would leave it.
+func inState(c *Claim, kind, phase string) {
+ c.Op.Kind, c.Op.Phase = kind, phase
+}
+
// 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) {
@@ -708,6 +714,10 @@ func TestLocationParkIsAParkReplicaTheStoreAccepts(t *testing.T) {
}
inc := o.Claim.Holder.Incarnation
scope := ParkScope{Location: "instances/" + testHost + "/" + inc, Incarnation: inc, EvidenceAt: now, RequestLimit: 2 * time.Minute}
+ // The commit's claim state: the source's location is parked from here.
+ if o, err = Update(ctx, s, o, func(c *Claim) error { inState(c, KindLanding, "committed"); return nil }); err != nil {
+ t.Fatal(err)
+ }
o, err = Update(ctx, s, o, QueueLocationPark(moveA, "ie", "adi-knitting", scope, counter()))
if err != nil {
t.Fatal(err)
@@ -819,8 +829,9 @@ 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)
+ inState(c, KindRestoring, "released")
lane := scope("moves/" + c.Host + "/" + moveA)
- loc := scope("instances/" + c.Host + "/" + c.Holder.Incarnation)
+ loc := scope(c.Holder.Location)
n := counter()
for i := 0; i < 2; i++ {
if err := QueueStagingPark(moveA, "uk", lane, n)(c); err != nil {
@@ -844,15 +855,112 @@ func TestLocationParkAndStagingParkCoexistInOneRegion(t *testing.T) {
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)
+}
+
+// A whole location is retired only where the region has stopped being
+// authoritative: the source after the commit (landing/committed), and a
+// rolled-back target (restoring, from its release). Every other state, and
+// the other region in either state, is refused with nothing queued and no
+// ids spent.
+func TestLocationParkOnlyWhereTheRegionIsRetired(t *testing.T) {
+ type tc struct {
+ kind, phase, region, box string
+ allowed bool
+ }
+ cases := map[string]tc{
+ "source after the commit": {KindLanding, "committed", "ie", "ie-box", true},
+ "source after the commit, region only": {KindLanding, "committed", "ie", "", true},
+ "rolled-back target at cleaning": {KindRestoring, "released", "uk", "uk-box", true},
+ "rolled-back target after the source": {KindRestoring, "claimed", "uk", "uk-box", true},
+ "source before the cut": {KindMoving, PhaseMoving, "ie", "ie-box", false},
+ "source sealed": {KindMoving, PhaseSealed, "ie", "ie-box", false},
+ "source in the interim abort": {KindMoving, PhaseAborting, "ie", "ie-box", false},
+ "source while the landing checks": {KindLanding, "checking", "ie", "ie-box", false},
+ "source while the landing probes": {KindLanding, "probing", "ie", "ie-box", false},
+ "source while reverting": {KindReverting, "evidence", "ie", "ie-box", false},
+ "source after the rollback began": {KindRestoring, "released", "ie", "ie-box", false},
+ "target after the commit": {KindLanding, "committed", "uk", "uk-box", false},
+ "target before the commit": {KindLanding, "probing", "uk", "uk-box", false},
+ "target before its release": {KindRestoring, "releasing", "uk", "uk-box", false},
+ "target still serving": {KindServing, "", "uk", "uk-box", false},
+ "the target's box from the source": {KindLanding, "committed", "ie", "uk-box", false},
+ "the source's box from a rolled-back uk": {KindRestoring, "released", "uk", "ie-box", false},
+ }
+ for name, tc := range cases {
+ t.Run(name, func(t *testing.T) {
+ c, scope := parkFixture(t)
+ inState(c, tc.kind, tc.phase)
+ ids := 0
+ newID := func() string { ids++; return fmt.Sprintf("p%d", ids) }
+ before := mustJSON(t, c)
+ err := QueueLocationPark(moveA, tc.region, tc.box, scope(c.Holder.Location), newID)(c)
+ if tc.allowed {
+ if err != nil || len(c.Obligations) == 0 {
+ t.Fatalf("refused: %v", err)
+ }
+ c.Gen++
+ if err := c.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ return
+ }
+ if !errors.Is(err, ErrWrongState) || ids != 0 || string(mustJSON(t, c)) != string(before) {
+ t.Fatalf("err=%v ids=%d, claim changed=%v", err, ids, string(mustJSON(t, c)) != string(before))
+ }
+ })
+ }
+}
+
+// Location and residue parks name holder.location, whichever form the
+// holder uses (spec §6.4): the legacy instances/<host> or the
+// incarnation-scoped instances/<host>/<inc>, never the other one. A trailing
+// slash is normalised away on both sides.
+func TestParkLocationMustBeTheHoldersLocation(t *testing.T) {
+ for _, form := range []string{"scoped", "legacy"} {
+ t.Run(form, func(t *testing.T) {
+ c, scope := parkFixture(t)
+ scoped := "instances/" + c.Host + "/" + c.Holder.Incarnation
+ legacy := "instances/" + c.Host
+ own, other := scoped, legacy
+ if form == "legacy" {
+ own, other = legacy, scoped
+ }
+ c.Holder.Location = own
+ inState(c, KindLanding, "committed")
+ if err := c.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ ids := 0
+ newID := func() string { ids++; return fmt.Sprintf("p%d", ids) }
+ before := mustJSON(t, c)
+ for name, fn := range map[string]func(*Claim) error{
+ "location": QueueLocationPark(moveA, "ie", "ie-box", scope(other), newID),
+ "residue": QueueResidueParks(moveA, "ie", scope(other), []string{"gen-1"}, newID),
+ "slash/other": QueueLocationPark(moveA, "ie", "", scope(other+"/"), newID),
+ } {
+ if err := fn(c); !errors.Is(err, ErrMalformed) && !errors.Is(err, ErrWrongState) || ids != 0 || string(mustJSON(t, c)) != string(before) {
+ t.Errorf("%s: err=%v ids=%d", name, err, ids)
+ }
+ }
+ // The holder's own form is accepted, with or without a trailing slash,
+ // and what is stored has none.
+ if err := QueueLocationPark(moveA, "ie", "", scope(own+"/"), newID)(c); err != nil {
+ t.Fatalf("own location with a slash: %v", err)
+ }
+ if err := QueueResidueParks(moveA, "ie", scope(own), []string{"gen-1"}, newID)(c); err != nil {
+ t.Fatalf("own location residue: %v", err)
+ }
+ if len(c.Obligations) != 2 || c.Obligations[0].Park.Location != own || c.Obligations[1].Park.Location != own {
+ t.Fatalf("stored: %+v", c.Obligations)
+ }
+ })
}
}
func TestLocationParkRefusals(t *testing.T) {
c, scope := parkFixture(t)
- loc := scope("instances/" + c.Host + "/" + c.Holder.Incarnation)
+ inState(c, KindLanding, "committed")
+ loc := scope(c.Holder.Location)
lane := scope("moves/" + c.Host + "/" + moveA)
bad := loc
bad.RequestLimit = 0
@@ -864,10 +972,10 @@ func TestLocationParkRefusals(t *testing.T) {
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()),
+ "an invalid scope": QueueLocationPark(moveA, "ie", "", 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),
+ "no id source": QueueLocationPark(moveA, "ie", "", loc, nil),
} {
before := mustJSON(t, c)
if err := fn(c); err == nil {
@@ -1001,6 +1109,7 @@ func TestParkQueueHelpersPreserveTheRestOfTheClaim(t *testing.T) {
if err := BeginMove(moveA, "ops", "ie-box", "enr-ie", "uk-box", "uk", now)(c); err != nil {
t.Fatal(err)
}
+ inState(c, KindLanding, "committed")
if err := c.Validate(); err != nil {
t.Fatalf("fixture: %v", err)
}
@@ -1010,7 +1119,7 @@ func TestParkQueueHelpersPreserveTheRestOfTheClaim(t *testing.T) {
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)
+ loc := scope(base.Holder.Location)
lane := scope("moves/" + base.Host + "/" + moveA)
cases := map[string]struct {
fn func(*Claim) error
diff --git a/internal/park/remote.go b/internal/park/remote.go
index 5ca78e372..eaac7f211 100644
--- a/internal/park/remote.go
+++ b/internal/park/remote.go
@@ -607,18 +607,30 @@ func (e Engine) Parked(ctx context.Context, w Work) (bool, error) {
if err != nil || m == nil {
return false, err
}
- // Each listed key is listed on its own: a version that is still there
+ // Each distinct listed key is listed once: a version that is still there
// shows up under its key, and a listing that fails is an error.
+ listed := map[string]map[string]bool{}
+ var keys []string
for _, d := range m.Delete {
- vs, err := e.Store.ListVersions(ctx, e.Bucket, d.Key)
+ if listed[d.Key] == nil {
+ listed[d.Key] = map[string]bool{}
+ keys = append(keys, d.Key)
+ }
+ listed[d.Key][d.VersionID] = true
+ }
+ for _, key := range keys {
+ vs, err := e.Store.ListVersions(ctx, e.Bucket, key)
if err != nil {
return false, err
}
for _, v := range vs {
- if v.Key == d.Key && v.VersionID == "" {
+ if v.Key != key {
+ continue
+ }
+ if v.VersionID == "" {
return false, ErrUnsafe
}
- if v.Key == d.Key && v.VersionID == d.VersionID {
+ if listed[key][v.VersionID] {
return false, nil
}
}
diff --git a/internal/park/remote_test.go b/internal/park/remote_test.go
index 0e408cd34..84a7a978c 100644
--- a/internal/park/remote_test.go
+++ b/internal/park/remote_test.go
@@ -637,6 +637,50 @@ func TestParkedReportsManifestAndDeletions(t *testing.T) {
}
}
+// A key with many versions is listed once, however many of its versions are
+// in the deletion set.
+func TestParkedListsEachKeyOnce(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
+ m, err := f.e.manifest(t.Context(), f.w, f.ob)
+ if err != nil || m == nil {
+ t.Fatal(m, err)
+ }
+ perKey := map[string]int{}
+ for _, d := range m.Delete {
+ perKey[d.Key]++
+ }
+ if len(m.Delete) < 3 || len(perKey) != 1 {
+ t.Fatalf("the fixture must put several versions under one key: %v", perKey)
+ }
+ // The manifest and chore reads list too; count only the deletion keys.
+ lists := 0
+ f.b.FailOn = func(op, key string) error {
+ if op == "list" && perKey[key] > 0 {
+ lists++
+ }
+ return nil
+ }
+ if ok, err := f.e.Parked(t.Context(), f.w); !ok || err != nil {
+ t.Fatalf("parked: %v %v", ok, err)
+ }
+ f.b.FailOn = nil
+ if lists != 1 {
+ t.Fatalf("a key with %d deleted versions was listed %d times", len(m.Delete), lists)
+ }
+}
+
func TestParkedNeverGuesses(t *testing.T) {
f := newFixture(t)
f.seed()
@@ -707,7 +751,8 @@ func TestEngineArchivesAMoveRetirementUnderParked(t *testing.T) {
t.Fatal(err)
}
_, err = hostclaim.Update(t.Context(), f.e.Claims, obs, func(c *hostclaim.Claim) error {
- c.Op.ID = move
+ // The source's region after the commit: the claim's landing/committed.
+ c.Op = hostclaim.Op{Kind: hostclaim.KindLanding, ID: move, Phase: "committed", Intent: map[string]string{"to": "uk"}}
c.Obligations = nil
return hostclaim.QueueLocationPark(move, "ie", "", scope, newID)(c)
})