Files
gbrain/src/core/db.ts
T
ffac8ce0f4 v0.41.27.0 fix: withRetry self-heals on null singleton + facts:absorb drain + disconnect audit (closes #1570) (#1608)
* merge master: rebump v0.41.25.0 → v0.41.27.0 (queue collision)

Master shipped v0.41.25.0 (#1538 batched sync deletes) and v0.41.26.0
(#1571 dream --source fix) while this branch was in flight. Conflict
resolution rebumps to the next available slot.

- VERSION: 0.41.25.0 → 0.41.27.0
- package.json: synced
- CHANGELOG.md: my v0.41.27.0 entry placed above master's v0.41.26.0
  and v0.41.25.0; in-entry version references updated 0.41.25.0 →
  0.41.27.0 and forward-references bumped to v0.41.28+.
- TODOS.md: kept master's v0.41.20.x section + my v0.41.27.0+ follow-ups

No source-file conflicts during the merge.

* feat(diagnostics): db-disconnect audit + doctor surface (v0.41.27.0)

Instruments every db.disconnect() and PostgresEngine.disconnect() call
with a JSONL audit record so the next user-reported #1570 cycle gives
us the offender's caller stack instead of the symptomatic
"No database connection" error.

Audit shape (~/.gbrain/audit/db-disconnect-YYYY-Www.jsonl):
  {ts, engine_kind, connection_style, caller_stack[], command, pid}

- src/core/audit/db-disconnect-audit.ts (NEW): the audit writer,
  built on the v0.40.4.0 createAuditWriter cathedral. Captures a
  6-frame stack via new Error().stack so the offender is readable
  without spending stderr noise.
- src/core/db.ts: logDbDisconnect call at the top of disconnect()
  (best-effort; never blocks the real teardown).
- src/core/postgres-engine.ts: same instrumentation in
  PostgresEngine.disconnect() — distinguishes 'module' vs 'instance'
  connection_style so we can tell legitimate worker-pool teardowns
  apart from the load-bearing module-singleton class.
- src/commands/doctor.ts: extends batch_retry_health to surface
  24h disconnect count + most-recent caller stack. Warns when the
  caller frame isn't a known CLI-exit frame (e.g. cli.ts's finally
  block at the end of an op-dispatch). This is the diagnostic that
  tells v0.41.28+ where to apply the real ownership fix.
- test/db-disconnect-audit.test.ts: unit coverage for the audit
  writer + caller-stack capture + JSONL shape.
- test/e2e/db-singleton-shared-recovery.test.ts: real-Postgres
  regression that exercises the singleton-null path end-to-end.

Refs #1570

* feat(retry): self-heal on null singleton — closes #1570 symptom (v0.41.27.0)

withRetry gains an opt-in reconnect callback that fires between the
isRetryableConnError classification and the inter-attempt sleep.
PostgresEngine.batchRetry injects this.reconnect() — race-safe via
the existing _reconnecting guard, handles module and instance pools.

Closes the production loss reported in #1570: dream cycles on Supabase
no longer drop ~150 link rows per cycle when the singleton goes null
mid-batch. The retry now rebuilds the connection between attempts so
the second try has somewhere to write to.

- src/core/retry.ts: WithRetryOpts gains `reconnect?: () => Promise<void>`.
  Awaited in the catch branch. onRetry is also now awaited (back-compat-
  safe: every existing in-tree caller is a sync arrow). Reconnect
  failures propagate as the real cause — replaces the symptomatic
  "No database connection" error with whatever the connect() throw
  was, so operators see the truth.
- src/core/postgres-engine.ts:batchRetry — injects
  `reconnect: () => this.reconnect()`. Covers all 9 batch-retry call
  sites (addLinksBatch, addTimelineEntriesBatch, upsertChunks, plus
  the 6 caller-supplied auditSite labels in extract / sync / reindex).
- test/core/retry-reconnect.test.ts: 8 hermetic cases pinning the
  contract — reconnect fires before sleep, only on retryable errors,
  back-compat when omitted, signal-aborted bypasses reconnect,
  onRetry is awaited, full success path end-to-end.

The deeper bug (who's calling disconnect mid-cycle) is left
unaddressed in this commit by design — the diagnostic instrumentation
in the prior commit will tell us in the next production run.

Refs #1570

* feat(facts): drainPending() + CLI await before disconnect (v0.41.27.0)

Closes the silent 'No database connection' tail-end errors after
gbrain capture / put_page: the facts:absorb fire-and-forget queue
sometimes outlived the CLI process's connection lifetime, so absorb
attempts after engine.disconnect() landed in stderr as the
GBrainError shape.

- src/core/facts/queue.ts: new drainPending({timeout: 1000}) method
  distinct from shutdown(). Stops accepting new enqueues, awaits
  in-flight settle, bounded by timeout, returns count of unfinished.
  Semantically different from shutdown() (which aborts in-flight)
  so the symptom — drop work that hasn't started yet but let
  in-flight work finish — matches what CLI exit actually needs.
- src/cli.ts: op-dispatch finally block awaits the drain BEFORE
  engine.disconnect(). Bounded 1s. Opt-out env GBRAIN_NO_FACTS_DRAIN
  for callers that don't enqueue (keeps fast-exit paths fast).
  Mirrors the v0.41.8.0 awaitPendingLastRetrievedWrites pattern.
- test/facts-queue-drain-pending.test.ts: 6 hermetic cases — empty
  drain returns immediately, single in-flight settles, timeout
  bounds wait, shutdown-after-drain is idempotent, post-drain
  enqueues are dropped, signal-aborted skips waiting.

Refs #1570

* docs: update project documentation for v0.41.27.0

README.md: added troubleshooting entry for the v0.41.27.0 retry-reconnect
+ facts:absorb drain fix (closes #1570), pointing operators at
`gbrain doctor --json` to find the offending disconnect caller.

CLAUDE.md: extended `src/core/retry.ts` entry with the new optional
`reconnect` callback (v0.41.27.0); added two new Key Files entries for
`src/core/audit/db-disconnect-audit.ts` (the diagnostic half of the
"instrument first, fix later" pivot) and `FactsQueue.drainPending`;
extended `doctor.ts:checkBatchRetryHealth` entry with the in-place
extension that surfaces 24h disconnect-call count.

llms-full.txt: regenerated to absorb CLAUDE.md edits.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: rebump v0.41.27.0 → v0.41.28.0 (queue collision with #1573)

Master shipped v0.41.27.0 (#1573 git-aware sync_freshness) claiming the
same slot. Rebump to the next available version.

- VERSION + package.json → 0.41.28.0
- CHANGELOG.md: my entry header + in-entry refs 0.41.27.0 → 0.41.28.0
- TODOS.md: my #1570 follow-up section header + body refs bumped

* test: pin gateway in put-page-provenance + embedding-dim-check (CI shard fix)

Both files failed on CI shards 1 and 8 under the cross-file gateway-state
leak class (CLAUDE.md "Test-isolation lint and helpers"). The v0.41.28.0
merge reshuffled the weight-based shard bin-packing, landing a
gateway-mutating sibling ahead of these two victims in the same `bun test`
process.

Mechanism:
- put-page-provenance: put_page embeds via the gateway. A sibling left
  the gateway configured with OpenAI + the CI placeholder `sk-test`
  (captured at configureGateway time, survives the withEnv restore as
  cached gateway state). put_page's embed then fired against live OpenAI
  and 401'd. The bunfig legacy-embedding preload's beforeEach only
  re-applies legacy when the gateway was RESET — it does NOT correct a
  sibling that configured a different LIVE config.
- embedding-dim-check: initSchema builds the content_chunks vector column
  at the gateway's configured dim. A sibling leaking ZE/1280 made the
  column 1280-d, so `expect(dims).toBe(1536)` failed.

Fix (victim-side pinning, the escape hatch the preload documents):
- Both: configure the gateway explicitly in beforeAll BEFORE initSchema
  (OpenAI/1536), resetGateway() in afterAll so neither leaks onward.
- put-page-provenance also stubs the embed transport via
  __setEmbedTransportForTests so embed is deterministic and offline; a
  dummy OPENAI_API_KEY is supplied in the gateway env because
  instantiateEmbedding builds the OpenAI client (key check) BEFORE the
  stubbed transport is reached — the stub then intercepts the actual
  call so the key never leaves the process.

Verified: CI shards 1 (1337 pass) + 8 (905 pass) green with
OPENAI_API_KEY unset, plus adversarial sibling orderings (gateway.test /
doctor-ze-checks preceding). Typecheck + check-test-isolation clean.

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 19:04:48 -07:00

318 lines
12 KiB
TypeScript

import postgres from 'postgres';
import { GBrainError, type EngineConfig } from './types.ts';
import { SCHEMA_SQL } from './schema-embedded.ts';
import type { BrainEngine } from './engine.ts';
import { verifySchema } from './schema-verify.ts';
let sql: ReturnType<typeof postgres> | null = null;
let connectedUrl: string | null = null;
/**
* Default pool size for Postgres connections. Users on the Supabase transaction
* pooler (port 6543) or any multi-tenant pooler can lower this to avoid
* MaxClients errors when `gbrain upgrade` spawns subprocesses that each open
* their own pool. Set `GBRAIN_POOL_SIZE=2` (or similar) before the command.
*/
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;
if (raw) {
const parsed = parseInt(raw, 10);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
return DEFAULT_POOL_SIZE_FALLBACK;
}
/**
* Session-level GUCs applied to every new backend connection. Prevents
* orphan pgbouncer sessions from holding locks or running queries
* indefinitely when the postgres.js client disconnects mid-transaction
* (typical cause: autopilot SIGKILL'd by launchd, worker crash-loop,
* or transient network drop).
*
* Observed failure mode these prevent: a single autopilot UPDATE on
* `minion_jobs.lock_until` left a pooler backend in `state='active'`
* / `wait_event='ClientRead'` for 24h+, holding a RowExclusiveLock
* that blocked every subsequent `ALTER TABLE minion_jobs ...`.
*
* Defaults are conservative (chosen not to interfere with bulk work
* like long-running embed passes or CREATE INDEX on large tables):
* - statement_timeout = '5min'
* - idle_in_transaction_session_timeout = '5min' (matches v0.18.0
* posture; #363's original 2min default was tightened to 5min on
* merge with v0.21.0's setSessionDefaults to avoid regressing
* long-running embed passes)
*
* Override per-GUC with env vars:
* - GBRAIN_STATEMENT_TIMEOUT
* - GBRAIN_IDLE_TX_TIMEOUT
* - GBRAIN_CLIENT_CHECK_INTERVAL (Postgres 14+; empty default - opt-in
* only since older self-hosted Postgres rejects this startup param)
*
* Set any env var to '0' or 'off' to disable that GUC entirely.
*
* Delivered via postgres.js's `connection` option, which sends these as
* startup parameters in the initial connection packet. Works correctly
* with PgBouncer session mode AND transaction mode: startup parameters
* pass through to the backend on connection creation and persist for the
* backend's lifetime (unlike `SET` commands which transaction-mode
* PgBouncer strips between transactions).
*
* Supersedes the v0.21.0 `setSessionDefaults(sql)` helper, which used
* a post-pool `SET` command. That approach is unreliable in PgBouncer
* transaction mode (transaction-mode poolers strip session-state SETs
* between transactions); startup parameters are durable.
*/
const DEFAULT_STATEMENT_TIMEOUT = '5min';
const DEFAULT_IDLE_TX_TIMEOUT = '5min';
export function resolveSessionTimeouts(): Record<string, string> {
const out: Record<string, string> = {};
const add = (envKey: string, gucKey: string, defaultVal: string) => {
const raw = process.env[envKey];
if (raw === '0' || raw === 'off') return; // explicitly disabled
const val = raw ?? defaultVal;
if (val) out[gucKey] = val;
};
add('GBRAIN_STATEMENT_TIMEOUT', 'statement_timeout', DEFAULT_STATEMENT_TIMEOUT);
add('GBRAIN_IDLE_TX_TIMEOUT', 'idle_in_transaction_session_timeout', DEFAULT_IDLE_TX_TIMEOUT);
// client_connection_check_interval is opt-in: Postgres 14+ only, and some
// managed pooler tiers reject unknown startup parameters. Users can enable
// it explicitly once they know their Postgres version supports it.
add('GBRAIN_CLIENT_CHECK_INTERVAL', 'client_connection_check_interval', '');
return out;
}
/**
* Backward-compat shim for v0.21.0's `setSessionDefaults` callers.
* The current implementation no-ops because session timeouts are now
* applied at connection-startup time via `resolveSessionTimeouts()` +
* postgres.js's `connection` option (more durable across PgBouncer
* transaction mode).
*
* Kept as a callable function so existing call sites in `connect()` and
* `PostgresEngine.connect()` don't need to be touched on the merge —
* the work has already happened by the time this function would run.
*/
export async function setSessionDefaults(_sql: ReturnType<typeof postgres>): Promise<void> {
// No-op: timeouts are now applied as startup parameters in resolveSessionTimeouts().
}
export function getConnection(): ReturnType<typeof postgres> {
if (!sql) {
throw new GBrainError(
'No database connection',
'connect() has not been called',
'Run gbrain init --supabase or gbrain init --url <connection_string>',
);
}
return sql;
}
export async function connect(config: EngineConfig): Promise<void> {
if (sql) {
// Warn if a different URL is passed — the old connection is still in use
if (config.database_url && connectedUrl && config.database_url !== connectedUrl) {
console.warn('[gbrain] connect() called with a different database_url but a connection already exists. Using existing connection.');
}
return;
}
const url = config.database_url;
if (!url) {
throw new GBrainError(
'No database URL',
'database_url is missing from config',
'Run gbrain init --supabase or gbrain init --url <connection_string>',
);
}
try {
const prepare = resolvePrepare(url);
const timeouts = resolveSessionTimeouts();
const opts: Record<string, unknown> = {
max: resolvePoolSize(),
idle_timeout: 20,
connect_timeout: 10,
types: {
// Register pgvector type
bigint: postgres.BigInt,
},
// Silence postgres NOTICE-level messages by default ("relation already
// exists, skipping" floods stdout under idempotent CREATE statements
// during migrations + initSchema, and breaks stdout-parsing callers like
// `gbrain jobs submit --json | ...`). Opt back in with GBRAIN_PG_NOTICES=1.
onnotice: process.env.GBRAIN_PG_NOTICES === '1' ? undefined : () => {},
};
if (Object.keys(timeouts).length > 0) {
opts.connection = timeouts;
}
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`;
connectedUrl = url;
await setSessionDefaults(sql);
} catch (e: unknown) {
sql = null;
connectedUrl = null;
const msg = e instanceof Error ? e.message : String(e);
throw new GBrainError(
'Cannot connect to database',
msg,
'Check your connection URL in ~/.gbrain/config.json',
);
}
}
export async function disconnect(): Promise<void> {
// v0.41.25.0 (#1570) — instrument every disconnect call site so v0.41.26
// can identify the caller that's nulling the module singleton mid-cycle.
// Best-effort: audit failure must never block the actual disconnect.
// The audit module is lazy-imported to keep db.ts cold-path-free for
// tools that import db without ever calling disconnect.
try {
const { logDbDisconnect } = await import('./audit/db-disconnect-audit.ts');
// db.ts is always the module-singleton path by construction; no
// instance-pool callers go through here.
logDbDisconnect('postgres', 'module');
} catch { /* best-effort; never block disconnect on audit failure */ }
if (sql) {
await sql.end();
sql = null;
connectedUrl = null;
}
}
export async function initSchema(): Promise<void> {
const conn = getConnection();
// Advisory lock prevents concurrent initSchema() calls from deadlocking
await conn`SELECT pg_advisory_lock(42)`;
try {
await conn.unsafe(SCHEMA_SQL);
} finally {
await conn`SELECT pg_advisory_unlock(42)`;
}
}
export { verifySchema } from './schema-verify.ts';
export async function withTransaction<T>(fn: (tx: ReturnType<typeof postgres>) => Promise<T>): Promise<T> {
const conn = getConnection();
return conn.begin(async (tx) => {
return fn(tx as unknown as ReturnType<typeof postgres>);
}) as Promise<T>;
}
const RETRYABLE_DB_CONNECT_PATTERNS = [
/password authentication failed/i,
/connection refused/i,
/the database system is starting up/i,
/Connection terminated unexpectedly/i,
/ECONNRESET/i,
];
export function isRetryableDbConnectError(err: unknown): boolean {
const msg = err instanceof Error ? err.message : String(err);
if (!msg) return false;
return RETRYABLE_DB_CONNECT_PATTERNS.some(p => p.test(msg));
}
export interface ConnectWithRetryOpts {
attempts?: number;
baseDelayMs?: number;
noRetry?: boolean;
log?: (line: string) => void;
}
export async function connectWithRetry(
engine: BrainEngine,
config: EngineConfig & { poolSize?: number },
opts: ConnectWithRetryOpts = {},
): Promise<void> {
const noRetry = opts.noRetry ?? (process.env.GBRAIN_NO_RETRY_CONNECT === '1');
const attempts = noRetry ? 1 : (opts.attempts ?? 3);
const baseDelayMs = opts.baseDelayMs ?? 1000;
const log = opts.log ?? ((line) => console.warn(line));
let lastErr: unknown;
for (let i = 0; i < attempts; i++) {
try {
await engine.connect(config);
return;
} catch (e: unknown) {
lastErr = e;
const retryable = isRetryableDbConnectError(e);
const isLast = i === attempts - 1;
if (!retryable || isLast) {
throw e;
}
const delay = baseDelayMs * Math.pow(2, i);
const msg = e instanceof Error ? e.message : String(e);
log(`[connect] attempt ${i + 1} failed (${msg.slice(0, 80)}), retrying in ${delay}ms`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
// Unreachable, but TS needs the throw.
throw lastErr;
}