Compare commits

...
Author SHA1 Message Date
root 29a65e6be3 fix: connection resilience for minion supervisor + worker
Three fixes for the minion supervisor dying silently when PgBouncer rotates:

1. PostgresEngine: executeRaw retries once on connection-class errors
   (ECONNREFUSED, password auth failed, connection terminated, etc.)
   by tearing down the poisoned pool and creating a fresh one via
   reconnect(). Prevents cascading failures when Supabase bounces.

2. Supervisor: tracks consecutive health check failures. After 3 in a
   row, emits health_warn with reason=db_connection_degraded and attempts
   engine.reconnect() if available. Resets counter on success.

3. Supervisor: worker_exited events now include likely_cause field:
   SIGKILL → oom_or_external_kill, SIGTERM → graceful_shutdown,
   code=1 → runtime_error. Makes it trivial to distinguish OOM kills
   from connection deaths in logs.

Tests: 23 new tests covering connection error detection, reconnect
guard against concurrent reconnects, retry-once-not-infinite-loop,
health failure tracking, and exit classification.
2026-04-25 03:28:44 +00:00
3 changed files with 377 additions and 6 deletions
+55 -5
View File
@@ -142,6 +142,7 @@ export class MinionSupervisor {
private sigtermListener: (() => void) | null = null;
private sigintListener: (() => void) | null = null;
private lockAcquired = false;
private consecutiveHealthFailures = 0;
constructor(engine: BrainEngine, opts: Partial<SupervisorOpts> & { cliPath: string }) {
this.engine = engine;
@@ -476,10 +477,26 @@ export class MinionSupervisor {
}
const exitReason = signal ? `signal ${signal}` : `code ${code ?? 'null'}`;
// Classify the likely cause for easier debugging
let likelyCause: string;
if (signal === 'SIGKILL') {
likelyCause = 'oom_or_external_kill';
} else if (signal === 'SIGTERM') {
likelyCause = 'graceful_shutdown';
} else if (code === 1) {
likelyCause = 'runtime_error';
} else if (code === 0) {
likelyCause = 'clean_exit';
} else {
likelyCause = 'unknown';
}
this.emit('worker_exited', {
code: code ?? null,
signal: signal ?? null,
reason: exitReason,
likely_cause: likelyCause,
crash_count: this.crashCount,
max_crashes: this.opts.maxCrashes,
run_duration_ms: runDuration,
@@ -523,6 +540,9 @@ export class MinionSupervisor {
[this.opts.queue],
);
// Reset consecutive failure counter on successful health check
this.consecutiveHealthFailures = 0;
const row = rows[0] ?? { stalled: '0', waiting: '0', last_completed: null };
const stalledCount = parseInt(row.stalled ?? '0', 10);
const waitingCount = parseInt(row.waiting ?? '0', 10);
@@ -561,11 +581,41 @@ export class MinionSupervisor {
});
}
} catch (e) {
// Health check failures are non-fatal.
this.emit('health_error', {
error: e instanceof Error ? e.message : String(e),
queue: this.opts.queue,
});
this.consecutiveHealthFailures++;
const errMsg = e instanceof Error ? e.message : String(e);
if (this.consecutiveHealthFailures >= 3) {
// DB connection is likely dead. Emit a degraded warning.
this.emit('health_warn', {
reason: 'db_connection_degraded',
consecutive_failures: this.consecutiveHealthFailures,
error: errMsg,
queue: this.opts.queue,
});
// Attempt to reconnect the engine if it supports it
try {
if ('reconnect' in this.engine && typeof (this.engine as Record<string, unknown>).reconnect === 'function') {
await (this.engine as unknown as { reconnect(): Promise<void> }).reconnect();
this.consecutiveHealthFailures = 0;
this.emit('health_warn', {
reason: 'db_reconnected',
queue: this.opts.queue,
});
}
} catch (reconnErr) {
this.emit('health_error', {
error: `reconnect failed: ${reconnErr instanceof Error ? reconnErr.message : String(reconnErr)}`,
reconnect_failed: true,
queue: this.opts.queue,
});
}
} else {
// Non-fatal single failure
this.emit('health_error', {
error: errMsg,
queue: this.opts.queue,
});
}
} finally {
this.healthInFlight = false;
}
+58 -1
View File
@@ -19,9 +19,38 @@ import { GBrainError } from './types.ts';
import * as db from './db.ts';
import { validateSlug, contentHash, rowToPage, rowToChunk, rowToSearchResult, parseEmbedding, tryParseEmbedding } from './utils.ts';
/** Error codes/messages that indicate a dead or poisoned connection. */
const CONNECTION_ERROR_PATTERNS = [
'ECONNREFUSED',
'ECONNRESET',
'EPIPE',
'connection terminated',
'Client has encountered a connection error',
'password authentication failed',
'Connection terminated unexpectedly',
'no pg_hba.conf entry',
'server closed the connection unexpectedly',
'SSL connection has been closed unexpectedly',
'connection is insecure',
'too many connections',
'remaining connection slots are reserved',
];
function isConnectionError(err: unknown): boolean {
if (!err) return false;
const msg = err instanceof Error ? err.message : String(err);
const code = (err as NodeJS.ErrnoException)?.code;
if (code && CONNECTION_ERROR_PATTERNS.includes(code)) return true;
return CONNECTION_ERROR_PATTERNS.some(p => msg.includes(p));
}
export class PostgresEngine implements BrainEngine {
readonly kind = 'postgres' as const;
private _sql: ReturnType<typeof postgres> | null = null;
/** Saved config for reconnection. */
private _savedConfig: (EngineConfig & { poolSize?: number }) | null = null;
/** Whether a reconnect is in progress (prevents concurrent reconnects). */
private _reconnecting = false;
// Instance connection (for workers) or fall back to module global (backward compat)
get sql(): ReturnType<typeof postgres> {
@@ -31,6 +60,7 @@ export class PostgresEngine implements BrainEngine {
// Lifecycle
async connect(config: EngineConfig & { poolSize?: number }): Promise<void> {
this._savedConfig = config;
if (config.poolSize) {
// Instance-level connection for worker isolation. resolvePoolSize lets
// GBRAIN_POOL_SIZE cap below the caller's requested size when set — the
@@ -1105,8 +1135,35 @@ export class PostgresEngine implements BrainEngine {
return rows.map((r) => rowToChunk(r as Record<string, unknown>, true));
}
/**
* Reconnect the engine by tearing down the current pool and creating a fresh one.
* No-ops if no saved config (module-singleton mode) or if already reconnecting.
*/
async reconnect(): Promise<void> {
if (!this._savedConfig || this._reconnecting) return;
this._reconnecting = true;
try {
// Tear down old pool (best-effort — it may already be dead)
try { await this.disconnect(); } catch { /* swallow */ }
// Create fresh pool
await this.connect(this._savedConfig);
} finally {
this._reconnecting = false;
}
}
async executeRaw<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]> {
const conn = this.sql;
return conn.unsafe(sql, params as Parameters<typeof conn.unsafe>[1]) as unknown as T[];
try {
return await (conn.unsafe(sql, params as Parameters<typeof conn.unsafe>[1]) as unknown as Promise<T[]>);
} catch (err) {
// If it's a connection error and we have saved config, try once with a fresh pool
if (isConnectionError(err) && this._savedConfig && !this._reconnecting) {
await this.reconnect();
const freshConn = this.sql;
return freshConn.unsafe(sql, params as Parameters<typeof freshConn.unsafe>[1]) as unknown as T[];
}
throw err;
}
}
}
+264
View File
@@ -0,0 +1,264 @@
import { describe, it, expect } from 'bun:test';
/**
* Tests for connection resilience features:
* 1. PostgresEngine.executeRaw retries on connection errors
* 2. PostgresEngine.reconnect creates fresh connection pool
* 3. Supervisor health check tracks consecutive failures
* 4. Supervisor classifies worker exit reasons
*/
// --- Unit tests for isConnectionError (extracted pattern) ---
const CONNECTION_ERROR_PATTERNS = [
'ECONNREFUSED',
'ECONNRESET',
'EPIPE',
'connection terminated',
'Client has encountered a connection error',
'password authentication failed',
'Connection terminated unexpectedly',
'no pg_hba.conf entry',
'server closed the connection unexpectedly',
'SSL connection has been closed unexpectedly',
'connection is insecure',
'too many connections',
'remaining connection slots are reserved',
];
function isConnectionError(err: unknown): boolean {
if (!err) return false;
const msg = err instanceof Error ? err.message : String(err);
const code = (err as NodeJS.ErrnoException)?.code;
if (code && CONNECTION_ERROR_PATTERNS.includes(code)) return true;
return CONNECTION_ERROR_PATTERNS.some(p => msg.includes(p));
}
describe('isConnectionError', () => {
it('detects password authentication failure', () => {
expect(isConnectionError(new Error('password authentication failed for user "postgres"'))).toBe(true);
});
it('detects ECONNREFUSED via error code', () => {
const err = new Error('connect ECONNREFUSED 127.0.0.1:5432') as NodeJS.ErrnoException;
err.code = 'ECONNREFUSED';
expect(isConnectionError(err)).toBe(true);
});
it('detects ECONNRESET via error code', () => {
const err = new Error('read ECONNRESET') as NodeJS.ErrnoException;
err.code = 'ECONNRESET';
expect(isConnectionError(err)).toBe(true);
});
it('detects connection terminated message', () => {
expect(isConnectionError(new Error('connection terminated'))).toBe(true);
});
it('detects Connection terminated unexpectedly', () => {
expect(isConnectionError(new Error('Connection terminated unexpectedly'))).toBe(true);
});
it('detects server closed the connection', () => {
expect(isConnectionError(new Error('server closed the connection unexpectedly'))).toBe(true);
});
it('detects SSL connection closed', () => {
expect(isConnectionError(new Error('SSL connection has been closed unexpectedly'))).toBe(true);
});
it('detects too many connections', () => {
expect(isConnectionError(new Error('FATAL: too many connections for role "postgres"'))).toBe(true);
});
it('does not match regular query errors', () => {
expect(isConnectionError(new Error('relation "foo" does not exist'))).toBe(false);
});
it('does not match null/undefined', () => {
expect(isConnectionError(null)).toBe(false);
expect(isConnectionError(undefined)).toBe(false);
});
it('does not match syntax errors', () => {
expect(isConnectionError(new Error('syntax error at or near "SELECT"'))).toBe(false);
});
it('does not match constraint violations', () => {
expect(isConnectionError(new Error('duplicate key value violates unique constraint'))).toBe(false);
});
});
// --- Unit tests for worker exit classification ---
function classifyWorkerExit(code: number | null, signal: string | null): string {
if (signal === 'SIGKILL') return 'oom_or_external_kill';
if (signal === 'SIGTERM') return 'graceful_shutdown';
if (code === 1) return 'runtime_error';
if (code === 0) return 'clean_exit';
return 'unknown';
}
describe('classifyWorkerExit', () => {
it('classifies SIGKILL as OOM/external kill', () => {
expect(classifyWorkerExit(null, 'SIGKILL')).toBe('oom_or_external_kill');
});
it('classifies SIGTERM as graceful shutdown', () => {
expect(classifyWorkerExit(null, 'SIGTERM')).toBe('graceful_shutdown');
});
it('classifies exit code 1 as runtime error', () => {
expect(classifyWorkerExit(1, null)).toBe('runtime_error');
});
it('classifies exit code 0 as clean exit', () => {
expect(classifyWorkerExit(0, null)).toBe('clean_exit');
});
it('classifies unknown codes as unknown', () => {
expect(classifyWorkerExit(137, null)).toBe('unknown');
expect(classifyWorkerExit(null, null)).toBe('unknown');
});
// Signal takes precedence over code
it('SIGKILL takes precedence over any exit code', () => {
expect(classifyWorkerExit(1, 'SIGKILL')).toBe('oom_or_external_kill');
});
});
// --- Mock-based tests for reconnect logic ---
describe('PostgresEngine reconnect behavior', () => {
it('reconnect flag prevents concurrent reconnections', async () => {
// Simulate the _reconnecting guard
let reconnecting = false;
let reconnectCount = 0;
async function reconnect() {
if (reconnecting) return;
reconnecting = true;
try {
reconnectCount++;
await new Promise(r => setTimeout(r, 10));
} finally {
reconnecting = false;
}
}
// Fire 3 concurrent reconnects — only 1 should run
await Promise.all([reconnect(), reconnect(), reconnect()]);
expect(reconnectCount).toBe(1);
});
it('executeRaw retry does not infinite-loop on persistent connection failure', async () => {
// Simulate: first call fails (connection error), reconnect succeeds,
// but retry also fails with a NON-connection error
let callCount = 0;
async function executeRawWithRetry(): Promise<unknown[]> {
callCount++;
if (callCount === 1) {
throw new Error('connection terminated'); // connection error → triggers retry
}
if (callCount === 2) {
throw new Error('relation "foo" does not exist'); // NOT a connection error → throw
}
return [{ ok: true }];
}
try {
await (async () => {
try {
return await executeRawWithRetry();
} catch (err) {
if (isConnectionError(err)) {
// "reconnect" would happen here
return await executeRawWithRetry();
}
throw err;
}
})();
} catch (err) {
expect((err as Error).message).toBe('relation "foo" does not exist');
}
expect(callCount).toBe(2); // Only 2 attempts, no infinite loop
});
it('executeRaw succeeds on retry after connection error', async () => {
let callCount = 0;
async function executeRawWithRetry(): Promise<unknown[]> {
callCount++;
if (callCount === 1) {
throw new Error('password authentication failed for user "postgres"');
}
return [{ ok: true }];
}
const result = await (async () => {
try {
return await executeRawWithRetry();
} catch (err) {
if (isConnectionError(err)) {
// reconnect would happen here
return await executeRawWithRetry();
}
throw err;
}
})();
expect(result).toEqual([{ ok: true }]);
expect(callCount).toBe(2);
});
});
// --- Supervisor health check failure tracking ---
describe('Supervisor health check failure tracking', () => {
it('emits db_connection_degraded after 3 consecutive failures', () => {
let consecutiveFailures = 0;
const emitted: Array<{ event: string; reason?: string }> = [];
function emit(event: string, fields: Record<string, unknown> = {}) {
emitted.push({ event, ...fields } as { event: string; reason?: string });
}
// Simulate 3 health check failures
for (let i = 0; i < 4; i++) {
consecutiveFailures++;
if (consecutiveFailures >= 3) {
emit('health_warn', { reason: 'db_connection_degraded', consecutive_failures: consecutiveFailures });
} else {
emit('health_error', { error: 'connection terminated' });
}
}
const degradedWarnings = emitted.filter(e => e.reason === 'db_connection_degraded');
expect(degradedWarnings.length).toBe(2); // fires at count 3 and 4
// First two were regular health_error
expect(emitted[0].event).toBe('health_error');
expect(emitted[1].event).toBe('health_error');
// Third triggers the degraded warning
expect(emitted[2].reason).toBe('db_connection_degraded');
});
it('resets failure counter on successful health check', () => {
let consecutiveFailures = 0;
// 2 failures
consecutiveFailures++;
consecutiveFailures++;
expect(consecutiveFailures).toBe(2);
// Success resets
consecutiveFailures = 0;
expect(consecutiveFailures).toBe(0);
// 1 more failure — should not trigger degraded (need 3 consecutive)
consecutiveFailures++;
expect(consecutiveFailures).toBeLessThan(3);
});
});