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
+4 -4
View File
@@ -131,10 +131,10 @@ func (e Enrolment) Heard(ctx context.Context, report Report) error {
if err != nil {
return err
}
// A refusal or a failure is not an account of what the machine holds, so it moves last_seen
// and nothing else. Recording a partial list as though it were the whole would tell a
// rebuilding node to remove what it still has.
if report.Refused != "" || len(report.Failed) > 0 {
// A refusal, a failure, or a bare word that the node is there — none of them is an account of
// what the machine holds, so each moves last_seen and nothing else. Recording a partial list
// as though it were the whole would tell a rebuilding node to remove what it still has.
if report.Refused != "" || len(report.Failed) > 0 || report.Applied == nil {
return e.Inventory.Seen(ctx, node.ID)
}
return e.Inventory.RecordOwned(ctx, node.ID, report.Applied)
+10
View File
@@ -17,6 +17,7 @@ const ControlQueue = "control"
const (
KeyEnrol = "enrol"
KeyReport = "report"
KeyAlive = "alive"
)
// QueueFor is the queue a node consumes from — the only one it may read.
@@ -59,6 +60,15 @@ type Signed struct {
Signature []byte `json:"signature"`
}
// Alive is a node saying nothing except that it is there.
//
// How long a node has been out of touch is a fact only the mesh can hold — nobody else is
// watching — and without it a node running last month's assignments looks exactly like one that
// is current.
type Alive struct {
Node string `json:"node"`
}
// Report is what a node states after applying. It states; the owning context writes.
type Report struct {
Node string `json:"node"`
+22 -2
View File
@@ -78,7 +78,7 @@ func Connect(enroller Enroller, listener Listener) (*Server, error) {
// accepts, finds no queue for, and drops — the publisher sees success and the consumer sees
// nothing. That is exactly what happened to reports: `report` was left unbound while `enrol`
// worked, so nodes announced what they had applied into a void for an afternoon.
for _, key := range []string{KeyEnrol, KeyReport} {
for _, key := range []string{KeyEnrol, KeyReport, KeyAlive} {
if err := channel.QueueBind(ControlQueue, key, Exchange, false, nil); err != nil {
conn.Close()
return nil, fmt.Errorf("cannot bind %s to %s/%s: %w", ControlQueue, Exchange, key, err)
@@ -121,7 +121,7 @@ func (s *Server) Serve(ctx context.Context) error {
}
closed := s.conn.NotifyClose(make(chan *amqp.Error, 1))
s.log.Printf("consuming %s, bound to %s/{%s,%s}", ControlQueue, Exchange, KeyEnrol, KeyReport)
s.log.Printf("consuming %s, bound to %s/{%s,%s,%s}", ControlQueue, Exchange, KeyEnrol, KeyReport, KeyAlive)
for {
select {
@@ -147,6 +147,8 @@ func (s *Server) handle(ctx context.Context, delivery amqp.Delivery) {
s.handleEnrol(ctx, delivery)
case KeyReport:
s.handleReport(delivery)
case KeyAlive:
s.handleAlive(delivery)
default:
// Rejected without requeue: a message nothing understands will not be understood on the
// next attempt either, and requeuing it would spin.
@@ -155,6 +157,24 @@ func (s *Server) handle(ctx context.Context, delivery amqp.Delivery) {
}
}
// handleAlive records that a node was heard from, and nothing else.
//
// Deliberately silent: 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.
func (s *Server) handleAlive(delivery amqp.Delivery) {
var alive Alive
if err := json.Unmarshal(delivery.Body, &alive); err != nil || alive.Node == "" {
_ = delivery.Reject(false)
return
}
if s.listener != nil {
if err := s.listener.Heard(context.Background(), Report{Node: alive.Node}); err != nil {
s.log.Printf("could not record that %s is here: %v", alive.Node, err)
}
}
_ = delivery.Ack(false)
}
// handleReport records what a node says it did.
//
// A node states; nothing here writes anything the node claimed about itself beyond that it was