Revert the revert of G's mesh-sdk merge (#191): G's sdk stands again over reader release 3

Main reverted G's half merge (#189, reverted by 8afc3eb in #191) and then took the reader release's S1-S5, which
copied G's code. G's form holds where they differ (process_bus.go, seats.go, state.go and their tests); the
release's kept names stay so main's callers keep building: SeatViewPrefix beside SeatReadPrefix, and QueueInvoke's
empty strings on not-ok (with its test TestQueueInvoke). The tree differs from G's head f30dee54 only by those and
the release's two tests (objects cap option, seat states and stream kinds).

Builds: design 47 G (the milestone's sdk part, as at f30dee54)

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
This commit is contained in:
2026-11-13 12:49:56 +01:00
parent 93e0a7b50d
commit 5fa64be894
211 changed files with 32027 additions and 2484 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
// the account the controller delivers, connects as it with its pinned fingerprint and its inbox prefix, and
// offers emitting an event, the module's states with compare-and-set, consuming a work queue, reading the
// router's records of the module's own asks, and calling a tool or verb — each only as the account's grants
// allow. It offers no Serve and no way to serve a tool: a process never answers a call. A sibling of stdio,
// allow. It answers its seat's verbs through AnswerSeat alone (design 47 G, lead answer A-107). A sibling of stdio,
// which it imports only for stdio's pure types (ErrChanged, CodeChanged, StateEntry, EmitOptions); it never
// touches stdio's current, channel or live(), which stay the bundle's own way to the bus.
package process
+54 -1
View File
@@ -19,7 +19,7 @@ import (
// 1171/AC2: given an account file broker-<process id> in the controller's form {url, fingerprint, node,
// module, user, password, claims}, when the process package reads it, then it yields those fields and
// tolerates claims.
func TestReadAccountYieldsEveryFieldAndToleratesClaims(t *testing.T) {
func subReadAccountYieldsEveryFieldAndToleratesClaims(t *testing.T) {
path := writeAccount(t, map[string]any{
"url": "nats://novox:4222", "fingerprint": "sha256:ab", "node": "novox", "module": "mesh-mail",
"user": "novox.mesh-mail.send", "password": "secret-pw",
@@ -295,3 +295,56 @@ func TestModuleAccountNamesAndDialOptions(t *testing.T) {
t.Errorf("a fingerprint did not pin the connection")
}
}
// W1.3 (design 47 *The fingerprint handover*): a process's own build is the fingerprint the node-engine handed it, read
// once at its start; with none, or with one that is no single word, it says its own build is unknown and why.
func subTheOwnBuildIsTheFingerprintTheProcessWasStartedWith(t *testing.T) {
for _, c := range []struct{ name, env, fp, unknown string }{
{"handed", "src2:1480985e", "src2:1480985e", ""},
{"handed with spaces around", " src2:1480985e\n", "src2:1480985e", ""},
{"not handed", "", "", "own build unknown: started without MESH_FINGERPRINT"},
{"not one word", "src2:1 src2:2", "", `own build unknown: MESH_FINGERPRINT "src2:1 src2:2" is not one fingerprint`},
} {
b := ReadOwnBuild(func(k string) string {
if k != FingerprintEnv {
t.Fatalf("%s: read %s", c.name, k)
}
return c.env
})
if b.Fingerprint != c.fp || b.Unknown != c.unknown {
t.Errorf("%s: got %+v, want fingerprint %q, unknown %q", c.name, b, c.fp, c.unknown)
}
}
var said []Build
keptSay, keptOnce := sayOwnBuild, ownBuild
t.Cleanup(func() { sayOwnBuild, ownBuild = keptSay, keptOnce })
sayOwnBuild, ownBuild = func(b Build) { said = append(said, b) }, sync.OnceValue(readAndSay)
t.Setenv(FingerprintEnv, "src2:aaaa")
first := OwnBuild()
t.Setenv(FingerprintEnv, "src2:bbbb")
if again := OwnBuild(); again != first || first.Fingerprint != "src2:aaaa" {
t.Errorf("read once: first %+v, then %+v", first, again)
}
if len(said) != 1 || said[0].String() != "src2:aaaa" {
t.Errorf("the journal is told once, at the first read: %+v", said)
}
// Both ways a process reaches the bus read it at the start, before they dial: a refused dial still said it.
for name, dial := range map[string]func(Account) error{
"Connect": func(a Account) error { _, err := Connect(a); return err },
"Dial": func(a Account) error { _, _, err := a.Dial(nats.RetryOnFailedConnect(false)); return err },
} {
said, ownBuild = nil, sync.OnceValue(readAndSay)
if err := dial(Account{URL: "nats://127.0.0.1:1", User: "n1.m", Password: "pw"}); err == nil {
t.Fatalf("%s: dialled a port nothing listens on", name)
}
if len(said) != 1 || said[0].Fingerprint != "src2:bbbb" {
t.Errorf("%s: the process's own build is not said at its start: %+v", name, said)
}
}
}
// TestReadAccountYieldsEveryFieldAndToleratesClaims runs the tests of this package that were merged to keep the module within its test budget (design 47 G §419), each as a subtest.
func TestReadAccountYieldsEveryFieldAndToleratesClaims(t *testing.T) {
t.Run("ReadAccountYieldsEveryFieldAndToleratesClaims", subReadAccountYieldsEveryFieldAndToleratesClaims)
t.Run("TheOwnBuildIsTheFingerprintTheProcessWasStartedWith", subTheOwnBuildIsTheFingerprintTheProcessWasStartedWith)
}
+191
View File
@@ -0,0 +1,191 @@
package process
import (
"encoding/json"
"fmt"
"slices"
"strings"
"sync"
"time"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/membership"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
// Binding is how a process with an account of its own reaches the kept data its `requires` declares: Names takes a local
// name and gives the entry's bus names (bucket, stream and subjects, object stores). It asks `show` of the seat
// the bus's seat for the module and each local name at its start, as a kept call, and asks again when it hears the seat's
// `recreated` or `dropped` for that entry (Register). Until `show` answers, Names answers an error naming the entry and
// the process acts on nothing that needs it; Waiting says so for the part's health (design 47 G §577; ADR 0283).
type Binding struct {
seat string
module string
call func(key string, body any) (json.RawMessage, error)
logf func(string, ...any)
mu sync.Mutex
entries map[string]*boundEntry
locals []string
// spawn runs an ask of its own; a test makes it synchronous.
spawn func(func())
}
type boundEntry struct {
names membership.BusBinding
have bool
why string // why there is no answer yet
ask int // the latest ask; an older answer is dropped
}
// NewBinding is a Binding of module's entries, asked of the seat that holds the bus's state (the caller names it, as it
// names the seat in its `invokes` as `seat:<seat>.show`: this package names no seat), by their local names, asking through call (a Conn's CallKept); logf says
// what it waits for (nil says nothing).
func NewBinding(seat, module string, call func(key string, body any) (json.RawMessage, error), logf func(string, ...any), locals ...string) *Binding {
if logf == nil {
logf = func(string, ...any) {}
}
b := &Binding{seat: seat, module: module, call: call, logf: logf, entries: map[string]*boundEntry{}, spawn: func(f func()) { go f() }}
for _, l := range locals {
if _, ok := b.entries[l]; !ok {
b.entries[l] = &boundEntry{why: "not asked yet"}
b.locals = append(b.locals, l)
}
}
return b
}
// Names are the bus names of the entry local, or an error naming the entry when `show` has not answered for it (or it is
// no entry this Binding was made for).
func (b *Binding) Names(local string) (membership.BusBinding, error) {
b.mu.Lock()
defer b.mu.Unlock()
e := b.entries[local]
switch {
case e == nil:
return membership.BusBinding{}, fmt.Errorf("%s asks no bus entry %q: its Binding holds %s", b.module, local, strings.Join(b.locals, ", "))
case !e.have:
return membership.BusBinding{}, fmt.Errorf("the bus entry %q of %s is not bound yet: %s", local, b.module, e.why)
}
return e.names, nil
}
// Asks says whether local is an entry this Binding was made for.
func (b *Binding) Asks(local string) bool {
b.mu.Lock()
defer b.mu.Unlock()
return b.entries[local] != nil
}
// Waiting is why the process waits, naming each entry `show` has not answered for, or empty when every one is bound.
func (b *Binding) Waiting() string {
b.mu.Lock()
defer b.mu.Unlock()
var out []string
for _, l := range b.locals {
if e := b.entries[l]; !e.have {
out = append(out, fmt.Sprintf("the bus entry %q (%s)", l, e.why))
}
}
if len(out) == 0 {
return ""
}
return "waits for " + strings.Join(out, ", ")
}
// Start asks `show` for every entry once, each as a kept call of its own that returns when answered, so the process's
// start waits for none.
func (b *Binding) Start() {
for _, l := range b.locals {
l := l
b.spawn(func() { b.Ask(l) })
}
}
// Ask asks `show` for the entry local once more, as a kept call, and keeps its answer; the answer of an ask replaced by a
// later one is dropped.
func (b *Binding) Ask(local string) {
b.mu.Lock()
e := b.entries[local]
if e == nil {
b.mu.Unlock()
return
}
e.ask++
mine := e.ask
b.mu.Unlock()
names, err := b.show(local)
b.mu.Lock()
defer b.mu.Unlock()
if e.ask != mine {
return
}
if err != nil {
e.have, e.why = false, err.Error()
b.logf("binding: the bus entry %q of %s is not bound: %v", local, b.module, err)
return
}
e.names, e.have, e.why = names, true, ""
}
func (b *Binding) show(local string) (membership.BusBinding, error) {
raw, err := b.call("seat:"+b.seat+".show", map[string]string{"module": b.module, "local-name": local})
if err != nil {
return membership.BusBinding{}, err
}
var held struct {
Binding *membership.BusBinding `json:"binding"`
State string `json:"state"`
Why string `json:"why"`
}
if err := json.Unmarshal(raw, &held); err != nil {
return membership.BusBinding{}, fmt.Errorf("show answered something that is not an entry: %w", err)
}
if held.Binding == nil {
return membership.BusBinding{}, fmt.Errorf("the bus holds no objects for it yet (%s %s)", held.State, held.Why)
}
return *held.Binding, nil
}
// Register makes the Binding hear `recreated` and `dropped` of the seat on hub, and asks every entry again at each bind
// of the hub's consumer, an event said before it was made being undelivered (ADR 0668). Register before the hub starts.
func (b *Binding) Register(hub *EventHub) error {
for _, event := range []string{"recreated", "dropped"} {
if err := hub.Handle(seatdef.EventSubject(b.seat, event), b.Hear); err != nil {
return err
}
}
hub.OnBound(func(time.Time) {
for _, l := range b.locals {
l := l
b.spawn(func() { b.Ask(l) })
}
})
return nil
}
// Hear takes a `recreated` or `dropped` of the seat: one for an entry of this module's Binding is asked again, and a
// dropped one is unbound until `show` answers. Any other is acknowledged and left.
func (b *Binding) Hear(m jetstream.Msg) {
defer func() { _ = m.Ack() }()
var said struct {
Module string `json:"module"`
Local string `json:"local-name"`
}
if json.Unmarshal(m.Data(), &said) != nil || said.Module != b.module {
return
}
b.mu.Lock()
e := b.entries[said.Local]
if e != nil && strings.HasSuffix(m.Subject(), ".dropped") {
e.have, e.why = false, "the bus dropped it"
}
b.mu.Unlock()
if e != nil {
b.spawn(func() { b.Ask(said.Local) })
}
}
// Locals are the local names this Binding holds, in the order made.
func (b *Binding) Locals() []string { return slices.Clone(b.locals) }
+93
View File
@@ -0,0 +1,93 @@
package process
import (
"encoding/json"
"errors"
"strings"
"sync"
"testing"
"github.com/nats-io/nats.go/jetstream"
)
type bindMsg struct {
jetstream.Msg
subject string
data string
acked bool
}
func (m *bindMsg) Subject() string { return m.subject }
func (m *bindMsg) Data() []byte { return []byte(m.data) }
func (m *bindMsg) Ack() error { m.acked = true; return nil }
// design 47 G §577: a process reaches the kept data its `requires` declares through its Binding: `show` of the seat
// mesh-broker at the start, asked again on `recreated` and `dropped` of the entry, and a wait naming the entry until it answers.
func TestABindingAsksShowAndAgainOnRecreatedAndDropped(t *testing.T) {
var mu sync.Mutex
bucket, answers := "m_performed", true
var asked []string
call := func(key string, body any) (json.RawMessage, error) {
mu.Lock()
defer mu.Unlock()
asked = append(asked, key+" "+toJSON(body))
if !answers {
return nil, errors.New("no responders")
}
return json.RawMessage(`{"binding":{"local":"performed","storage":"key-value","bucket":"` + bucket + `"}}`), nil
}
b := NewBinding("mesh-broker", "m", call, nil, "performed", "events")
b.spawn = func(f func()) { f() }
if _, err := b.Names("performed"); err == nil || !strings.Contains(err.Error(), `"performed"`) {
t.Fatalf("before show answered: %v", err)
}
if w := b.Waiting(); !strings.Contains(w, `"performed"`) || !strings.Contains(w, `"events"`) {
t.Fatalf("waiting words %q name neither entry", w)
}
if _, err := b.Names("nope"); err == nil {
t.Fatal("an entry this Binding was not made for was answered")
}
b.Ask("performed")
b.Ask("events")
got, err := b.Names("performed")
if err != nil || got.Bucket != "m_performed" || b.Waiting() != "" {
t.Fatalf("after show: %+v, %v, waiting %q", got, err, b.Waiting())
}
if !strings.Contains(asked[0], `seat:mesh-broker.show {"local-name":"performed","module":"m"}`) {
t.Fatalf("asked %v", asked)
}
// recreated for this module's entry: asked again, the new names kept
mu.Lock()
bucket = "m_performed2"
mu.Unlock()
for _, m := range []*bindMsg{
{subject: "mesh.seat.mesh-broker.event.recreated", data: `{"module":"other","local-name":"performed"}`},
{subject: "mesh.seat.mesh-broker.event.recreated", data: `{"module":"m","local-name":"performed"}`},
} {
b.Hear(m)
if !m.acked {
t.Errorf("%s not acknowledged", m.data)
}
}
if n, _ := b.Names("performed"); n.Bucket != "m_performed2" {
t.Fatalf("recreated was not asked again: %+v", n)
}
// dropped: unbound until show answers, which it does not
mu.Lock()
answers = false
mu.Unlock()
b.Hear(&bindMsg{subject: "mesh.seat.mesh-broker.event.dropped", data: `{"module":"m","local-name":"performed"}`})
if _, err := b.Names("performed"); err == nil || !strings.Contains(err.Error(), "no responders") {
t.Fatalf("dropped left it bound: %v", err)
}
// an entry the bus holds no objects for is waited for, not bound
b2 := NewBinding("mesh-broker", "m", func(string, any) (json.RawMessage, error) {
return json.RawMessage(`{"state":"waiting","why":"not made"}`), nil
}, nil, "x")
b2.Ask("x")
if _, err := b2.Names("x"); err == nil || !strings.Contains(err.Error(), "not made") {
t.Fatalf("an entry with no binding: %v", err)
}
}
func toJSON(v any) string { d, _ := json.Marshal(v); return string(d) }
+27 -3
View File
@@ -10,6 +10,7 @@ import (
"github.com/nats-io/nats.go"
"git.novox.be/novox/mesh-sdk/go/approval"
"git.novox.be/novox/mesh-sdk/go/busretry"
"git.novox.be/novox/mesh-sdk/go/calladdress"
)
@@ -24,8 +25,7 @@ var CallTimeout = 30 * time.Second
// mesh-provisioner-client/secrets.go:59-61, 93-97, 119, 125-133). key is "seat:<seat>.<verb>" for a seat's
// verb, or "<module>.<tool>" for a module's own tool, as a process's bus.invokes names it. The reply is
// {result, error, node} (mesh-tools node-tools/internal/bus/bus.go answerOf); a non-empty error is the
// call's failure. It offers no way to answer a call — a process reaches the bus only as itself, and
// never serves anything. A key naming its machine after '@' (`seat:node-service-manager.stop@novox`) is CallOn of that
// call's failure. A process answers calls through AnswerSeat (serve.go). A key naming its machine after '@' (`seat:node-service-manager.stop@novox`) is CallOn of that
// machine; any other malformed key is refused before anything is published (calladdress; novox/hq issue 774).
func (c *Conn) Call(key string, body any) (json.RawMessage, error) {
called, fallback, err := callSubjectsOn(key, "", c.callerToken())
@@ -76,6 +76,20 @@ func (c *Conn) CallKeptOn(node, key string, body any) (json.RawMessage, error) {
return c.callKeptOn(called, fallback, body)
}
// CallKeptWhileOpen is CallKept for an ask the asker can close or replace: it is asked again only while open says its
// key is still open (design 47 G §351, §460).
func (c *Conn) CallKeptWhileOpen(open busretry.Open, key string, body any) (json.RawMessage, error) {
called, fallback, err := callSubjectsOn(key, "", c.callerToken())
if err != nil {
return nil, err
}
data, err := json.Marshal(body)
if err != nil {
return nil, err
}
return busretry.DoKept("the call on "+fallback, open, askBoth(c.request, called, fallback, data), nil)
}
func (c *Conn) callKeptOn(called, fallback string, body any) (json.RawMessage, error) {
data, err := json.Marshal(body)
if err != nil {
@@ -175,7 +189,13 @@ func callSubjectsOn(key, node, token string) (called, fallback string, err error
func (c *Conn) request(subject string, data []byte) (json.RawMessage, error) {
ctx, cancel := context.WithTimeout(context.Background(), CallTimeout)
defer cancel()
msg, err := c.nc.RequestWithContext(ctx, subject, data)
req := nats.NewMsg(subject)
req.Data = data
if strings.HasPrefix(subject, "mesh.seat.") {
// Every seat call says it waits for a held call's end; a verb that is not marked ignores it (lead answer A-244).
req.Header.Set(approval.WaitHeader, approval.WaitHeld)
}
msg, err := c.nc.RequestMsgWithContext(ctx, req)
if err != nil {
return nil, err
}
@@ -183,6 +203,7 @@ func (c *Conn) request(subject string, data []byte) (json.RawMessage, error) {
Result json.RawMessage `json:"result"`
Error string `json:"error"`
Node string `json:"node"`
Held *approval.Held `json:"held"`
}
if err := json.Unmarshal(msg.Data, &r); err != nil {
return nil, fmt.Errorf("%s answered something that is not a seat's answer: %w", subject, err)
@@ -190,5 +211,8 @@ func (c *Conn) request(subject string, data []byte) (json.RawMessage, error) {
if r.Error != "" {
return nil, answered(r.Error)
}
if r.Held != nil {
return c.awaitGated(subject, *r.Held)
}
return r.Result, nil
}
+95
View File
@@ -6,6 +6,10 @@ import (
"context"
"encoding/json"
"errors"
"git.novox.be/novox/mesh-sdk/go/seatdef"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
@@ -281,3 +285,94 @@ func TestCallOnReachesTheNamedNodeAlone(t *testing.T) {
default:
}
}
// brokerSeat is the fixture seat's definition as a seat list holds it: its three verbs, each its class's default.
func brokerSeat() (seatdef.Definition, bool) {
return seatdef.Definition{Seat: "broker-seat", Verbs: []seatdef.Verb{{Name: "ping"}, {Name: "fail"}, {Name: "boom"}}}, true
}
// A-107: a process answers its seat's verbs over the bus. Given two instances of one holder answering seat
// `broker-seat`'s verbs, a call on the caller-named subject reaches exactly one, which is handed the caller the
// subject names (dots written `~`) and replies {result, node}; a call on the subject naming none is refused; a
// handler's error is the reply's error, a panic too; and after stop nothing answers.
func TestAnswerSeatServesVerbsWithTheProvedCallerInOneQueueGroup(t *testing.T) {
url, account := grantedSendProcess(t)
adminNC, _ := adminJS(t, url)
account.User, account.Password, account.Node = adminUser, adminPass, "novox"
var heard sync.Map
count := func(name string) SeatHandler {
return func(caller string, body json.RawMessage) (any, error) {
n, _ := heard.LoadOrStore(name, new(int32))
atomic.AddInt32(n.(*int32), 1)
return map[string]string{"caller": caller, "body": string(body)}, nil
}
}
verbs := func(name string) map[string]SeatHandler {
return map[string]SeatHandler{
"ping": count(name),
"fail": func(string, json.RawMessage) (any, error) { return nil, errors.New("refused: not yours") },
"boom": func(string, json.RawMessage) (any, error) { panic("oops") },
}
}
var stops []func()
for _, name := range []string{"a", "b"} {
conn, err := Connect(account)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
stop, err := conn.AnswerSeat("broker-seat", brokerSeat, verbs(name))
if err != nil {
t.Fatalf("AnswerSeat: %v", err)
}
stops = append(stops, stop)
}
ask := func(subject string) (struct {
Result map[string]string `json:"result"`
Error string `json:"error"`
Node string `json:"node"`
}, error) {
var r struct {
Result map[string]string `json:"result"`
Error string `json:"error"`
Node string `json:"node"`
}
msg, err := adminNC.Request(subject, []byte(`{"x":1}`), 5*time.Second)
if err != nil {
return r, err
}
return r, json.Unmarshal(msg.Data, &r)
}
r, err := ask("mesh.seat.broker-seat.call.ping.novox~mesh-delivery-builder")
if err != nil || r.Result["caller"] != "novox.mesh-delivery-builder" || r.Result["body"] != `{"x":1}` || r.Node != "novox" {
t.Fatalf("a caller-named call: %+v, %v", r, err)
}
// An unproven caller is refused before the handler runs (design 47 G §610; lead answers A-165, A-179, A-186).
r, err = ask("mesh.seat.broker-seat.tool.ping")
if err != nil || r.Result != nil || !strings.Contains(r.Error, "broker-seat.ping takes no unproven caller") {
t.Fatalf("an unproven call: %+v, %v", r, err)
}
if _, err := ask("mesh.seat.broker-seat.call.ping.novox~mesh-delivery-builder"); err != nil {
t.Fatal(err)
}
total := int32(0)
heard.Range(func(_, v any) bool { total += atomic.LoadInt32(v.(*int32)); return true })
if total != 2 {
t.Errorf("two proven calls were answered %d times in all, want once each (one queue group) and the unproven none", total)
}
if r, err = ask("mesh.seat.broker-seat.call.fail.x"); err != nil || r.Error != "refused: not yours" {
t.Errorf("a handler's error: %+v, %v", r, err)
}
if r, err = ask("mesh.seat.broker-seat.call.boom.x"); err != nil || !strings.Contains(r.Error, "oops") {
t.Errorf("a handler's panic: %+v, %v", r, err)
}
for _, stop := range stops {
stop()
}
_ = adminNC.Flush()
if _, err := adminNC.Request("mesh.seat.broker-seat.call.ping.x", nil, time.Second); !errors.Is(err, nats.ErrNoResponders) {
t.Errorf("after stop a call reached someone: %v", err)
}
}
+51
View File
@@ -3,6 +3,7 @@ package process
import (
"context"
"encoding/json"
"git.novox.be/novox/mesh-sdk/go/seatdef"
"strings"
"testing"
"time"
@@ -136,3 +137,53 @@ func TestACallIsAskedAgainOnlyWhenItReachedNoOne(t *testing.T) {
}
}
}
// A-107: AnswerSeat reads the caller from a call's last token (dots written `~`), "" for anything that is not a
// bus user's name; and it refuses what it cannot serve before subscribing. A-179, A-186: before a handler runs, an
// unproven caller is refused, and every caller while the seat's definition is unread, names no such verb or opens it to
// none; a proven caller of a verb the definition names runs it.
func TestUserOfTokenAndAnswerSeatRefusals(t *testing.T) {
for token, want := range map[string]string{"novox~mesh-delivery-builder": "novox.mesh-delivery-builder", "person~jo": "person.jo",
"plain": "plain", "": "", "a b": "", "a*": "", "~": ""} {
if got := userOfToken(token); got != want {
t.Errorf("userOfToken(%q) = %q, want %q", token, got, want)
}
}
c := &Conn{}
ok := func(string, json.RawMessage) (any, error) { return nil, nil }
defined := func() (seatdef.Definition, bool) {
return seatdef.Definition{Seat: "seat", Verbs: []seatdef.Verb{{Name: "v"}}}, true
}
for name, verbs := range map[string]map[string]SeatHandler{"none": nil, "nil handler": {"v": nil}, "bad verb": {"a b": ok}, "wildcard": {"*": ok}} {
if _, err := c.AnswerSeat("seat", defined, verbs); err == nil {
t.Errorf("AnswerSeat accepted %s", name)
}
}
if _, err := c.AnswerSeat("seat", nil, map[string]SeatHandler{"v": ok}); err == nil {
t.Error("AnswerSeat accepted no definition")
}
ran := 0
run := func(string, json.RawMessage) (any, error) { ran++; return "ran", nil }
for _, row := range []struct {
name, caller string
def func() (seatdef.Definition, bool)
says string
}{
{"an unproven caller", "", defined, "takes no unproven caller"},
{"the definition unread", "novox.mesh-recovery", func() (seatdef.Definition, bool) { return seatdef.Definition{}, false }, "not yet read"},
{"a verb the definition does not name", "novox.mesh-recovery", func() (seatdef.Definition, bool) { return seatdef.Definition{Seat: "seat"}, true }, "names no verb v"},
{"a verb open to none", "novox.mesh-recovery", func() (seatdef.Definition, bool) {
return seatdef.Definition{Seat: "seat", Verbs: []seatdef.Verb{{Name: "v", Callers: &seatdef.Callers{List: []string{}}}}}, true
}, "open to no caller"},
{"a marked verb with no gate (§677)", "novox.mesh-recovery", func() (seatdef.Definition, bool) {
return seatdef.Definition{Seat: "seat", Verbs: []seatdef.Verb{{Name: "v", NeedsApproval: true}}}, true
}, "through no gate"},
{"a proven caller", "novox.mesh-recovery", defined, ""},
} {
before := ran
_, _, err := c.admitted("seat", "v", row.def, run, nil)(row.caller, nil, false)
if row.says == "" && (err != nil || ran != before+1) || row.says != "" && (err == nil || !strings.Contains(err.Error(), row.says) || ran != before) {
t.Errorf("%s: %v, ran %d times", row.name, err, ran-before)
}
}
}
+110
View File
@@ -0,0 +1,110 @@
package process
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"time"
"github.com/nats-io/nats.go"
"git.novox.be/novox/mesh-sdk/go/buscalls"
"git.novox.be/novox/mesh-sdk/go/calladdress"
)
// CallChunks calls a seat verb whose answer file is sent in chunks (a verb its seat definition marks `chunks`, design 47 G
// §681, A-139), the one way a process receives such an answer (A-291): it subscribes to the chunks of call (the 128 random
// bits the verb's input carries as its call) before it asks, so none is missed, asks key ("seat:<seat>.<verb>[@<node>]")
// with body as Call does, hands the verb's answer to accept (nil accepts every answer; an error ends the call before a chunk
// is taken), then receives the number of chunks the answer's member `chunks` names, in order, acknowledging each, into w.
// It answers the verb's answer. A chunk that does not come within buscalls.SeatAckWithin, or ctx ending, ends the receive.
func (c *Conn) CallChunks(ctx context.Context, key, call string, body any, accept func(answer json.RawMessage) error, w io.Writer) (json.RawMessage, error) {
a, err := calladdress.Parse(key)
if err != nil {
return nil, err
}
if a.Seat == "" {
return nil, fmt.Errorf("%q names no seat verb, so it sends no chunks", key)
}
if call == "" {
return nil, fmt.Errorf("the call of %s names no call token for its chunks", key)
}
send := buscalls.SeatChunks{Seat: a.Seat, Verb: a.Verb, Call: call}
chunks := make(chan *nats.Msg, 4)
sub, err := c.nc.ChanSubscribe(send.Chunks(), chunks)
if err != nil {
return nil, fmt.Errorf("the chunks of %s cannot be heard: %w", key, err)
}
defer func() { _ = sub.Unsubscribe() }()
answer, err := c.Call(key, body)
if err != nil {
return nil, err
}
if accept != nil {
if err := accept(answer); err != nil {
return answer, err
}
}
var count struct {
Chunks int64 `json:"chunks"`
}
if err := json.Unmarshal(answer, &count); err != nil {
return answer, fmt.Errorf("%s answered no count of its chunks: %w", key, err)
}
next := func() (string, []byte, error) {
bound := time.NewTimer(buscalls.SeatAckWithin)
defer bound.Stop()
select {
case m := <-chunks:
return m.Subject, m.Data, nil
case <-bound.C:
return "", nil, buscalls.ErrSeatChunkMissed
case <-ctx.Done():
return "", nil, ctx.Err()
}
}
ack := func(subject string) error {
_, err := c.nc.Request(subject, []byte("{}"), buscalls.SeatAckWithin)
return err
}
if _, err := send.Receive(count.Chunks, next, ack, w); err != nil {
return answer, fmt.Errorf("the answer of %s: %w", key, err)
}
return answer, nil
}
// CallFile is CallChunks for an answer read whole, in the form a bundle's ordinary seat call answers a chunked verb (design 47
// G A-249, A-253): the verb's answer with the whole file added as its member `file` (base64), none when no chunk was sent. The
// call token is body's member `call`.
func (c *Conn) CallFile(key string, body any) (json.RawMessage, error) {
call := callOf(body)
var file bytes.Buffer
answer, err := c.CallChunks(context.Background(), key, call, body, nil, &file)
if err != nil {
return nil, err
}
var whole map[string]any
if err := json.Unmarshal(answer, &whole); err != nil {
return nil, fmt.Errorf("%s answered what is no object: %w", key, err)
}
if file.Len() > 0 {
whole["file"] = base64.StdEncoding.EncodeToString(file.Bytes())
}
return json.Marshal(whole)
}
// callOf is the member call of a verb's input.
func callOf(body any) string {
raw, err := json.Marshal(body)
if err != nil {
return ""
}
var in struct {
Call string `json:"call"`
}
_ = json.Unmarshal(raw, &in)
return in.Call
}
+71
View File
@@ -0,0 +1,71 @@
//go:build integration
package process
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/nats-io/nats.go"
"git.novox.be/novox/mesh-sdk/go/buscalls"
"git.novox.be/novox/mesh-sdk/go/testbus"
)
// Design 47 G A-291: a process receives a seat verb's chunked answer through one helper: it subscribes to the call's chunks
// before it asks, acknowledges each, and answers the whole body (CallChunks into a writer, CallFile as the member file);
// an answer accept refuses takes no chunk.
func TestAProcessReceivesAChunkedSeatAnswerWhole(t *testing.T) {
url := testbus.URL(t)
holder, err := nats.Connect(url)
if err != nil {
t.Fatal(err)
}
defer holder.Close()
body := []byte(strings.Repeat("0123456789", 2*buscalls.SeatChunkBytes/10+7)) // three chunks
of := int64((len(body) + buscalls.SeatChunkBytes - 1) / buscalls.SeatChunkBytes)
sub, err := holder.Subscribe("mesh.seat.node-build-go.call.output.ace.>", func(m *nats.Msg) {
var in struct{ Ask, Call string }
_ = json.Unmarshal(m.Data, &in)
answer, _ := json.Marshal(map[string]any{"result": map[string]any{"ask": in.Ask, "chunks": of}})
_ = m.Respond(answer)
go func() {
_ = buscalls.SeatChunks{Seat: "node-build-go", Verb: "output", Call: in.Call}.Send(holder, body, buscalls.SeatAckWithin, t.Logf)
}()
})
if err != nil {
t.Fatal(err)
}
defer func() { _ = sub.Unsubscribe() }()
_ = holder.Flush()
nc, err := nats.Connect(url)
if err != nil {
t.Fatal(err)
}
c := &Conn{nc: nc, account: Account{User: "novox.mesh-delivery-checker"}}
defer c.Close()
var got bytes.Buffer
answer, err := c.CallChunks(context.Background(), "seat:node-build-go.output@ace", "c1", map[string]any{"ask": "a1", "call": "c1"}, nil, &got)
if err != nil || !bytes.Equal(got.Bytes(), body) || !strings.Contains(string(answer), `"ask":"a1"`) {
t.Fatalf("answered %s and %d bytes (%v), want the verb's answer and the whole %d bytes", answer, got.Len(), err, len(body))
}
raw, err := c.CallFile("seat:node-build-go.output@ace", map[string]any{"ask": "a2", "call": "c2"})
var file struct{ Ask, File string }
if err != nil || json.Unmarshal(raw, &file) != nil || file.Ask != "a2" {
t.Fatalf("CallFile answered %.80s, %v", raw, err)
}
if whole, _ := base64.StdEncoding.DecodeString(file.File); !bytes.Equal(whole, body) {
t.Fatalf("CallFile's file holds %d bytes, want %d", len(whole), len(body))
}
refused := errors.New("too large for this disk")
got.Reset()
if _, err := c.CallChunks(context.Background(), "seat:node-build-go.output@ace", "c3", map[string]any{"ask": "a3", "call": "c3"},
func(json.RawMessage) error { return refused }, &got); !errors.Is(err, refused) || got.Len() != 0 {
t.Fatalf("an answer accept refused: %v, %d bytes taken", err, got.Len())
}
}
+3 -2
View File
@@ -15,8 +15,8 @@ import (
)
// Conn is one process's own connection to the bus (novox/hq ADR 0436 clause 4): dialled with the account
// the controller delivered, pinned to its fingerprint, on its own inbox prefix. It offers no Serve and no
// way to serve a tool — a process reaches the bus only as itself, and never answers a call.
// the controller delivered, pinned to its fingerprint, on its own inbox prefix. It answers its seat's verbs
// through AnswerSeat alone (design 47 G, lead answer A-107) and offers no other way to serve a tool.
type Conn struct {
nc *nats.Conn
account Account
@@ -53,6 +53,7 @@ var refusedPublish = regexp.MustCompile(`(?i)Publish to "(\S+)"`)
// permission violation refusing a publish this connection is waiting an answer to, and cancels that wait
// at once.
func Connect(account Account, opts ...nats.Option) (*Conn, error) {
OwnBuild()
c := &Conn{account: account, pending: map[string]map[uint64]context.CancelCauseFunc{}}
nc, err := nats.Connect(account.URL, c.options(opts)...)
if err != nil {
+131
View File
@@ -0,0 +1,131 @@
package process
import (
"context"
"encoding/json"
"sync"
"time"
"github.com/nats-io/nats.go/jetstream"
)
// ConsumersState is the name of the state a module keeps its durable consumers' facts in, one key per consumer by its name,
// history 1 (design 47 G A-166, A-176): what a module's tool reads of a consumer, since a tool runs in a runtime of its own and
// cannot read the consumer's information.
const ConsumersState = "consumers"
// ConsumerFacts is one durable consumer as ConsumerLedger keeps it.
type ConsumerFacts struct {
// Stream and Name are the consumer's stream and its name.
Stream string `json:"stream"`
Name string `json:"name"`
// Acknowledged is the stream sequence of the last message applied.
Acknowledged uint64 `json:"acknowledged"`
// Pending is the messages the consumer has not been given yet, as the last message said.
Pending uint64 `json:"pending"`
// LastEvent is the subject of the last event applied and LastAt its own `at`, or when the bus stored it where it has none.
LastEvent string `json:"last-event,omitempty"`
LastAt time.Time `json:"last-at,omitempty"`
// OldestUnacknowledged is when the oldest message delivered and not yet applied was delivered; zero when none is.
OldestUnacknowledged time.Time `json:"oldest-unacknowledged,omitempty"`
}
// ConsumerSink stores one key of the module's state consumers.
type ConsumerSink func(ctx context.Context, key string, value []byte) error
// ConsumerLedger writes a durable consumer's facts into the module's state consumers after each message delivered and each message
// applied, so a tool reads them and nothing is read on a timer (design 47 G A-166, A-176). One ledger serves one consumer.
type ConsumerLedger struct {
stream, name string
sink ConsumerSink
now func() time.Time
logf func(string, ...any)
mu sync.Mutex
facts ConsumerFacts
unacks map[uint64]time.Time // delivered and not applied, by stream sequence
}
// NewConsumerLedger is the ledger of the consumer name on stream, writing through sink; a nil now is the clock and a nil logf says
// nothing.
func NewConsumerLedger(stream, name string, sink ConsumerSink, now func() time.Time, logf func(string, ...any)) *ConsumerLedger {
if now == nil {
now = time.Now
}
if logf == nil {
logf = func(string, ...any) {}
}
return &ConsumerLedger{stream: stream, name: name, sink: sink, now: now, logf: logf, unacks: map[uint64]time.Time{},
facts: ConsumerFacts{Stream: stream, Name: name}}
}
// Delivered notes that m was given to the consumer, and writes the facts; a message given again keeps its first delivery's time.
func (l *ConsumerLedger) Delivered(m jetstream.Msg) {
md, err := m.Metadata()
if err != nil {
l.logf("consumers: %s's message has no metadata, its delivery is not kept: %v", l.name, err)
return
}
l.mu.Lock()
// A message delivered again keeps the time it was first delivered, so one that never settles grows old (A-187): asked again
// in this run, the time kept; after a restart, which kept none, the time the bus stored it, never later than its first
// delivery.
if _, known := l.unacks[md.Sequence.Stream]; !known {
first := l.now().UTC()
if md.NumDelivered > 1 && !md.Timestamp.IsZero() && md.Timestamp.Before(first) {
first = md.Timestamp.UTC()
}
l.unacks[md.Sequence.Stream] = first
}
l.facts.Pending = md.NumPending
l.mu.Unlock()
l.write()
}
// Applied notes that m was applied and acknowledged, at the event's own `at` (zero: when the bus stored it), and writes the facts.
func (l *ConsumerLedger) Applied(m jetstream.Msg, at time.Time) {
md, err := m.Metadata()
if err != nil {
l.logf("consumers: %s's message has no metadata, its application is not kept: %v", l.name, err)
return
}
if at.IsZero() {
at = md.Timestamp
}
l.mu.Lock()
delete(l.unacks, md.Sequence.Stream)
if md.Sequence.Stream > l.facts.Acknowledged {
l.facts.Acknowledged = md.Sequence.Stream
}
l.facts.Pending = md.NumPending
l.facts.LastEvent, l.facts.LastAt = m.Subject(), at.UTC()
l.mu.Unlock()
l.write()
}
// Facts are the facts now.
func (l *ConsumerLedger) Facts() ConsumerFacts {
l.mu.Lock()
defer l.mu.Unlock()
f := l.facts
f.OldestUnacknowledged = time.Time{}
for _, t := range l.unacks {
if f.OldestUnacknowledged.IsZero() || t.Before(f.OldestUnacknowledged) {
f.OldestUnacknowledged = t
}
}
return f
}
func (l *ConsumerLedger) write() {
if l.sink == nil {
return
}
data, err := json.Marshal(l.Facts())
if err == nil {
err = l.sink(context.Background(), l.name, data)
}
if err != nil {
l.logf("consumers: the facts of %s are not written to the state %s: %v", l.name, ConsumersState, err)
}
}
+67
View File
@@ -0,0 +1,67 @@
package process
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/nats-io/nats.go/jetstream"
)
type ledgerMsg struct {
jetstream.Msg
subject string
seq uint64
pending uint64
stored time.Time
times uint64
}
func (m ledgerMsg) Subject() string { return m.subject }
func (m ledgerMsg) Metadata() (*jetstream.MsgMetadata, error) {
return &jetstream.MsgMetadata{Sequence: jetstream.SequencePair{Stream: m.seq, Consumer: m.seq}, NumPending: m.pending, Timestamp: m.stored, NumDelivered: max(m.times, 1)}, nil
}
func TestConsumerLedgerKeepsTheFactsAfterEachMessage(t *testing.T) {
kept := map[string][]byte{}
now := time.Date(2026, 11, 12, 9, 0, 0, 0, time.UTC)
l := NewConsumerLedger("EVENTS", "n1_m", func(_ context.Context, key string, value []byte) error { kept[key] = value; return nil }, func() time.Time { return now }, nil)
read := func() ConsumerFacts {
var f ConsumerFacts
if err := json.Unmarshal(kept["n1_m"], &f); err != nil {
t.Fatal(err)
}
return f
}
a, b := ledgerMsg{subject: "mesh.seat.s.event.a", seq: 7, pending: 1}, ledgerMsg{subject: "mesh.seat.s.event.b", seq: 8, pending: 0}
l.Delivered(a)
now = now.Add(time.Minute)
l.Delivered(b)
if f := read(); f.Pending != 0 || !f.OldestUnacknowledged.Equal(now.Add(-time.Minute)) {
t.Errorf("after two deliveries: %+v, want pending 0 and the first one's delivery as the oldest unacknowledged", f)
}
// A message delivered again keeps its first delivery's time (A-187): asked again in this run, and after a restart, where the
// new ledger knows none, the time the bus stored it.
now = now.Add(time.Minute)
l.Delivered(ledgerMsg{subject: a.subject, seq: 7, pending: 0, times: 2})
if f := read(); !f.OldestUnacknowledged.Equal(now.Add(-2 * time.Minute)) {
t.Errorf("after a delivery again: %+v, want the first delivery kept as the oldest unacknowledged", f)
}
stored := now.Add(-time.Hour)
again := NewConsumerLedger("EVENTS", "n1_m", func(_ context.Context, key string, value []byte) error { kept[key] = value; return nil }, func() time.Time { return now }, nil)
again.Delivered(ledgerMsg{subject: a.subject, seq: 7, stored: stored, times: 3})
if f := read(); !f.OldestUnacknowledged.Equal(stored) {
t.Errorf("after a restart's delivery again: %+v, want the time the bus stored it", f)
}
now = now.Add(-time.Minute)
at := now.Add(-time.Hour)
l.Applied(a, at)
if f := read(); f.Acknowledged != 7 || f.LastEvent != "mesh.seat.s.event.a" || !f.LastAt.Equal(at) || !f.OldestUnacknowledged.Equal(now) {
t.Errorf("after the first applied: %+v, want sequence 7, its event and at, and the second one's delivery as the oldest", f)
}
l.Applied(b, time.Time{})
if f := read(); f.Acknowledged != 8 || !f.OldestUnacknowledged.IsZero() {
t.Errorf("after both applied: %+v, want sequence 8 and none unacknowledged", f)
}
}
+20 -1
View File
@@ -19,6 +19,25 @@ import (
// (EmitOptions.MessageID) is sent as x-event-id, and as the bus's own dedup id (Nats-Msg-Id), so a second
// publish inside the stream's duplicate window is dropped; empty is a random one, as stdio.Emit on main.
func (c *Conn) Emit(eventType string, body any, opts ...stdio.EmitOptions) error {
return c.emitOn("mesh.mod."+c.account.Module+".event."+eventType, body, opts)
}
// EmitSeat says a seat's event as its holder (design 47 G A-10, §276, §498): one call, publishing on
// mesh.seat.<seat>.event.<event>, with the same envelope, headers and message id as Emit. The grants part grants every
// holder the publish of its held seats' event subjects.
func (c *Conn) EmitSeat(seat, event string, body any, opts ...stdio.EmitOptions) error {
return c.emitOn("mesh.seat."+seat+".event."+event, body, opts)
}
// Accept sends the seat's holder an accepted kind (design 47 G §677, lead answer A-273): one call, publishing on
// mesh.seat.<seat>.accept.<kind>.<caller>, the caller being this process's module, with the same envelope, headers and
// message id as Emit. The grants part grants every user of a seat the publish of its accept subjects that name it.
func (c *Conn) Accept(seat, kind string, body any, opts ...stdio.EmitOptions) error {
return c.emitOn("mesh.seat."+seat+".accept."+kind+"."+c.account.Module, body, opts)
}
// emitOn publishes body on subject with the SDK's envelope.
func (c *Conn) emitOn(subject string, body any, opts []stdio.EmitOptions) error {
data, err := json.Marshal(body)
if err != nil {
return err
@@ -32,7 +51,7 @@ func (c *Conn) Emit(eventType string, body any, opts ...stdio.EmitOptions) error
if id == "" {
id = eventID()
}
msg := nats.NewMsg("mesh.mod." + c.account.Module + ".event." + eventType)
msg := nats.NewMsg(subject)
msg.Header.Set("x-event-id", id)
msg.Header.Set("x-source", c.account.Module)
msg.Header.Set("x-node", c.account.Node)
+19 -3
View File
@@ -19,7 +19,7 @@ func TestEmitTwiceWithTheSameIDIsKeptToOneWithTheSDKsHeaders(t *testing.T) {
account.Node = "novox"
_, adminJetStream := adminJS(t, url)
stream, err := adminJetStream.CreateStream(ctxFor(t), jetstream.StreamConfig{
Name: "MAIL_EVENTS", Subjects: []string{"mesh.mod.mesh-mail.event.>"}, Duplicates: time.Minute,
Name: "MAIL_EVENTS", Subjects: []string{"mesh.mod.mesh-mail.event.>", "mesh.seat.mail-sender.event.>", "mesh.seat.mail-sender.accept.>"}, Duplicates: time.Minute,
})
if err != nil {
t.Fatalf("the admin could not make the events stream: %v", err)
@@ -37,12 +37,28 @@ func TestEmitTwiceWithTheSameIDIsKeptToOneWithTheSDKsHeaders(t *testing.T) {
}
}
// A seat's event goes on mesh.seat.<seat>.event.<event> with the same headers (design 47 G A-10).
if err := conn.EmitSeat("mail-sender", "sent", map[string]any{"send": "s1"}, stdio.EmitOptions{MessageID: "seat-s1"}); err != nil {
t.Fatalf("EmitSeat: %v", err)
}
if m, err := stream.GetLastMsgForSubject(ctxFor(t), "mesh.seat.mail-sender.event.sent"); err != nil || m.Header.Get("x-event-id") != "seat-s1" || m.Header.Get("x-source") != "mesh-mail" {
t.Fatalf("the seat's event: %v, %v", m, err)
}
// An accepted kind goes on mesh.seat.<seat>.accept.<kind>.<caller>, the caller this process's module (A-273).
if err := conn.Accept("mail-sender", "send", map[string]any{"send": "s2"}, stdio.EmitOptions{MessageID: "accept-s2"}); err != nil {
t.Fatalf("Accept: %v", err)
}
if m, err := stream.GetLastMsgForSubject(ctxFor(t), "mesh.seat.mail-sender.accept.send.mesh-mail"); err != nil || m.Header.Get("x-event-id") != "accept-s2" || m.Header.Get("x-source") != "mesh-mail" {
t.Fatalf("the accepted kind: %v, %v", m, err)
}
info, err := stream.Info(ctxFor(t))
if err != nil {
t.Fatal(err)
}
if info.State.Msgs != 1 {
t.Fatalf("the stream holds %d messages, want exactly 1 (the second publish is a duplicate)", info.State.Msgs)
if info.State.Msgs != 3 {
t.Fatalf("the stream holds %d messages, want 3: one of the doubled publish, one of the seat's event, one accepted", info.State.Msgs)
}
msg, err := stream.GetMsg(ctxFor(t), 1)
+215 -1
View File
@@ -4,6 +4,7 @@ package process_test
import (
"context"
"strings"
"testing"
"time"
@@ -11,12 +12,13 @@ import (
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/seatdef"
"git.novox.be/novox/mesh-sdk/go/testbus"
)
// A module whose consumer the bus makes after the module started is not lost for that order: the hub tries again after
// each wait and calls live once it is bound (the checker's "until it starts again" hole).
func TestTheHubBindsALateConsumerAndSaysItIsLive(t *testing.T) {
func subTheHubBindsALateConsumerAndSaysItIsLive(t *testing.T) {
nc, err := nats.Connect(testbus.URL(t))
if err != nil {
t.Fatal(err)
@@ -65,3 +67,215 @@ func TestTheHubBindsALateConsumerAndSaysItIsLive(t *testing.T) {
}
hub.Stop()
}
// W1.12: a module hears a seat's event kept last per subject on its own stream through its durable, made by the maker
// after the events were said, and is handed the last of each subject; without the consumer the call says so.
func subAModuleHearsASeatEventKeptOnItsOwnStream(t *testing.T) {
nc, err := nats.Connect(testbus.URL(t))
if err != nil {
t.Fatal(err)
}
defer nc.Close()
js, err := jetstream.New(nc)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
kept, subjects := seatdef.KeptStream("node-engine", seatdef.Message{Name: "running"}), []string{"mesh.seat.node-engine.event.running.>"}
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: kept, Subjects: subjects, MaxMsgsPerSubject: 1}); err != nil {
t.Fatal(err)
}
for _, m := range []struct{ subject, fp string }{
{"mesh.seat.node-engine.event.running.ace.mesh-tools", "fp1"},
{"mesh.seat.node-engine.event.running.ace.mesh-tools", "fp2"},
{"mesh.seat.node-engine.event.running.g14.mesh-tools", "fp3"},
} {
if _, err := js.Publish(ctx, m.subject, []byte(m.fp)); err != nil {
t.Fatal(err)
}
}
cred := process.Account{Node: "ace", Module: "mesh-grants"}
got := make(chan string, 4)
hear := func(m jetstream.Msg) { got <- m.Subject() + "=" + string(m.Data()); _ = m.Ack() }
if _, err := cred.HearSeatEvents(ctx, js, kept, hear, t.Logf); err == nil {
t.Fatal("no consumer was made, and hearing said nothing")
}
// The durable has gone past every message (it starts from now), so only the start read, an ordered consumer on the stream
// kept last per subject, gives the last of each subject (design 47 G §641, A-19).
durable := cred.SeatEventConsumerConfig(subjects, true)
durable.DeliverPolicy = jetstream.DeliverNewPolicy
if _, err := js.CreateOrUpdateConsumer(ctx, kept, durable); err != nil {
t.Fatal(err)
}
var reads []string
cc, err := cred.HearSeatEvents(ctx, js, kept, hear, t.Logf, process.OnRead(func(s string) { reads = append(reads, s) }))
if err != nil {
t.Fatal(err)
}
defer cc.Stop()
heard := map[string]bool{}
for len(heard) < 2 {
select {
case h := <-got:
heard[h] = true
case <-ctx.Done():
t.Fatalf("heard %v", heard)
}
}
if !heard["mesh.seat.node-engine.event.running.ace.mesh-tools=fp2"] || !heard["mesh.seat.node-engine.event.running.g14.mesh-tools=fp3"] {
t.Errorf("heard %v, want the last of each subject", heard)
}
if len(reads) != 2 || reads[0] != process.ReadWaiting || reads[1] != process.ReadDone {
t.Errorf("the read said %v, want waiting then read", reads)
}
// then the durable is followed
if _, err := js.Publish(ctx, "mesh.seat.node-engine.event.running.ace.mesh-tools", []byte("fp4")); err != nil {
t.Fatal(err)
}
select {
case h := <-got:
if h != "mesh.seat.node-engine.event.running.ace.mesh-tools=fp4" {
t.Errorf("the durable heard %s", h)
}
case <-ctx.Done():
t.Fatal("the durable was not followed after the read")
}
}
// A-181: a module hears another module's own stream kept last per subject (a manifest's `streams` entry with `last-per-subject`) as
// it hears a seat's: the last message of each subject read whole at its start, though its durable began after them, then the durable.
func subAModuleHearsAnotherModulesStreamKeptLastPerSubject(t *testing.T) {
nc, err := nats.Connect(testbus.URL(t))
if err != nil {
t.Fatal(err)
}
defer nc.Close()
js, err := jetstream.New(nc)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
stream, subject := "BUILDER_BUILDS", process.ModuleStreamSubject("builder", "build.ended.>")
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: stream, Subjects: []string{subject}, MaxMsgsPerSubject: 1}); err != nil {
t.Fatal(err)
}
for _, m := range []struct{ event, v string }{{"build.ended.web.fp1", "old"}, {"build.ended.web.fp1", "new"}, {"build.ended.web.fp2", "two"}} {
if _, err := js.Publish(ctx, process.ModuleStreamSubject("builder", m.event), []byte(m.v)); err != nil {
t.Fatal(err)
}
}
cred := process.Account{Node: "ace", Module: "assigner"}
durable := cred.SeatEventConsumerConfig([]string{subject}, true)
durable.DeliverPolicy = jetstream.DeliverNewPolicy
if _, err := js.CreateOrUpdateConsumer(ctx, stream, durable); err != nil {
t.Fatal(err)
}
got := make(chan string, 4)
var reads []string
cc, err := cred.HearStream(ctx, js, stream, func(m jetstream.Msg) { got <- string(m.Data()); _ = m.Ack() }, t.Logf, process.OnRead(func(s string) { reads = append(reads, s) }))
if err != nil {
t.Fatal(err)
}
defer cc.Stop()
heard := map[string]bool{}
for len(heard) < 2 {
select {
case h := <-got:
heard[h] = true
case <-ctx.Done():
t.Fatalf("heard %v", heard)
}
}
if !heard["new"] || !heard["two"] || heard["old"] || len(reads) != 2 {
t.Errorf("heard %v with reads %v, want the last of each subject, read whole", heard, reads)
}
}
// A-233: a module hears a stream kept by age with FromFirst: at every start the stream is read whole from its first message, though
// its durable began after them, then the durable is followed; without FromFirst only the durable is.
func subAModuleHearsAStreamKeptByAgeFromItsFirstMessage(t *testing.T) {
nc, err := nats.Connect(testbus.URL(t))
if err != nil {
t.Fatal(err)
}
defer nc.Close()
js, err := jetstream.New(nc)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
kept, subjects := seatdef.KeptStream("mesh-catalog", seatdef.Message{Name: "version-released"}), []string{"mesh.seat.mesh-catalog.event.version-released"}
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: kept, Subjects: subjects, MaxAge: 90 * 24 * time.Hour}); err != nil {
t.Fatal(err)
}
for _, v := range []string{"web 1.0.0", "web 1.1.0", "api 2.0.0"} {
if _, err := js.Publish(ctx, subjects[0], []byte(v)); err != nil {
t.Fatal(err)
}
}
cred := process.Account{Node: "ace", Module: "assigner"}
durable := cred.SeatEventConsumerConfig(subjects, false)
durable.DeliverPolicy = jetstream.DeliverNewPolicy
if _, err := js.CreateOrUpdateConsumer(ctx, kept, durable); err != nil {
t.Fatal(err)
}
got := make(chan string, 8)
hear := func(m jetstream.Msg) { got <- string(m.Data()); _ = m.Ack() }
var reads []string
cc, err := cred.HearSeatEvents(ctx, js, kept, hear, t.Logf, process.FromFirst(), process.OnRead(func(s string) { reads = append(reads, s) }))
if err != nil {
t.Fatal(err)
}
var heard []string
for len(heard) < 3 {
select {
case h := <-got:
heard = append(heard, h)
case <-ctx.Done():
t.Fatalf("heard %v, want every message from the first", heard)
}
}
if strings.Join(heard, ",") != "web 1.0.0,web 1.1.0,api 2.0.0" || len(reads) != 2 {
t.Errorf("heard %v with reads %v, want every message in order, read whole", heard, reads)
}
if _, err := js.Publish(ctx, subjects[0], []byte("web 1.2.0")); err != nil {
t.Fatal(err)
}
select {
case h := <-got:
if h != "web 1.2.0" {
t.Errorf("the durable heard %s", h)
}
case <-ctx.Done():
t.Fatal("the durable was not followed after the read")
}
cc.Stop()
// Without FromFirst, a stream kept by age is not read: the durable alone, past every message, hands the next one first.
cc, err = cred.HearSeatEvents(ctx, js, kept, hear, t.Logf)
if err != nil {
t.Fatal(err)
}
defer cc.Stop()
if _, err := js.Publish(ctx, subjects[0], []byte("next")); err != nil {
t.Fatal(err)
}
select {
case h := <-got:
if h != "next" {
t.Errorf("without FromFirst %s was handed before the next message", h)
}
case <-ctx.Done():
t.Fatal("the durable was not followed")
}
}
// TestTheHubBindsALateConsumerAndSaysItIsLive runs the tests of this package that were merged to keep the module within its test budget (design 47 G §419), each as a subtest.
func TestTheHubBindsALateConsumerAndSaysItIsLive(t *testing.T) {
t.Run("TheHubBindsALateConsumerAndSaysItIsLive", subTheHubBindsALateConsumerAndSaysItIsLive)
t.Run("AModuleHearsASeatEventKeptOnItsOwnStream", subAModuleHearsASeatEventKeptOnItsOwnStream)
t.Run("AModuleHearsAnotherModulesStreamKeptLastPerSubject", subAModuleHearsAnotherModulesStreamKeptLastPerSubject)
t.Run("AModuleHearsAStreamKeptByAgeFromItsFirstMessage", subAModuleHearsAStreamKeptByAgeFromItsFirstMessage)
}
+21
View File
@@ -1,6 +1,8 @@
package process
import (
"context"
"strings"
"testing"
"github.com/nats-io/nats.go/jetstream"
@@ -35,4 +37,23 @@ func TestTheHubMatchesSubjectsAndRefusesOverlappingPatterns(t *testing.T) {
if err := h.Handle("mesh.mod.q.event.>", nop); err != nil {
t.Fatal(err)
}
// W1.12: a seat's event kept in a stream of its own is heard on one bus name and one durable, the maker's and the
// hearer's alike; an event kept last per subject is delivered from the last of each subject. The name is the
// contract's Stream<E> as it stands (seatdef.KeptStream), never made again here: a dotted or unprefixed name is
// refused by name before the bus is asked.
a := Account{Node: "ace", Module: "mesh-grants"}
for _, stream := range []string{"node-engine.running", "SEAT_node-engine.running", "SEAT_", "EVENTS"} {
if _, err := a.HearSeatEvents(context.Background(), nil, stream, func(jetstream.Msg) {}, nil); err == nil ||
!strings.Contains(err.Error(), "is not the bus's name of a seat event's stream") {
t.Errorf("hearing %q: %v", stream, err)
}
}
cfg := a.SeatEventConsumerConfig([]string{"mesh.seat.node-engine.event.running.>"}, true)
if cfg.Durable != "ace_mesh-grants" || cfg.DeliverPolicy != jetstream.DeliverLastPerSubjectPolicy ||
cfg.AckPolicy != jetstream.AckExplicitPolicy || len(cfg.FilterSubjects) != 1 {
t.Errorf("the durable on a stream kept last per subject: %+v", cfg)
}
if cfg := a.SeatEventConsumerConfig(nil, false); cfg.DeliverPolicy != jetstream.DeliverAllPolicy {
t.Errorf("the durable on a stream kept with an age starts from its first message, got %v", cfg.DeliverPolicy)
}
}
+2
View File
@@ -39,6 +39,8 @@ var sendGrants = []string{
"mesh.mod.mesh-mail.event.mail.send.failed",
"mesh.mod.mesh-mail.event.mail.send.sent",
"mesh.mod.mesh-mail.event.states",
"mesh.seat.mail-sender.event.sent", // a held seat's event, said through Conn.EmitSeat (design 47 G A-10)
"mesh.seat.mail-sender.accept.send.mesh-mail", // a kind accepted by a seat, sent through Conn.Accept (A-273)
"mesh.seat.operator-channel.call.tell.*.novox~mesh-mail~send",
"mesh.seat.operator-channel.call.tell.novox~mesh-mail~send",
"mesh.seat.operator-channel.tool.tell",
+169
View File
@@ -0,0 +1,169 @@
package process
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/nats-io/nats.go/jetstream"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/approval"
"git.novox.be/novox/mesh-sdk/go/busretry"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
// Gating is what a holder's gate reaches beyond this connection (design 47 G §677; lead answers A-219, A-224, A-228): the
// operator channel (mesh-contracts' operatorchannelclient.GateChannel over this connection, reading the router's record
// under the module's own name, §700) and the holder's held finding writer.
type Gating struct {
Channel approval.Channel
Findings approval.FindingWriter
// Build is the holder's own fingerprint; empty reads it from the process's environment (OwnBuild).
Build string
Logf func(string, ...any)
}
// Gate is the holder's gate over this connection (§677): its state `approvals`, its calls of the seat `mesh-hand-acts`'s
// `warranted` to record a run, and its seat's `gated-ended`, each on the process's own account. AnswerSeat holds the
// seat's marked verbs through it (Gated); the process hears its decided with HearDecided and reads it at its start with
// the gate's StartHolding.
func (c *Conn) Gate(g Gating) *approval.Gate {
build := g.Build
if build == "" {
build = OwnBuild().Fingerprint
}
state := c.State(approval.StateName)
return approval.New(approval.Deps{Node: c.account.Node, Runner: c.account.Module, Holding: true, Build: build,
State: state, Bounds: heldBounds{state}, Channel: g.Channel, Findings: g.Findings, Logf: g.Logf,
Warranted: func(asker, ask string, open func() (bool, error)) error {
_, err := c.CallKeptWhileOpen(busretry.Open{Key: ask, Open: func(string) (bool, error) { return open() }, Logf: g.Logf},
"seat:mesh-hand-acts.warranted", map[string]string{"asker": asker, "ask": ask})
return err
},
Say: func(subject string, body any, id string) error {
return c.emitOn(subject, body, []stdio.EmitOptions{{MessageID: id}})
}})
}
// heldBounds is the holder's bound keys in its own state approvals (lead answer A-255): a key with its own time to live,
// whose end the bus says as a marker that a watch hears, so a waiting call's 60 minutes need no clock in the process.
type heldBounds struct{ s State }
func (b heldBounds) CreateTTL(key string, value any, ttl time.Duration) error {
_, err := b.s.CreateTTL(key, value, ttl)
return err
}
func (b heldBounds) Delete(key string) error { return b.s.Delete(key) }
// Watch hands expired each key under pattern whose own time to live ended, heard from now on: the bus's marker of a time
// to live is a purge, which this state's own writes never make (a delete is not one).
func (b heldBounds) Watch(pattern string, expired func(key string)) (func(), error) {
ctx, cancel := context.WithCancel(context.Background())
bind, stop := context.WithTimeout(ctx, stateTimeout)
kv, err := b.s.store(bind)
stop()
if err != nil {
cancel()
return nil, err
}
w, err := kv.Watch(ctx, watchPattern(pattern), jetstream.UpdatesOnly())
if err != nil {
cancel()
return nil, fmt.Errorf("%s's state %s, watching %q: %w", b.s.Module, b.s.Name, pattern, err)
}
go func() {
defer cancel()
defer func() { _ = w.Stop() }()
for e := range w.Updates() {
if e != nil && e.Operation() == jetstream.KeyValuePurge {
expired(e.Key())
}
}
}()
return cancel, nil
}
// HearDecided hands the gate each `decided` of the holder's own asks that hub hears (§677): only the ask's id is read
// from it, the gate reading the router's own record.
func HearDecided(g *approval.Gate, ch approval.Channel, module string, hub *EventHub) error {
return hub.Handle(ch.Decided(module), func(m jetstream.Msg) {
var w struct {
Ask string `json:"ask"`
}
if err := json.Unmarshal(m.Data(), &w); err != nil || w.Ask == "" {
_ = m.Term()
return
}
g.Decided(w.Ask)
_ = m.Ack()
})
}
// HeldWithin bounds a caller's wait on a held call's end: the ask's 60 minutes and the 30 a run may come after its choice.
var HeldWithin = approval.AskLasts + approval.RunWithin
// awaitGated waits on the held call's own end on its seat's gated-ended, from the last said on its subject (so an end said
// before the wait began is read), and is its answer: `{answer, approval}` when it ran (lead answer A-158), its words as
// the call's error otherwise. Every failure is an answer, never asked again: the call reached its holder (ADR 0668 §2).
func (c *Conn) awaitGated(subject string, held approval.Held) (json.RawMessage, error) {
seat, ok := seatOfSubject(subject)
if !ok {
return nil, answered(fmt.Sprintf("%s answered held, and is no seat's subject", subject))
}
js, err := jetstream.New(c.nc)
if err != nil {
return nil, answered(err.Error())
}
on := approval.GatedEndedSubject(seat, c.account.User, held.Call)
stream := seatdef.KeptStream(seat, seatdef.Message{Name: approval.GatedEndedEvent})
ctx, cancel := context.WithTimeout(context.Background(), HeldWithin)
defer cancel()
cons, err := js.OrderedConsumer(ctx, stream, jetstream.OrderedConsumerConfig{FilterSubjects: []string{on},
DeliverPolicy: jetstream.DeliverLastPerSubjectPolicy})
if err != nil {
return nil, answered(fmt.Sprintf("the call %s waits on ask %s, and its end on %s cannot be heard: %v; %s.approvals reads it",
held.Call, held.Asked, stream, err, seat))
}
for {
m, err := cons.Next(jetstream.FetchMaxWait(time.Minute))
if err == nil {
_ = m.Ack()
return endOf(m.Data())
}
if ctx.Err() != nil || !errors.Is(err, jetstream.ErrNoMessages) && !errors.Is(err, context.DeadlineExceeded) {
return nil, answered(fmt.Sprintf("the call %s waits on ask %s, and no end of it was heard (%v); %s.approvals reads it",
held.Call, held.Asked, err, seat))
}
}
}
// endOf is a gated-ended's body as the call's answer.
func endOf(data []byte) (json.RawMessage, error) {
var e approval.GatedEnded
if err := json.Unmarshal(data, &e); err != nil {
return nil, answered("the end of the call is not read: " + err.Error())
}
if e.Outcome != approval.OutcomeRan || e.Error != "" {
words := e.Error
if words == "" {
words = "the call ended " + e.Outcome
}
return nil, answered(fmt.Sprintf("Not run: %s (ask %s, call %s)", words, e.Ask, e.Call))
}
return json.Marshal(approval.Answered{Answer: e.Answer, Approval: e.Approval})
}
// seatOfSubject is the seat a call subject `mesh.seat.<seat>.…` names.
func seatOfSubject(subject string) (string, bool) {
rest, ok := strings.CutPrefix(subject, "mesh.seat.")
if !ok {
return "", false
}
seat, _, ok := strings.Cut(rest, ".")
return seat, ok && seat != ""
}
+311
View File
@@ -0,0 +1,311 @@
//go:build integration
package process
import (
"encoding/json"
"fmt"
"strings"
"sync"
"testing"
"time"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go/jetstream"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/approval"
"git.novox.be/novox/mesh-sdk/go/operatorask"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
// busChannel is the operator channel as the test's router: the gate's asks kept in the holder's state approvals, the
// router's record read under the holder's name from messenger_asks (design 47 G §700), the claim as
// operatorchannelclient's Take makes it. asked says each ask raised.
type busChannel struct {
conn *Conn
state State
asked chan approval.Ask
}
func (b *busChannel) Ask(module string, a approval.Ask) error {
raw, _ := json.Marshal(a)
if _, err := b.state.Create("ask."+a.ID, approval.Kept[json.RawMessage, json.RawMessage]{Ask: raw, Asked: time.Now()}); err != nil {
return err
}
b.asked <- a
return nil
}
func (b *busChannel) Record(module, id string) (*approval.Record, error) {
e, err := b.conn.Ask("messenger_asks", id)
if err != nil || e == nil {
return nil, err
}
var r approval.Record
return &r, json.Unmarshal(e.Value, &r)
}
func (b *busChannel) Claim(module string, w approval.Warrant, act operatorask.Act, run func()) (approval.Claim, error) {
e, err := b.state.Get("ask." + w.Ask)
if err != nil || e == nil {
return approval.ClaimRefused, fmt.Errorf("no ask %s: %v", w.Ask, err)
}
var k approval.Kept[json.RawMessage, json.RawMessage]
_ = json.Unmarshal(e.Value, &k)
if k.Acted {
return approval.ClaimDone, nil
}
var a approval.Ask
_ = json.Unmarshal(k.Ask, &a)
if d, _ := operatorask.ActDigest(act); d != a.About {
return approval.ClaimRefused, fmt.Errorf("the option binds another act")
}
k.Acted = true
if _, err := b.state.Update("ask."+w.Ask, k, e.Revision); err != nil {
return approval.ClaimDone, nil
}
run()
return approval.Claimed, nil
}
func (b *busChannel) Cancel(string, string) error { return nil }
func (b *busChannel) Decided(module string) string {
return "mesh.seat.operator-channel.event.decided." + module
}
func (b *busChannel) Seat() string { return "operator-channel" }
// Design 47 G §677 (lead answers A-158, A-224, A-244), its one whole test: a process holder's marked verb, called by a
// module's process through process.Conn's call, is asked, approved, run once, recorded in the hand-act log and its
// answer heard on the caller's own gated-ended; a declined call runs nothing; a marked verb served through no gate, or
// a gate whose state approvals is not there, fails closed.
func TestAProcessHoldersMarkedVerbIsAskedApprovedRunAndRecorded(t *testing.T) {
opts := &server.Options{Host: "127.0.0.1", Port: server.RANDOM_PORT, JetStream: true, StoreDir: t.TempDir(), NoLog: true, NoSigs: true,
Users: []*server.User{{Username: adminUser, Password: adminPass}, {Username: "n1.holder", Password: "pw"},
{Username: "n1.caller", Password: "pw"}, {Username: "n1.mesh-hand-acts", Password: "pw"}, {Username: "n1.other", Password: "pw"}}}
s, err := server.NewServer(opts)
if err != nil {
t.Fatal(err)
}
go s.Start()
if !s.ReadyForConnections(10 * time.Second) {
t.Fatal("the test bus did not come up")
}
t.Cleanup(func() { s.Shutdown(); s.WaitForShutdown() })
url := s.ClientURL()
_, js := adminJS(t, url)
ctx := ctxFor(t)
records, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "messenger_asks"})
if err != nil {
t.Fatal(err)
}
if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "holder_" + approval.StateName, TTL: 48 * time.Hour,
LimitMarkerTTL: time.Minute}); err != nil {
t.Fatal(err)
}
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: EventsStream, Subjects: []string{"mesh.mod.*.event.>", "mesh.seat.operator-channel.event.>"}}); err != nil {
t.Fatal(err)
}
if _, err := js.CreateOrUpdateConsumer(ctx, EventsStream, jetstream.ConsumerConfig{Durable: "n1_holder", AckPolicy: jetstream.AckExplicitPolicy,
FilterSubjects: []string{"mesh.seat.operator-channel.event.decided.holder"}}); err != nil {
t.Fatal(err)
}
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: seatdef.KeptStream("wiper", seatdef.Message{Name: approval.GatedEndedEvent}),
Subjects: []string{"mesh.seat.wiper.event.gated-ended.>"}, MaxMsgsPerSubject: 1, MaxAge: approval.GatedEndedKeptFor}); err != nil {
t.Fatal(err)
}
connect := func(module string) *Conn {
c, err := Connect(Account{URL: url, Node: "n1", Module: module, User: "n1." + module, Password: "pw"})
if err != nil {
t.Fatal(err)
}
t.Cleanup(c.Close)
return c
}
// The seat mesh-hand-acts' holder, recording each warranted run.
var mu sync.Mutex
var warranted []string
handActs := connect("mesh-hand-acts")
if _, err := handActs.AnswerSeat("mesh-hand-acts", func() (seatdef.Definition, bool) {
return seatdef.Definition{Seat: "mesh-hand-acts", Verbs: []seatdef.Verb{{Name: "warranted"}}}, true
}, map[string]SeatHandler{"warranted": func(caller string, body json.RawMessage) (any, error) {
mu.Lock()
defer mu.Unlock()
warranted = append(warranted, caller+" "+string(body))
return map[string]bool{"recorded": true}, nil
}}); err != nil {
t.Fatal(err)
}
// The holder: its marked verb wipe behind its gate, its decided heard through its hub, its start read.
holder := connect("holder")
ch := &busChannel{conn: holder, state: holder.State(approval.StateName), asked: make(chan approval.Ask, 4)}
gate := holder.Gate(Gating{Channel: ch, Build: "fp1", Logf: t.Logf})
ran := 0
marked := func(seat string) func() (seatdef.Definition, bool) {
return func() (seatdef.Definition, bool) {
return seatdef.Definition{Seat: seat, Verbs: []seatdef.Verb{{Name: "wipe", NeedsApproval: true}, {Name: "approvals"}}}, true
}
}
wipe := map[string]SeatHandler{"wipe": func(caller string, body json.RawMessage) (any, error) {
mu.Lock()
defer mu.Unlock()
ran++
return "wiped", nil
}}
if _, err := holder.AnswerSeat("wiper", marked("wiper"), wipe, Gated(gate)); err != nil {
t.Fatal(err)
}
hub := NewEventHub(t.Logf)
if err := HearDecided(gate, ch, "holder", hub); err != nil {
t.Fatal(err)
}
hjs, _ := jetstream.New(holder.nc)
if err := hub.Start(ctx, hjs, holder.account); err != nil {
t.Fatal(err)
}
t.Cleanup(hub.Stop)
if err := gate.StartHolding(); err != nil {
t.Fatal(err)
}
caller := connect("caller")
type result struct {
answer json.RawMessage
err error
}
callWipe := func(args map[string]string) chan result {
done := make(chan result, 1)
go func() {
a, err := caller.Call("seat:wiper.wipe", args)
done <- result{a, err}
}()
return done
}
decide := func(a approval.Ask, option string) {
raw, _ := json.Marshal(approval.Record{State: "chosen", OfferedOn: []string{"telegram"},
Warrant: &approval.Warrant{Ask: a.ID, Asker: "holder", Option: option, Channel: "telegram", At: time.Now().UTC().Format(time.RFC3339Nano)}})
if _, err := records.Put(ctx, "holder."+a.ID, raw); err != nil {
t.Fatal(err)
}
// The decided's body is not read beyond the ask's id: the gate reads the router's record.
if _, err := js.Publish(ctx, ch.Decided("holder"), []byte(`{"ask":"`+a.ID+`","option":"approve"}`)); err != nil {
t.Fatal(err)
}
}
wait := func(done chan result) result {
select {
case r := <-done:
return r
case <-time.After(10 * time.Second):
t.Fatal("the call's end was not heard within 10s")
return result{}
}
}
asked := func() approval.Ask {
select {
case a := <-ch.asked:
return a
case <-time.After(10 * time.Second):
t.Fatal("no ask raised within 10s")
return approval.Ask{}
}
}
t.Run("asked, approved, run once, recorded, heard on the caller's gated-ended", func(t *testing.T) {
done := callWipe(map[string]string{"dir": "scratch"})
a := asked()
decide(a, approval.OptionApprove)
r := wait(done)
var got approval.Answered
if r.err != nil || json.Unmarshal(r.answer, &got) != nil || got.Answer != "wiped" || !strings.Contains(got.Approval, a.ID) {
t.Fatalf("answer %s, %v", r.answer, r.err)
}
mu.Lock()
defer mu.Unlock()
if ran != 1 || len(warranted) != 1 || !strings.Contains(warranted[0], a.ID) || !strings.HasPrefix(warranted[0], "n1.holder ") {
t.Errorf("ran %d, warranted %v", ran, warranted)
}
})
t.Run("declined, nothing runs", func(t *testing.T) {
done := callWipe(map[string]string{"dir": "other"})
decide(asked(), approval.OptionDecline)
if r := wait(done); r.err == nil || !strings.Contains(r.err.Error(), "declined") {
t.Fatalf("answer %s, %v", r.answer, r.err)
}
mu.Lock()
defer mu.Unlock()
if ran != 1 {
t.Errorf("ran %d", ran)
}
})
t.Run("a waiting call's bound key is kept with its time to live, and its end is heard", func(t *testing.T) {
// A-255: gate-unanswered rests on a bound key in the state approvals, never a clock wait.
state := holder.State(approval.StateName)
kept := make(chan string, 1)
if err := state.Watch("unanswered.*", func(c stdio.StateChange) error {
if c.Op == stdio.OpPut && !c.Current {
kept <- c.Key
}
return nil
}); err != nil {
t.Fatal(err)
}
done := callWipe(map[string]string{"dir": "bound"})
a := asked()
var bound string
select {
case bound = <-kept:
case <-time.After(10 * time.Second):
t.Fatal("no bound key kept within 10s")
}
decide(a, approval.OptionDecline)
wait(done)
if e, _ := state.Get(bound); e != nil {
t.Errorf("the bound %s of an ended call is kept", bound)
}
heard := make(chan string, 2)
stop, err := heldBounds{state}.Watch("unanswered.*", func(k string) { heard <- k })
if err != nil {
t.Fatal(err)
}
defer stop()
b := heldBounds{state}
if err := b.CreateTTL("unanswered.gone", map[string]string{}, MinTTL); err != nil {
t.Fatal(err)
}
if err := b.CreateTTL("unanswered.deleted", map[string]string{}, time.Hour); err != nil {
t.Fatal(err)
}
_ = b.Delete("unanswered.deleted")
select {
case k := <-heard:
if k != "unanswered.gone" {
t.Errorf("heard %s", k)
}
case <-time.After(10 * time.Second):
t.Fatal("the bound key's end was not heard within 10s")
}
})
t.Run("failing closed: no gate, or no state approvals", func(t *testing.T) {
if _, err := holder.AnswerSeat("bare", marked("bare"), wipe); err != nil {
t.Fatal(err)
}
other := connect("other") // its state approvals is not on the bus
g := other.Gate(Gating{Channel: &busChannel{conn: other, state: other.State(approval.StateName), asked: make(chan approval.Ask, 1)}, Build: "fp1"})
if _, err := other.AnswerSeat("keeper", marked("keeper"), wipe, Gated(g)); err != nil {
t.Fatal(err)
}
for _, seat := range []string{"bare", "keeper"} {
if _, err := caller.Call("seat:"+seat+".wipe", map[string]string{"dir": "x"}); err == nil || !strings.Contains(err.Error(), "Not run") {
t.Errorf("%s: %v", seat, err)
}
}
mu.Lock()
defer mu.Unlock()
if ran != 1 {
t.Errorf("ran %d", ran)
}
})
}
+1
View File
@@ -52,6 +52,7 @@ func (a Account) DialOptions(opts ...nats.Option) []nats.Option {
// Dial connects as the module's own principal and opens JetStream on the connection. The caller closes the
// connection.
func (a Account) Dial(opts ...nats.Option) (*nats.Conn, jetstream.JetStream, error) {
OwnBuild()
nc, err := nats.Connect(a.URL, a.DialOptions(opts...)...)
if err != nil {
return nil, nil, fmt.Errorf("connecting to the bus: %w", err)
+8 -5
View File
@@ -7,14 +7,17 @@ import (
"github.com/nats-io/nats.go"
)
// 1171/AC3: it offers no Serve and no way to serve a tool — a process reaches the bus only as itself, and
// never answers a call. Checked by reflection over *Conn's whole method set and field list, so a later
// addition cannot bring one back without this test naming it.
func TestConnOffersNoServeAndExposesNoRawConnection(t *testing.T) {
// 1171/AC3 as lead answer A-107 changes it: a process answers its seat's verbs through AnswerSeat alone — no Serve,
// Handle, Subscribe or Respond of its own — and exposes no raw connection. Checked by reflection over *Conn's whole
// method set and field list, so a later addition cannot bring one back without this test naming it.
func TestConnOffersOnlyAnswerSeatToServeAndExposesNoRawConnection(t *testing.T) {
ptrType := reflect.TypeOf(&Conn{})
if _, ok := ptrType.MethodByName("AnswerSeat"); !ok {
t.Error("*Conn has no AnswerSeat: a process answers its seat's verbs through it (A-107)")
}
for _, forbidden := range []string{"Serve", "ServeOn", "Handle", "Subscribe", "Respond", "Answer"} {
if _, ok := ptrType.MethodByName(forbidden); ok {
t.Errorf("*Conn has a method %s: a process offers no way to serve a tool", forbidden)
t.Errorf("*Conn has a method %s: a process serves only through AnswerSeat", forbidden)
}
}
+62
View File
@@ -0,0 +1,62 @@
package process
import (
"fmt"
"log"
"os"
"strings"
"sync"
)
// FingerprintEnv is the environment value the node-engine starts each process with, a service and a container alike:
// the fingerprint of the want it applies (novox/hq ADR 0656; design 47 *The fingerprint handover*).
const FingerprintEnv = "MESH_FINGERPRINT"
// OwnBuildUnknown is what a process started with no fingerprint says of its own build, in its journal and to whoever
// reads it (the node-engine's process builds, the indexer acting on it as ADR 0639 §8 has it).
const OwnBuildUnknown = "own build unknown"
// Build is the build a process was started with: its fingerprint, or why it is unknown.
type Build struct {
Fingerprint string `json:"fingerprint,omitempty"`
// Unknown says why there is no fingerprint, starting with OwnBuildUnknown; empty when there is one.
Unknown string `json:"unknown,omitempty"`
}
// String is the fingerprint, or why the build is unknown.
func (b Build) String() string {
if b.Fingerprint != "" {
return b.Fingerprint
}
return b.Unknown
}
// ReadOwnBuild is the build getenv's FingerprintEnv names: one word, or unknown with the reason.
func ReadOwnBuild(getenv func(string) string) Build {
fp := strings.TrimSpace(getenv(FingerprintEnv))
switch {
case fp == "":
return Build{Unknown: OwnBuildUnknown + ": started without " + FingerprintEnv}
case strings.ContainsAny(fp, " \t\r\n"):
return Build{Unknown: fmt.Sprintf("%s: %s %q is not one fingerprint", OwnBuildUnknown, FingerprintEnv, fp)}
}
return Build{Fingerprint: fp}
}
// sayOwnBuild writes the process's own build in its journal; a variable so a test reads what it says.
var sayOwnBuild = func(b Build) { log.Printf("own build: %s", b) }
// readAndSay reads the build from this process's environment and says it.
func readAndSay() Build {
b := ReadOwnBuild(os.Getenv)
sayOwnBuild(b)
return b
}
// ownBuild is read once; a test that reads it makes it again (sync.OnceValue(readAndSay)) and puts it back.
var ownBuild = sync.OnceValue(readAndSay)
// OwnBuild is this process's own build, read once from its environment at the first ask and said in its journal then;
// every later ask answers the same, whatever the environment says since. Connect and Dial ask it, so every process
// that reaches the bus says it at its start.
func OwnBuild() Build { return ownBuild() }
+170
View File
@@ -0,0 +1,170 @@
package process
import (
"context"
"fmt"
"strings"
"sync"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
// A seat's event kept in a stream of its own (seatdef's `keep`: the last message of each subject, or a stream with an age
// and a cap), not on the shared events stream (novox/hq design 47: the node-engine seat's `running.<node>.<module>`, the
// seat mesh-delivery's `owed`). Its name on the bus is seatdef.KeptStream's, `SEAT_<seat>_<event>`, which a generated
// contract carries as Stream<E>: one name in one place. These are the one rule, for the part that makes the stream's
// consumers (the grants part, from each module's `consumes`) and for every part that hears it, of the module's durable
// on it.
// keptStreamName is whether stream is a name seatdef.KeptStream gives: the bus's, with its prefix and no dot.
func keptStreamName(stream string) bool {
return strings.HasPrefix(stream, seatdef.KeptStreamPrefix) && len(stream) > len(seatdef.KeptStreamPrefix) &&
!strings.Contains(stream, ".")
}
// SeatEventConsumerConfig is the module's durable on a seat event's own stream as its maker makes it: <node>_<module>, as
// on the shared stream, filtered to the subjects the module consumes, acknowledged one by one; for an event kept last
// per subject it starts from the last message of each subject, never from now, so a consumer made after a subject was
// last said still hears it, and two parts' consumers made at different times hold the same (design 47).
func (a Account) SeatEventConsumerConfig(subjects []string, lastPerSubject bool) jetstream.ConsumerConfig {
cfg := jetstream.ConsumerConfig{Durable: a.EventConsumerName(), AckPolicy: jetstream.AckExplicitPolicy, FilterSubjects: subjects}
if lastPerSubject {
cfg.DeliverPolicy = jetstream.DeliverLastPerSubjectPolicy
}
return cfg
}
// What HearSeatEvents says of its start read through OnRead (design 47 G §641, A-19): `waiting` when it begins the read of
// a stream kept last per subject, `read` when the read has ended and the durable is followed.
const (
ReadWaiting = "waiting"
ReadDone = "read"
)
// HearOption is an option of HearSeatEvents.
type HearOption func(*hearOptions)
type hearOptions struct {
onRead func(state string)
fromFirst bool
}
// OnRead is the callback HearSeatEvents calls with ReadWaiting and then ReadDone around its start read; the module wires it
// to its first views (design 47 G A-19), so this package imports nothing of views.
func OnRead(f func(state string)) HearOption { return func(o *hearOptions) { o.onRead = f } }
// FromFirst is HearStream's form for a stream kept by age and cap, not last per subject (design 47 G A-233, A-189): at every
// start the stream is read whole from its first message through an ordered consumer made for that one read, on the subjects of
// the durable, and only then is the durable followed. A message the durable has not acknowledged yet is so handed twice, once
// by the read and once by the durable: the handler takes a message again as it took it.
func FromFirst() HearOption { return func(o *hearOptions) { o.fromFirst = true } }
// readWhole hands handle every message an ordered consumer, made on stream for the one read, delivers from the last of each
// subject (or, from DeliverAll, from the first message, FromFirst) filtered to subjects, until none is left pending (design 47 G §556): it ends when the message it delivered has
// none after it, or at once when the consumer, made, holds none.
func readWhole(ctx context.Context, js jetstream.JetStream, stream string, subjects []string, handle func(jetstream.Msg), from jetstream.DeliverPolicy) error {
cons, err := js.OrderedConsumer(ctx, stream, jetstream.OrderedConsumerConfig{FilterSubjects: subjects, DeliverPolicy: from})
if err != nil {
return err
}
last := make(chan struct{})
var once sync.Once
cc, err := cons.Consume(func(m jetstream.Msg) {
handle(m)
if md, err := m.Metadata(); err == nil && md.NumPending == 0 {
once.Do(func() { close(last) })
}
})
if err != nil {
return err
}
defer cc.Stop()
if info := cons.CachedInfo(); info != nil && info.NumPending == 0 && info.Delivered.Consumer == 0 {
return nil
}
select {
case <-last:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// HearSeatEvents binds the module's durable on the stream (a contract's Stream<E>, as it stands) and pulls it, handing each
// message to handle, which acknowledges it; every error the bus reports while hearing is said through logf. Like
// EventHub it makes neither the stream nor the consumer: one the bus does not hold yet is this call's error, naming
// both, and a later call binds it. The caller stops the returned context.
//
// A stream the bus's own info says is kept last per subject is first read whole (design 47 G §641, §556, A-19): through an
// ordered consumer made for that one read, on the subjects of the durable, to the last of each, its messages going to the
// same handle; only then is the durable followed, so until the read ends the part hears none of it. OnRead says `waiting`
// before the read and `read` after it.
func (a Account) HearSeatEvents(ctx context.Context, js jetstream.JetStream, stream string, handle func(jetstream.Msg), logf func(string, ...any), opts ...HearOption) (jetstream.ConsumeContext, error) {
if !keptStreamName(stream) {
return nil, fmt.Errorf("%q is not the bus's name of a seat event's stream: a contract's Stream<E> is %s<seat>_<event>, "+
"with no dot (seatdef.KeptStream)", stream, seatdef.KeptStreamPrefix)
}
return a.HearStream(ctx, js, stream, handle, logf, opts...)
}
// wholeRead is how a start read begins: from the last of each subject for a stream kept last per subject, from the first
// message for FromFirst's, and none for another stream.
func wholeRead(maxPerSubject int64, fromFirst bool) (jetstream.DeliverPolicy, bool) {
switch {
case maxPerSubject == 1:
return jetstream.DeliverLastPerSubjectPolicy, true
case fromFirst:
return jetstream.DeliverAllPolicy, true
}
return 0, false
}
// HearStream is HearSeatEvents for the stream of a module's own events (a manifest's `streams` entry, named as
// catalogue.StreamBusName names it), which the module is granted through its `consumes`: the same one rule, a stream the bus's own
// info says is kept last per subject (`last-per-subject`) read whole first, then the durable followed (design 47 G §556, §703, A-181);
// with FromFirst, a stream kept by age is read whole from its first message the same way (A-233).
func (a Account) HearStream(ctx context.Context, js jetstream.JetStream, stream string, handle func(jetstream.Msg), logf func(string, ...any), opts ...HearOption) (jetstream.ConsumeContext, error) {
if handle == nil {
return nil, fmt.Errorf("hearing %s needs a handler", stream)
}
if logf == nil {
logf = func(string, ...any) {}
}
var o hearOptions
for _, f := range opts {
f(&o)
}
cons, err := a.EventConsumer(ctx, js, stream)
if err != nil {
return nil, fmt.Errorf("the consumer %s on %s is not there: %w", a.EventConsumerName(), stream, err)
}
st, err := js.Stream(ctx, stream)
if err != nil {
return nil, fmt.Errorf("the stream %s is not read: %w", stream, err)
}
if from, whole := wholeRead(st.CachedInfo().Config.MaxMsgsPerSubject, o.fromFirst); whole {
if o.onRead != nil {
o.onRead(ReadWaiting)
}
cfg := cons.CachedInfo().Config
subjects := cfg.FilterSubjects
if cfg.FilterSubject != "" {
subjects = append(subjects, cfg.FilterSubject)
}
if err := readWhole(ctx, js, stream, subjects, handle, from); err != nil {
return nil, fmt.Errorf("the start read of %s is not made: %w", stream, err)
}
if o.onRead != nil {
o.onRead(ReadDone)
}
logf("events: %s is read whole at its start", stream)
}
cc, err := HearEvents(cons, handle, logf)
if err != nil {
return nil, fmt.Errorf("the consumer %s on %s is not heard: %w", a.EventConsumerName(), stream, err)
}
logf("events: the consumer %s on %s is heard", a.EventConsumerName(), stream)
return cc, nil
}
+196
View File
@@ -0,0 +1,196 @@
package process
import (
"encoding/json"
"fmt"
"regexp"
"sort"
"strings"
"github.com/nats-io/nats.go"
"git.novox.be/novox/mesh-sdk/go/approval"
"git.novox.be/novox/mesh-sdk/go/calladdress"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
// SeatHandler answers one call of a seat's verb: caller is the bus user the subject the call arrived on names
// (the bus server enforces it, so it is a fact, never read from the body), or "" when the call came on the subject
// that names none (an unproven caller); body is the call's arguments. A returned error is the call's failure.
type SeatHandler func(caller string, body json.RawMessage) (any, error)
// callerUser is the bus user in a caller token: each `~` is a dot (mesh-tools node-tools/internal/bus/caller.go
// UserOfToken), "" for a token that is not a bus user's name.
var callerUser = regexp.MustCompile(`^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$`)
func userOfToken(token string) string {
user := strings.ReplaceAll(token, "~", ".")
if !callerUser.MatchString(user) {
return ""
}
return user
}
// AnswerSeat answers the verbs of a mesh seat this process's module holds, over the bus, as the tool runner
// answers a bundle's (novox/hq design 47 G, lead answer A-107): on `mesh.seat.<seat>.call.<verb>.*`, handing the
// handler the caller its last token names, and on `mesh.seat.<seat>.tool.<verb>`, which names none. Every instance
// of the seat's holder subscribes in one queue group per seat, so a call reaches one of them. The reply is
// {result, error, node}, as Call reads it. The returned stop ends every subscription. Nothing is subscribed when
// any verb is malformed or a subscription is refused.
//
// Before a handler runs, the call's caller is checked against the verb's effective callers (design 47 G §610; lead
// answers A-165, A-179, A-186): definition answers the seat's definition from the seat list the process follows, false
// while it is not read. An unproven caller is refused, and so is every caller while the definition is unread, names no
// such verb or opens it to none; a proven caller is among them, since the grants part composes a verb's call subject
// only for its effective callers.
//
// A verb the definition marks `needs-approval` is held through the gate Gated gives (design 47 G §677; lead answers A-224,
// A-244): a call that carries the header approval.WaitHeader is kept and answered at once with the reply's field `held`,
// its end said on the seat's `gated-ended`; one that does not takes ADR 0286 §37's path. With no gate a marked verb is
// refused to every caller. With a gate the seat's verb `approvals` is served too, unless verbs answers it.
func (c *Conn) AnswerSeat(seat string, definition func() (seatdef.Definition, bool), verbs map[string]SeatHandler, opts ...SeatOption) (stop func(), err error) {
var o seatOptions
for _, f := range opts {
f(&o)
}
if definition == nil {
return nil, fmt.Errorf("AnswerSeat(%q) is given no definition of the seat to read its verbs' callers from", seat)
}
if len(verbs) == 0 {
return nil, fmt.Errorf("AnswerSeat(%q) answers no verb", seat)
}
if o.gate != nil && verbs[seatdef.ApprovalsVerb] == nil {
all := make(map[string]SeatHandler, len(verbs)+1)
for v, h := range verbs {
all[v] = h
}
gate := o.gate
all[seatdef.ApprovalsVerb] = func(caller string, _ json.RawMessage) (any, error) { return gate.Approvals(caller) }
verbs = all
}
names := make([]string, 0, len(verbs))
for verb, h := range verbs {
if h == nil {
return nil, fmt.Errorf("AnswerSeat(%q): the verb %s has no handler", seat, verb)
}
if _, err := calladdress.Parse("seat:" + seat + "." + verb); err != nil {
return nil, err
}
names = append(names, verb)
}
sort.Strings(names)
queue := "serve.seat." + seat
var subs []*nats.Subscription
undo := func() {
for _, s := range subs {
_ = s.Unsubscribe()
}
}
for _, verb := range names {
if o.gate != nil {
h := verbs[verb]
o.gate.Serves(seat, verb, func(caller string, args json.RawMessage) (any, error) { return h(caller, args) })
}
h := c.admitted(seat, verb, definition, verbs[verb], o.gate)
base := "mesh.seat." + seat
for _, subject := range []string{base + ".call." + verb + ".*", base + ".tool." + verb} {
subject := subject
sub, err := c.nc.QueueSubscribe(subject, queue, func(msg *nats.Msg) { go c.answerCall(msg, subject, h) })
if err != nil {
undo()
return nil, fmt.Errorf("answering %s: %w", subject, err)
}
subs = append(subs, sub)
}
}
if err := c.nc.Flush(); err != nil {
undo()
return nil, err
}
return undo, nil
}
// SeatOption is an option of AnswerSeat.
type SeatOption func(*seatOptions)
type seatOptions struct{ gate *approval.Gate }
// Gated holds the seat's marked verbs through g, the holder's gate (design 47 G §677): Conn.Gate makes it.
func Gated(g *approval.Gate) SeatOption { return func(o *seatOptions) { o.gate = g } }
// heldHandler is a verb's handler behind its checks: its answer, the held answer of a kept call, or its error.
type heldHandler func(caller string, body json.RawMessage, wait bool) (any, *approval.Held, error)
// admitted is h behind the check of its caller against the verb's effective callers (A-179, A-186), and, for a verb the
// definition marks, behind the gate (§677).
func (c *Conn) admitted(seat, verb string, definition func() (seatdef.Definition, bool), h SeatHandler, gate *approval.Gate) heldHandler {
return func(caller string, body json.RawMessage, wait bool) (any, *approval.Held, error) {
if caller == "" {
return nil, nil, fmt.Errorf("%s.%s takes no unproven caller: call it on its subject that names its caller, "+
"mesh.seat.%s.call.%s.<caller>", seat, verb, seat, verb)
}
def, read := definition()
if !read {
return nil, nil, fmt.Errorf("the definition of the seat %s is not yet read by this process's seat list, so no caller of %s.%s "+
"is admitted", seat, seat, verb)
}
for _, v := range def.Verbs {
if v.Name != verb {
continue
}
if len(v.EffectiveCallers()) == 0 {
return nil, nil, fmt.Errorf("%s.%s is open to no caller", seat, verb)
}
if !v.NeedsApproval {
answer, err := h(caller, body)
return answer, nil, err
}
if gate == nil {
return nil, nil, fmt.Errorf("Not run: %s.%s needs the operator's approval, and its holder serves it through no gate", seat, verb)
}
return gate.Hold(approval.Call{Address: seat + "." + verb, Module: c.account.Module, Seat: seat, Name: verb,
Caller: caller, Args: body, Description: v.Description}, wait, func() (any, error) { return h(caller, body) })
}
return nil, nil, fmt.Errorf("the seat %s's definition names no verb %s, so no caller is admitted", seat, verb)
}
}
// answerCall runs h for one call and replies {result, error, node}; a panic in h is the call's error, never silence.
func (c *Conn) answerCall(msg *nats.Msg, pattern string, h heldHandler) {
caller := ""
if strings.HasSuffix(pattern, ".*") {
caller = userOfToken(msg.Subject[strings.LastIndex(msg.Subject, ".")+1:])
}
var reply struct {
Result any `json:"result,omitempty"`
Error string `json:"error,omitempty"`
Node string `json:"node,omitempty"`
// Held is a kept call's answer (design 47 G §677; lead answer A-244).
Held *approval.Held `json:"held,omitempty"`
}
wait := msg.Header != nil && msg.Header.Get(approval.WaitHeader) == approval.WaitHeld
func() {
defer func() {
if r := recover(); r != nil {
reply.Error = fmt.Sprintf("%s failed: %v", msg.Subject, r)
}
}()
result, held, err := h(caller, json.RawMessage(msg.Data), wait)
switch {
case err != nil:
reply.Error = err.Error()
case held != nil:
reply.Held = held
case result == nil:
reply.Result = json.RawMessage("null")
default:
reply.Result = result
}
}()
reply.Node = c.account.Node
body, err := json.Marshal(reply)
if err != nil {
body, _ = json.Marshal(map[string]string{"error": "the answer is not JSON: " + err.Error(), "node": c.account.Node})
}
_ = msg.Respond(body)
}