A seat is handed over as one act, and the holder is on record
`seat <name> --to <node>/<module>` makes one assignment the holder of a seat in the same write that removes the previous one. The row is new (migration 0039); without one, the resolver derives the holder as it always did — the sole eligible assignment, two refused — so nothing changes for a mesh that never hands a seat over. With one, the recorded assignment holds and any other whose module could hold the seat is eligible and silent: not refused, not holding. That is what lets the next holder run beside the current one until the switch (hq design 26, design 28 task 5.3, ADR 0131). Why: the controller finds its own bus through a seat, and the day that seat was left with nobody in it — because two eligible holders could not coexist and the old one's claim was taken away — the control plane looped for two hours while every service stayed up. A handover that is never empty in between is the fix, not a workaround for it. `CanHold` is the one judgement of whether a module may hold a seat — claims it at its scope, provides what it delivers, against the store's row — shared by registration and the handover so they cannot drift apart. The holding belongs to the assignment and goes when it does, so a seat never points at nothing running. Tests: the resolver with and without a record, on the same and another machine, under a former name; the store's row replaced not added, refused for an unassigned target, removed with its assignment; CanHold's four answers and that they follow the store. Full suite green against a real NATS and store.
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// **A seat's holder on record settles who holds it, and lets the next holder stand beside the
|
||||
// current one** (novox/hq ADR 0131, design 28 task 5.3). Until the record existed, two assignments
|
||||
// whose modules both claimed a seat were refused outright — which left no way to hand a seat over
|
||||
// without a moment where nobody held it, and the control plane finds its own bus through one of
|
||||
// these seats. That moment was the outage of 2026-09-27.
|
||||
|
||||
func busSeatDelivering(t *testing.T, delivers string) {
|
||||
t.Helper()
|
||||
was := Seats()
|
||||
t.Cleanup(func() { UseSeats(was) })
|
||||
UseSeats([]Seat{{Name: "mesh-broker", Scope: ScopeMesh, Delivers: delivers, Decision: "test"}})
|
||||
}
|
||||
|
||||
func oldBroker() Manifest {
|
||||
return Manifest{Module: "old-broker", Provides: []Offer{{Name: "mesh-bus", Scope: ScopeMesh}},
|
||||
Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh}}}
|
||||
}
|
||||
|
||||
func newBroker() Manifest {
|
||||
return Manifest{Module: "new-broker", Provides: []Offer{{Name: "mesh-bus", Scope: ScopeMesh}},
|
||||
Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh}}}
|
||||
}
|
||||
|
||||
// Nothing on record: exactly the old rule. One claimant holds; two are refused.
|
||||
func TestWithNoHolderOnRecordTheSoleClaimantHoldsAndTwoAreRefused(t *testing.T) {
|
||||
busSeatDelivering(t, "mesh-bus")
|
||||
node := Node{Name: "anchor"}
|
||||
|
||||
held, problems := checkClaims([]Manifest{oldBroker()}, node, nil, nil)
|
||||
if len(problems) != 0 || len(held) != 1 || held[0].Module != "old-broker" {
|
||||
t.Fatalf("a sole claimant did not hold the seat: held=%v problems=%v", held, problems)
|
||||
}
|
||||
_, problems = checkClaims([]Manifest{oldBroker(), newBroker()}, node, nil, nil)
|
||||
if len(problems) != 1 || !strings.Contains(problems[0], "both claim") {
|
||||
t.Fatalf("two claimants with nothing on record were not refused: %v", problems)
|
||||
}
|
||||
}
|
||||
|
||||
// With a holder on record, the other eligible assignment is silent: not refused, and not holding.
|
||||
func TestTheHolderOnRecordHoldsAndTheOtherClaimantStandsBesideIt(t *testing.T) {
|
||||
busSeatDelivering(t, "mesh-bus")
|
||||
node := Node{Name: "anchor"}
|
||||
record := []Held{{Claim: "mesh-broker", Scope: ScopeMesh, Node: "anchor", Module: "new-broker"}}
|
||||
|
||||
held, problems := checkClaims([]Manifest{oldBroker(), newBroker()}, node, nil, record)
|
||||
if len(problems) != 0 {
|
||||
t.Fatalf("the assignment beside the holder was refused: %v", problems)
|
||||
}
|
||||
if len(held) != 1 || held[0].Module != "new-broker" {
|
||||
t.Fatalf("the holder on record is not the one holding: %v", held)
|
||||
}
|
||||
}
|
||||
|
||||
// The record names a node too: an eligible module on another machine holds nothing, and its
|
||||
// machine's set still resolves.
|
||||
func TestAHolderOnRecordElsewhereLeavesThisMachinesClaimantSilent(t *testing.T) {
|
||||
busSeatDelivering(t, "mesh-bus")
|
||||
record := []Held{{Claim: "mesh-broker", Scope: ScopeMesh, Node: "anchor", Module: "new-broker"}}
|
||||
|
||||
held, problems := checkClaims([]Manifest{oldBroker()}, Node{Name: "laptop"}, nil, record)
|
||||
if len(problems) != 0 || len(held) != 0 {
|
||||
t.Fatalf("a claimant elsewhere than the recorded holder was not simply silent: held=%v problems=%v",
|
||||
held, problems)
|
||||
}
|
||||
}
|
||||
|
||||
// A record naming a seat's former name still applies to it after a rename (ADR 0122).
|
||||
func TestAHolderRecordedUnderAFormerNameStillHolds(t *testing.T) {
|
||||
busSeatDelivering(t, "mesh-bus")
|
||||
wasAliases := aliases
|
||||
t.Cleanup(func() { UseAliases(wasAliases) })
|
||||
UseAliases(map[string]string{"the-broker": "mesh-broker"})
|
||||
record := []Held{{Claim: "the-broker", Scope: ScopeMesh, Node: "anchor", Module: "new-broker"}}
|
||||
|
||||
held, problems := checkClaims([]Manifest{oldBroker(), newBroker()}, Node{Name: "anchor"}, nil, record)
|
||||
if len(problems) != 0 || len(held) != 1 || held[0].Module != "new-broker" {
|
||||
t.Fatalf("a record under the former name did not settle the seat: held=%v problems=%v", held, problems)
|
||||
}
|
||||
}
|
||||
|
||||
// CanHold is the one judgement registration and the handover share, against the store's row.
|
||||
func TestCanHoldJudgesClaimScopeAndWhatTheSeatDelivers(t *testing.T) {
|
||||
busSeatDelivering(t, "mesh-bus")
|
||||
seat, _ := SeatNamed("mesh-broker")
|
||||
|
||||
if err := CanHold(newBroker(), seat); err != nil {
|
||||
t.Fatalf("a module that claims the seat and provides what it delivers was refused: %v", err)
|
||||
}
|
||||
noClaim := Manifest{Module: "quiet", Provides: []Offer{{Name: "mesh-bus", Scope: ScopeMesh}}}
|
||||
if err := CanHold(noClaim, seat); err == nil || !strings.Contains(err.Error(), "does not claim") {
|
||||
t.Fatalf("a module that never claimed the seat was allowed to hold it: %v", err)
|
||||
}
|
||||
wrongScope := newBroker()
|
||||
wrongScope.Claims[0].Scope = ScopeNode
|
||||
if err := CanHold(wrongScope, seat); err == nil || !strings.Contains(err.Error(), "scope") {
|
||||
t.Fatalf("a claim at the wrong scope was allowed: %v", err)
|
||||
}
|
||||
cannotAnswer := Manifest{Module: "amqp-only", Provides: []Offer{{Name: "amqp", Scope: ScopeMesh}},
|
||||
Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh}}}
|
||||
if err := CanHold(cannotAnswer, seat); err == nil || !strings.Contains(err.Error(), `does not provide "mesh-bus"`) {
|
||||
t.Fatalf("a holder that cannot answer for the seat was allowed: %v", err)
|
||||
}
|
||||
// And the judgement follows the store's row, not a compiled copy.
|
||||
busSeatDelivering(t, "amqp")
|
||||
seat, _ = SeatNamed("mesh-broker")
|
||||
if err := CanHold(cannotAnswer, seat); err != nil {
|
||||
t.Fatalf("with the row saying amqp, an amqp provider was refused: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user