mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-15 01:12:20 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29a65e6be3 |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user