Files
gbrain/src/core/retry-matcher.ts
T
3fe449361c v0.42.16.0 feat(doctor): brain health as a solved problem — cause-ranked doctor + OOM-loop line + auto-drain + pool-reap (#1685) (#1802)
* feat(minions): pool-recovery audit + reconnect reason-threading + shared drain helper (#1685 GAP B, 5A)

- pool-recovery-audit.ts: reap_detected (CONNECTION_ENDED) vs reconnect_other; recovered/failed split
- postgres-engine reconnect(ctx?) classifies the triggering error so only true pooler reaps are tagged (CODEX #8)
- retry.ts reconnect callback widened to thread the error; retry-matcher isConnectionEndedError
- runExtractAtomsDrainForSource shared helper (cycleLockIdFor + withRefreshingLock) — one drain path (5A)
- supervisor-audit readRecentSupervisorEvents (current+prev ISO week, CODEX #7)
- extract-atoms-drain PROTECTED; autopilot.auto_drain.* config keys

* feat(doctor): worker_oom_loop + pool_reap_health checks + cause-ranked top_issues (#1685 GAP A/B/C)

- computeWorkerOomLoopCheck: unions supervisor rss_watchdog + minion_jobs watchdog-abort (CODEX #5), cap fallback to resolveDefaultMaxRssMb (CODEX #6)
- computePoolReapHealthCheck: reaps-not-recovering fail, thrash warn
- doctor-cause-rank rankIssues: tier ordering + grounded downstream_of (CODEX #9) + drift guard (4A)
- supervisor causeStr + queue_health cross-reference worker_oom_loop (DRY 1C)
- register both checks in doctor-categories ops

* feat(autopilot): per-source extract_atoms auto-drain + handler + dream --drain refactor (#1685 GAP D)

- autopilot per-source gate: enabled + !packDeclares + backlog>threshold + daily cap; time-sloted idempotency key (CODEX #2)
- extract-atoms-drain Minion handler (thin wrapper, LockUnavailableError -> deferred)
- dream --drain routes through the shared helper (5A)

* chore: bump version and changelog (v0.42.12.0)

#1685 brain-health-as-solved-problem: cause-ranked doctor, worker_oom_loop
line, per-source auto-drain, pool-reap health. Layers on #1678/#1735.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(todos): file #1685 GAP E + remote-path follow-ups (v0.42.12.0)

* fix(#1685): pre-landing review — multi-source auto-drain, honest pool-reap signal, lock-renewal reap labeling

- autopilot: drop maxWaiting (coalesces by name+queue not source → only one source drained + cap over-count); pre-check idempotency key so only genuinely-new sources submit+count
- pool_reap_health: fail on reconnect FAILURES (the real signal), not reaps>0&&failures>0 (false causality when a recovered reap + unrelated failure co-occur)
- lock-renewal-tick threads its triggering error to reconnect() so a CONNECTION_ENDED pooler reap is labeled reap_detected not reconnect_other (pool_reap_health now fires for the #1678 incident path)

* chore: re-version v0.42.12.0 → v0.42.16.0 (#1685)

Slot collision avoidance per queue.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: restore slim CLAUDE.md + move #1685 entries to KEY_FILES.md (fix check:doc-history)

The master merge wrongly kept the pre-restructure 577KB CLAUDE.md; the
check:doc-history guard caps it at 60KB. Take master's slim CLAUDE.md and
record the #1685 files (doctor-cause-rank, pool-recovery-audit, worker_oom_loop
+ pool_reap_health checks, auto-drain, 5A helper) as current-state prose in
docs/architecture/KEY_FILES.md (no release markers). llms regenerated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 07:27:34 -07:00

141 lines
5.5 KiB
TypeScript

/**
* Typed retry-eligible-error predicates (v0.30.1, finding C4).
*
* Three v0.30.1 sites need to decide whether to retry on a given error:
* - db.ts:connectWithRetry (existing — auth, conn-refused, ECONNRESET)
* - migrate.ts retry wrapper (statement_timeout 57014 + conn-reset)
* - backfill-base.ts adaptive retry (statement_timeout 57014 + conn drop)
*
* Before this module these predicates lived inline at each site and drifted
* over time. One source of truth here; new call sites import the typed
* helper instead of pattern-matching the same regexes again.
*/
const CONN_PATTERNS = [
/password authentication failed/i,
/connection refused/i,
/the database system is starting up/i,
/Connection terminated unexpectedly/i,
/ECONNRESET/i,
/connection.*closed/i,
/server closed the connection/i,
/could not connect to server/i,
// v0.41.2.1: gbrain's own GBrainError thrown by getConnection() when
// the singleton pool was nulled (engine.disconnect mid-cycle, or
// postgres.js's auto-recovery between queries). Matches the literal
// message shape from PR #1416's reported batch-loss incident.
/No database connection/i,
// v0.42.5.0 (issue #1678): postgres.js throws errors carrying
// `code: 'CONNECTION_ENDED'` (a LIBRARY code, not an 08xxx SQLSTATE) when a
// transaction-mode pooler reaps an idle socket between queries. Without an
// explicit match it was only accidentally caught by /connection.*closed/i.
// Match the message form too for wrappers that fold the code into the text.
/CONNECTION_ENDED/i,
];
interface PgError {
code?: string;
message?: string;
cause?: unknown;
// v0.41.2.1: gbrain's GBrainError uses `problem` (typed) + `detail` so
// callers can switch on the engine-state class without string matching.
problem?: string;
}
function getCode(err: unknown): string | undefined {
if (err && typeof err === 'object') {
const code = (err as PgError).code;
if (typeof code === 'string') return code;
}
return undefined;
}
function getMessage(err: unknown): string {
if (err instanceof Error) return err.message;
if (err && typeof err === 'object') {
const msg = (err as PgError).message;
if (typeof msg === 'string') return msg;
}
return String(err ?? '');
}
/**
* SQLSTATE 57014: query_canceled / statement_timeout.
* Postgres signals this when a statement exceeds `statement_timeout`.
*/
export function isStatementTimeoutError(err: unknown): boolean {
if (getCode(err) === '57014') return true;
const msg = getMessage(err);
return /statement_timeout|canceling statement due to statement timeout/i.test(msg);
}
/**
* SQLSTATE 55P03: lock_not_available.
* Postgres signals this when `lock_timeout` or `NOWAIT` would block.
*/
export function isLockTimeoutError(err: unknown): boolean {
if (getCode(err) === '55P03') return true;
const msg = getMessage(err);
return /lock_not_available|could not obtain lock/i.test(msg);
}
/**
* Connection-level errors that are typically transient: TCP resets,
* pooler restarts, server-starting-up, auth race during DNS failover.
* Distinguish from statement_timeout / lock_timeout via the dedicated
* predicates above.
*/
export function isRetryableConnError(err: unknown): boolean {
// Statement / lock timeouts are NOT connection errors. Callers that
// want to retry on those use isStatementTimeoutError / isLockTimeoutError
// explicitly so they can apply different backoff (e.g. backfill halves
// batch size on stmt timeout but reconnects on conn drop).
if (isStatementTimeoutError(err) || isLockTimeoutError(err)) return false;
const code = getCode(err);
// Postgres connection-level codes:
// 08000 connection_exception
// 08003 connection_does_not_exist
// 08006 connection_failure
// 08001 sqlclient_unable_to_establish_sqlconnection
// 08004 sqlserver_rejected_establishment_of_sqlconnection
if (code && /^08/.test(code)) return true;
// v0.42.5.0 (issue #1678): postgres.js's library-level connection-ended
// code. Not an 08xxx SQLSTATE, so the /^08/ test above misses it.
if (code === 'CONNECTION_ENDED') return true;
// v0.41.2.1: typed-shape match for gbrain's own GBrainError
// (problem === 'No database connection'). Avoids brittle string match
// when the error wrapper is gbrain-internal.
if (
err && typeof err === 'object' &&
(err as PgError).problem === 'No database connection'
) {
return true;
}
const msg = getMessage(err);
return CONN_PATTERNS.some(p => p.test(msg));
}
/**
* issue #1685 (CODEX #8): is this error specifically a POOLER REAP — postgres.js's
* library-level `CONNECTION_ENDED` code (the transaction-mode pooler dropping an
* idle socket between ticks)? Narrower than `isRetryableConnError`, which also
* matches 08xxx SQLSTATEs, network blips, and auth races. Used by
* `PostgresEngine.reconnect()` to label the pool-recovery audit honestly so a
* generic reconnect isn't mis-recorded as a reap.
*/
export function isConnectionEndedError(err: unknown): boolean {
const code = getCode(err);
if (code === 'CONNECTION_ENDED') return true;
const msg = getMessage(err);
return /CONNECTION_ENDED/i.test(msg);
}
/**
* Convenience: is this error retryable for ANY reason (connection drop OR
* statement timeout)? Backfill uses this — callers that need finer-grained
* dispatch (different backoff per kind) call the dedicated predicates.
*/
export function isRetryableError(err: unknown): boolean {
return isRetryableConnError(err) || isStatementTimeoutError(err);
}