zdd-4 third fix wave: the hourly version sweep pins what the generations file names (F1(a), R19 item 1)
release.GenerationVersions reads the file through ReadGenerations and returns the version directory of every listed build: each version, and each path inside the version store (as written or by its real path). sweepStagedVersions adds them to its pin set; an unreadable or incomplete file skips the sweep with one log line, and neither file is no generations, as before. The sweep takes the generations path, so the existing sweep tests pass one with neither file present.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+197
−10
internal/host/versionsweep.go+19 −4internal/host/versionsweep_test.go+73 −5internal/release/generations_publish_test.go+40 −0internal/release/pins.go+58 −0internal/release/versions.go+7 −1
diff --git a/internal/host/versionsweep.go b/internal/host/versionsweep.go| index 0bf858e79..4579615b1 100644 |
| --- a/internal/host/versionsweep.go |
| +++ b/internal/host/versionsweep.go |
| @@ -40,11 +40,13 @@ type routeLister interface { |
| // sweepStagedVersions evicts the staged version trees nothing on this box |
| // still names (discussion #111). |
| // |
| +// genPath is the generations file (release.GenerationsPath of the data dir). |
| +// |
| // It never fails the pass. Every failure path here declines to delete and |
| -// returns: an unreadable live dir or an unlistable registry means the pin set |
| -// is incomplete, and an incomplete pin set is how a running app loses its |
| +// returns: an unreadable live dir, an unlistable registry or an incomplete |
| +// generations file means the pin set is incomplete, and an incomplete pin set is how a running app loses its |
| // binary. Freeing disk an hour later is cheap; deleting a live version is not. |
| -func sweepStagedVersions(reg routeLister, optDir string, grace time.Duration, now time.Time) { |
| +func sweepStagedVersions(reg routeLister, optDir, genPath string, grace time.Duration, now time.Time) { |
| pinned, err := release.LivePins(optDir) |
| if err != nil { |
| log.Printf("adopt: staged-version sweep skipped: %v", err) |
| @@ -67,6 +69,19 @@ func sweepStagedVersions(reg routeLister, optDir string, grace time.Duration, no |
| pinned[r.PrevVersion] = 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 |
| + // live link and the row have both moved on. Neither file present is no |
| + // generations; a file that does not read or whose publication did not |
| + // finish is an incomplete pin set. |
| + gens, err := release.GenerationVersions(genPath, optDir) |
| + if err != nil { |
| + log.Printf("adopt: staged-version sweep skipped: generations: %v", err) |
| + return |
| + } |
| + for v := range gens { |
| + pinned[v] = true |
| + } |
| res, err := release.SweepStaged(optDir, pinned, grace, now) |
| if err != nil { |
| log.Printf("adopt: staged-version sweep: %v", err) |
| @@ -90,5 +105,5 @@ func sweepStagedVersions(reg routeLister, optDir string, grace time.Duration, no |
| // sweepStagedVersionsPass is the RunAdopt entry point: the grace from the |
| // environment, the clock from the wall. |
| func sweepStagedVersionsPass(reg routeLister) { |
| - sweepStagedVersions(reg, release.OptDir(), versionSweepGrace(os.Getenv), time.Now()) |
| + sweepStagedVersions(reg, release.OptDir(), release.GenerationsPath(dataDir()), versionSweepGrace(os.Getenv), time.Now()) |
| } |
diff --git a/internal/host/versionsweep_test.go b/internal/host/versionsweep_test.go| index 74d1e5f66..e2833a04f 100644 |
| --- a/internal/host/versionsweep_test.go |
| +++ b/internal/host/versionsweep_test.go |
| @@ -33,6 +33,12 @@ func stageSweepVersion(t *testing.T, optDir, acct, ver, app string, age time.Dur |
| } |
| } |
| +// noGenerations is a generations file path with neither file present: no |
| +// generations, every box before its agent first published. |
| +func noGenerations(t *testing.T) string { |
| + return release.GenerationsPath(t.TempDir()) |
| +} |
| + |
| func staged(t *testing.T, optDir, ver string) bool { |
| t.Helper() |
| _, err := os.Stat(filepath.Join(optDir, "versions", ver)) |
| @@ -48,7 +54,7 @@ func TestSweepStagedVersionsPinsPrevVersion(t *testing.T) { |
| stageSweepVersion(t, opt, "acct1", "v1", "blog", 48*time.Hour) |
| reg := fakeLister{routes: []registry.Route{{Host: "blog.example.com", Version: "v3", PrevVersion: "v2"}}} |
| - sweepStagedVersions(reg, opt, 6*time.Hour, time.Now()) |
| + sweepStagedVersions(reg, opt, noGenerations(t), 6*time.Hour, time.Now()) |
| if !staged(t, opt, "v3") { |
| t.Error("v3 (Version) was removed") |
| @@ -67,7 +73,7 @@ func TestSweepStagedVersionsIgnoresEmptyVersions(t *testing.T) { |
| opt := t.TempDir() |
| stageSweepVersion(t, opt, "acct1", "v1", "blog", 48*time.Hour) |
| reg := fakeLister{routes: []registry.Route{{Host: "pending.example.com"}}} |
| - sweepStagedVersions(reg, opt, 6*time.Hour, time.Now()) |
| + sweepStagedVersions(reg, opt, noGenerations(t), 6*time.Hour, time.Now()) |
| if staged(t, opt, "v1") { |
| t.Error("v1 survived a pin set that should be empty") |
| } |
| @@ -78,7 +84,7 @@ func TestSweepStagedVersionsIgnoresEmptyVersions(t *testing.T) { |
| func TestSweepStagedVersionsDeletesNothingWhenRegistryFails(t *testing.T) { |
| opt := t.TempDir() |
| stageSweepVersion(t, opt, "acct1", "v1", "blog", 48*time.Hour) |
| - sweepStagedVersions(fakeLister{err: errors.New("database is locked")}, opt, 6*time.Hour, time.Now()) |
| + sweepStagedVersions(fakeLister{err: errors.New("database is locked")}, opt, noGenerations(t), 6*time.Hour, time.Now()) |
| if !staged(t, opt, "v1") { |
| t.Error("swept with an unreadable registry") |
| } |
| @@ -94,7 +100,7 @@ func TestSweepStagedVersionsDeletesNothingWhenLivePinsFail(t *testing.T) { |
| if err := os.WriteFile(filepath.Join(opt, "live", "stray"), []byte("not a link"), 0o644); err != nil { |
| t.Fatal(err) |
| } |
| - sweepStagedVersions(fakeLister{}, opt, 6*time.Hour, time.Now()) |
| + sweepStagedVersions(fakeLister{}, opt, noGenerations(t), 6*time.Hour, time.Now()) |
| if !staged(t, opt, "v1") { |
| t.Error("swept with an unreadable live dir") |
| } |
| @@ -111,12 +117,74 @@ func TestSweepStagedVersionsBelievesLiveLinkOverRegistry(t *testing.T) { |
| } |
| // The registry still says v1 — the version write never landed. |
| reg := fakeLister{routes: []registry.Route{{Host: "edge.example.com", Version: "v1"}}} |
| - sweepStagedVersions(reg, opt, 6*time.Hour, time.Now()) |
| + sweepStagedVersions(reg, opt, noGenerations(t), 6*time.Hour, time.Now()) |
| if !staged(t, opt, "v2") { |
| t.Fatal("the running version was removed because the registry had not caught up") |
| } |
| } |
| +// Ruling R19 item 1 (Astra r3 F1(a)): a worker's captured build that only |
| +// the generations file still names (by version, or by staged path alone) |
| +// survives the sweep past its grace. What nothing names is still removed. |
| +func TestSweepStagedVersionsKeepsBuildsTheGenerationsFileNames(t *testing.T) { |
| + opt := t.TempDir() |
| + for _, v := range []string{"v0", "v1", "v2", "v3"} { |
| + stageSweepVersion(t, opt, "acct1", v, "bot", 48*time.Hour) |
| + } |
| + gen := noGenerations(t) |
| + if err := release.WriteGenerations(gen, release.Generations{ |
| + Hosts: map[string][]string{"bot.example.com": {"v1"}}, |
| + Paths: map[string][]string{"chained.example.com": {release.VersionPath(opt, "acct1", "v2", "bot")}}, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg := fakeLister{routes: []registry.Route{{Host: "bot.example.com", Version: "v3"}}} |
| + sweepStagedVersions(reg, opt, gen, 6*time.Hour, time.Now()) |
| + if !staged(t, opt, "v1") { |
| + t.Error("v1 was removed; the generations file names it as a running worker's build") |
| + } |
| + if !staged(t, opt, "v2") { |
| + t.Error("v2 was removed; the generations file names its staged path") |
| + } |
| + if !staged(t, opt, "v3") { |
| + t.Error("v3 (Version) was removed") |
| + } |
| + if staged(t, opt, "v0") { |
| + t.Error("v0 survived; nothing names it and it is older than the grace") |
| + } |
| +} |
| + |
| +// Ruling R19 item 1: a generations file that does not read, or whose |
| +// publication did not finish (the intent ahead of the file), is an incomplete |
| +// pin set, and the sweep removes nothing that pass. |
| +func TestSweepStagedVersionsDeletesNothingWhenTheGenerationsFileIsIncomplete(t *testing.T) { |
| + for name, files := range map[string][2]string{ |
| + "unreadable": {"", `{"schema":1,"hosts":{"bot.exa`}, |
| + "intent ahead": {"2", `{"schema":1,"seq":1,"complete":true,"hosts":{}}`}, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + opt := t.TempDir() |
| + stageSweepVersion(t, opt, "acct1", "v1", "bot", 48*time.Hour) |
| + gen := noGenerations(t) |
| + if err := os.MkdirAll(filepath.Dir(gen), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if files[0] != "" { |
| + if err := os.WriteFile(release.GenerationsSeqPath(gen), []byte(files[0]), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := os.WriteFile(gen, []byte(files[1]), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + sweepStagedVersions(fakeLister{}, opt, gen, 6*time.Hour, time.Now()) |
| + if !staged(t, opt, "v1") { |
| + t.Error("swept with an incomplete generations file") |
| + } |
| + }) |
| + } |
| +} |
| + |
| func TestVersionSweepGrace(t *testing.T) { |
| env := func(v string) func(string) string { |
| return func(k string) string { |
diff --git a/internal/release/generations_publish_test.go b/internal/release/generations_publish_test.go| index 831174d32..17dab8f60 100644 |
| --- a/internal/release/generations_publish_test.go |
| +++ b/internal/release/generations_publish_test.go |
| @@ -230,3 +230,43 @@ func TestAStaleGenerationsFileNeverPermitsARestage(t *testing.T) { |
| writeGenFiles(t, path, "2", `{"schema":1,"seq":1,"complete":true,"hosts":{}}`) |
| f.assertRefused(t, f.pass(t, BakeDir(dataDir)), "what refers to it is unknown") |
| } |
| + |
| +// Ruling R19 item 1: the version sweep keys pins by version directory. Every |
| +// listed version is one, and every listed path inside the version store, as |
| +// written or by its real path under a symlinked opt dir, names its version |
| +// directory. A path outside the store names none. An incomplete file is an |
| +// error, and neither file is no versions. |
| +func TestGenerationVersionsNameTheVersionDirectories(t *testing.T) { |
| + real := t.TempDir() |
| + opt := filepath.Join(t.TempDir(), "opt") |
| + if err := os.Symlink(real, opt); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, v := range []string{"v2", "v3"} { |
| + if err := StageArtifact(opt, "kqmv", v, "km", []byte(v)); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + path := GenerationsPath(t.TempDir()) |
| + if got, err := GenerationVersions(path, opt); err != nil || len(got) != 0 { |
| + t.Fatalf("neither file: GenerationVersions = %v, %v; want none", got, err) |
| + } |
| + g := Generations{ |
| + Hosts: map[string][]string{"a.example.com": {"v1"}}, |
| + Paths: map[string][]string{ |
| + "b.example.com": {VersionPath(opt, "kqmv", "v2", "km")}, |
| + "c.example.com": {VersionPath(real, "kqmv", "v3", "km"), "/srv/operator/app"}, |
| + }, |
| + } |
| + if err := WriteGenerations(path, g); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := GenerationVersions(path, opt) |
| + if err != nil || !reflect.DeepEqual(got, map[string]bool{"v1": true, "v2": true, "v3": true}) { |
| + t.Fatalf("GenerationVersions = %v, %v; want v1, v2 and v3", got, err) |
| + } |
| + writeGenFiles(t, path, "9", "") |
| + if got, err := GenerationVersions(path, opt); err == nil { |
| + t.Fatalf("GenerationVersions on an incomplete file = %v, nil; want an error", got) |
| + } |
| +} |
diff --git a/internal/release/pins.go b/internal/release/pins.go| index ebbb3472a..9dcc26cb1 100644 |
| --- a/internal/release/pins.go |
| +++ b/internal/release/pins.go |
| @@ -391,3 +391,61 @@ func GenerationPins(path string, rows func() ([]registry.Route, error)) PinSourc |
| return pins, nil |
| }) |
| } |
| + |
| +// GenerationVersions is the generations file as the version sweep keys its |
| +// pins (spec §8.5, ruling R19 item 1): the name of every version directory a |
| +// listed build lives in. Each listed version is one. Each listed path inside |
| +// optDir's version store names its version directory, whether it is written |
| +// under the opt dir's own name or reaches the store by its real path (a |
| +// symlinked opt dir), so a build named only by path still protects its |
| +// directory. A path outside the store names none: the sweep removes nothing |
| +// there. It reads the file with ReadGenerations, so neither file is no |
| +// versions, and an incomplete file, or a path or the store that cannot be |
| +// resolved other than by not existing, is an error: the sweep's pin set is |
| +// then incomplete. |
| +func GenerationVersions(path, optDir string) (map[string]bool, error) { |
| + g, err := ReadGenerations(path) |
| + if err != nil { |
| + return nil, err |
| + } |
| + vers := map[string]bool{} |
| + if g == nil { |
| + return vers, nil |
| + } |
| + for _, vs := range g.Hosts { |
| + for _, v := range vs { |
| + vers[v] = true |
| + } |
| + } |
| + if len(g.Paths) == 0 { |
| + return vers, nil |
| + } |
| + bases := []string{versionsDir(optDir)} |
| + realBase, err := filepath.EvalSymlinks(versionsDir(optDir)) |
| + switch { |
| + case err == nil: |
| + bases = append(bases, realBase) |
| + case !errors.Is(err, os.ErrNotExist): |
| + return nil, fmt.Errorf("resolve %s: %w", versionsDir(optDir), err) |
| + } |
| + for _, ps := range g.Paths { |
| + for _, p := range ps { |
| + names := []string{filepath.Clean(p)} |
| + real, err := filepath.EvalSymlinks(p) |
| + switch { |
| + case err == nil: |
| + names = append(names, real) |
| + case !errors.Is(err, os.ErrNotExist): |
| + return nil, fmt.Errorf("generations file %s: resolve %s: %w", path, p, err) |
| + } |
| + for _, n := range names { |
| + for _, b := range bases { |
| + if v := versionUnder(b, n); v != "" { |
| + vers[v] = true |
| + } |
| + } |
| + } |
| + } |
| + } |
| + return vers, nil |
| +} |
diff --git a/internal/release/versions.go b/internal/release/versions.go| index 47a7cf1fe..eea8b9bbd 100644 |
| --- a/internal/release/versions.go |
| +++ b/internal/release/versions.go |
| @@ -306,7 +306,13 @@ func LiveVersion(optDir, host string) (string, error) { |
| // a version. A target outside the store, or a relative one, is not ours to |
| // interpret: "" is the same answer an absent link gets. |
| func versionOfTarget(optDir, target string) string { |
| - rel, err := filepath.Rel(versionsDir(optDir), target) |
| + return versionUnder(versionsDir(optDir), target) |
| +} |
| + |
| +// versionUnder is versionOfTarget against a given versions dir: the first |
| +// segment of target under vdir, or "" when target is not below it. |
| +func versionUnder(vdir, target string) string { |
| + rel, err := filepath.Rel(vdir, target) |
| if err != nil || strings.HasPrefix(rel, "..") { |
| return "" |
| } |