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 148243ce..f1aa923e 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main.go @@ -466,7 +466,8 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro SeatDefinitions: awaitedStore{b: seatDefs}, ReadFile: readFileVia(calls.Call), SaySeatDefinition: saySeats, Revisions: revisions, EntryIndex: awaitedStore{b: process.AwaitBucket(ctx, js, conn, index.Bucket(cred.Module, index.EntryIndexState), "no entry is found by its fingerprint", logger.Printf)}, // Message a kept call: a read, called again through a transport fault (ADR 0668 §1) // Each catalog revision may bring another holder of a build seat: the build modules are found again (buildmodules.go). - SayCatalogRevision: afterRevision(refreshingBuildModules(sayCatalogRevision(conn, cred), &buildFollow), &fillFollow), + // A new trunk revision that changes the modules is said on the views family of modules too (design 47 G A-360). + SayCatalogRevision: afterRevision(refreshingBuildModules(sayingModulesView(sayCatalogRevision(conn, cred), cred, views.JetStreamPublish(js)), &buildFollow), &fillFollow), SayFill: sayFill(conn, cred), // The catalog's released versions (design 47 G §349): the state versions, bound when it appears, and the event version-released. Versions: awaitedStore{b: process.AwaitBucket(ctx, js, conn, index.Bucket(cred.Module, index.VersionsState), "no released version is kept, so no package-published makes a version", logger.Printf)}, diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/modulesview_test.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/modulesview_test.go new file mode 100644 index 00000000..897504e3 --- /dev/null +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/modulesview_test.go @@ -0,0 +1,56 @@ +package main + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/nats-io/nats.go" + + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/keys" + "git.novox.be/novox/mesh-sdk/go/views" + + "mesh-delivery-indexer/internal/index" +) + +// Design 47 G A-360 (completing A-354): a new trunk revision that changes the modules says one views event of modules, +// the mark at the revision's number, on the indexer's own event subject; a revision of another watched branch, one that +// changes nothing, and a publish that fails say so. +func TestANewTrunkRevisionChangingTheModulesSaysOneViewsEventOfModules(t *testing.T) { + at := time.Date(2026, 11, 13, 18, 0, 0, 0, time.UTC) + var sent []*nats.Msg + var ids []string + var fail error + said := 0 + say := sayingModulesView(func(context.Context, meshcatalog.CatalogRevisionEvent) error { said++; return nil }, testAccount, + func(_ context.Context, m *nats.Msg, id string) error { + sent, ids = append(sent, m), append(ids, id) + return fail + }) + trunk := keys.EscapeName(index.TrunkBranch) + for _, e := range []meshcatalog.CatalogRevisionEvent{ + {Revision: 7, Kind: "trunk", Branch: trunk, At: &at, Changed: []string{"avahi", "nats"}}, + {Revision: 8, Kind: "trunk", Branch: keys.EscapeName("feat/x"), At: &at, Changed: []string{"avahi"}}, + {Revision: 9, Kind: "candidate", Branch: trunk, At: &at, Changed: []string{"avahi"}}, + {Revision: 10, Kind: "trunk", Branch: trunk, At: &at}, + } { + if err := say(context.Background(), e); err != nil { + t.Fatalf("revision %d: %v", e.Revision, err) + } + } + if said != 4 || len(sent) != 1 || sent[0].Subject != testAccount.EventSubject(views.EventName("modules")) { + t.Fatalf("catalog-revision said %d times, views events %d (%v), want 4 and one on views.modules", said, len(sent), sent) + } + ev, err := views.DecodeEvent(sent[0].Data) + if err != nil || !ev.Mark || ev.Owner != testIndexer || ev.State != "modules" || ev.Revision != 7 || !ev.At.Equal(at) || ids[0] != ev.ID() { + t.Errorf("the views event of modules is %+v (%v), id %q, want the mark of modules at revision 7 under its own id", ev, err, ids[0]) + } + fail = errors.New("the bus refuses") + if err := say(context.Background(), meshcatalog.CatalogRevisionEvent{Revision: 11, Kind: "trunk", Branch: trunk, At: &at, Changed: []string{"nats"}}); err == nil || + !strings.Contains(err.Error(), "views event of modules at revision 11") { + t.Errorf("a views event not said answered %v, want it said", err) + } +} diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/objects.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/objects.go index 345d7996..459c2584 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/objects.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/objects.go @@ -16,6 +16,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/process" + "git.novox.be/novox/mesh-sdk/go/views" "mesh-delivery-indexer/internal/index" ) @@ -145,6 +146,35 @@ func sayCatalogRevision(conn *nats.Conn, cred process.Account) func(context.Cont } } +// sayingModulesView is say, and then, for a new trunk revision that changes the modules, the indexer's views event of +// modules (index.ModulesViewsEvent; design 47 G A-360) on its own event subject under the event's duplicate id, so the +// readers of the first view modules hear a newly merged module without asking again. One not said is returned with +// say's error, which the take-in logs. +func sayingModulesView(say func(context.Context, meshcatalog.CatalogRevisionEvent) error, cred process.Account, + publish func(context.Context, *nats.Msg, string) error) func(context.Context, meshcatalog.CatalogRevisionEvent) error { + return func(ctx context.Context, e meshcatalog.CatalogRevisionEvent) error { + err := say(ctx, e) + ev, ok := index.ModulesViewsEvent(cred.Module, e) + if !ok { + return err + } + data, encErr := ev.Encode() + if encErr == nil { + msg := nats.NewMsg(cred.EventSubject(views.EventName(index.ModulesState))) + msg.Header.Set("x-event-id", ev.ID()) + msg.Header.Set("x-source", cred.Module) + msg.Header.Set("x-time", time.Now().UTC().Format(time.RFC3339Nano)) + msg.Header.Set("content-type", "application/json") + msg.Data = data + encErr = publish(ctx, msg, ev.ID()) + } + if encErr != nil { + return errors.Join(err, fmt.Errorf("the views event of modules at revision %d is not said, its readers hold the revision before until the next: %w", e.Revision, encErr)) + } + return err + } +} + // sayFill publishes the seat mesh-catalog's event filling, full or room of the catalog graph revisions under the duplicate // id given (milestone 47/G W4.6), so one said twice is heard once. func sayFill(conn *nats.Conn, cred process.Account) func(context.Context, string, string, meshcatalog.Fill) error { diff --git a/modules/mesh-delivery-indexer/internal/index/views.go b/modules/mesh-delivery-indexer/internal/index/views.go index 93837615..16327b47 100644 --- a/modules/mesh-delivery-indexer/internal/index/views.go +++ b/modules/mesh-delivery-indexer/internal/index/views.go @@ -1,7 +1,12 @@ package index import ( + "time" + + "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-sdk/go/keys" + "git.novox.be/novox/mesh-sdk/go/views" ) // FirstViewTools are the indexer's first-view tools (ADR 0621 §1, ADR 0622 §1, ADR 0631 §1): the tool, the state it answers, and @@ -41,3 +46,19 @@ var ViewedStates = map[string]bool{ ManifestsState: true, VerdictsState: true, PredefinedState: true, ConfigCandidatesState: true, CandidatesState: true, PackagesState: true, HeardState: true, TakenState: true, FindingsState: true, SeatDefinitionsState: true, UnjudgedState: true, } + +// ModulesViewsEvent is what the indexer says on its views family of modules when a new revision of the trunk branch +// changes the modules (design 47 G A-360, completing A-354): the first view modules answers every value at the trunk +// revision's number, so one revision's changes are said as one event, the mark at that number, after which a reader +// whose picture is short of it asks the first view once and holds the new revision whole. A revision of another watched +// branch, or one that changes nothing, says nothing (false). +func ModulesViewsEvent(owner string, e meshcatalog.CatalogRevisionEvent) (views.Event, bool) { + if e.Kind != "trunk" || e.Branch != keys.EscapeName(TrunkBranch) || e.Revision <= 0 || len(e.Changed) == 0 { + return views.Event{}, false + } + var at time.Time + if e.At != nil { + at = e.At.UTC() + } + return views.Event{V: views.Version, Owner: owner, State: ModulesState, Revision: uint64(e.Revision), At: at, Mark: true}, true +} diff --git a/modules/mesh-delivery-indexer/manifest_test.go b/modules/mesh-delivery-indexer/manifest_test.go index f29ca43e..fa439b68 100644 --- a/modules/mesh-delivery-indexer/manifest_test.go +++ b/modules/mesh-delivery-indexer/manifest_test.go @@ -220,7 +220,7 @@ func TestManifestDeclarations(t *testing.T) { // 1238/AC1, 1444 (reads the build t.Errorf("version = %q, want MAJOR.MINOR (design 47 G §368)", m.Version) } // Design 47 G §196, §185 (test 61): no state modules, no read tool or view of it, and no mesh-wide hold with its states, - // tools and the engines' reads it weighed; its first view stays for the grants part (A-354, above). + // tools and the engines' reads it weighed; its first view and its views family stay for the grants part (A-354, A-360). for _, s := range m.State { if slices.Contains([]string{"modules", "holds", "engine-reads", "hold-bounds"}, s.Name) { t.Errorf("the indexer declares the state %s, which design 47 G retires", s.Name) @@ -231,8 +231,8 @@ func TestManifestDeclarations(t *testing.T) { // 1238/AC1, 1444 (reads the build t.Errorf("the indexer serves %s, which design 47 G retires", retired) } } - if slices.Contains(m.Emits, "views.modules") || slices.Contains(m.Consumes, "node-engine.manifest-reads-changed") { - t.Errorf("the indexer says views.modules or hears manifest-reads-changed: emits %v, consumes %v", m.Emits, m.Consumes) + if slices.Contains(m.Consumes, "node-engine.manifest-reads-changed") { + t.Errorf("the indexer hears manifest-reads-changed: consumes %v", m.Consumes) } // Every read tool of the indexer a reader of this repository invokes is one it serves (the checker's seventh first // view, indexer_read_seat_definitions, was invoked and not served). @@ -309,7 +309,8 @@ func TestManifestDeclarations(t *testing.T) { // 1238/AC1, 1444 (reads the build // registrations' history (design 47 G §2, asserted above). // ADR 0636 §4: seat-definition-changed and -removed are the seat mesh-catalog's events, said on the seat's subject and kept // on the shared events stream, and not among its own emits. - for _, state := range []string{"candidates", "config-candidates", "findings", "graph", "heard", "manifests", "packages", "predefined", "seat-definitions", "taken", "verdicts"} { + // Design 47 G A-360: modules too, at each new trunk revision that changes it. + for _, state := range []string{"candidates", "config-candidates", "findings", "graph", "heard", "manifests", "modules", "packages", "predefined", "seat-definitions", "taken", "verdicts"} { if !slices.Contains(m.Emits, views.EventPrefix+state) { t.Errorf("emits = %v: want %s%s", m.Emits, views.EventPrefix, state) } diff --git a/modules/mesh-delivery-indexer/module.json b/modules/mesh-delivery-indexer/module.json index 9f9b320c..c9102662 100644 --- a/modules/mesh-delivery-indexer/module.json +++ b/modules/mesh-delivery-indexer/module.json @@ -151,6 +151,7 @@ "views.graph", "views.heard", "views.manifests", + "views.modules", "views.packages", "views.predefined", "views.seat-definitions",