From 2c37f48f9db0a4066cede1b63d2ba822927cc488 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 17:16:30 +0100 Subject: [PATCH] =?UTF-8?q?A=20source=20revision=20is=20delivered=20over?= =?UTF-8?q?=20the=20nodes=20present:=20the=20view=20leaves=20out=20a=20nod?= =?UTF-8?q?e=20the=20watchman=20says=20is=20away=20and=20names=20it,=20and?= =?UTF-8?q?=20the=20node=20counts=20once=20it=20runs=20its=20wants=20(ADR?= =?UTF-8?q?=200603=20=C2=A77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mesh-delivery-view/internal/view/reach.go | 6 ++++ .../internal/view/reach_test.go | 5 ++++ .../mesh-delivery-view/internal/view/view.go | 29 +++++++++++++++++-- .../internal/view/wants_test.go | 10 +++++++ 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/modules/mesh-delivery-view/internal/view/reach.go b/modules/mesh-delivery-view/internal/view/reach.go index 303fb5d8..1f68ce6e 100644 --- a/modules/mesh-delivery-view/internal/view/reach.go +++ b/modules/mesh-delivery-view/internal/view/reach.go @@ -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 { diff --git a/modules/mesh-delivery-view/internal/view/reach_test.go b/modules/mesh-delivery-view/internal/view/reach_test.go index 276049ce..69284aef 100644 --- a/modules/mesh-delivery-view/internal/view/reach_test.go +++ b/modules/mesh-delivery-view/internal/view/reach_test.go @@ -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", diff --git a/modules/mesh-delivery-view/internal/view/view.go b/modules/mesh-delivery-view/internal/view/view.go index a447fe76..1b3cd75b 100644 --- a/modules/mesh-delivery-view/internal/view/view.go +++ b/modules/mesh-delivery-view/internal/view/view.go @@ -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) diff --git a/modules/mesh-delivery-view/internal/view/wants_test.go b/modules/mesh-delivery-view/internal/view/wants_test.go index 07ad6dcd..e545bfe2 100644 --- a/modules/mesh-delivery-view/internal/view/wants_test.go +++ b/modules/mesh-delivery-view/internal/view/wants_test.go @@ -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) {