Files
mesh-catalog/modules/nats/cmd/nats-tools/consumer.go
T
jschoubben 13dea6b1aa The bus module reads one consumer in full and a stream's messages by subject (issue 1966)
A lost event between a shared stream and a module's consumer could not be located: no tool read a consumer's delivered and acknowledged sequence or a stream's messages for one subject. nats_consumer reads from the monitoring document; nats_stream_messages asks STREAM.MSG.GET on the module's own credential, newest first and bounded.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-09 21:13:02 +01:00

300 lines
11 KiB
Go

package main
// Where a lost event is located (issue 1966, tools-are-how-a-module-is-spoken-to): one consumer of a stream as the server
// holds it, and the messages a stream keeps for one subject. Both read; neither changes the bus. The consumer is read from
// the server's monitoring document, as nats_streams and nats_backlog are, so it needs no grant; the messages are asked of
// the bus on the module's own credential (STREAM.MSG.GET), as the removal tools are, so what the credential is not
// granted shows as an unanswered request and is said so (unanswered).
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// Consumer is one consumer of one stream: how it is set and where it stands.
func Consumer(jsz []byte, stream, durable string) (map[string]any, error) {
var doc jszDoc
if err := json.Unmarshal(jsz, &doc); err != nil {
return nil, err
}
streamFound := false
for _, a := range doc.Accounts {
for _, s := range a.Streams {
if s.Name != stream {
continue
}
streamFound = true
for _, c := range s.Consumers {
if c.Name != durable {
continue
}
filters := append([]string(nil), c.Config.FilterSubjects...)
if c.Config.FilterSubject != "" {
filters = append(filters, c.Config.FilterSubject)
}
kind := "pull"
if c.Config.DeliverSubject != "" {
kind = "push"
}
return map[string]any{
"stream": stream, "consumer": c.Name, "kind": kind, "created": c.Created,
"config": map[string]any{
"filter_subjects": filters, "deliver_policy": c.Config.DeliverPolicy, "ack_policy": c.Config.AckPolicy,
"ack_wait_seconds": c.Config.AckWait / int64(time.Second), "max_ack_pending": c.Config.MaxAckPending,
"max_deliver": c.Config.MaxDeliver, "replay_policy": c.Config.ReplayPolicy,
"deliver_subject": c.Config.DeliverSubject, "deliver_group": c.Config.DeliverGroup,
},
"state": map[string]any{
"delivered_stream_seq": c.Delivered.Stream, "delivered_consumer_seq": c.Delivered.Consumer,
"ack_floor_stream_seq": c.AckFloor.Stream, "ack_floor_consumer_seq": c.AckFloor.Consumer,
"num_pending": c.NumPending, "num_ack_pending": c.NumAckPending, "num_redelivered": c.NumRedelivered,
"num_waiting": c.NumWaiting, "last_delivered": c.Delivered.LastActive, "last_acked": c.AckFloor.LastActive,
},
"stream_state": map[string]any{"first_seq": s.State.FirstSeq, "last_seq": s.State.LastSeq, "messages": s.State.Messages},
}, nil
}
}
}
if !streamFound {
return nil, fmt.Errorf("no stream called %s; nats_streams lists them", stream)
}
return nil, fmt.Errorf("stream %s has no consumer called %s; nats_streams with the stream lists its consumers", stream, durable)
}
// StreamRange is a stream's first and last sequence, from the monitoring document.
func StreamRange(jsz []byte, stream string) (first, last uint64, err error) {
var doc jszDoc
if err := json.Unmarshal(jsz, &doc); err != nil {
return 0, 0, err
}
for _, a := range doc.Accounts {
for _, s := range a.Streams {
if s.Name == stream {
return uint64(s.State.FirstSeq), uint64(s.State.LastSeq), nil
}
}
}
return 0, 0, fmt.Errorf("no stream called %s; nats_streams lists them", stream)
}
// Stored is one message a stream keeps.
type Stored struct {
Seq uint64
Subject string
Time time.Time
Data []byte
}
// errNoMessage is what a MsgGet answers when no message at or after the sequence matches the subject.
var errNoMessage = errors.New("no message found")
// MsgGet asks the stream for the first message at or after seq whose subject matches (any when subject is empty).
type MsgGet func(ctx context.Context, stream string, seq uint64, subject string) (*Stored, error)
const (
defaultMessages = 20
maxMessages = 200
defaultBytes = 1 << 20
// bodyLimit is the largest body an answer carries, and only when asked.
bodyLimit = 4096
// block is how many sequences are scanned at once going back from the newest, and maxAsks the most requests one
// answer makes: a subject rarely kept stops the answer, said as truncated, never a read of the whole stream.
block = 4096
maxAsks = 3000
)
// MessageRow is one message of the answer.
type MessageRow struct {
Seq uint64 `json:"seq"`
Subject string `json:"subject"`
Time time.Time `json:"time"`
Size int `json:"size"`
Body string `json:"body,omitempty"`
BodyOmitted string `json:"body_omitted,omitempty"`
}
// NewestMessages is the messages of the stream on the subject (a filter may hold wildcards), newest first, at most count
// and at most maxBytes of their sizes; first and last are the stream's own range. Said: whether it stopped before the
// oldest the stream keeps.
func NewestMessages(ctx context.Context, get MsgGet, stream, subject string, first, last uint64, count, maxBytes int, body bool) (map[string]any, error) {
if count <= 0 {
count = defaultMessages
}
if count > maxMessages {
count = maxMessages
}
if maxBytes <= 0 {
maxBytes = defaultBytes
}
var found []*Stored
asks, bytes, stopped := 0, 0, ""
end := last + 1 // exclusive
scan:
for end > first && len(found) < count {
start := first
if end-first > block {
start = end - block
}
var chunk []*Stored
for seq := start; seq < end; {
if asks++; asks > maxAsks {
stopped = fmt.Sprintf("stopped after %d requests", maxAsks)
break scan
}
m, err := get(ctx, stream, seq, subject)
if errors.Is(err, errNoMessage) {
break
}
if err != nil {
return nil, unanswered(err, fmt.Sprintf("the messages of %s could not be read", stream))
}
if m.Seq >= end {
break
}
chunk = append(chunk, m)
seq = m.Seq + 1
}
for i := len(chunk) - 1; i >= 0; i-- {
if len(found) >= count {
stopped = fmt.Sprintf("stopped at the %d messages asked for", count)
break scan
}
if bytes+len(chunk[i].Data) > maxBytes && len(found) > 0 {
stopped = fmt.Sprintf("stopped at %d bytes", maxBytes)
break scan
}
bytes += len(chunk[i].Data)
found = append(found, chunk[i])
}
end = start
}
rows := make([]MessageRow, 0, len(found))
for _, m := range found {
r := MessageRow{Seq: m.Seq, Subject: m.Subject, Time: m.Time, Size: len(m.Data)}
switch {
case !body:
case len(m.Data) > bodyLimit:
r.BodyOmitted = fmt.Sprintf("larger than %d bytes", bodyLimit)
default:
r.Body = string(m.Data)
}
rows = append(rows, r)
}
out := map[string]any{"stream": stream, "subject": subject, "first_seq": first, "last_seq": last, "returned": len(rows), "messages": rows}
if stopped != "" {
out["stopped"] = stopped
}
return out, nil
}
// MsgOpen opens the bus for one answer and gives the reader of messages on it; close ends the connection.
type MsgOpen func(ctx context.Context) (get MsgGet, close func(), err error)
// LiveMsgGet reads messages by STREAM.MSG.GET on the module's own credential, one connection for the whole answer.
func LiveMsgGet(bus Bus) MsgOpen {
return func(ctx context.Context) (MsgGet, func(), error) {
js, closeBus, err := bus(ctx)
if err != nil {
return nil, nil, err
}
return func(ctx context.Context, stream string, seq uint64, subject string) (*Stored, error) {
sctx, cancel := step(ctx)
defer cancel()
req := map[string]any{"seq": seq}
if subject != "" {
req["next_by_subj"] = subject
}
raw, _ := json.Marshal(req)
rep, err := js.Conn().RequestWithContext(sctx, "$JS.API.STREAM.MSG.GET."+stream, raw)
if err != nil {
return nil, err
}
return decodeStored(rep.Data)
}, closeBus, nil
}
}
func decodeStored(b []byte) (*Stored, error) {
var doc struct {
Error *struct {
Code int
ErrCode int `json:"err_code"`
Description string `json:"description"`
} `json:"error"`
Message *struct {
Seq uint64 `json:"seq"`
Subject string `json:"subject"`
Data []byte `json:"data"`
Time time.Time `json:"time"`
} `json:"message"`
}
if err := json.Unmarshal(b, &doc); err != nil {
return nil, err
}
if doc.Error != nil {
if doc.Error.Code == 404 {
return nil, errNoMessage
}
return nil, fmt.Errorf("the bus refused: %s (%d)", doc.Error.Description, doc.Error.ErrCode)
}
if doc.Message == nil {
return nil, errNoMessage
}
return &Stored{Seq: doc.Message.Seq, Subject: doc.Message.Subject, Time: doc.Message.Time, Data: doc.Message.Data}, nil
}
// readTools are the two reads that locate a lost event.
func readTools(jsz func() ([]byte, error), open MsgOpen) []stdio.Tool {
return []stdio.Tool{
{Name: "nats_consumer",
Description: "One consumer of one stream, in full: how it is set (filters, deliver policy, ack policy, ack wait, max ack pending, max deliver) and where it stands (delivered and acknowledged-up-to sequence of the stream and of the consumer, pending, unacknowledged, redelivered, waiting pulls, last delivery), with the stream's own first and last sequence beside it — the question behind an event a module did not hear.",
Input: map[string]any{"stream": str("the stream, e.g. EVENTS"), "consumer": str("the consumer's durable name, e.g. novox_mesh-module-assigner")},
Run: func(a map[string]any) (any, error) {
if !name.MatchString(arg(a, "stream")) || !name.MatchString(arg(a, "consumer")) {
return nil, errors.New("stream and consumer are required, each one name (letters, digits, - and _)")
}
doc, err := jsz()
if err != nil {
return nil, err
}
return Consumer(doc, arg(a, "stream"), arg(a, "consumer"))
}},
{Name: "nats_stream_messages",
Description: "The messages a stream keeps for one subject (a filter with * or > is allowed), newest first: sequence, subject, time and size of each, and the body only when asked for and no larger than 4 KiB. Bounded by count (default 20, at most 200) and bytes (default 1 MiB); says when it stopped before the oldest the stream keeps. Read on the bus module's own credential.",
Input: map[string]any{"stream": str("the stream, e.g. EVENTS"), "subject": str("a subject or filter, e.g. mesh.mod.mesh-delivery-indexer.event.view-changed"),
"count": num("at most this many (default 20, at most 200)"), "bytes": num("at most this many bytes of messages (default 1048576)"),
"body": map[string]any{"type": "boolean", "description": "carry the body of each message of at most 4 KiB"}},
Run: func(a map[string]any) (any, error) {
stream, subject := arg(a, "stream"), strings.TrimSpace(arg(a, "subject"))
if !name.MatchString(stream) || subject == "" || strings.ContainsAny(subject, " \t") {
return nil, errors.New("stream (one name) and subject (a subject or filter, no space) are required")
}
doc, err := jsz()
if err != nil {
return nil, err
}
first, last, err := StreamRange(doc, stream)
if err != nil {
return nil, err
}
count, _ := a["count"].(float64)
bytes, _ := a["bytes"].(float64)
body, _ := a["body"].(bool)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
get, closeBus, err := open(ctx)
if err != nil {
return nil, err
}
defer closeBus()
return NewestMessages(ctx, get, stream, subject, first, last, int(count), int(bytes), body)
}},
}
}