213 lines
8.3 KiB
Go
213 lines
8.3 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 mesh 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 mesh 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 mesh'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, 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{}, true, nil
|
|
case errors.Is(err, nats.ErrNoResponders):
|
|
c.saidFallBack(subject)
|
|
return Answered{}, true, nil
|
|
case errors.Is(err, context.DeadlineExceeded) || errors.Is(err, nats.ErrTimeout):
|
|
return Answered{}, false, errors.New("timeout")
|
|
}
|
|
return Answered{}, false, err
|
|
}
|
|
answered, err = answerOf(msg.Data)
|
|
return answered, 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 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)
|
|
}
|
|
}
|