Merge branch 'feat/47-G-fix11-b5j' into feat/47-G-join11

# Conflicts:
#	modules/mesh-delivery-assigner/internal/assigngraph/graph_test.go
This commit is contained in:
2026-11-12 10:37:21 +01:00
32 changed files with 1805 additions and 950 deletions
@@ -345,3 +345,56 @@ func TestARunsCandidateGraphIsKeptApartServedAndDropped(t *testing.T) {
t.Error("dropping a candidate dropped trunk")
}
}
// Design 47 G §512, §186, A-242: the difference orders its changes itself: each change names the changes it is ordered after
// with the kind of the edge, and Tiers holds the changes in that order, an acting part and a provider before what they act on.
func TestTheDifferenceFillsItsTiersAndEachChangesAfter(t *testing.T) {
ctx := context.Background()
build := func(ne, pg, web string) Computed {
return Compute(Inputs{
Halves: map[string]meshcatalogclient.Half{"main": half("main", 7,
meshcatalog.CatalogEntry{Module: "node-engine", Fp: ne, Acts: []meshcatalog.ActingRule{{Seat: "node-engine", Step: "compose", On: "every"}}},
meshcatalog.CatalogEntry{Module: "postgres", Fp: pg, Provides: []string{"database"}},
meshcatalog.CatalogEntry{Module: "web", Fp: web, Requires: []string{"database"}},
)},
Placements: map[string]mma.Assignments{"anchor": {Node: "anchor", Modules: []mma.PlacedModule{{Module: "node-engine"}, {Module: "postgres"},
{Module: "web", Bindings: map[string]mma.Provider{"database": {Node: "anchor", Module: "postgres"}}}}}},
FirstBranch: func(string) string { return "main" },
})
}
o := memObjects{}
at := time.Date(2026, 11, 12, 1, 0, 0, 0, time.UTC)
if _, _, err := Keep(ctx, o, build("src2:e1", "src2:p1", "src2:w1"), "catalog-revision", at); err != nil {
t.Fatal(err)
}
if _, _, err := Keep(ctx, o, build("src2:e2", "src2:p2", "src2:w2"), "catalog-revision", at); err != nil {
t.Fatal(err)
}
d, err := Difference(ctx, o, meshwants.DifferenceInput{From: 1, To: 2})
if err != nil || len(d.Changes) != 3 {
t.Fatalf("difference %+v, %v", d, err)
}
after := map[string]string{}
for _, c := range d.Changes {
var a []string
for _, x := range c.After {
a = append(a, x.Kind+":"+x.Node+"/"+x.Module)
}
after[c.Module] = strings.Join(a, ",")
}
if after["node-engine"] != "" || after["postgres"] != "acting:anchor/node-engine" ||
after["web"] != "acting:anchor/node-engine,provisioning:anchor/postgres" {
t.Errorf("after = %v", after)
}
var tiers []string
for _, tier := range d.Tiers {
var ms []string
for _, r := range tier {
ms = append(ms, r.Module)
}
tiers = append(tiers, strings.Join(ms, ","))
}
if strings.Join(tiers, "|") != "node-engine|postgres|web" {
t.Errorf("tiers = %v", tiers)
}
}
@@ -6,6 +6,7 @@ import (
"fmt"
"maps"
"slices"
"strings"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
)
@@ -110,6 +111,7 @@ func Compare(ctx context.Context, o Objects, from, to meshwants.AssignmentRevisi
return d, err
}
nodes := map[string]bool{}
edges := map[string][]meshwants.Edge{}
for _, k := range slices.Sorted(maps.Keys(union(was, now))) {
a, hadA := was[k]
b, hasB := now[k]
@@ -126,11 +128,95 @@ func Compare(ctx context.Context, o Objects, from, to meshwants.AssignmentRevisi
c.Edges = moved(a.Edges, b.Edges)
d.Changes = append(d.Changes, c)
nodes[c.Node] = true
if hasB {
edges[c.Node+"/"+c.Module] = b.Edges
} else {
edges[c.Node+"/"+c.Module] = a.Edges
}
}
d.Nodes = slices.Sorted(maps.Keys(nodes))
order(&d, edges, digests(from, to))
return d, nil
}
// digests are the entry digests of the two revisions by node and module, the newer's winning.
func digests(from, to meshwants.AssignmentRevision) map[string]string {
out := map[string]string{}
for _, r := range []meshwants.AssignmentRevision{from, to} {
for _, ref := range r.Entries {
out[ref.Node+"/"+ref.Module] = ref.Digest
}
}
return out
}
// order fills each change's After and the difference's Tiers (design 47 G §512, §186, A-242): a change is ordered after
// each other change of the difference that an edge of its entry names (the module version it is provisioned by, contributes
// to or is acted on by), with that edge's kind; its tier is one past the highest tier it is after. Changes on a cycle come in
// the last tier. The view orders nothing itself.
func order(d *meshwants.Difference, edges map[string][]meshwants.Edge, digest map[string]string) {
if len(d.Changes) == 0 {
return
}
in := map[string]int{}
for i, c := range d.Changes {
in[c.Node+"/"+c.Module] = i
}
for i, c := range d.Changes {
self := c.Node + "/" + c.Module
seen := map[string]bool{}
for _, e := range edges[self] {
k := e.Node + "/" + e.Module
if _, ok := in[k]; !ok || k == self || seen[e.Kind+k] {
continue
}
seen[e.Kind+k] = true
d.Changes[i].After = append(d.Changes[i].After, meshwants.ChangeAfterItem{Node: e.Node, Module: e.Module, Kind: e.Kind})
}
slices.SortFunc(d.Changes[i].After, func(a, b meshwants.ChangeAfterItem) int {
return strings.Compare(a.Kind+"\x00"+a.Node+"\x00"+a.Module, b.Kind+"\x00"+b.Node+"\x00"+b.Module)
})
}
tier := map[int]int{}
var level func(int, map[int]bool) int
level = func(i int, visiting map[int]bool) int {
if t, ok := tier[i]; ok {
return t
}
if visiting[i] {
return -1
}
visiting[i] = true
t := 0
for _, a := range d.Changes[i].After {
l := level(in[a.Node+"/"+a.Module], visiting)
if l < 0 {
return -1
}
t = max(t, l+1)
}
delete(visiting, i)
tier[i] = t
return t
}
top := 0
for i := range d.Changes {
top = max(top, level(i, map[int]bool{}))
}
for i := range d.Changes {
if _, ok := tier[i]; !ok {
tier[i] = top + 1
}
}
for i, c := range d.Changes {
for len(d.Tiers) <= tier[i] {
d.Tiers = append(d.Tiers, nil)
}
d.Tiers[tier[i]] = append(d.Tiers[tier[i]], meshwants.NodeEntryRef{Node: c.Node, Module: c.Module, Digest: digest[c.Node+"/"+c.Module]})
}
d.Tiers = slices.DeleteFunc(d.Tiers, func(t []meshwants.NodeEntryRef) bool { return len(t) == 0 })
}
func entriesOf(ctx context.Context, o Objects, rev meshwants.AssignmentRevision) (map[string]meshwants.AssignmentEntry, error) {
out := make(map[string]meshwants.AssignmentEntry, len(rev.Entries))
for _, ref := range rev.Entries {
@@ -523,8 +523,18 @@ type CatalogOf struct {
Source int64 `json:"source,omitempty"`
}
// ChangeAfterItem is a type of the contract.
type ChangeAfterItem struct {
// the kind of the edge that orders the change after that one: provisioning, contribution or acting
Kind string `json:"kind"`
Module string `json:"module"`
Node string `json:"node"`
}
// Change is one module on a node whose version differs between two revisions.
type Change struct {
// the other changes of the difference it is ordered after, each with its edge's kind (design 47 G section 512)
After []ChangeAfterItem `json:"after,omitempty"`
// the edges that moved
Edges []Edge `json:"edges,omitempty"`
// the fingerprint before; absent when it is new there
@@ -1028,6 +1028,30 @@
"$ref": "#/defs/Edge"
},
"description": "the edges that moved"
},
"after": {
"type": "array",
"items": {
"type": "object",
"properties": {
"node": {
"type": "string"
},
"module": {
"type": "string"
},
"kind": {
"type": "string",
"description": "the kind of the edge that orders the change after that one: provisioning, contribution or acting"
}
},
"required": [
"node",
"module",
"kind"
]
},
"description": "the other changes of the difference it is ordered after, each with its edge's kind (design 47 G section 512)"
}
},
"required": [
@@ -192,8 +192,17 @@ export interface CatalogOf {
"source"?: number;
}
export interface ChangeAfterItem {
/** the kind of the edge that orders the change after that one: provisioning, contribution or acting */
"kind": string;
"module": string;
"node": string;
}
/** one module on a node whose version differs between two revisions */
export interface Change {
/** the other changes of the difference it is ordered after, each with its edge's kind (design 47 G section 512) */
"after"?: ChangeAfterItem[];
/** the edges that moved */
"edges"?: Edge[];
/** the fingerprint before; absent when it is new there */
@@ -22,7 +22,6 @@ import (
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshdeliveryclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate"
)
@@ -132,19 +131,18 @@ func mustHash(t *testing.T, h meshdeliveryclient.Head) string {
// the gate's, as mesh-sdk go/delivery EncodeAsk writes it under the key MergeAskKey gives (as internal/view asks_test.go).
func TestPrintsTheOpenAsksInTheTableAndTheJSON(t *testing.T) { // 1345/AC2
m := view.New()
heads := []meshdeliveryclient.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: "0123456789abcdef0123456789abcdef01234567"}}
key, err := meshdeliveryclient.MergeAskKey("feat/x", heads)
key, err := asks.HoldAskKey("plex", "src2:2222222222222222222222222222222222222222222222222222222222222222")
if err != nil {
t.Fatal(err)
}
ask := oc.Ask{ID: meshdeliveryclient.AskID(key), Headline: "Merge into main: mesh-delivery#12", Explanation: "Merges one pull request.", Who: occ.Operator,
Expires: occ.FormatTime(time.Now().Add(time.Hour)), OnExpiry: "the merge stays held",
Options: []oc.Option{{ID: "approve", Label: "Approve", Does: "merges", Level: occ.Approve, Binds: "sha256:00"}}}
value, err := meshdeliveryclient.EncodeAsk(meshdelivery.AsksByMergeGroupHeadsValue{Group: "feat/x", Heads: askHeads(heads), Ask: rawAsk(t, ask)})
ask := oc.Ask{ID: meshdeliveryclient.AskID(key), Headline: "plex is held after a failed health check", Explanation: "ace failed the check on the new build.", Who: occ.Operator,
Expires: occ.FormatTime(time.Now().Add(time.Hour)), OnExpiry: "the fingerprint stays held",
Options: []oc.Option{{ID: "release", Label: "Release", Does: "goes on", Level: occ.Approve}, {ID: "keep", Label: "Keep", Does: "stays", Level: occ.Approve}}}
value, err := asks.EncodeAsk(key, asks.Ask{Ask: rawAsk(t, ask)})
if err != nil {
t.Fatal(err)
}
m.Apply(view.Entry{Bucket: view.AsksBucket, Key: key, Value: value})
m.Apply(view.Entry{Bucket: view.AssignerAsksBucket, Key: key, Value: value})
dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "c")
if err != nil {
t.Fatal(err)
@@ -162,7 +160,7 @@ func TestPrintsTheOpenAsksInTheTableAndTheJSON(t *testing.T) { // 1345/AC2
if code := run(nil, socket, &out, &errOut); code != 0 {
t.Fatalf("exit %d: %s", code, errOut.String())
}
for _, want := range []string{"ASKS", "Merge into main: mesh-delivery#12", "Merges one pull request.", "answer on the phone"} {
for _, want := range []string{"ASKS", "plex is held after a failed health check", "ace failed the check on the new build.", view.AnswerInTheView} {
if !strings.Contains(out.String(), want) {
t.Errorf("the table lacks %q:\n%s", want, out.String())
}
@@ -179,7 +177,7 @@ func TestPrintsTheOpenAsksInTheTableAndTheJSON(t *testing.T) { // 1345/AC2
} `json:"asks"`
}
if err := json.Unmarshal(out.Bytes(), &got); err != nil || len(got.Asks) != 1 || got.Asks[0].Key != key ||
got.Asks[0].Headline != "Merge into main: mesh-delivery#12" || got.Asks[0].How != "answer on the phone" {
got.Asks[0].Headline != "plex is held after a failed health check" || got.Asks[0].How != view.AnswerInTheView {
t.Errorf("json = %s (%v)", out.String(), err)
}
}
@@ -221,16 +219,9 @@ func TestTheAnswerCommandAnswersAnAskNeedingNoProof(t *testing.T) { // 1297/B9b
if err != nil {
t.Fatal(err)
}
merge := oc.Ask{ID: meshdeliveryclient.AskID(mkey), Headline: "Merge", Explanation: "Merges.", Who: occ.Operator, Expires: occ.FormatTime(time.Now().Add(time.Hour)), OnExpiry: "held",
Options: []oc.Option{{ID: "approve", Label: "Approve", Does: "merges", Level: occ.Approve, Binds: "sha256:00"}}}
mv, err := meshdeliveryclient.EncodeAsk(meshdelivery.AsksByMergeGroupHeadsValue{Group: "feat/x", Heads: askHeads(heads), Ask: rawAsk(t, merge)})
if err != nil {
t.Fatal(err)
}
m, w := view.New(), &putter{puts: map[string][]byte{}}
m.SetWriter(w)
m.Apply(view.Entry{Bucket: view.AssignerAsksBucket, Key: key, Value: value})
m.Apply(view.Entry{Bucket: view.AsksBucket, Key: mkey, Value: mv})
socket := serveWith(t, m)
var out, errOut bytes.Buffer
@@ -309,12 +300,3 @@ func rawAsk(t *testing.T, a oc.Ask) json.RawMessage {
}
return b
}
// askHeads is the heads of a merge ask as the seat carries them.
func askHeads(heads []meshdeliveryclient.Head) []meshdelivery.AsksByMergeGroupHeadsValueHeadsItem {
out := make([]meshdelivery.AsksByMergeGroupHeadsValueHeadsItem, len(heads))
for i, h := range heads {
out[i] = meshdelivery.AsksByMergeGroupHeadsValueHeadsItem{Repository: h.Repository, Number: int64(h.Number), Head: h.Head}
}
return out
}
@@ -1,65 +1,55 @@
package main
import (
"context"
"encoding/json"
"fmt"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
"sort"
"sync"
"time"
"mesh-delivery-view/internal/view"
)
// catalogWithin is how long a call waits for the seat mesh-catalog's first view before it says there is none.
var catalogWithin = 30 * time.Second
// catalogRead is the modules the view's tools answer, read from the catalog half through the seat mesh-catalog's verb
// `revision` (design 47, Two graphs): the trunk's newest revision, asked once per call and in pages, each entry in the
// form of the value the tools always answered. With no view the read says so; it never answers no modules.
func catalogRead(ask func(key string, body any) (json.RawMessage, error)) view.StateRead {
var (
once sync.Once
values map[string]json.RawMessage
rev uint64
failed error
)
load := func() {
ctx, cancel := context.WithTimeout(context.Background(), catalogWithin)
defer cancel()
c, err := meshcatalogclient.NewCatalog(ask, "main")
if err != nil {
failed = err
return
// catalogRead is the modules the view's tools answer, read from the catalog half per watched branch through
// meshcatalogclient's branch follower (design 47 G §532, §614): every module a followed branch's view lists, each the
// entry Branches.Entry reads, the first branch of the module's own registration (A-103), in the form of the value the
// tools always answered. Until every watched branch has its first view the read says what it waits for; it never answers
// no modules.
func catalogRead(b *meshcatalogclient.Branches) view.StateRead {
read := func() (map[string]json.RawMessage, error) {
if err := b.Ready(); err != nil {
return nil, fmt.Errorf("%w (%s)", err, meshcatalogclient.RevisionVerb)
}
if err := c.Start(ctx); err != nil {
failed = fmt.Errorf("the catalog half has no first view yet (%s): %w", meshcatalogclient.RevisionVerb, err)
return
}
half, ok := c.View()
if !ok {
failed = fmt.Errorf("the catalog half has no first view yet (%s)", meshcatalogclient.RevisionVerb)
return
}
rev, values = uint64(half.Revision.Revision), map[string]json.RawMessage{}
for module, e := range half.Entries {
raw, _ := json.Marshal(e)
v, err := view.ModuleValue(raw)
if err != nil {
failed = fmt.Errorf("the catalog entry of %s: %w", module, err)
return
values := map[string]json.RawMessage{}
for _, branch := range b.Followed() {
c, ok := b.Catalog(branch)
if !ok {
continue
}
half, _ := c.View()
for module := range half.Entries {
if _, done := values[module]; done {
continue
}
e, ok, err := b.Entry(module)
if err != nil {
return nil, err
}
if !ok {
continue
}
raw, _ := json.Marshal(e)
v, err := view.ModuleValue(raw)
if err != nil {
return nil, fmt.Errorf("the catalog entry of %s: %w", module, err)
}
values[module] = v
}
values[module] = v
}
}
read := func() (map[string]json.RawMessage, uint64, error) {
once.Do(load)
return values, rev, failed
return values, nil
}
return view.StateRead{
Keys: func() ([]string, error) {
v, _, err := read()
v, err := read()
keys := make([]string, 0, len(v))
for k := range v {
keys = append(keys, k)
@@ -68,16 +58,16 @@ func catalogRead(ask func(key string, body any) (json.RawMessage, error)) view.S
return keys, err
},
Get: func(key string) (json.RawMessage, error) {
v, _, err := read()
return v[key], err
},
GetEntry: func(key string) (json.RawMessage, uint64, error) {
v, r, err := read()
return v[key], r, err
},
All: func() (map[string]json.RawMessage, error) {
v, _, err := read()
return v, err
if err := b.Ready(); err != nil {
return nil, fmt.Errorf("%w (%s)", err, meshcatalogclient.RevisionVerb)
}
e, ok, err := b.Entry(key)
if err != nil || !ok {
return nil, err
}
raw, _ := json.Marshal(e)
return view.ModuleValue(raw)
},
All: read,
}
}
@@ -4,6 +4,7 @@
package main
import (
"context"
"encoding/json"
"fmt"
"mesh-delivery-view/internal/view"
@@ -17,6 +18,7 @@ import (
"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-contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-indexer/takein"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/buildstep"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate"
@@ -395,11 +397,11 @@ func read(state stdio.KeptState) view.StateRead {
// ownersRead is the view's reading of the indexer's and the assigner's states: each through its owner's read tool, asked
// by the bundle on each call (ADR 0615 §1, design 25 § 12.2: the view keeps its tools and answers them by asking the
// owners), never their buckets.
func ownersRead(ask func(key string, body any) (json.RawMessage, error)) view.Reader {
func ownersRead(ask func(key string, body any) (json.RawMessage, error), catalog *meshcatalogclient.Branches) view.Reader {
// Each owner's read tool as the view's invokes names it.
indexer := func(tool string) view.StateRead { return view.OwnerRead(ask, view.IndexerModule+"."+tool) }
assigner := func(tool string) view.StateRead { return view.OwnerRead(ask, view.AssignerModule+"."+tool) }
return view.Reader{State: catalogRead(ask), CandidatesState: indexer("indexer_read_candidates"),
return view.Reader{State: catalogRead(catalog), CandidatesState: indexer("indexer_read_candidates"),
WantsState: assigner("assigner_read_wants"), DifferencesState: assigner("assigner_read_differences"),
HeardState: indexer("indexer_read_heard"),
PredefinedState: indexer("indexer_read_predefined"),
@@ -418,13 +420,26 @@ func holderOf(ask func(key string, body any) (json.RawMessage, error)) func(seat
}
func main() {
r := ownersRead(stdio.Ask)
logf := func(f string, a ...any) { fmt.Fprintf(os.Stderr, "[mesh-delivery-view-tools] "+f+"\n", a...) }
// The catalog half per watched branch, followed by the bundle itself through meshcatalogclient's branch follower on
// its own views reader (design 47 G §532, §614, A-103), as other bundles follow the views they read.
cat := &views.Reader{Module: viewstate.ViewModule, Logf: logf, Acts: meshhandactsclient.Recorder{Ask: stdio.Ask}}
branches, err := meshcatalogclient.FollowBranches(cat, stdio.Ask)
if err != nil {
logf("%v", err)
os.Exit(1)
}
branches.Logf = logf
cat.Start(context.Background())
r := ownersRead(stdio.Ask, branches)
r.Builds, r.HavesState = read(stdio.State("mesh-delivery-builder.builds")), read(stdio.State("node-engine.have"))
r.ReachState = read(stdio.State("reach"))
r.WaitsState = read(stdio.State(viewstate.WaitsState))
r.SignersState = read(stdio.State("mesh-vault.signers"))
r.ConditionsState, r.AttemptsState = read(stdio.State(view.StatusConditionsState)), read(stdio.State(view.StatusAttemptsState))
r.PresenceState, r.StuckState = read(stdio.State(view.StatusPresenceState)), read(stdio.State(view.StatusStuckState))
r.SourceState = read(stdio.State(view.GateModule + ".source"))
r.Sources = view.SourcesOf(stdio.Ask, branches.Followed)
r.Assignment, r.Difference = view.AssignmentOf(stdio.Ask), view.DifferenceOf(stdio.Ask)
r.ProvisionsState = read(stdio.State(view.ProvisionsRead))
r.BuildLog = stdio.Log(builds.BuildLogRead).Since
r.Pending = pendingOf
@@ -449,6 +464,7 @@ func main() {
// 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), gate.Tools()...)
tools = append(tools, AnswersReadTool(func(name string) views.Source { return views.KeptSource{State: stdio.State(name)} }, time.Now))
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)
@@ -456,6 +472,14 @@ func main() {
}
}
// AnswersReadTool is view_read_answers, the view's ordinary read tool of its own state answers (mesh-sdk views.ReadTool): the
// teller's first view of the answers the view says on views.answers (design 47 G A-175, A-178).
func AnswersReadTool(state func(string) views.Source, now func() time.Time) stdio.Tool {
return views.ReadTool(viewstate.ViewModule, viewstate.ViewAnswersReadTool, viewstate.ViewAnswersState,
"the operator's answers given in the view to the assigner's asks, one key <ask key>.view each, which the teller fills the ask from.",
state(viewstate.ViewAnswersState), state(views.MarksState), now)
}
// pendingOf asks the indexer's tool indexer_pending (declared in invokes): what it holds back and why (ADR 0633 §5).
func pendingOf() (takein.PendingAnswer, error) {
raw, err := stdio.Ask(view.IndexerModule+".indexer_pending", map[string]any{})
@@ -1,6 +1,7 @@
package main
import (
"context"
"encoding/json"
"fmt"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
@@ -12,6 +13,7 @@ import (
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/graph"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate"
"git.novox.be/novox/mesh-sdk/go/views"
)
@@ -88,9 +90,9 @@ func ownersAnswerTheToolsAsBefore(t *testing.T) {
}
asked := map[string]int{}
ask := func(key string, body any) (json.RawMessage, error) {
if key == meshcatalogclient.RevisionVerb {
if a, ok := catalogSeats(key, body); ok {
asked[key]++
return json.Marshal(catalogAnswer)
return a()
}
name, ok := tools[key]
if !ok {
@@ -116,7 +118,7 @@ func ownersAnswerTheToolsAsBefore(t *testing.T) {
}
return json.Marshal(a)
}
owned := ownersRead(ask)
owned := ownersRead(ask, startedBranches(t, context.Background(), ask))
for _, row := range []struct {
tool string
args map[string]any
@@ -146,7 +148,7 @@ func ownersAnswerTheToolsAsBefore(t *testing.T) {
// the form of the value the tool always answered, the modules without a fingerprint say why, and the module's history
// is not a thing the catalog half keeps.
a, err := run(t, owned, view.ModuleReadTool, map[string]any{"module": "a"})
if raw, _ := json.Marshal(a); err != nil || !strings.Contains(string(raw), `"fp2":"`+fp2A+`"`) || !strings.Contains(string(raw), `"commit":"c1"`) || !strings.Contains(string(raw), `"src":7`) {
if raw, _ := json.Marshal(a); err != nil || !strings.Contains(string(raw), `"fp2":"`+fp2A+`"`) || !strings.Contains(string(raw), `"commit":"c1"`) || !strings.Contains(string(raw), `"src":7`) || !strings.Contains(string(raw), `"manifest":"`+manifestDigestA+`"`) {
t.Errorf("indexer_module a = %s, %v", raw, err)
}
if _, err := run(t, owned, view.ModuleReadTool, map[string]any{"module": "gone"}); err == nil {
@@ -168,9 +170,11 @@ func ownersAnswerTheToolsAsBefore(t *testing.T) {
t.Errorf("the want's history through the assigner's read tool = %+v, %v", revs, err)
}
// With no view of the catalog half the modules are not read: the tools say so, never that no module is held.
catalogWithin = 20 * time.Millisecond
none := ownersRead(func(key string, body any) (json.RawMessage, error) { return nil, fmt.Errorf("the holder is away") })
if out, err := run(t, none, view.ModulesReadTool, map[string]any{}); err == nil || !strings.Contains(err.Error(), "no first view") {
away := func(key string, body any) (json.RawMessage, error) { return nil, fmt.Errorf("the holder is away") }
gone, cancel := context.WithCancel(context.Background())
cancel()
none := ownersRead(away, startedBranches(t, gone, away))
if out, err := run(t, none, view.ModulesReadTool, map[string]any{}); err == nil || !strings.Contains(err.Error(), "no watched branch") {
t.Errorf("indexer_modules with no view = %v, %v, want an error naming it", out, err)
}
// Each owner state the tools read was asked of its owner's read tool.
@@ -179,17 +183,96 @@ func ownersAnswerTheToolsAsBefore(t *testing.T) {
t.Errorf("%s was never asked", tool)
}
}
if asked[meshcatalogclient.RevisionVerb] == 0 {
t.Errorf("%s was never asked", meshcatalogclient.RevisionVerb)
for _, verb := range []string{meshcatalogclient.RevisionVerb, meshcatalogclient.RegistrationsVerb, meshcatalogclient.SourceVerb} {
if asked[verb] == 0 {
t.Errorf("%s was never asked", verb)
}
}
}
// catalogBranch is the one watched branch of the catalog half in these tests: the registrations name it, the gate's source
// keeps it, and the seat's revision answers it alone, so a reader that names a branch of its own reads nothing.
const catalogBranch = "release"
// catalogSeats answers the seats the branch follower asks (design 47 G §614): the registrations, each module on
// catalogBranch, the gate's source, and the newest trunk revision of catalogBranch; ok is false for any other key.
func catalogSeats(key string, body any) (func() (json.RawMessage, error), bool) {
switch key {
case meshcatalogclient.RegistrationsVerb:
return func() (json.RawMessage, error) {
return json.Marshal(meshcatalog.RegistrationsAnswer{Registrations: []meshcatalog.Registration{
{Module: "a", Version: 1, Branches: []string{catalogBranch}}, {Module: "b", Version: 1, Branches: []string{catalogBranch}}}})
}, true
case meshcatalogclient.SourceVerb:
return func() (json.RawMessage, error) { return json.Marshal(map[string][]string{"branches": {catalogBranch}}) }, true
case meshcatalogclient.RevisionVerb:
return func() (json.RawMessage, error) {
if in, _ := body.(meshcatalog.RevisionInput); in.Branch != catalogBranch {
return nil, fmt.Errorf("no watched branch %q", in.Branch)
}
return json.Marshal(catalogAnswer)
}, true
}
return nil, false
}
// startWiring is the bundle's reader for a test: each view takes its first view as it is adopted, within ctx.
type startWiring struct {
t *testing.T
ctx context.Context
}
func (w startWiring) Adopt(f *views.Follower, _, _ string, _ func(context.Context, []byte) error) error {
if err := f.Start(w.ctx); err != nil && w.ctx.Err() == nil {
w.t.Errorf("the first view of %s.%s: %v", f.Owner, f.State, err)
}
return nil
}
func (startWiring) Drop(string, string) error { return nil }
// startedBranches is the branch follower of meshcatalogclient asking ask, its views started at once.
func startedBranches(t *testing.T, ctx context.Context, ask func(string, any) (json.RawMessage, error)) *meshcatalogclient.Branches {
t.Helper()
b, err := meshcatalogclient.NewBranches(startWiring{t, ctx}, ask)
if err != nil {
t.Fatal(err)
}
return b
}
// catalogAnswer is the seat mesh-catalog's newest trunk revision: a with a fingerprint and the source revision it moved at,
// b with none and the reason.
var catalogAnswer = meshcatalog.RevisionAnswer{
Revision: &meshcatalog.CatalogRevision{Revision: 4, Kind: meshcatalogclient.KindTrunk, Source: 7},
Entries: []meshcatalog.CatalogEntry{
{Module: "a", Repository: "novox/r", Commit: "c1", Path: ".", Manifest: map[string]any{"module": "a"}, Fp: fp2A, Inputs: &meshcatalog.EntryInputs{Src: 7}},
{Module: "a", Repository: "novox/r", Commit: "c1", Path: ".", Manifest: map[string]any{"module": "a"}, ManifestDigest: "sha256:" + manifestDigestA, Fp: fp2A, Inputs: &meshcatalog.EntryInputs{Src: 7}},
{Module: "b", Repository: "novox/r", Commit: "c2", Path: "modules/b", Manifest: map[string]any{"module": "b"}, None: "builder's fingerprint not yet known"},
},
}
// manifestDigestA is the digest the indexer read of a's manifest bytes, which the module's value carries bare (design 47 G
// §506, §578), never one computed again from the manifest whole.
const manifestDigestA = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
// view_read_answers answers the view's own answers as every owner's read tool answers its state (design 47 G A-178): one
// key's value, and every key when none is named.
func TestTheViewReadsItsAnswersAsAnOwner(t *testing.T) {
answers := source{"hold.plex.ab.view": `{"asking":"a1","option":"release"}`}
tool := AnswersReadTool(func(name string) views.Source {
if name == viewstate.ViewAnswersState {
return answers
}
return source{}
}, time.Now)
if tool.Name != "view_read_answers" {
t.Fatalf("the tool is %s", tool.Name)
}
for args, want := range map[string]map[string]any{"release": {"key": "hold.plex.ab.view"}, "hold.plex.ab.view": {}} {
out, err := tool.Run(want)
raw, _ := json.Marshal(out)
if err != nil || !strings.Contains(string(raw), args) {
t.Errorf("%v: %s, %v", args, raw, err)
}
}
}
@@ -4,22 +4,16 @@ import (
"encoding/json"
"errors"
"fmt"
"mesh-delivery-view/internal/view"
"reflect"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshwatchdogsclient"
"git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-indexer/takein"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/graph"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate"
"mesh-delivery-view/internal/view"
)
// encoded is v as its contract's encoder writes it, failing the test when it refuses.
@@ -34,78 +28,69 @@ func encoded(t *testing.T, v []byte, err error) string {
// short is a fingerprint as the assigner's words shorten it.
func short(fp string) string { return fp[:len("src2:")+12] }
// tiersToolSaysEachModulesWait: view_tiers orders a source revision's modules by the module graph and names each one's
// wait (ADR 0603 §6): a consumer after its provider, held by the assigner's provision rule and its provision not provided;
// a module whose build waits for a package; a provider with no wait; a node offline named away; older modules left out.
// tiersToolSaysEachModulesWait: view_tiers reads a source revision's tiers from the assigner's difference of the kept revision
// carrying it against the one before (design 47 G §512, A-242) and names each module's waits as the parts said them in
// wait-held (the view's state waits), in the sayer's own words, beside the indexer's pending revisions.
func tiersToolSaysEachModulesWait(t *testing.T) {
fp := func(c string) string { return "src2:" + strings.Repeat(c, 64) }
manifest := strings.Repeat("d", 64)
mod := func(f string, src uint64) string {
b, err := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: f, Manifest: manifest, Src: int64(src), FpSrc: int64(src), Repository: "novox/mesh-catalog", Commit: "c"})
return encoded(t, b, err)
since := time.Date(2026, 11, 3, 16, 40, 0, 0, time.UTC)
r := fake{}.reader()
// db and lib first, web after db by its provisioning edge; the view orders nothing.
ref := func(m string) meshwants.NodeEntryRef {
return meshwants.NodeEntryRef{Node: "ace", Module: m, Digest: "sha256:" + m}
}
node := func(g graph.Node) string {
b, err := graph.Encode(g)
return encoded(t, b, err)
kept := map[int64]meshwants.AssignmentRevision{
3: {Revision: 3, Catalogs: []meshwants.CatalogOf{{Branch: "release", Revision: 2, Source: 5}}},
4: {Revision: 4, Catalogs: []meshwants.CatalogOf{{Branch: "release", Revision: 3, Source: 8}}},
5: {Revision: 5, Catalogs: []meshwants.CatalogOf{{Branch: "release", Revision: 4, Source: 9}}},
6: {Revision: 6, Catalogs: []meshwants.CatalogOf{{Branch: "release", Revision: 4, Source: 9}}},
}
build := func(b builds.BuilderBuild) string {
v, err := builds.EncodeBuilderBuild(b)
return encoded(t, v, err)
differences := map[[2]int64]meshwants.Difference{{4, 5}: {From: 4, To: 5,
Changes: []meshwants.Change{{Node: "ace", Module: "db", To: fp("a")}, {Node: "ace", Module: "lib", To: fp("c")},
{Node: "ace", Module: "web", To: fp("b"), After: []meshwants.ChangeAfterItem{{Node: "ace", Module: "db", Kind: "provisioning"}}}},
Tiers: [][]meshwants.NodeEntryRef{{ref("db"), ref("lib")}, {ref("web")}}}}
r.Assignment = func(n int64) (meshwants.AssignmentRevision, error) {
if n == 0 {
n = 6
}
if rev, ok := kept[n]; ok {
return rev, nil
}
return meshwants.AssignmentRevision{}, errors.New("not kept")
}
want := func(w meshwantsclient.Want) string {
v, err := meshwantsclient.EncodeWant(w)
return encoded(t, v, err)
r.Difference = func(from, to int64) (meshwants.Difference, error) {
if d, ok := differences[[2]int64{from, to}]; ok {
return d, nil
}
return meshwants.Difference{}, fmt.Errorf("no difference of %d and %d", from, to)
}
have := func(h nodeengine.HaveByNodeModuleValue) string {
v, err := nodeengineclient.EncodeHave(h)
return encoded(t, v, err)
}
key := func(k string, err error) string {
// The waits the parts said, as the view's process keeps them: web's delivery wait by the assigner, lib's toolchain wait by
// the builder; db holds none. Their words are the sayers' own.
wait := func(w viewstate.Wait) string {
raw, err := json.Marshal(w)
if err != nil {
t.Fatal(err)
}
return k
return string(raw)
}
since := time.Date(2026, 11, 3, 16, 40, 0, 0, time.UTC)
r := fake{"db": mod(fp("a"), 9), "web": mod(fp("b"), 9), "lib": mod(fp("c"), 9), "old": mod(fp("e"), 5)}.reader()
r.GraphState = fake{
"db": node(graph.Node{FP: fp("a"), Repository: "novox/mesh-catalog", Provides: []string{"database"}}),
"web": node(graph.Node{FP: fp("b"), Repository: "novox/mesh-catalog", Requires: []string{"database"}, Awaits: []string{"database"}}),
"lib": node(graph.Node{FP: fp("c"), Repository: "novox/mesh-catalog"}),
}.reader().State
r.Builds = fake{
key(builds.BuilderBuildKey("db", fp("a"))): build(builds.BuilderBuild{State: builds.BuildDone, Manifest: manifest}),
key(builds.BuilderBuildKey("web", fp("b"))): build(builds.BuilderBuild{State: builds.BuildDone, Manifest: manifest}),
key(builds.BuilderBuildKey("lib", fp("c"))): build(builds.BuilderBuild{State: builds.BuildQueued, Waits: "@novox/x@1.2.0"}),
}.reader().State
r.WantsState = fake{
key(meshwantsclient.WantKey("ace", "db")): want(meshwantsclient.Want{Fp: fp("a"), Self: true}),
key(meshwantsclient.WantKey("ace", "web")): want(meshwantsclient.Want{Self: true, Fp: fp("f"), Newest: fp("b"), Rule: meshwantsclient.RuleProvisions, Words: "the provision ace.web.database of web on ace is asked, not provided"}),
key(meshwantsclient.WantKey("g14", "web")): want(meshwantsclient.Want{Self: true, Fp: fp("f"), Newest: fp("b"), Rule: meshwantsclient.RuleCanary, Words: "the canary"}),
}.reader().State
r.WantsState.History = func(string) ([]view.Revision, error) { return []view.Revision{{At: since}}, nil }
r.HavesState = fake{key(nodeengineclient.HaveKey("ace", "db")): have(nodeengine.HaveByNodeModuleValue{Fp: fp("a"), Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK})}.reader().State
p, err := meshprovisionerclient.Encode(meshprovisionerclient.Provision{Provision: "database", Consumer: meshprovisionerclient.End{Module: "web", Node: "ace"},
Provider: meshprovisionerclient.End{Module: "db", Node: "ace"}, State: meshprovisionerclient.Asked, Why: "the build db runs does not offer it", At: since})
r.ProvisionsState = fake{key(meshprovisionerclient.Key("ace", "web", "database", "")): encoded(t, p, err)}.reader().State
off, err := meshwatchdogsclient.EncodeNodePresence(meshwatchdogsclient.NodePresence{Online: false})
r.PresenceState = fake{key(meshwatchdogsclient.PresenceNodeKey("g14")): encoded(t, off, err)}.reader().State
webWait := viewstate.Wait{Owner: "mesh-wants", Step: "delivery", Node: "ace", Module: "web", Part: "db", Machines: []string{"g14"}, Since: since,
Bound: "60m", Words: "waiting for delivery of db to g14"}
libWait := viewstate.Wait{Owner: "mesh-delivery-builder", Step: "building", Module: "lib", On: "toolchain:go@1.27", Since: since, Words: "the toolchain go 1.27 is not agreed yet"}
r.WaitsState = fake{webWait.Key(): wait(webWait), libWait.Key(): wait(libWait)}.reader().State
out, err := run(t, r, view.TiersTool, map[string]any{"revision": float64(9)})
got, _ := out.(view.Tiers)
if err != nil || got.Revision != 9 || got.Modules != 3 || len(got.Tiers) != 2 || !reflect.DeepEqual(got.Away, []string{"g14"}) || got.Graph != "" {
if err != nil || got.Revision != 9 || got.Modules != 3 || len(got.Tiers) != 2 || got.Graph != "" {
t.Fatalf("view_tiers 9 = %+v, %v", out, err)
}
first := fmt.Sprint(got.Tiers[0])
if len(got.Tiers[0]) != 2 || got.Tiers[0][0].Module != "db" || len(got.Tiers[0][0].Waits) != 0 || got.Tiers[0][1].Module != "lib" ||
len(got.Tiers[0][1].Waits) != 1 || !strings.Contains(got.Tiers[0][1].Waits[0].Words, "waiting for the package @novox/x@1.2.0") {
t.Errorf("the first tier = %s, want db with no wait and lib's build waiting for its package", first)
if len(got.Tiers[0]) != 2 || got.Tiers[0][0].Module != "db" || got.Tiers[0][0].FP != fp("a") || len(got.Tiers[0][0].Waits) != 0 || got.Tiers[0][1].Module != "lib" ||
!reflect.DeepEqual(got.Tiers[0][1].Waits, []view.TierWait{{Owner: "mesh-delivery-builder", Step: "building", On: "toolchain:go@1.27", Since: since, Words: "the toolchain go 1.27 is not agreed yet"}}) {
t.Errorf("the first tier = %+v, want db with no wait and lib's toolchain wait in its sayer's words", got.Tiers[0])
}
web := got.Tiers[1][0]
if web.Module != "web" || !reflect.DeepEqual(web.After, []string{"db"}) || len(web.Waits) != 2 ||
web.Waits[0].Edge != "want" || web.Waits[0].Node != "ace" || web.Waits[0].Rule != meshwantsclient.RuleProvisions || !web.Waits[0].Since.Equal(since) ||
web.Waits[1].Edge != "provision database" || !strings.Contains(web.Waits[1].Words, "asked: the build db runs does not offer it") {
t.Errorf("web = %+v, want after db, held on ace by the provision rule and its provision asked (g14 is away)", web)
if web.Module != "web" || !reflect.DeepEqual(web.After, []view.TierAfter{{Module: "db", Kind: "provisioning"}}) ||
!reflect.DeepEqual(web.Waits, []view.TierWait{{Owner: "mesh-wants", Step: "delivery", Node: "ace", Bound: "60m", Since: since, Words: "waiting for delivery of db to g14"}}) {
t.Errorf("web = %+v, want after db, waiting for the delivery of db in the assigner's words", web)
}
// ADR 0633 §5 (1585/T24): a module the indexer holds back, read through indexer_pending, waits on the edge take-in from the
// wait's since; a held module the revision does not move is named in the first tier with that wait alone, a group's wait
@@ -122,63 +107,54 @@ func tiersToolSaysEachModulesWait(t *testing.T) {
t.Fatalf("view_tiers with waits = %+v, %v", out, err)
}
web = held.Tiers[1][0]
if last := web.Waits[len(web.Waits)-1]; last.Edge != view.TakeInEdge || !last.Since.Equal(heldSince) || !strings.Contains(last.Words, "src2:abc") {
if last := web.Waits[len(web.Waits)-1]; last.Step != view.TakeInStep || !last.Since.Equal(heldSince) || !strings.Contains(last.Words, "src2:abc") {
t.Errorf("web = %+v, want its last wait on the edge take-in since %s", web, heldSince)
}
if lib := held.Tiers[0][1]; lib.Module != "lib" || lib.Waits[len(lib.Waits)-1].Edge != view.TakeInEdge || !strings.Contains(lib.Waits[len(lib.Waits)-1].Words, "group feat") {
if lib := held.Tiers[0][1]; lib.Module != "lib" || lib.Waits[len(lib.Waits)-1].Step != view.TakeInStep || !strings.Contains(lib.Waits[len(lib.Waits)-1].Words, "group feat") {
t.Errorf("lib = %+v, want its last wait the group's", lib)
}
if nm := held.Tiers[0][len(held.Tiers[0])-1]; nm.Module != "newmod" || len(nm.Waits) != 1 || nm.Waits[0].Edge != view.TakeInEdge {
if nm := held.Tiers[0][len(held.Tiers[0])-1]; nm.Module != "newmod" || len(nm.Waits) != 1 || nm.Waits[0].Step != view.TakeInStep {
t.Errorf("the first tier = %+v, want newmod, which the revision does not move, named with its take-in wait alone", held.Tiers[0])
}
r.Pending = func() (takein.PendingAnswer, error) { return takein.PendingAnswer{}, errors.New("no answer") }
if out, err = run(t, r, view.TiersTool, map[string]any{"revision": float64(9)}); err != nil || !strings.Contains(out.(view.Tiers).Pending, "no answer") ||
strings.Contains(fmt.Sprint(out.(view.Tiers).Tiers), view.TakeInEdge) {
strings.Contains(fmt.Sprint(out.(view.Tiers).Tiers), view.TakeInStep) {
t.Errorf("view_tiers with no answer of indexer_pending = %+v, %v", out, err)
}
r.Pending = nil
// No revision named: the gate's newest, its source key's revision.
r.SourceState = view.StateRead{GetEntry: func(string) (json.RawMessage, uint64, error) { return json.RawMessage(`{}`), 9, nil }}
if out, err := run(t, r, view.TiersTool, map[string]any{}); err != nil || out.(view.Tiers).Revision != 9 || out.(view.Tiers).Modules != 3 {
t.Errorf("view_tiers of the gate's newest = %+v, %v", out, err)
// No revision named: per watched branch, the newest source revision the gate's verb source answers for it (design 47 G
// A-173); a branch with no source revision yet is left out.
asked := map[string]bool{}
call := func(key string, body any) (json.RawMessage, error) {
in, _ := body.(meshdelivery.SourceInput)
asked[key+" "+in.Branch] = true
rev := map[string]int64{"release": 9}[in.Branch]
return json.Marshal(meshdelivery.SourceAnswer{Branch: in.Branch, Revision: rev})
}
// ADR 0614 §2-§5: a want held on a verb, a reader capability or bus state is named by its edge; bus state is no
// provisioner key (none is ever made for it), so a module asking it shows no provision wait of it; and each verb edge
// the graph leaves out on a cycle is named.
r.GraphState = fake{
"db": node(graph.Node{FP: fp("a"), Repository: "novox/mesh-catalog", Provides: []string{"database"}}),
"web": node(graph.Node{FP: fp("b"), Repository: "novox/mesh-catalog", Requires: []string{"database", graph.BusState}, Awaits: []string{"database", graph.BusState},
Invokes: []string{"seat:x.v", "db.loop"}, LeftOut: []string{"db.loop"}}),
"lib": node(graph.Node{FP: fp("c"), Repository: "novox/mesh-catalog"}),
}.reader().State
for words, edge := range map[string]string{
graph.VerbWaitWords("seat:x.v", "web", "ace"): "verb seat:x.v",
graph.ReaderWaitWords("ace", []string{"tool-runner"}, "web", short(fp("b"))): "reader capability tool-runner",
"the provision ace.web.database of web on ace is asked, not provided": "want",
} {
r.WantsState = fake{key(meshwantsclient.WantKey("ace", "web")): want(meshwantsclient.Want{Self: true, Fp: fp("f"), Newest: fp("b"), Rule: meshwantsclient.RuleProvisions, Words: words})}.reader().State
out, err := run(t, r, view.TiersTool, map[string]any{"revision": float64(9)})
got, _ := out.(view.Tiers)
if err != nil || len(got.Tiers) != 2 {
t.Fatalf("view_tiers 9 = %+v, %v", out, err)
}
web := got.Tiers[1][0]
if len(web.Waits) != 2 || web.Waits[0].Edge != edge || web.Waits[1].Edge != "provision database" ||
!reflect.DeepEqual(web.LeftOut, []string{"db.loop"}) {
t.Errorf("web held by %q = %+v, want its want named %s, no wait for a bus-state key and db.loop left out", words, web, edge)
}
r.Sources = view.SourcesOf(call, func() []string { return []string{"release", "beta"} })
if out, err := run(t, r, view.TiersTool, map[string]any{}); err != nil || len(out.(view.BranchTiers).Branches) != 1 ||
out.(view.BranchTiers).Branches[0].Branch != "release" || out.(view.BranchTiers).Branches[0].Revision != 9 || out.(view.BranchTiers).Branches[0].Modules != 3 {
t.Errorf("view_tiers of each branch's newest = %+v, %v", out, err)
}
// The graph not readable: every module in the first tier, said; past 500 modules the answer says it is cut short.
r.GraphState = view.StateRead{Keys: func() ([]string, error) { return nil, errors.New("no bucket") }, Get: r.GraphState.Get}
if out, err := run(t, r, view.TiersTool, map[string]any{"revision": float64(9)}); err != nil || len(out.(view.Tiers).Tiers) != 1 ||
!strings.Contains(out.(view.Tiers).Graph, "cannot be read") {
t.Errorf("view_tiers with no graph = %+v, %v", out, err)
if !asked["seat:mesh-delivery.source release"] || !asked["seat:mesh-delivery.source beta"] {
t.Errorf("the gate's verb source was asked %v, want once per watched branch", asked)
}
many := fake{}
// No revision kept before the one carrying the source: said, and no module named; past 500 modules the answer says it is cut
// short.
delete(kept, 4)
delete(kept, 3)
if out, err := run(t, r, view.TiersTool, map[string]any{"revision": float64(9)}); err != nil || len(out.(view.Tiers).Tiers) != 0 ||
!strings.Contains(out.(view.Tiers).Graph, "no assignment revision is kept before") {
t.Errorf("view_tiers with no earlier revision = %+v, %v", out, err)
}
kept[4] = meshwants.AssignmentRevision{Revision: 4, Catalogs: []meshwants.CatalogOf{{Branch: "release", Source: 8}}}
big := meshwants.Difference{From: 4, To: 5, Tiers: [][]meshwants.NodeEntryRef{{}}}
for i := 0; i < view.TiersMost+2; i++ {
many[fmt.Sprintf("m%03d", i)] = mod(fp("a"), 9)
m := fmt.Sprintf("m%03d", i)
big.Changes = append(big.Changes, meshwants.Change{Node: "ace", Module: m, To: fp("a")})
big.Tiers[0] = append(big.Tiers[0], ref(m))
}
r.State = many.reader().State
differences[[2]int64{4, 5}] = big
if out, err := run(t, r, view.TiersTool, map[string]any{"revision": "9"}); err != nil || out.(view.Tiers).Modules != view.TiersMost+2 ||
!strings.Contains(out.(view.Tiers).Cut, "the first 500") {
t.Errorf("view_tiers past its bound = %+v, %v", out.(view.Tiers).Cut, err)
@@ -32,6 +32,7 @@ import (
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/buildstep"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/views"
"git.novox.be/novox/mesh-sdk/go/views/viewstest"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
@@ -58,7 +59,9 @@ type catalogHolder struct {
// rev is the revision's number; entries are its entries by module.
rev int64
entries map[string]meshcatalog.CatalogEntry
ctx context.Context
// src is the gate's source revision of catalogBranch, as its verb source answers it.
src meshdelivery.SourceAnswer
ctx context.Context
}
func serveCatalog(t testing.TB, ctx context.Context, js jetstream.JetStream, nc *nats.Conn) *catalogHolder {
@@ -85,16 +88,62 @@ func serveCatalog(t testing.TB, ctx context.Context, js jetstream.JetStream, nc
raw, _ := json.Marshal(map[string]any{"result": a, "node": "n0"})
_ = m.Respond(raw)
}
for _, subject := range []string{"mesh.seat.mesh-catalog.call.revision.>", "mesh.seat.mesh-catalog.call.revision"} {
sub, err := nc.Subscribe(subject, answer)
if err != nil {
t.Fatal(err)
// The branch follower's other two first views (design 47 G §614): the registrations, each module on catalogBranch, and the
// gate's source, which keeps catalogBranch.
registrations := func(m *nats.Msg) {
h.mu.Lock()
a := meshcatalog.RegistrationsAnswer{Registrations: []meshcatalog.Registration{}}
for module := range h.entries {
a.Registrations = append(a.Registrations, meshcatalog.Registration{Module: module, Version: 1, Branches: []string{catalogBranch}})
}
h.mu.Unlock()
raw, _ := json.Marshal(map[string]any{"result": a, "node": "n0"})
_ = m.Respond(raw)
}
// The gate's verb source: given no branch the branches it keeps, given catalogBranch its source revision (A-173).
source := func(m *nats.Msg) {
var in meshdelivery.SourceInput
_ = json.Unmarshal(m.Data, &in)
var a any = map[string]any{"branches": []string{catalogBranch}}
if in.Branch != "" {
h.mu.Lock()
a = meshdelivery.SourceAnswer{Branch: in.Branch, Revision: h.src.Revision, Group: h.src.Group, Repositories: h.src.Repositories}
h.mu.Unlock()
}
raw, _ := json.Marshal(map[string]any{"result": a, "node": "n0"})
_ = m.Respond(raw)
}
for subject, handle := range map[string]nats.MsgHandler{"mesh.seat.mesh-catalog.call.revision": answer,
"mesh.seat.mesh-catalog.call.registrations": registrations, "mesh.seat.mesh-delivery.call.source": source} {
for _, s := range []string{subject + ".>", subject} {
sub, err := nc.Subscribe(s, handle)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sub.Unsubscribe() })
}
t.Cleanup(func() { _ = sub.Unsubscribe() })
}
return h
}
// catalogBranch is the one watched branch of these tests: no branch is named in the view's code (design 47 G §532).
const catalogBranch = "release"
// moveSource is the gate's next source revision of catalogBranch, said as source-moved (design 47 G §507, A-173).
func (h *catalogHolder) moveSource(rev int64, group string, heads map[string]string) {
h.t.Helper()
h.mu.Lock()
h.src = meshdelivery.SourceAnswer{Revision: rev, Group: group}
for repository, head := range heads {
h.src.Repositories = append(h.src.Repositories, meshdelivery.SourceHead{Repository: repository, Head: head, Trunk: catalogBranch})
}
h.mu.Unlock()
body, _ := json.Marshal(meshdelivery.SourceMovedEvent{Branch: catalogBranch, Revision: rev, Group: group})
if _, err := h.js.Publish(h.ctx, meshdelivery.SubjectSourceMoved(catalogBranch), body); err != nil {
h.t.Fatal(err)
}
}
// put is the holder's next trunk revision with the entry of one module changed, said as catalog-revision.
func (h *catalogHolder) put(e meshcatalog.CatalogEntry) {
h.t.Helper()
@@ -103,8 +152,8 @@ func (h *catalogHolder) put(e meshcatalog.CatalogEntry) {
h.entries[e.Module] = e
rev := h.rev
h.mu.Unlock()
body, _ := json.Marshal(meshcatalog.CatalogRevisionEvent{Kind: meshcatalogclient.KindTrunk, Branch: "main", Revision: rev, Changed: []string{e.Module}})
if _, err := h.js.Publish(h.ctx, "mesh.seat.mesh-catalog.event."+meshcatalog.EventCatalogRevision, body); err != nil {
body, _ := json.Marshal(meshcatalog.CatalogRevisionEvent{Kind: meshcatalogclient.KindTrunk, Branch: catalogBranch, Revision: rev, Changed: []string{e.Module}})
if _, err := h.js.Publish(h.ctx, "mesh.seat.mesh-catalog.event."+meshcatalog.EventCatalogRevision+"."+catalogBranch, body); err != nil {
h.t.Fatal(err)
}
}
@@ -396,98 +445,6 @@ func TestProcessWholeShowsTheIndexersBucketsOnceTheyExist(t *testing.T) { // 124
t.Fatalf("the snapshot never held the indexer's keys; last: %+v", last)
}
// TestProcessWholeShowsTheOpenAsksAndDropsTheAnsweredOnes starts the program whole before the gate's asks bucket and the
// teller's answers bucket exist, then creates them: an ask appears in the snapshot, and leaves it once the teller has an
// answer under its key (1345/AC1 against a real bus; the retry is the view's own, every 5 s).
func TestProcessWholeShowsTheOpenAsksAndDropsTheAnsweredOnes(t *testing.T) { // 1345/AC1
srv, err := server.NewServer(&server.Options{Host: "127.0.0.1", Port: -1, JetStream: true, StoreDir: t.TempDir(), NoLog: true, NoSigs: true})
if err != nil {
t.Fatal(err)
}
go srv.Start()
if !srv.ReadyForConnections(10 * time.Second) {
t.Fatal("the bus did not come up")
}
t.Cleanup(srv.Shutdown)
nc, err := nats.Connect(srv.ClientURL())
if err != nil {
t.Fatal(err)
}
t.Cleanup(nc.Close)
js, err := jetstream.New(nc)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "i")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(dir) })
broker := filepath.Join(dir, "broker")
cred, _ := json.Marshal(process.Account{URL: srv.ClientURL(), Node: "n", Module: "mesh-delivery-view", User: "mesh-delivery-view", Password: "x"})
if err := os.WriteFile(broker, cred, 0o600); err != nil {
t.Fatal(err)
}
socket := filepath.Join(dir, "run", "cli.sock")
env := map[string]string{"MESH_BROKER_FILE": broker, "MESH_DELIVERY_SOCKET": socket, "MESH_DELIVERY_WEB": "127.0.0.1:0"}
runCtx, stop := context.WithCancel(ctx)
done := make(chan error, 1)
serveOwners(t, ctx, js, nc)
serveCatalog(t, ctx, js, nc)
go func() { done <- run(runCtx, func(k string) string { return env[k] }, io.Discard) }()
t.Cleanup(func() { stop(); <-done })
gate, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: view.AsksBucket, History: view.AsksHistory})
if err != nil {
t.Fatal(err)
}
teller, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: view.AnswersBucket, History: view.AnswersHistory})
if err != nil {
t.Fatal(err)
}
heads := []meshdeliveryclient.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: "0123456789abcdef0123456789abcdef01234567"}}
key, err := meshdeliveryclient.MergeAskKey("feat/x", heads)
if err != nil {
t.Fatal(err)
}
ask := oc.Ask{ID: meshdeliveryclient.AskID(key), Headline: "Merge into main: mesh-delivery#12", Explanation: "Merges one pull request.", Who: occ.Operator,
Expires: occ.FormatTime(time.Now().Add(time.Hour)), OnExpiry: "the merge stays held",
Options: []oc.Option{{ID: "approve", Label: "Approve", Does: "merges", Level: occ.Approve, Binds: "sha256:00"}}}
value, err := meshdeliveryclient.EncodeAsk(meshdelivery.AsksByMergeGroupHeadsValue{Group: "feat/x", Heads: askHeads(heads), Ask: rawAsk(t, ask)})
if err != nil {
t.Fatal(err)
}
if _, err := gate.Put(ctx, key, value); err != nil {
t.Fatal(err)
}
wait := func(check func(view.Snapshot) bool, what string) {
t.Helper()
var last view.Snapshot
for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(100 * time.Millisecond) {
if s, err := view.Ask(socket); err == nil {
last = s
if check(s) {
return
}
}
}
t.Fatalf("%s; last: %+v", what, last)
}
wait(func(s view.Snapshot) bool {
return len(s.Asks) == 1 && s.Asks[0].Key == key && s.Asks[0].How == "answer on the phone"
}, "the open ask never showed")
answer, err := meshdeliveryclient.EncodeAnswer(meshdeliveryclient.Answer{Option: "approve", Warrant: ask.ID + "@2026-10-18T12:00:00Z", Words: "the operator, via telegram, chose Approve"})
if err != nil {
t.Fatal(err)
}
if _, err := teller.Put(ctx, key, answer); err != nil {
t.Fatal(err)
}
wait(func(s view.Snapshot) bool { return len(s.Asks) == 0 }, "the answered ask never left the list")
}
// TestProcessWholeListsEveryDifference starts the program whole before the assigner's differences bucket exists, then
// creates it and writes a difference explained by a rule and one unexplained: the socket's snapshot lists both with their
// rule, and a difference taken away leaves the list (1297/B8: the view lists every difference with its rule).
@@ -608,8 +565,15 @@ func TestProcessWholeWritesTheAnswerToAnAskNeedingNoProof(t *testing.T) { // 129
}
askBucket := bucket(view.AssignerAsksBucket, view.AssignerAsksHistory)
viewBucket := bucket(viewstate.ViewAnswersBucket, viewstate.ViewAnswersHistory)
bucket(viewstate.ViewModule+"_"+views.MarksState, 1)
teller := bucket(view.AnswersBucket, view.AnswersHistory)
gate := bucket(view.AsksBucket, view.AsksHistory)
// The view says its answers on its views family (design 47 G A-178), which the teller follows.
said := make(chan *nats.Msg, 8)
sub, err := nc.ChanSubscribe("mesh.mod."+viewstate.ViewModule+".event."+views.EventPrefix+viewstate.ViewAnswersState, said)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { sub.Unsubscribe() })
dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "i")
if err != nil {
@@ -668,23 +632,20 @@ func TestProcessWholeWritesTheAnswerToAnAskNeedingNoProof(t *testing.T) { // 129
t.Fatalf("answer = %+v, %v", a, err)
}
wait(func(s view.Snapshot) bool { return len(s.Asks) == 1 && s.Asks[0].Chosen == "release" }, "the ask never showed the view's choice")
select {
case msg := <-said:
if !strings.Contains(string(msg.Data), key+".view") {
t.Errorf("the view said %s, not its answer under %s.view", msg.Data, key)
}
case <-time.After(20 * time.Second):
t.Error("the view never said its answer on views.answers")
}
// The merge's Approve is answered on the phone alone.
heads := []meshdeliveryclient.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: "0123456789abcdef0123456789abcdef01234567"}}
mkey, err := meshdeliveryclient.MergeAskKey("feat/x", heads)
// The merge's Approve is no ask of the view's (design 47 G §431): refused by its key, never written.
mkey, err := meshdeliveryclient.MergeAskKey("feat/x", []meshdeliveryclient.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: "0123456789abcdef0123456789abcdef01234567"}})
if err != nil {
t.Fatal(err)
}
merge := oc.Ask{ID: meshdeliveryclient.AskID(mkey), Headline: "Merge", Explanation: "Merges.", Who: occ.Operator, Expires: occ.FormatTime(time.Now().Add(time.Hour)), OnExpiry: "held",
Options: []oc.Option{{ID: "approve", Label: "Approve", Does: "merges", Level: occ.Approve, Binds: "sha256:00"}}}
mv, err := meshdeliveryclient.EncodeAsk(meshdelivery.AsksByMergeGroupHeadsValue{Group: "feat/x", Heads: askHeads(heads), Ask: rawAsk(t, merge)})
if err != nil {
t.Fatal(err)
}
if _, err := gate.Put(ctx, mkey, mv); err != nil {
t.Fatal(err)
}
wait(func(s view.Snapshot) bool { return len(s.Asks) == 2 }, "the merge ask never showed")
if err := view.AnswerAsk(socket, mkey, "approve", ""); err == nil || !strings.Contains(err.Error(), "answer it on the phone (issue 953)") {
t.Errorf("merge Approve = %v, want the refusal naming the phone", err)
}
@@ -700,7 +661,7 @@ func TestProcessWholeWritesTheAnswerToAnAskNeedingNoProof(t *testing.T) { // 129
if _, err := teller.Put(ctx, key, answer); err != nil {
t.Fatal(err)
}
wait(func(s view.Snapshot) bool { return len(s.Asks) == 1 && s.Asks[0].Key == mkey }, "the answered ask never left the list")
wait(func(s view.Snapshot) bool { return len(s.Asks) == 0 }, "the answered ask never left the list")
}
// TestProcessWholeShowsTheBacklogOfBuildAsks starts the program whole against a bus holding the go seat's work-queue stream
@@ -827,7 +788,7 @@ func TestProcessWholeSaysTheSourceRevisionIsDelivered(t *testing.T) {
t.Fatal(err)
}
kvs := map[string]jetstream.KeyValue{}
for _, name := range []string{view.SourceBucket, view.AssignerWantsBucket, view.HaveBucket, viewstate.ReachBucket} {
for _, name := range []string{view.AssignerWantsBucket, view.HaveBucket, viewstate.ReachBucket} {
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: name, History: 5})
if err != nil {
t.Fatal(err)
@@ -856,7 +817,6 @@ func TestProcessWholeSaysTheSourceRevisionIsDelivered(t *testing.T) {
want, _ := meshwantsclient.EncodeWant(meshwantsclient.Want{Fp: fp, Composed: "sha256:" + strings.Repeat("c", 64)})
failed, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fp, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthFailed})
good, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fp, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK})
src, _ := meshdeliveryclient.EncodeSource(meshdelivery.SourceByBranchValue{Group: "feat/x", Heads: map[string]string{"novox/web": strings.Repeat("1", 40)}})
catalog.put(meshcatalog.CatalogEntry{Module: "web", Fp: fp, Manifest: map[string]any{"module": "a"}, Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath})
if _, err := kvs[view.AssignerWantsBucket].Put(ctx, "want.ace.web", want); err != nil {
t.Fatal(err)
@@ -864,10 +824,9 @@ func TestProcessWholeSaysTheSourceRevisionIsDelivered(t *testing.T) {
if _, err := kvs[view.HaveBucket].Put(ctx, "ace.web", failed); err != nil {
t.Fatal(err)
}
rev, err := kvs[view.SourceBucket].Put(ctx, meshdeliveryclient.SourceKey("main"), src)
if err != nil {
t.Fatal(err)
}
// The gate's source of the watched branch moves: the view asks the verb source for it and reads no gate state (A-173).
const rev = 41
catalog.moveSource(rev, "feat/x", map[string]string{"novox/web": strings.Repeat("1", 40)})
time.Sleep(time.Second)
if info, err := events.Info(ctx, jetstream.WithSubjectFilter("mesh.mod.mesh-delivery-view.event."+DeliveredEventType)); err != nil || len(info.State.Subjects) != 0 {
t.Fatalf("an event was said before the revision was delivered: %v %+v", err, info.State)
@@ -883,8 +842,8 @@ func TestProcessWholeSaysTheSourceRevisionIsDelivered(t *testing.T) {
t.Fatal("the delivery of the source revision was never said")
}
var e view.DeliveredEvent
if err := json.Unmarshal(msg.Data, &e); err != nil || e.Revision != rev || e.Group != "feat/x" || e.Heads["novox/web"] != strings.Repeat("1", 40) {
t.Errorf("the event = %+v, %v; want revision %d, group feat/x and the heads", e, err, rev)
if err := json.Unmarshal(msg.Data, &e); err != nil || e.Branch != catalogBranch || e.Revision != rev || e.Group != "feat/x" || e.Heads["novox/web"] != strings.Repeat("1", 40) {
t.Errorf("the event = %+v, %v; want the branch %s, revision %d, group feat/x and the heads", e, err, catalogBranch, rev)
}
if msg.Header.Get("x-source") != "mesh-delivery-view" || msg.Header.Get("x-event-id") == "" {
t.Errorf("the headers = %v", msg.Header)
@@ -1039,12 +998,3 @@ func TestProcessWholeSaysModulesInMotion(t *testing.T) {
t.Errorf("events said by type = %v, want moving 2, settled 1, stuck 1", count)
}
}
// askHeads is the heads of a merge ask as the seat carries them.
func askHeads(heads []meshdeliveryclient.Head) []meshdelivery.AsksByMergeGroupHeadsValueHeadsItem {
out := make([]meshdelivery.AsksByMergeGroupHeadsValueHeadsItem, len(heads))
for i, h := range heads {
out[i] = meshdelivery.AsksByMergeGroupHeadsValueHeadsItem{Repository: h.Repository, Number: int64(h.Number), Head: h.Head}
}
return out
}
@@ -14,15 +14,19 @@ import (
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
"io"
"log"
"maps"
"mesh-delivery-view/internal/view"
"net/http"
"os"
"os/signal"
"slices"
"strings"
"sync"
"syscall"
"time"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/trunk"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate"
"git.novox.be/novox/mesh-sdk/go/process"
@@ -123,36 +127,62 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
return err
}
defer calls.Close()
reader := &views.ProcessReader{Module: viewstate.ViewModule, Call: calls.Call, Logf: logger.Printf}
// The module's one pull of its durable consumer: the reader's views events and the waits' events are handlers on it, and it is
// started once, after both (design 47 G §512, A-242).
hub := process.NewEventHub(logger.Printf)
defer hub.Stop()
reader := &views.ProcessReader{Module: viewstate.ViewModule, Call: calls.Call, Logf: logger.Printf, Hub: hub}
writer, err := findingWriter(ctx, cred, conn, js, calls.State("findings"), logger.Printf)
if err != nil {
return fmt.Errorf("the view's finding writer: %w", err)
}
defer writer.Close()
reader.Findings = func() views.WireWriter { return writer }
sayErrs := make(chan error, 1)
sayAnswers(ctx, js, conn, cred, writer, sayErrs, logger.Printf)
for _, o := range ownersFollowed {
if _, err := reader.Follow(o.owner, o.state, o.tool); err != nil {
return err
}
}
open := buckets{js: js, reader: reader}
for _, bucket := range []string{view.HaveBucket, view.AssignmentsBucket, view.PresenceBucket, view.StuckBucket, view.CandidatesBucket, view.AsksBucket, view.AnswersBucket, builds.BuilderBuildsBucket, view.AssignerWantsBucket, view.SourceBucket, view.AssignerAsksBucket, viewstate.ViewAnswersBucket, view.TakenBucket} {
for _, bucket := range []string{view.HaveBucket, view.AssignmentsBucket, view.PresenceBucket, view.StuckBucket, view.CandidatesBucket, view.AnswersBucket, builds.BuilderBuildsBucket, view.AssignerWantsBucket, view.AssignerAsksBucket, viewstate.ViewAnswersBucket, view.TakenBucket, viewstate.ReachBucket} {
go follow(ctx, open, bucket, false, model, logger)
}
// The source's history gives each head's source revision (ADR 0556); the modules' history is each revision of the
// catalog half the view takes, in its order (below).
go follow(ctx, open, view.SourceBucket, true, model, logger)
// The view's own state reach, as it stood at the start, is what the derivation lays its history over (design 47 G A-225);
// the modules' history is each revision of the catalog half the view takes, in its order (below), and each watched
// branch's source is the seat mesh-delivery's verb source per branch, followed by its source-moved (A-173).
statusBucket := process.AwaitBucket(ctx, js, conn, firstViewsBucket, "view_first_views reads nothing and view_first_view_again is not heard", logger.Printf)
// The modules are the catalog half's, adopted whole by the seat's client: its first view, its status, its hand asks and
// its event catalog-revision (on the module's durable consumer) are the reader's.
catalog, _, err := meshcatalogclient.WireCatalogProcess(reader, calls.Call, "main", catalogChanged(model, logger))
// The modules are the catalog half's, per watched branch, through meshcatalogclient's branch follower adopted on the
// reader (design 47 G §532, §614): the registrations' branches, before the first registrations the gate's source's;
// each branch's first view, its status, its hand asks and its event catalog-revision are the reader's. A module's entry
// is the one Branches.Entry reads, the first branch of its own registration (A-103).
branches, err := meshcatalogclient.FollowBranchesProcess(reader, calls.Call)
if err != nil {
return err
}
branches.Logf = logger.Printf
sources := &branchSources{ctx: ctx, reader: reader, call: calls.Call, model: model, logger: logger}
modules := &catalogModules{ctx: ctx, branches: branches, sources: sources, model: model, logger: logger, applied: map[string]bool{}}
branches.Changed = modules.changed
reader.Start(ctx, js, cred, statusBucket)
catalogHeld(ctx, catalog, model)
if err := hearWaits(ctx, hub, js, logger.Printf); err != nil {
return fmt.Errorf("the waits' events are not handled: %w", err)
}
if err := hub.StartUntilBound(ctx, js, cred, nil, nil); err != nil {
logger.Printf("the consumer is not heard yet (tried again until it is): %v", err)
}
go func() {
select {
case <-branches.Registrations.Follower.Ready():
modules.settle()
case <-ctx.Done():
}
}()
var said error
select {
case <-ctx.Done():
case said = <-sayErrs:
case err := <-webUp:
if !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("the web view stopped: %w", err)
@@ -161,7 +191,23 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
shutdown, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
srv.Shutdown(shutdown)
return nil
return said
}
// sayAnswers says each change of the view's own state answers on its views family (`views.answers`), from the revision its
// marks state view-marks says it said up to, as every owner says its states (design 47 G A-175, A-178): the teller follows it
// by view_read_answers and that family, never by reading the state. A watch that ends is sent on errs, and the program ends
// with it, loudly.
func sayAnswers(ctx context.Context, js jetstream.JetStream, nc process.Subscriber, cred process.Account, writer views.WireWriter, errs chan<- error, logf func(string, ...any)) {
marks := viewstate.ViewModule + "_" + views.MarksState
process.AwaitBucket(ctx, js, nc, marks, "the view's answers are not said on its views family, so the teller waits on its first view", logf).Ready(func(mk jetstream.KeyValue) {
em := views.Emitter{Owner: viewstate.ViewModule, Subject: cred.EventSubject, Publish: views.JetStreamPublish(js), Marks: mk, Findings: writer, Logf: logf}
process.AwaitBucket(ctx, js, nc, viewstate.ViewAnswersBucket, "the view's answers are not said on its views family", logf).Ready(func(kv jetstream.KeyValue) {
if err := em.Start(ctx, viewstate.ViewAnswersState, kv, errs); err != nil {
logf("%v: the teller asks view_read_answers at each change until the view starts again", err)
}
})
})
}
// kvWriter is the view's one write: a key of its own bucket answers (manifest state entry answers), which the teller reads.
@@ -202,7 +248,7 @@ type ReachedEvent struct {
// The view's own state of its first views (views.StatusState).
var firstViewsBucket = viewstate.ViewModule + "_" + views.StatusState
// ownersFollowed are the indexer's and the assigner's states the view's process follows by their first views, each by its
// ownersFollowed are the indexer's, the assigner's and the teller's states the view's process follows by their first views, each by its
// owner's read tool (as the view's invokes names it) and views family (its events on the shared events stream); the
// view's tools ask the other six through the owners' read tools on each call.
var ownersFollowed = []struct{ owner, state, tool string }{
@@ -211,6 +257,7 @@ var ownersFollowed = []struct{ owner, state, tool string }{
{view.AssignerModule, "asks", view.AssignerModule + ".assigner_read_asks"},
{view.IndexerModule, "candidates", view.IndexerModule + ".indexer_read_candidates"},
{view.IndexerModule, "taken", view.IndexerModule + ".indexer_read_taken"},
{view.TellerModule, view.AnswersState, view.TellerModule + ".teller_read_answers"},
}
// buckets opens what the view watches: an owner's state as its picture (firstview), any other bucket on the bus.
@@ -298,34 +345,164 @@ func emitEvent(ctx context.Context, js jetstream.JetStream, cred process.Account
return err
}
// catalogChanged hands one module of a revision the view took to the model, in the form of its value; the modules'
// history is these changes in the order of the revisions.
func catalogChanged(model *view.Model, logger *log.Logger) func(string, json.RawMessage, bool, time.Time) {
return func(module string, entry json.RawMessage, gone bool, at time.Time) {
e := view.Entry{Bucket: view.ModulesBucket, Key: module, At: at, Deleted: gone}
if !gone {
v, err := view.ModuleValue(entry)
if err != nil {
logger.Printf("the catalog entry of %s is not taken: %v", module, err)
return
}
e.Value = v
}
model.Apply(e)
e.History = true
model.Apply(e)
}
// catalogModules hands the model the catalog half's modules, one value per module: the entry Branches.Entry reads. Until
// every watched branch has its first view (Branches.Ready) it hands nothing; once they all have, it hands every module
// whole and tells the model the modules are read (Initial), then each module a branch's change names. A branch adopted
// later without its first view stops the handing until it has one, when every module is handed whole again.
type catalogModules struct {
ctx context.Context
branches *meshcatalogclient.Branches
sources *branchSources // the source of each branch followed (A-173)
model *view.Model
logger *log.Logger
mu sync.Mutex
held bool // every module was handed whole since the last time a branch had no first view
waiting bool // a settle waits on the branches' first views
applied map[string]bool // the modules the model holds
}
// catalogHeld makes the model's pictures of the modules initial once the catalog half's first view is held: the process
// starts at once and waits for the seat's holder, saying so.
func catalogHeld(ctx context.Context, cat *meshcatalogclient.Catalog, model *view.Model) {
// changed is the branch follower's Changed: a module of one branch changed.
func (c *catalogModules) changed(_, module string, _ json.RawMessage, _ bool, at time.Time) {
c.follow()
c.mu.Lock()
held := c.held
c.mu.Unlock()
if !held {
c.settle()
return
}
e, ok, err := c.branches.Entry(module)
if err != nil {
c.mu.Lock()
c.held = false
c.mu.Unlock()
c.settle()
return
}
c.apply(module, e, ok, at)
}
// settle waits, on its own goroutine, for the first view of every followed branch, then hands every module whole. It
// waits on the views' Ready, never on a timer; with no branch followed it ends, and the next change starts it again.
func (c *catalogModules) settle() {
c.mu.Lock()
if c.waiting || c.held {
c.mu.Unlock()
return
}
c.waiting = true
c.mu.Unlock()
go func() {
select {
case <-cat.Follower.Ready():
model.Initial(view.ModulesBucket)
model.Initial(view.ReachHistory(view.ModulesBucket))
case <-ctx.Done():
defer func() {
c.mu.Lock()
c.waiting = false
c.mu.Unlock()
}()
for c.ctx.Err() == nil {
followed := c.branches.Followed()
if len(followed) == 0 {
return
}
for _, branch := range followed {
if cat, ok := c.branches.Catalog(branch); ok {
select {
case <-cat.Ready():
case <-c.ctx.Done():
return
}
}
}
if c.branches.Ready() == nil {
c.whole()
return
}
}
}()
}
// whole hands every module the followed branches list, removes from the model each it holds that none lists any more,
// and tells the model the modules are read.
func (c *catalogModules) whole() {
c.follow()
at := time.Now()
listed := map[string]bool{}
for _, branch := range c.branches.Followed() {
if cat, ok := c.branches.Catalog(branch); ok {
half, _ := cat.View()
for module := range half.Entries {
listed[module] = true
}
}
}
c.mu.Lock()
for module := range c.applied {
if !listed[module] {
listed[module] = false
}
}
c.mu.Unlock()
for _, module := range slices.Sorted(maps.Keys(listed)) {
e, ok, err := c.branches.Entry(module)
if err != nil {
c.logger.Printf("the catalog half is handed again at its next change: %v", err)
return
}
c.apply(module, e, ok, at)
}
c.mu.Lock()
c.held = true
c.mu.Unlock()
c.model.Initial(view.ModulesBucket)
c.model.Initial(view.ReachHistory(view.ModulesBucket))
}
// follow has the source of every followed branch followed, and no other (A-173).
func (c *catalogModules) follow() {
if c.sources != nil {
c.sources.ensure(c.branches.Followed())
}
}
// branchOf is the watched branch a module's entry is read from: the first branch of its registration, before the first
// registrations the first followed branch that lists it (design 47 G A-103, A-173); "" when none.
func (c *catalogModules) branchOf(module string) string {
if reg, ok := c.branches.Registrations.Registration(module); ok && len(reg.Branches) > 0 {
return reg.Branches[0]
}
for _, branch := range c.branches.Followed() {
if cat, ok := c.branches.Catalog(branch); ok {
if _, ok := cat.Entry(module); ok {
return branch
}
}
}
return ""
}
// apply hands the model one module's entry, or its removal when no branch lists it.
func (c *catalogModules) apply(module string, entry meshcatalog.CatalogEntry, listed bool, at time.Time) {
e := view.Entry{Bucket: view.ModulesBucket, Key: module, At: at, Deleted: !listed}
if listed {
e.Branch = c.branchOf(module)
}
if listed {
raw, _ := json.Marshal(entry)
v, err := view.ModuleValue(raw)
if err != nil {
c.logger.Printf("the catalog entry of %s is not taken: %v", module, err)
return
}
e.Value = v
}
c.mu.Lock()
if listed {
c.applied[module] = true
} else {
delete(c.applied, module)
}
c.mu.Unlock()
c.model.Apply(e)
e.History = true
c.model.Apply(e)
}
@@ -0,0 +1,153 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"mesh-delivery-view/internal/view"
"sync"
"time"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/sourcebranches"
"git.novox.be/novox/mesh-sdk/go/keys"
"git.novox.be/novox/mesh-sdk/go/views"
)
// sourceState is the state of the view of one watched branch's source in the view's reader: `mesh-delivery.source.<branch>`.
const sourceState = "source"
// adopter is how branchSources puts a branch's view on the view's one reader and takes it off (views.ProcessReader).
type adopter interface {
Adopt(f *views.Follower, seat, event string, hear func(context.Context, []byte) error) (*views.Picture, error)
Drop(owner, state string) error
}
// branchSources follows the source of each watched branch on its own (design 47 G §507, §512, A-173): per branch the
// first view of the seat mesh-delivery's verb source given that branch, then each source-moved of that branch, asked
// again until the revision heard is held; each answer goes to the model as the branch's SourceBucket entry. The branches
// are the ones the branch follower follows (ensure); none is named here.
type branchSources struct {
ctx context.Context
reader adopter
call func(key string, body any) (json.RawMessage, error)
model *view.Model
logger *log.Logger
mu sync.Mutex
branches map[string]*views.Follower // by escaped branch
}
// ensure follows the source of every branch named and drops the view of each followed branch no longer named.
func (s *branchSources) ensure(followed []string) {
s.mu.Lock()
defer s.mu.Unlock()
if s.branches == nil {
s.branches = map[string]*views.Follower{}
}
named := map[string]bool{}
for _, branch := range followed {
esc := keys.EscapeName(branch)
named[esc] = true
if s.branches[esc] != nil {
continue
}
f := s.follower(branch)
if _, err := s.reader.Adopt(f, meshdelivery.Seat, meshdelivery.EventSourceMoved, s.hear(branch, f)); err != nil {
s.logger.Printf("the source of %s is not followed, and is adopted again at the next change of the catalog half: %v", branch, err)
continue
}
s.branches[esc] = f
}
for esc := range s.branches {
if named[esc] {
continue
}
if err := s.reader.Drop(meshdelivery.Seat, views.BranchState(sourceState, mustUnescape(esc))); err != nil {
s.logger.Printf("the source of %s is not dropped: %v", esc, err)
continue
}
delete(s.branches, esc)
s.model.Apply(view.Entry{Bucket: view.SourceBucket, Key: esc, Deleted: true, At: time.Now()})
}
}
func mustUnescape(esc string) string {
name, err := keys.UnescapeName(esc)
if err != nil {
return esc
}
return name
}
// follower is the view of one branch's source: its one value, keyed by the escaped branch, the verb's answer.
func (s *branchSources) follower(branch string) *views.Follower {
esc := keys.EscapeName(branch)
f := &views.Follower{Owner: meshdelivery.Seat, Tool: view.SourceVerb, State: views.BranchState(sourceState, branch)}
f.Ask = func(context.Context, string) (views.Answer, error) {
a, err := view.SourceOf(s.call, branch)
if err != nil {
return views.Answer{}, err
}
raw, err := json.Marshal(a)
if err != nil {
return views.Answer{}, err
}
return views.Answer{V: 1, Owner: meshdelivery.Seat, State: f.State, Revision: uint64(a.Revision), Created: sourcebranches.Created,
Values: map[string]json.RawMessage{esc: raw}, At: time.Now()}, nil
}
f.Changed = func(_ string, value json.RawMessage, deleted bool, at time.Time) {
e := view.Entry{Bucket: view.SourceBucket, Key: esc, Deleted: deleted, At: at}
if !deleted {
var a meshdelivery.SourceAnswer
if err := json.Unmarshal(value, &a); err != nil {
s.logger.Printf("the source of %s is not taken: %v", branch, err)
return
}
se, err := view.SourceEntry(branch, a)
if err != nil {
s.logger.Printf("the source of %s is not taken: %v", branch, err)
return
}
se.At = at
e = se
}
s.model.Apply(e)
}
return f
}
// revisionOf is the source revision f holds of the branch, 0 when none.
func revisionOf(f *views.Follower, esc string) int64 {
raw, ok := f.Get(esc)
if !ok {
return 0
}
var a meshdelivery.SourceAnswer
if json.Unmarshal(raw, &a) != nil {
return 0
}
return a.Revision
}
// hear is the handler of source-moved for one branch's view: another branch's event is not its own; a revision it does not
// hold yet asks the first view again until it does, on its own goroutine, so the consumer is never held.
func (s *branchSources) hear(branch string, f *views.Follower) func(context.Context, []byte) error {
esc := keys.EscapeName(branch)
return func(ctx context.Context, data []byte) error {
var ev meshdelivery.SourceMovedEvent
if err := json.Unmarshal(data, &ev); err != nil || ev.Branch == "" {
return fmt.Errorf("not a %s event: %s", meshdelivery.EventSourceMoved, sourcebranches.ClipBytes(data))
}
if ev.Branch != branch && ev.Branch != esc {
return nil
}
held := func() bool { return revisionOf(f, esc) >= ev.Revision }
if held() {
return nil
}
go sourcebranches.CatchUp(s.ctx, f, held, fmt.Sprintf("source of %s moved to %d", branch, ev.Revision))
return nil
}
}
@@ -0,0 +1,94 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate"
"git.novox.be/novox/mesh-sdk/go/process"
"github.com/nats-io/nats.go/jetstream"
)
// The events of a wait, said by every part that holds one (the seats mesh-wants, node-engine, mesh-pipeline-runner and
// mesh-grants on their subjects, the builder and the others on their module's): the view keeps them in its state waits
// (design 47 G §512, §376, A-242).
const (
eventWaitHeld = "wait-held"
eventWaitEnded = "wait-ended"
)
// waitPatterns are the subjects the view's hub handles for the waits: a seat's and a module's, each with and without the
// node or key the sayer adds after the event's name.
func waitPatterns() []string {
var out []string
for _, prefix := range []string{"mesh.seat.*.event.", "mesh.mod.*.event."} {
for _, event := range []string{eventWaitHeld, eventWaitEnded} {
out = append(out, prefix+event, prefix+event+".>")
}
}
return out
}
// applyWait keeps one wait event: wait-held puts the wait under its key, wait-ended deletes it. The owner is the seat or module
// of the subject unless the body names one.
func applyWait(subject string, body []byte, put func(key string, value []byte) error, del func(key string) error) error {
parts := strings.Split(subject, ".")
if len(parts) < 5 || parts[0] != "mesh" || (parts[1] != "seat" && parts[1] != "mod") || parts[3] != "event" {
return fmt.Errorf("%s is no wait's subject", subject)
}
w, err := viewstate.ReadWait(parts[2], body)
if err != nil {
return fmt.Errorf("%s: %w", subject, err)
}
switch parts[4] {
case eventWaitHeld:
raw, err := json.Marshal(w)
if err != nil {
return err
}
return put(w.Key(), raw)
case eventWaitEnded:
return del(w.Key())
}
return fmt.Errorf("%s is no wait's subject", subject)
}
// hearWaits registers the waits' handlers on the process's hub; each keeps its event in the state waits and acknowledges it, and
// one that could not be kept is not acknowledged, so the bus gives it again.
func hearWaits(ctx context.Context, hub *process.EventHub, js jetstream.JetStream, logf func(string, ...any)) error {
kv := func() (jetstream.KeyValue, error) { return js.KeyValue(ctx, viewstate.WaitsBucket) }
put := func(key string, value []byte) error {
b, err := kv()
if err != nil {
return err
}
_, err = b.Put(ctx, key, value)
return err
}
del := func(key string) error {
b, err := kv()
if err != nil {
return err
}
if err = b.Delete(ctx, key); errors.Is(err, jetstream.ErrKeyNotFound) {
return nil
}
return err
}
for _, pattern := range waitPatterns() {
if err := hub.Handle(pattern, func(m jetstream.Msg) {
if err := applyWait(m.Subject(), m.Data(), put, del); err != nil {
logf("the wait event %s is not kept (the bus gives it again): %v", m.Subject(), err)
_ = m.Nak()
return
}
_ = m.Ack()
}); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,36 @@
package main
import (
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/process"
"github.com/nats-io/nats.go/jetstream"
)
func TestWaitEventsAreKeptUnderTheirKeyAndEnded(t *testing.T) {
kept := map[string]string{}
put := func(k string, v []byte) error { kept[k] = string(v); return nil }
del := func(k string) error { delete(kept, k); return nil }
held := `{"step":"delivery","module":"web","node":"ace","part":"db","machines":["g14"],"since":"2026-11-12T01:00:00Z","words":"waiting for delivery of db to g14"}`
if err := applyWait("mesh.seat.mesh-wants.event.wait-held", []byte(held), put, del); err != nil || len(kept) != 1 ||
!strings.Contains(kept["mesh-wants.delivery.ace.web"], "waiting for delivery of db to g14") {
t.Fatalf("kept %v, %v", kept, err)
}
if err := applyWait("mesh.mod.mesh-delivery-builder.event.wait-held.web.fp", []byte(`{"step":"building","module":"web","on":"toolchain:go@1","since":"2026-11-12T01:00:00Z"}`), put, del); err != nil || len(kept) != 2 {
t.Fatalf("a module's wait: %v, %v", kept, err)
}
if err := applyWait("mesh.seat.mesh-wants.event.wait-ended", []byte(`{"step":"delivery","module":"web","node":"ace","how":"ran","since":"2026-11-12T01:00:00Z"}`), put, del); err != nil || len(kept) != 1 {
t.Fatalf("ended: %v, %v", kept, err)
}
if err := applyWait("mesh.seat.mesh-wants.event.wait-held", []byte(`{"module":"web"}`), put, del); err == nil {
t.Error("a wait with no step was kept")
}
// None of the patterns overlaps another: the hub gives a message one taker.
hub := process.NewEventHub(func(string, ...any) {})
for _, p := range waitPatterns() {
if err := hub.Handle(p, func(jetstream.Msg) {}); err != nil {
t.Fatalf("the pattern %s overlaps another: %v", p, err)
}
}
}
@@ -97,12 +97,15 @@ func TestTheViewWritesItsAnswerUnderTheAsksViewKey(t *testing.T) { // 1297/B9a A
func TestTheViewRefusesWhatItMayNotAnswer(t *testing.T) { // 1297/B9a AC
e, a := holdAsk(t)
merge := askEntry(t, "feat/x", "Merge into main: mesh-delivery#12", "Merges.")
mergeKey, err := meshdeliveryclient.MergeAskKey("feat/x", []meshdeliveryclient.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}})
if err != nil {
t.Fatal(err)
}
cases := []struct {
name, key, option, want string
prep []Entry
}{
{"the merge's Approve", merge.Key, "approve", "answer it on the phone (issue 953)", []Entry{merge}},
{"the merge's Approve", mergeKey, "approve", "answer it on the phone (issue 953)", nil},
{"an option the ask does not offer", e.Key, "delete", `offers no option "delete"`, []Entry{e}},
{"an ask the view does not hold", e.Key, "release", "no open ask", nil},
{"an ask that is no ask key", "hold.x", "release", "not an ask key", nil},
@@ -145,9 +148,11 @@ func TestTheSocketAnswersAnAsk(t *testing.T) { // 1297/B9b
if _, ok := w.puts[e.Key+".view"]; !ok {
t.Fatalf("puts = %v", w.puts)
}
merge := askEntry(t, "feat/x", "Merge", "Merges.")
fill(m, merge)
err := AnswerAsk(path, merge.Key, "approve", "")
mergeKey, err := meshdeliveryclient.MergeAskKey("feat/x", []meshdeliveryclient.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}})
if err != nil {
t.Fatal(err)
}
err = AnswerAsk(path, mergeKey, "approve", "")
if err == nil || !strings.Contains(err.Error(), "answer it on the phone (issue 953)") {
t.Errorf("merge over the socket: %v", err)
}
@@ -204,9 +209,11 @@ func TestThePageAnswersAnAskAndRefusesAForeignPost(t *testing.T) { // 1297/B9a
t.Fatalf("answer = %d %q, puts %v", rec.Code, rec.Body.String(), w.puts)
}
// The merge's Approve is refused with its reason, and the rest of the page stays read-only.
merge := askEntry(t, "feat/x", "Merge", "Merges.")
fill(m, merge)
rec = postAnswer(h, url.Values{"key": {merge.Key}, "option": {"approve"}}, nil)
mergeKey, err := meshdeliveryclient.MergeAskKey("feat/x", []meshdeliveryclient.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}})
if err != nil {
t.Fatal(err)
}
rec = postAnswer(h, url.Values{"key": {mergeKey}, "option": {"approve"}}, nil)
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "answer it on the phone (issue 953)") || len(w.puts) != 1 {
t.Errorf("merge = %d %q", rec.Code, rec.Body.String())
}
@@ -7,35 +7,12 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshdeliveryclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery"
)
const askHeadA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
// askEntry is a key of the gate's asks as the gate writes it (mesh-sdk go/delivery EncodeAsk, MergeAskKey): the router's ask
// whole, under the id its key gives.
func askEntry(t *testing.T, group, headline, words string) Entry {
t.Helper()
heads := []meshdeliveryclient.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: askHeadA}}
key, err := meshdeliveryclient.MergeAskKey(group, heads)
if err != nil {
t.Fatal(err)
}
a := oc.Ask{ID: meshdeliveryclient.AskID(key), Headline: headline, Explanation: words, Who: occ.Operator, Expires: occ.FormatTime(t0.Add(23 * time.Hour)), OnExpiry: "the merge stays held",
Options: []oc.Option{{ID: "approve", Label: "Approve", Does: "merges", Level: occ.Approve, Binds: "sha256:00"}}}
v, err := meshdeliveryclient.EncodeAsk(meshdelivery.AsksByMergeGroupHeadsValue{Group: group, Heads: askHeads(heads), Ask: rawAsk(t, a)})
if err != nil {
t.Fatal(err)
}
return Entry{Bucket: AsksBucket, Key: key, Value: v, At: t0}
}
// answerEntry is the teller's answer to the first asking of the key; answerToAsking to the asking with this router id. The
// answer's warrant is `<router id>@<time>` (teller.go warrantID).
func answerEntry(t *testing.T, key string) Entry {
@@ -44,7 +21,7 @@ func answerEntry(t *testing.T, key string) Entry {
func answerToAsking(t *testing.T, key, id string) Entry {
t.Helper()
v, err := meshdeliveryclient.EncodeAnswer(meshdeliveryclient.Answer{Option: "approve", Warrant: id + "@2026-10-18T12:00:00Z", Words: "the operator, via telegram, chose Approve"})
v, err := meshdeliveryclient.EncodeAnswer(meshdeliveryclient.Answer{Option: "release", Warrant: id + "@2026-10-18T12:00:00Z", Words: "the operator, via telegram, chose Release"})
if err != nil {
t.Fatal(err)
}
@@ -53,17 +30,12 @@ func answerToAsking(t *testing.T, key, id string) Entry {
func TestAnAskWithNoAnswerIsListedAndAnAnsweredOneIsNot(t *testing.T) { // 1345/AC1
m := New()
e := askEntry(t, "feat/x", "Merge into main: mesh-delivery#12", "Merges one pull request.\nReview: approved.")
e, a := holdAsk(t)
fill(m, e)
s := m.Snapshot()
if len(s.Asks) != 1 {
if len(s.Asks) != 1 || s.Asks[0].Key != e.Key || s.Asks[0].Headline != a.Headline || s.Asks[0].How != AnswerInTheView {
t.Fatalf("asks = %+v, want the one open ask", s.Asks)
}
a := s.Asks[0]
if a.Key != e.Key || a.Group != "feat/x" || a.Headline != "Merge into main: mesh-delivery#12" || !strings.Contains(a.Words, "Merges one pull request.") ||
a.How != "answer on the phone" || len(a.Pulls) != 1 || !strings.Contains(a.Pulls[0], "novox/mesh-delivery#12") || !strings.Contains(a.Pulls[0], askHeadA[:12]) {
t.Errorf("ask = %+v", a)
}
fill(m, answerEntry(t, e.Key))
if got := m.Snapshot().Asks; len(got) != 0 {
t.Errorf("an answered ask is listed: %+v", got)
@@ -73,15 +45,26 @@ func TestAnAskWithNoAnswerIsListedAndAnAnsweredOneIsNot(t *testing.T) { // 1345/
if got := m.Snapshot().Asks; len(got) != 1 {
t.Errorf("asks after the answer was taken away = %+v", got)
}
fill(m, Entry{Bucket: AsksBucket, Key: e.Key, Deleted: true})
fill(m, Entry{Bucket: AssignerAsksBucket, Key: e.Key, Deleted: true})
if got := m.Snapshot().Asks; len(got) != 0 {
t.Errorf("a withdrawn ask is listed: %+v", got)
}
}
// The gate's asks go with the Approve per merge (design 47 G §431, §512): a value under the gate's former state is no ask
// of the view's, and nothing names it unreadable.
func TestTheGatesMergeAsksAreNoAsksOfTheView(t *testing.T) {
m := New()
fill(m, Entry{Bucket: "mesh-delivery-gate_asks", Key: "merge.g.0123456789ab", Value: []byte(`{"v":1,"group":"g"}`), At: t0},
Entry{Bucket: "mesh-delivery-gate_asks", Key: "merge.h.0123456789ab", Value: []byte("{"), At: t0})
if s := m.Snapshot(); len(s.Asks) != 0 || len(s.Unreadable) != 0 {
t.Errorf("snapshot = %+v", s)
}
}
func TestAnAnswerHeardBeforeItsAskStillHidesTheAsk(t *testing.T) {
m := New()
e := askEntry(t, "feat/x", "Merge", "Merges.")
e, _ := holdAsk(t)
fill(m, answerEntry(t, e.Key), e)
if got := m.Snapshot().Asks; len(got) != 0 {
t.Errorf("asks = %+v", got)
@@ -90,26 +73,28 @@ func TestAnAnswerHeardBeforeItsAskStillHidesTheAsk(t *testing.T) {
func TestAnUnreadableAskIsNamedAndNotListed(t *testing.T) { // never dropped silently
m := New()
fill(m, Entry{Bucket: AsksBucket, Key: "merge.g.0123456789ab", Value: []byte("{"), At: t0})
e, _ := holdAsk(t)
fill(m, Entry{Bucket: AssignerAsksBucket, Key: e.Key, Value: []byte("{"), At: t0})
s := m.Snapshot()
if len(s.Asks) != 0 || len(s.Unreadable) != 1 || s.Unreadable[0].Bucket != AsksBucket {
if len(s.Asks) != 0 || len(s.Unreadable) != 1 || s.Unreadable[0].Bucket != AssignerAsksBucket {
t.Errorf("snapshot = %+v", s)
}
}
func TestTableAndPageShowTheAsks(t *testing.T) { // 1345/AC2
m := New()
fill(m, askEntry(t, "feat/x", "Merge into main: mesh-delivery#12", "Merges one pull request."))
e, a := holdAsk(t)
fill(m, e)
var out bytes.Buffer
Table(&out, m.Snapshot())
for _, want := range []string{"ASKS", "Merge into main: mesh-delivery#12", "Merges one pull request.", "answer on the phone", "novox/mesh-delivery#12"} {
for _, want := range []string{"ASKS", a.Headline, a.Explanation, AnswerInTheView} {
if !strings.Contains(out.String(), want) {
t.Errorf("the table lacks %q:\n%s", want, out.String())
}
}
rec := httptest.NewRecorder()
Web(m).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
for _, want := range []string{"ASKS", "Merge into main: mesh-delivery#12", "Merges one pull request.", "answer on the phone"} {
for _, want := range []string{"ASKS", a.Headline, AnswerInTheView} {
if !strings.Contains(rec.Body.String(), want) {
t.Errorf("the page lacks %q:\n%s", want, rec.Body.String())
}
@@ -120,11 +105,6 @@ func TestTableAndPageShowTheAsks(t *testing.T) { // 1345/AC2
if strings.Contains(out.String(), "ASKS") {
t.Errorf("the table shows an ASKS section with no ask:\n%s", out.String())
}
}
func TestTheSnapshotsJSONCarriesTheAsks(t *testing.T) { // 1345/AC2
m := New()
fill(m, askEntry(t, "feat/x", "Merge into main: mesh-delivery#12", "Merges one pull request."))
raw, err := json.Marshal(m.Snapshot())
if err != nil {
t.Fatal(err)
@@ -135,7 +115,7 @@ func TestTheSnapshotsJSONCarriesTheAsks(t *testing.T) { // 1345/AC2
How string `json:"how"`
} `json:"asks"`
}
if err := json.Unmarshal(raw, &back); err != nil || len(back.Asks) != 1 || back.Asks[0].How != "answer on the phone" {
if err := json.Unmarshal(raw, &back); err != nil || len(back.Asks) != 1 || back.Asks[0].How != AnswerInTheView {
t.Errorf("json %s: %+v %v", raw, back, err)
}
}
@@ -164,33 +144,7 @@ func TestAHaveOfARemovedModuleIsShownRemovedNotLeftBehind(t *testing.T) { // 137
}
}
func TestAnAnswerToAnEarlierAskingDoesNotHideTheNextAsking(t *testing.T) { // R2-1 of review round 2
m := New()
e := askEntry(t, "feat/x", "Merge into main: mesh-delivery#12", "Merges one pull request.")
fill(m, e, answerEntry(t, e.Key))
if got := m.Snapshot().Asks; len(got) != 0 {
t.Fatalf("asking 1 is answered and listed: %+v", got)
}
// The gate rewrites the key at asking 2; only asking 1's answer exists.
heads := []meshdeliveryclient.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: askHeadA}}
a := oc.Ask{ID: meshdeliveryclient.AskingID(e.Key, 2), Headline: "Merge into main: mesh-delivery#12", Explanation: "Merges one pull request.", Who: occ.Operator,
Expires: occ.FormatTime(t0.Add(23 * time.Hour)), OnExpiry: "the merge stays held",
Options: []oc.Option{{ID: "approve", Label: "Approve", Does: "merges", Level: occ.Approve, Binds: "sha256:00"}}}
v, err := meshdeliveryclient.EncodeAsk(meshdelivery.AsksByMergeGroupHeadsValue{Group: "feat/x", Heads: askHeads(heads), Asking: 2, Ask: rawAsk(t, a)})
if err != nil {
t.Fatal(err)
}
fill(m, Entry{Bucket: AsksBucket, Key: e.Key, Value: v, At: t0})
if got := m.Snapshot().Asks; len(got) != 1 {
t.Fatalf("asking 2 is not listed while only asking 1's answer exists: %+v", got)
}
fill(m, answerToAsking(t, e.Key, a.ID))
if got := m.Snapshot().Asks; len(got) != 0 {
t.Errorf("asking 2 is answered and listed: %+v", got)
}
}
// rawAsk is the router's ask as the JSON a gate's or an assigner's value carries (mesh-sdk keeps it raw, ADR 0627 section 4).
// rawAsk is the router's ask as the JSON an assigner's value carries (mesh-sdk keeps it raw, ADR 0627 section 4).
func rawAsk(t *testing.T, a oc.Ask) json.RawMessage {
t.Helper()
b, err := json.Marshal(a)
@@ -199,12 +153,3 @@ func rawAsk(t *testing.T, a oc.Ask) json.RawMessage {
}
return b
}
// askHeads is the heads of a merge ask as the seat carries them.
func askHeads(heads []meshdeliveryclient.Head) []meshdelivery.AsksByMergeGroupHeadsValueHeadsItem {
out := make([]meshdelivery.AsksByMergeGroupHeadsValueHeadsItem, len(heads))
for i, h := range heads {
out[i] = meshdelivery.AsksByMergeGroupHeadsValueHeadsItem{Repository: h.Repository, Number: int64(h.Number), Head: h.Head}
}
return out
}
@@ -8,6 +8,7 @@ package view
import (
"encoding/json"
"fmt"
"strings"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
@@ -22,7 +23,8 @@ const CatalogSeat = meshcatalog.Seat
const ModulesBucket = CatalogSeat + "_modules"
// ModuleValue is the value of one module of the model, as the model decodes it, made from the catalog entry (its JSON):
// the fingerprint is the entry's `fp` (the only form there is), the source revisions are the entry's inputs'. A manifest
// the fingerprint is the entry's `fp` (the only form there is), the manifest's digest the entry's `manifest-digest`, the
// source revisions are the entry's inputs'. A manifest
// held for unknown fields has no verdict here: the model reads the fingerprint and the commit.
func ModuleValue(entry json.RawMessage) (json.RawMessage, error) {
var e meshcatalog.CatalogEntry
@@ -30,11 +32,9 @@ func ModuleValue(entry json.RawMessage) (json.RawMessage, error) {
return nil, fmt.Errorf("a catalog entry does not read: %v", err)
}
v := meshcatalog.ModulesByModuleValue{Commit: e.Commit, Fp2: e.Fp, None2: e.None, Path: e.Path, Repository: e.Repository, Packages: e.Packages}
if len(e.Manifest) > 0 {
// The entry holds its manifest whole; the model keeps the digest of its bytes, as the value it always kept does.
raw, _ := json.Marshal(e.Manifest)
v.Manifest = meshcatalogclient.ManifestDigest(raw)
}
// The entry carries the digest of its manifest's bytes as the indexer read them, `sha256:` and the 64 hex digits; the
// model keeps the bare digits, as the value it always kept, and computes none (design 47 G §506, §578).
v.Manifest = strings.TrimPrefix(e.ManifestDigest, "sha256:")
if e.Verdict == meshcatalogclient.VerdictAccepted || e.Verdict == meshcatalogclient.VerdictRefused {
v.Verdict = e.Verdict
}
@@ -0,0 +1,93 @@
package view
import (
"encoding/json"
"fmt"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"git.novox.be/novox/mesh-sdk/go/buscalls"
)
// The seat mesh-wants' verbs revision and difference as the view's invokes names them: the tiers of a source revision are
// read through them and worked out nowhere in the view (design 47 G §512, §186, A-242).
const (
RevisionVerb = "seat:" + meshwants.Seat + "." + meshwants.VerbRevision
DifferenceVerb = "seat:" + meshwants.Seat + "." + meshwants.VerbDifference
)
// WalkMost is the most kept assignment revisions a source revision's revision is looked for through, newest first.
const WalkMost = 256
// callSeat asks verb with body through call and reads its declared answer into out.
func callSeat(call func(key string, body any) (json.RawMessage, error), verb string, body, out any) error {
raw, err := call(verb, body)
if err != nil {
return fmt.Errorf("%s: %w", verb, err)
}
answer, said, ok := buscalls.Unwrap(raw)
if !ok {
return fmt.Errorf("%s answered: %s", verb, said)
}
if err := json.Unmarshal(answer, out); err != nil {
return fmt.Errorf("the answer of %s does not read: %w", verb, err)
}
return nil
}
// AssignmentOf asks the seat for one kept assignment graph revision (0: the trunk's newest).
func AssignmentOf(call func(key string, body any) (json.RawMessage, error)) func(revision int64) (meshwants.AssignmentRevision, error) {
return func(revision int64) (meshwants.AssignmentRevision, error) {
var a meshwants.RevisionAnswer
err := callSeat(call, RevisionVerb, meshwants.RevisionInput{Revision: revision}, &a)
return a.Revision, err
}
}
// DifferenceOf asks the seat for the difference of two kept revisions.
func DifferenceOf(call func(key string, body any) (json.RawMessage, error)) func(from, to int64) (meshwants.Difference, error) {
return func(from, to int64) (meshwants.Difference, error) {
var d meshwants.Difference
err := callSeat(call, DifferenceVerb, meshwants.DifferenceInput{From: from, To: to}, &d)
return d, err
}
}
// revisionsOf finds the kept assignment revisions that carry the source revision for a watched branch: to the first of them
// (the one whose change it is) and from the revision kept before it, walking back from the newest. A zero from says there is
// none before it.
func (r Reader) revisionsOf(source uint64) (from, to int64, err error) {
if r.Assignment == nil || r.Difference == nil {
return 0, 0, fmt.Errorf("the assigner's revisions cannot be read here")
}
newest, err := r.Assignment(0)
if err != nil {
return 0, 0, fmt.Errorf("the assigner's newest revision: %w", err)
}
carries := func(rev meshwants.AssignmentRevision) bool {
for _, c := range rev.Catalogs {
if c.Source > 0 && uint64(c.Source) == source {
return true
}
}
return false
}
for n, steps := newest.Revision, 0; n >= 1 && steps < WalkMost; n, steps = n-1, steps+1 {
rev := newest
if n != newest.Revision {
if rev, err = r.Assignment(n); err != nil {
continue // a revision no longer kept
}
}
if !carries(rev) {
if to != 0 {
return rev.Revision, to, nil // the kept revision before the first that carries it
}
continue
}
to = rev.Revision
}
if to == 0 {
return 0, 0, fmt.Errorf("no kept assignment revision of the assigner carries the source revision %d", source)
}
return 0, to, nil
}
@@ -3,7 +3,6 @@ package view
import (
"fmt"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshdeliveryclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/trunk"
"git.novox.be/novox/mesh-sdk/go/keys"
@@ -40,10 +39,12 @@ const (
AssignerWantsBucket = AssignerModule + "_wants"
AssignerAsksBucket = AssignerModule + "_asks"
// The gate's and the teller's states.
// SourceBucket names, in the model's entries, each watched branch's source as the seat mesh-delivery's verb source answers
// it, keyed by the escaped branch (design 47 G §507, A-173): never a bucket the view reads. The teller's state answers as
// the view follows it by its views family (design 47 G §431, A-175): its picture `<owner>_<state>`.
SourceBucket = GateModule + "_source"
AsksBucket = GateModule + "_asks"
AnswersBucket = TellerModule + "_answers"
AnswersState = "answers"
AnswersBucket = TellerModule + "_" + AnswersState
// The watchman's and the node-engine's states.
PresenceBucket = WatchmanModule + "_presence"
@@ -54,7 +55,6 @@ const (
StepsState = "steps"
// The revisions each of those states keeps per key.
AsksHistory = 5
AnswersHistory = 5
AssignerAsksHistory = 5
@@ -88,7 +88,3 @@ func PackagesKey(module string) (string, error) {
}
return meshcatalog.PackagesByModuleKey(keys.EscapeName(module)), nil
}
// trunkSourceKey is the key of the gate's state source for the trunk, the watched branch `main` (design 47 G §305: the source
// is kept per watched branch, meshdeliveryclient.SourceKey; the view's reads are of the trunk's).
var trunkSourceKey = meshdeliveryclient.SourceKey("main")
@@ -9,7 +9,6 @@ import (
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshdeliveryclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/trunk"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate"
@@ -22,14 +21,15 @@ import (
// head as its event delivered, per repository. Nothing here records a merge as delivered (ADR 0429 §1): a merge at or before
// the reached head is on the machines, derived by whoever reads it.
// ReachHistory names the watch of a bucket with its history, as Initial is told it: the modules' and the source's history
// give each fingerprint's first commit and each head's source revision.
// ReachHistory names the watch of a bucket with its history, as Initial is told it: the modules' history gives each
// fingerprint's first commit. Each head's source revision is from the branch's sources heard since the start (design 47 G
// A-173, A-225), laid over the reach the view's own state kept.
func ReachHistory(bucket string) string { return bucket + "#history" }
// reachBuckets are the watches the derivation waits for before it writes or says anything, so a start does not write a reach
// derived from half its inputs.
var reachBuckets = []string{ModulesBucket, AssignerWantsBucket, HaveBucket, SourceBucket,
TakenBucket, ReachHistory(ModulesBucket), ReachHistory(SourceBucket)}
// derived from half its inputs; the view's own reach first, which a restart lays the derivation over (A-225).
var reachBuckets = []string{ModulesBucket, AssignerWantsBucket, HaveBucket, TakenBucket, ReachHistory(ModulesBucket),
viewstate.ReachBucket}
// ReachWrite is a reach value to write under its key in the view's state reach.
type ReachWrite struct {
@@ -45,8 +45,7 @@ func (m *Model) SetReachWriter(put func(ReachWrite) error) {
m.reachPut = put
}
// applyHistory takes one revision of a watch with history: of the indexer's modules (each module's values in order) and of
// the gate's source (each revision's heads).
// applyHistory takes one revision of a watch with history: of the catalog half's modules (each module's values in order).
func (m *Model) applyHistory(e Entry) {
switch e.Bucket {
case ModulesBucket:
@@ -70,15 +69,6 @@ func (m *Model) applyHistory(e Entry) {
h = h[len(h)-viewstate.MostChanges:]
}
m.modHist[e.Key] = h
case SourceBucket:
if e.Key != trunkSourceKey || e.Deleted || e.Revision == 0 {
return
}
src, err := meshdeliveryclient.DecodeSource(e.Value)
if err != nil {
return
}
m.srcHist[e.Revision] = src.Heads
}
}
@@ -88,11 +78,13 @@ type headAt struct {
rev uint64
}
// headsOf are the heads of a repository in the source's history, oldest first, each with the first revision naming it.
func (m *Model) headsOf(repository string) []headAt {
// headsOf are the heads of a repository in the history of one branch's source (escaped), oldest first, each with the first
// revision naming it.
func (m *Model) headsOf(branch, repository string) []headAt {
var out []headAt
for _, rev := range slices.Sorted(maps.Keys(m.srcHist)) {
for r, head := range m.srcHist[rev] {
hist := m.srcHist[branch]
for _, rev := range slices.Sorted(maps.Keys(hist)) {
for r, head := range hist[rev] {
if !trunk.SameRepository(r, repository) || head == "" {
continue
}
@@ -136,10 +128,11 @@ func (m *Model) wantKeys() map[string][]string {
return out
}
// deriveReach is the reach of one repository from the model as it stands; wants are the wants' keys by module (wantKeys).
func (m *Model) deriveReach(repository string, wants map[string][]string) viewstate.Reach {
// deriveReach is the reach of one repository from the source of its branch (escaped) as the model holds it; wants are the
// wants' keys by module (wantKeys).
func (m *Model) deriveReach(branch, repository string, wants map[string][]string) viewstate.Reach {
r := viewstate.Reach{Repository: trunk.RepositoryName(repository), Modules: map[string]viewstate.ModuleReach{}}
heads := m.headsOf(repository)
heads := m.headsOf(branch, repository)
key, _ := trunk.RepositoryKey(repository)
if t, ok := m.taken[key]; ok {
r.Indexed = t.Merge
@@ -178,6 +171,11 @@ func (m *Model) deriveReach(repository string, wants map[string][]string) viewst
r.Reached, r.ReachedSrc = h.commit, h.rev
}
}
// The history is the revisions heard since the start: the reach the view's own state kept at the start stands until one
// derived from them is newer (A-225).
if k, ok := m.kept[key]; ok && k.Reached != "" && (r.Reached == "" || r.ReachedSrc < k.ReachedSrc) {
r.Reached, r.ReachedSrc = k.Reached, k.ReachedSrc
}
if n := len(heads); n > 0 && !sameCommit(heads[n-1].commit, r.Reached) {
if indexed != 0 && indexed < heads[n-1].rev {
holds = append(holds, fmt.Sprintf("the indexer has not yet taken in %.12s", heads[n-1].commit))
@@ -387,11 +385,30 @@ func (m *Model) reachReady() bool {
return false
}
}
return m.source != nil && m.source.err == ""
return true
}
// reachNow derives the reach of every repository the source names, and answers the values that changed and the reached
// heads not yet said. It is called with the lock held.
// branchOf is the escaped branch a repository's reach is derived from: the first branch of its modules' registration (by module
// name, the first that has one), before the first registrations the first branch whose source names the repository (design 47
// G A-173). "" when none.
func (m *Model) branchOf(repository string) string {
for _, name := range slices.Sorted(maps.Keys(m.modules)) {
if me := m.modules[name]; me.err == "" && trunk.SameRepository(me.value.Repository, repository) && m.modBranch[name] != "" {
return m.modBranch[name]
}
}
for _, b := range slices.Sorted(maps.Keys(m.sources)) {
for r := range m.sources[b].heads {
if trunk.SameRepository(r, repository) {
return b
}
}
}
return ""
}
// reachNow derives the reach of every repository a watched branch's source names, from the source of the repository's own
// branch (branchOf), and answers the values that changed and the reached heads not yet said. It is called with the lock held.
func (m *Model) reachNow() ([]ReachWrite, []DeliveredEvent) {
if (m.reachPut == nil && m.deliveredEvent == nil) || !m.reachReady() {
return nil, nil
@@ -399,12 +416,25 @@ func (m *Model) reachNow() ([]ReachWrite, []DeliveredEvent) {
var writes []ReachWrite
var events []DeliveredEvent
wants := m.wantKeys()
for _, repository := range slices.Sorted(maps.Keys(m.source.heads)) {
type repoAt struct{ branch, repository string }
var repos []repoAt
for _, b := range slices.Sorted(maps.Keys(m.sources)) {
if m.sources[b].err != "" {
continue
}
for _, repository := range slices.Sorted(maps.Keys(m.sources[b].heads)) {
if m.branchOf(repository) == b {
repos = append(repos, repoAt{b, repository})
}
}
}
for _, ra := range repos {
repository := ra.repository
key, err := trunk.RepositoryKey(repository)
if err != nil {
continue
}
r := m.deriveReach(repository, wants)
r := m.deriveReach(ra.branch, repository, wants)
if prev, ok := m.reach[key]; !ok || !viewstate.SameReach(prev, r) {
if data, err := viewstate.EncodeReach(r); err == nil {
m.reach[key] = r
@@ -415,7 +445,7 @@ func (m *Model) reachNow() ([]ReachWrite, []DeliveredEvent) {
said := strings.Join(notYet, ",")
if r.Reached != "" && (r.ReachedSrc > m.reachSaid[key] || said != m.reachNotYet[key]) {
m.reachSaid[key], m.reachNotYet[key] = r.ReachedSrc, said
events = append(events, DeliveredEvent{Revision: r.ReachedSrc, Heads: map[string]string{r.Repository: r.Reached},
events = append(events, DeliveredEvent{Branch: m.sources[ra.branch].branch, Revision: r.ReachedSrc, Heads: map[string]string{r.Repository: r.Reached},
Repository: r.Repository, NotYet: notYet})
}
}
@@ -16,6 +16,7 @@ import (
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-indexer/takein"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/trunk"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate"
"git.novox.be/novox/mesh-sdk/go/keys"
)
// 1280 (ADR 0556 clauses 2 and 3): the view derives, per repository, how far each module reached and the reached head, writes
@@ -30,14 +31,14 @@ func TestTheViewDerivesHowFarEachRepositoryReached(t *testing.T) {
if err != nil {
t.Fatal(err)
}
return Entry{Bucket: ModulesBucket, Key: module, Value: v, At: t0, History: history}
return Entry{Bucket: ModulesBucket, Key: module, Value: v, At: t0, History: history, Branch: testBranch}
}
modIn := func(fp, commit string, src uint64, in *meshcatalog.ModulesByModuleValueInputs2, history bool) Entry {
v, err := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: strings.Repeat("a", 64), Repository: web, Commit: commit, Src: int64(src), Path: "web", Inputs2: in})
if err != nil {
t.Fatal(err)
}
return Entry{Bucket: ModulesBucket, Key: "web", Value: v, At: t0, History: history}
return Entry{Bucket: ModulesBucket, Key: "web", Value: v, At: t0, History: history, Branch: testBranch}
}
in := func(tree string, pkgs ...string) *meshcatalog.ModulesByModuleValueInputs2 {
return &meshcatalog.ModulesByModuleValueInputs2{Tree: tree, Packages: pkgs}
@@ -48,12 +49,23 @@ func TestTheViewDerivesHowFarEachRepositoryReached(t *testing.T) {
later := func(commit string, to *meshcatalog.ModulesByModuleValueInputs2, extra ...Entry) []Entry {
return append([]Entry{modIn(fpLater, commit, 12, to, true), modIn(fpLater, commit, 12, to, false)}, extra...)
}
src := func(rev uint64, webHead string) Entry {
// Each watched branch's source as its verb source answers it, one revision heard after another (design 47 G A-173).
srcOn := func(branch string, rev uint64, webHead string) Entry {
v, err := meshdeliveryclient.EncodeSource(meshdelivery.SourceByBranchValue{Group: "g", Heads: map[string]string{web: webHead, sdk: commitS}})
if err != nil {
t.Fatal(err)
}
return Entry{Bucket: SourceBucket, Key: trunkSourceKey, Value: v, At: t0, Revision: rev, History: true}
return Entry{Bucket: SourceBucket, Key: keys.EscapeName(branch), Value: v, At: t0, Revision: rev}
}
src := func(rev uint64, webHead string) Entry { return srcOn(testBranch, rev, webHead) }
// The reach the view's own state held at its start (A-225).
kept := func(reached string, rev uint64) Entry {
key, _ := trunk.RepositoryKey(web)
v, err := viewstate.EncodeReach(viewstate.Reach{Repository: web, Reached: reached, ReachedSrc: rev})
if err != nil {
t.Fatal(err)
}
return Entry{Bucket: viewstate.ReachBucket, Key: key, Value: v, At: t0}
}
taken := func(repository, merge string) Entry {
key, _ := trunk.RepositoryKey(repository)
@@ -70,17 +82,18 @@ func TestTheViewDerivesHowFarEachRepositoryReached(t *testing.T) {
return e
}
// web changed at A (fpOld) and at C (fpNew); mail first appeared at A. The source named A, B, then C as web's head.
world := func(t *testing.T) []Entry {
cur, _ := meshdeliveryclient.DecodeSource(src(12, commitC).Value)
current := Entry{Bucket: SourceBucket, Key: trunkSourceKey, Revision: 12, At: t0}
current.Value, _ = meshdeliveryclient.EncodeSource(cur)
return []Entry{src(10, commitA), src(11, commitB), src(12, commitC), current,
world := func(t *testing.T, restarted bool) []Entry {
heard := []Entry{src(10, commitA), src(11, commitB), src(12, commitC)}
if restarted { // a start after which only the newest revision is heard, over the reach the state kept (A-225)
heard = []Entry{kept(commitB, 11), src(12, commitC)}
}
return append(heard,
mod("web", fpOld, commitA, 10, true), modIn(fpNew, commitC, 12, in("t1", "mesh-sdk/go"), true), mod("mail", fpMail, commitA, 0, true),
modIn(fpNew, commitC, 12, in("t1", "mesh-sdk/go"), false), mod("mail", fpMail, commitA, 0, false),
taken(web, commitC), taken(sdk, commitS),
wantE(t, "ace", "web", meshwantsclient.Want{Fp: fpNew}), haveFP(t, "ace", "web", fpNew, nodeengineclient.HealthOK),
wantE(t, "g14", "web", meshwantsclient.Want{Fp: fpNew}), haveFP(t, "g14", "web", fpNew, nodeengineclient.HealthOK),
wantE(t, "ace", "mail", meshwantsclient.Want{Fp: fpMail}), haveFP(t, "ace", "mail", fpMail, nodeengineclient.HealthOK)}
wantE(t, "ace", "mail", meshwantsclient.Want{Fp: fpMail}), haveFP(t, "ace", "mail", fpMail, nodeengineclient.HealthOK))
}
for _, tc := range []struct {
name string
@@ -90,8 +103,17 @@ func TestTheViewDerivesHowFarEachRepositoryReached(t *testing.T) {
waiting string // words web's module web waits with; "" none
notYet []string // the merges the reached head's event says are not yet on the machines
refused bool // the bus refuses every write and event until the watches handed over (a start, issue 1126)
// restarted: the view started again, heard only the newest source revision, and its state reach kept commitB (A-225)
restarted bool
}{
{name: "every node runs the newest build", reached: commitC},
// design 47 G A-173: a repository's reach is from the source of its modules' branch; another branch's source moves it not.
{name: "another branch's source naming the repository moves not its reach", reached: commitC,
then: []Entry{srcOn("beta", 13, commitX)}},
// A-225: after a restart the history is the revisions heard since; the kept reach stands until a newer one is derived.
{name: "after a restart the reach kept stands while the newest head lags", reached: commitB, holds: "web on g14", restarted: true,
then: []Entry{haveFP(t, "g14", "web", fpOld, nodeengineclient.HealthOK)}, waiting: "not yet the"},
{name: "after a restart a newer head reached replaces the kept one", reached: commitC, restarted: true},
{name: "a node lagging stops the head before the change it lacks", reached: commitB, holds: "web on g14",
then: []Entry{haveFP(t, "g14", "web", fpOld, nodeengineclient.HealthOK)}, waiting: "not yet the"},
// ADR 0603 §7: a node away holds no head back, is named, and holds it again once it returns still behind.
@@ -134,12 +156,10 @@ func TestTheViewDerivesHowFarEachRepositoryReached(t *testing.T) {
{name: "a merge that removed the last module is reached at the merge the indexer took in", reached: commitD,
then: func() []Entry {
head := src(13, commitD)
current := head
current.History = false
gone := func(module string, history bool) Entry {
return Entry{Bucket: ModulesBucket, Key: module, Deleted: true, At: t0, History: history}
}
return []Entry{head, current, gone("web", true), gone("mail", true), gone("web", false), gone("mail", false), taken(web, commitD)}
return []Entry{head, gone("web", true), gone("mail", true), gone("web", false), gone("mail", false), taken(web, commitD)}
}()},
// 1126: the view started while the bus did not answer its writes; its reach and its event are made at the next change it
// takes in, though the derivation is the same, so the state reach never keeps an older have than node_haves says.
@@ -168,7 +188,7 @@ func TestTheViewDerivesHowFarEachRepositoryReached(t *testing.T) {
}
return nil
})
fill(m, world(t)...)
fill(m, world(t, tc.restarted)...)
if len(written) != 0 {
t.Fatalf("written before every watch handed over: %v", written)
}
@@ -20,6 +20,7 @@ import (
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-indexer/takein"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/trunk"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate"
@@ -55,6 +56,7 @@ type Reader struct {
DifferencesState StateRead // the assigner's differences
HavesState StateRead // the node-engine's haves
ReachState StateRead // the view's own reach (ADR 0556)
WaitsState StateRead // the view's own waits: the parts' wait-held (design 47 G §512, A-242)
// HeardState is the FORGE messages the indexer handled (issue 1126 note 76); unset reads none.
HeardState StateRead
// SignersState is the vault's public signing halves (ADR 0550 §2); unset reads none.
@@ -66,9 +68,16 @@ type Reader struct {
PredefinedState, ConfigCandidatesState StateRead
// PackagesState is the indexer's published packages (PackagesBucket, ADR 0593 §2); unset reads none.
PackagesState StateRead
// GraphState is the indexer's module graph (graph.Bucket, ADR 0603 §2), SourceState the gate's source and
// ProvisionsState the provisioner's keys: what view_tiers reads (tiers.go). Unset reads none, said in the answer.
GraphState, SourceState, ProvisionsState StateRead
// GraphState is the indexer's module graph (graph.Bucket, ADR 0603 §2) and ProvisionsState the provisioner's keys: what
// view_tiers reads (tiers.go). Unset reads none, said in the answer.
GraphState, ProvisionsState StateRead
// Sources is the newest source revision of each watched branch, by branch name, as the seat mesh-delivery's verb source
// answers it (design 47 G §507, A-173): what view_tiers given no revision answers per branch. Unset, it must be named.
Sources func() (map[string]uint64, error)
// Assignment and Difference are the seat mesh-wants' verbs revision and difference (AssignmentOf, DifferenceOf): what
// view_tiers works the tiers of a source revision from (design 47 G §512, A-242).
Assignment func(revision int64) (meshwants.AssignmentRevision, error)
Difference func(from, to int64) (meshwants.Difference, error)
// Pending reads the indexer's waits through its tool indexer_pending (ADR 0633 §5); unset names no module held back.
Pending func() (takein.PendingAnswer, error)
// BuildLog reads the builder's log from a time (stdio.Log(builds.BuildLogRead).Since); unset reads none.
@@ -251,13 +251,15 @@ func wantText(s Snapshot) [][]string {
return out
}
// sourceText is the source revision's derived state in one line, then one line for each module on a node it waits for.
// sourceText is each watched branch's source revision's derived state in one line, then one line for each module on a node it
// waits for.
func sourceText(s Snapshot) []string {
if s.Source == nil {
return nil
var out []string
for _, src := range s.Sources {
out = append(out, fmt.Sprintf("%s: source revision %d: %s", src.Branch, src.Revision, src.State))
out = append(out, src.Waiting...)
}
out := []string{fmt.Sprintf("source revision %d: %s", s.Source.Revision, s.Source.State)}
return append(out, s.Source.Waiting...)
return out
}
// backlogText is one line per language and kind: its waiting asks, the asks held by a node, the oldest waiting ask's age.
@@ -0,0 +1,71 @@
package view
import (
"encoding/json"
"fmt"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshdeliveryclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery"
"git.novox.be/novox/mesh-sdk/go/buscalls"
"git.novox.be/novox/mesh-sdk/go/keys"
)
// SourceVerb is the seat mesh-delivery's verb source as the view's invokes names it: each watched branch's source revision,
// asked of the seat and never read from the gate's state (design 47 G §507, §512, A-173).
const SourceVerb = "seat:" + meshdelivery.Seat + "." + meshdelivery.VerbSource
// SourceOf asks the seat, through call, for one watched branch's source revision (branch as named) and reads its declared
// answer; an answer that carries an error is that error.
func SourceOf(call func(key string, body any) (json.RawMessage, error), branch string) (meshdelivery.SourceAnswer, error) {
raw, err := call(SourceVerb, meshdelivery.SourceInput{Branch: keys.EscapeName(branch)})
if err != nil {
return meshdelivery.SourceAnswer{}, fmt.Errorf("%s of %s: %w", SourceVerb, branch, err)
}
answer, said, ok := buscalls.Unwrap(raw)
if !ok {
return meshdelivery.SourceAnswer{}, fmt.Errorf("%s of %s answered: %s", SourceVerb, branch, said)
}
var a meshdelivery.SourceAnswer
if err := json.Unmarshal(answer, &a); err != nil {
return a, fmt.Errorf("the answer of %s of %s is not a source revision: %w", SourceVerb, branch, err)
}
if a.Error != "" {
return a, fmt.Errorf("%s of %s: %s", SourceVerb, branch, a.Error)
}
return a, nil
}
// SourceEntry is a branch's source answer as the model's entry of SourceBucket: keyed by the escaped branch, its revision the
// source revision, its value the heads by repository; a branch the gate holds no source revision of yet is a removal.
func SourceEntry(branch string, a meshdelivery.SourceAnswer) (Entry, error) {
heads := map[string]string{}
for _, h := range a.Repositories {
heads[h.Repository] = h.Head
}
e := Entry{Bucket: SourceBucket, Key: keys.EscapeName(branch), Revision: uint64(a.Revision)}
if a.Revision == 0 && len(heads) == 0 { // the gate holds no source revision of the branch yet
e.Deleted = true
return e, nil
}
v, err := meshdeliveryclient.EncodeSource(meshdelivery.SourceByBranchValue{Group: a.Group, Heads: heads})
if err != nil {
return Entry{}, err
}
e.Value = v
return e, nil
}
// SourcesOf is Reader.Sources over the watched branches followed now: each one's newest source revision through call.
func SourcesOf(call func(key string, body any) (json.RawMessage, error), followed func() []string) func() (map[string]uint64, error) {
return func() (map[string]uint64, error) {
out := map[string]uint64{}
for _, branch := range followed() {
a, err := SourceOf(call, branch)
if err != nil {
return nil, err
}
out[branch] = uint64(a.Revision)
}
return out, nil
}
}
+130 -299
View File
@@ -1,7 +1,6 @@
package view
import (
"encoding/json"
"fmt"
"maps"
"slices"
@@ -9,15 +8,9 @@ import (
"strings"
"time"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshwatchdogsclient"
"git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-indexer/takein"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/graph"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate"
)
// The tiers of a source revision are a view, worked out at each call and never stored (novox/hq ADR 0603 §1, §6): the
@@ -41,169 +34,160 @@ const TiersMost = 500
// could not be read (then every module is in the first tier), Cycle a cycle among them (its modules form the last
// tier), and Away the nodes left out.
type Tiers struct {
// Branch is the watched branch whose newest source revision it is, when view_tiers was given no revision (A-173).
Branch string `json:"branch,omitempty"`
Revision uint64 `json:"revision"`
Modules int `json:"modules"`
Tiers [][]TierModule `json:"tiers"`
Cut string `json:"cut,omitempty"`
Graph string `json:"graph,omitempty"`
Cycle []string `json:"cycle,omitempty"`
Away []string `json:"away,omitempty"`
// Pending says why the indexer's waits could not be read, so no module is named held by its revision's indexer (ADR 0633 §5).
Pending string `json:"pending,omitempty"`
}
// TierModule is one module of a tier: its fingerprint, the modules of the same revision it waits for (After), each
// wait of its own now (no wait is a module that reached every node present), and the verbs it invokes whose edge the
// module graph leaves out on a cycle, so nothing waits on them (LeftOut, ADR 0614 §3).
// TierModule is one module of a tier (design 47 G §512): its build (the fingerprint the difference says it is to run), the
// modules it is ordered after with the edge's kind, and each wait of its own now, from the parts' wait-held and the indexer's
// pending revisions (no wait is a module that reached every node present).
type TierModule struct {
Module string `json:"module"`
FP string `json:"fp,omitempty"`
None string `json:"none,omitempty"`
After []string `json:"after,omitempty"`
Waits []TierWait `json:"waits,omitempty"`
LeftOut []string `json:"left-out,omitempty"`
Module string `json:"module"`
// FP is the build.
FP string `json:"build,omitempty"`
After []TierAfter `json:"after,omitempty"`
Waits []TierWait `json:"waits,omitempty"`
}
// TierWait is one thing a module waits on: the edge (build, have, provision <name>, or for a want held back want, verb
// <verb>, reader capability <capabilities> or bus state as the assigner's words name it, ADR 0614 §2, §4, §5), the node
// it waits on (none for its build), the assigner's rule for a want held back, the part's words and since when, where the
// part says it.
// TierAfter is a module a module is ordered after, with the kind of the edge that orders it (design 47 G §512, §378).
type TierAfter struct {
Module string `json:"module"`
Kind string `json:"kind"`
}
// TierWait is one wait of a module, as the part that holds it said it (design 47 G §512, §498): the seat or module that holds it
// (Owner), its step, the node it is on, what it waits on, its bound, since when, and its own words. The pending revision of the
// indexer is Owner mesh-catalog and Step take-in.
type TierWait struct {
Edge string `json:"edge"`
Owner string `json:"owner"`
Step string `json:"step"`
Node string `json:"node,omitempty"`
Rule int `json:"rule,omitempty"`
Words string `json:"words"`
On string `json:"on,omitempty"`
Bound string `json:"bound,omitempty"`
Since time.Time `json:"since,omitzero"`
Words string `json:"words,omitempty"`
}
// Tiers works out the tiers of a source revision, the gate's newest when revision is 0.
// BranchTiers is view_tiers' answer given no revision: the tiers of each watched branch's newest source revision, in branch
// order (design 47 G A-173).
type BranchTiers struct {
Branches []Tiers `json:"branches"`
}
// Newest works out the tiers of each watched branch's newest source revision, as the gate's verb source answers it.
func (r Reader) Newest() (BranchTiers, error) {
if r.Sources == nil {
return BranchTiers{}, fmt.Errorf("the gate's source cannot be read here: name the source revision")
}
revs, err := r.Sources()
if err != nil {
return BranchTiers{}, fmt.Errorf("the gate's source: %w", err)
}
out := BranchTiers{Branches: []Tiers{}}
for _, branch := range slices.Sorted(maps.Keys(revs)) {
if revs[branch] == 0 {
continue
}
t, err := r.Tiers(revs[branch])
if err != nil {
return BranchTiers{}, fmt.Errorf("%s: %w", branch, err)
}
t.Branch = branch
out.Branches = append(out.Branches, t)
}
if len(out.Branches) == 0 {
return BranchTiers{}, fmt.Errorf("the gate holds no source revision of any watched branch yet")
}
return out, nil
}
// Tiers works out the tiers of one source revision (not 0): the difference of the kept assignment revision that carries it
// against the one kept before it, as the assigner's verb difference answers it with its tiers and each change's after
// (design 47 G §512, §186, A-242). The view orders nothing itself.
func (r Reader) Tiers(revision uint64) (Tiers, error) {
if revision == 0 {
if r.SourceState.GetEntry == nil {
return Tiers{}, fmt.Errorf("the gate's source cannot be read here: name the source revision")
}
_, rev, err := r.SourceState.GetEntry(trunkSourceKey)
if err != nil {
return Tiers{}, fmt.Errorf("the gate's source: %w", err)
}
if rev == 0 {
return Tiers{}, fmt.Errorf("the gate holds no source revision yet")
}
revision = rev
return Tiers{}, fmt.Errorf("name the source revision")
}
modules, err := values(r.State)
out := Tiers{Revision: revision}
from, to, err := r.revisionsOf(revision)
if err != nil {
return Tiers{}, fmt.Errorf("the indexer's modules: %w", err)
return Tiers{}, err
}
moved := map[string]meshcatalog.ModulesByModuleValue{}
for key, raw := range modules {
if m, err := meshcatalogclient.DecodeModule(raw); err == nil && uint64(m.FpSrc) == revision {
moved[key] = m
var d meshwants.Difference
if from > 0 {
if d, err = r.Difference(from, to); err != nil {
return Tiers{}, fmt.Errorf("the assigner's difference of %d and %d: %w", from, to, err)
}
} else {
out.Graph = fmt.Sprintf("no assignment revision is kept before %d, so the difference names nothing", to)
}
// One module per name, in the highest tier any of its nodes is in; the changes name each module's build and what it is
// ordered after.
changes := map[string][]meshwants.Change{}
for _, c := range d.Changes {
changes[c.Module] = append(changes[c.Module], c)
}
tierOf := map[string]int{}
for i, tier := range d.Tiers {
for _, ref := range tier {
tierOf[ref.Module] = max(tierOf[ref.Module], i)
}
}
out := Tiers{Revision: revision, Modules: len(moved)}
names := slices.Sorted(maps.Keys(moved))
names := slices.Sorted(maps.Keys(changes))
out.Modules = len(names)
if len(names) > TiersMost {
out.Cut = fmt.Sprintf("the revision moves %d modules: the first %d by name are shown, %s and the %d after it are not",
len(names), TiersMost, names[TiersMost], len(names)-TiersMost-1)
names = names[:TiersMost]
}
gr := graph.Graph{}
if raws, err := values(r.GraphState); err != nil {
out.Graph = "the module graph cannot be read, so no module is ordered after another: " + err.Error()
} else {
for key, raw := range raws {
if n, err := graph.Decode(raw); err == nil {
gr[key] = n
}
}
}
away := r.away()
out.Away = slices.Sorted(maps.Keys(away))
wants, _ := values(r.WantsState)
provided, perr := values(r.ProvisionsState)
waits := r.waitsHeld(&out)
held, heldAll := r.heldBack(&out)
// The ordering among the revision's modules: each one's tier is one past the highest of the modules it waits for.
in := map[string]bool{}
for _, n := range names {
in[n] = true
}
after := map[string][]string{}
for _, n := range names {
for _, b := range gr.Before(n) {
if in[b] {
after[n] = append(after[n], b)
}
}
}
sub := graph.Graph{}
for _, n := range names {
if g, ok := gr[n]; ok {
sub[n] = g
}
}
out.Cycle = sub.Cycle()
tier := map[string]int{}
var level func(string, map[string]bool) int
level = func(n string, visiting map[string]bool) int {
if t, ok := tier[n]; ok {
return t
}
if visiting[n] {
return -1
}
visiting[n] = true
t := 0
for _, b := range after[n] {
lb := level(b, visiting)
if lb < 0 {
return -1
}
t = max(t, lb+1)
}
delete(visiting, n)
tier[n] = t
return t
}
top := 0
for _, n := range names {
if t := level(n, map[string]bool{}); t > top {
top = t
}
}
for _, n := range names {
if _, ok := tier[n]; !ok {
tier[n] = top + 1 // on a cycle: after every tier that is ordered
}
}
for _, n := range names {
t := tier[n]
t := tierOf[n]
for len(out.Tiers) <= t {
out.Tiers = append(out.Tiers, nil)
}
m := moved[n]
tm := TierModule{Module: n, FP: m.Fp2, None: m.None2, After: after[n], LeftOut: gr[n].LeftOut}
tm.Waits = r.waitsOf(n, m, gr[n], wants, provided, perr, away)
tm := TierModule{Module: n}
seen := map[string]bool{}
for _, c := range changes[n] {
if tm.FP == "" {
tm.FP = c.To
}
for _, a := range c.After {
if k := a.Module + "\x00" + a.Kind; !seen[k] && a.Module != n {
seen[k] = true
tm.After = append(tm.After, TierAfter{Module: a.Module, Kind: a.Kind})
}
}
}
slices.SortFunc(tm.After, func(a, b TierAfter) int { return strings.Compare(a.Module+"\x00"+a.Kind, b.Module+"\x00"+b.Kind) })
tm.Waits = waits[n]
if w, ok := held[n]; ok {
tm.Waits = append(tm.Waits, w)
}
out.Tiers[t] = append(out.Tiers[t], tm)
}
// A module the indexer holds back is not at the revision yet, so no tier of it names it: it is named in the first tier with
// its wait alone (ADR 0633 §5), with the fingerprint the indexer last took in.
// its wait alone (ADR 0633 §5).
for _, n := range heldAll {
if in[n] {
continue
}
tm := TierModule{Module: n, Waits: []TierWait{held[n]}}
if m, err := meshcatalogclient.DecodeModule(modules[n]); err == nil {
tm.FP, tm.None = m.Fp2, m.None2
}
if len(out.Tiers) == 0 {
out.Tiers = append(out.Tiers, nil)
}
out.Tiers[0] = append(out.Tiers[0], tm)
out.Tiers[0] = append(out.Tiers[0], TierModule{Module: n, Waits: []TierWait{held[n]}})
}
compact := out.Tiers[:0]
for _, t := range out.Tiers {
@@ -215,8 +199,12 @@ func (r Reader) Tiers(revision uint64) (Tiers, error) {
return out, nil
}
// TakeInEdge is the edge of a wait on the indexer a source revision or a group is held for (ADR 0633 §5).
const TakeInEdge = "take-in"
// TakeInStep and TakeInOwner are the step and the owner of a wait on the indexer a source revision or a group is held for
// (ADR 0633 §5, design 47 G §512): the pending revision's wait is the seat mesh-catalog's step take-in.
const (
TakeInStep = "take-in"
TakeInOwner = "mesh-catalog"
)
// heldBack are the modules the indexer holds back, by module, each with its wait, read through the indexer's tool
// indexer_pending (ADR 0633 §5), and the same modules sorted. A tool that cannot be asked says so in out.Pending.
@@ -239,193 +227,33 @@ func (r Reader) heldBack(out *Tiers) (map[string]TierWait, []string) {
}
for _, module := range w.Holds {
if _, ok := held[module]; !ok { // the oldest wait names it
held[module] = TierWait{Edge: TakeInEdge, Words: words, Since: w.Since}
held[module] = TierWait{Owner: TakeInOwner, Step: TakeInStep, Words: words, Since: w.Since}
}
}
}
return held, slices.Sorted(maps.Keys(held))
}
// away are the nodes the watchman's presence says are offline.
func (r Reader) away() map[string]bool {
out := map[string]bool{}
presence, err := values(r.PresenceState)
// waitsHeld are the waits the parts hold, by module, from the view's own state waits (written from their wait-held and wait-ended,
// design 47 G §512, §376, A-242): each in the words its sayer gave. A state that cannot be read says so in out.Pending.
func (r Reader) waitsHeld(out *Tiers) map[string][]TierWait {
held := map[string][]TierWait{}
if r.WaitsState.Keys == nil || r.WaitsState.Get == nil {
return held
}
all, err := values(r.WaitsState)
if err != nil {
return out
out.Pending += "the parts' waits cannot be read, so no module is named waiting on a part: " + err.Error()
return held
}
for key, raw := range presence {
node, part, _, err := meshwatchdogsclient.ParsePresenceKey(key)
if err != nil || part != "" {
for _, key := range slices.Sorted(maps.Keys(all)) {
w, err := viewstate.ReadWait("", all[key])
if err != nil || w.Module == "" {
continue
}
if p, err := meshwatchdogsclient.DecodeNodePresence(raw); err == nil && !p.Online {
out[node] = true
}
held[w.Module] = append(held[w.Module], TierWait{Owner: w.Owner, Step: w.Step, Node: w.Node, On: w.On, Bound: w.Bound, Since: w.Since, Words: w.Words})
}
return out
}
// waitsOf is what module m waits on now: its build at its fingerprint, then on each node present that wants it, its want,
// its have, and each provision its graph node awaits.
func (r Reader) waitsOf(module string, m meshcatalog.ModulesByModuleValue, g graph.Node, wants, provided map[string]json.RawMessage,
perr error, away map[string]bool) []TierWait {
var out []TierWait
if m.Fp2 == "" {
return []TierWait{{Edge: "build", Words: "it has no fingerprint, so nothing is built: " + m.None2}}
}
if w, ok := r.buildWait(module, m.Fp2); ok {
out = append(out, w)
}
var nodes []string
for key := range wants {
node, mod, err := meshwantsclient.ParseWantKey(key)
if err == nil && mod == module {
nodes = append(nodes, node)
}
}
slices.Sort(nodes)
for _, node := range nodes {
if away[node] {
continue
}
w, err := meshwantsclient.DecodeWant(wants[mustWantKey(node, module)])
if err != nil || w.Removed {
continue
}
if w.Fp != m.Fp2 {
words := w.Words
if words == "" {
words = "the want names " + short(w.Fp) + ", not " + short(m.Fp2)
}
edge := graph.EdgeOfWords(w.Words)
if edge == "" {
edge = "want"
}
out = append(out, TierWait{Edge: edge, Node: node, Rule: int(w.Rule), Words: words, Since: r.since(r.WantsState, mustWantKey(node, module))})
} else if hw, ok := r.haveWait(node, module, m.Fp2); ok {
out = append(out, hw)
}
for _, a := range g.Awaits {
if a == graph.BusState {
// Bus state is offered with no credential of a consumer's own, so the provisioner keys nothing for it: its wait is
// the assigner's held want alone, named bus state from its words (ADR 0614 §5).
continue
}
out = append(out, provisionWaits(node, module, a, provided, perr)...)
}
}
return out
}
func mustWantKey(node, module string) string {
k, _ := meshwantsclient.WantKey(node, module)
return k
}
// buildWait is the wait of the build key <module>.<fp>, none when it is done.
func (r Reader) buildWait(module, fp string) (TierWait, bool) {
key, err := builds.BuilderBuildKey(module, fp)
if err != nil || r.Builds.Get == nil {
return TierWait{Edge: "build", Words: "the builder's builds cannot be read here"}, true
}
raw, err := r.Builds.Get(key)
switch {
case err != nil:
return TierWait{Edge: "build", Words: "the build key " + key + " cannot be read: " + err.Error()}, true
case raw == nil:
return TierWait{Edge: "build", Words: "the builder holds no build of " + short(fp) + " yet"}, true
}
b, err := builds.DecodeBuilderBuild(raw)
if err != nil {
return TierWait{Edge: "build", Words: "the build key " + key + " does not read: " + err.Error()}, true
}
switch b.State {
case builds.BuildDone:
return TierWait{}, false
case builds.BuildFailed:
return TierWait{Edge: "build", Words: "the build of " + short(fp) + " failed: " + b.Error}, true
}
words := "the build of " + short(fp) + " is " + b.State
if b.Waits != "" {
words += ", waiting for the package " + b.Waits
}
if b.Step != "" {
words += ": " + b.Step
}
return TierWait{Edge: "build", Words: words, Since: r.since(r.Builds, key)}, true
}
// haveWait is the wait of node's have of module at fp, none when it runs fp healthy.
func (r Reader) haveWait(node, module, fp string) (TierWait, bool) {
key, err := nodeengineclient.HaveKey(node, module)
if err != nil || r.HavesState.Get == nil {
return TierWait{}, false
}
raw, err := r.HavesState.Get(key)
if err != nil || raw == nil {
return TierWait{Edge: "have", Node: node, Words: node + " has not yet said what it runs of " + module}, true
}
h, err := nodeengineclient.DecodeHave(raw)
if err != nil {
return TierWait{Edge: "have", Node: node, Words: "its have does not read: " + err.Error()}, true
}
if h.Fp == fp && h.Phase == nodeengineclient.PhaseRunning && h.Health == nodeengineclient.HealthOK {
return TierWait{}, false
}
words := fmt.Sprintf("%s runs %s, %s", node, short(h.Fp), h.Phase)
if h.Health != "" {
words += ", health " + string(h.Health)
}
if h.Detail != "" {
words += ": " + h.Detail
}
return TierWait{Edge: "have", Node: node, Words: words, Since: r.since(r.HavesState, key)}, true
}
// provisionWaits are the waits of the provision a that module awaits on node: none once every key of it is provided.
func provisionWaits(node, module, a string, provided map[string]json.RawMessage, perr error) []TierWait {
edge := "provision " + a
if perr != nil {
return []TierWait{{Edge: edge, Node: node, Words: "the provisioner's keys cannot be read: " + perr.Error()}}
}
prefix, _ := meshprovisionerclient.Key(node, module, a, "")
var out []TierWait
found := false
for _, key := range slices.Sorted(maps.Keys(provided)) {
if key != prefix && !strings.HasPrefix(key, prefix+".") {
continue
}
found = true
p, err := meshprovisionerclient.Decode(provided[key])
if err != nil {
out = append(out, TierWait{Edge: edge, Node: node, Words: "the provision key " + key + " does not read: " + err.Error()})
continue
}
if p.State == meshprovisionerclient.Provided {
continue
}
words := "the provision " + key + " is " + p.State
if p.Why != "" {
words += ": " + p.Why
}
out = append(out, TierWait{Edge: edge, Node: node, Words: words, Since: p.At})
}
if !found {
out = append(out, TierWait{Edge: edge, Node: node, Words: "the provisioner holds no key of " + a + " for " + module + " on " + node + " yet"})
}
return out
}
// since is when key's value was written, from the state's history, or zero when it cannot say.
func (r Reader) since(s StateRead, key string) time.Time {
if s.History == nil {
return time.Time{}
}
revs, err := s.History(key)
if err != nil || len(revs) == 0 {
return time.Time{}
}
return revs[len(revs)-1].At
return held
}
// tiersRevision reads the tool's revision argument: a number, as JSON gives it or as text; 0 for the gate's newest.
@@ -453,5 +281,8 @@ func (r Reader) TiersOf(args map[string]any) (any, error) {
if err != nil {
return nil, err
}
if revision == 0 {
return r.Newest()
}
return r.Tiers(revision)
}
@@ -2,7 +2,6 @@
package view
import (
"encoding/json"
"fmt"
"maps"
"slices"
@@ -23,11 +22,11 @@ import (
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshdeliveryclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-indexer/takein"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/buildrecord"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/trunk"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate"
"git.novox.be/novox/mesh-sdk/go/keys"
"git.novox.be/novox/mesh-sdk/go/motion"
)
@@ -38,8 +37,12 @@ type Entry struct {
Value []byte
At time.Time
Deleted bool
// Revision is the bucket revision of the change, when the watch gives one (the source revision is the gate's source key's).
// Revision is the bucket revision of the change, when the watch gives one; for a branch's source (SourceBucket, keyed by the
// escaped branch) it is the source revision the gate's verb source answers.
Revision uint64
// Branch is, for a module's entry (ModulesBucket), the watched branch it is read from: the first branch of the module's
// registration, before the first registrations the first branch the gate's source answers that lists it (design 47 G A-173).
Branch string
// History says the change comes from a watch with history (ReachHistory): it feeds only the history the reach reads.
History bool
}
@@ -89,9 +92,9 @@ type Snapshot struct {
Asks []AskRow `json:"asks"`
// Wants is every want of the assigner beside the have of its node and where it stands (novox/hq ADR 0539 §8, issue 1906 D7).
Wants []WantRow `json:"wants"`
// Source is the state of the mesh's source revision, derived from the wants and the haves (ADR 0429 §1): recorded by no part.
// Nil when the gate holds no source yet.
Source *SourceRow `json:"source,omitempty"`
// Sources is the state of each watched branch's source revision, derived from the wants and the haves (ADR 0429 §1;
// design 47 G A-173): recorded by no part, by branch name. Empty while the gate's verb source answers no branch's.
Sources []SourceRow `json:"sources,omitempty"`
// Backlog is, for each language and kind of build ask with one waiting or held, the asks waiting for a node, the asks a node
// holds and the oldest waiting ask's age (issue 1887 A8a).
Backlog []BacklogRow `json:"backlog"`
@@ -131,10 +134,12 @@ const (
SourceNotDelivered = "not delivered"
)
// SourceRow is the mesh's source revision (the revision of the gate's source key) and whether it is delivered: every want equals its
// module's fingerprint at the indexer and every have equals its want, running and healthy, and every node a module is assigned to
// has a want. Waiting names each module on a node that is not there yet, and why.
// SourceRow is one watched branch's source revision (as the gate's verb source answers it) and whether it is delivered: every
// want of a module whose branch it is equals its module's fingerprint in the catalog half and every have equals its want, running
// and healthy, and every node such a module is assigned to has a want. Waiting names each module on a node that is not there
// yet, and why.
type SourceRow struct {
Branch string `json:"branch"`
Revision uint64 `json:"revision"`
State string `json:"state"`
Waiting []string `json:"waiting,omitempty"`
@@ -160,10 +165,6 @@ type AskRow struct {
Chosen string `json:"chosen,omitempty"`
}
// AnswerOnThePhone is what an open ask says about answering it: the view only shows, and the Approve is answered on the
// operator's phone (ADR 0437 §10: answering in the view is release 5's).
const AnswerOnThePhone = "answer on the phone"
// AnswerInTheView is what an open ask that needs no proof of who answered says (ADR 0528 clause 6): the operator answers it here.
const AnswerInTheView = "answer in the view"
@@ -240,14 +241,14 @@ type Model struct {
groups map[string]groupEntry // the indexer's candidates, by group
modules map[string]moduleEntry // the indexer's modules, by module
keys map[string]keyEntry // the builder's build keys, by <module>.<fingerprint>
asks map[string]askRecord // the gate's asks, by key
wants map[string]wantEntry // the assigner's wants, by <node>.<module>
source *sourceEntry // the gate's source key
sources map[string]*sourceEntry // each watched branch's source, by escaped branch (design 47 G A-173)
modBranch map[string]string // the escaped branch each module's entry is read from, by module
deliveredEvent func(DeliveredEvent) error
motionEvent func(MotionEvent) error
told map[string]bool // the motion events said, by their ids
initial map[string]bool // the buckets whose watch handed over what stood at its start
said uint64 // the source revision whose delivery was said
said map[string]uint64 // the source revision whose delivery was said, by escaped branch
aasks map[string]assignerAskRecord // the assigner's asks (hold, restore), by key
chosen map[string]viewstate.ViewAnswer // the answers the view wrote, by ask key
writer Writer
@@ -263,7 +264,8 @@ type Model struct {
stuck map[string]stuckEntry
// how far each repository reached (reach.go, ADR 0556)
modHist map[string][]meshcatalog.ModulesByModuleValue // the indexer's values of each module, oldest first
srcHist map[uint64]map[string]string // the source's heads, by revision
srcHist map[string]map[uint64]map[string]string // each branch's source heads heard since the start, by escaped branch and revision
kept map[string]viewstate.Reach // the reach the view's own state held at its start, by repository key (A-225)
taken map[string]takein.Taken // the indexer's taken, by repository key
reach map[string]viewstate.Reach // the reach derived last, by repository key
reachSaid map[string]uint64 // the reached head's source revision said last, by repository key
@@ -333,6 +335,7 @@ type wantEntry struct {
}
type sourceEntry struct {
branch string // the branch's name
revision uint64
group string
heads map[string]string
@@ -349,12 +352,6 @@ type moduleEntry struct {
err string
}
type askRecord struct {
value meshdelivery.AsksByMergeGroupHeadsValue
ask oc.Ask // the router's ask the gate's value carries
err string
}
type haveEntry struct {
have nodeengine.HaveByNodeModuleValue
err string
@@ -369,10 +366,11 @@ type nodeEntry struct {
// New is an empty model.
func New() *Model {
return &Model{placed: meshmoduleassignerclient.NewPlaced(), builds: map[string]buildEntry{}, groups: map[string]groupEntry{}, modules: map[string]moduleEntry{}, keys: map[string]keyEntry{}, asks: map[string]askRecord{}, wants: map[string]wantEntry{}, told: map[string]bool{}, initial: map[string]bool{}, aasks: map[string]assignerAskRecord{}, chosen: map[string]viewstate.ViewAnswer{}, answers: map[string]string{}, answerOf: map[string]meshdeliveryclient.Answer{}, holds: map[string]heldEntry{}, newest: map[string]string{},
return &Model{placed: meshmoduleassignerclient.NewPlaced(), builds: map[string]buildEntry{}, groups: map[string]groupEntry{}, modules: map[string]moduleEntry{}, keys: map[string]keyEntry{}, wants: map[string]wantEntry{}, told: map[string]bool{}, initial: map[string]bool{}, aasks: map[string]assignerAskRecord{}, chosen: map[string]viewstate.ViewAnswer{}, answers: map[string]string{}, answerOf: map[string]meshdeliveryclient.Answer{}, holds: map[string]heldEntry{}, newest: map[string]string{},
haves: map[string]haveEntry{}, nodes: map[string]nodeEntry{},
presence: map[string]presenceEntry{}, stuck: map[string]stuckEntry{},
modHist: map[string][]meshcatalog.ModulesByModuleValue{}, srcHist: map[uint64]map[string]string{}, taken: map[string]takein.Taken{},
modHist: map[string][]meshcatalog.ModulesByModuleValue{}, srcHist: map[string]map[uint64]map[string]string{}, kept: map[string]viewstate.Reach{},
sources: map[string]*sourceEntry{}, modBranch: map[string]string{}, said: map[string]uint64{}, taken: map[string]takein.Taken{},
reach: map[string]viewstate.Reach{}, reachSaid: map[string]uint64{}, reachNotYet: map[string]string{}}
}
@@ -380,21 +378,23 @@ func New() *Model {
// the source revision delivered, the event of it is said once (SetDeliveredEvent), after the model's lock is released.
func (m *Model) Apply(e Entry) {
m.mu.Lock()
var event *DeliveredEvent
var events []DeliveredEvent
var motion []MotionEvent
if e.History {
m.applyHistory(e)
} else {
m.apply(e)
event = m.deliveredNow()
events = m.deliveredNow()
motion = m.motionNow(e)
}
writes, reached := m.reachNow()
say, put := m.deliveredEvent, m.reachPut
sayMotion := m.motionEvent
m.mu.Unlock()
if event != nil && say != nil && say(*event) != nil {
m.unsay(*event)
for _, event := range events {
if say != nil && say(event) != nil {
m.unsay(event)
}
}
m.sendReach(put, say, writes, reached)
m.sayMotion(sayMotion, motion)
@@ -462,8 +462,8 @@ const unsaidMark = "\x00not said"
func (m *Model) unsay(ev DeliveredEvent) {
m.mu.Lock()
defer m.mu.Unlock()
if m.said == ev.Revision {
m.said = 0
if b := keys.EscapeName(ev.Branch); m.said[b] == ev.Revision {
delete(m.said, b)
}
}
@@ -475,6 +475,8 @@ func (m *Model) unsay(ev DeliveredEvent) {
// is the source revision that named that head and Group is empty; a consumer takes every earlier merge of that repository as on
// the machines.
type DeliveredEvent struct {
// Branch is the watched branch whose source revision it is (design 47 G A-173).
Branch string `json:"branch"`
Revision uint64 `json:"revision"`
Group string `json:"group"`
Heads map[string]string `json:"heads"`
@@ -491,18 +493,26 @@ func (m *Model) SetDeliveredEvent(say func(DeliveredEvent) error) {
m.deliveredEvent = say
}
// deliveredNow is the event of the source revision when it is delivered now and has not been said, or nil. A revision that went
// bad after it was delivered and recovered is not said again.
func (m *Model) deliveredNow() *DeliveredEvent {
if m.deliveredEvent == nil || m.source == nil || m.source.err != "" || m.said == m.source.revision {
// deliveredNow is the event of each watched branch's source revision that is delivered now and has not been said, in branch
// order. A revision that went bad after it was delivered and recovered is not said again.
func (m *Model) deliveredNow() []DeliveredEvent {
if m.deliveredEvent == nil {
return nil
}
row := m.sourceRow()
if row == nil || row.State != SourceDelivered || row.Wants == 0 {
return nil
var out []DeliveredEvent
for _, b := range slices.Sorted(maps.Keys(m.sources)) {
src := m.sources[b]
if src.err != "" || m.said[b] == src.revision {
continue
}
row := m.sourceRow(b)
if row == nil || row.State != SourceDelivered || row.Wants == 0 {
continue
}
m.said[b] = src.revision
out = append(out, DeliveredEvent{Branch: src.branch, Revision: src.revision, Group: src.group, Heads: src.heads})
}
m.said = m.source.revision
return &DeliveredEvent{Revision: m.source.revision, Group: m.source.group, Heads: m.source.heads}
return out
}
func (m *Model) apply(e Entry) {
@@ -555,24 +565,16 @@ func (m *Model) apply(e Entry) {
case ModulesBucket:
if e.Deleted {
delete(m.modules, e.Key)
delete(m.modBranch, e.Key)
return
}
mod, err := meshcatalogclient.DecodeModule(e.Value)
m.modules[e.Key] = moduleEntry{value: mod, err: errText(err)}
case AsksBucket:
if e.Deleted {
delete(m.asks, e.Key)
return
if e.Branch != "" {
m.modBranch[e.Key] = keys.EscapeName(e.Branch)
} else {
delete(m.modBranch, e.Key)
}
a, err := meshdeliveryclient.DecodeAsk(e.Value)
var ask oc.Ask
if err == nil {
var raw []byte
if raw, err = json.Marshal(a.Ask); err == nil {
ask, err = occ.Carried(raw, meshdeliveryclient.AskingID(e.Key, int(a.Asking)))
}
}
m.asks[e.Key] = askRecord{value: a, ask: ask, err: errText(err)}
case AssignerAsksBucket:
if e.Deleted {
delete(m.aasks, e.Key)
@@ -620,16 +622,31 @@ func (m *Model) apply(e Entry) {
} else {
delete(m.taken, e.Key)
}
case SourceBucket:
if e.Key != trunkSourceKey {
case SourceBucket: // one watched branch's source, keyed by the escaped branch (design 47 G A-173)
if e.Deleted {
delete(m.sources, e.Key)
delete(m.said, e.Key)
return
}
if e.Deleted {
m.source = nil
name, err := keys.UnescapeName(e.Key)
if err != nil {
return
}
src, err := meshdeliveryclient.DecodeSource(e.Value)
m.source = &sourceEntry{revision: e.Revision, group: src.Group, heads: src.Heads, err: errText(err)}
m.sources[e.Key] = &sourceEntry{branch: name, revision: e.Revision, group: src.Group, heads: src.Heads, err: errText(err)}
if err == nil && e.Revision != 0 { // the history the reach reads: each revision heard since the start (A-225)
if m.srcHist[e.Key] == nil {
m.srcHist[e.Key] = map[uint64]map[string]string{}
}
m.srcHist[e.Key][e.Revision] = src.Heads
}
case viewstate.ReachBucket: // the view's own state reach, as it stood at the start (A-225)
if m.initial[viewstate.ReachBucket] {
return
}
if r, err := viewstate.DecodeReach(e.Value); err == nil && !e.Deleted {
m.kept[e.Key] = r
}
case AnswersBucket:
if e.Deleted {
delete(m.answers, e.Key)
@@ -797,11 +814,6 @@ func (m *Model) snapshot() Snapshot {
unreadable = append(unreadable, UnreadableRow{Bucket: ModulesBucket, Key: key, Error: mod.err})
}
}
for key, a := range m.asks {
if a.err != "" {
unreadable = append(unreadable, UnreadableRow{Bucket: AsksBucket, Key: key, Error: a.err})
}
}
for key, a := range m.aasks {
if a.err != "" {
unreadable = append(unreadable, UnreadableRow{Bucket: AssignerAsksBucket, Key: key, Error: a.err})
@@ -823,7 +835,7 @@ func (m *Model) snapshot() Snapshot {
return unreadable[i].Bucket+"."+unreadable[i].Key < unreadable[j].Bucket+"."+unreadable[j].Key
})
return Snapshot{Rows: rows, Nodes: nodes, Offline: offline, Stuck: stuck, Unreadable: unreadable,
Groups: m.groupRows(), Fingerprints: m.fingerprintRows(), Asks: m.askRows(), Wants: m.wantRows(), Source: m.sourceRow()}
Groups: m.groupRows(), Fingerprints: m.fingerprintRows(), Asks: m.askRows(), Wants: m.wantRows(), Sources: m.sourceRows()}
}
// groupRows are the readable candidate groups, ordered by group, their pull requests by repository and number.
@@ -927,21 +939,6 @@ const (
// pull requests it covers (repository, number, head shortened to 12 digits) and how to answer.
func (m *Model) askRows() []AskRow {
out := []AskRow{}
for key, a := range m.asks {
if a.err != "" || m.answers[key] == a.ask.ID {
continue
}
row := AskRow{Key: key, Group: a.value.Group, Headline: a.ask.Headline, Words: a.ask.Explanation,
Expires: expiry(a.ask), How: AnswerOnThePhone, Pulls: []string{}}
for _, h := range a.value.Heads {
head := h.Head
if len(head) > 12 {
head = head[:12]
}
row.Pulls = append(row.Pulls, fmt.Sprintf("%s#%d @%s", h.Repository, h.Number, head))
}
out = append(out, row)
}
for key, a := range m.aasks {
if a.err != "" || m.answers[key] == a.ask.ID {
continue
@@ -1080,12 +1077,25 @@ func (m *Model) wantRows() []WantRow {
return out
}
// sourceRow derives the state of the source revision the gate holds (ADR 0429 §1; recorded by no part): delivered when every want
// equals its module's fingerprint at the indexer and every have equals its want, running and healthy, and every node a module is
// assigned to has a want, compared only on the nodes present: a node the watchman says is offline is named away and left out
// (ADR 0603 §7, changing ADR 0429 §1). It is nil when the gate holds no readable source.
func (m *Model) sourceRow() *SourceRow {
if m.source == nil || m.source.err != "" {
// sourceRows are the rows of every watched branch's readable source, in branch order (design 47 G A-173).
func (m *Model) sourceRows() []SourceRow {
var out []SourceRow
for _, b := range slices.Sorted(maps.Keys(m.sources)) {
if row := m.sourceRow(b); row != nil {
out = append(out, *row)
}
}
return out
}
// sourceRow derives the state of one watched branch's source revision (ADR 0429 §1; recorded by no part), branch escaped:
// delivered when every want of a module whose branch it is equals its module's fingerprint and every have equals its want,
// running and healthy, and every node such a module is assigned to has a want, compared only on the nodes present: a node the
// watchman says is offline is named away and left out (ADR 0603 §7, changing ADR 0429 §1). It is nil when the gate's verb
// answers no readable source of the branch.
func (m *Model) sourceRow(branch string) *SourceRow {
src := m.sources[branch]
if src == nil || src.err != "" {
return nil
}
waiting := []string{}
@@ -1104,7 +1114,7 @@ func (m *Model) sourceRow() *SourceRow {
continue
}
mod, ok := m.modules[module]
if w.err != "" || w.value.Removed || !ok || mod.err != "" || mod.value.Fp2 == "" {
if w.err != "" || w.value.Removed || !ok || mod.err != "" || mod.value.Fp2 == "" || m.modBranch[module] != branch {
continue
}
looked++
@@ -1128,12 +1138,12 @@ func (m *Model) sourceRow() *SourceRow {
if _, ok := m.wants[wk]; ok {
continue
}
if mod, ok := m.modules[a.Module]; ok && mod.err == "" && mod.value.Fp2 != "" {
if mod, ok := m.modules[a.Module]; ok && mod.err == "" && mod.value.Fp2 != "" && m.modBranch[a.Module] == branch {
waiting = append(waiting, fmt.Sprintf("%s on %s has no want yet", a.Module, a.Node))
}
}
sort.Strings(waiting)
row := &SourceRow{Revision: m.source.revision, State: SourceDelivered, Wants: looked, Away: slices.Sorted(maps.Keys(away))}
row := &SourceRow{Branch: src.branch, Revision: src.revision, State: SourceDelivered, Wants: looked, Away: slices.Sorted(maps.Keys(away))}
if len(waiting) > 0 {
row.State, row.Waiting = SourceNotDelivered, waiting
}
@@ -14,6 +14,7 @@ import (
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery"
"git.novox.be/novox/mesh-sdk/go/keys"
)
const (
@@ -56,16 +57,32 @@ func moduleE(t *testing.T, module, fp string) Entry {
if err != nil {
t.Fatal(err)
}
return Entry{Bucket: ModulesBucket, Key: module, Value: v, At: t0}
return Entry{Bucket: ModulesBucket, Key: module, Value: v, At: t0, Branch: testBranch}
}
func sourceE(t *testing.T, revision uint64) Entry {
// testBranch is the watched branch of these tests: no branch is named in the view's code (design 47 G §532, A-173).
const testBranch = "release"
func sourceE(t *testing.T, revision uint64) Entry { return sourceOn(t, testBranch, revision) }
// sourceOn is one watched branch's source as the gate's verb source answers it (A-173).
func sourceOn(t *testing.T, branch string, revision uint64) Entry {
t.Helper()
v, err := meshdeliveryclient.EncodeSource(meshdelivery.SourceByBranchValue{Group: "g", Heads: map[string]string{"novox/web": strings.Repeat("1", 40)}})
if err != nil {
t.Fatal(err)
}
return Entry{Bucket: SourceBucket, Key: trunkSourceKey, Value: v, At: t0, Revision: revision}
return Entry{Bucket: SourceBucket, Key: keys.EscapeName(branch), Value: v, At: t0, Revision: revision}
}
// sourceRowOf is the snapshot's source row of one branch, nil when it has none.
func sourceRowOf(m *Model, branch string) *SourceRow {
for _, r := range m.Snapshot().Sources {
if r.Branch == branch {
return &r
}
}
return nil
}
// 1906/D7 AC7: every want is a row beside the have of its node, with where it stands.
@@ -107,44 +124,66 @@ func TestEveryWantIsARowAgainstItsHave(t *testing.T) {
// want, healthy; one failed have shows it not delivered and names the module.
func TestASourceRevisionIsDerivedFromWantsAndHaves(t *testing.T) {
m := New()
var said []DeliveredEvent
m.SetDeliveredEvent(func(e DeliveredEvent) error {
if e.Repository == "" {
said = append(said, e)
}
return nil
})
fill(m, sourceE(t, 14355), moduleE(t, "web", fpNew), moduleE(t, "mail", fpNew),
place(t, "ace", "web"), place(t, "g14", "web"),
wantE(t, "ace", "web", meshwantsclient.Want{Fp: fpNew}), haveFP(t, "ace", "web", fpNew, nodeengineclient.HealthOK),
wantE(t, "g14", "web", meshwantsclient.Want{Fp: fpNew}), haveFP(t, "g14", "web", fpNew, nodeengineclient.HealthOK),
wantE(t, "ace", "mail", meshwantsclient.Want{Fp: fpNew}), haveFP(t, "ace", "mail", fpNew, nodeengineclient.HealthOK))
s := m.Snapshot().Source
s := sourceRowOf(m, testBranch)
if s == nil || s.Revision != 14355 || s.State != SourceDelivered || len(s.Waiting) != 0 {
t.Fatalf("source = %+v, want revision 14355 delivered", s)
}
// design 47 G A-173: each watched branch's source has its own row and its own delivered, over the modules whose branch it
// is: a module of another branch not yet run holds that branch's revision alone.
cms := moduleE(t, "cms", fpNew)
cms.Branch = "beta"
fill(m, sourceOn(t, "beta", 14360), cms, wantE(t, "ace", "cms", meshwantsclient.Want{Fp: fpNew}))
if b := sourceRowOf(m, "beta"); b == nil || b.Revision != 14360 || b.State != SourceNotDelivered || b.Wants != 1 {
t.Errorf("the branch beta's source = %+v, want revision 14360 not delivered over its one want", b)
}
if s := sourceRowOf(m, testBranch); s == nil || s.State != SourceDelivered || s.Wants != 3 {
t.Errorf("the branch %s's source = %+v, want delivered over its own three wants", testBranch, s)
}
fill(m, haveFP(t, "ace", "cms", fpNew, nodeengineclient.HealthOK))
if len(said) != 2 || said[0].Branch != testBranch || said[0].Revision != 14355 || said[1].Branch != "beta" || said[1].Revision != 14360 {
t.Errorf("delivered said = %+v, want %s at 14355, then beta at 14360", said, testBranch)
}
fill(m, haveFP(t, "g14", "web", fpNew, nodeengineclient.HealthFailed))
s = m.Snapshot().Source
s = sourceRowOf(m, testBranch)
if s.State != SourceNotDelivered || len(s.Waiting) != 1 || !strings.Contains(s.Waiting[0], "web") || !strings.Contains(s.Waiting[0], "g14") {
t.Errorf("a failed have: %+v", s)
}
fill(m, haveFP(t, "g14", "web", fpNew, nodeengineclient.HealthOK), wantE(t, "ace", "mail", meshwantsclient.Want{Fp: fpOld}))
s = m.Snapshot().Source
s = sourceRowOf(m, testBranch)
if s.State != SourceNotDelivered || !strings.Contains(strings.Join(s.Waiting, ";"), "mail") {
t.Errorf("a want that is not the module's fingerprint: %+v", s)
}
fill(m, wantE(t, "ace", "mail", meshwantsclient.Want{Fp: fpNew}), place(t, "novox", "web"))
s = m.Snapshot().Source
s = sourceRowOf(m, testBranch)
if s.State != SourceNotDelivered || !strings.Contains(strings.Join(s.Waiting, ";"), "novox") {
t.Errorf("a node assigned the module with no want: %+v", s)
}
// ADR 0603 §7: a node away holds nothing back and is named; it counts once it returns and runs its wants.
fill(m, presenceE(t, "novox", false, t0))
s = m.Snapshot().Source
s = sourceRowOf(m, testBranch)
if s.State != SourceDelivered || len(s.Away) != 1 || s.Away[0] != "novox" {
t.Errorf("a node away: %+v", s)
}
fill(m, presenceE(t, "novox", true, t0))
if s = m.Snapshot().Source; s.State != SourceNotDelivered || len(s.Away) != 0 {
if s = sourceRowOf(m, testBranch); s.State != SourceNotDelivered || len(s.Away) != 0 {
t.Errorf("the node returned and runs nothing yet: %+v", s)
}
}
func TestNoSourceRevisionHeldMeansNoStateIsDerived(t *testing.T) {
if s := New().Snapshot().Source; s != nil {
if s := New().Snapshot().Sources; len(s) != 0 {
t.Errorf("source = %+v", s)
}
}
+63 -22
View File
@@ -67,7 +67,7 @@ type manifest struct {
Slug string `json:"slug"`
Reads []string `json:"reads"`
Consumes []string `json:"consumes"`
Invokes []string `json:"invokes"`
Invokes []invoke `json:"invokes"`
Uses []string `json:"uses"`
Tools []string `json:"tools"`
Emits []string `json:"emits"`
@@ -82,6 +82,32 @@ type manifest struct {
} `json:"build"`
}
// invoke is an entry of `invokes`: a verb, or in its long form {verb, fields}, the fields of the verb's answer the view
// relies on (design 47 G §558).
type invoke struct {
Verb string `json:"verb"`
Fields []string `json:"fields,omitempty"`
}
func (i *invoke) UnmarshalJSON(raw []byte) error {
if err := json.Unmarshal(raw, &i.Verb); err == nil {
return nil
}
type long invoke
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
return dec.Decode((*long)(i))
}
// verbs are the verbs of the invokes, whichever their form.
func verbs(in []invoke) []string {
out := make([]string, 0, len(in))
for _, i := range in {
out = append(out, i.Verb)
}
return out
}
func load(t *testing.T) (manifest, map[string]json.RawMessage) {
t.Helper()
raw, err := os.ReadFile("module.json")
@@ -106,7 +132,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7
if m.Module != "mesh-delivery-view" {
t.Errorf("module = %q", m.Module)
}
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
want := []string{"mesh-conditions.conditions", "mesh-config.resolved", "mesh-delivery-builder.build-log", "mesh-delivery-builder.builds", "mesh-delivery-checker.tries", "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 read of the gate's source: each watched branch's is its verb source's (§512, A-173); // 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) {
@@ -119,15 +145,21 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7
}
// The indexer's and the assigner's states are asked of their owners' read tools (design 25 § 12.2), the six the process
// follows then heard on their views families (ADR 0615 §1, §5; DLV-M4).
if want := []string{"mesh-delivery-assigner.assigner_read_asks", "mesh-delivery-assigner.assigner_read_differences", "mesh-delivery-assigner.assigner_read_assignments",
"mesh-delivery-assigner.assigner_read_wants", "mesh-delivery-indexer.indexer_read_candidates", "mesh-delivery-indexer.indexer_read_config_candidates",
"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)
if want := []invoke{{Verb: "mesh-delivery-assigner.assigner_read_asks"}, {Verb: "mesh-delivery-assigner.assigner_read_differences"}, {Verb: "mesh-delivery-assigner.assigner_read_assignments"},
{Verb: "mesh-delivery-assigner.assigner_read_wants"}, {Verb: "mesh-delivery-indexer.indexer_read_candidates"}, {Verb: "mesh-delivery-indexer.indexer_read_config_candidates"},
{Verb: "mesh-delivery-indexer.indexer_read_graph"}, {Verb: "mesh-delivery-indexer.indexer_read_heard"}, {Verb: "mesh-delivery-indexer.indexer_pending"},
{Verb: "mesh-delivery-indexer.indexer_read_packages"}, {Verb: "mesh-delivery-indexer.indexer_read_predefined"}, {Verb: "mesh-delivery-indexer.indexer_read_taken"},
{Verb: "mesh-delivery-teller.teller_read_answers"}, // A-175: the teller's answers by their views family
{Verb: "seat:mesh-catalog.revision", Fields: []string{"none-yet"}},
{Verb: "seat:mesh-catalog.registrations"}, // §614: the watched branches of the registrations, then registration-changed
{Verb: "seat:mesh-catalog.entry", Fields: []string{"manifest-digest"}}, // §558: the module's value is the entry's manifest-digest (§506)
{Verb: "seat:mesh-delivery.source", Fields: []string{"branches", "group", "repositories", "revision"}}, // §507, §614, A-173: the branches before the first registrations, and each watched branch's source (§558: fields G adds) // ADR 0663: the modules are the catalog half's, read through the seat's verb revision, never the indexer's
{Verb: "seat:mesh-catalog.seat-definitions"}, // design 47 G A-82, §204: the build seats are read from the seat list
{Verb: "seat:mesh-hand-acts.hand-act"}, // the join: its first view asked again by hand is a hand act (views.StatusTools)
{Verb: "seat:mesh-module-assigner.assignments"}, // A-82: the holder of each build seat
{Verb: "seat:mesh-wants.revision"}, // §512, A-242: view_tiers compares kept assignment revisions
{Verb: "seat:mesh-wants.difference"}, // §512, A-242: the tiers and each change's after are the assigner's
{Verb: "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)
}
// design 47 G §387: the keeper's ask for the findings is heard, the finding stream declared (§383), and §368: version is MAJOR.MINOR.
@@ -141,12 +173,14 @@ 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-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", "mesh-delivery-indexer.views.candidates", "mesh-delivery-indexer.views.taken"}; !reflect.DeepEqual(m.Consumes, want) {
"mesh-catalog.catalog-revision", "mesh-catalog.registration-changed", "mesh-delivery.source-moved", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", "mesh-delivery-indexer.views.candidates", "mesh-delivery-indexer.views.taken", "mesh-delivery-teller.views.answers",
"mesh-wants.wait-held", "mesh-wants.wait-ended", "node-engine.wait-held", "node-engine.wait-ended", "mesh-pipeline-runner.wait-held", "mesh-pipeline-runner.wait-ended",
"mesh-grants.wait-held", "mesh-grants.wait-ended", "mesh-delivery-builder.wait-held", "mesh-delivery-builder.wait-ended"}; !reflect.DeepEqual(m.Consumes, want) { // design 47 G §512, A-242: every part's wait-held and wait-ended
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.
if !reflect.DeepEqual(m.Emits, []string{"delivered", "reached", "moving", "settled", "stuck"}) {
t.Errorf("emits = %v, want exactly delivered, reached (ADR 0596 §2), moving, settled and stuck (1906/D9, D10)", m.Emits)
if !reflect.DeepEqual(m.Emits, []string{"delivered", "reached", "moving", "settled", "stuck", "views.answers"}) {
t.Errorf("emits = %v, want exactly delivered, reached (ADR 0596 §2), moving, settled and stuck (1906/D9, D10), and views.answers (A-178)", m.Emits)
}
// The view uses no build seat: it reads their workers by the broker's named grant and publishes no ask (ADR 0525 §2).
if len(m.Uses) != 0 {
@@ -155,7 +189,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}}; !reflect.DeepEqual(m.State, want) {
if want := []state{{Name: "answers", History: 5}, {Name: "reach", History: 1}, {Name: "waits", History: 1}, {Name: "first-views", History: 1}, {Name: "view-marks", 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 {
@@ -166,14 +200,13 @@ 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", "view_seat_list", "view_seat_list_again"}; !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", "view_read_answers"}; !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
// state it reads directly is in `reads`, one it follows is a family it consumes, and the owners' tools it asks are invoked.
t.Run("the names are the manifest's", func(t *testing.T) {
direct := map[string]string{view.SourceBucket: view.GateModule + ".source", view.AsksBucket: view.GateModule + ".asks", view.AnswersBucket: view.TellerModule + ".answers",
view.PresenceBucket: view.WatchmanModule + ".presence", view.StuckBucket: view.WatchmanModule + ".stuck", view.HaveBucket: view.EngineModule + ".have"}
direct := map[string]string{view.PresenceBucket: view.WatchmanModule + ".presence", view.StuckBucket: view.WatchmanModule + ".stuck", view.HaveBucket: view.EngineModule + ".have"}
for bucket, read := range direct {
if strings.ReplaceAll(read, ".", "_") != bucket {
t.Errorf("the bucket %s is not the state %s", bucket, read)
@@ -190,7 +223,8 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7
}
followed := map[string]string{view.CandidatesBucket: view.IndexerModule + ".views.candidates",
view.TakenBucket: view.IndexerModule + ".views.taken", view.AssignmentsBucket: view.AssignerModule + ".views.assignments",
view.AssignerWantsBucket: view.AssignerModule + ".views.wants", view.AssignerAsksBucket: view.AssignerModule + ".views.asks"}
view.AssignerWantsBucket: view.AssignerModule + ".views.wants", view.AssignerAsksBucket: view.AssignerModule + ".views.asks",
view.AnswersBucket: view.TellerModule + ".views.answers"}
for bucket, family := range followed {
if module, state, _ := strings.Cut(family, ".views."); bucket != module+"_"+state {
t.Errorf("the bucket %s is not the state %s of %s", bucket, state, module)
@@ -199,16 +233,23 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7
t.Errorf("the view follows %s, its manifest consumes %v", family, m.Consumes)
}
}
for _, tool := range []string{meshcatalogclient.RevisionVerb, view.IndexerModule + ".indexer_read_candidates", view.IndexerModule + ".indexer_pending", view.AssignerModule + ".assigner_read_wants"} {
if !slices.Contains(m.Invokes, tool) {
for _, tool := range []string{meshcatalogclient.RevisionVerb, meshcatalogclient.RegistrationsVerb, meshcatalogclient.SourceVerb, meshcatalogclient.EntryVerb, view.IndexerModule + ".indexer_read_candidates", view.IndexerModule + ".indexer_pending", view.AssignerModule + ".assigner_read_wants", view.TellerModule + ".teller_read_answers"} {
if !slices.Contains(verbs(m.Invokes), tool) {
t.Errorf("the view asks %s, its manifest invokes %v", tool, m.Invokes)
}
}
// The gate's asks go with the Approve per merge, and the teller's answers are followed by their views family, never read
// (design 47 G §431, §512, A-175).
for _, read := range []string{view.GateModule + ".asks", view.TellerModule + ".answers"} {
if slices.Contains(m.Reads, read) {
t.Errorf("the manifest reads %s (design 47 G §512)", read)
}
}
// The modules are the catalog half's: the seat's verb is invoked, its event consumed, and the indexer's state modules is neither.
if !slices.Contains(m.Consumes, view.CatalogSeat+"."+meshcatalog.EventCatalogRevision) {
t.Errorf("the view hears the seat's %s, its manifest consumes %v", meshcatalog.EventCatalogRevision, m.Consumes)
}
for _, name := range append(slices.Clone(m.Invokes), m.Consumes...) {
for _, name := range append(verbs(m.Invokes), m.Consumes...) {
if strings.Contains(name, "indexer_read_modules") || strings.Contains(name, "views.modules") {
t.Errorf("the manifest names %s: the modules are the catalog half's (ADR 0663)", name)
}
+33 -7
View File
@@ -29,14 +29,16 @@
"view_first_view_again",
"node_manifest_reads",
"view_seat_list",
"view_seat_list_again"
"view_seat_list_again",
"view_read_answers"
],
"emits": [
"delivered",
"reached",
"moving",
"settled",
"stuck"
"stuck",
"views.answers"
],
"state": [
{
@@ -47,9 +49,17 @@
"name": "reach",
"history": 1
},
{
"name": "waits",
"history": 1
},
{
"name": "first-views",
"history": 1
},
{
"name": "view-marks",
"history": 1
}
],
"consumes": [
@@ -58,10 +68,23 @@
"mesh-delivery-assigner.views.assignments",
"mesh-delivery-assigner.views.wants",
"mesh-catalog.catalog-revision",
"mesh-catalog.registration-changed",
"mesh-delivery.source-moved",
"mesh-catalog.seat-definition-changed",
"mesh-catalog.seat-definition-removed",
"mesh-delivery-indexer.views.candidates",
"mesh-delivery-indexer.views.taken"
"mesh-delivery-indexer.views.taken",
"mesh-delivery-teller.views.answers",
"mesh-wants.wait-held",
"mesh-wants.wait-ended",
"node-engine.wait-held",
"node-engine.wait-ended",
"mesh-pipeline-runner.wait-held",
"mesh-pipeline-runner.wait-ended",
"mesh-grants.wait-held",
"mesh-grants.wait-ended",
"mesh-delivery-builder.wait-held",
"mesh-delivery-builder.wait-ended"
],
"invokes": [
"mesh-delivery-assigner.assigner_read_asks",
@@ -76,10 +99,16 @@
"mesh-delivery-indexer.indexer_read_packages",
"mesh-delivery-indexer.indexer_read_predefined",
"mesh-delivery-indexer.indexer_read_taken",
"seat:mesh-catalog.revision",
"mesh-delivery-teller.teller_read_answers",
{"verb": "seat:mesh-catalog.revision", "fields": ["none-yet"]},
"seat:mesh-catalog.registrations",
{"verb": "seat:mesh-catalog.entry", "fields": ["manifest-digest"]},
{"verb": "seat:mesh-delivery.source", "fields": ["branches", "group", "repositories", "revision"]},
"seat:mesh-catalog.seat-definitions",
"seat:mesh-hand-acts.hand-act",
"seat:mesh-module-assigner.assignments",
"seat:mesh-wants.revision",
"seat:mesh-wants.difference",
"seat:node-engine.manifest-reads"
],
"reads": [
@@ -93,9 +122,6 @@
"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-conditions.conditions",
"mesh-healers.attempts",
"mesh-provisioner.provisions",
@@ -0,0 +1,69 @@
package viewstate
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/keys"
)
// WaitsState is the view's own state `waits`: every wait a part holds, as the parts say it (design 47 G §512, §376, A-242), one
// key `<owner>.<step>.<node>.<module>` each, written by the view's process when it hears `wait-held` and deleted when it hears
// `wait-ended`, and read by view_tiers through the view's own read tool.
const WaitsState = "waits"
// WaitsBucket is the bus's bucket of the state waits.
const WaitsBucket = ViewModule + "_waits"
// Wait is the value of a waits key: the fields of the seat mesh-wants' wait (§498, §512, §629) as the part that holds it said
// them. The view writes no words of its own: Words is the sayer's.
type Wait struct {
// Owner is the seat or module that says the wait.
Owner string `json:"owner"`
Step string `json:"step"`
Node string `json:"node,omitempty"`
Module string `json:"module,omitempty"`
On string `json:"on,omitempty"`
Bound string `json:"bound,omitempty"`
Since time.Time `json:"since"`
Words string `json:"words,omitempty"`
Reason string `json:"reason,omitempty"`
Part string `json:"part,omitempty"`
Version string `json:"version,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
Machines []string `json:"machines,omitempty"`
}
// WaitKey is the key of a wait: owner, step, node and module, each as a key part (an absent one is `-`).
func WaitKey(owner, step, node, module string) string {
part := func(s string) string {
if s == "" {
return "-"
}
return keys.EscapeName(s)
}
return strings.Join([]string{part(owner), part(step), part(node), part(module)}, ".")
}
// Key is the wait's key.
func (w Wait) Key() string { return WaitKey(w.Owner, w.Step, w.Node, w.Module) }
// ReadWait reads the body of a wait-held or wait-ended said by owner (the seat or module of its subject): the owner is the
// body's own when it names one. A body with no step or a field the wait does not know is refused.
func ReadWait(owner string, body []byte) (Wait, error) {
var w Wait
dec := json.NewDecoder(bytes.NewReader(body))
if err := dec.Decode(&w); err != nil {
return Wait{}, fmt.Errorf("the wait does not read: %w", err)
}
if w.Owner == "" {
w.Owner = owner
}
if w.Step == "" || w.Owner == "" {
return Wait{}, fmt.Errorf("the wait names no step or no owner")
}
return w, nil
}
@@ -0,0 +1,19 @@
package viewstate
import "testing"
func TestAWaitIsKeyedByOwnerStepNodeAndModuleAndReadFromItsBody(t *testing.T) {
w, err := ReadWait("mesh-wants", []byte(`{"step":"delivery","module":"web","node":"ace","part":"db","machines":["g14"],"since":"2026-11-12T01:00:00Z","words":"waiting for delivery of db to g14","extra-field":1}`))
if err != nil || w.Owner != "mesh-wants" || w.Key() != "mesh-wants.delivery.ace.web" || w.Words != "waiting for delivery of db to g14" || w.Machines[0] != "g14" {
t.Fatalf("wait %+v, key %q, %v", w, w.Key(), err)
}
if k := WaitKey("node-engine", "kept-build", "", "x y"); k != "node-engine.kept-build.-.x=20y" {
t.Errorf("key %q", k)
}
if _, err := ReadWait("mesh-wants", []byte(`{"module":"web"}`)); err == nil {
t.Error("a wait with no step read")
}
if w, _ := ReadWait("a", []byte(`{"owner":"b","step":"put"}`)); w.Owner != "b" {
t.Errorf("owner %+v", w)
}
}