348 lines
12 KiB
Go
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)
|
|
}
|