diff --git a/modules/mesh-conditions/cmd/mesh-conditions/branches.go b/modules/mesh-conditions/cmd/mesh-conditions/branches.go new file mode 100644 index 000000000..f65d63ec8 --- /dev/null +++ b/modules/mesh-conditions/cmd/mesh-conditions/branches.go @@ -0,0 +1,128 @@ +package main + +// The keeper follows one view of the catalog half per watched branch, the branches the registrations name, and adopts a +// branch registered while it runs with no restart (design 47 G §532, §365, §479). No branch is named here (§7). + +import ( + "encoding/json" + "slices" + "sort" + "sync" + "time" + + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + catseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/keys" + "git.novox.be/novox/mesh-sdk/go/views" +) + +// Branches keeps the keeper's views of the catalog half, one per watched branch. +type Branches struct { + // Open adopts the view of the branch's catalog half and answers its name in the reader; Drop ends it, and Dropped + // forgets what the keeper held of the branch. + Open func(branch string) (string, error) + Drop func(name string) error + Dropped func(branch string) error + Logf func(string, ...any) + + mu sync.Mutex + names map[string]string +} + +// Watched sets the watched branches to the ones the registrations name: a branch new to them is adopted, a branch no +// registration names any more is dropped. +func (b *Branches) Watched(regs []catseat.Registration) { + want := map[string]bool{} + for _, r := range regs { + for _, branch := range r.Branches { + want[keys.EscapeName(branch)] = true + } + } + b.mu.Lock() + if b.names == nil { + b.names = map[string]string{} + } + var adopt, drop []string + for branch := range want { + if _, ok := b.names[branch]; !ok { + adopt = append(adopt, branch) + } + } + for branch := range b.names { + if !want[branch] { + drop = append(drop, branch) + } + } + b.mu.Unlock() + sort.Strings(adopt) + sort.Strings(drop) + for _, branch := range adopt { + name, err := b.Open(branch) + if err != nil { + b.Logf("the catalog half of %s is not followed: %v", branch, err) + continue + } + b.mu.Lock() + b.names[branch] = name + b.mu.Unlock() + } + for _, branch := range drop { + b.mu.Lock() + name := b.names[branch] + delete(b.names, branch) + b.mu.Unlock() + if err := b.Drop(name); err != nil { + b.Logf("the view %s is not dropped: %v", name, err) + } + if err := b.Dropped(branch); err != nil { + b.Logf("the conditions of the dropped branch %s are not cleared: %v", branch, err) + } + } +} + +// Followed is the branches followed, in name order. +func (b *Branches) Followed() []string { + b.mu.Lock() + defer b.mu.Unlock() + out := make([]string, 0, len(b.names)) + for branch := range b.names { + out = append(out, branch) + } + slices.Sort(out) + return out +} + +// followCatalog is Branches over the keeper's reader: each branch's view is the catalog client's, whose changes hand the +// branch's whole half to the keeper. +func followCatalog(k *Keeper, v *views.Reader, call meshcatalogclient.Call) *Branches { + return &Branches{ + Logf: logf, + Open: func(branch string) (string, error) { + var mu sync.Mutex + var c *meshcatalogclient.Catalog + changed := func(string, json.RawMessage, bool, time.Time) { + mu.Lock() + view := c + mu.Unlock() + if view == nil { + return + } + if half, ok := view.View(); ok { + if err := k.CatalogHeld(branch, half.Entries); err != nil { + logf("%v", err) + } + } + } + mu.Lock() + defer mu.Unlock() + made, err := meshcatalogclient.WireCatalog(v, call, branch, changed) + if err != nil { + return "", err + } + c = made + return made.Follower.Owner + "." + made.Follower.State, nil + }, + Drop: v.Drop, + Dropped: k.CatalogDropped, + } +} diff --git a/modules/mesh-conditions/cmd/mesh-conditions/branches_test.go b/modules/mesh-conditions/cmd/mesh-conditions/branches_test.go new file mode 100644 index 000000000..f7bbd1d8f --- /dev/null +++ b/modules/mesh-conditions/cmd/mesh-conditions/branches_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "slices" + "testing" + + catseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" +) + +// Design 47 G §532: the keeper adopts a branch a registration adds while it runs, and drops one no registration names. +func TestTheKeeperFollowsTheBranchesTheRegistrationsName(t *testing.T) { + var opened, dropped, forgotten []string + b := &Branches{ + Logf: func(string, ...any) {}, + Open: func(branch string) (string, error) { + opened = append(opened, branch) + return "mesh-catalog." + branch, nil + }, + Drop: func(name string) error { dropped = append(dropped, name); return nil }, + Dropped: func(branch string) error { forgotten = append(forgotten, branch); return nil }, + } + reg := func(module string, branches ...string) catseat.Registration { + return catseat.Registration{Module: module, Branches: branches} + } + steps := []struct { + name string + regs []catseat.Registration + followed, opened, dropped, forgotten []string + }{ + {"the first registrations", []catseat.Registration{reg("a", "trunk")}, []string{"trunk"}, []string{"trunk"}, nil, nil}, + {"a branch registered while it runs", []catseat.Registration{reg("a", "trunk"), reg("b", "feature/x")}, []string{"feature/x", "trunk"}, []string{"trunk", "feature/x"}, nil, nil}, + {"a branch nobody names is dropped", []catseat.Registration{reg("a", "trunk")}, []string{"trunk"}, []string{"trunk", "feature/x"}, []string{"mesh-catalog.feature/x"}, []string{"feature/x"}}, + } + for _, s := range steps { + b.Watched(s.regs) + if got := b.Followed(); !slices.Equal(got, s.followed) { + t.Errorf("%s: followed %v, want %v", s.name, got, s.followed) + } + if !slices.Equal(opened, s.opened) || !slices.Equal(dropped, s.dropped) || !slices.Equal(forgotten, s.forgotten) { + t.Errorf("%s: opened %v dropped %v forgotten %v", s.name, opened, dropped, forgotten) + } + } +} diff --git a/modules/mesh-conditions/cmd/mesh-conditions/catalog.go b/modules/mesh-conditions/cmd/mesh-conditions/catalog.go index b513677b5..f7f89381c 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/catalog.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/catalog.go @@ -52,6 +52,20 @@ func (k *Keeper) CatalogHeld(branch string, entries map[string]catseat.CatalogEn k.mu.Lock() k.init() k.halves[branch] = entries + return k.sweepCatalog() +} + +// CatalogDropped forgets a branch no registration watches any more (design 47 G §532): the modules only it listed leave +// the sources with their conditions. +func (k *Keeper) CatalogDropped(branch string) error { + k.mu.Lock() + k.init() + delete(k.halves, branch) + return k.sweepCatalog() +} + +// sweepCatalog clears the conditions of every observer no held revision lists. Called with mu held; it releases it. +func (k *Keeper) sweepCatalog() error { var gone []string seen := map[string]bool{} consider := func(observer string) { diff --git a/modules/mesh-conditions/cmd/mesh-conditions/keeper_test.go b/modules/mesh-conditions/cmd/mesh-conditions/keeper_test.go index 1f1836648..31e11071f 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/keeper_test.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/keeper_test.go @@ -607,6 +607,16 @@ func TestTheKeeperSetsItsViewFromWhatAnObserverStates(t *testing.T) { } return k2.CatalogHeld("main", map[string]catseat.CatalogEntry{selfCheckModule: {}}) }, []string{"machine.d.silent", "machine.g.silent"}, whyLeftCatalog}, + // Design 47 G §532: a module only a second watched branch lists is a source, and the branch dropped takes it away. + {"a module listed only by a second branch is a source", func() error { + if err := r.k.CatalogHeld("feature", map[string]catseat.CatalogEntry{"fixture": {Streams: []string{EventFindingWritten}}}); err != nil { + return err + } + return r.k.FindingWritten("fixture", "ace", mustJSON(t, f("fixture", "z"))) + }, []string{"machine.d.silent", "machine.g.silent", "machine.z.silent"}, ""}, + {"the second branch dropped takes its source away", func() error { + return r.k.CatalogDropped("feature") + }, []string{"machine.d.silent", "machine.g.silent"}, whyLeftCatalog}, } for _, s := range steps { if err := s.do(); err != nil { diff --git a/modules/mesh-conditions/cmd/mesh-conditions/main.go b/modules/mesh-conditions/cmd/mesh-conditions/main.go index 92c9ed5b3..55f780583 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/main.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/main.go @@ -18,7 +18,9 @@ import ( "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshwantsclient" + catseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" meshwants "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants" "git.novox.be/novox/mesh-sdk/go/views" ) @@ -156,6 +158,14 @@ func NewViews(k *Keeper) *views.Reader { if _, err := meshwantsclient.WireAssignment(v, stdio.Ask, func(rev meshwants.AssignmentRevision) { _ = k.AssignmentHeld(rev) }); err != nil { logf("the assignment half is not followed: %v", err) } + // One view of the catalog half per watched branch, the branches the registrations name, adopted and dropped while the + // keeper runs (design 47 G §365, §532). + branches := followCatalog(k, v, stdio.Ask) + var regs *meshcatalogclient.Registrations + regs, err := meshcatalogclient.WireRegistrations(v, stdio.Ask, func(catseat.Registration, bool) { branches.Watched(regs.All()) }) + if err != nil { + logf("the registrations are not followed: %v", err) + } return v } diff --git a/modules/mesh-conditions/cmd/mesh-conditions/manifest_test.go b/modules/mesh-conditions/cmd/mesh-conditions/manifest_test.go index 6b2d320ea..273a924a0 100644 --- a/modules/mesh-conditions/cmd/mesh-conditions/manifest_test.go +++ b/modules/mesh-conditions/cmd/mesh-conditions/manifest_test.go @@ -36,9 +36,10 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) { // The assigner's wants and assignments are asked once of their read tools and followed by their events (ADR 0615 §1); // the observers' findings are heard as events and no `findings` is read (design 47 G §383). reads := []string{HavesRead, AttemptsRead} - if !slices.Equal(m.Invokes, []string{WantsTool, AssignmentsTool, "seat:mesh-hand-acts.hand-act", "seat:mesh-wants.revision"}) || + if !slices.Equal(m.Invokes, []string{WantsTool, AssignmentsTool, "seat:mesh-hand-acts.hand-act", "seat:mesh-wants.revision", + "seat:mesh-catalog.registrations", "seat:mesh-catalog.revision"}) || !slices.Equal(m.Consumes, []string{"mesh-delivery-assigner.views.wants", "mesh-delivery-assigner.views.assignments", - "*.finding-written", "*.finding-cleared", "*.findings-stated", "mesh-watchdogs.machine-away", "mesh-watchdogs.machine-back", "mesh-wants.assignment-revision", "node-engine.running"}) { + "*.finding-written", "*.finding-cleared", "*.findings-stated", "mesh-watchdogs.machine-away", "mesh-watchdogs.machine-back", "mesh-wants.assignment-revision", "mesh-catalog.registration-changed", "mesh-catalog.catalog-revision", "node-engine.running"}) { t.Errorf("invokes %v, consumes %v", m.Invokes, m.Consumes) } for _, r := range m.Reads { diff --git a/modules/mesh-conditions/module.json b/modules/mesh-conditions/module.json index f9aed5620..66cced877 100644 --- a/modules/mesh-conditions/module.json +++ b/modules/mesh-conditions/module.json @@ -21,7 +21,9 @@ "mesh-delivery-assigner.assigner_read_wants", "mesh-delivery-assigner.assigner_read_assignments", "seat:mesh-hand-acts.hand-act", - "seat:mesh-wants.revision" + "seat:mesh-wants.revision", + "seat:mesh-catalog.registrations", + "seat:mesh-catalog.revision" ], "consumes": [ "mesh-delivery-assigner.views.wants", @@ -32,6 +34,8 @@ "mesh-watchdogs.machine-away", "mesh-watchdogs.machine-back", "mesh-wants.assignment-revision", + "mesh-catalog.registration-changed", + "mesh-catalog.catalog-revision", "node-engine.running" ], "state": [