carlos / platform Public

settings-4 task 8b: a holder location's build state is live state, never parked or misclassified

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev f7c9259cb23ef10e78f49f467021f750f19a557e parent db9f9ea
9 files changed, +547 −15
  • internal/activator/backend_exec_test.go +4 −2
  • internal/host/movesbuildstatepark_test.go +263 −0
  • internal/host/movesevidence.go +4 −2
  • internal/host/movesinstall.go +6 −0
  • internal/host/moveslandpark.go +13 −0
  • internal/host/movesrollback.go +35 −8
  • internal/park/buildstate_test.go +200 −0
  • internal/park/remote.go +17 −1
  • internal/park/sweep.go +5 −2
diff --git a/internal/activator/backend_exec_test.go b/internal/activator/backend_exec_test.go
index 8d86084f4..939a3e0ed 100644
--- a/internal/activator/backend_exec_test.go
+++ b/internal/activator/backend_exec_test.go
@@ -144,8 +144,10 @@ func TestExecBackendStartChildHandsTheHost(t *testing.T) {
}
// The staging fallback (issue #331): a host whose ordinary prefix is empty
-// restores from a move's staged copy — promoted server-side, dropped after
-// a clean restore — and the ordinary prefix ALWAYS wins when it exists.
+// restores from a move's staged copy, promoted server-side and dropped
+// after a clean restore. A staged copy present beside an own lineage wins:
+// it is promoted over the ordinary prefix (the boomerang case below;
+// ruling R-STAGEDREAD-2).
func TestRestoreStagingFallback(t *testing.T) {
ctx := context.Background()
b := testExecBackend(t)
diff --git a/internal/host/movesbuildstatepark_test.go b/internal/host/movesbuildstatepark_test.go
new file mode 100644
index 000000000..949fc60c8
--- /dev/null
+++ b/internal/host/movesbuildstatepark_test.go
@@ -0,0 +1,263 @@
+package host
+
+import (
+ "context"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/lease"
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/park"
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+// Ruling R-PARK (settings-4 Task 8b): <holder.Location>/build-state.json is
+// live state beside the lease, the generation marker's twin. Every path
+// that lists the holder location passes it over where it passes over the
+// marker; anywhere else it is residue like any other object.
+
+// A landing onto a location that already holds a build state (an earlier
+// stay's) parks the earlier stay's generations, passes the build state over
+// as it passes the marker over, and completes: the install then places the
+// moved state over it.
+func TestALandingOntoALocationHoldingABuildStateCompletes(t *testing.T) {
+ h := newLandingHarness(t)
+ storeState(h.phase1Harness, "data-ie", cutRoot+"/build-state.json", "v6")
+ h.flip(t)
+ keys := h.residue(t)
+ dst := cutRoot + "/build-state.json"
+ if _, err := h.vs.PutIfAbsent(context.Background(), "data-uk", dst, []byte(p1StateBody("v3"))); err != nil {
+ t.Fatal(err)
+ }
+ for i := 0; i < 4 && h.read(t, p1Host).Move.LandingPhase != provision.LandRestored; i++ {
+ h.land(t)
+ }
+ h.installed(t)
+ if got := h.f.buckets["data-uk"][dst].body; got != p1StateBody("v6") {
+ t.Fatalf("the location holds %q, want the moved v6", got)
+ }
+ for _, k := range keys {
+ if _, ok := h.f.buckets["data-uk"][k]; ok && k != cutRoot+"/generation.json" {
+ t.Fatalf("the earlier stay's %s was not parked", k)
+ }
+ }
+ for k := range h.f.buckets["data-uk"] {
+ if strings.HasPrefix(k, "parked/") && strings.Contains(k, "/build-state.json@") {
+ t.Fatalf("the location's build state was parked: %s", k)
+ }
+ }
+}
+
+// A dirty return whose source location holds the pre-move build state
+// classifies the location, parks the pre-move generations and installs;
+// the returned state is placed over the pre-move one.
+func TestADirtyReturnOverAPreMoveBuildStateClassifies(t *testing.T) {
+ h := newReturnHarness(t, true)
+ ctx := context.Background()
+ if _, err := h.vs.PutIfAbsent(ctx, "data-ie", cutRoot+"/build-state.json", []byte(p1StateBody("v5"))); err != nil {
+ t.Fatal(err)
+ }
+ key := moves.BuildStateKey(p1Host, p1RB)
+ ver, err := h.vs.PutIfAbsent(ctx, "data-ie", key, []byte(p1StateBody("v8")))
+ if err != nil {
+ t.Fatal(err)
+ }
+ h.seal.State = []moves.SealedObject{{Key: key, VersionID: ver, Size: int64(len(p1StateBody("v8")))}}
+ if err := h.seal.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ pinned := h.read(t, p1Host).Move.Inputs
+ if refused, err := h.install(t); err != nil || refused != "" {
+ t.Fatalf("install: %q %v", refused, err)
+ }
+ h.installedReturn(t, pinned)
+ if got := h.f.buckets["data-ie"][cutRoot+"/build-state.json"].body; got != p1StateBody("v8") {
+ t.Fatalf("the source's location holds %q, want the returned v8", got)
+ }
+}
+
+// TestMovesHolderBuildStateMatchesTheEngine keeps the host's twin key in
+// step with the park engine's, in the spirit of
+// TestMovesParkLockMatchesTheEngine: park.Decide is the oracle. In the
+// authoritative region, with a classifier that places every key (so a
+// skip is the engine's alone), Decide makes no candidate of a one-key
+// census exactly when the host says the key is the holder location's
+// build state. The same file name anywhere else is a candidate.
+func TestMovesHolderBuildStateMatchesTheEngine(t *testing.T) {
+ for name, bare := range map[string]bool{"scoped": false, "bare": true} {
+ t.Run(name, func(t *testing.T) {
+ f := newEvFixture(t)
+ if bare {
+ f.bare(t)
+ }
+ obs := f.observe(t)
+ view := park.View{ClaimETag: obs.ETag, Region: "ie", AuthoritativeRegion: "ie", MarkerKnown: true, MarkerGeneration: evMoveID,
+ Marker: park.Ref{Key: f.location + "/generation.json"},
+ GenerationOf: func(r park.Ref) (string, bool) {
+ if g, ok := landingGenerationOf(f.location, r.Key); ok {
+ return g, true
+ }
+ return "stale", true
+ }}
+ other := strings.Repeat("ab", 32)
+ keys := []string{
+ f.location + "/build-state.json",
+ f.location + "/g-old/build-state.json",
+ f.location + "/build-state.json.old",
+ f.location + "/state/build-state.json",
+ "instances/" + evHost + "/" + other + "/build-state.json",
+ "moves/" + evHost + "/build-state.json",
+ "moves/" + evHost + "/" + evMoveID + "/state/build-state.json",
+ }
+ if bare {
+ keys = append(keys, "instances/"+evHost+"/"+f.inc+"/build-state.json")
+ } else {
+ keys = append(keys, "instances/"+evHost+"/build-state.json")
+ }
+ skipped := 0
+ for _, key := range keys {
+ d, err := park.Decide(obs, view, []lease.ObjectVersion{{Key: key, VersionID: "v1"}, {Key: key, VersionID: "v0"}}, f.inc)
+ if err != nil {
+ t.Fatalf("%s: %v", key, err)
+ }
+ engine, mirror := len(d.Candidates) == 0, movesHolderBuildState(f.location, key)
+ if engine != mirror {
+ t.Errorf("%s: the engine skips it %v, the host's twin key says %v", key, engine, mirror)
+ }
+ if engine {
+ skipped++
+ }
+ }
+ if skipped != 1 {
+ t.Errorf("the engine skipped %d keys, want the holder location's build state alone", skipped)
+ }
+ })
+ }
+}
+
+// The close need not vouch for the live location's build state: it is the
+// location's own, as the marker is. A lane's copy written after the close
+// still has an unknown writer.
+func TestTheLiveLocationsBuildStateNeedsNoVouching(t *testing.T) {
+ for name, bare := range map[string]bool{"scoped": false, "bare": true} {
+ t.Run(name, func(t *testing.T) {
+ f := newEvFixture(t)
+ if bare {
+ f.bare(t)
+ }
+ f.lane(evMoveID, "move.json")
+ f.bucket.Put("data", f.genKey(evMoveID), []byte("live"))
+ f.bucket.Put("data", f.location+"/generation.json", []byte("marker"))
+ f.bucket.Put("data", f.location+"/build-state.json", []byte("older"))
+ f.bucket.Put("data", f.location+"/build-state.json", []byte("current"))
+ if _, _, err := f.provider().VerifyRetirement(t.Context(), f.observe(t), f.census(t)); err != nil {
+ t.Fatalf("VerifyRetirement refused the live build state: %v", err)
+ }
+ })
+ }
+ t.Run("a lane's state written after the close", func(t *testing.T) {
+ f := newEvFixture(t)
+ f.rec.LastMove.At = time.Date(2026, 10, 4, 0, 0, 0, 0, time.UTC) // before every write
+ f.lane("mv_unknown", moves.BuildStateSuffix)
+ bothRefuse(t, f, true)
+ })
+}
+
+// returnBeforeInstall is a rollback harness whose landing stopped at
+// building, before the install placed anything, with the forward seal's
+// state edited by edit.
+func returnBeforeInstall(t *testing.T, edit func(h *rollbackHarness, s *moves.SealedObject)) *rollbackHarness {
+ t.Helper()
+ h := &rollbackHarness{landingHarness: newLandingHarness(t)}
+ h.f.keepHistory = true
+ storeState(h.phase1Harness, "data-ie", cutRoot+"/build-state.json", "v6")
+ h.flip(t)
+ h.landAt = "after-record-" + provision.LandBuilding
+ h.crashLand(t)
+ h.uncover(t)
+ rewriteP1Claim(t, h.s, func(c *hostclaim.Claim) {
+ s, err := journalledSeal(c)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(s.State) != 1 {
+ t.Fatalf("seal state %+v", s.State)
+ }
+ edit(h, &s.State[0])
+ c.Op.Journal[hostclaim.JournalSeal] = s.Encode()
+ })
+ h.rollBack(t)
+ h.rollbackPass(t)
+ return h
+}
+
+// Task 8 re-review minor 1: a return before the install reads the forward
+// seal's state only at the move's own lane key, and only at the size the
+// seal names, as copyAsGiven reads every other sealed object.
+func TestAReturnBeforeTheInstallReadsOnlyItsOwnSealedState(t *testing.T) {
+ refused := func(t *testing.T, h *rollbackHarness) {
+ t.Helper()
+ rb := h.rb(t)
+ if rb.TargetPhase == provision.RbTargetReturned {
+ t.Fatalf("the return sealed: %+v", rb)
+ }
+ if _, ok := h.f.buckets["data-ie"][moves.BuildStateKey(p1Host, rb.ID)]; ok {
+ t.Fatal("a build state went back")
+ }
+ }
+ t.Run("the control returns", func(t *testing.T) {
+ h := returnBeforeInstall(t, func(*rollbackHarness, *moves.SealedObject) {})
+ if rb := h.rb(t); rb.TargetPhase != provision.RbTargetReturned {
+ t.Fatalf("rollback %+v", rb)
+ }
+ })
+ t.Run("another lane's key", func(t *testing.T) {
+ h := returnBeforeInstall(t, func(h *rollbackHarness, s *moves.SealedObject) {
+ key := moves.BuildStateKey(p1Host, "mv_other")
+ body := p1StateBody("v4")
+ ver, err := h.vs.PutIfAbsent(context.Background(), "data-uk", key, []byte(body))
+ if err != nil {
+ t.Fatal(err)
+ }
+ s.Key, s.VersionID, s.Size = key, ver, int64(len(body))
+ })
+ refused(t, h)
+ })
+ t.Run("a size the version is not", func(t *testing.T) {
+ h := returnBeforeInstall(t, func(_ *rollbackHarness, s *moves.SealedObject) { s.Size++ })
+ refused(t, h)
+ })
+}
+
+// Task 8 re-review minor 2: once the landing reached probing a writer may
+// have recorded over the placed state, so the target's location is the
+// source of the returned state even if the landing's journal lost its
+// install step (installManifest rebuilds it then; the build state is not
+// rebuilt).
+func TestAReturnPastProbingTakesTheTargetsStateWithoutTheInstallStep(t *testing.T) {
+ h := &rollbackHarness{landingHarness: newLandingHarness(t)}
+ storeState(h.phase1Harness, "data-ie", cutRoot+"/build-state.json", "v6")
+ h.flip(t)
+ h.land(t)
+ h.installed(t)
+ h.step(t, provision.LandRestored, provision.LandProbing)
+ h.tpos = h.n
+ storeState(h.phase1Harness, "data-uk", cutRoot+"/build-state.json", "v8")
+ j := h.landJournal(t)
+ delete(j.Steps, landStepInstall)
+ if err := j.Write(h.tdir); err != nil {
+ t.Fatal(err)
+ }
+ h.uncover(t)
+ h.rollBack(t)
+ h.rollbackPass(t)
+ rb := h.rb(t)
+ if !rb.Dirty || rb.TargetPhase != provision.RbTargetReturned {
+ t.Fatalf("rollback %+v", rb)
+ }
+ if _, body := returnSealState(t, h, rb.ID); body != p1StateBody("v8") {
+ t.Fatalf("the return lane holds %q, want the target's v8", body)
+ }
+}
diff --git a/internal/host/movesevidence.go b/internal/host/movesevidence.go
index 20d7ee717..13d426a22 100644
--- a/internal/host/movesevidence.go
+++ b/internal/host/movesevidence.go
@@ -159,7 +159,9 @@ func (e movesRetirementEvidence) VerifyRetirement(ctx context.Context, o hostcla
// leaves no such admission, so no generation is vouched for. Objects are
// classified as the landing's view classifies them (landingGenerationOf),
// after the lock objects the engine itself skips (movesParkLock, fix round
-// 2): the writer's lease stands at every live location.
+// 2): the writer's lease stands at every live location. The build state
+// beside it (movesHolderBuildState, ruling R-PARK) is the location's own,
+// the marker's twin, and needs no vouching either.
func (r movesRetired) retiredGenerations(versions []lease.ObjectVersion) error {
h := r.holder
refuse := func(gen, format string, args ...any) error {
@@ -179,7 +181,7 @@ func (r movesRetired) retiredGenerations(versions []lease.ObjectVersion) error {
}
}
for _, v := range versions {
- if loc, ok := movesInstancesLocation(r.host, v.Key); !ok || loc != r.location || movesParkLock(r.host, v.Key) {
+ if loc, ok := movesInstancesLocation(r.host, v.Key); !ok || loc != r.location || movesParkLock(r.host, v.Key) || movesHolderBuildState(r.location, v.Key) {
continue
}
gen, ok := landingGenerationOf(r.location, v.Key)
diff --git a/internal/host/movesinstall.go b/internal/host/movesinstall.go
index a75bab95d..7c9b6afa9 100644
--- a/internal/host/movesinstall.go
+++ b/internal/host/movesinstall.go
@@ -432,6 +432,12 @@ func (ir *installRun) parking(ctx context.Context) (bool, error) {
if o.Key == location+"/lock.json" {
continue
}
+ if movesHolderBuildState(location, o.Key) {
+ // The build state beside the lease is the marker's twin
+ // (ruling R-PARK): no generation's, never residue here.
+ // The build places the moved state over it.
+ continue
+ }
g, ok := landingGenerationOf(location, o.Key)
if !ok {
return false, fmt.Errorf("%s is not an object of a generation this %s can classify", o.Key, ir.src.Name)
diff --git a/internal/host/moveslandpark.go b/internal/host/moveslandpark.go
index 76244f0b9..645780250 100644
--- a/internal/host/moveslandpark.go
+++ b/internal/host/moveslandpark.go
@@ -13,6 +13,7 @@ import (
"github.com/carlosframework/platform/internal/lease"
"github.com/carlosframework/platform/internal/park"
"github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
)
// The installer's park and lease adapters (moves spec §6.4, §7.6), shared by
@@ -121,6 +122,18 @@ func landingGenerationOf(location, key string) (string, bool) {
return "", false
}
+// movesHolderBuildState reports whether key is the build state beside the
+// lease at the live holder location (release.BuildStateKey; ruling
+// R-PARK): the marker's twin, live state that belongs to no generation.
+// A caller passes it over exactly where it passes over the marker at that
+// location, before classifying (landingGenerationOf stays strict). The
+// same file name anywhere else (a retired location, a moves lane, under a
+// generation) is not it. TestMovesHolderBuildStateMatchesTheEngine keeps
+// it in step with the park engine.
+func movesHolderBuildState(location, key string) bool {
+ return key == release.BuildStateKey(strings.TrimSuffix(location, "/"))
+}
+
func isHexString(s string) bool {
for _, c := range s {
if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') {
diff --git a/internal/host/movesrollback.go b/internal/host/movesrollback.go
index db473e3a0..c238f7dd5 100644
--- a/internal/host/movesrollback.go
+++ b/internal/host/movesrollback.go
@@ -848,12 +848,18 @@ func (r *rollbackRun) copyAsGiven(ctx context.Context, gm *guardedMirror, staged
// returnState is a dirty return's build state (settings spec rev 11
// §4.3a), through the return lane's state/ key and sealed by the version
// written there. Once the target's install has built (its landing journal
-// records the install), the state beside the target's lease at the holder's
-// location is the instance's: the moved state the install placed, with
-// whatever the probe and the target's wakes recorded over it. Before that
-// the target's location holds no state of this move (a return before the
-// building step), so the forward lane's sealed state goes back as given,
-// read by its exact version. Neither holds one: none goes back.
+// records the install), or the landing reached probing (reachedProbing,
+// the predicate the return's lane choice uses: a writer may have run, and
+// a journal that lost the install step is rebuilt by installManifest,
+// which never places the build state again), the state beside the
+// target's lease at the holder's location is the instance's: the moved
+// state the install placed, with whatever the probe and the target's
+// wakes recorded over it. Before both the target's location holds no
+// state of this move that differs from the seal's (a return before the
+// building step, or before any writer ran over the placed copy), so the
+// forward lane's sealed state goes back as given: read at the move's own
+// lane key by its exact version and its sealed size, as copyAsGiven reads
+// the other classes. Neither holds one: none goes back.
func (r *rollbackRun) returnState(ctx context.Context, gm *guardedMirror, h *hostclaim.Holder, staged moves.Seal) ([]moves.SealedObject, error) {
cfg := r.cfg()
host := r.rec.Host
@@ -861,18 +867,21 @@ func (r *rollbackRun) returnState(ctx context.Context, gm *guardedMirror, h *hos
if err != nil {
return nil, err
}
- if !j.Done(landStepInstall) {
+ if !j.Done(landStepInstall) && !r.reachedProbing() {
if len(staged.State) == 0 {
// A lane object an earlier attempt left is not sealed, so the
// source's install never places it.
return nil, nil
}
o := staged.State[0]
+ if want := moves.BuildStateKey(host, r.g.ID); o.Key != want {
+ return nil, fmt.Errorf("%w: the sealed build state %s is not the move's lane key %s", moves.ErrBadSeal, o.Key, want)
+ }
vs, err := cfg.Versioned(ctx, cfg.OwnBucket, cfg.OwnBucketRegion)
if err != nil {
return nil, err
}
- body, err := vs.GetVersion(ctx, cfg.OwnBucket, o.Key, o.VersionID)
+ body, err := readExact(ctx, vs, cfg.OwnBucket, o.Key, o.VersionID, o.Size)
if err != nil {
return nil, fmt.Errorf("read the sealed build state %s: %w", o.Key, err)
}
@@ -902,6 +911,24 @@ func (r *rollbackRun) returnState(ctx context.Context, gm *guardedMirror, h *hos
return []moves.SealedObject{*o}, nil
}
+// readExact reads one exact version of size bytes into memory, as
+// downloadExact does into a file: a version of any other size refuses.
+func readExact(ctx context.Context, vs lease.VersionedStore, bucket, key, version string, size int64) ([]byte, error) {
+ rc, err := vs.OpenVersion(ctx, bucket, key, version)
+ if err != nil {
+ return nil, err
+ }
+ defer rc.Close()
+ body, err := io.ReadAll(io.LimitReader(rc, size+1))
+ if err != nil {
+ return nil, err
+ }
+ if int64(len(body)) != size {
+ return nil, fmt.Errorf("the version is %d bytes, sealed as %d", len(body), size)
+ }
+ return body, nil
+}
+
// downloadExact reads one exact version of size bytes into path.
func downloadExact(ctx context.Context, vs lease.VersionedStore, bucket, key, version string, size int64, path string) error {
rc, err := vs.OpenVersion(ctx, bucket, key, version)
diff --git a/internal/park/buildstate_test.go b/internal/park/buildstate_test.go
new file mode 100644
index 000000000..3ed7c997e
--- /dev/null
+++ b/internal/park/buildstate_test.go
@@ -0,0 +1,200 @@
+package park
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// Ruling R-PARK (settings-4 Task 8b): <holder.Location>/build-state.json,
+// every version of it, is live state beside the lease, the marker's twin.
+// The engine skips it wherever it skips the marker key at the holder
+// location, and refuses a whole-location park of the live holder location
+// because of it, exactly as for the marker. Anywhere else (a retired
+// location, a moves lane) it is residue like any other object.
+
+// TestHolderBuildStateIsNeverACandidate: a sweep never makes the live
+// location's build state a candidate and never parks a version of it; the
+// same key at a retired location is a candidate and is parked with the
+// location, and a lane's copy stays a moves/<host> candidate.
+func TestHolderBuildStateIsNeverACandidate(t *testing.T) {
+ f, obs, v, _ := sweepFixture(t)
+ key := release.BuildStateKey(f.ob.Park.Location)
+ older := f.b.Put("data", key, []byte("older"))
+ current := f.b.Put("data", key, []byte("current"))
+ retiredInc := strings.Repeat("a", 64)
+ retired := "instances/" + f.w.Host + "/" + retiredInc
+ stale := release.BuildStateKey(retired)
+ f.b.Put("data", stale, []byte("stale"))
+ lane := "moves/" + f.w.Host + "/mv_1/state/build-state.json"
+ f.b.Put("data", lane, []byte("lane"))
+ vs, err := f.e.Discover(t.Context(), f.w.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ d, err := Decide(obs, v, vs, f.ob.Park.Incarnation)
+ if err != nil {
+ t.Fatalf("Decide: %v", err)
+ }
+ want := []Candidate{
+ {Location: f.ob.Park.Location, Incarnation: f.ob.Park.Incarnation, Generation: "g0"},
+ {Location: retired, Incarnation: retiredInc},
+ {Location: "moves/" + f.w.Host, Incarnation: f.ob.Park.Incarnation},
+ }
+ if len(d.Alerts) != 0 || len(d.Candidates) != len(want) {
+ t.Fatalf("decision %+v, want %+v", d, want)
+ }
+ for _, w := range want {
+ found := false
+ for _, c := range d.Candidates {
+ found = found || c == w
+ }
+ if !found {
+ t.Fatalf("decision %+v lacks %+v", d.Candidates, w)
+ }
+ }
+ f.e.View = func(_ context.Context, o hostclaim.Observed) (View, error) {
+ x := v
+ x.ClaimETag = o.ETag
+ return x, nil
+ }
+ if _, err := f.e.Sweep(t.Context(), f.w.Host, f.ob.Park.Incarnation, f.ob.Park.EvidenceAt, time.Second); err != nil {
+ t.Fatalf("Sweep: %v", err)
+ }
+ for _, id := range []string{older, current} {
+ if _, err := f.b.GetVersion(t.Context(), "data", key, id); err != nil {
+ t.Fatalf("the live build state version %s was parked: %v", id, err)
+ }
+ }
+ left, err := f.b.ListVersions(t.Context(), "data", retired+"/")
+ if err != nil || len(left) != 0 {
+ t.Fatalf("the retired location still holds %+v (%v)", left, err)
+ }
+}
+
+// twinFixture is a holder location, serving, holding one generation that
+// is not live (g0) beside its build state, with one chore of kind at the
+// holder location, seen from an authoritative region or not.
+func twinFixture(t *testing.T, kind string, authoritative, withState bool) (*fixture, string) {
+ t.Helper()
+ f := newFixture(t)
+ p := f.ob.Park.Location + "/"
+ f.ob.Kind = kind
+ if kind == "park-generation" {
+ f.ob.Generation = "g0"
+ }
+ obs, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := hostclaim.Update(t.Context(), f.e.Claims, obs, func(c *hostclaim.Claim) error {
+ c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: "box"}
+ c.Holder.Generation, c.Holder.Session = "g1", 1
+ c.Holder.Admissions = []hostclaim.AdmissionRecord{{Session: 1, Generation: "g1", Mode: "cold", Region: "ie"}}
+ c.Obligations = []hostclaim.Obligation{f.ob}
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+ f.b.Put("data", p+"g0/data", []byte("g0"))
+ key := release.BuildStateKey(f.ob.Park.Location)
+ if withState {
+ f.b.Put("data", key, []byte("older"))
+ f.b.Put("data", key, []byte("current"))
+ }
+ classify := func(r Ref) (string, bool) {
+ rel, ok := strings.CutPrefix(r.Key, p)
+ if !ok {
+ return "", false
+ }
+ g, _, ok := strings.Cut(rel, "/")
+ return g, ok
+ }
+ f.e.View = func(_ context.Context, o hostclaim.Observed) (View, error) {
+ v := View{ClaimETag: o.ETag, Region: "ie", AuthoritativeRegion: "uk", GenerationOf: classify}
+ if authoritative {
+ v.AuthoritativeRegion, v.MarkerKnown, v.MarkerGeneration = "ie", true, "g1"
+ v.Marker = Ref{Key: p + "generation.json"}
+ }
+ return v, nil
+ }
+ return f, key
+}
+
+// TestHolderBuildStateIsParkedOnlyAsTheMarkerIs: at the live holder
+// location a generation's park passes over the build state in either
+// region; a whole-location park refuses because of it in the authoritative
+// region (the control without it completes), and in a region that is not
+// authoritative parks it with everything else, as it parks the marker.
+func TestHolderBuildStateIsParkedOnlyAsTheMarkerIs(t *testing.T) {
+ versionsAt := func(t *testing.T, f *fixture, key string) int {
+ t.Helper()
+ vs, err := f.b.ListVersions(t.Context(), "data", key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ n := 0
+ for _, v := range vs {
+ if v.Key == key {
+ n++
+ }
+ }
+ return n
+ }
+ for name, authoritative := range map[string]bool{"authoritative": true, "not authoritative": false} {
+ t.Run(name+": a generation's park passes it over", func(t *testing.T) {
+ f, key := twinFixture(t, "park-generation", authoritative, true)
+ if err := f.e.Run(t.Context(), f.w); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ f.done0(t)
+ if n := versionsAt(t, f, key); n != 2 {
+ t.Fatalf("%d versions of the build state left, want both", n)
+ }
+ if n := versionsAt(t, f, f.ob.Park.Location+"/g0/data"); n != 0 {
+ t.Fatalf("g0 was not parked: %d versions", n)
+ }
+ })
+ }
+ t.Run("authoritative: a whole-location park refuses because of it", func(t *testing.T) {
+ control, _ := twinFixture(t, "park-replica", true, false)
+ if err := control.e.Run(t.Context(), control.w); err != nil {
+ t.Fatalf("control without the build state: %v", err)
+ }
+ f, _ := twinFixture(t, "park-replica", true, true)
+ before := f.b.Snapshot()
+ if err := f.e.Run(t.Context(), f.w); !errors.Is(err, ErrUnsafe) {
+ t.Fatalf("Run = %v, want ErrUnsafe", err)
+ }
+ if f.b.Snapshot() != before {
+ t.Fatal("the refused park changed storage")
+ }
+ })
+ t.Run("not authoritative: a whole-location park parks it", func(t *testing.T) {
+ f, key := twinFixture(t, "park-replica", false, true)
+ if err := f.e.Run(t.Context(), f.w); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ f.done0(t)
+ if n := versionsAt(t, f, key); n != 0 {
+ t.Fatalf("%d versions of the build state left, want none", n)
+ }
+ })
+}
+
+// done0 asserts the chore is acknowledged.
+func (f *fixture) done0(t *testing.T) {
+ t.Helper()
+ obs, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(obs.Claim.Obligations) != 0 {
+ t.Fatalf("obligation still open: %+v", obs.Claim.Obligations)
+ }
+}
diff --git a/internal/park/remote.go b/internal/park/remote.go
index 549a49c24..aa3e4009c 100644
--- a/internal/park/remote.go
+++ b/internal/park/remote.go
@@ -189,6 +189,17 @@ func scopeContains(host, location, key string) (bool, error) {
loc, err := locationOf(host, key)
return loc == location, err
}
+
+// isBuildState reports whether key is the build state beside the lease at
+// location (release.BuildStateKey; ruling R-PARK). At the live holder
+// location it is the marker's twin: live state that belongs to no
+// generation, passed over wherever the marker key is, and a whole-location
+// park there refuses because of it as it refuses because of the marker.
+// Callers ask it only of the holder's location; at a retired location, or
+// in a moves lane, the same file name is residue like any other object.
+func isBuildState(location, key string) bool {
+ return key == release.BuildStateKey(location)
+}
func isLock(host, location, key string) bool {
for _, p := range prefixes(location) {
if key == p+"lock.json" {
@@ -349,7 +360,7 @@ func (e Engine) selected(ctx context.Context, w Work, o hostclaim.Obligation, he
return obs, v, nil, ErrUnsafe
}
if obs.Claim.Holder != nil && obs.Claim.Op.Kind != hostclaim.KindParking && o.Park.Location == obs.Claim.Holder.Location && v.Region == v.AuthoritativeRegion {
- if x.Key == v.Marker.Key {
+ if x.Key == v.Marker.Key || isBuildState(obs.Claim.Holder.Location, x.Key) {
if o.Kind == "park-generation" {
continue
}
@@ -367,6 +378,11 @@ func (e Engine) selected(ctx context.Context, w Work, o hostclaim.Obligation, he
return obs, v, nil, ErrUnsafe
}
} else if o.Kind == "park-generation" {
+ // The holder location's build state is no generation's, as the
+ // view's classifier answers for its marker: never this chore's.
+ if obs.Claim.Holder != nil && isBuildState(obs.Claim.Holder.Location, x.Key) {
+ continue
+ }
if v.GenerationOf == nil {
return obs, v, nil, ErrUnsafe
}
diff --git a/internal/park/sweep.go b/internal/park/sweep.go
index 92ceb109b..8e85333b5 100644
--- a/internal/park/sweep.go
+++ b/internal/park/sweep.go
@@ -166,7 +166,7 @@ func Decide(obs hostclaim.Observed, v View, versions []lease.ObjectVersion, lega
if liveErr != nil {
continue
}
- if x.Key == v.Marker.Key || v.Pinned[ref(x)] {
+ if x.Key == v.Marker.Key || isBuildState(c.Holder.Location, x.Key) || v.Pinned[ref(x)] {
continue
}
g, ok := v.GenerationOf(ref(x))
@@ -223,7 +223,10 @@ func parkedGenerations(c *hostclaim.Claim, v View, chores []hostclaim.Obligation
continue
}
for _, x := range versions {
- if !strings.HasPrefix(x.Key, c.Holder.Location+"/") || isLock(c.Host, o.Park.Location, x.Key) || x.Key == v.Marker.Key {
+ // The holder location's build state belongs to no generation
+ // (ruling R-PARK), so it names none parked, whichever region
+ // parked it.
+ if !strings.HasPrefix(x.Key, c.Holder.Location+"/") || isLock(c.Host, o.Park.Location, x.Key) || x.Key == v.Marker.Key || isBuildState(c.Holder.Location, x.Key) {
continue
}
inScope, err := scopeContains(c.Host, o.Park.Location, x.Key)