Merge branch 'feat/47-G-fix11-a5' into feat/47-G-join11
# Conflicts: # node-tools/internal/firstview/firstview_test.go # node-tools/internal/runtime/announce_test.go # node-tools/internal/runtime/busstate.go # node-tools/internal/runtime/ledgers_test.go
This commit is contained in:
@@ -849,6 +849,12 @@ func answerOf(data []byte) (Answered, error) {
|
||||
// PublishAs emits an event as a module: published into JetStream and awaited, de-duplicated by its
|
||||
// own id (ADR 0042). The body is the payload; the metadata rides as headers.
|
||||
func (c *Conn) PublishAs(module string, env Envelope) error {
|
||||
_, err := c.PublishAsAcked(module, env)
|
||||
return err
|
||||
}
|
||||
|
||||
// PublishAsAcked is PublishAs that also answers the sequence the bus acknowledged the event at.
|
||||
func (c *Conn) PublishAsAcked(module string, env Envelope) (uint64, error) {
|
||||
// On the module's own stream when its membership names one carrying the event (ADR 0363 §2), on EVENTS
|
||||
// otherwise — and only there, as before, for a membership that names no stream.
|
||||
msg := nats.NewMsg(eventSubject(c.Membership(module), module, env.Key))
|
||||
@@ -870,8 +876,11 @@ func (c *Conn) PublishAs(module string, env Envelope) error {
|
||||
if id := env.Headers["x-event-id"]; id != "" {
|
||||
opts = append(opts, nats.MsgId(id))
|
||||
}
|
||||
_, err := c.js.PublishMsg(msg, opts...)
|
||||
return err
|
||||
ack, err := c.js.PublishMsg(msg, opts...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return ack.Sequence, nil
|
||||
}
|
||||
|
||||
// Say publishes one message on core NATS, kept by nobody: a heartbeat, whose loss is the next one.
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buscalls"
|
||||
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
"git.novox.be/novox/mesh-sdk/go/membership"
|
||||
"github.com/nats-io/nats.go"
|
||||
@@ -143,6 +144,76 @@ func (h *busHistory) Read(keys string, from uint64, limit int) ([]ledger.Kept, e
|
||||
}
|
||||
}
|
||||
|
||||
// Purge removes every operation of one instance from the history stream, on the module's ledger connection, the one
|
||||
// granted the purge of its own ledger's streams.
|
||||
func (h *busHistory) Purge(key string) error {
|
||||
return purgeSubject(h.read, h.write, h.stream, h.prefix+"."+key)
|
||||
}
|
||||
|
||||
// Fill is the history stream's fill against its cap, the cap the manifest gives the ledger.
|
||||
func (h *busHistory) Fill() (buscalls.Fill, error) {
|
||||
name := h.stream
|
||||
if name == "" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
|
||||
defer cancel()
|
||||
js, err := h.read.JetStream()
|
||||
if err != nil {
|
||||
return buscalls.Fill{}, err
|
||||
}
|
||||
if name, err = js.StreamNameBySubject(ctx, h.prefix+".>"); err != nil {
|
||||
return buscalls.Fill{}, fmt.Errorf("no stream on the bus keeps %s: %w", h.prefix, err)
|
||||
}
|
||||
}
|
||||
f, err := h.read.streamFill(name)
|
||||
return f.Fill, err
|
||||
}
|
||||
|
||||
// purgeSubject purges one subject's messages from the stream that keeps it: the stream named, or found on the runner's
|
||||
// own connection by its subject, purged on the ledger connection.
|
||||
func purgeSubject(read, write *Conn, stream, subject string) error {
|
||||
if write == nil {
|
||||
return errors.New("a ledger's streams are purged only on its module's ledger connection, and none is held")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
|
||||
defer cancel()
|
||||
if stream == "" {
|
||||
js, err := read.JetStream()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if stream, err = js.StreamNameBySubject(ctx, subject); err != nil {
|
||||
return fmt.Errorf("no stream on the bus keeps %s: %w", subject, err)
|
||||
}
|
||||
}
|
||||
// The purge is asked of the stream by its name, with no read of its info first: the ledger account is granted the
|
||||
// purge of its own ledger's two streams and nothing else (PurgeAPI).
|
||||
body, err := json.Marshal(map[string]string{"filter": subject})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply, err := write.nc.RequestWithContext(ctx, PurgeAPI(stream), body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("purging %s from %s: %w", subject, stream, err)
|
||||
}
|
||||
var ack struct {
|
||||
Success bool `json:"success"`
|
||||
Error *struct {
|
||||
Description string `json:"description"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(reply.Data, &ack); err != nil || !ack.Success {
|
||||
why := string(reply.Data)
|
||||
if ack.Error != nil {
|
||||
why = ack.Error.Description
|
||||
}
|
||||
return fmt.Errorf("the bus refused the purge of %s from %s: %s", subject, stream, why)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PurgeAPI is the bus's subject that purges a stream, the one a ledger account is granted for its own ledger's streams.
|
||||
func PurgeAPI(stream string) string { return "$JS.API.STREAM.PURGE." + stream }
|
||||
|
||||
type busState struct {
|
||||
c *Conn
|
||||
bucket string
|
||||
@@ -231,6 +302,11 @@ func (s *busSnapshots) Get(key string) ([]byte, uint64, error) {
|
||||
return lastOn(s.read, "", s.prefix+"."+key)
|
||||
}
|
||||
|
||||
// Purge removes one instance's snapshot, on the module's ledger connection.
|
||||
func (s *busSnapshots) Purge(key string) error {
|
||||
return purgeSubject(s.read, s.write, "", s.prefix+"."+key)
|
||||
}
|
||||
|
||||
func (s *busSnapshots) Put(key string, value []byte, expect uint64) error {
|
||||
if s.write == nil {
|
||||
return errors.New("a ledger's snapshot is published only on its module's ledger connection, and none is held")
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package bus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// SubscribeNew hears one event, named `<emitter>.<event>`, from the next message on, on an ordered consumer of its own on each
|
||||
// stream that carries it, and hands every message to deliver as the envelope a module sees. The subjects the name lands on are
|
||||
// mesh-sdk's one rule (buscompose.EventSubjects, design 47 G §559, A-153), so a name with a wildcard is refused, naming the rule;
|
||||
// a name no stream on the bus carries is an error, never a subscription that hears nothing. stop ends every consumer.
|
||||
func (c *Conn) SubscribeNew(name string, deliver func(json.RawMessage) error) (func(), error) {
|
||||
subjects, err := buscompose.EventSubjects(name, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
js, err := c.JetStream()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
|
||||
defer cancel()
|
||||
// The streams that carry each subject, found by the bus: a namespace no stream carries hears nothing and is skipped.
|
||||
byStream := map[string][]string{}
|
||||
for _, subject := range subjects {
|
||||
if strings.HasSuffix(subject, ".>") {
|
||||
continue
|
||||
}
|
||||
stream, err := js.StreamNameBySubject(ctx, subject)
|
||||
if err != nil || stream == "" {
|
||||
continue
|
||||
}
|
||||
byStream[stream] = append(byStream[stream], subject, subject+".>")
|
||||
}
|
||||
if len(byStream) == 0 {
|
||||
return nil, fmt.Errorf("no stream on the bus carries %s (%s): nothing would be heard", name, strings.Join(subjects, ", "))
|
||||
}
|
||||
var stops []func()
|
||||
stopAll := func() {
|
||||
for _, s := range stops {
|
||||
s()
|
||||
}
|
||||
}
|
||||
for _, stream := range slices.Sorted(mapKeys(byStream)) {
|
||||
cons, err := js.OrderedConsumer(ctx, stream, jetstream.OrderedConsumerConfig{FilterSubjects: byStream[stream],
|
||||
DeliverPolicy: jetstream.DeliverNewPolicy})
|
||||
if err != nil {
|
||||
stopAll()
|
||||
return nil, fmt.Errorf("an ordered consumer of %s on %s: %w", name, stream, err)
|
||||
}
|
||||
cc, err := cons.Consume(func(m jetstream.Msg) {
|
||||
env, ok := envelopeOf(m.Subject(), m.Headers(), m.Data())
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
raw, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := deliver(raw); err != nil && c.Logf != nil {
|
||||
c.Logf("[mesh-tools] a subscription for new %s messages did not take one: %v", name, err)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
stopAll()
|
||||
return nil, fmt.Errorf("consuming %s on %s: %w", name, stream, err)
|
||||
}
|
||||
stops = append(stops, cc.Stop)
|
||||
}
|
||||
return stopAll, nil
|
||||
}
|
||||
|
||||
func mapKeys(m map[string][]string) func(func(string) bool) {
|
||||
return func(yield func(string) bool) {
|
||||
for k := range m {
|
||||
if !yield(k) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
)
|
||||
|
||||
@@ -20,6 +21,10 @@ type FirstViewer interface {
|
||||
// state and since when.
|
||||
type entryLister interface{ Entries() any }
|
||||
|
||||
// ownBuilder is a FirstViewer that also says the build the tool runner itself was started with (design 47 G §205, §424):
|
||||
// mesh_first_views names it, or says `own build unknown` and why.
|
||||
type ownBuilder interface{ OwnBuild() process.Build }
|
||||
|
||||
// firstViewsAnswer is mesh_first_views and mesh_first_view_again.
|
||||
func (s *Surface) firstViewsAnswer(ctx context.Context, name string, args map[string]any) map[string]any {
|
||||
fv, ok := s.src.(FirstViewer)
|
||||
@@ -29,6 +34,9 @@ func (s *Surface) firstViewsAnswer(ctx context.Context, name string, args map[st
|
||||
}
|
||||
if name == verbFirstViews {
|
||||
out := map[string]any{"first views": fv.Status()}
|
||||
if ob, ok := fv.(ownBuilder); ok {
|
||||
out["own build"] = ob.OwnBuild().String()
|
||||
}
|
||||
if el, ok := fv.(entryLister); ok {
|
||||
out["entries"] = el.Entries()
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
)
|
||||
|
||||
@@ -14,12 +15,16 @@ import (
|
||||
type viewer struct {
|
||||
busSource
|
||||
asked []string
|
||||
build process.Build
|
||||
}
|
||||
|
||||
func (v *viewer) Status() []views.Status {
|
||||
return []views.Status{{Owner: "mesh-delivery-assigner", Tool: "assignments", State: "assignments", Revision: 4, Asks: 1}}
|
||||
}
|
||||
|
||||
// OwnBuild is the build the viewer was started with.
|
||||
func (v *viewer) OwnBuild() process.Build { return v.build }
|
||||
|
||||
// Entries are the entries by fingerprint the viewer keeps (design 47 G A-103).
|
||||
func (v *viewer) Entries() any { return []string{"fp:a asked"} }
|
||||
|
||||
@@ -41,6 +46,13 @@ func TestTheConsoleShowsAndAsksAgainTheRunnersFirstViews(t *testing.T) {
|
||||
if got := text(s.discover(context.Background(), "mesh_first_views", nil)); !strings.Contains(got, "mesh-delivery-assigner") || !strings.Contains(got, "assignments") || !strings.Contains(got, "fp:a asked") {
|
||||
t.Errorf("mesh_first_views shows %s", got)
|
||||
}
|
||||
// The tool runner's own build is named, or said unknown with the reason (design 47 G §205, §424).
|
||||
for build, want := range map[string]string{"abcd1234": "abcd1234", "": "own build unknown"} {
|
||||
v.build = process.ReadOwnBuild(func(string) string { return build })
|
||||
if got := text(s.discover(context.Background(), "mesh_first_views", nil)); !strings.Contains(got, `own build`) || !strings.Contains(got, want) {
|
||||
t.Errorf("mesh_first_views with the build %q shows %s", build, got)
|
||||
}
|
||||
}
|
||||
got := s.discover(context.Background(), "mesh_first_view_again", map[string]any{"why": "doubted"})
|
||||
if len(v.asked) != 1 || v.asked[0] != "doubted" || !strings.Contains(text(got), "asked again") {
|
||||
t.Errorf("mesh_first_view_again asked %v and answered %s", v.asked, text(got))
|
||||
|
||||
@@ -70,8 +70,8 @@ func TestTheManifestNamesNoWaitsAndKeepsItsApprovals(t *testing.T) {
|
||||
// `registration-changed`, and the gate's `source-moved`; never the indexer's `views.modules` or `views.manifests`), the two
|
||||
// events of the seat that serves the seat list (ADR 0636 §4) and the keeper's `findings-asked`, and nothing else.
|
||||
wantConsumes := []string{"mesh-catalog.catalog-revision", "mesh-catalog.registration-changed", "mesh-catalog.seat-definition-changed",
|
||||
"mesh-catalog.seat-definition-removed", "mesh-conditions.findings-asked", "mesh-delivery-assigner.views.assignments",
|
||||
"mesh-delivery-assigner.views.seat-holders", "mesh-delivery.source-moved"}
|
||||
"mesh-catalog.seat-definition-removed", "mesh-conditions.findings-asked", "seat:mesh-module-assigner.views.assignments",
|
||||
"seat:mesh-module-assigner.views.seat-holders", "mesh-delivery.source-moved"}
|
||||
if !reflect.DeepEqual(m.Consumes, wantConsumes) {
|
||||
t.Errorf("the tool runner consumes %v, want %v", m.Consumes, wantConsumes)
|
||||
}
|
||||
|
||||
@@ -20,39 +20,41 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
|
||||
condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
|
||||
catalogseat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
|
||||
"github.com/novox/mesh-tools/node-tools/internal/approval"
|
||||
"github.com/novox/mesh-tools/node-tools/internal/bus"
|
||||
)
|
||||
|
||||
// The owner and its first-view tools (the tools' addresses are `<owner>.<tool>`).
|
||||
const Assigner = "mesh-delivery-assigner"
|
||||
// AssignerSeat is the seat whose holder's views the runner follows: the holder is read from the catalog half's claims of the
|
||||
// seat, never named in code (design 47 G §616, §428), and the names the console reads the two states by are the seat's
|
||||
// (`<seat>.<state>`; the manifest's `consumes` names their views families by the seat, as `seat:<seat>.views.<state>`).
|
||||
const AssignerSeat = meshmoduleassigner.Seat
|
||||
|
||||
// The names the console reads the assigner's two states by (`<owner>.<state>`), as the manifest's `consumes` names their views
|
||||
// families (no seat's client names a holder, novox/hq ADR 0625 §4; TestTheFirstViewsAreTheManifestsViews).
|
||||
const (
|
||||
AssignmentsRead = Assigner + ".assignments"
|
||||
SeatHoldersRead = Assigner + ".seat-holders"
|
||||
AssignmentsRead = AssignerSeat + ".assignments"
|
||||
SeatHoldersRead = AssignerSeat + ".seat-holders"
|
||||
)
|
||||
|
||||
// Spec is one first view the runner holds.
|
||||
type Spec struct{ Owner, Tool, State string }
|
||||
// Spec is one first view the runner holds of the seat's holder: its first-view tool and the state.
|
||||
type Spec struct{ Tool, State string }
|
||||
|
||||
// Specs are the two owner states asked of an owner's tool; the catalog half's views are adopted by the branch follower.
|
||||
// Specs are the two states asked of the holder's tool; the catalog half's views are adopted by the branch follower.
|
||||
var Specs = []Spec{
|
||||
{Assigner, "assignments", "assignments"},
|
||||
{Assigner, "seat-holders", "seat-holders"},
|
||||
{"assignments", "assignments"},
|
||||
{"seat-holders", "seat-holders"},
|
||||
}
|
||||
|
||||
// Name is the name a state is read by, `<owner>.<state>`: AssignmentsRead and SeatHoldersRead.
|
||||
func (s Spec) Name() string { return s.Owner + "." + s.State }
|
||||
// Name is the name a state is read by, `<seat>.<state>`: AssignmentsRead and SeatHoldersRead.
|
||||
func (s Spec) Name() string { return AssignerSeat + "." + s.State }
|
||||
|
||||
// Views is the first views: the assigner's two, and the catalog half's per watched branch.
|
||||
type Views struct {
|
||||
@@ -63,6 +65,7 @@ type Views struct {
|
||||
adopted map[string]*adoption // by `<owner>.<state>`, the views the branch follower adopted
|
||||
hearers map[string][]*adoption // event key `<seat>.<event>` -> the adopted views that hear it
|
||||
branches *meshcatalogclient.Branches
|
||||
holder *holding // the module that claims the assigner seat, once the catalog half names it
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
@@ -89,6 +92,9 @@ type Deps struct {
|
||||
Findings FindingWriter
|
||||
// StartFindings starts that writer, and its stop ends it; nil starts none.
|
||||
StartFindings func(ctx context.Context) (stop func(), err error)
|
||||
// Build is the build this process was started with (process.OwnBuild, read once at the start): its fingerprint, or
|
||||
// why it is unknown (design 47 G §205, §424).
|
||||
Build process.Build
|
||||
// Node is the machine the runner is on, named in its findings' subject.
|
||||
Node string
|
||||
// Bound and Wait are the follower's (zero: two minutes, five seconds).
|
||||
@@ -111,7 +117,7 @@ func Start(parent context.Context, conn *bus.Conn, logf func(string, ...any)) (*
|
||||
// state adapter, JetStream and node are this connection's, in this one place (design 47 G A-16, A-27).
|
||||
func DepsOf(conn *bus.Conn, logf func(string, ...any)) Deps {
|
||||
me := conn.Module()
|
||||
d := Deps{Module: me, Node: conn.Node(),
|
||||
d := Deps{Module: me, Node: conn.Node(), Build: process.OwnBuild(),
|
||||
Ask: func(address string, body any) (json.RawMessage, error) {
|
||||
got, err := conn.Ask(address, body, "")
|
||||
return got.Result, err
|
||||
@@ -168,22 +174,6 @@ func StartWith(parent context.Context, d Deps, logf func(string, ...any)) (*View
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
v := &Views{logf: logf, ctx: ctx, cancel: cancel, followers: map[string]*views.Follower{}, byState: map[string]*views.Follower{},
|
||||
adopted: map[string]*adoption{}, hearers: map[string][]*adoption{}, deps: d}
|
||||
for _, s := range Specs {
|
||||
s := s
|
||||
call := func(tool string, body any) (json.RawMessage, error) { return d.Ask(s.Owner+"."+tool, body) }
|
||||
f := &views.Follower{Owner: s.Owner, Tool: s.Tool, State: s.State, Ask: views.AskVia(call, s.Tool), Logf: logf,
|
||||
Bound: d.Bound, Wait: d.Wait,
|
||||
Raise: func(st views.Status) {
|
||||
v.finding(unansweredFinding(v.deps.Module, v.deps.Node, st, time.Now()), st.Tool, true)
|
||||
},
|
||||
Clear: func(st views.Status) {
|
||||
v.finding(unansweredFinding(v.deps.Module, v.deps.Node, st, time.Now()), st.Tool, false)
|
||||
}}
|
||||
v.followers[s.Name()] = f
|
||||
v.byState[s.State] = f
|
||||
v.wg.Add(1)
|
||||
go func() { defer v.wg.Done(); _ = f.Start(ctx) }()
|
||||
}
|
||||
stopFindings := func() {}
|
||||
if d.StartFindings != nil {
|
||||
if stop, err := d.StartFindings(ctx); err != nil {
|
||||
@@ -199,6 +189,9 @@ func StartWith(parent context.Context, d Deps, logf func(string, ...any)) (*View
|
||||
logf("[mesh-tools] the catalog half is not followed (%v): the console cannot say which modules exist", err)
|
||||
} else {
|
||||
branches.Logf = func(format string, args ...any) { logf("[mesh-tools] "+format, args...) }
|
||||
branches.Changed = func(_, module string, entry json.RawMessage, deleted bool, _ time.Time) {
|
||||
v.claimed(module, entry, deleted)
|
||||
}
|
||||
v.branches = branches
|
||||
}
|
||||
stop, err := d.Consume(v.hear)
|
||||
@@ -228,7 +221,9 @@ func (v *Views) hear(env bus.Envelope) error {
|
||||
if err != nil {
|
||||
return nil // not a views event: nothing to hold, nothing to retry
|
||||
}
|
||||
v.mu.Lock()
|
||||
f := v.byState[ev.State]
|
||||
v.mu.Unlock()
|
||||
if f == nil || f.Owner != ev.Owner {
|
||||
return nil
|
||||
}
|
||||
@@ -261,6 +256,13 @@ func (v *Views) finding(f condseat.Finding, tool string, raised bool) {
|
||||
// not answered yet, so nothing is claimed of it.
|
||||
func (v *Views) State(name string) (map[string][]byte, error) {
|
||||
v.mu.Lock()
|
||||
if state, ok := strings.CutPrefix(name, AssignerSeat+"."); ok { // the seat's name stands for its holder's
|
||||
if v.holder == nil {
|
||||
v.mu.Unlock()
|
||||
return nil, fmt.Errorf("the catalog half names no holder of the seat %s yet, so %s has no first view", AssignerSeat, name)
|
||||
}
|
||||
name = v.holder.module + "." + state
|
||||
}
|
||||
f := v.followers[name]
|
||||
v.mu.Unlock()
|
||||
if f == nil {
|
||||
@@ -297,6 +299,10 @@ func (v *Views) Status() []views.Status {
|
||||
return out
|
||||
}
|
||||
|
||||
// OwnBuild is the build the tool runner was started with: the fingerprint of the want its node-engine applied, or why it is
|
||||
// unknown (`own build unknown: started without MESH_FINGERPRINT`).
|
||||
func (v *Views) OwnBuild() process.Build { return v.deps.Build }
|
||||
|
||||
// Entries are the entries by fingerprint the catalog half's follower keeps, with their state and since when (design 47 G A-103).
|
||||
func (v *Views) Entries() any {
|
||||
if v.branches == nil {
|
||||
@@ -489,3 +495,78 @@ func (v *Views) Modules() (map[string]catalogseat.CatalogEntry, error) {
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// holding is the views of the module that claims the assigner seat, and what stops them.
|
||||
type holding struct {
|
||||
module string
|
||||
cancel context.CancelFunc
|
||||
views []*views.Follower
|
||||
}
|
||||
|
||||
// claimed is the branch follower's word of one entry changed: an entry that serves a verb of the assigner seat makes its module
|
||||
// the holder whose views the runner follows (a holder before it is dropped), the deletion of the holder's entry drops them
|
||||
// (design 47 G §616, §428). It acts on the event alone; nothing waits on a timer for the claim.
|
||||
func (v *Views) claimed(module string, entry json.RawMessage, deleted bool) {
|
||||
claims := false
|
||||
if !deleted {
|
||||
var e catalogseat.CatalogEntry
|
||||
if json.Unmarshal(entry, &e) == nil {
|
||||
claims = slices.ContainsFunc(e.Serves, func(s string) bool { return strings.HasPrefix(s, AssignerSeat+".") })
|
||||
}
|
||||
}
|
||||
v.mu.Lock()
|
||||
cur := v.holder
|
||||
switch {
|
||||
case claims && cur != nil && cur.module == module, !claims && (cur == nil || cur.module != module), v.ctx.Err() != nil:
|
||||
v.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if cur != nil {
|
||||
v.dropHolderLocked()
|
||||
}
|
||||
if claims {
|
||||
v.startHolderLocked(module)
|
||||
}
|
||||
v.mu.Unlock()
|
||||
}
|
||||
|
||||
// startHolderLocked follows the first views of module, the holder of the seat, on the runner's own consumer.
|
||||
func (v *Views) startHolderLocked(module string) {
|
||||
ctx, cancel := context.WithCancel(v.ctx)
|
||||
h := &holding{module: module, cancel: cancel}
|
||||
d := v.deps
|
||||
for _, s := range Specs {
|
||||
s := s
|
||||
call := func(tool string, body any) (json.RawMessage, error) { return d.Ask(module+"."+tool, body) }
|
||||
f := &views.Follower{Owner: module, Tool: s.Tool, State: s.State, Ask: views.AskVia(call, s.Tool), Logf: v.logf,
|
||||
Bound: d.Bound, Wait: d.Wait,
|
||||
Raise: func(st views.Status) {
|
||||
v.finding(unansweredFinding(d.Module, d.Node, st, time.Now()), st.Tool, true)
|
||||
},
|
||||
Clear: func(st views.Status) {
|
||||
v.finding(unansweredFinding(d.Module, d.Node, st, time.Now()), st.Tool, false)
|
||||
}}
|
||||
v.followers[module+"."+s.State] = f
|
||||
v.byState[s.State] = f
|
||||
h.views = append(h.views, f)
|
||||
v.wg.Add(1)
|
||||
go func() { defer v.wg.Done(); _ = f.Start(ctx) }()
|
||||
}
|
||||
v.holder = h
|
||||
v.logf("[mesh-tools] the seat %s is held by %s: its views are followed", AssignerSeat, module)
|
||||
}
|
||||
|
||||
// dropHolderLocked stops the views of the holder and clears the findings they raised.
|
||||
func (v *Views) dropHolderLocked() {
|
||||
h := v.holder
|
||||
v.holder = nil
|
||||
h.cancel()
|
||||
for _, f := range h.views {
|
||||
delete(v.followers, f.Owner+"."+f.State)
|
||||
delete(v.byState, f.State)
|
||||
if st := f.Status(); st.Raised {
|
||||
go v.finding(unansweredFinding(v.deps.Module, v.deps.Node, st, time.Now()), st.Tool, false)
|
||||
}
|
||||
}
|
||||
v.logf("[mesh-tools] %s no longer holds the seat %s: its views are no longer followed", h.module, AssignerSeat)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
delivery "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
|
||||
|
||||
"github.com/novox/mesh-tools/node-tools/internal/bus"
|
||||
mt "github.com/novox/mesh-tools/node-tools/internal/meshtest"
|
||||
)
|
||||
@@ -41,7 +45,9 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen
|
||||
if err := json.Unmarshal(raw, &manifest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
streamOf := map[string]string{Assigner: catalogue.StreamBusName("assigner-views")}
|
||||
// the manifest names the assigner's views by the seat; the module the catalog half says claims it is the fixture's
|
||||
const holder = "assigner-fixture"
|
||||
streamOf := map[string]string{"seat:" + AssignerSeat: catalogue.StreamBusName("assigner-views")}
|
||||
filters := map[string][]string{}
|
||||
families := 0
|
||||
for _, c := range manifest.Consumes {
|
||||
@@ -53,16 +59,16 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen
|
||||
if stream == "" {
|
||||
t.Fatalf("the manifest consumes %s, whose views stream the test does not know", c)
|
||||
}
|
||||
filters[stream] = append(filters[stream], process.ModuleStreamSubject(emitter, views.EventPrefix+event))
|
||||
filters[stream] = append(filters[stream], process.ModuleStreamSubject(holder, views.EventPrefix+event))
|
||||
families++
|
||||
}
|
||||
if families != len(Specs) {
|
||||
t.Fatalf("the manifest consumes %d families, the runner holds %d first views", families, len(Specs))
|
||||
}
|
||||
runner := mt.MembershipOf("node-tools", "anchor", false, nil)
|
||||
for _, owner := range []string{Assigner} {
|
||||
stream := streamOf[owner]
|
||||
mesh.ModuleStream(t, owner, stream)
|
||||
for _, seat := range []string{"seat:" + AssignerSeat} {
|
||||
stream := streamOf[seat]
|
||||
mesh.ModuleStream(t, holder, stream)
|
||||
mesh.ConsumerOn(t, stream, "anchor", "node-tools", filters[stream], 2*time.Second)
|
||||
runner.Hears = append(runner.Hears, bus.HeardIssued{Stream: stream, Consumer: "anchor_node-tools"})
|
||||
}
|
||||
@@ -93,13 +99,13 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen
|
||||
t.Cleanup(stop)
|
||||
|
||||
// The owners are down: no tool answers, nothing is claimed, and past the bound each view is a finding.
|
||||
if _, err := v.State(Assigner + ".assignments"); err == nil {
|
||||
if _, err := v.State(AssignmentsRead); err == nil {
|
||||
t.Fatal("a state its owner has not answered is claimed")
|
||||
}
|
||||
mt.Until(t, func() error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(findings) != len(Specs)+catalogViews {
|
||||
if len(findings) != catalogViews {
|
||||
return errFindings(len(findings))
|
||||
}
|
||||
return nil
|
||||
@@ -111,10 +117,25 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(nc.Close)
|
||||
respond := func(subject string, answer any) {
|
||||
if _, err := nc.Subscribe(subject, func(m *nats.Msg) {
|
||||
raw, _ := json.Marshal(answer)
|
||||
body, _ := json.Marshal(map[string]json.RawMessage{"result": raw})
|
||||
_ = m.Respond(body)
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// the catalog half answers: the gate's source names main, whose entries say the fixture claims the assigner seat
|
||||
respond("mesh.seat.mesh-delivery.tool.source", delivery.SourceAnswer{Branches: []string{"main"}})
|
||||
respond("mesh.seat.mesh-catalog.tool.registrations", catalogseat.RegistrationsAnswer{Registrations: []catalogseat.Registration{}})
|
||||
respond("mesh.seat.mesh-catalog.tool.revision", catalogseat.RevisionAnswer{
|
||||
Revision: &catalogseat.CatalogRevision{Revision: 3, Kind: meshcatalogclient.KindTrunk, Branch: "main"},
|
||||
Entries: []catalogseat.CatalogEntry{{Module: holder, Serves: []string{AssignerSeat + ".assignments"}}}})
|
||||
for _, s := range Specs {
|
||||
s := s
|
||||
if _, err := nc.Subscribe("mesh.mod."+s.Owner+".tool."+s.Tool, func(m *nats.Msg) {
|
||||
answer, _ := json.Marshal(views.Answer{V: 1, Owner: s.Owner, State: s.State, Revision: 4, Created: time.Unix(1000, 0).UTC(),
|
||||
if _, err := nc.Subscribe("mesh.mod."+holder+".tool."+s.Tool, func(m *nats.Msg) {
|
||||
answer, _ := json.Marshal(views.Answer{V: 1, Owner: holder, State: s.State, Revision: 4, Created: time.Unix(1000, 0).UTC(),
|
||||
Values: map[string]json.RawMessage{"first": json.RawMessage(`{"n":1}`)}})
|
||||
body, _ := json.Marshal(map[string]json.RawMessage{"result": answer})
|
||||
_ = m.Respond(body)
|
||||
@@ -130,7 +151,7 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen
|
||||
mt.Until(t, func() error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(findings) != catalogViews { // the seats' verbs are not served in this test: the catalog half stays unanswered
|
||||
if len(findings) != 0 { // every view answered clears its finding
|
||||
return errFindings(len(findings))
|
||||
}
|
||||
return nil
|
||||
@@ -138,9 +159,9 @@ func TestTheRunnersConsumerCarriesTheOwnersViewsFamiliesWhenTheOwnersAreDownThen
|
||||
|
||||
// Each owner's next event arrives on the runner's consumer: all four families are bound.
|
||||
for _, s := range Specs {
|
||||
ev, _ := json.Marshal(views.Event{V: 1, Owner: s.Owner, State: s.State, Key: "next", Revision: 5, Created: time.Unix(1000, 0).UTC(),
|
||||
ev, _ := json.Marshal(views.Event{V: 1, Owner: holder, State: s.State, Key: "next", Revision: 5, Created: time.Unix(1000, 0).UTC(),
|
||||
Value: json.RawMessage(`{"n":2}`)})
|
||||
mesh.Emit(t, process.ModuleStreamSubject(s.Owner, views.EventPrefix+s.State), json.RawMessage(ev))
|
||||
mesh.Emit(t, process.ModuleStreamSubject(holder, views.EventPrefix+s.State), json.RawMessage(ev))
|
||||
}
|
||||
for _, s := range Specs {
|
||||
name := s.Name()
|
||||
|
||||
@@ -32,12 +32,14 @@ type owners struct {
|
||||
// catalog is the revision of the watched branch main the seat mesh-catalog answers, and modules its entries
|
||||
catalog int64
|
||||
modules []string
|
||||
// holder is the module among them that claims the assigner seat
|
||||
holder string
|
||||
}
|
||||
|
||||
func newOwners() *owners {
|
||||
return &owners{state: map[string]map[string]string{"assignments": {"anchor": `{"p":1}`}, "seat-holders": {"seat": `{"h":1}`}},
|
||||
rev: map[string]uint64{"assignments": 4, "seat-holders": 2}, asked: map[string]int{}, finds: map[string]bool{},
|
||||
catalog: 3, modules: []string{"web"}}
|
||||
catalog: 3, modules: []string{"web", fixtureHolder}, holder: fixtureHolder}
|
||||
}
|
||||
|
||||
// Write and Clear are the runner's finding writer in a test: the findings written and not cleared, by their key.
|
||||
@@ -71,7 +73,11 @@ func (o *owners) ask(address string, _ any) (json.RawMessage, error) {
|
||||
case "seat:mesh-catalog.revision":
|
||||
a := catalogseat.RevisionAnswer{Revision: &catalogseat.CatalogRevision{Revision: o.catalog, Kind: meshcatalogclient.KindTrunk, Branch: "main"}}
|
||||
for _, m := range o.modules {
|
||||
a.Entries = append(a.Entries, catalogseat.CatalogEntry{Module: m, Manifest: map[string]any{"module": m, "tools": []string{m + "_check"}}})
|
||||
e := catalogseat.CatalogEntry{Module: m, Manifest: map[string]any{"module": m, "tools": []string{m + "_check"}}}
|
||||
if m == o.holder {
|
||||
e.Serves = []string{AssignerSeat + ".assignments", AssignerSeat + ".seat-holders"}
|
||||
}
|
||||
a.Entries = append(a.Entries, e)
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}
|
||||
@@ -94,14 +100,10 @@ func (o *owners) say(t *testing.T, state, key, value string, revision uint64, ga
|
||||
}
|
||||
}
|
||||
|
||||
func owner(state string) string {
|
||||
for _, s := range Specs {
|
||||
if s.State == state {
|
||||
return s.Owner
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
// fixtureHolder is the module the fixture catalog half says claims the assigner seat: no name is in the code.
|
||||
const fixtureHolder = "assigner-fixture"
|
||||
|
||||
func owner(state string) string { return fixtureHolder }
|
||||
|
||||
func eventually(t *testing.T, what string, ok func() bool) {
|
||||
t.Helper()
|
||||
@@ -127,39 +129,39 @@ func TestTheRunnerHoldsTheFourStatesFromTheOwnersFirstViewsAndTheirEvents(t *tes
|
||||
defer stop()
|
||||
|
||||
// Down: nothing is claimed, and past the bound each unanswered view is a finding.
|
||||
if _, err := v.State("mesh-delivery-assigner.assignments"); err == nil || !strings.Contains(err.Error(), "not been answered") {
|
||||
t.Fatalf("a state its owner has not answered is claimed: %v", err)
|
||||
if _, err := v.State(AssignmentsRead); err == nil || !strings.Contains(err.Error(), "names no holder") {
|
||||
t.Fatalf("a state whose holder is not known is claimed: %v", err)
|
||||
}
|
||||
eventually(t, "no finding past the bound", func() bool { o.mu.Lock(); defer o.mu.Unlock(); return len(o.finds) == 4 })
|
||||
eventually(t, "no finding past the bound", func() bool { o.mu.Lock(); defer o.mu.Unlock(); return len(o.finds) == 2 })
|
||||
|
||||
// Up: answered, the findings are cleared, and the pictures hold the owners' values.
|
||||
o.mu.Lock()
|
||||
o.down = false
|
||||
o.mu.Unlock()
|
||||
eventually(t, "not answered", func() bool { _, err := v.State("mesh-delivery-assigner.seat-holders"); return err == nil })
|
||||
eventually(t, "not answered", func() bool { _, err := v.State(SeatHoldersRead); return err == nil })
|
||||
eventually(t, "findings not cleared", func() bool { o.mu.Lock(); defer o.mu.Unlock(); return len(o.finds) == 0 })
|
||||
if got, _ := v.Entry("mesh-delivery-assigner.seat-holders", "none"); got != nil {
|
||||
if got, _ := v.Entry(AssignerSeat+".seat-holders", "none"); got != nil {
|
||||
t.Errorf("an absent key is %q", got)
|
||||
}
|
||||
|
||||
// The catalog half is followed by the shared branch follower on the runner's own consumer: the gate's source names main,
|
||||
// main's view is adopted, and its entries are the modules, each with its manifest (design 47 G §196, §614).
|
||||
eventually(t, "the catalog half holds no modules", func() bool { m, err := v.Modules(); return err == nil && len(m) == 1 })
|
||||
eventually(t, "the catalog half holds no modules", func() bool { m, err := v.Modules(); return err == nil && len(m) == 2 })
|
||||
m, _ := v.Modules()
|
||||
if m["web"].Manifest["module"] != "web" {
|
||||
t.Errorf("the module web is %+v", m["web"])
|
||||
}
|
||||
// A catalog-revision of the branch is heard on the same consumer and asks the view once more.
|
||||
o.mu.Lock()
|
||||
o.catalog, o.modules = 4, []string{"web", "db"}
|
||||
o.catalog, o.modules = 4, []string{"web", "db", fixtureHolder}
|
||||
o.mu.Unlock()
|
||||
ev, _ := json.Marshal(catalogseat.CatalogRevisionEvent{V: 1, Branch: "main", Kind: meshcatalogclient.KindTrunk, Revision: 4})
|
||||
if err := o.deliver(bus.Envelope{Key: catalogseat.Seat + "." + catalogseat.EventCatalogRevision, Body: ev}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eventually(t, "the catalog-revision was not taken", func() bool { m, err := v.Modules(); return err == nil && len(m) == 2 })
|
||||
eventually(t, "the catalog-revision was not taken", func() bool { m, err := v.Modules(); return err == nil && len(m) == 3 })
|
||||
// And the halves by branch, with their revision, which mesh_wait names checking an emitter (design 34 every-event §14).
|
||||
if h, err := v.Halves(); err != nil || h["main"].Revision.Revision != 4 || len(h["main"].Entries) != 2 {
|
||||
if h, err := v.Halves(); err != nil || h["main"].Revision.Revision != 4 || len(h["main"].Entries) != 3 {
|
||||
t.Errorf("the halves: %+v, %v", h, err)
|
||||
}
|
||||
|
||||
@@ -170,7 +172,7 @@ func TestTheRunnerHoldsTheFourStatesFromTheOwnersFirstViewsAndTheirEvents(t *tes
|
||||
o.say(t, "assignments", "laptop", `{"p":0}`, 5, false)
|
||||
o.say(t, "assignments", "anchor", "", 6, false)
|
||||
// (a deleted mark carries no value: Deleted is the encoding's, set below)
|
||||
if got, _ := v.Entry("mesh-delivery-assigner.assignments", "laptop"); string(got) != `{"p":2}` {
|
||||
if got, _ := v.Entry(AssignerSeat+".assignments", "laptop"); string(got) != `{"p":2}` {
|
||||
t.Errorf("the next revision is not applied: %q", got)
|
||||
}
|
||||
if n := asked("assignments"); n != before {
|
||||
@@ -188,7 +190,7 @@ func TestTheRunnerHoldsTheFourStatesFromTheOwnersFirstViewsAndTheirEvents(t *tes
|
||||
assignments = s
|
||||
}
|
||||
}
|
||||
if assignments.Owner != "mesh-delivery-assigner" || assignments.Tool != "assignments" || assignments.Asks < 2 || assignments.Answered.IsZero() {
|
||||
if assignments.Owner != fixtureHolder || assignments.Tool != "assignments" || assignments.Asks < 2 || assignments.Answered.IsZero() {
|
||||
t.Errorf("the status of assignments is %+v", assignments)
|
||||
}
|
||||
manifests := asked("seat-holders")
|
||||
@@ -228,13 +230,19 @@ func TestTheFirstViewsAreTheManifestsViews(t *testing.T) {
|
||||
t.Errorf("the first view %s is read by no name here", s.Name())
|
||||
}
|
||||
delete(want, s.Name())
|
||||
if view := s.Owner + ".views." + s.Tool; !slices.Contains(m.Consumes, view) {
|
||||
// the family is named by the seat, never by its holder (design 47 G §616)
|
||||
if view := "seat:" + AssignerSeat + ".views." + s.Tool; !slices.Contains(m.Consumes, view) {
|
||||
t.Errorf("the first view %s has no family in the manifest's consumes %v", view, m.Consumes)
|
||||
}
|
||||
}
|
||||
if len(want) > 0 {
|
||||
t.Errorf("no first view reads %v", want)
|
||||
}
|
||||
for _, c := range m.Consumes {
|
||||
if strings.Contains(c, "assigner") && !strings.HasPrefix(c, "seat:") {
|
||||
t.Errorf("the manifest names the assigner by a module: %s", c)
|
||||
}
|
||||
}
|
||||
// the catalog half's follower hears these events and asks these verbs, and the manifest says so (design 47 G §614)
|
||||
for _, ev := range []string{"mesh-catalog.catalog-revision", "mesh-catalog.registration-changed", "mesh-delivery.source-moved"} {
|
||||
if !slices.Contains(m.Consumes, ev) {
|
||||
@@ -247,3 +255,42 @@ func TestTheFirstViewsAreTheManifestsViews(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// design 47 G §616: the holder of the assigner seat is whichever module the catalog half says serves it; when another one
|
||||
// claims it the views are of the new holder, and when none does the seat's names answer that the catalog half names no holder.
|
||||
func TestTheAssignersViewsFollowTheModuleThatClaimsTheSeat(t *testing.T) {
|
||||
o := newOwners()
|
||||
deps := Deps{Module: "node-tools", Ask: o.ask, Bound: time.Second, Wait: 10 * time.Millisecond,
|
||||
Consume: func(deliver func(bus.Envelope) error) (func(), error) { o.deliver = deliver; return func() {}, nil }, Findings: o}
|
||||
v, stop := StartWith(context.Background(), deps, func(string, ...any) {})
|
||||
defer stop()
|
||||
eventually(t, "not answered", func() bool { _, err := v.State(AssignmentsRead); return err == nil })
|
||||
ownerOf := func() string {
|
||||
for _, s := range v.Status() {
|
||||
if s.State == "assignments" {
|
||||
return s.Owner
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if ownerOf() != fixtureHolder {
|
||||
t.Fatalf("the views are of %q", ownerOf())
|
||||
}
|
||||
revise := func(rev int64, holder string, modules ...string) {
|
||||
o.mu.Lock()
|
||||
o.catalog, o.modules, o.holder = rev, modules, holder
|
||||
o.mu.Unlock()
|
||||
ev, _ := json.Marshal(catalogseat.CatalogRevisionEvent{V: 1, Branch: "main", Kind: meshcatalogclient.KindTrunk, Revision: rev})
|
||||
if err := o.deliver(bus.Envelope{Key: catalogseat.Seat + "." + catalogseat.EventCatalogRevision, Body: ev}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
revise(4, "other-holder", "web", "other-holder")
|
||||
eventually(t, "the views did not move to the new holder", func() bool { return ownerOf() == "other-holder" })
|
||||
eventually(t, "not answered", func() bool { _, err := v.State(SeatHoldersRead); return err == nil })
|
||||
revise(5, "", "web")
|
||||
eventually(t, "the views stayed after the claim went", func() bool {
|
||||
_, err := v.State(AssignmentsRead)
|
||||
return err != nil && strings.Contains(err.Error(), "names no holder")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -387,7 +387,29 @@ func Start(module, entry string, env []string, mesh Bus, logf func(string, ...an
|
||||
asked := time.Now()
|
||||
switch m.Method {
|
||||
case "mesh/publish":
|
||||
err = mesh.Publish(m.Params)
|
||||
var said struct {
|
||||
Ack bool `json:"ack"`
|
||||
}
|
||||
_ = json.Unmarshal(m.Params, &said)
|
||||
if !said.Ack {
|
||||
err = mesh.Publish(m.Params)
|
||||
break
|
||||
}
|
||||
// With ack the runtime answers only once the bus acknowledged the event: its sequence, or {error}
|
||||
// (design 47 G §559). A refusal is an answer, so a writer says it as a finding's resend.
|
||||
acker, ok := mesh.(interface {
|
||||
PublishAcked(params json.RawMessage) (uint64, error)
|
||||
})
|
||||
switch {
|
||||
case !ok:
|
||||
result = map[string]any{"error": "this tool runner's bus publishes no event with an acknowledgement"}
|
||||
default:
|
||||
if seq, perr := acker.PublishAcked(m.Params); perr != nil {
|
||||
result = map[string]any{"error": perr.Error()}
|
||||
} else {
|
||||
result = map[string]any{"sequence": seq}
|
||||
}
|
||||
}
|
||||
case "mesh/ask":
|
||||
var asked json.RawMessage
|
||||
if asked, err = mesh.Ask(m.Params); err == nil {
|
||||
@@ -404,6 +426,38 @@ func Start(module, entry string, env []string, mesh Bus, logf func(string, ...an
|
||||
result = json.RawMessage(raw)
|
||||
}
|
||||
case "mesh/subscribe":
|
||||
var said struct {
|
||||
Pattern string `json:"pattern"`
|
||||
Deliver string `json:"deliver"`
|
||||
}
|
||||
_ = json.Unmarshal(m.Params, &said)
|
||||
if said.Deliver == "new" {
|
||||
// An ordered consumer of its own from the next message on (design 47 G §559, A-153): each message
|
||||
// is handed to this child as an event is, and the consumer ends with the child.
|
||||
newer, ok := mesh.(interface {
|
||||
SubscribeNew(pattern string, deliver func(json.RawMessage) error) (func(), error)
|
||||
})
|
||||
if !ok {
|
||||
err = errors.New("this tool runner's bus makes no subscription for new messages")
|
||||
break
|
||||
}
|
||||
var stop func()
|
||||
stop, err = newer.SubscribeNew(said.Pattern, func(envelope json.RawMessage) error {
|
||||
_, err := c.ask(module, "mesh/event", map[string]any{"envelope": envelope}, EventTimeout)
|
||||
return err
|
||||
})
|
||||
if err == nil {
|
||||
c.mu.Lock()
|
||||
if c.why != nil {
|
||||
c.mu.Unlock()
|
||||
stop()
|
||||
} else {
|
||||
c.watches = append(c.watches, stop)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
mu.Lock()
|
||||
subscriber = c
|
||||
mu.Unlock()
|
||||
|
||||
@@ -2,6 +2,7 @@ package launch
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -66,3 +67,77 @@ func TestABundleReadsTheCapabilityWordsRunsAPutSessionAndItsChannelsClosingDrops
|
||||
t.Fatalf("the runner took %v", b.chunks)
|
||||
}
|
||||
}
|
||||
|
||||
// findingBundle asks, as mesh-sdk's finding writer does, a publish with ack that the bus takes, one it refuses, and a
|
||||
// subscription for new messages; each answer it gets is written to $FINDING_OUT, and it ends after the subscription's.
|
||||
const findingBundle = `#!/bin/sh
|
||||
while IFS= read -r line; do
|
||||
id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9]*\)[,}].*/\1/p')
|
||||
case "$line" in
|
||||
*'"method":"initialize"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id" ;;
|
||||
*'"method":"tools/list"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"tools":[]}}\n' "$id"
|
||||
printf '{"jsonrpc":"2.0","id":"a1","method":"mesh/publish","params":{"key":"taken","ack":true,"body":{}}}\n' ;;
|
||||
*'"id":"a1"'*) printf '%s\n' "$line" >>"$FINDING_OUT"
|
||||
printf '{"jsonrpc":"2.0","id":"a2","method":"mesh/publish","params":{"key":"refused","ack":true,"body":{}}}\n' ;;
|
||||
*'"id":"a2"'*) printf '%s\n' "$line" >>"$FINDING_OUT"
|
||||
printf '{"jsonrpc":"2.0","id":"s1","method":"mesh/subscribe","params":{"pattern":"mesh-conditions.findings-asked","deliver":"new"}}\n' ;;
|
||||
*'"id":"s1"'*) printf '%s\n' "$line" >>"$FINDING_OUT"; exit 0 ;;
|
||||
esac
|
||||
done
|
||||
`
|
||||
|
||||
type findingBus struct {
|
||||
subscribing
|
||||
mu sync.Mutex
|
||||
pattern string
|
||||
stopped chan struct{}
|
||||
}
|
||||
|
||||
func (b *findingBus) PublishAcked(params json.RawMessage) (uint64, error) {
|
||||
if strings.Contains(string(params), `"refused"`) {
|
||||
return 0, errors.New("the bus refused it")
|
||||
}
|
||||
return 42, nil
|
||||
}
|
||||
|
||||
func (b *findingBus) SubscribeNew(pattern string, _ func(json.RawMessage) error) (func(), error) {
|
||||
b.mu.Lock()
|
||||
b.pattern = pattern
|
||||
b.mu.Unlock()
|
||||
return func() { close(b.stopped) }, nil
|
||||
}
|
||||
|
||||
// Design 47 G §559: `mesh/publish` with `ack` is answered with its sequence once the bus acknowledged it, or `{error}`;
|
||||
// `mesh/subscribe` with `deliver: new` makes a subscription for new messages that ends with the bundle's channel.
|
||||
func TestAPublishWithAckIsAnsweredWithItsSequenceAndASubscriptionForNewMessagesEndsWithTheChannel(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
entry, out := filepath.Join(dir, "bundle"), filepath.Join(dir, "out")
|
||||
if err := os.WriteFile(entry, []byte(findingBundle), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := &findingBus{stopped: make(chan struct{})}
|
||||
l, err := Start("writer", entry, append(os.Environ(), "FINDING_OUT="+out), b, func(string, ...any) {})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(l.Stop)
|
||||
select {
|
||||
case <-b.stopped:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the subscription for new messages was not ended with the bundle's channel")
|
||||
}
|
||||
got, _ := os.ReadFile(out)
|
||||
for _, want := range []string{`"sequence":42`, `"error":"the bus refused it"`} {
|
||||
if !strings.Contains(string(got), want) {
|
||||
t.Errorf("the bundle was answered %s, want it to hold %s", got, want)
|
||||
}
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.pattern != "mesh-conditions.findings-asked" {
|
||||
t.Errorf("the subscription was made for %q", b.pattern)
|
||||
}
|
||||
if len(b.published) != 0 {
|
||||
t.Errorf("a publish with ack went the unacknowledged way: %v", b.published)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,10 @@ type History interface {
|
||||
// Read is the operations of the instances keys names (a key, with `*` for one name and `>` for the rest) from a
|
||||
// sequence on, oldest first, at most limit of them (every one when limit is 0).
|
||||
Read(keys string, from uint64, limit int) ([]Kept, error)
|
||||
// Purge removes every operation of the instance key names, and nothing of another (an instance's removal).
|
||||
Purge(key string) error
|
||||
// Fill is the history's fill against its cap, the ledger's cap (buscalls.LedgerFill; design 47 G §587).
|
||||
Fill() (buscalls.Fill, error)
|
||||
}
|
||||
|
||||
// State is a ledger's state bucket: each instance's record under its key.
|
||||
@@ -69,6 +73,8 @@ type State interface {
|
||||
Write(key string, value []byte, revision uint64) (uint64, error)
|
||||
// Keys is every key the bucket holds.
|
||||
Keys() ([]string, error)
|
||||
// Delete removes the key; a key it does not hold is no fault.
|
||||
Delete(key string) error
|
||||
}
|
||||
|
||||
// Snapshots are a ledger's snapshots: the latest record of each instance, on a subject of its own, never expiring.
|
||||
@@ -77,6 +83,8 @@ type Snapshots interface {
|
||||
Get(key string) ([]byte, uint64, error)
|
||||
// Put writes the key's snapshot, expecting its last to be at expect (0: none); ErrRaced when it is not.
|
||||
Put(key string, value []byte, expect uint64) error
|
||||
// Purge removes the key's snapshot; one it does not hold is no fault.
|
||||
Purge(key string) error
|
||||
}
|
||||
|
||||
// Ledger is one ledger of one module: its types and where it is kept.
|
||||
@@ -495,3 +503,74 @@ func (l *Ledger) Apply(r buscalls.ApplyRequest, author buscalls.Author) (buscall
|
||||
}
|
||||
return buscalls.Applied{Taken: true, Sequence: seq, StateBehind: behind}, nil
|
||||
}
|
||||
|
||||
// Fill is the fill of the ledger's history against the ledger's cap (buscalls.LedgerFill; design 47 G §587): its messages,
|
||||
// its bytes and the cap in bytes. The history holds every operation, and the cap is its alone: neither the fullest of the
|
||||
// stores nor their sum is answered. A history that cannot say its fill is an error naming the ledger, never a fill of
|
||||
// nothing.
|
||||
func (l *Ledger) Fill(buscalls.LedgerRequest) (buscalls.Fill, error) {
|
||||
f, err := l.History.Fill()
|
||||
if err != nil {
|
||||
return buscalls.Fill{}, fmt.Errorf("%s's ledger %s cannot say the fill of its history: %w", l.Module, l.Name, err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Remove removes one instance with everything it kept (buscalls.LedgerRemove; design 47 G §587, §155): its operations, its
|
||||
// snapshot, its key in the state bucket and its keys in every declared index, and answers how many operations and how many
|
||||
// bytes of the history that was. An instance the ledger does not hold answers 0 and 0, and a removal that left something
|
||||
// (said, and answered as an error) is made again by calling it again, which removes the rest.
|
||||
func (l *Ledger) Remove(r buscalls.LedgerRemoveRequest) (buscalls.LedgerRemoved, error) {
|
||||
key, err := KeyOf(r.Instance)
|
||||
if err != nil {
|
||||
return buscalls.LedgerRemoved{}, err
|
||||
}
|
||||
out := buscalls.LedgerRemoved{Instance: r.Instance}
|
||||
before, err := l.History.Fill()
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("%s's ledger %s cannot say the fill of its history, so a removal is not made: %w", l.Module, l.Name, err)
|
||||
}
|
||||
ops, err := l.History.Read(key, 1, 0)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
for _, step := range []struct {
|
||||
what string
|
||||
remove func(string) error
|
||||
}{{"operations", l.History.Purge}, {"snapshot", l.Snapshots.Purge}, {"state", l.State.Delete}, {"index entries", l.dropFromIndexes}} {
|
||||
if err := step.remove(key); err != nil {
|
||||
l.say("[mesh-tools] %s's ledger %s removes %s and its %s could not be removed: %v; removing it again removes what "+
|
||||
"is left", l.Module, l.Name, r.Instance, step.what, err)
|
||||
return out, fmt.Errorf("%s's ledger %s removed part of %s and its %s are left: %w", l.Module, l.Name, r.Instance, step.what, err)
|
||||
}
|
||||
}
|
||||
after, err := l.History.Fill()
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("%s's ledger %s removed %s and cannot say the fill of its history after: %w", l.Module, l.Name, r.Instance, err)
|
||||
}
|
||||
out.Operations = uint64(len(ops))
|
||||
if before.Bytes > after.Bytes {
|
||||
out.Bytes = before.Bytes - after.Bytes
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// dropFromIndexes removes the entries of the instance key names from every index bucket the ledger is bound to.
|
||||
func (l *Ledger) dropFromIndexes(key string) error {
|
||||
instance := InstanceOf(key)
|
||||
for name, ix := range l.Indexes {
|
||||
held, err := ix.Entries("", 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the index %s: %w", name, err)
|
||||
}
|
||||
for _, e := range held {
|
||||
var v buscalls.IndexEntry
|
||||
if json.Unmarshal(e.Value, &v) == nil && v.Instance == instance {
|
||||
if err := ix.Delete(e.Key); err != nil {
|
||||
return fmt.Errorf("the index %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -100,3 +100,50 @@ func TestApplyAppendsDerivesAndSnapshots(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// design 47 G §587: a ledger's fill is its history's against the ledger's cap, a store that cannot say it is an error naming
|
||||
// the ledger; a removal takes an instance's operations, snapshot, state and index entries and answers what that was, an
|
||||
// unknown instance answers none, and nothing of another instance goes.
|
||||
func TestTheLedgerSaysItsFillAndRemovesAnInstanceWhole(t *testing.T) {
|
||||
l, f := newLedger()
|
||||
f.history.MaxBytes = 1 << 20
|
||||
for _, n := range []string{"a", "b"} {
|
||||
applied(t, l, "decision/0290", "note", `{"says":"`+n+`"}`, owner)
|
||||
}
|
||||
applied(t, l, "decision/0291", "note", `{"says":"other"}`, owner)
|
||||
entry, _ := json.Marshal(buscalls.IndexEntry{Instance: "decision/0290", Sequence: 2})
|
||||
other, _ := json.Marshal(buscalls.IndexEntry{Instance: "decision/0291", Sequence: 1})
|
||||
_ = f.index.Put("t.decision.0290", entry)
|
||||
_ = f.index.Put("t.decision.0291", other)
|
||||
|
||||
fill, err := l.Fill(buscalls.LedgerRequest{Ledger: "records"})
|
||||
if err != nil || fill.Count != 3 || fill.Bytes == 0 || fill.MaxBytes != 1<<20 {
|
||||
t.Fatalf("the fill is %+v (%v), want 3 messages against 1 MiB", fill, err)
|
||||
}
|
||||
got, err := l.Remove(buscalls.LedgerRemoveRequest{Ledger: "records", Instance: "decision/0290"})
|
||||
if err != nil || got.Instance != "decision/0290" || got.Operations != 2 || got.Bytes == 0 {
|
||||
t.Fatalf("the removal answered %+v (%v), want 2 operations and their bytes", got, err)
|
||||
}
|
||||
if kept, _ := f.history.Read("decision.0290", 1, 0); len(kept) != 0 {
|
||||
t.Errorf("operations are left: %d", len(kept))
|
||||
}
|
||||
if raw, _, _ := f.state.Get("decision.0290"); raw != nil {
|
||||
t.Errorf("the state is left: %s", raw)
|
||||
}
|
||||
if raw, _, _ := f.snapshots.Get("decision.0290"); raw != nil {
|
||||
t.Errorf("the snapshot is left: %s", raw)
|
||||
}
|
||||
if held, _ := f.index.Entries("", 0); len(held) != 1 || held[0].Key != "t.decision.0291" {
|
||||
t.Errorf("the index holds %v, want the other instance's entry alone", held)
|
||||
}
|
||||
if kept, _ := f.history.Read("decision.0291", 1, 0); len(kept) != 1 {
|
||||
t.Errorf("another instance lost operations: %d left", len(kept))
|
||||
}
|
||||
if again, err := l.Remove(buscalls.LedgerRemoveRequest{Ledger: "records", Instance: "decision/0290"}); err != nil || again.Operations != 0 || again.Bytes != 0 {
|
||||
t.Errorf("a removal said twice answered %+v (%v), want none", again, err)
|
||||
}
|
||||
f.history.FillFails = true
|
||||
if _, err := l.Fill(buscalls.LedgerRequest{}); err == nil || !strings.Contains(err.Error(), "ledger records cannot say the fill") {
|
||||
t.Errorf("a history that cannot say its fill answered %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buscalls"
|
||||
)
|
||||
|
||||
// The memory stores keep what the bus keeps, as the bus answers it, for a test of a ledger without a bus: a stream whose
|
||||
@@ -12,9 +15,13 @@ import (
|
||||
|
||||
// MemoryHistory is a history in memory.
|
||||
type MemoryHistory struct {
|
||||
mu sync.Mutex
|
||||
seq uint64
|
||||
kept []struct {
|
||||
// MaxBytes is the cap its fill is said against.
|
||||
MaxBytes uint64
|
||||
// FillFails makes Fill an error, as a store that cannot say its fill.
|
||||
FillFails bool
|
||||
mu sync.Mutex
|
||||
seq uint64
|
||||
kept []struct {
|
||||
key string
|
||||
Kept
|
||||
}
|
||||
@@ -73,6 +80,32 @@ func (h *MemoryHistory) Read(keys string, from uint64, limit int) ([]Kept, error
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (h *MemoryHistory) Purge(key string) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
kept := h.kept[:0]
|
||||
for _, k := range h.kept {
|
||||
if k.key != key {
|
||||
kept = append(kept, k)
|
||||
}
|
||||
}
|
||||
h.kept = kept
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *MemoryHistory) Fill() (buscalls.Fill, error) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.FillFails {
|
||||
return buscalls.Fill{}, errors.New("the history does not say its fill")
|
||||
}
|
||||
f := buscalls.Fill{Count: uint64(len(h.kept)), MaxBytes: h.MaxBytes}
|
||||
for _, k := range h.kept {
|
||||
f.Bytes += uint64(len(k.Data))
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Expire drops every operation of the history, as a ledger's keeping days end them.
|
||||
func (h *MemoryHistory) Expire() {
|
||||
h.mu.Lock()
|
||||
@@ -129,6 +162,13 @@ func (s *MemoryState) Keys() ([]string, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *MemoryState) Delete(key string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.kv, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MemorySnapshots are snapshots in memory.
|
||||
type MemorySnapshots struct {
|
||||
mu sync.Mutex
|
||||
@@ -157,6 +197,13 @@ func (s *MemorySnapshots) Put(key string, value []byte, expect uint64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemorySnapshots) Purge(key string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.kv, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MemoryIndex is an index's bucket in memory.
|
||||
type MemoryIndex struct {
|
||||
mu sync.Mutex
|
||||
|
||||
@@ -156,7 +156,7 @@ func sdkMembership(t *testing.T, mem bus.Membership) membership.Membership {
|
||||
// no holder).
|
||||
const (
|
||||
catalogHolder = "mesh-catalog"
|
||||
assignerHolder = "mesh-delivery-assigner"
|
||||
assignerHolder = "mesh-module-assigner" // the seat's name stands for its holder in the console's state names (firstview.AssignmentsRead)
|
||||
// AssignmentsBucket and SeatHoldersBucket are the assigner's states assignments and seat-holders.
|
||||
AssignmentsBucket = assignerHolder + "_assignments"
|
||||
SeatHoldersBucket = assignerHolder + "_seat-holders"
|
||||
|
||||
@@ -2,6 +2,9 @@ package runtime
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"git.novox.be/novox/mesh-sdk/go/buscalls"
|
||||
"os"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -139,7 +142,60 @@ func TestTheRunnerStatesTheCapabilitiesOfItsCallsInItsPing(t *testing.T) {
|
||||
t.Fatalf("ping of the instance: %v", err)
|
||||
}
|
||||
var ping micro.Ping
|
||||
if err := json.Unmarshal(msg.Data, &ping); err != nil || ping.Metadata[announce.Capabilities] != "approval-mark/1,bus-state-calls,ledger-calls,put-sessions,seat-settle-delay" {
|
||||
if err := json.Unmarshal(msg.Data, &ping); err != nil || ping.Metadata[announce.Capabilities] != "approval-mark/1,bus-state-calls,finding-calls,ledger-calls,ledger-upkeep-calls,put-sessions,seat-settle-delay,sent-objects-calls" {
|
||||
t.Errorf("the runner states %q (%v), want its bus-state and ledger calls", ping.Metadata[announce.Capabilities], err)
|
||||
}
|
||||
}
|
||||
|
||||
// Design 47 G §462, §488, §622: each call is stated under the capability word of its own family, the words mesh-sdk's buscalls
|
||||
// names: the read of a sent object under sent-objects-calls (no longer under bus-state-calls), the ledger's fill and removal
|
||||
// under ledger-upkeep-calls, the others where they were.
|
||||
func TestEachCallIsStatedUnderTheWordOfItsFamily(t *testing.T) {
|
||||
for method, word := range map[string]string{
|
||||
buscalls.ObjectsSentGet: buscalls.SentObjectsCalls,
|
||||
buscalls.LedgerFill: buscalls.LedgerUpkeepCalls,
|
||||
buscalls.LedgerRemove: buscalls.LedgerUpkeepCalls,
|
||||
buscalls.LedgerApply: buscalls.LedgerCalls,
|
||||
buscalls.ObjectsPut: buscalls.BusStateCalls,
|
||||
} {
|
||||
var in []string
|
||||
for c, methods := range callFamilies {
|
||||
if slices.Contains(methods, method) {
|
||||
in = append(in, c)
|
||||
}
|
||||
}
|
||||
if len(in) != 1 || in[0] != word {
|
||||
t.Errorf("%s is stated under %v, want %s alone", method, in, word)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Design 47 G §545: the words the tool runner states are exactly its manifest's artifact `states`, so the delivery plan's edge
|
||||
// of §605 runs to it for each word a module's artifact `calls`.
|
||||
func TestTheWordsTheRunnerStatesAreItsManifestsStates(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Build struct {
|
||||
Artifacts []struct {
|
||||
Name string `json:"name"`
|
||||
States []string `json:"states"`
|
||||
} `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var declared []string
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a.Name == "runtime" {
|
||||
declared = a.States
|
||||
}
|
||||
}
|
||||
slices.Sort(declared)
|
||||
if got := CallCapabilities(); !slices.Equal(got, declared) {
|
||||
t.Errorf("the runner states %v, its manifest's states are %v", got, declared)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,12 @@ import (
|
||||
// (announce.Capabilities) for its machine's node-engine to take into the node's record. A module whose manifest asks for one is
|
||||
// held off a machine whose runner does not state it.
|
||||
const (
|
||||
CapBusStateCalls = "bus-state-calls"
|
||||
CapLedgerCalls = "ledger-calls"
|
||||
CapBusStateCalls = buscalls.BusStateCalls
|
||||
CapLedgerCalls = buscalls.LedgerCalls
|
||||
// CapLedgerUpkeepCalls is the ledger's fill and removal (§462, §488) and CapSentObjectsCalls the read of a sent object (§488):
|
||||
// each is stated once the runner answers its family, the words mesh-sdk's buscalls names (§622).
|
||||
CapLedgerUpkeepCalls = buscalls.LedgerUpkeepCalls
|
||||
CapSentObjectsCalls = buscalls.SentObjectsCalls
|
||||
// CapPutSessions is the put and get sessions' word (design 47 G §518, §622), mesh-sdk's.
|
||||
CapPutSessions = stdio.CapPutSessions
|
||||
// CapApprovalMark says the runner honours the approval mark: its gate sits on every handler it serves (design 46
|
||||
@@ -30,16 +34,19 @@ const (
|
||||
// callFamilies are the methods each capability stands for, the ones BusState and BusStateWatch answer.
|
||||
var callFamilies = map[string][]string{
|
||||
CapBusStateCalls: {buscalls.ObjectsPut, buscalls.ObjectsGet, buscalls.ObjectsList, buscalls.ObjectsDelete, buscalls.ObjectsInfo,
|
||||
buscalls.ObjectsWatch, buscalls.ObjectsSentGet, buscalls.StreamRead, buscalls.StreamFill, buscalls.WorkQueueSize, buscalls.WorkQueueDead},
|
||||
buscalls.ObjectsWatch, buscalls.StreamRead, buscalls.StreamFill, buscalls.WorkQueueSize, buscalls.WorkQueueDead},
|
||||
CapLedgerCalls: {buscalls.LedgerApply, buscalls.LedgerGet, buscalls.LedgerHistory, buscalls.LedgerWatch, buscalls.LedgerQuery,
|
||||
buscalls.LedgerRebuild, buscalls.LedgerReindex, buscalls.LedgerSnapshot, buscalls.LedgerLag},
|
||||
CapPutSessions: {buscalls.ObjectsPutChunk, buscalls.ObjectsGetChunk},
|
||||
CapLedgerUpkeepCalls: {buscalls.LedgerFill, buscalls.LedgerRemove},
|
||||
CapSentObjectsCalls: {buscalls.ObjectsSentGet},
|
||||
CapPutSessions: {buscalls.ObjectsPutChunk, buscalls.ObjectsGetChunk},
|
||||
}
|
||||
|
||||
// CallCapabilities is every capability of the calls this runner answers, sorted. The delayed settle of a pulled seat
|
||||
// message names a fate, not a method, so it stands beside the method families (novox/hq ADR 0664 §5).
|
||||
// message names a fate, not a method, so it stands beside the method families (novox/hq ADR 0664 §5); so does `finding-calls`,
|
||||
// the acknowledged `mesh/publish` and the `mesh/subscribe` for new messages the launch answers (design 47 G §559).
|
||||
func CallCapabilities() []string {
|
||||
out := []string{stdio.CapSeatSettleDelay, CapApprovalMark}
|
||||
out := []string{stdio.CapSeatSettleDelay, stdio.CapFindingCalls, CapApprovalMark}
|
||||
for c := range callFamilies {
|
||||
out = append(out, c)
|
||||
}
|
||||
@@ -114,7 +121,7 @@ func (b *moduleBus) BusState(method string, params json.RawMessage) (json.RawMes
|
||||
answer, err = conn.WorkQueueDead(b.module, r)
|
||||
}
|
||||
case buscalls.LedgerApply, buscalls.LedgerGet, buscalls.LedgerHistory, buscalls.LedgerQuery, buscalls.LedgerRebuild,
|
||||
buscalls.LedgerReindex, buscalls.LedgerSnapshot, buscalls.LedgerLag:
|
||||
buscalls.LedgerReindex, buscalls.LedgerSnapshot, buscalls.LedgerLag, buscalls.LedgerFill, buscalls.LedgerRemove:
|
||||
answer, err = b.ledgerCall(method, params)
|
||||
default:
|
||||
return nil, fmt.Errorf("the runtime answers no %s", method)
|
||||
|
||||
@@ -240,6 +240,35 @@ func TestTheLedgerKeepsItsPromisesOnTheBus(t *testing.T) {
|
||||
t.Fatalf("queried %+v, %v", q, err)
|
||||
}
|
||||
}},
|
||||
{"fill and removal are answered through the calls and remove an instance from the bus whole", "decision.gone", func(t *testing.T) {
|
||||
must(t, open(t), "decision/gone", "title", `{"title":"Gone"}`)
|
||||
must(t, open(t), "decision/gone", "note", `{"says":"two"}`)
|
||||
must(t, open(t), "decision/stays", "note", `{"says":"kept"}`)
|
||||
raw, err := m.BusState(buscalls.LedgerFill, json.RawMessage(`{"ledger":"records"}`))
|
||||
var fill buscalls.Fill
|
||||
if _ = json.Unmarshal(raw, &fill); err != nil || fill.Count < 3 || fill.Bytes == 0 {
|
||||
t.Fatalf("the fill answered %s (%v)", raw, err)
|
||||
}
|
||||
raw, err = m.BusState(buscalls.LedgerRemove, json.RawMessage(`{"ledger":"records","instance":"decision/gone"}`))
|
||||
var removed buscalls.LedgerRemoved
|
||||
if _ = json.Unmarshal(raw, &removed); err != nil || removed.Operations != 2 || removed.Bytes == 0 {
|
||||
t.Fatalf("the removal answered %s (%v)", raw, err)
|
||||
}
|
||||
l := open(t)
|
||||
if last, _ := l.History.Last("decision.gone"); last != 0 {
|
||||
t.Fatalf("operations are left, at %d", last)
|
||||
}
|
||||
if st, _, _ := l.State.Get("decision.gone"); st != nil {
|
||||
t.Fatalf("the state is left: %s", st)
|
||||
}
|
||||
if last, _ := l.History.Last("decision.stays"); last == 0 {
|
||||
t.Fatal("another instance lost its operations")
|
||||
}
|
||||
if raw, err = m.BusState(buscalls.LedgerRemove, json.RawMessage(`{"ledger":"records","instance":"decision/gone"}`)); err != nil ||
|
||||
!strings.Contains(string(raw), `"operations":0`) {
|
||||
t.Fatalf("a removal said twice answered %s (%v)", raw, err)
|
||||
}
|
||||
}},
|
||||
{"a forged author is refused", "decision.forged", func(t *testing.T) {
|
||||
_, err := m.BusState(buscalls.LedgerApply, json.RawMessage(`{"ledger":"records","instance":"decision/forged","operation":"title","body":{"title":"T","author":"op@shanks"},"call":"forged"}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "the call forged, which is not a tool call in flight") {
|
||||
|
||||
@@ -171,6 +171,17 @@ func (b *moduleBus) ledgerCall(method string, params json.RawMessage) (any, erro
|
||||
var r buscalls.LedgerRequest
|
||||
_ = json.Unmarshal(params, &r)
|
||||
return l.Lag(r)
|
||||
case buscalls.LedgerFill:
|
||||
var r buscalls.LedgerRequest
|
||||
_ = json.Unmarshal(params, &r)
|
||||
return l.Fill(r)
|
||||
case buscalls.LedgerRemove:
|
||||
// Never on an apply: a removal is no operation of the history and has no author (design 47 G §587).
|
||||
var r buscalls.LedgerRemoveRequest
|
||||
if err := decode(method, params, &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return l.Remove(r)
|
||||
}
|
||||
return nil, fmt.Errorf("the runtime answers no %s", method)
|
||||
}
|
||||
|
||||
@@ -205,13 +205,9 @@ func TestABundleToolNamedAsALedgerRepairIsRefused(t *testing.T) {
|
||||
// no other name; none answers "the runtime answers no ...".
|
||||
func TestEveryCallOfAStatedCapabilityIsAnswered(t *testing.T) {
|
||||
m, _, _ := aLedger(t)
|
||||
if got := strings.Join(CallCapabilities(), ","); got != "approval-mark/1,bus-state-calls,ledger-calls,put-sessions,seat-settle-delay" {
|
||||
if got := strings.Join(CallCapabilities(), ","); got != "approval-mark/1,bus-state-calls,finding-calls,ledger-calls,ledger-upkeep-calls,put-sessions,seat-settle-delay,sent-objects-calls" {
|
||||
t.Fatalf("the runner states %s", got)
|
||||
}
|
||||
// Design 47 G §462: the call that reads a sent object is listed under the bus state capability.
|
||||
if !slices.Contains(callFamilies[CapBusStateCalls], buscalls.ObjectsSentGet) {
|
||||
t.Errorf("%s does not list %s", CapBusStateCalls, buscalls.ObjectsSentGet)
|
||||
}
|
||||
for capability, methods := range callFamilies {
|
||||
for _, method := range methods {
|
||||
t.Run(capability+" "+method, func(t *testing.T) {
|
||||
|
||||
@@ -38,6 +38,26 @@ func TestNoBusWordReachesABundle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// design 47 G §618, §519: the runtime's own fingerprint reaches no bundle; the fingerprint a module's composed words hold does.
|
||||
func TestTheRuntimesOwnFingerprintReachesNoBundle(t *testing.T) {
|
||||
base := []string{"MESH_FINGERPRINT=runner-build", "PATH=/usr/bin"}
|
||||
for name, c := range map[string]struct {
|
||||
given map[string]string
|
||||
want string
|
||||
}{
|
||||
"composed with none sees none": {nil, ""},
|
||||
"composed with one sees its own": {map[string]string{"MESH_FINGERPRINT": "module-build"}, "MESH_FINGERPRINT=module-build"},
|
||||
} {
|
||||
env := strings.Join(BundleEnv(base, c.given, "telegram", "anchor"), "\n")
|
||||
if strings.Contains(env, "runner-build") {
|
||||
t.Errorf("%s: the runtime's own fingerprint reached the bundle: %s", name, env)
|
||||
}
|
||||
if c.want == "" && strings.Contains(env, "MESH_FINGERPRINT") || !strings.Contains(env, c.want) {
|
||||
t.Errorf("%s: the environment is %s", name, env)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A tool call reaches only a tool's subject: whatever key a caller names, it is never a seat's event, accept
|
||||
// or proof — so no tool call says a choice, a link or a code, or submits an ask, in anybody's name.
|
||||
func TestAToolCallNeverReachesASeatsEventAcceptOrProof(t *testing.T) {
|
||||
|
||||
@@ -62,6 +62,10 @@ type ListedTool struct {
|
||||
// NodeRuntimeModule is the module that is a machine's runtime, serving every module on it.
|
||||
const NodeRuntimeModule = "node-tools"
|
||||
|
||||
// fingerprintEnv is the word the node-engine starts each process with, the fingerprint of the want it applies (mesh-sdk's
|
||||
// process.FingerprintEnv; this package imports no SDK): the runtime's own is no bundle's.
|
||||
const fingerprintEnv = "MESH_FINGERPRINT"
|
||||
|
||||
// runtimeOnly are the words of the runtime's own environment no bundle is given.
|
||||
var runtimeOnly = map[string]bool{ToolEnv: true, ToolModules: true, "MESH_BROKER_FILE": true,
|
||||
"MESH_BROKER_URL": true, "MESH_CONSOLE_LISTEN": true, LedgersFile: true}
|
||||
@@ -902,6 +906,21 @@ func (b *moduleBus) Publish(params json.RawMessage) error {
|
||||
return b.all.conn.PublishAs(b.module, env)
|
||||
}
|
||||
|
||||
// PublishAcked is Publish that answers the sequence the bus acknowledged the event at (`mesh/publish` with `ack`, design 47 G §559).
|
||||
func (b *moduleBus) PublishAcked(params json.RawMessage) (uint64, error) {
|
||||
var env bus.Envelope
|
||||
if err := json.Unmarshal(params, &env); err != nil {
|
||||
return 0, fmt.Errorf("not an event envelope: %w", err)
|
||||
}
|
||||
return b.all.conn.PublishAsAcked(b.module, env)
|
||||
}
|
||||
|
||||
// SubscribeNew hands the bundle one event's messages published from now on, on an ordered consumer of its own (`mesh/subscribe`
|
||||
// with `deliver: new`, design 47 G §559); the consumer ends with the bundle's channel.
|
||||
func (b *moduleBus) SubscribeNew(pattern string, deliver func(json.RawMessage) error) (func(), error) {
|
||||
return b.all.conn.SubscribeNew(pattern, deliver)
|
||||
}
|
||||
|
||||
// Ask calls a tool as the module: `{key, body}`, answered with the tool's result (ADR 0198).
|
||||
func (b *moduleBus) Ask(params json.RawMessage) (json.RawMessage, error) {
|
||||
var asked struct {
|
||||
@@ -1399,11 +1418,13 @@ func (b *moduleBus) bind(key string, stop func()) {
|
||||
// BundleEnv is what one module's bundle is started with: the runtime's environment without its own words,
|
||||
// the module's composed words over it, and the module's and machine's names. **No bus word reaches a
|
||||
// bundle** (security review of 2026-10-08): the credential and the bus's address are the runtime's, and a
|
||||
// bundle reaches the bus only through the runtime.
|
||||
// bundle reaches the bus only through the runtime. **The runtime's own fingerprint reaches none either**: a bundle reads
|
||||
// only the `MESH_FINGERPRINT` its module's composed words hold, the build the node-engine applied for that module, and with
|
||||
// none it has none (design 47 G §519, §618).
|
||||
func BundleEnv(base []string, given map[string]string, module, node string) []string {
|
||||
words := map[string]string{}
|
||||
for _, kv := range base {
|
||||
if k, v, ok := strings.Cut(kv, "="); ok && !runtimeOnly[k] {
|
||||
if k, v, ok := strings.Cut(kv, "="); ok && !runtimeOnly[k] && k != fingerprintEnv {
|
||||
words[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//go:build integration
|
||||
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"github.com/novox/mesh-tools/node-tools/internal/bus"
|
||||
mt "github.com/novox/mesh-tools/node-tools/internal/meshtest"
|
||||
)
|
||||
|
||||
// Design 47 G §559, A-153: against the real bus a publish with ack answers the sequence the bus gave it, and a subscription
|
||||
// for new messages hears only what is published after it was made, on the subject mesh-sdk's one rule names for the event;
|
||||
// a wildcard is refused.
|
||||
func TestAnAckedPublishAnswersItsSequenceAndANewSubscriptionHearsOnlyWhatComesAfter(t *testing.T) {
|
||||
mt.New(t)
|
||||
nc, err := nats.Connect(mt.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(nc.Close)
|
||||
js, _ := jetstream.New(nc)
|
||||
ctx := context.Background()
|
||||
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "SEATEVENTS", Subjects: []string{"mesh.seat.mesh-conditions.event.>"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn := connect(t, "node-tools", "anchor")
|
||||
publish := func(body string) {
|
||||
t.Helper()
|
||||
if _, err := js.Publish(ctx, "mesh.seat.mesh-conditions.event.findings-asked", []byte(body)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
publish(`{"before":true}`)
|
||||
|
||||
var mu sync.Mutex
|
||||
var heard []string
|
||||
stop, err := conn.SubscribeNew("mesh-conditions.findings-asked", func(raw json.RawMessage) error {
|
||||
var env bus.Envelope
|
||||
_ = json.Unmarshal(raw, &env)
|
||||
mu.Lock()
|
||||
heard = append(heard, env.Key+" "+string(env.Body))
|
||||
mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer stop()
|
||||
publish(`{"after":true}`)
|
||||
publish(`{"after":2}`)
|
||||
mt.Until(t, func() error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(heard) < 2 {
|
||||
return context.DeadlineExceeded
|
||||
}
|
||||
return nil
|
||||
})
|
||||
mu.Lock()
|
||||
if len(heard) != 2 || heard[0] != `mesh-conditions.findings-asked {"after":true}` {
|
||||
t.Errorf("heard %v, want the messages after the subscription alone, in order", heard)
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
for name, want := range map[string]string{"mesh-conditions.*": "[A-Za-z0-9_-]"} {
|
||||
if _, err := conn.SubscribeNew(name, func(json.RawMessage) error { return nil }); err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("a subscription for %s answered %v, want one naming %q", name, err, want)
|
||||
}
|
||||
}
|
||||
|
||||
first, err := conn.PublishAsAcked("node-tools", bus.Envelope{Key: "finding-written", Body: json.RawMessage(`{}`)})
|
||||
second, err2 := conn.PublishAsAcked("node-tools", bus.Envelope{Key: "finding-written", Body: json.RawMessage(`{}`)})
|
||||
if err != nil || err2 != nil || first == 0 || second != first+1 {
|
||||
t.Errorf("acknowledged publishes answered %d (%v) and %d (%v), want consecutive sequences", first, err, second, err2)
|
||||
}
|
||||
}
|
||||
+12
-3
@@ -58,7 +58,16 @@
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/node-tools",
|
||||
"binary": "node-tools"
|
||||
"binary": "node-tools",
|
||||
"states": [
|
||||
"bus-state-calls",
|
||||
"finding-calls",
|
||||
"ledger-calls",
|
||||
"ledger-upkeep-calls",
|
||||
"put-sessions",
|
||||
"seat-settle-delay",
|
||||
"sent-objects-calls"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -77,8 +86,8 @@
|
||||
"mesh-catalog.seat-definition-changed",
|
||||
"mesh-catalog.seat-definition-removed",
|
||||
"mesh-conditions.findings-asked",
|
||||
"mesh-delivery-assigner.views.assignments",
|
||||
"mesh-delivery-assigner.views.seat-holders",
|
||||
"seat:mesh-module-assigner.views.assignments",
|
||||
"seat:mesh-module-assigner.views.seat-holders",
|
||||
"mesh-delivery.source-moved"
|
||||
],
|
||||
"streams": [
|
||||
|
||||
Reference in New Issue
Block a user