Skip to content

Repository files navigation

tenantpool

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.Pool and database/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.

Usage

go get github.com/lrweck/tenantpool

Quick start

manager, 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).

The lease contract

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.

Quickly get Stats

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=1

Using with PostgreSQL

pgxpool

factory := 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 ...`)

database/sql

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.

Concurrency model

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=true and runs the factory outside any lock; concurrent callers park on an embedded sync.Cond until the result is published. There is no singleflight dependency — this creating flag 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 past IdleTimeout), and closes their pools outside the lock;
  • Close() is idempotent, stops the reaper, and closes every cached pool; a factory still in flight when Close() 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.

Design notes

  • Not transaction-aware. A lease guards a pool's lifecycle; your application still does Begin/Acquire/Exec through the concrete pool.
  • No per-tenant cumulative history. Stats tracks 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; ctx cancellation is the intended back-off knob.
  • Active leases are not revoked by Close(). Stop accepting work before Close().

Config reference

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.

Testing

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 ./...

Benchmarks

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.

About

Lazy per-tenant pool lifecycle management for Go: one pool per tenant, shared factory call on cold start, idle reaping, stats

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages