feat: use postgres
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
// Package mailstore implements the PostgreSQL-backed source-of-truth
|
||||
// persistence used by Mail Service.
|
||||
//
|
||||
// The package owns the on-disk shape of the `mail` schema (defined in
|
||||
// `galaxy/mail/internal/adapters/postgres/migrations`) and translates the
|
||||
// schema-agnostic Store interfaces declared by each `internal/service/*` use
|
||||
// case into concrete `database/sql` operations driven by the pgx driver.
|
||||
// Atomic composite operations (acceptance, render, attempt commit, resend)
|
||||
// execute inside explicit `BEGIN … COMMIT` transactions; the attempt
|
||||
// scheduler's claim path uses `SELECT … FOR UPDATE SKIP LOCKED` to coordinate
|
||||
// across multiple worker processes.
|
||||
//
|
||||
// Stage 4 of `PG_PLAN.md` migrates Mail Service away from Redis-backed
|
||||
// durable state. The inbound `mail:delivery_commands` Redis Stream and its
|
||||
// consumer offset remain on Redis; the store is no longer aware of them.
|
||||
package mailstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config configures one PostgreSQL-backed mail store instance. The store does
|
||||
// not own the underlying *sql.DB lifecycle: the caller (typically the service
|
||||
// runtime) opens, instruments, migrates, and closes the pool. The store only
|
||||
// borrows the pool and bounds individual round trips with OperationTimeout.
|
||||
type Config struct {
|
||||
// DB stores the connection pool the store uses for every query.
|
||||
DB *sql.DB
|
||||
|
||||
// OperationTimeout bounds one round trip. The store creates a derived
|
||||
// context for each operation so callers cannot starve the pool with an
|
||||
// unbounded ctx. Multi-statement transactions inherit this bound for the
|
||||
// whole BEGIN … COMMIT span.
|
||||
OperationTimeout time.Duration
|
||||
}
|
||||
|
||||
// Store persists Mail Service durable state in PostgreSQL and exposes the
|
||||
// per-use-case Store interfaces required by acceptance, render, execution,
|
||||
// operator listing, and the attempt scheduler.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
operationTimeout time.Duration
|
||||
}
|
||||
|
||||
// New constructs one PostgreSQL-backed mail store from cfg.
|
||||
func New(cfg Config) (*Store, error) {
|
||||
if cfg.DB == nil {
|
||||
return nil, errors.New("new postgres mail store: db must not be nil")
|
||||
}
|
||||
if cfg.OperationTimeout <= 0 {
|
||||
return nil, errors.New("new postgres mail store: operation timeout must be positive")
|
||||
}
|
||||
return &Store{
|
||||
db: cfg.DB,
|
||||
operationTimeout: cfg.OperationTimeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close is a no-op for the PostgreSQL-backed store: the connection pool is
|
||||
// owned by the caller (the runtime) and closed once the runtime shuts down.
|
||||
// The accessor remains so the runtime wiring can treat the store like the
|
||||
// previous Redis-backed implementation.
|
||||
func (store *Store) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ping verifies that the configured PostgreSQL backend is reachable. It runs
|
||||
// `db.PingContext` under the configured operation timeout.
|
||||
func (store *Store) Ping(ctx context.Context) error {
|
||||
operationCtx, cancel, err := withTimeout(ctx, "ping postgres mail store", store.operationTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
if err := store.db.PingContext(operationCtx); err != nil {
|
||||
return fmt.Errorf("ping postgres mail store: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// withTx runs fn inside a BEGIN … COMMIT transaction bounded by the store's
|
||||
// operation timeout. It rolls back on any error or panic and returns whatever
|
||||
// fn returned. The transaction uses the default isolation level (`READ
|
||||
// COMMITTED`); per-row locking is achieved through `SELECT … FOR UPDATE`
|
||||
// issued inside fn.
|
||||
func (store *Store) withTx(ctx context.Context, operation string, fn func(ctx context.Context, tx *sql.Tx) error) error {
|
||||
operationCtx, cancel, err := withTimeout(ctx, operation, store.operationTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
tx, err := store.db.BeginTx(operationCtx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: begin: %w", operation, err)
|
||||
}
|
||||
|
||||
if err := fn(operationCtx, tx); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("%s: commit: %w", operation, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// operationContext bounds one read or write that does not need a transaction
|
||||
// envelope (single statement). It mirrors store.withTx for non-transactional
|
||||
// callers.
|
||||
func (store *Store) operationContext(ctx context.Context, operation string) (context.Context, context.CancelFunc, error) {
|
||||
return withTimeout(ctx, operation, store.operationTimeout)
|
||||
}
|
||||
Reference in New Issue
Block a user