A try's end is said again until the stream acknowledges it; a waiting try says why; a hand recovery never overlaps a try event (issue 1921, ADR 0639)
mesh/delivery merged: source.main moved to novox/mesh-delivery@abe9ea3796ea
mesh/check being carried to f9578cb7b418 on 8c67adaf9136 | from abe9ea3796ea

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
This commit is contained in:
2026-11-05 19:24:45 +01:00
parent 69a2790099
commit f9578cb7b4
8 changed files with 141 additions and 18 deletions
@@ -192,11 +192,10 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
// The tries (ADR 0501 §5) are judged by the same judge, held by the same deliveries.
tryer := &check.Tryer{Tries: kvStore{buckets[check.TriesBucket]}, Bound: checkBound, NotTaken: stepBound, Logf: logger.Printf,
// A whole try's start and end are said on the stream checks, for the indexer to write and delete the candidate of its group.
Say: func(ctx context.Context, event string, body tryevent.Event) {
Say: func(ctx context.Context, event string, body tryevent.Event) error {
data, _ := json.Marshal(body)
if _, err := js.Publish(ctx, eventSubject(event), data, jetstream.WithRetryAttempts(10), jetstream.WithRetryWait(time.Second)); err != nil {
logger.Printf("%s of %s could not be said: %v (the indexer finds the open tries at its start)", event, body.ID, err)
}
_, err := js.Publish(ctx, eventSubject(event), data, jetstream.WithRetryAttempts(10), jetstream.WithRetryWait(time.Second))
return err
}}
announce := func(ctx context.Context, event, key string) {
body, _ := json.Marshal(map[string]string{"key": key})
@@ -55,6 +55,8 @@ type Case struct {
Watch func(askID string, step *delivery.Step)
// Progress says what the check is doing, so a check that ran past its bound names the step it was stuck at.
Progress *Progress
// Wait says what a whole try waits for now ("" when nothing); the try keeps it for try-read. Nil for a pull request's check.
Wait func(why string)
// Kind is what asked the check (KindNew, KindNodes, KindTrunk): a carry after a trunk or sibling move is asked on the
// build seats' recheck stream, every other check on check (ADR 0536 §4, ADR 0548 §5).
Kind string
@@ -283,6 +283,10 @@ func (st *state) takeTryCandidate(ctx context.Context) {
no("no preview of a merge of " + strings.Join(st.unpreviewed, ", ") + " (its head does not merge or its fetch did not answer)")
return
}
if st.c.Wait != nil {
st.c.Wait("the indexer's candidate of the group at its heads (up to " + st.j.buildWait().String() + ")")
defer st.c.Wait("")
}
cand, err := st.j.Candidates.TryCandidate(ctx, st.c.Group, st.c.HeadsHash, st.j.buildWait())
if err != nil {
no(err.Error())
@@ -84,6 +84,9 @@ type Try struct {
// Told is true from the moment a whole try's start is said on the checker's stream until its end is said there: kept in the
// try's own state, so an end that is written and not said (the checker stopped between) is said when the checker starts.
Told bool `json:"told,omitempty"`
// Waiting is what the running try waits for now (its group's candidate from the indexer, ADR 0639 §5), empty when it waits for nothing;
// try-read gives it, so a try that sits still says why.
Waiting string `json:"waiting,omitempty"`
// Said is the try's report in words: what failed first, the tests run, kept and skipped per level, the time of each part.
Said string `json:"said,omitempty"`
// Took is the time from its start to its end, as Go writes a duration.
@@ -180,6 +183,17 @@ func (w *tryWriter) heard(askID string, step *delivery.Step) {
}
}
// waiting writes what the try waits for now ("" when it waits for nothing).
func (w *tryWriter) waiting(why string) {
w.mu.Lock()
defer w.mu.Unlock()
if w.stopped || w.t.Waiting == why {
return
}
w.t.Waiting = why
w.write()
}
// write writes the record at the revision written last; a refusal stops the writes.
func (w *tryWriter) write() {
data, err := encodeTry(w.t)
@@ -519,7 +533,8 @@ type Tryer struct {
// Say publishes an event of the checker's stream (tryevent.Started, tryevent.Ended) with its body; nil says nothing. The
// indexer, which hears them, writes and deletes the candidate of a whole try's group.
Say func(ctx context.Context, event string, body tryevent.Event)
// It answers the publish's error, nil only when the stream acknowledged it: an end not acknowledged is said again.
Say func(ctx context.Context, event string, body tryevent.Event) error
mu sync.Mutex
ending map[string]bool // tries whose end is being said now
@@ -638,6 +653,7 @@ func (tr *Tryer) run(ctx context.Context, key string) {
tr.say(ctx, tryevent.Started, started)
}
cs := TryCase(key, t, progress.heard)
cs.Wait = progress.waiting
judging := ctx
if tr.NotTaken > 0 {
judging = buildrun.NotTakenBound(ctx, tr.NotTaken)
@@ -647,6 +663,7 @@ func (tr *Tryer) run(ctx context.Context, key string) {
return // left running: the checker takes it over when it starts; or ended by hand, which wrote its end
}
t, rev = progress.end()
t.Waiting = ""
v := out.Verdict
t.Ended = tr.now().UTC()
t.Took = t.Ended.Sub(t.Started).Round(time.Second).String()
@@ -720,7 +737,9 @@ func (tr *Tryer) sendEnd(ctx context.Context, key string) {
if err != nil || !t.Over() || !t.Told {
return
}
tr.say(ctx, tryevent.Ended, tryevent.Event{ID: key, State: t.State})
if tr.say(ctx, tryevent.Ended, tryevent.Event{ID: key, State: t.State}) != nil {
return // not acknowledged: Told stays, and the next Put or Reconsider says the end again
}
t.Told = false
data, err := encodeTry(t)
if err == nil {
@@ -732,10 +751,15 @@ func (tr *Tryer) sendEnd(ctx context.Context, key string) {
}
// say publishes a try event; a failure is said in the log (the indexer finds the open tries again at its start).
func (tr *Tryer) say(ctx context.Context, event string, body tryevent.Event) {
if tr.Say != nil {
tr.Say(ctx, event, body)
func (tr *Tryer) say(ctx context.Context, event string, body tryevent.Event) error {
if tr.Say == nil {
return nil
}
err := tr.Say(ctx, event, body)
if err != nil {
tr.logf("%s of %s could not be said: %v", event, body.ID, err)
}
return err
}
// OpenTries are every try that waits or runs now: what checker_tries answers, so a part that missed an event (the indexer at
@@ -152,9 +152,14 @@ type fakeTryCandidates struct {
cand delivery.Candidate
err error
asked []string
// during is called while the try waits for the candidate.
during func()
}
func (f *fakeTryCandidates) TryCandidate(_ context.Context, group, heads string, _ time.Duration) (delivery.Candidate, error) {
if f.during != nil {
f.during()
}
f.asked = append(f.asked, group+"."+heads)
return f.cand, f.err
}
@@ -180,9 +185,12 @@ func TestATryIsJudgedAsAGroupsCheckAndOwesTheForgeNothing(t *testing.T) { // ADR
b := newBench(t)
comps, cands := wholeBench(b, delivery.Candidate{FPSet: "fps-of-the-try"})
written := &writtenTries{memKV: kv}
var waiting string
cands.during = func() { waiting = readTry(t, kv, id).Waiting } // ADR 0639 §5: the stored try says what it waits for
var said []string
tr := &Tryer{Tries: written, Judge: b.ex, Say: func(_ context.Context, event string, e tryevent.Event) {
tr := &Tryer{Tries: written, Judge: b.ex, Say: func(_ context.Context, event string, e tryevent.Event) error {
said = append(said, fmt.Sprintf("%s %s %d %s", event, e.ID, len(e.Members), e.State))
return nil
}}
raw, _, _, _ := kv.Get(ctx, id)
tr.Put(ctx, id, raw)
@@ -191,7 +199,25 @@ func TestATryIsJudgedAsAGroupsCheckAndOwesTheForgeNothing(t *testing.T) { // ADR
if len(said) != 2 || said[0] != "try.started "+id+" 2 " || said[1] != "try.ended "+id+" 0 "+TryPass {
t.Errorf("events = %v, want the start with 2 members and the end", said)
}
// What the indexer reads: tryevent.Decode accepts the events as said and refuses a body that is no try's.
for _, row := range []struct {
name, event, body string
ok bool
}{
{"a start with its members", tryevent.Started, `{"id":"` + id + `","members":[{"repository":"` + repoA + `","head":"` + head1 + `","trunk":"` + trunkA + `"}]}`, true},
{"an end with its state", tryevent.Ended, `{"id":"` + id + `","state":"pass"}`, true},
{"an id that is no try's", tryevent.Ended, `{"id":"pull-7","state":"pass"}`, false},
{"a start with no member", tryevent.Started, `{"id":"` + id + `"}`, false},
{"a body that is no JSON", tryevent.Ended, `try-7`, false},
} {
if e, err := tryevent.Decode(row.event, []byte(row.body)); (err == nil) != row.ok || (row.ok && e.ID != id) {
t.Errorf("Decode of %s = %+v, %v, want ok=%v", row.name, e, err, row.ok)
}
}
got := readTry(t, kv, id)
if !strings.Contains(waiting, "candidate") || got.Waiting != "" {
t.Errorf("waiting while the candidate was awaited = %q, after the end %q: want the reason, then none", waiting, got.Waiting)
}
if got.Told { // the end was said: nothing is owed
t.Errorf("the ended try still owes its end: %+v", got)
}
@@ -264,8 +290,9 @@ func TestATryOfOneLevelAndSomeTestsRunsThemAloneAndKeepsNothing(t *testing.T) {
t.Fatal(err)
}
b := newBench(t)
tr := &Tryer{Tries: kv, Judge: b.ex, Say: func(context.Context, string, tryevent.Event) {
tr := &Tryer{Tries: kv, Judge: b.ex, Say: func(context.Context, string, tryevent.Event) error {
t.Error("a try of one level and some tests said an event: it has no candidate")
return nil
}}
raw, _, _, _ := kv.Get(ctx, id)
tr.Put(ctx, id, raw)
@@ -343,10 +370,11 @@ func TestAFailingTryNamesWhatFailedAndAStepThatCannotRunIsAnError(t *testing.T)
}
id, _ := AskTry(memTries{kv}, tryTrunks, TryAsk{Repository: repoA, Head: head1}, time.Now())
var ends []string
tr := &Tryer{Tries: kv, Judge: b.ex, Say: func(_ context.Context, event string, e tryevent.Event) {
tr := &Tryer{Tries: kv, Judge: b.ex, Say: func(_ context.Context, event string, e tryevent.Event) error {
if event == tryevent.Ended {
ends = append(ends, e.State)
}
return nil
}}
raw, _, _, _ := kv.Get(ctx, id)
tr.Put(ctx, id, raw)
@@ -366,11 +394,13 @@ func TestATryLeftRunningIsTakenOver(t *testing.T) {
// Whether the start was said is in the try's own state (Told), so a checker that starts later says the end of a try it did
// not start, whatever way the try ended.
for _, row := range []struct {
name string
told bool
after func(t *testing.T, kv *memKV, id string)
ends []string
name string
told bool
failFirst bool // the stream does not acknowledge the first end said
after func(t *testing.T, kv *memKV, id string)
ends []string
}{
{name: "the first publish of the end is not acknowledged", told: true, failFirst: true, ends: []string{TryPass}},
{name: "left running after its start was said", told: true, ends: []string{TryPass}},
{name: "left running with no start said", told: false, ends: []string{TryPass}},
{name: "ended by hand after the start was said", told: true, ends: []string{TryError}, after: func(t *testing.T, kv *memKV, id string) {
@@ -396,13 +426,22 @@ func TestATryLeftRunningIsTakenOver(t *testing.T) {
b := newBench(t)
wholeBench(b, delivery.Candidate{})
var ends []string
tr := &Tryer{Tries: kv, Judge: b.ex, Say: func(_ context.Context, event string, e tryevent.Event) {
failed := false
tr := &Tryer{Tries: kv, Judge: b.ex, Say: func(_ context.Context, event string, e tryevent.Event) error {
if event == tryevent.Ended {
if row.failFirst && !failed {
failed = true
return errors.New("the stream did not acknowledge")
}
ends = append(ends, e.State)
}
return nil
}}
tr.Reconsider(ctx)
tr.Wait()
if row.failFirst && !readTry(t, kv, id).Told {
t.Errorf("an end the stream did not acknowledge cleared Told: it is never said again")
}
tr.Reconsider(ctx) // said once: the second look finds nothing owed
tr.Wait()
if row.after == nil {
@@ -415,4 +454,28 @@ func TestATryLeftRunningIsTakenOver(t *testing.T) {
}
})
}
// An ended try is kept TryKeep and removed by the next Reconsider after.
t.Run("an ended try is kept seven days", func(t *testing.T) {
kv := newMemKV()
id, _ := AskTry(memTries{kv}, tryTrunks, TryAsk{Repository: repoA, Head: head1}, time.Now())
raw, rev, _, _ := kv.Get(ctx, id)
cur, _ := DecodeTry(raw)
ended := time.Now()
cur.State, cur.Ended = TryPass, ended
data, _ := encodeTry(cur)
if _, err := kv.Update(ctx, id, data, rev); err != nil {
t.Fatal(err)
}
now := ended.Add(TryKeep - time.Hour)
tr := &Tryer{Tries: kv, Now: func() time.Time { return now }}
tr.Reconsider(ctx)
if _, _, found, _ := kv.Get(ctx, id); !found {
t.Fatal("the try was removed before TryKeep")
}
now = ended.Add(TryKeep + time.Hour)
tr.Reconsider(ctx)
if _, _, found, _ := kv.Get(ctx, id); found {
t.Error("the try was kept past TryKeep")
}
})
}
@@ -147,6 +147,7 @@ type Indexer struct {
OpenTries func(ctx context.Context) ([]tryevent.Open, error)
// TryRecovery keeps how the last recovery of the open tries went (trycandidates.go); nil keeps none.
TryRecovery Store
tryMu sync.Mutex // one try event or recovery at a time (trycandidates.go)
recoveryFailed atomic.Bool // the last recovery of the open tries failed (trycandidates.go)
tries map[string][]TryMember // the whole tries that wait or run, by try id (trycandidates.go); guarded by mu
pendingMu sync.Mutex // one take-in step at a time: a merge, a group's end, the start and a hand act (pending.go)
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"os"
"runtime"
"slices"
"strings"
"testing"
@@ -385,6 +386,22 @@ func TestAPreviewHeardBeforeItsPullRequestGivesTheFingerprintWhenTheCandidateIsW
if err := ix.EndTry(ctx, "try-7"); err != nil || len(held()) != 0 {
t.Errorf("an ended try: %v, candidates %v, want none", err, held())
}
// The hand recovery never overlaps a try event: a start heard while the call to the checker is out writes its candidate
// before or after the sweep, never between the call and the sweep (which would delete it, the try not being listed).
startDone := make(chan error, 1)
ix.OpenTries = func(context.Context) ([]tryevent.Open, error) {
go func() { startDone <- ix.StartTry(ctx, "try-12", members) }()
for i := 0; i < 2000 && len(held()) == 0; i++ {
runtime.Gosched() // lets the start write if nothing holds it back
}
return nil, nil
}
if r := ix.RecoverTriesByHand(ctx, "lead", "a test"); !r.Stored {
t.Fatalf("the hand recovery = %+v", r)
}
if err := <-startDone; err != nil || len(held()) != 1 || held()[0] != "try-12" {
t.Errorf("a start heard during the hand recovery: %v, candidates %v, want try-12 kept", err, held())
}
})
}
}
@@ -57,6 +57,8 @@ type TryMember struct{ Repository, Head, Trunk string }
// StartTry takes a whole try in, as the checker said it started (tryevent.Started): its members at their commits are kept, and
// its candidate is written once every member's preview is held. The checker's state is never read (ADR 0596).
func (ix *Indexer) StartTry(ctx context.Context, id string, members []TryMember) error {
ix.tryMu.Lock()
defer ix.tryMu.Unlock()
ix.retryRecovery(ctx)
return ix.startTry(ctx, id, members)
}
@@ -64,7 +66,7 @@ func (ix *Indexer) StartTry(ctx context.Context, id string, members []TryMember)
// retryRecovery runs the recovery again on a try event when the last one failed: the failure stays said until one succeeds.
func (ix *Indexer) retryRecovery(ctx context.Context) {
if ix.recoveryFailed.Load() && ix.OpenTries != nil {
_ = ix.RecoverTries(ctx)
_ = ix.recoverNow(ctx)
}
}
@@ -83,6 +85,8 @@ func (ix *Indexer) startTry(ctx context.Context, id string, members []TryMember)
// EndTry takes a try out, as the checker said it ended (tryevent.Ended), and deletes its candidate.
func (ix *Indexer) EndTry(ctx context.Context, id string) error {
ix.tryMu.Lock()
defer ix.tryMu.Unlock()
ix.retryRecovery(ctx)
return ix.endTry(ctx, id)
}
@@ -103,7 +107,16 @@ func (ix *Indexer) endTry(ctx context.Context, id string) error {
//
// A recovery that fails is said loudly and kept (the try-recovery state, read by indexer_try_candidates) until one succeeds:
// the events are still heard after it, and indexer_recover_tries runs it again by hand.
//
// A recovery and a try event never overlap (tryMu): a sweep that deleted the candidate a start event wrote after the call to the
// checker would leave a running try with none.
func (ix *Indexer) RecoverTries(ctx context.Context) error {
ix.tryMu.Lock()
defer ix.tryMu.Unlock()
return ix.recoverNow(ctx)
}
func (ix *Indexer) recoverNow(ctx context.Context) error {
if ix.OpenTries == nil || ix.Store == nil {
return nil
}