Hold every kept archive by a manifest so the store's collector keeps it (hq issue 253)

The store's garbage-collect marks only from manifests, and archives were
published as bare blobs, so the first real collection would delete every
archive the mesh keeps. PublishArchive now puts a deterministic OCI holder
manifest (empty config, one layer) beside each archive; the sweep holds every
kept archive before it lets anything go, which backfills existing bare blobs,
and lets go of an archive holder-first. A forgotten module no longer keeps its
five recent builds (ADR 0189). `collection [--json]` reports kept archives
held/unheld and what may be let go, so the dry run can be lifted on evidence.
This commit is contained in:
jochen
2026-10-05 18:13:23 +02:00
parent bb3cd6437b
commit 01c5ab2aab
13 changed files with 1252 additions and 32 deletions
+36 -5
View File
@@ -3,6 +3,7 @@ package inventory
import (
"context"
"encoding/json"
"sort"
"strings"
"github.com/novox/mesh-controller/internal/catalogue"
@@ -33,7 +34,8 @@ const KeptBuilds = 5
// - **a definition names it** — the reference appears in a module's recorded manifest, which is
// what the mesh would hand a machine now. No age limit: this is the floor;
// - **the mesh can still go back to it** — it is an artifact of one of the KeptBuilds most
// recent successful builds of its module;
// recent successful builds of a module the mesh still holds. A forgotten module keeps
// nothing beyond what a held definition names (novox/hq issue 253);
// - it was already collected, in which case there is nothing left to do.
//
// Returned in a stated order so two runs over the same records ask for the same things in the
@@ -109,12 +111,19 @@ func (i *Inventory) keptReferences(ctx context.Context) (map[string]bool, error)
return nil, err
}
// The KeptBuilds most recent successful builds of each module, whole.
// The KeptBuilds most recent successful builds of each module the mesh still holds, whole.
//
// **Only a module the mesh still holds can be gone back to** (novox/hq issue 253). "Somewhere
// to return to" is a reason about a module's releases; a module that has been forgotten has
// no releases left to return between, and its build rows stay only as history. Without the
// join every module ever built kept five builds' artifacts for ever — and once the store's
// collector runs for real, what the keep set says is what the disk holds.
recent, err := i.store.Pool().Query(ctx,
`select made from (
select made, row_number() over (partition by module order by at desc, id desc) as back
from build
where failed = '' and module is not null and module <> ''
select b.made, row_number() over (partition by b.module order by b.at desc, b.id desc) as back
from build b
join module m on m.name = b.module
where b.failed = '' and b.module is not null and b.module <> ''
) ranked where back <= $1`, KeptBuilds)
if err != nil {
return nil, err
@@ -169,6 +178,28 @@ func (i *Inventory) keptReferences(ctx context.Context) (map[string]bool, error)
return keep, nil
}
// KeptArchives is every archive the mesh keeps, in a stated order: the references the sweep must
// hold by a manifest before it lets anything go, and the ones an operator needs to read as all
// held before the store's collector is let loose (novox/hq issue 253, ADR 0189).
//
// Only references into the mesh's own store, and only blobs: an image is its own manifest, and a
// reference that is kept because nothing here can speak for it is not one the store can be asked
// about.
func (i *Inventory) KeptArchives(ctx context.Context) ([]string, error) {
keep, err := i.keptReferences(ctx)
if err != nil {
return nil, err
}
var out []string
for reference := range keep {
if path, ours := catalogue.InArtifactStore(reference); ours && strings.Contains(path, "/blobs/sha256:") {
out = append(out, reference)
}
}
sort.Strings(out)
return out, nil
}
// everyReferenceMade is every artifact reference any successful build recorded.
func (i *Inventory) everyReferenceMade(ctx context.Context) ([]string, error) {
rows, err := i.store.Pool().Query(ctx,
+96
View File
@@ -20,6 +20,19 @@ func ref(module, artifact string, n int) string {
return fmt.Sprintf("%s%s/%s@sha256:%064x", catalogue.ArtifactStoreScheme, module, artifact, n)
}
// holding registers a definition for each module that names no artifact, so the mesh holds the
// module and its recent builds are somewhere it can go back to — and nothing more.
func holding(t *testing.T, inv *Inventory, modules ...string) {
t.Helper()
for _, module := range modules {
m := catalogue.Manifest{Module: module, Version: "1"}
if err := inv.RegisterModule(context.Background(), m,
Source{Repository: "https://forge.invalid/" + module + ".git"}); err != nil {
t.Fatal(err)
}
}
}
// built records one successful build of a module publishing one image.
func built(t *testing.T, inv *Inventory, id, module string, n int) string {
t.Helper()
@@ -35,6 +48,7 @@ func built(t *testing.T, inv *Inventory, id, module string, n int) string {
func TestTheStoreKeepsTheRecentBuildsAndLetsGoOfTheRest(t *testing.T) {
inv := fresh(t)
ctx := context.Background()
holding(t, inv, "web")
// Eight builds of one module, oldest first. Five are kept — the newest, and the four a
// release that turns out wrong can be taken back to.
@@ -98,6 +112,7 @@ func TestWhatHasBeenCollectedIsNotOfferedAgain(t *testing.T) {
// time it runs, for ever — a number of requests that grows with the mesh's whole history.
inv := fresh(t)
ctx := context.Background()
holding(t, inv, "web")
for i := 1; i <= 7; i++ {
built(t, inv, fmt.Sprintf("b%02d", i), "web", i)
}
@@ -123,6 +138,7 @@ func TestWhatHasBeenCollectedIsNotOfferedAgain(t *testing.T) {
func TestAFailedBuildNamesNothingToCollectAndEachModuleIsCountedOnItsOwn(t *testing.T) {
inv := fresh(t)
ctx := context.Background()
holding(t, inv, "web", "db")
// A failed build published nothing, so it is neither kept nor collected — and it must not
// count against the module's five.
@@ -156,6 +172,7 @@ func TestAFailedBuildNamesNothingToCollectAndEachModuleIsCountedOnItsOwn(t *test
func TestAnArtifactRecordedWithAnAddressIsOfferedAsTheMeshRecordsOne(t *testing.T) {
inv := fresh(t)
ctx := context.Background()
holding(t, inv, "tools")
// The oldest build published the old way; five newer ones fill the module's five.
old := aBuild("a00", "tools", "")
@@ -190,3 +207,82 @@ func TestAnArtifactRecordedWithAnAddressIsOfferedAsTheMeshRecordsOne(t *testing.
t.Fatalf("offered %v again after collecting it", again)
}
}
// A forgotten module keeps nothing beyond what a held definition names (novox/hq issue 253).
//
// "Somewhere to go back to" is a reason about a module's releases, and a module the mesh no
// longer holds has none. Its build rows stay as history; its artifacts go — except one a module
// the mesh still holds names, which is the floor whatever built it.
func TestAForgottenModuleKeepsNothingAHeldDefinitionDoesNotName(t *testing.T) {
inv := fresh(t)
ctx := context.Background()
// Three builds of a module that was never held, or was held and then forgotten: within its
// five, and kept for that reason until now.
var gone []string
for i := 1; i <= 3; i++ {
gone = append(gone, built(t, inv, fmt.Sprintf("o%02d", i), "old", 200+i))
}
// A module the mesh holds, whose definition runs the forgotten module's newest image.
named := gone[2]
m := catalogue.Manifest{Module: "web", Version: "1", Resources: []map[string]any{{
"id": "app", "type": "container", "name": "web", "image": named,
}}}
if err := inv.RegisterModule(ctx, m, Source{Repository: "https://forge.invalid/web.git"}); err != nil {
t.Fatal(err)
}
go_, err := inv.ToCollect(ctx)
if err != nil {
t.Fatal(err)
}
if len(go_) != 2 || go_[0] != gone[0] || go_[1] != gone[1] {
t.Fatalf("offered %v; want %v — a forgotten module's builds are no release to go back to, "+
"and only what a held definition names stays", go_, gone[:2])
}
// And once the module is held again, its five are kept again.
holding(t, inv, "old")
again, err := inv.ToCollect(ctx)
if err != nil {
t.Fatal(err)
}
if len(again) != 0 {
t.Fatalf("offered %v for a module the mesh holds, within its five", again)
}
}
// The archives the mesh keeps are what the sweep holds before it lets anything go, and what an
// operator reads as all held before the store's collector is let loose (novox/hq issue 253).
func TestKeptArchivesAreTheKeptBlobsOnly(t *testing.T) {
inv := fresh(t)
ctx := context.Background()
holding(t, inv, "shell")
archive := func(n int) string {
return fmt.Sprintf("%sshell/config/blobs/sha256:%064x", catalogue.ArtifactStoreScheme, n)
}
for i := 1; i <= 6; i++ {
b := aBuild(fmt.Sprintf("s%02d", i), "shell", "")
b.Made = []Artifact{
{Name: "app", Kind: "image", Reference: ref("shell", "app", i)},
{Name: "config", Kind: "archive", Reference: archive(i)},
}
if err := inv.RecordBuild(ctx, b); err != nil {
t.Fatal(err)
}
}
kept, err := inv.KeptArchives(ctx)
if err != nil {
t.Fatal(err)
}
want := []string{archive(2), archive(3), archive(4), archive(5), archive(6)}
if len(kept) != len(want) {
t.Fatalf("kept archives %v; want the five recent ones and no images", kept)
}
for i := range want {
if kept[i] != want[i] {
t.Fatalf("kept archives %v; want %v", kept, want)
}
}
}