A small, generic Go library for lazy per-tenant pool lifecycle management.
The manager is database-agnostic: a Factory creates the concrete pool for a tenant, and the manager owns its lifecycle:
- lazy creation, one pool per tenant, on first use;
- in-flight dedup: concurrent requests for a cold tenant share a single factory call;
- leases (
Leaser) that pin a pool while it is in use; - periodic reaping of pools that are idle and have no active leases;
- deterministic
Close(); - opt-in bridges for
pgxpool.Poolanddatabase/sql; Stats()for metrics and logging.
The reader does most of the work. The manager is deliberately not a connection pool: for PostgreSQL, each tenant's pgxpool.Pool or *sql.DB is the actual pool, and tenantpool just decides when each exists and dies.
go get github.com/lrweck/tenantpoolmanager, err := tenantpool.New(factory, tenantpool.Config{
IdleTimeout: 30 * time.Minute, // how long an idle pool stays cached
ReaperInterval: 5 * time.Minute, // how often idle pools are inspected
})
if err != nil {
log.Fatal(err)
}
defer manager.Close()A runnable, fully self-contained example lives in example_test.go (it needs no database).
lease, err := manager.Acquire(ctx, tenantID)
if err != nil {
return err
}
defer lease.Release()
pool := lease.Pool() // the concrete pool for this tenant
_, err = pool.Exec(ctx, `SELECT ...`)Leaser.Release() is idempotent — calling it twice is a no-op — so a deferred release is always safe. Leaser is an interface whose concrete type is unexported: a lease has no value to copy, so it cannot be duplicated by accident. The reaper only evicts entries with zero active leases, so all use of lease.Pool() must stay inside the lease lifetime.
st := manager.Stats() // snapshot: pools, active, creating, totals
for tenant, ts := range st.ByTenant {
_ = tenant
_ = ts.Active // held leases right now
_ = ts.Creating // factory still running for this tenant
_ = ts.LastUsed // last acquire or release
}
log.Println(st) // e.g. pools=1 active=1 creating=0 open=1 created=1 closed=0 acquired=1factory := tenantpool.PGXConfigFactory[string]{
Config: func(ctx context.Context, tenant string) (*pgxpool.Config, error) {
cfg, err := pgxpool.ParseConfig(baseConnString)
if err != nil {
return nil, err
}
// Load the tenant's real credentials; the factory runs once per cold
// tenant, so this can hit a config service without hot-path cost.
creds, err := loadTenantCredentials(ctx, tenant)
if err != nil {
return nil, err
}
cfg.ConnConfig.User = creds.Username
cfg.ConnConfig.Password = creds.Password
cfg.MinConns = 0 // leave idle count to the manager
cfg.MaxConns = creds.MaxConns // per-tenant cap
return cfg, nil
},
}
manager, err := tenantpool.New(factory, tenantpool.Config{
IdleTimeout: 30 * time.Minute,
ReaperInterval: 5 * time.Minute,
})
if err != nil {
log.Fatal(err)
}
defer manager.Close()
lease, err := manager.Acquire(ctx, tenantID)
if err != nil {
return err
}
defer lease.Release()
_, err = lease.Pool().Exec(ctx, `SELECT ...`)factory := tenantpool.SQLDBFactory[string]{
Open: func(ctx context.Context, tenant string) (*sql.DB, error) {
db, err := sql.Open("pgx", getTenantDSN(tenant))
if err != nil {
return nil, err
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(0) // let the manager reap idle pools
db.SetConnMaxIdleTime(10 * time.Minute)
return db, nil
},
}The database/sql bridge returns a *tenantpool.SQLPool, which embeds *sql.DB (all its methods are promoted; use .DB for the raw handle). database/sql's Close() returns an error, so the wrapper discards it and satisfies the Pool contract (Close()). The pgxpool bridge returns *pgxpool.Pool directly.
A plain sync.RWMutex guards a plain map[K]*entry — there is no sync.Map, and per-tenant entry state is not parked under the manager lock. activeUses and lastUsed are atomics updated by the acquiring/releasing goroutine:
- a warm acquire takes a shared read lock only to look the tenant up, then bumps the two atomics of that entry — no write lock on the hot path;
- a cold acquire installs an entry with
creating=trueand runs the factory outside any lock; concurrent callers park on an embeddedsync.Conduntil the result is published. There is nosingleflightdependency — thiscreatingflag is the in-flight dedup, and it guarantees one factory call per cold tenant; - the reaper iterates under the write lock, collects idle entries (
creating=false,activeUses=0, idle pastIdleTimeout), and closes their pools outside the lock; Close()is idempotent, stops the reaper, and closes every cached pool; a factory still in flight whenClose()is called closes its own newly-created pool once it sees shutdown;- creating pools fail with an error or panic, the entry is discarded and waiters retry — a bad tenant never poisons the manager.
- Not transaction-aware. A lease guards a pool's lifecycle; your application still does
Begin/Acquire/Execthrough the concrete pool. - No per-tenant cumulative history.
Statstracks cumulative totals globally (TotalCreated,TotalClosed,TotalAcquires) plus a live snapshot per tenant. Cumulative counters per tenant would grow without bound as pools are reaped and recreated. - Factory errors are not cached. Waiters retry after a failed create;
ctxcancellation is the intended back-off knob. - Active leases are not revoked by
Close(). Stop accepting work beforeClose().
| Field | Meaning | Constraint |
|---|---|---|
IdleTimeout |
how long an unused pool stays cached after its last released lease | > 0 |
ReaperInterval |
how often idle pools are inspected | > 0 |
A useful starting point is IdleTimeout of 10 to 30 minutes and ReaperInterval of minutes. The manager makes no connection to any database itself; choose limits that match your tenant's real usage.
Unit and stress tests run without any service:
go test ./...
go test -race ./...
go vet ./...
An integration test (integration_test.go) exercises the pgxpool and database/sql bridges against a real Postgres via testcontainers-go. It is skipped automatically when Docker is unavailable:
go test -run TestPostgresLifecycle -v ./...
Run them with:
go test -run=^$ -bench=. -benchtime=2s -benchmem ./...
Numbers are indicative, from a 13th Gen Intel i7-13700H, GOMAXPROCS=20, single run:
| Benchmark | ns/op | allocs/op | What it measures |
|---|---|---|---|
| AcquireReleaseHot | 109 | 1 | warm single-tenant acquire+release |
| AcquireReleaseParallelHot | 134 | 1 | same tenant, many goroutines |
| ParallelDistinctWarm | 88 | 1 | 256 pre-warmed tenants under contention |
| ColdCreateDistinct | 682 | 5 | brand-new string tenant each op |
| ColdCreateDistinctNoRelease | 864 | 5 | pure create/install, no release |
| ColdCreateIntKey | 743 | 3 | cold create with int keys (no formatting) |
The single allocation on every operation is the lease itself. Cold-create numbers include the factory returning a trivial pool; with a heavyweight factory they skew higher, which is why the cold benchmarks use a trivial factory.