Give each caller of a pass its own nodes' errors and write the grants whenever asked; a wake during a take-in makes it look once more (issue 1945)
mesh/delivery closed: a newer head of the same pull request
mesh/check pass 8114cee2af47 on 0fff3efd4e0b | unit 959 run, 172 kept, 2 skipped | integration 1615 run, 236 kept, 7 skipped | over bound: integration…

This commit is contained in:
2026-10-25 12:44:28 +01:00
parent baa8f58473
commit 8114cee2af
5 changed files with 175 additions and 33 deletions
+15 -4
View File
@@ -120,6 +120,9 @@ var (
doneKeysSay = func(s string) { fmt.Println(s) } doneKeysSay = func(s string) { fmt.Println(s) }
) )
// doneKeysLookAgain marks a module woken while a take-in of it ran: that take-in goes round once more.
var doneKeysLookAgain sync.Map
// doneKeysMu keeps one take-in of a module's done key at a time in this process, per module. // doneKeysMu keeps one take-in of a module's done key at a time in this process, per module.
var doneKeysMu sync.Map var doneKeysMu sync.Map
@@ -130,17 +133,25 @@ var doneKeysMu sync.Map
func takeDoneKey(ctx context.Context, open *stores, module string) { func takeDoneKey(ctx context.Context, open *stores, module string) {
mu, _ := doneKeysMu.LoadOrStore(module, &sync.Mutex{}) mu, _ := doneKeysMu.LoadOrStore(module, &sync.Mutex{})
if !mu.(*sync.Mutex).TryLock() { if !mu.(*sync.Mutex).TryLock() {
return // a take-in of the module already follows it, and looks again within doneKeyRetry // A take-in of the module already follows it: it is told to look again once it is done, so a newer key written
// meanwhile is never lost (the review of issue 1945).
doneKeysLookAgain.Store(module, true)
return
} }
defer mu.(*sync.Mutex).Unlock() defer mu.(*sync.Mutex).Unlock()
failed := false failed := false
saidWait := "" saidWait := ""
for { for {
doneKeysLookAgain.Delete(module) // what this look reads covers every wake before it
err := doneKeyTakeOnce(ctx, open, module) err := doneKeyTakeOnce(ctx, open, module)
o := doneKeyObservation(module) o := doneKeyObservation(module)
if err == nil { if err == nil {
if keeper := conditionsFrom; keeper != nil && failed { if keeper := conditionsFrom; keeper != nil && failed {
_, _ = keeper.Clear(ctx, o.Key(), "composed and granted") _, _ = keeper.Clear(ctx, o.Key(), "composed and granted")
failed = false
}
if _, again := doneKeysLookAgain.LoadAndDelete(module); again {
continue // a wake came while this one ran: once more, for the key written meanwhile
} }
return return
} }
@@ -357,9 +368,9 @@ func composerFor(open *stores) *composer {
if c, ok := doneKeysComposers.Load(open); ok { if c, ok := doneKeysComposers.Load(open); ok {
return c.(*composer) return c.(*composer)
} }
c := newComposer(func(ctx context.Context, nodes []string) error { c := newComposer(func(ctx context.Context, nodes []string) map[string]error {
_, err := composeModules(ctx, open, nodes) _, failed := composeModulesByNode(ctx, open, nodes)
return err return failed
}, func(ctx context.Context) error { }, func(ctx context.Context) error {
bus, err := aBus() bus, err := aBus()
if err != nil { if err != nil {
@@ -86,3 +86,38 @@ func TestAWakeWhileATakeInFollowsAddsNone(t *testing.T) {
close(release) close(release)
<-follower <-follower
} }
// A wake while a take-in of the module follows it is not lost: the follower goes round once more after it succeeds, so
// a newer build's key written meanwhile is taken in (the review of issue 1945).
func TestAWakeDuringATakeInIsTakenAfterIt(t *testing.T) {
was, wasRetry, wasSay := doneKeyTakeOnce, doneKeyRetry, doneKeysSay
t.Cleanup(func() { doneKeyTakeOnce, doneKeyRetry, doneKeysSay = was, wasRetry, wasSay })
doneKeyRetry = 20 * time.Millisecond
doneKeysSay = func(string) {}
var mu sync.Mutex
calls := 0
inFirst := make(chan struct{})
release := make(chan struct{})
doneKeyTakeOnce = func(context.Context, *stores, string) error {
mu.Lock()
calls++
n := calls
mu.Unlock()
if n == 1 {
close(inFirst)
<-release
}
return nil
}
follower := make(chan struct{})
go func() { takeDoneKey(context.Background(), nil, "mesh-host"); close(follower) }()
<-inFirst
takeDoneKey(context.Background(), nil, "mesh-host") // the newer key's wake, while the first runs
close(release)
<-follower
mu.Lock()
defer mu.Unlock()
if calls != 2 {
t.Fatalf("taken %d times: the wake during the take-in was lost", calls)
}
}
+45 -16
View File
@@ -26,29 +26,52 @@ import (
// every refusal is named in the error. It answers the keys it wrote, by node. Under the lease only: a controller that // every refusal is named in the error. It answers the keys it wrote, by node. Under the lease only: a controller that
// does not hold it writes nothing and says so. // does not hold it writes nothing and says so.
func composeModules(ctx context.Context, open *stores, nodes []string) (map[string][]string, error) { func composeModules(ctx context.Context, open *stores, nodes []string) (map[string][]string, error) {
out, byNode := composeModulesByNode(ctx, open, nodes)
names := make([]string, 0, len(byNode))
for n := range byNode {
names = append(names, n)
}
sort.Strings(names)
var errs []error
for _, n := range names {
errs = append(errs, byNode[n])
}
return out, errors.Join(errs...)
}
// composeModulesByNode is composeModules with each node's error apart: a failure before any node is composed is every
// node's (the composer gives each caller only the errors of the nodes it asked for, issue 1945).
func composeModulesByNode(ctx context.Context, open *stores, nodes []string) (map[string][]string, map[string]error) {
all := func(err error) (map[string][]string, map[string]error) {
byNode := map[string]error{}
for _, n := range nodes {
byNode[n] = err
}
return nil, byNode
}
if _, err := theLease.epoch(ctx); err != nil { if _, err := theLease.epoch(ctx); err != nil {
return nil, fmt.Errorf("nothing is composed: %w", err) return all(fmt.Errorf("nothing is composed: %w", err))
} }
bus, err := aBus() bus, err := aBus()
if err != nil { if err != nil {
return nil, fmt.Errorf("nothing is composed: the bus: %w", err) return all(fmt.Errorf("nothing is composed: the bus: %w", err))
} }
defer bus.Close() defer bus.Close()
js, err := jetstream.New(bus.Conn()) js, err := jetstream.New(bus.Conn())
if err != nil { if err != nil {
return nil, err return all(err)
} }
sign, err := controllerSigner(ctx, open) sign, err := controllerSigner(ctx, open)
if err != nil { if err != nil {
return nil, fmt.Errorf("nothing is composed: the controller's signing key: %w", err) return all(fmt.Errorf("nothing is composed: the controller's signing key: %w", err))
} }
fps, err := currentFingerprints(ctx, open.inventory) fps, err := currentFingerprints(ctx, open.inventory)
if err != nil { if err != nil {
return nil, err return all(err)
} }
entries, err := open.inventory.Catalogued(ctx) entries, err := open.inventory.Catalogued(ctx)
if err != nil { if err != nil {
return nil, err return all(err)
} }
manifests := make(map[string]catalogue.Manifest, len(entries)) manifests := make(map[string]catalogue.Manifest, len(entries))
for _, e := range entries { for _, e := range entries {
@@ -56,7 +79,7 @@ func composeModules(ctx context.Context, open *stores, nodes []string) (map[stri
} }
upgrades, err := open.inventory.Upgrades(ctx) upgrades, err := open.inventory.Upgrades(ctx)
if err != nil { if err != nil {
return nil, err return all(err)
} }
policy := func(module string) (string, string, string) { policy := func(module string) (string, string, string) {
u, ok := upgrades[module] u, ok := upgrades[module]
@@ -67,7 +90,7 @@ func composeModules(ctx context.Context, open *stores, nodes []string) (map[stri
} }
sort.Strings(nodes) sort.Strings(nodes)
out := map[string][]string{} out := map[string][]string{}
var errs []error errsOf := map[string][]error{}
for _, node := range nodes { for _, node := range nodes {
// One composition of a node at a time in this process: two at once collided on its keys' revisions (issue 1945). // One composition of a node at a time in this process: two at once collided on its keys' revisions (issue 1945).
unlock := lockComposing(node) unlock := lockComposing(node)
@@ -75,7 +98,7 @@ func composeModules(ctx context.Context, open *stores, nodes []string) (map[stri
defer unlock() defer unlock()
at, err := openComposedPlace(ctx, js, bus.Conn(), node) at, err := openComposedPlace(ctx, js, bus.Conn(), node)
if err != nil { if err != nil {
errs = append(errs, fmt.Errorf("%s: %w", node, err)) errsOf[node] = append(errsOf[node], fmt.Errorf("%s: %w", node, err))
return return
} }
at.sign, at.policy = sign, policy at.sign, at.policy = sign, policy
@@ -84,21 +107,21 @@ func composeModules(ctx context.Context, open *stores, nodes []string) (map[stri
} }
declared, err := composeSend(ctx, open, node) declared, err := composeSend(ctx, open, node)
if err != nil { if err != nil {
errs = append(errs, fmt.Errorf("%s: %w", node, err)) errsOf[node] = append(errsOf[node], fmt.Errorf("%s: %w", node, err))
return return
} }
body, err := compositionBody(declared) body, err := compositionBody(declared)
if err != nil { if err != nil {
errs = append(errs, fmt.Errorf("%s: %w", node, err)) errsOf[node] = append(errsOf[node], fmt.Errorf("%s: %w", node, err))
return return
} }
// The provisioner's plans of the modules the node's composition hands to it, for the assignments mirror. // The provisioner's plans of the modules the node's composition hands to it, for the assignments mirror.
if err := open.inventory.RecordComposedPlans(ctx, node, plansKept(declared)); err != nil { if err := open.inventory.RecordComposedPlans(ctx, node, plansKept(declared)); err != nil {
errs = append(errs, fmt.Errorf("%s: its provisioner's plans: %w", node, err)) errsOf[node] = append(errsOf[node], fmt.Errorf("%s: its provisioner's plans: %w", node, err))
} }
comps, moved, err := compositionsOf(node, body, fps, manifests, declared.modules) comps, moved, err := compositionsOf(node, body, fps, manifests, declared.modules)
if err != nil { if err != nil {
errs = append(errs, fmt.Errorf("%s: %w", node, err)) errsOf[node] = append(errsOf[node], fmt.Errorf("%s: %w", node, err))
return return
} }
if len(moved) > 0 { if len(moved) > 0 {
@@ -116,15 +139,21 @@ func composeModules(ctx context.Context, open *stores, nodes []string) (map[stri
// And where a data-keeping provision bound each consumer: what the next resolution keeps it at (novox/hq ADR 0232). // And where a data-keeping provision bound each consumer: what the next resolution keeps it at (novox/hq ADR 0232).
if len(declared.Bindings) > 0 { if len(declared.Bindings) > 0 {
if err := open.inventory.RecordBindings(ctx, node, declared.Bindings); err != nil { if err := open.inventory.RecordBindings(ctx, node, declared.Bindings); err != nil {
errs = append(errs, fmt.Errorf("%s: where its consumers are bound to their data: %w", node, err)) errsOf[node] = append(errsOf[node], fmt.Errorf("%s: where its consumers are bound to their data: %w", node, err))
} }
} }
if err != nil { if err != nil {
errs = append(errs, fmt.Errorf("%s: %w", node, err)) errsOf[node] = append(errsOf[node], fmt.Errorf("%s: %w", node, err))
} }
}() }()
} }
return out, errors.Join(errs...) byNode := map[string]error{}
for node, errs := range errsOf {
if len(errs) > 0 {
byNode[node] = errors.Join(errs...)
}
}
return out, byNode
} }
// composeAndSay is composeModules for an act that has nothing to do with its answer but say it: what each former send // composeAndSay is composeModules for an act that has nothing to do with its answer but say it: what each former send
+31 -10
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"errors"
"sort" "sort"
"sync" "sync"
) )
@@ -13,22 +14,30 @@ import (
// revision mismatch"), retried every 30 s, and the take-in that mattered waited behind them (2026-10-25 at 11:18 and // revision mismatch"), retried every 30 s, and the take-in that mattered waited behind them (2026-10-25 at 11:18 and
// 12:32). A caller waits for the pass that began after it asked, and hears that pass's error. // 12:32). A caller waits for the pass that began after it asked, and hears that pass's error.
type composer struct { type composer struct {
composeNodes func(ctx context.Context, nodes []string) error composeNodes func(ctx context.Context, nodes []string) map[string]error
writeGrants func(ctx context.Context) error writeGrants func(ctx context.Context) error
mu sync.Mutex mu sync.Mutex
pending map[string]bool pending map[string]bool
grants bool grants bool
waiters []chan error waiters []waiter
wake chan struct{} wake chan struct{}
} }
func newComposer(compose func(context.Context, []string) error, grants func(context.Context) error) *composer { // waiter is a caller waiting for its pass: the nodes it asked for, whether it asked for the grants, and where its answer
// goes.
type waiter struct {
nodes []string
grants bool
done chan error
}
func newComposer(compose func(context.Context, []string) map[string]error, grants func(context.Context) error) *composer {
return &composer{composeNodes: compose, writeGrants: grants, pending: map[string]bool{}, wake: make(chan struct{}, 1)} return &composer{composeNodes: compose, writeGrants: grants, pending: map[string]bool{}, wake: make(chan struct{}, 1)}
} }
// compose asks for the nodes to be composed, and the grants written after when grants is true, and waits for the pass // compose asks for the nodes to be composed, and the grants written after when grants is true, and waits for the pass
// that does it. // that does it, with the errors of its own nodes alone.
func (c *composer) compose(ctx context.Context, nodes []string, grants bool) error { func (c *composer) compose(ctx context.Context, nodes []string, grants bool) error {
done := make(chan error, 1) done := make(chan error, 1)
c.mu.Lock() c.mu.Lock()
@@ -36,7 +45,7 @@ func (c *composer) compose(ctx context.Context, nodes []string, grants bool) err
c.pending[n] = true c.pending[n] = true
} }
c.grants = c.grants || grants c.grants = c.grants || grants
c.waiters = append(c.waiters, done) c.waiters = append(c.waiters, waiter{nodes: nodes, grants: grants, done: done})
c.mu.Unlock() c.mu.Unlock()
select { select {
case c.wake <- struct{}{}: case c.wake <- struct{}{}:
@@ -70,15 +79,27 @@ func (c *composer) run(ctx context.Context) {
continue continue
} }
sort.Strings(nodes) sort.Strings(nodes)
var err error var failed map[string]error
if len(nodes) > 0 { if len(nodes) > 0 {
err = c.composeNodes(ctx, nodes) failed = c.composeNodes(ctx, nodes)
} }
if err == nil && grants { // The grants whenever a caller asked for them, whatever a node's composition said: a node that failed fails its
err = c.writeGrants(ctx) // own callers alone (issue 1945).
var grantsErr error
if grants {
grantsErr = c.writeGrants(ctx)
} }
for _, w := range waiters { for _, w := range waiters {
w <- err var errs []error
for _, n := range w.nodes {
if err := failed[n]; err != nil {
errs = append(errs, err)
}
}
if w.grants && grantsErr != nil {
errs = append(errs, grantsErr)
}
w.done <- errors.Join(errs...)
} }
} }
} }
+49 -3
View File
@@ -20,7 +20,7 @@ func TestABurstComposesEachNodeOncePerPass(t *testing.T) {
running, maxRunning := 0, 0 running, maxRunning := 0, 0
release := make(chan struct{}) release := make(chan struct{})
first := true first := true
c := newComposer(func(_ context.Context, nodes []string) error { c := newComposer(func(_ context.Context, nodes []string) map[string]error {
mu.Lock() mu.Lock()
running++ running++
maxRunning = max(maxRunning, running) maxRunning = max(maxRunning, running)
@@ -78,10 +78,10 @@ func TestABurstComposesEachNodeOncePerPass(t *testing.T) {
// A caller hears its own pass's failure, and only a pass that began after it asked counts as its own. // A caller hears its own pass's failure, and only a pass that began after it asked counts as its own.
func TestACallerHearsThePassThatComposedItsNodes(t *testing.T) { func TestACallerHearsThePassThatComposedItsNodes(t *testing.T) {
calls := 0 calls := 0
c := newComposer(func(_ context.Context, nodes []string) error { c := newComposer(func(_ context.Context, nodes []string) map[string]error {
calls++ calls++
if calls == 1 { if calls == 1 {
return context.DeadlineExceeded return map[string]error{"ace": context.DeadlineExceeded}
} }
return nil return nil
}, func(context.Context) error { return nil }) }, func(context.Context) error { return nil })
@@ -95,3 +95,49 @@ func TestACallerHearsThePassThatComposedItsNodes(t *testing.T) {
t.Fatalf("a good pass said %v", err) t.Fatalf("a good pass said %v", err)
} }
} }
// One node that fails its composition fails only the callers that asked for it, and the grants are written whenever a
// caller asked for them (the review of issue 1945: one joined error failed every take-in of the pass and stopped their
// grants).
func TestANodeThatFailsFailsOnlyItsCallers(t *testing.T) {
grants := 0
release := make(chan struct{})
first := true
c := newComposer(func(_ context.Context, nodes []string) map[string]error {
if first {
first = false
<-release
}
out := map[string]error{}
for _, n := range nodes {
if n == "broken" {
out[n] = context.DeadlineExceeded
}
}
return out
}, func(context.Context) error { grants++; return nil })
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go c.run(ctx)
go func() { _ = c.compose(ctx, []string{"warm"}, false) }()
time.Sleep(20 * time.Millisecond)
errs := make(chan error, 2)
go func() { errs <- c.compose(ctx, []string{"ace"}, true) }()
go func() { errs <- c.compose(ctx, []string{"broken"}, false) }()
time.Sleep(20 * time.Millisecond)
close(release)
var failed, fine int
for range 2 {
if err := <-errs; err != nil {
failed++
} else {
fine++
}
}
if failed != 1 || fine != 1 {
t.Fatalf("failed %d, fine %d: only the caller of the broken node fails", failed, fine)
}
if grants != 1 {
t.Fatalf("the grants were written %d times; a caller asked for them", grants)
}
}