807 lines
26 KiB
Go
807 lines
26 KiB
Go
package mailstore
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
pgtable "galaxy/mail/internal/adapters/postgres/jet/mail/table"
|
|
"galaxy/mail/internal/domain/attempt"
|
|
"galaxy/mail/internal/domain/common"
|
|
deliverydomain "galaxy/mail/internal/domain/delivery"
|
|
"galaxy/mail/internal/domain/idempotency"
|
|
|
|
pg "github.com/go-jet/jet/v2/postgres"
|
|
)
|
|
|
|
// queryable is satisfied by both *sql.DB and *sql.Tx so the row read/write
|
|
// helpers below run inside or outside an explicit transaction.
|
|
type queryable interface {
|
|
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
|
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
|
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
|
}
|
|
|
|
// recipientKind enumerates the supported delivery_recipients.kind values.
|
|
const (
|
|
recipientKindTo = "to"
|
|
recipientKindCc = "cc"
|
|
recipientKindBcc = "bcc"
|
|
recipientKindReplyTo = "reply_to"
|
|
)
|
|
|
|
// nextAttemptStatuses lists the delivery statuses for which next_attempt_at is
|
|
// kept populated. Other statuses store NULL so the partial scheduler index
|
|
// stays small.
|
|
var nextAttemptStatuses = map[deliverydomain.Status]struct{}{
|
|
deliverydomain.StatusQueued: {},
|
|
deliverydomain.StatusRendered: {},
|
|
}
|
|
|
|
// deliverySelectColumns is the canonical SELECT list for the deliveries
|
|
// table, matching scanDelivery's column order.
|
|
var deliverySelectColumns = pg.ColumnList{
|
|
pgtable.Deliveries.DeliveryID,
|
|
pgtable.Deliveries.ResendParentDeliveryID,
|
|
pgtable.Deliveries.Source,
|
|
pgtable.Deliveries.Status,
|
|
pgtable.Deliveries.PayloadMode,
|
|
pgtable.Deliveries.TemplateID,
|
|
pgtable.Deliveries.Locale,
|
|
pgtable.Deliveries.LocaleFallbackUsed,
|
|
pgtable.Deliveries.TemplateVariables,
|
|
pgtable.Deliveries.Attachments,
|
|
pgtable.Deliveries.Subject,
|
|
pgtable.Deliveries.TextBody,
|
|
pgtable.Deliveries.HTMLBody,
|
|
pgtable.Deliveries.IdempotencyKey,
|
|
pgtable.Deliveries.RequestFingerprint,
|
|
pgtable.Deliveries.IdempotencyExpiresAt,
|
|
pgtable.Deliveries.AttemptCount,
|
|
pgtable.Deliveries.LastAttemptStatus,
|
|
pgtable.Deliveries.ProviderSummary,
|
|
pgtable.Deliveries.NextAttemptAt,
|
|
pgtable.Deliveries.CreatedAt,
|
|
pgtable.Deliveries.UpdatedAt,
|
|
pgtable.Deliveries.SentAt,
|
|
pgtable.Deliveries.SuppressedAt,
|
|
pgtable.Deliveries.FailedAt,
|
|
pgtable.Deliveries.DeadLetteredAt,
|
|
}
|
|
|
|
// insertDelivery writes one delivery record together with its recipient rows.
|
|
// idem supplies the request_fingerprint and idempotency_expires_at fields; if
|
|
// zero-valued (resend), the helper stores an empty fingerprint and uses
|
|
// fallbackExpiresAt for the idempotency expiry. activeAttempt — when non-nil
|
|
// and the delivery is queued/rendered — drives the initial next_attempt_at.
|
|
func insertDelivery(ctx context.Context, q queryable, record deliverydomain.Delivery, idem idempotency.Record, fallbackExpiresAt time.Time, activeAttempt *attempt.Attempt) error {
|
|
templateVariables, err := marshalTemplateVariables(record.TemplateVariables)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
attachments, err := marshalAttachments(record.Attachments)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
requestFingerprint := idem.RequestFingerprint
|
|
idemExpires := idem.ExpiresAt
|
|
if idem.IdempotencyKey.IsZero() && idem.Source == "" {
|
|
requestFingerprint = ""
|
|
idemExpires = fallbackExpiresAt
|
|
}
|
|
|
|
stmt := pgtable.Deliveries.INSERT(
|
|
pgtable.Deliveries.DeliveryID,
|
|
pgtable.Deliveries.ResendParentDeliveryID,
|
|
pgtable.Deliveries.Source,
|
|
pgtable.Deliveries.Status,
|
|
pgtable.Deliveries.PayloadMode,
|
|
pgtable.Deliveries.TemplateID,
|
|
pgtable.Deliveries.Locale,
|
|
pgtable.Deliveries.LocaleFallbackUsed,
|
|
pgtable.Deliveries.TemplateVariables,
|
|
pgtable.Deliveries.Attachments,
|
|
pgtable.Deliveries.Subject,
|
|
pgtable.Deliveries.TextBody,
|
|
pgtable.Deliveries.HTMLBody,
|
|
pgtable.Deliveries.IdempotencyKey,
|
|
pgtable.Deliveries.RequestFingerprint,
|
|
pgtable.Deliveries.IdempotencyExpiresAt,
|
|
pgtable.Deliveries.AttemptCount,
|
|
pgtable.Deliveries.LastAttemptStatus,
|
|
pgtable.Deliveries.ProviderSummary,
|
|
pgtable.Deliveries.NextAttemptAt,
|
|
pgtable.Deliveries.CreatedAt,
|
|
pgtable.Deliveries.UpdatedAt,
|
|
pgtable.Deliveries.SentAt,
|
|
pgtable.Deliveries.SuppressedAt,
|
|
pgtable.Deliveries.FailedAt,
|
|
pgtable.Deliveries.DeadLetteredAt,
|
|
).VALUES(
|
|
record.DeliveryID.String(),
|
|
record.ResendParentDeliveryID.String(),
|
|
string(record.Source),
|
|
string(record.Status),
|
|
string(record.PayloadMode),
|
|
record.TemplateID.String(),
|
|
record.Locale.String(),
|
|
record.LocaleFallbackUsed,
|
|
templateVariables,
|
|
attachments,
|
|
record.Content.Subject,
|
|
record.Content.TextBody,
|
|
record.Content.HTMLBody,
|
|
record.IdempotencyKey.String(),
|
|
requestFingerprint,
|
|
idemExpires.UTC(),
|
|
record.AttemptCount,
|
|
string(record.LastAttemptStatus),
|
|
record.ProviderSummary,
|
|
nextAttemptValue(record, activeAttempt),
|
|
record.CreatedAt.UTC(),
|
|
record.UpdatedAt.UTC(),
|
|
nullableTime(record.SentAt),
|
|
nullableTime(record.SuppressedAt),
|
|
nullableTime(record.FailedAt),
|
|
nullableTime(record.DeadLetteredAt),
|
|
)
|
|
|
|
query, args := stmt.Sql()
|
|
if _, err := q.ExecContext(ctx, query, args...); err != nil {
|
|
return err
|
|
}
|
|
|
|
return insertRecipients(ctx, q, record.DeliveryID, record.Envelope)
|
|
}
|
|
|
|
// insertRecipients writes one row per envelope address, preserving the
|
|
// caller's slice ordering through the position column.
|
|
func insertRecipients(ctx context.Context, q queryable, deliveryID common.DeliveryID, envelope deliverydomain.Envelope) error {
|
|
groups := []struct {
|
|
kind string
|
|
emails []common.Email
|
|
}{
|
|
{recipientKindTo, envelope.To},
|
|
{recipientKindCc, envelope.Cc},
|
|
{recipientKindBcc, envelope.Bcc},
|
|
{recipientKindReplyTo, envelope.ReplyTo},
|
|
}
|
|
|
|
for _, group := range groups {
|
|
for index, email := range group.emails {
|
|
stmt := pgtable.DeliveryRecipients.INSERT(
|
|
pgtable.DeliveryRecipients.DeliveryID,
|
|
pgtable.DeliveryRecipients.Kind,
|
|
pgtable.DeliveryRecipients.Position,
|
|
pgtable.DeliveryRecipients.Email,
|
|
).VALUES(
|
|
deliveryID.String(),
|
|
group.kind,
|
|
index,
|
|
email.String(),
|
|
)
|
|
query, args := stmt.Sql()
|
|
if _, err := q.ExecContext(ctx, query, args...); err != nil {
|
|
return fmt.Errorf("insert delivery recipient (%s[%d]): %w", group.kind, index, err)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// updateDelivery writes mutated delivery columns. The set of columns covers
|
|
// every field that the domain model can change after acceptance: status,
|
|
// rendered content, attempt metadata, terminal timestamps, plus
|
|
// next_attempt_at. activeAttempt — when non-nil and the delivery is
|
|
// queued/rendered — drives the next_attempt_at column; otherwise NULL.
|
|
func updateDelivery(ctx context.Context, q queryable, record deliverydomain.Delivery, activeAttempt *attempt.Attempt) error {
|
|
templateVariables, err := marshalTemplateVariables(record.TemplateVariables)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
attachments, err := marshalAttachments(record.Attachments)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
stmt := pgtable.Deliveries.UPDATE(
|
|
pgtable.Deliveries.Status,
|
|
pgtable.Deliveries.TemplateVariables,
|
|
pgtable.Deliveries.Attachments,
|
|
pgtable.Deliveries.Subject,
|
|
pgtable.Deliveries.TextBody,
|
|
pgtable.Deliveries.HTMLBody,
|
|
pgtable.Deliveries.Locale,
|
|
pgtable.Deliveries.LocaleFallbackUsed,
|
|
pgtable.Deliveries.AttemptCount,
|
|
pgtable.Deliveries.LastAttemptStatus,
|
|
pgtable.Deliveries.ProviderSummary,
|
|
pgtable.Deliveries.NextAttemptAt,
|
|
pgtable.Deliveries.UpdatedAt,
|
|
pgtable.Deliveries.SentAt,
|
|
pgtable.Deliveries.SuppressedAt,
|
|
pgtable.Deliveries.FailedAt,
|
|
pgtable.Deliveries.DeadLetteredAt,
|
|
).SET(
|
|
string(record.Status),
|
|
templateVariables,
|
|
attachments,
|
|
record.Content.Subject,
|
|
record.Content.TextBody,
|
|
record.Content.HTMLBody,
|
|
record.Locale.String(),
|
|
record.LocaleFallbackUsed,
|
|
record.AttemptCount,
|
|
string(record.LastAttemptStatus),
|
|
record.ProviderSummary,
|
|
nextAttemptValue(record, activeAttempt),
|
|
record.UpdatedAt.UTC(),
|
|
nullableTime(record.SentAt),
|
|
nullableTime(record.SuppressedAt),
|
|
nullableTime(record.FailedAt),
|
|
nullableTime(record.DeadLetteredAt),
|
|
).WHERE(pgtable.Deliveries.DeliveryID.EQ(pg.String(record.DeliveryID.String())))
|
|
|
|
query, args := stmt.Sql()
|
|
result, err := q.ExecContext(ctx, query, args...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows, err := result.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rows == 0 {
|
|
return fmt.Errorf("update delivery %q: row not found", record.DeliveryID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// nextAttemptValue resolves the next_attempt_at column value: the active
|
|
// attempt's scheduled_for when the delivery is queued/rendered, otherwise
|
|
// NULL. Other statuses (sending/sent/suppressed/failed/dead_letter/accepted)
|
|
// store NULL so the partial scheduler index excludes the row.
|
|
func nextAttemptValue(record deliverydomain.Delivery, activeAttempt *attempt.Attempt) any {
|
|
if activeAttempt == nil {
|
|
return nil
|
|
}
|
|
if _, ok := nextAttemptStatuses[record.Status]; !ok {
|
|
return nil
|
|
}
|
|
if activeAttempt.Status != attempt.StatusScheduled {
|
|
return nil
|
|
}
|
|
return activeAttempt.ScheduledFor.UTC()
|
|
}
|
|
|
|
// insertAttempt writes one attempt row.
|
|
func insertAttempt(ctx context.Context, q queryable, record attempt.Attempt) error {
|
|
stmt := pgtable.Attempts.INSERT(
|
|
pgtable.Attempts.DeliveryID,
|
|
pgtable.Attempts.AttemptNo,
|
|
pgtable.Attempts.Status,
|
|
pgtable.Attempts.ScheduledFor,
|
|
pgtable.Attempts.StartedAt,
|
|
pgtable.Attempts.FinishedAt,
|
|
pgtable.Attempts.ProviderClassification,
|
|
pgtable.Attempts.ProviderSummary,
|
|
).VALUES(
|
|
record.DeliveryID.String(),
|
|
record.AttemptNo,
|
|
string(record.Status),
|
|
record.ScheduledFor.UTC(),
|
|
nullableTime(record.StartedAt),
|
|
nullableTime(record.FinishedAt),
|
|
record.ProviderClassification,
|
|
record.ProviderSummary,
|
|
)
|
|
|
|
query, args := stmt.Sql()
|
|
_, err := q.ExecContext(ctx, query, args...)
|
|
return err
|
|
}
|
|
|
|
// updateAttempt writes mutated attempt fields keyed by (delivery_id,
|
|
// attempt_no).
|
|
func updateAttempt(ctx context.Context, q queryable, record attempt.Attempt) error {
|
|
stmt := pgtable.Attempts.UPDATE(
|
|
pgtable.Attempts.Status,
|
|
pgtable.Attempts.ScheduledFor,
|
|
pgtable.Attempts.StartedAt,
|
|
pgtable.Attempts.FinishedAt,
|
|
pgtable.Attempts.ProviderClassification,
|
|
pgtable.Attempts.ProviderSummary,
|
|
).SET(
|
|
string(record.Status),
|
|
record.ScheduledFor.UTC(),
|
|
nullableTime(record.StartedAt),
|
|
nullableTime(record.FinishedAt),
|
|
record.ProviderClassification,
|
|
record.ProviderSummary,
|
|
).WHERE(pg.AND(
|
|
pgtable.Attempts.DeliveryID.EQ(pg.String(record.DeliveryID.String())),
|
|
pgtable.Attempts.AttemptNo.EQ(pg.Int(int64(record.AttemptNo))),
|
|
))
|
|
|
|
query, args := stmt.Sql()
|
|
result, err := q.ExecContext(ctx, query, args...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows, err := result.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rows == 0 {
|
|
return fmt.Errorf("update attempt %q/%d: row not found", record.DeliveryID, record.AttemptNo)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// insertDeadLetter writes the dead_letters row for a delivery that exhausted
|
|
// retries.
|
|
func insertDeadLetter(ctx context.Context, q queryable, entry deliverydomain.DeadLetterEntry) error {
|
|
stmt := pgtable.DeadLetters.INSERT(
|
|
pgtable.DeadLetters.DeliveryID,
|
|
pgtable.DeadLetters.FinalAttemptNo,
|
|
pgtable.DeadLetters.FailureClassification,
|
|
pgtable.DeadLetters.ProviderSummary,
|
|
pgtable.DeadLetters.RecoveryHint,
|
|
pgtable.DeadLetters.CreatedAt,
|
|
).VALUES(
|
|
entry.DeliveryID.String(),
|
|
entry.FinalAttemptNo,
|
|
entry.FailureClassification,
|
|
entry.ProviderSummary,
|
|
entry.RecoveryHint,
|
|
entry.CreatedAt.UTC(),
|
|
)
|
|
|
|
query, args := stmt.Sql()
|
|
_, err := q.ExecContext(ctx, query, args...)
|
|
return err
|
|
}
|
|
|
|
// scanDeliveryRow scans the columns produced by selectColumns into a
|
|
// deliverydomain.Delivery + the auxiliary idempotency fingerprint/expiry
|
|
// values. The auxiliary fields are returned alongside so callers can
|
|
// translate them into idempotency.Record where needed.
|
|
type deliveryAux struct {
|
|
RequestFingerprint string
|
|
IdempotencyExpiresAt time.Time
|
|
NextAttemptAt *time.Time
|
|
}
|
|
|
|
func scanDelivery(row interface {
|
|
Scan(dest ...any) error
|
|
}) (deliverydomain.Delivery, deliveryAux, error) {
|
|
var (
|
|
record deliverydomain.Delivery
|
|
resendParent string
|
|
source string
|
|
status string
|
|
payloadMode string
|
|
templateID string
|
|
locale string
|
|
templateVariables []byte
|
|
attachments []byte
|
|
idempotencyKey string
|
|
lastAttemptStatusStr string
|
|
nextAttemptAt *time.Time
|
|
sentAt *time.Time
|
|
suppressedAt *time.Time
|
|
failedAt *time.Time
|
|
deadLetteredAt *time.Time
|
|
idemExpiresAt time.Time
|
|
requestFingerprint string
|
|
)
|
|
|
|
if err := row.Scan(
|
|
(*string)(&record.DeliveryID),
|
|
&resendParent,
|
|
&source,
|
|
&status,
|
|
&payloadMode,
|
|
&templateID,
|
|
&locale,
|
|
&record.LocaleFallbackUsed,
|
|
&templateVariables,
|
|
&attachments,
|
|
&record.Content.Subject,
|
|
&record.Content.TextBody,
|
|
&record.Content.HTMLBody,
|
|
&idempotencyKey,
|
|
&requestFingerprint,
|
|
&idemExpiresAt,
|
|
&record.AttemptCount,
|
|
&lastAttemptStatusStr,
|
|
&record.ProviderSummary,
|
|
&nextAttemptAt,
|
|
&record.CreatedAt,
|
|
&record.UpdatedAt,
|
|
&sentAt,
|
|
&suppressedAt,
|
|
&failedAt,
|
|
&deadLetteredAt,
|
|
); err != nil {
|
|
return deliverydomain.Delivery{}, deliveryAux{}, err
|
|
}
|
|
|
|
record.ResendParentDeliveryID = common.DeliveryID(resendParent)
|
|
record.Source = deliverydomain.Source(source)
|
|
record.Status = deliverydomain.Status(status)
|
|
record.PayloadMode = deliverydomain.PayloadMode(payloadMode)
|
|
record.TemplateID = common.TemplateID(templateID)
|
|
record.Locale = common.Locale(locale)
|
|
record.IdempotencyKey = common.IdempotencyKey(idempotencyKey)
|
|
record.LastAttemptStatus = attempt.Status(lastAttemptStatusStr)
|
|
record.CreatedAt = record.CreatedAt.UTC()
|
|
record.UpdatedAt = record.UpdatedAt.UTC()
|
|
record.SentAt = timeFromNullable(sentAt)
|
|
record.SuppressedAt = timeFromNullable(suppressedAt)
|
|
record.FailedAt = timeFromNullable(failedAt)
|
|
record.DeadLetteredAt = timeFromNullable(deadLetteredAt)
|
|
|
|
if templateVariables != nil {
|
|
variables, err := unmarshalTemplateVariables(templateVariables)
|
|
if err != nil {
|
|
return deliverydomain.Delivery{}, deliveryAux{}, err
|
|
}
|
|
record.TemplateVariables = variables
|
|
}
|
|
atts, err := unmarshalAttachments(attachments)
|
|
if err != nil {
|
|
return deliverydomain.Delivery{}, deliveryAux{}, err
|
|
}
|
|
record.Attachments = atts
|
|
|
|
return record, deliveryAux{
|
|
RequestFingerprint: requestFingerprint,
|
|
IdempotencyExpiresAt: idemExpiresAt.UTC(),
|
|
NextAttemptAt: timeFromNullable(nextAttemptAt),
|
|
}, nil
|
|
}
|
|
|
|
// loadEnvelope materialises the four envelope groups for one delivery.
|
|
func loadEnvelope(ctx context.Context, q queryable, deliveryID common.DeliveryID) (deliverydomain.Envelope, error) {
|
|
stmt := pg.SELECT(
|
|
pgtable.DeliveryRecipients.Kind,
|
|
pgtable.DeliveryRecipients.Position,
|
|
pgtable.DeliveryRecipients.Email,
|
|
).FROM(pgtable.DeliveryRecipients).
|
|
WHERE(pgtable.DeliveryRecipients.DeliveryID.EQ(pg.String(deliveryID.String()))).
|
|
ORDER_BY(pgtable.DeliveryRecipients.Kind.ASC(), pgtable.DeliveryRecipients.Position.ASC())
|
|
|
|
query, args := stmt.Sql()
|
|
rows, err := q.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return deliverydomain.Envelope{}, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var envelope deliverydomain.Envelope
|
|
for rows.Next() {
|
|
var (
|
|
kind string
|
|
position int
|
|
email string
|
|
)
|
|
if err := rows.Scan(&kind, &position, &email); err != nil {
|
|
return deliverydomain.Envelope{}, err
|
|
}
|
|
switch kind {
|
|
case recipientKindTo:
|
|
envelope.To = append(envelope.To, common.Email(email))
|
|
case recipientKindCc:
|
|
envelope.Cc = append(envelope.Cc, common.Email(email))
|
|
case recipientKindBcc:
|
|
envelope.Bcc = append(envelope.Bcc, common.Email(email))
|
|
case recipientKindReplyTo:
|
|
envelope.ReplyTo = append(envelope.ReplyTo, common.Email(email))
|
|
default:
|
|
return deliverydomain.Envelope{}, fmt.Errorf("load envelope: unknown recipient kind %q", kind)
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return deliverydomain.Envelope{}, err
|
|
}
|
|
return envelope, nil
|
|
}
|
|
|
|
// loadDeliveryByID returns the delivery referenced by deliveryID along with
|
|
// its full envelope. Returns (Delivery{}, false, nil) when the row does not
|
|
// exist.
|
|
func loadDeliveryByID(ctx context.Context, q queryable, deliveryID common.DeliveryID) (deliverydomain.Delivery, bool, error) {
|
|
stmt := pg.SELECT(deliverySelectColumns).
|
|
FROM(pgtable.Deliveries).
|
|
WHERE(pgtable.Deliveries.DeliveryID.EQ(pg.String(deliveryID.String())))
|
|
|
|
query, args := stmt.Sql()
|
|
row := q.QueryRowContext(ctx, query, args...)
|
|
record, _, err := scanDelivery(row)
|
|
switch {
|
|
case errors.Is(err, sql.ErrNoRows):
|
|
return deliverydomain.Delivery{}, false, nil
|
|
case err != nil:
|
|
return deliverydomain.Delivery{}, false, err
|
|
}
|
|
envelope, err := loadEnvelope(ctx, q, deliveryID)
|
|
if err != nil {
|
|
return deliverydomain.Delivery{}, false, err
|
|
}
|
|
record.Envelope = envelope
|
|
return record, true, nil
|
|
}
|
|
|
|
// loadIdempotencyByScope returns the idempotency.Record for (source, key).
|
|
// Returns (Record{}, false, nil) when no delivery owns the scope.
|
|
func loadIdempotencyByScope(ctx context.Context, q queryable, source deliverydomain.Source, key common.IdempotencyKey) (idempotency.Record, bool, error) {
|
|
stmt := pg.SELECT(
|
|
pgtable.Deliveries.DeliveryID,
|
|
pgtable.Deliveries.RequestFingerprint,
|
|
pgtable.Deliveries.IdempotencyExpiresAt,
|
|
pgtable.Deliveries.CreatedAt,
|
|
).FROM(pgtable.Deliveries).
|
|
WHERE(pg.AND(
|
|
pgtable.Deliveries.Source.EQ(pg.String(string(source))),
|
|
pgtable.Deliveries.IdempotencyKey.EQ(pg.String(key.String())),
|
|
))
|
|
|
|
query, args := stmt.Sql()
|
|
row := q.QueryRowContext(ctx, query, args...)
|
|
|
|
var (
|
|
deliveryID string
|
|
requestFingerprint string
|
|
expiresAt time.Time
|
|
createdAt time.Time
|
|
)
|
|
if err := row.Scan(&deliveryID, &requestFingerprint, &expiresAt, &createdAt); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return idempotency.Record{}, false, nil
|
|
}
|
|
return idempotency.Record{}, false, err
|
|
}
|
|
if strings.TrimSpace(requestFingerprint) == "" {
|
|
// Resend / non-idempotent rows expose an empty fingerprint; the
|
|
// reservation is not idempotency-scoped and must not surface as a hit.
|
|
return idempotency.Record{}, false, nil
|
|
}
|
|
return idempotency.Record{
|
|
Source: source,
|
|
IdempotencyKey: key,
|
|
DeliveryID: common.DeliveryID(deliveryID),
|
|
RequestFingerprint: requestFingerprint,
|
|
CreatedAt: createdAt.UTC(),
|
|
ExpiresAt: expiresAt.UTC(),
|
|
}, true, nil
|
|
}
|
|
|
|
// loadAttempts returns the attempts of deliveryID in attempt_no ASC order.
|
|
// expectedCount lets the caller fail closed when the stored sequence has a
|
|
// gap.
|
|
func loadAttempts(ctx context.Context, q queryable, deliveryID common.DeliveryID, expectedCount int) ([]attempt.Attempt, error) {
|
|
stmt := pg.SELECT(
|
|
pgtable.Attempts.AttemptNo,
|
|
pgtable.Attempts.Status,
|
|
pgtable.Attempts.ScheduledFor,
|
|
pgtable.Attempts.StartedAt,
|
|
pgtable.Attempts.FinishedAt,
|
|
pgtable.Attempts.ProviderClassification,
|
|
pgtable.Attempts.ProviderSummary,
|
|
).FROM(pgtable.Attempts).
|
|
WHERE(pgtable.Attempts.DeliveryID.EQ(pg.String(deliveryID.String()))).
|
|
ORDER_BY(pgtable.Attempts.AttemptNo.ASC())
|
|
|
|
query, args := stmt.Sql()
|
|
rows, err := q.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := make([]attempt.Attempt, 0, expectedCount)
|
|
for rows.Next() {
|
|
var (
|
|
attemptNo int
|
|
status string
|
|
scheduledFor time.Time
|
|
startedAt *time.Time
|
|
finishedAt *time.Time
|
|
providerClassification string
|
|
providerSummary string
|
|
)
|
|
if err := rows.Scan(
|
|
&attemptNo, &status, &scheduledFor, &startedAt, &finishedAt,
|
|
&providerClassification, &providerSummary,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, attempt.Attempt{
|
|
DeliveryID: deliveryID,
|
|
AttemptNo: attemptNo,
|
|
Status: attempt.Status(status),
|
|
ScheduledFor: scheduledFor.UTC(),
|
|
StartedAt: timeFromNullable(startedAt),
|
|
FinishedAt: timeFromNullable(finishedAt),
|
|
ProviderClassification: providerClassification,
|
|
ProviderSummary: providerSummary,
|
|
})
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
if expectedCount >= 0 && len(out) != expectedCount {
|
|
return nil, fmt.Errorf("load attempts %q: expected %d, got %d", deliveryID, expectedCount, len(out))
|
|
}
|
|
for index, record := range out {
|
|
if record.AttemptNo != index+1 {
|
|
return nil, fmt.Errorf("load attempts %q: gap at attempt %d", deliveryID, index+1)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// loadDeadLetter returns the dead_letters row keyed by deliveryID.
|
|
func loadDeadLetter(ctx context.Context, q queryable, deliveryID common.DeliveryID) (deliverydomain.DeadLetterEntry, bool, error) {
|
|
stmt := pg.SELECT(
|
|
pgtable.DeadLetters.FinalAttemptNo,
|
|
pgtable.DeadLetters.FailureClassification,
|
|
pgtable.DeadLetters.ProviderSummary,
|
|
pgtable.DeadLetters.RecoveryHint,
|
|
pgtable.DeadLetters.CreatedAt,
|
|
).FROM(pgtable.DeadLetters).
|
|
WHERE(pgtable.DeadLetters.DeliveryID.EQ(pg.String(deliveryID.String())))
|
|
|
|
query, args := stmt.Sql()
|
|
row := q.QueryRowContext(ctx, query, args...)
|
|
var (
|
|
finalAttemptNo int
|
|
failureClassification string
|
|
providerSummary string
|
|
recoveryHint string
|
|
createdAt time.Time
|
|
)
|
|
if err := row.Scan(&finalAttemptNo, &failureClassification, &providerSummary, &recoveryHint, &createdAt); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return deliverydomain.DeadLetterEntry{}, false, nil
|
|
}
|
|
return deliverydomain.DeadLetterEntry{}, false, err
|
|
}
|
|
return deliverydomain.DeadLetterEntry{
|
|
DeliveryID: deliveryID,
|
|
FinalAttemptNo: finalAttemptNo,
|
|
FailureClassification: failureClassification,
|
|
ProviderSummary: providerSummary,
|
|
RecoveryHint: recoveryHint,
|
|
CreatedAt: createdAt.UTC(),
|
|
}, true, nil
|
|
}
|
|
|
|
// lockDelivery acquires a row-level lock on the deliveries row keyed by
|
|
// deliveryID for the lifetime of the surrounding transaction.
|
|
func lockDelivery(ctx context.Context, q queryable, deliveryID common.DeliveryID) error {
|
|
stmt := pg.SELECT(pgtable.Deliveries.DeliveryID).
|
|
FROM(pgtable.Deliveries).
|
|
WHERE(pgtable.Deliveries.DeliveryID.EQ(pg.String(deliveryID.String()))).
|
|
FOR(pg.UPDATE())
|
|
|
|
query, args := stmt.Sql()
|
|
row := q.QueryRowContext(ctx, query, args...)
|
|
var ignored string
|
|
if err := row.Scan(&ignored); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return fmt.Errorf("lock delivery %q: not found", deliveryID)
|
|
}
|
|
return fmt.Errorf("lock delivery %q: %w", deliveryID, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// loadActiveAttempt returns the attempt row identified by expectedAttemptNo.
|
|
// When expectedAttemptNo is zero, the helper falls back to the most-recent
|
|
// attempt (used by call sites that do not yet know the count).
|
|
func loadActiveAttempt(ctx context.Context, q queryable, deliveryID common.DeliveryID, expectedAttemptNo int) (attempt.Attempt, error) {
|
|
selectColumns := []pg.Projection{
|
|
pgtable.Attempts.AttemptNo,
|
|
pgtable.Attempts.Status,
|
|
pgtable.Attempts.ScheduledFor,
|
|
pgtable.Attempts.StartedAt,
|
|
pgtable.Attempts.FinishedAt,
|
|
pgtable.Attempts.ProviderClassification,
|
|
pgtable.Attempts.ProviderSummary,
|
|
}
|
|
|
|
var stmt pg.SelectStatement
|
|
if expectedAttemptNo > 0 {
|
|
stmt = pg.SELECT(selectColumns[0], selectColumns[1:]...).
|
|
FROM(pgtable.Attempts).
|
|
WHERE(pg.AND(
|
|
pgtable.Attempts.DeliveryID.EQ(pg.String(deliveryID.String())),
|
|
pgtable.Attempts.AttemptNo.EQ(pg.Int(int64(expectedAttemptNo))),
|
|
))
|
|
} else {
|
|
stmt = pg.SELECT(selectColumns[0], selectColumns[1:]...).
|
|
FROM(pgtable.Attempts).
|
|
WHERE(pgtable.Attempts.DeliveryID.EQ(pg.String(deliveryID.String()))).
|
|
ORDER_BY(pgtable.Attempts.AttemptNo.DESC()).
|
|
LIMIT(1)
|
|
}
|
|
|
|
query, args := stmt.Sql()
|
|
row := q.QueryRowContext(ctx, query, args...)
|
|
|
|
var (
|
|
attemptNo int
|
|
status string
|
|
scheduledFor time.Time
|
|
startedAt *time.Time
|
|
finishedAt *time.Time
|
|
providerClassification string
|
|
providerSummary string
|
|
)
|
|
if err := row.Scan(&attemptNo, &status, &scheduledFor, &startedAt, &finishedAt, &providerClassification, &providerSummary); err != nil {
|
|
return attempt.Attempt{}, err
|
|
}
|
|
return attempt.Attempt{
|
|
DeliveryID: deliveryID,
|
|
AttemptNo: attemptNo,
|
|
Status: attempt.Status(status),
|
|
ScheduledFor: scheduledFor.UTC(),
|
|
StartedAt: timeFromNullable(startedAt),
|
|
FinishedAt: timeFromNullable(finishedAt),
|
|
ProviderClassification: providerClassification,
|
|
ProviderSummary: providerSummary,
|
|
}, nil
|
|
}
|
|
|
|
// DeleteDeliveriesOlderThan removes deliveries whose created_at predates
|
|
// cutoff. Cascading FKs drop the related attempts/dead_letters/payloads/
|
|
// recipients automatically. The helper satisfies SQLRetentionStore.
|
|
func (store *Store) DeleteDeliveriesOlderThan(ctx context.Context, cutoff time.Time) (int64, error) {
|
|
if store == nil {
|
|
return 0, errors.New("delete deliveries: nil store")
|
|
}
|
|
operationCtx, cancel, err := store.operationContext(ctx, "delete deliveries")
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer cancel()
|
|
|
|
stmt := pgtable.Deliveries.DELETE().
|
|
WHERE(pgtable.Deliveries.CreatedAt.LT(pg.TimestampzT(cutoff.UTC())))
|
|
|
|
query, args := stmt.Sql()
|
|
result, err := store.db.ExecContext(operationCtx, query, args...)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("delete deliveries: %w", err)
|
|
}
|
|
rows, err := result.RowsAffected()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("delete deliveries: rows affected: %w", err)
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
// loadDeliveryPayload returns the payload bundle for deliveryID.
|
|
func loadDeliveryPayload(ctx context.Context, q queryable, deliveryID common.DeliveryID) ([]byte, bool, error) {
|
|
stmt := pg.SELECT(pgtable.DeliveryPayloads.Payload).
|
|
FROM(pgtable.DeliveryPayloads).
|
|
WHERE(pgtable.DeliveryPayloads.DeliveryID.EQ(pg.String(deliveryID.String())))
|
|
|
|
query, args := stmt.Sql()
|
|
row := q.QueryRowContext(ctx, query, args...)
|
|
var payload []byte
|
|
if err := row.Scan(&payload); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, false, nil
|
|
}
|
|
return nil, false, err
|
|
}
|
|
return payload, true, nil
|
|
}
|