Files
go-caatsm/internal/infra/postgres/repository.go
T

322 lines
9.1 KiB
Go

package postgres
import (
"caatsm/internal/adapter/dto"
"caatsm/internal/adapter/mapper"
obsmetrics "caatsm/internal/infra/metrics"
"caatsm/internal/port"
"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"
)
// Repository implements the port.Repository interface using PostgreSQL
type Repository struct {
pool *pgxpool.Pool
mapper *mapper.TelegramMapper
logger *zap.Logger
}
// ProvideRepository creates a PostgreSQL repository
func ProvideRepository(pool *pgxpool.Pool, logger *zap.Logger) (port.Repository, error) {
return &Repository{
pool: pool,
mapper: mapper.NewTelegramMapper(),
logger: logger,
}, nil
}
// InsertOne inserts a single telegram message
func (r *Repository) InsertOne(ctx context.Context, msg *dto.ParsedTelegram) error {
if msg == nil {
return fmt.Errorf("message cannot be nil")
}
ctx, span := otel.Tracer("caatsm/postgres").Start(ctx, "Repository.InsertOne")
defer span.End()
// Set semantic database attributes
span.SetAttributes(
attribute.String("db.system", "postgresql"),
attribute.String("db.operation", "insert"),
attribute.String("db.name", "aviation"),
attribute.String("db.table", "telegrams"),
attribute.String("caatsm.message.id", msg.MessageID),
attribute.String("caatsm.message.category", msg.Category),
)
// Optional idempotency check based on business message identity. If we have a
// non-empty message ID and date/time, we can cheaply skip duplicates here to
// avoid applying the same business event multiple times.
if msg.MessageID != "" && msg.DateTime != "" {
exists, err := r.messageExists(ctx, msg.MessageID, msg.DateTime)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
r.logger.Error("failed to check existing message",
zap.String("message_id", msg.MessageID),
zap.String("date_time", msg.DateTime),
zap.Error(err),
)
return fmt.Errorf("failed to check existing message: %w", err)
}
if exists {
r.logger.Info("Duplicate message detected by message_id/date_time; skipping insert",
zap.String("message_id", msg.MessageID),
zap.String("date_time", msg.DateTime),
)
return nil
}
}
row, err := r.mapper.ToDBRow(msg)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
r.logger.Error("failed to map message to DB row", zap.Error(err))
return fmt.Errorf("failed to map message to DB row: %w", err)
}
query := `
INSERT INTO aviation.telegrams (
uuid, message_id, date_time, priority_indicator, primary_address,
secondary_addresses, originator, originator_date_time, category,
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, $16, $17
)
ON CONFLICT (uuid, received_at) DO NOTHING
`
start := time.Now()
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[15], row[16],
)
elapsed := time.Since(start)
result := obsmetrics.DBResultOK
if err != nil {
result = obsmetrics.DBResultError
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
obsmetrics.RecordDBQuery("insert_one", result, elapsed)
r.logger.Error("failed to insert message",
zap.String("uuid", msg.Uuid),
zap.String("message_id", msg.MessageID),
zap.Duration("elapsed", elapsed),
zap.Error(err),
)
return fmt.Errorf("failed to insert message: %w", err)
}
obsmetrics.RecordDBQuery("insert_one", result, elapsed)
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",
zap.String("uuid", msg.Uuid),
zap.String("message_id", msg.MessageID),
)
return nil
}
// InsertBatch inserts multiple telegram messages in a batch using CopyFrom
func (r *Repository) InsertBatch(ctx context.Context, msgs []*dto.ParsedTelegram) 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
}
// Convert messages to rows
rows := make([][]interface{}, len(msgs))
for i, msg := range msgs {
row, err := r.mapper.ToDBRow(msg)
if err != nil {
r.logger.Error("failed to map message to DB row in batch",
zap.Int("index", i),
zap.Int("total", len(msgs)),
zap.String("message_id", msg.MessageID),
zap.Error(err),
)
return fmt.Errorf("failed to map message %d to DB row: %w", i, err)
}
rows[i] = row
}
// Use CopyFrom for efficient batch insert
start := time.Now()
copyCount, err := r.pool.CopyFrom(
ctx,
pgx.Identifier{"aviation", "telegrams"},
[]string{
"uuid", "message_id", "date_time", "priority_indicator", "primary_address",
"secondary_addresses", "originator", "originator_date_time", "category",
"content", "body_data", "status", "error_reason",
"received_at", "parsed_at", "dispatched_at", "need_dispatch",
},
pgx.CopyFromRows(rows),
)
elapsed := time.Since(start)
result := obsmetrics.DBResultOK
if err != nil {
result = obsmetrics.DBResultError
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
obsmetrics.RecordDBQuery("insert_batch", result, elapsed)
r.logger.Error("failed to batch insert messages",
zap.Int("attempted", len(msgs)),
zap.Duration("elapsed", elapsed),
zap.Error(err),
)
return fmt.Errorf("failed to batch insert messages: %w", err)
}
obsmetrics.RecordDBQuery("insert_batch", result, elapsed)
span.SetAttributes(attribute.Int64("db.inserted", copyCount))
r.logger.Info("Batch inserted messages",
zap.Int("count", int(copyCount)),
zap.Int("attempted", len(msgs)),
)
return nil
}
// InsertRaw inserts a failed telegram into aviation.telegrams_raw for post-processing.
func (r *Repository) InsertRaw(ctx context.Context, msg *dto.ParsedTelegram) 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())
r.logger.Error("message is nil in InsertRaw")
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 {
r.logger.Error("failed to marshal metadata",
zap.String("uuid", msg.Uuid),
zap.Error(err),
)
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, received_at) DO UPDATE
SET status = EXCLUDED.status,
error_reason = EXCLUDED.error_reason,
content = EXCLUDED.content,
metadata = EXCLUDED.metadata
`
start := time.Now()
_, err = r.pool.Exec(ctx, query,
msg.Uuid,
string(msg.Status),
msg.ErrorReason,
msg.Content,
msg.ReceivedAt,
metadataJSON,
)
elapsed := time.Since(start)
result := obsmetrics.DBResultOK
if err != nil {
result = obsmetrics.DBResultError
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
obsmetrics.RecordDBQuery("insert_raw", result, elapsed)
r.logger.Error("failed to insert raw telegram",
zap.String("uuid", msg.Uuid),
zap.String("status", string(msg.Status)),
zap.Duration("elapsed", elapsed),
zap.Error(err),
)
return fmt.Errorf("failed to insert raw telegram: %w", err)
}
obsmetrics.RecordDBQuery("insert_raw", result, elapsed)
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
}
// messageExists performs a lightweight existence check for a business message,
// using (message_id, date_time) as the idempotency key. This avoids requiring a
// unique constraint at the database level while still preventing duplicate effects.
func (r *Repository) messageExists(ctx context.Context, messageID, dateTime string) (bool, error) {
if messageID == "" || dateTime == "" {
return false, nil
}
const query = `
SELECT 1
FROM aviation.telegrams
WHERE message_id = $1 AND date_time = $2
LIMIT 1
`
var one int
if err := r.pool.QueryRow(ctx, query, messageID, dateTime).Scan(&one); err != nil {
if err == pgx.ErrNoRows {
return false, nil
}
r.logger.Error("failed to check message existence",
zap.String("message_id", messageID),
zap.String("date_time", dateTime),
zap.Error(err),
)
return false, err
}
return true, nil
}