diff --git a/cmd/mesh-controller/acts.go b/cmd/mesh-controller/acts.go index 9e7ca23..9644a76 100644 --- a/cmd/mesh-controller/acts.go +++ b/cmd/mesh-controller/acts.go @@ -46,6 +46,13 @@ func assign(ctx context.Context, open *stores, node, module string) (string, err return "", err } 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) if err != nil { return "", err @@ -57,6 +64,9 @@ func assign(ctx context.Context, open *stores, node, module string) (string, err node, module), nil } said := fmt.Sprintf("%s is assigned %s", node, module) + for _, line := range settled { + said += "\n " + line + } plan, _, err := planFor(ctx, open, node) if err != nil { // Kept, and still refused. Both halves are the answer, and the rest of the mesh is still diff --git a/cmd/mesh-controller/holdings.go b/cmd/mesh-controller/holdings.go new file mode 100644 index 0000000..190f1fe --- /dev/null +++ b/cmd/mesh-controller/holdings.go @@ -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 --to /` 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 +} diff --git a/cmd/mesh-controller/holdings_test.go b/cmd/mesh-controller/holdings_test.go new file mode 100644 index 0000000..8326709 --- /dev/null +++ b/cmd/mesh-controller/holdings_test.go @@ -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) + } + } +} diff --git a/internal/catalogue/holdings_test.go b/internal/catalogue/holdings_test.go index 3c8d8eb..0975f1c 100644 --- a/internal/catalogue/holdings_test.go +++ b/internal/catalogue/holdings_test.go @@ -158,3 +158,16 @@ func TestAStoreRowWithoutAProtocolKeepsTheCompiledOne(t *testing.T) { 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]) + } +} diff --git a/internal/catalogue/resolve.go b/internal/catalogue/resolve.go index 6f9c89c..b9c3786 100644 --- a/internal/catalogue/resolve.go +++ b/internal/catalogue/resolve.go @@ -707,9 +707,14 @@ func checkClaims(modules []Manifest, node Node, elsewhere []Held, holdings []Hel } switch h.Scope { 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( - "%s on %s claims %q, which %s on %s already holds — one per mesh", - h.Module, node.Name, h.Claim, e.Module, e.Node)) + "%s on %s claims %q, which %s on %s already holds — one per mesh. Nothing is "+ + "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: if node.Site != "" && node.Site == e.Site { problems = append(problems, fmt.Sprintf(