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>
9 files changed,
+547
−15
internal/activator/backend_exec_test.go+4 −2internal/host/movesbuildstatepark_test.go+263 −0internal/host/movesevidence.go+4 −2internal/host/movesinstall.go+6 −0internal/host/moveslandpark.go+13 −0internal/host/movesrollback.go+35 −8internal/park/buildstate_test.go+200 −0internal/park/remote.go+17 −1internal/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) |