mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 19:49:14 +00:00
fix(db): bound pool disconnect so teardown can't eat CLI output (#1972)
pool.end() against PgBouncer transaction-mode never drained, so disconnect blocked until the CLI's 10s force-exit fired and process.exit()'d mid-write, truncating stdout (e.g. #1959's relational query returned empty). Add a gbrain-owned endPoolBounded(pool): Promise.race of pool.end({timeout}) against a hard timer, so teardown is bounded regardless of what postgres.js does and is testable. connection-manager ends its direct + read pools concurrently so the per-pool bounds don't stack. PGLite disconnect is unaffected.
This commit is contained in:
@@ -37,7 +37,7 @@
|
||||
*/
|
||||
|
||||
import postgres from 'postgres';
|
||||
import { resolvePrepare, resolveSessionTimeouts, resolvePoolSize } from './db.ts';
|
||||
import { resolvePrepare, resolveSessionTimeouts, resolvePoolSize, endPoolBounded } from './db.ts';
|
||||
import { redactPgUrl } from './url-redact.ts';
|
||||
import { logConnectionEvent } from './connection-audit.ts';
|
||||
|
||||
@@ -400,15 +400,21 @@ export class ConnectionManager {
|
||||
* (db.ts singleton path). Direct pool is always ours.
|
||||
*/
|
||||
async disconnect(): Promise<void> {
|
||||
// #1972: end both pools concurrently with a gbrain-owned hard bound, so the
|
||||
// per-pool drains don't STACK (sequential 2s waits → ~4-6s once the
|
||||
// instance/module pool is added downstream) and neither can hang teardown
|
||||
// past the CLI's 10s force-exit. endPoolBounded never throws.
|
||||
const ends: Promise<void>[] = [];
|
||||
if (this._directPool) {
|
||||
try { await this._directPool.end(); } catch { /* idempotent */ }
|
||||
ends.push(endPoolBounded(this._directPool));
|
||||
this._directPool = null;
|
||||
this._directInit = null;
|
||||
}
|
||||
if (this._readPool && !this._readPoolOwnedExternally) {
|
||||
try { await this._readPool.end(); } catch { /* idempotent */ }
|
||||
ends.push(endPoolBounded(this._readPool));
|
||||
this._readPool = null;
|
||||
}
|
||||
await Promise.all(ends);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+42
-1
@@ -7,6 +7,47 @@ import { verifySchema } from './schema-verify.ts';
|
||||
let sql: ReturnType<typeof postgres> | null = null;
|
||||
let connectedUrl: string | null = null;
|
||||
|
||||
/**
|
||||
* #1972: hard upper bound (seconds) on a single pool `.end()` drain. postgres.js
|
||||
* accepts `{ timeout }` but applies it internally — against PgBouncer
|
||||
* transaction-mode the drain can still hang, and a stubbed `.end()` ignores it
|
||||
* entirely. So `endPoolBounded` ALSO wraps each end in a gbrain-owned
|
||||
* Promise.race and passes this value as the postgres.js hint so a healthy drain
|
||||
* still finishes fast.
|
||||
*/
|
||||
export const POOL_END_TIMEOUT_SECONDS = 2;
|
||||
|
||||
/**
|
||||
* #1972: end a postgres.js pool with a gbrain-owned hard bound. Resolves as soon
|
||||
* as `.end()` settles OR after POOL_END_TIMEOUT_SECONDS + a small slack — so
|
||||
* teardown never hangs (the prior bare `.end()` blocked until the CLI's 10s
|
||||
* force-exit fired, which `process.exit()`s and truncated pending stdout, e.g.
|
||||
* #1959's relational query came back empty). Never throws: a teardown that
|
||||
* rejects is worse than one that races past a stuck socket. The race timer is
|
||||
* the real guarantee; `{ timeout }` just lets a healthy drain return in ms.
|
||||
*
|
||||
* Note callers that close MULTIPLE pools should `Promise.all` them rather than
|
||||
* awaiting sequentially, so the per-pool bounds run concurrently instead of
|
||||
* stacking.
|
||||
*/
|
||||
export async function endPoolBounded(
|
||||
pool: { end: (opts?: { timeout?: number }) => Promise<void> },
|
||||
): Promise<void> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const guard = new Promise<void>((resolve) => {
|
||||
timer = setTimeout(resolve, POOL_END_TIMEOUT_SECONDS * 1000 + 500);
|
||||
timer.unref?.();
|
||||
});
|
||||
try {
|
||||
await Promise.race([
|
||||
pool.end({ timeout: POOL_END_TIMEOUT_SECONDS }).catch(() => { /* idempotent / already-closed */ }),
|
||||
guard,
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default pool size for Postgres connections. Users on the Supabase transaction
|
||||
* pooler (port 6543) or any multi-tenant pooler can lower this to avoid
|
||||
@@ -258,7 +299,7 @@ export async function disconnect(): Promise<void> {
|
||||
const s = sql;
|
||||
sql = null;
|
||||
connectedUrl = null;
|
||||
if (s) await s.end();
|
||||
if (s) await endPoolBounded(s);
|
||||
}
|
||||
|
||||
export async function initSchema(): Promise<void> {
|
||||
|
||||
@@ -248,7 +248,9 @@ export class PostgresEngine implements BrainEngine {
|
||||
this.connectionManager = null;
|
||||
}
|
||||
if (this._sql) {
|
||||
await this._sql.end();
|
||||
// #1972: gbrain-owned hard bound so a PgBouncer drain that never settles
|
||||
// can't block teardown until the CLI's 10s force-exit truncates stdout.
|
||||
await db.endPoolBounded(this._sql);
|
||||
this._sql = null;
|
||||
// After this point, _connectionStyle stays 'instance' so a second
|
||||
// disconnect() is a no-op rather than falling through and clearing
|
||||
|
||||
Reference in New Issue
Block a user