This commit adds comprehensive documentation for the new GEMINI project, detailing its architecture, tech stack, key commands, and development workflow. The documentation emphasizes the Clean Architecture principles and modular design of the application. Additionally, AGENTS.md is updated to include information about the composite parser pattern and core domain types related to aviation telegrams and weather reports, enhancing clarity on the project's structure and design choices. New Files: - GEMINI.md: Detailed project overview and architecture documentation. Updates: - AGENTS.md: Added sections on parsers and core domain types. These changes aim to improve onboarding and understanding of the project's architecture and functionality.
355 lines
9.0 KiB
Go
355 lines
9.0 KiB
Go
package nats
|
|
|
|
import (
|
|
"caatsm/internal/app"
|
|
"caatsm/internal/infra/config"
|
|
obsmetrics "caatsm/internal/infra/metrics"
|
|
"caatsm/internal/infra/telemetry"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/nats-io/nats.go"
|
|
"go.opentelemetry.io/otel"
|
|
"go.opentelemetry.io/otel/attribute"
|
|
"go.opentelemetry.io/otel/codes"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// Consumer handles NATS JetStream message consumption.
|
|
// It consolidates stream management, fetching, and processing into a single, cohesive unit.
|
|
type Consumer struct {
|
|
// Core dependencies
|
|
conn *nats.Conn
|
|
js nats.JetStreamContext
|
|
processor *app.MessageProcessor
|
|
cfg *config.Config
|
|
logger *zap.Logger
|
|
telemetry telemetry.Recorder
|
|
|
|
// Components
|
|
monitor *ConsumerMonitor
|
|
dlqHandler DLQHandler
|
|
|
|
// Configuration
|
|
streamName string
|
|
consumerName string
|
|
subject string
|
|
batchSize int
|
|
batchTimeout time.Duration
|
|
ackWait time.Duration
|
|
backoff []time.Duration
|
|
|
|
// State
|
|
consecutiveErrors int
|
|
}
|
|
|
|
// ProvideConsumer initializes a NATS consumer, ensuring infrastructure exists.
|
|
func ProvideConsumer(
|
|
conn *nats.Conn,
|
|
js nats.JetStreamContext,
|
|
processor *app.MessageProcessor,
|
|
cfg *config.Config,
|
|
rec telemetry.Recorder,
|
|
logger *zap.Logger,
|
|
) (*Consumer, error) {
|
|
// 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,
|
|
}
|
|
|
|
if c.batchSize <= 0 {
|
|
c.batchSize = 50
|
|
}
|
|
if c.batchTimeout <= 0 {
|
|
c.batchTimeout = 2 * time.Second
|
|
}
|
|
|
|
// 2. Initialize Components
|
|
c.monitor = NewConsumerMonitor(logger, cfg, js, c.streamName, c.consumerName, cfg.App.MonitorInterval)
|
|
if cfg.DLQ.Enabled && cfg.DLQ.Subject != "" {
|
|
c.dlqHandler = &defaultDLQHandler{
|
|
js: js,
|
|
dlqSubject: cfg.DLQ.Subject,
|
|
streamName: c.streamName,
|
|
consumerName: c.consumerName,
|
|
logger: logger,
|
|
telemetry: rec,
|
|
}
|
|
}
|
|
|
|
// 3. Ensure Infrastructure (Stream & Consumer)
|
|
if err := c.ensureInfrastructure(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return c, nil
|
|
}
|
|
|
|
// Start begins the main consumption loop.
|
|
func (c *Consumer) Start(ctx context.Context) error {
|
|
c.logger.Info("Starting consumer",
|
|
zap.String("stream", c.streamName),
|
|
zap.String("consumer", c.consumerName),
|
|
zap.String("subject", c.subject),
|
|
)
|
|
|
|
// 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)
|
|
}
|
|
|
|
// Main Loop
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil
|
|
default:
|
|
}
|
|
|
|
msgs, err := sub.Fetch(c.batchSize, nats.MaxWait(c.batchTimeout))
|
|
if err != nil {
|
|
if errors.Is(err, nats.ErrTimeout) {
|
|
continue // Normal timeout, just retry
|
|
}
|
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
|
return nil
|
|
}
|
|
// Log other errors but keep loop alive unless critical
|
|
c.logger.Warn("Fetch error", zap.Error(err))
|
|
time.Sleep(100 * time.Millisecond) // Slight backoff
|
|
continue
|
|
}
|
|
|
|
c.processBatch(ctx, msgs)
|
|
}
|
|
}
|
|
|
|
// Shutdown gracefully drains the connection.
|
|
func (c *Consumer) Shutdown(ctx context.Context) error {
|
|
if c.conn == nil {
|
|
return nil
|
|
}
|
|
c.logger.Info("Draining NATS connection...")
|
|
return c.conn.Drain()
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
return
|
|
}
|
|
|
|
// Transient error: Backpressure -> Nak with Backoff
|
|
c.consecutiveErrors++
|
|
c.applyBackpressure(ctx)
|
|
|
|
_ = c.nakWithBackoff(msg)
|
|
}
|
|
|
|
// 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])
|
|
}
|
|
|
|
// 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
|
|
}
|
|
if meta, err := msg.Metadata(); err == nil {
|
|
return fmt.Sprintf("js-%d", meta.Sequence.Stream)
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
func orDefault(val, def string) string {
|
|
if val != "" {
|
|
return val
|
|
}
|
|
return def
|
|
}
|
|
|
|
func orDefaultDuration(val, def time.Duration) time.Duration {
|
|
if val > 0 {
|
|
return val
|
|
}
|
|
return def
|
|
} |