Serve a ledger owner its repairs rebuild, reindex, snapshot and lag as tools, its own ledgers only, marked acts or reads (issue 1966 T4)

This commit is contained in:
2026-11-03 13:46:54 +01:00
parent 63c1e3faa2
commit 8fe7ef3b82
6 changed files with 656 additions and 3 deletions
+146
View File
@@ -0,0 +1,146 @@
package runtime
import (
"encoding/json"
"errors"
"fmt"
"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.
// LedgerTypes is where the runner reads a ledger's types, by module and local name. Where it reads them from on the
// live mesh is not decided (issue 1966: the binding carries no types, and no record names a source); until it is,
// the runner refuses to open a ledger and says why, and a test gives the types.
type LedgerTypes func(module, local string) ([]catalogue.LedgerType, error)
// errTypesUndecided is the refusal of a ledger whose types the runner has no source for.
func errTypesUndecided(module, local string) error {
return fmt.Errorf("the tool runner cannot open %s's ledger %q: it reads a ledger's types from nowhere yet, since its "+
"binding carries none and no record names their source (issue 1966)", module, local)
}
// ledgerSource is how the runner opens a module's ledger: its types, and the connection its history and snapshots are
// published on (the module's ledger credential, ADR 0597 §9).
type ledgerSource struct {
types LedgerTypes
writer func(module string) (*bus.Conn, error)
}
// 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
}
types := c.ledgers.types
if types == nil {
return nil, errTypesUndecided(module, local)
}
ts, err := types(module, local)
if err != nil {
return nil, err
}
var writer *bus.Conn
if c.ledgers.writer != nil {
if writer, err = c.ledgers.writer(module); err != nil {
return nil, err
}
}
history, state, snapshots := c.conn.LedgerStores(b, writer)
return &ledger.Ledger{Module: module, Name: local, Types: ts, History: history, State: state, Snapshots: snapshots,
Indexes: c.conn.LedgerIndexes(b), Logf: c.logf}, nil
}
// 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)
})},
}
}
@@ -0,0 +1,74 @@
package runtime
import (
"encoding/json"
"errors"
"sort"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/membership"
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", State: "owner_records",
Snapshot: "mesh.ledger-snapshot.owner.records"}}
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
})
})
}
+63 -1
View File
@@ -52,6 +52,8 @@ type ListedTool struct {
Description string `json:"description"`
Input json.RawMessage `json:"input"`
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.
@@ -133,6 +135,60 @@ type registration struct {
module string // the module's own name, or a seat's
owner string // the module whose bundle made it
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
@@ -230,6 +286,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, ", "))
}
logf("%s", line)
conn.OnMembership(func(bus.Membership) { go func() { r.refreshRepairs(); conn.Flush() }() })
stopSeats, again := serveSeats(conn, r.modules, r.registrations, logf)
r.seatsAgain = again
r.stops = append(r.stops, stopSeats)
@@ -341,6 +398,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
}
@@ -411,7 +471,7 @@ func (r *runner) bindVerbs() {
answer := ToolsAnswer{Module: module, Tools: []ListedTool{}, Failed: why}
for _, t := range tools {
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
})
@@ -682,6 +742,8 @@ type consumers struct {
seatBound map[string]func()
// provisioned is every watch a module's code holds of its values at runtime (provision.go).
provisioned provisionWatches
// ledgers is how a module's ledger is opened (ledgers.go).
ledgers ledgerSource
}
type moduleEvents struct {