The way back: a node whose placement key is deleted returns to the controller's compositions without a restart, says so in its journal and its have, and answers such a candidate as returning; pin mesh-sdk 472d5f3 (issue 1126, the seed's revert)

The seed's revert (mesh-catalog #526) was refused on 'would stop running node',
while a self-composing node would have refused its placement's absence, kept
what runs and marked every have failed until a restart. endWhenUnplaced ends the
agent's run when the key goes, so it begins again on the controller's
compositions, as at a start without a key; the composer refuses nothing while
unplaced; the node's have says whose compositions it applies (Composes); the
shadow answers a deleted placement Returns, verdict same.
This commit is contained in:
2026-10-27 23:25:59 +01:00
parent d00926e089
commit 4e8fdfdf04
8 changed files with 145 additions and 25 deletions
+42 -7
View File
@@ -544,9 +544,14 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, sess
session.NoteCandidates(ctx, answers)
})
go shadow.Keep(linked, link.Backoff)
if composer := selfComposer(linked, session, node, say, hooks); composer != nil {
if composer, why := selfComposer(linked, session, node, say, hooks); composer != nil {
env.Composed, env.SelfComposed, env.Trusted, env.Policy = composer, true, nil, composer.Policy
session.NoteComposes(linked, delivery.ComposesOwn, why)
// And once the catalogue no longer places it, the agent begins again on the controller's compositions: the
// placement key deleted ends this run, the way back (issue 1126, the seed's revert).
go endWhenUnplaced(run, session, node, stopRun, say)
} else {
session.NoteComposes(linked, delivery.ComposesController, why)
// And once the catalogue places it, the agent begins again composing its own: the placement key appearing ends
// this run, so a node does not wait for a restart to follow the seed of its placement (ADR 0549 §4).
go endWhenPlaced(run, session, node, stopRun, say)
@@ -843,23 +848,24 @@ type composerHooks struct {
held func(module string) []string
}
func selfComposer(ctx context.Context, session *link.Session, node string, say func(string), hooks composerHooks) *selfcompose.Composer {
func selfComposer(ctx context.Context, session *link.Session, node string, say func(string), hooks composerHooks) (*selfcompose.Composer, string) {
key, err := delivery.PlacementKey(node)
if err != nil {
say("this node applies the controller's compositions: " + err.Error())
return nil
return nil, err.Error()
}
got, err := session.KVHistory(ctx, delivery.PlacementBucket, key)
if err != nil || len(got) == 0 || got[len(got)-1].Deleted {
why := "the catalogue does not place it yet"
why := "the catalogue does not place it"
if err != nil {
why = "its placement cannot be read: " + err.Error()
}
say("this node applies the controller's compositions: " + why)
return nil
return nil, why
}
say("this node composes its own modules: the catalogue places it (" + key + ")")
return selfcompose.New(composerEnv(session, node, say, hooks))
why := "the catalogue places it (" + key + ")"
say("this node composes its own modules: " + why)
return selfcompose.New(composerEnv(session, node, say, hooks)), why
}
// composerEnv is what the node's composers read of the bus, the vault and the agent: its own composer and its answer
@@ -889,6 +895,35 @@ func composerEnv(session *link.Session, node string, say func(string), hooks com
Running: hooks.running, Unseal: hooks.unseal, Refused: hooks.refused, Held: hooks.held}
}
// endWhenUnplaced calls stop once this node's placement key is deleted from the indexer's placement (after the watch
// caught up, or gone already), so the agent begins again and applies the controller's compositions, as a node the
// catalogue does not place does from its start: the way back (issue 1126, the seed's revert). A watch that cannot be
// made is said; the node then returns at its next start.
func endWhenUnplaced(ctx context.Context, session *link.Session, node string, stop func(), say link.Announce) {
key, err := delivery.PlacementKey(node)
if err != nil {
return
}
updates, err := bucketWatch{session, delivery.PlacementBucket}.Watch(ctx, key)
if err != nil {
say("whether the catalogue still places this node is not followed (" + err.Error() + "): it returns to the controller's compositions at its next start")
return
}
placed := false
for u := range updates {
switch {
case u.Caught && placed:
continue
case u.Caught || u.Deleted || len(u.Value) == 0:
say("the catalogue no longer places this node (" + key + "): the node agent begins again, applying the controller's compositions")
stop()
return
default:
placed = true
}
}
}
// endWhenPlaced calls stop once this node's placement key appears in the indexer's placement (a value after the watch
// caught up, or one already there), so the agent begins again and composes its own. A watch that cannot be made is said;
// the node then switches at its next start.
+47 -1
View File
@@ -402,8 +402,54 @@ func TestNatsANodeThePlacementNamesComposesAndAppliesItsOwn(t *testing.T) {
placement, resolved, candidates := st.placement, st.resolved, st.candidates
hkey, place, fp := st.hkey, st.place, st.fp
within(t, 5*time.Second, "the node did not compose and apply its own module once placed", func() bool { return b.file("web") == "greeting=bonjour\n" })
_ = placement
h8rest(t, b, resolved, candidates, hkey, place, fp)
h8back(t, b, placement, candidates, hkey)
}
// h8back is the way back (issue 1126, the seed's revert): a candidate deleting the node's placement is answered as
// returning to the controller's compositions, and the placement key deleted returns the agent to them without a
// restart, said in the journal and the node's have, keeping what runs.
func h8back(t *testing.T, b *itBed, placement, candidates nats.KeyValue, hkey string) {
t.Helper()
node := func() delivery.HaveNode {
e, err := b.haves.Get(hkey)
if err != nil {
return delivery.HaveNode{}
}
n, _ := delivery.DecodeHaveNode(e.Value())
return n
}
if n := node(); n.Composes != delivery.ComposesOwn {
t.Errorf("a node composing its own says it composes %q", n.Composes)
}
craw, err := delivery.EncodePlacementCandidate(delivery.PlacementCandidate{Group: "revert/x",
Pull: delivery.Head{Repository: delivery.CatalogueRepository, Number: 2, Head: "h2"}})
if err != nil {
t.Fatal(err)
}
ckey, _ := delivery.PlacementCandidateKey("revert/x", itNode)
if _, err := candidates.Put(ckey, craw); err != nil {
t.Fatal(err)
}
within(t, 10*time.Second, "the node did not answer a candidate deleting its placement as returning", func() bool {
a := node().Candidates["revert/x"]
return a.Head == "h2" && a.Returns == delivery.ReturnsToController && a.Verdict == delivery.CandidateSame &&
len(a.Removed) == 0
})
ran := b.have("web").Composed
pkey, _ := delivery.PlacementKey(itNode)
if err := placement.Delete(pkey); err != nil {
t.Fatal(err)
}
within(t, 15*time.Second, "the node did not return to the controller's compositions when its placement went", func() bool {
b.mu.Lock()
said := strings.Join(b.said, "\n")
b.mu.Unlock()
return strings.Contains(said, "the catalogue no longer places this node") && node().Composes == delivery.ComposesController
})
if h := b.have("web"); h.Composed != ran || h.Phase == delivery.PhaseFailed || b.file("web") != "greeting=salut\n" {
t.Errorf("returning to the controller's compositions moved what runs: %+v, %q", h, b.file("web"))
}
}
// h8state is what h8 set up that the rest of H8 writes to.