Skip to content

Bound shared Postgres pool registry for dynamic connection strings #142

Description

@larimonious

Context

PR #141 changed std/db/postgres.connect(url) to reuse process-local shared pools keyed by normalized connection target, while returning fresh logical handles for each connect() call. That fixes the request-path physical-pool churn problem, but it introduces a new lifecycle question: shared physical pools currently live for the worker/process lifetime.

Risk / problem

For normal apps with one or a few stable env-backed database URLs, this is fine and desirable.

The risky shape is dynamic connection strings, for example:

fn handler(req: Map) -> Map {
    let url = req.params["tenant_db_url"] ?? ""
    let db = connect(url) otherwise { return json(map { "error": "db" }, 500) }
    // ... query ...
    close(db)
}

Because close(handle) now invalidates only the logical handle and keeps the shared pool cached, many distinct URLs can accumulate:

  • one shared pool per distinct normalized connection target
  • up to NTNT_DB_POOL_SIZE server connections per pool
  • memory/connection growth for the worker lifetime

A related but smaller concern: repeated connect(url) without close(handle) no longer creates physical pool churn, but it can still accumulate logical handle entries in HANDLE_REGISTRY.

Proposed direction

Consider adding bounded shared-pool lifecycle management.

A max shared-pool count with LRU eviction seems sound if implemented carefully:

  • Add an env/config knob, e.g. NTNT_POSTGRES_MAX_SHARED_POOLS, with a conservative default or disabled-by-default behavior if we want maximum compatibility.
  • Track last-used time / generation per pool key.
  • On connect(url) when inserting a new shared pool and the registry exceeds the max, evict least-recently-used pools that have no active logical handles and no active transactions.
  • Never evict a pool while any handle currently points at it, or while a transaction is pinned to a handle backed by it.
  • Make eviction best-effort; if all pools are active, either keep the new pool and emit a warning, or return a clear recoverable error depending on the chosen policy.
  • Keep the registry key hashed/redacted; no raw connection strings in logs or errors.

Also consider diagnostics:

  • warn when shared pool count crosses a threshold
  • optionally expose internal stats for shared pool count / logical handle count
  • document that dynamic/user-controlled URLs should be avoided unless bounded intentionally

Acceptance criteria

  • Stable env-backed URLs keep the fast shared-pool behavior from PR perf: reuse Postgres pools for connect #141.
  • Distinct dynamic URLs cannot grow shared physical pools without bound when a max is configured.
  • Eviction never invalidates live logical handles or active transactions.
  • No raw credentials/connection strings are stored in registry metadata, logs, or errors.
  • Tests cover:
    • LRU eviction of inactive pools
    • no eviction of active-handle pools
    • no eviction of active-transaction pools
    • max-pool behavior when every pool is active
    • repeated connect → query → close does not leak physical pools beyond the configured bound

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions