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 by8afc3ebin #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 headf30dee54only 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 atf30dee54) Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 != ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user