Files
mesh-tools/node-tools/internal/bus/caller.go
T
jschoubben 541de48e04
mesh/merge-gate pass: builds mesh-tools, node-tools → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of …
mesh/repo-check pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the change's judges the pull requests after it merges; it…
mesh/delivery delivered
node-tools: only a call pattern names a caller, and a fall-back for want of a responder is said (hq issue 365, review)
callerOn reads a caller only under the call kind, so a tool subscription ending
in a wildcard never yields one. A caller-named call that reached nobody is said
once per subject before it is asked on the old subject, so a runtime or grant
that drifted behind is seen rather than a quiet unproven. The caller tests wait
for their bundle's exit, which reads the launcher's restart pauses another test
sets.
2026-10-11 11:22:29 +02:00

213 lines
8.4 KiB
Go

package bus
import (
"context"
"encoding/json"
"errors"
"regexp"
"strings"
"sync"
"github.com/nats-io/nats.go"
)
// A tool call names its caller (novox/hq issue 365, by ADR 0259 §3's precedent for asks).
//
// mesh.mod.<module>.call.<tool>.<caller> to whichever instance answers
// mesh.mod.<module>.call.<tool>.<node>.<caller> to the instance on one machine
// mesh.seat.<seat>.call.<verb>.<caller> a mesh seat's verb
// mesh.seat.<seat>.call.<verb>.<node>.<caller> a node seat's verb on one machine
//
// The last token is the bus user that published the call, and the controller grants each user the publish of
// these subjects with its own name there and no other — so the caller is a fact the bus server enforces, and
// the runtime hands it to the module from the subject a call arrived on, never from the payload.
//
// **A kind of its own, `call`, not the `tool` subject with a token appended.** Every grant to call a tool is a
// wildcard over the `tool` kind — `mesh.mod.<m>.tool.<t>.*` for the instance on any machine, `mesh.mod.*.tool.>`
// for every tool — and either matches a `tool` subject with any caller appended, so a caller could name
// another. Under `call` no grant but the caller-named ones reaches the caller token.
//
// The `tool` subjects are served beside these for one release, and say Unproven, so callers move without a
// gap; their retirement is hq issue 464.
const CallKind = "call"
// A bus user's name carries dots (`<node>.<module>`, `person.<name>`); a subject token cannot. In the token
// each dot is written `~`, which no part of a bus user's name may hold (the controller refuses any part that
// is not letters, digits, `_` or `-`), so the token is the user's name and nothing else.
const userDot = "~"
var userName = regexp.MustCompile(`^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$`)
// CallerToken is a bus user's name as the last token of a call it publishes, or "" for a name that is not a
// bus user's.
func CallerToken(user string) string {
if !userName.MatchString(user) {
return ""
}
return strings.ReplaceAll(user, ".", userDot)
}
// UserOfToken is the bus user a caller token names, or "" when it names none.
func UserOfToken(token string) string {
user := strings.ReplaceAll(token, userDot, ".")
if !userName.MatchString(user) {
return ""
}
return user
}
// toolShape splits a tool subject — `mesh.mod.<m>.tool.<rest>` or `mesh.seat.<s>.tool.<rest>` — into what
// comes before the kind and what after; ok is false for any other subject.
func toolShape(subject string) (head, rest string, ok bool) {
parts := strings.SplitN(subject, ".", 5)
if len(parts) != 5 || parts[0] != "mesh" || (parts[1] != "mod" && parts[1] != "seat") || parts[3] != "tool" ||
parts[2] == "" || parts[4] == "" {
return "", "", false
}
return parts[0] + "." + parts[1] + "." + parts[2], parts[4], true
}
// CalledSubject is where a call to a tool subject goes naming its caller: the same address under the `call`
// kind, the caller's token last. "" for a subject that is not a tool's, or a caller that is not a bus user.
func CalledSubject(subject, user string) string {
head, rest, ok := toolShape(subject)
token := CallerToken(user)
if !ok || token == "" || strings.ContainsAny(rest, "*>") {
return ""
}
return head + "." + CallKind + "." + rest + "." + token
}
// calledPattern is what a runtime answering a tool subject also subscribes, to hear the calls that name their
// caller: the same address under the `call` kind, with any caller. "" for a subject that is not a tool's.
func calledPattern(subject string) string {
head, rest, ok := toolShape(subject)
if !ok || strings.ContainsAny(rest, "*>") {
return ""
}
return head + "." + CallKind + "." + rest + ".*"
}
// callerOn is the bus user a call names, from the subject it arrived on: its last token, under a pattern
// calledPattern made. "" — said by the module as Unproven — for a call on the subject that names no caller.
func callerOn(pattern, subject string) string {
// Only the `call` kind names a caller: a `tool` pattern ending in a wildcard (a node seat's verb on any
// machine) is never read as one.
parts := strings.SplitN(pattern, ".", 5)
if len(parts) != 5 || parts[0] != "mesh" || (parts[1] != "mod" && parts[1] != "seat") || parts[3] != CallKind ||
!strings.HasSuffix(pattern, ".*") {
return ""
}
dot := strings.LastIndex(subject, ".")
if dot < 0 || subject[:dot] != strings.TrimSuffix(pattern, ".*") {
return ""
}
return UserOfToken(subject[dot+1:])
}
// CalledHandler answers one call, told who called: the bus user the subject named, or "" when it named none.
type CalledHandler func(caller string, body json.RawMessage) (any, error)
// callers is what a connection keeps of its caller-named calls: those in flight, by subject, so a refused
// publish ends its call at once instead of after RequestTimeout.
type callers struct {
mu sync.Mutex
// fellBack are the caller-named subjects already said to have reached nobody, so each is said once.
fellBack map[string]bool
next uint64
pending map[string]map[uint64]context.CancelCauseFunc
}
// errCallRefused is the bus refusing a caller-named publish: this user is not granted it yet (the controller's
// grant not delivered to the bus), so the call is asked again on the subject that names no caller.
var errCallRefused = errors.New("the bus refused the caller-named subject")
var refusedPublish = regexp.MustCompile(`(?i)Publish to "(\S+)"`)
// publishRefused ends every call in flight on a subject the bus refused this connection the publish of.
func (c *Conn) publishRefused(err error) bool {
if !errors.Is(err, nats.ErrPermissionViolation) {
return false
}
m := refusedPublish.FindStringSubmatch(err.Error())
if len(m) < 2 {
return false
}
c.calls.mu.Lock()
cancels := c.calls.pending[m[1]]
delete(c.calls.pending, m[1])
c.calls.mu.Unlock()
if len(cancels) > 0 {
c.Logf("[mesh-tools] the bus refused this account the caller-named %s; asked again on the subject that "+
"names no caller, until the mesh grants it (novox/hq issue 365)", m[1])
}
for _, cancel := range cancels {
cancel(errCallRefused)
}
return len(cancels) > 0
}
// askCalled asks once on a caller-named subject. fallBack says the call reached nobody — nothing serves that
// subject yet, or the bus refused this user its publish — so it may be asked on the subject that names no
// caller without being done twice.
func (c *Conn) askCalled(subject string, data []byte) (answered Answered, handingOver, fallBack bool, err error) {
ctx, cancel := context.WithCancelCause(context.Background())
ctx, stop := context.WithTimeout(ctx, RequestTimeout)
defer stop()
c.calls.mu.Lock()
if c.calls.pending == nil {
c.calls.pending = map[string]map[uint64]context.CancelCauseFunc{}
}
if c.calls.pending[subject] == nil {
c.calls.pending[subject] = map[uint64]context.CancelCauseFunc{}
}
c.calls.next++
id := c.calls.next
c.calls.pending[subject][id] = cancel
c.calls.mu.Unlock()
defer func() {
c.calls.mu.Lock()
if list := c.calls.pending[subject]; list != nil {
delete(list, id)
if len(list) == 0 {
delete(c.calls.pending, subject)
}
}
c.calls.mu.Unlock()
cancel(nil)
}()
msg, err := c.nc.RequestWithContext(ctx, subject, data)
if err != nil {
switch {
case errors.Is(context.Cause(ctx), errCallRefused):
return Answered{}, false, true, nil
case errors.Is(err, nats.ErrNoResponders):
c.saidFallBack(subject)
return Answered{}, false, true, nil
case errors.Is(err, context.DeadlineExceeded) || errors.Is(err, nats.ErrTimeout):
return Answered{}, false, false, errors.New("timeout")
}
return Answered{}, false, false, err
}
answered, handingOver, err = c.answerOf(subject, msg.Data)
return answered, handingOver, false, err
}
// saidFallBack says, once per subject, that a caller-named call reached nobody and is asked on the subject that
// names no caller: a runtime or holder not yet serving it, or a grant that drifted, is seen in the log rather
// than read later as a quiet "unproven" (novox/hq issue 365; expected for the controller's seat until hq issue
// 464).
func (c *Conn) saidFallBack(subject string) {
c.calls.mu.Lock()
if c.calls.fellBack == nil {
c.calls.fellBack = map[string]bool{}
}
first := !c.calls.fellBack[subject]
c.calls.fellBack[subject] = true
c.calls.mu.Unlock()
if first {
c.Logf("[mesh-tools] nothing answers the caller-named %s; asked on the subject that names no caller, where "+
"the module is told no caller (said once per subject; novox/hq issue 365)", subject)
}
}