carlos / platform Public

settings-4 task 12a: the final-wave fixes queued by the task reviews

- claimwake: a pinned claimed start with no generation number hands the
  sidecar the file it ran (s.Staged) with the pin's demand.
- claimwake: the claimed exec asks its attempt gate again after the
  record, as startSidecar does; a fence raised during the PUT execs
  nothing.
- adopt: the up-to-date refusal clear keeps a refusal that still blocks
  either the selected build or the head.
- wakecheck: the wait's re-ask is bounded by the wait, so a slow answer
  never holds the wake past wakeWaitBound.
- movesinstall: a landing whose seal carries no state removes every
  version of a leftover build-state.json beside the lease.
- buildstate (R-HELD-SCOPE): heldTarget and wakeTarget hold only Kind
  instance rows with no Unit, as root applies settings.
- Doc and log fixes: SnapshotFingerprint's four churn exceptions, the
  stage-marker journal line, the bsErr operator line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev d02b043aea779578433b4969ebd4b9642085e722 parent 69c72fe
13 files changed, +315 −34
  • internal/activator/claimrecord_linux_test.go +64 −3
  • internal/activator/claimwake.go +15 −1
  • internal/activator/wakecheck.go +23 −3
  • internal/activator/wakecheck_test.go +34 −0
  • internal/host/agent.go +11 −3
  • internal/host/buildstate.go +16 −5
  • internal/host/buildstate_moves_test.go +47 −0
  • internal/host/buildstate_test.go +49 −0
  • internal/host/movesfinal_test.go +8 −1
  • internal/host/movesinstall.go +19 −3
  • internal/registry/registry.go +11 −8
  • internal/release/adopt.go +4 −3
  • internal/release/adoptsettings_test.go +14 −4
diff --git a/internal/activator/claimrecord_linux_test.go b/internal/activator/claimrecord_linux_test.go
index bab6806ac..7c56af25a 100644
--- a/internal/activator/claimrecord_linux_test.go
+++ b/internal/activator/claimrecord_linux_test.go
@@ -279,6 +279,20 @@ func TestAClaimedProbeRecords(t *testing.T) {
// claim's Holder.Location (internal/host buildStates.locate, ruling
// R-CLAIMPREFIX-2), which is the claimed lease's.
func TestAClaimedSidecarRestartRecordsTheBuildItsPrimaryRan(t *testing.T) {
+ claimedSidecarRestart(t, false)
+}
+
+// The same for a pinned claimed start (Task 12a): every claimed wake is
+// pinned once a planner is on, and with no generation number the pin's
+// start must still hand the sidecar the file it ran, by path, with the
+// pin's demand. Prepare's start was checked against the pin (s.Version is
+// Pin.Build), and s.Staged is what actually ran.
+func TestAPinnedClaimedSidecarRestartRunsTheClaimedFile(t *testing.T) {
+ claimedSidecarRestart(t, true)
+}
+
+func claimedSidecarRestart(t *testing.T, pinned bool) {
+ t.Helper()
f := newRootLaunchFixture(t)
f.root.grant = launchedChild(t)
host := f.launch.Target.Host
@@ -316,6 +330,15 @@ func TestAClaimedSidecarRestartRecordsTheBuildItsPrimaryRan(t *testing.T) {
}}}, b, reg)
l := &execLog{}
a.BeforeExec = l.hook
+ id := claimedBuildOf(t, f, "v1")
+ wantDemand := ""
+ if pinned {
+ boot := id
+ boot.Demand, wantDemand = "hold", "hold"
+ a.Planner = func(context.Context, registry.Route) WakePlan {
+ return WakePlan{Boot: boot, Mode: ModeHold}
+ }
+ }
rt := registry.Route{Host: host, Kind: "instance", Hibernate: true, Socket: filepath.Join(f.runDir, "app.sock"), DBPath: filepath.Join(f.launchDir, "app.db")}
done, err := a.beginRequest(t.Context(), rt)
if err != nil {
@@ -323,9 +346,8 @@ func TestAClaimedSidecarRestartRecordsTheBuildItsPrimaryRan(t *testing.T) {
}
done()
g, ok := a.entryGen(host)
- id := claimedBuildOf(t, f, "v1")
- if !ok || g.Build != "v1" || g.StagedPath != id.StagedPath || g.Digest != id.Digest {
- t.Fatalf("serving %+v (%v), want the hashed v1 by path and digest", g, ok)
+ if !ok || g.Build != "v1" || g.StagedPath != id.StagedPath || g.Digest != id.Digest || g.Demand != wantDemand {
+ t.Fatalf("serving %+v (%v), want the hashed v1 by path and digest, demand %q", g, ok, wantDemand)
}
a.sweepOnce()
waitFor(t, "the sidecar start", func() bool { return len(b.sidecarsOn()) == 1 && a.sidecarSettled(host) })
@@ -340,3 +362,42 @@ func TestAClaimedSidecarRestartRecordsTheBuildItsPrimaryRan(t *testing.T) {
t.Fatalf("locations %+v: want the primary's at the claimed lease, the sidecar's left to the claim read", locs)
}
}
+
+// The claimed exec asks its attempt gate again after the record, as
+// startSidecar does (Task 12a): a fence raised or a lease lost while the
+// build state was being written (the S3 PUT) execs nothing.
+func TestAClaimedStartExecsOnlyIfStillGatedAfterItsRecord(t *testing.T) {
+ c := newClaimedRecord(t, nil)
+ var mu sync.Mutex
+ fenced, gates := false, 0
+ record := c.log.hook
+ c.a.BeforeExec = func(ctx context.Context, host, build string) error {
+ err := record(ctx, host, build)
+ mu.Lock()
+ fenced = true // the fence lands during the PUT
+ mu.Unlock()
+ return err
+ }
+ gate := func() error {
+ mu.Lock()
+ defer mu.Unlock()
+ gates++
+ if fenced {
+ return ErrFenced
+ }
+ return nil
+ }
+ err := c.a.startAdmitted(t.Context(), &actEntry{host: c.obs.Host}, c.obs, startPolicy{MaxStarts: 1, Window: 30 * time.Second, Gate: gate})
+ if !errors.Is(err, ErrFenced) {
+ t.Fatalf("startAdmitted = %v, want the fence raised during the record", err)
+ }
+ if calls, _, _ := c.log.seen(); len(calls) != 1 {
+ t.Fatalf("records %v, want the one record before the fence", calls)
+ }
+ mu.Lock()
+ asked := gates
+ mu.Unlock()
+ if _, _, _, launches := c.counts(); launches != 0 || asked != 2 {
+ t.Fatalf("launches=%d gates=%d, want the gate asked before and after the record and nothing execed", launches, asked)
+ }
+}
diff --git a/internal/activator/claimwake.go b/internal/activator/claimwake.go
index 107ae829e..8389e1eae 100644
--- a/internal/activator/claimwake.go
+++ b/internal/activator/claimwake.go
@@ -526,6 +526,14 @@ func (a *Service) startAdmitted(ctx context.Context, e *actEntry, admitted hostc
unrecorded = true
return err
}
+ if policy.Gate != nil {
+ // Asked again after the record, as startSidecar does: a
+ // fence raised or a lease lost while the build state was
+ // written (the PUT) execs nothing (Task 12a).
+ if err := policy.Gate(); err != nil {
+ return err
+ }
+ }
if err := budget.start(ctx, s.OpenProbe); err != nil {
return err
}
@@ -612,13 +620,19 @@ func (a *Service) startAdmitted(ctx context.Context, e *actEntry, admitted hostc
g.StagedPath, g.Digest, g.Demand = policy.Pin.StagedPath, policy.Pin.Digest, policy.Pin.Demand
}
}
- if policy.Pin == nil && s.Staged.StagedPath != "" && s.Staged.Digest != "" && s.Staged.Version == s.Version {
+ if (policy.Pin == nil || g.N == 0) && s.Staged.StagedPath != "" && s.Staged.Digest != "" && s.Staged.Version == s.Version {
// The file this start ran (the hashed resolution of the
// live link), so a legacy sidecar restart runs that file
// by path, never whatever the link names by then (Task
// 5d; ruling R-EXEC-SIDECAR). With no number, wake()
// keeps it on the canonical handle it gives this start.
+ // A pinned start with no number takes the same file: its
+ // version was checked against Pin.Build above, and
+ // s.Staged is what ran; the demand is the pin's (Task 12a).
g.Build, g.StagedPath, g.Digest = s.Version, s.Staged.StagedPath, s.Staged.Digest
+ if policy.Pin != nil {
+ g.Demand = policy.Pin.Demand
+ }
}
e.claimResult = &wokenInstance{proc: p, rep: rep, spawnVersion: s.Version, gen: g, stopOutcome: &stopObservation{}}
return nil
diff --git a/internal/activator/wakecheck.go b/internal/activator/wakecheck.go
index dca5e029d..265a7ee11 100644
--- a/internal/activator/wakecheck.go
+++ b/internal/activator/wakecheck.go
@@ -114,13 +114,19 @@ func (a *Service) wakeCheck(ctx context.Context, host string, direct bool) (stri
if a.liveVersion(host) == h.Target {
return h.Target, nil, nil
}
- if n%heldReask == 0 {
+ if n%heldReask == 0 && wait.Err() == nil {
// The answer is asked again while the wake waits: a member's
// change (a pin, Update now, auto) or root's verdict (failed,
// unknown) ends this wait now, rather than at its bound onto a
// target that is no longer the instance's. The next wake checks
- // afresh.
- if next := a.HeldTarget(host); !sameHeld(next, h) {
+ // afresh. The re-ask is bounded by the wait (Task 12a): it may
+ // read the bucket for up to its own budget, and an answer that
+ // comes back after the wait has ended is not waited for.
+ next, ok := a.reaskHeld(wait, host)
+ if !ok {
+ return "", nil, fmt.Errorf("%w: %s is on %q, wants %s", errStaleLink, host, installed, h.Target)
+ }
+ if !sameHeld(next, h) {
switch {
case next.Held && (next.Unknown || next.Target == ""):
return "", nil, errBuildUnknown
@@ -142,6 +148,20 @@ func (a *Service) wakeCheck(ctx context.Context, host string, direct bool) (stri
// HeldTarget again (which may read the bucket).
const heldReask = 8
+// reaskHeld is HeldTarget(host) bounded by wait: false when wait ends first.
+// The call itself is not cancelled (HeldTarget takes no context); it finishes
+// within its own budget into a buffered channel nobody reads.
+func (a *Service) reaskHeld(wait context.Context, host string) (HeldWake, bool) {
+ got := make(chan HeldWake, 1)
+ go func() { got <- a.HeldTarget(host) }()
+ select {
+ case h := <-got:
+ return h, true
+ case <-wait.Done():
+ return HeldWake{}, false
+ }
+}
+
// sameHeld reports whether two answers send the wake to the same place.
func sameHeld(x, y HeldWake) bool {
return x.Held == y.Held && x.Target == y.Target && x.Unknown == y.Unknown && x.Failed == y.Failed
diff --git a/internal/activator/wakecheck_test.go b/internal/activator/wakecheck_test.go
index e7d41f464..837a8b4ff 100644
--- a/internal/activator/wakecheck_test.go
+++ b/internal/activator/wakecheck_test.go
@@ -542,3 +542,37 @@ func TestAHeldUnitRowRefusesALinkThatMovedAfterTheWait(t *testing.T) {
}
waitFor(t, "the lease to go back", func() bool { return unit.releaseCount() == 1 })
}
+
+// The wait's re-ask is bounded by the wait (Task 12a): an answer that is slow
+// to come back (the agent's refresh reads the bucket, up to its own 5 s
+// budget) never holds the wake past wakeWaitBound.
+func TestAHeldWakesReaskNeverOutlastsTheWait(t *testing.T) {
+ shrinkWakeWait(t, 1900*time.Millisecond)
+ a, b := identityActivator(t, "v3")
+ var asked atomic.Int32
+ a.HeldTarget = func(string) HeldWake {
+ if asked.Add(1) > 1 {
+ time.Sleep(5 * time.Second) // the refresh's own budget
+ }
+ return HeldWake{Target: "v1", Held: true}
+ }
+ a.RequestStage = func(string, string) {} // root never installs v1
+ // The check itself, timed: the request's own waiters give up at the same
+ // bound, so through beginRequest an overrun shows only as their timeout
+ // while the wake is still stuck in the re-ask.
+ began := time.Now()
+ _, _, err := a.wakeCheck(context.Background(), "one.example.com", true)
+ took := time.Since(began)
+ if !errors.Is(err, errStaleLink) {
+ t.Fatalf("err = %v, want errStaleLink at the bound", err)
+ }
+ if asked.Load() < 2 {
+ t.Fatalf("asked %d times: the wait never asked again", asked.Load())
+ }
+ if took >= 3*time.Second {
+ t.Fatalf("the wait ran %v, past its 1.9 s bound: the re-ask outlasted it", took)
+ }
+ if b.startCount() != 0 {
+ t.Fatal("a wake that never saw its target ran something")
+ }
+}
diff --git a/internal/host/agent.go b/internal/host/agent.go
index f2f58a1ae..fa95c708a 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -297,13 +297,17 @@ func runAgent(args []string, buildClaims ClaimFactory) {
// wiring so it locates claimed hosts through cfg.Claims. nil, nil on a box
// with no replica bucket. The activator's BeforeExec hook comes from bs
// (buildStateHook, below): a failure here is one operator line, the
- // agent keeps running, and every spawn is refused until it is fixed.
+ // agent keeps running, and every spawn that records its build is refused
+ // until it is fixed: every exec-backed wake, worker, sidecar, swap and
+ // claimed start (Tasks 5a-5d). A unit-backed row is started by systemd
+ // on its live link and records nothing (activator identityFor is nil
+ // for it), so it still starts.
bs, bsErr := newBuildStates(cfg, reg, release.OptDir())
// The status report says this box enforces update settings only while
// the build-state store is up (settings spec §4.2; Astra I3 #1).
noteBuildStatesInit(bsErr)
if bsErr != nil {
- log.Printf("agent: build state store unavailable: %v. every instance and worker spawn is refused until it is fixed and the agent restarted", bsErr)
+ log.Printf("agent: build state store unavailable: %v. every exec-backed instance, worker and sidecar spawn is refused until it is fixed and the agent restarted (unit-backed rows still start)", bsErr)
}
// Fleet box identity (customer-fleets plan, Task 6 fix round — controller
@@ -1509,7 +1513,11 @@ func stageMarkerRequester(dd string) func(host, ver string, err error) {
log.Printf("agent: cannot request root stage for %s %s: %v", host, ver, err)
return
}
- log.Printf("agent: %s needs %s staged and this process cannot write the version store — asked for a root pass", host, ver)
+ // One line for every cause the marker relays (Task 12a): a build
+ // this process cannot stage, an install request only root
+ // resolves (ErrInstallRequestNeedsRoot), and a held wake's target
+ // only root installs. The throttle does not carry the cause.
+ log.Printf("agent: %s needs a root pass for %s (a build only root can stage or install, or an install request only root resolves); asked for one", host, ver)
})
}
diff --git a/internal/host/buildstate.go b/internal/host/buildstate.go
index f5de81452..b68d89c72 100644
--- a/internal/host/buildstate.go
+++ b/internal/host/buildstate.go
@@ -524,9 +524,13 @@ func buildStateHook(bs *buildStates, initErr error) func(ctx context.Context, ho
// decides whether a major hold is in force (the agent does not read tags).
// A pending request's T is followed only with the build state read (Known):
// with it unread root does no request bookkeeping and will not install T.
+//
+// Settings hold only the rows root applies them to (settingsApply; ruling
+// R-HELD-SCOPE): every other row, a named unit or an exec worker under an
+// app-policy setting, is not held.
func (b *buildStates) heldTarget(host string) activator.HeldWake {
rt, err := b.reg.Get(host)
- if err != nil || rt == nil {
+ if err != nil || rt == nil || !settingsApply(*rt) {
return activator.HeldWake{}
}
c, _ := release.ReadConsumed(b.optDir, host)
@@ -601,6 +605,12 @@ func (b *buildStates) heldTarget(host string) activator.HeldWake {
return activator.HeldWake{} // no build state: whatever root installed is the instance's
}
+// settingsApply reports a row root applies update settings to: Kind
+// instance with no Unit, release's adopt condition (ruling R-HELD-SCOPE).
+// Root and the wake must agree on it, or a wake waits for an install root
+// never makes.
+func settingsApply(rt registry.Route) bool { return rt.Kind == "instance" && rt.Unit == "" }
+
// retained is v's staged executable on this box when its bytes match its
// staged manifest's sha256 for this platform (release.RetainedStaged, the
// check stageAuthorized's recovery uses), else nil (Astra I3 #5).
@@ -614,12 +624,13 @@ const wakeHeldBudget = 5 * time.Second
// wakeTarget is heldTarget with the copy refreshed first (§4.2: "refreshed
// at every reconcile and before every wake"), for a route whose settings
-// could hold it. A route with default settings is today's wake: nothing is
-// read for it. A refresh that fails keeps the last copy (refresh records the
-// failure), and the answer is read from that, as root reads it.
+// could hold it. A route with default settings, or one root applies no
+// settings to (settingsApply), is today's wake: nothing is read for it. A
+// refresh that fails keeps the last copy (refresh records the failure), and
+// the answer is read from that, as root reads it.
func (b *buildStates) wakeTarget(ctx context.Context, host string) activator.HeldWake {
rt, err := b.reg.Get(host)
- if err != nil || rt == nil {
+ if err != nil || rt == nil || !settingsApply(*rt) {
return activator.HeldWake{}
}
if u, err := release.ParseUpdateSettingsLenient(rt.UpdateSettings); err == nil && u.Default() {
diff --git a/internal/host/buildstate_moves_test.go b/internal/host/buildstate_moves_test.go
index 8bd455b6f..ad2498776 100644
--- a/internal/host/buildstate_moves_test.go
+++ b/internal/host/buildstate_moves_test.go
@@ -4,12 +4,14 @@ import (
"context"
"strings"
"testing"
+ "time"
"github.com/carlosframework/platform/internal/activator"
"github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/hostwrite"
"github.com/carlosframework/platform/internal/lease"
"github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/park"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/release"
)
@@ -315,6 +317,51 @@ func TestALandingWithNoBuildStatePlacesNone(t *testing.T) {
}
}
+// A landing whose seal carries no state removes what an earlier stay left
+// beside the target's lease (Task 12a): otherwise that stay's build state
+// would read as this instance's. The removal runs under the location's
+// lease, like the placement.
+func TestALandingWithNoBuildStateRemovesALeftoverOne(t *testing.T) {
+ h := newLandingHarness(t)
+ key := cutRoot + "/build-state.json"
+ storeState(h.phase1Harness, "data-uk", key, "v2") // an earlier stay's
+ h.flip(t)
+ if st := h.seal(t).State; len(st) != 0 {
+ t.Fatalf("seal state %+v, want none", st)
+ }
+ h.land(t)
+ h.installed(t)
+ if o, ok := h.f.buckets["data-uk"][key]; ok {
+ t.Fatalf("an earlier stay's build state %q is still beside the lease", o.body)
+ }
+ if calls := h.vs.callsOn("delete-version data-uk/" + key); len(calls) == 0 {
+ t.Fatal("the leftover was not removed by version")
+ }
+}
+
+// A committed move's source location park acknowledges with a build state
+// beside the source's lease (the 8b reviewer's regression): the state is the
+// marker's twin, never an object of no generation that would hold the
+// park-replica open for ever.
+func TestTheSourceLocationParkAcknowledgesWithABuildState(t *testing.T) {
+ lh := newLandingHarness(t)
+ storeState(lh.phase1Harness, "data-ie", cutRoot+"/build-state.json", "v6")
+ h := newFinalHarnessFrom(t, lh)
+ // One admission on the holder: with none the engine names no parked
+ // generations at all, and the build state is never classified.
+ rewriteP1Claim(t, h.s, func(c *hostclaim.Claim) {
+ c.Holder.Admissions = append(c.Holder.Admissions, hostclaim.AdmissionRecord{Region: "ie", Session: 1, Box: "ie-box", Generation: "cur", Mode: "cold"})
+ })
+ h.commit(t)
+ h.clock.Advance(park.SignatureAge + cutRequestLimit + time.Minute)
+ h.sourcePass(t)
+ for _, o := range h.claim(t).Obligations {
+ if o.Kind == "park-replica" && o.Region == "ie" {
+ t.Fatalf("the ie park-replica is still owed past its horizon: %+v", o)
+ }
+ }
+}
+
// returnSealState is the return seal's state class, checked against the
// lane object it names.
func returnSealState(t *testing.T, h *rollbackHarness, rbID string) (moves.SealedObject, string) {
diff --git a/internal/host/buildstate_test.go b/internal/host/buildstate_test.go
index 98a799aa3..f4017014a 100644
--- a/internal/host/buildstate_test.go
+++ b/internal/host/buildstate_test.go
@@ -981,6 +981,55 @@ func TestTheWakeTargetRefreshesOnlyAHeldRoute(t *testing.T) {
}
}
+// Ruling R-HELD-SCOPE (Task 12a): settings hold only the rows root applies
+// them to (Kind instance, no Unit; adopt.go's settings condition). A named
+// unit row and an exec worker row under an app-policy manual wake as today:
+// not held, and nothing is read for them. Before, the unit row read
+// "build unknown" on every wake with nothing to clear it, and the worker was
+// held on a build root never targets.
+func TestOnlyRowsRootHoldsAreHeldAtTheWake(t *testing.T) {
+ b, s, reg := testBuildStates(t)
+ ctx := context.Background()
+ manual := release.UpdateSettings{Updates: release.UpdatesManual, Channel: "stable"}
+ for _, rt := range []registry.Route{
+ {Host: "unit.example.com", Kind: "instance", App: "km", Hibernate: true, Socket: "/run/u.sock", Unit: "km-unit"},
+ {Host: "w.example.com", Kind: "sidecar", App: "km", Hibernate: true},
+ } {
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetUpdateSettings(rt.Host, manual.Column()); err != nil {
+ t.Fatal(err)
+ }
+ }
+ // The worker keeps a build state (its lease's), read and copied.
+ if err := put(t, b, ctx, "w.example.com", release.BuildState{LastExecuted: "v2"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetBuildState("w.example.com", release.BuildStateCopy{Known: true, Replica: true, State: &release.BuildState{LastExecuted: "v2"}}.Column()); err != nil {
+ t.Fatal(err)
+ }
+ gets := &getCounter{Store: s}
+ b.storeFor = storesOf(map[string]release.Store{"hib": gets})
+ for _, host := range []string{"unit.example.com", "w.example.com"} {
+ if h := b.heldTarget(host); h.Held || h.Unknown || h.Target != "" {
+ t.Errorf("%s: heldTarget = %+v; want today's wake", host, h)
+ }
+ if h := b.wakeTarget(ctx, host); h.Held || h.Unknown || h.Target != "" {
+ t.Errorf("%s: wakeTarget = %+v; want today's wake", host, h)
+ }
+ }
+ if n := gets.n(); n != 0 {
+ t.Errorf("%d bucket reads for rows root never holds; want none", n)
+ }
+ // The instance row under the same settings is still held.
+ setSettings(t, reg, manual)
+ setCopy(t, reg, release.BuildStateCopy{Known: true, Replica: true, State: &release.BuildState{LastExecuted: "v3"}})
+ if h := b.heldTarget("a.example.com"); !h.Held || h.Target != "v3" {
+ t.Fatalf("the instance: heldTarget = %+v; want held on v3", h)
+ }
+}
+
type getCounter struct {
release.Store
mu sync.Mutex
diff --git a/internal/host/movesfinal_test.go b/internal/host/movesfinal_test.go
index c4cf9d286..58abdb359 100644
--- a/internal/host/movesfinal_test.go
+++ b/internal/host/movesfinal_test.go
@@ -47,7 +47,14 @@ type finalHarness struct {
func newFinalHarness(t *testing.T) *finalHarness {
t.Helper()
- h := &finalHarness{landingHarness: newLandingHarness(t)}
+ return newFinalHarnessFrom(t, newLandingHarness(t))
+}
+
+// newFinalHarnessFrom is newFinalHarness over a landing harness the caller
+// prepared before the flip.
+func newFinalHarnessFrom(t *testing.T, lh *landingHarness) *finalHarness {
+ t.Helper()
+ h := &finalHarness{landingHarness: lh}
h.flip(t)
h.land(t)
h.installed(t)
diff --git a/internal/host/movesinstall.go b/internal/host/movesinstall.go
index 7c9b6afa9..04b372f81 100644
--- a/internal/host/movesinstall.go
+++ b/internal/host/movesinstall.go
@@ -619,17 +619,33 @@ func (ir *installRun) building(ctx context.Context) (bool, error) {
// request a stale round signed can land a newer version in the lane until
// the seal's bound, and that version is never placed. A seal with no state
// (the source had none, or a seal from before the class existed) places
-// nothing. A replay copies again.
+// nothing, and removes every version of a build state an earlier stay left
+// at the location (Task 12a): it is the marker's twin, so the landing's park
+// passes it over, and otherwise that stay's state would read as this
+// instance's. A replay copies, or removes, again.
func (ir *installRun) placeBuildState(ctx context.Context, vs lease.VersionedStore, seal moves.Seal, holder hostclaim.Holder) error {
+ bucket := ir.cfg().OwnBucket
+ dst := release.BuildStateKey(strings.TrimSuffix(holder.Location, "/"))
if len(seal.State) == 0 {
+ vers, err := vs.ListVersions(ctx, bucket, dst)
+ if err != nil {
+ return fmt.Errorf("list a leftover build state at %s: %w", dst, err)
+ }
+ for _, v := range vers {
+ if v.Key != dst {
+ continue
+ }
+ if err := vs.DeleteVersion(ctx, bucket, dst, v.VersionID); err != nil {
+ return fmt.Errorf("remove a leftover build state at %s (version %s): %w", dst, v.VersionID, err)
+ }
+ log.Printf("moves: %s: the %s carries no build state; removed version %s of an earlier stay's at %s", ir.rec.Host, ir.src.Name, v.VersionID, dst)
+ }
return nil
}
o := seal.State[0]
if o.Key != ir.src.Lane+moves.BuildStateSuffix {
return fmt.Errorf("%w: the sealed build state %s is not the %s's lane key", moves.ErrBadSeal, o.Key, ir.src.Name)
}
- bucket := ir.cfg().OwnBucket
- dst := release.BuildStateKey(strings.TrimSuffix(holder.Location, "/"))
if _, _, err := vs.CopyVersion(ctx, bucket, o.Key, o.VersionID, bucket, dst, false); err != nil {
return fmt.Errorf("place the build state at %s: %w", dst, err)
}
diff --git a/internal/registry/registry.go b/internal/registry/registry.go
index 0ac7f9d62..384d5c1ad 100644
--- a/internal/registry/registry.go
+++ b/internal/registry/registry.go
@@ -3139,14 +3139,17 @@ FROM route_paths ORDER BY host, prefix`)
// values are scanned generically and written in column order, which is why
// this needs no per-column typing the way Fingerprint does.
//
-// "Every column" has exactly three exceptions, snapshotChurnCols: the
-// scheduling stamps (and the serving_version rollout stamp) the box rewrites
-// on its own clock with no operator change behind them. Hashing those made the gate never hold (#207 — both
-// production boxes wrote a generation on essentially every 5-minute tick,
-// so the 8-generation history was ~40 minutes deep, not 8 changes deep).
-// They are still IN the snapshot; only the decision to take one ignores
-// them. A restore therefore loses up to one tick of those stamps, which the
-// activator and DNS sweeps re-derive on their first pass anyway.
+// "Every column" has exactly four exceptions, snapshotChurnCols: the
+// scheduling stamps (next_due, dns_checked_at), the serving_version rollout
+// stamp, and build_state, the agent's copy of each instance's build state
+// (its ReadAt moves on every refresh). The box rewrites them on its own
+// clock with no operator change behind them. Hashing those made the gate
+// never hold (#207: both production boxes wrote a generation on
+// essentially every 5-minute tick, so the 8-generation history was ~40
+// minutes deep, not 8 changes deep). They are still IN the snapshot; only
+// the decision to take one ignores them. A restore therefore loses up to
+// one tick of those stamps, which the activator and DNS sweeps and the
+// build-state refresh re-derive on their first pass anyway.
//
// route_aliases and wildcard_domains are not hashed either: both are
// pass-owned materializations of domain claims (domains.Reconcile rebuilds
diff --git a/internal/release/adopt.go b/internal/release/adopt.go
index 688a779bf..37e54ae71 100644
--- a/internal/release/adopt.go
+++ b/internal/release/adopt.go
@@ -1237,10 +1237,11 @@ func adoptHost(ctx context.Context, work hostwrite.Work, reg *registry.Registry,
// next root pass retries. A record that still blocks stays: the link
// is on the refused build only when its rollback failed, and that is
// worth showing. The record is judged against the build this route
- // selected (sel): under a pin or a hold, a refusal of the head is
- // not the live build's.
+ // selected (sel) and against the head (ptr): under a pin or a hold
+ // a refusal of the head that is still current stays, so an unpin
+ // does not retry a promotion this box already refused (Task 12a).
if cfg.bakeDir != "" {
- if r, err := ReadRefusal(cfg.bakeDir, rt.Host); err == nil && r != nil && !r.Blocks(sel) {
+ if r, err := ReadRefusal(cfg.bakeDir, rt.Host); err == nil && r != nil && !r.Blocks(sel) && !r.Blocks(ptr) {
_ = ClearRefusal(cfg.bakeDir, rt.Host)
}
}
diff --git a/internal/release/adoptsettings_test.go b/internal/release/adoptsettings_test.go
index aad34b03d..3da350580 100644
--- a/internal/release/adoptsettings_test.go
+++ b/internal/release/adoptsettings_test.go
@@ -803,9 +803,11 @@ func TestAnAgentPassRelaysAPendingRequestToRoot(t *testing.T) {
}
}
-// R-T6-MISC fix round 1: the up-to-date refusal clear judges the selected
-// build. Pinned and live on v1: a refusal of the head v2 is stale for this
-// route and cleared; a refusal of v1 itself (its rollback failed) stays.
+// R-T6-MISC fix round 1, amended by Task 12a: the up-to-date refusal clear
+// judges both the selected build and the head. Pinned and live on v1: a
+// refusal of the head v2 that is still current is kept (unpinning would
+// otherwise retry a refused promotion once); a refusal of v1 itself (its
+// rollback failed) stays too.
func TestTheLiveBuildsRefusalIsJudgedOnTheSelectedBuild(t *testing.T) {
s := NewDirStore(t.TempDir())
ctx := context.Background()
@@ -835,8 +837,16 @@ func TestTheLiveBuildsRefusalIsJudgedOnTheSelectedBuild(t *testing.T) {
t.Fatal(err)
}
pass()
+ if r, err := ReadRefusal(bake, "a.example.com"); err != nil || r == nil || r.Version != "v2" {
+ t.Fatalf("refusal = %+v, %v; the pin cleared a refusal of the head that is still current", r, err)
+ }
+ // A refusal of an earlier promotion of v2 blocks neither build: cleared.
+ if err := WriteRefusal(bake, Refusal{Host: "a.example.com", App: "km", Version: "v2", PointerPromotedAt: head.PromotedAt.Add(-time.Hour), Reason: "did not come up"}); err != nil {
+ t.Fatal(err)
+ }
+ pass()
if r, err := ReadRefusal(bake, "a.example.com"); err != nil || r != nil {
- t.Fatalf("refusal = %+v, %v; the head's refusal was kept as though it were the live build's", r, err)
+ t.Fatalf("refusal = %+v, %v; a refusal that blocks nothing was kept", r, err)
}
if err := WriteRefusal(bake, Refusal{Host: "a.example.com", App: "km", Version: "v1", Reason: "did not come up"}); err != nil {
t.Fatal(err)