185 lines
7.9 KiB
Go
185 lines
7.9 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/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 (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 }
|