Node-engine: while the seat list is unread, the node's own composition lets through the holder of the seat mesh-catalog's resources and accounts over what runs (ADR 0636 §1; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
This commit is contained in:
@@ -28,6 +28,7 @@ import (
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"github.com/novox/mesh-host/internal/buckets"
|
||||
"github.com/novox/mesh-host/internal/compositions"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
@@ -1784,6 +1785,18 @@ const WaitsForTheSeatList = "the node has not read the seat list, so it composes
|
||||
// cannot judge before the list was read, so only the last applied one runs (ADR 0636 §1).
|
||||
const WaitsForTheSeatListOwn = "the node has not read the seat list, so it composes nothing new of itself and keeps what runs (novox/hq ADR 0636 §1)"
|
||||
|
||||
// WaitsForTheSeatListOwnPart is why the rest of the node's own composition is held: of it the node composes what the holder
|
||||
// of the seat mesh-catalog on this node needs, so that holder can start and the list can be read (ADR 0636 §1).
|
||||
const WaitsForTheSeatListOwnPart = "the node has not read the seat list, so it composes of itself only what the holder of the seat mesh-catalog needs " +
|
||||
"and keeps what runs of the rest (novox/hq ADR 0636 §1)"
|
||||
|
||||
// Unread is the gate of a node whose reader of the seat list could not start: it has no list and can never read one, so it
|
||||
// acts on nothing new, as a reader that waits does, where a nil gate would act on everything (ADR 0636 §1).
|
||||
type Unread struct{}
|
||||
|
||||
// MayAct is false for every seat.
|
||||
func (Unread) MayAct(...string) bool { return false }
|
||||
|
||||
// holdUntilListed takes out of compositions every module's composition that is new (no running composition has its
|
||||
// digest) while the reader may not act on its seats, and names the module in held: it keeps running what it runs, and
|
||||
// the pass after the list was read hands it. The node's own composition is held the same way, whatever it folds, under
|
||||
@@ -1808,6 +1821,16 @@ func (c *Composer) holdUntilListed(ctx context.Context, r reader, compositions m
|
||||
continue
|
||||
}
|
||||
module, why = agent.NodeModule, WaitsForTheSeatListOwn
|
||||
// The list is read from the holder of the seat mesh-catalog, which the node's own composition may have to
|
||||
// start: its part is composed whatever the list says, over what the node's own composition runs, and only that.
|
||||
if part, ok := c.holderPart(ctx, r, comp, running); ok {
|
||||
compositions[key] = part
|
||||
if held == nil {
|
||||
held = map[string]string{}
|
||||
}
|
||||
held[module] = WaitsForTheSeatListOwnPart
|
||||
continue
|
||||
}
|
||||
} else if c.env.SeatList.MayAct(c.seatsOf(ctx, r, module)...) {
|
||||
continue
|
||||
}
|
||||
@@ -1822,6 +1845,105 @@ func (c *Composer) holdUntilListed(ctx context.Context, r reader, compositions m
|
||||
return held
|
||||
}
|
||||
|
||||
// holderPart is the node's own composition as it runs with, of the new one comp, what the holders of the seat mesh-catalog
|
||||
// on this node need: their resources and their accounts, each replacing the one of the same id or name or following the
|
||||
// rest. False when the new composition has nothing of a holder: the caller holds the composition whole then.
|
||||
func (c *Composer) holderPart(ctx context.Context, r reader, comp compositions.Composed, running []compositions.Composed) (compositions.Composed, bool) {
|
||||
holders := c.claimants(ctx, r, meshcatalog.Seat)
|
||||
if len(holders) == 0 {
|
||||
return compositions.Composed{}, false
|
||||
}
|
||||
out := compositions.Composed{V: comp.V, Node: comp.Node, Plan: comp.Plan}
|
||||
for _, run := range running {
|
||||
if run.Module == "" {
|
||||
out = run
|
||||
out.Resources = slices.Clone(run.Resources)
|
||||
out.Filled = slices.Clone(run.Filled)
|
||||
out.Own.Identities = slices.Clone(run.Own.Identities)
|
||||
break
|
||||
}
|
||||
}
|
||||
belongs := func(module, id string) bool {
|
||||
for _, h := range holders {
|
||||
if module == h || strings.HasPrefix(id, h+".") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
touched := false
|
||||
paths := map[string]bool{}
|
||||
for _, raw := range comp.Resources {
|
||||
var head struct{ ID, Module, Type, Path string }
|
||||
if json.Unmarshal(raw, &head) != nil || !belongs(head.Module, head.ID) {
|
||||
continue
|
||||
}
|
||||
touched = true
|
||||
if head.Type == "file" {
|
||||
paths[head.Path] = true
|
||||
}
|
||||
at := slices.IndexFunc(out.Resources, func(have json.RawMessage) bool {
|
||||
var h struct{ ID string }
|
||||
return json.Unmarshal(have, &h) == nil && h.ID == head.ID
|
||||
})
|
||||
if at >= 0 {
|
||||
out.Resources[at] = raw
|
||||
} else {
|
||||
out.Resources = append(out.Resources, raw)
|
||||
}
|
||||
}
|
||||
for _, id := range comp.Own.Identities {
|
||||
if !belongs(id.Module, "") {
|
||||
continue
|
||||
}
|
||||
touched = true
|
||||
at := slices.IndexFunc(out.Own.Identities, func(have declaration.Identity) bool { return have.Module == id.Module && have.Name == id.Name })
|
||||
if at >= 0 {
|
||||
out.Own.Identities[at] = id
|
||||
} else {
|
||||
out.Own.Identities = append(out.Own.Identities, id)
|
||||
}
|
||||
}
|
||||
for _, p := range comp.Filled {
|
||||
if paths[p] && !slices.Contains(out.Filled, p) {
|
||||
out.Filled = append(out.Filled, p)
|
||||
}
|
||||
}
|
||||
return out, touched
|
||||
}
|
||||
|
||||
// claimants is the modules, sorted, whose manifest claims the seat.
|
||||
func (c *Composer) claimants(ctx context.Context, r reader, seat string) []string {
|
||||
c.mu.Lock()
|
||||
recs := maps.Clone(c.modules)
|
||||
c.mu.Unlock()
|
||||
var out []string
|
||||
for _, module := range slices.Sorted(maps.Keys(recs)) {
|
||||
if recs[module].Manifest == "" {
|
||||
continue
|
||||
}
|
||||
raw, err := r.Manifest(ctx, recs[module].Manifest)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var man struct {
|
||||
Claims []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"claims"`
|
||||
}
|
||||
if json.Unmarshal(raw, &man) != nil {
|
||||
continue
|
||||
}
|
||||
for _, cl := range man.Claims {
|
||||
if cl.Name == seat {
|
||||
out = append(out, module)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// seatsOf is the seats a module claims, invokes or provides, from the manifest its module record names; none when it
|
||||
// cannot be read, which the reader judges as a module no bootstrapped seat names.
|
||||
func (c *Composer) seatsOf(ctx context.Context, r reader, module string) []string {
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"github.com/novox/mesh-host/internal/buckets"
|
||||
"github.com/novox/mesh-host/internal/buckets/bucketstest"
|
||||
"github.com/novox/mesh-host/internal/compositions"
|
||||
@@ -1849,3 +1850,64 @@ func TestAnUnreadSeatListHoldsWhatIsNewAndHandsItAtTheRead(t *testing.T) {
|
||||
t.Errorf("lemurs is still held after the read: %q", still)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0636 §1, the cycle of the node's own composition: while the list is unread the node's own composition is held, but
|
||||
// a node whose new own composition must start the holder of the seat mesh-catalog composes of it, whatever the list says,
|
||||
// the holder's resources and its account and nothing else, over what the node's own composition ran before; and the rest
|
||||
// of the new one is handed once the list was read.
|
||||
func TestAnUnreadSeatListLetsThroughOfTheOwnCompositionOnlyWhatTheHolderOfTheCatalogNeeds(t *testing.T) {
|
||||
catalog := `{"module":"catalog","version":"1","claims":[{"name":"` + meshcatalog.Seat + `"}]}`
|
||||
other := `{"module":"other","version":"1","claims":[{"name":"node-files"}]}`
|
||||
c := New(newFakeBus().env())
|
||||
c.env.SeatList = &gate{}
|
||||
c.modules = map[string]meshcatalog.ModulesByModuleValue{"catalog": {Manifest: "dc"}, "other": {Manifest: "do"}}
|
||||
c.manifests = map[string][]byte{"dc": []byte(catalog), "do": []byte(other)}
|
||||
res := func(id string) json.RawMessage {
|
||||
return json.RawMessage(`{"id":"` + id + `","type":"directory","path":"/x/` + id + `"}`)
|
||||
}
|
||||
ran := compositions.Composed{Node: "n1", Resources: []json.RawMessage{res("base.dir")},
|
||||
Own: compositions.NodeOwn{Identities: []declaration.Identity{{Module: "base", Name: "mesh-base", UID: 1}}}}
|
||||
next := compositions.Composed{Node: "n1", Resources: []json.RawMessage{res("base.dir"), res("catalog.dir"), res("other.dir")},
|
||||
Own: compositions.NodeOwn{Identities: []declaration.Identity{{Module: "base", Name: "mesh-base", UID: 1},
|
||||
{Module: "catalog", Name: "mesh-catalog", UID: 2}, {Module: "other", Name: "mesh-other", UID: 3}}}}
|
||||
comps := map[string]compositions.Composed{"n1.node": next}
|
||||
held := c.holdUntilListed(context.Background(), reader{c: c}, comps, nil, []compositions.Composed{ran})
|
||||
got, ok := comps["n1.node"]
|
||||
if !ok {
|
||||
t.Fatalf("the node's own composition is held whole, so the holder of the seat mesh-catalog is never started and the list never read")
|
||||
}
|
||||
var ids []string
|
||||
for _, raw := range got.Resources {
|
||||
var h struct{ ID string }
|
||||
_ = json.Unmarshal(raw, &h)
|
||||
ids = append(ids, h.ID)
|
||||
}
|
||||
var names []string
|
||||
for _, i := range got.Own.Identities {
|
||||
names = append(names, i.Name)
|
||||
}
|
||||
if !slices.Equal(ids, []string{"base.dir", "catalog.dir"}) || !slices.Equal(names, []string{"mesh-base", "mesh-catalog"}) {
|
||||
t.Errorf("the own composition lets through resources %v and accounts %v, want what ran and the holder's alone", ids, names)
|
||||
}
|
||||
if held[agent.NodeModule] != WaitsForTheSeatListOwnPart {
|
||||
t.Errorf("the rest of the own composition is held with %q, want %q", held[agent.NodeModule], WaitsForTheSeatListOwnPart)
|
||||
}
|
||||
// Nothing ran: a fresh node composes the holder's part alone.
|
||||
comps = map[string]compositions.Composed{"n1.node": next}
|
||||
c.holdUntilListed(context.Background(), reader{c: c}, comps, nil, nil)
|
||||
if got := comps["n1.node"]; len(got.Resources) != 1 || len(got.Own.Identities) != 1 || got.Own.Identities[0].Name != "mesh-catalog" {
|
||||
t.Errorf("a fresh node lets through %d resources and %d accounts, want the holder's one each", len(got.Resources), len(got.Own.Identities))
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0636 §1: a reader the node could not start is no reader that gates nothing: the node holds like one that waits.
|
||||
func TestACompositionHoldsWhenTheSeatListReaderCouldNotStart(t *testing.T) {
|
||||
gate := Unread{}
|
||||
c := New(newFakeBus().env())
|
||||
comps := map[string]compositions.Composed{"n1.node": {Node: "n1"}}
|
||||
c.env.SeatList = gate
|
||||
held := c.holdUntilListed(context.Background(), reader{c: c}, comps, nil, nil)
|
||||
if _, ok := held[agent.NodeModule]; !ok || len(comps) != 0 {
|
||||
t.Errorf("held %v, comps %v: a reader that did not start must hold the node's own composition", held, comps)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user