Files
mesh-controller/cmd/mesh-controller/core_downgrade.go
T
jochen 9038d4b0d3
mesh/merge-gate out of date: judged against trunk f10aedac5880, the trunk is now f8daec01d3f0; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk f10aedac5880, the trunk is now f8daec01d3f0; judged again on a recheck or when it is merged
mesh/delivery superseded: a newer head of the same pull request
Refuse a send that puts the controller or node-engine behind its machine's build (issue 201)
On 2026-10-02 a plan sent the control node a controller older than the one
running there, whose seat row the newer build had widened; the older binary
refused the row and the mesh went quiet for ten minutes. ADR 0185 took the
outage out of it, and the race stayed open.

Every send but a person's push now refuses a move of mesh-controller or
mesh-host to a build asked before the one its machine was last sent (the
builds' registration order, issue 219), whatever composed it — gated or not.
A gate's own put-back is the one exception, for the modules it puts back,
and says so in its send's scope. The refusal is the send's error, which the
plan keeps as its note, and a warning condition on the machine, cleared by
the next send there that carries no such move.
2026-10-12 18:21:17 +02:00

172 lines
7.1 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"sort"
"time"
"github.com/novox/mesh-controller/internal/conditions"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/lease"
)
// **A send never moves the mesh's own core to a build older than the one its machine runs** (novox/hq issue
// 201). On 2026-10-02 two merges a minute apart made two plans; the controller's plan rolled the new controller,
// which widened its seat's row, and the node-engine's plan then sent the control node the controller build
// before it. The older binary read the row it could not serve and the mesh lost its voice for ten minutes — the
// thing that would have corrected it was the thing that was down. ADR 0185 took the outage out of it (a
// controller behind its row serves what it can); the race is closed here, without needing its trigger: whatever
// composed the older build, the send that would carry it is refused.
//
// Older is by the builds' registration order — when each was asked (novox/hq issue 219) — never by the
// commits' dates. A gate's own put-back is the one send that may carry it, for the modules it puts back: it says
// so (sendScope.puttingBack), and the controller's put-back is held to the store's schema already (issue 352).
// A person's push carries what it carries, as every other refusal of a send leaves it.
//
// The refusal is said in the plan's words — the send's error, which the plan keeps as its note and tries again
// — and as a warning condition on the machine, cleared by the first send there that carries no such move.
// errCoreDowngrade is a send refused because it would put a core module on a machine back behind the build that
// machine runs.
var errCoreDowngrade = errors.New("a send would put the mesh's own core back behind the build its machine runs")
// kindCoreDowngrade is that refusal, as a condition.
const kindCoreDowngrade = "core-downgrade-refused"
// coreDowngradeSource is the condition's source: the sends, which raise and clear it.
const coreDowngradeSource = "sends"
// downgradeGuarded is the core components a send may not move backwards: the controller, which reads the
// store and its seat's row, and the node-engine, which reads what the controller declares.
var downgradeGuarded = []string{lease.ComponentController, lease.ComponentEngine}
// buildAsked is when the newest build of a module from a commit that worked was asked — or recorded, when the
// request time is not known — and false when the records hold none. A variable, so a test reads its own order.
var buildAsked = func(ctx context.Context, inv *inventory.Inventory, module, commit string) (time.Time, bool, error) {
builds, err := inv.Builds(ctx, module, askedLookBack)
if err != nil {
return time.Time{}, false, err
}
for _, b := range builds {
if b.Worked() && sameCommit(b.Commit, commit) {
return b.AskedOrAt(), true, nil
}
}
return time.Time{}, false, nil
}
// askedLookBack is how many of a module's newest builds are read for when a build was asked.
const askedLookBack = 50
// coreDowngrade is one move of a core module to an older build than its machine runs.
type coreDowngrade struct {
move inventory.CarriedMove
component string
fromAsked, asked time.Time
}
func (d coreDowngrade) String() string {
return fmt.Sprintf("%s would be sent %s %s, older than the %s it runs (asked %s, before %s) — no send but a "+
"gate's own put-back moves the %s back (novox/hq issue 201)", d.move.Node, d.move.Module, short(d.move.To),
short(d.move.From), d.asked.UTC().Format(time.RFC3339), d.fromAsked.UTC().Format(time.RFC3339), d.component)
}
// coreDowngrades is the moves of a machine's send that put a core module back to a build asked before the one it
// runs, leaving out what a put-back puts back. A move whose either build the records do not hold is not proved
// older, and is left to the gate that judges it. Pure but for asked.
func coreDowngrades(moves []inventory.CarriedMove, scope sendScope,
asked func(module, commit string) (time.Time, bool, error)) ([]coreDowngrade, error) {
var out []coreDowngrade
for _, mv := range moves {
component := coreComponent(mv.Module)
if !guarded(component) || mv.From == "" || mv.To == "" || sameCommit(mv.From, mv.To) {
continue
}
if scope.puttingBack && scope.modules[mv.Module] {
continue
}
from, known, err := asked(mv.Module, mv.From)
if err != nil || !known {
if err != nil {
return nil, fmt.Errorf("when %s's build %s was asked cannot be read: %w", mv.Module, short(mv.From), err)
}
continue
}
to, known, err := asked(mv.Module, mv.To)
if err != nil {
return nil, fmt.Errorf("when %s's build %s was asked cannot be read: %w", mv.Module, short(mv.To), err)
}
if known && to.Before(from) {
out = append(out, coreDowngrade{move: mv, component: component, fromAsked: from, asked: to})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].move.Module < out[j].move.Module })
return out, nil
}
func guarded(component string) bool {
for _, c := range downgradeGuarded {
if c == component {
return true
}
}
return false
}
// refuseCoreDowngrades says, for one machine's moves, each that would put a core module back, raising its
// condition; and clears the condition of each guarded component that machine's send does not move back.
func refuseCoreDowngrades(ctx context.Context, inv *inventory.Inventory, scope sendScope, node string,
moves []inventory.CarriedMove) ([]string, error) {
found, err := coreDowngrades(moves, scope, func(module, commit string) (time.Time, bool, error) {
return buildAsked(ctx, inv, module, commit)
})
if err != nil {
return nil, err
}
var said []string
refused := map[string]bool{}
for _, d := range found {
said = append(said, d.String())
refused[d.component] = true
sayCoreDowngrade(ctx, d)
}
for _, c := range downgradeGuarded {
if !refused[c] {
clearCoreDowngrade(ctx, c, node)
}
}
return said, nil
}
// coreDowngradeObservation is a refused downgrade as a condition: `core.<component>.<machine>.<kind>`.
func coreDowngradeObservation(component, node, summary string) conditions.Observation {
return conditions.Observation{Scope: conditions.ScopeCore, ID: component + "." + node, Kind: kindCoreDowngrade,
Token: kindCoreDowngrade, Machine: node, Severity: conditions.Warning, Source: coreDowngradeSource,
Summary: summary}
}
func sayCoreDowngrade(ctx context.Context, d coreDowngrade) {
fmt.Printf("NOT SENT: %s\n", d)
keeper := conditionsFrom
if keeper == nil {
return
}
if _, err := keeper.Observe(ctx, coreDowngradeObservation(d.component, d.move.Node, d.String())); err != nil {
fmt.Printf("the condition for %s's refused downgrade on %s could not be raised: %v\n", d.move.Module,
d.move.Node, err)
}
}
func clearCoreDowngrade(ctx context.Context, component, node string) {
keeper := conditionsFrom
if keeper == nil {
return
}
key := coreDowngradeObservation(component, node, "").Key()
if _, err := keeper.Clear(ctx, key, "a send to "+node+" carries no older "+component); err != nil {
fmt.Printf("the condition %s could not be cleared: %v\n", key, err)
}
}