From bbd872f519127af8a3dda6e066b74e90ef26d055 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 06:03:49 +0100 Subject: [PATCH] AS21: the assigner follows the watched branches the gate's source and the registrations name, reads no gate bucket and names no branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The assigner follows the catalog half through meshcatalogclient's branch follower (the registrations' branches, before the first registrations those of the gate's verb `source` and its event `source-moved`, §507, §532): its trunk is the first watched branch, never a fixed "main" (§7). It reads neither the gate's `source` nor `merges` (§431); the Approve of a release is no yes to a move (the release lookup goes). The follower tells a part of each branch it adopts (Branches.Adopted). Builds: design 47 G §507, §532, §431, §7 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../buildends_helper_test.go | 3 + .../cmd/mesh-delivery-assigner/graphkept.go | 5 +- .../cmd/mesh-delivery-assigner/halves.go | 111 ++++++++++++------ .../integration_test.go | 24 ++++ .../cmd/mesh-delivery-assigner/main.go | 24 +--- .../cmd/mesh-delivery-assigner/pass.go | 22 +--- .../cmd/mesh-delivery-assigner/pass_test.go | 84 ++++--------- .../cmd/mesh-delivery-assigner/pipelines.go | 7 +- .../mesh-delivery-assigner/pipelines_test.go | 3 + .../cmd/mesh-delivery-assigner/views.go | 4 +- .../cmd/mesh-delivery-assigner/world.go | 67 ++++------- .../cmd/mesh-delivery-assigner/world_test.go | 31 +++++ .../internal/assign/wants.go | 4 - .../internal/buckets/buckets.go | 4 - .../internal/buckets/buckets_test.go | 5 +- .../mesh-delivery-assigner/manifest_test.go | 11 +- modules/mesh-delivery-assigner/module.json | 4 +- .../go/clients/meshcatalogclient/branches.go | 6 + .../meshcatalogclient/branches_test.go | 18 +++ 19 files changed, 232 insertions(+), 205 deletions(-) diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/buildends_helper_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/buildends_helper_test.go index 0f12d2a2..ce5afcda 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/buildends_helper_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/buildends_helper_test.go @@ -19,3 +19,6 @@ func endDone(t testing.TB, w *world, module, fp, manifest string) { t.Fatal(err) } } + +// trunkBranch is the first watched branch the tests give the world: the code names none (design 47 G §507). +const trunkBranch = "main" diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphkept.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphkept.go index c013f018..4fc83d69 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphkept.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphkept.go @@ -44,7 +44,8 @@ func (p *passer) keepGraph(ctx context.Context) { } in := assigngraph.Inputs{Halves: g.halves(), Placements: p.w.assignments(), Capabilities: p.capabilityDigests()} // With no pipeline ends kept, the wants are the trunk-only form (pass.go): a placement naming no branch runs the trunk's. - in.FirstBranch = func(string) string { return trunkBranch } + first := p.w.trunkName() + in.FirstBranch = func(string) string { return first } if p.ends != nil { in.FirstBranch = func(module string) string { return p.ends.branchOf(module, "") } } @@ -66,7 +67,7 @@ func (p *passer) keepGraph(ctx context.Context) { g.last, g.has, g.kept, g.ok = c.Revision, true, rev, true g.source = 0 for _, cat := range rev.Catalogs { - if cat.Branch == trunkBranch { + if cat.Branch == p.w.trunkName() { g.source = cat.Source } } diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/halves.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/halves.go index 8d617071..83ce5f4f 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/halves.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/halves.go @@ -16,11 +16,12 @@ import ( "git.novox.be/novox/mesh-sdk/go/views" ) -// halves follows the catalog half of each watched branch through the seat mesh-catalog (design 47 G §14, §184, §195): one -// meshcatalogclient.Catalog per branch, adopted through the module's views reader (A-26), which asks the first view of the verb -// `revision`, hears each `catalog-revision` of that branch and raises and clears the follower's findings; each entry is taken -// into the world as the value and the graph node the rules read. Of the view the assigner reads only the catalog half, never its -// own output back (§195). +// halves follows the catalog half of each watched branch through the seat mesh-catalog (design 47 G §14, §184, §195, §507, §532): the +// branch follower of meshcatalogclient, adopted on the module's views reader (A-26), follows the branches the registrations name +// and, before the first registrations, the branches the gate's verb `source` answers and its event `source-moved` brings; each +// branch's view asks the verb `revision`, hears each `catalog-revision` of that branch and raises and clears the follower's findings; +// each entry is taken into the world as the value and the graph node the rules read. Of the view the assigner reads only the catalog +// half, never its own output back (§195). type halves struct { w *world call mcc.Call @@ -29,42 +30,70 @@ type halves struct { logf func(string, ...any) ctx context.Context - mu sync.Mutex - cats map[string]*mcc.Catalog - // settled is told once when a branch's first view is answered, for the first pass to wait on (the trunk's alone). + branches *mcc.Branches + mu sync.Mutex + // settled is told once when the first watched branch's first view is answered, for the first pass to wait on. settled chan<- struct{} + told bool } -// follow adopts the catalog of branch through the reader, once; true when it was made now. -func (h *halves) follow(branch string) bool { - h.mu.Lock() - if h.cats[branch] != nil { - h.mu.Unlock() - return false - } - c, _, err := mcc.WireCatalogProcess(h.reader, h.call, branch, func(module string, entry json.RawMessage, deleted bool, _ time.Time) { - h.changed(branch, module, entry, deleted) - }) +// follow makes the branch follower on the reader; the reader's start asks the first views. +func (h *halves) follow() error { + b, err := mcc.FollowBranchesProcess(h.reader, h.call) if err != nil { - h.mu.Unlock() - h.logf("the catalog half of branch %q is not followed: %v", branch, err) - return false + return err } - if h.cats == nil { - h.cats = map[string]*mcc.Catalog{} + b.Logf = h.logf + b.Changed = func(branch, module string, entry json.RawMessage, deleted bool, _ time.Time) { + h.changed(branch, module, entry, deleted) } - h.cats[branch] = c + b.Adopted = h.adopted + h.mu.Lock() + h.branches = b h.mu.Unlock() - if branch == trunkBranch && h.settled != nil { - go func() { - select { - case <-c.Ready(): - h.settled <- struct{}{} - case <-h.ctx.Done(): - } - }() + return nil +} + +// first is the first watched branch followed, the trunk's: "" while none is. +func (h *halves) first() string { + h.mu.Lock() + b := h.branches + h.mu.Unlock() + if b == nil { + return "" } - return true + if f := b.Followed(); len(f) > 0 { + return f[0] + } + return "" +} + +// adopted is told of each branch whose view the follower adopted: the first branch is the trunk's, and the first pass waits for its +// first view. +func (h *halves) adopted(branch string, c *mcc.Catalog) { + first := h.first() + if first == "" { + return + } + h.w.setTrunk(first) + h.wake() + if branch != first || h.settled == nil { + return + } + h.mu.Lock() + told := h.told + h.told = true + h.mu.Unlock() + if told { + return + } + go func() { + select { + case <-c.Ready(): + h.settled <- struct{}{} + case <-h.ctx.Done(): + } + }() } // changed takes one entry of branch's half into the world, or a module taken out of it. @@ -114,11 +143,17 @@ func fromEntry(e meshcatalog.CatalogEntry) (meshcatalog.ModulesByModuleValue, gr // views is the catalog graph revision held now of each followed branch whose first view is answered. func (h *halves) views() map[string]mcc.Half { h.mu.Lock() - defer h.mu.Unlock() - out := make(map[string]mcc.Half, len(h.cats)) - for branch, c := range h.cats { - if half, ok := c.View(); ok { - out[branch] = half + b := h.branches + h.mu.Unlock() + out := map[string]mcc.Half{} + if b == nil { + return out + } + for _, branch := range b.Followed() { + if c, ok := b.Catalog(branch); ok { + if half, ok := c.View(); ok { + out[branch] = half + } } } return out 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 1670fa6f..849d0215 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 @@ -44,6 +44,7 @@ import ( occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient" + mdseat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/announce" "git.novox.be/novox/mesh-sdk/go/buscompose" @@ -1954,6 +1955,29 @@ func serveCatalog(t *testing.T, b *bed, nc *nats.Conn) { raw, _ := json.Marshal(map[string]any{"result": meshcatalog.RevisionAnswer{Revision: &rev, Entries: entries}, "node": "n0"}) _ = m.Respond(raw) } + // The gate's verb `source` given no branch (design 47 G §507, §690): the branches it keeps a source revision of, which the assigner + // follows before the first registrations, and the trunk's `main` first. + source := func(m *nats.Msg) { + b.cat.mu.Lock() + defer b.cat.mu.Unlock() + names := map[string]bool{"main": true} + for branch := range b.cat.rev { + names[branch] = true + } + var escaped []string + for _, n := range slices.Sorted(maps.Keys(names)) { + escaped = append(escaped, keys.EscapeName(n)) + } + raw, _ := json.Marshal(map[string]any{"result": mdseat.SourceAnswer{Branches: escaped}, "node": "n0"}) + _ = m.Respond(raw) + } + for _, subject := range []string{"mesh.seat.mesh-delivery.tool.source", "mesh.seat.mesh-delivery.call.source.*", "mesh.seat.mesh-delivery.tool.source.*"} { + sub, err := nc.Subscribe(subject, source) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = sub.Unsubscribe() }) + } for _, subject := range []string{"mesh.seat.mesh-catalog.tool.revision", "mesh.seat.mesh-catalog.call.revision.*", "mesh.seat.mesh-catalog.tool.revision.*"} { sub, err := nc.Subscribe(subject, answer) if err != nil { 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 2c583b55..2b579639 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go @@ -66,8 +66,6 @@ var readBuckets = []string{ buckets.Presence, buckets.Have, buckets.Provisions, - buckets.Source, - buckets.Merges, } // readFamilies are the states kept one bucket per node the assigner reads, each named in its manifest's reads: every @@ -167,12 +165,6 @@ func open(ctx context.Context, js process.BucketOpener, logf func(string, ...any logf("the bucket %s cannot be opened (%v): the assigner's conditions are said straight to the router until it starts again", name, err) continue } - if (name == buckets.Source || name == buckets.Merges) && err != nil { - // The gate's source and merges (ADR 0601 §1): without them no release is known to carry a build, so every move of - // a record module is asked of a person, as before. - logf("the bucket %s cannot be opened (%v): no release's Approve answers a record ask until it is bound", name, err) - continue - } if name == engineReadsBucket && err != nil { // New with ADR 0618 §3: without it the statements the verb answers are kept in memory only, and // assigner_engine_reads lists nothing. @@ -214,7 +206,7 @@ func open(ctx context.Context, js process.BucketOpener, logf func(string, ...any func startsWithout(name string) bool { switch name { case plc.SeatHoldersBucket, buckets.Provisions, assign.PoliciesBucket, assign.FindingsBucket, - buckets.Puts, buckets.Source, buckets.Merges, engineReadsBucket, retiredBucket, writtenBucket, buckets.Waits, buckets.Inputs: + buckets.Puts, engineReadsBucket, retiredBucket, writtenBucket, buckets.Waits, buckets.Inputs: return true } return slices.Contains(seatBuckets, name) || name == buckets.Grants @@ -338,8 +330,6 @@ var watched = []string{ assign.PoliciesBucket, buckets.Provisions, plc.SeatHoldersBucket, - buckets.Source, - buckets.Merges, engineReadsBucket, writtenBucket, buckets.Waits, @@ -453,8 +443,8 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber, } started++ var opts []jetstream.WatchOpt - if name == buckets.Have || name == buckets.Source { - // what each node ran healthy before, for a set-back; and the release behind each source revision (ADR 0601 §1) + if name == buckets.Have { + // what each node ran healthy before, for a set-back opts = append(opts, jetstream.IncludeHistory()) } if err := watch(name, opts...); err != nil { @@ -562,13 +552,7 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber, // The runner's pipeline-ended and group-ended (design 47 G §29): kept in the state written, which the wants follow // once the first registrations are written (§95, §353). p.ends = newPipelineEnds(kvEnds{ctx, kv}, wake, logger.Printf) - if iv != nil && iv.h != nil { - p.ends.onBranches = func(branches []string) { - for _, b := range branches { - iv.h.follow(b) - } - } - } + p.ends.trunk = w.trunkName err := hearEnds(hub, p.ends) if err == nil && bus != nil { err = followRegistrations(ctx, hub, bus.Call, p.ends) 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 f6cfa1d5..8bcfa64d 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go @@ -247,7 +247,7 @@ func (p *passer) wants(ctx context.Context) { views[branch] = v return v } - nodes := viewOf(trunkBranch).nodes + nodes := viewOf(p.w.trunkName()).nodes p.engineReads(nodes) nodesOf := map[string][]string{} for node, modules := range assigned { @@ -319,8 +319,7 @@ func (p *passer) wants(ctx context.Context) { Allowed: func(node, digest string) bool { key, err := asks.RecordAskKey(node, name, digest) return err == nil && p.answeredWith(key, assign.OptionYes) - }, - Release: func(fp string) (string, bool) { return p.w.release(name, fp) }} + }} if fp != "" { held, _ := mwc.HeldKey(name, fp) grant, _ := grantsstate.PartGrantKey(name, fp) @@ -1061,9 +1060,8 @@ func (p *passer) backupAsk(ctx context.Context, key, node, module string, want m // yeses writes a placed node's wants naming the build alone, and sees to the moves a node that composes its own judged a // person must allow (ADR 0547 §3): for a have of phase // waiting-yes it raises the record ask once, naming the composition waiting and the node's judgement, and on a yes writes -// the want again naming it; a yes once written is kept while the want stays at its build, so the node is not asked again. A -// move of the build alone (the have says so) to a build an approved release carries is given its yes by that Approve, the -// want naming the release, with no ask (ADR 0601 §1). +// the want again naming it; a yes once written is kept while the want stays at its build, so the node is not asked again. No +// release's Approve is a yes: the Approve per merge goes and the assigner reads no `merges` (design 47 G §431). func (p *passer) yeses(ctx context.Context, module string, m assign.Module, before map[string]mwc.Want, out *assign.Outcome) { for _, node := range m.Nodes { if m.Self == nil || !m.Self(node) { @@ -1092,14 +1090,6 @@ func (p *passer) yeses(ctx context.Context, module string, m assign.Module, befo switch { case m.Allowed != nil && m.Allowed(node, h.Waits): yes, release = h.Waits, "" - case h.BuildAlone && h.Fp != w.Fp && m.Release != nil: - // A move of the build alone, to the build an approved release carries: that Approve is the yes (ADR 0601 §1), - // named in the want. A move that changes a setting or a secret too, or one the node could not tell, is asked. - if r, ok := m.Release(w.Fp); ok { - yes, release = h.Waits, r - break - } - p.recordAsk(ctx, module, w.Fp, assign.RecordNeed{Node: node, Digest: h.Waits, Judgement: h.Judgement, Words: h.Detail}) default: p.recordAsk(ctx, module, w.Fp, assign.RecordNeed{Node: node, Digest: h.Waits, Judgement: h.Judgement, Words: h.Detail}) } @@ -1409,14 +1399,14 @@ type group struct { func (p *passer) groups(module string, nodes []string, placed map[string]string) []group { by := map[string][]string{} for _, node := range nodes { - b := trunkBranch + b := p.w.trunkName() if p.ends.following() { b = p.ends.branchOf(module, placed[node]) } by[b] = append(by[b], node) } if len(by) == 0 { - b := trunkBranch + b := p.w.trunkName() if p.ends.following() { b = p.ends.branchOf(module, "") } 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 9eb18aea..67b4464f 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 @@ -22,7 +22,6 @@ import ( mdc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshdeliveryclient" mwc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" - mdseat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-teller/tellerevents" "git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate" @@ -69,7 +68,9 @@ func (m *memKV) Delete(_ context.Context, key string, _ ...jetstream.KVDeleteOpt // newPasser is a pass on a fresh world, as after a start, writing to the buckets given, at the clock given. func newPasser(kvs kvSet, at time.Time) *passer { - return &passer{kvs: kvs, w: newWorld(), logf: func(string, ...any) {}, said: map[string]bool{}, answeredAt: map[string]uint64{}, + w := newWorld() + w.setTrunk(trunkBranch) + return &passer{kvs: kvs, w: w, logf: func(string, ...any) {}, said: map[string]bool{}, answeredAt: map[string]uint64{}, now: func() time.Time { return at }} } @@ -1163,70 +1164,29 @@ func TestARecordAskOfAHeldBackNodeSurvivesPassesAndItsYesMovesTheWant(t *testing aReleasesApproveAnswersTheMoveOfItsBuildAlone(t) } -// ADR 0601 §1: a placed node's have waiting for a yes to a move of the build alone, to a build an approved release carries, is -// given its yes by that Approve, the want naming the release, and no record ask is raised; a move that changes a setting or a -// secret too, one at the build it runs, and one no approved release carries are asked of a person as before. +// Design 47 G §431: no release's Approve is a yes (the assigner reads no `merges`): a placed node's have waiting for a yes to a move +// of the build alone is asked of a person, the want naming no release and no yes. func aReleasesApproveAnswersTheMoveOfItsBuildAlone(t *testing.T) { fpOld := meshcatalogclient.SourcePrefix2 + strings.Repeat("a", 64) waiting, was := "sha256:"+strings.Repeat("e", 64), "sha256:"+strings.Repeat("d", 64) commit := strings.Repeat("1", 40) - for _, c := range []struct { - name string - alone bool - at string // the fingerprint the have runs - mergedAt string // the commit the release merged web at; "" for no merge of the release - fpSrc int64 // the source revision at which web's fingerprint last changed - yes, asked bool - }{ - {"the build alone, its release approved", true, fpOld, commit, 7, true, false}, - {"a setting changed too", false, fpOld, commit, 7, false, true}, - {"a setting changed at the build it runs", true, fpTest, commit, 7, false, true}, - {"no approved release carries it", true, fpOld, "", 7, false, true}, - {"the release merged another commit", true, fpOld, strings.Repeat("2", 40), 7, false, true}, - // Its fingerprint last moved at another release's revision, which merged nothing of web: that Approve answers nothing. - {"a fingerprint another release moved", true, fpOld, commit, 8, false, true}, - // Review rounds 1 and 2: moved by a recompute after the release (a toolchain, a package, a base) with no merge, the - // indexer writes fp-src 0, not known, though Src is still the release's: asked of a person. - {"a fingerprint moved by a recompute after the release", true, fpOld, commit, 0, false, true}, - } { - asksKV := &memKV{values: map[string][]byte{}} - p := newPasser(kvSet{buckets.Asks: asksKV}, time.Now()) - p.logf = t.Logf - mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fpTest, Repository: "novox/web", Commit: commit, Src: 7, FpSrc: c.fpSrc, Path: meshcatalogclient.RootPath}) - putModuleRaw(p.w, trunkBranch, "web", mod) - src, _ := mdc.EncodeSource(mdseat.SourceByBranchValue{Group: "feat/web", Heads: map[string]string{"novox/web": commit}}) - p.w.putEntry(buckets.Source, mdc.SourceKey(trunkBranch), src, 7, time.Now()) - later, _ := mdc.EncodeSource(mdseat.SourceByBranchValue{Group: "feat/other", Heads: map[string]string{"novox/web": commit, "novox/x": commit}}) - p.w.putEntry(buckets.Source, mdc.SourceKey(trunkBranch), later, 8, time.Now()) // a later merge leaves revision 7's release - if c.mergedAt != "" { - merge, err := mdc.EncodeMerge(mdseat.MergesByGroupValue{Group: "feat/web", Ask: "merge.feat_web.0123456789ab", Pulls: []mdseat.MergesByGroupValuePullsItem{ - {Repository: "novox/web", Number: 3, Head: strings.Repeat("3", 40), State: string(mdc.MergeMerged), Commit: c.mergedAt}}}) - if err != nil { - t.Fatal(err) - } - p.w.put(buckets.Merges, "feat/web", merge) - other, err := mdc.EncodeMerge(mdseat.MergesByGroupValue{Group: "feat/other", Ask: "merge.feat_other.0123456789ab", Pulls: []mdseat.MergesByGroupValuePullsItem{ - {Repository: "novox/x", Number: 4, Head: strings.Repeat("4", 40), State: string(mdc.MergeMerged), Commit: commit}}}) - if err != nil { - t.Fatal(err) - } - p.w.put(buckets.Merges, "feat/other", other) - } - have, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: c.at, Composed: was, Phase: nodeengineclient.PhaseWaitingYes, Health: nodeengineclient.HealthOK, - Judgement: assign.JudgedRecreates, Waits: waiting, Detail: "it recreates web.server", BuildAlone: c.alone}) - p.w.put(buckets.Have, "g14.web", have) - m := assign.Module{Name: "web", FP: fpTest, Nodes: []string{"g14"}, Self: func(string) bool { return true }, - Release: func(fp string) (string, bool) { return p.w.release("web", fp) }} - out := assign.Outcome{Wants: map[string]mwc.Want{"g14": {Fp: fpTest, Self: true}}} - p.yeses(context.Background(), "web", m, map[string]mwc.Want{"g14": {Fp: fpOld, Self: true}}, &out) - w := out.Wants["g14"] - if got := w.Yes == waiting && w.Release == "feat/web"; got != c.yes || (!c.yes && (w.Yes != "" || w.Release != "")) { - t.Errorf("%s: the want is %+v", c.name, w) - } - akey, _ := asks.RecordAskKey("g14", "web", waiting) - if _, there := asksKV.values[akey]; there != c.asked { - t.Errorf("%s: a record ask raised %v, want %v", c.name, there, c.asked) - } + asksKV := &memKV{values: map[string][]byte{}} + p := newPasser(kvSet{buckets.Asks: asksKV}, time.Now()) + p.logf = t.Logf + mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fpTest, Repository: "novox/web", Commit: commit, Src: 7, FpSrc: 7, Path: meshcatalogclient.RootPath}) + putModuleRaw(p.w, trunkBranch, "web", mod) + have, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fpOld, Composed: was, Phase: nodeengineclient.PhaseWaitingYes, Health: nodeengineclient.HealthOK, + Judgement: assign.JudgedRecreates, Waits: waiting, Detail: "it recreates web.server", BuildAlone: true}) + p.w.put(buckets.Have, "g14.web", have) + m := assign.Module{Name: "web", FP: fpTest, Nodes: []string{"g14"}, Self: func(string) bool { return true }} + out := assign.Outcome{Wants: map[string]mwc.Want{"g14": {Fp: fpTest, Self: true}}} + p.yeses(context.Background(), "web", m, map[string]mwc.Want{"g14": {Fp: fpOld, Self: true}}, &out) + if w := out.Wants["g14"]; w.Yes != "" || w.Release != "" { + t.Errorf("the want is %+v: no release's Approve is a yes", w) + } + akey, _ := asks.RecordAskKey("g14", "web", waiting) + if _, there := asksKV.values[akey]; !there { + t.Error("no record ask was raised for the move of the build alone") } } diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pipelines.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pipelines.go index 63951fc7..8ecc9bec 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pipelines.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pipelines.go @@ -153,6 +153,8 @@ type pipelineEnds struct { firsts map[string]string // onBranches is told the branches a registration names, for the catalog half of each to be followed (design 47 G §14, §195). onBranches func([]string) + // trunk names the first watched branch, the trunk's (design 47 G §507), the branch of a module registered with none. + trunk func() string } func newPipelineEnds(st endStore, wake func(), logf func(string, ...any)) *pipelineEnds { @@ -225,7 +227,10 @@ func (e *pipelineEnds) branchOf(module, placed string) string { if b := e.firsts[module]; b != "" { return b } - return trunkBranch + if e.trunk != nil { + return e.trunk() + } + return "" } // hearPipeline takes a pipeline-ended. A pipeline of a group writes nothing on its own end, and a run on request delivers diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pipelines_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pipelines_test.go index e3e92cd0..d0c6fa1c 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pipelines_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pipelines_test.go @@ -166,6 +166,7 @@ func TestAPassWritesNoWantWithoutAPassedPipeline(t *testing.T) { p.w.put(buckets.Presence, "g14", on) if c.follow { p.ends = newPipelineEnds(newMemEnds(), func() {}, t.Logf) + p.ends.trunk = p.w.trunkName p.ends.on.Store(true) if c.passed { _ = p.ends.take(pipe("web", trunkBranch, 5, 1, fp, mpr.PipelineEndedOutcomePassed), true) @@ -193,6 +194,7 @@ func TestTheWantsSwitchOnTheFirstRegistration(t *testing.T) { st := newMemEnds() woken := 0 e := newPipelineEnds(st, func() { woken++ }, t.Logf) + e.trunk = func() string { return trunkBranch } _ = e.take(pipe("web", "main", 5, 1, "fp1", mpr.PipelineEndedOutcomeFailed), false) _ = e.take(pipe("web", "beta", 5, 1, "fp1", mpr.PipelineEndedOutcomePassed), true) if e.following() || !e.mayWant("web", "fp1", []string{"main"}, nil) { @@ -222,6 +224,7 @@ func TestACommitOnBetaMovesOnlyTheNodeThatChoseBeta(t *testing.T) { p := newPasser(kvSet{buckets.Wants: wantsKV, buckets.Asks: &memKV{values: map[string][]byte{}}}, time.Now()) p.say = func(oc.Condition) error { return nil } p.ends = newPipelineEnds(newMemEnds(), func() {}, t.Logf) + p.ends.trunk = p.w.trunkName p.ends.registered(mpcat.Registration{Module: "web", Branches: []string{"main", "beta"}}, false) digest := "sha256:" + strings.Repeat("c", 64) on, _ := presenceValue(true) diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/views.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/views.go index f52e96e7..77d5144b 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/views.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/views.go @@ -136,7 +136,9 @@ func (iv *catalogViews) start(ctx context.Context, w *world, wake func(), settle reader.Findings = func() views.WireWriter { return iv.writer } } iv.h = &halves{w: w, call: iv.call, reader: reader, wake: wake, logf: iv.logf, ctx: ctx, settled: settled} - iv.h.follow(trunkBranch) + if err := iv.h.follow(); err != nil { + iv.logf("the catalog half is not followed, so no module is placed until the assigner starts again: %v", err) + } reader.Start(ctx, iv.js, iv.account, iv.status) return 1 } 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 3acf053b..ce13650a 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go @@ -18,7 +18,6 @@ import ( mwc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/graph" - "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/trunk" "git.novox.be/novox/mesh-sdk/go/keys" mmac "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" @@ -48,9 +47,10 @@ type world struct { // assignments is the seat's state assignments assembled node by node from its keys in either form (mesh-sdk // mmac.Keys; research 072 row 37): the one reader of its keys in the assigner. assignmentKeys *mmac.Keys - // sourceGroups is, by revision of the gate's source.main, the release (group) that moved it, from every revision its - // history keeps (watched with history): a build's source revision names the release that carries it (ADR 0601 §1). - sourceGroups map[uint64]string + // first is the first watched branch the catalog half is followed of, the trunk's: the branch of the trunk-only want, read from the + // branches the registrations name, before the first registrations from those the gate's `source` answers (design 47 G §507, §532), + // and never fixed in code (§7). + first string // running is, by `.`, the last `running` the node-engine seat said of it, kept from its stream by the assigner's // durable consumer (design 47 G §199, §309); runningHeard says that consumer is bound. running map[string]nodeengine.Running @@ -77,7 +77,7 @@ type parsed struct { func newWorld() *world { return &world{values: map[string]map[string][]byte{}, manifests: map[string]parsed{}, healthy: map[string][]string{}, changes: map[string]uint64{}, meta: map[string]entryMeta{}, assignmentKeys: mmac.NewKeys(), - sourceGroups: map[uint64]string{}, running: map[string]nodeengine.Running{}, + running: map[string]nodeengine.Running{}, modules: map[string]map[string]meshcatalog.ModulesByModuleValue{}, graphs: map[string]map[string]graph.Node{}} } @@ -94,44 +94,6 @@ func (w *world) putEntry(bucket, key string, value []byte, revision uint64, at t if bucket == plc.AssignmentsBucket { _, _ = w.assignmentKeys.Put(key, value, revision, false) // put took it already; this is its revision } - if bucket == buckets.Source && key == mdc.SourceKey(trunkBranch) { - if s, err := mdc.DecodeSource(value); err == nil && s.Group != "" { - w.sourceGroups[revision] = s.Group - } - } -} - -// release is the release whose Approve carries module's build fp (ADR 0601 §1), and whether one does: the trunk's value of -// the module names fp, the source revision at which that fingerprint last changed (FPSrc, never the Src of its last merge, -// which a recompute with no merge keeps) names the release that moved it, and the gate's merge of that release, which it -// writes on the operator's Approve, holds the module's own trunk merge among its merged pull requests. Any of them -// unknown: none, and the move is asked of a person. -func (w *world) release(module, fp string) (string, bool) { - t, ok := w.trunk()[module] - if !ok || fp == "" || t.Fp2 != fp || t.FpSrc == 0 || t.Commit == "" { - return "", false - } - w.mu.Lock() - group := w.sourceGroups[uint64(t.FpSrc)] - w.mu.Unlock() - key, err := mdc.MergesKey(group) - if group == "" || err != nil { - return "", false - } - raw := w.value(buckets.Merges, key) - if raw == nil { - return "", false - } - merge, err := mdc.DecodeMerge(raw) - if err != nil { - return "", false - } - for _, pull := range merge.Pulls { - if pull.State == string(mdc.MergeMerged) && pull.Commit == t.Commit && trunk.SameRepository(pull.Repository, t.Repository) { - return group, true - } - } - return "", false } // metaOf is the revision and time of the value a bucket holds at key, as the watch said them. @@ -267,8 +229,8 @@ func (w *world) branches() []string { w.mu.Lock() defer w.mu.Unlock() out := slices.Sorted(maps.Keys(w.modules)) - if i := slices.Index(out, trunkBranch); i > 0 { - out = slices.Insert(slices.Delete(out, i, i+1), 0, trunkBranch) + if i := slices.Index(out, w.first); i > 0 { + out = slices.Insert(slices.Delete(out, i, i+1), 0, w.first) } return out } @@ -576,8 +538,19 @@ func (w *world) unplacedNodes() []string { return out } -// trunkBranch is the branch of the trunk-only want, kept until the first registrations (design 47 G §95, §353). -const trunkBranch = "main" +// trunkName is the first watched branch, the trunk's (design 47 G §507, §353): "" until the first view of the catalog half is adopted. +func (w *world) trunkName() string { + w.mu.Lock() + defer w.mu.Unlock() + return w.first +} + +// setTrunk says which watched branch is the first. +func (w *world) setTrunk(branch string) { + w.mu.Lock() + defer w.mu.Unlock() + w.first = branch +} // placedBranches is, by module and node, the watched branch each placement names ("" when it names none). func (w *world) placedBranches() map[string]map[string]string { 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 7cf9ea22..bc013a53 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 @@ -3,6 +3,8 @@ package main import ( "context" "mesh-delivery-assigner/internal/buckets" + "os" + "path/filepath" "strings" "testing" "time" @@ -81,3 +83,32 @@ func putModuleRaw(w *world, branch, module string, raw []byte) { } w.putModule(branch, module, v, graph.Node{FP: v.Fp2, Repository: v.Repository}, nil) } + +// Design 47 G §507, §532, §7: the first watched branch the catalog half is followed of is the trunk's, whatever its name, and no part of the +// assigner's code names a branch: the world's trunk comes first, and no non-test source holds the string "main". +func TestTheTrunkIsTheFirstWatchedBranchAndNoCodeNamesOne(t *testing.T) { + w := newWorld() + w.setTrunk("develop") + for _, b := range []string{"beta", "develop", "alpha"} { + w.putModule(b, "web", meshcatalog.ModulesByModuleValue{Fp2: "fp"}, graph.Node{FP: "fp"}, nil) + } + if got := w.branches(); strings.Join(got, ",") != "develop,alpha,beta" { + t.Errorf("branches = %v, want the first watched first, then by name", got) + } + files, err := filepath.Glob("*.go") + if err != nil { + t.Fatal(err) + } + for _, f := range files { + if strings.HasSuffix(f, "_test.go") { + continue + } + src, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(src), `"main"`) { + t.Errorf("%s names the branch main in code (design 47 G §507)", f) + } + } +} diff --git a/modules/mesh-delivery-assigner/internal/assign/wants.go b/modules/mesh-delivery-assigner/internal/assign/wants.go index b5f365d5..058770c8 100644 --- a/modules/mesh-delivery-assigner/internal/assign/wants.go +++ b/modules/mesh-delivery-assigner/internal/assign/wants.go @@ -73,10 +73,6 @@ type Module struct { // Allowed says whether the operator answered yes to the record ask about the move of this module on node to the composition // with this digest (ADR 0539 §14). Nil: none did. Allowed func(node, digest string) bool - // Release names the release whose Approve carries the build fp, as mesh-delivery's gate holds it for the release's heads, - // read by the build's source revision (ADR 0601 §1): that Approve is the yes to a move of the build alone. False when no - // approved release is known to carry it. Nil: none is read. - Release func(fp string) (string, bool) // Released is a hold on FP the operator released (ADR 0539 §19): FP is not held again by a health check that failed. Released bool // Restored says whether the operator answered "restore" to the restore ask of this data module on node: it is then set back diff --git a/modules/mesh-delivery-assigner/internal/buckets/buckets.go b/modules/mesh-delivery-assigner/internal/buckets/buckets.go index 30d65c63..b7c53119 100644 --- a/modules/mesh-delivery-assigner/internal/buckets/buckets.go +++ b/modules/mesh-delivery-assigner/internal/buckets/buckets.go @@ -8,7 +8,6 @@ import ( "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" watchdogs "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshwatchdogs" catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" - delivery "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery" wants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants" "git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate" @@ -57,9 +56,6 @@ const ( Provisions = ProvisionerModule + "_provisions" // Presence holds each part's presence and the node's. Presence = "mesh-delivery-watchman_" + watchdogs.StatePresence - // Source and Merges are the gate's. - Source = "mesh-delivery-gate_" + delivery.StateSource - Merges = "mesh-delivery-gate_" + delivery.StateMerges // TellerModule is the module whose hand the operator's answers pass: the one that holds each ask's answer, and the // name the hand-act log records a warrant under. Held equal to the manifest's reads by its test. TellerModule = "mesh-delivery-teller" diff --git a/modules/mesh-delivery-assigner/internal/buckets/buckets_test.go b/modules/mesh-delivery-assigner/internal/buckets/buckets_test.go index 9b092c1e..e5d8a881 100644 --- a/modules/mesh-delivery-assigner/internal/buckets/buckets_test.go +++ b/modules/mesh-delivery-assigner/internal/buckets/buckets_test.go @@ -8,10 +8,9 @@ func TestBucketsAreTheEarlierNames(t *testing.T) { for name, c := range map[string][2]string{ "Have": {Have, "node-engine_have"}, "ComposedRead": {ComposedRead, "mesh-host.composed"}, "Modules": {Modules, "mesh-delivery-indexer_modules"}, "Manifests": {Manifests, "mesh-delivery-indexer_manifests"}, - "Presence": {Presence, "mesh-delivery-watchman_presence"}, "Source": {Source, "mesh-delivery-gate_source"}, - "Merges": {Merges, "mesh-delivery-gate_merges"}, "Answers": {Answers, "mesh-delivery-teller_answers"}, + "Presence": {Presence, "mesh-delivery-watchman_presence"}, "Answers": {Answers, "mesh-delivery-teller_answers"}, "Grants": {Grants, "mesh-grants_grants"}, - "Wants": {Wants, "mesh-delivery-assigner_wants"}, "Differences": {Differences, "mesh-delivery-assigner_differences"}, + "Wants": {Wants, "mesh-delivery-assigner_wants"}, "Differences": {Differences, "mesh-delivery-assigner_differences"}, "Asks": {Asks, "mesh-delivery-assigner_asks"}, "Puts": {Puts, "mesh-delivery-assigner_puts"}, "Policies": {Policies, "mesh-delivery-assigner_policies"}, "Provisions": {Provisions, "mesh-provisioner_provisions"}, "TellerModule": {TellerModule, "mesh-delivery-teller"}, } { diff --git a/modules/mesh-delivery-assigner/manifest_test.go b/modules/mesh-delivery-assigner/manifest_test.go index 947184bc..61184a25 100644 --- a/modules/mesh-delivery-assigner/manifest_test.go +++ b/modules/mesh-delivery-assigner/manifest_test.go @@ -43,18 +43,18 @@ func TestManifest(t *testing.T) { reads := slices.Clone(m.Reads) slices.Sort(reads) // ADR 0593 §8: it composes nothing, so it reads no candidate, held or resolved value, compose step or build log; it reads - // every node's published compositions to compare them, and the gate's source and merges, for the release that carries a build (ADR 0601 §1). + // every node's published compositions to compare them,. // DLV-F2r (ADR 0615 §1, ADR 0631 §1): the indexer's manifests, modules and module graph (whose verb edges a want waits // on, ADR 0614 §2, §3) are read through its first views and views family, none of the indexer's states by reads. // A-181: what was built is heard from the builder's stream of ends, so the builder's builds bucket is no read. - want := []string{"mesh-delivery-gate.merges", "mesh-delivery-gate.source", - "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-grants.grants", "mesh-host.composed", + // §431, §507: the gate's source is read through its verb `source` and its event `source-moved`, and no `merges` is read. + want := []string{"mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-grants.grants", "mesh-host.composed", "mesh-provisioner.provisions", "node-engine.have"} if !reflect.DeepEqual(reads, want) { t.Errorf("reads = %v, want exactly %v", reads, want) } // the buckets the program names are the manifest's reads, `.` as `_` - named := []string{buckets.Merges, buckets.Source, buckets.Answers, buckets.Presence, buckets.Grants, + named := []string{buckets.Answers, buckets.Presence, buckets.Grants, buckets.ComposedRead, "mesh-provisioner_provisions", buckets.Have} slices.Sort(named) for i, r := range want { @@ -131,12 +131,13 @@ func TestManifest(t *testing.T) { } // ADR 0618 §4: and it hears each node's engine say its reads changed; ADR 0636 §4 (issue 1966): and the seat list's two events; // issue 1966 note 151: and the teller's ask-ended, its subject the one the assigner hears. - if !slices.Equal(m.Consumes, []string{"mesh-conditions.findings-asked", "mesh-conditions.condition-raised", "mesh-conditions.condition-changed", "mesh-conditions.condition-cleared", "mesh-catalog.catalog-revision", "mesh-catalog.registration-changed", "mesh-catalog.version-released", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", builds.BuilderModule + "." + builds.BuildsStreamEvents, buckets.TellerModule + "." + tellerevents.AskEnded, "mesh-pipeline-runner.pipeline-ended", "mesh-pipeline-runner.group-ended", "node-engine.manifest-reads-changed", "node-engine.running"}) { + if !slices.Equal(m.Consumes, []string{"mesh-conditions.findings-asked", "mesh-conditions.condition-raised", "mesh-conditions.condition-changed", "mesh-conditions.condition-cleared", "mesh-catalog.catalog-revision", "mesh-catalog.registration-changed", "mesh-delivery.source-moved", "mesh-catalog.version-released", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", builds.BuilderModule + "." + builds.BuildsStreamEvents, buckets.TellerModule + "." + tellerevents.AskEnded, "mesh-pipeline-runner.pipeline-ended", "mesh-pipeline-runner.group-ended", "node-engine.manifest-reads-changed", "node-engine.running"}) { t.Errorf("consumes = %v, want the catalog half's revisions (design 47 G §195), the teller's ask-ended, the engines' reads changed and what they run", m.Consumes) } if want := []string{"mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules", "seat:mesh-catalog.registrations", "seat:mesh-catalog.revision", "seat:mesh-catalog.seat-definitions", // ADR 0636 §4: the seat list "seat:mesh-conditions.conditions", // design 47 G §706: the conditions read at each judging of an outside wait + "seat:mesh-delivery.source", // §507: the branches the gate keeps a source revision of, before the first registrations "seat:mesh-hand-acts.hand-act", "seat:mesh-hand-acts.hand-acts", "seat:mesh-hand-acts.warranted", // ADR 0565 §9, §11: the seat alone "seat:node-backup.backed-up", "seat:node-backup.now", "seat:node-engine.manifest-reads", // ADR 0618 §3 diff --git a/modules/mesh-delivery-assigner/module.json b/modules/mesh-delivery-assigner/module.json index 49058b99..a494e580 100644 --- a/modules/mesh-delivery-assigner/module.json +++ b/modules/mesh-delivery-assigner/module.json @@ -30,8 +30,6 @@ } ], "reads": [ - "mesh-delivery-gate.merges", - "mesh-delivery-gate.source", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-grants.grants", @@ -49,6 +47,7 @@ "mesh-conditions.condition-cleared", "mesh-catalog.catalog-revision", "mesh-catalog.registration-changed", + "mesh-delivery.source-moved", "mesh-catalog.version-released", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", @@ -66,6 +65,7 @@ "seat:mesh-catalog.revision", "seat:mesh-catalog.seat-definitions", "seat:mesh-conditions.conditions", + "seat:mesh-delivery.source", "seat:mesh-hand-acts.hand-act", "seat:mesh-hand-acts.hand-acts", "seat:mesh-hand-acts.warranted", diff --git a/modules/mesh-delivery-contracts/go/clients/meshcatalogclient/branches.go b/modules/mesh-delivery-contracts/go/clients/meshcatalogclient/branches.go index d9a149be..bfe073b1 100644 --- a/modules/mesh-delivery-contracts/go/clients/meshcatalogclient/branches.go +++ b/modules/mesh-delivery-contracts/go/clients/meshcatalogclient/branches.go @@ -68,6 +68,9 @@ type Branches struct { Entries *Entries // Changed, when not nil, is told of each change to a branch's view (module, its entry, deleted), set before Wire. Changed func(branch, module string, entry json.RawMessage, deleted bool, at time.Time) + // Adopted, when not nil, is told on a goroutine of its own of each branch whose view was adopted, with that view (a part that waits for + // its first branch's first view, design 47 G §507), set before Wire. + Adopted func(branch string, c *Catalog) // Logf, when not nil, says each view adopted and dropped. Logf func(format string, args ...any) @@ -250,6 +253,9 @@ func (b *Branches) reconcile() { b.catalogs[e] = c b.order = append(b.order, e) b.logf("meshcatalogclient: the branch %q is followed", want[e]) + if b.Adopted != nil { + go b.Adopted(branch, c) + } } for _, e := range slices.Clone(b.order) { if _, ok := want[e]; ok { diff --git a/modules/mesh-delivery-contracts/go/clients/meshcatalogclient/branches_test.go b/modules/mesh-delivery-contracts/go/clients/meshcatalogclient/branches_test.go index 34c5937b..6e0f733e 100644 --- a/modules/mesh-delivery-contracts/go/clients/meshcatalogclient/branches_test.go +++ b/modules/mesh-delivery-contracts/go/clients/meshcatalogclient/branches_test.go @@ -126,6 +126,12 @@ func TestBranchesFollowSourceThenRegistrations(t *testing.T) { t.Fatal(err) } b.Logf = func(format string, args ...any) { said <- fmt.Sprintf(format, args...) } + adopted := make(chan string, 8) + b.Adopted = func(branch string, c *Catalog) { + if c.Branch == branch { + adopted <- branch + } + } want := func(names ...string) { t.Helper() if got := w.names(); !slices.Equal(got, names) { @@ -148,6 +154,18 @@ func TestBranchesFollowSourceThenRegistrations(t *testing.T) { t.Fatal(err) } waitFor(`"feat/x" is followed`) + told := []string{} // each adopted branch is told, with its view (a part waits for its first one's first view), in no set order + for range 2 { + select { + case got := <-adopted: + told = append(told, got) + case <-ctx.Done(): + t.Fatalf("told of %v, want main and feat/x", told) + } + } + if slices.Sort(told); !slices.Equal(told, []string{"feat/x", "main"}) { + t.Fatalf("told of %v", told) + } if got := b.Followed(); !slices.Equal(got, []string{"main", "feat/x"}) { t.Fatalf("followed = %v", got) }