Files
mesh-delivery/modules/mesh-delivery-checker/internal/check/read.go
T
jschoubben 16594feff1 checker_ask_again: refuse while an ask of the check waits in its build seat's stream
Review of fix/lost-check: an ask no node took yet has no step key, so
asking again then would run the check twice (issue 1791's kind). The
checker keeps the asks each running check put in a new state asks, and the
tool refuses unless every one of them has a step key. Asking again under the
same ask id is not done: the stream drops a second put of an id within its
de-duplication window and keeps both past it, so the waiting copy is never
replaced.

Tests: the hand row of the lost-ask table refuses while its ask waits;
counts unchanged.
2026-10-28 20:36:25 +01:00

656 lines
24 KiB
Go

package check
import (
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/delivery/buildrun"
)
// The read tools (novox/hq ADR 0484 clauses 8 and 13, issue 1686): what the checker stored, as stored, with the line of the
// status it owes in full, so a check is read live without a shell. They write nothing.
// The read tools, named as the module is: checker_ and the noun.
const (
VerdictsReadTool = "checker_verdicts"
PullReadTool = "checker_pull"
KeptReadTool = "checker_kept"
// PreviewsReadTool reads the previews state (issue 1680); StatusReadTool reads what the checker runs (issue 1685: nothing holds it since R4g).
PreviewsReadTool = "checker_previews"
StatusReadTool = "checker_status"
)
// StateRead reads one state: its keys and a key's stored value (nil for a key with none).
type StateRead struct {
Keys func() ([]string, error)
Get func(key string) (json.RawMessage, error)
}
// Reader answers the read tools from the checker's verdicts and kept states and the indexer's candidates.
type Reader struct {
VerdictState, KeptState, CandidateState StateRead
PreviewState StateRead // the checker's merge previews
StepStates map[string]StateRead // the build modules' steps states, by module
HaveState StateRead // the node-engine's have: whether a build module is on a node and running
StepBound time.Duration // how long the checker waits for one step; DefaultStepBound when 0
// Trunks are the trunk heads of every repository (the gate's source); nil names no re-judge after a trunk move.
Trunks func() (map[string]string, error)
// TurnState is the gate's merge turns (ADR 0548 §3); without it no check is named as held by a turn.
TurnState StateRead
// Runs says which commit the mesh runs of a module (the indexer's modules), as the checker's decision reads it: the
// node-engine a verdict names is compared at its running commit, not at the trunk's (ADR 0548 §6); nil knows none.
Runs interface{ CommitOf(module string) string }
// HearingState is the checker's state hearing: whether it hears DEAD_LETTERS (issue 734).
HearingState StateRead
// AskState is the checker's state asks: the asks each running check put (AsksState), which checker_ask_again reads.
AskState StateRead
Now func() time.Time
}
// VerdictRead is a verdict as stored with the full report of the status of each pull request it judged (Report), when the
// candidate still holds those heads; without it the report is that of the verdict alone.
type VerdictRead struct {
Key string `json:"key"`
Verdict json.RawMessage `json:"verdict"`
Reports map[string]string `json:"reports,omitempty"`
Note string `json:"note,omitempty"`
}
func (r Reader) candidate(group string) (delivery.Candidate, bool) {
key, err := delivery.CandidatesKey(group)
if err != nil || r.CandidateState.Get == nil {
return delivery.Candidate{}, false
}
raw, err := r.CandidateState.Get(key)
if err != nil || len(raw) == 0 {
return delivery.Candidate{}, false
}
c, err := delivery.DecodeCandidate(raw)
return c, err == nil
}
func (r Reader) read(key string, raw json.RawMessage) (VerdictRead, error) {
out := VerdictRead{Key: key, Verdict: raw}
v, err := delivery.DecodeVerdict(raw)
if err != nil {
return out, fmt.Errorf("%s holds what is not a verdict: %w", key, err)
}
c, ok := r.candidate(v.Group)
if !ok || (c.HeadsHash != v.Heads && GateHeads(c) != v.Heads) {
out.Note = "the candidate no longer holds these heads"
return out, nil
}
cs := CaseOf(c, v.Trunk)
out.Reports = map[string]string{}
if cs == nil {
if IsUntrunked(v) {
for _, p := range leftOf(c, v.Trunk) {
out.Reports[pullKey(p)] = v.Failed.Words
}
}
return out, nil
}
for _, p := range cs.Pulls {
out.Reports[pullKey(p)] = Report(v, cs, p, nil, nil, nil)
}
for _, l := range cs.Left {
out.Reports[strings.SplitN(l, ": ", 2)[0]] = strings.SplitN(l, ": ", 2)[1]
}
return out, nil
}
// Verdicts are the verdicts the checker holds, by key; with a group only that group's. The keys are narrowed by the group
// before any value is read, at most MaxReads are read (sixteen at a time), and cut says when it stopped there.
func (r Reader) Verdicts(group string) (out []VerdictRead, cut string, err error) {
if r.VerdictState.Keys == nil || r.VerdictState.Get == nil {
return nil, "", fmt.Errorf("the verdicts state cannot be read here")
}
keys, err := r.VerdictState.Keys()
if err != nil {
return nil, "", err
}
sort.Strings(keys)
var matching []string
for _, k := range keys {
if group != "" {
if g, _, err := delivery.ParseCheckerVerdictKey(k); err != nil || g != group {
continue
}
}
matching = append(matching, k)
}
matching, cut = capKeys(matching, "give a group to narrow the answer")
rows := make([]VerdictRead, len(matching))
found := make([]bool, len(matching))
if err := parallel(len(matching), func(i int) error {
raw, err := r.VerdictState.Get(matching[i])
if err != nil || len(raw) == 0 {
return err
}
rows[i], err = r.read(matching[i], raw)
found[i] = err == nil
return err
}); err != nil {
return nil, "", err
}
for i, row := range rows {
if found[i] {
out = append(out, row)
}
}
return out, cut, nil
}
// Pull answers for one pull request: the verdict of the candidate that holds it, with its report; for one the indexer holds
// in no candidate, that none does.
func (r Reader) Pull(repository string, number int) (VerdictRead, error) {
if r.CandidateState.Keys == nil || r.CandidateState.Get == nil {
return VerdictRead{}, fmt.Errorf("the candidates state cannot be read here")
}
keys, err := r.CandidateState.Keys()
if err != nil {
return VerdictRead{}, err
}
sort.Strings(keys)
keys, cut := capKeys(keys, "")
// The candidates are read in batches, each in parallel and taken in key order, so the read stops at the batch that holds it.
for from := 0; from < len(keys); from += ReadWorkers * 4 {
batch := keys[from:min(from+ReadWorkers*4, len(keys))]
cands := make([]delivery.Candidate, len(batch))
ok := make([]bool, len(batch))
_ = parallel(len(batch), func(i int) error {
raw, err := r.CandidateState.Get(batch[i])
if err != nil || len(raw) == 0 {
return nil
}
c, err := delivery.DecodeCandidate(raw)
cands[i], ok[i] = c, err == nil
return nil
})
for i, c := range cands {
if !ok[i] {
continue
}
for _, p := range c.Pulls {
if p.Repository != repository || p.Number != number {
continue
}
vkey, err := delivery.CheckerVerdictKey(c.Group, c.HeadsHash)
if err != nil {
return VerdictRead{}, err
}
vraw, err := r.VerdictState.Get(vkey)
if err != nil {
return VerdictRead{}, err
}
if len(vraw) == 0 {
return VerdictRead{Key: vkey, Note: "no verdict yet for the heads of its candidate"}, nil
}
return r.read(vkey, vraw)
}
}
}
if cut != "" {
return VerdictRead{}, fmt.Errorf("no candidate among the first %d holds %s#%d", MaxReads, repository, number)
}
return VerdictRead{}, fmt.Errorf("no candidate holds %s#%d", repository, number)
}
// Kept is the kept verdicts: one key's value as stored, or with an empty key the keys of a level (or every level) and their values.
func (r Reader) Kept(level, digest string) (map[string]json.RawMessage, string, error) {
if r.KeptState.Keys == nil || r.KeptState.Get == nil {
return nil, "", fmt.Errorf("the kept state cannot be read here")
}
if digest != "" {
key, err := delivery.KeptKey(level, digest)
if err != nil {
return nil, "", err
}
raw, err := r.KeptState.Get(key)
if err != nil {
return nil, "", err
}
if len(raw) == 0 {
return nil, "", fmt.Errorf("no kept verdict %s", key)
}
return map[string]json.RawMessage{key: raw}, "", nil
}
keys, err := r.KeptState.Keys()
if err != nil {
return nil, "", err
}
sort.Strings(keys)
var matching []string
for _, k := range keys {
if l, _, err := delivery.ParseKeptKey(k); err == nil && (level == "" || l == level) {
matching = append(matching, k)
}
}
matching, cut := capKeys(matching, "give a level and a digest to read one")
values := make([]json.RawMessage, len(matching))
if err := parallel(len(matching), func(i int) (err error) {
values[i], err = r.KeptState.Get(matching[i])
return err
}); err != nil {
return nil, "", err
}
out := map[string]json.RawMessage{}
for i, k := range matching {
if len(values[i]) > 0 {
out[k] = values[i]
}
}
return out, cut, nil
}
// PreviewRead is one preview as stored, with the parts of its key.
type PreviewRead struct {
Key string `json:"key"`
Repository string `json:"repository"`
Head string `json:"head"`
Trunk string `json:"trunk"`
Preview json.RawMessage `json:"preview"`
}
// Previews are the previews the checker holds, each as stored. With a repository only that repository's; with a head (a
// prefix of at least 7 characters is enough) only previews of that head. A key that is no preview key is listed as unreadable.
func (r Reader) Previews(repository, head string) ([]PreviewRead, []string, string, error) {
if r.PreviewState.Keys == nil || r.PreviewState.Get == nil {
return nil, nil, "", fmt.Errorf("the previews state cannot be read here")
}
keys, err := r.PreviewState.Keys()
if err != nil {
return nil, nil, "", err
}
sort.Strings(keys)
type part struct{ repo, head, trunk string }
parts := map[string]part{}
var matching, bad []string
for _, k := range keys {
repo, h, trunk, err := delivery.ParsePreviewKey(k)
if err != nil {
bad = append(bad, k)
continue
}
if (repository != "" && repo != repository) || (head != "" && !strings.HasPrefix(h, head)) {
continue
}
parts[k] = part{repo, h, trunk}
matching = append(matching, k)
}
matching, cut := capKeys(matching, "give a repository and a head to narrow the answer")
values := make([]json.RawMessage, len(matching))
if err := parallel(len(matching), func(i int) (err error) {
values[i], err = r.PreviewState.Get(matching[i])
return err
}); err != nil {
return nil, nil, "", err
}
out := []PreviewRead{}
for i, k := range matching {
if len(values[i]) > 0 {
p := parts[k]
out = append(out, PreviewRead{Key: k, Repository: p.repo, Head: p.head, Trunk: p.trunk, Preview: values[i]})
}
}
return out, bad, cut, nil
}
// RunningStep is a step in flight on a build module.
type RunningStep struct {
Module string `json:"module"`
Node string `json:"node"`
ID string `json:"id"`
Tool string `json:"tool"`
Since string `json:"since,omitempty"`
// What says what the step is (buildstep.AskWhat): an ask's kind, repository@head and levels.
What string `json:"what,omitempty"`
}
// LeftStep is a key a build module left running that no step behind it runs any more, and why.
type LeftStep struct {
RunningStep
Why string `json:"why"`
}
// Status is what runs now, read from the buckets: the steps in flight on the build modules and the candidates without a
// verdict for their heads. Nothing holds a check since R4g: a check is an ask on the build seats' streams, whose backlog
// the delivery view shows (ADR 0525 §9).
type Status struct {
Running []RunningStep `json:"running_steps"`
LeftRunning []LeftStep `json:"left_running"`
RunningByNode map[string]int `json:"running_by_node"`
Unjudged []string `json:"unjudged_candidates"`
// Waiting are the candidates whose check waits, by kind (ADR 0536 §4): new (no verdict for its heads) and nodes (failed
// for the nodes, asked again when they change) start before trunk (judged on trunk heads that moved since).
Waiting map[string][]string `json:"waiting_by_kind"`
// HeldByTurn are the candidates whose check an approved merge's turn holds off (ADR 0548 §3), by group: the repository and
// the group of the merge that waits. Their check is claimed when that turn goes.
HeldByTurn map[string]string `json:"held_by_turn"`
// CannotBeJudged are the candidates no pull request of which can be judged, by group, with why: the gate's source.main
// names no trunk head of their repositories (issue 1422). They are judged once it does; until then their status is error.
CannotBeJudged map[string]string `json:"cannot_be_judged"`
// Lost are the candidates whose verdict is running while no step of theirs can still run and their newest step ended, by
// group, with that step: nothing will judge them (the lost check of 2026-10-28); checker_ask_again asks one again.
Lost map[string]string `json:"lost"`
// DeadLetters says whether the checker hears the asks the build seats give up on (issue 734): deaf, a try or check
// whose ask is given up waits until its bound.
DeadLetters DeadLettersHeard `json:"dead_letters"`
Notes []string `json:"notes,omitempty"`
}
// DeadLettersHeard is the checker's hearing of DEAD_LETTERS as the status says it: healthy or not, and why.
type DeadLettersHeard struct {
Heard bool `json:"heard"`
Why string `json:"why"`
}
// deadLetters reads the checker's kept hearing (buildrun.HearingHealth).
func (r Reader) deadLetters() DeadLettersHeard {
if r.HearingState.Get == nil {
return DeadLettersHeard{Why: "the checker's state hearing is not readable here"}
}
raw, err := r.HearingState.Get(buildrun.HearingKey)
if err != nil {
return DeadLettersHeard{Why: "the checker's state hearing cannot be read: " + err.Error()}
}
a := buildrun.HearingHealth(raw)
return DeadLettersHeard{Heard: a.Healthy, Why: a.Why}
}
// turns reads the gate's merge turns, by repository; nil when they cannot be read, said in a note.
func (r Reader) turns(st *Status) *Turns {
if r.TurnState.Keys == nil || r.TurnState.Get == nil {
st.Notes = append(st.Notes, "the gate's merge turns are not readable here, so no check is named as held by one")
return nil
}
keys, err := r.TurnState.Keys()
if err != nil {
st.Notes = append(st.Notes, "the gate's merge turns cannot be listed, so no check is named as held by one: "+err.Error())
return nil
}
t := &Turns{}
for _, k := range keys {
if raw, err := r.TurnState.Get(k); err == nil && len(raw) > 0 {
t.Put(k, raw, false)
}
}
return t
}
// heldBy says the turn that holds a candidate's check off, or "".
func heldBy(t *Turns, c delivery.Candidate, heads map[string]string) string {
cs := CaseOf(c, heads)
if t == nil || cs == nil {
return ""
}
repos := make([]string, 0, len(cs.Trunk))
for r := range cs.Trunk {
repos = append(repos, r)
}
if repo, w, ok := t.Holding(repos, cs.Heads); ok {
return repo + ": the approved merge of " + w.Group + " waits on it"
}
return ""
}
// Status reads the build modules' steps and the candidates against their verdicts.
func (r Reader) Status() (Status, error) {
st := Status{RunningByNode: map[string]int{}, Lost: map[string]string{}}
read := r.readSteps()
st.Running, st.LeftRunning, st.Notes = read.running, read.left, read.notes
for _, rs := range st.Running {
st.RunningByNode[rs.Node]++
}
if r.HaveState.Get == nil {
st.Notes = append(st.Notes, "the node-engine's have is not readable here, so a step is not checked against its build module being on its node")
}
if len(st.LeftRunning) > 0 {
st.Notes = append(st.Notes, "left_running are keys a build module left running (it stopped, restarted or was unassigned before it wrote the step's end): the checker's wait for each ended at the step bound, so none holds a place; the build module never ends them")
}
st.Unjudged = []string{}
st.Waiting = map[string][]string{KindNew: {}, KindNodes: {}, KindTrunk: {}}
st.HeldByTurn, st.CannotBeJudged = map[string]string{}, map[string]string{}
turns := r.turns(&st)
var heads map[string]string
if r.Trunks == nil {
st.Notes = append(st.Notes, "the trunk heads are not readable here, so no check is named as waiting for a trunk move")
} else if h, err := r.Trunks(); err != nil {
st.Notes = append(st.Notes, "the trunk heads cannot be read, so no check is named as waiting for a trunk move: "+err.Error())
} else {
heads = h
}
if r.CandidateState.Keys != nil && r.CandidateState.Get != nil && r.VerdictState.Get != nil {
keys, err := r.CandidateState.Keys()
if err != nil {
return st, err
}
sort.Strings(keys)
total := len(keys)
keys, cut := capKeys(keys, "")
if cut != "" {
st.Notes = append(st.Notes, fmt.Sprintf("read the first %d candidates of %d", MaxReads, total))
}
type judged struct {
c delivery.Candidate
raw json.RawMessage
ok bool
}
rows := make([]judged, len(keys))
_ = parallel(len(keys), func(i int) error {
raw, err := r.CandidateState.Get(keys[i])
if err != nil || len(raw) == 0 {
return nil
}
c, err := delivery.DecodeCandidate(raw)
if err != nil {
return nil
}
vkey, err := delivery.CheckerVerdictKey(c.Group, c.HeadsHash)
if err != nil {
return nil
}
rows[i].c = c
if vraw, err := r.VerdictState.Get(vkey); err == nil {
rows[i].raw, rows[i].ok = vraw, true
}
return nil
})
for _, row := range rows {
if !row.ok {
continue
}
c, raw := row.c, row.raw
if heads != nil && CaseOf(c, heads) == nil {
// Nothing of it can be judged now, whatever its verdict says (issue 1422): never counted as waiting.
var repos []string
for _, p := range leftOf(c, heads) {
repos = append(repos, p.Repository)
}
st.CannotBeJudged[c.Group] = UntrunkedWordsOf(repos)
continue
}
if len(raw) == 0 {
st.Unjudged = append(st.Unjudged, c.Group)
st.Waiting[KindNew] = append(st.Waiting[KindNew], c.Group)
if why := heldBy(turns, c, heads); why != "" {
st.HeldByTurn[c.Group] = why
}
continue
}
v, err := delivery.DecodeVerdict(raw)
switch {
case err != nil:
case v.State == delivery.VerdictRunning:
if why := read.lostWhy(c); why != "" {
st.Lost[c.Group] = why
}
case IsNodesFailure(v):
st.Waiting[KindNodes] = append(st.Waiting[KindNodes], c.Group)
case IsUntrunked(v):
// Its trunk head is known now: it waits for its whole check.
st.Waiting[KindNew] = append(st.Waiting[KindNew], c.Group)
case heads != nil:
if cs := CaseOf(c, heads); cs != nil && (!sameTrunk(v.Trunk, cs.Trunk) || besideMoved(v.Beside, besideAt(heads, r.Runs))) {
st.Waiting[KindTrunk] = append(st.Waiting[KindTrunk], c.Group)
if why := heldBy(turns, c, heads); why != "" {
st.HeldByTurn[c.Group] = why
}
}
}
}
}
st.DeadLetters = r.deadLetters()
st.Notes = append(st.Notes, "nothing holds the checker and it holds no place: running_steps are the build modules' keys that can still be running (started within the step bound, their build module running on the node); a candidate without a verdict has its check ask in a build seat's stream, waiting for a node with a free slot, whose backlog the delivery view shows (ADR 0525 §4, §9)")
return st, nil
}
// stepsRead is what the build modules' steps states hold: the steps that can still run, those left running, and every
// ask step read, by module, for the candidate it belongs to (stepsOf).
type stepsRead struct {
running []RunningStep
left []LeftStep
all []moduleStep
notes []string
}
type moduleStep struct {
module string
step delivery.Step
}
// readSteps reads the newest MaxReads keys of each build module's steps state.
func (r Reader) readSteps() stepsRead {
out := stepsRead{running: []RunningStep{}, left: []LeftStep{}}
mods := make([]string, 0, len(r.StepStates))
for m := range r.StepStates {
mods = append(mods, m)
}
sort.Strings(mods)
now, bound := time.Now(), r.StepBound
if r.Now != nil {
now = r.Now()
}
if bound <= 0 {
bound = DefaultStepBound
}
for _, m := range mods {
sr := r.StepStates[m]
if sr.Keys == nil || sr.Get == nil {
continue
}
keys, err := sr.Keys()
if err != nil {
out.notes = append(out.notes, "the steps of "+m+" cannot be listed: "+err.Error())
continue
}
// A step that runs started lately: the newest ids are read first, at most MaxReads of them.
newestFirst(keys)
keys, cut := capKeys(keys, "")
if cut != "" {
out.notes = append(out.notes, "the steps of "+m+": read the newest "+fmt.Sprint(MaxReads)+" of its keys; a step older than those is not shown")
}
steps := make([]delivery.Step, len(keys))
decoded := make([]bool, len(keys))
_ = parallel(len(keys), func(i int) error {
raw, err := sr.Get(keys[i])
if err != nil || len(raw) == 0 {
return nil
}
step, err := delivery.DecodeStep(raw)
steps[i], decoded[i] = step, err == nil
return nil
})
for i := range keys {
if !decoded[i] {
continue
}
step := steps[i]
out.all = append(out.all, moduleStep{module: m, step: step})
if step.State != delivery.StepRunning {
continue
}
rs := RunningStep{Module: m, Node: step.Node, ID: step.ID, Tool: step.Tool, What: step.What}
if !step.Started.IsZero() {
rs.Since = step.Started.UTC().Format("2006-01-02T15:04:05Z")
}
if why := r.leftWhy(m, step, now, bound); why != "" {
out.left = append(out.left, LeftStep{RunningStep: rs, Why: why})
continue
}
out.running = append(out.running, rs)
}
}
return out
}
// asksOf says whether a step is an ask of one of the candidate's pull requests: its What names `<repository>@<head12>`
// (buildstep.AskWhat).
func asksOf(c delivery.Candidate, what string) bool {
for _, p := range c.Pulls {
if p.Repository != "" && strings.Contains(what, p.Repository+"@"+short12(p.Head.Head)) {
return true
}
}
return false
}
// lostWhy says why a candidate whose verdict is running has a check that nothing will judge, or "": no step of its pull
// requests can still run, and its newest one ended.
func (s stepsRead) lostWhy(c delivery.Candidate) string {
for _, rs := range s.running {
if asksOf(c, rs.What) {
return ""
}
}
var last *delivery.Step
for i := range s.all {
st := s.all[i].step
if st.State != delivery.StepRunning && asksOf(c, st.What) && (last == nil || st.Ended.After(last.Ended)) {
last = &s.all[i].step
}
}
if last == nil {
return "" // its ask waits in its build seat's stream: no node took it yet
}
return fmt.Sprintf("its verdict is running, but no step of it runs: its last step %s on %s ended %s at %s and no verdict followed; %s asks it again",
last.ID, last.Node, last.State, last.Ended.UTC().Format(time.RFC3339), AskAgainTool)
}
// DefaultStepBound is how long one check ask's step may run once a build node took it (its key written running; the fetch,
// the digests and the levels), never counting its wait in the stream; past it the check fails naming the node.
const DefaultStepBound = 30 * time.Minute
// leftWhy says why a key in the running state runs no step any more, or "" when it can still be running: its build module
// is not on its node or is not running there (the node-engine's have), or it started longer ago than the step bound, when
// the checker stopped waiting for it.
func (r Reader) leftWhy(module string, step delivery.Step, now time.Time, bound time.Duration) string {
if r.HaveState.Get != nil {
key, err := delivery.HaveKey(step.Node, module)
if err == nil {
raw, err := r.HaveState.Get(key)
switch {
case err != nil:
case len(raw) == 0:
return fmt.Sprintf("%s is not on %s", module, step.Node)
default:
if h, err := delivery.DecodeHave(raw); err == nil && h.Phase != delivery.PhaseRunning {
return fmt.Sprintf("%s on %s is %s", module, step.Node, h.Phase)
}
}
}
}
if step.Started.IsZero() {
return fmt.Sprintf("it says no start, so no wait of the checker's (bound %s) can be behind it", bound)
}
if end := step.Started.Add(bound); now.After(end) {
return fmt.Sprintf("started %s, past the step bound of %s: the checker stopped waiting for it at %s", step.Started.UTC().Format(time.RFC3339), bound, end.UTC().Format(time.RFC3339))
}
return ""
}