A source revision is delivered over the nodes present: the view leaves out a node the watchman says is away and names it, and the node counts once it runs its wants (ADR 0603 §7)

This commit is contained in:
2026-11-03 17:16:30 +01:00
parent 4a736858b6
commit 2c37f48f9d
4 changed files with 47 additions and 3 deletions
@@ -202,6 +202,7 @@ func (m *Model) moduleReach(name string, mod delivery.Module, heads []headAt, wa
mr.Changes = append(mr.Changes, c)
}
var waiting []string
away := m.awayNodes()
floor, floorNode, unset := -1, "", ""
for _, key := range wantKeys {
node, _ := splitKey(key)
@@ -209,6 +210,11 @@ func (m *Model) moduleReach(name string, mod delivery.Module, heads []headAt, wa
if w.err != "" || w.value.Removed || w.value.FP == "" {
continue
}
if away[node] {
// A node away holds no head back (ADR 0603 §7); it catches up by its wants and counts once it runs them.
waiting = append(waiting, fmt.Sprintf("%s on %s is away", name, node))
continue
}
mr.Nodes++
h, ok := m.haves[key]
switch {
@@ -68,6 +68,11 @@ func TestTheViewDerivesHowFarEachRepositoryReached(t *testing.T) {
{name: "every node runs the newest build", reached: commitC},
{name: "a node lagging stops the head before the change it lacks", reached: commitB, holds: "web on g14",
then: []Entry{haveFP(t, "g14", "web", fpOld, delivery.HealthOK)}, waiting: "not yet the"},
// ADR 0603 §7: a node away holds no head back, is named, and holds it again once it returns still behind.
{name: "a node away lagging holds no head back and is named", reached: commitC, waiting: "g14 is away",
then: []Entry{haveFP(t, "g14", "web", fpOld, delivery.HealthOK), presenceE(t, "g14", false, t0)}},
{name: "a node returned lagging holds the head again", reached: commitB, holds: "web on g14", waiting: "not yet the",
then: []Entry{haveFP(t, "g14", "web", fpOld, delivery.HealthOK), presenceE(t, "g14", false, t0), presenceE(t, "g14", true, t0)}},
{name: "a want held back at an older build holds the head as a lag does", reached: commitB, holds: "web on g14 runs the build of",
then: []Entry{wantE(t, "g14", "web", delivery.Want{FP: fpOld, Newest: fpNew, Rule: 2}), haveFP(t, "g14", "web", fpOld, delivery.HealthOK)}},
{name: "a failing health check is said and holds nothing", reached: commitC, waiting: "health check failed",
@@ -124,6 +124,9 @@ type SourceRow struct {
Waiting []string `json:"waiting,omitempty"`
// Wants is how many wants of modules at the indexer were looked at; a revision with none is delivered of nothing.
Wants int `json:"wants"`
// Away are the nodes the watchman says are offline, left out of the comparison (ADR 0603 §7): the revision counts for them
// once they return and run their wants.
Away []string `json:"away,omitempty"`
}
// AskRow is an open ask (novox/hq ADR 0437 §10): the gate's ask with no answer under its key in the teller's answers.
@@ -1061,7 +1064,8 @@ func (m *Model) wantRows() []WantRow {
// sourceRow derives the state of the source revision the gate holds (ADR 0429 §1; recorded by no part): delivered when every want
// equals its module's fingerprint at the indexer and every have equals its want, running and healthy, and every node a module is
// assigned to has a want. It is nil when the gate holds no readable source.
// assigned to has a want, compared only on the nodes present: a node the watchman says is offline is named away and left out
// (ADR 0603 §7, changing ADR 0429 §1). It is nil when the gate holds no readable source.
func (m *Model) sourceRow() *SourceRow {
if m.source == nil || m.source.err != "" {
return nil
@@ -1075,8 +1079,12 @@ func (m *Model) sourceRow() *SourceRow {
return fp
}
looked := 0
away := m.awayNodes()
for key, w := range m.wants {
node, module := splitKey(key)
if away[node] {
continue
}
mod, ok := m.modules[module]
if w.err != "" || w.value.Removed || !ok || mod.err != "" || mod.value.FP2 == "" {
continue
@@ -1096,7 +1104,7 @@ func (m *Model) sourceRow() *SourceRow {
}
for _, a := range m.placedPairs() {
wk, werr := delivery.HaveKey(a.Node, a.Module)
if werr != nil {
if werr != nil || away[a.Node] {
continue
}
if _, ok := m.wants[wk]; ok {
@@ -1107,13 +1115,28 @@ func (m *Model) sourceRow() *SourceRow {
}
}
sort.Strings(waiting)
row := &SourceRow{Revision: m.source.revision, State: SourceDelivered, Wants: looked}
row := &SourceRow{Revision: m.source.revision, State: SourceDelivered, Wants: looked, Away: slices.Sorted(maps.Keys(away))}
if len(waiting) > 0 {
row.State, row.Waiting = SourceNotDelivered, waiting
}
return row
}
// awayNodes are the nodes whose presence the watchman says is offline (a node's own key, not a part instance's): a source
// revision is delivered over the nodes present (ADR 0603 §7). Called with the lock held.
func (m *Model) awayNodes() map[string]bool {
out := map[string]bool{}
for key, p := range m.presence {
if p.err != "" || p.value.Online {
continue
}
if node, part, _, err := delivery.ParsePresenceKey(key); err == nil && part == "" {
out[node] = true
}
}
return out
}
// splitKey is the node and the module (unescaped, issue 1150) of a model key, which is the have bucket's key form.
func splitKey(key string) (node, module string) {
node, module, isNode, err := delivery.ParseHaveKey(key)
@@ -124,6 +124,16 @@ func TestASourceRevisionIsDerivedFromWantsAndHaves(t *testing.T) {
if s.State != SourceNotDelivered || !strings.Contains(strings.Join(s.Waiting, ";"), "novox") {
t.Errorf("a node assigned the module with no want: %+v", s)
}
// ADR 0603 §7: a node away holds nothing back and is named; it counts once it returns and runs its wants.
fill(m, presenceE(t, "novox", false, t0))
s = m.Snapshot().Source
if s.State != SourceDelivered || len(s.Away) != 1 || s.Away[0] != "novox" {
t.Errorf("a node away: %+v", s)
}
fill(m, presenceE(t, "novox", true, t0))
if s = m.Snapshot().Source; s.State != SourceNotDelivered || len(s.Away) != 0 {
t.Errorf("the node returned and runs nothing yet: %+v", s)
}
}
func TestNoSourceRevisionHeldMeansNoStateIsDerived(t *testing.T) {