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 431eea94..da114536 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 @@ -365,9 +365,14 @@ func putOfTheBus(t *testing.T) { buildKV := mem() buildKV.values[bkey] = built - modules := mem() - modules.values["nats"] = mod - s := newPutTools(memStore{putsState: putsKV, builds.BuilderModule + ".builds": buildKV, buckets.IndexerModule + ".modules": modules}) + // Issue 1966: newest is asked of the indexer's first view of modules, never read from its bus state. + indexer := &fakeIndexer{views: map[string]map[string][]byte{"mesh-delivery-indexer.modules": {"mesh-catalog": mod, "nats": mod}}} + s := newPutTools(memStore{putsState: putsKV, builds.BuilderModule + ".builds": buildKV}, func(key string, body any) (json.RawMessage, error) { + if raw, ok, err := indexer.answer(key, body); ok { + return raw, err + } + return nil, fmt.Errorf("%s is not served here", key) + }) s.now = func() time.Time { return now } was := answerWithin answerWithin = 0 @@ -378,8 +383,11 @@ func putOfTheBus(t *testing.T) { if got, err := s.version("nats", "fd497ab80000"); err != nil || got != fp { t.Errorf("the prefix of the fingerprint built reads %q, %v", got, err) } - if got, err := s.version("nats", "newest"); err != nil || got != fp { - t.Errorf("newest reads %q, %v", got, err) + if got, err := s.version("nats", "newest"); err != nil || got != fp || indexer.asks["mesh-delivery-indexer.modules"] != 2 { + t.Errorf("newest reads %q, %v, asking %d pages of the indexer's first view (want its 2)", got, err, indexer.asks["mesh-delivery-indexer.modules"]) + } + if _, err := s.version("ghost", "newest"); err == nil || !strings.Contains(err.Error(), "holds no module ghost") { + t.Errorf("newest of a module the indexer does not hold: %v", err) } ans, err := s.doPut(map[string]any{"node": "novox", "module": "nats", "version": fp, "hold": "true", "why": "the bus's user list"}, "shanks.node-tools") if err != nil { diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/tools.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/tools.go index 62f94dbd..1d2a8313 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/tools.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/tools.go @@ -4,7 +4,6 @@ import ( "encoding/json" "errors" "fmt" - "mesh-delivery-assigner/internal/buckets" "mesh-delivery-assigner/internal/puts" "os" "regexp" @@ -45,7 +44,9 @@ type store interface { // putTools serves the tools over a store, and keeps the newest value of every put it heard, to wait on. type putTools struct { - st store + st store + // ask asks a module's tool (stdio.Ask, or a test's): the indexer's first view of modules for newest. + ask func(key string, body any) (json.RawMessage, error) now func() time.Time mu sync.Mutex // heard is every put the watch handed, by key; changed is closed and replaced at each. @@ -53,8 +54,8 @@ type putTools struct { changed chan struct{} } -func newPutTools(st store) *putTools { - return &putTools{st: st, now: time.Now, heard: map[string][]byte{}, changed: make(chan struct{})} +func newPutTools(st store, ask func(string, any) (json.RawMessage, error)) *putTools { + return &putTools{st: st, ask: ask, now: time.Now, heard: map[string][]byte{}, changed: make(chan struct{})} } // hear takes a put's change in from the watch. @@ -78,10 +79,12 @@ func (s *putTools) version(module, version string) (string, error) { v := strings.ToLower(strings.TrimSpace(version)) switch { case v == "newest": - raw, _, found, err := s.st.get(buckets.ModulesRead, module) + // Asked of the indexer's first view of modules, never read from its bus state (ADR 0596, ADR 0621; issue 1966). + mods, err := indexerView(s.ask, indexerModules) if err != nil { return "", fmt.Errorf("the indexer's modules cannot be read: %w", err) } + raw, found := mods[module] if !found { return "", fmt.Errorf("the indexer holds no module %s at the trunk", module) } @@ -345,7 +348,7 @@ func (runtimeStore) keys(state string) ([]string, error) { return stdio.State(st // serveTools serves the tools to the runtime until it stops, hearing every put to answer assigner_put with its first steps. func serveTools() error { - s := newPutTools(runtimeStore{}) + s := newPutTools(runtimeStore{}, stdio.Ask) go func() { <-stdio.Served() for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main.go index 72953a5e..84a9c770 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main.go @@ -1,6 +1,6 @@ // Command mesh-delivery-checker-tools serves the checker's read tools (novox/hq ADR 0484 clause 13, issue 1686): the runtime -// launches it for a call, it reads the checker's verdicts and kept states and the indexer's candidates through the checker's -// state and reads. It writes only the tries (the try's tools) and, asking a check again by hand, one verdict. The checker is the other program: it serves no tool (ADR 0437 §4). +// launches it for a call, it reads the checker's verdicts and kept states through the checker's state and reads, and the +// indexer's candidates and modules through the indexer's read tools. It writes only the tries (the try's tools) and, asking a check again by hand, one verdict. The checker is the other program: it serves no tool (ADR 0437 §4). package main import ( @@ -18,6 +18,7 @@ import ( "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/buscalls" "git.novox.be/novox/mesh-sdk/go/views" ) @@ -58,6 +59,52 @@ func read(s stdio.KeptState) check.StateRead { }} } +// ownerRead is a state of another module read through that module's ordinary read tool (views.ReadTool), asked through ask +// (stdio.Ask, or a test's): its keys with no argument, one key's value with key. Another module's data is asked through its +// tools, never read from its bus state (novox/hq ADR 0596); the manifest invokes the tool, and the owner answers every reader +// that invokes it. Each read is asked when it is read: the owner's state is the truth, and the tools keep no copy. +func ownerRead(ask func(string, any) (json.RawMessage, error), tool string) check.StateRead { + call := func(args map[string]any, into any) error { + reads.Add(1) + raw, err := ask(tool, args) + if err != nil { + return fmt.Errorf("%s does not answer: %w", tool, err) + } + answer, said, ok := buscalls.Unwrap(raw) + if !ok { + return fmt.Errorf("%s answered: %s", tool, said) + } + if err := json.Unmarshal(answer, into); err != nil { + return fmt.Errorf("%s answered what does not decode: %w", tool, err) + } + return nil + } + return check.StateRead{ + Keys: func() ([]string, error) { + var a views.ReadAnswer + if err := call(map[string]any{}, &a); err != nil { + return nil, err + } + return a.Keys, nil + }, + Get: func(key string) (json.RawMessage, error) { + var a views.KeyAnswer + if err := call(map[string]any{"key": key}, &a); err != nil { + return nil, err + } + if !a.Found { + return nil, nil + } + return a.Value, nil + }} +} + +// The indexer's read tools the tools ask for its candidates and modules (the manifest's invokes). +const ( + indexerCandidatesTool = check.IndexerModule + ".indexer_read_candidates" + indexerModulesTool = check.IndexerModule + ".indexer_read_modules" +) + // runsOf says which commit the mesh runs of a module from the indexer's modules state, as the checker's own record of it does // (the commit the module's record was computed at); "" when the record is not readable. type runsOf struct{ modules check.StateRead } @@ -371,13 +418,13 @@ func stepBound() time.Duration { } func main() { - r := check.Reader{VerdictState: read(stdio.State("verdicts")), KeptState: read(stdio.State("kept")), CandidateState: read(stdio.State("mesh-delivery-indexer.candidates")), + r := check.Reader{VerdictState: read(stdio.State("verdicts")), KeptState: read(stdio.State("kept")), CandidateState: ownerRead(stdio.Ask, indexerCandidatesTool), PreviewState: read(stdio.State("previews")), InputState: read(stdio.State(check.InputsStateName)), StepStates: map[string]check.StateRead{"mesh-build-go": read(stdio.State("mesh-build-go.steps")), "mesh-build-node": read(stdio.State("mesh-build-node.steps"))}, HaveState: read(stdio.State("node-engine.have")), StepBound: stepBound(), TurnState: read(stdio.State("mesh-delivery-gate.turns")), HearingState: read(stdio.State(buildrun.HearingState)), AskState: read(stdio.State(check.AsksState)), BuildState: read(stdio.State("mesh-delivery-builder.builds"))} - r.Runs = runsOf{read(stdio.State("mesh-delivery-indexer.modules"))} // the node-engine at the commit the mesh runs, as the checker reads it + r.Runs = runsOf{ownerRead(stdio.Ask, indexerModulesTool)} // the node-engine at the commit the mesh runs, as the checker reads it tries := stdio.State(check.TriesState) source := stdio.State("mesh-delivery-gate.source") trunks := func() (map[string]string, error) { diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main_test.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main_test.go index 01b9bef8..96f73db1 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main_test.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "os" + "regexp" "slices" "strings" "testing" @@ -67,6 +68,73 @@ func TestTheToolsServedAreTheFiveReadTools(t *testing.T) { if _, err := RequestTools(check.StateRead{})[0].Run(map[string]any{}); err == nil { t.Errorf("answered with no state to read") } + // Issue 1966: another module's state is asked of its read tool, never read from its bus state (ADR 0596). Every + // stdio.State this program names of another module is one its manifest declares under reads, which the runtime grants; + // the indexer's candidates and modules are asked of indexer_read_candidates and indexer_read_modules, which it invokes. + t.Run("1966: another module's state is read only where the manifest declares it", func(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var man struct { + Reads []string `json:"reads"` + Invokes []string `json:"invokes"` + } + if err := json.Unmarshal(raw, &man); err != nil { + t.Fatal(err) + } + src, err := os.ReadFile("main.go") + if err != nil { + t.Fatal(err) + } + for _, m := range regexp.MustCompile(`stdio\.State\("([^"]+\.[^"]+)"\)`).FindAllStringSubmatch(string(src), -1) { + if !slices.Contains(man.Reads, m[1]) { + t.Errorf("main.go reads %s, which the manifest does not declare under reads: the runtime refuses it", m[1]) + } + } + for _, tool := range []string{indexerCandidatesTool, indexerModulesTool} { + if !slices.Contains(man.Invokes, tool) { + t.Errorf("the manifest does not invoke %s", tool) + } + } + }) + for _, c := range []struct { + name, key, answer, want string + failed bool + }{ + {name: "the keys", answer: `{"owner":"mesh-delivery-indexer","state":"candidates","keys":["a","b"]}`, want: "[a b]"}, + {name: "one key, wrapped as the tool runner hands it", key: "a", + answer: `{"content":[{"type":"text","text":"{\"key\":\"a\",\"found\":true,\"value\":{\"commit\":\"c\"}}"}]}`, want: `{"commit":"c"}`}, + {name: "a key the indexer does not hold", key: "gone", answer: `{"key":"gone","found":false}`, want: ""}, + {name: "the tool refused", key: "a", answer: `{"content":[{"type":"text","text":"refused"}],"isError":true}`, failed: true}, + } { + t.Run("1966: the indexer's read tool, "+c.name, func(t *testing.T) { + var asked []string + r := ownerRead(func(key string, body any) (json.RawMessage, error) { + b, _ := json.Marshal(body) + asked = append(asked, key+" "+string(b)) + return json.RawMessage(c.answer), nil + }, indexerCandidatesTool) + var got string + var err error + if c.key == "" { + var keys []string + keys, err = r.Keys() + got = strings.Trim(strings.Join([]string{"[", strings.Join(keys, " "), "]"}, ""), " ") + } else { + var raw json.RawMessage + raw, err = r.Get(c.key) + got = string(raw) + } + want := []string{indexerCandidatesTool + " {}"} + if c.key != "" { + want = []string{indexerCandidatesTool + ` {"key":"` + c.key + `"}`} + } + if (err != nil) != c.failed || (!c.failed && got != c.want) || !slices.Equal(asked, want) { + t.Errorf("read %q, %v, asking %v; want %q, failed %v, asking %v", got, err, asked, c.want, c.failed, want) + } + }) + } // With nothing to read from a tool says so and changes nothing. for _, tl := range Tools(check.Reader{}) { if tl.Name == check.StatusReadTool {