Compare commits

...
Author SHA1 Message Date
rootandWintermute ae37a3a8c3 fix: auto-detect PgBouncer transaction mode and disable prepared statements (#260)
When the connection URL targets port 6543 (Supabase PgBouncer convention),
gbrain now automatically sets prepare=false on the postgres.js connection.
This prevents 'prepared statement does not exist' errors that silently drop
pages during sync when multiple gbrain processes (autopilot, jobs worker,
sync) compete for pooled connections.

Precedence chain:
  1. GBRAIN_PREPARE env var (explicit override)
  2. ?prepare= query param in URL
  3. Auto-detect: port 6543 → disable
  4. Default: postgres.js default (enabled)

Also adds:
- Doctor check: warns if port 6543 detected with prepare still enabled
- resolvePrepare() exported for testability
- 11 unit tests covering all precedence paths

Root cause: PgBouncer in transaction mode multiplexes clients across backend
connections. Prepared statements are connection-scoped in Postgres, so a
statement prepared on connection A fails when routed to connection B.

Discovered during batch enrichment of 4,500+ brain pages where sync silently
skipped pages with 'prepared statement ql7ganp7eyh25 does not exist'.

Co-Authored-By: Wintermute <wintermute@openclaw.ai>
2026-04-20 21:15:43 +00:00
3 changed files with 149 additions and 2 deletions
+31
View File
@@ -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
View File
@@ -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`;
+59
View File
@@ -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);
});
});