Merge pull request 'A seat is handed over as one act, and the holder is on record' (#91) from feat/seat-handover into main
This commit was merged in pull request #91.
This commit is contained in:
@@ -185,6 +185,15 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory,
|
||||
|
||||
// Every node, not only the placed ones. A machine that was never put on the private network
|
||||
// still runs modules, still holds claims, and still offers whatever it offers.
|
||||
// **Who holds each seat on record, before anything is resolved** (novox/hq ADR 0131). Both
|
||||
// passes below need it: without it, the assignment standing beside a seat's holder — the next
|
||||
// holder, waiting for the handover — is refused as a second holder, and its node's whole set
|
||||
// with it.
|
||||
holdings, err := inv.Holdings(ctx)
|
||||
if err != nil {
|
||||
return catalogue.World{}, err
|
||||
}
|
||||
|
||||
nodes, err := inv.Nodes(ctx)
|
||||
if err != nil {
|
||||
return catalogue.World{}, err
|
||||
@@ -227,7 +236,7 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory,
|
||||
offered := map[string][]catalogue.Provider{}
|
||||
var firstHeld []catalogue.Held
|
||||
for _, o := range others {
|
||||
got, err := catalogue.Resolve(shelf, o.assigned, o.node, catalogue.World{Unchecked: true})
|
||||
got, err := catalogue.Resolve(shelf, o.assigned, o.node, catalogue.World{Unchecked: true, Holdings: holdings})
|
||||
if err != nil {
|
||||
// Their set does not resolve for some other reason. Not this node's problem to
|
||||
// report, and nothing of theirs is running, so it offers nothing.
|
||||
@@ -258,7 +267,7 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory,
|
||||
// with several providers (novox/hq ADR 0110), so a node consuming one resolves only once the
|
||||
// holder is known. Without them its set is refused here, and a refused node's own claims drop
|
||||
// out of what the mesh holds — so a second holder of one of its seats would pass unrefused.
|
||||
world := catalogue.World{Offered: offered, Held: firstHeld}
|
||||
world := catalogue.World{Offered: offered, Held: firstHeld, Holdings: holdings}
|
||||
var held []catalogue.Held
|
||||
for _, o := range others {
|
||||
got, err := catalogue.Resolve(shelf, o.assigned, o.node, world)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
@@ -85,7 +86,8 @@ func seatsHeld(seats []catalogue.Seat, held []catalogue.Held) ([]seatRow, []cata
|
||||
return rows, outside
|
||||
}
|
||||
|
||||
// seatCommand changes the set — the whole point of it being data (novox/hq ADR 0122).
|
||||
// seatCommand changes the set — the whole point of it being data (novox/hq ADR 0122) — and, since
|
||||
// ADR 0131, changes who holds a seat.
|
||||
func seatCommand(ctx context.Context, args []string) error {
|
||||
if len(args) == 3 && args[0] == "rename" {
|
||||
from, to := args[1], args[2]
|
||||
@@ -101,7 +103,82 @@ func seatCommand(ctx context.Context, args []string) error {
|
||||
"re-registered or frozen (novox/hq ADR 0122)\n", from, to)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("seat rename <from> <to>")
|
||||
if len(args) == 3 && args[1] == "--to" {
|
||||
return handOver(ctx, args[0], args[2])
|
||||
}
|
||||
return fmt.Errorf("seat rename <from> <to> | seat <name> --to <node>/<module>")
|
||||
}
|
||||
|
||||
// handOver makes one assignment the holder of a seat, as one act, so the seat is never without a
|
||||
// holder in between (novox/hq ADR 0131, design 28 task 5.3). The control plane finds its own bus
|
||||
// through one of these seats; the day it was left empty mid-change is why this exists.
|
||||
//
|
||||
// Everything that could make the new holder wrong is refused here, before the row is written: the
|
||||
// seat must exist, the assignment must exist, and the module must be able to hold the seat —
|
||||
// claim it at its scope and provide what it delivers, judged against the store's row. What is
|
||||
// **not** checked is whether the module is running yet: that is what `push` confirms afterwards,
|
||||
// and refusing to record a handover to a module the node has not started would make the handover
|
||||
// impossible to do before the switch instead of as the switch.
|
||||
func handOver(ctx context.Context, seatName, to string) error {
|
||||
nodeName, module, ok := strings.Cut(to, "/")
|
||||
if !ok || nodeName == "" || module == "" {
|
||||
return fmt.Errorf("the new holder is named <node>/<module>, not %q", to)
|
||||
}
|
||||
open, err := openStores(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer open.Close()
|
||||
inv := open.inventory
|
||||
|
||||
seat, known := catalogue.SeatNamed(seatName)
|
||||
if !known {
|
||||
return fmt.Errorf("%q is not a seat this mesh defines — `seats` lists them", seatName)
|
||||
}
|
||||
assigned, err := inv.Assigned(ctx, nodeName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !slices.Contains(assigned, module) {
|
||||
return fmt.Errorf("%s is not assigned to %s, so it cannot hold anything there — "+
|
||||
"`assign %s %s` first", module, nodeName, nodeName, module)
|
||||
}
|
||||
entries, err := inv.Catalogued(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var m *catalogue.Manifest
|
||||
for i := range entries {
|
||||
if entries[i].Manifest.Module == module {
|
||||
m = &entries[i].Manifest
|
||||
}
|
||||
}
|
||||
if m == nil {
|
||||
return fmt.Errorf("%s is assigned but not in the catalogue, which should not happen", module)
|
||||
}
|
||||
if err := catalogue.CanHold(*m, seat); err != nil {
|
||||
return fmt.Errorf("%s cannot hold %s: %w", module, seat.Name, err)
|
||||
}
|
||||
|
||||
var was string
|
||||
if holdings, err := inv.Holdings(ctx); err == nil {
|
||||
for _, h := range holdings {
|
||||
if hs, ok := catalogue.SeatNamed(h.Claim); ok && hs.Name == seat.Name {
|
||||
was = h.Node
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := inv.HoldSeat(ctx, seat.Name, seat.Scope, nodeName, module); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s is held by %s on %s\n", seat.Name, module, nodeName)
|
||||
if was != "" && was != nodeName {
|
||||
fmt.Printf(" `push %s` and `push %s` send both machines what changed\n", was, nodeName)
|
||||
} else {
|
||||
fmt.Printf(" `push %s` sends the machine what changed; every other machine that reads the "+
|
||||
"seat is re-declared by `push --behind`\n", nodeName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func seatsCommand(ctx context.Context, args []string) error {
|
||||
|
||||
@@ -46,21 +46,39 @@ func TestTheSeatRefusesADifferentBusToo(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The old broker no longer claims the seat: it is an ordinary provider of `amqp`
|
||||
// (novox/hq ADR 0119), so it can sit on the same mesh as the bus without contending for it.
|
||||
func TestTheAmqpBrokerDoesNotContendForTheSeat(t *testing.T) {
|
||||
// **The old broker claims the seat until the seat is handed over, and stands beside the new one
|
||||
// while it waits** (novox/hq ADR 0131, superseding the record this test used to pin). Whoever is on
|
||||
// record holds it; the other eligible claimant is neither refused nor holding. This is the shape the
|
||||
// handover needs: both brokers assigned, one bus, no moment with nobody in the seat.
|
||||
func TestTheOldBrokerStandsBesideTheNewOneUntilTheHandover(t *testing.T) {
|
||||
was := Seats()
|
||||
t.Cleanup(func() { UseSeats(was) })
|
||||
UseSeats([]Seat{{Name: "mesh-broker", Scope: ScopeMesh, Delivers: "mesh-bus", Decision: "test"}})
|
||||
|
||||
lavinmq := catalogueManifest(t, "lavinmq")
|
||||
for _, c := range lavinmq.Claims {
|
||||
if c.Name == "mesh-broker" {
|
||||
t.Fatal("the amqp broker still claims mesh-broker; it is a provider, not foundation")
|
||||
if !lavinmq.ClaimsSeat("mesh-broker") {
|
||||
t.Skip("the old broker no longer claims the seat: design 28 task 5.4 has removed it")
|
||||
}
|
||||
}
|
||||
busHeld := World{Held: []Held{{Claim: "mesh-broker", Scope: ScopeMesh,
|
||||
nats := catalogueManifest(t, "nats")
|
||||
onRecord := World{Holdings: []Held{{Claim: "mesh-broker", Scope: ScopeMesh,
|
||||
Node: "anchor", Module: "nats"}}}
|
||||
other := workstation()
|
||||
other.Name = "laptop"
|
||||
if _, err := Resolve(shelf(lavinmq), []string{"lavinmq"}, other, busHeld); err != nil {
|
||||
t.Fatalf("the amqp broker was refused beside the mesh bus: %v", err)
|
||||
|
||||
// The same machine runs both. Without the record this is two holders and refused; with it, the
|
||||
// recorded one holds and the other is silent.
|
||||
anchor := workstation()
|
||||
anchor.Name = "anchor"
|
||||
got, err := Resolve(shelf(lavinmq, nats), []string{"lavinmq", "nats"}, anchor, onRecord)
|
||||
if err != nil {
|
||||
t.Fatalf("the old broker beside the recorded holder was refused: %v", err)
|
||||
}
|
||||
var holders []string
|
||||
for _, h := range got.Claims {
|
||||
if h.Claim == "mesh-broker" {
|
||||
holders = append(holders, h.Module)
|
||||
}
|
||||
}
|
||||
if len(holders) != 1 || holders[0] != "nats" {
|
||||
t.Fatalf("the seat is held by %v, not by the holder on record alone", holders)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,11 @@ type Node struct {
|
||||
type World struct {
|
||||
// Held is the claims already taken, for the scopes wider than one node.
|
||||
Held []Held
|
||||
// Holdings is every seat whose holder is **on record** (novox/hq ADR 0131): the one assignment
|
||||
// that holds it, chosen by a handover. A seat absent here is held by derivation — the sole
|
||||
// eligible assignment — as it always was. Present, it decides, and any other assignment whose
|
||||
// module could hold the seat is eligible and silent rather than refused.
|
||||
Holdings []Held
|
||||
// Offered is what other nodes provide at mesh scope, and everything needed to use it.
|
||||
Offered map[string][]Provider
|
||||
// Pinned is which node this machine was told to get a provision from, by name. Only consulted
|
||||
@@ -557,7 +562,7 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
||||
}
|
||||
|
||||
problems = append(problems, checkCapabilities(resolution.Modules, node)...)
|
||||
claims, claimProblems := checkClaims(resolution.Modules, node, elsewhere)
|
||||
claims, claimProblems := checkClaims(resolution.Modules, node, elsewhere, world.Holdings)
|
||||
problems = append(problems, claimProblems...)
|
||||
problems = append(problems, checkResources(resolution.Modules)...)
|
||||
resolution.Claims = claims
|
||||
@@ -650,14 +655,35 @@ func checkCapabilities(modules []Manifest, node Node) []string {
|
||||
//
|
||||
// Within this node's own set, and against what is already held elsewhere for the wider scopes. A
|
||||
// claim at mesh scope is the same idea as the mesh's one hub, said once instead of hard-coded.
|
||||
func checkClaims(modules []Manifest, node Node, elsewhere []Held) ([]Held, []string) {
|
||||
func checkClaims(modules []Manifest, node Node, elsewhere []Held, holdings []Held) ([]Held, []string) {
|
||||
var problems []string
|
||||
var held []Held
|
||||
|
||||
// onRecord is the recorded holder of a seat, if a handover ever named one.
|
||||
onRecord := func(claim, scope string) (Held, bool) {
|
||||
for _, h := range holdings {
|
||||
hs, ok := SeatNamed(h.Claim)
|
||||
cs, cok := SeatNamed(claim)
|
||||
if ok && cok && hs.Name == cs.Name && h.Scope == scope {
|
||||
return h, true
|
||||
}
|
||||
}
|
||||
return Held{}, false
|
||||
}
|
||||
|
||||
byScope := map[string]map[string]string{} // scope → claim → module
|
||||
for _, m := range modules {
|
||||
for _, c := range m.Claims {
|
||||
scope := c.At()
|
||||
// **A recorded holder settles it before any counting.** An assignment that could hold
|
||||
// the seat but is not the one on record is eligible, and that is all: it is not a second
|
||||
// holder, so it is not refused, and it does not hold (novox/hq ADR 0131). This is what
|
||||
// lets the next holder stand beside the current one until the seat is handed over.
|
||||
if rec, recorded := onRecord(c.Name, scope); recorded {
|
||||
if rec.Node != node.Name || rec.Module != m.Module {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if byScope[scope] == nil {
|
||||
byScope[scope] = map[string]string{}
|
||||
}
|
||||
|
||||
@@ -222,6 +222,31 @@ func claimProblems(m Manifest) []string {
|
||||
return problems
|
||||
}
|
||||
|
||||
// CanHold is why a module could not hold a seat, or nothing: its definition must claim the seat at
|
||||
// the seat's scope, and provide what the seat delivers, if it delivers anything. The seat is the
|
||||
// store's row, so this is judged only where the store's set is loaded — at registration and in the
|
||||
// handover command (novox/hq ADR 0131), never in the parser.
|
||||
func CanHold(m Manifest, seat Seat) error {
|
||||
var claimed *Claim
|
||||
for i := range m.Claims {
|
||||
if hs, ok := SeatNamed(m.Claims[i].Name); ok && hs.Name == seat.Name {
|
||||
claimed = &m.Claims[i]
|
||||
}
|
||||
}
|
||||
if claimed == nil {
|
||||
return fmt.Errorf("%s does not claim %s", m.Module, seat.Name)
|
||||
}
|
||||
if claimed.At() != seat.Scope {
|
||||
return fmt.Errorf("%s claims %s at scope %q, and %s is a %s seat",
|
||||
m.Module, seat.Name, claimed.At(), seat.Name, seat.Scope)
|
||||
}
|
||||
if seat.Delivers != "" && !providesAt(m, seat.Delivers, seat.Scope) {
|
||||
return fmt.Errorf("%s claims %s, whose holder answers for %q, and %s does not provide %q at %s scope",
|
||||
m.Module, seat.Name, seat.Delivers, m.Module, seat.Delivers, seat.Scope)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func providesAt(m Manifest, provision, scope string) bool {
|
||||
for _, o := range m.Provides {
|
||||
if o.Name == provision && o.At() == scope {
|
||||
|
||||
@@ -195,15 +195,8 @@ func CatalogueProblems(shelf Shelf) []string {
|
||||
// The parser cannot do it — it also runs on the build machine, against whatever
|
||||
// set that binary was compiled with.
|
||||
seat, _ := SeatNamed(c.Name)
|
||||
if c.At() != seat.Scope {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s claims %s at scope %q, and %s is a %s seat",
|
||||
module, c.Name, c.At(), c.Name, seat.Scope))
|
||||
}
|
||||
if seat.Delivers != "" && !providesAt(m, seat.Delivers, seat.Scope) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s claims %s, whose holder answers for %q, and %s does not provide %q at %s scope",
|
||||
module, c.Name, seat.Delivers, module, seat.Delivers, seat.Scope))
|
||||
if err := CanHold(m, seat); err != nil {
|
||||
problems = append(problems, err.Error())
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// A seat's holder is a row, changed as one act (novox/hq ADR 0131). What these pin is the shape of
|
||||
// that row's life: it needs an assignment to point at, it is replaced rather than added to, and it
|
||||
// goes when the assignment does — so a seat never points at something that is not running anywhere.
|
||||
|
||||
func twoBrokersOnTwoNodes(t *testing.T) (*Inventory, context.Context) {
|
||||
t.Helper()
|
||||
old := catalogue.Manifest{Module: "old-broker", Version: "1",
|
||||
Provides: []catalogue.Offer{{Name: "mesh-bus", Scope: catalogue.ScopeMesh}},
|
||||
Claims: []catalogue.Claim{{Name: "mesh-broker", Scope: catalogue.ScopeMesh}}}
|
||||
new := catalogue.Manifest{Module: "new-broker", Version: "1",
|
||||
Provides: []catalogue.Offer{{Name: "mesh-bus", Scope: catalogue.ScopeMesh}},
|
||||
Claims: []catalogue.Claim{{Name: "mesh-broker", Scope: catalogue.ScopeMesh}}}
|
||||
inv, ctx := aMeshWith(t, old, new)
|
||||
// The holding references the seat's row, which `migrate` seeds on a real mesh.
|
||||
if _, err := inv.SeedSeats(ctx, catalogue.DefaultSeats()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, n := range []string{"anchor", "laptop"} {
|
||||
if _, err := inv.AddNode(ctx, n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := inv.Assign(ctx, "anchor", "old-broker"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := inv.Assign(ctx, "laptop", "new-broker"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return inv, ctx
|
||||
}
|
||||
|
||||
func TestAHandoverIsOneRowReplacedNotOneAdded(t *testing.T) {
|
||||
inv, ctx := twoBrokersOnTwoNodes(t)
|
||||
|
||||
if err := inv.HoldSeat(ctx, "mesh-broker", catalogue.ScopeMesh, "anchor", "old-broker"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.HoldSeat(ctx, "mesh-broker", catalogue.ScopeMesh, "laptop", "new-broker"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
held, err := inv.Holdings(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(held) != 1 || held[0].Node != "laptop" || held[0].Module != "new-broker" || held[0].Scope != catalogue.ScopeMesh {
|
||||
t.Fatalf("after a handover the seat is not held by exactly the new holder: %+v", held)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASeatCannotBeHandedToSomethingNotAssigned(t *testing.T) {
|
||||
inv, ctx := twoBrokersOnTwoNodes(t)
|
||||
// new-broker is assigned to laptop, not anchor.
|
||||
if err := inv.HoldSeat(ctx, "mesh-broker", catalogue.ScopeMesh, "anchor", "new-broker"); err == nil {
|
||||
t.Fatal("a seat was handed to a module not assigned where it was named")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnassigningTheHolderTakesTheHoldingWithIt(t *testing.T) {
|
||||
inv, ctx := twoBrokersOnTwoNodes(t)
|
||||
if err := inv.HoldSeat(ctx, "mesh-broker", catalogue.ScopeMesh, "laptop", "new-broker"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.Unassign(ctx, "laptop", "new-broker"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
held, err := inv.Holdings(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(held) != 0 {
|
||||
t.Fatalf("the holding outlived the assignment it pointed at: %+v", held)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
-- A seat's holder is a recorded fact, not a derivation (novox/hq ADR 0131, design 26).
|
||||
--
|
||||
-- Until this, "which assignment holds the seat" was derived: the module that is assigned and
|
||||
-- claims the seat holds it, and a second eligible assignment was refused at resolution. That has no
|
||||
-- way to hand a seat from one holder to the next without a moment where nothing holds it — and the
|
||||
-- control plane finds its own bus through one of these seats, so that moment was an outage
|
||||
-- (2026-09-27). Now the holder is one row here, changed by `seat <name> --to <node>/<module>` as one
|
||||
-- act, and other assignments whose module could hold the seat are simply eligible and silent.
|
||||
--
|
||||
-- No row means what it always meant: the sole eligible assignment holds the seat, and two eligible
|
||||
-- ones are refused. So a mesh that has never handed a seat over behaves exactly as before, and the
|
||||
-- row appears the first time somebody does.
|
||||
--
|
||||
-- The seat is referenced by name because claims still are (0034); the rename cascades here so a
|
||||
-- handed-over seat survives being renamed. The holder is the assignment itself, so unassigning it
|
||||
-- takes the holding with it and the seat falls back to derivation rather than pointing at nothing.
|
||||
create table seat_holding (
|
||||
seat text primary key references seat(name) on update cascade on delete cascade,
|
||||
scope text not null,
|
||||
node uuid not null,
|
||||
module text not null,
|
||||
since timestamptz not null default now(),
|
||||
foreign key (node, module) references assignment(node, module) on delete cascade
|
||||
);
|
||||
@@ -106,3 +106,44 @@ func (i *Inventory) RenameSeat(ctx context.Context, from, to string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HoldSeat records that one assignment holds a seat, replacing whoever held it — as one write, so
|
||||
// the seat is never without a holder in between (novox/hq ADR 0131, design 28 task 5.3). The
|
||||
// assignment must exist; the store refuses otherwise, and that refusal is the right one: a seat
|
||||
// cannot be handed to something that is not running anywhere.
|
||||
func (i *Inventory) HoldSeat(ctx context.Context, seat, scope, nodeName, module string) error {
|
||||
node, err := i.NodeByName(ctx, nodeName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = i.store.Pool().Exec(ctx,
|
||||
`insert into seat_holding (seat, scope, node, module) values ($1, $2, $3, $4)
|
||||
on conflict (seat) do update set scope = excluded.scope, node = excluded.node,
|
||||
module = excluded.module, since = now()`,
|
||||
seat, scope, node.ID, module)
|
||||
if err != nil {
|
||||
return fmt.Errorf("recording %s on %s as the holder of %s: %w", module, nodeName, seat, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Holdings is every seat whose holder is on record, as the resolver reads it. A seat with no row here
|
||||
// is held by derivation, exactly as before the table existed.
|
||||
func (i *Inventory) Holdings(ctx context.Context) ([]catalogue.Held, error) {
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
`select h.seat, h.scope, n.name, h.module, coalesce(n.site, '')
|
||||
from seat_holding h join node n on n.id = h.node order by h.seat`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []catalogue.Held
|
||||
for rows.Next() {
|
||||
var h catalogue.Held
|
||||
if err := rows.Scan(&h.Claim, &h.Scope, &h.Node, &h.Module, &h.Site); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, h)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user