From aac39f13265df9df887124d5011e3182ba168e3a Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 21:34:20 +0100 Subject: [PATCH 01/13] G findings4: gofmt of the indexer's and watchman's manifest_test.go Builds: design 47 G (lead's handover, round 4: gofmt only, no design line) Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- .../mesh-delivery-indexer/manifest_test.go | 2 +- .../mesh-delivery-watchman/manifest_test.go | 22 +++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/modules/mesh-delivery-indexer/manifest_test.go b/modules/mesh-delivery-indexer/manifest_test.go index e61d668e..fb11fcfd 100644 --- a/modules/mesh-delivery-indexer/manifest_test.go +++ b/modules/mesh-delivery-indexer/manifest_test.go @@ -194,7 +194,7 @@ func TestManifestDeclarations(t *testing.T) { // 1238/AC1, 1444 (reads the build index.PendingTool, index.TakeInPendingTool, index.DropPendingTool, index.TryCandidatesTool, index.DropTryCandidateTool, index.RecoverTriesTool, index.TakeInMergeTool, index.FingerprintsAgainTool, // issue 1966; 1585/T12, T13 (ADR 0633 §5) // milestone 47/G W4.12 (design 47, the indexer's tools): the catalog graph revisions read and steered. index.CatalogRevisionTool, index.CatalogRevisionsTool, index.CandidateGraphsTool, index.RecomputeTool, index.DropCandidateGraphTool, - index.EntryIndexTool, // design 47 G §675 + index.EntryIndexTool, // design 47 G §675 index.ProposalsTool, index.ProposalDropTool, index.SeedAgainTool) // design 47 G §667, §8 if !reflect.DeepEqual(m.Tools, want) || len(want) != 45 || slices.Contains(m.Tools, "modules") { t.Errorf("tools = %v, want %v", m.Tools, want) diff --git a/modules/mesh-delivery-watchman/manifest_test.go b/modules/mesh-delivery-watchman/manifest_test.go index 04571e4d..9fc4cd0f 100644 --- a/modules/mesh-delivery-watchman/manifest_test.go +++ b/modules/mesh-delivery-watchman/manifest_test.go @@ -47,17 +47,17 @@ type claim struct { } type manifest struct { - Module string `json:"module"` - Claims []claim `json:"claims"` - Tools []string `json:"tools"` - Moved bool `json:"moved"` - Version string `json:"version"` - Slug string `json:"slug"` - Reads []string `json:"reads"` - Consumes []string `json:"consumes"` - Invokes []string `json:"invokes"` - State []state `json:"state"` - Streams []struct { + Module string `json:"module"` + Claims []claim `json:"claims"` + Tools []string `json:"tools"` + Moved bool `json:"moved"` + Version string `json:"version"` + Slug string `json:"slug"` + Reads []string `json:"reads"` + Consumes []string `json:"consumes"` + Invokes []string `json:"invokes"` + State []state `json:"state"` + Streams []struct { Name string `json:"name"` Events []string `json:"events"` MaxAgeDays int `json:"max-age-days"` From 67cec41b9cbb58be7967ae4e369aa89a7134b655 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 21:48:23 +0100 Subject: [PATCH 02/13] G findings4 teller: the teller's views reader writes its findings through mesh-sdk's finding writer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G §689, §686 (A-3), §639, A-13 Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- .../cmd/mesh-delivery-teller/findingwriter.go | 33 +++++++++++++++++++ .../mesh-delivery-teller/integration_test.go | 3 ++ .../cmd/mesh-delivery-teller/main.go | 9 +++-- modules/mesh-delivery-teller/go.mod | 3 +- 4 files changed, 43 insertions(+), 5 deletions(-) create mode 100644 modules/mesh-delivery-teller/cmd/mesh-delivery-teller/findingwriter.go diff --git a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/findingwriter.go b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/findingwriter.go new file mode 100644 index 00000000..8a628be6 --- /dev/null +++ b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/findingwriter.go @@ -0,0 +1,33 @@ +package main + +import ( + "context" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + + condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + "git.novox.be/novox/mesh-sdk/go/process" + "git.novox.be/novox/mesh-sdk/go/views" +) + +// findingWriter is the teller's mesh-sdk finding writer (design 47 G §461, §689): the teller observes only what its views +// reader finds, so its own observe states none and the writer's statement covers the reader's open findings. The writer +// forms its subjects itself (design 47 G §383, A-13). +func findingWriter(ctx context.Context, cred process.Account, conn *nats.Conn, js jetstream.JetStream, state process.State, logf func(string, ...any)) (*views.Writer[condseat.Finding, condseat.FindingCleared, condseat.FindingsStated, condseat.FindingsAskedEvent], error) { + w, err := views.NewWriter(views.WriterConfig[condseat.Finding, condseat.FindingCleared, condseat.FindingsStated, condseat.FindingsAskedEvent]{ + Module: cred.Module, State: state, Node: cred.Node, JS: js, Conn: conn, Logf: logf, + Observe: func(context.Context) (views.Observed[condseat.Finding], error) { + return views.Observed[condseat.Finding]{}, nil + }, + }) + if err != nil { + return nil, err + } + // A writer that cannot start (the stream of its asks is not there yet) is said; its findings are then kept in the + // state alone and said by the next start (as the node-engine's is). + if err := w.Start(ctx); err != nil { + logf("the finding writer cannot start, its findings are kept in the state alone: %v", err) + } + return w, nil +} diff --git a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/integration_test.go b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/integration_test.go index 86ba1c0b..636318ee 100644 --- a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/integration_test.go +++ b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/integration_test.go @@ -187,6 +187,9 @@ func fullConf(dir string) string { seatAsk, seatCancel, seatCondition, "$JS.API.DIRECT.GET.KV_"+messengerAsks+".$KV."+messengerAsks+".mesh-delivery-teller.>") // The durable consumer on EVENTS the controller makes for the seat it uses, pulled and acknowledged (issue 1126). teller = append(teller, "$JS.API.CONSUMER.INFO.EVENTS."+tellerConsumer, "$JS.API.CONSUMER.MSG.NEXT.EVENTS."+tellerConsumer, "$JS.ACK.EVENTS."+tellerConsumer+".>") + // The finding writer finds the stream of the keeper's findings-asked (design 47 G §384): the bed has none, so it is + // said and the findings stay in the state. + teller = append(teller, "$JS.API.STREAM.NAMES") teller = append(teller, "mesh.mod.mesh-delivery-teller.event.answered") // the manifest's emits (ADR 0596 §2) teller = append(teller, readOf(viewAnswers)...) // issue 1297 B10: the manifest's reads // The assigner's asks by its read tool and its views family (ADR 0615, DLV-M3), and the teller's own first-views and diff --git a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/main.go b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/main.go index a73eb3ba..9ac4db39 100644 --- a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/main.go +++ b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/main.go @@ -11,7 +11,6 @@ import ( "context" "errors" "fmt" - sdkfindings "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient/findings" occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" "io" "log" @@ -104,8 +103,12 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro return calls.Emit(answeredEvent, Answered{Key: key, Ask: askID, Answer: answer}, stdio.EmitOptions{MessageID: answeredID(key, askID)}) } reader := &views.ProcessReader{Module: tellerModule, Call: calls.Call, Logf: logger.Printf, Hub: hub} - findings := process.AwaitBucket(ctx, js, conn, findingsBucket, "a first view unanswered past its bound is said in the log alone", logger.Printf) - reader.Findings = func() views.FindingWriter { return sdkfindings.Writer(ctx, findings.KV) } + writer, err := findingWriter(ctx, cred, conn, js, calls.State("findings"), logger.Printf) + if err != nil { + return fmt.Errorf("the teller's finding writer: %w", err) + } + defer writer.Close() + reader.Findings = func() views.FindingWriter { return views.WriterFindings{Writer: writer} } asks, err := reader.Follow(assignerModule, assignerAsksState, assignerModule+".assigner_read_asks") if err != nil { return err diff --git a/modules/mesh-delivery-teller/go.mod b/modules/mesh-delivery-teller/go.mod index 4f586c1a..a8b768e0 100644 --- a/modules/mesh-delivery-teller/go.mod +++ b/modules/mesh-delivery-teller/go.mod @@ -29,7 +29,6 @@ require ( ) require ( - git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000 ) @@ -43,7 +42,7 @@ require ( git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-00010101000000-000000000000 // indirect git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000 // indirect git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-00010101000000-000000000000 // indirect - git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts v0.0.0-00010101000000-000000000000 // indirect git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000 // indirect git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000 // indirect From cd6e0ef317fe3c3f774e762f98a01aeb5f934f43 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 21:48:49 +0100 Subject: [PATCH 03/13] G findings4 pipeline: the runner's views reader writes its findings through mesh-sdk's finding writer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G §689, §686 (A-3), §639, A-13 Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- .../mesh-delivery-pipeline/findingwriter.go | 33 +++++++++++++++++++ .../cmd/mesh-delivery-pipeline/main.go | 2 +- .../cmd/mesh-delivery-pipeline/views.go | 12 ++++--- modules/mesh-delivery-pipeline/go.mod | 2 +- 4 files changed, 42 insertions(+), 7 deletions(-) create mode 100644 modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/findingwriter.go diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/findingwriter.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/findingwriter.go new file mode 100644 index 00000000..a976499b --- /dev/null +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/findingwriter.go @@ -0,0 +1,33 @@ +package main + +import ( + "context" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + + condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + "git.novox.be/novox/mesh-sdk/go/process" + "git.novox.be/novox/mesh-sdk/go/views" +) + +// findingWriter is the runner's mesh-sdk finding writer (design 47 G §461, §689): the runner observes only what its views +// reader finds, so its own observe states none and the writer's statement covers the reader's open findings. The writer +// forms its subjects itself (design 47 G §383, A-13). +func findingWriter(ctx context.Context, cred process.Account, conn *nats.Conn, js jetstream.JetStream, state process.State, logf func(string, ...any)) (*views.Writer[condseat.Finding, condseat.FindingCleared, condseat.FindingsStated, condseat.FindingsAskedEvent], error) { + w, err := views.NewWriter(views.WriterConfig[condseat.Finding, condseat.FindingCleared, condseat.FindingsStated, condseat.FindingsAskedEvent]{ + Module: cred.Module, State: state, Node: cred.Node, JS: js, Conn: conn, Logf: logf, + Observe: func(context.Context) (views.Observed[condseat.Finding], error) { + return views.Observed[condseat.Finding]{}, nil + }, + }) + if err != nil { + return nil, err + } + // A writer that cannot start (the stream of its asks is not there yet) is said; its findings are then kept in the + // state alone and said by the next start (as the node-engine's is). + if err := w.Start(ctx); err != nil { + logf("the finding writer cannot start, its findings are kept in the state alone: %v", err) + } + return w, nil +} diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/main.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/main.go index 5805b1e0..c9b95273 100644 --- a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/main.go +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/main.go @@ -56,7 +56,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro } defer calls.Close() hub := process.NewEventHub(logger.Printf) - rv, err := startViews(ctx, js, conn, cred, hub, calls.Call, logger.Printf) + rv, err := startViews(ctx, js, conn, cred, hub, calls.State("findings"), calls.Call, logger.Printf) if err != nil { return err } diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/views.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/views.go index 0ff4228f..d60f9e58 100644 --- a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/views.go +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/views.go @@ -5,10 +5,9 @@ import ( "sync" "time" + "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" - mcc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" - sdkfindings "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient/findings" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-sdk/go/process" @@ -30,11 +29,14 @@ type runnerViews struct { // startViews wires the runner's views on hub and asks each first view; register every other handler on hub before or // after, then start hub once. -func startViews(ctx context.Context, js jetstream.JetStream, conn process.Subscriber, cred process.Account, hub *process.EventHub, +func startViews(ctx context.Context, js jetstream.JetStream, conn *nats.Conn, cred process.Account, hub *process.EventHub, findingsState process.State, call meshcatalogclient.Call, logf func(string, ...any)) (*runnerViews, error) { - findings := process.AwaitBucket(ctx, js, conn, mcc.FindingsBucket(cred.Module), "a view waited for past five minutes is said in the log alone", logf) + findings, err := findingWriter(ctx, cred, conn, js, findingsState, logf) + if err != nil { + return nil, err + } status := process.AwaitBucket(ctx, js, conn, cred.Module+"_"+views.StatusState, "the runner's first views are not said in its first-view tool, nor asked again by hand", logf) - writer := func() views.FindingWriter { return sdkfindings.Writer(ctx, findings.KV) } + writer := func() views.FindingWriter { return views.WriterFindings{Writer: findings} } reader := &views.ProcessReader{Module: cred.Module, Call: call, Logf: logf, Hub: hub, Findings: writer} // A watched branch first named by a registration gets its view of the catalog half (CT10), its conditions and its diff --git a/modules/mesh-delivery-pipeline/go.mod b/modules/mesh-delivery-pipeline/go.mod index daa61da4..1f8869f5 100644 --- a/modules/mesh-delivery-pipeline/go.mod +++ b/modules/mesh-delivery-pipeline/go.mod @@ -9,8 +9,8 @@ require ( ) require ( - git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-delivery/contracts/go/clients/meshpipelinerunnerclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-delivery/contracts/go/seats/meshbuilds v0.0.0-00010101000000-000000000000 From 8e4039a618b5ddd91fd687b75d481691f7457428 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 21:49:16 +0100 Subject: [PATCH 04/13] G findings4 view: the status view's views reader writes its findings through mesh-sdk's finding writer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G §689, §686 (A-3), §639, A-13 Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- .../cmd/mesh-delivery-view/findingwriter.go | 33 +++++++++++++++++++ .../cmd/mesh-delivery-view/main.go | 17 +++++----- 2 files changed, 41 insertions(+), 9 deletions(-) create mode 100644 modules/mesh-delivery-view/cmd/mesh-delivery-view/findingwriter.go diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view/findingwriter.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view/findingwriter.go new file mode 100644 index 00000000..e2bc27e8 --- /dev/null +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view/findingwriter.go @@ -0,0 +1,33 @@ +package main + +import ( + "context" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + + condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + "git.novox.be/novox/mesh-sdk/go/process" + "git.novox.be/novox/mesh-sdk/go/views" +) + +// findingWriter is the view's mesh-sdk finding writer (design 47 G §461, §689): the view observes only what its views +// reader finds, so its own observe states none and the writer's statement covers the reader's open findings. The writer +// forms its subjects itself (design 47 G §383, A-13). +func findingWriter(ctx context.Context, cred process.Account, conn *nats.Conn, js jetstream.JetStream, state process.State, logf func(string, ...any)) (*views.Writer[condseat.Finding, condseat.FindingCleared, condseat.FindingsStated, condseat.FindingsAskedEvent], error) { + w, err := views.NewWriter(views.WriterConfig[condseat.Finding, condseat.FindingCleared, condseat.FindingsStated, condseat.FindingsAskedEvent]{ + Module: cred.Module, State: state, Node: cred.Node, JS: js, Conn: conn, Logf: logf, + Observe: func(context.Context) (views.Observed[condseat.Finding], error) { + return views.Observed[condseat.Finding]{}, nil + }, + }) + if err != nil { + return nil, err + } + // A writer that cannot start (the stream of its asks is not there yet) is said; its findings are then kept in the + // state alone and said by the next start (as the node-engine's is). + if err := w.Start(ctx); err != nil { + logf("the finding writer cannot start, its findings are kept in the state alone: %v", err) + } + return w, nil +} diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view/main.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view/main.go index 267f1463..b0181359 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view/main.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view/main.go @@ -22,7 +22,6 @@ import ( "syscall" "time" - sdkfindings "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient/findings" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate" "git.novox.be/novox/mesh-delivery/sdk/trunk" @@ -125,8 +124,12 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro } defer calls.Close() reader := &views.ProcessReader{Module: viewstate.ViewModule, Call: calls.Call, Logf: logger.Printf} - findings := process.AwaitBucket(ctx, js, conn, findingsBucket, "a first view unanswered past its bound is said in the log alone", logger.Printf) - reader.Findings = func() views.FindingWriter { return sdkfindings.Writer(ctx, findings.KV) } + writer, err := findingWriter(ctx, cred, conn, js, calls.State("findings"), logger.Printf) + if err != nil { + return fmt.Errorf("the view's finding writer: %w", err) + } + defer writer.Close() + reader.Findings = func() views.FindingWriter { return views.WriterFindings{Writer: writer} } for _, o := range ownersFollowed { if _, err := reader.Follow(o.owner, o.state, o.tool); err != nil { return err @@ -196,12 +199,8 @@ type ReachedEvent struct { Reach json.RawMessage `json:"reach"` } -// The view's own states of its first views (views.StatusState) and of its findings, where the condition keeper raises a -// first view unanswered past its bound (ADR 0615 §6). -var ( - firstViewsBucket = viewstate.ViewModule + "_" + views.StatusState - findingsBucket = viewstate.ViewModule + "_findings" -) +// The view's own state of its first views (views.StatusState). +var firstViewsBucket = viewstate.ViewModule + "_" + views.StatusState // ownersFollowed are the indexer's and the assigner's states the view's process follows by their first views, each by its // owner's read tool (as the view's invokes names it) and views family (its events on the shared events stream); the From c87fc4e6cf7a72c111dd58334cdce4058cebbd86 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 21:50:39 +0100 Subject: [PATCH 05/13] G findings4 checker: the checker's check-could-not-run and its views reader's findings go through mesh-sdk's finding writer, which observes the raised ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G §689, §686 (A-3), §639, §384, A-13 Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- .../mesh-delivery-checker/findingwriter.go | 82 +++++++++++++++++++ .../cmd/mesh-delivery-checker/main.go | 23 ++++-- modules/mesh-delivery-checker/go.mod | 3 +- .../internal/check/checker_test.go | 7 +- .../internal/check/couldnotrun.go | 20 +++++ 5 files changed, 125 insertions(+), 10 deletions(-) create mode 100644 modules/mesh-delivery-checker/cmd/mesh-delivery-checker/findingwriter.go diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/findingwriter.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/findingwriter.go new file mode 100644 index 00000000..ee0eeb3e --- /dev/null +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/findingwriter.go @@ -0,0 +1,82 @@ +package main + +import ( + "context" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + + condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + "git.novox.be/novox/mesh-sdk/go/keys" + "git.novox.be/novox/mesh-sdk/go/process" + "git.novox.be/novox/mesh-sdk/go/views" + + "mesh-delivery-checker/internal/check" +) + +type findingWriterOf = views.Writer[condseat.Finding, condseat.FindingCleared, condseat.FindingsStated, condseat.FindingsAskedEvent] + +// checkerFindings is the checker's finding writer (design 47 G §461, §689): it owns the finding check-could-not-run beside +// its views reader's, observes the raised ones of its kept checks, and writes both as the generated contract type. The +// writer forms its subjects itself (A-13). +type checkerFindings struct { + w *findingWriterOf + node string +} + +// newCheckerFindings makes and starts the writer; observe says the checks that could not run and whose finding is +// written (check.Checker.RaisedCouldNotRun). +func newCheckerFindings(ctx context.Context, cred process.Account, conn *nats.Conn, js jetstream.JetStream, state process.State, + observe func(context.Context) ([]views.Finding, error), logf func(string, ...any)) (*checkerFindings, error) { + c := &checkerFindings{node: cred.Node} + w, err := views.NewWriter(views.WriterConfig[condseat.Finding, condseat.FindingCleared, condseat.FindingsStated, condseat.FindingsAskedEvent]{ + Module: cred.Module, State: state, Node: cred.Node, JS: js, Conn: conn, Logf: logf, + Kinds: []string{check.CouldNotRunKind}, + Observe: func(ctx context.Context) (views.Observed[condseat.Finding], error) { + found, err := observe(ctx) + if err != nil { + return views.Observed[condseat.Finding]{}, err + } + var out views.Observed[condseat.Finding] + for _, f := range found { + out.Findings = append(out.Findings, c.body(f)) + } + return out, nil + }, + }) + if err != nil { + return nil, err + } + c.w = w + // A writer that cannot start (the stream of its asks is not there yet) is said; its findings are then kept in the + // state alone and said by the next start (as the node-engine's is). + if err := w.Start(ctx); err != nil { + logf("the finding writer cannot start, its findings are kept in the state alone: %v", err) + } + return c, nil +} + +// body lays the checker's finding out as the seat's contract type: a finding of the module, about the module on its node. +func (c *checkerFindings) body(f views.Finding) condseat.Finding { + module, _ := c.w.ModuleNode() + return condseat.Finding{Kind: f.Kind, Scope: views.FindingScope, ID: keys.EscapeName(f.ID), At: f.At, Since: f.At, + Severity: condseat.FindingSeverityWarning, Source: f.Kind, Summary: f.Summary, Headline: f.Headline, Explanation: f.Explanation, + Subject: condseat.Subject{Scope: views.FindingScope, ID: module, Machine: c.node}} +} + +// Put writes one finding; its `since` stays where its key exists. +func (c *checkerFindings) Put(f views.Finding) error { + return c.w.Write(context.Background(), c.body(f)) +} + +// Delete clears one finding. +func (c *checkerFindings) Delete(f views.Finding) error { + return c.w.Clear(context.Background(), views.FindingScope, keys.EscapeName(f.ID), f.Kind, "the checker no longer observes it") +} + +var _ views.FindingWriter = (*checkerFindings)(nil) + +// readerFindings is the views reader's findings through the same writer, as kinds of the reader's own. +func (c *checkerFindings) readerFindings() views.FindingWriter { + return views.WriterFindings{Writer: c.w} +} diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go index c82a599e..61cf7263 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go @@ -15,11 +15,10 @@ import ( "os" "os/signal" "strings" + "sync/atomic" "syscall" "time" - sdkfindings "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient/findings" - "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" @@ -77,7 +76,6 @@ func durationEnv(getenv func(string) string, name string, def time.Duration) (ti // ModulesBucket, ManifestsBucket). var ( firstViewsBucket = check.CheckerModule + "_" + views.StatusState - findingsBucket = check.CheckerModule + "_findings" ) const ( @@ -163,8 +161,15 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro hub := process.NewEventHub(logger.Printf) reader := &views.ProcessReader{Module: check.CheckerModule, Call: caller.Call, Logf: logger.Printf, Hub: hub} seats := seatList{reader: followSeatList(seatlist.ProcessBus(caller.Call, hub), logger.Printf)} - findings := process.AwaitBucket(ctx, js, conn, findingsBucket, "a first view unanswered past its bound is said in the log alone", logger.Printf) - reader.Findings = func() views.FindingWriter { return sdkfindings.Writer(ctx, findings.KV) } + // The finding writer is made once the checker is (its observe reads the checker's kept checks); until then a first view + // unanswered is said in the log alone. + var findings atomic.Pointer[checkerFindings] + reader.Findings = func() views.FindingWriter { + if f := findings.Load(); f != nil { + return f.readerFindings() + } + return nil + } // Each by the owner's read tool and views family as the owner's manifest names them. var followed error followOwner := func(owner, state, tool, stream string) *views.Picture { @@ -236,13 +241,19 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro Owed: kvStore{buckets[check.CheckerOwedBucket]}, // A check that could not run waits for its part's running, and is the finding check-could-not-run after its bound. CouldNotRun: awaitedKV{couldNotRunKV}, - Findings: sdkfindings.Writer(ctx, findings.KV), Trunks: trunks, Bound: checkBound, Link: link, Logf: logger.Printf, Settle: settleWindow, } + cf, err := newCheckerFindings(ctx, cred, conn, js, caller.State("findings"), ch.RaisedCouldNotRun, logger.Printf) + if err != nil { + return fmt.Errorf("the checker's finding writer: %w", err) + } + defer cf.w.Close() + ch.Findings = cf + findings.Store(cf) kept := make(chan struct{}, 1) ch.CouldNotRunKept = func() { select { diff --git a/modules/mesh-delivery-checker/go.mod b/modules/mesh-delivery-checker/go.mod index f568baf3..73fb6820 100644 --- a/modules/mesh-delivery-checker/go.mod +++ b/modules/mesh-delivery-checker/go.mod @@ -44,7 +44,6 @@ replace git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun require git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 require ( - git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-delivery/modules/mesh-delivery-checker/checkermsg v0.0.0-00010101000000-000000000000 ) @@ -53,7 +52,7 @@ replace git.novox.be/novox/mesh-delivery/modules/mesh-delivery-checker/checkerms replace git.novox.be/novox/mesh-delivery/sdk => ../../sdk require ( - git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts v0.0.0-00010101000000-000000000000 // indirect git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000 // indirect git.novox.be/novox/mesh-delivery/contracts/go/clients/gopackageregistryclient v0.0.0-00010101000000-000000000000 // indirect diff --git a/modules/mesh-delivery-checker/internal/check/checker_test.go b/modules/mesh-delivery-checker/internal/check/checker_test.go index 68bb4c5d..51e8775f 100644 --- a/modules/mesh-delivery-checker/internal/check/checker_test.go +++ b/modules/mesh-delivery-checker/internal/check/checker_test.go @@ -301,10 +301,13 @@ func TestACheckThatCouldNotRunIsAskedAgainWhenItsPartRuns(t *testing.T) { // N6/ if due := r.ch.CouldNotRunDue(ctx, now.Add(CouldNotRunBound)); !due.IsZero() || found[key].Kind != CouldNotRunKind || !strings.Contains(found[key].Summary, "forge on g14") { t.Fatalf("at its bound: due %v, findings %+v, want %s naming the part", due, found, CouldNotRunKind) } + if observed, err := r.ch.RaisedCouldNotRun(ctx); err != nil || len(observed) != 1 || observed[0].Kind != CouldNotRunKind || observed[0].ID != key { + t.Fatalf("observed at its bound = %+v, %v, want its one finding, for the finding writer's statement", observed, err) + } r.ch.PartRunning(ctx, "g14", "forge", "src2:1") r.ch.Wait() - if len(found) != 0 { - t.Errorf("checked again, the finding stays: %v", found) + if observed, _ := r.ch.RaisedCouldNotRun(ctx); len(found) != 0 || len(observed) != 0 { + t.Errorf("checked again, the finding stays: %v, observed %v", found, observed) } }, "its heads replaced, or its pull request closed, the record and the finding go": func(t *testing.T) { diff --git a/modules/mesh-delivery-checker/internal/check/couldnotrun.go b/modules/mesh-delivery-checker/internal/check/couldnotrun.go index 003bc80e..643e06e7 100644 --- a/modules/mesh-delivery-checker/internal/check/couldnotrun.go +++ b/modules/mesh-delivery-checker/internal/check/couldnotrun.go @@ -233,6 +233,26 @@ func (ch *Checker) CouldNotRunDue(ctx context.Context, now time.Time) time.Time return next } +// RaisedCouldNotRun is every finding the checker observes now (design 47 G §384, §461): the check that could not run +// and whose finding is written. The finding writer states them at its start and when the keeper asks. +func (ch *Checker) RaisedCouldNotRun(ctx context.Context) ([]views.Finding, error) { + if ch.CouldNotRun == nil { + return nil, nil + } + keys, err := ch.CouldNotRun.Keys(ctx) + if err != nil { + return nil, fmt.Errorf("the checks that could not run cannot be listed: %w", err) + } + sort.Strings(keys) + var out []views.Finding + for _, key := range keys { + if r, _, ok := ch.couldNotRunOf(ctx, key); ok && r.Raised { + out = append(out, couldNotRunFinding(key, r, r.Since)) + } + } + return out, nil +} + // couldNotRunFinding is the finding of the check of key that could not run. func couldNotRunFinding(key string, r CouldNotRun, at time.Time) views.Finding { return views.Finding{Observer: CheckerModule, Kind: CouldNotRunKind, ID: key, From 8cbb86dfbdcdcae6f4f2afd417a2d6c011d13b8b Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 21:55:43 +0100 Subject: [PATCH 06/13] G findings4 watchman: the watchman's findings are the seat's generated type, written through mesh-sdk's finding writer; the resolved words go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G §689, §686 (A-3), §461, §384, A-13, A-14, A-22 Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- .../mesh-delivery-watchman/findingwriter.go | 104 ++++++++++++++++++ .../integration_test.go | 11 ++ .../cmd/mesh-delivery-watchman/main.go | 47 ++++---- modules/mesh-delivery-watchman/go.mod | 2 +- .../internal/watch/busstate.go | 6 +- .../internal/watch/finding.go | 51 ++++++--- .../internal/watch/findings_test.go | 36 +++++- .../internal/watch/nodehealth.go | 9 +- .../internal/watch/signals.go | 4 +- 9 files changed, 218 insertions(+), 52 deletions(-) create mode 100644 modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/findingwriter.go diff --git a/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/findingwriter.go b/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/findingwriter.go new file mode 100644 index 00000000..9d1ddabe --- /dev/null +++ b/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/findingwriter.go @@ -0,0 +1,104 @@ +package main + +import ( + "context" + "sync" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + + condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + "git.novox.be/novox/mesh-sdk/go/process" + "git.novox.be/novox/mesh-sdk/go/views" + + "mesh-delivery-watchman/internal/watch" +) + +type findingWriterOf = views.Writer[condseat.Finding, condseat.FindingCleared, condseat.FindingsStated, condseat.FindingsAskedEvent] + +// heldFindings is the watchman's findings through mesh-sdk's finding writer (design 47 G §461, §689): every finding the +// watchman holds, by its own key, which the writer states at its start and when the keeper asks, and writes and clears as the +// watchman puts and deletes them. It has a lock of its own, never the watchman's: the writer calls observe under its own +// lock while the watchman writes under its. +type heldFindings struct { + mu sync.Mutex + held map[string]condseat.Finding + w *findingWriterOf +} + +// seedFindings reads the findings that stand in the state `findings` at the start, by the watchman's key of each. +func seedFindings(state process.State) (*heldFindings, map[string][]byte, error) { + h := &heldFindings{held: map[string]condseat.Finding{}} + seeds := map[string][]byte{} + keys, err := state.Keys() + if err != nil { + return nil, nil, err + } + for _, k := range keys { + e, err := state.Get(k) + if err != nil { + return nil, nil, err + } + if e == nil { + continue + } + f, err := watch.DecodeFinding(e.Value) + if err != nil { + continue + } + h.held[f.Key()] = f.Contract() + seeds[f.Key()] = e.Value + } + return h, seeds, nil +} + +// start makes and starts the writer: it states what is held at once, so the seeds are made before it. +func (h *heldFindings) start(ctx context.Context, cred process.Account, conn *nats.Conn, js jetstream.JetStream, state process.State, logf func(string, ...any)) error { + w, err := views.NewWriter(views.WriterConfig[condseat.Finding, condseat.FindingCleared, condseat.FindingsStated, condseat.FindingsAskedEvent]{ + Module: cred.Module, State: state, Node: cred.Node, JS: js, Conn: conn, Logf: logf, + Observe: func(context.Context) (views.Observed[condseat.Finding], error) { + h.mu.Lock() + defer h.mu.Unlock() + var o views.Observed[condseat.Finding] + for _, f := range h.held { + o.Findings = append(o.Findings, f) + } + return o, nil + }, + }) + if err != nil { + return err + } + h.w = w + // A writer that cannot start (the stream of its asks is not there yet) is said; its findings are then kept in the + // state alone and said by the next start (as the node-engine's is). + if err := w.Start(ctx); err != nil { + logf("the finding writer cannot start, its findings are kept in the state alone: %v", err) + } + return nil +} + +// Put writes the finding the watchman holds under key. +func (h *heldFindings) Put(ctx context.Context, key string, value []byte) error { + f, err := watch.DecodeFinding(value) + if err != nil { + return err + } + c := f.Contract() + h.mu.Lock() + h.held[key] = c + h.mu.Unlock() + return h.w.Write(ctx, c) +} + +// Delete clears the finding the watchman held under key. +func (h *heldFindings) Delete(ctx context.Context, key string) error { + h.mu.Lock() + c, ok := h.held[key] + delete(h.held, key) + h.mu.Unlock() + if !ok { + return nil + } + return h.w.Clear(ctx, c.Scope, c.ID, c.Kind, "the watchman no longer observes it") +} diff --git a/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/integration_test.go b/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/integration_test.go index df13bcfb..ec19015b 100644 --- a/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/integration_test.go +++ b/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/integration_test.go @@ -71,6 +71,14 @@ func serveOwners(t *testing.T, ctx context.Context, js jetstream.JetStream, nc * viewstest.Serve(t, ctx, js, nc, watch.IndexerModule, modulesState, watch.IndexerModule+".indexer_read_modules", "n1_"+watch.WatchmanModule) } +// findingsStream is the watchman's own stream of its findings' events (design 47 G §383), which its finding writer says into. +func findingsStream(t *testing.T, ctx context.Context, js jetstream.JetStream) { + t.Helper() + if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "WATCHMAN_FINDINGS", Subjects: []string{"mesh.stream." + watch.WatchmanModule + ".event.>"}}); err != nil { + t.Fatal(err) + } +} + // TestWatchmanWhole starts the program the way the node does (its entry, run, with its environment and a broker file) // against an embedded bus with the system exports and JetStream, and proves the four things the issue names. func TestWatchmanWhole(t *testing.T) { // 1135/AC8 @@ -132,6 +140,7 @@ func TestWatchmanWhole(t *testing.T) { // 1135/AC8 kvs[c.Bucket] = kv } serveOwners(t, ctx, js, ctl) + findingsStream(t, ctx, js) // The controller's consumer on DEAD_LETTER_NOTICES, the dead letters' streams and a source stream with one message a // consumer will give up on (1952/WM5); no lease bucket at all (issue 1126). @@ -383,6 +392,7 @@ func anAskRunningBeforeTheStartGetsItsPulse(t *testing.T) { // 1909 kvs[c.Bucket] = kv } serveOwners(t, ctx, js, ctl) + findingsStream(t, ctx, js) const id = "m0000009ffffff" body, err := buildstep.EncodeStep(buildstep.Step{ID: id, Tool: "build_go_test", Node: "n1", State: buildstep.StepRunning, Started: time.Now().UTC()}) if err != nil { @@ -470,6 +480,7 @@ func aWantIsMirroredAsAPulseAndTheHaveNamingItEndsIt(t *testing.T) { kvs[c.Bucket] = kv } serveOwners(t, ctx, js, ctl) + findingsStream(t, ctx, js) fpNew := meshcatalogclient.SourcePrefix2 + strings.Repeat("b", 64) fpOld := meshcatalogclient.SourcePrefix2 + strings.Repeat("a", 64) putWant := func(node, module, fp string) { diff --git a/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/main.go b/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/main.go index dc99c4ef..135fd11e 100644 --- a/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/main.go +++ b/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/main.go @@ -18,8 +18,6 @@ import ( "syscall" "time" - sdkfindings "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient/findings" - "git.novox.be/novox/mesh-sdk/go/process" "git.novox.be/novox/mesh-sdk/go/views" @@ -51,12 +49,13 @@ func main() { } } -// kvOut writes the watchman's four buckets. +// kvOut writes the watchman's three buckets and its findings, which go through mesh-sdk's finding writer. type kvOut struct { - ctx context.Context - presence, pulse, stuk, findings jetstream.KeyValue - js jetstream.JetStream - cred process.Account + ctx context.Context + presence, pulse, stuk jetstream.KeyValue + findings *heldFindings + js jetstream.JetStream + cred process.Account } // Say publishes an event of the seat mesh-watchdogs, said under the watchman's own account (design 47 G §389, §436). @@ -74,10 +73,7 @@ func (o *kvOut) Say(subject string, body []byte, id string) error { return err } -func (o *kvOut) PutFinding(key string, value []byte) error { - _, err := o.findings.Put(o.ctx, key, value) - return err -} +func (o *kvOut) PutFinding(key string, value []byte) error { return o.findings.Put(o.ctx, key, value) } func (o *kvOut) DeleteFinding(key string) error { return o.findings.Delete(o.ctx, key) } @@ -233,11 +229,11 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro // Its own presence, pulses and stuck rows are what it keeps: each stops the start, naming its grant. The assigner's wants // and the node-engine's haves only make the pulses of a move: without the haves the watchman watches presence and the // build seats' steps, and says that moves are not watched (issue 1945). - var kvs struct{ presence, pulse, stuck, findings, haves jetstream.KeyValue } + var kvs struct{ presence, pulse, stuck, haves jetstream.KeyValue } for _, b := range []struct { name string into *jetstream.KeyValue - }{{watch.PresenceBucket, &kvs.presence}, {watch.PulseBucket, &kvs.pulse}, {watch.StuckBucket, &kvs.stuck}, {watch.FindingsBucket, &kvs.findings}} { + }{{watch.PresenceBucket, &kvs.presence}, {watch.PulseBucket, &kvs.pulse}, {watch.StuckBucket, &kvs.stuck}} { if *b.into, err = process.EssentialBucket(ctx, js, "watchman", b.name, "say who is online or what stopped moving"); err != nil { return err } @@ -255,10 +251,19 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro // The module has one durable consumer of EVENTS: one hub pulls it for the views reader, the indexer's take-in events // and the seats' events, started once every part is registered. hub := process.NewEventHub(logger.Printf) + // Its findings: those that stand in its state are read first, so the writer's statement at its start says them and + // deletes none (the watchman reconciles them below, once it has read what it observes). + findingsState := calls.State("findings") + held, seeds, err := seedFindings(findingsState) + if err != nil { + return fmt.Errorf("the watchman's findings cannot be read: %w", err) + } + if err := held.start(ctx, cred, conn, js, findingsState, logger.Printf); err != nil { + return fmt.Errorf("the watchman's finding writer: %w", err) + } + defer held.w.Close() reader := &views.ProcessReader{Module: watch.WatchmanModule, Call: calls.Call, Logf: logger.Printf, Hub: hub, - Findings: func() views.FindingWriter { - return sdkfindings.Writer(ctx, func() jetstream.KeyValue { return kvs.findings }) - }} + Findings: func() views.FindingWriter { return views.WriterFindings{Writer: held.w} }} wants, err := reader.Follow(watch.AssignerModule, wantsState, watch.AssignerModule+".assigner_read_wants") if err != nil { return err @@ -268,7 +273,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro return err } - w := watch.New(&kvOut{ctx: ctx, presence: kvs.presence, pulse: kvs.pulse, stuk: kvs.stuck, findings: kvs.findings, js: js, cred: cred}, time.Now) + w := watch.New(&kvOut{ctx: ctx, presence: kvs.presence, pulse: kvs.pulse, stuk: kvs.stuck, findings: held, js: js, cred: cred}, time.Now) w.Logf(logger.Printf) // What the wants and the modules hold is ended by their own late read (watch.ReadLate), never by the start's end. w.Awaits(watch.AssignerWantsBucket) @@ -282,6 +287,9 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro w.SetBound(d) } + for key, value := range seeds { + w.SeedFinding(key, value) + } // What stands now: presence and stuck as written, pulses as the server holds them, then the checks. for _, s := range []struct { kv jetstream.KeyValue @@ -292,11 +300,6 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro w.SeedPresence(e.Key(), p.Online, e.Created()) } }}, - {kvs.findings, func(e jetstream.KeyValueEntry) { - if opOf(e) == watch.Put { - w.SeedFinding(e.Key(), e.Value()) - } - }}, {kvs.stuck, func(e jetstream.KeyValueEntry) { if opOf(e) == watch.Put { w.SeedStuck(e.Key(), e.Value()) diff --git a/modules/mesh-delivery-watchman/go.mod b/modules/mesh-delivery-watchman/go.mod index cf227cfd..39040a2f 100644 --- a/modules/mesh-delivery-watchman/go.mod +++ b/modules/mesh-delivery-watchman/go.mod @@ -47,7 +47,7 @@ replace git.novox.be/novox/mesh-delivery/sdk => ../../sdk require git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000 require ( - git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 // indirect + git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts v0.0.0-00010101000000-000000000000 // indirect git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000 // indirect git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient v0.0.0-00010101000000-000000000000 // indirect diff --git a/modules/mesh-delivery-watchman/internal/watch/busstate.go b/modules/mesh-delivery-watchman/internal/watch/busstate.go index 8b1b66b4..5e07bf53 100644 --- a/modules/mesh-delivery-watchman/internal/watch/busstate.go +++ b/modules/mesh-delivery-watchman/internal/watch/busstate.go @@ -83,8 +83,7 @@ func (w *Watchman) Accepted(a meshcatalog.ManifestAcceptedEvent) { Explanation: "The indexer accepted a manifest of " + a.Module + " asking the bus module for " + e.Name + ", and the bus " + "module said neither created nor refused within " + OverdueBound.String() + ": it is down, or behind. " + a.Module + " fails at its start while the entry's binding is missing (novox/hq ADR 0595 §1).", - Needs: "a look at the bus module's health and its process's log; nats_first_view_again asks it to read the verdicts again", - Resolved: "the bus module said " + e.Name + " of " + a.Module + " created, or a later manifest no longer asks for it"}}) + Needs: "a look at the bus module's health and its process's log; nats_first_view_again asks it to read the verdicts again"}}) } for _, local := range w.busStateEntriesOf(a.Module) { if asked[local] { @@ -151,8 +150,7 @@ func (w *Watchman) BrokerRefused(s BrokerSaid) { Headline: "The bus state " + s.Local + " of " + s.Module + " was refused", Explanation: "The bus module could not create " + s.Local + " for " + s.Module + ", so no binding is handed and " + s.Module + " fails at its start, naming the entry (novox/hq ADR 0595 §1): " + why, - Needs: "a pull request that mends the entry in the manifest of " + s.Module + ", or the bus module's cause fixed", - Resolved: "the bus module said " + s.Local + " of " + s.Module + " created"}) + Needs: "a pull request that mends the entry in the manifest of " + s.Module + ", or the bus module's cause fixed"}) } // busStateEntriesOf are the entries of module this watchman waits on or holds a finding of, by local name, sorted. diff --git a/modules/mesh-delivery-watchman/internal/watch/finding.go b/modules/mesh-delivery-watchman/internal/watch/finding.go index 6ba41b80..b0707089 100644 --- a/modules/mesh-delivery-watchman/internal/watch/finding.go +++ b/modules/mesh-delivery-watchman/internal/watch/finding.go @@ -1,20 +1,23 @@ package watch import ( + "encoding/json" + "strings" "time" mcc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient/heard" ) // The watchman is an observer of the seat mesh-watchdogs (novox/hq ADR 0564 §2, §4): what it observes wrong it writes as a -// finding, one key `..` of its own bucket `findings` in the seat mesh-conditions' layout (meshconditionsclient), only when what -// it says changes, and it deletes the key when it no longer observes it. The condition keeper (mesh-conditions) raises and +// finding, one key `..` of its own state `findings` in the seat mesh-conditions' generated layout, written +// through mesh-sdk's finding writer (design 47 G §689), only when what it says changes, and it deletes the key when it no longer observes it. The condition keeper (mesh-conditions) raises and // clears the conditions from them; the watchman writes no condition and reads none (Limits 11). // FindingsBucket is the watchman's findings. -var FindingsBucket = mcc.FindingsBucket(Observer) +var FindingsBucket = Observer + "_findings" // Observer is the name a finding of the watchman carries. const Observer = "mesh-delivery-watchman" @@ -43,7 +46,7 @@ type Finding struct { Severity string Summary, Said string Headline, Explanation string - Needs, Resolved string + Needs string Source string Observer string // Since is when what it says began, as the watchman saw it: the finding's time and its observation's, so the same @@ -63,22 +66,42 @@ func (f Finding) Key() string { return FindingKey(f.Scope, f.ID, token) } -// EncodeFinding is f in the seat's layout, refusing one the keeper could not make a condition of. -func EncodeFinding(f Finding) ([]byte, error) { - return mcc.EncodeFinding(mcc.Observation{Scope: f.Scope, ID: f.ID, Kind: f.Kind, Ref: f.Token, Machine: f.Machine, Also: f.Also, - Severity: f.Severity, Summary: f.Summary, Said: f.Said, Headline: f.Headline, Explanation: f.Explanation, - Resolved: f.Resolved, Needs: f.Needs, Source: f.Source}.Finding(Observer, "", f.Since)) +// Contract is f as the seat mesh-conditions' generated finding (design 47 G §686, §689): a ref, where the finding has a +// token other than its kind, goes into the id as `.` (A-14), and is kept in `ref` so the finding reads back as +// it was written. The resolved words are not a field of the seat (A-22), and its `at` is its `since`, so the same thing +// observed again writes the same bytes. +func (f Finding) Contract() condseat.Finding { + id, ref := f.ID, "" + if f.Token != "" && f.Token != f.Kind { + ref = f.Token + id = f.ID + "." + ref + } + v := condseat.Finding{Scope: f.Scope, ID: id, Kind: f.Kind, Ref: ref, Severity: f.Severity, Summary: f.Summary, + Headline: f.Headline, Explanation: f.Explanation, Needs: f.Needs, Source: f.Source, Observer: Observer, + At: f.Since.UTC(), Since: f.Since.UTC(), + Subject: condseat.Subject{Scope: f.Scope, ID: id, Machine: f.Machine, Also: f.Also}} + if f.Said != "" { + v.Evidence = []condseat.Evidence{{At: f.Since.UTC(), Said: f.Said}} + } + return v } +// EncodeFinding is f in the seat's generated layout. +func EncodeFinding(f Finding) ([]byte, error) { return json.Marshal(f.Contract()) } + // DecodeFinding reads a finding the watchman wrote. func DecodeFinding(data []byte) (Finding, error) { - h, err := mcc.DecodeFinding(data) - if err != nil { + var h condseat.Finding + if err := json.Unmarshal(data, &h); err != nil { return Finding{}, err } - f := Finding{Scope: h.Subject.Scope, ID: h.Subject.ID, Token: h.Ref, Kind: h.Kind, Machine: h.Subject.Machine, + id := h.ID + if h.Ref != "" { + id = strings.TrimSuffix(id, "."+h.Ref) + } + f := Finding{Scope: h.Scope, ID: id, Token: h.Ref, Kind: h.Kind, Machine: h.Subject.Machine, Also: h.Subject.Also, Severity: h.Severity, Summary: h.Summary, Headline: h.Headline, Explanation: h.Explanation, - Needs: h.Needs, Resolved: h.Resolved, Source: h.Source, Observer: h.Observer, Since: h.At} + Needs: h.Needs, Source: h.Source, Observer: h.Observer, Since: h.Since} if len(h.Evidence) > 0 { f.Said = h.Evidence[0].Said } @@ -163,7 +186,7 @@ func (w *Watchman) NotHeard(family, consequence string, why error) { w.say("%s", summary) w.find(Finding{Scope: ScopeModule, ID: heard.ID(Observer, family), Token: heard.Kind, Kind: heard.Kind, Severity: Warning, Source: Observer + "." + heard.Kind, Since: w.now().UTC().Truncate(time.Second), Summary: summary, Headline: headline, - Explanation: explanation, Resolved: "the watchman's consumer of " + family + " is bound"}) + Explanation: explanation}) } // Heard ends the finding that the watchman does not hear family. diff --git a/modules/mesh-delivery-watchman/internal/watch/findings_test.go b/modules/mesh-delivery-watchman/internal/watch/findings_test.go index 2363ea72..6a99ed7b 100644 --- a/modules/mesh-delivery-watchman/internal/watch/findings_test.go +++ b/modules/mesh-delivery-watchman/internal/watch/findings_test.go @@ -1,6 +1,7 @@ package watch import ( + "encoding/json" "errors" "strings" "sync/atomic" @@ -9,7 +10,7 @@ import ( "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" - mcc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshwantsclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" @@ -325,7 +326,8 @@ func TestTheWatchmansFindings(t *testing.T) { case !row.noState && row.want == "" && stands: t.Errorf("%s: the finding stands once bound", row.name) case row.want != "": - f, err := mcc.DecodeFinding(raw) + var f condseat.Finding + err := json.Unmarshal(raw, &f) // Review round 2: it says it clears at the part's next start, since Bind runs at the start. if err != nil || f.Kind != heard.Kind || f.Summary != row.want || f.Observer != "mesh-delivery-indexer" || !strings.Contains(f.Explanation, "next start") { t.Errorf("%s: the finding %+v (%v), want summary %q", row.name, f, err, row.want) @@ -820,3 +822,33 @@ func takeInBody(t *testing.T, kind, key string, since time.Time) []byte { } return b } + +// Design 47 G §689, A-14, A-22: a token other than the kind goes into the contract finding's id and reads back as it was +// written; the finding's time is its `since`; the resolved words are not on the wire. +func TestAFindingIsTheContractsWithItsRefInItsID(t *testing.T) { + since := time.Date(2026, 11, 11, 20, 0, 0, 0, time.UTC) + for _, f := range []Finding{ + {Scope: ScopeModule, ID: "web.ace", Token: "unhealthy", Kind: KindUnhealthy, Machine: "ace", Severity: Warning, Source: "health", Since: since, Summary: "s", Said: "said"}, + {Scope: ScopeModule, ID: "web", Token: KindBusStateOverdue + "-state", Kind: KindBusStateOverdue, Severity: Warning, Source: "bus", Since: since, Summary: "s"}, + {Scope: ScopeMachine, ID: "ace", Kind: KindMachineUnits, Severity: Warning, Source: "units", Since: since, Summary: "s", Needs: "mend"}, + } { + c := f.Contract() + wantID := f.ID + if f.Token != "" && f.Token != f.Kind { + wantID = f.ID + "." + f.Token + } + if c.ID != wantID || c.Kind != f.Kind || !c.At.Equal(since) || !c.Since.Equal(since) || c.Subject.ID != wantID { + t.Errorf("%+v: contract %+v, want id %q, kind %q, at and since %v", f, c, wantID, f.Kind, since) + } + raw, err := EncodeFinding(f) + if err != nil { + t.Fatal(err) + } + if back, err := DecodeFinding(raw); err != nil || back.ID != f.ID || back.Token != f.Token || back.Said != f.Said || back.Needs != f.Needs { + t.Errorf("%+v read back as %+v (%v)", f, back, err) + } + if strings.Contains(string(raw), "esolved") { + t.Errorf("%+v: resolved words are on the wire: %s", f, raw) + } + } +} diff --git a/modules/mesh-delivery-watchman/internal/watch/nodehealth.go b/modules/mesh-delivery-watchman/internal/watch/nodehealth.go index a53a3d8a..da6adfe1 100644 --- a/modules/mesh-delivery-watchman/internal/watch/nodehealth.go +++ b/modules/mesh-delivery-watchman/internal/watch/nodehealth.go @@ -198,8 +198,7 @@ func unitsOf(node string, u *nodeengine.UnitsHealth) (Finding, bool) { "mend or remove it there, or have a module place it", node, len(u.Failed), strings.Join(names, ", ")), Said: strings.Join(said, "; "), Headline: strings.ToUpper(failed[:1]) + failed[1:] + " on " + node, - Needs: fmt.Sprintf("mend or remove them on %s, or silence this if they do not matter.", node), - Resolved: "No failed services on " + node + " any more"}, true + Needs: fmt.Sprintf("mend or remove them on %s, or silence this if they do not matter.", node)}, true } // reloginOf is one finding per module whose accounts wait for a new login. @@ -251,8 +250,7 @@ func foundOf(node string, dirs []nodeengine.NodeDirectory) []Finding { Headline: fmt.Sprintf("%s waits for a directory on %s", m, node), Explanation: fmt.Sprintf("Needs you: hand the directory over from the control-node, as the operator; the details name it "+ "and the line to type. A directory of %s was already on %s, with another owner or mode than %s declares.", m, node, m), - Needs: "hand the directory over from the control-node, as the operator; the details name it and the line to type.", - Resolved: fmt.Sprintf("%s's directory on %s is the mesh's", m, node)}) + Needs: "hand the directory over from the control-node, as the operator; the details name it and the line to type."}) } return out } @@ -266,8 +264,7 @@ func fallbackOf(node string, f *nodeengine.LoginFallback) (Finding, bool) { return Finding{Scope: ScopeMachine, ID: node, Token: KindAccountFallback, Kind: KindAccountFallback, Machine: node, Severity: Warning, Source: "health-report", Since: f.At, Summary: said, Said: said, Headline: fmt.Sprintf("%s fell back to its old bus login", node), - Needs: fmt.Sprintf("abandon the rotation of %s's account, or rotate it again once its new login authenticates.", node), - Resolved: fmt.Sprintf("%s no longer falls back to its old bus login", node)}, true + Needs: fmt.Sprintf("abandon the rotation of %s's account, or rotate it again once its new login authenticates.", node)}, true } func orModule(m string) string { diff --git a/modules/mesh-delivery-watchman/internal/watch/signals.go b/modules/mesh-delivery-watchman/internal/watch/signals.go index 8cfef6d4..e72c31da 100644 --- a/modules/mesh-delivery-watchman/internal/watch/signals.go +++ b/modules/mesh-delivery-watchman/internal/watch/signals.go @@ -763,7 +763,7 @@ func (w *Watchman) applyCheck(node string) { } } f := Finding{Scope: ScopeMachine, ID: node, Kind: KindApplyFailing, Machine: node, Severity: Warning, Source: "S21", Since: since, - Said: strings.Join(words, "; "), Resolved: node + " applies what it is sent again", Headline: node + " is not applying its update"} + Said: strings.Join(words, "; "), Headline: node + " is not applying its update"} switch { case len(applied) == 0: f.Summary = fmt.Sprintf("%s's provisioner has failed to place the files of %s since %s (bound %s)", node, @@ -845,7 +845,6 @@ func (w *Watchman) Closed(kind string, c BusClosure) { f.Headline = clip(capital(who)+" was too slow for the bus", 60) f.Explanation = capital(fmt.Sprintf("%s could not keep up with what the bus sent it, so the bus closed its connection (%s "+ "in the last hour) and what was on its way was lost. It connects again by itself; this clears an hour after the last time.", who, count)) - f.Resolved = "Resolved: " + who + " kept up for an hour" } else { reason := strings.ToLower(c.Reason) f.Said = fmt.Sprintf("the bus closed %s's connection %d: %s", c.User, c.CID, c.Reason) @@ -854,7 +853,6 @@ func (w *Watchman) Closed(kind string, c BusClosure) { f.Headline = clip(capital(who)+" was cut off by the bus", 60) f.Explanation = capital(fmt.Sprintf("The bus closed the connection of %s because it broke the bus's rules (%s, %s in the "+ "last hour), so what it was sending was lost. This clears an hour after the last time.", who, reason, count)) - f.Resolved = "Resolved: " + who + " kept to the bus's rules for an hour" } w.find(f) if quiet := AdvisoryQuiet - w.now().Sub(last); quiet > 0 { From 7310af36e6a04d187b24f889ee549f46087a9026 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 21:56:38 +0100 Subject: [PATCH 07/13] G findings4 watchman: a finding's id keeps the safe key parts its condition's key had MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G §689, A-14 Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- .../internal/watch/finding.go | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/modules/mesh-delivery-watchman/internal/watch/finding.go b/modules/mesh-delivery-watchman/internal/watch/finding.go index b0707089..837e3db3 100644 --- a/modules/mesh-delivery-watchman/internal/watch/finding.go +++ b/modules/mesh-delivery-watchman/internal/watch/finding.go @@ -66,15 +66,29 @@ func (f Finding) Key() string { return FindingKey(f.Scope, f.ID, token) } +// KeyID is an id as the parts of a key: each dot-separated part made safe for a bus key, as the key of a condition is +// (mcc.Key), so the finding's key keeps the shape its condition had. +func KeyID(id string) string { + parts := strings.Split(id, ".") + for i, p := range parts { + p = unsafeKey.ReplaceAllString(strings.TrimSpace(p), "_") + if p == "" { + p = "_" + } + parts[i] = p + } + return strings.Join(parts, ".") +} + // Contract is f as the seat mesh-conditions' generated finding (design 47 G §686, §689): a ref, where the finding has a // token other than its kind, goes into the id as `.` (A-14), and is kept in `ref` so the finding reads back as // it was written. The resolved words are not a field of the seat (A-22), and its `at` is its `since`, so the same thing // observed again writes the same bytes. func (f Finding) Contract() condseat.Finding { - id, ref := f.ID, "" + id, ref := KeyID(f.ID), "" if f.Token != "" && f.Token != f.Kind { ref = f.Token - id = f.ID + "." + ref + id = id + "." + KeyID(ref) } v := condseat.Finding{Scope: f.Scope, ID: id, Kind: f.Kind, Ref: ref, Severity: f.Severity, Summary: f.Summary, Headline: f.Headline, Explanation: f.Explanation, Needs: f.Needs, Source: f.Source, Observer: Observer, From 0fdf56f4bbd58e0d5bc541cd8161e6496891fd78 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 21:58:50 +0100 Subject: [PATCH 08/13] G findings4: the readers' findings go through the part's writer as views.WireWriter; the pipeline runner adopts each branch's catalog view through its reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G §689, §687, A-22, A-26 Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- .../mesh-delivery-checker/findingwriter.go | 15 ++-- .../cmd/mesh-delivery-checker/main.go | 4 +- .../internal/check/checker.go | 3 +- .../internal/check/checker_test.go | 7 +- .../internal/check/couldnotrun.go | 22 +++-- .../cmd/mesh-delivery-pipeline/views.go | 82 ++----------------- .../cmd/mesh-delivery-teller/main.go | 2 +- .../cmd/mesh-delivery-view/main.go | 2 +- .../cmd/mesh-delivery-watchman/main.go | 2 +- 9 files changed, 36 insertions(+), 103 deletions(-) diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/findingwriter.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/findingwriter.go index ee0eeb3e..bb96deb7 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/findingwriter.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/findingwriter.go @@ -27,7 +27,7 @@ type checkerFindings struct { // newCheckerFindings makes and starts the writer; observe says the checks that could not run and whose finding is // written (check.Checker.RaisedCouldNotRun). func newCheckerFindings(ctx context.Context, cred process.Account, conn *nats.Conn, js jetstream.JetStream, state process.State, - observe func(context.Context) ([]views.Finding, error), logf func(string, ...any)) (*checkerFindings, error) { + observe func(context.Context) ([]check.Finding, error), logf func(string, ...any)) (*checkerFindings, error) { c := &checkerFindings{node: cred.Node} w, err := views.NewWriter(views.WriterConfig[condseat.Finding, condseat.FindingCleared, condseat.FindingsStated, condseat.FindingsAskedEvent]{ Module: cred.Module, State: state, Node: cred.Node, JS: js, Conn: conn, Logf: logf, @@ -57,7 +57,7 @@ func newCheckerFindings(ctx context.Context, cred process.Account, conn *nats.Co } // body lays the checker's finding out as the seat's contract type: a finding of the module, about the module on its node. -func (c *checkerFindings) body(f views.Finding) condseat.Finding { +func (c *checkerFindings) body(f check.Finding) condseat.Finding { module, _ := c.w.ModuleNode() return condseat.Finding{Kind: f.Kind, Scope: views.FindingScope, ID: keys.EscapeName(f.ID), At: f.At, Since: f.At, Severity: condseat.FindingSeverityWarning, Source: f.Kind, Summary: f.Summary, Headline: f.Headline, Explanation: f.Explanation, @@ -65,18 +65,13 @@ func (c *checkerFindings) body(f views.Finding) condseat.Finding { } // Put writes one finding; its `since` stays where its key exists. -func (c *checkerFindings) Put(f views.Finding) error { +func (c *checkerFindings) Put(f check.Finding) error { return c.w.Write(context.Background(), c.body(f)) } // Delete clears one finding. -func (c *checkerFindings) Delete(f views.Finding) error { +func (c *checkerFindings) Delete(f check.Finding) error { return c.w.Clear(context.Background(), views.FindingScope, keys.EscapeName(f.ID), f.Kind, "the checker no longer observes it") } -var _ views.FindingWriter = (*checkerFindings)(nil) - -// readerFindings is the views reader's findings through the same writer, as kinds of the reader's own. -func (c *checkerFindings) readerFindings() views.FindingWriter { - return views.WriterFindings{Writer: c.w} -} +var _ check.FindingWriter = (*checkerFindings)(nil) diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go index 61cf7263..1d6548c4 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/main.go @@ -164,9 +164,9 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro // The finding writer is made once the checker is (its observe reads the checker's kept checks); until then a first view // unanswered is said in the log alone. var findings atomic.Pointer[checkerFindings] - reader.Findings = func() views.FindingWriter { + reader.Findings = func() views.WireWriter { if f := findings.Load(); f != nil { - return f.readerFindings() + return f.w } return nil } diff --git a/modules/mesh-delivery-checker/internal/check/checker.go b/modules/mesh-delivery-checker/internal/check/checker.go index 02912e41..98f4530d 100644 --- a/modules/mesh-delivery-checker/internal/check/checker.go +++ b/modules/mesh-delivery-checker/internal/check/checker.go @@ -20,7 +20,6 @@ import ( "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" "git.novox.be/novox/mesh-delivery/sdk/buildstep" "git.novox.be/novox/mesh-sdk/go/keys" - "git.novox.be/novox/mesh-sdk/go/views" ) // DefaultBound is how long a check may run before it is ended failed naming the step it was stuck at (ADR 0484, issue 1678): @@ -94,7 +93,7 @@ type Checker struct { CouldNotRun KV // Findings writes the checker's finding of a check not asked again within CouldNotRunBound (CouldNotRunDue); nil // writes none. - Findings views.FindingWriter + Findings FindingWriter // CouldNotRunKept is called each time a check that could not run is kept, so the process waits for its bound anew. CouldNotRunKept func() // Bound is how long one check may run; none when 0 (DefaultBound). diff --git a/modules/mesh-delivery-checker/internal/check/checker_test.go b/modules/mesh-delivery-checker/internal/check/checker_test.go index 51e8775f..592a6e5c 100644 --- a/modules/mesh-delivery-checker/internal/check/checker_test.go +++ b/modules/mesh-delivery-checker/internal/check/checker_test.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "git.novox.be/novox/mesh-sdk/go/views" "strings" "sync" "testing" @@ -228,10 +227,10 @@ func TestACheckPastItsBoundFailsNamingTheStuckStep(t *testing.T) { // N6/AC3 } // findingsKept is a FindingWriter that keeps what is written, by ID. -type findingsKept map[string]views.Finding +type findingsKept map[string]Finding -func (f findingsKept) Put(x views.Finding) error { f[x.ID] = x; return nil } -func (f findingsKept) Delete(x views.Finding) error { delete(f, x.ID); return nil } +func (f findingsKept) Put(x Finding) error { f[x.ID] = x; return nil } +func (f findingsKept) Delete(x Finding) error { delete(f, x.ID); return nil } // A check that could not hear a part of the mesh says `could not run`, naming the part, and releases its claim; only the // running of that part asks it again, once per heads and part at one fingerprint; not asked again within an hour it is the diff --git a/modules/mesh-delivery-checker/internal/check/couldnotrun.go b/modules/mesh-delivery-checker/internal/check/couldnotrun.go index 643e06e7..1884aa55 100644 --- a/modules/mesh-delivery-checker/internal/check/couldnotrun.go +++ b/modules/mesh-delivery-checker/internal/check/couldnotrun.go @@ -10,7 +10,6 @@ import ( "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker" - "git.novox.be/novox/mesh-sdk/go/views" ) // A check that failed because a part of the mesh was not heard says `could not run`, naming the part, and releases its @@ -233,9 +232,22 @@ func (ch *Checker) CouldNotRunDue(ctx context.Context, now time.Time) time.Time return next } +// Finding is the checker's finding of a check that could not run, in plain fields: the checker's writer lays it out as the seat +// mesh-conditions' finding (design 47 G §689). +type Finding struct { + Kind, ID, Summary, Headline, Explanation string + At time.Time +} + +// FindingWriter writes and clears the checker's findings. +type FindingWriter interface { + Put(f Finding) error + Delete(f Finding) error +} + // RaisedCouldNotRun is every finding the checker observes now (design 47 G §384, §461): the check that could not run // and whose finding is written. The finding writer states them at its start and when the keeper asks. -func (ch *Checker) RaisedCouldNotRun(ctx context.Context) ([]views.Finding, error) { +func (ch *Checker) RaisedCouldNotRun(ctx context.Context) ([]Finding, error) { if ch.CouldNotRun == nil { return nil, nil } @@ -244,7 +256,7 @@ func (ch *Checker) RaisedCouldNotRun(ctx context.Context) ([]views.Finding, erro return nil, fmt.Errorf("the checks that could not run cannot be listed: %w", err) } sort.Strings(keys) - var out []views.Finding + var out []Finding for _, key := range keys { if r, _, ok := ch.couldNotRunOf(ctx, key); ok && r.Raised { out = append(out, couldNotRunFinding(key, r, r.Since)) @@ -254,8 +266,8 @@ func (ch *Checker) RaisedCouldNotRun(ctx context.Context) ([]views.Finding, erro } // couldNotRunFinding is the finding of the check of key that could not run. -func couldNotRunFinding(key string, r CouldNotRun, at time.Time) views.Finding { - return views.Finding{Observer: CheckerModule, Kind: CouldNotRunKind, ID: key, +func couldNotRunFinding(key string, r CouldNotRun, at time.Time) Finding { + return Finding{Kind: CouldNotRunKind, ID: key, Summary: fmt.Sprintf("the check of %s (heads %s) could not run since %s: %s was not heard, and has not run since", r.Group, r.Heads, r.Since.UTC().Format(time.RFC3339), r.Part), Headline: fmt.Sprintf("The check of %s could not run", r.Group), diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/views.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/views.go index d60f9e58..3918bfd5 100644 --- a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/views.go +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/views.go @@ -2,8 +2,6 @@ package main import ( "context" - "sync" - "time" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" @@ -16,9 +14,6 @@ import ( "mesh-delivery-pipeline/internal/pipeline" ) -// catalogRevisions is every watched branch's catalog-revision, `mesh.seat.mesh-catalog.event.catalog-revision.`. -const catalogRevisions = "mesh.seat." + catalog.Seat + ".event." + catalog.EventCatalogRevision + ".>" - // runnerViews are the runner's first views (design 47 G §21, §195, §200): the registrations, then the catalog half of // each watched branch they name, each said in `_first_views` (its state first-views) and, past five minutes // without a view, as view-waits in its findings. @@ -36,55 +31,17 @@ func startViews(ctx context.Context, js jetstream.JetStream, conn *nats.Conn, cr return nil, err } status := process.AwaitBucket(ctx, js, conn, cred.Module+"_"+views.StatusState, "the runner's first views are not said in its first-view tool, nor asked again by hand", logf) - writer := func() views.FindingWriter { return views.WriterFindings{Writer: findings} } + writer := func() views.WireWriter { return findings } reader := &views.ProcessReader{Module: cred.Module, Call: call, Logf: logf, Hub: hub, Findings: writer} - // A watched branch first named by a registration gets its view of the catalog half (CT10), its conditions and its - // status kept as every first view's, asked at once. - wire := func(f *views.Follower) { - f.Logf = logf - f.Raise = func(s views.Status) { - putFinding(writer(), views.UnansweredFinding(cred.Module, s, time.Now()), true, logf) - } - f.Clear = func(s views.Status) { - putFinding(writer(), views.UnansweredFinding(cred.Module, s, time.Now()), false, logf) - } - f.RaiseWaits = func(s views.Status) { - logf("waits for %s's view %s since %s", s.Owner, s.WaitsFor, s.WaitingSince.Format(time.RFC3339)) - putFinding(writer(), views.ViewWaitsFinding(cred.Module, s, time.Now()), true, logf) - } - f.ClearWaits = func(s views.Status) { - putFinding(writer(), views.ViewWaitsFinding(cred.Module, s, time.Now()), false, logf) - } - f.Said = func(s views.Status) { - if kv := status.KV(); kv != nil { - views.KeepStatus(ctx, kv, logf)(s) - } - } - status.Ready(func(kv jetstream.KeyValue) { - if err := views.HearAgain(ctx, kv, []*views.Follower{f}, logf); err != nil { - logf("the hand asks of the first view %s are not heard (%v) until the runner starts again", f.Tool, err) - } - }) - } - var ( - mu sync.Mutex - made []*views.Follower - ) + // A watched branch first named by a registration gets its view of the catalog half (CT10), adopted through the reader (design + // 47 G §532, A-26): the reader keeps its status, raises and clears its findings through the module's writer, hears the + // seat's catalog-revision on the module's consumer and asks its first view at once. cv := &pipeline.Views{Logf: logf, Open: func(branch string) (pipeline.BranchView, error) { - c, err := meshcatalogclient.NewCatalog(call, branch) + c, _, err := meshcatalogclient.WireCatalogProcess(reader, call, branch, nil) if err != nil { return nil, err } - wire(c.Follower) - mu.Lock() - made = append(made, c.Follower) - mu.Unlock() - go func() { - if err := c.Start(ctx); err != nil { - logf("the first view of the catalog half of %s was not answered: %v", branch, err) - } - }() return catalogView{c}, nil }} regs, _, err := meshcatalogclient.WireRegistrationsProcess(reader, call, func(reg catalog.Registration, gone bool) { @@ -95,39 +52,10 @@ func startViews(ctx context.Context, js jetstream.JetStream, conn *nats.Conn, cr if err != nil { return nil, err } - if err := hub.Handle(catalogRevisions, func(m jetstream.Msg) { - if err := cv.HearRevision(ctx, m.Data()); err != nil { - logf("%s is not taken: %v", m.Subject(), err) - } - _ = m.Ack() - }); err != nil { - return nil, err - } - // A consumer made after a view was asked does not deliver what was said before it: each branch's view asks again. - hub.OnBound(func(created time.Time) { - mu.Lock() - fs := append([]*views.Follower(nil), made...) - mu.Unlock() - for _, f := range fs { - f.ConsumerMade(ctx, created) - } - }) reader.Start(ctx, js, cred, status) return &runnerViews{Registrations: regs, Catalog: cv}, nil } -func putFinding(w views.FindingWriter, f views.Finding, raised bool, logf func(string, ...any)) { - var err error - if raised { - err = w.Put(f) - } else { - err = w.Delete(f) - } - if err != nil { - logf("the finding %s is not written: %v", f.Kind, err) - } -} - // catalogView is a branch's meshcatalogclient.Catalog as the runner reads it. type catalogView struct{ c *meshcatalogclient.Catalog } diff --git a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/main.go b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/main.go index 9ac4db39..3342bd6b 100644 --- a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/main.go +++ b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/main.go @@ -108,7 +108,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro return fmt.Errorf("the teller's finding writer: %w", err) } defer writer.Close() - reader.Findings = func() views.FindingWriter { return views.WriterFindings{Writer: writer} } + reader.Findings = func() views.WireWriter { return writer } asks, err := reader.Follow(assignerModule, assignerAsksState, assignerModule+".assigner_read_asks") if err != nil { return err diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view/main.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view/main.go index b0181359..771b2a5d 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view/main.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view/main.go @@ -129,7 +129,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro return fmt.Errorf("the view's finding writer: %w", err) } defer writer.Close() - reader.Findings = func() views.FindingWriter { return views.WriterFindings{Writer: writer} } + reader.Findings = func() views.WireWriter { return writer } for _, o := range ownersFollowed { if _, err := reader.Follow(o.owner, o.state, o.tool); err != nil { return err diff --git a/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/main.go b/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/main.go index 135fd11e..3398eca1 100644 --- a/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/main.go +++ b/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/main.go @@ -263,7 +263,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro } defer held.w.Close() reader := &views.ProcessReader{Module: watch.WatchmanModule, Call: calls.Call, Logf: logger.Printf, Hub: hub, - Findings: func() views.FindingWriter { return views.WriterFindings{Writer: held.w} }} + Findings: func() views.WireWriter { return held.w }} wants, err := reader.Follow(watch.AssignerModule, wantsState, watch.AssignerModule+".assigner_read_wants") if err != nil { return err From 08fcdac0f877ff8e39fcd85de23a858a1c870255 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 21:59:17 +0100 Subject: [PATCH 09/13] G findings4: each writer is given the seat's findings-asked subject from the generated contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G §383, §390, A-13, A-17 Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- .../cmd/mesh-delivery-checker/findingwriter.go | 2 +- .../cmd/mesh-delivery-pipeline/findingwriter.go | 2 +- .../cmd/mesh-delivery-teller/findingwriter.go | 2 +- .../mesh-delivery-view/cmd/mesh-delivery-view/findingwriter.go | 2 +- .../cmd/mesh-delivery-watchman/findingwriter.go | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/findingwriter.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/findingwriter.go index bb96deb7..996b94c8 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/findingwriter.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker/findingwriter.go @@ -30,7 +30,7 @@ func newCheckerFindings(ctx context.Context, cred process.Account, conn *nats.Co observe func(context.Context) ([]check.Finding, error), logf func(string, ...any)) (*checkerFindings, error) { c := &checkerFindings{node: cred.Node} w, err := views.NewWriter(views.WriterConfig[condseat.Finding, condseat.FindingCleared, condseat.FindingsStated, condseat.FindingsAskedEvent]{ - Module: cred.Module, State: state, Node: cred.Node, JS: js, Conn: conn, Logf: logf, + Module: cred.Module, State: state, Node: cred.Node, Asked: condseat.SubjectFindingsAsked, JS: js, Conn: conn, Logf: logf, Kinds: []string{check.CouldNotRunKind}, Observe: func(ctx context.Context) (views.Observed[condseat.Finding], error) { found, err := observe(ctx) diff --git a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/findingwriter.go b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/findingwriter.go index a976499b..0fedcad4 100644 --- a/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/findingwriter.go +++ b/modules/mesh-delivery-pipeline/cmd/mesh-delivery-pipeline/findingwriter.go @@ -16,7 +16,7 @@ import ( // forms its subjects itself (design 47 G §383, A-13). func findingWriter(ctx context.Context, cred process.Account, conn *nats.Conn, js jetstream.JetStream, state process.State, logf func(string, ...any)) (*views.Writer[condseat.Finding, condseat.FindingCleared, condseat.FindingsStated, condseat.FindingsAskedEvent], error) { w, err := views.NewWriter(views.WriterConfig[condseat.Finding, condseat.FindingCleared, condseat.FindingsStated, condseat.FindingsAskedEvent]{ - Module: cred.Module, State: state, Node: cred.Node, JS: js, Conn: conn, Logf: logf, + Module: cred.Module, State: state, Node: cred.Node, Asked: condseat.SubjectFindingsAsked, JS: js, Conn: conn, Logf: logf, Observe: func(context.Context) (views.Observed[condseat.Finding], error) { return views.Observed[condseat.Finding]{}, nil }, diff --git a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/findingwriter.go b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/findingwriter.go index 8a628be6..be9d2cf1 100644 --- a/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/findingwriter.go +++ b/modules/mesh-delivery-teller/cmd/mesh-delivery-teller/findingwriter.go @@ -16,7 +16,7 @@ import ( // forms its subjects itself (design 47 G §383, A-13). func findingWriter(ctx context.Context, cred process.Account, conn *nats.Conn, js jetstream.JetStream, state process.State, logf func(string, ...any)) (*views.Writer[condseat.Finding, condseat.FindingCleared, condseat.FindingsStated, condseat.FindingsAskedEvent], error) { w, err := views.NewWriter(views.WriterConfig[condseat.Finding, condseat.FindingCleared, condseat.FindingsStated, condseat.FindingsAskedEvent]{ - Module: cred.Module, State: state, Node: cred.Node, JS: js, Conn: conn, Logf: logf, + Module: cred.Module, State: state, Node: cred.Node, Asked: condseat.SubjectFindingsAsked, JS: js, Conn: conn, Logf: logf, Observe: func(context.Context) (views.Observed[condseat.Finding], error) { return views.Observed[condseat.Finding]{}, nil }, diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view/findingwriter.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view/findingwriter.go index e2bc27e8..4cf3b9b2 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view/findingwriter.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view/findingwriter.go @@ -16,7 +16,7 @@ import ( // forms its subjects itself (design 47 G §383, A-13). func findingWriter(ctx context.Context, cred process.Account, conn *nats.Conn, js jetstream.JetStream, state process.State, logf func(string, ...any)) (*views.Writer[condseat.Finding, condseat.FindingCleared, condseat.FindingsStated, condseat.FindingsAskedEvent], error) { w, err := views.NewWriter(views.WriterConfig[condseat.Finding, condseat.FindingCleared, condseat.FindingsStated, condseat.FindingsAskedEvent]{ - Module: cred.Module, State: state, Node: cred.Node, JS: js, Conn: conn, Logf: logf, + Module: cred.Module, State: state, Node: cred.Node, Asked: condseat.SubjectFindingsAsked, JS: js, Conn: conn, Logf: logf, Observe: func(context.Context) (views.Observed[condseat.Finding], error) { return views.Observed[condseat.Finding]{}, nil }, diff --git a/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/findingwriter.go b/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/findingwriter.go index 9d1ddabe..495eec6b 100644 --- a/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/findingwriter.go +++ b/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/findingwriter.go @@ -55,7 +55,7 @@ func seedFindings(state process.State) (*heldFindings, map[string][]byte, error) // start makes and starts the writer: it states what is held at once, so the seeds are made before it. func (h *heldFindings) start(ctx context.Context, cred process.Account, conn *nats.Conn, js jetstream.JetStream, state process.State, logf func(string, ...any)) error { w, err := views.NewWriter(views.WriterConfig[condseat.Finding, condseat.FindingCleared, condseat.FindingsStated, condseat.FindingsAskedEvent]{ - Module: cred.Module, State: state, Node: cred.Node, JS: js, Conn: conn, Logf: logf, + Module: cred.Module, State: state, Node: cred.Node, Asked: condseat.SubjectFindingsAsked, JS: js, Conn: conn, Logf: logf, Observe: func(context.Context) (views.Observed[condseat.Finding], error) { h.mu.Lock() defer h.mu.Unlock() From f531d5524dae7a51962396d63cfc31a0c9add5c1 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 22:03:27 +0100 Subject: [PATCH 10/13] G findings4 watchman: the watchman's findings go through views.NewHeldWriter and its own copy of the held writer goes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G A-29, §689, §461 Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- .../mesh-delivery-watchman/findingwriter.go | 38 +++++++------------ 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/findingwriter.go b/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/findingwriter.go index 495eec6b..cd0c5b19 100644 --- a/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/findingwriter.go +++ b/modules/mesh-delivery-watchman/cmd/mesh-delivery-watchman/findingwriter.go @@ -14,21 +14,20 @@ import ( "mesh-delivery-watchman/internal/watch" ) -type findingWriterOf = views.Writer[condseat.Finding, condseat.FindingCleared, condseat.FindingsStated, condseat.FindingsAskedEvent] +type heldWriterOf = views.HeldWriter[condseat.Finding, condseat.FindingCleared, condseat.FindingsStated, condseat.FindingsAskedEvent] -// heldFindings is the watchman's findings through mesh-sdk's finding writer (design 47 G §461, §689): every finding the -// watchman holds, by its own key, which the writer states at its start and when the keeper asks, and writes and clears as the -// watchman puts and deletes them. It has a lock of its own, never the watchman's: the writer calls observe under its own -// lock while the watchman writes under its. +// heldFindings is the watchman's findings through mesh-sdk's held finding writer (design 47 G §461, §689, A-29): the writer +// holds what the watchman has put, states it at its start and when the keeper asks, writes and clears as the watchman puts and +// deletes. Here is only the watchman's own key of each finding, which the watchman deletes by. type heldFindings struct { - mu sync.Mutex - held map[string]condseat.Finding - w *findingWriterOf + mu sync.Mutex + by map[string]condseat.Finding + w *heldWriterOf } // seedFindings reads the findings that stand in the state `findings` at the start, by the watchman's key of each. func seedFindings(state process.State) (*heldFindings, map[string][]byte, error) { - h := &heldFindings{held: map[string]condseat.Finding{}} + h := &heldFindings{by: map[string]condseat.Finding{}} seeds := map[string][]byte{} keys, err := state.Keys() if err != nil { @@ -46,25 +45,16 @@ func seedFindings(state process.State) (*heldFindings, map[string][]byte, error) if err != nil { continue } - h.held[f.Key()] = f.Contract() + h.by[f.Key()] = f.Contract() seeds[f.Key()] = e.Value } return h, seeds, nil } -// start makes and starts the writer: it states what is held at once, so the seeds are made before it. +// start makes and starts the held writer, which reads the state's findings itself before it states anything. func (h *heldFindings) start(ctx context.Context, cred process.Account, conn *nats.Conn, js jetstream.JetStream, state process.State, logf func(string, ...any)) error { - w, err := views.NewWriter(views.WriterConfig[condseat.Finding, condseat.FindingCleared, condseat.FindingsStated, condseat.FindingsAskedEvent]{ + w, err := views.NewHeldWriter(views.WriterConfig[condseat.Finding, condseat.FindingCleared, condseat.FindingsStated, condseat.FindingsAskedEvent]{ Module: cred.Module, State: state, Node: cred.Node, Asked: condseat.SubjectFindingsAsked, JS: js, Conn: conn, Logf: logf, - Observe: func(context.Context) (views.Observed[condseat.Finding], error) { - h.mu.Lock() - defer h.mu.Unlock() - var o views.Observed[condseat.Finding] - for _, f := range h.held { - o.Findings = append(o.Findings, f) - } - return o, nil - }, }) if err != nil { return err @@ -86,7 +76,7 @@ func (h *heldFindings) Put(ctx context.Context, key string, value []byte) error } c := f.Contract() h.mu.Lock() - h.held[key] = c + h.by[key] = c h.mu.Unlock() return h.w.Write(ctx, c) } @@ -94,8 +84,8 @@ func (h *heldFindings) Put(ctx context.Context, key string, value []byte) error // Delete clears the finding the watchman held under key. func (h *heldFindings) Delete(ctx context.Context, key string) error { h.mu.Lock() - c, ok := h.held[key] - delete(h.held, key) + c, ok := h.by[key] + delete(h.by, key) h.mu.Unlock() if !ok { return nil From 77772056bd959ca92341f8f5db1b16a866ed101b Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 22:12:52 +0100 Subject: [PATCH 11/13] G findings4 assigner: the assigner's findings are the seat's generated finding written through views.NewHeldWriter, its catalog halves adopted through the views reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G A-29, A-26, A-22, A-3, A-14, §689, §687, §461 --- .../cmd/mesh-delivery-assigner/askended.go | 7 +- .../cmd/mesh-delivery-assigner/halves.go | 81 +++-------- .../integration_test.go | 15 +- .../cmd/mesh-delivery-assigner/main.go | 64 ++++++--- .../cmd/mesh-delivery-assigner/main_test.go | 15 +- .../cmd/mesh-delivery-assigner/pass.go | 20 ++- .../cmd/mesh-delivery-assigner/pass_test.go | 4 +- .../mesh-delivery-assigner/seat_tools_test.go | 10 +- .../cmd/mesh-delivery-assigner/views.go | 133 +++++------------- .../cmd/mesh-delivery-assigner/wants_tools.go | 3 +- .../internal/assign/asks_test.go | 13 +- .../internal/assign/findings.go | 86 ++++++++--- .../internal/assign/policy.go | 27 ++-- .../mesh-delivery-assigner/manifest_test.go | 7 +- modules/mesh-delivery-assigner/module.json | 9 ++ 15 files changed, 247 insertions(+), 247 deletions(-) diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/askended.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/askended.go index 3b9d5ba1..ef2e4617 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/askended.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/askended.go @@ -18,7 +18,6 @@ import ( oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" "mesh-delivery-assigner/internal/assign" - plc "mesh-delivery-assigner/internal/assignment" "mesh-delivery-assigner/internal/buckets" "mesh-delivery-assigner/internal/puts" ) @@ -119,11 +118,7 @@ func (cw callWait) keep(ctx context.Context, id, callee string, call func() (jso if said || clock().Sub(since) < mcc.CallUnansweredAfter || cw.heard == nil || !cw.heard() || cw.findings.Put == nil { continue } - o := mcc.CallUnanswered(id, callee, "", err.Error(), "assigner."+mcc.KindCallUnanswered, since) - value, eerr := mcc.EncodeFinding(o.Finding(plc.Module, "", clock().UTC())) - if eerr == nil { - eerr = cw.findings.Put(key, value) - } + eerr := cw.findings.Put(assign.CallUnanswered(id, callee, "", err.Error(), "assigner."+mcc.KindCallUnanswered, since).Finding(clock())) if eerr != nil { logf("the finding %s is not written: %v", key, eerr) continue diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/halves.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/halves.go index 07829862..b57d1575 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/halves.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/halves.go @@ -9,99 +9,64 @@ import ( "sync" "time" - "github.com/nats-io/nats.go/jetstream" - mcc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-delivery/sdk/graph" "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/views" ) // halves follows the catalog half of each watched branch through the seat mesh-catalog (design 47 G §14, §184, §195): one -// meshcatalogclient.Catalog per branch, a first view of the verb `revision` and then each `catalog-revision` of that branch, -// each entry taken into the world as the value and the graph node the rules read. Of the view the assigner reads only the catalog -// half, never its own output back (§195). +// meshcatalogclient.Catalog per branch, adopted through the module's views reader (A-26), which asks the first view of the verb +// `revision`, hears each `catalog-revision` of that branch and raises and clears the follower's findings; each entry is taken +// into the world as the value and the graph node the rules read. Of the view the assigner reads only the catalog half, never its +// own output back (§195). type halves struct { - w *world - call mcc.Call - wake func() - logf func(string, ...any) - ctx context.Context + w *world + call mcc.Call + reader *views.ProcessReader + wake func() + logf func(string, ...any) + ctx context.Context mu sync.Mutex cats map[string]*mcc.Catalog - // started is called with each catalog made, for the part that reads their status. - started func(*mcc.Catalog) // settled is told once when a branch's first view is answered, for the first pass to wait on (the trunk's alone). settled chan<- struct{} } -// follow makes the catalog of branch and asks its first view, once; true when it was made now. +// follow adopts the catalog of branch through the reader, once; true when it was made now. func (h *halves) follow(branch string) bool { h.mu.Lock() if h.cats[branch] != nil { h.mu.Unlock() return false } - c, err := mcc.NewCatalog(h.call, branch) + c, _, err := mcc.WireCatalogProcess(h.reader, h.call, branch, func(module string, entry json.RawMessage, deleted bool, _ time.Time) { + h.changed(branch, module, entry, deleted) + }) if err != nil { h.mu.Unlock() h.logf("the catalog half of branch %q is not followed: %v", branch, err) return false } - c.Follower.Logf = h.logf - c.Follower.Changed = func(module string, entry json.RawMessage, deleted bool, _ time.Time) { - h.changed(branch, module, entry, deleted) - } if h.cats == nil { h.cats = map[string]*mcc.Catalog{} } h.cats[branch] = c h.mu.Unlock() - if h.started != nil { - h.started(c) - } - go func() { - if err := c.Start(h.ctx); err != nil { - if h.ctx.Err() == nil { - h.logf("the first view of the catalog half of branch %q is not answered: %v", branch, err) + if branch == trunkBranch && h.settled != nil { + go func() { + select { + case <-c.Ready(): + h.settled <- struct{}{} + case <-h.ctx.Done(): } - return - } - if branch == trunkBranch && h.settled != nil { - h.settled <- struct{}{} - } - }() + }() + } return true } -// hear gives a catalog-revision to every catalog: each takes its own branch's and passes over another's. -func (h *halves) hear(m jetstream.Msg) { - h.mu.Lock() - cats := make([]*mcc.Catalog, 0, len(h.cats)) - for _, c := range h.cats { - cats = append(cats, c) - } - h.mu.Unlock() - for _, c := range cats { - if err := c.Hear(h.ctx, m.Data()); err != nil { - h.logf("%s on branch %q: %v", m.Subject(), c.Branch, err) - } - } - _ = m.Ack() -} - -// followers are the first views the catalogs hold. -func (h *halves) followers() []*mcc.Catalog { - h.mu.Lock() - defer h.mu.Unlock() - out := make([]*mcc.Catalog, 0, len(h.cats)) - for _, c := range h.cats { - out = append(out, c) - } - return out -} - // changed takes one entry of branch's half into the world, or a module taken out of it. func (h *halves) changed(branch, module string, raw json.RawMessage, deleted bool) { defer h.wake() diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go index e10e9312..618cfb82 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + mcseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" "git.novox.be/novox/mesh-sdk/go/keys" "io" "maps" @@ -52,7 +53,6 @@ import ( "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient/enginereads" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" - "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" "git.novox.be/novox/mesh-sdk/go/views" "mesh-delivery-assigner/internal/assign" @@ -179,6 +179,10 @@ func startBed(t *testing.T, leaveOut string) *bed { if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "EVENTS", Subjects: []string{"mesh.mod.*.event.>", "mesh.seat.*.event.>"}}); err != nil { t.Fatal(err) } + // The assigner's own stream of its findings' events (design 47 G §383), which its finding writer says into. + if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "ASSIGNER_FINDINGS", Subjects: []string{"mesh.stream.mesh-delivery-assigner.event.finding-written.*", "mesh.stream.mesh-delivery-assigner.event.finding-cleared.*", "mesh.stream.mesh-delivery-assigner.event.findings-stated.*"}}); err != nil { + t.Fatal(err) + } // The assigner's durable consumer on EVENTS, as the grants part makes it from the manifest's consumes: a node-engine seat's // event names the engine's machine last, so the filter is the event with every machine (mesh-grants buscompose // machineEvent; the grants part's own test holds the derivation). @@ -508,7 +512,8 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) { "seat-holders": {"n1.node-tools"}, "wants": {"n1.mesh-grants"}, }) - graphKey := meshconditionsclient.Key(meshconditionsclient.ScopeModule, assignment.Module+"."+meshcatalog.Seat+"."+views.BranchState(meshcatalog.StateCatalogRevisions, trunkBranch), views.UnansweredKind) + // The reader raises the finding of the first view through the module's finding writer (A-26), its id escaped as a key's name. + graphKey := views.FindingKey(views.FindingScope, keys.EscapeName(assignment.Module+"."+meshcatalog.Seat+"."+views.BranchState(meshcatalog.StateCatalogRevisions, trunkBranch)), views.UnansweredKind) graphRaised := func() bool { e, err := b.buckets[assign.FindingsBucket].Get(b.ctx, graphKey) return err == nil && strings.Contains(string(e.Value()), "mesh-catalog.revision") @@ -855,12 +860,12 @@ func (b *bed) conditionsSaid(t *testing.T) map[string]oc.Condition { c := out[e.Key()] c.Key, c.State = e.Key(), occ.StateCleared if e.Operation() == jetstream.KeyValuePut { - f, err := meshconditionsclient.DecodeFinding(e.Value()) - if err != nil { + var f mcseat.Finding + if err := json.Unmarshal(e.Value(), &f); err != nil { t.Fatal(err) } c = oc.Condition{Key: e.Key(), State: occ.StateRaised, Severity: string(f.Severity), Explanation: f.Explanation, - Resolved: f.Resolved, Subject: oc.ConditionSubject{Scope: string(f.Subject.Scope), ID: f.Subject.ID, Machine: f.Subject.Machine}} + Subject: oc.ConditionSubject{Scope: string(f.Subject.Scope), ID: f.Subject.ID, Machine: f.Subject.Machine}} } out[e.Key()] = c } diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go index e99492ea..ffd7ab90 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go @@ -8,7 +8,6 @@ package main import ( "context" - "errors" "fmt" "io" "log" @@ -18,6 +17,7 @@ import ( "os" "os/signal" "slices" + "strings" "syscall" "time" @@ -25,7 +25,7 @@ import ( "github.com/nats-io/nats.go/jetstream" mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" - mcc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + mcseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" "git.novox.be/novox/mesh-sdk/go/announce" @@ -110,7 +110,7 @@ var keptStates = []announce.State{ {Name: "differences", Readable: true}, {Name: "puts", Readable: true}, {Name: "wants", Readable: true}, - {Name: mcc.FindingsState, Readable: true}, + {Name: assign.FindingsState, Readable: true}, {Name: buckets.PoliciesState, Readable: true}, {Name: mma.StateSeatHolders, Readable: true}, {Name: views.MarksState, Readable: true}, // what its views family said each state up to (views.go) @@ -235,11 +235,14 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro } thisNode = account.Node logger.Printf("started on the account %s, reading %d buckets", account.User, len(kvs)) - findings := findingsOf(ctx, kvs, logger.Printf) + findings, writer := findingsOf(ctx, account, conn, js, said, kvs, logger.Printf) + if writer != nil { + defer writer.Close() + } // Each change of the states its readers read is said on its views family (ADR 0615 §5, ADR 0619 §2); a watch of it // that ends stops the program, as a watch of what a pass reads does. viewErrs := make(chan error, len(viewedStates)) - startViews(ctx, js, conn, account, kvs, findings, viewErrs, logger.Printf) + startViews(ctx, js, conn, account, kvs, wireOf(writer), viewErrs, logger.Printf) go func() { select { case <-ctx.Done(): @@ -248,27 +251,52 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro conn.Close() } }() - iv := &catalogViews{js: js, account: account, call: said.Call, findings: findings, logf: logger.Printf, + iv := &catalogViews{js: js, account: account, call: said.Call, writer: wireOf(writer), logf: logger.Printf, status: process.AwaitBucket(ctx, js, conn, firstViewsBucket, "assigner_first_views reads nothing and assigner_first_view_again is not heard", logger.Printf)} return follow(ctx, js, conn, kvs, findings.Say(sayVia(ctx, js)), findings, emitting{said}, iv, logger) } // findingsOf is where the assigner's said conditions go as findings (issue 1952 WM6): its findings bucket, which the // condition keeper carries (novox/hq ADR 0590 §3). Without it the conditions are said straight to the router. -func findingsOf(ctx context.Context, kvs kvSet, logf func(string, ...any)) assign.Findings { +func findingsOf(ctx context.Context, account process.Account, conn *nats.Conn, js jetstream.JetStream, said *process.Conn, kvs kvSet, logf func(string, ...any)) (assign.Findings, *heldWriter) { var f assign.Findings - if kv := kvs[assign.FindingsBucket]; kv != nil { - f.Put = func(key string, value []byte) error { _, err := kv.Put(ctx, key, value); return err } - f.Delete = func(key string) error { return kv.Delete(ctx, key) } - f.Keys = func() ([]string, error) { - keys, err := kv.Keys(ctx) - if errors.Is(err, jetstream.ErrNoKeysFound) { - return nil, nil - } - return keys, err - } + if kvs[assign.FindingsBucket] == nil { + return f, nil } - return f + state := said.State(assign.FindingsState) + w, err := views.NewHeldWriter(views.WriterConfig[mcseat.Finding, mcseat.FindingCleared, mcseat.FindingsStated, mcseat.FindingsAskedEvent]{ + Module: account.Module, State: state, Node: account.Node, Asked: mcseat.SubjectFindingsAsked, JS: js, Conn: conn, Logf: logf}) + if err != nil { + logf("the assigner's finding writer cannot be made, so its findings are not written: %v", err) + return f, nil + } + // A writer that cannot start (the stream of its asks is not there yet) is said; its findings are then kept in the + // state alone and said by the next start (as the node-engine's is). + if err := w.Start(ctx); err != nil { + logf("the finding writer cannot start, its findings are kept in the state alone: %v", err) + } + f.Put = func(x mcseat.Finding) error { return w.Write(ctx, x) } + f.Delete = func(key string) error { + scope, rest, _ := strings.Cut(key, ".") + i := strings.LastIndex(rest, ".") + if i < 0 { + return fmt.Errorf("%q is no finding's key", key) + } + return w.Clear(ctx, scope, rest[:i], rest[i+1:], "the assigner no longer observes it") + } + f.Keys = state.Keys + return f, w +} + +// heldWriter is the assigner's finding writer: its findings are raised as they happen, so the writer holds them (design 47 G A-29). +type heldWriter = views.HeldWriter[mcseat.Finding, mcseat.FindingCleared, mcseat.FindingsStated, mcseat.FindingsAskedEvent] + +// wireOf is w as the views parts' finding writer; nil when the assigner has none, so no part writes through a nil writer. +func wireOf(w *heldWriter) views.WireWriter { + if w == nil { + return nil + } + return w } // thisNode is the node this process runs on, as its credential names it: said with each of the seat's events. diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main_test.go index f1a66b44..3155a771 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main_test.go @@ -1,6 +1,9 @@ package main import ( + "encoding/json" + mcseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + "git.novox.be/novox/mesh-sdk/go/views" "mesh-delivery-assigner/internal/assign" plc "mesh-delivery-assigner/internal/assignment" "mesh-delivery-assigner/internal/buckets" @@ -31,8 +34,7 @@ import ( // (`.` read as the bucket `_`), so it is granted what it reads and reads nothing else. func TestOpensWhatItsManifestNames(t *testing.T) { // Review round 2: the assigner's views emitter writes its finding view-unsaid (Emitter.Findings set) when it keeps findings. - findings := assign.Findings{Put: func(string, []byte) error { return nil }, Delete: func(string) error { return nil }} - if viewsEmitter(nil, process.Account{}, nil, findings, nil, t.Logf).Findings == nil { + if viewsEmitter(nil, process.Account{}, nil, &heldWriter{}, t.Logf).Findings == nil { t.Error("the assigner's views emitter writes no view-unsaid finding") } // 1297/B2a: without a credential the program does not start, and says why. @@ -197,3 +199,12 @@ func (e kvEntry) Created() time.Time { return time.Time{} } func (w *watchKV) Keys(context.Context, ...jetstream.WatchOpt) ([]string, error) { return nil, jetstream.ErrNoKeysFound } + +// putInto is an assign.Findings Put that keeps each finding in into, as the finding writer would, by its key and its words. +func putInto(into map[string][]byte) func(mcseat.Finding) error { + return func(f mcseat.Finding) error { + raw, err := json.Marshal(f) + into[views.FindingKey(f.Scope, f.ID, f.Kind)] = raw + return err + } +} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go index 33382b2d..1999a44b 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go @@ -149,13 +149,9 @@ func (p *passer) unansweredReads(known []string, online map[string]bool) { if p.calls == nil || p.calls.Keeper != p.reads { p.calls = &enginereads.ReadsCalls{Keeper: p.reads, Bound: mcc.CallUnansweredAfter, Now: p.clock, Logf: p.logf, Raise: func(_ context.Context, u enginereads.UnansweredCall) error { - o := mcc.CallUnanswered(enginereads.CallID(u.Node), enginereads.Invoke, u.Node, u.Words, "assigner."+mcc.KindCallUnanswered, u.Since) + o := assign.CallUnanswered(enginereads.CallID(u.Node), enginereads.Invoke, u.Node, u.Words, "assigner."+mcc.KindCallUnanswered, u.Since) o.Needs = "assigner_engine_reads with ask_again asks the node again now and shows its statement." - value, err := mcc.EncodeFinding(o.Finding(plc.Module, "", u.At)) - if err == nil { - err = p.findings.Put(o.Key(), value) - } - return err + return p.findings.Put(o.Finding(u.At)) }, Clear: func(_ context.Context, node string) error { return p.findings.Delete(mcc.Key(mcc.ScopeCall, enginereads.CallID(node), mcc.KindCallUnanswered)) @@ -1126,9 +1122,9 @@ func (p *passer) provisionFindings(failed map[string]meshprovisionerclient.Provi if p.failedProvisions[key] { continue } - value, err := assign.ProvisionFailedFinding(key, pr, now()) + f, err := assign.ProvisionFailedFinding(key, pr, now()) if err == nil { - err = p.findings.Put(assign.ProvisionFailedKey(key), value) + err = p.findings.Put(f) } if err != nil { p.logf("the failed provision %s is not said: %v", key, err) @@ -1178,9 +1174,9 @@ func (p *passer) provisionsMissing(ctx context.Context, missing map[string]bool) if p.missingSaid[key] || p.findings.Put == nil { continue } - value, err := assign.ProvisionMissingFinding(key, MissingBound, p.clock()) + f, err := assign.ProvisionMissingFinding(key, MissingBound, p.clock()) if err == nil { - err = p.findings.Put(assign.ProvisionMissingKey(key), value) + err = p.findings.Put(f) } if err != nil { p.logf("the missing provision %s is not said: %v", key, err) @@ -1226,9 +1222,9 @@ func (p *passer) unplacedFindings() { if p.unplaced[node] { continue } - value, err := assign.UnplacedFinding(node, p.clock()) + f, err := assign.UnplacedFinding(node, p.clock()) if err == nil { - err = p.findings.Put(assign.UnplacedKey(node), value) + err = p.findings.Put(f) } if err != nil { p.logf("the unplaced node %s is not said: %v", node, err) 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 93654d0d..f884f342 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 @@ -209,7 +209,7 @@ func TestAHoldAskedAgainAfterALiftAndARestartHasANewID(t *testing.T) { for _, heard := range []bool{false, true} { clock, findings := now, map[string][]byte{} cw := callWait{heard: func() bool { return heard }, now: func() time.Time { return clock }, - findings: assign.Findings{Put: func(k string, v []byte) error { findings[k] = v; return nil }, + findings: assign.Findings{Put: putInto(findings), Delete: func(k string) error { delete(findings, k); return nil }}} tries, during := 0, 0 answer, err := cw.keep(context.Background(), asksCallID, channelAsksVerb, func() (json.RawMessage, error) { @@ -332,7 +332,7 @@ func TestAnAnswerCountsOnlyForTheAskingItAnswered(t *testing.T) { return nil, context.DeadlineExceeded } q := &passer{w: w, logf: t.Logf, now: func() time.Time { return clock }, findings: assign.Findings{ - Put: func(k string, v []byte) error { held[k] = v; return nil }, + Put: putInto(held), Delete: func(k string) error { delete(held, k); return nil }, Keys: func() ([]string, error) { return slices.Collect(maps.Keys(held)), nil }}, reads: &enginereads.Keeper{Call: silent, Sync: true, Now: func() time.Time { return clock }}} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go index 0cd6b438..792f9f18 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go @@ -24,7 +24,6 @@ import ( mmac "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" - mcc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/catalogue" @@ -147,6 +146,9 @@ func TestTheSeatsVerbs(t *testing.T) { } } for _, stream := range raw0.Streams { + if strings.HasSuffix(stream.Name, "-findings") { // the part's own finding events, not a state of the seat + continue + } if !reads["stream "+stream.Name] { t.Errorf("no verb reads the seat's stream %s", stream.Name) } @@ -283,7 +285,7 @@ func TestTheSeatsVerbs(t *testing.T) { } findings := map[string][]byte{} p := &passer{w: w, logf: func(string, ...any) {}, findings: assign.Findings{ - Put: func(k string, v []byte) error { findings[k] = v; return nil }, + Put: putInto(findings), Delete: func(k string) error { delete(findings, k); return nil }}} p.unplacedFindings() if _, ok := findings[assign.UnplacedKey("g14")]; !ok || len(findings) != 1 { @@ -471,7 +473,7 @@ func TestTheSeatsVerbs(t *testing.T) { t.Run("1954/D1: a failed provision is the assigner's finding until it is provided", func(t *testing.T) { findings := map[string][]byte{} p := &passer{w: newWorld(), logf: func(string, ...any) {}, kvs: kvSet{buckets.Provisions: nil}, findings: assign.Findings{ - Put: func(k string, v []byte) error { findings[k] = v; return nil }, + Put: putInto(findings), Delete: func(k string) error { delete(findings, k); return nil }}} failed, _ := meshprovisionerclient.Encode(meshprovisionerclient.Provision{Provision: "db", Consumer: meshprovisionerclient.End{Module: "photos", Node: "ace"}, Provider: meshprovisionerclient.End{Module: "postgres", Node: "novox"}, State: meshprovisionerclient.Failed, Why: "the role exists already"}) @@ -612,7 +614,7 @@ func wantsVerbs(t *testing.T) { state, watches[state], watches[state+" by key"], indexer.asks[state]) } } - for _, state := range []string{haveState, mcc.FindingsState} { + for _, state := range []string{haveState, assign.FindingsState} { if watches[state] != 1 { t.Errorf("%s was watched %d times, want once", state, watches[state]) } diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/views.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/views.go index 4186a832..f52e96e7 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/views.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/views.go @@ -6,23 +6,19 @@ import ( "mesh-delivery-assigner/internal/assignment" "mesh-delivery-assigner/internal/buckets" "slices" - "sync" "time" "github.com/nats-io/nats.go/jetstream" mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" - mcc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/process" "git.novox.be/novox/mesh-sdk/go/views" - sdkfindings "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient/findings" "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient/heard" - mcat "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" - "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + mcseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" "mesh-delivery-assigner/internal/assign" plc "mesh-delivery-assigner/internal/assignment" @@ -47,7 +43,7 @@ var readTools = []viewedTool{ {"assigner_read_assignments", mma.StateAssignments, "where each module is placed, by module."}, {"assigner_read_seat_holders", mma.StateSeatHolders, "who holds which seat on which node."}, {"assigner_read_wants", "wants", "what each node should run: want..."}, - {"assigner_read_findings", mcc.FindingsState, "the assigner's findings, which the condition keeper raises and clears."}, + {"assigner_read_findings", assign.FindingsState, "the assigner's findings, which the condition keeper raises and clears."}, {"assigner_read_asks", "asks", "the asks the assigner put to a person, and their answers."}, {"assigner_read_differences", "differences", "every difference between a want and what a node runs."}, {"assigner_read_copies", copiesState, "the copies the assigner made of a retired state: when the copy into assignments ended and how many keys it copied."}, @@ -58,7 +54,7 @@ var viewedStates = map[string]string{ mma.StateAssignments: plc.AssignmentsBucket, mma.StateSeatHolders: plc.SeatHoldersBucket, "wants": buckets.Wants, - mcc.FindingsState: assign.FindingsBucket, + assign.FindingsState: assign.FindingsBucket, "asks": buckets.Asks, "differences": buckets.Differences, copiesState: copiesBucket, @@ -83,18 +79,10 @@ func keptSource(name string) views.Source { return views.KeptSource{State: stdio // startViews says each change of the assigner's viewed states on its views family, once its marks state is bound, // each state as soon as its bucket is (kvs, or bound once it appears). A watch that ends is sent on errs. -func startViews(ctx context.Context, js jetstream.JetStream, nc process.Subscriber, account process.Account, kvs kvSet, findings assign.Findings, errs chan<- error, logf func(string, ...any)) { - tooLarge := func(state, key string, size int) { - if findings.Put == nil { - return - } - if err := viewFindings(findings).Put(views.TooLargeFinding(assignment.Module, state, key, size, time.Now())); err != nil { - logf("views: the finding of %s.%s too large is not written: %v", state, key, err) - } - } +func startViews(ctx context.Context, js jetstream.JetStream, nc process.Subscriber, account process.Account, kvs kvSet, writer views.WireWriter, errs chan<- error, logf func(string, ...any)) { process.AwaitBucket(ctx, js, nc, marksBucket, "no change is said on the views family, so every reader waits on its first view", logf).Ready(func(mk jetstream.KeyValue) { - em := viewsEmitter(js, account, mk, findings, tooLarge, logf) - for _, state := range []string{"asks", "differences", mcc.FindingsState, mma.StateAssignments, mma.StateSeatHolders, "wants", copiesState} { + em := viewsEmitter(js, account, mk, writer, logf) + for _, state := range []string{"asks", "differences", assign.FindingsState, mma.StateAssignments, mma.StateSeatHolders, "wants", copiesState} { state := state start := func(kv jetstream.KeyValue) { if err := em.Start(ctx, state, kv, errs); err != nil { @@ -111,35 +99,27 @@ func startViews(ctx context.Context, js jetstream.JetStream, nc process.Subscrib } // viewsEmitter is the assigner's emitter of its views family: a change not said past views.Bound is its finding -// view-unsaid, in its findings (review round 2); with no findings state it is said in the log only. -func viewsEmitter(js jetstream.JetStream, account process.Account, mk jetstream.KeyValue, findings assign.Findings, tooLarge func(string, string, int), logf func(string, ...any)) views.Emitter { - em := views.Emitter{Owner: assignment.Module, Subject: account.EventSubject, Publish: views.JetStreamPublish(js), Marks: mk, TooLarge: tooLarge, Logf: logf} - if findings.Put != nil && findings.Delete != nil { - em.Findings = viewFindings(findings) - } - return em -} - -// viewFindings is the assigner's findings as the views' FindingWriter, in the seat mesh-conditions' layout. -func viewFindings(f assign.Findings) views.FindingWriter { - return sdkfindings.WriterOver(f.Put, f.Delete) +// view-unsaid, in its findings, written through its finding writer (review round 2); with none it is said in the log only. +func viewsEmitter(js jetstream.JetStream, account process.Account, mk jetstream.KeyValue, writer views.WireWriter, logf func(string, ...any)) views.Emitter { + return views.Emitter{Owner: assignment.Module, Subject: account.EventSubject, Publish: views.JetStreamPublish(js), Marks: mk, Findings: writer, Logf: logf} } // firstViewsBucket is the assigner's own state of its first views (views.StatusState). var firstViewsBucket = assignment.Module + "_" + views.StatusState -// catalogViews is the assigner's first view of the catalog half of each watched branch (design 47 G §14, §184, §195): asked -// at the start through the seat mesh-catalog's verb `revision` (the trunk's, then each branch a registration names), then -// followed on its event `catalog-revision`, asking once more on a gap; past the bound its finding names the seat and the verb, -// cleared by the answer. Its status is read by assigner_first_views and asked again by assigner_first_view_again. +// catalogViews is the assigner's first view of the catalog half of each watched branch (design 47 G §14, §184, §195, A-26): the +// module's views reader adopts each branch's follower, so it asks the seat mesh-catalog's verb `revision` (the trunk's, then +// each branch a registration names), follows its event `catalog-revision`, asks once more on a gap, and past the bound raises +// the finding naming the seat and the verb, cleared by the answer, in the assigner's findings. Its status is read by +// assigner_first_views and asked again by assigner_first_view_again. type catalogViews struct { - js jetstream.JetStream - account process.Account - call func(string, any) (json.RawMessage, error) - findings assign.Findings - status *process.AwaitedBucket - logf func(string, ...any) - hub *process.EventHub + js jetstream.JetStream + account process.Account + call func(string, any) (json.RawMessage, error) + writer views.WireWriter + status *process.AwaitedBucket + logf func(string, ...any) + hub *process.EventHub h *halves } @@ -149,70 +129,31 @@ type catalogViews struct { // seconds). A test shortens them. var firstViewBound, firstViewWait time.Duration -// start follows the trunk's catalog half and hears the seat's catalog-revision; it answers how many settles to wait for. +// start adopts the trunk's catalog half through the reader and starts the reader; it answers how many settles to wait for. func (iv *catalogViews) start(ctx context.Context, w *world, wake func(), settled chan<- struct{}) int { - finding := func(s views.Status, raised bool) { - if iv.findings.Put == nil { - return - } - f := views.UnansweredFinding(assignment.Module, s, time.Now()) - var err error - if raised { - err = viewFindings(iv.findings).Put(f) - } else { - err = viewFindings(iv.findings).Delete(f) - } - if err != nil { - iv.logf("views: the finding of the first view %s is not written: %v", s.Tool, err) - } + reader := &views.ProcessReader{Module: assignment.Module, Call: iv.call, Logf: iv.logf, Hub: iv.hub, Bound: firstViewBound, Wait: firstViewWait} + if iv.writer != nil { + reader.Findings = func() views.WireWriter { return iv.writer } } - var statusKV jetstream.KeyValue - var mu sync.Mutex - iv.h = &halves{w: w, call: iv.call, wake: wake, logf: iv.logf, ctx: ctx, settled: settled} - iv.h.started = func(c *mcat.Catalog) { - f := c.Follower - f.Bound, f.Wait = firstViewBound, firstViewWait - f.Raise, f.Clear = func(s views.Status) { finding(s, true) }, func(s views.Status) { finding(s, false) } - mu.Lock() - defer mu.Unlock() - if statusKV != nil { - f.Said = views.KeepStatus(ctx, statusKV, iv.logf) - } - } - if iv.status != nil { - iv.status.Ready(func(kv jetstream.KeyValue) { - mu.Lock() - statusKV = kv - mu.Unlock() - var all []*views.Follower - for _, c := range iv.h.followers() { - c.Follower.Said = views.KeepStatus(ctx, kv, iv.logf) - all = append(all, c.Follower) - } - if err := views.HearAgain(ctx, kv, all, iv.logf); err != nil { - iv.logf("views: assigner_first_view_again is not heard (%v) until the assigner starts again", err) - } - }) - } - // The hub says when the bus made its consumer: a picture asked before then is asked again (views.Follower.ConsumerMade). - iv.hub.OnBound(func(created time.Time) { - for _, c := range iv.h.followers() { - c.Follower.ConsumerMade(ctx, created) - } - }) - // Not heard, the catalog-revision is the finding family-not-heard until it binds (ADR 0615 §5): the handler is registered - // on the module's hub, which the assigner starts once every handler is. - _ = notHeardWriter(iv.findings, iv.logf).Bind(process.EventsStream, "the catalog half is the first views' alone until the assigner starts again", func() error { - return iv.hub.Handle(meshcatalog.SubjectCatalogRevisionAll, iv.h.hear) - }) + iv.h = &halves{w: w, call: iv.call, reader: reader, wake: wake, logf: iv.logf, ctx: ctx, settled: settled} iv.h.follow(trunkBranch) + reader.Start(ctx, iv.js, iv.account, iv.status) return 1 } // notHeardWriter writes the assigner's finding of a family it does not hear into its findings (heard.Writer); with no // findings state it is said in the log alone. func notHeardWriter(findings assign.Findings, logf func(string, ...any)) heard.Writer { - w := heard.Writer{Observer: assignment.Module, Logf: logf, Put: findings.Put} + w := heard.Writer{Observer: assignment.Module, Logf: logf} + if findings.Put != nil { + w.Put = func(_ string, value []byte) error { + var f mcseat.Finding + if err := json.Unmarshal(value, &f); err != nil { + return err + } + return findings.Put(f) + } + } if findings.Delete != nil { w.Delete = func(key string) error { if keys, err := findings.Keys(); err == nil && !slices.Contains(keys, key) { diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/wants_tools.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/wants_tools.go index a2da5bfb..747b3027 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/wants_tools.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/wants_tools.go @@ -16,7 +16,6 @@ import ( "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants" - mcc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/catalogue" @@ -170,7 +169,7 @@ func (s *wantsTools) doUpgrade(args map[string]any, by string) (any, error) { out["published"] = published(values) } // The assigner's findings, its said conditions as the condition keeper reads them (§15). - if found, err := s.values(mcc.FindingsState); err != nil { + if found, err := s.values(assign.FindingsState); err != nil { out["findings-unread"] = err.Error() } else { keys := []string{} diff --git a/modules/mesh-delivery-assigner/internal/assign/asks_test.go b/modules/mesh-delivery-assigner/internal/assign/asks_test.go index 5264609f..f8c560a9 100644 --- a/modules/mesh-delivery-assigner/internal/assign/asks_test.go +++ b/modules/mesh-delivery-assigner/internal/assign/asks_test.go @@ -16,10 +16,10 @@ import ( "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate" - mcc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" mcseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" + "git.novox.be/novox/mesh-sdk/go/views" ) // 1297/B9 (the assigner's half): the ask about a held fingerprint is a valid ask of the router and of mesh-sdk's layout at @@ -292,11 +292,10 @@ func saidConditionsAreFindings(t *testing.T) { {SeatWaitsCondition([]string{"assignments"}), SeatWaitsCondition(nil), "module.mesh-delivery-assigner.seat-states"}, {BackupCondition("ace", "mail", fNew, "disk full", true), BackupCondition("ace", "mail", fNew, "", false), "module.ace.mail." + strings.TrimPrefix(fNew, meshcatalogclient.SourcePrefix2) + ".backup"}, } { - puts, deletes, said := map[string]mcseat.FindingsByKeyValue{}, []string{}, 0 - f := Findings{Put: func(k string, v []byte) error { - got, err := mcc.DecodeFinding(v) - puts[k] = got - return err + puts, deletes, said := map[string]mcseat.Finding{}, []string{}, 0 + f := Findings{Put: func(f mcseat.Finding) error { + puts[views.FindingKey(f.Scope, f.ID, f.Kind)] = f + return nil }, Delete: func(k string) error { deletes = append(deletes, k); return nil }, Now: func() time.Time { return at }} say := f.Say(func(oc.Condition) error { said++; return nil }) if err := say(c.raised); err != nil { @@ -314,7 +313,7 @@ func saidConditionsAreFindings(t *testing.T) { t.Fatalf("%s: with no findings bucket said %d (%v), want straight once", c.key, said, err) } } - if _, _, _, err := FindingOf(oc.Condition{Key: "another.x", State: occ.StateRaised}, at); err == nil { + if _, _, err := FindingOf(oc.Condition{Key: "another.x", State: occ.StateRaised}, at); err == nil { t.Error("a condition not the assigner's is a finding") } } diff --git a/modules/mesh-delivery-assigner/internal/assign/findings.go b/modules/mesh-delivery-assigner/internal/assign/findings.go index ca58dfc9..de663d5a 100644 --- a/modules/mesh-delivery-assigner/internal/assign/findings.go +++ b/modules/mesh-delivery-assigner/internal/assign/findings.go @@ -11,6 +11,7 @@ import ( mcseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" + "git.novox.be/novox/mesh-sdk/go/views" ) // The assigner's said conditions become findings (novox/hq ADR 0564 §2, issue 1952 WM6, note 16): every condition it says @@ -21,15 +22,66 @@ import ( // assigner no longer says it straight (never both). // FindingsBucket is the assigner's findings. -var FindingsBucket = mcc.FindingsBucket(assignment.Module) +// FindingsState is the assigner's state of its findings, and FindingsBucket its bucket. +const FindingsState = "findings" -// FindingOf is a said condition as a finding: its key `..`, from the condition's own key -// `mesh-delivery-assigner..` (the subject's id when the key names none), its words unchanged. raised is false for -// a clear, whose key is to be deleted; value is nil then. -func FindingOf(c oc.Condition, at time.Time) (key string, value []byte, raised bool, err error) { +var FindingsBucket = assignment.Module + "_" + FindingsState + +// KeyID is an id as the parts of a key: each dot-separated part made safe for a bus key, as the key of a condition is +// (mcc.Key), so a finding's key keeps the shape its condition had. +func KeyID(id string) string { + key := mcc.Key("x", id, "x") + return strings.TrimSuffix(strings.TrimPrefix(key, "x."), ".x") +} + +// Observed is what the assigner observes wrong, before it is the seat mesh-conditions' generated finding. +type Observed struct { + Scope, ID, Kind, Machine string + Also []string + Severity string + Summary, Said string + Headline, Explanation string + Needs, Source string + Actions []mcseat.Action +} + +// Finding is o as the seat's generated finding at at (design 47 G §686, A-22): its id made safe as a key's part, the resolved +// words not a field of it, its observer the assigner; the writer completes its `since`. +func (o Observed) Finding(at time.Time) mcseat.Finding { + at = at.UTC() + id := KeyID(o.ID) + said := o.Said + if said == "" { + said = o.Summary + } + sev := o.Severity + if sev == "" { + sev = mcseat.FindingSeverityWarning + } + return mcseat.Finding{Scope: o.Scope, ID: id, Kind: o.Kind, Severity: sev, Summary: o.Summary, Headline: o.Headline, + Explanation: o.Explanation, Needs: o.Needs, Actions: o.Actions, Source: o.Source, Observer: assignment.Module, At: at, + Evidence: []mcseat.Evidence{{At: at, Said: said}}, + Subject: mcseat.Subject{Scope: o.Scope, ID: id, Machine: o.Machine, Also: o.Also}} +} + +// CallUnanswered is the observation of a kept call to callee (a seat verb's or a tool's key) about id (what the assigner waits +// on: a node, a key), unanswered since since, its last fault said. Its key is `call..call-unanswered`. +func CallUnanswered(id, callee, machine, fault, source string, since time.Time) Observed { + return Observed{Scope: mcc.ScopeCall, ID: id, Kind: mcc.KindCallUnanswered, Machine: machine, Severity: mcseat.FindingSeverityWarning, + Summary: fmt.Sprintf("the call %s about %s is unanswered since %s", callee, id, since.UTC().Format(time.RFC3339)), + Said: fault, Headline: "A call whose answer is kept is not answered", + Explanation: fmt.Sprintf("The call %s is made again on every transport fault and on the callee's event; it has "+ + "not been answered since %s. Its last fault: %s.", callee, since.UTC().Format(time.RFC3339), fault), + Source: source} +} + +// FindingOf is a said condition as a finding: from the condition's own key `mesh-delivery-assigner..` (the +// subject's id when the key names none), its words unchanged but the resolved ones. raised is false for a clear, whose +// finding names only its scope, id and kind. +func FindingOf(c oc.Condition, at time.Time) (f mcseat.Finding, raised bool, err error) { rest, ok := strings.CutPrefix(c.Key, assignment.Module+".") if !ok || rest == "" { - return "", nil, false, fmt.Errorf("the condition %q is not the assigner's", c.Key) + return f, false, fmt.Errorf("the condition %q is not the assigner's", c.Key) } kind, id, _ := strings.Cut(rest, ".") if id == "" { @@ -39,12 +91,11 @@ func FindingOf(c oc.Condition, at time.Time) (key string, value []byte, raised b if scope == "" { scope = mcc.ScopeModule } - key = mcc.Key(scope, id, kind) if c.State == occ.StateCleared { - return key, nil, false, nil + return mcseat.Finding{Scope: scope, ID: KeyID(id), Kind: kind}, false, nil } if c.State != occ.StateRaised { - return "", nil, false, fmt.Errorf("the condition %s is neither raised nor cleared", c.Key) + return f, false, fmt.Errorf("the condition %s is neither raised nor cleared", c.Key) } summary := c.Summary if summary == "" { @@ -54,16 +105,14 @@ func FindingOf(c oc.Condition, at time.Time) (key string, value []byte, raised b for _, a := range c.Actions { actions = append(actions, mcseat.Action{Label: a.Label, Verb: a.Verb, Machine: a.Machine, Arguments: a.Arguments, Level: string(a.Level)}) } - f := mcc.Observation{Scope: scope, ID: id, Kind: kind, Machine: c.Subject.Machine, Severity: string(c.Severity), Summary: summary, - Headline: c.Headline, Explanation: c.Explanation, Resolved: c.Resolved, Needs: c.Needs, Actions: actions, - Source: "assigner." + kind}.Finding(assignment.Module, "", at) - value, err = mcc.EncodeFinding(f) - return key, value, true, err + return Observed{Scope: scope, ID: id, Kind: kind, Machine: c.Subject.Machine, Severity: string(c.Severity), Summary: summary, + Headline: c.Headline, Explanation: c.Explanation, Needs: c.Needs, Actions: actions, Source: "assigner." + kind}.Finding(at), true, nil } -// Findings is where the assigner's findings go: the bucket's put and delete. +// Findings is where the assigner's findings go: its finding writer's write and clear (design 47 G §689, A-29), by the +// finding's key `..`. type Findings struct { - Put func(key string, value []byte) error + Put func(f mcseat.Finding) error Delete func(key string) error Keys func() ([]string, error) Now func() time.Time @@ -80,12 +129,13 @@ func (f Findings) Say(say func(oc.Condition) error) func(oc.Condition) error { if f.Now != nil { now = f.Now } - key, value, raised, err := FindingOf(c, now()) + finding, raised, err := FindingOf(c, now()) + key := views.FindingKey(finding.Scope, finding.ID, finding.Kind) switch { case err != nil: errs = append(errs, err) case raised: - if err := f.Put(key, value); err != nil { + if err := f.Put(finding); err != nil { errs = append(errs, fmt.Errorf("the finding %s cannot be written: %w", key, err)) } default: diff --git a/modules/mesh-delivery-assigner/internal/assign/policy.go b/modules/mesh-delivery-assigner/internal/assign/policy.go index 5a20f309..1e75c117 100644 --- a/modules/mesh-delivery-assigner/internal/assign/policy.go +++ b/modules/mesh-delivery-assigner/internal/assign/policy.go @@ -10,6 +10,7 @@ import ( "time" mcc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + mcseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient" "git.novox.be/novox/mesh-sdk/go/catalogue" @@ -65,19 +66,17 @@ func ProvisionFailedKey(key string) string { } // ProvisionFailedFinding is the finding of a failed provision, naming its consumer, its provider and why. -func ProvisionFailedFinding(key string, p meshprovisionerclient.Provision, at time.Time) ([]byte, error) { +func ProvisionFailedFinding(key string, p meshprovisionerclient.Provision, at time.Time) (mcseat.Finding, error) { summary := fmt.Sprintf("%s for %s on %s failed at %s on %s: %s, so %s is not moved there until it is provided", p.Provision, p.Consumer.Module, p.Consumer.Node, p.Provider.Module, p.Provider.Node, p.Why, p.Consumer.Module) - return encode(mcc.Observation{Scope: mcc.ScopeProvider, ID: key, Kind: KindProvisionFailed, Machine: p.Consumer.Node, Also: also(p.Provider.Node, p.Consumer.Node), + return encode(Observed{Scope: mcc.ScopeProvider, ID: key, Kind: KindProvisionFailed, Machine: p.Consumer.Node, Also: also(p.Provider.Node, p.Consumer.Node), Summary: summary, Said: p.Why, Headline: clipHeadline(p.Consumer.Module + " waits for a provision that failed"), Explanation: occ.NothingToDo + " " + summary + ". It clears once the provider provides it.", - Resolved: p.Provision + " for " + p.Consumer.Module + " is provided", Source: "assigner." + KindProvisionFailed}, at) + Source: "assigner." + KindProvisionFailed}, at) } -// encode is the observation as the assigner's finding at at, in the seat's layout. -func encode(o mcc.Observation, at time.Time) ([]byte, error) { - return mcc.EncodeFinding(o.Finding(assignment.Module, "", at)) -} +// encode is the observation as the assigner's finding at at. +func encode(o Observed, at time.Time) (mcseat.Finding, error) { return o.Finding(at), nil } func also(provider, consumer string) []string { if provider == "" || provider == consumer { @@ -95,18 +94,18 @@ func ProvisionMissingKey(key string) string { } // ProvisionMissingFinding is the finding of a required provision with no key past bound (key `..`). -func ProvisionMissingFinding(key string, bound time.Duration, at time.Time) ([]byte, error) { +func ProvisionMissingFinding(key string, bound time.Duration, at time.Time) (mcseat.Finding, error) { parts := strings.SplitN(key, ".", 3) if len(parts) != 3 { - return nil, fmt.Errorf("%q is not ..", key) + return mcseat.Finding{}, fmt.Errorf("%q is not ..", key) } node, module, provision := parts[0], parts[1], parts[2] summary := fmt.Sprintf("%s on %s requires %s and mesh-provisioner has composed no key for it in %s, so %s is not moved there: "+ "no provider of it may be placed", module, node, provision, bound, module) - return encode(mcc.Observation{Scope: mcc.ScopeProvider, ID: key, Kind: KindProvisionMissing, Machine: node, + return encode(Observed{Scope: mcc.ScopeProvider, ID: key, Kind: KindProvisionMissing, Machine: node, Summary: summary, Headline: clipHeadline(module + " on " + node + " lacks " + provision), Explanation: occ.NothingToDo + " " + summary + ". It clears once the provision has its key.", - Resolved: provision + " for " + module + " on " + node + " has its key", Source: "assigner." + KindProvisionMissing}, at) + Source: "assigner." + KindProvisionMissing}, at) } // KindUnplaced is, from the lease write, a node that runs but no assignments names (ADR 0571 §6). @@ -118,13 +117,13 @@ func UnplacedKey(node string) string { } // UnplacedFinding is the finding of a node no assignments names since the lease write. -func UnplacedFinding(node string, at time.Time) ([]byte, error) { +func UnplacedFinding(node string, at time.Time) (mcseat.Finding, error) { summary := fmt.Sprintf("%s runs, but no assignments names it: since the controller handed its lease away nothing composes it, "+ "so the assigner writes it no want", node) - return encode(mcc.Observation{Scope: mcc.ScopeMachine, ID: node, Kind: KindUnplaced, Machine: node, Summary: summary, + return encode(Observed{Scope: mcc.ScopeMachine, ID: node, Kind: KindUnplaced, Machine: node, Summary: summary, Headline: clipHeadline(node + " has no assignments"), Needs: "place it with mesh-module-assigner.propose.", Explanation: occ.NeedsYou + "place it with mesh-module-assigner.propose. " + summary + ".", - Resolved: node + " is placed", Source: "assigner." + KindUnplaced}, at) + Source: "assigner." + KindUnplaced}, at) } func clipHeadline(s string) string { diff --git a/modules/mesh-delivery-assigner/manifest_test.go b/modules/mesh-delivery-assigner/manifest_test.go index 986d15dd..0430440d 100644 --- a/modules/mesh-delivery-assigner/manifest_test.go +++ b/modules/mesh-delivery-assigner/manifest_test.go @@ -117,8 +117,9 @@ func TestManifest(t *testing.T) { // ADR 0635 §5: its views family is its ordinary events, `views.` among its emits, on no stream of its own. // The seat's four events are said on the seat's own subject (the seat's family), so the module keeps no stream for them // and lists none among its emits. - if len(m.Streams) != 0 { - t.Errorf("streams = %+v, want none: the seat's events ride the shared events stream", m.Streams) + // Its one stream is its own findings' events (design 47 G A-30, §383), which its finding writer says. + if len(m.Streams) != 1 || m.Streams[0].Name != "mesh-delivery-assigner-findings" || !slices.Equal(m.Streams[0].Events, []string{"finding-written.*", "finding-cleared.*", "findings-stated.*"}) { + t.Errorf("streams = %+v, want its findings' stream alone: the seat's events ride the shared events stream", m.Streams) } for _, e := range meshmoduleassigner.Events() { if slices.Contains(m.Emits, e) { @@ -127,7 +128,7 @@ func TestManifest(t *testing.T) { } // ADR 0618 §4: and it hears each node's engine say its reads changed; ADR 0636 §4 (issue 1966): and the seat list's two events; // issue 1966 note 151: and the teller's ask-ended, its subject the one the assigner hears. - if !slices.Equal(m.Consumes, []string{"mesh-catalog.catalog-revision", "mesh-catalog.registration-changed", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", buckets.TellerModule + "." + tellerevents.AskEnded, "mesh-pipeline-runner.pipeline-ended", "mesh-pipeline-runner.group-ended", "node-engine.manifest-reads-changed", "node-engine.running"}) { + if !slices.Equal(m.Consumes, []string{"mesh-conditions.findings-asked", "mesh-catalog.catalog-revision", "mesh-catalog.registration-changed", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", buckets.TellerModule + "." + tellerevents.AskEnded, "mesh-pipeline-runner.pipeline-ended", "mesh-pipeline-runner.group-ended", "node-engine.manifest-reads-changed", "node-engine.running"}) { t.Errorf("consumes = %v, want the catalog half's revisions (design 47 G §195), the teller's ask-ended, the engines' reads changed and what they run", m.Consumes) } if want := []string{"mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules", diff --git a/modules/mesh-delivery-assigner/module.json b/modules/mesh-delivery-assigner/module.json index e7f8e724..f9d921a5 100644 --- a/modules/mesh-delivery-assigner/module.json +++ b/modules/mesh-delivery-assigner/module.json @@ -42,6 +42,7 @@ "operator-channel" ], "consumes": [ + "mesh-conditions.findings-asked", "mesh-catalog.catalog-revision", "mesh-catalog.registration-changed", "mesh-catalog.seat-definition-changed", @@ -66,6 +67,14 @@ "seat:node-engine.manifest-reads", "seat:operator-channel.asks" ], + "streams": [ + { + "name": "mesh-delivery-assigner-findings", + "events": ["finding-written.*", "finding-cleared.*", "findings-stated.*"], + "max-age-days": 7, + "max-mib": 16 + } + ], "emits": [ "assignments-copied", "put", From 99b05bbb2e42df2b577705fd5473901b724b8404 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 22:16:37 +0100 Subject: [PATCH 12/13] G findings4 buildrun: the checker's and the indexer's reads of the build modules' toolchains keep the gate over the assignments, which BL8 took out with the builder's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BL8 (8255eb28) replaced buildrun's ToolchainGate, ToolchainSet.Put and ToolchainsBucket by PlacedNodes and ToolchainSet.Set for the builder (§546, §604); no design line removes the checker's and the indexer's toolchain reads, so the three come back beside them, and FollowAssignments feeds either. Builds: design 47 G §492, §604, §546, §472, §574 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../mesh-delivery-builder/buildrun/names.go | 4 + .../buildrun/standing.go | 35 ++- .../buildrun/toolchaingate.go | 236 ++++++++++++++++ .../buildrun/toolchaingate_test.go | 260 ++++++++++++++++++ .../buildrun/toolchains.go | 36 +++ 5 files changed, 564 insertions(+), 7 deletions(-) create mode 100644 modules/mesh-delivery-builder/buildrun/toolchaingate.go create mode 100644 modules/mesh-delivery-builder/buildrun/toolchaingate_test.go diff --git a/modules/mesh-delivery-builder/buildrun/names.go b/modules/mesh-delivery-builder/buildrun/names.go index f42177cf..0896d2ea 100644 --- a/modules/mesh-delivery-builder/buildrun/names.go +++ b/modules/mesh-delivery-builder/buildrun/names.go @@ -11,3 +11,7 @@ const ( // StepsBucket is the bucket of a build module's state steps. func StepsBucket(module string) string { return module + "_steps" } + +// ToolchainsBucket is the bucket of a build module's state toolchains, which the checker and the indexer read (the builder +// hears the toolchain-reported events and keeps ToolchainsHeardBucket instead; design 47 G §492, §604). +func ToolchainsBucket(module string) string { return module + "_toolchains" } diff --git a/modules/mesh-delivery-builder/buildrun/standing.go b/modules/mesh-delivery-builder/buildrun/standing.go index 52a490b1..6d1b1963 100644 --- a/modules/mesh-delivery-builder/buildrun/standing.go +++ b/modules/mesh-delivery-builder/buildrun/standing.go @@ -103,11 +103,33 @@ func (g *PlacedNodes) Settled() { g.known = true } +// assignmentsFollowed is what FollowAssignments feeds: the builder's PlacedNodes, or the ToolchainGate of the checker and the +// indexer. settle says the history is read and answers whether what the part passes on changed; placedTold tells its OnPlaced. +type assignmentsFollowed interface { + Assignments(key string, value []byte, deleted bool, at time.Time) bool + settle() bool + placedTold() +} + +func (g *PlacedNodes) settle() bool { g.Settled(); return true } +func (g *PlacedNodes) placedTold() { + if g.OnPlaced != nil { + g.OnPlaced() + } +} + +func (g *ToolchainGate) settle() bool { return g.Settled() } +func (g *ToolchainGate) placedTold() { + if g.OnPlaced != nil { + g.OnPlaced() + } +} + // FollowAssignments reads the seat mesh-module-assigner's assignments with its history into gate, says it settled, and follows it; changed // is told whenever what the gate passes on changed. Where the state cannot be read or watched (not there, or this part // may not read it) it says so and the gate passes every report on: not fatal, since an indexer, builder or checker that // stops for it stops every delivery (issue 1945). An error of the watch after that ends on the channel. -func FollowAssignments(ctx context.Context, js process.BucketOpener, gate *PlacedNodes, changed func(context.Context), logf func(string, ...any)) (<-chan error, error) { +func FollowAssignments(ctx context.Context, js process.BucketOpener, gate assignmentsFollowed, changed func(context.Context), logf func(string, ...any)) (<-chan error, error) { errs := make(chan error, 1) kv, err := js.KeyValue(ctx, AssignmentsBucket) if err != nil { @@ -138,11 +160,10 @@ func FollowAssignments(ctx context.Context, js process.BucketOpener, gate *Place put(e) } } - gate.Settled() - changed(ctx) - if gate.OnPlaced != nil { - gate.OnPlaced() + if gate.settle() { + changed(ctx) } + gate.placedTold() go func() { defer func() { _ = w.Stop() }() for { @@ -158,8 +179,8 @@ func FollowAssignments(ctx context.Context, js process.BucketOpener, gate *Place if e != nil && put(e) { changed(ctx) } - if e != nil && gate.OnPlaced != nil { - gate.OnPlaced() + if e != nil { + gate.placedTold() } } } diff --git a/modules/mesh-delivery-builder/buildrun/toolchaingate.go b/modules/mesh-delivery-builder/buildrun/toolchaingate.go new file mode 100644 index 00000000..0f6a8ae6 --- /dev/null +++ b/modules/mesh-delivery-builder/buildrun/toolchaingate.go @@ -0,0 +1,236 @@ +package buildrun + +import ( + "fmt" + "maps" + "slices" + "sort" + "sync" + "time" + + "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshwantsclient" + meshwants "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants" + "git.novox.be/novox/mesh-delivery/sdk/buildstep" +) + +// A build module writes . of its toolchains state on every node it runs on, and nothing deletes the key +// when the module leaves a node: no part writes another module's state, and the module is gone. A report of a node the +// build module no longer runs on is therefore no builder's (issue 1940: mesh-build-node taken off ace on 2026-10-23 left +// ace.typescript at v26.12.0, and every Node fingerprint said "builders disagree"). +// +// ToolchainGate stands between the toolchains buckets and what takes their entries in (the indexer's PutToolchain, a +// ToolchainSet): an entry is passed on only while the assignments (the seat mesh-module-assigner's state `assignments`, held by +// the assigner, AssignmentsBucket, read by meshmoduleassignerclient.Placed; ADR 0567, ADR 0549 §3) places its build module on +// its node, and only once it was written since that assignments; it is passed on as deleted otherwise, said once, and +// passed on when its node writes it again. A report written before the assignments is one an earlier run of the module +// left (issue 1940, 2026-10-25 20:56: mesh-build-node put on ace again brought ace.typescript v26.12.0 of 2026-10-23 +// back into the agreement before the module ran there). Until the assignments is settled, and while no node's assignments +// is held (before the assigner's adopt writes the first one, or where the state cannot be read), every entry is passed +// on. Builders that run and report different versions still disagree: that refusal is the guard of novox/hq ADR 0465, +// and it stays. +// +// This replaces the copies of the gate the indexer, the builder and the checker each kept over the controller's +// assignments (issue 1940's subtask, issue 774: those go with the controller's lease, ADR 0549 §6). +type ToolchainGate struct { + mu sync.Mutex + modules map[string]bool // the build modules whose toolchains pass the gate + forward func(module, key string, value []byte, deleted bool) bool // answers whether it changed what it holds + logf func(format string, a ...any) + placed placementsSince + known bool // the assignments is settled + raw map[string]map[string][]byte // module -> toolchains key -> value, as the bucket holds it + left map[string]string // / -> why it was said left out, so each reason is said once + // OnPlaced is told, outside the gate's lock, once the assignments's history is read and after each revision of it + // FollowAssignments hears from then on (the counted nodes, Nodes, may have changed). Nil tells nobody. + OnPlaced func() +} + +// placementsSince is where the gate reads which modules stand on which node, and since when: the assigner's state +// (meshmoduleassignerclient.Placed, fed by Assignments) or the assignment half (meshwantsclient.Placed, fed by Revision). +type placementsSince interface { + Seeded() bool + Since(node, module string) (time.Time, bool) + ByNode() map[string][]string +} + +// NewToolchainGate is a gate over the toolchains of modules, passing what stands on to forward and saying with logf what +// it leaves out. +func NewToolchainGate(modules []string, forward func(module, key string, value []byte, deleted bool) bool, logf func(string, ...any)) *ToolchainGate { + g := &ToolchainGate{modules: map[string]bool{}, forward: forward, logf: logf, placed: meshmoduleassignerclient.NewPlaced(), + raw: map[string]map[string][]byte{}, left: map[string]string{}} + for _, m := range modules { + g.modules[m] = true + } + return g +} + +// UseModules replaces the build modules whose reports the gate passes on again when the assignments moves (a part that +// finds them from the seat list and the manifests, as the indexer does, gives them at each change), and passes every +// report it holds on again. It answers whether what it passed on changed. +func (g *ToolchainGate) UseModules(modules []string) bool { + g.mu.Lock() + defer g.mu.Unlock() + g.modules = map[string]bool{} + for _, m := range modules { + g.modules[m] = true + } + return g.all() +} + +// Toolchain takes one change of a build module's toolchains bucket in and answers whether what it passed on changed. +func (g *ToolchainGate) Toolchain(module, key string, value []byte, deleted bool) bool { + g.mu.Lock() + defer g.mu.Unlock() + if g.raw[module] == nil { + g.raw[module] = map[string][]byte{} + } + if deleted { + delete(g.raw[module], key) + } else { + g.raw[module][key] = value + } + return g.pass(module, key) +} + +// Assignments takes one revision of the seat mesh-module-assigner's assignments in (at is when the bus wrote it) and answers whether what it +// passed on changed. A value that does not read is said, and places nothing on its node. +func (g *ToolchainGate) Assignments(key string, value []byte, deleted bool, at time.Time) bool { + g.mu.Lock() + defer g.mu.Unlock() + placed, ok := g.placed.(*meshmoduleassignerclient.Placed) + if !ok { + g.logf("%s: %s is not read, the gate takes the assignment half (design 47)", AssignmentsBucket, key) + return false + } + was := placed.Seeded() + node, changed, err := placed.Put(key, value, deleted, at) + if err != nil { + g.logf("%s: %v (issue 1940)", AssignmentsBucket, err) + } + if !g.known { + return false + } + if was != g.placed.Seeded() { + // The first assignments came, or the last went: every report's standing changed. + return g.all() + } + return g.again(node, changed) +} + +// Revision takes one assignment graph revision the part's view holds in (design 47 *Each part acts on its own entries*: +// the placements come from the seat mesh-wants' assignment half, never the assigner's state; assignmentview's Held hands +// each) and answers whether what it passed on changed. The first revision taken settles the gate, as the assignments' +// history read does; a later one passes on again the reports of each node whose build modules' standing changed. A gate +// fed by Revision reads no Assignments. +func (g *ToolchainGate) Revision(r meshwants.AssignmentRevision) bool { + g.mu.Lock() + defer g.mu.Unlock() + placed, ok := g.placed.(*meshwantsclient.Placed) + if !ok { + placed = meshwantsclient.NewPlaced() + g.placed = placed + } + was := placed.Seeded() + changed := placed.Take(r) + if changed == nil { + return false + } + if !g.known || was != placed.Seeded() { + g.known = true + return g.all() + } + moved := false + for _, node := range slices.Sorted(maps.Keys(changed)) { + moved = g.again(node, changed[node]) || moved + } + return moved +} + +// Nodes are the nodes the assignments places any module on, sorted: the nodes the mesh counts (a node leaves the count +// when the assignments no longer names it; ADR 0624 §1). None until the assignments is read. +func (g *ToolchainGate) Nodes() []string { + var out []string + for node, modules := range g.placed.ByNode() { + if len(modules) > 0 { + out = append(out, node) + } + } + sort.Strings(out) + return out +} + +// Settled says the assignments's history is read: from now on only the reports of placed nodes pass, once a node's +// assignments is held. It answers whether what it passed on changed. +func (g *ToolchainGate) Settled() bool { + g.mu.Lock() + defer g.mu.Unlock() + g.known = true + return g.all() +} + +// all passes on every report again. The caller holds mu. +func (g *ToolchainGate) all() bool { + changed := false + for _, module := range slices.Sorted(maps.Keys(g.raw)) { + for _, k := range slices.Sorted(maps.Keys(g.raw[module])) { + changed = g.pass(module, k) || changed + } + } + return changed +} + +// again passes on every report of node by a build module among modules. The caller holds mu. +func (g *ToolchainGate) again(node string, modules []string) bool { + changed := false + for _, module := range modules { + if !g.modules[module] { + continue + } + for _, k := range slices.Sorted(maps.Keys(g.raw[module])) { + if n, _, err := buildstep.ParseToolchainKey(k); err == nil && n == node { + changed = g.pass(module, k) || changed + } + } + } + return changed +} + +// pass hands one key on as it stands: its value when its node is placed and wrote it since (or the assignments is not +// known), deleted otherwise. The caller holds mu. +func (g *ToolchainGate) pass(module, key string) bool { + value, held := g.raw[module][key] + if !held { + return g.forward(module, key, nil, true) + } + node, _, err := buildstep.ParseToolchainKey(key) + why := "" + if err == nil && g.known && g.placed.Seeded() { + why = g.leftOut(module, node, value) + } + if why == "" { + delete(g.left, module+"/"+key) + return g.forward(module, key, value, false) + } + if g.left[module+"/"+key] != why { + g.left[module+"/"+key] = why + g.logf("%s.%s is left out of the agreed toolchain: %s (issue 1940)", ToolchainsBucket(module), key, why) + } + return g.forward(module, key, nil, true) +} + +// leftOut is why a node's report is no builder's, or empty when it counts: the catalogue does not place the module on its +// node, or the report was written before it did. A value that is no toolchain is passed on for its reader to refuse. +// The caller holds mu. +func (g *ToolchainGate) leftOut(module, node string, value []byte) string { + since, placed := g.placed.Since(node, module) + if !placed { + return fmt.Sprintf("the catalogue does not place %s on %s, so it is no builder", module, node) + } + tc, err := buildstep.DecodeToolchain(value) + if err == nil && tc.At.Before(since) { + return fmt.Sprintf("it was written at %s, before %s was placed on %s at %s: it counts once %s writes it again", + tc.At.UTC().Format(time.RFC3339), module, node, since.UTC().Format(time.RFC3339), node) + } + return "" +} diff --git a/modules/mesh-delivery-builder/buildrun/toolchaingate_test.go b/modules/mesh-delivery-builder/buildrun/toolchaingate_test.go new file mode 100644 index 00000000..9e6521c3 --- /dev/null +++ b/modules/mesh-delivery-builder/buildrun/toolchaingate_test.go @@ -0,0 +1,260 @@ +package buildrun + +import ( + "bytes" + "context" + "errors" + "fmt" + "log" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" + meshwants "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants" + "git.novox.be/novox/mesh-delivery/sdk/buildstep" +) + +func gateReport(t *testing.T, node, version string, at time.Time) (string, []byte) { + t.Helper() + key, _ := buildstep.ToolchainKey(node, "typescript") + value, err := buildstep.EncodeToolchain(buildstep.Toolchain{Language: "typescript", Version: version, Recipe: "f59571cf4be75adf", At: at}) + if err != nil { + t.Fatal(err) + } + return key, value +} + +// placing is a value of the state `assignments` placing modules on node. + +func agreedOf(set *ToolchainSet) (string, string) { + tc, none := buildstep.AgreedToolchain(set.Entries("typescript")) + return tc.Version, none +} + +// Issue 1940, the gate over the catalogue's assignments (issue 774, ADR 0549 §3): a build node's report counts only while +// the assignments places its build module on its node, and only once written since; before the assignments is settled, and +// while no node's assignments is held, every report counts. The live cases: mesh-build-node taken off ace left +// ace.typescript at v26.12.0 against v26.12.1 on the three others; put on ace again, its report of 2026-10-23 06:29 came +// back before the module ran there. +func TestToolchainGate(t *testing.T) { + old := time.Date(2026, 10, 23, 6, 29, 18, 0, time.UTC) // ace's report from its earlier run + placed := time.Date(2026, 10, 25, 19, 56, 45, 0, time.UTC) // mesh-build-node placed on ace again + three := []string{"g14", "novox", "shanks"} + start := func(t *testing.T, seeded bool) (*ToolchainGate, *ToolchainSet, *[]string) { + set := &ToolchainSet{} + said := &[]string{} + gate := NewToolchainGate([]string{GoBuildModule, NodeBuildModule}, set.Put, func(f string, a ...any) { *said = append(*said, fmt.Sprintf(f, a...)) }) + for _, node := range three { + if seeded { + key, value := placing(t, node, NodeBuildModule) + gate.Assignments(key, value, false, old.Add(-time.Hour)) + } + key, value := gateReport(t, node, "v26.12.1", placed.Add(time.Minute)) + gate.Toolchain(NodeBuildModule, key, value, false) + } + return gate, set, said + } + + t.Run("not placed on ace: its report is no builder's", func(t *testing.T) { + gate, set, said := start(t, true) + key, value := gateReport(t, "ace", "v26.12.0", old) + gate.Toolchain(NodeBuildModule, key, value, false) + if a := buildstep.ToolchainAgreement(set.Entries("typescript")); len(a.SittingOut) != 1 || a.SittingOut[0].Node != "ace" { // ADR 0584: the majority is agreed, ace's report counts as the one that sits out + t.Fatalf("before the assignments is settled every report counts; got %+v", a) + } + k, v := placing(t, "ace", GoBuildModule) // another build module on ace: no matter + gate.Assignments(k, v, false, old) + if !gate.Settled() { + t.Fatal("settling left ace's report out, so what was passed on changed") + } + if v, none := agreedOf(set); v != "v26.12.1" || none != "" { + t.Fatalf("the placed nodes agree on v26.12.1; got %q, %q", v, none) + } + if len(*said) != 1 || !strings.Contains((*said)[0], "does not place mesh-build-node on ace") { + t.Fatalf("ace's report is said left out once; said %q", *said) + } + if gate.Toolchain(NodeBuildModule, key, value, false) { + t.Fatal("a report of a node not placed changed what was passed on") + } + // Placed on ace with a report written since: it counts, and the builders really disagree. + k, v = placing(t, "ace", GoBuildModule, NodeBuildModule) + gate.Assignments(k, v, false, old.Add(-time.Minute)) + if a := buildstep.ToolchainAgreement(set.Entries("typescript")); a.Toolchain.Version != "v26.12.1" || len(a.SittingOut) != 1 || a.SittingOut[0].Version != "v26.12.0" { + t.Fatalf("builders that run different versions: the majority is agreed and ace sits out; got %+v", a) + } + // Taken off ace again (its key deleted): its report goes again. + if !gate.Assignments(k, nil, true, placed) { + t.Fatal("taking ace's assignments away passed nothing on") + } + if v, none := agreedOf(set); v != "v26.12.1" || none != "" { + t.Fatalf("got %q, %q", v, none) + } + // A report deleted from the bucket goes whether its node is placed or not. + key, _ = gateReport(t, "g14", "v26.12.1", placed) + if !gate.Toolchain(NodeBuildModule, key, nil, true) || len(set.Entries("typescript")) != 2 { + t.Fatalf("deleting g14's report: %d entries left, want novox and shanks", len(set.Entries("typescript"))) + } + }) + + t.Run("build modules given later pass their reports on when the assignments moves", func(t *testing.T) { + set := &ToolchainSet{} + gate := NewToolchainGate(nil, set.Put, func(string, ...any) {}) + gate.Settled() + k, v := placing(t, "novox", GoBuildModule) + gate.Assignments(k, v, false, old) + key, value := gateReport(t, "ace", "v26.12.1", placed.Add(time.Minute)) + gate.Toolchain(NodeBuildModule, key, value, false) // ace is not placed: left out + k, v = placing(t, "ace", NodeBuildModule) + if gate.Assignments(k, v, false, placed) || len(set.Entries("typescript")) != 0 { + t.Fatalf("a module the gate was not given passed on at the assignments: %v", set.Entries("typescript")) + } + if !gate.UseModules([]string{NodeBuildModule}) || len(set.Entries("typescript")) != 1 { + t.Fatalf("given mesh-build-node, ace's report placed since is passed on; entries %v", set.Entries("typescript")) + } + }) + + for _, c := range []struct { + name string + at time.Time // when ace wrote its report + counts bool + }{ + {"a report left before the assignments", old, false}, + {"a report written since the assignments", placed.Add(30 * time.Second), true}, + } { + t.Run(c.name, func(t *testing.T) { + gate, set, said := start(t, true) + k, v := placing(t, "ace") // ace's key, first heard placing nothing + gate.Assignments(k, v, false, old.Add(-time.Hour)) + gate.Settled() + k, v = placing(t, "ace", NodeBuildModule) + if gate.Assignments(k, v, false, placed) { + t.Fatal("assignments with no report passed something on") + } + key, value := gateReport(t, "ace", "v26.12.0", c.at) + if got := gate.Toolchain(NodeBuildModule, key, value, false); got != c.counts { + t.Fatalf("ace's report passed on: %v, want %v", got, c.counts) + } + // ace's assignments written again with another module beside: its standing does not move, nothing is said again. + k, v = placing(t, "ace", GoBuildModule, NodeBuildModule) + if gate.Assignments(k, v, false, placed.Add(time.Minute)) { + t.Fatal("the same assignments of mesh-build-node changed what was passed on") + } + if c.counts { + return + } + if v, none := agreedOf(set); v != "v26.12.1" || none != "" { + t.Fatalf("ace's left report is no builder's; got %q, %q", v, none) + } + if len(*said) != 1 || !strings.Contains((*said)[0], "before mesh-build-node was placed on ace at 2026-10-25T19:56:45Z") { + t.Fatalf("said %q, want once why ace's report is left out", *said) + } + key, value = gateReport(t, "ace", "v26.12.1", placed.Add(time.Minute)) + if !gate.Toolchain(NodeBuildModule, key, value, false) || len(set.Entries("typescript")) != 4 { + t.Fatal("ace's new report was not passed on") + } + }) + } + + t.Run("no assignments held: every report counts", func(t *testing.T) { + gate, set, _ := start(t, false) + key, value := gateReport(t, "ace", "v26.12.0", old) + gate.Toolchain(NodeBuildModule, key, value, false) + gate.Settled() + if n := len(set.Entries("typescript")); n != 4 { + t.Fatalf("before the first assignments file every report counts; got %d", n) + } + k, v := placing(t, "g14", NodeBuildModule) + if !gate.Assignments(k, v, false, old.Add(-time.Hour)) || len(set.Entries("typescript")) != 1 { + t.Fatalf("the first assignments leaves every node it does not place out; got %d", len(set.Entries("typescript"))) + } + if !gate.Assignments(k, nil, true, placed) || len(set.Entries("typescript")) != 4 { + t.Fatal("the last assignments gone: every report counts again") + } + }) + + // Design 47 *Each part acts on its own entries*: fed by the assignment half's revisions, the gate settles at the first, + // leaves out a node no revision places the build module on, and counts a node placed in a later revision only by a + // report written since that revision's time. + t.Run("fed by the assignment half", func(t *testing.T) { + set := &ToolchainSet{} + said := &[]string{} + gate := NewToolchainGate([]string{GoBuildModule, NodeBuildModule}, set.Put, func(f string, a ...any) { *said = append(*said, fmt.Sprintf(f, a...)) }) + for _, node := range append([]string{"ace"}, three...) { + key, value := gateReport(t, node, "v26.12.1", placed.Add(time.Minute)) + if node == "ace" { + key, value = gateReport(t, node, "v26.12.0", old) + } + gate.Toolchain(NodeBuildModule, key, value, false) + } + revision := func(n int64, at time.Time, nodes ...string) meshwants.AssignmentRevision { + r := meshwants.AssignmentRevision{Revision: n, Kind: "trunk", At: &at} + for _, node := range nodes { + r.Entries = append(r.Entries, meshwants.NodeEntryRef{Node: node, Module: NodeBuildModule, Digest: "d-" + node}) + } + return r + } + if !gate.Revision(revision(7, old.Add(-time.Hour), three...)) || len(set.Entries("typescript")) != 3 { + t.Fatalf("the first revision settles the gate and leaves ace out; %d entries", len(set.Entries("typescript"))) + } + if gate.Revision(revision(7, old.Add(-time.Hour), three...)) { + t.Fatal("the same revision again changed what was passed on") + } + if gate.Revision(revision(8, placed, "ace", "g14", "novox", "shanks")); len(set.Entries("typescript")) != 3 { + t.Fatal("ace's report from before revision 8 placed it counts") + } + if len(*said) < 2 || !strings.Contains((*said)[len(*said)-1], "before mesh-build-node was placed on ace at 2026-10-25T19:56:45Z") { + t.Fatalf("said %q, want why ace's old report is left out", *said) + } + key, value := gateReport(t, "ace", "v26.12.1", placed.Add(time.Minute)) + if !gate.Toolchain(NodeBuildModule, key, value, false) || len(set.Entries("typescript")) != 4 { + t.Fatal("ace's report written since revision 8 is not passed on") + } + if !gate.Revision(revision(9, placed.Add(time.Hour), "ace", "novox", "shanks")) || len(set.Entries("typescript")) != 3 || len(gate.Nodes()) != 3 { + t.Fatalf("g14 taken off in revision 9: %d entries, nodes %v", len(set.Entries("typescript")), gate.Nodes()) + } + }) + + t.Run("the history: a module in the first revision heard of a key stands since before it", func(t *testing.T) { + p := meshmoduleassignerclient.NewPlaced() + // ace's node key is the first revision heard of it: its module stands since before. + k, v := placing(t, "ace", NodeBuildModule) + p.Put(k, v, false, old) + if _, changed, _ := p.Put("ace."+NodeBuildModule, []byte("{}"), false, placed); len(changed) != 0 { + t.Errorf("a module key says which modules stand: %v", changed) + } + k2, v2 := placing(t, "g14") + p.Put(k2, v2, false, old) + k2, v2 = placing(t, "g14", NodeBuildModule) + if _, changed, _ := p.Put(k2, v2, false, placed); strings.Join(changed, ",") != NodeBuildModule { + t.Errorf("a module placed says changed %v", changed) + } + if _, _, err := p.Put("novox.node", []byte("not json"), false, old); err == nil { + t.Error("a value that does not read was not said") + } + if s, ok := p.Since("ace", NodeBuildModule); !ok || !s.IsZero() { + t.Errorf("ace's module stands since %v, %v; want the zero time", s, ok) + } + if s, ok := p.Since("g14", NodeBuildModule); !ok || !s.Equal(placed) { + t.Errorf("g14's module stands since %v, %v; want the revision that placed it", s, ok) + } + if got := p.PlacedNodes(NodeBuildModule); strings.Join(got, ",") != "ace,g14" || len(p.ByNode()) != 2 || !p.Seeded() { + t.Errorf("PlacedNodes = %v, ByNode = %v", got, p.ByNode()) + } + }) + + t.Run("assignments the bus refuses is said, and the part goes on", func(t *testing.T) { + var logs bytes.Buffer + logger := log.New(&logs, "", 0) + refused := errors.New("nats: permissions violation for publish to $JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_assignments") + gate, _, _ := start(t, false) + for _, bus := range []refusingBus{{err: refused}, {refuseOpen: true, err: refused}} { + if _, err := FollowAssignments(context.Background(), bus, gate, func(context.Context) {}, logger.Printf); err != nil { + t.Errorf("a refused assignments stopped the start: %v", err) + } + } + if !strings.Contains(logs.String(), "cannot be watched") || !strings.Contains(logs.String(), "cannot be read") { + t.Errorf("the refusals were not said: %q", logs.String()) + } + }) +} diff --git a/modules/mesh-delivery-builder/buildrun/toolchains.go b/modules/mesh-delivery-builder/buildrun/toolchains.go index ba2368c6..af1f53e6 100644 --- a/modules/mesh-delivery-builder/buildrun/toolchains.go +++ b/modules/mesh-delivery-builder/buildrun/toolchains.go @@ -91,3 +91,39 @@ const ( ToolchainsTool = "builder_toolchains" ToolchainsDropTool = "builder_toolchains_drop" ) + +// Put takes a change of a build module's toolchains bucket in: a put of a node's entry (key .) or its +// delete. It answers whether the language's entries changed (a node's version or recipe, or a node appearing or going). +// A key or a value that is not an entry of the module's language is left out. +func (s *ToolchainSet) Put(module, key string, value []byte, deleted bool) bool { + language, ok := LanguageOf[module] + if !ok { + return false + } + node, keyLanguage, err := buildstep.ParseToolchainKey(key) + if err != nil || keyLanguage != language { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + if deleted { + if _, had := s.by[language][node]; !had { + return false + } + delete(s.by[language], node) + return true + } + tc, err := buildstep.DecodeToolchain(value) + if err != nil || tc.Language != language { + return false + } + if s.by == nil { + s.by = map[string]map[string]buildstep.Toolchain{} + } + if s.by[language] == nil { + s.by[language] = map[string]buildstep.Toolchain{} + } + cur, had := s.by[language][node] + s.by[language][node] = tc + return !had || cur.Version != tc.Version || cur.Recipe != tc.Recipe || cur.Digest != tc.Digest +} From b815b54bff048440fa417e9551ecfaacd656d03c Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 22:19:57 +0100 Subject: [PATCH 13/13] G findings4 indexer: the indexer's findings are the seat's generated finding written through views.NewHeldWriter, its assignments followed through the views reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The indexer's CAS Findings store is the held writer through the small FindingWriter interface (Write, Clear, Held): the unread-call, accepted-unsaid, seat-definition and out-of-step findings are raised and cleared through it, the unsaid ones read from Held. followAssignments follows the assigner's assignments through the module's ProcessReader (the reader raises and clears the first view's finding), the emitter and the family-not-heard finding write through the same writer, and the view-waits finding of the seat git is written by hand through Write and Clear (A-28). The old resolved words go (A-22). The integration bed has the findings stream and the test sees finding-written said on it. Builds: design 47 G A-29, A-26, A-28, A-22, A-3, A-14, A-17, A-30, A-37, A-38, §689, §687, §461 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../cmd/mesh-delivery-indexer-tools/views.go | 3 +- .../mesh-delivery-indexer/integration_test.go | 9 + .../cmd/mesh-delivery-indexer/main.go | 32 +++- .../cmd/mesh-delivery-indexer/main_test.go | 27 +-- .../packages_integration_test.go | 14 ++ .../cmd/mesh-delivery-indexer/views.go | 161 ++++++++---------- .../internal/index/accepted.go | 52 ++---- .../internal/index/candidates.go | 6 +- .../internal/index/control_records_test.go | 2 +- .../internal/index/findings.go | 49 ++++++ .../internal/index/helpers_test.go | 43 +++++ .../internal/index/pending_test.go | 6 +- .../internal/index/seatdefs.go | 48 +++--- .../internal/index/unreadcall.go | 48 +++--- .../internal/index/verdicts_test.go | 6 +- .../internal/index/views.go | 6 +- .../mesh-delivery-indexer/manifest_test.go | 3 +- 17 files changed, 291 insertions(+), 224 deletions(-) create mode 100644 modules/mesh-delivery-indexer/internal/index/findings.go diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/views.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/views.go index ecd4431e..9a36fcf2 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/views.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/views.go @@ -5,7 +5,6 @@ import ( "fmt" "time" - mcc "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/seats/meshcatalog" @@ -179,7 +178,7 @@ func outOfStepTool(state func(string) views.Source) stdio.Tool { "build of the same module), then each open pull request whose check's verdict named out-of-step files.", Input: map[string]any{"type": "object", "properties": map[string]any{}}, Run: func(map[string]any) (any, error) { - rows, err := index.OutOfStepOf(state(index.SeatDefinitionsState), state(mcc.FindingsState)) + rows, err := index.OutOfStepOf(state(index.SeatDefinitionsState), state(index.FindingsState)) if err != nil { return nil, err } diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/integration_test.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/integration_test.go index baa19b47..ef756cfb 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/integration_test.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/integration_test.go @@ -477,6 +477,9 @@ func waitViews(t *testing.T, ctx context.Context, js jetstream.JetStream, state, const views0Stream = "EVENTS" +// findingsStream is the bus name of the indexer's stream of its findings' events. +const findingsStream = "INDEXER_FINDINGS" + // acceptedHeard counts what a waiter on manifest-accepted heard. var acceptedHeard atomic.Int64 @@ -640,6 +643,12 @@ func startIndexerRun(t *testing.T, put func(context.Context, buildLog), before . if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "EVENTS", Subjects: []string{"mesh.mod.*.event.>", "mesh.seat.*.event.>"}}); err != nil { t.Fatal(err) } + // The indexer's own stream of its findings' events (design 47 G §383), which its finding writer says into (A-30). + if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: findingsStream, Subjects: []string{ + "mesh.stream." + index.Module + ".event.finding-written.*", "mesh.stream." + index.Module + ".event.finding-cleared.*", + "mesh.stream." + index.Module + ".event.findings-stated.*"}}); err != nil { + t.Fatal(err) + } put(ctx, buildLog{ctx: ctx, js: js}) for _, b := range before { b(ctx, js) 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 cb57a985..54eb0e52 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main.go @@ -31,8 +31,10 @@ import ( "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg" stdio "git.novox.be/novox/mesh-sdk/go" + mcseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" "git.novox.be/novox/mesh-sdk/go/process" "git.novox.be/novox/mesh-sdk/go/seatdef" + "git.novox.be/novox/mesh-sdk/go/views" "mesh-delivery-indexer/internal/index" ) @@ -401,6 +403,21 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro unjudged := process.AwaitBucket(ctx, js, conn, index.UnjudgedBucket, "a take-in's new manifests are kept for their judging in its memory alone", logger.Printf) tryRecovery := process.AwaitBucket(ctx, js, conn, index.TryRecoveryBucket, "how the recovery of the open tries went is not kept", logger.Printf) findings := process.AwaitBucket(ctx, js, conn, findingsBucket, "no finding of the indexer is written", logger.Printf) + // The indexer's findings are the seat mesh-conditions' generated finding, written through mesh-sdk's held finding writer + // (design 47 G §689, A-29): it holds what is written before the state is bound, and starts once the state is. + writer, err := views.NewHeldWriter(views.WriterConfig[mcseat.Finding, mcseat.FindingCleared, mcseat.FindingsStated, mcseat.FindingsAskedEvent]{ + Module: cred.Module, State: calls.State(index.FindingsState), Node: cred.Node, Asked: mcseat.SubjectFindingsAsked, JS: js, Conn: conn, Logf: logger.Printf}) + if err != nil { + return fmt.Errorf("the indexer's finding writer cannot be made: %w", err) + } + defer writer.Close() + findings.Ready(func(jetstream.KeyValue) { + // A writer that cannot start (the stream of its asks is not there yet) is said; its findings are then kept in the + // state alone and said by the next start. + if err := writer.Start(ctx); err != nil { + logger.Printf("the finding writer cannot start, its findings are kept in the state alone: %v", err) + } + }) // The seat definitions (design 26, DLV-I17): a state this delivery brings, bound when it appears; until then none is kept. seatDefs := process.AwaitBucket(ctx, js, conn, index.SeatDefinitionsBucket, "no seat definition is kept", logger.Printf) marks := process.AwaitBucket(ctx, js, conn, index.MarksBucket, "no change is said on the views family, so every reader waits on its first view", logger.Printf) @@ -413,7 +430,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro } }) readWhenBound(seatReader, seatDefs, marks) - ix := &index.Indexer{Findings: awaitedStore{b: findings}, OldSections: awaitedStore{b: oldSections}, Unjudged: awaitedStore{b: unjudged}, Requirements: awaitedStore{b: requirements}, Store: kvStore{candidates}, Modules: modules, Manifests: kvStore{manifests}, Taken: awaitedStore{b: taken}, Builds: known, + ix := &index.Indexer{Findings: writer, OldSections: awaitedStore{b: oldSections}, Unjudged: awaitedStore{b: unjudged}, Requirements: awaitedStore{b: requirements}, Store: kvStore{candidates}, Modules: modules, Manifests: kvStore{manifests}, Taken: awaitedStore{b: taken}, Builds: known, Verdicts: awaitedStore{b: verdicts}, SayRefused: sayRefused(conn, cred, logger.Printf), SayAccepted: sayAccepted(conn, cred), Predefined: awaitedStore{b: predefined}, ConfigCandidates: awaitedStore{b: configCandidates}, Logf: logger.Printf, Named: namedVia(calls.Call), Trunk: trunkVia(calls.Call), Contains: containsVia(calls.Call), Read: readVia(calls.Call), @@ -492,11 +509,13 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro // The indexer's one pull of its durable on the shared events stream (process.EventHub): every handler is registered // before it starts. hub := process.NewEventHub(logger.Printf) - followAssignments(ctx, hub, cred, calls.Call, gate, func(ctx context.Context) { + reader := &views.ProcessReader{Module: index.Module, Call: calls.Call, Logf: logger.Printf, Hub: hub, + Findings: func() views.WireWriter { return writer }} + followAssignments(ctx, reader, process.AwaitBucket(ctx, js, conn, firstViewsBucket, "indexer_first_views reads nothing and indexer_first_view_again is not heard", logger.Printf), cred, js, gate, func(ctx context.Context) { if err := ix.Recompute(ctx); err != nil { logger.Printf("writing the modules again after the assignments of a build module changed: %v", err) } - }, findings, process.AwaitBucket(ctx, js, conn, firstViewsBucket, "indexer_first_views reads nothing and indexer_first_view_again is not heard", logger.Printf), logger.Printf) + }, logger.Printf) tools := newToolchainWatch(ctx, js, toolchainSink{ put: gate.Toolchain, apply: func(ctx context.Context, module, key string, value []byte, deleted bool) error { @@ -565,7 +584,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro // Through the seat git (milestone 47/G W4.15, design 47 *A part that cannot read the forge keeps what it has*): a seat that // does not answer is waited for at most index.GitWaitBound before the judging below runs on the kept definitions, said in // indexer_first_views and as view-waits past five minutes, and read again on the next forge message heard. - defs := gitWaits(ctx, ix, findings, process.AwaitBucket(ctx, js, conn, firstViewsBucket, "indexer_first_views does not say the wait for the seat git", logger.Printf), logger.Printf) + defs := gitWaits(ctx, ix, writer, cred.Node, process.AwaitBucket(ctx, js, conn, firstViewsBucket, "indexer_first_views does not say the wait for the seat git", logger.Printf), logger.Printf) seatDefs.Ready(func(jetstream.KeyValue) { defs.Start(ctx) }) // Every manifest judged at the start, so a judge that changed with this program judges every one again (ADR 0578 // §1), once the verdicts state is there; each merge judges them again (index.ApplyMessage). @@ -601,7 +620,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro // with the build module, as with the gate's source. // Each consumer below that is not there yet, or cannot be heard, is the finding family-not-heard naming its stream until // it binds at a later start (heard.Writer; ADR 0615 §5), so the indexer is not healthy while what it waits on is lost. - notHeard := notHeardWriter(ctx, findings, logger.Printf) + notHeard := notHeardWriter(writer, logger.Printf) // A consumer that is there and cannot be heard stops the indexer, as before; it is said as the finding first. var stopped error hear := func(stream, consequence string, each func(jetstream.Msg)) { @@ -706,8 +725,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro heard := awaitedStore{b: process.AwaitBucket(ctx, js, conn, index.HeardBucket, "no FORGE message the indexer handles is written for indexer_heard", logger.Printf)} // Each change of the states its readers read is said on its views family (ADR 0615 §5), from the mark each state was // said up to; a first view (the tools bundle) answers at that mark. - startViews(ctx, js, cred, marks, - findings, + startViews(ctx, js, cred, marks, writer, map[string]readyOf{"manifests": boundNow(manifests), "candidates": boundNow(candidates), index.VerdictsState: awaited(verdicts), index.PredefinedState: awaited(predefined), index.ConfigCandidatesState: awaited(configCandidates), index.PackagesState: awaited(packages), diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main_test.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main_test.go index 7496f17a..dd54b4e6 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main_test.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/main_test.go @@ -17,6 +17,7 @@ import ( "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg" "git.novox.be/novox/mesh-sdk/go/process" + "git.novox.be/novox/mesh-sdk/go/views" "mesh-delivery-indexer/internal/index" ) @@ -80,7 +81,7 @@ var testAccount = process.Account{Node: "n1", Module: "mesh-delivery-indexer"} func TestAMessageIsIndexedAndAcked(t *testing.T) { // 1238/AC2 (unit part) // Review round 2: the indexer's views emitter writes its finding view-unsaid (Emitter.Findings set). - if em := viewsEmitter(context.Background(), nil, process.Account{}, nil, &process.AwaitedBucket{}, nil, t.Logf); em.Findings == nil { + if em := viewsEmitter(nil, process.Account{}, nil, noWire{}, t.Logf); em.Findings == nil { t.Error("the indexer's views emitter writes no view-unsaid finding") } // Milestone 47/G: the object buckets are the module's own by the name its account runs as, never a fixed name. @@ -245,25 +246,5 @@ func TestTheSeatsSourceAnswerIsReadAndItsErrorIsAnError(t *testing.T) { // desig func ptr[T any](v T) *T { return &v } -// findingsKV is a findings bucket of the two calls the indexer's call finding makes. -type findingsKV struct { - jetstream.KeyValue - vals map[string][]byte -} - -func (f *findingsKV) Put(_ context.Context, key string, value []byte) (uint64, error) { - f.vals[key] = value - return uint64(len(f.vals)), nil -} - -func (f *findingsKV) Get(_ context.Context, key string) (jetstream.KeyValueEntry, error) { - if _, ok := f.vals[key]; !ok { - return nil, jetstream.ErrKeyNotFound - } - return nil, nil -} - -func (f *findingsKV) Delete(_ context.Context, key string, _ ...jetstream.KVDeleteOpt) error { - delete(f.vals, key) - return nil -} +// noWire is a finding writer that is none: a non-nil WireWriter for the test of which emitter is made. +type noWire struct{ views.WireWriter } diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/packages_integration_test.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/packages_integration_test.go index 5372a403..9061d7c6 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/packages_integration_test.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/packages_integration_test.go @@ -332,6 +332,20 @@ func TestAMeshSDKMergeMovesOnlyTheModulesImportingAChangedPackage(t *testing.T) if rows := outOfStep(2); rows[0].Module != "mesh-contracts" || !strings.Contains(rows[0].Summary, "types.go") || rows[1].Pull != "novox/mesh-contracts#82" { t.Errorf("indexer_out_of_step = %+v, want the trunk's finding and the pull request's files", rows) } + // The finding was said by the indexer's finding writer on its own stream (design 47 G §689, A-30): finding-written. + findingWritten := func() bool { + st, err := js.Stream(ctx, findingsStream) + if err != nil { + return false + } + info, err := st.Info(ctx) + return err == nil && info.State.Msgs > 0 + } + for deadline := time.Now().Add(20 * time.Second); !findingWritten() && time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) { + } + if !findingWritten() { + t.Error("the out-of-step finding was written into the findings state but its finding-written was not said on the indexer's findings stream") + } logEnd(builds.BuildDone) if rows := outOfStep(1); rows[0].Source != index.SourcePull { t.Errorf("after a clean build end indexer_out_of_step = %+v, want the pull request's files alone", rows) diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/views.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/views.go index a1afd067..3bba0087 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/views.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/views.go @@ -1,20 +1,23 @@ package main import ( + "cmp" "context" "encoding/json" + "fmt" "git.novox.be/novox/mesh-delivery/contracts/go/seats/git" + "strings" "sync/atomic" "time" "github.com/nats-io/nats.go/jetstream" - mcc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" + "git.novox.be/novox/mesh-sdk/go/keys" "git.novox.be/novox/mesh-sdk/go/process" - sdkfindings "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient/findings" "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient/heard" + mcseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" "git.novox.be/novox/mesh-delivery/sdk/graph" "git.novox.be/novox/mesh-sdk/go/views" @@ -23,7 +26,7 @@ import ( // findingsBucket is the indexer's own findings, which the condition keeper raises and clears (ADR 0564 §2): a value too // large for its views event, and its first view of the assigner's assignments unanswered past its bound. -var findingsBucket = mcc.FindingsBucket(index.Module) +var findingsBucket = index.FindingsBucket // readyOf binds a state's bucket: now for one bound at the start, once it appears for one awaited. type readyOf func(func(jetstream.KeyValue)) @@ -33,19 +36,11 @@ func boundNow(kv jetstream.KeyValue) readyOf { return func(f func(jetstream.KeyV func awaited(b *process.AwaitedBucket) readyOf { return b.Ready } // startViews says each change of the indexer's viewed states on its views family (ADR 0615 §5), once its marks state -// is bound: each state from where its mark says, as soon as its own bucket is bound. A watch that ends is sent on errs. -func startViews(ctx context.Context, js jetstream.JetStream, cred process.Account, marks, findings *process.AwaitedBucket, states map[string]readyOf, errs chan<- error, logf func(string, ...any)) { - tooLarge := func(state, key string, size int) { - kv := findings.KV() - if kv == nil { - return - } - if err := sdkfindings.Writer(ctx, findings.KV).Put(views.TooLargeFinding(index.Module, state, key, size, time.Now())); err != nil { - logf("views: the finding of %s.%s too large is not written: %v", state, key, err) - } - } +// is bound: each state from where its mark says, as soon as its own bucket is bound. A watch that ends is sent on errs. Its +// findings (a value too large, a change not said) go through the indexer's finding writer (design 47 G §687). +func startViews(ctx context.Context, js jetstream.JetStream, cred process.Account, marks *process.AwaitedBucket, writer views.WireWriter, states map[string]readyOf, errs chan<- error, logf func(string, ...any)) { marks.Ready(func(mk jetstream.KeyValue) { - em := viewsEmitter(ctx, js, cred, mk, findings, tooLarge, logf) + em := viewsEmitter(js, cred, mk, writer, logf) for _, state := range sortedStates(states) { states[state](func(kv jetstream.KeyValue) { if err := em.Start(ctx, state, kv, errs); err != nil { @@ -57,11 +52,10 @@ func startViews(ctx context.Context, js jetstream.JetStream, cred process.Accoun } // viewsEmitter is the indexer's emitter of its views family: a change not said past views.Bound is its finding -// view-unsaid, in its own findings once bound (review round 2). -func viewsEmitter(ctx context.Context, js jetstream.JetStream, cred process.Account, mk jetstream.KeyValue, findings *process.AwaitedBucket, - tooLarge func(string, string, int), logf func(string, ...any)) views.Emitter { +// view-unsaid, written through its finding writer (review round 2). +func viewsEmitter(js jetstream.JetStream, cred process.Account, mk jetstream.KeyValue, writer views.WireWriter, logf func(string, ...any)) views.Emitter { return views.Emitter{Owner: index.Module, Subject: cred.EventSubject, Publish: views.JetStreamPublish(js), - Marks: mk, TooLarge: tooLarge, Findings: sdkfindings.Writer(ctx, findings.KV), Logf: logf} + Marks: mk, Findings: writer, Logf: logf} } func sortedStates(states map[string]readyOf) []string { @@ -79,30 +73,36 @@ func sortedStates(states map[string]readyOf) []string { return out } -// notHeardWriter writes the indexer's finding of a family it does not hear into its findings state, once that is bound. -func notHeardWriter(ctx context.Context, findings *process.AwaitedBucket, logf func(string, ...any)) heard.Writer { +// notHeardWriter writes the indexer's finding of a family it does not hear through its finding writer (design 47 G §689). +func notHeardWriter(w index.FindingWriter, logf func(string, ...any)) heard.Writer { return heard.Writer{Observer: index.Module, Logf: logf, - Put: func(key string, value []byte) error { - findings.Ready(func(kv jetstream.KeyValue) { - if _, err := kv.Put(ctx, key, value); err != nil { - logf("the finding %s is not written: %v", key, err) - } - }) - return nil + Put: func(_ string, value []byte) error { + var f mcseat.Finding + if err := json.Unmarshal(value, &f); err != nil { + return err + } + return w.Write(context.Background(), f) }, Delete: func(key string) error { - findings.Ready(func(kv jetstream.KeyValue) { - if _, err := kv.Get(ctx, key); err != nil { - return // none stands - } - if err := kv.Delete(ctx, key); err != nil { - logf("the finding %s is not deleted: %v", key, err) - } - }) - return nil + scope, rest, _ := strings.Cut(key, ".") + i := strings.LastIndex(rest, ".") + if i < 0 { + return fmt.Errorf("%q is no finding's key", key) + } + return clearHeld(context.Background(), w, scope, rest[:i], rest[i+1:], "the indexer hears it now") }} } +// clearHeld clears the finding of scope, id and kind when the writer holds it: none standing says nothing. +func clearHeld(ctx context.Context, w index.FindingWriter, scope, id, kind, why string) error { + for _, f := range w.Held() { + if f.Scope == scope && f.ID == id && f.Kind == kind { + return w.Clear(ctx, scope, id, kind, why) + } + } + return nil +} + // graphState is the module graph's state, named apart from run's bucket of the same name. const graphState = graph.State @@ -114,46 +114,38 @@ var firstViewsBucket = index.Module + "_" + views.StatusState const assignmentsTool = index.AssignerModule + "." + index.AssignmentsState // followAssignments reads the seat mesh-module-assigner's assignments through the assigner's first view and then its views -// family, never its bucket (ADR 0615 §1, DLV-F1r), into gate: each key as it changes, settled once answered, with -// changed told whenever what the gate passes on changed. It waits for the first answer at most views.Bound, so a -// normal start, the two owners coming up together, computes the fingerprints once; past it the indexer goes on, the gate -// passing every report, and raises its condition naming the assigner and assignments until the answer clears it. -func followAssignments(ctx context.Context, hub *process.EventHub, cred process.Account, call func(string, any) (json.RawMessage, error), - gate *buildrun.ToolchainGate, changed func(context.Context), findings, status *process.AwaitedBucket, logf func(string, ...any)) *views.Follower { - finding := func(s views.Status, raised bool) { - w := sdkfindings.Writer(ctx, findings.KV) - f := views.UnansweredFinding(index.Module, s, time.Now()) - var err error - if raised { - err = w.Put(f) - } else { - err = w.Delete(f) +// family, never its bucket (ADR 0615 §1, DLV-F1r), into gate: the module's views reader follows it (design 47 G §532, A-26), +// which asks the first view, hears the views family and raises and clears the finding of a first view unanswered through the +// indexer's finding writer. Each key is taken as it changes, settled once answered, with changed told whenever what the gate +// passes on changed. It waits for the first answer at most views.Bound, so a normal start, the two owners coming up together, +// computes the fingerprints once; past it the indexer goes on, the gate passing every report, and raises its finding naming the +// assigner and assignments until the answer clears it. +func followAssignments(ctx context.Context, reader *views.ProcessReader, status *process.AwaitedBucket, cred process.Account, js jetstream.JetStream, + gate *buildrun.ToolchainGate, changed func(context.Context), logf func(string, ...any)) { + var settled atomic.Bool // the first view answered: the gate's nodes are the counted ones from now on + pic, err := reader.Follow(index.AssignerModule, index.AssignmentsState, assignmentsTool) + if err != nil { + logf("the assignments of the assigner is not followed: %v", err) + return + } + f := pic.Follower() + keep := f.Changed // the picture's own, kept + f.Changed = func(key string, value json.RawMessage, deleted bool, at time.Time) { + if keep != nil { + keep(key, value, deleted, at) } - if err != nil { - logf("views: the finding of the first view %s is not written: %v", s.Tool, err) + if gate.Assignments(key, value, deleted, at) { + changed(ctx) + } + if settled.Load() && gate.OnPlaced != nil { + gate.OnPlaced() // the counted nodes may have changed (ADR 0624 §1) } } - var settled atomic.Bool // the first view answered: the gate's nodes are the counted ones from now on - f := &views.Follower{Owner: index.AssignerModule, Tool: assignmentsTool, State: index.AssignmentsState, - Ask: views.AskVia(call, assignmentsTool), Logf: logf, - Raise: func(s views.Status) { finding(s, true) }, Clear: func(s views.Status) { finding(s, false) }, - Changed: func(key string, value json.RawMessage, deleted bool, at time.Time) { - if gate.Assignments(key, value, deleted, at) { - changed(ctx) - } - if settled.Load() && gate.OnPlaced != nil { - gate.OnPlaced() // the counted nodes may have changed (ADR 0624 §1) - } - }} - status.Ready(func(kv jetstream.KeyValue) { - f.Said = views.KeepStatus(ctx, kv, logf) - if err := views.HearAgain(ctx, kv, []*views.Follower{f}, logf); err != nil { - logf("views: indexer_first_view_again is not heard (%v) until the indexer starts again", err) - } - }) answered := make(chan struct{}) go func() { - if err := f.Start(ctx); err != nil { + select { + case <-f.Ready(): + case <-ctx.Done(): return } if gate.Settled() { @@ -165,13 +157,7 @@ func followAssignments(ctx context.Context, hub *process.EventHub, cred process. } close(answered) }() - // The assigner's views family, on the module's one hub (the host starts it once every handler is registered); a family the - // hub cannot register is the finding family-not-heard until the indexer starts again (ADR 0615 §5). - // The hub says when the bus made its consumer: a picture asked before then is asked again (views.Follower.ConsumerMade). - hub.OnBound(func(created time.Time) { f.ConsumerMade(ctx, created) }) - _ = notHeardWriter(ctx, findings, logf).Bind(process.EventsStream, "the assignments is the first view's alone, asked again by hand, until the indexer starts again", func() error { - return hub.Handle("mesh.mod."+index.AssignerModule+".event."+views.EventPrefix+"*", views.Hear(ctx, map[string]*views.Follower{index.AssignmentsState: f}, logf)) - }) + reader.Start(ctx, js, cred, status) wait, cancel := context.WithTimeout(ctx, views.Bound) // a context's bound, no timer (ADR 0437 §8) defer cancel() select { @@ -179,21 +165,24 @@ func followAssignments(ctx context.Context, hub *process.EventHub, cred process. case <-wait.Done(): logf("the assigner has not answered assignments within %s: the indexer goes on, every node's toolchain report counting until it answers", views.Bound) } - return f } // gitWaits is the indexer's read of the trunk definitions at its start through the seat git (index.DefinitionsAtStart): its // wait said as a first view of the seat's (Owner git, State seat-definitions) in the indexer's first views, and its finding -// view-waits written past views.WaitsBound and cleared once the seat answers. -func gitWaits(ctx context.Context, ix *index.Indexer, findings, status *process.AwaitedBucket, logf func(string, ...any)) *index.DefinitionsAtStart { +// view-waits written past views.WaitsBound and cleared once the seat answers, by hand through the finding writer (design 47 G A-28). +func gitWaits(ctx context.Context, ix *index.Indexer, w index.FindingWriter, node string, status *process.AwaitedBucket, logf func(string, ...any)) *index.DefinitionsAtStart { finding := func(s views.Status, raised bool) { - w := sdkfindings.Writer(ctx, findings.KV) - f := views.ViewWaitsFinding(index.Module, s, time.Now()) + id := keys.EscapeName(index.Module + "." + s.Owner + "." + s.State) var err error if raised { - err = w.Put(f) + at := time.Now().UTC() + summary := fmt.Sprintf("%s waits for the view %s.%s at revision %s since %s and acts on nothing new until it holds it; it holds %s", + index.Module, s.Owner, s.State, s.WaitsFor, s.WaitingSince.UTC().Format(time.RFC3339), cmp.Or(s.View, "none")) + err = w.Write(ctx, mcseat.Finding{Scope: views.FindingScope, ID: id, Kind: views.ViewWaitsKind, Severity: mcseat.FindingSeverityWarning, + Summary: summary, Headline: index.Module + " waits for a view of " + s.Owner, Source: "views", Observer: index.Module, At: at, + Subject: mcseat.Subject{Scope: views.FindingScope, ID: index.Module, Machine: node}}) } else { - err = w.Delete(f) + err = clearHeld(ctx, w, views.FindingScope, id, views.ViewWaitsKind, "the part no longer observes it") } if err != nil { logf("views: the finding view-waits of the seat git is not written: %v", err) diff --git a/modules/mesh-delivery-indexer/internal/index/accepted.go b/modules/mesh-delivery-indexer/internal/index/accepted.go index 586ab4f6..0180466b 100644 --- a/modules/mesh-delivery-indexer/internal/index/accepted.go +++ b/modules/mesh-delivery-indexer/internal/index/accepted.go @@ -60,31 +60,24 @@ func (ix *Indexer) sayAccepted(ctx context.Context, a meshcatalog.ManifestAccept return err } -// unsaidKey is the key of module's unsaid finding. -func unsaidKey(module string) string { - return mcc.Key(mcc.ScopeModule, Module+".accepted."+module, UnsaidKind) -} +// unsaidID is the id of module's unsaid finding. +func unsaidID(module string) string { return Module + ".accepted." + module } -// unsaid writes module's finding for err, or deletes it with none. +// unsaid raises module's finding for err, or clears it with none. func (ix *Indexer) unsaid(ctx context.Context, module string, err error) { if ix.Findings == nil { return } - key := unsaidKey(module) - werr := write(ctx, ix.Findings, key, func(value []byte, found bool) ([]byte, bool, error) { - if err == nil { - return nil, found, nil - } - at := ix.now().UTC() - summary := fmt.Sprintf("%s of %s was not published: %v", meshcatalog.EventManifestAccepted, module, err) - data, ferr := mcc.EncodeFinding(mcc.Observation{Scope: mcc.ScopeModule, ID: Module + ".accepted." + module, Kind: UnsaidKind, - Summary: summary, - Headline: "The indexer could not tell the bus module that " + module + "'s manifest was accepted", - Explanation: "The bus module creates and removes a module's bus state from this event. The indexer says it again at its " + - "next judging pass and clears this once it is said; indexer_say_accepted says it now.", - Resolved: "manifest-accepted of " + module + " was said.", Source: UnsaidKind}.Finding(Module, "", at)) - return data, false, ferr - }) + var werr error + if err == nil { + werr = ix.clearFinding(ctx, mcc.ScopeModule, unsaidID(module), UnsaidKind, "manifest-accepted of "+module+" was said.") + } else { + werr = ix.Findings.Write(ctx, ix.observed(mcc.ScopeModule, unsaidID(module), UnsaidKind, + fmt.Sprintf("%s of %s was not published: %v", meshcatalog.EventManifestAccepted, module, err), + "The indexer could not tell the bus module that "+module+"'s manifest was accepted", + "The bus module creates and removes a module's bus state from this event. The indexer says it again at its "+ + "next judging pass and clears this once it is said; indexer_say_accepted says it now.", UnsaidKind)) + } if werr != nil { ix.logf("judge: the finding of %s for %s is not written: %v", meshcatalog.EventManifestAccepted, module, werr) } @@ -149,25 +142,14 @@ func (ix *Indexer) sayFor(ctx context.Context, module string, allowLeft, again b return fmt.Sprintf("said for %s, manifest %s at revision %d, %d bus-state entries", module, a.Digest, a.Revision, len(a.BusState)), nil } -// SayUnsaid says again each manifest-accepted whose finding stands (the judging pass's end): cleared once said. +// SayUnsaid says again each manifest-accepted whose finding stands (the judging pass's end): cleared once said. What stands is +// what the finding writer holds (design 47 G A-38). func (ix *Indexer) SayUnsaid(ctx context.Context) { if ix.Findings == nil { return } - keys, err := ix.Findings.Keys(ctx) - if err != nil { - return - } - for _, key := range keys { - if !strings.HasSuffix(key, "."+UnsaidKind) { - continue - } - value, _, found, err := ix.Findings.Get(ctx, key) - if err != nil || !found { - continue - } - f, err := mcc.DecodeFinding(value) - if err != nil || f.Kind != UnsaidKind { + for _, f := range ix.Findings.Held() { + if f.Kind != UnsaidKind { continue } module := strings.TrimPrefix(f.Subject.ID, Module+".accepted.") diff --git a/modules/mesh-delivery-indexer/internal/index/candidates.go b/modules/mesh-delivery-indexer/internal/index/candidates.go index cfb56164..d86201b2 100644 --- a/modules/mesh-delivery-indexer/internal/index/candidates.go +++ b/modules/mesh-delivery-indexer/internal/index/candidates.go @@ -84,9 +84,9 @@ type Indexer struct { // DLV-I9): for every accepted verdict newly written, carrying the entries whole, and an empty one for a module leaving // the trunk; nil says nothing. SayAccepted func(ctx context.Context, a meshcatalog.ManifestAcceptedEvent, id string) error - // Findings is the indexer's findings state, where a manifest-accepted whose publish failed twice is kept until said - // (accepted.go, DLV-I9b); nil writes none. - Findings Store + // Findings is the indexer's finding writer (design 47 G A-29), where a manifest-accepted whose publish failed twice is kept + // until said (accepted.go, DLV-I9b); nil writes none. + Findings FindingWriter // Packages is the packages state (PackagesBucket, packages.go; novox/hq ADR 0593 §2); nil publishes none, // and every fingerprint takes the version line. Packages Store diff --git a/modules/mesh-delivery-indexer/internal/index/control_records_test.go b/modules/mesh-delivery-indexer/internal/index/control_records_test.go index 337fb881..5cb909fa 100644 --- a/modules/mesh-delivery-indexer/internal/index/control_records_test.go +++ b/modules/mesh-delivery-indexer/internal/index/control_records_test.go @@ -165,7 +165,7 @@ func TestTheControlRecords(t *testing.T) { m3: {"seats/git/seat.json": strings.Replace(git, "ADR 0111", "ADR 0112", 1)}, head: {"seats/git/seat.json": strings.Replace(git, "ADR 0111", "ADR 0200", 1)}, } - defs, findings := newMem(), newMem() + defs, findings := newMem(), newFindings() var said []SeatDefinitionChanged ix := &Indexer{SeatDefinitions: defs, Findings: findings, Logf: t.Logf, // The forge's listing of a contracts module's `seats/` directory: every blob under it at the commit (ADR 0635 §6). diff --git a/modules/mesh-delivery-indexer/internal/index/findings.go b/modules/mesh-delivery-indexer/internal/index/findings.go new file mode 100644 index 00000000..8e83eae3 --- /dev/null +++ b/modules/mesh-delivery-indexer/internal/index/findings.go @@ -0,0 +1,49 @@ +package index + +import ( + "context" + + mcc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + mcseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" +) + +// FindingsState is the indexer's state of its findings, and FindingsBucket its bucket (design 47 G §383, §687). +const ( + FindingsState = "findings" + FindingsBucket = Module + "_" + FindingsState +) + +// FindingWriter is the indexer's finding writer, mesh-sdk's held writer of the seat mesh-conditions' generated finding (design +// 47 G A-29, A-38): Write raises a finding, Clear clears one, Held answers the findings it holds, so the indexer reads what +// stands from the writer. +type FindingWriter interface { + Write(ctx context.Context, f mcseat.Finding) error + Clear(ctx context.Context, scope, id, kind, why string) error + Held() []mcseat.Finding +} + +// keyID is an id as the parts of a key: each dot-separated part made safe for a bus key as a condition's key is (design 47 G +// A-22), so a finding's key keeps the shape it had. +func keyID(id string) string { + key := mcc.Key("x", id, "x") + return key[len("x.") : len(key)-len(".x")] +} + +// observed is the indexer's finding of kind for the subject id as the seat's generated finding: a warning, its id made safe as a +// key's part, the writer completing its `since` (design 47 G §686, A-22). +func (ix *Indexer) observed(scope, id, kind, summary, headline, explanation, source string) mcseat.Finding { + id = keyID(id) + return mcseat.Finding{Scope: scope, ID: id, Kind: kind, Severity: mcseat.FindingSeverityWarning, Summary: summary, Headline: headline, + Explanation: explanation, Source: source, Observer: Module, At: ix.now().UTC(), Subject: mcseat.Subject{Scope: scope, ID: id}} +} + +// clearFinding clears the finding of scope, id and kind when the writer holds it: a clear of none held says nothing. +func (ix *Indexer) clearFinding(ctx context.Context, scope, id, kind, why string) error { + id = keyID(id) + for _, f := range ix.Findings.Held() { + if f.Scope == scope && f.ID == id && f.Kind == kind { + return ix.Findings.Clear(ctx, scope, id, kind, why) + } + } + return nil +} diff --git a/modules/mesh-delivery-indexer/internal/index/helpers_test.go b/modules/mesh-delivery-indexer/internal/index/helpers_test.go index 4c458ce4..d49cff8f 100644 --- a/modules/mesh-delivery-indexer/internal/index/helpers_test.go +++ b/modules/mesh-delivery-indexer/internal/index/helpers_test.go @@ -6,6 +6,10 @@ import ( "maps" "slices" "sync" + + mcc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + mcseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + "git.novox.be/novox/mesh-sdk/go/views" ) // rawList is the list a kept predefined value holds, as it was written. @@ -82,3 +86,42 @@ var errObjectsFullForTest = &objectsFullError{} type objectsFullError struct{} func (*objectsFullError) Error() string { return "maximum bytes exceeded" } + +// memFindings is the finding writer in memory (FindingWriter): what is written is kept by its key as the finding's JSON, what +// is cleared is gone, and Held answers what is kept. +type memFindings struct { + vals map[string][]byte + err error // every write's error +} + +func newFindings() *memFindings { return &memFindings{vals: map[string][]byte{}} } + +func (m *memFindings) Write(_ context.Context, f mcseat.Finding) error { + if m.err != nil { + return m.err + } + raw, err := json.Marshal(f) + m.vals[views.FindingKey(f.Scope, f.ID, f.Kind)] = raw + return err +} + +func (m *memFindings) Clear(_ context.Context, scope, id, kind, _ string) error { + delete(m.vals, views.FindingKey(scope, id, kind)) + return nil +} + +func (m *memFindings) Held() []mcseat.Finding { + var out []mcseat.Finding + for _, k := range slices.Sorted(maps.Keys(m.vals)) { + var f mcseat.Finding + if json.Unmarshal(m.vals[k], &f) == nil { + out = append(out, f) + } + } + return out +} + +// unsaidKey is the key of module's unsaid finding. +func unsaidKey(module string) string { + return views.FindingKey(mcc.ScopeModule, keyID(unsaidID(module)), UnsaidKind) +} diff --git a/modules/mesh-delivery-indexer/internal/index/pending_test.go b/modules/mesh-delivery-indexer/internal/index/pending_test.go index b5d7747c..fc7481fe 100644 --- a/modules/mesh-delivery-indexer/internal/index/pending_test.go +++ b/modules/mesh-delivery-indexer/internal/index/pending_test.go @@ -638,7 +638,7 @@ func TestTakeIn(t *testing.T) { // 1585/T3-T13 (ADR 0633) } { b = held() clock := time.Date(2026, 11, 10, 1, 0, 0, 0, time.UTC) - b.ix.Now, b.ix.Findings = func() time.Time { return clock }, newMem() + b.ix.Now, b.ix.Findings = func() time.Time { return clock }, newFindings() answers := b.ix.Message b.ix.held = nil b.ix.Message = func(context.Context, string, string, int) ([]byte, error) { return nil, c.fault } @@ -653,10 +653,8 @@ func TestTakeIn(t *testing.T) { // 1585/T3-T13 (ADR 0633) // machine away, only a toolchain report heard), none either; the next message heard on the forge, the read still // failing, writes call-unanswered naming the bridge's call; the merge read and taken in clears it. finding := func() (string, bool) { - keys, _ := b.ix.Findings.Keys(ctx) - for _, k := range keys { + for k, v := range b.ix.Findings.(*memFindings).vals { if strings.HasSuffix(k, "."+mcc.KindCallUnanswered) { - v, _, _, _ := b.ix.Findings.Get(ctx, k) return string(v), true } } diff --git a/modules/mesh-delivery-indexer/internal/index/seatdefs.go b/modules/mesh-delivery-indexer/internal/index/seatdefs.go index 488bb37f..8d3906c9 100644 --- a/modules/mesh-delivery-indexer/internal/index/seatdefs.go +++ b/modules/mesh-delivery-indexer/internal/index/seatdefs.go @@ -28,6 +28,7 @@ import ( "time" mcc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + mcseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg" @@ -462,8 +463,7 @@ func (ix *Indexer) twiceFinding(ctx context.Context, repository, module string, fmt.Sprintf("%s's trunk defines seats another trunk already defines, refused: %s", module, strings.Join(lines, "; ")), "A seat on "+module+"'s trunk is already defined by another repository", "A seat has one definition: the one that stood first stays the seat's contract and "+module+"'s is kept nowhere. "+ - "Remove the seat from one of the two repositories; the next merge of "+module+" clears this.", - "No seat on "+module+"'s trunk is defined by another repository.") + "Remove the seat from one of the two repositories; the next merge of "+module+" clears this.") } // outsideFinding writes the contracts module's seat-outside-its-domain finding for the seats it defines that belong to @@ -478,8 +478,7 @@ func (ix *Indexer) outsideFinding(ctx context.Context, module string, outside ma fmt.Sprintf("%s's trunk defines seats of another domain, refused: %s", module, strings.Join(lines, "; ")), "A seat on "+module+"'s trunk belongs to another domain's contracts module", "A seat of the mesh's own contracts is taken only from its domain's contracts module (mesh-contracts for a domain with none). "+ - "Move the definition there; the next merge of "+module+" without it clears this.", - "Every seat on "+module+"'s trunk is of its domain.") + "Move the definition there; the next merge of "+module+" without it clears this.") } // dropPull removes a pull request's entries and its out-of-step files. @@ -528,32 +527,24 @@ func (ix *Indexer) unparsedFinding(ctx context.Context, module string, unparsed fmt.Sprintf("%s's trunk holds seat definitions that do not parse: %s", module, strings.Join(lines, "; ")), "A seat definition on "+module+"'s trunk does not parse", "The trunk's last definition that parsed stays the seat's contract. A change fixing the definition clears this when it "+ - "merges; indexer_seat_definitions_again reads the trunk again now.", - "Every seat definition on "+module+"'s trunk parses.") + "merges; indexer_seat_definitions_again reads the trunk again now.") } -// finding writes (raise) or deletes the indexer's finding of a kind for a subject id. -func (ix *Indexer) finding(ctx context.Context, id, kind string, raise bool, summary, headline, explanation, resolved string) { +// finding raises (raise) or clears the indexer's finding of a kind for a subject id through its finding writer (design 47 G +// §689, A-29): a finding of the seat mesh-conditions' generated type, its id made safe as a key's part (A-22); the writer says +// again only what changed. +func (ix *Indexer) finding(ctx context.Context, id, kind string, raise bool, summary, headline, explanation string) { if ix.Findings == nil { return } - key := mcc.Key(mcc.ScopeModule, id, kind) - err := write(ctx, ix.Findings, key, func(value []byte, found bool) ([]byte, bool, error) { - if !raise { - return nil, found, nil - } - if found { - if f, err := mcc.DecodeFinding(value); err == nil && f.Summary == summary { - return nil, false, nil - } - } - at := ix.now().UTC() - data, err := mcc.EncodeFinding(mcc.Observation{Scope: mcc.ScopeModule, ID: id, Kind: kind, Summary: summary, - Headline: headline, Explanation: explanation, Resolved: resolved, Source: kind}.Finding(Module, "", at)) - return data, false, err - }) + var err error + if raise { + err = ix.Findings.Write(ctx, ix.observed(mcc.ScopeModule, id, kind, summary, headline, explanation, kind)) + } else { + err = ix.clearFinding(ctx, mcc.ScopeModule, id, kind, "the indexer no longer observes it") + } if err != nil { - ix.logf("seat definitions: the finding %s is not written: %v", key, err) + ix.logf("seat definitions: the finding %s is not written: %v", views.FindingKey(mcc.ScopeModule, keyID(id), kind), err) } } @@ -584,10 +575,9 @@ func (ix *Indexer) ApplyOutOfStepBuild(ctx context.Context, value []byte) { fmt.Sprintf("%s's trunk build at %.12s has generated files out of step with their seat definitions: %s", e.Module, e.Commit, strings.Join(files, ", ")), "Generated types on "+e.Module+"'s trunk are out of step with their seat definitions", "Its build's test step regenerated the types and found the committed files differ, missing or not written by the "+ - "generator (a generator's change does this). A change regenerating them clears this once a build of "+e.Module+" ends clean.", - "A build of "+e.Module+" ended clean: its generated types match their definitions.") + "generator (a generator's change does this). A change regenerating them clears this once a build of "+e.Module+" ends clean.") case e.Outcome == builds.BuildDone: - ix.finding(ctx, findingID("out-of-step", e.Module), OutOfStepKind, false, "", "", "", "") + ix.finding(ctx, findingID("out-of-step", e.Module), OutOfStepKind, false, "", "", "") } } @@ -699,8 +689,8 @@ func OutOfStepOf(defs, findings views.Source) ([]OutOfStepRow, error) { if err != nil || !ok || v == nil { continue } - f, err := mcc.DecodeFinding(v.Value) - if err != nil || f.Kind != OutOfStepKind { + var f mcseat.Finding + if err := json.Unmarshal(v.Value, &f); err != nil || f.Kind != OutOfStepKind { continue } out = append(out, OutOfStepRow{Source: SourceTrunk, Module: strings.TrimPrefix(f.Subject.ID, Module+".out-of-step."), Summary: f.Summary}) diff --git a/modules/mesh-delivery-indexer/internal/index/unreadcall.go b/modules/mesh-delivery-indexer/internal/index/unreadcall.go index e8d2f1da..d81e49fb 100644 --- a/modules/mesh-delivery-indexer/internal/index/unreadcall.go +++ b/modules/mesh-delivery-indexer/internal/index/unreadcall.go @@ -2,9 +2,13 @@ package index import ( "context" + "fmt" "strings" + "time" mcc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" + mcseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + "git.novox.be/novox/mesh-sdk/go/views" ) // **A merged message unread past five minutes, the forge heard, is a finding** (issue 1966 note 153; novox/hq ADR 0668 §3). @@ -21,7 +25,8 @@ const UnreadCallee = ForgeBridgeModule + ".bridge_merged_message" // unreadCallID is the finding's id for the pending key: the merge it waits on. func unreadCallID(key string) string { return "merged-message." + key } -// unreadFindings raises the finding of every merge of entries unread past the bound and clears the finding of every other. +// unreadFindings raises the finding of every merge of entries unread past the bound and clears the finding of every other +// that the finding writer holds (design 47 G §689, A-29). func (ix *Indexer) unreadFindings(ctx context.Context, entries []PendingEntry) { if ix.Findings == nil { return @@ -31,39 +36,32 @@ func (ix *Indexer) unreadFindings(ctx context.Context, entries []PendingEntry) { if e.Value.Unread == "" { continue } - unread[mcc.Key(mcc.ScopeCall, unreadCallID(e.Key), mcc.KindCallUnanswered)] = true + id := keyID(unreadCallID(e.Key)) + unread[id] = true since := e.Value.UnreadSince if since.IsZero() || ix.now().Sub(since) < mcc.CallUnansweredAfter { continue } - o := mcc.CallUnanswered(unreadCallID(e.Key), UnreadCallee, "", e.Value.Unread, "indexer."+mcc.KindCallUnanswered, since) - o.Needs = TakeInPendingTool + " reads the merge again now; " + PendingTool + " shows it unread." - key := o.Key() - if err := write(ctx, ix.Findings, key, func(value []byte, found bool) ([]byte, bool, error) { - if found { - if f, err := mcc.DecodeFinding(value); err == nil && f.Summary == o.Summary { - return nil, false, nil - } - } - data, err := mcc.EncodeFinding(o.Finding(Module, "", ix.now().UTC())) - return data, false, err - }); err != nil { - ix.logf("take-in: the finding %s is not written: %v", key, err) + f := ix.observed(mcc.ScopeCall, unreadCallID(e.Key), mcc.KindCallUnanswered, + fmt.Sprintf("the call %s about %s is unanswered since %s", UnreadCallee, unreadCallID(e.Key), since.UTC().Format(time.RFC3339)), + "A call whose answer is kept is not answered", + fmt.Sprintf("The call %s is made again on every transport fault and on the callee's event; it has not been answered since %s. Its last fault: %s.", + UnreadCallee, since.UTC().Format(time.RFC3339), e.Value.Unread), "indexer."+mcc.KindCallUnanswered) + f.Needs = TakeInPendingTool + " reads the merge again now; " + PendingTool + " shows it unread." + f.Evidence = []mcseat.Evidence{{At: since.UTC(), Said: e.Value.Unread}} + if err := ix.Findings.Write(ctx, f); err != nil { + ix.logf("take-in: the finding %s is not written: %v", views.FindingKey(f.Scope, f.ID, f.Kind), err) } else { - ix.logf("take-in: %s (%s#%d) is unread since %s and the forge is heard: the finding %s", e.Key, e.Forge.Repository, e.Forge.Number, since.Format("2006-01-02T15:04:05Z07:00"), key) + ix.logf("take-in: %s (%s#%d) is unread since %s and the forge is heard: the finding %s", e.Key, e.Forge.Repository, e.Forge.Number, since.Format("2006-01-02T15:04:05Z07:00"), views.FindingKey(f.Scope, f.ID, f.Kind)) } } - keys, err := ix.Findings.Keys(ctx) - if err != nil { - return - } - prefix, suffix := mcc.ScopeCall+"."+unreadCallID(""), "."+mcc.KindCallUnanswered - for _, k := range keys { - if !strings.HasPrefix(k, prefix) || !strings.HasSuffix(k, suffix) || unread[k] { + prefix := keyID(strings.TrimSuffix(unreadCallID(""), ".")) + "." + for _, f := range ix.Findings.Held() { + if f.Scope != mcc.ScopeCall || f.Kind != mcc.KindCallUnanswered || !strings.HasPrefix(f.ID, prefix) || unread[f.ID] { continue } - if err := write(ctx, ix.Findings, k, func(_ []byte, found bool) ([]byte, bool, error) { return nil, found, nil }); err != nil { - ix.logf("take-in: the finding %s is not cleared: %v", k, err) + if err := ix.Findings.Clear(ctx, f.Scope, f.ID, f.Kind, "the merge is no longer unread"); err != nil { + ix.logf("take-in: the finding %s is not cleared: %v", views.FindingKey(f.Scope, f.ID, f.Kind), err) } } } diff --git a/modules/mesh-delivery-indexer/internal/index/verdicts_test.go b/modules/mesh-delivery-indexer/internal/index/verdicts_test.go index 87353e68..b72ad051 100644 --- a/modules/mesh-delivery-indexer/internal/index/verdicts_test.go +++ b/modules/mesh-delivery-indexer/internal/index/verdicts_test.go @@ -237,7 +237,7 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) { } // DLV-I9b: a failed publish is published again; one failing twice writes the finding naming the module, which the next // judging pass says again and clears. - ix.Findings = newMem() + ix.Findings = newFindings() failing = 1 once := hold("once", `{"module":"once","version":"1.0"}`) if _, err := ix.JudgeAll(ctx, map[string]bool{once: true}); err != nil || accepted[len(accepted)-1].Module != "once" { @@ -248,13 +248,13 @@ func TestTheIndexerJudgesEveryManifestOnce(t *testing.T) { if _, err := ix.JudgeAll(ctx, map[string]bool{flaky: true}); err != nil { t.Fatal(err) } - if value, _, found, _ := ix.Findings.Get(ctx, unsaidKey("flaky")); !found || !strings.Contains(string(value), "flaky") { + if value, found := ix.Findings.(*memFindings).vals[unsaidKey("flaky")]; !found || !strings.Contains(string(value), "flaky") { t.Fatalf("a publish failing twice wrote no finding naming flaky: %s", value) } if _, err := ix.JudgeAll(ctx, nil); err != nil { t.Fatal(err) } - if _, _, found, _ := ix.Findings.Get(ctx, unsaidKey("flaky")); found || accepted[len(accepted)-1].Module != "flaky" || + if _, found := ix.Findings.(*memFindings).vals[unsaidKey("flaky")]; found || accepted[len(accepted)-1].Module != "flaky" || accepted[len(accepted)-1].Digest != flaky { t.Fatalf("the next pass did not say flaky and clear its finding (found %v, last said %+v)", found, accepted[len(accepted)-1]) } diff --git a/modules/mesh-delivery-indexer/internal/index/views.go b/modules/mesh-delivery-indexer/internal/index/views.go index c57952ff..9aacd256 100644 --- a/modules/mesh-delivery-indexer/internal/index/views.go +++ b/modules/mesh-delivery-indexer/internal/index/views.go @@ -1,8 +1,6 @@ package index import ( - mcc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" - "git.novox.be/novox/mesh-delivery/sdk/graph" "git.novox.be/novox/mesh-sdk/go/views" ) @@ -30,7 +28,7 @@ var ReadTools = []ViewedTool{ {Tool: "indexer_read_graph", State: graph.State, Words: "the module graph: each module's build and provision edges."}, {Tool: "indexer_read_heard", State: HeardState, Words: "the forge messages the indexer heard and what it did with each."}, {Tool: "indexer_read_taken", State: "taken", Words: "each repository's merge the indexer took in last."}, - {Tool: "indexer_read_findings", State: mcc.FindingsState, Words: "the indexer's findings, which the condition keeper raises and clears."}, + {Tool: "indexer_read_findings", State: FindingsState, Words: "the indexer's findings, which the condition keeper raises and clears."}, {Tool: "indexer_read_seat_definitions", State: SeatDefinitionsState, Words: "the seat definitions the trunk's and each open pull request's contracts modules hold, resolved, or why one does not parse."}, {Tool: "indexer_read_unjudged", State: UnjudgedState, Words: "the manifests a take-in newly wrote that no judge pass judged yet, by digest, each with its module and merge; indexer_judge judges them."}, } @@ -50,7 +48,7 @@ var ViewedStates = map[string]string{ PackagesState: PackagesBucket, HeardState: HeardBucket, "taken": TakenBucket, - mcc.FindingsState: mcc.FindingsBucket(Module), + FindingsState: FindingsBucket, SeatDefinitionsState: SeatDefinitionsBucket, UnjudgedState: UnjudgedBucket, } diff --git a/modules/mesh-delivery-indexer/manifest_test.go b/modules/mesh-delivery-indexer/manifest_test.go index fb11fcfd..f22b616d 100644 --- a/modules/mesh-delivery-indexer/manifest_test.go +++ b/modules/mesh-delivery-indexer/manifest_test.go @@ -16,7 +16,6 @@ import ( "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-indexer/takein" "git.novox.be/novox/mesh-sdk/go/catalogue" - mcc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" "git.novox.be/novox/mesh-sdk/go/views" "mesh-delivery-indexer/internal/index" @@ -121,7 +120,7 @@ func TestManifestDeclarations(t *testing.T) { // 1238/AC1, 1444 (reads the build } // 1126 note 76: heard keeps the last 64 FORGE messages the indexer handled, for indexer_heard. if want := []state{{Name: "candidates", History: 10}, {Name: "heard", History: 64}, {Name: "manifests", History: 1}, {Name: "predefined", History: 5}, {Name: "config-candidates", History: 5}, {Name: "taken", History: 1}, {Name: "takeins", History: 1}, {Name: "verdicts", History: 5}, {Name: "oldsections", History: 1}, {Name: "try-recovery", History: 1}, {Name: "own-build", History: 1}, {Name: index.PackagesState, History: index.PackagesHistory}, - {Name: views.MarksState, History: 1}, {Name: mcc.FindingsState, History: 5}, {Name: views.StatusState, History: 1}, + {Name: views.MarksState, History: 1}, {Name: index.FindingsState, History: 5}, {Name: views.StatusState, History: 1}, {Name: index.RequirementsState, History: 5}, {Name: index.SeatDefinitionsState, History: 5}, {Name: index.PendingState, History: 1}, {Name: index.UnjudgedState, History: 1}, {Name: index.EntryIndexState, History: 1}, {Name: index.RegistrationsState, History: 1}, {Name: index.ProposalsState, History: 1}}; !reflect.DeepEqual(m.State, want) { // DLV-I7, DLV-I17