Merge pull request 'Bus state by template: the tool runner's objects, stream, work-queue and ledger calls with the proven author (issue 1966, B1)' (#57) from feat/bus-state into main

This commit was merged in pull request #57.
This commit is contained in:
2026-11-03 15:24:40 +00:00
25 changed files with 3613 additions and 17 deletions
+10 -1
View File
@@ -5,6 +5,7 @@
// wherever MESH_CONSOLE_LISTEN says — it is also the console, MCP over HTTP on loopback. // wherever MESH_CONSOLE_LISTEN says — it is also the console, MCP over HTTP on loopback.
// //
// MESH_BROKER_FILE the credential the mesh sealed to this machine for the runtime // MESH_BROKER_FILE the credential the mesh sealed to this machine for the runtime
// MESH_LEDGERS_FILE its own secret ledgers, each module's ledger credential; beside the broker file when unset
// MESH_TOOL_MODULES <module>=<entrypoint>,… the bundles to launch // MESH_TOOL_MODULES <module>=<entrypoint>,… the bundles to launch
// MESH_TOOL_ENV {"<module>": {"<word>": "<value>"}} what each is given (ADR 0192) // MESH_TOOL_ENV {"<module>": {"<word>": "<value>"}} what each is given (ADR 0192)
// MESH_OPERATOR_ACCOUNT whose machine this is, and MESH_OPERATOR_HOME their home // MESH_OPERATOR_ACCOUNT whose machine this is, and MESH_OPERATOR_HOME their home
@@ -56,7 +57,15 @@ func main() {
if err != nil { if err != nil {
log.Fatalf("node-tools: %v", err) log.Fatalf("node-tools: %v", err)
} }
stop, err := runtime.Run(conn, served, envs, log.Printf) // Its own secret ledgers: each module's ledger connection, the only one its history is published on (novox/hq ADR
// 0597 §9). Followed, so a credential minted or retired is taken with no restart.
var opts []runtime.Option
if file := runtime.LedgersFileOf(os.Getenv("MESH_BROKER_FILE")); file != "" {
ledgers := runtime.FollowLedgerCredentials(file, bus.Dial, log.Printf)
defer ledgers.Stop()
opts = append(opts, runtime.WithLedgerWriters(ledgers.Writer))
}
stop, err := runtime.Run(conn, served, envs, log.Printf, opts...)
if err != nil { if err != nil {
log.Fatalf("node-tools: %v", err) log.Fatalf("node-tools: %v", err)
} }
+1 -1
View File
@@ -8,7 +8,7 @@ require (
) )
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103142843-f8bd12e4b7b1
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
github.com/google/go-tpm v0.9.8 // indirect github.com/google/go-tpm v0.9.8 // indirect
github.com/klauspost/compress v1.20.0 // indirect github.com/klauspost/compress v1.20.0 // indirect
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103142843-f8bd12e4b7b1 h1:T66tg9T/1hHg6KN/bDScHPT41IHkxLAGGO8VJiAOlb8=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103142843-f8bd12e4b7b1/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+398
View File
@@ -0,0 +1,398 @@
package bus
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/buscalls"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/membership"
"github.com/nats-io/nats.go/jetstream"
)
// A module's bus state in the new form (novox/hq ADR 0595, design 25 § 12.5; issue 1966 T1): an object store, a stream,
// a work queue, a ledger, each asked for in `requires` by a local name and bound in the module's membership by the bus
// names the bus module created. **A bundle reaches only its own module's bindings**: one account per machine carries
// every module on it, so what a module may reach is decided here, from its membership, and refused with the reason
// before anything is sent, as its state is (state.go).
// Binding is the module's binding of a local name, of the storage type storage, or the reason there is none.
func (c *Conn) Binding(module, local, storage string) (membership.BusBinding, error) {
m := c.Membership(module)
if m == nil {
return membership.BusBinding{}, fmt.Errorf("%s has no membership issued on %s yet, so none of its bus state is "+
"reachable until the mesh issues one (novox/hq ADR 0595)", module, c.node)
}
var names []string
for _, b := range m.Bindings {
if b.Local != local {
names = append(names, b.Local+" ("+b.Storage+")")
continue
}
if b.Storage != storage {
return membership.BusBinding{}, fmt.Errorf("%s's %q is bound with the storage type %s, not %s (novox/hq ADR 0595)",
module, local, b.Storage, storage)
}
return b, nil
}
sort.Strings(names)
bound := "none"
if len(names) > 0 {
bound = strings.Join(names, ", ")
}
return membership.BusBinding{}, fmt.Errorf("%s was bound no %s called %q: a module reaches only the bus state its "+
"manifest asks for in `requires`, by its local name, once the mesh created it; it was bound: %s (novox/hq ADR 0595)",
module, storage, local, bound)
}
// Bindings is every binding a module's membership carries now.
func (c *Conn) Bindings(module string) []membership.BusBinding {
if m := c.Membership(module); m != nil {
return append([]membership.BusBinding(nil), m.Bindings...)
}
return nil
}
// jetStream is a JetStream handle on this connection.
func (c *Conn) jetStream() (jetstream.JetStream, error) { return jetstream.New(c.nc) }
func (c *Conn) objectStore(ctx context.Context, module, local string) (jetstream.ObjectStore, membership.BusBinding, error) {
b, err := c.Binding(module, local, catalogue.StorageObjectStore)
if err != nil {
return nil, b, err
}
js, err := c.jetStream()
if err != nil {
return nil, b, err
}
obs, err := js.ObjectStore(ctx, b.ObjectStore)
if errors.Is(err, jetstream.ErrBucketNotFound) || errors.Is(err, jetstream.ErrStreamNotFound) {
return nil, b, fmt.Errorf("%s's objects %q are bound and their store %s is not on the bus", module, local, b.ObjectStore)
}
return obs, b, err
}
func objectInfoOf(i *jetstream.ObjectInfo) buscalls.ObjectInfo {
return buscalls.ObjectInfo{Name: i.Name, Size: i.Size, Chunks: i.Chunks, Digest: i.Digest, Description: i.Description,
Modified: i.ModTime, Deleted: i.Deleted}
}
// ObjectsPut stores one object whole under its name in the module's object store.
func (c *Conn) ObjectsPut(module string, r buscalls.ObjectsPutRequest) (buscalls.ObjectInfo, error) {
if r.Name == "" {
return buscalls.ObjectInfo{}, errors.New("an object is put under a name")
}
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
defer cancel()
obs, _, err := c.objectStore(ctx, module, r.Objects)
if err != nil {
return buscalls.ObjectInfo{}, err
}
info, err := obs.PutBytes(ctx, r.Name, r.Data)
if err != nil {
return buscalls.ObjectInfo{}, err
}
if r.Description != "" {
if err := obs.UpdateMeta(ctx, r.Name, jetstream.ObjectMeta{Name: r.Name, Description: r.Description}); err != nil {
return buscalls.ObjectInfo{}, err
}
if info, err = obs.GetInfo(ctx, r.Name); err != nil {
return buscalls.ObjectInfo{}, err
}
}
return objectInfoOf(info), nil
}
// ObjectsGet is one object of the module's object store, its info and its bytes; nil when there is none.
func (c *Conn) ObjectsGet(module string, r buscalls.ObjectsNameRequest) (*buscalls.ObjectData, error) {
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
defer cancel()
obs, _, err := c.objectStore(ctx, module, r.Objects)
if err != nil {
return nil, err
}
res, err := obs.Get(ctx, r.Name)
if errors.Is(err, jetstream.ErrObjectNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
defer res.Close()
info, err := res.Info()
if err != nil {
return nil, err
}
data, err := obs.GetBytes(ctx, r.Name)
if err != nil {
return nil, err
}
return &buscalls.ObjectData{Info: objectInfoOf(info), Data: data}, nil
}
// ObjectsDelete deletes one object of the module's object store; one that is not there is not an error.
func (c *Conn) ObjectsDelete(module string, r buscalls.ObjectsNameRequest) error {
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
defer cancel()
obs, _, err := c.objectStore(ctx, module, r.Objects)
if err != nil {
return err
}
if err := obs.Delete(ctx, r.Name); err != nil && !errors.Is(err, jetstream.ErrObjectNotFound) {
return err
}
return nil
}
// ObjectsList is every object the module's object store holds.
func (c *Conn) ObjectsList(module string, r buscalls.ObjectsRequest) (buscalls.ObjectsListed, error) {
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
defer cancel()
obs, _, err := c.objectStore(ctx, module, r.Objects)
if err != nil {
return buscalls.ObjectsListed{}, err
}
out := buscalls.ObjectsListed{Objects: []buscalls.ObjectInfo{}}
infos, err := obs.List(ctx)
if errors.Is(err, jetstream.ErrNoObjectsFound) {
return out, nil
}
if err != nil {
return out, err
}
for _, i := range infos {
out.Objects = append(out.Objects, objectInfoOf(i))
}
return out, nil
}
// ObjectsInfo is how full the module's object store is: its objects, its bytes and its cap.
func (c *Conn) ObjectsInfo(module string, r buscalls.ObjectsRequest) (buscalls.Fill, error) {
listed, err := c.ObjectsList(module, r)
if err != nil {
return buscalls.Fill{}, err
}
b, _ := c.Binding(module, r.Objects, catalogue.StorageObjectStore)
fill, err := c.streamFill("OBJ_" + b.ObjectStore)
if err != nil {
return buscalls.Fill{}, err
}
fill.Count = uint64(len(listed.Objects))
return fill.Fill, nil
}
// ObjectsWatch hands deliver every object of the module's object store as it is, then each stored or deleted.
func (c *Conn) ObjectsWatch(module string, r buscalls.ObjectsWatchRequest, deliver func(buscalls.ObjectsChange) error) (func(), error) {
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
obs, _, err := c.objectStore(ctx, module, r.Objects)
cancel()
if err != nil {
return nil, err
}
watchCtx, stop := context.WithCancel(context.Background())
w, err := obs.Watch(watchCtx, jetstream.IncludeHistory())
if err != nil {
stop()
return nil, err
}
current := true
for current {
select {
case i, ok := <-w.Updates():
if !ok {
stop()
return nil, errors.New("the object store's watch ended before its current objects were handed over")
}
if i == nil {
current = false
continue
}
if err := deliver(buscalls.ObjectsChange{Watch: r.Watch, Object: objectInfoOf(i), Current: true}); err != nil {
c.Logf("[mesh-tools] %s did not take an object of %s: %v", module, r.Objects, err)
}
case <-time.After(StateTimeout):
_ = w.Stop()
stop()
return nil, fmt.Errorf("the object store %s did not hand its current objects in %s", r.Objects, StateTimeout)
}
}
go func() {
for i := range w.Updates() {
if i == nil {
continue
}
if err := deliver(buscalls.ObjectsChange{Watch: r.Watch, Object: objectInfoOf(i)}); err != nil {
c.Logf("[mesh-tools] %s did not take an object of %s: %v", module, r.Objects, err)
}
}
}()
return func() { _ = w.Stop(); stop() }, nil
}
// streamFill is a stream's fill and its first and last sequence, by its name on the bus.
func (c *Conn) streamFill(name string) (buscalls.StreamFilled, error) {
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
defer cancel()
js, err := c.jetStream()
if err != nil {
return buscalls.StreamFilled{}, err
}
s, err := js.Stream(ctx, name)
if errors.Is(err, jetstream.ErrStreamNotFound) {
return buscalls.StreamFilled{}, fmt.Errorf("the stream %s is bound and not on the bus", name)
}
if err != nil {
return buscalls.StreamFilled{}, err
}
info, err := s.Info(ctx)
if err != nil {
return buscalls.StreamFilled{}, err
}
max := uint64(0)
if info.Config.MaxBytes > 0 {
max = uint64(info.Config.MaxBytes)
}
return buscalls.StreamFilled{Fill: buscalls.Fill{Count: info.State.Msgs, Bytes: info.State.Bytes, MaxBytes: max},
First: info.State.FirstSeq, Last: info.State.LastSeq}, nil
}
// streamBinding is the module's binding of a stream by its local name, a work queue among them.
func (c *Conn) streamBinding(module, local string) (membership.BusBinding, error) {
return c.Binding(module, local, catalogue.StorageStream)
}
// StreamFill is how full the module's stream is.
func (c *Conn) StreamFill(module string, r buscalls.StreamRequest) (buscalls.StreamFilled, error) {
b, err := c.streamBinding(module, r.Stream)
if err != nil {
return buscalls.StreamFilled{}, err
}
return c.streamFill(b.Stream)
}
// StreamReadMost is the most messages one read answers.
const StreamReadMost = 1000
// StreamRead is the module's stream's messages from a sequence on, Subjects narrowing them, at most Limit of them.
func (c *Conn) StreamRead(module string, r buscalls.StreamReadRequest) (buscalls.StreamPage, error) {
b, err := c.streamBinding(module, r.Stream)
if err != nil {
return buscalls.StreamPage{}, err
}
return c.readStream(b.Stream, r.Subjects, r.From, r.Limit)
}
// readStream reads a stream by its bus name from a sequence on, the messages on the subjects filter names (every
// subject when empty), at most limit of them.
func (c *Conn) readStream(name, filter string, from uint64, limit int) (buscalls.StreamPage, error) {
if limit <= 0 || limit > StreamReadMost {
limit = StreamReadMost
}
if filter == "" {
filter = ">"
}
if from == 0 {
from = 1
}
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
defer cancel()
js, err := c.jetStream()
if err != nil {
return buscalls.StreamPage{}, err
}
s, err := js.Stream(ctx, name)
if errors.Is(err, jetstream.ErrStreamNotFound) {
return buscalls.StreamPage{}, fmt.Errorf("the stream %s is bound and not on the bus", name)
}
if err != nil {
return buscalls.StreamPage{}, err
}
info, err := s.Info(ctx)
if err != nil {
return buscalls.StreamPage{}, err
}
page := buscalls.StreamPage{Messages: []buscalls.StreamMessage{}, Last: info.State.LastSeq}
for seq := from; seq <= info.State.LastSeq && len(page.Messages) < limit; {
msg, err := s.GetMsg(ctx, seq, jetstream.WithGetMsgSubject(filter))
if errors.Is(err, jetstream.ErrMsgNotFound) {
break
}
if err != nil {
return page, err
}
body := json.RawMessage(msg.Data)
if !json.Valid(msg.Data) {
body, _ = json.Marshal(string(msg.Data))
}
page.Messages = append(page.Messages, buscalls.StreamMessage{Sequence: msg.Sequence, Subject: msg.Subject,
Body: body, At: msg.Time})
seq = msg.Sequence + 1
if len(page.Messages) == limit && seq <= info.State.LastSeq {
page.Next = seq
}
}
return page, nil
}
// WorkQueueSize is how many messages wait in the module's work queue, against its cap.
func (c *Conn) WorkQueueSize(module string, r buscalls.WorkQueueRequest) (buscalls.WorkQueueSized, error) {
b, err := c.streamBinding(module, r.Queue)
if err != nil {
return buscalls.WorkQueueSized{}, err
}
fill, err := c.streamFill(b.Stream)
if err != nil {
return buscalls.WorkQueueSized{}, err
}
return buscalls.WorkQueueSized{Fill: fill.Fill}, nil
}
// WorkQueueDead is the module's work queue's dead letters: in a work queue a message is gone once settled, so one still
// kept below a consumer's acknowledgement floor was delivered as often as the consumer allows and never settled.
func (c *Conn) WorkQueueDead(module string, r buscalls.WorkQueueDeadRequest) (buscalls.WorkQueueDeadLetters, error) {
b, err := c.streamBinding(module, r.Queue)
if err != nil {
return buscalls.WorkQueueDeadLetters{}, err
}
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
defer cancel()
js, err := c.jetStream()
if err != nil {
return buscalls.WorkQueueDeadLetters{}, err
}
s, err := js.Stream(ctx, b.Stream)
if err != nil {
return buscalls.WorkQueueDeadLetters{}, fmt.Errorf("the work queue %s: %w", b.Stream, err)
}
out := buscalls.WorkQueueDeadLetters{Dead: []buscalls.DeadLetter{}}
consumers := s.ListConsumers(ctx)
for ci := range consumers.Info() {
floor := ci.AckFloor.Stream
if ci.Config.MaxDeliver <= 0 || floor == 0 {
continue
}
page, err := c.readStream(b.Stream, ci.Config.FilterSubject, 1, r.Limit)
if err != nil {
return out, err
}
for _, m := range page.Messages {
if m.Sequence > floor {
break
}
out.Dead = append(out.Dead, buscalls.DeadLetter{Sequence: m.Sequence, Subject: m.Subject,
Deliveries: ci.Config.MaxDeliver, Body: m.Body, At: m.At})
}
}
if err := consumers.Err(); err != nil {
return out, err
}
if r.Limit > 0 && len(out.Dead) > r.Limit {
out.Dead = out.Dead[:r.Limit]
}
return out, nil
}
+112
View File
@@ -0,0 +1,112 @@
package bus_test
import (
"context"
"errors"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/buscalls"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/membership"
"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"
)
// 1966/AC12: given a bundle whose membership binds objects `files`, when it puts and gets `a`, then it reads back what it
// put; a local name it was not bound is refused with the reason, and so is another module's binding and a binding of
// another storage type. A stream and a work queue bound alike answer their read-back, fill and size.
func TestABundleReachesItsOwnBoundObjectsAndStreamsOnly(t *testing.T) {
mesh := 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.CreateObjectStore(ctx, jetstream.ObjectStoreConfig{Bucket: "m_files"}); err != nil {
t.Fatal(err)
}
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "m_events", Subjects: []string{"mesh.state.m.events.>"}}); err != nil {
t.Fatal(err)
}
for _, s := range []string{"mesh.state.m.events.one", "mesh.state.m.events.two"} {
if _, err := js.Publish(ctx, s, []byte(`{"n":1}`)); err != nil {
t.Fatal(err)
}
}
m := mt.MembershipOf("m", "anchor", false, nil)
m.Bindings = []membership.BusBinding{
{Local: "files", Storage: catalogue.StorageObjectStore, Template: catalogue.TemplateObjects, ObjectStore: "m_files"},
{Local: "events", Storage: catalogue.StorageStream, Template: catalogue.TemplateEvents, Stream: "m_events",
Subject: "mesh.state.m.events"},
}
other := mt.MembershipOf("other", "anchor", false, nil)
mesh.Issue(t, m)
mesh.Issue(t, other)
conn, err := bus.Connect(bus.Credential{URL: mt.URL(t), Module: "node-tools", Node: "anchor"})
if err != nil {
t.Fatal(err)
}
conn.Logf = func(string, ...any) {}
t.Cleanup(conn.Close)
conn.Follow("m")
conn.Follow("other")
mt.Until(t, func() error {
if conn.Membership("m") == nil || conn.Membership("other") == nil {
return errors.New("not issued yet")
}
return nil
})
if _, err := conn.ObjectsPut("m", buscalls.ObjectsPutRequest{Objects: "files", Name: "a", Data: []byte("what was put")}); err != nil {
t.Fatal(err)
}
got, err := conn.ObjectsGet("m", buscalls.ObjectsNameRequest{Objects: "files", Name: "a"})
if err != nil || got == nil || string(got.Data) != "what was put" || got.Info.Name != "a" {
t.Fatalf("read back %+v, %v", got, err)
}
if listed, err := conn.ObjectsList("m", buscalls.ObjectsRequest{Objects: "files"}); err != nil || len(listed.Objects) != 1 {
t.Fatalf("listed %+v, %v", listed, err)
}
page, err := conn.StreamRead("m", buscalls.StreamReadRequest{Stream: "events", Limit: 1})
if err != nil || len(page.Messages) != 1 || page.Next != 2 || page.Last != 2 {
t.Fatalf("read %+v, %v", page, err)
}
if fill, err := conn.StreamFill("m", buscalls.StreamRequest{Stream: "events"}); err != nil || fill.Count != 2 {
t.Fatalf("fill %+v, %v", fill, err)
}
for _, c := range []struct {
name, module, refusal string
call func(module string) error
}{
{"a local name not bound", "m", `bound no object-store called "photos"`, func(module string) error {
_, err := conn.ObjectsGet(module, buscalls.ObjectsNameRequest{Objects: "photos", Name: "a"})
return err
}},
{"another module's binding", "other", `other was bound no object-store called "files"`, func(module string) error {
_, err := conn.ObjectsGet(module, buscalls.ObjectsNameRequest{Objects: "files", Name: "a"})
return err
}},
{"a binding of another storage type", "m", `"events" is bound with the storage type stream, not object-store`, func(module string) error {
_, err := conn.ObjectsList(module, buscalls.ObjectsRequest{Objects: "events"})
return err
}},
{"a work queue not bound", "m", `bound no stream called "jobs"`, func(module string) error {
_, err := conn.WorkQueueSize(module, buscalls.WorkQueueRequest{Queue: "jobs"})
return err
}},
} {
t.Run(c.name, func(t *testing.T) {
err := c.call(c.module)
if err == nil || !strings.Contains(err.Error(), c.refusal) {
t.Fatalf("refused with %v, want the reason %q", err, c.refusal)
}
})
}
}
+16 -3
View File
@@ -78,6 +78,9 @@ type Membership struct {
// Waits are the event families a session may wait on through the mesh MCP server (novox/hq ADR 0367 §2): // Waits are the event families a session may wait on through the mesh MCP server (novox/hq ADR 0367 §2):
// the tool runner's alone, composed from its manifest with the grant that lets it subscribe them. // the tool runner's alone, composed from its manifest with the grant that lets it subscribe them.
Waits []WaitIssued `json:"waits,omitempty"` Waits []WaitIssued `json:"waits,omitempty"`
// Bindings are the module's bus state in the new form, by local name (novox/hq ADR 0595 §1, mesh-sdk
// membership.BusBinding): its object stores, streams, work queues and ledgers, its own alone (bindings.go).
Bindings []membership.BusBinding `json:"bindings,omitempty"`
} }
// Served is an address a tool is answered on; `{tool}` stands for the tool's name. // Served is an address a tool is answered on; `{tool}` stands for the tool's name.
@@ -285,9 +288,20 @@ func (c *Conn) refused(err error) {
}) })
} }
// Connect dials the bus as the credential's module. A module's subjects come from its credential, // Connect dials the bus as the credential's module and follows its membership. A module's subjects come from its
// never from its calls (ADR 0074). // credential, never from its calls (ADR 0074).
func Connect(cred Credential) (*Conn, error) { func Connect(cred Credential) (*Conn, error) {
c, err := Dial(cred)
if err != nil {
return nil, err
}
c.Follow(cred.Module)
return c, nil
}
// Dial dials the bus as the credential's module and follows nothing: a connection that only publishes for its module,
// as a module's ledger connection does (novox/hq ADR 0597 §9).
func Dial(cred Credential) (*Conn, error) {
if cred.Module == "" { if cred.Module == "" {
return nil, errors.New("a broker credential with no module: the runtime derives its subjects " + return nil, errors.New("a broker credential with no module: the runtime derives its subjects " +
"from the account the mesh issued, and cannot guess which module it is") "from the account the mesh issued, and cannot guess which module it is")
@@ -326,7 +340,6 @@ func Connect(cred Credential) (*Conn, error) {
} }
c = &Conn{nc: nc, js: js, self: cred.Module, node: cred.Node, cred: cred, c = &Conn{nc: nc, js: js, self: cred.Module, node: cred.Node, cred: cred,
issued: map[string]*Membership{}, Logf: log.Printf, answering: map[string]*answered{}} issued: map[string]*Membership{}, Logf: log.Printf, answering: map[string]*answered{}}
c.Follow(cred.Module)
return c, nil return c, nil
} }
+351
View File
@@ -0,0 +1,351 @@
package bus
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/membership"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"github.com/novox/mesh-tools/node-tools/internal/ledger"
)
// A ledger on the bus (novox/hq ADR 0597, design 25 § 12.4; issue 1966): its history a stream with a subject per
// instance under mesh-sdk's buscompose.LedgerHistorySubject, its state a key-value bucket, its snapshots a subject per
// instance under buscompose.LedgerSnapshotSubject, the latest only (the stream that holds them keeps one per subject, as the bus module
// creates it). **Only the module's ledger connection publishes on its history and its snapshots** (ADR 0597 §9): what
// is read, and the state, go on the runner's own.
// LedgerStores are the stores of a module's ledger binding: reads and the state on c, the history's appends and the
// snapshots' writes on writer, the module's ledger connection. The subjects are mesh-sdk's, the ones the grants part
// grants the module's ledger account byte for byte; a binding naming others is refused, naming both.
func (c *Conn) LedgerStores(module string, b membership.BusBinding, writer *Conn) (ledger.History, ledger.State, ledger.Snapshots, error) {
history, snapshot := buscompose.LedgerHistorySubject(module, b.Local), buscompose.LedgerSnapshotSubject(module, b.Local)
for _, named := range []struct{ what, bound, want string }{{"history", b.Subject, history}, {"snapshot", b.Snapshot, snapshot}} {
if named.bound != "" && prefixOf(named.bound) != named.want {
return nil, nil, nil, fmt.Errorf("%s's ledger %q is bound with the %s subject %s, and its ledger account is granted "+
"%s alone (mesh-sdk buscompose); the binding is refused", module, b.Local, named.what, named.bound, named.want)
}
}
return &busHistory{read: c, write: writer, stream: b.History, prefix: history},
&busState{c: c, bucket: b.State},
&busSnapshots{read: c, write: writer, prefix: snapshot}, nil
}
// prefixOf is a binding's subject as the prefix an instance's key is added to: written with or without its `.>`.
func prefixOf(subject string) string {
return strings.TrimSuffix(strings.TrimSuffix(subject, ">"), ".")
}
// publishExpecting publishes body on subject on the connection, written only when the subject's last message is still
// at expect (0: it has none); ledger.ErrRaced when it is not.
func publishExpecting(c *Conn, subject string, body []byte, expect uint64) (uint64, error) {
msg := nats.NewMsg(subject)
msg.Data = body
msg.Header.Set(jetstream.ExpectedLastSubjSeqHeader, strconv.FormatUint(expect, 10))
reply, err := c.nc.RequestMsg(msg, StateTimeout)
if errors.Is(err, nats.ErrNoResponders) {
return 0, fmt.Errorf("no stream on the bus keeps %s", subject)
}
if err != nil {
return 0, err
}
var ack struct {
Seq uint64 `json:"seq"`
Error *struct {
ErrCode uint16 `json:"err_code"`
Description string `json:"description"`
} `json:"error"`
}
if err := json.Unmarshal(reply.Data, &ack); err != nil {
return 0, fmt.Errorf("the bus answered the write to %s with what is not an acknowledgement: %q", subject, reply.Data)
}
if ack.Error != nil {
if code := jetstream.ErrorCode(ack.Error.ErrCode); code == jetstream.JSErrCodeStreamWrongLastSequence ||
code == jetstream.JSErrCodeStreamWrongLastSequenceConstant {
return 0, ledger.ErrRaced
}
return 0, fmt.Errorf("the bus refused the write to %s: %s (%d)", subject, ack.Error.Description, ack.Error.ErrCode)
}
return ack.Seq, nil
}
// lastOn is the last message on a subject of the stream that keeps it: its bytes and sequence, nil and 0 for none.
func lastOn(c *Conn, stream, subject string) ([]byte, uint64, error) {
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
defer cancel()
js, err := c.jetStream()
if err != nil {
return nil, 0, err
}
if stream == "" {
if stream, err = js.StreamNameBySubject(ctx, subject); err != nil {
return nil, 0, fmt.Errorf("no stream on the bus keeps %s: %w", subject, err)
}
}
s, err := js.Stream(ctx, stream)
if err != nil {
return nil, 0, fmt.Errorf("the stream %s: %w", stream, err)
}
m, err := s.GetLastMsgForSubject(ctx, subject)
if errors.Is(err, jetstream.ErrMsgNotFound) {
return nil, 0, nil
}
if err != nil {
return nil, 0, err
}
return m.Data, m.Sequence, nil
}
type busHistory struct {
read, write *Conn
stream, prefix string
}
func (h *busHistory) Last(key string) (uint64, error) {
_, seq, err := lastOn(h.read, h.stream, h.prefix+"."+key)
return seq, err
}
func (h *busHistory) Append(key string, op []byte, expect uint64) (uint64, error) {
if h.write == nil {
return 0, errors.New("the ledger's history is published only on its module's ledger connection, and none is held")
}
return publishExpecting(h.write, h.prefix+"."+key, op, expect)
}
func (h *busHistory) Read(keys string, from uint64, limit int) ([]ledger.Kept, error) {
var out []ledger.Kept
for {
want := StreamReadMost
if limit > 0 && limit-len(out) < want {
want = limit - len(out)
}
page, err := h.read.readStream(h.stream, h.prefix+"."+keys, from, want)
if err != nil {
return out, err
}
for _, m := range page.Messages {
out = append(out, ledger.Kept{Sequence: m.Sequence, Data: m.Body})
}
if page.Next == 0 || (limit > 0 && len(out) >= limit) {
return out, nil
}
from = page.Next
}
}
type busState struct {
c *Conn
bucket string
}
func (s *busState) kv(ctx context.Context) (jetstream.KeyValue, error) {
js, err := s.c.jetStream()
if err != nil {
return nil, err
}
kv, err := js.KeyValue(ctx, s.bucket)
if errors.Is(err, jetstream.ErrBucketNotFound) {
return nil, fmt.Errorf("the ledger's state %s is not on the bus", s.bucket)
}
return kv, err
}
func (s *busState) Get(key string) ([]byte, uint64, error) {
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
defer cancel()
kv, err := s.kv(ctx)
if err != nil {
return nil, 0, err
}
e, err := kv.Get(ctx, key)
if errors.Is(err, jetstream.ErrKeyNotFound) {
return nil, 0, nil
}
if err != nil {
return nil, 0, err
}
return e.Value(), e.Revision(), nil
}
// isFull is a write a bucket or stream refused because it holds as much as it may.
func isFull(err error) bool {
msg := err.Error()
return strings.Contains(msg, "maximum bytes exceeded") || strings.Contains(msg, "maximum messages exceeded") ||
strings.Contains(msg, "insufficient resources")
}
func (s *busState) Write(key string, value []byte, revision uint64) (uint64, error) {
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
defer cancel()
kv, err := s.kv(ctx)
if err != nil {
return 0, err
}
var rev uint64
if revision == 0 {
rev, err = kv.Create(ctx, key, value)
} else {
rev, err = kv.Update(ctx, key, value, revision)
}
switch {
case err == nil:
return rev, nil
case errors.Is(err, jetstream.ErrKeyExists) || errors.Is(err, jetstream.ErrKeyRevisionMismatch) || isWrongSequence(err):
return 0, ledger.ErrChanged
case isFull(err):
return 0, fmt.Errorf("%w: %v", ledger.ErrFull, err)
}
return 0, err
}
func (s *busState) Keys() ([]string, error) {
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
defer cancel()
kv, err := s.kv(ctx)
if err != nil {
return nil, err
}
keys, err := kv.Keys(ctx)
if errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, nil
}
return keys, err
}
type busSnapshots struct {
read, write *Conn
prefix string
}
func (s *busSnapshots) Get(key string) ([]byte, uint64, error) {
return lastOn(s.read, "", 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")
}
_, err := publishExpecting(s.write, s.prefix+"."+key, value, expect)
return err
}
// Watch hands deliver every instance's record under a key pattern as it is, then each change; a removed key is handed as
// nil.
func (s *busState) Watch(pattern string, deliver func(key string, value []byte, current bool) error) (func(), error) {
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
kv, err := s.kv(ctx)
cancel()
if err != nil {
return nil, err
}
w, err := kv.Watch(context.Background(), pattern)
if err != nil {
return nil, err
}
hand := func(e jetstream.KeyValueEntry, current bool) {
var value []byte
if e.Operation() == jetstream.KeyValuePut {
value = e.Value()
}
if err := deliver(e.Key(), value, current); err != nil {
s.c.Logf("[mesh-tools] a ledger watch of %s was not taken: %v", s.bucket, err)
}
}
timer := time.NewTimer(StateTimeout)
defer timer.Stop()
for current := true; current; {
select {
case e, ok := <-w.Updates():
if !ok {
return nil, errors.New("the ledger's state watch ended before its current states were handed over")
}
if e == nil {
current = false
continue
}
hand(e, true)
case <-timer.C:
_ = w.Stop()
return nil, fmt.Errorf("the ledger's state %s did not hand its current states in %s", s.bucket, StateTimeout)
}
}
go func() {
for e := range w.Updates() {
if e != nil {
hand(e, false)
}
}
}()
return func() { _ = w.Stop() }, nil
}
// LedgerIndexes are the buckets of a ledger binding's declared indexes, by index name, on the runner's own connection.
func (c *Conn) LedgerIndexes(b membership.BusBinding) map[string]ledger.Index {
out := map[string]ledger.Index{}
for name, bucket := range b.Indexes {
out[name] = &busState{c: c, bucket: bucket}
}
return out
}
func (s *busState) Entries(pattern string, limit int) ([]ledger.Indexed, error) {
if pattern == "" {
pattern = ">"
}
keys, err := s.Keys()
if err != nil {
return nil, err
}
sort.Strings(keys)
var out []ledger.Indexed
for _, k := range keys {
if limit > 0 && len(out) >= limit {
break
}
if !SubjectMatches(pattern, k) {
continue
}
v, _, err := s.Get(k)
if err != nil {
return out, err
}
if v != nil {
out = append(out, ledger.Indexed{Key: k, Value: v})
}
}
return out, nil
}
func (s *busState) Put(key string, value []byte) error {
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
defer cancel()
kv, err := s.kv(ctx)
if err != nil {
return err
}
_, err = kv.Put(ctx, key, value)
return err
}
func (s *busState) Delete(key string) error {
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
defer cancel()
kv, err := s.kv(ctx)
if err != nil {
return err
}
if err := kv.Delete(ctx, key); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
return err
}
return nil
}
@@ -48,6 +48,11 @@ func (b *subscribing) Provision(string, json.RawMessage) (json.RawMessage, error
func (b *subscribing) ProvisionWatch(json.RawMessage, func(json.RawMessage) error) (func(), error) { func (b *subscribing) ProvisionWatch(json.RawMessage, func(json.RawMessage) error) (func(), error) {
return func() {}, nil return func() {}, nil
} }
func (b *subscribing) BusState(string, json.RawMessage) (json.RawMessage, error) { return nil, nil }
func (b *subscribing) BusStateWatch(string, json.RawMessage, func(json.RawMessage) error) (func(), error) {
return func() {}, nil
}
func (b *subscribing) Begin(string) (string, func()) { return "", func() {} }
func (b *subscribing) Subscribe(d func(json.RawMessage) error) error { func (b *subscribing) Subscribe(d func(json.RawMessage) error) error {
b.mu.Lock() b.mu.Lock()
defer b.mu.Unlock() defer b.mu.Unlock()
+67 -5
View File
@@ -19,6 +19,7 @@ import (
"syscall" "syscall"
"time" "time"
"git.novox.be/novox/mesh-sdk/go/buscalls"
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
"github.com/novox/mesh-tools/node-tools/internal/wire" "github.com/novox/mesh-tools/node-tools/internal/wire"
@@ -38,6 +39,9 @@ type Tool struct {
Name string Name string
Description string Description string
Input json.RawMessage Input json.RawMessage
// Effect is what the tool does, reads, acts or dangerous (novox/hq ADR 0387 §1), where the runtime knows it: its own
// tools say it; a bundle's tool is marked by its manifest.
Effect string
// Run calls the tool for caller: the bus user the call's subject named, or "" when it named none. A // Run calls the tool for caller: the bus user the call's subject named, or "" when it named none. A
// caller is handed to the bundle in the call's `_meta` (MetaCaller), never among its arguments. // caller is handed to the bundle in the call's `_meta` (MetaCaller), never among its arguments.
Run func(caller string, args json.RawMessage) (json.RawMessage, error) Run func(caller string, args json.RawMessage) (json.RawMessage, error)
@@ -48,12 +52,21 @@ type Tool struct {
// stdio.CallerOf, and says Unproven when it is absent. // stdio.CallerOf, and says Unproven when it is absent.
const MetaCaller = "mesh/caller" const MetaCaller = "mesh/caller"
// callParams is a `tools/call`'s params: the tool, its arguments as the caller sent them, and — only when the // callParams is a `tools/call`'s params: the tool, its arguments as the caller sent them, and in `_meta`, which the
// subject named one — the caller, in `_meta`, which the runtime writes and no caller can. // runtime writes and no caller can, the caller — only when the subject named one — and the id of the call in flight
func callParams(name, caller string, args json.RawMessage) map[string]any { // (mesh-sdk buscalls.MetaCall), which a ledger's apply names so the runtime stamps the caller it proved (novox/hq ADR
// 0597 §7).
func callParams(name, caller, call string, args json.RawMessage) map[string]any {
params := map[string]any{"name": name, "arguments": args} params := map[string]any{"name": name, "arguments": args}
meta := map[string]any{}
if caller != "" { if caller != "" {
params["_meta"] = map[string]any{MetaCaller: caller} meta[MetaCaller] = caller
}
if call != "" {
meta[buscalls.MetaCall] = call
}
if len(meta) > 0 {
params["_meta"] = meta
} }
return params return params
} }
@@ -91,8 +104,29 @@ type Bus interface {
// (novox/hq ADR 0349 §14, ADR 0370 §4): its config, and its bindings' values that are not secret. // (novox/hq ADR 0349 §14, ADR 0370 §4): its config, and its bindings' values that are not secret.
Provision(verb string, params json.RawMessage) (json.RawMessage, error) Provision(verb string, params json.RawMessage) (json.RawMessage, error)
ProvisionWatch(params json.RawMessage, deliver func(change json.RawMessage) error) (stop func(), err error) ProvisionWatch(params json.RawMessage, deliver func(change json.RawMessage) error) (stop func(), err error)
// BusState answers a bundle's calls on its module's bus state in the new form (mesh-sdk buscalls; novox/hq ADR 0595
// §9, design 25 § 12.5): its objects, a stream's read-back and fill, a work queue's size and dead letters, and its
// ledgers, by the method the bundle asked, from its own module's bindings alone.
BusState(method string, params json.RawMessage) (json.RawMessage, error)
// BusStateWatch hands deliver each change of an objects or a ledger watch, the current ones first.
BusStateWatch(method string, params json.RawMessage, deliver func(change json.RawMessage) error) (stop func(), err error)
// Begin says a tool call of the module is in flight for the caller the runtime proved, and answers its id, which the
// bundle is handed in the call's `_meta`, and the end to call once the call is answered (novox/hq ADR 0597 §7).
Begin(caller string) (id string, end func())
} }
// busStateCalls are the methods of mesh-sdk buscalls a bundle asks and BusState answers.
var busStateCalls = map[string]bool{
buscalls.ObjectsPut: true, buscalls.ObjectsGet: true, buscalls.ObjectsList: true, buscalls.ObjectsDelete: true,
buscalls.ObjectsInfo: true, buscalls.StreamRead: true, buscalls.StreamFill: true, buscalls.WorkQueueSize: true,
buscalls.WorkQueueDead: true, buscalls.LedgerApply: true, buscalls.LedgerGet: true, buscalls.LedgerHistory: true,
buscalls.LedgerQuery: true, buscalls.LedgerRebuild: true, buscalls.LedgerReindex: true, buscalls.LedgerSnapshot: true,
buscalls.LedgerLag: true,
}
// busStateWatches are the watches of mesh-sdk buscalls, each with the method its changes are handed to the bundle by.
var busStateWatches = map[string]string{buscalls.ObjectsWatch: buscalls.ObjectsWatched, buscalls.LedgerWatch: buscalls.LedgerWatched}
// EventTimeout bounds how long a bundle has to handle one event before it is offered again. // EventTimeout bounds how long a bundle has to handle one event before it is offered again.
var EventTimeout = 2 * time.Minute var EventTimeout = 2 * time.Minute
@@ -399,7 +433,33 @@ func Start(module, entry string, env []string, mesh Bus, logf func(string, ...an
c.mu.Unlock() c.mu.Unlock()
} }
} }
case buscalls.ObjectsWatch, buscalls.LedgerWatch:
// As a state's watch: each change asked of this child in order, by the method of its
// family; the change names the watch by the id the bundle gave in its request.
var stop func()
told := busStateWatches[m.Method]
stop, err = mesh.BusStateWatch(m.Method, m.Params, func(change json.RawMessage) error {
_, err := c.ask(module, told, change, 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()
}
}
default: default:
if busStateCalls[m.Method] {
var answered json.RawMessage
if answered, err = mesh.BusState(m.Method, m.Params); err == nil {
result = answered
}
break
}
if len(m.ID) > 0 { if len(m.ID) > 0 {
_ = c.write(map[string]any{"jsonrpc": "2.0", "id": m.ID, _ = c.write(map[string]any{"jsonrpc": "2.0", "id": m.ID,
"error": map[string]any{"code": -32601, "message": "the runtime answers no " + m.Method + " from a bundle"}}) "error": map[string]any{"code": -32601, "message": "the runtime answers no " + m.Method + " from a bundle"}})
@@ -611,7 +671,9 @@ func Start(module, entry string, env []string, mesh Bus, logf func(string, ...an
if len(args) == 0 || string(args) == "null" { if len(args) == 0 || string(args) == "null" {
args = json.RawMessage("{}") args = json.RawMessage("{}")
} }
res, err := asking("tools/call", callParams(full, caller, args), CallTimeout) call, end := mesh.Begin(caller)
defer end()
res, err := asking("tools/call", callParams(full, caller, call, args), CallTimeout)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+497
View File
@@ -0,0 +1,497 @@
// Package ledger is the tool runner's side of a ledger (novox/hq ADR 0597, design 25 § 12.4; issue 1966): a storage type
// whose state is derived from its history. `apply` checks an operation against its type, appends it to the instance's
// history with the last sequence expected, derives the state by the operation's effect and writes it as an update of the
// revision read, and writes the instance's snapshot. What the history, the state and the snapshots are kept in is given
// (History, State, Snapshots): the bus in the runtime, fakes in a test.
package ledger
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/buscalls"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// The refusals the stores answer, told by errors.Is.
var (
// ErrRaced is an append or a snapshot written with an expected sequence that is no longer the last: another writer
// wrote first.
ErrRaced = errors.New("another writer wrote the instance's history first")
// ErrChanged is a state written at a revision that is no longer the key's.
ErrChanged = errors.New("the state was written by another writer first")
// ErrFull is a state bucket that refuses a write because it is full.
ErrFull = errors.New("the state bucket is full")
)
// CodeChanged is the error code a bundle is answered a lost race with, as a lost state update (mesh-sdk stdio.CodeChanged).
const CodeChanged = -32010
// raced is an apply refused because another writer appended to the instance first (ADR 0597 §5).
type raced struct{ instance string }
func (e raced) Error() string {
return "the operation on " + e.instance + " is not taken: another writer appended to its history first; read it and apply again (novox/hq ADR 0597 §5)"
}
func (e raced) Is(target error) bool { return target == ErrRaced }
func (e raced) ErrorCode() int { return CodeChanged }
// Kept is one message a history keeps: its sequence and its bytes.
type Kept struct {
Sequence uint64
Data []byte
}
// History is a ledger's history: a stream with a subject per instance, each instance named by its key.
type History interface {
// Last is the sequence of the instance's last operation, 0 when it has none.
Last(key string) (uint64, error)
// Append writes one operation, expecting the instance's last sequence to be expect (0: it has none); ErrRaced when
// it is not.
Append(key string, op []byte, expect uint64) (uint64, error)
// 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)
}
// State is a ledger's state bucket: each instance's record under its key.
type State interface {
// Get is the key's value and revision; nil and 0 when it has none.
Get(key string) ([]byte, uint64, error)
// Write writes the key's value at the revision read (0: only when it has none); ErrChanged when the key is no longer
// at it, ErrFull when the bucket is full.
Write(key string, value []byte, revision uint64) (uint64, error)
// Keys is every key the bucket holds.
Keys() ([]string, error)
}
// Snapshots are a ledger's snapshots: the latest record of each instance, on a subject of its own, never expiring.
type Snapshots interface {
// Get is the key's latest snapshot and the sequence it was written at; nil and 0 when it has none.
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
}
// Ledger is one ledger of one module: its types and where it is kept.
type Ledger struct {
Module string
Name string
// Account is the owning module's authenticated account, which every operation it applies is stamped with.
Account string
Types []catalogue.LedgerType
History History
State State
Snapshots Snapshots
// Indexes are its declared indexes' buckets, by index name.
Indexes map[string]Index
// Now is the clock an operation is stamped by; time.Now when nil.
Now func() time.Time
// Logf says what an apply could not do after its operation was taken; nothing is said when nil.
Logf func(format string, args ...any)
}
func (l *Ledger) say(format string, args ...any) {
if l.Logf != nil {
l.Logf(format, args...)
}
}
// Record is what the state and a snapshot keep of an instance: its state and the sequence of the last operation in it.
type Record struct {
Sequence uint64 `json:"sequence"`
State json.RawMessage `json:"state"`
}
// segment is one part of an instance's path.
var segment = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
// KeyOf is the key an instance is kept under, in the state and as its history's and snapshot's subject token: its path's
// parts joined by dots (`decision/0290` is `decision.0290`).
func KeyOf(instance string) (string, error) {
parts := strings.Split(instance, "/")
if len(parts) < 2 {
return "", fmt.Errorf("%q is not an instance: an instance is named by a path whose first part is its type's name, "+
"such as decision/0290 (novox/hq ADR 0597 §1)", instance)
}
for _, p := range parts {
if !segment.MatchString(p) {
return "", fmt.Errorf("%q is not an instance: each part of its path holds letters, digits, - and _ alone", instance)
}
}
return strings.Join(parts, "."), nil
}
// InstanceOf is the instance a key names.
func InstanceOf(key string) string { return strings.ReplaceAll(key, ".", "/") }
func (l *Ledger) now() time.Time {
if l.Now != nil {
return l.Now().UTC()
}
return time.Now().UTC()
}
// typeOf is the instance's type, by its path's first part.
func (l *Ledger) typeOf(instance string) (catalogue.LedgerType, error) {
name, _, _ := strings.Cut(instance, "/")
for _, t := range l.Types {
if t.Name == name {
return t, nil
}
}
return catalogue.LedgerType{}, fmt.Errorf("%s's ledger %s has no type %q, which the instance %s names", l.Module, l.Name, name, instance)
}
// operationOf is the operation a type allows by its name.
func operationOf(t catalogue.LedgerType, name string) (catalogue.LedgerOperation, error) {
for _, op := range t.Operations {
if op.Name == name {
return op, nil
}
}
return catalogue.LedgerOperation{}, fmt.Errorf("the type %s allows no operation %q", t.Name, name)
}
// Digest is the digest a type pairs a text field with (ADR 0597 §2): sha256 of the text, as the knowledge base writes it.
func Digest(text string) string {
sum := sha256.Sum256([]byte(text))
return "sha256:" + hex.EncodeToString(sum[:])
}
// kinds is the JSON kind each schema type name is held to; a name not here is the judge's to know.
var kinds = map[string]func(any) bool{
"string": func(v any) bool { _, ok := v.(string); return ok },
"number": func(v any) bool { _, ok := v.(float64); return ok },
"integer": func(v any) bool { f, ok := v.(float64); return ok && f == float64(int64(f)) },
"boolean": func(v any) bool { _, ok := v.(bool); return ok },
"object": func(v any) bool { _, ok := v.(map[string]any); return ok },
"array": func(v any) bool { _, ok := v.([]any); return ok },
}
// checked is an operation as apply checked it: its instance's key, its operation and its body.
type checked struct {
key string
op catalogue.LedgerOperation
body map[string]any
}
// check holds an operation to its type (ADR 0597 §2): a type the instance names, an operation it allows, a body that is an
// object whose fields fit the schema, the key its effect names, and every digest matching its text.
func (l *Ledger) check(r buscalls.ApplyRequest) (checked, error) {
key, err := KeyOf(r.Instance)
if err != nil {
return checked{}, err
}
t, err := l.typeOf(r.Instance)
if err != nil {
return checked{}, err
}
op, err := operationOf(t, r.Operation)
if err != nil {
return checked{}, err
}
body := map[string]any{}
if len(r.Body) > 0 && string(r.Body) != "null" {
if err := json.Unmarshal(r.Body, &body); err != nil {
return checked{}, fmt.Errorf("the operation %s's body is a JSON object: %w", r.Operation, err)
}
}
for name, field := range t.Schema {
v, has := body[name]
if !has {
continue
}
if fits, known := kinds[field.Type]; known && !fits(v) {
return checked{}, fmt.Errorf("the operation %s's field %s is a %s in the type %s", r.Operation, name, field.Type, t.Name)
}
}
for name, field := range t.Schema {
if field.DigestOf == "" {
continue
}
digest, hasDigest := body[name]
text, hasText := body[field.DigestOf]
if !hasDigest && !hasText {
continue
}
s, isText := text.(string)
if !hasDigest || !hasText || !isText || digest != Digest(s) {
return checked{}, fmt.Errorf("the operation %s's %s is not the digest of its %s, so it is refused (novox/hq ADR 0597 §2)",
r.Operation, name, field.DigestOf)
}
}
switch op.Effect {
case catalogue.EffectSetFields, catalogue.EffectAppend:
case catalogue.EffectSetEntry, catalogue.EffectRemove:
if op.Effect == catalogue.EffectRemove && op.Into == "" {
break
}
if _, ok := body[op.Key].(string); !ok {
return checked{}, fmt.Errorf("the operation %s names its entry by its field %s, a text, which its body lacks", r.Operation, op.Key)
}
default:
return checked{}, fmt.Errorf("the operation %s's effect %q is not one the mesh offers (novox/hq ADR 0597 §2)", r.Operation, op.Effect)
}
return checked{key: key, op: op, body: body}, nil
}
// effect is an operation's effect on a state (ADR 0597 §2): it copies only the fields the operation names, and the
// operation's sequence and proven author where it says so; null is a removed instance.
func effect(state json.RawMessage, op catalogue.LedgerOperation, body map[string]any, seq uint64, author buscalls.Author) (json.RawMessage, error) {
s := map[string]any{}
if len(state) > 0 && string(state) != "null" {
if err := json.Unmarshal(state, &s); err != nil {
return nil, fmt.Errorf("the state held is not an object: %w", err)
}
}
copied := map[string]any{}
for _, f := range op.Fields {
if v, ok := body[f]; ok {
copied[f] = v
}
}
if op.Sequence {
copied["sequence"] = seq
}
// Written last, so a body's own `author` never stands for the proven one (ADR 0597 §7).
if op.Author {
copied["author"] = author
}
switch op.Effect {
case catalogue.EffectSetFields:
for k, v := range copied {
s[k] = v
}
case catalogue.EffectSetEntry:
m, _ := s[op.Into].(map[string]any)
if m == nil {
m = map[string]any{}
}
m[body[op.Key].(string)] = copied
s[op.Into] = m
case catalogue.EffectAppend:
list, _ := s[op.Into].([]any)
s[op.Into] = append(list, copied)
case catalogue.EffectRemove:
if op.Into == "" {
return json.RawMessage("null"), nil
}
if m, ok := s[op.Into].(map[string]any); ok {
delete(m, body[op.Key].(string))
}
}
return json.Marshal(s)
}
// fold applies one kept operation to a record, as apply derived it.
func (l *Ledger) fold(rec Record, k Kept) (Record, error) {
var o buscalls.Operation
if err := json.Unmarshal(k.Data, &o); err != nil {
return rec, fmt.Errorf("the history's operation %d is not one: %w", k.Sequence, err)
}
t, err := l.typeOf(o.Instance)
if err != nil {
return rec, err
}
op, err := operationOf(t, o.Operation)
if err != nil {
return rec, err
}
body := map[string]any{}
if len(o.Body) > 0 && string(o.Body) != "null" {
if err := json.Unmarshal(o.Body, &body); err != nil {
return rec, err
}
}
if (op.Effect == catalogue.EffectSetEntry || (op.Effect == catalogue.EffectRemove && op.Into != "")) && !isText(body[op.Key]) {
return rec, fmt.Errorf("the history's operation %d names no entry", k.Sequence)
}
state, err := effect(rec.State, op, body, k.Sequence, o.Author)
if err != nil {
return rec, err
}
return Record{Sequence: k.Sequence, State: state}, nil
}
func isText(v any) bool { _, ok := v.(string); return ok }
// replay folds the instance's operations after rec's sequence, up to upto (every one when 0), onto rec.
func (l *Ledger) replay(key string, rec Record, upto uint64) (Record, error) {
kept, err := l.History.Read(key, rec.Sequence+1, 0)
if err != nil {
return rec, err
}
for _, k := range kept {
if upto != 0 && k.Sequence > upto {
break
}
if k.Sequence <= rec.Sequence {
continue
}
if rec, err = l.fold(rec, k); err != nil {
return rec, err
}
}
return rec, nil
}
// readState is the instance's record in the state and its revision; an empty record when it has none.
func (l *Ledger) readState(key string) (Record, uint64, error) {
raw, rev, err := l.State.Get(key)
if err != nil || raw == nil {
return Record{}, rev, err
}
var rec Record
if err := json.Unmarshal(raw, &rec); err != nil {
return Record{}, rev, fmt.Errorf("the state of %s is not a record: %w", key, err)
}
return rec, rev, nil
}
// readSnapshot is the instance's snapshot and the sequence it was written at; an empty record when it has none.
func (l *Ledger) readSnapshot(key string) (Record, uint64, error) {
raw, at, err := l.Snapshots.Get(key)
if err != nil || raw == nil {
return Record{}, at, err
}
var rec Record
if err := json.Unmarshal(raw, &rec); err != nil {
return Record{}, at, fmt.Errorf("the snapshot of %s is not a record: %w", key, err)
}
return rec, at, nil
}
// derive is the instance's record caught up with its history up to last: from the state, or from the snapshot when it
// is further on (an instance whose state was lost, or whose operations expired, ADR 0597 §8).
func (l *Ledger) derive(key string, base Record, last uint64) (Record, error) {
if base.Sequence >= last {
return base, nil
}
snap, _, err := l.readSnapshot(key)
if err != nil {
return base, err
}
if snap.Sequence > base.Sequence {
base = snap
}
return l.replay(key, base, last)
}
// settle writes want into the state at the revision read (ADR 0597 §5): a lost update re-reads, and catches the state
// read up to want's sequence from the history, never appending again; a state already further on is left as it is. It
// answers what the state holds after it, and whether it is behind because the bucket is full (ADR 0597 §6).
func (l *Ledger) settle(key string, want Record, rev uint64) (Record, bool, error) {
for tries := 0; tries < 16; tries++ {
raw, err := json.Marshal(want)
if err != nil {
return want, false, err
}
_, err = l.State.Write(key, raw, rev)
switch {
case err == nil:
return want, false, nil
case errors.Is(err, ErrFull):
return want, true, nil
case !errors.Is(err, ErrChanged):
return want, false, err
}
cur, curRev, err := l.readState(key)
if err != nil {
return want, false, err
}
if cur.Sequence >= want.Sequence {
return cur, false, nil
}
if want, err = l.replay(key, cur, want.Sequence); err != nil {
return want, false, err
}
rev = curRev
}
return want, false, fmt.Errorf("the state of %s kept being written by other writers; the operation is in its history and the next apply catches it up", key)
}
// writeSnapshot writes rec as the instance's snapshot unless the snapshot is already as far on (ADR 0597 §8).
func (l *Ledger) writeSnapshot(key string, rec Record) error {
for tries := 0; tries < 16; tries++ {
snap, at, err := l.readSnapshot(key)
if err != nil {
return err
}
if at != 0 && snap.Sequence >= rec.Sequence {
return nil
}
raw, err := json.Marshal(rec)
if err != nil {
return err
}
if err = l.Snapshots.Put(key, raw, at); err == nil || !errors.Is(err, ErrRaced) {
return err
}
}
return fmt.Errorf("the snapshot of %s kept being written by other writers", key)
}
// Apply applies one operation to one instance, stamped with author (ADR 0597 §3, §5, §6, §8; design 25 § 12.4 steps 1-5):
// checked against its type, appended with the instance's last sequence expected, the state derived by its effect and
// written at the revision read, and the snapshot written. A racing writer's append is refused; a full state bucket
// answers taken with the state behind.
func (l *Ledger) Apply(r buscalls.ApplyRequest, author buscalls.Author) (buscalls.Applied, error) {
c, err := l.check(r)
if err != nil {
return buscalls.Applied{}, err
}
last, err := l.History.Last(c.key)
if err != nil {
return buscalls.Applied{}, err
}
held, rev, err := l.readState(c.key)
if err != nil {
return buscalls.Applied{}, err
}
base, err := l.derive(c.key, held, last)
if err != nil {
return buscalls.Applied{}, err
}
body := r.Body
if len(body) == 0 {
body = json.RawMessage("{}")
}
op, err := json.Marshal(buscalls.Operation{Instance: r.Instance, Operation: r.Operation, Body: body, Author: author, At: l.now()})
if err != nil {
return buscalls.Applied{}, err
}
seq, err := l.History.Append(c.key, op, last)
if errors.Is(err, ErrRaced) {
return buscalls.Applied{}, raced{r.Instance}
}
if err != nil {
return buscalls.Applied{}, err
}
state, err := effect(base.State, c.op, c.body, seq, author)
if err != nil {
return buscalls.Applied{}, err
}
// From here the operation is taken: it is in the history, and a fault below is the state's to catch up, said here
// and answered as the state behind, never as a refusal of what later takes effect (ADR 0597 §6).
now, behind, err := l.settle(c.key, Record{Sequence: seq, State: state}, rev)
if err != nil {
l.say("[mesh-tools] %s's ledger %s took operation %d on %s and its state could not follow: %v; the next apply "+
"or a rebuild catches it up", l.Module, l.Name, seq, r.Instance, err)
behind, now = true, Record{Sequence: seq, State: state}
}
if behind {
now = Record{Sequence: seq, State: state}
}
if err := l.writeSnapshot(c.key, now); err != nil {
l.say("[mesh-tools] %s's ledger %s took operation %d on %s and its snapshot could not be written: %v; the next "+
"apply or a snapshot repair writes it", l.Module, l.Name, seq, r.Instance, err)
}
return buscalls.Applied{Taken: true, Sequence: seq, StateBehind: behind}, nil
}
+102
View File
@@ -0,0 +1,102 @@
package ledger
import (
"encoding/json"
"fmt"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/buscalls"
)
var owner = buscalls.Author{Account: "ledger.anchor.m"}
// 1966/AC13, 1966/AC14 and the checks of an operation against its type (ADR 0597 §2, §5, §6, §8; design 25 § 12.4).
func TestApplyAppendsDerivesAndSnapshots(t *testing.T) {
cases := []struct {
name string
run func(t *testing.T, l *Ledger, f fakes)
}{
{"1966/AC13 an instance at sequence 4 is taken with 5, its state and snapshot the effect's result", func(t *testing.T, l *Ledger, f fakes) {
for i := 1; i <= 4; i++ {
applied(t, l, "decision/0290", "note", fmt.Sprintf(`{"says":"n%d"}`, i), owner)
}
if last, _ := f.history.Last("decision.0290"); last != 4 {
t.Fatalf("the instance is at %d, want 4", last)
}
got := applied(t, l, "decision/0290", "title", `{"title":"A ledger","ignored":"x"}`, owner)
if !got.Taken || got.Sequence != 5 || got.StateBehind {
t.Fatalf("answered %+v, want taken with 5", got)
}
raw, _, _ := f.state.Get("decision.0290")
state := record(t, raw)
want := `{"author":{"account":"ledger.anchor.m"},"notes":[{"says":"n1"},{"says":"n2"},{"says":"n3"},{"says":"n4"}],"sequence":5,"title":"A ledger"}`
if state.Sequence != 5 || string(state.State) != want {
t.Fatalf("the state is %d %s, want 5 %s", state.Sequence, state.State, want)
}
snap, _, _ := f.snapshots.Get("decision.0290")
if string(snap) != string(raw) {
t.Fatalf("the snapshot is %s, the state %s", snap, raw)
}
}},
{"1966/AC14 a full state bucket answers taken and state behind, and the next apply after room catches up", func(t *testing.T, l *Ledger, f fakes) {
applied(t, l, "decision/0290", "note", `{"says":"first"}`, owner)
f.state.Full = true
got := applied(t, l, "decision/0290", "note", `{"says":"while full"}`, owner)
if !got.Taken || got.Sequence != 2 || !got.StateBehind {
t.Fatalf("answered %+v, want taken with 2 and state behind", got)
}
if raw, _, _ := f.state.Get("decision.0290"); record(t, raw).Sequence != 1 {
t.Fatalf("the full state moved: %s", raw)
}
f.state.Full = false
got = applied(t, l, "decision/0290", "note", `{"says":"after room"}`, owner)
raw, _, _ := f.state.Get("decision.0290")
state := record(t, raw)
if got.StateBehind || state.Sequence != 3 || string(state.State) != `{"notes":[{"says":"first"},{"says":"while full"},{"says":"after room"}]}` {
t.Fatalf("after room: %+v, the state %d %s", got, state.Sequence, state.State)
}
}},
{"each effect: set an entry, remove it, remove the instance", func(t *testing.T, l *Ledger, f fakes) {
text := "the section's text"
applied(t, l, "decision/1", "section", fmt.Sprintf(`{"id":"a","text":%q,"digest":%q}`, text, Digest(text)), owner)
applied(t, l, "decision/1", "section", fmt.Sprintf(`{"id":"b","text":"b","digest":%q}`, Digest("b")), owner)
applied(t, l, "decision/1", "drop-section", `{"id":"a"}`, owner)
raw, _, _ := f.state.Get("decision.1")
if got := string(record(t, raw).State); got != fmt.Sprintf(`{"sections":{"b":{"digest":%q}}}`, Digest("b")) {
t.Fatalf("the state is %s", got)
}
applied(t, l, "decision/1", "withdraw", `{}`, owner)
raw, _, _ = f.state.Get("decision.1")
if got := record(t, raw); got.Sequence != 4 || string(got.State) != "null" {
t.Fatalf("the removed instance is %d %s", got.Sequence, got.State)
}
}},
{"an operation outside its type, a body off its schema and a digest off its text are refused", func(t *testing.T, l *Ledger, f fakes) {
for _, c := range []struct{ instance, operation, body, refusal string }{
{"listing/a17", "title", `{}`, `no type "listing"`},
{"decision/0290", "accept", `{}`, `no operation "accept"`},
{"decision/0290", "title", `{"title":7}`, "field title is a string"},
{"decision/0290", "section", `{"id":"a","text":"x","digest":"sha256:00"}`, "is not the digest of its text"},
{"decision/0290", "section", `{"text":"x"}`, "is not the digest"},
{"decision/0290", "drop-section", `{}`, "names its entry by its field id"},
{"decision", "title", `{}`, "is not an instance"},
{"decision/a.b", "title", `{}`, "is not an instance"},
} {
_, err := l.Apply(buscalls.ApplyRequest{Instance: c.instance, Operation: c.operation, Body: json.RawMessage(c.body)}, owner)
if err == nil || !strings.Contains(err.Error(), c.refusal) {
t.Errorf("%s %s %s: %v, want %q", c.instance, c.operation, c.body, err, c.refusal)
}
}
if last, _ := f.history.Last("decision.0290"); last != 0 {
t.Fatalf("a refused operation was appended: %d", last)
}
}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
l, f := newLedger()
c.run(t, l, f)
})
}
}
+204
View File
@@ -0,0 +1,204 @@
package ledger
import (
"sort"
"strings"
"sync"
)
// 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
// sequence counts across every instance, a bucket with revisions that may be full, and snapshots written with an expected
// sequence.
// MemoryHistory is a history in memory.
type MemoryHistory struct {
mu sync.Mutex
seq uint64
kept []struct {
key string
Kept
}
}
// Matches is whether a key pattern (`*` one name, `>` the rest) names a key.
func Matches(pattern, key string) bool {
p, k := strings.Split(pattern, "."), strings.Split(key, ".")
for i, part := range p {
if part == ">" {
return len(k) > i
}
if i >= len(k) || (part != "*" && part != k[i]) {
return false
}
}
return len(p) == len(k)
}
func (h *MemoryHistory) Last(key string) (uint64, error) {
h.mu.Lock()
defer h.mu.Unlock()
last := uint64(0)
for _, k := range h.kept {
if k.key == key {
last = k.Sequence
}
}
return last, nil
}
func (h *MemoryHistory) Append(key string, op []byte, expect uint64) (uint64, error) {
last, _ := h.Last(key)
h.mu.Lock()
defer h.mu.Unlock()
if last != expect {
return 0, ErrRaced
}
h.seq++
h.kept = append(h.kept, struct {
key string
Kept
}{key, Kept{Sequence: h.seq, Data: op}})
return h.seq, nil
}
func (h *MemoryHistory) Read(keys string, from uint64, limit int) ([]Kept, error) {
h.mu.Lock()
defer h.mu.Unlock()
var out []Kept
for _, k := range h.kept {
if k.Sequence >= from && Matches(keys, k.key) && (limit == 0 || len(out) < limit) {
out = append(out, k.Kept)
}
}
return out, nil
}
// Expire drops every operation of the history, as a ledger's keeping days end them.
func (h *MemoryHistory) Expire() {
h.mu.Lock()
defer h.mu.Unlock()
h.kept = nil
}
type memoryValue struct {
value []byte
rev uint64
}
// MemoryState is a state bucket in memory.
type MemoryState struct {
mu sync.Mutex
rev uint64
// Full makes every write refused as a full bucket's.
Full bool
kv map[string]memoryValue
}
func (s *MemoryState) Get(key string) ([]byte, uint64, error) {
s.mu.Lock()
defer s.mu.Unlock()
v := s.kv[key]
return v.value, v.rev, nil
}
func (s *MemoryState) Write(key string, value []byte, revision uint64) (uint64, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.Full {
return 0, ErrFull
}
if s.kv[key].rev != revision {
return 0, ErrChanged
}
if s.kv == nil {
s.kv = map[string]memoryValue{}
}
s.rev++
s.kv[key] = memoryValue{value, s.rev}
return s.rev, nil
}
func (s *MemoryState) Keys() ([]string, error) {
s.mu.Lock()
defer s.mu.Unlock()
var out []string
for k := range s.kv {
out = append(out, k)
}
sort.Strings(out)
return out, nil
}
// MemorySnapshots are snapshots in memory.
type MemorySnapshots struct {
mu sync.Mutex
at uint64
kv map[string]memoryValue
}
func (s *MemorySnapshots) Get(key string) ([]byte, uint64, error) {
s.mu.Lock()
defer s.mu.Unlock()
v := s.kv[key]
return v.value, v.rev, nil
}
func (s *MemorySnapshots) Put(key string, value []byte, expect uint64) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.kv[key].rev != expect {
return ErrRaced
}
if s.kv == nil {
s.kv = map[string]memoryValue{}
}
s.at++
s.kv[key] = memoryValue{value, s.at}
return nil
}
// MemoryIndex is an index's bucket in memory.
type MemoryIndex struct {
mu sync.Mutex
kv map[string][]byte
}
func (x *MemoryIndex) Entries(pattern string, limit int) ([]Indexed, error) {
x.mu.Lock()
defer x.mu.Unlock()
if pattern == "" {
pattern = ">"
}
var keys []string
for k := range x.kv {
if Matches(pattern, k) {
keys = append(keys, k)
}
}
sort.Strings(keys)
var out []Indexed
for _, k := range keys {
if limit > 0 && len(out) >= limit {
break
}
out = append(out, Indexed{Key: k, Value: x.kv[k]})
}
return out, nil
}
func (x *MemoryIndex) Put(key string, value []byte) error {
x.mu.Lock()
defer x.mu.Unlock()
if x.kv == nil {
x.kv = map[string][]byte{}
}
x.kv[key] = value
return nil
}
func (x *MemoryIndex) Delete(key string) error {
x.mu.Lock()
defer x.mu.Unlock()
delete(x.kv, key)
return nil
}
+166
View File
@@ -0,0 +1,166 @@
package ledger
import (
"encoding/json"
"errors"
"fmt"
"git.novox.be/novox/mesh-sdk/go/buscalls"
)
// Reading a ledger (ADR 0597 §10, design 25 § 12.5; issue 1966 T3): an instance's state, its history, a watch of its
// states, and a declared index. Only the owning module reads its ledger: the runtime opens a ledger for the module whose
// bundle asks, never one the request names.
// Index is one declared index's bucket, kept by a watcher in the owning module's process and rebuilt by reindex (ADR
// 0597 §4).
type Index interface {
// Entries is the entries whose key a pattern names (`*` one name, `>` the rest; empty is every key), at most limit
// (every one when 0), each as the bucket holds it.
Entries(pattern string, limit int) ([]Indexed, error)
// Put writes one entry; Delete removes one.
Put(key string, value []byte) error
Delete(key string) error
}
// Indexed is one key of an index's bucket and its value, a buscalls.IndexEntry as JSON.
type Indexed struct {
Key string
Value []byte
}
// Watcher is a state bucket that hands each instance's record as it is and as it changes, under a key pattern.
type Watcher interface {
Watch(pattern string, deliver func(key string, value []byte, current bool) error) (func(), error)
}
// HistoryMost is the most operations one history read answers.
const HistoryMost = 1000
// Get is an instance's state as the state holds it, and the sequence of its last operation in it; null and 0 for an
// instance with none.
func (l *Ledger) Get(r buscalls.InstanceRequest) (buscalls.InstanceState, error) {
key, err := KeyOf(r.Instance)
if err != nil {
return buscalls.InstanceState{}, err
}
rec, _, err := l.readState(key)
if err != nil {
return buscalls.InstanceState{}, err
}
state := rec.State
if len(state) == 0 {
state = json.RawMessage("null")
}
return buscalls.InstanceState{Instance: r.Instance, State: state, Sequence: rec.Sequence}, nil
}
// operationsOf reads kept operations as the history answers them, each with its sequence.
func operationsOf(kept []Kept) ([]buscalls.Operation, error) {
out := make([]buscalls.Operation, 0, len(kept))
for _, k := range kept {
var o buscalls.Operation
if err := json.Unmarshal(k.Data, &o); err != nil {
return nil, fmt.Errorf("the history's operation %d is not one: %w", k.Sequence, err)
}
o.Sequence = k.Sequence
out = append(out, o)
}
return out, nil
}
// ReadHistory is an instance's operations from a sequence on, oldest first, each with its sequence and its proven author.
func (l *Ledger) ReadHistory(r buscalls.HistoryRequest) (buscalls.HistoryPage, error) {
key, err := KeyOf(r.Instance)
if err != nil {
return buscalls.HistoryPage{}, err
}
limit := r.Limit
if limit <= 0 || limit > HistoryMost {
limit = HistoryMost
}
last, err := l.History.Last(key)
if err != nil {
return buscalls.HistoryPage{}, err
}
kept, err := l.History.Read(key, r.From, limit+1)
if err != nil {
return buscalls.HistoryPage{}, err
}
page := buscalls.HistoryPage{Last: last}
if len(kept) > limit {
page.Next = kept[limit].Sequence
kept = kept[:limit]
}
if page.Operations, err = operationsOf(kept); err != nil {
return buscalls.HistoryPage{}, err
}
return page, nil
}
// Watch hands deliver the state of every instance Instances names (`*` one name, `>` the rest, in an instance's path),
// the current ones first, then each change.
func (l *Ledger) Watch(r buscalls.LedgerWatchRequest, deliver func(buscalls.InstanceChange) error) (func(), error) {
w, ok := l.State.(Watcher)
if !ok {
return nil, errors.New("this ledger's state cannot be watched")
}
pattern := ">"
if r.Instances != "" {
pattern = keyPattern(r.Instances)
}
return w.Watch(pattern, func(key string, value []byte, current bool) error {
var rec Record
if value != nil {
if err := json.Unmarshal(value, &rec); err != nil {
return fmt.Errorf("the state of %s is not a record: %w", key, err)
}
}
state := rec.State
if len(state) == 0 {
state = json.RawMessage("null")
}
return deliver(buscalls.InstanceChange{Watch: r.Watch, Instance: InstanceOf(key), State: state, Sequence: rec.Sequence,
Current: current})
})
}
// keyPattern is an instance pattern as a key pattern: its path's parts joined by dots.
func keyPattern(instances string) string {
out := []byte(instances)
for i, c := range out {
if c == '/' {
out[i] = '.'
}
}
return string(out)
}
// index is a declared index by its name, or the reason there is none.
func (l *Ledger) index(name string) (Index, error) {
if ix, ok := l.Indexes[name]; ok {
return ix, nil
}
return nil, fmt.Errorf("%s's ledger %s declares no index %q", l.Module, l.Name, name)
}
// Query is a declared index's entries whose key Key names.
func (l *Ledger) Query(r buscalls.QueryRequest) (buscalls.QueryAnswer, error) {
ix, err := l.index(r.Index)
if err != nil {
return buscalls.QueryAnswer{}, err
}
held, err := ix.Entries(r.Key, r.Limit)
if err != nil {
return buscalls.QueryAnswer{}, err
}
entries := make([]buscalls.IndexEntry, 0, len(held))
for _, h := range held {
var e buscalls.IndexEntry
if err := json.Unmarshal(h.Value, &e); err != nil {
return buscalls.QueryAnswer{}, fmt.Errorf("the index %s's entry %s is not one: %w", r.Index, h.Key, err)
}
entries = append(entries, e)
}
return buscalls.QueryAnswer{Entries: entries}, nil
}
+120
View File
@@ -0,0 +1,120 @@
package ledger
import (
"fmt"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/buscalls"
)
// 1966/AC15, the other reads of a ledger (get, query) and its repairs (ADR 0597 §8, §10; design 25 § 12.5).
func TestReadsAndRepairsOfALedger(t *testing.T) {
cases := []struct {
name string
run func(t *testing.T, l *Ledger, f fakes)
}{
{"1966/AC15 history answers an instance's operations oldest first with sequence and author", func(t *testing.T, l *Ledger, f fakes) {
op := buscalls.Author{Account: "ledger.anchor.m", Caller: "op@shanks"}
applied(t, l, "decision/1", "note", `{"says":"one"}`, owner)
applied(t, l, "decision/2", "note", `{"says":"elsewhere"}`, owner)
applied(t, l, "decision/1", "title", `{"title":"Two"}`, op)
applied(t, l, "decision/1", "note", `{"says":"three"}`, owner)
page, err := l.ReadHistory(buscalls.HistoryRequest{Instance: "decision/1", Limit: 2})
if err != nil {
t.Fatal(err)
}
var got []string
for _, o := range page.Operations {
got = append(got, fmt.Sprintf("%s@%d %s %s", o.Operation, o.Sequence, o.Author.Account, o.Author.Caller))
}
if strings.Join(got, "; ") != "note@1 ledger.anchor.m ; title@3 ledger.anchor.m op@shanks" || page.Next != 4 || page.Last != 4 {
t.Fatalf("the history is %q, next %d, last %d", got, page.Next, page.Last)
}
rest, _ := l.ReadHistory(buscalls.HistoryRequest{Instance: "decision/1", From: page.Next})
if len(rest.Operations) != 1 || rest.Operations[0].Sequence != 4 || rest.Next != 0 {
t.Fatalf("read on: %+v", rest)
}
}},
{"get answers the state and its sequence, null for an instance with none", func(t *testing.T, l *Ledger, f fakes) {
applied(t, l, "decision/1", "title", `{"title":"One"}`, owner)
got, err := l.Get(buscalls.InstanceRequest{Instance: "decision/1"})
if err != nil || got.Sequence != 1 || !strings.Contains(string(got.State), `"title":"One"`) {
t.Fatalf("got %+v, %v", got, err)
}
if none, err := l.Get(buscalls.InstanceRequest{Instance: "decision/9"}); err != nil || string(none.State) != "null" {
t.Fatalf("none is %+v, %v", none, err)
}
}},
{"query reads a declared index by key, and an undeclared one is refused", func(t *testing.T, l *Ledger, f fakes) {
_ = f.index.Put("One.decision.1", []byte(`{"instance":"decision/1","sequence":1}`))
_ = f.index.Put("Two.decision.2", []byte(`{"instance":"decision/2","sequence":2}`))
got, err := l.Query(buscalls.QueryRequest{Index: "by-title", Key: "One.>"})
if err != nil || len(got.Entries) != 1 || got.Entries[0] != (buscalls.IndexEntry{Instance: "decision/1", Sequence: 1}) {
t.Fatalf("queried %+v, %v", got, err)
}
if _, err := l.Query(buscalls.QueryRequest{Index: "by-date"}); err == nil || !strings.Contains(err.Error(), `no index "by-date"`) {
t.Fatalf("an undeclared index: %v", err)
}
}},
{"a lost state rebuilds from the history, an expired one from its snapshot, and lag says what is behind", func(t *testing.T, l *Ledger, f fakes) {
applied(t, l, "decision/1", "title", `{"title":"One"}`, owner)
applied(t, l, "decision/1", "note", `{"says":"kept"}`, owner)
want, _, _ := f.state.Get("decision.1")
f.state.kv = nil
lag, _ := l.Lag(buscalls.LedgerRequest{})
if len(lag.Instances) != 1 || lag.Instances[0].History != 2 || lag.Instances[0].State != 0 || lag.Instances[0].Snapshot != 2 {
t.Fatalf("lag says %+v", lag)
}
if got, err := l.Rebuild(buscalls.RebuildRequest{}); err != nil || got.Written != 1 {
t.Fatalf("rebuilt %+v, %v", got, err)
}
if got, _, _ := f.state.Get("decision.1"); string(record(t, got).State) != string(record(t, want).State) {
t.Fatalf("rebuilt from the history %s, want %s", got, want)
}
f.state.kv = nil
f.history.Expire()
if got, err := l.Rebuild(buscalls.RebuildRequest{Instance: "decision/1", From: buscalls.RebuildFromSnapshot}); err != nil || got.Written != 1 {
t.Fatalf("rebuilt %+v, %v", got, err)
}
if got, _, _ := f.state.Get("decision.1"); string(got) != string(want) {
t.Fatalf("rebuilt from the snapshot %s, want %s", got, want)
}
}},
{"reindex builds a declared index from the states, and lag says an index behind until it does", func(t *testing.T, l *Ledger, f fakes) {
applied(t, l, "decision/1", "title", `{"title":"One"}`, owner)
applied(t, l, "decision/2", "note", `{"says":"untitled"}`, owner)
_ = f.index.Put("stale.decision.9", []byte(`{}`))
if lag, _ := l.Lag(buscalls.LedgerRequest{}); len(lag.Indexes) != 1 || lag.Indexes[0].Behind != 1 {
t.Fatalf("lag says %+v", lag)
}
if got, err := l.Reindex(buscalls.ReindexRequest{Index: "by-title"}); err != nil || got.Written != 1 {
t.Fatalf("reindexed %+v, %v", got, err)
}
entries, _ := f.index.Entries("", 0)
if len(entries) != 1 || entries[0].Key != buscalls.IndexKey([]string{"One"}, "decision/1") || string(entries[0].Value) != `{"instance":"decision/1","sequence":1}` {
t.Fatalf("the index holds %+v", entries)
}
if lag, _ := l.Lag(buscalls.LedgerRequest{}); len(lag.Indexes) != 0 || len(lag.Instances) != 0 {
t.Fatalf("lag after reindex says %+v", lag)
}
}},
{"snapshot rewrites a snapshot behind its state", func(t *testing.T, l *Ledger, f fakes) {
applied(t, l, "decision/1", "title", `{"title":"One"}`, owner)
f.snapshots.kv = nil
if got, err := l.Snapshot(buscalls.InstanceRequest{}); err != nil || got.Written != 1 {
t.Fatalf("snapshot %+v, %v", got, err)
}
state, _, _ := f.state.Get("decision.1")
if snap, _, _ := f.snapshots.Get("decision.1"); string(snap) != string(state) {
t.Fatalf("the snapshot %s, the state %s", snap, state)
}
}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
l, f := newLedger()
c.run(t, l, f)
})
}
}
+304
View File
@@ -0,0 +1,304 @@
package ledger
import (
"encoding/json"
"fmt"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/buscalls"
)
// The ledger's repairs (ADR 0597 §8, §10; design 25 § 12.5; issue 1966 T4): rebuild an instance's state from its history
// or its snapshot, rewrite a snapshot behind its state, build a declared index again, and say what is behind its history.
// The tool runner serves them as tools of every module owning a ledger, its own ledgers only (the runtime's repairs.go).
// instances is every instance the ledger knows, by key: those its state holds and those its history names.
func (l *Ledger) instances() ([]string, error) {
seen := map[string]bool{}
keys, err := l.State.Keys()
if err != nil {
return nil, err
}
for _, k := range keys {
seen[k] = true
}
kept, err := l.History.Read(">", 1, 0)
if err != nil {
return nil, err
}
ops, err := operationsOf(kept)
if err != nil {
return nil, err
}
for _, o := range ops {
if k, err := KeyOf(o.Instance); err == nil {
seen[k] = true
}
}
out := make([]string, 0, len(seen))
for k := range seen {
out = append(out, k)
}
sort.Strings(out)
return out, nil
}
// keysOf is the one instance a request names, or every instance when it names none.
func (l *Ledger) keysOf(instance string) ([]string, error) {
if instance == "" {
return l.instances()
}
k, err := KeyOf(instance)
if err != nil {
return nil, err
}
return []string{k}, nil
}
// Rebuild derives the state of an instance (every instance when it names none) again, from its whole history or from its
// snapshot and the operations after it, and writes it, and its snapshot when that is behind. A derivation further back
// than the state holds is not written: the state never goes back (ADR 0597 §5).
func (l *Ledger) Rebuild(r buscalls.RebuildRequest) (buscalls.Repaired, error) {
from := r.From
if from == "" {
from = buscalls.RebuildFromHistory
}
if from != buscalls.RebuildFromHistory && from != buscalls.RebuildFromSnapshot {
return buscalls.Repaired{}, fmt.Errorf("a rebuild is from %q or %q, not %q", buscalls.RebuildFromHistory, buscalls.RebuildFromSnapshot, from)
}
keys, err := l.keysOf(r.Instance)
if err != nil {
return buscalls.Repaired{}, err
}
var out buscalls.Repaired
for _, key := range keys {
base := Record{}
if from == buscalls.RebuildFromSnapshot {
if base, _, err = l.readSnapshot(key); err != nil {
return out, err
}
}
derived, err := l.replay(key, base, 0)
if err != nil {
return out, err
}
held, rev, err := l.readState(key)
if err != nil {
return out, err
}
if derived.Sequence < held.Sequence {
continue
}
now, _, err := l.settle(key, derived, rev)
if err != nil {
return out, err
}
if err := l.writeSnapshot(key, now); err != nil {
return out, err
}
out.Written++
}
return out, nil
}
// Snapshot rewrites the snapshot of an instance (every instance when it names none) that is behind its state.
func (l *Ledger) Snapshot(r buscalls.InstanceRequest) (buscalls.Repaired, error) {
keys, err := l.keysOf(r.Instance)
if err != nil {
return buscalls.Repaired{}, err
}
var out buscalls.Repaired
for _, key := range keys {
held, _, err := l.readState(key)
if err != nil {
return out, err
}
snap, at, err := l.readSnapshot(key)
if err != nil {
return out, err
}
if held.Sequence == 0 || (at != 0 && snap.Sequence >= held.Sequence) {
continue
}
if err := l.writeSnapshot(key, held); err != nil {
return out, err
}
out.Written++
}
return out, nil
}
// indexEntryOf is an instance's entry in a declared index, as reindex writes it and the owner's watcher keeps it
// (mesh-sdk buscalls.IndexKey, buscalls.IndexEntry): its key the values of the fields the index is on, in their order,
// then the instance; its value the instance and the sequence of the state it was taken from. False when the state holds
// none of those fields. A value that is not text is written as its JSON.
func indexEntryOf(on []string, key string, rec Record) (string, []byte, bool) {
var state map[string]any
if json.Unmarshal(rec.State, &state) != nil || state == nil {
return "", nil, false
}
values := make([]string, 0, len(on))
found := false
for _, f := range on {
v, has := state[f]
found = found || has
s, ok := v.(string)
if !ok {
raw, _ := json.Marshal(v)
s = string(raw)
}
values = append(values, s)
}
if !found {
return "", nil, false
}
instance := InstanceOf(key)
value, _ := json.Marshal(buscalls.IndexEntry{Instance: instance, Sequence: rec.Sequence})
return buscalls.IndexKey(values, instance), value, true
}
// declared is the fields an index is on and the types declaring it.
func (l *Ledger) declared(index string) ([]string, map[string]bool, error) {
types := map[string]bool{}
var on []string
for _, t := range l.Types {
for _, ix := range t.Indexes {
if ix.Name == index {
types[t.Name] = true
on = ix.On
}
}
}
if len(types) == 0 {
return nil, nil, fmt.Errorf("%s's ledger %s declares no index %q", l.Module, l.Name, index)
}
return on, types, nil
}
// wanted is every entry an index should hold, from the states of the types declaring it.
func (l *Ledger) wanted(index string) (map[string][]byte, map[string]uint64, error) {
on, types, err := l.declared(index)
if err != nil {
return nil, nil, err
}
keys, err := l.instances()
if err != nil {
return nil, nil, err
}
want, seqs := map[string][]byte{}, map[string]uint64{}
for _, key := range keys {
name, _, _ := strings.Cut(key, ".")
if !types[name] {
continue
}
held, _, err := l.readState(key)
if err != nil {
return nil, nil, err
}
if k, v, ok := indexEntryOf(on, key, held); ok {
want[k], seqs[k] = v, held.Sequence
}
}
return want, seqs, nil
}
// Reindex builds a declared index again from the ledger's states, derived from its history: every entry it should hold
// written, every other removed.
func (l *Ledger) Reindex(r buscalls.ReindexRequest) (buscalls.Repaired, error) {
ix, err := l.index(r.Index)
if err != nil {
return buscalls.Repaired{}, err
}
want, _, err := l.wanted(r.Index)
if err != nil {
return buscalls.Repaired{}, err
}
held, err := ix.Entries("", 0)
if err != nil {
return buscalls.Repaired{}, err
}
var out buscalls.Repaired
for _, e := range held {
if _, keep := want[e.Key]; !keep {
if err := ix.Delete(e.Key); err != nil {
return out, err
}
}
}
keys := make([]string, 0, len(want))
for k := range want {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if err := ix.Put(k, want[k]); err != nil {
return out, err
}
out.Written++
}
return out, nil
}
// Lag is every instance whose state or snapshot is behind its history, and how many entries each declared index lacks
// or holds from an older state; nothing when nothing is behind.
func (l *Ledger) Lag(buscalls.LedgerRequest) (buscalls.Lagged, error) {
keys, err := l.instances()
if err != nil {
return buscalls.Lagged{}, err
}
var out buscalls.Lagged
for _, key := range keys {
last, err := l.History.Last(key)
if err != nil {
return out, err
}
held, _, err := l.readState(key)
if err != nil {
return out, err
}
snap, _, err := l.readSnapshot(key)
if err != nil {
return out, err
}
if held.Sequence < last || snap.Sequence < last {
out.Instances = append(out.Instances, buscalls.InstanceLag{Instance: InstanceOf(key), History: last,
State: held.Sequence, Snapshot: snap.Sequence})
}
}
names := make([]string, 0, len(l.Indexes))
for name := range l.Indexes {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
if _, _, err := l.declared(name); err != nil {
// A bucket bound for an index no type declares has nothing it should hold.
continue
}
want, seqs, err := l.wanted(name)
if err != nil {
return out, err
}
held, err := l.Indexes[name].Entries("", 0)
if err != nil {
return out, err
}
at := map[string]uint64{}
for _, e := range held {
var v buscalls.IndexEntry
_ = json.Unmarshal(e.Value, &v)
at[e.Key] = v.Sequence
}
behind := uint64(0)
for k := range want {
if s, has := at[k]; !has || s < seqs[k] {
behind++
}
}
if behind > 0 {
out.Indexes = append(out.Indexes, buscalls.IndexLag{Index: name, Behind: behind})
}
}
return out, nil
}
+61
View File
@@ -0,0 +1,61 @@
package ledger
import (
"encoding/json"
"testing"
"git.novox.be/novox/mesh-sdk/go/buscalls"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// records is a ledger type of each effect: a title set, a section set as an entry, a note appended, a section dropped,
// and the whole instance removed.
var records = catalogue.LedgerType{
Name: "decision",
Schema: map[string]catalogue.LedgerField{
"title": {Type: "string"}, "text": {Type: "string"}, "digest": {Type: "string", DigestOf: "text"},
},
Operations: []catalogue.LedgerOperation{
{Name: "title", Effect: catalogue.EffectSetFields, Fields: []string{"title"}, Sequence: true, Author: true},
{Name: "section", Effect: catalogue.EffectSetEntry, Into: "sections", Key: "id", Fields: []string{"digest"}},
{Name: "note", Effect: catalogue.EffectAppend, Into: "notes", Fields: []string{"says"}},
{Name: "drop-section", Effect: catalogue.EffectRemove, Into: "sections", Key: "id"},
{Name: "withdraw", Effect: catalogue.EffectRemove},
},
Indexes: []catalogue.LedgerIndex{{Name: "by-title", On: []string{"title"}}},
}
type fakes struct {
history *MemoryHistory
state *MemoryState
snapshots *MemorySnapshots
index *MemoryIndex
}
func newLedger() (*Ledger, fakes) {
f := fakes{&MemoryHistory{}, &MemoryState{}, &MemorySnapshots{}, &MemoryIndex{}}
return &Ledger{Module: "m", Name: "records", Types: []catalogue.LedgerType{records}, History: f.history, State: f.state,
Snapshots: f.snapshots, Indexes: map[string]Index{"by-title": f.index}}, f
}
// applied applies an operation and fails the test on a refusal.
func applied(t *testing.T, l *Ledger, instance, operation, body string, author buscalls.Author) buscalls.Applied {
t.Helper()
got, err := l.Apply(buscalls.ApplyRequest{Ledger: l.Name, Instance: instance, Operation: operation, Body: json.RawMessage(body)}, author)
if err != nil {
t.Fatalf("%s %s: %v", operation, body, err)
}
return got
}
// record is the instance's record a store holds, read as a Record.
func record(t *testing.T, raw []byte) Record {
t.Helper()
var r Record
if raw != nil {
if err := json.Unmarshal(raw, &r); err != nil {
t.Fatal(err)
}
}
return r
}
+107
View File
@@ -0,0 +1,107 @@
package runtime
import (
"encoding/json"
"fmt"
"git.novox.be/novox/mesh-sdk/go/buscalls"
)
// BusState answers a bundle's calls on its module's bus state in the new form (mesh-sdk buscalls; novox/hq ADR 0595
// §9, design 25 § 12.5; issue 1966): each as the module whose bundle asks, never a module the request names, so a
// bundle reaches its own module's bindings alone.
func (b *moduleBus) BusState(method string, params json.RawMessage) (json.RawMessage, error) {
conn := b.all.conn
var answer any
var err error
switch method {
case buscalls.ObjectsPut:
var r buscalls.ObjectsPutRequest
if err = decode(method, params, &r); err == nil {
answer, err = conn.ObjectsPut(b.module, r)
}
case buscalls.ObjectsGet:
var r buscalls.ObjectsNameRequest
if err = decode(method, params, &r); err == nil {
var got *buscalls.ObjectData
if got, err = conn.ObjectsGet(b.module, r); err == nil && got == nil {
return json.RawMessage("null"), nil
}
answer = got
}
case buscalls.ObjectsDelete:
var r buscalls.ObjectsNameRequest
if err = decode(method, params, &r); err == nil {
err = conn.ObjectsDelete(b.module, r)
answer = buscalls.Done{}
}
case buscalls.ObjectsList:
var r buscalls.ObjectsRequest
if err = decode(method, params, &r); err == nil {
answer, err = conn.ObjectsList(b.module, r)
}
case buscalls.ObjectsInfo:
var r buscalls.ObjectsRequest
if err = decode(method, params, &r); err == nil {
answer, err = conn.ObjectsInfo(b.module, r)
}
case buscalls.StreamRead:
var r buscalls.StreamReadRequest
if err = decode(method, params, &r); err == nil {
answer, err = conn.StreamRead(b.module, r)
}
case buscalls.StreamFill:
var r buscalls.StreamRequest
if err = decode(method, params, &r); err == nil {
answer, err = conn.StreamFill(b.module, r)
}
case buscalls.WorkQueueSize:
var r buscalls.WorkQueueRequest
if err = decode(method, params, &r); err == nil {
answer, err = conn.WorkQueueSize(b.module, r)
}
case buscalls.WorkQueueDead:
var r buscalls.WorkQueueDeadRequest
if err = decode(method, params, &r); err == nil {
answer, err = conn.WorkQueueDead(b.module, r)
}
case buscalls.LedgerApply, buscalls.LedgerGet, buscalls.LedgerHistory, buscalls.LedgerQuery, buscalls.LedgerRebuild,
buscalls.LedgerReindex, buscalls.LedgerSnapshot, buscalls.LedgerLag:
answer, err = b.ledgerCall(method, params)
default:
return nil, fmt.Errorf("the runtime answers no %s", method)
}
if err != nil {
return nil, err
}
return json.Marshal(answer)
}
// BusStateWatch hands a bundle each change of its objects or its ledger, the current ones first.
func (b *moduleBus) BusStateWatch(method string, params json.RawMessage, deliver func(json.RawMessage) error) (func(), error) {
switch method {
case buscalls.ObjectsWatch:
var r buscalls.ObjectsWatchRequest
if err := decode(method, params, &r); err != nil {
return nil, err
}
return b.all.conn.ObjectsWatch(b.module, r, func(change buscalls.ObjectsChange) error {
raw, err := json.Marshal(change)
if err != nil {
return err
}
return deliver(raw)
})
case buscalls.LedgerWatch:
return b.ledgerWatch(params, deliver)
}
return nil, fmt.Errorf("the runtime answers no %s", method)
}
// decode reads a bus-state call's request.
func decode(method string, params json.RawMessage, into any) error {
if err := json.Unmarshal(params, into); err != nil {
return fmt.Errorf("%s's request is not of its shape (mesh-sdk buscalls): %w", method, err)
}
return nil
}
@@ -13,6 +13,7 @@ import (
type saidCaller struct { type saidCaller struct {
Caller string `json:"caller"` Caller string `json:"caller"`
Call string `json:"call"`
Arguments map[string]any `json:"arguments"` Arguments map[string]any `json:"arguments"`
} }
@@ -36,6 +37,7 @@ func TestACallOnTheCallerNamedSubjectReachesTheModuleWithItsCaller(t *testing.T)
asker.Logf = func(string, ...any) {} asker.Logf = func(string, ...any) {}
defer asker.Close() defer asker.Close()
calls := map[string]bool{}
for _, key := range []string{"ledger.who", "ledger.who@anchor", "seat:node-desk.who@anchor"} { for _, key := range []string{"ledger.who", "ledger.who@anchor", "seat:node-desk.who@anchor"} {
var got saidCaller var got saidCaller
mt.Until(t, func() error { mt.Until(t, func() error {
@@ -49,6 +51,11 @@ func TestACallOnTheCallerNamedSubjectReachesTheModuleWithItsCaller(t *testing.T)
if got.Caller != "shanks.node-tools" { if got.Caller != "shanks.node-tools" {
t.Errorf("%s reached the module with caller %q, want the asker's bus user", key, got.Caller) t.Errorf("%s reached the module with caller %q, want the asker's bus user", key, got.Caller)
} }
// And the id of the call in flight, one per call, which a ledger's apply names (novox/hq ADR 0597 §7).
if got.Call == "" || calls[got.Call] {
t.Errorf("%s reached the module with call id %q, want one of its own", key, got.Call)
}
calls[got.Call] = true
if got.Arguments["caller"] != "novox.node-tools" { if got.Arguments["caller"] != "novox.node-tools" {
t.Errorf("%s: the arguments did not reach the module as sent: %v", key, got.Arguments) t.Errorf("%s: the arguments did not reach the module as sent: %v", key, got.Arguments)
} }
@@ -0,0 +1,145 @@
package runtime
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"sync"
"github.com/novox/mesh-tools/node-tools/internal/bus"
)
// The ledger credentials (novox/hq ADR 0597 §9, design 25 § 12.4; issue 1966 T6): one per module assignment, held by
// the machine's tool runner in its own secret `ledgers`, a map of module to credential (`{}` when none), which the vault
// rewrites on each mint and retirement. **A module's ledger history is appended, and its snapshots written, only on
// that module's own ledger connection**; everything else stays on the runner's own.
// LedgersFile is where the runner reads its own secret `ledgers`: MESH_LEDGERS_FILE when set, else beside its broker
// credential, both in its directory mesh-state (`${dir:mesh-state}/ledgers`). Its manifest declares the secret only once
// the vault holds it on every machine (B3 of issue 1966): until then a file not there means no ledger credentials, and
// every ledger write is refused naming the missing one.
const LedgersFile = "MESH_LEDGERS_FILE"
// LedgersFileOf is the path of the secret `ledgers` for a runtime whose broker credential is at broker.
func LedgersFileOf(broker string) string {
if file := os.Getenv(LedgersFile); file != "" {
return file
}
if broker == "" {
return ""
}
return filepath.Join(filepath.Dir(broker), "ledgers")
}
// LedgerCredentials are the ledger connections of the modules on this machine, read from the secret `ledgers` and
// followed as it changes, with no restart.
type LedgerCredentials struct {
file string
dial func(bus.Credential) (*bus.Conn, error)
logf func(string, ...any)
mu sync.Mutex
creds map[string]bus.Credential
conns map[string]*bus.Conn
why string // why the file could not be read, when it could not
stop func()
}
// FollowLedgerCredentials reads the secret `ledgers` at file and follows it (WatchFile), dialling a module's connection
// with dial the first time it is asked for.
func FollowLedgerCredentials(file string, dial func(bus.Credential) (*bus.Conn, error), logf func(string, ...any)) *LedgerCredentials {
l := &LedgerCredentials{file: file, dial: dial, logf: logf, creds: map[string]bus.Credential{}, conns: map[string]*bus.Conn{}}
l.read()
stop, err := WatchFile(file, l.read, logf, "a change of the ledger credentials is taken only after a restart")
if err != nil {
logf("[mesh-tools] cannot follow the ledger credentials at %s: %v; a change is taken only after a restart", file, err)
} else {
l.stop = stop
}
return l
}
// read takes the file as it is now: a module whose credential changed or went has its connection closed, and is
// dialled again from the new one when next asked for.
func (l *LedgerCredentials) read() {
creds, why := map[string]bus.Credential{}, ""
raw, err := os.ReadFile(l.file)
switch {
case errors.Is(err, os.ErrNotExist):
why = "the file " + l.file + " is not there yet"
case err != nil:
why = err.Error()
case len(bytes.TrimSpace(raw)) == 0:
why = "the file " + l.file + " is empty"
default:
if err := json.Unmarshal(raw, &creds); err != nil {
why = "the file " + l.file + " is not a map of module to credential: " + err.Error()
creds = map[string]bus.Credential{}
}
}
l.mu.Lock()
var closing []*bus.Conn
for module, conn := range l.conns {
if now, has := creds[module]; !has || !reflect.DeepEqual(now, l.creds[module]) {
closing = append(closing, conn)
delete(l.conns, module)
}
}
saidBefore := l.why
l.creds, l.why = creds, why
l.mu.Unlock()
for _, c := range closing {
c.Close()
}
if why != "" && why != saidBefore {
l.logf("[mesh-tools] the ledger credentials cannot be read (%s); no ledger is written until they can", why)
}
}
// Writer is a module's ledger connection, dialled from its credential the first time it is asked for; refused, naming
// what is missing, when the secret holds none for it.
func (l *LedgerCredentials) Writer(module string) (*bus.Conn, error) {
l.mu.Lock()
defer l.mu.Unlock()
if c := l.conns[module]; c != nil {
return c, nil
}
cred, has := l.creds[module]
if !has {
why := ""
if l.why != "" {
why = " (" + l.why + ")"
}
return nil, fmt.Errorf("%s's ledger credential is missing from the tool runner's own secret ledgers%s: its history "+
"is published only on its own ledger connection, which the grants part issues with its membership and the "+
"vault writes there (novox/hq ADR 0597 §9)", module, why)
}
if cred.Module == "" {
cred.Module = module
}
c, err := l.dial(cred)
if err != nil {
return nil, fmt.Errorf("%s's ledger connection could not be opened: %w", module, err)
}
c.Logf = l.logf
l.conns[module] = c
return c, nil
}
// Stop ends the following and closes every ledger connection.
func (l *LedgerCredentials) Stop() {
if l.stop != nil {
l.stop()
}
l.mu.Lock()
conns := l.conns
l.conns = map[string]*bus.Conn{}
l.mu.Unlock()
for _, c := range conns {
c.Close()
}
}
@@ -0,0 +1,135 @@
package runtime
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/buscalls"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/membership"
"github.com/nats-io/nats-server/v2/server"
"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"
)
// 1966/AC22: given the secret ledgers holding m's credential, when m applies, then the append goes on m's connection
// (the runner's own account may not publish on m's ledger, as the grants part grants it); given it missing, apply is
// refused naming it; a change of the file is taken without a restart.
func TestALedgerIsWrittenOnlyOnItsModulesLedgerConnection(t *testing.T) {
ledgerSubjects := []string{buscompose.LedgerHistorySubject("m", "records") + ".>", buscompose.LedgerSnapshotSubject("m", "records") + ".>"}
users := []*server.User{
{Username: "node-tools", Password: "pw", Permissions: &server.Permissions{
Publish: &server.SubjectPermission{Allow: []string{">"}, Deny: ledgerSubjects}}},
// As the grants part composes a ledger account (mesh-grants buscompose ledgerPermissions): publish on its
// module's ledger histories and snapshots, subscribe its own inbox, nothing else.
{Username: "ledger.anchor.m", Password: "pw", Permissions: &server.Permissions{
Publish: &server.SubjectPermission{Allow: ledgerSubjects},
Subscribe: &server.SubjectPermission{Allow: []string{"_INBOX.ledger.anchor.m.>"}}}},
{Username: "admin", Password: "pw"},
}
s, err := server.NewServer(&server.Options{Host: "127.0.0.1", Port: server.RANDOM_PORT, JetStream: true, StoreDir: t.TempDir(),
NoLog: true, NoSigs: true, Users: users})
if err != nil {
t.Fatal(err)
}
go s.Start()
if !s.ReadyForConnections(30 * time.Second) {
t.Fatal("the bus did not come up")
}
t.Cleanup(s.Shutdown)
url := s.ClientURL()
admin, err := nats.Connect(url, nats.UserInfo("admin", "pw"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(admin.Close)
js, _ := jetstream.New(admin)
ctx := context.Background()
for _, cfg := range []jetstream.StreamConfig{
{Name: "LEDGER_m_records", Subjects: []string{"mesh.ledger.m.records.history.>"}},
{Name: "SNAPSHOT_m_records", Subjects: []string{"mesh.ledger.m.records.snapshot.>"}, MaxMsgsPerSubject: 1},
} {
if _, err := js.CreateStream(ctx, cfg); err != nil {
t.Fatal(err)
}
}
for _, b := range []string{"m_records", buscompose.GrantsMembershipsBucket} {
if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: b}); err != nil {
t.Fatal(err)
}
}
mem := mt.MembershipOf("m", "anchor", false, nil)
mem.Bindings = []membership.BusBinding{{Local: "records", Storage: catalogue.StorageLedger, Template: catalogue.TemplateLedger,
History: "LEDGER_m_records", Subject: "mesh.ledger.m.records.history", State: "m_records", Snapshot: "mesh.ledger.m.records.snapshot",
Types: []catalogue.LedgerType{{Name: "decision", Operations: []catalogue.LedgerOperation{
{Name: "title", Effect: catalogue.EffectSetFields, Fields: []string{"title"}}}}}}}
raw, _ := json.Marshal(mem)
var sdk membership.Membership
_ = json.Unmarshal(raw, &sdk)
grants, _ := js.KeyValue(ctx, buscompose.GrantsMembershipsBucket)
if _, err := grants.Put(ctx, buscompose.MembershipKey("anchor", "m"), membership.EncodeGrantsMembership(sdk)); err != nil {
t.Fatal(err)
}
conn, err := bus.Connect(bus.Credential{URL: url, Module: "node-tools", Node: "anchor", User: "node-tools", Password: "pw"})
if err != nil {
t.Fatal(err)
}
conn.Logf = func(string, ...any) {}
t.Cleanup(conn.Close)
conn.Follow("m")
mt.Until(t, func() error {
if conn.Membership("m") == nil {
return os.ErrNotExist
}
return nil
})
// Not there yet, as on a machine whose vault has not made it: no ledger credentials, and the runner serves on.
file := filepath.Join(t.TempDir(), "ledgers")
creds := FollowLedgerCredentials(file, bus.Dial, t.Logf)
t.Cleanup(creds.Stop)
all := &consumers{conn: conn, logf: t.Logf, of: map[string]*moduleEvents{}}
WithLedgerWriters(creds.Writer)(all)
m := all.forModule("m").(*moduleBus)
apply := func(instance string) error {
_, err := m.BusState(buscalls.LedgerApply, json.RawMessage(`{"ledger":"records","instance":"`+instance+`","operation":"title","body":{"title":"T"}}`))
return err
}
// Missing: refused, naming it and why.
if err := apply("decision/1"); err == nil || !strings.Contains(err.Error(), "m's ledger credential is missing from the tool runner's own secret ledgers (the file "+file+" is not there yet)") {
t.Fatalf("an apply with no credential: %v", err)
}
// Written: taken without a restart, on m's connection (the runner's own account is denied m's ledger subjects, so an
// append that lands went on m's), whose account stamps the operation.
cred, _ := json.Marshal(map[string]bus.Credential{"m": {URL: url, Node: "anchor", Module: "m", User: "ledger.anchor.m", Password: "pw"}})
if err := os.WriteFile(file, cred, 0o600); err != nil {
t.Fatal(err)
}
mt.Until(t, func() error { return apply("decision/1") })
page, err := m.BusState(buscalls.LedgerHistory, json.RawMessage(`{"ledger":"records","instance":"decision/1"}`))
if err != nil || !strings.Contains(string(page), `"author":{"account":"ledger.anchor.m"}`) {
t.Fatalf("the history holds %s, %v", page, err)
}
// Gone again: refused again, the change taken without a restart.
if err := os.WriteFile(file, []byte(`{}`), 0o600); err != nil {
t.Fatal(err)
}
mt.Until(t, func() error {
if err := apply("decision/2"); err == nil || !strings.Contains(err.Error(), "credential is missing") {
return os.ErrExist
}
return nil
})
}
@@ -0,0 +1,261 @@
//go:build integration
package runtime
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/buscalls"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/membership"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"github.com/novox/mesh-tools/node-tools/internal/bus"
"github.com/novox/mesh-tools/node-tools/internal/ledger"
mt "github.com/novox/mesh-tools/node-tools/internal/meshtest"
)
// racing is a history whose append lets another writer apply first, once: before its own append, or after it.
type racing struct {
ledger.History
before, after func()
}
func (r *racing) Append(key string, op []byte, expect uint64) (uint64, error) {
if f := r.before; f != nil {
r.before = nil
f()
}
seq, err := r.History.Append(key, op, expect)
if f := r.after; f != nil && err == nil {
r.after = nil
f()
}
return seq, err
}
// cut is a state whose next write is lost, as a writer stopped between its history and its state would lose it.
type cut struct {
ledger.State
once bool
}
func (c *cut) Write(key string, value []byte, revision uint64) (uint64, error) {
if c.once {
c.once = false
return 0, errors.New("the writer stopped")
}
return c.State.Write(key, value, revision)
}
// 1966/AC19: the ledger keeps its promises against the real bus (ADR 0597's How it is checked; design 25 § 12.7), one
// case each, and every apply leaves the snapshot equal to the state.
func TestTheLedgerKeepsItsPromisesOnTheBus(t *testing.T) {
mesh := 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()
for _, cfg := range []jetstream.StreamConfig{
{Name: "LEDGER_m_records", Subjects: []string{"mesh.ledger.m.records.history.>"}},
{Name: "SNAPSHOT_m_records", Subjects: []string{"mesh.ledger.m.records.snapshot.>"}, MaxMsgsPerSubject: 1},
} {
if _, err := js.CreateStream(ctx, cfg); err != nil {
t.Fatal(err)
}
}
for _, b := range []string{"m_records", "m_records_by_title"} {
if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: b}); err != nil {
t.Fatal(err)
}
}
types := []catalogue.LedgerType{{Name: "decision", Schema: map[string]catalogue.LedgerField{"title": {Type: "string"}},
Operations: []catalogue.LedgerOperation{
{Name: "title", Effect: catalogue.EffectSetFields, Fields: []string{"title"}, Author: true},
{Name: "note", Effect: catalogue.EffectAppend, Into: "notes", Fields: []string{"says"}},
},
Indexes: []catalogue.LedgerIndex{{Name: "by-title", On: []string{"title"}}}}}
binding := membership.BusBinding{Local: "records", Storage: catalogue.StorageLedger, Template: catalogue.TemplateLedger,
History: "LEDGER_m_records", Subject: "mesh.ledger.m.records.history", State: "m_records",
Indexes: map[string]string{"by-title": "m_records_by_title"}, Snapshot: "mesh.ledger.m.records.snapshot"}
mem := mt.MembershipOf("m", "anchor", false, nil)
binding.Types = types
mem.Bindings = []membership.BusBinding{binding}
mesh.Issue(t, mem)
conn := connect(t, "node-tools", "anchor")
conn.Follow("m")
mt.Until(t, func() error {
if conn.Membership("m") == nil {
return errors.New("not issued yet")
}
return nil
})
all := &consumers{conn: conn, logf: t.Logf, of: map[string]*moduleEvents{}}
WithLedgerWriters(func(string) (*bus.Conn, error) { return conn, nil })(all)
m := all.forModule("m").(*moduleBus)
open := func(t *testing.T) *ledger.Ledger {
t.Helper()
l, err := all.ledger("m", "records")
if err != nil {
t.Fatal(err)
}
return l
}
apply := func(t *testing.T, l *ledger.Ledger, instance, operation, body string) (buscalls.Applied, error) {
t.Helper()
return l.Apply(buscalls.ApplyRequest{Ledger: "records", Instance: instance, Operation: operation, Body: json.RawMessage(body)},
buscalls.Author{Account: "m"})
}
must := func(t *testing.T, l *ledger.Ledger, instance, operation, body string) buscalls.Applied {
t.Helper()
got, err := apply(t, l, instance, operation, body)
if err != nil {
t.Fatal(err)
}
return got
}
held := func(t *testing.T, key string) ledger.Record {
t.Helper()
raw, _, err := open(t).State.Get(key)
if err != nil {
t.Fatal(err)
}
var r ledger.Record
if raw != nil {
_ = json.Unmarshal(raw, &r)
}
return r
}
snapshotIsState := func(t *testing.T, key string) {
t.Helper()
l := open(t)
state, _, _ := l.State.Get(key)
snap, _, _ := l.Snapshots.Get(key)
if string(snap) != string(state) {
t.Fatalf("the snapshot of %s is %s, its state %s", key, snap, state)
}
}
cases := []struct {
name, key string
run func(t *testing.T)
}{
{"two writers race and one is refused", "decision.race", func(t *testing.T) {
l := open(t)
l.History = &racing{History: l.History, before: func() { must(t, open(t), "decision/race", "note", `{"says":"first"}`) }}
_, err := apply(t, l, "decision/race", "note", `{"says":"second"}`)
if !errors.Is(err, ledger.ErrRaced) || !strings.Contains(err.Error(), "not taken") {
t.Fatalf("the racing writer was answered %v", err)
}
if got := held(t, "decision.race"); string(got.State) != `{"notes":[{"says":"first"}]}` {
t.Fatalf("the state is %s", got.State)
}
}},
{"two interleaved applies leave the state at the higher sequence", "decision.interleave", func(t *testing.T) {
var inner buscalls.Applied
l := open(t)
l.History = &racing{History: l.History, after: func() { inner = must(t, open(t), "decision/interleave", "note", `{"says":"later"}`) }}
outer := must(t, l, "decision/interleave", "note", `{"says":"earlier"}`)
got := held(t, "decision.interleave")
if inner.Sequence <= outer.Sequence || got.Sequence != inner.Sequence ||
string(got.State) != `{"notes":[{"says":"earlier"},{"says":"later"}]}` {
t.Fatalf("applied %d then %d, the state at %d: %s", outer.Sequence, inner.Sequence, got.Sequence, got.State)
}
}},
{"a write cut between history and state is repaired by the next apply", "decision.cut", func(t *testing.T) {
must(t, open(t), "decision/cut", "note", `{"says":"one"}`)
l := open(t)
l.State = &cut{State: l.State, once: true}
if got := must(t, l, "decision/cut", "note", `{"says":"cut"}`); !got.Taken || !got.StateBehind {
t.Fatalf("the cut apply was answered %+v", got)
}
must(t, open(t), "decision/cut", "note", `{"says":"three"}`)
if got := held(t, "decision.cut"); string(got.State) != `{"notes":[{"says":"one"},{"says":"cut"},{"says":"three"}]}` {
t.Fatalf("the state is %s", got.State)
}
}},
{"a full state bucket answers taken with its sequence and catches up", "decision.full", func(t *testing.T) {
must(t, open(t), "decision/full", "note", `{"says":"small"}`)
kv, _ := js.KeyValue(ctx, "m_records")
status, _ := kv.Status(ctx)
if _, err := js.UpdateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "m_records", MaxBytes: int64(status.Bytes()) + 64}); err != nil {
t.Fatal(err)
}
got := must(t, open(t), "decision/full", "note", fmt.Sprintf(`{"says":%q}`, strings.Repeat("x", 512)))
if !got.Taken || got.Sequence == 0 || !got.StateBehind {
t.Fatalf("the full bucket's apply was answered %+v", got)
}
if _, err := js.UpdateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "m_records", MaxBytes: -1}); err != nil {
t.Fatal(err)
}
after := must(t, open(t), "decision/full", "note", `{"says":"room"}`)
if st := held(t, "decision.full"); after.StateBehind || st.Sequence != after.Sequence || !strings.Contains(string(st.State), strings.Repeat("x", 512)) {
t.Fatalf("after room: %+v, the state at %d", after, st.Sequence)
}
}},
{"an instance whose operations expired rebuilds from its snapshot", "decision.expired", func(t *testing.T) {
must(t, open(t), "decision/expired", "title", `{"title":"Kept"}`)
want := held(t, "decision.expired")
s, _ := js.Stream(ctx, "LEDGER_m_records")
if err := s.Purge(ctx, jetstream.WithPurgeSubject("mesh.ledger.m.records.history.decision.expired")); err != nil {
t.Fatal(err)
}
kv, _ := js.KeyValue(ctx, "m_records")
_ = kv.Purge(ctx, "decision.expired")
got, err := open(t).Rebuild(buscalls.RebuildRequest{Instance: "decision/expired", From: buscalls.RebuildFromSnapshot})
if st := held(t, "decision.expired"); err != nil || got.Written != 1 || string(st.State) != string(want.State) {
t.Fatalf("rebuilt %+v, %v: the state %s, want %s", got, err, st.State, want.State)
}
}},
{"rebuild, reindex and lag repair by hand", "decision.repair", func(t *testing.T) {
must(t, open(t), "decision/repair", "title", `{"title":"Repaired"}`)
kv, _ := js.KeyValue(ctx, "m_records")
_ = kv.Purge(ctx, "decision.repair")
l := open(t)
lag, err := l.Lag(buscalls.LedgerRequest{})
if err != nil || !strings.Contains(fmt.Sprint(lag), "decision/repair") || len(lag.Indexes) == 0 {
t.Fatalf("lag says %+v, %v", lag, err)
}
if got, err := l.Rebuild(buscalls.RebuildRequest{Instance: "decision/repair"}); err != nil || got.Written != 1 {
t.Fatalf("rebuilt %+v, %v", got, err)
}
if _, err := l.Reindex(buscalls.ReindexRequest{Index: "by-title"}); err != nil {
t.Fatal(err)
}
if lag, err := l.Lag(buscalls.LedgerRequest{}); err != nil || len(lag.Instances) != 0 || len(lag.Indexes) != 0 {
t.Fatalf("lag after the repairs says %+v, %v", lag, err)
}
q, err := l.Query(buscalls.QueryRequest{Index: "by-title", Key: "Repaired.>"})
if err != nil || len(q.Entries) != 1 {
t.Fatalf("queried %+v, %v", q, 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") {
t.Fatalf("a forged call was answered %v", err)
}
if last, _ := open(t).History.Last("decision.forged"); last != 0 {
t.Fatalf("the forged apply was appended at %d", last)
}
}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
c.run(t)
if c.key != "decision.forged" {
snapshotIsState(t, c.key)
}
})
}
}
+280
View File
@@ -0,0 +1,280 @@
package runtime
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"sync"
"git.novox.be/novox/mesh-sdk/go/buscalls"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"github.com/novox/mesh-tools/node-tools/internal/bus"
"github.com/novox/mesh-tools/node-tools/internal/launch"
"github.com/novox/mesh-tools/node-tools/internal/ledger"
)
// A module's ledgers as the tool runner serves them (novox/hq ADR 0597, design 25 § 12.4, § 12.5; issue 1966): opened
// for the module whose bundle or whose repair tool asks, from its own bindings alone, with its types and the connection
// its history is published on.
// ledgerSource is how the runner opens a module's ledger beside its binding: the connection its history and snapshots
// are published on (the module's ledger credential, ADR 0597 §9).
type ledgerSource struct {
writer func(module string) (*bus.Conn, error)
}
// callsInFlight are the tool calls the runtime is serving, each with the module whose bundle serves it and the caller
// the runtime proved from the subject the call arrived on (bus.CalledSubject, novox/hq issue 365).
type callsInFlight struct {
mu sync.Mutex
of map[string]callInFlight
}
type callInFlight struct{ module, caller string }
// begin keeps a call of module in flight for caller, under an id no bundle can guess, until end.
func (f *callsInFlight) begin(module, caller string) (string, func()) {
var raw [16]byte
_, _ = rand.Read(raw[:])
id := hex.EncodeToString(raw[:])
f.mu.Lock()
if f.of == nil {
f.of = map[string]callInFlight{}
}
f.of[id] = callInFlight{module, caller}
f.mu.Unlock()
return id, func() {
f.mu.Lock()
delete(f.of, id)
f.mu.Unlock()
}
}
// authorOf is the author an apply of module naming call is stamped with (ADR 0597 §7): the module's account, and the
// caller the runtime proved for that call; naming no call, the account alone. A call not in flight, or another
// module's, is refused, naming it. Nothing the bundle writes is taken as the author.
func (f *callsInFlight) authorOf(module, account, call string) (buscalls.Author, error) {
author := buscalls.Author{Account: account}
if call == "" {
return author, nil
}
f.mu.Lock()
inFlight, ok := f.of[call]
f.mu.Unlock()
switch {
case !ok:
return author, fmt.Errorf("the apply names the call %s, which is not a tool call in flight here; an apply names the "+
"call it serves as its tools/call's _meta %s gave it, or none (novox/hq ADR 0597 §7)", call, buscalls.MetaCall)
case inFlight.module != module:
return author, fmt.Errorf("the apply names the call %s, which is another module's tool call; %s names only its own "+
"(novox/hq ADR 0597 §7)", call, module)
}
author.Caller = inFlight.caller
return author, nil
}
// Begin keeps one of the module's tool calls in flight for the caller the runtime proved (launch.Bus).
func (b *moduleBus) Begin(caller string) (string, func()) { return b.all.calls.begin(b.module, caller) }
// ledger opens one of a module's ledgers by its local name.
func (c *consumers) ledger(module, local string) (*ledger.Ledger, error) {
b, err := c.conn.Binding(module, local, catalogue.StorageLedger)
if err != nil {
return nil, err
}
// Its types travel on its binding, copied by the grants part from the entry the manifest-accepted event carries
// (mesh-sdk membership.BusBinding.Types): the runner reads no other part's state for them (ADR 0596).
ts := b.Types
if len(ts) == 0 {
return nil, fmt.Errorf("%s's ledger %q is bound with no types, so no operation can be checked against one: its "+
"binding carries the types its manifest asks for in `requires` (novox/hq ADR 0597 §1)", module, local)
}
var writer *bus.Conn
if c.ledgers.writer != nil {
if writer, err = c.ledgers.writer(module); err != nil {
return nil, err
}
}
history, state, snapshots, err := c.conn.LedgerStores(module, b, writer)
if err != nil {
return nil, err
}
// The owning module's authenticated account: the user of the connection its history is published on.
account := module
if writer != nil && writer.Credential().User != "" {
account = writer.Credential().User
}
return &ledger.Ledger{Module: module, Name: local, Account: account, Types: ts, History: history, State: state,
Snapshots: snapshots, Indexes: c.conn.LedgerIndexes(b), Logf: c.logf}, nil
}
// ledgerCall answers a bundle's ledger call, as the module whose bundle asks.
func (b *moduleBus) ledgerCall(method string, params json.RawMessage) (any, error) {
var named struct {
Ledger string `json:"ledger"`
}
if err := decode(method, params, &named); err != nil {
return nil, err
}
if method == buscalls.LedgerApply {
var r buscalls.ApplyRequest
if err := decode(method, params, &r); err != nil {
return nil, err
}
// The call is judged first: a forged one is refused before anything of the ledger is touched.
if _, err := b.all.calls.authorOf(b.module, "", r.Call); err != nil {
return nil, err
}
l, err := b.all.ledger(b.module, r.Ledger)
if err != nil {
return nil, err
}
author, err := b.all.calls.authorOf(b.module, l.Account, r.Call)
if err != nil {
return nil, err
}
return l.Apply(r, author)
}
l, err := b.all.ledger(b.module, named.Ledger)
if err != nil {
return nil, err
}
switch method {
case buscalls.LedgerGet:
var r buscalls.InstanceRequest
_ = json.Unmarshal(params, &r)
return l.Get(r)
case buscalls.LedgerHistory:
var r buscalls.HistoryRequest
_ = json.Unmarshal(params, &r)
return l.ReadHistory(r)
case buscalls.LedgerQuery:
var r buscalls.QueryRequest
_ = json.Unmarshal(params, &r)
return l.Query(r)
case buscalls.LedgerRebuild:
var r buscalls.RebuildRequest
_ = json.Unmarshal(params, &r)
return l.Rebuild(r)
case buscalls.LedgerReindex:
var r buscalls.ReindexRequest
_ = json.Unmarshal(params, &r)
return l.Reindex(r)
case buscalls.LedgerSnapshot:
var r buscalls.InstanceRequest
_ = json.Unmarshal(params, &r)
return l.Snapshot(r)
case buscalls.LedgerLag:
var r buscalls.LedgerRequest
_ = json.Unmarshal(params, &r)
return l.Lag(r)
}
return nil, fmt.Errorf("the runtime answers no %s", method)
}
// ledgerWatch hands a bundle each change of its ledger's states.
func (b *moduleBus) ledgerWatch(params json.RawMessage, deliver func(json.RawMessage) error) (func(), error) {
var r buscalls.LedgerWatchRequest
if err := decode(buscalls.LedgerWatch, params, &r); err != nil {
return nil, err
}
l, err := b.all.ledger(b.module, r.Ledger)
if err != nil {
return nil, err
}
return l.Watch(r, func(change buscalls.InstanceChange) error {
raw, err := json.Marshal(change)
if err != nil {
return err
}
return deliver(raw)
})
}
// hasLedger is whether a module's membership binds a ledger.
func hasLedger(conn *bus.Conn, module string) bool {
for _, b := range conn.Bindings(module) {
if b.Storage == catalogue.StorageLedger {
return true
}
}
return false
}
// The repair tools the runner serves for every module owning a ledger (design 25 § 12.5), named so no bundle's tool is
// taken for one.
const (
RepairRebuild = "ledger_rebuild"
RepairReindex = "ledger_reindex"
RepairSnapshot = "ledger_snapshot"
RepairLag = "ledger_lag"
)
// The effects a tool carries (novox/hq ADR 0387 §1).
const (
EffectReads = "reads"
EffectActs = "acts"
)
// repairTools are a module's ledger repairs as tools, each limited to its own ledgers: the request's ledger is a local
// name of the module whose tool is called.
func (c *consumers) repairTools(module string) []launch.Tool {
schema := func(props string, required ...string) json.RawMessage {
req, _ := json.Marshal(append([]string{"ledger"}, required...))
return json.RawMessage(`{"type":"object","properties":{"ledger":{"type":"string","description":"the ledger's local name"}` +
props + `},"required":` + string(req) + `}`)
}
instance := `,"instance":{"type":"string","description":"one instance, such as decision/0290; every instance when empty"}`
run := func(do func(l *ledger.Ledger, args json.RawMessage) (any, error)) func(string, json.RawMessage) (json.RawMessage, error) {
return func(_ string, args json.RawMessage) (json.RawMessage, error) {
var named struct {
Ledger string `json:"ledger"`
}
if err := json.Unmarshal(args, &named); err != nil || named.Ledger == "" {
return nil, errors.New("a repair names the ledger, by its local name")
}
l, err := c.ledger(module, named.Ledger)
if err != nil {
return nil, err
}
answer, err := do(l, args)
if err != nil {
return nil, err
}
return json.Marshal(answer)
}
}
return []launch.Tool{
{Name: RepairRebuild, Effect: EffectActs, Input: schema(instance + `,"from":{"type":"string","enum":["history","snapshot"],"description":"derive from the whole history (the default) or from the snapshot and the operations after it"}`),
Description: "Derive a ledger instance's state again from its history or its snapshot, and write it and its snapshot (novox/hq ADR 0597 §10).",
Run: run(func(l *ledger.Ledger, args json.RawMessage) (any, error) {
var r buscalls.RebuildRequest
_ = json.Unmarshal(args, &r)
return l.Rebuild(r)
})},
{Name: RepairReindex, Effect: EffectActs, Input: schema(`,"index":{"type":"string","description":"the declared index"}`, "index"),
Description: "Build a ledger's declared index again from its states, derived from its history (novox/hq ADR 0597 §4, §10).",
Run: run(func(l *ledger.Ledger, args json.RawMessage) (any, error) {
var r buscalls.ReindexRequest
_ = json.Unmarshal(args, &r)
return l.Reindex(r)
})},
{Name: RepairSnapshot, Effect: EffectActs, Input: schema(instance),
Description: "Rewrite the snapshot of a ledger instance that is behind its state (novox/hq ADR 0597 §8, §10).",
Run: run(func(l *ledger.Ledger, args json.RawMessage) (any, error) {
var r buscalls.InstanceRequest
_ = json.Unmarshal(args, &r)
return l.Snapshot(r)
})},
{Name: RepairLag, Effect: EffectReads, Input: schema(""),
Description: "Say every ledger instance whose state or snapshot is behind its history, and each declared index behind it (novox/hq ADR 0597 §10).",
Run: run(func(l *ledger.Ledger, args json.RawMessage) (any, error) {
var r buscalls.LedgerRequest
_ = json.Unmarshal(args, &r)
return l.Lag(r)
})},
}
}
+181
View File
@@ -0,0 +1,181 @@
package runtime
import (
"context"
"encoding/json"
"errors"
"sort"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/buscalls"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/membership"
"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"
)
// 1966/AC16: given a module with a ledger binding, when the runner lists its tools, then the four repair tools are
// among them, marked acts or reads; a module without one has none, and a binding written later brings them.
func TestTheLedgerRepairsAreToolsOfEveryLedgerOwner(t *testing.T) {
mesh := mt.New(t)
owner := mt.MembershipOf("owner", "anchor", true, nil)
owner.Bindings = []membership.BusBinding{{Local: "records", Storage: catalogue.StorageLedger, Template: catalogue.TemplateLedger,
History: "LEDGER_owner_records", Subject: "mesh.ledger.owner.records.history", State: "owner_records",
Snapshot: "mesh.ledger.owner.records.snapshot"}}
plain := mt.MembershipOf("plain", "anchor", true, nil)
mesh.Issue(t, owner)
mesh.Issue(t, plain)
nodeTools := connect(t, "node-tools", "anchor")
stop, err := Run(nodeTools, []Served{{"owner", []string{mt.Fixture("caller-echo.py")}},
{"plain", []string{mt.Fixture("caller-echo.py")}}}, nil, func(string, ...any) {})
if err != nil {
t.Fatal(err)
}
defer stop()
asker := connect(t, "console", "workstation")
listed := func(module string) (string, error) {
answered, err := asker.Ask(module+"."+ToolsVerb, map[string]any{}, "")
if err != nil {
return "", err
}
var got ToolsAnswer
if err := json.Unmarshal(answered.Result, &got); err != nil {
return "", err
}
var names []string
for _, tool := range got.Tools {
names = append(names, tool.Name+":"+tool.Effect)
}
sort.Strings(names)
return strings.Join(names, " "), nil
}
for _, c := range []struct{ name, module, want string }{
{"a ledger owner lists the four repairs", "owner", "ledger_lag:reads ledger_rebuild:acts ledger_reindex:acts ledger_snapshot:acts who:"},
{"a module with no ledger has none", "plain", "who:"},
} {
t.Run(c.name, func(t *testing.T) {
var got string
mt.Until(t, func() (err error) { got, err = listed(c.module); return })
if got != c.want {
t.Fatalf("%s lists %q, want %q", c.module, got, c.want)
}
})
}
t.Run("a ledger bound later brings them", func(t *testing.T) {
plain.Bindings = owner.Bindings
mesh.Issue(t, plain)
mt.Until(t, func() error {
got, err := listed("plain")
if err == nil && !strings.Contains(got, "ledger_rebuild:acts") {
err = errors.New("listed " + got)
}
return err
})
})
}
// aLedger raises one ledger of module m on the bus as the bus module would create it, binds it in m's membership, and
// answers the runtime's buses for m and for other; its types travel on its binding, and a second binding carries none.
func aLedger(t *testing.T) (m, other *moduleBus, mesh *mt.Mesh) {
t.Helper()
mesh = 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()
for _, cfg := range []jetstream.StreamConfig{
{Name: "LEDGER_m_records", Subjects: []string{"mesh.ledger.m.records.history.>"}},
{Name: "SNAPSHOT_m_records", Subjects: []string{"mesh.ledger.m.records.snapshot.>"}, MaxMsgsPerSubject: 1},
} {
if _, err := js.CreateStream(ctx, cfg); err != nil {
t.Fatal(err)
}
}
if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "m_records"}); err != nil {
t.Fatal(err)
}
mem := mt.MembershipOf("m", "anchor", false, nil)
mem.Bindings = []membership.BusBinding{{Local: "records", Storage: catalogue.StorageLedger, Template: catalogue.TemplateLedger,
History: "LEDGER_m_records", Subject: "mesh.ledger.m.records.history", State: "m_records", Snapshot: "mesh.ledger.m.records.snapshot",
Types: []catalogue.LedgerType{{Name: "decision", Schema: map[string]catalogue.LedgerField{"title": {Type: "string"}},
Operations: []catalogue.LedgerOperation{{Name: "title", Effect: catalogue.EffectSetFields, Fields: []string{"title"}, Author: true}}}}},
{Local: "untyped", Storage: catalogue.StorageLedger, Template: catalogue.TemplateLedger, History: "LEDGER_m_records",
State: "m_records"},
{Local: "elsewhere", Storage: catalogue.StorageLedger, Template: catalogue.TemplateLedger, History: "LEDGER_m_records",
Subject: "mesh.ledger.m.records.history", State: "m_records", Types: []catalogue.LedgerType{{Name: "decision"}}}}
mesh.Issue(t, mem)
mesh.Issue(t, mt.MembershipOf("other", "anchor", false, nil))
conn := connect(t, "node-tools", "anchor")
conn.Follow("m")
conn.Follow("other")
mt.Until(t, func() error {
if conn.Membership("m") == nil || conn.Membership("other") == nil {
return errors.New("not issued yet")
}
return nil
})
all := &consumers{conn: conn, logf: t.Logf, of: map[string]*moduleEvents{}, ledgers: ledgerSource{
writer: func(string) (*bus.Conn, error) { return conn, nil },
}}
return all.forModule("m").(*moduleBus), all.forModule("other").(*moduleBus), mesh
}
// 1966/AC17, 1966/AC18: the author an apply is stamped with is the caller the runtime proved for the call in flight it
// names, whatever author the bundle wrote; another module's call, or one not in flight, is refused naming it (ADR 0597 §7).
func TestAnApplyIsStampedWithTheCallerTheRuntimeProved(t *testing.T) {
m, other, _ := aLedger(t)
call, end := m.Begin("op@shanks")
defer end()
theirs, endTheirs := other.Begin("op@shanks")
defer endTheirs()
forged := `"body":{"title":"T","author":{"account":"x","caller":"forged"}}`
for _, c := range []struct{ name, params, caller, refusal string }{
{"1966/AC17 a call in flight stamps its proven caller", `{"ledger":"records","instance":"decision/1","operation":"title",` + forged + `,"call":"` + call + `","author":"forged"}`, "op@shanks", ""},
{"naming no call stamps the account alone", `{"ledger":"records","instance":"decision/2","operation":"title",` + forged + `}`, "", ""},
{"1966/AC18 another module's call is refused, naming it", `{"ledger":"records","instance":"decision/3","operation":"title",` + forged + `,"call":"` + theirs + `"}`, "", "the call " + theirs + ", which is another module's"},
{"a ledger bound with no types is refused, naming it", `{"ledger":"untyped","instance":"decision/3","operation":"title",` + forged + `}`, "", `m's ledger "untyped" is bound with no types`},
{"a binding naming subjects its account is not granted is refused, naming both", `{"ledger":"elsewhere","instance":"decision/3","operation":"title",` + forged + `}`, "", "bound with the history subject mesh.ledger.m.records.history, and its ledger account is granted mesh.ledger.m.elsewhere.history alone"},
{"a call not in flight is refused, naming it", `{"ledger":"records","instance":"decision/3","operation":"title",` + forged + `,"call":"c0ffee"}`, "", "the call c0ffee, which is not a tool call in flight"},
} {
t.Run(c.name, func(t *testing.T) {
var req struct {
Instance string `json:"instance"`
}
_ = json.Unmarshal([]byte(c.params), &req)
_, err := m.BusState(buscalls.LedgerApply, json.RawMessage(c.params))
if c.refusal != "" {
if err == nil || !strings.Contains(err.Error(), c.refusal) {
t.Fatalf("applied with %v, want refused with %q", err, c.refusal)
}
return
}
if err != nil {
t.Fatal(err)
}
raw, err := m.BusState(buscalls.LedgerHistory, json.RawMessage(`{"ledger":"records","instance":"`+req.Instance+`"}`))
if err != nil {
t.Fatal(err)
}
var page buscalls.HistoryPage
_ = json.Unmarshal(raw, &page)
if len(page.Operations) != 1 || page.Operations[0].Author != (buscalls.Author{Account: "m", Caller: c.caller}) {
t.Fatalf("the history holds %+v, want the author {m %s}", page.Operations, c.caller)
}
state, _ := m.BusState(buscalls.LedgerGet, json.RawMessage(`{"ledger":"records","instance":"`+req.Instance+`"}`))
if !strings.Contains(string(state), `"author":{"account":"m"`) || strings.Contains(string(state), "forged") {
t.Fatalf("the state took the bundle's author: %s", state)
}
})
}
end()
if _, err := m.BusState(buscalls.LedgerApply, json.RawMessage(`{"ledger":"records","instance":"decision/4","operation":"title","call":"`+call+`"}`)); err == nil {
t.Fatal("a call that ended was still taken as in flight")
}
}
+78 -3
View File
@@ -52,6 +52,8 @@ type ListedTool struct {
Description string `json:"description"` Description string `json:"description"`
Input json.RawMessage `json:"input"` Input json.RawMessage `json:"input"`
Subjects []string `json:"subjects,omitempty"` Subjects []string `json:"subjects,omitempty"`
// Effect is the tool's mark where the runtime knows it (novox/hq ADR 0387 §1): its own tools, the ledger repairs.
Effect string `json:"effect,omitempty"`
} }
// NodeRuntimeModule is the module that is a machine's runtime, serving every module on it. // NodeRuntimeModule is the module that is a machine's runtime, serving every module on it.
@@ -59,7 +61,7 @@ const NodeRuntimeModule = "node-tools"
// runtimeOnly are the words of the runtime's own environment no bundle is given. // 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, var runtimeOnly = map[string]bool{ToolEnv: true, ToolModules: true, "MESH_BROKER_FILE": true,
"MESH_BROKER_URL": true, "MESH_CONSOLE_LISTEN": true} "MESH_BROKER_URL": true, "MESH_CONSOLE_LISTEN": true, LedgersFile: true}
// ServedModulesFrom reads MESH_TOOL_MODULES: `<module>=<entrypoint>` entries, comma-separated, several // ServedModulesFrom reads MESH_TOOL_MODULES: `<module>=<entrypoint>` entries, comma-separated, several
// per module. The one-module form — a bare path, or the runtime's own module — is the per-module // per module. The one-module form — a bare path, or the runtime's own module — is the per-module
@@ -133,6 +135,60 @@ type registration struct {
module string // the module's own name, or a seat's module string // the module's own name, or a seat's
owner string // the module whose bundle made it owner string // the module whose bundle made it
tools []launch.Tool tools []launch.Tool
// repairs says it is the runtime's own ledger repairs for the module, not a bundle's (ledgers.go).
repairs bool
}
// withRepairs is regs with the module's ledger repairs as its binding now says: added when it binds a ledger, removed
// when it binds none.
func withRepairs(regs []registration, repairs *registration) []registration {
out := make([]registration, 0, len(regs)+1)
for _, r := range regs {
if !r.repairs {
out = append(out, r)
}
}
if repairs != nil {
out = append(out, *repairs)
}
return out
}
// repairsOf is a module's ledger repairs as a registration, nil when its membership binds no ledger.
func (r *runner) repairsOf(module string) *registration {
if !hasLedger(r.conn, module) {
return nil
}
return &registration{module: module, owner: module, tools: r.events.repairTools(module), repairs: true}
}
// refreshRepairs serves each module's ledger repairs as its membership binds a ledger now, re-serving its tools where
// that changed (a binding is written when the bus module created the ledger, design 25 § 12.2).
func (r *runner) refreshRepairs() {
r.mu.Lock()
defer r.mu.Unlock()
changed := false
for _, m := range r.modules {
l := r.of[m]
served := false
for _, reg := range l.own {
served = served || reg.repairs
}
repairs := r.repairsOf(m)
if served == (repairs != nil) {
continue
}
for i := len(l.serving) - 1; i >= 0; i-- {
l.serving[i]()
}
l.serving, l.names = nil, nil
l.own, l.regs = withRepairs(l.own, repairs), withRepairs(l.regs, repairs)
r.serve(l)
changed = true
}
if changed {
r.bindVerbs()
}
} }
// loadedModule is one served module as its bundles were last loaded: what they registered, what of it // loadedModule is one served module as its bundles were last loaded: what they registered, what of it
@@ -178,9 +234,17 @@ type runner struct {
stops []func() // the watch, the announcement and the seats, in the order they began stops []func() // the watch, the announcement and the seats, in the order they began
} }
// Option sets how Run serves, beside what it is given.
type Option func(*consumers)
// WithLedgerWriters gives the runtime the connection each module's ledger history is published on (ledger_credentials.go).
func WithLedgerWriters(writer func(module string) (*bus.Conn, error)) Option {
return func(c *consumers) { c.ledgers.writer = writer }
}
// Run launches, binds and serves, and serves each module's bundle again when it appears, changes or // Run launches, binds and serves, and serves each module's bundle again when it appears, changes or
// goes, with no restart. It answers a stop function. // goes, with no restart. It answers a stop function.
func Run(conn *bus.Conn, served []Served, envs map[string]map[string]string, logf func(string, ...any)) (func(), error) { func Run(conn *bus.Conn, served []Served, envs map[string]map[string]string, logf func(string, ...any), opts ...Option) (func(), error) {
if account := os.Getenv(OperatorAccount); account != "" { if account := os.Getenv(OperatorAccount); account != "" {
home := "" home := ""
if h := os.Getenv(OperatorHome); h != "" { if h := os.Getenv(OperatorHome); h != "" {
@@ -203,6 +267,9 @@ func Run(conn *bus.Conn, served []Served, envs map[string]map[string]string, log
// The module's events, for every child of it that subscribes (ADR 0198): one consumer per module, // The module's events, for every child of it that subscribes (ADR 0198): one consumer per module,
// bound the first time any of its children subscribes, each event handed to every child that did. // bound the first time any of its children subscribes, each event handed to every child that did.
r.events = &consumers{conn: conn, logf: logf, of: map[string]*moduleEvents{}} r.events = &consumers{conn: conn, logf: logf, of: map[string]*moduleEvents{}}
for _, opt := range opts {
opt(r.events)
}
for _, s := range served { for _, s := range served {
r.of[s.Module] = r.load(s) r.of[s.Module] = r.load(s)
@@ -230,6 +297,7 @@ func Run(conn *bus.Conn, served []Served, envs map[string]map[string]string, log
line += fmt.Sprintf("; not serving %s, whose bundle(s) failed to load", strings.Join(failedNames, ", ")) line += fmt.Sprintf("; not serving %s, whose bundle(s) failed to load", strings.Join(failedNames, ", "))
} }
logf("%s", line) logf("%s", line)
conn.OnMembership(func(bus.Membership) { go func() { r.refreshRepairs(); conn.Flush() }() })
stopSeats, again := serveSeats(conn, r.modules, r.registrations, logf) stopSeats, again := serveSeats(conn, r.modules, r.registrations, logf)
r.seatsAgain = again r.seatsAgain = again
r.stops = append(r.stops, stopSeats) r.stops = append(r.stops, stopSeats)
@@ -341,6 +409,9 @@ func (r *runner) load(s Served) *loadedModule {
} }
} }
} }
if repairs := r.repairsOf(s.Module); repairs != nil {
l.regs, l.own = append(l.regs, *repairs), append(l.own, *repairs)
}
return l return l
} }
@@ -411,7 +482,7 @@ func (r *runner) bindVerbs() {
answer := ToolsAnswer{Module: module, Tools: []ListedTool{}, Failed: why} answer := ToolsAnswer{Module: module, Tools: []ListedTool{}, Failed: why}
for _, t := range tools { for _, t := range tools {
answer.Tools = append(answer.Tools, ListedTool{Name: t.Name, Description: t.Description, answer.Tools = append(answer.Tools, ListedTool{Name: t.Name, Description: t.Description,
Input: t.Input, Subjects: subjectsOf(r.conn, module, t.Name)}) Input: t.Input, Subjects: subjectsOf(r.conn, module, t.Name), Effect: t.Effect})
} }
return answer, nil return answer, nil
}) })
@@ -682,6 +753,10 @@ type consumers struct {
seatBound map[string]func() seatBound map[string]func()
// provisioned is every watch a module's code holds of its values at runtime (provision.go). // provisioned is every watch a module's code holds of its values at runtime (provision.go).
provisioned provisionWatches provisioned provisionWatches
// ledgers is how a module's ledger is opened (ledgers.go).
ledgers ledgerSource
// calls are the tool calls in flight, by the id each bundle was handed (ledgers.go, novox/hq ADR 0597 §7).
calls callsInFlight
} }
type moduleEvents struct { type moduleEvents struct {
+3 -2
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# A tools bundle that says who the runtime said called it (novox/hq issue 365): MCP over stdio, no SDK. Its tool # A tools bundle that says who the runtime said called it (novox/hq issue 365): MCP over stdio, no SDK. Its tool
# and its seat verb answer the caller the call's `_meta` named — "" when it named none — and the arguments as given. # and its seat verb answer the caller the call's `_meta` named — "" when it named none —, the id of the call in flight
# it named (novox/hq ADR 0597 §7), and the arguments as given.
import json, sys import json, sys
def say(m): def say(m):
@@ -18,7 +19,7 @@ for line in sys.stdin:
say({"id": rid, "result": {"tools": TOOLS}}) say({"id": rid, "result": {"tools": TOOLS}})
elif m == "tools/call": elif m == "tools/call":
meta = p.get("_meta") or {} meta = p.get("_meta") or {}
said = {"caller": meta.get("mesh/caller", ""), "arguments": p.get("arguments") or {}} said = {"caller": meta.get("mesh/caller", ""), "call": meta.get("mesh/call", ""), "arguments": p.get("arguments") or {}}
say({"id": rid, "result": {"content": [{"type": "text", "text": json.dumps(said)}]}}) say({"id": rid, "result": {"content": [{"type": "text", "text": json.dumps(said)}]}})
elif rid is not None: elif rid is not None:
say({"id": rid, "result": {}}) say({"id": rid, "result": {}})