tenantpool

package module
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Apr 27, 2026 License: MIT Imports: 14 Imported by: 0

README

tenantpool

Tenant-aware Postgres connection pool registry for Go HTTP services.

Single API for two deployment shapes:

  • Single-database — one long-lived *pgxpool.Pool for every request.
  • Multi-tenant — one pool per tenant ID, cached with LRU + idle eviction. The pooler behind the DSN (Supavisor, PgBouncer, direct PG) is a deployment detail; module code does not change when it swaps.
import "github.com/palgroup/tenantpool"

Quick start

Single-database
reg, err := tenantpool.New(tenantpool.Config{
    DatabaseURL: "postgres://user:pw@host:5432/db?sslmode=disable",
})
if err != nil { log.Fatal(err) }
defer reg.Close()

router.Use(reg.Middleware())

// In a handler:
pool := tenantpool.PoolFromCtx(r.Context())
Multi-tenant (Supavisor)
reg, err := tenantpool.New(tenantpool.Config{
    DSNBuilder: tenantpool.SupavisorDSN(tenantpool.SupavisorOpts{
        Host: "supavisor.internal:5432",
        UserPrefix: "auth",
        Password: pw,
    }),
    Resolver: tenantpool.HeaderResolver("X-Tenant-Ref"),
    MaxPoolsCached: 500,
    IdleTimeout:    15 * time.Minute,
})
if err != nil { log.Fatal(err) }
defer reg.Close()

// Background janitor (optional but recommended)
go func() {
    t := time.NewTicker(1 * time.Minute)
    defer t.Stop()
    for range t.C { reg.EvictIdle() }
}()

router.Use(reg.Middleware(
    tenantpool.WithErrorHandler(myEnvelopeWriter),
))

// Handlers are identical to the single-database case:
pool := tenantpool.PoolFromCtx(r.Context())
Background workers

Workers with no HTTP request resolve the tenant from the message payload, then build the ctx manually:

pool, err := reg.Get(ctx, job.TenantID)
if err != nil { return err }
ctx = tenantpool.WithPool(ctx, pool)
return service.Process(ctx, job)

Pooler swap

Changing from Supavisor to a direct-PG deployment is a one-line change in main.go:

DSNBuilder: tenantpool.DirectDSN(tenantpool.DirectOpts{
    Host: "postgres.internal:5432",
    User: "app", Password: pw,
}),

No handler, service, or repository code changes. tenantpool.PoolFromCtx still returns a *pgxpool.Pool.

Sentinel errors

Registry operations and the default error handler classify failures:

Error HTTP (default handler) Meaning
ErrTenantNotFound 404 DSN builder could not resolve the tenant.
ErrUpstreamUnreachable 503 Pooler or backend Postgres is unreachable.
ErrPoolExhausted 503 Pool saturated under its wait timeout.
ErrInvalidConfig 500 Config or programming error.
ErrNoPool PoolFromCtxOK returned (nil, false).

Plug a service-specific envelope with tenantpool.WithErrorHandler.

Prometheus metrics

metrics := tenantpool.NewMetrics(map[string]string{"module": "auth"})
reg.WithMetrics(metrics)
registerer.MustRegister(metrics.Collectors()...)

Exposed:

  • tenantpool_pools_active — live pools.
  • tenantpool_pools_created_total — new pools opened.
  • tenantpool_pools_evicted_total — pools closed (LRU, idle, invalidate).
  • tenantpool_pool_errors_total{reason} — failed Get calls by sentinel.
  • tenantpool_pool_acquire_duration_seconds — pool acquire latency.

Go version

Go 1.26+. Uses pgx/v5 and golang.org/x/sync/singleflight.

License

MIT.

Documentation

Overview

Package tenantpool provides a tenant-aware Postgres connection pool registry for Go HTTP services.

A single Registry serves both single-database and multi-tenant deployments through the same API. The module code that uses the Registry never sees whether there is one pool or one-per-tenant, nor which pooler (Supavisor, PgBouncer, direct PG) sits behind the DSN — that detail lives entirely in the Config passed to New.

Single-database mode

reg, err := tenantpool.New(tenantpool.Config{
    DatabaseURL: "postgres://user:pw@host:5432/db",
})

Multi-tenant mode

reg, err := tenantpool.New(tenantpool.Config{
    DSNBuilder: tenantpool.SupavisorDSN(tenantpool.SupavisorOpts{
        Host: "supavisor:5432", Schema: "auth", Password: "…",
    }),
    Resolver: tenantpool.HeaderResolver("X-Tenant-Ref"),
})

Either way, handlers read the pool through pgctx helpers:

pool := tenantpool.PoolFromCtx(r.Context())

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrInvalidConfig is returned at construction time or on Get with
	// bad input (empty tenantID, parse failure, ConfigurePool returning
	// an error). Treat as a programming or deployment error; 500.
	ErrInvalidConfig = errors.New("tenantpool: invalid config")

	// ErrTenantNotFound is returned when DSNBuilder fails for the given
	// tenant ID. Callers typically map to HTTP 404 so probes cannot
	// distinguish missing tenants from unreachable ones.
	ErrTenantNotFound = errors.New("tenantpool: tenant not found")

	// ErrUpstreamUnreachable is returned when the pooler or backend
	// Postgres cannot be reached. Callers typically map to HTTP 503.
	ErrUpstreamUnreachable = errors.New("tenantpool: upstream unreachable")

	// ErrPoolExhausted is returned when a tenant pool cannot acquire a
	// connection within its wait timeout. Callers typically map to
	// HTTP 503.
	ErrPoolExhausted = errors.New("tenantpool: pool exhausted")

	// ErrNoPool is returned by PoolFromCtxOK and ErrorHandler callbacks
	// when no pool was attached to the request context — a sign the
	// Middleware chain is misconfigured.
	ErrNoPool = errors.New("tenantpool: no pool in context")
)

Sentinel errors returned by Registry operations and Middleware. Use errors.Is to detect them; custom error handlers should map them to HTTP responses as appropriate for the service.

Functions

func DefaultErrorHandler

func DefaultErrorHandler(w http.ResponseWriter, _ *http.Request, err error)

DefaultErrorHandler writes a plain text 503 response. Services with a canonical error envelope should replace this via WithErrorHandler.

func DirectDSN

func DirectDSN(opts DirectOpts) func(string) (string, error)

DirectDSN returns a DSNBuilder that connects each tenant to a database whose name equals its tenant ID. Suitable for PgBouncer or direct-PG deployments that use DB-per-tenant isolation without a tenant-aware pooler in front.

func PgBouncerDSN

func PgBouncerDSN(opts PgBouncerOpts) func(string) (string, error)

PgBouncerDSN is an alias for DirectDSN. PgBouncer deployments use DB-per-tenant with a shared pooler role; the DSN shape is identical to direct PG.

func PoolFromCtx

func PoolFromCtx(ctx context.Context) *pgxpool.Pool

PoolFromCtx extracts the pool attached by Middleware.

Panics when no pool is present: a handler reaching here without one means the middleware chain is misconfigured and silent failure would mask the bug. Callers that must tolerate a missing pool should use PoolFromCtxOK.

func PoolFromCtxOK

func PoolFromCtxOK(ctx context.Context) (*pgxpool.Pool, bool)

PoolFromCtxOK is the non-panicking form of PoolFromCtx.

func SupavisorDSN

func SupavisorDSN(opts SupavisorOpts) func(string) (string, error)

SupavisorDSN returns a DSNBuilder that produces Supavisor-style connection strings. The tenantID is embedded in the wire username so Supavisor can route server-side; no custom SupavisorOpts field references the tenantID itself.

func WithPool

func WithPool(ctx context.Context, pool *pgxpool.Pool) context.Context

WithPool returns a context with the given pool attached.

Used by background workers (NATS consumers, Redis Streams consumers, cron jobs) that resolve the tenant from message payloads rather than HTTP headers: Registry.Get to fetch the pool, WithPool to attach it, then call into service code that expects PoolFromCtx.

Types

type CachingPasswordResolver added in v0.2.0

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

CachingPasswordResolver wraps another resolver with an in-memory cache that expires entries after ttl. Concurrent Resolve calls for the same tenant share a single underlying fetch (singleflight-like semantics via the registry's existing singleflight group is not reused here so the resolver stays decoupled — one extra fetch under contention is cheap and bounded).

CachingPasswordResolver is goroutine-safe.

func NewCachingPasswordResolver added in v0.2.0

func NewCachingPasswordResolver(inner PasswordResolver, ttl time.Duration) *CachingPasswordResolver

NewCachingPasswordResolver wraps inner with ttl-bounded caching. ttl<=0 disables caching (every Resolve hits the inner resolver).

func (*CachingPasswordResolver) Invalidate added in v0.2.0

func (r *CachingPasswordResolver) Invalidate(tenantID string)

Invalidate forces the next Resolve for tenantID to bypass the cache. Pair with Registry.Invalidate when a tenant's password rotates.

func (*CachingPasswordResolver) Resolve added in v0.2.0

func (r *CachingPasswordResolver) Resolve(ctx context.Context, tenantID string) (string, error)

Resolve returns the cached password for tenantID if still valid, otherwise delegates to the inner resolver and caches the result.

type Config

type Config struct {
	// DatabaseURL puts the Registry in single-database mode.
	DatabaseURL string

	// DSNBuilder returns the connection string for a given tenant ID.
	// Mutually exclusive with DSNTemplate. Typical implementations come
	// from the SupavisorDSN / PgBouncerDSN / DirectDSN helpers in this
	// package, or a custom closure built by the caller.
	DSNBuilder func(tenantID string) (string, error)

	// DSNTemplate is the per-tenant connection string template with
	// {{tenant}} and (optionally) {{password}} placeholders. Used in
	// combination with PasswordResolver — the registry handles the
	// substitution + password fetch. Mutually exclusive with DSNBuilder.
	//
	// Example:
	//
	//	postgres://palbase_auth.{{tenant}}:{{password}}@supavisor:5432/{{tenant}}?sslmode=disable
	DSNTemplate string

	// PasswordResolver supplies the tenant role password substituted
	// into DSNTemplate's {{password}} placeholder. Required when the
	// template contains {{password}}. Wrap with
	// NewCachingPasswordResolver for production deployments.
	PasswordResolver PasswordResolver

	// Resolver extracts the tenant ID from an HTTP request for use by
	// Middleware. Optional when the Registry is only consumed by
	// background workers that call Get with an explicit tenant ID.
	Resolver Resolver

	// MaxPoolsCached caps the number of live tenant pools. When exceeded,
	// the least-recently-used pool is closed in the background.
	// Default 500. Ignored in single-database mode.
	MaxPoolsCached int

	// IdleTimeout closes pools that have not been accessed for this
	// duration. Default 15 minutes. Ignored in single-database mode.
	IdleTimeout time.Duration

	// MaxConnsPerPool caps pgxpool.MaxConns on each tenant pool. Default
	// 10.
	MaxConnsPerPool int32

	// MinConnsPerPool caps pgxpool.MinConns on each tenant pool. Default
	// 0 — no idle connections held for unused tenants.
	MinConnsPerPool int32

	// ConfigurePool lets callers tweak a pgxpool.Config after parsing
	// and before NewWithConfig. Use it for statement-cache settings,
	// lifetime caps, tracers, or extra TLS configuration. Runs once per
	// pool creation. Optional.
	ConfigurePool func(*pgxpool.Config) error
}

Config describes how a Registry resolves pools.

Exactly one of DatabaseURL, DSNBuilder, or DSNTemplate must be set.

  • DatabaseURL: single-database mode. Every Get returns the same pool; tenantID is ignored.
  • DSNBuilder: multi-tenant mode, raw form. The caller fully owns the DSN string returned per tenant — useful when the secret source is bespoke (vault token sidecar, etc).
  • DSNTemplate + PasswordResolver: multi-tenant mode, declarative form. The template substitutes {{tenant}} and {{password}}; the resolver supplies the password per-tenant. Production callers should land here so password fetch + caching is one well-tested path across modules.

Resolver (HTTP tenant identification) is required for any multi-tenant caller that uses Middleware; background workers that call Get with explicit tenant IDs can omit it.

type DirectOpts

type DirectOpts struct {
	// Host is the Postgres host:port.
	Host string
	// User is the database role used for every tenant.
	User string
	// Password is the role's password.
	Password string
	// SSLMode is appended to the DSN. Default "require".
	SSLMode string
	// ExtraParams are appended after sslmode. Keys and values are URL
	// escaped.
	ExtraParams map[string]string
}

DirectOpts configures the DirectDSN builder — a plain user@host/tenant_db connection string with no pooler in front.

type ErrorHandler

type ErrorHandler func(w http.ResponseWriter, r *http.Request, err error)

ErrorHandler converts a Registry error into an HTTP response. Services with a canonical error envelope should plug it in through WithErrorHandler; otherwise DefaultErrorHandler is used.

type KVPasswordResolver added in v0.2.0

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

KVPasswordResolver fetches per-tenant role passwords from an Azure Key Vault. The secret name is built from a template containing the {tenant} placeholder; for example

"platform-cnpg-{tenant}-tenant-palbase-auth-password"

resolved against tenant "acme" hits

platform-cnpg-acme-tenant-palbase-auth-password

inside the configured vault.

Wrap with CachingPasswordResolver for production — a raw KV call per pgxpool.New burns a network hop and a token-renewal lookup.

func NewKVPasswordResolver added in v0.2.0

func NewKVPasswordResolver(vaultName, template string) (*KVPasswordResolver, error)

NewKVPasswordResolver constructs a resolver bound to vaultName. Authentication uses azidentity.NewDefaultAzureCredential, which means callers running in AKS with Workload Identity get federated tokens automatically; local dev picks up `az login` credentials. The template must contain {tenant} as the substitution marker; missing it errors on construction so misconfiguration fails loud.

func NewKVPasswordResolverWithCredential added in v0.2.0

func NewKVPasswordResolverWithCredential(vaultName, template string, cred azcore.TokenCredential) (*KVPasswordResolver, error)

NewKVPasswordResolverWithCredential is the credential-injecting constructor variant, useful in tests or when callers already have a chained credential they want to share.

func (*KVPasswordResolver) Resolve added in v0.2.0

func (r *KVPasswordResolver) Resolve(ctx context.Context, tenantID string) (string, error)

Resolve fetches the latest version of the tenant's secret. Returns an error if the secret doesn't exist or the value is empty — callers treat this as "tenant not provisioned" and surface it as a 5xx through the registry's ErrTenantNotFound classification.

type Metrics

type Metrics struct {
	PoolsActive  prometheus.Gauge
	PoolsCreated prometheus.Counter
	PoolsEvicted prometheus.Counter
	PoolErrors   *prometheus.CounterVec
	Acquire      prometheus.Histogram
}

Metrics wraps the Prometheus collectors the Registry updates. Callers register them with their own Registerer; the package never touches prometheus.DefaultRegisterer so it cannot double-register when imported from multiple modules.

func NewMetrics

func NewMetrics(constLabels map[string]string) *Metrics

NewMetrics returns a Metrics bundle. Pass the result to Registry.WithMetrics; call MustRegister on your Registerer to expose them. The optional constLabels map (e.g. {"module":"auth"}) lets multi-module deployments distinguish pools in the same Prometheus scrape.

func (*Metrics) Collectors

func (m *Metrics) Collectors() []prometheus.Collector

Collectors returns the slice needed to register all metrics with a single MustRegister call.

type MiddlewareOption

type MiddlewareOption func(*middlewareOptions)

MiddlewareOption tweaks the behaviour of Registry.Middleware.

func WithErrorHandler

func WithErrorHandler(h ErrorHandler) MiddlewareOption

WithErrorHandler installs a custom error handler, typically one that writes the service's canonical JSON envelope. Without it, DefaultErrorHandler writes a plain 503.

func WithResolver

func WithResolver(res Resolver) MiddlewareOption

WithResolver overrides the Registry's Config.Resolver for this Middleware instance. Useful when the same Registry is shared between routers that identify tenants differently (e.g. public vs admin routes keyed off different headers).

type PasswordResolver added in v0.2.0

type PasswordResolver interface {
	Resolve(ctx context.Context, tenantID string) (string, error)
}

PasswordResolver returns the database role password for a given tenant. Implementations decide where the password lives — Azure Key Vault, an in-memory map for tests, a file mount per tenant, etc. — and how aggressively to cache.

Resolvers run on the hot path of pgxpool.New whenever the registry needs to open a tenant pool, so they should be cheap on cache hits and bounded on cache misses (one upstream call, idempotent under concurrency). Errors propagate up as ErrTenantNotFound.

func StaticPasswordResolver added in v0.2.0

func StaticPasswordResolver(pw string) PasswordResolver

StaticPasswordResolver returns the same password for every tenant. Useful in dev when one shared role serves every tenant DB, and as a migration step from the legacy "global env var" model that predates per-tenant secrets.

Production callers should prefer a real KV-backed resolver — sharing a single password across tenants kills the blast-radius story when any one tenant's pod gets compromised.

type PasswordResolverFunc added in v0.2.0

type PasswordResolverFunc func(ctx context.Context, tenantID string) (string, error)

PasswordResolverFunc adapts a plain function into a PasswordResolver. Use it for one-off test stubs: `tenantpool.PasswordResolverFunc(func(_, t) (string, error) { return "secret", nil })`.

func (PasswordResolverFunc) Resolve added in v0.2.0

func (f PasswordResolverFunc) Resolve(ctx context.Context, tenantID string) (string, error)

Resolve implements PasswordResolver.

type PgBouncerOpts

type PgBouncerOpts = DirectOpts

PgBouncerOpts is an alias for DirectOpts kept for clarity at call sites. PgBouncer is transparent at the wire level (same user/DSN shape as direct PG), so the two builders produce identical strings.

type Registry

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

Registry is the tenant pool cache. Safe for concurrent use.

Callers must call Close at service shutdown to drain pools cleanly.

func New

func New(cfg Config) (*Registry, error)

New constructs a Registry from Config. Validation errors return ErrInvalidConfig. In multi-tenant mode nothing is dialled until Get is called. In single-database mode the single pool is opened eagerly so configuration errors surface at startup.

func (*Registry) Close

func (r *Registry) Close()

Close drains all pools and clears the cache. Call once at service shutdown. Safe to call multiple times.

func (*Registry) EvictIdle

func (r *Registry) EvictIdle() int

EvictIdle closes every pool that has not been accessed within IdleTimeout. Call periodically from a janitor goroutine (once a minute is plenty). Returns the number of pools evicted.

No-op in single-database mode.

func (*Registry) Get

func (r *Registry) Get(ctx context.Context, tenantID string) (*pgxpool.Pool, error)

Get returns a pool for tenantID. In single-database mode the tenantID is ignored and the shared pool is returned.

Concurrent Get calls for the same tenantID are de-duplicated; only one pool is ever opened per key. Errors are classified through the sentinel set defined in errors.go.

func (*Registry) Invalidate

func (r *Registry) Invalidate(tenantID string)

Invalidate drops the pool for tenantID. Use this when tenant metadata changes (password rotated, tenant paused). No-op in single mode.

func (*Registry) Middleware

func (r *Registry) Middleware(opts ...MiddlewareOption) func(http.Handler) http.Handler

Middleware returns the HTTP middleware that attaches a pool to the request context. Handlers read it back with PoolFromCtx.

In single-database mode the configured pool is attached directly, skipping resolver and DSN builder. In multi-tenant mode the Registry's Resolver is called to identify the tenant; a missing or errorful resolver aborts the chain via the error handler.

func (*Registry) Stats

func (r *Registry) Stats() Stats

Stats returns the current counter snapshot.

func (*Registry) WithMetrics

func (r *Registry) WithMetrics(m *Metrics) *Registry

WithMetrics attaches a Metrics bundle to the Registry. Call before Get is invoked; changing metrics at runtime is unsupported.

type Resolver

type Resolver func(r *http.Request) (tenantID string, err error)

Resolver extracts the tenant ID from an HTTP request. The default Middleware skips resolution in single-database mode.

func HeaderResolver

func HeaderResolver(name string) Resolver

HeaderResolver returns a Resolver that reads tenantID from the given HTTP header. An empty header value produces ErrTenantNotFound so the default error handler maps it to 404 rather than leaking a 500.

Typical deployment: a gateway (Kong, nginx) injects the header after authenticating an API key or iJWT. Modules never touch the raw token.

func StaticResolver

func StaticResolver(tenantID string) Resolver

StaticResolver returns a Resolver that always yields the given tenantID. Intended for single-tenant deployments that still want to exercise the multi-tenant code path (e.g. integration tests), or for workers whose tenancy is fixed at startup.

type Stats

type Stats struct {
	ActivePools int
	Created     uint64
	Evicted     uint64
	Hits        uint64
	Misses      uint64
	Errors      uint64
	SingleMode  bool
}

Stats is a snapshot of Registry counters. Intended for logging; metrics wire into Prometheus via the metrics.go hooks.

type SupavisorOpts

type SupavisorOpts struct {
	// Host is the Supavisor endpoint (host:port).
	Host string
	// UserPrefix is prepended before the dot in the wire username. In
	// Supabase Cloud this is "postgres"; in self-hosted deployments it
	// is typically your module schema name (e.g. "auth"). Example:
	// UserPrefix="auth" → username "auth.tenant_abc".
	UserPrefix string
	// Password is the role password.
	Password string
	// DBName overrides the Postgres dbname in the DSN. When empty, the
	// tenantID is used — Supavisor will pass this through to the
	// backend. Most deployments leave this empty.
	DBName string
	// SSLMode is appended to the DSN. Default "require".
	SSLMode string
	// ExtraParams are appended after sslmode. Keys and values are URL
	// escaped. Use this to set application_name,
	// default_query_exec_mode, etc.
	ExtraParams map[string]string
}

SupavisorOpts configures the SupavisorDSN builder — Supabase's tenant-aware pooler. Supavisor parses the dotted username ("{schema}.{tenantID}") to route the connection to the tenant's backend Postgres.

Jump to

Keyboard shortcuts

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