Files
galaxy-game/mail/internal/adapters/postgres/mailstore/deliveries.go
T
2026-04-26 20:34:39 +02:00

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
}