diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go index 165d89f5..8f615fbf 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go @@ -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 diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go index 8e8b47ad..e9bc747e 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go @@ -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 ``, 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() + }() +} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go index f8480563..e560a24d 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go @@ -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) diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go index ec5f1bc0..560572a4 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go @@ -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) + } + } }) } diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/tools.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/tools.go index 30f55902..59cab6a3 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/tools.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/tools.go @@ -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) } diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go index ac28931e..90062381 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go @@ -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` 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). diff --git a/modules/mesh-delivery-assigner/go.mod b/modules/mesh-delivery-assigner/go.mod index 777c0c97..f1ce8f99 100644 --- a/modules/mesh-delivery-assigner/go.mod +++ b/modules/mesh-delivery-assigner/go.mod @@ -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 + diff --git a/modules/mesh-delivery-assigner/go.sum b/modules/mesh-delivery-assigner/go.sum index 7679a5dc..98dc72c4 100644 --- a/modules/mesh-delivery-assigner/go.sum +++ b/modules/mesh-delivery-assigner/go.sum @@ -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= diff --git a/modules/mesh-delivery-assigner/internal/assign/wants.go b/modules/mesh-delivery-assigner/internal/assign/wants.go index 5057439e..f029f074 100644 --- a/modules/mesh-delivery-assigner/internal/assign/wants.go +++ b/modules/mesh-delivery-assigner/internal/assign/wants.go @@ -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:`, 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 } diff --git a/modules/mesh-delivery-assigner/internal/assign/wants_test.go b/modules/mesh-delivery-assigner/internal/assign/wants_test.go index 5e61414d..a3ce7e58 100644 --- a/modules/mesh-delivery-assigner/internal/assign/wants_test.go +++ b/modules/mesh-delivery-assigner/internal/assign/wants_test.go @@ -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) } diff --git a/modules/mesh-delivery-assigner/manifest_test.go b/modules/mesh-delivery-assigner/manifest_test.go index b6169296..4ce9d83e 100644 --- a/modules/mesh-delivery-assigner/manifest_test.go +++ b/modules/mesh-delivery-assigner/manifest_test.go @@ -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"}) { diff --git a/modules/mesh-delivery-assigner/module.json b/modules/mesh-delivery-assigner/module.json index 496111dc..08ac20df 100644 --- a/modules/mesh-delivery-assigner/module.json +++ b/modules/mesh-delivery-assigner/module.json @@ -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": [ { diff --git a/modules/mesh-delivery-builder/buildrun/standing.go b/modules/mesh-delivery-builder/buildrun/standing.go index c0b92e96..fdaf1da6 100644 --- a/modules/mesh-delivery-builder/buildrun/standing.go +++ b/modules/mesh-delivery-builder/buildrun/standing.go @@ -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 // / -> 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() + } } } }() diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/main.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/main.go index 650113cb..110fd3f2 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/main.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/main.go @@ -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, /, as builder_versions lists it"}, + "fp": map[string]any{"type": "string", "description": "the fingerprint, src2:"}, + "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": "/"}, + "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, /"}, + "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, /"}}}, + 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) diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/branchend.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/branchend.go new file mode 100644 index 00000000..a63c2785 --- /dev/null +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/branchend.go @@ -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..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) + } +} diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/findings.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/findings.go new file mode 100644 index 00000000..7fe0f73a --- /dev/null +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/findings.go @@ -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."}) + } +} diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/integration_test.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/integration_test.go index 2b9e6c38..4c47f301 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/integration_test.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/integration_test.go @@ -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. 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 := ®istry{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 +} diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go index b9b21eac..ff5791ff 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go @@ -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 ( diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/oldform.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/oldform.go new file mode 100644 index 00000000..ee93522e --- /dev/null +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/oldform.go @@ -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) + } +} diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/reads.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/reads.go new file mode 100644 index 00000000..fd426cb4 --- /dev/null +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/reads.go @@ -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 ``, 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 +} diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/reasks.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/reasks.go index f2b4f371..3e57a46a 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/reasks.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/reasks.go @@ -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..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) + } +} diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/refused_test.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/refused_test.go index 08d5fb7c..e98037ab 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/refused_test.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/refused_test.go @@ -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 +} diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/seats.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/seats.go new file mode 100644 index 00000000..58ca0421 --- /dev/null +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/seats.go @@ -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 `.` 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 +} diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/tagbounds.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/tagbounds.go new file mode 100644 index 00000000..4c6d3618 --- /dev/null +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/tagbounds.go @@ -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 + } + } + } + } +} diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/versions.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/versions.go new file mode 100644 index 00000000..feabc77b --- /dev/null +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/versions.go @@ -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:.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..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 (`/`, `.`): 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) + } +} diff --git a/modules/mesh-delivery-builder/go.mod b/modules/mesh-delivery-builder/go.mod index ec9702c5..1371e1d1 100644 --- a/modules/mesh-delivery-builder/go.mod +++ b/modules/mesh-delivery-builder/go.mod @@ -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 diff --git a/modules/mesh-delivery-builder/go.sum b/modules/mesh-delivery-builder/go.sum index 7ff42872..4fbe62eb 100644 --- a/modules/mesh-delivery-builder/go.sum +++ b/modules/mesh-delivery-builder/go.sum @@ -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= diff --git a/modules/mesh-delivery-builder/internal/build/claim_test.go b/modules/mesh-delivery-builder/internal/build/claim_test.go index ed359a31..54cf5e55 100644 --- a/modules/mesh-delivery-builder/internal/build/claim_test.go +++ b/modules/mesh-delivery-builder/internal/build/claim_test.go @@ -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) { diff --git a/modules/mesh-delivery-builder/internal/build/numbers.go b/modules/mesh-delivery-builder/internal/build/numbers.go new file mode 100644 index 00000000..06d019cf --- /dev/null +++ b/modules/mesh-delivery-builder/internal/build/numbers.go @@ -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 +} diff --git a/modules/mesh-delivery-builder/internal/build/run.go b/modules/mesh-delivery-builder/internal/build/run.go index 12eca8e3..8504b0ec 100644 --- a/modules/mesh-delivery-builder/internal/build/run.go +++ b/modules/mesh-delivery-builder/internal/build/run.go @@ -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{} diff --git a/modules/mesh-delivery-builder/internal/build/run_test.go b/modules/mesh-delivery-builder/internal/build/run_test.go index 003bda37..788aac2b 100644 --- a/modules/mesh-delivery-builder/internal/build/run_test.go +++ b/modules/mesh-delivery-builder/internal/build/run_test.go @@ -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//@) 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) + } + } +} diff --git a/modules/mesh-delivery-builder/internal/build/stuck.go b/modules/mesh-delivery-builder/internal/build/stuck.go index d6a713c0..93dd5703 100644 --- a/modules/mesh-delivery-builder/internal/build/stuck.go +++ b/modules/mesh-delivery-builder/internal/build/stuck.go @@ -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 (`/`) 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, /, 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.`, 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, /, 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.`, 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. diff --git a/modules/mesh-delivery-builder/internal/versions/again.go b/modules/mesh-delivery-builder/internal/versions/again.go new file mode 100644 index 00000000..c298beac --- /dev/null +++ b/modules/mesh-delivery-builder/internal/versions/again.go @@ -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: /") + 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-.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 " + "-" + t.Tag + ". 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 +} diff --git a/modules/mesh-delivery-builder/internal/versions/branchend.go b/modules/mesh-delivery-builder/internal/versions/branchend.go new file mode 100644 index 00000000..c2be5eff --- /dev/null +++ b/modules/mesh-delivery-builder/internal/versions/branchend.go @@ -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) +} diff --git a/modules/mesh-delivery-builder/internal/versions/claim.go b/modules/mesh-delivery-builder/internal/versions/claim.go new file mode 100644 index 00000000..957ec6b1 --- /dev/null +++ b/modules/mesh-delivery-builder/internal/versions/claim.go @@ -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: `/`, the name the seat +// answers it by (ADR 0605 §9's `//@…`). 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.`). +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 +} diff --git a/modules/mesh-delivery-builder/internal/versions/floor.go b/modules/mesh-delivery-builder/internal/versions/floor.go new file mode 100644 index 00000000..8beea79b --- /dev/null +++ b/modules/mesh-delivery-builder/internal/versions/floor.go @@ -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:.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 `.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 +} diff --git a/modules/mesh-delivery-builder/internal/versions/heard.go b/modules/mesh-delivery-builder/internal/versions/heard.go new file mode 100644 index 00000000..d5223107 --- /dev/null +++ b/modules/mesh-delivery-builder/internal/versions/heard.go @@ -0,0 +1,63 @@ +package versions + +import ( + "context" + "fmt" +) + +// The registry seats' events the builder hears on its durable consumer of each `.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 +} diff --git a/modules/mesh-delivery-builder/internal/versions/names.go b/modules/mesh-delivery-builder/internal/versions/names.go new file mode 100644 index 00000000..65b57eff --- /dev/null +++ b/modules/mesh-delivery-builder/internal/versions/names.go @@ -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 `/` 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 `.`; 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 + } +} diff --git a/modules/mesh-delivery-builder/internal/versions/oldform.go b/modules/mesh-delivery-builder/internal/versions/oldform.go new file mode 100644 index 00000000..0342c01b --- /dev/null +++ b/modules/mesh-delivery-builder/internal/versions/oldform.go @@ -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) +} diff --git a/modules/mesh-delivery-builder/internal/versions/store.go b/modules/mesh-delivery-builder/internal/versions/store.go new file mode 100644 index 00000000..4f064420 --- /dev/null +++ b/modules/mesh-delivery-builder/internal/versions/store.go @@ -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 +} diff --git a/modules/mesh-delivery-builder/internal/versions/tagbound.go b/modules/mesh-delivery-builder/internal/versions/tagbound.go new file mode 100644 index 00000000..51cfdc83 --- /dev/null +++ b/modules/mesh-delivery-builder/internal/versions/tagbound.go @@ -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 +} diff --git a/modules/mesh-delivery-builder/internal/versions/tools.go b/modules/mesh-delivery-builder/internal/versions/tools.go new file mode 100644 index 00000000..bfe00f39 --- /dev/null +++ b/modules/mesh-delivery-builder/internal/versions/tools.go @@ -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." diff --git a/modules/mesh-delivery-builder/internal/versions/versions.go b/modules/mesh-delivery-builder/internal/versions/versions.go new file mode 100644 index 00000000..42c07212 --- /dev/null +++ b/modules/mesh-delivery-builder/internal/versions/versions.go @@ -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 `.`. +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 .", series) + } + if major, err = strconv.Atoi(a); err != nil || major < 0 { + return 0, 0, fmt.Errorf("series %q is not .", series) + } + if minor, err = strconv.Atoi(b); err != nil || minor < 0 { + return 0, 0, fmt.Errorf("series %q is not .", 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.