Skip to content

Commit facd581

Browse files
authored
Merge pull request #1126 from constructive-io/docs/pg-pool-lifecycle-architecture
docs: add PostgreSQL pool lifecycle architecture document
2 parents 8557eb2 + b34bb79 commit facd581

1 file changed

Lines changed: 327 additions & 0 deletions

File tree

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
# PostgreSQL Pool Lifecycle Management
2+
3+
How PostgreSQL connection pools are created, cached, borrowed, and disposed
4+
across the constructive server, GraphQL middleware, and test infrastructure.
5+
6+
---
7+
8+
## Ownership Model
9+
10+
**pg-cache** is the single owner of all `pg.Pool` instances in the server
11+
process. Every other layer borrows pools from pg-cache and returns connections
12+
when finished. No other code calls `pool.end()`.
13+
14+
```
15+
pg-cache — owns pools (create, cache, dispose)
16+
graphile-cache — owns PostGraphile instances, borrows pools via preset
17+
server / middleware — resolve tenant config, borrow pools via getPgPool()
18+
PostGraphile — borrows pool for query execution, never ends it
19+
pgsql-test — manages its own pools independently (test-only)
20+
```
21+
22+
---
23+
24+
## pg-cache
25+
26+
**Package:** `postgres/pg-cache/src/`
27+
28+
### Pool Creation (`pg.ts`)
29+
30+
`getPgPool(pgConfig)` is the only entry point for obtaining a pool at runtime:
31+
32+
1. Resolve config via `getPgEnvOptions(pgConfig)`.
33+
2. Check cache by database name — return cached pool if present.
34+
3. Create `new pg.Pool({ connectionString })`.
35+
4. Attach a pool-level `error` handler for idle-connection errors
36+
(e.g. `57P01 admin_shutdown` during database teardown).
37+
5. Store in cache, keyed by database name.
38+
39+
Pools are created with default `pg.Pool` settings (max 10 connections).
40+
41+
### Pool Storage (`lru.ts`)
42+
43+
`PgPoolCacheManager` wraps each pool in a `ManagedPgPool` object and stores
44+
them in an LRU cache:
45+
46+
| Setting | Value |
47+
|-------------------|------------|
48+
| `max` | 10 |
49+
| `ttl` | ~1 year |
50+
| `updateAgeOnGet` | true |
51+
52+
When an entry is evicted (LRU pressure, TTL expiry, or manual deletion):
53+
54+
1. `notifyCleanup(key)` — fires registered cleanup callbacks so downstream
55+
caches (graphile-cache) can react.
56+
2. `disposePool(managedPool)` — calls `pool.end()` to close all connections.
57+
58+
### Cleanup Callbacks
59+
60+
`pgCache.registerCleanupCallback(fn)` lets downstream caches subscribe to
61+
pool disposal events. graphile-cache uses this to evict PostGraphile instances
62+
whose underlying pool has been disposed.
63+
64+
### Shutdown
65+
66+
`pgCache.close()`:
67+
68+
1. `clear()` — evicts all entries, triggering cleanup callbacks and
69+
`pool.end()` for each.
70+
2. `waitForDisposals()` — awaits all pending `pool.end()` promises.
71+
72+
A SIGTERM handler calls `close()` automatically for graceful pod shutdown.
73+
74+
`teardownPgPools()` is an alias for `close()`.
75+
76+
---
77+
78+
## graphile-cache
79+
80+
**Package:** `graphile/graphile-cache/src/`
81+
82+
### Instance Cache (`graphile-cache.ts`)
83+
84+
`graphileCache` is an LRU cache of `GraphileCacheEntry` objects (PostGraphile
85+
instance + grafserv handler + HTTP server):
86+
87+
| Setting | Default (prod) | Default (dev) |
88+
|-------------------|-----------------|----------------|
89+
| `max` | 15 | 15 |
90+
| `ttl` | ~1 year | 5 minutes |
91+
| `updateAgeOnGet` | true | true |
92+
93+
Configurable via `GRAPHILE_CACHE_MAX` and `GRAPHILE_CACHE_TTL_MS` env vars.
94+
95+
### Instance Disposal
96+
97+
When a cache entry is evicted, `disposeEntry(entry, key)` runs asynchronously:
98+
99+
1. Close the entry's HTTP server (if listening).
100+
2. Call `pgl.release()` on the PostGraphile instance.
101+
102+
`pgl.release()` triggers PostGraphile's internal cleanup chain:
103+
104+
- Each pgService calls `service.release()`.
105+
- `service.release()` releases the `PgSubscriber` (frees the dedicated
106+
LISTEN/NOTIFY connection back to the pool).
107+
- **The pool itself is NOT ended**`makePgService` only adds `pool.end()`
108+
to its releasers when it created the pool internally. Since we pass an
109+
external pool from pg-cache, the pool survives instance disposal.
110+
111+
A `disposedKeys` set prevents double-disposal when multiple paths trigger
112+
cleanup for the same entry.
113+
114+
### Cascade from pg-cache
115+
116+
graphile-cache registers a cleanup callback with pg-cache:
117+
118+
```
119+
pgCache pool disposed → cleanup callback fires →
120+
graphile-cache evicts any entries whose cacheKey contains the pool key →
121+
disposeEntry() releases PostGraphile instance
122+
```
123+
124+
This ensures PostGraphile instances don't outlive their underlying pool.
125+
126+
### `closeAllCaches()`
127+
128+
Called during server shutdown:
129+
130+
1. Dispose all graphile entries (await all).
131+
2. Clear graphile cache.
132+
3. Call `pgCache.close()` — ends all pools.
133+
134+
---
135+
136+
## PostGraphile / @dataplan/pg Internals
137+
138+
### `makePgService({ pool, schemas })`
139+
140+
When an external pool is passed:
141+
142+
- Stores it in `adaptorSettings.pool` (accessible on the resolved preset).
143+
- Creates a `PgSubscriber(pool)` for LISTEN/NOTIFY — acquires one dedicated
144+
connection from the pool.
145+
- `service.release()` releases the PgSubscriber but does **not** call
146+
`pool.end()`.
147+
148+
When no pool is passed (connection string only):
149+
150+
- Creates an internal pool via `new pg.Pool(...)`.
151+
- `service.release()` calls `pool.end()` on the internally-created pool.
152+
153+
### `PgSubscriber`
154+
155+
Holds one dedicated connection from the pool for aggregated LISTEN/NOTIFY.
156+
On `release()`: UNLISTENs all topics, releases the client back to the pool.
157+
158+
### Query Execution
159+
160+
For each GraphQL request, PostGraphile's `withPgClient`:
161+
162+
1. `pool.connect()` — borrow a connection.
163+
2. Optionally set `pgSettings` via `set_config(...)` in a transaction.
164+
3. Execute the query plan.
165+
4. `pgClient.release()` — return connection to the pool.
166+
167+
---
168+
169+
## Server (graphql/server/src/server.ts)
170+
171+
### Startup
172+
173+
1. Create Express app with middleware pipeline.
174+
2. `addEventListener()``getPgPool(opts.pg)``pool.connect()` → hold
175+
a dedicated client for `LISTEN "schema:update"`.
176+
3. `listen()` — start HTTP server.
177+
178+
### Schema Update Notifications
179+
180+
The dedicated LISTEN client receives `schema:update` notifications (fired
181+
when a tenant's schema changes). On notification:
182+
183+
1. `flushService(databaseId)` — deletes matching entries from `graphileCache`
184+
and `svcCache`.
185+
2. Cache eviction triggers `disposeEntry()``pgl.release()`.
186+
3. Next request for that tenant rebuilds the PostGraphile instance.
187+
188+
### Shutdown (`close()`)
189+
190+
1. `removeEventListener()` — UNLISTEN, release dedicated client.
191+
2. Close HTTP server.
192+
3. `closeAllCaches()`:
193+
- Dispose all graphile entries (releases PgSubscribers).
194+
- Clear graphile cache.
195+
- `pgCache.close()` — end all pools.
196+
197+
Resources are released before pools are ended.
198+
199+
---
200+
201+
## Middleware Connection Patterns
202+
203+
### api.ts — Tenant Resolution
204+
205+
Uses `getPgPool(config)` and `pool.query(SQL)` directly for metadata queries
206+
(domain lookup, RLS settings, CORS, auth settings, database settings, etc.).
207+
208+
`pool.query()` is a convenience method that internally does
209+
`pool.connect()` → query → `client.release()`. This is the correct pattern
210+
for simple one-shot SELECT queries that don't need transaction management
211+
or pgSettings injection.
212+
213+
These queries run as the pool's default user (the connection string user),
214+
which is intentional for metadata/service queries.
215+
216+
### graphile.ts — PostGraphile Instance Creation
217+
218+
1. `getPgPool(pgConfig)` — obtain/create cached pool for the tenant database.
219+
2. `buildPreset(pool, schemas, ...)` — embed pool via
220+
`makePgService({ pool, schemas })`.
221+
3. `createGraphileInstance({ preset, cacheKey })` — create PostGraphile
222+
instance, grafserv handler, HTTP server.
223+
4. Store in `graphileCache`.
224+
225+
The pool flows from pg-cache → preset → PostGraphile. PostGraphile borrows
226+
connections from the pool for each GraphQL request and releases them when
227+
the request completes.
228+
229+
### flush.ts — Cache Invalidation
230+
231+
`/flush` endpoint and `flushService()` delete entries from `graphileCache`
232+
and `svcCache`. Deletion triggers the LRU dispose callback which runs
233+
`disposeEntry()` asynchronously.
234+
235+
`flushService()` also calls `getPgPool()` to query the `domains` table
236+
for additional cache keys to invalidate.
237+
238+
---
239+
240+
## Explorer (graphql/explorer/src/server.ts)
241+
242+
Same pattern as the main server:
243+
244+
1. `getPgPool(config)` for the tenant database.
245+
2. `makePgService({ pool, schemas })` → preset.
246+
3. `createGraphileInstance({ preset, cacheKey })`.
247+
4. Also uses `getPgPool()` directly for schema listing and connectivity
248+
checks.
249+
250+
No explicit shutdown handler — relies on pg-cache's SIGTERM handler.
251+
252+
---
253+
254+
## Test Infrastructure (postgres/pgsql-test/)
255+
256+
Test infrastructure manages its own pools independently of pg-cache.
257+
258+
### PgTestConnector (`manager.ts`)
259+
260+
- Singleton per test run.
261+
- Creates pools via `new Pool(config)` directly (not `getPgPool()`).
262+
- Tracks pools in its own `Map<string, Pool>`.
263+
- `closeAll()`: close test clients → `pool.end()` for each pool → drop
264+
test databases.
265+
266+
### getConnections (`connect.ts`)
267+
268+
- Creates a temporary test database.
269+
- Returns test clients and a `teardown()` function.
270+
- `teardown()`:
271+
1. `manager.beginTeardown()` — prevents new client creation.
272+
2. `teardownPgPools()` — flushes any pg-cache pools that may exist in
273+
the test process.
274+
3. `manager.closeAll()` — closes test pools and drops the database.
275+
276+
---
277+
278+
## Connection Accounting (per tenant)
279+
280+
Each active tenant consumes these connections from its pool:
281+
282+
| Consumer | Connections | Lifetime |
283+
|------------------------------|------------:|-----------------------|
284+
| PgSubscriber (LISTEN/NOTIFY) | 1 | PostGraphile instance |
285+
| Server LISTEN client | 1 | Server process* |
286+
| GraphQL query execution | 1 per req | Request duration |
287+
| api.ts metadata queries | 1 per query | Query duration |
288+
289+
\* The server LISTEN client uses the services database pool, not tenant pools.
290+
291+
With the default pool size of 10, each tenant pool has ~9 connections
292+
available for concurrent queries (1 held by PgSubscriber).
293+
294+
---
295+
296+
## Disposal Sequence Diagram
297+
298+
```
299+
Server shutdown
300+
301+
├─ removeEventListener()
302+
│ └─ UNLISTEN "schema:update"
303+
│ └─ client.release() → connection returned to pool
304+
305+
├─ httpServer.close()
306+
307+
└─ closeAllCaches()
308+
309+
├─ For each graphile entry:
310+
│ disposeEntry()
311+
│ ├─ httpServer.close()
312+
│ └─ pgl.release()
313+
│ └─ service.release()
314+
│ └─ PgSubscriber.release()
315+
│ ├─ UNLISTEN all topics
316+
│ └─ client.release() → connection returned to pool
317+
318+
├─ graphileCache.clear()
319+
320+
└─ pgCache.close()
321+
322+
├─ For each pool:
323+
│ notifyCleanup(key) → graphile-cache callback (no-op, already cleared)
324+
│ pool.end() → closes all connections
325+
326+
└─ await all pool.end() promises
327+
```

0 commit comments

Comments
 (0)