From 2f742fd3feb345c2e71a6d4f3388f8b9b71b282b Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 25 Oct 2026 21:05:39 +0100 Subject: [PATCH 1/5] Put a version of a module on a node with one verb: the assigner's seat put, release and puts, held until released, the bus on the operator's warrant after a snapshot (ADR 0546) --- .../cmd/mesh-delivery-assigner/main.go | 47 +- .../cmd/mesh-delivery-assigner/pass.go | 6 +- .../cmd/mesh-delivery-assigner/pass_test.go | 203 ++++++++ .../cmd/mesh-delivery-assigner/puts.go | 440 ++++++++++++++++++ .../cmd/mesh-delivery-assigner/seat.go | 329 +++++++++++++ modules/mesh-delivery-assigner/go.mod | 2 +- modules/mesh-delivery-assigner/go.sum | 2 + .../internal/assign/asks_test.go | 26 ++ .../internal/assign/put.go | 239 ++++++++++ .../internal/assign/wants.go | 34 +- .../internal/assign/wants_test.go | 135 ++++++ .../mesh-delivery-assigner/manifest_test.go | 20 +- modules/mesh-delivery-assigner/module.json | 26 +- modules/mesh-delivery-teller/go.mod | 2 +- modules/mesh-delivery-teller/go.sum | 2 + 15 files changed, 1485 insertions(+), 28 deletions(-) create mode 100644 modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/puts.go create mode 100644 modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go create mode 100644 modules/mesh-delivery-assigner/internal/assign/put.go diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go index 54a5dc1..094b987 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go @@ -29,6 +29,15 @@ import ( ) func main() { + if os.Getenv("MESH_BROKER_FILE") == "" { + // Started by the tool runner, with no credential of its own: the seat's verbs (ADR 0546), served as every seat a module + // holds is, since a process never serves (ADR 0436). The process the manifest runs names its credential. + if err := serveSeat(); err != nil { + fmt.Fprintln(os.Stderr, "mesh-delivery-assigner:", err) + os.Exit(1) + } + return + } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() if err := run(ctx, os.Getenv, os.Stderr); err != nil { @@ -59,15 +68,17 @@ var readBuckets = []string{ const goBuildModule = "mesh-build-go" // ownBuckets are the assigner's own states, as its manifest declares them. -var ownBuckets = []string{delivery.AssignerAsksBucket, delivery.AssignerCompositionsBucket, delivery.AssignerDifferencesBucket, delivery.AssignerWantsBucket} +var ownBuckets = []string{delivery.AssignerAsksBucket, delivery.AssignerCompositionsBucket, delivery.AssignerDifferencesBucket, + delivery.AssignerPutsBucket, delivery.AssignerWantsBucket} // keptStates is what the assigner keeps and who may read it (ADR 0495 §2-§3): its wants by the watchman and the view // (and the node agent from release 6), its differences by the view, its compositions by the checker, its asks by the view -// and the teller. +// and the teller, its puts by whoever follows one (ADR 0546). var keptStates = []announce.State{ {Name: "asks", Readable: true}, {Name: "compositions", Readable: true}, {Name: "differences", Readable: true}, + {Name: "puts", Readable: true}, {Name: "wants", Readable: true}, } @@ -80,6 +91,11 @@ func open(ctx context.Context, js jetstream.JetStream) (buckets, error) { out := buckets{} for _, name := range append(append([]string{}, readBuckets...), ownBuckets...) { kv, err := js.KeyValue(ctx, name) + if name == delivery.AssignerPutsBucket && errors.Is(err, jetstream.ErrBucketNotFound) { + // The puts (ADR 0546) are taken further only once their bucket exists: an assigner never stops writing wants + // for want of it. The verb's first put is refused by the runtime until then. + continue + } if err != nil { return nil, fmt.Errorf("the bucket %s cannot be opened: %w", name, err) } @@ -122,6 +138,9 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro if err != nil { return err } + if kvs[delivery.AssignerPutsBucket] == nil { + logger.Printf("the bucket %s does not exist yet, so no put is taken further until the assigner starts again", delivery.AssignerPutsBucket) + } // What the assigner keeps, said on the bus (ADR 0495 §2-§3): a reader is granted from it. Not fatal when it cannot // be said now: process.Conn says it again at every reconnect. said, err := process.Connect(account, nats.Name(account.Node+"."+account.Module+".keeps"), nats.RetryOnFailedConnect(true)) @@ -133,9 +152,14 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro logger.Printf("what the assigner keeps could not be said on the bus, so its readers are not granted until it is: %v", err) } logger.Printf("started on the account %s, reading %d buckets", account.User, len(kvs)) - return follow(ctx, js, kvs, sayVia(ctx, js), logger) + return follow(ctx, js, kvs, sayVia(ctx, js), emitting{said}, logger) } +// emitting is the process connection as the puts' caller: its calls, and its events with no options. +type emitting struct{ *process.Conn } + +func (e emitting) Emit(eventType string, body any) error { return e.Conn.Emit(eventType, body) } + // watched are the buckets whose every value a pass reads; the manifests are read by digest when a pass needs one, and // watched only for keys, so a manifest that arrives wakes a composition that waits for it. var watched = []string{ @@ -156,7 +180,7 @@ var watched = []string{ // follow watches every bucket a pass reads and makes a pass at start, once every watch handed what stands, and after // each change; changes that come during a pass make one more. It returns when ctx ends or a watch ends. -func follow(ctx context.Context, js jetstream.JetStream, kvs buckets, say func(asks.Condition) error, logger *log.Logger) error { +func follow(ctx context.Context, js jetstream.JetStream, kvs buckets, say func(asks.Condition) error, bus caller, logger *log.Logger) error { w := newWorld() dirty := make(chan struct{}, 1) wake := func() { @@ -165,8 +189,8 @@ func follow(ctx context.Context, js jetstream.JetStream, kvs buckets, say func(a default: } } - errs := make(chan error, len(watched)+1) - settled := make(chan struct{}, len(watched)+1) + errs := make(chan error, len(watched)+2) + settled := make(chan struct{}, len(watched)+2) watch := func(name string, opts ...jetstream.WatchOpt) error { kw, err := kvs[name].WatchAll(ctx, opts...) if err != nil { @@ -199,7 +223,11 @@ func follow(ctx context.Context, js jetstream.JetStream, kvs buckets, say func(a }() return nil } - for _, name := range watched { + followed := watched + if kvs[delivery.AssignerPutsBucket] != nil { + followed = append(append([]string{}, watched...), delivery.AssignerPutsBucket) + } + for _, name := range followed { var opts []jetstream.WatchOpt if name == delivery.HaveBucket { opts = append(opts, jetstream.IncludeHistory()) // what each node ran healthy before, for a set-back @@ -211,7 +239,7 @@ func follow(ctx context.Context, js jetstream.JetStream, kvs buckets, say func(a if err := watch(delivery.ManifestsBucket, jetstream.MetaOnly()); err != nil { return err } - for range len(watched) + 1 { + for range len(followed) + 1 { select { case <-ctx.Done(): return nil @@ -222,6 +250,9 @@ func follow(ctx context.Context, js jetstream.JetStream, kvs buckets, say func(a } p := &passer{kvs: kvs, w: w, logf: logger.Printf, say: say, said: map[string]bool{}, answeredAt: map[string]uint64{}} p.asks = newComposeAsks(ctx, buildrun.Asker{Publisher: buildrun.JetStreamAsks{JS: js}, Waiter: buildrun.JetStreamAsks{JS: js}}, wake, logger.Printf) + if bus != nil && kvs[delivery.AssignerPutsBucket] != nil { + p.acts = newPutActs(bus, wake, logger.Printf) + } wake() for { select { diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go index 15282f6..1bbe91f 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go @@ -33,6 +33,8 @@ type passer struct { // neither is, by node and module, each module on neither list whose condition this process raised (issue 1945). Nil until the // first pass after a start, which clears what a previous process may have raised (startClears). neither map[string]neitherOne + // acts are the puts' acts (ADR 0546); nil: the puts are not taken further (no bus to call, or no puts bucket). + acts *putActs } func (p *passer) clock() time.Time { @@ -45,6 +47,7 @@ func (p *passer) clock() time.Time { func (p *passer) pass(ctx context.Context) { p.compositions(ctx) p.wants(ctx) + p.puts(ctx) p.nodeWants(ctx) p.backups(ctx) } @@ -90,6 +93,7 @@ func (p *passer) wants(ctx context.Context) { } } wanted := p.w.wantsOf() + puts := p.putsByModule() names := map[string]bool{} for name := range trunk { names[name] = true @@ -108,7 +112,7 @@ func (p *passer) wants(ctx context.Context) { for name := range names { fp := trunk[name].FP2 name := name - m := assign.Module{Name: name, FP: fp, Manifest: manifests[name], Nodes: nodesOf[name], Now: time.Now(), + m := assign.Module{Name: name, FP: fp, Manifest: manifests[name], Nodes: nodesOf[name], Now: time.Now(), Puts: puts[name], Composed: func(node, fp string) (delivery.ComposedDigest, bool) { return p.w.composed(node, name, fp) }, GrantedAt: func(fp string) bool { key, err := delivery.ControllerGrantKey(name, fp) diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go index 1af98fa..49f8a3a 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "errors" "fmt" "strings" @@ -11,6 +12,7 @@ import ( "github.com/nats-io/nats.go/jetstream" "git.novox.be/novox/mesh-sdk/go/asks" + "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/provider" @@ -101,6 +103,207 @@ func TestAnAnswerCountsOnlyForTheAskingItAnswered(t *testing.T) { if p.answeredWith(key, "release") { t.Error("an answer to an ask taken away was taken") } + t.Run("a put of the bus acts on the operator's warrant alone", putOfTheBus) +} + +// memEntry is a value a memKV holds, as a bucket's Get answers it. +type memEntry struct { + jetstream.KeyValueEntry + value []byte + revision uint64 +} + +func (e memEntry) Value() []byte { return e.value } +func (e memEntry) Revision() uint64 { return e.revision } +func (m *memKV) Get(_ context.Context, key string) (jetstream.KeyValueEntry, error) { + v, ok := m.values[key] + if !ok { + return nil, jetstream.ErrKeyNotFound + } + return memEntry{value: v, revision: uint64(len(v))}, nil +} +func (m *memKV) Update(_ context.Context, key string, value []byte, revision uint64) (uint64, error) { + if uint64(len(m.values[key])) != revision { + return 0, errors.New("wrong last sequence") + } + m.values[key] = value + return uint64(len(value)), nil +} + +// memStore is the runtime's states for the seat's verbs, over the same memKVs a pass writes. +type memStore map[string]*memKV + +func (s memStore) get(state, key string) ([]byte, uint64, bool, error) { + v, ok := s[state].values[key] + return v, uint64(len(v)), ok, nil +} +func (s memStore) put(state, key string, value []byte) error { + s[state].values[key] = value + return nil +} +func (s memStore) keys(state string) ([]string, error) { + var out []string + for k := range s[state].values { + out = append(out, k) + } + return out, nil +} + +// fakeBus is the assigner's process account in a test: every call recorded, each answered at once. +type fakeBus struct { + calls chan string + events []string +} + +func (b *fakeBus) Call(key string, body any) (json.RawMessage, error) { + b.calls <- fmt.Sprintf("%s %v", key, body) + return json.RawMessage(`{"ok":true,"output":""}`), nil +} +func (b *fakeBus) CallOn(node, key string, body any) (json.RawMessage, error) { + return b.Call(node+"/"+key, body) +} +func (b *fakeBus) Emit(eventType string, body any) error { + b.events = append(b.events, fmt.Sprintf("%s %+v", eventType, body)) + return nil +} + +// ADR 0546, the first put (2026-10-25, want.novox.nats): the verb writes the put; a pass asks the controller's compose verb; +// once the composition and grant are there it asks the operator with a yes that approves; only on that warrant does it have +// the streams snapshotted, then writes the want held and records the warrant; the have running it healthy holds it; a release +// gives the want back to the rules. Each step is said as the event put. +func putOfTheBus(t *testing.T) { + ctx := context.Background() + now := time.Date(2026, 10, 25, 22, 0, 0, 0, time.UTC) + fp := delivery.SourcePrefix2 + "fd497ab8" + strings.Repeat("0", 56) + digest := "sha256:" + strings.Repeat("d", 64) + mem := func() *memKV { return &memKV{values: map[string][]byte{}} } + puts, asksKV, wantsKV := mem(), mem(), mem() + kvs := buckets{delivery.AssignerPutsBucket: puts, delivery.AssignerAsksBucket: asksKV, delivery.AssignerWantsBucket: wantsKV} + p := newPasser(kvs, now) + bus := &fakeBus{calls: make(chan string, 8)} + woke := make(chan struct{}, 8) // each act's goroutine wakes a pass once it is done + p.acts = newPutActs(bus, func() { woke <- struct{}{} }, func(string, ...any) {}) + p.acts.snapshot = func(context.Context, string, string) (string, error) { + return "novox's restore point of nats (streams) taken 22:01", nil + } + // The world: nats assigned to novox, built at fp, the bus by its manifest, novox running it from before the switch. + world := func(bucket, key string, v any) { + var data []byte + switch x := v.(type) { + case []byte: + data = x + default: + data, _ = json.Marshal(x) + } + p.w.put(bucket, key, data) + } + world(provider.AssignmentsBucket, "novox.nats", map[string]string{"node": "novox", "module": "nats"}) + mdigest := strings.Repeat("e", 64) + mod, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: mdigest, Repository: "novox/mesh-catalog", Commit: strings.Repeat("c", 40)}) + world(delivery.ModulesBucket, "nats", mod) + p.w.manifests[mdigest] = parsed{module: "nats", manifest: catalogue.Manifest{Module: "nats", Provides: []catalogue.Offer{{Name: "mesh-bus"}}}} + bkey, _ := delivery.BuilderBuildKey("nats", fp) + built, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mdigest}) + world(delivery.BuilderBuildsBucket, bkey, built) + hkey, _ := delivery.HaveKey("novox", "nats") + before, _ := delivery.EncodeHave(delivery.Have{FP: delivery.SourcePrefix2 + strings.Repeat("a", 64), Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) + world(delivery.HaveBucket, hkey, before) + + builds := mem() + builds.values[bkey] = built + modules := mem() + modules.values["nats"] = mod + s := newSeat(memStore{putsState: puts, delivery.BuilderModule + ".builds": builds, delivery.IndexerModule + ".modules": modules}) + s.now = func() time.Time { return now } + was := answerWithin + answerWithin = 0 + defer func() { answerWithin = was }() + if _, err := s.doPut(map[string]any{"node": "novox", "module": "nats", "version": "fd497ab8ffff", "why": "x"}, "shanks.node-tools"); err == nil { + t.Error("a prefix of a fingerprint the builder never built was taken") + } + if got, err := s.version("nats", "fd497ab80000"); err != nil || got != fp { + t.Errorf("the prefix of the fingerprint built reads %q, %v", got, err) + } + if got, err := s.version("nats", "newest"); err != nil || got != fp { + t.Errorf("newest reads %q, %v", got, err) + } + ans, err := s.doPut(map[string]any{"node": "novox", "module": "nats", "version": fp, "hold": "true", "why": "the bus's user list"}, "shanks.node-tools") + if err != nil { + t.Fatal(err) + } + if a := ans.(putAnswer); a.Step != delivery.PutAsked || !a.Hold || !strings.Contains(a.Follow, "mesh_wait") { + t.Fatalf("put answered %+v", a) + } + pkey, _ := delivery.PutKey("novox", "nats") + step := func() delivery.Put { + t.Helper() + world(delivery.AssignerPutsBucket, pkey, puts.values[pkey]) + p.pass(ctx) + got, err := delivery.DecodePut(puts.values[pkey]) + if err != nil { + t.Fatal(err) + } + return got + } + if got := step(); got.Step != delivery.PutCompose { + t.Fatalf("the first pass left the put at %s: %s", got.Step, got.Words) + } + if call := <-bus.calls; !strings.HasPrefix(call, "seat:mesh-controller.compose") || !strings.Contains(call, fp) { + t.Errorf("the controller was asked %q", call) + } + <-woke + ckey, _ := delivery.ComposedKey("novox", "nats", fp) + cd, _ := delivery.EncodeComposedDigest(delivery.ComposedDigest{Digest: digest, Judgement: delivery.JudgedCannot, Words: "the have names no composition"}) + world(delivery.ControllerComposedBucket, ckey, cd) + gkey, _ := delivery.ControllerGrantKey("nats", fp) + g, _ := delivery.EncodeControllerGrant(delivery.ControllerGrant{}) + world(delivery.ControllerGrantsBucket, gkey, g) + got := step() + akey, _ := delivery.RecordAskKey("novox", "nats", digest) + if got.Step != delivery.PutPerson || got.Ask != akey { + t.Fatalf("composed, the put stands at %s (%s), ask %q", got.Step, got.Words, got.Ask) + } + a, err := delivery.DecodeAssignerAsk(akey, asksKV.values[akey]) + if err != nil || a.Ask.Options[0].Level != asks.Approve { + t.Fatalf("the ask is %+v, %v; want a yes that approves", a, err) + } + if _, there := wantsKV.values["want.novox.nats"]; there { + t.Fatal("the bus's want was written before the operator's warrant") + } + world(delivery.AssignerAsksBucket, akey, asksKV.values[akey]) + yes, _ := delivery.EncodeAnswer(delivery.Answer{Option: assign.OptionYes, Warrant: a.Ask.ID + "@2026-10-25T22:01:00Z", Words: "the operator, verified"}) + world(delivery.AnswersBucket, akey, yes) + if got := step(); got.Step != delivery.PutSnapshot { + t.Fatalf("on the warrant the put stands at %s: %s", got.Step, got.Words) + } + <-woke + got = step() + w, err := delivery.DecodeWant(wantsKV.values["want.novox.nats"]) + if got.Step != delivery.PutWanted || err != nil || w.FP != fp || w.Composed != digest || !w.Put || !w.Hold { + t.Fatalf("snapshotted, the put stands at %s (%s); the want %+v, %v", got.Step, got.Words, w, err) + } + if call := <-bus.calls; !strings.Contains(call, "warranted") || !strings.Contains(call, a.Ask.ID) { + t.Errorf("the warrant was recorded as %q", call) + } + world(delivery.AssignerWantsBucket, "want.novox.nats", wantsKV.values["want.novox.nats"]) + running, _ := delivery.EncodeHave(delivery.Have{FP: fp, Composed: digest, Phase: delivery.PhaseRunning, Health: delivery.HealthOK}) + world(delivery.HaveBucket, hkey, running) + if got := step(); got.Step != delivery.PutHolding { + t.Fatalf("running healthy, the put stands at %s: %s", got.Step, got.Words) + } + if _, err := s.doRelease(map[string]any{"node": "novox", "module": "nats", "why": "done"}, "shanks.node-tools"); err != nil { + t.Fatal(err) + } + if got := step(); got.Step != delivery.PutReleased { + t.Fatalf("released, the put stands at %s", got.Step) + } + listed, _ := s.doPuts(map[string]any{"module": "nats"}) + if l := listed.(map[string]any)["puts"].([]putAnswer); len(l) != 1 || l[0].Step != delivery.PutReleased || len(l[0].Steps) < 6 { + t.Errorf("puts lists %+v", l) + } + if len(bus.events) < 5 || !strings.Contains(strings.Join(bus.events, "\n"), "holding") { + t.Errorf("the steps said as events: %v", bus.events) + } } // 1945/AC21: given a module on neither list on a node, when passes follow, then the assigner raises its condition once, says it diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/puts.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/puts.go new file mode 100644 index 0000000..ec574a8 --- /dev/null +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/puts.go @@ -0,0 +1,440 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "sync" + "time" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" + + "mesh-delivery-assigner/internal/assign" +) + +// The puts (novox/hq ADR 0546): the seat's verb `put` writes the key `.` of the assigner's puts, and each pass +// takes every put that has not ended one step further (assign.StepPut), doing the acts the step asks for: the controller's +// compose verb, the put's ask, the bus's snapshot, the warranted record. Each act's outcome is heard back as a change of the +// world (the composition, the grant, the answer) or, for the calls, as a wake once they answer; nothing polls but the +// snapshot's read-back of the backup holder, as the controller's bus step reads it (ADR 0235), bounded. Every step is said as +// the event mesh-delivery-assigner.put, so a caller waits on it. + +// PutEvent is the event a put's every step is said as: mesh.mod.mesh-delivery-assigner.event.put. +const PutEvent = "put" + +// caller is what the puts need of the bus as the assigner's own process account: a call of a seat's verb, on one node or on +// the seat held once, and an event said. process.Conn, or a test's. +type caller interface { + Call(key string, body any) (json.RawMessage, error) + CallOn(node, key string, body any) (json.RawMessage, error) + Emit(eventType string, body any) error +} + +// putActs keeps, by put (its key and when it was asked), what the acts of a put came to while this process runs: a restart +// asks the controller and the snapshot again, which compose and back up again without harm. +type putActs struct { + mu sync.Mutex + bus caller + wake func() + logf func(string, ...any) + composing map[string]time.Time + composeNo map[string]string + snapping map[string]bool + snapped map[string]string + snapNo map[string]string + warranted map[string]bool + said map[string]string + // snapshot takes the bus's snapshot on node; a variable so a test takes none. + snapshot func(ctx context.Context, node, module string) (string, error) +} + +func newPutActs(bus caller, wake func(), logf func(string, ...any)) *putActs { + a := &putActs{bus: bus, wake: wake, logf: logf, composing: map[string]time.Time{}, composeNo: map[string]string{}, + snapping: map[string]bool{}, snapped: map[string]string{}, snapNo: map[string]string{}, warranted: map[string]bool{}, + said: map[string]string{}} + a.snapshot = a.snapshotTheBus + return a +} + +// identity names one put: its key and when it was asked, so a put asked again under the key is another. +func identity(key string, p delivery.Put) string { + return key + "@" + p.At.UTC().Format(time.RFC3339Nano) +} + +// putsByModule is every put the assigner holds, by module then node. +func (p *passer) putsByModule() map[string]map[string]delivery.Put { + out := map[string]map[string]delivery.Put{} + for key, value := range p.w.read(delivery.AssignerPutsBucket) { + node, module, err := delivery.ParsePutKey(key) + if err != nil { + continue + } + put, err := delivery.DecodePut(value) + if err != nil { + p.logf("the put %s cannot be read, and is left: %v", key, err) + continue + } + if out[module] == nil { + out[module] = map[string]delivery.Put{} + } + out[module][node] = put + } + return out +} + +// puts takes every put that has not ended one step further. +func (p *passer) puts(ctx context.Context) { + if p.acts == nil { + return + } + all := p.putsByModule() + if len(all) == 0 { + return + } + assigned, _ := p.w.assigned() + trunk := p.w.trunk() + get := kvGetter(p.kvs[delivery.ManifestsBucket]) + wanted := p.w.wantsOf() + modules := make([]string, 0, len(all)) + for m := range all { + modules = append(modules, m) + } + sort.Strings(modules) + for _, module := range modules { + var manifest catalogue.Manifest + if mod, ok := trunk[module]; ok && mod.Manifest != "" { + if got := p.w.manifest(ctx, get, mod.Manifest); got.err == "" { + manifest = got.manifest + } + } + for node, put := range all[module] { + if delivery.PutEnded(put.Step) { + continue + } + key, _ := delivery.PutKey(node, module) + id := identity(key, put) + world := p.putWorld(node, module, put, manifest, assigned[node]) + world.ComposeAsked, world.ComposeFailed, world.Snapshot, world.Snapshotting, world.SnapshotFailed = p.acts.state(id) + next := assign.StepPut(world) + // A held bus whose user list the rules moved on (ADR 0539 §7): the put follows the composition its want names. + if w, ok := wanted[module][node]; ok && w.Put && w.FP == next.Put.FP && w.Composed != "" && w.Composed != next.Put.Composed && + (next.Put.Step == delivery.PutWanted || next.Put.Step == delivery.PutHolding) { + if d, there := p.w.composed(node, module, w.FP); there && d.UserListOnly && d.Digest == w.Composed { + next.Put = next.Put.Said(world.Now, next.Put.Step, fmt.Sprintf("the bus's user list changed: the want names composition %s", shortHex(w.Composed))) + next.Put.Composed = w.Composed + } + } + p.actOn(ctx, node, module, id, world, next) + if !samePut(put, next.Put) { + p.writePut(ctx, key, put, next.Put) + } + if next.Put.Step == delivery.PutWanted && put.Step != delivery.PutWanted { + // The want is written in this pass too, before the next one would: Module.Puts writes the same. + if wkey, err := delivery.WantKey(node, module); err == nil { + want := assign.PutWant(next.Put) + if before, ok := wanted[module][node]; !ok || !delivery.SameWant(normalWant(before), normalWant(want)) { + p.put(ctx, delivery.AssignerWantsBucket, wkey, func() ([]byte, error) { return delivery.EncodeWant(want) }) + } + } + } + } + } +} + +func normalWant(w delivery.Want) delivery.Want { + w.V = 0 + return w +} + +// putWorld is what one step of a put reads from the world. +func (p *passer) putWorld(node, module string, put delivery.Put, manifest catalogue.Manifest, assignedHere []string) assign.PutWorld { + w := assign.PutWorld{Node: node, Module: module, Put: put, Now: p.clock(), Bus: catalogue.ProvidesBus(manifest), Newest: p.w.trunk()[module].FP2} + for _, m := range assignedHere { + if m == module { + w.Assigned = true + } + } + if bkey, err := delivery.BuilderBuildKey(module, put.FP); err == nil { + if raw := p.w.value(delivery.BuilderBuildsBucket, bkey); raw != nil { + if b, err := delivery.DecodeBuilderBuild(raw); err == nil { + w.Build, w.BuildWords = b.State, b.Error + } else { + w.Build = "unreadable: " + err.Error() + } + } + } + if d, ok := p.w.composed(node, module, put.FP); ok { + w.Composed = &d + policy := d.Policy + if policy == "" { + policy, _, _ = catalogue.DerivedUpgrade(manifest) + } + w.Recorded = policy == catalogue.PolicyRecord + if akey, err := delivery.RecordAskKey(node, module, d.Digest); err == nil && put.Ask == akey { + switch { + case p.answeredWith(akey, assign.OptionYes): + w.Answer = assign.OptionYes + case p.answeredWith(akey, assign.OptionNo): + w.Answer = assign.OptionNo + } + } + } + if gkey, err := delivery.ControllerGrantKey(module, put.FP); err == nil { + if raw := p.w.value(delivery.ControllerGrantsBucket, gkey); raw != nil { + if g, err := delivery.DecodeControllerGrant(raw); err == nil { + w.Granted = g.NamesNode(node, module) + } + } + } + if hkey, err := delivery.HaveKey(node, module); err == nil { + if raw := p.w.value(delivery.HaveBucket, hkey); raw != nil { + if h, err := delivery.DecodeHave(raw); err == nil { + w.Have = &h + } + } + } + return w +} + +// actOn does the acts a step asks for. +func (p *passer) actOn(ctx context.Context, node, module, id string, w assign.PutWorld, next assign.PutNext) { + if next.Compose { + p.acts.compose(ctx, id, node, module, next.Put.FP) + } + if next.Ask && w.Composed != nil { + akey := next.Put.Ask + if !p.w.has(delivery.AssignerAsksBucket, akey) { + a, err := assign.PutAsk(akey, node, module, next.Put, *w.Composed, w.Bus, w.Now) + if err != nil { + p.logf("the ask about putting %s on %s is not written: %v", module, node, err) + } else { + p.put(ctx, delivery.AssignerAsksBucket, akey, func() ([]byte, error) { return delivery.EncodeAssignerAsk(akey, a) }) + } + } + } + if next.Snapshot { + p.acts.snap(ctx, id, node, module) + } + if next.Warranted { + p.acts.warrant(id, delivery.AskingID(next.Put.Ask, 0)) + } +} + +// samePut says two puts say the same: their step, words and what they found. +func samePut(a, b delivery.Put) bool { + return a.Step == b.Step && a.Words == b.Words && a.Composed == b.Composed && a.Ask == b.Ask && a.Snapshot == b.Snapshot +} + +// writePut writes a put's next value over the one the pass read, by compare-and-set, unless the verb has put again or +// released it since; and says it as the event put. +func (p *passer) writePut(ctx context.Context, key string, read, next delivery.Put) { + kv := p.kvs[delivery.AssignerPutsBucket] + if kv == nil { + return + } + e, err := kv.Get(ctx, key) + if err != nil { + p.logf("the put %s cannot be read again, and is taken further at the next pass: %v", key, err) + return + } + now, err := delivery.DecodePut(e.Value()) + if err != nil || !now.At.Equal(read.At) || now.Step != read.Step { + return // asked again or released meanwhile: the next pass reads that + } + data, err := delivery.EncodePut(next) + if err != nil { + p.logf("the put %s is not written: %v", key, err) + return + } + if _, err := kv.Update(ctx, key, data, e.Revision()); err != nil { + if !errors.Is(err, context.Canceled) { + p.logf("the put %s could not be written, and is taken further at the next pass: %v", key, err) + } + return + } + p.w.put(delivery.AssignerPutsBucket, key, data) + p.logf("put %s: %s: %s", key, next.Step, next.Words) + p.acts.say(key, next) +} + +// state is what this process knows of a put's acts. +func (a *putActs) state(id string) (asked time.Time, composeNo, snapshot string, snapping bool, snapNo string) { + a.mu.Lock() + defer a.mu.Unlock() + return a.composing[id], a.composeNo[id], a.snapped[id], a.snapping[id], a.snapNo[id] +} + +// composeAnswer is what the controller's compose verb answers inside its seat's answer ({output, ok, answer}). +type composeAnswer struct { + OK bool `json:"ok"` + Output string `json:"output"` + Answer *struct { + Digest string `json:"digest"` + Granted bool `json:"granted"` + Grant string `json:"grant"` + } `json:"answer"` +} + +// compose asks the controller's compose verb once for a put. Its answer wakes a pass; the composition and the grant are +// read from their buckets, and a refusal is kept as the put's words. +func (a *putActs) compose(ctx context.Context, id, node, module, fp string) { + a.mu.Lock() + if _, asked := a.composing[id]; asked { + a.mu.Unlock() + return + } + a.composing[id] = time.Now() + a.mu.Unlock() + go func() { + defer a.wake() + raw, err := a.bus.Call("seat:"+catalogue.ControllerSeatName+".compose", map[string]any{"node": node, "module": module, "fp": fp}) + why := "" + var ans composeAnswer + switch { + case err != nil: + why = err.Error() + case json.Unmarshal(raw, &ans) != nil: + // An answer of another shape: the controller still running it after its answer window. The buckets say the rest. + case !ans.OK && strings.TrimSpace(ans.Output) != "": + why = strings.TrimSpace(ans.Output) + case ans.Answer != nil && ans.Answer.Digest != "" && !ans.Answer.Granted: + why = ans.Answer.Grant + } + if why != "" { + a.mu.Lock() + a.composeNo[id] = why + a.mu.Unlock() + a.logf("the controller's compose of %s on %s at %s: %s", module, node, fp, why) + } + }() +} + +// snap has the bus's snapshot taken once for a put, in the background. +func (a *putActs) snap(ctx context.Context, id, node, module string) { + a.mu.Lock() + if a.snapping[id] || a.snapped[id] != "" || a.snapNo[id] != "" { + a.mu.Unlock() + return + } + a.snapping[id] = true + a.mu.Unlock() + go func() { + defer a.wake() + where, err := a.snapshot(ctx, node, module) + a.mu.Lock() + defer a.mu.Unlock() + a.snapping[id] = false + if err != nil { + a.snapNo[id] = err.Error() + return + } + a.snapped[id] = where + }() +} + +// SnapshotWithin is how long the bus node's backup holder is given to take the bus's snapshot (as the controller's bus step). +var SnapshotWithin = 15 * time.Minute + +// snapshotLook is how often the backup holder's record is read back while the snapshot is taken. +var snapshotLook = 10 * time.Second + +// backedUp is one module as the backup holder's `backed-up` answers it. +type backedUp struct { + Module string `json:"module"` + Data []struct { + Item string `json:"item"` + LastBackup *time.Time `json:"last_backup"` + Error string `json:"error"` + } `json:"data"` +} + +// snapshotTheBus asks the bus node's backup holder to back the bus module up now, whose dump is the streams' snapshot +// (ADR 0235), and reads its record back until it names a backup newer than the ask: where the snapshot is. +func (a *putActs) snapshotTheBus(ctx context.Context, node, module string) (string, error) { + asked := time.Now() + if _, err := a.bus.CallOn(node, "seat:"+catalogue.BackupSeat+".now", map[string]any{"module": module}); err != nil { + return "", fmt.Errorf("%s's backup holder was not asked to take the bus's snapshot: %w", node, err) + } + deadline := asked.Add(SnapshotWithin) + for { + if raw, err := a.bus.CallOn(node, "seat:"+catalogue.BackupSeat+".backed-up", map[string]any{}); err == nil { + var modules []backedUp + if json.Unmarshal(raw, &modules) == nil { + for _, m := range modules { + for _, it := range m.Data { + if m.Module == module && it.LastBackup != nil && it.LastBackup.After(asked) && it.Error == "" { + return fmt.Sprintf("%s's restore point of %s (%s) taken %s", node, module, it.Item, it.LastBackup.UTC().Format(time.RFC3339)), nil + } + } + } + } + } + if time.Now().After(deadline) { + return "", fmt.Errorf("%s's backup holder did not say the bus's snapshot was taken within %s", node, SnapshotWithin) + } + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(snapshotLook): + } + } +} + +// warrant records once, in the controller's hand-act log, that the bus's want was written on the operator's warrant: the +// router's record of the teller's ask says who chose and how (ADR 0274). +func (a *putActs) warrant(id, ask string) { + a.mu.Lock() + if a.warranted[id] { + a.mu.Unlock() + return + } + a.warranted[id] = true + a.mu.Unlock() + go func() { + if _, err := a.bus.Call("seat:"+catalogue.ControllerSeatName+".warranted", map[string]any{"asker": delivery.TellerModule, "ask": ask}); err != nil { + a.logf("the put's warrant %s is not recorded in the hand-act log: %v", ask, err) + } + }() +} + +// putSaid is the body of the event put. +type putSaid struct { + Node string `json:"node"` + Module string `json:"module"` + FP string `json:"fp"` + Hold bool `json:"hold"` + Step string `json:"step"` + Words string `json:"words"` + Composed string `json:"composed,omitempty"` +} + +// say says a put's step as the event put, once per step and words. +func (a *putActs) say(key string, p delivery.Put) { + node, module, err := delivery.ParsePutKey(key) + if err != nil || a.bus == nil { + return + } + a.mu.Lock() + if a.said[key] == p.Step+p.Words { + a.mu.Unlock() + return + } + a.said[key] = p.Step + p.Words + a.mu.Unlock() + if err := a.bus.Emit(PutEvent, putSaid{Node: node, Module: module, FP: p.FP, Hold: p.Hold, Step: p.Step, Words: p.Words, Composed: p.Composed}); err != nil { + a.logf("the put %s's step %s is not said as an event: %v", key, p.Step, err) + } +} + +// shortHex is a digest's first twelve hex digits. +func shortHex(digest string) string { + h := strings.TrimPrefix(digest, "sha256:") + if len(h) > 12 { + return h[:12] + } + return h +} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go new file mode 100644 index 0000000..9de8ce7 --- /dev/null +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go @@ -0,0 +1,329 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "regexp" + "sort" + "strings" + "sync" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// The assigner's seat (novox/hq ADR 0546), served through the tool runner as every seat a module holds is: a process never +// serves (ADR 0436, mesh-sdk process). The verbs write and read the puts; the assigner's process takes each put further +// (puts.go) and says every step as the event put. + +// putsState is the assigner's state of puts, as the runtime names a module's own state. +const putsState = "puts" + +// answerWithin is how long `put` waits for the assigner's process to take the put up before it answers what stands. +var answerWithin = 20 * time.Second + +// store is what the verbs need of the states: the runtime's (stdio.State), or a test's. +type store interface { + get(state, key string) ([]byte, uint64, bool, error) + put(state, key string, value []byte) error + keys(state string) ([]string, error) +} + +// seat serves the verbs over a store, and keeps the newest value of every put it heard, to wait on. +type seat struct { + st store + now func() time.Time + mu sync.Mutex + // heard is every put the watch handed, by key; changed is closed and replaced at each. + heard map[string][]byte + changed chan struct{} +} + +func newSeat(st store) *seat { + return &seat{st: st, now: time.Now, heard: map[string][]byte{}, changed: make(chan struct{})} +} + +// hear takes a put's change in from the watch. +func (s *seat) hear(key string, value []byte) { + s.mu.Lock() + s.heard[key] = value + close(s.changed) + s.changed = make(chan struct{}) + s.mu.Unlock() +} + +// fullFP is a fingerprint of the new form, whole; prefix at least its first twelve hex digits. +var ( + fullFP = regexp.MustCompile(`^src2:[0-9a-f]{64}$`) + prefix = regexp.MustCompile(`^(src2:)?[0-9a-f]{12,63}$`) +) + +// version reads the version a put names: newest (the indexer's fingerprint at the trunk), a whole fingerprint, or the start +// of one the builder built, which must name one build alone. +func (s *seat) version(module, version string) (string, error) { + v := strings.ToLower(strings.TrimSpace(version)) + switch { + case v == "newest": + raw, _, found, err := s.st.get(delivery.IndexerModule+".modules", module) + if err != nil { + return "", fmt.Errorf("the indexer's modules cannot be read: %w", err) + } + if !found { + return "", fmt.Errorf("the indexer holds no module %s at the trunk", module) + } + m, err := delivery.DecodeModule(raw) + if err != nil { + return "", fmt.Errorf("the indexer's %s cannot be read: %w", module, err) + } + if m.FP2 == "" { + return "", fmt.Errorf("the indexer holds no fingerprint of the new form for %s: %s", module, m.None2) + } + return m.FP2, nil + case fullFP.MatchString(v): + return v, nil + case prefix.MatchString(v): + start := strings.TrimPrefix(v, delivery.SourcePrefix2) + keys, err := s.st.keys(delivery.BuilderModule + ".builds") + if err != nil { + return "", fmt.Errorf("the builder's builds cannot be listed: %w", err) + } + var found []string + for _, k := range keys { + m, fp, err := delivery.ParseBuilderBuildKey(k) + if err == nil && m == module && strings.HasPrefix(strings.TrimPrefix(fp, delivery.SourcePrefix2), start) { + found = append(found, fp) + } + } + switch len(found) { + case 0: + return "", fmt.Errorf("the builder built no %s whose fingerprint starts %s: give it whole", module, start) + case 1: + return found[0], nil + } + sort.Strings(found) + return "", fmt.Errorf("%s starts %d fingerprints of %s the builder built (%s): give more of it", start, len(found), module, strings.Join(found, ", ")) + } + return "", fmt.Errorf("%q is no version: newest, a fingerprint (src2: and 64 hex digits) or at least its first 12 hex digits", version) +} + +// putAnswer is what `put` and `puts` answer of one put. +type putAnswer struct { + Node string `json:"node"` + Module string `json:"module"` + delivery.Put + Follow string `json:"follow,omitempty"` +} + +// doPut is the verb put. +func (s *seat) doPut(args map[string]any, by string) (any, error) { + node, module, why := arg(args, "node"), arg(args, "module"), arg(args, "why") + key, err := delivery.PutKey(node, module) + if err != nil { + return nil, err + } + if why == "" { + return nil, errors.New("put says why: it is kept with the put and said in any ask") + } + hold := false + switch arg(args, "hold") { + case "true": + hold = true + case "", "false": + default: + return nil, fmt.Errorf("hold is true or false, not %q", arg(args, "hold")) + } + fp, err := s.version(module, arg(args, "version")) + if err != nil { + return nil, err + } + now := s.now().UTC() + words := fmt.Sprintf("asked by %s: %s", by, why) + if raw, _, found, err := s.st.get(putsState, key); err != nil { + return nil, fmt.Errorf("the put of %s on %s cannot be read: %w", module, node, err) + } else if found { + if before, err := delivery.DecodePut(raw); err == nil && !delivery.PutEnded(before.Step) { + if before.FP == fp && before.Hold == hold { + return putAnswer{Node: node, Module: module, Put: before, Follow: followPut(node, module)}, nil + } + words += fmt.Sprintf("; it replaces the put of %s at %s, which stood at %s", module, shortFP(before.FP), before.Step) + } + } + p := delivery.Put{FP: fp, Hold: hold, Why: why, By: by, At: now}.Said(now, delivery.PutAsked, words) + data, err := delivery.EncodePut(p) + if err != nil { + return nil, err + } + if err := s.st.put(putsState, key, data); err != nil { + return nil, fmt.Errorf("the put of %s on %s is not written: %w", module, node, err) + } + s.hear(key, data) + return putAnswer{Node: node, Module: module, Put: s.await(key, p), Follow: followPut(node, module)}, nil +} + +// await is the put at key once the assigner's process took it up past its asking, or as it stands after answerWithin. +func (s *seat) await(key string, p delivery.Put) delivery.Put { + deadline := time.After(answerWithin) + for { + s.mu.Lock() + raw, changed := s.heard[key], s.changed + s.mu.Unlock() + if now, err := delivery.DecodePut(raw); err == nil && now.At.Equal(p.At) { + p = now + if now.Step != delivery.PutAsked && now.Step != delivery.PutBuild && now.Step != delivery.PutCompose { + return p // it waits on a person or the node, or ended + } + } + select { + case <-changed: + case <-deadline: + return p + } + } +} + +// followPut says how a caller follows a put after the answer. +func followPut(node, module string) string { + return fmt.Sprintf("the put goes on: mesh_wait on mesh-delivery-assigner.put with filter {\"node\": %q, \"module\": %q}, then "+ + "mesh-delivery-assigner.puts for where it stands", node, module) +} + +// doRelease is the verb release. +func (s *seat) doRelease(args map[string]any, by string) (any, error) { + node, module, why := arg(args, "node"), arg(args, "module"), arg(args, "why") + key, err := delivery.PutKey(node, module) + if err != nil { + return nil, err + } + if why == "" { + return nil, errors.New("release says why: it is kept with the put") + } + raw, _, found, err := s.st.get(putsState, key) + if err != nil { + return nil, fmt.Errorf("the put of %s on %s cannot be read: %w", module, node, err) + } + if !found { + return nil, fmt.Errorf("%s holds no put of %s: nothing to release", node, module) + } + p, err := delivery.DecodePut(raw) + if err != nil { + return nil, fmt.Errorf("the put of %s on %s cannot be read: %w", module, node, err) + } + if delivery.PutEnded(p.Step) { + return nil, fmt.Errorf("the put of %s on %s ended already (%s: %s): nothing to release", module, node, p.Step, p.Words) + } + p = p.Said(s.now(), delivery.PutReleased, fmt.Sprintf("released by %s: %s; the assigner's rules write the want again", by, why)) + data, err := delivery.EncodePut(p) + if err != nil { + return nil, err + } + if err := s.st.put(putsState, key, data); err != nil { + return nil, fmt.Errorf("the release of %s on %s is not written: %w", module, node, err) + } + s.hear(key, data) + return putAnswer{Node: node, Module: module, Put: p}, nil +} + +// doPuts is the verb puts. +func (s *seat) doPuts(args map[string]any) (any, error) { + keys, err := s.st.keys(putsState) + if err != nil { + return nil, fmt.Errorf("the puts cannot be listed: %w", err) + } + sort.Strings(keys) + out := []putAnswer{} + for _, key := range keys { + node, module, err := delivery.ParsePutKey(key) + if err != nil || (arg(args, "node") != "" && node != arg(args, "node")) || (arg(args, "module") != "" && module != arg(args, "module")) { + continue + } + raw, _, found, err := s.st.get(putsState, key) + if err != nil || !found { + continue + } + p, err := delivery.DecodePut(raw) + if err != nil { + continue + } + out = append(out, putAnswer{Node: node, Module: module, Put: p}) + } + return map[string]any{"puts": out}, nil +} + +func arg(args map[string]any, name string) string { + switch v := args[name].(type) { + case string: + return strings.TrimSpace(v) + case bool: + return fmt.Sprint(v) + } + return "" +} + +// tools are the seat's verbs as the runtime serves them. +func (s *seat) tools() []stdio.Tool { + verbs := map[string]catalogue.Verb{} + if seat, ok := catalogue.SeatNamed(catalogue.AssignerSeat); ok { + for _, v := range seat.Serves { + verbs[v.Name] = v + } + } + tool := func(name string, run func(map[string]any) (any, error)) stdio.Tool { + return stdio.Tool{Name: catalogue.AssignerSeat + "." + name, Description: verbs[name].Description, Input: verbs[name].Input, Run: run} + } + return []stdio.Tool{ + tool("put", func(a map[string]any) (any, error) { return s.doPut(a, stdio.CallerOf(a)) }), + tool("release", func(a map[string]any) (any, error) { return s.doRelease(a, stdio.CallerOf(a)) }), + tool("puts", s.doPuts), + } +} + +// runtimeStore is the runtime's states. +type runtimeStore struct{} + +func (runtimeStore) get(state, key string) ([]byte, uint64, bool, error) { + e, err := stdio.State(state).Get(key) + if err != nil || e == nil { + return nil, 0, false, err + } + return e.Value, e.Revision, true, nil +} + +func (runtimeStore) put(state, key string, value []byte) error { + _, err := stdio.State(state).Put(key, json.RawMessage(value)) + return err +} + +func (runtimeStore) keys(state string) ([]string, error) { return stdio.State(state).Keys() } + +// serveSeat serves the seat's verbs to the runtime until it stops, hearing every put to answer `put` with its first steps. +func serveSeat() error { + s := newSeat(runtimeStore{}) + go func() { + <-stdio.Served() + for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { + err := stdio.State(putsState).Watch("", func(c stdio.StateChange) error { + s.hear(c.Key, c.Value) + return nil + }) + if err == nil { + return + } + fmt.Fprintf(os.Stderr, "mesh-delivery-assigner: the puts are not heard yet (%v); asked again in %s\n", err, wait) + time.Sleep(wait) + } + }() + return stdio.Serve("", s.tools()) +} + +// shortFP is a fingerprint's first twelve hex digits. +func shortFP(fp string) string { + part, err := delivery.FingerprintKeyPart(fp) + if err != nil || len(part) < 12 { + return fp + } + return part[:12] +} diff --git a/modules/mesh-delivery-assigner/go.mod b/modules/mesh-delivery-assigner/go.mod index 781795a..8d2add7 100644 --- a/modules/mesh-delivery-assigner/go.mod +++ b/modules/mesh-delivery-assigner/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-assigner go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024210118-748c564cdf7c + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025195051-20a6dc4920f8 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-assigner/go.sum b/modules/mesh-delivery-assigner/go.sum index d36d16c..b0041d3 100644 --- a/modules/mesh-delivery-assigner/go.sum +++ b/modules/mesh-delivery-assigner/go.sum @@ -1,5 +1,7 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024210118-748c564cdf7c h1:pY2YpHSSsfDPBrOEiWTNSIPn0zdnAayRmtmmyQ3hCHY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024210118-748c564cdf7c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025195051-20a6dc4920f8 h1:jA7V7hlfJAigVUvJDpKwfgd9ylQ9bqppg3rK9XgLWoA= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025195051-20a6dc4920f8/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/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-assigner/internal/assign/asks_test.go b/modules/mesh-delivery-assigner/internal/assign/asks_test.go index dbd6523..1fa82b1 100644 --- a/modules/mesh-delivery-assigner/internal/assign/asks_test.go +++ b/modules/mesh-delivery-assigner/internal/assign/asks_test.go @@ -146,6 +146,32 @@ func TestTheRecordAndBackupAsksAreValid(t *testing.T) { if _, err := asks.EncodeCondition(c); err != nil { t.Errorf("the backup condition is refused: %v", err) } + // ADR 0546 clauses 7 and 8: a put's ask is valid at the record ask's key; for the bus its yes approves and binds the act, + // so the view cannot answer it, and it lasts no longer than an approving ask may. + put := delivery.Put{FP: fNew, Why: "the bus's user list", Hold: true} + d := delivery.ComposedDigest{Digest: dgst, Judgement: delivery.JudgedCannot, Words: "the have names no composition"} + for _, bus := range []bool{false, true} { + a, err := PutAsk(rkey, "ace", "mail", put, d, bus, now) + if err != nil { + t.Fatalf("bus %v: %v", bus, err) + } + if err := a.Ask.Check(now); err != nil { + t.Errorf("bus %v: the router refuses the put's ask: %v", bus, err) + } + if _, err := delivery.EncodeAssignerAsk(rkey, a); err != nil { + t.Errorf("bus %v: the put's ask cannot be written: %v", bus, err) + } + viewed := delivery.ViewAnswerFits(delivery.ViewAnswer{Asking: a.Ask.ID, Option: OptionYes}, a) + yes := a.Ask.Options[0] + switch { + case !strings.Contains(a.Ask.Explanation, "the bus's user list"): + t.Errorf("bus %v: the put's ask does not say why: %s", bus, a.Ask.Explanation) + case bus && (yes.Level != asks.Approve || yes.Binds == "" || viewed == nil || a.Ask.Expires.After(now.Add(asks.ApproveLasts))): + t.Errorf("bus: the yes is %+v, the view's answer %v, expiring %s", yes, viewed, a.Ask.Expires) + case !bus && (yes.Level != asks.Acknowledge || viewed != nil): + t.Errorf("a recorded module: the yes is %+v, the view's answer %v", yes, viewed) + } + } } // 1945/AC21: the condition of a module on neither list is urgent, names the module, the node and the rule holding its want back, diff --git a/modules/mesh-delivery-assigner/internal/assign/put.go b/modules/mesh-delivery-assigner/internal/assign/put.go new file mode 100644 index 0000000..eacc669 --- /dev/null +++ b/modules/mesh-delivery-assigner/internal/assign/put.go @@ -0,0 +1,239 @@ +package assign + +import ( + "fmt" + "time" + + "git.novox.be/novox/mesh-sdk/go/asks" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// A put (novox/hq ADR 0546): one version of a module put on one node by the assigner seat's verb. The verb writes the put +// key; each pass takes it one step further from what the world holds (StepPut), and the rules pass over the node's want for +// the module from its asking until it ends (Module.Put). + +// ComposeWithin is how long a put waits for the controller's composition after it asked for it before it is refused. +const ComposeWithin = 10 * time.Minute + +// PutWorld is what one step of a put reads. +type PutWorld struct { + Node, Module string + Put delivery.Put + // Assigned says the module stands assigned to the node. + Assigned bool + // Bus says the module provides the mesh's bus (ADR 0539 §15); Recorded that it is of policy record (§14). + Bus, Recorded bool + // Build is the state of the builder's build at the put's fingerprint, "" when no key is written; BuildWords its error. + Build, BuildWords string + // Newest is the module's newest fingerprint at the trunk, which the builder builds by itself. + Newest string + // Composed is the controller's composition of the module for the node at the fingerprint; nil when there is none. + Composed *delivery.ComposedDigest + // Granted says the controller's grant at the fingerprint names the node's accounts. + Granted bool + // ComposeAsked is when this put asked the controller's compose verb, zero when it has not; ComposeFailed the words + // of its answer when it refused. + ComposeAsked time.Time + ComposeFailed string + // Answer is the option the operator chose on the put's ask for the composition, "" when none. + Answer string + // Snapshot says where the bus's streams were snapshotted for this put, once they were; Snapshotting that it was + // asked; SnapshotFailed why it was not taken. + Snapshot string + Snapshotting bool + SnapshotFailed string + // Have is what the node says it runs of the module; nil when it says nothing. + Have *delivery.Have + Now time.Time +} + +// PutNext is what a step decides: the put as it stands after it, and the acts the pass does for it. +type PutNext struct { + Put delivery.Put + // Compose asks the controller's compose verb for the composition at the fingerprint. + Compose bool + // Ask writes the put's ask about the move (PutAsk). + Ask bool + // Snapshot has the bus's node back the bus up now, its dump the streams' snapshot (ADR 0235). + Snapshot bool + // Warranted records, once, in the controller's hand-act log that the bus's want was written on the operator's warrant. + Warranted bool +} + +// StepPut takes a put one step further from what the world holds (ADR 0546 clauses 3 to 8). +func StepPut(w PutWorld) PutNext { + p := w.Put + if delivery.PutEnded(p.Step) { + return PutNext{Put: p} + } + say := func(step, format string, args ...any) PutNext { + return PutNext{Put: p.Said(w.Now, step, fmt.Sprintf(format, args...))} + } + fp := short(p.FP) + if !w.Assigned { + return say(delivery.PutRefused, "%s is not assigned to %s: a put moves a module where it stands assigned (mesh-controller.assign first)", w.Module, w.Node) + } + switch w.Build { + case delivery.BuildDone: + case delivery.BuildFailed: + return say(delivery.PutRefused, "the builder's build of %s at %s failed: %s", w.Module, fp, w.BuildWords) + case "": + if p.FP != w.Newest { + // The builder builds the trunk's newest fingerprint and the candidates', and takes no ask for another yet (ADR 0546 + // clause 3): an older fingerprint it never built, or whose key it removed, cannot be put. + return say(delivery.PutRefused, "the builder holds no build of %s at %s, and builds by itself only the trunk's newest (%s): put that, or a version it built", w.Module, fp, short(w.Newest)) + } + return say(delivery.PutBuild, "waits for the builder's build of %s at %s, the trunk's newest, which it builds by itself", w.Module, fp) + default: + return say(delivery.PutBuild, "waits for the builder's build of %s at %s, which is %s", w.Module, fp, w.Build) + } + if w.Composed == nil || !w.Granted { + switch { + case w.ComposeFailed != "": + return say(delivery.PutRefused, "the controller did not compose %s for %s at %s: %s", w.Module, w.Node, fp, w.ComposeFailed) + case w.ComposeAsked.IsZero(): + next := say(delivery.PutCompose, "asked the controller to issue the accounts of %s on %s, write its grant and compose it at %s", w.Module, w.Node, fp) + next.Compose = true + return next + case w.Now.Sub(w.ComposeAsked) > ComposeWithin: + return say(delivery.PutRefused, "the controller wrote no composition of %s for %s at %s with a grant naming %s within %s of the ask", w.Module, w.Node, fp, w.Node, ComposeWithin) + case w.Composed != nil: + return say(delivery.PutCompose, "waits for the controller's grant of %s at %s to name %s's accounts", w.Module, fp, w.Node) + default: + return say(delivery.PutCompose, "waits for the controller's composition of %s for %s at %s", w.Module, w.Node, fp) + } + } + d := *w.Composed + if p.Step == delivery.PutWanted || p.Step == delivery.PutHolding { + return PutNext{Put: applied(w, p)} + } + p.Composed = d.Digest + if needsPerson(w, d) { + switch w.Answer { + case OptionNo: + return say(delivery.PutRefused, "the operator answered no to putting %s at %s on %s", w.Module, fp, w.Node) + case OptionYes: + default: + next := say(delivery.PutPerson, "waits for the operator's yes to putting %s at %s on %s (composition %s, judged %s)", w.Module, fp, w.Node, short(d.Digest), d.Judgement) + if p.Ask == "" { + next.Ask = true + next.Put.Ask, _ = delivery.RecordAskKey(w.Node, w.Module, d.Digest) + } + return next + } + if w.Bus { + switch { + case w.Snapshot != "": + p.Snapshot = w.Snapshot + case w.SnapshotFailed != "": + return say(delivery.PutRefused, "the bus's streams were not snapshotted, so the bus is not put: %s", w.SnapshotFailed) + case w.Snapshotting: + return say(delivery.PutSnapshot, "the operator said yes; waits for %s's backup holder to snapshot the bus's streams", w.Node) + default: + next := say(delivery.PutSnapshot, "the operator said yes; asked %s's backup holder to snapshot the bus's streams", w.Node) + next.Snapshot = true + return next + } + } + } + words := fmt.Sprintf("the want of %s on %s names %s (composition %s)", w.Module, w.Node, fp, short(d.Digest)) + if p.Hold { + words += ", held until released" + } + if p.Snapshot != "" { + words += "; the streams were snapshotted: " + p.Snapshot + } + next := PutNext{Put: applied(w, p.Said(w.Now, delivery.PutWanted, words))} + next.Warranted = w.Bus && p.Ask != "" + return next +} + +// applied is a wanted put once the node's have says how it went: holding or done when it runs the composition healthy. +func applied(w PutWorld, p delivery.Put) delivery.Put { + h := w.Have + if h == nil || h.FP != p.FP || h.Composed != p.Composed { + return p + } + switch { + case h.Phase == delivery.PhaseRunning && h.Health == delivery.HealthOK && p.Hold: + return p.Said(w.Now, delivery.PutHolding, fmt.Sprintf("%s runs %s at %s healthy; held there until released", w.Node, w.Module, short(p.FP))) + case h.Phase == delivery.PhaseRunning && h.Health == delivery.HealthOK: + return p.Said(w.Now, delivery.PutDone, fmt.Sprintf("%s runs %s at %s healthy; the rules write its want again", w.Node, w.Module, short(p.FP))) + case failed(*h): + return p.Said(w.Now, p.Step, fmt.Sprintf("%s runs %s at %s and its health check failed: %s", w.Node, w.Module, short(p.FP), h.Detail)) + } + return p +} + +// needsPerson says whether the put's move waits for the operator: any move of the bus but a revision at the fingerprint the +// node runs that changes only its user list (ADR 0539 §7, §15), and a move of a module of policy record judged to recreate a +// container, move data, or that cannot be judged (§14). +func needsPerson(w PutWorld, d delivery.ComposedDigest) bool { + runs := w.Have != nil && w.Have.FP == w.Put.FP + if runs && w.Have.Composed == d.Digest { + return false // the node runs this composition already: nothing moves + } + if w.Bus { + return !(runs && d.UserListOnly) + } + if !w.Recorded { + return false + } + return d.Judgement == delivery.JudgedRecreates || d.Judgement == delivery.JudgedMovesData || d.Judgement == delivery.JudgedCannot +} + +// PutWant is the want a put writes once it is wanted: the fingerprint and composition, marked put, and held when it holds. +func PutWant(p delivery.Put) delivery.Want { + return delivery.Want{FP: p.FP, Composed: p.Composed, Put: true, Hold: p.Hold, Words: "put: " + p.Why} +} + +// PutOwns says what a put does to the node's want: owns says the rules pass over it, want the want it writes (nil: what +// stands stays). A put owns the want from its asking until it ends; a held put ends only when released. +func PutOwns(p delivery.Put) (want *delivery.Want, owns bool) { + switch p.Step { + case delivery.PutWanted, delivery.PutHolding: + w := PutWant(p) + return &w, true + case delivery.PutDone, delivery.PutReleased, delivery.PutRefused: + return nil, false + } + return nil, true +} + +// PutAsk is the ask about a put's move (ADR 0546 clauses 7 and 8), under the record ask's key of the composition so the +// teller relays it and its answer as any record ask's. For the bus its yes is of level approve and binds the act, so only a +// channel that proves who answered takes it (ADR 0259), never the view. +func PutAsk(key, node, module string, p delivery.Put, d delivery.ComposedDigest, bus bool, now time.Time) (delivery.AssignerAsk, error) { + yes := asks.Option{ID: OptionYes, Label: "Yes, put it", Does: "the node is told to apply " + module + " at " + short(p.FP), Level: asks.Acknowledge} + what := map[delivery.Judgement]string{ + delivery.JudgedRecreates: "recreates a container", + delivery.JudgedMovesData: "moves the module's data", + delivery.JudgedCannot: "cannot be judged: " + d.Words, + delivery.JudgedNeither: "recreates no container and moves no data", + delivery.JudgedUnchanged: "changes nothing the node runs", + }[d.Judgement] + explanation := fmt.Sprintf("Asked to put %s at version %s on %s: %s. The move %s.", module, short(p.FP), node, p.Why, what) + expires := now.Add(7 * 24 * time.Hour) + if bus { + act := asks.Act{"put": "want", "node": node, "module": module, "fp": p.FP, "composed": d.Digest} + binds, err := asks.ActDigest(act) + if err != nil { + return delivery.AssignerAsk{}, err + } + yes.Level, yes.Binds = asks.Approve, binds + yes.Does = "the bus's streams are snapshotted, then " + node + " is told to apply the bus at " + short(p.FP) + explanation += " It is the mesh's bus: every module reconnects while it restarts, and its streams are snapshotted first." + expires = now.Add(asks.ApproveLasts) + } + return delivery.AssignerAsk{Ask: asks.Ask{ + ID: delivery.AskingID(key, 0), + Headline: fmt.Sprintf("Put %s on %s?", module, node), + Explanation: explanation, + Options: []asks.Option{yes, + {ID: OptionNo, Label: "No", Does: "the node keeps what it runs, and the put is refused", Level: asks.Acknowledge}}, + Who: asks.Operator, + Expires: expires, + OnExpiry: "the node keeps what it runs", + About: key, + }}, nil +} diff --git a/modules/mesh-delivery-assigner/internal/assign/wants.go b/modules/mesh-delivery-assigner/internal/assign/wants.go index 2f79b1b..942c194 100644 --- a/modules/mesh-delivery-assigner/internal/assign/wants.go +++ b/modules/mesh-delivery-assigner/internal/assign/wants.go @@ -44,6 +44,8 @@ type Module struct { Kept bool // Now is when this pass reads the haves: the held record names it as when the health check was seen to fail. Zero: not said. Now time.Time + // Puts are the puts of the module, by node (ADR 0546): while one owns a node's want (PutOwns), the rules pass over it. + Puts map[string]delivery.Put } func (m Module) composition(node, fp string) (delivery.ComposedDigest, bool) { @@ -224,6 +226,21 @@ func Wants(m Module, n Nodes, wants map[string]delivery.Want, lastHealthy map[st } before, had := wants[node] var next delivery.Want + if p, put := m.Puts[node]; put { + if want, owns := PutOwns(p); owns { + if want != nil { + w := *want + // A held bus still takes a revision of its composition that changes only its user list (ADR 0539 §7). + if ul, ok := m.userListRevision(node, before, had, n); ok && ul.FP == w.FP { + w.Composed = ul.Composed + } + if !had || !delivery.SameWant(normal(before), normal(w)) { + out.Wants[node] = w + } + } + continue + } + } // A revision of the bus module's composition that differs only in its user list is written at once, at the fingerprint the // node runs, outside the rules, the record hold and the bar on a new fingerprint: a new module's credential must reach the // bus before the module can connect (ADR 0539 §7). The controller marks it; whatever it judges of the file's mount stands. @@ -266,7 +283,7 @@ func Wants(m Module, n Nodes, wants map[string]delivery.Want, lastHealthy map[st next.Newest, next.Rule, next.Words = m.FP, rule, words at.FP = target } - if had && !before.Removed && before.FP == target && before.Composed == d.Digest { + if had && !before.Removed && !before.Put && before.FP == target && before.Composed == d.Digest { next.Backup, next.Words = before.Backup, before.Words // the answer to a failed backup stays while its target stays (ADR 0539 §16) } running, ran := n.Haves[node][m.Name] @@ -475,17 +492,8 @@ func (m Module) userListRevision(node string, before delivery.Want, had bool, n return w, true } -// GrantNamesNode says whether a grant of module names its accounts on node: its own `.` or a process's -// `..`. A grant that names no account (a module that does not speak on the bus) names every node. +// GrantNamesNode says whether a grant of module names its accounts on node (mesh-sdk's ControllerGrant.NamesNode, which the +// controller's compose verb answers by too). func GrantNamesNode(g delivery.ControllerGrant, node, module string) bool { - if len(g.Accounts) == 0 { - return true - } - own := node + "." + module - for _, a := range g.Accounts { - if a == own || strings.HasPrefix(a, own+".") { - return true - } - } - return false + return g.NamesNode(node, module) } diff --git a/modules/mesh-delivery-assigner/internal/assign/wants_test.go b/modules/mesh-delivery-assigner/internal/assign/wants_test.go index 775d53c..e91e8e9 100644 --- a/modules/mesh-delivery-assigner/internal/assign/wants_test.go +++ b/modules/mesh-delivery-assigner/internal/assign/wants_test.go @@ -384,6 +384,141 @@ func TestTheBusIsNotMovedButItsUserListRevisionIsWrittenAtOnce(t *testing.T) { if len(out.Record) != 0 { t.Errorf("a user-list revision was asked: %+v", out.Record) } + // ADR 0546: a put owns the node's want from its asking until it ends. While it waits the rules write nothing there; once + // wanted its want stands, marked put and held, and a held bus still takes its user-list revision (ADR 0539 §7). + put := delivery.Put{FP: fNew, Hold: true, Why: "the bus's user list", Composed: dig(fNew)} + for _, c := range []struct { + name string + step string + wanted map[string]delivery.Want + want *delivery.Want // nil: nothing written on ace + }{ + {"waiting for the operator", delivery.PutPerson, wantedOld(), nil}, + {"wanted", delivery.PutWanted, wantedOld(), &delivery.Want{FP: fNew, Composed: dig(fNew), Put: true, Hold: true, Words: "put: the bus's user list"}}, + {"holding, written already", delivery.PutHolding, map[string]delivery.Want{"ace": PutWant(put)}, nil}, + {"released: the rules again", delivery.PutReleased, map[string]delivery.Want{"ace": PutWant(put)}, &delivery.Want{FP: fNew, Composed: dig(fNew)}}, + } { + p := put + p.Step = c.step + m.Puts = map[string]delivery.Put{"ace": p} + out := Wants(m, n, c.wanted, nil) + got, wrote := out.Wants["ace"] + switch { + case c.want == nil && wrote: + t.Errorf("put %s: the rules wrote ace's want %+v", c.name, got) + case c.want != nil && (!wrote || !delivery.SameWant(normal(got), *c.want)): + t.Errorf("put %s: ace's want = %+v (written %v), want %+v", c.name, got, wrote, *c.want) + } + } + userList := "sha256:" + strings.Repeat("7", 64) + m.Composed = func(node, fp string) (delivery.ComposedDigest, bool) { + return delivery.ComposedDigest{Digest: userList, Judgement: delivery.JudgedNeither, UserListOnly: true}, true + } + n.Haves["ace"]["mail"] = delivery.Have{FP: fNew, Composed: dig(fNew), Phase: delivery.PhaseRunning, Health: delivery.HealthOK} + held := put + held.Step = delivery.PutHolding + m.Puts = map[string]delivery.Put{"ace": held} + if w := Wants(m, n, map[string]delivery.Want{"ace": PutWant(held)}, nil).Wants["ace"]; w.Composed != userList || !w.Put || !w.Hold { + t.Errorf("a held bus did not take its user-list revision as a put: %+v", w) + } + m.Puts = nil + stepsOfAPut(t) +} + +// stepsOfAPut takes a put through its steps (ADR 0546 clauses 3 to 8): the build, the controller's composition, the +// operator's yes, the bus's snapshot, the want, and the have. +func stepsOfAPut(t *testing.T) { + t.Helper() + now := time.Date(2026, 10, 25, 22, 0, 0, 0, time.UTC) + base := delivery.Put{FP: fNew, Hold: true, Why: "the bus's user list", At: now}.Said(now, delivery.PutAsked, "asked") + d := delivery.ComposedDigest{Digest: dig(fNew), Judgement: delivery.JudgedCannot, Words: "the have names no composition"} + running := delivery.Have{FP: fOld, Phase: delivery.PhaseRunning, Health: delivery.HealthOK} + bus := func() PutWorld { + return PutWorld{Node: "novox", Module: "nats", Put: base, Assigned: true, Bus: true, Build: delivery.BuildDone, Newest: fNew, + Composed: &d, Granted: true, Have: &running, Now: now} + } + cases := []struct { + name string + world func() PutWorld + step string + says string + compose, ask, snap bool + warranted bool + }{ + {"not assigned", func() PutWorld { w := bus(); w.Assigned = false; return w }, delivery.PutRefused, "not assigned", false, false, false, false}, + {"no build yet of the newest", func() PutWorld { w := bus(); w.Build = ""; return w }, delivery.PutBuild, "builds by itself", false, false, false, false}, + {"no build of an older one", func() PutWorld { w := bus(); w.Build, w.Newest = "", fpOf('c'); return w }, delivery.PutRefused, "holds no build", false, false, false, false}, + {"a failed build", func() PutWorld { w := bus(); w.Build, w.BuildWords = delivery.BuildFailed, "go vet"; return w }, delivery.PutRefused, "go vet", false, false, false, false}, + {"no composition: asks the controller", func() PutWorld { w := bus(); w.Composed = nil; return w }, delivery.PutCompose, "asked the controller", true, false, false, false}, + {"composing", func() PutWorld { w := bus(); w.Composed, w.ComposeAsked = nil, now.Add(-time.Minute); return w }, delivery.PutCompose, "waits for the controller's composition", false, false, false, false}, + {"composed, grant not naming the node", func() PutWorld { w := bus(); w.Granted, w.ComposeAsked = false, now.Add(-time.Minute); return w }, delivery.PutCompose, "grant", false, false, false, false}, + {"the controller refused", func() PutWorld { + w := bus() + w.Composed, w.ComposeAsked, w.ComposeFailed = nil, now, "no lease" + return w + }, delivery.PutRefused, "no lease", false, false, false, false}, + {"the controller silent too long", func() PutWorld { + w := bus() + w.Composed, w.ComposeAsked = nil, now.Add(-ComposeWithin-time.Second) + return w + }, delivery.PutRefused, "within", false, false, false, false}, + {"the bus asks the operator", bus, delivery.PutPerson, "waits for the operator's yes", false, true, false, false}, + {"the operator said no", func() PutWorld { w := bus(); w.Answer = OptionNo; return w }, delivery.PutRefused, "answered no", false, false, false, false}, + {"yes: the snapshot is asked", func() PutWorld { w := bus(); w.Answer = OptionYes; return w }, delivery.PutSnapshot, "asked novox's backup holder", false, false, true, false}, + {"the snapshot is taken", func() PutWorld { w := bus(); w.Answer, w.Snapshotting = OptionYes, true; return w }, delivery.PutSnapshot, "waits for novox's backup holder", false, false, false, false}, + {"the snapshot failed", func() PutWorld { w := bus(); w.Answer, w.SnapshotFailed = OptionYes, "no restic"; return w }, delivery.PutRefused, "no restic", false, false, false, false}, + {"snapshotted: the want, warranted", func() PutWorld { + w := bus() + w.Answer, w.Snapshot = OptionYes, "novox's restore point" + w.Put.Ask, _ = delivery.RecordAskKey("novox", "nats", d.Digest) + return w + }, delivery.PutWanted, "held until released; the streams were snapshotted: novox's restore point", false, false, false, true}, + {"a user-list revision of the bus needs nobody", func() PutWorld { + w := bus() + ul := d + ul.UserListOnly = true + w.Composed, w.Have = &ul, &delivery.Have{FP: fNew, Composed: dig(fOld), Phase: delivery.PhaseRunning, Health: delivery.HealthOK} + return w + }, delivery.PutWanted, "held until released", false, false, false, false}, + {"a module not recorded needs nobody", func() PutWorld { w := bus(); w.Bus = false; return w }, delivery.PutWanted, "names", false, false, false, false}, + {"a recorded module judged to recreate asks", func() PutWorld { + w := bus() + w.Bus, w.Recorded = false, true + r := d + r.Judgement = delivery.JudgedRecreates + w.Composed = &r + return w + }, delivery.PutPerson, "waits for the operator's yes", false, true, false, false}, + {"wanted, the node runs it: holding", func() PutWorld { + w := bus() + w.Put = w.Put.Said(now, delivery.PutWanted, "wanted") + w.Put.Composed = d.Digest + w.Have = &delivery.Have{FP: fNew, Composed: d.Digest, Phase: delivery.PhaseRunning, Health: delivery.HealthOK} + return w + }, delivery.PutHolding, "held there until released", false, false, false, false}, + {"wanted without hold: done", func() PutWorld { + w := bus() + w.Put.Hold = false + w.Put = w.Put.Said(now, delivery.PutWanted, "wanted") + w.Put.Composed = d.Digest + w.Have = &delivery.Have{FP: fNew, Composed: d.Digest, Phase: delivery.PhaseRunning, Health: delivery.HealthOK} + return w + }, delivery.PutDone, "the rules write its want again", false, false, false, false}, + {"released: nothing more", func() PutWorld { w := bus(); w.Put = w.Put.Said(now, delivery.PutReleased, "released"); return w }, delivery.PutReleased, "released", false, false, false, false}, + } + for _, c := range cases { + next := StepPut(c.world()) + if next.Put.Step != c.step || !strings.Contains(next.Put.Words, c.says) { + t.Errorf("put, %s: at %s, %q; want %s saying %q", c.name, next.Put.Step, next.Put.Words, c.step, c.says) + } + if next.Compose != c.compose || next.Ask != c.ask || next.Snapshot != c.snap || next.Warranted != c.warranted { + t.Errorf("put, %s: acts compose %v ask %v snapshot %v warranted %v; want %v %v %v %v", c.name, next.Compose, next.Ask, + next.Snapshot, next.Warranted, c.compose, c.ask, c.snap, c.warranted) + } + if next.Ask && next.Put.Ask == "" { + t.Errorf("put, %s: an ask without its key", c.name) + } + } } // 1906/D4 (ADR 0539 §16): the answer to a failed backup stays in the want while its target stays. diff --git a/modules/mesh-delivery-assigner/manifest_test.go b/modules/mesh-delivery-assigner/manifest_test.go index cd6fe55..fc528e4 100644 --- a/modules/mesh-delivery-assigner/manifest_test.go +++ b/modules/mesh-delivery-assigner/manifest_test.go @@ -42,7 +42,7 @@ func TestManifest(t *testing.T) { for _, s := range m.State { states[s.Name] = s.History } - if want := map[string]int{"asks": 5, "compositions": 5, "differences": 64, "wants": 20}; !reflect.DeepEqual(states, want) { + if want := map[string]int{"asks": 5, "compositions": 5, "differences": 64, "puts": 20, "wants": 20}; !reflect.DeepEqual(states, want) { t.Errorf("state = %v, want %v", states, want) } if _, ok := m.OwnSecrets["broker"]; !ok || len(m.OwnSecrets) != 1 { @@ -56,7 +56,21 @@ func TestManifest(t *testing.T) { if !slices.Equal(m.Uses, []string{"node-build-go", "operator-channel"}) { t.Errorf("uses = %v, want exactly node-build-go and operator-channel", m.Uses) } - if len(m.Claims) != 0 || len(m.Tools) != 0 || len(m.Invokes) != 0 { - t.Errorf("the assigner claims, serves or calls something: claims %v, tools %v, invokes %v", m.Claims, m.Tools, m.Invokes) + // ADR 0546: it holds its seat (put, release, puts), served by its bundle through the tool runner (the artifact loads the + // module), says each put's step as the event put, and calls the controller's compose and warranted and the bus node's + // backup holder. + if len(m.Claims) != 1 || m.Claims[0].Name != catalogue.AssignerSeat || len(m.Tools) != 0 { + t.Errorf("the assigner claims %v and serves tools %v, want its own seat alone", m.Claims, m.Tools) + } + if seat, ok := catalogue.SeatNamed(catalogue.AssignerSeat); !ok { + t.Errorf("the sdk defines no seat %s", catalogue.AssignerSeat) + } else if err := catalogue.CanHold(m, seat); err != nil { + t.Errorf("the assigner cannot hold its seat: %v", err) + } + if want := []string{"seat:mesh-controller.compose", "seat:mesh-controller.warranted", "seat:node-backup.backed-up", "seat:node-backup.now"}; !slices.Equal(slices.Sorted(slices.Values(m.Invokes)), want) { + t.Errorf("invokes = %v, want %v", m.Invokes, want) + } + if !slices.Equal(m.Emits, []string{"put"}) { + t.Errorf("emits = %v, want put", m.Emits) } } diff --git a/modules/mesh-delivery-assigner/module.json b/modules/mesh-delivery-assigner/module.json index 9de9024..bdf441a 100644 --- a/modules/mesh-delivery-assigner/module.json +++ b/modules/mesh-delivery-assigner/module.json @@ -2,6 +2,17 @@ "module": "mesh-delivery-assigner", "version": "1", "slug": "assigner", + "claims": [ + { + "name": "mesh-delivery-assigner", + "scope": "mesh", + "serves": [ + "put", + "release", + "puts" + ] + } + ], "reads": [ "mesh-build-go.steps", "mesh-controller.assignments", @@ -20,10 +31,20 @@ "node-build-go", "operator-channel" ], + "invokes": [ + "seat:mesh-controller.compose", + "seat:mesh-controller.warranted", + "seat:node-backup.now", + "seat:node-backup.backed-up" + ], + "emits": [ + "put" + ], "state": [ { "name": "asks", "history": 5 }, { "name": "compositions", "history": 5 }, { "name": "differences", "history": 64 }, + { "name": "puts", "history": 20 }, { "name": "wants", "history": 20 } ], "own-secrets": { @@ -71,7 +92,10 @@ "language": "go", "system": "arch", "from": "cmd/mesh-delivery-assigner", - "binary": "mesh-delivery-assigner" + "binary": "mesh-delivery-assigner", + "loads": [ + "mesh-delivery-assigner" + ] } ] } diff --git a/modules/mesh-delivery-teller/go.mod b/modules/mesh-delivery-teller/go.mod index f64ffb6..b0bf04e 100644 --- a/modules/mesh-delivery-teller/go.mod +++ b/modules/mesh-delivery-teller/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-teller go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025195051-20a6dc4920f8 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-teller/go.sum b/modules/mesh-delivery-teller/go.sum index 2a3e9f3..2b4dabf 100644 --- a/modules/mesh-delivery-teller/go.sum +++ b/modules/mesh-delivery-teller/go.sum @@ -1,5 +1,7 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468 h1:UcMee+IV2ii5E4dRZdQUaSaeh9IedTFYumuQbBiB0k0= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025195051-20a6dc4920f8 h1:jA7V7hlfJAigVUvJDpKwfgd9ylQ9bqppg3rK9XgLWoA= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025195051-20a6dc4920f8/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/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= From 45fae9da8a5969f4fa67432e870dfd9d47ca817f Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 25 Oct 2026 21:13:42 +0100 Subject: [PATCH 2/5] Serve the put as the assigner's own tools from the compositions and grants the controller already writes, with no seat and no compose verb: the controller takes no change (ADR 0546) --- .../cmd/mesh-delivery-assigner/main.go | 6 +- .../cmd/mesh-delivery-assigner/pass_test.go | 17 +-- .../cmd/mesh-delivery-assigner/puts.go | 108 ++++++------------ .../{seat.go => tools.go} | 105 +++++++++++------ modules/mesh-delivery-assigner/go.mod | 2 +- modules/mesh-delivery-assigner/go.sum | 2 + .../internal/assign/put.go | 44 +++---- .../internal/assign/wants.go | 4 +- .../internal/assign/wants_test.go | 67 +++++------ .../mesh-delivery-assigner/manifest_test.go | 17 +-- modules/mesh-delivery-assigner/module.json | 17 +-- modules/mesh-delivery-teller/go.mod | 2 +- modules/mesh-delivery-teller/go.sum | 2 + 13 files changed, 186 insertions(+), 207 deletions(-) rename modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/{seat.go => tools.go} (65%) diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go index 094b987..e84e562 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/main.go @@ -30,9 +30,9 @@ import ( func main() { if os.Getenv("MESH_BROKER_FILE") == "" { - // Started by the tool runner, with no credential of its own: the seat's verbs (ADR 0546), served as every seat a module - // holds is, since a process never serves (ADR 0436). The process the manifest runs names its credential. - if err := serveSeat(); err != nil { + // Started by the tool runner, with no credential of its own: the put's tools (ADR 0546), served through the runtime, + // since a process never serves (ADR 0436). The process the manifest runs names its credential. + if err := serveTools(); err != nil { fmt.Fprintln(os.Stderr, "mesh-delivery-assigner:", err) os.Exit(1) } diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go index 49f8a3a..71b88a1 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go @@ -130,7 +130,7 @@ func (m *memKV) Update(_ context.Context, key string, value []byte, revision uin return uint64(len(value)), nil } -// memStore is the runtime's states for the seat's verbs, over the same memKVs a pass writes. +// memStore is the runtime's states for the put's tools, over the same memKVs a pass writes. type memStore map[string]*memKV func (s memStore) get(state, key string) ([]byte, uint64, bool, error) { @@ -167,8 +167,8 @@ func (b *fakeBus) Emit(eventType string, body any) error { return nil } -// ADR 0546, the first put (2026-10-25, want.novox.nats): the verb writes the put; a pass asks the controller's compose verb; -// once the composition and grant are there it asks the operator with a yes that approves; only on that warrant does it have +// ADR 0546, the first put (2026-10-25, want.novox.nats): the tool writes the put; a pass waits for the composition and grant +// the controller's take-in writes; once they are there it asks the operator with a yes that approves; only on that warrant does it have // the streams snapshotted, then writes the want held and records the warrant; the have running it healthy holds it; a release // gives the want back to the rules. Each step is said as the event put. func putOfTheBus(t *testing.T) { @@ -213,7 +213,7 @@ func putOfTheBus(t *testing.T) { builds.values[bkey] = built modules := mem() modules.values["nats"] = mod - s := newSeat(memStore{putsState: puts, delivery.BuilderModule + ".builds": builds, delivery.IndexerModule + ".modules": modules}) + s := newPutTools(memStore{putsState: puts, delivery.BuilderModule + ".builds": builds, delivery.IndexerModule + ".modules": modules}) s.now = func() time.Time { return now } was := answerWithin answerWithin = 0 @@ -245,13 +245,14 @@ func putOfTheBus(t *testing.T) { } return got } - if got := step(); got.Step != delivery.PutCompose { + if got := step(); got.Step != delivery.PutCompose || !strings.Contains(got.Words, "no composition") { t.Fatalf("the first pass left the put at %s: %s", got.Step, got.Words) } - if call := <-bus.calls; !strings.HasPrefix(call, "seat:mesh-controller.compose") || !strings.Contains(call, fp) { - t.Errorf("the controller was asked %q", call) + select { + case call := <-bus.calls: + t.Errorf("a put asked %q: it reads the compositions and asks nobody for one", call) + default: } - <-woke ckey, _ := delivery.ComposedKey("novox", "nats", fp) cd, _ := delivery.EncodeComposedDigest(delivery.ComposedDigest{Digest: digest, Judgement: delivery.JudgedCannot, Words: "the have names no composition"}) world(delivery.ControllerComposedBucket, ckey, cd) diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/puts.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/puts.go index ec574a8..c5216be 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/puts.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/puts.go @@ -16,12 +16,12 @@ import ( "mesh-delivery-assigner/internal/assign" ) -// The puts (novox/hq ADR 0546): the seat's verb `put` writes the key `.` of the assigner's puts, and each pass -// takes every put that has not ended one step further (assign.StepPut), doing the acts the step asks for: the controller's -// compose verb, the put's ask, the bus's snapshot, the warranted record. Each act's outcome is heard back as a change of the -// world (the composition, the grant, the answer) or, for the calls, as a wake once they answer; nothing polls but the -// snapshot's read-back of the backup holder, as the controller's bus step reads it (ADR 0235), bounded. Every step is said as -// the event mesh-delivery-assigner.put, so a caller waits on it. +// The puts (novox/hq ADR 0546): the tool `assigner_put` writes the key `.` of the assigner's puts, and each pass +// takes every put that has not ended one step further (assign.StepPut), doing the acts the step asks for: the put's ask, the +// bus's snapshot, the warranted record. Each act's outcome is heard back as a change of the world (the composition, the grant, +// the answer) or, for the calls, as a wake once they answer; nothing polls but the snapshot's read-back of the backup holder, +// as the controller's bus step reads it (ADR 0235), bounded. Every step is said as the event mesh-delivery-assigner.put, so a +// caller waits on it. // PutEvent is the event a put's every step is said as: mesh.mod.mesh-delivery-assigner.event.put. const PutEvent = "put" @@ -35,14 +35,12 @@ type caller interface { } // putActs keeps, by put (its key and when it was asked), what the acts of a put came to while this process runs: a restart -// asks the controller and the snapshot again, which compose and back up again without harm. +// takes the snapshot again, without harm. type putActs struct { mu sync.Mutex bus caller wake func() logf func(string, ...any) - composing map[string]time.Time - composeNo map[string]string snapping map[string]bool snapped map[string]string snapNo map[string]string @@ -53,8 +51,7 @@ type putActs struct { } func newPutActs(bus caller, wake func(), logf func(string, ...any)) *putActs { - a := &putActs{bus: bus, wake: wake, logf: logf, composing: map[string]time.Time{}, composeNo: map[string]string{}, - snapping: map[string]bool{}, snapped: map[string]string{}, snapNo: map[string]string{}, warranted: map[string]bool{}, + a := &putActs{bus: bus, wake: wake, logf: logf, snapping: map[string]bool{}, snapped: map[string]string{}, snapNo: map[string]string{}, warranted: map[string]bool{}, said: map[string]string{}} a.snapshot = a.snapshotTheBus return a @@ -118,12 +115,12 @@ func (p *passer) puts(ctx context.Context) { key, _ := delivery.PutKey(node, module) id := identity(key, put) world := p.putWorld(node, module, put, manifest, assigned[node]) - world.ComposeAsked, world.ComposeFailed, world.Snapshot, world.Snapshotting, world.SnapshotFailed = p.acts.state(id) + world.Snapshot, world.Snapshotting, world.SnapshotFailed = p.acts.state(id) next := assign.StepPut(world) // A held bus whose user list the rules moved on (ADR 0539 §7): the put follows the composition its want names. if w, ok := wanted[module][node]; ok && w.Put && w.FP == next.Put.FP && w.Composed != "" && w.Composed != next.Put.Composed && (next.Put.Step == delivery.PutWanted || next.Put.Step == delivery.PutHolding) { - if d, there := p.w.composed(node, module, w.FP); there && d.UserListOnly && d.Digest == w.Composed { + if d, _ := p.composedFor(node, module, w.FP); d != nil && d.UserListOnly && d.Digest == w.Composed { next.Put = next.Put.Said(world.Now, next.Put.Step, fmt.Sprintf("the bus's user list changed: the want names composition %s", shortHex(w.Composed))) next.Put.Composed = w.Composed } @@ -167,8 +164,8 @@ func (p *passer) putWorld(node, module string, put delivery.Put, manifest catalo } } } - if d, ok := p.w.composed(node, module, put.FP); ok { - w.Composed = &d + w.Composed, w.Granted = p.composedFor(node, module, put.FP) + if d := w.Composed; d != nil { policy := d.Policy if policy == "" { policy, _, _ = catalogue.DerivedUpgrade(manifest) @@ -183,13 +180,6 @@ func (p *passer) putWorld(node, module string, put delivery.Put, manifest catalo } } } - if gkey, err := delivery.ControllerGrantKey(module, put.FP); err == nil { - if raw := p.w.value(delivery.ControllerGrantsBucket, gkey); raw != nil { - if g, err := delivery.DecodeControllerGrant(raw); err == nil { - w.Granted = g.NamesNode(node, module) - } - } - } if hkey, err := delivery.HaveKey(node, module); err == nil { if raw := p.w.value(delivery.HaveBucket, hkey); raw != nil { if h, err := delivery.DecodeHave(raw); err == nil { @@ -200,11 +190,28 @@ func (p *passer) putWorld(node, module string, put delivery.Put, manifest catalo return w } +// composedFor is the composition of module for node at fp and whether the grant at fp names the node's accounts: the one +// place a put reads them (ADR 0546 clause 4). Today their writer is the controller (mesh-controller_composed and +// mesh-controller_grants); when composing and grants move out of the controller into delivery's own parts, this reads them +// from their new owner and nothing else changes. +func (p *passer) composedFor(node, module, fp string) (*delivery.ComposedDigest, bool) { + var out *delivery.ComposedDigest + if d, ok := p.w.composed(node, module, fp); ok { + out = &d + } + granted := false + if gkey, err := delivery.ControllerGrantKey(module, fp); err == nil { + if raw := p.w.value(delivery.ControllerGrantsBucket, gkey); raw != nil { + if g, err := delivery.DecodeControllerGrant(raw); err == nil { + granted = g.NamesNode(node, module) + } + } + } + return out, granted +} + // actOn does the acts a step asks for. func (p *passer) actOn(ctx context.Context, node, module, id string, w assign.PutWorld, next assign.PutNext) { - if next.Compose { - p.acts.compose(ctx, id, node, module, next.Put.FP) - } if next.Ask && w.Composed != nil { akey := next.Put.Ask if !p.w.has(delivery.AssignerAsksBucket, akey) { @@ -229,7 +236,7 @@ func samePut(a, b delivery.Put) bool { return a.Step == b.Step && a.Words == b.Words && a.Composed == b.Composed && a.Ask == b.Ask && a.Snapshot == b.Snapshot } -// writePut writes a put's next value over the one the pass read, by compare-and-set, unless the verb has put again or +// writePut writes a put's next value over the one the pass read, by compare-and-set, unless the tool has put again or // released it since; and says it as the event put. func (p *passer) writePut(ctx context.Context, key string, read, next delivery.Put) { kv := p.kvs[delivery.AssignerPutsBucket] @@ -262,55 +269,10 @@ func (p *passer) writePut(ctx context.Context, key string, read, next delivery.P } // state is what this process knows of a put's acts. -func (a *putActs) state(id string) (asked time.Time, composeNo, snapshot string, snapping bool, snapNo string) { +func (a *putActs) state(id string) (snapshot string, snapping bool, snapNo string) { a.mu.Lock() defer a.mu.Unlock() - return a.composing[id], a.composeNo[id], a.snapped[id], a.snapping[id], a.snapNo[id] -} - -// composeAnswer is what the controller's compose verb answers inside its seat's answer ({output, ok, answer}). -type composeAnswer struct { - OK bool `json:"ok"` - Output string `json:"output"` - Answer *struct { - Digest string `json:"digest"` - Granted bool `json:"granted"` - Grant string `json:"grant"` - } `json:"answer"` -} - -// compose asks the controller's compose verb once for a put. Its answer wakes a pass; the composition and the grant are -// read from their buckets, and a refusal is kept as the put's words. -func (a *putActs) compose(ctx context.Context, id, node, module, fp string) { - a.mu.Lock() - if _, asked := a.composing[id]; asked { - a.mu.Unlock() - return - } - a.composing[id] = time.Now() - a.mu.Unlock() - go func() { - defer a.wake() - raw, err := a.bus.Call("seat:"+catalogue.ControllerSeatName+".compose", map[string]any{"node": node, "module": module, "fp": fp}) - why := "" - var ans composeAnswer - switch { - case err != nil: - why = err.Error() - case json.Unmarshal(raw, &ans) != nil: - // An answer of another shape: the controller still running it after its answer window. The buckets say the rest. - case !ans.OK && strings.TrimSpace(ans.Output) != "": - why = strings.TrimSpace(ans.Output) - case ans.Answer != nil && ans.Answer.Digest != "" && !ans.Answer.Granted: - why = ans.Answer.Grant - } - if why != "" { - a.mu.Lock() - a.composeNo[id] = why - a.mu.Unlock() - a.logf("the controller's compose of %s on %s at %s: %s", module, node, fp, why) - } - }() + return a.snapped[id], a.snapping[id], a.snapNo[id] } // snap has the bus's snapshot taken once for a put, in the background. diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/tools.go similarity index 65% rename from modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go rename to modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/tools.go index 9de8ce7..016654b 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/tools.go @@ -6,35 +6,36 @@ import ( "fmt" "os" "regexp" + "slices" "sort" "strings" "sync" "time" stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" ) -// The assigner's seat (novox/hq ADR 0546), served through the tool runner as every seat a module holds is: a process never -// serves (ADR 0436, mesh-sdk process). The verbs write and read the puts; the assigner's process takes each put further -// (puts.go) and says every step as the event put. +// The assigner's tools (novox/hq ADR 0546), served through the tool runner from its bundle: a process never serves (ADR +// 0436, mesh-sdk process). They write and read the puts; the assigner's process takes each put further (puts.go) and says +// every step as the event put. They are the module's own tools, not a seat's verbs: a new seat needs a change of the +// controller, which takes none; they become the verbs of a seat once composing leaves the controller. // putsState is the assigner's state of puts, as the runtime names a module's own state. const putsState = "puts" -// answerWithin is how long `put` waits for the assigner's process to take the put up before it answers what stands. +// answerWithin is how long assigner_put waits for the assigner's process to take the put up before it answers what stands. var answerWithin = 20 * time.Second -// store is what the verbs need of the states: the runtime's (stdio.State), or a test's. +// store is what the tools need of the states: the runtime's (stdio.State), or a test's. type store interface { get(state, key string) ([]byte, uint64, bool, error) put(state, key string, value []byte) error keys(state string) ([]string, error) } -// seat serves the verbs over a store, and keeps the newest value of every put it heard, to wait on. -type seat struct { +// putTools serves the tools over a store, and keeps the newest value of every put it heard, to wait on. +type putTools struct { st store now func() time.Time mu sync.Mutex @@ -43,12 +44,12 @@ type seat struct { changed chan struct{} } -func newSeat(st store) *seat { - return &seat{st: st, now: time.Now, heard: map[string][]byte{}, changed: make(chan struct{})} +func newPutTools(st store) *putTools { + return &putTools{st: st, now: time.Now, heard: map[string][]byte{}, changed: make(chan struct{})} } // hear takes a put's change in from the watch. -func (s *seat) hear(key string, value []byte) { +func (s *putTools) hear(key string, value []byte) { s.mu.Lock() s.heard[key] = value close(s.changed) @@ -64,7 +65,7 @@ var ( // version reads the version a put names: newest (the indexer's fingerprint at the trunk), a whole fingerprint, or the start // of one the builder built, which must name one build alone. -func (s *seat) version(module, version string) (string, error) { +func (s *putTools) version(module, version string) (string, error) { v := strings.ToLower(strings.TrimSpace(version)) switch { case v == "newest": @@ -110,7 +111,7 @@ func (s *seat) version(module, version string) (string, error) { return "", fmt.Errorf("%q is no version: newest, a fingerprint (src2: and 64 hex digits) or at least its first 12 hex digits", version) } -// putAnswer is what `put` and `puts` answer of one put. +// putAnswer is what assigner_put and assigner_puts answer of one put. type putAnswer struct { Node string `json:"node"` Module string `json:"module"` @@ -118,8 +119,8 @@ type putAnswer struct { Follow string `json:"follow,omitempty"` } -// doPut is the verb put. -func (s *seat) doPut(args map[string]any, by string) (any, error) { +// doPut is the tool assigner_put. +func (s *putTools) doPut(args map[string]any, by string) (any, error) { node, module, why := arg(args, "node"), arg(args, "module"), arg(args, "why") key, err := delivery.PutKey(node, module) if err != nil { @@ -165,7 +166,7 @@ func (s *seat) doPut(args map[string]any, by string) (any, error) { } // await is the put at key once the assigner's process took it up past its asking, or as it stands after answerWithin. -func (s *seat) await(key string, p delivery.Put) delivery.Put { +func (s *putTools) await(key string, p delivery.Put) delivery.Put { deadline := time.After(answerWithin) for { s.mu.Lock() @@ -188,11 +189,11 @@ func (s *seat) await(key string, p delivery.Put) delivery.Put { // followPut says how a caller follows a put after the answer. func followPut(node, module string) string { return fmt.Sprintf("the put goes on: mesh_wait on mesh-delivery-assigner.put with filter {\"node\": %q, \"module\": %q}, then "+ - "mesh-delivery-assigner.puts for where it stands", node, module) + "mesh-delivery-assigner.assigner_puts for where it stands", node, module) } -// doRelease is the verb release. -func (s *seat) doRelease(args map[string]any, by string) (any, error) { +// doRelease is the tool assigner_release. +func (s *putTools) doRelease(args map[string]any, by string) (any, error) { node, module, why := arg(args, "node"), arg(args, "module"), arg(args, "why") key, err := delivery.PutKey(node, module) if err != nil { @@ -227,8 +228,8 @@ func (s *seat) doRelease(args map[string]any, by string) (any, error) { return putAnswer{Node: node, Module: module, Put: p}, nil } -// doPuts is the verb puts. -func (s *seat) doPuts(args map[string]any) (any, error) { +// doPuts is the tool assigner_puts. +func (s *putTools) doPuts(args map[string]any) (any, error) { keys, err := s.st.keys(putsState) if err != nil { return nil, fmt.Errorf("the puts cannot be listed: %w", err) @@ -263,21 +264,55 @@ func arg(args map[string]any, name string) string { return "" } -// tools are the seat's verbs as the runtime serves them. -func (s *seat) tools() []stdio.Tool { - verbs := map[string]catalogue.Verb{} - if seat, ok := catalogue.SeatNamed(catalogue.AssignerSeat); ok { - for _, v := range seat.Serves { - verbs[v.Name] = v +// The tools' names, as the manifest lists them. +const ( + toolPut = "assigner_put" + toolRelease = "assigner_release" + toolPuts = "assigner_puts" +) + +// schema is a tool's arguments, each a string: required ones first named, and switches true or false. +func schema(properties map[string]string, required []string, switches ...string) map[string]any { + props := map[string]any{} + for name, description := range properties { + p := map[string]any{"type": "string", "description": description} + if slices.Contains(switches, name) { + p["enum"] = []string{"true", "false"} } + props[name] = p } - tool := func(name string, run func(map[string]any) (any, error)) stdio.Tool { - return stdio.Tool{Name: catalogue.AssignerSeat + "." + name, Description: verbs[name].Description, Input: verbs[name].Input, Run: run} + out := map[string]any{"type": "object", "properties": props} + if len(required) > 0 { + out["required"] = required } + return out +} + +// tools are the assigner's tools as the runtime serves them (ADR 0546 clause 1). +func (s *putTools) tools() []stdio.Tool { return []stdio.Tool{ - tool("put", func(a map[string]any) (any, error) { return s.doPut(a, stdio.CallerOf(a)) }), - tool("release", func(a map[string]any) (any, error) { return s.doRelease(a, stdio.CallerOf(a)) }), - tool("puts", s.doPuts), + {Name: toolPut, Description: "Put one version of a module on one machine (novox/hq ADR 0546): the build at that fingerprint, " + + "the composition and grant the controller wrote for that machine, then the machine's want naming it, held there until " + + "released when hold is true. A module whose moves a person takes (the bus, a module of policy record) is asked of the " + + "operator first, and the bus has its streams snapshotted after the yes. Answers what was done or what it waits on; the " + + "put goes on after the answer, each step said as the event mesh-delivery-assigner.put, and assigner_puts says where it stands.", + Input: schema(map[string]string{ + "node": "the machine, where the module is assigned", + "module": "the module", + "version": "newest, a fingerprint (src2: and 64 hex digits), or at least its first 12 hex digits", + "hold": "\"true\": the want stays at this version until released; \"false\" (the default): the rules move it again once it runs", + "why": "why, kept with the put and said in any ask", + }, []string{"node", "module", "version", "why"}, "hold"), + Run: func(a map[string]any) (any, error) { return s.doPut(a, stdio.CallerOf(a)) }}, + {Name: toolRelease, Description: "End a put on one machine (novox/hq ADR 0546): its hold is taken away and the assigner's " + + "rules write the machine's want again from their next pass. Nothing changes on the machine until they move it.", + Input: schema(map[string]string{"node": "the machine", "module": "the module", "why": "why, kept with the put"}, + []string{"node", "module", "why"}), + Run: func(a map[string]any) (any, error) { return s.doRelease(a, stdio.CallerOf(a)) }}, + {Name: toolPuts, Description: "Every put and where it stands (novox/hq ADR 0546): its version, hold, why, each step with " + + "its time and words. Narrowed by machine or module. Read-only.", + Input: schema(map[string]string{"node": "one machine", "module": "one module"}, nil), + Run: s.doPuts}, } } @@ -299,9 +334,9 @@ func (runtimeStore) put(state, key string, value []byte) error { func (runtimeStore) keys(state string) ([]string, error) { return stdio.State(state).Keys() } -// serveSeat serves the seat's verbs to the runtime until it stops, hearing every put to answer `put` with its first steps. -func serveSeat() error { - s := newSeat(runtimeStore{}) +// serveTools serves the tools to the runtime until it stops, hearing every put to answer assigner_put with its first steps. +func serveTools() error { + s := newPutTools(runtimeStore{}) go func() { <-stdio.Served() for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { diff --git a/modules/mesh-delivery-assigner/go.mod b/modules/mesh-delivery-assigner/go.mod index 8d2add7..7a109b9 100644 --- a/modules/mesh-delivery-assigner/go.mod +++ b/modules/mesh-delivery-assigner/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-assigner go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025195051-20a6dc4920f8 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025200810-d4ae4b6790b8 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-assigner/go.sum b/modules/mesh-delivery-assigner/go.sum index b0041d3..5164f2d 100644 --- a/modules/mesh-delivery-assigner/go.sum +++ b/modules/mesh-delivery-assigner/go.sum @@ -2,6 +2,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024210118-748c564cdf7c h1:pY2YpHSS git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024210118-748c564cdf7c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025195051-20a6dc4920f8 h1:jA7V7hlfJAigVUvJDpKwfgd9ylQ9bqppg3rK9XgLWoA= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025195051-20a6dc4920f8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025200810-d4ae4b6790b8 h1:vhYk/iv43u2WGSTrFyQGDvGBxLaRITgMAiiI9DjuA10= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025200810-d4ae4b6790b8/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/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-assigner/internal/assign/put.go b/modules/mesh-delivery-assigner/internal/assign/put.go index eacc669..e6f13c8 100644 --- a/modules/mesh-delivery-assigner/internal/assign/put.go +++ b/modules/mesh-delivery-assigner/internal/assign/put.go @@ -8,11 +8,12 @@ import ( "git.novox.be/novox/mesh-sdk/go/delivery" ) -// A put (novox/hq ADR 0546): one version of a module put on one node by the assigner seat's verb. The verb writes the put +// A put (novox/hq ADR 0546): one version of a module put on one node by the assigner's tool assigner_put. The tool writes the put // key; each pass takes it one step further from what the world holds (StepPut), and the rules pass over the node's want for -// the module from its asking until it ends (Module.Put). +// the module from its asking until it ends (Module.Puts). -// ComposeWithin is how long a put waits for the controller's composition after it asked for it before it is refused. +// ComposeWithin is how long a put waits for the composition and grant it needs (a take-in of a build just done writes them) +// before it is refused. const ComposeWithin = 10 * time.Minute // PutWorld is what one step of a put reads. @@ -27,14 +28,10 @@ type PutWorld struct { Build, BuildWords string // Newest is the module's newest fingerprint at the trunk, which the builder builds by itself. Newest string - // Composed is the controller's composition of the module for the node at the fingerprint; nil when there is none. + // Composed is the composition of the module for the node at the fingerprint, as its writer holds it; nil when there is + // none. Granted says the grant at the fingerprint names the node's accounts. Both are read, never asked for (clause 4). Composed *delivery.ComposedDigest - // Granted says the controller's grant at the fingerprint names the node's accounts. - Granted bool - // ComposeAsked is when this put asked the controller's compose verb, zero when it has not; ComposeFailed the words - // of its answer when it refused. - ComposeAsked time.Time - ComposeFailed string + Granted bool // Answer is the option the operator chose on the put's ask for the composition, "" when none. Answer string // Snapshot says where the bus's streams were snapshotted for this put, once they were; Snapshotting that it was @@ -50,8 +47,6 @@ type PutWorld struct { // PutNext is what a step decides: the put as it stands after it, and the acts the pass does for it. type PutNext struct { Put delivery.Put - // Compose asks the controller's compose verb for the composition at the fingerprint. - Compose bool // Ask writes the put's ask about the move (PutAsk). Ask bool // Snapshot has the bus's node back the bus up now, its dump the streams' snapshot (ADR 0235). @@ -88,20 +83,19 @@ func StepPut(w PutWorld) PutNext { return say(delivery.PutBuild, "waits for the builder's build of %s at %s, which is %s", w.Module, fp, w.Build) } if w.Composed == nil || !w.Granted { - switch { - case w.ComposeFailed != "": - return say(delivery.PutRefused, "the controller did not compose %s for %s at %s: %s", w.Module, w.Node, fp, w.ComposeFailed) - case w.ComposeAsked.IsZero(): - next := say(delivery.PutCompose, "asked the controller to issue the accounts of %s on %s, write its grant and compose it at %s", w.Module, w.Node, fp) - next.Compose = true - return next - case w.Now.Sub(w.ComposeAsked) > ComposeWithin: - return say(delivery.PutRefused, "the controller wrote no composition of %s for %s at %s with a grant naming %s within %s of the ask", w.Module, w.Node, fp, w.Node, ComposeWithin) - case w.Composed != nil: - return say(delivery.PutCompose, "waits for the controller's grant of %s at %s to name %s's accounts", w.Module, fp, w.Node) - default: - return say(delivery.PutCompose, "waits for the controller's composition of %s for %s at %s", w.Module, w.Node, fp) + missing := fmt.Sprintf("no composition of %s for %s at %s is written", w.Module, w.Node, fp) + if w.Composed != nil { + missing = fmt.Sprintf("the grant of %s at %s does not name %s's accounts", w.Module, fp, w.Node) } + since := w.Now + if p.Step == delivery.PutCompose && len(p.Steps) > 0 { + since = p.Steps[len(p.Steps)-1].At + } + if w.Now.Sub(since) > ComposeWithin { + return say(delivery.PutRefused, "%s after %s: only a composition written by the take-in of its build (or one of the five kept) is put; "+ + "for another the hand path is mesh-hand-compose (ADR 0539 §10)", missing, ComposeWithin) + } + return say(delivery.PutCompose, "waits up to %s: %s yet", ComposeWithin, missing) } d := *w.Composed if p.Step == delivery.PutWanted || p.Step == delivery.PutHolding { diff --git a/modules/mesh-delivery-assigner/internal/assign/wants.go b/modules/mesh-delivery-assigner/internal/assign/wants.go index 942c194..c53f67d 100644 --- a/modules/mesh-delivery-assigner/internal/assign/wants.go +++ b/modules/mesh-delivery-assigner/internal/assign/wants.go @@ -492,8 +492,8 @@ func (m Module) userListRevision(node string, before delivery.Want, had bool, n return w, true } -// GrantNamesNode says whether a grant of module names its accounts on node (mesh-sdk's ControllerGrant.NamesNode, which the -// controller's compose verb answers by too). +// GrantNamesNode says whether a grant of module names its accounts on node (mesh-sdk's ControllerGrant.NamesNode, which a +// put reads by too). func GrantNamesNode(g delivery.ControllerGrant, node, module string) bool { return g.NamesNode(node, module) } diff --git a/modules/mesh-delivery-assigner/internal/assign/wants_test.go b/modules/mesh-delivery-assigner/internal/assign/wants_test.go index e91e8e9..f1a3684 100644 --- a/modules/mesh-delivery-assigner/internal/assign/wants_test.go +++ b/modules/mesh-delivery-assigner/internal/assign/wants_test.go @@ -438,49 +438,44 @@ func stepsOfAPut(t *testing.T) { Composed: &d, Granted: true, Have: &running, Now: now} } cases := []struct { - name string - world func() PutWorld - step string - says string - compose, ask, snap bool - warranted bool + name string + world func() PutWorld + step string + says string + ask, snap bool + warranted bool }{ - {"not assigned", func() PutWorld { w := bus(); w.Assigned = false; return w }, delivery.PutRefused, "not assigned", false, false, false, false}, - {"no build yet of the newest", func() PutWorld { w := bus(); w.Build = ""; return w }, delivery.PutBuild, "builds by itself", false, false, false, false}, - {"no build of an older one", func() PutWorld { w := bus(); w.Build, w.Newest = "", fpOf('c'); return w }, delivery.PutRefused, "holds no build", false, false, false, false}, - {"a failed build", func() PutWorld { w := bus(); w.Build, w.BuildWords = delivery.BuildFailed, "go vet"; return w }, delivery.PutRefused, "go vet", false, false, false, false}, - {"no composition: asks the controller", func() PutWorld { w := bus(); w.Composed = nil; return w }, delivery.PutCompose, "asked the controller", true, false, false, false}, - {"composing", func() PutWorld { w := bus(); w.Composed, w.ComposeAsked = nil, now.Add(-time.Minute); return w }, delivery.PutCompose, "waits for the controller's composition", false, false, false, false}, - {"composed, grant not naming the node", func() PutWorld { w := bus(); w.Granted, w.ComposeAsked = false, now.Add(-time.Minute); return w }, delivery.PutCompose, "grant", false, false, false, false}, - {"the controller refused", func() PutWorld { + {"not assigned", func() PutWorld { w := bus(); w.Assigned = false; return w }, delivery.PutRefused, "not assigned", false, false, false}, + {"no build yet of the newest", func() PutWorld { w := bus(); w.Build = ""; return w }, delivery.PutBuild, "builds by itself", false, false, false}, + {"no build of an older one", func() PutWorld { w := bus(); w.Build, w.Newest = "", fpOf('c'); return w }, delivery.PutRefused, "holds no build", false, false, false}, + {"a failed build", func() PutWorld { w := bus(); w.Build, w.BuildWords = delivery.BuildFailed, "go vet"; return w }, delivery.PutRefused, "go vet", false, false, false}, + {"no composition yet: waits", func() PutWorld { w := bus(); w.Composed = nil; return w }, delivery.PutCompose, "no composition of nats for novox", false, false, false}, + {"composed, grant not naming the node", func() PutWorld { w := bus(); w.Granted = false; return w }, delivery.PutCompose, "does not name novox", false, false, false}, + {"none within the bound: refused", func() PutWorld { w := bus() - w.Composed, w.ComposeAsked, w.ComposeFailed = nil, now, "no lease" + w.Composed = nil + w.Put = w.Put.Said(now.Add(-ComposeWithin-time.Second), delivery.PutCompose, "waits") return w - }, delivery.PutRefused, "no lease", false, false, false, false}, - {"the controller silent too long", func() PutWorld { - w := bus() - w.Composed, w.ComposeAsked = nil, now.Add(-ComposeWithin-time.Second) - return w - }, delivery.PutRefused, "within", false, false, false, false}, - {"the bus asks the operator", bus, delivery.PutPerson, "waits for the operator's yes", false, true, false, false}, - {"the operator said no", func() PutWorld { w := bus(); w.Answer = OptionNo; return w }, delivery.PutRefused, "answered no", false, false, false, false}, - {"yes: the snapshot is asked", func() PutWorld { w := bus(); w.Answer = OptionYes; return w }, delivery.PutSnapshot, "asked novox's backup holder", false, false, true, false}, - {"the snapshot is taken", func() PutWorld { w := bus(); w.Answer, w.Snapshotting = OptionYes, true; return w }, delivery.PutSnapshot, "waits for novox's backup holder", false, false, false, false}, - {"the snapshot failed", func() PutWorld { w := bus(); w.Answer, w.SnapshotFailed = OptionYes, "no restic"; return w }, delivery.PutRefused, "no restic", false, false, false, false}, + }, delivery.PutRefused, "mesh-hand-compose", false, false, false}, + {"the bus asks the operator", bus, delivery.PutPerson, "waits for the operator's yes", true, false, false}, + {"the operator said no", func() PutWorld { w := bus(); w.Answer = OptionNo; return w }, delivery.PutRefused, "answered no", false, false, false}, + {"yes: the snapshot is asked", func() PutWorld { w := bus(); w.Answer = OptionYes; return w }, delivery.PutSnapshot, "asked novox's backup holder", false, true, false}, + {"the snapshot is taken", func() PutWorld { w := bus(); w.Answer, w.Snapshotting = OptionYes, true; return w }, delivery.PutSnapshot, "waits for novox's backup holder", false, false, false}, + {"the snapshot failed", func() PutWorld { w := bus(); w.Answer, w.SnapshotFailed = OptionYes, "no restic"; return w }, delivery.PutRefused, "no restic", false, false, false}, {"snapshotted: the want, warranted", func() PutWorld { w := bus() w.Answer, w.Snapshot = OptionYes, "novox's restore point" w.Put.Ask, _ = delivery.RecordAskKey("novox", "nats", d.Digest) return w - }, delivery.PutWanted, "held until released; the streams were snapshotted: novox's restore point", false, false, false, true}, + }, delivery.PutWanted, "held until released; the streams were snapshotted: novox's restore point", false, false, true}, {"a user-list revision of the bus needs nobody", func() PutWorld { w := bus() ul := d ul.UserListOnly = true w.Composed, w.Have = &ul, &delivery.Have{FP: fNew, Composed: dig(fOld), Phase: delivery.PhaseRunning, Health: delivery.HealthOK} return w - }, delivery.PutWanted, "held until released", false, false, false, false}, - {"a module not recorded needs nobody", func() PutWorld { w := bus(); w.Bus = false; return w }, delivery.PutWanted, "names", false, false, false, false}, + }, delivery.PutWanted, "held until released", false, false, false}, + {"a module not recorded needs nobody", func() PutWorld { w := bus(); w.Bus = false; return w }, delivery.PutWanted, "names", false, false, false}, {"a recorded module judged to recreate asks", func() PutWorld { w := bus() w.Bus, w.Recorded = false, true @@ -488,14 +483,14 @@ func stepsOfAPut(t *testing.T) { r.Judgement = delivery.JudgedRecreates w.Composed = &r return w - }, delivery.PutPerson, "waits for the operator's yes", false, true, false, false}, + }, delivery.PutPerson, "waits for the operator's yes", true, false, false}, {"wanted, the node runs it: holding", func() PutWorld { w := bus() w.Put = w.Put.Said(now, delivery.PutWanted, "wanted") w.Put.Composed = d.Digest w.Have = &delivery.Have{FP: fNew, Composed: d.Digest, Phase: delivery.PhaseRunning, Health: delivery.HealthOK} return w - }, delivery.PutHolding, "held there until released", false, false, false, false}, + }, delivery.PutHolding, "held there until released", false, false, false}, {"wanted without hold: done", func() PutWorld { w := bus() w.Put.Hold = false @@ -503,17 +498,17 @@ func stepsOfAPut(t *testing.T) { w.Put.Composed = d.Digest w.Have = &delivery.Have{FP: fNew, Composed: d.Digest, Phase: delivery.PhaseRunning, Health: delivery.HealthOK} return w - }, delivery.PutDone, "the rules write its want again", false, false, false, false}, - {"released: nothing more", func() PutWorld { w := bus(); w.Put = w.Put.Said(now, delivery.PutReleased, "released"); return w }, delivery.PutReleased, "released", false, false, false, false}, + }, delivery.PutDone, "the rules write its want again", false, false, false}, + {"released: nothing more", func() PutWorld { w := bus(); w.Put = w.Put.Said(now, delivery.PutReleased, "released"); return w }, delivery.PutReleased, "released", false, false, false}, } for _, c := range cases { next := StepPut(c.world()) if next.Put.Step != c.step || !strings.Contains(next.Put.Words, c.says) { t.Errorf("put, %s: at %s, %q; want %s saying %q", c.name, next.Put.Step, next.Put.Words, c.step, c.says) } - if next.Compose != c.compose || next.Ask != c.ask || next.Snapshot != c.snap || next.Warranted != c.warranted { - t.Errorf("put, %s: acts compose %v ask %v snapshot %v warranted %v; want %v %v %v %v", c.name, next.Compose, next.Ask, - next.Snapshot, next.Warranted, c.compose, c.ask, c.snap, c.warranted) + if next.Ask != c.ask || next.Snapshot != c.snap || next.Warranted != c.warranted { + t.Errorf("put, %s: acts ask %v snapshot %v warranted %v; want %v %v %v", c.name, next.Ask, next.Snapshot, next.Warranted, + c.ask, c.snap, c.warranted) } if next.Ask && next.Put.Ask == "" { t.Errorf("put, %s: an ask without its key", c.name) diff --git a/modules/mesh-delivery-assigner/manifest_test.go b/modules/mesh-delivery-assigner/manifest_test.go index fc528e4..a236dcf 100644 --- a/modules/mesh-delivery-assigner/manifest_test.go +++ b/modules/mesh-delivery-assigner/manifest_test.go @@ -56,18 +56,13 @@ func TestManifest(t *testing.T) { if !slices.Equal(m.Uses, []string{"node-build-go", "operator-channel"}) { t.Errorf("uses = %v, want exactly node-build-go and operator-channel", m.Uses) } - // ADR 0546: it holds its seat (put, release, puts), served by its bundle through the tool runner (the artifact loads the - // module), says each put's step as the event put, and calls the controller's compose and warranted and the bus node's - // backup holder. - if len(m.Claims) != 1 || m.Claims[0].Name != catalogue.AssignerSeat || len(m.Tools) != 0 { - t.Errorf("the assigner claims %v and serves tools %v, want its own seat alone", m.Claims, m.Tools) + // ADR 0546: it serves its own tools (put, release, puts) from its bundle through the tool runner (the artifact loads the + // module), claims no seat (a new seat needs a change of the controller), says each put's step as the event put, and + // calls the controller's warranted and the bus node's backup holder. + if len(m.Claims) != 0 || !slices.Equal(m.Tools, []string{"assigner_put", "assigner_release", "assigner_puts"}) { + t.Errorf("the assigner claims %v and serves tools %v, want no claim and its three put tools", m.Claims, m.Tools) } - if seat, ok := catalogue.SeatNamed(catalogue.AssignerSeat); !ok { - t.Errorf("the sdk defines no seat %s", catalogue.AssignerSeat) - } else if err := catalogue.CanHold(m, seat); err != nil { - t.Errorf("the assigner cannot hold its seat: %v", err) - } - if want := []string{"seat:mesh-controller.compose", "seat:mesh-controller.warranted", "seat:node-backup.backed-up", "seat:node-backup.now"}; !slices.Equal(slices.Sorted(slices.Values(m.Invokes)), want) { + if want := []string{"seat:mesh-controller.warranted", "seat:node-backup.backed-up", "seat:node-backup.now"}; !slices.Equal(slices.Sorted(slices.Values(m.Invokes)), want) { t.Errorf("invokes = %v, want %v", m.Invokes, want) } if !slices.Equal(m.Emits, []string{"put"}) { diff --git a/modules/mesh-delivery-assigner/module.json b/modules/mesh-delivery-assigner/module.json index bdf441a..3c31dc8 100644 --- a/modules/mesh-delivery-assigner/module.json +++ b/modules/mesh-delivery-assigner/module.json @@ -2,17 +2,6 @@ "module": "mesh-delivery-assigner", "version": "1", "slug": "assigner", - "claims": [ - { - "name": "mesh-delivery-assigner", - "scope": "mesh", - "serves": [ - "put", - "release", - "puts" - ] - } - ], "reads": [ "mesh-build-go.steps", "mesh-controller.assignments", @@ -32,7 +21,6 @@ "operator-channel" ], "invokes": [ - "seat:mesh-controller.compose", "seat:mesh-controller.warranted", "seat:node-backup.now", "seat:node-backup.backed-up" @@ -40,6 +28,11 @@ "emits": [ "put" ], + "tools": [ + "assigner_put", + "assigner_release", + "assigner_puts" + ], "state": [ { "name": "asks", "history": 5 }, { "name": "compositions", "history": 5 }, diff --git a/modules/mesh-delivery-teller/go.mod b/modules/mesh-delivery-teller/go.mod index b0bf04e..2b39b27 100644 --- a/modules/mesh-delivery-teller/go.mod +++ b/modules/mesh-delivery-teller/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-teller go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025195051-20a6dc4920f8 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025200810-d4ae4b6790b8 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-teller/go.sum b/modules/mesh-delivery-teller/go.sum index 2b4dabf..96b0283 100644 --- a/modules/mesh-delivery-teller/go.sum +++ b/modules/mesh-delivery-teller/go.sum @@ -2,6 +2,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468 h1:UcMee+IV git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025195051-20a6dc4920f8 h1:jA7V7hlfJAigVUvJDpKwfgd9ylQ9bqppg3rK9XgLWoA= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025195051-20a6dc4920f8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025200810-d4ae4b6790b8 h1:vhYk/iv43u2WGSTrFyQGDvGBxLaRITgMAiiI9DjuA10= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025200810-d4ae4b6790b8/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/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= From d8477c71b1967117e658ef939c8e84744c25497c Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 25 Oct 2026 21:33:04 +0100 Subject: [PATCH 3/5] A put asks under its own asking and writes its want only while it stands: an earlier put's answer never counts, a put released meanwhile writes nothing, the bus's want is written only for the act the yes bound, an expired ask refuses the put, and a warrant not recorded is said in the put's words (ADR 0546, review round 1) --- .../cmd/mesh-delivery-assigner/pass_test.go | 30 +++++++ .../cmd/mesh-delivery-assigner/puts.go | 85 ++++++++++++++----- .../internal/assign/asks_test.go | 12 +++ .../internal/assign/put.go | 31 +++++-- .../internal/assign/wants_test.go | 1 + 5 files changed, 134 insertions(+), 25 deletions(-) diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go index 71b88a1..0458ebc 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass_test.go @@ -305,6 +305,36 @@ func putOfTheBus(t *testing.T) { if len(bus.events) < 5 || !strings.Contains(strings.Join(bus.events, "\n"), "holding") { t.Errorf("the steps said as events: %v", bus.events) } + // The same bus put again at the same composition after the release is its own asking: its ask is written again under an + // id of its own, and the earlier yes, still at the answers' key, does not count for it (ADR 0546 clause 8). + world(delivery.HaveBucket, hkey, before) + s.now = func() time.Time { return now.Add(time.Minute) } + if _, err := s.doPut(map[string]any{"node": "novox", "module": "nats", "version": fp, "hold": "true", "why": "again"}, "shanks.node-tools"); err != nil { + t.Fatal(err) + } + again := step() + a2, err := delivery.DecodeAssignerAsk(akey, asksKV.values[akey]) + if again.Step != delivery.PutPerson || err != nil || a2.Ask.ID == a.Ask.ID { + t.Fatalf("put again, it stands at %s (%s); its ask %q, %v; the first ask was %q", again.Step, again.Words, a2.Ask.ID, err, a.Ask.ID) + } + world(delivery.AssignerAsksBucket, akey, asksKV.values[akey]) + yes2, _ := delivery.EncodeAnswer(delivery.Answer{Option: assign.OptionYes, Warrant: a2.Ask.ID + "@2026-10-25T22:03:00Z", Words: "the operator, verified"}) + world(delivery.AnswersBucket, akey, yes2) + if got := step(); got.Step != delivery.PutSnapshot { + t.Fatalf("on the second warrant the put stands at %s: %s", got.Step, got.Words) + } + <-woke + // Released between the pass's read and its write: the pass writes no want for the put that no longer stands. + oldWant, _ := delivery.EncodeWant(delivery.Want{FP: delivery.SourcePrefix2 + strings.Repeat("a", 64)}) + wantsKV.values["want.novox.nats"] = oldWant + world(delivery.AssignerWantsBucket, "want.novox.nats", oldWant) + if _, err := s.doRelease(map[string]any{"node": "novox", "module": "nats", "why": "changed my mind"}, "shanks.node-tools"); err != nil { + t.Fatal(err) + } + p.pass(ctx) + if string(wantsKV.values["want.novox.nats"]) != string(oldWant) { + t.Errorf("a want was written for a put released meanwhile: %s", wantsKV.values["want.novox.nats"]) + } } // 1945/AC21: given a module on neither list on a node, when passes follow, then the assigner raises its condition once, says it diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/puts.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/puts.go index c5216be..7549bb9 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/puts.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/puts.go @@ -10,6 +10,7 @@ import ( "sync" "time" + "git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" @@ -45,13 +46,14 @@ type putActs struct { snapped map[string]string snapNo map[string]string warranted map[string]bool + warrantNo map[string]string said map[string]string // snapshot takes the bus's snapshot on node; a variable so a test takes none. snapshot func(ctx context.Context, node, module string) (string, error) } func newPutActs(bus caller, wake func(), logf func(string, ...any)) *putActs { - a := &putActs{bus: bus, wake: wake, logf: logf, snapping: map[string]bool{}, snapped: map[string]string{}, snapNo: map[string]string{}, warranted: map[string]bool{}, + a := &putActs{bus: bus, wake: wake, logf: logf, snapping: map[string]bool{}, snapped: map[string]string{}, snapNo: map[string]string{}, warranted: map[string]bool{}, warrantNo: map[string]string{}, said: map[string]string{}} a.snapshot = a.snapshotTheBus return a @@ -125,9 +127,20 @@ func (p *passer) puts(ctx context.Context) { next.Put.Composed = w.Composed } } + // The bus's want is written only for the act the operator's yes bound (ADR 0546 clause 8). + if next.Put.Step == delivery.PutWanted && put.Step != delivery.PutWanted && world.Bus && next.Put.Ask != "" { + if err := p.performs(next.Put.Ask, assign.PutAct(node, module, next.Put.FP, next.Put.Composed)); err != nil { + next = assign.PutNext{Put: next.Put.Said(world.Now, delivery.PutRefused, "the bus's want is not written: "+err.Error())} + } + } + if failed := p.acts.warrantFailed(id); failed != "" && (next.Put.Step == delivery.PutWanted || next.Put.Step == delivery.PutHolding) && + !strings.Contains(next.Put.Words, failed) { + next.Put = next.Put.Said(world.Now, next.Put.Step, next.Put.Words+"; "+failed) + } p.actOn(ctx, node, module, id, world, next) - if !samePut(put, next.Put) { - p.writePut(ctx, key, put, next.Put) + written := samePut(put, next.Put) || p.writePut(ctx, key, put, next.Put) + if !written { + continue // released or put again meanwhile: nothing is done for the put that no longer stands } if next.Put.Step == delivery.PutWanted && put.Step != delivery.PutWanted { // The want is written in this pass too, before the next one would: Module.Puts writes the same. @@ -137,6 +150,9 @@ func (p *passer) puts(ctx context.Context) { p.put(ctx, delivery.AssignerWantsBucket, wkey, func() ([]byte, error) { return delivery.EncodeWant(want) }) } } + if next.Warranted { + p.acts.warrant(id, delivery.AskingID(next.Put.Ask, assign.PutAsking(next.Put))) + } } } } @@ -172,11 +188,16 @@ func (p *passer) putWorld(node, module string, put delivery.Put, manifest catalo } w.Recorded = policy == catalogue.PolicyRecord if akey, err := delivery.RecordAskKey(node, module, d.Digest); err == nil && put.Ask == akey { - switch { - case p.answeredWith(akey, assign.OptionYes): - w.Answer = assign.OptionYes - case p.answeredWith(akey, assign.OptionNo): - w.Answer = assign.OptionNo + // Only this put's own ask counts: an ask (and its answer) of an earlier put under the same key is another asking. + if a, err := delivery.DecodeAssignerAsk(akey, p.w.value(delivery.AssignerAsksBucket, akey)); err == nil && a.Asking == assign.PutAsking(put) { + switch { + case p.answeredWith(akey, assign.OptionYes): + w.Answer = assign.OptionYes + case p.answeredWith(akey, assign.OptionNo): + w.Answer = assign.OptionNo + default: + w.AskExpired = !a.Ask.Expires.IsZero() && w.Now.After(a.Ask.Expires) + } } } } @@ -212,9 +233,12 @@ func (p *passer) composedFor(node, module, fp string) (*delivery.ComposedDigest, // actOn does the acts a step asks for. func (p *passer) actOn(ctx context.Context, node, module, id string, w assign.PutWorld, next assign.PutNext) { - if next.Ask && w.Composed != nil { + if next.Put.Step == delivery.PutPerson && next.Put.Ask != "" && w.Composed != nil { + // The put's own ask, written again over one an earlier put left under the key (its asking differs), so the teller + // raises it anew under its own id. akey := next.Put.Ask - if !p.w.has(delivery.AssignerAsksBucket, akey) { + current, err := delivery.DecodeAssignerAsk(akey, p.w.value(delivery.AssignerAsksBucket, akey)) + if err != nil || current.Asking != assign.PutAsking(next.Put) { a, err := assign.PutAsk(akey, node, module, next.Put, *w.Composed, w.Bus, w.Now) if err != nil { p.logf("the ask about putting %s on %s is not written: %v", module, node, err) @@ -226,9 +250,20 @@ func (p *passer) actOn(ctx context.Context, node, module, id string, w assign.Pu if next.Snapshot { p.acts.snap(ctx, id, node, module) } - if next.Warranted { - p.acts.warrant(id, delivery.AskingID(next.Put.Ask, 0)) +} + +// performs checks that the act about to be performed is the one the yes of the ask at key bound (asks.Option.Performs). +func (p *passer) performs(key string, act asks.Act) error { + a, err := delivery.DecodeAssignerAsk(key, p.w.value(delivery.AssignerAsksBucket, key)) + if err != nil { + return fmt.Errorf("the ask %s cannot be read: %w", key, err) } + for _, o := range a.Ask.Options { + if o.ID == assign.OptionYes { + return o.Performs(act) + } + } + return fmt.Errorf("the ask %s offers no yes", key) } // samePut says two puts say the same: their step, words and what they found. @@ -237,35 +272,36 @@ func samePut(a, b delivery.Put) bool { } // writePut writes a put's next value over the one the pass read, by compare-and-set, unless the tool has put again or -// released it since; and says it as the event put. -func (p *passer) writePut(ctx context.Context, key string, read, next delivery.Put) { +// released it since; and says it as the event put. It says whether it wrote. +func (p *passer) writePut(ctx context.Context, key string, read, next delivery.Put) bool { kv := p.kvs[delivery.AssignerPutsBucket] if kv == nil { - return + return false } e, err := kv.Get(ctx, key) if err != nil { p.logf("the put %s cannot be read again, and is taken further at the next pass: %v", key, err) - return + return false } now, err := delivery.DecodePut(e.Value()) if err != nil || !now.At.Equal(read.At) || now.Step != read.Step { - return // asked again or released meanwhile: the next pass reads that + return false // asked again or released meanwhile: the next pass reads that } data, err := delivery.EncodePut(next) if err != nil { p.logf("the put %s is not written: %v", key, err) - return + return false } if _, err := kv.Update(ctx, key, data, e.Revision()); err != nil { if !errors.Is(err, context.Canceled) { p.logf("the put %s could not be written, and is taken further at the next pass: %v", key, err) } - return + return false } p.w.put(delivery.AssignerPutsBucket, key, data) p.logf("put %s: %s: %s", key, next.Step, next.Words) p.acts.say(key, next) + return true } // state is what this process knows of a put's acts. @@ -359,10 +395,21 @@ func (a *putActs) warrant(id, ask string) { go func() { if _, err := a.bus.Call("seat:"+catalogue.ControllerSeatName+".warranted", map[string]any{"asker": delivery.TellerModule, "ask": ask}); err != nil { a.logf("the put's warrant %s is not recorded in the hand-act log: %v", ask, err) + a.mu.Lock() + a.warrantNo[id] = fmt.Sprintf("the warrant %s is not recorded in the controller's hand-act log: %v", ask, err) + a.mu.Unlock() + a.wake() } }() } +// warrantFailed says why the put's warrant was not recorded, "" when it was or is not yet known. +func (a *putActs) warrantFailed(id string) string { + a.mu.Lock() + defer a.mu.Unlock() + return a.warrantNo[id] +} + // putSaid is the body of the event put. type putSaid struct { Node string `json:"node"` diff --git a/modules/mesh-delivery-assigner/internal/assign/asks_test.go b/modules/mesh-delivery-assigner/internal/assign/asks_test.go index 1fa82b1..0f0fbdb 100644 --- a/modules/mesh-delivery-assigner/internal/assign/asks_test.go +++ b/modules/mesh-delivery-assigner/internal/assign/asks_test.go @@ -171,6 +171,18 @@ func TestTheRecordAndBackupAsksAreValid(t *testing.T) { case !bus && (yes.Level != asks.Acknowledge || viewed != nil): t.Errorf("a recorded module: the yes is %+v, the view's answer %v", yes, viewed) } + later := put + later.At = now.Add(time.Minute) + b, _ := PutAsk(rkey, "ace", "mail", later, d, bus, now) + if b.Ask.ID == a.Ask.ID || b.Asking != PutAsking(later) { + t.Errorf("bus %v: a later put's ask is asking %d under id %q, the earlier's %q", bus, b.Asking, b.Ask.ID, a.Ask.ID) + } + if err := yes.Performs(PutAct("ace", "mail", fNew, dgst)); err != nil { + t.Errorf("bus %v: the yes does not perform the act it bound: %v", bus, err) + } + if err := yes.Performs(PutAct("ace", "mail", fNew, dig(fOld))); bus && err == nil { + t.Errorf("bus: the yes performs another composition's act") + } } } diff --git a/modules/mesh-delivery-assigner/internal/assign/put.go b/modules/mesh-delivery-assigner/internal/assign/put.go index e6f13c8..6a4d283 100644 --- a/modules/mesh-delivery-assigner/internal/assign/put.go +++ b/modules/mesh-delivery-assigner/internal/assign/put.go @@ -32,8 +32,10 @@ type PutWorld struct { // none. Granted says the grant at the fingerprint names the node's accounts. Both are read, never asked for (clause 4). Composed *delivery.ComposedDigest Granted bool - // Answer is the option the operator chose on the put's ask for the composition, "" when none. - Answer string + // Answer is the option the operator chose on this put's own ask (its asking, PutAsking), "" when none; AskExpired says + // that ask expired unanswered. + Answer string + AskExpired bool // Snapshot says where the bus's streams were snapshotted for this put, once they were; Snapshotting that it was // asked; SnapshotFailed why it was not taken. Snapshot string @@ -108,6 +110,9 @@ func StepPut(w PutWorld) PutNext { return say(delivery.PutRefused, "the operator answered no to putting %s at %s on %s", w.Module, fp, w.Node) case OptionYes: default: + if w.AskExpired { + return say(delivery.PutRefused, "the operator's ask about putting %s at %s on %s expired unanswered: the node keeps what it runs", w.Module, fp, w.Node) + } next := say(delivery.PutPerson, "waits for the operator's yes to putting %s at %s on %s (composition %s, judged %s)", w.Module, fp, w.Node, short(d.Digest), d.Judgement) if p.Ask == "" { next.Ask = true @@ -194,6 +199,21 @@ func PutOwns(p delivery.Put) (want *delivery.Want, owns bool) { return nil, true } +// PutAsking is a put's own asking number: when it was asked, in milliseconds. Each put asked under the same key is another +// asking, so the ask is written again under an id of its own and an answer to an earlier put, whose warrant names that put's +// asking, never counts for a later one (ADR 0546 clause 8: one approval binds one act). +func PutAsking(p delivery.Put) int { + if n := p.At.UnixMilli(); n > 0 { + return int(n) + } + return 0 +} + +// PutAct is the act a bus put's yes binds and that is performed when its want is written. +func PutAct(node, module, fp, composed string) asks.Act { + return asks.Act{"put": "want", "node": node, "module": module, "fp": fp, "composed": composed} +} + // PutAsk is the ask about a put's move (ADR 0546 clauses 7 and 8), under the record ask's key of the composition so the // teller relays it and its answer as any record ask's. For the bus its yes is of level approve and binds the act, so only a // channel that proves who answered takes it (ADR 0259), never the view. @@ -209,8 +229,7 @@ func PutAsk(key, node, module string, p delivery.Put, d delivery.ComposedDigest, explanation := fmt.Sprintf("Asked to put %s at version %s on %s: %s. The move %s.", module, short(p.FP), node, p.Why, what) expires := now.Add(7 * 24 * time.Hour) if bus { - act := asks.Act{"put": "want", "node": node, "module": module, "fp": p.FP, "composed": d.Digest} - binds, err := asks.ActDigest(act) + binds, err := asks.ActDigest(PutAct(node, module, p.FP, d.Digest)) if err != nil { return delivery.AssignerAsk{}, err } @@ -220,7 +239,7 @@ func PutAsk(key, node, module string, p delivery.Put, d delivery.ComposedDigest, expires = now.Add(asks.ApproveLasts) } return delivery.AssignerAsk{Ask: asks.Ask{ - ID: delivery.AskingID(key, 0), + ID: delivery.AskingID(key, PutAsking(p)), Headline: fmt.Sprintf("Put %s on %s?", module, node), Explanation: explanation, Options: []asks.Option{yes, @@ -229,5 +248,5 @@ func PutAsk(key, node, module string, p delivery.Put, d delivery.ComposedDigest, Expires: expires, OnExpiry: "the node keeps what it runs", About: key, - }}, nil + }, Asking: PutAsking(p)}, nil } diff --git a/modules/mesh-delivery-assigner/internal/assign/wants_test.go b/modules/mesh-delivery-assigner/internal/assign/wants_test.go index f1a3684..7c213b4 100644 --- a/modules/mesh-delivery-assigner/internal/assign/wants_test.go +++ b/modules/mesh-delivery-assigner/internal/assign/wants_test.go @@ -459,6 +459,7 @@ func stepsOfAPut(t *testing.T) { }, delivery.PutRefused, "mesh-hand-compose", false, false, false}, {"the bus asks the operator", bus, delivery.PutPerson, "waits for the operator's yes", true, false, false}, {"the operator said no", func() PutWorld { w := bus(); w.Answer = OptionNo; return w }, delivery.PutRefused, "answered no", false, false, false}, + {"the ask expired unanswered", func() PutWorld { w := bus(); w.AskExpired = true; return w }, delivery.PutRefused, "expired unanswered", false, false, false}, {"yes: the snapshot is asked", func() PutWorld { w := bus(); w.Answer = OptionYes; return w }, delivery.PutSnapshot, "asked novox's backup holder", false, true, false}, {"the snapshot is taken", func() PutWorld { w := bus(); w.Answer, w.Snapshotting = OptionYes, true; return w }, delivery.PutSnapshot, "waits for novox's backup holder", false, false, false}, {"the snapshot failed", func() PutWorld { w := bus(); w.Answer, w.SnapshotFailed = OptionYes, "no restic"; return w }, delivery.PutRefused, "no restic", false, false, false}, From 7b618b2317dae6be4dc98a6b8a99c9b94094e9c9 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 25 Oct 2026 21:37:17 +0100 Subject: [PATCH 4/5] Pin the assigner and the teller to mesh-sdk main db26f7e, which carries the put (ADR 0546) --- modules/mesh-delivery-assigner/go.mod | 2 +- modules/mesh-delivery-assigner/go.sum | 2 ++ modules/mesh-delivery-teller/go.mod | 2 +- modules/mesh-delivery-teller/go.sum | 2 ++ 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/modules/mesh-delivery-assigner/go.mod b/modules/mesh-delivery-assigner/go.mod index 7a109b9..893c786 100644 --- a/modules/mesh-delivery-assigner/go.mod +++ b/modules/mesh-delivery-assigner/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-assigner go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025200810-d4ae4b6790b8 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025203528-db26f7e8b6f5 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-assigner/go.sum b/modules/mesh-delivery-assigner/go.sum index 5164f2d..2d6d7be 100644 --- a/modules/mesh-delivery-assigner/go.sum +++ b/modules/mesh-delivery-assigner/go.sum @@ -4,6 +4,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025195051-20a6dc4920f8 h1:jA7V7hlf git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025195051-20a6dc4920f8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025200810-d4ae4b6790b8 h1:vhYk/iv43u2WGSTrFyQGDvGBxLaRITgMAiiI9DjuA10= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025200810-d4ae4b6790b8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025203528-db26f7e8b6f5 h1:gE80bR0tPME7MTMBLcdP62v+F/9soVXmdcBYNMOye44= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025203528-db26f7e8b6f5/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/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-teller/go.mod b/modules/mesh-delivery-teller/go.mod index 2b39b27..db3470d 100644 --- a/modules/mesh-delivery-teller/go.mod +++ b/modules/mesh-delivery-teller/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-teller go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025200810-d4ae4b6790b8 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025203528-db26f7e8b6f5 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-teller/go.sum b/modules/mesh-delivery-teller/go.sum index 96b0283..97c314d 100644 --- a/modules/mesh-delivery-teller/go.sum +++ b/modules/mesh-delivery-teller/go.sum @@ -4,6 +4,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025195051-20a6dc4920f8 h1:jA7V7hlf git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025195051-20a6dc4920f8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025200810-d4ae4b6790b8 h1:vhYk/iv43u2WGSTrFyQGDvGBxLaRITgMAiiI9DjuA10= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025200810-d4ae4b6790b8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025203528-db26f7e8b6f5 h1:gE80bR0tPME7MTMBLcdP62v+F/9soVXmdcBYNMOye44= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025203528-db26f7e8b6f5/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/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= From 22d4cbd14423856dc79d4e70c19b3afce8b5099d Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 25 Oct 2026 21:48:32 +0100 Subject: [PATCH 5/5] The assigner's start test expects every state its manifest declares, read from module.json, not a count: the state puts broke the count of four (ADR 0546) --- .../integration_test.go | 23 +++++++++++++++---- .../mesh-delivery-assigner/manifest_test.go | 2 +- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go index a8c4365..ab8d70d 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/integration_test.go @@ -19,6 +19,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/announce" "git.novox.be/novox/mesh-sdk/go/asks" + "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/process" "git.novox.be/novox/mesh-sdk/go/provider" @@ -125,8 +126,8 @@ func (b *bed) startStoppable(t *testing.T) (<-chan error, func()) { return done, stopped } -// 1297/B2b: started whole on its credential, the assigner says on the bus the four states it keeps, each readable, -// having opened every bucket it names. +// 1297/B2b: started whole on its credential, the assigner says on the bus every state its manifest declares, each readable, +// having opened every bucket it names. The states are read from module.json, so a state added there is expected here too. func TestTheAssignerStartsAndSaysWhatItKeeps(t *testing.T) { b := startBed(t, "") done := b.start(t) @@ -150,9 +151,21 @@ func TestTheAssignerStartsAndSaysWhatItKeeps(t *testing.T) { } break } - if said.Module != "mesh-delivery-assigner" || said.Node != "n1" || len(said.States) != 4 || !said.Readable("asks") || - !said.Readable("wants") || !said.Readable("differences") || !said.Readable("compositions") { - t.Fatalf("the assigner said %+v, want its four states, each readable", said) + raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) + if err != nil { + t.Fatal(err) + } + m, err := catalogue.ParseManifest(raw) + if err != nil { + t.Fatal(err) + } + if said.Module != "mesh-delivery-assigner" || said.Node != "n1" || len(said.States) != len(m.State) { + t.Fatalf("the assigner said %+v, want the %d states its manifest declares", said, len(m.State)) + } + for _, s := range m.State { + if !said.Readable(s.Name) { + t.Errorf("the assigner said %+v: its state %s is not readable", said, s.Name) + } } } diff --git a/modules/mesh-delivery-assigner/manifest_test.go b/modules/mesh-delivery-assigner/manifest_test.go index a236dcf..cf387b7 100644 --- a/modules/mesh-delivery-assigner/manifest_test.go +++ b/modules/mesh-delivery-assigner/manifest_test.go @@ -24,7 +24,7 @@ func load(t *testing.T) catalogue.Manifest { // 1297/B2a: the controller's reader (mesh-sdk catalogue, which the controller reads manifests with) takes the // manifest; it reads exactly the buckets the assigner opens (the program's list, held equal in its own test), keeps -// its four states (the differences kept read-only, 1906/D5), and holds a credential of its own. +// the states it declares (the differences kept read-only, 1906/D5), and holds a credential of its own. func TestManifest(t *testing.T) { m := load(t) if m.Module != "mesh-delivery-assigner" {