feat: use postgres

This commit is contained in:
Ilia Denisov
2026-04-26 20:34:39 +02:00
committed by GitHub
parent 48b0056b49
commit fe829285a6
365 changed files with 29223 additions and 24049 deletions
+196
View File
@@ -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
}
+198
View File
@@ -0,0 +1,198 @@
package postgres
import (
"strings"
"testing"
"time"
)
func TestDefaultConfigReturnsExpectedTuning(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
if cfg.OperationTimeout != DefaultOperationTimeout {
t.Fatalf("operation timeout = %v, want %v", cfg.OperationTimeout, DefaultOperationTimeout)
}
if cfg.MaxOpenConns != DefaultMaxOpenConns {
t.Fatalf("max open conns = %d, want %d", cfg.MaxOpenConns, DefaultMaxOpenConns)
}
if cfg.MaxIdleConns != DefaultMaxIdleConns {
t.Fatalf("max idle conns = %d, want %d", cfg.MaxIdleConns, DefaultMaxIdleConns)
}
if cfg.ConnMaxLifetime != DefaultConnMaxLifetime {
t.Fatalf("conn max lifetime = %v, want %v", cfg.ConnMaxLifetime, DefaultConnMaxLifetime)
}
}
func TestConfigValidateAcceptsHappyPath(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
cfg.PrimaryDSN = "postgres://localhost:5432/galaxy?sslmode=disable"
if err := cfg.Validate(); err != nil {
t.Fatalf("validate happy path: %v", err)
}
}
func TestConfigValidateRejectsInvalidValues(t *testing.T) {
t.Parallel()
tests := []struct {
name string
mutate func(*Config)
wantSub string
}{
{
name: "missing primary",
mutate: func(c *Config) {
c.PrimaryDSN = ""
},
wantSub: "primary DSN",
},
{
name: "blank replica entry",
mutate: func(c *Config) {
c.ReplicaDSNs = []string{"postgres://a", " "}
},
wantSub: "replica DSN",
},
{
name: "non-positive timeout",
mutate: func(c *Config) {
c.OperationTimeout = 0
},
wantSub: "operation timeout",
},
{
name: "non-positive max open",
mutate: func(c *Config) {
c.MaxOpenConns = 0
},
wantSub: "max open conns",
},
{
name: "negative max idle",
mutate: func(c *Config) {
c.MaxIdleConns = -1
},
wantSub: "max idle conns must not be negative",
},
{
name: "max idle exceeds open",
mutate: func(c *Config) {
c.MaxOpenConns = 4
c.MaxIdleConns = 5
},
wantSub: "must not exceed",
},
{
name: "non-positive lifetime",
mutate: func(c *Config) {
c.ConnMaxLifetime = 0
},
wantSub: "conn max lifetime",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
cfg.PrimaryDSN = "postgres://localhost"
tt.mutate(&cfg)
err := cfg.Validate()
if err == nil {
t.Fatalf("expected validate error, got nil")
}
if !strings.Contains(err.Error(), tt.wantSub) {
t.Fatalf("error %q does not contain %q", err, tt.wantSub)
}
})
}
}
func TestLoadFromEnvUsesDefaultsWhenOnlyPrimarySet(t *testing.T) {
const prefix = "TESTSVC"
t.Setenv(prefix+"_POSTGRES_PRIMARY_DSN", "postgres://example/galaxy?sslmode=disable")
cfg, err := LoadFromEnv(prefix)
if err != nil {
t.Fatalf("load from env: %v", err)
}
if cfg.PrimaryDSN != "postgres://example/galaxy?sslmode=disable" {
t.Fatalf("primary DSN = %q", cfg.PrimaryDSN)
}
if len(cfg.ReplicaDSNs) != 0 {
t.Fatalf("replica DSNs = %v, want empty", cfg.ReplicaDSNs)
}
if cfg.OperationTimeout != DefaultOperationTimeout {
t.Fatalf("operation timeout = %v", cfg.OperationTimeout)
}
if cfg.MaxOpenConns != DefaultMaxOpenConns {
t.Fatalf("max open conns = %d", cfg.MaxOpenConns)
}
}
func TestLoadFromEnvParsesAllOverrides(t *testing.T) {
const prefix = "TESTSVC"
t.Setenv(prefix+"_POSTGRES_PRIMARY_DSN", "postgres://example/galaxy?sslmode=disable")
t.Setenv(prefix+"_POSTGRES_REPLICA_DSNS", "postgres://r1, postgres://r2 ,")
t.Setenv(prefix+"_POSTGRES_OPERATION_TIMEOUT", "750ms")
t.Setenv(prefix+"_POSTGRES_MAX_OPEN_CONNS", "40")
t.Setenv(prefix+"_POSTGRES_MAX_IDLE_CONNS", "10")
t.Setenv(prefix+"_POSTGRES_CONN_MAX_LIFETIME", "15m")
cfg, err := LoadFromEnv(prefix)
if err != nil {
t.Fatalf("load from env: %v", err)
}
if got, want := cfg.OperationTimeout, 750*time.Millisecond; got != want {
t.Fatalf("operation timeout = %v, want %v", got, want)
}
if got, want := cfg.MaxOpenConns, 40; got != want {
t.Fatalf("max open conns = %d, want %d", got, want)
}
if got, want := cfg.MaxIdleConns, 10; got != want {
t.Fatalf("max idle conns = %d, want %d", got, want)
}
if got, want := cfg.ConnMaxLifetime, 15*time.Minute; got != want {
t.Fatalf("conn max lifetime = %v, want %v", got, want)
}
if got, want := len(cfg.ReplicaDSNs), 2; got != want {
t.Fatalf("replica DSN count = %d, want %d", got, want)
}
if cfg.ReplicaDSNs[0] != "postgres://r1" || cfg.ReplicaDSNs[1] != "postgres://r2" {
t.Fatalf("replica DSNs = %v", cfg.ReplicaDSNs)
}
}
func TestLoadFromEnvFailsWhenPrimaryMissing(t *testing.T) {
const prefix = "TESTSVC"
t.Setenv(prefix+"_POSTGRES_PRIMARY_DSN", "")
if _, err := LoadFromEnv(prefix); err == nil {
t.Fatal("expected error when primary DSN missing")
}
}
func TestLoadFromEnvRejectsEmptyPrefix(t *testing.T) {
t.Parallel()
if _, err := LoadFromEnv(" "); err == nil {
t.Fatal("expected error on empty prefix")
}
}
func TestLoadFromEnvSurfacesDurationParseErrors(t *testing.T) {
const prefix = "TESTSVC"
t.Setenv(prefix+"_POSTGRES_PRIMARY_DSN", "postgres://example/galaxy")
t.Setenv(prefix+"_POSTGRES_OPERATION_TIMEOUT", "not-a-duration")
if _, err := LoadFromEnv(prefix); err == nil {
t.Fatal("expected parse error")
} else if !strings.Contains(err.Error(), "OPERATION_TIMEOUT") {
t.Fatalf("error %q should name the env var", err)
}
}
+72
View File
@@ -0,0 +1,72 @@
module galaxy/postgres
go 1.26.1
require (
github.com/XSAM/otelsql v0.42.0
github.com/jackc/pgx/v5 v5.9.2
github.com/pressly/goose/v3 v3.27.1
github.com/testcontainers/testcontainers-go v0.42.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/metric v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/moby/api v1.54.2 // indirect
github.com/moby/moby/client v0.4.1 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+185
View File
@@ -0,0 +1,185 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/XSAM/otelsql v0.42.0 h1:Li0xF4eJUxG2e0x3D4rvRlys1f27yJKvjTh7ljkUP5o=
github.com/XSAM/otelsql v0.42.0/go.mod h1:4mOrEv+cS1KmKzrvTktvJnstr5GtKSAK+QHvFR9OcpI=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=
github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo=
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
+30
View File
@@ -0,0 +1,30 @@
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
// Ping bounds db.PingContext under timeout and returns a wrapped error so
// startup failures are easy to spot in service logs.
//
// timeout is typically taken from Config.OperationTimeout.
func Ping(ctx context.Context, db *sql.DB, timeout time.Duration) error {
if db == nil {
return errors.New("ping postgres: nil db")
}
if timeout <= 0 {
return errors.New("ping postgres: timeout must be positive")
}
pingCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
if err := db.PingContext(pingCtx); err != nil {
return fmt.Errorf("ping postgres: %w", err)
}
return nil
}
+53
View File
@@ -0,0 +1,53 @@
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"io/fs"
"strings"
"sync"
"github.com/pressly/goose/v3"
)
// gooseMu serialises access to goose's package-level filesystem state so
// concurrent calls to RunMigrations from independent services in the same
// process do not race on goose.SetBaseFS.
var gooseMu sync.Mutex
// RunMigrations applies every pending Up migration found under dir inside fsys
// against db. The PostgreSQL dialect is forced; goose's package-level base FS
// is restored to the OS filesystem on the way out so a second caller in the
// same process is safe.
//
// dir is the path within fsys (use "." when the migration files sit at the
// embed root). The function does not handle Down migrations or partial
// targets — services apply the full forward sequence at startup.
func RunMigrations(ctx context.Context, db *sql.DB, fsys fs.FS, dir string) error {
if db == nil {
return errors.New("run migrations: nil db")
}
if fsys == nil {
return errors.New("run migrations: nil fs")
}
if strings.TrimSpace(dir) == "" {
return errors.New("run migrations: dir must not be empty")
}
gooseMu.Lock()
defer gooseMu.Unlock()
goose.SetBaseFS(fsys)
defer goose.SetBaseFS(nil)
if err := goose.SetDialect("postgres"); err != nil {
return fmt.Errorf("run migrations: set dialect: %w", err)
}
if err := goose.UpContext(ctx, db, dir); err != nil {
return fmt.Errorf("run migrations: %w", err)
}
return nil
}
+136
View File
@@ -0,0 +1,136 @@
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/XSAM/otelsql"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/stdlib"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/trace"
)
// dbSystemAttribute identifies the wrapped backend in OpenTelemetry spans
// without locking the package to a specific semconv release.
var dbSystemAttribute = attribute.String("db.system", "postgresql")
// Option configures the OpenTelemetry providers attached to a connection by
// OpenPrimary, OpenReplicas, and InstrumentDBStats. Unset providers fall back
// to the OpenTelemetry global providers.
type Option func(*options)
type options struct {
tracerProvider trace.TracerProvider
meterProvider metric.MeterProvider
}
// WithTracerProvider sets the tracer provider used for SQL statement spans.
func WithTracerProvider(tp trace.TracerProvider) Option {
return func(o *options) {
o.tracerProvider = tp
}
}
// WithMeterProvider sets the meter provider used for connection-pool stats.
func WithMeterProvider(mp metric.MeterProvider) Option {
return func(o *options) {
o.meterProvider = mp
}
}
func evalOptions(opts []Option) options {
var resolved options
for _, opt := range opts {
if opt == nil {
continue
}
opt(&resolved)
}
return resolved
}
func (o options) otelsqlOpenOptions() []otelsql.Option {
out := []otelsql.Option{otelsql.WithAttributes(dbSystemAttribute)}
if o.tracerProvider != nil {
out = append(out, otelsql.WithTracerProvider(o.tracerProvider))
}
if o.meterProvider != nil {
out = append(out, otelsql.WithMeterProvider(o.meterProvider))
}
return out
}
// OpenPrimary opens the primary `*sql.DB` from cfg. ctx bounds individual
// pgx connect attempts via the parsed pgx config's ConnectTimeout (set to
// cfg.OperationTimeout). The returned pool has SetMaxOpenConns,
// SetMaxIdleConns and SetConnMaxLifetime applied.
func OpenPrimary(ctx context.Context, cfg Config, opts ...Option) (*sql.DB, error) {
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("open postgres primary: %w", err)
}
db, err := openDB(ctx, cfg, cfg.PrimaryDSN, evalOptions(opts))
if err != nil {
return nil, fmt.Errorf("open postgres primary: %w", err)
}
return db, nil
}
// OpenReplicas opens one `*sql.DB` per replica DSN. It returns nil when no
// replicas are configured. When opening a replica fails mid-way, every
// already-opened replica is closed before returning the error.
func OpenReplicas(ctx context.Context, cfg Config, opts ...Option) ([]*sql.DB, error) {
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("open postgres replicas: %w", err)
}
if len(cfg.ReplicaDSNs) == 0 {
return nil, nil
}
resolved := evalOptions(opts)
pools := make([]*sql.DB, 0, len(cfg.ReplicaDSNs))
for index, dsn := range cfg.ReplicaDSNs {
db, err := openDB(ctx, cfg, dsn, resolved)
if err != nil {
for _, opened := range pools {
_ = opened.Close()
}
return nil, fmt.Errorf("open postgres replica at index %d: %w", index, err)
}
pools = append(pools, db)
}
return pools, nil
}
func openDB(ctx context.Context, cfg Config, dsn string, opts options) (*sql.DB, error) {
if ctx.Err() != nil {
return nil, ctx.Err()
}
pgxCfg, err := pgx.ParseConfig(dsn)
if err != nil {
return nil, fmt.Errorf("parse dsn: %w", err)
}
pgxCfg.ConnectTimeout = cfg.OperationTimeout
registeredName := stdlib.RegisterConnConfig(pgxCfg)
db, err := otelsql.Open("pgx", registeredName, opts.otelsqlOpenOptions()...)
if err != nil {
stdlib.UnregisterConnConfig(registeredName)
return nil, fmt.Errorf("otelsql open: %w", err)
}
if db == nil {
stdlib.UnregisterConnConfig(registeredName)
return nil, errors.New("otelsql open returned nil db")
}
db.SetMaxOpenConns(cfg.MaxOpenConns)
db.SetMaxIdleConns(cfg.MaxIdleConns)
db.SetConnMaxLifetime(cfg.ConnMaxLifetime)
return db, nil
}
+38
View File
@@ -0,0 +1,38 @@
package postgres
import (
"database/sql"
"errors"
"fmt"
"github.com/XSAM/otelsql"
)
// Unregister releases an instrumentation registration. Returned by
// InstrumentDBStats so callers can detach metrics during shutdown.
type Unregister func() error
// InstrumentDBStats registers `database/sql` connection-pool metrics
// (`db.sql.connection.*`) for db. Statement spans are already attached at open
// time inside OpenPrimary/OpenReplicas — this function adds the meter-side
// instrumentation.
//
// The returned Unregister detaches the metric callbacks; callers usually
// invoke it during shutdown after closing db.
func InstrumentDBStats(db *sql.DB, opts ...Option) (Unregister, error) {
if db == nil {
return nil, errors.New("instrument postgres db stats: nil db")
}
resolved := evalOptions(opts)
otelOpts := []otelsql.Option{otelsql.WithAttributes(dbSystemAttribute)}
if resolved.meterProvider != nil {
otelOpts = append(otelOpts, otelsql.WithMeterProvider(resolved.meterProvider))
}
reg, err := otelsql.RegisterDBStatsMetrics(db, otelOpts...)
if err != nil {
return nil, fmt.Errorf("instrument postgres db stats: %w", err)
}
return reg.Unregister, nil
}
+115
View File
@@ -0,0 +1,115 @@
package postgres_test
import (
"context"
"embed"
"io/fs"
"testing"
"time"
"galaxy/postgres"
testcontainers "github.com/testcontainers/testcontainers-go"
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
)
const smokeImage = "postgres:16-alpine"
//go:embed testdata/migrations/*.sql
var smokeMigrationsFS embed.FS
func TestPostgresPackageRoundTrip(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
pgContainer, err := tcpostgres.Run(ctx,
smokeImage,
tcpostgres.WithDatabase("galaxy_smoke"),
tcpostgres.WithUsername("galaxy_smoke"),
tcpostgres.WithPassword("galaxy_smoke"),
// The Postgres image emits "ready to accept connections" twice during
// startup: once for the temporary bootstrap instance, once for the real
// listener on the mapped port. Waiting for the second occurrence
// prevents racing the bootstrap.
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(60*time.Second),
),
)
if err != nil {
t.Fatalf("start postgres container: %v", err)
}
t.Cleanup(func() {
if err := testcontainers.TerminateContainer(pgContainer); err != nil {
t.Errorf("terminate postgres container: %v", err)
}
})
dsn, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("postgres connection string: %v", err)
}
cfg := postgres.DefaultConfig()
cfg.PrimaryDSN = dsn
cfg.OperationTimeout = 5 * time.Second
db, err := postgres.OpenPrimary(ctx, cfg)
if err != nil {
t.Fatalf("open primary: %v", err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Errorf("close db: %v", err)
}
})
if err := postgres.Ping(ctx, db, cfg.OperationTimeout); err != nil {
t.Fatalf("ping: %v", err)
}
migrationsDir, err := fs.Sub(smokeMigrationsFS, "testdata/migrations")
if err != nil {
t.Fatalf("sub migrations FS: %v", err)
}
if err := postgres.RunMigrations(ctx, db, migrationsDir, "."); err != nil {
t.Fatalf("run migrations: %v", err)
}
var insertedID int64
if err := db.QueryRowContext(ctx,
"INSERT INTO smoke (note) VALUES ($1) RETURNING id", "hello",
).Scan(&insertedID); err != nil {
t.Fatalf("insert returning id: %v", err)
}
if insertedID <= 0 {
t.Fatalf("inserted id = %d, want > 0", insertedID)
}
var note string
if err := db.QueryRowContext(ctx,
"SELECT note FROM smoke WHERE id = $1", insertedID,
).Scan(&note); err != nil {
t.Fatalf("select note: %v", err)
}
if note != "hello" {
t.Fatalf("note = %q, want %q", note, "hello")
}
}
func TestOpenReplicasReturnsNilWhenUnconfigured(t *testing.T) {
t.Parallel()
cfg := postgres.DefaultConfig()
cfg.PrimaryDSN = "postgres://localhost:5432/galaxy?sslmode=disable"
dbs, err := postgres.OpenReplicas(context.Background(), cfg)
if err != nil {
t.Fatalf("open replicas: %v", err)
}
if dbs != nil {
t.Fatalf("replicas = %v, want nil", dbs)
}
}
+8
View File
@@ -0,0 +1,8 @@
-- +goose Up
CREATE TABLE smoke (
id BIGSERIAL PRIMARY KEY,
note TEXT NOT NULL
);
-- +goose Down
DROP TABLE smoke;