Files
mesh-delivery/modules/mesh-delivery-view/viewstate/reach.go
T
jschoubben 0de534c692 Names N3 (view): the status view names its own buckets from its credential's module
Its states answers, reach and waits (viewstate WaitsBucket, ReachBucket, ViewAnswersBucket), its
first views and its marks were named from the constant ViewModule; they are now
`<cred.Module>_<state>` (viewstate.BucketsOf), and the model is made for its module.

Builds: design 47 G A-296

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 19:14:55 +01:00

196 lines
8.6 KiB
Go

// 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/modules/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 (its state reach): 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.
// ReachState is the delivery view's state `reach`: one key per repository (RepositoryKey), how far its modules reached.
const ReachState = "reach"
// Buckets are the delivery view's own state buckets, `<module>_<state>`: waits, reach and answers.
type Buckets struct{ Waits, Reach, Answers string }
// BucketsOf names the view's own buckets from module, its credential's, never a constant (design 47 G A-296).
func BucketsOf(module string) Buckets {
return Buckets{Waits: module + "_" + WaitsState, Reach: module + "_" + ReachState, Answers: module + "_" + ViewAnswersState}
}
// 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
}
// ViewAnswersState is the delivery view's state `answers`: one key per ask it answered, and ViewAnswersHistory the revisions it
// keeps per key.
const (
ViewAnswersState = "answers"
ViewAnswersHistory = 5
// ViewAnswersReadTool is the view's read tool of its state answers (mesh-sdk views.ReadTool), whose changes it says on its
// views family as views.answers; its marks are its state view-marks (views.MarksState) (design 47 G A-178).
ViewAnswersReadTool = "view_read_answers"
)
// 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 (keys.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 }