A holder by derivation is recorded before an assignment can unsettle it (hq 170)
`assign ace postgres` made the control plane's own store unresolvable: postgres's manifest claims mesh-store, nobody was ever recorded as its holder, and with two eligible assignments and nothing on record both claimed and both were refused — novox's included. ADR 0110 says the assignment holds, by a deliberate act; ADR 0131 gave the record its force but left a seat nobody handed over held by whichever assignment happened to be alone. Before acting on an assignment the controller now writes the derived answer down: every mesh-scoped seat the store knows, resolved to exactly one holder with nothing on record, gets that holder recorded — the same record `seat <name> --to <node>/<module>` makes by hand. The next assignment able to hold the seat then stands beside the holder, eligible and silent. A seat with two derived claimants is left for a person; a seat on record is never rewritten; seats the store does not list stay held by derivation as before. And the refusal, when it still happens, names the handover that records the holder. Verified: catalogue tests against the real catalogue; the cmd suite against a store (the only failure, TestConvergingPreviewsThenChanges…, fails identically on main).
This commit is contained in:
@@ -46,6 +46,13 @@ func assign(ctx context.Context, open *stores, node, module string) (string, err
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
defer release()
|
defer release()
|
||||||
|
// **Before the new assignment can unsettle a seat somebody holds only by being alone**
|
||||||
|
// (novox/hq 04-ISSUES/170): what the mesh derived so far is written down, and then the
|
||||||
|
// assignment resolves against a record rather than against a coincidence.
|
||||||
|
settled, err := recordDerivedHolders(ctx, open)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
fresh, err := open.inventory.Assign(ctx, node, module)
|
fresh, err := open.inventory.Assign(ctx, node, module)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -57,6 +64,9 @@ func assign(ctx context.Context, open *stores, node, module string) (string, err
|
|||||||
node, module), nil
|
node, module), nil
|
||||||
}
|
}
|
||||||
said := fmt.Sprintf("%s is assigned %s", node, module)
|
said := fmt.Sprintf("%s is assigned %s", node, module)
|
||||||
|
for _, line := range settled {
|
||||||
|
said += "\n " + line
|
||||||
|
}
|
||||||
plan, _, err := planFor(ctx, open, node)
|
plan, _, err := planFor(ctx, open, node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Kept, and still refused. Both halves are the answer, and the rest of the mesh is still
|
// Kept, and still refused. Both halves are the answer, and the rest of the mesh is still
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// recordDerivedHolders writes down who holds each mesh-scoped seat that nobody was ever recorded
|
||||||
|
// as holding.
|
||||||
|
//
|
||||||
|
// **A seat held by derivation is a seat held by accident of being alone** (novox/hq
|
||||||
|
// 04-ISSUES/170). ADR 0131 lets a holder on record settle a seat, and lets any other assignment
|
||||||
|
// whose module could hold it stand beside the holder, eligible and silent. But a seat nobody
|
||||||
|
// ever handed over has no record, so its holder is whichever assignment happened to be the sole
|
||||||
|
// claimant — and the day a second one is assigned, both claim, both are refused, and the first
|
||||||
|
// one's whole machine stops resolving. That is what assigning a second postgres did to the
|
||||||
|
// control plane's own store.
|
||||||
|
//
|
||||||
|
// So the mesh writes the derived answer down before it acts on an assignment: for every
|
||||||
|
// mesh-scoped seat with exactly one resolved holder and nothing on record, that holder is
|
||||||
|
// recorded as the standing one — the same record `seat <name> --to <node>/<module>` makes by
|
||||||
|
// hand, made from what the mesh already resolved. A seat with two derived claimants is left
|
||||||
|
// alone: that is the ambiguity a person settles, and recording either would be guessing.
|
||||||
|
//
|
||||||
|
// Node-scoped seats are untouched: a record is one holder per seat, and a node-scoped seat has
|
||||||
|
// one holder per machine (ADR 0121), so there is nothing for a record to settle there.
|
||||||
|
func recordDerivedHolders(ctx context.Context, open *stores) ([]string, error) {
|
||||||
|
inv := open.inventory
|
||||||
|
shelf, err := inv.Catalogue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// exclude nobody: every node's claims, resolved with the holdings on record.
|
||||||
|
world, err := theRestOfTheMesh(ctx, inv, shelf, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
recorded, err := inv.Holdings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// A record is a row against a seat the store knows. A seat it does not — a mesh whose seats
|
||||||
|
// were never seeded, a seat a module declares for itself — stays held by derivation, as it
|
||||||
|
// always was; a missing row is not a reason an assignment fails.
|
||||||
|
known, err := inv.Seats(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
recordable := map[string]bool{}
|
||||||
|
for _, s := range known {
|
||||||
|
recordable[s.Name] = true
|
||||||
|
}
|
||||||
|
onRecord := map[string]bool{}
|
||||||
|
for _, h := range recorded {
|
||||||
|
if s, ok := catalogue.SeatNamed(h.Claim); ok {
|
||||||
|
onRecord[s.Name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
holders := map[string][]catalogue.Held{}
|
||||||
|
for _, h := range world.Held {
|
||||||
|
if h.Scope != catalogue.ScopeMesh {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s, ok := catalogue.SeatNamed(h.Claim)
|
||||||
|
if !ok || onRecord[s.Name] || !recordable[s.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
holders[s.Name] = append(holders[s.Name], h)
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(holders))
|
||||||
|
for name := range holders {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
var said []string
|
||||||
|
for _, name := range names {
|
||||||
|
if len(holders[name]) != 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h := holders[name][0]
|
||||||
|
if err := inv.HoldSeat(ctx, name, catalogue.ScopeMesh, h.Node, h.Module); err != nil {
|
||||||
|
return said, err
|
||||||
|
}
|
||||||
|
said = append(said, fmt.Sprintf(
|
||||||
|
"recorded %s on %s as the standing holder of %s, which it held only by being alone",
|
||||||
|
h.Module, h.Node, name))
|
||||||
|
}
|
||||||
|
return said, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A seat nobody ever handed over is held by whichever assignment happened to be alone — and the
|
||||||
|
// day a second module able to hold it is assigned, both claimed, both were refused, and the first
|
||||||
|
// one's machine stopped resolving (novox/hq 04-ISSUES/170). The mesh now writes the derived holder
|
||||||
|
// down before it acts, so the second assignment stands beside the holder on record.
|
||||||
|
|
||||||
|
func aSeatedStore() catalogue.Manifest {
|
||||||
|
return catalogue.Manifest{Module: "store", Version: "1",
|
||||||
|
Provides: []catalogue.Offer{{Name: "postgres-database", Scope: catalogue.ScopeMesh}},
|
||||||
|
Serves: map[string]map[string]any{"postgres-database": {"port": 5432}},
|
||||||
|
Claims: []catalogue.Claim{{Name: "mesh-store", Scope: catalogue.ScopeMesh}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestASecondEligibleHolderStandsBesideTheOneHeldByBeingAlone(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
// Every deploy seeds the mesh's own seats; a record is a row against one of them.
|
||||||
|
if _, err := open.inventory.SeedSeats(ctx, catalogue.DefaultSeats()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
register(t, open, aSeatedStore())
|
||||||
|
|
||||||
|
if _, err := assign(ctx, open, "anchor", "store"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
said, err := assign(ctx, open, "laptop", "store")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a second store, eligible for the seat, was refused:\n%s\n%v", said, err)
|
||||||
|
}
|
||||||
|
if strings.Contains(said, "cannot be worked out") {
|
||||||
|
t.Fatalf("assigning a second store unsettled the first one's machine:\n%s", said)
|
||||||
|
}
|
||||||
|
if !strings.Contains(said, "recorded store on anchor as the standing holder of mesh-store") {
|
||||||
|
t.Fatalf("the holder by derivation was not written down:\n%s", said)
|
||||||
|
}
|
||||||
|
|
||||||
|
holdings, err := open.inventory.Holdings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var found bool
|
||||||
|
for _, h := range holdings {
|
||||||
|
if h.Claim == "mesh-store" {
|
||||||
|
found = true
|
||||||
|
if h.Node != "anchor" || h.Module != "store" {
|
||||||
|
t.Fatalf("mesh-store is recorded on %s/%s, not on the one that held it", h.Node, h.Module)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("mesh-store has no holder on record after assigning: %v", holdings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the record decides from here: the anchor's plan holds the seat, the laptop's does not.
|
||||||
|
for node, holds := range map[string]bool{"anchor": true, "laptop": false} {
|
||||||
|
plan, _, err := planFor(ctx, open, node)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s no longer resolves: %v", node, err)
|
||||||
|
}
|
||||||
|
var claimed bool
|
||||||
|
for _, c := range plan.Claims {
|
||||||
|
if c.Claim == "mesh-store" {
|
||||||
|
claimed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if claimed != holds {
|
||||||
|
t.Fatalf("%s holds mesh-store: %v, want %v", node, claimed, holds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAHolderOnRecordIsNotRewrittenByDerivation(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
if _, err := open.inventory.SeedSeats(ctx, catalogue.DefaultSeats()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
register(t, open, aSeatedStore())
|
||||||
|
for _, node := range []string{"anchor", "laptop"} {
|
||||||
|
if _, err := assign(ctx, open, node, "store"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A person hands the seat to the laptop. From here the record decides, and what the mesh
|
||||||
|
// derives must never write over it.
|
||||||
|
if err := open.inventory.HoldSeat(ctx, "mesh-store", catalogue.ScopeMesh, "laptop", "store"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
said, err := recordDerivedHolders(ctx, open)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(said) != 0 {
|
||||||
|
t.Fatalf("a seat on record was written again from derivation: %v", said)
|
||||||
|
}
|
||||||
|
holdings, _ := open.inventory.Holdings(ctx)
|
||||||
|
for _, h := range holdings {
|
||||||
|
if h.Claim == "mesh-store" && h.Node != "laptop" {
|
||||||
|
t.Fatalf("the record moved to %s", h.Node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -158,3 +158,16 @@ func TestAStoreRowWithoutAProtocolKeepsTheCompiledOne(t *testing.T) {
|
|||||||
t.Fatalf("the store's own columns were not kept: %+v", got)
|
t.Fatalf("the store's own columns were not kept: %+v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestARefusalWithNothingOnRecordNamesTheHandover(t *testing.T) {
|
||||||
|
busSeatDelivering(t, "mesh-bus")
|
||||||
|
elsewhere := []Held{{Claim: "mesh-broker", Scope: ScopeMesh, Node: "anchor", Module: "old-broker"}}
|
||||||
|
|
||||||
|
_, problems := checkClaims([]Manifest{newBroker()}, Node{Name: "laptop"}, elsewhere, nil)
|
||||||
|
if len(problems) != 1 {
|
||||||
|
t.Fatalf("two derived claimants across machines were not refused: %v", problems)
|
||||||
|
}
|
||||||
|
if !strings.Contains(problems[0], "`seat mesh-broker --to anchor/old-broker`") {
|
||||||
|
t.Fatalf("the refusal does not name the handover that records the holder: %s", problems[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -707,9 +707,14 @@ func checkClaims(modules []Manifest, node Node, elsewhere []Held, holdings []Hel
|
|||||||
}
|
}
|
||||||
switch h.Scope {
|
switch h.Scope {
|
||||||
case ScopeMesh:
|
case ScopeMesh:
|
||||||
|
// Both claim and nobody is on record, or this refusal could not have happened.
|
||||||
|
// The remedy is the handover that records the holder (novox/hq ADR 0131,
|
||||||
|
// 04-ISSUES/170), so it is named here rather than left to be found.
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%s on %s claims %q, which %s on %s already holds — one per mesh",
|
"%s on %s claims %q, which %s on %s already holds — one per mesh. Nothing is "+
|
||||||
h.Module, node.Name, h.Claim, e.Module, e.Node))
|
"on record for it; `seat %s --to %s/%s` records the holder, and the other "+
|
||||||
|
"assignment then stands beside it, eligible and silent",
|
||||||
|
h.Module, node.Name, h.Claim, e.Module, e.Node, h.Claim, e.Node, e.Module))
|
||||||
case ScopeSite:
|
case ScopeSite:
|
||||||
if node.Site != "" && node.Site == e.Site {
|
if node.Site != "" && node.Site == e.Site {
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
|||||||
Reference in New Issue
Block a user