carlos / platform Public

zdd-readiness: final review round 1, a host retry keeps its canary for the retried build

Finding 5 (Astra, Medium): RetryHost bound the queue's forced retry only when the slot wanted the retried build B at that moment, so a retry of B while the slot wanted A left the host exempt in the book but with no admission through B's halt. The entitlement (hostSlot.forceBuild) is now bound to B whenever the retry clears B's record, survives attempts onto other builds, restarts and config attempts, and is spent only by the forced adoption onto B; rearmRetry gives it back even when the desire has moved on.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev e72e68d2654e1e598c6c9e0ea3f662724024edbb parent c327f70
4 files changed, +147 −31
  • internal/rollout/admit_test.go +21 −13
  • internal/rollout/finalreview1_test.go +86 −0
  • internal/rollout/manager.go +10 −2
  • internal/rollout/queue.go +30 −16
diff --git a/internal/rollout/admit_test.go b/internal/rollout/admit_test.go
index afe72fd4f..b4754b22f 100644
--- a/internal/rollout/admit_test.go
+++ b/internal/rollout/admit_test.go
@@ -178,7 +178,7 @@ func TestARetriedInstanceRunsAsACanaryThroughAHalt(t *testing.T) {
if ds := q.admit(currentView(q), t0); len(ds) != 0 {
t.Fatalf("admitted %+v from a halted cohort", ds)
}
- q.retryHost("b")
+ q.retryHost("b", "v2")
ds := q.admit(currentView(q), t0)
if len(ds) != 1 || ds[0].ID.Host != "b" || !ds[0].Canary {
t.Fatalf("admitted %+v, want b alone, as a canary", ds)
@@ -252,7 +252,7 @@ func TestARetryThenAnUnhaltLeavesOneCanaryUntilItSettles(t *testing.T) {
q.concurrency = 2
releaseAll(q, "v2", "a", "b", "c")
q.halt("v2", "health check failed")
- q.retryHost("b")
+ q.retryHost("b", "v2")
forced := q.admit(currentView(q), t0)
if len(forced) != 1 || forced[0].ID.Host != "b" || !forced[0].Canary {
t.Fatalf("admitted %+v, want b alone, as a canary", forced)
@@ -278,7 +278,7 @@ func TestARetriedInstanceWaitsForTheCohortsUnsettledCanary(t *testing.T) {
if len(first) != 1 || first[0].ID.Host != "a" || !first[0].Canary {
t.Fatalf("admitted %+v, want a as the canary", first)
}
- q.retryHost("b")
+ q.retryHost("b", "v2")
if more := q.admit(currentView(q), t0); len(more) != 0 {
t.Fatalf("admitted %+v beside a's unsettled canary", more)
}
@@ -301,7 +301,7 @@ func TestARetriedInstanceIsACanaryInACohortThatIsNotHalted(t *testing.T) {
t.Fatalf("admitted %+v, want a as the canary", first)
}
q.finish(first[0], activator.SwapDone)
- q.retryHost("b")
+ q.retryHost("b", "v2")
ds := q.admit(currentView(q), t0)
if len(ds) != 1 || ds[0].ID.Host != "b" || !ds[0].Canary {
t.Fatalf("admitted %+v, want b as a canary", ds)
@@ -320,7 +320,7 @@ func TestARetriedInstanceWaitsForARunningCanaryInAHaltedCohort(t *testing.T) {
releaseAll(q, "v2", "a", "b")
first := q.admit(currentView(q), t0)
q.halt("v2", "another host failed")
- q.retryHost("b")
+ q.retryHost("b", "v2")
if more := q.admit(currentView(q), t0); len(more) != 0 {
t.Fatalf("admitted %+v beside a running canary", more)
}
@@ -331,13 +331,16 @@ func TestARetriedInstanceWaitsForARunningCanaryInAHaltedCohort(t *testing.T) {
}
// Final review F8: a forced attempt that ran nothing gets its allowance
-// back, bound to the build it was for. A host whose desire moved on to
-// another build, or an attempt that was never forced, gets nothing.
+// back, bound to the build it was for. Since final review round 1, finding
+// 5, that holds when the host's desire has moved on to another build too:
+// the allowance waits for the adoption onto its build, and never lets an
+// adoption onto the other build through. An attempt that was never forced
+// gets nothing.
func TestARearmedRetryStaysBoundToItsBuild(t *testing.T) {
q := awakeQueue(noBlocks, "a", "b")
releaseAll(q, "v2", "a", "b")
q.halt("v2", "health check failed")
- q.retryHost("b")
+ q.retryHost("b", "v2")
forced := q.admit(currentView(q), t0)
if len(forced) != 1 || forced[0].ID.Host != "b" || !forced[0].Forced {
t.Fatalf("admitted %+v, want b's forced canary", forced)
@@ -351,8 +354,12 @@ func TestARearmedRetryStaysBoundToItsBuild(t *testing.T) {
q.finish(again[0], activator.SwapAbandoned)
q.wantBuild("b", activator.CauseRelease, "v3", t0) // a newer build replaced the desire
q.rearmRetry(again[0])
- if got := q.hosts["b"].forceBuild; got != "" {
- t.Fatalf("forceBuild = %q: an allowance for v2 came back to a host that now wants v3", got)
+ if got := q.hosts["b"].forceBuild; got != "v2" {
+ t.Fatalf("forceBuild = %q: want the allowance for v2 back, waiting for v2", got)
+ }
+ q.halt("v3", "health check failed")
+ if ds := q.admit(currentView(q), t0); len(ds) != 0 {
+ t.Fatalf("admitted %+v: an allowance for v2 let an adoption onto v3 through v3's halt", ds)
}
q.rearmRetry(dispatch{ID: Identity{Host: "a", Build: "v2"}}) // never forced
if got := q.hosts["a"].forceBuild; got != "" {
@@ -360,12 +367,13 @@ func TestARearmedRetryStaysBoundToItsBuild(t *testing.T) {
}
}
-// A retry on a host with nothing pending must not leave a flag that lets an
-// unrelated later attempt skip the settle, or a halt.
+// A retry of another build must not leave a flag that lets an unrelated
+// later attempt skip the settle, or a halt (the entitlement is its build's,
+// final review round 1, finding 5).
func TestAStaleRetryDoesNotBypassTheSettleOrAHalt(t *testing.T) {
q := awakeQueue(noBlocks, "a", "b", "c")
q.concurrency = 3
- q.retryHost("c") // c has no desired build: nothing to retry
+ q.retryHost("c", "v9") // a retry of a build c does not want: nothing for v2
releaseAll(q, "v2", "a", "b", "c")
first := q.admit(currentView(q), t0)
if len(first) != 1 || first[0].ID.Host != "a" {
diff --git a/internal/rollout/finalreview1_test.go b/internal/rollout/finalreview1_test.go
index ddb44941f..2e7b93ed4 100644
--- a/internal/rollout/finalreview1_test.go
+++ b/internal/rollout/finalreview1_test.go
@@ -2,6 +2,7 @@ package rollout
import (
"context"
+ "slices"
"strings"
"testing"
"time"
@@ -285,3 +286,88 @@ func TestANoOpRetryNotesNoClear(t *testing.T) {
t.Fatalf("a failure after a no-op retry = %v, want recorded", res)
}
}
+
+// Final review round 1, finding 5 (Astra): a host retry for a build B the
+// slot does not want now (it wants A) still entitles the host to a canary
+// onto B. The entitlement survives an attempt onto another build in
+// between, and is spent only by the adoption onto B: when the slot comes
+// to want B while B's cohort is halted, that adoption is admitted as the
+// host's canary.
+func TestARetryForABuildNotWantedYetKeepsItsCanary(t *testing.T) {
+ k := appKey{"bab", "blog"}
+ h := newHarness(t, "h", "s")
+ h.stage("v2", "v3")
+ h.act.mu.Lock()
+ h.act.outcome = func(a activator.Attempt) activator.SwapOutcome {
+ if a.Host == "s" && a.Build == "v3" {
+ return activator.SwapFailed
+ }
+ return activator.SwapDone
+ }
+ h.act.mu.Unlock()
+ h.pass()
+
+ // s fails v3: a record holds v3 for h too, and v3's cohort halts.
+ h.install("v3", "s")
+ h.pass()
+ waitFor(t, "s's attempt onto v3 to fail", func() bool { calls, _, _ := h.act.snapshot(); return len(calls) == 1 })
+ s := h.m.scheduler(k)
+ halted := func() bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.q.cohortFor(cohortKey("v3", classAdopt)).halted
+ }
+ waitFor(t, "v3's cohort to halt", halted)
+
+ // The console's retry of h names v3; h's slot wants v1, which it serves.
+ if !h.retry("h", "v3") {
+ t.Fatal("the retry for v3 cleared nothing")
+ }
+ // An adoption onto another build (A = v2) runs in between: it does not
+ // spend the entitlement for v3.
+ h.install("v2", "h")
+ h.pass()
+ waitFor(t, "h's attempt onto v2", func() bool { return h.servingAll("v2", "h") && !h.m.attemptRunning("h") })
+
+ // Now h's slot wants v3, its cohort still halted: the adoption onto v3 is
+ // admitted, as h's canary.
+ if !halted() {
+ t.Fatal("v3's cohort is no longer halted: the case needs the halt")
+ }
+ gate := h.act.hold("h")
+ defer close(gate)
+ h.install("v3", "h")
+ h.pass()
+ waitFor(t, "h's adoption onto v3 through the halt", func() bool {
+ _, started, _ := h.act.snapshot()
+ return slices.Contains(started, "h:v3")
+ })
+ s.mu.Lock()
+ d := s.q.hosts["h"].running
+ s.mu.Unlock()
+ if d == nil || d.ID.Build != "v3" || !d.Canary || !d.Forced {
+ t.Fatalf("h's running attempt %+v: want the forced canary onto v3", d)
+ }
+}
+
+// The entitlement belongs to its build: it never lets an adoption onto
+// another build through that build's halt.
+func TestARetryEntitlementIsNeverSpentOnAnotherBuild(t *testing.T) {
+ q := newAppQueue("bab", "blog", func(string, string, bool) bool { return false })
+ q.concurrency = 1
+ now := t0
+ q.observe("h", "v1", "v1", now)
+ q.wantBuild("h", activator.CauseRelease, "v1", now)
+ q.retryHost("h", "v3")
+ q.cohortFor(cohortKey("v2", classAdopt)).halted = true
+ q.wantBuild("h", activator.CauseRelease, "v2", now)
+ if _, ok := q.gate(q.hosts["h"], now); ok {
+ t.Fatal("an entitlement for v3 let an adoption onto v2 through v2's halt")
+ }
+ q.cohortFor(cohortKey("v3", classAdopt)).halted = true
+ q.wantBuild("h", activator.CauseRelease, "v3", now)
+ d, ok := q.gate(q.hosts["h"], now)
+ if !ok || !d.Forced || !d.Canary {
+ t.Fatalf("gate onto v3 = %+v, %v: want the forced canary", d, ok)
+ }
+}
diff --git a/internal/rollout/manager.go b/internal/rollout/manager.go
index f53062248..d88c51e17 100644
--- a/internal/rollout/manager.go
+++ b/internal/rollout/manager.go
@@ -823,7 +823,10 @@ func (m *Manager) retryBuild(k appKey, build, host string) {
// added under the scheduler's lock: the book saves as it did before.
//
// A grant naming another build than the slot's (the marker's build B while
-// the slot wants A) clears B's restriction and leaves A's queue alone.
+// the slot wants A) clears B's restriction and leaves A's attempt alone; a
+// cleared record of B still binds the host's canary entitlement for B,
+// which the adoption onto B spends when the slot comes to want it, through
+// B's halt if one stands (final review round 1, finding 5).
func (m *Manager) RetryHost(host, build, book string, epoch uint64) bool {
m.mu.Lock()
k, ok := m.hostApp[host]
@@ -838,10 +841,15 @@ func (m *Manager) RetryHost(host, build, book string, epoch uint64) bool {
want = h.wantBuild
}
cleared, unmarked, cur, err := m.d.Book.RetryHostAt(k.acct, k.app, build, host, book, epoch)
+ if cleared {
+ // The canary entitlement is build's whatever the slot wants now
+ // (final review round 1, finding 5): bound here, in the step that
+ // cleared the book, and spent only by the adoption onto build.
+ s.q.retryHost(host, build)
+ }
requeue := cleared && want == build
if requeue {
s.q.wantBuild(host, activator.CauseRetry, build, m.d.Now())
- s.q.retryHost(host)
}
s.mu.Unlock()
if err != nil {
diff --git a/internal/rollout/queue.go b/internal/rollout/queue.go
index 63a6caae8..e8bf76bba 100644
--- a/internal/rollout/queue.go
+++ b/internal/rollout/queue.go
@@ -82,9 +82,14 @@ type hostSlot struct {
configAfter time.Time // a failed config attempt's backoff (spec §8.1)
notBefore time.Time // a staging refusal's wait (spec §4.2)
- running *dispatch
- buildRev uint64 // newest(host): moved only by a change of desired build
- forceBuild string // `carlos instances retry`: lets the adoption onto this build through a halt
+ running *dispatch
+ buildRev uint64 // newest(host): moved only by a change of desired build
+ // forceBuild is `carlos instances retry`'s canary entitlement: it lets
+ // the adoption onto this build through a halt, as a canary. It is the
+ // retried build's, whatever the slot wants when the retry lands, and is
+ // spent only by that adoption, never by an attempt onto another build
+ // or a restart or config attempt (final review round 1, finding 5).
+ forceBuild string
}
// cohort is one app's attempts on this box onto one build, of one class
@@ -372,7 +377,12 @@ func (q *appQueue) start(h *hostSlot, d dispatch) {
c := q.cohortFor(d.Cohort)
c.canary, c.settled = h.host, false
}
- h.forceBuild = ""
+ if d.Forced {
+ // Only the adoption the entitlement let through spends it (final
+ // review round 1, finding 5): a restart, a config attempt or an
+ // adoption onto another build starting first leaves it for later.
+ h.forceBuild = ""
+ }
h.running = &d
q.running++
}
@@ -450,13 +460,16 @@ func (q *appQueue) unhalt(build string) {
}
}
-// retryHost is `carlos instances retry <host>`: if the host is waiting on a
-// build, that adoption runs as a canary even through a halt. The flag is
-// bound to the build the host wants now, so a retry with nothing pending, or
-// a later change of desire, leaves nothing behind that gating could honour.
-func (q *appQueue) retryHost(host string) {
- if h := q.hosts[host]; h != nil {
- h.forceBuild = h.wantBuild
+// retryHost is `carlos instances retry <host>` naming build: the host's
+// adoption onto build runs as a canary even through a halt, now if the slot
+// wants build, or whenever it comes to want it (final review round 1,
+// finding 5, Astra: a retry of a build the slot did not want yet used to
+// leave the host exempt in the book but with no admission through the
+// halt, and no later retry could give it one). The entitlement is bound to
+// build: it never lets an adoption onto another build through.
+func (q *appQueue) retryHost(host, build string) {
+ if h := q.hosts[host]; h != nil && build != "" {
+ h.forceBuild = build
}
}
@@ -464,14 +477,15 @@ func (q *appQueue) retryHost(host string) {
// start (final review F8), for an attempt that ended with nothing of its
// build run: abandoned before its exec, or refused as a staging error. Spec
// §8.4 promises the retried host's next adoption runs as a canary, and that
-// adoption has not happened yet. The allowance stays bound to the build:
-// a host that now wants another build, or holds a newer allowance, gets
-// nothing back. An attempt whose build ran spent the allowance for good,
-// whatever its outcome. The caller holds the scheduler's lock, in the step
+// adoption has not happened yet. The allowance stays bound to the build,
+// and comes back even when the host now wants another build, as a retry
+// for a build not yet wanted keeps it (finding 5); a host that holds a
+// newer allowance gets nothing back. An attempt whose build ran spent the
+// allowance for good, whatever its outcome. The caller holds the scheduler's lock, in the step
// that frees the attempt's slot.
func (q *appQueue) rearmRetry(d dispatch) {
h := q.hosts[d.ID.Host]
- if !d.Forced || h == nil || h.forceBuild != "" || h.wantBuild != d.ID.Build {
+ if !d.Forced || h == nil || h.forceBuild != "" {
return
}
h.forceBuild = d.ID.Build