2025-11-14 08:42:26 +08:00
|
|
|
package nats
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"caatsm/internal/infra/config"
|
2025-11-14 22:43:04 +08:00
|
|
|
"errors"
|
2025-11-14 08:42:26 +08:00
|
|
|
"fmt"
|
2025-11-14 22:43:04 +08:00
|
|
|
"os"
|
2025-11-14 21:42:04 +08:00
|
|
|
"strings"
|
|
|
|
|
|
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-14 21:42:04 +08:00
|
|
|
// ProvideNATSConn creates a reusable NATS connection.
|
|
|
|
|
func ProvideNATSConn(cfg *config.Config, logger *zap.Logger) (*nats.Conn, error) {
|
2025-11-14 08:42:26 +08:00
|
|
|
nc, err := nats.Connect(
|
|
|
|
|
cfg.NATS.URL,
|
|
|
|
|
nats.RetryOnFailedConnect(true),
|
|
|
|
|
nats.Timeout(cfg.Timeouts.Server),
|
|
|
|
|
nats.ReconnectWait(cfg.Timeouts.ReconnectWait),
|
|
|
|
|
nats.DisconnectErrHandler(func(nc *nats.Conn, err error) {
|
|
|
|
|
if err != nil {
|
|
|
|
|
logger.Warn("NATS disconnected", zap.Error(err))
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
nats.ReconnectHandler(func(nc *nats.Conn) {
|
|
|
|
|
logger.Info("NATS reconnected", zap.String("url", nc.ConnectedUrl()))
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to connect to NATS: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-14 21:42:04 +08:00
|
|
|
return nc, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ProvideJetStream creates a NATS JetStream context using an existing connection.
|
|
|
|
|
func ProvideJetStream(nc *nats.Conn, cfg *config.Config, logger *zap.Logger) (nats.JetStreamContext, error) {
|
2025-11-14 08:42:26 +08:00
|
|
|
// Get JetStream context
|
|
|
|
|
js, err := nc.JetStream()
|
|
|
|
|
if err != nil {
|
|
|
|
|
nc.Close()
|
|
|
|
|
return nil, fmt.Errorf("failed to get JetStream context: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create stream if it doesn't exist
|
|
|
|
|
streamName := cfg.NATS.Stream
|
2025-11-15 14:24:11 +08:00
|
|
|
consumerSubject := cfg.EffectiveSubscriptionTopic()
|
|
|
|
|
publisherSubject := strings.TrimSpace(cfg.Publisher.Topic)
|
|
|
|
|
|
|
|
|
|
streamSubjects := dedupeSubjects([]string{consumerSubject, publisherSubject})
|
|
|
|
|
if len(streamSubjects) == 0 {
|
|
|
|
|
nc.Close()
|
|
|
|
|
return nil, fmt.Errorf("no subjects configured for JetStream stream %s", streamName)
|
|
|
|
|
}
|
2025-11-14 08:42:26 +08:00
|
|
|
|
2025-11-14 21:42:04 +08:00
|
|
|
streamLimits := cfg.NATS.StreamLimits
|
|
|
|
|
storage := nats.FileStorage
|
|
|
|
|
switch strings.ToLower(streamLimits.Storage) {
|
|
|
|
|
case "memory":
|
|
|
|
|
storage = nats.MemoryStorage
|
|
|
|
|
case "file":
|
|
|
|
|
storage = nats.FileStorage
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
discard := nats.DiscardOld
|
|
|
|
|
if strings.EqualFold(streamLimits.Discard, "new") {
|
|
|
|
|
discard = nats.DiscardNew
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-14 08:42:26 +08:00
|
|
|
streamConfig := &nats.StreamConfig{
|
|
|
|
|
Name: streamName,
|
2025-11-15 14:24:11 +08:00
|
|
|
Subjects: streamSubjects,
|
2025-11-14 08:42:26 +08:00
|
|
|
Retention: nats.LimitsPolicy,
|
2025-11-14 21:42:04 +08:00
|
|
|
MaxMsgs: streamLimits.MaxMsgs,
|
|
|
|
|
MaxBytes: streamLimits.MaxBytes,
|
|
|
|
|
MaxAge: streamLimits.MaxAge,
|
|
|
|
|
Discard: discard,
|
|
|
|
|
Storage: storage,
|
|
|
|
|
Replicas: streamLimits.Replicas,
|
2025-11-14 08:42:26 +08:00
|
|
|
}
|
|
|
|
|
|
2025-11-14 22:43:04 +08:00
|
|
|
info, err := js.StreamInfo(streamName)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, nats.ErrStreamNotFound) {
|
|
|
|
|
if shouldBootstrapStream() {
|
|
|
|
|
if _, err = js.AddStream(streamConfig); err != nil {
|
|
|
|
|
nc.Close()
|
|
|
|
|
return nil, fmt.Errorf("failed to create stream: %w", err)
|
|
|
|
|
}
|
2025-11-15 14:24:11 +08:00
|
|
|
logger.Info("Created JetStream",
|
|
|
|
|
zap.String("stream", streamName),
|
|
|
|
|
zap.Strings("subjects", streamSubjects),
|
|
|
|
|
)
|
2025-11-14 22:43:04 +08:00
|
|
|
} else {
|
|
|
|
|
nc.Close()
|
|
|
|
|
return nil, fmt.Errorf("stream %s not found and auto-creation disabled", streamName)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
nc.Close()
|
|
|
|
|
return nil, fmt.Errorf("failed to fetch stream info: %w", err)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
2025-11-15 14:24:11 +08:00
|
|
|
validateStreamConfig(info, streamSubjects, logger)
|
2025-11-14 08:42:26 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return js, nil
|
|
|
|
|
}
|
2025-11-14 22:43:04 +08:00
|
|
|
|
|
|
|
|
func shouldBootstrapStream() bool {
|
|
|
|
|
switch strings.ToLower(os.Getenv("GO_ENV")) {
|
|
|
|
|
case "", "dev", "development", "test", "testing":
|
|
|
|
|
return true
|
|
|
|
|
default:
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-15 14:24:11 +08:00
|
|
|
func validateStreamConfig(info *nats.StreamInfo, expectedSubjects []string, logger *zap.Logger) {
|
2025-11-14 22:43:04 +08:00
|
|
|
if info == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
2025-11-15 14:24:11 +08:00
|
|
|
defer func() {
|
|
|
|
|
if len(expectedSubjects) == 0 {
|
|
|
|
|
expectedSubjects = []string{"<none>"}
|
|
|
|
|
}
|
|
|
|
|
}()
|
2025-11-14 22:43:04 +08:00
|
|
|
|
2025-11-15 14:24:11 +08:00
|
|
|
missing := make([]string, 0)
|
|
|
|
|
for _, subj := range expectedSubjects {
|
|
|
|
|
if subj == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if !containsSubject(info.Config.Subjects, subj) {
|
|
|
|
|
missing = append(missing, subj)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if len(missing) > 0 {
|
|
|
|
|
logger.Warn("JetStream stream subjects missing expected entries",
|
2025-11-14 22:43:04 +08:00
|
|
|
zap.String("stream", info.Config.Name),
|
|
|
|
|
zap.Strings("stream_subjects", info.Config.Subjects),
|
2025-11-15 14:24:11 +08:00
|
|
|
zap.Strings("missing_subjects", missing),
|
2025-11-14 22:43:04 +08:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-15 14:24:11 +08:00
|
|
|
func containsSubject(subjects []string, target string) bool {
|
2025-11-14 22:43:04 +08:00
|
|
|
for _, s := range subjects {
|
|
|
|
|
if s == target {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
2025-11-15 14:24:11 +08:00
|
|
|
|
|
|
|
|
func dedupeSubjects(subjects []string) []string {
|
|
|
|
|
seen := make(map[string]struct{})
|
|
|
|
|
result := make([]string, 0, len(subjects))
|
|
|
|
|
for _, subj := range subjects {
|
|
|
|
|
subj = strings.TrimSpace(subj)
|
|
|
|
|
if subj == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if _, ok := seen[subj]; ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
seen[subj] = struct{}{}
|
|
|
|
|
result = append(result, subj)
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}
|