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:
2026-09-27 23:22:20 +02:00
parent 4e4481b6f2
commit 585a6abbdd
10 changed files with 438 additions and 27 deletions
+11 -2
View File
@@ -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)
+79 -2
View File
@@ -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 {
+30 -12
View File
@@ -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")
}
nats := catalogueManifest(t, "nats")
onRecord := World{Holdings: []Held{{Claim: "mesh-broker", Scope: ScopeMesh,
Node: "anchor", Module: "nats"}}}
// 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)
}
}
busHeld := World{Held: []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)
if len(holders) != 1 || holders[0] != "nats" {
t.Fatalf("the seat is held by %v, not by the holder on record alone", holders)
}
}
+116
View File
@@ -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)
}
}
+28 -2
View File
@@ -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{}
}
+25
View File
@@ -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 {
+2 -9
View File
@@ -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
}
+82
View File
@@ -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
);
+41
View File
@@ -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()
}