From cae9a3e54f89763541e86cc7e7c3215a82751415 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 5 Sep 2026 22:37:53 +0200 Subject: [PATCH] A bare alive moves last_seen and nothing else A node says it is there every minute and describes what it applied rarely, and both went through Heard, which wrote every one down as a report. So a bare alive replaced the node's last real apply with an empty one -- clearing the declaration digest `current` is measured against, the carried ports a push assigns around, and the clean-or-failed outcome. A node that had just caught up read as behind within the minute, and never converged. Whether it converged in time was a race the node's own apply set: the link's one loop applies a declaration to completion before it can send the pending heartbeat, so a fast apply (catalogue-small) leaves the digest standing the ~60s until the next beat -- long enough for the lab to see `current` -- while a heavy wave whose apply outran the first beat (mongodb + unifi + marrytts) had the alive fire milliseconds after the report and never showed `current` at all, timing out settle even at 1200s. Heard now returns after moving last_seen for a report that carries no account of what the machine did -- nothing applied, nothing refused, nothing failed, which is exactly a bare alive. A real report always carries one. This is what the commit that began hearing alives said it did and did not: "a bare word that a node is there moves last_seen and touches nothing else." Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- internal/link/enrolment.go | 12 +++++++ internal/link/heard_test.go | 63 +++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/internal/link/enrolment.go b/internal/link/enrolment.go index 5531e68..dc7eef2 100644 --- a/internal/link/enrolment.go +++ b/internal/link/enrolment.go @@ -151,6 +151,18 @@ func (e Enrolment) Heard(ctx context.Context, report Report) error { if err != nil { return err } + + // A bare word that a node is there is not an account of what the machine did or holds: it + // moves last_seen and touches nothing else. This arrives every minute (link.AliveEvery), + // while a real report is rare, so recording it as one would overwrite the node's last real + // apply with an empty one — wiping the declaration digest that decides whether the node is + // current, the carried ports a push assigns around, and the clean-or-failed outcome — and a + // node that had just caught up would read as behind within the minute. The alive path calls + // this with only a node name; a real report always carries an account (something applied, or + // a refusal, or a failure), so those are the reports that get written down. + if report.Applied == nil && report.Refused == "" && len(report.Failed) == 0 { + return e.Inventory.Seen(ctx, node.ID) + } // What it did is kept whichever way it went. Until this, a refusal or a failure moved // last_seen and the reason went to a log line, so "which machine is not doing what it was // told" had no answer the next morning — which is the question a mesh exists to answer. diff --git a/internal/link/heard_test.go b/internal/link/heard_test.go index d339054..c9b438d 100644 --- a/internal/link/heard_test.go +++ b/internal/link/heard_test.go @@ -86,6 +86,69 @@ func TestACleanApplyIsRecordedAsOne(t *testing.T) { } } +func TestABareAliveDoesNotWipeTheDeclarationThatSaysANodeIsCurrent(t *testing.T) { + // A node reports it is alive every minute and describes what it applied rarely. If a bare + // alive were written down as a report it would replace the last real apply with an empty one + // — clearing the declaration digest current is measured against — so a node that had just + // caught up would read as behind within the minute, and never converge. The lab saw exactly + // this: a heavy wave whose apply outran the first heartbeat never reached `current`. + inv := inventory.ForTest(t) + ctx := context.Background() + node, err := inv.AddNode(ctx, "anchor") + if err != nil { + t.Fatal(err) + } + // The mesh sent this node a declaration, and the node applied it and named which by digest. + const digest = "d640d1b6a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f6071829304152" + if err := inv.RecordSent(ctx, node.ID, digest); err != nil { + t.Fatal(err) + } + if err := (link.Enrolment{Inventory: inv}).Heard(ctx, link.Report{ + Node: "anchor", Applied: []string{"a", "b"}, Declared: digest, Carried: []int{5432}, + }); err != nil { + t.Fatal(err) + } + currentOf := func(name string) bool { + reports, err := inv.LastReports(ctx) + if err != nil { + t.Fatal(err) + } + for _, r := range reports { + if r.Node == name { + return r.Current + } + } + t.Fatalf("no report for %s", name) + return false + } + if !currentOf("anchor") { + t.Fatal("a node that applied exactly what it was sent does not read as current") + } + + // Now the node says only that it is there, as it does every minute. + if err := (link.Enrolment{Inventory: inv}).Heard(ctx, link.Report{Node: "anchor"}); err != nil { + t.Fatal(err) + } + if !currentOf("anchor") { + t.Fatal("a bare alive wiped the declaration digest, so a current node now reads as behind") + } + // And the last real account of what it did and holds is untouched. + doing, said, err := inv.DoingOf(ctx, "anchor") + if err != nil { + t.Fatal(err) + } + if !said || doing.Outcome != inventory.OutcomeApplied || doing.Applied != 2 { + t.Fatalf("a bare alive overwrote the last real report: said=%v %+v", said, doing) + } + owned, _, err := inv.Owned(ctx, node.ID) + if err != nil { + t.Fatal(err) + } + if len(owned) != 2 { + t.Fatalf("a bare alive replaced the account of what the machine holds: %v", owned) + } +} + func TestAFailureDoesNotBecomeTheAccountOfWhatTheMachineHolds(t *testing.T) { // A partial list is not an account of what the machine holds. Recording one as though it // were would tell a rebuilding node to remove what it still has — which is the fault that -- 2.54.0