Keep a consumer bound where its data is; only a pin moves it (hq ADR 0232, issue 273)
Issue 258's fix let a mesh seat's holder elsewhere answer before this machine's own provider. Right for the resolver, which any provider answers alike; for the store's seat it re-bound every database consumer on a machine running its own store to the holder on another, each was given a fresh, empty database there, and nothing said so for twenty hours. - An offer says whether it keeps its consumers' data (`keeps-consumer-data`); unsaid, a provider that grants each consumer a credential does. For such a provision the seat's holder no longer overrules a provider beside the consumer; a pin still does. - Where each such consumer was sent is recorded (migration 0071). A resolution that would bind it elsewhere keeps the recorded provider and says the move; one whose provider is gone is refused, never answered by another. - A push says a kept move and raises it as an urgent condition at once; the self-check's D12 raises it every run, with a pinned move not yet sent as a warning and any unasked move as urgent.
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// A binding to data (novox/hq ADR 0232): where each consumer of a provision that keeps its data was
|
||||
// last sent, so a resolution that would answer it from anywhere else keeps it there instead.
|
||||
|
||||
// Binding is one consumer's recorded provider for one provision.
|
||||
type Binding struct {
|
||||
// Machine is where the consumer runs, Consumer the module there.
|
||||
Machine string
|
||||
Consumer string
|
||||
Provision string
|
||||
Provider catalogue.Chosen
|
||||
BoundAt time.Time
|
||||
SentAt time.Time
|
||||
// MovedFrom is where it was bound before a pin moved it, empty when never moved.
|
||||
MovedFrom string
|
||||
}
|
||||
|
||||
// BindingsFor is every binding recorded for a machine's consumers, by consumer then provision — the
|
||||
// shape the resolver reads (catalogue.World.Bound).
|
||||
func (i *Inventory) BindingsFor(ctx context.Context, machine string) (map[string]map[string]catalogue.Chosen, error) {
|
||||
node, err := i.NodeByName(ctx, machine)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
`select consumer, provision, provider_node, provider_module from binding where node = $1`, node.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]map[string]catalogue.Chosen{}
|
||||
for rows.Next() {
|
||||
var consumer, provision string
|
||||
var c catalogue.Chosen
|
||||
if err := rows.Scan(&consumer, &provision, &c.Node, &c.Module); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out[consumer] == nil {
|
||||
out[consumer] = map[string]catalogue.Chosen{}
|
||||
}
|
||||
out[consumer][provision] = c
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Bindings is every binding recorded, by machine, consumer and provision.
|
||||
func (i *Inventory) Bindings(ctx context.Context) ([]Binding, error) {
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
`select n.name, b.consumer, b.provision, b.provider_node, b.provider_module, b.bound_at, b.sent_at,
|
||||
coalesce(b.moved_from, '')
|
||||
from binding b join node n on n.id = b.node
|
||||
order by n.name, b.consumer, b.provision`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Binding
|
||||
for rows.Next() {
|
||||
var b Binding
|
||||
if err := rows.Scan(&b.Machine, &b.Consumer, &b.Provision, &b.Provider.Node, &b.Provider.Module,
|
||||
&b.BoundAt, &b.SentAt, &b.MovedFrom); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// RecordBindings writes down the bindings a declaration just sent to a machine carried. A binding
|
||||
// already recorded at the same provider is only marked sent; one recorded elsewhere — moved by a pin,
|
||||
// the only way the resolver lets one move — keeps where it was in moved_from and is bound anew.
|
||||
func (i *Inventory) RecordBindings(ctx context.Context, machine string, bindings []Binding) error {
|
||||
if len(bindings) == 0 {
|
||||
return nil
|
||||
}
|
||||
node, err := i.NodeByName(ctx, machine)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := i.store.Pool().Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
for _, b := range bindings {
|
||||
_, err := tx.Exec(ctx,
|
||||
`insert into binding (node, consumer, provision, provider_node, provider_module)
|
||||
values ($1, $2, $3, $4, $5)
|
||||
on conflict (node, consumer, provision) do update set
|
||||
sent_at = now(),
|
||||
moved_from = case
|
||||
when binding.provider_node = excluded.provider_node
|
||||
and binding.provider_module = excluded.provider_module then binding.moved_from
|
||||
else binding.provider_node || '/' || binding.provider_module end,
|
||||
bound_at = case
|
||||
when binding.provider_node = excluded.provider_node
|
||||
and binding.provider_module = excluded.provider_module then binding.bound_at
|
||||
else now() end,
|
||||
provider_node = excluded.provider_node,
|
||||
provider_module = excluded.provider_module`,
|
||||
node.ID, b.Consumer, b.Provision, b.Provider.Node, b.Provider.Module)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// Where a consumer of data was bound is kept, and a move keeps where it was (novox/hq ADR 0232).
|
||||
func TestABindingIsRecordedAndAMoveKeepsWhereItWas(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
ctx := t.Context()
|
||||
for _, n := range []string{"home", "anchor"} {
|
||||
if _, err := inv.AddNode(ctx, n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
home := catalogue.Chosen{Node: "home", Module: "store"}
|
||||
anchor := catalogue.Chosen{Node: "anchor", Module: "store"}
|
||||
if err := inv.RecordBindings(ctx, "home", []Binding{
|
||||
{Consumer: "board", Provision: "postgres-database", Provider: home},
|
||||
{Consumer: "game", Provision: "postgres-database", Provider: home},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := inv.BindingsFor(ctx, "home")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["board"]["postgres-database"] != home || got["game"]["postgres-database"] != home {
|
||||
t.Fatalf("recorded %+v", got)
|
||||
}
|
||||
first, err := inv.Bindings(ctx)
|
||||
if err != nil || len(first) != 2 {
|
||||
t.Fatalf("%+v, %v", first, err)
|
||||
}
|
||||
|
||||
// Sent again where it is: only marked sent.
|
||||
if err := inv.RecordBindings(ctx, "home", []Binding{{Consumer: "board", Provision: "postgres-database", Provider: home}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Moved by a pin and sent: bound anew, with where it was.
|
||||
if err := inv.RecordBindings(ctx, "home", []Binding{{Consumer: "game", Provision: "postgres-database", Provider: anchor}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
all, err := inv.Bindings(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, b := range all {
|
||||
switch b.Consumer {
|
||||
case "board":
|
||||
if b.Provider != home || b.MovedFrom != "" || !b.BoundAt.Equal(first[0].BoundAt) || b.Machine != "home" {
|
||||
t.Errorf("a binding sent again where it is changed: %+v (was %+v)", b, first[0])
|
||||
}
|
||||
case "game":
|
||||
if b.Provider != anchor || b.MovedFrom != "home/store" || !b.BoundAt.After(first[1].BoundAt) {
|
||||
t.Errorf("a moved binding: %+v", b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A provider machine leaving keeps the record of where the data is.
|
||||
if _, err := inv.store.Pool().Exec(ctx, `delete from node where name = 'anchor'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err = inv.BindingsFor(ctx, "home")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["game"]["postgres-database"] != anchor {
|
||||
t.Fatalf("the record went with the provider's machine: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Where each consumer of a provision that keeps its data was bound (novox/hq ADR 0232, issue 273).
|
||||
--
|
||||
-- A consumer of a database is bound to its rows. What resolving chooses — the seat's holder, a pin,
|
||||
-- the providers assigned where — can change under a consumer without anybody meaning to move it, and
|
||||
-- on 2026-10-05 one change to how a seat's holder answers re-bound five database consumers on one
|
||||
-- machine to the store on another: each was made a fresh, empty database there, and nothing warned
|
||||
-- for twenty hours. The pair secrets were the only trace, and only because a new pair was minted.
|
||||
--
|
||||
-- One row per consumer and provision, written when a declaration carrying the binding is sent. A
|
||||
-- resolution that would answer the consumer from another provider keeps this one and raises an urgent
|
||||
-- condition; only a pin naming the other provider moves it, and the send that carries the move
|
||||
-- rewrites the row, keeping where it was in `moved_from`.
|
||||
--
|
||||
-- **The provider by name, not by reference.** A provider machine leaving the mesh must not take the
|
||||
-- record of where a consumer's data is with it: the data is still there, and a cascade would turn
|
||||
-- the record into nothing, which resolves as a binding never made — the silent move again.
|
||||
--
|
||||
-- Numbered 0071, past 0070, the highest on main or any open branch when this was written.
|
||||
create table binding (
|
||||
node uuid not null references node(id) on delete cascade,
|
||||
consumer text not null,
|
||||
provision text not null,
|
||||
provider_node text not null,
|
||||
provider_module text not null,
|
||||
-- When it was first bound where it is, and when a declaration last carried it.
|
||||
bound_at timestamptz not null default now(),
|
||||
sent_at timestamptz not null default now(),
|
||||
-- Where it was before a pin moved it, as `<node>/<module>`; null for a binding never moved.
|
||||
moved_from text,
|
||||
primary key (node, consumer, provision)
|
||||
);
|
||||
Reference in New Issue
Block a user