diff --git a/modules/mesh-delivery-indexer/takein/go.mod b/modules/mesh-delivery-indexer/takein/go.mod index 48ab0844..b221739e 100644 --- a/modules/mesh-delivery-indexer/takein/go.mod +++ b/modules/mesh-delivery-indexer/takein/go.mod @@ -2,4 +2,9 @@ module git.novox.be/novox/mesh-delivery/modules/mesh-delivery-indexer/takein go 1.26.0 -require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104002338-5a38a1221659 +require ( + git.novox.be/novox/mesh-delivery/sdk v0.0.0 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104002338-5a38a1221659 +) + +replace git.novox.be/novox/mesh-delivery/sdk => ../../../sdk diff --git a/modules/mesh-delivery-indexer/takein/taken.go b/modules/mesh-delivery-indexer/takein/taken.go new file mode 100644 index 00000000..e7cd3ea8 --- /dev/null +++ b/modules/mesh-delivery-indexer/takein/taken.go @@ -0,0 +1,43 @@ +package takein + +import ( + "encoding/json" + "fmt" + "time" + + "git.novox.be/novox/mesh-delivery/sdk/kept" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// TakenState is the indexer's state `taken`: one key per repository (trunk.RepositoryKey), the last merge it took in. +const TakenState = "taken" + +// Taken is the value of a taken key: the repository, the merge commit the indexer last took in (applied its message +// whole), and when. +type Taken struct { + V int `json:"v"` + Repository string `json:"repository"` + Merge string `json:"merge"` + At time.Time `json:"at"` +} + +// EncodeTaken is t as JSON at Version; a value with no repository or merge is refused. +func EncodeTaken(t Taken) ([]byte, error) { + if t.Repository == "" || t.Merge == "" { + return nil, fmt.Errorf("takein: a taken value names its repository and merge") + } + t.V = keys.Version + return json.Marshal(t) +} + +// DecodeTaken reads a taken value; it refuses an unknown version (ErrUnknownVersion) and one with no merge. +func DecodeTaken(data []byte) (Taken, error) { + var t Taken + if err := kept.Decode(data, &t); err != nil { + return t, err + } + if t.Repository == "" || t.Merge == "" { + return t, fmt.Errorf("takein: a taken value names no repository or merge") + } + return t, nil +} diff --git a/modules/mesh-delivery-indexer/takein/taken_test.go b/modules/mesh-delivery-indexer/takein/taken_test.go new file mode 100644 index 00000000..7a0aca61 --- /dev/null +++ b/modules/mesh-delivery-indexer/takein/taken_test.go @@ -0,0 +1,33 @@ +package takein + +import ( + "errors" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-delivery/sdk/kept" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// 1280: the taken value (ADR 0556) round-trips at its version, and one with no repository or merge, or of another version, +// is refused. +func TestATakenValueRoundTripsAndRefusesWhatLacksARepositoryOrAMerge(t *testing.T) { + tk := Taken{Repository: "novox/mesh-sdk", Merge: "cccc", At: time.Date(2026, 10, 26, 20, 0, 0, 0, time.UTC)} + data, err := EncodeTaken(tk) + if err != nil { + t.Fatal(err) + } + tk.V = keys.Version + if got, err := DecodeTaken(data); err != nil || got != tk { + t.Errorf("taken round trip: %+v %v", got, err) + } + if _, err := EncodeTaken(Taken{Repository: "a/b"}); err == nil || !strings.Contains(err.Error(), "merge") { + t.Errorf("taken without merge: %v", err) + } + for _, in := range []string{`{"v":2,"repository":"a/b","merge":"c"}`, `{"repository":"a/b","merge":"c"}`} { + if _, err := DecodeTaken([]byte(in)); !errors.Is(err, kept.ErrUnknownVersion) { + t.Errorf("decode of %s = %v, want ErrUnknownVersion", in, err) + } + } +} diff --git a/modules/mesh-delivery-view/module.json b/modules/mesh-delivery-view/module.json index 8608e49d..dccb6fa0 100644 --- a/modules/mesh-delivery-view/module.json +++ b/modules/mesh-delivery-view/module.json @@ -188,6 +188,15 @@ "name": "files", "kind": "archive", "from": "files" + }, + { + "name": "viewstate", + "kind": "package", + "language": "go", + "paths": [ + "viewstate" + ], + "series": "0.1" } ] } diff --git a/modules/mesh-delivery-view/viewstate/go.mod b/modules/mesh-delivery-view/viewstate/go.mod new file mode 100644 index 00000000..5a17cb64 --- /dev/null +++ b/modules/mesh-delivery-view/viewstate/go.mod @@ -0,0 +1,10 @@ +module git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate + +go 1.26.0 + +require ( + git.novox.be/novox/mesh-delivery/sdk v0.0.0 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f +) + +replace git.novox.be/novox/mesh-delivery/sdk => ../../../sdk diff --git a/modules/mesh-delivery-view/viewstate/reach.go b/modules/mesh-delivery-view/viewstate/reach.go new file mode 100644 index 00000000..4cad0487 --- /dev/null +++ b/modules/mesh-delivery-view/viewstate/reach.go @@ -0,0 +1,184 @@ +// Package viewstate holds what the delivery view owns and no seat carries (novox/hq ADR 0634 §1): the layout of its state +// `reach` (how far a repository's modules reached on the nodes that want them) and of its answers to asks, and the rules over +// them. Other modules import it from the view by its published version (ADR 0573 §1). +package viewstate + +import ( + "encoding/json" + "fmt" + "maps" + "slices" + "strings" + + "git.novox.be/novox/mesh-delivery/sdk/kept" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// ViewModule is the module of the delivery view. +const ViewModule = "mesh-delivery-view" + +// modulesHistory is the revisions the indexer's state `modules` keeps per key, the most changes a module's reach keeps. +const modulesHistory = 20 + +// How far a repository reached (novox/hq ADR 0556, issue 1280). The indexer says, per repository, the last merge it took in +// (the indexer's state `taken`); the delivery view keeps, per repository, how far each of its modules reached on the nodes that want it, and +// derives from it the repository's reached head (ReachBucket): the newest head of the repository at which every wanted +// module runs, on every node, a build at or after its last change. A merge at or before the reached head is on the +// machines. Nothing records a merge as delivered (ADR 0429 §1): the gate and the tracker derive it from this state. + +// ReachBucket is the delivery view's state `reach`: one key per repository (RepositoryKey), how far its modules reached. +const ReachBucket = ViewModule + "_reach" + +// Reach is the value of a reach key: how far the modules of one repository reached on the nodes. Indexed is the last +// merge the indexer took in for it (Taken.Merge), empty while it took in none. A repository the indexer holds no module +// of (a library) has no Modules. Reached is the reached head: the newest head of the repository in the source's history, +// not past Indexed, at which every module with a node that wants it has its floor at or after its last change at or before +// that head; ReachedSrc is the source revision that named it. Holds says, in words, what keeps the next head of the +// repository from being reached (a module on a node, the indexer's lag); empty when the repository's newest head is reached. +type Reach struct { + V int `json:"v"` + Repository string `json:"repository"` + Indexed string `json:"indexed,omitempty"` + Modules map[string]ModuleReach `json:"modules,omitempty"` + Reached string `json:"reached,omitempty"` + ReachedSrc uint64 `json:"reached_src,omitempty"` + Holds []string `json:"holds,omitempty"` +} + +// ModuleReach is how far one module reached. FP is its fingerprint at the indexer now; Nodes how many nodes want it +// (a want not removed); Changes its fingerprints the view knows of, each with the merge commit it first appeared at, +// newest first. Floor is the oldest first-appearance commit among the fingerprints its nodes' haves run, a running have +// counting whether its health check passes or not; it is empty when a node that wants the module has no running have, or +// runs a fingerprint not among Changes. Waiting names, in words, every node where the module is not settled (its have +// different from its want, not running, or not healthy), with or without a floor: health is said here and never moves the +// floor (ADR 0556 clause 2). A module whose floor is empty while a node wants it holds no head of the repository back: a +// reader takes a merge among its Changes as not yet on the machines, with these words. A module no node wants has neither. +type ModuleReach struct { + FP string `json:"fp,omitempty"` + Nodes int `json:"nodes"` + Changes []Change `json:"changes,omitempty"` + Floor string `json:"floor,omitempty"` + Waiting string `json:"waiting,omitempty"` +} + +// Change is one fingerprint of a module, the merge commit it first appeared at (Module.Commit), and the source revision +// that named that commit as the repository's head (Module.Src, or the source's history), 0 when not known. +type Change struct { + Commit string `json:"commit"` + FP string `json:"fp"` + Src uint64 `json:"src,omitempty"` +} + +// MostChanges is how many changes of a module a reach value keeps: as many revisions as the indexer's modules bucket keeps. +const MostChanges = modulesHistory + +func (r Reach) check() error { + if r.Repository == "" { + return fmt.Errorf("viewstate: a reach value names no repository") + } + if (r.Reached == "") != (r.ReachedSrc == 0) { + return fmt.Errorf("viewstate: the reach of %s names its reached head and its source revision together", r.Repository) + } + for name, m := range r.Modules { + if name == "" { + return fmt.Errorf("viewstate: a reach value names a module with no name") + } + if m.Nodes < 0 { + return fmt.Errorf("viewstate: the reach of %s counts %d nodes", name, m.Nodes) + } + if len(m.Changes) > MostChanges { + return fmt.Errorf("viewstate: the reach of %s keeps %d changes, more than %d", name, len(m.Changes), MostChanges) + } + for _, c := range m.Changes { + if c.Commit == "" || c.FP == "" { + return fmt.Errorf("viewstate: a change of %s names no commit or fingerprint", name) + } + } + } + return nil +} + +// EncodeReach is r as JSON at Version; a value that does not check is refused. +func EncodeReach(r Reach) ([]byte, error) { + if err := r.check(); err != nil { + return nil, err + } + r.V = keys.Version + return json.Marshal(r) +} + +// DecodeReach reads a reach value; it refuses an unknown version (ErrUnknownVersion) and one that does not check. +func DecodeReach(data []byte) (Reach, error) { + var r Reach + if err := kept.Decode(data, &r); err != nil { + return r, err + } + return r, r.check() +} + +// SameReach says whether two reach values are the same, so the view writes only when they differ. +func SameReach(a, b Reach) bool { + if a.Repository != b.Repository || a.Indexed != b.Indexed || len(a.Modules) != len(b.Modules) || + a.Reached != b.Reached || a.ReachedSrc != b.ReachedSrc || !slices.Equal(a.Holds, b.Holds) { + return false + } + for _, name := range slices.Sorted(maps.Keys(a.Modules)) { + x, ok := b.Modules[name] + y := a.Modules[name] + if !ok || x.FP != y.FP || x.Nodes != y.Nodes || x.Floor != y.Floor || x.Waiting != y.Waiting || + !slices.Equal(x.Changes, y.Changes) { + return false + } + } + return true +} + +// ViewAnswersBucket is the delivery view's state `answers`: one key per ask it answered, and ViewAnswersHistory the revisions it +// keeps per key. +const ( + ViewAnswersBucket = ViewModule + "_answers" + ViewAnswersHistory = 5 +) + +// ViewAnswer is the value of the view's answer to an ask of the assigner: the asking it answers, the option chosen and the words +// said with it. The key of an answer is the ask's key and ".view" (the assigner's package of asks holds the ask keys). +type ViewAnswer struct { + V int `json:"v"` + Asking string `json:"asking"` + Option string `json:"option"` + Words string `json:"words,omitempty"` +} + +func (a ViewAnswer) check() error { + if strings.TrimSpace(a.Asking) == "" { + return fmt.Errorf("viewstate: a view answer names no asking") + } + if strings.TrimSpace(a.Option) == "" { + return fmt.Errorf("viewstate: a view answer names no option") + } + return nil +} + +// EncodeViewAnswer is a as JSON at the version; an answer naming no asking or no option is refused. +func EncodeViewAnswer(a ViewAnswer) ([]byte, error) { + if err := a.check(); err != nil { + return nil, err + } + a.V = keys.Version + return json.Marshal(a) +} + +// DecodeViewAnswer reads an answer; it refuses an unknown version (kept.ErrUnknownVersion) and what EncodeViewAnswer refuses. +func DecodeViewAnswer(data []byte) (ViewAnswer, error) { + var a ViewAnswer + if err := kept.Decode(data, &a); err != nil { + return ViewAnswer{}, err + } + if err := a.check(); err != nil { + return ViewAnswer{}, err + } + return a, nil +} + +// SameViewAnswer says whether two answers are the same value, so the view writes only when they differ. +func SameViewAnswer(a, b ViewAnswer) bool { return a == b } diff --git a/modules/mesh-delivery-view/viewstate/viewstate_test.go b/modules/mesh-delivery-view/viewstate/viewstate_test.go new file mode 100644 index 00000000..c35726bb --- /dev/null +++ b/modules/mesh-delivery-view/viewstate/viewstate_test.go @@ -0,0 +1,91 @@ +package viewstate + +import ( + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/keys" +) + +func sampleReach() Reach { + return Reach{V: keys.Version, Repository: "novox/mesh-catalog", Indexed: "cccc", Reached: "cccc", ReachedSrc: 9, Modules: map[string]ModuleReach{ + "gitea": {FP: "src2:a", Nodes: 1, Floor: "cccc", Waiting: "gitea on ace runs src2:a and its health check failed", Changes: []Change{{Commit: "cccc", FP: "src2:a", Src: 9}, {Commit: "bbbb", FP: "src2:b", Src: 7}}}, + "zsh": {FP: "src2:z", Changes: []Change{{Commit: "aaaa", FP: "src2:z"}}}, + }} +} + +// 1280: the reach value (ADR 0556) round-trips, and values that do not hold are refused, naming what is wrong. +func TestAReachValueRoundTripsAndRefusesWhatDoesNotHold(t *testing.T) { + wantErr := func(what string, err error, in string) { + t.Helper() + if err == nil || !strings.Contains(err.Error(), in) { + t.Errorf("%s: %v, want an error naming %q", what, err, in) + } + } + r := sampleReach() + data, err := EncodeReach(r) + if err != nil { + t.Fatal(err) + } + if got, err := DecodeReach(data); err != nil || !SameReach(got, r) { + t.Errorf("reach round trip: %+v %v", got, err) + } + r.Repository = "" + _, err = EncodeReach(r) + wantErr("reach without repository", err, "repository") + r = sampleReach() + m := r.Modules["gitea"] + m.Changes = append(m.Changes, Change{Commit: "dddd"}) + r.Modules["gitea"] = m + _, err = EncodeReach(r) + wantErr("reach with a change naming no fingerprint", err, "gitea") + r = sampleReach() + m = r.Modules["gitea"] + m.Changes = make([]Change, MostChanges+1) + r.Modules["gitea"] = m + _, err = EncodeReach(r) + wantErr("reach keeping too many changes", err, "more than") + r = sampleReach() + r.ReachedSrc = 0 + _, err = EncodeReach(r) + wantErr("reach with a head and no revision", err, "reached head") +} + +func TestReachValuesDifferingInAnyPartAreNotTheSame(t *testing.T) { + if !SameReach(sampleReach(), sampleReach()) { + t.Error("equal reach values differ") + } + for name, change := range map[string]func(*Reach){ + "indexed": func(r *Reach) { r.Indexed = "dddd" }, + "floor": func(r *Reach) { m := r.Modules["gitea"]; m.Floor = "bbbb"; r.Modules["gitea"] = m }, + "changes": func(r *Reach) { m := r.Modules["zsh"]; m.Changes = nil; r.Modules["zsh"] = m }, + "modules": func(r *Reach) { delete(r.Modules, "zsh") }, + "reached": func(r *Reach) { r.Reached, r.ReachedSrc = "bbbb", 7 }, + "holds": func(r *Reach) { r.Holds = []string{"gitea on ace does not yet run src2:a"} }, + } { + r := sampleReach() + change(&r) + if SameReach(r, sampleReach()) { + t.Errorf("reach values differing in %s are the same", name) + } + } +} + +func TestViewAnswerRoundTrip(t *testing.T) { + a := ViewAnswer{Asking: "hold-mesh-mail-aaaa-0123456789ab", Option: "release", Words: "fixed by #12"} + data, err := EncodeViewAnswer(a) + if err != nil { + t.Fatal(err) + } + got, err := DecodeViewAnswer(data) + a.V = keys.Version + if err != nil || !SameViewAnswer(got, a) { + t.Errorf("view answer came back %+v, %v", got, err) + } + if _, err := EncodeViewAnswer(ViewAnswer{Asking: "x", Option: " "}); err == nil { + t.Errorf("a view answer naming no option was accepted") + } + if _, err := EncodeViewAnswer(ViewAnswer{Option: "release"}); err == nil { + t.Errorf("a view answer naming no asking was accepted") + } +} diff --git a/sdk/trunk/trunk.go b/sdk/trunk/trunk.go index 44402a43..94459fba 100644 --- a/sdk/trunk/trunk.go +++ b/sdk/trunk/trunk.go @@ -1,6 +1,11 @@ package trunk -import "strings" +import ( + "fmt" + "strings" + + "git.novox.be/novox/mesh-sdk/go/keys" +) // RepositoryName is the repository as the forge names it, "owner/name", from either spelling the mesh holds: the name // itself, or the clone URL a build record carries ("http://host:port/owner/name.git", with or without ".git" or a @@ -23,3 +28,14 @@ func SameRepository(a, b string) bool { a, b = RepositoryName(a), RepositoryName(b) return a != "" && strings.EqualFold(a, b) } + +// RepositoryKey is a repository's key in the indexer's state `taken` and the view's state `reach`: its forge name `owner/name` (RepositoryName), in +// lower case as the forge compares it, escaped as a key part (keys.EscapeName). An empty spelling, or one with no owner, is +// refused. +func RepositoryKey(repository string) (string, error) { + name := strings.ToLower(RepositoryName(repository)) + if owner, repo, ok := strings.Cut(name, "/"); !ok || owner == "" || repo == "" { + return "", fmt.Errorf("trunk: %q names no repository as owner/name", repository) + } + return keys.EscapeName(name), nil +} diff --git a/sdk/trunk/trunk_test.go b/sdk/trunk/trunk_test.go index c0c6b53f..fd7c3e6e 100644 --- a/sdk/trunk/trunk_test.go +++ b/sdk/trunk/trunk_test.go @@ -45,3 +45,14 @@ func TestAnImportSetHoldsMeshPackagesSortedAndEachOnce(t *testing.T) { } } } + +// A repository keys the indexer's taken and the view's reach by its forge name, in lower case, from either spelling (ADR 0556). +func TestARepositoryIsKeyedByItsLowerCaseForgeName(t *testing.T) { + for in, want := range map[string]string{"novox/Mesh-SDK": "novox/mesh-sdk", "http://h:3000/novox/mesh-sdk.git": "novox/mesh-sdk", + "a/b.c": "a/b=2ec", "": "", "nope": "", "/x": ""} { + got, err := RepositoryKey(in) + if (want == "") != (err != nil) || got != want { + t.Errorf("RepositoryKey(%q) = %q, %v; want %q", in, got, err, want) + } + } +}