// Package view derives the operator's rows from the delivery buckets. package view import ( "encoding/json" "fmt" "slices" "sort" "strings" "sync" "time" "git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/delivery" ) // Entry is one change of a bucket's key, as the watch delivers it. type Entry struct { Bucket string Key string Value []byte At time.Time Deleted bool } // Row is one module on one node. type Row struct { Node string `json:"node"` Module string `json:"module"` Status string `json:"status"` Assigned string `json:"assigned,omitempty"` Have string `json:"have,omitempty"` Phase string `json:"phase,omitempty"` Health string `json:"health,omitempty"` Since time.Time `json:"since,omitzero"` Detail string `json:"detail,omitempty"` Waits string `json:"waits,omitempty"` // RequiredBy is what pulled the module onto the node, for an assignment that is pulled in. RequiredBy string `json:"required-by,omitempty"` Error string `json:"error,omitempty"` } // NodeRow is a node's own facts as counts. type NodeRow struct { Node string `json:"node"` Windows int `json:"windows"` Busy int `json:"busy"` Strays int `json:"strays"` Since time.Time `json:"since,omitzero"` Error string `json:"error,omitempty"` } // Snapshot is everything the view shows. type Snapshot struct { Rows []Row `json:"rows"` Nodes []NodeRow `json:"nodes"` // Offline is every node and part instance whose presence is offline (the watchman's word). Offline []OfflineRow `json:"offline"` // Stuck is every key that stopped moving and has not moved again. Stuck []StuckRow `json:"stuck"` // Unreadable is every watchman value the view could not read, named by its key: never dropped silently. Unreadable []UnreadableRow `json:"unreadable"` // Groups is every candidate group the indexer holds: the pull requests that share a branch, with the modules each touches. Groups []GroupRow `json:"groups"` // Fingerprints is every module the indexer fingerprinted at a trunk merge, beside its newest successful trunk build's. Fingerprints []FingerprintRow `json:"fingerprints"` // Asks is every ask the gate holds that has no answer yet: what waits for the operator's Approve. Asks []AskRow `json:"asks"` // Differences is every difference between what the assigner wants a node to run and what the controller sends, with the rule that // explains it or unexplained (novox/hq ADR 0528 clause 4, issue 1297 B8). Differences []DifferenceRow `json:"differences"` // Backlog is, for each language and kind of build ask with one waiting or held, the asks waiting for a node, the asks a node // holds and the oldest waiting ask's age (issue 1887 A8a). Backlog []BacklogRow `json:"backlog"` } // DifferenceRow is a module on a node whose want and send differ: the fingerprint wanted (empty when the node has no want, // WantRemoved when the want is removed), the fingerprint of the build the controller's assignment names (empty when it sends // none; SentBuild names that build), and the rule that explains it (Rule 1 to 6) or Unexplained. type DifferenceRow struct { Node string `json:"node"` Module string `json:"module"` Want string `json:"want,omitempty"` WantRemoved bool `json:"want-removed,omitempty"` Sent string `json:"sent,omitempty"` SentBuild string `json:"sent-build,omitempty"` Rule int `json:"rule,omitempty"` Unexplained bool `json:"unexplained,omitempty"` Words string `json:"words,omitempty"` } // AskRow is an open ask (novox/hq ADR 0437 §10): the gate's ask with no answer under its key in the teller's answers. type AskRow struct { Key string `json:"key"` Group string `json:"group"` Headline string `json:"headline"` Words string `json:"words"` Pulls []string `json:"pulls"` Expires time.Time `json:"expires,omitzero"` How string `json:"how"` // Options is what the view offers to answer with, for an ask that needs no proof of who answered (the assigner's hold and // restore, ADR 0528 clause 6); Chosen is the option the view wrote that the teller has not filled yet. Options []string `json:"options,omitempty"` Chosen string `json:"chosen,omitempty"` } // AnswerOnThePhone is what an open ask says about answering it: the view only shows, and the Approve is answered on the // operator's phone (ADR 0437 §10: answering in the view is release 5's). const AnswerOnThePhone = "answer on the phone" // AnswerInTheView is what an open ask that needs no proof of who answered says (ADR 0528 clause 6): the operator answers it here. const AnswerInTheView = "answer in the view" // GroupRow is a candidate group (novox/hq ADR 0437 §5): the branch its pull requests share and the pull requests. type GroupRow struct { Group string `json:"group"` Pulls []GroupPull `json:"pulls"` } // GroupPull is one pull request of a group, its head shortened to 12 digits, and the modules it touches. type GroupPull struct { Repository string `json:"repository"` Number int `json:"number"` Head string `json:"head"` Modules []string `json:"modules"` } // The states of a module's fingerprint against its newest successful trunk build's. const ( FPSame = "same" FPDiffers = "differs" FPNone = "none" FPNoBuild = "no build" // The states of a fingerprint of the new form against the builder's key of it (I5, issue 1851): built (the key is done), // build failed (Reason is its error), building (queued, claimed or building; Reason says what it waits for) and no key. FPBuilt = "built" FPBuildFailed = "build failed" FPBuilding = "building" FPNoKey = "no key" ) // FingerprintRow compares the indexer's fingerprint of a module at a trunk merge with the source fingerprint its newest // successful trunk build recorded. State is same, differs (both are given), none (the indexer could not fingerprint it: // Reason says why) or no build (the indexer's alone is given, no successful trunk build recorded one). type FingerprintRow struct { Module string `json:"module"` Repository string `json:"repository,omitempty"` Commit string `json:"commit,omitempty"` State string `json:"state"` Indexer string `json:"indexer,omitempty"` Build string `json:"build,omitempty"` Reason string `json:"reason,omitempty"` } // OfflineRow is a node ("node") or a part's instance ("part") that is offline, since the presence entry's time. type OfflineRow struct { Kind string `json:"kind"` Node string `json:"node,omitempty"` Part string `json:"part,omitempty"` Instance string `json:"instance,omitempty"` Since time.Time `json:"since,omitzero"` } // StuckRow is a key that stopped moving: its bucket and key, what it waited for and since when. type StuckRow struct { Bucket string `json:"bucket"` Key string `json:"key"` Waited string `json:"waited,omitempty"` Since time.Time `json:"since,omitzero"` } // UnreadableRow is a value of the watchman's buckets that did not decode, with the reader's error. type UnreadableRow struct { Bucket string `json:"bucket"` Key string `json:"key"` Error string `json:"error"` } // Model holds the rows' inputs in memory and nothing else: a restart fills it again from the buckets' last values. type Model struct { mu sync.Mutex assigns map[string]assignment builds map[string]buildEntry // by .build. groups map[string]groupEntry // the indexer's candidates, by group modules map[string]moduleEntry // the indexer's modules, by module keys map[string]keyEntry // the builder's build keys, by . asks map[string]askRecord // the gate's asks, by key diffs map[string]diffEntry // the assigner's differences, by . aasks map[string]assignerAskRecord // the assigner's asks (hold, restore), by key chosen map[string]delivery.ViewAnswer // the answers the view wrote, by ask key writer Writer backlog *Backlog answers map[string]string // by key: the router id of the asking the teller's answer is for ("" when unreadable) newest map[string]string // module -> id haves map[string]haveEntry nodes map[string]nodeEntry // the watchman's buckets, by key presence map[string]presenceEntry stuck map[string]stuckEntry } type presenceEntry struct { value delivery.Presence err string at time.Time } type stuckEntry struct { value delivery.Stuck err string } // assignment holds only the fields of the controller's Assignment the view reads. type assignment struct { Node string `json:"node"` Module string `json:"module"` Build string `json:"build"` Commit string `json:"commit"` Since time.Time `json:"since"` Ended time.Time `json:"ended"` State string `json:"state"` PulledIn bool `json:"pulled-in"` RequiredBy string `json:"required-by"` } // open says whether the value is an assignment that stands for the module on its node, as the controller's // Assignment.Stands does. A module pulled in by another stands too. func (a assignment) open() bool { return a.Ended.IsZero() && a.State == "" } // build holds only the fields of the controller's Build record the view reads. A record with no state and no failure // is a done build. type build struct { ID string `json:"id"` Module string `json:"module"` State string `json:"state"` Commit string `json:"commit"` Failed string `json:"failed"` } func (b build) done() bool { return b.State == "" && b.Failed == "" } // buildEntry is a build as the view reads it: its own fields (state, failure) and the SDK's record (the source // fingerprint, the ref, the time), decoded as the controller writes them. type buildEntry struct { build rec delivery.BuildRecord } type groupEntry struct { value delivery.Candidate err string } type keyEntry struct { value delivery.BuilderBuild err string } type diffEntry struct { value delivery.Difference err string } type assignerAskRecord struct { ask asks.Ask err string } type moduleEntry struct { value delivery.Module err string } type askRecord struct { value delivery.Ask err string } type haveEntry struct { have delivery.Have err string at time.Time } type nodeEntry struct { node delivery.HaveNode err string at time.Time } // New is an empty model. func New() *Model { return &Model{assigns: map[string]assignment{}, builds: map[string]buildEntry{}, groups: map[string]groupEntry{}, modules: map[string]moduleEntry{}, keys: map[string]keyEntry{}, asks: map[string]askRecord{}, diffs: map[string]diffEntry{}, aasks: map[string]assignerAskRecord{}, chosen: map[string]delivery.ViewAnswer{}, answers: map[string]string{}, newest: map[string]string{}, haves: map[string]haveEntry{}, nodes: map[string]nodeEntry{}, presence: map[string]presenceEntry{}, stuck: map[string]stuckEntry{}} } // Apply takes one change of a key. A value it cannot read is kept as an error on its row, never guessed. func (m *Model) Apply(e Entry) { m.mu.Lock() defer m.mu.Unlock() switch e.Bucket { case delivery.HaveBucket: node, module, isNode, err := delivery.ParseHaveKey(e.Key) if err != nil { return } if isNode { if e.Deleted { delete(m.nodes, node) return } n, err := delivery.DecodeHaveNode(e.Value) m.nodes[node] = nodeEntry{node: n, err: errText(err), at: e.At} return } key := node + "." + module if e.Deleted { delete(m.haves, key) return } h, err := delivery.DecodeHave(e.Value) m.haves[key] = haveEntry{have: h, err: errText(err), at: e.At} case delivery.PresenceBucket: if e.Deleted { delete(m.presence, e.Key) return } p, err := delivery.DecodePresence(e.Value) m.presence[e.Key] = presenceEntry{value: p, err: errText(err), at: e.At} case delivery.StuckBucket: if e.Deleted { delete(m.stuck, e.Key) return } st, err := delivery.DecodeStuck(e.Value) m.stuck[e.Key] = stuckEntry{value: st, err: errText(err)} case AssignmentsBucket: if e.Deleted { delete(m.assigns, e.Key) return } var a assignment if json.Unmarshal(e.Value, &a) != nil || a.Node == "" || a.Module == "" { return } m.assigns[a.Node+"."+a.Module] = a case delivery.CandidatesBucket: if e.Deleted { delete(m.groups, e.Key) return } c, err := delivery.DecodeCandidate(e.Value) m.groups[e.Key] = groupEntry{value: c, err: errText(err)} case delivery.ModulesBucket: if e.Deleted { delete(m.modules, e.Key) return } mod, err := delivery.DecodeModule(e.Value) m.modules[e.Key] = moduleEntry{value: mod, err: errText(err)} case delivery.AsksBucket: if e.Deleted { delete(m.asks, e.Key) return } a, err := delivery.DecodeAsk(e.Value) m.asks[e.Key] = askRecord{value: a, err: errText(err)} case delivery.AssignerAsksBucket: if e.Deleted { delete(m.aasks, e.Key) return } a, err := decodeAssignerAsk(e.Key, e.Value) m.aasks[e.Key] = assignerAskRecord{ask: a, err: errText(err)} case delivery.ViewAnswersBucket: askKey, err := delivery.ParseViewAnswerKey(e.Key) if err != nil { return } if e.Deleted { delete(m.chosen, askKey) return } if a, err := delivery.DecodeViewAnswer(e.Value); err == nil { m.chosen[askKey] = a } case delivery.AssignerDifferencesBucket: if e.Deleted { delete(m.diffs, e.Key) return } d, err := delivery.DecodeDifference(e.Value) m.diffs[e.Key] = diffEntry{value: d, err: errText(err)} case delivery.AnswersBucket: if e.Deleted { delete(m.answers, e.Key) return } // The answer's warrant is `@