mesh-delivery-checker: stop a check whose heads its group no longer holds and end it failed, superseded; judge a group's open members by the verdict it had once others merged, so their merge is not refused (issue 1921, issue 1791, try speed S9)

This commit is contained in:
2026-10-25 01:20:29 +02:00
parent 588cfd54bb
commit 9740842af7
6 changed files with 184 additions and 5 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ module mesh-delivery-checker
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024214154-bc16740feb0d
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024231716-571ac521f053
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
)
+2
View File
@@ -1,5 +1,7 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024214154-bc16740feb0d h1:oJREw0ggKqJWJ5DEISSL0i2ZC5XqdXAidxo9NJ74TCc=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024214154-bc16740feb0d/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024231716-571ac521f053 h1:8Kbp3aJG3jGndDOfmGdMSDgJVEcc1hyu921hVnnlnlQ=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024231716-571ac521f053/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
@@ -82,6 +82,9 @@ func CaseOf(c delivery.Candidate, trunk map[string]string) *Case {
}
cs := &Case{Key: key, Group: c.Group, HeadsHash: c.HeadsHash, Heads: GateHeads(c), Trunk: map[string]string{}, Progress: &Progress{}}
for _, p := range c.Pulls {
if p.Merged {
continue // merged while the group waits for its other members: judged by the verdict the group had (issue 1791)
}
switch head, known := trunk[p.Repository]; {
case !known || head == "":
cs.Left = append(cs.Left, fmt.Sprintf("%s#%d: the trunk head of %s is not known", p.Repository, p.Number, p.Repository))
@@ -119,7 +122,18 @@ func GateHeads(c delivery.Candidate) string {
}
// sameTrunk says whether two verdicts' trunk heads are the same.
func sameTrunk(a, b map[string]string) bool { return maps.Equal(a, b) }
// sameTrunk says whether a verdict judged on the trunk heads judged stands for a case on the trunk heads now: every
// repository of the case at the head it was judged on. A repository the verdict names and the case does not is a member
// that merged since (issue 1791): its own merge moved that trunk, and the members left are judged by the verdict the
// group had.
func sameTrunk(judged, now map[string]string) bool {
for repo, head := range now {
if judged[repo] != head {
return false
}
}
return len(now) > 0 || len(judged) == 0
}
// Trunks holds the trunk head of each repository, as the gate's source.main says (a watch puts it in).
type Trunks struct {
@@ -83,12 +83,24 @@ type Checker struct {
tried map[string]bool // by verdict key: looked at since this process started
epoch uint64 // changes of the nodes seen since this process started
nodesFail map[string]uint64 // by verdict key: the epoch a failure of the nodes was written at
// current is, by candidates key, the verdict key of the heads the group holds now; running holds, by candidates key,
// the stop of each check that runs, by its verdict key (issue 1791; issue 1921, try speed S9).
current map[string]string
running map[string]map[string]context.CancelCauseFunc
}
// superseded is why a check whose heads the group no longer holds was stopped: the heads it holds now.
type superseded struct{ by string }
func (s superseded) Error() string {
return "superseded: the group holds other heads now (" + s.by + "), whose check judges it"
}
func (ch *Checker) init() {
ch.once.Do(func() {
ch.known, ch.inflight, ch.again, ch.tried = map[string]delivery.Candidate{}, map[string]bool{}, map[string]bool{}, map[string]bool{}
ch.nodesFail = map[string]uint64{}
ch.current, ch.running = map[string]string{}, map[string]map[string]context.CancelCauseFunc{}
})
}
@@ -126,6 +138,15 @@ func (ch *Checker) Candidate(ctx context.Context, key string, value []byte) {
}
ch.mu.Lock()
ch.known[key] = c
if vk, err := delivery.CheckerVerdictKey(c.Group, c.HeadsHash); err == nil {
ch.current[key] = vk
// A check of heads the group no longer holds is stopped: its asks are not put, and it ends superseded.
for running, stop := range ch.running[key] {
if running != vk {
stop(superseded{by: c.HeadsHash})
}
}
}
ch.mu.Unlock()
ch.consider(ctx, key, c)
}
@@ -329,12 +350,48 @@ func (ch *Checker) try(ctx context.Context, key string, cs *Case) {
if cl == nil {
return
}
run, stop := context.WithCancelCause(ctx)
defer stop(nil)
ch.mu.Lock()
now := ch.current[key]
if ch.running[key] == nil {
ch.running[key] = map[string]context.CancelCauseFunc{}
}
ch.running[key][cs.Key] = stop
ch.mu.Unlock()
defer func() {
ch.mu.Lock()
delete(ch.running[key], cs.Key)
if len(ch.running[key]) == 0 {
delete(ch.running, key)
}
ch.mu.Unlock()
}()
if now != "" && now != cs.Key {
stop(superseded{by: now}) // the group moved on before the check began
}
began := ch.now()
ch.writeOwed(ctx, cs, delivery.Verdict{State: delivery.VerdictRunning}, Outcome{})
out := ch.judge(ctx, cs)
if run.Err() == nil {
ch.writeOwed(ctx, cs, delivery.Verdict{State: delivery.VerdictRunning}, Outcome{})
}
out := ch.judge(run, cs)
if ctx.Err() != nil {
return
}
var sup superseded
if errors.As(context.Cause(run), &sup) {
// Stopped for newer heads: the verdict ends failed, not running, and no status is put, as the newer check owns the
// status of the heads they share.
v := delivery.Verdict{Group: cs.Group, Heads: cs.Heads, Trunk: cs.Trunk, State: delivery.VerdictFail,
Failed: delivery.FailedFirst{Machine: "checker", Words: sup.Error()}}
if data, err := delivery.EncodeVerdict(v); err == nil {
if _, err := ch.Verdicts.Update(ctx, cs.Key, data, cl.rev); err != nil {
ch.logf("%s: the superseded verdict could not be written: %v", cs.Key, err)
}
}
ch.logf("%s: %s", cs.Key, sup.Error())
return
}
v := out.Verdict
v.Group, v.Heads, v.Trunk = cs.Group, cs.Heads, cs.Trunk
if v.Took.Queue == 0 && !cs.Queued.IsZero() && began.After(cs.Queued) {
@@ -400,9 +400,13 @@ func (st *state) target(ctx context.Context, p Pull, bm string) *target {
return t
}
// put puts the target's check ask on its build seat's stream.
// put puts the target's check ask on its build seat's stream; a check that was stopped (its group holds other heads now,
// issue 1791) puts none.
func (st *state) put(ctx context.Context, t *target) *delivery.Verdict {
p := t.pull
if err := context.Cause(ctx); err != nil {
return fail(t.module, fmt.Sprintf("the check of %s#%d was not asked: %v", p.Repository, p.Number, err))
}
job := &delivery.CheckJob{Fetch: t.fetchArgs, Levels: st.levelsOf(), Kept: true}
if t.runner != "" {
job.Runner, job.RunnerWhen = t.runner, buildGoDir
@@ -0,0 +1,102 @@
package check
import (
"context"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
const repoC = "novox/mesh-controller"
func statusOf(t *testing.T, r *rig, repo string, n int, head string) string {
t.Helper()
key, _ := delivery.CheckerOwedKey(repo, n, head)
raw, _, found, _ := r.owed.Get(context.Background(), key)
if !found {
return ""
}
o, err := delivery.DecodeCheckerOwed(raw)
if err != nil {
t.Fatal(err)
}
return o.State
}
// 1791 note 9 (issue 1921, try speed S9): two of a group's three members merge. The candidate keeps them, marked merged,
// with the same heads; their trunks moved by their own merges. No new check is asked, and the member left keeps its
// passed status, so the merge of the rest is not refused.
func TestTwoMembersMergingAskNoNewCheckAndTheThirdKeepsItsPass(t *testing.T) {
ctx, j := context.Background(), passJudge()
r := newRig(t, j)
r.setTrunks(t, map[string]string{repoA: trunkA, repoSDK: trunkS, repoC: trunkA})
a, s, c := pullOf(repoA, 7, head1), pullOf(repoSDK, 12, head2), pullOf(repoC, 3, head1)
r.ch.Candidate(ctx, "feat%2Fx", candidateOf(t, "feat/x", a, s, c))
r.ch.Wait()
if j.count() != 1 || statusOf(t, r, repoC, 3, head1) != "success" {
t.Fatalf("judged %d times, the third's status %q", j.count(), statusOf(t, r, repoC, 3, head1))
}
a.Merged, s.Merged = true, true
r.ch.Candidate(ctx, "feat%2Fx", candidateOf(t, "feat/x", a, s, c))
r.setTrunks(t, map[string]string{repoA: trunkA2, repoSDK: trunkS2, repoC: trunkA})
r.ch.TrunkMoved(ctx)
r.ch.Wait()
if j.count() != 1 {
t.Errorf("a new check was asked after two members merged: judged %d times", j.count())
}
if got := statusOf(t, r, repoC, 3, head1); got != "success" {
t.Errorf("the third member's status is %q, want success", got)
}
// The trunk of the member left moves: that is a re-judge as before.
r.setTrunks(t, map[string]string{repoA: trunkA2, repoSDK: trunkS2, repoC: trunkA2})
r.ch.TrunkMoved(ctx)
r.ch.Wait()
if j.count() != 2 {
t.Errorf("the member left's trunk moved and it was judged %d times, want again", j.count())
}
}
// 1791 note 8 (issue 1921, try speed S9): a group opened member by member writes a candidate for each partial set. When
// the group's candidate moves to new heads, the check of the older heads is stopped and ends failed, superseded, instead
// of running on or staying running; it puts no status, as the newer check owns the heads they share.
func TestACheckWhoseHeadsTheGroupNoLongerHoldsIsStoppedAndEndsSuperseded(t *testing.T) {
ctx := context.Background()
started := make(chan struct{})
j := &judgeFunc{f: func(ctx context.Context, c *Case, call int) (Outcome, error) {
if len(c.Pulls) == 1 {
close(started)
select {
case <-ctx.Done():
return Outcome{Verdict: failure("checker", ctx.Err().Error())}, nil
case <-time.After(3 * time.Second):
return Outcome{Verdict: passing()}, nil // never stopped
}
}
return Outcome{Verdict: passing()}, nil
}}
r := newRig(t, j)
partial := candidateOf(t, "feat/x", pullOf(repoA, 7, head1))
whole := candidateOf(t, "feat/x", pullOf(repoA, 7, head1), pullOf(repoSDK, 12, head2))
r.ch.Candidate(ctx, "feat%2Fx", partial)
<-started
r.ch.Candidate(ctx, "feat%2Fx", whole)
done := make(chan struct{})
go func() { r.ch.Wait(); close(done) }()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("the check of the older heads was not stopped")
}
old := r.verdict(t, keyOf(t, "feat/x", partial))
if old.State != delivery.VerdictFail || !strings.Contains(old.Failed.Words, "superseded") {
t.Errorf("the older heads' verdict = %+v, want failed, superseded", old)
}
if v := r.verdict(t, keyOf(t, "feat/x", whole)); v.State != delivery.VerdictPass {
t.Errorf("the newer heads' verdict = %+v", v)
}
if got := statusOf(t, r, repoA, 7, head1); got != "success" {
t.Errorf("the shared head's status is %q, want the newer check's success", got)
}
}