tool runner: mesh/stream.publish says an event on the calling bundle's own bound stream (A-42)

Builds: design 42 zfs-owns-zfs §25, §24, lead answer A-42
This commit is contained in:
2026-11-13 01:49:02 +01:00
parent 447a8d0359
commit a775b42e4d
6 changed files with 97 additions and 2 deletions
+35
View File
@@ -275,6 +275,41 @@ func (c *Conn) StreamFill(module string, r buscalls.StreamRequest) (buscalls.Str
return c.streamFill(b.Stream)
}
// StreamPublish says one event on the module's own bound stream, by an acknowledged publish on the binding's subject plus
// "." plus the event, the id as the message id (design 42 zfs-owns-zfs, A-42). The module's own binding alone: another
// module's, and a binding that is not a stream, are refused by Binding.
func (c *Conn) StreamPublish(module string, r buscalls.StreamPublishRequest) (buscalls.StreamPublished, error) {
b, err := c.streamBinding(module, r.Stream)
if err != nil {
return buscalls.StreamPublished{}, err
}
if b.Subject == "" {
return buscalls.StreamPublished{}, fmt.Errorf("%s's stream %q has no subject to say an event on", module, r.Stream)
}
if r.Event == "" || strings.ContainsAny(r.Event, " *>\t\r\n") || strings.HasPrefix(r.Event, ".") || strings.HasSuffix(r.Event, ".") {
return buscalls.StreamPublished{}, fmt.Errorf("the event %q is not a name to say on %s's stream %q", r.Event, module, r.Stream)
}
body := []byte(r.Body)
if len(body) == 0 {
body = []byte("null")
}
ctx, cancel := context.WithTimeout(context.Background(), StateTimeout)
defer cancel()
js, err := c.jetStream()
if err != nil {
return buscalls.StreamPublished{}, err
}
var opts []jetstream.PublishOpt
if r.ID != "" {
opts = append(opts, jetstream.WithMsgID(r.ID))
}
ack, err := js.Publish(ctx, b.Subject+"."+r.Event, body, opts...)
if err != nil {
return buscalls.StreamPublished{}, fmt.Errorf("the event %s was not acknowledged on %s's stream %q: %w", r.Event, module, r.Stream, err)
}
return buscalls.StreamPublished{Sequence: ack.Sequence}, nil
}
// StreamReadMost is the most messages one read answers.
const StreamReadMost = 1000
+26
View File
@@ -2,6 +2,7 @@ package bus_test
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
@@ -81,6 +82,23 @@ func TestABundleReachesItsOwnBoundObjectsAndStreamsOnly(t *testing.T) {
t.Fatalf("fill %+v, %v", fill, err)
}
// A-42: an event said on the module's own stream lands on the binding's subject plus the event, acknowledged, and a
// second say with the same id is the same message.
said, err := conn.StreamPublish("m", buscalls.StreamPublishRequest{Stream: "events", Event: "scrub.requested",
Body: json.RawMessage(`{"pool":"tank"}`), ID: "id-1"})
if err != nil || said.Sequence != 3 {
t.Fatalf("said %+v, %v", said, err)
}
if again, err := conn.StreamPublish("m", buscalls.StreamPublishRequest{Stream: "events", Event: "scrub.requested",
Body: json.RawMessage(`{"pool":"tank"}`), ID: "id-1"}); err != nil || again.Sequence != 3 {
t.Fatalf("said again %+v, %v", again, err)
}
if page, err := conn.StreamRead("m", buscalls.StreamReadRequest{Stream: "events", From: 3,
Subjects: "mesh.state.m.events.scrub.requested"}); err != nil || len(page.Messages) != 1 ||
string(page.Messages[0].Body) != `{"pool":"tank"}` {
t.Fatalf("read back %+v, %v", page, err)
}
for _, c := range []struct {
name, module, refusal string
call func(module string) error
@@ -97,6 +115,14 @@ func TestABundleReachesItsOwnBoundObjectsAndStreamsOnly(t *testing.T) {
_, err := conn.ObjectsList(module, buscalls.ObjectsRequest{Objects: "events"})
return err
}},
{"say on another module's stream", "other", `other was bound no stream called "events"`, func(module string) error {
_, err := conn.StreamPublish(module, buscalls.StreamPublishRequest{Stream: "events", Event: "x"})
return err
}},
{"say on a binding that is not a stream", "m", `"files" is bound with the storage type object-store, not stream`, func(module string) error {
_, err := conn.StreamPublish(module, buscalls.StreamPublishRequest{Stream: "files", Event: "x"})
return err
}},
{"a work queue not bound", "m", `bound no stream called "jobs"`, func(module string) error {
_, err := conn.WorkQueueSize(module, buscalls.WorkQueueRequest{Queue: "jobs"})
return err
+1 -1
View File
@@ -120,7 +120,7 @@ type Bus interface {
// busStateCalls are the methods of mesh-sdk buscalls a bundle asks and BusState answers.
var busStateCalls = map[string]bool{
buscalls.ObjectsPut: true, buscalls.ObjectsGet: true, buscalls.ObjectsList: true, buscalls.ObjectsDelete: true,
buscalls.ObjectsInfo: true, buscalls.StreamRead: true, buscalls.StreamFill: true, buscalls.WorkQueueSize: true,
buscalls.ObjectsInfo: true, buscalls.StreamRead: true, buscalls.StreamFill: true, buscalls.StreamPublish: true, buscalls.WorkQueueSize: true,
buscalls.WorkQueueDead: true, buscalls.LedgerApply: true, buscalls.LedgerGet: true, buscalls.LedgerHistory: true,
buscalls.LedgerQuery: true, buscalls.LedgerRebuild: true, buscalls.LedgerReindex: true, buscalls.LedgerSnapshot: true,
buscalls.LedgerLag: true,
@@ -0,0 +1,14 @@
package launch
import (
"testing"
"git.novox.be/novox/mesh-sdk/go/buscalls"
)
// A-42: a launched bundle may ask mesh/stream.publish.
func TestABundleMayAskStreamPublish(t *testing.T) {
if !busStateCalls[buscalls.StreamPublish] {
t.Fatal("mesh/stream.publish is not among the methods a bundle may ask")
}
}
+6 -1
View File
@@ -21,7 +21,7 @@ const (
// callFamilies are the methods each capability stands for, the ones BusState and BusStateWatch answer.
var callFamilies = map[string][]string{
CapBusStateCalls: {buscalls.ObjectsPut, buscalls.ObjectsGet, buscalls.ObjectsList, buscalls.ObjectsDelete, buscalls.ObjectsInfo,
buscalls.ObjectsWatch, buscalls.StreamRead, buscalls.StreamFill, buscalls.WorkQueueSize, buscalls.WorkQueueDead},
buscalls.ObjectsWatch, buscalls.StreamRead, buscalls.StreamFill, buscalls.StreamPublish, buscalls.WorkQueueSize, buscalls.WorkQueueDead},
CapLedgerCalls: {buscalls.LedgerApply, buscalls.LedgerGet, buscalls.LedgerHistory, buscalls.LedgerWatch, buscalls.LedgerQuery,
buscalls.LedgerRebuild, buscalls.LedgerReindex, buscalls.LedgerSnapshot, buscalls.LedgerLag},
}
@@ -85,6 +85,11 @@ func (b *moduleBus) BusState(method string, params json.RawMessage) (json.RawMes
if err = decode(method, params, &r); err == nil {
answer, err = conn.StreamFill(b.module, r)
}
case buscalls.StreamPublish:
var r buscalls.StreamPublishRequest
if err = decode(method, params, &r); err == nil {
answer, err = conn.StreamPublish(b.module, r)
}
case buscalls.WorkQueueSize:
var r buscalls.WorkQueueRequest
if err = decode(method, params, &r); err == nil {
@@ -0,0 +1,15 @@
package runtime
import (
"slices"
"testing"
"git.novox.be/novox/mesh-sdk/go/buscalls"
)
// A-42: mesh/stream.publish is part of the bus-state-calls capability a module's manifest asks for.
func TestStreamPublishIsPartOfTheBusStateCallsCapability(t *testing.T) {
if !slices.Contains(callFamilies[CapBusStateCalls], buscalls.StreamPublish) {
t.Fatal("bus-state-calls does not state mesh/stream.publish")
}
}