Serve a per-node state to a bundle as one state: get by the key's node, keys and watch over every node's bucket, a later bucket too
The provisioner, the assigner and the view read every node's composition (ADR 0593 §6); the runtime followed mesh-sdk's membership.StateIssued PerNode and process.NodeFamily on its own connection. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
package bus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// A state its owner keeps one bucket per node (novox/hq ADR 0593 §6: each node's engine writes its own `composed-<node>`,
|
||||
// and the provisioner, the assigner and the view read them all) is served to a bundle as one state, as mesh-sdk's
|
||||
// process.NodeFamily serves it to a process: a key is read from the bucket its first part names, the keys and a watch
|
||||
// go over every bucket of the prefix, and a watch takes in a bucket created later, heard by the bus's creation
|
||||
// advisory, so a node added later is heard with no timer. This runtime holds its own connection, not mesh-sdk's, so
|
||||
// the same rules are written here over it; the two subjects below are the ones the grants part grants a reader.
|
||||
const (
|
||||
familyListSubject = "$JS.API.STREAM.NAMES"
|
||||
familyCreatedAdvisory = "$JS.EVENT.ADVISORY.STREAM.CREATED."
|
||||
)
|
||||
|
||||
func perNodeRefusal(module, name, what string) error {
|
||||
return fmt.Errorf("%s's state %s is kept one bucket per node and read only: no %s of it as one state (novox/hq ADR 0593 §6)",
|
||||
module, name, what)
|
||||
}
|
||||
|
||||
// nodeBucketOf is the state of the one node a key names: its first part, which must be a node's name.
|
||||
func nodeBucketOf(s StateIssued, key string) (StateIssued, error) {
|
||||
node, _, ok := strings.Cut(key, ".")
|
||||
if !ok || node == "" {
|
||||
return s, fmt.Errorf("%q names no node: a key of the per-node state %s starts <node>.", key, s.Name)
|
||||
}
|
||||
s.Bucket += node
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// perNodeNodes are the nodes whose bucket of the family the bus holds now, sorted.
|
||||
func (c *Conn) perNodeNodes(s StateIssued) ([]string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
|
||||
defer cancel()
|
||||
js, err := jetstream.New(c.nc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := js.KeyValueStoreNames(ctx)
|
||||
var nodes []string
|
||||
for name := range names.Name() {
|
||||
if node, ok := strings.CutPrefix(name, s.Bucket); ok && node != "" {
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
}
|
||||
if err := names.Error(); err != nil && !errors.Is(err, jetstream.ErrEndOfData) {
|
||||
return nil, fmt.Errorf("the state %s: listing the buckets %s*: %w", s.Name, s.Bucket, err)
|
||||
}
|
||||
sort.Strings(nodes)
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
func (c *Conn) perNodeKeys(s StateIssued) ([]string, error) {
|
||||
nodes, err := c.perNodeNodes(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys := []string{}
|
||||
for _, node := range nodes {
|
||||
one := s
|
||||
one.Bucket += node
|
||||
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
|
||||
kv, err := c.bucket(ctx, one)
|
||||
if err == nil {
|
||||
var k []string
|
||||
k, err = keysOf(ctx, kv)
|
||||
keys = append(keys, k...)
|
||||
}
|
||||
cancel()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// perNodeWatch is StateWatch over every bucket of the family, and over a bucket created later. A pattern whose first
|
||||
// part names a node watches that node's bucket only.
|
||||
func (c *Conn) perNodeWatch(module string, s StateIssued, filter string, deliver func(StateChange) error) (func(), error) {
|
||||
var mu sync.Mutex
|
||||
one := func(ch StateChange) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return deliver(ch)
|
||||
}
|
||||
var (
|
||||
wmu sync.Mutex
|
||||
watched = map[string]bool{}
|
||||
stops []func()
|
||||
done bool
|
||||
sub *nats.Subscription
|
||||
)
|
||||
add := func(node string) error {
|
||||
wmu.Lock()
|
||||
if watched[node] || done {
|
||||
wmu.Unlock()
|
||||
return nil
|
||||
}
|
||||
watched[node] = true
|
||||
wmu.Unlock()
|
||||
bs := s
|
||||
bs.Bucket += node
|
||||
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
|
||||
kv, err := c.bucket(ctx, bs)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stop, err := c.watchBucket(module, s.Name, kv, filter, one)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wmu.Lock()
|
||||
if done {
|
||||
wmu.Unlock()
|
||||
stop()
|
||||
return nil
|
||||
}
|
||||
stops = append(stops, stop)
|
||||
wmu.Unlock()
|
||||
return nil
|
||||
}
|
||||
stopAll := func() {
|
||||
wmu.Lock()
|
||||
done = true
|
||||
all := stops
|
||||
stops = nil
|
||||
s := sub
|
||||
wmu.Unlock()
|
||||
if s != nil {
|
||||
_ = s.Unsubscribe()
|
||||
}
|
||||
for _, stop := range all {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
if first, _, _ := strings.Cut(filter, "."); filter != ">" && first != "*" && first != ">" {
|
||||
if err := add(first); err != nil {
|
||||
stopAll()
|
||||
return nil, err
|
||||
}
|
||||
return stopAll, nil
|
||||
}
|
||||
// Heard before the buckets are listed, so a bucket created between the two is not missed.
|
||||
subject := familyCreatedAdvisory + "*"
|
||||
sb, err := c.nc.Subscribe(subject, func(m *nats.Msg) {
|
||||
bucket, isKV := strings.CutPrefix(m.Subject, familyCreatedAdvisory+"KV_")
|
||||
if node, ok := strings.CutPrefix(bucket, s.Bucket); isKV && ok && node != "" {
|
||||
go func() {
|
||||
if err := add(node); err != nil {
|
||||
c.Logf("[mesh-tools] %s did not take in the new bucket %s%s of %s: %v", module, s.Bucket, node, s.Name, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the state %s: hearing new buckets %s*: %w", s.Name, s.Bucket, err)
|
||||
}
|
||||
wmu.Lock()
|
||||
sub = sb
|
||||
wmu.Unlock()
|
||||
nodes, err := c.perNodeNodes(s)
|
||||
if err != nil {
|
||||
stopAll()
|
||||
return nil, err
|
||||
}
|
||||
for _, node := range nodes {
|
||||
if err := add(node); err != nil {
|
||||
stopAll()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return stopAll, nil
|
||||
}
|
||||
@@ -28,6 +28,10 @@ type StateIssued struct {
|
||||
Name string `json:"name"`
|
||||
Bucket string `json:"bucket"`
|
||||
Writes bool `json:"writes,omitempty"`
|
||||
// PerNode says the state is kept one bucket per node (novox/hq ADR 0593 §6, mesh-sdk membership.StateIssued): Bucket is
|
||||
// then the buckets' prefix, each bucket the prefix and a node's name, and a key's first part names its node. It is read
|
||||
// only, served as one state (per_node.go).
|
||||
PerNode bool `json:"per-node,omitempty"`
|
||||
}
|
||||
|
||||
// StateEntry is one key's current value.
|
||||
@@ -81,6 +85,9 @@ func (c *Conn) issuedState(module, name string) (StateIssued, error) {
|
||||
module, name, issued)
|
||||
}
|
||||
|
||||
// errNoBucket marks a bucket the bus does not hold yet.
|
||||
var errNoBucket = errors.New("no such bucket")
|
||||
|
||||
func (c *Conn) bucket(ctx context.Context, s StateIssued) (jetstream.KeyValue, error) {
|
||||
js, err := jetstream.New(c.nc)
|
||||
if err != nil {
|
||||
@@ -90,7 +97,7 @@ func (c *Conn) bucket(ctx context.Context, s StateIssued) (jetstream.KeyValue, e
|
||||
if err != nil {
|
||||
if errors.Is(err, jetstream.ErrBucketNotFound) {
|
||||
return nil, fmt.Errorf("the state %q is issued and its bucket is not on the bus yet: the mesh "+
|
||||
"creates it from the catalogue on its next raise", s.Name)
|
||||
"creates it from the catalogue on its next raise: %w", s.Name, errNoBucket)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
@@ -114,10 +121,18 @@ func (c *Conn) StateGet(module, name, key string) (*StateEntry, error) {
|
||||
if err := checkKey(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.PerNode {
|
||||
if s, err = nodeBucketOf(s, key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
|
||||
defer cancel()
|
||||
kv, err := c.bucket(ctx, s)
|
||||
if err != nil {
|
||||
if s.PerNode && errors.Is(err, errNoBucket) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
e, err := kv.Get(ctx, key)
|
||||
@@ -149,6 +164,9 @@ func (c *Conn) StateHistory(module, name, key string) ([]StateKept, error) {
|
||||
if err := checkKey(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.PerNode {
|
||||
return nil, perNodeRefusal(module, name, "history")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
|
||||
defer cancel()
|
||||
kv, err := c.bucket(ctx, s)
|
||||
@@ -199,6 +217,9 @@ func (c *Conn) StateInfo(module, name string) (*StateInfo, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.PerNode {
|
||||
return nil, perNodeRefusal(module, name, "fill")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
|
||||
defer cancel()
|
||||
kv, err := c.bucket(ctx, s)
|
||||
@@ -325,12 +346,20 @@ func (c *Conn) StateKeys(module, name string) ([]string, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.PerNode {
|
||||
return c.perNodeKeys(s)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
|
||||
defer cancel()
|
||||
kv, err := c.bucket(ctx, s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return keysOf(ctx, kv)
|
||||
}
|
||||
|
||||
// keysOf is every key with a current value in one bucket, sorted.
|
||||
func keysOf(ctx context.Context, kv jetstream.KeyValue) ([]string, error) {
|
||||
lister, err := kv.ListKeys(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -392,12 +421,20 @@ func (c *Conn) StateWatch(module, name, pattern string, deliver func(StateChange
|
||||
}
|
||||
filter = strings.Join(parts, ".")
|
||||
}
|
||||
if s.PerNode {
|
||||
return c.perNodeWatch(module, s, filter, deliver)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
|
||||
kv, err := c.bucket(ctx, s)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.watchBucket(module, name, kv, filter, deliver)
|
||||
}
|
||||
|
||||
// watchBucket is StateWatch over one bucket: the current values, then every change.
|
||||
func (c *Conn) watchBucket(module, name string, kv jetstream.KeyValue, filter string, deliver func(StateChange) error) (stop func(), err error) {
|
||||
watching, stopWatching := context.WithCancel(context.Background())
|
||||
w, err := kv.Watch(watching, filter)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
//go:build integration
|
||||
|
||||
package bus_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-tools/node-tools/internal/bus"
|
||||
mt "github.com/novox/mesh-tools/node-tools/internal/meshtest"
|
||||
)
|
||||
|
||||
// **A state its owner keeps one bucket per node is served to a bundle as one state** (novox/hq ADR 0593 §6: each node's
|
||||
// engine writes its own `composed-<node>`, and the provisioner reads them all). Get goes to the bucket the key's first
|
||||
// part names, Keys and Watch over every bucket of the prefix, a bucket created during the watch included, and a bucket
|
||||
// that only starts like the prefix is never read.
|
||||
func TestAPerNodeStateIsServedAcrossEveryNodesBucket(t *testing.T) {
|
||||
mesh := mt.New(t)
|
||||
a, b := mesh.Bucket(t, "composed-a"), mesh.Bucket(t, "composed-b")
|
||||
other := mesh.Bucket(t, "composedx-z")
|
||||
for kv, key := range map[interface {
|
||||
Put(string, []byte) (uint64, error)
|
||||
}]string{a: "a.m1.f1", b: "b.m2.f2", other: "z.m3.f3"} {
|
||||
if _, err := kv.Put(key, []byte(`{"v":1}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
prov := mt.MembershipOf("mesh-provisioner", "anchor", false, nil)
|
||||
prov.State = []bus.StateIssued{{Name: "mesh-host.composed", Bucket: "composed-", PerNode: true}}
|
||||
mesh.Issue(t, prov)
|
||||
conn, err := bus.Connect(bus.Credential{URL: mt.URL(t), Module: "node-tools", Node: "anchor"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn.Logf = func(string, ...any) {}
|
||||
t.Cleanup(conn.Close)
|
||||
conn.Follow("mesh-provisioner")
|
||||
mt.Until(t, func() error {
|
||||
if conn.Membership("mesh-provisioner") == nil {
|
||||
return errors.New("not issued yet")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if e, err := conn.StateGet("mesh-provisioner", "mesh-host.composed", "b.m2.f2"); err != nil || e == nil || e.Key != "b.m2.f2" {
|
||||
t.Fatalf("get from the second node's bucket: %+v, %v", e, err)
|
||||
}
|
||||
if e, err := conn.StateGet("mesh-provisioner", "mesh-host.composed", "q.m.f"); err != nil || e != nil {
|
||||
t.Fatalf("a key of a node with no bucket should read as none: %+v, %v", e, err)
|
||||
}
|
||||
if e, err := conn.StateGet("mesh-provisioner", "mesh-host.composed", "z.m3.f3"); err != nil || e != nil {
|
||||
t.Fatalf("the look-alike bucket was read: %+v, %v", e, err)
|
||||
}
|
||||
if _, err := conn.StateGet("mesh-provisioner", "mesh-host.composed", "nonode"); err == nil {
|
||||
t.Fatal("a key naming no node should be refused")
|
||||
}
|
||||
if keys, err := conn.StateKeys("mesh-provisioner", "mesh-host.composed"); err != nil || !reflect.DeepEqual(keys, []string{"a.m1.f1", "b.m2.f2"}) {
|
||||
t.Fatalf("keys across the buckets: %v, %v", keys, err)
|
||||
}
|
||||
if _, err := conn.StatePut("mesh-provisioner", "mesh-host.composed", "a.m1.f9", []byte(`{}`)); err == nil {
|
||||
t.Fatal("a per-node state is read only")
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
var current, live []string
|
||||
stop, err := conn.StateWatch("mesh-provisioner", "mesh-host.composed", "", func(ch bus.StateChange) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if ch.Current {
|
||||
current = append(current, ch.Key)
|
||||
} else {
|
||||
live = append(live, ch.Key)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(stop)
|
||||
mu.Lock()
|
||||
sort.Strings(current)
|
||||
if !reflect.DeepEqual(current, []string{"a.m1.f1", "b.m2.f2"}) {
|
||||
t.Fatalf("current values: %v", current)
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
// A third node's bucket, created during the watch, and a change in a first one.
|
||||
c := mesh.Bucket(t, "composed-c")
|
||||
if _, err := c.Put("c.m4.f4", []byte(`{"v":1}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.Put("a.m1.f5", []byte(`{"v":1}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mt.Until(t, func() error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
got := strings.Join(append(append([]string(nil), current...), live...), ",")
|
||||
for _, want := range []string{"a.m1.f5", "c.m4.f4"} {
|
||||
if !strings.Contains(got, want) {
|
||||
return fmt.Errorf("%s not heard yet: current %v, changes %v", want, current, live)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user