mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 08:53:22 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae37a3a8c3 |
@@ -221,6 +221,37 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
|
||||
checks.push({ name: 'pgvector', status: 'warn', message: 'Could not check pgvector extension' });
|
||||
}
|
||||
|
||||
// 4b. PgBouncer / prepared statement compatibility
|
||||
try {
|
||||
const { resolvePrepare } = await import('../core/db.ts');
|
||||
const config = (await import('../core/config.ts')).loadConfig();
|
||||
const url = config?.database_url || '';
|
||||
const prepare = resolvePrepare(url);
|
||||
if (prepare === false) {
|
||||
checks.push({
|
||||
name: 'pgbouncer_prepare',
|
||||
status: 'ok',
|
||||
message: 'Prepared statements disabled (PgBouncer-safe)',
|
||||
});
|
||||
} else {
|
||||
// Check if we're on a PgBouncer port but prepare is NOT disabled
|
||||
try {
|
||||
const parsed = new URL(url.replace(/^postgres(ql)?:\/\//, 'http://'));
|
||||
if (parsed.port === '6543') {
|
||||
checks.push({
|
||||
name: 'pgbouncer_prepare',
|
||||
status: 'warn',
|
||||
message: 'Port 6543 (PgBouncer) detected but prepared statements are enabled. ' +
|
||||
'This causes "prepared statement does not exist" errors under concurrent load. ' +
|
||||
'Fix: set GBRAIN_PREPARE=false or add ?prepare=false to the connection URL.',
|
||||
});
|
||||
}
|
||||
} catch { /* URL parse failure, skip */ }
|
||||
}
|
||||
} catch {
|
||||
// Best-effort, don't fail doctor
|
||||
}
|
||||
|
||||
// 5. RLS
|
||||
try {
|
||||
const sql = db.getConnection();
|
||||
|
||||
+59
-2
@@ -13,6 +13,54 @@ let connectedUrl: string | null = null;
|
||||
*/
|
||||
const DEFAULT_POOL_SIZE_FALLBACK = 10;
|
||||
|
||||
/**
|
||||
* Well-known PgBouncer transaction-pooling ports. When the connection URL
|
||||
* targets one of these ports, prepared statements are disabled automatically
|
||||
* because transaction-mode PgBouncer can route successive queries to different
|
||||
* backend connections, breaking connection-scoped prepared statements.
|
||||
*
|
||||
* Override with `GBRAIN_PREPARE=true` or `?prepare=true` in the URL to force
|
||||
* prepared statements even on these ports.
|
||||
*/
|
||||
const PGBOUNCER_PORTS = new Set(['6543', '5432' /* Supabase pooler, common alt */]);
|
||||
// Only 6543 is auto-detected; 5432 could be direct Postgres. Be conservative.
|
||||
const AUTO_DETECT_PORTS = new Set(['6543']);
|
||||
|
||||
/**
|
||||
* Determine whether to disable prepared statements.
|
||||
*
|
||||
* Precedence:
|
||||
* 1. `GBRAIN_PREPARE` env var (explicit override)
|
||||
* 2. `?prepare=` query param in the URL (explicit per-URL)
|
||||
* 3. Auto-detect: port 6543 → disable (Supabase PgBouncer convention)
|
||||
* 4. Default: leave undefined (postgres.js default = enabled)
|
||||
*/
|
||||
export function resolvePrepare(url: string): boolean | undefined {
|
||||
// 1. Env var override
|
||||
const envPrepare = process.env.GBRAIN_PREPARE;
|
||||
if (envPrepare === 'false' || envPrepare === '0') return false;
|
||||
if (envPrepare === 'true' || envPrepare === '1') return true;
|
||||
|
||||
// 2. URL query param (postgres.js already handles this, but we check
|
||||
// explicitly so we can log the auto-detect decision accurately)
|
||||
try {
|
||||
const parsed = new URL(url.replace(/^postgres(ql)?:\/\//, 'http://'));
|
||||
const urlPrepare = parsed.searchParams.get('prepare');
|
||||
if (urlPrepare === 'false') return false;
|
||||
if (urlPrepare === 'true') return true;
|
||||
|
||||
// 3. Auto-detect PgBouncer transaction mode via port
|
||||
if (AUTO_DETECT_PORTS.has(parsed.port)) {
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
// URL parse failure — don't guess, use default
|
||||
}
|
||||
|
||||
// 4. Default: let postgres.js decide (prepared statements enabled)
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolvePoolSize(explicit?: number): number {
|
||||
if (typeof explicit === 'number' && explicit > 0) return explicit;
|
||||
const raw = process.env.GBRAIN_POOL_SIZE;
|
||||
@@ -53,7 +101,8 @@ export async function connect(config: EngineConfig): Promise<void> {
|
||||
}
|
||||
|
||||
try {
|
||||
sql = postgres(url, {
|
||||
const prepare = resolvePrepare(url);
|
||||
const opts: Record<string, unknown> = {
|
||||
max: resolvePoolSize(),
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10,
|
||||
@@ -61,7 +110,15 @@ export async function connect(config: EngineConfig): Promise<void> {
|
||||
// Register pgvector type
|
||||
bigint: postgres.BigInt,
|
||||
},
|
||||
});
|
||||
};
|
||||
// Only set prepare when we have an explicit decision; undefined = postgres.js default
|
||||
if (typeof prepare === 'boolean') {
|
||||
opts.prepare = prepare;
|
||||
if (!prepare) {
|
||||
console.warn('[gbrain] Prepared statements disabled (PgBouncer transaction pooling detected on port 6543). Override with GBRAIN_PREPARE=true if using session mode.');
|
||||
}
|
||||
}
|
||||
sql = postgres(url, opts);
|
||||
|
||||
// Test connection
|
||||
await sql`SELECT 1`;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { resolvePrepare } from '../src/core/db.ts';
|
||||
|
||||
describe('resolvePrepare', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
afterEach(() => {
|
||||
// Restore env
|
||||
delete process.env.GBRAIN_PREPARE;
|
||||
Object.assign(process.env, originalEnv);
|
||||
});
|
||||
|
||||
it('returns false for Supabase pooler port 6543', () => {
|
||||
expect(resolvePrepare('postgresql://user:pass@host:6543/db')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns undefined for direct Postgres port 5432', () => {
|
||||
expect(resolvePrepare('postgresql://user:pass@host:5432/db')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for default port (no port specified)', () => {
|
||||
expect(resolvePrepare('postgresql://user:pass@host/db')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('respects ?prepare=false in URL', () => {
|
||||
expect(resolvePrepare('postgresql://user:pass@host:5432/db?prepare=false')).toBe(false);
|
||||
});
|
||||
|
||||
it('respects ?prepare=true in URL even on port 6543', () => {
|
||||
expect(resolvePrepare('postgresql://user:pass@host:6543/db?prepare=true')).toBe(true);
|
||||
});
|
||||
|
||||
it('GBRAIN_PREPARE=false overrides everything', () => {
|
||||
process.env.GBRAIN_PREPARE = 'false';
|
||||
expect(resolvePrepare('postgresql://user:pass@host:5432/db?prepare=true')).toBe(false);
|
||||
});
|
||||
|
||||
it('GBRAIN_PREPARE=true overrides auto-detect on 6543', () => {
|
||||
process.env.GBRAIN_PREPARE = 'true';
|
||||
expect(resolvePrepare('postgresql://user:pass@host:6543/db')).toBe(true);
|
||||
});
|
||||
|
||||
it('GBRAIN_PREPARE=0 is falsy', () => {
|
||||
process.env.GBRAIN_PREPARE = '0';
|
||||
expect(resolvePrepare('postgresql://user:pass@host:6543/db')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns undefined for malformed URL', () => {
|
||||
expect(resolvePrepare('not-a-url')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles postgres:// scheme (no ql)', () => {
|
||||
expect(resolvePrepare('postgres://user:pass@host:6543/db')).toBe(false);
|
||||
});
|
||||
|
||||
it('handles URL with special chars in password', () => {
|
||||
expect(resolvePrepare('postgresql://user:p%40ss$word@host:6543/db')).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user