feat: use postgres
This commit is contained in:
@@ -0,0 +1,806 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user