feat: backend service
This commit is contained in:
@@ -0,0 +1,665 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/postgres/jet/backend/model"
|
||||
"galaxy/backend/internal/postgres/jet/backend/table"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/go-jet/jet/v2/qrm"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Store is the Postgres-backed query surface for the mail outbox
|
||||
// (`mail_deliveries`, `mail_recipients`, `mail_attempts`,
|
||||
// `mail_dead_letters`, `mail_payloads`). All queries are built through
|
||||
// go-jet against the generated table bindings under
|
||||
// `backend/internal/postgres/jet/backend/table`.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewStore constructs a Store wrapping db.
|
||||
func NewStore(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
// Delivery mirrors a row in `backend.mail_deliveries`. Tests and
|
||||
// admin endpoints work against this struct directly.
|
||||
type Delivery struct {
|
||||
DeliveryID uuid.UUID
|
||||
TemplateID string
|
||||
IdempotencyKey string
|
||||
Status string
|
||||
Attempts int32
|
||||
NextAttemptAt *time.Time
|
||||
PayloadID uuid.UUID
|
||||
LastError string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
SentAt *time.Time
|
||||
DeadLetteredAt *time.Time
|
||||
}
|
||||
|
||||
// Attempt mirrors a row in `backend.mail_attempts`.
|
||||
type Attempt struct {
|
||||
AttemptID uuid.UUID
|
||||
DeliveryID uuid.UUID
|
||||
AttemptNo int32
|
||||
StartedAt time.Time
|
||||
FinishedAt *time.Time
|
||||
Outcome string
|
||||
Error string
|
||||
}
|
||||
|
||||
// DeadLetter mirrors a row in `backend.mail_dead_letters`.
|
||||
type DeadLetter struct {
|
||||
DeadLetterID uuid.UUID
|
||||
DeliveryID uuid.UUID
|
||||
ArchivedAt time.Time
|
||||
Reason string
|
||||
}
|
||||
|
||||
// Payload mirrors a row in `backend.mail_payloads`. Body is the raw
|
||||
// rendered bytes; Subject is nullable in the schema and is therefore a
|
||||
// pointer here.
|
||||
type Payload struct {
|
||||
PayloadID uuid.UUID
|
||||
ContentType string
|
||||
Subject *string
|
||||
Body []byte
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Recipient mirrors a row in `backend.mail_recipients`.
|
||||
type Recipient struct {
|
||||
RecipientID uuid.UUID
|
||||
DeliveryID uuid.UUID
|
||||
Address string
|
||||
Kind string
|
||||
}
|
||||
|
||||
// EnqueueArgs aggregates the inputs to InsertEnqueue. Constructing the
|
||||
// struct by name keeps the call site readable when the Service grows
|
||||
// new optional fields (locale, headers, etc.).
|
||||
type EnqueueArgs struct {
|
||||
DeliveryID uuid.UUID
|
||||
TemplateID string
|
||||
IdempotencyKey string
|
||||
Recipients []string
|
||||
ContentType string
|
||||
Subject string
|
||||
Body []byte
|
||||
}
|
||||
|
||||
// deliveryColumns lists the projection used by every read of
|
||||
// `mail_deliveries`. The order matches model.MailDeliveries field
|
||||
// layout for direct QRM scanning.
|
||||
func deliveryColumns() postgres.ColumnList {
|
||||
d := table.MailDeliveries
|
||||
return postgres.ColumnList{
|
||||
d.DeliveryID, d.TemplateID, d.IdempotencyKey, d.Status,
|
||||
d.Attempts, d.NextAttemptAt, d.PayloadID, d.LastError,
|
||||
d.CreatedAt, d.UpdatedAt, d.SentAt, d.DeadLetteredAt,
|
||||
}
|
||||
}
|
||||
|
||||
// InsertEnqueue persists a fresh delivery row together with its payload
|
||||
// and recipients in a single transaction. The (template_id,
|
||||
// idempotency_key) UNIQUE constraint handles duplicate enqueue: when
|
||||
// the conflict triggers, the transaction rolls back the payload insert
|
||||
// (so we do not leak orphaned payloads) and reports `inserted=false`
|
||||
// to the caller.
|
||||
func (s *Store) InsertEnqueue(ctx context.Context, args EnqueueArgs) (bool, error) {
|
||||
var inserted bool
|
||||
err := withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
payloadID := uuid.New()
|
||||
payloadStmt := table.MailPayloads.INSERT(
|
||||
table.MailPayloads.PayloadID,
|
||||
table.MailPayloads.ContentType,
|
||||
table.MailPayloads.Subject,
|
||||
table.MailPayloads.Body,
|
||||
).VALUES(payloadID, args.ContentType, args.Subject, args.Body)
|
||||
if _, err := payloadStmt.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert payload: %w", err)
|
||||
}
|
||||
|
||||
deliveryStmt := table.MailDeliveries.INSERT(
|
||||
table.MailDeliveries.DeliveryID,
|
||||
table.MailDeliveries.TemplateID,
|
||||
table.MailDeliveries.IdempotencyKey,
|
||||
table.MailDeliveries.Status,
|
||||
table.MailDeliveries.NextAttemptAt,
|
||||
table.MailDeliveries.PayloadID,
|
||||
).VALUES(
|
||||
args.DeliveryID, args.TemplateID, args.IdempotencyKey, StatusPending,
|
||||
postgres.NOW(), payloadID,
|
||||
).
|
||||
ON_CONFLICT(table.MailDeliveries.TemplateID, table.MailDeliveries.IdempotencyKey).
|
||||
DO_NOTHING().
|
||||
RETURNING(table.MailDeliveries.DeliveryID)
|
||||
|
||||
var stored model.MailDeliveries
|
||||
if err := deliveryStmt.QueryContext(ctx, tx, &stored); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
// Idempotent re-enqueue. Roll back the transaction so the
|
||||
// orphan payload insert does not survive.
|
||||
return errIdempotentNoop
|
||||
}
|
||||
return fmt.Errorf("insert delivery: %w", err)
|
||||
}
|
||||
|
||||
for _, addr := range args.Recipients {
|
||||
recipientStmt := table.MailRecipients.INSERT(
|
||||
table.MailRecipients.RecipientID,
|
||||
table.MailRecipients.DeliveryID,
|
||||
table.MailRecipients.Address,
|
||||
table.MailRecipients.Kind,
|
||||
).VALUES(uuid.New(), args.DeliveryID, addr, RecipientKindTo)
|
||||
if _, err := recipientStmt.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert recipient %q: %w", addr, err)
|
||||
}
|
||||
}
|
||||
inserted = true
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, errIdempotentNoop) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return inserted, nil
|
||||
}
|
||||
|
||||
// errIdempotentNoop is an internal sentinel that tells withTx to roll
|
||||
// back the transaction without surfacing an error to the caller. It
|
||||
// must never escape this package — InsertEnqueue catches it on the
|
||||
// way out.
|
||||
var errIdempotentNoop = errors.New("mail store: idempotent noop")
|
||||
|
||||
// ClaimDue locks up to `limit` due rows with FOR UPDATE SKIP LOCKED
|
||||
// and returns them with their full payload and recipient set. The
|
||||
// supplied tx must be the worker's per-row transaction; the caller
|
||||
// completes the work and commits. exclude is the list of delivery_ids
|
||||
// already handled in the current tick — they are filtered out so a
|
||||
// row whose retry lands at next_attempt_at <= now() is not re-claimed
|
||||
// inside the same tick loop.
|
||||
func (s *Store) ClaimDue(ctx context.Context, tx *sql.Tx, limit int, exclude ...uuid.UUID) ([]ClaimedDelivery, error) {
|
||||
d := table.MailDeliveries
|
||||
condition := d.Status.IN(postgres.String(StatusPending), postgres.String(StatusRetrying)).
|
||||
AND(d.NextAttemptAt.IS_NULL().OR(d.NextAttemptAt.LT_EQ(postgres.NOW())))
|
||||
if len(exclude) > 0 {
|
||||
excludeExprs := make([]postgres.Expression, 0, len(exclude))
|
||||
for _, id := range exclude {
|
||||
excludeExprs = append(excludeExprs, postgres.UUID(id))
|
||||
}
|
||||
condition = condition.AND(d.DeliveryID.NOT_IN(excludeExprs...))
|
||||
}
|
||||
|
||||
stmt := postgres.SELECT(deliveryColumns()).
|
||||
FROM(d).
|
||||
WHERE(condition).
|
||||
ORDER_BY(postgres.COALESCE(d.NextAttemptAt, d.CreatedAt).ASC()).
|
||||
LIMIT(int64(limit)).
|
||||
FOR(postgres.UPDATE().SKIP_LOCKED())
|
||||
|
||||
var rows []model.MailDeliveries
|
||||
if err := stmt.QueryContext(ctx, tx, &rows); err != nil {
|
||||
return nil, fmt.Errorf("claim due: %w", err)
|
||||
}
|
||||
|
||||
claimed := make([]ClaimedDelivery, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
delivery := modelToDelivery(row)
|
||||
payload, err := s.loadPayloadTx(ctx, tx, delivery.PayloadID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recipients, err := s.listRecipientsTx(ctx, tx, delivery.DeliveryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claimed = append(claimed, ClaimedDelivery{
|
||||
Delivery: delivery,
|
||||
Payload: payload,
|
||||
Recipients: recipients,
|
||||
})
|
||||
}
|
||||
return claimed, nil
|
||||
}
|
||||
|
||||
// ClaimedDelivery bundles a locked delivery row with its payload and
|
||||
// recipients so the worker has everything it needs in one structure.
|
||||
type ClaimedDelivery struct {
|
||||
Delivery Delivery
|
||||
Payload Payload
|
||||
Recipients []Recipient
|
||||
}
|
||||
|
||||
// RecordAttempt inserts a row into `mail_attempts` for the given
|
||||
// delivery. attempt_no is derived from MAX(attempt_no) + 1 within the
|
||||
// transaction, which keeps the column monotonic across resend cycles
|
||||
// — the delivery's wire-visible `attempts` field counts only the
|
||||
// current cycle (and resets on resend), while `mail_attempts` stays
|
||||
// append-only forensic history.
|
||||
func (s *Store) RecordAttempt(ctx context.Context, tx *sql.Tx, deliveryID uuid.UUID, startedAt time.Time, finishedAt time.Time, outcome string, errMsg string) (int32, error) {
|
||||
a := table.MailAttempts
|
||||
|
||||
// Read the current max attempt_no for this delivery first; the
|
||||
// surrounding worker transaction guarantees no concurrent inserts on
|
||||
// the same delivery_id, so a simple read-then-write is sufficient
|
||||
// (and avoids the awkward correlated subquery inside INSERT...VALUES
|
||||
// that jet does not parenthesise).
|
||||
maxStmt := postgres.SELECT(postgres.MAXi(a.AttemptNo).AS("max")).
|
||||
FROM(a).
|
||||
WHERE(a.DeliveryID.EQ(postgres.UUID(deliveryID)))
|
||||
|
||||
var maxRow struct {
|
||||
Max *int32 `alias:"max"`
|
||||
}
|
||||
if err := maxStmt.QueryContext(ctx, tx, &maxRow); err != nil {
|
||||
return 0, fmt.Errorf("record attempt: read max attempt_no: %w", err)
|
||||
}
|
||||
nextNo := int32(1)
|
||||
if maxRow.Max != nil {
|
||||
nextNo = *maxRow.Max + 1
|
||||
}
|
||||
|
||||
insertStmt := a.INSERT(
|
||||
a.AttemptID, a.DeliveryID, a.AttemptNo,
|
||||
a.StartedAt, a.FinishedAt, a.Outcome, a.Error,
|
||||
).VALUES(
|
||||
uuid.New(), deliveryID, nextNo,
|
||||
startedAt, finishedAt, outcome, errMsg,
|
||||
).RETURNING(a.AttemptNo)
|
||||
|
||||
var inserted model.MailAttempts
|
||||
if err := insertStmt.QueryContext(ctx, tx, &inserted); err != nil {
|
||||
return 0, fmt.Errorf("record attempt: %w", err)
|
||||
}
|
||||
return inserted.AttemptNo, nil
|
||||
}
|
||||
|
||||
// MarkSent flips the delivery to status='sent' and stamps sent_at.
|
||||
func (s *Store) MarkSent(ctx context.Context, tx *sql.Tx, deliveryID uuid.UUID, at time.Time) error {
|
||||
d := table.MailDeliveries
|
||||
stmt := d.UPDATE().
|
||||
SET(
|
||||
d.Status.SET(postgres.String(StatusSent)),
|
||||
d.Attempts.SET(d.Attempts.ADD(postgres.Int(1))),
|
||||
d.SentAt.SET(postgres.TimestampzT(at)),
|
||||
d.UpdatedAt.SET(postgres.TimestampzT(at)),
|
||||
d.NextAttemptAt.SET(postgres.TimestampzExp(postgres.NULL)),
|
||||
d.LastError.SET(postgres.String("")),
|
||||
).
|
||||
WHERE(d.DeliveryID.EQ(postgres.UUID(deliveryID)))
|
||||
if _, err := stmt.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("mark sent: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ScheduleRetry flips the delivery to status='retrying', bumps
|
||||
// attempts, and arms next_attempt_at.
|
||||
func (s *Store) ScheduleRetry(ctx context.Context, tx *sql.Tx, deliveryID uuid.UUID, at time.Time, nextAt time.Time, errMsg string) error {
|
||||
d := table.MailDeliveries
|
||||
stmt := d.UPDATE().
|
||||
SET(
|
||||
d.Status.SET(postgres.String(StatusRetrying)),
|
||||
d.Attempts.SET(d.Attempts.ADD(postgres.Int(1))),
|
||||
d.NextAttemptAt.SET(postgres.TimestampzT(nextAt)),
|
||||
d.UpdatedAt.SET(postgres.TimestampzT(at)),
|
||||
d.LastError.SET(postgres.String(errMsg)),
|
||||
).
|
||||
WHERE(d.DeliveryID.EQ(postgres.UUID(deliveryID)))
|
||||
if _, err := stmt.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("schedule retry: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkDeadLettered moves the delivery to the terminal `dead_lettered`
|
||||
// state and inserts the matching row into `mail_dead_letters` under
|
||||
// the same transaction.
|
||||
func (s *Store) MarkDeadLettered(ctx context.Context, tx *sql.Tx, deliveryID uuid.UUID, at time.Time, reason string) error {
|
||||
d := table.MailDeliveries
|
||||
updateStmt := d.UPDATE().
|
||||
SET(
|
||||
d.Status.SET(postgres.String(StatusDeadLettered)),
|
||||
d.Attempts.SET(d.Attempts.ADD(postgres.Int(1))),
|
||||
d.DeadLetteredAt.SET(postgres.TimestampzT(at)),
|
||||
d.UpdatedAt.SET(postgres.TimestampzT(at)),
|
||||
d.NextAttemptAt.SET(postgres.TimestampzExp(postgres.NULL)),
|
||||
d.LastError.SET(postgres.String(reason)),
|
||||
).
|
||||
WHERE(d.DeliveryID.EQ(postgres.UUID(deliveryID)))
|
||||
if _, err := updateStmt.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("mark dead-lettered: %w", err)
|
||||
}
|
||||
|
||||
dl := table.MailDeadLetters
|
||||
insertStmt := dl.INSERT(
|
||||
dl.DeadLetterID, dl.DeliveryID, dl.ArchivedAt, dl.Reason,
|
||||
).VALUES(uuid.New(), deliveryID, at, reason)
|
||||
if _, err := insertStmt.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert dead-letter: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountByStatus returns a map keyed by the four status values so the
|
||||
// worker can publish `mail_outbox_depth{state}` without scanning the
|
||||
// whole table per metric tick.
|
||||
func (s *Store) CountByStatus(ctx context.Context) (map[string]int64, error) {
|
||||
d := table.MailDeliveries
|
||||
stmt := postgres.SELECT(
|
||||
d.Status,
|
||||
postgres.COUNT(postgres.STAR).AS("count"),
|
||||
).FROM(d).GROUP_BY(d.Status)
|
||||
|
||||
var rows []struct {
|
||||
MailDeliveries model.MailDeliveries
|
||||
Count int64 `alias:"count"`
|
||||
}
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return nil, fmt.Errorf("count by status: %w", err)
|
||||
}
|
||||
out := map[string]int64{
|
||||
StatusPending: 0,
|
||||
StatusRetrying: 0,
|
||||
StatusSent: 0,
|
||||
StatusDeadLettered: 0,
|
||||
}
|
||||
for _, row := range rows {
|
||||
out[row.MailDeliveries.Status] = row.Count
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetDelivery loads a single row by primary key. ErrDeliveryNotFound
|
||||
// is returned when no row matches.
|
||||
func (s *Store) GetDelivery(ctx context.Context, deliveryID uuid.UUID) (Delivery, error) {
|
||||
stmt := postgres.SELECT(deliveryColumns()).
|
||||
FROM(table.MailDeliveries).
|
||||
WHERE(table.MailDeliveries.DeliveryID.EQ(postgres.UUID(deliveryID))).
|
||||
LIMIT(1)
|
||||
|
||||
var row model.MailDeliveries
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return Delivery{}, ErrDeliveryNotFound
|
||||
}
|
||||
return Delivery{}, fmt.Errorf("get delivery: %w", err)
|
||||
}
|
||||
return modelToDelivery(row), nil
|
||||
}
|
||||
|
||||
// ListDeliveries returns the deliveries page in newest-first order
|
||||
// together with the total row count.
|
||||
func (s *Store) ListDeliveries(ctx context.Context, offset, limit int) ([]Delivery, int64, error) {
|
||||
total, err := countAll(ctx, s.db, table.MailDeliveries)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("count deliveries: %w", err)
|
||||
}
|
||||
d := table.MailDeliveries
|
||||
stmt := postgres.SELECT(deliveryColumns()).
|
||||
FROM(d).
|
||||
ORDER_BY(d.CreatedAt.DESC(), d.DeliveryID.DESC()).
|
||||
LIMIT(int64(limit)).OFFSET(int64(offset))
|
||||
|
||||
var rows []model.MailDeliveries
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return nil, 0, fmt.Errorf("list deliveries: %w", err)
|
||||
}
|
||||
out := make([]Delivery, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, modelToDelivery(row))
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
// ListAttempts returns every attempt for the given delivery, ordered
|
||||
// by attempt_no.
|
||||
func (s *Store) ListAttempts(ctx context.Context, deliveryID uuid.UUID) ([]Attempt, error) {
|
||||
a := table.MailAttempts
|
||||
stmt := postgres.SELECT(
|
||||
a.AttemptID, a.DeliveryID, a.AttemptNo,
|
||||
a.StartedAt, a.FinishedAt, a.Outcome, a.Error,
|
||||
).
|
||||
FROM(a).
|
||||
WHERE(a.DeliveryID.EQ(postgres.UUID(deliveryID))).
|
||||
ORDER_BY(a.AttemptNo.ASC())
|
||||
|
||||
var rows []model.MailAttempts
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return nil, fmt.Errorf("list attempts: %w", err)
|
||||
}
|
||||
out := make([]Attempt, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, modelToAttempt(row))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListDeadLetters returns the dead-letter page newest-first.
|
||||
func (s *Store) ListDeadLetters(ctx context.Context, offset, limit int) ([]DeadLetter, int64, error) {
|
||||
total, err := countAll(ctx, s.db, table.MailDeadLetters)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("count dead-letters: %w", err)
|
||||
}
|
||||
dl := table.MailDeadLetters
|
||||
stmt := postgres.SELECT(
|
||||
dl.DeadLetterID, dl.DeliveryID, dl.ArchivedAt, dl.Reason,
|
||||
).
|
||||
FROM(dl).
|
||||
ORDER_BY(dl.ArchivedAt.DESC(), dl.DeadLetterID.DESC()).
|
||||
LIMIT(int64(limit)).OFFSET(int64(offset))
|
||||
|
||||
var rows []model.MailDeadLetters
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return nil, 0, fmt.Errorf("list dead-letters: %w", err)
|
||||
}
|
||||
out := make([]DeadLetter, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, DeadLetter{
|
||||
DeadLetterID: row.DeadLetterID,
|
||||
DeliveryID: row.DeliveryID,
|
||||
ArchivedAt: row.ArchivedAt,
|
||||
Reason: row.Reason,
|
||||
})
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
// ResendNonSent re-arms the delivery for another attempt cycle. The
|
||||
// `status <> 'sent'` clause makes it the storage-level guard that
|
||||
// matches the contract: ErrResendOnSent is returned when the row is
|
||||
// already terminal-sent. ErrDeliveryNotFound surfaces when no row
|
||||
// matches.
|
||||
func (s *Store) ResendNonSent(ctx context.Context, deliveryID uuid.UUID, at time.Time) (Delivery, error) {
|
||||
var d Delivery
|
||||
err := withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
md := table.MailDeliveries
|
||||
lockStmt := postgres.SELECT(md.Status).
|
||||
FROM(md).
|
||||
WHERE(md.DeliveryID.EQ(postgres.UUID(deliveryID))).
|
||||
FOR(postgres.UPDATE())
|
||||
|
||||
var locked model.MailDeliveries
|
||||
if err := lockStmt.QueryContext(ctx, tx, &locked); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return ErrDeliveryNotFound
|
||||
}
|
||||
return fmt.Errorf("lock delivery: %w", err)
|
||||
}
|
||||
if locked.Status == StatusSent {
|
||||
return ErrResendOnSent
|
||||
}
|
||||
updateStmt := md.UPDATE().
|
||||
SET(
|
||||
md.Status.SET(postgres.String(StatusPending)),
|
||||
md.Attempts.SET(postgres.Int(0)),
|
||||
md.NextAttemptAt.SET(postgres.TimestampzT(at)),
|
||||
md.DeadLetteredAt.SET(postgres.TimestampzExp(postgres.NULL)),
|
||||
md.LastError.SET(postgres.String("")),
|
||||
md.UpdatedAt.SET(postgres.TimestampzT(at)),
|
||||
).
|
||||
WHERE(md.DeliveryID.EQ(postgres.UUID(deliveryID)))
|
||||
if _, err := updateStmt.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("re-arm delivery: %w", err)
|
||||
}
|
||||
reloadStmt := postgres.SELECT(deliveryColumns()).
|
||||
FROM(md).
|
||||
WHERE(md.DeliveryID.EQ(postgres.UUID(deliveryID))).
|
||||
LIMIT(1)
|
||||
var refreshed model.MailDeliveries
|
||||
if err := reloadStmt.QueryContext(ctx, tx, &refreshed); err != nil {
|
||||
return fmt.Errorf("reload delivery: %w", err)
|
||||
}
|
||||
d = modelToDelivery(refreshed)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Delivery{}, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (s *Store) loadPayloadTx(ctx context.Context, tx *sql.Tx, payloadID uuid.UUID) (Payload, error) {
|
||||
p := table.MailPayloads
|
||||
stmt := postgres.SELECT(
|
||||
p.PayloadID, p.ContentType, p.Subject, p.Body, p.CreatedAt,
|
||||
).FROM(p).
|
||||
WHERE(p.PayloadID.EQ(postgres.UUID(payloadID))).
|
||||
LIMIT(1)
|
||||
|
||||
var row model.MailPayloads
|
||||
if err := stmt.QueryContext(ctx, tx, &row); err != nil {
|
||||
return Payload{}, fmt.Errorf("load payload: %w", err)
|
||||
}
|
||||
return Payload{
|
||||
PayloadID: row.PayloadID,
|
||||
ContentType: row.ContentType,
|
||||
Subject: row.Subject,
|
||||
Body: row.Body,
|
||||
CreatedAt: row.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) listRecipientsTx(ctx context.Context, tx *sql.Tx, deliveryID uuid.UUID) ([]Recipient, error) {
|
||||
r := table.MailRecipients
|
||||
stmt := postgres.SELECT(
|
||||
r.RecipientID, r.DeliveryID, r.Address, r.Kind,
|
||||
).FROM(r).
|
||||
WHERE(r.DeliveryID.EQ(postgres.UUID(deliveryID))).
|
||||
ORDER_BY(r.RecipientID.ASC())
|
||||
|
||||
var rows []model.MailRecipients
|
||||
if err := stmt.QueryContext(ctx, tx, &rows); err != nil {
|
||||
return nil, fmt.Errorf("list recipients: %w", err)
|
||||
}
|
||||
out := make([]Recipient, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, Recipient{
|
||||
RecipientID: row.RecipientID,
|
||||
DeliveryID: row.DeliveryID,
|
||||
Address: row.Address,
|
||||
Kind: row.Kind,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// withTx wraps fn in a Postgres transaction. fn's return value
|
||||
// determines commit (nil) vs rollback (non-nil). Rollback errors are
|
||||
// swallowed when fn already returned an error, since the latter is
|
||||
// more actionable.
|
||||
func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mail store: begin tx: %w", err)
|
||||
}
|
||||
if err := fn(tx); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("mail store: commit tx: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeginTx exposes the package-level transaction helper to the worker
|
||||
// so it can scope ClaimDue + RecordAttempt + Mark* under a single
|
||||
// commit boundary.
|
||||
func (s *Store) BeginTx(ctx context.Context) (*sql.Tx, error) {
|
||||
return s.db.BeginTx(ctx, nil)
|
||||
}
|
||||
|
||||
// modelToDelivery projects a generated model row onto the public
|
||||
// Delivery struct. Pointer fields are copied so callers cannot mutate
|
||||
// the underlying scan buffer.
|
||||
func modelToDelivery(row model.MailDeliveries) Delivery {
|
||||
d := Delivery{
|
||||
DeliveryID: row.DeliveryID,
|
||||
TemplateID: row.TemplateID,
|
||||
IdempotencyKey: row.IdempotencyKey,
|
||||
Status: row.Status,
|
||||
Attempts: row.Attempts,
|
||||
PayloadID: row.PayloadID,
|
||||
LastError: row.LastError,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
if row.NextAttemptAt != nil {
|
||||
t := *row.NextAttemptAt
|
||||
d.NextAttemptAt = &t
|
||||
}
|
||||
if row.SentAt != nil {
|
||||
t := *row.SentAt
|
||||
d.SentAt = &t
|
||||
}
|
||||
if row.DeadLetteredAt != nil {
|
||||
t := *row.DeadLetteredAt
|
||||
d.DeadLetteredAt = &t
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// modelToAttempt projects a generated model row onto the public Attempt
|
||||
// struct.
|
||||
func modelToAttempt(row model.MailAttempts) Attempt {
|
||||
a := Attempt{
|
||||
AttemptID: row.AttemptID,
|
||||
DeliveryID: row.DeliveryID,
|
||||
AttemptNo: row.AttemptNo,
|
||||
StartedAt: row.StartedAt,
|
||||
Outcome: row.Outcome,
|
||||
Error: row.Error,
|
||||
}
|
||||
if row.FinishedAt != nil {
|
||||
t := *row.FinishedAt
|
||||
a.FinishedAt = &t
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// countAll runs `SELECT COUNT(*) FROM <tbl>` through jet and returns
|
||||
// the result as int64. The destination uses an alias-tagged scalar so
|
||||
// QRM can map the un-prefixed alias produced by AS("count").
|
||||
func countAll(ctx context.Context, db qrm.DB, tbl postgres.ReadableTable) (int64, error) {
|
||||
stmt := postgres.SELECT(postgres.COUNT(postgres.STAR).AS("count")).FROM(tbl)
|
||||
var dest struct {
|
||||
Count int64 `alias:"count"`
|
||||
}
|
||||
if err := stmt.QueryContext(ctx, db, &dest); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return dest.Count, nil
|
||||
}
|
||||
Reference in New Issue
Block a user