Apply only the node's own compositions, never the controller's
mesh/delivery merged: source.main moved to novox/mesh-host@84737f4cb26d
mesh/check being carried to 38d666f22390 on 84737f4cb26d | from 14a12cc7b6f9

The controller ended by its unassignment, so a node that falls back to the
compositions in its bucket follows what nobody writes (issue 1126, A7). The
node agent now always runs its own composer: without a placement key it
composes nothing and keeps what runs, a placement it cannot watch stops the
agent and is said, and a candidate taking its placement away is refused
instead of answered as a return to the controller. The start-time placement
read, the end-when-placed watchers and the returning answer go, with their
tests and the three integration tests of the controller-composed path.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
2026-11-02 10:40:50 +01:00
parent 5cfc4ff4a7
commit 38d666f223
5 changed files with 61 additions and 621 deletions
+17 -94
View File
@@ -1162,9 +1162,11 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) {
return a.Verdict == delivery.CandidateSame && a.Returns == "" && a.Revision == 8 && len(a.Removed) == 0 &&
fmt.Sprint(a.Same) == "[lemurs node]"
},
// Issue 1126, A7: a candidate deleting the node's placement is refused, never answered as a return to the
// controller's compositions: no composition made elsewhere is applied, so the node would compose nothing.
"deletes": func(a delivery.CandidateAnswer) bool {
return a.Verdict == delivery.CandidateSame && a.Returns == delivery.ReturnsToController && a.Revision == 7 &&
len(a.Removed) == 0 && fmt.Sprint(a.Same) == "[lemurs node]"
return a.Verdict == delivery.CandidateRefused && a.Returns == "" && a.Revision == 7 &&
strings.Contains(a.Refused, "composes nothing")
},
} {
if !want(answers[group]) {
@@ -1286,61 +1288,21 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) {
return !ok && len(a) == 4
})
// Whose compositions a node applies at a start (review of the shadow, round 1): only a read that succeeds chooses; a
// failing read is tried again, said once, and chooses neither, even when the link ends.
for name, row := range map[string]struct {
answers []error // per try; nil then the value
value []byte
end bool
placed bool
tries int
quiet bool // read and known: nothing to say
// switched: the node's have says it composed its own before (it never falls back on a failed read).
switched bool
}{
"switched: placed after two failures": {answers: []error{fmt.Errorf("nats: timeout"), fmt.Errorf("nats: timeout")}, value: []byte(`{}`), placed: true, tries: 3, switched: true},
"never switched: a timeout is not placed, said once": {answers: []error{fmt.Errorf("nats: timeout")}, placed: false, tries: 1},
"no key": {placed: false, tries: 2}, // its node key, then its legacy key (research 072 row 37)
"no placement state yet": {answers: []error{fmt.Errorf("the bucket %s: %w", delivery.PlacementBucket, nats.ErrBucketNotFound)}, placed: false, tries: 1, quiet: true},
"switched: failing until the link ends": {answers: []error{fmt.Errorf("jetstream not ready"), fmt.Errorf("jetstream not ready"),
fmt.Errorf("jetstream not ready")}, end: true, tries: 3, switched: true},
} {
lctx, lcancel := context.WithCancel(ctx)
tries := 0
var said []string
get := func(context.Context, string, string) ([]byte, error) {
tries++
if tries <= len(row.answers) {
if row.end && tries == len(row.answers) {
lcancel()
}
return nil, row.answers[tries-1]
// Issue 1126, A7 AC1: a placement that cannot be read composes nothing and says why; the node never applies a
// composition made elsewhere instead. (A placement read without the node's key composes nothing either: above.)
{
refused := newFakeBus()
env := refused.env()
watch := env.Watch
env.Watch = func(ctx context.Context, bucket, pattern string) (<-chan agent.Update, error) {
if bucket == delivery.PlacementBucket {
return nil, fmt.Errorf("nats: Permissions Violation for Subscription to %s", bucket)
}
return row.value, nil
return watch(ctx, bucket, pattern)
}
placed, err := Placed(lctx, get, "n1", func(int) time.Duration { return time.Millisecond }, func(s string) { said = append(said, s) }, row.switched)
lcancel()
if placed != row.placed || tries != row.tries || (err != nil) != row.end || (len(row.answers) > 0 && !row.quiet) != (len(said) == 1) {
t.Errorf("%s: placed %v, err %v after %d reads, said %q", name, placed, err, tries, said)
}
}
// Whether the node composed its own before is read from its own have: own says yes, the controller's or no have says
// no, and a have that cannot be read says yes (the safe side: a node waits and does not fall back).
for name, row := range map[string]struct {
raw []byte
err error
want bool
}{
"own": {raw: []byte(`{"v":1,"composes":"own"}`), want: true},
"the controller's": {raw: []byte(`{"v":1,"composes":"controller"}`), want: false},
"no have yet": {want: false},
"a have that cannot be read": {err: fmt.Errorf("nats: timeout"), want: true},
"a have that is no have": {raw: []byte(`{`), want: true},
} {
get := func(context.Context, string, string) ([]byte, error) { return row.raw, row.err }
if got := ComposedItsOwn(ctx, get, "n1"); got != row.want {
t.Errorf("composed its own, %s: %v, want %v", name, got, row.want)
out, err := New(env).Watch(ctx, "")
if out != nil || err == nil || !strings.Contains(err.Error(), delivery.PlacementBucket) || !strings.Contains(err.Error(), "cannot be watched") {
t.Errorf("an unreadable placement: composed %v, said %v", out != nil, err)
}
}
@@ -1450,45 +1412,6 @@ func TestTheNodeComposesItsWantsFromTheBus(t *testing.T) {
}
}
// The way back, row by row (review of the shadow, round 1): the controller's compositions at n1's wants compared
// with what runs; one differing is answered so; one the controller holds none of refuses, without returning.
var runsList []delivery.Composed
for _, c := range running {
runsList = append(runsList, c)
}
lemursKey, _ = delivery.ComposedKey("n1", "lemurs", fp)
changed := running[lemursKey]
changed.Resources = []json.RawMessage{json.RawMessage(`{"id":"lemurs.service","type":"service","module":"lemurs","unit":"lemurs.service","state":"stopped","boot":"disabled"}`)}
changedRaw, _ := delivery.EncodeComposed(changed)
for name, row := range map[string]struct {
lemurs []byte
check func(delivery.CandidateAnswer) bool
}{
"the controller's differs": {changedRaw, func(a delivery.CandidateAnswer) bool {
return a.Verdict == delivery.CandidateDiffers && a.Returns == delivery.ReturnsToController &&
len(a.Differs) == 1 && a.Differs[0].Module == "lemurs" && fmt.Sprint(a.Same) == "[node]"
}},
"the controller holds none": {nil, func(a delivery.CandidateAnswer) bool {
return a.Verdict == delivery.CandidateRefused && a.Returns == "" && strings.Contains(a.Refused, "lemurs: the controller holds no composition")
}},
} {
get := func(_ context.Context, bucket, key string) ([]byte, error) {
if key == lemursKey {
return row.lemurs, nil
}
readOnly.mu.Lock()
defer readOnly.mu.Unlock()
return readOnly.kv[bucket+"/"+key], nil
}
a := returning(ctx, get, "n1", map[string]delivery.Want{"lemurs": {FP: fp}, agent.NodeModule: {}}, runsList, "pro", 1)
if !row.check(a) {
t.Errorf("%s: %+v", name, a)
}
if _, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"pro": a}}); err != nil {
t.Errorf("%s: the answer is not one a have holds: %v", name, err)
}
}
// Issue 1126, the seed's review: a module n1 runs inside its own composition (nats, at a build of the old form) is
// composed there again, not at the trunk's build as one added; lemurs at the build it runs, keeper (added) at the
// trunk's.