What sits between Prisme.ai and PostgreSQL
All four backend services that talk to PostgreSQL (api-gateway, workspaces, events, runtime) use:
-
MikroORM v6 as the ORM layer (instantiated from
@prisme.ai/permissions→lib/databases/orm.ts). -
MikroORM uses Knex.js + Tarn.js under the hood for SQL connection pooling (not
pg-pooldirectly). -
Pool defaults come from Knex (
node_modules/knex/lib/client.js:poolDefaults()):
min sockets warm at all times (this is why every pool you observe holds ~2 connections even when idle) and grows on demand up to max. Idle sockets above min are supposed to be released after idleTimeoutMillis. In practice (see Why pools never shrink) continuous background activity keeps the pools at their high-water mark for hours.
How many pools does each pod open?
This is the part that explains why a “small” deployment can hold many connections.prismeai-api-gateway
Single-process pod (no clustering, no worker threads).
The api-gateway does not connect to the
permissions database. Its AccessManager handles org-level RBAC (organizations, memberships, service accounts, invites) and stores those tables inside the users DB alongside the user accounts. The permissions DB is reserved for workspace-level RBAC, which only workspaces, events and runtime consume.
The two pools are independent pg.Pool instances on the same DB, which is why a single api-gateway pod typically shows ~4 connections on users (2 pools × ~2 warm sockets).
Also, no separate pool exists for sessions, magic links, refresh tokens, or service accounts — they all share the single users storage pool.
Total pools per pod: 2, both targeting users.
prismeai-workspaces
Single-process pod.
Total pools per pod: 1.
prismeai-events
Single-process pod.
Total pools per pod: 1.
prismeai-runtime
Multi-threaded via Node.js worker_threads. The number of worker threads is controlled by RUNNER_MAX_THREADS (services/runtime/config/runtime.ts:7, default 1). Each worker thread fully re-initializes its database stack, so it opens its own pools — they are not shared with the main thread.
Let W = RUNNER_MAX_THREADS.
Main thread:
The main thread does not open a pool to
collections — the collections modules are only started inside workers.
Per worker thread (×W):
Total pools per pod:
permissions:1 + Wpoolscollections:2 × Wpools
W=1: 2 pools to permissions, 2 to collections.
For a tuned runtime W=4: 5 pools to permissions, 8 to collections.
Connection budget per pod, per database
Multiplying pool count by thepg-pool max=10 default gives the theoretical maximum each pod can hold. The observed minimum in our sandbox (idle but warm) is roughly 1–3 sockets per pool, because there is enough background traffic to keep every pool at a non-zero high-water mark.
prismeai-api-gateway does not open any pool against the permissions DB — its org-level RBAC tables live alongside the user accounts in the users DB.
Total connection envelope
For a deployment withNa api-gateway pods, Nw workspaces pods, Ne events pods, Nr runtime pods (each with W worker threads):
Why pools never shrink in practice
pg-pool is supposed to release idle sockets after idleTimeoutMillis=10s. Yet observed connections on the sandbox stay open for hours:
PID 3066265 opened at 05:00, last query at 11:47 — still open at 12:45.This is because Prisme.ai pods continuously emit small queries against
permissions (RBAC checks during event subscriptions, cron-triggered automations, workspace registry refresh, etc.). Each of these queries touches a socket and resets its idle timer just often enough to keep it from being recycled. Once a pool reaches its high-water mark during a burst, it stays there indefinitely.
A signature of this pattern is visible in pg_stat_activity: several connections holding the exact same query_id for the same workspace’s role lookup. That is one warm socket per runtime thread (main + each worker), each having most recently served the same RBAC check.
Tuning levers
Every storage block accepts pool-sizing env vars of the form<NAME>_STORAGE_POOL_<key>. They are parsed by parsePoolOptionsFromEnv() (packages/permissions/lib/databases/poolOptions.ts) and forwarded to MikroORM’s pool option, which Knex/Tarn enforces. Set the same env vars on every replica of the corresponding Deployment.
The same
MIN / MAX / IDLE_TIMEOUT_MS keys exist for USERS_STORAGE_POOL_* (api-gateway users pools — the org RBAC AccessManager also targets the users DB and respects the same vars) and COLLECTIONS_STORAGE_POOL_* (each runtime collections pool, one per worker). Set the same env vars on every replica of the corresponding Deployment.
For the runtime specifically, RUNNER_MAX_THREADS (default 1) has the largest impact on connection count — each unit reduction removes one permissions pool and two collections pools.
The legacy
<NAME>_STORAGE_OPT_<key> env vars feed MikroORM’s driverOptions, which are passed to the underlying client (pg.Client for Postgres, MongoClient for Mongo) — not to the pool on Postgres. On Postgres, setting PERMISSIONS_STORAGE_OPT_max=5 does not cap the pool; use PERMISSIONS_STORAGE_POOL_MAX=5 instead. The *_STORAGE_OPT_* channel is still useful as an escape hatch when a driver-specific option needs to override a *_STORAGE_POOL_* value (for example, *_STORAGE_OPT_maxIdleTimeMS wins over *_STORAGE_POOL_IDLE_TIMEOUT_MS on Mongo).Typical tuning recipes
Cap pool growth without losing parallelism (sensible default for medium deployments):IDLE_TIMEOUT_MS, this brings the steady-state connection count back down between bursts instead of camping at min:
Inspection queries
Distribution of connections per database (replaceavnadmin with your role):
query_id across N rows = N runtime threads):
Related
- Self-hosting / PostgreSQL — user-facing setup, sizing, and PgBouncer guidance.