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-15 09:01:24 +08:00
|
|
|
"strings"
|
2025-11-14 23:18:10 +08:00
|
|
|
"time"
|
|
|
|
|
|
2025-11-14 08:42:26 +08:00
|
|
|
"github.com/nats-io/nats.go"
|
2025-11-14 21:42:04 +08:00
|
|
|
"go.uber.org/zap"
|
2025-11-14 08:42:26 +08:00
|
|
|
)
|
|
|
|
|
|
2025-11-18 11:47:35 +08:00
|
|
|
// Consumer handles NATS JetStream message consumption with clean separation of concerns
|
|
|
|
|
type Consumer struct {
|
|
|
|
|
// Core dependencies
|
|
|
|
|
conn *nats.Conn
|
|
|
|
|
js nats.JetStreamContext
|
|
|
|
|
processor *app.MessageProcessor
|
|
|
|
|
cfg *config.Config
|
|
|
|
|
logger *zap.Logger
|
|
|
|
|
telemetry telemetry.Recorder
|
|
|
|
|
|
|
|
|
|
// Configuration
|
|
|
|
|
config consumerConfig
|
|
|
|
|
|
|
|
|
|
// Collaborators (injected for testability)
|
|
|
|
|
fetcher MessageFetcher
|
|
|
|
|
batchProcessor MessageProcessor
|
|
|
|
|
dlqHandler DLQHandler
|
|
|
|
|
|
|
|
|
|
// Resource managers
|
2025-11-18 08:38:03 +08:00
|
|
|
consumerManager *ConsumerManager
|
|
|
|
|
streamManager *StreamManager
|
|
|
|
|
|
2025-11-18 11:47:35 +08:00
|
|
|
// State
|
2025-11-16 09:15:37 +08:00
|
|
|
consecutiveProcessErrors int
|
2025-11-14 08:42:26 +08:00
|
|
|
}
|
|
|
|
|
|
2025-11-17 16:25:23 +08:00
|
|
|
// consumerConfig holds normalized consumer configuration values.
|
|
|
|
|
type consumerConfig struct {
|
|
|
|
|
subject string
|
|
|
|
|
consumerName string
|
|
|
|
|
streamName string
|
|
|
|
|
dlqSubject string
|
|
|
|
|
ackWait time.Duration
|
|
|
|
|
batchSize int
|
|
|
|
|
batchTimeout time.Duration
|
|
|
|
|
monitorInterval time.Duration
|
2025-11-16 17:24:16 +08:00
|
|
|
}
|
|
|
|
|
|
2025-11-19 13:07:09 +08:00
|
|
|
// ProvideConsumer creates a NATS consumer with clean architecture.
|
|
|
|
|
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)
|
2025-11-18 11:47:35 +08:00
|
|
|
|
2025-11-19 13:07:09 +08:00
|
|
|
consumer := &Consumer{
|
|
|
|
|
conn: conn,
|
|
|
|
|
js: js,
|
|
|
|
|
processor: processor,
|
|
|
|
|
cfg: cfg,
|
|
|
|
|
logger: logger,
|
|
|
|
|
telemetry: rec,
|
|
|
|
|
config: *normCfg, // dereference the pointer
|
2025-11-18 11:47:35 +08:00
|
|
|
}
|
2025-11-19 13:07:09 +08:00
|
|
|
consumer.initCollaborators()
|
2025-11-18 11:47:35 +08:00
|
|
|
|
2025-11-19 16:49:53 +08:00
|
|
|
// Initialize the pending messages metric early (set to 0) so it appears in Prometheus
|
|
|
|
|
// even before the consumer starts. This ensures the metric is always visible.
|
2025-11-21 09:59:47 +08:00
|
|
|
logger.Info("Initializing NATS consumer pending messages metric",
|
|
|
|
|
zap.String("stream", normCfg.streamName),
|
|
|
|
|
zap.String("consumer", normCfg.consumerName),
|
|
|
|
|
zap.Uint64("pending", 0),
|
|
|
|
|
zap.Bool("js_available", js != nil),
|
|
|
|
|
)
|
|
|
|
|
obsmetrics.RecordNATSConsumerPending(normCfg.streamName, normCfg.consumerName, 0)
|
2025-11-19 16:49:53 +08:00
|
|
|
|
2025-11-19 13:07:09 +08:00
|
|
|
// Initialize managers
|
2025-11-21 09:59:47 +08:00
|
|
|
consumer.consumerManager = NewConsumerManager(js, normCfg.streamName, normCfg.consumerName, normCfg.subject, logger)
|
|
|
|
|
// Use StreamManager with full configuration
|
|
|
|
|
streamSubjects := []string{normCfg.subject}
|
|
|
|
|
if publisherSubject := strings.TrimSpace(cfg.Publisher.Topic); publisherSubject != "" {
|
|
|
|
|
streamSubjects = append(streamSubjects, publisherSubject)
|
|
|
|
|
}
|
|
|
|
|
// Add DLQ subject to stream if DLQ is enabled
|
|
|
|
|
if normCfg.dlqSubject != "" {
|
|
|
|
|
streamSubjects = append(streamSubjects, normCfg.dlqSubject)
|
|
|
|
|
}
|
|
|
|
|
streamSubjects = dedupeSubjects(streamSubjects)
|
|
|
|
|
consumer.streamManager = NewStreamManager(js, normCfg.streamName, streamSubjects, logger)
|
2025-11-18 11:47:35 +08:00
|
|
|
|
2025-11-21 09:59:47 +08:00
|
|
|
// Update fetcher with managers now that they're initialized
|
|
|
|
|
if fetcher, ok := consumer.fetcher.(*defaultMessageFetcher); ok {
|
|
|
|
|
fetcher.consumerManager = consumer.consumerManager
|
|
|
|
|
fetcher.streamManager = consumer.streamManager
|
|
|
|
|
}
|
2025-11-18 11:47:35 +08:00
|
|
|
|
2025-11-21 09:59:47 +08:00
|
|
|
// Ensure stream exists before creating consumer
|
|
|
|
|
streamCfg := &StreamConfig{
|
|
|
|
|
MaxMsgs: cfg.NATS.StreamLimits.MaxMsgs,
|
|
|
|
|
MaxBytes: cfg.NATS.StreamLimits.MaxBytes,
|
|
|
|
|
MaxAge: cfg.NATS.StreamLimits.MaxAge,
|
|
|
|
|
Discard: cfg.NATS.StreamLimits.Discard,
|
|
|
|
|
Storage: cfg.NATS.StreamLimits.Storage,
|
|
|
|
|
Replicas: cfg.NATS.StreamLimits.Replicas,
|
|
|
|
|
}
|
|
|
|
|
if err := consumer.streamManager.EnsureStream(streamCfg); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to ensure stream: %w", err)
|
|
|
|
|
}
|
2025-11-19 16:49:53 +08:00
|
|
|
|
2025-11-21 09:59:47 +08:00
|
|
|
// 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)
|
2025-11-18 14:15:58 +08:00
|
|
|
}
|
|
|
|
|
|
2025-11-19 13:07:09 +08:00
|
|
|
return consumer, nil
|
2025-11-18 11:47:35 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// initCollaborators initializes the collaborator components
|
|
|
|
|
func (c *Consumer) initCollaborators() {
|
|
|
|
|
c.fetcher = &defaultMessageFetcher{
|
|
|
|
|
batchSize: c.config.batchSize,
|
|
|
|
|
batchTimeout: c.config.batchTimeout,
|
|
|
|
|
logger: c.logger,
|
|
|
|
|
conn: c.conn,
|
|
|
|
|
js: c.js,
|
|
|
|
|
consumerManager: c.consumerManager,
|
|
|
|
|
streamManager: c.streamManager,
|
|
|
|
|
config: &c.config,
|
|
|
|
|
cfg: c.cfg,
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-18 14:15:58 +08:00
|
|
|
// Initialize DLQ handler first if needed, so batch processor can reference it
|
2025-11-18 11:47:35 +08:00
|
|
|
if c.config.dlqSubject != "" {
|
|
|
|
|
c.dlqHandler = &defaultDLQHandler{
|
|
|
|
|
js: c.js,
|
|
|
|
|
dlqSubject: c.config.dlqSubject,
|
|
|
|
|
streamName: c.config.streamName,
|
|
|
|
|
consumerName: c.config.consumerName,
|
|
|
|
|
logger: c.logger,
|
|
|
|
|
telemetry: c.telemetry,
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-11-18 14:15:58 +08:00
|
|
|
|
|
|
|
|
c.batchProcessor = &defaultBatchProcessor{
|
|
|
|
|
processor: c.processor,
|
|
|
|
|
dlqHandler: c.dlqHandler,
|
|
|
|
|
logger: c.logger,
|
|
|
|
|
telemetry: c.telemetry,
|
|
|
|
|
streamName: c.config.streamName,
|
|
|
|
|
consumerName: c.config.consumerName,
|
|
|
|
|
backoff: c.cfg.NATS.ConsumerRules.Backoff,
|
|
|
|
|
consecutiveProcessErrors: &c.consecutiveProcessErrors,
|
|
|
|
|
}
|
2025-11-18 11:47:35 +08:00
|
|
|
}
|
|
|
|
|
|
2025-11-17 16:25:23 +08:00
|
|
|
// 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 {
|
2025-11-14 22:43:04 +08:00
|
|
|
subject := cfg.EffectiveSubscriptionTopic()
|
2025-11-14 08:42:26 +08:00
|
|
|
|
|
|
|
|
consumerName := cfg.NATS.Consumer
|
|
|
|
|
if consumerName == "" {
|
|
|
|
|
consumerName = "telegram-consumer"
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-16 08:24:29 +08:00
|
|
|
streamName := cfg.NATS.Stream
|
|
|
|
|
if streamName == "" {
|
|
|
|
|
streamName = "TELEGRAM"
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-16 11:55:13 +08:00
|
|
|
// 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)
|
|
|
|
|
}
|
2025-11-16 09:15:37 +08:00
|
|
|
|
2025-11-16 08:24:29 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-17 16:25:23 +08:00
|
|
|
return &consumerConfig{
|
2025-11-16 08:24:29 +08:00
|
|
|
subject: subject,
|
|
|
|
|
consumerName: consumerName,
|
|
|
|
|
streamName: streamName,
|
2025-11-16 09:15:37 +08:00
|
|
|
dlqSubject: dlqSubject,
|
2025-11-16 08:24:29 +08:00
|
|
|
ackWait: ackWait,
|
|
|
|
|
batchSize: batchSize,
|
|
|
|
|
batchTimeout: batchTimeout,
|
|
|
|
|
monitorInterval: monitorInterval,
|
2025-11-14 08:42:26 +08:00
|
|
|
}
|
2025-11-17 16:25:23 +08:00
|
|
|
}
|
|
|
|
|
|
2025-11-18 08:38:03 +08:00
|
|
|
// buildConsumerConfig builds the NATS consumer configuration
|
|
|
|
|
func (c *Consumer) buildConsumerConfig() *nats.ConsumerConfig {
|
|
|
|
|
return &nats.ConsumerConfig{
|
2025-11-18 11:47:35 +08:00
|
|
|
Durable: c.config.consumerName,
|
2025-11-18 08:38:03 +08:00
|
|
|
DeliverPolicy: mapDeliverPolicy(c.cfg.NATS.ConsumerRules.DeliverPolicy),
|
|
|
|
|
AckPolicy: nats.AckExplicitPolicy,
|
2025-11-18 11:47:35 +08:00
|
|
|
AckWait: c.config.ackWait,
|
2025-11-18 08:38:03 +08:00
|
|
|
ReplayPolicy: mapReplayPolicy(c.cfg.NATS.ConsumerRules.ReplayPolicy),
|
|
|
|
|
MaxDeliver: c.cfg.NATS.ConsumerRules.MaxDeliver,
|
|
|
|
|
MaxAckPending: c.cfg.NATS.ConsumerRules.MaxAckPending,
|
2025-11-18 11:47:35 +08:00
|
|
|
FilterSubject: c.config.subject,
|
2025-11-18 08:38:03 +08:00
|
|
|
BackOff: c.cfg.NATS.ConsumerRules.Backoff,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-21 09:59:47 +08:00
|
|
|
// Start starts consuming messages from JetStream.
|
2025-11-14 08:42:26 +08:00
|
|
|
func (c *Consumer) Start(ctx context.Context) error {
|
2025-11-15 12:08:26 +08:00
|
|
|
return c.startJetStream(ctx)
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-18 11:47:35 +08:00
|
|
|
// RouteToDLQ implements DLQHandler interface
|
|
|
|
|
func (c *Consumer) RouteToDLQ(ctx context.Context, msg *nats.Msg, cause error) error {
|
|
|
|
|
if c.dlqHandler != nil {
|
|
|
|
|
return c.dlqHandler.RouteToDLQ(ctx, msg, cause)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ValidateDLQ implements DLQHandler interface
|
|
|
|
|
func (c *Consumer) ValidateDLQ() error {
|
|
|
|
|
if c.dlqHandler != nil {
|
|
|
|
|
return c.dlqHandler.ValidateDLQ()
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-19 13:07:09 +08:00
|
|
|
// validateDLQ is a helper for internal use (lowercase)
|
|
|
|
|
func (c *Consumer) validateDLQ() error {
|
|
|
|
|
return c.ValidateDLQ()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// createPullSubscription creates a pull subscription
|
|
|
|
|
func (c *Consumer) createPullSubscription() (*nats.Subscription, error) {
|
|
|
|
|
return c.consumerManager.CreatePullSubscription()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// startJetStream starts the JetStream consumer loop.
|
|
|
|
|
func (c *Consumer) startJetStream(ctx context.Context) error {
|
|
|
|
|
// Create pull subscription
|
|
|
|
|
sub, err := c.createPullSubscription()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Use a closure that always cleans up the current subscription.
|
|
|
|
|
// When subscription is replaced in handleFetchError, this will clean up
|
|
|
|
|
// whatever currentSub points to at shutdown time.
|
|
|
|
|
var currentSub = sub
|
|
|
|
|
cleanupSubscriber := func() {
|
|
|
|
|
if currentSub != nil {
|
|
|
|
|
if err := currentSub.Unsubscribe(); err != nil {
|
|
|
|
|
c.logger.Error("Failed to unsubscribe subscription", zap.Error(err))
|
|
|
|
|
}
|
|
|
|
|
currentSub = nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
defer cleanupSubscriber()
|
|
|
|
|
|
|
|
|
|
c.logger.Info("Started consuming messages",
|
|
|
|
|
zap.String("subject", c.config.subject),
|
|
|
|
|
zap.String("consumer", c.config.consumerName),
|
|
|
|
|
zap.String("stream", c.config.streamName),
|
|
|
|
|
)
|
|
|
|
|
|
2025-11-19 16:49:53 +08:00
|
|
|
// Record initial pending messages metric immediately
|
|
|
|
|
// This ensures the metric appears in Prometheus right away
|
|
|
|
|
if info, err := c.js.ConsumerInfo(c.config.streamName, c.config.consumerName); err == nil {
|
|
|
|
|
c.logger.Info("Recording initial NATS consumer pending messages metric",
|
|
|
|
|
zap.String("stream", c.config.streamName),
|
|
|
|
|
zap.String("consumer", c.config.consumerName),
|
|
|
|
|
zap.Uint64("pending", info.NumPending),
|
|
|
|
|
)
|
|
|
|
|
obsmetrics.RecordNATSConsumerPending(c.config.streamName, c.config.consumerName, info.NumPending)
|
|
|
|
|
} else {
|
|
|
|
|
c.logger.Warn("Failed to fetch initial consumer info for pending messages metric",
|
|
|
|
|
zap.String("stream", c.config.streamName),
|
|
|
|
|
zap.String("consumer", c.config.consumerName),
|
|
|
|
|
zap.Error(err),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-19 13:07:09 +08:00
|
|
|
statsCtx, statsCancel := context.WithCancel(ctx)
|
|
|
|
|
defer statsCancel()
|
|
|
|
|
go c.emitConsumerStats(statsCtx)
|
|
|
|
|
|
|
|
|
|
var fetchErrorStreak int
|
|
|
|
|
|
|
|
|
|
for {
|
|
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
c.logger.Info("Stopping consumer", zap.Error(ctx.Err()))
|
|
|
|
|
return ctx.Err()
|
|
|
|
|
default:
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fetch messages in batch
|
|
|
|
|
msgs, err := c.fetcher.FetchBatch(ctx, currentSub)
|
|
|
|
|
if err != nil {
|
|
|
|
|
// If context was cancelled, return immediately
|
|
|
|
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
|
|
|
|
c.logger.Info("Stopping consumer due to context cancellation", zap.Error(err))
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
shouldContinue, handleErr := c.fetcher.HandleFetchError(ctx, err, ¤tSub, &fetchErrorStreak)
|
|
|
|
|
if !shouldContinue {
|
|
|
|
|
return handleErr
|
|
|
|
|
}
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Successful fetch -> reset error streak.
|
|
|
|
|
if fetchErrorStreak > 0 {
|
|
|
|
|
fetchErrorStreak = 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Process batch
|
|
|
|
|
c.batchProcessor.ProcessBatch(ctx, msgs)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// emitConsumerStats periodically emits basic consumer statistics.
|
|
|
|
|
func (c *Consumer) emitConsumerStats(ctx context.Context) {
|
|
|
|
|
ticker := time.NewTicker(c.config.monitorInterval)
|
|
|
|
|
defer ticker.Stop()
|
|
|
|
|
|
2025-11-19 16:49:53 +08:00
|
|
|
// Record initial metric (0) to ensure it appears in Prometheus even before first tick
|
|
|
|
|
c.logger.Info("Starting NATS consumer stats emission goroutine, recording initial pending metric",
|
|
|
|
|
zap.String("stream", c.config.streamName),
|
|
|
|
|
zap.String("consumer", c.config.consumerName),
|
|
|
|
|
zap.Duration("interval", c.config.monitorInterval),
|
|
|
|
|
)
|
|
|
|
|
obsmetrics.RecordNATSConsumerPending(c.config.streamName, c.config.consumerName, 0)
|
|
|
|
|
|
2025-11-19 13:07:09 +08:00
|
|
|
for {
|
|
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
return
|
|
|
|
|
case <-ticker.C:
|
|
|
|
|
info, err := c.js.ConsumerInfo(c.config.streamName, c.config.consumerName)
|
|
|
|
|
if err != nil {
|
2025-11-19 16:49:53 +08:00
|
|
|
c.logger.Warn("Failed to fetch consumer info for pending messages metric",
|
|
|
|
|
zap.String("stream", c.config.streamName),
|
|
|
|
|
zap.String("consumer", c.config.consumerName),
|
|
|
|
|
zap.Error(err),
|
|
|
|
|
)
|
2025-11-19 13:07:09 +08:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
// Record pending messages for monitoring
|
2025-11-19 16:49:53 +08:00
|
|
|
c.logger.Debug("Recording NATS consumer pending messages metric",
|
|
|
|
|
zap.String("stream", c.config.streamName),
|
|
|
|
|
zap.String("consumer", c.config.consumerName),
|
|
|
|
|
zap.Uint64("pending", info.NumPending),
|
|
|
|
|
)
|
2025-11-19 13:07:09 +08:00
|
|
|
obsmetrics.RecordNATSConsumerPending(c.config.streamName, c.config.consumerName, info.NumPending)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-14 21:42:04 +08:00
|
|
|
// 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 {
|
2025-11-18 11:47:35 +08:00
|
|
|
timeout = 2 * time.Second // Reduced from 10s for faster shutdown
|
2025-11-14 21:42:04 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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())
|
|
|
|
|
}
|
|
|
|
|
}
|