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 7d7f8c8..2f81833 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "context" "encoding/json" "errors" @@ -566,6 +567,11 @@ func (p *passer) put(ctx context.Context, bucket, key string, encode func() ([]b p.logf("%s %s is not written: %v", bucket, key, err) return } + // A value the bucket already holds is not put again: every put is a change its watchers hear, and a want put unchanged + // made its node compose again (issue 774, 2026-10-31: novox composed every 8 s, two asks of the vault each time). + if held := p.w.value(bucket, key); held != nil && bytes.Equal(held, value) { + return + } if _, err := p.kvs[bucket].Put(ctx, key, value); err != nil && !errors.Is(err, context.Canceled) { p.logf("%s %s could not be written: %v", bucket, key, err) return @@ -645,6 +651,14 @@ func (p *passer) staleAsk(key string, trunk map[string]delivery.Module) bool { if !on { return true } + // A node that composes its own asks about its own composition, which its have names as the one it waits on (yeses): that + // ask stands while the have waits on it, whatever the controller composes (issue 774, 2026-10-31: novox's asks of + // postgres, keycloak and mongodb were taken away and made again under a new id every pass, so no answer could count). + if hkey, err := delivery.HaveKey(k.Node, k.Module); err == nil { + if h, err := delivery.DecodeHave(p.w.value(delivery.HaveBucket, hkey)); err == nil && h.Phase == delivery.PhaseWaitingYes && h.Waits == k.Composed { + return false + } + } // The assigner asks about a composition at more than the trunk fingerprint: a node held back by the rules is asked about the // revised composition of the fingerprint it runs (ADR 0545), and a node's first want at the older fingerprint it runs. So a // record ask is stale only when its digest is the composition of none of the fingerprints the node can still want (the 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 0cba96b..38f40b9 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 @@ -1,6 +1,7 @@ package main import ( + "bytes" "context" "encoding/json" "errors" @@ -26,10 +27,12 @@ import ( type memKV struct { jetstream.KeyValue values map[string][]byte + puts int // how many puts reached the bucket } func (m *memKV) Put(_ context.Context, key string, value []byte) (uint64, error) { m.values[key] = value + m.puts++ return uint64(len(m.values)), nil } @@ -952,6 +955,30 @@ func aPlacedNodesWantsNameTheBuildAloneAndItsYesComesFromItsHave(t *testing.T) { if _, there := asksKV.values[akey]; !there { t.Fatal("a have waiting for a yes raised no record ask") } + // Issue 774, 2026-10-31: while the controller still composes the placed node's module (its lease not handed away), its + // digests are not the one the node's have waits on; the ask stays, and a pass that changes nothing puts nothing: on novox + // the asks of postgres, keycloak and mongodb were taken away and made again every 8 s, and a want put unchanged each + // pass made the node compose again each time. + for _, fp := range []string{fpTest, fpOld} { + ck, _ := delivery.ComposedKey("g14", "web", fp) + cv, _ := delivery.EncodeComposedDigest(delivery.ComposedDigest{Digest: "sha256:" + strings.Repeat("c", 64), Judgement: delivery.JudgedUnchanged}) + put(delivery.ControllerComposedBucket, ck, cv) + } + asked := asksKV.values[akey] + p.wants(context.Background()) + if !bytes.Equal(asksKV.values[akey], asked) { + t.Fatalf("the record ask the node's have waits on was taken away or made again: %q", asksKV.values[akey]) + } + wantPuts, askPuts := wantsKV.puts, asksKV.puts + p.wants(context.Background()) + if wantsKV.puts != wantPuts || asksKV.puts != askPuts { + t.Fatalf("a pass that changed nothing put %d wants and %d asks", wantsKV.puts-wantPuts, asksKV.puts-askPuts) + } + // Whichever rule asks it, a want put with the value the bucket holds reaches no bucket: each put is a change its node hears. + p.put(context.Background(), delivery.AssignerWantsBucket, wkey, func() ([]byte, error) { return wantsKV.values[wkey], nil }) + if wantsKV.puts != wantPuts { + t.Fatalf("a want put unchanged reached the bucket") + } a, err := delivery.DecodeAssignerAsk(akey, asksKV.values[akey]) if err != nil { t.Fatal(err)