233 lines
6.1 KiB
Go
233 lines
6.1 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
|
|
|
|
// managers for resource lifecycle
|
|
consumerManager *ConsumerManager
|
|
streamManager *StreamManager
|
|
errorHandler *ErrorHandler
|
|
|
|
// 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()
|
|
|
|
// Initialize managers
|
|
consumer.errorHandler = NewErrorHandler(logger)
|
|
if consumer.mode == "jetstream" {
|
|
consumer.consumerManager = NewConsumerManager(js, normCfg.streamName, normCfg.consumerName, normCfg.subject, logger)
|
|
consumer.streamManager = NewStreamManager(js, normCfg.streamName, []string{normCfg.subject}, logger)
|
|
|
|
// Create consumer if it doesn't exist
|
|
consumerConfig := consumer.buildConsumerConfig()
|
|
if err := consumer.consumerManager.EnsureConsumer(consumerConfig); 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
|
|
}
|
|
|
|
// buildConsumerConfig builds the NATS consumer configuration
|
|
func (c *Consumer) buildConsumerConfig() *nats.ConsumerConfig {
|
|
return &nats.ConsumerConfig{
|
|
Durable: c.consumerName,
|
|
DeliverPolicy: mapDeliverPolicy(c.cfg.NATS.ConsumerRules.DeliverPolicy),
|
|
AckPolicy: nats.AckExplicitPolicy,
|
|
AckWait: c.ackWait,
|
|
ReplayPolicy: mapReplayPolicy(c.cfg.NATS.ConsumerRules.ReplayPolicy),
|
|
MaxDeliver: c.cfg.NATS.ConsumerRules.MaxDeliver,
|
|
MaxAckPending: c.cfg.NATS.ConsumerRules.MaxAckPending,
|
|
FilterSubject: c.subject,
|
|
BackOff: c.cfg.NATS.ConsumerRules.Backoff,
|
|
}
|
|
}
|
|
|
|
// 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())
|
|
}
|
|
}
|