Files
mesh-catalog/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/react.go
T
jschoubben 67092161bd Gate hands every member of an approved group, in pin order, and says a stopped pass (issue 1275)
Met again 2026-10-25: group fix/puller-wakes-on-capacity was merged half. Three causes in the gate:

- The next member's turn compared the group's state with "delivering", a word GroupState no longer says
  since release 6 renamed it "merging" (ready.go groupTurnToMerge). Once the first member's merge was heard
  from the forge the group read merging, and the second member waited for ever. Group states are now named
  constants, the turn goes on in ready and merging (groupMayMerge), and a test holds every word to it.
- Members merged in id order, so mesh-catalog went before the mesh-sdk commit it pins. membersOf now orders
  by mergeRank: mesh-sdk first, then members that change no go.mod or go.sum, then those that do.
- No condition was raised: the controller's self-check that read `stalled` went with release 6
  (mesh-controller dc456d56). The gate now says its stalled lines as conditions itself on the
  operator-channel seat (ADR 0437 §9) after every look, clears those listed no more, and keeps which it
  raised in its new bucket conditions. A held group's line says "half merged" when a member merged.

A pass held for a required status no longer retries on a timer: the status turning success is the event
that asks the merge again.
2026-10-25 18:20:58 +01:00

100 lines
3.2 KiB
Go

package main
import (
"sync"
"time"
)
// How this owner keeps time (novox/hq ADR 0406 §1, issue 975). It has no tick, no forge poll and no reconcile loop:
// a look (React) is taken after each event that can change what a step needs, and a step that waits on the clock — a
// grace, a bound, a retry after a failure — says when it must be looked at again (lookAt). The one timer below is
// armed for the earliest of those and fires one more look; it is never a tick, since a look that finds nothing
// waiting arms nothing.
// askAgainAfter is how long after a failed ask of the controller, the forge or the operator it is asked again: the
// failure is the reason, and it is said in the log when it happens.
const askAgainAfter = 30 * time.Second
// lookWanted is the earliest time a look must be taken again, and the bounded step that wants it.
type lookWanted struct {
mu sync.Mutex
at time.Time
step string
working bool
timer *time.Timer
}
// lookAt says a bounded step, waiting on the clock, must be looked at again at a time. Safe to call with or without
// the holder's lock.
func (h *Holder) lookAt(at time.Time, step string) {
h.wanted.mu.Lock()
defer h.wanted.mu.Unlock()
if h.wanted.at.IsZero() || at.Before(h.wanted.at) {
h.wanted.at, h.wanted.step = at, step
}
}
// again says an ask that failed is asked again after askAgainAfter: a bounded step retried by the look's timer.
func (h *Holder) again(step string) { h.lookAt(h.Now().Add(askAgainAfter), step) }
// Poke asks for a look; any number of pokes before it starts are one look.
func (h *Holder) Poke() {
select {
case h.poke <- struct{}{}:
default:
}
}
// Work takes a look each time it is poked, for ever: the one goroutine that acts on events.
func (h *Holder) Work() {
h.wanted.mu.Lock()
h.wanted.working = true
h.wanted.mu.Unlock()
for range h.poke {
h.React()
}
}
// React takes every step the facts allow, after an event that can change what they are: the forge's open pull requests
// read when a check may have answered, the steps that need another's word (look), what is owed (Flush), and then
// the conditions of what stalled (conditions.go), and then the time the next look is wanted at, if any step waits on the clock. Never concurrent with itself.
func (h *Holder) React() {
h.reactMu.Lock()
defer h.reactMu.Unlock()
h.wanted.mu.Lock()
h.wanted.at, h.wanted.step = time.Time{}, ""
h.wanted.mu.Unlock()
if h.uncheckedDue() {
h.wantForge()
}
if h.takeForgeWanted() {
if err := h.SeeOpenPulls(); err != nil {
h.wantForge()
h.again("the forge's open pull requests, read when a check may have changed")
}
}
h.look()
h.Flush()
h.sayConditions()
h.arm()
}
// arm sets the timer for the earliest time a step wants a look at, or stops it when none does.
func (h *Holder) arm() {
h.wanted.mu.Lock()
defer h.wanted.mu.Unlock()
if !h.wanted.working {
return
}
if h.wanted.timer != nil {
h.wanted.timer.Stop()
h.wanted.timer = nil
}
if h.wanted.at.IsZero() {
return
}
wait := max(h.wanted.at.Sub(h.Now()), 100*time.Millisecond)
// retries: the time-bound step the last look left waiting (a grace, a bound or an ask that failed), at its time
h.wanted.timer = time.AfterFunc(wait, h.Poke)
}