Reconcile a module from the machine's facts at engine start and at each edge of a declared-running resource going down (issue 1957, ADR 0568)
mesh/delivery merged: source.main moved to novox/mesh-host@58b4f8b6bc07
mesh/check being carried to bf667a602d21 on 58b4f8b6bc07 | from 9a8b3870bad0

A reboot left the have running, so a unit that failed to start at boot was never started again and nothing said so. Baseline now asks the machine; the judge's look calls Agent.Reconcile once per down edge, leaving held and unwanted modules alone.
This commit is contained in:
2026-10-28 18:12:35 +01:00
parent 9a8b3870ba
commit bf667a602d
9 changed files with 475 additions and 13 deletions
+61 -1
View File
@@ -118,6 +118,11 @@ type Env struct {
// Recorded says the engine holds a record of every resource of the composition, so it was applied and not removed
// since; nil says yes. A composition the kept declaration or a have says runs is applied again when it says no.
Recorded func(ctx context.Context, c delivery.Composed) bool
// Inactive says a resource the composition declares running is not active on the machine now (a unit stopped, a
// container not running), read from the machine and not from any record (ADR 0568 §1). A have or a kept declaration
// that says the module runs is believed at the agent's start only when this says no; nil says no, and an unreadable
// machine says no (unknown is not inactive).
Inactive func(ctx context.Context, c delivery.Composed) bool
// Revisions are a composition key's revisions the bucket keeps, oldest first; nil reads none. The composition a want
// names is taken from them when a newer one replaced it under the same key (ADR 0539 clause 1).
Revisions func(ctx context.Context, key string) ([]Update, error)
@@ -167,6 +172,8 @@ type Agent struct {
// composition is held out of the node's own apply until a composition of its own is handed (heldOut).
heldAlone map[string]bool
retry chan []string
// reconcile carries the judge's edges: a resource found not active (ADR 0568 §2).
reconcile chan []string
// tries counts the transient failures in a row of each module; again carries a retry whose wait ran out, and
// transient asks for every transiently failed module at once. stopped is closed when Run returns.
tries map[string]int
@@ -213,7 +220,7 @@ func New(env Env) *Agent {
}
return &Agent{env: env, wants: map[string]wantNow{}, comps: map[string]compNow{}, running: map[string]delivery.Have{}, applied: map[string]compNow{},
attempt: map[string]attempt{}, checked: map[string]bool{}, prior: map[string]bool{}, said: map[string]string{}, notComposed: map[string]notComposedMark{},
retry: make(chan []string, 8), tries: map[string]int{}, again: make(chan againAsk, 64),
retry: make(chan []string, 8), reconcile: make(chan []string, 64), tries: map[string]int{}, again: make(chan againAsk, 64),
transient: make(chan struct{}, 1), stopped: make(chan struct{})}
}
@@ -245,6 +252,17 @@ func (a *Agent) Run(ctx context.Context) error {
dirty[m] = true
}
a.mu.Unlock()
case modules := <-a.reconcile:
a.mu.Lock()
for _, m := range modules {
if w := a.wants[m]; !w.Present || w.Want.Removed || a.attempt[m].Held {
continue
}
delete(a.running, m)
delete(a.attempt, m)
dirty[m] = true
}
a.mu.Unlock()
case ask := <-a.again:
// The wait of a transiently failed apply ran out: tried again from the keys it has, if nothing tried it since.
a.mu.Lock()
@@ -303,6 +321,16 @@ func (a *Agent) Retry(modules ...string) {
}
}
// Reconcile asks for the modules to be applied again because a resource each declares running was found not active, once
// per edge (the judge's look, ADR 0568 §2). It is Retry, but a module whose apply is held (a failed backup) is left
// alone, as is one the node wants no more (ADR 0568 §4). It never blocks.
func (a *Agent) Reconcile(modules ...string) {
select {
case a.reconcile <- modules:
default:
}
}
// RetryTransient asks for every module whose last apply failed transiently to be applied again now: the engine says it
// when it knows the bus answers again (after it wrote the bus's user list and the bus reloaded its accounts). It never
// blocks.
@@ -1010,6 +1038,18 @@ func (a *Agent) baseline(ctx context.Context, module string, w wantNow, comp com
a.mu.Lock()
a.prior[module] = true
a.mu.Unlock()
if h.Phase == string(delivery.PhaseHeld) {
// Held for a failed backup, and the operator's answer is still to come: the hold is kept, nothing is
// backed up again or applied, and the next revision of the want is the answer (ADR 0568 §4).
a.mu.Lock()
a.attempt[module] = attempt{FP: w.Want.FP, Composed: w.Want.Composed, WantRev: w.Rev, CompRev: comp.Rev, Held: true,
AnsweredRev: w.Rev}
if h.Composed != "" {
a.running[module] = delivery.Have{FP: h.FP, Composed: h.Composed, Phase: delivery.PhaseRunning}
}
a.mu.Unlock()
return true
}
if h.Composed != "" {
// This node-engine wrote it: what it says is what runs, and the kept declaration says nothing more. A
// failed apply or a different composition is applied again.
@@ -1022,6 +1062,9 @@ func (a *Agent) baseline(ctx context.Context, module string, w wantNow, comp com
}
if h.FP == w.Want.FP && h.Composed == w.Want.Composed &&
(h.Phase == "" || h.Phase == string(delivery.PhaseRunning) || marked) {
if a.inactiveAtStart(ctx, module, comp) {
return false
}
a.mu.Lock()
a.running[module] = delivery.Have{FP: h.FP, Composed: h.Composed, Phase: delivery.PhaseRunning}
a.mu.Unlock()
@@ -1047,6 +1090,9 @@ func (a *Agent) baseline(ctx context.Context, module string, w wantNow, comp com
if !ok || !SameResources(kept, comp.C.Resources) {
return false
}
if a.inactiveAtStart(ctx, module, comp) {
return false
}
a.mu.Lock()
a.running[module] = delivery.Have{FP: w.Want.FP, Composed: w.Want.Composed, Phase: delivery.PhaseRunning}
a.mu.Unlock()
@@ -1054,6 +1100,20 @@ func (a *Agent) baseline(ctx context.Context, module string, w wantNow, comp com
return true
}
// inactiveAtStart says the machine shows a resource the module declares running not active, so what a have or the kept
// declaration says runs is not believed and the module is applied once, as for a changed want (ADR 0568 §1).
func (a *Agent) inactiveAtStart(ctx context.Context, module string, comp compNow) bool {
if a.env.Inactive == nil || !a.env.Inactive(ctx, comp.C) {
return false
}
a.mu.Lock()
a.prior[module] = true
a.sayOnce("inactive:"+module+":"+comp.Dig, fmt.Sprintf("%s is applied again: a resource it declares running is not "+
"active on this machine", module))
a.mu.Unlock()
return true
}
func (a *Agent) apply(ctx context.Context, module string, w wantNow, comp compNow, others []delivery.Composed) {
fp, dig := w.Want.FP, w.Want.Composed
a.mu.Lock()
+121
View File
@@ -342,6 +342,7 @@ func TestStartingOnWhatTheKeptDeclarationHoldsAppliesNothing(t *testing.T) {
// What the node-engine itself last applied is what runs after a restart, not the kept declaration.
func TestStartingOnItsOwnHaveAppliesNothing(t *testing.T) {
t.Run("1957: an engine start reads the machine's facts", testAnEngineStartReconcilesAModuleFromTheMachinesFacts)
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).
@@ -431,6 +432,7 @@ func testStartingOnItsOwnHaveSaysTheHaveAgainSoItsHealthIsThisEngines(t *testing
// A module whose kept resources differ from the composition is applied, and a failed apply is retried only by a change
// of either key.
func TestAFailedApplyIsRetriedOnlyByAChangeOfEitherKey(t *testing.T) {
t.Run("1957: the judge's edge reconciles, and a failed re-apply is failed", testReconcileAppliesARunningModuleAgainAndLeavesTheHeldAndTheUnwantedAlone)
// A real failure is not retried by the clock, however short the backoff: only by a change of either key.
t.Run("a real failure", func(t *testing.T) {
b := newBed(t)
@@ -1233,3 +1235,122 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
r.Phase == string(delivery.PhaseRunning) && r.FP == fpB && r.Composed == rdig
})
}
// 1957/AC1 and AC4 (unit; ADR 0568 §1, §4): an engine start takes a module from what the machine shows, not only from the
// have or the kept declaration it kept. Every resource up applies nothing; one declared running and not active applies the
// module once, from a have or from the kept declaration; a module with no want is not looked at.
func testAnEngineStartReconcilesAModuleFromTheMachinesFacts(t *testing.T) {
for _, c := range []struct {
name string
have bool // the engine's own have says running at the want
held bool // else its have says held, from a failed backup
kept bool // else the kept declaration says it runs
inactive bool
unwanted bool
applied int
asked bool // the machine was looked at
}{
{"all up: nothing is applied", true, false, false, false, false, 0, true},
{"a unit not active: applied once", true, false, false, true, false, 1, true},
{"kept declaration, a unit not active: applied once", false, false, true, true, false, 1, true},
{"kept declaration, all up: nothing is applied", false, false, true, false, false, 0, true},
{"a held module applies nothing at start", false, true, false, true, false, 0, false},
{"no want: nothing is looked at or applied", true, false, false, true, true, 0, false},
} {
t.Run(c.name, func(t *testing.T) {
b := newBed(t)
fp := fpOf("a")
rs := []json.RawMessage{file("web", "x", "1")}
ck, craw, dig := composition(t, "web", fp, false, rs...)
wk, wraw := wantFor(t, "web", fp, dig, "")
b.comps.before(ck, craw)
if !c.unwanted {
b.wants.before(wk, wraw)
}
if c.have {
b.haves.stored = map[string]Have{"web": {Module: "web", FP: fp, Composed: dig, Phase: "running"}}
}
if c.held {
b.haves.stored = map[string]Have{"web": {Module: "web", FP: fpOf("0"), Composed: "sha256:" + strings.Repeat("0", 64),
Phase: string(delivery.PhaseHeld), Detail: "restic: the repository is locked"}}
}
if c.kept {
b.kept = map[string][]json.RawMessage{"web": rs}
}
var mu sync.Mutex
asked := false
b.env.Inactive = func(context.Context, delivery.Composed) bool {
mu.Lock()
defer mu.Unlock()
asked = true
return c.inactive
}
stop := b.run()
defer stop()
if !c.unwanted && !c.held {
eventually(t, "the module was not settled", func() bool { return b.haves.last("web").Composed == dig })
}
time.Sleep(50 * time.Millisecond)
if got := b.appliedList(); len(got) != c.applied {
t.Fatalf("applied %v, want %d", got, c.applied)
}
mu.Lock()
defer mu.Unlock()
if asked != c.asked {
t.Fatalf("the machine was asked: %v, want %v", asked, c.asked)
}
})
}
}
// 1957/AC2, AC3 and AC4 (unit; ADR 0568 §2, §3, §4): the judge's edge, Reconcile, applies a running module again; one held
// for a failed backup and one with no want are left alone; and an apply that fails writes the have `failed` with the
// apply's own words.
func testReconcileAppliesARunningModuleAgainAndLeavesTheHeldAndTheUnwantedAlone(t *testing.T) {
b := newBed(t)
fp := fpOf("a")
ckW, crawW, digW := composition(t, "web", fp, false, file("web", "x", "1"))
wkW, wrawW := wantFor(t, "web", fp, digW, "")
ckD, crawD, digD := composition(t, "db", fp, true, file("db", "x", "1"))
wkD, wrawD := wantFor(t, "db", fp, digD, "")
b.comps.before(ckW, crawW)
b.wants.before(wkW, wrawW)
b.comps.before(ckD, crawD)
b.wants.before(wkD, wrawD)
b.haves.stored = map[string]Have{"web": {Module: "web", FP: fp, Composed: digW, Phase: "running"},
"db": {Module: "db", FP: fpOf("0"), Composed: "sha256:" + strings.Repeat("0", 64), Phase: "running"}}
b.backup = func(string) Backup { return Backup{Words: "restic: the repository is locked"} }
stop := b.run()
defer stop()
eventually(t, "web was not settled", func() bool { return b.haves.last("web").Composed == digW })
eventually(t, "db was not held", func() bool { return b.haves.last("db").Phase == string(delivery.PhaseHeld) })
if got := b.appliedList(); len(got) != 0 {
t.Fatalf("applied at the start: %v", got)
}
b.ag.Reconcile("db", "ghost")
time.Sleep(50 * time.Millisecond)
if got := b.appliedList(); len(got) != 0 {
t.Fatalf("a held module and an unwanted one were applied: %v", got)
}
if h := b.haves.last("db"); h.Phase != string(delivery.PhaseHeld) {
t.Fatalf("the held module's have changed: %+v", h)
}
b.ag.Reconcile("web")
eventually(t, "web was not applied again", func() bool { return len(b.appliedList()) == 1 })
eventually(t, "web is not running again", func() bool { return b.haves.last("web").Phase == string(delivery.PhaseRunning) })
b.mu.Lock()
b.failWith = "systemctl start web.service: exit status 1"
b.mu.Unlock()
b.ag.Reconcile("web")
eventually(t, "the failed re-apply did not write the have failed with its words", func() bool {
h := b.haves.last("web")
return h.Phase == string(delivery.PhaseFailed) && h.Detail == "systemctl start web.service: exit status 1"
})
time.Sleep(50 * time.Millisecond)
if got := b.appliedList(); len(got) != 2 {
t.Fatalf("a failed apply was tried again with no edge: %v", got)
}
}