Files
mesh-controller/internal/broker/derived_test.go
T
jochen 826dcb91b1 Keep what a consumer gives up on until a person delivers it again or drops it
Design 25 promised a dead-letter stream that did not exist: a message a
consumer gave up on stayed only in its source, which drops it after a week,
and its condition cleared when the advisories stopped (hq issue 330, ADR 0264).
2026-10-08 18:32:58 +02:00

183 lines
7.8 KiB
Go

package broker
import (
"strings"
"testing"
)
func telegramSeat() DeclaredSeat {
return DeclaredSeat{Name: "telegram-sender", Accepts: []string{"send"}}
}
// The stream exists from registration, not assignment: work queues until a holder appears, so
// installing the module a week later flushes the backlog rather than having lost it.
func TestASeatGetsAWorkQueueOfItsOwn(t *testing.T) {
got := SeatStreams([]DeclaredSeat{telegramSeat()})
if len(got) != 1 {
t.Fatalf("expected one stream, got %d", len(got))
}
s := got[0]
if s.Retention != RetentionWorkQueue {
t.Fatalf("a seat's inbound queue retains as %q; one holder must take each message once", s.Retention)
}
if s.Subjects[0] != "mesh.seat.telegram-sender.accept.>" {
t.Fatalf("filters on %v", s.Subjects)
}
}
// A seat that only emits and serves needs no stream: its events ride EVENTS and its tools are
// core request/reply, which is never persisted.
func TestASeatThatAcceptsNothingGetsNoStream(t *testing.T) {
if got := SeatStreams([]DeclaredSeat{{Name: "announcer"}}); len(got) != 0 {
t.Fatalf("a seat with no inbound work got %d stream(s)", len(got))
}
}
// Retention belongs to whoever owns the namespace, and a seat owns its own.
func TestASeatsRetentionIsItsOwn(t *testing.T) {
s := SeatStreams([]DeclaredSeat{{Name: "slow", Accepts: []string{"work"}, RetainSeconds: 30 * 24 * 60 * 60}})
if s[0].MaxAge != 30*24*60*60 {
t.Fatalf("the seat's declared retention was not used: %d", s[0].MaxAge)
}
d := SeatStreams([]DeclaredSeat{telegramSeat()})
if d[0].MaxAge == 0 {
t.Fatal("a seat that declares no retention got an unbounded queue")
}
}
// NATS refuses an overlapping stream outright, so a clash here is a registration that fails.
func TestNoDerivedStreamOverlapsTheMeshsOwn(t *testing.T) {
seats := []DeclaredSeat{telegramSeat(), {Name: "licensing-master", Accepts: []string{"report"}}}
if c := AllOverlaps(seats); len(c) != 0 {
t.Fatalf("overlapping filters: %v", c)
}
}
// One consumer per module, with every consumed subject as a filter — because its ack permission
// is derived from its name, and a consumer per event would need an ack permission per consumer.
func TestAModuleGetsOneConsumerCarryingEveryFilter(t *testing.T) {
c, ok := ConsumerFor(Principal{Kind: KindModule, Node: "one", Module: "audit",
Consumes: []string{"shop.order.placed", "billing.invoice.sent"}, PasswordHash: "x"})
if !ok {
t.Fatal("a module that consumes got no consumer")
}
// Both, and its own share of what is delivered again (novox/hq issue 330).
if len(c.Filters) != 3 || c.Filters[2] != "mesh.again.one_audit.>" {
t.Fatalf("expected both subjects and its own again filter, got %v", c.Filters)
}
perms, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "audit",
Consumes: []string{"shop.order.placed"}, PasswordHash: "x"})
ack := "$JS.ACK." + c.Stream + "." + c.Name + ".>"
found := false
for _, p := range perms.Publish {
if p == ack {
found = true
}
}
if !found {
t.Fatalf("the consumer is named %q but the ack permission is %v; a module could not ack "+
"its own deliveries", c.Name, perms.Publish)
}
}
// A module that subscribes to nothing needs no consumer, and creating one would leave an object
// nothing reads and everything has to maintain.
func TestAModuleThatConsumesNothingGetsNoConsumer(t *testing.T) {
if _, ok := ConsumerFor(Principal{Kind: KindModule, Node: "one", Module: "shop",
Emits: []string{"order.placed"}, PasswordHash: "x"}); ok {
t.Fatal("a pure emitter got a consumer")
}
}
// The seat is authority and the worker is delivery (novox/hq ADR 0190): one worker per seat, shared
// by every holder and pulled from, so a second holder takes the next ask rather than a copy of the
// same one — which is what a queue group used to guard, and what pulling one durable gives outright.
func TestAHoldersWorkerIsOneSharedByItsHolders(t *testing.T) {
c, ok := HolderConsumerFor("one", "telegram", telegramSeat())
if !ok {
t.Fatal("the holder of a seat with inbound work got no worker")
}
two, _ := HolderConsumerFor("two", "telegram", telegramSeat())
if c.Name != two.Name || c.Stream != two.Stream {
t.Fatal("two holders got two workers, so each would process every ask")
}
if c.Push || c.Queue != "" {
t.Fatal("the worker is pushed, so the server would hand an ask to a busy holder")
}
if c.Stream != "SEAT_TELEGRAM_SENDER" {
t.Fatalf("the worker reads %q, not the seat's own stream", c.Stream)
}
if c.MaxDeliver == 0 {
t.Fatal("a failing worker would redeliver forever rather than dead-letter")
}
}
// The stream is named after the seat, not its holder: the holder can change and the queued work
// must not care.
func TestASeatsStreamIsNamedAfterTheSeat(t *testing.T) {
name := seatStreamName("telegram-sender")
if strings.Contains(name, "telegram-sender") {
t.Fatalf("%q keeps characters a stream name may not hold", name)
}
if name != "SEAT_TELEGRAM_SENDER" {
t.Fatalf("unexpected stream name %q", name)
}
}
// A node hears its declaration through a consumer only the controller can make.
//
// The three things that would each break it silently: a name other than the node's is one the host
// cannot acknowledge a delivery from, because its ack grant is derived from the node's name; a
// filter other than its own declaration subject is a node reading another's; and a pull consumer is
// one the host cannot bind a channel to without creating something, which it has no authority for.
func TestANodesDeclarationConsumerIsWhatItsOwnGrantAllows(t *testing.T) {
c := NodeConsumer("anchor")
if c.Name != "anchor" {
t.Fatalf("named %q, so the node cannot ack from it: its grant is $JS.ACK.NODES.anchor.>", c.Name)
}
if c.Stream != "NODES" {
t.Fatalf("on stream %q rather than the one declarations live in", c.Stream)
}
if len(c.Filters) != 1 || c.Filters[0] != "mesh.node.anchor.declare" {
t.Fatalf("filters %v, which is not this node's own declaration and nothing else", c.Filters)
}
if !c.Push {
t.Fatal("pulled, which a host cannot do: pulling needs the JetStream API and a host reaches none of it")
}
if c.MaxDeliver != 0 {
t.Fatalf("max-deliver %d: a declaration a node has not taken yet is not one to dead-letter, "+
"because the stream holds exactly one per node", c.MaxDeliver)
}
// And the grant the node actually gets has to match, or none of the above matters.
perms, err := PermissionsFor(Principal{Kind: KindNode, Node: "anchor"})
if err != nil {
t.Fatal(err)
}
// Without the ack grant every declaration a node receives is redelivered for ever; without the
// subscribe grant its consumer delivers to nobody.
has(t, perms.Publish, "$JS.ACK.NODES."+c.Name+".>")
has(t, perms.Subscribe, c.Filters[0])
}
// Every holder of a seat shares one worker and pulls from it (novox/hq ADR 0190): no queue group
// and no delivery subject, because a push consumer hands the next ask to whichever subscriber the
// server picks, busy or not; and no cap of one in flight, because pulling bounds the asks in flight
// by the holders that are free — which is what ended the race of issue 186, where asks delivered
// behind the one being worked expired and were dropped.
func TestAHoldersWorkerIsPulledByEveryHolder(t *testing.T) {
c, found := HolderConsumerFor("anchor", "build-agent", DeclaredSeat{Name: "node-build-agent", Accepts: []string{"build"}})
if !found {
t.Fatal("a seat that accepts work has no worker")
}
if c.Queue != "" || c.Push {
t.Fatalf("the worker is pushed (queue %q, push %v); a holder pulls when it is free", c.Queue, c.Push)
}
if c.MaxAckPending != 0 {
t.Fatalf("the worker caps asks in flight at %d; pulling bounds them by the holders working", c.MaxAckPending)
}
if c.Name != "SEAT_NODE_BUILD_AGENT_worker" || c.Stream != "SEAT_NODE_BUILD_AGENT" {
t.Fatalf("the worker is %s on %s; one per seat, shared by its holders", c.Name, c.Stream)
}
}