Files
go-caatsm/internal/infra/nats/consumer.go
T

207 lines
5.0 KiB
Go

package nats
import (
"caatsm/internal/app"
"caatsm/internal/infra/config"
"caatsm/internal/infra/telemetry"
"context"
"fmt"
"strings"
"time"
"github.com/nats-io/nats.go"
"go.opentelemetry.io/otel/metric"
"go.uber.org/zap"
)
// Consumer handles NATS JetStream message consumption
type Consumer struct {
conn *nats.Conn
js nats.JetStreamContext
processor *app.MessageProcessor
cfg *config.Config
logger *zap.Logger
telemetry telemetry.Recorder
subject string
consumerName string
mode string
streamName string
dlqSubject string
ackWait time.Duration
batchSize int
batchTimeout time.Duration
monitorInterval time.Duration
meter metric.Meter
ackPending metric.Int64Histogram
redelivered metric.Int64Histogram
pending metric.Int64Histogram
delivered metric.Int64Histogram
// simple backpressure / degradation state
consecutiveProcessErrors int
}
// consumerConfig holds normalized consumer configuration values.
type consumerConfig struct {
subject string
consumerName string
mode string
streamName string
dlqSubject string
ackWait time.Duration
batchSize int
batchTimeout time.Duration
monitorInterval time.Duration
}
// normalizeConsumerConfig extracts and normalizes consumer configuration from the application config.
// This function can be unit-tested without requiring a JetStream context.
func normalizeConsumerConfig(cfg *config.Config) *consumerConfig {
subject := cfg.EffectiveSubscriptionTopic()
consumerName := cfg.NATS.Consumer
if consumerName == "" {
consumerName = "telegram-consumer"
}
mode := strings.ToLower(cfg.NATS.Mode)
if mode == "" {
mode = "jetstream"
}
streamName := cfg.NATS.Stream
if streamName == "" {
streamName = "TELEGRAM"
}
// DLQ routing is only meaningful in JetStream mode. Respect dlq.enabled to allow
// environments to opt out cleanly even if a subject is configured.
dlqSubject := ""
if cfg.DLQ.Enabled {
dlqSubject = strings.TrimSpace(cfg.DLQ.Subject)
}
ackWait := cfg.NATS.ConsumerRules.AckWait
if ackWait == 0 {
ackWait = cfg.Timeouts.AckWait
}
if ackWait == 0 {
ackWait = 30 * time.Second
}
batchSize := cfg.App.BatchSize
if batchSize == 0 {
batchSize = 50
}
batchTimeout := cfg.App.BatchTimeout
if batchTimeout == 0 {
batchTimeout = 2 * time.Second
}
monitorInterval := cfg.App.MonitorInterval
if monitorInterval <= 0 {
monitorInterval = 30 * time.Second
}
return &consumerConfig{
subject: subject,
consumerName: consumerName,
mode: mode,
streamName: streamName,
dlqSubject: dlqSubject,
ackWait: ackWait,
batchSize: batchSize,
batchTimeout: batchTimeout,
monitorInterval: monitorInterval,
}
}
// ProvideConsumer creates a NATS consumer.
func ProvideConsumer(
conn *nats.Conn,
js nats.JetStreamContext,
processor *app.MessageProcessor,
cfg *config.Config,
rec telemetry.Recorder,
logger *zap.Logger,
) (*Consumer, error) {
normCfg := normalizeConsumerConfig(cfg)
consumer := &Consumer{
conn: conn,
js: js,
processor: processor,
cfg: cfg,
logger: logger,
telemetry: rec,
subject: normCfg.subject,
consumerName: normCfg.consumerName,
mode: normCfg.mode,
streamName: normCfg.streamName,
dlqSubject: normCfg.dlqSubject,
ackWait: normCfg.ackWait,
batchSize: normCfg.batchSize,
batchTimeout: normCfg.batchTimeout,
monitorInterval: normCfg.monitorInterval,
}
consumer.initMetrics()
if consumer.mode == "jetstream" {
// Create consumer if it doesn't exist
if err := consumer.ensureConsumer(); err != nil {
return nil, fmt.Errorf("failed to ensure consumer: %w", err)
}
// Validate DLQ configuration early so misconfiguration is visible at startup
// rather than only when the first poison message appears.
if err := consumer.validateDLQ(); err != nil {
return nil, fmt.Errorf("DLQ validation failed: %w", err)
}
} else {
logger.Info("Running consumer in core NATS mode",
zap.String("subject", normCfg.subject),
zap.String("queue_group", cfg.Subscription.QueueGroup),
)
}
return consumer, nil
}
// Start starts consuming messages.
func (c *Consumer) Start(ctx context.Context) error {
if c.mode == "core" {
return c.startCore(ctx)
}
return c.startJetStream(ctx)
}
// Shutdown drains the underlying NATS connection gracefully.
func (c *Consumer) Shutdown(ctx context.Context) error {
if c.conn == nil {
return nil
}
timeout := c.cfg.Timeouts.Close
if timeout <= 0 {
timeout = 10 * time.Second
}
closeCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
errCh := make(chan error, 1)
go func() {
errCh <- c.conn.Drain()
}()
select {
case err := <-errCh:
c.conn.Close()
return err
case <-closeCtx.Done():
c.conn.Close()
return fmt.Errorf("nats drain timeout: %w", closeCtx.Err())
}
}