compose-candidate reads each manifest once and answers within the bus's reply window

Off the control-node an ask of compose-candidate ran into its 60 s bound: each of
its two compositions read every manifest in the mesh one key at a time over the
bus, on scratch copies of the shadow that kept nothing, so each ask read them all
twice again (about 35 s per composition on ace, g14 and shanks; 2 s on novox).
The answer at 60 s then came after the bus's one-minute reply window
(allow_responses ttl), so the bus refused it and the checker heard nothing.

Now the manifests are read eight at a time into the shadow before either
composition, a scratch copy keeps what never changes (manifest, archive, done
build) in the shadow too, the candidate is laid over a copy taken after the
composition without it, and the engine answers within 50 s: unready, naming what
it waits on, when not done. Every answer is said in one journal line with its
reads and its longest wait.

Issue 1126.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
2026-11-03 01:23:17 +01:00
parent 27a956448c
commit ebb4f4af18
4 changed files with 330 additions and 32 deletions
+56 -17
View File
@@ -97,6 +97,8 @@ type Env struct {
// ComposedState is this node's composition state `composed-<node>` (novox/hq ADR 0593 §6), where every composition
// a pass makes is published for its readers (publishComposed). Nil keeps none.
ComposedState ComposedStore
// Now is the clock what the composer waits on is timed by; nil is time.Now.
Now func() time.Time
}
// VaultSecretChanged is the vault's event that a secret changed: a name and its generation, never a value.
@@ -141,10 +143,16 @@ type Composer struct {
held []string
// doing is what the composer waits on now (waiting), for a running answer to say (issue 1126, T2); its own lock, so
// it is read while c.mu is held.
doingMu sync.Mutex
doing string
caught bool
rev uint64
doingMu sync.Mutex
doing string
doingSince time.Time
// waits is what the composer waited on, under doingMu: for compose-candidate's line (issue 1126).
waits waitLog
// origin is the composer a scratch copy was taken of (scratch): what is read once by its digest or key and never
// changes (a manifest, a templates archive, a done build) is kept there too, so the next copy reads it no more.
origin *Composer
caught bool
rev uint64
// missed counts the reads the bus did not answer (BusFailed), for a pass or an answer to tell whether it met one:
// a read that failed on the bus is tried again after a bound, never kept as an answer (issue 1126, 2026-10-28).
missed atomic.Uint64
@@ -1060,19 +1068,56 @@ func (c *Composer) keepRunning(comp delivery.Composed, running []delivery.Compos
return delivery.KeepSeals(comp, kept, c.sameValue)
}
// waiting notes what the composer waits on until the returned func is called, which restores what it waited on before.
// waitLog is what a composer waited on: how many waits ended, the longest and what it was, and the last.
type waitLog struct {
n int
longest time.Duration
longestOn string
lastOn string
}
// now is the composer's clock (Env.Now).
func (c *Composer) now() time.Time {
if c.env.Now != nil {
return c.env.Now()
}
return time.Now()
}
// waiting notes what the composer waits on until the returned func is called, which restores what it waited on before
// and counts the wait, timed, in waits.
func (c *Composer) waiting(what string) func() {
began := c.now()
c.doingMu.Lock()
before := c.doing
c.doing = what
before, beforeSince := c.doing, c.doingSince
c.doing, c.doingSince = what, began
c.doingMu.Unlock()
return func() {
took := c.now().Sub(began)
c.doingMu.Lock()
c.doing = before
c.doing, c.doingSince = before, beforeSince
c.waits.n++
c.waits.lastOn = what
if took > c.waits.longest || c.waits.longestOn == "" {
c.waits.longest, c.waits.longestOn = took, what
}
c.doingMu.Unlock()
}
}
// keepRead keeps, in c and in the composer c is a scratch copy of, what set writes: a read that never changes for its
// digest or key. Each under its own lock, one after the other.
func (c *Composer) keepRead(set func(*Composer)) {
c.mu.Lock()
set(c)
c.mu.Unlock()
if o := c.origin; o != nil {
o.mu.Lock()
set(o)
o.mu.Unlock()
}
}
// waitingOn is what the composer waits on now, in words.
func (c *Composer) waitingOn() string {
c.doingMu.Lock()
@@ -1303,9 +1348,7 @@ func (r reader) Manifest(ctx context.Context, digest string) ([]byte, error) {
if err != nil {
return nil, err
}
r.c.mu.Lock()
r.c.manifests[digest] = []byte(m.JSON)
r.c.mu.Unlock()
r.c.keepRead(func(k *Composer) { k.manifests[digest] = []byte(m.JSON) })
return []byte(m.JSON), nil
}
@@ -1330,9 +1373,7 @@ func (r reader) Build(ctx context.Context, module, fp string) (*delivery.Builder
return nil, fmt.Errorf("the builder's %s: %w", key, err)
}
if b.State == delivery.BuildDone {
r.c.mu.Lock()
r.c.builds[key] = b
r.c.mu.Unlock()
r.c.keepRead(func(k *Composer) { k.builds[key] = b })
}
return &b, nil
}
@@ -1363,9 +1404,7 @@ func (r reader) Archive(ctx context.Context, url, digest string) ([]byte, error)
if got := "sha256:" + hex.EncodeToString(sum[:]); got != digest {
return nil, fmt.Errorf("%s was recorded as %s and what arrived is %s", url, digest, got)
}
r.c.mu.Lock()
r.c.archives[digest] = body
r.c.mu.Unlock()
r.c.keepRead(func(k *Composer) { k.archives[digest] = body })
return body, nil
}