Files
mesh-catalog/modules/nats/internal/keeper/plan.go
T
jschoubben bf60d6a8bb NATS3 (rest): the bus module plans from the catalog half of every watched branch, not from manifest-accepted
The keeper follows meshcatalogclient's branch follower and the seat list on its views reader, and plans again at each
move of a branch's view, the registrations or the seat list. Each local name is planned from the first of the module's
watched branches in its registration's order; another branch asking it with other settings is said drifted, naming
the branch. A module of the new form whose resolved seat definition has a verb or an event gets its sent store; one
including only parts, and a foundation part, none. A module in no watched branch's newest revision loses its plans
key, its entries orphans. The sum of the caps counts the old sections the entries declare. The manifest consumes the
catalog half's and the seat list's events and invokes their verbs; manifest-accepted and indexer_bus_state go.

Builds: design 47 G §344, §433, §532, §533, §537, §538, §547, §591

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 05:45:49 +01:00

419 lines
17 KiB
Go

// Package keeper is the bus module's keeper (novox/hq design 25 § 12.2-12.3, ADR 0595 §4-§6): it creates the bus state
// every accepted manifest asks for in `requires`, compares what the bus holds with what was asked, finds orphans and
// reads the fill of what refuses when full, and says each on the bus module's own events. It reads nothing of another
// part's state: what to create comes from the catalog half of each watched branch (design 47 G §433, §532). Everything it touches it reaches through the interfaces in keeper.go; this file is pure.
package keeper
import (
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/membership"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
// The kinds of bus object an entry is made of: a stream, a key-value bucket (stream KV_<name>) and an object store
// (stream OBJ_<name>).
const (
KindStream = "stream"
KindBucket = "key-value"
KindObjects = "object-store"
)
// Separator joins a module's name and a local name into a bus name (design 25 § 12.1: `<module>_<local>`); a local
// name holding it is refused, so no two modules' names meet.
const Separator = "_"
// Fixed settings of the templates (design 25 § 12.1's table): never options.
const (
MiB = int64(1) << 20
StateCap = 64 * MiB
ValueMost = 256 << 10
EntryMost = 256 << 10
DefaultCapMiB = 1024
EventsDays = 7
)
// The sent store of a module that sends (design 47 G §59, §477): the local name its bus name `<module>_sent-objects` is made
// of, its cap and the age after which the bus expires an object in it.
const (
SentLocal = "sent-objects"
SentCapMiB = 256
SentAgeDays = 7
)
// StateObjectsCapMiB is the cap of a state's object store `<state>-objects` unless the state names `objects-max-mib`
// (design 47 G §591, §547).
const StateObjectsCapMiB = 16
// Sends says whether a module sends (design 47 G §344): it claims a seat whose definition has a verb or an event, or it
// declares an event of its own (emitsOwn). claims are the seats its catalog graph entry claims, defs the seat list's
// definitions by seat, each as the seat list answers that one seat, resolved with the parts it includes; a claimed seat
// the list does not hold counts for nothing, and including parts alone counts for nothing (design 47 G §537).
func Sends(claims []string, emitsOwn bool, defs map[string]seatdef.Definition) bool {
if emitsOwn {
return true
}
for _, c := range claims {
if d, ok := defs[c]; ok && (len(d.Verbs) > 0 || len(d.Events) > 0) {
return true
}
}
return false
}
// Settings are one bus object's settings, as asked and as held: what check compares.
type Settings struct {
// MaxBytes is its cap in bytes; MaxAgeSeconds its age, 0 for none.
MaxBytes int64 `json:"max-bytes"`
MaxAgeSeconds int64 `json:"max-age-seconds,omitempty"`
// History is how many values a key keeps (a bucket's), or how many messages a subject keeps (a snapshot's); 0 none.
History int64 `json:"history,omitempty"`
// Discard is what it does when full: "new" (refuses) or "old" (drops the oldest).
Discard string `json:"discard"`
// WorkQueue says a message is removed once taken.
WorkQueue bool `json:"work-queue,omitempty"`
// MaxMsgSize is the largest message (a value, a log entry), 0 none.
MaxMsgSize int64 `json:"max-msg-size,omitempty"`
// DuplicatesSeconds is a work queue's duplicate window.
DuplicatesSeconds int64 `json:"duplicates-seconds,omitempty"`
// KeyTTL says a bucket's key may be given a lifetime of its own.
KeyTTL bool `json:"key-ttl,omitempty"`
}
// The words of Discard.
const (
DiscardNew = "new"
DiscardOld = "old"
)
// Refuses says the object refuses a new message when full: the ones the fill pass reads (ADR 0595 §6).
func (s Settings) Refuses() bool { return s.Discard == DiscardNew }
// Object is one bus object an entry is made of: its kind, its name as the kind names it (a bucket by its bucket name,
// a stream by its stream name), its subjects for a stream, and its settings.
type Object struct {
Kind string `json:"kind"`
Name string `json:"name"`
Subjects []string `json:"subjects,omitempty"`
Settings Settings `json:"settings"`
}
// Stream is the stream the bus keeps the object on: KV_<bucket>, OBJ_<store>, or the stream itself.
func (o Object) Stream() string {
switch o.Kind {
case KindBucket:
return "KV_" + o.Name
case KindObjects:
return "OBJ_" + o.Name
}
return o.Name
}
// Plan is what one entry is on the bus: its binding (the names the module receives) and the objects it is made of.
type Plan struct {
Module string `json:"module"`
Local string `json:"local"`
Storage string `json:"storage"`
Template string `json:"template,omitempty"`
Binding membership.BusBinding `json:"binding"`
Objects []Object `json:"objects"`
Entry catalogue.BusStateEntry `json:"entry"`
}
// BusName is `<module>_<local>`, the name the mesh gives an entry on the bus (design 25 § 12.1).
func BusName(module, local string) string { return module + Separator + local }
// StreamSubject is the subject a stream entry's messages are under (`mesh.bus.<module>.<local>`, messages on its `.>`).
func StreamSubject(module, local string) string { return "mesh.bus." + module + "." + local }
// PlanOf is the plan of one entry of module, or why it cannot be made: a local name holding the separator, a storage
// type and template that do not go together, an option of the wrong shape. The judge refuses each of these first
// (ADR 0595 §8); this is the keeper's own guard, so a manifest judged by an older judge is refused, never half-made.
func PlanOf(module string, e catalogue.BusStateEntry) (Plan, error) {
if e.Name == SentLocal {
return Plan{}, fmt.Errorf("the local name %q is the bus module's own for the sent store %s (design 47 G §59)", e.Name, BusName(module, SentLocal))
}
if e.Name == "" || strings.Contains(e.Name, Separator) || strings.ContainsAny(e.Name, ".*> ") {
return Plan{}, fmt.Errorf("the local name %q is empty or holds %q, a dot, a wildcard or a space: the mesh names it %s on the bus, and two modules' names must never meet", e.Name, Separator, BusName(module, "<local>"))
}
name := BusName(module, e.Name)
p := Plan{Module: module, Local: e.Name, Storage: e.Storage, Template: e.Template, Entry: e,
Binding: membership.BusBinding{Local: e.Name, Storage: e.Storage, Template: e.Template}}
opts := options{raw: e.Options}
conf := options{raw: e.Configuration}
streamOf := func(s Settings) Object {
subject := StreamSubject(module, e.Name)
p.Binding.Stream, p.Binding.Subject = name, subject
return Object{Kind: KindStream, Name: name, Subjects: []string{subject + ".>"}, Settings: s}
}
switch {
case e.Storage == catalogue.StorageKeyValue && e.Template == catalogue.TemplateState:
s, err := stateSettings(opts)
if err != nil {
return Plan{}, err
}
// Beside each state its object store `<state>-objects`, which the grants part grants to the state's owner alone (design
// 47 G §58, §591): 16 MiB unless the state's requires names `objects-max-mib`.
objCap, err := opts.int(catalogue.OptionObjectsCapMiB, StateObjectsCapMiB)
if err != nil {
return Plan{}, err
}
if objCap <= 0 {
return Plan{}, fmt.Errorf("the option %s of %q is %d, not a size above 0", catalogue.OptionObjectsCapMiB, e.Name, objCap)
}
objects := BusName(module, catalogue.StateObjectsName(e.Name))
p.Binding.Bucket, p.Binding.Objects = name, objects
p.Objects = []Object{{Kind: KindBucket, Name: name, Settings: s},
{Kind: KindObjects, Name: objects, Settings: Settings{MaxBytes: objCap * MiB, Discard: DiscardNew}}}
case e.Storage == catalogue.StorageStream && e.Template == catalogue.TemplateEvents:
age, err := opts.int(catalogue.OptionAgeDays, EventsDays)
if err != nil {
return Plan{}, err
}
capMiB, err := opts.int(catalogue.OptionCapMiB, DefaultCapMiB)
if err != nil {
return Plan{}, err
}
p.Objects = []Object{streamOf(Settings{MaxBytes: capMiB * MiB, MaxAgeSeconds: days(age), Discard: DiscardOld})}
case e.Storage == catalogue.StorageStream && e.Template == catalogue.TemplateWorkQueue:
capMiB, err := opts.int(catalogue.OptionCapMiB, DefaultCapMiB)
if err != nil {
return Plan{}, err
}
age, err := opts.int(catalogue.OptionAgeDays, 0)
if err != nil {
return Plan{}, err
}
dup, err := opts.int(catalogue.OptionDuplicates, 0)
if err != nil {
return Plan{}, err
}
p.Objects = []Object{streamOf(Settings{MaxBytes: capMiB * MiB, MaxAgeSeconds: days(age), Discard: DiscardNew,
WorkQueue: true, DuplicatesSeconds: dup})}
case e.Storage == catalogue.StorageStream && e.Template == catalogue.TemplateLog:
s, err := logSettings(opts)
if err != nil {
return Plan{}, err
}
p.Objects = []Object{streamOf(s)}
case e.Storage == catalogue.StorageObjectStore && e.Template == catalogue.TemplateObjects:
capMiB, err := opts.int(catalogue.OptionCapMiB, 0)
if err != nil {
return Plan{}, err
}
if capMiB <= 0 {
return Plan{}, fmt.Errorf("objects %q gives no %s, which the template objects requires", e.Name, catalogue.OptionCapMiB)
}
p.Binding.ObjectStore = name
p.Objects = []Object{{Kind: KindObjects, Name: name, Settings: Settings{MaxBytes: capMiB * MiB, Discard: DiscardNew}}}
case e.Storage == catalogue.StorageLedger && e.Template == catalogue.TemplateLedger:
history, err := logSettings(opts)
if err != nil {
return Plan{}, err
}
// Keeping days only in a ledger's own configuration (design 25 § 12.1).
age, err := conf.int(catalogue.OptionAgeDays, 0)
if err != nil {
return Plan{}, err
}
history.MaxAgeSeconds = days(age)
state, err := stateSettings(options{})
if err != nil {
return Plan{}, err
}
subject := buscompose.LedgerHistorySubject(module, e.Name)
snapshot := buscompose.LedgerSnapshotSubject(module, e.Name)
p.Binding.History, p.Binding.Subject = name, subject
p.Binding.State = name + "_state"
p.Binding.Snapshot = snapshot
p.Binding.Types = e.Types
p.Objects = []Object{
{Kind: KindStream, Name: name, Subjects: []string{subject + ".>"}, Settings: history},
{Kind: KindBucket, Name: p.Binding.State, Settings: state},
}
var indexes []string
for _, t := range e.Types {
for _, ix := range t.Indexes {
indexes = append(indexes, ix.Name)
}
}
sort.Strings(indexes)
for _, ix := range indexes {
if p.Binding.Indexes == nil {
p.Binding.Indexes = map[string]string{}
}
if _, twice := p.Binding.Indexes[ix]; twice {
continue
}
p.Binding.Indexes[ix] = name + "_index-" + ix
p.Objects = append(p.Objects, Object{Kind: KindBucket, Name: p.Binding.Indexes[ix], Settings: state})
}
// The snapshot: the latest state per instance, never expiring (ADR 0597 §8), one message per subject.
p.Objects = append(p.Objects, Object{Kind: KindStream, Name: name + "_snapshot", Subjects: []string{snapshot + ".>"},
Settings: Settings{MaxBytes: history.MaxBytes, History: 1, Discard: DiscardOld}})
case e.Storage == catalogue.StorageStream && e.Template == "":
// A bare stream: its own configuration, which must give when full, a cap and an age (design 25 § 12.1).
var full string
if raw, ok := conf.raw[catalogue.ConfigWhenFull]; ok {
_ = json.Unmarshal(raw, &full)
}
capMiB, err := conf.int(catalogue.OptionCapMiB, 0)
if err != nil {
return Plan{}, err
}
age, err := conf.ageDays(-1)
if err != nil {
return Plan{}, err
}
discard := map[string]string{catalogue.WhenFullRefuse: DiscardNew, catalogue.WhenFullDropOldest: DiscardOld}[full]
if discard == "" || capMiB <= 0 || age < 0 {
return Plan{}, fmt.Errorf("the bare stream %q must give %s (refuse or drop-oldest), %s and %s in its configuration", e.Name,
catalogue.ConfigWhenFull, catalogue.OptionCapMiB, catalogue.OptionAgeDays)
}
p.Objects = []Object{streamOf(Settings{MaxBytes: capMiB * MiB, MaxAgeSeconds: days(age), Discard: discard})}
default:
return Plan{}, fmt.Errorf("the storage type %q with the template %q is none the bus module makes (design 25 § 12.1)", e.Storage, e.Template)
}
return p, nil
}
// SentPlanOf is the plan of module's sent store (§59): an object store `<module>_sent-objects` of 256 MiB (§477) that
// refuses when full and keeps an object 7 days, after which the bus expires it. It is transport and not kept data, so
// its expiry is no removal that needs a person's word. It is made with no manifest field (§344).
func SentPlanOf(module string) Plan {
name := BusName(module, SentLocal)
e := catalogue.BusStateEntry{Name: SentLocal, Storage: catalogue.StorageObjectStore, Template: catalogue.TemplateObjects,
Options: map[string]json.RawMessage{catalogue.OptionCapMiB: json.RawMessage(strconv.Itoa(SentCapMiB))}}
return Plan{Module: module, Local: SentLocal, Storage: e.Storage, Template: e.Template, Entry: e,
Binding: membership.BusBinding{Local: SentLocal, Storage: e.Storage, Template: e.Template, ObjectStore: name},
Objects: []Object{{Kind: KindObjects, Name: name, Settings: Settings{MaxBytes: SentCapMiB * MiB, MaxAgeSeconds: days(SentAgeDays), Discard: DiscardNew}}}}
}
func stateSettings(o options) (Settings, error) {
history, err := o.int(catalogue.OptionPastValues, 1)
if err != nil {
return Settings{}, err
}
ttl, err := o.int(catalogue.OptionTimeToLive, 0)
if err != nil {
return Settings{}, err
}
keyTTL, err := o.bool(catalogue.OptionLifetimePerKey)
if err != nil {
return Settings{}, err
}
if perMachine, err := o.bool(catalogue.OptionPerMachine); err != nil || perMachine {
if err != nil {
return Settings{}, err
}
return Settings{}, fmt.Errorf("the option %s (one bucket per machine) is not made by the bus module yet: the module keeps one bucket", catalogue.OptionPerMachine)
}
return Settings{MaxBytes: StateCap, MaxAgeSeconds: ttl, History: history, Discard: DiscardNew, MaxMsgSize: ValueMost, KeyTTL: keyTTL}, nil
}
func logSettings(o options) (Settings, error) {
capMiB, err := o.int(catalogue.OptionCapMiB, DefaultCapMiB)
if err != nil {
return Settings{}, err
}
age, err := o.int(catalogue.OptionAgeDays, 0)
if err != nil {
return Settings{}, err
}
return Settings{MaxBytes: capMiB * MiB, MaxAgeSeconds: days(age), Discard: DiscardNew, MaxMsgSize: EntryMost}, nil
}
func days(n int64) int64 {
if n <= 0 {
return 0
}
return int64((time.Duration(n) * 24 * time.Hour) / time.Second)
}
// noAge is how a bare stream says it keeps its messages for good, as the indexer's judge accepts it
// (mesh-delivery modules/mesh-delivery-indexer/judge/busstate.go noAge): kept as no age limit.
const noAge = "none"
// ageDays is a bare stream's max-age-days: a whole number of days, or "none" for no limit (0).
func (o options) ageDays(fallback int64) (int64, error) {
if raw, ok := o.raw[catalogue.OptionAgeDays]; ok {
var word string
if json.Unmarshal(raw, &word) == nil && word == noAge {
return 0, nil
}
}
return o.int(catalogue.OptionAgeDays, fallback)
}
// options reads a template's options as the manifest wrote them.
type options struct{ raw map[string]json.RawMessage }
func (o options) int(name string, fallback int64) (int64, error) {
raw, ok := o.raw[name]
if !ok {
return fallback, nil
}
n, err := strconv.ParseInt(strings.TrimSpace(string(raw)), 10, 64)
if err != nil {
return 0, fmt.Errorf("the option %s is %s, not a whole number", name, raw)
}
return n, nil
}
func (o options) bool(name string) (bool, error) {
raw, ok := o.raw[name]
if !ok {
return false, nil
}
var b bool
if err := json.Unmarshal(raw, &b); err != nil {
return false, fmt.Errorf("the option %s is %s, not true or false", name, raw)
}
return b, nil
}
// Grows is whether held reaches asked by growing alone (a larger cap, a longer age, more past values), so the keeper
// applies it without a person (ADR 0595 §6); false for any other difference, which is a drift a person decides. Equal
// settings grow by nothing and are no difference (Differs).
func Grows(asked, held Settings) bool {
if asked.Discard != held.Discard || asked.WorkQueue != held.WorkQueue || asked.MaxMsgSize != held.MaxMsgSize ||
asked.DuplicatesSeconds != held.DuplicatesSeconds || asked.KeyTTL != held.KeyTTL {
return false
}
return asked.MaxBytes >= held.MaxBytes && longer(asked.MaxAgeSeconds, held.MaxAgeSeconds) && asked.History >= held.History
}
// longer is whether age a keeps at least as long as b, 0 being for ever.
func longer(a, b int64) bool {
if a == 0 {
return true
}
return b != 0 && a >= b
}
// Differs says how held differs from asked, field by field, or "" when it does not.
func Differs(asked, held Settings) string {
var out []string
add := func(name string, a, h any) {
if fmt.Sprint(a) != fmt.Sprint(h) {
out = append(out, fmt.Sprintf("%s asked %v, held %v", name, a, h))
}
}
add("max-bytes", asked.MaxBytes, held.MaxBytes)
add("max-age-seconds", asked.MaxAgeSeconds, held.MaxAgeSeconds)
add("history", asked.History, held.History)
add("discard", asked.Discard, held.Discard)
add("work-queue", asked.WorkQueue, held.WorkQueue)
add("max-msg-size", asked.MaxMsgSize, held.MaxMsgSize)
add("duplicates-seconds", asked.DuplicatesSeconds, held.DuplicatesSeconds)
add("key-ttl", asked.KeyTTL, held.KeyTTL)
return strings.Join(out, "; ")
}