318 lines
9.1 KiB
Go
318 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 {
|
|
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 != nil && 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
|
|
}
|