mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 03:12:32 +00:00
* fix(jobs): reap stale dead-holder cycle/sync locks (#1972) A crashed sync (OOM, recycle, SIGKILL) stranded its gbrain_cycle_locks row until something contended for it — reclaim was on-contention only. Add a host-scoped background reaper: reapDeadHolderLocks deletes locks whose holder PID is provably dead on this host, scoped to the gbrain-sync:*/gbrain-cycle* namespaces only (never elections/supervisor/reindex), with a snapshot-matched delete (date_trunc on acquired_at) that is TOCTOU-safe against PID reuse. Reuses isHolderDeadLocally (same-host + ESRCH + 60s grace). doctor --fix now auto-reaps for no-autopilot brains. DRY: selectLockRows + shared mapper now back inspectLock + listStaleLocks (killed the triplication). * fix(db): bound pool disconnect so teardown can't eat CLI output (#1972) pool.end() against PgBouncer transaction-mode never drained, so disconnect blocked until the CLI's 10s force-exit fired and process.exit()'d mid-write, truncating stdout (e.g. #1959's relational query returned empty). Add a gbrain-owned endPoolBounded(pool): Promise.race of pool.end({timeout}) against a hard timer, so teardown is bounded regardless of what postgres.js does and is testable. connection-manager ends its direct + read pools concurrently so the per-pool bounds don't stack. PGLite disconnect is unaffected. * fix(cycle): complete cooperative-abort coverage + wire lock reaper (#1972) v0.42.29 made only the embed phase honor the abort signal; a 24h pull still showed force-evicts from a long non-embed phase ignoring it. Thread the signal into every cycle-reachable long loop: extract (extractForSlugs + the full-walk extractLinksFromDir/extractTimelineFromDir), extract_facts (per-page loop + embed signal + the phantom-redirect 30s lock-retry), and consolidate's bucket loop. Add a terminal abort check so an aborted cycle never stamps last_full_cycle_at as a completed run (Codex #9). lint now yields + checks abort every 200 pages (it's synchronous; the yield is what lets the signal land). New phase-duration force-evict attribution log names any phase that crosses the 30s deadline. Wire reapDeadHolderLocks at cycle start. * chore: bump version and changelog (v0.42.38.0) #1972 — stale-lock reaper, bounded pool disconnect, and complete cooperative-abort coverage across cycle phases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(key-files): current-state for the #1972 reaper, bounded disconnect, abort coverage document-release: update db-lock.ts (reapDeadHolderLocks + selectLockRows DRY), db.ts (endPoolBounded), and abort-check.ts (coverage now spans extract/ extract_facts/consolidate/lint + terminal guard) entries to current truth. * test(isolation): fix shard-order flakes exposed by #1972's new test files Adding 3 new test files reshuffled the hash-based shards, exposing two pre-existing test-isolation bugs: - cycle-consolidate.test.ts assumed the global legacy-embedding preload's 1536-d gateway config still held at initSchema, but a co-sharded test that calls resetGateway() in teardown nulls it, so initSchema fell back to the 1280-d default and built a halfvec(1280) facts column its 1536-d fixtures can't fill. Re-pin the legacy OpenAI/1536 config in beforeAll (the pattern legacy-embedding-preload.ts documents for 1536-d fixture tests). - db-lock-heartbeat-takeover.test.ts (merged from master's #1794) mutated process.env.GBRAIN_LOCK_STEAL_GRACE_SECONDS raw, tripping check:test-isolation rule R1. Convert to withEnv(). --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
453 lines
17 KiB
TypeScript
453 lines
17 KiB
TypeScript
/**
|
|
* Connection Manager — route Postgres queries by query type (v0.30.1, Fix 1).
|
|
*
|
|
* Three pools, one decision: read() goes to the pooler (port 6543, fast,
|
|
* many connections); ddl() and bulk() go to a direct connection (port 5432,
|
|
* 30min statement_timeout, capped at 3 conns) so DDL doesn't time out on
|
|
* the Supabase pooler's 2-min statement_timeout.
|
|
*
|
|
* The connection-manager is the URL-routing layer. It layers on top of
|
|
* postgres.js's existing pool primitives + PostgresEngine.withReservedConnection.
|
|
*
|
|
* ┌─────────────────────────────┐
|
|
* │ GBRAIN_DATABASE_URL │ GBRAIN_DIRECT_DATABASE_URL (override)
|
|
* │ (pooler, port 6543) │
|
|
* └────────┬────────────────────┘
|
|
* │
|
|
* ▼ auto-detect Supabase
|
|
* ┌──────────────┐ ┌──────────────┐
|
|
* │ read pool │ │ direct pool │
|
|
* │ size 10 │ │ size 3 │
|
|
* │ prepare:no │ │ stmt 30min │
|
|
* │ stmt 5min │ │ idle 5min │
|
|
* └──────────────┘ │ mwm 256MB │
|
|
* └──────────────┘
|
|
*
|
|
* Architectural notes:
|
|
* - INSTANCE-owned (T5 / X1 amendment): each PostgresEngine constructs its
|
|
* own ConnectionManager. Worker engines (cycle, sync) inherit the parent's
|
|
* via constructor option `parent`. transaction() clones share the parent's.
|
|
* - Lazy direct pool init via cached Promise<Sql> (A1): concurrent first
|
|
* callers await the same Promise, so no double-init.
|
|
* - Kill-switch (F1): GBRAIN_DISABLE_DIRECT_POOL=1 falls back to single-pool
|
|
* legacy path. With parent set, inherit parent's kill-switch state (A2).
|
|
* - Audit (F8): every acquire/release/error logs to connection-events.jsonl.
|
|
* - Non-Supabase passthrough: if URL isn't a Supabase pooler and no
|
|
* GBRAIN_DIRECT_DATABASE_URL override, ddl()/bulk() share the read pool.
|
|
*/
|
|
|
|
import postgres from 'postgres';
|
|
import { resolvePrepare, resolveSessionTimeouts, resolvePoolSize, endPoolBounded } from './db.ts';
|
|
import { redactPgUrl } from './url-redact.ts';
|
|
import { logConnectionEvent } from './connection-audit.ts';
|
|
|
|
export type Sql = ReturnType<typeof postgres>;
|
|
|
|
export interface ConnectionManagerOpts {
|
|
/** Primary URL — usually the pooler (port 6543) on Supabase. */
|
|
url: string;
|
|
/**
|
|
* Override for the direct URL. When set, takes precedence over auto-derivation.
|
|
* Sourced from GBRAIN_DIRECT_DATABASE_URL or explicit caller config.
|
|
*/
|
|
directUrl?: string | null;
|
|
/**
|
|
* Inherit pools + kill-switch state from a parent manager (worker engines,
|
|
* transaction clones). When set, this manager is a thin reference holder
|
|
* and does NOT open its own pools.
|
|
*/
|
|
parent?: ConnectionManager;
|
|
/**
|
|
* Read pool size override (defaults to resolvePoolSize() — 10 normally).
|
|
*/
|
|
readPoolSize?: number;
|
|
/**
|
|
* Direct pool size override (defaults to GBRAIN_DIRECT_POOL_SIZE env or 3).
|
|
*/
|
|
directPoolSize?: number;
|
|
/**
|
|
* When true, the read pool is owned by some other code (e.g. db.ts:connect's
|
|
* module singleton). The connection manager will USE it via getReadPool but
|
|
* not call .end() on disconnect(). Default false (we own both pools).
|
|
*/
|
|
readPoolOwnedExternally?: boolean;
|
|
}
|
|
|
|
/** Default direct-pool size (P1 raised from 2 to 3). Override via env. */
|
|
export const DEFAULT_DIRECT_POOL_SIZE = 3;
|
|
|
|
/** Search statement timeout (F5 consolidation) — was 8s scattered. */
|
|
export const SEARCH_STMT_TIMEOUT_MS = 8000;
|
|
|
|
/** DDL pool default statement_timeout (Fix 1). */
|
|
const DDL_STMT_TIMEOUT_MS = 30 * 60 * 1000; // 30min
|
|
|
|
/** DDL pool default idle-in-transaction timeout (Fix 1). */
|
|
const DDL_IDLE_TX_TIMEOUT_MS = 5 * 60 * 1000; // 5min
|
|
|
|
/** Bulk pool default maintenance_work_mem (P1). 256MB safe on Supabase. */
|
|
const BULK_MAINTENANCE_WORK_MEM = '256MB';
|
|
|
|
/**
|
|
* Hostname patterns that indicate a Supabase pooler. Used for auto-detection
|
|
* of the dual-pool topology. Adding more patterns is safe; mis-detection
|
|
* just means we open a "direct" pool against the same URL — wasteful but
|
|
* not broken (the kill-switch is the operator's escape hatch).
|
|
*/
|
|
const SUPABASE_POOLER_HOSTNAME_PATTERNS = [
|
|
/\.pooler\.supabase\.com$/i,
|
|
/^pooler\.supabase\.com$/i,
|
|
];
|
|
|
|
const SUPABASE_POOLER_PORTS = new Set(['6543']);
|
|
|
|
/**
|
|
* True if the URL looks like a Supabase pooler endpoint. Used for kill-switch
|
|
* activation and dual-pool routing.
|
|
*/
|
|
export function isSupabasePoolerUrl(url: string): boolean {
|
|
try {
|
|
const parsed = new URL(url.replace(/^postgres(ql)?:\/\//, 'http://'));
|
|
if (SUPABASE_POOLER_PORTS.has(parsed.port)) return true;
|
|
if (SUPABASE_POOLER_HOSTNAME_PATTERNS.some(re => re.test(parsed.hostname))) return true;
|
|
return false;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Derive a direct (non-pooler) URL from a Supabase pooler URL. Two known shapes:
|
|
*
|
|
* Pooler hostname: aws-N-region.pooler.supabase.com on port 6543
|
|
* → swap to db.<project-ref>.supabase.co on port 5432
|
|
* (project-ref encoded in the user component as postgres.<ref>)
|
|
* Direct hostname: db.<ref>.supabase.co already on port 5432 → returned as-is
|
|
*
|
|
* For the modern shape, we try to extract project-ref from the user component.
|
|
* If we cannot, we fall back to swapping port-only and the caller may warn.
|
|
*
|
|
* Returns null when the URL isn't a recognized Supabase pooler.
|
|
*/
|
|
export function deriveDirectUrl(url: string): string | null {
|
|
try {
|
|
const parsed = new URL(url.replace(/^postgres(ql)?:\/\//, 'http://'));
|
|
const port = parsed.port;
|
|
const hostname = parsed.hostname;
|
|
const isPoolerHost = SUPABASE_POOLER_HOSTNAME_PATTERNS.some(re => re.test(hostname));
|
|
if (port !== '6543' && !isPoolerHost) return null;
|
|
// User part on Supabase pooler is typically `postgres.<project-ref>`.
|
|
// Extract <project-ref> for the direct hostname.
|
|
const user = parsed.username || '';
|
|
const decodedUser = decodeURIComponent(user);
|
|
const refMatch = decodedUser.match(/^postgres\.([a-z0-9]+)$/i);
|
|
let directHost = hostname;
|
|
let directUser = parsed.username;
|
|
if (refMatch && refMatch[1] && isPoolerHost) {
|
|
directHost = `db.${refMatch[1]}.supabase.co`;
|
|
// Supabase direct connections use bare `postgres`; the `postgres.<ref>`
|
|
// form is pooler-only (Supavisor uses the suffix for tenant routing).
|
|
// Without this strip, direct auth fails with `password authentication
|
|
// failed for user "postgres.<ref>"` even though the password is correct.
|
|
directUser = 'postgres';
|
|
}
|
|
// Compose direct URL by swapping host + port. Preserve auth, db, query.
|
|
parsed.hostname = directHost;
|
|
parsed.port = '5432';
|
|
// Reconstruct with the original scheme.
|
|
const scheme = url.match(/^postgres(?:ql)?:\/\//i)?.[0] ?? 'postgres://';
|
|
const auth = directUser
|
|
? `${directUser}${parsed.password ? `:${parsed.password}` : ''}@`
|
|
: '';
|
|
const search = parsed.search ?? '';
|
|
const path = parsed.pathname ?? '';
|
|
return `${scheme}${auth}${directHost}:5432${path}${search}`;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read kill-switch state from env. Subordinate to parent manager's state
|
|
* when present (A2 inheritance).
|
|
*/
|
|
export function readKillSwitchEnv(): boolean {
|
|
return process.env.GBRAIN_DISABLE_DIRECT_POOL === '1' ||
|
|
process.env.GBRAIN_DISABLE_DIRECT_POOL === 'true';
|
|
}
|
|
|
|
/**
|
|
* Resolve direct pool size: explicit > env > default.
|
|
*/
|
|
export function resolveDirectPoolSize(explicit?: number): number {
|
|
if (typeof explicit === 'number' && explicit > 0) return explicit;
|
|
const raw = process.env.GBRAIN_DIRECT_POOL_SIZE;
|
|
if (raw) {
|
|
const parsed = parseInt(raw, 10);
|
|
if (Number.isFinite(parsed) && parsed > 0 && parsed <= 20) return parsed;
|
|
}
|
|
return DEFAULT_DIRECT_POOL_SIZE;
|
|
}
|
|
|
|
export class ConnectionManager {
|
|
private readonly opts: ConnectionManagerOpts;
|
|
private _readPool: Sql | null = null;
|
|
private _readPoolOwnedExternally: boolean;
|
|
private _directInit: Promise<Sql | null> | null = null;
|
|
private _directPool: Sql | null = null;
|
|
private _killSwitch: boolean;
|
|
private _directUrl: string | null;
|
|
private _isSupabase: boolean;
|
|
|
|
constructor(opts: ConnectionManagerOpts) {
|
|
this.opts = opts;
|
|
this._readPoolOwnedExternally = opts.readPoolOwnedExternally === true;
|
|
|
|
// A2: kill-switch resolution. Parent overrides env when present.
|
|
if (opts.parent) {
|
|
this._killSwitch = opts.parent.isKillSwitchActive();
|
|
this._isSupabase = opts.parent.isSupabase();
|
|
this._directUrl = opts.parent.resolveDirectUrl();
|
|
this._readPool = opts.parent.peekReadPool();
|
|
this._readPoolOwnedExternally = true; // never end the parent's pool
|
|
} else {
|
|
this._killSwitch = readKillSwitchEnv();
|
|
this._isSupabase = isSupabasePoolerUrl(opts.url);
|
|
// Direct URL: explicit override > env > derive > null
|
|
const envOverride = process.env.GBRAIN_DIRECT_DATABASE_URL;
|
|
this._directUrl = opts.directUrl ?? envOverride ?? deriveDirectUrl(opts.url);
|
|
}
|
|
}
|
|
|
|
/** Whether dual-pool routing is active (false on non-Supabase or kill-switch). */
|
|
isDualPoolActive(): boolean {
|
|
return this._isSupabase && !this._killSwitch && !!this._directUrl;
|
|
}
|
|
|
|
isSupabase(): boolean { return this._isSupabase; }
|
|
isKillSwitchActive(): boolean { return this._killSwitch; }
|
|
resolveDirectUrl(): string | null { return this._directUrl; }
|
|
|
|
/**
|
|
* Internal: peek at the read pool without forcing init. Used by parent
|
|
* inheritance to share the same instance.
|
|
*/
|
|
peekReadPool(): Sql | null { return this._readPool; }
|
|
|
|
/**
|
|
* Set the read pool. Used by db.ts:connect or PostgresEngine.connect when
|
|
* they own the pool externally (and connection-manager is just routing).
|
|
*/
|
|
setReadPool(sql: Sql): void {
|
|
this._readPool = sql;
|
|
this._readPoolOwnedExternally = true;
|
|
}
|
|
|
|
/**
|
|
* Get or lazily create the read pool. Honors `readPoolOwnedExternally` so
|
|
* we don't double-create when db.ts:connect already owns the singleton.
|
|
*/
|
|
async getReadPool(): Promise<Sql> {
|
|
if (this._readPool) return this._readPool;
|
|
if (this._readPoolOwnedExternally) {
|
|
throw new Error('connection-manager: read pool marked as externally-owned but not provided');
|
|
}
|
|
const opts: Record<string, unknown> = {
|
|
max: resolvePoolSize(this.opts.readPoolSize),
|
|
idle_timeout: 20,
|
|
connect_timeout: 10,
|
|
types: { bigint: postgres.BigInt },
|
|
};
|
|
const timeouts = resolveSessionTimeouts();
|
|
if (Object.keys(timeouts).length > 0) opts.connection = timeouts;
|
|
const prepare = resolvePrepare(this.opts.url);
|
|
if (typeof prepare === 'boolean') opts.prepare = prepare;
|
|
this._readPool = postgres(this.opts.url, opts);
|
|
logConnectionEvent({ pool: 'read', op: 'init' });
|
|
return this._readPool;
|
|
}
|
|
|
|
/**
|
|
* Acquire the read connection. Synchronous accessor — assumes read pool
|
|
* is already initialized (matches existing engine.sql semantics).
|
|
* Throws if pool not ready.
|
|
*/
|
|
read(): Sql {
|
|
if (!this._readPool) {
|
|
throw new Error('connection-manager: read pool not initialized; call getReadPool() first or set externally');
|
|
}
|
|
return this._readPool;
|
|
}
|
|
|
|
/**
|
|
* Acquire (and lazy-init) the direct DDL pool. When kill-switch is active
|
|
* or non-Supabase, returns the read pool (single-pool fallback).
|
|
*
|
|
* A1: lazy init wraps in a cached Promise<Sql> so concurrent first-callers
|
|
* await the same init instead of racing two pool constructions.
|
|
*/
|
|
async ddl(): Promise<Sql> {
|
|
if (!this.isDualPoolActive()) {
|
|
return this.getReadPool();
|
|
}
|
|
return this.getDirectPool();
|
|
}
|
|
|
|
/**
|
|
* Acquire the direct pool for a long-running BULK operation. Caller can
|
|
* override the per-op timeout via SET LOCAL inside a sql.begin block.
|
|
* Same pool as ddl(); the distinction is callsite intent (used by audit
|
|
* + caller-side timeout SET LOCAL).
|
|
*/
|
|
async bulk(_timeoutSeconds?: number): Promise<Sql> {
|
|
if (!this.isDualPoolActive()) {
|
|
return this.getReadPool();
|
|
}
|
|
return this.getDirectPool();
|
|
}
|
|
|
|
private async getDirectPool(): Promise<Sql> {
|
|
if (this._directPool) return this._directPool;
|
|
// A1: cache the Promise so concurrent first callers await the same init.
|
|
if (!this._directInit) {
|
|
this._directInit = this.initDirectPool().then(pool => {
|
|
this._directPool = pool;
|
|
return pool;
|
|
}).catch(err => {
|
|
// Reset cache on failure so next caller can retry.
|
|
this._directInit = null;
|
|
throw err;
|
|
});
|
|
}
|
|
const pool = await this._directInit;
|
|
if (!pool) {
|
|
// Defensive — initDirectPool should have thrown.
|
|
throw new Error('connection-manager: direct pool init returned null');
|
|
}
|
|
return pool;
|
|
}
|
|
|
|
private async initDirectPool(): Promise<Sql> {
|
|
if (!this._directUrl) {
|
|
throw new Error('connection-manager: cannot init direct pool — no direct URL');
|
|
}
|
|
const size = resolveDirectPoolSize(this.opts.directPoolSize);
|
|
const opts: Record<string, unknown> = {
|
|
max: size,
|
|
idle_timeout: 20,
|
|
connect_timeout: 10,
|
|
types: { bigint: postgres.BigInt },
|
|
// Always use prepared statements on the direct pool — no PgBouncer
|
|
// here, so the prepare-cache invalidation issue doesn't apply.
|
|
prepare: true,
|
|
// Apply DDL session GUCs as connection startup parameters (durable
|
|
// through any intermediary pooling layer, same trick as
|
|
// resolveSessionTimeouts).
|
|
connection: {
|
|
statement_timeout: String(DDL_STMT_TIMEOUT_MS),
|
|
idle_in_transaction_session_timeout: String(DDL_IDLE_TX_TIMEOUT_MS),
|
|
maintenance_work_mem: BULK_MAINTENANCE_WORK_MEM,
|
|
},
|
|
};
|
|
const t0 = Date.now();
|
|
try {
|
|
const pool = postgres(this._directUrl, opts);
|
|
// Probe to validate connectivity early.
|
|
await pool`SELECT 1`;
|
|
logConnectionEvent({
|
|
pool: 'ddl',
|
|
op: 'init',
|
|
duration_ms: Date.now() - t0,
|
|
host: this._directUrl ? this.hostOnly(this._directUrl) : undefined,
|
|
});
|
|
return pool;
|
|
} catch (err) {
|
|
logConnectionEvent({
|
|
pool: 'ddl',
|
|
op: 'error',
|
|
duration_ms: Date.now() - t0,
|
|
error: { message: err instanceof Error ? err.message : String(err) },
|
|
});
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* SELECT 1 latency probe on each pool. Used by doctor's connection_routing
|
|
* check + healthCheck() in the gateway.
|
|
*/
|
|
async healthCheck(): Promise<{ read: number | null; direct: number | null }> {
|
|
const result: { read: number | null; direct: number | null } = { read: null, direct: null };
|
|
try {
|
|
const t0 = Date.now();
|
|
const pool = await this.getReadPool();
|
|
await pool`SELECT 1`;
|
|
result.read = Date.now() - t0;
|
|
} catch { /* leave null */ }
|
|
if (this.isDualPoolActive()) {
|
|
try {
|
|
const t0 = Date.now();
|
|
const pool = await this.getDirectPool();
|
|
await pool`SELECT 1`;
|
|
result.direct = Date.now() - t0;
|
|
} catch { /* leave null */ }
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Disconnect pools we own. Read pool stays alive if marked externally owned
|
|
* (db.ts singleton path). Direct pool is always ours.
|
|
*/
|
|
async disconnect(): Promise<void> {
|
|
// #1972: end both pools concurrently with a gbrain-owned hard bound, so the
|
|
// per-pool drains don't STACK (sequential 2s waits → ~4-6s once the
|
|
// instance/module pool is added downstream) and neither can hang teardown
|
|
// past the CLI's 10s force-exit. endPoolBounded never throws.
|
|
const ends: Promise<void>[] = [];
|
|
if (this._directPool) {
|
|
ends.push(endPoolBounded(this._directPool));
|
|
this._directPool = null;
|
|
this._directInit = null;
|
|
}
|
|
if (this._readPool && !this._readPoolOwnedExternally) {
|
|
ends.push(endPoolBounded(this._readPool));
|
|
this._readPool = null;
|
|
}
|
|
await Promise.all(ends);
|
|
}
|
|
|
|
/**
|
|
* Diagnostic snapshot for doctor / get_health surfaces.
|
|
*/
|
|
describeMode(): {
|
|
mode: 'split' | 'single (kill-switch)' | 'single (non-supabase)' | 'single (no-direct-url)';
|
|
direct_host?: string;
|
|
kill_switch_active: boolean;
|
|
direct_pool_size: number;
|
|
} {
|
|
let mode: 'split' | 'single (kill-switch)' | 'single (non-supabase)' | 'single (no-direct-url)';
|
|
if (!this._isSupabase) mode = 'single (non-supabase)';
|
|
else if (this._killSwitch) mode = 'single (kill-switch)';
|
|
else if (!this._directUrl) mode = 'single (no-direct-url)';
|
|
else mode = 'split';
|
|
return {
|
|
mode,
|
|
direct_host: this._directUrl ? this.hostOnly(this._directUrl) : undefined,
|
|
kill_switch_active: this._killSwitch,
|
|
direct_pool_size: resolveDirectPoolSize(this.opts.directPoolSize),
|
|
};
|
|
}
|
|
|
|
private hostOnly(url: string): string {
|
|
// Redact creds first, then strip everything except host:port for doctor display.
|
|
const redacted = redactPgUrl(url);
|
|
try {
|
|
const parsed = new URL(redacted.replace(/^postgres(ql)?:\/\//, 'http://'));
|
|
return `${parsed.hostname}${parsed.port ? `:${parsed.port}` : ''}`;
|
|
} catch {
|
|
return redacted;
|
|
}
|
|
}
|
|
}
|