From d6ee77f17c65f7bcacf4ed30886ae4a2c340a544 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 20 Sep 2026 13:27:12 +0200 Subject: [PATCH] The consistency cascade sends tolerantly and stops loudly (issue 057 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two robustness fixes to the ADR 0083 cascade, from an adversarial review: - It routed swept machines through sendTo, which is all-or-nothing — so one swept machine's compose error failed the operator's named push and skipped its --wait, the intolerance the main path exists to avoid (ADR 0066). It now composes them through composeEach, exactly as the named send does: a machine that cannot be worked out is a refusal in the final report, and the rest are still sent. composeEach's tolerance is already covered by TestOneUnresolvableNodeStillLetsTheRestBeSent. - The fixed 4-round cap could stop a real cascade short in silence. The loop is now bounded by the node count (a node is flushed once and never revisited, so it cannot run longer) and says so if the guard is ever hit, rather than passing over an unfinished cascade quietly. Scope is unchanged: a named push still flushes every machine left behind, per ADR 0083 as accepted. --- cmd/mesh-controller/push.go | 49 ++++++++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/cmd/mesh-controller/push.go b/cmd/mesh-controller/push.go index 2713cd1..7a21c0e 100644 --- a/cmd/mesh-controller/push.go +++ b/cmd/mesh-controller/push.go @@ -336,7 +336,13 @@ func pushCommand(ctx context.Context, args []string) error { // flushed send may itself mint, so this converges over a few rounds. if len(args) == 1 { flushed := map[string]bool{args[0]: true} - for round := 0; round < 4; round++ { + // Bounded by the node count: a node is marked flushed the round it is handled and is + // never handled twice, so the loop cannot run more than len(nodes) rounds. The bound is + // a guard against a logic error, not a real limit — if it were ever hit, that is a bug + // rather than a cascade legitimately still converging, so it is said rather than passed + // over in silence, unlike the earlier fixed cap that could stop a real cascade short. + rounds := 0 + for { would, err := wouldSend(ctx, open, nodes) if err != nil { return err @@ -354,12 +360,49 @@ func pushCommand(ctx context.Context, args []string) error { if len(also) == 0 { break } + if rounds++; rounds > len(nodes) { + fmt.Printf("\nstopped cascading after %d rounds with %s still behind — this "+ + "should not happen; run `push --behind` to finish\n", + rounds-1, strings.Join(also, ", ")) + break + } sort.Strings(also) fmt.Printf("\nthis push left %s behind — a provision granted from there, or a "+ "declaration since changed; sending it too\n", strings.Join(also, ", ")) - if err := sendTo(ctx, open, also); err != nil { - return err + // Tolerantly, exactly as the named send above: a machine that cannot be composed is + // collected as a refusal and reported at the end, and the others are still sent + // (novox/hq ADR 0066). The earlier cut routed these through sendTo, which is + // all-or-nothing — so one swept machine's compose error failed the operator's named + // push and skipped its --wait, the very intolerance the main path exists to avoid. + sending, refused := composeEach(also, func(node string) ([]map[string]any, error) { + plan, settings, err := planFor(ctx, open, node) + if err != nil { + return nil, err + } + reportUnhostable(node, plan) + return declarationWith(ctx, open, node, plan, settings, gens, Allocating) + }) + refusals = append(refusals, refused...) + for _, s := range sending { + body, err := json.Marshal(map[string]any{"declaration": 1, "resources": s.resources}) + if err != nil { + return err + } + if err := link.Declare(ctx, server.Channel(), ident, s.node, body, 15*time.Second); err != nil { + return err + } + record, err := inv.NodeByName(ctx, s.node) + if err != nil { + return err + } + if err := inv.RecordSent(ctx, record.ID, digestOf(body)); err != nil { + return err + } + fmt.Printf("sent %s %d resource(s)\n", s.node, len(s.resources)) } + // Every candidate this round is marked handled — the sent ones so they are not + // re-listed, and the refused ones so a machine that cannot be composed does not make + // the loop spin on it for ever. Its refusal is already in the report. for _, name := range also { flushed[name] = true }