feat: use postgres
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
// Package postgres provides shared helpers for opening, instrumenting and
|
||||
// migrating PostgreSQL backends used by Galaxy services.
|
||||
//
|
||||
// The package codifies the steady-state rules captured in `ARCHITECTURE.md`
|
||||
// `§Persistence Backends`: services connect through `database/sql` driven by
|
||||
// the pgx driver, apply embedded goose migrations at startup, and expose
|
||||
// statement spans plus pool metrics via OpenTelemetry.
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Default configuration values applied by DefaultConfig and LoadFromEnv when
|
||||
// the corresponding environment variable is absent.
|
||||
const (
|
||||
DefaultOperationTimeout = 1 * time.Second
|
||||
DefaultMaxOpenConns = 25
|
||||
DefaultMaxIdleConns = 5
|
||||
DefaultConnMaxLifetime = 30 * time.Minute
|
||||
)
|
||||
|
||||
// Config stores the connection and pool tuning used to open a primary plus
|
||||
// zero-or-more replica `*sql.DB` instances. Stage 1 wires only the primary;
|
||||
// the replica list is preserved so future read-routing is a non-breaking
|
||||
// change.
|
||||
type Config struct {
|
||||
// PrimaryDSN stores the DSN used by the primary connection. Required.
|
||||
PrimaryDSN string
|
||||
|
||||
// ReplicaDSNs stores zero-or-more read-only replica DSNs.
|
||||
ReplicaDSNs []string
|
||||
|
||||
// OperationTimeout bounds startup operations such as Ping and individual
|
||||
// pgx connect attempts.
|
||||
OperationTimeout time.Duration
|
||||
|
||||
// MaxOpenConns caps the maximum number of open connections per pool.
|
||||
MaxOpenConns int
|
||||
|
||||
// MaxIdleConns caps the maximum number of idle connections per pool.
|
||||
MaxIdleConns int
|
||||
|
||||
// ConnMaxLifetime bounds the lifetime of an individual connection.
|
||||
ConnMaxLifetime time.Duration
|
||||
}
|
||||
|
||||
// DefaultConfig returns the default tuning. PrimaryDSN and ReplicaDSNs remain
|
||||
// zero-valued and must be supplied by callers (or by LoadFromEnv).
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
OperationTimeout: DefaultOperationTimeout,
|
||||
MaxOpenConns: DefaultMaxOpenConns,
|
||||
MaxIdleConns: DefaultMaxIdleConns,
|
||||
ConnMaxLifetime: DefaultConnMaxLifetime,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate reports whether cfg is usable. DSN strings are checked for
|
||||
// non-emptiness only; full pgx parsing happens at OpenPrimary/OpenReplicas
|
||||
// time so callers see a single failure point.
|
||||
func (cfg Config) Validate() error {
|
||||
if strings.TrimSpace(cfg.PrimaryDSN) == "" {
|
||||
return errors.New("postgres primary DSN must not be empty")
|
||||
}
|
||||
for index, dsn := range cfg.ReplicaDSNs {
|
||||
if strings.TrimSpace(dsn) == "" {
|
||||
return fmt.Errorf("postgres replica DSN at index %d must not be empty", index)
|
||||
}
|
||||
}
|
||||
if cfg.OperationTimeout <= 0 {
|
||||
return errors.New("postgres operation timeout must be positive")
|
||||
}
|
||||
if cfg.MaxOpenConns <= 0 {
|
||||
return errors.New("postgres max open conns must be positive")
|
||||
}
|
||||
if cfg.MaxIdleConns < 0 {
|
||||
return errors.New("postgres max idle conns must not be negative")
|
||||
}
|
||||
if cfg.MaxIdleConns > cfg.MaxOpenConns {
|
||||
return errors.New("postgres max idle conns must not exceed max open conns")
|
||||
}
|
||||
if cfg.ConnMaxLifetime <= 0 {
|
||||
return errors.New("postgres conn max lifetime must be positive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadFromEnv populates Config from environment variables prefixed with
|
||||
// `<prefix>_POSTGRES_`. The required variable is `<prefix>_POSTGRES_PRIMARY_DSN`;
|
||||
// every other variable falls back to DefaultConfig values.
|
||||
//
|
||||
// Example variable set for prefix "USERSERVICE":
|
||||
//
|
||||
// USERSERVICE_POSTGRES_PRIMARY_DSN=postgres://userservice:secret@host:5432/galaxy?search_path=user&sslmode=disable
|
||||
// USERSERVICE_POSTGRES_REPLICA_DSNS=postgres://...,postgres://...
|
||||
// USERSERVICE_POSTGRES_OPERATION_TIMEOUT=1s
|
||||
// USERSERVICE_POSTGRES_MAX_OPEN_CONNS=25
|
||||
// USERSERVICE_POSTGRES_MAX_IDLE_CONNS=5
|
||||
// USERSERVICE_POSTGRES_CONN_MAX_LIFETIME=30m
|
||||
func LoadFromEnv(prefix string) (Config, error) {
|
||||
if strings.TrimSpace(prefix) == "" {
|
||||
return Config{}, errors.New("postgres env prefix must not be empty")
|
||||
}
|
||||
|
||||
cfg := DefaultConfig()
|
||||
|
||||
primaryName := envName(prefix, "PRIMARY_DSN")
|
||||
primary, ok := os.LookupEnv(primaryName)
|
||||
if !ok || strings.TrimSpace(primary) == "" {
|
||||
return Config{}, fmt.Errorf("%s must be set", primaryName)
|
||||
}
|
||||
cfg.PrimaryDSN = strings.TrimSpace(primary)
|
||||
|
||||
if raw, ok := os.LookupEnv(envName(prefix, "REPLICA_DSNS")); ok {
|
||||
cfg.ReplicaDSNs = splitCSV(raw)
|
||||
}
|
||||
|
||||
timeout, err := loadDuration(envName(prefix, "OPERATION_TIMEOUT"), cfg.OperationTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.OperationTimeout = timeout
|
||||
|
||||
maxOpen, err := loadInt(envName(prefix, "MAX_OPEN_CONNS"), cfg.MaxOpenConns)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.MaxOpenConns = maxOpen
|
||||
|
||||
maxIdle, err := loadInt(envName(prefix, "MAX_IDLE_CONNS"), cfg.MaxIdleConns)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.MaxIdleConns = maxIdle
|
||||
|
||||
connLifetime, err := loadDuration(envName(prefix, "CONN_MAX_LIFETIME"), cfg.ConnMaxLifetime)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.ConnMaxLifetime = connLifetime
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func envName(prefix, suffix string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(prefix)) + "_POSTGRES_" + suffix
|
||||
}
|
||||
|
||||
func splitCSV(raw string) []string {
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func loadDuration(name string, fallback time.Duration) (time.Duration, error) {
|
||||
raw, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
return fallback, nil
|
||||
}
|
||||
parsed, err := time.ParseDuration(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func loadInt(name string, fallback int) (int, error) {
|
||||
raw, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
return fallback, nil
|
||||
}
|
||||
parsed, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
Reference in New Issue
Block a user