A composer's mark never covers another cause, and a run that ended marks and clears nothing (issue 1126, review of the switch, round 1)
mesh/delivery merged: source.main moved to novox/mesh-host@449cda1b003e
mesh/delivery-group group fix/1126-switch-safe merged: every member is merged
mesh/check pass a6e53a485813 on 199f6b48b1df | unit 2992 run, 1008 kept, 36 skipped | integration 3025 run, 1102 kept, 29 skipped | over bound: unit (…

NotComposed marked from a read have whatever its phase: an apply that failed,
naming the want's build and digest, became a composer's mark, which baseline
then took for what runs, and never applied again. It now marks only a have that
runs, says nothing, or is a composer's mark already. And its loop, whose reads
can take seconds, checks the run's context at each module, so a run that ended
writes no have.
This commit is contained in:
2026-10-28 00:41:25 +01:00
parent 4993f1b1d7
commit a6e53a4858
2 changed files with 56 additions and 1 deletions
+25 -1
View File
@@ -318,6 +318,11 @@ type notComposedMark struct {
// name, and only the node's composition of it failed. // name, and only the node's composition of it failed.
const NotComposedDetail = "the node cannot compose it, and keeps running what it ran: " const NotComposedDetail = "the node cannot compose it, and keeps running what it ran: "
// composerMark says h is a mark NotComposed wrote: failed, its detail opening with NotComposedDetail.
func composerMark(h Have) bool {
return h.Phase == string(delivery.PhaseFailed) && strings.HasPrefix(h.Detail, NotComposedDetail)
}
// HavesReadErr is what a Haves may answer besides Read: a have absent (false, nil) told from one that could not be read // HavesReadErr is what a Haves may answer besides Read: a have absent (false, nil) told from one that could not be read
// (false, the error). // (false, the error).
type HavesReadErr interface { type HavesReadErr interface {
@@ -375,6 +380,9 @@ func (a *Agent) NotComposed(ctx context.Context, why string) {
a.mu.Unlock() a.mu.Unlock()
sort.Strings(modules) sort.Strings(modules)
for _, module := range modules { for _, module := range modules {
if ctx.Err() != nil {
return // the run ended: what it would mark or clear is the next run's to say (issue 1126)
}
a.mu.Lock() a.mu.Lock()
mark, marked := a.notComposed[module] mark, marked := a.notComposed[module]
r, ran := a.running[module] r, ran := a.running[module]
@@ -405,6 +413,9 @@ func (a *Agent) NotComposed(ctx context.Context, why string) {
} else if h, found, unread := a.readHave(ctx, module); found { } else if h, found, unread := a.readHave(ctx, module); found {
mark.prior = h // what its last have said runs: this agent has run nothing of it since its start mark.prior = h // what its last have said runs: this agent has run nothing of it since its start
} else if unread { } else if unread {
if ctx.Err() != nil {
return
}
// What its have names cannot be read (the bus paused, issue 1126): a mark written now would drop the build // What its have names cannot be read (the bus paused, issue 1126): a mark written now would drop the build
// and digest it names. Nothing is written; the next refusal marks it once the have reads. // and digest it names. Nothing is written; the next refusal marks it once the have reads.
a.mu.Lock() a.mu.Lock()
@@ -413,6 +424,19 @@ func (a *Agent) NotComposed(ctx context.Context, why string) {
continue continue
} }
} }
// A have that already says another cause (an apply that failed, a backup held, an apply under way) keeps it: a
// composer's mark over it would hide that cause, and baseline would take the mark for what runs (review of the
// switch, round 1).
if p := mark.prior.Phase; !marked && p != "" && p != string(delivery.PhaseRunning) && !composerMark(mark.prior) {
a.mu.Lock()
a.sayOnce("unmarked-cause:"+module, fmt.Sprintf("%s is not marked: its have says %s (%s), and the node cannot compose it: %s",
module, p, mark.prior.Detail, why))
a.mu.Unlock()
continue
}
if ctx.Err() != nil {
return
}
mark.why = why mark.why = why
a.mu.Lock() a.mu.Lock()
a.notComposed[module] = mark a.notComposed[module] = mark
@@ -948,7 +972,7 @@ func (a *Agent) baseline(ctx context.Context, module string, w wantNow, comp com
// A have a composer marked failed (NotComposed) names what still runs: taken as running, so a mark this // A have a composer marked failed (NotComposed) names what still runs: taken as running, so a mark this
// agent did not make clears once what it names is what the want names (issue 1126, the stale marks after // agent did not make clears once what it names is what the want names (issue 1126, the stale marks after
// the seed's revert). // the seed's revert).
marked := h.Phase == string(delivery.PhaseFailed) && strings.HasPrefix(h.Detail, NotComposedDetail) marked := composerMark(h)
if marked { if marked {
h.Detail = "" h.Detail = ""
} }
+31
View File
@@ -342,6 +342,21 @@ func TestStartingOnWhatTheKeptDeclarationHoldsAppliesNothing(t *testing.T) {
// What the node-engine itself last applied is what runs after a restart, not the kept declaration. // What the node-engine itself last applied is what runs after a restart, not the kept declaration.
func TestStartingOnItsOwnHaveAppliesNothing(t *testing.T) { func TestStartingOnItsOwnHaveAppliesNothing(t *testing.T) {
t.Run("says the have again", testStartingOnItsOwnHaveSaysTheHaveAgainSoItsHealthIsThisEngines) t.Run("says the have again", testStartingOnItsOwnHaveSaysTheHaveAgainSoItsHealthIsThisEngines)
// An apply that failed is not what runs, though its have names the want's build and digest: the agent starting on
// it applies again (review of the switch, round 1).
t.Run("applies again after an apply failure", func(t *testing.T) {
b := newBed(t)
fp := fpOf("a")
ck, craw, dig := composition(t, "avahi", fp, false, file("avahi", "x", "1"))
wk, wraw := wantFor(t, "avahi", fp, dig, "")
b.haves.stored = map[string]Have{"avahi": {Module: "avahi", FP: fp, Composed: dig, Phase: string(delivery.PhaseFailed),
Detail: "apply: unit failed to start"}}
b.comps.before(ck, craw)
b.wants.before(wk, wraw)
stop := b.run()
defer stop()
eventually(t, "a failed apply was not applied again", func() bool { return len(b.appliedList()) == 1 })
})
// Issue 1126, the stale marks after the seed's revert: a have a composer marked failed names what still runs; the // Issue 1126, the stale marks after the seed's revert: a have a composer marked failed names what still runs; the
// agent starting on it applies nothing and says it running, so a mark it did not make clears. // agent starting on it applies nothing and says it running, so a mark it did not make clears.
t.Run("clears a composer's mark", func(t *testing.T) { t.Run("clears a composer's mark", func(t *testing.T) {
@@ -1084,6 +1099,11 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
b.haves.mu.Unlock() b.haves.mu.Unlock()
want("unread", fpOf("d"), "") want("unread", fpOf("d"), "")
want("fresh", fpOf("e"), "") // never ran, its have absent: marked, naming nothing it runs want("fresh", fpOf("e"), "") // never ran, its have absent: marked, naming nothing it runs
// A have that already says another cause keeps it (review of the switch, round 1): an apply that failed.
brokenFP, brokenDig := fpOf("f"), "sha256:"+strings.Repeat("8", 64)
b.haves.Write(context.Background(), Have{Module: "broken", FP: brokenFP, Composed: brokenDig, Phase: string(delivery.PhaseFailed),
Detail: "apply: unit failed to start"})
want("broken", brokenFP, "")
eventually(t, "unread's want was not taken", func() bool { eventually(t, "unread's want was not taken", func() bool {
b.ag.mu.Lock() b.ag.mu.Lock()
defer b.ag.mu.Unlock() defer b.ag.mu.Unlock()
@@ -1096,6 +1116,17 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
if h := b.haves.last("fresh"); h.Phase != string(delivery.PhaseFailed) || h.FP != "" { if h := b.haves.last("fresh"); h.Phase != string(delivery.PhaseFailed) || h.FP != "" {
t.Errorf("a module that never ran, its have absent, is not marked: %+v", h) t.Errorf("a module that never ran, its have absent, is not marked: %+v", h)
} }
if h := b.haves.last("broken"); h.Detail != "apply: unit failed to start" || h.Composed != brokenDig {
t.Errorf("an apply failure was overwritten by a composer's mark: %+v", h)
}
// A run that ended marks nothing: what it would say is the next run's (review of the switch, round 1).
ended, end := context.WithCancel(context.Background())
end()
before := len(b.haves.log)
b.ag.NotComposed(ended, "a refusal from a run that ended")
if len(b.haves.log) != before {
t.Errorf("a run that ended wrote %d haves", len(b.haves.log)-before)
}
b.ag.NotComposed(context.Background(), "") b.ag.NotComposed(context.Background(), "")
for _, row := range []struct{ module, composed string }{{"web", dig}, {"idle", idleDig}} { for _, row := range []struct{ module, composed string }{{"web", dig}, {"idle", idleDig}} {
if h := b.haves.last(row.module); h.Phase != string(delivery.PhaseRunning) || h.Composed != row.composed || len(h.Sealed) != 1 { if h := b.haves.last(row.module); h.Phase != string(delivery.PhaseRunning) || h.Composed != row.composed || len(h.Sealed) != 1 {