127 lines
4.7 KiB
Go
127 lines
4.7 KiB
Go
// Package notificationstore implements the PostgreSQL-backed source-of-truth
|
|
// persistence used by Notification Service.
|
|
//
|
|
// The package owns the on-disk shape of the `notification` schema (defined
|
|
// in `galaxy/notification/internal/adapters/postgres/migrations`) and
|
|
// translates the schema-agnostic Store interfaces declared by the
|
|
// `internal/service/acceptintent` use case and the route publishers into
|
|
// concrete `database/sql` operations driven by the pgx driver. Atomic
|
|
// composite operations (acceptance, route-completion transitions) execute
|
|
// inside explicit `BEGIN … COMMIT` transactions; per-row lifecycle
|
|
// transitions use optimistic concurrency on the `updated_at` token rather
|
|
// than retaining a `FOR UPDATE` lock across the publisher's outbound stream
|
|
// emission.
|
|
//
|
|
// Stage 5 of `PG_PLAN.md` migrates Notification Service away from
|
|
// Redis-backed durable state. The inbound `notification:intents` Redis
|
|
// Stream and its consumer offset, the outbound `gateway:client-events` and
|
|
// `mail:delivery_commands` Redis Streams, and the short-lived
|
|
// `route_leases:*` exclusivity hint all remain on Redis; this store is no
|
|
// longer aware of any of them.
|
|
package notificationstore
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// Config configures one PostgreSQL-backed notification 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 Notification Service durable state in PostgreSQL and
|
|
// exposes the per-use-case Store interfaces required by acceptance,
|
|
// publication completion, malformed-intent recording, and the periodic
|
|
// retention worker.
|
|
type Store struct {
|
|
db *sql.DB
|
|
operationTimeout time.Duration
|
|
}
|
|
|
|
// New constructs one PostgreSQL-backed notification store from cfg.
|
|
func New(cfg Config) (*Store, error) {
|
|
if cfg.DB == nil {
|
|
return nil, errors.New("new postgres notification store: db must not be nil")
|
|
}
|
|
if cfg.OperationTimeout <= 0 {
|
|
return nil, errors.New("new postgres notification 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 notification store", store.operationTimeout)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cancel()
|
|
|
|
if err := store.db.PingContext(operationCtx); err != nil {
|
|
return fmt.Errorf("ping postgres notification 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 contention is resolved through optimistic
|
|
// concurrency on `updated_at` rather than `SELECT … FOR UPDATE`.
|
|
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)
|
|
}
|