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) + } +}