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

140 lines
4.3 KiB
Go

package nats
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/nats-io/nats.go"
"go.uber.org/zap"
)
// validateDLQ verifies whether DLQ routing should be enabled and, if so, whether
// the configured DLQ subject is bound to a JetStream stream. Returns an error
// if DLQ is enabled but misconfigured, allowing the caller to fail fast.
func (c *Consumer) validateDLQ() error {
if c == nil {
return nil
}
// DLQ routing is only active in JetStream mode.
if c.config.mode != "jetstream" {
return nil
}
// If DLQ is not enabled in config, make sure we don't accidentally route to it.
if !c.cfg.DLQ.Enabled {
if strings.TrimSpace(c.config.dlqSubject) != "" {
c.logger.Info("DLQ subject configured but dlq.enabled is false; DLQ routing disabled",
zap.String("dlq_subject", c.config.dlqSubject),
)
}
c.config.dlqSubject = ""
return nil
}
subject := strings.TrimSpace(c.config.dlqSubject)
if subject == "" {
return fmt.Errorf("DLQ enabled but dlq.subject is empty")
}
if c.js == nil {
return fmt.Errorf("DLQ enabled but JetStream context is nil")
}
// Ensure the DLQ subject is actually bound to a JetStream stream. This avoids
// the opaque `nats: no response from stream` error later when publishing.
c.telemetry.RecordJSAPICall("dlq_validate_stream")
streamName, err := c.js.StreamNameBySubject(subject)
if err != nil || strings.TrimSpace(streamName) == "" {
return fmt.Errorf("DLQ subject %s not bound to any JetStream stream: %w", subject, err)
}
c.logger.Info("DLQ configuration validated",
zap.String("dlq_subject", subject),
zap.String("dlq_stream", streamName),
)
return nil
}
// routeToDLQ publishes a copy of the failed message to the configured DLQ subject,
// including useful metadata for offline analysis. If DLQ is not configured or the
// consumer is not running in JetStream mode, this is a no-op.
func (c *Consumer) routeToDLQ(ctx context.Context, msg *nats.Msg, cause error) error {
if c == nil || c.js == nil {
return nil
}
if c.config.mode != "jetstream" {
return nil
}
if strings.TrimSpace(c.config.dlqSubject) == "" {
return nil
}
meta, _ := msg.Metadata()
jsSeq := uint64(0)
deliveries := uint64(0)
if meta != nil {
jsSeq = meta.Sequence.Stream
deliveries = meta.NumDelivered
}
payload := map[string]any{
"transport_msg_id": msg.Header.Get("Nats-Msg-Id"),
"subject": msg.Subject,
"stream": c.config.streamName,
"consumer": c.config.consumerName,
"nats_sequence": jsSeq,
"deliveries": deliveries,
"error": fmt.Sprint(cause),
"received_at": time.Now().UTC(),
"body": string(msg.Data),
}
data, err := json.Marshal(payload)
if err != nil {
c.logger.Error("failed to marshal DLQ payload",
zap.String("stream", c.config.streamName),
zap.String("consumer", c.config.consumerName),
zap.String("dlq_subject", c.config.dlqSubject),
zap.Error(err),
)
return fmt.Errorf("marshal dlq payload: %w", err)
}
if _, err := c.js.Publish(c.config.dlqSubject, data); err != nil {
// nats.ErrNoResponders typically means that no JetStream stream is
// configured to receive this subject, or JetStream is temporarily
// unavailable. Surface this explicitly to make operational diagnosis
// easier.
if errors.Is(err, nats.ErrNoResponders) {
c.logger.Error("transient DLQ publish error (no responders)",
zap.String("stream", c.config.streamName),
zap.String("consumer", c.config.consumerName),
zap.String("dlq_subject", c.config.dlqSubject),
zap.Int("payload_size", len(data)),
zap.Error(err),
)
c.telemetry.RecordDLQPublishFailure(ctx, c.config.streamName, c.config.consumerName)
return fmt.Errorf("publish to dlq subject %s: no JetStream stream found for subject or JetStream unavailable: %w", c.config.dlqSubject, err)
}
c.logger.Error("failed to publish to DLQ",
zap.String("stream", c.config.streamName),
zap.String("consumer", c.config.consumerName),
zap.String("dlq_subject", c.config.dlqSubject),
zap.Int("payload_size", len(data)),
zap.Error(err),
)
c.telemetry.RecordDLQPublishFailure(ctx, c.config.streamName, c.config.consumerName)
return fmt.Errorf("publish to dlq subject %s: %w", c.config.dlqSubject, err)
}
c.telemetry.RecordDLQMessage(ctx, c.config.streamName, c.config.consumerName)
return nil
}