✨ Update dependencies, enhance NATS consumer configuration, and improve error handling in message processing. Introduce telemetry support with OpenTelemetry for tracing and metrics. Refactor README to include new configuration options and update tests for improved coverage of error scenarios.
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
package mapper
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestMapper(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Adapter Mapper Suite")
|
||||
}
|
||||
@@ -43,6 +43,11 @@ func (m *TelegramMapper) ToDBRow(msg *domain.ParsedMessage) ([]interface{}, erro
|
||||
// SecondaryAddresses is already a string, so we can use it directly
|
||||
secondaryAddresses := msg.SecondaryAddresses
|
||||
|
||||
status := msg.Status
|
||||
if status == "" {
|
||||
status = domain.MessageStatusUnknown
|
||||
}
|
||||
|
||||
return []interface{}{
|
||||
msgUUID, // uuid
|
||||
msg.MessageID, // message_id
|
||||
@@ -55,6 +60,8 @@ func (m *TelegramMapper) ToDBRow(msg *domain.ParsedMessage) ([]interface{}, erro
|
||||
msg.Category, // category
|
||||
msg.Content, // content (TEXT, original message)
|
||||
bodyDataJSON, // body_data (JSONB)
|
||||
string(status), // status
|
||||
msg.ErrorReason, // error_reason
|
||||
msg.ReceivedAt, // received_at
|
||||
msg.ParsedAt, // parsed_at
|
||||
msg.DispatchedAt, // dispatched_at
|
||||
@@ -64,7 +71,7 @@ func (m *TelegramMapper) ToDBRow(msg *domain.ParsedMessage) ([]interface{}, erro
|
||||
|
||||
// FromDBRow converts a database row to a domain.ParsedMessage
|
||||
func (m *TelegramMapper) FromDBRow(row []interface{}) (*domain.ParsedMessage, error) {
|
||||
const expectedColumns = 15
|
||||
const expectedColumns = 17
|
||||
if len(row) < expectedColumns {
|
||||
return nil, fmt.Errorf("expected %d columns, got %d", expectedColumns, len(row))
|
||||
}
|
||||
@@ -94,6 +101,19 @@ func (m *TelegramMapper) FromDBRow(row []interface{}) (*domain.ParsedMessage, er
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
toBool := func(v interface{}) bool {
|
||||
switch val := v.(type) {
|
||||
case bool:
|
||||
return val
|
||||
case *bool:
|
||||
return val != nil && *val
|
||||
case int64:
|
||||
return val != 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var bodyData interface{}
|
||||
if raw := row[10]; raw != nil {
|
||||
switch val := raw.(type) {
|
||||
@@ -108,7 +128,10 @@ func (m *TelegramMapper) FromDBRow(row []interface{}) (*domain.ParsedMessage, er
|
||||
}
|
||||
}
|
||||
|
||||
needDispatch, _ := row[14].(bool)
|
||||
status := domain.MessageStatusUnknown
|
||||
if rawStatus := toString(row[11]); rawStatus != "" {
|
||||
status = domain.MessageStatus(rawStatus)
|
||||
}
|
||||
|
||||
return &domain.ParsedMessage{
|
||||
Uuid: msgUUID.String(),
|
||||
@@ -122,9 +145,11 @@ func (m *TelegramMapper) FromDBRow(row []interface{}) (*domain.ParsedMessage, er
|
||||
Category: toString(row[8]),
|
||||
Content: toString(row[9]),
|
||||
BodyData: bodyData,
|
||||
ReceivedAt: parseTime(row[11]),
|
||||
ParsedAt: parseTime(row[12]),
|
||||
DispatchedAt: parseTime(row[13]),
|
||||
NeedDispatch: needDispatch,
|
||||
Status: status,
|
||||
ErrorReason: toString(row[12]),
|
||||
ReceivedAt: parseTime(row[13]),
|
||||
ParsedAt: parseTime(row[14]),
|
||||
DispatchedAt: parseTime(row[15]),
|
||||
NeedDispatch: toBool(row[16]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1,71 +1,68 @@
|
||||
package mapper
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"caatsm/internal/domain"
|
||||
|
||||
"github.com/google/uuid"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestTelegramMapper_ToDBRow_GeneratesUUIDWhenEmpty(t *testing.T) {
|
||||
mapper := NewTelegramMapper()
|
||||
msg := &domain.ParsedMessage{}
|
||||
var _ = Describe("TelegramMapper", func() {
|
||||
var mapper *TelegramMapper
|
||||
|
||||
row, err := mapper.ToDBRow(msg)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
BeforeEach(func() {
|
||||
mapper = NewTelegramMapper()
|
||||
})
|
||||
|
||||
value, ok := row[0].(uuid.UUID)
|
||||
if !ok {
|
||||
t.Fatalf("expected first column to be uuid.UUID, got %T", row[0])
|
||||
}
|
||||
if value == uuid.Nil {
|
||||
t.Fatalf("expected generated uuid to be non-nil")
|
||||
}
|
||||
}
|
||||
Describe("ToDBRow", func() {
|
||||
It("generates a UUID when missing", func() {
|
||||
msg := &domain.ParsedMessage{}
|
||||
|
||||
func TestTelegramMapper_FromDBRow_RoundTrip(t *testing.T) {
|
||||
mapper := NewTelegramMapper()
|
||||
now := time.Now().UTC()
|
||||
row, err := mapper.ToDBRow(msg)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
original := &domain.ParsedMessage{
|
||||
Uuid: uuid.NewString(),
|
||||
MessageID: "TMQ1324",
|
||||
DateTime: "150631",
|
||||
PriorityIndicator: "FF",
|
||||
PrimaryAddress: "ZBTJZPZX",
|
||||
SecondaryAddresses: "150630 ZBACZQZX",
|
||||
Originator: "ORIGIN",
|
||||
OriginatorDateTime: "150630",
|
||||
Category: "FPL",
|
||||
Content: "raw telegram",
|
||||
BodyData: map[string]string{"key": "value"},
|
||||
ReceivedAt: now,
|
||||
ParsedAt: now,
|
||||
DispatchedAt: now,
|
||||
NeedDispatch: true,
|
||||
}
|
||||
value, ok := row[0].(uuid.UUID)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(value).NotTo(Equal(uuid.Nil))
|
||||
})
|
||||
})
|
||||
|
||||
row, err := mapper.ToDBRow(original)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
Describe("FromDBRow", func() {
|
||||
It("round-trips telegram data", func() {
|
||||
now := time.Now().UTC()
|
||||
original := &domain.ParsedMessage{
|
||||
Uuid: uuid.NewString(),
|
||||
MessageID: "TMQ1324",
|
||||
DateTime: "150631",
|
||||
PriorityIndicator: "FF",
|
||||
PrimaryAddress: "ZBTJZPZX",
|
||||
SecondaryAddresses: "150630 ZBACZQZX",
|
||||
Originator: "ORIGIN",
|
||||
OriginatorDateTime: "150630",
|
||||
Category: "FPL",
|
||||
Content: "raw telegram",
|
||||
BodyData: map[string]string{"key": "value"},
|
||||
ReceivedAt: now,
|
||||
ParsedAt: now,
|
||||
DispatchedAt: now,
|
||||
NeedDispatch: true,
|
||||
Status: domain.MessageStatusParsed,
|
||||
}
|
||||
|
||||
roundTrip, err := mapper.FromDBRow(row)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error reading row: %v", err)
|
||||
}
|
||||
row, err := mapper.ToDBRow(original)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(row).To(HaveLen(17))
|
||||
|
||||
if roundTrip.Uuid != original.Uuid {
|
||||
t.Fatalf("expected uuid %s, got %s", original.Uuid, roundTrip.Uuid)
|
||||
}
|
||||
if roundTrip.MessageID != original.MessageID {
|
||||
t.Fatalf("expected message_id %s, got %s", original.MessageID, roundTrip.MessageID)
|
||||
}
|
||||
if roundTrip.NeedDispatch != original.NeedDispatch {
|
||||
t.Fatalf("expected need_dispatch %v, got %v", original.NeedDispatch, roundTrip.NeedDispatch)
|
||||
}
|
||||
}
|
||||
roundTrip, err := mapper.FromDBRow(row)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
Expect(roundTrip.Uuid).To(Equal(original.Uuid))
|
||||
Expect(roundTrip.MessageID).To(Equal(original.MessageID))
|
||||
Expect(roundTrip.NeedDispatch).To(Equal(original.NeedDispatch))
|
||||
Expect(roundTrip.Status).To(Equal(original.Status))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,8 +14,7 @@ func NewAviationParser() *AviationParser {
|
||||
}
|
||||
|
||||
// Parse parses a raw message string and returns a ParsedMessage
|
||||
func (p *AviationParser) Parse(rawText string) *domain.ParsedMessage {
|
||||
func (p *AviationParser) Parse(rawText string) (*domain.ParsedMessage, error) {
|
||||
// Use the existing Parse function from internal/parsers
|
||||
return parsers.Parse(rawText)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,5 @@ import "caatsm/internal/domain"
|
||||
// Parser defines the interface for parsing raw telegram messages
|
||||
type Parser interface {
|
||||
// Parse parses a raw message string and returns a ParsedMessage
|
||||
Parse(rawText string) *domain.ParsedMessage
|
||||
|
||||
// TODO: consider returning (*domain.ParsedMessage, error) to surface parse failures explicitly.
|
||||
Parse(rawText string) (*domain.ParsedMessage, error)
|
||||
}
|
||||
|
||||
@@ -12,5 +12,8 @@ type Repository interface {
|
||||
|
||||
// InsertBatch inserts multiple telegram messages in a batch
|
||||
InsertBatch(ctx context.Context, msgs []*domain.ParsedMessage) error
|
||||
|
||||
// InsertRaw captures an unparsed or failed telegram for later analysis.
|
||||
InsertRaw(ctx context.Context, msg *domain.ParsedMessage) error
|
||||
}
|
||||
|
||||
|
||||
+17
-25
@@ -2,32 +2,24 @@ package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestPermanentWrapsError(t *testing.T) {
|
||||
base := errors.New("boom")
|
||||
perr := Permanent(base)
|
||||
var _ = Describe("Permanent errors", func() {
|
||||
It("wraps errors and reports permanence", func() {
|
||||
base := errors.New("boom")
|
||||
perr := Permanent(base)
|
||||
|
||||
if perr == nil {
|
||||
t.Fatalf("expected wrapped error, got nil")
|
||||
}
|
||||
if !IsPermanent(perr) {
|
||||
t.Fatalf("expected IsPermanent to be true")
|
||||
}
|
||||
if !errors.Is(perr, base) {
|
||||
t.Fatalf("expected wrapped error to unwrap to base")
|
||||
}
|
||||
if errors.Is(base, perr) {
|
||||
t.Fatalf("expected base not to consider wrapper as same")
|
||||
}
|
||||
}
|
||||
Expect(perr).NotTo(BeNil())
|
||||
Expect(IsPermanent(perr)).To(BeTrue())
|
||||
Expect(errors.Is(perr, base)).To(BeTrue())
|
||||
Expect(errors.Is(base, perr)).To(BeFalse())
|
||||
})
|
||||
|
||||
func TestPermanentNil(t *testing.T) {
|
||||
if Permanent(nil) != nil {
|
||||
t.Fatalf("Permanent(nil) should return nil")
|
||||
}
|
||||
if IsPermanent(nil) {
|
||||
t.Fatalf("IsPermanent(nil) should be false")
|
||||
}
|
||||
}
|
||||
It("treats nil as non-permanent", func() {
|
||||
Expect(Permanent(nil)).To(BeNil())
|
||||
Expect(IsPermanent(nil)).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
+90
-18
@@ -3,11 +3,16 @@ package app
|
||||
import (
|
||||
"caatsm/internal/adapter"
|
||||
"caatsm/internal/adapter/parser"
|
||||
"caatsm/internal/domain"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -40,11 +45,20 @@ func (p *MessageProcessor) Handle(ctx context.Context, raw []byte, msgID string)
|
||||
return Permanent(fmt.Errorf("empty message"))
|
||||
}
|
||||
|
||||
tracer := otel.Tracer("caatsm/app")
|
||||
ctx, span := tracer.Start(ctx, "MessageProcessor.Handle")
|
||||
defer span.End()
|
||||
span.SetAttributes(attribute.String("nats.msg_id", msgID))
|
||||
|
||||
receivedAt := time.Now()
|
||||
|
||||
parsed := p.parser.Parse(string(raw))
|
||||
parsed, parseErr := p.parser.Parse(string(raw))
|
||||
if parsed == nil {
|
||||
return Permanent(fmt.Errorf("parser returned nil"))
|
||||
parsed = domain.NewParsedMessage()
|
||||
parsed.Content = string(raw)
|
||||
parsed.ErrorReason = "parser returned nil"
|
||||
parsed.Status = domain.MessageStatusBodyError
|
||||
parseErr = fmt.Errorf("parser returned nil")
|
||||
}
|
||||
|
||||
if msgID != "" {
|
||||
@@ -60,44 +74,102 @@ func (p *MessageProcessor) Handle(ctx context.Context, raw []byte, msgID string)
|
||||
if parsed.ParsedAt.IsZero() {
|
||||
parsed.ParsedAt = time.Now()
|
||||
}
|
||||
if parsed.Status == domain.MessageStatusUnknown {
|
||||
if parseErr == nil {
|
||||
parsed.Status = domain.MessageStatusParsed
|
||||
} else {
|
||||
parsed.Status = domain.MessageStatusBodyError
|
||||
}
|
||||
}
|
||||
|
||||
if parseErr != nil || !parsed.Parsed {
|
||||
if parsed.ErrorReason == "" && parseErr != nil {
|
||||
parsed.ErrorReason = parseErr.Error()
|
||||
}
|
||||
span.RecordError(parseErr)
|
||||
span.SetStatus(codes.Error, parseErr.Error())
|
||||
p.persistRaw(ctx, parsed)
|
||||
p.logger.Warn("Message failed to parse",
|
||||
zap.String("msg_id", msgID),
|
||||
zap.String("status", string(parsed.Status)),
|
||||
zap.String("content_preview", truncateContent(parsed.Content, 256)),
|
||||
zap.Error(parseErr),
|
||||
)
|
||||
return Permanent(fmt.Errorf("parser error: %w", parseErr))
|
||||
}
|
||||
parsed.ErrorReason = ""
|
||||
|
||||
// Log parsing result
|
||||
if !parsed.Parsed {
|
||||
p.logger.Warn("Message not parsed",
|
||||
zap.String("msg_id", msgID),
|
||||
zap.String("message_id", parsed.MessageID),
|
||||
zap.String("category", parsed.Category),
|
||||
zap.String("content_preview", truncateContent(parsed.Content, 256)),
|
||||
)
|
||||
} else {
|
||||
p.logger.Info("Message parsed successfully",
|
||||
zap.String("msg_id", msgID),
|
||||
zap.String("message_id", parsed.MessageID),
|
||||
zap.String("category", parsed.Category),
|
||||
zap.Time("received_at", parsed.ReceivedAt),
|
||||
zap.Time("parsed_at", parsed.ParsedAt),
|
||||
)
|
||||
}
|
||||
span.SetAttributes(
|
||||
attribute.String("telegram.status", string(parsed.Status)),
|
||||
attribute.Bool("telegram.parsed", parsed.Parsed),
|
||||
attribute.String("telegram.category", parsed.Category),
|
||||
)
|
||||
|
||||
p.logger.Info("Message parsed successfully",
|
||||
zap.String("msg_id", msgID),
|
||||
zap.String("message_id", parsed.MessageID),
|
||||
zap.String("category", parsed.Category),
|
||||
zap.Time("received_at", parsed.ReceivedAt),
|
||||
zap.Time("parsed_at", parsed.ParsedAt),
|
||||
)
|
||||
|
||||
// Insert into database
|
||||
if err := p.repository.InsertOne(ctx, parsed); err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return fmt.Errorf("failed to insert message: %w", err)
|
||||
}
|
||||
|
||||
// Publish parsed message
|
||||
_, pubSpan := tracer.Start(ctx, "Publisher.Publish")
|
||||
if err := p.publisher.Publish(parsed); err != nil {
|
||||
// Log error but don't fail the entire operation
|
||||
p.logger.Error("Failed to publish message",
|
||||
zap.String("msg_id", msgID),
|
||||
zap.Error(err),
|
||||
)
|
||||
pubSpan.RecordError(err)
|
||||
pubSpan.SetStatus(codes.Error, err.Error())
|
||||
parsed.Status = domain.MessageStatusPublishFail
|
||||
parsed.ErrorReason = err.Error()
|
||||
p.persistRaw(ctx, parsed)
|
||||
// Mark as permanent so the consumer will ack instead of retrying
|
||||
pubSpan.End()
|
||||
return Permanent(fmt.Errorf("failed to publish message: %w", err))
|
||||
}
|
||||
pubSpan.End()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *MessageProcessor) persistRaw(ctx context.Context, msg *domain.ParsedMessage) {
|
||||
if msg == nil || p.repository == nil {
|
||||
return
|
||||
}
|
||||
if msg.Content == "" && msg.BodyData != nil {
|
||||
msg.Content = fmt.Sprintf("%v", msg.BodyData)
|
||||
}
|
||||
if msg.ReceivedAt.IsZero() {
|
||||
msg.ReceivedAt = time.Now()
|
||||
}
|
||||
if err := p.repository.InsertRaw(ctx, msg); err != nil {
|
||||
p.logger.Error("Failed to persist raw telegram",
|
||||
zap.String("message_id", msg.MessageID),
|
||||
zap.String("status", string(msg.Status)),
|
||||
zap.Error(err),
|
||||
)
|
||||
} else {
|
||||
if span := trace.SpanFromContext(ctx); span.IsRecording() {
|
||||
span.AddEvent("raw telegram persisted",
|
||||
trace.WithAttributes(
|
||||
attribute.String("telegram.status", string(msg.Status)),
|
||||
attribute.String("telegram.message_id", msg.MessageID),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func truncateContent(content string, limit int) string {
|
||||
if limit <= 0 || len(content) <= limit {
|
||||
return content
|
||||
|
||||
+134
-142
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"caatsm/internal/adapter"
|
||||
@@ -12,166 +11,137 @@ import (
|
||||
"caatsm/internal/domain"
|
||||
|
||||
"github.com/google/uuid"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
)
|
||||
|
||||
func TestHandleEmptyMessageIsPermanent(t *testing.T) {
|
||||
proc := newTestProcessor(&stubParser{}, &stubRepository{}, &stubPublisher{})
|
||||
err := proc.Handle(context.Background(), nil, "id-1")
|
||||
if err == nil || !IsPermanent(err) {
|
||||
t.Fatalf("expected permanent error for empty message, got %v", err)
|
||||
}
|
||||
}
|
||||
var _ = Describe("MessageProcessor", func() {
|
||||
var (
|
||||
repo *stubRepository
|
||||
pub *stubPublisher
|
||||
proc *MessageProcessor
|
||||
ctx context.Context
|
||||
parserStub *stubParser
|
||||
)
|
||||
|
||||
func TestHandleNilParserResultIsPermanent(t *testing.T) {
|
||||
proc := newTestProcessor(&stubParser{value: nil}, &stubRepository{}, &stubPublisher{})
|
||||
err := proc.Handle(context.Background(), []byte("payload"), "id-2")
|
||||
if err == nil || !IsPermanent(err) {
|
||||
t.Fatalf("expected permanent error for nil parser result, got %v", err)
|
||||
}
|
||||
}
|
||||
BeforeEach(func() {
|
||||
repo = &stubRepository{}
|
||||
pub = &stubPublisher{}
|
||||
parserStub = &stubParser{}
|
||||
proc = newTestProcessor(parserStub, repo, pub)
|
||||
ctx = context.Background()
|
||||
})
|
||||
|
||||
func TestHandleSuccessDoesNotOverwriteUuid(t *testing.T) {
|
||||
originalUUID := uuid.NewString()
|
||||
parsed := &domain.ParsedMessage{Uuid: originalUUID, Parsed: true}
|
||||
Describe("Handle", func() {
|
||||
It("returns a permanent error when payload is empty", func() {
|
||||
err := proc.Handle(ctx, nil, "id-1")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(IsPermanent(err)).To(BeTrue())
|
||||
})
|
||||
|
||||
repo := &stubRepository{}
|
||||
pub := &stubPublisher{}
|
||||
proc := newTestProcessor(&stubParser{value: parsed}, repo, pub)
|
||||
It("records raw messages when parser returns nil", func() {
|
||||
parserStub.value = nil
|
||||
err := proc.Handle(ctx, []byte("payload"), "id-2")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(IsPermanent(err)).To(BeTrue())
|
||||
Expect(repo.rawCount()).To(Equal(1))
|
||||
})
|
||||
|
||||
const msgID = "msg-123"
|
||||
if err := proc.Handle(context.Background(), []byte("payload"), msgID); err != nil {
|
||||
t.Fatalf("expected success, got %v", err)
|
||||
}
|
||||
It("preserves UUIDs and appends nats message id comment", func() {
|
||||
originalUUID := uuid.NewString()
|
||||
parserStub.value = &domain.ParsedMessage{Uuid: originalUUID, Parsed: true, Status: domain.MessageStatusParsed}
|
||||
|
||||
if repo.last() == nil {
|
||||
t.Fatalf("expected message to be inserted")
|
||||
}
|
||||
if repo.last().Uuid != originalUUID {
|
||||
t.Fatalf("expected uuid to remain %s, got %s", originalUUID, repo.last().Uuid)
|
||||
}
|
||||
if !strings.Contains(repo.last().Comments, "nats_msg_id=msg-123") {
|
||||
t.Fatalf("expected comments to contain msg id, got %q", repo.last().Comments)
|
||||
}
|
||||
if pub.last == nil {
|
||||
t.Fatalf("expected publisher to receive message")
|
||||
}
|
||||
}
|
||||
Expect(proc.Handle(ctx, []byte("payload"), "msg-123")).To(Succeed())
|
||||
|
||||
func TestHandlePublisherErrorIsPermanent(t *testing.T) {
|
||||
parsed := &domain.ParsedMessage{Parsed: true}
|
||||
Expect(repo.last()).NotTo(BeNil())
|
||||
Expect(repo.last().Uuid).To(Equal(originalUUID))
|
||||
Expect(repo.last().Comments).To(ContainSubstring("nats_msg_id=msg-123"))
|
||||
Expect(pub.last).NotTo(BeNil())
|
||||
})
|
||||
|
||||
repo := &stubRepository{}
|
||||
pub := &stubPublisher{err: errors.New("publish failed")}
|
||||
proc := newTestProcessor(&stubParser{value: parsed}, repo, pub)
|
||||
It("treats publisher failures as permanent and stores raw entries", func() {
|
||||
parserStub.value = &domain.ParsedMessage{Parsed: true, Status: domain.MessageStatusParsed}
|
||||
pub.err = errors.New("publish failed")
|
||||
|
||||
err := proc.Handle(context.Background(), []byte("payload"), "id-3")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when publisher fails")
|
||||
}
|
||||
if !IsPermanent(err) {
|
||||
t.Fatalf("publisher failure should be permanent")
|
||||
}
|
||||
if repo.last() == nil {
|
||||
t.Fatalf("expected message to insert before publish failure")
|
||||
}
|
||||
}
|
||||
err := proc.Handle(ctx, []byte("payload"), "id-3")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(IsPermanent(err)).To(BeTrue())
|
||||
Expect(repo.last()).NotTo(BeNil())
|
||||
Expect(repo.rawCount()).To(Equal(1))
|
||||
Expect(repo.lastRaw().Status).To(Equal(domain.MessageStatusPublishFail))
|
||||
})
|
||||
|
||||
func TestMessageProcessor_Handle_SetsReceivedAndParsedAtWhenZero(t *testing.T) {
|
||||
parsed := &domain.ParsedMessage{
|
||||
Uuid: uuid.NewString(),
|
||||
Parsed: true,
|
||||
}
|
||||
repo := &stubRepository{}
|
||||
publisher := &stubPublisher{}
|
||||
logger := zap.NewNop()
|
||||
processor := NewMessageProcessor(&stubParser{value: parsed}, repo, publisher, logger)
|
||||
It("sets timestamps when missing", func() {
|
||||
parserStub.value = &domain.ParsedMessage{
|
||||
Uuid: uuid.NewString(),
|
||||
Parsed: true,
|
||||
Status: domain.MessageStatusParsed,
|
||||
}
|
||||
pub.err = nil
|
||||
|
||||
start := time.Now()
|
||||
if err := processor.Handle(context.Background(), []byte("raw"), "msg-4"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
saved := repo.last()
|
||||
if saved == nil {
|
||||
t.Fatal("expected repository to receive a message")
|
||||
}
|
||||
if saved.ReceivedAt.IsZero() || saved.ParsedAt.IsZero() {
|
||||
t.Fatalf("expected timestamps to be set, got received=%v parsed=%v", saved.ReceivedAt, saved.ParsedAt)
|
||||
}
|
||||
if saved.ReceivedAt.Before(start.Add(-time.Second)) || saved.ParsedAt.Before(start.Add(-time.Second)) {
|
||||
t.Fatalf("timestamps look stale: received=%v parsed=%v", saved.ReceivedAt, saved.ParsedAt)
|
||||
}
|
||||
}
|
||||
start := time.Now()
|
||||
Expect(proc.Handle(ctx, []byte("payload"), "msg-4")).To(Succeed())
|
||||
|
||||
func TestMessageProcessor_Handle_DoesNotOverrideExistingTimestamps(t *testing.T) {
|
||||
received := time.Now().Add(-2 * time.Minute)
|
||||
parsedAt := time.Now().Add(-time.Minute)
|
||||
saved := repo.last()
|
||||
Expect(saved).NotTo(BeNil())
|
||||
Expect(saved.ReceivedAt).NotTo(BeZero())
|
||||
Expect(saved.ParsedAt).NotTo(BeZero())
|
||||
Expect(saved.ReceivedAt.After(start.Add(-time.Second))).To(BeTrue())
|
||||
Expect(saved.ParsedAt.After(start.Add(-time.Second))).To(BeTrue())
|
||||
})
|
||||
|
||||
parsed := &domain.ParsedMessage{
|
||||
Uuid: uuid.NewString(),
|
||||
Parsed: true,
|
||||
ReceivedAt: received,
|
||||
ParsedAt: parsedAt,
|
||||
}
|
||||
repo := &stubRepository{}
|
||||
publisher := &stubPublisher{}
|
||||
logger := zap.NewNop()
|
||||
processor := NewMessageProcessor(&stubParser{value: parsed}, repo, publisher, logger)
|
||||
It("does not override provided timestamps", func() {
|
||||
received := time.Now().Add(-2 * time.Minute)
|
||||
parsedAt := time.Now().Add(-1 * time.Minute)
|
||||
parserStub.value = &domain.ParsedMessage{
|
||||
Uuid: uuid.NewString(),
|
||||
Parsed: true,
|
||||
Status: domain.MessageStatusParsed,
|
||||
ReceivedAt: received,
|
||||
ParsedAt: parsedAt,
|
||||
}
|
||||
|
||||
if err := processor.Handle(context.Background(), []byte("raw"), "msg-5"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
saved := repo.last()
|
||||
if saved.ReceivedAt != received {
|
||||
t.Fatalf("expected received_at to remain %v, got %v", received, saved.ReceivedAt)
|
||||
}
|
||||
if saved.ParsedAt != parsedAt {
|
||||
t.Fatalf("expected parsed_at to remain %v, got %v", parsedAt, saved.ParsedAt)
|
||||
}
|
||||
}
|
||||
Expect(proc.Handle(ctx, []byte("payload"), "msg-5")).To(Succeed())
|
||||
Expect(repo.last().ReceivedAt).To(Equal(received))
|
||||
Expect(repo.last().ParsedAt).To(Equal(parsedAt))
|
||||
})
|
||||
|
||||
func TestContentPreview_TruncatesLongContent(t *testing.T) {
|
||||
longContent := strings.Repeat("a", 1024)
|
||||
preview := truncateContent(longContent, 256)
|
||||
if len(preview) != 256 {
|
||||
t.Fatalf("expected preview length 256, got %d", len(preview))
|
||||
}
|
||||
if !strings.HasSuffix(preview, "...") {
|
||||
t.Fatalf("expected preview to end with ellipsis, got %q", preview[len(preview)-10:])
|
||||
}
|
||||
}
|
||||
It("logs truncated previews when parsing fails", func() {
|
||||
core, logs := observer.New(zap.WarnLevel)
|
||||
logger := zap.New(core)
|
||||
parserStub = &stubParser{
|
||||
value: &domain.ParsedMessage{
|
||||
Content: strings.Repeat("x", 1024),
|
||||
Parsed: false,
|
||||
Status: domain.MessageStatusBodyError,
|
||||
ErrorReason: "parse failure",
|
||||
},
|
||||
err: errors.New("parse failure"),
|
||||
}
|
||||
proc = NewMessageProcessor(parserStub, repo, pub, logger)
|
||||
|
||||
func TestMessageProcessor_Handle_NotParsedLogsPreviewOnly(t *testing.T) {
|
||||
core, logs := observer.New(zap.WarnLevel)
|
||||
logger := zap.New(core)
|
||||
err := proc.Handle(ctx, []byte("raw"), "msg-6")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(IsPermanent(err)).To(BeTrue())
|
||||
|
||||
parser := &stubParser{
|
||||
value: &domain.ParsedMessage{
|
||||
Content: strings.Repeat("x", 1024),
|
||||
Parsed: false,
|
||||
},
|
||||
}
|
||||
repo := &stubRepository{}
|
||||
publisher := &stubPublisher{}
|
||||
processor := NewMessageProcessor(parser, repo, publisher, logger)
|
||||
entries := logs.FilterMessage("Message failed to parse").All()
|
||||
Expect(entries).NotTo(BeEmpty())
|
||||
preview, ok := entries[0].ContextMap()["content_preview"].(string)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(len(preview)).To(BeNumerically("<=", 256))
|
||||
})
|
||||
})
|
||||
|
||||
if err := processor.Handle(context.Background(), []byte("raw"), "msg-6"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
entries := logs.FilterMessage("Message not parsed").All()
|
||||
if len(entries) == 0 {
|
||||
t.Fatal("expected a warning log for unparsed message")
|
||||
}
|
||||
preview, ok := entries[0].ContextMap()["content_preview"].(string)
|
||||
if !ok {
|
||||
t.Fatal("expected content_preview field in log")
|
||||
}
|
||||
if len(preview) > 256 {
|
||||
t.Fatalf("expected preview <= 256 chars, got %d", len(preview))
|
||||
}
|
||||
}
|
||||
Describe("truncateContent", func() {
|
||||
It("keeps length at limit with ellipsis", func() {
|
||||
longContent := strings.Repeat("a", 1024)
|
||||
Expect(truncateContent(longContent, 256)).To(HaveLen(256))
|
||||
Expect(truncateContent(longContent, 256)).To(HaveSuffix("..."))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func newTestProcessor(p parser.Parser, repo adapter.Repository, pub adapter.Publisher) *MessageProcessor {
|
||||
return NewMessageProcessor(p, repo, pub, zap.NewNop())
|
||||
@@ -179,15 +149,18 @@ func newTestProcessor(p parser.Parser, repo adapter.Repository, pub adapter.Publ
|
||||
|
||||
type stubParser struct {
|
||||
value *domain.ParsedMessage
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubParser) Parse(rawText string) *domain.ParsedMessage {
|
||||
return s.value
|
||||
func (s *stubParser) Parse(rawText string) (*domain.ParsedMessage, error) {
|
||||
return s.value, s.err
|
||||
}
|
||||
|
||||
type stubRepository struct {
|
||||
inserted []*domain.ParsedMessage
|
||||
raw []*domain.ParsedMessage
|
||||
err error
|
||||
rawErr error
|
||||
}
|
||||
|
||||
func (s *stubRepository) InsertOne(ctx context.Context, msg *domain.ParsedMessage) error {
|
||||
@@ -202,6 +175,14 @@ func (s *stubRepository) InsertBatch(ctx context.Context, msgs []*domain.ParsedM
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (s *stubRepository) InsertRaw(ctx context.Context, msg *domain.ParsedMessage) error {
|
||||
if s.rawErr != nil {
|
||||
return s.rawErr
|
||||
}
|
||||
s.raw = append(s.raw, msg)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubRepository) last() *domain.ParsedMessage {
|
||||
if len(s.inserted) == 0 {
|
||||
return nil
|
||||
@@ -209,6 +190,17 @@ func (s *stubRepository) last() *domain.ParsedMessage {
|
||||
return s.inserted[len(s.inserted)-1]
|
||||
}
|
||||
|
||||
func (s *stubRepository) lastRaw() *domain.ParsedMessage {
|
||||
if len(s.raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.raw[len(s.raw)-1]
|
||||
}
|
||||
|
||||
func (s *stubRepository) rawCount() int {
|
||||
return len(s.raw)
|
||||
}
|
||||
|
||||
type stubPublisher struct {
|
||||
last interface{}
|
||||
err error
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestApp(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "App Suite")
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var MyConfig *Config
|
||||
|
||||
type Config struct {
|
||||
Nats NatsConfig
|
||||
Subscription SubscriptionConfig
|
||||
Publisher PublisherConfig
|
||||
Timeouts TimeoutsConfig
|
||||
Hasura HasuraConfig
|
||||
}
|
||||
|
||||
type NatsConfig struct {
|
||||
Client string
|
||||
URL string
|
||||
Cluster string
|
||||
}
|
||||
|
||||
type SubscriptionConfig struct {
|
||||
Topic string `mapstructure:"topic"`
|
||||
QueueGroup string `mapstructure:"queue_group"`
|
||||
}
|
||||
|
||||
type PublisherConfig struct {
|
||||
Topic string `mapstructure:"topic"`
|
||||
}
|
||||
|
||||
type TimeoutsConfig struct {
|
||||
Server time.Duration `mapstructure:"server"`
|
||||
ReconnectWait time.Duration `mapstructure:"reconnect_wait"`
|
||||
Close time.Duration `mapstructure:"close"`
|
||||
AckWait time.Duration `mapstructure:"ack_wait"`
|
||||
}
|
||||
|
||||
type BodyConfig struct {
|
||||
Patterns []PatternConfig
|
||||
}
|
||||
|
||||
type PatternConfig struct {
|
||||
Pattern string
|
||||
Comments string
|
||||
Expression *regexp.Regexp
|
||||
}
|
||||
|
||||
type HasuraConfig struct {
|
||||
Endpoint string
|
||||
Secret string
|
||||
}
|
||||
|
||||
const (
|
||||
EnvProd = "prod"
|
||||
EnvDev = "dev"
|
||||
EnvTest = "test"
|
||||
)
|
||||
|
||||
func SetMyConfig(cfg *Config) {
|
||||
MyConfig = cfg
|
||||
}
|
||||
|
||||
func GetMyConfig() *Config {
|
||||
if MyConfig == nil {
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
fmt.Printf("error loading config: %v", err)
|
||||
}
|
||||
MyConfig = cfg
|
||||
}
|
||||
return MyConfig
|
||||
}
|
||||
|
||||
// LoadConfig loads the configuration from a file
|
||||
func LoadConfig() (*Config, error) {
|
||||
// log := utils.Logger
|
||||
env := os.Getenv("GO_ENV")
|
||||
if env == "" {
|
||||
env = "dev"
|
||||
}
|
||||
// log.Infof("Environment: %s", env)
|
||||
|
||||
viper.SetConfigType("toml")
|
||||
viper.SetConfigName("config." + env)
|
||||
viper.AddConfigPath("configs")
|
||||
viper.SetEnvPrefix("tele")
|
||||
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
// log.Errorf("error reading config file for environment '%s': %v", env, err)
|
||||
return nil, fmt.Errorf("error reading config file for environment '%s': %w", env, err)
|
||||
}
|
||||
|
||||
var config Config
|
||||
if err := viper.Unmarshal(&config); err != nil {
|
||||
// log.Errorf("unable to decode config into struct for environment '%s': %v", env, err)
|
||||
return nil, fmt.Errorf("unable to decode config into struct for environment '%s': %w", env, err)
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// ValidateConfig validates the loaded configuration
|
||||
func ValidateConfig(cfg *Config) error {
|
||||
// log := utils.Logger
|
||||
|
||||
if cfg.Nats.Client == "" {
|
||||
return fmt.Errorf("nats client is required")
|
||||
}
|
||||
if cfg.Nats.URL == "" {
|
||||
return fmt.Errorf("nats URL is required")
|
||||
}
|
||||
if cfg.Subscription.Topic == "" {
|
||||
return fmt.Errorf("subscription topic is required")
|
||||
}
|
||||
// fmt.Println("config validation passed")
|
||||
return nil
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
func TestConfig(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Config Suite")
|
||||
}
|
||||
|
||||
var _ = Describe("Config", func() {
|
||||
var originalEnv string
|
||||
|
||||
BeforeEach(func() {
|
||||
// Save the original GO_ENV value
|
||||
originalEnv = os.Getenv("GO_ENV")
|
||||
// Set up a temporary configuration file for testing
|
||||
viper.Reset()
|
||||
viper.SetConfigType("toml")
|
||||
configContent := `
|
||||
[nats]
|
||||
client = "test-client"
|
||||
url = "nats://localhost:4222"
|
||||
cluster = "test-cluster"
|
||||
|
||||
[subscription]
|
||||
topic = "example-topic"
|
||||
queue_group = "example-group"
|
||||
|
||||
[timeouts]
|
||||
server_timeout = "30s"
|
||||
reconnect_wait = "10s"
|
||||
close_timeout = "10s"
|
||||
ack_wait_timeout = "5s"
|
||||
|
||||
[hasura]
|
||||
endpoint = "http://localhost:8080/v1/graphql"
|
||||
secret = "aviation-test"
|
||||
`
|
||||
tmpFile, err := os.CreateTemp("", "config.*.toml")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
_, err = tmpFile.Write([]byte(configContent))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
err = tmpFile.Close()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
viper.SetConfigFile(tmpFile.Name())
|
||||
err = viper.ReadInConfig()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Load the configuration
|
||||
MyConfig = &Config{}
|
||||
err = viper.Unmarshal(MyConfig)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
// Restore the original GO_ENV value
|
||||
os.Setenv("GO_ENV", originalEnv)
|
||||
})
|
||||
|
||||
Context("Loading configuration", func() {
|
||||
It("should load the configuration correctly", func() {
|
||||
cfg := GetMyConfig()
|
||||
Expect(cfg).NotTo(BeNil())
|
||||
Expect(cfg.Nats.Client).To(Equal("test-client"))
|
||||
Expect(cfg.Nats.URL).To(Equal("nats://localhost:4222"))
|
||||
Expect(cfg.Subscription.Topic).To(Equal("example-topic"))
|
||||
Expect(cfg.Subscription.QueueGroup).To(Equal("example-group"))
|
||||
Expect(cfg.Hasura.Endpoint).To(Equal("http://localhost:8080/v1/graphql"))
|
||||
Expect(cfg.Hasura.Secret).To(Equal("aviation-test"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Validating configuration", func() {
|
||||
It("should validate a valid configuration", func() {
|
||||
cfg := GetMyConfig()
|
||||
err := ValidateConfig(cfg)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should return an error for missing NATS client", func() {
|
||||
cfg := GetMyConfig()
|
||||
cfg.Nats.Client = ""
|
||||
err := ValidateConfig(cfg)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(Equal("nats client is required"))
|
||||
})
|
||||
|
||||
It("should return an error for missing NATS URL", func() {
|
||||
cfg := GetMyConfig()
|
||||
cfg.Nats.URL = ""
|
||||
err := ValidateConfig(cfg)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(Equal("nats URL is required"))
|
||||
})
|
||||
|
||||
It("should return an error for missing subscription topic", func() {
|
||||
cfg := GetMyConfig()
|
||||
cfg.Subscription.Topic = ""
|
||||
err := ValidateConfig(cfg)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(Equal("subscription topic is required"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -84,6 +84,18 @@ DispatchedAt: time.Time{}.
|
||||
NeedDispatch: false.
|
||||
*/
|
||||
|
||||
// ParsedMessage holds the parsed data from an aviation message
|
||||
type MessageStatus string
|
||||
|
||||
const (
|
||||
MessageStatusUnknown MessageStatus = "unknown"
|
||||
MessageStatusParsed MessageStatus = "parsed"
|
||||
MessageStatusHeaderError MessageStatus = "header_error"
|
||||
MessageStatusBodyError MessageStatus = "body_error"
|
||||
MessageStatusRepositoryFail MessageStatus = "repository_error"
|
||||
MessageStatusPublishFail MessageStatus = "publish_error"
|
||||
)
|
||||
|
||||
// ParsedMessage holds the parsed data from an aviation message
|
||||
type ParsedMessage struct {
|
||||
// StartIndicator string `json:"startIndicator"` // 电报开始标识: The start of the message indicator (e.g., 'ZCZC').
|
||||
@@ -105,7 +117,8 @@ type ParsedMessage struct {
|
||||
NeedDispatch bool `json:"needDispatch"` // 需要分发: Indicates if the message needs to be dispatched.
|
||||
Parsed bool `json:"parsed"` // 解析: Indicates if the message has been parsed.
|
||||
Comments string `json:"comments,omitempty"` // 备注: Additional comments.
|
||||
|
||||
Status MessageStatus
|
||||
ErrorReason string `json:"errorReason,omitempty"`
|
||||
}
|
||||
|
||||
// NewParsedMessage initializes a ParsedMessage with default values
|
||||
@@ -113,6 +126,7 @@ func NewParsedMessage() *ParsedMessage {
|
||||
return &ParsedMessage{
|
||||
// SecondaryAddresses: []string{},
|
||||
Parsed: false,
|
||||
Status: MessageStatusUnknown,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package iface
|
||||
|
||||
import (
|
||||
"caatsm/internal/config"
|
||||
"caatsm/internal/domain"
|
||||
"caatsm/internal/infra/config"
|
||||
)
|
||||
|
||||
type MessageHandler interface {
|
||||
|
||||
@@ -19,6 +19,7 @@ type Config struct {
|
||||
App AppConfig `koanf:"app"`
|
||||
Log LogConfig `koanf:"log"`
|
||||
Publisher PublisherConfig `koanf:"publisher"`
|
||||
Telemetry TelemetryConfig `koanf:"telemetry"`
|
||||
// Legacy fields for backward compatibility during migration
|
||||
Subscription SubscriptionConfig `koanf:"subscription"`
|
||||
Timeouts TimeoutsConfig `koanf:"timeouts"`
|
||||
@@ -51,6 +52,11 @@ type ConsumerRulesConfig struct {
|
||||
MaxDeliver int `koanf:"max_deliver"`
|
||||
AckWait time.Duration `koanf:"ack_wait"`
|
||||
MaxAckPending int `koanf:"max_ack_pending"`
|
||||
DeliverPolicy string `koanf:"deliver_policy"`
|
||||
ReplayPolicy string `koanf:"replay_policy"`
|
||||
Backoff []time.Duration `koanf:"backoff"`
|
||||
StartSequence uint64 `koanf:"start_sequence"`
|
||||
StartTime string `koanf:"start_time"`
|
||||
}
|
||||
|
||||
// PostgresConfig holds PostgreSQL configuration
|
||||
@@ -78,6 +84,13 @@ type PublisherConfig struct {
|
||||
Topic string `koanf:"topic"`
|
||||
}
|
||||
|
||||
// TelemetryConfig controls tracing/metrics exporters.
|
||||
type TelemetryConfig struct {
|
||||
Enabled bool `koanf:"enabled"`
|
||||
Endpoint string `koanf:"endpoint"`
|
||||
Insecure bool `koanf:"insecure"`
|
||||
}
|
||||
|
||||
// SubscriptionConfig holds subscription configuration (legacy)
|
||||
type SubscriptionConfig struct {
|
||||
Topic string `koanf:"topic"`
|
||||
@@ -183,6 +196,15 @@ func LoadConfig() (*Config, error) {
|
||||
if cfg.NATS.ConsumerRules.MaxAckPending == 0 {
|
||||
cfg.NATS.ConsumerRules.MaxAckPending = 1024
|
||||
}
|
||||
if cfg.NATS.ConsumerRules.DeliverPolicy == "" {
|
||||
cfg.NATS.ConsumerRules.DeliverPolicy = "all"
|
||||
}
|
||||
if cfg.NATS.ConsumerRules.ReplayPolicy == "" {
|
||||
cfg.NATS.ConsumerRules.ReplayPolicy = "instant"
|
||||
}
|
||||
if cfg.Telemetry.Endpoint == "" {
|
||||
cfg.Telemetry.Endpoint = ""
|
||||
}
|
||||
|
||||
// Validate configuration
|
||||
if err := cfg.Validate(); err != nil {
|
||||
@@ -236,6 +258,29 @@ func (c *Config) Validate() error {
|
||||
if c.NATS.ConsumerRules.MaxAckPending < 0 {
|
||||
return fmt.Errorf("nats.consumer.max_ack_pending must be >= 0")
|
||||
}
|
||||
switch strings.ToLower(c.NATS.ConsumerRules.DeliverPolicy) {
|
||||
case "", "all", "new", "last", "last_per_subject", "sequence", "time":
|
||||
default:
|
||||
return fmt.Errorf("nats.consumer.deliver_policy must be one of all,new,last,last_per_subject,sequence,time")
|
||||
}
|
||||
switch strings.ToLower(c.NATS.ConsumerRules.ReplayPolicy) {
|
||||
case "", "instant", "original":
|
||||
default:
|
||||
return fmt.Errorf("nats.consumer.replay_policy must be instant or original")
|
||||
}
|
||||
if c.NATS.ConsumerRules.StartTime != "" {
|
||||
if _, err := time.Parse(time.RFC3339, c.NATS.ConsumerRules.StartTime); err != nil {
|
||||
return fmt.Errorf("nats.consumer.start_time must be RFC3339: %w", err)
|
||||
}
|
||||
}
|
||||
for _, d := range c.NATS.ConsumerRules.Backoff {
|
||||
if d < 0 {
|
||||
return fmt.Errorf("nats.consumer.backoff durations must be >= 0")
|
||||
}
|
||||
}
|
||||
if c.Telemetry.Endpoint == "" && c.Telemetry.Enabled {
|
||||
return fmt.Errorf("telemetry.endpoint is required when telemetry.enabled=true")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,35 +3,38 @@ package config
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestLoadConfig_DefaultAckWait(t *testing.T) {
|
||||
t.Setenv("GO_ENV", "testdefaults")
|
||||
var _ = Describe("LoadConfig", func() {
|
||||
var (
|
||||
originalWD string
|
||||
)
|
||||
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get working dir: %v", err)
|
||||
}
|
||||
repoRoot := filepath.Clean(filepath.Join(wd, "..", "..", ".."))
|
||||
if err := os.Chdir(repoRoot); err != nil {
|
||||
t.Fatalf("failed to chdir to repo root: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(wd)
|
||||
BeforeEach(func() {
|
||||
Expect(os.Setenv("GO_ENV", "testdefaults")).To(Succeed())
|
||||
|
||||
var err error
|
||||
originalWD, err = os.Getwd()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
repoRoot := filepath.Clean(filepath.Join(originalWD, "..", "..", ".."))
|
||||
Expect(os.Chdir(repoRoot)).To(Succeed())
|
||||
})
|
||||
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
AfterEach(func() {
|
||||
Expect(os.Chdir(originalWD)).To(Succeed())
|
||||
})
|
||||
|
||||
want := 30 * time.Second
|
||||
if cfg.Timeouts.AckWait != want {
|
||||
t.Fatalf("expected timeouts.ack_wait to default to %v, got %v", want, cfg.Timeouts.AckWait)
|
||||
}
|
||||
if cfg.NATS.ConsumerRules.AckWait != want {
|
||||
t.Fatalf("expected consumer ack_wait to default to %v, got %v", want, cfg.NATS.ConsumerRules.AckWait)
|
||||
}
|
||||
}
|
||||
It("defaults ack waits when not provided", func() {
|
||||
cfg, err := LoadConfig()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
want := 30 * time.Second
|
||||
Expect(cfg.Timeouts.AckWait).To(Equal(want))
|
||||
Expect(cfg.NATS.ConsumerRules.AckWait).To(Equal(want))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestInfraConfig(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Infra Config Suite")
|
||||
}
|
||||
@@ -6,9 +6,14 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -21,6 +26,11 @@ type Consumer struct {
|
||||
logger *zap.Logger
|
||||
subject string
|
||||
consumerName string
|
||||
meter metric.Meter
|
||||
ackPending metric.Int64Histogram
|
||||
redelivered metric.Int64Histogram
|
||||
pending metric.Int64Histogram
|
||||
delivered metric.Int64Histogram
|
||||
}
|
||||
|
||||
// ProvideConsumer creates a NATS consumer
|
||||
@@ -47,6 +57,7 @@ func ProvideConsumer(
|
||||
subject: subject,
|
||||
consumerName: consumerName,
|
||||
}
|
||||
consumer.initMetrics()
|
||||
|
||||
// Create consumer if it doesn't exist
|
||||
if err := consumer.ensureConsumer(); err != nil {
|
||||
@@ -74,12 +85,28 @@ func (c *Consumer) ensureConsumer() error {
|
||||
|
||||
consumerConfig := &nats.ConsumerConfig{
|
||||
Durable: c.consumerName,
|
||||
DeliverPolicy: nats.DeliverAllPolicy,
|
||||
DeliverPolicy: mapDeliverPolicy(c.cfg.NATS.ConsumerRules.DeliverPolicy),
|
||||
AckPolicy: nats.AckExplicitPolicy,
|
||||
AckWait: ackWait,
|
||||
ReplayPolicy: mapReplayPolicy(c.cfg.NATS.ConsumerRules.ReplayPolicy),
|
||||
MaxDeliver: c.cfg.NATS.ConsumerRules.MaxDeliver,
|
||||
MaxAckPending: c.cfg.NATS.ConsumerRules.MaxAckPending,
|
||||
FilterSubject: c.subject,
|
||||
BackOff: c.cfg.NATS.ConsumerRules.Backoff,
|
||||
}
|
||||
if consumerConfig.DeliverPolicy == nats.DeliverByStartSequencePolicy && c.cfg.NATS.ConsumerRules.StartSequence > 0 {
|
||||
consumerConfig.OptStartSeq = c.cfg.NATS.ConsumerRules.StartSequence
|
||||
}
|
||||
if consumerConfig.DeliverPolicy == nats.DeliverByStartTimePolicy && strings.TrimSpace(c.cfg.NATS.ConsumerRules.StartTime) != "" {
|
||||
startTime, err := time.Parse(time.RFC3339, c.cfg.NATS.ConsumerRules.StartTime)
|
||||
if err != nil {
|
||||
c.logger.Warn("Invalid start time, falling back to deliver policy defaults",
|
||||
zap.String("start_time", c.cfg.NATS.ConsumerRules.StartTime),
|
||||
zap.Error(err),
|
||||
)
|
||||
} else {
|
||||
consumerConfig.OptStartTime = &startTime
|
||||
}
|
||||
}
|
||||
|
||||
_, err := c.js.AddConsumer(streamName, consumerConfig)
|
||||
@@ -93,6 +120,8 @@ func (c *Consumer) ensureConsumer() error {
|
||||
zap.String("stream", streamName),
|
||||
zap.String("subject", c.subject),
|
||||
zap.Duration("ack_wait", ackWait),
|
||||
zap.String("deliver_policy", c.cfg.NATS.ConsumerRules.DeliverPolicy),
|
||||
zap.String("replay_policy", c.cfg.NATS.ConsumerRules.ReplayPolicy),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -133,6 +162,9 @@ func (c *Consumer) Start(ctx context.Context) error {
|
||||
zap.Duration("batch_timeout", batchTimeout),
|
||||
zap.Int("max_deliver", c.cfg.NATS.ConsumerRules.MaxDeliver),
|
||||
zap.Duration("ack_wait", c.cfg.NATS.ConsumerRules.AckWait),
|
||||
zap.String("deliver_policy", c.cfg.NATS.ConsumerRules.DeliverPolicy),
|
||||
zap.String("replay_policy", c.cfg.NATS.ConsumerRules.ReplayPolicy),
|
||||
zap.Int("backoff_steps", len(c.cfg.NATS.ConsumerRules.Backoff)),
|
||||
)
|
||||
|
||||
statsCtx, statsCancel := context.WithCancel(ctx)
|
||||
@@ -177,8 +209,8 @@ func (c *Consumer) Start(ctx context.Context) error {
|
||||
continue
|
||||
}
|
||||
|
||||
// Transient error: request redelivery
|
||||
if nakErr := msg.Nak(); nakErr != nil {
|
||||
// Transient error: request redelivery with optional delay
|
||||
if nakErr := c.nakWithStrategy(msg); nakErr != nil {
|
||||
c.logger.Error("Failed to NAK message", zap.Error(nakErr))
|
||||
}
|
||||
continue
|
||||
@@ -221,6 +253,7 @@ func (c *Consumer) emitConsumerStats(ctx context.Context, streamName string) {
|
||||
zap.Uint64("delivered_consumer_seq", uint64(info.Delivered.Consumer)),
|
||||
zap.Uint64("delivered_stream_seq", uint64(info.Delivered.Stream)),
|
||||
)
|
||||
c.recordConsumerMetrics(ctx, info)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,10 +287,80 @@ func (c *Consumer) Shutdown(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Consumer) initMetrics() {
|
||||
meter := otel.Meter("caatsm/nats")
|
||||
c.meter = meter
|
||||
|
||||
if hist, err := meter.Int64Histogram("nats.consumer.ack_pending"); err == nil {
|
||||
c.ackPending = hist
|
||||
}
|
||||
if hist, err := meter.Int64Histogram("nats.consumer.redelivered"); err == nil {
|
||||
c.redelivered = hist
|
||||
}
|
||||
if hist, err := meter.Int64Histogram("nats.consumer.pending"); err == nil {
|
||||
c.pending = hist
|
||||
}
|
||||
if hist, err := meter.Int64Histogram("nats.consumer.delivered"); err == nil {
|
||||
c.delivered = hist
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Consumer) recordConsumerMetrics(ctx context.Context, info *nats.ConsumerInfo) {
|
||||
if info == nil {
|
||||
return
|
||||
}
|
||||
if c.ackPending != nil {
|
||||
c.ackPending.Record(ctx, int64(info.NumAckPending))
|
||||
}
|
||||
if c.redelivered != nil {
|
||||
c.redelivered.Record(ctx, int64(info.NumRedelivered))
|
||||
}
|
||||
if c.pending != nil {
|
||||
c.pending.Record(ctx, int64(info.NumPending))
|
||||
}
|
||||
if c.delivered != nil {
|
||||
c.delivered.Record(ctx, int64(info.Delivered.Stream))
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Consumer) nakWithStrategy(msg *nats.Msg) error {
|
||||
backoff := c.cfg.NATS.ConsumerRules.Backoff
|
||||
if len(backoff) == 0 {
|
||||
return msg.Nak()
|
||||
}
|
||||
|
||||
meta, err := msg.Metadata()
|
||||
if err != nil {
|
||||
c.logger.Warn("Failed to read metadata for backoff strategy", zap.Error(err))
|
||||
return msg.Nak()
|
||||
}
|
||||
|
||||
attempt := int(meta.NumDelivered)
|
||||
index := attempt - 1
|
||||
if index < 0 {
|
||||
index = 0
|
||||
}
|
||||
if index >= len(backoff) {
|
||||
index = len(backoff) - 1
|
||||
}
|
||||
delay := backoff[index]
|
||||
if delay <= 0 {
|
||||
return msg.Nak()
|
||||
}
|
||||
|
||||
return msg.NakWithDelay(delay)
|
||||
}
|
||||
|
||||
// processMessage processes a single message
|
||||
func (c *Consumer) processMessage(ctx context.Context, msg *nats.Msg) error {
|
||||
ctx, span := otel.Tracer("caatsm/nats").Start(ctx, "Consumer.processMessage")
|
||||
defer span.End()
|
||||
span.SetAttributes(attribute.String("nats.subject", msg.Subject))
|
||||
|
||||
msgID, source, err := c.resolveMsgID(msg)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return fmt.Errorf("unable to resolve message id: %w", err)
|
||||
}
|
||||
if source != "header" {
|
||||
@@ -276,9 +379,12 @@ func (c *Consumer) processMessage(ctx context.Context, msg *nats.Msg) error {
|
||||
|
||||
// Call processor
|
||||
if err := c.processor.Handle(ctx, msg.Data, msgID); err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return fmt.Errorf("processor error: %w", err)
|
||||
}
|
||||
|
||||
span.SetAttributes(attribute.String("telegram.msg_id", msgID))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -294,3 +400,29 @@ func (c *Consumer) resolveMsgID(msg *nats.Msg) (string, string, error) {
|
||||
|
||||
return fmt.Sprintf("js-%d", meta.Sequence.Stream), "metadata", nil
|
||||
}
|
||||
|
||||
func mapDeliverPolicy(value string) nats.DeliverPolicy {
|
||||
switch strings.ToLower(value) {
|
||||
case "new":
|
||||
return nats.DeliverNewPolicy
|
||||
case "last":
|
||||
return nats.DeliverLastPolicy
|
||||
case "last_per_subject":
|
||||
return nats.DeliverLastPerSubjectPolicy
|
||||
case "sequence":
|
||||
return nats.DeliverByStartSequencePolicy
|
||||
case "time":
|
||||
return nats.DeliverByStartTimePolicy
|
||||
default:
|
||||
return nats.DeliverAllPolicy
|
||||
}
|
||||
}
|
||||
|
||||
func mapReplayPolicy(value string) nats.ReplayPolicy {
|
||||
switch strings.ToLower(value) {
|
||||
case "original":
|
||||
return nats.ReplayOriginalPolicy
|
||||
default:
|
||||
return nats.ReplayInstantPolicy
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,16 @@ import (
|
||||
"caatsm/internal/adapter/mapper"
|
||||
"caatsm/internal/domain"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -30,8 +36,14 @@ func ProvideRepository(pool *pgxpool.Pool, logger *zap.Logger) (adapter.Reposito
|
||||
|
||||
// InsertOne inserts a single telegram message
|
||||
func (r *Repository) InsertOne(ctx context.Context, msg *domain.ParsedMessage) error {
|
||||
ctx, span := otel.Tracer("caatsm/postgres").Start(ctx, "Repository.InsertOne")
|
||||
defer span.End()
|
||||
span.SetAttributes(attribute.String("db.table", "aviation.telegrams"))
|
||||
|
||||
row, err := r.mapper.ToDBRow(msg)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return fmt.Errorf("failed to map message to DB row: %w", err)
|
||||
}
|
||||
|
||||
@@ -39,18 +51,21 @@ func (r *Repository) InsertOne(ctx context.Context, msg *domain.ParsedMessage) e
|
||||
INSERT INTO aviation.telegrams (
|
||||
uuid, message_id, date_time, priority_indicator, primary_address,
|
||||
secondary_addresses, originator, originator_date_time, category,
|
||||
content, body_data, received_at, parsed_at, dispatched_at, need_dispatch
|
||||
content, body_data, status, error_reason,
|
||||
received_at, parsed_at, dispatched_at, need_dispatch
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17
|
||||
)
|
||||
ON CONFLICT (uuid) DO NOTHING
|
||||
`
|
||||
|
||||
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[9], row[10], row[11], row[12], row[13], row[14],
|
||||
row[9], row[10], row[11], row[12], row[13], row[14], row[15], row[16],
|
||||
)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return fmt.Errorf("failed to insert message: %w", err)
|
||||
}
|
||||
|
||||
@@ -72,6 +87,10 @@ func (r *Repository) InsertOne(ctx context.Context, msg *domain.ParsedMessage) e
|
||||
|
||||
// InsertBatch inserts multiple telegram messages in a batch using CopyFrom
|
||||
func (r *Repository) InsertBatch(ctx context.Context, msgs []*domain.ParsedMessage) error {
|
||||
ctx, span := otel.Tracer("caatsm/postgres").Start(ctx, "Repository.InsertBatch")
|
||||
defer span.End()
|
||||
span.SetAttributes(attribute.String("db.table", "aviation.telegrams"))
|
||||
|
||||
if len(msgs) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -93,14 +112,18 @@ func (r *Repository) InsertBatch(ctx context.Context, msgs []*domain.ParsedMessa
|
||||
[]string{
|
||||
"uuid", "message_id", "date_time", "priority_indicator", "primary_address",
|
||||
"secondary_addresses", "originator", "originator_date_time", "category",
|
||||
"content", "body_data", "received_at", "parsed_at", "dispatched_at", "need_dispatch",
|
||||
"content", "body_data", "status", "error_reason",
|
||||
"received_at", "parsed_at", "dispatched_at", "need_dispatch",
|
||||
},
|
||||
pgx.CopyFromRows(rows),
|
||||
)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return fmt.Errorf("failed to batch insert messages: %w", err)
|
||||
}
|
||||
|
||||
span.SetAttributes(attribute.Int64("db.inserted", copyCount))
|
||||
r.logger.Info("Batch inserted messages",
|
||||
zap.Int("count", int(copyCount)),
|
||||
zap.Int("attempted", len(msgs)),
|
||||
@@ -108,3 +131,69 @@ func (r *Repository) InsertBatch(ctx context.Context, msgs []*domain.ParsedMessa
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// InsertRaw inserts a failed telegram into aviation.telegrams_raw for post-processing.
|
||||
func (r *Repository) InsertRaw(ctx context.Context, msg *domain.ParsedMessage) error {
|
||||
ctx, span := otel.Tracer("caatsm/postgres").Start(ctx, "Repository.InsertRaw")
|
||||
defer span.End()
|
||||
span.SetAttributes(attribute.String("db.table", "aviation.telegrams_raw"))
|
||||
|
||||
if msg == nil {
|
||||
err := fmt.Errorf("message is nil")
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return fmt.Errorf("message is nil")
|
||||
}
|
||||
if msg.Uuid == "" {
|
||||
msg.Uuid = uuid.NewString()
|
||||
}
|
||||
if msg.ReceivedAt.IsZero() {
|
||||
msg.ReceivedAt = time.Now()
|
||||
}
|
||||
|
||||
metadata := map[string]interface{}{
|
||||
"message_id": msg.MessageID,
|
||||
"category": msg.Category,
|
||||
"comments": msg.Comments,
|
||||
}
|
||||
metadataJSON, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal metadata: %w", err)
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO aviation.telegrams_raw (
|
||||
uuid, status, error_reason, content, received_at, metadata
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6
|
||||
)
|
||||
ON CONFLICT (uuid) DO UPDATE
|
||||
SET status = EXCLUDED.status,
|
||||
error_reason = EXCLUDED.error_reason,
|
||||
content = EXCLUDED.content,
|
||||
received_at = EXCLUDED.received_at,
|
||||
metadata = EXCLUDED.metadata
|
||||
`
|
||||
|
||||
_, err = r.pool.Exec(ctx, query,
|
||||
msg.Uuid,
|
||||
string(msg.Status),
|
||||
msg.ErrorReason,
|
||||
msg.Content,
|
||||
msg.ReceivedAt,
|
||||
metadataJSON,
|
||||
)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return fmt.Errorf("failed to insert raw telegram: %w", err)
|
||||
}
|
||||
|
||||
span.SetAttributes(attribute.String("telegram.uuid", msg.Uuid), attribute.String("telegram.status", string(msg.Status)))
|
||||
r.logger.Debug("Persisted raw telegram",
|
||||
zap.String("uuid", msg.Uuid),
|
||||
zap.String("status", string(msg.Status)),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package parsers
|
||||
import (
|
||||
"caatsm/internal/domain"
|
||||
"caatsm/pkg/utils"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -51,6 +52,10 @@ var (
|
||||
performancePattern,
|
||||
regPattern,
|
||||
reroutePattern}
|
||||
// ErrHeaderParse indicates an invalid header section.
|
||||
ErrHeaderParse = errors.New("invalid telegram header")
|
||||
// ErrBodyParse indicates a failure matching the telegram body.
|
||||
ErrBodyParse = errors.New("invalid telegram body")
|
||||
)
|
||||
|
||||
type BodyParser struct {
|
||||
@@ -194,27 +199,33 @@ func (parser *BodyParser) createBodyData(data map[string]string) (string, interf
|
||||
}
|
||||
}
|
||||
|
||||
func Parse(rawText string) *domain.ParsedMessage {
|
||||
func Parse(rawText string) (*domain.ParsedMessage, error) {
|
||||
message, err := ParseHeader(rawText)
|
||||
if err != nil {
|
||||
msg := domain.NewParsedMessage()
|
||||
msg.Content = rawText
|
||||
return msg
|
||||
msg.Comments = err.Error()
|
||||
msg.ErrorReason = err.Error()
|
||||
msg.Status = domain.MessageStatusHeaderError
|
||||
return msg, fmt.Errorf("%w: %w", ErrHeaderParse, err)
|
||||
}
|
||||
|
||||
bodyParser := NewBodyParser(message.Body)
|
||||
category, bodyData, err := bodyParser.Parse()
|
||||
category, bodyData, bodyErr := bodyParser.Parse()
|
||||
message.Category = category
|
||||
message.ParsedAt = time.Now()
|
||||
|
||||
if err != nil {
|
||||
message.Comments = err.Error()
|
||||
return &message
|
||||
if bodyErr != nil {
|
||||
message.Comments = bodyErr.Error()
|
||||
message.ErrorReason = bodyErr.Error()
|
||||
message.Status = domain.MessageStatusBodyError
|
||||
return &message, fmt.Errorf("%w: %w", ErrBodyParse, bodyErr)
|
||||
}
|
||||
message.Parsed = true
|
||||
message.Status = domain.MessageStatusParsed
|
||||
message.BodyData = bodyData
|
||||
message.Uuid = uuid.New().String()
|
||||
return &message
|
||||
return &message, nil
|
||||
}
|
||||
|
||||
func cleanMessage(text string) string {
|
||||
|
||||
@@ -282,7 +282,8 @@ FF ZBTJZPZX
|
||||
NNNN
|
||||
`
|
||||
It("should parse the whole message correctly", func() {
|
||||
parsedMessage := Parse(message)
|
||||
parsedMessage, err := Parse(message)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(parsedMessage).ToNot(BeNil())
|
||||
Expect(parsedMessage.Parsed).To(BeTrue())
|
||||
Expect(parsedMessage.MessageID).To(Equal("TMQ2526"))
|
||||
@@ -333,7 +334,8 @@ GG ZBTJZPZX
|
||||
NNNN
|
||||
`
|
||||
It("should parse the whole message correctly", func() {
|
||||
parsedMessage := Parse(message)
|
||||
parsedMessage, err := Parse(message)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(parsedMessage).ToNot(BeNil())
|
||||
Expect(parsedMessage.Parsed).To(BeTrue())
|
||||
Expect(parsedMessage.MessageID).To(Equal("TMQ2617"))
|
||||
|
||||
@@ -12,6 +12,8 @@ CREATE TABLE aviation.telegrams (
|
||||
category VARCHAR(255),
|
||||
content TEXT,
|
||||
body_data JSONB,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'parsed',
|
||||
error_reason TEXT,
|
||||
received_at TIMESTAMP NOT NULL,
|
||||
parsed_at TIMESTAMP,
|
||||
dispatched_at TIMESTAMP,
|
||||
@@ -24,3 +26,12 @@ CREATE INDEX idx_telegrams_date_time ON aviation.telegrams (date_time);
|
||||
CREATE INDEX idx_telegrams_priority_indicator ON aviation.telegrams (priority_indicator);
|
||||
CREATE INDEX idx_telegrams_primary_address ON aviation.telegrams (primary_address);
|
||||
CREATE INDEX idx_telegrams_received_at ON aviation.telegrams (received_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS aviation.telegrams_raw (
|
||||
uuid UUID PRIMARY KEY,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
error_reason TEXT,
|
||||
content TEXT NOT NULL,
|
||||
received_at TIMESTAMP NOT NULL,
|
||||
metadata JSONB
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user