Pass a seat held once to its only claimant when its recorded holder is assigned nowhere (issue 1317, ADR 0449)

This commit is contained in:
2026-10-19 21:21:32 +00:00
parent caa3fc8995
commit 666dcff79b
7 changed files with 467 additions and 2 deletions
+12 -2
View File
@@ -285,12 +285,22 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory,
assigned []string assigned []string
} }
var others []candidate var others []candidate
// **Every machine's assignments, before the holdings are read by any resolution** (novox/hq ADR 0449 §4): a
// seat held once whose holder on record is assigned on no machine needs the whole mesh to be known, and it
// passes to its only claimant here, in the composition that finds it.
everyAssigned := map[string][]string{}
for _, n := range nodes {
if theirs, err := inv.Assigned(ctx, n.Name); err == nil && len(theirs) > 0 {
everyAssigned[n.Name] = theirs
}
}
holdings = theMeshPassesSeatsOn(ctx, inv, shelf, everyAssigned, holdings)
for _, n := range nodes { for _, n := range nodes {
if n.Name == exclude { if n.Name == exclude {
continue continue
} }
theirs, err := inv.Assigned(ctx, n.Name) theirs := everyAssigned[n.Name]
if err != nil || len(theirs) == 0 { if len(theirs) == 0 {
continue continue
} }
caps, _ := inv.ProfileOf(ctx, n.Name) caps, _ := inv.ProfileOf(ctx, n.Name)
+104
View File
@@ -0,0 +1,104 @@
package main
import (
"context"
"fmt"
"io"
"os"
"github.com/nats-io/nats.go"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// seatPassing is what passing seats on needs of the outside: the record's compare-and-set, and the hand act.
type seatPassing struct {
pass func(ctx context.Context, p catalogue.SeatPass) (bool, error)
act func(ctx context.Context, p catalogue.SeatPass) error
say io.Writer
}
// passSeatsOn records, in the composition that finds it, the new holder of each seat held once whose holder on
// record is assigned nowhere and which has exactly one claimant able to hold it, and writes the controller's own
// hand act for it (novox/hq ADR 0449 §4, issue 1317). It answers the holdings as they are after it, so the
// resolution that follows reads the record it just moved. A failed write is said and leaves the record as it was:
// a composition is never refused for it.
func passSeatsOn(ctx context.Context, sp seatPassing, holdings []catalogue.Held,
assignments []catalogue.Assignment) []catalogue.Held {
passes, said := catalogue.SeatsToPassOn(holdings, assignments)
for _, line := range said {
fmt.Fprintln(sp.say, line)
}
for _, p := range passes {
moved, err := sp.pass(ctx, p)
if err != nil {
fmt.Fprintf(sp.say, "%s could not be passed from %s on %s to %s on %s: %v\n", p.Seat.Name,
p.From.Module, p.From.Node, p.To.Module, p.To.Node, err)
continue
}
if !moved {
continue // another composition passed it first, and wrote the act
}
if err := sp.act(ctx, p); err != nil {
fmt.Fprintf(sp.say, "%s passed from %s on %s to %s on %s, and the hand act could NOT be recorded: %v\n",
p.Seat.Name, p.From.Module, p.From.Node, p.To.Module, p.To.Node, err)
}
var next []catalogue.Held
for _, h := range holdings {
if hs, ok := catalogue.SeatNamed(h.Claim); ok && hs.Name == p.Seat.Name {
continue
}
next = append(next, h)
}
p.To.Site = p.From.Site
holdings = append(next, p.To)
}
return holdings
}
// seatPassHandAct is the controller's own entry for a pass: it is the act that puts the holder on record,
// where a person's `seat --to` would have, so the record of who held what stays complete (ADR 0449 §4).
func seatPassHandAct(p catalogue.SeatPass) link.HandAct {
return link.HandAct{
By: "the controller",
Verb: "seat",
Args: []string{p.Seat.Name, "--to", p.To.Node + "/" + p.To.Module, "--was", p.From.Node + "/" + p.From.Module},
Why: fmt.Sprintf("%s on %s, the holder on record of %s, is assigned on no machine, and %s on %s is the only "+
"assignment that claims it and can hold it (ADR 0449 §4)", p.From.Module, p.From.Node, p.Seat.Name,
p.To.Module, p.To.Node),
Cause: "seat-passed",
Outcome: "done",
}
}
// theMeshPassesSeatsOn is passSeatsOn as the composition runs it: against the store's record, with the hand act
// on the bus. Said to where the composition says its other lines (sayLeftOut).
func theMeshPassesSeatsOn(ctx context.Context, inv *inventory.Inventory, shelf map[string]catalogue.Manifest,
assigned map[string][]string, holdings []catalogue.Held) []catalogue.Held {
var assignments []catalogue.Assignment
for node, modules := range assigned {
for _, module := range modules {
if m, ok := shelf[module]; ok {
assignments = append(assignments, catalogue.Assignment{Node: node, Manifest: m})
}
}
}
var w io.Writer = os.Stderr
if lw, ok := ctx.Value(leftOutKey{}).(io.Writer); ok {
w = lw
}
return passSeatsOn(ctx, seatPassing{
say: w,
pass: func(ctx context.Context, p catalogue.SeatPass) (bool, error) {
return inv.PassSeat(ctx, p.Seat.Name, p.Seat.Scope, p.From, p.To)
},
act: func(ctx context.Context, p catalogue.SeatPass) error {
return onTheBus(func(conn *nats.Conn) error {
_, err := link.RecordHandAct(ctx, conn, seatPassHandAct(p))
return err
})
},
}, holdings, assignments)
}
+122
View File
@@ -0,0 +1,122 @@
package main
import (
"bytes"
"context"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/link"
)
func aBrokerClaiming(module string) catalogue.Manifest {
return catalogue.Manifest{Module: module,
Provides: []catalogue.Offer{{Name: "mesh-bus", Scope: catalogue.ScopeMesh}},
Claims: []catalogue.Claim{{Name: "mesh-broker", Scope: catalogue.ScopeMesh}}}
}
func withBusSeat(t *testing.T) {
t.Helper()
was := catalogue.Seats()
t.Cleanup(func() { catalogue.UseSeats(was) })
catalogue.UseSeats([]catalogue.Seat{{Name: "mesh-broker", Scope: catalogue.ScopeMesh, Delivers: "mesh-bus", Decision: "test"}})
}
// 1317/AC1: the composition records the claimant as the holder and writes one hand act by the controller naming
// the seat, the former holder and the new one; the holdings it answers name the new holder.
func TestACompositionPassesTheSeatAndWritesOneHandActByTheController(t *testing.T) {
withBusSeat(t)
var passed []catalogue.SeatPass
var acts []link.HandAct
var said bytes.Buffer
sp := seatPassing{say: &said,
pass: func(_ context.Context, p catalogue.SeatPass) (bool, error) {
passed = append(passed, p)
return true, nil
},
act: func(_ context.Context, p catalogue.SeatPass) error {
acts = append(acts, seatPassHandAct(p))
return nil
},
}
holdings := []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "novox", Module: "old-broker"}}
got := passSeatsOn(t.Context(), sp, holdings, []catalogue.Assignment{{Node: "novox", Manifest: aBrokerClaiming("gate")}})
if len(got) != 1 || got[0].Module != "gate" || got[0].Node != "novox" {
t.Fatalf("the holdings after the composition do not name the gate: %+v", got)
}
if len(passed) != 1 || len(acts) != 1 {
t.Fatalf("passes=%d acts=%d, want one each", len(passed), len(acts))
}
a := acts[0]
text := strings.Join(append([]string{a.By, a.Verb, a.Why}, a.Args...), " ")
for _, want := range []string{"the controller", "mesh-broker", "novox/old-broker", "novox/gate"} {
if !strings.Contains(text, want) {
t.Fatalf("the hand act does not name %q: %+v", want, a)
}
}
}
// 1317/AC2: two claimants, nothing recorded, the refusal said.
func TestACompositionWithTwoClaimantsRecordsNoneAndSaysSo(t *testing.T) {
withBusSeat(t)
sp := seatPassing{say: &bytes.Buffer{},
pass: func(context.Context, catalogue.SeatPass) (bool, error) {
t.Fatal("a seat with two claimants was passed")
return false, nil
},
act: func(context.Context, catalogue.SeatPass) error { t.Fatal("an act was written"); return nil },
}
var said bytes.Buffer
sp.say = &said
holdings := []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "novox", Module: "old-broker"}}
got := passSeatsOn(t.Context(), sp, holdings, []catalogue.Assignment{
{Node: "novox", Manifest: aBrokerClaiming("gate")}, {Node: "ace", Manifest: aBrokerClaiming("other")}})
if len(got) != 1 || got[0].Module != "old-broker" {
t.Fatalf("the record changed: %+v", got)
}
if !strings.Contains(said.String(), "gate") || !strings.Contains(said.String(), "other") ||
!strings.Contains(said.String(), "seat mesh-broker --to") {
t.Fatalf("the refusal is not said with both and the remedy: %q", said.String())
}
}
// 1317/AC3 and AC4: nothing changes while the holder is assigned, nor with no claimant.
func TestACompositionChangesNothingWhileTheHolderIsAssignedOrNobodyClaims(t *testing.T) {
withBusSeat(t)
sp := seatPassing{say: &bytes.Buffer{},
pass: func(context.Context, catalogue.SeatPass) (bool, error) { t.Fatal("passed"); return false, nil },
act: func(context.Context, catalogue.SeatPass) error { t.Fatal("act"); return nil },
}
holdings := []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "novox", Module: "old-broker"}}
got := passSeatsOn(t.Context(), sp, holdings, []catalogue.Assignment{
{Node: "novox", Manifest: aBrokerClaiming("old-broker")}, {Node: "novox", Manifest: aBrokerClaiming("gate")}})
if len(got) != 1 || got[0].Module != "old-broker" {
t.Fatalf("holder assigned: record changed: %+v", got)
}
got = passSeatsOn(t.Context(), sp, holdings, nil)
if len(got) != 1 || got[0].Module != "old-broker" {
t.Fatalf("no claimant: record changed: %+v", got)
}
}
// A composition that lost the compare-and-set writes no act, and a failed write leaves the record.
func TestALostPassWritesNoActAndAFailedOneLeavesTheRecord(t *testing.T) {
withBusSeat(t)
holdings := []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "novox", Module: "old-broker"}}
as := []catalogue.Assignment{{Node: "novox", Manifest: aBrokerClaiming("gate")}}
lost := seatPassing{say: &bytes.Buffer{},
pass: func(context.Context, catalogue.SeatPass) (bool, error) { return false, nil },
act: func(context.Context, catalogue.SeatPass) error { t.Fatal("act without a pass"); return nil },
}
passSeatsOn(t.Context(), lost, holdings, as)
var said bytes.Buffer
sp := seatPassing{say: &said,
pass: func(context.Context, catalogue.SeatPass) (bool, error) { return false, context.DeadlineExceeded },
act: func(context.Context, catalogue.SeatPass) error { t.Fatal("act"); return nil },
}
got := passSeatsOn(t.Context(), sp, holdings, as)
if got[0].Module != "old-broker" || !strings.Contains(said.String(), "could not be passed") {
t.Fatalf("got %+v said %q", got, said.String())
}
}
+85
View File
@@ -0,0 +1,85 @@
package catalogue
import (
"fmt"
"sort"
)
// An assignment as the passing of a seat reads it: a module on a machine, with its manifest.
type Assignment struct {
Node string
Manifest Manifest
}
// SeatPass is one seat whose holder on record is assigned nowhere, and the only assignment that can take it.
type SeatPass struct {
Seat Seat
From Held
To Held
}
// SeatsToPassOn reads the mesh for seats held once whose holder on record has left (novox/hq ADR 0449 §4).
//
// **A holder on record that is assigned on no machine holds nothing, and a seat that answers nothing is
// the day a delivery removed its module.** Of such a seat, exactly one assignment in the mesh able to hold
// it (it claims the seat at its scope and CanHold accepts it) takes it over: that is a pass, and the caller
// records it and writes the hand act. With no such assignment the record stays; with two or more nothing is
// recorded and the answer says so, naming both and the `seat --to` that settles it. A holder still assigned
// anywhere changes nothing: the other claimants stand beside it, eligible and silent (ADR 0131). Only mesh
// seats held once are read: a replicated seat's holders are added by an act (ADR 0223), a kinded bench is held
// by its claims, and a seat with several holders on record is a store that disagrees with the mesh (refused
// by the resolver).
func SeatsToPassOn(holdings []Held, assignments []Assignment) ([]SeatPass, []string) {
assigned := map[string]bool{}
for _, a := range assignments {
assigned[a.Manifest.Module] = true
}
bySeat := map[string][]Held{}
var order []string
for _, h := range holdings {
s, ok := SeatNamed(h.Claim)
if !ok || s.Replicated || s.Scope != ScopeMesh || h.Scope != ScopeMesh || KindedBenches[s.Name] {
continue
}
if _, seen := bySeat[s.Name]; !seen {
order = append(order, s.Name)
}
bySeat[s.Name] = append(bySeat[s.Name], h)
}
sort.Strings(order)
var passes []SeatPass
var said []string
for _, name := range order {
recs := bySeat[name]
if len(recs) != 1 || assigned[recs[0].Module] {
continue
}
seat, _ := SeatNamed(name)
var claimants []Held
for _, a := range assignments {
if CanHold(a.Manifest, seat) != nil {
continue
}
claimants = append(claimants, Held{Claim: seat.Name, Scope: seat.Scope, Node: a.Node, Module: a.Manifest.Module})
}
sort.Slice(claimants, func(i, j int) bool {
if claimants[i].Node != claimants[j].Node {
return claimants[i].Node < claimants[j].Node
}
return claimants[i].Module < claimants[j].Module
})
switch len(claimants) {
case 0:
case 1:
passes = append(passes, SeatPass{Seat: seat, From: recs[0], To: claimants[0]})
default:
said = append(said, fmt.Sprintf(
"%s on %s is on record as the holder of %q and is assigned nowhere, and %s on %s and %s on %s both claim it "+
"— nothing is recorded; `seat %s --to <node>/<module>` records the one that holds it",
recs[0].Module, recs[0].Node, seat.Name, claimants[0].Module, claimants[0].Node,
claimants[1].Module, claimants[1].Node, seat.Name))
}
}
return passes, said
}
+81
View File
@@ -0,0 +1,81 @@
package catalogue
import (
"strings"
"testing"
)
// **A seat held once whose holder on record is assigned nowhere passes to its only claimant** (novox/hq ADR
// 0449 §4, issue 1317).
func onRecord(node, module string) []Held {
return []Held{{Claim: "mesh-broker", Scope: ScopeMesh, Node: node, Module: module}}
}
// 1317/AC1
func TestASeatWhoseHolderIsAssignedNowhereIsPassedToItsOnlyClaimant(t *testing.T) {
busSeatDelivering(t, "mesh-bus")
passes, said := SeatsToPassOn(onRecord("novox", "old-broker"),
[]Assignment{{Node: "novox", Manifest: newBroker()}})
if len(said) != 0 || len(passes) != 1 {
t.Fatalf("the only claimant was not given the seat: passes=%v said=%v", passes, said)
}
p := passes[0]
if p.Seat.Name != "mesh-broker" || p.From.Module != "old-broker" || p.From.Node != "novox" ||
p.To.Module != "new-broker" || p.To.Node != "novox" {
t.Fatalf("the pass does not name the seat, the former holder and the new one: %+v", p)
}
}
// 1317/AC2
func TestWithTwoClaimantsAndTheHolderGoneNothingIsPassedAndTheRefusalNamesBoth(t *testing.T) {
busSeatDelivering(t, "mesh-bus")
passes, said := SeatsToPassOn(onRecord("novox", "gone"), []Assignment{
{Node: "novox", Manifest: newBroker()}, {Node: "ace", Manifest: oldBroker()}})
if len(passes) != 0 || len(said) != 1 {
t.Fatalf("two claimants: passes=%v said=%v", passes, said)
}
for _, want := range []string{"old-broker", "new-broker", "seat mesh-broker --to"} {
if !strings.Contains(said[0], want) {
t.Fatalf("the refusal does not say %q: %s", want, said[0])
}
}
}
// 1317/AC3
func TestAHolderStillAssignedSomewhereChangesNothing(t *testing.T) {
busSeatDelivering(t, "mesh-bus")
// Assigned on another machine than the record names: still assigned somewhere.
passes, said := SeatsToPassOn(onRecord("novox", "old-broker"), []Assignment{
{Node: "ace", Manifest: oldBroker()}, {Node: "novox", Manifest: newBroker()}})
if len(passes) != 0 || len(said) != 0 {
t.Fatalf("a holder still assigned lost its seat: passes=%v said=%v", passes, said)
}
}
// 1317/AC4
func TestWithNoClaimantTheRecordStays(t *testing.T) {
busSeatDelivering(t, "mesh-bus")
passes, said := SeatsToPassOn(onRecord("novox", "old-broker"), []Assignment{
{Node: "novox", Manifest: Manifest{Module: "other"}}})
if len(passes) != 0 || len(said) != 0 {
t.Fatalf("with no claimant: passes=%v said=%v", passes, said)
}
// A claimant that cannot hold the seat (it does not provide what the seat delivers) is no claimant.
bad := Manifest{Module: "no-bus", Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh}}}
passes, said = SeatsToPassOn(onRecord("novox", "old-broker"), []Assignment{{Node: "novox", Manifest: bad}})
if len(passes) != 0 || len(said) != 0 {
t.Fatalf("a module that cannot hold the seat was passed it: passes=%v said=%v", passes, said)
}
}
// A replicated seat is never passed: its holders are added by an act (ADR 0223).
func TestAReplicatedSeatIsNeverPassed(t *testing.T) {
record := []Held{{Claim: "mesh-dns-resolver", Scope: ScopeMesh, Node: "novox", Module: "gone"}}
resolver := Manifest{Module: "resolver", Provides: []Offer{{Name: "wildcard-resolution", Scope: ScopeMesh}},
Claims: []Claim{{Name: "mesh-dns-resolver", Scope: ScopeMesh}}}
passes, _ := SeatsToPassOn(record, []Assignment{{Node: "novox", Manifest: resolver}})
if len(passes) != 0 {
t.Fatalf("a replicated seat was passed: %v", passes)
}
}
+21
View File
@@ -153,3 +153,24 @@ func TestAReplicatedSeatHasSeveralHoldersOnRecord(t *testing.T) {
t.Fatalf("a handover left other holders on record: %+v", held) t.Fatalf("a handover left other holders on record: %+v", held)
} }
} }
// A pass is a compare-and-set: it moves the record only while the holder it was read with is still on it.
func TestPassingASeatMovesTheRecordOnceAndOnlyFromTheHolderRead(t *testing.T) {
inv, ctx := twoBrokersOnTwoNodes(t)
if err := inv.HoldSeat(ctx, "mesh-broker", catalogue.ScopeMesh, "anchor", "old-broker"); err != nil {
t.Fatal(err)
}
from := catalogue.Held{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "old-broker"}
to := catalogue.Held{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "laptop", Module: "new-broker"}
passed, err := inv.PassSeat(ctx, "mesh-broker", catalogue.ScopeMesh, from, to)
if err != nil || !passed {
t.Fatalf("the seat was not passed: %v %v", passed, err)
}
held, _ := inv.Holdings(ctx)
if len(held) != 1 || held[0].Node != "laptop" || held[0].Module != "new-broker" {
t.Fatalf("the record does not name the new holder alone: %+v", held)
}
if passed, err = inv.PassSeat(ctx, "mesh-broker", catalogue.ScopeMesh, from, to); err != nil || passed {
t.Fatalf("a second pass from the same read moved the record again: %v %v", passed, err)
}
}
+42
View File
@@ -284,6 +284,48 @@ func (i *Inventory) HoldSeat(ctx context.Context, seat, scope, nodeName, module
return tx.Commit(ctx) return tx.Commit(ctx)
} }
// PassSeat records `to` as the holder of a seat held once in place of `from`, only while `from` is still the
// one on record: a compare-and-set in one transaction, so two compositions reading the same record cannot both
// hand the seat over (novox/hq ADR 0449 §4). It answers false, with no error, when the record no longer names
// `from` (another composition passed it first). The new holder must be an assignment.
func (i *Inventory) PassSeat(ctx context.Context, seat, scope string, from, to catalogue.Held) (bool, error) {
fromNode, err := i.NodeByName(ctx, from.Node)
if err != nil {
return false, err
}
toNode, err := i.NodeByName(ctx, to.Node)
if err != nil {
return false, err
}
if err := i.mustBeAssigned(ctx, seat, to.Node, to.Module); err != nil {
return false, err
}
tx, err := i.store.Pool().Begin(ctx)
if err != nil {
return false, err
}
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
gone, err := tx.Exec(ctx,
`delete from seat_holding where seat = $1 and node = $2 and module = $3`,
seat, fromNode.ID, from.Module)
if err != nil {
return false, fmt.Errorf("passing %s from %s on %s: %w", seat, from.Module, from.Node, err)
}
if gone.RowsAffected() == 0 {
return false, nil
}
if _, err := tx.Exec(ctx,
`delete from seat_holding where seat = $1`, seat); err != nil {
return false, fmt.Errorf("passing %s: %w", seat, err)
}
if _, err := tx.Exec(ctx,
`insert into seat_holding (seat, scope, node, module) values ($1, $2, $3, $4)`,
seat, scope, toNode.ID, to.Module); err != nil {
return false, fmt.Errorf("recording %s on %s as the holder of %s: %w", to.Module, to.Node, seat, err)
}
return true, tx.Commit(ctx)
}
// AddSeatHolder records one more assignment holding a replicated seat, beside those already on // AddSeatHolder records one more assignment holding a replicated seat, beside those already on
// record (novox/hq ADR 0223). Whether the seat may have several holders is the caller's to judge — // record (novox/hq ADR 0223). Whether the seat may have several holders is the caller's to judge —
// the seat's definition is compiled, and the store holds no column for it. Recording a holder // the seat's definition is compiled, and the store holds no column for it. Recording a holder