The mesh knows who is out of touch

09-the-node-lifecycle asks for this in as many words -- *how long it has been
disconnected is a fact the mesh must hold, and nothing holds it today. Without
it, a node running last month's assignments looks exactly like one that is
current.* Now it holds it.

`node list` says "here", "out of touch 4m", or "never spoken", and the third is
kept distinct from the second on purpose: a node that has never spoken did not
finish joining, and a node last heard from a month ago is running a month-old
picture of the mesh. Those need different responses from a person.

A bare word that a node is there moves last_seen and touches nothing else. It
is not an account of what the machine holds, and recording it as one would
replace the recovery copy with an empty list every minute -- so a rebuilding
node would then be told it owns nothing and remove whatever it found. There is
a test for exactly that.

Heard is silent in the log. A node saying it is there every minute would fill
the log with the ordinary case, and a log where the ordinary case is loud is a
log nobody reads.

Verified in the lab across the threshold, both directions.
This commit is contained in:
2026-08-29 20:32:27 +02:00
parent fc1417be72
commit f0cff88172
6 changed files with 153 additions and 9 deletions
+59
View File
@@ -374,3 +374,62 @@ func TestAnAnswerAboutAMachineCarriesItsAge(t *testing.T) {
t.Errorf("the report time is %s, which is before the report", reported)
}
}
func TestNeverHeardFromIsNotTheSameAsLongAgo(t *testing.T) {
// The distinction the whole thing rests on. A node that has never spoken did not finish
// joining; a node last heard from a month ago is running a month-old picture of the mesh.
// Until this existed both looked exactly like a node that is current.
inv := fresh(t)
node, err := inv.AddNode(t.Context(), "laptop")
if err != nil {
t.Fatal(err)
}
nodes, err := inv.Nodes(t.Context())
if err != nil {
t.Fatal(err)
}
if _, ever := nodes[0].Silent(); ever {
t.Error("a node that has never spoken reports a time since it last did")
}
if err := inv.Seen(t.Context(), node.ID); err != nil {
t.Fatal(err)
}
nodes, err = inv.Nodes(t.Context())
if err != nil {
t.Fatal(err)
}
silent, ever := nodes[0].Silent()
if !ever {
t.Fatal("a node that has spoken still reports never having done so")
}
if silent > time.Minute {
t.Errorf("a node heard from just now has been silent for %s", silent)
}
}
func TestBeingHeardFromDoesNotChangeWhatANodeOwns(t *testing.T) {
// A node saying it is there is not an account of what it holds. Treating one as the other
// would replace the recovery copy with an empty list every minute, and a rebuilding node
// would then be told it owns nothing.
inv := fresh(t)
node, err := inv.AddNode(t.Context(), "laptop")
if err != nil {
t.Fatal(err)
}
if err := inv.RecordOwned(t.Context(), node.ID, []string{"a", "b"}); err != nil {
t.Fatal(err)
}
if err := inv.Seen(t.Context(), node.ID); err != nil {
t.Fatal(err)
}
owned, _, err := inv.Owned(t.Context(), node.ID)
if err != nil {
t.Fatal(err)
}
if len(owned) != 2 {
t.Errorf("after a bare word that the node is here, the mesh believes it owns %v", owned)
}
}
+22 -2
View File
@@ -40,6 +40,22 @@ type Node struct {
ID string
Name string
Created time.Time
// LastSeen is when this node was last heard from, or zero if it never has been.
//
// Zero and long-ago are different answers and are kept different. A node that has never
// spoken has not joined properly; a node that spoke last month is a node running last
// month's assignments, and until this existed those looked the same as a node that is
// current (novox/hq 09-the-node-lifecycle).
LastSeen time.Time
}
// Silent is how long since this node was last heard from, and whether it ever was.
func (n Node) Silent() (time.Duration, bool) {
if n.LastSeen.IsZero() {
return 0, false
}
return time.Since(n.LastSeen), true
}
// ErrNoSuchNode is returned when a name matches no record.
@@ -78,7 +94,7 @@ func (i *Inventory) AddNode(ctx context.Context, name string) (Node, error) {
// Nodes are every node record, oldest first.
func (i *Inventory) Nodes(ctx context.Context) ([]Node, error) {
rows, err := i.store.Pool().Query(ctx,
`select id, name, created from node order by created, name`)
`select id, name, created, last_seen from node order by created, name`)
if err != nil {
return nil, err
}
@@ -87,9 +103,13 @@ func (i *Inventory) Nodes(ctx context.Context) ([]Node, error) {
var nodes []Node
for rows.Next() {
var n Node
if err := rows.Scan(&n.ID, &n.Name, &n.Created); err != nil {
var seen *time.Time
if err := rows.Scan(&n.ID, &n.Name, &n.Created, &seen); err != nil {
return nil, err
}
if seen != nil {
n.LastSeen = *seen
}
nodes = append(nodes, n)
}
return nodes, rows.Err()