2025-11-14 08:42:26 +08:00
|
|
|
package nats
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"caatsm/internal/app"
|
|
|
|
|
"caatsm/internal/infra/config"
|
2025-11-18 14:15:58 +08:00
|
|
|
obsmetrics "caatsm/internal/infra/metrics"
|
2025-11-17 11:47:23 +08:00
|
|
|
"caatsm/internal/infra/telemetry"
|
2025-11-14 08:42:26 +08:00
|
|
|
"context"
|
2025-11-18 11:47:35 +08:00
|
|
|
"errors"
|
2025-11-14 08:42:26 +08:00
|
|
|
"fmt"
|
2025-11-14 23:18:10 +08:00
|
|
|
"time"
|
|
|
|
|
|
2025-11-14 08:42:26 +08:00
|
|
|
"github.com/nats-io/nats.go"
|
2025-12-27 12:20:06 +08:00
|
|
|
"go.opentelemetry.io/otel"
|
|
|
|
|
"go.opentelemetry.io/otel/attribute"
|
|
|
|
|
"go.opentelemetry.io/otel/codes"
|
2025-11-14 21:42:04 +08:00
|
|
|
"go.uber.org/zap"
|
2025-11-14 08:42:26 +08:00
|
|
|
)
|
|
|
|
|
|
2025-12-27 12:20:06 +08:00
|
|
|
// Consumer handles NATS JetStream message consumption.
|
|
|
|
|
// It consolidates stream management, fetching, and processing into a single, cohesive unit.
|
2025-11-18 11:47:35 +08:00
|
|
|
type Consumer struct {
|
|
|
|
|
// Core dependencies
|
|
|
|
|
conn *nats.Conn
|
|
|
|
|
js nats.JetStreamContext
|
|
|
|
|
processor *app.MessageProcessor
|
|
|
|
|
cfg *config.Config
|
|
|
|
|
logger *zap.Logger
|
|
|
|
|
telemetry telemetry.Recorder
|
|
|
|
|
|
2025-12-27 12:20:06 +08:00
|
|
|
// Components
|
|
|
|
|
monitor *ConsumerMonitor
|
|
|
|
|
dlqHandler DLQHandler
|
|
|
|
|
|
2025-11-18 11:47:35 +08:00
|
|
|
// Configuration
|
2025-12-27 12:20:06 +08:00
|
|
|
streamName string
|
|
|
|
|
consumerName string
|
|
|
|
|
subject string
|
|
|
|
|
batchSize int
|
|
|
|
|
batchTimeout time.Duration
|
|
|
|
|
ackWait time.Duration
|
|
|
|
|
backoff []time.Duration
|
2025-11-18 08:38:03 +08:00
|
|
|
|
2025-11-18 11:47:35 +08:00
|
|
|
// State
|
2025-12-27 12:20:06 +08:00
|
|
|
consecutiveErrors int
|
2025-11-14 08:42:26 +08:00
|
|
|
}
|
|
|
|
|
|
2025-12-27 12:20:06 +08:00
|
|
|
// ProvideConsumer initializes a NATS consumer, ensuring infrastructure exists.
|
2025-11-19 13:07:09 +08:00
|
|
|
func ProvideConsumer(
|
|
|
|
|
conn *nats.Conn,
|
|
|
|
|
js nats.JetStreamContext,
|
|
|
|
|
processor *app.MessageProcessor,
|
|
|
|
|
cfg *config.Config,
|
|
|
|
|
rec telemetry.Recorder,
|
|
|
|
|
logger *zap.Logger,
|
|
|
|
|
) (*Consumer, error) {
|
2025-12-27 12:20:06 +08:00
|
|
|
// 1. Normalize Configuration
|
|
|
|
|
c := &Consumer{
|
|
|
|
|
conn: conn,
|
|
|
|
|
js: js,
|
|
|
|
|
processor: processor,
|
|
|
|
|
cfg: cfg,
|
|
|
|
|
logger: logger,
|
|
|
|
|
telemetry: rec,
|
|
|
|
|
streamName: orDefault(cfg.NATS.Stream, "TELEGRAM"),
|
|
|
|
|
consumerName: orDefault(cfg.NATS.Consumer, "telegram-consumer"),
|
|
|
|
|
subject: cfg.EffectiveSubscriptionTopic(),
|
|
|
|
|
batchSize: cfg.App.BatchSize,
|
|
|
|
|
batchTimeout: cfg.App.BatchTimeout,
|
|
|
|
|
ackWait: orDefaultDuration(cfg.NATS.ConsumerRules.AckWait, 30*time.Second),
|
|
|
|
|
backoff: cfg.NATS.ConsumerRules.Backoff,
|
2025-11-21 09:59:47 +08:00
|
|
|
}
|
2025-11-18 11:47:35 +08:00
|
|
|
|
2025-12-27 12:20:06 +08:00
|
|
|
if c.batchSize <= 0 {
|
|
|
|
|
c.batchSize = 50
|
2025-11-21 09:59:47 +08:00
|
|
|
}
|
2025-12-27 12:20:06 +08:00
|
|
|
if c.batchTimeout <= 0 {
|
|
|
|
|
c.batchTimeout = 2 * time.Second
|
2025-11-21 09:59:47 +08:00
|
|
|
}
|
2025-11-19 16:49:53 +08:00
|
|
|
|
2025-12-27 12:20:06 +08:00
|
|
|
// 2. Initialize Components
|
|
|
|
|
c.monitor = NewConsumerMonitor(logger, cfg, js, c.streamName, c.consumerName, cfg.App.MonitorInterval)
|
|
|
|
|
if cfg.DLQ.Enabled && cfg.DLQ.Subject != "" {
|
2025-11-18 11:47:35 +08:00
|
|
|
c.dlqHandler = &defaultDLQHandler{
|
2025-12-27 12:20:06 +08:00
|
|
|
js: js,
|
|
|
|
|
dlqSubject: cfg.DLQ.Subject,
|
|
|
|
|
streamName: c.streamName,
|
|
|
|
|
consumerName: c.consumerName,
|
|
|
|
|
logger: logger,
|
|
|
|
|
telemetry: rec,
|
2025-11-18 11:47:35 +08:00
|
|
|
}
|
|
|
|
|
}
|
2025-11-18 14:15:58 +08:00
|
|
|
|
2025-12-27 12:20:06 +08:00
|
|
|
// 3. Ensure Infrastructure (Stream & Consumer)
|
|
|
|
|
if err := c.ensureInfrastructure(); err != nil {
|
|
|
|
|
return nil, err
|
2025-11-18 14:15:58 +08:00
|
|
|
}
|
2025-12-27 12:20:06 +08:00
|
|
|
|
|
|
|
|
return c, nil
|
2025-11-18 11:47:35 +08:00
|
|
|
}
|
|
|
|
|
|
2025-12-27 12:20:06 +08:00
|
|
|
// Start begins the main consumption loop.
|
2025-11-14 08:42:26 +08:00
|
|
|
func (c *Consumer) Start(ctx context.Context) error {
|
2025-12-27 12:20:06 +08:00
|
|
|
c.logger.Info("Starting consumer",
|
|
|
|
|
zap.String("stream", c.streamName),
|
|
|
|
|
zap.String("consumer", c.consumerName),
|
|
|
|
|
zap.String("subject", c.subject),
|
2025-11-19 13:07:09 +08:00
|
|
|
)
|
|
|
|
|
|
2025-12-27 12:20:06 +08:00
|
|
|
// Start background monitoring
|
|
|
|
|
monitorCtx, cancelMonitor := context.WithCancel(ctx)
|
|
|
|
|
defer cancelMonitor()
|
|
|
|
|
go c.monitor.Start(monitorCtx)
|
|
|
|
|
|
|
|
|
|
// Create subscription
|
|
|
|
|
sub, err := c.js.PullSubscribe(c.subject, c.consumerName, nats.BindStream(c.streamName))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("failed to subscribe: %w", err)
|
|
|
|
|
}
|
|
|
|
|
defer sub.Unsubscribe()
|
|
|
|
|
|
|
|
|
|
// Initial metric recording
|
|
|
|
|
if info, err := c.js.ConsumerInfo(c.streamName, c.consumerName); err == nil {
|
|
|
|
|
c.monitor.RecordInitialPending(info.NumPending)
|
2025-11-19 16:49:53 +08:00
|
|
|
}
|
|
|
|
|
|
2025-12-27 12:20:06 +08:00
|
|
|
// Main Loop
|
2025-11-19 13:07:09 +08:00
|
|
|
for {
|
|
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
2025-12-27 12:20:06 +08:00
|
|
|
return nil
|
2025-11-19 13:07:09 +08:00
|
|
|
default:
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-27 12:20:06 +08:00
|
|
|
msgs, err := sub.Fetch(c.batchSize, nats.MaxWait(c.batchTimeout))
|
2025-11-19 13:07:09 +08:00
|
|
|
if err != nil {
|
2025-12-27 12:20:06 +08:00
|
|
|
if errors.Is(err, nats.ErrTimeout) {
|
|
|
|
|
continue // Normal timeout, just retry
|
|
|
|
|
}
|
2025-11-19 13:07:09 +08:00
|
|
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
2025-12-27 12:20:06 +08:00
|
|
|
return nil
|
2025-11-19 13:07:09 +08:00
|
|
|
}
|
2025-12-27 12:20:06 +08:00
|
|
|
// Log other errors but keep loop alive unless critical
|
|
|
|
|
c.logger.Warn("Fetch error", zap.Error(err))
|
|
|
|
|
time.Sleep(100 * time.Millisecond) // Slight backoff
|
2025-11-19 13:07:09 +08:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-27 12:20:06 +08:00
|
|
|
c.processBatch(ctx, msgs)
|
2025-11-19 13:07:09 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-27 12:20:06 +08:00
|
|
|
// Shutdown gracefully drains the connection.
|
2025-11-14 21:42:04 +08:00
|
|
|
func (c *Consumer) Shutdown(ctx context.Context) error {
|
|
|
|
|
if c.conn == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2025-12-27 12:20:06 +08:00
|
|
|
c.logger.Info("Draining NATS connection...")
|
|
|
|
|
return c.conn.Drain()
|
|
|
|
|
}
|
2025-11-14 21:42:04 +08:00
|
|
|
|
2025-12-27 12:20:06 +08:00
|
|
|
// processBatch iterates through a batch of messages.
|
|
|
|
|
func (c *Consumer) processBatch(ctx context.Context, msgs []*nats.Msg) {
|
|
|
|
|
for _, msg := range msgs {
|
|
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
return
|
|
|
|
|
default:
|
|
|
|
|
c.monitor.TrackMessage(msg)
|
|
|
|
|
c.processMsg(ctx, msg)
|
|
|
|
|
}
|
2025-11-14 21:42:04 +08:00
|
|
|
}
|
|
|
|
|
}
|
2025-12-24 14:21:59 +08:00
|
|
|
|
2025-12-27 12:20:06 +08:00
|
|
|
// processMsg handles a single message: Trace -> App Logic -> Ack/Nak.
|
|
|
|
|
func (c *Consumer) processMsg(ctx context.Context, msg *nats.Msg) {
|
|
|
|
|
start := time.Now()
|
|
|
|
|
ctx, span := otel.Tracer("caatsm/nats").Start(ctx, "Consumer.processMsg")
|
|
|
|
|
defer span.End()
|
|
|
|
|
|
|
|
|
|
msgID := c.resolveMsgID(msg)
|
|
|
|
|
|
|
|
|
|
// Add metadata to span/logger
|
|
|
|
|
span.SetAttributes(
|
|
|
|
|
attribute.String("messaging.system", "nats"),
|
|
|
|
|
attribute.String("messaging.message_id", msgID),
|
|
|
|
|
attribute.String("caatsm.stream", c.streamName),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Execute Application Logic
|
|
|
|
|
err := c.processor.Handle(ctx, msg.Data, msgID)
|
|
|
|
|
|
|
|
|
|
// Handle Result
|
|
|
|
|
if err != nil {
|
|
|
|
|
c.handleError(ctx, msg, msgID, err)
|
|
|
|
|
span.RecordError(err)
|
|
|
|
|
span.SetStatus(codes.Error, err.Error())
|
|
|
|
|
c.telemetry.RecordMessageHandled(ctx, c.streamName, c.consumerName, obsmetrics.ResultFail, time.Since(start))
|
|
|
|
|
} else {
|
|
|
|
|
// Success
|
|
|
|
|
if c.consecutiveErrors > 0 {
|
|
|
|
|
c.consecutiveErrors = 0
|
|
|
|
|
}
|
|
|
|
|
if ackErr := msg.Ack(); ackErr != nil {
|
|
|
|
|
c.logger.Warn("Failed to ACK", zap.String("msg_id", msgID), zap.Error(ackErr))
|
|
|
|
|
}
|
|
|
|
|
c.telemetry.RecordMessageHandled(ctx, c.streamName, c.consumerName, "ok", time.Since(start))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleError decides whether to Ack (Permanent/DLQ) or Nak (Transient).
|
|
|
|
|
func (c *Consumer) handleError(ctx context.Context, msg *nats.Msg, msgID string, err error) {
|
|
|
|
|
isPermanent := app.IsPermanent(err)
|
|
|
|
|
c.logger.Error("Processing failed",
|
|
|
|
|
zap.String("msg_id", msgID),
|
|
|
|
|
zap.Error(err),
|
|
|
|
|
zap.Bool("permanent", isPermanent),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if isPermanent {
|
|
|
|
|
// Poison message: Route to DLQ -> Ack
|
|
|
|
|
c.consecutiveErrors = 0
|
|
|
|
|
if c.dlqHandler != nil {
|
|
|
|
|
_ = c.dlqHandler.RouteToDLQ(ctx, msg, err) // Logged inside handler
|
|
|
|
|
}
|
|
|
|
|
_ = msg.Ack()
|
2025-12-24 14:21:59 +08:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-27 12:20:06 +08:00
|
|
|
// Transient error: Backpressure -> Nak with Backoff
|
|
|
|
|
c.consecutiveErrors++
|
|
|
|
|
c.applyBackpressure(ctx)
|
|
|
|
|
|
|
|
|
|
_ = c.nakWithBackoff(msg)
|
|
|
|
|
}
|
2025-12-24 14:21:59 +08:00
|
|
|
|
2025-12-27 12:20:06 +08:00
|
|
|
// nakWithBackoff calculates the appropriate NAK delay based on delivery attempts.
|
|
|
|
|
func (c *Consumer) nakWithBackoff(msg *nats.Msg) error {
|
|
|
|
|
if len(c.backoff) == 0 {
|
|
|
|
|
return msg.Nak()
|
|
|
|
|
}
|
|
|
|
|
meta, err := msg.Metadata()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return msg.Nak()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// attempt is 1-based, index is 0-based
|
|
|
|
|
attempt := int(meta.NumDelivered)
|
|
|
|
|
index := attempt - 1
|
|
|
|
|
if index >= len(c.backoff) {
|
|
|
|
|
index = len(c.backoff) - 1
|
|
|
|
|
} else if index < 0 {
|
|
|
|
|
index = 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return msg.NakWithDelay(c.backoff[index])
|
|
|
|
|
}
|
2025-12-24 14:21:59 +08:00
|
|
|
|
2025-12-27 12:20:06 +08:00
|
|
|
// applyBackpressure sleeps if error streak is high to protect the system.
|
|
|
|
|
func (c *Consumer) applyBackpressure(ctx context.Context) {
|
|
|
|
|
if c.consecutiveErrors < 10 {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
delay := time.Duration(c.consecutiveErrors) * 100 * time.Millisecond
|
|
|
|
|
if delay > 5*time.Second {
|
|
|
|
|
delay = 5 * time.Second
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
select {
|
|
|
|
|
case <-time.After(delay):
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ensureInfrastructure creates the Stream and Consumer if they don't exist.
|
|
|
|
|
func (c *Consumer) ensureInfrastructure() error {
|
|
|
|
|
// 1. Ensure Stream
|
|
|
|
|
subjects := []string{c.subject}
|
|
|
|
|
if c.cfg.Publisher.Topic != "" {
|
|
|
|
|
subjects = append(subjects, c.cfg.Publisher.Topic)
|
|
|
|
|
}
|
|
|
|
|
if c.cfg.DLQ.Enabled && c.cfg.DLQ.Subject != "" {
|
|
|
|
|
subjects = append(subjects, c.cfg.DLQ.Subject)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
streamCfg := &nats.StreamConfig{
|
|
|
|
|
Name: c.streamName,
|
|
|
|
|
Subjects: dedupeSubjects(subjects),
|
|
|
|
|
Retention: nats.WorkQueuePolicy, // Defaulting to WorkQueue for queues
|
|
|
|
|
MaxMsgs: c.cfg.NATS.StreamLimits.MaxMsgs,
|
|
|
|
|
MaxBytes: c.cfg.NATS.StreamLimits.MaxBytes,
|
|
|
|
|
MaxAge: c.cfg.NATS.StreamLimits.MaxAge,
|
|
|
|
|
Replicas: c.cfg.NATS.StreamLimits.Replicas,
|
|
|
|
|
Storage: nats.FileStorage,
|
|
|
|
|
}
|
|
|
|
|
if c.cfg.NATS.StreamLimits.Discard == "new" {
|
|
|
|
|
streamCfg.Discard = nats.DiscardNew
|
|
|
|
|
}
|
|
|
|
|
if c.cfg.NATS.StreamLimits.Storage == "memory" {
|
|
|
|
|
streamCfg.Storage = nats.MemoryStorage
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Idempotent add/update
|
|
|
|
|
if _, err := c.js.AddStream(streamCfg); err != nil {
|
|
|
|
|
return fmt.Errorf("ensure stream: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. Ensure Consumer
|
|
|
|
|
consumerCfg := &nats.ConsumerConfig{
|
|
|
|
|
Durable: c.consumerName,
|
|
|
|
|
FilterSubject: c.subject,
|
|
|
|
|
AckPolicy: nats.AckExplicitPolicy,
|
|
|
|
|
AckWait: c.ackWait,
|
|
|
|
|
MaxDeliver: c.cfg.NATS.ConsumerRules.MaxDeliver,
|
|
|
|
|
MaxAckPending: c.cfg.NATS.ConsumerRules.MaxAckPending,
|
|
|
|
|
ReplayPolicy: nats.ReplayInstantPolicy,
|
|
|
|
|
}
|
|
|
|
|
if c.cfg.NATS.ConsumerRules.ReplayPolicy == "original" {
|
|
|
|
|
consumerCfg.ReplayPolicy = nats.ReplayOriginalPolicy
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Idempotent add/update
|
|
|
|
|
if _, err := c.js.AddConsumer(c.streamName, consumerCfg); err != nil {
|
|
|
|
|
return fmt.Errorf("ensure consumer: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// resolveMsgID extracts the ID from headers or metadata.
|
|
|
|
|
func (c *Consumer) resolveMsgID(msg *nats.Msg) string {
|
|
|
|
|
if id := msg.Header.Get("Nats-Msg-Id"); id != "" {
|
|
|
|
|
return id
|
|
|
|
|
}
|
2025-12-24 14:21:59 +08:00
|
|
|
if meta, err := msg.Metadata(); err == nil {
|
2025-12-27 12:20:06 +08:00
|
|
|
return fmt.Sprintf("js-%d", meta.Sequence.Stream)
|
2025-12-24 14:21:59 +08:00
|
|
|
}
|
2025-12-27 12:20:06 +08:00
|
|
|
return "unknown"
|
2025-12-24 14:21:59 +08:00
|
|
|
}
|
|
|
|
|
|
2025-12-27 12:20:06 +08:00
|
|
|
// --- Helpers ---
|
2025-12-24 14:21:59 +08:00
|
|
|
|
2025-12-27 12:20:06 +08:00
|
|
|
func orDefault(val, def string) string {
|
|
|
|
|
if val != "" {
|
|
|
|
|
return val
|
2025-12-24 14:21:59 +08:00
|
|
|
}
|
2025-12-27 12:20:06 +08:00
|
|
|
return def
|
2025-12-24 14:21:59 +08:00
|
|
|
}
|
|
|
|
|
|
2025-12-27 12:20:06 +08:00
|
|
|
func orDefaultDuration(val, def time.Duration) time.Duration {
|
|
|
|
|
if val > 0 {
|
|
|
|
|
return val
|
2025-12-24 14:21:59 +08:00
|
|
|
}
|
2025-12-27 12:20:06 +08:00
|
|
|
return def
|
|
|
|
|
}
|