From e88e0c825c9886e989d948728c02dcc0fd5d767a Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 04:56:33 +0100 Subject: [PATCH] R-5: the tool runner follows the assigner's views of the module that claims the seat, named by the seat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The holder is read from the catalog half's entries serving a verb of mesh-module-assigner, on the branch follower's change callback; the manifest's consumes names the two views by the seat (seat:mesh-module-assigner.views.*). Builds: design 47 G §616, §428, §7 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../internal/console/manifest_waits_test.go | 4 +- node-tools/internal/firstview/firstview.go | 131 ++++++++++++++---- .../firstview/firstview_integration_test.go | 45 ++++-- .../internal/firstview/firstview_test.go | 89 +++++++++--- node-tools/internal/meshtest/meshtest.go | 2 +- node-tools/module.json | 4 +- 6 files changed, 208 insertions(+), 67 deletions(-) diff --git a/node-tools/internal/console/manifest_waits_test.go b/node-tools/internal/console/manifest_waits_test.go index f80ec5f..c985454 100644 --- a/node-tools/internal/console/manifest_waits_test.go +++ b/node-tools/internal/console/manifest_waits_test.go @@ -48,8 +48,8 @@ func TestTheManifestNamesTheFirstFamiliesOneByOne(t *testing.T) { // `registration-changed`, and the gate's `source-moved`; never the indexer's `views.modules` or `views.manifests`), the two // events of the seat that serves the seat list (ADR 0636 §4) and the keeper's `findings-asked`, and nothing else. wantConsumes := []string{"mesh-catalog.catalog-revision", "mesh-catalog.registration-changed", "mesh-catalog.seat-definition-changed", - "mesh-catalog.seat-definition-removed", "mesh-conditions.findings-asked", "mesh-delivery-assigner.views.assignments", - "mesh-delivery-assigner.views.seat-holders", "mesh-delivery.source-moved"} + "mesh-catalog.seat-definition-removed", "mesh-conditions.findings-asked", "seat:mesh-module-assigner.views.assignments", + "seat:mesh-module-assigner.views.seat-holders", "mesh-delivery.source-moved"} if !reflect.DeepEqual(m.Consumes, wantConsumes) { t.Errorf("the tool runner consumes %v, want %v", m.Consumes, wantConsumes) } diff --git a/node-tools/internal/firstview/firstview.go b/node-tools/internal/firstview/firstview.go index 9e60efd..706c5d7 100644 --- a/node-tools/internal/firstview/firstview.go +++ b/node-tools/internal/firstview/firstview.go @@ -20,6 +20,7 @@ import ( "sync" "time" + "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" @@ -31,27 +32,27 @@ import ( "github.com/novox/mesh-tools/node-tools/internal/bus" ) -// The owner and its first-view tools (the tools' addresses are `.`). -const Assigner = "mesh-delivery-assigner" +// AssignerSeat is the seat whose holder's views the runner follows: the holder is read from the catalog half's claims of the +// seat, never named in code (design 47 G §616, §428), and the names the console reads the two states by are the seat's +// (`.`; the manifest's `consumes` names their views families by the seat, as `seat:.views.`). +const AssignerSeat = meshmoduleassigner.Seat -// The names the console reads the assigner's two states by (`.`), as the manifest's `consumes` names their views -// families (no seat's client names a holder, novox/hq ADR 0625 §4; TestTheFirstViewsAreTheManifestsViews). const ( - AssignmentsRead = Assigner + ".assignments" - SeatHoldersRead = Assigner + ".seat-holders" + AssignmentsRead = AssignerSeat + ".assignments" + SeatHoldersRead = AssignerSeat + ".seat-holders" ) -// Spec is one first view the runner holds. -type Spec struct{ Owner, Tool, State string } +// Spec is one first view the runner holds of the seat's holder: its first-view tool and the state. +type Spec struct{ Tool, State string } -// Specs are the two owner states asked of an owner's tool; the catalog half's views are adopted by the branch follower. +// Specs are the two states asked of the holder's tool; the catalog half's views are adopted by the branch follower. var Specs = []Spec{ - {Assigner, "assignments", "assignments"}, - {Assigner, "seat-holders", "seat-holders"}, + {"assignments", "assignments"}, + {"seat-holders", "seat-holders"}, } -// Name is the name a state is read by, `.`: AssignmentsRead and SeatHoldersRead. -func (s Spec) Name() string { return s.Owner + "." + s.State } +// Name is the name a state is read by, `.`: AssignmentsRead and SeatHoldersRead. +func (s Spec) Name() string { return AssignerSeat + "." + s.State } // Views is the first views: the assigner's two, and the catalog half's per watched branch. type Views struct { @@ -62,6 +63,7 @@ type Views struct { adopted map[string]*adoption // by `.`, the views the branch follower adopted hearers map[string][]*adoption // event key `.` -> the adopted views that hear it branches *meshcatalogclient.Branches + holder *holding // the module that claims the assigner seat, once the catalog half names it ctx context.Context cancel context.CancelFunc wg sync.WaitGroup @@ -163,22 +165,6 @@ func StartWith(parent context.Context, d Deps, logf func(string, ...any)) (*View ctx, cancel := context.WithCancel(parent) v := &Views{logf: logf, ctx: ctx, cancel: cancel, followers: map[string]*views.Follower{}, byState: map[string]*views.Follower{}, adopted: map[string]*adoption{}, hearers: map[string][]*adoption{}, deps: d} - for _, s := range Specs { - s := s - call := func(tool string, body any) (json.RawMessage, error) { return d.Ask(s.Owner+"."+tool, body) } - f := &views.Follower{Owner: s.Owner, Tool: s.Tool, State: s.State, Ask: views.AskVia(call, s.Tool), Logf: logf, - Bound: d.Bound, Wait: d.Wait, - Raise: func(st views.Status) { - v.finding(unansweredFinding(v.deps.Module, v.deps.Node, st, time.Now()), st.Tool, true) - }, - Clear: func(st views.Status) { - v.finding(unansweredFinding(v.deps.Module, v.deps.Node, st, time.Now()), st.Tool, false) - }} - v.followers[s.Name()] = f - v.byState[s.State] = f - v.wg.Add(1) - go func() { defer v.wg.Done(); _ = f.Start(ctx) }() - } stopFindings := func() {} if d.StartFindings != nil { if stop, err := d.StartFindings(ctx); err != nil { @@ -194,6 +180,9 @@ func StartWith(parent context.Context, d Deps, logf func(string, ...any)) (*View logf("[mesh-tools] the catalog half is not followed (%v): the console cannot say which modules exist", err) } else { branches.Logf = func(format string, args ...any) { logf("[mesh-tools] "+format, args...) } + branches.Changed = func(_, module string, entry json.RawMessage, deleted bool, _ time.Time) { + v.claimed(module, entry, deleted) + } v.branches = branches } stop, err := d.Consume(v.hear) @@ -221,7 +210,9 @@ func (v *Views) hear(env bus.Envelope) error { if err != nil { return nil // not a views event: nothing to hold, nothing to retry } + v.mu.Lock() f := v.byState[ev.State] + v.mu.Unlock() if f == nil || f.Owner != ev.Owner { return nil } @@ -253,6 +244,13 @@ func (v *Views) finding(f condseat.Finding, tool string, raised bool) { // not answered yet, so nothing is claimed of it. func (v *Views) State(name string) (map[string][]byte, error) { v.mu.Lock() + if state, ok := strings.CutPrefix(name, AssignerSeat+"."); ok { // the seat's name stands for its holder's + if v.holder == nil { + v.mu.Unlock() + return nil, fmt.Errorf("the catalog half names no holder of the seat %s yet, so %s has no first view", AssignerSeat, name) + } + name = v.holder.module + "." + state + } f := v.followers[name] v.mu.Unlock() if f == nil { @@ -462,3 +460,78 @@ func (v *Views) Modules() (map[string]catalogseat.CatalogEntry, error) { } return out, nil } + +// holding is the views of the module that claims the assigner seat, and what stops them. +type holding struct { + module string + cancel context.CancelFunc + views []*views.Follower +} + +// claimed is the branch follower's word of one entry changed: an entry that serves a verb of the assigner seat makes its module +// the holder whose views the runner follows (a holder before it is dropped), the deletion of the holder's entry drops them +// (design 47 G §616, §428). It acts on the event alone; nothing waits on a timer for the claim. +func (v *Views) claimed(module string, entry json.RawMessage, deleted bool) { + claims := false + if !deleted { + var e catalogseat.CatalogEntry + if json.Unmarshal(entry, &e) == nil { + claims = slices.ContainsFunc(e.Serves, func(s string) bool { return strings.HasPrefix(s, AssignerSeat+".") }) + } + } + v.mu.Lock() + cur := v.holder + switch { + case claims && cur != nil && cur.module == module, !claims && (cur == nil || cur.module != module), v.ctx.Err() != nil: + v.mu.Unlock() + return + } + if cur != nil { + v.dropHolderLocked() + } + if claims { + v.startHolderLocked(module) + } + v.mu.Unlock() +} + +// startHolderLocked follows the first views of module, the holder of the seat, on the runner's own consumer. +func (v *Views) startHolderLocked(module string) { + ctx, cancel := context.WithCancel(v.ctx) + h := &holding{module: module, cancel: cancel} + d := v.deps + for _, s := range Specs { + s := s + call := func(tool string, body any) (json.RawMessage, error) { return d.Ask(module+"."+tool, body) } + f := &views.Follower{Owner: module, Tool: s.Tool, State: s.State, Ask: views.AskVia(call, s.Tool), Logf: v.logf, + Bound: d.Bound, Wait: d.Wait, + Raise: func(st views.Status) { + v.finding(unansweredFinding(d.Module, d.Node, st, time.Now()), st.Tool, true) + }, + Clear: func(st views.Status) { + v.finding(unansweredFinding(d.Module, d.Node, st, time.Now()), st.Tool, false) + }} + v.followers[module+"."+s.State] = f + v.byState[s.State] = f + h.views = append(h.views, f) + v.wg.Add(1) + go func() { defer v.wg.Done(); _ = f.Start(ctx) }() + } + v.holder = h + v.logf("[mesh-tools] the seat %s is held by %s: its views are followed", AssignerSeat, module) +} + +// dropHolderLocked stops the views of the holder and clears the findings they raised. +func (v *Views) dropHolderLocked() { + h := v.holder + v.holder = nil + h.cancel() + for _, f := range h.views { + delete(v.followers, f.Owner+"."+f.State) + delete(v.byState, f.State) + if st := f.Status(); st.Raised { + go v.finding(unansweredFinding(v.deps.Module, v.deps.Node, st, time.Now()), st.Tool, false) + } + } + v.logf("[mesh-tools] %s no longer holds the seat %s: its views are no longer followed", h.module, AssignerSeat) +} diff --git a/node-tools/internal/firstview/firstview_integration_test.go b/node-tools/internal/firstview/firstview_integration_test.go index c9fadcd..e3afd0f 100644 --- a/node-tools/internal/firstview/firstview_integration_test.go +++ b/node-tools/internal/firstview/firstview_integration_test.go @@ -17,6 +17,10 @@ import ( "git.novox.be/novox/mesh-sdk/go/views" "github.com/nats-io/nats.go" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + delivery "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery" + "github.com/novox/mesh-tools/node-tools/internal/bus" mt "github.com/novox/mesh-tools/node-tools/internal/meshtest" ) @@ -41,7 +45,9 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen if err := json.Unmarshal(raw, &manifest); err != nil { t.Fatal(err) } - streamOf := map[string]string{Assigner: catalogue.StreamBusName("assigner-views")} + // the manifest names the assigner's views by the seat; the module the catalog half says claims it is the fixture's + const holder = "assigner-fixture" + streamOf := map[string]string{"seat:" + AssignerSeat: catalogue.StreamBusName("assigner-views")} filters := map[string][]string{} families := 0 for _, c := range manifest.Consumes { @@ -53,16 +59,16 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen if stream == "" { t.Fatalf("the manifest consumes %s, whose views stream the test does not know", c) } - filters[stream] = append(filters[stream], process.ModuleStreamSubject(emitter, views.EventPrefix+event)) + filters[stream] = append(filters[stream], process.ModuleStreamSubject(holder, views.EventPrefix+event)) families++ } if families != len(Specs) { t.Fatalf("the manifest consumes %d families, the runner holds %d first views", families, len(Specs)) } runner := mt.MembershipOf("node-tools", "anchor", false, nil) - for _, owner := range []string{Assigner} { - stream := streamOf[owner] - mesh.ModuleStream(t, owner, stream) + for _, seat := range []string{"seat:" + AssignerSeat} { + stream := streamOf[seat] + mesh.ModuleStream(t, holder, stream) mesh.ConsumerOn(t, stream, "anchor", "node-tools", filters[stream], 2*time.Second) runner.Hears = append(runner.Hears, bus.HeardIssued{Stream: stream, Consumer: "anchor_node-tools"}) } @@ -93,13 +99,13 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen t.Cleanup(stop) // The owners are down: no tool answers, nothing is claimed, and past the bound each view is a finding. - if _, err := v.State(Assigner + ".assignments"); err == nil { + if _, err := v.State(AssignmentsRead); err == nil { t.Fatal("a state its owner has not answered is claimed") } mt.Until(t, func() error { mu.Lock() defer mu.Unlock() - if len(findings) != len(Specs)+catalogViews { + if len(findings) != catalogViews { return errFindings(len(findings)) } return nil @@ -111,10 +117,25 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen t.Fatal(err) } t.Cleanup(nc.Close) + respond := func(subject string, answer any) { + if _, err := nc.Subscribe(subject, func(m *nats.Msg) { + raw, _ := json.Marshal(answer) + body, _ := json.Marshal(map[string]json.RawMessage{"result": raw}) + _ = m.Respond(body) + }); err != nil { + t.Fatal(err) + } + } + // the catalog half answers: the gate's source names main, whose entries say the fixture claims the assigner seat + respond("mesh.seat.mesh-delivery.tool.source", delivery.SourceAnswer{Branches: []string{"main"}}) + respond("mesh.seat.mesh-catalog.tool.registrations", catalogseat.RegistrationsAnswer{Registrations: []catalogseat.Registration{}}) + respond("mesh.seat.mesh-catalog.tool.revision", catalogseat.RevisionAnswer{ + Revision: &catalogseat.CatalogRevision{Revision: 3, Kind: meshcatalogclient.KindTrunk, Branch: "main"}, + Entries: []catalogseat.CatalogEntry{{Module: holder, Serves: []string{AssignerSeat + ".assignments"}}}}) for _, s := range Specs { s := s - if _, err := nc.Subscribe("mesh.mod."+s.Owner+".tool."+s.Tool, func(m *nats.Msg) { - answer, _ := json.Marshal(views.Answer{V: 1, Owner: s.Owner, State: s.State, Revision: 4, Created: time.Unix(1000, 0).UTC(), + if _, err := nc.Subscribe("mesh.mod."+holder+".tool."+s.Tool, func(m *nats.Msg) { + answer, _ := json.Marshal(views.Answer{V: 1, Owner: holder, State: s.State, Revision: 4, Created: time.Unix(1000, 0).UTC(), Values: map[string]json.RawMessage{"first": json.RawMessage(`{"n":1}`)}}) body, _ := json.Marshal(map[string]json.RawMessage{"result": answer}) _ = m.Respond(body) @@ -130,7 +151,7 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen mt.Until(t, func() error { mu.Lock() defer mu.Unlock() - if len(findings) != catalogViews { // the seats' verbs are not served in this test: the catalog half stays unanswered + if len(findings) != 0 { // every view answered clears its finding return errFindings(len(findings)) } return nil @@ -138,9 +159,9 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen // Each owner's next event arrives on the runner's consumer: all four families are bound. for _, s := range Specs { - ev, _ := json.Marshal(views.Event{V: 1, Owner: s.Owner, State: s.State, Key: "next", Revision: 5, Created: time.Unix(1000, 0).UTC(), + ev, _ := json.Marshal(views.Event{V: 1, Owner: holder, State: s.State, Key: "next", Revision: 5, Created: time.Unix(1000, 0).UTC(), Value: json.RawMessage(`{"n":2}`)}) - mesh.Emit(t, process.ModuleStreamSubject(s.Owner, views.EventPrefix+s.State), json.RawMessage(ev)) + mesh.Emit(t, process.ModuleStreamSubject(holder, views.EventPrefix+s.State), json.RawMessage(ev)) } for _, s := range Specs { name := s.Name() diff --git a/node-tools/internal/firstview/firstview_test.go b/node-tools/internal/firstview/firstview_test.go index 997203b..9229cb3 100644 --- a/node-tools/internal/firstview/firstview_test.go +++ b/node-tools/internal/firstview/firstview_test.go @@ -32,12 +32,14 @@ type owners struct { // catalog is the revision of the watched branch main the seat mesh-catalog answers, and modules its entries catalog int64 modules []string + // holder is the module among them that claims the assigner seat + holder string } func newOwners() *owners { return &owners{state: map[string]map[string]string{"assignments": {"anchor": `{"p":1}`}, "seat-holders": {"seat": `{"h":1}`}}, rev: map[string]uint64{"assignments": 4, "seat-holders": 2}, asked: map[string]int{}, finds: map[string]bool{}, - catalog: 3, modules: []string{"web"}} + catalog: 3, modules: []string{"web", fixtureHolder}, holder: fixtureHolder} } // Write and Clear are the runner's finding writer in a test: the findings written and not cleared, by their key. @@ -71,7 +73,11 @@ func (o *owners) ask(address string, _ any) (json.RawMessage, error) { case "seat:mesh-catalog.revision": a := catalogseat.RevisionAnswer{Revision: &catalogseat.CatalogRevision{Revision: o.catalog, Kind: meshcatalogclient.KindTrunk, Branch: "main"}} for _, m := range o.modules { - a.Entries = append(a.Entries, catalogseat.CatalogEntry{Module: m, Manifest: map[string]any{"module": m, "tools": []string{m + "_check"}}}) + e := catalogseat.CatalogEntry{Module: m, Manifest: map[string]any{"module": m, "tools": []string{m + "_check"}}} + if m == o.holder { + e.Serves = []string{AssignerSeat + ".assignments", AssignerSeat + ".seat-holders"} + } + a.Entries = append(a.Entries, e) } return json.Marshal(a) } @@ -94,14 +100,10 @@ func (o *owners) say(t *testing.T, state, key, value string, revision uint64, ga } } -func owner(state string) string { - for _, s := range Specs { - if s.State == state { - return s.Owner - } - } - return "" -} +// fixtureHolder is the module the fixture catalog half says claims the assigner seat: no name is in the code. +const fixtureHolder = "assigner-fixture" + +func owner(state string) string { return fixtureHolder } func eventually(t *testing.T, what string, ok func() bool) { t.Helper() @@ -127,37 +129,37 @@ func TestTheRunnerHoldsTheFourStatesFromTheOwnersFirstViewsAndTheirEvents(t *tes defer stop() // Down: nothing is claimed, and past the bound each unanswered view is a finding. - if _, err := v.State("mesh-delivery-assigner.assignments"); err == nil || !strings.Contains(err.Error(), "not been answered") { - t.Fatalf("a state its owner has not answered is claimed: %v", err) + if _, err := v.State(AssignmentsRead); err == nil || !strings.Contains(err.Error(), "names no holder") { + t.Fatalf("a state whose holder is not known is claimed: %v", err) } - eventually(t, "no finding past the bound", func() bool { o.mu.Lock(); defer o.mu.Unlock(); return len(o.finds) == 4 }) + eventually(t, "no finding past the bound", func() bool { o.mu.Lock(); defer o.mu.Unlock(); return len(o.finds) == 2 }) // Up: answered, the findings are cleared, and the pictures hold the owners' values. o.mu.Lock() o.down = false o.mu.Unlock() - eventually(t, "not answered", func() bool { _, err := v.State("mesh-delivery-assigner.seat-holders"); return err == nil }) + eventually(t, "not answered", func() bool { _, err := v.State(SeatHoldersRead); return err == nil }) eventually(t, "findings not cleared", func() bool { o.mu.Lock(); defer o.mu.Unlock(); return len(o.finds) == 0 }) - if got, _ := v.Entry("mesh-delivery-assigner.seat-holders", "none"); got != nil { + if got, _ := v.Entry(AssignerSeat+".seat-holders", "none"); got != nil { t.Errorf("an absent key is %q", got) } // The catalog half is followed by the shared branch follower on the runner's own consumer: the gate's source names main, // main's view is adopted, and its entries are the modules, each with its manifest (design 47 G §196, §614). - eventually(t, "the catalog half holds no modules", func() bool { m, err := v.Modules(); return err == nil && len(m) == 1 }) + eventually(t, "the catalog half holds no modules", func() bool { m, err := v.Modules(); return err == nil && len(m) == 2 }) m, _ := v.Modules() if m["web"].Manifest["module"] != "web" { t.Errorf("the module web is %+v", m["web"]) } // A catalog-revision of the branch is heard on the same consumer and asks the view once more. o.mu.Lock() - o.catalog, o.modules = 4, []string{"web", "db"} + o.catalog, o.modules = 4, []string{"web", "db", fixtureHolder} o.mu.Unlock() ev, _ := json.Marshal(catalogseat.CatalogRevisionEvent{V: 1, Branch: "main", Kind: meshcatalogclient.KindTrunk, Revision: 4}) if err := o.deliver(bus.Envelope{Key: catalogseat.Seat + "." + catalogseat.EventCatalogRevision, Body: ev}); err != nil { t.Fatal(err) } - eventually(t, "the catalog-revision was not taken", func() bool { m, err := v.Modules(); return err == nil && len(m) == 2 }) + eventually(t, "the catalog-revision was not taken", func() bool { m, err := v.Modules(); return err == nil && len(m) == 3 }) // Events: the next revision is applied; an old one passed over; a deletion removes; none of it asks again. asked := func(tool string) int { o.mu.Lock(); defer o.mu.Unlock(); return o.asked[tool] } @@ -166,7 +168,7 @@ func TestTheRunnerHoldsTheFourStatesFromTheOwnersFirstViewsAndTheirEvents(t *tes o.say(t, "assignments", "laptop", `{"p":0}`, 5, false) o.say(t, "assignments", "anchor", "", 6, false) // (a deleted mark carries no value: Deleted is the encoding's, set below) - if got, _ := v.Entry("mesh-delivery-assigner.assignments", "laptop"); string(got) != `{"p":2}` { + if got, _ := v.Entry(AssignerSeat+".assignments", "laptop"); string(got) != `{"p":2}` { t.Errorf("the next revision is not applied: %q", got) } if n := asked("assignments"); n != before { @@ -184,7 +186,7 @@ func TestTheRunnerHoldsTheFourStatesFromTheOwnersFirstViewsAndTheirEvents(t *tes assignments = s } } - if assignments.Owner != "mesh-delivery-assigner" || assignments.Tool != "assignments" || assignments.Asks < 2 || assignments.Answered.IsZero() { + if assignments.Owner != fixtureHolder || assignments.Tool != "assignments" || assignments.Asks < 2 || assignments.Answered.IsZero() { t.Errorf("the status of assignments is %+v", assignments) } manifests := asked("seat-holders") @@ -224,13 +226,19 @@ func TestTheFirstViewsAreTheManifestsViews(t *testing.T) { t.Errorf("the first view %s is read by no name here", s.Name()) } delete(want, s.Name()) - if view := s.Owner + ".views." + s.Tool; !slices.Contains(m.Consumes, view) { + // the family is named by the seat, never by its holder (design 47 G §616) + if view := "seat:" + AssignerSeat + ".views." + s.Tool; !slices.Contains(m.Consumes, view) { t.Errorf("the first view %s has no family in the manifest's consumes %v", view, m.Consumes) } } if len(want) > 0 { t.Errorf("no first view reads %v", want) } + for _, c := range m.Consumes { + if strings.Contains(c, "assigner") && !strings.HasPrefix(c, "seat:") { + t.Errorf("the manifest names the assigner by a module: %s", c) + } + } // the catalog half's follower hears these events and asks these verbs, and the manifest says so (design 47 G §614) for _, ev := range []string{"mesh-catalog.catalog-revision", "mesh-catalog.registration-changed", "mesh-delivery.source-moved"} { if !slices.Contains(m.Consumes, ev) { @@ -243,3 +251,42 @@ func TestTheFirstViewsAreTheManifestsViews(t *testing.T) { } } } + +// design 47 G §616: the holder of the assigner seat is whichever module the catalog half says serves it; when another one +// claims it the views are of the new holder, and when none does the seat's names answer that the catalog half names no holder. +func TestTheAssignersViewsFollowTheModuleThatClaimsTheSeat(t *testing.T) { + o := newOwners() + deps := Deps{Module: "node-tools", Ask: o.ask, Bound: time.Second, Wait: 10 * time.Millisecond, + Consume: func(deliver func(bus.Envelope) error) (func(), error) { o.deliver = deliver; return func() {}, nil }, Findings: o} + v, stop := StartWith(context.Background(), deps, func(string, ...any) {}) + defer stop() + eventually(t, "not answered", func() bool { _, err := v.State(AssignmentsRead); return err == nil }) + ownerOf := func() string { + for _, s := range v.Status() { + if s.State == "assignments" { + return s.Owner + } + } + return "" + } + if ownerOf() != fixtureHolder { + t.Fatalf("the views are of %q", ownerOf()) + } + revise := func(rev int64, holder string, modules ...string) { + o.mu.Lock() + o.catalog, o.modules, o.holder = rev, modules, holder + o.mu.Unlock() + ev, _ := json.Marshal(catalogseat.CatalogRevisionEvent{V: 1, Branch: "main", Kind: meshcatalogclient.KindTrunk, Revision: rev}) + if err := o.deliver(bus.Envelope{Key: catalogseat.Seat + "." + catalogseat.EventCatalogRevision, Body: ev}); err != nil { + t.Fatal(err) + } + } + revise(4, "other-holder", "web", "other-holder") + eventually(t, "the views did not move to the new holder", func() bool { return ownerOf() == "other-holder" }) + eventually(t, "not answered", func() bool { _, err := v.State(SeatHoldersRead); return err == nil }) + revise(5, "", "web") + eventually(t, "the views stayed after the claim went", func() bool { + _, err := v.State(AssignmentsRead) + return err != nil && strings.Contains(err.Error(), "names no holder") + }) +} diff --git a/node-tools/internal/meshtest/meshtest.go b/node-tools/internal/meshtest/meshtest.go index aafeef3..685b59e 100644 --- a/node-tools/internal/meshtest/meshtest.go +++ b/node-tools/internal/meshtest/meshtest.go @@ -156,7 +156,7 @@ func sdkMembership(t *testing.T, mem bus.Membership) membership.Membership { // no holder). const ( catalogHolder = "mesh-catalog" - assignerHolder = "mesh-delivery-assigner" + assignerHolder = "mesh-module-assigner" // the seat's name stands for its holder in the console's state names (firstview.AssignmentsRead) // AssignmentsBucket and SeatHoldersBucket are the assigner's states assignments and seat-holders. AssignmentsBucket = assignerHolder + "_assignments" SeatHoldersBucket = assignerHolder + "_seat-holders" diff --git a/node-tools/module.json b/node-tools/module.json index f206a4d..1c474b8 100644 --- a/node-tools/module.json +++ b/node-tools/module.json @@ -76,8 +76,8 @@ "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", "mesh-conditions.findings-asked", - "mesh-delivery-assigner.views.assignments", - "mesh-delivery-assigner.views.seat-holders", + "seat:mesh-module-assigner.views.assignments", + "seat:mesh-module-assigner.views.seat-holders", "mesh-delivery.source-moved" ], "streams": [