From e69f55468c99a5ca29f34ce7a3833d1cca7e648b Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 00:59:22 +0100 Subject: [PATCH 1/7] fix9-1: the teller, view and watchman manifest tests' reader takes requires, and hold the findings as requires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G §383, §387; A-30 --- modules/mesh-delivery-teller/manifest_test.go | 31 ++++++++++++++++--- modules/mesh-delivery-view/manifest_test.go | 29 +++++++++++++++-- .../mesh-delivery-watchman/manifest_test.go | 30 +++++++++++++++--- 3 files changed, 79 insertions(+), 11 deletions(-) diff --git a/modules/mesh-delivery-teller/manifest_test.go b/modules/mesh-delivery-teller/manifest_test.go index bbc56f58..aca44653 100644 --- a/modules/mesh-delivery-teller/manifest_test.go +++ b/modules/mesh-delivery-teller/manifest_test.go @@ -55,6 +55,7 @@ type manifest struct { OwnSecrets map[string]string `json:"own-secrets"` SecretsOwner string `json:"secrets-owner"` Resources []map[string]any `json:"resources"` + Requires []require `json:"requires"` Build struct { Artifacts []artifact `json:"artifacts"` } `json:"build"` @@ -93,7 +94,7 @@ func TestManifestDeclarations(t *testing.T) { // 1240/AC1 // answers is the teller's answer to each ask; asked is what it asked of the router, which condition it raised and which // gate key and revision each ask was made at (issue 1343); ends is the ends asked by hand with teller_end_ask (issue 774). // first-views is where its first view of the assigner's asks stands and findings its condition past the bound (ADR 0615). - if want := []state{{Name: "answers", History: 5}, {Name: "asked", History: 1}, {Name: "ends", History: 1}, {Name: "first-views", History: 1}, {Name: "findings", History: 1}}; !reflect.DeepEqual(m.State, want) { + if want := []state{{Name: "answers", History: 5}, {Name: "asked", History: 1}, {Name: "ends", History: 1}, {Name: "first-views", History: 1}}; !reflect.DeepEqual(m.State, want) { t.Errorf("state = %+v, want %+v", m.State, want) } // The view's answers to the assigner's asks are read so the view's answer fills an ask that needs no proof (1297/B10); the @@ -133,14 +134,17 @@ func TestManifestDeclarations(t *testing.T) { // 1240/AC1 t.Errorf("emits = %v, want %v", m.Emits, want) } // design 47 G §383, §387: the stream -findings, 7 days and 16 MiB; §368: version is MAJOR.MINOR. - if want := []stream{{Name: "mesh-delivery-teller-findings", Events: []string{"finding-written.*", "finding-cleared.*", "findings-stated.*"}, MaxAgeDays: 7, MaxMiB: 16}}; !reflect.DeepEqual(m.Streams, want) { - t.Errorf("streams = %+v, want %+v", m.Streams, want) + if len(m.Streams) != 0 { + t.Errorf("streams = %+v: the finding stream is a require now", m.Streams) + } + if want := findingRequires(1); !reflect.DeepEqual(m.Requires, want) { + t.Errorf("requires = %+v, want %+v", m.Requires, want) } if m.Version != "0.1" { t.Errorf("version = %q, want 0.1", m.Version) } // No bus or seat (ADR 0437 §4). - for _, k := range []string{"logs", "claims", "provides", "requires", "defines-seats", "seat", "seats", "bus"} { + for _, k := range []string{"logs", "claims", "provides", "defines-seats", "seat", "seats", "bus"} { if _, ok := top[k]; ok { t.Errorf("the manifest declares %q: the teller has none", k) } @@ -243,3 +247,22 @@ func TestNoPartHandWritesItsBusConnection(t *testing.T) { } } } + +// require is one entry of the manifest's requires: a provision the module asks for by a template (ADR 0595), which the +// controller's reader takes. +type require struct { + Name string `json:"name"` + Storage string `json:"storage"` + Template string `json:"template"` + Options map[string]any `json:"options"` +} + +// findingRequires is the findings state and the finding stream a module asks for by their templates (design 47 G +// §383, §387; A-30): 7 days and 16 MiB, the findings state keeping the given history. +func findingRequires(history float64) []require { + return []require{ + {Name: "findings", Storage: "key-value", Template: "state", Options: map[string]any{"history": history}}, + {Name: "finding-events", Storage: "stream", Template: "events", Options: map[string]any{ + "events": []any{"finding-written.*", "finding-cleared.*", "findings-stated.*"}, "max-age-days": float64(7), "max-mib": float64(16)}}, + } +} diff --git a/modules/mesh-delivery-view/manifest_test.go b/modules/mesh-delivery-view/manifest_test.go index 846c0b58..bf6b6067 100644 --- a/modules/mesh-delivery-view/manifest_test.go +++ b/modules/mesh-delivery-view/manifest_test.go @@ -75,6 +75,7 @@ type manifest struct { OwnSecrets map[string]string `json:"own-secrets"` SecretsOwner string `json:"secrets-owner"` Resources []map[string]any `json:"resources"` + Requires []require `json:"requires"` Items []item `json:"items"` Build struct { Artifacts []artifact `json:"artifacts"` @@ -128,8 +129,11 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 t.Errorf("invokes = %v, want %v", m.Invokes, want) } // design 47 G §387: the keeper's ask for the findings is heard, the finding stream declared (§383), and §368: version is MAJOR.MINOR. - if want := []stream{{Name: "mesh-delivery-view-findings", Events: []string{"finding-written.*", "finding-cleared.*", "findings-stated.*"}, MaxAgeDays: 7, MaxMiB: 16}}; !reflect.DeepEqual(m.Streams, want) { - t.Errorf("streams = %+v, want %+v", m.Streams, want) + if len(m.Streams) != 0 { + t.Errorf("streams = %+v: the finding stream is a require now", m.Streams) + } + if want := findingRequires(1); !reflect.DeepEqual(m.Requires, want) { + t.Errorf("requires = %+v, want %+v", m.Requires, want) } if m.Version != "0.1" { t.Errorf("version = %q, want 0.1", m.Version) @@ -149,7 +153,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 // The states the view writes: the operator's answers to the asks that need no proof (ADR 0528 clause 6, 1297/B9), which the // teller reads, and how far each repository reached (ADR 0556 clause 2, issue 1280), which the gate reads. // Its first views of the owners' states and the finding of one unanswered past its bound (ADR 0615 §6). - if want := []state{{Name: "answers", History: 5}, {Name: "reach", History: 1}, {Name: "first-views", History: 1}, {Name: "findings", History: 1}}; !reflect.DeepEqual(m.State, want) { + if want := []state{{Name: "answers", History: 5}, {Name: "reach", History: 1}, {Name: "first-views", History: 1}}; !reflect.DeepEqual(m.State, want) { t.Errorf("state = %+v, want %+v", m.State, want) } if _, ok := m.OwnSecrets["broker"]; !ok || len(m.OwnSecrets) != 1 { @@ -430,3 +434,22 @@ func TestEveryStateTheCommandsReadIsIssued(t *testing.T) { t.Fatal("no stdio.State literal found under cmd/: the scan reads nothing") } } + +// require is one entry of the manifest's requires: a provision the module asks for by a template (ADR 0595), which the +// controller's reader takes. +type require struct { + Name string `json:"name"` + Storage string `json:"storage"` + Template string `json:"template"` + Options map[string]any `json:"options"` +} + +// findingRequires is the findings state and the finding stream a module asks for by their templates (design 47 G +// §383, §387; A-30): 7 days and 16 MiB, the findings state keeping the given history. +func findingRequires(history float64) []require { + return []require{ + {Name: "findings", Storage: "key-value", Template: "state", Options: map[string]any{"history": history}}, + {Name: "finding-events", Storage: "stream", Template: "events", Options: map[string]any{ + "events": []any{"finding-written.*", "finding-cleared.*", "findings-stated.*"}, "max-age-days": float64(7), "max-mib": float64(16)}}, + } +} diff --git a/modules/mesh-delivery-watchman/manifest_test.go b/modules/mesh-delivery-watchman/manifest_test.go index 5f6a53dc..16598284 100644 --- a/modules/mesh-delivery-watchman/manifest_test.go +++ b/modules/mesh-delivery-watchman/manifest_test.go @@ -66,6 +66,7 @@ type manifest struct { OwnSecrets map[string]string `json:"own-secrets"` SecretsOwner string `json:"secrets-owner"` Resources []map[string]any `json:"resources"` + Requires []require `json:"requires"` Build struct { Artifacts []artifact `json:"artifacts"` } `json:"build"` @@ -95,7 +96,7 @@ func TestManifestStateAndReads(t *testing.T) { // 1135/AC6 if m.Module != "mesh-delivery-watchman" || m.Moved || m.Slug != "watchman" { t.Errorf("module %q moved %v (it has no template, so it is not moved) slug %q", m.Module, m.Moved, m.Slug) } - want := []state{{Name: "presence", History: 20}, {Name: "pulse", KeyTTL: true}, {Name: "stuck", History: 20}, {Name: "findings", History: 5}, {Name: "first-views", History: 1}} + want := []state{{Name: "presence", History: 20}, {Name: "pulse", KeyTTL: true}, {Name: "stuck", History: 20}, {Name: "first-views", History: 1}} if !reflect.DeepEqual(m.State, want) { t.Errorf("state = %+v, want %+v", m.State, want) } @@ -110,9 +111,11 @@ func TestManifestStateAndReads(t *testing.T) { // 1135/AC6 t.Errorf("version = %q, want MAJOR.MINOR", m.Version) } // Design 47 G §387: the finding stream, 7 days and 16 MiB, and the keeper's ask. - if len(m.Streams) != 1 || m.Streams[0].Name != "mesh-delivery-watchman-findings" || m.Streams[0].MaxAgeDays != 7 || m.Streams[0].MaxMiB != 16 || - !reflect.DeepEqual(m.Streams[0].Events, []string{"finding-written.*", "finding-cleared.*", "findings-stated.*"}) { - t.Errorf("streams = %+v, want the finding stream", m.Streams) + if len(m.Streams) != 0 { + t.Errorf("streams = %+v: the finding stream is a require now", m.Streams) + } + if want := findingRequires(5); !reflect.DeepEqual(m.Requires, want) { + t.Errorf("requires = %+v, want %+v", m.Requires, want) } // DLV-M5 (ADR 0615 §1): the assigner's wants and the indexer's modules are their owners' states, asked by their read // tools and followed by their views families, never read. @@ -266,3 +269,22 @@ func TestNoTickerTimerOrSleep(t *testing.T) { // 1135/AC7 t.Fatalf("read %d source files, want the program's", files) } } + +// require is one entry of the manifest's requires: a provision the module asks for by a template (ADR 0595), which the +// controller's reader takes. +type require struct { + Name string `json:"name"` + Storage string `json:"storage"` + Template string `json:"template"` + Options map[string]any `json:"options"` +} + +// findingRequires is the findings state and the finding stream a module asks for by their templates (design 47 G +// §383, §387; A-30): 7 days and 16 MiB, the findings state keeping the given history. +func findingRequires(history float64) []require { + return []require{ + {Name: "findings", Storage: "key-value", Template: "state", Options: map[string]any{"history": history}}, + {Name: "finding-events", Storage: "stream", Template: "events", Options: map[string]any{ + "events": []any{"finding-written.*", "finding-cleared.*", "findings-stated.*"}, "max-age-days": float64(7), "max-mib": float64(16)}}, + } +} From 418bf4f54f31f170c760b4ef19e13b1bb161f5ff Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 01:23:47 +0100 Subject: [PATCH 2/7] fix9b2-4: the view's build usage finds a seat's holder with mesh-sdk's seatlist.HolderOf, no lookup of its own Builds: design 47 G A-126, A-82 (the view part of fix9-6 70e5845b taken in with the manifest tests' reader of fix9-1 09839e5d, which the view's tests need) --- .../cmd/mesh-delivery-view-tools/main.go | 38 +++++++-- .../cmd/mesh-delivery-view-tools/main_test.go | 25 ++++++ modules/mesh-delivery-view/go.mod | 1 + .../mesh-delivery-view/internal/view/usage.go | 81 +++++++++++++------ .../internal/view/usage_test.go | 49 +++++++++-- modules/mesh-delivery-view/manifest_test.go | 8 +- modules/mesh-delivery-view/module.json | 20 +++-- 7 files changed, 175 insertions(+), 47 deletions(-) diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go index 9baa4b52..73d70683 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go @@ -15,12 +15,15 @@ import ( "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient/enginereads" "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshwatchdogsclient" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-indexer/takein" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate" "git.novox.be/novox/mesh-delivery/sdk/buildstep" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/busretry" + "git.novox.be/novox/mesh-sdk/go/seatdef" + "git.novox.be/novox/mesh-sdk/go/seatlist" "git.novox.be/novox/mesh-sdk/go/views" ) @@ -405,6 +408,15 @@ func ownersRead(ask func(key string, body any) (json.RawMessage, error)) view.Re GraphState: indexer("indexer_read_graph")} } +// holderOf is a seat's holder as mesh-sdk's seatlist.HolderOf answers it (design 47 G A-126): false while the seat has none or +// the seat list cannot be read. +func holderOf(ask func(key string, body any) (json.RawMessage, error)) func(seat string) (string, bool) { + return func(seat string) (string, bool) { + module, err := seatlist.HolderOf(ask, seat) + return module, err == nil + } +} + func main() { r := ownersRead(stdio.Ask) r.Builds, r.HavesState = read(stdio.State("mesh-delivery-builder.builds")), read(stdio.State("node-engine.have")) @@ -416,16 +428,28 @@ func main() { r.ProvisionsState = read(stdio.State(view.ProvisionsRead)) r.BuildLog = stdio.Log(builds.BuildLogRead).Since r.Pending = pendingOf + // The build modules are no names (design 47 G A-82): the build seats on the seat list, each held by the module the seat + // list names for it; the seat list and its two tools are wired once by mesh-sdk (§204). + gate := seatlist.NewPart("view", []string{stdio.AnyPerson, "node-tools", "mesh-healers"}) + go func() { + <-stdio.Served() + gate.Follow(seatlist.StdioBus(), nil, func(f string, a ...any) { fmt.Fprintf(os.Stderr, "[mesh-delivery-view-tools] "+f+"\n", a...) }) + }() + seats := buildrun.NewBuildSeats(func() map[string]seatdef.Definition { + if rd := gate.Reader(); rd != nil { + return rd.Definitions() + } + return nil + }, holderOf(stdio.Ask)) u := view.UsageReader{Haves: r.HavesState, Resolved: read(stdio.State("mesh-config.resolved")), Tries: read(stdio.State("mesh-delivery-checker.tries")), - Steps: map[string]view.StateRead{}, Pauses: map[string]view.StateRead{}, Backlog: backlogOf(socket())} - for _, lang := range buildstep.AskLanguages { - module, _ := buildstep.BuildModuleOf(lang) - u.Steps[lang] = read(stdio.State(module + "." + view.StepsState)) - u.Pauses[lang] = read(stdio.State(module + "." + buildstep.PausesState)) - } + Seats: seats, + StepsOf: func(seat string) view.StateRead { return read(stdio.State(seat + "." + view.StepsState)) }, + PausesOf: func(seat string) view.StateRead { return read(stdio.State(seat + "." + buildstep.PausesState)) }, + Backlog: backlogOf(socket())} // view_first_views and view_first_view_again: where the view's process stands with its first views of the indexer's and // the assigner's states (ADR 0615 §6), and a re-read by hand. - tools := append(Tools(r, u), views.StatusTools(viewstate.ViewModule, "view_first_views", "view_first_view_again", stdio.State(views.StatusState), meshhandactsclient.Recorder{Ask: stdio.Ask}, time.Now)...) + tools := append(Tools(r, u), gate.Tools()...) + tools = append(tools, views.StatusTools(viewstate.ViewModule, "view_first_views", "view_first_view_again", stdio.State(views.StatusState), meshhandactsclient.Recorder{Ask: stdio.Ask}, time.Now)...) if err := stdio.Serve("", tools); err != nil { fmt.Fprintf(os.Stderr, "[mesh-delivery-view-tools] %v\n", err) os.Exit(1) diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go index c4231d61..1072a07e 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go @@ -3,6 +3,7 @@ package main import ( "crypto/ed25519" "encoding/json" + "errors" "fmt" "mesh-delivery-view/internal/view" "reflect" @@ -20,6 +21,7 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/busretry" "git.novox.be/novox/mesh-sdk/go/keys" + "git.novox.be/novox/mesh-sdk/go/seatlist" ) type fake map[string]string @@ -625,3 +627,26 @@ func manifestReadsAsksEveryNode(t *testing.T) { t.Errorf("node_manifest_reads = %+v", rows) } } + +// Design 47 G A-82, A-74: a build seat's holder is the module the seat mesh-module-assigner's verb assignments names for it with +// the seats, and none while the seat has no holder or the list cannot be read. +func TestABuildSeatsHolderIsTheOneTheSeatListNames(t *testing.T) { + var asked []string + ask := func(key string, _ any) (json.RawMessage, error) { + asked = append(asked, key) + return json.RawMessage(`{"seats":[{"seat":"node-build-go","scope":"node","holders":[{"module":"acme-go","node":"n1"}]},{"seat":"node-build-node","scope":"node","holders":[]}]}`), nil + } + holder := holderOf(ask) + if m, ok := holder("node-build-go"); !ok || m != "acme-go" { + t.Errorf("the holder of node-build-go = %q, %v; want acme-go", m, ok) + } + if m, ok := holder("node-build-node"); ok { + t.Errorf("a seat with no holder has %q", m) + } + if len(asked) != 2 || asked[0] != seatlist.HolderKey() { + t.Errorf("asked %v; want the seat mesh-module-assigner's assignments", asked) + } + if _, ok := holderOf(func(string, any) (json.RawMessage, error) { return nil, errors.New("down") })("node-build-go"); ok { + t.Error("a seat list that cannot be read names a holder") + } +} diff --git a/modules/mesh-delivery-view/go.mod b/modules/mesh-delivery-view/go.mod index a97d1bc4..b86a0904 100644 --- a/modules/mesh-delivery-view/go.mod +++ b/modules/mesh-delivery-view/go.mod @@ -16,6 +16,7 @@ require ( git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 diff --git a/modules/mesh-delivery-view/internal/view/usage.go b/modules/mesh-delivery-view/internal/view/usage.go index 190d6baa..368a2605 100644 --- a/modules/mesh-delivery-view/internal/view/usage.go +++ b/modules/mesh-delivery-view/internal/view/usage.go @@ -21,6 +21,7 @@ import ( "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + buildseats "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" "git.novox.be/novox/mesh-delivery/sdk/buildrun" "git.novox.be/novox/mesh-delivery/sdk/buildstep" ) @@ -50,9 +51,11 @@ const ( // UsageReader reads what build_usage answers from. type UsageReader struct { - // Steps and Pauses are each build module's states by language (go, node, image). - Steps map[string]StateRead - Pauses map[string]StateRead + // Seats are the build seats, each by the acting rule that builds, with the module holding it on the seat list (design 47 G + // A-82): no build module is named here. StepsOf and PausesOf read a build seat's steps and pauses through the seat. + Seats *buildseats.BuildSeats + StepsOf func(seat string) StateRead + PausesOf func(seat string) StateRead // Resolved is mesh-config's resolved values (..); Haves the node-engine's have. Resolved StateRead Haves StateRead @@ -209,6 +212,16 @@ func (u UsageReader) Usage() UsageAnswer { note := func(f string, a ...any) { ans.Notes = append(ans.Notes, fmt.Sprintf(f, a...)) } tries := u.tries(now, &ans, note) + // The build seats once for this answer: each asks the seat list for its holder (design 47 G A-82). + buildSeats := u.Seats.All() + buildSeatOfModule := func(module string) buildseats.BuildModule { + for _, m := range buildSeats { + if m.Module == module && module != "" { + return m + } + } + return buildseats.BuildModule{} + } seats := map[string]map[string]*SeatUsage{} // node -> language -> seat seat := func(node, lang string) *SeatUsage { if seats[node] == nil { @@ -217,9 +230,13 @@ func (u UsageReader) Usage() UsageAnswer { if s := seats[node][lang]; s != nil { return s } - name, _ := buildstep.BuildSeat(lang) - module, _ := buildstep.BuildModuleOf(lang) - s := &SeatUsage{Seat: name, Module: module, Running: RunningUsage{ByKind: map[string]int{}}, LeftRunning: []StepUsage{}, EndedStale: []StepUsage{}, Steps: []StepUsage{}} + var bm buildseats.BuildModule + for _, m := range buildSeats { + if m.AskLanguage() == lang { + bm = m + } + } + s := &SeatUsage{Seat: bm.Seat, Module: bm.Module, Running: RunningUsage{ByKind: map[string]int{}}, LeftRunning: []StepUsage{}, EndedStale: []StepUsage{}, Steps: []StepUsage{}} seats[node][lang] = s return s } @@ -233,10 +250,8 @@ func (u UsageReader) Usage() UsageAnswer { } else { var want []string for _, k := range keys { - for _, lang := range buildstep.AskLanguages { - if m, _ := buildstep.BuildModuleOf(lang); strings.HasSuffix(k, "."+m) { - want = append(want, k) - } + if _, module, _ := strings.Cut(k, "."); buildSeatOfModule(module).Seat != "" { + want = append(want, k) } } raws, err := u.Haves.values(want) @@ -256,8 +271,8 @@ func (u UsageReader) Usage() UsageAnswer { } for k, h := range haves { node, module, _ := strings.Cut(k, ".") - lang := strings.TrimPrefix(module, "mesh-build-") - s := seat(node, lang) + bm := buildSeatOfModule(module) + s := seat(node, bm.AskLanguage()) s.Phase, s.Holds = string(h.Phase), h.Phase == nodeengineclient.PhaseRunning } @@ -310,19 +325,27 @@ func (u UsageReader) Usage() UsageAnswer { } for key := range unreadable { node, module, _ := strings.Cut(key, ".") - seat(node, strings.TrimPrefix(module, "mesh-build-")) + bm := buildSeatOfModule(module) + seat(node, bm.AskLanguage()) } // Every node with a resolved capacity is listed, so "is this node eligible" is answered for it. for key := range resolved { node, module, _ := strings.Cut(key, ".") - seat(node, strings.TrimPrefix(module, "mesh-build-")) + bm := buildSeatOfModule(module) + seat(node, bm.AskLanguage()) + } + if len(buildSeats) == 0 { + note("no build seat is known from the seat definitions and the seat list, so no steps or pauses are read") } // The steps of each build module. steps := map[string]map[string][]usageStep{} // node -> language -> steps - for _, lang := range buildstep.AskLanguages { - module, _ := buildstep.BuildModuleOf(lang) - sr, ok := u.Steps[lang] - if !ok || sr.Keys == nil || sr.Get == nil { + for _, bm := range buildSeats { + lang, module := bm.AskLanguage(), cmpOr(bm.Module, bm.Seat) + var sr StateRead + if u.StepsOf != nil { + sr = u.StepsOf(bm.Seat) + } + if sr.Keys == nil || sr.Get == nil { note("the steps of %s are not readable here", module) continue } @@ -379,10 +402,13 @@ func (u UsageReader) Usage() UsageAnswer { // Pauses: paused. of each module; and capacity., the capacity the module says it judges by. paused := map[string]bool{} // . judged := map[string]buildstep.Capacity{} - for _, lang := range buildstep.AskLanguages { - module, _ := buildstep.BuildModuleOf(lang) - pr, ok := u.Pauses[lang] - if !ok || pr.Get == nil { + for _, bm := range buildSeats { + lang, module := bm.AskLanguage(), cmpOr(bm.Module, bm.Seat) + var pr StateRead + if u.PausesOf != nil { + pr = u.PausesOf(bm.Seat) + } + if pr.Get == nil { note("the pauses of %s are not readable here, so none is said paused", module) continue } @@ -422,7 +448,8 @@ func (u UsageReader) Usage() UsageAnswer { for _, node := range nodes { nu := NodeUsage{Node: node, Seats: []SeatUsage{}, Said: []string{}, CPU: "not on the bus: the disk-load module keeps no state; " + node + "/disk-load.cpu_top reads it (the build modules' share among its units)"} - for _, lang := range buildstep.AskLanguages { + for _, bm := range buildSeats { + lang := bm.AskLanguage() s := seats[node][lang] if s == nil { continue @@ -696,3 +723,11 @@ func sumMedian(d []int64) (sum, median int64) { } return sum, s[(len(s)-1)/2] } + +// cmpOr is the first of two names that is not empty. +func cmpOr(a, b string) string { + if a != "" { + return a + } + return b +} diff --git a/modules/mesh-delivery-view/internal/view/usage_test.go b/modules/mesh-delivery-view/internal/view/usage_test.go index 2762f299..02d87032 100644 --- a/modules/mesh-delivery-view/internal/view/usage_test.go +++ b/modules/mesh-delivery-view/internal/view/usage_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "errors" "fmt" + buildseats "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" + "git.novox.be/novox/mesh-sdk/go/seatdef" "strconv" "strings" "testing" @@ -82,8 +84,19 @@ func usageFixture() UsageReader { "shanks.mesh-config.other": `{"value":"x"}`, "g14.mesh-build-node.capacity": `{"node":"g14","module":"mesh-build-node","key":"capacity","source":"unreadable"}`, }), - Steps: map[string]StateRead{"go": states(goSteps), "node": states(map[string]string{}), "image": states(map[string]string{})}, - Pauses: map[string]StateRead{"go": states(map[string]string{"paused.ace": `{"v":1,"paused":true,"by":"lead@shanks","at":"2026-10-26T09:00:00Z"}`}), "node": states(nil), "image": states(nil)}, + Seats: buildSeatsOf(), + StepsOf: func(seat string) StateRead { + if seat == "node-build-go" { + return states(goSteps) + } + return states(map[string]string{}) + }, + PausesOf: func(seat string) StateRead { + if seat == "node-build-go" { + return states(map[string]string{"paused.ace": `{"v":1,"paused":true,"by":"lead@shanks","at":"2026-10-26T09:00:00Z"}`}) + } + return states(nil) + }, Tries: states(map[string]string{ "try-" + strconv.FormatInt(at("11:29").UnixNano(), 10): `{"id":"try-1","repository":"novox/mesh-sdk","head":"h","trunk":"t","state":"pass","asks":["` + tryAsk + `"]}`, "try-" + strconv.FormatInt(at("11:58").UnixNano(), 10): `{"id":"try-2","repository":"novox/mesh-delivery","head":"h","trunk":"t","state":"running","started":"2026-10-26T11:58:00Z"}`, @@ -167,7 +180,7 @@ func TestBuildUsageAnswersEveryNodesUseFromTheStates(t *testing.T) { // Nothing readable: each state is named in a note, the backlog's refusal too, and nothing is said held. none := UsageReader{Now: func() time.Time { return usageNow }, Backlog: func() ([]BacklogRow, error) { return nil, errors.New("no socket") }}.Usage() notes := strings.Join(none.Notes, " | ") - for _, want := range []string{"have is not readable", "capacity unknown to this tool: mesh-config's resolved values are not readable", "steps of mesh-build-go are not readable", "tries are not readable", "did not answer: no socket"} { + for _, want := range []string{"have is not readable", "capacity unknown to this tool: mesh-config's resolved values are not readable", "no build seat is known", "tries are not readable", "did not answer: no socket"} { if !strings.Contains(notes, want) { t.Errorf("the notes %q do not say %q", notes, want) } @@ -196,8 +209,8 @@ func TestBuildUsageAnswersEveryNodesUseFromTheStates(t *testing.T) { // Issue 1806's follow-up: the module on shanks says it judges by 1 of mesh-config's 2 (its build CPU bounds it); the // seat says the module's capacity and why. And issue 1890: a running try that names its asks is said with them. u = usageFixture() - u.Pauses["go"] = states(map[string]string{"capacity.shanks": `{"v":1,"effective":1,"configured":2,"quota-from":"the cpu.max of /meshyield.slice","quota-cores":6,"cores-per-step":6,"by-cpu":1,"why":"1, bounded by the CPU","at":"2026-10-26T11:00:00Z"}`, - "capacity.ace": `{"v":1,"effective":2,"configured":3,"why":"stale: mesh-config says 2 now"}`}) + setPauses(&u, "node-build-go", states(map[string]string{"capacity.shanks": `{"v":1,"effective":1,"configured":2,"quota-from":"the cpu.max of /meshyield.slice","quota-cores":6,"cores-per-step":6,"by-cpu":1,"why":"1, bounded by the CPU","at":"2026-10-26T11:00:00Z"}`, + "capacity.ace": `{"v":1,"effective":2,"configured":3,"why":"stale: mesh-config says 2 now"}`})) u.Tries = states(map[string]string{"try-" + strconv.FormatInt(at("11:58").UnixNano(), 10): `{"id":"try-2","repository":"novox/mesh-delivery","head":"h","trunk":"t","state":"running","asks":["` + tryAsk0 + `"],"steps":[{"ask":"` + tryAsk0 + `","node":"shanks","state":"running","phase":"unit: tests of 3 packages"}]}`}) bound := u.Usage() sc := seatOf(t, bound, "shanks", "node-build-go") @@ -208,13 +221,13 @@ func TestBuildUsageAnswersEveryNodesUseFromTheStates(t *testing.T) { t.Errorf("a module's word older than mesh-config's value is not taken: %+v", sa) } // The operator, 2026-10-26: outside its build hours the seat takes nothing new, and says so. - u.Pauses["go"] = states(map[string]string{"capacity.shanks": `{"v":1,"effective":0,"configured":2,"build-hours":"18:00-06:00 CET","outside-hours":true,"why":"0, outside its build hours 18:00-06:00 CET until 18:00 (otherwise 2)","at":"2026-10-26T11:00:00Z"}`}) + setPauses(&u, "node-build-go", states(map[string]string{"capacity.shanks": `{"v":1,"effective":0,"configured":2,"build-hours":"18:00-06:00 CET","outside-hours":true,"why":"0, outside its build hours 18:00-06:00 CET until 18:00 (otherwise 2)","at":"2026-10-26T11:00:00Z"}`})) if so := seatOf(t, u.Usage(), "shanks", "node-build-go"); so.Capacity != 0 || !so.OutsideHours || so.Eligible == nil || *so.Eligible || so.WhyNot != "outside its build hours 18:00-06:00 CET on shanks: it takes nothing new until they open" { t.Errorf("a seat outside its build hours: %+v", so) } // A build-hours value the module cannot read: it builds at any hour, and the seat says the value and why. - u.Pauses["go"] = states(map[string]string{"capacity.shanks": `{"v":1,"effective":2,"configured":2,"build-hours-fault":"the build hours \"shanks=18:00-6\": \"6\" is not a time HH:MM","why":"2; at any hour, because its build hours cannot be read","at":"2026-10-26T11:00:00Z"}`}) + setPauses(&u, "node-build-go", states(map[string]string{"capacity.shanks": `{"v":1,"effective":2,"configured":2,"build-hours-fault":"the build hours \"shanks=18:00-6\": \"6\" is not a time HH:MM","why":"2; at any hour, because its build hours cannot be read","at":"2026-10-26T11:00:00Z"}`})) if sf := seatOf(t, u.Usage(), "shanks", "node-build-go"); sf.BuildHours != "any hour" || !strings.Contains(sf.BuildHoursFault, "shanks=18:00-6") || sf.Eligible == nil || !*sf.Eligible { t.Errorf("a seat whose build hours cannot be read: %+v", sf) } @@ -225,3 +238,25 @@ func TestBuildUsageAnswersEveryNodesUseFromTheStates(t *testing.T) { } var tryAsk0 = stepID(at("11:58"), "a00010") + +// buildSeatsOf is the build seats as the seat definitions and the seat list give them (design 47 G A-82): the seats whose +// acting rule builds, each held by the module the seat list names. +func buildSeatsOf() *buildseats.BuildSeats { + rule := func(on string) seatdef.Definition { + return seatdef.Definition{Acts: []seatdef.ActingRule{{Step: seatdef.StepBuild, On: on}}} + } + defs := map[string]seatdef.Definition{"node-build-go": rule("go"), "node-build-node": rule("typescript"), "node-build-image": rule(seatdef.OnKindImage)} + holders := map[string]string{"node-build-go": "mesh-build-go", "node-build-node": "mesh-build-node", "node-build-image": "mesh-build-image"} + return buildseats.NewBuildSeats(func() map[string]seatdef.Definition { return defs }, func(seat string) (string, bool) { h, ok := holders[seat]; return h, ok }) +} + +// setPauses replaces what one build seat's pauses read. +func setPauses(u *UsageReader, seat string, st StateRead) { + prev := u.PausesOf + u.PausesOf = func(s string) StateRead { + if s == seat { + return st + } + return prev(s) + } +} diff --git a/modules/mesh-delivery-view/manifest_test.go b/modules/mesh-delivery-view/manifest_test.go index bf6b6067..d0e3e918 100644 --- a/modules/mesh-delivery-view/manifest_test.go +++ b/modules/mesh-delivery-view/manifest_test.go @@ -106,7 +106,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 if m.Module != "mesh-delivery-view" { t.Errorf("module = %q", m.Module) } - want := []string{"mesh-build-go.pauses", "mesh-build-go.steps", "mesh-build-image.pauses", "mesh-build-image.steps", "mesh-build-node.pauses", "mesh-build-node.steps", "mesh-conditions.conditions", "mesh-config.resolved", "mesh-delivery-builder.build-log", "mesh-delivery-builder.builds", "mesh-delivery-checker.tries", "mesh-delivery-gate.asks", "mesh-delivery-gate.source", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "mesh-healers.attempts", "mesh-provisioner.provisions", "mesh-vault.signers", "node-engine.have"} // no reads of the indexer's or the assigner's states (ADR 0615 §1, DLV-M4); view_tiers reads the module graph and the provisioner's keys (ADR 0603 §6); view_status reads the keeper's conditions and the healers' attempts (1952/V1); build_usage reads the build modules' steps and pauses, mesh-config's capacities and the checker's tries; 1168/AC1, 1241/AC4, 1345/AC3, 1297/B8, B9 + want := []string{"mesh-conditions.conditions", "mesh-config.resolved", "mesh-delivery-builder.build-log", "mesh-delivery-builder.builds", "mesh-delivery-checker.tries", "mesh-delivery-gate.asks", "mesh-delivery-gate.source", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "mesh-healers.attempts", "mesh-provisioner.provisions", "mesh-vault.signers", "node-build-go.pauses", "node-build-go.steps", "node-build-image.pauses", "node-build-image.steps", "node-build-node.pauses", "node-build-node.steps", "node-engine.have"} // the build seats' states by seat, never a build module (design 47 G A-82); // no reads of the indexer's or the assigner's states (ADR 0615 §1, DLV-M4); view_tiers reads the module graph and the provisioner's keys (ADR 0603 §6); view_status reads the keeper's conditions and the healers' attempts (1952/V1); build_usage reads the build modules' steps and pauses, mesh-config's capacities and the checker's tries; 1168/AC1, 1241/AC4, 1345/AC3, 1297/B8, B9 got := slices.Clone(m.Reads) slices.Sort(got) if !reflect.DeepEqual(got, want) { @@ -124,7 +124,9 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 "mesh-delivery-indexer.indexer_read_graph", "mesh-delivery-indexer.indexer_read_heard", "mesh-delivery-indexer.indexer_pending", "mesh-delivery-indexer.indexer_read_packages", "mesh-delivery-indexer.indexer_read_predefined", "mesh-delivery-indexer.indexer_read_taken", "seat:mesh-catalog.revision", // ADR 0663: the modules are the catalog half's, read through the seat's verb revision, never the indexer's + "seat:mesh-catalog.seat-definitions", // design 47 G A-82, §204: the build seats are read from the seat list "seat:mesh-hand-acts.hand-act", // the join: its first view asked again by hand is a hand act (views.StatusTools) + "seat:mesh-module-assigner.assignments", // A-82: the holder of each build seat "seat:node-engine.manifest-reads"}; !reflect.DeepEqual(m.Invokes, want) { // ADR 0618 §3: node_manifest_reads asks the node-engine seat's verb of every node; 1585/T24: view_tiers asks indexer_pending, a verb edge (ADR 0614 §2) t.Errorf("invokes = %v, want %v", m.Invokes, want) } @@ -139,7 +141,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 t.Errorf("version = %q, want 0.1", m.Version) } if want := []string{"mesh-conditions.findings-asked", "mesh-delivery-assigner.views.asks", "mesh-delivery-assigner.views.assignments", "mesh-delivery-assigner.views.wants", - "mesh-catalog.catalog-revision", "mesh-delivery-indexer.views.candidates", "mesh-delivery-indexer.views.taken"}; !reflect.DeepEqual(m.Consumes, want) { + "mesh-catalog.catalog-revision", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", "mesh-delivery-indexer.views.candidates", "mesh-delivery-indexer.views.taken"}; !reflect.DeepEqual(m.Consumes, want) { t.Errorf("consumes = %v, want %v", m.Consumes, want) } // The one event the view says: a source revision is delivered (1906/D9, ADR 0539 §17), derived by it from the wants and the haves. @@ -164,7 +166,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 } // The two read tools of the indexer's modules (issue 1562), carried by a bundle the runtime launches; the indexer // itself serves no tool (ADR 0437 §4). Carrying them gives the tool runner read access to every bucket the view reads. - if want := []string{"view_status", "indexer_module", "indexer_modules", "indexer_candidate", "indexer_candidates", "indexer_predefined", "indexer_config_candidates", "indexer_packages", "builder_build", "builder_builds", "builder_history", "assigner_wants", "assigner_want", "assigner_differences", "node_haves", "node_trusts", "build_usage", "view_reach", "indexer_module_history", "indexer_heard", "view_tiers", "view_first_views", "view_first_view_again", "node_manifest_reads"}; !reflect.DeepEqual(m.Tools, want) { + if want := []string{"view_status", "indexer_module", "indexer_modules", "indexer_candidate", "indexer_candidates", "indexer_predefined", "indexer_config_candidates", "indexer_packages", "builder_build", "builder_builds", "builder_history", "assigner_wants", "assigner_want", "assigner_differences", "node_haves", "node_trusts", "build_usage", "view_reach", "indexer_module_history", "indexer_heard", "view_tiers", "view_first_views", "view_first_view_again", "node_manifest_reads", "view_seat_list", "view_seat_list_again"}; !reflect.DeepEqual(m.Tools, want) { t.Errorf("tools = %v, want %v", m.Tools, want) } // D-11: the holders and buckets the view names are its manifest's (no contract names a holder, novox/hq ADR 0634 §1): a diff --git a/modules/mesh-delivery-view/module.json b/modules/mesh-delivery-view/module.json index feb4694c..cb6231ff 100644 --- a/modules/mesh-delivery-view/module.json +++ b/modules/mesh-delivery-view/module.json @@ -27,7 +27,9 @@ "view_tiers", "view_first_views", "view_first_view_again", - "node_manifest_reads" + "node_manifest_reads", + "view_seat_list", + "view_seat_list_again" ], "emits": [ "delivered", @@ -56,6 +58,8 @@ "mesh-delivery-assigner.views.assignments", "mesh-delivery-assigner.views.wants", "mesh-catalog.catalog-revision", + "mesh-catalog.seat-definition-changed", + "mesh-catalog.seat-definition-removed", "mesh-delivery-indexer.views.candidates", "mesh-delivery-indexer.views.taken" ], @@ -73,16 +77,18 @@ "mesh-delivery-indexer.indexer_read_predefined", "mesh-delivery-indexer.indexer_read_taken", "seat:mesh-catalog.revision", + "seat:mesh-catalog.seat-definitions", "seat:mesh-hand-acts.hand-act", + "seat:mesh-module-assigner.assignments", "seat:node-engine.manifest-reads" ], "reads": [ - "mesh-build-go.pauses", - "mesh-build-go.steps", - "mesh-build-image.pauses", - "mesh-build-image.steps", - "mesh-build-node.pauses", - "mesh-build-node.steps", + "node-build-go.pauses", + "node-build-go.steps", + "node-build-image.pauses", + "node-build-image.steps", + "node-build-node.pauses", + "node-build-node.steps", "mesh-config.resolved", "mesh-delivery-builder.build-log", "mesh-delivery-builder.builds", From 89bbd20b5af40b1c604505a601dce47db993113f Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 01:25:50 +0100 Subject: [PATCH 3/7] fix9b2-3: the builder's operator asker names no list of the seat holders (operatorask finds the holder itself) Builds: design 47 G A-126 --- .../mesh-delivery-builder/cmd/mesh-delivery-builder/main.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go index 390eb0bd..cf83ccb0 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go @@ -18,7 +18,6 @@ import ( "syscall" "time" - mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" "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" @@ -245,8 +244,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro decided := &decisions{subject: oc.SubjectDecided(cred.Module)} retakeActed := awaitedReserved{awaitedVersions{b: actedKV, bucket: versions.ActedBucket}} asker := &operatorask.Asker[oc.Warrant]{Module: cred.Module, Call: calls.Call, Record: calls.Ask, Publish: publishVia(ctx, js), Subscribe: decided.hear(logger.Printf), - Contract: operatorask.Contract{Lists: "seat:" + mma.Seat + "." + mma.VerbAssignments, ListBody: mma.AssignmentsInput{Seats: mma.AssignmentsInputSeatsTrue}, - Seat: oc.Seat, StateAsks: oc.StateAsks}, + Contract: operatorask.Contract{Seat: oc.Seat, StateAsks: oc.StateAsks}, AskSubject: oc.AcceptSubjectAsk, DecidedSubject: oc.SubjectDecided} operator := &build.OperatorAsk{Builder: b, Acted: retakeActed, Ctx: ctx, Logf: logger.Printf, Asker: asker, Client: &occ.Client{Module: cred.Module, State: calls.State("asked"), From 6034559dc2151d333569d1517c411d3662fa9e98 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 01:28:12 +0100 Subject: [PATCH 4/7] fix9b2-1: the builder follows the assigner's view in seat form, its holder found at run time consumes seat:mesh-module-assigner.views.assignments, reads seat:mesh-module-assigner.assignments; the holder is asked of the seat list (seatlist.HolderOf), the first view is the holder's first-view tool (A-131), the assigner's callers list gains the builder; FollowAssignments takes the bucket. Builds: design 47 G A-119, A-116, A-131 --- .../integration_test.go | 2 +- .../mesh-delivery-assigner/manifest_test.go | 2 +- modules/mesh-delivery-assigner/module.json | 2 +- .../buildrun/standing.go | 14 +++---- .../buildrun/standing_test.go | 2 +- .../buildrun/toolchaingate_test.go | 2 +- .../cmd/mesh-delivery-builder/assignments.go | 41 +++++++++++++++++++ .../mesh-delivery-builder/assignments_test.go | 37 +++++++++++++++++ .../mesh-delivery-builder/integration_test.go | 13 +++++- .../cmd/mesh-delivery-builder/main.go | 22 ++++++---- .../cmd/mesh-delivery-builder/names.go | 3 -- .../cmd/mesh-delivery-builder/names_test.go | 7 ++-- .../mesh-delivery-builder/manifest_test.go | 14 +++---- modules/mesh-delivery-builder/module.json | 5 +-- 14 files changed, 129 insertions(+), 37 deletions(-) create mode 100644 modules/mesh-delivery-builder/cmd/mesh-delivery-builder/assignments.go create mode 100644 modules/mesh-delivery-builder/cmd/mesh-delivery-builder/assignments_test.go 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 e331c3a4..d643a419 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 @@ -528,7 +528,7 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) { // indexer its runtime of its own answers each first-view tool to the readers its manifest's callers lists name, at // its mark, and refuses every other account, the assigner's own included. firstViewsBeforeAnyNewFormState(t, b, map[string][]string{ - "assignments": {"n1.mesh-grants", "n1.node-tools", "n1.mesh-delivery-indexer"}, + "assignments": {"n1.mesh-grants", "n1.node-tools", "n1.mesh-delivery-indexer", "n1.mesh-delivery-builder"}, "seat-holders": {"n1.node-tools"}, "wants": {"n1.mesh-grants"}, }) diff --git a/modules/mesh-delivery-assigner/manifest_test.go b/modules/mesh-delivery-assigner/manifest_test.go index a536bdf1..df353e50 100644 --- a/modules/mesh-delivery-assigner/manifest_test.go +++ b/modules/mesh-delivery-assigner/manifest_test.go @@ -109,7 +109,7 @@ func TestManifest(t *testing.T) { t.Errorf("runs-as = %q: the bundle runs on the assigner's own account (ADR 0621 §1)", m.RunsAs) } // ADR 0621 §2: each first-view tool answers exactly its readers, never the assigner. - callers := map[string]string{"assignments": "mesh-grants,node-tools,mesh-delivery-indexer", "seat-holders": "node-tools", "wants": "mesh-grants"} + callers := map[string]string{"assignments": "mesh-grants,node-tools,mesh-delivery-indexer,mesh-delivery-builder", "seat-holders": "node-tools", "wants": "mesh-grants"} env := m.Build.Artifacts[0].Env for tool, list := range callers { if env[views.CallersEnv(tool)] != list || views.ParseCallers(env[views.CallersEnv(tool)]).Allows("n1."+m.Module) { diff --git a/modules/mesh-delivery-assigner/module.json b/modules/mesh-delivery-assigner/module.json index dffd8ee6..d54d1be6 100644 --- a/modules/mesh-delivery-assigner/module.json +++ b/modules/mesh-delivery-assigner/module.json @@ -212,7 +212,7 @@ "mesh-delivery-assigner" ], "env": { - "MESH_VIEW_CALLERS_ASSIGNMENTS": "mesh-grants,node-tools,mesh-delivery-indexer", + "MESH_VIEW_CALLERS_ASSIGNMENTS": "mesh-grants,node-tools,mesh-delivery-indexer,mesh-delivery-builder", "MESH_VIEW_CALLERS_SEAT_HOLDERS": "node-tools", "MESH_VIEW_CALLERS_WANTS": "mesh-grants" } diff --git a/modules/mesh-delivery-builder/buildrun/standing.go b/modules/mesh-delivery-builder/buildrun/standing.go index 6d1b1963..c4db87d1 100644 --- a/modules/mesh-delivery-builder/buildrun/standing.go +++ b/modules/mesh-delivery-builder/buildrun/standing.go @@ -125,20 +125,20 @@ func (g *ToolchainGate) placedTold() { } } -// FollowAssignments reads the seat mesh-module-assigner's assignments with its history into gate, says it settled, and follows it; changed +// FollowAssignments reads the seat mesh-module-assigner's assignments, in bucket (the seat's holder found at run time, `_assignments`; design 47 G A-119), 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 assignmentsFollowed, changed func(context.Context), logf func(string, ...any)) (<-chan error, error) { +func FollowAssignments(ctx context.Context, js process.BucketOpener, bucket string, gate assignmentsFollowed, changed func(context.Context), logf func(string, ...any)) (<-chan error, error) { errs := make(chan error, 1) - kv, err := js.KeyValue(ctx, AssignmentsBucket) + kv, err := js.KeyValue(ctx, bucket) if err != nil { - logf("the state %s cannot be read, so no node is counted by the assignments: %v", AssignmentsBucket, err) + logf("the state %s cannot be read, so no node is counted by the assignments: %v", bucket, err) return errs, nil } w, err := kv.WatchAll(ctx, jetstream.IncludeHistory()) if err != nil { - logf("the state %s cannot be watched, so no node is counted by the assignments: %v", AssignmentsBucket, err) + logf("the state %s cannot be watched, so no node is counted by the assignments: %v", bucket, err) return errs, nil } put := func(e jetstream.KeyValueEntry) bool { @@ -151,7 +151,7 @@ func FollowAssignments(ctx context.Context, js process.BucketOpener, gate assign return errs, ctx.Err() case e, ok := <-w.Updates(): if !ok { - return errs, errors.New("the watch of " + AssignmentsBucket + " ended before it settled") + return errs, errors.New("the watch of " + bucket + " ended before it settled") } if e == nil { settled = true @@ -173,7 +173,7 @@ func FollowAssignments(ctx context.Context, js process.BucketOpener, gate assign return case e, ok := <-w.Updates(): if !ok { - errs <- errors.New("the watch of " + AssignmentsBucket + " ended") + errs <- errors.New("the watch of " + bucket + " ended") return } if e != nil && put(e) { diff --git a/modules/mesh-delivery-builder/buildrun/standing_test.go b/modules/mesh-delivery-builder/buildrun/standing_test.go index f2ceee5d..51b688ee 100644 --- a/modules/mesh-delivery-builder/buildrun/standing_test.go +++ b/modules/mesh-delivery-builder/buildrun/standing_test.go @@ -86,7 +86,7 @@ func TestPlacedNodes(t *testing.T) { refused := errors.New("nats: permissions violation for publish to $JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_assignments") gate := NewPlacedNodes(logger.Printf) for _, bus := range []refusingBus{{err: refused}, {refuseOpen: true, err: refused}} { - if _, err := FollowAssignments(context.Background(), bus, gate, func(context.Context) {}, logger.Printf); err != nil { + if _, err := FollowAssignments(context.Background(), bus, AssignmentsBucket, gate, func(context.Context) {}, logger.Printf); err != nil { t.Errorf("a refused assignments stopped the start: %v", err) } } diff --git a/modules/mesh-delivery-builder/buildrun/toolchaingate_test.go b/modules/mesh-delivery-builder/buildrun/toolchaingate_test.go index 90f6a73a..39919219 100644 --- a/modules/mesh-delivery-builder/buildrun/toolchaingate_test.go +++ b/modules/mesh-delivery-builder/buildrun/toolchaingate_test.go @@ -249,7 +249,7 @@ func TestToolchainGate(t *testing.T) { 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 { + if _, err := FollowAssignments(context.Background(), bus, AssignmentsBucket, gate, func(context.Context) {}, logger.Printf); err != nil { t.Errorf("a refused assignments stopped the start: %v", err) } } diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/assignments.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/assignments.go new file mode 100644 index 00000000..a8dd75e1 --- /dev/null +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/assignments.go @@ -0,0 +1,41 @@ +package main + +import ( + "context" + "time" + + mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" + "git.novox.be/novox/mesh-sdk/go/seatlist" +) + +// assignerHolder is the module holding the seat mesh-module-assigner now, asked of the seat list through mesh-sdk's seatlist.HolderOf +// until it answers or ctx ends (design 47 G A-119, A-126): the builder names no assigner module, so a new holder is followed with no +// change of code. wait is the pause between two asks that failed; sleep says false when ctx ended during it. +func assignerHolder(ctx context.Context, call seatlist.HolderCaller, wait time.Duration, sleep func(context.Context, time.Duration) bool, logf func(string, ...any)) (string, bool) { + for { + holder, err := seatlist.HolderOf(call, mma.Seat) + if err == nil { + return holder, true + } + logf("who holds the seat %s is not known yet, so no node is counted by the assignments: %v", mma.Seat, err) + if !sleep(ctx, wait) { + return "", false + } + } +} + +// assignmentsNames are the names made from the holder: the view's first-view tool `.assignments` and the +// bucket `_assignments` of the state, both made from the holder found at run time. +type assignmentsNames struct{ Holder, Tool, Bucket string } + +func assignmentsOf(holder string) assignmentsNames { + return assignmentsNames{Holder: holder, Tool: holder + "." + mma.StateAssignments, Bucket: holder + "_" + mma.StateAssignments} +} + +// sleepFor waits d, a context's bound, and says whether ctx is still alive. +func sleepFor(ctx context.Context, d time.Duration) bool { + w, cancel := context.WithTimeout(ctx, d) + defer cancel() + <-w.Done() + return ctx.Err() == nil +} diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/assignments_test.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/assignments_test.go new file mode 100644 index 00000000..2fc7e34a --- /dev/null +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/assignments_test.go @@ -0,0 +1,37 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/seatlist" +) + +// The builder finds the assigner's holder at run time: asked again while the seat list does not answer, and the names of the view +// are made from the holder (design 47 G A-119), not fixed in code. +func TestTheAssignerHolderIsFoundAtRunTime(t *testing.T) { + asks, slept := 0, 0 + call := func(key string, _ any) (json.RawMessage, error) { + if key != seatlist.HolderKey() { + t.Errorf("asked %q, want %q", key, seatlist.HolderKey()) + } + if asks++; asks == 1 { + return nil, errors.New("down") + } + return json.RawMessage(`{"seats":[{"seat":"mesh-module-assigner","holders":[{"module":"acme-assigner"}]}]}`), nil + } + holder, ok := assignerHolder(context.Background(), call, time.Second, func(context.Context, time.Duration) bool { slept++; return true }, func(string, ...any) {}) + if !ok || holder != "acme-assigner" || slept != 1 { + t.Fatalf("holder = %q, %v after %d pauses, want acme-assigner after one", holder, ok, slept) + } + if n := assignmentsOf(holder); n.Tool != "acme-assigner.assignments" || n.Bucket != "acme-assigner_assignments" { + t.Errorf("names = %+v", n) + } + if _, ok := assignerHolder(context.Background(), func(string, any) (json.RawMessage, error) { return nil, errors.New("down") }, time.Second, + func(context.Context, time.Duration) bool { return false }, func(string, ...any) {}); ok { + t.Error("a holder was found though ctx ended") + } +} diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/integration_test.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/integration_test.go index 431f1873..73977984 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/integration_test.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/integration_test.go @@ -58,6 +58,8 @@ accounts { } ` +const testAssigner = "acme-assigner" // a holder of the seat mesh-module-assigner no code names + const ( commit = "7a74cee83f88e6585a5511c3cc53bdf0f5303d20" fp = "src2:3333333333333333333333333333333333333333333333333333333333333333" @@ -288,7 +290,16 @@ func startBedWith(t *testing.T, closed bool) *bed { b.catalog.buildSeats(t, ctl, map[string]string{"node-build-go": testGo, "node-build-node": testNode, "node-build-image": testImage, "git": "mesh-delivery-forge-bridge", "mesh-catalog": "mesh-delivery-indexer"}) // the floor's sources are the holders of git and mesh-catalog readers := []string{"n1_" + builds.BuilderModule, "n2_" + builds.BuilderModule} - viewstest.Serve(t, ctx, js, ctl, assignerModule, "assignments", assignerModule+".assigner_read_assignments", readers...) + // The seat mesh-module-assigner's holder is read from the seat's verb assignments, as the builder finds it at run time (A-119). + assignerSub, err := ctl.Subscribe("mesh.seat.mesh-module-assigner.call.assignments.>", func(m *nats.Msg) { + reply, _ := json.Marshal(map[string]any{"result": map[string]any{"seats": []map[string]any{{"seat": "mesh-module-assigner", "holders": []map[string]string{{"module": testAssigner}}}}}, "node": "ctl"}) + _ = m.Respond(reply) + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = assignerSub.Unsubscribe() }) + viewstest.Serve(t, ctx, js, ctl, testAssigner, "assignments", testAssigner+".assignments", readers...) b.builds = mk(builds.BuilderBuildsBucket, builds.BuilderBuildsHistory) b.instances = mk(instancesBucket, 1) // The state bounds, whose keys have each its own time to live: an ask's step bounds (design 47 G A-77, A-118) are kept in it. diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go index cf83ccb0..de27d334 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go @@ -18,6 +18,7 @@ import ( "syscall" "time" + mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" "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" @@ -119,10 +120,6 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro // catalog half's claims, found at each use. seatGate := newSeatGate() seats := buildrun.NewBuildSeats(seatGate.definitions, versions.HolderOf(branches)) - assignments, err := reader.Follow(assignerModule, "assignments", assignerModule+".assigner_read_assignments") - if err != nil { - return err - } bound := defaultStepBound if v := strings.TrimSpace(getenv("MESH_BUILDER_STEP_BOUND")); v != "" { if bound, err = time.ParseDuration(v); err != nil { @@ -244,7 +241,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro decided := &decisions{subject: oc.SubjectDecided(cred.Module)} retakeActed := awaitedReserved{awaitedVersions{b: actedKV, bucket: versions.ActedBucket}} asker := &operatorask.Asker[oc.Warrant]{Module: cred.Module, Call: calls.Call, Record: calls.Ask, Publish: publishVia(ctx, js), Subscribe: decided.hear(logger.Printf), - Contract: operatorask.Contract{Seat: oc.Seat, StateAsks: oc.StateAsks}, + Contract: operatorask.Contract{Seat: oc.Seat, StateAsks: oc.StateAsks}, AskSubject: oc.AcceptSubjectAsk, DecidedSubject: oc.SubjectDecided} operator := &build.OperatorAsk{Builder: b, Acted: retakeActed, Ctx: ctx, Logf: logger.Printf, Asker: asker, Client: &occ.Client{Module: cred.Module, State: calls.State("asked"), @@ -346,15 +343,26 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro // asked when the bound ends), is call-unanswered in the builder's findings (issue 1966, ADR 0668 §3, ADR 0596 §2). reads.callsOf(found, pubBoundsKV.KV, heardBy(calls.CallKept), logger.Printf) b.Forms = reads.built - // The assignments names the nodes the builder counts, once its first view is answered. + // The assignments names the nodes the builder counts, once the seat list names the seat's holder and its first view is answered: + // the view is the holder's first-view tool, followed by its views family (A-119, A-131); no assigner module is named. assignmentsErrs := make(chan error, 1) go func() { + holder, ok := assignerHolder(ctx, calls.Call, 5*time.Second, sleepFor, logger.Printf) + if !ok { + return + } + names := assignmentsOf(holder) + assignments, err := reader.Follow(names.Holder, mma.StateAssignments, names.Tool) + if err != nil { + assignmentsErrs <- err + return + } select { case <-ctx.Done(): return case <-assignments.Follower().Ready(): } - pe, err := buildrun.FollowAssignments(ctx, reader, gate, b.NodesChanged, logger.Printf) + pe, err := buildrun.FollowAssignments(ctx, reader, names.Bucket, gate, b.NodesChanged, logger.Printf) if err != nil { assignmentsErrs <- err return diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/names.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/names.go index 79b2a481..d53dc33e 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/names.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/names.go @@ -2,7 +2,6 @@ package main import ( "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" - "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" ) // The holders and buckets the builder names, taken from its own manifest (module.json: its `reads`) and from the holders' @@ -15,8 +14,6 @@ const ( // indexerModule is the holder of the states modules, manifests, candidates and packages the builder follows. indexerModule = "mesh-delivery-indexer" - // assignerModule is the holder of the state assignments. - assignerModule = buildrun.AssignerModule ) // namedTreePrefix opens the tree input of a module whose every artifact names its paths. diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/names_test.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/names_test.go index 0e646bd0..5447f9bc 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/names_test.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/names_test.go @@ -38,15 +38,14 @@ func TestTheNamesAreTheManifests(t *testing.T) { t.Errorf("the builder follows %s, an indexer state, its manifest consumes it", c) } } - for _, view := range []string{"mesh-catalog.catalog-revision", "mesh-catalog.registration-changed", "mesh-delivery.source-moved", assignerModule + ".views.assignments"} { + for _, view := range []string{"mesh-catalog.catalog-revision", "mesh-catalog.registration-changed", "mesh-delivery.source-moved", "seat:mesh-module-assigner.views.assignments"} { if !slices.Contains(m.Consumes, view) { t.Errorf("the builder follows %s, its manifest consumes %v", view, m.Consumes) } } for want, got := range map[string]string{ - "mesh-build-go_steps": buildrun.StepsBucket("mesh-build-go"), - buildrun.ToolchainsHeardBucket: "mesh-delivery-builder_toolchains-heard", - "mesh-delivery-assigner_assignments": buildrun.AssignmentsBucket, + "mesh-build-go_steps": buildrun.StepsBucket("mesh-build-go"), + buildrun.ToolchainsHeardBucket: "mesh-delivery-builder_toolchains-heard", } { if want != got { t.Errorf("a bucket is %q, want %q", got, want) diff --git a/modules/mesh-delivery-builder/manifest_test.go b/modules/mesh-delivery-builder/manifest_test.go index 8fbbe0b4..976dfedd 100644 --- a/modules/mesh-delivery-builder/manifest_test.go +++ b/modules/mesh-delivery-builder/manifest_test.go @@ -119,8 +119,8 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1 // 1887/A4c: the builder chooses no node, so it reads neither presence nor have, invokes no build module's tool and // holds no capacity; it puts asks on the three build seats and reads their steps (ADR 0525 §1, §4, §5). Whether a // node's machine is heard it asks the watchdogs seat's presence (issue 1966, ADR 0596 §2, ADR 0668 §3). - if want := []string{"mesh-delivery-assigner.assignments", - "node-build-go.steps", "node-build-image.steps", "node-build-node.steps"}; !reflect.DeepEqual(sorted(m.Reads), want) { + if want := []string{"node-build-go.steps", "node-build-image.steps", "node-build-node.steps", + "seat:mesh-module-assigner.assignments"}; !reflect.DeepEqual(sorted(m.Reads), want) { t.Errorf("reads = %v, want %v", m.Reads, want) } if want := []string{"node-build-go", "node-build-image", "node-build-node", "operator-channel"}; !reflect.DeepEqual(sorted(m.Uses), want) { @@ -133,15 +133,15 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1 // read; the catalog half (the modules, their manifests, their packages and the package names a package is numbered under) is // read through the seat mesh-catalog's branch follower (design 47 G §348, §614), and the indexer's states not at all. for _, r := range m.Reads { - // The toolchain gate follows the assigner's assignments through meshmoduleassignerclient (buildrun.AssignmentsBucket), so - // it declares that one read, as the console and route-proxy do; everything else of theirs is asked by tools. - if strings.HasPrefix(r, "mesh-delivery-indexer.") || (strings.HasPrefix(r, "mesh-delivery-assigner.") && r != "mesh-delivery-assigner.assignments") { + // The toolchain gate follows the assigner's assignments in the bucket of the seat's holder, so it declares that one read by the + // seat (A-116, A-119); no module is named and everything else of the assigner's is asked by tools. + if strings.HasPrefix(r, "mesh-delivery-indexer.") || strings.HasPrefix(r, "mesh-delivery-assigner.") { t.Errorf("reads %s: the indexer's and the assigner's state is read by their tools and views families alone", r) } } // Design 51 *The floor*: the floor's three sources, the registry seats' versions, git.tags and indexer_requirements. // The watchdogs seat's presence says a node's machine is heard (issue 1966, ADR 0596 §2, ADR 0668 §3). - if want := []string{"mesh-delivery-assigner.assigner_read_assignments", "seat:mesh-catalog.entry", "seat:mesh-catalog.registrations", "seat:mesh-catalog.seat-definitions", "seat:mesh-catalog.revision", "seat:mesh-delivery.source", "seat:container-image-registry.publish", "seat:container-image-registry.remove", "seat:container-image-registry.versions", + if want := []string{"seat:mesh-catalog.entry", "seat:mesh-catalog.registrations", "seat:mesh-catalog.seat-definitions", "seat:mesh-catalog.revision", "seat:mesh-delivery.source", "seat:container-image-registry.publish", "seat:container-image-registry.remove", "seat:container-image-registry.versions", "seat:git.tag", "seat:git.tags", "seat:go-package-registry.publish", "seat:go-package-registry.remove", "seat:go-package-registry.versions", "seat:mesh-artifact-sweep.held", "seat:mesh-builds.reserved", "seat:mesh-catalog.versions", "seat:mesh-hand-acts.hand-act", "seat:mesh-module-assigner.assignments", "seat:mesh-watchdogs.presence", "seat:node-build-go.drop", "seat:node-build-image.drop", "seat:node-build-node.drop", "seat:node-engine.manifest-reads", "seat:npm-package-registry.publish", "seat:npm-package-registry.remove", "seat:npm-package-registry.versions", "seat:oci-artifact-registry.publish", "seat:oci-artifact-registry.remove", "seat:oci-artifact-registry.versions"}; !reflect.DeepEqual(sorted(m.Invokes), sorted(want)) { @@ -156,7 +156,7 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1 if want := []string{"container-image-registry.kept", "container-image-registry.published", "container-image-registry.refused", "container-image-registry.removed", "go-package-registry.kept", "go-package-registry.published", "go-package-registry.refused", "go-package-registry.removed", "mesh-artifact-sweep.artifacts-gone", "mesh-catalog.manifest-accepted", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", "mesh-catalog.version-released", "mesh-catalog.versions-filled", - "mesh-conditions.condition-cleared", "mesh-conditions.findings-asked", "mesh-delivery-assigner.views.assignments", + "mesh-conditions.condition-cleared", "mesh-conditions.findings-asked", "seat:mesh-module-assigner.views.assignments", "mesh-catalog.catalog-revision", "mesh-catalog.registration-changed", "mesh-delivery.source-moved", "node-build-go.toolchain-reported", "node-build-node.toolchain-reported", "node-engine.manifest-reads-changed", "node-engine.running", "npm-package-registry.kept", "npm-package-registry.published", "npm-package-registry.refused", "npm-package-registry.removed", "oci-artifact-registry.kept", "oci-artifact-registry.published", "oci-artifact-registry.refused", "oci-artifact-registry.removed", "operator-channel.decided"}; !reflect.DeepEqual(sorted(m.Consumes), sorted(want)) { diff --git a/modules/mesh-delivery-builder/module.json b/modules/mesh-delivery-builder/module.json index 9702b31b..0c5ad37b 100644 --- a/modules/mesh-delivery-builder/module.json +++ b/modules/mesh-delivery-builder/module.json @@ -73,7 +73,7 @@ "oci-artifact-registry.published", "oci-artifact-registry.refused", "oci-artifact-registry.removed", - "mesh-delivery-assigner.views.assignments", + "seat:mesh-module-assigner.views.assignments", "mesh-catalog.catalog-revision", "mesh-catalog.registration-changed", "mesh-delivery.source-moved" @@ -99,7 +99,6 @@ "seat:npm-package-registry.versions", "seat:oci-artifact-registry.remove", "seat:oci-artifact-registry.versions", - "mesh-delivery-assigner.assigner_read_assignments", "seat:container-image-registry.publish", "seat:go-package-registry.publish", "seat:npm-package-registry.publish", @@ -160,7 +159,7 @@ "node-build-go.steps", "node-build-image.steps", "node-build-node.steps", - "mesh-delivery-assigner.assignments" + "seat:mesh-module-assigner.assignments" ], "uses": [ "node-build-go", From a5bb2ba5b5a003d86fc2fd4089e3eabdc4a3be29 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 01:30:37 +0100 Subject: [PATCH 5/7] fix9b2-2: the builder's process answers the seat mesh-builds' verb reserved; the tools bundle no longer serves it serves beside the process in the manifest (no bus), the catalog half's carriers and seat mesh-catalog.versions used in the process; builder_build stays the bundle's. Builds: design 47 G A-115, A-127, A-87 --- .../cmd/mesh-delivery-builder-tools/main.go | 15 +---- .../mesh-delivery-builder-tools/reserved.go | 21 +------ .../reservedname_test.go | 30 +++------- .../mesh-delivery-builder/integration_test.go | 11 ++++ .../cmd/mesh-delivery-builder/main.go | 8 +++ .../cmd/mesh-delivery-builder/reservedverb.go | 60 +++++++++++++++++++ .../reservedverb_test.go | 45 ++++++++++++++ .../mesh-delivery-builder/manifest_test.go | 15 ++++- modules/mesh-delivery-builder/module.json | 4 +- 9 files changed, 153 insertions(+), 56 deletions(-) create mode 100644 modules/mesh-delivery-builder/cmd/mesh-delivery-builder/reservedverb.go create mode 100644 modules/mesh-delivery-builder/cmd/mesh-delivery-builder/reservedverb_test.go diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/main.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/main.go index f2db1a07..2ba23cde 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/main.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/main.go @@ -17,7 +17,6 @@ import ( "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/meshbuilds" - "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" "git.novox.be/novox/mesh-delivery/sdk/buildstep" @@ -404,18 +403,6 @@ func main() { } return nil }, versions.HolderOf(branches)) - carriers := versions.CarriersFrom(versions.EntriesOf(branches)) - released := func(_ context.Context, module string) ([]meshcatalog.VersionEntry, error) { - raw, err := stdio.Ask(catalogVersionsVerb, meshcatalog.VersionsInput{Module: module}) - if err != nil { - return nil, err - } - var a meshcatalog.VersionsAnswer - if err := json.Unmarshal(raw, &a); err != nil { - return nil, fmt.Errorf("the seat mesh-catalog's versions answered what does not read: %w", err) - } - return a.Versions, nil - } reserve := func(name, version string) (meshbuilds.ReservedAnswer, error) { raw, err := stdio.Ask(reservedVerb, meshbuilds.ReservedInput{Name: name, Version: version}) if err != nil { @@ -427,7 +414,7 @@ func main() { tools := Tools(r, put, askQueue(stdio.State(build.QueuesState), 25*time.Second), buildAgain, stdio.Log(builds.BuilderLog).Since, time.Now, vr, fr, ofr, reserve) tools = append(tools, buildTool(&build.BuildReader{Builds: read(stdio.State("builds")), Entry: entryOf(stdio.State("builds")), Asked: read(stdio.State("asked")), Newest: newestVia(stdio.Ask), Steps: r})) - tools = append(tools, reservedTools(rr, buildAgain, time.Now, meshhandactsclient.Recorder{Ask: stdio.Ask}, carriers, released)...) + tools = append(tools, reservedTools(rr, buildAgain, time.Now, meshhandactsclient.Recorder{Ask: stdio.Ask})...) qs, as := stdio.State(versions.PublishQueueState), stdio.State(versions.ActedState) tools = append(tools, republishTools(readerOf(qs), readerOf(as), buildAgain, time.Now, meshhandactsclient.Recorder{Ask: stdio.Ask})...) ws := stdio.State(build.WaitsState) diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/reserved.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/reserved.go index d6a45807..983a54f4 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/reserved.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/reserved.go @@ -1,7 +1,6 @@ package main import ( - "context" "errors" "fmt" "strings" @@ -14,31 +13,17 @@ import ( "mesh-delivery-builder/internal/versions" ) -// reservedTools are builder_reserved, builder_reserved_drop and the seat mesh-builds' verb reserved (design 47 G section -// 369), served as tools of the bundle. The drop is a hand act: it is recorded in the hand-act log first, then written to +// reservedTools are builder_reserved and builder_reserved_drop (design 47 G section 369), served as tools of the bundle. The drop is a hand act: it is recorded in the hand-act log first, then written to // reasks for the builder, which owns the reservations, to answer. // -// The verb reserved given a name finds the module from the catalog half's view the bundle follows (carriers) and, for a version -// no reservation holds, asks the seat mesh-catalog's verb versions (released); the builder's process serves no call (A-70). -func reservedTools(rr versions.Reader, ask queueAsker, now func() time.Time, acts meshhandactsclient.Recorder, carriers func(context.Context, string) ([]versions.Built, error), released versions.CatalogVersions) []stdio.Tool { +// The seat's verb reserved is the builder's process's (design 47 G A-115, A-127), not the bundle's. +func reservedTools(rr versions.Reader, ask queueAsker, now func() time.Time, acts meshhandactsclient.Recorder) []stdio.Tool { str := func(args map[string]any, k string) string { s, _ := args[k].(string); return strings.TrimSpace(s) } return []stdio.Tool{ {Name: versions.ReservedTool, Description: versions.ReservedDescription, Input: map[string]any{"type": "object", "properties": map[string]any{ "module": map[string]any{"type": "string", "description": "only this module's reservations"}}}, Run: func(args map[string]any) (any, error) { return versions.AnswerReserved(rr, str(args, "module")) }}, - {Name: versions.ReservedVerb, Description: "The seat mesh-builds' verb reserved: a module's reserved version with each artifact's digest once built " + - "(design 47 G section 369), for a registry holder deciding whether to publish. Read-only.", - Input: map[string]any{"type": "object", "properties": map[string]any{ - "module": map[string]any{"type": "string", "description": "the module; give this or name"}, - "name": map[string]any{"type": "string", "description": "a package's or an image's name in its ecosystem, in place of module"}, - "version": map[string]any{"type": "string", "description": "the version, when asked for a particular one"}}}, - Run: func(args map[string]any) (any, error) { - if name := str(args, "name"); name != "" { - return versions.AnswerReservedByName(context.Background(), rr, carriers, released, name, str(args, "version")) - } - return versions.AnswerReservedVerb(rr, str(args, "module"), str(args, "version")) - }}, {Name: versions.ReservedDropTool, Description: versions.ReservedDropDescription, Input: map[string]any{"type": "object", "required": []string{"module", "series", "why"}, "properties": map[string]any{ "module": map[string]any{"type": "string", "description": "the module"}, diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/reservedname_test.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/reservedname_test.go index e23c2dba..26b51535 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/reservedname_test.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/reservedname_test.go @@ -1,41 +1,27 @@ package main import ( - "context" "encoding/json" "testing" "time" "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshbuilds" - "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "mesh-delivery-builder/internal/build" "mesh-delivery-builder/internal/versions" ) -// Design 47 G §572, §582, A-70: the bundle's verb reserved takes a name and answers from the view it follows and the catalog's -// versions, and builder_versions, asked by a name, calls reserved and answers what it answers. -func TestReservedByNameIsTheBundlesAndBuilderVersionsCallsIt(t *testing.T) { +// Design 47 G §572, §582, A-70: builder_versions, asked by a name, calls reserved and answers what it answers. +func TestBuilderVersionsByNameCallsReserved(t *testing.T) { none := versions.Reader{Keys: func() ([]string, error) { return nil, nil }, Get: func(string) (json.RawMessage, bool, error) { return nil, false, nil }} - carriers := func(_ context.Context, name string) ([]versions.Built, error) { - if name == "@novox/mesh-sdk" { - return []versions.Built{{Module: "mesh-sdk", Artifact: "ts"}}, nil - } - return nil, nil - } - released := func(_ context.Context, module string) ([]meshcatalog.VersionEntry, error) { - return []meshcatalog.VersionEntry{{Module: module, Version: "0.1.2", Artifacts: []meshcatalog.VersionArtifact{{Artifact: "mesh-sdk:ts", Digest: "sha256:dd"}}}}, nil - } - acts := meshhandactsclient.Recorder{Ask: func(string, any) (json.RawMessage, error) { return json.RawMessage(`{}`), nil }} - byName := map[string]func(map[string]any) (any, error){} - for _, tl := range reservedTools(none, nil, time.Now, acts, carriers, released) { - byName[tl.Name] = tl.Run - } - got, err := byName[versions.ReservedVerb](map[string]any{"name": "@novox/mesh-sdk", "version": "0.1.2"}) want := meshbuilds.ReservedAnswer{Module: "mesh-sdk", Version: "0.1.2", Released: true, Artifacts: []meshbuilds.ReservedAnswerArtifactsItem{{Artifact: "mesh-sdk:ts", Digest: "sha256:dd"}}} - if err != nil || got.(meshbuilds.ReservedAnswer).Released != want.Released || got.(meshbuilds.ReservedAnswer).Artifacts[0] != want.Artifacts[0] { - t.Fatalf("reserved by name = %+v (%v), want %+v", got, err, want) + // The bundle serves no verb reserved any more: the builder's process does (design 47 G A-115, A-127). + acts := meshhandactsclient.Recorder{Ask: func(string, any) (json.RawMessage, error) { return json.RawMessage(`{}`), nil }} + for _, tl := range reservedTools(none, nil, time.Now, acts) { + if tl.Name == "reserved" { + t.Error("the bundle serves the verb reserved, which the builder's process answers") + } } var called []string reserve := func(name, version string) (meshbuilds.ReservedAnswer, error) { diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/integration_test.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/integration_test.go index 73977984..fd9cd17c 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/integration_test.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/integration_test.go @@ -28,6 +28,7 @@ import ( runner "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshbuilds" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" @@ -556,6 +557,16 @@ func TestBuilderMakesADoneKeyWithTheDigests(t *testing.T) { // 1607/AC7 if v.Toolchains["go"].Version != "go1.27.1" || v.Toolchains["typescript"].Version != "tsc5" { t.Errorf("toolchains = %+v", v.Toolchains) } + // A-115: the builder's process answers the seat mesh-builds' verb reserved over the bus; the reservation of a module it never + // built is answered in words (here that the bed's state reserved is not on the bus), never left unanswered. + if conn, err := process.Connect(process.Account{URL: b.srvURL, Module: "mesh-delivery-view", User: "ctl", Password: "pw"}); err != nil { + t.Fatal(err) + } else { + defer conn.Close() + if _, err := conn.Call("seat:mesh-builds.reserved", meshbuilds.ReservedInput{Module: "never-built"}); err == nil || !strings.Contains(err.Error(), "_reserved") { + t.Errorf("reserved of a module never built = %v, want the process's answer naming its state reserved", err) + } + } // ADR 0618 §1: the done key names the reference forms it is written in. if len(v.Needs) != 1 || v.Needs[0] != "form:artifact-store" { t.Errorf("needs = %v, want the first form", v.Needs) diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go index de27d334..59e3138e 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go @@ -403,6 +403,14 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro // modules again (design 47 G A-82, §204). // The runner's build step ask, the builder's one entry point for a candidate (design 47 G A-97). b.StepEntry = newEntryWaits(branches, half.manifests).entry + // The seat mesh-builds' verb reserved, answered here (A-115, A-127): the reservations, the catalog half's carriers and the seat + // mesh-catalog's versions. + stopReserved, err := calls.AnswerSeat(meshbuilds.Seat, map[string]process.SeatHandler{ + meshbuilds.VerbReserved: reservedVerb(ctx, reservedReader(ctx, reserved.r.Store), versions.CarriersFrom(versions.EntriesOf(branches)), catalogReleased(calls.Call))}) + if err != nil { + return fmt.Errorf("answering the verb reserved: %w", err) + } + defer stopReserved() if err := serveSteps(ctx, conn, js, cred, b, logger.Printf); err != nil { return err } diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/reservedverb.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/reservedverb.go new file mode 100644 index 00000000..49bd2b14 --- /dev/null +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/reservedverb.go @@ -0,0 +1,60 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshbuilds" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/process" + + "mesh-delivery-builder/internal/versions" +) + +// The seat mesh-builds' verb reserved is answered by the builder's own process (design 47 G A-115, A-127; A-87: builder_build stays +// its tools bundle's): a module's reserved version with each artifact's digest once built, for a registry holder deciding whether to +// publish. Given a name it finds the module from the catalog half the process follows (carriers) and, for a version no reservation +// holds, asks the seat mesh-catalog's verb versions. The process's manifest declares the verb in `serves` beside its process. + +// reservedReader is the state reserved read through its store, as the verb's answers read it. +func reservedReader(ctx context.Context, store versions.Store) versions.Reader { + return versions.Reader{ + Keys: func() ([]string, error) { return store.KeysOf(ctx, versions.AllFilter) }, + Get: func(key string) (json.RawMessage, bool, error) { + v, _, found, err := store.Get(ctx, key) + return v, found, err + }} +} + +// reservedVerb is the handler of the verb reserved. +func reservedVerb(ctx context.Context, rr versions.Reader, carriers func(context.Context, string) ([]versions.Built, error), released versions.CatalogVersions) process.SeatHandler { + return func(_ string, body json.RawMessage) (any, error) { + var in meshbuilds.ReservedInput + if len(body) > 0 { + if err := json.Unmarshal(body, &in); err != nil { + return nil, fmt.Errorf("the arguments do not fit the verb reserved: %w", err) + } + } + if name := strings.TrimSpace(in.Name); name != "" { + return versions.AnswerReservedByName(ctx, rr, carriers, released, name, in.Version) + } + return versions.AnswerReservedVerb(rr, in.Module, in.Version) + } +} + +// catalogReleased are a module's released versions, asked of the seat mesh-catalog's verb versions through call. +func catalogReleased(call toolCaller) versions.CatalogVersions { + return func(_ context.Context, module string) ([]meshcatalog.VersionEntry, error) { + raw, err := call("seat:"+meshcatalog.Seat+".versions", meshcatalog.VersionsInput{Module: module}) + if err != nil { + return nil, err + } + var a meshcatalog.VersionsAnswer + if err := json.Unmarshal(raw, &a); err != nil { + return nil, fmt.Errorf("the seat mesh-catalog's versions answered what does not read: %w", err) + } + return a.Versions, nil + } +} diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/reservedverb_test.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/reservedverb_test.go new file mode 100644 index 00000000..ef87f65e --- /dev/null +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/reservedverb_test.go @@ -0,0 +1,45 @@ +package main + +import ( + "context" + "encoding/json" + "testing" + + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshbuilds" + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + + "mesh-delivery-builder/internal/versions" +) + +// Design 47 G A-115, A-127: the builder's process answers the verb reserved, given a name, from the catalog half's carriers and the +// seat mesh-catalog's versions, and given a module from its reservations. +func TestTheProcessAnswersReserved(t *testing.T) { + none := versions.Reader{Keys: func() ([]string, error) { return nil, nil }, Get: func(string) (json.RawMessage, bool, error) { return nil, false, nil }} + carriers := func(_ context.Context, name string) ([]versions.Built, error) { + if name == "@novox/mesh-sdk" { + return []versions.Built{{Module: "mesh-sdk", Artifact: "ts"}}, nil + } + return nil, nil + } + var asked []string + released := catalogReleased(func(key string, _ any) (json.RawMessage, error) { + asked = append(asked, key) + return json.Marshal(meshcatalog.VersionsAnswer{Versions: []meshcatalog.VersionEntry{{Module: "mesh-sdk", Version: "0.1.2", + Artifacts: []meshcatalog.VersionArtifact{{Artifact: "mesh-sdk:ts", Digest: "sha256:dd"}}}}}) + }) + handle := reservedVerb(context.Background(), none, carriers, released) + got, err := handle("", json.RawMessage(`{"name":"@novox/mesh-sdk","version":"0.1.2"}`)) + a, ok := got.(meshbuilds.ReservedAnswer) + if err != nil || !ok || !a.Released || a.Module != "mesh-sdk" || len(a.Artifacts) != 1 || a.Artifacts[0].Digest != "sha256:dd" { + t.Fatalf("reserved by name = %+v (%v)", got, err) + } + if len(asked) != 1 || asked[0] != "seat:mesh-catalog.versions" { + t.Errorf("the released versions were asked of %v, want the seat mesh-catalog's versions", asked) + } + if _, err := handle("", json.RawMessage(`{"module":"mesh-sdk"}`)); err == nil { + t.Error("a module with no reservation was answered") + } + if _, err := handle("", json.RawMessage(`{"module":`)); err == nil { + t.Error("arguments that are no JSON were answered") + } +} diff --git a/modules/mesh-delivery-builder/manifest_test.go b/modules/mesh-delivery-builder/manifest_test.go index 976dfedd..1311e4e5 100644 --- a/modules/mesh-delivery-builder/manifest_test.go +++ b/modules/mesh-delivery-builder/manifest_test.go @@ -170,7 +170,7 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1 } // 1945: the builder's tools (builder_seat_queue: note 62), served by its tools bundle, and the event it says when it asks an ask again. if want := []string{"builder_announce_again", "builder_ask_again", "builder_bounds", "builder_build", "builder_build_again", "builder_builds_since", "builder_engine_reads", "builder_first_view_again", "builder_first_views", - "builder_floor_again", "builder_floor_waits", "builder_old_form", "builder_publish_again", "builder_publish_queue", "builder_release", "builder_reserved", "builder_reserved_drop", "builder_seat_list", "builder_seat_list_again", "builder_seat_queue", "builder_stuck", "builder_toolchains", "builder_toolchains_drop", "builder_versions", "builder_waits", "reserved"}; !reflect.DeepEqual(sorted(m.Tools), want) { + "builder_floor_again", "builder_floor_waits", "builder_old_form", "builder_publish_again", "builder_publish_queue", "builder_release", "builder_reserved", "builder_reserved_drop", "builder_seat_list", "builder_seat_list_again", "builder_seat_queue", "builder_stuck", "builder_toolchains", "builder_toolchains_drop", "builder_versions", "builder_waits"}; !reflect.DeepEqual(sorted(m.Tools), want) { t.Errorf("tools = %v, want %v", m.Tools, want) } // Design 47 G §672: the toolchain waits' stream, kept 7 days within 16 MiB, and the bound's setting, 60 minutes. @@ -195,6 +195,19 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1 if len(m.Claims) != 1 || m.Claims[0].Name != "mesh-builds" || m.Claims[0].Scope != "mesh" || !reflect.DeepEqual(m.Claims[0].Serves, []string{"reserved"}) { t.Errorf("claims = %+v, want mesh-builds, scope mesh, serving reserved", m.Claims) } + // A-115: the process answers the verb itself and says so in `serves` beside its process, with no `bus`; the bundle serves no tool named so. + var resources []map[string]any + if err := json.Unmarshal(top["resources"], &resources); err != nil { + t.Fatal(err) + } + for _, r := range resources { + if r["type"] == "process" && !reflect.DeepEqual(r["serves"], []any{"seat:mesh-builds.reserved"}) { + t.Errorf("the process serves %v, want seat:mesh-builds.reserved (A-115)", r["serves"]) + } + } + if slices.Contains(m.Tools, "reserved") { + t.Error("the tools bundle serves reserved, which the process answers") + } for _, k := range []string{"provides", "requires", "defines-seats", "seat", "seats", "bus"} { if _, ok := top[k]; ok { t.Errorf("the manifest declares %q: the builder has none", k) diff --git a/modules/mesh-delivery-builder/module.json b/modules/mesh-delivery-builder/module.json index 0c5ad37b..74a51154 100644 --- a/modules/mesh-delivery-builder/module.json +++ b/modules/mesh-delivery-builder/module.json @@ -35,7 +35,6 @@ "builder_toolchains_drop", "builder_waits", "builder_versions", - "reserved", "builder_first_views", "builder_first_view_again" ], @@ -208,6 +207,9 @@ "type": "process", "name": "mesh-delivery-builder", "artifact": "program", + "serves": [ + "seat:mesh-builds.reserved" + ], "run": [ "./mesh-delivery-builder" ], From f299a1a8b9265da79f7be66d2afac9bfad45fad1 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 01:31:19 +0100 Subject: [PATCH 6/7] fix9b2-6: the publish queue sends in the order it queued, whatever the clock says between two versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G §588 (newest first) --- .../internal/versions/republish.go | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/modules/mesh-delivery-builder/internal/versions/republish.go b/modules/mesh-delivery-builder/internal/versions/republish.go index 07dc30cc..e5950b5d 100644 --- a/modules/mesh-delivery-builder/internal/versions/republish.go +++ b/modules/mesh-delivery-builder/internal/versions/republish.go @@ -118,6 +118,22 @@ type Republisher struct { mu sync.Mutex runs map[string]map[string][]string // node -> holder module -> the registry seats it claims busy map[string]bool + // lastQueued is the newest time a version was queued at: a clock that has not moved between two versions (a coarse clock, + // or a fixed one) must not make the queue's order the order of its keys, so each is queued after the last. + lastQueued time.Time +} + +// queuedAt is the time a version is queued at: now, or a nanosecond after the last one queued when now is not after it, so the +// order the versions were queued in (newest reservation first) is the order they are sent in. +func (r *Republisher) queuedAt() time.Time { + r.mu.Lock() + defer r.mu.Unlock() + at := r.now() + if !at.After(r.lastQueued) { + at = r.lastQueued.Add(time.Nanosecond) + } + r.lastQueued = at + return at } func (r *Republisher) now() time.Time { @@ -349,7 +365,7 @@ func (r *Republisher) enqueue(ctx context.Context, v ReservedVersion, holder str } else if found { return false, nil } - raw, _ := json.Marshal(QueuedPublish{V: 1, Holder: holder, Module: v.Module, Version: v.Version, Artifacts: arts, Queued: r.now()}) + raw, _ := json.Marshal(QueuedPublish{V: 1, Holder: holder, Module: v.Module, Version: v.Version, Artifacts: arts, Queued: r.queuedAt()}) used, err := r.Queue.Bytes(ctx) if err != nil { return false, fmt.Errorf("the size of %s cannot be read: %w", PublishQueueBucket, err) From 9757688f5669af9cc7dd6015e133f025af56c21b Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 01:32:40 +0100 Subject: [PATCH 7/7] fix9b2-1b: FollowAssignmentsIn takes the bucket; FollowAssignments keeps the fixed one for the checker, which another change owns Builds: design 47 G A-119 --- modules/mesh-delivery-builder/buildrun/standing.go | 11 +++++++++-- .../mesh-delivery-builder/buildrun/standing_test.go | 2 +- .../buildrun/toolchaingate_test.go | 2 +- .../cmd/mesh-delivery-builder/main.go | 2 +- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/modules/mesh-delivery-builder/buildrun/standing.go b/modules/mesh-delivery-builder/buildrun/standing.go index c4db87d1..45f49453 100644 --- a/modules/mesh-delivery-builder/buildrun/standing.go +++ b/modules/mesh-delivery-builder/buildrun/standing.go @@ -125,11 +125,18 @@ func (g *ToolchainGate) placedTold() { } } -// FollowAssignments reads the seat mesh-module-assigner's assignments, in bucket (the seat's holder found at run time, `_assignments`; design 47 G A-119), with its history into gate, says it settled, and follows it; changed +// 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, bucket string, gate assignmentsFollowed, 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) { + return FollowAssignmentsIn(ctx, js, AssignmentsBucket, gate, changed, logf) +} + +// FollowAssignmentsIn is FollowAssignments for the state in bucket, the holder's `_assignments` where the holder is found at +// run time (design 47 G A-119): the builder follows it so; FollowAssignments, with the fixed bucket, is the checker's until its own +// change finds the holder. +func FollowAssignmentsIn(ctx context.Context, js process.BucketOpener, bucket string, gate assignmentsFollowed, changed func(context.Context), logf func(string, ...any)) (<-chan error, error) { errs := make(chan error, 1) kv, err := js.KeyValue(ctx, bucket) if err != nil { diff --git a/modules/mesh-delivery-builder/buildrun/standing_test.go b/modules/mesh-delivery-builder/buildrun/standing_test.go index 51b688ee..f2ceee5d 100644 --- a/modules/mesh-delivery-builder/buildrun/standing_test.go +++ b/modules/mesh-delivery-builder/buildrun/standing_test.go @@ -86,7 +86,7 @@ func TestPlacedNodes(t *testing.T) { refused := errors.New("nats: permissions violation for publish to $JS.API.CONSUMER.CREATE.KV_mesh-delivery-assigner_assignments") gate := NewPlacedNodes(logger.Printf) for _, bus := range []refusingBus{{err: refused}, {refuseOpen: true, err: refused}} { - if _, err := FollowAssignments(context.Background(), bus, AssignmentsBucket, gate, func(context.Context) {}, logger.Printf); err != nil { + if _, err := FollowAssignments(context.Background(), bus, gate, func(context.Context) {}, logger.Printf); err != nil { t.Errorf("a refused assignments stopped the start: %v", err) } } diff --git a/modules/mesh-delivery-builder/buildrun/toolchaingate_test.go b/modules/mesh-delivery-builder/buildrun/toolchaingate_test.go index 39919219..90f6a73a 100644 --- a/modules/mesh-delivery-builder/buildrun/toolchaingate_test.go +++ b/modules/mesh-delivery-builder/buildrun/toolchaingate_test.go @@ -249,7 +249,7 @@ func TestToolchainGate(t *testing.T) { 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, AssignmentsBucket, gate, func(context.Context) {}, logger.Printf); err != nil { + if _, err := FollowAssignments(context.Background(), bus, gate, func(context.Context) {}, logger.Printf); err != nil { t.Errorf("a refused assignments stopped the start: %v", err) } } diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go index 59e3138e..f85253dd 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go @@ -362,7 +362,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro return case <-assignments.Follower().Ready(): } - pe, err := buildrun.FollowAssignments(ctx, reader, names.Bucket, gate, b.NodesChanged, logger.Printf) + pe, err := buildrun.FollowAssignmentsIn(ctx, reader, names.Bucket, gate, b.NodesChanged, logger.Printf) if err != nil { assignmentsErrs <- err return