A-360: the indexer says each new trunk revision's change of modules on views.modules

The grants part follows the first view modules on the indexer's views family after its first ask. G's indexer keeps
no state modules, so nothing said its changes and a newly merged module reached grants only when it asked again. A
new trunk revision that changes the modules is now said as the mark of modules at that revision's number (the number
the first view answers every value at), after which a reader short of it asks the first view once; views.modules is
among the indexer's emits.

Builds: design 47 G A-360, A-354
Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
This commit is contained in:
2026-11-13 17:33:57 +01:00
parent 359c37d17f
commit ec58535ca0
6 changed files with 115 additions and 5 deletions
@@ -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)},
@@ -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)
}
}
@@ -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 {
@@ -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
}
@@ -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)
}
@@ -151,6 +151,7 @@
"views.graph",
"views.heard",
"views.manifests",
"views.modules",
"views.packages",
"views.predefined",
"views.seat-definitions",