Files
mesh-delivery/modules/mesh-delivery-checker/internal/check/builds.go
T
jschoubben 4735fc0b5f
mesh/delivery merged: source.main moved to novox/mesh-delivery@8ad2bf6951d5
mesh/delivery-group group feat/registry-milestone merged: every member is merged
mesh/check checking 4735fc0b5fdb on 8ad2bf6951d5 | novox/mesh-delivery#151 in group feat/registry-milestone
Merge main (#164, a check composes only after the change's own builds end) into the registry milestone
Conflicts in the checker's judge.go, builds.go, composition_test.go and try_candidates.go:
the group's contract types and kept composition path stay, #164's behaviour is taken whole.
WaitsOnPreview reads the contracts' meshcatalogclient.NoneNoPreview instead of a copied
constant; awaitBuilds names its failures failedBuilds, beside the builds package.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
2026-11-07 14:36:42 +01:00

170 lines
6.9 KiB
Go

package check
import (
"context"
"fmt"
"maps"
"slices"
"sort"
"strings"
"time"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
)
// The states of a build the status line says (ADR 0484 clause 7).
const (
BuildBuilt = "built"
BuildFailed = "failed"
BuildWaiting = "waiting"
)
// BuildStates reads the builder's build of a module at a fingerprint of the new form (builds.<module>.<fingerprint>): the
// value and whether the key exists.
type BuildStates interface {
Build(ctx context.Context, module, fp2 string) (builds.BuilderBuild, bool, error)
}
// NoteOf is the state of a build as the line says it: done is built, failed is failed, and one queued, building or not
// begun is waiting.
func NoteOf(b builds.BuilderBuild, found bool) string {
switch {
case !found:
return BuildWaiting
case b.State == builds.BuildDone:
return BuildBuilt
case b.State == builds.BuildFailed:
return BuildFailed
}
return BuildWaiting
}
// buildNotes are the state of each build the case's modules carry a fingerprint of, by module, sorted. A module two pull
// requests give is said once.
func (j *Examiner) buildNotes(ctx context.Context, c *Case) []BuildNote {
if j.Builds == nil {
return nil
}
fps := map[string]string{}
for _, p := range c.Pulls {
for module, cm := range p.Fps {
if cm.Fp2 != "" {
fps[module] = cm.Fp2
}
}
}
modules := make([]string, 0, len(fps))
for m := range fps {
modules = append(modules, m)
}
sort.Strings(modules)
var notes []BuildNote
for _, m := range modules {
b, found, err := j.Builds.Build(ctx, m, fps[m])
if err != nil {
j.logf("the build of %s at %s cannot be read: %v", m, fps[m], err)
found = false
}
notes = append(notes, BuildNote{Module: m, State: NoteOf(b, found)})
}
return notes
}
// DefaultBuildWait is how long a check waits for the candidate's builds to end before it composes (the setting
// build-wait-bound, novox/hq ADR 0603 §10): past it the check composes without the builds not ended and names them.
const DefaultBuildWait = 30 * time.Minute
// BuildEnds waits on the builder's build keys (builds.<module>.<fingerprint>), hearing each key's changes on the bus as the
// builder writes them: the builder owns the fact, and nothing is polled.
type BuildEnds interface {
// Ended waits until the build of every module at its fingerprint (module to fingerprint) is done or failed, or ctx ends,
// and answers the builds that ended, by module. An error is only builds that cannot be heard at all.
Ended(ctx context.Context, builds map[string]string) (map[string]builds.BuilderBuild, error)
// Done waits until the build of every module at its fingerprint is done (a failed one built again, builder_build_again),
// and says whether they all are; false when ctx ends first or they cannot be heard.
Done(ctx context.Context, builds map[string]string) bool
}
// BuildFailedTest is what a failure of the check names a failed build of the candidate by: the test `build: <module>`.
// A verdict failed on builds is asked again by the checker itself once each is built again (Checker.Rebuilt).
const BuildFailedTest = "build: "
// failedBuilds are the candidate's builds a failed verdict names as failed (BuildFailedTest), by module, at the fingerprint
// the case carries for it; none for a verdict that names no failed build.
func failedBuilds(v meshdeliverychecker.VerdictsByGroupHeadsValue, cs *Case) map[string]string {
if v.State != meshdeliverycheckerclient.VerdictFail {
return nil
}
out := map[string]string{}
for _, f := range append([]FailedFirst{failedOf(v)}, failedList(v.Failures)...) {
module, named := strings.CutPrefix(f.Test, BuildFailedTest)
if !named {
continue
}
for _, p := range cs.Pulls {
if cm, ok := p.Fps[module]; ok && cm.Fp2 != "" {
out[module] = cm.Fp2
}
}
}
if len(out) == 0 {
return nil
}
return out
}
func (j *Examiner) buildWait() time.Duration {
if j.BuildWait > 0 {
return j.BuildWait
}
return DefaultBuildWait
}
// awaitBuilds waits for the candidate's builds before the check composes (ADR 0603 §10): the check builds what the builder
// builds of the candidate and composes with it. A failed build fails the check, naming the module and the builder's words;
// a build not ended within the bound is named in late, and the check does not compose: an engine composes a module only at
// a done build, so its answer would be the builder's lateness read as the change's refusal (issue 1921). It answers the
// words of the composition's warnings (what the check composes without waiting on its build, and why) and the failures of
// the builds that failed, for the rules to take in (rules.JudgeComposition); failed says the check has failed already.
func (st *state) awaitBuilds(ctx context.Context, failed bool) (words []string, failedBuilds []FailedFirst, late string) {
wait, words := st.buildsOfTheCheck(ctx)
if len(wait) == 0 {
return words, nil, ""
}
modules := slices.Sorted(maps.Keys(wait))
if failed {
// The check has failed already: its red status is never held back by a build (the lead, 2026-11-03).
return append(words, fmt.Sprintf("the check failed before its builds were waited on, so it composes at once, without waiting on the builds of %s (ADR 0603 §10)", strings.Join(modules, ", "))), nil, ""
}
if st.j.BuildEnds == nil {
return append(words, fmt.Sprintf("the builds of %s cannot be heard, so the check composes without waiting on them (ADR 0603 §10)", strings.Join(modules, ", "))), nil, ""
}
bound := st.j.buildWait()
waits := fmt.Sprintf("waits on the builder's builds of %s before it composes, at most %s (ADR 0603 §10)", strings.Join(modules, ", "), bound)
st.c.note(waits)
st.j.logf("%s %s", st.c.Key, waits)
at, cancel := context.WithTimeout(ctx, bound)
ended, err := st.j.BuildEnds.Ended(at, wait)
cancel()
if err != nil {
st.j.logf("%s: the builds of %s cannot be heard: %v", st.c.Key, strings.Join(modules, ", "), err)
return append(words, fmt.Sprintf("the builds of %s cannot be heard (%v), so the check composes without waiting on them (ADR 0603 §10)", strings.Join(modules, ", "), err)), nil, ""
}
var notEnded []string
for _, module := range modules {
b, done := ended[module]
switch {
case !done:
notEnded = append(notEnded, module)
case b.State == builds.BuildFailed:
failedBuilds = append(failedBuilds, FailedFirst{Test: BuildFailedTest + module, Words: fmt.Sprintf("the candidate's build at %s failed (rebuild with builder_build_again; the check asks itself again when the build is done): %s", wait[module], b.Error)})
}
}
if len(notEnded) > 0 && ctx.Err() == nil {
late = fmt.Sprintf("the builder's builds of %s did not end within the build-wait-bound of %s, so the change is not composed: an engine composes a module only at a done build (ADR 0603 §10)", strings.Join(notEnded, ", "), bound)
}
return words, failedBuilds, late
}