fix: v0.15.4 — PgBouncer prepare:false for Supabase transaction pooler (closes #284, #286, #270) (#301)
* fix(migrate): v0_13_0 shells out to `gbrain` shim, not `process.execPath` On bun-installed trees, process.execPath is the bun runtime itself. `bun extract links ...` got reinterpreted as `bun run extract` and crashed the upgrade mid-Phase B. The canonical shim on PATH already wraps the right runtime+entrypoint; trust it. Regression-guarded by test/migrations-v0_13_0.test.ts which greps the source for `process.execPath` and `bun` invocations. This was Bug 1 of tonight's v0.13 → v0.14 upgrade-night postmortem. * fix(autopilot): resolveGbrainCliPath prefers shim, never returns .ts argv[1] check used to short-circuit on /cli.ts, so bun-source installs got a .ts path back. spawn() then failed EACCES because TypeScript source isn't executable, and autopilot silently lost its worker. Reordered probes: which gbrain (shim) first, then compiled execPath, then argv[1] only if it ends in /gbrain. Deleted the .ts branch entirely — no valid case exists. Rewrote the existing test that enshrined the buggy .ts return. Critical regression guard: resolver MUST NEVER return a .ts path across any combination of argv[1] + execPath + shim availability. This was Bug 4 of tonight's v0.13 → v0.14 upgrade-night postmortem. * chore: bump version and changelog (v0.15.3) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(db): resolvePrepare() helper for PgBouncer transaction-mode pools Adds port-6543 auto-detect with a 4-level precedence chain: GBRAIN_PREPARE env var → ?prepare= URL param → port auto-detect → default. Wires into the module-singleton connect() so the main CLI path no longer hits "prepared statement does not exist" against Supabase transaction pooler. Returns boolean | undefined; undefined means omit the option and let postgres.js default (true) stand. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(postgres-engine): honor resolvePrepare in worker-instance pool Without this, \`gbrain jobs work\` against a Supabase pooler URL hits "prepared statement does not exist" under load even after the module singleton was fixed in db.ts. Community PR #270 (@notjbg) caught this second path that #284 had missed. Reuses the shared helper, no regex duplication. Co-Authored-By: Jonah Berg <jonah.berg.g@gmail.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(doctor): pgbouncer_prepare check URL-only check (no DB roundtrip) that reads the configured URL via loadConfig() and flags the footgun: port 6543 with prepared statements still enabled. Warns with the exact env override (GBRAIN_PREPARE=false) and URL-query alternative (?prepare=false). Works for both the module singleton and worker-instance engines. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: resolvePrepare precedence matrix + postgres-engine wiring guard - test/resolve-prepare.test.ts: 11 cases covering env override, URL query param, port auto-detect, malformed URLs, postgres:// scheme, URL-encoded credentials. Uses bun:test — #284's original vitest file would never have run in this project. - test/postgres-engine.test.ts: new source-level grep case asserting the worker-pool connect() branch calls db.resolvePrepare(url) and includes a typeof prepare === 'boolean' check. Mirrors the existing SET LOCAL regression guard. If anyone rips out the wiring, the build fails before shipping starts dropping rows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.15.4) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Jonah Berg <jonah.berg.g@gmail.com>
This commit is contained in:
@@ -44,30 +44,35 @@ function logError(phase: string, e: unknown) {
|
||||
/**
|
||||
* Resolve the gbrain CLI entrypoint for spawning the worker child.
|
||||
*
|
||||
* Codex caught the bug in earlier plan drafts: `process.execPath` is the
|
||||
* Bun (or Node) runtime binary on source installs, not `gbrain`. Blindly
|
||||
* using it would spawn `bun jobs work`, which does not work.
|
||||
* A .ts source path is never a valid spawn target — spawning it fails with
|
||||
* EACCES because TypeScript source isn't executable. The canonical install
|
||||
* puts a shim at `/usr/local/bin/gbrain` (or wherever `which gbrain`
|
||||
* resolves to) that already wraps the right runtime+entrypoint; prefer it.
|
||||
*
|
||||
* Order of resolution:
|
||||
* 1. argv[1] if it clearly points at a gbrain entry (cli.ts or /gbrain).
|
||||
* 2. process.execPath when running as the compiled binary.
|
||||
* 3. `which gbrain` for installs where the binary is on $PATH.
|
||||
* 4. Throw — nothing on $PATH, no way to supervise the worker.
|
||||
* 1. `which gbrain` — the shim on PATH, canonical for installed builds.
|
||||
* 2. process.execPath if it ends with /gbrain (compiled binary, no shim).
|
||||
* 3. argv[1] if it ends with /gbrain (e.g., direct invocation of compiled
|
||||
* binary without PATH). Never .ts source paths.
|
||||
* 4. Throw with a clear install hint.
|
||||
*/
|
||||
export function resolveGbrainCliPath(): string {
|
||||
const arg1 = process.argv[1] ?? '';
|
||||
if (arg1.endsWith('/gbrain') || arg1.endsWith('/cli.ts') || arg1.endsWith('\\gbrain.exe')) {
|
||||
return arg1;
|
||||
}
|
||||
try {
|
||||
const which = execSync('which gbrain', { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
||||
if (which) return which;
|
||||
} catch { /* not on $PATH — fall through */ }
|
||||
|
||||
const exec = process.execPath ?? '';
|
||||
if (exec.endsWith('/gbrain') || exec.endsWith('\\gbrain.exe')) {
|
||||
return exec;
|
||||
}
|
||||
try {
|
||||
const which = execSync('which gbrain', { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
||||
if (which) return which;
|
||||
} catch { /* not on $PATH */ }
|
||||
throw new Error('Could not resolve the gbrain CLI path. Install gbrain so it is on $PATH, or run autopilot from the compiled binary directly.');
|
||||
|
||||
const arg1 = process.argv[1] ?? '';
|
||||
if (arg1.endsWith('/gbrain') || arg1.endsWith('\\gbrain.exe')) {
|
||||
return arg1;
|
||||
}
|
||||
|
||||
throw new Error('Could not resolve the gbrain CLI path. Install gbrain so it is on $PATH (e.g. /usr/local/bin/gbrain), or run autopilot from the compiled binary directly.');
|
||||
}
|
||||
|
||||
export async function runAutopilot(engine: BrainEngine, args: string[]) {
|
||||
|
||||
@@ -237,6 +237,44 @@ 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.
|
||||
// URL-only inspection — no DB roundtrip — so this is cheap and works
|
||||
// regardless of whether the caller is the module singleton or a
|
||||
// worker-instance engine.
|
||||
progress.heartbeat('pgbouncer_prepare');
|
||||
try {
|
||||
const { resolvePrepare } = await import('../core/db.ts');
|
||||
const { loadConfig } = await import('../core/config.ts');
|
||||
const config = 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 {
|
||||
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 transaction mode) detected but prepared statements are enabled. ' +
|
||||
'This causes "prepared statement does not exist" errors under concurrent load. ' +
|
||||
'Fix: unset GBRAIN_PREPARE (or set =false), or add ?prepare=false to the connection URL.',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// URL parse failure — skip, nothing actionable
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// best-effort; never fail doctor on this check
|
||||
}
|
||||
|
||||
// 5. RLS
|
||||
progress.heartbeat('rls');
|
||||
try {
|
||||
|
||||
@@ -36,17 +36,18 @@ import type { Migration, OrchestratorOpts, OrchestratorResult, OrchestratorPhase
|
||||
// and swaps the unique constraint. Schema build time on 46K pages is
|
||||
// ~10s (ALTER + index builds). Bumped timeout accounts for slow Supabase
|
||||
// links (v0.12.1 pattern — migrations can time out on the 60s default).
|
||||
// Use the CURRENTLY-RUNNING binary path (not `gbrain` off $PATH). After
|
||||
// `gbrain upgrade` rewrites the binary, a bare `gbrain` could resolve to
|
||||
// an older installed copy via alias shadowing or stale PATH cache. The
|
||||
// active process.execPath is the one that loaded THIS migration module,
|
||||
// so recursing into it is always the right binary.
|
||||
const GBRAIN = process.execPath;
|
||||
//
|
||||
// Shell out to the canonical `gbrain` shim on PATH (`/usr/local/bin/gbrain`
|
||||
// by default). An earlier revision resolved via the active Node/Bun runtime
|
||||
// binary, but on bun-installed trees that binary is `bun` — the spawned
|
||||
// `bun extract ...` gets reinterpreted as `bun run extract` and crashes the
|
||||
// upgrade mid-migration. The shim is already the canonical wrapper; trust
|
||||
// it. Regression guarded by test/migrations-v0_13_0.test.ts.
|
||||
|
||||
function phaseASchema(opts: OrchestratorOpts): OrchestratorPhaseResult {
|
||||
if (opts.dryRun) return { name: 'schema', status: 'skipped', detail: 'dry-run' };
|
||||
try {
|
||||
execSync(`${GBRAIN} init --migrate-only`, { stdio: 'inherit', timeout: 600_000, env: process.env });
|
||||
execSync('gbrain init --migrate-only', { stdio: 'inherit', timeout: 600_000, env: process.env });
|
||||
return { name: 'schema', status: 'complete' };
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
@@ -63,7 +64,7 @@ function phaseBBackfill(opts: OrchestratorOpts): OrchestratorPhaseResult {
|
||||
// `--include-frontmatter` is the v0.13 flag that enables the canonical
|
||||
// frontmatter link extractor. Default-OFF in the CLI for back-compat;
|
||||
// the migration explicitly opts in because this is the canonical backfill.
|
||||
execSync(`${GBRAIN} extract links --source db --include-frontmatter`, {
|
||||
execSync('gbrain extract links --source db --include-frontmatter', {
|
||||
stdio: 'inherit',
|
||||
timeout: 1_800_000, // 30 min hard cap; typical 2-5 min on 46K pages
|
||||
env: process.env,
|
||||
@@ -88,7 +89,7 @@ function phaseCVerify(opts: OrchestratorOpts): OrchestratorPhaseResult {
|
||||
// docs-only brains, and brains with no entity pages legitimately
|
||||
// produce 0. Phase B's own stdout shows `Links: created N` which is
|
||||
// the authoritative signal — user sees it during upgrade.
|
||||
const out = execSync(`${GBRAIN} call get_stats`, {
|
||||
const out = execSync('gbrain call get_stats', {
|
||||
encoding: 'utf-8', timeout: 60_000, env: process.env,
|
||||
});
|
||||
const parsed = JSON.parse(out) as { link_count?: number; page_count?: number };
|
||||
|
||||
@@ -13,6 +13,54 @@ let connectedUrl: string | null = null;
|
||||
*/
|
||||
const DEFAULT_POOL_SIZE_FALLBACK = 10;
|
||||
|
||||
/**
|
||||
* Supabase PgBouncer transaction-mode convention: port 6543 routes through
|
||||
* PgBouncer, which recycles the backend connection between queries and
|
||||
* invalidates per-client prepared-statement caches. On that port postgres.js
|
||||
* defaults (prepare=true) surface as `prepared statement "..." does not exist`
|
||||
* under sustained load and silently drop rows during sync.
|
||||
*
|
||||
* This is a heuristic, not a protocol guarantee. A direct-Postgres server
|
||||
* deliberately bound to 6543 will also get `prepare: false`; the
|
||||
* `GBRAIN_PREPARE=true` env var (or `?prepare=true` on the URL) is the
|
||||
* documented escape hatch.
|
||||
*/
|
||||
const AUTO_DETECT_PORTS = new Set(['6543']);
|
||||
|
||||
/**
|
||||
* Decide whether to force `prepare: true`/`false` on the postgres.js client.
|
||||
*
|
||||
* Precedence:
|
||||
* 1. `GBRAIN_PREPARE` env var (`true`/`1` or `false`/`0`)
|
||||
* 2. `?prepare=true|false` query param on the URL
|
||||
* 3. Auto-detect: port 6543 → `false`
|
||||
* 4. Default: `undefined` (caller omits the option; postgres.js default stands)
|
||||
*
|
||||
* Returns `boolean | undefined`. `undefined` is meaningful — callers MUST
|
||||
* omit the `prepare` key entirely in that case rather than passing
|
||||
* `undefined` through to `postgres(url, {prepare: undefined})`.
|
||||
*/
|
||||
export function resolvePrepare(url: string): boolean | undefined {
|
||||
const envPrepare = process.env.GBRAIN_PREPARE;
|
||||
if (envPrepare === 'false' || envPrepare === '0') return false;
|
||||
if (envPrepare === 'true' || envPrepare === '1') return true;
|
||||
|
||||
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;
|
||||
|
||||
if (AUTO_DETECT_PORTS.has(parsed.port)) {
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
// URL parse failure — fall through to default
|
||||
}
|
||||
|
||||
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,16 @@ export async function connect(config: EngineConfig): Promise<void> {
|
||||
// Register pgvector type
|
||||
bigint: postgres.BigInt,
|
||||
},
|
||||
});
|
||||
};
|
||||
if (typeof prepare === 'boolean') {
|
||||
opts.prepare = prepare;
|
||||
if (!prepare) {
|
||||
console.warn(
|
||||
'[gbrain] Prepared statements disabled (PgBouncer transaction-mode convention on port 6543). Override with GBRAIN_PREPARE=true if your pooler runs in session mode.',
|
||||
);
|
||||
}
|
||||
}
|
||||
sql = postgres(url, opts);
|
||||
|
||||
// Test connection
|
||||
await sql`SELECT 1`;
|
||||
|
||||
@@ -38,12 +38,21 @@ export class PostgresEngine implements BrainEngine {
|
||||
const url = config.database_url;
|
||||
if (!url) throw new GBrainError('No database URL', 'database_url is missing', 'Provide --url');
|
||||
const size = Math.min(config.poolSize, db.resolvePoolSize(config.poolSize));
|
||||
this._sql = postgres(url, {
|
||||
// Honor PgBouncer transaction-mode detection on worker-instance pools too.
|
||||
// Without this, `gbrain jobs work` against a Supabase pooler URL hits
|
||||
// "prepared statement does not exist" under load just like the module
|
||||
// singleton did before v0.15.4.
|
||||
const prepare = db.resolvePrepare(url);
|
||||
const opts: Record<string, unknown> = {
|
||||
max: size,
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10,
|
||||
types: { bigint: postgres.BigInt },
|
||||
});
|
||||
};
|
||||
if (typeof prepare === 'boolean') {
|
||||
opts.prepare = prepare;
|
||||
}
|
||||
this._sql = postgres(url, opts);
|
||||
await this._sql`SELECT 1`;
|
||||
} else {
|
||||
// Module-level singleton (backward compat for CLI main engine)
|
||||
|
||||
Reference in New Issue
Block a user