mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-07 08:37:52 +02:00
fix: detect PgBouncer transaction-mode pooler and set GBRAIN_PREPARE=true (#1435)
When gbrain connects through a PgBouncer transaction-mode pooler (port
6543), it auto-disables prepared statements. This breaks `gbrain search`
silently — the /sync-gbrain capability check fails and the GBrain Search
Guidance block never gets written to CLAUDE.md.
Three-layer fix:
1. **lib/gbrain-exec.ts** — `buildGbrainEnv()` now detects port 6543 in
the effective DATABASE_URL and sets `GBRAIN_PREPARE=true` in the env
passed to every gbrain spawn. This is the single chokepoint — all
gstack gbrain invocations inherit the fix. Caller can opt out with
`GBRAIN_PREPARE=false`.
2. **sync-gbrain/SKILL.md{,.tmpl}** — capability check now exports
`GBRAIN_PREPARE=true` explicitly and retries search up to 3x with 1s
delay for async index propagation under connection pooling.
3. **bin/gstack-gbrain-detect** — surfaces `gbrain_pooler_mode` field
("transaction" | "session" | null) in the preamble probe JSON so
/setup-gbrain and /sync-gbrain can advise users about pooler state.
Closes #1435
Built with [ClosedLoop.AI](https://closedloop.ai) | [GitHub](https://github.com/closedloop-ai/claude-plugins)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,7 +18,8 @@
|
|||||||
* "gstack_brain_sync_mode": "off"|"artifacts-only"|"full",
|
* "gstack_brain_sync_mode": "off"|"artifacts-only"|"full",
|
||||||
* "gstack_brain_git": true|false,
|
* "gstack_brain_git": true|false,
|
||||||
* "gstack_artifacts_remote": "https://..." | "",
|
* "gstack_artifacts_remote": "https://..." | "",
|
||||||
* "gbrain_local_status": "ok"|"no-cli"|"missing-config"|"broken-config"|"broken-db"
|
* "gbrain_local_status": "ok"|"no-cli"|"missing-config"|"broken-config"|"broken-db",
|
||||||
|
* "gbrain_pooler_mode": "transaction"|"session"|null
|
||||||
* }
|
* }
|
||||||
*
|
*
|
||||||
* Backward compatibility (per plan codex #5): the 9 pre-existing fields stay
|
* Backward compatibility (per plan codex #5): the 9 pre-existing fields stay
|
||||||
@@ -42,6 +43,7 @@ import {
|
|||||||
resolveGbrainBin,
|
resolveGbrainBin,
|
||||||
readGbrainVersion,
|
readGbrainVersion,
|
||||||
} from "../lib/gbrain-local-status";
|
} from "../lib/gbrain-local-status";
|
||||||
|
import { isTransactionModePooler } from "../lib/gbrain-exec";
|
||||||
|
|
||||||
const STATE_DIR = process.env.GSTACK_HOME || join(userHome(), ".gstack");
|
const STATE_DIR = process.env.GSTACK_HOME || join(userHome(), ".gstack");
|
||||||
const SCRIPT_DIR = __dirname;
|
const SCRIPT_DIR = __dirname;
|
||||||
@@ -98,6 +100,17 @@ function detectConfig(): { exists: boolean; engine: "pglite" | "postgres" | null
|
|||||||
return { exists: true, engine: null };
|
return { exists: true, engine: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- pooler mode detection (#1435) ---
|
||||||
|
//
|
||||||
|
// Reads DATABASE_URL from ~/.gbrain/config.json and checks whether it targets
|
||||||
|
// a PgBouncer transaction-mode pooler (port 6543). Surfaced so /sync-gbrain
|
||||||
|
// and /setup-gbrain can advise users when search may require GBRAIN_PREPARE.
|
||||||
|
function detectPoolerMode(): "transaction" | "session" | "unknown" | null {
|
||||||
|
const parsed = tryReadJSON(GBRAIN_CONFIG) as { database_url?: string } | null;
|
||||||
|
if (!parsed?.database_url) return null;
|
||||||
|
return isTransactionModePooler(parsed.database_url) ? "transaction" : "session";
|
||||||
|
}
|
||||||
|
|
||||||
// --- gbrain doctor health (any nonzero exit or non-"ok"/"warnings" status → false) ---
|
// --- gbrain doctor health (any nonzero exit or non-"ok"/"warnings" status → false) ---
|
||||||
//
|
//
|
||||||
// Uses --fast to avoid hanging on a dead DB. Per the local-status classifier
|
// Uses --fast to avoid hanging on a dead DB. Per the local-status classifier
|
||||||
@@ -215,6 +228,7 @@ function main(): void {
|
|||||||
gstack_brain_git: detectBrainGit(),
|
gstack_brain_git: detectBrainGit(),
|
||||||
gstack_artifacts_remote: detectArtifactsRemote(),
|
gstack_artifacts_remote: detectArtifactsRemote(),
|
||||||
gbrain_local_status: localEngineStatus({ noCache }),
|
gbrain_local_status: localEngineStatus({ noCache }),
|
||||||
|
gbrain_pooler_mode: detectPoolerMode(),
|
||||||
};
|
};
|
||||||
|
|
||||||
process.stdout.write(JSON.stringify(out, null, 2) + "\n");
|
process.stdout.write(JSON.stringify(out, null, 2) + "\n");
|
||||||
|
|||||||
+47
-5
@@ -54,6 +54,26 @@ export interface BuildGbrainEnvOptions {
|
|||||||
announce?: boolean;
|
announce?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect whether a DATABASE_URL targets a PgBouncer transaction-mode pooler.
|
||||||
|
*
|
||||||
|
* Supabase transaction-mode poolers conventionally run on port 6543 at
|
||||||
|
* `*.pooler.supabase.com`. When gbrain connects through one of these, it
|
||||||
|
* auto-disables prepared statements — but search requires them (#1435).
|
||||||
|
* Returns `true` when the URL looks like a transaction-mode pooler so the
|
||||||
|
* caller can set `GBRAIN_PREPARE=true` to re-enable prepared statements.
|
||||||
|
*/
|
||||||
|
export function isTransactionModePooler(url: string): boolean {
|
||||||
|
try {
|
||||||
|
// DATABASE_URLs use postgresql:// scheme which URL() doesn't natively
|
||||||
|
// parse host/port from, so swap to http:// for reliable parsing.
|
||||||
|
const parsed = new URL(url.replace(/^postgres(ql)?:\/\//, "http://"));
|
||||||
|
return parsed.port === "6543";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build an env dict with DATABASE_URL seeded from
|
* Build an env dict with DATABASE_URL seeded from
|
||||||
* `${GBRAIN_HOME:-$HOME/.gbrain}/config.json`. Returns the base env
|
* `${GBRAIN_HOME:-$HOME/.gbrain}/config.json`. Returns the base env
|
||||||
@@ -63,6 +83,11 @@ export interface BuildGbrainEnvOptions {
|
|||||||
* - the config has no `database_url`,
|
* - the config has no `database_url`,
|
||||||
* - the caller already set DATABASE_URL to the same value.
|
* - the caller already set DATABASE_URL to the same value.
|
||||||
*
|
*
|
||||||
|
* When the effective DATABASE_URL targets a PgBouncer transaction-mode
|
||||||
|
* pooler (port 6543), sets `GBRAIN_PREPARE=true` so gbrain re-enables
|
||||||
|
* prepared statements needed for search (#1435). Caller can override
|
||||||
|
* with `GBRAIN_PREPARE=false` in the base env.
|
||||||
|
*
|
||||||
* Always returns a fresh object — mutating the returned env never
|
* Always returns a fresh object — mutating the returned env never
|
||||||
* affects the caller's env. Tests assert on effective values, not
|
* affects the caller's env. Tests assert on effective values, not
|
||||||
* object identity.
|
* object identity.
|
||||||
@@ -84,14 +109,31 @@ export function buildGbrainEnv(opts: BuildGbrainEnvOptions = {}): NodeJS.Process
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
if (!cfg.database_url) return out;
|
if (!cfg.database_url) return out;
|
||||||
if (baseEnv.DATABASE_URL === cfg.database_url) return out;
|
|
||||||
|
|
||||||
const hadCaller = baseEnv.DATABASE_URL !== undefined;
|
const hadCaller = baseEnv.DATABASE_URL !== undefined;
|
||||||
out.DATABASE_URL = cfg.database_url;
|
const alreadyMatch = baseEnv.DATABASE_URL === cfg.database_url;
|
||||||
if (opts.announce) {
|
if (!alreadyMatch) {
|
||||||
const note = hadCaller ? " (overrode value from caller env / .env.local)" : "";
|
out.DATABASE_URL = cfg.database_url;
|
||||||
process.stderr.write(`[gbrain-exec] seeded DATABASE_URL from ${configPath}${note}\n`);
|
if (opts.announce) {
|
||||||
|
const note = hadCaller ? " (overrode value from caller env / .env.local)" : "";
|
||||||
|
process.stderr.write(`[gbrain-exec] seeded DATABASE_URL from ${configPath}${note}\n`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PgBouncer transaction-mode pooler detection (#1435): when the effective
|
||||||
|
// DATABASE_URL targets port 6543 (Supabase transaction-mode convention),
|
||||||
|
// gbrain auto-disables prepared statements — but search needs them.
|
||||||
|
// Set GBRAIN_PREPARE=true unless the caller explicitly opted out.
|
||||||
|
const effectiveUrl = out.DATABASE_URL || cfg.database_url;
|
||||||
|
if (effectiveUrl && !out.GBRAIN_PREPARE && isTransactionModePooler(effectiveUrl)) {
|
||||||
|
out.GBRAIN_PREPARE = "true";
|
||||||
|
if (opts.announce) {
|
||||||
|
process.stderr.write(
|
||||||
|
`[gbrain-exec] set GBRAIN_PREPARE=true (port 6543 transaction-mode pooler detected)\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+18
-6
@@ -905,13 +905,25 @@ Capability check (per /plan-eng-review §6):
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
SLUG="_capability_check_$$"
|
SLUG="_capability_check_$$"
|
||||||
|
CAPABILITY_OK=0
|
||||||
if [ -f ~/.gbrain/config.json ] && \
|
if [ -f ~/.gbrain/config.json ] && \
|
||||||
gbrain --version 2>/dev/null | grep -q '^gbrain ' && \
|
gbrain --version 2>/dev/null | grep -q '^gbrain '; then
|
||||||
echo "ping" | gbrain put "$SLUG" >/dev/null 2>&1 && \
|
# GBRAIN_PREPARE=true ensures prepared statements stay enabled when
|
||||||
gbrain search "ping" 2>/dev/null | grep -q "$SLUG"; then
|
# connecting through a PgBouncer transaction-mode pooler (port 6543).
|
||||||
CAPABILITY_OK=1
|
# Without it, search silently returns no results (#1435).
|
||||||
else
|
export GBRAIN_PREPARE=true
|
||||||
CAPABILITY_OK=0
|
if echo "ping" | gbrain put "$SLUG" >/dev/null 2>&1; then
|
||||||
|
# Retry search up to 3 times with 1s delay — under transaction-mode
|
||||||
|
# pooling the search index may not be visible on the next connection
|
||||||
|
# immediately after the put.
|
||||||
|
for _attempt in 1 2 3; do
|
||||||
|
if gbrain search "ping" 2>/dev/null | grep -q "$SLUG"; then
|
||||||
|
CAPABILITY_OK=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
gbrain delete "$SLUG" 2>/dev/null || true
|
gbrain delete "$SLUG" 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -185,13 +185,25 @@ Capability check (per /plan-eng-review §6):
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
SLUG="_capability_check_$$"
|
SLUG="_capability_check_$$"
|
||||||
|
CAPABILITY_OK=0
|
||||||
if [ -f ~/.gbrain/config.json ] && \
|
if [ -f ~/.gbrain/config.json ] && \
|
||||||
gbrain --version 2>/dev/null | grep -q '^gbrain ' && \
|
gbrain --version 2>/dev/null | grep -q '^gbrain '; then
|
||||||
echo "ping" | gbrain put "$SLUG" >/dev/null 2>&1 && \
|
# GBRAIN_PREPARE=true ensures prepared statements stay enabled when
|
||||||
gbrain search "ping" 2>/dev/null | grep -q "$SLUG"; then
|
# connecting through a PgBouncer transaction-mode pooler (port 6543).
|
||||||
CAPABILITY_OK=1
|
# Without it, search silently returns no results (#1435).
|
||||||
else
|
export GBRAIN_PREPARE=true
|
||||||
CAPABILITY_OK=0
|
if echo "ping" | gbrain put "$SLUG" >/dev/null 2>&1; then
|
||||||
|
# Retry search up to 3 times with 1s delay — under transaction-mode
|
||||||
|
# pooling the search index may not be visible on the next connection
|
||||||
|
# immediately after the put.
|
||||||
|
for _attempt in 1 2 3; do
|
||||||
|
if gbrain search "ping" 2>/dev/null | grep -q "$SLUG"; then
|
||||||
|
CAPABILITY_OK=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
gbrain delete "$SLUG" 2>/dev/null || true
|
gbrain delete "$SLUG" 2>/dev/null || true
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "fs";
|
|||||||
import { tmpdir } from "os";
|
import { tmpdir } from "os";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
|
|
||||||
import { buildGbrainEnv } from "../lib/gbrain-exec";
|
import { buildGbrainEnv, isTransactionModePooler } from "../lib/gbrain-exec";
|
||||||
|
|
||||||
describe("buildGbrainEnv", () => {
|
describe("buildGbrainEnv", () => {
|
||||||
let home: string;
|
let home: string;
|
||||||
@@ -117,4 +117,74 @@ describe("buildGbrainEnv", () => {
|
|||||||
const result = buildGbrainEnv({ baseEnv });
|
const result = buildGbrainEnv({ baseEnv });
|
||||||
expect(result.DATABASE_URL).toBe("postgresql://gbrain/db");
|
expect(result.DATABASE_URL).toBe("postgresql://gbrain/db");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- GBRAIN_PREPARE auto-detection (#1435) ---
|
||||||
|
|
||||||
|
it("sets GBRAIN_PREPARE=true when DATABASE_URL targets port 6543 (transaction-mode pooler)", () => {
|
||||||
|
const poolerUrl = "postgresql://postgres.abc:pw@aws-0-us-east-1.pooler.supabase.com:6543/postgres";
|
||||||
|
writeFileSync(join(gbrainHome, "config.json"), JSON.stringify({ database_url: poolerUrl }));
|
||||||
|
const baseEnv = { HOME: home };
|
||||||
|
const result = buildGbrainEnv({ baseEnv });
|
||||||
|
expect(result.DATABASE_URL).toBe(poolerUrl);
|
||||||
|
expect(result.GBRAIN_PREPARE).toBe("true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not set GBRAIN_PREPARE when DATABASE_URL targets port 5432 (session-mode pooler)", () => {
|
||||||
|
const sessionUrl = "postgresql://postgres.abc:pw@aws-0-us-east-1.pooler.supabase.com:5432/postgres";
|
||||||
|
writeFileSync(join(gbrainHome, "config.json"), JSON.stringify({ database_url: sessionUrl }));
|
||||||
|
const baseEnv = { HOME: home };
|
||||||
|
const result = buildGbrainEnv({ baseEnv });
|
||||||
|
expect(result.GBRAIN_PREPARE).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not set GBRAIN_PREPARE for pglite (no port in URL)", () => {
|
||||||
|
writeFileSync(join(gbrainHome, "config.json"), JSON.stringify({ database_url: "postgresql://gbrain/db" }));
|
||||||
|
const baseEnv = { HOME: home };
|
||||||
|
const result = buildGbrainEnv({ baseEnv });
|
||||||
|
expect(result.GBRAIN_PREPARE).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects caller's explicit GBRAIN_PREPARE=false (opt-out)", () => {
|
||||||
|
const poolerUrl = "postgresql://postgres.abc:pw@aws-0-us-east-1.pooler.supabase.com:6543/postgres";
|
||||||
|
writeFileSync(join(gbrainHome, "config.json"), JSON.stringify({ database_url: poolerUrl }));
|
||||||
|
const baseEnv = { HOME: home, GBRAIN_PREPARE: "false" };
|
||||||
|
const result = buildGbrainEnv({ baseEnv });
|
||||||
|
expect(result.GBRAIN_PREPARE).toBe("false");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets GBRAIN_PREPARE even when caller DATABASE_URL already matches config on port 6543", () => {
|
||||||
|
const poolerUrl = "postgresql://postgres.abc:pw@aws-0-us-east-1.pooler.supabase.com:6543/postgres";
|
||||||
|
writeFileSync(join(gbrainHome, "config.json"), JSON.stringify({ database_url: poolerUrl }));
|
||||||
|
const baseEnv = { HOME: home, DATABASE_URL: poolerUrl };
|
||||||
|
const result = buildGbrainEnv({ baseEnv });
|
||||||
|
expect(result.GBRAIN_PREPARE).toBe("true");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isTransactionModePooler", () => {
|
||||||
|
it("returns true for Supabase transaction-mode pooler URL (port 6543)", () => {
|
||||||
|
expect(isTransactionModePooler(
|
||||||
|
"postgresql://postgres.abc:pw@aws-0-us-east-1.pooler.supabase.com:6543/postgres"
|
||||||
|
)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for session-mode pooler URL (port 5432)", () => {
|
||||||
|
expect(isTransactionModePooler(
|
||||||
|
"postgresql://postgres.abc:pw@aws-0-us-east-1.pooler.supabase.com:5432/postgres"
|
||||||
|
)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for pglite-style URL (no port)", () => {
|
||||||
|
expect(isTransactionModePooler("postgresql://gbrain/db")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for unparseable URL", () => {
|
||||||
|
expect(isTransactionModePooler("not-a-url")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles postgres:// scheme (without 'ql')", () => {
|
||||||
|
expect(isTransactionModePooler(
|
||||||
|
"postgres://postgres.abc:pw@host:6543/postgres"
|
||||||
|
)).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user