From 30ec4329e0998467157ccb98f6dadb3944f92929 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 2 Nov 2026 00:30:51 +0100 Subject: [PATCH] The assigner's integration tests place their modules and name the node's own composition; a placed node's backup answer stays in its want The integration tests put placement (not assignments) and the node's own composition (not the controller's) on the bus; the three that tested the controller's judgements of a move (waiting for a composition, a record move, a user-list revision) go, their unit counterparts standing. Run locally against the embedded bus, all of this module's integration tests pass. Found by them: the answer to a failed backup was dropped from a placed node's want at every pass, because the carry-over compared the want's digest, which a placed node's want does not carry; fixed in wants.go, with a case in the existing unit test. Tests: three integration tests deleted, none added; one case added to an existing unit test. (issue 774, 774/S26, 774/S27) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL --- .../compose_integration_test.go | 5 +- .../integration_test.go | 237 +++++------------- .../internal/assign/wants.go | 3 +- .../internal/assign/wants_test.go | 7 + 4 files changed, 70 insertions(+), 182 deletions(-) diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/compose_integration_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/compose_integration_test.go index 43030fa..9e60c43 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/compose_integration_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/compose_integration_test.go @@ -14,7 +14,6 @@ import ( "git.novox.be/novox/mesh-sdk/go/composer" "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/provider" ) // holder is a test holder of the Go build seat: it takes every ask from the seat's check worker, as mesh-build-go does, @@ -103,9 +102,9 @@ func TestAGroupWithAnSDKMemberIsComposedByTheComposerAtItsHead(t *testing.T) { t.Fatal(err) } b.putJSON(t, delivery.ModulesBucket, "web", mod) - b.putJSON(t, provider.AssignmentsBucket, "g14.web", []byte(`{"node":"g14","module":"web","build":"build-1"}`)) + b.place(t, "g14", "web") running := strings.Repeat("3", 40) - b.putJSON(t, provider.AssignmentsBucket, "novox.mesh-controller", []byte(`{"node":"novox","module":"mesh-controller","build":"c-1"}`)) + b.place(t, "novox", "mesh-controller") b.built(t, "mesh-controller", "novox/mesh-controller", running) sdkHead := strings.Repeat("4", 40) heads := []delivery.Head{{Repository: "novox/mesh-sdk", Number: 80, Head: sdkHead}, {Repository: "novox/web", Number: 7, Head: strings.Repeat("2", 40)}} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go index cd1670e..9e48b6a 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go @@ -27,7 +27,6 @@ import ( "git.novox.be/novox/mesh-sdk/go/compose/judge" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/process" - "git.novox.be/novox/mesh-sdk/go/provider" "mesh-delivery-assigner/internal/assign" ) @@ -40,6 +39,8 @@ type bed struct { ctx context.Context dir string buckets map[string]jetstream.KeyValue + // placed is, by node, the modules the bed's placement names on it (place). + placed map[string][]string } func startBed(t *testing.T, leaveOut string) *bed { @@ -235,8 +236,7 @@ func TestTheAssignerDoesNotStartWithoutABucketItReads(t *testing.T) { } // Without the seat's placement state it starts all the same, and once the bucket is made (the controller makes - // it from the manifest) it is read with no restart: a module only the placement names is - // said as placed by one source only. + // it from the manifest) it is read with no restart: a node it names gets its own want, the node composing its own. pb := startBed(t, delivery.PlacementBucket) pdone := pb.start(t) select { @@ -253,13 +253,8 @@ func TestTheAssignerDoesNotStartWithoutABucketItReads(t *testing.T) { if _, err := placement.Put(pb.ctx, plkey, pl); err != nil { t.Fatal(err) } - for deadline := time.Now().Add(15 * time.Second); ; time.Sleep(200 * time.Millisecond) { - if c, ok := pb.conditionsSaid(t)[delivery.AssignerModule+".placement.n1.web"]; ok && c.State == asks.StateRaised { - break - } - if time.Now().After(deadline) { - t.Fatal("the placement state made after the start was not read") - } + if nw, err := delivery.DecodeNodeWant(pb.get(t, delivery.AssignerWantsBucket, "want.n1.node")); err != nil || !nw.Self { + t.Fatalf("the placement state made after the start was not read: n1's own want = %+v, %v", nw, err) } } @@ -271,6 +266,29 @@ func (b *bed) putJSON(t *testing.T, bucket, key string, value []byte) { } } +// place adds module to the seat's placement of node, written as the seat writes it: one node key and a key per module +// (research 072 row 37). The placement alone names where modules run (ADR 0567 §8). +func (b *bed) place(t *testing.T, node, module string) { + t.Helper() + if b.placed == nil { + b.placed = map[string][]string{} + } + if !slices.Contains(b.placed[node], module) { + b.placed[node] = append(b.placed[node], module) + } + pl := delivery.Placement{Node: node} + for _, m := range b.placed[node] { + pl.Modules = append(pl.Modules, delivery.PlacedModule{Module: m}) + } + values, err := delivery.PlacementValues(pl) + if err != nil { + t.Fatal(err) + } + for key, raw := range values { + b.putJSON(t, delivery.PlacementBucket, key, raw) + } +} + // built appends the end of a done trunk build of module at commit to the builder's log, as the builder does. func (b *bed) built(t *testing.T, module, repository, commit string) { t.Helper() @@ -284,24 +302,20 @@ func (b *bed) built(t *testing.T, module, repository, commit string) { } } -// compose says the controller composed module for node at fp, with the digest d (a letter repeated) and the judgement of the -// move to it, in mesh-controller_composed. -func (b *bed) compose(t *testing.T, node, module, fp string, d byte, judgement delivery.Judgement) string { +// compose says the node's own composition is the digest d (a letter repeated), as its have names it (`.node`): a +// placed node composes its own (ADR 0571 §6), and the controller's composition is read no more. It answers the digest. +func (b *bed) compose(t *testing.T, node string, d byte) string { t.Helper() - key, err := delivery.ComposedKey(node, module, fp) + key, err := delivery.HaveNodeKey(node) if err != nil { t.Fatal(err) } digest := "sha256:" + strings.Repeat(string(d), 64) - cd := delivery.ComposedDigest{Digest: digest, Judgement: judgement} - if judgement != delivery.JudgedNeither { - cd.Against, cd.Words = "sha256:"+strings.Repeat("0", 64), "cannot be told" - } - v, err := delivery.EncodeComposedDigest(cd) + v, err := delivery.EncodeHaveNode(delivery.HaveNode{Composed: digest}) if err != nil { t.Fatal(err) } - b.putJSON(t, delivery.ControllerComposedBucket, key, v) + b.putJSON(t, delivery.HaveBucket, key, v) return digest } @@ -329,8 +343,8 @@ func TestAChangeIsComposedOnTheBus(t *testing.T) { t.Fatal(err) } b.putJSON(t, delivery.ModulesBucket, "web", mod) - b.putJSON(t, provider.AssignmentsBucket, "g14.web", []byte(`{"node":"g14","module":"web","build":"build-1"}`)) - b.putJSON(t, provider.AssignmentsBucket, "ace.other", []byte(`{"node":"ace","module":"other","build":"build-2"}`)) + b.place(t, "g14", "web") + b.place(t, "ace", "other") heads := []delivery.Head{{Repository: "novox/web", Number: 7, Head: strings.Repeat("2", 40)}} hash, _ := delivery.HeadsHash(heads) // 1927/AC1: the candidate carries the fingerprint set the SDK computes, as the indexer writes it. @@ -411,14 +425,14 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) { } runs := map[string]string{"ace": fOld, "g14": "build-9", "shanks": fOld} for _, node := range []string{"ace", "g14", "shanks"} { - b.compose(t, node, "web", fNew, 'c', delivery.JudgedNeither) + b.compose(t, node, 'c') on, _ := delivery.EncodePresence(delivery.Presence{Online: true}) b.putJSON(t, delivery.PresenceBucket, node, on) have, _ := delivery.EncodeHave(delivery.Have{FP: runs[node], Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) b.putJSON(t, delivery.HaveBucket, node+".web", have) - b.putJSON(t, provider.AssignmentsBucket, node+".web", []byte(`{"node":"`+node+`","module":"web","build":"b-`+node+`"}`)) + b.place(t, node, "web") } - b.compose(t, "shanks", "web", fOld, 'a', delivery.JudgedNeither) + b.compose(t, "shanks", 'a') b.start(t) wkey, _ := delivery.WantKey("ace", "web") @@ -426,8 +440,8 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) { if err != nil || want.FP != fNew { t.Fatalf("the canary ace's want = %+v, %v", want, err) } - if w := b.wantNow(t, "shanks", "web", "shanks' first want at what it runs", func(w delivery.Want) bool { return w.FP != "" }); w.FP != fOld || w.Composed != "sha256:"+strings.Repeat("a", 64) || w.Newest != fNew { - t.Errorf("shanks' want = %+v, want the fingerprint it runs naming its composition, held back from F", w) + if w := b.wantNow(t, "shanks", "web", "shanks' first want at what it runs", func(w delivery.Want) bool { return w.FP != "" }); w.FP != fOld || w.Composed != "" || !w.Self || w.Newest != fNew { + t.Errorf("shanks' want = %+v, want the fingerprint it runs, the node composing it, held back from F", w) } var neither asks.Condition for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline) && neither.Key == ""; time.Sleep(100 * time.Millisecond) { @@ -451,8 +465,8 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) { // there by itself: the condition is cleared. healthyF, _ := delivery.EncodeHave(delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) b.putJSON(t, delivery.HaveBucket, "ace.web", healthyF) - if w := b.wantNow(t, "g14", "web", "g14's first want at F once the canary runs it healthy", func(w delivery.Want) bool { return w.FP != "" }); w.FP != fNew || w.Composed != "sha256:"+strings.Repeat("c", 64) || w.Newest != "" { - t.Errorf("g14's want = %+v, want F with its composition", w) + if w := b.wantNow(t, "g14", "web", "g14's first want at F once the canary runs it healthy", func(w delivery.Want) bool { return w.FP != "" }); w.FP != fNew || w.Composed != "" || !w.Self || w.Newest != "" { + t.Errorf("g14's want = %+v, want F, the node composing it", w) } cleared := false for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline) && !cleared; time.Sleep(100 * time.Millisecond) { @@ -503,7 +517,7 @@ func TestAHeldFingerprintIsAskedAbout(t *testing.T) { b.putJSON(t, delivery.PresenceBucket, "ace", on) failed, _ := delivery.EncodeHave(delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthFailed, Detail: "connection refused"}) b.putJSON(t, delivery.HaveBucket, "ace.web", failed) - b.putJSON(t, provider.AssignmentsBucket, "ace.web", []byte(`{"node":"ace","module":"web","build":"b-1"}`)) + b.place(t, "ace", "web") b.start(t) hkey, _ := delivery.HeldKey("web", fNew) // 1906/H3: the held record names the machine and keeps the health, when and the node's words. @@ -566,7 +580,7 @@ func TestAFailedDataModuleIsAskedToRestore(t *testing.T) { wantKey, _ := delivery.WantKey("ace", "mail") want, _ := delivery.EncodeWant(delivery.Want{FP: fNew, Composed: "sha256:" + strings.Repeat("c", 64)}) b.putJSON(t, delivery.AssignerWantsBucket, wantKey, want) - b.putJSON(t, provider.AssignmentsBucket, "ace.mail", []byte(`{"node":"ace","module":"mail","build":"b-1"}`)) + b.place(t, "ace", "mail") b.start(t) akey, _ := delivery.RestoreAskKey("ace", "mail", fNew) @@ -618,8 +632,8 @@ func TestTheAssignerReplacedResumesWithNothingLostOrRepeated(t *testing.T) { gkey, _ := delivery.ControllerGrantKey(name, fp) grant, _ := delivery.EncodeControllerGrant(delivery.ControllerGrant{}) b.putJSON(t, delivery.PartGrantsBucket, gkey, grant) - b.compose(t, "ace", name, fp, 'c', delivery.JudgedNeither) - b.putJSON(t, provider.AssignmentsBucket, "ace."+name, []byte(fmt.Sprintf(`{"node":"ace","module":%q,"build":"b-%d"}`, name, i))) + b.compose(t, "ace", 'c') + b.place(t, "ace", name) } count := func() int { keys, err := b.buckets[delivery.AssignerWantsBucket].Keys(b.ctx) @@ -649,58 +663,6 @@ func TestTheAssignerReplacedResumesWithNothingLostOrRepeated(t *testing.T) { } } -// 1906/D2 (ADR 0539 §6), whole on the bus: a want waits for the composition of its module for the node, names its digest once it -// appears, and a new revision of the composition at the same fingerprint is written as a new want; the node's own want -// `want..node` is written once the node's composition exists, and again when its digest changes. -func TestAWantWaitsForItsComposition(t *testing.T) { - b := startBed(t, "") - fNew := delivery.SourcePrefix2 + strings.Repeat("e", 64) - digest := b.manifest(t, "web", `{"module":"web","version":"1"}`) - mod, _ := delivery.EncodeModule(delivery.Module{FP2: fNew, Manifest: digest, Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: delivery.RootPath}) - b.putJSON(t, delivery.ModulesBucket, "web", mod) - built, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, By: "builder.novox.1", Step: "pushed", Manifest: digest}) - bkey, _ := delivery.BuilderBuildKey("web", fNew) - b.putJSON(t, delivery.BuilderBuildsBucket, bkey, built) - gkey, _ := delivery.ControllerGrantKey("web", fNew) - grant, _ := delivery.EncodeControllerGrant(delivery.ControllerGrant{}) - b.putJSON(t, delivery.PartGrantsBucket, gkey, grant) - on, _ := delivery.EncodePresence(delivery.Presence{Online: true}) - b.putJSON(t, delivery.PresenceBucket, "ace", on) - b.putJSON(t, provider.AssignmentsBucket, "ace.web", []byte(`{"node":"ace","module":"web","build":"b-1"}`)) - b.start(t) - wkey, _ := delivery.WantKey("ace", "web") - time.Sleep(time.Second) - if e, err := b.buckets[delivery.AssignerWantsBucket].Get(b.ctx, wkey); err == nil { - t.Fatalf("a want was written before the composition existed: %s", e.Value()) - } - first := b.compose(t, "ace", "web", fNew, 'c', delivery.JudgedNeither) - if w, err := delivery.DecodeWant(b.get(t, delivery.AssignerWantsBucket, wkey)); err != nil || w.FP != fNew || w.Composed != first { - t.Fatalf("the want = %+v, %v; want F naming %s", w, err, first) - } - second := b.compose(t, "ace", "web", fNew, 'd', delivery.JudgedNeither) - for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) { - if e, err := b.buckets[delivery.AssignerWantsBucket].Get(b.ctx, wkey); err == nil { - if w, err := delivery.DecodeWant(e.Value()); err == nil && w.Composed == second { - break - } - } - } - if w, err := delivery.DecodeWant(b.get(t, delivery.AssignerWantsBucket, wkey)); err != nil || w.Composed != second { - t.Fatalf("after a new revision at the same fingerprint the want = %+v, %v; want it naming %s", w, err, second) - } - // the node's own want - nkey, _ := delivery.WantNodeKey("ace") - if e, err := b.buckets[delivery.AssignerWantsBucket].Get(b.ctx, nkey); err == nil { - t.Fatalf("the node's own want was written before its composition: %s", e.Value()) - } - ownKey, _ := delivery.ComposedNodeKey("ace") - own, _ := delivery.EncodeComposedDigest(delivery.ComposedDigest{Digest: "sha256:" + strings.Repeat("a", 64), Judgement: delivery.JudgedNeither}) - b.putJSON(t, delivery.ControllerComposedBucket, ownKey, own) - if w, err := delivery.DecodeNodeWant(b.get(t, delivery.AssignerWantsBucket, nkey)); err != nil || w.Composed != "sha256:"+strings.Repeat("a", 64) { - t.Fatalf("the node's own want = %+v, %v", w, err) - } -} - // fixture is a module web (or the manifest given) at fp on the node ace, which is online: built, granted and assigned. func (b *bed) fixture(t *testing.T, name, fp, manifest string) { t.Helper() @@ -715,7 +677,7 @@ func (b *bed) fixture(t *testing.T, name, fp, manifest string) { b.putJSON(t, delivery.PartGrantsBucket, gkey, grant) on, _ := delivery.EncodePresence(delivery.Presence{Online: true}) b.putJSON(t, delivery.PresenceBucket, "ace", on) - b.putJSON(t, provider.AssignmentsBucket, "ace."+name, []byte(`{"node":"ace","module":"`+name+`","build":"b-1"}`)) + b.place(t, "ace", name) } func (b *bed) putWant(t *testing.T, node, module string, w delivery.Want) { @@ -778,8 +740,8 @@ func TestAReleaseAnswerWritesTheWantAgain(t *testing.T) { b := startBed(t, "") fNew, fOld := delivery.SourcePrefix2+strings.Repeat("b", 64), delivery.SourcePrefix2+strings.Repeat("a", 64) b.fixture(t, "web", fNew, `{"module":"web","version":"1"}`) - b.compose(t, "ace", "web", fNew, 'b', delivery.JudgedNeither) - b.compose(t, "ace", "web", fOld, 'a', delivery.JudgedNeither) + b.compose(t, "ace", 'b') + b.compose(t, "ace", 'a') healthy, _ := delivery.EncodeHave(delivery.Have{FP: fOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) b.putJSON(t, delivery.HaveBucket, "ace.web", healthy) // it ran the older fingerprint healthy before failed, _ := delivery.EncodeHave(delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthFailed, Detail: "connection refused"}) @@ -794,8 +756,8 @@ func TestAReleaseAnswerWritesTheWantAgain(t *testing.T) { b.get(t, delivery.AssignerAsksBucket, hold) b.answer(t, hold, "release") w = b.wantNow(t, "ace", "web", "the held fingerprint's want written again", func(w delivery.Want) bool { return w.FP == fNew }) - if w.Composed != "sha256:"+strings.Repeat("b", 64) || w.Rule != 0 { - t.Errorf("the want = %+v", w) + if w.Composed != "" || !w.Self || w.Rule != 0 { + t.Errorf("the want = %+v, want F by the rules, the node composing it", w) } for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(100 * time.Millisecond) { cleared := false @@ -825,19 +787,19 @@ func (b *bed) askNow(t *testing.T, key string, asking int) delivery.AssignerAsk // 1906/D4 (ADR 0539 §16), whole on the bus: a module held on a node because its backup failed is asked about, and the answer // is written into the node's want; an answer "again" followed by another failure is asked again, and its answer is another want -// value, so the node retries; an old answer is never taken for a new revision at the same fingerprint. +// value, so the node retries. func TestABackupAnswerIsWrittenIntoTheNodesWant(t *testing.T) { b := startBed(t, "") fNew := delivery.SourcePrefix2 + strings.Repeat("b", 64) b.fixture(t, "web", fNew, `{"module":"web","version":"1"}`) - first := b.compose(t, "ace", "web", fNew, 'b', delivery.JudgedNeither) - b.putWant(t, "ace", "web", delivery.Want{FP: fNew, Composed: first}) + b.compose(t, "ace", 'b') + b.putWant(t, "ace", "web", delivery.Want{FP: fNew, Self: true}) // a placed node's want names the build alone held, _ := delivery.EncodeHave(delivery.Have{FP: fNew, Phase: delivery.PhaseHeld, Health: delivery.HealthWaits, Detail: "no space left"}) b.putJSON(t, delivery.HaveBucket, "ace.web", held) b.start(t) akey, _ := delivery.BackupAskKey("ace", "web", fNew) a := b.askNow(t, akey, 0) - if !strings.Contains(a.Ask.Explanation, "no space left") || !strings.Contains(a.Ask.Explanation, first) { + if !strings.Contains(a.Ask.Explanation, "no space left") { t.Fatalf("the backup ask = %+v", a) } // an answer to another ask is no answer to this one @@ -848,7 +810,7 @@ func TestABackupAnswerIsWrittenIntoTheNodesWant(t *testing.T) { } b.answerTo(t, akey, delivery.BackupAgain, a.Ask.ID) w1 := b.wantNow(t, "ace", "web", "the answer in the want", func(w delivery.Want) bool { return w.Backup == delivery.BackupAgain }) - if w1.FP != fNew || w1.Composed != first { + if w1.FP != fNew || w1.Composed != "" || !w1.Self { t.Errorf("the want = %+v", w1) } // the node retries and the backup fails again: a new have value, asked again under the next number @@ -863,91 +825,10 @@ func TestABackupAnswerIsWrittenIntoTheNodesWant(t *testing.T) { if w2.Words == w1.Words { t.Errorf("the second answer left the want as it was: %+v", w2) } - // a new revision at the same fingerprint, whose backup fails as well, is asked, and the old answer is not taken for it - second := b.compose(t, "ace", "web", fNew, 'c', delivery.JudgedNeither) - b.putWant(t, "ace", "web", delivery.Want{FP: fNew, Composed: second}) - a3 := b.askNow(t, akey, 3) - if !strings.Contains(a3.Ask.Explanation, second) { - t.Errorf("the ask for the new revision = %+v", a3) - } - time.Sleep(time.Second) - if w := b.wantNow(t, "ace", "web", "the want", func(delivery.Want) bool { return true }); w.Backup != "" { - t.Errorf("the old answer was taken for the new revision: %+v", w) - } } const recorded = `{"module":"web","version":"1","upgrade":{"policy":"record","why":"a person takes each build"}}` -// 1906/D4 (ADR 0539 §14), whole on the bus: a move of a module of policy record waits for its ask's yes when judged to recreate -// a container, to move data or not to be judgeable, at a new fingerprint and at a new revision of the same alike; one judged -// neither is written at once. -func TestARecordMoveWaitsForTheYesAndANeitherMoveDoesNot(t *testing.T) { - b := startBed(t, "") - fNew, fOld := delivery.SourcePrefix2+strings.Repeat("b", 64), delivery.SourcePrefix2+strings.Repeat("a", 64) - b.fixture(t, "web", fNew, recorded) - old := "sha256:" + strings.Repeat("a", 64) - b.putWant(t, "ace", "web", delivery.Want{FP: fOld, Composed: old}) - nw := b.compose(t, "ace", "web", fNew, 'b', delivery.JudgedRecreates) - b.start(t) - akey, _ := delivery.RecordAskKey("ace", "web", nw) - a, err := delivery.DecodeAssignerAsk(akey, b.get(t, delivery.AssignerAsksBucket, akey)) - if err != nil || !strings.Contains(a.Ask.Explanation, "recreates a container") { - t.Fatalf("the record ask = %+v, %v", a, err) - } - time.Sleep(time.Second) - if w := b.wantNow(t, "ace", "web", "the old want", func(delivery.Want) bool { return true }); w.FP != fOld { - t.Fatalf("the move was written before the yes: %+v", w) - } - b.answer(t, akey, assign.OptionYes) - b.wantNow(t, "ace", "web", "the move after the yes", func(w delivery.Want) bool { return w.FP == fNew && w.Composed == nw }) - // a new revision at the same fingerprint, judged to move data, waits as well - rev := b.compose(t, "ace", "web", fNew, 'c', delivery.JudgedMovesData) - rkey, _ := delivery.RecordAskKey("ace", "web", rev) - b.get(t, delivery.AssignerAsksBucket, rkey) - time.Sleep(time.Second) - if w := b.wantNow(t, "ace", "web", "the want", func(delivery.Want) bool { return true }); w.Composed != nw { - t.Fatalf("the revision was written before the yes: %+v", w) - } - // and a revision judged neither is written at once - free := b.compose(t, "ace", "web", fNew, 'd', delivery.JudgedNeither) - b.wantNow(t, "ace", "web", "a move judged neither written at once", func(w delivery.Want) bool { return w.Composed == free }) -} - -// 1906/D4 (ADR 0539 §7, §15), whole on the bus: the bus's new fingerprint is not written, and a revision of its composition at the -// fingerprint it runs that differs only in its user list is written at once, with the shape the controller gives it: judged -// recreates (the file is mounted), policy record from the bus, user-list-only; no record ask is made. -func TestAUserListRevisionOfTheBusIsWrittenAtOnce(t *testing.T) { - b := startBed(t, "") - fNew, fOld := delivery.SourcePrefix2+strings.Repeat("b", 64), delivery.SourcePrefix2+strings.Repeat("a", 64) - b.fixture(t, "bus", fNew, `{"module":"bus","version":"1","provides":[{"name":"mesh-bus"}]}`) - running, _ := delivery.EncodeHave(delivery.Have{FP: fOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) - b.putJSON(t, delivery.HaveBucket, "ace.bus", running) - b.putWant(t, "ace", "bus", delivery.Want{FP: fOld, Composed: "sha256:" + strings.Repeat("a", 64)}) - b.compose(t, "ace", "bus", fNew, 'b', delivery.JudgedNeither) - b.start(t) - time.Sleep(time.Second) - if w := b.wantNow(t, "ace", "bus", "the want", func(delivery.Want) bool { return true }); w.FP != fOld { - t.Fatalf("the assigner moved the bus to a new fingerprint: %+v", w) - } - // the controller composes the bus at the fingerprint it runs with a new user list - key, _ := delivery.ComposedKey("ace", "bus", fOld) - free := "sha256:" + strings.Repeat("e", 64) - v, err := delivery.EncodeComposedDigest(delivery.ComposedDigest{Digest: free, Judgement: delivery.JudgedRecreates, Against: "sha256:" + strings.Repeat("a", 64), - Words: "the user list file is mounted", UserListOnly: true, Policy: delivery.PolicyRecord, PolicyFrom: "the bus"}) - if err != nil { - t.Fatal(err) - } - b.putJSON(t, delivery.ControllerComposedBucket, key, v) - w := b.wantNow(t, "ace", "bus", "the user-list revision written at once", func(w delivery.Want) bool { return w.Composed == free }) - if w.FP != fOld { - t.Errorf("the want = %+v, want it at the fingerprint the bus runs", w) - } - rkey, _ := delivery.RecordAskKey("ace", "bus", free) - if b.hasKey(delivery.AssignerAsksBucket, rkey) { - t.Errorf("the user-list revision was asked") - } -} - // 1906/H2, whole on the bus: given a fingerprint held after one failed check, when every node that runs the module then runs // it healthy, the assigner lifts the hold by itself: the held record and its ask are taken away, the held condition is // cleared saying why, and the want stays at the fingerprint. @@ -955,7 +836,7 @@ func TestAHoldIsLiftedOnceEveryNodeRunsItHealthy(t *testing.T) { b := startBed(t, "") fNew := delivery.SourcePrefix2 + strings.Repeat("b", 64) b.fixture(t, "web", fNew, `{"module":"web","version":"1"}`) - b.compose(t, "ace", "web", fNew, 'b', delivery.JudgedNeither) + b.compose(t, "ace", 'b') failed, _ := delivery.EncodeHave(delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthFailed, Detail: "connection refused"}) b.putJSON(t, delivery.HaveBucket, "ace.web", failed) b.putWant(t, "ace", "web", delivery.Want{FP: fNew, Composed: "sha256:" + strings.Repeat("b", 64)}) @@ -988,7 +869,7 @@ func TestAHoldWrittenAgainAfterALiftAndARestartIsAskedAgain(t *testing.T) { b := startBed(t, "") fNew := delivery.SourcePrefix2 + strings.Repeat("b", 64) b.fixture(t, "web", fNew, `{"module":"web","version":"1"}`) - b.compose(t, "ace", "web", fNew, 'b', delivery.JudgedNeither) + b.compose(t, "ace", 'b') failed, _ := delivery.EncodeHave(delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthFailed, Detail: "connection refused"}) healthy, _ := delivery.EncodeHave(delivery.Have{FP: fNew, Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) b.putJSON(t, delivery.HaveBucket, "ace.web", failed) diff --git a/modules/mesh-delivery-assigner/internal/assign/wants.go b/modules/mesh-delivery-assigner/internal/assign/wants.go index fe6ade5..460808b 100644 --- a/modules/mesh-delivery-assigner/internal/assign/wants.go +++ b/modules/mesh-delivery-assigner/internal/assign/wants.go @@ -412,7 +412,8 @@ func Wants(m Module, n Nodes, wants map[string]delivery.Want, lastHealthy map[st next.Newest, next.Rule, next.Words = m.FP, rule, words at.FP = target } - if had && !before.Removed && !before.Put && before.FP == target && before.Composed == d.Digest { + // A placed node's want names the build alone: it is the same target while the build is. + if had && !before.Removed && !before.Put && before.FP == target && (before.Composed == d.Digest || (before.Self && m.selfOn(node))) { next.Backup, next.Words = before.Backup, before.Words // the answer to a failed backup stays while its target stays (ADR 0539 §16) } running, ran := n.Haves[node][m.Name] diff --git a/modules/mesh-delivery-assigner/internal/assign/wants_test.go b/modules/mesh-delivery-assigner/internal/assign/wants_test.go index f9326d8..470ec5c 100644 --- a/modules/mesh-delivery-assigner/internal/assign/wants_test.go +++ b/modules/mesh-delivery-assigner/internal/assign/wants_test.go @@ -815,6 +815,13 @@ func TestTheBackupAnswerStaysInTheWantWhileItsTargetStays(t *testing.T) { if w, ok := Wants(m, n, running, nil).Wants["ace"]; ok { t.Errorf("the answer was erased: %+v", w) } + // A placed node composes its own: its want names the build alone, and the answer stays in it all the same (the digest its + // have names is not in the want, so a want kept for its digest would erase the answer at every pass). + m.Self = func(string) bool { return true } + placed := map[string]delivery.Want{"ace": {FP: fNew, Self: true, Backup: delivery.BackupSkip, Words: BackupAnswerWords("skip", 0)}} + if w, ok := Wants(m, n, placed, nil).Wants["ace"]; ok { + t.Errorf("the answer in a placed node's want was erased: %+v", w) + } } // 1906/D4 (ADR 0539 §19): a hold released by the operator is not held again by a health check that failed before, and the