Issue a key-value entry asked in requires as a state, by its local name (issue 1966, recovery) #66

Merged
mesh-tools merged 1 commits from fix/1966-requires-state into main 2026-11-09 12:56:38 +00:00
2 changed files with 34 additions and 10 deletions
+11 -9
View File
@@ -11,6 +11,7 @@ import (
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/membership"
"git.novox.be/novox/mesh-sdk/go/pernode"
"github.com/nats-io/nats.go/jetstream"
)
@@ -71,21 +72,22 @@ func (c *Conn) issuedState(module, name string) (StateIssued, error) {
return StateIssued{}, fmt.Errorf("%s has no membership issued on %s yet, so no state of it is reachable "+
"until the mesh issues one (novox/hq ADR 0201)", module, c.node)
}
var names []string
// What a membership issues by a local name is mesh-sdk's one rule (membership.StateIssued, issue 1966): the old
// `state` list, then a key-value entry the module asks for in `requires` (ADR 0595 §1), by its local name.
issuing := membership.Membership{Bindings: m.Bindings}
for _, s := range m.State {
if s.Name == name {
return s, nil
}
names = append(names, s.Name)
issuing.State = append(issuing.State, membership.StateIssued{Name: s.Name, Bucket: s.Bucket, Writes: s.Writes, PerNode: s.PerNode})
}
if s, ok := issuing.StateIssued(name); ok {
return StateIssued{Name: s.Name, Bucket: s.Bucket, Writes: s.Writes, PerNode: s.PerNode}, nil
}
sort.Strings(names)
issued := "none"
if len(names) > 0 {
if names := issuing.StateNames(); len(names) > 0 {
issued = strings.Join(names, ", ")
}
return StateIssued{}, fmt.Errorf("%s keeps and reads no state called %q: it declares what it keeps under "+
"`state` and what it reads under `reads` as <module>.<name>, and was issued: %s (novox/hq ADR 0201)",
module, name, issued)
"`state` or as a key-value entry in `requires`, and what it reads under `reads` as <module>.<name>, and was "+
"issued: %s (novox/hq ADR 0201, ADR 0595 §1)", module, name, issued)
}
func (c *Conn) bucket(ctx context.Context, s StateIssued) (jetstream.KeyValue, error) {
+23 -1
View File
@@ -6,6 +6,8 @@ import (
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/membership"
"github.com/novox/mesh-tools/node-tools/internal/bus"
mt "github.com/novox/mesh-tools/node-tools/internal/meshtest"
)
@@ -35,16 +37,24 @@ func TestABundleKeepsAndWatchesItsStateThroughTheRuntime(t *testing.T) {
peeker.State = []bus.StateIssued{{Name: "keeper.servers", Bucket: "keeper_servers"}}
mesh.Issue(t, keeper)
mesh.Issue(t, peeker)
// Issue 1966 (F1): a module that asks for its state as a key-value entry of `requires` (ADR 0595 §1) is issued it by
// its binding alone, under its local name, as its own; no other module is.
mesh.Bucket(t, "binder_holds")
binder := mt.MembershipOf("binder", "anchor", false, nil)
binder.Bindings = []membership.BusBinding{{Local: "holds", Storage: catalogue.StorageKeyValue, Template: catalogue.TemplateState, Bucket: "binder_holds"}}
mesh.Issue(t, binder)
nodeTools := connect(t, "node-tools", "anchor")
asker := connect(t, "console", "workstation")
dir := t.TempDir()
keeperLog, peekerLog := filepath.Join(dir, "keeper.log"), filepath.Join(dir, "peeker.log")
keeperLog, peekerLog, binderLog := filepath.Join(dir, "keeper.log"), filepath.Join(dir, "peeker.log"), filepath.Join(dir, "binder.log")
said := &lines{}
stop, err := Run(nodeTools, []Served{
{Module: "keeper", Entrypoints: []string{mt.Fixture("state-keeper.mjs")}},
{Module: "peeker", Entrypoints: []string{mt.Fixture("state-keeper.mjs")}},
{Module: "binder", Entrypoints: []string{mt.Fixture("state-keeper.mjs")}},
}, map[string]map[string]string{
"binder": {"STATE_NAME": "holds", "STATE_LOG": binderLog},
"keeper": {"STATE_NAME": "servers", "STATE_LOG": keeperLog},
"peeker": {"STATE_NAME": "keeper.servers", "STATE_LOG": peekerLog},
}, said.logf)
@@ -91,6 +101,16 @@ func TestABundleKeepsAndWatchesItsStateThroughTheRuntime(t *testing.T) {
}
same(t, got, `["all.one","anchor.two"]`)
// The bound module puts, gets and watches its key-value entry by its local name.
waitFor(t, binderLog, "current")
if _, err := call(t, asker, "binder.put@anchor", map[string]any{"state": "holds", "key": "h1", "value": 1}); err != nil {
t.Fatalf("a put on a bound key-value entry: %v", err)
}
waitFor(t, binderLog, "now put h1 1")
if got, err := call(t, asker, "binder.get@anchor", map[string]any{"state": "holds", "key": "h1"}); err != nil || !strings.Contains(got, `"value":1`) {
t.Fatalf("a get on a bound key-value entry answered %s, %v", got, err)
}
// Refused, with the reason, before anything is sent.
for _, c := range []struct {
key, why string
@@ -100,6 +120,8 @@ func TestABundleKeepsAndWatchesItsStateThroughTheRuntime(t *testing.T) {
map[string]any{"state": "keeper.servers", "key": "x", "value": 1}},
{"keeper.get@anchor", `keeper keeps and reads no state called "other"`,
map[string]any{"state": "other", "key": "x"}},
{"keeper.get@anchor", `keeper keeps and reads no state called "holds"`,
map[string]any{"state": "holds", "key": "h1"}},
{"keeper.put@anchor", `carries a field "accessToken", which names a credential`,
map[string]any{"state": "servers", "key": "x", "value": map[string]any{"headers": map[string]any{"accessToken": "s"}}}},
{"keeper.put@anchor", "is not a key the bus can hold",