Files
mesh-delivery/modules/mesh-delivery-view/internal/view/reach.go
T

348 lines
12 KiB
Go

package view
import (
"fmt"
"maps"
"slices"
"strings"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// How far each repository reached (novox/hq ADR 0556 clauses 2 and 3, issue 1280). The view keeps, per repository the gate's
// source names, how far each of its modules reached on the nodes that want it, and derives the repository's reached head: the
// newest head of the repository in the source's history, not past the merge the indexer took in, at which every wanted module's
// floor is at or after its last change at or before that head. It writes that as its state reach and says each new reached
// 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.
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{delivery.ModulesBucket, delivery.AssignerWantsBucket, delivery.HaveBucket, delivery.SourceBucket,
delivery.TakenBucket, ReachHistory(delivery.ModulesBucket), ReachHistory(delivery.SourceBucket)}
// ReachWrite is a reach value to write under its key in the view's state reach.
type ReachWrite struct {
Key string
Value []byte
}
// SetReachWriter sets where the view's reach values go; nothing is written until it is set.
func (m *Model) SetReachWriter(put func(ReachWrite)) {
m.mu.Lock()
defer m.mu.Unlock()
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).
func (m *Model) applyHistory(e Entry) {
switch e.Bucket {
case delivery.ModulesBucket:
if e.Deleted {
delete(m.modHist, e.Key)
return
}
mod, err := delivery.DecodeModule(e.Value)
if err != nil || mod.FP2 == "" || mod.Commit == "" {
return
}
h := m.modHist[e.Key]
if n := len(h); n > 0 && h[n-1].FP2 == mod.FP2 && h[n-1].Commit == mod.Commit {
if mod.Src != 0 {
h[n-1].Src = mod.Src
}
return
}
h = append(h, mod)
if len(h) > 2*delivery.MostChanges {
h = h[len(h)-delivery.MostChanges:]
}
m.modHist[e.Key] = h
case delivery.SourceBucket:
if e.Key != delivery.SourceMainKey || e.Deleted || e.Revision == 0 {
return
}
src, err := delivery.DecodeSource(e.Value)
if err != nil {
return
}
m.srcHist[e.Revision] = src.Heads
}
}
// headAt is one head of a repository and the first source revision that named it.
type headAt struct {
commit string
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 {
var out []headAt
for _, rev := range slices.Sorted(maps.Keys(m.srcHist)) {
for r, head := range m.srcHist[rev] {
if !delivery.SameRepository(r, repository) || head == "" {
continue
}
if n := len(out); n == 0 || !sameCommit(out[n-1].commit, head) {
out = append(out, headAt{commit: head, rev: rev})
}
}
}
return out
}
// revOf is the first source revision naming a commit as the repository's head, or 0 when the history holds none.
func revOf(heads []headAt, commit string) uint64 {
for _, h := range heads {
if sameCommit(h.commit, commit) {
return h.rev
}
}
return 0
}
// sameCommit is whether two spellings of a commit are one (either may be abbreviated to at least seven characters).
func sameCommit(a, b string) bool {
a, b = strings.ToLower(a), strings.ToLower(b)
if len(a) > len(b) {
a, b = b, a
}
return len(a) >= 7 && strings.HasPrefix(b, a)
}
// noBound is a module's bound when its floor is its newest change: it holds no head back.
const noBound = ^uint64(0)
// wantKeys are the wants' keys by module, each sorted: read once for every repository a derivation looks at.
func (m *Model) wantKeys() map[string][]string {
out := map[string][]string{}
for _, key := range slices.Sorted(maps.Keys(m.wants)) {
_, module := splitKey(key)
out[module] = append(out[module], key)
}
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) delivery.Reach {
r := delivery.Reach{Repository: delivery.RepositoryName(repository), Modules: map[string]delivery.ModuleReach{}}
heads := m.headsOf(repository)
key, _ := delivery.RepositoryKey(repository)
if t, ok := m.taken[key]; ok {
r.Indexed = t.Merge
}
bound := noBound
var holds []string
for _, name := range slices.Sorted(maps.Keys(m.modules)) {
me := m.modules[name]
if me.err != "" || me.value.FP2 == "" || !delivery.SameRepository(me.value.Repository, repository) {
continue
}
mr, b, words := m.moduleReach(name, me.value, heads, wants[name])
r.Modules[name] = mr
if b < bound {
bound = b
}
if b != noBound {
holds = append(holds, words)
}
}
if len(r.Modules) == 0 {
r.Modules = nil
}
indexed := uint64(0)
if r.Indexed != "" {
indexed = revOf(heads, r.Indexed)
}
switch {
case r.Indexed == "":
holds = append(holds, "the indexer has taken in no merge of "+r.Repository)
case indexed == 0:
holds = append(holds, fmt.Sprintf("the merge the indexer took in, %.12s, is not a head of %s in the source's history", r.Indexed, r.Repository))
}
for _, h := range heads {
if h.rev <= indexed && h.rev < bound {
r.Reached, r.ReachedSrc = h.commit, h.rev
}
}
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))
}
slices.Sort(holds)
r.Holds = slices.Compact(holds)
}
return r
}
// moduleReach is how far one module reached, the source revision it holds the reached head before (noBound when none), and
// what holds it, in words. A node counts for the fingerprint its running have runs, healthy or not (ADR 0556 clause 2).
func (m *Model) moduleReach(name string, mod delivery.Module, heads []headAt, wantKeys []string) (delivery.ModuleReach, uint64, string) {
mr := delivery.ModuleReach{FP: mod.FP2}
hist := m.modHist[name]
if n := len(hist); n == 0 || hist[n-1].FP2 != mod.FP2 || hist[n-1].Commit != mod.Commit {
hist = append(slices.Clone(hist), mod) // the value watched now is the newest, whatever the history watch has given
}
for i := len(hist) - 1; i >= 0 && len(mr.Changes) < delivery.MostChanges; i-- {
c := delivery.Change{Commit: hist[i].Commit, FP: hist[i].FP2, Src: hist[i].Src}
if c.Src == 0 {
c.Src = revOf(heads, c.Commit)
}
if n := len(mr.Changes); n > 0 && mr.Changes[n-1].FP == c.FP {
mr.Changes[n-1] = c // the same fingerprint again, back to back: its first appearance is the older commit
continue
}
mr.Changes = append(mr.Changes, c)
}
var waiting []string
floor, floorNode, unset := -1, "", ""
for _, key := range wantKeys {
node, _ := splitKey(key)
w := m.wants[key]
if w.err != "" || w.value.Removed || w.value.FP == "" {
continue
}
mr.Nodes++
h, ok := m.haves[key]
switch {
case !ok || h.err != "" || h.have.FP == "":
waiting = append(waiting, fmt.Sprintf("%s on %s runs nothing yet", name, node))
unset = waiting[len(waiting)-1]
continue
case !runs(h.have.Phase):
waiting = append(waiting, fmt.Sprintf("%s on %s is %s at %s, not running", name, node, h.have.Phase, short(h.have.FP)))
unset = waiting[len(waiting)-1]
continue
case h.have.FP != w.value.FP:
waiting = append(waiting, fmt.Sprintf("%s on %s runs %s, not yet the %s it wants", name, node, short(h.have.FP), short(w.value.FP)))
case h.have.Health == delivery.HealthFailed:
waiting = append(waiting, fmt.Sprintf("%s on %s runs %s and its health check failed", name, node, short(h.have.FP)))
case h.have.Health != delivery.HealthOK:
waiting = append(waiting, fmt.Sprintf("%s on %s runs %s, not yet healthy", name, node, short(h.have.FP)))
}
i := slices.IndexFunc(mr.Changes, func(c delivery.Change) bool { return c.FP == h.have.FP })
if i < 0 {
unset = fmt.Sprintf("%s on %s runs %s, which is not among its %d known changes", name, node, short(h.have.FP), len(mr.Changes))
waiting = append(waiting, unset)
continue
}
if i > floor {
floor, floorNode = i, node
}
}
mr.Waiting = strings.Join(waiting, "; ")
switch {
case mr.Nodes == 0:
return mr, noBound, ""
case unset != "":
// A node that wants the module runs nothing countable of it (no have, not running, or a build older than the kept
// history): the module holds no head of the repository back, which would make every apply anywhere, or one module
// stuck failed, freeze the whole repository (review of issue 1280, ADR 0556 clause 2). Its words name it, and a merge
// among its changes is not yet on the machines (notYet).
return mr, noBound, ""
}
mr.Floor = mr.Changes[floor].Commit
if floor == 0 {
return mr, noBound, ""
}
next := mr.Changes[floor-1]
words := fmt.Sprintf("%s on %s runs the build of %.12s, not yet that of %.12s", name, floorNode, mr.Floor, next.Commit)
if next.Src == 0 {
// The next change's order is not known: the head is held at the floor's own revision, or before every head when that is
// not known either.
if own := mr.Changes[floor].Src; own != 0 {
return mr, own + 1, words
}
return mr, 0, words
}
return mr, next.Src, words
}
// runs is whether a have in this phase runs the fingerprint it names: running, being checked after its apply, or held after a
// failed backup, which keeps running what it has (ADR 0539 clause 16). A have backing up or applying, failed or removed runs
// nothing the derivation can count.
func runs(p delivery.Phase) bool {
return p == delivery.PhaseRunning || p == delivery.PhaseChecking || p == delivery.PhaseHeld
}
// short is a fingerprint's first twelve characters, after its form.
func short(fp string) string {
fp = strings.TrimPrefix(fp, delivery.SourcePrefix2)
if len(fp) > 12 {
fp = fp[:12]
}
return fp
}
// reachReady is whether every watch the derivation reads handed over what stood at its start.
func (m *Model) reachReady() bool {
for _, b := range reachBuckets {
if !m.initial[b] {
return false
}
}
return m.source != nil && m.source.err == ""
}
// 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.
func (m *Model) reachNow() ([]ReachWrite, []DeliveredEvent) {
if (m.reachPut == nil && m.deliveredEvent == nil) || !m.reachReady() {
return nil, nil
}
var writes []ReachWrite
var events []DeliveredEvent
wants := m.wantKeys()
for _, repository := range slices.Sorted(maps.Keys(m.source.heads)) {
key, err := delivery.RepositoryKey(repository)
if err != nil {
continue
}
r := m.deriveReach(repository, wants)
if prev, ok := m.reach[key]; !ok || !delivery.SameReach(prev, r) {
if data, err := delivery.EncodeReach(r); err == nil {
m.reach[key] = r
writes = append(writes, ReachWrite{Key: key, Value: data})
}
}
notYet := notYetOf(r)
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},
Repository: r.Repository, NotYet: notYet})
}
}
return writes, events
}
// notYetOf are the merges at or before a repository's reached head that are not yet on the machines: the changes of each module
// a node wants with no floor (a node runs nothing countable of it), sorted. A consumer takes every other merge at or before
// the reached head as on the machines.
func notYetOf(r delivery.Reach) []string {
var out []string
for _, mr := range r.Modules {
if mr.Nodes == 0 || mr.Floor != "" {
continue
}
for _, c := range mr.Changes {
out = append(out, c.Commit)
}
}
slices.Sort(out)
return slices.Compact(out)
}
// Reach is the reach of every repository as the view derived it last, by key.
func (m *Model) Reach() map[string]delivery.Reach {
m.mu.Lock()
defer m.mu.Unlock()
return maps.Clone(m.reach)
}