Task 3.9's other half and 1.4's missing client. The derivation is pure and unit-tested; only "does the server accept this" needs one running, behind MESH_TEST_NATS so the ordinary suite stays offline. A seat's work queue is created at registration, not assignment, so work queues until a holder appears — a stream created at assignment would make "the holder is not here yet" mean "your messages are gone". Named after the seat, because the holder can change and the queued work must not care. A holder's worker uses a queue group even though the seat guarantees one holder: the seat is authority, the queue group is delivery, and tying them together means the day somebody allows two holders every message is processed twice with nothing reporting it. One consumer per module carrying every filter, because its ack permission is derived from its name. And a real bug the live server caught: a durable name may not contain a dot, but an ack subject is $JS.ACK.<stream>.<consumer>, so the single string that read correctly inside the permission was rejected as a consumer name. Split in two, beside the permission that has to match. Unfixed, the symptom would have been every message redelivered forever with a permission list that looks right — which is the failure design 25 §4 warns about.
179 lines
6.8 KiB
Go
179 lines
6.8 KiB
Go
package broker
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
)
|
|
|
|
// Streams and consumers derived from what modules declare.
|
|
//
|
|
// The mesh's own four exist before any module does (streams.go). Everything here is the other
|
|
// half: a seat's stream comes into being when the module declaring it is **registered**, and a
|
|
// consumer when a module is **assigned** — which is why ADR 0116's task 1.4 had to be narrowed to
|
|
// the foundation set. Neither has happened at genesis.
|
|
//
|
|
// All of it is a pure function of declarations. The controller is still the only writer; this is
|
|
// only what it writes.
|
|
|
|
// A Consumer is a durable subscription the controller creates on a module's behalf. A module
|
|
// declares what it reacts to, never how delivery works, so it does not name these and cannot
|
|
// misconfigure them.
|
|
type Consumer struct {
|
|
Name string
|
|
Stream string
|
|
// Filters are the subjects this consumer receives. One consumer per module with several
|
|
// filters, rather than one per consumed event: its ack subject is derived from its name, and
|
|
// a module with five consumers would need five ack permissions to ack its own deliveries.
|
|
Filters []string
|
|
// Queue is the queue group, set for a seat's worker so that "exactly one holder" survives a
|
|
// seat later being relaxed to several. Authority and delivery are kept separate on purpose.
|
|
Queue string
|
|
// AckWaitSeconds before an unacknowledged delivery is redelivered.
|
|
AckWaitSeconds int
|
|
// MaxDeliver before the message is dead-lettered; zero for the mesh's default.
|
|
MaxDeliver int
|
|
Why string
|
|
}
|
|
|
|
// seatStreamName is the stream holding a seat's inbound work. Named after the seat rather than
|
|
// the module holding it, because the holder can change and the queued work must not care — which
|
|
// is the whole reason a caller addresses a seat instead of a module.
|
|
func seatStreamName(seat string) string { return "SEAT_" + upperSnake(seat) }
|
|
|
|
// SeatStreams is one work queue per declared seat, created when the declaring module is
|
|
// registered rather than when it is assigned.
|
|
//
|
|
// **The stream exists before anyone holds the seat, and that is the point.** Work queues until a
|
|
// holder appears, so installing the telegram module a week after something started sending to it
|
|
// flushes the backlog instead of having lost it. A stream created at assignment would make "the
|
|
// holder is not here yet" mean "your messages are gone".
|
|
func SeatStreams(seats []DeclaredSeat) []Stream {
|
|
sorted := append([]DeclaredSeat(nil), seats...)
|
|
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
|
|
|
|
var out []Stream
|
|
for _, s := range sorted {
|
|
if len(s.Accepts) == 0 {
|
|
// 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.
|
|
continue
|
|
}
|
|
retain := s.RetainSeconds
|
|
if retain == 0 {
|
|
retain = 7 * 24 * 60 * 60
|
|
}
|
|
out = append(out, Stream{
|
|
Name: seatStreamName(s.Name),
|
|
Subjects: []string{"mesh.seat." + s.Name + ".accept.>"},
|
|
Retention: RetentionWorkQueue,
|
|
MaxAge: retain,
|
|
Why: fmt.Sprintf("work submitted to the %s seat; one holder consumes it, and it "+
|
|
"queues while nobody does", s.Name),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// A DeclaredSeat is a seat as the catalogue knows it. Mirrored here rather than imported so this
|
|
// package stays free of the catalogue's own types — the same reason the host mirrors the
|
|
// contracts instead of importing the sdk.
|
|
type DeclaredSeat struct {
|
|
Name string
|
|
Accepts []string
|
|
RetainSeconds int
|
|
}
|
|
|
|
// ConsumerFor is the durable consumer a module's declarations imply, or false when it subscribes
|
|
// to nothing and needs none.
|
|
//
|
|
// One per module, with every consumed subject as a filter, because its ack permission is derived
|
|
// from its name: a module with a consumer per event would need an ack permission per consumer,
|
|
// and the permission list would stop being derivable from the declaration.
|
|
func ConsumerFor(p Principal) (Consumer, bool) {
|
|
if p.Kind != KindModule || len(p.Consumes) == 0 {
|
|
return Consumer{}, false
|
|
}
|
|
perms, err := PermissionsFor(p)
|
|
if err != nil {
|
|
return Consumer{}, false
|
|
}
|
|
var filters []string
|
|
for _, s := range perms.Subscribe {
|
|
if len(s) > 9 && s[:9] == "mesh.mod." {
|
|
filters = append(filters, s)
|
|
}
|
|
}
|
|
if len(filters) == 0 {
|
|
return Consumer{}, false
|
|
}
|
|
sort.Strings(filters)
|
|
return Consumer{
|
|
Name: consumerDurable(p),
|
|
Stream: consumerStream(p),
|
|
Filters: filters,
|
|
AckWaitSeconds: 30,
|
|
MaxDeliver: 5,
|
|
Why: "what " + p.Module + " declared it consumes; after max-deliver it dead-letters",
|
|
}, true
|
|
}
|
|
|
|
// HolderConsumerFor is the worker a seat's holder gets on that seat's work queue.
|
|
//
|
|
// **A queue group even though the seat guarantees one holder.** The seat is *authority* — who may
|
|
// be the telegram sender — and the queue group is *delivery*. Tie delivery to the seat and the
|
|
// day somebody allows two holders for throughput, every message is processed twice with nothing
|
|
// reporting it. Kept separate, relaxing one changes nothing about the other.
|
|
func HolderConsumerFor(node, module string, seat DeclaredSeat) (Consumer, bool) {
|
|
if len(seat.Accepts) == 0 {
|
|
return Consumer{}, false
|
|
}
|
|
return Consumer{
|
|
Name: "SEAT_" + upperSnake(seat.Name) + "_worker",
|
|
Stream: seatStreamName(seat.Name),
|
|
Filters: []string{"mesh.seat." + seat.Name + ".accept.>"},
|
|
Queue: "holders",
|
|
AckWaitSeconds: 60,
|
|
MaxDeliver: 5,
|
|
Why: fmt.Sprintf("%s on %s holds %s; it acknowledges after the work is done, so a "+
|
|
"crash mid-work redelivers rather than loses", module, node, seat.Name),
|
|
}, true
|
|
}
|
|
|
|
// AllOverlaps reports subject filters claimed by more than one stream, across the mesh's own and
|
|
// every derived one.
|
|
//
|
|
// NATS refuses an overlapping stream rather than merging it (verified against nats-server 2.10:
|
|
// "subjects overlap with an existing stream"), so this is not a subtle divergence — it is a
|
|
// registration that fails. Catching it here names both streams, before a half-applied mesh does.
|
|
func AllOverlaps(seats []DeclaredSeat) []string {
|
|
all := append(MeshStreams(), SeatStreams(seats)...)
|
|
seen := map[string]string{}
|
|
var clashes []string
|
|
for _, s := range all {
|
|
for _, subject := range s.Subjects {
|
|
if first, ok := seen[subject]; ok {
|
|
clashes = append(clashes, fmt.Sprintf("%s and %s both claim %s", first, s.Name, subject))
|
|
continue
|
|
}
|
|
seen[subject] = s.Name
|
|
}
|
|
}
|
|
sort.Strings(clashes)
|
|
return clashes
|
|
}
|
|
|
|
// upperSnake makes a stream name from a seat name. NATS stream names may not contain a dot,
|
|
// a space or a wildcard, and a hyphen is legal but reads badly beside the mesh's own.
|
|
func upperSnake(s string) string {
|
|
out := []rune(s)
|
|
for i, r := range out {
|
|
switch {
|
|
case r >= 'a' && r <= 'z':
|
|
out[i] = r - 32
|
|
case r == '-' || r == '.':
|
|
out[i] = '_'
|
|
}
|
|
}
|
|
return string(out)
|
|
}
|