carlos / platform Public

zdd-5 task 12: instance generations in the generations file; publication gates staged spawns; GoodVersion in root's pins

Extends plan 4's generationsSnapshotLocked (no second writer): every
entry's current generation, a hold's pin, a planned wake's build and
fallback, every operation-set member, retained failed wakes' generations
and a socketless attempt's builds. With generations off an instance adds
nothing, so the flag-off file is unchanged.

A planned or pinned wake (doWake, doClaimWake) publishes before the
lease, a replan publishes before its exec (checkPlan), and an overlap
successor publishes before Step 3a. A failed write refuses the spawn with
ErrBuildNotPublished: abandonment, never a failure of the build.

release.BoxPins and the host version sweep pin GoodVersion.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 221f63e0d86585089ab5ba73f5724bb32902b88c parent 3d7d7ea
14 files changed, +660 −42
  • internal/activator/activator.go +35 −9
  • internal/activator/claimwake.go +12 −0
  • internal/activator/generations_file.go +116 −21
  • internal/activator/generations_instances_test.go +423 −0
  • internal/activator/opset.go +2 −2
  • internal/activator/swap.go +10 −1
  • internal/activator/wakeplan.go +6 −0
  • internal/activator/wakeplan_test.go +4 −2
  • internal/activator/workerbuild.go +7 −5
  • internal/activator/workerswap.go +3 −0
  • internal/host/versionsweep.go +5 −0
  • internal/host/versionsweep_test.go +17 −0
  • internal/release/pins.go +3 −2
  • internal/release/pins_test.go +17 −0
diff --git a/internal/activator/activator.go b/internal/activator/activator.go
index 1b3370b6e..efc47a9fe 100644
--- a/internal/activator/activator.go
+++ b/internal/activator/activator.go
@@ -1015,10 +1015,12 @@ type Service struct {
servingMu sync.Mutex
// GenerationsFile, when set, is where the activator publishes every
- // worker's captured serving build for root's pin set
- // (release.GenerationsPath of the data dir root's passes use as BakeDir;
- // see generations.go). Empty publishes nothing. Set before Start and
- // before any wake, like OnWake.
+ // worker's captured serving build and every live or pending instance
+ // generation's build for root's pin set (release.GenerationsPath of the
+ // data dir root's passes use as BakeDir; see generations_file.go). A
+ // staged spawn whose build cannot be written there first is refused.
+ // Empty publishes nothing. Set before Start and before any wake, like
+ // OnWake.
GenerationsFile string
// schedMu guards schedFences, each host's schedule write fence
@@ -1371,9 +1373,18 @@ func (a *Service) wake(e *actEntry) {
servingID release.StagedIdentity
servingInc uint64
publishServing bool
+ republish bool
)
a.mu.Lock()
if err != nil {
+ // The failed wake's entry goes, and with it everything it published
+ // (a worker's captured build, a plan's builds, a pinned build); a
+ // generation retained as an unproven wake is listed from there.
+ republish = e.worker || a.publishesLocked(e)
+ if u := a.unprovenWakes[e.host]; u != nil {
+ _, listed := a.genIdentity(u.gen)
+ republish = republish || listed
+ }
e.err = err
outcome := &stopObservation{uncertain: true}
if w != nil && w.stopOutcome != nil {
@@ -1429,6 +1440,9 @@ func (a *Service) wake(e *actEntry) {
if e.gen.N != 0 {
a.noteCurrent(e.host, e.gen.N)
}
+ // An instance's current generation is in the generations file from
+ // here (spec §8.5). A worker's build was published at its install.
+ republish = !e.worker && a.publishesLocked(e)
if e.sidecarGen == 0 {
// A wake that carried no generation number (the claim path) ran
// its sidecar on the live link, which is that wake's own build.
@@ -1482,9 +1496,9 @@ func (a *Service) wake(e *actEntry) {
// publication that blocks cannot hold that record up.
a.publishWorkerServing(e, servingID, servingInc)
}
- if err != nil && e.worker {
- // The failed wake's entry is gone, and with it any build its spawn
- // published (generations.go).
+ if republish {
+ // After a change, the write only logs: the file it failed to
+ // replace pins at least as much, and the sweep retries it.
_ = a.persistGenerations()
}
}
@@ -1663,6 +1677,16 @@ func (a *Service) doWake(ctx context.Context, e *actEntry) (*wokenInstance, erro
e.plan, e.firstWake = plan, plan.FirstWake
a.mu.Unlock()
}
+ // 0.8. A staged build, planned or pinned, is published before anything
+ // execs it (spec §4.2, §8.5): the entry now names it (and a plan's
+ // fallback), so the write puts both in root's pin set. A failed write
+ // refuses the wake here, before the lease: nothing is taken, nothing
+ // runs, and no build is blamed (ErrBuildNotPublished).
+ if plan.planned() || e.pin != nil {
+ if err := a.publishBeforeExec(); err != nil {
+ return w, fmt.Errorf("%s: refusing to wake: %w", host, err)
+ }
+ }
// 1. The lease first: everything after this line is done as the prefix's
// single writer. Contention is not retried here — on one box it means a
@@ -3417,6 +3441,7 @@ func (a *Service) finishStop(e *actEntry, outcome *stopObservation) {
outcome.uncertain = !outcome.complete()
e.stopOutcome = outcome
a.rememberStopLocked(e.host, outcome)
+ republish := e.worker || a.publishesLocked(e)
delete(a.entries, e.host)
retired := e.gen
// The canonical link goes with the last generation (spec §7.1). Under
@@ -3439,8 +3464,9 @@ func (a *Service) finishStop(e *actEntry, outcome *stopObservation) {
h.woke <- holdWoke{next: next, err: herr}
}
a.mu.Unlock()
- if e.worker {
- // Its child is gone, so its build is no longer referenced by it.
+ if republish {
+ // Its processes are gone, so its builds are no longer referenced by
+ // them.
_ = a.persistGenerations()
}
// The generation stopped being current when its entry went (spec §7.2:
diff --git a/internal/activator/claimwake.go b/internal/activator/claimwake.go
index b0188ba60..b368192a0 100644
--- a/internal/activator/claimwake.go
+++ b/internal/activator/claimwake.go
@@ -201,6 +201,18 @@ func (a *Service) doClaimWake(ctx context.Context, e *actEntry) (*wokenInstance,
a.mu.Unlock()
}
}
+ // A pinned or planned build is published before the admission, as
+ // doWake does before its lease (spec §4.2, §8.5): claimed wakes never
+ // reach doWake's publication. A failed write refuses the wake with
+ // nothing admitted and no build blamed.
+ a.mu.Lock()
+ staged := e.pin != nil || e.plan.planned()
+ a.mu.Unlock()
+ if staged {
+ if err := a.publishBeforeExec(); err != nil {
+ return nil, fmt.Errorf("%s: refusing to wake: %w", rt.Host, err)
+ }
+ }
policy := startPolicy{MaxStarts: 5, Window: 30 * time.Second, Pin: e.pin}
if e.pin != nil && e.pinCheck != nil {
// A claims-on hold's pinned wake: the attempt gate before anything
diff --git a/internal/activator/generations_file.go b/internal/activator/generations_file.go
index 9029ca3e3..2cbcd5848 100644
--- a/internal/activator/generations_file.go
+++ b/internal/activator/generations_file.go
@@ -23,6 +23,24 @@ import (
// (when it has one). It does so whatever CARLOS_SWAP says; this
// flag-independent writer is the one plan 5 extends.
//
+// Plan 5 (Task 12) adds every live or pending INSTANCE generation (spec
+// §8.5: current, successor, retiring, spawning): the current generation, a
+// hold's pinned one, a planned wake's build and its fallback, every
+// operation-set member, a failed wake retained until its end is proven, and
+// a socketless attempt's builds. With generations off an instance runs from
+// the live link, which root pins already, so an instance generation is
+// listed only with generations on or when it was exec'd from a staged path
+// (genIdentity); a flag-off file says exactly what plan 4's said.
+//
+// Every staged instance spawn (a planned wake and its fallback, a hold's
+// pinned wake, an overlap successor) publishes the entry state that names
+// its build BEFORE the exec, and does not exec when that write fails
+// (ErrBuildNotPublished): doWake and doClaimWake before the lease, checkPlan
+// for a replanned build, swapOverlap before its Step 3a. The entry state is
+// set first (e.plan, e.pin, the operation set) and then written, rather than
+// recorded as a pending build: that state already lives as long as the
+// spawn's process may, so nothing has to be dropped when the spawn installs.
+//
// The file is written before the first worker spawn (so it exists, even
// empty, before any worker runs), before every staged spawn's exec, naming
// the build about to run (startWorker's before-exec hook, ruling R19 item 3),
@@ -68,11 +86,9 @@ func (a *Service) dropPendingGenerationLocked(k pendingGeneration) {
}
// generationsSnapshotLocked is what the generations file should say now: for
-// every worker entry (waking, awake, mid-respawn or stopping, since its child
-// may still run) its captured serving build and the build of any shed child
-// its operation set (or, with generations off, the entry) retains, the build
-// of every retained failed worker wake (unprovenWakes), and every pending
-// build, each
+// every entry (waking, awake, mid-swap, mid-respawn or stopping, since its
+// processes may still run) what entryGenerationsLocked names, the build of
+// every retained failed wake (unprovenWakes), and every pending build, each
// listed under its host by version when the version is a valid version name,
// and by staged path (made absolute) when it has one. A build with neither
// names nothing root could restage or sweep. Each host's lists are sorted and
@@ -101,24 +117,16 @@ func (a *Service) generationsSnapshotLocked() release.Generations {
}
}
for host, e := range a.entries {
- if e.worker {
- add(host, e.workerServing)
- }
- // A shed respawn child retained in the operation set until its
- // termination is proven (final review F12).
- for _, m := range e.ops.members {
- add(host, m.build)
- }
- // With generations off, the entry keeps such a child itself
- // (final review R2-1).
- if e.shed != nil {
- add(host, e.shed.build)
- }
+ a.entryGenerationsLocked(e, func(id release.StagedIdentity) { add(host, id) })
}
- // A failed worker wake retained until its termination is proven: its
- // entry is gone, but its process may still run the build (F12).
+ // A failed wake retained until its termination is proven: its entry is
+ // gone, but its process may still run the build (F12; spec §4.2, §8.5).
+ // A worker's is its captured identity, an instance's its generation.
for host, u := range a.unprovenWakes {
add(host, u.build)
+ if id, ok := a.genIdentity(u.gen); ok {
+ add(host, id)
+ }
}
for _, pb := range a.genPending {
add(pb.host, pb.id)
@@ -126,6 +134,93 @@ func (a *Service) generationsSnapshotLocked() release.Generations {
return release.Generations{Hosts: sortedLists(hosts), Paths: sortedLists(paths)}
}
+// entryGenerationsLocked calls add with every build e's processes run or may
+// be about to run (spec §8.5):
+// - a worker's captured serving build;
+// - the current generation, and every operation-set member's (successor,
+// retiring, spawning), and the build of a shed worker child the set (or,
+// with generations off, the entry) retains (final review F12, R2-1);
+// - a hold's pinned generation, from the moment its waking entry exists;
+// - a planned wake's build and its fallback, from the moment the plan is
+// stored, so both are on disk before either execs;
+// - a socketless attempt's build and fallback (Task 11).
+//
+// The plan's builds stay listed while the entry lives, as e.plan does
+// (afterWake reads it after the wake): a few more pins than the processes
+// strictly need, never fewer. The caller holds a.mu.
+func (a *Service) entryGenerationsLocked(e *actEntry, add func(release.StagedIdentity)) {
+ gen := func(g Gen) {
+ if id, ok := a.genIdentity(g); ok {
+ add(id)
+ }
+ }
+ if e.worker {
+ add(e.workerServing)
+ }
+ gen(e.gen)
+ if e.pin != nil {
+ gen(*e.pin)
+ }
+ if e.plan.planned() {
+ add(e.plan.Boot)
+ if e.plan.Fallback != nil {
+ add(*e.plan.Fallback)
+ }
+ }
+ for _, m := range e.ops.members {
+ add(m.build)
+ gen(m.gen)
+ }
+ if e.shed != nil {
+ add(e.shed.build)
+ }
+ for _, v := range e.attemptBuilds {
+ add(release.StagedIdentity{Version: v})
+ }
+}
+
+// genIdentity is g as the generations file names it, and whether it is
+// listed at all. A generation exec'd from a staged path is listed whatever
+// the switch says. Otherwise it is listed only with generations on: with
+// them off an instance's generation is the live link's build, which root
+// pins as the row's Version already, and plan 4's flag-off file never
+// named it.
+func (a *Service) genIdentity(g Gen) (release.StagedIdentity, bool) {
+ if g.StagedPath == "" && (!a.gens || g.Build == "") {
+ return release.StagedIdentity{}, false
+ }
+ return release.StagedIdentity{Version: g.Build, StagedPath: g.StagedPath}, true
+}
+
+// publishesLocked reports whether e names anything in the generations file,
+// so a change that removes e must rewrite it. The caller holds a.mu.
+func (a *Service) publishesLocked(e *actEntry) bool {
+ named := false
+ a.entryGenerationsLocked(e, func(id release.StagedIdentity) {
+ if id.StagedPath != "" || release.ValidVersion(id.Version) {
+ named = true
+ }
+ })
+ return named
+}
+
+// publishBeforeExec writes the generations file before a staged instance
+// spawn's exec (spec §4.2, §8.5; the index's generations-file contract).
+// The caller has already put the build about to run on its entry (e.plan,
+// e.pin, the operation set), so the snapshot names it. When the write fails
+// the caller does not exec: root reads only what the file names, and could
+// sweep or restage a build it does not name under the process. The refusal
+// is ErrBuildNotPublished, which classifyStep reads as abandonment, never a
+// failure of the build. The write error itself goes to the operator log
+// (persistGenerations), not into the returned error, which may reach a
+// member's wake event. Never call it with a.mu held.
+func (a *Service) publishBeforeExec() error {
+ if err := a.persistGenerations(); err != nil {
+ return ErrBuildNotPublished
+ }
+ return nil
+}
+
func sortedLists(m map[string]map[string]bool) map[string][]string {
out := make(map[string][]string, len(m))
for k, set := range m {
@@ -161,7 +256,7 @@ func (a *Service) persistGenerationsGenLocked() error {
a.mu.Unlock()
if err := release.WriteGenerations(a.GenerationsFile, g); err != nil {
a.genDirty = true
- log.Printf("activator: publish worker generations to %s: %v; the sweep retries", a.GenerationsFile, err)
+ log.Printf("activator: publish generations to %s: %v; the sweep retries", a.GenerationsFile, err)
return err
}
a.genWritten = true
diff --git a/internal/activator/generations_instances_test.go b/internal/activator/generations_instances_test.go
new file mode 100644
index 000000000..238406ad2
--- /dev/null
+++ b/internal/activator/generations_instances_test.go
@@ -0,0 +1,423 @@
+package activator
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "reflect"
+ "slices"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// Zero-downtime plan 5 Task 12: the generations file names every live or
+// pending INSTANCE generation too (spec §4.2, §8.5), and the build a staged
+// instance spawn is about to exec is in a successfully written file before
+// that exec, or the spawn does not happen.
+
+// hostsOf is the generations file's hosts, or nil when it does not read.
+func hostsOf(path string) map[string][]string {
+ g, err := release.ReadGenerations(path)
+ if err != nil || g == nil {
+ return nil
+ }
+ return g.Hosts
+}
+
+// waitForPublications waits until the generations file at path has had n
+// publications (its seq). A wake goroutine's last write comes after the
+// request it served has returned; a test whose file lives in a TempDir
+// waits for it, or the write races the directory's removal.
+func waitForPublications(t *testing.T, path string, n uint64) *release.Generations {
+ t.Helper()
+ var g *release.Generations
+ waitFor(t, "the wake's last publication", func() bool {
+ var err error
+ g, err = release.ReadGenerations(path)
+ return err == nil && g != nil && g.Seq >= n
+ })
+ return g
+}
+
+// unwritableGenerationsFile is a path whose parent is a regular file, so
+// every write fails.
+func unwritableGenerationsFile(t *testing.T) string {
+ t.Helper()
+ blocker := filepath.Join(t.TempDir(), "not-a-dir")
+ if err := os.WriteFile(blocker, nil, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return filepath.Join(blocker, "zdd", "generations.json")
+}
+
+// breakGenerationsFile makes every later write of path fail: its directory
+// is replaced by a regular file.
+func breakGenerationsFile(t *testing.T, path string) {
+ t.Helper()
+ dir := filepath.Dir(path)
+ if err := os.RemoveAll(dir); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(dir, nil, 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestTheGenerationsFileFollowsEveryChange(t *testing.T) {
+ dir := t.TempDir()
+ good := stagedID(t, dir, "v1")
+ a, _, _ := planActivator(t, WakePlan{Boot: good, Mode: ModeOverlap})
+ a.GenerationsFile = release.GenerationsPath(dir)
+ if err := a.persistGenerations(); err != nil {
+ t.Fatal(err)
+ }
+ g, err := release.ReadGenerations(a.GenerationsFile)
+ if err != nil || g == nil || len(g.Hosts) != 0 {
+ t.Fatalf("before any spawn: %+v, %v; want an empty file, written", g, err)
+ }
+ done, err := a.beginRequest(context.Background(), testRoute("one.example.com"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ done()
+ g, err = release.ReadGenerations(a.GenerationsFile)
+ if err != nil || !slices.Equal(g.Hosts["one.example.com"], []string{"v1"}) {
+ t.Fatalf("awake: %+v, %v", g, err)
+ }
+ if !slices.Equal(g.Paths["one.example.com"], []string{good.StagedPath}) {
+ t.Fatalf("awake: paths %v, want the staged %s", g.Paths, good.StagedPath)
+ }
+ a.BounceFor("one.example.com", CauseRestart)
+ // Four publications: the empty file, the plan before the lease, the
+ // awake generation, the stop (in either order with the third).
+ if g := waitForPublications(t, a.GenerationsFile, 4); len(g.Hosts) != 0 || len(g.Paths) != 0 {
+ t.Fatalf("after the stop: %+v, want no generations", g)
+ }
+}
+
+func TestTheGenerationsFileNamesThePlannedBuildBeforeItSpawns(t *testing.T) {
+ dir := t.TempDir()
+ good, next := stagedID(t, dir, "v1"), stagedID(t, dir, "v2")
+ a, b, _ := planActivator(t, WakePlan{Boot: next, Mode: ModeOverlap, FirstWake: true, Fallback: &good})
+ a.GenerationsFile = filepath.Join(dir, "zdd", "generations.json")
+ b.gate = make(chan struct{})
+ woke := make(chan error, 1)
+ go func() {
+ done, err := a.beginRequest(context.Background(), testRoute("one.example.com"))
+ if err == nil {
+ done()
+ }
+ woke <- err
+ }()
+ waitFor(t, "the spawning build and its fallback pinned before the spawn", func() bool {
+ return len(b.started()) == 0 && slices.Equal(hostsOf(a.GenerationsFile)["one.example.com"], []string{"v1", "v2"})
+ })
+ close(b.gate)
+ if err := <-woke; err != nil {
+ t.Fatal(err)
+ }
+ waitForPublications(t, a.GenerationsFile, 2) // the plan, then the awake generation
+}
+
+// Spec §7.4, §8.1: a planned build the selection gate refuses after the
+// restore is planned again, and the replanned build is published before it
+// execs; when it cannot be, it is not exec'd, and that is no failure of
+// either build.
+func TestAReplannedBuildIsPublishedBeforeItExecs(t *testing.T) {
+ for _, broken := range []bool{false, true} {
+ name := "published"
+ if broken {
+ name = "unpublished"
+ }
+ t.Run(name, func(t *testing.T) {
+ dir := t.TempDir()
+ v2, v3 := stagedID(t, dir, "v2"), stagedID(t, dir, "v3")
+ a, b, evs := planActivator(t, WakePlan{})
+ a.GenerationsFile = filepath.Join(dir, "zdd", "generations.json")
+ var plans atomic.Int32
+ a.Planner = func(context.Context, registry.Route) WakePlan {
+ if plans.Add(1) == 1 {
+ return WakePlan{Boot: v2, Mode: ModeOverlap, Check: func(build string) error {
+ if build != "v2" {
+ return nil
+ }
+ if broken {
+ breakGenerationsFile(t, a.GenerationsFile)
+ }
+ return errors.New("v2 is held now")
+ }}
+ }
+ return WakePlan{Boot: v3, Mode: ModeOverlap}
+ }
+ if broken {
+ _, err := a.beginRequest(context.Background(), testRoute("one.example.com"))
+ if !errors.Is(err, ErrBuildNotPublished) {
+ t.Fatalf("err = %v, want ErrBuildNotPublished", err)
+ }
+ if gens := b.started(); len(gens) != 0 {
+ t.Fatalf("spawned %+v with the replanned build unpublished", gens)
+ }
+ if k := evs.kinds(); len(k) != 0 {
+ t.Fatalf("events %v: a publication failure is no failure of the build", k)
+ }
+ return
+ }
+ b.gate = make(chan struct{})
+ woke := make(chan error, 1)
+ go func() {
+ done, err := a.beginRequest(context.Background(), testRoute("one.example.com"))
+ if err == nil {
+ done()
+ }
+ woke <- err
+ }()
+ waitFor(t, "the replanned v3 pinned before its exec", func() bool {
+ return len(b.started()) == 0 && slices.Contains(hostsOf(a.GenerationsFile)["one.example.com"], "v3")
+ })
+ close(b.gate)
+ if err := <-woke; err != nil {
+ t.Fatal(err)
+ }
+ if gens := b.started(); len(gens) != 1 || gens[0].Build != "v3" {
+ t.Fatalf("spawned %+v, want the replanned v3 alone", gens)
+ }
+ waitForPublications(t, a.GenerationsFile, 3) // the plan, the replan, the awake generation
+ })
+ }
+}
+
+// Spec §4.2, §8.5: a failed wake whose generation is not proven terminated
+// keeps its lease and its process may still run (plan 3, unprovenWakes),
+// though its entry is gone. Its build stays in the generations file until
+// the proof holds.
+func TestARetainedFailedWakeStaysPinned(t *testing.T) {
+ a, _, _ := planActivator(t, WakePlan{})
+ a.GenerationsFile = filepath.Join(t.TempDir(), "zdd", "generations.json")
+ a.mu.Lock()
+ a.unprovenWakes = map[string]*unprovenWake{"one.example.com": {gen: Gen{N: 4, Build: "v2", StagedPath: "/opt/carlos/versions/v2/app"}}}
+ a.mu.Unlock()
+ if err := a.persistGenerations(); err != nil {
+ t.Fatal(err)
+ }
+ g, err := release.ReadGenerations(a.GenerationsFile)
+ if err != nil || g == nil || !slices.Equal(g.Hosts["one.example.com"], []string{"v2"}) ||
+ !slices.Equal(g.Paths["one.example.com"], []string{"/opt/carlos/versions/v2/app"}) {
+ t.Fatalf("generations %+v, %v: want the retained failed wake's v2 pinned", g, err)
+ }
+}
+
+// Spec §8.5: every generation of the operation set is named, the successor
+// before its exec, and the retiring one until its termination is proven.
+func TestTheGenerationsFileFollowsAnOverlapSwap(t *testing.T) {
+ f := newSwapFixture(t)
+ f.a.GenerationsFile = release.GenerationsPath(t.TempDir())
+ gate := make(chan struct{})
+ f.b.gmu.Lock()
+ f.b.genGate = gate
+ f.b.gmu.Unlock()
+ res := make(chan SwapResult, 1)
+ go func() { res <- f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest) }()
+ waitFor(t, "the successor pinned beside the incumbent before its exec", func() bool {
+ gens, _, _ := f.b.calls()
+ return gens == 1 && slices.Equal(hostsOf(f.a.GenerationsFile)[f.host], []string{"v1", "v2"})
+ })
+ close(gate)
+ if r := <-res; r.Outcome != SwapDone {
+ t.Fatalf("Swap = %+v, want done", r)
+ }
+ waitForGenerations(t, f.a.GenerationsFile, map[string][]string{f.host: {"v2"}})
+}
+
+// Spec §4.2, §8.5: root pins only what the file names. A staged instance
+// spawn whose build could not be published must not exec it. The refusal is
+// no failure of the build, takes no lease, and is never down.
+func TestAnUnpublishedBuildIsNeverSpawned(t *testing.T) {
+ t.Run("planned wake", func(t *testing.T) {
+ dir := t.TempDir()
+ good, next := stagedID(t, dir, "v1"), stagedID(t, dir, "v2")
+ a, b, evs := planActivator(t, WakePlan{Boot: next, Mode: ModeOverlap, FirstWake: true, Fallback: &good})
+ a.GenerationsFile = unwritableGenerationsFile(t)
+ _, err := a.beginRequest(context.Background(), testRoute("one.example.com"))
+ if !errors.Is(err, ErrBuildNotPublished) {
+ t.Fatalf("err = %v, want ErrBuildNotPublished", err)
+ }
+ if gens := b.started(); len(gens) != 0 {
+ t.Fatalf("spawned %+v with the generations file unwritten", gens)
+ }
+ if acquires, _, _ := b.counts(); acquires != 0 {
+ t.Fatal("an unpublished wake took the lease")
+ }
+ if k := evs.kinds(); len(k) != 0 {
+ t.Fatalf("events %v: a publication failure is no failure of the build", k)
+ }
+ })
+ t.Run("overlap successor", func(t *testing.T) {
+ f := newSwapFixture(t)
+ f.a.GenerationsFile = unwritableGenerationsFile(t)
+ res := f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest)
+ if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, ErrBuildNotPublished.Error()) {
+ t.Fatalf("Swap = %+v, want abandoned: unpublished", res)
+ }
+ if gens, raises, restores := f.b.calls(); gens != 0 || raises != restores {
+ t.Fatalf("spawned %d, raises %d, restores %d", gens, raises, restores)
+ }
+ f.a.mu.Lock()
+ members := len(f.a.entries[f.host].ops.members)
+ f.a.mu.Unlock()
+ if members != 0 {
+ t.Fatalf("%d members left in the operation set", members)
+ }
+ })
+ t.Run("hold's pinned wake", func(t *testing.T) {
+ f := newSwapFixture(t)
+ sink := &lockedSink{}
+ f.a.Logs = sink
+ f.a.GenerationsFile = unwritableGenerationsFile(t)
+ res := f.a.Swap(context.Background(), f.attempt(ModeHold), f.newest)
+ if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, ErrBuildNotPublished.Error()) {
+ t.Fatalf("Swap = %+v, want abandoned: unpublished", res)
+ }
+ if gens, _, _ := f.b.calls(); gens != 0 {
+ t.Fatalf("spawned %d generations with the pinned build unpublished", gens)
+ }
+ if got := sink.errors(); len(got) != 0 {
+ t.Fatalf("member error events %q, want none for an abandoned hold", got)
+ }
+ })
+ t.Run("claimed planned wake", func(t *testing.T) {
+ dir := t.TempDir()
+ good, next := stagedID(t, dir, "v1"), stagedID(t, dir, "v2")
+ a, admits, prepares := claimedPlanActivator(t, WakePlan{Boot: next, Mode: ModeOverlap, FirstWake: true, Fallback: &good})
+ a.GenerationsFile = unwritableGenerationsFile(t)
+ _, err := a.beginRequest(context.Background(), registry.Route{Host: "x.example", Kind: "instance", Hibernate: true})
+ if !errors.Is(err, ErrBuildNotPublished) {
+ t.Fatalf("err = %v, want ErrBuildNotPublished", err)
+ }
+ if admits.Load() != 0 || prepares.Load() != 0 {
+ t.Fatalf("admitted %d, prepared %d: an unpublished claimed wake reached the admission", admits.Load(), prepares.Load())
+ }
+ })
+}
+
+// claimedPlanActivator is a claims-on Service whose planner answers plan,
+// as TestAPlannedClaimedWakeRefusesAtTheLaunchRecordingNothing builds it,
+// counting the admissions and preparations a wake reaches.
+func claimedPlanActivator(t *testing.T, plan WakePlan) (*Service, *atomic.Int32, *atomic.Int32) {
+ t.Helper()
+ const host = "x.example"
+ obs := claimedObs(host)
+ obs.Claim.Op.Exec = "box"
+ obs.Claim.Holder.WriterBox = "box"
+ admitted := obs
+ claim := *obs.Claim
+ holder := *claim.Holder
+ holder.Session, holder.Generation = 2, "two"
+ claim.Holder = &holder
+ admitted.Claim = &claim
+ work, _ := (hostwrite.Config{}).Select(context.Background(), host, "spawn")
+ var (
+ reads atomic.Int32
+ admits atomic.Int32
+ prepares atomic.Int32
+ )
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ a := New(Config{Bucket: "test-bucket", Idle: time.Hour, HostWrites: hostwrite.Config{Enabled: true}, Claims: &ClaimRuntime{Box: "box",
+ Read: func(context.Context, string) (hostclaim.Observed, error) {
+ if reads.Add(1) == 1 {
+ return obs, nil
+ }
+ return admitted, nil
+ },
+ StopForeign: func(context.Context, string) error { return nil },
+ FailedAdmission: func(context.Context, hostclaim.Observed, error) error { return nil },
+ Prerequisites: func(context.Context, registry.Route, hostclaim.Observed, startPolicy) error { return nil },
+ Admit: func(context.Context, registry.Route, hostclaim.Observed, startPolicy) (hostclaim.Observed, error) {
+ admits.Add(1)
+ return admitted, nil
+ },
+ Prepare: func(_ context.Context, _ registry.Route, _ hostclaim.Observed, p startPolicy) (ClaimStart, error) {
+ prepares.Add(1)
+ return claimedStartFor(admitted, work, p.Pin.Build), nil
+ }}}, &fakeBackend{}, reg)
+ a.OnGen = (&genLog{}).add
+ a.Planner = func(context.Context, registry.Route) WakePlan { return plan }
+ return a, &admits, &prepares
+}
+
+// The flag-independent behaviour (rulings R17, R19): with generations off
+// the file says exactly what plan 4's writer said. A worker's captured build
+// is listed by version and by staged path; an instance, which runs from the
+// live link, adds nothing.
+func TestTheGenerationsFileIsUnchangedWithTheFlagOff(t *testing.T) {
+ a, _, _, _, _ := stagedActivator(t) // generations off, no planner
+ a.GenerationsFile = release.GenerationsPath(t.TempDir())
+ const bot = "bot.x.internal"
+ if err := a.WakeWorker(alwaysOnWorkerRoute(bot)); err != nil {
+ t.Fatal(err)
+ }
+ done, err := a.beginRequest(context.Background(), testRoute("one.example.com"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ done()
+ a.mu.Lock()
+ instGen := a.entries["one.example.com"].gen
+ a.mu.Unlock()
+ if instGen.Build == "" {
+ t.Fatal("the instance's generation names no build: this test would prove nothing")
+ }
+ want := map[string][]string{bot: {"v1"}}
+ wantPaths := map[string][]string{bot: {fakeIdentity("v1").StagedPath}}
+ check := func(when string) {
+ t.Helper()
+ if err := a.persistGenerations(); err != nil {
+ t.Fatal(err)
+ }
+ g := readGenerationsFile(t, a.GenerationsFile)
+ if !reflect.DeepEqual(g.Hosts, want) || !reflect.DeepEqual(g.Paths, wantPaths) {
+ t.Fatalf("%s: flag-off file names %v and paths %v, want %v and %v", when, g.Hosts, g.Paths, want, wantPaths)
+ }
+ }
+ // With the instance awake, after a sweep, and after its stop: none of
+ // it may add the instance.
+ check("both awake")
+ a.sweepOnce()
+ check("after a sweep")
+ a.mu.Lock()
+ a.beginStopLocked(a.entries["one.example.com"])
+ a.mu.Unlock()
+ waitFor(t, "the instance's stop", func() bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return a.entries["one.example.com"] == nil
+ })
+ check("after the instance's stop")
+}
+
+// Task 11's socketless attempt names its build and its fallback while it
+// runs (its fallback above all: startWorker publishes only the build about
+// to exec).
+func TestAWorkerAttemptsBuildsArePublished(t *testing.T) {
+ a, _, _ := planActivator(t, WakePlan{})
+ a.GenerationsFile = filepath.Join(t.TempDir(), "zdd", "generations.json")
+ a.mu.Lock()
+ a.entries["bot.x.internal"] = &actEntry{host: "bot.x.internal", worker: true, attemptBuilds: []string{"v3", "v1"}}
+ a.mu.Unlock()
+ if err := a.persistGenerations(); err != nil {
+ t.Fatal(err)
+ }
+ if got := hostsOf(a.GenerationsFile)["bot.x.internal"]; !slices.Equal(got, []string{"v1", "v3"}) {
+ t.Fatalf("attempt builds published as %v, want [v1 v3]", got)
+ }
+}
diff --git a/internal/activator/opset.go b/internal/activator/opset.go
index f1809303b..d0fb79bf5 100644
--- a/internal/activator/opset.go
+++ b/internal/activator/opset.go
@@ -329,7 +329,7 @@ func (a *Service) leaveSet(e *actEntry, m *genMember) {
}
a.mu.Unlock()
close(m.done)
- if m.build != (release.StagedIdentity{}) {
+ if _, listed := a.genIdentity(m.gen); listed || m.build != (release.StagedIdentity{}) {
// Its child is proven gone: its build is no longer referenced.
_ = a.persistGenerations()
}
@@ -622,7 +622,7 @@ func (a *Service) reapUnprovenWake(host string, u *unprovenWake) {
}
u.busy = false
a.mu.Unlock()
- if u.build != (release.StagedIdentity{}) {
+ if _, listed := a.genIdentity(u.gen); listed || u.build != (release.StagedIdentity{}) {
// Proven gone: its build is no longer referenced.
_ = a.persistGenerations()
}
diff --git a/internal/activator/swap.go b/internal/activator/swap.go
index d4b302af5..55238eb06 100644
--- a/internal/activator/swap.go
+++ b/internal/activator/swap.go
@@ -436,8 +436,17 @@ func (a *Service) swapOverlap(ctx context.Context, gb SwapBackend, at Attempt, n
return abandon(fmt.Errorf("raise parent limit: %w", err))
}
+ // The successor's build is published before its exec (spec §4.2, §8.5):
+ // it has been in the operation set since admission, so this write names
+ // it. A failed write abandons the attempt; nothing of it ran, so it is
+ // never a failure of the build.
+ if err := a.publishBeforeExec(); err != nil {
+ a.dropMember(e, m) // restores the raised parent (leaveSet)
+ return abandon(err)
+ }
+
// Step 3a: the §7.4 checks again, at the exec (spec §6.3, §7.4). The
- // raise (and plan 5's pin publication, just above this step) took time:
+ // raise (and the pin publication just above this step) took time:
// a newer attempt, a halted cohort, a lost lease or a fence since
// admission means nothing execs. Claims on, this runs inside the
// selected work as the admission did.
diff --git a/internal/activator/wakeplan.go b/internal/activator/wakeplan.go
index 49c2a79b8..2b60d260f 100644
--- a/internal/activator/wakeplan.go
+++ b/internal/activator/wakeplan.go
@@ -80,6 +80,12 @@ func (a *Service) checkPlan(ctx context.Context, e *actEntry, rt registry.Route,
a.mu.Lock()
e.plan, e.firstWake = next, next.FirstWake
a.mu.Unlock()
+ // The replanned build (and its fallback) is published before it execs
+ // (spec §4.2, §8.5). A failed write execs neither: the wake fails with
+ // ErrBuildNotPublished, which is abandonment, never a failure.
+ if err := a.publishBeforeExec(); err != nil {
+ return next, err
+ }
return next, nil
}
diff --git a/internal/activator/wakeplan_test.go b/internal/activator/wakeplan_test.go
index d3895b955..79b7d13aa 100644
--- a/internal/activator/wakeplan_test.go
+++ b/internal/activator/wakeplan_test.go
@@ -920,8 +920,10 @@ func TestAPlannedWorkerRefusedForPublicationRecordsNothing(t *testing.T) {
if n := b.workerStarts(); n != 0 {
t.Fatalf("%d worker children started for an unpublished build", n)
}
- if calls := b.calls(); len(calls) != 1 {
- t.Fatalf("spawn attempts %v: a refusal before the exec fell back", calls)
+ // The plan is published before the lease (Task 12), so the refusal
+ // comes before any spawn is attempted, let alone a fallback.
+ if calls := b.calls(); len(calls) != 0 {
+ t.Fatalf("spawn attempts %v: an unpublished plan reached a spawn", calls)
}
time.Sleep(50 * time.Millisecond)
if k := evs.kinds(); len(k) != 0 {
diff --git a/internal/activator/workerbuild.go b/internal/activator/workerbuild.go
index c17dee2fa..4975ca77f 100644
--- a/internal/activator/workerbuild.go
+++ b/internal/activator/workerbuild.go
@@ -27,11 +27,13 @@ var (
_ stagedWorkerStarter = (*SplitBackend)(nil)
)
-// ErrBuildNotPublished is a worker spawn refused because the build it was
-// about to exec could not be written to the generations file first (ruling
-// R19 item 3): root could not see that build as referenced, so it is not
-// run. The failed-spawn handling applies, and the sweep retries the write.
-var ErrBuildNotPublished = errors.New("worker build not published before its exec")
+// ErrBuildNotPublished is a spawn refused because the build it was about to
+// exec could not be written to the generations file first (ruling R19 item
+// 3; plan 5 Task 12 for staged instance spawns, publishBeforeExec): root
+// could not see that build as referenced, so it is not run. The
+// failed-spawn handling applies, and the sweep retries the write. It is
+// never a failure of the build (classifyStep).
+var ErrBuildNotPublished = errors.New("build not published before its exec")
// beforeExecKey carries the activator's before-exec hook on a spawn's
// context (withBeforeExec).
diff --git a/internal/activator/workerswap.go b/internal/activator/workerswap.go
index 2eef2820e..ba3e30f27 100644
--- a/internal/activator/workerswap.go
+++ b/internal/activator/workerswap.go
@@ -83,6 +83,9 @@ func (a *Service) ReplaceWorker(ctx context.Context, at Attempt, fallback *relea
a.mu.Lock()
e.attemptBuilds = nil
a.mu.Unlock()
+ // The attempt's builds are no longer pinned on its account; what
+ // the worker runs now is its captured build, published at install.
+ _ = a.persistGenerations()
}()
if !a.awaitRespawn(ctx, done) {
diff --git a/internal/host/versionsweep.go b/internal/host/versionsweep.go
index 4579615b1..6ee76ffc8 100644
--- a/internal/host/versionsweep.go
+++ b/internal/host/versionsweep.go
@@ -68,6 +68,11 @@ func sweepStagedVersions(reg routeLister, optDir, genPath string, grace time.Dur
if r.PrevVersion != "" {
pinned[r.PrevVersion] = true
}
+ // GoodVersion likewise (spec §8.5): it is what a failed first wake
+ // falls back to, however long ago it last served.
+ if r.GoodVersion != "" {
+ pinned[r.GoodVersion] = true
+ }
}
// Every live or pending generation the agent published (spec §8.5,
// ruling R19 item 1): a worker's captured build stays pinned after the
diff --git a/internal/host/versionsweep_test.go b/internal/host/versionsweep_test.go
index e2833a04f..493173085 100644
--- a/internal/host/versionsweep_test.go
+++ b/internal/host/versionsweep_test.go
@@ -253,3 +253,20 @@ func TestAdoptAndRestartLocksAreDistinct(t *testing.T) {
}
inner()
}
+
+// Spec §8.5: the good build is a pin, with no grace, like PrevVersion: it is
+// the fallback a failed first wake boots, however long ago it last served.
+func TestSweepStagedVersionsKeepsTheGoodBuild(t *testing.T) {
+ opt := t.TempDir()
+ for _, v := range []string{"good", "gone"} {
+ stageSweepVersion(t, opt, "acct1", v, "km", 48*time.Hour)
+ }
+ reg := fakeLister{routes: []registry.Route{{Host: "a.example.com", Kind: "instance", App: "km", Account: "acct1", Version: "x", GoodVersion: "good"}}}
+ sweepStagedVersions(reg, opt, noGenerations(t), 6*time.Hour, time.Now())
+ if !staged(t, opt, "good") {
+ t.Error("the good build was removed")
+ }
+ if staged(t, opt, "gone") {
+ t.Error("gone survived; nothing names it and it is older than the grace")
+ }
+}
diff --git a/internal/release/pins.go b/internal/release/pins.go
index 9dcc26cb1..e936e228d 100644
--- a/internal/release/pins.go
+++ b/internal/release/pins.go
@@ -85,7 +85,8 @@ func (f PinSourceFunc) StagedPins(ctx context.Context, optDir string) (StagedPat
// relative hop pinned at the real path the OS reaches, and at its lexical
// join too when the two differ), plus the fully resolved real path of the
// chain's end when it exists (StagedPaths.addReal), and the staged path of
-// every row's Version and PrevVersion. routes is every registry row, unfiltered: a hibernating route,
+// every row's Version, PrevVersion and GoodVersion (the good build a failed
+// first wake falls back to, spec §8.5). routes is every registry row, unfiltered: a hibernating route,
// an expired canary and a mid-provision row all still refer to their builds.
//
// An entry that cannot be read is an error, never a skipped entry: a pin set
@@ -119,7 +120,7 @@ func BoxPins(optDir string, routes []registry.Route) (StagedPaths, error) {
if rt.App == "" {
continue
}
- for _, v := range []string{rt.Version, rt.PrevVersion} {
+ for _, v := range []string{rt.Version, rt.PrevVersion, rt.GoodVersion} {
if ValidVersion(v) {
pins.add(VersionPath(optDir, RouteAccount(rt), v, rt.App))
}
diff --git a/internal/release/pins_test.go b/internal/release/pins_test.go
index 1a1ef766b..f076f0009 100644
--- a/internal/release/pins_test.go
+++ b/internal/release/pins_test.go
@@ -340,3 +340,20 @@ func TestStagedPathsRefersComparesTheRealPath(t *testing.T) {
t.Fatalf("Refers(a missing copy) = %v, %v; want false, nil", ok, err)
}
}
+
+// Spec §8.5: the good build is a pin. It is what a failed first wake falls
+// back to and what a hold's recovery boots, long after the row's Version and
+// PrevVersion have moved past it.
+func TestBoxPinsNamesTheGoodBuild(t *testing.T) {
+ opt := t.TempDir()
+ if err := StageArtifact(opt, "bab", "v0", "km", []byte("v0")); err != nil {
+ t.Fatal(err)
+ }
+ pins, err := BoxPins(opt, []registry.Route{{Host: "b.example.com", Kind: "instance", App: "km", Account: "bab", Version: "v2", PrevVersion: "v1", GoodVersion: "v0"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !pins.Has(VersionPath(opt, "bab", "v0", "km")) {
+ t.Fatal("GoodVersion is not a pin (spec §8.5)")
+ }
+}