Join: merge A3's builder and L3 (wip/registry-a3 88a9d19)

L3 (710c4032) arrives with it. Conflicts, each kept from both sides:
- assigner module.json, manifest_test.go, main.go, tools.go, integration_test.go: A2's first-view tools,
  read tools and states beside L3's assigner_engine_reads and engine-reads; the two "consumes" lists
  joined into one (the indexer's views and node-engine.manifest-reads-changed).
- builder module.json and manifest_test.go: A3's tools, states, consumes and invokes beside A2g's first
  views (DLV-M1); one findings state (A3's, history 5) carries both; reads keep A3o's
  mesh-delivery-indexer.packages until the builder follows it (join item).
- builder cmd main.go: rebuilt from A3's with A2g's changes applied (the reader follows modules,
  manifests, candidates and placement; one calls connection; one findings bucket, A3's
  health.FindingsBucket; FollowPlacement on a goroutine once placement's first view is answered;
  buildFromModules in place of the start's settle). Tools main.go: A3's tools then the first-view tools.
- builder run.go: A2k's out-of-step on the end entry, then A3's numbering of a done build.
- view module.json and manifest_test.go: node_manifest_reads and seat:node-engine.manifest-reads beside
  A2j's first views and read tools; one invokes list.
- indexer judge rules_test.go: L3's manifest-reads verb and event, without pull-waits (J1 dropped it).
- sdk/go.mod and builder go.mod: both requires kept.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
2026-11-04 14:58:45 +01:00
77 changed files with 7542 additions and 156 deletions
@@ -13,6 +13,7 @@ import (
"path/filepath"
"slices"
"strings"
"sync"
"testing"
"time"
@@ -31,6 +32,8 @@ import (
"git.novox.be/novox/mesh-sdk/go/provisions"
"mesh-delivery-assigner/internal/judge"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-delivery/sdk/enginereads"
"git.novox.be/novox/mesh-sdk/go/views"
"mesh-delivery-assigner/internal/assign"
@@ -95,7 +98,14 @@ func startBed(t *testing.T, leaveOut string) *bed {
Subjects: []string{buscompose.LogSubject(delivery.BuilderModule, delivery.BuilderLog) + ".>"}, AllowDirect: true}); err != nil {
t.Fatal(err)
}
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "EVENTS", Subjects: []string{"mesh.mod.*.event.>"}}); err != nil {
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "EVENTS", Subjects: []string{"mesh.mod.*.event.>", "mesh.seat.*.event.>"}}); err != nil {
t.Fatal(err)
}
// The assigner's durable consumer on EVENTS, as the grants part makes it from the manifest's consumes: a node-engine seat's
// event names the engine's machine last, so the filter is the event with every machine (mesh-grants buscompose
// machineEvent; the grants part's own test holds the derivation).
if _, err := js.CreateOrUpdateConsumer(ctx, "EVENTS", jetstream.ConsumerConfig{Durable: "n1_mesh-delivery-assigner",
FilterSubjects: []string{enginereads.SubjectPrefix + "*"}, AckPolicy: jetstream.AckExplicitPolicy}); err != nil {
t.Fatal(err)
}
// The operator-channel seat's stream of conditions, which the assigner says its conditions on.
@@ -522,6 +532,118 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) {
if w := b.wantNow(t, "ace", "app", "the consumer's want once its provision is provided", func(w delivery.Want) bool { return w.FP == fpOf('3') }); w.FP != fpOf('3') {
t.Errorf("app's want = %+v", w)
}
// ADR 0618 §1, §3, §4, ADR 0624 §2: ace's engine answers the node-engine seat's manifest-reads; a build written only in a
// form it does not state is held on ace, naming the form, and moves once the engine says manifest-reads-changed on its
// subject and states the form; no timer moves it. g14's engine answers nothing and has no record: read as the baseline.
stated := catalogue.ManifestReads()
var statedMu sync.Mutex
nc, err := nats.Connect(b.srv.ClientURL())
if err != nil {
t.Fatal(err)
}
defer nc.Close()
if _, err := nc.Subscribe("mesh.seat.node-engine.tool.manifest-reads.ace", func(m *nats.Msg) {
statedMu.Lock()
answer, _ := json.Marshal(nodeengine.ManifestReadsAnswer{V: 1, Node: "ace", ManifestReads: stated, StatedAt: time.Now().Format(time.RFC3339Nano)})
statedMu.Unlock()
reply, _ := json.Marshal(map[string]any{"result": json.RawMessage(answer), "node": "ace"})
_ = m.Respond(reply)
}); err != nil {
t.Fatal(err)
}
say := func(why string) {
body, _ := json.Marshal(nodeengine.ManifestReadsChangedEvent{V: 1, Node: "ace", ManifestReads: stated, At: time.Now().Format(time.RFC3339Nano), Why: why})
if _, err := b.js.Publish(b.ctx, enginereads.SubjectPrefix+"ace", body); err != nil {
t.Fatal(err)
}
}
say(enginereads.ReadsStarted)
formsOld, formsNew := fpOf('5'), fpOf('6')
formsDigest := b.manifest(t, "forms", `{"module":"forms","version":"1"}`)
formsAt := func(fp string) {
mod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: formsDigest, Repository: "novox/forms", Commit: strings.Repeat("3", 40), Path: delivery.RootPath})
b.putJSON(t, delivery.ModulesBucket, "forms", mod)
}
for _, fp := range []string{formsOld, formsNew} {
build := delivery.BuilderBuild{State: delivery.BuildDone, By: "builder.novox.1", Step: "pushed", Manifest: formsDigest}
if fp == formsNew {
build.Needs = []string{"form:new"}
}
built, err := delivery.EncodeBuilderBuild(build)
if err != nil {
t.Fatal(err)
}
bkey, _ := delivery.BuilderBuildKey("forms", fp)
b.putJSON(t, delivery.BuilderBuildsBucket, bkey, built)
gkey, _ := delivery.PartGrantKey("forms", fp)
grant, _ := delivery.EncodeGrant(delivery.Grant{})
b.putJSON(t, delivery.PartGrantsBucket, gkey, grant)
}
runsFormsOld, _ := delivery.EncodeHave(delivery.Have{FP: formsOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK})
b.putJSON(t, delivery.HaveBucket, "ace.forms", runsFormsOld)
b.place(t, "ace", "forms")
formsAt(formsOld)
b.wantNow(t, "ace", "forms", "the want at what ace runs, a build in the first form", func(w delivery.Want) bool { return w.FP == formsOld })
formsAt(formsNew)
statement := func(node string, ok func(enginereads.Statement) bool) enginereads.Statement {
var last enginereads.Statement
for deadline := time.Now().Add(15 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
if e, err := b.buckets[engineReadsBucket].Get(b.ctx, node); err == nil {
if last, err = enginereads.Decode(e.Value()); err == nil && ok(last) {
return last
}
}
}
t.Fatalf("%s's engine reads are %+v", node, last)
return last
}
statement("ace", func(s enginereads.Statement) bool { return s.From == enginereads.FromVerb && s.Answered })
if s := statement("g14", func(s enginereads.Statement) bool { return !s.Answered }); s.From != enginereads.FromNone {
t.Errorf("g14, whose engine answers nothing and which has no record, reads %+v", s)
}
if w := b.wantNow(t, "ace", "forms", "the want held on ace, naming the form", func(w delivery.Want) bool {
return w.Newest == formsNew && strings.Contains(w.Words, "form:new")
}); w.FP != formsOld || w.Rule != delivery.RuleComposed {
t.Errorf("forms' want = %+v, want it held at what ace runs under rule 7", w)
}
statedMu.Lock()
stated = append(slices.Clone(stated), "form:new")
statedMu.Unlock()
say(enginereads.ReadsStarted)
b.wantNow(t, "ace", "forms", "the want moved once ace's engine said it reads the form", func(w delivery.Want) bool { return w.FP == formsNew })
// assigner_engine_reads asks ace's engine again through the state: the assigner answers there with what it states now.
again, err := enginereads.AskAgainOf(bedState{b, engineReadsBucket}, "ace", "n1.ada", "a person asks", time.Now(), 15*time.Second)
if err != nil || again.From != enginereads.FromVerb || !slices.Contains(again.ManifestReads, "form:new") || again.AskAgain != nil {
t.Errorf("the ask again of ace answered %+v, %v", again, err)
}
}
// bedState is one of the bed's buckets as the engine reads' tool reads it.
type bedState struct {
b *bed
bucket string
}
func (s bedState) Keys() ([]string, error) { return s.b.buckets[s.bucket].Keys(s.b.ctx) }
func (s bedState) Get(key string) ([]byte, uint64, bool, error) {
e, err := s.b.buckets[s.bucket].Get(s.b.ctx, key)
if err != nil {
return nil, 0, false, nil
}
return e.Value(), e.Revision(), true, nil
}
func (s bedState) Put(key string, value []byte) (uint64, error) {
return s.b.buckets[s.bucket].Put(s.b.ctx, key, value)
}
func (s bedState) Delete(key string) error { return s.b.buckets[s.bucket].Delete(s.b.ctx, key) }
func (s bedState) Await(key string, after uint64, done func([]byte) bool, bound time.Duration) ([]byte, error) {
for deadline := time.Now().Add(bound); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
if v, rev, found, _ := s.Get(key); found && rev > after && done(v) {
return v, nil
}
}
return nil, fmt.Errorf("no answer at %s within %s", key, bound)
}
// 1297/B9 (the assigner's half, ADR 0528 clause 6): started whole, the assigner holds a fingerprint whose health check
@@ -28,6 +28,7 @@ import (
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/provisions"
"git.novox.be/novox/mesh-delivery/sdk/enginereads"
"git.novox.be/novox/mesh-delivery/sdk/graph"
"git.novox.be/novox/mesh-sdk/go/views"
@@ -74,7 +75,15 @@ var readFamilies = []string{delivery.ComposedRead}
// (ADR 0567 §1), named after the assigner as every module state is.
var ownBuckets = []string{delivery.AssignerAsksBucket, delivery.AssignerDifferencesBucket,
delivery.AssignerPutsBucket, delivery.AssignerWantsBucket, delivery.PlacementBucket, delivery.PlacementProposalsBucket,
delivery.PlacementCandidatesBucket, delivery.PlacementPulsesBucket, assign.FindingsBucket, assign.PoliciesBucket, delivery.SeatHoldersBucket}
delivery.PlacementCandidatesBucket, delivery.PlacementPulsesBucket, assign.FindingsBucket, assign.PoliciesBucket, delivery.SeatHoldersBucket,
engineReadsBucket}
// engineReadsState is the assigner's state of what each node's engine states it reads (ADR 0618 §3), one key `<node>`, as
// the verb manifest-reads last answered it or the fallback read it: what assigner_engine_reads lists and asks again.
const engineReadsState = "engine-reads"
// engineReadsBucket is that state's bucket.
const engineReadsBucket = delivery.AssignerModule + "_" + engineReadsState
// seatBuckets are the seat's states, new with it: until the controller has made them from the manifest and the bus grants
// them, the assigner starts without them and binds each once the bus says it was created (issue 1790's kind).
@@ -100,6 +109,7 @@ var keptStates = []announce.State{
{Name: delivery.SeatHoldersState, Readable: true},
{Name: views.MarksState, Readable: true}, // what its views family said each state up to (views.go)
{Name: views.StatusState, Readable: true}, // its first views of the indexer's states (views.go)
{Name: engineReadsState, Readable: true},
}
// buckets is every bucket the assigner opened, by name.
@@ -157,6 +167,12 @@ func open(ctx context.Context, js process.BucketOpener, logf func(string, ...any
logf("the bucket %s cannot be opened (%v): no want waits for a verb it invokes until it is bound once the bus says it was made", name, err)
continue
}
if name == engineReadsBucket && err != nil {
// New with ADR 0618 §3: without it the statements the verb answers are kept in memory only, and
// assigner_engine_reads lists nothing.
logf("the bucket %s cannot be opened (%v): what each node's engine reads is kept in memory only until it starts again", name, err)
continue
}
if name == delivery.PartGrantsBucket && err != nil {
// The grants part's grants (ADR 0571 clause 9): without them no module counts as granted until it is bound.
logf("the bucket %s cannot be opened (%v): no grant is read until it is bound once the bus says it was made", name, err)
@@ -175,7 +191,7 @@ func open(ctx context.Context, js process.BucketOpener, logf func(string, ...any
func startsWithout(name string) bool {
switch name {
case delivery.SeatHoldersBucket, provisions.ProvisionsBucket, assign.PoliciesBucket, assign.FindingsBucket,
delivery.AssignerPutsBucket, delivery.SourceBucket, delivery.MergesBucket, graph.Bucket:
delivery.AssignerPutsBucket, delivery.SourceBucket, delivery.MergesBucket, graph.Bucket, engineReadsBucket:
return true
}
return slices.Contains(seatBuckets, name) || name == delivery.PartGrantsBucket
@@ -274,6 +290,7 @@ var watched = []string{
delivery.SourceBucket,
delivery.MergesBucket,
graph.Bucket,
engineReadsBucket,
}
// follow watches every bucket a pass reads and makes a pass at start, once every watch handed what stands, and after
@@ -289,6 +306,16 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
}
errs := make(chan error, len(watched)+2)
settled := make(chan struct{}, len(watched)+2)
// What each node's engine reads (ADR 0618 §3, §4): asked through the seat's verb, kept in the state engine-reads, read
// from the node's record where the engine does not answer (the fallback, said in the journal).
reads := &enginereads.Keeper{Fallback: w.recordReads, Changed: wake, Logf: logger.Printf}
if bus != nil {
reads.Call = bus.CallOn
}
if kv := kvs[engineReadsBucket]; kv != nil {
reads.Put = func(node string, v []byte) error { _, err := kv.Put(ctx, node, v); return err }
reads.Delete = func(node string) error { return kv.Delete(ctx, node) }
}
watch := func(name string, opts ...jetstream.WatchOpt) error {
kw, err := kvs[name].WatchAll(ctx, opts...)
if err != nil {
@@ -322,6 +349,10 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
if e.Operation() == jetstream.KeyValuePut {
value = e.Value()
}
if name == engineReadsBucket {
reads.Heard(e.Key(), value, value == nil) // it wakes a pass when a statement changed
continue
}
w.putEntry(name, e.Key(), value, e.Revision(), e.Created())
wake()
}
@@ -421,7 +452,14 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
case <-settled:
}
}
p := &passer{kvs: kvs, w: w, logf: logger.Printf, say: say, findings: findings, said: map[string]bool{}, answeredAt: map[string]uint64{}}
p := &passer{kvs: kvs, w: w, logf: logger.Printf, say: say, findings: findings, said: map[string]bool{}, answeredAt: map[string]uint64{},
reads: reads}
// The node-engine seat's manifest-reads-changed, for one node (ADR 0618 §4): that node is asked again, with no timer.
// Heard on the assigner's durable consumer on EVENTS, which the grants part makes from the manifest's consumes; where it
// is not there yet, a node is asked again only when it comes back online.
if js != nil {
hearReadsChanged(ctx, js, reads, logger.Printf)
}
if bus != nil && kvs[delivery.AssignerPutsBucket] != nil {
p.acts = newPutActs(bus, wake, logger.Printf)
}
@@ -486,3 +524,37 @@ func sayVia(ctx context.Context, js jetstream.JetStream) func(asks.Condition) er
return err
}
}
// hearReadsChanged hears the node-engine seat's event manifest-reads-changed on the assigner's durable consumer on EVENTS,
// and asks the node it names again. Any other event the consumer hands is acknowledged and left.
func hearReadsChanged(ctx context.Context, js jetstream.JetStream, reads *enginereads.Keeper, logf func(string, ...any)) {
account := process.Account{Node: thisNode, Module: delivery.AssignerModule}
cons, err := account.EventConsumer(ctx, js, process.EventsStream)
if err != nil {
logf("the consumer %s on %s does not exist yet, so %s is not heard until the assigner starts again (a node is asked again when it comes back online): %v",
account.EventConsumerName(), process.EventsStream, enginereads.Event, err)
return
}
cc, err := process.HearEvents(cons, func(m jetstream.Msg) {
if err := m.Ack(); err != nil {
logf("acknowledging %s: %v", m.Subject(), err)
}
node, ours, err := enginereads.NodeOf(m.Subject(), m.Data())
switch {
case !ours:
case err != nil:
logf("%s holds what is not %s, and %s is asked again all the same: %v", m.Subject(), enginereads.Event, node, err)
reads.AskNode(node, enginereads.Event+" that does not read")
default:
reads.AskNode(node, enginereads.Event)
}
}, logf)
if err != nil {
logf("%s is not heard: %v", enginereads.Event, err)
return
}
go func() {
<-ctx.Done()
cc.Stop()
}()
}
@@ -15,6 +15,8 @@ import (
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/provisions"
"git.novox.be/novox/mesh-delivery/sdk/enginereads"
"mesh-delivery-assigner/internal/assign"
)
@@ -68,6 +70,40 @@ type passer struct {
// saidWaits is the last said of the seat's wait for its states (sayWaits).
saidWaits string
intended map[string]map[string]map[string]bool
// reads keeps what each node's engine states it reads, asked through the node-engine seat's verb (ADR 0618 §3); nil reads
// each node's record, as the rules did before the verb. online is, by node, whether the last pass saw it online.
reads *enginereads.Keeper
online map[string]bool
}
// engineReads sets what the rules read of each node's engine (ADR 0618 §3, §4): the statement the verb manifest-reads
// answered, or its record where the verb was not answered (the fallback) or not asked yet. A node is asked when the
// assigner first hears of it, and again when it comes back online, since an engine built before the event says none.
func (p *passer) engineReads(n assign.Nodes) {
if p.reads == nil {
return
}
known := p.w.knownNodes()
first := p.online == nil
for _, node := range known {
switch {
case first:
p.reads.AskNode(node, "the assigner started")
case !p.reads.Known(node):
p.reads.AskNode(node, "first heard of")
case !first && n.Online[node] && !p.online[node]:
p.reads.AskNode(node, "back online")
}
}
p.online = maps.Clone(n.Online)
if p.online == nil {
p.online = map[string]bool{}
}
for node, reads := range p.reads.Reads(known) {
if p.reads.Known(node) {
n.Reads[node] = reads
}
}
}
func (p *passer) clock() time.Time {
@@ -120,6 +156,7 @@ func (p *passer) wants(ctx context.Context) {
}
}
nodes := p.w.nodes(manifests, assigned)
p.engineReads(nodes)
nodesOf := map[string][]string{}
for node, modules := range assigned {
for _, m := range modules {
@@ -162,7 +199,7 @@ func (p *passer) wants(ctx context.Context) {
for name := range names {
fp := trunk[name].FP2
name := name
m := assign.Module{Name: name, FP: fp, Manifest: manifests[name], Needs: trunk[name].Needs, Chosen: chosenOf(chosen, name), Provisions: provisionsOf(name), Answering: answering(manifests, nodes), Offered: offered(manifests), Nodes: nodesOf[name], Now: time.Now(), Puts: puts[name],
m := assign.Module{Name: name, FP: fp, Manifest: manifests[name], Needs: trunk[name].Needs, Chosen: chosenOf(chosen, name), Provisions: provisionsOf(name), Answering: answering(manifests, nodes), Offered: offered(manifests), Nodes: nodesOf[name], Now: time.Now(), Puts: puts[name], FormNeeds: p.w.buildNeeds(name, fp),
Self: func(node string) bool { return placed[node] },
Composed: func(node, fp string) (delivery.ComposedDigest, bool) {
return p.w.composed(node, name, fp) // a placed node's own: the digest its own have names (ADR 0571 §6)
@@ -14,9 +14,12 @@ import (
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/busretry"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-delivery/sdk/enginereads"
"git.novox.be/novox/mesh-delivery/sdk/graph"
"mesh-delivery-assigner/internal/assign"
@@ -166,6 +169,72 @@ func TestAnAnswerCountsOnlyForTheAskingItAnswered(t *testing.T) {
if !slices.Equal(n.Reads["ace"], []string{"word:module", "word:tool-runner"}) || len(n.Reads["g14"]) != 0 || len(n.Reads["novox"]) != 0 {
t.Errorf("the nodes' reads are %v", n.Reads)
}
// ADR 0618 §3: with the verb asked, a node's reads are what its engine answered; where the engine answers it does not
// serve the verb, its record is read once in its place (the fallback, issue 774); a node asked again when it comes
// back online, since an engine built before the event says none.
asked := map[string]int{}
call := func(node, key string, _ any) (json.RawMessage, error) {
asked[node]++
if node == "g14" || key != enginereads.Invoke {
return nil, &busretry.Answered{Words: "the seat node-engine serves no verb manifest-reads"}
}
return json.Marshal(nodeengine.ManifestReadsAnswer{V: 1, Node: node, ManifestReads: []string{"form:new"}, StatedAt: time.Now().Format(time.RFC3339Nano)})
}
p := &passer{w: w, reads: &enginereads.Keeper{Call: call, Fallback: w.recordReads, Sync: true}}
on, _ := delivery.EncodePresence(delivery.Presence{Online: false})
w.put(delivery.PresenceBucket, "ace", on)
n = w.nodes(nil, nil)
p.engineReads(n)
if !slices.Equal(n.Reads["ace"], []string{"form:new"}) || len(n.Reads["g14"]) != 0 || asked["ace"] != 1 {
t.Errorf("with the verb the nodes' reads are %v (asked %v)", n.Reads, asked)
}
if s := p.reads.Statements(); s["ace"].From != enginereads.FromVerb || s["g14"].From != enginereads.FromRecord {
t.Errorf("the statements are %+v", s)
}
p.engineReads(w.nodes(nil, nil))
on, _ = delivery.EncodePresence(delivery.Presence{Online: true})
w.put(delivery.PresenceBucket, "ace", on)
p.engineReads(w.nodes(nil, nil))
if asked["ace"] != 2 {
t.Errorf("ace, back online, was asked %d times, want 2", asked["ace"])
}
// The contract carries no checks, so its reader judges what the engine says (mesh-contracts seats/nodeengine): an
// answer or an event that does not hold is no statement, and an event's node is its subject's last token.
now := time.Now().Format(time.RFC3339Nano)
for _, row := range []struct {
name string
raw string
stays bool
}{
{"a whole answer", `{"v":1,"node":"ace","manifest-reads":["form:new"],"stated-at":"` + now + `"}`, true},
{"an engine stating nothing", `{"v":1,"node":"ace","stated-at":"` + now + `"}`, true},
{"another layout", `{"v":2,"node":"ace","stated-at":"` + now + `"}`, false},
{"no node", `{"v":1,"node":"","stated-at":"` + now + `"}`, false},
{"a node that is no token", `{"v":1,"node":"a.ce","stated-at":"` + now + `"}`, false},
{"another node's answer", `{"v":1,"node":"g14","stated-at":"` + now + `"}`, false},
{"an empty capability", `{"v":1,"node":"ace","manifest-reads":[""],"stated-at":"` + now + `"}`, false},
{"a time that is no time", `{"v":1,"node":"ace","stated-at":"yesterday"}`, false},
} {
s := enginereads.Ask(func(string, string, any) (json.RawMessage, error) { return json.RawMessage(row.raw), nil }, "ace", time.Now())
if (s.From == enginereads.FromVerb) != row.stays || !s.Answered {
t.Errorf("answer %s: %+v", row.name, s)
}
}
for _, row := range []struct {
name, subject, raw string
ours, fails bool
}{
{"the event", enginereads.SubjectPrefix + "ace", `{"v":1,"node":"ace","at":"` + now + `","why":"returned"}`, true, false},
{"another reason", enginereads.SubjectPrefix + "ace", `{"v":1,"node":"ace","at":"` + now + `","why":"bored"}`, true, true},
{"another node in the body", enginereads.SubjectPrefix + "ace", `{"v":1,"node":"g14","at":"` + now + `","why":"started"}`, true, true},
{"a time that is no time", enginereads.SubjectPrefix + "ace", `{"v":1,"node":"ace","at":"x","why":"started"}`, true, true},
{"another event", "mesh.seat.node-engine.event.done.ace", `{}`, false, false},
} {
_, ours, err := enginereads.NodeOf(row.subject, []byte(row.raw))
if ours != row.ours || (err != nil) != row.fails {
t.Errorf("event %s: ours %v, %v", row.name, ours, err)
}
}
})
}
@@ -14,6 +14,8 @@ import (
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-delivery/sdk/enginereads"
)
// The assigner's tools (novox/hq ADR 0546), served through the tool runner from its bundle: a process never serves (ADR
@@ -21,6 +23,9 @@ import (
// every step as the event put. They are the module's own tools; the seat mesh-placement's verbs are served beside them
// (seat_tools.go, ADR 0567 §10).
// engineReadsTool lists what each node's engine reads, as the assigner uses it, and asks one node's engine again.
const engineReadsTool = "assigner_engine_reads"
// putsState is the assigner's state of puts, as the runtime names a module's own state.
const putsState = "puts"
@@ -359,6 +364,8 @@ func serveTools() error {
wants.watched = stdio.NewWatchedStates() // the large states read whole from one watched copy (ADR 0580 clause 3)
tools = append(tools, wants.tools()...)
tools = append(tools, viewTools(os.Getenv, keptSource, time.Now)...)
// What each node's engine reads, as the assigner uses it (ADR 0618 §3): its own state engine-reads, asked again through it.
tools = append(tools, enginereads.Tool(engineReadsTool, "the assigner", enginereads.KeptState(stdio.State(engineReadsState)), false, time.Now))
return stdio.Serve("", tools)
}
@@ -3,6 +3,7 @@ package main
import (
"context"
"errors"
"maps"
"slices"
"sort"
"sync"
@@ -433,6 +434,42 @@ func (w *world) built(module, fp string) bool {
return err == nil && b.State == delivery.BuildDone
}
// buildNeeds are the reference forms the builder's done build of module at fp is written in (the build key's needs, ADR
// 0618 §1): nil when it is not done or names none.
func (w *world) buildNeeds(module, fp string) []string {
key, err := delivery.BuilderBuildKey(module, fp)
if err != nil {
return nil
}
b, err := delivery.DecodeBuilderBuild(w.value(delivery.BuilderBuildsBucket, key))
if err != nil || b.State != delivery.BuildDone {
return nil
}
return b.Needs
}
// recordReads is what node's record `<node>.node` states its engine reads (ADR 0614 §4), and whether the assigner holds
// a record of it: the fallback where the engine does not answer the verb manifest-reads (enginereads.FromRecord).
func (w *world) recordReads(node string) ([]string, bool) {
r, ok := w.records()[node]
return r.ManifestReads, ok
}
// knownNodes is every node the assigner hears of: placed, with a record, or with a presence.
func (w *world) knownNodes() []string {
set := map[string]bool{}
for node := range w.placedNodes() {
set[node] = true
}
for node := range w.records() {
set[node] = true
}
for node := range w.online() {
set[node] = true
}
return slices.Sorted(maps.Keys(set))
}
// composed is the composition of module for node: a placed node composes its own, and its composition is the digest its own
// have names (ADR 0571 §6); an unplaced node has none, so it gets no want (and the pass says so, unplacedFindings). The
// controller's copy (mesh-controller_composed) is read no more (issue 774).
+1
View File
@@ -24,3 +24,4 @@ require (
require git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000
replace git.novox.be/novox/mesh-delivery/sdk => ../../sdk
-2
View File
@@ -1,5 +1,3 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
@@ -12,6 +12,7 @@ import (
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/provisions"
"git.novox.be/novox/mesh-delivery/sdk/forms"
"git.novox.be/novox/mesh-delivery/sdk/graph"
)
@@ -80,6 +81,10 @@ type Module struct {
// Needs are the reader capabilities the manifest at FP needs (the indexer's modules value, catalogue.ManifestNeeds, ADR
// 0614 §4): a node's want moves only while that node's engine states each of them. Nil: none.
Needs []string
// FormNeeds are the reference forms the module's build at FP is written in (`form:<name>`, the builder's build key's
// needs, ADR 0618 §1): a node's want is held only while that node's engine reads none of them (ADR 0624 §2). Nil: the
// build names none, and holds nothing.
FormNeeds []string
// Self says whether node composes the module itself: the catalogue's placement names the node (ADR 0547 §2). It is the one
// source of a want's Self: every want the rules write or keep is derived from it (selfDerived), so a want written Self while
// the node was placed does not outlive the placement (issue 1126). Nil: no node does.
@@ -179,8 +184,14 @@ func holds(m Module, node string, n Nodes, ordering bool) (bool, int, string) {
// manifest needs that the engine does not state, read as the baseline when it states none. It is held under rule 7 (the
// composition), since no rule of its own is numbered in this release, with words naming the capability; it moves once the
// engine states it. The node-engine's own new build is held only by a word of its own manifest no engine states.
//
// A build's reference forms hold the want only when the engine reads none of them (ADR 0624 §2): every engine reads a
// first form, so a build written in one is never held by its forms; the words name the forms it reads none of.
func (m Module) readerLacking(node string, n Nodes) (string, bool) {
missing := catalogue.MissingManifestReads(m.Needs, n.Reads[node])
if len(missing) == 0 && forms.Current.Held(m.FormNeeds, n.Reads[node]) {
missing = slices.Clone(m.FormNeeds)
}
if len(missing) == 0 {
return "", false
}
@@ -122,21 +122,33 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) {
needs []string
reads map[string][]string
held bool
// forms are the build's reference forms (ADR 0624 §2), and names what the hold's words name.
forms []string
names string
}{
{"a word the baseline lacks, on engines stating nothing", "mail", []string{"word:module", "word:tool-runner"}, nil, true},
{"a word the baseline lacks, on engines stating nothing", "mail", []string{"word:module", "word:tool-runner"}, nil, true, nil, "word:tool-runner"},
{"the same, once both engines state it", "mail", []string{"word:module", "word:tool-runner"},
map[string][]string{"ace": catalogue.ManifestReads(), "g14": catalogue.ManifestReads()}, false},
{"the baseline's words on engines stating nothing", "mail", []string{"word:claims.serves", "seat:mesh-catalog", "form:config"}, nil, false},
{"the node-engine's new build, whose contract has more words than its manifest uses", "mesh-host", hostNeeds, nil, false},
map[string][]string{"ace": catalogue.ManifestReads(), "g14": catalogue.ManifestReads()}, false, nil, ""},
{"the baseline's words on engines stating nothing", "mail", []string{"word:claims.serves", "seat:mesh-catalog", "form:config"}, nil, false, nil, ""},
{"the node-engine's new build, whose contract has more words than its manifest uses", "mesh-host", hostNeeds, nil, false, nil, ""},
// ADR 0624 §2, with form:new a form no engine reads unless it states it and artifact-store the first form.
{"g14 back stating {old}, a build carrying {old,new}: moves", "mail", nil, map[string][]string{"g14": {"word:module"}}, false,
[]string{"form:artifact-store", "form:new"}, ""},
{"an engine stating nothing, a build carrying the baseline's form: moves", "mail", nil, nil, false, []string{"form:artifact-store"}, ""},
{"a build carrying {new} only: held, naming the form", "mail", nil, map[string][]string{"g14": {"word:module"}}, true,
[]string{"form:new"}, "form:new"},
{"the same, once g14's engine states it", "mail", nil, map[string][]string{"g14": {"form:new"}}, false, []string{"form:new"}, ""},
{"a manifest needing an unstated word, its build in the first form: held, naming the word", "mail", []string{"word:tool-runner"}, nil, true,
[]string{"form:artifact-store"}, "word:tool-runner"},
} {
m, n := ready()
m.Name, m.Needs, n.Reads = c.name, c.needs, c.reads
m.Name, m.Needs, n.Reads, m.FormNeeds = c.name, c.needs, c.reads, c.forms
n.Haves["ace"][c.name], n.Haves["g14"][c.name] = n.Haves["ace"]["mail"], n.Haves["g14"]["mail"]
out := Wants(m, n, wantedOld(), nil)
if moved := out.Wants["g14"].FP == fNew; moved == c.held {
t.Errorf("%s: g14's want %+v, held by %+v", c.why, out.Wants["g14"], out.Rules["g14"])
}
if r := out.Rules["g14"]; c.held && (r.Rule != delivery.RuleComposed || !strings.Contains(r.Words, "word:tool-runner") ||
if r := out.Rules["g14"]; c.held && (r.Rule != delivery.RuleComposed || !strings.Contains(r.Words, c.names) ||
!strings.Contains(r.Words, "node-engine of g14")) {
t.Errorf("%s: held by %+v, want rule 7 naming the capability", c.why, r)
}
@@ -50,7 +50,8 @@ func TestManifest(t *testing.T) {
states[s.Name] = s.History
}
if want := map[string]int{"asks": 5, "differences": 64, "puts": 20, "wants": 20,
"placement": 64, "proposals": 5, "candidates": 5, "pulses": 0, "findings": 5, "policies": 20, "seat-holders": 5, views.MarksState: 1, views.StatusState: 1}; !reflect.DeepEqual(states, want) {
"placement": 64, "proposals": 5, "candidates": 5, "pulses": 0, "findings": 5, "policies": 20, "seat-holders": 5, views.MarksState: 1, views.StatusState: 1,
"engine-reads": 1}; !reflect.DeepEqual(states, want) {
t.Errorf("state = %v, want %v", states, want)
}
if _, ok := m.OwnSecrets["broker"]; !ok || len(m.OwnSecrets) != 1 {
@@ -82,11 +83,12 @@ func TestManifest(t *testing.T) {
t.Errorf("the assigner cannot hold the seat: %v", catalogue.CanHold(m, seat))
}
// ADR 0621 §1: the first-view tools placement, seat-holders and wants and design 25 § 12.2's six read tools, of the
// program's bundle, which runs in a runtime of its own on the assigner's account.
// program's bundle, which runs in a runtime of its own on the assigner's account; ADR 0618 §3: and assigner_engine_reads,
// which lists and asks again what each node's engine reads.
if !slices.Equal(m.Tools, []string{"assigner_put", "assigner_release", "assigner_puts", "placement", "seat-holders", "wants",
"assigner_read_placement", "assigner_read_seat_holders", "assigner_read_wants", "assigner_read_findings", "assigner_read_asks", "assigner_read_differences",
"assigner_first_views", "assigner_first_view_again"}) {
t.Errorf("the assigner serves tools %v, want its three put tools, three first views and six read tools", m.Tools)
"assigner_first_views", "assigner_first_view_again", "assigner_engine_reads"}) {
t.Errorf("the assigner serves tools %v, want its three put tools, three first views, six read tools and assigner_engine_reads", m.Tools)
}
if m.RunsAs != m.Module || m.SecretsOwner != m.RunsAs {
t.Errorf("runs-as = %q: the bundle runs on the assigner's own account (ADR 0621 §1)", m.RunsAs)
@@ -108,12 +110,14 @@ func TestManifest(t *testing.T) {
!slices.Equal(slices.Sorted(slices.Values(m.Streams[0].Events)), slices.Sorted(slices.Values(delivery.PlacementEvents))) {
t.Errorf("streams = %+v, want placement carrying the seat's four events for %d days", m.Streams, delivery.PlacementStreamDays)
}
if !slices.Equal(m.Consumes, []string{"mesh-delivery-indexer.views.manifests", "mesh-delivery-indexer.views.modules"}) {
t.Errorf("consumes = %v, want the indexer's views of manifests and modules", m.Consumes)
// ADR 0618 §4: and it hears each node's engine say its reads changed.
if !slices.Equal(m.Consumes, []string{"mesh-delivery-indexer.views.manifests", "mesh-delivery-indexer.views.modules", "node-engine.manifest-reads-changed"}) {
t.Errorf("consumes = %v, want the indexer's views of manifests and modules and the engines' reads changed", m.Consumes)
}
if want := []string{"mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules",
"seat:mesh-hand-acts.hand-act", "seat:mesh-hand-acts.hand-acts", "seat:mesh-hand-acts.warranted", // ADR 0565 §9, §11: the seat alone
"seat:node-backup.backed-up", "seat:node-backup.now"}; !slices.Equal(slices.Sorted(slices.Values(m.Invokes)), want) {
"seat:node-backup.backed-up", "seat:node-backup.now",
"seat:node-engine.manifest-reads"}; !slices.Equal(slices.Sorted(slices.Values(m.Invokes)), want) { // ADR 0618 §3
t.Errorf("invokes = %v, want %v", m.Invokes, want)
}
if !slices.Equal(m.Emits, []string{"put"}) {
+8 -4
View File
@@ -41,7 +41,8 @@
],
"consumes": [
"mesh-delivery-indexer.views.manifests",
"mesh-delivery-indexer.views.modules"
"mesh-delivery-indexer.views.modules",
"node-engine.manifest-reads-changed"
],
"invokes": [
"mesh-delivery-indexer.manifests",
@@ -50,7 +51,8 @@
"seat:mesh-hand-acts.hand-acts",
"seat:mesh-hand-acts.warranted",
"seat:node-backup.backed-up",
"seat:node-backup.now"
"seat:node-backup.now",
"seat:node-engine.manifest-reads"
],
"emits": [
"put"
@@ -69,7 +71,8 @@
"assigner_read_asks",
"assigner_read_differences",
"assigner_first_views",
"assigner_first_view_again"
"assigner_first_view_again",
"assigner_engine_reads"
],
"state": [
{ "name": "placement", "history": 64 },
@@ -84,7 +87,8 @@
{ "name": "policies", "history": 20 },
{ "name": "seat-holders", "history": 5 },
{ "name": "view-marks", "history": 1 },
{ "name": "first-views", "history": 1 }
{ "name": "first-views", "history": 1 },
{ "name": "engine-reads", "history": 1 }
],
"streams": [
{
@@ -6,6 +6,7 @@ import (
"fmt"
"maps"
"slices"
"sort"
"sync"
"time"
@@ -42,6 +43,9 @@ type ToolchainGate struct {
known bool // the placement is settled
raw map[string]map[string][]byte // module -> toolchains key -> value, as the bucket holds it
left map[string]string // <module>/<key> -> why it was said left out, so each reason is said once
// OnPlaced is told, outside the gate's lock, once the placement's history is read and after each revision of it
// FollowPlacement hears from then on (the counted nodes, Nodes, may have changed). Nil tells nobody.
OnPlaced func()
}
// NewToolchainGate is a gate over the toolchains of modules, passing what stands on to forward and saying with logf what
@@ -90,6 +94,19 @@ func (g *ToolchainGate) Placement(key string, value []byte, deleted bool, at tim
return g.again(node, changed)
}
// Nodes are the nodes the placement places any module on, sorted: the nodes the mesh counts (a node leaves the count
// when the placement no longer names it; ADR 0624 §1). None until the placement is read.
func (g *ToolchainGate) Nodes() []string {
var out []string
for node, modules := range g.placed.ByNode() {
if len(modules) > 0 {
out = append(out, node)
}
}
sort.Strings(out)
return out
}
// Settled says the placement's history is read: from now on only the reports of placed nodes pass, once a node's
// placement is held. It answers whether what it passed on changed.
func (g *ToolchainGate) Settled() bool {
@@ -203,6 +220,9 @@ func FollowPlacement(ctx context.Context, js process.BucketOpener, gate *Toolcha
if gate.Settled() {
changed(ctx)
}
if gate.OnPlaced != nil {
gate.OnPlaced()
}
go func() {
defer func() { _ = w.Stop() }()
for {
@@ -218,6 +238,9 @@ func FollowPlacement(ctx context.Context, js process.BucketOpener, gate *Toolcha
if e != nil && put(e) {
changed(ctx)
}
if e != nil && gate.OnPlaced != nil {
gate.OnPlaced()
}
}
}
}()
@@ -19,17 +19,113 @@ import (
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/views"
"git.novox.be/novox/mesh-delivery/sdk/enginereads"
"mesh-delivery-builder/internal/build"
"mesh-delivery-builder/internal/versions"
)
// EngineReadsTool lists, asks again and drops what each counted node's engine reads, as the builder keeps it.
const EngineReadsTool = "builder_engine_reads"
// queueAsker writes a queues value and waits for the builder's answer to it.
type queueAsker func(key string, value []byte) (json.RawMessage, error)
// Tools are the builder's tools: stuck reads, askAgain writes the reasks value, queue writes a queues value and waits for
// the builder's read, buildAgain writes a reasks value of mode build and waits for the builder's answer.
func Tools(r build.StuckReader, askAgain func(key string, value []byte) error, queue, buildAgain queueAsker, since build.LogSince, now func() time.Time) []stdio.Tool {
// tags writes a tag-asks value and waits for the builder's answer; ta reads tag-asks.
func Tools(r build.StuckReader, askAgain func(key string, value []byte) error, queue, buildAgain queueAsker, since build.LogSince, now func() time.Time, vs versions.Reader,
tags queueAsker, ta, fw, of versions.Reader) []stdio.Tool {
str := func(args map[string]any, k string) string { s, _ := args[k].(string); return strings.TrimSpace(s) }
// handAct writes a reasks value of a versions hand act and answers the builder's answer, or its refusal as an error.
handAct := func(key string, value []byte, err error) (any, error) {
if err != nil {
return nil, err
}
raw, err := buildAgain(key, value)
if err != nil {
return nil, err
}
a, err := build.DecodeReAsk(raw)
if err != nil {
return nil, err
}
if a.Refused {
return nil, errors.New(a.Answer)
}
return map[string]any{"key": key, "name": a.Name, "do": a.Do, "version": a.Version, "answer": a.Answer}, nil
}
tagAct := func(key string, value []byte, err error) (any, error) {
if err != nil {
return nil, err
}
raw, err := tags(key, value)
if err != nil {
return nil, err
}
return versions.DecodeTagAsk(raw)
}
nameFP := map[string]any{"type": "object", "required": []string{"name", "fp", "why"}, "properties": map[string]any{
"name": map[string]any{"type": "string", "description": "the numbered name, <module>/<artifact>, as builder_versions lists it"},
"fp": map[string]any{"type": "string", "description": "the fingerprint, src2:<hex>"},
"why": map[string]any{"type": "string", "description": "why, in plain words"}}}
branch := func(head bool) map[string]any {
req := []string{"repository", "branch", "why"}
props := map[string]any{
"repository": map[string]any{"type": "string", "description": "<owner>/<name>"},
"branch": map[string]any{"type": "string", "description": "the branch; its cleaned name is the tag"},
"why": map[string]any{"type": "string", "description": "why, in plain words"}}
if head {
req = append(req, "head")
props["head"] = map[string]any{"type": "string", "description": "the commit the tagged version is built from"}
}
return map[string]any{"type": "object", "required": req, "properties": props}
}
return []stdio.Tool{
{Name: versions.PublishAgainTool, Description: versions.PublishAgainDescription, Input: nameFP,
Run: func(args map[string]any) (any, error) {
return handAct(build.NewPublishAgain(str(args, "name"), str(args, "fp"), str(args, "why"), stdio.CallerOf(args), now()))
}},
{Name: versions.FloorAgainTool, Description: versions.FloorAgainDescription,
Input: map[string]any{"type": "object", "required": []string{"name", "why"}, "properties": map[string]any{
"name": map[string]any{"type": "string", "description": "the numbered name, <module>/<artifact>"},
"why": map[string]any{"type": "string", "description": "why, in plain words"}}},
Run: func(args map[string]any) (any, error) {
return handAct(build.NewFloorAgain(str(args, "name"), str(args, "why"), stdio.CallerOf(args), now()))
}},
{Name: versions.TagTool, Description: versions.TagDescription, Input: branch(true),
Run: func(args map[string]any) (any, error) {
return tagAct(versions.NewTagAsk(str(args, "repository"), str(args, "branch"), str(args, "head"), str(args, "why"), stdio.CallerOf(args), now()))
}},
{Name: versions.UntagTool, Description: versions.UntagDescription, Input: branch(false),
Run: func(args map[string]any) (any, error) {
return tagAct(versions.NewUntag(str(args, "repository"), str(args, "branch"), str(args, "why"), stdio.CallerOf(args), now()))
}},
{Name: versions.TagAsksTool, Description: versions.TagAsksDescription,
Input: map[string]any{"type": "object", "properties": map[string]any{
"repository": map[string]any{"type": "string", "description": "only this repository's asks, <owner>/<name>"}}},
Run: func(args map[string]any) (any, error) { return versions.AnswerTagAsks(ta, str(args, "repository")) }},
{Name: versions.OldFormTool, Description: versions.OldFormDescription,
Input: map[string]any{"type": "object", "properties": map[string]any{
"drop": map[string]any{"type": "string", "description": "a retired node to drop from the count by hand"},
"why": map[string]any{"type": "string", "description": "with drop: why, in plain words"}}},
Run: func(args map[string]any) (any, error) {
if node := str(args, "drop"); node != "" {
return handAct(build.NewOldFormDrop(node, str(args, "why"), stdio.CallerOf(args), now()))
}
return versions.AnswerOldForm(of)
}},
{Name: versions.FloorWaitsTool, Description: versions.FloorWaitsDescription,
Input: map[string]any{"type": "object", "properties": map[string]any{}},
Run: func(map[string]any) (any, error) { return versions.AnswerFloorWaits(fw) }},
{Name: versions.VersionsTool, Description: versions.VersionsDescription,
Input: map[string]any{"type": "object", "required": []string{"name"}, "properties": map[string]any{
"name": map[string]any{"type": "string", "description": "the package, image, bundle or archive name"},
"version": map[string]any{"type": "string", "description": "one version, to ask whether it is recorded"},
"digest": map[string]any{"type": "string", "description": "with version: the digest the holder received"}}},
Run: func(args map[string]any) (any, error) {
return versions.AnswerVersions(context.Background(), vs, str(args, "name"), str(args, "version"), str(args, "digest"))
}},
{Name: build.StuckTool,
Description: "Every build key that waits (building or queued), oldest first: its module, fingerprint, step words and since when, and each ask it " +
"waits on by language with its id, when it was put, its asking (1 for the first; the builder asks an ask again when it can no longer be " +
@@ -265,10 +361,49 @@ func main() {
a, err := build.DecodeReAsk(v)
return err == nil && !a.Answered.IsZero()
})
vs := stdio.State(versions.State)
vr := versions.Reader{Keys: vs.Keys, Get: func(key string) (json.RawMessage, bool, error) {
e, err := vs.Get(key)
if err != nil || e == nil {
return nil, false, err
}
return e.Value, true, nil
}}
ts := stdio.State(versions.TagAsksState)
tags := askKept(ts, versions.TagAsksState, versions.TagAsksBucket, "the ask", 25*time.Second, func(v []byte) bool {
a, err := versions.DecodeTagAsk(v)
return err == nil && !a.Answered.IsZero()
})
tr := versions.Reader{Keys: ts.Keys, Get: func(key string) (json.RawMessage, bool, error) {
e, err := ts.Get(key)
if err != nil || e == nil {
return nil, false, err
}
return e.Value, true, nil
}}
fs := stdio.State(versions.FloorWaitsState)
fr := versions.Reader{Keys: fs.Keys, Get: func(key string) (json.RawMessage, bool, error) {
e, err := fs.Get(key)
if err != nil || e == nil {
return nil, false, err
}
return e.Value, true, nil
}}
ofs := stdio.State(versions.OldFormState)
ofr := versions.Reader{Keys: ofs.Keys, Get: func(key string) (json.RawMessage, bool, error) {
e, err := ofs.Get(key)
if err != nil || e == nil {
return nil, false, err
}
return e.Value, true, nil
}}
tools := Tools(r, put, askQueue(stdio.State(build.QueuesState), 25*time.Second), buildAgain, stdio.Log(delivery.BuilderLog).Since, time.Now, vr, tags, tr, fr, ofr)
// What each counted node's engine reads, as the builder keeps it (ADR 0618 §2, ADR 0624 §1): listed, one node asked
// again, or one node's key dropped.
tools = append(tools, enginereads.Tool(EngineReadsTool, "the builder", enginereads.KeptState(stdio.State("engine-reads")), true, time.Now))
// builder_first_views and builder_first_view_again: where the builder's first views of the indexer's and the assigner's
// states stand, and asking one again by hand (ADR 0615, DLV-M1).
tools := append(Tools(r, put, askQueue(stdio.State(build.QueuesState), 25*time.Second), buildAgain, stdio.Log(delivery.BuilderLog).Since, time.Now),
views.StatusTools(delivery.BuilderModule, "builder_first_views", "builder_first_view_again", stdio.State(views.StatusState), time.Now)...)
tools = append(tools, views.StatusTools(delivery.BuilderModule, "builder_first_views", "builder_first_view_again", stdio.State(views.StatusState), time.Now)...)
if err := stdio.Serve("", tools); err != nil {
fmt.Fprintf(os.Stderr, "[mesh-delivery-builder-tools] %v\n", err)
os.Exit(1)
@@ -0,0 +1,174 @@
package main
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health"
"mesh-delivery-builder/internal/build"
"mesh-delivery-builder/internal/versions"
)
// forgeStream is the forge bridge's stream `forge` as the bus names it, where the builder's durable consumer hears a
// branch's end (design 51 *Tagged versions*).
const forgeStream = "FORGE"
// forgeBranchDeleted is the forge bridge's kind for a branch's end (delivery.ForgeBranchDeleted in the group's mesh-sdk).
const forgeBranchDeleted = "branch-deleted"
// checkerAsksStream is the checker's family `asks` as the bus names it, and tagWantedSubject its event tag-wanted
// (design 51 *Tagged versions*, DLV-C3): on the checker's own stream, so its subject is mesh.stream.<module>.event.*.
const (
checkerAsksStream = "ASKS"
tagWantedSubject = "mesh.stream." + delivery.CheckerModule + ".event.tag-wanted"
)
// forgeEnd is the part of a forge message the builder reads.
type forgeEnd struct {
Kind string `json:"kind"`
Repository string `json:"repository"`
Branch string `json:"branch"`
Action string `json:"action"`
}
// ended says whether the message ends a branch: merged, a pull closed, or branch-deleted.
func (f forgeEnd) ended() bool {
switch f.Kind {
case delivery.ForgeMerged, forgeBranchDeleted:
return f.Branch != ""
case delivery.ForgePull:
return f.Action == delivery.PullClosed && f.Branch != ""
}
return false
}
// branchEnds hears the forge bridge's stream: every message wakes a floor whose bridge_tags was silent, and a branch's
// end removes its tagged versions, acknowledged only once all are removed.
type branchEnds struct {
acts versions.Acts
findings *findings
tagAsks func() jetstream.KeyValue
wake func(ctx context.Context)
logf func(string, ...any)
now func() time.Time
}
func (h *branchEnds) heard(ctx context.Context, m jetstream.Msg) {
if h.wake != nil {
h.wake(ctx)
}
var f forgeEnd
if err := json.Unmarshal(m.Data(), &f); err != nil || !f.ended() {
h.ack(m)
return
}
end, err := h.acts.BranchEnded(ctx, f.Repository, f.Branch)
if err != nil {
h.logf("the end of %s %s: the versions cannot be read, heard again: %v", f.Repository, f.Branch, err)
h.again(m)
return
}
id := f.Repository + "/" + f.Branch
if end.Done {
h.findings.clear(ctx, versions.RemovalNotDone, health.ScopeModule, id)
if kv := h.tagAsks(); kv != nil {
if err := kv.Delete(ctx, versions.TagAskKey(f.Repository, f.Branch)); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
h.logf("the tag ask of %s %s stays in %s: %v", f.Repository, f.Branch, versions.TagAsksState, err)
}
}
h.ack(m)
return
}
since := h.now()
if md, err := m.Metadata(); err == nil {
since = md.Timestamp
}
if h.now().Sub(since) >= versions.RemovalFindingAfter {
h.findings.raise(ctx, finding{kind: versions.RemovalNotDone, scope: health.ScopeModule, id: id,
summary: end.Finding(f.Repository, f.Branch, since),
headline: "A branch's tagged versions are not removed",
explanation: "The branch ended, and the registry seats did not say removed for every version tagged for it within 15 minutes.",
resolved: "Every seat said removed.",
needs: "Look at the seats named: " + strings.Join(end.Seats(), ", ")})
}
h.logf("the end of %s %s: remove asked of %d tagged versions, heard again in a minute", f.Repository, f.Branch, len(end.Left))
h.again(m)
}
func (h *branchEnds) ack(m jetstream.Msg) {
if err := m.Ack(); err != nil {
h.logf("acknowledging %s: %v", m.Subject(), err)
}
}
// again leaves the message for the bus to give again in a minute: the retry is the bus's redelivery, not a timer here.
func (h *branchEnds) again(m jetstream.Msg) {
if err := m.NakWithDelay(versions.RemoveAgainAfter); err != nil {
h.logf("leaving %s for the bus to give again: %v", m.Subject(), err)
}
}
// tagWanted takes the checker's tag-wanted in: written to tag-asks as builder_tag writes it, answered by its watch.
func tagWanted(ctx context.Context, kv jetstream.KeyValue, m jetstream.Msg, by string, now time.Time, logf func(string, ...any)) {
if m.Subject() != tagWantedSubject {
_ = m.Ack() // the family's other events (the verdicts) are not the builder's
return
}
var t versions.TagWanted
err := json.Unmarshal(m.Data(), &t)
var key string
var value []byte
if err == nil {
key, value, err = versions.TagWantedAsk(t, by, now)
}
if err != nil {
logf("%s holds what is no tag-wanted, left: %v", m.Subject(), err)
_ = m.Ack()
return
}
if kv == nil {
logf("tag-wanted of %s %s is heard again: %s is not on the bus yet", t.Repository, t.Branch, versions.TagAsksState)
_ = m.NakWithDelay(versions.RemoveAgainAfter)
return
}
if _, err := kv.Put(ctx, key, value); err != nil {
logf("tag-wanted of %s %s is heard again: %v", t.Repository, t.Branch, err)
_ = m.Nak()
return
}
_ = m.Ack()
}
// tagsVia is build.Builder.Tags over the state tag-asks: the asks of the repository, read when a check's build is
// looked at; none while the state is not bound.
func tagsVia(kv func() jetstream.KeyValue, logf func(string, ...any)) build.TagOf {
return func(ctx context.Context, repository, head string) (string, bool) {
store := kv()
if store == nil {
return "", false
}
lister, err := store.ListKeysFiltered(ctx, versions.EscapeToken(repository)+".>")
if err != nil {
logf("the tag asks of %s cannot be read, so its check builds no tagged version: %v", repository, err)
return "", false
}
var asks []versions.TagAsk
for key := range lister.Keys() {
e, err := store.Get(ctx, key)
if err != nil {
continue
}
if t, err := versions.DecodeTagAsk(e.Value()); err == nil {
asks = append(asks, t)
}
}
return versions.TagFor(asks, repository, head)
}
}
@@ -0,0 +1,139 @@
package main
import (
"context"
"errors"
"time"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health"
"git.novox.be/novox/mesh-sdk/go/process"
"mesh-delivery-builder/internal/versions"
)
// findingsBucket is the builder's findings (novox/hq ADR 0564 §2): what it sees wrong now, written at its raise and
// deleted at its clear, which the condition keeper raises and clears.
var findingsBucket = health.FindingsBucket(delivery.BuilderModule)
// findings writes the builder's findings into its state, once bound; until then each is said in the log only.
type findings struct {
kv *process.AwaitedBucket
logf func(string, ...any)
now func() time.Time
// store stands in for kv in a test.
store findingStore
}
// findingStore is the part of the findings bucket written.
type findingStore interface {
Get(ctx context.Context, key string) (jetstream.KeyValueEntry, error)
Put(ctx context.Context, key string, value []byte) (uint64, error)
Delete(ctx context.Context, key string, opts ...jetstream.KVDeleteOpt) error
}
// finding is one of the builder's findings: its kind, what it is about, and its words.
type finding struct {
kind, scope, id string
summary, headline, explanation, resolved, needs string
severity health.Severity
}
func (f *findings) bucket() findingStore {
switch {
case f == nil:
return nil
case f.store != nil:
return f.store
case f.kv == nil:
return nil
}
if kv := f.kv.KV(); kv != nil {
return kv
}
return nil
}
// BuildFailed is the finding of a failed trunk build (DLV-B7, design 25), about its module.
const BuildFailed = "build-failed"
// buildEnded raises the module's build-failed for a trunk build that failed and clears it at a later done one; a
// candidate's build (its ref named) is the check's to say.
func (f *findings) buildEnded(ctx context.Context, e delivery.BuildLogEntry) {
if e.Event != delivery.BuildEndedEvent || e.Ref != "" {
return
}
switch e.Outcome {
case delivery.BuildFailed:
f.raise(ctx, finding{kind: BuildFailed, scope: health.ScopeModule, id: e.Module,
summary: "the trunk build of " + e.Module + " at " + e.FP + " (commit " + e.Commit + ") failed",
headline: "A module's trunk build failed",
explanation: "The builder built " + e.Module + " from the trunk and the build failed, so its new version is not delivered.",
resolved: "A later trunk build of " + e.Module + " is done.",
needs: "builder_builds_since shows the build; builder_build_again builds it again."})
case delivery.BuildDone:
f.clear(ctx, BuildFailed, health.ScopeModule, e.Module)
}
}
// raise writes fd; it is written again only when its words changed, so the keeper's condition moves only then.
func (f *findings) raise(ctx context.Context, fd finding) {
key := health.FindingKey(fd.scope, fd.id, fd.kind)
kv := f.bucket()
if kv == nil {
f.logf("finding %s (not written, %s is not on the bus yet): %s", key, findingsBucket, fd.summary)
return
}
if e, err := kv.Get(ctx, key); err == nil {
if old, err := health.DecodeFinding(e.Value()); err == nil && old.Summary == fd.summary {
return
}
}
at := f.now().UTC()
sev := fd.severity
if sev == "" {
sev = health.Warning
}
value, err := health.EncodeFinding(health.Finding{Key: key, Kind: fd.kind, Subject: health.Subject{Scope: fd.scope, ID: fd.id},
Severity: sev, Summary: fd.summary, Headline: fd.headline, Explanation: fd.explanation, Resolved: fd.resolved, Needs: fd.needs,
Evidence: []health.Evidence{{At: at, Said: fd.summary}}, Source: "builder." + fd.kind, Observer: delivery.BuilderModule, At: at})
if err == nil {
_, err = kv.Put(ctx, key, value)
}
if err != nil {
f.logf("the finding %s cannot be written: %v", key, err)
}
}
// clear deletes the finding of kind about scope and id; one not there is nothing to clear.
func (f *findings) clear(ctx context.Context, kind, scope, id string) {
kv := f.bucket()
if kv == nil {
return
}
key := health.FindingKey(scope, id, kind)
if _, err := kv.Get(ctx, key); errors.Is(err, jetstream.ErrKeyNotFound) {
return
}
if err := kv.Delete(ctx, key); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
f.logf("the finding %s cannot be cleared: %v", key, err)
}
}
// floorSaid is the floor finding (design 51 *Health and conditions*: a floor source silent): raised for a name waiting on
// its floor, about that name, cleared once all three sources answered.
func (f *findings) floorSaid(ctx context.Context) func(versions.FloorWait, bool) {
return func(w versions.FloorWait, raised bool) {
if !raised {
f.clear(ctx, versions.FloorSilent, health.ScopeModule, w.Name)
return
}
f.raise(ctx, finding{kind: versions.FloorSilent, scope: health.ScopeModule, id: w.Name, summary: w.Finding(),
headline: "A version's floor is not known",
explanation: "The builder numbers a name only once its registry seat, the forge bridge's tags and the indexer's requirements all answered; one of them did not.",
resolved: "All three answered.",
needs: "builder_floor_waits names the silent source; builder_floor_again asks again by hand."})
}
}
@@ -9,9 +9,9 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"sync/atomic"
@@ -25,13 +25,17 @@ import (
// The build runner the build modules pull and run asks with: mesh-build-go's own package (novox/hq ADR 0593 §1),
// another repository's, by its published version, so the builder is tested against the real pulling code.
runner "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep"
"git.novox.be/novox/mesh-contracts/go/seats/gopackageregistry"
"git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry"
"git.novox.be/novox/mesh-delivery/sdk/firstview/firstviewtest"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-delivery/sdk/firstview/firstviewtest"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health"
"git.novox.be/novox/mesh-sdk/go/process"
"mesh-delivery-builder/internal/build"
"mesh-delivery-builder/internal/versions"
)
// busConf is the shape of the composed accounts file (mesh-controller internal/broker): one account with JetStream, the
@@ -73,6 +77,9 @@ type fakeBuildModules struct {
// waiting in their streams).
closed atomic.Bool
pullers []*runner.Puller
// hang, while it holds a channel, keeps every build step running until the channel is closed: a step that never ends,
// so the builder's step bound runs out (design 51's round-2 case).
hang atomic.Pointer[chan struct{}]
}
// open lets the nodes pull again and wakes their pullers.
@@ -111,6 +118,12 @@ func (f *fakeBuildModules) step(module, name string, args map[string]any) (any,
f.builds = append(f.builds, args)
}
f.mu.Unlock()
if h := f.hang.Load(); h != nil && name == "build" {
select {
case <-*h:
case <-f.ctx.Done():
}
}
switch {
case name == "fetch":
merge := ""
@@ -196,7 +209,10 @@ type bed struct {
builds jetstream.KeyValue
tools map[string]jetstream.KeyValue
instances jetstream.KeyValue
stop func() // stops the builder started last
tagAsks jetstream.KeyValue
tagBounds jetstream.KeyValue
out *saidLog // the builder's process log, every instance's
stop func() // stops the builder started last
fake *fakeBuildModules
srvURL string
dir string
@@ -280,6 +296,13 @@ func startBedWith(t *testing.T, closed bool) *bed {
firstviewtest.Serve(t, ctx, js, ctl, delivery.AssignerModule, delivery.PlacementState, readers...)
b.builds = mk(delivery.BuilderBuildsBucket, delivery.BuilderBuildsHistory)
b.instances = mk(instancesBucket, 1)
// The tag asks and their bounds (design 51 *Tagged versions*): the bounds declared key-ttl, so an ended bound leaves the
// bus's marker a watch hears.
b.tagAsks = mk(versions.TagAsksBucket, 1)
if b.tagBounds, err = js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: versions.TagAskBoundsBucket, History: 1, LimitMarkerTTL: time.Minute}); err != nil {
t.Fatal(err)
}
b.out = &saidLog{}
// The builder's module log, as the runtime raises it (ADR 0297): its stream on the log's subjects.
logStream := buscompose.LogStreamName(delivery.BuilderModule, delivery.BuilderLog)
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: logStream, Subjects: []string{buscompose.LogSubject(delivery.BuilderModule, delivery.BuilderLog) + ".>"},
@@ -339,15 +362,24 @@ func (b *bed) report(t *testing.T, module, node, language, version, recipe strin
// module writes modules.<name> as the indexer does for a module with a new-form fingerprint, and its manifest by digest.
func (b *bed) module(t *testing.T, name, fixture, path string) string {
return b.moduleWith(t, name, fixture, path, map[string]delivery.ToolchainUsed{"go": {Version: "go1.27.1", Recipe: "1"}, "typescript": {Version: "tsc5", Recipe: "1"}})
return b.moduleWith(t, name, fixture, path, bothToolchains)
}
// bothToolchains are the toolchains the bed's nodes report, as a module's fingerprint names them.
var bothToolchains = map[string]delivery.ToolchainUsed{"go": {Version: "go1.27.1", Recipe: "1"}, "typescript": {Version: "tsc5", Recipe: "1"}}
// moduleWith is module with the toolchains its fingerprint was computed with.
func (b *bed) moduleWith(t *testing.T, name, fixture, path string, toolchains map[string]delivery.ToolchainUsed) string {
return b.moduleCommit(t, name, fixture, path, toolchains, commit)
}
func (b *bed) moduleCommit(t *testing.T, name, fixture, path string, toolchains map[string]delivery.ToolchainUsed, at string) string {
t.Helper()
return b.moduleFP(t, name, fixture, path, toolchains, at, fp, 0)
}
// moduleFP is moduleCommit with the new-form fingerprint fp2 and the source revision src of its merge (0: none).
func (b *bed) moduleFP(t *testing.T, name, fixture, path string, toolchains map[string]delivery.ToolchainUsed, at, fp2 string, src uint64) string {
t.Helper()
raw, err := os.ReadFile(filepath.Join("testdata", fixture))
if err != nil {
@@ -365,8 +397,8 @@ func (b *bed) moduleCommit(t *testing.T, name, fixture, path string, toolchains
if _, err := manifests.Put(b.ctx, digest, mv); err != nil {
t.Fatal(err)
}
data, err := delivery.EncodeModule(delivery.Module{Repository: "novox/mesh-catalog", Commit: at, FP: "src1:" + strings.Repeat("f", 64), FP2: fp,
Manifest: digest, Path: path, Toolchains: toolchains})
data, err := delivery.EncodeModule(delivery.Module{Repository: "novox/mesh-catalog", Commit: at, FP: "src1:" + strings.Repeat("f", 64), FP2: fp2,
Manifest: digest, Path: path, Toolchains: toolchains, Src: src})
if err != nil {
t.Fatal(err)
}
@@ -397,16 +429,58 @@ func (b *bed) start(t *testing.T, node string, env map[string]string) {
}
runCtx, stop := context.WithCancel(b.ctx)
done := make(chan error, 1)
go func() { done <- run(runCtx, func(k string) string { return e[k] }, io.Discard) }()
go func() { done <- run(runCtx, func(k string) string { return e[k] }, b.out) }()
var once sync.Once
b.stop = func() { once.Do(func() { stop(); <-done }) }
t.Cleanup(b.stop)
}
// saidLog is the builder's process log as its instances write it.
type saidLog struct {
mu sync.Mutex
buf strings.Builder
}
func (l *saidLog) Write(p []byte) (int, error) {
l.mu.Lock()
defer l.mu.Unlock()
return l.buf.Write(p)
}
// awaitSaid reads the process log until it holds want, within 20 s, and answers the line.
func (b *bed) awaitSaid(t *testing.T, want string) string {
t.Helper()
deadline := time.Now().Add(20 * time.Second)
for {
b.out.mu.Lock()
all := b.out.buf.String()
b.out.mu.Unlock()
for _, line := range strings.Split(all, "\n") {
if strings.Contains(line, want) {
return line
}
}
if time.Now().After(deadline) {
t.Fatalf("the builder never said %q; it said:\n%s", want, all)
}
select {
case <-b.ctx.Done():
t.Fatal(b.ctx.Err())
case <-time.After(50 * time.Millisecond):
}
}
}
// await reads the build key until pred holds of it, within 20 s.
func (b *bed) await(t *testing.T, module string, pred func(delivery.BuilderBuild) bool) delivery.BuilderBuild {
t.Helper()
key, err := delivery.BuilderBuildKey(module, fp)
return b.awaitFP(t, module, fp, pred)
}
// awaitFP is await of the build key of fingerprint at.
func (b *bed) awaitFP(t *testing.T, module, at string, pred func(delivery.BuilderBuild) bool) delivery.BuilderBuild {
t.Helper()
key, err := delivery.BuilderBuildKey(module, at)
if err != nil {
t.Fatal(err)
}
@@ -433,9 +507,31 @@ func isState(state string) func(delivery.BuilderBuild) bool {
// the digests of both bundles, both build modules' toolchains, after a fetch and a build on one node (ADR 0478 clause 4).
func TestBuilderMakesADoneKeyWithTheDigests(t *testing.T) { // 1607/AC7
b := startBed(t)
digest := b.module(t, "netcheck", "catalogue-a67f0196-netcheck.module.json", "modules/netcheck")
b.start(t, "n1", nil)
// DLV-B10 (design 51, round 2): the builder's versions, findings and the floor's three sources are on the bus; the seat
// answers 0.1.4, so netcheck's bundles are numbered from 0.1.5. Two merges close together, the first replaced before
// its claim: the second is numbered at once, and the first never.
reg := b.registry(t, "n1", "0.1.4")
const netcheck = "catalogue-a67f0196-netcheck.module.json"
first := "src2:" + strings.Repeat("1", 64)
b.moduleFP(t, "netcheck", netcheck, "modules/netcheck", bothToolchains, commit, first, 1)
digest := b.moduleFP(t, "netcheck", netcheck, "modules/netcheck", bothToolchains, commit, fp, 2)
// DLV-B6: a tag ask kept and never numbered (no check builds its head) is narrated once its bound ends on the bus, with
// what it waits on, and its bound is written again; no timer runs in the builder.
key, ask, err := versions.NewTagAsk("novox/mesh-sdk", "feat/x", "abc", "a group member's check", "me@shanks", time.Now())
if err != nil {
t.Fatal(err)
}
if _, err := b.tagAsks.Put(b.ctx, key, ask); err != nil {
t.Fatal(err)
}
b.start(t, "n1", map[string]string{"MESH_BUILDER_TAG_ASK_BOUND": "1s", "MESH_BUILDER_STEP_BOUND": "1s"})
v := b.await(t, "netcheck", isState(delivery.BuildDone))
for _, a := range []string{"tools-go", "tools-typescript"} {
es := reg.of(t, "netcheck/"+a)
if len(es) != 1 || es[0].Version != "0.1.5" || es[0].Fingerprint != fp || es[0].State != versions.Numbered || es[0].Digest != digestOf(a) {
t.Errorf("versions of netcheck/%s = %+v, want the second merge numbered 0.1.5 at once with its digest, the first never", a, es)
}
}
if v.By != "n1.mesh-delivery-builder" || v.Manifest != digest || len(v.Artifacts) != 2 {
t.Fatalf("build = %+v", v)
}
@@ -447,9 +543,21 @@ func TestBuilderMakesADoneKeyWithTheDigests(t *testing.T) { // 1607/AC7
if v.Toolchains["go"].Version != "go1.27.1" || v.Toolchains["typescript"].Version != "tsc5" {
t.Errorf("toolchains = %+v", v.Toolchains)
}
// ADR 0618 §1: the done key names the reference forms it is written in.
if len(v.Needs) != 1 || v.Needs[0] != "form:artifact-store" {
t.Errorf("needs = %v, want the first form", v.Needs)
}
// 1955/L7 (ADR 0571 §1): the build's start and end are in the module log build-log under the module's key, read as a
// reader with the log's read reads them, the end naming the manifest and what was made.
ended := b.logged(t, "netcheck")
// DLV-B10: between them the build log holds the build's narrations (build.said), each version numbered, and why.
var ended []delivery.BuildLogEntry
for _, e := range b.logged(t, "netcheck") {
if e.Event != delivery.BuildSaidEvent {
ended = append(ended, e)
} else if e.Why != versions.SaidNumbered || !strings.Contains(e.Line, "numbered 0.1.5") || e.FP != fp {
t.Errorf("said = %+v, want netcheck's versions numbered", e)
}
}
if len(ended) != 2 || ended[0].Event != delivery.BuildStartedEvent || ended[1].Event != delivery.BuildEndedEvent ||
ended[1].Manifest != digest || len(ended[1].Made) != 2 || ended[1].Node != "n1" {
t.Errorf("logged = %+v, want the start and the end of netcheck's build", ended)
@@ -459,38 +567,139 @@ func TestBuilderMakesADoneKeyWithTheDigests(t *testing.T) { // 1607/AC7
t.Errorf("%s called %d times, want %d (on one node, ADR 0478 clause 4)", tool, got, want)
}
}
}
// TestTwoBuilderInstancesStartingTogetherEndWithOneDoneKey: two builders on two nodes start together on one module key of an
// archive only (no toolchain named, as the indexer writes it); the second takes over the key the first holds building
// (ADR 0478 clause 6), and the key ends done, written once.
func TestTwoBuilderInstancesStartingTogetherEndWithOneDoneKey(t *testing.T) { // 1607/AC7
b := startBed(t)
b.archive(t, "ca-trust", "modules/ca-trust", commit)
b.start(t, "n1", nil)
b.start(t, "n2", nil)
v := b.await(t, "ca-trust", isState(delivery.BuildDone))
if len(v.Artifacts) != 1 || v.Artifacts[0].Kind != "archive" || v.Artifacts[0].Digest != digestOf("files") {
t.Fatalf("build = %+v, want the archive's digest", v)
said := b.awaitSaid(t, "the tag ask of novox/mesh-sdk feat/x at abc (feat-x) is not answered after")
if !strings.Contains(said, "the check's build of abc is not claimed yet") {
t.Errorf("the narration = %q, want what the ask waits on", said)
}
if n := b.fake.count("mesh-build-go.build_go_build"); n < 1 { // each instance that built, on one node
t.Errorf("build_go_build was called %d times, want one for each instance that built", n)
// And in the build log, under the builder's own key (the ruling of 13:17: build.said).
if !slices.ContainsFunc(b.logged(t, delivery.BuilderModule), func(e delivery.BuildLogEntry) bool {
return e.Event == delivery.BuildSaidEvent && e.Why == versions.SaidUnanswered && e.FP == key && e.Commit == "abc" &&
strings.Contains(e.Line, "the check's build of abc is not claimed yet")
}) {
t.Errorf("the build log of %s = %+v, want the unanswered tag ask said", delivery.BuilderModule, b.logged(t, delivery.BuilderModule))
}
if e, err := b.tagAsks.Get(b.ctx, key); err != nil {
t.Fatal(err)
} else if a, _ := versions.DecodeTagAsk(e.Value()); a.Answered.IsZero() || !a.Unanswered() {
t.Errorf("the tag ask = %+v, want kept and answered, no version yet", a)
}
for deadline := time.Now().Add(10 * time.Second); ; time.Sleep(50 * time.Millisecond) {
if _, err := b.tagBounds.Get(b.ctx, key); err == nil {
break
} else if time.Now().After(deadline) {
t.Fatalf("the bound is not written again after its narration: %v", err)
}
}
if got := reg.numberedOf("netcheck/tools-go"); len(got) != 1 || got[0] != "0.1.5" {
t.Errorf("version.numbered of netcheck/tools-go = %v, want 0.1.5 once", got)
}
// DLV-B10: builder_publish_again after a published takes a new number, and says it numbered.
for _, a := range []string{"tools-go", "tools-typescript"} {
reg.published(t, "netcheck/"+a, "0.1.5", digestOf(a))
}
reg.awaitOf(t, "netcheck/tools-go", func(es []versions.Entry) bool { return len(es) == 1 && es[0].State == versions.Published })
key, again, err := build.NewPublishAgain("netcheck/tools-go", fp, "the holder lost it", "me@shanks", time.Now())
if err != nil {
t.Fatal(err)
}
if a := reg.answered(t, key, again); a.Refused || a.Do != versions.AgainRenumber || a.Version != "0.1.6" {
t.Errorf("builder_publish_again = %+v, want a new number, 0.1.6", a)
}
if got := reg.numberedOf("netcheck/tools-go"); len(got) != 2 || got[1] != "0.1.6" {
t.Errorf("version.numbered of netcheck/tools-go = %v, want 0.1.6 said after 0.1.5", got)
}
// DLV-B10: a build module's bound running out fails the trunk build, which is the builder's finding build-failed; the
// fingerprint built again keeps its number, said with why, and its done build clears the finding.
release := make(chan struct{})
b.fake.hang.Store(&release)
third := "src2:" + strings.Repeat("4", 64)
b.moduleFP(t, "netcheck", netcheck, "modules/netcheck", bothToolchains, commit, third, 3)
if f := b.awaitFP(t, "netcheck", third, isState(delivery.BuildFailed)); !strings.Contains(f.Error, "did not end within") {
t.Errorf("the failed build = %+v, want its bound run out", f)
}
failed := health.FindingKey(health.ScopeModule, "netcheck", BuildFailed)
reg.awaitFinding(t, failed, true)
b.fake.hang.Store(nil)
close(release)
b.moduleFP(t, "netcheck", netcheck, "modules/netcheck", bothToolchains, commit, third, 3) // the indexer writes it again
b.awaitFP(t, "netcheck", third, isState(delivery.BuildDone))
b.awaitSaid(t, "kept: fingerprint "+third+" is numbered under it")
if !slices.ContainsFunc(b.logged(t, "netcheck"), func(e delivery.BuildLogEntry) bool {
return e.Event == delivery.BuildSaidEvent && e.Why == versions.SaidKept && e.FP == third && strings.Contains(e.Line, "kept: fingerprint "+third)
}) {
t.Errorf("the build log of netcheck says no version kept at %s", third)
}
reg.awaitFinding(t, failed, false)
// DLV-B4: the seats' refused and removed, heard on the builder's durable consumer, move a numbered version: a retention
// removal turns the entry removed with its reason, a refusal refused with the holder's reason and the bytes it refused.
reg.outcome(t, ociartifactregistry.SubjectRemoved, versions.Outcome{Name: "netcheck/tools-typescript", Version: "0.1.5", Kind: "bundle", Holder: "distribution", Reason: "retention"})
reg.outcome(t, ociartifactregistry.SubjectRefused, versions.Outcome{Name: "netcheck/tools-go", Version: "0.1.6", Digest: digestOf("other"), Kind: "bundle", Holder: "distribution", Reason: "the cap"})
reg.awaitOf(t, "netcheck/tools-typescript", func(es []versions.Entry) bool {
return slices.ContainsFunc(es, func(e versions.Entry) bool {
return e.Version == "0.1.5" && e.State == versions.Removed && e.Reason == "retention"
})
})
reg.awaitOf(t, "netcheck/tools-go", func(es []versions.Entry) bool {
return slices.ContainsFunc(es, func(e versions.Entry) bool {
return e.Version == "0.1.6" && e.State == versions.Refused && e.Reason == "the cap" && e.RefusedDigest == digestOf("other")
})
})
// DLV-B6: a branch's end (the forge bridge's merged) asks the seat to remove each version tagged for it, the reason
// tag-gone, and the seat's removed turns it removed.
tagged := versions.Entry{Name: "mesh-sdk/go", Kind: versions.KindPackage, Series: "0.1", Version: "0.1.15-feat-x.1", Tag: "feat-x", Module: "mesh-sdk",
Fingerprint: "src2:" + strings.Repeat("5", 64), Repository: "novox/mesh-sdk", Seat: gopackageregistry.Seat, State: versions.Published, At: time.Now().UTC()}
raw, _ := json.Marshal(tagged)
if _, err := reg.versions.Put(b.ctx, versions.Key(tagged.Name, tagged.Version), raw); err != nil {
t.Fatal(err)
}
if _, err := b.js.Publish(b.ctx, process.ModuleStreamSubject(delivery.ForgeBridgeModule, "forge"),
[]byte(`{"kind":"`+delivery.ForgeMerged+`","repository":"novox/mesh-sdk","branch":"feat/x"}`)); err != nil {
t.Fatal(err)
}
for deadline := time.Now().Add(20 * time.Second); ; time.Sleep(50 * time.Millisecond) {
reg.mu.Lock()
asked := slices.ContainsFunc(reg.removes, func(a map[string]any) bool {
return a["name"] == tagged.Name && a["version"] == tagged.Version && a["reason"] == "tag-gone"
})
reg.mu.Unlock()
if asked {
break
}
if time.Now().After(deadline) {
t.Fatalf("the seat's remove of %s@%s was not asked at the branch's end: %v", tagged.Name, tagged.Version, reg.removes)
}
}
reg.outcome(t, gopackageregistry.SubjectRemoved, versions.Outcome{Name: tagged.Name, Version: tagged.Version, Kind: versions.KindPackage, Holder: "gitea", Reason: "tag-gone"})
reg.awaitOf(t, tagged.Name, func(es []versions.Entry) bool { return len(es) == 1 && es[0].State == versions.Removed })
}
// TestAnArchiveOnlyModuleAndARootModuleAreBuilt: the indexer gives an archive no language, so modules.ca-trust has no
// toolchains; the builder builds it on the build module's nodes all the same (review round 4). A module whose path is "."
// is given to the build tools as it is (1615/AC4). One table: the two tests were merged to pay for the enrolment's.
// is given to the build tools as it is (1615/AC4). Two builders on two nodes start together on the archive's key: the
// second takes over the key the first holds building (ADR 0478 clause 6), and the key ends done, written once (1607/AC7).
// One table: three tests were merged, to pay for the enrolment's and to bring mesh-delivery's count down (DLV-B8).
func TestAnArchiveOnlyModuleAndARootModuleAreBuilt(t *testing.T) {
for _, c := range []struct{ name, path string }{{"no toolchain", "modules/ca-trust"}, {"root", delivery.RootPath}} {
for _, c := range []struct {
name, path string
two bool
}{{"no toolchain", "modules/ca-trust", false}, {"root", delivery.RootPath, false}, {"two instances together", "modules/ca-trust", true}} {
t.Run(c.name, func(t *testing.T) {
b := startBed(t)
b.archive(t, "ca-trust", c.path, commit)
b.start(t, "n1", nil)
if c.two {
b.start(t, "n2", nil)
}
v := b.await(t, "ca-trust", isState(delivery.BuildDone))
if c.name == "no toolchain" && (len(v.Toolchains) != 0 || len(v.Artifacts) != 1 || strings.HasPrefix(v.Step, "one node (")) {
t.Errorf("build = %+v, want done naming no toolchain", v)
}
if c.two && (len(v.Artifacts) != 1 || v.Artifacts[0].Kind != "archive" || v.Artifacts[0].Digest != digestOf("files")) {
t.Errorf("build = %+v, want the archive's digest", v)
}
if n := b.fake.count("mesh-build-go.build_go_build"); c.two && n < 1 { // each instance that built, on one node
t.Errorf("build_go_build was called %d times, want one for each instance that built", n)
}
})
}
}
@@ -923,3 +1132,184 @@ func TestARestartedBuilderPutsNoSecondAsk(t *testing.T) {
t.Errorf("done = %+v", done)
}
}
// registry is the part of the bed design 51's builder cases need: the builder's states versions, findings, floor-waits and
// reasks, EVENTS with the seat oci-artifact-registry's outcomes and the builder's durable consumer of it, the builder's
// own events kept, and the floor's three sources served on the bus as their holders answer through the tool runner
// ({result}): the seat's versions, the forge bridge's bridge_tags and the indexer's indexer_requirements.
type registry struct {
b *bed
versions, findings, reasks jetstream.KeyValue
mu sync.Mutex
numbered []versions.NumberedBody
removes []map[string]any
}
func (b *bed) registry(t *testing.T, node string, seatVersions ...string) *registry {
t.Helper()
r := &registry{b: b}
mk := func(name string) jetstream.KeyValue {
kv, err := b.js.CreateKeyValue(b.ctx, jetstream.KeyValueConfig{Bucket: name, History: 1})
if err != nil {
t.Fatal(err)
}
return kv
}
r.versions, r.findings, r.reasks = mk(versions.Bucket), mk(findingsBucket), mk(build.ReAsksBucket)
mk(versions.FloorWaitsBucket)
// EVENTS with two seats' outcomes, and the forge bridge's stream (a branch's end): each with the builder's durable consumer.
for stream, subjects := range map[string][]string{
eventsStream: {delivery.ArtifactsGoneSubject, ociartifactregistry.SubjectPublished, ociartifactregistry.SubjectRefused, ociartifactregistry.SubjectRemoved,
gopackageregistry.SubjectPublished, gopackageregistry.SubjectRefused, gopackageregistry.SubjectRemoved},
forgeStream: {process.ModuleStreamSubject(delivery.ForgeBridgeModule, ">")},
} {
if _, err := b.js.CreateStream(b.ctx, jetstream.StreamConfig{Name: stream, Subjects: subjects}); err != nil {
t.Fatal(err)
}
if _, err := b.js.CreateOrUpdateConsumer(b.ctx, stream, jetstream.ConsumerConfig{Durable: node + "_" + delivery.BuilderModule,
AckPolicy: jetstream.AckExplicitPolicy}); err != nil {
t.Fatal(err)
}
}
// The Go seat's remove, as its holder answers it; each call is kept.
rm, err := b.js.Conn().Subscribe("mesh.seat."+gopackageregistry.Seat+".call.remove.>", func(m *nats.Msg) {
var args map[string]any
_ = json.Unmarshal(m.Data, &args)
r.mu.Lock()
r.removes = append(r.removes, args)
r.mu.Unlock()
_ = m.Respond([]byte(`{"result":{},"node":"ctl"}`))
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = rm.Unsubscribe() })
own := "mesh.mod." + delivery.BuilderModule + ".event.>"
if _, err := b.js.CreateStream(b.ctx, jetstream.StreamConfig{Name: "BUILDER_EVENTS", Subjects: []string{own}}); err != nil {
t.Fatal(err)
}
nc := b.js.Conn()
sub, err := nc.Subscribe("mesh.mod."+delivery.BuilderModule+".event."+versions.NumberedEvent, func(m *nats.Msg) {
var nb versions.NumberedBody
if json.Unmarshal(m.Data, &nb) == nil {
r.mu.Lock()
r.numbered = append(r.numbered, nb)
r.mu.Unlock()
}
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sub.Unsubscribe() })
var listed []map[string]string
for _, v := range seatVersions {
listed = append(listed, map[string]string{"version": v})
}
for subject, answer := range map[string]any{
"mesh.seat." + ociartifactregistry.Seat + ".call.versions.>": map[string]any{"versions": listed},
"mesh.mod.mesh-delivery-forge-bridge.call.bridge_tags.>": map[string]any{"tags": []string{}},
"mesh.mod." + delivery.IndexerModule + ".call.indexer_requirements.>": map[string]any{"requirements": []string{}},
} {
reply, _ := json.Marshal(map[string]any{"result": answer, "node": "ctl"})
s, err := nc.Subscribe(subject, func(m *nats.Msg) { _ = m.Respond(reply) })
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = s.Unsubscribe() })
}
return r
}
// of is every entry versions holds of name.
func (r *registry) of(t *testing.T, name string) []versions.Entry {
t.Helper()
l, err := r.versions.ListKeysFiltered(r.b.ctx, versions.NameFilter(name))
if err != nil {
t.Fatal(err)
}
defer func() { _ = l.Stop() }()
var out []versions.Entry
for k := range l.Keys() {
e, err := r.versions.Get(r.b.ctx, k)
if err != nil {
t.Fatal(err)
}
var v versions.Entry
if err := json.Unmarshal(e.Value(), &v); err != nil {
t.Fatal(err)
}
out = append(out, v)
}
return out
}
// awaitOf reads name's entries until pred holds of them, within 20 s.
func (r *registry) awaitOf(t *testing.T, name string, pred func([]versions.Entry) bool) []versions.Entry {
t.Helper()
var es []versions.Entry
for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
if es = r.of(t, name); pred(es) {
return es
}
}
t.Fatalf("versions of %s = %+v within 20 s", name, es)
return es
}
// published says the artifact seat's published for name@version, as its holder does.
func (r *registry) published(t *testing.T, name, version, digest string) {
t.Helper()
r.outcome(t, ociartifactregistry.SubjectPublished, versions.Outcome{Name: name, Version: version, Digest: digest, Kind: "bundle", Holder: "distribution"})
}
// outcome says a seat's outcome on subject, as its holder does.
func (r *registry) outcome(t *testing.T, subject string, o versions.Outcome) {
t.Helper()
data, _ := json.Marshal(o)
msg := nats.NewMsg(subject)
msg.Header.Set("x-event-id", subject+"-"+o.Name+"-"+o.Version)
msg.Data = data
if _, err := r.b.js.PublishMsg(r.b.ctx, msg); err != nil {
t.Fatal(err)
}
}
// answered writes a reasks value and reads it until the builder answered it, within 20 s.
func (r *registry) answered(t *testing.T, key string, value []byte) build.ReAsk {
t.Helper()
if _, err := r.reasks.Put(r.b.ctx, key, value); err != nil {
t.Fatal(err)
}
var a build.ReAsk
for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
if e, err := r.reasks.Get(r.b.ctx, key); err == nil && json.Unmarshal(e.Value(), &a) == nil && !a.Answered.IsZero() {
return a
}
}
t.Fatalf("reasks.%s = %+v, not answered within 20 s", key, a)
return a
}
// awaitFinding reads the builder's finding key until it is there (raised) or gone, within 20 s.
func (r *registry) awaitFinding(t *testing.T, key string, raised bool) {
t.Helper()
for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
if _, err := r.findings.Get(r.b.ctx, key); (err == nil) == raised {
return
}
}
t.Fatalf("the finding %s raised = %v within 20 s, want %v", key, !raised, raised)
}
// numberedOf are the version.numbered the builder said for name, in order.
func (r *registry) numberedOf(name string) []string {
r.mu.Lock()
defer r.mu.Unlock()
var out []string
for _, nb := range r.numbered {
if nb.Name == name {
out = append(out, nb.Version)
}
}
return out
}
@@ -19,6 +19,7 @@ import (
"time"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-delivery/sdk/forms"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
@@ -30,6 +31,7 @@ import (
"git.novox.be/novox/mesh-delivery/sdk/firstview"
"mesh-delivery-builder/internal/build"
"mesh-delivery-builder/internal/versions"
)
const (
@@ -74,8 +76,8 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
}
defer calls.Close()
reader := &firstview.Reader{Module: delivery.BuilderModule, Call: calls.Call, Logf: logger.Printf}
findings := process.AwaitBucket(ctx, js, conn, findingsBucket, "a first view unanswered past its bound is said in the log alone", logger.Printf)
reader.Findings = findings.KV
findingsKV := process.AwaitBucket(ctx, js, conn, findingsBucket, "the builder's findings, a first view unanswered past its bound among them, are said in its log only", logger.Printf)
reader.Findings = findingsKV.KV
modules := reader.Follow(delivery.IndexerModule, modulesState)
manifests := reader.Follow(delivery.IndexerModule, manifestsState)
// The candidates' fingerprints are built too (issue 1684).
@@ -87,6 +89,13 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
return fmt.Errorf("MESH_BUILDER_STEP_BOUND: %w", err)
}
}
// An unanswered tag ask is narrated every TagAskBound (design 51: five minutes); a test gives a shorter one.
tagAskBound := versions.TagAskBound
if v := strings.TrimSpace(getenv("MESH_BUILDER_TAG_ASK_BOUND")); v != "" {
if tagAskBound, err = time.ParseDuration(v); err != nil {
return fmt.Errorf("MESH_BUILDER_TAG_ASK_BOUND: %w", err)
}
}
tools := &buildrun.ToolchainSet{}
// Each build module's part of a build is an ask on its build seat's stream, pulled by a build node with a free slot
// (novox/hq ADR 0525): the builder chooses no node, so it reads neither presence nor have and holds no capacity.
@@ -114,6 +123,26 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
conn.SetReconnectHandler(func(*nats.Conn) { hearing.Again(); heard.Nudge("") })
// The builder asks the controller, as its own account, whether the artifact store still holds a done key's blobs
// (the seat verb held, novox/hq issue 1937) before it leaves the key done, on its calls connection.
// The builder numbers each trunk build at its claim (novox/hq design 51 *The versions*): its state versions, bound once
// the bus holds it; the floor's three sources asked as its own account; each new number said as version.numbered.
versionsKV := process.AwaitBucket(ctx, js, conn, versions.Bucket, "no trunk build is numbered: each is built unnumbered and published by its digest", logger.Printf)
found := &findings{kv: findingsKV, logf: logger.Printf, now: time.Now}
floorWaits := &versions.FloorWaits{Logf: logger.Printf, Said: found.floorSaid(context.WithoutCancel(ctx))}
// The names waiting on their floor are kept in the state floor-waits (DLV-B4): a restarted builder takes them in, so
// their silent sources' events still wake them; until the state is bound they are kept here only.
process.AwaitBucket(ctx, js, conn, versions.FloorWaitsBucket, "the names waiting on their floor are kept in memory only, and builder_floor_waits shows none", logger.Printf).
Ready(func(kv jetstream.KeyValue) {
floorWaits.Keep(kvKeeper{ctx: ctx, kv: kv}, heldFloorWaits(ctx, kv, logger.Printf))
})
// A package is numbered under its name in its ecosystem, read from the indexer's packages state (design 32).
packagesKV := process.AwaitBucket(ctx, js, conn, delivery.PackagesBucket, "no package is numbered: each is built unnumbered and said", logger.Printf)
numberer := &versions.Numberer{Versions: versions.Versions{Store: awaitedVersions{versionsKV}}, Floor: floorVia(calls.Call), Waits: floorWaits,
Names: versions.NamesIn(indexerPackages{packagesKV}),
Said: func(nb versions.NumberedBody) {
if err := emitNumbered(context.WithoutCancel(ctx), js, cred, nb); err != nil {
logger.Printf("%v", err)
}
}}
b := &build.Builder{
Claimer: build.Claimer{Store: kvStore{builds}, By: cred.Node + "." + cred.Module},
Asks: buildrun.Asker{Publisher: asks, Waiter: buildrun.LostAskWaiter{Inner: heard, Dead: dead, Hearing: hearing}, Bound: bound},
@@ -127,20 +156,52 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
Stored: storedVia(calls.Call),
Node: cred.Node,
}
tagAsksKV := process.AwaitBucket(ctx, js, conn, versions.TagAsksBucket, "builder_tag and builder_untag are not answered", logger.Printf)
// A check's build of a head a kept tag ask names numbers its packages tagged (design 51 *Tagged versions*), and the ask
// is marked numbered with its versions.
b.Tags = tagsVia(tagAsksKV.KV, logger.Printf)
b.Numbers = build.VersionNumbers{N: numberer, TagNumbered: tagNumbered(tagAsksKV.KV, logger.Printf, time.Now)}
// An ask not answered within five minutes is narrated with what it waits on: its bound's end on the bus says when.
boundsKV := process.AwaitBucket(ctx, js, conn, versions.TagAskBoundsBucket, "an unanswered tag ask is not narrated", logger.Printf)
bounds := &tagBounds{asks: tagAsksKV.KV, bounds: boundsKV.KV, bound: tagAskBound, waits: floorWaits.Waiting, logf: logger.Printf, now: time.Now,
log: func(e delivery.BuildLogEntry) { b.Log(e) }}
// A state bound later numbers the builds looked at without it.
versionsKV.Ready(func(jetstream.KeyValue) { b.Reconsider(ctx) })
// The versions' hand acts (builder_publish_again, builder_floor_again) come through reasks, as builder_build_again.
acts := versions.Acts{Numberer: numberer, Done: doneVia(b), Reconsider: b.Reconsider, Remove: removeVia(calls.Call),
BuildAgain: func(ctx context.Context, module, fp, why, by string) (string, bool) {
r := b.BuildAgain(ctx, module, fp, why, by, buildAgainBound)
if r.Refused != "" {
return r.Refused, true
}
if r.Failed != "" {
return "built again, and it failed before its asks were put: " + r.Failed, false
}
return fmt.Sprintf("built again as revision %d", r.Revision), false
}}
// A candidate's preview is merged onto the trunk head the indexer previewed it onto: source.main at the candidate's base
// revision (the gate's source state). The source's revisions are watched with their history (a direct get of one revision
// is not what a read grants): a revision heard after the candidate that names it builds that candidate then. Where it
// cannot be read the trunk's keys are built and the candidates wait (issue 1945).
errs := make(chan error, 6)
trunks := &trunkSource{ctx: ctx, js: js, heads: &sourceHeads{}, moved: b.TrunkMoved, errs: errs, logf: logger.Printf}
errs := make(chan error, 10)
trunks := &trunkSource{ctx: ctx, js: js, heads: &sourceHeads{}, moved: func(ctx context.Context) {
// A source revision heard is the forge's merge: it wakes a floor whose bridge_tags was silent (design 51 *The floor*).
(&floorWake{b: b, waits: floorWaits, logf: logger.Printf}).wake(ctx, versions.WakeForge, "", "")
b.TrunkMoved(ctx)
}, errs: errs, logf: logger.Printf}
trunks.start()
b.Trunk = trunks.head
// The builder's log (ADR 0571 §1): each build's start and end in its module log `build-log`, read by the view, the
// indexer and the assigner through their reads, and by the operator's command nox builds through the tool builder_builds_since.
b.Log = func(e delivery.BuildLogEntry) {
if e.Node == "" { // a narration of no one build (a tag ask, the old form's window) is this instance's
e.Node = cred.Node
}
if err := appendLog(context.WithoutCancel(ctx), js, cred, e); err != nil {
logger.Printf("%v", err)
}
// A failed trunk build is the builder's finding build-failed, cleared by a later done one (DLV-B7).
found.buildEnded(context.WithoutCancel(ctx), e)
}
b.Said = func(e build.AskedAgain) {
if err := emitAskedAgain(context.WithoutCancel(ctx), js, cred, e); err != nil {
@@ -170,6 +231,32 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
// placement is settled first, so the toolchains settle through the gate already knowing it.
gate := buildrun.NewToolchainGate([]string{buildrun.GoBuildModule, buildrun.NodeBuildModule}, tools.Put, logger.Printf)
watch.put = gate.Toolchain
// What each counted node's engine reads (ADR 0618 §2, ADR 0624 §1): asked through the node-engine seat's verb as the
// builder's own account, for every node the placement names, and kept in the state engine-reads once it is bound.
reads := newEngineReads(calls.CallOn, gate, logger.Printf)
// The old reference form's window over them (DLV-B9), kept in the state old-form once it is bound; builder_old_form
// reads it and drops a retired node by hand.
reads.ctx = context.WithoutCancel(ctx)
reads.window = &oldForm{forms: forms.Current, findings: found, logf: logger.Printf, now: time.Now, log: func(e delivery.BuildLogEntry) { b.Log(e) },
said: func(body versions.OldFormClosedBody) {
if err := emitEvent(context.WithoutCancel(ctx), js, cred, versions.OldFormClosedEvent, "old-form-closed-"+body.At.Format(time.RFC3339Nano), body); err != nil {
logger.Printf("saying %s: %v", versions.OldFormClosedEvent, err)
}
}}
acts.DropOldForm = func(ctx context.Context, node, by, why string, at time.Time) (string, error) {
answer, err := reads.window.drop(ctx, node, by, why, at)
if err == nil {
reads.stepWindow()
}
return answer, err
}
process.AwaitBucket(ctx, js, conn, versions.OldFormBucket, "the old form's window is kept in memory only, and builder_old_form shows none", logger.Printf).
Ready(func(kv jetstream.KeyValue) {
reads.window.bind(context.WithoutCancel(ctx), kv)
reads.stepWindow()
})
gate.OnPlaced = reads.placed
b.Forms = reads.built
// Until the placement's first view is answered the gate passes every report on, as before the placement is settled.
placementErrs := make(chan error, 1)
go func() {
@@ -212,7 +299,17 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
logger.Printf("the consumer %s_%s on %s does not exist yet, so %s is not heard until the builder starts again: %v",
cred.Node, cred.Module, eventsStream, delivery.ArtifactsGoneEvent, err)
default:
cc, err := process.HearEvents(cons, func(m jetstream.Msg) { heardGone(ctx, b, m, logger.Printf) }, logger.Printf)
// The same consumer carries what wakes a floor (design 51 *The floor*): the indexer's manifest-accepted and the
// condition keeper's condition-cleared.
floor := floorWake{b: b, waits: floorWaits, logf: logger.Printf}
// And the four registry seats' published, refused and removed (DLV-B4), which move a numbered version.
seats := seatHearer{n: numberer, b: b, logf: logger.Printf}
cc, err := process.HearEvents(cons, func(m jetstream.Msg) {
if reads.heard(m, logger.Printf) || floor.heard(ctx, m) || seats.heard(ctx, m) { // reads: the node-engine seat's manifest-reads-changed (ADR 0618 §4)
return
}
heardGone(ctx, b, m, logger.Printf)
}, logger.Printf)
if err != nil {
return err
}
@@ -225,13 +322,38 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
// every other input. The builder asks again by itself without it.
// A state not on the bus yet at this start is watched once it appears (issue 1790).
process.AwaitBucket(ctx, js, conn, build.ReAsksBucket, "builder_ask_again is not heard; an ask that can no longer be answered is still asked again by itself", logger.Printf).Ready(func(kv jetstream.KeyValue) {
eachReAsk := func(e jetstream.KeyValueEntry) { reAsked(ctx, b, kv, e, logger.Printf) }
eachReAsk := func(e jetstream.KeyValueEntry) { reAsked(ctx, b, acts, kv, e, logger.Printf) }
if rw, err := settle(ctx, kv, eachReAsk); err != nil {
process.NotWatched(logger.Printf, build.ReAsksBucket, err, "builder_ask_again is not heard until the builder starts again")
} else {
go func() { errs <- follow(ctx, rw, eachReAsk) }()
}
})
// A tag ask or a branch's removal by hand (builder_tag, builder_untag) is written to tag-asks and answered there
// (design 51 *Tagged versions*); the checker's tag-wanted and the branch's end join it on their consumers (DLV-B6).
tagAsksKV.Ready(func(kv jetstream.KeyValue) {
each := func(e jetstream.KeyValueEntry) {
tagAsked(ctx, acts, kv, e, logger.Printf)
if !opOf(e) {
bounds.disarm(ctx, e.Key())
} else if t, err := versions.DecodeTagAsk(e.Value()); err == nil {
bounds.asked(ctx, e.Key(), t)
}
}
if tw, err := settle(ctx, kv, each); err != nil {
process.NotWatched(logger.Printf, versions.TagAsksBucket, err, "builder_tag and builder_untag are not answered until the builder starts again")
} else {
go func() { errs <- follow(ctx, tw, each) }()
}
})
boundsKV.Ready(func(kv jetstream.KeyValue) {
each := func(e jetstream.KeyValueEntry) { bounds.ended(ctx, e) }
if bw, err := settle(ctx, kv, each); err != nil {
process.NotWatched(logger.Printf, versions.TagAskBoundsBucket, err, "an unanswered tag ask is not narrated until the builder starts again")
} else {
go func() { errs <- follow(ctx, bw, each) }()
}
})
// A person reads a build seat's stream with builder_seat_queue, which writes the queues state (issue 1945 note 62): the
// builder alone may read the stream, so it answers there.
process.AwaitBucket(ctx, js, conn, build.QueuesBucket, "builder_seat_queue is not answered", logger.Printf).Ready(func(kv jetstream.KeyValue) {
@@ -242,6 +364,38 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
go func() { errs <- follow(ctx, qw, eachRead) }()
}
})
// A branch's end removes its tagged versions (design 51 *Tagged versions*, DLV-B6): the forge bridge's merged, a pull's
// closed or branch-deleted on the builder's durable consumer of its stream; each forge message also wakes a floor whose
// bridge_tags was silent. The checker's tag-wanted is kept in tag-asks. A consumer not there yet is said.
ends := &branchEnds{acts: acts, findings: found, tagAsks: tagAsksKV.KV, logf: logger.Printf, now: time.Now,
wake: func(ctx context.Context) {
(&floorWake{b: b, waits: floorWaits, logf: logger.Printf}).wake(ctx, versions.WakeForge, "", "")
}}
for _, c := range []struct {
stream, what string
hear func(jetstream.Msg)
}{
{forgeStream, "a branch's end", func(m jetstream.Msg) { ends.heard(ctx, m) }},
{checkerAsksStream, "the checker's tag-wanted", func(m jetstream.Msg) {
tagWanted(ctx, tagAsksKV.KV(), m, delivery.CheckerModule, time.Now(), logger.Printf)
}},
} {
cons, err := cred.EventConsumer(ctx, js, c.stream)
if err != nil {
logger.Printf("the consumer %s_%s on %s does not exist yet, so %s is not heard until the builder starts again: %v", cred.Node, cred.Module, c.stream, c.what, err)
continue
}
cc, err := process.HearEvents(cons, c.hear, logger.Printf)
if err != nil {
return err
}
defer cc.Stop()
}
// The state engine-reads, new with ADR 0618 (issue 1790's kind): until it is on the bus each statement is kept in memory
// only, and builder_engine_reads answers nothing.
process.AwaitBucket(ctx, js, conn, engineReadsBucket, "what each node's engine reads is kept in memory only", logger.Printf).Ready(func(kv jetstream.KeyValue) {
reads.bind(ctx, kv, errs, logger.Printf)
})
logger.Printf("started on %s", cred.Node)
select {
case <-ctx.Done():
@@ -256,12 +410,9 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
}
}
// The builder's own states of its first views (views.StatusState) and of its findings, where the condition keeper raises a
// first view unanswered past its bound (ADR 0615 §6).
var (
firstViewsBucket = delivery.BuilderModule + "_" + views.StatusState
findingsBucket = delivery.BuilderModule + "_findings"
)
// The builder's own state of its first views (views.StatusState); its findings (findingsBucket) are where the condition
// keeper raises a first view unanswered past its bound (ADR 0615 §6).
var firstViewsBucket = delivery.BuilderModule + "_" + views.StatusState
// The indexer's states the builder follows (delivery.ModulesBucket, ManifestsBucket, CandidatesBucket).
const (
@@ -0,0 +1,149 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"sync"
"time"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health"
"git.novox.be/novox/mesh-delivery/sdk/forms"
"mesh-delivery-builder/internal/versions"
)
// oldForm keeps the old reference form's window (design 51 *Every build keeps the old reference form*, DLV-B9) in the
// state old-form: stepped on every change of what the counted nodes state, of the placement, on a hand drop and at each
// build key written, never on a timer. A node holding it seven days is the finding old-form-held; its close is said once
// as version.old-form-closed and in the builder's log.
type oldForm struct {
forms forms.ArtifactForms
findings *findings
// said emits version.old-form-closed.
said func(versions.OldFormClosedBody)
// log writes the close into the builder's build log (build.said); nil writes none.
log func(delivery.BuildLogEntry)
logf func(string, ...any)
now func() time.Time
mu sync.Mutex
w versions.OldFormWindow
kv jetstream.KeyValue
// overdue are the nodes the finding is raised for now.
overdue []string
}
// bind takes the kept window in, so a restarted builder keeps each node's since and the nodes dropped by hand, and keeps
// every later step in kv.
func (o *oldForm) bind(ctx context.Context, kv jetstream.KeyValue) {
e, err := kv.Get(ctx, versions.OldFormKey)
o.mu.Lock()
defer o.mu.Unlock()
switch {
case errors.Is(err, jetstream.ErrKeyNotFound):
case err != nil:
o.logf("%s.%s cannot be read, so the window starts again: %v", versions.OldFormState, versions.OldFormKey, err)
default:
w, err := versions.DecodeOldFormWindow(e.Value())
if err != nil {
o.logf("%v; the window starts again", err)
break
}
// Taken in under what was stepped before the state was bound: the kept since and drops are the older truth.
for n, s := range o.w.Since {
if _, ok := w.Since[n]; !ok && w.Open && slices.Contains(w.Holding, n) {
w.Since[n] = s
}
}
for _, d := range o.w.Dropped {
w, _ = w.Drop(d.Node, d.By, d.Why, d.At)
}
o.w = w
}
o.kv = kv
o.keep(ctx)
}
// dropped is the nodes dropped by hand.
func (o *oldForm) dropped() map[string]bool {
o.mu.Lock()
defer o.mu.Unlock()
return o.w.DroppedSet()
}
// step moves the window to what counted states.
func (o *oldForm) step(ctx context.Context, counted map[string][]string) {
o.mu.Lock()
defer o.mu.Unlock()
before := o.w
next, closed, overdue := o.w.Step(versions.Holding(o.forms, counted, o.w.DroppedSet()), o.now())
o.w = next
if !slices.Equal(before.Holding, next.Holding) || before.Open != next.Open || closed {
o.keep(ctx)
}
for _, n := range o.overdue {
if !slices.Contains(overdue, n) {
o.findings.clear(ctx, versions.OldFormHeld, health.ScopeMachine, n)
}
}
for _, n := range overdue {
o.findings.raise(ctx, finding{kind: versions.OldFormHeld, scope: health.ScopeMachine, id: n,
summary: fmt.Sprintf("the node-engine of %s has read only an older reference form since %s, so every build key still carries it",
n, next.Since[n].Format(time.RFC3339)),
headline: "A node holds the old reference form",
explanation: "The builder writes an older reference form into every build key while a counted node's engine reads none of the newest (ADR 0624 §1); this node has held it seven days.",
resolved: "The node's engine states the newest form, the node leaves the placement, or it is dropped as retired.",
needs: "builder_old_form shows the window; a retired node is dropped there by hand; builder_engine_reads asks the node again."})
}
o.overdue = overdue
if closed {
body := next.Closing()
line := fmt.Sprintf("the old reference form's window closed at %s: no counted node-engine reads only an older form (last held by %s; dropped by hand: %d); new build keys leave it out",
body.At.Format(time.RFC3339), strings.Join(body.LastHolding, ", "), len(body.Dropped))
o.logf("%s", line)
// In the build log too (design 51 *The node-engine's own build*), under the builder's own key, the window's key
// standing for the fingerprint: the close is no one build's.
if o.log != nil {
o.log(delivery.BuildLogEntry{Event: delivery.BuildSaidEvent, Module: delivery.BuilderModule, FP: versions.OldFormKey,
At: body.At.UTC(), Line: line, Why: versions.SaidOldFormClosed})
}
if o.said != nil {
o.said(body)
}
}
}
// drop is builder_old_form's drop: the node leaves the count, the window is stepped by the caller.
func (o *oldForm) drop(ctx context.Context, node, by, why string, at time.Time) (string, error) {
o.mu.Lock()
defer o.mu.Unlock()
w, err := o.w.Drop(node, by, why, at)
if err != nil {
return "", err
}
o.w = w
o.keep(ctx)
return node + " dropped from the old form's count by hand; the window is stepped now", nil
}
// keep writes the window, once the state is bound; until then it is kept here only. Called with mu held.
func (o *oldForm) keep(ctx context.Context) {
if o.kv == nil {
return
}
raw, err := json.Marshal(o.w)
if err == nil {
_, err = o.kv.Put(ctx, versions.OldFormKey, raw)
}
if err != nil {
o.logf("%s.%s cannot be written: %v", versions.OldFormState, versions.OldFormKey, err)
}
}
@@ -0,0 +1,144 @@
package main
import (
"context"
"slices"
"sync"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-delivery/sdk/enginereads"
)
// EngineReadsState is the builder's state of what each counted node's engine states it reads (novox/hq ADR 0618 §2, ADR
// 0624 §1): one key `<node>`, written from the node-engine seat's verb manifest-reads at the builder's start, on the
// seat's event manifest-reads-changed for that node, and when a person asks again (builder_engine_reads); a node that
// is away keeps its last statement, and its key is removed when the placement no longer names the node. A counted node
// with no key reads as stating the baseline: its first forms. No node's record is read in its place.
const EngineReadsState = "engine-reads"
// engineReadsBucket is that state's bucket.
const engineReadsBucket = delivery.BuilderModule + "_" + EngineReadsState
// engineReads keeps the statements and the counted nodes for the builder's forms (build.Builder.Forms).
type engineReads struct {
keeper *enginereads.Keeper
gate *buildrun.ToolchainGate
mu sync.Mutex
counted []string
// window is the old reference form's window over these statements (DLV-B9); nil keeps none.
window *oldForm
ctx context.Context
}
// newEngineReads asks through call (process.Conn.CallOn) and keeps each answer in the state once it is bound.
func newEngineReads(call enginereads.Caller, gate *buildrun.ToolchainGate, logf func(string, ...any)) *engineReads {
r := &engineReads{gate: gate, keeper: &enginereads.Keeper{Call: call, Logf: logf}}
r.keeper.Changed = r.stepWindow
return r
}
// stepWindow moves the old form's window to what the counted nodes state now (design 51 *Every build keeps the old
// reference form*): on a statement's change, the placement's and a hand drop, and at each build key written.
func (r *engineReads) stepWindow() {
if r.window == nil {
return
}
ctx := r.ctx
if ctx == nil {
ctx = context.Background()
}
_, counted := r.forms()
r.window.step(ctx, counted)
}
// bind keeps the statements in kv and hears it: every node's last statement at the start, a person's ask again and drop
// later. Bound once the state is on the bus (process.AwaitBucket).
func (r *engineReads) bind(ctx context.Context, kv jetstream.KeyValue, errs chan<- error, logf func(string, ...any)) {
r.keeper.Put = func(node string, v []byte) error { _, err := kv.Put(ctx, node, v); return err }
r.keeper.Delete = func(node string) error { return kv.Delete(ctx, node) }
each := func(e jetstream.KeyValueEntry) {
r.keeper.Heard(e.Key(), e.Value(), e.Operation() != jetstream.KeyValuePut)
}
w, err := settle(ctx, kv, each)
if err != nil {
process.NotWatched(logf, engineReadsBucket, err, "builder_engine_reads is not answered until the builder starts again")
return
}
go func() { errs <- follow(ctx, w, each) }()
// Every counted node asked again, so what was asked before the state was bound is kept in it too.
r.mu.Lock()
nodes := slices.Clone(r.counted)
r.mu.Unlock()
for _, node := range nodes {
r.keeper.AskNode(node, "the state engine-reads is bound")
}
}
// placed takes the placement in: a node it names newly is asked, one it no longer names is forgotten, key and all. The
// first call asks every counted node (the builder's start).
func (r *engineReads) placed() {
nodes := r.gate.Nodes()
r.mu.Lock()
before, first := r.counted, r.counted == nil
r.counted = nodes
r.mu.Unlock()
for _, node := range nodes {
if first || !slices.Contains(before, node) {
r.keeper.AskNode(node, "counted: the placement names it")
}
}
for _, node := range before {
if !slices.Contains(nodes, node) {
r.keeper.Forget(node)
}
}
r.stepWindow()
}
// heard takes an event of the builder's consumer on EVENTS: manifest-reads-changed asks its node again, and answers true.
func (r *engineReads) heard(m jetstream.Msg, logf func(string, ...any)) bool {
node, ours, err := enginereads.NodeOf(m.Subject(), m.Data())
if !ours {
return false
}
if ackErr := m.Ack(); ackErr != nil {
logf("acknowledging %s: %v", m.Subject(), ackErr)
}
if err != nil {
logf("%s holds what is not %s, and %s is asked again all the same: %v", m.Subject(), enginereads.Event, node, err)
}
r.keeper.AskNode(node, enginereads.Event)
return true
}
// forms is the counted nodes' statements, and those of the nodes whose engine answered; a retired node dropped by hand
// (builder_old_form) counts no more.
func (r *engineReads) forms() (present, counted map[string][]string) {
r.mu.Lock()
nodes := slices.Clone(r.counted)
r.mu.Unlock()
if r.window != nil {
dropped := r.window.dropped()
nodes = slices.DeleteFunc(nodes, func(n string) bool { return dropped[n] })
}
return r.keeper.Present(nodes), r.keeper.Reads(nodes)
}
// built is build.Builder.Forms: forms, and the window stepped with them, so a node's seven days are seen at the next
// build key at the latest.
func (r *engineReads) built() (present, counted map[string][]string) {
present, counted = r.forms()
if r.window != nil {
ctx := r.ctx
if ctx == nil {
ctx = context.Background()
}
r.window.step(ctx, counted)
}
return present, counted
}
@@ -15,6 +15,7 @@ import (
"github.com/nats-io/nats.go/jetstream"
"mesh-delivery-builder/internal/build"
"mesh-delivery-builder/internal/versions"
)
// askAgainer is the part of the builder a reasks value asks through.
@@ -31,7 +32,7 @@ const buildAgainBound = 15 * time.Second
// reAsked answers one value of the reasks state (the tool builder_ask_again, issue 1945): a value not yet answered asks the
// key's waiting lanes again and is written back, by compare-and-set at its revision, with what the builder did. A value
// answered already (this builder's own write) or deleted is left.
func reAsked(ctx context.Context, b askAgainer, kv jetstream.KeyValue, e jetstream.KeyValueEntry, logf func(string, ...any)) {
func reAsked(ctx context.Context, b askAgainer, acts versions.Acts, kv jetstream.KeyValue, e jetstream.KeyValueEntry, logf func(string, ...any)) {
if e.Operation() != jetstream.KeyValuePut {
return
}
@@ -52,6 +53,11 @@ func reAsked(ctx context.Context, b askAgainer, kv jetstream.KeyValue, e jetstre
announcedAgain(ctx, b, kv, e, r, logf)
return
}
if r.Mode == build.ModePublishAgain || r.Mode == build.ModeFloorAgain || r.Mode == build.ModeOldFormDrop {
// A version's hand act (design 51 *Tools of the builder*) may build again, so it is answered beside the watch.
go versionActed(ctx, acts, kv, e, r, logf)
return
}
asked := b.AskAgain(e.Key(), r.Why)
if len(asked) == 0 {
r.Answer = "no ask of this key waits in this builder now (it is not building, or its build has not reached its ask yet): nothing was asked; builder_stuck shows where it stands"
@@ -72,21 +78,7 @@ func reAsked(ctx context.Context, b askAgainer, kv jetstream.KeyValue, e jetstre
// emitAskedAgain says the builder's event build.asked-again (issue 1945): mesh.mod.<module>.event.build.asked-again,
// with the SDK's envelope headers and the new ask's id as the bus's dedup id.
func emitAskedAgain(ctx context.Context, js jetstream.JetStream, cred process.Account, e build.AskedAgain) error {
data, err := json.Marshal(e)
if err != nil {
return err
}
msg := nats.NewMsg(cred.EventSubject(build.AskedAgainEvent))
id := "asked-again-" + e.New
msg.Header.Set("x-event-id", id)
msg.Header.Set("x-source", cred.Module)
msg.Header.Set("x-node", cred.Node)
msg.Header.Set("x-time", time.Now().UTC().Format(time.RFC3339Nano))
msg.Header.Set("content-type", "application/json")
msg.Data = data
pub, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if _, err := js.PublishMsg(pub, msg, jetstream.WithMsgID(id)); err != nil {
if err := emitEvent(ctx, js, cred, build.AskedAgainEvent, "asked-again-"+e.New, e); err != nil {
return fmt.Errorf("saying %s for %s: %w", build.AskedAgainEvent, e.Key, err)
}
return nil
@@ -163,3 +155,33 @@ func appendLog(ctx context.Context, js jetstream.JetStream, cred process.Account
}
return nil
}
// versionActed answers a reasks value of builder_publish_again, builder_floor_again or builder_old_form's drop, a hand act said in the log.
func versionActed(ctx context.Context, acts versions.Acts, kv jetstream.KeyValue, e jetstream.KeyValueEntry, r build.ReAsk, logf func(string, ...any)) {
var err error
if r.Mode == build.ModePublishAgain {
var ag versions.Again
ag, r.Answer, err = acts.PublishAgain(ctx, r.Name, r.FP, r.Why, r.By, r.At)
r.Do, r.Version = ag.Do, ag.Entry.Version
} else if r.Mode == build.ModeOldFormDrop {
if acts.DropOldForm == nil {
err = errors.New("this builder keeps no old-form window")
} else {
r.Answer, err = acts.DropOldForm(ctx, r.Name, r.By, r.Why, r.At)
}
} else {
r.Answer, err = acts.FloorAgain(ctx, r.Name)
}
if err != nil {
r.Answer, r.Refused = err.Error(), true
}
r.Answered = time.Now().UTC()
logf("%s: %s of %s by %s (%s): %s", e.Key(), r.Mode, r.Name, r.By, r.Why, r.Answer)
data, err := json.Marshal(r)
if err != nil {
return
}
if _, err := kv.Update(ctx, e.Key(), data, e.Revision()); err != nil && !errors.Is(err, jetstream.ErrKeyExists) && !errors.Is(err, jetstream.ErrKeyRevisionMismatch) {
logf("%s.%s: the answer could not be written: %v", build.ReAsksState, e.Key(), err)
}
}
@@ -3,17 +3,29 @@ package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"log"
"maps"
"slices"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/health"
"git.novox.be/novox/mesh-sdk/go/process"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-delivery/sdk/enginereads"
"git.novox.be/novox/mesh-delivery/sdk/forms"
"git.novox.be/novox/mesh-sdk/go/delivery"
"mesh-delivery-builder/internal/build"
"mesh-delivery-builder/internal/versions"
)
// 1945 (a part exits at start for a bucket it works without): a bus that refuses every toolchains bucket leaves the
@@ -21,6 +33,9 @@ import (
// its grant. The gate over the seat's placement, and its refusal, are mesh-sdk's (buildrun.ToolchainGate,
// issue 774).
func TestARefusedBucketIsSaidAndThePartGoesOn(t *testing.T) {
t.Run("a floor's wake is heard on the events consumer (design 51 The floor)", aFloorsWakeIsHeard)
t.Run("a failed trunk build is a finding until a later done one (DLV-B7)", aFailedTrunkBuildIsAFinding)
t.Run("a branch's end is acknowledged once its tagged versions are removed (design 51 Tagged versions)", aBranchsEndIsHeard)
set := &buildrun.ToolchainSet{}
var logs bytes.Buffer
logger := log.New(&logs, "", 0)
@@ -45,6 +60,100 @@ func TestARefusedBucketIsSaidAndThePartGoesOn(t *testing.T) {
if src.opened {
t.Error("a refused source counts as opened, so it is never opened again")
}
t.Run("ADR 0624: the counted nodes' engine reads", theCountedNodesEngineReads)
}
// theCountedNodesEngineReads: the builder asks the node-engine seat's verb of every node the placement names; a node away
// and never asked has no statement and reads as the baseline's first forms, with no node record read in its place; an
// away node keeps its last statement; a node the placement no longer names leaves the count, its key removed; and
// builder_engine_reads drops one node's key.
func theCountedNodesEngineReads(t *testing.T) {
gate := buildrun.NewToolchainGate(nil, func(string, string, []byte, bool) bool { return false }, t.Logf)
place := func(nodes ...string) {
for _, node := range []string{"n1", "n2"} {
pl := delivery.Placement{Node: node}
if slices.Contains(nodes, node) {
pl.Modules = []delivery.PlacedModule{{Module: "web"}}
}
values, err := delivery.PlacementValues(pl)
if err != nil {
t.Fatal(err)
}
for key, raw := range values {
gate.Placement(key, raw, false, time.Now())
}
if !slices.Contains(nodes, node) {
key, _ := delivery.PlacementModuleKey(node, "web")
gate.Placement(key, nil, true, time.Now())
}
}
}
away := map[string]bool{"n2": true}
asked := map[string]int{}
r := newEngineReads(func(node, key string, _ any) (json.RawMessage, error) {
asked[node]++
if away[node] {
return nil, nats.ErrNoResponders
}
return json.Marshal(nodeengine.ManifestReadsAnswer{V: 1, Node: node, ManifestReads: []string{"form:new"}, StatedAt: time.Now().Format(time.RFC3339Nano)})
}, gate, t.Logf)
r.keeper.Sync = true
kept := map[string][]byte{}
r.keeper.Put = func(node string, v []byte) error { kept[node] = v; return nil }
r.keeper.Delete = func(node string) error { delete(kept, node); return nil }
place("n1", "n2")
r.placed()
present, counted := r.forms()
if !slices.Equal(present["n1"], []string{"form:new"}) || len(present) != 1 || len(counted) != 2 || counted["n2"] != nil {
t.Errorf("away and never asked: present %v, counted %v", present, counted)
}
if s := r.keeper.Statements()["n2"]; s.From != enginereads.FromNone || s.Answered || asked["n2"] != 1 {
t.Errorf("n2, away and never asked, is %+v (asked %d)", s, asked["n2"])
}
// n2 back, stating {word:module}, then away again: it keeps that statement.
away["n2"] = false
r.keeper.Heard("n2", mustStatement(t, enginereads.Statement{Node: "n2", From: enginereads.FromVerb, Answered: true,
AskAgain: &enginereads.Again{By: "n1.ada", Why: "back"}}), false)
away["n2"] = true
r.keeper.AskNode("n2", "its event")
if _, counted = r.forms(); !slices.Equal(counted["n2"], []string{"form:new"}) || r.keeper.Statements()["n2"].Answered {
t.Errorf("away again, n2 reads %v (%+v)", counted["n2"], r.keeper.Statements()["n2"])
}
// n2 retired: it leaves the placement, and its key goes.
place("n1")
r.placed()
if _, counted = r.forms(); len(counted) != 1 || kept["n2"] != nil {
t.Errorf("retired n2: counted %v, its key %s", counted, kept["n2"])
}
// builder_engine_reads drops one key.
st := &memReads{v: map[string][]byte{"n1": kept["n1"]}}
tool := enginereads.Tool("builder_engine_reads", "the builder", st, true, time.Now)
if _, err := tool.Run(map[string]any{"node": "n1", "drop": "true", "why": "a test"}); err != nil || st.v["n1"] != nil {
t.Errorf("drop: %v, the key %s", err, st.v["n1"])
}
}
func mustStatement(t *testing.T, s enginereads.Statement) []byte {
t.Helper()
raw, err := enginereads.Encode(s)
if err != nil {
t.Fatal(err)
}
return raw
}
// memReads is the builder's state of engine reads in memory, as the tool reads it.
type memReads struct{ v map[string][]byte }
func (m *memReads) Keys() ([]string, error) { return slices.Collect(maps.Keys(m.v)), nil }
func (m *memReads) Get(key string) ([]byte, uint64, bool, error) {
v, ok := m.v[key]
return v, 1, ok, nil
}
func (m *memReads) Put(key string, value []byte) (uint64, error) { m.v[key] = value; return 2, nil }
func (m *memReads) Delete(key string) error { delete(m.v, key); return nil }
func (m *memReads) Await(string, uint64, func([]byte) bool, time.Duration) ([]byte, error) {
return nil, errors.New("no part answers here")
}
// refusingBus is a bus that refuses: every open (refuseOpen), or every watch of a bucket it opens, with err.
@@ -71,3 +180,229 @@ func (r refusedWatch) WatchAll(context.Context, ...jetstream.WatchOpt) (jetstrea
func (r refusedWatch) Watch(context.Context, string, ...jetstream.WatchOpt) (jetstream.KeyWatcher, error) {
return nil, r.err
}
// wakeMsg is a message of the builder's events consumer: its subject and body, acknowledged.
type wakeMsg struct {
jetstream.Msg
subject string
data []byte
acked bool
naked bool
delayed time.Duration
}
func (m *wakeMsg) Subject() string { return m.subject }
func (m *wakeMsg) Data() []byte { return m.data }
func (m *wakeMsg) Ack() error { m.acked = true; return nil }
func (m *wakeMsg) Nak() error { m.naked = true; return nil }
func (m *wakeMsg) NakWithDelay(d time.Duration) error {
m.delayed = d
return nil
}
func (m *wakeMsg) Metadata() (*jetstream.MsgMetadata, error) {
return nil, errors.New("no metadata")
}
func (m *wakeMsg) Headers() nats.Header {
return nil
}
func aFloorsWakeIsHeard(t *testing.T) {
var logs bytes.Buffer
waits := &versions.FloorWaits{}
waits.Wait("m/lib", versions.SeatGoPackages, "", versions.Floor{Silent: []string{versions.SourceRequirements}}, nil, time.Unix(0, 0))
waits.Wait("m/img", versions.SeatImages, "", versions.Floor{Silent: []string{versions.SourceTags}}, nil, time.Unix(0, 0))
f := &floorWake{b: &build.Builder{}, waits: waits, logf: log.New(&logs, "", 0).Printf}
accepted := &wakeMsg{subject: manifestAcceptedSubject}
if !f.heard(context.Background(), accepted) || !accepted.acked || !strings.Contains(logs.String(), "the floor of m/lib is asked again: manifest-accepted") {
t.Fatalf("manifest-accepted: acked %v, log %q", accepted.acked, logs.String())
}
logs.Reset()
cleared := &wakeMsg{subject: conditionClearedSubject, data: []byte(`{"subject":{"scope":"probe","id":"ace/mesh-delivery-forge-bridge"}}`)}
if !f.heard(context.Background(), cleared) || !strings.Contains(logs.String(), "the floor of m/img is asked again: condition-cleared") {
t.Fatalf("condition-cleared: log %q", logs.String())
}
if f.heard(context.Background(), &wakeMsg{subject: delivery.ArtifactsGoneSubject}) {
t.Fatal("artifacts-gone was taken as a floor's wake")
}
// DLV-B4: a registry seat's outcome on the same consumer moves a version; a published wakes a name whose seat was
// silent; one the state cannot take is left for the bus to give again.
logs.Reset()
waits.Wait("m/pkg", versions.SeatGoPackages, "", versions.Floor{Silent: []string{versions.SourceSeat}}, nil, time.Unix(0, 0))
h := &seatHearer{n: &versions.Numberer{Versions: versions.Versions{Store: noVersions{}}, Waits: waits}, b: &build.Builder{}, logf: log.New(&logs, "", 0).Printf}
byDigest := &wakeMsg{subject: "mesh.seat.go-package-registry.event.published", data: []byte(`{"name":"m/other","digest":"sha256:aa","kind":"package","holder":"gitea"}`)}
if !h.heard(context.Background(), byDigest) || !byDigest.acked || !strings.Contains(logs.String(), "the floor of m/pkg is asked again: go-package-registry said published") {
t.Fatalf("published: acked %v, log %q", byDigest.acked, logs.String())
}
refused := &wakeMsg{subject: "mesh.seat.oci-artifact-registry.event.refused", data: []byte(`{"name":"m/program","version":"0.1.3","digest":"sha256:bb","reason":"bytes differ"}`)}
if !h.heard(context.Background(), refused) || refused.acked || !refused.naked {
t.Fatalf("a refusal the state cannot take: acked %v, naked %v, log %q", refused.acked, refused.naked, logs.String())
}
if h.heard(context.Background(), &wakeMsg{subject: manifestAcceptedSubject}) {
t.Fatal("manifest-accepted was taken as a seat's outcome")
}
}
// noVersions is a state versions that holds nothing.
type noVersions struct{}
func (noVersions) Get(context.Context, string) ([]byte, uint64, bool, error) {
return nil, 0, false, nil
}
func (noVersions) Create(context.Context, string, []byte) (uint64, error) {
return 0, errors.New("read only")
}
func (noVersions) Update(context.Context, string, []byte, uint64) (uint64, error) {
return 0, errors.New("read only")
}
func (noVersions) KeysOf(context.Context, string) ([]string, error) { return nil, nil }
func aBranchsEndIsHeard(t *testing.T) {
for _, c := range []struct {
f forgeEnd
want bool
}{
{forgeEnd{Kind: delivery.ForgeMerged, Repository: "o/r", Branch: "feat/x"}, true},
{forgeEnd{Kind: forgeBranchDeleted, Repository: "o/r", Branch: "feat/x"}, true},
{forgeEnd{Kind: delivery.ForgePull, Repository: "o/r", Branch: "feat/x", Action: delivery.PullClosed}, true},
{forgeEnd{Kind: delivery.ForgePull, Repository: "o/r", Branch: "feat/x", Action: delivery.PullOpened}, false},
{forgeEnd{Kind: delivery.ForgeMerged, Repository: "o/r"}, false},
} {
if got := c.f.ended(); got != c.want {
t.Errorf("%+v ended = %v, want %v", c.f, got, c.want)
}
}
var logs bytes.Buffer
woken := 0
h := &branchEnds{acts: versions.Acts{Numberer: &versions.Numberer{Versions: versions.Versions{Store: noVersions{}}}}, findings: &findings{logf: t.Logf},
tagAsks: func() jetstream.KeyValue { return nil }, wake: func(context.Context) { woken++ }, logf: log.New(&logs, "", 0).Printf, now: time.Now}
open := &wakeMsg{subject: "mesh.mod.mesh-delivery-forge-bridge.event.forge.o.r.pull", data: []byte(`{"kind":"pull","repository":"o/r","branch":"feat/x","action":"opened"}`)}
h.heard(context.Background(), open)
merged := &wakeMsg{subject: "mesh.mod.mesh-delivery-forge-bridge.event.forge.o.r.merged", data: []byte(`{"kind":"merged","repository":"o/r","branch":"feat/x"}`)}
h.heard(context.Background(), merged)
if !open.acked || !merged.acked || woken != 2 {
t.Errorf("an open pull acked %v, a merge with nothing tagged acked %v, floors woken %d times", open.acked, merged.acked, woken)
}
// The checker's family holds its verdicts beside tag-wanted: a verdict is acknowledged and not kept; a tag-wanted with
// tag-asks not bound is heard again.
verdict := &wakeMsg{subject: "mesh.stream.mesh-delivery-checker.event.verdict", data: []byte(`{"repository":"o/r"}`)}
wanted := &wakeMsg{subject: tagWantedSubject, data: []byte(`{"repository":"o/r","branch":"feat/x","head":"abc","tag":"feat-x"}`)}
for _, m := range []*wakeMsg{verdict, wanted} {
tagWanted(context.Background(), nil, m, delivery.CheckerModule, time.Now(), t.Logf)
}
if !verdict.acked || verdict.delayed != 0 || wanted.acked || wanted.delayed != versions.RemoveAgainAfter || !strings.HasPrefix(tagWantedSubject, "mesh.stream.") {
t.Errorf("a verdict acked %v (delayed %v), a tag-wanted acked %v (delayed %v)", verdict.acked, verdict.delayed, wanted.acked, wanted.delayed)
}
unreadable := &wakeMsg{subject: merged.subject, data: merged.data}
h.acts.Numberer.Versions.Store = failingVersions{}
h.heard(context.Background(), unreadable)
if unreadable.acked || unreadable.delayed != versions.RemoveAgainAfter {
t.Errorf("a branch's end the versions cannot answer: acked %v, given again after %v", unreadable.acked, unreadable.delayed)
}
}
// failingVersions is a state versions that cannot be read.
type failingVersions struct{ noVersions }
func (failingVersions) KeysOf(context.Context, string) ([]string, error) {
return nil, errors.New("down")
}
func aFailedTrunkBuildIsAFinding(t *testing.T) {
st := &memFindings{vals: map[string][]byte{}}
f := &findings{store: st, logf: t.Logf, now: func() time.Time { return time.Unix(0, 0) }}
ended := func(outcome, ref string) delivery.BuildLogEntry {
return delivery.BuildLogEntry{Event: delivery.BuildEndedEvent, Module: "m", FP: "f1", Commit: "c", Ref: ref, Outcome: outcome}
}
key := health.FindingKey(health.ScopeModule, "m", BuildFailed)
f.buildEnded(context.Background(), ended(delivery.BuildFailed, "refs/pull/3/head"))
if len(st.vals) != 0 {
t.Fatalf("a candidate's failed build is a finding: %v", st.vals)
}
f.buildEnded(context.Background(), ended(delivery.BuildFailed, ""))
got, err := health.DecodeFinding(st.vals[key])
if err != nil || got.Observer != delivery.BuilderModule || !strings.Contains(got.Summary, "trunk build of m at f1") {
t.Fatalf("the finding = %+v, %v", got, err)
}
f.buildEnded(context.Background(), ended(delivery.BuildDone, ""))
if _, ok := st.vals[key]; ok {
t.Error("a later done trunk build leaves build-failed")
}
// The floor finding: raised while a name waits on its floor, cleared once it is known.
fw := &versions.FloorWaits{Said: f.floorSaid(context.Background())}
fw.Wait("m/lib", versions.SeatGoPackages, "", versions.Floor{Silent: []string{versions.SourceTags}}, nil, time.Unix(0, 0))
floorKey := health.FindingKey(health.ScopeModule, "m/lib", versions.FloorSilent)
if got, err := health.DecodeFinding(st.vals[floorKey]); err != nil || !strings.Contains(got.Summary, "bridge_tags did not answer") {
t.Fatalf("the floor finding = %+v, %v", got, err)
}
fw.Answered("m/lib")
if _, ok := st.vals[floorKey]; ok {
t.Error("a known floor leaves floor-silent")
}
// DLV-B9, the old form's window: one counted node reading only the first form holds it; seven days on it is
// old-form-held about that node; the node stating the newest form closes it, said once, the finding cleared; a
// retired node dropped by hand leaves the count.
now := time.Unix(0, 0)
fs := forms.ArtifactForms{{Name: "registry", First: true, Kinds: []string{"image"}}, {Name: "oci", Kinds: []string{"image"}}}
var closings []versions.OldFormClosedBody
var logged []delivery.BuildLogEntry
window := &oldForm{forms: fs, findings: f, logf: t.Logf, now: func() time.Time { return now },
said: func(b versions.OldFormClosedBody) { closings = append(closings, b) },
log: func(e delivery.BuildLogEntry) { logged = append(logged, e) }}
reads := &engineReads{keeper: &enginereads.Keeper{}, window: window, counted: []string{"a", "b"}}
newest := []string{forms.FormNeed("oci")}
counted := map[string][]string{"a": newest, "b": nil}
window.step(context.Background(), counted)
heldKey := health.FindingKey(health.ScopeMachine, "b", versions.OldFormHeld)
if !window.w.Open || !slices.Equal(window.w.Holding, []string{"b"}) || st.vals[heldKey] != nil {
t.Fatalf("one node lacking the newest form: %+v", window.w)
}
now = now.Add(versions.FindingAfter)
window.step(context.Background(), counted)
if got, err := health.DecodeFinding(st.vals[heldKey]); err != nil || !strings.Contains(got.Summary, "the node-engine of b") {
t.Fatalf("seven days held: the finding = %+v, %v", got, err)
}
counted["b"] = newest
window.step(context.Background(), counted)
window.step(context.Background(), counted)
if window.w.Open || len(closings) != 1 || !slices.Equal(closings[0].LastHolding, []string{"b"}) || st.vals[heldKey] != nil {
t.Errorf("the node back with the newest form: %+v, closings %+v, finding left %v", window.w, closings, st.vals[heldKey] != nil)
}
if len(logged) != 1 || logged[0].Event != delivery.BuildSaidEvent || logged[0].Why != versions.SaidOldFormClosed ||
!strings.Contains(logged[0].Line, "last held by b") {
t.Errorf("the build log = %+v, want the close said once (build.said)", logged)
} else if _, err := delivery.EncodeBuildLogEntry(logged[0]); err != nil {
t.Errorf("the close is no entry the build log takes: %v", err)
}
if _, err := window.drop(context.Background(), "b", "op@shanks", "retired", now); err != nil {
t.Fatal(err)
}
if _, c := reads.forms(); len(c) != 1 || c["b"] != nil {
t.Errorf("a node dropped by hand is still counted: %v", c)
}
}
// memFindings is a findings bucket in memory.
type memFindings struct{ vals map[string][]byte }
type memEntry struct {
jetstream.KeyValueEntry
v []byte
}
func (e memEntry) Value() []byte { return e.v }
func (m *memFindings) Get(_ context.Context, key string) (jetstream.KeyValueEntry, error) {
v, ok := m.vals[key]
if !ok {
return nil, jetstream.ErrKeyNotFound
}
return memEntry{v: v}, nil
}
func (m *memFindings) Put(_ context.Context, key string, v []byte) (uint64, error) {
m.vals[key] = v
return 1, nil
}
func (m *memFindings) Delete(_ context.Context, key string, _ ...jetstream.KVDeleteOpt) error {
delete(m.vals, key)
return nil
}
@@ -0,0 +1,130 @@
package main
import (
"context"
"encoding/json"
"errors"
"strings"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry"
"git.novox.be/novox/mesh-contracts/go/seats/gopackageregistry"
"git.novox.be/novox/mesh-contracts/go/seats/npmpackageregistry"
"git.novox.be/novox/mesh-contracts/go/seats/ociartifactregistry"
"mesh-delivery-builder/internal/build"
"mesh-delivery-builder/internal/versions"
)
// seatEvent is one registry seat's event the builder hears: the seat and the event's name.
type seatEvent struct{ seat, event string }
// seatEvents are the four registry seats' published, refused and removed, by subject (design 51 *How a publish ends*,
// DLV-B4): each a seat event `<seat>.<event>` on the shared events stream, heard on the builder's durable consumer of
// it through the manifest's consumes. The subjects are the seats' contracts' (mesh-contracts).
var seatEvents = map[string]seatEvent{
containerimageregistry.SubjectPublished: {containerimageregistry.Seat, containerimageregistry.EventPublished},
containerimageregistry.SubjectRefused: {containerimageregistry.Seat, containerimageregistry.EventRefused},
containerimageregistry.SubjectRemoved: {containerimageregistry.Seat, containerimageregistry.EventRemoved},
ociartifactregistry.SubjectPublished: {ociartifactregistry.Seat, ociartifactregistry.EventPublished},
ociartifactregistry.SubjectRefused: {ociartifactregistry.Seat, ociartifactregistry.EventRefused},
ociartifactregistry.SubjectRemoved: {ociartifactregistry.Seat, ociartifactregistry.EventRemoved},
gopackageregistry.SubjectPublished: {gopackageregistry.Seat, gopackageregistry.EventPublished},
gopackageregistry.SubjectRefused: {gopackageregistry.Seat, gopackageregistry.EventRefused},
gopackageregistry.SubjectRemoved: {gopackageregistry.Seat, gopackageregistry.EventRemoved},
npmpackageregistry.SubjectPublished: {npmpackageregistry.Seat, npmpackageregistry.EventPublished},
npmpackageregistry.SubjectRefused: {npmpackageregistry.Seat, npmpackageregistry.EventRefused},
npmpackageregistry.SubjectRemoved: {npmpackageregistry.Seat, npmpackageregistry.EventRemoved},
}
// seatHearer takes the registry seats' outcomes in: each moves a numbered version (versions.Numberer.Heard), and a
// `published` that wakes a name waiting on its floor looks at every module again.
type seatHearer struct {
n *versions.Numberer
b *build.Builder
logf func(string, ...any)
}
// heard takes a message of the events consumer in when it is a registry seat's outcome, and answers whether it was.
// One the state could not take is left unacknowledged (Nak), so the bus gives it again; one that is no outcome is said
// and dropped.
func (h *seatHearer) heard(ctx context.Context, m jetstream.Msg) bool {
se, ok := seatEvents[m.Subject()]
if !ok {
return false
}
var o versions.Outcome
if err := json.Unmarshal(m.Data(), &o); err != nil || o.Name == "" {
h.logf("%s holds what is no outcome of %s, left: %v", m.Subject(), se.seat, err)
h.ack(m)
return true
}
id := ""
if m.Headers() != nil {
id = m.Headers().Get("x-event-id")
}
wake, err := h.n.Heard(ctx, se.seat, se.event, id, o)
if err != nil {
h.logf("%v; heard again", err)
if nakErr := m.Nak(); nakErr != nil {
h.logf("leaving %s for the bus to give again: %v", m.Subject(), nakErr)
}
return true
}
h.ack(m)
if len(wake) > 0 {
h.logf("the floor of %s is asked again: %s said %s", strings.Join(wake, ", "), se.seat, se.event)
h.b.Reconsider(ctx)
}
return true
}
func (h *seatHearer) ack(m jetstream.Msg) {
if err := m.Ack(); err != nil {
h.logf("acknowledging %s: %v", m.Subject(), err)
}
}
// kvKeeper is the state floor-waits as versions.FloorKeeper.
type kvKeeper struct {
ctx context.Context
kv jetstream.KeyValue
}
func (k kvKeeper) Put(key string, value []byte) error {
_, err := k.kv.Put(k.ctx, key, value)
return err
}
func (k kvKeeper) Delete(key string) error {
err := k.kv.Delete(k.ctx, key)
if errors.Is(err, jetstream.ErrKeyNotFound) {
return nil
}
return err
}
// heldFloorWaits are the waits floor-waits holds at the builder's start; a value that is no wait is said and left.
func heldFloorWaits(ctx context.Context, kv jetstream.KeyValue, logf func(string, ...any)) []versions.FloorWait {
l, err := kv.ListKeys(ctx)
if err != nil {
logf("%s cannot be listed, so a restart's waits are asked at their next claim: %v", versions.FloorWaitsState, err)
return nil
}
defer func() { _ = l.Stop() }()
var out []versions.FloorWait
for k := range l.Keys() {
e, err := kv.Get(ctx, k)
if err != nil {
continue
}
w, err := versions.DecodeFloorWait(e.Value())
if err != nil {
logf("%s.%s holds what is no floor wait, left: %v", versions.FloorWaitsState, k, err)
continue
}
out = append(out, w)
}
return out
}
@@ -0,0 +1,132 @@
package main
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/delivery"
"mesh-delivery-builder/internal/versions"
)
// tagBounds narrates a tag ask the builder has not answered within five minutes, with what it waits on (design 51
// *Tagged versions*): each unanswered ask has a key in the state tag-ask-bounds written with its own time to live, and
// the bus's marker of its end, heard on the watch, is what narrates it and writes the bound again. No timer.
type tagBounds struct {
asks func() jetstream.KeyValue
bounds func() jetstream.KeyValue
waits func() []versions.FloorWait
logf func(string, ...any)
// log writes a narration into the builder's build log (build.said); nil writes none.
log func(delivery.BuildLogEntry)
now func() time.Time
bound time.Duration
}
// arm writes key's bound when none stands: a Create, so a standing bound keeps its time and a restart arms each
// unanswered ask whose bound ended while the builder was away.
func (tb *tagBounds) arm(ctx context.Context, key string) {
kv := tb.bounds()
if kv == nil {
return
}
if _, err := kv.Create(ctx, key, []byte(`{}`), jetstream.KeyTTL(tb.bound)); err != nil && !errors.Is(err, jetstream.ErrKeyExists) {
tb.logf("the bound of the tag ask %s is not written, so it is not narrated past five minutes: %v", key, err)
}
}
// disarm removes key's bound: the ask was answered or ended.
func (tb *tagBounds) disarm(ctx context.Context, key string) {
if kv := tb.bounds(); kv != nil {
_ = kv.Delete(ctx, key)
}
}
// asked is told each value of tag-asks: an unanswered ask is armed, any other disarmed.
func (tb *tagBounds) asked(ctx context.Context, key string, t versions.TagAsk) {
if t.Unanswered() {
tb.arm(ctx, key)
return
}
tb.disarm(ctx, key)
}
// ended is told each change of tag-ask-bounds: a bound whose time ended (the bus's marker, a purge on the watch) on an
// ask still unanswered narrates it and arms it again.
func (tb *tagBounds) ended(ctx context.Context, e jetstream.KeyValueEntry) {
if e.Operation() != jetstream.KeyValuePurge {
return
}
kv := tb.asks()
if kv == nil {
return
}
got, err := kv.Get(ctx, e.Key())
if err != nil {
return // the ask ended with its branch
}
t, err := versions.DecodeTagAsk(got.Value())
if err != nil || !t.Unanswered() {
return
}
var waits []versions.FloorWait
if tb.waits != nil {
waits = tb.waits()
}
line := versions.NarrateUnanswered(t, waits, tb.now())
tb.logf("%s", line)
// In the build log too (design 51 *Tagged versions*): under the builder's own key, no build being claimed yet, the
// ask's key standing for the fingerprint.
if tb.log != nil {
tb.log(delivery.BuildLogEntry{Event: delivery.BuildSaidEvent, Module: delivery.BuilderModule, FP: e.Key(), Commit: t.Head,
At: tb.now().UTC(), Line: line, Why: versions.SaidUnanswered})
}
tb.arm(ctx, e.Key())
}
// tagNumbered marks the tag ask of a check's build's head numbered with its versions in tag-asks, by compare-and-set.
func tagNumbered(kv func() jetstream.KeyValue, logf func(string, ...any), now func() time.Time) func(context.Context, versions.BuildClaim, versions.Numbering) {
return func(ctx context.Context, c versions.BuildClaim, got versions.Numbering) {
store := kv()
if store == nil {
return
}
lister, err := store.ListKeysFiltered(ctx, versions.EscapeToken(c.Repository)+".>")
if err != nil {
logf("the tag ask of %s at %s is not marked numbered: %v", c.Repository, c.Head, err)
return
}
for key := range lister.Keys() {
for range 3 {
e, err := store.Get(ctx, key)
if err != nil {
break
}
t, err := versions.DecodeTagAsk(e.Value())
if err != nil {
break
}
t, changed := t.WithNumbered(c.Module, c.Head, got.Versions, got.Names, now())
if !changed {
break
}
data, err := json.Marshal(t)
if err != nil {
break
}
if _, err = store.Update(ctx, key, data, e.Revision()); err == nil {
logf("%s: tag ask of %s %s: %s", key, t.Repository, t.Branch, t.Answer)
break
}
if !errors.Is(err, jetstream.ErrKeyExists) && !errors.Is(err, jetstream.ErrKeyRevisionMismatch) {
logf("the tag ask %s is not marked numbered: %v", key, err)
break
}
}
}
}
}
@@ -0,0 +1,328 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health"
"git.novox.be/novox/mesh-sdk/go/process"
"mesh-delivery-builder/internal/build"
"mesh-delivery-builder/internal/versions"
)
// The floor's two module tools (novox/hq design 51 *The floor*); the seat's is `seat:<registry>.versions`.
const (
bridgeTagsTool = "mesh-delivery-forge-bridge.bridge_tags"
requirementsTool = "mesh-delivery-indexer.indexer_requirements"
)
// awaitedVersions is the state versions as versions.Store: bound once the bus holds it (the grants part makes it, issue
// 1790); until then every use is an error, so a trunk build is built unnumbered and said (build.Builder.number).
type awaitedVersions struct{ b *process.AwaitedBucket }
var errVersionsMissing = errors.New("the state " + versions.Bucket + " is not on the bus yet")
func (s awaitedVersions) kv() (jetstream.KeyValue, error) {
if kv := s.b.KV(); kv != nil {
return kv, nil
}
return nil, errVersionsMissing
}
func (s awaitedVersions) Get(ctx context.Context, key string) ([]byte, uint64, bool, error) {
kv, err := s.kv()
if err != nil {
return nil, 0, false, err
}
e, err := kv.Get(ctx, key)
if errors.Is(err, jetstream.ErrKeyNotFound) {
return nil, 0, false, nil
}
if err != nil {
return nil, 0, false, err
}
return e.Value(), e.Revision(), true, nil
}
// indexerPackages is the indexer's packages state (delivery.PackagesBucket, the manifest's reads grant) as the builder
// reads a package's name in its ecosystem from it (design 32): bound once the bus holds it; until then a package is
// built unnumbered and said (versions.Numberer.Names).
type indexerPackages struct{ b *process.AwaitedBucket }
var errPackagesMissing = errors.New("the indexer's state " + delivery.PackagesBucket + " is not on the bus yet")
func (s indexerPackages) Keys(ctx context.Context) ([]string, error) {
kv := s.b.KV()
if kv == nil {
return nil, errPackagesMissing
}
keys, err := kv.Keys(ctx)
if errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, nil
}
return keys, err
}
func (s indexerPackages) Get(ctx context.Context, key string) ([]byte, bool, error) {
kv := s.b.KV()
if kv == nil {
return nil, false, errPackagesMissing
}
e, err := kv.Get(ctx, key)
if errors.Is(err, jetstream.ErrKeyNotFound) || errors.Is(err, jetstream.ErrKeyDeleted) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
return e.Value(), true, nil
}
func versionsConflict(err error) error {
if errors.Is(err, jetstream.ErrKeyExists) || errors.Is(err, jetstream.ErrKeyRevisionMismatch) {
return versions.ErrConflict
}
return err
}
func (s awaitedVersions) Create(ctx context.Context, key string, value []byte) (uint64, error) {
kv, err := s.kv()
if err != nil {
return 0, err
}
rev, err := kv.Create(ctx, key, value)
return rev, versionsConflict(err)
}
func (s awaitedVersions) Update(ctx context.Context, key string, value []byte, revision uint64) (uint64, error) {
kv, err := s.kv()
if err != nil {
return 0, err
}
rev, err := kv.Update(ctx, key, value, revision)
return rev, versionsConflict(err)
}
func (s awaitedVersions) KeysOf(ctx context.Context, filter string) ([]string, error) {
kv, err := s.kv()
if err != nil {
return nil, err
}
l, err := kv.ListKeysFiltered(ctx, filter)
if err != nil {
return nil, err
}
defer func() { _ = l.Stop() }()
var out []string
for k := range l.Keys() {
out = append(out, k)
}
return out, nil
}
// floorVia is the floor's three sources as calls of the builder's own account (design 51 *The floor*): the seat's verb
// versions, the forge bridge's bridge_tags and the indexer's indexer_requirements. A call that fails is that source
// silent.
func floorVia(call toolCaller) versions.FloorSources {
return versions.FloorSources{
Seat: func(_ context.Context, seat, name string) ([]string, error) {
raw, err := call("seat:"+seat+".versions", map[string]any{"name": name})
if err != nil {
return nil, err
}
var a struct {
Versions []struct {
Version string `json:"version"`
} `json:"versions"`
}
if err := json.Unmarshal(raw, &a); err != nil {
return nil, fmt.Errorf("%s's versions answered what is no list of versions: %w", seat, err)
}
var out []string
for _, v := range a.Versions {
if v.Version != "" {
out = append(out, v.Version)
}
}
return out, nil
},
Tags: func(_ context.Context, repository, prefix string) ([]string, error) {
raw, err := call(bridgeTagsTool, map[string]any{"repository": repository, "prefix": prefix})
if err != nil {
return nil, err
}
var a struct {
Tags []string `json:"tags"`
}
if err := json.Unmarshal(raw, &a); err != nil {
return nil, fmt.Errorf("bridge_tags answered what is no list of tags: %w", err)
}
return a.Tags, nil
},
Requirements: func(_ context.Context, name string) ([]string, error) {
raw, err := call(requirementsTool, map[string]any{"package": name})
if err != nil {
return nil, err
}
var a struct {
Requirements []string `json:"requirements"`
}
if err := json.Unmarshal(raw, &a); err != nil {
return nil, fmt.Errorf("indexer_requirements answered what is no list of versions: %w", err)
}
return a.Requirements, nil
},
}
}
// emitNumbered says the builder's event version.numbered (design 51 *Events of the builder*), on its own family apart
// from its builds' ends; the name and version are the bus's dedup id.
func emitNumbered(ctx context.Context, js jetstream.JetStream, cred process.Account, b versions.NumberedBody) error {
id := "numbered-" + versions.Key(b.Name, b.Version)
if b.Again != "" {
id += "-again-" + b.Again
}
if err := emitEvent(ctx, js, cred, versions.NumberedEvent, id, b); err != nil {
return fmt.Errorf("saying %s for %s@%s: %w", versions.NumberedEvent, b.Name, b.Version, err)
}
return nil
}
// emitEvent says one of the builder's events, mesh.mod.<module>.event.<event>, with the SDK's envelope headers and id as
// the bus's dedup id.
func emitEvent(ctx context.Context, js jetstream.JetStream, cred process.Account, event, id string, body any) error {
data, err := json.Marshal(body)
if err != nil {
return err
}
msg := nats.NewMsg(cred.EventSubject(event))
msg.Header.Set("x-event-id", id)
msg.Header.Set("x-source", cred.Module)
msg.Header.Set("x-node", cred.Node)
msg.Header.Set("x-time", time.Now().UTC().Format(time.RFC3339Nano))
msg.Header.Set("content-type", "application/json")
msg.Data = data
pub, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
_, err = js.PublishMsg(pub, msg, jetstream.WithMsgID(id))
return err
}
// The events on the builder's consumer of EVENTS that wake a floor (design 51 *The floor*, DLV-B3b).
var (
manifestAcceptedSubject = "mesh.mod." + delivery.IndexerModule + ".event." + delivery.ManifestAcceptedEvent
conditionClearedSubject = "mesh.mod." + health.KeeperModule + ".event." + health.EventCleared
)
// floorWake asks the floor of the names an event wakes again, by looking at every module again: a trunk build is
// numbered before its claim (build.Builder.number).
type floorWake struct {
b *build.Builder
waits *versions.FloorWaits
logf func(string, ...any)
}
// heard takes a message of the consumer in when it is one that wakes a floor, acknowledged, and answers whether it was.
func (f *floorWake) heard(ctx context.Context, m jetstream.Msg) bool {
switch m.Subject() {
case manifestAcceptedSubject:
f.ack(m)
f.wake(ctx, versions.WakeAccepted, "", "")
case conditionClearedSubject:
f.ack(m)
var e health.Event
if err := json.Unmarshal(m.Data(), &e); err != nil {
f.logf("%s holds what is no condition, left: %v", m.Subject(), err)
return true
}
for _, module := range conditionModules(e.Subject.ID) {
f.wake(ctx, versions.WakeCleared, "", module)
}
default:
return false
}
return true
}
func (f *floorWake) ack(m jetstream.Msg) {
if err := m.Ack(); err != nil {
f.logf("acknowledging %s: %v", m.Subject(), err)
}
}
// wake looks at every module again when the event wakes a name, and says which.
func (f *floorWake) wake(ctx context.Context, kind, seat, module string) {
names := f.waits.Wake(kind, seat, module, "")
if len(names) == 0 {
return
}
f.logf("the floor of %s is asked again: %s", strings.Join(names, ", "), kind)
f.b.Reconsider(ctx)
}
// conditionModules are the tokens of a condition's subject id (`<node>/<module>`, `<module>.<check>`): a module named
// among them is the one whose condition cleared.
func conditionModules(id string) []string {
return strings.FieldsFunc(id, func(r rune) bool { return r == '/' || r == '.' || r == ':' })
}
// doneVia says whether a build key is done, read from the builder's own builds state: the build's file is held.
func doneVia(b *build.Builder) func(ctx context.Context, module, fp string) (bool, error) {
return func(ctx context.Context, module, fp string) (bool, error) {
key, err := delivery.BuilderBuildKey(module, fp)
if err != nil {
return false, err
}
raw, _, found, err := b.Claimer.Store.Get(ctx, key)
if err != nil || !found {
return false, err
}
cur, err := delivery.DecodeBuilderBuild(raw)
if err != nil {
return false, err
}
return cur.State == delivery.BuildDone, nil
}
}
// removeVia calls a registry seat's remove for one tagged version, as the builder's own account (design 51 *Tagged
// versions*): the reason tag-gone; the seat says removed on its events.
func removeVia(call toolCaller) func(ctx context.Context, seat string, e versions.Entry) error {
return func(_ context.Context, seat string, e versions.Entry) error {
_, err := call("seat:"+seat+".remove", map[string]any{"name": e.Name, "version": e.Version, "tag": e.Tag, "reason": "tag-gone"})
return err
}
}
// tagAsked answers an unanswered value of tag-asks by compare-and-set at its revision, said in the log.
func tagAsked(ctx context.Context, acts versions.Acts, kv jetstream.KeyValue, e jetstream.KeyValueEntry, logf func(string, ...any)) {
if e.Operation() != jetstream.KeyValuePut {
return
}
t, err := versions.DecodeTagAsk(e.Value())
if err != nil {
logf("%s.%s holds what is not a tag ask, left: %v", versions.TagAsksState, e.Key(), err)
return
}
if !t.Answered.IsZero() {
return
}
t = acts.TagAsked(ctx, t, time.Now())
logf("%s: %s of %s %s by %s (%s): %s", e.Key(), map[bool]string{true: "untag", false: "tag ask"}[t.Untag], t.Repository, t.Branch, t.By, t.Why, t.Answer)
data, err := json.Marshal(t)
if err != nil {
return
}
if _, err := kv.Update(ctx, e.Key(), data, e.Revision()); err != nil && !errors.Is(err, jetstream.ErrKeyExists) && !errors.Is(err, jetstream.ErrKeyRevisionMismatch) {
logf("%s.%s: the answer could not be written: %v", versions.TagAsksState, e.Key(), err)
}
}
+1
View File
@@ -11,6 +11,7 @@ require (
require git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261102193506-d221c633799a
require (
git.novox.be/novox/mesh-contracts/go v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun v0.0.0-00010101000000-000000000000
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
github.com/google/go-tpm v0.9.8 // indirect
-2
View File
@@ -1,7 +1,5 @@
git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261102193506-d221c633799a h1:oxgksCiq272/afXbUION/dxtAS5MPGnmyq6+AZQxmXg=
git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261102193506-d221c633799a/go.mod h1:kpsX4Pgss5EeuBvel1zWa2ogj7MkR3jZso6oa/J0YUQ=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
@@ -114,12 +114,11 @@ func TestClaimCreatesTheKeyBuildingAndASecondBuilderGetsNothing(t *testing.T) {
if second, err := b.Claim(context.Background(), "m", fp1, hex64('a'), ClaimOptions{}); err != nil || second != nil {
t.Errorf("second builder's claim = %v, %v, want none: the same source is never built twice", second, err)
}
}
func TestClaimLosingTheCreateRaceGetsNothing(t *testing.T) { // 1607/AC3
st := newMem()
// A claim losing the create race gets nothing (merged into this test for the test budget, design 51).
st = newMem()
st.before = func() { seed(t, st, delivery.BuilderBuild{State: delivery.BuildBuilding, By: "novox.b"}) }
got, err := Claimer{Store: st, By: "novox.a"}.Claim(context.Background(), "m", fp1, "", ClaimOptions{})
got, err = Claimer{Store: st, By: "novox.a"}.Claim(context.Background(), "m", fp1, "", ClaimOptions{})
if err != nil || got != nil {
t.Errorf("claim = %v, %v, want none: the create failed because the key exists", got, err)
}
@@ -197,12 +196,11 @@ func TestFinishMovesTheKeyByCompareAndSetAndWritesOnlyADifferentValue(t *testing
if st.writes != writes {
t.Errorf("the same value was written again (%d writes, was %d)", st.writes, writes)
}
}
func TestFinishAfterTheKeyChangedIsAConflictNotAnOverwrite(t *testing.T) { // 1607/AC3
st := newMem()
c := Claimer{Store: st, By: "novox.a"}
got, _ := c.Claim(context.Background(), "m", fp1, "", ClaimOptions{})
// Finish after the key changed is a conflict, not an overwrite (merged into this test for the test budget).
st = newMem()
c = Claimer{Store: st, By: "novox.a"}
got, _ = c.Claim(context.Background(), "m", fp1, "", ClaimOptions{})
st.put(got.Key, []byte(`{"v":1,"state":"failed","error":"someone else"}`))
err := c.Finish(context.Background(), got, delivery.BuilderBuild{State: delivery.BuildFailed, Error: "mine"})
if !errors.Is(err, ErrConflict) {
@@ -0,0 +1,95 @@
package build
import (
"context"
"fmt"
"strconv"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"mesh-delivery-builder/internal/versions"
)
// Numbers numbers a trunk build at its claim (novox/hq design 51 *The versions*, ADR 0606, ADR 0617 §1) and records the
// digests it was built with. Nil numbers nothing.
type Numbers interface {
// tag is a check's build's tag (TagOf), empty for a trunk build.
Number(ctx context.Context, module string, m delivery.Module, plan Plan, tag string) (versions.Numbering, error)
Built(ctx context.Context, module string, m delivery.Module, plan Plan, got versions.Numbering, made []delivery.BuiltArtifact) error
}
// VersionNumbers is Numbers over the builder's state versions.
type VersionNumbers struct {
N *versions.Numberer
// TagNumbered is told the versions a check's build of a tag ask's head was numbered (the ask is then marked numbered
// in tag-asks, design 51 *Tagged versions*); nil tells nobody.
TagNumbered func(ctx context.Context, c versions.BuildClaim, got versions.Numbering)
}
// ClaimOf is the claim a module value's trunk build is numbered by: a package per source revision (Src), the rest per
// fingerprint, in merge order (Src).
func ClaimOf(module string, m delivery.Module, plan Plan) versions.BuildClaim {
c := versions.BuildClaim{Module: module, Path: m.Path, Repository: delivery.RepositoryName(m.Repository), Fingerprint: m.FP2,
Commit: m.Commit, Merge: m.Src}
if m.Src != 0 {
c.Revision = strconv.FormatUint(m.Src, 10)
}
for _, t := range plan.Tasks {
c.Artifacts = append(c.Artifacts, versions.ClaimArtifact{Artifact: t.Artifact, Kind: t.Kind, Language: t.Language})
}
return c
}
func (v VersionNumbers) Number(ctx context.Context, module string, m delivery.Module, plan Plan, tag string) (versions.Numbering, error) {
c := ClaimOf(module, m, plan)
if tag != "" {
// A check's build of a head a tag ask names: its packages tagged, below the next version (design 51 *Tagged versions*).
c.Tag, c.Head, c.Revision, c.Merge = tag, m.Commit, "", 0
}
got, err := v.N.Number(ctx, c)
if err == nil && tag != "" && len(got.Versions) > 0 && v.TagNumbered != nil {
v.TagNumbered(ctx, c, got)
}
return got, err
}
func (v VersionNumbers) Built(ctx context.Context, module string, m delivery.Module, plan Plan, got versions.Numbering, made []delivery.BuiltArtifact) error {
digests := map[string]string{}
for _, a := range made {
digests[a.Name] = a.Digest
}
return v.N.Built(ctx, ClaimOf(module, m, plan), got, digests)
}
// TagOf answers the cleaned tag a kept tag ask names for a repository's head (the builder's state tag-asks, design 51
// *Tagged versions*): a check's build of that head numbers its packages tagged at its claim.
type TagOf func(ctx context.Context, repository, head string) (tag string, ok bool)
// number numbers a build before its claim, a trunk build's artifacts or a check's build's packages under its tag: false
// when it waits for its floor (a package, design 51 *The floor*), and then nothing is claimed; the floor's wake looks at
// the module again.
func (b *Builder) number(ctx context.Context, module string, m delivery.Module, plan Plan, tag string) (versions.Numbering, bool) {
if b.Numbers == nil {
return versions.Numbering{}, true
}
got, err := b.Numbers.Number(ctx, module, m, plan, tag)
for _, s := range got.Said {
b.logf("%s at %s: %s", module, m.FP2, s.Line)
// And in the build log (design 51 *The versions*: it says a version was kept, and why), between the build's start and end.
if b.Log != nil {
b.Log(delivery.BuildLogEntry{Event: delivery.BuildSaidEvent, Module: module, FP: m.FP2, Commit: m.Commit, Node: b.Node,
At: time.Now().UTC(), Line: s.Line, Why: s.Why})
}
}
if err != nil {
// Said, and built as before: an unnumbered build is published by its digest (ADR 0617 §1), numbered at its next look.
b.logf("%s at %s: %v", module, m.FP2, fmt.Errorf("not numbered, the versions cannot be used: %w", err))
return versions.Numbering{}, true
}
if got.Wait != "" {
b.logf("%s at %s is not built yet: %s", module, m.FP2, got.Wait)
return got, false
}
return got, true
}
@@ -14,9 +14,12 @@ import (
"time"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-delivery/sdk/forms"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"mesh-delivery-builder/internal/versions"
)
// ToolchainEntries are the nodes' toolchain entries of a language (buildrun.ToolchainSet).
@@ -79,6 +82,16 @@ type Builder struct {
// time.Now. Node is the node the builder runs on, which asked the build.
Now func() time.Time
Node string
// Numbers numbers each trunk build at its claim and records what it was built with (design 51 *The versions*); nil
// numbers nothing.
Numbers Numbers
// Tags answers a check's build's tag from the kept tag asks; nil numbers no check's build.
Tags TagOf
// Forms answers what each node's engine states it reads (ADR 0618 §2, ADR 0624 §1): present, the nodes whose engine
// answered when last asked, and counted, every node the placement names, present or away, a node with no statement
// stating nothing (the baseline's first forms). A done key names each artifact in the forms these keep, the oldest in
// its single reference, and its needs. Nil: no node states anything, and the first forms are kept.
Forms func() (present, counted map[string][]string)
once sync.Once
sem chan struct{}
@@ -432,6 +445,17 @@ func (b *Builder) try(ctx context.Context, module string, m delivery.Module) {
first := !b.tried[key]
b.tried[key] = true
b.mu.Unlock()
var numbering versions.Numbering
tag := ""
if !trunk && b.Tags != nil && b.refOf(module, m.FP2) != "" {
tag, _ = b.Tags(ctx, delivery.RepositoryName(m.Repository), m.Commit)
}
if (trunk || tag != "") && planErr == nil && !nothing {
var goOn bool
if numbering, goOn = b.number(ctx, module, m, plan, tag); !goOn {
return
}
}
cl, err := b.Claimer.Claim(ctx, module, m.FP2, m.Manifest, ClaimOptions{TakeOver: first, ImageBuilder: imageSeat, Trunk: trunk, Published: b.isPublished,
Stored: b.storedOnce(key)})
if err != nil {
@@ -495,6 +519,11 @@ func (b *Builder) try(ctx context.Context, module string, m delivery.Module) {
ended := LogEnded(module, m, b.refOf(module, m.FP2), b.Node, b.now(), result)
ended.OutOfStep = b.takeOutOfStep(key) // the generated files the build module's compare found out of step (design 26)
b.logBuild(ended)
if result.State == delivery.BuildDone && b.Numbers != nil && len(numbering.Versions) > 0 {
if err := b.Numbers.Built(ctx, module, m, plan, numbering, result.Artifacts); err != nil {
b.logf("%s: %v", cl.Key, err)
}
}
if result.State == delivery.BuildFailed {
b.logf("%s: failed: %s", cl.Key, result.Error)
}
@@ -614,6 +643,10 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p
return failed("%s", keyError(errs))
}
var artifacts []delivery.BuiltArtifact
var present, counted map[string][]string
if b.Forms != nil {
present, counted = b.Forms()
}
keptModule := map[string]bool{}
for _, bm := range kept {
keptModule[bm] = true
@@ -642,6 +675,7 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p
return failed("%s", err)
}
built := delivery.BuiltArtifact{Name: t.Artifact, Kind: t.Kind, Digest: d}
var answered []string
for _, l := range lanes {
if l.module != t.Module {
continue
@@ -653,10 +687,11 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p
return failed("%s%s on %s: the build answered no reference, so its push to the artifact store was not answered", delivery.NotStored, t.Artifact, l.node)
}
if r != "" {
built.Reference = r
answered = append(answered, r)
}
}
artifacts = append(artifacts, built)
// The forms a counted node's engine reads are kept, the oldest in the single reference (ADR 0624 §1, §3).
artifacts = append(artifacts, forms.Current.Choose(built, answered, present, counted))
}
var steps []string
for _, l := range lanes {
@@ -678,7 +713,7 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p
used = nil
}
done := delivery.BuilderBuild{State: delivery.BuildDone, By: b.Claimer.By, Manifest: m.Manifest, Step: step, Artifacts: artifacts, Toolchains: used,
Imports: importsOf(lanes)}
Imports: importsOf(lanes), Needs: forms.Current.Needs(artifacts)} // the forms it is written in (ADR 0618 §1)
if _, err := delivery.EncodeBuilderBuild(done); err != nil {
// A reference the build module answered that does not name the artifact and its digest is no reference.
return failed("%s%v", delivery.NotStored, err)
@@ -773,7 +808,7 @@ func (n *askNotes) note(ctx context.Context, language, id, step string) error {
n.mu.Lock()
defer n.mu.Unlock()
v := n.cl.Build
v.State, v.By, v.Step, v.Error, v.Artifacts = delivery.BuildBuilding, n.b.Claimer.By, step, "", nil
v.State, v.By, v.Step, v.Error, v.Artifacts, v.Needs = delivery.BuildBuilding, n.b.Claimer.By, step, "", nil, nil
v.Asks = maps.Clone(v.Asks)
if v.Asks == nil {
v.Asks = map[string]string{}
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"reflect"
"slices"
"strconv"
"strings"
"sync"
@@ -14,9 +15,12 @@ import (
"time"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-delivery/sdk/forms"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"mesh-delivery-builder/internal/versions"
)
// buildModules simulates the two build modules on the nodes: a long tool answers a step and key at once and writes the
@@ -223,6 +227,7 @@ var (
func TestABundleAndAnArchiveAreBuiltOnOneNodeAndDone(t *testing.T) { // 1607/AC7, ADR 0478 clauses 1, 4
t.Run("the imports a build answers reach the done key and the log", importsABuildAnswersReachTheDoneKeyAndTheLog)
t.Run("the out-of-step files a build answers reach the log", outOfStepFilesABuildAnswersReachTheLog)
t.Run("ADR 0624: a done key keeps each form a counted node reads", aDoneKeyKeepsEachFormACountedNodeReads)
r := newRig(t, goBundle, archiveA, tsBundle)
// ADR 0584: a third node agrees with novox and ace's typescript is the odd one; the build is done and its step says ace sits out.
r.tools.Put(buildrun.NodeBuildModule, "g14.typescript", tcValue(t, "typescript", "tsc5"), false)
@@ -279,6 +284,11 @@ func TestABundleAndAnArchiveAreBuiltOnOneNodeAndDone(t *testing.T) { // 1607/AC7
if v.Artifacts[0].Kind != "archive" || v.Artifacts[1].Kind != "bundle" {
t.Errorf("kinds = %+v", v.Artifacts)
}
// ADR 0618 §1: the done key names the forms its references are written in, here the first form alone, and keeps no list
// beside the single reference while no counted node needs another form.
if !slices.Equal(v.Needs, []string{"form:artifact-store"}) || v.Artifacts[0].References != nil {
t.Errorf("needs = %v, references = %v", v.Needs, v.Artifacts[0].References)
}
if got := v.Toolchains["go"]; got.Version != "go1.27.1" || got.Recipe != "1" {
t.Errorf("toolchains = %+v, want go and typescript named", v.Toolchains)
}
@@ -419,8 +429,19 @@ func TestACandidatesImageCallCarriesNoPublishAndItsKeyNoReference(t *testing.T)
r := newRig(t, imageArt)
r.puller = "ace"
trunkModule(t, r)
// DLV-B6: a check's build of a head a kept tag ask names is numbered under that tag at its claim.
nums := &fakeNumbers{}
var tagAsked []string
r.b.Numbers = nums
r.b.Tags = func(_ context.Context, repository, head string) (string, bool) {
tagAsked = append(tagAsked, repository+"@"+head)
return "feat-x", head == headX
}
r.b.Candidate(context.Background(), "feat%2Fx", candidateValue(t, "feat/x", 7, headX))
r.b.Wait()
if !slices.Equal(nums.tags, []string{"feat-x"}) || len(tagAsked) != 1 || !strings.HasSuffix(tagAsked[0], "@"+headX) {
t.Errorf("the check's build numbered with tags %v (asked %v), want feat-x for its head", nums.tags, tagAsked)
}
v := valueOf(t, r.st, r.key)
if v.State != delivery.BuildDone || len(v.Artifacts) != 1 || v.Artifacts[0].Reference != "" {
t.Fatalf("build = %+v, want done with no reference", v)
@@ -434,14 +455,50 @@ func TestACandidatesImageCallCarriesNoPublishAndItsKeyNoReference(t *testing.T)
}
}
func TestATrunkImageCallCarriesPublish(t *testing.T) { // 1705b
func TestATrunkImageCallCarriesPublish(t *testing.T) { // 1705b; design 51 *The versions*: numbered at the claim
r := newRig(t, imageArt)
r.puller = "ace"
nums := &fakeNumbers{wait: "m/img: the package waits for its floor"}
r.b.Numbers = nums
r.b.Module(context.Background(), "m", moduleValue(t, "modules/m"))
r.b.Wait()
if _, ok := r.st.vals[r.key]; ok || nums.numbered != 1 {
t.Fatalf("a build waiting for its floor was claimed (numbered %d times)", nums.numbered)
}
nums.wait, nums.versions = "", map[string]string{"img": "0.1.4"}
r.consider(t, "modules/m")
calls := r.bm.callsOf("build_image_build")
if len(calls) != 1 || calls[0].body.(map[string]any)["publish"] != true {
t.Errorf("calls = %+v, want publish true", calls)
}
if nums.built["img"] != digest {
t.Errorf("the built digest recorded = %v, want %s for img", nums.built, digest)
}
}
// fakeNumbers is Numbers that answers what it is set to and keeps the digests a done build recorded.
type fakeNumbers struct {
wait string
versions map[string]string
numbered int
built map[string]string
tags []string
}
func (f *fakeNumbers) Number(_ context.Context, _ string, _ delivery.Module, _ Plan, tag string) (versions.Numbering, error) {
f.tags = append(f.tags, tag)
f.numbered++
return versions.Numbering{Wait: f.wait, Versions: f.versions}, nil
}
func (f *fakeNumbers) Built(_ context.Context, _ string, _ delivery.Module, _ Plan, got versions.Numbering, made []delivery.BuiltArtifact) error {
f.built = map[string]string{}
for _, a := range made {
if got.Versions[a.Name] != "" {
f.built[a.Name] = a.Digest
}
}
return nil
}
func TestAnUnpinnedUpstreamFailsNotPinnedAndIsNeverBuiltAgain(t *testing.T) { // B3, ADR 0498 clause 4
@@ -1235,3 +1292,109 @@ type silentWatch struct{}
func (silentWatch) WatchAsk(context.Context, string, string) (<-chan delivery.Step, error) {
return make(chan delivery.Step), nil
}
// aDoneKeyKeepsEachFormACountedNodeReads holds the builder's rows of ADR 0624's check: every build keeps each form a
// counted node's engine reads, present or away, a node stating nothing reading the baseline's first form; the oldest kept
// form is the single reference; a retired node leaves the count. old is the first form, new a fixture second form read
// only by an engine stating form:new.
func aDoneKeyKeepsEachFormACountedNodeReads(t *testing.T) {
fs := forms.ArtifactForms{
{Name: "old", First: true, Kinds: []string{"bundle"}, Is: func(r string) bool { return strings.HasPrefix(r, "artifact-store://") }},
{Name: "new", Kinds: []string{"bundle"}, Is: func(r string) bool { return strings.HasPrefix(r, "10.0.0.1:5000/") }},
}
both, nothing, onlyNew := []string{"form:new", "word:module"}, []string(nil), []string{"form:new"}
oldRef, newRef := "artifact-store://m/code/blobs/"+digest, "10.0.0.1:5000/m/code@"+digest
for _, row := range []struct {
name string
present, counted map[string][]string
kept []string
reference string
references []string
}{
{"A present {old,new}, B away {old}: both forms, the old in the single reference",
map[string][]string{"a": both}, map[string][]string{"a": both, "b": {"word:module"}}, []string{"old", "new"}, oldRef, []string{oldRef, newRef}},
{"A present {new}, B away stating nothing: the baseline's form stays",
map[string][]string{"a": onlyNew}, map[string][]string{"a": onlyNew, "b": nothing}, []string{"old", "new"}, oldRef, []string{oldRef, newRef}},
{"A present {old,new}, B away and never asked (no key): the baseline's form stays",
map[string][]string{"a": both}, map[string][]string{"a": both, "b": nil}, []string{"old", "new"}, oldRef, []string{oldRef, newRef}},
{"B retired: the new form only", map[string][]string{"a": both}, map[string][]string{"a": both}, []string{"new"}, newRef, nil},
{"B present stating nothing: the new form is not written",
map[string][]string{"a": both, "b": nothing}, map[string][]string{"a": both, "b": nothing}, []string{"old"}, oldRef, nil},
{"no node present: the first form", nil, map[string][]string{"a": both}, []string{"old"}, oldRef, nil},
} {
if got := fs.Kept("bundle", row.present, row.counted); !slices.Equal(got, row.kept) {
t.Errorf("%s: kept %v, want %v", row.name, got, row.kept)
}
a := fs.Choose(delivery.BuiltArtifact{Name: "code", Kind: "bundle", Digest: digest}, []string{newRef, oldRef}, row.present, row.counted)
if a.Reference != row.reference || !slices.Equal(a.References, row.references) {
t.Errorf("%s: %q %v, want %q %v", row.name, a.Reference, a.References, row.reference, row.references)
}
// An engine that predates the list reads the single reference, in a form every counted node reads (ADR 0624 §3).
if form, _ := fs.FormOf("bundle", a.Reference); form != row.kept[0] {
t.Errorf("%s: the single reference is in %q, want the oldest kept %q", row.name, form, row.kept[0])
}
}
// An answered reference no kept form names still names where the artifact is (ADR 0498 clause 1).
if a := fs.Choose(delivery.BuiltArtifact{Name: "code", Kind: "bundle", Digest: digest}, []string{oldRef}, map[string][]string{"a": both},
map[string][]string{"a": both}); a.Reference != oldRef {
t.Errorf("a lone answered reference was dropped: %+v", a)
}
withBoth := []delivery.BuiltArtifact{{Name: "code", Kind: "bundle", Digest: digest, Reference: oldRef, References: []string{oldRef, newRef}}}
if got := fs.Needs(withBoth); !slices.Equal(got, []string{"form:new", "form:old"}) {
t.Errorf("needs %v", got)
}
// The builder's own forms (forms.Current): a bundle a build step answers in the registry seat's form (mesh-build-go's
// oci-artifact-registry/<module>/<artifact>@<digest>) keeps the artifact-store form it derives for each counted node
// whose engine does not state form:seat-reference, that one in the single reference (ADR 0624 §1, §3).
seatRef, storeRef := "oci-artifact-registry/m/code@"+digest, "artifact-store://m/code/blobs/"+digest
seat := []string{forms.FormNeed(forms.FormSeatReference), "word:module"}
for _, row := range []struct {
name string
present, counted map[string][]string
reference string
references []string
needs []string
}{
{"seat form answered, B away stating nothing: the derived store form stays, in the single reference",
map[string][]string{"a": seat}, map[string][]string{"a": seat, "b": nil}, storeRef, []string{storeRef, seatRef},
[]string{"form:artifact-store", "form:seat-reference"}},
{"seat form answered, every counted engine reads it: the seat form only",
map[string][]string{"a": seat}, map[string][]string{"a": seat, "b": seat}, seatRef, nil, []string{"form:seat-reference"}},
{"seat form answered, a present engine stating nothing: the store form only",
map[string][]string{"a": seat, "b": nil}, map[string][]string{"a": seat, "b": nil}, storeRef, nil, []string{"form:artifact-store"}},
{"seat form answered, no node present: the store form", nil, map[string][]string{"a": seat}, storeRef, nil, []string{"form:artifact-store"}},
} {
a := forms.Current.Choose(delivery.BuiltArtifact{Name: "code", Kind: "bundle", Digest: digest}, []string{seatRef}, row.present, row.counted)
if a.Reference != row.reference || !slices.Equal(a.References, row.references) {
t.Errorf("%s: %q %v, want %q %v", row.name, a.Reference, a.References, row.reference, row.references)
}
needs := forms.Current.Needs([]delivery.BuiltArtifact{a})
if !slices.Equal(needs, row.needs) {
t.Errorf("%s: needs %v, want %v", row.name, needs, row.needs)
}
for node, stated := range row.counted {
if forms.Current.Held(needs, stated) {
t.Errorf("%s: the want of %s is held", row.name, node)
}
}
if _, err := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: strings.TrimPrefix(digest, "sha256:"), Artifacts: []delivery.BuiltArtifact{a}, Needs: needs}); err != nil {
t.Errorf("%s: %v", row.name, err)
}
}
// What the assigner holds a want by (ADR 0624 §2), from the same forms.
for _, row := range []struct {
name string
needs []string
stated []string
held bool
}{
{"a node stating {old}, a build carrying {old,new}: moves", []string{"form:new", "form:old"}, []string{"word:module"}, false},
{"a node stating nothing, a build carrying the baseline's form: moves", []string{"form:old"}, nil, false},
{"a build carrying {new} only, the node stating {old}: held", []string{"form:new"}, []string{"word:module"}, true},
{"a build naming no form: moves", nil, nil, false},
} {
if got := fs.Held(row.needs, row.stated); got != row.held {
t.Errorf("%s: held %v", row.name, got)
}
}
}
@@ -1,6 +1,8 @@
package build
import (
"mesh-delivery-builder/internal/versions"
"encoding/json"
"errors"
"fmt"
@@ -54,6 +56,67 @@ type ReAsk struct {
Failed string `json:"failed,omitempty"`
Revision uint64 `json:"revision,omitempty"`
Asks map[string]string `json:"asks,omitempty"`
// Name is the numbered name (`<module>/<artifact>`) of a ModePublishAgain or ModeFloorAgain value; Do and Version the
// builder's answer: what it did (versions.Again*) and the version published.
Name string `json:"name,omitempty"`
Do string `json:"do,omitempty"`
Version string `json:"version,omitempty"`
}
// ModePublishAgain is the Mode of a reasks value of builder_publish_again (design 51 *Tools of the builder*).
const ModePublishAgain = "publish-again"
// ModeFloorAgain is the Mode of a reasks value of builder_floor_again (design 51 *The floor*).
const ModeFloorAgain = "floor-again"
// NewPublishAgain is the value builder_publish_again writes for a name's fingerprint: the key of the name's module and
// fingerprint, and the value.
func NewPublishAgain(name, fp, why, by string, at time.Time) (string, []byte, error) {
module, artifact, ok := strings.Cut(strings.TrimSpace(name), "/")
if !ok || module == "" || artifact == "" {
return "", nil, fmt.Errorf("name %q is not a numbered name, <module>/<artifact>, as builder_versions lists it", name)
}
key, err := delivery.BuilderBuildKey(module, fp)
if err != nil {
return "", nil, err
}
if strings.TrimSpace(why) == "" {
return "", nil, errors.New("say why it is published again (why)")
}
data, err := json.Marshal(ReAsk{V: 1, Why: strings.TrimSpace(why), By: by, At: at.UTC(), Mode: ModePublishAgain, Module: module, FP: fp, Name: strings.TrimSpace(name)})
return key, data, err
}
// NewFloorAgain is the value builder_floor_again writes for a name: the key `floor.<name escaped>`, never a build key,
// and the value.
func NewFloorAgain(name, why, by string, at time.Time) (string, []byte, error) {
name = strings.TrimSpace(name)
if module, artifact, ok := strings.Cut(name, "/"); !ok || module == "" || artifact == "" {
return "", nil, fmt.Errorf("name %q is not a numbered name, <module>/<artifact>, as builder_versions lists it", name)
}
if strings.TrimSpace(why) == "" {
return "", nil, errors.New("say why the floor is asked again (why)")
}
data, err := json.Marshal(ReAsk{V: 1, Why: strings.TrimSpace(why), By: by, At: at.UTC(), Mode: ModeFloorAgain, Name: name})
return "floor." + versions.EscapeToken(name), data, err
}
// ModeOldFormDrop is the Mode of a reasks value of builder_old_form's drop (design 51 *Every build keeps the old
// reference form*): a retired node dropped from the count by hand; Name is the node.
const ModeOldFormDrop = "old-form-drop"
// NewOldFormDrop is the value builder_old_form writes to drop a retired node: the key `old-form.<node>`, never a build
// key, and the value.
func NewOldFormDrop(node, why, by string, at time.Time) (string, []byte, error) {
node = strings.TrimSpace(node)
if node == "" || strings.ContainsAny(node, ". *>") {
return "", nil, fmt.Errorf("node %q is not a node's name", node)
}
if strings.TrimSpace(why) == "" {
return "", nil, errors.New("say why the node leaves the count (why)")
}
data, err := json.Marshal(ReAsk{V: 1, Why: strings.TrimSpace(why), By: by, At: at.UTC(), Mode: ModeOldFormDrop, Name: node})
return "old-form." + node, data, err
}
// ModeBuild is the Mode of a reasks value that asks the key to be built again, not its waiting asks to be asked again.
@@ -0,0 +1,331 @@
package versions
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
)
// What builder_publish_again does with one fingerprint of a name (design 51 *Tools of the builder*), after a holder failed.
const (
// AgainPublish says version.numbered again for the recorded version: the build module publishes the held digest.
AgainPublish = "publish"
// AgainBuild builds the fingerprint again under the same number: the build's file is gone and no published was said
// for the number, so the new digest replaces the recorded one (Numberer.Built).
AgainBuild = "build"
// AgainRenumber takes a new number: a published was said for the number, so other bytes never go under it.
AgainRenumber = "renumber"
)
// Again is what builder_publish_again does, with the entry it acts on and why.
type Again struct {
Do string `json:"do"`
Entry Entry `json:"entry"`
Why string `json:"why"`
}
// PublishAgain decides builder_publish_again for fingerprint among a name's entries; built says whether the builder still
// holds the build's file (its done key). It refuses a fingerprint not numbered, and a version refused for other bytes
// than the ones recorded under it.
func PublishAgain(entries []Entry, fingerprint string, built bool) (Again, error) {
fingerprint = strings.TrimSpace(fingerprint)
if fingerprint == "" {
return Again{}, errors.New("fp is required: the fingerprint whose version is published again")
}
for _, e := range latestFirst(entries) {
if e.Tag != "" || e.Fingerprint != fingerprint {
continue
}
switch {
case e.State == Refused && e.RefusedDigest != "" && e.Digest != "" && e.RefusedDigest != e.Digest:
return Again{}, fmt.Errorf("%s@%s was refused for other bytes (%s, recorded %s): it is not published again; a new build takes a new number",
e.Name, e.Version, e.RefusedDigest, e.Digest)
case e.State == Published || e.State == Removed:
return Again{Do: AgainRenumber, Entry: e, Why: fmt.Sprintf("%s@%s was %s, so the fingerprint takes a new number", e.Name, e.Version, e.State)}, nil
case !built:
return Again{Do: AgainBuild, Entry: e, Why: fmt.Sprintf("the build's file is gone and no published was said for %s@%s: built again under the same number", e.Name, e.Version)}, nil
}
return Again{Do: AgainPublish, Entry: e, Why: fmt.Sprintf("%s@%s (%s) is said numbered again, so its build module publishes the held digest", e.Name, e.Version, e.State)}, nil
}
return Again{}, fmt.Errorf("fingerprint %s is not numbered for this name: builder_versions lists what is", fingerprint)
}
// The builder's state of tag asks (design 51 *Tagged versions*): one key per repository and branch, removed when the
// branch is merged or deleted or its pull request closed.
const (
TagAsksState = "tag-asks"
TagAsksBucket = "mesh-delivery-builder_tag-asks"
)
// The states of a tag ask.
const (
TagAsked = "asked"
TagNumbered = "numbered"
TagRemoving = "removing"
TagRemoved = "removed"
)
// TagAsk is one value of tag-asks: an ask of a tagged version for a branch's head, from the checker's tag-wanted or by
// hand (builder_tag), or by hand the removal of the branch's tagged versions (builder_untag, Untag).
type TagAsk struct {
V int `json:"v"`
Repository string `json:"repository"`
Branch string `json:"branch"`
Head string `json:"head,omitempty"`
Tag string `json:"tag"`
By string `json:"by,omitempty"`
Why string `json:"why,omitempty"`
At time.Time `json:"at"`
Untag bool `json:"untag,omitempty"`
// State, Versions (name to version), Left (what a removal still waits on, name@version to its seat) and Answer are
// the builder's.
State string `json:"state,omitempty"`
Versions map[string]string `json:"versions,omitempty"`
Left map[string]string `json:"left,omitempty"`
Answer string `json:"answer,omitempty"`
Answered time.Time `json:"answered,omitzero"`
}
// TagAskKey is the key of a repository's branch in tag-asks.
func TagAskKey(repository, branch string) string {
return EscapeToken(repository) + "." + EscapeToken(branch)
}
// NewTagAsk is the value builder_tag writes: its key and the value. The tag is the branch's, cleaned (CleanTag).
func NewTagAsk(repository, branch, head, why, by string, at time.Time) (string, []byte, error) {
return newTagAsk(repository, branch, head, why, by, at, false)
}
// NewUntag is the value builder_untag writes: its key and the value.
func NewUntag(repository, branch, why, by string, at time.Time) (string, []byte, error) {
return newTagAsk(repository, branch, "", why, by, at, true)
}
func newTagAsk(repository, branch, head, why, by string, at time.Time, untag bool) (string, []byte, error) {
repository, branch, head, why = strings.TrimSpace(repository), strings.TrimSpace(branch), strings.TrimSpace(head), strings.TrimSpace(why)
switch {
case repository == "" || !strings.Contains(repository, "/"):
return "", nil, errors.New("repository is required: <owner>/<name>")
case branch == "":
return "", nil, errors.New("branch is required")
case !untag && head == "":
return "", nil, errors.New("head is required: the commit the tagged version is built from")
case why == "":
return "", nil, errors.New("say why (why): a hand act is recorded")
}
data, err := json.Marshal(TagAsk{V: 1, Repository: repository, Branch: branch, Head: head, Tag: CleanTag(branch), By: by, Why: why,
At: at.UTC(), Untag: untag, State: TagAsked})
return TagAskKey(repository, branch), data, err
}
// DecodeTagAsk reads a tag-asks value.
func DecodeTagAsk(data []byte) (TagAsk, error) {
var a TagAsk
if err := json.Unmarshal(data, &a); err != nil {
return a, err
}
if a.V != 1 {
return a, fmt.Errorf("a tag-asks value of version %d, not 1", a.V)
}
return a, nil
}
// TagAsksAnswer is what builder_tag_asks answers: the open asks, oldest first.
type TagAsksAnswer struct {
Asks []TagAsk `json:"asks"`
}
// AnswerTagAsks is builder_tag_asks over the state read, only repository's when it is given.
func AnswerTagAsks(r Reader, repository string) (TagAsksAnswer, error) {
keys, err := r.Keys()
if err != nil {
return TagAsksAnswer{}, fmt.Errorf("%s cannot be listed: %w", TagAsksState, err)
}
out := TagAsksAnswer{Asks: []TagAsk{}}
for _, k := range keys {
raw, ok, err := r.Get(k)
if err != nil {
return TagAsksAnswer{}, fmt.Errorf("%s.%s: %w", TagAsksState, k, err)
}
if !ok {
continue
}
a, err := DecodeTagAsk(raw)
if err != nil {
return TagAsksAnswer{}, fmt.Errorf("%s.%s holds what is not a tag ask: %w", TagAsksState, k, err)
}
if repository == "" || a.Repository == repository {
out.Asks = append(out.Asks, a)
}
}
sort.Slice(out.Asks, func(i, j int) bool { return out.Asks[i].At.Before(out.Asks[j].At) })
return out, nil
}
// Untagged are repository's tagged versions of tag that are not removed (a group's branch spans repositories, each
// ending apart): what a branch's end, or builder_untag, removes through
// each one's seat (design 51 *Tagged versions*). A version with no tag is never among them.
func Untagged(entries []Entry, repository, tag string) []Entry {
tag = CleanTag(tag)
var out []Entry
for _, e := range entries {
if e.Tag != "" && e.Repository == repository && CleanTag(e.Tag) == tag && e.State != Removed {
out = append(out, e)
}
}
return InOrder(out)
}
// The tools' descriptions.
const (
PublishAgainDescription = "Publish one fingerprint's version again after its holder failed (design 51): with the build's file held, the " +
"builder says version.numbered again and the build module publishes the held digest; with the file gone, it builds the fingerprint " +
"again under the same number, the new digest replacing the recorded one, since no published was said for it; after a published (or " +
"a removal) it takes a new number. It refuses a fingerprint not numbered and a version the holder refused for other bytes. A hand " +
"act, recorded with its caller and why in the builder's log."
FloorAgainDescription = "Ask the floor of one name again now, by hand (design 51 *The floor*): the seat's versions, the forge bridge's " +
"bridge_tags and the indexer's indexer_requirements, as the builder asks them by itself when a silent source says something. " +
"Answers which sources were silent and whether the floor is now known. A name the builder's versions already hold needs no floor: " +
"refused, saying so. A hand act."
TagDescription = "Ask a tagged version for a branch's head by hand, as the checker's tag-wanted asks it (design 51 *Tagged versions*): " +
"the builder keeps the ask in its state tag-asks until the branch is merged or deleted, or its pull request closed, and numbers " +
"each package the head builds below the next trunk version (0.1.15-<tag>.2). A hand act."
TagAsksDescription = "The open tag asks of the builder's state tag-asks, oldest first: repository, branch, head, tag, who asked and why, " +
"its state (asked, numbered, removing, removed), the versions numbered for it and, while its tagged versions are removed, the " +
"seats that have not yet said removed. With repository, that repository's only. Read-only."
UntagDescription = "Remove a branch's tagged versions by hand, as its merge, its pull request's close or its deletion does (design 51 " +
"*Tagged versions*): the builder calls each seat's remove with the reason tag-gone and marks each version removed once its seat says " +
"removed. Answers the versions it removes and the seats still waited on. A version with no tag is never removed. A hand act."
)
// Acts are the builder's hand acts on its versions, answered by its program (the tools bundle writes them to reasks).
type Acts struct {
Numberer *Numberer
// Done says whether the build key of module and fingerprint is done: the build's file is held.
Done func(ctx context.Context, module, fp string) (bool, error)
// BuildAgain builds a failed key again (builder_build_again's path): its answer, or why it refused.
BuildAgain func(ctx context.Context, module, fp, why, by string) (answer string, refused bool)
// Reconsider looks at every module again, so a claim asks a name's floor.
Reconsider func(ctx context.Context)
// Remove calls a seat's verb remove for one tagged version, reason tag-gone.
Remove func(ctx context.Context, seat string, e Entry) error
// DropOldForm drops a retired node from the old form's count by hand (builder_old_form): its answer, or why not.
DropOldForm func(ctx context.Context, node, by, why string, at time.Time) (string, error)
}
// TagAsked answers one unanswered value of tag-asks. An untag calls remove on each of the branch's tagged versions'
// seats and leaves each in Left until its seat says removed (Heard, on the seat's events); a call that fails stays in
// Left with its error, asked again by the branch's end. A tag ask is kept: its versions are numbered when the head's
// packages are claimed for the group's check.
func (a Acts) TagAsked(ctx context.Context, t TagAsk, now time.Time) TagAsk {
t.Answered = now.UTC()
if !t.Untag {
t.State = TagAsked
t.Answer = "kept: each package the head builds is numbered " + "<next>-" + t.Tag + ".<n> when its build is claimed for the check"
return t
}
all, err := a.Numberer.Versions.All(ctx)
if err != nil {
t.Answer = "nothing removed: " + err.Error()
return t
}
t.Left, t.Versions = map[string]string{}, map[string]string{}
for _, e := range Untagged(all, t.Repository, t.Tag) {
t.Versions[e.Name] = e.Version
at := e.Name + "@" + e.Version
t.Left[at] = e.Seat
if err := a.Remove(ctx, e.Seat, e); err != nil {
t.Left[at] = e.Seat + ": " + err.Error()
}
}
if len(t.Left) == 0 {
t.State, t.Left, t.Answer = TagRemoved, nil, "the branch holds no tagged version: nothing to remove"
return t
}
t.State = TagRemoving
t.Answer = fmt.Sprintf("remove asked of %d tagged versions; each is marked removed when its seat says removed", len(t.Left))
return t
}
// PublishAgain is builder_publish_again of name's fingerprint: what it did, the version, and its answer; an error refuses.
func (a Acts) PublishAgain(ctx context.Context, name, fp, why, by string, at time.Time) (Again, string, error) {
module, _, _ := strings.Cut(name, "/")
entries, err := a.Numberer.Versions.Of(ctx, name)
if err != nil {
return Again{}, "", err
}
done, err := a.Done(ctx, module, fp)
if err != nil {
return Again{}, "", fmt.Errorf("the build key of %s at %s cannot be read: %w", module, fp, err)
}
ag, err := PublishAgain(entries, fp, done)
if err != nil {
return Again{}, "", err
}
e := ag.Entry
if ag.Do == AgainRenumber {
_, ne, err := a.Numberer.Versions.Number(ctx, Claim{Name: e.Name, Kind: e.Kind, Series: e.Series, Module: e.Module, Fingerprint: e.Fingerprint,
Commit: e.Commit, Revision: e.Revision, Seat: e.Seat, Merge: e.Merge, Fresh: true})
if err != nil {
return Again{}, "", err
}
ag.Entry = ne
}
answer := ag.Why
if ag.Do == AgainBuild || (ag.Do == AgainRenumber && !done) {
said, refused := a.BuildAgain(ctx, module, fp, why, by)
if refused {
return Again{}, "", fmt.Errorf("%s; and the build again was refused: %s", ag.Why, said)
}
answer += "; " + said
}
if ag.Do == AgainPublish || (ag.Do == AgainRenumber && done) {
if a.Numberer.Said != nil {
b := NumberedOf(ag.Entry, "")
b.Again = at.UTC().Format(time.RFC3339Nano)
a.Numberer.Said(b)
}
answer += "; " + NumberedEvent + " said for " + ag.Entry.Version
}
return ag, answer, nil
}
// FloorAgain is builder_floor_again of name: the floor counts only for a name the versions hold nothing of.
func (a Acts) FloorAgain(ctx context.Context, name string) (string, error) {
entries, err := a.Numberer.Versions.Of(ctx, name)
if err != nil {
return "", err
}
if len(entries) > 0 {
return "", fmt.Errorf("%s holds %d versions: its floor counts only for its first number, so nothing is asked", name, len(entries))
}
answer := name + " waits on no floor in this builder: the floor is asked at its next claim"
if a.Numberer.Waits != nil {
for _, w := range a.Numberer.Waits.Waiting() {
if w.Name == name {
answer = fmt.Sprintf("the floor of %s is asked again (%s were silent)", name, strings.Join(w.Silent, ", "))
}
}
a.Numberer.Waits.Wake(WakeByHand, "", "", name)
}
a.Reconsider(ctx)
return answer + "; builder_versions shows its version once numbered", nil
}
// TagFor is the cleaned tag a kept tag ask names for repository's head: the ask a check's build of that head is
// numbered under (design 51 *Tagged versions*). An untag, or an ask whose branch ended, names none.
func TagFor(asks []TagAsk, repository, head string) (string, bool) {
for _, t := range asks {
if t.Untag || t.State == TagRemoving || t.State == TagRemoved || t.Head == "" {
continue
}
if t.Repository == repository && t.Head == head {
return t.Tag, t.Tag != ""
}
}
return "", false
}
@@ -0,0 +1,89 @@
package versions
import (
"context"
"fmt"
"sort"
"strings"
"time"
)
// A branch's end removes its tagged versions (design 51 *Tagged versions*, DLV-B6): on the forge bridge's `merged`, a
// pull's `closed` or `branch-deleted`, heard on the builder's durable consumer of the stream `forge`, the builder calls
// each seat's `remove` and acknowledges the message only once every seat said `removed` (Heard marks the entry); until
// then the bus gives it again each minute (RemoveAgainAfter) and, past RemovalFindingAfter, the builder's finding names
// the seats still owing.
const (
RemoveAgainAfter = time.Minute
RemovalFindingAfter = 15 * time.Minute
// RemovalNotDone is the finding's kind.
RemovalNotDone = "tag-removal-not-done"
)
// BranchEnd is what one hearing of a branch's end did: the versions still not removed (name@version to its seat, with
// the error of a remove that failed), and whether all are gone so the message is acknowledged.
type BranchEnd struct {
Left map[string]string
Done bool
}
// Seats are the seats still owing a removal, sorted, each once.
func (b BranchEnd) Seats() []string {
seen := map[string]bool{}
var out []string
for _, s := range b.Left {
seat, _, _ := strings.Cut(s, ":")
if !seen[seat] {
seen[seat] = true
out = append(out, seat)
}
}
sort.Strings(out)
return out
}
// Finding is the removal finding's words: what is left, since when.
func (b BranchEnd) Finding(repository, branch string, since time.Time) string {
var at []string
for v, s := range b.Left {
at = append(at, v+" on "+s)
}
sort.Strings(at)
return fmt.Sprintf("the tagged versions of %s %s are not all removed since its end at %s: %s; the builder asks remove again each minute",
repository, branch, since.UTC().Format(time.RFC3339), strings.Join(at, ", "))
}
// BranchEnded removes repository's tagged versions of branch: every one not yet removed is asked of its seat again.
// A store that cannot be read is the error: nothing is acknowledged then.
func (a Acts) BranchEnded(ctx context.Context, repository, branch string) (BranchEnd, error) {
all, err := a.Numberer.Versions.All(ctx)
if err != nil {
return BranchEnd{}, err
}
left := map[string]string{}
for _, e := range Untagged(all, repository, branch) {
at := e.Name + "@" + e.Version
left[at] = e.Seat
if a.Remove == nil {
continue
}
if err := a.Remove(ctx, e.Seat, e); err != nil {
left[at] = e.Seat + ": " + err.Error()
}
}
return BranchEnd{Left: left, Done: len(left) == 0}, nil
}
// TagWanted is the body of the checker's `tag-wanted` (design 51 *Tagged versions*): a group member's head to number
// tagged versions for.
type TagWanted struct {
Repository string `json:"repository"`
Branch string `json:"branch"`
Head string `json:"head"`
Tag string `json:"tag,omitempty"`
}
// TagWantedAsk is the tag-asks value a tag-wanted becomes, as builder_tag writes it by hand: by the checker.
func TagWantedAsk(t TagWanted, by string, at time.Time) (string, []byte, error) {
return NewTagAsk(t.Repository, t.Branch, t.Head, "the checker's tag-wanted for a group member's check", by, at)
}
@@ -0,0 +1,249 @@
package versions
import (
"context"
"fmt"
"sort"
"strings"
"time"
)
// The registry seats a version is published to (design 51 *The seats*), by what is published.
const (
SeatImages = "container-image-registry"
SeatArtifacts = "oci-artifact-registry"
SeatGoPackages = "go-package-registry"
SeatNPM = "npm-package-registry"
)
// SeatOf is the registry seat an artifact of kind in language is published to, and the kind the builder numbers it as;
// ok is false for what is not numbered (an upstream copy is mirrored by its pinned digest, design 51 *Publishing*).
func SeatOf(kind, language string) (seat, numberedAs string, ok bool) {
switch kind {
case KindImage:
return SeatImages, KindImage, true
case KindBundle, KindArchive:
return SeatArtifacts, kind, true
case KindPackage:
switch language {
case "go":
return SeatGoPackages, KindPackage, true
case "typescript", "javascript", "node":
return SeatNPM, KindPackage, true
}
}
return "", "", false
}
// NameOf is the name a module's image, bundle or archive is numbered under: `<module>/<artifact>`, the name the seat
// answers it by (ADR 0605 §9's `<registry>/<module>/<artifact>@…`). A package is numbered under its name in its own
// ecosystem instead (Numberer.Names).
func NameOf(module, artifact string) string { return module + "/" + artifact }
// PackageNames answers a package's name in its own ecosystem (design 32: a Go package's module path, an npm package's
// package.json name) for the module and artifact it is built from, as the indexer's packages state holds it; known is
// false when the state names none for it. An error is a state that cannot be read.
type PackageNames func(ctx context.Context, module, artifact string) (name string, known bool, err error)
// TagPrefix is the git tag prefix of a package in the module's directory path: `go/v` for mesh-sdk's `go`, `v` at the
// root (design 51: the builder tags each Go version `go/v0.1.<n>`).
func TagPrefix(path string) string {
path = strings.Trim(path, "/")
if path == "" || path == "." {
return "v"
}
return path + "/v"
}
// ClaimArtifact is one artifact of a trunk build to number.
type ClaimArtifact struct {
Artifact, Kind, Language string
}
// BuildClaim is a trunk build at its claim: what Numberer.Number numbers.
type BuildClaim struct {
Module, Path, Repository, Fingerprint, Commit, Series string
// Revision is the source revision of the merge (a package is numbered per revision); Merge its sequence, for order.
Revision string
Merge uint64
Artifacts []ClaimArtifact
// Tag numbers a check's build of a head a tag ask names (design 51 *Tagged versions*): its packages only, each a
// tagged version kept with Repository for its branch's end; Head is the head, said in numbered. Empty for a trunk build.
Tag, Head string
}
// Numbering is what the claim got.
type Numbering struct {
// Versions are each artifact's version, by artifact; an artifact built unnumbered (its floor not known) has none.
Versions map[string]string
// Unnumbered are the artifacts built and published by digest, numbered once their floor answers (ADR 0617 §1).
Unnumbered []string
// Wait is why the build waits: a package whose floor is not known (design 51 *The floor*). Nothing is claimed.
Wait string
// Said are the builder's narrations of each decision, for its process log and its build log (build.said).
Said []Narration
// Names are the names each numbered artifact was numbered under, by artifact (NameOf, or a package's name in its
// ecosystem).
Names map[string]string
}
// NameOf is the name artifact of module was numbered under in this numbering.
func (g Numbering) NameOf(module, artifact string) string {
if name := g.Names[artifact]; name != "" {
return name
}
return NameOf(module, artifact)
}
// Narration is one line the builder says in its build log (design 51: the build log says a version was kept, and
// why), and what it narrates.
type Narration struct{ Line, Why string }
// What a narration of the build log narrates (the build.said entry's why).
const (
SaidNumbered = "numbered"
SaidKept = "kept"
SaidWaiting = "waiting"
SaidUnnumbered = "unnumbered"
SaidUnanswered = "tag ask unanswered"
SaidOldFormClosed = "old-form-closed"
)
// narrationOf is a decision's narration.
func narrationOf(name string, d Decision) Narration {
why := SaidNumbered
switch {
case d.Keep:
why = SaidKept
case d.Wait:
why = SaidWaiting
case d.Unnumbered:
why = SaidUnnumbered
}
return Narration{Line: name + ": " + d.Why, Why: why}
}
// Numberer numbers trunk builds at their claim (design 51 *The versions*, DLV-B2, B3): it asks a name's floor only
// when versions holds nothing for it, records a silent floor as a wait (FloorWaits, woken by its source's event), and
// says each new number (Said, the event version.numbered).
type Numberer struct {
Versions Versions
Floor FloorSources
Waits *FloorWaits
// Holder is the module holding a seat, whose health condition's clear wakes a silent seat source; nil names none.
Holder func(seat string) string
// Said is told each version newly numbered; nil tells nobody.
Said func(NumberedBody)
// Names answers a package's name in its ecosystem; nil numbers a package as NameOf names it.
Names PackageNames
Now func() time.Time
}
// Number numbers every numbered artifact of c. An error is a store that cannot be read or written: the claim is then
// built as before, unnumbered, and said.
func (n *Numberer) Number(ctx context.Context, c BuildClaim) (Numbering, error) {
out := Numbering{Versions: map[string]string{}, Names: map[string]string{}}
arts := append([]ClaimArtifact(nil), c.Artifacts...)
sort.Slice(arts, func(i, j int) bool { return arts[i].Artifact < arts[j].Artifact })
for _, a := range arts {
seat, kind, ok := SeatOf(a.Kind, a.Language)
if !ok || (c.Tag != "" && kind != KindPackage) {
continue
}
name := NameOf(c.Module, a.Artifact)
if kind == KindPackage && n.Names != nil {
// A package is numbered, asked for its floor and published under its name in its ecosystem (design 32), the
// name the holder's builder_versions question and the seat's versions name it by.
named, known, err := n.Names(ctx, c.Module, a.Artifact)
if err != nil || !known {
why := "the indexer's packages state names no name of it in its ecosystem yet (it writes one at the module's next trunk merge)"
if err != nil {
why = "the indexer's packages state cannot be read: " + err.Error()
}
out.Unnumbered = append(out.Unnumbered, a.Artifact)
out.Said = append(out.Said, Narration{Line: name + ": built unnumbered: " + why, Why: SaidUnnumbered})
continue
}
name = named
}
out.Names[a.Artifact] = name
entries, err := n.Versions.Of(ctx, name)
if err != nil {
return out, err
}
var floor Floor
var why map[string]string
if len(entries) == 0 {
floor, why = n.askFloor(ctx, seat, name, c)
}
d, e, err := n.Versions.Number(ctx, Claim{Name: name, Kind: kind, Series: c.Series, Module: c.Module, Fingerprint: c.Fingerprint,
Commit: c.Commit, Revision: c.Revision, Seat: seat, Merge: c.Merge, Floor: floor, Tag: c.Tag, Repository: c.Repository})
if err != nil {
return out, err
}
out.Said = append(out.Said, narrationOf(name, d))
switch {
case d.Wait:
n.wait(name, seat, floor, why)
out.Wait = name + ": " + d.Why
return out, nil
case d.Unnumbered:
n.wait(name, seat, floor, why)
out.Unnumbered = append(out.Unnumbered, a.Artifact)
continue
}
if n.Waits != nil {
n.Waits.Answered(name)
}
out.Versions[a.Artifact] = d.Version
if !d.Keep && n.Said != nil {
n.Said(NumberedOf(e, c.Head))
}
}
return out, nil
}
func (n *Numberer) askFloor(ctx context.Context, seat, name string, c BuildClaim) (Floor, map[string]string) {
return n.Floor.Ask(ctx, FloorOf{Seat: seat, Name: name, Repository: c.Repository, Prefix: TagPrefix(c.Path)})
}
func (n *Numberer) wait(name, seat string, f Floor, why map[string]string) {
if n.Waits == nil {
return
}
holder := ""
if n.Holder != nil {
holder = n.Holder(seat)
}
now := time.Now()
if n.Now != nil {
now = n.Now()
}
n.Waits.Wait(name, seat, holder, f, why, now)
}
// Built records the digest each numbered artifact was built with, on an entry that holds none yet or was never published: what the holder's
// builder_versions question compares (ADR 0605 §4).
func (n *Numberer) Built(ctx context.Context, c BuildClaim, got Numbering, digests map[string]string) error {
var errs []string
for artifact, version := range got.Versions {
d := digests[artifact]
if d == "" {
continue
}
if _, err := n.Versions.Mark(ctx, got.NameOf(c.Module, artifact), version, func(e *Entry) {
// The new digest replaces the recorded one only while no published was said for the number (design 51
// *Tools of the builder*): a rebuild of a gone file under the same number.
if e.Digest == "" || e.State == Numbered || e.State == Refused {
e.Digest = d
}
}); err != nil {
errs = append(errs, err.Error())
}
}
if len(errs) > 0 {
sort.Strings(errs)
return fmt.Errorf("the built digests could not be recorded: %s", strings.Join(errs, "; "))
}
return nil
}
@@ -0,0 +1,319 @@
package versions
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"sort"
"strings"
"sync"
"time"
)
// FloorSources are the three sources of a name's floor (design 51 *The floor*): each a tool call that answers the
// versions it knows of a name, or an error when it does not answer.
type FloorSources struct {
// Seat is `seat:<registry>.versions` of the registry seat the name is published to.
Seat func(ctx context.Context, seat, name string) ([]string, error)
// Tags is the forge bridge's bridge_tags: the git tags of the repository the name is built from, under prefix.
Tags func(ctx context.Context, repository, prefix string) ([]string, error)
// Requirements is the indexer's indexer_requirements: the highest pseudo-versions consumers require.
Requirements func(ctx context.Context, name string) ([]string, error)
}
// FloorOf names what a floor is asked for: the name on its seat, and the repository and tag prefix of its git tags.
type FloorOf struct{ Seat, Name, Repository, Prefix string }
// Ask asks the three sources of a name; a source that errs, or is not wired, is named in Silent with its error in why.
func (s FloorSources) Ask(ctx context.Context, of FloorOf) (Floor, map[string]string) {
seat, name := of.Seat, of.Name
var f Floor
why := map[string]string{}
ask := func(source string, call func() ([]string, error), into *[]string) {
if call == nil {
f.Silent, why[source] = append(f.Silent, source), "not asked: the builder has no path to it"
return
}
got, err := call()
if err != nil {
f.Silent, why[source] = append(f.Silent, source), err.Error()
return
}
*into = got
}
var seatCall, tagsCall, reqCall func() ([]string, error)
if s.Seat != nil {
seatCall = func() ([]string, error) { return s.Seat(ctx, seat, name) }
}
if s.Tags != nil {
tagsCall = func() ([]string, error) { return s.Tags(ctx, of.Repository, of.Prefix) }
}
if s.Requirements != nil {
reqCall = func() ([]string, error) { return s.Requirements(ctx, name) }
}
ask(SourceSeat, seatCall, &f.Seat)
ask(SourceTags, tagsCall, &f.Tags)
ask(SourceRequirements, reqCall, &f.Requirements)
return f, why
}
// What wakes a name waiting on its floor, never a timer (design 51, DLV-B3b): the source that was silent saying
// something, or its module's health condition clearing.
const (
// WakePublished is the holder's `published` on the builder's durable consumer of `<seat>.events`.
WakePublished = "published"
// WakeForge is the forge bridge's next message on its stream `forge`.
WakeForge = "forge"
// WakeAccepted is the indexer's next `manifest-accepted`.
WakeAccepted = "manifest-accepted"
// WakeCleared is the condition keeper's clear of a silent module's health condition on `mesh-conditions`.
WakeCleared = "condition-cleared"
// WakeByHand is builder_floor_again, a hand act.
WakeByHand = "by hand"
)
// sourceOfWake is the floor source each event says something of; a clear names the source by its module.
var sourceOfWake = map[string]string{WakePublished: SourceSeat, WakeForge: SourceTags, WakeAccepted: SourceRequirements}
// ModuleOfSource is the module whose health condition's clear wakes a silent source.
var ModuleOfSource = map[string]string{
SourceTags: "mesh-delivery-forge-bridge",
SourceRequirements: "mesh-delivery-indexer",
}
// FloorWait is one name waiting on its floor: the sources still silent and why, since when; the finding the builder
// writes names them, cleared once all three answer.
type FloorWait struct {
Name string `json:"name"`
Seat string `json:"seat"`
Silent []string `json:"silent"`
Why map[string]string `json:"why,omitempty"`
Since time.Time `json:"since"`
// Holder is the module holding the name's seat: its condition's clear wakes the seat source.
Holder string `json:"holder,omitempty"`
}
// Finding is the floor finding's words.
func (w FloorWait) Finding() string {
var parts []string
for _, s := range w.Silent {
p := s
if why := w.Why[s]; why != "" {
p += " (" + why + ")"
}
parts = append(parts, p)
}
return fmt.Sprintf("the floor of %s is not known since %s: %s did not answer; nothing of it is numbered until all three answer",
w.Name, w.Since.UTC().Format(time.RFC3339), strings.Join(parts, ", "))
}
// The builder's state floor-waits (DLV-B4): each name waiting on its floor, keyed by its escaped name, so a restarted
// builder still knows which names its silent sources' events wake; builder_floor_waits reads it.
const (
FloorWaitsState = "floor-waits"
FloorWaitsBucket = "mesh-delivery-builder_floor-waits"
FloorWaitsTool = "builder_floor_waits"
)
// FloorWaitKey is a wait's key in floor-waits.
func FloorWaitKey(name string) string { return EscapeToken(name) }
// FloorKeeper keeps the waits on the bus: Put a wait's value, Delete its key.
type FloorKeeper interface {
Put(key string, value []byte) error
Delete(key string) error
}
// FloorWaits are the names waiting on their floor. Wake answers which names to ask again for an event, Answered drops
// a name once all three answered; the builder's finding for each is the wait's Finding. Once Kept, every change is
// written to the state floor-waits as well.
type FloorWaits struct {
mu sync.Mutex
waits map[string]FloorWait
keeper FloorKeeper
// Logf says a wait that could not be kept; nil says nothing.
Logf func(string, ...any)
// Said is told each wait recorded (raised) and each answered (not raised): the builder's finding floor-silent.
Said func(w FloorWait, raised bool)
}
// FloorSilent is the kind of the floor finding.
const FloorSilent = "floor-silent"
// Keep binds the state: the waits it holds that are not known here are taken in (a restarted builder's), every wait
// known here is written to it, and every change from now on is.
func (fw *FloorWaits) Keep(k FloorKeeper, held []FloorWait) {
fw.mu.Lock()
if fw.waits == nil {
fw.waits = map[string]FloorWait{}
}
for _, w := range held {
if _, had := fw.waits[w.Name]; !had && w.Name != "" {
fw.waits[w.Name] = w
}
}
fw.keeper = k
all := make([]FloorWait, 0, len(fw.waits))
for _, w := range fw.waits {
all = append(all, w)
}
fw.mu.Unlock()
for _, w := range all {
fw.put(k, w)
}
}
func (fw *FloorWaits) put(k FloorKeeper, w FloorWait) {
if k == nil {
return
}
data, err := json.Marshal(w)
if err == nil {
err = k.Put(FloorWaitKey(w.Name), data)
}
if err != nil && fw.Logf != nil {
fw.Logf("the floor wait of %s is not kept in %s (a restart forgets it until its next claim): %v", w.Name, FloorWaitsState, err)
}
}
// DecodeFloorWait reads one value of floor-waits.
func DecodeFloorWait(raw []byte) (FloorWait, error) {
var w FloorWait
if err := json.Unmarshal(raw, &w); err != nil {
return FloorWait{}, err
}
if w.Name == "" {
return FloorWait{}, errors.New("a floor wait names no name")
}
return w, nil
}
// Wait records that name's floor is not known.
func (fw *FloorWaits) Wait(name, seat, holder string, f Floor, why map[string]string, now time.Time) FloorWait {
fw.mu.Lock()
if fw.waits == nil {
fw.waits = map[string]FloorWait{}
}
w, had := fw.waits[name]
if !had {
w.Since = now
}
w.Name, w.Seat, w.Holder, w.Silent, w.Why = name, seat, holder, slices.Clone(f.Silent), why
fw.waits[name] = w
k := fw.keeper
fw.mu.Unlock()
fw.put(k, w)
if fw.Said != nil {
fw.Said(w, true)
}
return w
}
// Answered drops name: its floor is known. It says whether the name waited, so its finding is cleared.
func (fw *FloorWaits) Answered(name string) bool {
fw.mu.Lock()
w, had := fw.waits[name]
delete(fw.waits, name)
k := fw.keeper
fw.mu.Unlock()
if had && fw.Said != nil {
fw.Said(w, false)
}
if had && k != nil {
if err := k.Delete(FloorWaitKey(name)); err != nil && fw.Logf != nil {
fw.Logf("the floor wait of %s is not cleared from %s: %v", name, FloorWaitsState, err)
}
}
return had
}
// FloorWaitsAnswer is what builder_floor_waits answers: every name waiting on its floor, oldest first, with its finding.
type FloorWaitsAnswer struct {
Waits []FloorWaitRead `json:"waits"`
}
// FloorWaitRead is one wait as builder_floor_waits shows it.
type FloorWaitRead struct {
FloorWait
Finding string `json:"finding"`
}
// AnswerFloorWaits is builder_floor_waits, read from the state floor-waits.
func AnswerFloorWaits(r Reader) (FloorWaitsAnswer, error) {
keys, err := r.Keys()
if err != nil {
return FloorWaitsAnswer{}, err
}
out := FloorWaitsAnswer{Waits: []FloorWaitRead{}}
for _, k := range keys {
raw, ok, err := r.Get(k)
if err != nil {
return FloorWaitsAnswer{}, err
}
if !ok {
continue
}
w, err := DecodeFloorWait(raw)
if err != nil {
return FloorWaitsAnswer{}, fmt.Errorf("%s.%s: %w", FloorWaitsState, k, err)
}
out.Waits = append(out.Waits, FloorWaitRead{FloorWait: w, Finding: w.Finding()})
}
sort.Slice(out.Waits, func(i, j int) bool {
if !out.Waits[i].Since.Equal(out.Waits[j].Since) {
return out.Waits[i].Since.Before(out.Waits[j].Since)
}
return out.Waits[i].Name < out.Waits[j].Name
})
return out, nil
}
// FloorWaitsDescription is builder_floor_waits' description.
const FloorWaitsDescription = "The names waiting on their floor (the builder's state floor-waits, design 51 *The floor*), oldest first: " +
"each name, its registry seat, the sources still silent and why, since when, and the finding the builder says for it. " +
"Nothing of a waiting name is numbered until all three sources answer; builder_floor_again asks one again by hand. Read-only."
// Wake answers the names to ask their floor again on an event: kind one of the Wake constants; seat the seat a
// `published` came on; module the module whose condition cleared. A name is woken only when the event says something
// of a source it waits on; WakeByHand with a name wakes that name alone.
func (fw *FloorWaits) Wake(kind, seat, module, name string) []string {
fw.mu.Lock()
defer fw.mu.Unlock()
var out []string
for n, w := range fw.waits {
switch kind {
case WakeByHand:
if n == name {
out = append(out, n)
}
case WakeCleared:
for _, s := range w.Silent {
if ModuleOfSource[s] == module || (s == SourceSeat && w.Holder != "" && w.Holder == module) {
out = append(out, n)
break
}
}
default:
src := sourceOfWake[kind]
if slices.Contains(w.Silent, src) && (kind != WakePublished || seat == "" || seat == w.Seat) {
out = append(out, n)
}
}
}
sort.Strings(out)
return out
}
// Waiting is every wait, by name.
func (fw *FloorWaits) Waiting() []FloorWait {
fw.mu.Lock()
defer fw.mu.Unlock()
out := make([]FloorWait, 0, len(fw.waits))
for _, w := range fw.waits {
out = append(out, w)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
@@ -0,0 +1,63 @@
package versions
import (
"context"
"fmt"
)
// The registry seats' events the builder hears on its durable consumer of each `<seat>.events` (design 51 *How a
// publish ends*, DLV-B4).
const (
HeardPublished = "published"
HeardRefused = "refused"
HeardRemoved = "removed"
)
// Outcome is the body of a registry seat's published, refused and removed (mesh-contracts parts/registry Outcome), the
// fields the builder reads.
type Outcome struct {
Name string `json:"name"`
Version string `json:"version,omitempty"`
Digest string `json:"digest"`
Kind string `json:"kind"`
Holder string `json:"holder"`
Reason string `json:"reason,omitempty"`
}
// Heard takes one seat event in: a numbered version's entry turns published (with its digest when it held none),
// refused with the holder's reason, or removed with its reason and the event's id; a by-digest publish (no version)
// marks nothing. It answers the names waiting on their floor that the event wakes: a `published` says the seat
// answers (design 51 *The floor*).
func (n *Numberer) Heard(ctx context.Context, seat, event, id string, o Outcome) ([]string, error) {
var wake []string
if event == HeardPublished && n.Waits != nil {
wake = n.Waits.Wake(WakePublished, seat, "", "")
}
if o.Version == "" {
return wake, nil
}
var change func(*Entry)
switch event {
case HeardPublished:
change = func(e *Entry) {
e.State, e.Reason = Published, ""
if e.Digest == "" {
e.Digest = o.Digest
}
}
case HeardRefused:
change = func(e *Entry) {
if e.State != Published {
e.State, e.Reason, e.RefusedDigest = Refused, o.Reason, o.Digest
}
}
case HeardRemoved:
change = func(e *Entry) { e.State, e.Reason, e.RemovedBy = Removed, o.Reason, id }
default:
return wake, fmt.Errorf("%s says %q, which the builder does not hear", seat, event)
}
if _, err := n.Versions.Mark(ctx, o.Name, o.Version, change); err != nil {
return wake, fmt.Errorf("%s's %s of %s@%s: %w", seat, event, o.Name, o.Version, err)
}
return wake, nil
}
@@ -0,0 +1,52 @@
package versions
import (
"context"
"encoding/json"
"sort"
"strings"
)
// seriesName is what the builder reads of a series key of the indexer's packages state (delivery.PackagesState): the
// package's name in its ecosystem and the `<module>/<artifact>` it is built from. The value's type is the indexer's own
// (its SeriesPackage, fields `name` and `package`); the fields are read here by their names, not by its type.
type seriesName struct {
Name string `json:"name"`
Package string `json:"package"`
}
// PackagesState is the indexer's packages state as the builder reads it (its manifest's reads grant): its keys, and one
// key's value (found false when it holds none).
type PackagesState interface {
Keys(ctx context.Context) ([]string, error)
Get(ctx context.Context, key string) (value []byte, found bool, err error)
}
// NamesIn are a package's names in its ecosystem as the indexer's packages state holds them (design 32): the name of the
// series key that names module and artifact as what it is built from. A series key is named `<name>.<series>`; a key
// with no dot is a mesh Go module's published packages, not a series. A value that does not decode is passed over.
func NamesIn(s PackagesState) PackageNames {
return func(ctx context.Context, module, artifact string) (string, bool, error) {
keys, err := s.Keys(ctx)
if err != nil {
return "", false, err
}
sort.Strings(keys)
built := NameOf(module, artifact)
for _, key := range keys {
if !strings.Contains(key, ".") {
continue
}
value, found, err := s.Get(ctx, key)
if err != nil {
return "", false, err
}
var v seriesName
if !found || json.Unmarshal(value, &v) != nil || v.Package != built || v.Name == "" {
continue
}
return v.Name, true, nil
}
return "", false, nil
}
}
@@ -0,0 +1,178 @@
package versions
import (
"encoding/json"
"errors"
"fmt"
"slices"
"sort"
"strings"
"time"
"git.novox.be/novox/mesh-delivery/sdk/forms"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// The old reference form's window (design 51 *Every build keeps the old reference form*, ADR 0624 §1, ADR 0618 §2-§4,
// DLV-B9). Which forms a build key keeps is mesh-delivery's sdk/forms (Kept, Choose), the counted nodes' statements the
// builder's state engine-reads; this is only the window over them: open while a counted node-engine, present or away,
// reads of some artifact kind none of the newest form the builder knows for it, so an older form stays in new build keys
// for it; closed, said once with `old-form-closed` and a log line, when none does. It moves on what moves a statement
// (the node-engine seat's verb `manifest-reads` and its event `manifest-reads-changed`), the placement, a hand drop and
// each build key written; never on a timer.
// OldFormState is the builder's state of the window: one key, OldFormKey, an OldFormWindow. builder_old_form reads it.
const (
OldFormState = "old-form"
OldFormKey = "window"
)
// OldFormBucket is that state's bucket.
const OldFormBucket = delivery.BuilderModule + "_" + OldFormState
// OldFormHeld is the finding of a node holding the window open for FindingAfter, about that node, cleared when the
// window closes or the node no longer holds it.
const OldFormHeld = "old-form-held"
// Holding are the counted nodes, those dropped by hand left out, whose engine reads, of some artifact kind fs knows, not
// the newest form of that kind: the nodes for which an older form stays in new build keys (forms.ArtifactForms.Kept). A
// node stating nothing reads the first forms (fs.Reads), sorted.
func Holding(fs forms.ArtifactForms, counted map[string][]string, dropped map[string]bool) []string {
newest := map[string]string{} // kind -> newest form
var kinds []string
for _, f := range fs {
for _, k := range f.Kinds {
if _, ok := newest[k]; !ok {
kinds = append(kinds, k)
}
newest[k] = f.Name
}
}
var out []string
for node, stated := range counted {
if dropped[node] {
continue
}
for _, k := range kinds {
if !fs.Reads(stated, newest[k]) {
out = append(out, node)
break
}
}
}
sort.Strings(out)
return out
}
// OldFormWindow is the window's record: whether it is open, who holds it and since when, and the nodes dropped by
// hand; what builder_old_form answers and what `old-form-closed` carries when it closes.
type OldFormWindow struct {
Open bool `json:"open"`
Holding []string `json:"holding,omitempty"`
Since map[string]time.Time `json:"since,omitempty"`
Dropped []Dropped `json:"dropped,omitempty"`
Closed time.Time `json:"closed,omitzero"`
// LastHolding are the nodes that held it last, said when it closes.
LastHolding []string `json:"last_holding,omitempty"`
}
// Dropped is one retired node dropped from the count by hand (builder_old_form): a hand act with who and why.
type Dropped struct {
Node string `json:"node"`
By string `json:"by"`
Why string `json:"why"`
At time.Time `json:"at"`
}
// OldFormClosedBody is the body of OldFormClosedEvent (design 51): when, the nodes that held it last and the nodes dropped
// by hand.
type OldFormClosedBody struct {
At time.Time `json:"at"`
LastHolding []string `json:"last_holding,omitempty"`
Dropped []Dropped `json:"dropped,omitempty"`
}
// FindingAfter is how long a node holds the window open before it is the builder's finding naming the node.
const FindingAfter = 7 * 24 * time.Hour
// Step moves the window to the nodes holding it at now: it answers the new window, whether it closed in this step (so
// the builder says `old-form-closed` and its log line once), and the nodes holding it past FindingAfter (the finding,
// cleared for every other node).
func (w OldFormWindow) Step(holding []string, now time.Time) (next OldFormWindow, closed bool, overdue []string) {
next = OldFormWindow{Dropped: w.Dropped}
if len(holding) == 0 {
next.Closed, next.LastHolding = w.Closed, w.LastHolding
if w.Open {
next.Closed, next.LastHolding = now.UTC(), w.Holding
return next, true, nil
}
return next, false, nil
}
next.Open, next.Holding, next.Since = true, slices.Clone(holding), map[string]time.Time{}
for _, n := range holding {
since, had := w.Since[n]
if !had {
since = now.UTC()
}
next.Since[n] = since
if now.Sub(since) >= FindingAfter {
overdue = append(overdue, n)
}
}
return next, false, overdue
}
// Drop drops a retired node from the count by hand; dropping it again changes nothing.
func (w OldFormWindow) Drop(node, by, why string, at time.Time) (OldFormWindow, error) {
node, why = strings.TrimSpace(node), strings.TrimSpace(why)
if node == "" || why == "" {
return w, errors.New("name the retired node (node) and say why it leaves the count (why)")
}
if w.DroppedSet()[node] {
return w, nil
}
w.Dropped = append(slices.Clone(w.Dropped), Dropped{Node: node, By: by, Why: why, At: at.UTC()})
return w, nil
}
// DroppedSet is the dropped nodes, as Holding takes them.
func (w OldFormWindow) DroppedSet() map[string]bool {
out := map[string]bool{}
for _, d := range w.Dropped {
out[d.Node] = true
}
return out
}
// Closing is the event's body of a window that closed in a Step.
func (w OldFormWindow) Closing() OldFormClosedBody {
return OldFormClosedBody{At: w.Closed, LastHolding: w.LastHolding, Dropped: w.Dropped}
}
// DecodeOldFormWindow reads the state's value; none is a closed window no node holds.
func DecodeOldFormWindow(raw []byte) (OldFormWindow, error) {
var w OldFormWindow
if len(raw) == 0 {
return w, nil
}
if err := json.Unmarshal(raw, &w); err != nil {
return OldFormWindow{}, fmt.Errorf("the old-form window is not readable: %w", err)
}
return w, nil
}
// OldFormDescription is builder_old_form's description.
const OldFormDescription = "The old reference form's window (design 51 *Every build keeps the old reference form*, ADR 0624): whether it is open, " +
"the counted nodes, present or away, whose node-engine reads an older reference form alone and since when (a node holding it seven days " +
"is the builder's finding " + OldFormHeld + "), when it last closed and who held it then, and the retired nodes dropped from the count by " +
"hand. With drop (a node) and why, drops that retired node from the count by hand, a hand act the builder answers; a node still placed " +
"leaves the count by itself only when the placement no longer names it. builder_engine_reads shows what each node's engine states."
// AnswerOldForm is builder_old_form's read: the window as the state holds it.
func AnswerOldForm(r Reader) (OldFormWindow, error) {
raw, ok, err := r.Get(OldFormKey)
if err != nil || !ok {
return OldFormWindow{}, err
}
return DecodeOldFormWindow(raw)
}
@@ -0,0 +1,230 @@
package versions
import (
"context"
"errors"
"fmt"
"time"
)
// AllFilter is the key filter of every version of every name: two tokens.
const AllFilter = "*.*"
// ErrConflict is what a Store answers when a Create finds the key or an Update finds another revision.
var ErrConflict = errors.New("the key exists or changed")
// Store is the bucket: create and compare-and-set, as the builder's builds bucket (internal/build.Store).
type Store interface {
Get(ctx context.Context, key string) (value []byte, revision uint64, found bool, err error)
Create(ctx context.Context, key string, value []byte) (uint64, error)
Update(ctx context.Context, key string, value []byte, revision uint64) (uint64, error)
// KeysOf are the keys under a filter (NameFilter, or AllFilter for every key).
KeysOf(ctx context.Context, filter string) ([]string, error)
}
// Versions is the builder's state.
type Versions struct {
Store Store
Now func() time.Time
}
// Of is every entry of name.
func (v Versions) Of(ctx context.Context, name string) ([]Entry, error) {
keys, err := v.Store.KeysOf(ctx, NameFilter(name))
if err != nil {
return nil, fmt.Errorf("the versions of %s cannot be listed: %w", name, err)
}
var out []Entry
for _, k := range keys {
raw, _, found, err := v.Store.Get(ctx, k)
if err != nil {
return nil, fmt.Errorf("%s: %w", k, err)
}
if !found {
continue
}
e, err := Decode(raw)
if err != nil {
return nil, fmt.Errorf("%s holds what is not a version: %w", k, err)
}
out = append(out, e)
}
return InOrder(out), nil
}
// Claim is one numbering asked of the builder.
type Claim struct {
Name, Kind, Series, Module, Fingerprint, Commit, Revision, Digest, Seat string
// Repository is the module's repository, kept on a tagged version for its branch's end.
Repository string
// Tag asks a tagged version for this cleaned tag; empty for a trunk version.
Tag string
Merge uint64
Floor Floor
// Fresh numbers anew a fingerprint or revision already numbered: builder_publish_again after a published (design 51
// *Tools of the builder*).
Fresh bool
}
// Number numbers a claim under compare-and-set: two claims of one name never get one number, the second reading the
// first's key and taking the next (ADR 0606 §2). It answers the decision and, when it numbered or kept a version, its
// entry. Attempts are bounded by the claims the name can meet at once; a store that keeps conflicting is an error.
func (v Versions) Number(ctx context.Context, c Claim) (Decision, Entry, error) {
if c.Series == "" {
c.Series = DefaultSeries
}
for attempt := 0; attempt < 16; attempt++ {
entries, err := v.Of(ctx, c.Name)
if err != nil {
return Decision{}, Entry{}, err
}
var d Decision
if c.Tag != "" {
if !c.Floor.Known() && len(entries) == 0 {
return Decision{Wait: true, Why: "the tagged version waits for its floor"}, Entry{}, nil
}
ver := Tagged(entries, c.Series, c.Tag, c.Floor)
d = Decision{Version: ver, Publish: true, Why: "tagged " + ver}
for _, e := range entries {
// The same fingerprint claimed again for its tag (a check's build looked at again) keeps its number.
if e.Tag == CleanTag(c.Tag) && e.Fingerprint == c.Fingerprint && e.State != Removed {
d = Decision{Version: e.Version, Keep: true, Publish: true, Why: "tagged " + e.Version + ", kept for this fingerprint"}
break
}
}
} else {
d = Decide(fresh(entries, c), c.Kind, c.Series, c.Fingerprint, c.Revision, c.Floor)
}
if d.Version == "" {
return d, Entry{}, nil
}
if d.Keep {
for _, e := range entries {
if e.Version == d.Version {
return d, e, nil
}
}
}
e := Entry{Name: c.Name, Kind: c.Kind, Series: c.Series, Version: d.Version, Tag: c.Tag, Module: c.Module,
Fingerprint: c.Fingerprint, Commit: c.Commit, Revision: c.Revision, Digest: c.Digest, Seat: c.Seat,
State: Numbered, At: v.now(), Merge: c.Merge}
if c.Tag != "" {
e.Repository = c.Repository
}
raw, err := Encode(e)
if err != nil {
return Decision{}, Entry{}, err
}
_, err = v.Store.Create(ctx, Key(c.Name, d.Version), raw)
if errors.Is(err, ErrConflict) {
continue // another claim took this number: read again and take the next
}
if err != nil {
return Decision{}, Entry{}, fmt.Errorf("%s@%s: %w", c.Name, d.Version, err)
}
return d, e, nil
}
return Decision{}, Entry{}, fmt.Errorf("%s: the versions kept changing under 16 claims", c.Name)
}
// Mark moves one version to a new state by compare-and-set, as the holders' events say (published, refused, removed):
// change edits the entry read; a version the state does not hold is an error naming it.
func (v Versions) Mark(ctx context.Context, name, version string, change func(*Entry)) (Entry, error) {
key := Key(name, version)
for attempt := 0; attempt < 16; attempt++ {
raw, rev, found, err := v.Store.Get(ctx, key)
if err != nil {
return Entry{}, fmt.Errorf("%s: %w", key, err)
}
if !found {
return Entry{}, fmt.Errorf("%s@%s is not numbered by the builder", name, version)
}
e, err := Decode(raw)
if err != nil {
return Entry{}, fmt.Errorf("%s holds what is not a version: %w", key, err)
}
change(&e)
e.At = v.now()
out, err := Encode(e)
if err != nil {
return Entry{}, err
}
if _, err := v.Store.Update(ctx, key, out, rev); errors.Is(err, ErrConflict) {
continue
} else if err != nil {
return Entry{}, fmt.Errorf("%s: %w", key, err)
}
return e, nil
}
return Entry{}, fmt.Errorf("%s@%s kept changing under 16 marks", name, version)
}
// Recorded is the holder's question through builder_versions (ADR 0605 §4): whether name, version and digest are a
// version the builder numbered. It answers the entry when they are, and why not otherwise.
func (v Versions) Recorded(ctx context.Context, name, version, digest string) (Entry, bool, string, error) {
raw, _, found, err := v.Store.Get(ctx, Key(name, version))
if err != nil {
return Entry{}, false, "", err
}
if !found {
return Entry{}, false, fmt.Sprintf("%s@%s is not a version the builder numbered", name, version), nil
}
e, err := Decode(raw)
if err != nil {
return Entry{}, false, "", err
}
switch {
case e.State == Removed:
return e, false, fmt.Sprintf("%s@%s was removed (%s)", name, version, e.Reason), nil
case e.Digest != "" && digest != "" && e.Digest != digest:
return e, false, fmt.Sprintf("%s@%s is recorded with digest %s, not %s", name, version, e.Digest, digest), nil
}
return e, true, "", nil
}
func (v Versions) now() time.Time {
if v.Now != nil {
return v.Now().UTC()
}
return time.Now().UTC()
}
// fresh is entries as Decide reads them for c: with Fresh, the entries of c's fingerprint and revision still count for the
// next number but keep none.
func fresh(entries []Entry, c Claim) []Entry {
if !c.Fresh {
return entries
}
out := make([]Entry, len(entries))
for i, e := range entries {
if e.Tag == "" && (e.Fingerprint == c.Fingerprint || (c.Revision != "" && e.Revision == c.Revision)) {
e.Fingerprint, e.Revision = "", ""
}
out[i] = e
}
return out
}
// All is every entry of every name, by name in order: what builder_untag and the tag asks read.
func (v Versions) All(ctx context.Context) ([]Entry, error) {
keys, err := v.Store.KeysOf(ctx, AllFilter)
if err != nil {
return nil, fmt.Errorf("the versions cannot be listed: %w", err)
}
var out []Entry
for _, k := range keys {
raw, _, found, err := v.Store.Get(ctx, k)
if err != nil {
return nil, fmt.Errorf("%s: %w", k, err)
}
if !found {
continue
}
e, err := Decode(raw)
if err != nil {
return nil, fmt.Errorf("%s holds what is not a version: %w", k, err)
}
out = append(out, e)
}
return out, nil
}
@@ -0,0 +1,75 @@
package versions
import (
"fmt"
"maps"
"slices"
"strings"
"time"
)
// The bound of an unanswered tag ask (design 51 *Tagged versions*: "an ask the builder has not answered within five
// minutes is narrated in the build log with what it waits on"): a key of the state tag-ask-bounds, written with its own
// time to live, whose expiry the builder hears on its watch of the state. No timer: the bus ends the bound.
const (
TagAskBoundsState = "tag-ask-bounds"
TagAskBoundsBucket = "mesh-delivery-builder_tag-ask-bounds"
TagAskBound = 5 * time.Minute
)
// Unanswered says whether a tag ask still waits for its versions: kept, not an untag, none numbered yet.
func (t TagAsk) Unanswered() bool {
return !t.Untag && t.State == TagAsked && len(t.Versions) == 0
}
// WithNumbered is the tag ask once a check's build of its head numbered module's packages under its tag: numbered, each
// version by the name it was numbered under (names, by artifact; NameOf where it names none), the answer naming them. An
// untag, or an ask of another head, is left as it is.
func (t TagAsk) WithNumbered(module, head string, got, names map[string]string, at time.Time) (TagAsk, bool) {
if t.Untag || t.Head != head || len(got) == 0 || t.State == TagRemoving || t.State == TagRemoved {
return t, false
}
if t.Versions == nil {
t.Versions = map[string]string{}
}
changed := false
for artifact, v := range got {
name := Numbering{Names: names}.NameOf(module, artifact)
if t.Versions[name] != v {
t.Versions[name], changed = v, true
}
}
if !changed && t.State == TagNumbered {
return t, false
}
var said []string
for _, name := range slices.Sorted(maps.Keys(t.Versions)) {
said = append(said, name+" "+t.Versions[name])
}
t.State, t.Answered = TagNumbered, at.UTC()
t.Answer = "numbered: " + strings.Join(said, ", ")
return t, true
}
// NarrateUnanswered is the line said for a tag ask its bound found unanswered: since when, and what it waits on, the
// names waiting on their floor named with their silent sources.
func NarrateUnanswered(t TagAsk, waits []FloorWait, now time.Time) string {
on := "the check's build of " + short(t.Head) + " is not claimed yet (the checker builds a group member's head once it asks)"
var floors []string
for _, w := range waits {
floors = append(floors, w.Name+" waits on its floor ("+strings.Join(w.Silent, ", ")+" silent)")
}
if len(floors) > 0 {
slices.Sort(floors)
on += "; " + strings.Join(floors, "; ")
}
return fmt.Sprintf("the tag ask of %s %s at %s (%s) is not answered after %s: it waits on %s",
t.Repository, t.Branch, short(t.Head), t.Tag, now.Sub(t.At).Round(time.Second), on)
}
func short(head string) string {
if len(head) > 12 {
return head[:12]
}
return head
}
@@ -0,0 +1,128 @@
package versions
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
)
// The builder's tools of its versions (design 51 *Events of the builder*, *Tools of the builder*).
const (
VersionsTool = "builder_versions"
PublishAgainTool = "builder_publish_again"
FloorAgainTool = "builder_floor_again"
TagTool = "builder_tag"
TagAsksTool = "builder_tag_asks"
UntagTool = "builder_untag"
OldFormTool = "builder_old_form"
// NumberedEvent and OldFormClosedEvent are the builder's own family `version`, kept seven days, apart from its builds'
// ends, so a waiter on a build's end does not wake on them.
NumberedEvent = "version.numbered"
OldFormClosedEvent = "version.old-form-closed"
)
// NumberedBody is the body of NumberedEvent: what a build module consumes to publish a build's version with the held digest,
// which stores nothing (design 51 *The floor*).
type NumberedBody struct {
Name string `json:"name"`
Kind string `json:"kind"`
Version string `json:"version"`
Tag string `json:"tag,omitempty"`
Module string `json:"module"`
Fingerprint string `json:"fingerprint"`
Commit string `json:"commit,omitempty"`
Digest string `json:"digest,omitempty"`
Seat string `json:"seat,omitempty"`
// Head is the head the version answers, when it answers a tag ask.
Head string `json:"head,omitempty"`
// Again is when builder_publish_again said it once more, so the bus's dedup does not drop it.
Again string `json:"again,omitempty"`
}
// NumberedOf is the event of an entry.
func NumberedOf(e Entry, head string) NumberedBody {
return NumberedBody{Name: e.Name, Kind: e.Kind, Version: e.Version, Tag: e.Tag, Module: e.Module, Fingerprint: e.Fingerprint,
Commit: e.Commit, Digest: e.Digest, Seat: e.Seat, Head: head}
}
// Reader reads the state as the tools bundle may: every key, and one.
type Reader struct {
Keys func() ([]string, error)
Get func(key string) (json.RawMessage, bool, error)
}
// readStore is a Store over a Reader that refuses every write: the tools bundle only reads versions.
type readStore struct{ r Reader }
var errReadOnly = errors.New("the versions are written by the builder alone")
func (s readStore) Get(_ context.Context, key string) ([]byte, uint64, bool, error) {
v, ok, err := s.r.Get(key)
return v, 0, ok, err
}
func (readStore) Create(context.Context, string, []byte) (uint64, error) { return 0, errReadOnly }
func (readStore) Update(context.Context, string, []byte, uint64) (uint64, error) {
return 0, errReadOnly
}
func (s readStore) KeysOf(_ context.Context, filter string) ([]string, error) {
all, err := s.r.Keys()
if err != nil {
return nil, err
}
if filter == AllFilter {
return all, nil
}
prefix := strings.TrimSuffix(filter, "*")
var out []string
for _, k := range all {
if strings.HasPrefix(k, prefix) && !strings.Contains(k[len(prefix):], ".") {
out = append(out, k)
}
}
return out, nil
}
// VersionsAnswer is what builder_versions answers.
type VersionsAnswer struct {
Name string `json:"name"`
Versions []Entry `json:"versions"`
// Asked, Recorded and Why answer a holder's question for one version and digest (ADR 0605 §4).
Asked string `json:"asked,omitempty"`
Recorded *bool `json:"recorded,omitempty"`
Why string `json:"why,omitempty"`
}
// AnswerVersions is builder_versions: a name's versions in order, in one call; with a version (and a digest), whether
// the builder numbered it with that digest, which the holder asks before it stores (ADR 0605 §4).
func AnswerVersions(ctx context.Context, r Reader, name, version, digest string) (VersionsAnswer, error) {
name = strings.TrimSpace(name)
if name == "" {
return VersionsAnswer{}, errors.New("name is required: the package, image, bundle or archive name")
}
v := Versions{Store: readStore{r}}
all, err := v.Of(ctx, name)
if err != nil {
return VersionsAnswer{}, err
}
out := VersionsAnswer{Name: name, Versions: all}
if out.Versions == nil {
out.Versions = []Entry{}
}
if version = strings.TrimSpace(version); version != "" {
_, ok, why, err := v.Recorded(ctx, name, version, strings.TrimSpace(digest))
if err != nil {
return VersionsAnswer{}, fmt.Errorf("%s@%s: %w", name, version, err)
}
out.Asked, out.Recorded, out.Why = version, &ok, why
}
return out, nil
}
// VersionsDescription is builder_versions' description.
const VersionsDescription = "A name's versions as the builder numbered them (its state versions, design 51): every trunk and tagged " +
"version of a package, image, bundle or archive in order, each with its kind, series, fingerprint, commit, source revision, digest, " +
"seat, state (numbered, published, refused with its reason, removed with its time, reason and the event that removed it) and when. " +
"With version, and digest, it also answers recorded: whether the builder numbered that version with that digest, and why not: the " +
"question a registry seat's holder asks before it stores a publish (ADR 0605 §4). Read-only."
@@ -0,0 +1,413 @@
// Package versions is the builder's state `versions` (novox/hq design 51 *The versions*, ADR 0606): one key per name,
// tag and version, written only by the builder under compare-and-set, read by others only through the builder's tool
// builder_versions or its events (ADR 0596 §2). It holds what each version is (its fingerprint, commit, source revision
// and digest) and where it stands: numbered, published, refused with its reason, or removed with its time, reason and the
// event that removed it.
//
// The state is declared in the builder's old `state` section (ADR 0619: the builder is one of the foundation's nine
// parts), history one; its cap is 64 MiB, the most the template state allows (ADR 0595 §3), and past 80 % of it the
// builder writes a finding, cleared below it (Fill).
package versions
import (
"encoding/json"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
)
// State is the builder state's name; Bucket the bucket the bus keeps it in.
const (
State = "versions"
Bucket = "mesh-delivery-builder_versions"
// Cap is the bucket's cap in bytes (64 MiB); FullPercent the share of it past which the builder writes its finding.
Cap = 64 << 20
FullPercent = 80
// DefaultSeries is the series of a name whose manifest declares none (design 32).
DefaultSeries = "0.1"
)
// The states a version is in.
const (
Numbered = "numbered"
Published = "published"
Refused = "refused"
Removed = "removed"
)
// The kinds the builder numbers: a package per source revision, the rest per fingerprint (design 51).
const (
KindPackage = "package"
KindImage = "image"
KindBundle = "bundle"
KindArchive = "archive"
)
// Entry is one key of the state: a version of a name, tagged or not.
type Entry struct {
Name string `json:"name"`
Kind string `json:"kind"`
Series string `json:"series"`
Version string `json:"version"`
// Tag is the cleaned tag of a tagged version (`feat-x`); empty for a trunk version.
Tag string `json:"tag,omitempty"`
Module string `json:"module"`
Fingerprint string `json:"fingerprint"`
Commit string `json:"commit,omitempty"`
// Revision is the source revision a package's version is numbered for (one version per revision, design 51).
Revision string `json:"revision,omitempty"`
// Digest is the digest the build recorded (an image's manifest, a blob's, Go's h1: hash).
Digest string `json:"digest,omitempty"`
// Seat is the registry seat it is published to.
Seat string `json:"seat,omitempty"`
State string `json:"state"`
At time.Time `json:"at"`
// Reason is why it was refused or removed: the holder's reason, a branch's end, retention, the sweep.
Reason string `json:"reason,omitempty"`
// Repository is a tagged version's repository: its branch's end removes it (Untagged).
Repository string `json:"repository,omitempty"`
// RemovedBy is the id of the event that removed it.
RemovedBy string `json:"removed_by,omitempty"`
// RefusedDigest is the digest the holder refused: one other than Digest means the seat was offered other bytes under
// this number, which builder_publish_again refuses to publish again (design 51 *Tools of the builder*).
RefusedDigest string `json:"refused_digest,omitempty"`
// Merge is the forge bridge's sequence of the merge that numbered it, for merge order.
Merge uint64 `json:"merge,omitempty"`
}
// Valid says what is wrong with an entry, or nil.
func (e Entry) Valid() error {
switch {
case strings.TrimSpace(e.Name) == "":
return errors.New("a version names no name")
case e.Version == "":
return fmt.Errorf("%s: a version with no version", e.Name)
}
switch e.State {
case Numbered, Published, Refused, Removed:
default:
return fmt.Errorf("%s@%s: unknown state %q", e.Name, e.Version, e.State)
}
switch e.Kind {
case KindPackage, KindImage, KindBundle, KindArchive:
default:
return fmt.Errorf("%s@%s: unknown kind %q", e.Name, e.Version, e.Kind)
}
if _, _, err := SplitSeries(e.Series); err != nil {
return fmt.Errorf("%s@%s: %w", e.Name, e.Version, err)
}
return nil
}
// Encode and Decode are the value's bytes.
func Encode(e Entry) ([]byte, error) {
if err := e.Valid(); err != nil {
return nil, err
}
return json.Marshal(e)
}
func Decode(raw []byte) (Entry, error) {
var e Entry
if err := json.Unmarshal(raw, &e); err != nil {
return Entry{}, err
}
return e, e.Valid()
}
// Key is the state's key of a name's version: two tokens, the name and the version each escaped (EscapeToken), so a
// name's versions are the keys under NameFilter and no name's keys fall under another's.
func Key(name, version string) string { return EscapeToken(name) + "." + EscapeToken(version) }
// NameFilter is the key filter of every version of name.
func NameFilter(name string) string { return EscapeToken(name) + ".*" }
// EscapeToken makes s one token of a bus key: letters, digits, '-', '_' and '/' stay; '=' and every other byte become
// '=' and two hex digits ('.' is "=2E", '@' "=40"). Two different strings never escape alike.
func EscapeToken(s string) string {
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '_', c == '/':
b.WriteByte(c)
default:
fmt.Fprintf(&b, "=%02X", c)
}
}
return b.String()
}
// SplitSeries reads a series `<major>.<minor>`.
func SplitSeries(series string) (major, minor int, err error) {
a, b, ok := strings.Cut(series, ".")
if !ok {
return 0, 0, fmt.Errorf("series %q is not <major>.<minor>", series)
}
if major, err = strconv.Atoi(a); err != nil || major < 0 {
return 0, 0, fmt.Errorf("series %q is not <major>.<minor>", series)
}
if minor, err = strconv.Atoi(b); err != nil || minor < 0 {
return 0, 0, fmt.Errorf("series %q is not <major>.<minor>", series)
}
return major, minor, nil
}
// Patch is the count of a trunk version of series (`0.1.14` → 14), read leniently: a leading 'v' and a path prefix
// (`go/v0.1.13`) are dropped; ok is false for a version of another series or a tagged one.
func Patch(series, version string) (int, bool) {
if i := strings.LastIndex(version, "/"); i >= 0 {
version = version[i+1:]
}
version = strings.TrimPrefix(version, "v")
if strings.ContainsAny(version, "-+") {
return 0, false
}
rest, ok := strings.CutPrefix(version, series+".")
if !ok {
return 0, false
}
n, err := strconv.Atoi(rest)
if err != nil || n < 0 {
return 0, false
}
return n, true
}
// Floor is what the three sources answered for a name (design 51 *The floor*, ADR 0606 §3): the seat's versions, the
// package's git tags (the forge bridge's bridge_tags) and the pseudo-versions consumers require (the indexer's
// indexer_requirements). A source not answered is listed in Silent.
type Floor struct {
Seat []string `json:"seat,omitempty"`
Tags []string `json:"tags,omitempty"`
Requirements []string `json:"requirements,omitempty"`
// Silent names the sources that did not answer: "seat", "bridge_tags", "indexer_requirements".
Silent []string `json:"silent,omitempty"`
}
// The floor's sources, as Floor.Silent names them.
const (
SourceSeat = "seat"
SourceTags = "bridge_tags"
SourceRequirements = "indexer_requirements"
)
// Known is whether every source answered.
func (f Floor) Known() bool { return len(f.Silent) == 0 }
// Highest is the highest count of series the floor's three sources name, -1 when none names one. A pseudo-version
// (`v0.1.14-0.2026…-abc`) counts as the version below it was cut from, so its base (`0.1.13`) counts: Go orders it
// before `0.1.14`.
func (f Floor) Highest(series string) int {
high := -1
for _, list := range [][]string{f.Seat, f.Tags, f.Requirements} {
for _, v := range list {
n, ok := Patch(series, v)
if !ok {
n, ok = pseudoBase(series, v)
}
if ok && n > high {
high = n
}
}
}
return high
}
// pseudoBase is the count a Go pseudo-version stands on: `v0.1.14-0.<time>-<hash>` is cut after `v0.1.13`.
func pseudoBase(series, version string) (int, bool) {
version = strings.TrimPrefix(version, "v")
base, rest, ok := strings.Cut(version, "-")
if !ok || !strings.HasPrefix(rest, "0.") || strings.Count(rest, "-") != 1 {
return 0, false
}
n, ok := Patch(series, base)
if !ok || n == 0 {
return 0, false
}
return n - 1, true
}
// Next is the next trunk version of name in series: one above the highest of the trunk versions entries hold and the
// floor (design 51: a name with none starts at `<series>.0`).
func Next(entries []Entry, series string, floor Floor) string {
high := floor.Highest(series)
for _, e := range entries {
if e.Tag != "" || e.Series != series {
continue
}
if n, ok := Patch(series, e.Version); ok && n > high {
high = n
}
}
return fmt.Sprintf("%s.%d", series, high+1)
}
// Decision is what the builder does with a trunk build at its claim.
type Decision struct {
// Version is the version the build is published under; empty when it is built unnumbered (Unnumbered) or waits (Wait).
Version string
// Keep is true when the version is one entries already hold for this fingerprint or source revision.
Keep bool
// Publish is false only for a package's revision held published: it publishes nothing (design 51).
Publish bool
// Unnumbered: an image, bundle or archive whose floor is not known is built and published by its digest, numbered
// once the floor answers (ADR 0617 §1).
Unnumbered bool
// Wait: a package whose floor is not known waits for it (a package is published only under its version).
Wait bool
// Why is said in the build log.
Why string
}
// Decide numbers a trunk build of kind for name in series at fingerprint and source revision, from what entries hold of
// the name and what the floor answered (design 51 *The versions*, ADR 0606, ADR 0617 §1):
// - a package is numbered per source revision: a revision held published keeps its version and publishes nothing; a
// revision numbered or refused but never stored is published again under the same version;
// - an image, bundle or archive is numbered per fingerprint: a fingerprint already numbered keeps its number;
// - otherwise the next version, when the floor is known; else an image, bundle or archive is built unnumbered and a
// package waits.
func Decide(entries []Entry, kind, series, fingerprint, revision string, floor Floor) Decision {
for _, e := range latestFirst(entries) {
if e.Tag != "" || e.State == Removed {
continue
}
switch {
case kind == KindPackage && revision != "" && e.Revision == revision:
if e.State == Published {
return Decision{Version: e.Version, Keep: true, Why: fmt.Sprintf("version %s kept: source revision %s is published under it, and a package's content is its source", e.Version, revision)}
}
return Decision{Version: e.Version, Keep: true, Publish: true, Why: fmt.Sprintf("version %s kept: source revision %s was numbered %s but never stored, so it is published under it", e.Version, revision, e.State)}
case kind != KindPackage && e.Fingerprint == fingerprint:
return Decision{Version: e.Version, Keep: true, Publish: e.State != Published, Why: fmt.Sprintf("version %s kept: fingerprint %s is numbered under it", e.Version, fingerprint)}
}
}
if !floor.Known() {
silent := strings.Join(floor.Silent, ", ")
if kind == KindPackage {
return Decision{Wait: true, Why: "the package waits for its floor: " + silent + " did not answer"}
}
return Decision{Unnumbered: true, Publish: true, Why: "built unnumbered and published by its digest: the floor is not known (" + silent + " did not answer); numbered once it answers"}
}
v := Next(entries, series, floor)
return Decision{Version: v, Publish: true, Why: "numbered " + v}
}
// CleanTag makes a branch or tag name a semver prerelease identifier: lower case, every run of characters other than
// letters and digits one '-', none at either end; "tag" when nothing is left.
func CleanTag(tag string) string {
var b strings.Builder
dash := false
for _, r := range strings.ToLower(tag) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
dash = false
continue
}
if !dash && b.Len() > 0 {
b.WriteByte('-')
dash = true
}
}
out := strings.TrimSuffix(b.String(), "-")
if out == "" {
return "tag"
}
return out
}
// Tagged is the next tagged version of name for tag: below the next trunk version, never the latest
// (`0.1.15-feat-x.2`, design 51 *Tagged versions*); a package new in a group is numbered from its floor
// (`0.1.0-feat-x.1`).
func Tagged(entries []Entry, series, tag string, floor Floor) string {
base := Next(entries, series, floor)
tag = CleanTag(tag)
n := 0
prefix := base + "-" + tag + "."
for _, e := range entries {
if rest, ok := strings.CutPrefix(e.Version, prefix); ok {
if k, err := strconv.Atoi(rest); err == nil && k > n {
n = k
}
}
}
return fmt.Sprintf("%s%d", prefix, n+1)
}
// Order is merge order (design 51): a claim of a later merge of the same name waits until every earlier merge of it
// whose fingerprint the indexer still holds is numbered; a fingerprint replaced by a later merge before its claim is
// skipped. held is the fingerprints the indexer still holds for the name with their merge sequences. WaitsOn answers
// the earliest merge sequence the claim at merge waits on, 0 when it waits on none.
func WaitsOn(entries []Entry, merge uint64, held map[string]uint64) uint64 {
numbered := map[string]bool{}
for _, e := range entries {
if e.Tag == "" {
numbered[e.Fingerprint] = true
}
}
var wait uint64
for fp, seq := range held {
if seq >= merge || numbered[fp] {
continue
}
if wait == 0 || seq < wait {
wait = seq
}
}
return wait
}
// Fill is whether a bucket holding bytes of cap is past its finding's mark.
func Fill(bytes, capacity uint64) (share float64, full bool) {
if capacity == 0 {
return 0, false
}
share = float64(bytes) / float64(capacity)
return share, bytes*100 >= capacity*FullPercent
}
// InOrder sorts a name's entries by version, trunk versions by count and tagged ones after the trunk version they stand
// below: the order builder_versions answers.
func InOrder(entries []Entry) []Entry {
out := append([]Entry(nil), entries...)
sort.SliceStable(out, func(i, j int) bool {
ki, kj := orderKey(out[i]), orderKey(out[j])
if ki.n != kj.n {
return ki.n < kj.n
}
if ki.tagged != kj.tagged {
return ki.tagged // a tagged version stands below the trunk version of its count
}
return out[i].Version < out[j].Version
})
return out
}
type order struct {
n int
tagged bool
}
func orderKey(e Entry) order {
v := e.Version
tagged := false
if i := strings.IndexByte(v, '-'); i >= 0 {
v, tagged = v[:i], true
}
n, ok := Patch(e.Series, v)
if !ok {
n = -1
}
return order{n: n, tagged: tagged}
}
func latestFirst(entries []Entry) []Entry {
out := InOrder(entries)
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out
}
@@ -0,0 +1,761 @@
package versions
import (
"context"
"encoding/json"
"errors"
"maps"
"reflect"
"slices"
"strings"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-delivery/sdk/forms"
)
// mem is a Store in memory; before runs once before the next Create, to stage a race.
type mem struct {
mu sync.Mutex
vals map[string][]byte
revs map[string]uint64
rev uint64
before func()
}
func newMem() *mem { return &mem{vals: map[string][]byte{}, revs: map[string]uint64{}} }
func (m *mem) Get(_ context.Context, key string) ([]byte, uint64, bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
v, ok := m.vals[key]
return v, m.revs[key], ok, nil
}
func (m *mem) Create(ctx context.Context, key string, value []byte) (uint64, error) {
if f := m.before; f != nil {
m.before = nil
f()
}
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.vals[key]; ok {
return 0, ErrConflict
}
m.rev++
m.vals[key], m.revs[key] = value, m.rev
return m.rev, nil
}
func (m *mem) Update(_ context.Context, key string, value []byte, revision uint64) (uint64, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.revs[key] != revision {
return 0, ErrConflict
}
m.rev++
m.vals[key], m.revs[key] = value, m.rev
return m.rev, nil
}
func (m *mem) KeysOf(_ context.Context, filter string) ([]string, error) {
m.mu.Lock()
defer m.mu.Unlock()
prefix := strings.TrimSuffix(filter, "*")
var out []string
for k := range m.vals {
if filter == AllFilter {
out = append(out, k)
continue
}
if strings.HasPrefix(k, prefix) && !strings.Contains(k[len(prefix):], ".") {
out = append(out, k)
}
}
return out, nil
}
// keeperMem is the state floor-waits in memory.
type keeperMem map[string][]byte
func (k keeperMem) Put(key string, v []byte) error { k[key] = v; return nil }
func (k keeperMem) Delete(key string) error { delete(k, key); return nil }
func (k keeperMem) keys() ([]string, error) {
var out []string
for key := range k {
out = append(out, key)
}
return out, nil
}
func (k keeperMem) get(key string) (json.RawMessage, bool, error) { v, ok := k[key]; return v, ok, nil }
// packagesMem is the indexer's packages state as the builder reads it, in memory.
type packagesMem map[string][]byte
func (p packagesMem) Keys(context.Context) ([]string, error) {
if p == nil {
return nil, errors.New("not readable")
}
return slices.Collect(maps.Keys(p)), nil
}
func (p packagesMem) Get(_ context.Context, key string) ([]byte, bool, error) {
v, ok := p[key]
return v, ok, nil
}
func (p packagesMem) failing() packagesMem { return nil }
func known(seat, tags, reqs []string) Floor { return Floor{Seat: seat, Tags: tags, Requirements: reqs} }
// TestTheBuildersVersions holds design 51 *The versions*, *The floor* and *Tagged versions* (DLV-B1, DLV-B2, DLV-B3):
// one table per rule, the unit cases of the builder's numbering.
func TestTheBuildersVersions(t *testing.T) {
t.Run("keys", func(t *testing.T) {
names := []string{"@novox/mesh-sdk", "git.novox.be/novox/mesh-sdk/go", "git.novox.be/novox/mesh-sdk", "a=40b", "a@b"}
seen := map[string]string{}
for _, n := range names {
k := Key(n, "0.1.14")
if strings.Count(k, ".") != 1 {
t.Errorf("Key(%q) = %q, want two tokens", n, k)
}
if o, dup := seen[k]; dup {
t.Errorf("%q and %q escape alike: %q", n, o, k)
}
seen[k] = n
if !strings.HasPrefix(k, strings.TrimSuffix(NameFilter(n), "*")) {
t.Errorf("Key(%q) = %q is not under NameFilter %q", n, k, NameFilter(n))
}
}
})
t.Run("floor", func(t *testing.T) {
for _, c := range []struct {
name string
floor Floor
want string
}{
{"none starts at 0", known(nil, nil, nil), "0.1.0"},
{"the Go tag of today gives v0.1.14", known(nil, []string{"go/v0.1.13"}, []string{"v0.1.14-0.20261103221822-85ba0a9a70d8"}), "0.1.14"},
{"npm's 0.1.17 on the seat gives 0.1.18", known([]string{"0.1.17", "0.1.3"}, nil, nil), "0.1.18"},
{"another series and a tagged version count nothing", known([]string{"0.2.40", "0.1.9-feat-x.1"}, nil, nil), "0.1.0"},
{"the highest of the three", known([]string{"0.1.4"}, []string{"v0.1.9"}, []string{"v0.1.7"}), "0.1.10"},
} {
if got := Next(nil, "0.1", c.floor); got != c.want {
t.Errorf("%s: Next = %s, want %s", c.name, got, c.want)
}
}
if got := Next([]Entry{{Version: "0.1.20", Series: "0.1"}, {Version: "0.1.21-x.1", Series: "0.1", Tag: "x"}}, "0.1", known([]string{"0.1.3"}, nil, nil)); got != "0.1.21" {
t.Errorf("Next above the state's own = %s, want 0.1.21", got)
}
})
t.Run("decide", func(t *testing.T) {
pub := Entry{Name: "n", Kind: KindPackage, Series: "0.1", Version: "0.1.5", Revision: "r1", Fingerprint: "fA", State: Published}
ref := pub
ref.State, ref.Version, ref.Revision = Refused, "0.1.6", "r2"
img := Entry{Name: "i", Kind: KindBundle, Series: "0.1", Version: "0.1.2", Fingerprint: "fB", State: Published}
silent := Floor{Silent: []string{SourceSeat}}
for _, c := range []struct {
name string
entries []Entry
kind, fp, rev string
floor Floor
version string
keep, publish, unnum bool
wait bool
}{
{"a package's revision held published keeps its version and publishes nothing", []Entry{pub}, KindPackage, "fX", "r1", silent, "0.1.5", true, false, false, false},
{"a revision numbered but refused is published by its second build under the same version", []Entry{pub, ref}, KindPackage, "fY", "r2", known(nil, nil, nil), "0.1.6", true, true, false, false},
{"a new revision takes the next", []Entry{pub, ref}, KindPackage, "fZ", "r3", known(nil, nil, nil), "0.1.7", false, true, false, false},
{"a bundle's fingerprint keeps its number", []Entry{img}, KindBundle, "fB", "", silent, "0.1.2", true, false, false, false},
{"an unknown floor builds a bundle unnumbered", nil, KindBundle, "fC", "", silent, "", false, true, true, false},
{"an unknown floor builds an image unnumbered", nil, KindImage, "fC", "", silent, "", false, true, true, false},
{"an unknown floor makes a package wait", nil, KindPackage, "fC", "r9", silent, "", false, false, false, true},
} {
d := Decide(c.entries, c.kind, "0.1", c.fp, c.rev, c.floor)
if d.Version != c.version || d.Keep != c.keep || d.Publish != c.publish || d.Unnumbered != c.unnum || d.Wait != c.wait || d.Why == "" {
t.Errorf("%s: Decide = %+v", c.name, d)
}
}
})
t.Run("two claims never one number", func(t *testing.T) {
st := newMem()
v := Versions{Store: st, Now: func() time.Time { return time.Unix(0, 0) }}
ctx := context.Background()
st.before = func() { // a second claim takes 0.1.0 between the first's read and its create
if _, _, err := (Versions{Store: st}).Number(ctx, Claim{Name: "n", Kind: KindBundle, Fingerprint: "f2"}); err != nil {
t.Error(err)
}
}
d, e, err := v.Number(ctx, Claim{Name: "n", Kind: KindBundle, Fingerprint: "f1"})
if err != nil || d.Version != "0.1.1" || e.State != Numbered || e.Series != DefaultSeries {
t.Fatalf("Number = %+v, %+v, %v, want 0.1.1 after the other claim's 0.1.0", d, e, err)
}
if d, _, _ := v.Number(ctx, Claim{Name: "n", Kind: KindBundle, Fingerprint: "f1"}); !d.Keep || d.Version != "0.1.1" {
t.Errorf("a numbered fingerprint claimed again = %+v, want it kept", d)
}
got, err := v.Mark(ctx, "n", "0.1.1", func(e *Entry) { e.State = Published })
if err != nil || got.State != Published {
t.Errorf("Mark = %+v, %v", got, err)
}
if _, err := v.Mark(ctx, "n", "0.9.9", func(*Entry) {}); err == nil {
t.Error("marking a version never numbered is no error")
}
})
t.Run("recorded", func(t *testing.T) {
st := newMem()
v := Versions{Store: st}
ctx := context.Background()
if _, _, err := v.Number(ctx, Claim{Name: "n", Kind: KindImage, Fingerprint: "f", Digest: "sha256:aa", Floor: known(nil, nil, nil)}); err != nil {
t.Fatal(err)
}
for _, c := range []struct {
version, digest string
ok bool
}{{"0.1.0", "sha256:aa", true}, {"0.1.0", "sha256:bb", false}, {"0.1.7", "sha256:aa", false}} {
if _, ok, why, err := v.Recorded(ctx, "n", c.version, c.digest); err != nil || ok != c.ok || (!ok && why == "") {
t.Errorf("Recorded(%s, %s) = %v, %q, %v; want %v", c.version, c.digest, ok, why, err, c.ok)
}
}
// builder_versions answers the same through the tools bundle's read-only view.
r := Reader{Keys: func() ([]string, error) {
var all []string
for k := range st.vals {
all = append(all, k)
}
return all, nil
}, Get: func(k string) (json.RawMessage, bool, error) {
v, _, ok, err := st.Get(ctx, k)
return v, ok, err
}}
a, err := AnswerVersions(ctx, r, "n", "0.1.0", "sha256:bb")
if err != nil || len(a.Versions) != 1 || a.Recorded == nil || *a.Recorded || a.Why == "" {
t.Errorf("builder_versions = %+v, %v; want one version and not recorded for another digest", a, err)
}
if _, err := AnswerVersions(ctx, r, " ", "", ""); err == nil {
t.Error("builder_versions with no name answered")
}
})
t.Run("tagged", func(t *testing.T) {
es := []Entry{{Version: "0.1.14", Series: "0.1"}, {Version: "0.1.15-feat-x.1", Series: "0.1", Tag: "feat-x"}}
if got := Tagged(es, "0.1", "feat/X", known(nil, nil, nil)); got != "0.1.15-feat-x.2" {
t.Errorf("Tagged = %s, want 0.1.15-feat-x.2", got)
}
if got := Tagged(nil, "0.1", "feat-x", known(nil, nil, nil)); got != "0.1.0-feat-x.1" {
t.Errorf("a package new in a group = %s, want 0.1.0-feat-x.1", got)
}
if got := CleanTag("--Fix/ä_1965--"); got != "fix-1965" {
t.Errorf("CleanTag = %s", got)
}
})
t.Run("merge order", func(t *testing.T) {
es := []Entry{{Fingerprint: "f1", Version: "0.1.0", Series: "0.1"}}
for _, c := range []struct {
name string
merge uint64
held map[string]uint64
want uint64
}{
{"an earlier merge numbered waits on nothing", 5, map[string]uint64{"f1": 3}, 0},
{"an earlier merge not numbered is waited on", 5, map[string]uint64{"f1": 3, "f2": 4}, 4},
{"a replaced fingerprint the indexer no longer holds is skipped", 5, map[string]uint64{}, 0},
{"a later merge is not waited on", 5, map[string]uint64{"f3": 7}, 0},
} {
if got := WaitsOn(es, c.merge, c.held); got != c.want {
t.Errorf("%s: WaitsOn = %d, want %d", c.name, got, c.want)
}
}
})
t.Run("the floor wakes on its silent source, never a timer", func(t *testing.T) {
ctx := context.Background()
src := FloorSources{
Seat: func(context.Context, string, string) ([]string, error) { return []string{"0.1.3"}, nil },
Tags: func(context.Context, string, string) ([]string, error) { return nil, errors.New("no answer") },
Requirements: func(context.Context, string) ([]string, error) { return nil, nil },
}
f, why := src.Ask(ctx, FloorOf{Seat: "oci-artifact-registry", Name: "m/program"})
if f.Known() || !reflect.DeepEqual(f.Silent, []string{SourceTags}) || why[SourceTags] != "no answer" {
t.Fatalf("Ask = %+v, %v; want bridge_tags silent", f, why)
}
var fw FloorWaits
w := fw.Wait("m/program", "oci-artifact-registry", "distribution", f, why, time.Unix(0, 0))
if !strings.Contains(w.Finding(), "bridge_tags (no answer)") {
t.Errorf("finding = %q", w.Finding())
}
for _, c := range []struct {
kind, seat, module, name string
want []string
}{
{WakePublished, "oci-artifact-registry", "", "", nil}, // the seat answered already: a publish wakes nothing
{WakeAccepted, "", "", "", nil},
{WakeForge, "", "", "", []string{"m/program"}},
{WakeCleared, "", "mesh-delivery-forge-bridge", "", []string{"m/program"}}, // recovered with nothing to say
{WakeCleared, "", "mesh-delivery-indexer", "", nil},
{WakeByHand, "", "", "m/program", []string{"m/program"}},
{WakeByHand, "", "", "other", nil},
} {
if got := fw.Wake(c.kind, c.seat, c.module, c.name); !reflect.DeepEqual(got, c.want) {
t.Errorf("Wake(%s %s %s %s) = %v, want %v", c.kind, c.seat, c.module, c.name, got, c.want)
}
}
seatDown := Floor{Silent: []string{SourceSeat}}
fw.Wait("distribution/program", "container-image-registry", "distribution", seatDown, nil, time.Unix(0, 0))
if got := fw.Wake(WakePublished, "container-image-registry", "", ""); !reflect.DeepEqual(got, []string{"distribution/program"}) {
t.Errorf("the holder's published woke %v, want its own image's name", got)
}
if got := fw.Wake(WakeCleared, "", "distribution", ""); !reflect.DeepEqual(got, []string{"distribution/program"}) {
t.Errorf("the holder's clear woke %v", got)
}
if !fw.Answered("m/program") || fw.Answered("m/program") || len(fw.Waiting()) != 1 {
t.Error("Answered drops the wait once")
}
// DLV-B4: the waits are kept in floor-waits; a restarted builder takes them in, and its sources' events still wake them.
kept := keeperMem{}
restarted := &FloorWaits{}
restarted.Keep(kept, []FloorWait{{Name: "m/held", Seat: SeatImages, Silent: []string{SourceTags}, Since: time.Unix(5, 0)}})
if got := restarted.Wake(WakeForge, "", "", ""); !reflect.DeepEqual(got, []string{"m/held"}) {
t.Errorf("a wait held in floor-waits woke %v after a restart, want m/held", got)
}
restarted.Wait("m/new", SeatNPM, "", Floor{Silent: []string{SourceSeat}}, nil, time.Unix(9, 0))
ans, err := AnswerFloorWaits(Reader{Keys: kept.keys, Get: kept.get})
if err != nil || len(ans.Waits) != 2 || ans.Waits[0].Name != "m/held" || ans.Waits[1].Name != "m/new" || !strings.Contains(ans.Waits[1].Finding, "seat did not answer") {
t.Fatalf("builder_floor_waits = %+v, %v", ans, err)
}
restarted.Answered("m/held")
if _, ok := kept[FloorWaitKey("m/held")]; ok {
t.Error("an answered floor stays in floor-waits")
}
})
t.Run("old form", func(t *testing.T) {
// One kind, a first form and a newer one.
fs := forms.ArtifactForms{{Name: "registry", First: true, Kinds: []string{"image"}}, {Name: "oci", Kinds: []string{"image"}}}
both := []string{forms.FormNeed("oci")}
for _, c := range []struct {
name string
counted map[string][]string
dropped map[string]bool
want []string
}{
{"one node lacking the new form holds the old", map[string][]string{"a": both, "b": nil}, nil, []string{"b"}},
{"an away node counts, stating its last", map[string][]string{"a": both, "c": {"bus-state"}}, nil, []string{"c"}},
{"every counted node reads the newest: closed", map[string][]string{"a": both, "c": both}, nil, nil},
{"a retired node dropped by hand counts no more", map[string][]string{"a": both, "c": nil}, map[string]bool{"c": true}, nil},
{"the builder's own forms: no window", nil, nil, nil},
} {
if got := Holding(fs, c.counted, c.dropped); !reflect.DeepEqual(got, c.want) {
t.Errorf("%s: Holding = %v, want %v", c.name, got, c.want)
}
}
// The builder's own forms: the registry seat's form of a bundle is not a first form, so a node stating nothing
// holds the window and one stating form:seat-reference does not.
if got := Holding(forms.Current, map[string][]string{"a": nil, "b": {forms.FormNeed(forms.FormSeatReference)}}, nil); !reflect.DeepEqual(got, []string{"a"}) {
t.Errorf("forms.Current, a node stating nothing and one stating form:seat-reference: Holding = %v, want a", got)
}
t0 := time.Unix(0, 0)
w, closed, overdue := OldFormWindow{}.Step([]string{"c"}, t0)
if closed || len(overdue) != 0 || !w.Open {
t.Fatalf("Step = %+v %v %v", w, closed, overdue)
}
w, _, overdue = w.Step([]string{"c"}, t0.Add(FindingAfter))
if !reflect.DeepEqual(overdue, []string{"c"}) {
t.Errorf("seven days held: overdue %v, want c", overdue)
}
w, err := w.Drop("d", "op@shanks", "retired", t0)
if err != nil || !w.DroppedSet()["d"] {
t.Fatalf("Drop = %+v, %v", w, err)
}
if again, _ := w.Drop("d", "op@shanks", "again", t0); len(again.Dropped) != 1 {
t.Errorf("a node dropped twice is kept twice: %+v", again.Dropped)
}
if _, err := w.Drop("e", "op@shanks", "", t0); err == nil {
t.Error("a drop with no why is taken")
}
w, closed, _ = w.Step(nil, t0.Add(FindingAfter+time.Hour))
if !closed || w.Open || !reflect.DeepEqual(w.LastHolding, []string{"c"}) || w.Closing().Dropped[0].Node != "d" {
t.Errorf("closing: %+v closed=%v", w, closed)
}
raw, _ := json.Marshal(w)
if back, err := DecodeOldFormWindow(raw); err != nil || !back.Closed.Equal(w.Closed) {
t.Errorf("the window read back = %+v, %v", back, err)
}
if _, again, _ := w.Step(nil, t0.Add(FindingAfter+2*time.Hour)); again {
t.Error("a closed window closes again: old-form-closed would be said twice")
}
})
t.Run("at the claim", func(t *testing.T) {
ctx := context.Background()
tagsUp := false
var asked []string
src := FloorSources{
Seat: func(context.Context, string, string) ([]string, error) { return []string{"0.1.3"}, nil },
Tags: func(_ context.Context, repo, prefix string) ([]string, error) {
asked = append(asked, repo+" "+prefix)
if !tagsUp {
return nil, errors.New("no answer")
}
return []string{"go/v0.1.13"}, nil
},
Requirements: func(context.Context, string) ([]string, error) { return nil, nil },
}
var said []NumberedBody
fw := &FloorWaits{}
n := &Numberer{Versions: Versions{Store: newMem()}, Floor: src, Waits: fw, Said: func(b NumberedBody) { said = append(said, b) }}
c := BuildClaim{Module: "m", Path: "go", Repository: "novox/m", Fingerprint: "fp1", Commit: "c1", Revision: "7", Merge: 7,
Artifacts: []ClaimArtifact{{Artifact: "program", Kind: KindBundle, Language: "go"}, {Artifact: "base", Kind: "upstream"}}}
got, err := n.Number(ctx, c)
if err != nil || len(got.Versions) != 0 || !reflect.DeepEqual(got.Unnumbered, []string{"program"}) || got.Wait != "" {
t.Fatalf("silent floor: %+v, %v; want program unnumbered, the upstream copy left out", got, err)
}
if !reflect.DeepEqual(asked, []string{"novox/m go/v"}) || len(fw.Waiting()) != 1 || len(said) != 0 {
t.Fatalf("asked %v, waits %+v, said %v", asked, fw.Waiting(), said)
}
pkg := c
pkg.Artifacts = []ClaimArtifact{{Artifact: "lib", Kind: KindPackage, Language: "go"}}
if got, _ := n.Number(ctx, pkg); got.Wait == "" || len(got.Versions) != 0 {
t.Fatalf("a package with no floor = %+v; want it to wait", got)
}
tagsUp = true
got, err = n.Number(ctx, c)
if err != nil || got.Versions["program"] != "0.1.14" || len(said) != 1 || said[0].Version != "0.1.14" || len(fw.Waiting()) != 1 {
t.Fatalf("floor answered: %+v, %v, said %v, waits %+v; want 0.1.14 said once, lib still waiting", got, err, said, fw.Waiting())
}
// DLV-B6: a check's build of a head a tag ask names numbers only its packages, tagged below the next version, kept
// with its repository for the branch's end, said with the head; claimed again it keeps its number.
tagged := c
tagged.Tag, tagged.Head, tagged.Revision, tagged.Merge = "feat-x", "h1", "", 0
tagged.Artifacts = append(tagged.Artifacts, ClaimArtifact{Artifact: "tlib", Kind: KindPackage, Language: "go"})
saidBefore, askedBefore := said, asked
said = nil
got, err = n.Number(ctx, tagged)
if err != nil || len(got.Versions) != 1 || !strings.Contains(got.Versions["tlib"], "-feat-x.1") || len(said) != 1 || said[0].Head != "h1" || said[0].Tag != "feat-x" {
t.Fatalf("tagged claim: %+v, %v, said %+v; want tlib alone tagged, said with its head", got, err, said)
}
if es, _ := n.Versions.Of(ctx, "m/tlib"); len(es) != 1 || es[0].Repository != "novox/m" {
t.Errorf("the tagged version kept = %+v, want its repository", es)
}
if again, _ := n.Number(ctx, tagged); again.Versions["tlib"] != got.Versions["tlib"] || len(said) != 1 {
t.Errorf("claimed again: %+v, said %d; want the same number, said once", again, len(said))
}
said, asked = saidBefore, askedBefore
got, _ = n.Number(ctx, c)
if err := n.Built(ctx, c, got, map[string]string{"program": "sha256:aa"}); err != nil {
t.Fatal(err)
}
again, _ := n.Number(ctx, c)
if again.Versions["program"] != "0.1.14" || len(said) != 1 || len(asked) != 3 {
t.Fatalf("again: %+v, said %d, asked %v; want the number kept, nothing said, the floor not asked", again, len(said), asked)
}
if _, ok, why, _ := n.Versions.Recorded(ctx, "m/program", "0.1.14", "sha256:bb"); ok {
t.Fatalf("another digest recorded: %s", why)
}
// DLV-B4: the seat's events move the entry; a published wakes the names waiting on that seat.
if wake, err := n.Heard(ctx, SeatArtifacts, HeardPublished, "e1", Outcome{Name: "m/other", Digest: "sha256:cc"}); err != nil || len(wake) != 0 {
t.Fatalf("a by-digest publish of another seat's wait: %v, %v", wake, err)
}
n.Waits.Wait("m/lib", SeatGoPackages, "", Floor{Silent: []string{SourceSeat}}, nil, time.Unix(0, 0))
if wake, _ := n.Heard(ctx, SeatGoPackages, HeardPublished, "e2", Outcome{Name: "m/x", Digest: "sha256:dd"}); !reflect.DeepEqual(wake, []string{"m/lib"}) {
t.Fatalf("a published on the Go seat woke %v, want m/lib, whose seat was silent", wake)
}
for _, h := range []struct{ event, state, reason string }{{HeardRefused, Refused, "bytes differ"}, {HeardPublished, Published, ""}, {HeardRefused, Published, ""}, {HeardRemoved, Removed, "retention"}} {
if _, err := n.Heard(ctx, SeatArtifacts, h.event, "e3", Outcome{Name: "m/program", Version: "0.1.14", Digest: "sha256:aa", Reason: h.reason}); err != nil {
t.Fatal(err)
}
all, _ := n.Versions.Of(ctx, "m/program")
if all[0].State != h.state {
t.Fatalf("after %s: %+v, want %s", h.event, all[0], h.state)
}
}
if _, err := n.Heard(ctx, SeatArtifacts, HeardPublished, "e4", Outcome{Name: "m/none", Version: "0.1.0"}); err == nil {
t.Fatal("a published of a version the builder never numbered is not said")
}
})
t.Run("a package is numbered under its name in its ecosystem", func(t *testing.T) {
// design 32: the indexer's packages state names, in each series key, the package's name and the <module>/<artifact>
// it is built from; the builder numbers, asks the floor of and records the package under that name.
ctx := context.Background()
state := packagesMem{
"git=2enovox=2ebe/novox/mesh-sdk/go.0.1": []byte(`{"v":1,"name":"git.novox.be/novox/mesh-sdk/go","package":"mesh-sdk/go","series":"0.1"}`),
"@novox/mesh-sdk.0.1": []byte(`{"v":1,"name":"@novox/mesh-sdk","package":"mesh-sdk/ts","series":"0.1"}`),
"git=2enovox=2ebe/novox/mesh-sdk/go": []byte(`{"v":1,"module":"git.novox.be/novox/mesh-sdk/go"}`),
"broken.0.1": []byte(`not json`),
}
var floorOf []string
src := FloorSources{
Seat: func(_ context.Context, _, name string) ([]string, error) {
floorOf = append(floorOf, name)
return []string{"0.1.3"}, nil
},
Tags: func(context.Context, string, string) ([]string, error) { return nil, nil },
Requirements: func(_ context.Context, name string) ([]string, error) {
floorOf = append(floorOf, name)
return nil, nil
},
}
var said []NumberedBody
n := &Numberer{Versions: Versions{Store: newMem()}, Floor: src, Names: NamesIn(state), Said: func(b NumberedBody) { said = append(said, b) }}
c := BuildClaim{Module: "mesh-sdk", Path: "go", Repository: "novox/mesh-sdk", Fingerprint: "fp1", Commit: "c1", Revision: "3", Merge: 3,
Artifacts: []ClaimArtifact{{Artifact: "go", Kind: KindPackage, Language: "go"}, {Artifact: "ts", Kind: KindPackage, Language: "typescript"},
{Artifact: "new", Kind: KindPackage, Language: "go"}, {Artifact: "program", Kind: KindBundle, Language: "go"}}}
got, err := n.Number(ctx, c)
if err != nil || got.Versions["go"] != "0.1.4" || got.Versions["ts"] != "0.1.4" || !reflect.DeepEqual(got.Unnumbered, []string{"new"}) {
t.Fatalf("Number = %+v, %v; want go and ts numbered, new unnumbered (no name yet)", got, err)
}
if got.Names["go"] != "git.novox.be/novox/mesh-sdk/go" || got.Names["ts"] != "@novox/mesh-sdk" || got.Names["program"] != "mesh-sdk/program" {
t.Errorf("names = %v", got.Names)
}
if !slices.Contains(floorOf, "git.novox.be/novox/mesh-sdk/go") || slices.Contains(floorOf, "mesh-sdk/go") {
t.Errorf("the floor was asked of %v, want the Go module path", floorOf)
}
if es, _ := n.Versions.Of(ctx, "git.novox.be/novox/mesh-sdk/go"); len(es) != 1 || es[0].Version != "0.1.4" {
t.Errorf("versions of the Go module path = %+v", es)
}
if es, _ := n.Versions.Of(ctx, "mesh-sdk/go"); len(es) != 0 {
t.Errorf("numbered under the builder's name too: %+v", es)
}
names := map[string]bool{}
for _, b := range said {
names[b.Name] = true
}
if !names["git.novox.be/novox/mesh-sdk/go"] || !names["@novox/mesh-sdk"] {
t.Errorf("version.numbered said %v, want the ecosystem names", names)
}
if err := n.Built(ctx, c, got, map[string]string{"go": "sha256:aa"}); err != nil {
t.Fatal(err)
}
if es, _ := n.Versions.Of(ctx, "git.novox.be/novox/mesh-sdk/go"); len(es) != 1 || es[0].Digest != "sha256:aa" {
t.Errorf("the built digest is not recorded under the name: %+v", es)
}
tagged, _ := TagAsk{Head: "h", State: TagAsked}.WithNumbered("mesh-sdk", "h", map[string]string{"go": "0.1.4-x.1"}, got.Names, time.Unix(1, 0))
if tagged.Versions["git.novox.be/novox/mesh-sdk/go"] != "0.1.4-x.1" {
t.Errorf("a tag ask names %v, want the Go module path", tagged.Versions)
}
if _, _, err := NamesIn(packagesMem(nil).failing())(ctx, "m", "a"); err == nil {
t.Error("a packages state that cannot be read is no error")
}
// A packages state not on the bus yet leaves the package unnumbered, said, and numbers the rest of the claim.
n.Names = NamesIn(packagesMem(nil).failing())
bare, err := n.Number(ctx, c)
if err != nil || !reflect.DeepEqual(bare.Unnumbered, []string{"go", "new", "ts"}) || bare.Versions["program"] == "" ||
!strings.Contains(bare.Said[0].Line, "cannot be read") {
t.Errorf("an unreadable packages state: %+v, %v", bare, err)
}
})
t.Run("fill", func(t *testing.T) {
if _, full := Fill(Cap*79/100, Cap); full {
t.Error("79 % is full")
}
if _, full := Fill((Cap*80+99)/100, Cap); !full {
t.Error("80 % is not full")
}
})
t.Run("publish again", func(t *testing.T) {
num := Entry{Name: "m/p", Kind: KindBundle, Version: "0.1.3", Fingerprint: "f1", Digest: "sha256:aa", State: Numbered}
ref := num
ref.State, ref.RefusedDigest = Refused, "sha256:aa"
other := ref
other.RefusedDigest = "sha256:bb"
pub := num
pub.State = Published
gone := pub
gone.State = Removed
tagged := num
tagged.Tag, tagged.Version = "feat-x", "0.1.3-feat-x.1"
for _, c := range []struct {
name string
entries []Entry
fp string
built bool
do string
}{
{"a held build is said numbered again", []Entry{num}, "f1", true, AgainPublish},
{"refused for its own bytes is published again", []Entry{ref}, "f1", true, AgainPublish},
{"a gone file is built again under the same number", []Entry{ref}, "f1", false, AgainBuild},
{"after a published it takes a new number", []Entry{pub}, "f1", true, AgainRenumber},
{"after a removal it takes a new number", []Entry{gone}, "f1", false, AgainRenumber},
{"refused for other bytes is refused", []Entry{other}, "f1", true, ""},
{"a fingerprint not numbered is refused", []Entry{num}, "f2", true, ""},
{"a tagged version is not a trunk fingerprint's", []Entry{tagged}, "f1", true, ""},
} {
a, err := PublishAgain(c.entries, c.fp, c.built)
if (err != nil) != (c.do == "") || a.Do != c.do {
t.Errorf("%s: PublishAgain = %+v, %v; want %q", c.name, a, err, c.do)
}
}
ctx := context.Background()
// A fresh claim of a published fingerprint takes the next number; the old one stays.
st := newMem()
v := Versions{Store: st}
raw, _ := Encode(Entry{Name: "m/p", Kind: KindBundle, Series: "0.1", Version: "0.1.3", Fingerprint: "f1", State: Published, At: time.Unix(1, 0)})
_, _ = st.Create(ctx, Key("m/p", "0.1.3"), raw)
if d, _, err := v.Number(ctx, Claim{Name: "m/p", Kind: KindBundle, Fingerprint: "f1"}); err != nil || d.Version != "0.1.3" || !d.Keep {
t.Fatalf("a published fingerprint keeps its number: %+v %v", d, err)
}
if d, e, err := v.Number(ctx, Claim{Name: "m/p", Kind: KindBundle, Fingerprint: "f1", Fresh: true}); err != nil || d.Version != "0.1.4" || d.Keep || e.Fingerprint != "f1" {
t.Fatalf("a fresh claim takes the next number: %+v %+v %v", d, e, err)
}
// A refusal records the digest refused; Built replaces a digest no published was said for, never a published one.
n := &Numberer{Versions: v}
_, _ = n.Heard(ctx, SeatArtifacts, HeardRefused, "r1", Outcome{Name: "m/p", Version: "0.1.4", Digest: "sha256:old", Reason: "x"})
if es, _ := v.Of(ctx, "m/p"); es[1].RefusedDigest != "sha256:old" {
t.Errorf("refused records no digest: %+v", es[1])
}
_, _ = v.Mark(ctx, "m/p", "0.1.4", func(e *Entry) { e.Digest = "sha256:old" })
bc := BuildClaim{Module: "m"}
_ = n.Built(ctx, bc, Numbering{Versions: map[string]string{"p": "0.1.4"}}, map[string]string{"p": "sha256:new"})
_, _ = v.Mark(ctx, "m/p", "0.1.3", func(e *Entry) { e.Digest = "sha256:held" })
_ = n.Built(ctx, bc, Numbering{Versions: map[string]string{"p": "0.1.3"}}, map[string]string{"p": "sha256:other"})
es, _ := v.Of(ctx, "m/p")
if es[0].Digest != "sha256:held" || es[1].Digest != "sha256:new" {
t.Errorf("Built: published %s (want sha256:held), refused %s (want sha256:new)", es[0].Digest, es[1].Digest)
}
if all, err := v.All(ctx); err != nil || len(all) != 2 {
t.Errorf("All = %d entries, %v", len(all), err)
}
// The hand acts: a held build is said numbered once more under a fresh dedup id; a gone one is built again; a
// published one takes a new number; a name with versions asks no floor.
if _, _, err := (Acts{Numberer: &Numberer{Versions: v}, Done: func(context.Context, string, string) (bool, error) { return true, nil }}).PublishAgain(ctx, "m/p", "f1", "w", "", time.Unix(4, 0)); err == nil {
t.Error("a version refused for other bytes is published again")
}
_, _ = v.Mark(ctx, "m/p", "0.1.4", func(e *Entry) { e.State = Published })
var said []NumberedBody
var rebuilt []string
done := true
acts := Acts{Numberer: &Numberer{Versions: v, Said: func(b NumberedBody) { said = append(said, b) }, Waits: &FloorWaits{}},
Done: func(context.Context, string, string) (bool, error) { return done, nil },
BuildAgain: func(_ context.Context, m, fp, _, _ string) (string, bool) {
rebuilt = append(rebuilt, m+" "+fp)
return "built again", false
},
Reconsider: func(context.Context) {}}
if ag, _, err := acts.PublishAgain(ctx, "m/p", "f1", "w", "me", time.Unix(5, 0)); err != nil || ag.Do != AgainRenumber || ag.Entry.Version != "0.1.5" ||
len(said) != 1 || said[0].Version != "0.1.5" || said[0].Again == "" || len(rebuilt) != 0 {
t.Errorf("publish again of a published, held build: %+v %v said %+v rebuilt %v", ag, err, said, rebuilt)
}
done = false
if ag, _, err := acts.PublishAgain(ctx, "m/p", "f1", "w", "me", time.Unix(6, 0)); err != nil || ag.Do != AgainBuild || ag.Entry.Version != "0.1.5" || len(rebuilt) != 1 || len(said) != 1 {
t.Errorf("publish again of a gone build: %+v %v said %d rebuilt %v", ag, err, len(said), rebuilt)
}
if _, err := acts.FloorAgain(ctx, "m/p"); err == nil {
t.Error("a name with versions asks its floor again")
}
if ans, err := acts.FloorAgain(ctx, "m/new"); err != nil || ans == "" {
t.Errorf("floor again of a new name: %q %v", ans, err)
}
})
t.Run("tag asks", func(t *testing.T) {
at := time.Unix(100, 0)
k, raw, err := NewTagAsk("novox/mesh-sdk", "feat/X y", "abc", "a check", "me@shanks", at)
if err != nil || k != TagAskKey("novox/mesh-sdk", "feat/X y") || strings.Count(k, ".") != 1 {
t.Fatalf("NewTagAsk = %q, %v", k, err)
}
a, err := DecodeTagAsk(raw)
if err != nil || a.Tag != "feat-x-y" || a.State != TagAsked || a.Untag {
t.Fatalf("DecodeTagAsk = %+v, %v", a, err)
}
for _, bad := range [][4]string{{"mesh-sdk", "b", "h", "w"}, {"o/r", "", "h", "w"}, {"o/r", "b", "", "w"}, {"o/r", "b", "h", ""}} {
if _, _, err := NewTagAsk(bad[0], bad[1], bad[2], bad[3], "", at); err == nil {
t.Errorf("NewTagAsk%v is not refused", bad)
}
}
_, uraw, err := NewUntag("o/r", "feat/x", "branch gone by hand", "", at.Add(time.Second))
if u, _ := DecodeTagAsk(uraw); err != nil || !u.Untag {
t.Fatalf("NewUntag = %+v, %v", u, err)
}
vals := map[string][]byte{"a": uraw, "b": raw}
r := Reader{Keys: func() ([]string, error) { return []string{"a", "b"}, nil },
Get: func(k string) (json.RawMessage, bool, error) { return vals[k], true, nil }}
if got, err := AnswerTagAsks(r, ""); err != nil || len(got.Asks) != 2 || got.Asks[0].Repository != "novox/mesh-sdk" {
t.Errorf("AnswerTagAsks = %+v, %v", got, err)
}
if got, _ := AnswerTagAsks(r, "o/r"); len(got.Asks) != 1 || !got.Asks[0].Untag {
t.Errorf("AnswerTagAsks(o/r) = %+v", got)
}
es := []Entry{{Name: "n", Version: "0.1.2-feat-x.1", Tag: "feat-x", Series: "0.1", Repository: "o/r"}, {Name: "n", Version: "0.1.2-feat-x.2", Tag: "feat-x", Series: "0.1", State: Removed, Repository: "o/r"},
{Name: "n2", Version: "0.1.2-feat-x.1", Tag: "feat-x", Series: "0.1", Repository: "o/other"},
{Name: "n", Version: "0.1.1", Series: "0.1"}, {Name: "n", Version: "0.1.2-feat-y.1", Tag: "feat-y", Series: "0.1"}}
for _, c := range []struct {
asks []TagAsk
repo, head string
want string
}{
{[]TagAsk{a}, "novox/mesh-sdk", "abc", "feat-x-y"},
{[]TagAsk{a}, "novox/mesh-sdk", "other", ""},
{[]TagAsk{{Repository: "novox/mesh-sdk", Head: "abc", Tag: "t", State: TagRemoved}}, "novox/mesh-sdk", "abc", ""},
} {
if got, _ := TagFor(c.asks, c.repo, c.head); got != c.want {
t.Errorf("TagFor(%s, %s) = %q, want %q", c.repo, c.head, got, c.want)
}
}
if got := Untagged(es, "o/r", "feat/x"); len(got) != 1 || got[0].Version != "0.1.2-feat-x.1" {
t.Errorf("Untagged = %+v", got)
}
st := newMem()
for _, e := range append(es, Entry{Name: "n3", Version: "0.1.0-feat-x.1", Tag: "feat-x", Series: "0.1", Repository: "o/r", Seat: SeatNPM}) {
e.Kind, e.Module, e.Fingerprint, e.At = KindPackage, "m", "f", at
if e.State == "" {
e.State = Numbered
}
raw, err := Encode(e)
if err != nil {
t.Fatal(err)
}
_, _ = st.Create(context.Background(), Key(e.Name, e.Version), raw)
}
var removed []string
acts := Acts{Numberer: &Numberer{Versions: Versions{Store: st}}, Remove: func(_ context.Context, seat string, e Entry) error {
removed = append(removed, e.Name+"@"+e.Version)
if seat == SeatNPM {
return errors.New("down")
}
return nil
}}
u, _ := DecodeTagAsk(uraw)
got := acts.TagAsked(context.Background(), u, at)
if got.State != TagRemoving || len(removed) != 2 || len(got.Left) != 2 || !strings.Contains(got.Left["n3@0.1.0-feat-x.1"], "down") || got.Answered.IsZero() {
t.Errorf("untag: %+v removed %v", got, removed)
}
if k := acts.TagAsked(context.Background(), a, at); k.State != TagAsked || k.Answer == "" {
t.Errorf("a tag ask is not kept: %+v", k)
}
// DLV-B6: a branch's end asks remove of every tagged version not removed, and is done once each seat said removed.
removed = nil
end, err := acts.BranchEnded(context.Background(), "o/r", "feat/x")
if err != nil || end.Done || len(removed) != 2 || !reflect.DeepEqual(end.Seats(), []string{"", SeatNPM}) || !strings.Contains(end.Finding("o/r", "feat/x", at), "n3@0.1.0-feat-x.1 on npm-package-registry: down") {
t.Fatalf("BranchEnded = %+v %v, removed %v", end, err, removed)
}
for _, v := range [][2]string{{"n", "0.1.2-feat-x.1"}, {"n3", "0.1.0-feat-x.1"}} {
if _, err := acts.Numberer.Heard(context.Background(), SeatNPM, HeardRemoved, "rm", Outcome{Name: v[0], Version: v[1], Reason: "tag-gone"}); err != nil {
t.Fatal(err)
}
}
if end, _ := acts.BranchEnded(context.Background(), "o/r", "feat/x"); !end.Done {
t.Errorf("every seat said removed, and the branch's end is not done: %+v", end)
}
// B6: a check's build of the ask's head marks it numbered with its versions; another head, or an untag, is left.
if !a.Unanswered() {
t.Errorf("a kept ask with no version is answered: %+v", a)
}
if _, changed := a.WithNumbered("mesh-sdk", "other", map[string]string{"go": "0.1.15-feat-x-y.1"}, nil, at); changed {
t.Error("an ask is marked numbered by a build of another head")
}
if _, changed := u.WithNumbered("o", "", map[string]string{"go": "1"}, nil, at); changed {
t.Error("an untag is marked numbered")
}
n, changed := a.WithNumbered("mesh-sdk", "abc", map[string]string{"go": "0.1.15-feat-x-y.1"}, nil, at.Add(time.Minute))
if !changed || n.State != TagNumbered || n.Versions["mesh-sdk/go"] != "0.1.15-feat-x-y.1" || n.Unanswered() || !strings.Contains(n.Answer, "mesh-sdk/go 0.1.15-feat-x-y.1") {
t.Errorf("WithNumbered = %+v, %v", n, changed)
}
if _, again := n.WithNumbered("mesh-sdk", "abc", map[string]string{"go": "0.1.15-feat-x-y.1"}, nil, at); again {
t.Error("the same versions mark the ask again")
}
said := NarrateUnanswered(a, []FloorWait{{Name: "mesh-sdk/go", Silent: []string{"bridge_tags"}}}, at.Add(6*time.Minute))
for _, w := range []string{"novox/mesh-sdk feat/X y", "after 6m0s", "not claimed yet", "mesh-sdk/go waits on its floor (bridge_tags silent)"} {
if !strings.Contains(said, w) {
t.Errorf("NarrateUnanswered = %q, want %q in it", said, w)
}
}
if k, v, err := TagWantedAsk(TagWanted{Repository: "o/r", Branch: "feat/x", Head: "abc"}, "mesh-delivery-checker", at); err != nil || k != TagAskKey("o/r", "feat/x") || !strings.Contains(string(v), `"by":"mesh-delivery-checker"`) {
t.Errorf("TagWantedAsk = %q %s %v", k, v, err)
}
})
}
+24 -9
View File
@@ -22,6 +22,7 @@ import (
type state struct {
Name string `json:"name"`
History int `json:"history,omitempty"`
KeyTTL bool `json:"key-ttl,omitempty"`
}
type artifact struct {
@@ -93,13 +94,13 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1
if m.Module != "mesh-delivery-builder" {
t.Errorf("module = %q", m.Module)
}
if want := []state{{Name: "builds", History: 5}, {Name: "hearing", History: 1}, {Name: "instances", History: 1}, {Name: "queues", History: 1}, {Name: "reasks", History: 1}, {Name: "first-views", History: 1}, {Name: "findings", History: 1}}; !reflect.DeepEqual(m.State, want) { // delivery.BuilderBuildsHistory
if want := []state{{Name: "builds", History: 5}, {Name: "engine-reads", History: 1}, {Name: "findings", History: 5}, {Name: "floor-waits", History: 1}, {Name: "hearing", History: 1}, {Name: "instances", History: 1}, {Name: "old-form", History: 1}, {Name: "queues", History: 1}, {Name: "reasks", History: 1}, {Name: "tag-ask-bounds", History: 1, KeyTTL: true}, {Name: "tag-asks", History: 1}, {Name: "versions", History: 1}, {Name: "first-views", History: 1}}; !reflect.DeepEqual(m.State, want) { // delivery.BuilderBuildsHistory
t.Errorf("state = %+v, want %+v", m.State, want)
}
// 1887/A4c: the builder chooses no node, so it reads neither presence nor have, invokes no build module's tool and
// holds no capacity; it puts asks on the three build seats and reads their steps (ADR 0525 §1, §4, §5).
if want := []string{"mesh-build-go.steps", "mesh-build-go.toolchains", "mesh-build-image.steps", "mesh-build-node.steps", "mesh-build-node.toolchains",
"mesh-delivery-gate.source"}; !reflect.DeepEqual(sorted(m.Reads), want) {
"mesh-delivery-gate.source", "mesh-delivery-indexer.packages"}; !reflect.DeepEqual(sorted(m.Reads), want) {
t.Errorf("reads = %v, want %v", m.Reads, want)
}
if want := []string{"node-build-go", "node-build-image", "node-build-node"}; !reflect.DeepEqual(sorted(m.Uses), want) {
@@ -111,19 +112,31 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1
// DLV-M1 (ADR 0615 §1): the indexer's modules, manifests and candidates and the assigner's placement are asked by their
// read tools and followed by their views families, never read.
for _, r := range m.Reads {
if strings.HasPrefix(r, "mesh-delivery-indexer.") || strings.HasPrefix(r, "mesh-delivery-assigner.") {
if (strings.HasPrefix(r, "mesh-delivery-indexer.") || strings.HasPrefix(r, "mesh-delivery-assigner.")) && r != "mesh-delivery-indexer.packages" {
t.Errorf("reads %s: the indexer's and the assigner's state is read by their tools and views families alone", r)
}
}
if want := []string{"mesh-delivery-assigner.assigner_read_placement", "mesh-delivery-indexer.indexer_read_candidates", "mesh-delivery-indexer.indexer_read_manifests",
"mesh-delivery-indexer.indexer_read_modules", "seat:mesh-artifact-sweep.held"}; !reflect.DeepEqual(sorted(m.Invokes), want) || len(m.Config) != 0 {
// Design 51 *The floor*: the floor's three sources, the registry seats' versions, bridge_tags and indexer_requirements.
if want := []string{"mesh-delivery-assigner.assigner_read_placement", "mesh-delivery-forge-bridge.bridge_tags", "mesh-delivery-indexer.indexer_read_candidates", "mesh-delivery-indexer.indexer_read_manifests",
"mesh-delivery-indexer.indexer_read_modules", "mesh-delivery-indexer.indexer_requirements", "seat:container-image-registry.remove", "seat:container-image-registry.versions",
"seat:go-package-registry.remove", "seat:go-package-registry.versions", "seat:mesh-artifact-sweep.held", "seat:node-engine.manifest-reads", "seat:npm-package-registry.remove",
"seat:npm-package-registry.versions", "seat:oci-artifact-registry.remove", "seat:oci-artifact-registry.versions"}; !reflect.DeepEqual(sorted(m.Invokes), want) || len(m.Config) != 0 {
t.Errorf("invokes = %v, config = %v, want %v and no config", m.Invokes, m.Config, want)
}
// The builder is woken by a build module's report of a toolchain, as the indexer is: the bucket may not exist when it
// starts; and by the seat mesh-artifact-sweep's artifacts-gone, a blob the sweep found gone (1937/AC4, 1955/L7), the
// controller's no longer (1126/B9, ADR 0590).
if want := []string{"mesh-artifact-sweep.artifacts-gone", "mesh-build-go.toolchain.reported", "mesh-build-node.toolchain.reported",
"mesh-delivery-assigner.views.placement", "mesh-delivery-indexer.views.candidates", "mesh-delivery-indexer.views.manifests", "mesh-delivery-indexer.views.modules"}; !reflect.DeepEqual(sorted(m.Consumes), want) {
// Design 51 *The floor*: the indexer's manifest-accepted and the condition keeper's condition-cleared wake a silent floor.
// DLV-B4: the four registry seats' outcomes, each a seat event `<seat>.<event>` on the shared events stream.
// DLV-M1: the indexer's and the assigner's views families of the states it follows.
if want := []string{"container-image-registry.published", "container-image-registry.refused", "container-image-registry.removed",
"go-package-registry.published", "go-package-registry.refused", "go-package-registry.removed",
"mesh-artifact-sweep.artifacts-gone", "mesh-build-go.toolchain.reported", "mesh-build-node.toolchain.reported",
"mesh-conditions.condition-cleared", "mesh-delivery-assigner.views.placement", "mesh-delivery-checker.tag-wanted", "mesh-delivery-forge-bridge.forge.**",
"mesh-delivery-indexer.manifest-accepted", "mesh-delivery-indexer.views.candidates", "mesh-delivery-indexer.views.manifests", "mesh-delivery-indexer.views.modules",
"node-engine.manifest-reads-changed",
"npm-package-registry.published", "npm-package-registry.refused", "npm-package-registry.removed",
"oci-artifact-registry.published", "oci-artifact-registry.refused", "oci-artifact-registry.removed"}; !reflect.DeepEqual(sorted(m.Consumes), want) {
t.Errorf("consumes = %v, want %v", m.Consumes, want)
}
if b, ok := m.OwnSecrets["broker"]; !ok || len(m.OwnSecrets) != 1 || b != "${dir:state}/broker" {
@@ -133,10 +146,12 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1
t.Errorf("secrets-owner = %q", m.SecretsOwner)
}
// 1945: the builder's tools (builder_seat_queue: note 62), served by its tools bundle, and the event it says when it asks an ask again.
if want := []string{"builder_announce_again", "builder_ask_again", "builder_build_again", "builder_builds_since", "builder_first_view_again", "builder_first_views", "builder_seat_queue", "builder_stuck"}; !reflect.DeepEqual(sorted(m.Tools), want) {
if want := []string{"builder_announce_again", "builder_ask_again", "builder_build_again", "builder_builds_since", "builder_engine_reads", "builder_first_view_again", "builder_first_views",
"builder_floor_again", "builder_floor_waits", "builder_old_form", "builder_publish_again", "builder_seat_queue", "builder_stuck", "builder_tag", "builder_tag_asks", "builder_untag", "builder_versions"}; !reflect.DeepEqual(sorted(m.Tools), want) {
t.Errorf("tools = %v, want %v", m.Tools, want)
}
if want := []string{"build.asked-again"}; !reflect.DeepEqual(m.Emits, want) {
// Design 51 *Events of the builder*: numbered and old-form-closed on the family version, apart from the builds' ends.
if want := []string{"build.asked-again", "version.numbered", "version.old-form-closed"}; !reflect.DeepEqual(m.Emits, want) {
t.Errorf("emits = %v, want %v", m.Emits, want)
}
// 1955/L7 (ADR 0571 §1): its module log, each build's start and end, kept 30 days; no bare stream.
+50 -4
View File
@@ -7,25 +7,64 @@
"builder_ask_again",
"builder_build_again",
"builder_builds_since",
"builder_engine_reads",
"builder_floor_again",
"builder_floor_waits",
"builder_old_form",
"builder_publish_again",
"builder_seat_queue",
"builder_stuck",
"builder_tag",
"builder_tag_asks",
"builder_untag",
"builder_versions",
"builder_first_views",
"builder_first_view_again"
],
"emits": [
"build.asked-again"
"build.asked-again",
"version.numbered",
"version.old-form-closed"
],
"consumes": [
"container-image-registry.published",
"container-image-registry.refused",
"container-image-registry.removed",
"go-package-registry.published",
"go-package-registry.refused",
"go-package-registry.removed",
"mesh-artifact-sweep.artifacts-gone",
"mesh-build-go.toolchain.reported",
"mesh-build-node.toolchain.reported",
"mesh-conditions.condition-cleared",
"mesh-delivery-checker.tag-wanted",
"mesh-delivery-forge-bridge.forge.**",
"mesh-delivery-indexer.manifest-accepted",
"node-engine.manifest-reads-changed",
"npm-package-registry.published",
"npm-package-registry.refused",
"npm-package-registry.removed",
"oci-artifact-registry.published",
"oci-artifact-registry.refused",
"oci-artifact-registry.removed",
"mesh-delivery-assigner.views.placement",
"mesh-delivery-indexer.views.candidates",
"mesh-delivery-indexer.views.manifests",
"mesh-delivery-indexer.views.modules"
],
"invokes": [
"mesh-delivery-forge-bridge.bridge_tags",
"mesh-delivery-indexer.indexer_requirements",
"seat:container-image-registry.remove",
"seat:container-image-registry.versions",
"seat:go-package-registry.remove",
"seat:go-package-registry.versions",
"seat:mesh-artifact-sweep.held",
"seat:node-engine.manifest-reads",
"seat:npm-package-registry.remove",
"seat:npm-package-registry.versions",
"seat:oci-artifact-registry.remove",
"seat:oci-artifact-registry.versions",
"mesh-delivery-assigner.assigner_read_placement",
"mesh-delivery-indexer.indexer_read_candidates",
"mesh-delivery-indexer.indexer_read_manifests",
@@ -33,12 +72,18 @@
],
"state": [
{ "name": "builds", "history": 5 },
{ "name": "engine-reads", "history": 1 },
{ "name": "findings", "history": 5 },
{ "name": "floor-waits", "history": 1 },
{ "name": "hearing", "history": 1 },
{ "name": "instances", "history": 1 },
{ "name": "old-form", "history": 1 },
{ "name": "queues", "history": 1 },
{ "name": "reasks", "history": 1 },
{ "name": "first-views", "history": 1 },
{ "name": "findings", "history": 1 }
{ "name": "tag-ask-bounds", "history": 1, "key-ttl": true },
{ "name": "tag-asks", "history": 1 },
{ "name": "versions", "history": 1 },
{ "name": "first-views", "history": 1 }
],
"logs": [
{ "name": "build-log", "max-age-days": 30 }
@@ -49,7 +94,8 @@
"mesh-build-image.steps",
"mesh-build-node.steps",
"mesh-build-node.toolchains",
"mesh-delivery-gate.source"
"mesh-delivery-gate.source",
"mesh-delivery-indexer.packages"
],
"uses": [
"node-build-go",
@@ -214,7 +214,13 @@ func (e EngineCompositions) inSandbox(ctx context.Context, c *Case, head, node s
delete(verdicts, name) // a manifest the change brings is the checker's own judge's (ADR 0578 §2)
}
}
req := composer.Request{V: composer.Version, Input: a.Input, Depth: composer.DepthCompose, Machines: []string{node},
// The answer carries its input as the object the seat's contract names, decoded with its numbers as written
// (json.Number), so encoding it again gives the composer the same values the engine sent.
input, err := json.Marshal(a.Input)
if err != nil {
return a, fmt.Errorf("the engine of %s answered a composition input that cannot be encoded again: %w", node, err)
}
req := composer.Request{V: composer.Version, Input: input, Depth: composer.DepthCompose, Machines: []string{node},
Cases: []composer.Case{{Name: "change", Manifests: manifests, Verdicts: verdicts}}}
raw, err := json.Marshal(req)
if err != nil {
@@ -143,7 +143,7 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
}
host := CaseOf(c, map[string]string{repoA: trunkA, nodeEngineRepo: trunkA})
withInput := composed("anchor")
withInput.Input = json.RawMessage(`{"nodes":[{"name":"anchor"}]}`)
withInput.Input = map[string]any{"nodes": []any{map[string]any{"name": "anchor"}}}
e := &fakeEngines{answers: map[string]delivery.ComposeCandidateAnswer{"anchor": withInput}}
sb := &fakeSandbox{v: func(node string) composer.Verdict {
return composer.Verdict{V: composer.Version, Cases: []composer.CaseVerdict{{Name: "change",
@@ -162,7 +162,7 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
var req composer.Request
_ = json.Unmarshal(job.Request, &req)
if job.Fetch["repository"] != nodeEngineRepo || job.Fetch["commit"] != head2 || job.Package != composer.Package ||
string(req.Input) != string(withInput.Input) || req.Cases[0].Manifests["web"].Digest == "" {
string(req.Input) != `{"nodes":[{"name":"anchor"}]}` || req.Cases[0].Manifests["web"].Digest == "" {
t.Errorf("job = %+v, request %+v", job, req)
}
if v.State != delivery.CompositionFail || v.SDK != head2 || !strings.Contains(v.Words, "the new composer refuses it") {
@@ -0,0 +1,42 @@
// Command mesh-delivery-forge-bridge-tools serves the forge bridge's tools (novox/hq design 51): bridge_tags, a
// package's git tags read from the forge through the forge module's gitea_api, for the builder's floor. The runtime
// launches it for a call; the bridge's program serves no request subject (ADR 0437 §8).
package main
import (
"encoding/json"
"fmt"
"os"
"strings"
stdio "git.novox.be/novox/mesh-sdk/go"
"mesh-delivery-forge-bridge/internal/bridge"
)
// tools are the bridge's tools over api.
func tools(api bridge.API) []stdio.Tool {
str := func(args map[string]any, k string) string { s, _ := args[k].(string); return strings.TrimSpace(s) }
return []stdio.Tool{{
Name: bridge.TagsTool,
Description: "A package's git tags on the forge, for the builder's floor (design 51): every tag of repository whose name starts " +
"with prefix (go/v for a Go package in the go directory, v for one at the root), sorted. Read-only; read from the forge " +
"through the forge module's gitea_api, at most " + fmt.Sprint(bridge.TagsMaxPages*bridge.TagsPageSize) + " tags (more says so).",
Input: map[string]any{"type": "object", "required": []string{"repository"}, "properties": map[string]any{
"repository": map[string]any{"type": "string", "description": "<owner>/<name>"},
"prefix": map[string]any{"type": "string", "description": "only tags starting with it"}}},
Run: func(args map[string]any) (any, error) {
return bridge.Tags(api, str(args, "repository"), str(args, "prefix"))
},
}}
}
func main() {
api := func(path string) (json.RawMessage, error) {
return stdio.Ask("gitea.gitea_api", map[string]any{"path": path, "method": "GET"})
}
if err := stdio.Serve("", tools(api)); err != nil {
fmt.Fprintf(os.Stderr, "[mesh-delivery-forge-bridge-tools] %v\n", err)
os.Exit(1)
}
}
@@ -118,6 +118,7 @@ accounts {
subscribe { allow: [
"_INBOX.n1.mesh-delivery-forge-bridge.>"
"mesh.assignment.n1.mesh-delivery-forge-bridge"
"mesh.mod.gitea.event.forge.delete"
"mesh.mod.gitea.event.pull.closed"
"mesh.mod.gitea.event.pull.merged"
"mesh.mod.gitea.event.pull.updated"
@@ -235,7 +236,7 @@ func startBed(t *testing.T, openReply string, prepare func(ctx context.Context,
}
if _, err := js.CreateOrUpdateConsumer(ctx, "EVENTS", jetstream.ConsumerConfig{
Durable: "n1_mesh-delivery-forge-bridge", AckPolicy: jetstream.AckExplicitPolicy, DeliverPolicy: jetstream.DeliverNewPolicy,
FilterSubjects: []string{"mesh.mod.gitea.event.pull.updated", "mesh.mod.gitea.event.pull.merged", "mesh.mod.gitea.event.pull.closed"},
FilterSubjects: []string{"mesh.mod.gitea.event.pull.updated", "mesh.mod.gitea.event.pull.merged", "mesh.mod.gitea.event.pull.closed", "mesh.mod.gitea.event.forge.delete"},
}); err != nil {
t.Fatal(err)
}
@@ -293,7 +294,8 @@ func startBed(t *testing.T, openReply string, prepare func(ctx context.Context,
// TestBridgeWritesPullMessages starts the program the way the node does (its entry, run, with MESH_BROKER_FILE).
func TestBridgeWritesPullMessages(t *testing.T) { // 1235/AC2
_, js, ctx, _ := startBridge(t, `{"result":{"pulls":[]}}`)
ctl, js, ctx, _ := startBridge(t, `{"result":{"pulls":[]}}`)
defer branchDeletedCase(t, ctl.ConnectedUrl(), js, ctx)
// gitea.pull.updated as the forge module publishes it: the body of trunk.ts:233-250 on mesh.mod.gitea.event.pull.updated.
ev, _ := json.Marshal(map[string]any{"owner": "novox", "repo": "mesh-delivery", "number": 12, "title": "t", "body": "b", "base": "main",
@@ -346,6 +348,42 @@ func TestBridgeWritesPullMessages(t *testing.T) { // 1235/AC2
t.Errorf("the event is not acknowledged: %+v", ci)
}
// branchDeletedCase is DLV-FB1's case of the bridge's whole-module test (design 51): the forge module's forge.delete of
// a branch is a branch-deleted message on FORGE, and a forge.delete from another account than the forge module's is
// refused by the bus.
func branchDeletedCase(t *testing.T, srv string, js jetstream.JetStream, ctx context.Context) {
t.Helper()
ev, _ := json.Marshal(map[string]any{"owner": "novox", "repo": "mesh-sdk", "ref": "feat/x", "ref_type": "branch", "sha": head})
if _, err := js.Publish(ctx, "mesh.mod.gitea.event.forge.delete", ev); err != nil {
t.Fatal(err)
}
forge, err := js.Stream(ctx, "FORGE")
if err != nil {
t.Fatal(err)
}
subject := "mesh.stream.mesh-delivery-forge-bridge.event.forge.novox.mesh-sdk.branch-deleted"
var raw *jetstream.RawStreamMsg
for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
if raw, err = forge.GetLastMsgForSubject(ctx, subject); err == nil {
break
}
}
if err != nil || !strings.Contains(string(raw.Data), `"branch":"feat/x"`) {
t.Fatalf("no branch-deleted message on FORGE within 20 s: %v", err)
}
other, err := nats.Connect(srv, nats.UserInfo("n1.mesh-delivery-forge-bridge", "pw"))
if err != nil {
t.Fatal(err)
}
defer other.Close()
ojs, _ := jetstream.New(other)
octx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
if _, err := ojs.Publish(octx, "mesh.mod.gitea.event.forge.delete", ev); err == nil {
t.Error("a forge.delete from the bridge's account was taken by the bus: only the forge module's account may say it")
}
}
const merge = "89abcdef0123456789abcdef0123456789abcdef"
// TestBridgeWritesMergedMessage has a stand-in answer the forge's gitea_commit_trees on its tool subject
@@ -68,8 +68,62 @@ const (
UpdatedSubject = "mesh.mod.gitea.event.pull.updated"
ClosedSubject = "mesh.mod.gitea.event.pull.closed"
MergedSubject = "mesh.mod.gitea.event.pull.merged"
// DeletedSubject is the forge module's forge.delete: a branch or a tag deleted (mesh-catalog modules/gitea
// webhook.ts: owner, repo, ref, ref_type, sha). Only the forge module's account may publish it, the subject being
// in its own namespace (design 32 § 2), so no other module can end a branch's tagged versions.
DeletedSubject = "mesh.mod.gitea.event.forge.delete"
)
// ForgeBranchDeleted is the kind of a branch-deleted message on FORGE (novox/hq design 51 *The forge bridge says a
// branch's end*): the builder removes the branch's tagged versions on it, as on a pull request's merged or closed.
// Every existing consumer of FORGE ignores a kind it does not know.
const ForgeBranchDeleted = "branch-deleted"
// BranchDeleted is the body of a branch-deleted message.
type BranchDeleted struct {
V int `json:"v"`
Kind string `json:"kind"`
Repository string `json:"repository"`
Branch string `json:"branch"`
// Head is the commit the branch named when it was deleted, when the forge said it.
Head string `json:"head,omitempty"`
}
// deleted is the part of forge.delete the bridge reads.
type deleted struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Ref string `json:"ref"`
RefType string `json:"ref_type"`
SHA string `json:"sha"`
}
// branchDeleted turns forge.delete into a branch-deleted message; a deleted tag is not handled.
func branchDeleted(subject string, data []byte) (Message, error) {
var d deleted
if err := json.Unmarshal(data, &d); err != nil {
return Message{}, unreadable(subject, "the body is not json: %v", err)
}
if d.RefType != "branch" {
return Message{}, fmt.Errorf("%w: %s of a %s (%s)", ErrNotHandled, subject, d.RefType, d.Ref)
}
branch := strings.TrimPrefix(d.Ref, "refs/heads/")
switch {
case d.Owner == "" || d.Repo == "" || strings.ContainsAny(d.Owner+d.Repo, "./ *>"):
return Message{}, unreadable(subject, "no owner or repo a forge event can name (%q/%q)", d.Owner, d.Repo)
case branch == "":
return Message{}, unreadable(subject, "no branch")
case branch == Trunk:
return Message{}, fmt.Errorf("%w: %s of the trunk %s", ErrNotHandled, subject, Trunk)
}
body, err := json.Marshal(BranchDeleted{V: delivery.Version, Kind: ForgeBranchDeleted, Repository: d.Owner + "/" + d.Repo, Branch: branch, Head: d.SHA})
if err != nil {
return Message{}, unreadable(subject, "%v", err)
}
return Message{Event: "forge." + d.Owner + "." + d.Repo + "." + ForgeBranchDeleted, Data: body,
Said: fmt.Sprintf("branch %s of %s/%s deleted: its tagged versions end", branch, d.Owner, d.Repo)}, nil
}
var sha40 = regexp.MustCompile(`^[0-9a-f]{40}$`)
// pull is the part of gitea.pull.merged (index.ts:186-207), gitea.pull.updated (mesh-catalog modules/gitea/trunk.ts:233-250) and gitea.pull.closed
@@ -115,6 +169,8 @@ func ConvertKept(subject string, data []byte, trees Trees, files Files, kept Kep
action = delivery.PullClosed
case MergedSubject:
action = delivery.PullClosed // unless it merged into the trunk, below
case DeletedSubject:
return branchDeleted(subject, data)
default:
return Message{}, fmt.Errorf("%w: %s", ErrNotHandled, subject)
}
@@ -63,14 +63,13 @@ func TestUpdatedBecomesAPullMessage(t *testing.T) { // 1235/AC2 (the conversion;
if !delivery.SameForge(f, want) {
t.Errorf("message = %+v, want %+v", f, want)
}
}
func TestDirsNotSaidAreNotKnown(t *testing.T) { // 1235/AC3
msg, err := Convert(updatedSubject, updated(map[string]any{"module_dirs": nil, "module_dirs_said": nil}), nil, anyFiles)
// 1235/AC3, merged into this test for the test budget: dirs not said are not known.
msg, err = Convert(updatedSubject, updated(map[string]any{"module_dirs": nil, "module_dirs_said": nil}), nil, anyFiles)
if err != nil {
t.Fatal(err)
}
f, _ := delivery.DecodeForge(msg.Data)
f, _ = delivery.DecodeForge(msg.Data)
if f.DirsKnown || len(f.Dirs) != 0 {
t.Errorf("dirs = %v known = %v, want not known", f.Dirs, f.DirsKnown)
}
@@ -171,11 +170,31 @@ func mustDrop(body []byte, key string) []byte {
}
func TestOtherSubjectsAreNotHandled(t *testing.T) { // 1235
for _, s := range []string{"mesh.mod.gitea.event.something.else", "mesh.mod.other.event.pull.updated"} {
for _, s := range []string{"mesh.mod.gitea.event.something.else", "mesh.mod.other.event.pull.updated", "mesh.mod.other.event.forge.delete"} {
if _, err := Convert(s, updated(nil), nil, anyFiles); !errors.Is(err, ErrNotHandled) {
t.Errorf("%s: err = %v, want ErrNotHandled", s, err)
}
}
// The forge module's forge.delete of a branch is a branch-deleted message (DLV-FB1, design 51); of a tag or the
// trunk it is not handled, and a body naming no repository is unreadable.
del := func(refType, ref string) []byte {
b, _ := json.Marshal(map[string]any{"owner": "novox", "repo": "mesh-sdk", "full_name": "novox/mesh-sdk", "ref": ref, "ref_type": refType, "sha": head})
return b
}
msg, err := Convert(DeletedSubject, del("branch", "feat/x"), nil, anyFiles)
var bd BranchDeleted
if err != nil || msg.Event != "forge.novox.mesh-sdk.branch-deleted" || json.Unmarshal(msg.Data, &bd) != nil ||
bd.Kind != ForgeBranchDeleted || bd.Repository != "novox/mesh-sdk" || bd.Branch != "feat/x" || bd.Head != head || msg.Said == "" {
t.Errorf("forge.delete of a branch = %+v (%s), %v", msg, msg.Data, err)
}
for _, c := range []struct{ refType, ref string }{{"tag", "go/v0.1.13"}, {"branch", "main"}} {
if _, err := Convert(DeletedSubject, del(c.refType, c.ref), nil, anyFiles); !errors.Is(err, ErrNotHandled) {
t.Errorf("forge.delete of %s %s: err = %v, want ErrNotHandled", c.refType, c.ref, err)
}
}
if _, err := Convert(DeletedSubject, []byte(`{"ref":"x","ref_type":"branch"}`), nil, anyFiles); !errors.Is(err, ErrUnreadable) {
t.Errorf("forge.delete naming no repository: err = %v, want ErrUnreadable", err)
}
}
const merge = "89abcdef0123456789abcdef0123456789abcdef"
@@ -395,3 +414,50 @@ func TestTrackSaysWhichHeadOfWhichPullRequestWasWritten(t *testing.T) { // 1279/
t.Error("an open message was tracked")
}
}
func TestBridgeTagsAnswersAPackagesTagsByPrefixPageByPage(t *testing.T) { // DLV-FB1, design 51 *The floor*
var asked []string
page := func(n int) json.RawMessage {
var tags []map[string]string
for i := 0; i < n; i++ {
tags = append(tags, map[string]string{"name": "x" + strings.Repeat("y", i)})
}
b, _ := json.Marshal(tags)
return b
}
api := func(path string) (json.RawMessage, error) {
asked = append(asked, path)
if strings.Contains(path, "page=1&") {
full := page(TagsPageSize - 2)
var tags []map[string]string
_ = json.Unmarshal(full, &tags)
tags = append(tags, map[string]string{"name": "go/v0.1.13"}, map[string]string{"name": "v0.0.1"})
b, _ := json.Marshal(tags)
return b, nil
}
return json.RawMessage(`[{"name":"go/v0.1.2"}]`), nil
}
a, err := Tags(api, "novox/mesh-sdk", "go/v")
if err != nil || !slicesEqual(a.Tags, []string{"go/v0.1.13", "go/v0.1.2"}) || a.More || len(asked) != 2 ||
asked[0] != "/repos/novox/mesh-sdk/tags?page=1&limit=50" {
t.Errorf("Tags = %+v, %v; asked %v", a, err, asked)
}
if _, err := Tags(func(string) (json.RawMessage, error) { return nil, errors.New("down") }, "novox/mesh-sdk", ""); err == nil || !strings.Contains(err.Error(), "page 1") {
t.Errorf("a forge not answering = %v, want an error naming the page", err)
}
if _, err := Tags(api, "mesh-sdk", ""); err == nil {
t.Error("a repository with no owner answered")
}
}
func slicesEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
@@ -0,0 +1,68 @@
package bridge
import (
"encoding/json"
"fmt"
"net/url"
"sort"
"strings"
)
// TagsTool is the forge bridge's tool bridge_tags (novox/hq design 51 *The floor*): a package's git tags, which the
// builder's floor reads as one of its three sources.
const TagsTool = "bridge_tags"
// TagsPageSize is how many tags one page of the forge's tags answers; TagsMaxPages bounds the read.
const (
TagsPageSize = 50
TagsMaxPages = 40
)
// API asks the forge one GET of its API (the forge module's gitea_api), answering the body.
type API func(path string) (json.RawMessage, error)
// TagsAnswer is what bridge_tags answers.
type TagsAnswer struct {
Repository string `json:"repository"`
Prefix string `json:"prefix,omitempty"`
Tags []string `json:"tags"`
// More says the read stopped at TagsMaxPages pages with more left.
More bool `json:"more,omitempty"`
}
// Tags is bridge_tags: every tag of repository (`<owner>/<name>`) whose name starts with prefix (`go/v` for the Go
// package of mesh-sdk's `go` directory, `v` or empty for a package at the root), sorted. A forge that does not answer
// is an error naming the page: the builder then counts this source silent.
func Tags(api API, repository, prefix string) (TagsAnswer, error) {
owner, name, ok := strings.Cut(strings.TrimSpace(repository), "/")
if !ok || owner == "" || name == "" || strings.Contains(name, "/") {
return TagsAnswer{}, fmt.Errorf("repository %q is not <owner>/<name>", repository)
}
out := TagsAnswer{Repository: owner + "/" + name, Prefix: prefix, Tags: []string{}}
for page := 1; ; page++ {
if page > TagsMaxPages {
out.More = true
break
}
raw, err := api(fmt.Sprintf("/repos/%s/%s/tags?page=%d&limit=%d", url.PathEscape(owner), url.PathEscape(name), page, TagsPageSize))
if err != nil {
return TagsAnswer{}, fmt.Errorf("the forge did not answer the tags of %s (page %d): %w", out.Repository, page, err)
}
var tags []struct {
Name string `json:"name"`
}
if err := json.Unmarshal(raw, &tags); err != nil {
return TagsAnswer{}, fmt.Errorf("the forge's tags of %s (page %d) are no list of tags: %w", out.Repository, page, err)
}
for _, t := range tags {
if strings.HasPrefix(t.Name, prefix) {
out.Tags = append(out.Tags, t.Name)
}
}
if len(tags) < TagsPageSize {
break
}
}
sort.Strings(out.Tags)
return out, nil
}
@@ -28,18 +28,20 @@ type state struct {
}
type artifact struct {
Name string `json:"name"`
Kind string `json:"kind"`
Language string `json:"language"`
System string `json:"system"`
From string `json:"from"`
Binary string `json:"binary"`
Name string `json:"name"`
Kind string `json:"kind"`
Language string `json:"language"`
System string `json:"system"`
From string `json:"from"`
Binary string `json:"binary"`
Loads []string `json:"loads"`
}
type manifest struct {
Module string `json:"module"`
Version string `json:"version"`
Slug string `json:"slug"`
Tools []string `json:"tools"`
Consumes []string `json:"consumes"`
Streams []stream `json:"streams"`
State []state `json:"state"`
@@ -87,13 +89,14 @@ func TestManifestDeclarations(t *testing.T) { // 1235/AC1
t.Errorf("streams = %+v", m.Streams)
}
// The checker's owed is the forge module's to write (ADR 0521, R4e finding R2-1): the bridge neither hears nor reads it.
if want := []string{"gitea.pull.closed", "gitea.pull.merged", "gitea.pull.updated"}; !reflect.DeepEqual(sorted(m.Consumes), want) {
// gitea.forge.delete: a branch's end, said as a branch-deleted kind (design 51, DLV-FB1).
if want := []string{"gitea.forge.delete", "gitea.pull.closed", "gitea.pull.merged", "gitea.pull.updated"}; !reflect.DeepEqual(sorted(m.Consumes), want) {
t.Errorf("consumes = %v, want %v", m.Consumes, want)
}
if want := []string{"mesh-delivery-gate.merges", "mesh-delivery-gate.owed"}; !reflect.DeepEqual(sorted(m.Reads), want) {
t.Errorf("reads = %v, want %v", m.Reads, want)
}
if want := []string{"gitea.gitea_add_comment", "gitea.gitea_commit_status", "gitea.gitea_commit_trees", "gitea.gitea_delivery_view",
if want := []string{"gitea.gitea_add_comment", "gitea.gitea_api", "gitea.gitea_commit_status", "gitea.gitea_commit_trees", "gitea.gitea_delivery_view",
"gitea.gitea_get_file", "gitea.gitea_merge_pull_request", "gitea.gitea_note_append", "gitea.gitea_open_pulls"}; !reflect.DeepEqual(sorted(m.Invokes), want) {
t.Errorf("invokes = %v, want %v", m.Invokes, want)
}
@@ -110,8 +113,12 @@ func TestManifestDeclarations(t *testing.T) { // 1235/AC1
if !reflect.DeepEqual(m.State, []state{{Name: "heads", History: 1}, {Name: "performed", History: 1}, {Name: "inputs", History: 1}}) { // 1279/AC6, 1341, 1126
t.Errorf("state = %+v, want the buckets heads, performed and inputs", m.State)
}
// No other state, no bus, no seat, no tool, no event (ADR 0437 §4; ADR 0521: no check.status).
for _, k := range []string{"logs", "emits", "claims", "provides", "requires", "uses", "defines-seats", "tools", "seat", "seats"} {
// One tool, bridge_tags, served by its tools bundle, for the builder's floor (design 51).
if !reflect.DeepEqual(m.Tools, []string{"bridge_tags"}) {
t.Errorf("tools = %v, want bridge_tags", m.Tools)
}
// No other state, no bus, no seat, no event (ADR 0437 §4; ADR 0521: no check.status).
for _, k := range []string{"logs", "emits", "claims", "provides", "requires", "uses", "defines-seats", "seat", "seats"} {
if _, ok := top[k]; ok {
t.Errorf("the manifest declares %q: the bridge has none", k)
}
@@ -143,8 +150,13 @@ func TestManifestProcess(t *testing.T) { // 1235/AC1
if _, bus := p["bus"]; bus {
t.Errorf("the process declares a bus: it runs on its own broker credential")
}
if len(m.Build.Artifacts) != 1 {
t.Fatalf("artifacts = %+v", m.Build.Artifacts)
if len(m.Build.Artifacts) != 2 {
t.Fatalf("artifacts = %+v, want the program and the tools", m.Build.Artifacts)
}
if a := m.Build.Artifacts[1]; a.Name != "tools" || a.Binary != "mesh-delivery-forge-bridge-tools" || !reflect.DeepEqual(a.Loads, []string{a.Binary}) {
t.Errorf("tools artifact = %+v", a)
} else if st, err := os.Stat(a.From); err != nil || !st.IsDir() {
t.Errorf("the tools' source %s: %v", a.From, err)
}
a := m.Build.Artifacts[0]
if st, err := os.Stat(a.From); err != nil || !st.IsDir() || a.Binary != "mesh-delivery-forge-bridge" || !strings.HasSuffix(a.From, a.Binary) {
+18 -2
View File
@@ -2,10 +2,14 @@
"module": "mesh-delivery-forge-bridge",
"version": "1",
"slug": "forge-bridge",
"tools": [
"bridge_tags"
],
"consumes": [
"gitea.pull.updated",
"gitea.pull.merged",
"gitea.pull.closed"
"gitea.pull.closed",
"gitea.forge.delete"
],
"streams": [
{ "name": "forge", "events": ["forge.**"] }
@@ -27,7 +31,8 @@
"gitea.gitea_add_comment",
"gitea.gitea_open_pulls",
"gitea.gitea_commit_trees",
"gitea.gitea_get_file"
"gitea.gitea_get_file",
"gitea.gitea_api"
],
"own-secrets": {
"broker": "${dir:state}/broker"
@@ -75,6 +80,17 @@
"system": "arch",
"from": "cmd/mesh-delivery-forge-bridge",
"binary": "mesh-delivery-forge-bridge"
},
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/mesh-delivery-forge-bridge-tools",
"binary": "mesh-delivery-forge-bridge-tools",
"loads": [
"mesh-delivery-forge-bridge-tools"
]
}
]
}
+1
View File
@@ -33,3 +33,4 @@ require (
)
replace git.novox.be/novox/mesh-delivery/sdk => ../../sdk
-2
View File
@@ -1,5 +1,3 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
@@ -4978,7 +4978,8 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
}
}
// The node seat node-engine is the node's engine's (novox/hq ADR 0563 §1): at node scope, delivering nothing, its verbs
// (compose-candidate since ADR 0593 §7) and three events, held by no assignment, and a module's claim of it refused.
// (compose-candidate since ADR 0593 §7, manifest-reads since ADR 0618 §3) and four events (manifest-reads-changed since
// ADR 0618 §4), held by no assignment, and a module's claim of it refused.
if engine, ok := catalogue.SeatNamed(catalogue.EngineSeat); !ok || engine.Scope != catalogue.ScopeNode || engine.Delivers != "" || !engine.HeldByTheEngine {
t.Errorf("node-engine is %+v, not a node seat delivering nothing that the engine holds", engine)
} else {
@@ -4990,8 +4991,8 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
if props, _ := engine.Serves[5].Input["properties"].(map[string]any); props["id"] == nil {
t.Errorf("acts takes no id: %v", engine.Serves[5].Input)
}
if strings.Join(verbs, ",") != "hand-over,access-act,judge-directories,setuid-search,root-free,acts,packages,composition,compose,compose-candidate,pull-waits" ||
strings.Join(engine.Emits, ",") != "asked,done,refused" {
if strings.Join(verbs, ",") != "hand-over,access-act,judge-directories,setuid-search,root-free,acts,packages,composition,compose,compose-candidate,manifest-reads" ||
strings.Join(engine.Emits, ",") != "asked,done,refused,manifest-reads-changed" {
t.Errorf("node-engine serves %v and emits %v", verbs, engine.Emits)
}
}
@@ -5236,6 +5237,9 @@ func TestUsingASeatNobodyDeclaresIsRefused(t *testing.T) {
catalogue.Manifest{Module: "shop", Invokes: []string{"seat:node-engine.root-free"}}, telegram(), false},
{"a verb the engine's seat does not promise", `the seat node-engine serves no verb "root-freed"`,
catalogue.Manifest{Module: "shop", Invokes: []string{"seat:node-engine.root-freed"}}, telegram(), false},
{"what the engine reads of a manifest, and its event (ADR 0618 §3, §4)", "",
catalogue.Manifest{Module: "shop", Invokes: []string{"seat:node-engine.manifest-reads"},
Consumes: []string{"node-engine.manifest-reads-changed"}}, telegram(), false},
} {
got := CatalogueProblems(catalogue.Shelf{"telegram": row.seat, "shop": row.shop})
joined := strings.Join(got, "; ")
@@ -20,11 +20,88 @@ import (
"git.novox.be/novox/mesh-sdk/go/views"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/busretry"
"git.novox.be/novox/mesh-delivery/sdk/enginereads"
)
// ManifestReadsTool lists, per node, what its engine states it reads, by asking the node-engine seat's verb of every node
// at once (ADR 0618 §3).
const ManifestReadsTool = "node_manifest_reads"
// ManifestReadsBound bounds each node's ask of the survey: the whole call stays within a tool call's 30 s.
var ManifestReadsBound = 10 * time.Second
// askEngine is the survey's caller: the node-engine seat's verb of one machine, through the runtime (stdio.Ask). An error
// that is no lack of an answer is the engine's own words: it answered and does not serve the verb.
func askEngine(node, key string, body any) (json.RawMessage, error) {
raw, err := stdio.Ask(key+"@"+node, body)
if err != nil {
words := strings.ToLower(err.Error())
if !strings.Contains(words, "no responders") && !strings.Contains(words, "timeout") && !strings.Contains(words, "deadline") {
return nil, &busretry.Answered{Words: err.Error()}
}
}
return raw, err
}
// engineCaller is how node_manifest_reads asks; a test's fake in its place.
var engineCaller enginereads.Caller = askEngine
// manifestReads is node_manifest_reads over r's haves and presence: every node with a record or a presence, asked through
// call.
func manifestReads(r view.Reader, call enginereads.Caller) (any, error) {
set := map[string]bool{}
records := map[string][]string{}
if r.HavesState.Keys != nil {
keys, err := r.HavesState.Keys()
if err != nil {
return nil, fmt.Errorf("the haves cannot be read: %w", err)
}
for _, key := range keys {
node, _, isNode, err := delivery.ParseHaveKey(key)
if err != nil || !isNode {
continue
}
set[node] = true
if raw, err := r.HavesState.Get(key); err == nil && raw != nil {
if h, err := delivery.DecodeHaveNode(raw); err == nil && h.Record != nil {
records[node] = h.Record.ManifestReads
}
}
}
}
if r.PresenceState.Keys != nil {
if keys, err := r.PresenceState.Keys(); err == nil {
for _, key := range keys {
if node, part, _, err := delivery.ParsePresenceKey(key); err == nil && part == "" && node != "" {
set[node] = true
}
}
}
}
nodes := make([]string, 0, len(set))
for node := range set {
nodes = append(nodes, node)
}
rows := enginereads.Survey(call, nodes, func(node string) ([]string, bool) {
reads, ok := records[node]
return reads, ok
}, ManifestReadsBound, time.Now())
return map[string]any{"nodes": rows}, nil
}
// Tools are the read tools over a modules state, and build_usage over the build modules' states.
func Tools(r view.Reader, u view.UsageReader) []stdio.Tool {
return []stdio.Tool{
{Name: ManifestReadsTool,
Description: "What each node's engine states it reads of a manifest (manifest words, forms and seat names, novox/hq ADR 0618 " +
"§3), for the whole mesh in one call: every node with a record or a presence is asked the node-engine seat's verb " +
"manifest-reads at once, each within " + ManifestReadsBound.String() + ". Per node: stated (with the capabilities and " +
"when the engine stated them), not served (the engine is built before the verb) or away (nobody answered), and for the " +
"last two the reads its node record states, which the assigner reads in their place. Read-only.",
Input: map[string]any{"type": "object", "properties": map[string]any{}},
Run: func(map[string]any) (any, error) { return manifestReads(r, engineCaller) }},
{Name: view.StatusTool,
Description: "The mesh's status, derived by the view from the buckets it reads (novox/hq ADR 0564 §10), nothing written as a " +
"summary: every open condition of the condition keeper (mesh-conditions), urgent first then oldest; what is quiet (each " +
@@ -11,8 +11,12 @@ import (
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/busretry"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-delivery/sdk/enginereads"
"mesh-delivery-view/internal/view"
)
@@ -57,6 +61,7 @@ func TestModuleToolAnswersTheStoredValue(t *testing.T) {
t.Run("indexer_packages answers the published modules", packagesToolAnswersThePublishedModules)
t.Run("view_tiers names each module's wait", tiersToolSaysEachModulesWait)
t.Run("each tool answers through the owners' read tools as before", ownersAnswerTheToolsAsBefore)
t.Run("node_manifest_reads asks every node's engine in one call", manifestReadsAsksEveryNode)
r := fake{"a": withFP2, "b": withNone}.reader()
for key, want := range map[string]string{"a": withFP2, "b": withNone} {
out, err := run(t, r, view.ModuleReadTool, map[string]any{"module": key})
@@ -573,3 +578,48 @@ func packagesToolAnswersThePublishedModules(t *testing.T) {
t.Error("a path that is no mesh Go module's was read")
}
}
// manifestReadsAsksEveryNode: node_manifest_reads lists each node's capabilities by asking the node-engine seat's verb
// (ADR 0618 §3) of every node with a record or a presence, at once, each within its bound: stated, not served (with its
// record's reads, which the assigner reads in their place) or away (a node that never answers holds no other).
func manifestReadsAsksEveryNode(t *testing.T) {
was, bound := engineCaller, ManifestReadsBound
defer func() { engineCaller, ManifestReadsBound = was, bound }()
ManifestReadsBound = 200 * time.Millisecond
hang := make(chan struct{})
defer close(hang)
engineCaller = func(node, key string, _ any) (json.RawMessage, error) {
switch {
case key != "seat:node-engine.manifest-reads":
return nil, fmt.Errorf("asked %s", key)
case node == "g14":
return nil, &busretry.Answered{Words: "the seat node-engine serves no verb manifest-reads"}
case node == "novox":
<-hang // never answers within the bound
return nil, nil
}
return json.Marshal(nodeengine.ManifestReadsAnswer{V: 1, Node: node, ManifestReads: []string{"form:new"}, StatedAt: time.Now().Format(time.RFC3339Nano)})
}
record := func(node string, reads ...string) string {
raw, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: node, ManifestReads: reads}})
return string(raw)
}
on, _ := delivery.EncodePresence(delivery.Presence{Online: true})
r := view.Reader{HavesState: fake{"ace.node": record("ace"), "g14.node": record("g14", "word:module"), "ace.web": "{}"}.reader().State,
PresenceState: fake{"novox": string(on)}.reader().State}
started := time.Now()
out, err := run(t, r, ManifestReadsTool, nil)
if err != nil || time.Since(started) > 2*time.Second {
t.Fatalf("node_manifest_reads: %v after %s", err, time.Since(started))
}
rows, _ := out.(map[string]any)["nodes"].([]enginereads.SurveyRow)
byNode := map[string]enginereads.SurveyRow{}
for _, row := range rows {
byNode[row.Node] = row
}
if len(rows) != 3 || byNode["ace"].Answer != enginereads.Stated || byNode["ace"].ManifestReads[0] != "form:new" ||
byNode["g14"].Answer != enginereads.NotServed || !byNode["g14"].RecordRead || byNode["g14"].Record[0] != "word:module" ||
byNode["novox"].Answer != enginereads.Away || byNode["novox"].RecordRead {
t.Errorf("node_manifest_reads = %+v", rows)
}
}
+1
View File
@@ -24,3 +24,4 @@ require (
require git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000
replace git.novox.be/novox/mesh-delivery/sdk => ../../sdk
-2
View File
@@ -1,5 +1,3 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+3 -2
View File
@@ -105,7 +105,8 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7
if want := []string{"mesh-delivery-assigner.assigner_read_asks", "mesh-delivery-assigner.assigner_read_differences", "mesh-delivery-assigner.assigner_read_placement",
"mesh-delivery-assigner.assigner_read_wants", "mesh-delivery-indexer.indexer_read_candidates", "mesh-delivery-indexer.indexer_read_config_candidates",
"mesh-delivery-indexer.indexer_read_graph", "mesh-delivery-indexer.indexer_read_heard", "mesh-delivery-indexer.indexer_read_modules",
"mesh-delivery-indexer.indexer_read_packages", "mesh-delivery-indexer.indexer_read_predefined", "mesh-delivery-indexer.indexer_read_taken"}; !reflect.DeepEqual(m.Invokes, want) {
"mesh-delivery-indexer.indexer_read_packages", "mesh-delivery-indexer.indexer_read_predefined", "mesh-delivery-indexer.indexer_read_taken",
"seat:node-engine.manifest-reads"}; !reflect.DeepEqual(m.Invokes, want) { // ADR 0618 §3: node_manifest_reads asks the node-engine seat's verb of every node
t.Errorf("invokes = %v, want %v", m.Invokes, want)
}
if want := []string{"mesh-delivery-assigner.views.asks", "mesh-delivery-assigner.views.placement", "mesh-delivery-assigner.views.wants",
@@ -134,7 +135,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7
}
// The two read tools of the indexer's modules (issue 1562), carried by a bundle the runtime launches; the indexer
// itself serves no tool (ADR 0437 §4). Carrying them gives the tool runner read access to every bucket the view reads.
if want := []string{"view_status", "indexer_module", "indexer_modules", "indexer_candidate", "indexer_candidates", "indexer_predefined", "indexer_config_candidates", "indexer_packages", "builder_build", "builder_builds", "builder_history", "assigner_wants", "assigner_want", "assigner_differences", "node_haves", "node_trusts", "build_usage", "view_reach", "indexer_module_history", "indexer_heard", "view_tiers", "view_first_views", "view_first_view_again"}; !reflect.DeepEqual(m.Tools, want) {
if want := []string{"view_status", "indexer_module", "indexer_modules", "indexer_candidate", "indexer_candidates", "indexer_predefined", "indexer_config_candidates", "indexer_packages", "builder_build", "builder_builds", "builder_history", "assigner_wants", "assigner_want", "assigner_differences", "node_haves", "node_trusts", "build_usage", "view_reach", "indexer_module_history", "indexer_heard", "view_tiers", "view_first_views", "view_first_view_again", "node_manifest_reads"}; !reflect.DeepEqual(m.Tools, want) {
t.Errorf("tools = %v, want %v", m.Tools, want)
}
for _, a := range m.Build.Artifacts {
+4 -2
View File
@@ -26,7 +26,8 @@
"indexer_heard",
"view_tiers",
"view_first_views",
"view_first_view_again"
"view_first_view_again",
"node_manifest_reads"
],
"emits": [
"delivered",
@@ -60,7 +61,8 @@
"mesh-delivery-indexer.indexer_read_modules",
"mesh-delivery-indexer.indexer_read_packages",
"mesh-delivery-indexer.indexer_read_predefined",
"mesh-delivery-indexer.indexer_read_taken"
"mesh-delivery-indexer.indexer_read_taken",
"seat:node-engine.manifest-reads"
],
"reads": [
"mesh-build-go.pauses",
+1
View File
@@ -20,3 +20,4 @@ require (
golang.org/x/sys v0.48.0 // indirect
golang.org/x/time v0.15.0 // indirect
)
-2
View File
@@ -1,5 +1,3 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
@@ -859,6 +859,8 @@ func healthChecks(t *testing.T) {
{"the holder says no other seat's", selfCheck, "mesh.seat.mesh-conditions.event.condition-raised", false},
{"a machine's event on a node seat is not composed here", Declared{Module: "pause", Holds: []Seat{{Name: "node-power",
Scope: "node", Emits: []string{"paused.*"}}}}, "mesh.seat.node-power.event.paused.b", false},
{"a flat event of a node seat held by a module is said flat", Declared{Module: "pause", Holds: []Seat{{Name: "node-power",
Scope: "node", Emits: []string{"slept"}}}}, "mesh.seat.node-power.event.slept", true},
} {
m := MembershipFor("a", c.d, Placements{})
listed := m.SeatTraffic != nil && slices.ContainsFunc(m.SeatTraffic.Publish, func(p string) bool { return p == c.subject })
@@ -871,6 +873,28 @@ func healthChecks(t *testing.T) {
MayPublish(perms, c.subject), c.may)
}
}
// ADR 0618 §4: the node-engine says each of its seat's events with its own machine as the last token, and a watcher hears
// that event from every machine, its consumer filtered the same way; a flat event of a node seat a module holds is
// heard flat. One rule (machineEvent) places both sides.
engine, err := PermissionsFor(Principal{Kind: KindNode, Node: "ace"})
if err != nil {
t.Fatal(err)
}
watcher := Principal{Kind: KindModule, Node: "novox", Module: "mesh-delivery-assigner", Watches: []Seat{
{Name: EngineSeat, Scope: "node", Emits: []string{"manifest-reads-changed"}, HeldByTheEngine: true},
{Name: "node-power", Scope: "node", Emits: []string{"slept"}}}}
heard, err := PermissionsFor(watcher)
if err != nil {
t.Fatal(err)
}
consumer, _ := ConsumerFor(watcher)
said := "mesh.seat.node-engine.event.manifest-reads-changed.ace"
if !MayPublish(engine, said) || MayPublish(engine, "mesh.seat.node-engine.event.manifest-reads-changed.novox") ||
!MaySubscribe(heard, said) || !MaySubscribe(heard, "mesh.seat.node-power.event.slept") ||
!slices.Contains(consumer.Filters, "mesh.seat.node-engine.event.manifest-reads-changed.*") {
t.Errorf("the engine says %s: %v; the watcher hears it %v, the flat event %v; its consumer filters %v", said,
MayPublish(engine, said), MaySubscribe(heard, said), MaySubscribe(heard, "mesh.seat.node-power.event.slept"), consumer.Filters)
}
seen := map[string]bool{}
for _, u := range users {
name := u.Username()
@@ -51,7 +51,9 @@ func EnginePackagesGrants(node string) []string {
// principal may subscribe this seat's subjects of this node, and a caller is still granted each verb by name.
func engineGrants(p Principal) (pub, sub []string) {
sub = append(sub, "mesh.seat."+EngineSeat+".tool.*."+p.Node)
pub = append(pub, "mesh.seat."+EngineSeat+".event.*."+p.Node)
// Any event of the seat about its own node, by the one rule a watcher of it is composed by (machineEvent).
every, _ := machineEvent(Seat{Name: EngineSeat, Scope: "node", HeldByTheEngine: true}, "*")
pub = append(pub, seatSubject(Seat{Name: EngineSeat}, "event", onMachine(every, p.Node)))
sub = append(sub, announcing(EngineSeat)...)
pub = append(pub, discovering()...)
tp, ts := seatTrafficGrants(SeatTrafficOf(asks.EngineAsker(p.Node), nil, p.Uses, nil))
@@ -73,6 +73,17 @@ func UserListFixtures() []UserListFixture {
},
RootFree: map[string]bool{"ace": true},
}},
// ADR 0618 §4: an event of the node-engine's seat names the engine's machine, so its watcher hears it from every
// machine; a node seat held by a module says its other events flat, and is heard flat.
{Name: "a node seat's events, by machine and flat", Records: Records{
Nodes: []string{"ace", "novox"},
Assigned: map[string][]Declared{
"novox": {{Module: "mesh-delivery-assigner", Watches: []Seat{
{Name: EngineSeat, Scope: "node", Emits: []string{"manifest-reads-changed"}, HeldByTheEngine: true},
{Name: "node-power", Scope: "node", Emits: []string{"slept"}}}}},
"ace": {{Module: "pause", Holds: []Seat{{Name: "node-power", Scope: "node", Emits: []string{"slept", "paused.*"}}}}},
},
}},
}
}
@@ -98,6 +98,9 @@ type Seat struct {
// PulledByKind says each accepted kind is a worker of its own on the seat's queue, filtered to that kind, which
// every holder pulls in the order Accepts lists them: the build seats' (novox/hq ADR 0525 §2, ADR 0536 §4).
PulledByKind bool
// HeldByTheEngine says every node's engine holds the seat on its own account (catalogue.Seat.HeldByTheEngine, novox/hq
// ADR 0563 §1): each of its events names the engine's machine as its last token (machineEvent).
HeldByTheEngine bool
}
// A Principal is one user of the bus. Its permissions are derived from what it declares and
@@ -414,6 +417,25 @@ func NodeLoginsReads(node string) []string {
// holder's machine (novox/hq ADR 0219): `paused.<node>`, the build agent saying whether it takes work.
var perMachineEvents = map[string]bool{"paused.*": true}
// machineEvent is the one rule for where a node-scoped seat's event names its holder's machine as its last token (novox/hq
// ADR 0219, ADR 0618 §4), as a pattern whose last token `*` is that machine, and whether e is such an event: every event
// of a seat the node-engine holds (engineGrants), and each of perMachineEvents of any other node seat. The holder is
// granted the pattern with its own machine in place of `*`, a watcher the pattern, so the two cannot differ.
func machineEvent(s Seat, e string) (string, bool) {
switch {
case s.Scope != "node":
return "", false
case perMachineEvents[e]:
return e, true
case s.HeldByTheEngine:
return e + ".*", true
}
return "", false
}
// onMachine is a machineEvent pattern with node in place of its last `*`.
func onMachine(pattern, node string) string { return strings.TrimSuffix(pattern, "*") + node }
// enrolmentPrefix is the space every enrolling node's user and inbox live under, so the one place the
// controller may answer an enrolment is derived from the same constant the user is named from.
const enrolmentPrefix = "enrol"
@@ -889,6 +911,11 @@ func PermissionsFor(p Principal) (Permissions, error) {
continue // composed by SeatTrafficOf below
}
for _, e := range w.Emits {
// An event that names its holder's machine is heard from every machine (machineEvent): a flat subject would
// match no event the holder says.
if pattern, ok := machineEvent(w, e); ok {
e = pattern
}
sub = append(sub, seatSubject(w, "event", e))
}
}
@@ -934,8 +961,8 @@ func PermissionsFor(p Principal) (Permissions, error) {
// **A machine says its own state and no other's** (novox/hq ADR 0219): on a node-scoped
// seat, an event about the holder itself carries the machine as its last token, and
// each holder is granted its own machine's alone.
if s.Scope == "node" && p.Node != "" && perMachineEvents[e] {
pub = append(pub, seatSubject(s, "event", strings.TrimSuffix(e, "*")+p.Node))
if pattern, ok := machineEvent(s, e); ok && p.Node != "" {
pub = append(pub, seatSubject(s, "event", onMachine(pattern, p.Node)))
continue
}
pub = append(pub, seatSubject(s, "event", e))
@@ -64,7 +64,7 @@ func SeatTrafficOf(module string, holds, uses, watches []Seat) SeatTraffic {
// runtime and the runtime is granted them. A machine's event on a node seat carries the machine, which
// only the holder's own account is composed with, so it is left to that rule.
for _, e := range s.Emits {
if s.Scope == "node" && perMachineEvents[e] {
if _, ok := machineEvent(s, e); ok {
continue
}
t.Publish = append(t.Publish, seatSubject(s, "event", e))
File diff suppressed because one or more lines are too long
@@ -132,7 +132,10 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio
}
}
if s, isASeat := seats[emitter]; isASeat && catalogue.SeatSays(s.Emits, event) {
watches = append(watches, buscompose.Seat{Name: s.Name, Emits: []string{event}})
// Its scope, and whether the node-engine holds it, place an event that names its holder's machine (ADR 0618 §4).
compiled, _ := catalogue.SeatNamed(s.Name)
watches = append(watches, buscompose.Seat{Name: s.Name, Scope: s.Scope, Emits: []string{event},
HeldByTheEngine: compiled.HeldByTheEngine})
continue
}
}
+641
View File
@@ -0,0 +1,641 @@
// Package enginereads is how a mesh-delivery part learns what each node's engine reads of a manifest (novox/hq ADR 0618
// §3, §4): it asks the node seat node-engine's verb manifest-reads, on the part's start, on the seat's event
// manifest-reads-changed for that node and when a person asks again, and keeps each node's last answer in a state of its
// own, one key per node, so a node that is away keeps the statement it last made (ADR 0624 §1). The assigner, the
// builder and the delivery view share it; it is mesh-delivery's, not mesh-sdk's (ADR 0593 §1, ADR 0608 §5).
package enginereads
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"sort"
"strings"
"sync"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/busretry"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
)
// The verb and the event as the seat's contract names them (mesh-contracts seats/nodeengine), as a manifest names them,
// and the subject the event is said on with the engine's machine last.
const (
Verb = nodeengine.VerbManifestReads
Event = nodeengine.EventManifestReadsChanged
Invoke = "seat:" + nodeengine.Seat + "." + Verb
Consumes = nodeengine.Seat + "." + Event
)
// SubjectPrefix is the event's subject up to the machine, its last token.
var SubjectPrefix = strings.TrimSuffix(nodeengine.SubjectManifestReadsChanged("*"), "*")
// The reasons an engine says its reads, in the event's why (ADR 0618 §4).
const (
// ReadsStarted: the engine started, or what it states changed.
ReadsStarted = "started"
// ReadsReturned: the engine's node returned after being away.
ReadsReturned = "returned"
)
// ContractVersion is the layout version of the verb's answer and the event's body this reader reads.
const ContractVersion = 1
// checkNode refuses a node that is no subject token.
func checkNode(node string) error {
if node == "" || strings.ContainsAny(node, ".*> \t\n") {
return fmt.Errorf("%q is no node", node)
}
return nil
}
// checkReads refuses an empty reader capability.
func checkReads(node string, reads []string) error {
for _, c := range reads {
if strings.TrimSpace(c) == "" {
return fmt.Errorf("the reads of %s name an empty reader capability", node)
}
}
return nil
}
// DecodeAnswer reads the verb's answer as the contract lays it out, and judges it: the layout's version, the node a
// name, no empty capability, and its stated-at an RFC 3339 time (the contract carries no checks; its reader does).
func DecodeAnswer(raw []byte) (nodeengine.ManifestReadsAnswer, time.Time, error) {
var a nodeengine.ManifestReadsAnswer
if err := json.Unmarshal(raw, &a); err != nil {
return a, time.Time{}, err
}
if a.V != ContractVersion {
return a, time.Time{}, fmt.Errorf("the answer's layout is version %d, not %d", a.V, ContractVersion)
}
if err := checkNode(a.Node); err != nil {
return a, time.Time{}, err
}
if err := checkReads(a.Node, a.ManifestReads); err != nil {
return a, time.Time{}, err
}
at, err := time.Parse(time.RFC3339Nano, a.StatedAt)
if err != nil {
return a, time.Time{}, fmt.Errorf("stated-at %q is no RFC 3339 time", a.StatedAt)
}
return a, at, nil
}
// DecodeChanged reads the event's body as the contract lays it out, and judges it as DecodeAnswer does, its why one of
// ReadsStarted and ReadsReturned.
func DecodeChanged(raw []byte) (nodeengine.ManifestReadsChangedEvent, error) {
var e nodeengine.ManifestReadsChangedEvent
if err := json.Unmarshal(raw, &e); err != nil {
return e, err
}
if e.V != ContractVersion {
return e, fmt.Errorf("the event's layout is version %d, not %d", e.V, ContractVersion)
}
if err := checkNode(e.Node); err != nil {
return e, err
}
if e.Why != ReadsStarted && e.Why != ReadsReturned {
return e, fmt.Errorf("the reads of %s changed for %q, not %s or %s", e.Node, e.Why, ReadsStarted, ReadsReturned)
}
if err := checkReads(e.Node, e.ManifestReads); err != nil {
return e, err
}
if _, err := time.Parse(time.RFC3339Nano, e.At); err != nil {
return e, fmt.Errorf("at %q is no RFC 3339 time", e.At)
}
return e, nil
}
// Where a node's statement came from.
const (
// FromVerb: the node's engine answered the verb.
FromVerb = "verb"
// FromRecord: the engine did not answer it (an engine built before the verb, or nobody answered) and the node's record
// `<node>.node` was read once in its place (the fallback, until every counted engine serves the verb; issue 774).
FromRecord = "record"
// FromNone: neither said anything, so the node is read as stating the baseline (ADR 0614 §4).
FromNone = "none"
)
// Version is the version of a statement this package writes and the only one it reads.
const Version = 1
// Statement is one node's key: what its engine states it reads, when it stated it, when it was asked, where it came
// from, whether the engine answered that ask (it is present), and, while a person's ask again waits, that ask.
type Statement struct {
V int `json:"v"`
Node string `json:"node"`
ManifestReads []string `json:"manifest-reads,omitempty"`
StatedAt time.Time `json:"stated-at,omitzero"`
AskedAt time.Time `json:"asked-at,omitzero"`
From string `json:"from"`
Answered bool `json:"answered"`
Words string `json:"words,omitempty"`
AskAgain *Again `json:"ask-again,omitempty"`
}
// Again is a person's ask again of one node, written by the tool and answered by the part that keeps the state.
type Again struct {
By string `json:"by"`
Why string `json:"why"`
At time.Time `json:"at"`
}
// Encode is s as JSON at Version.
func Encode(s Statement) ([]byte, error) {
if s.Node == "" || strings.ContainsAny(s.Node, ".*> ") {
return nil, fmt.Errorf("enginereads: %q is no node", s.Node)
}
s.V = Version
return json.Marshal(s)
}
// Decode reads a statement; it refuses another version and a value naming no node.
func Decode(raw []byte) (Statement, error) {
var s Statement
if err := json.Unmarshal(raw, &s); err != nil {
return Statement{}, err
}
if s.V != Version {
return Statement{}, fmt.Errorf("enginereads: version %d, not %d", s.V, Version)
}
if s.Node == "" {
return Statement{}, errors.New("enginereads: a statement names no node")
}
return s, nil
}
// Caller calls a seat's verb on one node: process.Conn.CallOn.
type Caller func(node, key string, body any) (json.RawMessage, error)
// Ask asks node's engine the verb once and says what it answered. An engine that answered the call with an error is
// present and states nothing through it; one that did not answer is not present.
func Ask(call Caller, node string, now time.Time) Statement {
s := Statement{Node: node, AskedAt: now.UTC(), From: FromNone}
raw, err := call(node, Invoke, map[string]any{})
var answered *busretry.Answered
switch {
case err == nil:
a, at, derr := DecodeAnswer(raw)
if derr == nil && a.Node != node {
derr = fmt.Errorf("it names the node %s", a.Node)
}
if derr != nil {
s.Answered, s.Words = true, "the engine answered what is no statement: "+derr.Error()
return s
}
s.ManifestReads, s.StatedAt, s.From, s.Answered = slices.Clone(a.ManifestReads), at.UTC(), FromVerb, true
case errors.As(err, &answered):
s.Answered, s.Words = true, "the engine does not serve "+Verb+": "+answered.Words
default:
s.Words = "nobody answered " + Verb + ": " + err.Error()
}
return s
}
// NodeOf is the node an event of manifest-reads-changed is about, read from its subject's last token and held equal to
// its body's; ok false for a subject of another event.
func NodeOf(subject string, data []byte) (string, bool, error) {
node, ok := strings.CutPrefix(subject, SubjectPrefix)
if !ok || node == "" || strings.Contains(node, ".") {
return "", false, nil
}
e, err := DecodeChanged(data)
if err != nil {
return node, true, err
}
if e.Node != node {
return node, true, fmt.Errorf("the event on %s names the node %s", subject, e.Node)
}
return node, true, nil
}
// Keeper keeps every node's statement for one part: in memory, and in the part's state when Put is set, so a statement
// outlives the part's restart. Nothing in it waits on a timer: a node is asked when the part says so.
type Keeper struct {
// Call asks the verb; nil asks nothing (every node then reads as the fallback or the baseline).
Call Caller
// Fallback reads a node's record `<node>.node` once where its engine did not answer the verb; nil reads none.
Fallback func(node string) ([]string, bool)
// Put writes a node's key of the part's state, Delete removes it; nil keeps the statements in memory only.
Put func(node string, value []byte) error
Delete func(node string) error
// Changed is told after a node's statement changed.
Changed func()
Logf func(string, ...any)
Now func() time.Time
// Sync asks in the caller's goroutine (tests); otherwise each ask runs on its own.
Sync bool
mu sync.Mutex
by map[string]Statement
asking map[string]bool
}
func (k *Keeper) logf(f string, a ...any) {
if k.Logf != nil {
k.Logf(f, a...)
}
}
func (k *Keeper) now() time.Time {
if k.Now != nil {
return k.Now()
}
return time.Now()
}
// AskNode asks node's engine, unless an ask of it runs already; why is said in the log.
func (k *Keeper) AskNode(node, why string) {
k.mu.Lock()
if k.asking == nil {
k.asking = map[string]bool{}
}
if k.asking[node] {
k.mu.Unlock()
return
}
k.asking[node] = true
k.mu.Unlock()
if k.Sync {
k.ask(node, why)
return
}
go k.ask(node, why)
}
func (k *Keeper) ask(node, why string) {
defer func() {
k.mu.Lock()
delete(k.asking, node)
k.mu.Unlock()
}()
s := Statement{Node: node, AskedAt: k.now().UTC(), From: FromNone, Words: "nothing asks the verb"}
if k.Call != nil {
s = Ask(k.Call, node, k.now())
}
if s.From != FromVerb && k.Fallback != nil {
if reads, ok := k.Fallback(node); ok {
s.ManifestReads, s.From = slices.Clone(reads), FromRecord
k.logf("%s on %s (%s): %s; its record %s.node is read once in its place (until every counted engine serves the verb)",
Verb, node, why, s.Words, node)
}
}
if s.From == FromNone {
k.logf("%s on %s (%s): %s; the node reads as its last statement, or the baseline", Verb, node, why, s.Words)
}
k.take(s)
}
// take keeps s, unless the node did not answer and an earlier statement stands: an away node keeps its last one (ADR 0624
// §1), marked as not answering.
func (k *Keeper) take(s Statement) {
k.mu.Lock()
if k.by == nil {
k.by = map[string]Statement{}
}
if old, ok := k.by[s.Node]; ok && !s.Answered && old.From != FromNone {
old.Answered, old.AskedAt, old.Words, old.AskAgain = false, s.AskedAt, s.Words, nil
s = old
}
k.by[s.Node] = s
k.mu.Unlock()
if k.Put != nil {
raw, err := Encode(s)
if err == nil {
err = k.Put(s.Node, raw)
}
if err != nil {
k.logf("the statement of %s could not be kept: %v", s.Node, err)
}
}
if k.Changed != nil {
k.Changed()
}
}
// Heard takes a change of the part's own state in: at the start, every node's last statement; later, a person's ask again
// (asked now) or a key a person dropped (forgotten). The part's own writes come back here and change nothing.
func (k *Keeper) Heard(node string, raw []byte, deleted bool) {
if deleted {
k.mu.Lock()
_, had := k.by[node]
delete(k.by, node)
k.mu.Unlock()
if had && k.Changed != nil {
k.Changed()
}
return
}
s, err := Decode(raw)
if err != nil || s.Node != node {
k.logf("the key %s of the engine reads is no statement of it, left: %v", node, err)
return
}
if s.AskAgain != nil {
k.AskNode(node, fmt.Sprintf("asked again by %s: %s", s.AskAgain.By, s.AskAgain.Why))
return
}
k.mu.Lock()
if k.by == nil {
k.by = map[string]Statement{}
}
_, had := k.by[node]
if !had {
k.by[node] = s
}
k.mu.Unlock()
if !had && k.Changed != nil {
k.Changed()
}
}
// Forget drops a node the part no longer counts (it left the placement): its key too.
func (k *Keeper) Forget(node string) {
k.mu.Lock()
_, had := k.by[node]
delete(k.by, node)
k.mu.Unlock()
if had && k.Delete != nil {
if err := k.Delete(node); err != nil {
k.logf("the statement of %s could not be removed: %v", node, err)
}
}
}
// Known says whether the part holds a statement of node, from any source.
func (k *Keeper) Known(node string) bool {
k.mu.Lock()
defer k.mu.Unlock()
_, ok := k.by[node]
return ok
}
// Statements is every node's statement, by node.
func (k *Keeper) Statements() map[string]Statement {
k.mu.Lock()
defer k.mu.Unlock()
out := make(map[string]Statement, len(k.by))
for n, s := range k.by {
out[n] = s
}
return out
}
// Reads is, of the nodes given, what each states it reads: its last statement, nil (the baseline) for a node with none.
func (k *Keeper) Reads(nodes []string) map[string][]string {
k.mu.Lock()
defer k.mu.Unlock()
out := make(map[string][]string, len(nodes))
for _, n := range nodes {
out[n] = slices.Clone(k.by[n].ManifestReads)
}
return out
}
// Present is, of the nodes given, those whose engine answered when last asked, with what each states.
func (k *Keeper) Present(nodes []string) map[string][]string {
k.mu.Lock()
defer k.mu.Unlock()
out := map[string][]string{}
for _, n := range nodes {
if s, ok := k.by[n]; ok && s.Answered {
out[n] = slices.Clone(s.ManifestReads)
}
}
return out
}
// State is what the tool needs of the part's state: the runtime's (stdio.KeptState through KeptState), or a test's.
type State interface {
Keys() ([]string, error)
Get(key string) ([]byte, uint64, bool, error)
Put(key string, value []byte) (uint64, error)
Delete(key string) error
// Await waits at most bound for a revision of key after after whose value done says is the answer.
Await(key string, after uint64, done func([]byte) bool, bound time.Duration) ([]byte, error)
}
// Read lists every node's statement in st, by node name, or one node's; a key that does not read is named with why.
func Read(st State, node string) (map[string]any, error) {
keys := []string{node}
if node == "" {
var err error
if keys, err = st.Keys(); err != nil {
return nil, err
}
}
sort.Strings(keys)
nodes := map[string]any{}
for _, key := range keys {
raw, _, found, err := st.Get(key)
switch {
case err != nil:
return nil, err
case !found:
nodes[key] = map[string]any{"from": FromNone, "words": "no statement is kept: read as the baseline"}
continue
}
s, derr := Decode(raw)
if derr != nil {
nodes[key] = map[string]any{"unread": derr.Error()}
continue
}
nodes[key] = s
}
return map[string]any{"nodes": nodes}, nil
}
// AskAgainOf writes node's key with a person's ask again and waits at most bound for the part's answer: the next
// statement it writes there.
func AskAgainOf(st State, node, by, why string, now time.Time, bound time.Duration) (Statement, error) {
if node == "" || strings.ContainsAny(node, ".*> ") {
return Statement{}, fmt.Errorf("%q is no node", node)
}
if strings.TrimSpace(why) == "" {
return Statement{}, errors.New("say why it is asked again")
}
s := Statement{Node: node, From: FromNone}
if raw, _, found, err := st.Get(node); err != nil {
return Statement{}, err
} else if found {
if old, err := Decode(raw); err == nil {
s = old
}
}
s.AskAgain = &Again{By: by, Why: why, At: now.UTC()}
raw, err := Encode(s)
if err != nil {
return Statement{}, err
}
rev, err := st.Put(node, raw)
if err != nil {
return Statement{}, err
}
answer, err := st.Await(node, rev, func(v []byte) bool {
got, err := Decode(v)
return err == nil && got.AskAgain == nil
}, bound)
if err != nil {
return Statement{}, fmt.Errorf("the part did not answer the ask again of %s within %s: %w", node, bound, err)
}
return Decode(answer)
}
// KeptState is a part's state as the runtime keeps it, for the tool.
func KeptState(s stdio.KeptState) State { return keptState{s} }
type keptState struct{ s stdio.KeptState }
func (k keptState) Keys() ([]string, error) { return k.s.Keys() }
func (k keptState) Get(key string) ([]byte, uint64, bool, error) {
e, err := k.s.Get(key)
if err != nil || e == nil {
return nil, 0, false, err
}
return e.Value, e.Revision, true, nil
}
func (k keptState) Put(key string, value []byte) (uint64, error) { return k.s.Put(key, json.RawMessage(value)) }
func (k keptState) Delete(key string) error { return k.s.Delete(key) }
// Await watches key for the answer, and reads it once too in case the watch heard it before it began; a context's bound,
// no timer (ADR 0437 §8).
func (k keptState) Await(key string, after uint64, done func([]byte) bool, bound time.Duration) ([]byte, error) {
heard := make(chan []byte, 1)
if err := k.s.Watch(key, func(c stdio.StateChange) error {
if c.Op == "put" && c.Revision > after && done(c.Value) {
select {
case heard <- c.Value:
default:
}
}
return nil
}); err != nil {
return nil, err
}
if e, err := k.s.Get(key); err == nil && e != nil && e.Revision > after && done(e.Value) {
return e.Value, nil
}
wait, cancel := context.WithTimeout(context.Background(), bound)
defer cancel()
select {
case v := <-heard:
return v, nil
case <-wait.Done():
return nil, errors.New("it is not running, or cannot read the verb's answer")
}
}
// AnswerWithin is how long the tool waits for the part to answer an ask again: the verb's own call bound and a margin.
var AnswerWithin = 25 * time.Second
// Tool is a part's tool over its state of engine reads (tools-first, ADR 0618 §3): read every node's statement or one,
// ask one node's engine again now, and, where drops, drop one node's key (a node the part keeps by mistake). owner names
// the part in the words.
func Tool(name, owner string, st State, drops bool, now func() time.Time) stdio.Tool {
props := map[string]any{
"node": map[string]any{"type": "string", "description": "one node; every node when left out"},
"ask_again": map[string]any{"type": "string", "description": "\"true\": ask the node's engine " + Verb + " again now, and answer what it states"},
"why": map[string]any{"type": "string", "description": "why it is asked again or dropped, in plain words"},
}
description := "What each node's engine states it reads of a manifest (manifest words, forms and seat names, novox/hq ADR 0618 §3), " +
"as " + owner + " last asked the node-engine seat's verb " + Verb + " and uses it: per node its capabilities, when the engine " +
"stated them and when it was asked, whether it answered (present) and where the statement came from: verb, record (the " +
"engine did not answer and its node record was read once in its place) or none (read as the baseline). A node that is " +
"away keeps its last statement. With node and ask_again, " + owner + " asks that node's engine again and answers its new " +
"statement."
if drops {
props["drop"] = map[string]any{"type": "string", "description": "\"true\": drop the node's key, so it reads as stating the baseline until its engine is asked again"}
description += " With node and drop, the node's key is dropped."
}
return stdio.Tool{Name: name, Description: description, Input: map[string]any{"type": "object", "properties": props},
Run: func(args map[string]any) (any, error) {
str := func(k string) string { s, _ := args[k].(string); return strings.TrimSpace(s) }
node := str("node")
switch {
case str("ask_again") == "true":
return AskAgainOf(st, node, stdio.CallerOf(args), str("why"), now(), AnswerWithin)
case drops && str("drop") == "true":
if node == "" || str("why") == "" {
return nil, errors.New("drop takes node and why")
}
if err := st.Delete(node); err != nil {
return nil, err
}
return map[string]any{"node": node, "dropped": true,
"next": "the node reads as stating the baseline until its engine is asked again (ask_again, its event, or " + owner + "'s start)"}, nil
}
return Read(st, node)
}}
}
// How a node answered a survey (Survey).
const (
// Stated: the engine answered the verb.
Stated = "stated"
// NotServed: the engine answered, but does not serve the verb (an engine built before it).
NotServed = "not served"
// Away: nobody answered within the bound: the node is away, or its engine does not serve the verb and nothing else
// answers for it.
Away = "away"
)
// SurveyRow is one node's row of a survey: how it answered, its statement when it stated one, and its record's reads
// where it did not (the fallback the assigner reads in its place).
type SurveyRow struct {
Node string `json:"node"`
Answer string `json:"answer"`
ManifestReads []string `json:"manifest-reads,omitempty"`
StatedAt time.Time `json:"stated-at,omitzero"`
Record []string `json:"record,omitempty"`
RecordRead bool `json:"record-read,omitempty"`
Words string `json:"words,omitempty"`
}
// Survey asks every node's engine the verb at once, each ask bounded by bound, so the whole survey takes at most about
// bound however many nodes there are; a node that did not answer is named with its record's reads where record reads
// one. Sorted by node.
func Survey(call Caller, nodes []string, record func(node string) ([]string, bool), bound time.Duration, now time.Time) []SurveyRow {
rows := make([]SurveyRow, len(nodes))
var wg sync.WaitGroup
for i, node := range nodes {
wg.Add(1)
go func() {
defer wg.Done()
got := make(chan Statement, 1)
go func() { got <- Ask(call, node, now) }()
var s Statement
wait, cancel := context.WithTimeout(context.Background(), bound)
defer cancel()
select {
case s = <-got:
case <-wait.Done():
s = Statement{Node: node, Words: fmt.Sprintf("no answer within %s", bound)}
}
row := SurveyRow{Node: node, Words: s.Words}
switch {
case s.From == FromVerb:
row.Answer, row.ManifestReads, row.StatedAt = Stated, s.ManifestReads, s.StatedAt
case s.Answered:
row.Answer = NotServed
default:
row.Answer = Away
}
if row.Answer != Stated && record != nil {
row.Record, row.RecordRead = record(node)
}
rows[i] = row
}()
}
wg.Wait()
sort.Slice(rows, func(i, j int) bool { return rows[i].Node < rows[j].Node })
return rows
}
+266
View File
@@ -0,0 +1,266 @@
// Package forms holds the artifact reference forms the builder writes a done build key in and the assigner holds a want
// by (novox/hq ADR 0618 §1-§2, ADR 0624): logic only those two modules of mesh-delivery share, so it is mesh-delivery's
// and not mesh-sdk's (ADR 0593 §1, ADR 0608 §5). The build key's fields (delivery.BuilderBuild.Needs,
// delivery.BuiltArtifact.References) are the contract, in mesh-sdk.
package forms
import (
"slices"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// artifactStore opens the artifact store's references (delivery.BuiltArtifact).
const artifactStore = "artifact-store://"
// The artifact reference forms (novox/hq ADR 0605 §9, ADR 0618 §1-§2, ADR 0624): each form a done key's reference may be
// written in, named as the reader capability `form:<name>` a node-engine states when it reads it. The forms written
// before any engine stated what it reads are first forms: every engine reads them, so none needs a statement, and a
// node whose engine states nothing (the baseline, ADR 0614 §4) reads exactly those. A new form is added to
// Current, newest last, in the change that teaches the node-engine to read it.
// The first forms, by name.
const (
// FormArtifactStore: artifact-store://<module>/<artifact>/blobs/<digest>, or an image's kept artifact-store://…@<digest>.
FormArtifactStore = "artifact-store"
// FormRegistry: an image's or an upstream copy's <registry>/<module>/<artifact>@<digest>.
FormRegistry = "registry"
// FormPackageVersion: a package's <name>@<version>.
FormPackageVersion = "package-version"
)
// FormSeatReference is the form a registry seat answers for a bundle or an archive it published,
// oci-artifact-registry/<module>/<artifact>@sha256:<digest> (novox/hq ADR 0605 §9; catalogue.SeatReference, read back by
// catalogue.InRegistrySeat), read by a node-engine that states form:seat-reference (catalogue.ReferenceFormSeat). Not a
// first form: an engine stating nothing reads its blob only in the older artifact-store form, which it derives (Older).
const FormSeatReference = catalogue.ReferenceFormSeat
// ArtifactForm is one reference form: its name, whether it is a first form, the artifact kinds it names, how a
// reference is told to be in it, and, for a form that names the same artifact an older form does, that older reference
// (Older, nil when it derives none): the old-form window writes it beside the answered one (ADR 0624 §1, §3).
type ArtifactForm struct {
Name string
First bool
Kinds []string
Is func(reference string) bool
Older func(reference string) string
}
// ArtifactForms are the forms a builder knows, oldest first.
type ArtifactForms []ArtifactForm
// Current are the forms the builder writes and the assigner knows: the three first forms (delivery.BuiltArtifact's
// reference by kind), then the registry seat's form of a bundle or an archive.
var Current = ArtifactForms{
{Name: FormArtifactStore, First: true, Is: func(r string) bool { return strings.HasPrefix(r, artifactStore) },
Kinds: []string{catalogue.ArtifactBundle, catalogue.ArtifactArchive, catalogue.ArtifactImage, catalogue.ArtifactUpstream}},
{Name: FormRegistry, First: true, Is: func(r string) bool { return !strings.HasPrefix(r, artifactStore) && strings.Contains(r, "@sha256:") },
Kinds: []string{catalogue.ArtifactImage, catalogue.ArtifactUpstream}},
{Name: FormPackageVersion, First: true, Is: func(r string) bool { return strings.LastIndex(r, "@") > 0 && !strings.Contains(r, "@sha256:") },
Kinds: []string{catalogue.ArtifactPackage}},
{Name: FormSeatReference, Kinds: []string{catalogue.ArtifactBundle, catalogue.ArtifactArchive},
Is: func(r string) bool {
seat, _, ok := catalogue.InRegistrySeat(r)
return ok && seat == catalogue.OciArtifactRegistrySeat
},
// The same blob in the artifact store's form: oci-artifact-registry/<m>/<a>@<d> is artifact-store://<m>/<a>/blobs/<d>.
Older: func(r string) string {
path, _ := catalogue.InArtifactStore(r)
return catalogue.ArtifactStoreScheme + path
}},
}
// FormNeed is the reader capability a form needs of a node-engine: `form:<name>`.
func FormNeed(form string) string { return catalogue.ReadForm + form }
// FormOf is the name of the form reference is written in for an artifact of kind, and whether one of fs names it.
func (fs ArtifactForms) FormOf(kind, reference string) (string, bool) {
for i := len(fs) - 1; i >= 0; i-- { // the newest first: a newer form may be a narrower shape of an older one
f := fs[i]
if (kind == "" || slices.Contains(f.Kinds, kind)) && f.Is(reference) {
return f.Name, true
}
}
return "", false
}
// Reads says whether an engine whose statement is stated reads form: a first form always (the baseline included), any
// other only when stated names `form:<name>`. A form fs does not know is read only when stated.
func (fs ArtifactForms) Reads(stated []string, form string) bool {
for _, f := range fs {
if f.Name == form && f.First {
return true
}
}
return slices.Contains(stated, FormNeed(form))
}
// Needs are the reader capabilities a done build's artifacts carry (ADR 0618 §1): `form:<name>` of every form any of
// its references, single or listed, is written in, sorted. Nil when it names none (no artifact, or no reference).
func (fs ArtifactForms) Needs(artifacts []delivery.BuiltArtifact) []string {
set := map[string]bool{}
for _, a := range artifacts {
for _, r := range append([]string{a.Reference}, a.References...) {
if r == "" {
continue
}
if form, ok := fs.FormOf(a.Kind, r); ok {
set[FormNeed(form)] = true
}
}
}
if len(set) == 0 {
return nil
}
out := make([]string, 0, len(set))
for n := range set {
out = append(out, n)
}
sort.Strings(out)
return out
}
// Held says whether a build's reference forms hold a node's want (ADR 0624 §2): only when the build names forms and the
// node's engine, its statement stated, reads none of them. A build that names none holds no want.
func (fs ArtifactForms) Held(needs, stated []string) bool {
if len(needs) == 0 {
return false
}
for _, n := range needs {
if form, ok := strings.CutPrefix(n, catalogue.ReadForm); ok && fs.Reads(stated, form) {
return false
}
}
return true
}
// Writable are the forms of kind every present node's engine reads (ADR 0618 §2: a new form is written only once every
// present engine states it), oldest first, by node its statement. With no node present, the first forms only.
func (fs ArtifactForms) Writable(kind string, present map[string][]string) []string {
var out []string
for _, f := range fs {
if !slices.Contains(f.Kinds, kind) {
continue
}
every := f.First || len(present) > 0
for _, stated := range present {
every = every && fs.Reads(stated, f.Name)
}
if every {
out = append(out, f.Name)
}
}
return out
}
// Kept are the forms a build key names an artifact of kind in (ADR 0624 §1, §3), oldest first, so the first is the one
// the single reference holds: the newest form every present engine reads (Writable), then, for each counted node, present
// or away, whose engine reads none of those kept so far, the newest form of kind it reads. A node stating nothing reads
// the first forms. A form leaves only once no counted node reads it alone; a node leaves the count when it is retired.
func (fs ArtifactForms) Kept(kind string, present, counted map[string][]string) []string {
writable := fs.Writable(kind, present)
kept := map[string]bool{}
if len(writable) > 0 {
kept[writable[len(writable)-1]] = true
}
nodes := make([]string, 0, len(counted))
for node := range counted {
nodes = append(nodes, node)
}
sort.Strings(nodes)
for _, node := range nodes {
stated := counted[node]
var newest string
readsKept := false
for _, f := range fs {
if !slices.Contains(f.Kinds, kind) || !fs.Reads(stated, f.Name) {
continue
}
newest = f.Name
readsKept = readsKept || kept[f.Name]
}
if !readsKept && newest != "" {
kept[newest] = true
}
}
var out []string
for _, f := range fs {
if kept[f.Name] {
out = append(out, f.Name)
}
}
return out
}
// Choose names a built artifact by the references the build answered in the forms Kept keeps (ADR 0624 §1, §3): the
// oldest kept form in the single Reference, every kept one in References when there are more than one. A reference in a
// form Kept does not keep is left out, unless none of the answered is kept: then the artifact keeps what was answered,
// its first answered in Reference, since a key names where its artifact is (ADR 0498 clause 1). An answered reference
// in a form that derives an older one (ArtifactForm.Older) answers that older one too, so the build answers one form and
// the key keeps each a counted node reads.
func (fs ArtifactForms) Choose(a delivery.BuiltArtifact, answered []string, present, counted map[string][]string) delivery.BuiltArtifact {
kept := fs.Kept(a.Kind, present, counted)
answered = fs.withOlder(a.Kind, answered)
type ref struct {
at int
reference string
}
var in []ref
for _, r := range answered {
if r == "" {
continue
}
form, ok := fs.FormOf(a.Kind, r)
if at := slices.Index(kept, form); ok && at >= 0 {
in = append(in, ref{at, r})
}
}
sort.SliceStable(in, func(i, j int) bool { return in[i].at < in[j].at })
a.References = nil
switch {
case len(in) > 0:
a.Reference = in[0].reference
if len(in) > 1 {
for _, r := range in {
a.References = append(a.References, r.reference)
}
}
default:
a.Reference = ""
for _, r := range answered {
if r == "" {
continue
}
if a.Reference == "" {
a.Reference = r
}
a.References = append(a.References, r)
}
if len(a.References) < 2 {
a.References = nil
}
}
return a
}
// withOlder is answered with, after it, each older reference an answered one's form derives that was not answered itself.
func (fs ArtifactForms) withOlder(kind string, answered []string) []string {
out := slices.Clone(answered)
for _, r := range answered {
name, ok := fs.FormOf(kind, r)
if !ok {
continue
}
for _, f := range fs {
if f.Name == name && f.Older != nil {
if older := f.Older(r); !slices.Contains(out, older) {
out = append(out, older)
}
}
}
}
return out
}
+8
View File
@@ -6,3 +6,11 @@ require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8
github.com/nats-io/nats.go v1.54.0
)
require (
github.com/klauspost/compress v1.20.0 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/sys v0.48.0 // indirect
)
-2
View File
@@ -1,5 +1,3 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=