The node says why it composes nothing of a want, and hands again what its agent waits for (issue 1966)
Every pass names each wanted module it made no composition of, with why (Pass.Unmade, the verb compose, one journal line per change); a want not taken is said; the agent says once per want that it waits for the composer and why, and tells it, so a composition the composer believed handed is handed again. Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
This commit is contained in:
@@ -154,6 +154,12 @@ type Composer struct {
|
||||
// the data items after it (publishData) do not read each placed module's build again over the bus (ADR 0577).
|
||||
builds map[string]builds.BuilderBuild
|
||||
handed map[string]string // composed key to the digest handed
|
||||
// notTaken is, by module, why a want of this node's was not taken (it could not be decoded, or names no fingerprint).
|
||||
notTaken map[string]string
|
||||
// modulesRev is the revision of the last update of the indexer's modules taken: where the picture of them stands.
|
||||
modulesRev uint64
|
||||
// rehand asks for a pass that hands again what the agent waits for (Waiting).
|
||||
rehand chan struct{}
|
||||
// sealedOwn is each own secret's sealed value as first handed, by module/name and generation: a value sealed again
|
||||
// is other bytes, so the one first had is kept while the generation stands, and the composition stays the same.
|
||||
sealedOwn map[string]ownSealed
|
||||
@@ -238,6 +244,11 @@ type Pass struct {
|
||||
// holds is what holds each module Held names by its manifest's verdict or its decoder, with the build it would be
|
||||
// composed at (frombus.Scope.Holds), for LastHeld's words.
|
||||
holds map[string]compose.Hold
|
||||
// Unmade is each module wanted that the pass composed no composition of at its want's fp, with why: the want not
|
||||
// taken, the module not assigned here, no manifest in the composer's picture of the indexer's modules (and where that
|
||||
// picture stands), or what the composition left it out for. A module held alone is in Held, not here. Said once per
|
||||
// change as a journal line, so a want that waits for the composer says what for.
|
||||
Unmade map[string]string `json:"unmade,omitempty"`
|
||||
// Handed is how many compositions the pass handed the agent that differ from what it handed before.
|
||||
Handed int `json:"handed"`
|
||||
// Again is when the node composes again on its own, after a seat or a read that did not answer; zero when it does
|
||||
@@ -340,7 +351,7 @@ func New(env Env) *Composer {
|
||||
}
|
||||
return &Composer{env: env, wants: map[string]meshwants.WantsByWantNodeModuleValue{}, assignments: map[string]meshmoduleassigner.Assignments{}, assignmentKeys: meshmoduleassignerclient.NewKeys(),
|
||||
records: map[string]nodeengine.NodeRecord{}, inOwn: map[string]bool{}, modules: map[string]meshcatalog.ModulesByModuleValue{}, values: map[string]map[string]any{},
|
||||
manifests: map[string][]byte{}, archives: map[string][]byte{}, builds: map[string]builds.BuilderBuild{}, handed: map[string]string{}, sealedOwn: map[string]ownSealed{},
|
||||
manifests: map[string][]byte{}, archives: map[string][]byte{}, builds: map[string]builds.BuilderBuild{}, handed: map[string]string{}, notTaken: map[string]string{}, rehand: make(chan struct{}, 1), sealedOwn: map[string]ownSealed{},
|
||||
pkgs: map[string]compositions.Composed{}, asked: make(chan chan Pass, 8)}
|
||||
}
|
||||
|
||||
@@ -544,6 +555,8 @@ func (c *Composer) Watch(ctx context.Context, _ string) (<-chan agent.Update, er
|
||||
again = nil
|
||||
dirty = true
|
||||
c.env.Say(c.againLine())
|
||||
case <-c.rehand:
|
||||
dirty = true
|
||||
case reply := <-c.asked:
|
||||
// Asked by hand (the verb compose): composed now; the growing bound is not restarted, so a pass that
|
||||
// fails again waits longer, not less.
|
||||
@@ -699,6 +712,13 @@ func (c *Composer) entry(bucket, key string) (v any, has, known bool) {
|
||||
}
|
||||
|
||||
func (c *Composer) takeWant(u agent.Update) {
|
||||
if line := c.takeWantLocked(u); line != "" {
|
||||
c.sayOnceEach(line)
|
||||
}
|
||||
}
|
||||
|
||||
// takeWantLocked takes the want in and answers a line to say when it is a want of this node's that is not taken.
|
||||
func (c *Composer) takeWantLocked(u agent.Update) string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if _, ok := meshwants.ParseWantsByWantNodeNodeKey(u.Key); ok {
|
||||
@@ -707,18 +727,30 @@ func (c *Composer) takeWant(u agent.Update) {
|
||||
} else {
|
||||
c.wants[agent.NodeModule] = meshwants.WantsByWantNodeModuleValue{}
|
||||
}
|
||||
return
|
||||
return ""
|
||||
}
|
||||
node, module, ok := meshwants.ParseWantsByWantNodeModuleKey(u.Key)
|
||||
if !ok || node != c.env.Node {
|
||||
return
|
||||
return ""
|
||||
}
|
||||
delete(c.notTaken, module)
|
||||
w, err := meshwants.DecodeWantsByWantNodeModuleValue(u.Value)
|
||||
if u.Deleted || err != nil || w.Removed || w.Fp == "" {
|
||||
delete(c.wants, module)
|
||||
return
|
||||
switch {
|
||||
case u.Deleted || (err == nil && w.Removed):
|
||||
case err != nil:
|
||||
c.notTaken[module] = "its want cannot be decoded: " + err.Error()
|
||||
default:
|
||||
c.notTaken[module] = "its want names no fingerprint"
|
||||
}
|
||||
if why := c.notTaken[module]; why != "" {
|
||||
return "the node composes nothing of " + module + ": " + why
|
||||
}
|
||||
return ""
|
||||
}
|
||||
c.wants[module] = w
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *Composer) takeAssignments(u agent.Update) {
|
||||
@@ -844,6 +876,7 @@ func (c *Composer) takeModule(u agent.Update) {
|
||||
return
|
||||
}
|
||||
c.modules[name] = m
|
||||
c.modulesRev = u.Revision
|
||||
}
|
||||
|
||||
func (c *Composer) takeValue(u agent.Update) {
|
||||
@@ -946,6 +979,7 @@ func (c *Composer) compose(ctx context.Context) []agent.Update {
|
||||
keptRuns := map[string][]string{}
|
||||
var keptMu sync.Mutex
|
||||
var holds map[string]compose.Hold
|
||||
fromManifest := map[string]string{} // by module, why it is composed from the indexer's manifest and not a build
|
||||
scope := frombus.Scope{Node: c.env.Node, KeptAt: kept, InOwn: inOwn, Running: running, Kept: func(module, words string) {
|
||||
keptMu.Lock()
|
||||
defer keptMu.Unlock()
|
||||
@@ -953,12 +987,16 @@ func (c *Composer) compose(ctx context.Context) []agent.Update {
|
||||
keptRuns[module] = append(keptRuns[module], words)
|
||||
}
|
||||
c.sayOnceEach(words)
|
||||
}, Holds: func(h map[string]compose.Hold) { holds = h }}
|
||||
}, Holds: func(h map[string]compose.Hold) { holds = h }, Unmade: func(module, why string) {
|
||||
keptMu.Lock()
|
||||
defer keptMu.Unlock()
|
||||
fromManifest[module] = why
|
||||
}}
|
||||
c.mu.Lock()
|
||||
c.dataScope = frombus.Scope{Node: c.env.Node, KeptAt: kept, InOwn: inOwn} // the data items are worked out as composed
|
||||
c.mu.Unlock()
|
||||
missed := c.missed.Load()
|
||||
compositions, _, held, err := frombus.Compose(ctx, r, c.env.Mesh, scope, fps)
|
||||
compositions, skipped, held, err := frombus.Compose(ctx, r, c.env.Mesh, scope, fps)
|
||||
err = c.missedIn(missed, err)
|
||||
// A bus that does not answer for a moment (a reload of its accounts pauses it a few seconds) is no refusal: the pass is
|
||||
// tried again after a growing wait before anything is marked (issue 1126).
|
||||
@@ -970,7 +1008,7 @@ func (c *Composer) compose(ctx context.Context) []agent.Update {
|
||||
}
|
||||
if ctx.Err() == nil {
|
||||
missed = c.missed.Load()
|
||||
compositions, _, held, err = frombus.Compose(ctx, r, c.env.Mesh, scope, fps)
|
||||
compositions, skipped, held, err = frombus.Compose(ctx, r, c.env.Mesh, scope, fps)
|
||||
err = c.missedIn(missed, err)
|
||||
}
|
||||
}
|
||||
@@ -1013,6 +1051,8 @@ func (c *Composer) compose(ctx context.Context) []agent.Update {
|
||||
}
|
||||
c.paid()
|
||||
pass.Held, pass.holds = held, holds
|
||||
pass.Unmade = c.unmadeOf(fps, compositions, held, skipped, own, inOwn, fromManifest, &keptMu)
|
||||
c.sayUnmade(pass.Unmade)
|
||||
// The provisioner client's account the node runs with, dropped by this pass, is said: every filled write is refused
|
||||
// under a declaration that names none (filling.JudgeAccount), and no module is handed to it (issue 774, ace,
|
||||
// 2026-11-04 01:29). A placed client keeps its account while it is held (compose.Node.RunningProvisioner).
|
||||
@@ -2028,3 +2068,98 @@ func (c *Composer) seatsOf(ctx context.Context, r reader, module string) []strin
|
||||
}
|
||||
return seats
|
||||
}
|
||||
|
||||
// unmadeOf is each module wanted at a fingerprint (fps) of which the pass made no composition at it, with why, apart from
|
||||
// the modules held alone (Pass.Unmade), and each want of this node's that was not taken.
|
||||
func (c *Composer) unmadeOf(fps map[string]string, comps map[string]engcomp.Composed, held map[string]string, skipped []string,
|
||||
own meshmoduleassigner.Assignments, inOwn []string, fromManifest map[string]string, mu *sync.Mutex) map[string]string {
|
||||
made := map[string]string{}
|
||||
for _, comp := range comps {
|
||||
if comp.Module != "" {
|
||||
made[comp.Module] = comp.FP
|
||||
}
|
||||
}
|
||||
placed := map[string]bool{}
|
||||
for _, m := range own.Modules {
|
||||
placed[m.Module] = true
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
out := map[string]string{}
|
||||
for module, fp := range fps {
|
||||
if _, isHeld := held[module]; isHeld || made[module] == fp {
|
||||
continue
|
||||
}
|
||||
var why string
|
||||
switch m, in := c.modules[module]; {
|
||||
case compose.Folds(slices.Contains(inOwn, module), fp):
|
||||
why = "it is composed inside the node's own composition at " + fp
|
||||
case !placed[module]:
|
||||
why = fmt.Sprintf("the seat's assignment places no %s on this node (%d modules placed)", module, len(own.Modules))
|
||||
case !in:
|
||||
why = fmt.Sprintf("no manifest in the composer's picture of the indexer's modules (%d modules, last update at revision %d)",
|
||||
len(c.modules), c.modulesRev)
|
||||
case slices.Contains(skipped, module):
|
||||
why = "the composition skipped it: the declaration gives it no fingerprint (indexer's picture: " + m.Fp2 + ")"
|
||||
default:
|
||||
why = fmt.Sprintf("the composition made nothing of it at %s (indexer's picture: %s, revision %d)", fp, m.Fp2, c.modulesRev)
|
||||
}
|
||||
if w, ok := fromManifest[module]; ok {
|
||||
why += "; " + w
|
||||
}
|
||||
out[module] = why
|
||||
}
|
||||
for module, why := range c.notTaken {
|
||||
if _, wanted := fps[module]; !wanted {
|
||||
out[module] = "want not taken: " + why
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sayUnmade says each change of what the pass composed none of, once.
|
||||
func (c *Composer) sayUnmade(unmade map[string]string) {
|
||||
if len(unmade) == 0 {
|
||||
return
|
||||
}
|
||||
var lines []string
|
||||
for _, m := range heldModules(unmade) {
|
||||
lines = append(lines, m+": "+unmade[m])
|
||||
}
|
||||
c.sayOnceEach("the node composes nothing of " + strings.Join(lines, "; "))
|
||||
}
|
||||
|
||||
// Waiting is the agent's word that it waits for the composition of module at fp, which it holds none of. When the composer
|
||||
// believes it handed that composition, it forgets so and composes again, so the agent is handed it again: what was handed
|
||||
// is no more trusted over what the agent holds (issue 1966, novox: handed zero, the agent waiting).
|
||||
func (c *Composer) Waiting(module, fp string) {
|
||||
key, err := engcomp.Key(c.env.Node, module, fp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
_, believed := c.handed[key]
|
||||
delete(c.handed, key)
|
||||
c.mu.Unlock()
|
||||
if !believed {
|
||||
return
|
||||
}
|
||||
c.env.Say(fmt.Sprintf("the agent waits for the composition of %s at %s, which the node believed it handed: handed again", module, fp))
|
||||
select {
|
||||
case c.rehand <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// WhyUnmade is why the last pass composed nothing of module at its want's fingerprint (Pass.Unmade); empty when it said
|
||||
// none, or no pass was made.
|
||||
func (c *Composer) WhyUnmade(module string) string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.last.Unmade[module]
|
||||
}
|
||||
|
||||
@@ -2090,3 +2090,59 @@ func (b *patternBus) putAssigned(vals map[string][]byte, rev uint64) {
|
||||
b.put(buckets.Assignments, k, vals[k], rev)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1966 (novox, 2026-11-09): a want the composer made nothing of was said nowhere, and a composition it believed
|
||||
// handed that the agent never held stayed unhanded. Every pass names each wanted module it composed nothing of, with why,
|
||||
// in Pass.Unmade and as a journal line; a want not taken is said; and the agent's word that it waits hands the
|
||||
// composition again.
|
||||
func TestAWantTheNodeMakesNothingOfSaysWhyAndAWaitingAgentIsHandedItAgain(t *testing.T) {
|
||||
b := newFakeBus()
|
||||
fp := meshcatalogclient.SourcePrefix2 + strings.Repeat("a", 64)
|
||||
gfp := meshcatalogclient.SourcePrefix2 + strings.Repeat("b", 64)
|
||||
digest := meshcatalogclient.ManifestDigest([]byte(lemurs))
|
||||
man, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "lemurs", Manifest: lemurs})
|
||||
key, _ := meshcatalogclient.ManifestsKey(digest)
|
||||
b.kv[buckets.Manifests+"/"+key] = man
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
c := New(b.env())
|
||||
out, err := c.Watch(ctx, "n1.>")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
place := assignedValues(t, meshmoduleassigner.Assignments{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"},
|
||||
Modules: []meshmoduleassigner.PlacedModule{{Module: "lemurs"}}})
|
||||
have, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "n1", TunnelKey: "k", SealingKey: "sk", Capabilities: []string{"systemd"}}})
|
||||
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest, Repository: "novox/x", Commit: "c"})
|
||||
want, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fp, Self: true})
|
||||
ghost, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: gfp, Self: true})
|
||||
nofp, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Self: true})
|
||||
b.putAssigned(t, place)
|
||||
b.put(t, buckets.Have, "n1.node", have)
|
||||
b.put(t, buckets.Modules, "lemurs", mod)
|
||||
b.put(t, buckets.Wants, "want.n1.lemurs", want)
|
||||
b.put(t, buckets.Wants, "want.n1.ghost", ghost)
|
||||
b.put(t, buckets.Wants, "want.n1.nofp", nofp)
|
||||
for _, bucket := range []string{buckets.Wants, buckets.Assignments, buckets.Have, buckets.Modules, ResolvedBucket, builds.BuilderBuildsBucket} {
|
||||
b.send(bucket, agent.Update{Caught: true})
|
||||
}
|
||||
for !next(t, out).Caught {
|
||||
}
|
||||
why := map[string]string{"ghost": c.WhyUnmade("ghost"), "nofp": c.WhyUnmade("nofp"), "lemurs": c.WhyUnmade("lemurs")}
|
||||
if !strings.Contains(why["ghost"], "places no ghost") || !strings.Contains(why["nofp"], "names no fingerprint") || why["lemurs"] != "" {
|
||||
t.Errorf("the pass says it composed nothing of %q", why)
|
||||
}
|
||||
b.mu.Lock()
|
||||
said := strings.Join(b.said, "\n")
|
||||
b.mu.Unlock()
|
||||
if !strings.Contains(said, "the node composes nothing of ghost: ") {
|
||||
t.Errorf("the journal does not say why ghost is not composed: %q", said)
|
||||
}
|
||||
// The agent waits for what the node believes it handed: handed again, and a wait for what was never handed does nothing.
|
||||
c.Waiting("ghost", gfp)
|
||||
quiet(t, out)
|
||||
c.Waiting("lemurs", fp)
|
||||
if u := next(t, out); !strings.Contains(u.Key, "lemurs") {
|
||||
t.Errorf("handed %s, want lemurs's composition again", u.Key)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user