diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/mesh-delivery-assigner b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/mesh-delivery-assigner new file mode 100755 index 0000000..334d4c4 Binary files /dev/null and b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/mesh-delivery-assigner differ diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go index 31cf444..85480c4 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go @@ -60,7 +60,23 @@ func (p *passer) pass(ctx context.Context) { // controller has composed it and again whenever that digest changes (ADR 0539 §6). func (p *passer) nodeWants(ctx context.Context) { written := p.w.read(delivery.AssignerWantsBucket) + placed := p.w.placedNodes() + // A node the catalogue places composes its own composition (ADR 0547 §2): its own want names none. + for node := range placed { + key, err := delivery.WantNodeKey(node) + if err != nil { + continue + } + if before, err := delivery.DecodeNodeWant(written[key]); err == nil && before.Composed == "" { + continue + } + want := delivery.NodeWant{Words: "the node composes its own composition (ADR 0547 §2)"} + p.put(ctx, delivery.AssignerWantsBucket, key, func() ([]byte, error) { return delivery.EncodeNodeWant(want) }) + } for node, d := range p.w.nodeComposed() { + if placed[node] { + continue + } key, err := delivery.WantNodeKey(node) if err != nil { continue @@ -98,6 +114,7 @@ func (p *passer) wants(ctx context.Context) { } wanted := p.w.wantsOf() frozen := p.sayPlacement(p.w.placementDiffers()) + placed := p.w.placedNodes() puts := p.putsByModule() p.staleAsks(ctx, trunk) names := map[string]bool{} @@ -120,7 +137,14 @@ func (p *passer) wants(ctx context.Context) { fp := trunk[name].FP2 name := name m := assign.Module{Name: name, FP: fp, Manifest: manifests[name], Nodes: nodesOf[name], Now: time.Now(), Puts: puts[name], - Composed: func(node, fp string) (delivery.ComposedDigest, bool) { return p.w.composed(node, name, fp) }, + Composed: func(node, fp string) (delivery.ComposedDigest, bool) { + if placed[node] { + // The node composes its own (ADR 0547 §2, §4): no composition of the controller's is waited for, the want + // names the build alone, and the node judges the move itself. + return delivery.ComposedDigest{Judgement: delivery.JudgedNeither, Words: "the node composes its own"}, true + } + return p.w.composed(node, name, fp) + }, GrantedAt: func(fp string) bool { key, err := delivery.ControllerGrantKey(name, fp) return err == nil && p.w.has(delivery.ControllerGrantsBucket, key) @@ -154,6 +178,7 @@ func (p *passer) wants(ctx context.Context) { last[node] = p.w.lastHealthy(node, name, fp) } out := assign.Wants(m, nodes, wanted[name], last) + p.yeses(ctx, name, m, placed, wanted[name], &out) gatherNeither(neither, name, out) leftNeither(left, m, wanted[name], out) p.sayNoWant(name, m.Nodes, wanted[name], out) @@ -768,3 +793,47 @@ func (p *passer) sayPlacement(differ []assign.Pair) map[string]bool { } return frozen } + +// yeses writes a placed node's wants naming the build alone, and sees to the moves a node that composes its own judged a +// person must allow (ADR 0547 §3): for a have of phase +// waiting-yes it raises the record ask once, naming the composition waiting and the node's judgement, and on a yes writes +// the want again naming it; a yes once written is kept while the want stays at its build, so the node is not asked again. +func (p *passer) yeses(ctx context.Context, module string, m assign.Module, placed map[string]bool, before map[string]delivery.Want, out *assign.Outcome) { + for _, node := range m.Nodes { + if !placed[node] { + continue + } + was, had := before[node] + w, written := out.Wants[node] + if !written { + if !had || was.Removed { + continue + } + w = was + } + if w.Removed { + continue + } + yes := "" + if had && was.FP == w.FP { + yes = was.Yes + } + key, err := delivery.HaveKey(node, module) + if err != nil { + continue + } + if h, err := delivery.DecodeHave(p.w.value(delivery.HaveBucket, key)); err == nil && h.Phase == delivery.PhaseWaitingYes && h.Waits != yes { + if m.Allowed != nil && m.Allowed(node, h.Waits) { + yes = h.Waits + } else { + p.recordAsk(ctx, module, w.FP, assign.RecordNeed{Node: node, Digest: h.Waits, Judgement: h.Judgement, Words: h.Detail}) + } + } + // A want carried over from before the node composed its own may still name the controller's composition; a placed + // node's want names the build alone (ADR 0547 §2). + if yes != w.Yes || w.Composed != "" { + w.Yes, w.Composed = yes, "" + out.Wants[node] = w + } + } +} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go index ee886d5..69b3639 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go @@ -794,3 +794,73 @@ func TestAPairOneSourceNamesAloneGetsNoWantAndOneCondition(t *testing.T) { t.Errorf("both agree again: frozen %v, said %+v", frozen, said) } } + +// 1126 (ADR 0547 §2 to §4): a node the catalogue places composes its own: its wants name the build alone and its own want +// names no composition; a have waiting for a yes raises the record ask once, and the yes is written into the want and +// kept there while the want stays at its build. +func TestAPlacedNodesWantsNameTheBuildAloneAndItsYesComesFromItsHave(t *testing.T) { + fpOld := delivery.SourcePrefix2 + strings.Repeat("a", 64) + waiting, was := "sha256:"+strings.Repeat("e", 64), "sha256:"+strings.Repeat("d", 64) + asksKV, wantsKV := &memKV{values: map[string][]byte{}}, &memKV{values: map[string][]byte{}} + p := newPasser(buckets{delivery.AssignerAsksBucket: asksKV, delivery.AssignerWantsBucket: wantsKV}, time.Now()) + p.logf = t.Logf + put := func(bucket, key string, v []byte) { p.w.put(bucket, key, v) } + mod, _ := delivery.EncodeModule(delivery.Module{FP2: fpTest, Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: delivery.RootPath}) + put(delivery.ModulesBucket, "web", mod) // the trunk's build is not done: rule 1 holds g14 at fpOld + put(provider.AssignmentsBucket, "g14.web", []byte(`{"node":"g14","module":"web","build":"b-1"}`)) + pl, _ := delivery.EncodePlacement(delivery.Placement{Node: "g14", Modules: []delivery.PlacedModule{{Module: "web"}}}) + put(delivery.PlacementBucket, "placement.g14", pl) + on, _ := delivery.EncodePresence(delivery.Presence{Online: true}) + put(delivery.PresenceBucket, "g14", on) + have, _ := delivery.EncodeHave(delivery.Have{FP: fpOld, Composed: was, Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) + put(delivery.HaveBucket, "g14.web", have) + wkey, _ := delivery.WantKey("g14", "web") + want, _ := delivery.EncodeWant(delivery.Want{FP: fpOld, Composed: was}) + wantsKV.values[wkey] = want + put(delivery.AssignerWantsBucket, wkey, want) + sync := func() { + for k, v := range wantsKV.values { + put(delivery.AssignerWantsBucket, k, v) + } + } + + p.wants(context.Background()) + p.nodeWants(context.Background()) + sync() + got, err := delivery.DecodeWant(wantsKV.values[wkey]) + if err != nil || got.FP != fpOld || got.Composed != "" { + t.Fatalf("the placed node's want is %+v (%v), want it at %s naming no composition", got, err, fpOld) + } + nkey, _ := delivery.WantNodeKey("g14") + if nw, err := delivery.DecodeNodeWant(wantsKV.values[nkey]); err != nil || nw.Composed != "" { + t.Fatalf("the placed node's own want is %+v (%v), want one naming no composition", nw, err) + } + + waits, _ := delivery.EncodeHave(delivery.Have{FP: fpOld, Composed: was, Phase: delivery.PhaseWaitingYes, Health: delivery.HealthOK, + Judgement: delivery.JudgedRecreates, Waits: waiting, Detail: "it recreates web.server"}) + put(delivery.HaveBucket, "g14.web", waits) + akey, _ := delivery.RecordAskKey("g14", "web", waiting) + p.wants(context.Background()) + if _, there := asksKV.values[akey]; !there { + t.Fatal("a have waiting for a yes raised no record ask") + } + a, err := delivery.DecodeAssignerAsk(akey, asksKV.values[akey]) + if err != nil { + t.Fatal(err) + } + put(delivery.AssignerAsksBucket, akey, asksKV.values[akey]) + yes, _ := delivery.EncodeAnswer(delivery.Answer{Option: assign.OptionYes, Warrant: a.Ask.ID + "@2026-10-26T19:00:00Z", Words: "the operator"}) + put(delivery.AnswersBucket, akey, yes) + p.wants(context.Background()) + sync() + if got, err = delivery.DecodeWant(wantsKV.values[wkey]); err != nil || got.Yes != waiting || got.FP != fpOld { + t.Fatalf("after the yes the want is %+v (%v), want it to name the yes %s", got, err, waiting) + } + // The node applied it: its have runs the composition; the yes stays while the want stays at its build. + ran, _ := delivery.EncodeHave(delivery.Have{FP: fpOld, Composed: waiting, Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) + put(delivery.HaveBucket, "g14.web", ran) + p.wants(context.Background()) + if got, _ = delivery.DecodeWant(wantsKV.values[wkey]); got.Yes != waiting { + t.Errorf("a pass after the move dropped the yes: %+v", got) + } +} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go index 884a220..821cd89 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go @@ -198,6 +198,17 @@ func (w *world) placed() (map[string][]string, bool) { return out, len(values) > 0 } +// placedNodes is every node the indexer's placement names (ADR 0549 §2): those compose their own modules (ADR 0547 §2), +// so their wants name the build alone and the seventh rule does not hold them. +func (w *world) placedNodes() map[string]bool { + placed, _ := w.placed() + out := make(map[string]bool, len(placed)) + for node := range placed { + out[node] = true + } + return out +} + // assignedByController is each node's modules with an open assignment, read from the controller's assignments, in // name order. func (w *world) assignedByController() (map[string][]string, map[string]assignment) {