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 47c923a..1323409 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 @@ -18,9 +18,11 @@ import ( "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" + stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/announce" "git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/compose/judge" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/process" "git.novox.be/novox/mesh-sdk/go/provider" @@ -70,7 +72,11 @@ func startBed(t *testing.T, leaveOut string) *bed { if name == leaveOut { continue } - kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: name, History: 5}) + cfg := jetstream.KeyValueConfig{Bucket: name, History: 5} + if name == delivery.PlacementPulsesBucket { + cfg.History, cfg.LimitMarkerTTL = 1, time.Minute // a time to live per key, as the controller makes it (key-ttl) + } + kv, err := js.CreateKeyValue(ctx, cfg) if err != nil { t.Fatal(err) } @@ -129,9 +135,7 @@ func (b *bed) startStoppable(t *testing.T) (<-chan error, func()) { // 1297/B2b: started whole on its credential, the assigner says on the bus every state its manifest declares, each readable, // having opened every bucket it names. The states are read from module.json, so a state added there is expected here too. -func TestTheAssignerStartsAndSaysWhatItKeeps(t *testing.T) { - b := startBed(t, "") - done := b.start(t) +func saysWhatItKeeps(t *testing.T, b *bed, done <-chan error) { events, err := b.js.Stream(b.ctx, "EVENTS") if err != nil { t.Fatal(err) @@ -223,8 +227,8 @@ func TestTheAssignerDoesNotStartWithoutABucketItReads(t *testing.T) { } } - // Review of L2 (D1): without the indexer's placement state it starts all the same, and once the bucket is made (the - // indexer and the assigner come in one delivery) it is read with no restart: a module only the placement names is + // Without the seat's placement state it starts all the same, and once the bucket is made (the controller makes + // it from the manifest) it is read with no restart: a module only the placement names is // said as placed by one source only. pb := startBed(t, delivery.PlacementBucket) pdone := pb.start(t) @@ -297,10 +301,6 @@ func (b *bed) manifest(t *testing.T, module, body string) string { // 1297/AC1: against the real bus, a fixture change touching one module on one machine is composed and answered within // seconds under the checker's key of the check, naming the machine composed and the mesh-sdk it composed with. func TestAChangeIsComposedOnTheBus(t *testing.T) { - // The placement answers' bound (ADR 0559 §5), shortened before the program starts and put back after it stops. - bound := assign.AnswerBound - t.Cleanup(func() { assign.AnswerBound = bound }) - assign.AnswerBound = 1500 * time.Millisecond b := startBed(t, "") trunk := b.manifest(t, "web", `{"module":"web","version":"1"}`) changed := b.manifest(t, "web", `{"module":"web","version":"2"}`) @@ -352,63 +352,6 @@ func TestAChangeIsComposedOnTheBus(t *testing.T) { } else if info, err := st.Info(b.ctx); err != nil || info.State.Msgs != 0 { t.Errorf("a group without a mesh-sdk member put %d asks on the Go build seat (%v)", info.State.Msgs, err) } - - // ADR 0559 §5 (issue 1126): a catalogue pull request that places g14 for the first time (the seed's case) is composed - // with its candidate placement, touches g14, and waits for g14's answer at its head; g14 answering the same passes it. - // A node that does not answer within the bound fails the check, named, at the bound's end with no other event; its later - // answer at the same head passes the same heads' composition. - record, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: "g14"}}) - b.putJSON(t, delivery.HaveBucket, "g14.node", record) - placed := func(group string, number int, head string) (string, uint64) { - t.Helper() - value, err := delivery.EncodePlacementCandidate(delivery.PlacementCandidate{Group: group, - Pull: delivery.Head{Repository: delivery.CatalogueRepository, Number: number, Head: head}, - Placement: &delivery.Placement{Node: "g14", Modules: []delivery.PlacedModule{{Module: "web"}}}}) - if err != nil { - t.Fatal(err) - } - key, _ := delivery.PlacementCandidateKey(group, "g14") - rev, err := b.buckets[delivery.PlacementCandidatesBucket].Put(b.ctx, key, value) - if err != nil { - t.Fatal(err) - } - pull := []delivery.CandidatePull{{Head: delivery.Head{Repository: delivery.CatalogueRepository, Number: number, Head: head}, Modules: []string{}}} - hash, _ := delivery.HeadsHash([]delivery.Head{pull[0].Head}) - cand, err := delivery.EncodeCandidate(delivery.Candidate{Group: group, HeadsHash: hash, Pulls: pull, FPSet: delivery.FingerprintSet(pull)}) - if err != nil { - t.Fatal(err) - } - ckey, _ := delivery.CandidatesKey(group) - b.putJSON(t, delivery.CandidatesBucket, ckey, cand) - key, _ = delivery.CompositionKey(group, hash) - return key, rev - } - answer := func(group, head string, rev uint64) { - t.Helper() - have, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: "g14"}, - Candidates: map[string]delivery.CandidateAnswer{group: {Head: head, Revision: rev, Verdict: delivery.CandidateSame, Same: []string{"web"}}}}) - if err != nil { - t.Fatal(err) - } - b.putJSON(t, delivery.HaveBucket, "g14.node", have) - } - seedHead, lateHead := strings.Repeat("5", 40), strings.Repeat("6", 40) - seed, seedRev := placed("feat/seed", 20, seedHead) - waiting := b.verdict(t, seed, func(v delivery.CompositionVerdict) bool { return v.State == delivery.CompositionWaits }) - if !strings.Contains(waiting.Words, "g14") { - t.Errorf("the seed's composition waits saying %q, want g14 named", waiting.Words) - } - answer("feat/seed", seedHead, seedRev) - if v := b.verdict(t, seed, func(v delivery.CompositionVerdict) bool { return v.State == delivery.CompositionPass }); !slices.Contains(v.Machines, "g14") { - t.Errorf("the seed's composition = %+v, want g14 composed", v) - } - late, lateRev := placed("feat/late", 21, lateHead) - failed := b.verdict(t, late, func(v delivery.CompositionVerdict) bool { return v.State == delivery.CompositionFail }) - if failed.Machine != "g14" || !strings.Contains(failed.Words, delivery.CompositionUnanswered) { - t.Errorf("a node silent past its bound: %+v", failed) - } - answer("feat/late", lateHead, lateRev) - b.verdict(t, late, func(v delivery.CompositionVerdict) bool { return v.State == delivery.CompositionPass }) } // get waits for a key of one of the bed's buckets and answers its value. @@ -1062,3 +1005,196 @@ func TestAHoldWrittenAgainAfterALiftAndARestartIsAskedAgain(t *testing.T) { } } } + +// bedStore is the runtime's states over the bed's buckets, as the seat's verbs read and write them: the assigner's own by +// their local names, another module's `.`. +type bedStore struct{ b *bed } + +func (s bedStore) kv(state string) jetstream.KeyValue { + if strings.Contains(state, ".") { + return s.b.buckets[strings.Replace(state, ".", "_", 1)] + } + return s.b.buckets[delivery.AssignerModule+"_"+state] +} + +func (s bedStore) get(state, key string) ([]byte, uint64, bool, error) { + e, err := s.kv(state).Get(s.b.ctx, key) + if err != nil { + return nil, 0, false, nil + } + return e.Value(), e.Revision(), true, nil +} + +func (s bedStore) put(state, key string, value []byte) error { + _, err := s.kv(state).Put(s.b.ctx, key, value) + return err +} + +func (s bedStore) keys(state string) ([]string, error) { + keys, err := s.kv(state).Keys(s.b.ctx) + if err != nil && strings.Contains(err.Error(), "no keys") { + return nil, nil + } + return keys, err +} + +func (s bedStore) del(state, key string) error { return s.kv(state).Delete(s.b.ctx, key) } + +func (s bedStore) history(string, string) ([]stdio.StateRevision, error) { return nil, nil } + +// 1126 (ADR 0567 §2-§6), the seat mesh-placement whole on the bus: a proposal moving web from the anchor to the laptop is +// proposed through the verb, the assigner finds both nodes must answer, they answer the same in their haves, the +// operator is asked and approves, and each key is written at a new revision while placement-changed is heard on the +// assigner's stream naming both nodes. A second proposal laid over the old revision of a key moved since is laid again, +// and declined, writes nothing and is withdrawn. +func TestAProposalIsAnsweredApprovedAndWritten(t *testing.T) { + b := startBed(t, "") + if _, err := b.js.CreateStream(b.ctx, jetstream.StreamConfig{Name: "MOD_" + delivery.AssignerModule + "_placement", + Subjects: []string{process.ModuleStreamSubject(delivery.AssignerModule, ">")}}); err != nil { + t.Fatal(err) + } + heard := make(chan delivery.PlacementEvent, 16) + nc, err := nats.Connect(b.srv.ClientURL()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(nc.Close) + if _, err := nc.Subscribe(process.ModuleStreamSubject(delivery.AssignerModule, delivery.PlacementChangedEvent), func(m *nats.Msg) { + var e delivery.PlacementEvent + if json.Unmarshal(m.Data, &e) == nil { + heard <- e + } + }); err != nil { + t.Fatal(err) + } + for _, node := range []string{"anchor", "laptop"} { + rec, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: node}}) + b.putJSON(t, delivery.HaveBucket, node+".node", rec) + } + b.putJSON(t, delivery.ModulesBucket, "web", []byte(`{"v":1}`)) + b.putJSON(t, delivery.ModulesBucket, "shell", []byte(`{"v":1}`)) + place := func(node string, modules ...string) uint64 { + p := delivery.Placement{Node: node, Modules: []delivery.PlacedModule{}} + for _, m := range modules { + p.Modules = append(p.Modules, delivery.PlacedModule{Module: m}) + } + raw, _ := delivery.EncodePlacement(p) + key, _ := delivery.PlacementKey(node) + rev, err := b.buckets[delivery.PlacementBucket].Put(b.ctx, key, raw) + if err != nil { + t.Fatal(err) + } + return rev + } + anchorRev, laptopRev := place("anchor", "web"), place("laptop", "shell") + bound := judge.AnswerBound + t.Cleanup(func() { judge.AnswerBound = bound }) + judge.AnswerBound = 2 * time.Second + saysWhatItKeeps(t, b, b.start(t)) + verbs := newSeatTools(bedStore{b}, func(string, any) (json.RawMessage, error) { return json.RawMessage(`{"ok":true}`), nil }) + propose := func() delivery.PlacementProposal { + t.Helper() + got, err := verbs.doPropose(map[string]any{"why": "move web", "changes": `[{"op":"take-off","node":"anchor","module":"web"},` + + `{"op":"place","node":"laptop","module":"web"}]`}, "operator@laptop") + if err != nil { + t.Fatal(err) + } + return got.(proposed).Proposal + } + proposalNow := func(id, what string, ok func(delivery.PlacementProposal) bool) delivery.PlacementProposal { + t.Helper() + var last delivery.PlacementProposal + for deadline := time.Now().Add(15 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) { + if e, err := b.buckets[delivery.PlacementProposalsBucket].Get(b.ctx, id); err == nil { + if p, err := delivery.DecodePlacementProposal(e.Value()); err == nil { + if last = p; ok(p) { + return p + } + } + } + } + t.Fatalf("the proposal %s never was %s: %+v", id, what, last) + return last + } + answerAll := func(p delivery.PlacementProposal) { + t.Helper() + for _, node := range p.Must { + key, _ := delivery.PlacementCandidateKey(p.ID, node) + e, err := b.buckets[delivery.PlacementCandidatesBucket].Get(b.ctx, key) + if err != nil { + t.Fatalf("no candidate %s: %v", key, err) + } + have, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: node}, Candidates: map[string]delivery.CandidateAnswer{ + p.ID: {Proposal: p.ID, Revision: e.Revision(), Verdict: delivery.CandidateSame, Same: []string{"node"}}}}) + b.putJSON(t, delivery.HaveBucket, node+".node", have) + } + } + + p := propose() + p = proposalNow(p.ID, "told who must answer", func(p delivery.PlacementProposal) bool { return len(p.Must) > 0 }) + if !slices.Equal(p.Must, []string{"anchor", "laptop"}) { + t.Fatalf("must answer = %v, want both nodes", p.Must) + } + answerAll(p) + p = proposalNow(p.ID, "asked", func(p delivery.PlacementProposal) bool { return p.State == delivery.ProposalAsked }) + akey, _ := delivery.PlacementAskKey(p.ID) + b.answer(t, akey, assign.OptionYes) + select { + case e := <-heard: + if e.Proposal.ID != p.ID || len(e.Nodes) != 2 || e.Nodes[0].Before != anchorRev || e.Nodes[1].Before != laptopRev || + e.Nodes[0].After <= anchorRev || e.Warrant == nil || !slices.Equal(e.Nodes[0].Removed, []string{"web"}) { + t.Errorf("placement-changed = %+v", e) + } + case <-time.After(15 * time.Second): + t.Fatal("no placement-changed heard") + } + for node, want := range map[string][]string{"anchor": {}, "laptop": {"shell", "web"}} { + key, _ := delivery.PlacementKey(node) + e, err := b.buckets[delivery.PlacementBucket].Get(b.ctx, key) + if err != nil { + t.Fatal(err) + } + if got, err := delivery.DecodePlacement(e.Value()); err != nil || !slices.Equal(got.Assigned(), want) { + t.Errorf("%s = %v, %v, want %v", node, got.Assigned(), err, want) + } + } + if b.hasKey(delivery.PlacementProposalsBucket, p.ID) { + t.Error("an applied proposal stays in the state") + } + + // A proposal whose key moves before its warrant is laid again; then declined, it writes nothing and ends. + q, err := verbs.doPropose(map[string]any{"why": "back", "changes": `[{"op":"take-off","node":"laptop","module":"web"}]`}, "op") + if err != nil { + t.Fatal(err) + } + moved := q.(proposed).Proposal + place("laptop", "shell", "web") // another writer moved the key + relaid := proposalNow(moved.ID, "laid again", func(p delivery.PlacementProposal) bool { + return strings.Contains(p.Words, "laid again") && len(p.Must) > 0 + }) + answerAll(relaid) + proposalNow(moved.ID, "asked", func(p delivery.PlacementProposal) bool { return p.State == delivery.ProposalAsked }) + akey, _ = delivery.PlacementAskKey(moved.ID) + b.answer(t, akey, assign.OptionNo) + for deadline := time.Now().Add(15 * time.Second); b.hasKey(delivery.PlacementProposalsBucket, moved.ID); time.Sleep(50 * time.Millisecond) { + if time.Now().After(deadline) { + t.Fatal("a declined proposal was not ended") + } + } + key, _ := delivery.PlacementKey("laptop") + if e, _ := b.buckets[delivery.PlacementBucket].Get(b.ctx, key); e == nil || !strings.Contains(string(e.Value()), "web") { + t.Error("a declined proposal wrote its key") + } + + // ADR 0567 §4: a node that does not answer fails the verdict once its pulse key's time to live ends, heard on the bus + // with no timer of the assigner's, naming it. + q, err = verbs.doPropose(map[string]any{"why": "again", "changes": `[{"op":"take-off","node":"laptop","module":"web"}]`}, "op") + if err != nil { + t.Fatal(err) + } + silent := q.(proposed).Proposal + failed := proposalNow(silent.ID, "failed for a silent node", func(p delivery.PlacementProposal) bool { return p.State == delivery.ProposalFailed }) + if failed.Verdict == nil || !strings.Contains(strings.Join(failed.Verdict.Fails, "; "), "laptop (offline) did not answer") { + t.Errorf("the silent node's verdict = %+v", failed.Verdict) + } +} 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 393f5dd..54791b2 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go @@ -13,7 +13,7 @@ import ( "log" "os" "os/signal" - "sync" + "slices" "syscall" "time" @@ -22,6 +22,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/announce" "git.novox.be/novox/mesh-sdk/go/asks" + "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/delivery/buildrun" "git.novox.be/novox/mesh-sdk/go/process" @@ -49,6 +50,7 @@ func main() { // readBuckets are the buckets of other parts the assigner reads, each named in its manifest's reads (the manifest test // holds the two lists equal), so the controller's broker grants exactly these. var readBuckets = []string{ + buscompose.ResolvedBucket, provider.AssignmentsBucket, delivery.BuildsBucket, delivery.ControllerGrantsBucket, @@ -58,8 +60,6 @@ var readBuckets = []string{ delivery.CandidatesBucket, delivery.ManifestsBucket, delivery.ModulesBucket, - delivery.PlacementBucket, - delivery.PlacementCandidatesBucket, delivery.PresenceBucket, delivery.HaveBucket, delivery.StepsBucket(goBuildModule), @@ -69,14 +69,26 @@ var readBuckets = []string{ // their steps in its steps state (novox/hq ADR 0482 clause 4). const goBuildModule = "mesh-build-go" -// ownBuckets are the assigner's own states, as its manifest declares them. +// ownBuckets are the assigner's own states, as its manifest declares them: the seat mesh-placement's three among them +// (ADR 0567 §1), named after the assigner as every module state is. var ownBuckets = []string{delivery.AssignerAsksBucket, delivery.AssignerCompositionsBucket, delivery.AssignerDifferencesBucket, - delivery.AssignerPutsBucket, delivery.AssignerWantsBucket} + delivery.AssignerPutsBucket, delivery.AssignerWantsBucket, delivery.PlacementBucket, delivery.PlacementProposalsBucket, + delivery.PlacementCandidatesBucket, delivery.PlacementPulsesBucket} + +// 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). +var seatBuckets = []string{delivery.PlacementBucket, delivery.PlacementProposalsBucket, delivery.PlacementCandidatesBucket, + delivery.PlacementPulsesBucket} // keptStates is what the assigner keeps and who may read it (ADR 0495 §2-§3): its wants by the watchman and the view // (and the node agent from release 6), its differences by the view, its compositions by the checker, its asks by the view -// and the teller, its puts by whoever follows one (ADR 0546). +// and the teller, its puts by whoever follows one (ADR 0546), and the seat mesh-placement's placement, proposals and +// candidates by the node agents, the grants part, mesh-config and the view (ADR 0567 §9). var keptStates = []announce.State{ + {Name: delivery.PlacementState, Readable: true}, + {Name: delivery.PlacementProposalsState, Readable: true}, + {Name: delivery.PlacementCandidatesState, Readable: true}, + {Name: delivery.PlacementPulsesState, Readable: true}, {Name: "asks", Readable: true}, {Name: "compositions", Readable: true}, {Name: "differences", Readable: true}, @@ -99,10 +111,10 @@ func open(ctx context.Context, js process.BucketOpener, logf func(string, ...any logf("the bucket %s cannot be opened (%v): no put is taken further until the assigner starts again", name, err) continue } - if name == delivery.PlacementBucket && err != nil { - // The indexer's placement state comes with the second milestone (ADR 0549 §2): without it the controller's - // assignments alone name where modules run, as before any placement was read (ADR 0549 §5). - logf("the bucket %s cannot be opened (%v): the controller's assignments alone are read until the assigner starts again", name, err) + if slices.Contains(seatBuckets, name) && err != nil { + // The seat's states come in the same delivery as this assigner (ADR 0567 §1): without placement the controller's + // assignments alone name where modules run, as before any placement was written (ADR 0549 §5). + logf("the bucket %s cannot be opened (%v): it is bound once the bus says it was made", name, err) continue } if err != nil { @@ -139,10 +151,14 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro if err := said.Keeps(keptStates...); err != nil { logger.Printf("what the assigner keeps could not be said on the bus, so its readers are not granted until it is: %v", err) } + thisNode = account.Node logger.Printf("started on the account %s, reading %d buckets", account.User, len(kvs)) return follow(ctx, js, conn, kvs, sayVia(ctx, js), emitting{said}, logger) } +// thisNode is the node this process runs on, as its credential names it: said with each of the seat's events. +var thisNode string + // emitting is the process connection as the puts' caller: its calls, and its events with no options. type emitting struct{ *process.Conn } @@ -151,6 +167,7 @@ func (e emitting) Emit(eventType string, body any) error { return e.Conn.Emit(ev // watched are the buckets whose every value a pass reads; the manifests are read by digest when a pass needs one, and // watched only for keys, so a manifest that arrives wakes a composition that waits for it. var watched = []string{ + buscompose.ResolvedBucket, provider.AssignmentsBucket, delivery.ControllerGrantsBucket, delivery.ControllerComposedBucket, @@ -159,7 +176,9 @@ var watched = []string{ delivery.CandidatesBucket, delivery.ModulesBucket, delivery.PlacementBucket, + delivery.PlacementProposalsBucket, delivery.PlacementCandidatesBucket, + delivery.PlacementPulsesBucket, delivery.PresenceBucket, delivery.HaveBucket, delivery.AssignerAsksBucket, @@ -256,7 +275,7 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber, } started := 0 for _, name := range watched { - if kvs[name] == nil && (name == delivery.PlacementBucket || name == delivery.PlacementCandidatesBucket) { + if kvs[name] == nil && slices.Contains(seatBuckets, name) { continue // not on the bus at this start (open said so) } started++ @@ -271,24 +290,24 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber, if err := watch(delivery.ManifestsBucket, jetstream.MetaOnly()); err != nil { return err } - // The indexer's placement state arrives with the same delivery as this assigner (ADR 0549 §2): when it is not on the - // bus at this start it is bound once the bus says it was created, and watched from then on, with no restart (issue - // 1790's kind). Until then the controller's assignments alone name where modules run (ADR 0549 §5). + // The seat's states arrive with the same delivery as this assigner (ADR 0567 §1): one not on the bus at this start is + // bound once the bus says it was created, and watched from then on, with no restart (issue 1790's kind). Until then the + // controller's assignments alone name where modules run (ADR 0549 §5). type appeared struct { name string kv jetstream.KeyValue } - placementAppeared := make(chan appeared, 2) - if kvs[delivery.PlacementBucket] == nil && js != nil { - process.AwaitBucket(ctx, js, nc, delivery.PlacementBucket, "the controller's assignments alone are read", - logger.Printf).Ready(func(kv jetstream.KeyValue) { placementAppeared <- appeared{delivery.PlacementBucket, kv} }) - } - // So are the placement candidates (ADR 0559 §3), with the same delivery as the indexer that writes them. - if kvs[delivery.PlacementCandidatesBucket] == nil && js != nil { - process.AwaitBucket(ctx, js, nc, delivery.PlacementCandidatesBucket, "no change's placement is composed", - logger.Printf).Ready(func(kv jetstream.KeyValue) { placementAppeared <- appeared{delivery.PlacementCandidatesBucket, kv} }) + placementAppeared := make(chan appeared, len(seatBuckets)) + for _, name := range seatBuckets { + if kvs[name] == nil && js != nil { + process.AwaitBucket(ctx, js, nc, name, "the seat mesh-placement waits for it", + logger.Printf).Ready(func(kv jetstream.KeyValue) { placementAppeared <- appeared{name, kv} }) + } } takePlacement := func(a appeared) error { + if kvs[a.name] != nil { + return nil // bound already, by the other of its two events + } kvs[a.name] = a.kv logger.Printf("the bucket %s is open and watched", a.name) return watch(a.name) @@ -303,11 +322,16 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber, } } p := &passer{kvs: kvs, w: w, logf: logger.Printf, say: say, said: map[string]bool{}, answeredAt: map[string]uint64{}} - p.wakeAt = wakeAtVia(ctx, wake) p.asks = newComposeAsks(ctx, buildrun.Asker{Publisher: buildrun.JetStreamAsks{JS: js}, Waiter: buildrun.JetStreamAsks{JS: js}}, wake, logger.Printf) if bus != nil && kvs[delivery.AssignerPutsBucket] != nil { p.acts = newPutActs(bus, wake, logger.Printf) } + if bus != nil { + p.seat = newSeatActs(bus, wake, logger.Printf) + } + if js != nil { + p.publish = streamPublisher(ctx, js, thisNode) + } wake() for { select { @@ -322,36 +346,28 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber, } case <-dirty: reopenPuts(p) + // The grants part makes the seat's buckets in the pass that seals the list granting them, so their creation can + // be said before this credential may hear it: every pass, itself started by a watched change, binds each one the + // bus now holds, and says the seat waits while one is missing. + var unbound []string + for _, name := range seatBuckets { + if kvs[name] != nil || js == nil { + continue + } + if kv, err := js.KeyValue(ctx, name); err == nil { + if err := takePlacement(appeared{name, kv}); err != nil { + return err + } + continue + } + unbound = append(unbound, name) + } + p.sayWaits(unbound) p.pass(ctx) } } } -// wakeAtVia asks wake once at each time it is given, as a context's deadline (ADR 0437 §8: no timer), each time once. -func wakeAtVia(ctx context.Context, wake func()) func(time.Time) { - var mu sync.Mutex - asked := map[int64]bool{} - return func(at time.Time) { - mu.Lock() - defer mu.Unlock() - if asked[at.UnixNano()] { - return - } - asked[at.UnixNano()] = true - go func() { - bound, cancel := context.WithDeadline(ctx, at) - defer cancel() - <-bound.Done() - mu.Lock() - delete(asked, at.UnixNano()) - mu.Unlock() - if ctx.Err() == nil { - wake() - } - }() - } -} - // PutsReopenEvery is how often, at most, a pass opens the puts' bucket again while it is not open. var PutsReopenEvery = time.Minute diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main_test.go index d146cc7..70572f5 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main_test.go @@ -24,6 +24,10 @@ import ( // 1297/B2a: the buckets the program opens are exactly the manifest's reads and states, as the broker names them // (`.` read as the bucket `_`), so it is granted what it reads and reads nothing else. func TestOpensWhatItsManifestNames(t *testing.T) { + // 1297/B2a: without a credential the program does not start, and says why. + if err := run(context.Background(), func(string) string { return "" }, io.Discard); err == nil || !strings.Contains(err.Error(), "MESH_BROKER_FILE") { + t.Errorf("run without a credential = %v", err) + } raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) if err != nil { t.Fatal(err) @@ -105,14 +109,6 @@ func TestOpensWhatItsManifestNames(t *testing.T) { } } -// 1297/B2a: without a credential the program does not start, and says why. -func TestNoCredential(t *testing.T) { - err := run(context.Background(), func(string) string { return "" }, io.Discard) - if err == nil || !strings.Contains(err.Error(), "MESH_BROKER_FILE") { - t.Errorf("run without a credential = %v", err) - } -} - // refusingJS opens every bucket but one, which it refuses with err. type refusingJS struct { jetstream.JetStream 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 e87746a..a1f264f 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go @@ -43,9 +43,14 @@ type passer struct { // nodeFailed is, by node and module, the words of the condition last raised for a failure that is the node's own (ADR 0557 // §1, issue 1496): raised once per words, cleared once when it ends. nodeFailed map[string]string - // wakeAt asks a pass at a time: the end of a placement candidate's answer bound (ADR 0559 §5), as a context's deadline - // (ADR 0437 §8: no timer). Nil asks none. - wakeAt func(at time.Time) + // The seat mesh-placement's holder (seat.go, ADR 0567): its calls of the controller (nil: none is made), how it says + // the seat's events (nil: none is said), the proposals it said, and what Intended computed by proposal and base. + seat *seatActs + publish publisher + saidProposed map[string]bool + // saidWaits is the last said of the seat's wait for its states (sayWaits). + saidWaits string + intended map[string]map[string]map[string]bool } func (p *passer) clock() time.Time { @@ -56,6 +61,7 @@ func (p *passer) clock() time.Time { } func (p *passer) pass(ctx context.Context) { + p.placementSeat(ctx) p.compositions(ctx) p.wants(ctx) p.puts(ctx) @@ -120,7 +126,7 @@ func (p *passer) wants(ctx context.Context) { } } wanted := p.w.wantsOf() - frozen := p.sayPlacement(p.w.placementDiffers()) + frozen := p.sayPlacement(p.withCalls(p.w.placementDiffers())) placed := p.w.placedNodes() puts := p.putsByModule() p.staleAsks(ctx, trunk) @@ -482,9 +488,6 @@ func (p *passer) compositions(ctx context.Context) { } else { v = assign.Compose(*mesh, ch, p.sdk, time.Since(began)) } - if ch.Placement != nil && v.State != delivery.CompositionWaits { - v = p.answers(*mesh, ch, v) - } } if before, err := delivery.DecodeCompositionVerdict(written[out]); err == nil { before.Took, before.V = v.Took, 0 @@ -496,40 +499,6 @@ func (p *passer) compositions(ctx context.Context) { } } -// answers judges every placed node's answer to the change's placement into v (ADR 0559 §5). While a node has not answered -// within the bound the verdict waits, and the pass at the bound's end is asked (wakeAt), so a silent node is failed then, -// named, and a later answer of the same head is judged on its own have's change. -func (p *passer) answers(mesh assign.Mesh, ch assign.Change, v delivery.CompositionVerdict) delivery.CompositionVerdict { - trunkPlaced := map[string]delivery.Placement{} - for key, value := range p.w.read(delivery.PlacementBucket) { - node, err := delivery.ParsePlacementKey(key) - if err != nil { - continue - } - if pl, err := delivery.DecodePlacement(value); err == nil && pl.Node == node { - trunkPlaced[node] = pl - } - } - fps := map[string]string{} - for name, m := range p.w.trunk() { - if m.FP2 != "" { - fps[name] = m.FP2 - } else if m.FP != "" { - fps[name] = m.FP - } - } - intended, notes := assign.Intended(mesh.Input, trunkPlaced, ch.Placement, fps) - a := assign.JudgeAnswers(ch.Placement, assign.PlacedNodes(trunkPlaced, ch.Placement), p.w.candidateAnswers(ch.Group), intended, p.w.online(), p.clock()) - v = assign.WithAnswers(v, a) - if v.State != delivery.CompositionWaits { - v.Warnings = append(v.Warnings, notes...) - } - if a.Waits && p.wakeAt != nil { - p.wakeAt(a.Until) - } - return v -} - // put writes one key of the assigner's own bucket; a write that fails is said and made again at the next pass. func (p *passer) put(ctx context.Context, bucket, key string, encode func() ([]byte, error)) { value, err := encode() 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 1adc490..e584c94 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 @@ -853,7 +853,7 @@ func aPairOneSourceNamesAloneGetsNoWantAndOneCondition(t *testing.T) { p.sayPlacement(p.w.placementDiffers()) if !slices.Equal(got["g14"], []string{"a"}) || !frozen["g14.b"] || !frozen["g14.c"] || frozen["g14.a"] || len(said) != 2 || said[0].State != asks.StateRaised || !strings.Contains(said[0].Explanation, "the controller's assignments names b on g14") || - !strings.Contains(said[1].Explanation, "the catalogue's placement names c on g14") { + !strings.Contains(said[1].Explanation, "the seat mesh-placement's placement names c on g14") { t.Fatalf("one source alone: assigned %v, frozen %v, said %+v", got, frozen, said) } place("g14", "a", "b", "c") diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go new file mode 100644 index 0000000..85d363d --- /dev/null +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go @@ -0,0 +1,810 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "reflect" + "sort" + "strings" + "sync" + "time" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-sdk/go/asks" + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/compose" + "git.novox.be/novox/mesh-sdk/go/compose/frombus" + "git.novox.be/novox/mesh-sdk/go/compose/judge" + "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/process" + + "mesh-delivery-assigner/internal/assign" +) + +// The seat mesh-placement's holder in the assigner's process (novox/hq ADR 0567 §2-§8): each pass follows every open +// proposal one step, from what the world holds. A proposal the verbs wrote is said (placement-proposed); the nodes it +// must hear from are found by composing every node with and without it, and those its key does not change get a reached +// candidate; their answers are judged (placement-judged) and, on a passing verdict, the operator is asked to approve it; +// on the warrant each changed key is written by compare-and-set at the revision it was laid over (placement-changed), +// and the controller's assignments are kept in step through its verbs until the lease write. A key moved meanwhile +// lays the proposal again; a decline, an expiry or a withdrawal ends it (placement-withdrawn). Every event is said on the +// assigner's own stream, so only this process says them. Nothing polls: the wake at an answer's bound is the one wake. + +// publisher says one of the seat's events on the assigner's stream, under a message id the bus keeps one of. +type publisher func(event string, body any, id string) error + +// streamPublisher publishes on mesh.stream.mesh-delivery-assigner.event., with the headers an emit carries. +func streamPublisher(ctx context.Context, js jetstream.JetStream, node string) publisher { + return func(event string, body any, id string) error { + data, err := json.Marshal(body) + if err != nil { + return err + } + msg := nats.NewMsg(process.ModuleStreamSubject(delivery.AssignerModule, event)) + msg.Header.Set("x-event-id", id) + msg.Header.Set("x-source", delivery.AssignerModule) + msg.Header.Set("x-node", node) + msg.Header.Set("x-time", time.Now().UTC().Format(time.RFC3339Nano)) + msg.Header.Set("content-type", "application/json") + msg.Data = data + _, err = js.PublishMsg(ctx, msg, jetstream.WithMsgID(id)) + return err + } +} + +// seatActs keeps what the seat's calls of the controller came to while this process runs: the export read again at a +// seed's warrant, and each call that keeps the controller's assignments in step (§8). +type seatActs struct { + mu sync.Mutex + bus caller + wake func() + logf func(string, ...any) + export map[string]exportCheck + // calls is, by pair (node and module, or node and provision for a pin), what the last call for it came to. + calls map[string]string +} + +// exportCheck is the export read again for one proposal: running, its answer, or why it was not read. +type exportCheck struct { + running bool + export map[string]delivery.Placement + err string +} + +func newSeatActs(bus caller, wake func(), logf func(string, ...any)) *seatActs { + return &seatActs{bus: bus, wake: wake, logf: logf, export: map[string]exportCheck{}, calls: map[string]string{}} +} + +// exportOf is the export read again for a proposal, asked once in the background. +func (a *seatActs) exportOf(id string) exportCheck { + a.mu.Lock() + defer a.mu.Unlock() + if c, ok := a.export[id]; ok { + return c + } + a.export[id] = exportCheck{running: true} + go func() { + defer a.wake() + raw, err := a.bus.Call(exportCall, map[string]any{}) + c := exportCheck{} + if err != nil { + c.err = "the controller's placement-export did not answer: " + err.Error() + } else if c.export, err = assign.ExportOf(raw); err != nil { + c.err = err.Error() + } + a.mu.Lock() + a.export[id] = c + a.mu.Unlock() + }() + return exportCheck{running: true} +} + +// forget drops what is kept of a proposal's export, so a later warrant reads it again. +func (a *seatActs) forget(id string) { + if a == nil { + return + } + a.mu.Lock() + delete(a.export, id) + a.mu.Unlock() +} + +// record records once, in the controller's hand-act log, that placement was written on the operator's warrant (§5): the +// router's record of the teller's ask says who chose and how (ADR 0274). +func (a *seatActs) record(warrant string) { + if a == nil || warrant == "" { + return + } + ask, _, _ := strings.Cut(warrant, "@") + go func() { + if _, err := a.bus.Call("seat:"+catalogue.ControllerSeatName+".warranted", map[string]any{"asker": delivery.TellerModule, "ask": ask}); err != nil { + a.logf("the placement's warrant %s is not recorded in the hand-act log: %v", ask, err) + } + }() +} + +// handAct records a hand-back in the controller's hand-act log (§7). +func (a *seatActs) handAct(prop delivery.PlacementProposal) { + go func() { + if _, err := a.bus.Call(handActCall, map[string]any{"what": "mesh-placement hand-back of " + strings.Join(prop.Nodes(), ", "), + "why": prop.Why, "cause": "asked by " + prop.By + ", approved by the operator"}); err != nil { + a.logf("the hand-back %s is not recorded in the hand-act log: %v", prop.ID, err) + } + }() +} + +// callAll makes the controller's calls in order in the background, each said in the journal and kept by its pair. +func (a *seatActs) callAll(calls []assign.ControllerCall) { + if len(calls) == 0 { + return + } + a.mu.Lock() + for _, c := range calls { + a.calls[c.Node+"."+c.Module] = c.Key() + " is asked" + } + a.mu.Unlock() + go func() { + defer a.wake() + for _, c := range calls { + words := c.Key() + " answered" + if _, err := a.bus.Call("seat:"+catalogue.ControllerSeatName+"."+c.Verb, c.Args); err != nil { + words = c.Key() + " refused: " + err.Error() + } + a.logf("keeping the controller in step with placement: %s", words) + a.mu.Lock() + a.calls[c.Node+"."+c.Module] = words + a.mu.Unlock() + } + }() +} + +// callOf is what the last call for a pair came to, "" when none was made. +func (a *seatActs) callOf(node, module string) string { + if a == nil { + return "" + } + a.mu.Lock() + defer a.mu.Unlock() + return a.calls[node+"."+module] +} + +// withCalls names, on each pair the two sources do not both name, the call the assigner made for it (§8). +func (p *passer) withCalls(pairs []assign.Pair) []assign.Pair { + for i := range pairs { + pairs[i].Call = p.seat.callOf(pairs[i].Node, pairs[i].Module) + } + return pairs +} + +// sayWaits raises, once, that the seat waits for the states not yet bound, and clears it once, when all are. +func (p *passer) sayWaits(unbound []string) { + c := assign.SeatWaitsCondition(unbound) + state := string(c.State) + c.Summary + if p.say == nil || p.said[c.Key] && p.saidWaits == state { + return + } + if c.State == asks.StateCleared && p.saidWaits == "" { + return // nothing was raised by this process + } + if err := p.say(c); err != nil { + p.logf("that the seat waits for its states could not be said, and is said at the next pass: %v", err) + return + } + p.said[c.Key], p.saidWaits = true, state +} + +// seatPlacements is every node's placement key and its revision, as the watch holds them. +func (p *passer) seatPlacements() (map[string]delivery.Placement, map[string]uint64) { + out, revs := map[string]delivery.Placement{}, map[string]uint64{} + for key, value := range p.w.read(delivery.PlacementBucket) { + node, err := delivery.ParsePlacementKey(key) + if err != nil { + continue + } + if pl, err := delivery.DecodePlacement(value); err == nil && pl.Node == node { + out[node], revs[node] = pl, p.w.metaOf(delivery.PlacementBucket, key).revision + } + } + return out, revs +} + +// candidatesOf is a proposal's candidates by node, with their keys' revisions and the newest write. +func (p *passer) candidatesOf(id string) (map[string]delivery.PlacementCandidate, map[string]uint64, time.Time) { + out, revs := map[string]delivery.PlacementCandidate{}, map[string]uint64{} + var newest time.Time + for key, value := range p.w.read(delivery.PlacementCandidatesBucket) { + pid, node, err := delivery.ParsePlacementCandidateKey(key) + if err != nil || pid != id { + continue + } + c, err := delivery.DecodePlacementCandidate(value) + if err != nil { + continue + } + meta := p.w.metaOf(delivery.PlacementCandidatesBucket, key) + out[node], revs[node] = c, meta.revision + if meta.at.After(newest) { + newest = meta.at + } + } + return out, revs, newest +} + +// answersTo is, by node, each node's answer to the proposal in its `.node` have. +func (p *passer) answersTo(id string) map[string]*delivery.CandidateAnswer { + out := map[string]*delivery.CandidateAnswer{} + for key, value := range p.w.read(delivery.HaveBucket) { + node, _, isNode, err := delivery.ParseHaveKey(key) + if err != nil || !isNode { + continue + } + h, err := delivery.DecodeHaveNode(value) + if err != nil { + continue + } + if a, ok := h.Candidates[id]; ok { + out[node] = &a + } + } + return out +} + +// placementSeat takes every open proposal one step further. +func (p *passer) placementSeat(ctx context.Context) { + for _, b := range seatBuckets { + if p.kvs[b] == nil { + return + } + } + if p.saidProposed == nil { + p.saidProposed, p.intended = map[string]bool{}, map[string]map[string]map[string]bool{} + } + current, revs := p.seatPlacements() + ids := make([]string, 0) + raws := p.w.read(delivery.PlacementProposalsBucket) + for id := range raws { + ids = append(ids, id) + } + sort.Strings(ids) + for _, id := range ids { + prop, err := delivery.DecodePlacementProposal(raws[id]) + if err != nil || prop.ID != id { + p.logf("the proposal %s cannot be read, so it is left: %v", id, err) + continue + } + p.stepProposal(ctx, prop, p.w.metaOf(delivery.PlacementProposalsBucket, id).revision, current, revs) + } +} + +// pulse writes a bound's key with its time to live (§4): its expiry, heard on the watch as the server's delete marker, +// is the event that ends the wait. Written again over one that stands, so its time starts anew. +func (p *passer) pulse(ctx context.Context, key string, ttl time.Duration) { + kv := p.kvs[delivery.PlacementPulsesBucket] + _ = kv.Delete(ctx, key) + rev, err := kv.Create(ctx, key, []byte(`{}`), jetstream.KeyTTL(ttl)) + if err != nil { + p.logf("the bound %s is not written, so its wait ends at once: %v", key, err) + return + } + p.w.putEntry(delivery.PlacementPulsesBucket, key, []byte(`{}`), rev, p.clock()) +} + +// pulsing says whether a bound's key stands. +func (p *passer) pulsing(key string) bool { return p.w.has(delivery.PlacementPulsesBucket, key) } + +// deletePulses deletes every bound of a proposal. +func (p *passer) deletePulses(ctx context.Context, id string) { + for key := range p.w.read(delivery.PlacementPulsesBucket) { + if strings.HasSuffix(key, "."+id) || strings.Contains(key, "."+id+".") { + if err := p.kvs[delivery.PlacementPulsesBucket].Delete(ctx, key); err == nil { + p.w.put(delivery.PlacementPulsesBucket, key, nil) + } + } + } +} + +// ask raises (or raises again) a proposal's authorising ask and its bound, and answers the proposal asked. +func (p *passer) ask(ctx context.Context, prop delivery.PlacementProposal, akey, brief string) (delivery.PlacementProposal, bool) { + a, err := assign.PlacementAsk(akey, prop, brief, assign.PlacementAsking(prop, p.clock()), p.clock()) + if err != nil { + p.logf("the ask about the proposal %s is not written: %v", prop.ID, err) + return prop, false + } + p.put(ctx, delivery.AssignerAsksBucket, akey, func() ([]byte, error) { return delivery.EncodeAssignerAsk(akey, a) }) + if pk, err := delivery.PulseAskKey(prop.ID); err == nil { + p.pulse(ctx, pk, a.Ask.Expires.Sub(p.clock())) + } + prop.State, prop.Ask = delivery.ProposalAsked, a.Ask.ID + return prop, true +} + +// stepProposal takes one proposal one step further. +func (p *passer) stepProposal(ctx context.Context, prop delivery.PlacementProposal, rev uint64, current map[string]delivery.Placement, revs map[string]uint64) { + if !p.saidProposed[prop.ID] && prop.State == delivery.ProposalAnswering && prop.Verdict == nil { + p.sayEvent(delivery.PlacementProposedEvent, delivery.PlacementEvent{Proposal: prop}, prop.ID) + p.saidProposed[prop.ID] = true + } + if prop.Withdrawn != "" { + p.endProposal(ctx, prop, prop.Withdrawn) + return + } + akey, _ := delivery.PlacementAskKey(prop.ID) + again := !prop.Again.IsZero() && (prop.Verdict == nil || prop.Again.After(prop.Verdict.At)) + if prop.State == delivery.ProposalAsked { + askPulse, _ := delivery.PulseAskKey(prop.ID) + switch { + case p.answeredWith(akey, assign.OptionYes): + if prop.HandBack { + p.handBack(ctx, prop, akey, revs) + } else { + p.warranted(ctx, prop, rev, akey, current, revs) + } + return + case p.answeredWith(akey, assign.OptionNo): + p.endProposal(ctx, prop, "declined by the operator") + return + case !p.pulsing(askPulse) && !again: + p.endProposal(ctx, prop, "its ask expired unanswered") + return + } + } + if prop.HandBack { + // The operator's alone (§7): no node answers it; its ask is raised at once, and again on again. + if prop.State != delivery.ProposalAsked || again { + brief := fmt.Sprintf("%s (asked by %s): every node's placement key is deleted, %s, and each node returns to the "+ + "controller's compositions; the history is kept", prop.Why, prop.By, strings.Join(prop.Nodes(), ", ")) + now := p.clock().UTC() + prop.Verdict = &delivery.PlacementVerdict{State: delivery.VerdictPass, At: now} + if prop, ok := p.ask(ctx, prop, akey, brief); ok { + p.writeProposal(ctx, prop, rev) + } + } + return + } + if moved(prop, revs) { + p.layAgain(ctx, prop, rev, current, revs, "a key it was laid over moved since") + return + } + cands, candRevs, written := p.candidatesOf(prop.ID) + if len(prop.Must) == 0 { + p.mustAnswer(ctx, prop, rev, current, cands) + return + } + for _, node := range prop.Must { + if _, ok := cands[node]; !ok { + return // a candidate just written is not heard yet: its watch wakes the next pass + } + } + if written.After(prop.Written) { + prop.Written = written + } + pulsing := map[string]bool{} + for _, node := range prop.Must { + if k, err := delivery.PulseAnswerKey(prop.ID, node); err == nil { + pulsing[node] = p.pulsing(k) + } + } + v := judge.JudgeAnswers(prop, p.intendedOf(prop, current, cands), p.answersTo(prop.ID), candRevs, p.w.online(), pulsing, p.clock()) + if prop.Verdict != nil && sameVerdict(*prop.Verdict, v) && !again { + return + } + prop.Verdict = &v + switch v.State { + case delivery.VerdictFail: + prop.State, prop.Ask = delivery.ProposalFailed, "" + case delivery.VerdictWaits: + prop.State, prop.Ask = delivery.ProposalAnswering, "" + case delivery.VerdictPass: + if prop.State != delivery.ProposalAsked || again { + var ok bool + if prop, ok = p.ask(ctx, prop, akey, judge.Brief(prop, v)); !ok { + return + } + if again { + prop.Words = "judged and asked again (again)" + } + } + } + if p.writeProposal(ctx, prop, rev) { + p.sayEvent(delivery.PlacementJudgedEvent, delivery.PlacementEvent{Proposal: prop}, + fmt.Sprintf("%s.judged.%d", prop.ID, v.At.UnixNano())) + } +} + +// sameVerdict says two verdicts say the same, their time aside. +func sameVerdict(a, b delivery.PlacementVerdict) bool { + a.At, b.At = time.Time{}, time.Time{} + return reflect.DeepEqual(a, b) +} + +// moved says whether a key the proposal changes is no longer at the revision it was laid over. +func moved(prop delivery.PlacementProposal, revs map[string]uint64) bool { + for node, base := range prop.Base { + if revs[node] != base { + return true + } + } + return false +} + +// intended is, by proposal and the revisions it was laid over, what Intended computed, kept while this process runs. +func (p *passer) intendedOf(prop delivery.PlacementProposal, current map[string]delivery.Placement, cands map[string]delivery.PlacementCandidate) map[string]map[string]bool { + key := fmt.Sprint(prop.ID, prop.Base) + if got, ok := p.intended[key]; ok { + return got + } + got, _, _ := p.compute(prop, current, cands) + p.intended[key] = got + return got +} + +// compute composes every node with the current placement and with the proposal laid over it (judge.Intended). +func (p *passer) compute(prop delivery.PlacementProposal, current map[string]delivery.Placement, cands map[string]delivery.PlacementCandidate) (map[string]map[string]bool, []string, []string) { + laid := map[string]*delivery.Placement{} + for _, node := range prop.Nodes() { + if c, ok := cands[node]; ok && !c.Reached { + laid[node] = c.Placement + } + } + get := kvGetter(p.kvs[delivery.ManifestsBucket]) + r := worldReader{w: p.w, manifests: get} + var now, with *compose.Input + if in, err := frombus.Input(context.Background(), r, frombus.Mesh{}, frombus.Scope{Every: true}); err == nil { + now = &in + } + if in, err := frombus.Input(context.Background(), r, frombus.Mesh{}, frombus.Scope{Every: true, Placements: laid}); err == nil { + with = &in + } + nodes := map[string]bool{} + for n := range current { + nodes[n] = true + } + if records, err := r.Records(context.Background()); err == nil { + for n := range records { + nodes[n] = true + } + } + names := make([]string, 0, len(nodes)) + for n := range nodes { + names = append(names, n) + } + fps := map[string]string{} + for name, m := range p.w.trunk() { + if m.FP2 != "" { + fps[name] = m.FP2 + } + } + return judge.Intended(now, with, current, laid, names, fps) +} + +// mustAnswer finds the nodes the proposal must hear from (§3), writes a reached candidate for each whose key it does not +// change, and writes them into the proposal. +func (p *passer) mustAnswer(ctx context.Context, prop delivery.PlacementProposal, rev uint64, current map[string]delivery.Placement, cands map[string]delivery.PlacementCandidate) { + for _, node := range prop.Nodes() { + if _, ok := cands[node]; !ok { + return // the verbs write the candidates before the proposal: wait to hear them + } + } + intended, must, notes := p.compute(prop, current, cands) + p.intended[fmt.Sprint(prop.ID, prop.Base)] = intended + for _, node := range must { + if _, ok := cands[node]; ok { + continue + } + key, err := delivery.PlacementCandidateKey(prop.ID, node) + if err != nil { + continue + } + c := delivery.PlacementCandidate{Proposal: prop.ID, Reached: true} + if pl, ok := current[node]; ok { + c.Placement = &pl + } + p.put(ctx, delivery.PlacementCandidatesBucket, key, func() ([]byte, error) { return delivery.EncodePlacementCandidate(c) }) + } + for _, node := range must { + if k, err := delivery.PulseAnswerKey(prop.ID, node); err == nil { + p.pulse(ctx, k, judge.AnswerBound) + } + } + prop.Must = must + if len(notes) > 0 { + prop.Words = strings.TrimPrefix(prop.Words+"; "+strings.Join(notes, "; "), "; ") + } + p.writeProposal(ctx, prop, rev) +} + +// layAgain lays a proposal over the keys as they stand now (§5: a key moved): its candidates are written again, the +// nodes that must answer found again, and it is judged and asked again; a proposal that no longer lays ends. +func (p *passer) layAgain(ctx context.Context, prop delivery.PlacementProposal, rev uint64, current map[string]delivery.Placement, revs map[string]uint64, why string) { + held, recorded := p.lays(ctx) + again, laid, findings := assign.Propose(assign.ProposeIn{ID: prop.ID, By: prop.By, Why: prop.Why, At: prop.At, Changes: prop.Changes, + Current: current, Revisions: revs, Held: held, Recorded: recorded, + Seed: prop.Seed, ExportDigest: prop.ExportDigest}) + if len(findings) > 0 { + p.endProposal(ctx, prop, why+", and laid over the keys now it is refused: "+strings.Join(findings, "; ")) + return + } + p.deleteCandidates(ctx, prop.ID) + p.deletePulses(ctx, prop.ID) + for _, node := range again.Nodes() { + key, _ := delivery.PlacementCandidateKey(prop.ID, node) + c := delivery.PlacementCandidate{Proposal: prop.ID, Placement: laid[node]} + p.put(ctx, delivery.PlacementCandidatesBucket, key, func() ([]byte, error) { return delivery.EncodePlacementCandidate(c) }) + } + again.Written, again.Words = p.clock().UTC(), why+": laid again over the keys as they stand, to be judged and asked again" + p.seat.forget(prop.ID) + p.writeProposal(ctx, again, rev) +} + +// lays is what a proposal is checked against, as the verbs check it: the modules the indexer holds, by key, and the +// nodes with a record. +func (p *passer) lays(ctx context.Context) (func(string) bool, func(string) bool) { + held := map[string]bool{} + for key := range p.w.read(delivery.ModulesBucket) { + if name, err := delivery.ParseModulesKey(key); err == nil { + held[name] = true + } + } + records, _ := worldReader{w: p.w}.Records(ctx) + return func(m string) bool { return held[m] }, func(n string) bool { _, ok := records[n]; return ok } +} + +// warranted writes an approved proposal (§5): for the seed, after the export is read again and found unchanged (§7); +// each changed key by compare-and-set at the revision it was laid over; then the warrant recorded, the candidates and the +// proposal deleted, placement-changed said, and the controller kept in step (§8). +func (p *passer) warranted(ctx context.Context, prop delivery.PlacementProposal, rev uint64, akey string, current map[string]delivery.Placement, revs map[string]uint64) { + if err := p.performs(akey, assign.PlacementAct(prop)); err != nil { + p.endProposal(ctx, prop, "its warrant does not bind these changes, so nothing is written: "+err.Error()) + return + } + answer, _ := p.w.answer(akey) + if prop.Seed { + if p.seat == nil { + return + } + c := p.seat.exportOf(prop.ID) + switch { + case c.running: + return // its answer wakes the next pass + case c.err != "": + if words := "the export cannot be read again at the warrant: " + c.err; prop.Words != words { + prop.Words = words + p.writeProposal(ctx, prop, rev) + } + return + case delivery.ExportDigest(c.export) != prop.ExportDigest: + p.endProposal(ctx, prop, "the controller's export changed since the seed was proposed: the new export is proposed as the seed") + p.proposeSeed(ctx, prop, c.export) + return + } + } + if moved(prop, revs) { + p.layAgain(ctx, prop, rev, current, revs, "a key moved since it was approved, so its warrant is spent") + return + } + cands, _, _ := p.candidatesOf(prop.ID) + laid := map[string]*delivery.Placement{} + for _, node := range prop.Nodes() { + laid[node] = cands[node].Placement + } + writes, err := assign.Writes(prop, laid) + if err != nil { + p.logf("the proposal %s cannot be written: %v", prop.ID, err) + return + } + kv := p.kvs[delivery.PlacementBucket] + var changes []delivery.PlacementChange + writtenLaid := map[string]*delivery.Placement{} + after := maps.Clone(current) + afterRevs := maps.Clone(revs) + for _, w := range writes { + var rev2 uint64 + var err error + switch { + case w.Value == nil: + err = kv.Delete(ctx, w.Key, jetstream.LastRevision(w.Base)) + default: + raw, encErr := delivery.EncodePlacement(*w.Value) + if encErr != nil { + err = encErr + } else if w.Base == 0 { + rev2, err = kv.Create(ctx, w.Key, raw) + } else { + rev2, err = kv.Update(ctx, w.Key, raw, w.Base) + } + } + if err != nil { + // Another writer came first (a hand-back): what is written stands and is followed through; the rest is laid + // again over the keys as this write left them. + p.logf("placement %s is not written at revision %d (%v): the rest of the proposal %s is laid again", w.Key, w.Base, err, prop.ID) + p.followWrite(prop, answer, changes, current, writtenLaid) + if rest, ok := assign.Rest(prop, writtenLaid); ok { + p.layAgain(ctx, rest, rev, after, afterRevs, "a key moved while it was written, after "+strings.Join(sortedNodes(writtenLaid), ", ")+" were") + } else { + p.endProposal(ctx, prop, "a key moved while it was written: "+err.Error()) + } + return + } + before := current[w.Node] + now := delivery.Placement{Node: w.Node} + if w.Value != nil { + now = *w.Value + after[w.Node] = *w.Value + } else { + delete(after, w.Node) + } + afterRevs[w.Node] = rev2 + c := delivery.ChangeOf(before, now) + c.Before, c.After = w.Base, rev2 + changes = append(changes, c) + writtenLaid[w.Node] = w.Value + } + p.deleteCandidates(ctx, prop.ID) + p.deletePulses(ctx, prop.ID) + _ = p.kvs[delivery.PlacementProposalsBucket].Delete(ctx, prop.ID) + p.w.put(delivery.PlacementProposalsBucket, prop.ID, nil) + p.followWrite(prop, answer, changes, current, writtenLaid) +} + +// followWrite follows the keys a warrant wrote: the warrant recorded, placement-changed said, and, until the lease +// write, the controller's assignments kept in step for them (§8). The seed calls no controller verb: it is what the +// controller holds already (§9). +func (p *passer) followWrite(prop delivery.PlacementProposal, answer delivery.Answer, changes []delivery.PlacementChange, + before map[string]delivery.Placement, written map[string]*delivery.Placement) { + if len(changes) == 0 { + return + } + p.seat.record(answer.Warrant) + p.sayChanged(prop, answer, changes) + if p.seat != nil && !assign.Leased && !prop.Seed { + p.seat.callAll(assign.ControllerCalls(before, written, prop.ID, answer.Warrant)) + } +} + +func sortedNodes(m map[string]*delivery.Placement) []string { + out := make([]string, 0, len(m)) + for n := range m { + out = append(out, n) + } + sort.Strings(out) + return out +} + +// handBack deletes every key on the operator's warrant (§7) at the revision the hand-back read, records it in the +// controller's hand-act log and says placement-changed; every node returns to the controller's compositions. A key moved +// since ends it, and the operator hands back again. +func (p *passer) handBack(ctx context.Context, prop delivery.PlacementProposal, akey string, revs map[string]uint64) { + if err := p.performs(akey, assign.PlacementAct(prop)); err != nil { + p.endProposal(ctx, prop, "its warrant does not bind this hand-back, so nothing is deleted: "+err.Error()) + return + } + answer, _ := p.w.answer(akey) + current, _ := p.seatPlacements() + var changes []delivery.PlacementChange + for _, node := range prop.Nodes() { + key, _ := delivery.PlacementKey(node) + if err := p.kvs[delivery.PlacementBucket].Delete(ctx, key, jetstream.LastRevision(prop.Base[node])); err != nil { + p.sayChanged(prop, answer, changes) + p.endProposal(ctx, prop, fmt.Sprintf("%s moved since the hand-back was asked (%v): hand back again", key, err)) + return + } + c := delivery.ChangeOf(current[node], delivery.Placement{Node: node}) + c.Before = prop.Base[node] + changes = append(changes, c) + } + if p.seat != nil { + p.seat.handAct(prop) + } + p.seat.record(answer.Warrant) + _ = p.kvs[delivery.PlacementProposalsBucket].Delete(ctx, prop.ID) + p.w.put(delivery.PlacementProposalsBucket, prop.ID, nil) + p.deletePulses(ctx, prop.ID) + p.sayChanged(prop, answer, changes) +} + +// sayChanged says placement-changed for the keys written. +func (p *passer) sayChanged(prop delivery.PlacementProposal, answer delivery.Answer, changes []delivery.PlacementChange) { + if len(changes) == 0 { + return + } + at := p.clock().UTC() + if _, when, ok := strings.Cut(answer.Warrant, "@"); ok { + if t, err := time.Parse(time.RFC3339Nano, when); err == nil { + at = t + } + } + delivery.SortChanges(changes) + p.sayEvent(delivery.PlacementChangedEvent, delivery.PlacementEvent{Proposal: prop, Nodes: changes, + Warrant: &delivery.PlacementWarrant{Warrant: answer.Warrant, Words: answer.Words, At: at}}, prop.ID+".changed") +} + +// proposeSeed proposes an export as the seed again, by the same proposer and why. +func (p *passer) proposeSeed(ctx context.Context, was delivery.PlacementProposal, export map[string]delivery.Placement) { + held, recorded := p.lays(ctx) + now := p.clock() + seed, laid, findings := assign.Propose(assign.ProposeIn{ID: delivery.NewProposalID(now), By: was.By, Why: was.Why, At: now, + Changes: assign.SeedChanges(export), Current: map[string]delivery.Placement{}, Revisions: map[string]uint64{}, + Held: held, Recorded: recorded, Seed: true, ExportDigest: delivery.ExportDigest(export)}) + if len(findings) > 0 { + p.logf("the new export is not proposed as the seed: %s", strings.Join(findings, "; ")) + return + } + for _, node := range seed.Nodes() { + key, _ := delivery.PlacementCandidateKey(seed.ID, node) + c := delivery.PlacementCandidate{Proposal: seed.ID, Placement: laid[node]} + p.put(ctx, delivery.PlacementCandidatesBucket, key, func() ([]byte, error) { return delivery.EncodePlacementCandidate(c) }) + } + seed.Written = now.UTC() + p.writeProposal(ctx, seed, 0) +} + +// endProposal ends a proposal: its ask, its candidates and its key deleted, and placement-withdrawn said carrying it +// whole with why it ended. +func (p *passer) endProposal(ctx context.Context, prop delivery.PlacementProposal, why string) { + if akey, err := delivery.PlacementAskKey(prop.ID); err == nil && p.w.has(delivery.AssignerAsksBucket, akey) { + if err := p.kvs[delivery.AssignerAsksBucket].Delete(ctx, akey); err == nil { + p.w.put(delivery.AssignerAsksBucket, akey, nil) + } + } + p.deleteCandidates(ctx, prop.ID) + p.deletePulses(ctx, prop.ID) + if err := p.kvs[delivery.PlacementProposalsBucket].Delete(ctx, prop.ID); err != nil && !errors.Is(err, context.Canceled) { + p.logf("the proposal %s is not deleted, and is ended at the next pass: %v", prop.ID, err) + return + } + p.w.put(delivery.PlacementProposalsBucket, prop.ID, nil) + p.seat.forget(prop.ID) + p.logf("the proposal %s ends: %s", prop.ID, why) + p.sayEvent(delivery.PlacementWithdrawnEvent, delivery.PlacementEvent{Proposal: prop, Ended: why}, prop.ID+".withdrawn") +} + +// deleteCandidates deletes every candidate of a proposal. +func (p *passer) deleteCandidates(ctx context.Context, id string) { + for key := range p.w.read(delivery.PlacementCandidatesBucket) { + if pid, _, err := delivery.ParsePlacementCandidateKey(key); err == nil && pid == id { + if err := p.kvs[delivery.PlacementCandidatesBucket].Delete(ctx, key); err == nil { + p.w.put(delivery.PlacementCandidatesBucket, key, nil) + } + } + } +} + +// writeProposal writes a proposal over the revision the pass read, by compare-and-set: a verb that marked it meanwhile +// (withdraw, again) wins, and the next pass reads that. It says whether it wrote. +func (p *passer) writeProposal(ctx context.Context, prop delivery.PlacementProposal, rev uint64) bool { + raw, err := delivery.EncodePlacementProposal(prop) + if err != nil { + p.logf("the proposal %s is not written: %v", prop.ID, err) + return false + } + kv := p.kvs[delivery.PlacementProposalsBucket] + var at uint64 + if rev == 0 { + at, err = kv.Create(ctx, prop.ID, raw) + } else { + at, err = kv.Update(ctx, prop.ID, raw, rev) + } + if err != nil { + if !errors.Is(err, context.Canceled) { + p.logf("the proposal %s is not written, and is taken further at the next pass: %v", prop.ID, err) + } + return false + } + p.w.putEntry(delivery.PlacementProposalsBucket, prop.ID, raw, at, p.clock()) + return true +} + +// say says one of the seat's events, said in the journal when the bus refuses it. +func (p *passer) sayEvent(event string, body delivery.PlacementEvent, id string) { + if p.publish == nil { + return + } + if err := p.publish(event, body, id); err != nil { + p.logf("%s of %s is not said: %v", event, body.Proposal.ID, err) + } +} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools.go new file mode 100644 index 0000000..3c7e708 --- /dev/null +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools.go @@ -0,0 +1,532 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + + 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-assigner/internal/assign" +) + +// The seat mesh-placement's seven verbs (novox/hq ADR 0567 §10), served through the tool runner beside the put tools: a +// process never serves (ADR 0436). They read every state the seat keeps and write what a person asks of it: a proposal +// and its candidates (propose, adopt), a proposal marked to end (withdraw) or to be judged again (again), and the keys +// deleted (hand-back). Everything that follows a proposal — composing it, judging the answers, the ask, the write at the +// warrant and the seat's events on its stream — is the assigner's process's (seat.go), started by these writes. + +// seatStore is what the seat's verbs need of the states: the runtime's, or a test's. +type seatStore interface { + store + del(state, key string) error + history(state, key string) ([]stdio.StateRevision, error) +} + +func (runtimeStore) del(state, key string) error { return stdio.State(state).Delete(key) } + +func (runtimeStore) history(state, key string) ([]stdio.StateRevision, error) { + return stdio.State(state).History(key) +} + +// The states the verbs read, as the runtime names them: the seat's own by their local names, the others' +// `.`. +const ( + modulesState = delivery.IndexerModule + ".modules" + haveState = "node-engine.have" +) + +// seatTools serves the seat's verbs over a store, asking the controller through ask (stdio.Ask, or a test's). +type seatTools struct { + st seatStore + ask func(key string, body any) (json.RawMessage, error) + now func() time.Time +} + +func newSeatTools(st seatStore, ask func(string, any) (json.RawMessage, error)) *seatTools { + return &seatTools{st: st, ask: ask, now: time.Now} +} + +// keyed is one placement key as a verb answers it. +type keyed struct { + Placement delivery.Placement `json:"placement"` + Revision uint64 `json:"revision"` +} + +// placements reads every node's placement and its key's revision. +func (s *seatTools) placements() (map[string]delivery.Placement, map[string]uint64, error) { + keys, err := s.st.keys(delivery.PlacementState) + if err != nil { + return nil, nil, fmt.Errorf("placement cannot be listed: %w", err) + } + out, revs := map[string]delivery.Placement{}, map[string]uint64{} + for _, key := range keys { + node, err := delivery.ParsePlacementKey(key) + if err != nil { + continue + } + raw, rev, found, err := s.st.get(delivery.PlacementState, key) + if err != nil { + return nil, nil, fmt.Errorf("placement of %s cannot be read: %w", node, err) + } + if !found { + continue + } + p, err := delivery.DecodePlacement(raw) + if err != nil { + return nil, nil, fmt.Errorf("placement of %s cannot be read: %w", node, err) + } + out[node], revs[node] = p, rev + } + return out, revs, nil +} + +// lays is what a proposal is checked against: the modules the indexer holds and the nodes with a record. +func (s *seatTools) lays() (func(string) bool, func(string) bool, error) { + mods, err := s.st.keys(modulesState) + if err != nil { + return nil, nil, fmt.Errorf("the indexer's modules cannot be listed: %w", err) + } + held := map[string]bool{} + for _, k := range mods { + if name, err := delivery.ParseModulesKey(k); err == nil { + held[name] = true + } + } + haves, err := s.st.keys(haveState) + if err != nil { + return nil, nil, fmt.Errorf("the nodes' records cannot be listed: %w", err) + } + recorded := map[string]bool{} + for _, k := range haves { + node, _, isNode, err := delivery.ParseHaveKey(k) + if err != nil || !isNode { + continue + } + if raw, _, found, err := s.st.get(haveState, k); err == nil && found { + if h, err := delivery.DecodeHaveNode(raw); err == nil && h.Record != nil { + recorded[node] = true + } + } + } + return func(m string) bool { return held[m] }, func(n string) bool { return recorded[n] }, nil +} + +// proposal reads one open proposal. +func (s *seatTools) proposal(id string) (delivery.PlacementProposal, error) { + if err := delivery.CheckProposalID(id); err != nil { + return delivery.PlacementProposal{}, err + } + raw, _, found, err := s.st.get(delivery.PlacementProposalsState, id) + if err != nil { + return delivery.PlacementProposal{}, fmt.Errorf("the proposal %s cannot be read: %w", id, err) + } + if !found { + return delivery.PlacementProposal{}, fmt.Errorf("no proposal %s is open: mesh-placement.proposals lists those that are", id) + } + return delivery.DecodePlacementProposal(raw) +} + +// write writes a proposal laid by assign.Propose: each candidate, then the proposal, whose write the assigner's process +// takes as the event to compose and judge it. +func (s *seatTools) write(p delivery.PlacementProposal, laid map[string]*delivery.Placement) error { + p.Written = s.now().UTC() + for _, node := range p.Nodes() { + key, err := delivery.PlacementCandidateKey(p.ID, node) + if err != nil { + return err + } + raw, err := delivery.EncodePlacementCandidate(delivery.PlacementCandidate{Proposal: p.ID, Placement: laid[node]}) + if err != nil { + return err + } + if err := s.st.put(delivery.PlacementCandidatesState, key, raw); err != nil { + return fmt.Errorf("the candidate %s is not written: %w", key, err) + } + } + raw, err := delivery.EncodePlacementProposal(p) + if err != nil { + return err + } + if err := s.st.put(delivery.PlacementProposalsState, p.ID, raw); err != nil { + return fmt.Errorf("the proposal %s is not written: %w", p.ID, err) + } + return nil +} + +// proposed is what propose and adopt answer. +type proposed struct { + Proposal delivery.PlacementProposal `json:"proposal"` + Follow string `json:"follow"` +} + +func followProposal(id string) string { + return fmt.Sprintf("the nodes it touches answer it; mesh-placement.proposals with id %q says where it stands, and the "+ + "operator is asked to approve it once its verdict passes", id) +} + +// changesOf reads propose's changes: a JSON array, given as one or as its text. +func changesOf(v any) ([]delivery.PlacementProposalChange, error) { + var raw []byte + switch c := v.(type) { + case string: + raw = []byte(c) + case nil: + return nil, errors.New("propose names its changes") + default: + var err error + if raw, err = json.Marshal(c); err != nil { + return nil, err + } + } + var out []delivery.PlacementProposalChange + dec := json.NewDecoder(strings.NewReader(string(raw))) + dec.DisallowUnknownFields() + if err := dec.Decode(&out); err != nil { + return nil, fmt.Errorf("the changes are no list of changes: %w", err) + } + return out, nil +} + +// doPropose is the verb propose (§2). +func (s *seatTools) doPropose(args map[string]any, by string) (any, error) { + changes, err := changesOf(args["changes"]) + if err != nil { + return nil, err + } + for _, c := range changes { + if c.Op == delivery.OpSeed { + return nil, errors.New("the seed is adopt's alone: propose takes place, take-off, pin, unpin, hold, unhold, decide and config") + } + } + current, revs, err := s.placements() + if err != nil { + return nil, err + } + held, recorded, err := s.lays() + if err != nil { + return nil, err + } + now := s.now() + p, laid, findings := assign.Propose(assign.ProposeIn{ID: delivery.NewProposalID(now), By: by, Why: arg(args, "why"), At: now, + Changes: changes, Current: current, Revisions: revs, Held: held, Recorded: recorded}) + if len(findings) > 0 { + return nil, fmt.Errorf("the proposal is refused: %s", strings.Join(findings, "; ")) + } + if err := s.write(p, laid); err != nil { + return nil, err + } + return proposed{Proposal: p, Follow: followProposal(p.ID)}, nil +} + +// doWithdraw is the verb withdraw (§2): the proposal is marked, and the assigner deletes it and its candidates and says +// placement-withdrawn carrying it whole. +func (s *seatTools) doWithdraw(args map[string]any, by string) (any, error) { + why := arg(args, "why") + if why == "" { + return nil, errors.New("withdraw says why: it is kept in the event that ends the proposal") + } + p, err := s.proposal(arg(args, "id")) + if err != nil { + return nil, err + } + p.Withdrawn = fmt.Sprintf("withdrawn by %s: %s", by, why) + raw, err := delivery.EncodePlacementProposal(p) + if err != nil { + return nil, err + } + if err := s.st.put(delivery.PlacementProposalsState, p.ID, raw); err != nil { + return nil, fmt.Errorf("the proposal %s is not withdrawn: %w", p.ID, err) + } + return map[string]any{"proposal": p.ID, "withdrawn": p.Withdrawn}, nil +} + +// doAgain is the verb again (§10): the proposal is marked, and the assigner judges it now and raises its ask again +// when its verdict passes. +func (s *seatTools) doAgain(args map[string]any) (any, error) { + p, err := s.proposal(arg(args, "id")) + if err != nil { + return nil, err + } + p.Again = s.now().UTC() + raw, err := delivery.EncodePlacementProposal(p) + if err != nil { + return nil, err + } + if err := s.st.put(delivery.PlacementProposalsState, p.ID, raw); err != nil { + return nil, fmt.Errorf("the proposal %s is not asked again: %w", p.ID, err) + } + return map[string]any{"proposal": p.ID, "again": p.Again, "follow": followProposal(p.ID)}, nil +} + +// exportCall is the controller's verb the seed is read from, by request and reply on its seat (§7). +const exportCall = "seat:" + catalogue.ControllerSeatName + ".placement-export" + +// doAdopt is the verb adopt (§7): the controller's export proposed whole as the seed. +func (s *seatTools) doAdopt(args map[string]any, by string) (any, error) { + current, _, err := s.placements() + if err != nil { + return nil, err + } + if len(current) > 0 { + return nil, fmt.Errorf("placement is taken over already (%d keys): propose a change instead, or hand it back first", len(current)) + } + if ids, err := s.st.keys(delivery.PlacementProposalsState); err == nil { + for _, id := range ids { + if p, err := s.proposal(id); err == nil && p.Seed { + return nil, fmt.Errorf("the seed %s is open: withdraw it first, or answer its ask", id) + } + } + } + raw, err := s.ask(exportCall, map[string]any{}) + if err != nil { + return nil, fmt.Errorf("the controller's placement-export did not answer: %w", err) + } + export, err := assign.ExportOf(raw) + if err != nil { + return nil, err + } + held, recorded, err := s.lays() + if err != nil { + return nil, err + } + now := s.now() + p, laid, findings := assign.Propose(assign.ProposeIn{ID: delivery.NewProposalID(now), By: by, Why: arg(args, "why"), At: now, + Changes: assign.SeedChanges(export), Current: map[string]delivery.Placement{}, Revisions: map[string]uint64{}, + Held: held, Recorded: recorded, Seed: true, ExportDigest: delivery.ExportDigest(export)}) + if len(findings) > 0 { + return nil, fmt.Errorf("the seed is refused: %s", strings.Join(findings, "; ")) + } + if err := s.write(p, laid); err != nil { + return nil, err + } + return proposed{Proposal: p, Follow: followProposal(p.ID)}, nil +} + +// handActCall records a hand act in the controller's hand-act log. +const handActCall = "seat:" + catalogue.ControllerSeatName + ".hand-act" + +// doHandBack is the verb hand-back (§7): the operator's alone, so it is asked as a proposal is. It writes a hand-back +// proposal naming every key at its revision; the assigner raises its authorising ask and, on its warrant, deletes the keys, +// records the hand act and says placement-changed; every node returns to the controller's compositions. +func (s *seatTools) doHandBack(args map[string]any, by string) (any, error) { + if assign.Leased { + return nil, errors.New("placement is the mesh's alone since the lease write: there is no controller to hand it back to") + } + why := arg(args, "why") + if why == "" { + return nil, errors.New("hand-back says why: it is recorded with the hand act") + } + if by == "" { + return nil, errors.New("the runtime names no caller: a hand-back is made by someone") + } + _, revs, err := s.placements() + if err != nil { + return nil, err + } + if len(revs) == 0 { + return nil, errors.New("there is no placement key to hand back") + } + now := s.now() + p := delivery.PlacementProposal{ID: delivery.NewProposalID(now), By: by, Why: why, At: now.UTC(), Written: now.UTC(), + HandBack: true, Base: revs, State: delivery.ProposalAnswering} + raw, err := delivery.EncodePlacementProposal(p) + if err != nil { + return nil, err + } + if err := s.st.put(delivery.PlacementProposalsState, p.ID, raw); err != nil { + return nil, fmt.Errorf("the hand-back is not written: %w", err) + } + return proposed{Proposal: p, Follow: "the operator is asked to approve it; on the warrant every key is deleted and each " + + "node returns to the controller's compositions"}, nil +} + +// placementAnswer is what the verb placement answers. +type placementAnswer struct { + Keys map[string]keyed `json:"keys,omitempty"` + Nodes []string `json:"nodes,omitempty"` + Count int `json:"count"` + History int `json:"history-per-key"` + Changes []historyEntry `json:"changes,omitempty"` + Older string `json:"older,omitempty"` +} + +// historyEntry is one change of a key in the state's revisions. +type historyEntry struct { + Node string `json:"node"` + Revision uint64 `json:"revision"` + At time.Time `json:"at"` + Change delivery.PlacementChange `json:"change"` + Deleted bool `json:"deleted,omitempty"` +} + +// doPlacement is the verb placement (§10, §6): one node's key, one module's nodes, or every key; with history, the +// changes in order from the revisions the state keeps. +func (s *seatTools) doPlacement(args map[string]any) (any, error) { + all, revs, err := s.placements() + if err != nil { + return nil, err + } + node, module := arg(args, "node"), arg(args, "module") + out := placementAnswer{Keys: map[string]keyed{}, Count: len(all), History: delivery.PlacementHistory} + for n, p := range all { + if node != "" && n != node { + continue + } + if module != "" && !strings.Contains(" "+strings.Join(p.Assigned(), " ")+" ", " "+module+" ") { + continue + } + out.Keys[n] = keyed{Placement: p, Revision: revs[n]} + out.Nodes = append(out.Nodes, n) + } + sort.Strings(out.Nodes) + if node != "" && len(out.Keys) == 0 && arg(args, "history") != "true" { + return nil, fmt.Errorf("%s has no placement key", node) + } + if arg(args, "history") != "true" { + return out, nil + } + nodes := out.Nodes + if node != "" { + nodes = []string{node} + } else if module == "" { + nodes = nil + for n := range all { + nodes = append(nodes, n) + } + sort.Strings(nodes) + } + for _, n := range nodes { + key, err := delivery.PlacementKey(n) + if err != nil { + return nil, err + } + revisions, err := s.st.history(delivery.PlacementState, key) + if err != nil { + return nil, fmt.Errorf("the history of %s cannot be read: %w", key, err) + } + var before delivery.Placement + for _, r := range revisions { + e := historyEntry{Node: n, Revision: r.Revision, At: r.At} + after := delivery.Placement{} + if r.Op == "" || r.Op == "put" || r.Op == "PUT" { + if p, err := delivery.DecodePlacement(r.Value); err == nil { + after = p + } + } else { + e.Deleted = true + } + e.Change = delivery.ChangeOf(before, after) + e.Change.Before, e.Change.After = 0, r.Revision + before = after + if module != "" && !strings.Contains(" "+strings.Join(append(append(e.Change.Added, e.Change.Removed...), e.Change.Changed...), " ")+" ", " "+module+" ") { + continue + } + out.Changes = append(out.Changes, e) + } + } + out.Older = fmt.Sprintf("the state keeps %d revisions of each key; an older change is in its placement-changed event on "+ + "the assigner's stream %s, kept %d days", delivery.PlacementHistory, delivery.PlacementStream, delivery.PlacementStreamDays) + return out, nil +} + +// openProposal is one open proposal as the verb proposals answers it: the proposal (its changes, base revisions, +// verdict with the differences and the answers it judged, its ask), its candidates by node with their revisions, and +// every node's answer now. +type openProposal struct { + Proposal delivery.PlacementProposal `json:"proposal"` + Candidates map[string]uint64 `json:"candidates"` + Answers map[string]delivery.CandidateAnswer `json:"answers,omitempty"` +} + +// doProposals is the verb proposals (§10). +func (s *seatTools) doProposals(args map[string]any) (any, error) { + ids, err := s.st.keys(delivery.PlacementProposalsState) + if err != nil { + return nil, fmt.Errorf("the proposals cannot be listed: %w", err) + } + cands, err := s.st.keys(delivery.PlacementCandidatesState) + if err != nil { + return nil, fmt.Errorf("the candidates cannot be listed: %w", err) + } + haves, err := s.st.keys(haveState) + if err != nil { + return nil, fmt.Errorf("the nodes' haves cannot be listed: %w", err) + } + answers := map[string]map[string]delivery.CandidateAnswer{} // by proposal, then node + for _, k := range haves { + node, _, isNode, err := delivery.ParseHaveKey(k) + if err != nil || !isNode { + continue + } + raw, _, found, err := s.st.get(haveState, k) + if err != nil || !found { + continue + } + h, err := delivery.DecodeHaveNode(raw) + if err != nil { + continue + } + for id, a := range h.Candidates { + if answers[id] == nil { + answers[id] = map[string]delivery.CandidateAnswer{} + } + answers[id][node] = a + } + } + sort.Strings(ids) + out := []openProposal{} + for _, id := range ids { + if want := arg(args, "id"); want != "" && want != id { + continue + } + p, err := s.proposal(id) + if err != nil { + return nil, err + } + o := openProposal{Proposal: p, Candidates: map[string]uint64{}, Answers: answers[id]} + for _, k := range cands { + pid, node, err := delivery.ParsePlacementCandidateKey(k) + if err != nil || pid != id { + continue + } + if _, rev, found, err := s.st.get(delivery.PlacementCandidatesState, k); err == nil && found { + o.Candidates[node] = rev + } + } + out = append(out, o) + } + if arg(args, "id") != "" && len(out) == 0 { + return nil, fmt.Errorf("no proposal %s is open", arg(args, "id")) + } + return map[string]any{"proposals": out}, nil +} + +// tools are the seat's verbs as the runtime serves them, under the seat's verb names, with the seat's own schemas. +func (s *seatTools) tools() []stdio.Tool { + seat, _ := catalogue.SeatNamed(catalogue.PlacementSeat) + input := map[string]map[string]any{} + description := map[string]string{} + for _, v := range seat.Serves { + input[v.Name], description[v.Name] = v.Input, v.Description + } + run := map[string]func(map[string]any) (any, error){ + catalogue.PlacementVerbPlacement: s.doPlacement, + catalogue.PlacementVerbProposals: s.doProposals, + catalogue.PlacementVerbPropose: func(a map[string]any) (any, error) { return s.doPropose(a, stdio.CallerOf(a)) }, + catalogue.PlacementVerbWithdraw: func(a map[string]any) (any, error) { return s.doWithdraw(a, stdio.CallerOf(a)) }, + catalogue.PlacementVerbAgain: s.doAgain, + catalogue.PlacementVerbAdopt: func(a map[string]any) (any, error) { return s.doAdopt(a, stdio.CallerOf(a)) }, + catalogue.PlacementVerbHandBack: func(a map[string]any) (any, error) { return s.doHandBack(a, stdio.CallerOf(a)) }, + } + var out []stdio.Tool + for _, v := range seat.Serves { + out = append(out, stdio.Tool{Name: v.Name, Description: description[v.Name], Input: input[v.Name], Run: run[v.Name]}) + } + return out +} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go new file mode 100644 index 0000000..1594f62 --- /dev/null +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go @@ -0,0 +1,157 @@ +package main + +import ( + "encoding/json" + "os" + "slices" + "strings" + "testing" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +func (s memStore) del(state, key string) error { + delete(s[state].values, key) + return nil +} + +func (s memStore) history(state, key string) ([]stdio.StateRevision, error) { + v, ok := s[state].values[key] + if !ok { + return nil, nil + } + return []stdio.StateRevision{{Key: key, Value: v, Revision: uint64(len(v)), Op: "put"}}, nil +} + +// ADR 0567 §2, §7, §10: the seat's seven verbs over the states. propose lays a proposal and writes it with its candidates, +// or refuses naming every finding and writes nothing; withdraw and again mark it for the holder; adopt proposes the +// controller's export whole as the seed, once; hand-back records the hand act and deletes every key; placement and +// proposals read every state the seat keeps, and history names the stream for what the state no longer keeps. +func TestTheSeatsVerbs(t *testing.T) { + anchor := delivery.Placement{V: delivery.Version, Node: "anchor", Modules: []delivery.PlacedModule{{Module: "web"}}} + st := memStore{} + for _, state := range []string{delivery.PlacementState, delivery.PlacementProposalsState, delivery.PlacementCandidatesState, modulesState, haveState} { + st[state] = &memKV{values: map[string][]byte{}} + } + for _, m := range []string{"web", "shell"} { + st[modulesState].values[m] = []byte("{}") + } + for _, n := range []string{"anchor", "laptop"} { + raw, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: n}}) + st[haveState].values[n+".node"] = raw + } + export, _ := json.Marshal(map[string]any{"anchor": anchor}) + var asked []string + s := newSeatTools(st, func(key string, body any) (json.RawMessage, error) { + asked = append(asked, key) + if key == exportCall { + out, _ := json.Marshal(map[string]any{"ok": true, "output": string(export)}) + return out, nil + } + return json.RawMessage(`{"ok":true}`), nil + }) + s.now = func() time.Time { return time.Date(2026, 10, 28, 20, 0, 0, 0, time.UTC) } + + // adopt: the export proposed whole as the seed, once. + got, err := s.doAdopt(map[string]any{"why": "take over"}, "operator@laptop") + if err != nil { + t.Fatal(err) + } + seed := got.(proposed).Proposal + if !seed.Seed || seed.ExportDigest != delivery.ExportDigest(map[string]delivery.Placement{"anchor": anchor}) || !slices.Equal(asked, []string{exportCall}) || + len(st[delivery.PlacementCandidatesState].values) != 1 || st[delivery.PlacementProposalsState].values[seed.ID] == nil { + t.Fatalf("adopt = %+v, asked %v", seed, asked) + } + if _, err := s.doAdopt(map[string]any{"why": "again"}, "op"); err == nil || !strings.Contains(err.Error(), "is open") { + t.Errorf("a second adopt with a seed open: %v", err) + } + // withdraw and again mark the proposal; the holder ends or judges it. + if _, err := s.doWithdraw(map[string]any{"id": seed.ID, "why": "not yet"}, "op"); err != nil { + t.Fatal(err) + } + if _, err := s.doAgain(map[string]any{"id": seed.ID}); err != nil { + t.Fatal(err) + } + if p, err := s.proposal(seed.ID); err != nil || !strings.Contains(p.Withdrawn, "not yet") || p.Again.IsZero() { + t.Errorf("marked = %+v, %v", p, err) + } + if _, err := s.doWithdraw(map[string]any{"id": "p-none", "why": "x"}, "op"); err == nil { + t.Error("an unknown proposal was withdrawn") + } + delete(st[delivery.PlacementProposalsState].values, seed.ID) // the holder ended it + st[delivery.PlacementCandidatesState].values = map[string][]byte{} + + // propose: refused whole, naming every finding; a valid move laid over the keys at their revisions. + raw, _ := delivery.EncodePlacement(anchor) + st[delivery.PlacementState].values["placement.anchor"] = raw + if _, err := s.doPropose(map[string]any{"why": "x", "changes": `[{"op":"place","node":"anchor","module":"ghost"},{"op":"hold","node":"anchor","module":"web","claim":"x"}]`}, "op"); err == nil || + !strings.Contains(err.Error(), "ghost") || !strings.Contains(err.Error(), "lease write") || len(st[delivery.PlacementProposalsState].values) != 0 { + t.Errorf("a refused proposal: %v", err) + } + if _, err := s.doPropose(map[string]any{"why": "x", "changes": `[{"op":"swap","node":"anchor"}]`}, "op"); err == nil { + t.Error("an unknown change was taken") + } + got, err = s.doPropose(map[string]any{"why": "move web", "changes": []any{ + map[string]any{"op": "take-off", "node": "anchor", "module": "web"}, map[string]any{"op": "place", "node": "laptop", "module": "web"}}}, "operator@laptop") + if err != nil { + t.Fatal(err) + } + move := got.(proposed).Proposal + if move.By != "operator@laptop" || move.Base["anchor"] != uint64(len(raw)) || move.Base["laptop"] != 0 || len(st[delivery.PlacementCandidatesState].values) != 2 { + t.Fatalf("propose = %+v", move) + } + + // placement and proposals read every state the seat keeps (§10); history names the stream for what is older. + reads := map[string]bool{} + pa, err := s.doPlacement(map[string]any{"module": "web", "history": "true"}) + if a := pa.(placementAnswer); err != nil || !slices.Equal(a.Nodes, []string{"anchor"}) || len(a.Changes) != 1 || !strings.Contains(a.Older, delivery.PlacementStream) { + t.Errorf("placement of web = %+v, %v", pa, err) + } else { + reads[delivery.PlacementState], reads["stream "+delivery.PlacementStream] = true, true + } + if _, err := s.doPlacement(map[string]any{"node": "laptop"}); err == nil { + t.Error("a node with no key was answered") + } + ps, err := s.doProposals(map[string]any{"id": move.ID}) + if list := ps.(map[string]any)["proposals"].([]openProposal); err != nil || len(list) != 1 || len(list[0].Candidates) != 2 { + t.Errorf("proposals = %+v, %v", ps, err) + } else { + reads[delivery.PlacementProposalsState], reads[delivery.PlacementCandidatesState] = true, true + } + raw0, err := readManifest() + if err != nil { + t.Fatal(err) + } + for _, state := range raw0.State { + if slices.Contains([]string{delivery.PlacementState, delivery.PlacementProposalsState, delivery.PlacementCandidatesState}, state.Name) && !reads[state.Name] { + t.Errorf("no verb reads the seat's state %s", state.Name) + } + } + for _, stream := range raw0.Streams { + if !reads["stream "+stream.Name] { + t.Errorf("no verb reads the seat's stream %s", stream.Name) + } + } + + // hand-back: the operator's alone, written as a hand-back proposal naming every key at its revision, for the assigner + // to ask; nothing is deleted by the verb. + got, err = s.doHandBack(map[string]any{"why": "back"}, "op") + if hb := got.(proposed).Proposal; err != nil || !hb.HandBack || hb.Base["anchor"] != uint64(len(raw)) || len(st[delivery.PlacementState].values) != 1 { + t.Errorf("hand-back = %+v, %v", got, err) + } + if tools := s.tools(); len(tools) != 7 || tools[2].Name != catalogue.PlacementVerbPropose || tools[2].Run == nil || tools[2].Input == nil { + t.Errorf("the seat's verbs as served: %+v", tools) + } +} + +// readManifest is the module's manifest, read as the controller reads it. +func readManifest() (catalogue.Manifest, error) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + return catalogue.Manifest{}, err + } + return catalogue.ParseManifest(raw) +} 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 016654b..0e47bb4 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/tools.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/tools.go @@ -18,8 +18,8 @@ import ( // The assigner's tools (novox/hq ADR 0546), served through the tool runner from its bundle: a process never serves (ADR // 0436, mesh-sdk process). They write and read the puts; the assigner's process takes each put further (puts.go) and says -// every step as the event put. They are the module's own tools, not a seat's verbs: a new seat needs a change of the -// controller, which takes none; they become the verbs of a seat once composing leaves the controller. +// 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). // putsState is the assigner's state of puts, as the runtime names a module's own state. const putsState = "puts" @@ -351,7 +351,7 @@ func serveTools() error { time.Sleep(wait) } }() - return stdio.Serve("", s.tools()) + return stdio.Serve("", append(s.tools(), newSeatTools(runtimeStore{}, stdio.Ask).tools()...)) } // shortFP is a fingerprint's first twelve hex digits. 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 6ebbe6a..3dba2bc 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go @@ -13,6 +13,7 @@ import ( "github.com/nats-io/nats.go/jetstream" + "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/compose" "git.novox.be/novox/mesh-sdk/go/compose/frombus" @@ -192,7 +193,7 @@ func (w *world) manifest(ctx context.Context, get getter, digest string) parsed return p } -// assigned is each node's modules both the controller's assignments and the indexer's placement name, in name order +// assigned is each node's modules both the controller's assignments and the seat mesh-placement's placement name, in name order // (ADR 0549 §5; the assignments alone before any placement was read), with every open assignment by node and module. func (w *world) assigned() (map[string][]string, map[string]assignment) { byAssignment, all := w.assignedByController() @@ -201,7 +202,7 @@ func (w *world) assigned() (map[string][]string, map[string]assignment) { return both, all } -// placementDiffers is every pair the controller's assignments and the indexer's placement do not both name. +// placementDiffers is every pair the controller's assignments and the seat's placement do not both name. func (w *world) placementDiffers() []assign.Pair { byAssignment, _ := w.assignedByController() placed, seeded := w.placed() @@ -209,7 +210,7 @@ func (w *world) placementDiffers() []assign.Pair { return differ } -// placed is each node's modules in the indexer's placement (ADR 0549 §2), and whether any placement key was read: a key +// placed is each node's modules in the seat's placement (ADR 0567 §1), and whether any placement key was read: a key // that does not read places nothing on its node and is said by the indexer's own read tool. func (w *world) placed() (map[string][]string, bool) { out := map[string][]string{} @@ -228,7 +229,7 @@ func (w *world) placed() (map[string][]string, bool) { return out, len(values) > 0 } -// placedNodes is every node the indexer's placement names (ADR 0549 §2): those compose their own modules (ADR 0547 §2), +// placedNodes is every node the seat's placement names (ADR 0567 §1): those compose their own modules (ADR 0547 §2), // so their wants name the build alone and the seventh rule does not hold them. func (w *world) placedNodes() map[string]bool { placed, _ := w.placed() @@ -302,7 +303,7 @@ func (w *world) mesh(ctx context.Context, manifests getter) assign.Mesh { return m } -// worldReader is what the assigner watched as frombus reads the bus: the indexer's placement and modules, each node's +// worldReader is what the assigner watched as frombus reads the bus: the seat's placement, the indexer's modules, each node's // record in its own have key, the manifests by digest; the check reads no config value, own secret or credential (the // composition it asks resolves, and stands in for what it does not carry). type worldReader struct { @@ -358,7 +359,25 @@ func (r worldReader) Manifest(ctx context.Context, digest string) ([]byte, error return []byte(m.JSON), nil } -func (worldReader) Values(context.Context, string, string) (map[string]any, error) { return nil, nil } +// Values is one module's config values on one node as mesh-config resolved them, each a value its source lays +// (catalogue.LaidValue): the holder composes every node a proposal touches with them (ADR 0567 §9). +func (r worldReader) Values(_ context.Context, node, module string) (map[string]any, error) { + var out map[string]any + prefix := node + "." + module + "." + for key, raw := range r.w.read(buscompose.ResolvedBucket) { + name, ok := strings.CutPrefix(key, prefix) + if !ok || strings.Contains(name, ".") { + continue + } + if v, laid := catalogue.LaidValue(raw); laid { + if out == nil { + out = map[string]any{} + } + out[name] = v + } + } + return out, nil +} func (worldReader) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error) { return nil, nil @@ -368,8 +387,8 @@ func (worldReader) Credentials(context.Context, string, []compose.CredentialKey, return nil, nil, nil } -// Build, Archive and Served are read only for a node composing itself (frombus.Scope without Every): the check of every -// node composes from the manifests the indexer read, reads no build and no archive, and reads no config value. +// Build and Archive are read only for a node composing itself (frombus.Scope without Every): the check of every node +// composes from the manifests the indexer read, and reads no build and no archive. func (worldReader) Build(context.Context, string, string) (*delivery.BuilderBuild, error) { return nil, nil } @@ -378,8 +397,18 @@ func (worldReader) Archive(_ context.Context, url, _ string) ([]byte, error) { return nil, fmt.Errorf("the composition check reads no archive (%s)", url) } -func (worldReader) Served(context.Context, string, string, []string) (map[string]any, error) { - return nil, nil +func (r worldReader) Served(ctx context.Context, node, module string, keys []string) (map[string]any, error) { + all, err := r.Values(ctx, node, module) + var out map[string]any + for _, k := range keys { + if v, ok := all[k]; ok { + if out == nil { + out = map[string]any{} + } + out[k] = v + } + } + return out, err } // change is a candidate's change for the composition check, or why the composition waits for it. @@ -431,77 +460,9 @@ func (w *world) change(ctx context.Context, manifests getter, c delivery.Candida sort.Strings(ch.Twice[name]) } } - cp, waits := w.placementOf(ctx, manifests, c) - if waits != "" { - return ch, waits - } - ch.Placement = cp return ch, "" } -// placementOf is the change's own placement (ADR 0559 §5): the indexer's placement candidates of the group's catalogue -// pull request, with the input the nodes compose from with them laid over the trunk's placement. None when the group has -// no catalogue pull request or it changes no placement file. It waits while a key names another head than the group's. -func (w *world) placementOf(ctx context.Context, manifests getter, c delivery.Candidate) (*assign.ChangePlacement, string) { - var pull *delivery.CandidatePull - for i := range c.Pulls { - if delivery.SameRepository(c.Pulls[i].Repository, delivery.CatalogueRepository) { - pull = &c.Pulls[i] - } - } - if pull == nil { - return nil, "" - } - cp := &assign.ChangePlacement{Placements: map[string]*delivery.Placement{}, Revisions: map[string]uint64{}, Head: pull.Head.Head} - for key, value := range w.read(delivery.PlacementCandidatesBucket) { - group, node, err := delivery.ParsePlacementCandidateKey(key) - if err != nil || group != c.Group { - continue - } - pc, err := delivery.DecodePlacementCandidate(value) - if err != nil || pc.Pull.Number != pull.Number || !delivery.SameRepository(pc.Pull.Repository, pull.Repository) { - continue - } - if pc.Pull.Head != pull.Head.Head { - return nil, fmt.Sprintf("the indexer holds the placement candidates of %s#%d at %s, not yet at its head %s", pull.Repository, pull.Number, pc.Pull.Head, pull.Head.Head) - } - meta := w.metaOf(delivery.PlacementCandidatesBucket, key) - cp.Placements[node], cp.Revisions[node] = pc.Placement, meta.revision - if meta.at.After(cp.Written) { - cp.Written = meta.at - } - } - if len(cp.Placements) == 0 { - return nil, "" - } - in, err := frombus.Input(ctx, worldReader{w: w, manifests: manifests}, frombus.Mesh{}, frombus.Scope{Every: true, Placements: cp.Placements}) - if err != nil { - cp.Unread = err.Error() - } else { - cp.Input = &in - } - return cp, "" -} - -// candidateAnswers is, by node, each node's answer to the group's placement candidates in its `.node` have. -func (w *world) candidateAnswers(group string) map[string]*delivery.CandidateAnswer { - out := map[string]*delivery.CandidateAnswer{} - for key, value := range w.read(delivery.HaveBucket) { - node, _, isNode, err := delivery.ParseHaveKey(key) - if err != nil || !isNode { - continue - } - h, err := delivery.DecodeHaveNode(value) - if err != nil { - continue - } - if a, ok := h.Candidates[group]; ok { - out[node] = &a - } - } - return out -} - // online is, by node, whether the watchman holds the node online. func (w *world) online() map[string]bool { out := map[string]bool{} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world_test.go index ecea239..eb4fa5b 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world_test.go @@ -48,6 +48,7 @@ func pull(repo string, number int, modules map[string]delivery.CandidateModule) // module with no manifest in the preview as removed, a mesh-sdk member's head, and a module the trunk holds from // another repository as defined twice. func TestTheChangeOfACandidate(t *testing.T) { + theWorldCountsTheValuesAKeyTakes(t) f := fakeManifests{} web := f.add(t, "web", `{"module":"web","version":"2"}`) mail := f.add(t, "mail", `{"module":"mail","version":"1"}`) @@ -113,7 +114,7 @@ func TestAManifestFallingBackToAPredefinedKeyIsReadable(t *testing.T) { } // Review round 1 (items 3 and 4): the world counts the values a key takes, so a have written again after an answer is seen. -func TestTheWorldCountsTheValuesAKeyTakes(t *testing.T) { +func theWorldCountsTheValuesAKeyTakes(t *testing.T) { w := newWorld() w.put(delivery.HaveBucket, "ace.web", []byte(`{"v":1}`)) w.put(delivery.HaveBucket, "ace.web", []byte(`{"v":1}`)) diff --git a/modules/mesh-delivery-assigner/go.mod b/modules/mesh-delivery-assigner/go.mod index 8da7a7d..3559cda 100644 --- a/modules/mesh-delivery-assigner/go.mod +++ b/modules/mesh-delivery-assigner/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-assigner go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027201213-7a8e9f970648 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-assigner/go.sum b/modules/mesh-delivery-assigner/go.sum index f6eae7d..6245202 100644 --- a/modules/mesh-delivery-assigner/go.sum +++ b/modules/mesh-delivery-assigner/go.sum @@ -1,7 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027193820-8ad9372b1952 h1:TmveT3Bija643RGWkilvkzP/h8UWlVvHFZZtQfJK5oU= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027193820-8ad9372b1952/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027201213-7a8e9f970648 h1:XoMsgg9kSgjQWa86NZM8h8OpxJ2kovxcMS2C0i6WSVU= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027201213-7a8e9f970648/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388 h1:ycu/sNieTdLpD3bzhZOCXdzcoqXUOxPRmqWV019REpo= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388/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/candidates.go b/modules/mesh-delivery-assigner/internal/assign/candidates.go deleted file mode 100644 index 67b7739..0000000 --- a/modules/mesh-delivery-assigner/internal/assign/candidates.go +++ /dev/null @@ -1,242 +0,0 @@ -package assign - -import ( - "fmt" - "reflect" - "sort" - "strings" - "time" - - "git.novox.be/novox/mesh-sdk/go/compose" - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// A change's own placement (novox/hq ADR 0559 §5, issue 1126): a catalogue pull request that changes placement files has -// the indexer's placement candidates, one key `.` for each node whose file at its head differs from the -// trunk's. The check composes the change with those placements laid over the trunk's (frombus, Scope.Placements), counts -// every such node among the machines it touches, and then reads each placed node's own answer in its `.node` -// have: it fails on what a node composes differently that the change does not intend, on a refusal, and on a node -// placed at the head or on the trunk that has not answered at the head within AnswerBound of the keys being written. - -// AnswerBound is how long the check waits for every placed node's answer after the candidate keys were written (ADR 0559 -// §5): a node asleep or away is named past it, never passed. A variable so a test on the bus can shorten it. -var AnswerBound = 10 * time.Minute - -// ChangePlacement is the change's own placement, read from the indexer's placement candidates of its group. -type ChangePlacement struct { - // Placements is by node the candidate placement; nil when the head removes the node's file. - Placements map[string]*delivery.Placement - // Revisions is by node the revision of its candidate key: the answer to it names that revision. - Revisions map[string]uint64 - // Head is the catalogue pull request's head the candidates were read at, and Written when the newest key was written. - Head string - Written time.Time - // Input is the trunk's composition input with Placements laid over it (frombus.Input, Scope.Placements), its catalogue - // the trunk's; nil with Unread saying why when it could not be read. - Input *compose.Input - Unread string -} - -// Nodes is every node the change's placement names, sorted. -func (p *ChangePlacement) Nodes() []string { - if p == nil { - return nil - } - out := make([]string, 0, len(p.Placements)) - for node := range p.Placements { - out = append(out, node) - } - sort.Strings(out) - return out -} - -// Intended is, by node the trunk places, the modules whose composition the change's placement changes: those it adds, -// removes or whose placed entry differs, and those whose composition (with the trunk's catalogue, the same stand-ins -// either side) differs between the trunk's input and the change's (delivery.CompareComposed), the node's own named -// "node". A node the trunk does not place yet has none (ADR 0559 §5: at the seed every node must answer the same). fps -// gives the build each module is composed at, the trunk's. A node whose compositions cannot be made either side keeps -// the modules its placement entries name, and is said in notes. -func Intended(trunk *compose.Input, trunkPlaced map[string]delivery.Placement, cp *ChangePlacement, fps map[string]string) (intended map[string]map[string]bool, notes []string) { - intended = map[string]map[string]bool{} - if cp == nil { - return intended, nil - } - for _, node := range cp.Nodes() { - before, placed := trunkPlaced[node] - if !placed { - continue - } - set := map[string]bool{} - after := cp.Placements[node] - var afterModules []delivery.PlacedModule - if after != nil { - afterModules = after.Modules - } - entries := map[string]delivery.PlacedModule{} - for _, m := range before.Modules { - entries[m.Module] = m - } - seen := map[string]bool{} - for _, m := range afterModules { - seen[m.Module] = true - if e, ok := entries[m.Module]; !ok || !reflect.DeepEqual(e, m) { - set[m.Module] = true - } - } - for name := range entries { - if !seen[name] { - set[name] = true - } - } - if trunk != nil && cp.Input != nil { - was, _, _, errB := compose.CompositionsShadow(*trunk, node, fps) - now, _, _, errA := compose.CompositionsShadow(*cp.Input, node, fps) - if errA == nil && errB == nil { - running := make([]delivery.Composed, 0, len(was)) - for _, c := range was { - running = append(running, c) - } - cmp := delivery.CompareComposed(now, running) - for _, d := range cmp.Differs { - set[d.Module] = true - } - for _, d := range cmp.Added { - set[d.Module] = true - } - for _, m := range cmp.Removed { - set[m] = true - } - } else { - notes = append(notes, fmt.Sprintf("what the change intends on %s is read from its placement file alone: its compositions cannot be made (trunk: %v; change: %v)", node, errB, errA)) - } - } - intended[node] = set - } - return intended, notes -} - -// Answered is what the placed nodes answered: the failures to name, the nodes that have not answered, and the notes. -type Answered struct { - Fails []string - Silent []string - Machine string - Waits bool - Until time.Time - Warnings []string -} - -// JudgeAnswers reads each placed node's answer to the change's placement (ADR 0559 §5). placed is every node placed at -// the head or on the trunk; answers is by node its HaveNode.Candidates entry for the group, nil when it has none; online -// says whether the watchman holds the node online. An answer counts when it names the candidates' head and, for a node -// with a key of its own, that key's revision. It fails on a refusal, on a module that differs, is added or is removed -// that intended does not name, and, from Written plus AnswerBound on, on every node with no answer that counts, named, -// asleep or not; before that the check waits for them. -func JudgeAnswers(cp *ChangePlacement, placed []string, answers map[string]*delivery.CandidateAnswer, intended map[string]map[string]bool, online map[string]bool, now time.Time) Answered { - var out Answered - if cp == nil { - return out - } - failAt := func(node, words string) { - if out.Machine == "" { - out.Machine = node - } - out.Fails = append(out.Fails, words) - } - for _, node := range placed { - a := answers[node] - rev, keyed := cp.Revisions[node] - if a == nil || a.Head != cp.Head || (keyed && a.Revision != rev) { - out.Silent = append(out.Silent, node) - continue - } - if a.Refused != "" { - failAt(node, fmt.Sprintf("%s cannot compose the candidate placement: %s", node, a.Refused)) - continue - } - for _, r := range a.Refusals { - failAt(node, fmt.Sprintf("%s refuses the candidate placement for %s: %s", node, r.Module, r.Why)) - } - want := intended[node] - for _, d := range a.Differs { - if !want[d.Module] { - failAt(node, fmt.Sprintf("%s composes %s differently with the candidate placement, which the change does not intend: %s", node, d.Module, d.Why)) - } - } - for _, d := range a.Added { - if !want[d.Module] { - failAt(node, fmt.Sprintf("%s would run %s with the candidate placement, which the change does not intend", node, d.Module)) - } - } - for _, m := range a.Removed { - if !want[m] { - failAt(node, fmt.Sprintf("%s would stop running %s with the candidate placement, which the change does not intend", node, m)) - } - } - } - if len(out.Silent) == 0 { - return out - } - out.Until = cp.Written.Add(AnswerBound) - named := make([]string, len(out.Silent)) - for i, node := range out.Silent { - named[i] = node - if !online[node] { - named[i] = node + " (offline)" - } - } - if now.Before(out.Until) { - out.Waits = true - out.Warnings = append(out.Warnings, fmt.Sprintf("waits for the answers of %s to the candidate placement at %s, until %s", - strings.Join(named, ", "), short(cp.Head), out.Until.UTC().Format(time.RFC3339))) - return out - } - for _, node := range out.Silent { - if out.Machine == "" { - out.Machine = node - } - } - out.Fails = append(out.Fails, fmt.Sprintf("%s %s (%s after the candidate keys were written at %s)", - strings.Join(named, ", "), delivery.CompositionUnanswered, AnswerBound, cp.Written.UTC().Format(time.RFC3339))) - return out -} - -// PlacedNodes is every node placed at the head or on the trunk: the trunk's placed nodes with the change's placement laid -// over them (a node whose file the head removes stays, as the trunk places it), sorted. -func PlacedNodes(trunkPlaced map[string]delivery.Placement, cp *ChangePlacement) []string { - set := map[string]bool{} - for node := range trunkPlaced { - set[node] = true - } - if cp != nil { - for node, p := range cp.Placements { - if p != nil { - set[node] = true - } - } - } - out := make([]string, 0, len(set)) - for node := range set { - out = append(out, node) - } - sort.Strings(out) - return out -} - -// WithAnswers is v with the placed nodes' answers judged in: a fail adds its failures (the words of nodes not heard end -// with delivery.CompositionUnanswered, so the checker takes a later composition of the same heads in when they answer), a -// wait makes a passing verdict wait (a composition that waits composed no machine), and a failing one stands. -func WithAnswers(v delivery.CompositionVerdict, a Answered) delivery.CompositionVerdict { - if len(a.Fails) > 0 { - if v.State == delivery.CompositionFail { - v.Words += "; " + strings.Join(a.Fails, "; ") - } else { - v.State, v.Words, v.Machine = delivery.CompositionFail, strings.Join(a.Fails, "; "), a.Machine - } - return v - } - if a.Waits && v.State == delivery.CompositionPass { - return delivery.CompositionVerdict{Group: v.Group, Heads: v.Heads, State: delivery.CompositionWaits, SDK: v.SDK, Took: v.Took, - Words: strings.Join(a.Warnings, "; ")} - } - return v -} diff --git a/modules/mesh-delivery-assigner/internal/assign/compose.go b/modules/mesh-delivery-assigner/internal/assign/compose.go index 6bde172..65c1481 100644 --- a/modules/mesh-delivery-assigner/internal/assign/compose.go +++ b/modules/mesh-delivery-assigner/internal/assign/compose.go @@ -59,15 +59,13 @@ type Change struct { // Members the head of each member by repository: what a composer at the group's mesh-sdk reads (issues 1622, 1623). Digests map[string]string Members map[string]string - // Placement is the change's own placement, read from the indexer's placement candidates of the group (ADR 0559 §5); - // nil when the group changes no placement file. - Placement *ChangePlacement } // Touched is the machines a change touches (ADR 0446 clause 3): those running a module the change moves, adds or // removes, those running a consumer of a provision such a module provides (before or after), and those running a // provider of a provision such a module requires after the change. A module the change adds that is assigned nowhere -// touches no machine. Every node whose placement the change changes is touched too (ADR 0559 §5, changing ADR 0446 §3). +// touches no machine. A change of placement is no change of a repository (ADR 0567 §4): the seat +// mesh-placement judges it. func Touched(m Mesh, c Change) []string { moved := map[string]bool{} for name := range c.Manifests { @@ -101,11 +99,6 @@ func Touched(m Mesh, c Change) []string { } } } - for _, node := range c.Placement.Nodes() { - if !slices.Contains(out, node) { - out = append(out, node) - } - } sort.Strings(out) return out } @@ -178,25 +171,6 @@ func Compose(m Mesh, c Change, sdk string, took time.Duration) delivery.Composit delete(changed, name) } after := input(changed, m.Assigned) - if c.Placement != nil { - // A change of placement is composed over the input the nodes compose from, its placement laid over the trunk's - // (frombus, ADR 0559 §5): the trunk as the nodes compose it where the catalogue places them, the change's - // manifests over the trunk's catalogue. - if m.Input != nil { - trunk = *m.Input - trunk.Catalogue = withOverlay(m.Input.Catalogue, m.Manifests) - } - if c.Placement.Input == nil { - failAt("", "the change's placement cannot be composed: the input with it cannot be read from the bus: "+c.Placement.Unread) - } else { - after = *c.Placement.Input - cat := withOverlay(c.Placement.Input.Catalogue, changed) - for _, name := range c.Removed { - delete(cat, name) - } - after.Catalogue = cat - } - } for _, node := range machines { _, before := compose.Resolve(trunk, node) _, now := compose.Resolve(after, node) diff --git a/modules/mesh-delivery-assigner/internal/assign/compose_test.go b/modules/mesh-delivery-assigner/internal/assign/compose_test.go index 4054e91..96c46ab 100644 --- a/modules/mesh-delivery-assigner/internal/assign/compose_test.go +++ b/modules/mesh-delivery-assigner/internal/assign/compose_test.go @@ -4,7 +4,6 @@ import ( "slices" "strings" "testing" - "time" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" @@ -58,79 +57,6 @@ func TestAMachineTheChangeBreaksFails(t *testing.T) { if v.State != delivery.CompositionFail || v.Machine != "ace" || !strings.Contains(v.Words, "database") { t.Fatalf("composition = %+v, want a fail on ace naming the provision nothing provides", v) } - - // ADR 0559 §5 (issue 1126): a change of placement composes its placement laid over the trunk's input and fails naming - // the node it breaks: a module no manifest names placed on g14; ace, - // is no longer composed with it; a placement whose input cannot be read fails; a placement that breaks nothing passes, g14 touched. - m := meshOf() - placed := func(assigned map[string][]string) *ChangePlacement { - in := input(m.Manifests, assigned) - return &ChangePlacement{Placements: map[string]*delivery.Placement{"g14": {Node: "g14"}}, Input: &in} - } - for _, row := range []struct { - name string - cp *ChangePlacement - state string - machine string - words string - }{ - {"a placement that breaks g14", placed(map[string][]string{"ace": {"mail", "store"}, "g14": {"web", "ghost"}}), delivery.CompositionFail, "g14", "ghost"}, - {"a placement whose input is not read", &ChangePlacement{Placements: map[string]*delivery.Placement{"g14": nil}, Unread: "no record of g14"}, delivery.CompositionFail, "", "no record of g14"}, - {"a placement that breaks nothing", placed(map[string][]string{"ace": {"mail", "store"}, "g14": {"web"}}), delivery.CompositionPass, "", ""}, - } { - v := Compose(m, Change{Group: "g", Heads: "0123456789ab", Placement: row.cp}, "sdk", 0) - if v.State != row.state || v.Machine != row.machine || !strings.Contains(v.Words, row.words) || !slices.Contains(v.Machines, "g14") { - t.Errorf("%s: %+v", row.name, v) - } - } - - // ADR 0559 §5: the placed nodes' answers. ace is placed on the trunk and its candidate moves web there (intended); - // g14 is not placed on the trunk (the seed), so nothing is intended there; novox is placed and unchanged. - at := time.Date(2026, 10, 27, 2, 0, 0, 0, time.UTC) - cp := &ChangePlacement{Head: "h2", Written: at, Revisions: map[string]uint64{"ace": 7, "g14": 8}, - Placements: map[string]*delivery.Placement{"ace": {Node: "ace"}, "g14": {Node: "g14"}}} - intended := map[string]map[string]bool{"ace": {"web": true}} - same := func(rev uint64) *delivery.CandidateAnswer { - return &delivery.CandidateAnswer{Head: "h2", Revision: rev, Verdict: delivery.CandidateSame, Same: []string{"mail"}} - } - placedNodes := []string{"ace", "g14", "novox"} - for _, row := range []struct { - name string - answers map[string]*delivery.CandidateAnswer - now time.Time - state string - words string - }{ - {"all the same", map[string]*delivery.CandidateAnswer{"ace": same(7), "g14": same(8), "novox": same(0)}, at, delivery.CompositionPass, ""}, - {"a module added where intended", map[string]*delivery.CandidateAnswer{"ace": {Head: "h2", Revision: 7, Verdict: delivery.CandidateDiffers, - Added: []delivery.ModuleAdded{{Module: "web", Unissued: []string{"its own secret"}}}}, "g14": same(8), "novox": same(0)}, at, delivery.CompositionPass, ""}, - {"a module differing where not intended", map[string]*delivery.CandidateAnswer{"ace": {Head: "h2", Revision: 7, Verdict: delivery.CandidateDiffers, - Differs: []delivery.ModuleDiffers{{Module: "mail", Why: "the port differs"}}}, "g14": same(8), "novox": same(0)}, at, delivery.CompositionFail, "ace composes mail differently"}, - {"a node's first placement file with a module differing", map[string]*delivery.CandidateAnswer{"ace": same(7), "g14": {Head: "h2", Revision: 8, Verdict: delivery.CandidateDiffers, - Differs: []delivery.ModuleDiffers{{Module: "web", Why: "a binding differs"}}}, "novox": same(0)}, at, delivery.CompositionFail, "g14 composes web differently"}, - {"a refusal", map[string]*delivery.CandidateAnswer{"ace": same(7), "g14": {Head: "h2", Revision: 8, Verdict: delivery.CandidateRefused, - Refusals: []delivery.ModuleRefused{{Module: "web", Why: "no own secret"}}}, "novox": same(0)}, at, delivery.CompositionFail, "g14 refuses"}, - {"a node not answering, within its bound", map[string]*delivery.CandidateAnswer{"ace": same(7), "novox": same(0)}, at.Add(AnswerBound - time.Second), delivery.CompositionWaits, "g14 (offline)"}, - {"an answer of an earlier revision, past its bound", map[string]*delivery.CandidateAnswer{"ace": same(6), "g14": same(8), "novox": same(0)}, at.Add(AnswerBound), delivery.CompositionFail, "ace " + delivery.CompositionUnanswered}, - {"its later answer", map[string]*delivery.CandidateAnswer{"ace": same(7), "g14": same(8), "novox": same(0)}, at.Add(time.Hour), delivery.CompositionPass, ""}, - } { - pass := delivery.CompositionVerdict{Group: "g", Heads: "0123456789ab", State: delivery.CompositionPass, SDK: "sdk", Machines: []string{"ace", "g14"}} - v := WithAnswers(pass, JudgeAnswers(cp, placedNodes, row.answers, intended, map[string]bool{"ace": true, "novox": true}, row.now)) - if v.State != row.state || !strings.Contains(v.Words, row.words) { - t.Errorf("%s: %+v", row.name, v) - } - if _, err := delivery.EncodeCompositionVerdict(v); err != nil { - t.Errorf("%s: the verdict cannot be written: %v", row.name, err) - } - } - trunk := map[string]delivery.Placement{"ace": {Node: "ace", Modules: []delivery.PlacedModule{{Module: "mail"}}}, "novox": {Node: "novox"}} - if got := PlacedNodes(trunk, &ChangePlacement{Placements: map[string]*delivery.Placement{"novox": nil, "g14": {Node: "g14"}}}); !slices.Equal(got, []string{"ace", "g14", "novox"}) { - t.Errorf("placed at the head or on the trunk = %v", got) - } - got, _ := Intended(nil, trunk, &ChangePlacement{Placements: map[string]*delivery.Placement{"ace": {Node: "ace", Modules: []delivery.PlacedModule{{Module: "mail", Ports: map[string]int{"25": 2525}}, {Module: "web"}}}, "g14": {Node: "g14", Modules: []delivery.PlacedModule{{Module: "web"}}}}}, nil) - if !got["ace"]["mail"] || !got["ace"]["web"] || len(got["g14"]) != 0 { - t.Errorf("intended = %v, want ace's mail and web, nothing on g14 the trunk does not place", got) - } } // 1297/B3c: what was already broken on the trunk is said and fails nothing. @@ -183,10 +109,6 @@ func TestTouched(t *testing.T) { if got := Touched(m, Change{Manifests: map[string]catalogue.Manifest{"store": m.Manifests["store"]}}); !slices.Equal(got, []string{"ace", "g14"}) { t.Errorf("touched by store with a consumer on g14 = %v", got) } - // ADR 0559 §5: every node whose placement the change changes is touched, its file removed or not. - if got := Touched(m, Change{Placement: &ChangePlacement{Placements: map[string]*delivery.Placement{"novox": nil, "shanks": {Node: "shanks"}}}}); !slices.Equal(got, []string{"novox", "shanks"}) { - t.Errorf("touched by a placement = %v", got) - } c := Change{Group: "g", Heads: "0123456789ab", Manifests: map[string]catalogue.Manifest{"new": {Module: "new", Version: "1"}}} if v := Compose(m, c, "sdk", 0); len(v.Machines) != 0 || !hasWarning(v, "touches no machine") || v.State != delivery.CompositionPass { t.Errorf("an added module assigned nowhere: %+v", v) diff --git a/modules/mesh-delivery-assigner/internal/assign/composer.go b/modules/mesh-delivery-assigner/internal/assign/composer.go index 03b4d60..b2dac9e 100644 --- a/modules/mesh-delivery-assigner/internal/assign/composer.go +++ b/modules/mesh-delivery-assigner/internal/assign/composer.go @@ -50,17 +50,6 @@ func HeadJob(m Mesh, c Change) (delivery.ComposeJob, error) { fromClaims = len(in.Holdings) == 0 } changeCase := composer.Case{Name: CaseChange, Manifests: change} - if c.Placement != nil { - // The change's own placement, laid over the input's in the change case (ADR 0559 §5; composer.Case's Placements): - // a node the input does not hold yet is added bare, so the composer places it. - changeCase.Placements = c.Placement.Placements - in.Nodes = slices.Clone(in.Nodes) - for _, node := range c.Placement.Nodes() { - if !slices.ContainsFunc(in.Nodes, func(n compose.Node) bool { return n.Name == node }) { - in.Nodes = append(in.Nodes, compose.Node{Name: node}) - } - } - } req := composer.Request{V: composer.Version, Input: in, Depth: composer.DepthResolve, HoldingsFromClaims: fromClaims, Machines: Touched(m, c), Cases: []composer.Case{{Name: CaseTrunk, Manifests: trunk}, changeCase}} diff --git a/modules/mesh-delivery-assigner/internal/assign/composer_test.go b/modules/mesh-delivery-assigner/internal/assign/composer_test.go index a01cebd..2bc3258 100644 --- a/modules/mesh-delivery-assigner/internal/assign/composer_test.go +++ b/modules/mesh-delivery-assigner/internal/assign/composer_test.go @@ -120,22 +120,6 @@ func TestAGroupWithAnSDKMemberAsksTheComposerAtItsHead(t *testing.T) { len(placed.Input.Holdings) != 1 || placed.HoldingsFromClaims { t.Errorf("the placed check's request = %+v, holdings from claims %v", placed.Input, placed.HoldingsFromClaims) } - - // ADR 0559 §5: a change of placement composes its own placement in the change case alone (composer.Case's Placements), - // a node the input does not hold yet added bare so the composer places it, and the node touched. - c.Placement = &ChangePlacement{Placements: map[string]*delivery.Placement{"novox": {Node: "novox"}}} - candJob, err := HeadJob(m, c) - if err != nil { - t.Fatal(err) - } - cand, err := composer.Decode(candJob.Request) - if err != nil { - t.Fatal(err) - } - if cand.Cases[0].Placements != nil || cand.Cases[1].Placements["novox"] == nil || len(cand.Input.Nodes) != 2 || cand.Input.Nodes[1].Name != "novox" || - !slices.Equal(cand.Machines, []string{"g14", "novox"}) || len(m.Input.Nodes) != 1 { - t.Errorf("the placement's request = cases %+v, nodes %+v, machines %v", cand.Cases, cand.Input.Nodes, cand.Machines) - } } // 1622/AC2, AC3: a catalogue member using a field only the SDK head reads passes with the head's composer, and the diff --git a/modules/mesh-delivery-assigner/internal/assign/placement.go b/modules/mesh-delivery-assigner/internal/assign/placement.go index 9856b12..e164ef9 100644 --- a/modules/mesh-delivery-assigner/internal/assign/placement.go +++ b/modules/mesh-delivery-assigner/internal/assign/placement.go @@ -2,6 +2,7 @@ package assign import ( "fmt" + "strings" "sort" "git.novox.be/novox/mesh-sdk/go/asks" @@ -9,21 +10,25 @@ import ( ) // Until ADR 0547's third milestone two sources name which module runs on which node: the controller's assignments and -// the indexer's placement, read from the catalogue (ADR 0549 §5). The assigner writes wants only for a pair both name: +// the seat mesh-placement's placement (ADR 0549 §5, ADR 0567 §8). The assigner writes wants only for a pair both name: // for a pair one source names alone it writes no want and touches none it wrote before, so nothing moves and nothing is -// removed on one source's word, and it raises a condition naming the pair until both agree. Before the first placement -// file reaches the indexer (no placement key at all) the controller's assignments are the one source, as before. +// removed on one source's word, and it raises a condition naming the pair until both agree. After an approved proposal +// the assigner keeps the controller in step itself, calling its assign, unassign, pin and unpin; a call refused or not +// yet answered leaves the pair differing, and the condition names the call (Pair.Call). Before the first placement key +// is written the controller's assignments are the one source, as before. // Pair is a module on a node that one source names and the other does not; Only says which: OnlyAssignments or // OnlyPlacement. type Pair struct { Node, Module, Only string + // Call is what became of the assigner's call of the controller's verb for the pair, in words, when it made one. + Call string } // The source that names a pair alone. const ( OnlyAssignments = "the controller's assignments" - OnlyPlacement = "the catalogue's placement" + OnlyPlacement = "the seat mesh-placement's placement" ) // Placed is, by node, the modules both sources name, in name order, and every pair one names alone, by node then @@ -79,7 +84,7 @@ func PlacementCondition(p Pair, raised bool) asks.Condition { subject := asks.Scope{Scope: "module", ID: p.Module, Machine: p.Node} if !raised { return asks.Condition{Key: key, Subject: subject, State: asks.StateCleared, - Resolved: fmt.Sprintf("the controller's assignments and the catalogue's placement agree on %s on %s.", p.Module, p.Node)} + Resolved: fmt.Sprintf("the controller's assignments and the seat's placement agree on %s on %s.", p.Module, p.Node)} } headline := fmt.Sprintf("%s on %s is placed by one source only", p.Module, p.Node) if n := len([]rune(headline)); n > asks.HeadlineLength { @@ -87,9 +92,12 @@ func PlacementCondition(p Pair, raised bool) asks.Condition { } var needs string if p.Only == OnlyAssignments { - needs = fmt.Sprintf("add %s to the catalogue's placement file of %s, or unassign it at the controller", p.Module, p.Node) + needs = fmt.Sprintf("propose placing %s on %s (mesh-placement.propose), or unassign it at the controller", p.Module, p.Node) } else { - needs = fmt.Sprintf("assign %s to %s at the controller, as the merged placement file says", p.Module, p.Node) + needs = fmt.Sprintf("assign %s to %s at the controller, as the approved placement says, or propose taking it off", p.Module, p.Node) + } + if p.Call != "" { + needs += "; the assigner's call: " + p.Call } return asks.Condition{Key: key, Severity: asks.SeverityUrgent, Subject: subject, State: asks.StateRaised, Summary: headline, Headline: headline, Needs: needs, @@ -97,3 +105,17 @@ func PlacementCondition(p Pair, raised bool) asks.Condition { "name a module on a node (ADR 0549 §5): the assigner writes no want for it there and leaves the one it wrote before, so "+ "nothing moves or is removed. This clears once both name it or neither does.", asks.NeedsYou, needs, p.Only, p.Module, p.Node)} } + +// SeatWaitsCondition is the assigner's word on the seat mesh-placement while a state of the seat is not bound (ADR 0567 +// §1): raised naming each, cleared when none is left. +func SeatWaitsCondition(unbound []string) asks.Condition { + key := delivery.AssignerModule + ".seat-states" + subject := asks.Scope{Scope: "module", ID: delivery.AssignerModule} + if len(unbound) == 0 { + return asks.Condition{Key: key, Subject: subject, State: asks.StateCleared, Resolved: "the seat mesh-placement holds all its states."} + } + why := "the seat waits for its states: " + strings.Join(unbound, ", ") + " are not on the bus or not granted yet" + return asks.Condition{Key: key, Severity: asks.SeverityWarning, Subject: subject, State: asks.StateRaised, + Summary: "the seat waits for its states", Headline: "the seat waits for its states", + Explanation: asks.NothingToDo + " " + why + "; each is bound at the next pass once it is, with no restart."} +} diff --git a/modules/mesh-delivery-assigner/internal/assign/seat.go b/modules/mesh-delivery-assigner/internal/assign/seat.go new file mode 100644 index 0000000..0874e81 --- /dev/null +++ b/modules/mesh-delivery-assigner/internal/assign/seat.go @@ -0,0 +1,296 @@ +package assign + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// The seat mesh-placement (novox/hq ADR 0567), held by the assigner beside its wants: placement is its state +// `placement`, changed only by a proposal (§2) the nodes it touches answer (§3), judged (§4) and written on the +// operator's Approve at the revision it was laid over (§5). What here decides; the tools and the passes do. + +// Leased says whether the lease write of ADR 0547 has happened, after which the controller's assignments are read no +// more (§8). Not before L3: until then the holder keeps the controller in step and refuses what its verbs cannot take. +var Leased = false + +// ProposeIn is what a proposal is laid over: its id, who and why, its changes; every node's current placement and the +// revision of its key; which modules the indexer holds and which nodes have a record; and, for the seed, the export's +// digest. +type ProposeIn struct { + ID, By, Why string + At time.Time + Changes []delivery.PlacementProposalChange + Current map[string]delivery.Placement + Revisions map[string]uint64 + Held func(module string) bool + Recorded func(node string) bool + Seed bool + ExportDigest string +} + +// Propose lays a proposal over the current keys (§2): the proposal (state answering, each changed node's base revision), +// the laid placement of every node whose key it changes, and every finding, which refuses it whole: a change the +// controller's verbs cannot take before the lease write (§8), a change that does not fit its node, and what +// CheckPlacements refuses of the result laid over every placement (two modules on one host port, a provider not placed, +// a seat held once held twice, two nodes with one address, two hubs, a module the indexer does not hold, a node with no +// record). A key whose value the proposal leaves as it is changes nothing and is not laid. +func Propose(in ProposeIn) (delivery.PlacementProposal, map[string]*delivery.Placement, []string) { + p := delivery.PlacementProposal{ID: in.ID, By: strings.TrimSpace(in.By), Why: strings.TrimSpace(in.Why), At: in.At.UTC(), + Changes: in.Changes, Seed: in.Seed, ExportDigest: in.ExportDigest, Base: map[string]uint64{}, State: delivery.ProposalAnswering} + var findings []string + if p.By == "" { + findings = append(findings, "the runtime names no caller: a proposal is made by someone") + } + if p.Why == "" { + findings = append(findings, "a proposal says why: it is shown to the operator") + } + if len(in.Changes) == 0 { + findings = append(findings, "a proposal changes something: it names no change") + } + for i, c := range in.Changes { + if (c.Op == delivery.OpSeed) != in.Seed { + findings = append(findings, fmt.Sprintf("change %d: the seed is adopt's alone, and never mixed with other changes", i+1)) + continue + } + if !Leased { + if why := c.BeforeLease(); why != "" { + findings = append(findings, fmt.Sprintf("change %d: %s", i+1, why)) + } + } + } + laid, lay := delivery.LayChanges(in.Current, in.Changes) + findings = append(findings, lay...) + all := map[string]delivery.Placement{} + for node, pl := range in.Current { + all[node] = pl + } + out := map[string]*delivery.Placement{} + for node, pl := range laid { + if before, ok := in.Current[node]; ok && delivery.SamePlacement(before, pl) { + continue + } + pl := pl + all[node] = pl + out[node] = &pl + p.Base[node] = in.Revisions[node] + } + if len(findings) == 0 && len(out) == 0 { + findings = append(findings, "the changes leave every placement as it is") + } + held, recorded := in.Held, in.Recorded + if held == nil { + held = func(string) bool { return false } + } + if recorded == nil { + recorded = func(string) bool { return false } + } + for _, r := range delivery.CheckPlacements(all, held, recorded) { + findings = append(findings, r) + } + return p, out, findings +} + +// Write is one key a warrant writes (§5): the node, its key, the revision it is written at (0: it has none, so it is +// created) and its value (nil: it is deleted). +type Write struct { + Node, Key string + Base uint64 + Value *delivery.Placement +} + +// Writes is every key a proposal writes, by node order. +func Writes(p delivery.PlacementProposal, laid map[string]*delivery.Placement) ([]Write, error) { + var out []Write + for _, node := range p.Nodes() { + key, err := delivery.PlacementKey(node) + if err != nil { + return nil, err + } + out = append(out, Write{Node: node, Key: key, Base: p.Base[node], Value: laid[node]}) + } + return out, nil +} + +// ControllerCall is one call of the controller's seat verbs the holder makes after writing an approved proposal, until +// the lease write (§8): assign, unassign, pin or unpin, with its arguments. +type ControllerCall struct { + Verb string + Args map[string]any + // Node and Module name the pair the call keeps in step (a pin names no module). + Node, Module string +} + +// Key names the call, as the condition of its pair says it. +func (c ControllerCall) Key() string { + return fmt.Sprintf("mesh-controller.%s %s %s", c.Verb, c.Node, c.Module) +} + +// ControllerCalls is every call that brings the controller's assignments and pins to the placement after: assign for a +// module placed on a node before not, unassign for one taken off, pin for a pin set or changed, unpin for one removed, +// each naming the proposal and its warrant in its why. In node order, unassigns before assigns, so a module moved +// between nodes is first taken off. +func ControllerCalls(before map[string]delivery.Placement, after map[string]*delivery.Placement, proposal, warrant string) []ControllerCall { + why := fmt.Sprintf("placement proposal %s, approved (%s)", proposal, warrant) + nodes := make([]string, 0, len(after)) + for node := range after { + nodes = append(nodes, node) + } + sort.Strings(nodes) + var off, on, pins []ControllerCall + for _, node := range nodes { + was, now := before[node], delivery.Placement{} + if a := after[node]; a != nil { + now = *a + } + had, has := map[string]bool{}, map[string]bool{} + for _, m := range was.Modules { + had[m.Module] = true + } + for _, m := range now.Modules { + has[m.Module] = true + if !had[m.Module] { + on = append(on, ControllerCall{Verb: "assign", Node: node, Module: m.Module, Args: map[string]any{"node": node, "module": m.Module}}) + } + } + for _, m := range was.Modules { + if !has[m.Module] { + off = append(off, ControllerCall{Verb: "unassign", Node: node, Module: m.Module, Args: map[string]any{"node": node, "module": m.Module}}) + } + } + for _, provision := range sortedKeys(now.Pins) { + to := now.Pins[provision] + if old, ok := was.Pins[provision]; !ok || old != to { + pins = append(pins, ControllerCall{Verb: "pin", Node: node, Module: provision, + Args: map[string]any{"node": node, "provision": provision, "from": to.Node, "module": to.Module}}) + } + } + for _, provision := range sortedKeys(was.Pins) { + if _, ok := now.Pins[provision]; !ok { + pins = append(pins, ControllerCall{Verb: "unpin", Node: node, Module: provision, Args: map[string]any{"node": node, "provision": provision}}) + } + } + } + out := append(append(off, on...), pins...) + for i := range out { + out[i].Args["why"] = why + } + return out +} + +// PlacementAct is the act an approved proposal's yes binds: writing exactly its changes at its base revisions. +func PlacementAct(p delivery.PlacementProposal) asks.Act { + return asks.Act{"placement": "write", "proposal": p.ID, "changes": delivery.ChangesDigest(p)} +} + +// PlacementAsking is a proposal's asking number: each judging that raises its ask again (again, a key moved) is +// another asking, under an id of its own. +func PlacementAsking(p delivery.PlacementProposal, at time.Time) int { + return int(at.Unix()) +} + +// PlacementAsk is the authorising ask about a proposal (§5): an Approve that binds its act, so only a channel that +// proves who answered takes it, with the brief of the verdict by node (judge.Brief) as its explanation. +func PlacementAsk(key string, p delivery.PlacementProposal, brief string, asking int, now time.Time) (delivery.AssignerAsk, error) { + binds, err := asks.ActDigest(PlacementAct(p)) + if err != nil { + return delivery.AssignerAsk{}, err + } + nodes := p.Nodes() + headline := fmt.Sprintf("Change placement on %s?", strings.Join(nodes, ", ")) + if p.Seed { + headline = "Take placement over from the controller?" + } + if n := len([]rune(headline)); n > asks.HeadlineLength { + headline = fmt.Sprintf("Change placement on %d machines?", len(nodes)) + } + return delivery.AssignerAsk{Ask: asks.Ask{ + ID: delivery.AskingID(key, asking), + Headline: headline, + Explanation: brief, + Options: []asks.Option{ + {ID: OptionYes, Label: "Approve", Does: "the placement of " + strings.Join(nodes, ", ") + " is written as proposed", + Level: asks.Approve, Binds: binds}, + {ID: OptionNo, Label: "Decline", Does: "nothing is written, and the proposal ends", Level: asks.Acknowledge}, + }, + Who: asks.Operator, + Expires: now.Add(asks.ApproveLasts), + OnExpiry: "nothing is written, and the proposal ends", + About: key, + }, Asking: asking}, nil +} + +// ExportOf reads the controller's answer to placement-export (§7): its verb answers what the command printed, the +// JSON of every node's placement, in `output` (the controller's verbAnswer; `answer` when it carries it as data). A +// refused or unreadable answer is an error naming it. +func ExportOf(raw []byte) (map[string]delivery.Placement, error) { + var a struct { + Output string `json:"output"` + OK *bool `json:"ok"` + Answer json.RawMessage `json:"answer"` + } + if err := json.Unmarshal(raw, &a); err != nil { + return nil, fmt.Errorf("the controller's placement-export answered no verb answer: %w", err) + } + if a.OK != nil && !*a.OK { + return nil, fmt.Errorf("the controller's placement-export refused: %s", strings.TrimSpace(a.Output)) + } + var byNode map[string]json.RawMessage + if len(a.Answer) > 0 && string(a.Answer) != "null" { + if err := json.Unmarshal(a.Answer, &byNode); err != nil { + return nil, fmt.Errorf("the controller's placement-export is no placement by node: %w", err) + } + } else if err := json.NewDecoder(strings.NewReader(a.Output)).Decode(&byNode); err != nil { + // The command prints its JSON first; what its standard error said after it is left. + return nil, fmt.Errorf("the controller's placement-export printed no placement by node: %w", err) + } + out := map[string]delivery.Placement{} + for node, rawP := range byNode { + p, err := delivery.DecodePlacement(rawP) + if err != nil { + return nil, fmt.Errorf("the controller's placement-export of %s cannot be read: %w", node, err) + } + if p.Node != node { + return nil, fmt.Errorf("the controller's placement-export names %s under %s", p.Node, node) + } + out[node] = p + } + if len(out) == 0 { + return nil, fmt.Errorf("the controller's placement-export names no node") + } + return out, nil +} + +// SeedChanges is the seed's changes: every node's placement of the export, whole, in node order. +func SeedChanges(export map[string]delivery.Placement) []delivery.PlacementProposalChange { + var out []delivery.PlacementProposalChange + for _, node := range sortedKeys(export) { + p := export[node] + out = append(out, delivery.PlacementProposalChange{Op: delivery.OpSeed, Node: node, Whole: &p}) + } + return out +} + +// Rest is what a proposal leaves to write once the keys of written are: its changes of the other nodes, to be laid again +// over the keys as the write left them (§5: a key moved while it was written). False when nothing is left. +func Rest(p delivery.PlacementProposal, written map[string]*delivery.Placement) (delivery.PlacementProposal, bool) { + rest := p + rest.Changes, rest.Base = nil, map[string]uint64{} + for _, c := range p.Changes { + if _, done := written[c.Node]; !done { + rest.Changes = append(rest.Changes, c) + } + } + for node, base := range p.Base { + if _, done := written[node]; !done { + rest.Base[node] = base + } + } + rest.Must, rest.Verdict, rest.Ask, rest.State = nil, nil, "", delivery.ProposalAnswering + return rest, len(rest.Changes) > 0 +} diff --git a/modules/mesh-delivery-assigner/internal/assign/seat_test.go b/modules/mesh-delivery-assigner/internal/assign/seat_test.go new file mode 100644 index 0000000..2a94a8a --- /dev/null +++ b/modules/mesh-delivery-assigner/internal/assign/seat_test.go @@ -0,0 +1,147 @@ +package assign + +import ( + "encoding/json" + "slices" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// ADR 0567 §2, §5, §7, §8: what the seat's holder decides of a proposal: every refusal named at once, a valid one laid +// with the base revision of each key it changes, the writes at those revisions, the controller's calls that keep its +// assignments in step, the authorising ask bound to the changes, and the export read as the controller answers it. +func TestThePlacementSeatDecides(t *testing.T) { + anchor := delivery.Placement{V: delivery.Version, Node: "anchor", Decided: delivery.NodeDecided{Hub: true, Address: "10.0.0.1"}, + Modules: []delivery.PlacedModule{{Module: "store", Ports: map[string]int{"5432": 5432}}, {Module: "web"}}} + laptop := delivery.Placement{V: delivery.Version, Node: "laptop", Decided: delivery.NodeDecided{Address: "10.0.0.2"}, Modules: []delivery.PlacedModule{{Module: "shell"}}} + in := func(changes ...delivery.PlacementProposalChange) ProposeIn { + return ProposeIn{ID: "p-one", By: "operator@laptop", Why: "move web", At: time.Unix(1, 0), Changes: changes, + Current: map[string]delivery.Placement{"anchor": anchor, "laptop": laptop}, Revisions: map[string]uint64{"anchor": 4, "laptop": 9}, + Held: func(m string) bool { return m != "ghost" }, + Recorded: func(n string) bool { return n == "anchor" || n == "laptop" }} + } + move := []delivery.PlacementProposalChange{{Op: delivery.OpTakeOff, Node: "anchor", Module: "web"}, {Op: delivery.OpPlace, Node: "laptop", Module: "web"}} + p, laid, findings := Propose(in(move...)) + if len(findings) != 0 || p.State != delivery.ProposalAnswering || p.Base["anchor"] != 4 || p.Base["laptop"] != 9 || + !slices.Equal(laid["laptop"].Assigned(), []string{"shell", "web"}) || !slices.Equal(laid["anchor"].Assigned(), []string{"store"}) { + t.Fatalf("a move: %+v, %+v, %v", p, laid, findings) + } + if _, err := delivery.EncodePlacementProposal(p); err != nil { + t.Fatalf("the proposal cannot be written: %v", err) + } + for name, c := range map[string]struct { + in ProposeIn + want string + }{ + "a port before the lease": {in(delivery.PlacementProposalChange{Op: delivery.OpPlace, Node: "laptop", Module: "web", Ports: map[string]int{"80": 8080}}), "lease write"}, + "a holding": {in(delivery.PlacementProposalChange{Op: delivery.OpHold, Node: "anchor", Module: "store", Claim: "mesh-store", Scope: "mesh"}), "lease write"}, + "a binding": {in(delivery.PlacementProposalChange{Op: delivery.OpPlace, Node: "laptop", Module: "web", Bindings: map[string]delivery.Provider{"db": {Node: "anchor", Module: "store"}}}), "lease write"}, + "a config place": {in(delivery.PlacementProposalChange{Op: delivery.OpConfig, Node: "anchor", Module: "web", Layer: map[string]any{"places": []any{}}}), "lease write"}, + "a config access": {in(delivery.PlacementProposalChange{Op: delivery.OpConfig, Node: "anchor", Module: "web", MeshWide: map[string]any{"accesses": []any{}}}), "lease write"}, + "a module not held": {in(delivery.PlacementProposalChange{Op: delivery.OpPlace, Node: "laptop", Module: "ghost"}), "the indexer holds no module ghost"}, + "a node with no record": {in(delivery.PlacementProposalChange{Op: delivery.OpPlace, Node: "stranger", Module: "web"}), "stranger is a node with no record"}, + "nothing changes": {in(delivery.PlacementProposalChange{Op: delivery.OpPlace, Node: "anchor", Module: "web"}), "leave every placement as it is"}, + "no why": {func() ProposeIn { i := in(move...); i.Why = " "; return i }(), "says why"}, + "no caller": {func() ProposeIn { i := in(move...); i.By = ""; return i }(), "names no caller"}, + "a seed by hand": {in(delivery.PlacementProposalChange{Op: delivery.OpSeed, Node: "laptop", Whole: &laptop}), "adopt's alone"}, + "a pin of nobody placed": {in(delivery.PlacementProposalChange{Op: delivery.OpPin, Node: "laptop", Provision: "db", Provider: &delivery.Provider{Node: "laptop", Module: "store"}}), "not placed there"}, + } { + if _, _, f := Propose(c.in); !slices.ContainsFunc(f, func(s string) bool { return strings.Contains(s, c.want) }) { + t.Errorf("%s: findings %v, want one naming %q", name, f, c.want) + } + } + + // A module's config values may be proposed before the lease write (§13); once the lease is written every change is + // the state's alone, and none waits. + if _, _, f := Propose(in(delivery.PlacementProposalChange{Op: delivery.OpConfig, Node: "anchor", Module: "web", Values: map[string]any{"puid": 1}})); len(f) != 0 { + t.Errorf("config values before the lease write: %v", f) + } + Leased = true + for _, c := range []delivery.PlacementProposalChange{ + {Op: delivery.OpPlace, Node: "laptop", Module: "web", Ports: map[string]int{"80": 8080}, Bindings: map[string]delivery.Provider{"db": {Node: "anchor", Module: "store"}}}, + {Op: delivery.OpConfig, Node: "anchor", Module: "web", Layer: map[string]any{"places": []any{}}}, + {Op: delivery.OpDecide, Node: "laptop", Field: "site", Value: json.RawMessage(`"attic"`)}, + {Op: delivery.OpHold, Node: "anchor", Module: "store", Claim: "node-shell", Scope: "node"}, + } { + if _, _, f := Propose(in(c)); len(f) != 0 { + t.Errorf("after the lease write %s is refused: %v", c.Op, f) + } + } + Leased = false + + // A key moved while the warrant wrote: what is left to write is the other nodes' changes, to be laid and asked again. + if rest, ok := Rest(p, map[string]*delivery.Placement{"anchor": laid["anchor"]}); !ok || len(rest.Changes) != 1 || rest.Changes[0].Node != "laptop" || + len(rest.Base) != 1 || rest.State != delivery.ProposalAnswering || rest.Must != nil { + t.Errorf("the rest = %+v, %v", rest, ok) + } + if _, ok := Rest(p, laid); ok { + t.Error("a proposal written whole leaves something to write") + } + + // The seat says it waits while one of its states is not bound, naming it, and clears once all are. + for _, unbound := range [][]string{{delivery.PlacementPulsesBucket}, nil} { + c := SeatWaitsCondition(unbound) + if _, err := asks.EncodeCondition(c); err != nil || (len(unbound) > 0) != (c.State == asks.StateRaised) || + (len(unbound) > 0 && !strings.Contains(c.Explanation, "the seat waits for its states: "+delivery.PlacementPulsesBucket)) { + t.Errorf("waiting for %v: %+v, %v", unbound, c, err) + } + } + + writes, err := Writes(p, laid) + if err != nil || len(writes) != 2 || writes[0].Key != "placement.anchor" || writes[0].Base != 4 || writes[1].Base != 9 { + t.Fatalf("writes = %+v, %v", writes, err) + } + pinned := *laid["laptop"] + pinned.Pins = map[string]delivery.Provider{"db": {Node: "anchor", Module: "store"}} + calls := ControllerCalls(map[string]delivery.Placement{"anchor": anchor, "laptop": laptop}, + map[string]*delivery.Placement{"anchor": laid["anchor"], "laptop": &pinned}, "p-one", "w1@t") + var said []string + for _, c := range calls { + said = append(said, c.Key()) + if !strings.Contains(c.Args["why"].(string), "p-one") { + t.Errorf("%s does not name the proposal: %v", c.Key(), c.Args) + } + } + if !slices.Equal(said, []string{"mesh-controller.unassign anchor web", "mesh-controller.assign laptop web", "mesh-controller.pin laptop db"}) { + t.Errorf("the controller's calls = %v", said) + } + + a, err := PlacementAsk("placement.p-one", p, "move web (proposed by operator@laptop)", 7, time.Unix(100, 0)) + if err != nil || a.Ask.Check(time.Unix(100, 0)) != nil || a.Ask.Options[0].Level != asks.Approve || a.Ask.Options[0].Performs(PlacementAct(p)) != nil { + t.Fatalf("the ask = %+v, %v", a, err) + } + q := p + q.Changes = append(slices.Clone(p.Changes), delivery.PlacementProposalChange{Op: delivery.OpUnpin, Node: "laptop", Provision: "x"}) + if a.Ask.Options[0].Performs(PlacementAct(q)) == nil { + t.Error("the ask's yes binds changes it was not asked about") + } + + export, _ := json.Marshal(map[string]any{"anchor": anchor, "laptop": laptop}) + for name, c := range map[string]struct { + raw string + want int + }{ + "in output": {`{"ok":true,"output":` + quote(string(export)+"\n") + `}`, 2}, + "as answer": {`{"ok":true,"output":"","answer":` + string(export) + `}`, 2}, + "refused": {`{"ok":false,"output":"no store"}`, 0}, + "no node": {`{"ok":true,"output":"{}"}`, 0}, + "no version": {`{"ok":true,"output":"{\"anchor\":{\"node\":\"anchor\",\"modules\":[]}}"}`, 0}, + } { + got, err := ExportOf([]byte(c.raw)) + if len(got) != c.want || (c.want == 0) != (err != nil) { + t.Errorf("%s: %d nodes, %v", name, len(got), err) + } + } + if seed := SeedChanges(map[string]delivery.Placement{"laptop": laptop, "anchor": anchor}); len(seed) != 2 || seed[0].Node != "anchor" || seed[0].Op != delivery.OpSeed { + t.Errorf("the seed's changes = %+v", seed) + } +} + +func quote(s string) string { + raw, _ := json.Marshal(s) + return string(raw) +} diff --git a/modules/mesh-delivery-assigner/manifest_test.go b/modules/mesh-delivery-assigner/manifest_test.go index 9e5584f..1803f4b 100644 --- a/modules/mesh-delivery-assigner/manifest_test.go +++ b/modules/mesh-delivery-assigner/manifest_test.go @@ -7,6 +7,7 @@ import ( "testing" "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" ) func load(t *testing.T) catalogue.Manifest { @@ -32,9 +33,9 @@ func TestManifest(t *testing.T) { } reads := slices.Clone(m.Reads) slices.Sort(reads) - want := []string{"mesh-build-go.steps", "mesh-controller.assignments", "mesh-controller.builds", "mesh-controller.composed", "mesh-controller.grants", + want := []string{"mesh-build-go.steps", "mesh-config.resolved", "mesh-controller.assignments", "mesh-controller.builds", "mesh-controller.composed", "mesh-controller.grants", "mesh-delivery-builder.builds", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.manifests", - "mesh-delivery-indexer.modules", "mesh-delivery-indexer.placement", "mesh-delivery-indexer.placement-candidates", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "node-engine.have"} + "mesh-delivery-indexer.modules", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "node-engine.have"} if !reflect.DeepEqual(reads, want) { t.Errorf("reads = %v, want exactly %v", reads, want) } @@ -42,7 +43,8 @@ func TestManifest(t *testing.T) { for _, s := range m.State { states[s.Name] = s.History } - if want := map[string]int{"asks": 5, "compositions": 5, "differences": 64, "puts": 20, "wants": 20}; !reflect.DeepEqual(states, want) { + if want := map[string]int{"asks": 5, "compositions": 5, "differences": 64, "puts": 20, "wants": 20, + "placement": 64, "proposals": 5, "candidates": 5, "pulses": 0}; !reflect.DeepEqual(states, want) { t.Errorf("state = %v, want %v", states, want) } if _, ok := m.OwnSecrets["broker"]; !ok || len(m.OwnSecrets) != 1 { @@ -57,12 +59,27 @@ func TestManifest(t *testing.T) { t.Errorf("uses = %v, want exactly node-build-go and operator-channel", m.Uses) } // ADR 0546: it serves its own tools (put, release, puts) from its bundle through the tool runner (the artifact loads the - // module), claims no seat (a new seat needs a change of the controller), says each put's step as the event put, and - // calls the controller's warranted and the bus node's backup holder. - if len(m.Claims) != 0 || !slices.Equal(m.Tools, []string{"assigner_put", "assigner_release", "assigner_puts"}) { - t.Errorf("the assigner claims %v and serves tools %v, want no claim and its three put tools", m.Claims, m.Tools) + // module), says each put's step as the event put, and calls the controller's warranted and the bus node's backup holder. + // ADR 0567 §1, §6-§10: it holds the seat mesh-placement, serving its seven verbs; keeps the seat's events on its stream + // placement for 30 days; calls the controller's export, its four verbs that keep its assignments in step and its + // hand-act log; and reads mesh-config's resolved values to compose every node a proposal touches. + if len(m.Claims) != 1 || m.Claims[0].Name != catalogue.PlacementSeat || m.Claims[0].At() != catalogue.ScopeMesh || + !slices.Equal(m.Claims[0].Serves, []string{"placement", "proposals", "propose", "withdraw", "again", "adopt", "hand-back"}) { + t.Errorf("the assigner claims %+v, want mesh-placement with its seven verbs", m.Claims) } - if want := []string{"seat:mesh-controller.warranted", "seat:node-backup.backed-up", "seat:node-backup.now"}; !slices.Equal(slices.Sorted(slices.Values(m.Invokes)), want) { + if seat, _ := catalogue.SeatNamed(catalogue.PlacementSeat); catalogue.CanHold(m, seat) != nil { + t.Errorf("the assigner cannot hold the seat: %v", catalogue.CanHold(m, seat)) + } + if !slices.Equal(m.Tools, []string{"assigner_put", "assigner_release", "assigner_puts"}) { + t.Errorf("the assigner serves tools %v, want its three put tools", m.Tools) + } + if len(m.Streams) != 1 || m.Streams[0].Name != delivery.PlacementStream || m.Streams[0].MaxAgeDays != delivery.PlacementStreamDays || + !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 want := []string{"seat:mesh-controller.assign", "seat:mesh-controller.hand-act", "seat:mesh-controller.pin", + "seat:mesh-controller.placement-export", "seat:mesh-controller.unassign", "seat:mesh-controller.unpin", + "seat:mesh-controller.warranted", "seat:node-backup.backed-up", "seat:node-backup.now"}; !slices.Equal(slices.Sorted(slices.Values(m.Invokes)), want) { 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 eb171e7..91b6bca 100644 --- a/modules/mesh-delivery-assigner/module.json +++ b/modules/mesh-delivery-assigner/module.json @@ -2,8 +2,24 @@ "module": "mesh-delivery-assigner", "version": "1", "slug": "assigner", + "claims": [ + { + "name": "mesh-placement", + "scope": "mesh", + "serves": [ + "placement", + "proposals", + "propose", + "withdraw", + "again", + "adopt", + "hand-back" + ] + } + ], "reads": [ "mesh-build-go.steps", + "mesh-config.resolved", "mesh-controller.assignments", "mesh-controller.builds", "mesh-controller.composed", @@ -12,8 +28,6 @@ "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules", - "mesh-delivery-indexer.placement", - "mesh-delivery-indexer.placement-candidates", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "node-engine.have" @@ -23,9 +37,15 @@ "operator-channel" ], "invokes": [ + "seat:mesh-controller.assign", + "seat:mesh-controller.hand-act", + "seat:mesh-controller.pin", + "seat:mesh-controller.placement-export", + "seat:mesh-controller.unassign", + "seat:mesh-controller.unpin", "seat:mesh-controller.warranted", - "seat:node-backup.now", - "seat:node-backup.backed-up" + "seat:node-backup.backed-up", + "seat:node-backup.now" ], "emits": [ "put" @@ -36,12 +56,28 @@ "assigner_puts" ], "state": [ + { "name": "placement", "history": 64 }, + { "name": "proposals", "history": 5 }, + { "name": "candidates", "history": 5 }, + { "name": "pulses", "key-ttl": true }, { "name": "asks", "history": 5 }, { "name": "compositions", "history": 5 }, { "name": "differences", "history": 64 }, { "name": "puts", "history": 20 }, { "name": "wants", "history": 20 } ], + "streams": [ + { + "name": "placement", + "events": [ + "placement-proposed", + "placement-judged", + "placement-changed", + "placement-withdrawn" + ], + "max-age-days": 30 + } + ], "own-secrets": { "broker": "${dir:state}/broker" }, diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go index 904b8b3..8576332 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/judge.go @@ -2,12 +2,16 @@ package main import ( "context" + "fmt" "sort" "strings" "sync" + "github.com/nats-io/nats.go/jetstream" + "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/delivery/buildrun" + "git.novox.be/novox/mesh-sdk/go/process" "mesh-delivery-checker/internal/check" ) @@ -25,11 +29,46 @@ type judgeDeps struct { builds check.BuildStates // compositions is the assigner's bucket of compositions; nil leaves every composition not run. compositions check.CompositionReader - logf func(format string, args ...any) + // nodes names every node with a record; nil hands no names. + nodes *nodeNames + logf func(format string, args ...any) +} + +// nodeNames reads every node's name from the node-engine's have, one key `.node` per node with a record (novox/hq +// ADR 0567 §12), at each ask: a node enrolled since is named in the next check. +type nodeNames struct{ kv jetstream.KeyValue } + +// openNodeNames opens the node-engine's have for the nodes' names alone: it is not watched, so no check waits on a node. +func openNodeNames(ctx context.Context, js process.BucketOpener, logf func(string, ...any)) *nodeNames { + return &nodeNames{kv: process.OptionalBucket(ctx, js, delivery.HaveBucket, "no check is asked until it is: the names check needs the nodes' names", logf)} +} + +func (n nodeNames) Names(ctx context.Context) ([]string, error) { + if n.kv == nil { + return nil, fmt.Errorf("the bucket %s could not be opened at the checker's start", delivery.HaveBucket) + } + lister, err := n.kv.ListKeysFiltered(ctx, "*."+delivery.HaveNodeWord) + if err != nil { + return nil, err + } + var out []string + for key := range lister.Keys() { + if node, _, isNode, err := delivery.ParseHaveKey(key); err == nil && isNode { + out = append(out, node) + } + } + sort.Strings(out) + return out, nil } func newJudge(d judgeDeps) check.Judge { - return &check.Examiner{Compositions: d.compositions, Asks: d.asks, Toolchains: d.tools, + var nodes interface { + Names(ctx context.Context) ([]string, error) + } + if d.nodes != nil { + nodes = d.nodes + } + return &check.Examiner{Nodes: nodes, Compositions: d.compositions, Asks: d.asks, Toolchains: d.tools, Kept: check.Kept{Store: d.kept}, Previews: d.previews, Modules: d.repos, Packages: d.repos, Forge: d.forge, Runs: d.repos, Builds: d.builds, Announce: d.announce, Trunks: d.trunks, Logf: d.logf} } diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go index 5dc2441..52530d5 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go @@ -194,6 +194,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro kept: kvStore{buckets[delivery.CheckerKeptBucket]}, previews: kvStore{buckets[delivery.PreviewsBucket]}, repos: repos, builds: builds, forge: forgeReads{call: caller.Call}, logf: logger.Printf, announce: announce, compositions: compositions, + nodes: openNodeNames(ctx, js, logger.Printf), }) ch.Announce = announce tryer.Judge = ch.Judge diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/nodes_test.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/nodes_test.go index 2a7b78d..550d402 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/nodes_test.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/nodes_test.go @@ -7,7 +7,8 @@ import ( ) // 1912: with checks as asks on the build seats' streams, the stream redelivers a lost node's ask (ADR 0525 §6), so the -// checker program opens no node-engine have to start again a check that failed for want of a node. +// checker program watches no node-engine have to start again a check that failed for want of a node. It reads the have's +// keys only for the nodes' names at each ask (judge.go, novox/hq ADR 0567 §12). func TestTheCheckerWatchesNoHave(t *testing.T) { raw, err := os.ReadFile("main.go") if err != nil { diff --git a/modules/mesh-delivery-checker/go.mod b/modules/mesh-delivery-checker/go.mod index c34164e..85ac234 100644 --- a/modules/mesh-delivery-checker/go.mod +++ b/modules/mesh-delivery-checker/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-checker go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027193820-8ad9372b1952 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-checker/go.sum b/modules/mesh-delivery-checker/go.sum index fa4f833..6245202 100644 --- a/modules/mesh-delivery-checker/go.sum +++ b/modules/mesh-delivery-checker/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027193820-8ad9372b1952 h1:TmveT3Bija643RGWkilvkzP/h8UWlVvHFZZtQfJK5oU= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027193820-8ad9372b1952/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388 h1:ycu/sNieTdLpD3bzhZOCXdzcoqXUOxPRmqWV019REpo= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388/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-checker/internal/check/examiner_test.go b/modules/mesh-delivery-checker/internal/check/examiner_test.go index c8f5b13..5d708df 100644 --- a/modules/mesh-delivery-checker/internal/check/examiner_test.go +++ b/modules/mesh-delivery-checker/internal/check/examiner_test.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -272,8 +273,36 @@ func TestACleanMergeIsFetchedAtItsTrunkAndBothLevelsPass(t *testing.T) { // 1679 t.Errorf("%s was not kept", k) } } + + // ADR 0567 §12: every check ask carries every node's name for the names check; a check whose names cannot be read, or + // that reads none, is not asked and fails saying so. + for _, c := range []struct { + names []string + err error + fails string + }{{[]string{"anchor", "laptop"}, nil, ""}, {nil, nil, "no node record"}, {nil, errors.New("no bucket"), "no bucket"}} { + b := newBench(t) + b.ex.Nodes = namesOf{c.names, c.err} + out := b.judge(t, caseOf(t, pullOf(repoA, 7, head1))) + asks := b.asks.Asks() + if c.fails != "" { + if out.Verdict.State != delivery.VerdictFail || len(asks) != 0 || !strings.Contains(out.Verdict.Failed.Words, c.fails) { + t.Errorf("names %v, %v: verdict %+v, %d asks", c.names, c.err, out.Verdict, len(asks)) + } + } else if len(asks) == 0 || strings.Join(asks[0].Check.NodeNames, ",") != "anchor,laptop" { + t.Errorf("the check ask carries the names %v", asks) + } + } } +// namesOf is the nodes' names as a test gives them. +type namesOf struct { + names []string + err error +} + +func (n namesOf) Names(context.Context) ([]string, error) { return n.names, n.err } + func TestOnlyThePackageWithoutAKeptVerdictRuns(t *testing.T) { // 1288/AC1 b := newBench(t) b.digest[delivery.LevelUnit] = Digested{Level: "unit", Packages: map[string]string{"fixture/slow": digA, "fixture/other": digC}} diff --git a/modules/mesh-delivery-checker/internal/check/judge.go b/modules/mesh-delivery-checker/internal/check/judge.go index b6a1ed8..c0fa364 100644 --- a/modules/mesh-delivery-checker/internal/check/judge.go +++ b/modules/mesh-delivery-checker/internal/check/judge.go @@ -110,8 +110,14 @@ type Examiner struct { // waiting until the assigner wrote a verdict that does not wait, or ctx ends (ADR 0528 clause 5, issue 1297); nil // leaves the composition not run. Compositions CompositionReader - Now func() time.Time - Logf func(format string, args ...any) + // Nodes names every node with a record, read from each `.node` of the node-engine's have (novox/hq ADR 0567 + // §12): every check ask carries them (CheckJob.NodeNames) for the names check, so no repository lists them. A check + // whose names cannot be read, or that reads none, is not asked and fails saying so; nil hands no names. + Nodes interface { + Names(ctx context.Context) ([]string, error) + } + Now func() time.Time + Logf func(format string, args ...any) } // CompositionReader answers the assigner's composition of a check by the check's key, waiting until the assigner wrote @@ -403,6 +409,16 @@ func (st *state) askNeeding(ctx context.Context, t *target, merged *[]Fetched, l p := t.pull placed := len(needs) > 0 job := &delivery.CheckJob{Fetch: t.fetchArgs, Levels: levels, Kept: true, Packages: packages} + if st.j.Nodes != nil { + names, err := st.j.Nodes.Names(ctx) + if err == nil && len(names) == 0 { + err = fmt.Errorf("no node record is on the bus") + } + if err != nil { + return fail(t.module, fmt.Sprintf("the check of %s#%d is not asked: the nodes' names the names check needs cannot be read: %v", p.Repository, p.Number, err)) + } + job.NodeNames = names + } if t.runner != "" { job.Runner, job.RunnerWhen = t.runner, buildGoDir } diff --git a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/call.go b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/call.go index 51fe90a..9310a73 100644 --- a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/call.go +++ b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/call.go @@ -91,28 +91,6 @@ func treesVia(ctx context.Context, call caller) bridge.Trees { } } -// listedVia asks the forge for the files directly under one directory of a commit through call: gitea_commit_trees with -// listed (issue 1126), answer {listed: {directory: {name: blob id}}}. -func listedVia(ctx context.Context, call caller) bridge.Listed { - return func(owner, repo, sha, dir string) (map[string]string, error) { - raw, err := call(ctx, commitTreesTool, map[string]any{"owner": owner, "repo": repo, "sha": sha, "listed": []string{dir}}) - if err != nil { - return nil, err - } - var a struct { - Listed map[string]map[string]string `json:"listed"` - } - if err := json.Unmarshal(raw, &a); err != nil { - return nil, fmt.Errorf("the answer of %s is not {listed}: %w", commitTreesTool, err) - } - if a.Listed == nil { - // A forge module from before listed answers none: placement cannot be read, never read as empty. - return nil, fmt.Errorf("the answer of %s lists no directory; the forge module does not list yet", commitTreesTool) - } - return a.Listed[dir], nil - } -} - // getFileTool is the forge's tool for one file at a ref (mesh-catalog modules/gitea tools/index.ts gitea_get_file): // arguments owner, repo, path and ref, answer {file: {path, ref, sha, size, content}}, the content decoded to text // (client.ts getFile). A path the forge does not have fails with `Gitea API : 404 ` (client.ts request). diff --git a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/main.go b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/main.go index df27efa..deb2a42 100644 --- a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/main.go +++ b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/main.go @@ -67,8 +67,8 @@ type publisher interface { // handle turns one event into its message on FORGE and acknowledges it. An event it cannot read is terminated, never // redelivered, and the log names its subject and why. A message it could not write stays unacknowledged: the server // delivers it again (the server's ack wait is the only clock). -func handle(ctx context.Context, m delivered, pub publisher, trees bridge.Trees, files bridge.Files, list bridge.Listed, kept bridge.Kept, logger *log.Logger) { - msg, err := bridge.ConvertKept(m.Subject(), m.Data(), trees, files, list, kept) +func handle(ctx context.Context, m delivered, pub publisher, trees bridge.Trees, files bridge.Files, kept bridge.Kept, logger *log.Logger) { + msg, err := bridge.ConvertKept(m.Subject(), m.Data(), trees, files, kept) switch { case errors.Is(err, bridge.ErrRetry): logger.Printf("%v; heard again in %s", err, retryDelay) @@ -175,7 +175,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro // Bounded: the turn is held, and no sync or other event goes on without it. hctx, cancel := context.WithTimeout(ctx, syncDeadline) defer cancel() - handle(hctx, m, rec, treesVia(hctx, call), filesVia(hctx, call), listedVia(hctx, call), inputs, logger) + handle(hctx, m, rec, treesVia(hctx, call), filesVia(hctx, call), inputs, logger) }) }, logger.Printf) if err != nil { diff --git a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/main_test.go b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/main_test.go index 881a1ae..85df582 100644 --- a/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/main_test.go +++ b/modules/mesh-delivery-forge-bridge/cmd/mesh-delivery-forge-bridge/main_test.go @@ -81,7 +81,7 @@ func TestReadableEventIsWrittenOnForgeAndAcked(t *testing.T) { // 1235/AC2 (unit }) var logs bytes.Buffer m, pub := &fakeMsg{subject: "mesh.mod.gitea.event.pull.updated", data: body(nil)}, &fakePub{} - handle(context.Background(), m, pub, nil, someFiles, nil, nil, log.New(&logs, "", 0)) + handle(context.Background(), m, pub, nil, someFiles, nil, log.New(&logs, "", 0)) if len(pub.subjects) != 1 || pub.subjects[0] != "mesh.stream.mesh-delivery-forge-bridge.event.forge.novox.mesh-delivery.pull" { t.Fatalf("published on %v", pub.subjects) } @@ -97,7 +97,7 @@ func TestReadableEventIsWrittenOnForgeAndAcked(t *testing.T) { // 1235/AC2 (unit func TestUnreadableEventIsTerminatedAndLogged(t *testing.T) { // 1235/AC5 var logs bytes.Buffer m, pub := &fakeMsg{subject: "mesh.mod.gitea.event.pull.updated", data: body(map[string]any{"head_sha": "short"})}, &fakePub{} - handle(context.Background(), m, pub, nil, someFiles, nil, nil, log.New(&logs, "", 0)) + handle(context.Background(), m, pub, nil, someFiles, nil, log.New(&logs, "", 0)) if len(pub.subjects) != 0 { t.Errorf("a message was written: %v", pub.subjects) } @@ -112,7 +112,7 @@ func TestUnreadableEventIsTerminatedAndLogged(t *testing.T) { // 1235/AC5 func TestUnwrittenMessageIsNotAcked(t *testing.T) { // 1235 (a failed write is redelivered by the server, not retried here) var logs bytes.Buffer m, pub := &fakeMsg{subject: "mesh.mod.gitea.event.pull.updated", data: body(nil)}, &fakePub{err: errors.New("no stream")} - handle(context.Background(), m, pub, nil, someFiles, nil, nil, log.New(&logs, "", 0)) + handle(context.Background(), m, pub, nil, someFiles, nil, log.New(&logs, "", 0)) if m.acked != 0 || m.termd != 0 { t.Errorf("acked %d terminated %d, want neither", m.acked, m.termd) } @@ -134,7 +134,7 @@ func TestTreesRefusedLeavesTheEventForTenSecondsAndLogs(t *testing.T) { // 1236/ call := func(context.Context, string, any) (json.RawMessage, error) { return nil, errors.New("the tree listing of novox/mesh-delivery was cut") } - handle(context.Background(), m, pub, treesVia(context.Background(), call), filesVia(context.Background(), call), nil, nil, log.New(&logs, "", 0)) + handle(context.Background(), m, pub, treesVia(context.Background(), call), filesVia(context.Background(), call), nil, log.New(&logs, "", 0)) if len(pub.subjects) != 0 { t.Errorf("a message was written: %v", pub.subjects) } @@ -163,7 +163,7 @@ func TestMergeCallsTheToolOnceAndWritesTheMergedMessage(t *testing.T) { // 1236/ } return json.RawMessage(`{"root":"r0","trees":{"":"t0","modules/a":"t1"}}`), nil } - handle(context.Background(), m, pub, treesVia(context.Background(), call), filesVia(context.Background(), call), nil, nil, log.New(&logs, "", 0)) + handle(context.Background(), m, pub, treesVia(context.Background(), call), filesVia(context.Background(), call), nil, log.New(&logs, "", 0)) if len(keys) == 0 || keys[0] != "gitea.gitea_commit_trees" || slices.Index(keys[1:], "gitea.gitea_commit_trees") >= 0 { t.Fatalf("calls %v, want the trees once and first", keys) } diff --git a/modules/mesh-delivery-forge-bridge/go.mod b/modules/mesh-delivery-forge-bridge/go.mod index b1bc526..f7661dc 100644 --- a/modules/mesh-delivery-forge-bridge/go.mod +++ b/modules/mesh-delivery-forge-bridge/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-forge-bridge go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027193820-8ad9372b1952 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-forge-bridge/go.sum b/modules/mesh-delivery-forge-bridge/go.sum index fa4f833..6245202 100644 --- a/modules/mesh-delivery-forge-bridge/go.sum +++ b/modules/mesh-delivery-forge-bridge/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027193820-8ad9372b1952 h1:TmveT3Bija643RGWkilvkzP/h8UWlVvHFZZtQfJK5oU= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027193820-8ad9372b1952/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388 h1:ycu/sNieTdLpD3bzhZOCXdzcoqXUOxPRmqWV019REpo= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388/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-forge-bridge/internal/bridge/bridge.go b/modules/mesh-delivery-forge-bridge/internal/bridge/bridge.go index 19fd944..087279a 100644 --- a/modules/mesh-delivery-forge-bridge/internal/bridge/bridge.go +++ b/modules/mesh-delivery-forge-bridge/internal/bridge/bridge.go @@ -86,20 +86,6 @@ type pull struct { PathsTruncated bool `json:"paths_truncated"` } -// touchesPlacement says whether a pull request may change a placement file: a changed path under -// delivery.PlacementDir, or paths the forge did not say or cut (the safe direction: the files are read). -func (p pull) touchesPlacement() bool { - if p.Paths == nil || p.PathsTruncated { - return true - } - for _, path := range *p.Paths { - if strings.HasPrefix(path, delivery.PlacementDir+"/") { - return true - } - } - return false -} - func unreadable(subject, why string, args ...any) error { return fmt.Errorf("%w: %s: %s", ErrUnreadable, subject, fmt.Sprintf(why, args...)) } @@ -109,25 +95,12 @@ func unreadable(subject, why string, args ...any) error { // the trunk asks trees once and becomes a merged message; a merge into any other branch is a closed pull message, as the // pull request left its group. A forge that cannot say is ErrRetry; a merge with no module.json is ErrNoModules. func Convert(subject string, data []byte, trees Trees, files Files) (Message, error) { - return ConvertKept(subject, data, trees, files, nil, nil) + return ConvertKept(subject, data, trees, files, nil) } -// Listed asks the forge for the files directly under one directory of a commit, by name to blob id (gitea_commit_trees -// with listed); a directory the commit lacks lists none. -type Listed func(owner, repo, sha, dir string) (map[string]string, error) - -// ConvertWith is Convert; with list, a merge of the catalogue repository (delivery.CatalogueRepository) also carries every -// placement file of its merge commit (novox/hq ADR 0549 §2, issue 1126), read whole or not at all: a forge that cannot -// say is ErrRetry, as for the manifests, so the indexer never takes a part of placement for all of it. So does an -// updated pull request of the catalogue that touches placement/ (touchesPlacement), with every placement file at its -// head, which the indexer writes as placement candidates (ADR 0549 §4: the nodes it places answer it before it merges). -func ConvertWith(subject string, data []byte, trees Trees, files Files, list Listed) (Message, error) { - return ConvertKept(subject, data, trees, files, list, nil) -} - -// ConvertKept is ConvertWith; with kept, a merge reads the forge only for the module directories whose tree hash kept +// ConvertKept is Convert; with kept, a merge reads the forge only for the module directories whose tree hash kept // does not hold, and keeps what it read (kept.go). -func ConvertKept(subject string, data []byte, trees Trees, files Files, list Listed, kept Kept) (Message, error) { +func ConvertKept(subject string, data []byte, trees Trees, files Files, kept Kept) (Message, error) { var action string switch subject { case UpdatedSubject: @@ -154,7 +127,7 @@ func ConvertKept(subject string, data []byte, trees Trees, files Files, list Lis return Message{}, unreadable(subject, "no branch") } if subject == MergedSubject && p.Base == Trunk { - return merged(subject, p, trees, files, list, kept) + return merged(subject, p, trees, files, kept) } said := "" if action == delivery.PullUpdated && p.Base != "" && p.Base != Trunk { @@ -175,13 +148,6 @@ func ConvertKept(subject string, data []byte, trees Trees, files Files, list Lis return Message{}, fmt.Errorf("%w: %s: the manifests of %s/%s at the head %s: %v", ErrRetry, subject, p.Owner, p.Repo, p.HeadSHA, err) } } - if action == delivery.PullUpdated && list != nil && delivery.SameRepository(f.Repository, delivery.CatalogueRepository) && p.touchesPlacement() { - var err error - if f.Placements, err = placementsOf(p.Owner, p.Repo, p.HeadSHA, list, files); err != nil { - return Message{}, fmt.Errorf("%w: %s: the placement files of %s at the head %s: %v", ErrRetry, subject, f.Repository, p.HeadSHA, err) - } - f.PlacementsRead = true - } event, err := delivery.ForgeEvent(f.Repository, delivery.ForgePull) if err != nil { return Message{}, unreadable(subject, "%v", err) @@ -311,7 +277,7 @@ func inputsRead(owner, repo, ref string, dirs []string, files Files) (manifests } // merged is the merged message of a merge into the trunk: the trees of the merge commit, asked once. -func merged(subject string, p pull, trees Trees, files Files, list Listed, kept Kept) (Message, error) { +func merged(subject string, p pull, trees Trees, files Files, kept Kept) (Message, error) { if !sha40.MatchString(p.MergeSHA) { return Message{}, unreadable(subject, "the merge commit %q is not 40 hex digits", p.MergeSHA) } @@ -349,12 +315,6 @@ func merged(subject string, p pull, trees Trees, files Files, list Listed, kept } f := delivery.Forge{Kind: delivery.ForgeMerged, Repository: repository, Branch: p.Head, Number: p.Number, Head: p.HeadSHA, Merge: p.MergeSHA, Root: root, Trees: dirs, Manifests: manifests, Locks: locks} - if list != nil && delivery.SameRepository(repository, delivery.CatalogueRepository) { - if f.Placements, err = placementsOf(p.Owner, p.Repo, p.MergeSHA, list, files); err != nil { - return Message{}, fmt.Errorf("%w: %s: the placement files of %s at the merge commit %s: %v", ErrRetry, subject, repository, p.MergeSHA, err) - } - f.PlacementsRead = true - } body, err := delivery.EncodeForge(f) if err != nil { return Message{}, unreadable(subject, "%v", err) @@ -452,30 +412,3 @@ func OpenMessage(answer []byte) (Message, error) { } return Message{Event: delivery.OpenEvent(), Data: body}, nil } - -// placementsOf is every placement file of the catalogue at a commit, by node, its bytes exact: each `.json` directly -// under delivery.PlacementDir. A file whose name is no node is left out and the rest read; the catalogue's check refuses -// such a file before it merges (ADR 0549 §1). Nil when there is none. -func placementsOf(owner, repo, sha string, list Listed, files Files) (map[string]string, error) { - names, err := list(owner, repo, sha, delivery.PlacementDir) - if err != nil { - return nil, err - } - var out map[string]string - for _, name := range slices.Sorted(maps.Keys(names)) { - file := delivery.PlacementDir + "/" + name - node, ok := delivery.PlacementFileNode(file) - if !ok { - continue - } - raw, err := files(owner, repo, file, sha) - if err != nil { - return nil, fmt.Errorf("%s: %w", file, err) - } - if out == nil { - out = map[string]string{} - } - out[node] = string(raw) - } - return out, nil -} diff --git a/modules/mesh-delivery-forge-bridge/internal/bridge/files_test.go b/modules/mesh-delivery-forge-bridge/internal/bridge/files_test.go index 3e88564..3e5e681 100644 --- a/modules/mesh-delivery-forge-bridge/internal/bridge/files_test.go +++ b/modules/mesh-delivery-forge-bridge/internal/bridge/files_test.go @@ -79,46 +79,23 @@ func TestMergedCarriesManifestsAndLocks(t *testing.T) { // 1448/AC1 if want := map[string]map[string]string{"modules/a": {"go.sum": hexSum(sum)}}; !reflect.DeepEqual(f.Locks, want) { t.Errorf("locks = %v, want %v", f.Locks, want) } - if f.Placements != nil || f.PlacementsRead { - t.Errorf("a merge of another repository than the catalogue carries placement: %v", f.Placements) - } - - // Issue 1126 (ADR 0549 §2): a merge of the catalogue carries every placement file of its merge commit, by node; a file - // whose name is no node is left out; a listing or a file the forge cannot give is a retry, never a part. - catalogue := mergedBody(map[string]any{"repo": "mesh-catalog"}) - forge.have["placement/ace.json"] = []byte(`{"v":1,"node":"ace","modules":[]}`) - forge.have["placement/g14.json"] = []byte(`{"v":1,"node":"g14","modules":[{"module":"zsh"}]}`) - listing := map[string]string{"ace.json": "b1", "g14.json": "b2", "README.md": "b3", "a.b.json": "b4"} - var listedAt []string - list := func(owner, repo, sha, dir string) (map[string]string, error) { - listedAt = append(listedAt, owner+"/"+repo+"@"+sha+":"+dir) - return listing, nil - } - msg, err = ConvertWith(mergedSubject, catalogue, treesOf(&calls, "r0", map[string]string{"modules/a": "t1"}, nil), forge.get, list) + // ADR 0567: placement is the seat mesh-placement's: a merge of the catalogue carries no placement file and asks the + // forge for none. + forge.calls = nil + msg, err = Convert(mergedSubject, mergedBody(map[string]any{"repo": "mesh-catalog"}), treesOf(&calls, "r0", map[string]string{"modules/a": "t1"}, nil), forge.get) if err != nil { t.Fatal(err) } if f, err = delivery.DecodeForge(msg.Data); err != nil { t.Fatal(err) } - want := map[string]string{"ace": `{"v":1,"node":"ace","modules":[]}`, "g14": `{"v":1,"node":"g14","modules":[{"module":"zsh"}]}`} - if !f.PlacementsRead || !reflect.DeepEqual(f.Placements, want) || !reflect.DeepEqual(listedAt, []string{"novox/mesh-catalog@" + merge + ":placement"}) { - t.Errorf("placements = %v (read %v), listed at %v", f.Placements, f.PlacementsRead, listedAt) + if len(f.RetiredPlacements) > 0 || f.RetiredRead || strings.Contains(string(msg.Data), `"placements`) { + t.Errorf("a merge of the catalogue carries placement: %s", msg.Data) } - listing = map[string]string{} - if msg, err = ConvertWith(mergedSubject, catalogue, treesOf(&calls, "r0", map[string]string{"modules/a": "t1"}, nil), forge.get, list); err != nil { - t.Fatal(err) - } - if f, _ = delivery.DecodeForge(msg.Data); !f.PlacementsRead || f.Placements != nil { - t.Errorf("a catalogue with no placement file says it read none: %v, %v", f.Placements, f.PlacementsRead) - } - listing = map[string]string{"gone.json": "b5"} - if _, err := ConvertWith(mergedSubject, catalogue, treesOf(&calls, "r0", map[string]string{"modules/a": "t1"}, nil), forge.get, list); !errors.Is(err, ErrRetry) || !strings.Contains(err.Error(), "placement/gone.json") { - t.Errorf("a placement file the forge cannot give: %v", err) - } - failing := func(_, _, _, _ string) (map[string]string, error) { return nil, errors.New("forge down") } - if _, err := ConvertWith(mergedSubject, catalogue, treesOf(&calls, "r0", map[string]string{"modules/a": "t1"}, nil), forge.get, failing); !errors.Is(err, ErrRetry) { - t.Errorf("a listing the forge cannot give: %v", err) + for _, c := range forge.calls { + if strings.HasPrefix(c.path, "placement") { + t.Errorf("the forge was asked for %s", c.path) + } } // Issue 1126: a merge reads the forge only for the module directories whose tree changed. t.Run("a merge reads only the directories whose tree changed", mergeReadsOnlyChangedDirs) @@ -148,55 +125,6 @@ func TestPullWithKnownDirsCarriesTheSameAtTheHead(t *testing.T) { // 1448/AC2 if f.Manifests != nil || f.Locks != nil || len(forge.calls) != 0 { t.Errorf("manifests %v locks %v calls %v", f.Manifests, f.Locks, forge.calls) } - - // Issue 1126 (ADR 0549 §4): an updated pull request of the catalogue that touches placement/ carries every placement - // file at its head, read whole or not at all; one that touches none, or of another repository, carries none and lists - // nothing; paths the forge did not say or cut are read as touching; a closed one carries none. - forge = &forgeFiles{have: map[string][]byte{"modules/a/module.json": manifest, - "placement/ace.json": []byte(`{"v":1,"node":"ace","modules":[]}`), "placement/g14.json": []byte(`{"v":1,"node":"g14","modules":[]}`)}} - listing := map[string]string{"ace.json": "b1", "g14.json": "b2", "README.md": "b3"} - var listedAt []string - list := func(owner, repo, sha, dir string) (map[string]string, error) { - listedAt = append(listedAt, owner+"/"+repo+"@"+sha+":"+dir) - return listing, nil - } - all := map[string]string{"ace": `{"v":1,"node":"ace","modules":[]}`, "g14": `{"v":1,"node":"g14","modules":[]}`} - catalogue := map[string]any{"repo": "mesh-catalog", "paths": []string{"placement/g14.json"}} - for _, row := range []struct { - name string - subject string - body map[string]any - want map[string]string - read bool - }{ - {"a catalogue pull touching placement", updatedSubject, catalogue, all, true}, - {"a catalogue pull touching no placement", updatedSubject, map[string]any{"repo": "mesh-catalog"}, nil, false}, - {"a catalogue pull whose paths are cut", updatedSubject, map[string]any{"repo": "mesh-catalog", "paths_truncated": true}, all, true}, - {"a catalogue pull whose paths are not said", updatedSubject, map[string]any{"repo": "mesh-catalog", "paths": nil}, all, true}, - {"another repository's pull touching placement/", updatedSubject, map[string]any{"paths": []string{"placement/g14.json"}}, nil, false}, - {"a closed catalogue pull", closedSubject, catalogue, nil, false}, - } { - listedAt = nil - body := updated(row.body) - msg, err := ConvertWith(row.subject, body, nil, forge.get, list) - if err != nil { - t.Fatalf("%s: %v", row.name, err) - } - f, err := delivery.DecodeForge(msg.Data) - if err != nil { - t.Fatalf("%s: %v", row.name, err) - } - if f.PlacementsRead != row.read || !reflect.DeepEqual(f.Placements, row.want) || (len(listedAt) > 0) != row.read { - t.Errorf("%s: placements %v (read %v), listed %v", row.name, f.Placements, f.PlacementsRead, listedAt) - } - if row.read && listedAt[0] != "novox/mesh-catalog@"+head+":placement" { - t.Errorf("%s: listed at %v, want the head", row.name, listedAt) - } - } - listing = map[string]string{"gone.json": "b5"} - if _, err := ConvertWith(updatedSubject, updated(catalogue), nil, forge.get, list); !errors.Is(err, ErrRetry) || !strings.Contains(err.Error(), "placement/gone.json") { - t.Errorf("a placement file the forge cannot give at the head: %v", err) - } } func TestAForgeErrorOrAMissingManifestIsRetryAndALockNotFoundIsLeftOut(t *testing.T) { // 1448/AC3 @@ -385,7 +313,7 @@ func mergeReadsOnlyChangedDirs(t *testing.T) { forge := &forgeFiles{have: map[string][]byte{"modules/a/module.json": manifest, "modules/a/go.sum": sum, "modules/b/module.json": []byte(`{"module":"b"}`)}} kept := memKept{} var calls []call - first, err := ConvertKept(mergedSubject, mergedBody(nil), treesOf(&calls, "r0", map[string]string{"modules/a": "t1", "modules/b": "t2"}, nil), forge.get, nil, kept) + first, err := ConvertKept(mergedSubject, mergedBody(nil), treesOf(&calls, "r0", map[string]string{"modules/a": "t1", "modules/b": "t2"}, nil), forge.get, kept) if err != nil { t.Fatal(err) } @@ -396,7 +324,7 @@ func mergeReadsOnlyChangedDirs(t *testing.T) { forge.calls = nil forge.have["modules/b/module.json"] = []byte(`{"module":"b","v":2}`) trees := map[string]string{"modules/a": "t1", "modules/b": "t3"} - next, err := ConvertKept(mergedSubject, mergedBody(nil), treesOf(&calls, "r1", trees, nil), forge.get, nil, kept) + next, err := ConvertKept(mergedSubject, mergedBody(nil), treesOf(&calls, "r1", trees, nil), forge.get, kept) if err != nil { t.Fatal(err) } @@ -422,7 +350,7 @@ func mergeReadsOnlyChangedDirs(t *testing.T) { // rest: a merge too large for one try's bound converges over its tries. kept2 := memKept{} failing := &forgeFiles{have: forge.have, errs: map[string]error{"modules/b/module.json": errors.New("context deadline exceeded")}} - if _, err := ConvertKept(mergedSubject, mergedBody(nil), treesOf(&calls, "r1", trees, nil), failing.get, nil, kept2); !errors.Is(err, ErrRetry) { + if _, err := ConvertKept(mergedSubject, mergedBody(nil), treesOf(&calls, "r1", trees, nil), failing.get, kept2); !errors.Is(err, ErrRetry) { t.Fatalf("err = %v, want ErrRetry", err) } if _, ok := kept2["t1"]; !ok || len(kept2) != 1 { // modules/a's files are asked first, before the failure is seen @@ -430,7 +358,7 @@ func mergeReadsOnlyChangedDirs(t *testing.T) { } failing.calls = nil failing.errs = nil - again, err := ConvertKept(mergedSubject, mergedBody(nil), treesOf(&calls, "r1", trees, nil), failing.get, nil, kept2) + again, err := ConvertKept(mergedSubject, mergedBody(nil), treesOf(&calls, "r1", trees, nil), failing.get, kept2) if err != nil { t.Fatal(err) } diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/main.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/main.go index 6904086..ff97616 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/main.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/main.go @@ -49,25 +49,6 @@ func Tools(ask asker, now func() time.Time) []stdio.Tool { } return a.Result, nil }}, - {Name: index.PlacementRereadTool, - Description: "Read the catalogue's placement files again now (novox/hq ADR 0559 §8): the indexer asks the forge for the trunk's head and " + - "every open catalogue pull request's, and writes its states placement (placement.) and placement-candidates (.) " + - "as a merge and a pull request's head would, only where a key differs; a candidate key whose file no longer differs, or whose pull " + - "request is closed, is deleted. The recovery for a forge message the indexer missed. Answers the heads read, the keys written or " + - "deleted in each state, or why nothing was (the forge could not say). indexer_placement (mesh-delivery-view) reads the result.", - Input: map[string]any{"type": "object", "properties": map[string]any{ - "why": map[string]any{"type": "string", "description": "why it is read again now, in plain words"}}}, - Run: func(args map[string]any) (any, error) { - key, value, err := index.NewPlacementReread(str(args, "why"), stdio.CallerOf(args), now()) - if err != nil { - return nil, err - } - a, err := ask(key, value) - if err != nil { - return nil, err - } - return a.Reread, nil - }}, } } diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main.go index 9de33a4..aa732ef 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main.go @@ -273,12 +273,6 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro // Its taken state (ADR 0556 clause 1) is bound when it appears, as every state a delivery adds (issue 1790): until then // a merge's taken key is said and not written, and the rest of the indexer runs. taken := process.AwaitBucket(ctx, js, conn, delivery.TakenBucket, "no repository's taken key is written", logger.Printf) - // Its placement state (ADR 0549 §2) is bound when it appears, as every state a delivery adds (issue 1790): until then a - // catalogue merge's placement is said and not written, and the rest of the indexer runs. - placement := process.AwaitBucket(ctx, js, conn, delivery.PlacementBucket, "no catalogue merge's placement is written", logger.Printf) - // Its placement candidates (issue 1126, ADR 0549 §4) are bound the same way: until the state appears a catalogue pull - // request's placement is said and not written, so no node answers it and its check names every placed node as silent. - placementCandidates := process.AwaitBucket(ctx, js, conn, delivery.PlacementCandidatesBucket, "no catalogue pull request's placement candidate is written", logger.Printf) // The controller's build records only name the directory of a module whose key names none (fillPaths): without them // such a module waits for its path, every other is indexed (issue 1945). builds := process.OptionalBucket(ctx, js, delivery.BuildsBucket, "a module whose key names no directory is not given one from the controller's build records until the indexer starts again", logger.Printf) @@ -310,7 +304,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro defer calls.Close() ix := &index.Indexer{Store: kvStore{candidates}, Modules: kvStore{modules}, Manifests: kvStore{manifests}, Taken: awaitedStore{taken}, Builds: known, Logf: logger.Printf, Named: namedVia(calls.Call), Trunk: trunkVia(calls.Call), Contains: containsVia(calls.Call), Read: readVia(calls.Call), - Placement: awaitedStore{placement}, PlacementCandidates: awaitedStore{placementCandidates}, ReadPlacement: placementVia(calls.Call), ReadOpen: openVia(calls.Call), Say: func(event string, body any) error { return calls.Emit(event, body) }} + ReadOpen: openVia(calls.Call)} // The gate's source bucket (mesh-delivery-gate_source) comes with the gate (release 3e, novox/hq ADR 0450 §1): its key // main is the mesh's source revision, which stamps the modules (issue 1344). Where it does not exist yet the indexer does // not wait for it (that needs a timer, ADR 0437 §8): it says so, and is started again with the gate. diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/named.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/named.go index 55a9034..631624f 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/named.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/named.go @@ -171,12 +171,10 @@ func readVia(call toolCaller) func(ctx context.Context, repository, sha, dir str } } -// The forge's tools the placement reread asks (indexer_placement_reread, novox/hq ADR 0559 §8): a repository's branches -// with their commits, and every open pull request with its branch and head. +// The forge's tool for the open pull requests, and the trunk branch they are open into. const ( - listBranchesTool = "gitea.gitea_list_branches" - openPullsTool = "gitea.gitea_open_pulls" - placementTrunk = "main" + openPullsTool = "gitea.gitea_open_pulls" + trunkBranch = "main" ) // openVia asks the forge which pull requests into the trunk are open now (gitea_open_pulls), every repository's. @@ -199,109 +197,10 @@ func openVia(call toolCaller) func(ctx context.Context) ([]delivery.OpenPull, er } out := []delivery.OpenPull{} for _, p := range *o.Pulls { - if p.Base == placementTrunk && p.Number > 0 { + if p.Base == trunkBranch && p.Number > 0 { out = append(out, delivery.OpenPull{Repository: p.Owner + "/" + p.Repo, Number: p.Number}) } } return out, nil } } - -// placementVia reads the catalogue's placement now through call: the trunk's head (gitea_list_branches), every placement -// file at it, and each open pull request of the catalogue into the trunk (gitea_open_pulls) with every placement file at -// its head, each read whole or not at all (gitea_commit_trees with listed, gitea_get_file). -func placementVia(call toolCaller) func(ctx context.Context) (index.PlacementRead, error) { - owner, repo, _ := strings.Cut(delivery.CatalogueRepository, "/") - filesAt := func(sha string) (map[string]string, error) { - raw, err := call(commitTreesTool, map[string]any{"owner": owner, "repo": repo, "sha": sha, "listed": []string{delivery.PlacementDir}}) - if err != nil { - return nil, err - } - var a struct { - Listed map[string]map[string]string `json:"listed"` - } - if err := json.Unmarshal(raw, &a); err != nil || a.Listed == nil { - return nil, fmt.Errorf("the answer of %s lists no directory: %v", commitTreesTool, err) - } - var out map[string]string - for name := range a.Listed[delivery.PlacementDir] { - file := delivery.PlacementDir + "/" + name - node, ok := delivery.PlacementFileNode(file) - if !ok { - continue - } - raw, err := call(getFileTool, map[string]string{"owner": owner, "repo": repo, "path": file, "ref": sha}) - if err != nil { - return nil, fmt.Errorf("%s at %s: %w", file, sha, err) - } - var f struct { - File *struct { - Content *string `json:"content"` - } `json:"file"` - } - if err := json.Unmarshal(raw, &f); err != nil || f.File == nil || f.File.Content == nil { - return nil, fmt.Errorf("the answer of %s for %s is not {file: {content}}: %v", getFileTool, file, err) - } - if out == nil { - out = map[string]string{} - } - out[node] = *f.File.Content - } - return out, nil - } - return func(context.Context) (index.PlacementRead, error) { - raw, err := call(listBranchesTool, map[string]string{"owner": owner, "repo": repo}) - if err != nil { - return index.PlacementRead{}, err - } - var b struct { - Branches []struct { - Name string `json:"name"` - Commit string `json:"commit"` - } `json:"branches"` - } - if err := json.Unmarshal(raw, &b); err != nil { - return index.PlacementRead{}, fmt.Errorf("the answer of %s is not {branches}: %w", listBranchesTool, err) - } - var out index.PlacementRead - for _, br := range b.Branches { - if br.Name == placementTrunk { - out.Trunk = br.Commit - } - } - if out.Trunk == "" { - return index.PlacementRead{}, fmt.Errorf("%s names no branch %s of %s", listBranchesTool, placementTrunk, delivery.CatalogueRepository) - } - if out.Files, err = filesAt(out.Trunk); err != nil { - return index.PlacementRead{}, err - } - raw, err = call(openPullsTool, map[string]any{}) - if err != nil { - return index.PlacementRead{}, err - } - var o struct { - Pulls *[]struct { - Owner string `json:"owner"` - Repo string `json:"repo"` - Number int `json:"number"` - Base string `json:"base"` - Head string `json:"head"` - HeadSHA string `json:"head_sha"` - } `json:"pulls"` - } - if err := json.Unmarshal(raw, &o); err != nil || o.Pulls == nil { - return index.PlacementRead{}, fmt.Errorf("the answer of %s is not {pulls}: %v", openPullsTool, err) - } - for _, p := range *o.Pulls { - if !delivery.SameRepository(p.Owner+"/"+p.Repo, delivery.CatalogueRepository) || p.Base != placementTrunk || p.Number <= 0 || p.Head == "" || p.HeadSHA == "" { - continue - } - files, err := filesAt(p.HeadSHA) - if err != nil { - return index.PlacementRead{}, fmt.Errorf("%s#%d: %w", delivery.CatalogueRepository, p.Number, err) - } - out.Pulls = append(out.Pulls, index.PullPlacement{Branch: p.Head, Number: p.Number, Head: p.HeadSHA, Files: files}) - } - return out, nil - } -} diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/takein.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/takein.go index 13d4fbb..f0c7b02 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/takein.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/takein.go @@ -14,7 +14,6 @@ import ( // takerIn is the part of the indexer a takeins value asks through. type takerIn interface { TakeIn(ctx context.Context, module string) index.TakenIn - RereadPlacement(ctx context.Context) index.PlacementReread } // takenIn answers one value of the takeins state (the tool indexer_take_in, issue 1945): a value not yet answered takes the @@ -32,15 +31,9 @@ func takenIn(ctx context.Context, ix takerIn, kv jetstream.KeyValue, e jetstream if !a.Answered.IsZero() { return } - if a.Placement { - r := ix.RereadPlacement(ctx) - a.Reread, a.Answered = &r, time.Now().UTC() - logf("a placement reread asked by %s (%s): %s", a.By, a.Why, r.Answer) - } else { - r := ix.TakeIn(ctx, a.Module) - a.Result, a.Answered = &r, time.Now().UTC() - logf("%s: a take-in asked by %s (%s): %s", a.Module, a.By, a.Why, r.Answer) - } + r := ix.TakeIn(ctx, a.Module) + a.Result, a.Answered = &r, time.Now().UTC() + logf("%s: a take-in asked by %s (%s): %s", a.Module, a.By, a.Why, r.Answer) data, err := json.Marshal(a) if err != nil { return diff --git a/modules/mesh-delivery-indexer/go.mod b/modules/mesh-delivery-indexer/go.mod index 554ba2b..6523675 100644 --- a/modules/mesh-delivery-indexer/go.mod +++ b/modules/mesh-delivery-indexer/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-indexer go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028090029-3028c7ec4ed2 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-indexer/go.sum b/modules/mesh-delivery-indexer/go.sum index a460d12..6245202 100644 --- a/modules/mesh-delivery-indexer/go.sum +++ b/modules/mesh-delivery-indexer/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028090029-3028c7ec4ed2 h1:eSwObLdxs3jxPrlcwdaMo9hzIDTUz2ogWw8cjX8To7I= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028090029-3028c7ec4ed2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388 h1:ycu/sNieTdLpD3bzhZOCXdzcoqXUOxPRmqWV019REpo= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388/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-indexer/internal/index/candidates.go b/modules/mesh-delivery-indexer/internal/index/candidates.go index 7f3bbc6..ebb36fe 100644 --- a/modules/mesh-delivery-indexer/internal/index/candidates.go +++ b/modules/mesh-delivery-indexer/internal/index/candidates.go @@ -65,20 +65,10 @@ type Indexer struct { // Contains says whether a commit is the second or an ancestor of it (gitea.gitea_contains). Contains func(ctx context.Context, repository, commit, in string) (bool, error) Logf func(format string, args ...any) - // Placement is the placement bucket (delivery.PlacementBucket, ADR 0549 §2); nil writes no placement. - Placement Store - // PlacementCandidates is the placement candidates bucket (delivery.PlacementCandidatesBucket, issue 1126); nil writes - // no candidate (placement_candidates.go). - PlacementCandidates Store - // ReadPlacement asks the forge for the catalogue's placement now, at the trunk's head and each open pull request's - // (indexer_placement_reread, ADR 0559 §8); nil rereads none. - ReadPlacement func(ctx context.Context) (PlacementRead, error) // ReadOpen asks the forge which pull requests into the trunk are open now (gitea_open_pulls). A catalogue pull message // that finds the candidates of another pull request asks it and deletes those of a pull request no longer open, so a // closed message the indexer missed heals at the next one (issue 1126); nil asks none. ReadOpen func(ctx context.Context) ([]delivery.OpenPull, error) - // Say says one of the indexer's events (placement-changed); nil says none. - Say func(event string, body any) error mu sync.Mutex // guards source, toolchains and previews, which the watches and the forge messages all reach source sourceNow @@ -134,14 +124,6 @@ func (ix *Indexer) ApplyMessage(ctx context.Context, f delivery.Forge) (Applied, } ix.kickRetry(ctx) // a registered module the forge could not read is read again on a merge, in the background (issue 1945) } - if applied.Late == "" { // a late merged message would write an older placement back - if err := ix.applyPlacement(ctx, f); err != nil { - return applied, err - } - } - if err := ix.applyPlacementCandidates(ctx, f); err != nil { - return applied, err - } var digests map[string]string var held map[string]delivery.Module if f.Kind == delivery.ForgePull { @@ -196,14 +178,6 @@ func (ix *Indexer) applyOpen(ctx context.Context, f delivery.Forge) error { for _, p := range f.Open { named[p] = true } - if ix.PlacementCandidates != nil { - // A pull request the forge no longer has open keeps no placement candidate (issue 1126). - if err := ix.dropPlacementCandidates(ctx, func(_ string, c delivery.PlacementCandidate) bool { - return !named[delivery.OpenPull{Repository: c.Pull.Repository, Number: c.Pull.Number}] - }, "its pull request is not open"); err != nil { - return err - } - } keys, err := ix.Store.Keys(ctx) if err != nil { return fmt.Errorf("listing the candidates: %w", err) diff --git a/modules/mesh-delivery-indexer/internal/index/manifests_test.go b/modules/mesh-delivery-indexer/internal/index/manifests_test.go index 3cdd5f3..08455bb 100644 --- a/modules/mesh-delivery-indexer/internal/index/manifests_test.go +++ b/modules/mesh-delivery-indexer/internal/index/manifests_test.go @@ -43,8 +43,6 @@ func withManifests(f delivery.Forge, manifests map[string]string, locks map[stri } func TestAMergedManifestIsKeptByItsDigestAndNamedByTheModule(t *testing.T) { // 1449/AC1 - // A merge's placement files are written as its manifests are (issue 1126): folded here to hold the count. - placementIsWrittenAtAMergeOnlyWhereItChanged(t) avahi := catalogueManifest(t, "avahi") ix, mods, manifests := manifestsIndexer(t, rec(t, "build-1", "avahi", catalogueRepo, "modules/avahi", "main", "", "2026-10-19T09:00:00Z", aInputs)) f := withManifests(mergedMsg(catalogueRepo, map[string]string{"modules/avahi": "newtree"}), map[string]string{"modules/avahi": avahi}, nil) diff --git a/modules/mesh-delivery-indexer/internal/index/order.go b/modules/mesh-delivery-indexer/internal/index/order.go index c09cc5e..5aec9fe 100644 --- a/modules/mesh-delivery-indexer/internal/index/order.go +++ b/modules/mesh-delivery-indexer/internal/index/order.go @@ -21,8 +21,8 @@ import ( // that cannot say for another reason (a timeout) writes nothing and is an error: the server delivers the message again. // // - A merged message asks once, before it writes anything: is its merge older than the repository's taken merge (the -// last merge applied whole, taken.go)? Then the whole message is late: no module is written or deleted, no placement -// is written, the taken key stays, and the log says so. +// last merge applied whole, taken.go)? Then the whole message is late: no module is written or deleted, the taken key +// stays, and the log says so. // - A take-in of a held module asks once for that module: is the source's head older than the module's record // (takeInHeld)? @@ -92,7 +92,7 @@ func (ix *Indexer) lateMerge(ctx context.Context, w *mergeWrite) error { w.asked = cur.Merge if old { w.late = cur.Merge - ix.logf("%s at %.12s: the merged message is older than taken.%s at %.12s, so no module is written or deleted and no placement written (a late or repeated message; a merge never moves a module backwards, issue 1126)", + ix.logf("%s at %.12s: the merged message is older than taken.%s at %.12s, so no module is written or deleted (a late or repeated message; a merge never moves a module backwards, issue 1126)", w.repository, w.merge, key, cur.Merge) } return nil diff --git a/modules/mesh-delivery-indexer/internal/index/placement.go b/modules/mesh-delivery-indexer/internal/index/placement.go deleted file mode 100644 index d611e68..0000000 --- a/modules/mesh-delivery-indexer/internal/index/placement.go +++ /dev/null @@ -1,123 +0,0 @@ -package index - -import ( - "context" - "errors" - "fmt" - "maps" - "slices" - - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/process" -) - -// Placement of modules on nodes (novox/hq ADR 0549 §2, issue 1126): at each merge of the catalogue the forge bridge -// carries every placement file of the merge commit, and the indexer writes each node's key `placement.` in its -// state `placement` only when it differs (the revision alone is no change), deletes the key of a node whose file is -// gone, and says `placement-changed` for each node it wrote or deleted, naming the modules added, removed or changed. -// A merge that changes no placement writes nothing and says nothing. -// -// A file that does not read as a placement, or names another node than its file, is said in the log and its key is left -// as it was: the catalogue's own check refuses such a file before it merges (ADR 0549 §1), so it is a fault of that -// check, and a node keeps composing from the placement it had rather than from none. - -// applyPlacement takes the placement files of a merged message in; a message that read none (another repository, an -// older bridge) changes nothing. -func (ix *Indexer) applyPlacement(ctx context.Context, f delivery.Forge) error { - if ix.Placement == nil || f.Kind != delivery.ForgeMerged || !f.PlacementsRead { - return nil - } - keys, err := ix.Placement.Keys(ctx) - if errors.Is(err, process.ErrNotYet) { - // The state is not on the bus yet: said, and the merge's candidates are still taken out (issue 1790's binding - // writes the next catalogue merge's placement once it appears). - ix.logf("%s at %s: its placement files are not written: %v", f.Repository, f.Merge, err) - return nil - } - if err != nil { - return fmt.Errorf("reading the placement keys: %w", err) - } - gone := map[string]bool{} - for _, key := range keys { - if node, err := delivery.ParsePlacementKey(key); err == nil { - gone[node] = true - } - } - var refused []string - for _, node := range slices.Sorted(maps.Keys(f.Placements)) { - delete(gone, node) - next, err := delivery.DecodePlacement([]byte(f.Placements[node])) - if err == nil && next.Node != node { - err = fmt.Errorf("the file of %s names the node %q", node, next.Node) - } - if err != nil { - refused = append(refused, node) - ix.logf("%s at %s: the placement file of %s is refused and placement.%s is left as it was (the catalogue's check should have refused it): %v", - f.Repository, f.Merge, node, node, err) - continue - } - next.Revision = f.Merge - if err := ix.writePlacement(ctx, node, &next); err != nil { - return err - } - } - for _, node := range slices.Sorted(maps.Keys(gone)) { - if err := ix.writePlacement(ctx, node, nil); err != nil { - return err - } - } - if len(refused) > 0 { - // Said, and the merge's candidates are still taken out: a refused file holds nobody's delivery. - ix.logf("%s at %s: the placement files of %v do not read; their keys are left as they were", f.Repository, f.Merge, refused) - } - return nil -} - -// writePlacement writes one node's key, or deletes it for next nil, only when it differs, and says what changed. -func (ix *Indexer) writePlacement(ctx context.Context, node string, next *delivery.Placement) error { - key, err := delivery.PlacementKey(node) - if err != nil { - return err - } - var change *delivery.PlacementChange - err = write(ctx, ix.Placement, key, func(value []byte, found bool) ([]byte, bool, error) { - change = nil - var before delivery.Placement - if found { - cur, err := delivery.DecodePlacement(value) - if err != nil { - ix.logf("placement.%s holds what is not a placement, written again: %v", node, err) - } else { - before = cur - } - } - if next == nil { - if !found { - return nil, false, nil - } - c := delivery.ChangeOf(before, delivery.Placement{Node: node}) - change = &c - return nil, true, nil - } - if found && delivery.SamePlacement(before, *next) { - return nil, false, nil - } - data, err := delivery.EncodePlacement(*next) - if err != nil { - return nil, false, err - } - c := delivery.ChangeOf(before, *next) - change = &c - return data, false, nil - }) - if err != nil || change == nil { - return err - } - if ix.Say != nil { - if err := ix.Say(delivery.PlacementChangedEvent, *change); err != nil { - // The key is written: a reader that watches it hears the change; the event is said loudly as not said. - ix.logf("placement.%s is written, and %s could not be said: %v", node, delivery.PlacementChangedEvent, err) - } - } - return nil -} diff --git a/modules/mesh-delivery-indexer/internal/index/placement_candidates.go b/modules/mesh-delivery-indexer/internal/index/placement_candidates.go deleted file mode 100644 index a9cf29f..0000000 --- a/modules/mesh-delivery-indexer/internal/index/placement_candidates.go +++ /dev/null @@ -1,352 +0,0 @@ -package index - -import ( - "context" - "errors" - "fmt" - "maps" - "slices" - - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/process" -) - -// Placement candidates (issue 1126; novox/hq ADR 0549 §4: a placement change passes the composition check before any -// node sees it). An updated pull request of the catalogue that touches placement/ carries every placement file at its -// head (the forge bridge, PlacementsRead). For each node whose file at the head differs from the trunk's key -// `placement.` (SamePlacement, the revision left out), or whose file the head removes, the indexer writes the key -// `.` of its state `placement-candidates`; a node whose file is the trunk's has none, and a key the head no -// longer needs is deleted. The key is written only when its value changes, so its write is the node's event to compose -// in shadow and answer. When the pull request closes or merges, or an open message no longer names it, its keys are -// deleted, and so are they when a head the forge says the changes of (DirsKnown) carries no placement: it touches none. -// A pull message that says nothing of its head (a resend of the open pull requests) leaves them as they are. -// -// A file at the head that does not read as a placement, or names another node, is said in the log and that node gets no -// key (one it had is deleted): the catalogue's own check refuses the pull request (ADR 0549 §1), and a node answers no -// candidate that is not one. - -// applyPlacementCandidates takes one forge message into the placement candidates. -func (ix *Indexer) applyPlacementCandidates(ctx context.Context, f delivery.Forge) error { - if ix.PlacementCandidates == nil || !delivery.SameRepository(f.Repository, delivery.CatalogueRepository) || trunkLike[f.Branch] { - return nil - } - if f.Kind == delivery.ForgeMerged || f.Action == delivery.PullClosed { - return ix.dropPlacementCandidates(ctx, func(_ string, c delivery.PlacementCandidate) bool { - return c.Group == f.Branch && delivery.SameRepository(c.Pull.Repository, f.Repository) && c.Pull.Number == f.Number - }, fmt.Sprintf("%s#%d %s", f.Repository, f.Number, map[bool]string{true: "merged", false: "closed"}[f.Kind == delivery.ForgeMerged])) - } - if f.Kind != delivery.ForgePull { - return nil - } - if !f.PlacementsRead { - if f.DirsKnown { - // The forge said what this head changes (its directories and paths) and the bridge read no placement: the head - // changes no placement file, so a key an earlier head left goes (ADR 0559 §3). - return ix.dropPlacementCandidates(ctx, func(_ string, c delivery.PlacementCandidate) bool { - return c.Group == f.Branch && delivery.SameRepository(c.Pull.Repository, f.Repository) && c.Pull.Number == f.Number - }, fmt.Sprintf("%s#%d at %s changes no placement file", f.Repository, f.Number, f.Head)) - } - return nil - } - ix.dropCandidatesOfClosedPulls(ctx, f) - trunk, err := ix.trunkPlacements(ctx) - if err != nil { - ix.logf("%s#%d at %s: its placement candidates are not written: %v", f.Repository, f.Number, f.Head, err) - return nil - } - nodes := map[string]bool{} - for node := range f.Placements { - nodes[node] = true - } - for node := range trunk { - nodes[node] = true - } - pull := delivery.Head{Repository: f.Repository, Number: f.Number, Head: f.Head} - for _, node := range slices.Sorted(maps.Keys(nodes)) { - key, err := delivery.PlacementCandidateKey(f.Branch, node) - if err != nil { - return fmt.Errorf("%w: %s#%d: %v", ErrUnreadable, f.Repository, f.Number, err) - } - var next *delivery.PlacementCandidate // nil: no key for this node - raw, atHead := f.Placements[node] - before, inTrunk := trunk[node] - switch { - case atHead: - p, err := delivery.DecodePlacement([]byte(raw)) - if err == nil && p.Node != node { - err = fmt.Errorf("the file of %s names the node %q", node, p.Node) - } - if err != nil { - ix.logf("%s#%d at %s: the placement file of %s is refused, so %s has no placement candidate (the catalogue's check refuses the pull request): %v", - f.Repository, f.Number, f.Head, node, node, err) - break - } - if inTrunk && delivery.SamePlacement(before, p) { - break - } - next = &delivery.PlacementCandidate{Group: f.Branch, Pull: pull, Placement: &p} - case inTrunk: - next = &delivery.PlacementCandidate{Group: f.Branch, Pull: pull} // the head removes the node's file - } - if err := ix.writePlacementCandidate(ctx, key, next); err != nil { - return err - } - } - // A node the head and the trunk both no longer name keeps no key of this group. - return ix.dropPlacementCandidates(ctx, func(node string, c delivery.PlacementCandidate) bool { - return c.Group == f.Branch && delivery.SameRepository(c.Pull.Repository, f.Repository) && c.Pull.Number == f.Number && !nodes[node] - }, fmt.Sprintf("%s#%d at %s", f.Repository, f.Number, f.Head)) -} - -// dropCandidatesOfClosedPulls heals a closed or merged message the indexer never heard (issue 1126: closed probe 532 kept -// its candidates, and ace answered them for 22 minutes before the open 534). When a catalogue pull message arrives and a -// candidate of another pull request exists, the forge is asked which pull requests are open, and a candidate of one that is -// not is deleted. A forge that cannot say leaves them as they are and is said in the log; indexer_placement_reread is the -// same by hand. -func (ix *Indexer) dropCandidatesOfClosedPulls(ctx context.Context, f delivery.Forge) { - if ix.ReadOpen == nil { - return - } - keys, err := ix.PlacementCandidates.Keys(ctx) - if err != nil { - return - } - other := false - for _, key := range keys { - value, _, found, err := ix.PlacementCandidates.Get(ctx, key) - if err != nil || !found { - continue - } - if c, err := delivery.DecodePlacementCandidate(value); err != nil || c.Pull.Number != f.Number || !delivery.SameRepository(c.Pull.Repository, f.Repository) { - other = true - break - } - } - if !other { - return - } - open, err := ix.ReadOpen(ctx) - if err != nil { - ix.logf("%s#%d: the open pull requests could not be read, so no stale placement candidate is deleted: %v", f.Repository, f.Number, err) - return - } - isOpen := map[delivery.OpenPull]bool{} - for _, p := range open { - isOpen[delivery.OpenPull{Repository: p.Repository, Number: p.Number}] = true - } - isOpen[delivery.OpenPull{Repository: f.Repository, Number: f.Number}] = true // the message in hand is an open one - if err := ix.dropPlacementCandidates(ctx, func(_ string, c delivery.PlacementCandidate) bool { - for p := range isOpen { - if p.Number == c.Pull.Number && delivery.SameRepository(p.Repository, c.Pull.Repository) { - return false - } - } - return true - }, "its pull request is not open (the forge says so at the next pull message)"); err != nil { - ix.logf("%s#%d: stale placement candidates are not deleted: %v", f.Repository, f.Number, err) - } -} - -// trunkPlacements is every node's placement in the trunk's state placement, by node. -func (ix *Indexer) trunkPlacements(ctx context.Context) (map[string]delivery.Placement, error) { - out := map[string]delivery.Placement{} - if ix.Placement == nil { - return nil, errors.New("the indexer reads no placement state") - } - keys, err := ix.Placement.Keys(ctx) - if err != nil { - return nil, fmt.Errorf("reading the placement keys: %w", err) - } - for _, key := range keys { - node, err := delivery.ParsePlacementKey(key) - if err != nil { - continue - } - value, _, found, err := ix.Placement.Get(ctx, key) - if err != nil { - return nil, fmt.Errorf("reading %s: %w", key, err) - } - if !found { - continue - } - p, err := delivery.DecodePlacement(value) - if err != nil { - return nil, fmt.Errorf("%s holds what is not a placement: %w", key, err) - } - out[node] = p - } - return out, nil -} - -// writePlacementCandidate writes one candidate key, or deletes it for next nil, only when it changes. -func (ix *Indexer) writePlacementCandidate(ctx context.Context, key string, next *delivery.PlacementCandidate) error { - var data []byte - if next != nil { - var err error - if data, err = delivery.EncodePlacementCandidate(*next); err != nil { - return fmt.Errorf("%w: %s.%s: %v", ErrUnreadable, delivery.PlacementCandidatesState, key, err) - } - } - err := write(ctx, ix.PlacementCandidates, key, func(value []byte, found bool) ([]byte, bool, error) { - switch { - case next == nil: - return nil, found, nil - case found && string(value) == string(data): - return nil, false, nil - } - return data, false, nil - }) - if errors.Is(err, process.ErrNotYet) { - ix.logf("%s.%s is not written: %v", delivery.PlacementCandidatesState, key, err) - return nil - } - return err -} - -// dropPlacementCandidates deletes every candidate key whose node and value gone says, and says each with why. -func (ix *Indexer) dropPlacementCandidates(ctx context.Context, gone func(node string, c delivery.PlacementCandidate) bool, why string) error { - keys, err := ix.PlacementCandidates.Keys(ctx) - if errors.Is(err, process.ErrNotYet) { - return nil - } - if err != nil { - return fmt.Errorf("reading the placement candidate keys: %w", err) - } - for _, key := range keys { - _, node, err := delivery.ParsePlacementCandidateKey(key) - if err != nil { - continue - } - dropped := false - err = write(ctx, ix.PlacementCandidates, key, func(value []byte, found bool) ([]byte, bool, error) { - if !found { - return nil, false, nil - } - c, err := delivery.DecodePlacementCandidate(value) - if err != nil { - ix.logf("%s.%s holds what is not a placement candidate, deleted: %v", delivery.PlacementCandidatesState, key, err) - return nil, true, nil - } - dropped = gone(node, c) - return nil, dropped, nil - }) - if err != nil { - return err - } - if dropped { - ix.logf("%s.%s is deleted: %s", delivery.PlacementCandidatesState, key, why) - } - } - return nil -} - -// PlacementRead is the catalogue's placement as the forge has it now (indexer_placement_reread, ADR 0559 §8): the trunk's -// head and every placement file at it, by node, and each open pull request into the trunk with its own. -type PlacementRead struct { - Trunk string - Files map[string]string - Pulls []PullPlacement -} - -// PullPlacement is one open catalogue pull request and every placement file at its head, by node. -type PullPlacement struct { - Branch string - Number int - Head string - Files map[string]string -} - -// PlacementReread is the answer of indexer_placement_reread: the heads read, the keys written or deleted in each state, -// and in words what happened. -type PlacementReread struct { - Trunk string `json:"trunk,omitempty"` - Pulls []string `json:"pulls,omitempty"` - Placement []string `json:"placement"` - Candidates []string `json:"candidates"` - Answer string `json:"answer"` -} - -// RereadPlacement reads the catalogue's placement now, at the trunk's head and at the head of each open catalogue pull -// request, and writes placement and placement-candidates as a merged and a pull message would (ADR 0549 §2, ADR 0559 -// §3), only where a key differs; a candidate key of a pull request that is no longer open, or whose file no longer -// differs at its head, is deleted. It is the recovery for a forge message the indexer missed (ADR 0559 §8). -func (ix *Indexer) RereadPlacement(ctx context.Context) PlacementReread { - out := PlacementReread{Placement: []string{}, Candidates: []string{}} - if ix.ReadPlacement == nil || ix.Placement == nil || ix.PlacementCandidates == nil { - out.Answer = "the indexer reads no placement here: it has no forge reader or no placement state" - return out - } - read, err := ix.ReadPlacement(ctx) - if err != nil { - out.Answer = "the forge could not say the catalogue's placement, nothing is written: " + err.Error() - return out - } - out.Trunk = read.Trunk - before := revisions(ctx, ix.Placement) - beforeC := revisions(ctx, ix.PlacementCandidates) - if err := ix.applyPlacement(ctx, delivery.Forge{Kind: delivery.ForgeMerged, Repository: delivery.CatalogueRepository, Merge: read.Trunk, - Placements: read.Files, PlacementsRead: true}); err != nil { - out.Answer = "the trunk's placement could not be written: " + err.Error() - return out - } - open := map[int]bool{} - var failed []string - for _, p := range read.Pulls { - open[p.Number] = true - out.Pulls = append(out.Pulls, fmt.Sprintf("%s#%d at %s", delivery.CatalogueRepository, p.Number, p.Head)) - f := delivery.Forge{Kind: delivery.ForgePull, Repository: delivery.CatalogueRepository, Branch: p.Branch, Number: p.Number, Head: p.Head, - Action: delivery.PullUpdated, Placements: p.Files, PlacementsRead: true} - if err := ix.applyPlacementCandidates(ctx, f); err != nil { - failed = append(failed, fmt.Sprintf("#%d: %v", p.Number, err)) - } - } - if err := ix.dropPlacementCandidates(ctx, func(_ string, c delivery.PlacementCandidate) bool { - return delivery.SameRepository(c.Pull.Repository, delivery.CatalogueRepository) && !open[c.Pull.Number] - }, "its pull request is not open (indexer_placement_reread)"); err != nil { - failed = append(failed, err.Error()) - } - out.Placement = changed(before, revisions(ctx, ix.Placement)) - out.Candidates = changed(beforeC, revisions(ctx, ix.PlacementCandidates)) - switch { - case len(failed) > 0: - out.Answer = "read at the trunk's head " + read.Trunk + "; some candidates are not written: " + fmt.Sprint(failed) - case len(out.Placement)+len(out.Candidates) == 0: - out.Answer = "read at the trunk's head " + read.Trunk + " and every open catalogue pull request's: every key was as the forge has it, nothing written" - default: - out.Answer = fmt.Sprintf("read at the trunk's head %s and every open catalogue pull request's: %d placement keys and %d candidate keys written or deleted", - read.Trunk, len(out.Placement), len(out.Candidates)) - } - return out -} - -// revisions is every key of a store with its revision; a state not on the bus yet has none. -func revisions(ctx context.Context, st Store) map[string]uint64 { - out := map[string]uint64{} - keys, err := st.Keys(ctx) - if err != nil { - return out - } - for _, key := range keys { - if _, rev, found, err := st.Get(ctx, key); err == nil && found { - out[key] = rev - } - } - return out -} - -// changed is every key written, created or deleted between two revisions maps, sorted. -func changed(before, after map[string]uint64) []string { - out := []string{} - for key, rev := range after { - if before[key] != rev { - out = append(out, key) - } - } - for key := range before { - if _, ok := after[key]; !ok { - out = append(out, key) - } - } - slices.Sort(out) - return out -} diff --git a/modules/mesh-delivery-indexer/internal/index/placement_test.go b/modules/mesh-delivery-indexer/internal/index/placement_test.go deleted file mode 100644 index d90b39a..0000000 --- a/modules/mesh-delivery-indexer/internal/index/placement_test.go +++ /dev/null @@ -1,219 +0,0 @@ -package index - -import ( - "context" - "reflect" - "slices" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// ADR 0549 §2 (issue 1126): a merge changing one node's file writes that key alone and says one event; a merge with no -// placement change writes nothing and says nothing; a node whose file is gone loses its key and says its modules -// removed; a file that does not read leaves its key; a message that read no placement changes none. -func placementIsWrittenAtAMergeOnlyWhereItChanged(t *testing.T) { - ctx := context.Background() - ix, _, logs := newIndexer(t) - place := newMem() - ix.Placement = place - var said []delivery.PlacementChange - ix.Say = func(event string, body any) error { - if event != delivery.PlacementChangedEvent { - t.Errorf("said %s", event) - } - said = append(said, body.(delivery.PlacementChange)) - return nil - } - merged := func(merge string, files map[string]string) delivery.Forge { - return delivery.Forge{V: delivery.Version, Kind: delivery.ForgeMerged, Repository: delivery.CatalogueRepository, Branch: "main", - Number: 7, Head: "h", Merge: merge, Root: "r", Trees: map[string]string{"": "t"}, Placements: files, PlacementsRead: true} - } - ace := `{"v":1,"node":"ace","modules":[{"module":"plex"}]}` - g14 := `{"v":1,"node":"g14","modules":[{"module":"zsh"}]}` - written := func(node string) (delivery.Placement, uint64) { - t.Helper() - v, ok := place.vals["placement."+node] - if !ok { - return delivery.Placement{}, 0 - } - p, err := delivery.DecodePlacement(v) - if err != nil { - t.Fatal(err) - } - return p, place.revs["placement."+node] - } - - if err := ix.Apply(ctx, merged("m1", map[string]string{"ace": ace, "g14": g14})); err != nil { - t.Fatal(err) - } - if p, _ := written("ace"); p.Revision != "m1" || !reflect.DeepEqual(p.Assigned(), []string{"plex"}) || len(said) != 2 { - t.Fatalf("first merge: %+v, said %v", p, said) - } - _, aceRev := written("ace") - - said = nil - if err := ix.Apply(ctx, merged("m2", map[string]string{"ace": ace, "g14": g14})); err != nil { - t.Fatal(err) - } - if p, rev := written("ace"); rev != aceRev || p.Revision != "m1" || len(said) != 0 { - t.Errorf("a merge with no placement change wrote %+v (revision %d, was %d) and said %v", p, rev, aceRev, said) - } - - said = nil - if err := ix.Apply(ctx, merged("m3", map[string]string{"ace": ace, "g14": `{"v":1,"node":"g14","modules":[{"module":"zsh"},{"module":"xterm"}]}`})); err != nil { - t.Fatal(err) - } - if _, rev := written("ace"); rev != aceRev || len(said) != 1 || said[0].Node != "g14" || said[0].Revision != "m3" || - !reflect.DeepEqual(said[0].Added, []string{"xterm"}) { - t.Errorf("one file changed: ace's revision %d (was %d), said %+v", rev, aceRev, said) - } - - said = nil - if err := ix.Apply(ctx, merged("m4", map[string]string{"ace": ace, "g14": `{"v":1,"node":"ace","modules":[]}`})); err != nil { - t.Fatal(err) - } - if p, _ := written("g14"); len(p.Modules) != 2 || len(said) != 0 || !strings.Contains(logs.String(), "the placement file of %s is refused") { - t.Errorf("a file naming another node replaced the key: %+v, said %v, logs %s", p, said, logs) - } - - said = nil - if err := ix.Apply(ctx, merged("m5", map[string]string{"ace": ace})); err != nil { - t.Fatal(err) - } - if _, rev := written("g14"); rev != 0 || len(said) != 1 || !reflect.DeepEqual(said[0].Removed, []string{"xterm", "zsh"}) { - t.Errorf("a file removed: g14's key at revision %d, said %+v", rev, said) - } - - said = nil - unread := merged("m6", nil) - unread.PlacementsRead = false - if err := ix.Apply(ctx, unread); err != nil { - t.Fatal(err) - } - if p, _ := written("ace"); p.Revision != "m1" || len(said) != 0 { - t.Errorf("a message that read no placement changed it: %+v, %v", p, said) - } - - // Issue 1126 (ADR 0549 §4), placement candidates: the trunk now places ace alone. A catalogue pull request writes - // `.` for each node whose file at its head differs from the trunk's, or that the head removes, and only - // when the value changes; a pull message that read no placement, or a pull request changing none, writes nothing; a - // file that does not read gets no key; a merge, a close or an open message not naming it deletes its keys. - cands := newMem() - ix.PlacementCandidates = cands - said = nil - pullOf := func(branch string, number int, head string, files map[string]string) delivery.Forge { - return delivery.Forge{V: delivery.Version, Kind: delivery.ForgePull, Repository: delivery.CatalogueRepository, Branch: branch, - Number: number, Head: head, Action: delivery.PullUpdated, Placements: files, PlacementsRead: files != nil} - } - candidate := func(key string) (delivery.PlacementCandidate, uint64, bool) { - t.Helper() - v, ok := cands.vals[key] - if !ok { - return delivery.PlacementCandidate{}, 0, false - } - c, err := delivery.DecodePlacementCandidate(v) - if err != nil { - t.Fatal(err) - } - return c, cands.revs[key], true - } - keys := func() string { k, _ := cands.Keys(ctx); slices.Sort(k); return strings.Join(k, ",") } - g14New := `{"v":1,"node":"g14","modules":[{"module":"zsh"}]}` - g14P := `{"v":1,"node":"g14","modules":[{"module":"htop"}]}` - g14Q := `{"v":1,"node":"g14","modules":[{"module":"mc"}]}` - for _, row := range []struct { - name string - f delivery.Forge - keys string - open []int // the pull requests the forge says are open; nil: the indexer asks none - }{ - {"a pull request adding g14", pullOf("feat/p", 9, h1, map[string]string{"ace": ace, "g14": g14New}), "feat/p.g14", nil}, - {"the same head again", pullOf("feat/p", 9, h1, map[string]string{"ace": ace, "g14": g14New}), "feat/p.g14", nil}, - {"a pull message that read no placement", pullOf("feat/p", 9, h2, nil), "feat/p.g14", nil}, - {"a pull request changing no placement", pullOf("feat/q", 10, h1, map[string]string{"ace": ace}), "feat/p.g14", nil}, - {"a new head removing ace", pullOf("feat/p", 9, h2, map[string]string{"g14": g14New}), "feat/p.ace,feat/p.g14", nil}, - {"a head whose g14 file does not read", pullOf("feat/p", 9, h3, map[string]string{"g14": `{"v":1,"node":"ace","modules":[]}`}), "feat/p.ace", nil}, - {"the head adding g14 again", pullOf("feat/p", 9, h2, map[string]string{"g14": g14New}), "feat/p.ace,feat/p.g14", nil}, - {"a head the forge says changes no placement file", func() delivery.Forge { f := pullOf("feat/p", 9, h3, nil); f.DirsKnown = true; return f }(), "", nil}, - {"the head adding g14 and removing ace again", pullOf("feat/p", 9, h2, map[string]string{"g14": g14New}), "feat/p.ace,feat/p.g14", nil}, - {"an open message not naming it", delivery.Forge{V: delivery.Version, Kind: delivery.ForgeOpen, Open: []delivery.OpenPull{{Repository: delivery.CatalogueRepository, Number: 10}}}, "", nil}, - {"the pull request heard again", pullOf("feat/p", 9, h2, map[string]string{"g14": g14New}), "feat/p.ace,feat/p.g14", nil}, - {"its merge", func() delivery.Forge { - m := merged("m7", map[string]string{"g14": g14New}) - m.Branch, m.Number = "feat/p", 9 - return m - }(), "", nil}, - // A closed message the indexer never heard heals at the next pull message (issue 1126, closed probe 532). - {"pull 9 changes g14, both open", pullOf("feat/p", 9, h2, map[string]string{"g14": g14P}), "feat/p.g14", []int{9, 10}}, - {"pull 10 changes g14, both open", pullOf("feat/q", 10, h1, map[string]string{"g14": g14Q}), "feat/p.g14,feat/q.g14", []int{9, 10}}, - {"pull 9 closed unheard, pull 10 heard again", pullOf("feat/q", 10, h2, map[string]string{"g14": g14Q}), "feat/q.g14", []int{10}}, - {"pull 10 closed", func() delivery.Forge { f := pullOf("feat/q", 10, h2, nil); f.Action = delivery.PullClosed; return f }(), "", []int{10}}, - } { - ix.ReadOpen = nil - if row.open != nil { - ix.ReadOpen = func(context.Context) ([]delivery.OpenPull, error) { - var out []delivery.OpenPull - for _, n := range row.open { - out = append(out, delivery.OpenPull{Repository: delivery.CatalogueRepository, Number: n}) - } - return out, nil - } - } - _, revBefore, _ := candidate("feat/p.g14") - if err := ix.Apply(ctx, row.f); err != nil { - t.Fatalf("%s: %v", row.name, err) - } - if got := keys(); got != row.keys { - t.Errorf("%s: keys %q, want %q", row.name, got, row.keys) - } - c, rev, ok := candidate("feat/p.g14") - switch row.name { - case "a pull request adding g14": - if !ok || c.Group != "feat/p" || c.Pull != (delivery.Head{Repository: delivery.CatalogueRepository, Number: 9, Head: h1}) || c.Placement == nil || - !reflect.DeepEqual(c.Placement.Assigned(), []string{"zsh"}) { - t.Errorf("%s: %+v", row.name, c) - } - case "the same head again", "a pull message that read no placement", "a pull request changing no placement": - if rev != revBefore { - t.Errorf("%s: feat/p.g14 written again (revision %d, was %d)", row.name, rev, revBefore) - } - case "a new head removing ace": - if a, _, _ := candidate("feat/p.ace"); a.Placement != nil || a.Pull.Head != h2 || c.Pull.Head != h2 { - t.Errorf("%s: ace %+v, g14 at %s", row.name, a, c.Pull.Head) - } - } - } - if p, _ := written("g14"); len(p.Modules) != 1 || p.Revision != "m7" { - t.Errorf("the merge's trunk placement of g14: %+v", p) - } - - // ADR 0559 §8, indexer_placement_reread: a reread with nothing changed writes nothing; one after a missed merge and a - // missed head writes those keys; one after a missed revert deletes the candidate; a forge that cannot say writes nothing. - g14Other := `{"v":1,"node":"g14","modules":[{"module":"zsh"},{"module":"htop"}]}` - for _, row := range []struct { - name string - read PlacementRead - err error - placement, cands []string - }{ - {"nothing changed", PlacementRead{Trunk: "m7", Files: map[string]string{"g14": g14New}}, nil, []string{}, []string{}}, - {"a missed merge and a missed head", PlacementRead{Trunk: "m8", Files: map[string]string{"g14": g14New, "ace": ace}, - Pulls: []PullPlacement{{Branch: "feat/r", Number: 11, Head: h1, Files: map[string]string{"g14": g14Other, "ace": ace}}}}, nil, - []string{"placement.ace"}, []string{"feat/r.g14"}}, - {"a missed revert", PlacementRead{Trunk: "m8", Files: map[string]string{"g14": g14New, "ace": ace}, - Pulls: []PullPlacement{{Branch: "feat/r", Number: 11, Head: h2, Files: map[string]string{"g14": g14New, "ace": ace}}}}, nil, - []string{}, []string{"feat/r.g14"}}, - {"a forge that cannot say", PlacementRead{}, context.DeadlineExceeded, []string{}, []string{}}, - } { - ix.ReadPlacement = func(context.Context) (PlacementRead, error) { return row.read, row.err } - r := ix.RereadPlacement(ctx) - if !reflect.DeepEqual(r.Placement, row.placement) || !reflect.DeepEqual(r.Candidates, row.cands) || r.Answer == "" { - t.Errorf("%s: %+v", row.name, r) - } - } - if keys() != "" { - t.Errorf("after the reread of a revert, candidates %q", keys()) - } -} diff --git a/modules/mesh-delivery-indexer/internal/index/takein.go b/modules/mesh-delivery-indexer/internal/index/takein.go index 5ceec19..9d3915f 100644 --- a/modules/mesh-delivery-indexer/internal/index/takein.go +++ b/modules/mesh-delivery-indexer/internal/index/takein.go @@ -17,10 +17,6 @@ import ( // of the indexer's states stay in mesh-delivery-view. const ( TakeInTool = "indexer_take_in" - // PlacementRereadTool reads the catalogue's placement again now (ADR 0559 §8); it asks through the same state, under - // PlacementRereadKey, a key no module has (a module's name holds no dot). - PlacementRereadTool = "indexer_placement_reread" - PlacementRereadKey = "placement.reread" // TakeInsState is the indexer's state of take-in asks: the bucket mesh-delivery-indexer_takeins, keyed as the modules state. TakeInsState = "takeins" TakeInsBucket = delivery.IndexerModule + "_" + TakeInsState @@ -36,19 +32,10 @@ type TakeInAsk struct { At time.Time `json:"at"` Result *TakenIn `json:"result,omitempty"` Answered time.Time `json:"answered,omitzero"` - // Placement asks indexer_placement_reread instead of a module's take-in; Reread is its answer. - Placement bool `json:"placement,omitempty"` - Reread *PlacementReread `json:"reread,omitempty"` } // Done says the ask is answered. -func (a TakeInAsk) Done() bool { return !a.Answered.IsZero() && (a.Result != nil || a.Reread != nil) } - -// NewPlacementReread is the value indexer_placement_reread writes: its key and the value. -func NewPlacementReread(why, by string, at time.Time) (string, []byte, error) { - data, err := json.Marshal(TakeInAsk{V: 1, Placement: true, Why: strings.TrimSpace(why), By: by, At: at.UTC()}) - return PlacementRereadKey, data, err -} +func (a TakeInAsk) Done() bool { return !a.Answered.IsZero() && a.Result != nil } // DecodeTakeIn reads a takeins value. func DecodeTakeIn(data []byte) (TakeInAsk, error) { @@ -59,7 +46,7 @@ func DecodeTakeIn(data []byte) (TakeInAsk, error) { if a.V != 1 { return a, fmt.Errorf("a takeins value of version %d, not 1", a.V) } - if a.Module == "" && !a.Placement { + if a.Module == "" { return a, errors.New("a takeins value naming no module") } return a, nil diff --git a/modules/mesh-delivery-indexer/manifest_test.go b/modules/mesh-delivery-indexer/manifest_test.go index abd5e9c..e05aab4 100644 --- a/modules/mesh-delivery-indexer/manifest_test.go +++ b/modules/mesh-delivery-indexer/manifest_test.go @@ -81,12 +81,12 @@ func TestManifestDeclarations(t *testing.T) { // 1238/AC1, 1444 (reads the build if m.Module != "mesh-delivery-indexer" { t.Errorf("module = %q", m.Module) } - // 1126 (ADR 0549 §2): the indexer's placement state, and the event it says when a node's placement changed. - if want := []string{"placement-changed"}; !reflect.DeepEqual(m.Emits, want) { - t.Errorf("emits = %v, want %v", m.Emits, want) + // 1126: placement is the seat mesh-placement's (ADR 0567): the indexer holds no placement state and says no event. + if len(m.Emits) != 0 { + t.Errorf("emits = %v, want none", m.Emits) } // 1126 note 76: heard keeps the last 64 FORGE messages the indexer handled, for indexer_heard. - if want := []state{{Name: "candidates", History: 10}, {Name: "heard", History: 64}, {Name: "manifests", History: 1}, {Name: "modules", History: 20}, {Name: "placement", History: 10}, {Name: "placement-candidates", History: 5}, {Name: "taken", History: 1}, {Name: "takeins", History: 1}}; !reflect.DeepEqual(m.State, want) { + if want := []state{{Name: "candidates", History: 10}, {Name: "heard", History: 64}, {Name: "manifests", History: 1}, {Name: "modules", History: 20}, {Name: "taken", History: 1}, {Name: "takeins", History: 1}}; !reflect.DeepEqual(m.State, want) { t.Errorf("state = %+v, want %+v", m.State, want) } if want := []string{"mesh-build-go.toolchains", "mesh-build-node.toolchains", "mesh-controller.assignments", "mesh-controller.builds", "mesh-delivery-checker.previews", "mesh-delivery-gate.source"}; !reflect.DeepEqual(sorted(m.Reads), want) { @@ -106,15 +106,14 @@ func TestManifestDeclarations(t *testing.T) { // 1238/AC1, 1444 (reads the build // Three invokes: the file of a registered module's manifest and lock files (registered.go), the forge's trees (the named paths' objects, since the indexer is the one reader of a manifest's paths: // ADR 0499 clauses 2 and 3, ADR 0495 clause 1; and the module directories of the trunk, issue 1900) and whether a // commit is in another (issue 1900). - // ADR 0559 §8: the placement reread also asks the catalogue's branches and open pull requests. - if want := []string{"gitea.gitea_commit_trees", "gitea.gitea_contains", "gitea.gitea_get_file", "gitea.gitea_list_branches", "gitea.gitea_open_pulls"}; !reflect.DeepEqual(m.Invokes, want) { + if want := []string{"gitea.gitea_commit_trees", "gitea.gitea_contains", "gitea.gitea_get_file", "gitea.gitea_open_pulls"}; !reflect.DeepEqual(m.Invokes, want) { t.Errorf("invokes = %v, want %v", m.Invokes, want) } - // 1945: indexer_take_in, and indexer_placement_reread (ADR 0559 §8), served by its tools bundle through the takeins state; the process serves none. - if want := []string{"indexer_take_in", "indexer_placement_reread"}; !reflect.DeepEqual(m.Tools, want) { + // 1945: indexer_take_in, served by its tools bundle through the takeins state; the process serves none. + if want := []string{"indexer_take_in"}; !reflect.DeepEqual(m.Tools, want) { t.Errorf("tools = %v, want %v", m.Tools, want) } - // No streams, no bus, no seat (ADR 0437 §4); its one event is placement-changed (ADR 0549 §2), checked above. + // No streams, no bus, no seat (ADR 0437 §4); it says no event. for _, k := range []string{"streams", "logs", "claims", "provides", "requires", "uses", "defines-seats", "seat", "seats", "bus"} { if _, ok := top[k]; ok { t.Errorf("the manifest declares %q: the indexer has none", k) diff --git a/modules/mesh-delivery-indexer/module.json b/modules/mesh-delivery-indexer/module.json index 6a7fd5e..544e452 100644 --- a/modules/mesh-delivery-indexer/module.json +++ b/modules/mesh-delivery-indexer/module.json @@ -3,11 +3,7 @@ "version": "1", "slug": "indexer", "tools": [ - "indexer_take_in", - "indexer_placement_reread" - ], - "emits": [ - "placement-changed" + "indexer_take_in" ], "consumes": [ "mesh-build-go.toolchain.reported", @@ -20,8 +16,6 @@ { "name": "heard", "history": 64 }, { "name": "manifests", "history": 1 }, { "name": "modules", "history": 20 }, - { "name": "placement", "history": 10 }, - { "name": "placement-candidates", "history": 5 }, { "name": "taken", "history": 1 }, { "name": "takeins", "history": 1 } ], @@ -37,7 +31,6 @@ "gitea.gitea_commit_trees", "gitea.gitea_contains", "gitea.gitea_get_file", - "gitea.gitea_list_branches", "gitea.gitea_open_pulls" ], "own-secrets": { diff --git a/modules/mesh-delivery-teller/go.mod b/modules/mesh-delivery-teller/go.mod index d89c1a0..4135620 100644 --- a/modules/mesh-delivery-teller/go.mod +++ b/modules/mesh-delivery-teller/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-teller go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027193820-8ad9372b1952 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-teller/go.sum b/modules/mesh-delivery-teller/go.sum index fa4f833..6245202 100644 --- a/modules/mesh-delivery-teller/go.sum +++ b/modules/mesh-delivery-teller/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027193820-8ad9372b1952 h1:TmveT3Bija643RGWkilvkzP/h8UWlVvHFZZtQfJK5oU= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027193820-8ad9372b1952/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388 h1:ycu/sNieTdLpD3bzhZOCXdzcoqXUOxPRmqWV019REpo= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388/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-view/cmd/mesh-delivery-view-tools/main.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go index 9231f3e..d76f73b 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go @@ -85,30 +85,6 @@ func Tools(r view.Reader, u view.UsageReader) []stdio.Tool { mods, err := r.Modules(only) return map[string]any{"modules": mods}, err }}, - {Name: view.PlacementReadTool, - Description: "Placement candidates and the shadow answers per node: where modules run, as the indexer read it from the catalogue's placement files at the last merge (placement., novox/hq ADR 0549), " + - "and beside it, the candidates, each open catalogue pull request's placement of a node that differs from the trunk's (placement-candidates ., issue 1126) " + - "with that node's shadow answer from its .node have (answered: same, differs, refused, stale for an earlier head, none when it has not answered), as the node wrote it: " + - "the modules that are the same, those that differ with each differing resource's why, bodies and the JSON paths that differ, the refusals. " + - "Each node's placement lists its modules in order with their host ports, bindings and settings placement, its pins and the seats its modules hold, and the catalogue revision. " + - "With node, that node's; with module, every node placing it; with neither, every node's. Keys that do not read are listed as unreadable. " + - "With summary true, only the compact form: per node and candidate the answer, the counts of same and differing, and per differing resource its module, id, why and paths, with no bodies and no placement (a read for one node is about 60 KB whole). Read-only.", - Input: map[string]any{"type": "object", "properties": map[string]any{ - "node": nodeModule["node"], - "module": nodeModule["module"], - "summary": map[string]any{"type": "boolean", "description": "the compact form: the answer, the counts and the differing resources' paths, without bodies or placements"}}}, - Run: func(args map[string]any) (any, error) { - node, module := nodeModuleOf(args) - out, err := r.Placement(node, module) - if err != nil { - return out, err - } - if summary, _ := args["summary"].(bool); summary { - rows, _ := out["candidates"].([]view.CandidateRow) - return map[string]any{"candidates": view.SummarizeCandidates(rows)}, nil - } - return out, nil - }}, {Name: view.CandidateReadTool, Description: "What the indexer stored for one candidate (the open pull requests of a branch) under candidates., as stored: " + "the pull requests and heads, the base, and per pull request the touched modules with their new-form fingerprint (fp2) or why " + @@ -247,10 +223,8 @@ func read(state stdio.KeptState) view.StateRead { func main() { r := view.Reader{State: read(stdio.State("mesh-delivery-indexer.modules")), Builds: read(stdio.State("mesh-delivery-builder.builds")), CandidatesState: read(stdio.State("mesh-delivery-indexer.candidates")), WantsState: read(stdio.State("mesh-delivery-assigner.wants")), DifferencesState: read(stdio.State("mesh-delivery-assigner.differences")), HavesState: read(stdio.State("node-engine.have")), - ReachState: read(stdio.State("reach")), - PlacementState: read(stdio.State("mesh-delivery-indexer.placement")), - PlacementCandidatesState: read(stdio.State("mesh-delivery-indexer.placement-candidates")), - HeardState: read(stdio.State("mesh-delivery-indexer.heard"))} + ReachState: read(stdio.State("reach")), + HeardState: read(stdio.State("mesh-delivery-indexer.heard"))} u := view.UsageReader{Haves: r.HavesState, Resolved: read(stdio.State("mesh-config.resolved")), Tries: read(stdio.State("mesh-delivery-checker.tries")), Steps: map[string]view.StateRead{}, Pauses: map[string]view.StateRead{}, Backlog: backlogOf(socket())} for _, lang := range delivery.AskLanguages { diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go index e2ec081..3cb27d2 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go @@ -135,112 +135,6 @@ func TestModulesToolAnswersEveryKeyAsStored(t *testing.T) { if len(only) != 1 || string(only["b"]) != withNone { t.Errorf("none2 modules = %v", only) } - - // 1126 (ADR 0549 §2): indexer_placement answers a node's key as stored, every node placing a module, or every node's, - // and lists a key that does not read. - const ace, g14 = `{"v":1,"node":"ace","revision":"m1","modules":[{"module":"plex"}]}`, `{"v":1,"node":"g14","revision":"m1","modules":[{"module":"zsh"},{"module":"plex"}]}` - p := view.Reader{PlacementState: fake{"placement.ace": ace, "placement.g14": g14, "placement.bad": `{"v":1}`, "other": "{}"}.reader().State} - got := func(args map[string]any) map[string]json.RawMessage { - t.Helper() - out, err := run(t, p, view.PlacementReadTool, args) - if err != nil { - t.Fatal(err) - } - return out.(map[string]any)["placement"].(map[string]json.RawMessage) - } - if one := got(map[string]any{"node": "ace"}); len(one) != 1 || string(one["ace"]) != ace { - t.Errorf("node ace = %v", one) - } - if zsh := got(map[string]any{"module": "zsh"}); len(zsh) != 1 || string(zsh["g14"]) != g14 { - t.Errorf("module zsh = %v", zsh) - } - out, err = run(t, p, view.PlacementReadTool, map[string]any{}) - if all := out.(map[string]any); err != nil || len(all["placement"].(map[string]json.RawMessage)) != 2 || len(all["unreadable"].([]string)) != 2 { - t.Errorf("every node = %v, %v", all, err) - } - if _, err := run(t, p, view.PlacementReadTool, map[string]any{"node": "novox"}); err == nil || !strings.Contains(err.Error(), "no placement of novox") { - t.Errorf("a node with no key: %v", err) - } - if c := out.(map[string]any)["candidates"].([]view.CandidateRow); len(c) != 0 { - t.Errorf("with no candidate open: %v", c) - } - - // ADR 0559 §7 (issue 1126): beside the trunk's, each open candidate key and the node's answer from its .node: - // answered at the key's head and revision, at an earlier head (stale), or not at all (none); a node the trunk does not - // place yet is shown by its candidate alone; ace, placed on the trunk with its file unchanged (no key of its own), is - // shown with its answer, counted by the head alone. - const h1, h2 = "1111111111111111111111111111111111111111", "2222222222222222222222222222222222222222" - cand := func(node, head string, placed bool) string { - pl := `,"placement":{"v":1,"node":"` + node + `","modules":[{"module":"plex"}]}` - if !placed { - pl = "" - } - return `{"v":1,"group":"feat/p","pull":{"repository":"novox/mesh-catalog","number":9,"head":"` + head + `"}` + pl + `}` - } - cands := fake{"feat/p.g14": cand("g14", h2, false), "feat/p.novox": cand("novox", h2, true)} - revs := map[string]uint64{"feat/p.g14": 5, "feat/p.novox": 6} - p.PlacementCandidatesState = view.StateRead{Keys: cands.reader().State.Keys, GetEntry: func(key string) (json.RawMessage, uint64, error) { - raw, _ := cands.reader().State.Get(key) - return raw, revs[key], nil - }} - p.HavesState = fake{ - // ace's answer as the node wrote it, with what this view's mesh-sdk may not know (issue 1126, probe #532: the - // bodies of each difference were dropped on the way): shown as stored. - "ace.node": `{"v":1,"composed":"x","candidates":{"feat/p":{"head":"` + h2 + `","verdict":"same","same":["plex"],` + - `"differs":[],"note":"took over a minute","future":{"bodies":[{"id":"x","composed":""}]}}}}`, - "g14.node": `{"v":1,"composed":"x","candidates":{"feat/p":{"head":"` + h1 + `","revision":2,"verdict":"same"}}}`, - }.reader().State - out, err = run(t, p, view.PlacementReadTool, map[string]any{}) - if err != nil { - t.Fatal(err) - } - answered := map[string]string{} - for _, c := range out.(map[string]any)["candidates"].([]view.CandidateRow) { - answered[c.Node] = c.Answered - if c.Node == "ace" && (!strings.Contains(string(c.Answer), `"future":{"bodies"`) || !strings.Contains(string(c.Answer), `"note":"took over a minute"`)) { - t.Errorf("ace's answer is not shown as stored: %s", c.Answer) - } - } - if want := map[string]string{"ace": "same", "g14": view.AnswerStale, "novox": view.AnswerNone}; fmt.Sprint(answered) != fmt.Sprint(want) { - t.Errorf("answers %v, want %v", answered, want) - } - out, err = run(t, p, view.PlacementReadTool, map[string]any{"node": "novox"}) - if c := out.(map[string]any)["candidates"].([]view.CandidateRow); err != nil || len(c) != 1 || c[0].Pull.Head != h2 || len(out.(map[string]any)["placement"].(map[string]json.RawMessage)) != 0 { - t.Errorf("a node placed by its candidate alone: %v, %v", out, err) - } - out, _ = run(t, p, view.PlacementReadTool, map[string]any{"module": "plex"}) - if c := out.(map[string]any)["candidates"].([]view.CandidateRow); len(c) != 2 { - t.Errorf("the candidates placing plex: %v (g14's removes its file)", c) - } - - // Issue 1126, summary: true keeps the verdict, the counts and each differing resource's module, id, why and paths, and - // drops the bodies and the placements; a field the node adds to a differing resource (paths) rides through whole. - p.HavesState = fake{ - "ace.node": `{"v":1,"composed":"x","candidates":{"feat/p":{"head":"` + h2 + `","revision":0,"verdict":"differs","same":["plex","zsh"],` + - `"differs":[{"module":"brother","resource":"brother.cfg","composed":"sha256:a","running":"sha256:b","why":"the resource differs",` + - `"bodies":[{"id":"brother.cfg","why":"the resource differs","paths":["sealed","links.0"],"composed":"BODYBODYBODY","running":"BODYBODYBODY"}]}]}}}`, - }.reader().State - out, err = run(t, p, view.PlacementReadTool, map[string]any{"summary": true}) - if err != nil { - t.Fatal(err) - } - sums := out.(map[string]any)["candidates"].([]view.CandidateSummary) - raw, _ := json.Marshal(sums) - var ace2 *view.CandidateSummary - for i := range sums { - if sums[i].Node == "ace" { - ace2 = &sums[i] - } - } - if _, has := out.(map[string]any)["placement"]; has || ace2 == nil || ace2.Verdict != "differs" || ace2.Same != 2 || ace2.Differing != 1 || - strings.Contains(string(raw), "BODY") || !strings.Contains(string(raw), `"paths":["sealed","links.0"]`) || !strings.Contains(string(raw), `"module":"brother"`) || - !strings.Contains(string(raw), `"why":"the resource differs"`) { - t.Errorf("the summary of a differing answer: %s", raw) - } - whole, _ := run(t, p, view.PlacementReadTool, map[string]any{}) - if w, _ := json.Marshal(whole); !strings.Contains(string(w), "BODYBODYBODY") || !strings.Contains(string(w), `"paths"`) { - t.Errorf("the whole answer drops what the node wrote: %s", w) - } } const ( diff --git a/modules/mesh-delivery-view/go.mod b/modules/mesh-delivery-view/go.mod index d082fed..2d50f31 100644 --- a/modules/mesh-delivery-view/go.mod +++ b/modules/mesh-delivery-view/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-view go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028090029-3028c7ec4ed2 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-view/go.sum b/modules/mesh-delivery-view/go.sum index a460d12..6245202 100644 --- a/modules/mesh-delivery-view/go.sum +++ b/modules/mesh-delivery-view/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028090029-3028c7ec4ed2 h1:eSwObLdxs3jxPrlcwdaMo9hzIDTUz2ogWw8cjX8To7I= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028090029-3028c7ec4ed2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388 h1:ycu/sNieTdLpD3bzhZOCXdzcoqXUOxPRmqWV019REpo= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388/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-view/internal/view/read.go b/modules/mesh-delivery-view/internal/view/read.go index 928a37e..05ee803 100644 --- a/modules/mesh-delivery-view/internal/view/read.go +++ b/modules/mesh-delivery-view/internal/view/read.go @@ -7,7 +7,6 @@ package view import ( "encoding/json" "fmt" - "maps" "slices" "sort" "strings" @@ -21,8 +20,6 @@ import ( const ( ModuleReadTool = "indexer_module" ModulesReadTool = "indexer_modules" - // PlacementReadTool reads the indexer's placement (novox/hq ADR 0549 §2, issue 1126). - PlacementReadTool = "indexer_placement" ) // StateRead reads the indexer's modules state, as the view's reads grant it: its keys and a key's stored value (nil for a key with none). @@ -45,9 +42,6 @@ type Reader struct { DifferencesState StateRead // the assigner's differences HavesState StateRead // the node-engine's haves ReachState StateRead // the view's own reach (ADR 0556) - PlacementState StateRead // the indexer's placement - // PlacementCandidatesState is the indexer's placement candidates (issue 1126); unset reads none. - PlacementCandidatesState StateRead // HeardState is the FORGE messages the indexer handled (issue 1126 note 76); unset reads none. HeardState StateRead } @@ -655,329 +649,3 @@ func (r Reader) Differences(node, module string) (KeptAnswer, error) { func (r Reader) Haves(node, module string) (KeptAnswer, error) { return r.kept(r.HavesState, "the haves", node, module) } - -// Placement is the indexer's placement as stored (ADR 0549 §2): one node's key with node, every node placing a module -// with module (each node's key whole, so its pins, ports and bindings are beside the module), and every node's key with -// neither. A key that does not read as a placement is listed as unreadable, never dropped. -// -// Beside it, candidates (issue 1126, ADR 0549 §4): each open catalogue pull request's placement of a node that differs from -// the trunk's (the indexer's placement-candidates `.`), narrowed the same way, each with the node's answer from -// its `.node` have and whether that answer is for the candidate as it stands (its head and key revision). -func (r Reader) Placement(node, module string) (map[string]any, error) { - node, module = strings.TrimSpace(node), strings.TrimSpace(module) - if r.PlacementState.Keys == nil || r.PlacementState.Get == nil { - return nil, fmt.Errorf("the placement state cannot be read here") - } - candidates, err := r.PlacementCandidates(node, module) - if err != nil { - return nil, err - } - if node != "" { - key, err := delivery.PlacementKey(node) - if err != nil { - return nil, err - } - raw, err := r.PlacementState.Get(key) - if err != nil { - return nil, err - } - if len(raw) == 0 && len(candidates) == 0 { - return nil, fmt.Errorf("no placement of %s: the catalogue holds no placement file for it, or the indexer has read none yet", node) - } - out := map[string]json.RawMessage{} - if len(raw) > 0 { - out[node] = raw - } - return map[string]any{"placement": out, "candidates": candidates}, nil - } - keys, err := r.PlacementState.Keys() - if err != nil { - return nil, err - } - sort.Strings(keys) - values, err := r.PlacementState.values(keys) - if err != nil { - return nil, err - } - out := map[string]json.RawMessage{} - var unreadable []string - for i, key := range keys { - n, err := delivery.ParsePlacementKey(key) - if err != nil { - unreadable = append(unreadable, key) - continue - } - p, err := delivery.DecodePlacement(values[i]) - if err != nil { - unreadable = append(unreadable, key) - continue - } - if module != "" && !slices.Contains(p.Assigned(), module) { - continue - } - out[n] = values[i] - } - return map[string]any{"placement": out, "unreadable": unreadable, "candidates": candidates}, nil -} - -// The states of a node's answer to a placement candidate, as the view and the assigner's check read it. -const ( - // AnswerNone: the node's have holds no answer for the candidate's group (a node asleep, or not composing yet). - AnswerNone = "none" - // AnswerStale: the node answered an earlier head or revision of the candidate. - AnswerStale = "stale" -) - -// CandidateRow is one placement candidate and the node's answer to it. -type CandidateRow struct { - Group string `json:"group"` - Node string `json:"node"` - Pull delivery.Head `json:"pull"` - Revision uint64 `json:"revision"` - Placement json.RawMessage `json:"placement,omitempty"` // the candidate value as stored - Removes bool `json:"removes,omitempty"` // the head removes the node's file - Answered string `json:"answered"` // same, differs, refused, stale or none - // Answer is the node's answer as its node wrote it, whole: what this view's mesh-sdk does not know rides through (issue - // 1126, probe #532: each difference's bodies were dropped when the answer was decoded and encoded again here). - Answer json.RawMessage `json:"answer,omitempty"` -} - -// PlacementCandidates is every placement candidate, narrowed by node and by a module its placement names, with each -// node's answer; beside each group's keys, a row for every other node placed at the group's head or on the trunk, whose -// answer counts by the head alone (its file is unchanged, so it has no key, ADR 0559 §5, §7). A key that does not read is -// a row naming only its key's group and node, answered none, never dropped. -func (r Reader) PlacementCandidates(node, module string) ([]CandidateRow, error) { - st := r.PlacementCandidatesState - if st.Keys == nil || st.GetEntry == nil { - return []CandidateRow{}, nil - } - keys, err := st.Keys() - if err != nil { - return nil, fmt.Errorf("reading the placement candidates: %w", err) - } - sort.Strings(keys) - // The trunk's placed nodes, each with its modules. - trunk := map[string][]string{} - if r.PlacementState.Keys != nil && r.PlacementState.Get != nil { - if tkeys, err := r.PlacementState.Keys(); err == nil { - for _, k := range tkeys { - n, err := delivery.ParsePlacementKey(k) - if err != nil { - continue - } - if raw, err := r.PlacementState.Get(k); err == nil && len(raw) > 0 { - if p, err := delivery.DecodePlacement(raw); err == nil { - trunk[n] = p.Assigned() - } - } - } - } - } - type group struct { - pull delivery.Head - keyed map[string]bool - placed map[string]bool - } - groups := map[string]*group{} - answers := map[string]map[string]storedAnswer{} // node, then group - answerOf := func(n, g string) (storedAnswer, bool) { - if _, read := answers[n]; !read { - answers[n] = r.candidateAnswers(n) - } - a, ok := answers[n][g] - return a, ok - } - rows := []CandidateRow{} - for _, key := range keys { - gname, n, err := delivery.ParsePlacementCandidateKey(key) - if err != nil { - continue - } - raw, rev, err := st.GetEntry(key) - if err != nil { - return nil, fmt.Errorf("reading %s: %w", key, err) - } - if len(raw) == 0 { - continue - } - g := groups[gname] - if g == nil { - g = &group{keyed: map[string]bool{}, placed: map[string]bool{}} - groups[gname] = g - } - g.keyed[n] = true - row := CandidateRow{Group: gname, Node: n, Revision: rev, Placement: raw, Answered: AnswerNone} - c, err := delivery.DecodePlacementCandidate(raw) - if err == nil { - g.pull = c.Pull - if c.Placement != nil { - g.placed[n] = true - } - row.Pull, row.Removes = c.Pull, c.Placement == nil - if module != "" && (c.Placement == nil || !slices.Contains(c.Placement.Assigned(), module)) { - continue - } - } else if module != "" { - continue - } - if node != "" && n != node { - continue - } - if a, ok := answerOf(n, gname); ok && err == nil { - row.Answer = a.raw - row.Answered = AnswerOf(c.Pull.Head, rev, true, a.a) - } - rows = append(rows, row) - } - for _, gname := range slices.Sorted(maps.Keys(groups)) { - g := groups[gname] - if g.pull.Head == "" { - continue - } - others := map[string]bool{} - for n := range trunk { - others[n] = true - } - for n := range g.placed { - others[n] = true - } - for _, n := range slices.Sorted(maps.Keys(others)) { - if g.keyed[n] || (node != "" && n != node) || (module != "" && !slices.Contains(trunk[n], module)) { - continue - } - row := CandidateRow{Group: gname, Node: n, Pull: g.pull, Answered: AnswerNone} - if a, ok := answerOf(n, gname); ok { - row.Answer = a.raw - row.Answered = AnswerOf(g.pull.Head, 0, false, a.a) - } - rows = append(rows, row) - } - } - return rows, nil -} - -// CandidateSummary is one placement candidate in the compact form of indexer_placement (summary: true): the node's answer -// counted and its differing resources named, without the candidate's value or any resource body. -type CandidateSummary struct { - Group string `json:"group"` - Node string `json:"node"` - Pull delivery.Head `json:"pull"` - Revision uint64 `json:"revision"` - Removes bool `json:"removes,omitempty"` - Answered string `json:"answered"` - // Verdict, Head and the counts are the node's answer as it wrote it; Differs names each differing module and, in - // Resources, each differing resource with why and the JSON paths that differ when the node says them. - Verdict string `json:"verdict,omitempty"` - Head string `json:"head,omitempty"` - Same int `json:"same"` - Differing int `json:"differing"` - Differs []map[string]any `json:"differs,omitempty"` - Added int `json:"added,omitempty"` - Removed int `json:"removed,omitempty"` - Refused string `json:"refused,omitempty"` - Refusals json.RawMessage `json:"refusals,omitempty"` -} - -// summaryKeep are the keys a summary keeps of a differing module and of a differing resource; a body (composed, running) -// is not among them, and a key a newer node adds is left out until it is named here. -var ( - summaryModuleKeys = []string{"module", "resource", "why", "paths"} - summaryResourceKeys = []string{"id", "why", "paths"} -) - -// SummarizeCandidates is the compact form of rows: per candidate the answer's verdict, the counts of same and differing -// resources, and per differing resource its module, id, why and paths list when the node says one, with no body. -func SummarizeCandidates(rows []CandidateRow) []CandidateSummary { - out := make([]CandidateSummary, 0, len(rows)) - pick := func(m map[string]json.RawMessage, keys []string) map[string]any { - o := map[string]any{} - for _, k := range keys { - if v, ok := m[k]; ok { - o[k] = v - } - } - return o - } - for _, row := range rows { - sum := CandidateSummary{Group: row.Group, Node: row.Node, Pull: row.Pull, Revision: row.Revision, Removes: row.Removes, Answered: row.Answered} - var a struct { - Verdict string `json:"verdict"` - Head string `json:"head"` - Same []json.RawMessage `json:"same"` - Differs []map[string]json.RawMessage `json:"differs"` - Added []json.RawMessage `json:"added"` - Removed []json.RawMessage `json:"removed"` - Refusals json.RawMessage `json:"refusals"` - Refused string `json:"refused"` - } - if len(row.Answer) > 0 && json.Unmarshal(row.Answer, &a) == nil { - sum.Verdict, sum.Head, sum.Same, sum.Added, sum.Removed = a.Verdict, a.Head, len(a.Same), len(a.Added), len(a.Removed) - sum.Refused, sum.Refusals = a.Refused, a.Refusals - for _, d := range a.Differs { - m := pick(d, summaryModuleKeys) - var bodies []map[string]json.RawMessage - if json.Unmarshal(d["bodies"], &bodies) == nil && len(bodies) > 0 { - res := make([]map[string]any, 0, len(bodies)) - for _, b := range bodies { - res = append(res, pick(b, summaryResourceKeys)) - } - m["resources"] = res - sum.Differing += len(bodies) - } else { - sum.Differing++ - } - sum.Differs = append(sum.Differs, m) - } - } - out = append(out, sum) - } - return out -} - -// AnswerOf is the state of a node's answer a to its group's candidates at head: its verdict when it answers that head -// and, for a node with a key of its own (keyed), that key's revision rev; AnswerStale otherwise. -func AnswerOf(head string, rev uint64, keyed bool, a delivery.CandidateAnswer) string { - if a.Head != head || (keyed && a.Revision != rev) { - return AnswerStale - } - return a.Verdict -} - -// storedAnswer is a node's answer to one group: as stored, and read for its head, revision and verdict. -type storedAnswer struct { - raw json.RawMessage - a delivery.CandidateAnswer -} - -// candidateAnswers is a node's answers to its candidates, by group, from its `.node` have, each as stored; none -// when it has none. -func (r Reader) candidateAnswers(node string) map[string]storedAnswer { - if r.HavesState.Get == nil { - return nil - } - key, err := delivery.HaveNodeKey(node) - if err != nil { - return nil - } - raw, err := r.HavesState.Get(key) - if err != nil || len(raw) == 0 { - return nil - } - if _, err := delivery.DecodeHaveNode(raw); err != nil { - return nil - } - var stored struct { - Candidates map[string]json.RawMessage `json:"candidates"` - } - if json.Unmarshal(raw, &stored) != nil { - return nil - } - out := make(map[string]storedAnswer, len(stored.Candidates)) - for g, ans := range stored.Candidates { - var a delivery.CandidateAnswer - if json.Unmarshal(ans, &a) == nil { - out[g] = storedAnswer{raw: ans, a: a} - } - } - return out -} diff --git a/modules/mesh-delivery-view/internal/view/usage.go b/modules/mesh-delivery-view/internal/view/usage.go index 35e1fbc..49787a3 100644 --- a/modules/mesh-delivery-view/internal/view/usage.go +++ b/modules/mesh-delivery-view/internal/view/usage.go @@ -21,7 +21,6 @@ import ( "git.novox.be/novox/mesh-sdk/go/buildstep" "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/delivery/buildrun" ) // BuildUsageTool is the view's tool of the build nodes' use. @@ -261,7 +260,7 @@ func (u UsageReader) Usage() UsageAnswer { } // Capacity: mesh-config's resolved value of the module's `capacity` on each node. - resolved := map[string]string{} // . -> setting + resolved := map[string]int{} // . -> its capacity unknown := "" // why this tool cannot say a capacity: mesh-config's values cannot be read here unreadable := map[string]string{} // . -> why its resolved key holds no readable value: the puller uses 0 if u.Resolved.Keys == nil || u.Resolved.Get == nil { @@ -288,7 +287,7 @@ func (u UsageReader) Usage() UsageAnswer { Source string `json:"source"` } key := strings.TrimSuffix(k, "."+buildstep.CapacitySetting) - setting := "" + n := 0 switch { case i >= len(raws) || len(raws[i]) == 0: unreadable[key] = "its resolved key holds no value" @@ -296,10 +295,11 @@ func (u UsageReader) Usage() UsageAnswer { unreadable[key] = "its resolved key is not a resolved value" case r.Source == "unreadable": unreadable[key] = "mesh-config resolves it as unreadable" - case len(r.Value) == 0 || string(r.Value) == "null" || json.Unmarshal(r.Value, &setting) != nil || strings.TrimSpace(setting) == "": - unreadable[key] = fmt.Sprintf("its resolved value %s is empty or not a capacity", strings.TrimSpace(string(r.Value))) + case len(r.Value) == 0 || string(r.Value) == "null" || json.Unmarshal(r.Value, &n) != nil || n < 0: + // The capacity is the node's integer on its layer of mesh-config (novox/hq ADR 0567 §12). + unreadable[key] = fmt.Sprintf("its resolved value %s is not a whole number of asks at once", strings.TrimSpace(string(r.Value))) default: - resolved[key] = setting + resolved[key] = n } } } @@ -310,34 +310,10 @@ func (u UsageReader) Usage() UsageAnswer { node, module, _ := strings.Cut(key, ".") seat(node, strings.TrimPrefix(module, "mesh-build-")) } - // Every node a capacity names is listed, so "is this node eligible" is answered for any node: the nodes each module's - // resolved value names (as resolved on the first node in name order), or the modules' default when mesh-config - // resolves none for the module. - keys := make([]string, 0, len(resolved)) + // Every node with a resolved capacity is listed, so "is this node eligible" is answered for it. for key := range resolved { - keys = append(keys, key) - } - sort.Strings(keys) - named := map[string]string{} // language -> the module's setting, as resolved on some node - namedOn := map[string]string{} // language -> that node - for _, key := range keys { node, module, _ := strings.Cut(key, ".") - lang := strings.TrimPrefix(module, "mesh-build-") - seat(node, lang) - if _, ok := named[lang]; !ok { - named[lang], namedOn[lang] = resolved[key], node - } - } - for _, lang := range delivery.AskLanguages { - setting, ok := named[lang] - if !ok { - setting = buildstep.DefaultCapacities - } - if caps, err := buildrun.ParseCapacity(setting); err == nil { - for node := range caps { - seat(node, lang) - } - } + seat(node, strings.TrimPrefix(module, "mesh-build-")) } // The steps of each build module. steps := map[string]map[string][]usageStep{} // node -> language -> steps @@ -449,30 +425,17 @@ func (u UsageReader) Usage() UsageAnswer { if s == nil { continue } - setting, ok := resolved[node+"."+s.Module] - from := "mesh-config, resolved for " + s.Module + " on " + node - if _, has := named[lang]; !ok && has { - from = "mesh-config, resolved for " + s.Module + " on " + namedOn[lang] + " (" + s.Module + " is not assigned to " + node + ")" - // mesh-config resolves the module's value only where it is assigned; elsewhere its capacity is what that - // value names for the node. - setting, ok = named[lang], true - } + capacity, ok := resolved[node+"."+s.Module] bad, isBad := unreadable[node+"."+s.Module] - if unknown != "" { + switch { + case unknown != "": s.CapacityFrom = "capacity unknown to this tool: " + unknown - } else if isBad { + case isBad: s.CapacityFrom = "mesh-config's value for " + s.Module + " on " + node + " cannot be read (" + bad + "), so its puller uses 0" - } else if ok { - s.CapacityFrom = from - if caps, err := buildrun.ParseCapacity(setting); err != nil { - s.CapacityFrom = fmt.Sprintf("mesh-config's value %q, which cannot be read, so the module pulls nothing: %v", setting, err) - } else { - s.Capacity = (&buildrun.Places{Capacity: caps}).CapacityOf(node) - } - } else { - caps, _ := buildrun.ParseCapacity(buildstep.DefaultCapacities) - s.Capacity = (&buildrun.Places{Capacity: caps}).CapacityOf(node) - s.CapacityFrom = fmt.Sprintf("the modules' default %q: mesh-config holds no resolved value for %s on %s", buildstep.DefaultCapacities, s.Module, node) + case ok: + s.Capacity, s.CapacityFrom = capacity, "mesh-config, resolved for "+s.Module+" on "+node + default: + s.CapacityFrom = fmt.Sprintf("the modules' default 0: mesh-config holds no resolved value for %s on %s", s.Module, node) } if c, ok := judged[node+"."+lang]; ok && s.Holds && unknown == "" && !isBad && c.Configured == s.Capacity { // The module running on the node says its build hours, and judges by less than mesh-config gives when its diff --git a/modules/mesh-delivery-view/internal/view/usage_test.go b/modules/mesh-delivery-view/internal/view/usage_test.go index dc33ff1..2762f29 100644 --- a/modules/mesh-delivery-view/internal/view/usage_test.go +++ b/modules/mesh-delivery-view/internal/view/usage_test.go @@ -48,9 +48,10 @@ func step(node, id, tool, state string, started, ended time.Time) string { return fmt.Sprintf(`{"v":1,"id":%q,"tool":%q,"node":%q,"state":%q,"started":%q%s}`, id, tool, node, state, started.Format(time.RFC3339), e) } -// usageFixture is four nodes: shanks builds Go at capacity 2 (two asks running, one left running, a try's ask, a check, -// a recheck of the morning and a hand step ended); ace holds the Go seat at capacity 2 and is paused; g14 holds it at -// capacity 0; the Node seat on shanks has no resolved capacity and takes the modules' default. +// usageFixture is three nodes, each capacity the node's integer on its layer of mesh-config (ADR 0567 §12): shanks builds +// Go at capacity 2 (two asks running, one left running, a try's ask, a check, a recheck of the morning and a hand step +// ended); ace holds the Go seat at capacity 2 and is paused, and its Node capacity is still of the old form; g14 holds the +// Go seat at capacity 0; the Node seat on shanks has no resolved capacity and takes the modules' default 0. func usageFixture() UsageReader { running := `{"v":1,"phase":"running","health":"ok"}` tryAsk := stepID(at("11:30"), "a00003") @@ -74,9 +75,10 @@ func usageFixture() UsageReader { Haves: states(map[string]string{"shanks.mesh-build-go": running, "shanks.mesh-build-node": running, "ace.mesh-build-go": running, "g14.mesh-build-go": running, "g14.mesh-build-node": running, "shanks.node": `{}`, "shanks.mesh-delivery-view": running}), Resolved: states(map[string]string{ - "shanks.mesh-build-go.capacity": `{"node":"shanks","module":"mesh-build-go","key":"capacity","value":"shanks=2,ace=2,g14=0,novox=0","source":"mesh"}`, - "ace.mesh-build-go.capacity": `{"node":"ace","module":"mesh-build-go","key":"capacity","value":"shanks=2,ace=2,g14=0,novox=0","source":"mesh"}`, - "g14.mesh-build-go.capacity": `{"node":"g14","module":"mesh-build-go","key":"capacity","value":"shanks=2,ace=2,g14=0,novox=0","source":"mesh"}`, + "shanks.mesh-build-go.capacity": `{"node":"shanks","module":"mesh-build-go","key":"capacity","value":2,"source":"node"}`, + "ace.mesh-build-go.capacity": `{"node":"ace","module":"mesh-build-go","key":"capacity","value":2,"source":"node"}`, + "g14.mesh-build-go.capacity": `{"node":"g14","module":"mesh-build-go","key":"capacity","value":0,"source":"node"}`, + "ace.mesh-build-node.capacity": `{"node":"ace","module":"mesh-build-node","key":"capacity","value":"shanks=2,ace=2","source":"mesh"}`, "shanks.mesh-config.other": `{"value":"x"}`, "g14.mesh-build-node.capacity": `{"node":"g14","module":"mesh-build-node","key":"capacity","source":"unreadable"}`, }), @@ -122,17 +124,16 @@ func TestBuildUsageAnswersEveryNodesUseFromTheStates(t *testing.T) { what string got, want any }{ - {"nodes, novox named by the default capacity", len(a.Nodes), 4}, - {"eligible", eligible, map[string]string{"shanks": "eligible", "ace": "not eligible", "g14": "not eligible", "novox": "not eligible"}}, + {"nodes, none named by a default", len(a.Nodes), 3}, + {"eligible", eligible, map[string]string{"shanks": "eligible", "ace": "not eligible", "g14": "not eligible"}}, {"a resolved key with no readable value: the puller uses 0", seatOf(t, a, "g14", "node-build-node").WhyNot, "its capacity on g14 cannot be read, so its puller uses 0"}, {"and is said so", seatOf(t, a, "g14", "node-build-node").CapacityFrom, "mesh-config's value for mesh-build-node on g14 cannot be read (mesh-config resolves it as unreadable), so its puller uses 0"}, - {"novox why not", seatOf(t, a, "novox", "node-build-go").WhyNot, "mesh-build-go is not on novox"}, - {"novox capacity, from the module's value resolved elsewhere", seatOf(t, a, "novox", "node-build-go").CapacityFrom, "mesh-config, resolved for mesh-build-go on ace (mesh-build-go is not assigned to novox)"}, + {"a capacity of the old form naming nodes is unreadable", seatOf(t, a, "ace", "node-build-node").CapacityFrom, `mesh-config's value for mesh-build-node on ace cannot be read (its resolved value "shanks=2,ace=2" is not a whole number of asks at once), so its puller uses 0`}, {"ended by restart or bound, apart", len(sg.EndedStale), 2}, {"ace why not", seatOf(t, a, "ace", "node-build-go").WhyNot, "paused (node-build-go.resume takes asks again)"}, {"g14 why not", seatOf(t, a, "g14", "node-build-go").WhyNot, "its capacity on g14 is 0"}, {"shanks capacity", sg.Capacity, 2}, - {"shanks node seat takes the default", seatOf(t, a, "shanks", "node-build-node").Capacity, 3}, + {"shanks node seat takes the default", seatOf(t, a, "shanks", "node-build-node").Capacity, 0}, {"running", sg.Running.Total, 2}, {"running by kind", sg.Running.ByKind, map[string]int{"delivery": 1, "check": 1}}, {"running steps, the hand step's ended", len(sg.Steps), 2}, @@ -152,7 +153,7 @@ func TestBuildUsageAnswersEveryNodesUseFromTheStates(t *testing.T) { {"backlog", a.Backlog, []BacklogRow{{Language: "go", Kind: "check", Waiting: 3, Held: 2, OldestSecs: 240}}}, {"tries running", len(a.TriesRunning), 1}, {"said", a.Nodes[0].Said[:1], []string{"node-build-go: not eligible, paused (node-build-go.resume takes asks again); 0 of capacity 2 running; last hour 0 finished, median 0 s, at capacity 0% of it; last day 0 finished, median 0 s, at capacity 0.0% of it"}}, - {"said shanks", a.Nodes[3].Said[0], "node-build-go: eligible; 2 of capacity 2 running (check 1, delivery 1), 1 left running; last hour 3 finished, median 360 s, at capacity 8% of it; last day 4 finished, median 360 s, at capacity 0.3% of it"}, + {"said shanks", a.Nodes[2].Said[0], "node-build-go: eligible; 2 of capacity 2 running (check 1, delivery 1), 1 left running; last hour 3 finished, median 360 s, at capacity 8% of it; last day 4 finished, median 360 s, at capacity 0.3% of it"}, {"cpu says where it is", strings.Contains(a.Nodes[0].CPU, "/disk-load.cpu_top"), true}, } { if fmt.Sprint(c.got) != fmt.Sprint(c.want) { diff --git a/modules/mesh-delivery-view/manifest_test.go b/modules/mesh-delivery-view/manifest_test.go index eab7c9c..50985b4 100644 --- a/modules/mesh-delivery-view/manifest_test.go +++ b/modules/mesh-delivery-view/manifest_test.go @@ -87,7 +87,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 if m.Module != "mesh-delivery-view" { t.Errorf("module = %q", m.Module) } - want := []string{"mesh-build-go.pauses", "mesh-build-go.steps", "mesh-build-image.pauses", "mesh-build-image.steps", "mesh-build-node.pauses", "mesh-build-node.steps", "mesh-config.resolved", "mesh-controller.assignments", "mesh-controller.builds", "mesh-controller.composed", "mesh-delivery-assigner.asks", "mesh-delivery-assigner.differences", "mesh-delivery-assigner.wants", "mesh-delivery-builder.builds", "mesh-delivery-checker.tries", "mesh-delivery-gate.asks", "mesh-delivery-gate.source", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.heard", "mesh-delivery-indexer.modules", "mesh-delivery-indexer.placement", "mesh-delivery-indexer.placement-candidates", "mesh-delivery-indexer.taken", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "node-engine.have"} // indexer_placement reads the placement candidates and every .node (ADR 0559 §7); build_usage reads the build modules' steps and pauses, mesh-config's capacities and the checker's tries; 1168/AC1, 1241/AC4, 1345/AC3, 1297/B8, B9 + want := []string{"mesh-build-go.pauses", "mesh-build-go.steps", "mesh-build-image.pauses", "mesh-build-image.steps", "mesh-build-node.pauses", "mesh-build-node.steps", "mesh-config.resolved", "mesh-controller.assignments", "mesh-controller.builds", "mesh-controller.composed", "mesh-delivery-assigner.asks", "mesh-delivery-assigner.differences", "mesh-delivery-assigner.wants", "mesh-delivery-builder.builds", "mesh-delivery-checker.tries", "mesh-delivery-gate.asks", "mesh-delivery-gate.source", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.heard", "mesh-delivery-indexer.modules", "mesh-delivery-indexer.taken", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "node-engine.have"} // build_usage reads the build modules' steps and pauses, mesh-config's capacities and the checker's tries; 1168/AC1, 1241/AC4, 1345/AC3, 1297/B8, B9 got := slices.Clone(m.Reads) slices.Sort(got) if !reflect.DeepEqual(got, want) { @@ -119,7 +119,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 } // The two read tools of the indexer's modules (issue 1562), carried by a bundle the runtime launches; the indexer // itself serves no tool (ADR 0437 §4). Carrying them gives the tool runner read access to every bucket the view reads. - if want := []string{"indexer_module", "indexer_modules", "indexer_placement", "indexer_candidate", "indexer_candidates", "builder_build", "builder_builds", "assigner_wants", "assigner_want", "assigner_differences", "node_haves", "build_usage", "view_reach", "indexer_module_history", "indexer_heard"}; !reflect.DeepEqual(m.Tools, want) { + if want := []string{"indexer_module", "indexer_modules", "indexer_candidate", "indexer_candidates", "builder_build", "builder_builds", "assigner_wants", "assigner_want", "assigner_differences", "node_haves", "build_usage", "view_reach", "indexer_module_history", "indexer_heard"}; !reflect.DeepEqual(m.Tools, want) { t.Errorf("tools = %v, want %v", m.Tools, want) } for _, a := range m.Build.Artifacts { diff --git a/modules/mesh-delivery-view/module.json b/modules/mesh-delivery-view/module.json index e8ea5a0..475f6ca 100644 --- a/modules/mesh-delivery-view/module.json +++ b/modules/mesh-delivery-view/module.json @@ -6,7 +6,6 @@ "tools": [ "indexer_module", "indexer_modules", - "indexer_placement", "indexer_candidate", "indexer_candidates", "builder_build", @@ -51,8 +50,6 @@ "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.heard", "mesh-delivery-indexer.modules", - "mesh-delivery-indexer.placement", - "mesh-delivery-indexer.placement-candidates", "mesh-delivery-indexer.taken", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", diff --git a/modules/mesh-grants/internal/grants/inputs.go b/modules/mesh-grants/internal/grants/inputs.go index 0634fe5..f8dcbd7 100644 --- a/modules/mesh-grants/internal/grants/inputs.go +++ b/modules/mesh-grants/internal/grants/inputs.go @@ -34,9 +34,9 @@ const ( ReadBuilds = "mesh-delivery-builder.builds" // ReadAccounts is the vault's bcrypt hashes of the bus accounts it holds, by bus user (ADR 0547 clause 9). ReadAccounts = "mesh-vault.bus-accounts" - // ReadPlacement is the indexer's placement, read from the catalogue (ADR 0549 §2, §3): each node's modules and the seats + // ReadPlacement is the seat mesh-placement's placement (ADR 0567 §1, §9): each node's modules and the seats // they hold. - ReadPlacement = "mesh-delivery-indexer.placement" + ReadPlacement = delivery.PlacementRead ) // Reads is every state the part reads, in the manifest's order. @@ -109,7 +109,7 @@ type Inputs struct { // Assigned is each machine's standing assignments and what the catalogue's placement puts there, sorted: while both // stand (ADR 0549 §5) a module either names keeps its membership. Assigned map[string][]string - // Placed is each node's placement as the indexer read it from the catalogue (ADR 0549 §2); empty before any is read. + // Placed is each node's placement as the seat mesh-placement holds it (ADR 0567 §1); empty before any is read. // Where it is, a seat held once for the mesh is held where placement says, not wherever a module claiming it runs. Placed map[string]delivery.Placement // Manifests is each module's manifest at the indexer's trunk, by module. @@ -237,7 +237,7 @@ func ReadInputs(r Reader, cache *Manifests) Inputs { in.BusNode = a.Node } } - // The catalogue's placement (ADR 0549 §3): what it places is composed beside the assignments, and the seats held once + // The seat's placement (ADR 0567 §9): what it places is composed beside the assignments, and the seats held once // for the mesh are held where it says. Not on the bus yet is a standing gap, never a failed read, so the list is still // composed and sealed from the assignments alone. if keys, err := r.Keys(ReadPlacement); err != nil { diff --git a/modules/mesh-grants/module.json b/modules/mesh-grants/module.json index 65d06b0..785008e 100644 --- a/modules/mesh-grants/module.json +++ b/modules/mesh-grants/module.json @@ -10,7 +10,7 @@ "mesh-delivery-builder.builds", "mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules", - "mesh-delivery-indexer.placement", + "mesh-delivery-assigner.placement", "node-engine.have", "mesh-vault.bus-accounts" ],