Answer held and say artifacts-gone, so the builder rebuilds a done key whose blob the artifact store no longer holds (issue 1937)
mesh/delivery-group group fix/1937-done-key-blobs rejected: composed together: the checker's verdict at the members' heads (c20fdcb8c623): integration: github.…
mesh/delivery superseded: a newer head of the same pull request
mesh/check failed: integration: github.com/novox/mesh-controller/cmd/mesh-controller.TestAWarrantForRetryRetriesAFailedPlanInTheStoreAndEndsItsConditi…

A done key the controller never took in holds no manifest, and the store's nightly collector deleted its bare blob:
node-tools', mesh-host's and the forge bridge's keys name blobs that answer 404. Since issue 1933 the take-in refuses such a
key, and nothing built it again. held (in the serving process, HEADs only) lets the builder ask before it reuses a key; a
refused take-in now names every gone archive and says artifacts-gone with the module, key, fingerprint and references.
This commit is contained in:
2026-10-24 04:12:02 +02:00
parent 1c50608246
commit 3b51d3f86e
12 changed files with 301 additions and 8 deletions
+128
View File
@@ -0,0 +1,128 @@
package main
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-controller/internal/artifacts"
"github.com/novox/mesh-controller/internal/builderkeys"
"github.com/novox/mesh-controller/internal/link"
)
// The verb `held` and the event `artifacts-gone` (novox/hq issue 1937).
//
// **A done key can name a blob the store no longer holds.** A build module pushes a bundle or an archive as a bare blob
// (ADR 0498 §1); the store's nightly collector deletes every blob no manifest names; and only a take-in or a sweep holds
// one by its manifest. A key the controller has not taken in by the next collection names a blob that is gone. On
// 2026-10-24 that was node-tools', mesh-host's and the forge bridge's. Since issue 1933 the take-in refuses such a key;
// these two let the builder build it again: `held` before it reuses a done key, `artifacts-gone` when a take-in found it.
// heldMost is the most references one ask of held judges: a builder asks for one key's artifacts at a time.
const heldMost = 64
// heldAsk asks the store whether it holds one reference: nil, artifacts.Gone or another error. A variable so a test
// needs no store.
var heldAsk = func(ctx context.Context, address, reference string) error {
return artifacts.Store{Address: address}.Holds(ctx, reference)
}
// heldAddress is the artifact store's address the serving controller asks. A variable so a test needs no records.
var heldAddress = func(ctx context.Context) (string, error) {
d := doctorFrom
if d == nil || d.open == nil || d.open.inventory == nil {
return "", errors.New("this controller is not serving, so it asks the artifact store nothing: ask again, and " +
"the serving controller answers")
}
shelf, err := d.open.inventory.Catalogue(ctx)
if err != nil {
return "", err
}
address, err := artifactStoreAddress(ctx, d.open.inventory, shelf, "")
if err != nil {
return "", err
}
if address == "" {
return "", errors.New("the artifact store is on no network the controller reaches")
}
return address, nil
}
// heldAnswer is the verb `held`: each reference given, held, gone (the store answered 404) or unknown with why. It
// holds and writes nothing: a reference the store holds and no record names is left for the take-in to hold.
func heldAnswer(ctx context.Context, given string) (any, error) {
var refs []string
for _, r := range strings.Split(given, ",") {
if r = strings.TrimSpace(r); r != "" && !slices.Contains(refs, r) {
refs = append(refs, r)
}
}
if len(refs) == 0 {
return nil, errors.New("held judges the references given, and none was")
}
if len(refs) > heldMost {
return nil, fmt.Errorf("held judges at most %d references at once, and %d were given", heldMost, len(refs))
}
for _, r := range refs {
if _, ours := catalogue.InArtifactStore(r); !ours {
return nil, fmt.Errorf("%s is not a reference into the mesh's artifact store (artifact-store://...): nothing was asked", r)
}
}
address, err := heldAddress(ctx)
if err != nil {
return nil, err
}
asking, stop := context.WithTimeout(ctx, 20*time.Second)
defer stop()
a := sdkdelivery.HeldAnswer{Store: address, Held: []string{}, Gone: []string{}}
for _, r := range refs {
switch err := heldAsk(asking, address, r); {
case err == nil:
a.Held = append(a.Held, r)
case errors.Is(err, artifacts.Gone):
a.Gone = append(a.Gone, r)
default:
if a.Unknown == nil {
a.Unknown = map[string]string{}
}
a.Unknown[r] = err.Error()
}
}
out := fmt.Sprintf("the artifact store at %s holds %d of the %d reference(s) asked", address, len(a.Held), len(refs))
if len(a.Gone) > 0 {
out += "; gone: " + strings.Join(a.Gone, ", ")
}
if len(a.Unknown) > 0 {
out += fmt.Sprintf("; %d could not be asked", len(a.Unknown))
}
return verbAnswer{Output: out + "\n", OK: true, Answer: a}, nil
}
// sayArtifactsGone says `artifacts-gone` for a builder's key the take-in refused because the store does not hold what
// it names (issue 1937): the builder hears it and builds the key again. Said from the serving controller only, and never
// in the take-in's way: a bus that cannot carry it is said in the journal.
var sayArtifactsGone = func(ctx context.Context, k builderkeys.Key, module string, gone []string) {
bus := checkEvents
if bus == nil || len(gone) == 0 {
return
}
key, _ := sdkdelivery.BuilderBuildKey(module, k.Fingerprint)
raw, err := sdkdelivery.EncodeArtifactsGone(sdkdelivery.ArtifactsGone{Module: module, Key: key,
Fingerprint: k.Fingerprint, Gone: gone, At: time.Now().UTC()})
if err != nil {
fmt.Printf("%s: its key names archives the store does not hold, and that could not be said: %v\n", module, err)
return
}
saying, stop := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
defer stop()
if err := bus.PublishSeatEvent(saying, link.MeshControllerSeat, link.KeyArtifactsGone, raw); err != nil {
fmt.Printf("%s: its key names archives the store does not hold, and %s could not be said: %v\n",
module, link.KeyArtifactsGone, err)
}
}
+132
View File
@@ -0,0 +1,132 @@
package main
import (
"context"
"encoding/json"
"errors"
"slices"
"strings"
"sync"
"testing"
sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-controller/internal/artifacts"
"github.com/novox/mesh-controller/internal/link"
)
// 1937/AC1: held answers each reference held, gone (the store's 404) or unknown, asks only HEADs, and its answer is what
// the builder reads (mesh-sdk delivery.DecodeHeldAnswer).
func TestHeldAnswersEachReferenceHeldOrGone(t *testing.T) {
present := "artifact-store://node-tools/runtime/blobs/" + shaOf("held")
missing := "artifact-store://node-tools/runtime/blobs/" + shaOf("gone")
broken := "artifact-store://mesh-host/host-arch/blobs/" + shaOf("broken")
address, ask := heldAddress, heldAsk
t.Cleanup(func() { heldAddress, heldAsk = address, ask })
heldAddress = func(context.Context) (string, error) { return "store.test:5100", nil }
var asked []string
var mu sync.Mutex
heldAsk = func(_ context.Context, at, reference string) error {
mu.Lock()
asked = append(asked, at+" "+reference)
mu.Unlock()
switch reference {
case missing:
return artifacts.Gone
case broken:
return errors.New("the artifact store answered 500")
}
return nil
}
got, err := heldAnswer(t.Context(), present+", "+missing+","+broken+","+present)
if err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(got)
if err != nil {
t.Fatal(err)
}
a, err := sdkdelivery.DecodeHeldAnswer(raw, []string{present, missing, broken})
if err != nil {
t.Fatalf("the builder cannot read held's answer: %v\n%s", err, raw)
}
if !slices.Equal(a.Held, []string{present}) || !slices.Equal(a.Gone, []string{missing}) || a.Unknown[broken] == "" {
t.Errorf("held answered %+v", a)
}
if len(asked) != 3 {
t.Errorf("held asked the store %d times for three distinct references: %v", len(asked), asked)
}
if !strings.Contains(got.(verbAnswer).Output, missing) {
t.Errorf("held's words do not name the gone reference: %s", got.(verbAnswer).Output)
}
for _, refused := range []string{"", "registry.example/x@sha256:00"} {
if _, err := heldAnswer(t.Context(), refused); err == nil {
t.Errorf("held took %q", refused)
}
}
// A verb answered in the serving process, never by a command line.
if !inProcess["held"] {
t.Error("held is not answered by the serving controller")
}
if _, err := argvFor("held", map[string]any{"references": present}); err == nil {
t.Error("held composed a command line")
}
}
// saidGone keeps the artifacts-gone events said.
type saidGone struct {
link.Bus // only PublishSeatEvent is said on
mu sync.Mutex
said []sdkdelivery.ArtifactsGone
}
func (s *saidGone) PublishSeatEvent(_ context.Context, seat, event string, body []byte) error {
if seat != link.MeshControllerSeat || event != link.KeyArtifactsGone {
return errors.New("said under the wrong seat or name: " + seat + " " + event)
}
e, err := sdkdelivery.DecodeArtifactsGone(body)
if err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
s.said = append(s.said, e)
return nil
}
// 1937/AC2: a take-in refused for a gone archive names every gone one and says artifacts-gone with the module, the key,
// its fingerprint and the gone references, so the builder builds the key again; a hand registration says nothing.
func TestARefusedTakeInSaysArtifactsGone(t *testing.T) {
program := "artifact-store://x/tools/blobs/" + shaOf("t")
image := "artifact-store://x/img@" + shaOf("i")
keys, trunk := aKeyOf(tsBundle, 3,
sdkdelivery.BuiltArtifact{Name: "tools", Kind: "bundle", Digest: shaOf("t"), Reference: program},
sdkdelivery.BuiltArtifact{Name: "img", Kind: "image", Digest: shaOf("i"), Reference: image})
bus := &saidGone{}
was := checkEvents
checkEvents = bus
t.Cleanup(func() { checkEvents = was })
gone := func(context.Context, string) error { return artifacts.Gone }
_, _, err := buildOfKey(t.Context(), keys, keyTake{Module: "x", Trunk: trunk}, keyContext{hold: gone})
if err == nil || !strings.Contains(err.Error(), program) || !strings.Contains(err.Error(), image) ||
!strings.Contains(err.Error(), "not in the artifact store") {
t.Fatalf("a take-in of a key whose archives are gone: %v", err)
}
if len(bus.said) != 1 {
t.Fatalf("said %d artifacts-gone events; want one", len(bus.said))
}
e := bus.said[0]
wantKey, _ := sdkdelivery.BuilderBuildKey("x", keyFP)
if e.Module != "x" || e.Fingerprint != keyFP || e.Key != wantKey || !slices.Equal(e.Gone, []string{image, program}) {
t.Errorf("said %+v", e)
}
bus.said = nil
_, _, _ = buildOfKey(t.Context(), keys, keyTake{Module: "x", Trunk: trunk, Hand: map[string]string{"tools": program}},
keyContext{hold: gone})
if len(bus.said) != 0 {
t.Errorf("a registration by hand said %v: no builder's key was refused", bus.said)
}
}
+14 -3
View File
@@ -351,6 +351,9 @@ func buildOfKey(ctx context.Context, keys keyReads, t keyTake, kc keyContext) (
// archive the store does not hold is sent, and every machine assigned the module fails its apply on the 404. An
// archive is held by its manifest, so the store's own collector keeps it too.
if kc.hold != nil {
// **Every gone archive named, and said** (issue 1937): the builder hears `artifacts-gone` and builds the key
// again, so the refusal is the last word on this key, not a plan that fails at every retry.
var gone, goneNames []string
for _, a := range k.Build.Artifacts {
reference := catalogue.Recorded(a.Reference)
if _, ours := catalogue.InArtifactStore(reference); !ours {
@@ -360,13 +363,21 @@ func buildOfKey(ctx context.Context, keys keyReads, t keyTake, kc keyContext) (
switch {
case err == nil, errors.Is(err, artifacts.ErrNotOurs):
case errors.Is(err, artifacts.Gone):
return inventory.Build{}, catalogue.Manifest{}, fmt.Errorf("the builder's key of %s names %s for its "+
"artifact %q, which is not in the artifact store: nothing was recorded or registered, and the key "+
"must be built again", t.Module, reference, a.Name)
gone = append(gone, reference)
goneNames = append(goneNames, fmt.Sprintf("%s for its artifact %q", reference, a.Name))
default:
return waits("the artifact store could not hold %s's artifact %q (%s): %v", t.Module, a.Name, reference, err)
}
}
if len(gone) > 0 {
if t.Hand == nil {
sayArtifactsGone(ctx, k, t.Module, gone)
}
return inventory.Build{}, catalogue.Manifest{}, fmt.Errorf("the builder's key of %s names %s, not in "+
"the artifact store: nothing was recorded or registered, and the key must be built "+
"again (said as %s, which the builder builds it again on)", t.Module, strings.Join(goneNames, " and "),
link.KeyArtifactsGone)
}
}
built, err := builtOfKey(ctx, kc.archive, manifest, k)
if err != nil {
+9 -1
View File
@@ -318,6 +318,8 @@ func (a *verbArguments) commandLine() ([]string, error) {
return nil, errors.New("dead-letters is answered by the serving controller, on its own connection, not by a command")
case "root-free":
return nil, errors.New("root-free is judged by the serving controller, on its own connection, not by a command")
case "held":
return nil, errors.New("held is asked by the serving controller, of the artifact store, not by a command")
case "status":
return []string{"status", "--json"}, nil
case "nodes":
@@ -1253,6 +1255,9 @@ func seatToolHandlers() (map[string]link.ToolHandler, []string, error) {
if verb == "dead-letters" {
return deadLettersAnswer(ctx, a)
}
if verb == "held" {
return heldAnswer(ctx, a.given["references"])
}
if verb == "root-free" {
return rootFreeAnswer(ctx, a.given["machines"], rootClock())
}
@@ -1376,7 +1381,10 @@ var inProcess = map[string]bool{"tools": true, "calls": true, "doctor": true,
"dead-letters": true,
// Whether a machine is root-free, judged live on the serving controller's store and connection (novox/hq ADR
// 0259 §8): the router asks it before an approval.
"root-free": true}
"root-free": true,
// Whether the artifact store holds what a builder's key names, asked by the builder before it reuses the key (novox/hq
// issue 1937).
"held": true}
// pushFileVar names the file the controller's push settings are written to (module.json, `push-wait`).
const pushFileVar = "MESH_PUSH_FILE"