`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.
117 lines
5.2 KiB
Go
117 lines
5.2 KiB
Go
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)
|
|
}
|
|
}
|