W13.8: the node-engine fetches a kept build through the build node's kept-artifact, the node taken from the want's kept-on

A want that names a node in kept-on, for an artifact its parts name, is fetched through kept-artifact of that node's build seat and
read as an object on the bus, checked against the digest; with no kept-on it is fetched from the store, and the builder's builds
are read no more (the apply hook, the fetch and the object read from bc8fbc7, its node and fingerprint from the want).

Builds: design 47 G §271, §526, §421 (A-50)
Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
This commit is contained in:
2026-11-11 23:39:36 +01:00
parent 38e13ef030
commit 5173f2a31c
11 changed files with 353 additions and 4 deletions
+71
View File
@@ -0,0 +1,71 @@
package main
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/nodebuildgo"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/nodebuildimage"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/nodebuildnode"
"github.com/novox/mesh-host/internal/apply"
)
// The artifact store's holder takes its own new build from the build node that kept it (design 47): its done build names the
// node and the digest, the node's build seat answers where the bytes are as an object on the bus, and the node-engine
// reads them and checks them against the digest. Never another module's files: the seat's verb is the way.
// keptAskWithin bounds one ask of a build seat; keptObjectWithin one read of the object.
var (
keptAskWithin = 30 * time.Second
keptObjectWithin = 10 * time.Minute
)
// keptBuildSeats are the three build seats, each of which serves the verb kept-artifact: the node that kept the build
// holds the seat of the language it was built in, and the others do not answer.
var keptBuildSeats = []string{nodebuildgo.Seat, nodebuildnode.Seat, nodebuildimage.Seat}
// keptArtifactFetch is the apply's fetch of what a module's done build kept on a build node.
func keptArtifactFetch(buildOf func(context.Context, string, string) (string, string, bool, error),
ask func(ctx context.Context, node, seat, verb string, args any, within time.Duration) (json.RawMessage, error),
object func(ctx context.Context, store, name string, limit int64) ([]byte, error)) apply.KeptFetch {
return func(ctx context.Context, module, digest string) ([]byte, bool, error) {
node, fp, kept, err := buildOf(ctx, module, digest)
if err != nil || !kept {
return nil, false, err
}
hex := strings.TrimPrefix(digest, "sha256:")
var notHeld []string
for _, seat := range keptBuildSeats {
raw, err := ask(ctx, node, seat, nodebuildgo.VerbKeptArtifact,
nodebuildgo.KeptArtifactInput{Module: module, Fp: fp, Digest: hex}, keptAskWithin)
if err != nil {
notHeld = append(notHeld, seat+": "+err.Error())
continue
}
var got nodebuildgo.KeptArtifactAnswer
if json.Unmarshal(raw, &got) != nil {
return nil, true, fmt.Errorf("%s of %s answered with something that is not an answer of kept-artifact", seat, node)
}
switch {
case got.Error != "":
return nil, true, fmt.Errorf("%s of %s could not give the kept build of %s: %s", seat, node, module, got.Error)
case got.Refused != "":
return nil, true, fmt.Errorf("%s of %s refuses the kept build of %s: %s %s", seat, node, module, got.Refused, got.Words)
case got.Store == "" || got.Object == "" || got.Digest != hex:
return nil, true, fmt.Errorf("%s of %s answered for %s without the store and the object of the digest %s", seat, node, module, hex)
}
octx, cancel := context.WithTimeout(ctx, keptObjectWithin)
body, err := object(octx, got.Store, got.Object, got.Size+1)
cancel()
if err != nil {
return nil, true, fmt.Errorf("the build of %s kept on %s could not be read from %s: %w", module, node, got.Store, err)
}
return body, true, nil
}
return nil, true, fmt.Errorf("%s keeps the build of %s for the artifact store and none of its build seats answers (%s)",
node, module, strings.Join(notHeld, "; "))
}
}
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
)
// A module's done build that kept its artifact on a build node is fetched through that node's build seat (design 47).
func TestAKeptArtifactIsFetchedThroughTheBuildSeatOfItsNode(t *testing.T) {
hex := strings.Repeat("a", 64)
stored := func(string) (json.RawMessage, error) {
return json.RawMessage(`{"module":"distribution","fp":"f1","digest":"` + hex + `","size":5,"store":"kept-builds","object":"` + hex + `"}`), nil
}
for _, row := range []struct {
name string
kept bool
answer func(seat string) (json.RawMessage, error)
object error
wantKept bool
wantErr string
wantSeat string
}{
{"a build kept nothing is not asked", false, nil, nil, false, "", ""},
{"the seat of the language answers", true, func(seat string) (json.RawMessage, error) {
if seat != "node-build-node" {
return nil, errors.New("no responder")
}
return stored(seat)
}, nil, true, "", "node-build-node"},
{"no build seat of the node answers", true, func(string) (json.RawMessage, error) { return nil, errors.New("no responder") },
nil, true, "novox keeps the build of distribution", ""},
{"the seat refuses it", true, func(string) (json.RawMessage, error) {
return json.RawMessage(`{"module":"distribution","fp":"f1","refused":"not kept","words":"swept"}`), nil
}, nil, true, "refuses the kept build of distribution: not kept", ""},
{"the object cannot be read", true, stored, errors.New("no object"), true, "could not be read from kept-builds", "node-build-go"},
} {
t.Run(row.name, func(t *testing.T) {
var seats []string
fetch := keptArtifactFetch(
func(context.Context, string, string) (string, string, bool, error) { return "novox", "f1", row.kept, nil },
func(_ context.Context, node, seat, verb string, _ any, _ time.Duration) (json.RawMessage, error) {
if node != "novox" || verb != "kept-artifact" {
t.Errorf("asked %s of %s on %s", verb, seat, node)
}
seats = append(seats, seat)
return row.answer(seat)
},
func(_ context.Context, store, name string, limit int64) ([]byte, error) {
if store != "kept-builds" || name != hex || limit < 5 {
t.Errorf("read %s/%s up to %d", store, name, limit)
}
return []byte("bytes"), row.object
})
body, kept, err := fetch(context.Background(), "distribution", "sha256:"+hex)
if kept != row.wantKept {
t.Fatalf("kept = %v", kept)
}
if row.wantErr == "" && (err != nil || (kept && string(body) != "bytes")) {
t.Fatalf("got %q, %v", body, err)
}
if row.wantErr != "" && (err == nil || !strings.Contains(err.Error(), row.wantErr)) {
t.Fatalf("the error is %v, want %q", err, row.wantErr)
}
if !row.kept && len(seats) > 0 {
t.Errorf("a build that kept nothing asked %v", seats)
}
})
}
}
+6
View File
@@ -448,6 +448,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
}
began := time.Now()
node := session.Membership.Node
var keptFetch atomic.Pointer[apply.KeptFetch] // the composer's, set once it exists
composedBucket, err := compositions.BucketFor(node)
if err != nil {
cancel()
@@ -550,6 +551,9 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
Backoff: link.Backoff,
Apply: func(ctx context.Context, c compositions.Composed, others []compositions.Composed) agent.Result {
actx, done := applyContext(session.WithLink(ctx))
if kf := keptFetch.Load(); kf != nil {
actx = apply.WithKept(actx, *kf)
}
// While the grants part's list is followed, the controller's list in the bus module's composition is not
// written: the apply keeps the list on the machine (ADR 0547 clause 8, never both).
held, herr := users.Hold(c)
@@ -681,6 +685,8 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
apply.UseEngineModule(composer.EngineModule) // the guard of the engine's own trees fixes no name (§527)
env.Composed, env.SelfComposed, env.Policy, env.ComposedToday = composer, true, composer.Policy, composer.ComposedToday
env.WhyUnmade, env.Waiting = composer.WhyUnmade, composer.Waiting
keptFetchFn := keptArtifactFetch(composer.KeptBuildOf, session.AskSeatOn, session.KeptObject)
keptFetch.Store(&keptFetchFn)
env.HoldsData = composer.HoldsData // what runs from the kept declaration says Data as its manifest does
session.NoteComposes(linked, nodeengineclient.ComposesOwn, "it composes its own modules, and applies no composition made elsewhere")
ag = agent.New(env)