activator: a one-shot is good only after a clean park, and a waiting wake never starts a second
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 files changed,
+253
−51
internal/activator/activator.go+85 −49internal/activator/workerswap.go+8 −2internal/activator/workerswap_test.go+160 −0
diff --git a/internal/activator/activator.go b/internal/activator/activator.go| index d815c595e..fc67b3c18 100644 |
| --- a/internal/activator/activator.go |
| +++ b/internal/activator/activator.go |
| @@ -612,8 +612,11 @@ type actEntry struct { |
| // heldServing is the publication holdServing kept back, delivered by |
| // PublishHeldServing. workerInc is the worker child's incarnation, |
| // bumped at every install (a wake, a respawn): an attempt counts only if |
| - // it moved, and a publication is delivered only for the latest. All |
| - // written under a.mu. |
| + // it moved, and a publication is delivered only for the latest. |
| + // parkedInc is the incarnation a clean-exit park stopped |
| + // (markCleanParkLocked), 0 for none: the only stop after which |
| + // ReplaceWorker counts an exited one-shot as a good build. All written |
| + // under a.mu. |
| attemptBuilds []string |
| retireTook time.Duration |
| attemptGate func() error |
| @@ -622,6 +625,7 @@ type actEntry struct { |
| holdServing bool |
| heldServing *heldServing |
| workerInc uint64 |
| + parkedInc uint64 |
| // shed is a respawn child, refused at its installation with generations |
| // off, whose exit is not proven yet (final review R2-1). With |
| @@ -868,6 +872,11 @@ func ConvergeDecision(cur string, readErr error, spawnVer string, staleSince, no |
| // forever. A var so tests can shrink it. |
| var wakeWaitBound = 30 * time.Second |
| +// wakeBoundExtended is a test seam, nil in production: awaitWake calls it |
| +// right after a first wake's waiter extends its bound (spec §8.2), so a test |
| +// can fail the wake inside the extension window. |
| +var wakeBoundExtended func() |
| + |
| // errLeaseHeld: the per-host lease is held elsewhere. On one box this means a |
| // previous incarnation hasn't released (crashed agent; lease expires within |
| // ~lease.LeaseTTL) — the edge answers 503 with Retry-After. |
| @@ -1231,32 +1240,16 @@ func (a *Service) begin(ctx context.Context, rt registry.Route, stamp bool) (rel |
| case actWaking: |
| ready := e.ready |
| a.mu.Unlock() |
| - select { |
| - case <-ready: |
| - // A failed wake reports to every coalesced waiter (retrying |
| - // here would turn one bad wake into a storm); a successful |
| - // one loops to register against the now-awake entry. |
| - if e.err != nil { |
| - return nil, e.err |
| - } |
| - continue |
| - case <-ctx.Done(): |
| - return nil, ctx.Err() |
| - case <-deadline.C: |
| - // A first wake onto a new build gives its waiters arrival + |
| - // 55 s (zero-downtime spec §8.2): enough for B's health |
| - // window, B's kill and the good build's boot. Only that kind |
| - // of wake; firstWake is set only by a planner (flag on). |
| - a.mu.Lock() |
| - fw := e.firstWake |
| - a.mu.Unlock() |
| - if fw && !extended { |
| - extended = true |
| - deadline.Reset(time.Until(arrival.Add(firstWakeBudget))) |
| - continue |
| - } |
| - return nil, errWakeTimeout |
| + if err := a.awaitWake(ctx, e, ready, deadline, arrival, &extended); err != nil { |
| + return nil, err |
| + } |
| + // A failed wake reports to every coalesced waiter (retrying |
| + // here would turn one bad wake into a storm); a successful one |
| + // loops to register against the now-awake entry. |
| + if e.err != nil { |
| + return nil, e.err |
| } |
| + continue |
| case actStopping: |
| stopped := e.stopped |
| @@ -1278,6 +1271,42 @@ func (a *Service) beginRequest(ctx context.Context, rt registry.Route) (release |
| return a.begin(ctx, rt, true) |
| } |
| +// awaitWake is a waiter's wait on one wake: begin's and WakeWorker's. It |
| +// returns nil once ready closes (the caller then reads e.err), ctx.Err(), or |
| +// errWakeTimeout at the deadline. A first wake onto a new build gives its |
| +// waiters arrival + firstWakeBudget (zero-downtime spec §8.2): enough for |
| +// B's health window, B's kill and the good build's boot. Only that kind of |
| +// wake (firstWake is set only by a planner, flag on), and once per caller: |
| +// *extended carries across the caller's loop. |
| +// |
| +// After the extension it waits again on the SAME ready and never goes back |
| +// to the entries map (round 2 Task 6). A wake that fails inside the |
| +// extension closes ready and deletes its entry; a waiter that re-read the |
| +// map found nothing and started a second wake, two lease acquires and two |
| +// spawns for one caller (#184's shape). Called without a.mu. |
| +func (a *Service) awaitWake(ctx context.Context, e *actEntry, ready <-chan struct{}, deadline *time.Timer, arrival time.Time, extended *bool) error { |
| + for { |
| + select { |
| + case <-ready: |
| + return nil |
| + case <-ctx.Done(): |
| + return ctx.Err() |
| + case <-deadline.C: |
| + a.mu.Lock() |
| + fw := e.firstWake |
| + a.mu.Unlock() |
| + if !fw || *extended { |
| + return errWakeTimeout |
| + } |
| + *extended = true |
| + deadline.Reset(time.Until(arrival.Add(firstWakeBudget))) |
| + if wakeBoundExtended != nil { |
| + wakeBoundExtended() |
| + } |
| + } |
| + } |
| +} |
| + |
| // errSidecarAsleep: a sidecar-marked request arrived for a host with no awake |
| // instance. Sidecar traffic never wakes anything — the activator itself spawns |
| // the sidecar as part of a wake, so a marked request for a sleeping host is |
| @@ -2826,29 +2855,17 @@ func (a *Service) WakeWorker(rt registry.Route) error { |
| case actWaking: |
| ready := e.ready |
| a.mu.Unlock() |
| - select { |
| - case <-ready: |
| - // A failed wake reports to every coalesced caller; retrying |
| - // here would turn one bad wake into a storm (beginRequest's |
| - // rule, for the same reason). |
| - return e.err |
| - case <-deadline.C: |
| - // A first wake onto a new build gives its waiters arrival + |
| - // firstWakeBudget (spec §8.2), a worker's caller as much as |
| - // a request: B's health window, B's kill and the good |
| - // build's boot. Without it a fallback that succeeds after |
| - // the ordinary bound was reported as a timeout (Astra R3 |
| - // flag triage T10). |
| - a.mu.Lock() |
| - fw := e.firstWake |
| - a.mu.Unlock() |
| - if fw && !extended { |
| - extended = true |
| - deadline.Reset(time.Until(arrival.Add(firstWakeBudget))) |
| - continue |
| - } |
| - return errWakeTimeout |
| + // A worker's caller gets a first wake's extension as much as a |
| + // request does (Astra R3 flag triage T10): without it a |
| + // fallback that succeeds after the ordinary bound was reported |
| + // as a timeout. |
| + if err := a.awaitWake(context.Background(), e, ready, deadline, arrival, &extended); err != nil { |
| + return err |
| } |
| + // A failed wake reports to every coalesced caller; retrying |
| + // here would turn one bad wake into a storm (beginRequest's |
| + // rule, for the same reason). |
| + return e.err |
| case actStopping: |
| stopped := e.stopped |
| @@ -3942,6 +3959,21 @@ func (a *Service) Start() { |
| }() |
| } |
| +// markCleanParkLocked records that a clean-exit park is stopping e's current |
| +// worker incarnation (round 2 Task 6). ReplaceWorker's observation forgives |
| +// an exited one-shot that is no longer awake (spec §6.5) only when this mark |
| +// names the incarnation it watched: the park ran with no fence, the lease |
| +// still held and a clean exit. A stop for a lost lease, a fence or a |
| +// shutdown never marks, so the build is never recorded good on it. The |
| +// conditions are checked here again so the mark never depends on the |
| +// caller's order. The caller holds a.mu. |
| +func (a *Service) markCleanParkLocked(e *actEntry) { |
| + if a.fences[e.host] != 0 || e.rep == nil || e.rep.Err() != nil || procCrashed(e.proc) { |
| + return |
| + } |
| + e.parkedInc = e.workerInc |
| +} |
| + |
| // sweepOnce examines every awake instance once: idle ones hibernate; a lost |
| // lease or dead child stands down immediately regardless of activity. It then |
| // checks the due-time index and wakes any sleeping host whose scheduled |
| @@ -4055,6 +4087,10 @@ func (a *Service) sweepOnce() { |
| } |
| if e.worker { |
| log.Printf("activator: sidecar %s exited cleanly — its work is done; parking it", e.host) |
| + // Past this sweep's fence skip and lease check: the one |
| + // stop of an exited one-shot that a ReplaceWorker |
| + // observation may still count as a good build. |
| + a.markCleanParkLocked(e) |
| } else { |
| log.Printf("activator: instance %s exited unexpectedly — recycling", e.host) |
| } |
diff --git a/internal/activator/workerswap.go b/internal/activator/workerswap.go| index a03d4ff8b..5ed469060 100644 |
| --- a/internal/activator/workerswap.go |
| +++ b/internal/activator/workerswap.go |
| @@ -185,9 +185,15 @@ func (a *Service) ReplaceWorker(ctx context.Context, at Attempt, fallback *relea |
| // a newer release or a halted cohort since the spawn means this |
| // build must not be recorded good. Its child stays until the newer |
| // attempt replaces it. A one-shot that finished may have been parked |
| - // meanwhile: no longer awake, but still not superseded. |
| + // meanwhile: no longer awake, but still not superseded. Only the |
| + // sweep's clean park of this very child counts (markCleanParkLocked, |
| + // past the sweep's own fence and lease checks), and only while no |
| + // fence or shutdown stands now. Any other stop of the exited child |
| + // (a lost lease, a fence, the agent's shutdown) leaves it not awake |
| + // too, with no ownership behind the exit, and is abandoned. |
| err := a.swapChecksLocked(e, at, newest) |
| - if r == settleExited && errors.Is(err, errSwapNotAwake) { |
| + if r == settleExited && errors.Is(err, errSwapNotAwake) && |
| + e.parkedInc == inc1 && a.fences[at.Host] == 0 && !a.shuttingDown { |
| err = nil |
| if newest(at.Host) != at.SchedRev { |
| err = errSwapSuperseded |
diff --git a/internal/activator/workerswap_test.go b/internal/activator/workerswap_test.go| index a2cbad45d..dc8ded9ad 100644 |
| --- a/internal/activator/workerswap_test.go |
| +++ b/internal/activator/workerswap_test.go |
| @@ -848,3 +848,163 @@ func TestASupersededWorkerAttemptRestoresOnlyWhatProvablyNeverRan(t *testing.T) |
| }) |
| } |
| } |
| + |
| +// Round 2 Task 6 (recon B §1): a one-shot's clean exit inside its |
| +// observation is a good build (spec §6.5) only when the sweep parked it |
| +// after its own fence and lease checks. Any other stop of the exited child |
| +// (a lost lease, a fence) leaves the entry not awake, and that answer was |
| +// forgiven for every exited one-shot, so the build was recorded good with no |
| +// ownership behind it. Those stops are abandoned; the sweep's park is done. |
| +func TestAnExitedOneShotIsGoodOnlyAfterACleanPark(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + stop func(t *testing.T, a *Service, b *stagedFake, host string) |
| + clean bool |
| + }{ |
| + {"parked by the sweep", func(t *testing.T, a *Service, b *stagedFake, host string) { |
| + a.sweepOnce() |
| + }, true}, |
| + {"lease lost, then the sweep", func(t *testing.T, a *Service, b *stagedFake, host string) { |
| + b.lastRep().setErr(errors.New("renewal failed")) |
| + a.sweepOnce() |
| + }, false}, |
| + {"fenced", func(t *testing.T, a *Service, b *stagedFake, host string) { |
| + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
| + defer cancel() |
| + if _, err := a.FenceAndStop(ctx, host, 1); err != nil { |
| + t.Errorf("FenceAndStop: %v", err) |
| + } |
| + }, false}, |
| + {"parked, then fenced before the result", func(t *testing.T, a *Service, b *stagedFake, host string) { |
| + a.sweepOnce() |
| + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
| + defer cancel() |
| + if _, err := a.FenceAndStop(ctx, host, 1); err != nil { |
| + t.Errorf("FenceAndStop: %v", err) |
| + } |
| + }, false}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + // A 2 s window polls every 250 ms (settlePoll): the stop below |
| + // lands well before the observation's first look at the exit. |
| + shrinkFirstWake(t, 55*time.Second, 25*time.Second, 2*time.Second) |
| + a, b, _, _, _ := stagedActivator(t) |
| + host := "bot.x.internal" |
| + if err := a.WakeWorker(testWorkerRoute(host)); err != nil { |
| + t.Fatal(err) |
| + } |
| + done := make(chan struct{}) |
| + go func() { |
| + defer close(done) |
| + waitForWorkerStarts(t, b, 2) |
| + waitFor(t, "the observation", func() bool { |
| + a.mu.Lock() |
| + defer a.mu.Unlock() |
| + e := a.entries[host] |
| + return e != nil && e.firstWatch != nil |
| + }) |
| + b.lastWorker().exit() // status 0: the one-shot finished |
| + tc.stop(t, a, b, host) |
| + }() |
| + res := a.ReplaceWorker(context.Background(), workerAttempt(CauseRelease, ModeOverlap, fakeIdentity("v2")), nil, one) |
| + <-done |
| + if tc.clean { |
| + if res.Outcome != SwapDone || !res.CleanExit { |
| + t.Fatalf("ReplaceWorker = %+v, want done with a clean exit: the sweep parked a finished one-shot", res) |
| + } |
| + return |
| + } |
| + if res.Outcome != SwapAbandoned || res.CleanExit { |
| + t.Fatalf("ReplaceWorker = %+v, want abandoned: no clean park with no fence stands behind the exit, so its build must not be recorded good", res) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// awaitExtensionSeam installs wakeBoundExtended for one test: the first |
| +// extension of a waiter's bound lets the wake fail (release) and waits until |
| +// that failed wake has deleted its entry, the window in which a waiter that |
| +// re-read the entries map started a second wake. |
| +func awaitExtensionSeam(t *testing.T, a *Service, release chan struct{}) *atomic.Int32 { |
| + t.Helper() |
| + var calls atomic.Int32 |
| + old := wakeBoundExtended |
| + wakeBoundExtended = func() { |
| + if calls.Add(1) != 1 { |
| + return |
| + } |
| + close(release) |
| + waitFor(t, "the failed wake to delete its entry", func() bool { return a.entryCount() == 0 }) |
| + } |
| + t.Cleanup(func() { wakeBoundExtended = old }) |
| + return &calls |
| +} |
| + |
| +// Round 2 Task 6 (recon B §2): a first wake's waiter extends its bound once |
| +// (spec §8.2) and then waits again on the SAME ready. It used to loop back to |
| +// the entries map: a wake that failed inside the extension had deleted its |
| +// entry, so the waiter found nothing and started a second wake, a second |
| +// lease acquire and a second spawn (#184's shape). |
| +func TestWakeWorkerAWakeFailingInsideTheExtensionStartsNoSecond(t *testing.T) { |
| + old := wakeWaitBound |
| + wakeWaitBound = 50 * time.Millisecond |
| + t.Cleanup(func() { wakeWaitBound = old }) |
| + a, b, _, _, _ := stagedActivator(t) |
| + a.gens = true |
| + a.backend = &plannedHostKill{stagedFake: b} |
| + next := stagedID(t, t.TempDir(), "v2") |
| + a.Planner = func(context.Context, registry.Route) WakePlan { |
| + return WakePlan{Boot: next, Mode: ModeOverlap, FirstWake: true} |
| + } |
| + boom := errors.New("exec refused") |
| + release := make(chan struct{}) |
| + calls := awaitExtensionSeam(t, a, release) |
| + b.mu.Lock() |
| + b.workerStartHook = func(registry.Route) { |
| + <-release |
| + b.mu.Lock() |
| + b.workerErr = boom |
| + b.mu.Unlock() |
| + } |
| + b.mu.Unlock() |
| + err := a.WakeWorker(testWorkerRoute("bot.x.internal")) |
| + if calls.Load() == 0 { |
| + t.Fatal("the waiter never extended its bound: the test did not reach the window") |
| + } |
| + if !errors.Is(err, boom) { |
| + t.Fatalf("WakeWorker = %v, want the wake's own error", err) |
| + } |
| + if acquires, _, _ := b.counts(); acquires != 1 { |
| + t.Fatalf("%d lease acquires, want 1: a waiter whose wake failed inside the extension started a second wake", acquires) |
| + } |
| +} |
| + |
| +// The same window for a request (begin): the failed wake reports to its |
| +// waiter, and nothing wakes the instance a second time. |
| +func TestBeginAWakeFailingInsideTheExtensionStartsNoSecond(t *testing.T) { |
| + old := wakeWaitBound |
| + wakeWaitBound = 50 * time.Millisecond |
| + t.Cleanup(func() { wakeWaitBound = old }) |
| + next := stagedID(t, t.TempDir(), "v2") |
| + a, b, _ := planActivator(t, WakePlan{Boot: next, Mode: ModeOverlap, FirstWake: true}) |
| + boom := errors.New("GET / answered 500") |
| + release := make(chan struct{}) |
| + calls := awaitExtensionSeam(t, a, release) |
| + a.genHealth = func(context.Context, Gen) error { |
| + <-release |
| + return boom |
| + } |
| + _, err := a.beginRequest(context.Background(), testRoute("one.example.com")) |
| + if calls.Load() == 0 { |
| + t.Fatal("the waiter never extended its bound: the test did not reach the window") |
| + } |
| + if err == nil || errors.Is(err, errWakeTimeout) { |
| + t.Fatalf("beginRequest = %v, want the failed wake's error", err) |
| + } |
| + if acquires, _, _ := b.counts(); acquires != 1 { |
| + t.Fatalf("%d lease acquires, want 1: a waiter whose wake failed inside the extension started a second wake", acquires) |
| + } |
| + if gens := b.started(); len(gens) != 1 { |
| + t.Fatalf("spawned %+v, want one generation", gens) |
| + } |
| +} |