Help us shape the future of Go! Take the 2026 Go Developer Survey

postgres

package
v0.0.0-...-cc22c74 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 30, 2026 License: MIT Imports: 21 Imported by: 0

README

postgres

PostgreSQL driver for db, built on pgx v5 with pgxpool and OpenTelemetry tracing via otelpgx.

import (
	"github.com/shortlink-org/go-sdk/db"
	_ "github.com/shortlink-org/go-sdk/db/drivers/postgres"
)

store, err := db.New(ctx, log, tracer, metrics, cfg)
pool, err := db.Conn[*pgxpool.Pool](store)

STORE_TYPE=postgres selects the driver; STORE_POSTGRES_URI is the DSN.

Layout

postgres/            the driver: Store, Init, options, RouterFrom
  replica/           the router: routing decision, health gate, poller, watermarks
    wal/             WAL positions and cross-boundary tokens
    sqlclass/        statement classification
    metrics/         OpenTelemetry instruments
  middlewares/httpmw/  HTTP boundary middleware
  middlewares/grpcmw/  gRPC boundary interceptors
  ops/               Grafana dashboard, Prometheus Operator rules
  ADR/               architecture decision record

The dependency runs one way, postgres → replica → {wal, sqlclass, metrics}, and the three leaves depend on nothing of ours. metrics in particular takes strings and numbers rather than the router's own types, which is what keeps it from having to know about routing at all.

Inside the router, routing requests, replica selections, cluster lineage and transaction targets are value models with behaviour. Transport adapters map their (value, error) capture contract into explicit watermark states at the boundary. Target is immutable, replica selection is optional rather than a negative index, and token resolution is an enum rather than an LSN/bool tuple.

Both boundary middlewares live here, under middlewares/, because carrying this guarantee across a hop is the driver's concern whichever transport speaks it. The HTTP middleware uses the router directly; the gRPC middleware declares the small interface it needs and takes router.Port(). The separate Watermill module follows the latter shape so it does not pull the database module's heavy driver dependency graph into message transport code.

Rich-model API

The replica API intentionally uses domain values instead of flag tuples:

previous shape current shape
target.Pool, target.Replica == -1 target.Pool(), target.Replica() == nil
router.Accept(token) (lsn, bool) router.ResolveToken(token) TokenResolution
port.Watermark(ctx) (string, bool, error) port.Capture(ctx) (string, error)
WithReplicaFallback(bool) WithReplicaFallback(replica.FallbackPolicy)
WithSyncWatermark(bool) WithWatermarkPolicy(replica.WatermarkPolicy)
HTTP Header/Cookie flags Carriers: httpmw.CarrierPolicy

For Capture, "", nil means no write, while an error means a write happened but its position is unresolved. Boundary adapters convert the latter to the primary-only wire marker.

Architecture decisions

Read replicas

Set STORE_POSTGRES_REPLICA_URI and reads move to a standby, without giving up read-your-writes. Unset, nothing changes: no second pool, no poller, no behavioural difference.

router, err := postgres.RouterFrom(store)

// Reads route to a replica; writes go to the primary and pin the rest of
// this context there.
rows, err := router.Query(ctx, `SELECT ...`)

// Explicitly accept lag, or explicitly refuse it.
rows, err := router.Query(replica.Stale(ctx), `SELECT ...`)
rows, err := router.Query(replica.OnPrimary(ctx), `SELECT ...`)

See the ADR for the design, the failure modes, and what this deliberately does not cover.

Configuration
key default meaning
STORE_POSTGRES_URI postgres://postgres:shortlink@localhost:5432/shortlink?sslmode=disable primary DSN
STORE_POSTGRES_REPLICA_URI "" replica DSNs, comma separated. Empty disables routing
STORE_POSTGRES_REPLICA_POLL_INTERVAL 250ms how often replicas are probed; zero disables polling and keeps replicas ineligible
STORE_POSTGRES_REPLICA_POLL_JITTER 0.15 fraction of the interval, so a fleet does not probe in lockstep; values above 1 are clamped
STORE_POSTGRES_REPLICA_PROBE_TIMEOUT 500ms per-probe timeout
STORE_POSTGRES_REPLICA_SAMPLE_STALE_AFTER 2s age past which a sample is not trusted
STORE_POSTGRES_REPLICA_MAX_LAG_BYTES 8388608 staleness budget for reads with no watermark
STORE_POSTGRES_REPLICA_NO_TRACKER_POLICY primary where an unscoped read goes
STORE_POSTGRES_GATE_MAX_WAIT 250ms inline wait for a replica to catch up

One pool must mean one endpoint. A replica DSN pointing at a load balancer in front of several standbys breaks the guarantee: the probe describes one node and the read reaches another. Run one pool per standby.

With DDD and CQRS

The router implements the same surface as *pgxpool.Pool, so it stays in the infrastructure layer: one field in a repository adapter changes type and the domain never sees it. Under CQRS the tracker belongs on the bus, which already knows which side of the split it is on:

// command bus — the write taints the context by itself
ctx = replica.WithTracker(ctx)

// query bus — a read model tolerates lag by definition
ctx = replica.Stale(ctx)

Two rules do not follow from the code, and the ADR explains why both are load-bearing: Fit with DDD and CQRS, which also has the table of what belongs on which pool.

Pin the write side to the primary — aggregate rehydration looks exactly like an ordinary read, and the classifier cannot catch it:

func (r *AggregateRepo) Load(ctx context.Context, id uuid.UUID) (*Order, error) {
	return r.load(replica.OnPrimary(ctx), id)
}

Wire the unit of work, so a repository called inside a transaction runs on that transaction's connection:

store, err := db.New(ctx, log, tracer, metrics, cfg,
	postgres.With(postgres.WithTxLookup(uow.FromContext)),
)
Behind a load-balanced replica endpoint

A Kubernetes Service in front of several standbys — Crunchy PGO's <cluster>-replicas, or any DNS round-robin — breaks the token path and only the token path. kube-proxy balances per TCP connection, so the health probe and the read it authorises can land on different pods, and the gate then reports "caught up" about a node that did not serve the read.

What still holds, because it never consults the gate:

mechanism behind a multi-pod Service
taint — a read after a write in the same request works; goes to the primary
stale reads within a lag budget approximate; the sample may come from another pod
a token carried across a boundary not sound

So the working configuration is the carriers off, the scoping on:

r.Use(httpmw.New(httpmw.Config{Router: router, Carriers: httpmw.CarriersNone}))

That still scopes every request — without it, reads carry no tracker and the default policy sends all of them to the primary, which is the whole benefit gone. It just does not mint or trust tokens. The offload figures above were measured in exactly this mode: the load harness used the taint and nothing else.

To get the token path as well, one pool must mean one endpoint: either run a single replica, in which case the Service resolves to it and everything works, or give each standby its own DSN. Pod-level discovery that survives a rollout is not implemented.

Two more notes for Crunchy PGO specifically. Point the primary DSN at <cluster>-primary: it follows failover, Patroni bumps the timeline, and the poller notices and discards tokens from the previous one. And pg_control_system() needs a superuser, which the application role is not — so the system identifier stays unknown and lineage checks fall back to the timeline, which still catches every failover.

Behind a pooler

PgBouncer — including Crunchy PGO's spec.proxy.pgBouncer — does not parse SQL and does not balance, so it pools without competing with the Router. Two things to know. (A proxy that routes as well as pools, such as pgpool-II, is a second router for the same decision and is not supported; see the ADR.)

PGO's generated [databases] section is a single wildcard pointing at <cluster>-primary, so its PgBouncer fronts the primary only. The replica DSN does not go through it — either add a [databases] entry for the standby or point STORE_POSTGRES_REPLICA_URI at the standby directly, subject to the one-pool-one-endpoint rule above.

PGO sets no pool_mode, so PgBouncer's default of session applies and nothing here changes. Under transaction pooling it does: pgx's prepared statement cache needs PgBouncer 1.21+ with max_prepared_statements above zero, and the session-scoped statements the classifier routes to the primary (SET, PREPARE, DISCARD) stop being reliable — a property of transaction pooling, not of routing.

Transactions

Begin and BeginTx return an ordinary pgx.Tx and behave as you would expect: a read-write transaction goes to the primary and taints the context, a pgx.ReadOnly one may go to a replica.

InTx owns the whole lifecycle instead — rollback on error, rollback on panic, exactly one release — and earns its place under WatermarkOnCommit:

store, err := db.New(ctx, log, tracer, metrics, cfg,
	postgres.With(postgres.WithWatermarkPolicy(replica.WatermarkOnCommit)),
)
router, err := postgres.RouterFrom(store)

err = router.InTx(ctx, pgx.TxOptions{}, func(ctx context.Context, tx pgx.Tx) error {
	_, err := tx.Exec(ctx, `INSERT INTO orders (...) VALUES (...)`)

	return err
})

The WAL position has to be read after the commit — read inside the transaction it excludes the commit record, and the gate then opens exactly one WAL record too early. Through pgx.Tx that is necessarily a second round trip, because Commit sends COMMIT by itself. InTx pipelines the two:

commit; select pg_current_wal_insert_lsn()::text

The tx handed to fn is valid only for that call — do not retain it, and do not commit or roll it back yourself.

Carrying the guarantee across a boundary

A context.Context dies with the request, so POST /orders followed by GET /orders loses the guarantee exactly where a user notices. The boundary middlewares carry it as a token instead:

// HTTP: stamps X-Read-LSN on mutating responses, gates the requests that follow
r.Use(httpmw.New(httpmw.Config{Router: router}))

// Queue: stamps the token into message metadata, and the consumer waits for it
client.Publisher = watermill.NewConsistencyPublisher(client.Publisher, router.Port(), log, meter)
client.Router.AddMiddleware(watermill.NewConsistencyMiddleware(router.Port(), watermill.ConsistencyOptions{}, meter))

// gRPC: both directions — the caller's watermark rides out in metadata, the
// callee's comes back in the trailer
grpc.WithChainUnaryInterceptor(grpcmw.UnaryClientInterceptor(router.Port()))
grpc.ChainUnaryInterceptor(grpcmw.UnaryServerInterceptor(router.Port()))

All three are optional. Without them the routing still works — reads that never write still reach a replica — but a read following a write stays on the primary.

They share one adapter, router.Port(), and none of them imports replica. Each declares the small interface it needs — a method or two taking strings — and Go's structural typing does the rest, so a transport never reaches into the router's types and the watermill module never imports db at all.

What it buys

Not latency — a replica read is not faster, and crosses one more hop. What it buys is capacity on the primary: at a fixed offered load and a 90% read mix the primary served 75.7% fewer transactions, with read latency unchanged. The measurements, the arithmetic ceiling on that number, and what it does not claim are in the ADR.

Whether there is anything to move is a question about your primary, not about this driver:

SELECT calls, total_exec_time, query
FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 20;

If SELECTs without FOR UPDATE sit at the top, there is. At 20% CPU on the primary you are buying headroom for a spike, not performance today.

Once it is running, the share that actually left the primary is a metric:

sum(rate(postgres_route_decisions_total{target="replica"}[5m]))
  / sum(rate(postgres_route_decisions_total[5m]))

and postgres_route_fallbacks_total{reason="no_tracker"} climbing means the feature is enabled but not wired — reads are not being scoped, so they default to the primary.

There is a dashboard in ops/grafana/ and Prometheus Operator rules in ops/prometheus/.

The routing decision itself costs 14–31 ns with no allocation, against a 255 µs round trip — the full benchmark table is in the ADR. To rerun it:

go test -tags unit -run XXX -bench . ./drivers/postgres/

Migrations

import "github.com/shortlink-org/go-sdk/db/drivers/postgres/migrate"

err := migrate.Migration(ctx, store, migrationsFS, "my-service")

Each service gets its own schema_migrations_<name> table, so several services can share a database without colliding.

Tests

go test -mod=readonly -race -tags unit ./drivers/postgres/...

Integration against a real server, and against a real streaming replica:

go test -mod=readonly -tags "database postgres" ./drivers/postgres/...
go test -mod=readonly -tags "database postgres replica" ./drivers/postgres/...

The replica suite covers routing against real streaming replication, the degradation paths (standby stopped, standby promoted, a standby delayed by recovery_min_apply_delay and refused at startup), the batched commit, and the load comparison above. It builds a primary and a pg_basebackup standby on a private network, so it needs Docker and takes about a minute.

Documentation

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrInvalidDSN indicates a connection string PostgreSQL cannot parse.
	ErrInvalidDSN = errors.New("invalid PostgreSQL DSN")
	// ErrClientConnection indicates a failure to reach the PostgreSQL server.
	ErrClientConnection = errors.New("failed to connect to PostgreSQL server")
	// ErrInvalidDatabase indicates the named database does not exist.
	ErrInvalidDatabase = errors.New("invalid database name")
	// ErrInvalidCredentials indicates the server rejected the credentials.
	ErrInvalidCredentials = errors.New("invalid PostgreSQL credentials")
	// ErrInvalidSchema indicates the named schema does not exist.
	ErrInvalidSchema = errors.New("invalid schema name")
	// ErrNotPostgresStore indicates the store is backed by another driver.
	ErrNotPostgresStore = errors.New("store is not backed by the postgres driver")
)

Kinds of failure, for callers that want to branch rather than log.

Each is reachable with errors.Is on whatever Init returns: the driver classifies the underlying pgx error and wraps the matching sentinel alongside the cause, so both are found.

err := store.Init(ctx)
switch {
case errors.Is(err, postgres.ErrInvalidCredentials):
	// the password is wrong, and retrying will not help
case errors.Is(err, postgres.ErrClientConnection):
	// the server is unreachable, and retrying might
}

Functions

func RouterFrom

func RouterFrom(store db.DB) (*replica.Router, error)

RouterFrom returns the read-replica router of a postgres store.

router, err := postgres.RouterFrom(store)

It reports ErrNotPostgresStore when another driver is in use, and replica.ErrRouterDisabled when no replicas were configured.

It deliberately does not return a nil router that quietly forwards everything to the primary. That shape is convenient right up to the day somebody spends an afternoon asking why the replica is idle.

func With

func With(opts ...Option) db.Option

With addresses this driver's options to db.New, so that client code passes them without naming the driver or giving up type safety:

store, err := db.New(ctx, log, tracer, metrics, cfg,
	postgres.With(postgres.WithAfterConnect(fn)),
)

The options apply only when STORE_TYPE selects this driver.

Types

type AfterConnectFunc

type AfterConnectFunc func(ctx context.Context, conn *pgx.Conn) error

AfterConnectFunc is a callback executed after each new connection is established.

type Config

type Config struct {
	// contains filtered or unexported fields
}

Config - config

type Option

type Option func(*Store)

Option is a functional option for Store configuration.

Options are applied in Init, after the defaults and the configuration keys, so an option always wins over an environment variable.

func WithAfterConnect

func WithAfterConnect(fn AfterConnectFunc) Option

WithAfterConnect sets a callback to be executed after each new connection. Useful for registering custom types (e.g., pgx-shopspring-decimal).

func WithClassifier

func WithClassifier(c sqlclass.Classifier) Option

WithClassifier replaces the built-in statement classifier.

func WithGateMaxWait

func WithGateMaxWait(d time.Duration) Option

WithGateMaxWait bounds how long a caller waits inline for a replica to replay a required position. Zero disables waiting.

func WithLogger

func WithLogger(log *slog.Logger) Option

WithLogger gives the driver a logger. Without one, the replica poller stays silent about promotions and timeline switches, which are exactly the events you want to hear about.

The db.New constructor wires this from its own deps; set it only when building the store directly.

func WithMaxReplicaLagBytes

func WithMaxReplicaLagBytes(n int64) Option

WithMaxReplicaLagBytes is the staleness budget for reads that carry no watermark. Zero means unlimited.

It does not apply when a concrete watermark is being satisfied: comparing WAL positions is strictly stronger than comparing lag.

func WithNoTrackerPolicy

func WithNoTrackerPolicy(p replica.NoTrackerPolicy) Option

WithNoTrackerPolicy decides where a read goes when its context carries neither a tracker nor an explicit strategy. Default replica.NoTrackerPrimary.

func WithReplicaFallback

func WithReplicaFallback(policy replica.FallbackPolicy) Option

WithReplicaFallback sets what a read does when no replica qualifies. The default is replica.FallbackToPrimary.

Use replica.FallbackReject in tests, where a routing bug should be loud, or in a service that would rather shed read load than melt the primary. It never affects StrategyReplica, which never falls back either way.

func WithReplicaPollInterval

func WithReplicaPollInterval(d time.Duration) Option

WithReplicaPollInterval sets how often replica positions are refreshed. Zero disables the poller, which leaves every replica ineligible.

func WithReplicaPollJitter

func WithReplicaPollJitter(fraction float64) Option

WithReplicaPollJitter spreads polls by the given fraction of the interval, so that a fleet of pods does not probe the same replica in lockstep. The value must be between zero and one; Init rejects an out-of-range configuration.

func WithReplicaPoolConfig

func WithReplicaPoolConfig(fn func(index int, cfg *pgxpool.Config)) Option

WithReplicaPoolConfig adjusts each replica's pool before it is built — sizing, timeouts, application_name. It runs after the primary's settings have been copied over, with the replica's index.

func WithReplicaProbeTimeout

func WithReplicaProbeTimeout(d time.Duration) Option

WithReplicaProbeTimeout bounds a single health probe.

func WithReplicaSampleStaleAfter

func WithReplicaSampleStaleAfter(d time.Duration) Option

WithReplicaSampleStaleAfter sets the age past which a health sample is no longer trusted. A stale sample is indistinguishable from a hung replica.

func WithReplicaURI

func WithReplicaURI(uri ...string) Option

WithReplicaURI adds replica DSNs. Repeated calls append. With none, the router stays disabled and every statement runs on the primary.

One DSN must resolve to one standby. Pointing it at a load balancer in front of several standbys breaks the guarantee: the health probe describes one node and the read reaches another.

func WithTxLookup

func WithTxLookup(fn func(ctx context.Context) pgx.Tx) Option

WithTxLookup lets the router honor a transaction the application manages itself. Wire it at the composition root:

postgres.With(postgres.WithTxLookup(uow.FromContext))

Without it the router cannot tell that a transaction is in flight, and will happily run the statement on a different connection — outside the transaction, without its locks, and able to deadlock against it. This is the single most dangerous way to use the router, so wire the hook.

func WithWatermarkPolicy

func WithWatermarkPolicy(policy replica.WatermarkPolicy) Option

WithWatermarkPolicy sets when a committed transaction resolves its WAL position. WatermarkOnCommit costs one extra primary round trip per commit.

On-handoff capture is the default. In-request read-after-write needs only the taint, and a cross-boundary handoff is better served by calling Router.Watermark once, where the caller can see what it costs.

type PingConnectionError

type PingConnectionError = db.PingConnectionError

The lifecycle error types are shared by every driver, so that a fix reaches all of them at once. Aliases rather than new types: existing callers keep working with *StoreError unchanged.

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store implementation of db interface

func New

func New(tracer trace.TracerProvider, metrics *metric.MeterProvider, cfg *config.Config, opts ...Option) *Store

New return new instance of Store

func (*Store) Close

func (s *Store) Close()

Close releases the connection pools and stops the replica poller. It is idempotent, it waits for the poller goroutine to exit, and it is what context cancellation ends up calling.

Waiting matters: a poller that outlives its store shows up as a leaked goroutine in tests and as a slow climb in a service that rebuilds stores.

func (*Store) GetConn

func (s *Store) GetConn() any

GetConn - get connect

It returns the primary pool, and always will: db.Conn[*pgxpool.Pool] is public API, and downstream consumers of this SDK are invisible from here. The read-replica router is reached through RouterFrom.

func (*Store) Init

func (s *Store) Init(ctx context.Context) error

Init - initialize

func (*Store) Router

func (s *Store) Router() *replica.Router

Router returns the store's router, or nil when Init has not run.

Unlike RouterFrom it does not insist that replicas are configured: a disabled router is still usable and sends every statement to the primary.

type StoreError

type StoreError = db.StoreError

The lifecycle error types are shared by every driver, so that a fix reaches all of them at once. Aliases rather than new types: existing callers keep working with *StoreError unchanged.

type Tracer

type Tracer struct {
	trace.TracerProvider
}

Tracer is the tracer provider the store hands to otelpgx, which emits the query spans and their database attributes. A nil provider leaves the pool uninstrumented.

Directories

Path Synopsis
middlewares
grpcmw
Package grpcmw carries the driver's read-your-writes guarantee across a gRPC hop.
Package grpcmw carries the driver's read-your-writes guarantee across a gRPC hop.
httpmw
Package httpmw carries a read-your-writes guarantee across an HTTP request boundary.
Package httpmw carries a read-your-writes guarantee across an HTTP request boundary.
metrics
Package metrics holds the OpenTelemetry instruments for read-replica routing.
Package metrics holds the OpenTelemetry instruments for read-replica routing.
semconv
Package semconv holds the attribute keys and metric instruments of the postgres module as declared in the go-sdk semantic convention registry.
Package semconv holds the attribute keys and metric instruments of the postgres module as declared in the go-sdk semantic convention registry.
sqlclass
Package sqlclass decides whether a SQL statement may run on a read replica.
Package sqlclass decides whether a SQL statement may run on a read replica.
wal
Package wal models PostgreSQL Write-Ahead Log positions.
Package wal models PostgreSQL Write-Ahead Log positions.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL