✨ Refactor application initialization to load configuration and enhance message processing. Introduce effective subscription topic handling and improve error management in NATS consumer. Update tests for publisher error handling and add new dependency injection method for app initialization.
This commit is contained in:
+15
-1
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"caatsm/internal/infra/config"
|
||||||
"caatsm/pkg/di"
|
"caatsm/pkg/di"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -52,8 +53,21 @@ func setupApp() *cli.App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func executeListen(c *cli.Context) error {
|
func executeListen(c *cli.Context) error {
|
||||||
|
cfg, err := config.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if flagURL := c.String("nats"); flagURL != "" {
|
||||||
|
cfg.NATS.URL = flagURL
|
||||||
|
}
|
||||||
|
|
||||||
|
if flagTopic := c.String("topic"); flagTopic != "" {
|
||||||
|
cfg.Subscription.Topic = flagTopic
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize dependencies using Wire
|
// Initialize dependencies using Wire
|
||||||
processor, consumer, err := di.InitializeApp()
|
processor, consumer, err := di.InitializeAppWithConfig(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to initialize app: %w", err)
|
return fmt.Errorf("failed to initialize app: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"caatsm/internal/adapter/parser"
|
"caatsm/internal/adapter/parser"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ func NewMessageProcessor(
|
|||||||
|
|
||||||
// Handle processes a message
|
// Handle processes a message
|
||||||
func (p *MessageProcessor) Handle(ctx context.Context, raw []byte, msgID string) error {
|
func (p *MessageProcessor) Handle(ctx context.Context, raw []byte, msgID string) error {
|
||||||
if raw == nil || len(raw) == 0 {
|
if len(raw) == 0 {
|
||||||
return Permanent(fmt.Errorf("empty message"))
|
return Permanent(fmt.Errorf("empty message"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ func (p *MessageProcessor) Handle(ctx context.Context, raw []byte, msgID string)
|
|||||||
if !parsed.Parsed {
|
if !parsed.Parsed {
|
||||||
p.logger.Info("Message not parsed",
|
p.logger.Info("Message not parsed",
|
||||||
zap.String("msg_id", msgID),
|
zap.String("msg_id", msgID),
|
||||||
zap.String("content", parsed.Content),
|
zap.String("content_preview", truncateContent(parsed.Content, 200)),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
p.logger.Info("Message parsed successfully",
|
p.logger.Info("Message parsed successfully",
|
||||||
@@ -72,9 +73,19 @@ func (p *MessageProcessor) Handle(ctx context.Context, raw []byte, msgID string)
|
|||||||
zap.String("msg_id", msgID),
|
zap.String("msg_id", msgID),
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
)
|
)
|
||||||
// Return error to trigger NAK and retry
|
// Mark as permanent so the consumer will ack instead of retrying
|
||||||
return fmt.Errorf("failed to publish message: %w", err)
|
return Permanent(fmt.Errorf("failed to publish message: %w", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func truncateContent(content string, limit int) string {
|
||||||
|
if limit <= 0 || len(content) <= limit {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
if limit <= 3 {
|
||||||
|
return content[:limit]
|
||||||
|
}
|
||||||
|
return content[:limit-3] + "..."
|
||||||
|
}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ func TestHandleSuccessSetsUuidAndPublishes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandlePublisherErrorIsRetriable(t *testing.T) {
|
func TestHandlePublisherErrorIsPermanent(t *testing.T) {
|
||||||
parsed := domain.NewParsedMessage()
|
parsed := domain.NewParsedMessage()
|
||||||
parsed.Parsed = true
|
parsed.Parsed = true
|
||||||
|
|
||||||
@@ -106,8 +106,8 @@ func TestHandlePublisherErrorIsRetriable(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("expected error when publisher fails")
|
t.Fatalf("expected error when publisher fails")
|
||||||
}
|
}
|
||||||
if IsPermanent(err) {
|
if !IsPermanent(err) {
|
||||||
t.Fatalf("publisher failure should not be permanent")
|
t.Fatalf("publisher failure should be permanent")
|
||||||
}
|
}
|
||||||
if len(repo.inserted) != 1 {
|
if len(repo.inserted) != 1 {
|
||||||
t.Fatalf("expected message to insert before publish failure")
|
t.Fatalf("expected message to insert before publish failure")
|
||||||
|
|||||||
@@ -244,3 +244,16 @@ func (c *Config) Validate() error {
|
|||||||
func ProvideConfig() (*Config, error) {
|
func ProvideConfig() (*Config, error) {
|
||||||
return LoadConfig()
|
return LoadConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EffectiveSubscriptionTopic returns the active subscription subject.
|
||||||
|
// Retains support for legacy config.Subscription fields while allowing
|
||||||
|
// future consolidation.
|
||||||
|
func (c *Config) EffectiveSubscriptionTopic() string {
|
||||||
|
if c == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if topic := c.Subscription.Topic; topic != "" {
|
||||||
|
return topic
|
||||||
|
}
|
||||||
|
return "telegram.>"
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,10 +30,7 @@ func ProvideConsumer(
|
|||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
logger *zap.Logger,
|
logger *zap.Logger,
|
||||||
) (*Consumer, error) {
|
) (*Consumer, error) {
|
||||||
subject := cfg.Subscription.Topic
|
subject := cfg.EffectiveSubscriptionTopic()
|
||||||
if subject == "" {
|
|
||||||
subject = "telegram.>"
|
|
||||||
}
|
|
||||||
|
|
||||||
consumerName := cfg.NATS.Consumer
|
consumerName := cfg.NATS.Consumer
|
||||||
if consumerName == "" {
|
if consumerName == "" {
|
||||||
@@ -170,8 +167,8 @@ func (c *Consumer) Start(ctx context.Context) error {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if isPermanent {
|
if isPermanent {
|
||||||
if termErr := msg.Term(); termErr != nil {
|
if ackErr := msg.Ack(); ackErr != nil {
|
||||||
c.logger.Error("Failed to TERM message", zap.Error(termErr))
|
c.logger.Error("Failed to ACK permanent-error message", zap.Error(ackErr))
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -255,14 +252,16 @@ func (c *Consumer) Shutdown(ctx context.Context) error {
|
|||||||
|
|
||||||
// processMessage processes a single message
|
// processMessage processes a single message
|
||||||
func (c *Consumer) processMessage(ctx context.Context, msg *nats.Msg) error {
|
func (c *Consumer) processMessage(ctx context.Context, msg *nats.Msg) error {
|
||||||
msgID := msg.Header.Get("Nats-Msg-Id")
|
msgID, source, err := c.resolveMsgID(msg)
|
||||||
if msgID == "" {
|
if err != nil {
|
||||||
// Use reply subject or generate a simple ID
|
return fmt.Errorf("unable to resolve message id: %w", err)
|
||||||
if msg.Reply != "" {
|
}
|
||||||
msgID = msg.Reply
|
if source != "header" {
|
||||||
} else {
|
c.logger.Warn("Message missing NATS id header; using fallback",
|
||||||
msgID = fmt.Sprintf("msg-%d", time.Now().UnixNano())
|
zap.String("subject", msg.Subject),
|
||||||
}
|
zap.String("msg_id_source", source),
|
||||||
|
zap.String("msg_id", msgID),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.logger.Debug("Processing message",
|
c.logger.Debug("Processing message",
|
||||||
@@ -278,3 +277,16 @@ func (c *Consumer) processMessage(ctx context.Context, msg *nats.Msg) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Consumer) resolveMsgID(msg *nats.Msg) (string, string, error) {
|
||||||
|
if id := msg.Header.Get("Nats-Msg-Id"); id != "" {
|
||||||
|
return id, "header", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, err := msg.Metadata()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("fetch metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("js-%d", meta.Sequence.Stream), "metadata", nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package nats
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"caatsm/internal/infra/config"
|
"caatsm/internal/infra/config"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/nats-io/nats.go"
|
"github.com/nats-io/nats.go"
|
||||||
@@ -43,10 +45,7 @@ func ProvideJetStream(nc *nats.Conn, cfg *config.Config, logger *zap.Logger) (na
|
|||||||
|
|
||||||
// Create stream if it doesn't exist
|
// Create stream if it doesn't exist
|
||||||
streamName := cfg.NATS.Stream
|
streamName := cfg.NATS.Stream
|
||||||
subject := cfg.Subscription.Topic
|
subject := cfg.EffectiveSubscriptionTopic()
|
||||||
if subject == "" {
|
|
||||||
subject = "telegram.>"
|
|
||||||
}
|
|
||||||
|
|
||||||
streamLimits := cfg.NATS.StreamLimits
|
streamLimits := cfg.NATS.StreamLimits
|
||||||
storage := nats.FileStorage
|
storage := nats.FileStorage
|
||||||
@@ -74,15 +73,58 @@ func ProvideJetStream(nc *nats.Conn, cfg *config.Config, logger *zap.Logger) (na
|
|||||||
Replicas: streamLimits.Replicas,
|
Replicas: streamLimits.Replicas,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = js.AddStream(streamConfig)
|
info, err := js.StreamInfo(streamName)
|
||||||
if err != nil && err != nats.ErrStreamNameAlreadyInUse {
|
if err != nil {
|
||||||
nc.Close()
|
if errors.Is(err, nats.ErrStreamNotFound) {
|
||||||
return nil, fmt.Errorf("failed to create stream: %w", err)
|
if shouldBootstrapStream() {
|
||||||
}
|
if _, err = js.AddStream(streamConfig); err != nil {
|
||||||
|
nc.Close()
|
||||||
if err == nil {
|
return nil, fmt.Errorf("failed to create stream: %w", err)
|
||||||
logger.Info("Created JetStream", zap.String("stream", streamName), zap.String("subject", subject))
|
}
|
||||||
|
logger.Info("Created JetStream", zap.String("stream", streamName), zap.String("subject", subject))
|
||||||
|
} 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 {
|
||||||
|
validateStreamConfig(info, subject, logger)
|
||||||
}
|
}
|
||||||
|
|
||||||
return js, nil
|
return js, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shouldBootstrapStream() bool {
|
||||||
|
switch strings.ToLower(os.Getenv("GO_ENV")) {
|
||||||
|
case "", "dev", "development", "test", "testing":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateStreamConfig(info *nats.StreamInfo, expectedSubject string, logger *zap.Logger) {
|
||||||
|
if info == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !subjectListContains(info.Config.Subjects, expectedSubject) {
|
||||||
|
logger.Warn("JetStream stream subjects do not match config",
|
||||||
|
zap.String("stream", info.Config.Name),
|
||||||
|
zap.Strings("stream_subjects", info.Config.Subjects),
|
||||||
|
zap.String("configured_subject", expectedSubject),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func subjectListContains(subjects []string, target string) bool {
|
||||||
|
for _, s := range subjects {
|
||||||
|
if s == target {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"caatsm/internal/domain"
|
"caatsm/internal/domain"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -45,7 +46,7 @@ func (r *Repository) InsertOne(ctx context.Context, msg *domain.ParsedMessage) e
|
|||||||
ON CONFLICT (uuid) DO NOTHING
|
ON CONFLICT (uuid) DO NOTHING
|
||||||
`
|
`
|
||||||
|
|
||||||
_, err = r.pool.Exec(ctx, query,
|
tag, err := r.pool.Exec(ctx, query,
|
||||||
row[0], row[1], row[2], row[3], row[4], row[5], row[6], row[7], row[8],
|
row[0], row[1], row[2], row[3], row[4], row[5], row[6], row[7], row[8],
|
||||||
row[9], row[10], row[11], row[12], row[13], row[14],
|
row[9], row[10], row[11], row[12], row[13], row[14],
|
||||||
)
|
)
|
||||||
@@ -53,6 +54,14 @@ func (r *Repository) InsertOne(ctx context.Context, msg *domain.ParsedMessage) e
|
|||||||
return fmt.Errorf("failed to insert message: %w", err)
|
return fmt.Errorf("failed to insert message: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
r.logger.Info("Duplicate message skipped",
|
||||||
|
zap.String("uuid", msg.Uuid),
|
||||||
|
zap.String("message_id", msg.MessageID),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
r.logger.Debug("Inserted message",
|
r.logger.Debug("Inserted message",
|
||||||
zap.String("uuid", msg.Uuid),
|
zap.String("uuid", msg.Uuid),
|
||||||
zap.String("message_id", msg.MessageID),
|
zap.String("message_id", msg.MessageID),
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package di
|
||||||
|
|
||||||
|
import (
|
||||||
|
"caatsm/internal/adapter/parser"
|
||||||
|
"caatsm/internal/app"
|
||||||
|
"caatsm/internal/infra/config"
|
||||||
|
"caatsm/internal/infra/log"
|
||||||
|
"caatsm/internal/infra/nats"
|
||||||
|
"caatsm/internal/infra/postgres"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InitializeAppWithConfig wires dependencies using the provided config.
|
||||||
|
func InitializeAppWithConfig(cfg *config.Config) (*app.MessageProcessor, *nats.Consumer, error) {
|
||||||
|
if cfg == nil {
|
||||||
|
return nil, nil, errors.New("config is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger, err := log.ProvideLogger(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pool, err := postgres.ProvideDB(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
repo, err := postgres.ProvideRepository(pool, logger)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := nats.ProvideNATSConn(cfg, logger)
|
||||||
|
{
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
js, err := nats.ProvideJetStream(conn, cfg, logger)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
publisher, err := nats.ProvidePublisher(js, cfg, logger)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
messageParser := parser.ProvideParser()
|
||||||
|
processor := app.NewMessageProcessor(messageParser, repo, publisher, logger)
|
||||||
|
|
||||||
|
consumer, err := nats.ProvideConsumer(conn, js, processor, cfg, logger)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return processor, consumer, nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user