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. A value it answers an error for is // written again at the next change the view takes in (sendReach). func (m *Model) SetReachWriter(put func(ReachWrite) error) { 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) }