// 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. 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 // `_POSTGRES_`. The required variable is `_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 }