From 43f2d6020e11acd0a1d3c1d32014d8c1694c4b88 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 13:38:40 +0100 Subject: [PATCH] Write the bus's user list into a have no apply here says, merged over the key's value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the bus node the bus module has no want and has left the declaration, so no pass of this engine held its fact and the follower's user list was kept in memory and never written: the vault and the grants part read no hashes and no key. A module's user list now reaches its key on every pass, merged into what the key says with every other field kept; a missing key is said once, never made up (ADR 0547 §8). --- internal/link/have.go | 79 +++++++++++++++++++++++++++++++++++++- internal/link/have_test.go | 54 ++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) diff --git a/internal/link/have.go b/internal/link/have.go index 36ee178..fa923fb 100644 --- a/internal/link/have.go +++ b/internal/link/have.go @@ -51,6 +51,8 @@ type haveState struct { // userList is what the bus node's engine last wrote of the bus's user list, by the module whose have says it (novox/hq // ADR 0547 §8); kept apart from the module's fact, which every apply replaces. userList map[string]delivery.UserListWritten + // userListNoKey is each module whose user list was said to have no have key to ride on, so it is said once. + userListNoKey map[string]bool // unjudged is each module whose composition moved (an apply began, or the fact names another build) and that no look // has judged since it stopped moving: its health is not yet judged, never the last look's of what ran before. unjudged map[string]bool @@ -283,7 +285,7 @@ func (q *Session) writeHaves(ctx context.Context, bus Bus) { s := &q.haves s.mu.Lock() defer s.mu.Unlock() - if s.modules == nil && s.node == nil && len(s.removed) == 0 { + if s.modules == nil && s.node == nil && len(s.removed) == 0 && len(s.userList) == 0 { return } node := q.Membership.Node @@ -340,6 +342,9 @@ func (q *Session) writeHaves(ctx context.Context, bus Bus) { if !q.writeRemoved(write, bucket, node) { return } + if !q.writeUserListsAlone(write, bucket, node) { + return + } if s.node != nil { key, err := delivery.HaveNodeKey(node) if err == nil { @@ -455,6 +460,78 @@ func (q *Session) writeRemoved(ctx context.Context, b HaveBucket, node string) b return true } +// writeUserListsAlone writes the user list of each module this node-engine holds no fact of (no apply here said its have) +// into that module's key as the bucket holds it, keeping every other field the key says (novox/hq ADR 0547 §8). The have +// of a module is one value with parts from more than one source: the apply's (fingerprint, digest, build, phase, health) +// and the user-list follower's. Written only on the pass over the modules this engine applied, the follower's part was +// lost whenever the bus module's apply was not this engine's to say (2026-10-26 on the bus node: the bus module had no +// want and had left the declaration, so its key kept an older engine's value and the vault read no hashes in it). A key +// that does not exist is not created: a have says a phase, and nothing here knows it; that is said once. False when the +// pass should stop. The caller holds the lock of the haves. +func (q *Session) writeUserListsAlone(ctx context.Context, b HaveBucket, node string) bool { + s := &q.haves + alone := make([]string, 0, len(s.userList)) + for module := range s.userList { + _, held := s.modules[module] + _, gone := s.removed[module] + if !held && !gone { + alone = append(alone, module) + } + } + sort.Strings(alone) + for _, module := range alone { + key, err := delivery.HaveKey(node, module) + if err != nil { + q.sayHave(fmt.Sprintf("module %q has no key in the have bucket: %v", module, err)) + continue + } + if ctx.Err() != nil { + q.sayHave(fmt.Sprintf("the time for writing the have bucket ran out before %s; the next apply or look writes it", key)) + return false + } + current, revision, err := b.Get(ctx, key) + switch { + case errors.Is(err, ErrHaveAbsent): + if !s.userListNoKey[module] { + if s.userListNoKey == nil { + s.userListNoKey = map[string]bool{} + } + s.userListNoKey[module] = true + q.sayHave(fmt.Sprintf("the bus's user list written for %s is in no have: %s does not exist and no apply on "+ + "this node says %s's phase, so it is written once one does", module, key, module)) + } + continue + case err != nil: + q.sayHave(fmt.Sprintf("could not read %s in the have bucket, and wrote nothing more there: %v", key, err)) + q.haveFailed(err) + return false + } + delete(s.userListNoKey, module) + h, err := delivery.DecodeHave(current) + if err != nil { + q.sayHave(fmt.Sprintf("the bus's user list is not written in %s: its value cannot be read: %v", key, err)) + continue + } + w := s.userList[module] + next := h + next.UserList = &w + if delivery.SameHave(h, next) { + continue + } + raw, err := delivery.EncodeHave(next) + if err != nil { + q.sayHave(fmt.Sprintf("cannot encode %s: %v", key, err)) + continue + } + if err := b.Set(ctx, key, raw, revision); err != nil { + q.sayHave(fmt.Sprintf("could not write %s in the have bucket; the next apply or look writes it again: %v", key, err)) + q.haveFailed(err) + return false + } + } + return true +} + // putHave reads a key and writes it unless same says it already holds the value. False when the pass should stop: the // bucket failed, the error is said once, and the rest wait for the next pass. func (q *Session) putHave(ctx context.Context, b HaveBucket, key string, value []byte, same func([]byte) bool) bool { diff --git a/internal/link/have_test.go b/internal/link/have_test.go index a99fa11..6b36223 100644 --- a/internal/link/have_test.go +++ b/internal/link/have_test.go @@ -660,3 +660,57 @@ func TestADeclarationReportMovesAHealthAndARemovalForgetsIt(t *testing.T) { t.Fatal("a removed module is still marked unjudged") } } + +// ADR 0547 §8, 2026-10-26 on the bus node: the bus's user list of a module this engine says no have of (no want, and no +// longer in the declaration) still reaches the module's key, merged into what the key says, every other field kept; a +// key that does not exist is not made up and is said once; a writer that drops the list again is written over. +func TestAUserListReachesTheHaveOfAModuleNoApplyHereSays(t *testing.T) { + l := newHaveLink() + older := delivery.Have{V: delivery.Version, Build: "build-1", Phase: delivery.PhaseRunning, Health: delivery.HealthOK} + l.put(t, "n1.nats", older) + q, said := aHaveQueue(l) + dig := "sha256:" + strings.Repeat("c", 64) + w := delivery.UserListWritten{Source: "n1.nats", Revision: 7, Wanted: dig, Digest: dig, Users: 58, + At: time.Date(2026, 10, 26, 12, 26, 51, 0, time.UTC)} + q.NoteUserList(context.Background(), "nats", w) + got := l.have(t, "n1.nats") + if got.UserList == nil || got.UserList.Users != 58 || got.UserList.Digest != dig { + t.Fatalf("the user list is not in the have: %+v", got) + } + if got.Build != "build-1" || got.Phase != delivery.PhaseRunning || got.Health != delivery.HealthOK { + t.Fatalf("the merge lost the key's own fields: %+v", got) + } + rev := l.kv["n1.nats"].rev + q.NoteUserList(context.Background(), "nats", w) + if l.kv["n1.nats"].rev != rev { + t.Errorf("the same list was written again, to revision %d", l.kv["n1.nats"].rev) + } + + // Another writer replaces the value without the list: the next pass merges it back. + l.put(t, "n1.nats", older) + q.SayFacts(context.Background(), Report{}) + if got := l.have(t, "n1.nats"); got.UserList == nil || got.Build != "build-1" { + t.Fatalf("a value written without the list kept it out: %+v", got) + } + + // No key at all: nothing is created, said once; the module's first have carries the list. + q.NoteUserList(context.Background(), "other", w) + q.NoteUserList(context.Background(), "other", w) + if _, ok := l.kv["n1.other"]; ok { + t.Fatalf("a have was made up for a module no apply says") + } + n := 0 + for _, s := range *said { + if strings.Contains(s, "is in no have") { + n++ + } + } + if n != 1 { + t.Errorf("the missing key was said %d times: %q", n, *said) + } + fp := "src2:" + strings.Repeat("a", 64) + q.NoteModule(context.Background(), ModuleFact{Module: "other", FP: fp, Composed: dig}) + if got := l.have(t, "n1.other"); got.UserList == nil || got.FP != fp { + t.Fatalf("the first have of the module lost the list: %+v", got) + } +}