mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 14:59:47 +00:00
* fix(engine): batch inserts use jsonb_to_recordset, not text[] array literals (#1861) addLinksBatch/addTimelineEntriesBatch/addTakesBatch passed free text through unnest(${arr}::text[]); postgres.js serialized it to a Postgres text[] literal that array_in rejected ("malformed array literal") on calendar/Zoom context, aborting the whole `extract links --stale` sweep. Bind the batch as one JSONB doc via jsonb_to_recordset(($1::jsonb)->'rows') through the audited executeRawJsonb contract instead. Shared row builders (src/core/batch-rows.ts) keep both engines byte-identical; NUL is stripped only from free-text body fields (context/summary/detail/claim), while identity/security fields (slugs/source_ids/holder/kind/dates) still reject NUL. addTakesBatch is now batchRetry-wrapped ('addTakesBatch' audit site) and its BrainEngine signature takes BatchOpts. Scalar addLink context is NUL-stripped too. Regression tests on both engines: PGLite always-on poison/NUL/parity suite + DATABASE_URL-gated Postgres lane (the engine that actually crashed). * test: make "no Anthropic key" tests hermetic via withoutAnthropicKey hasAnthropicKey() reads both ANTHROPIC_API_KEY and ~/.gbrain config; tests that only deleted the env var fired a real LLM call on configured machines (warning flipped NO_ANTHROPIC_API_KEY -> LLM_OUTPUT_NOT_JSON). New test/helpers/no-anthropic-key.ts neutralizes both sources (env + GBRAIN_HOME temp dir) for the duration of the call. Refactors the five no-key tests in think-pipeline + takes-mcp-allowlist to use it, including two that previously passed only by luck of the live LLM output. * chore: docs + version bump (v0.42.28.0) KEY_FILES.md/RETRIEVAL.md describe the jsonb_to_recordset batch path; TODOS.md files the #1861 follow-ups (element-isolation, remaining ::text[] sites, shared SQL-string hoist, batch-insert edge-case tests). CHANGELOG + VERSION + package.json to 0.42.28.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: sync TESTING.md batch-insert references for v0.42.28.0 The #1861 fix migrated links/timeline/takes batch inserts from unnest(::text[]) to jsonb_to_recordset. Update the stale "postgres-js unnest() binding" note and add the two new poison-regression test files (test/links-timeline-jsonb-poison.test.ts PGLite half, test/e2e/jsonb-batch-poison-postgres.test.ts Postgres lane) to the inventory. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sql-query): reject top-level array jsonb params in executeRawJsonb (#1861 P2a) The "no top-level array" rule was only a comment. A bare JS array bound to a $N::jsonb position can serialize as a Postgres array literal (not jsonb) through postgres.js, silently re-entering the "malformed array literal" class #1861 just escaped. executeRawJsonb now throws a clear error steering callers to the { rows: [...] } object wrapper. Verified breaks zero call sites (all pass objects or null). Codex adversarial P2a; batch-size enforcement (P2b) filed as a TODO. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
351 lines
14 KiB
TypeScript
351 lines
14 KiB
TypeScript
/**
|
|
* Shared retry primitive for transient connection errors (v0.41.18.0).
|
|
*
|
|
* THE PROBLEM
|
|
* -----------
|
|
* Long-running batch loops over Supabase Supavisor (session-mode pooler,
|
|
* port 5432) periodically hit the pooler's circuit breaker (ECIRCUITBREAKER)
|
|
* which needs **5-10s** to recover. The previous v0.41.2.1 helper (extract.ts
|
|
* local `withRetry`) did a single 500ms retry — designed for PgBouncer
|
|
* transaction-mode recycling (sub-second) and inadequate for Supavisor's
|
|
* longer recovery window. Production result: ~3,000 rows lost per dream
|
|
* cycle on a 16K-page brain as the breaker stayed hot across ~30 sequential
|
|
* batches.
|
|
*
|
|
* THE FIX
|
|
* -------
|
|
* This module exports the canonical `withRetry` execution wrapper plus
|
|
* `BULK_RETRY_OPTS` defaults tuned for the Supavisor recovery window.
|
|
* Engine methods (`addLinksBatch` / `addTimelineEntriesBatch` / `upsertChunks`
|
|
* in both postgres-engine.ts and pglite-engine.ts) wrap their internal SQL
|
|
* in `withRetry(BULK_RETRY_OPTS)` so every caller — current AND future —
|
|
* inherits retry as part of the data-primitive's contract.
|
|
*
|
|
* Retry classification routes through the canonical `isRetryableConnError`
|
|
* from `retry-matcher.ts` (re-exported below) so error-shape recognition
|
|
* never drifts across the codebase.
|
|
*
|
|
* BACKOFF MATH (D8 from codex review)
|
|
* ------------------------------------
|
|
* Defaults: `maxRetries=3, delayMs=1000, delayMaxMs=10000, jitter='decorrelated'`.
|
|
* Total worst-case wait: ~1s + ~3s + ~8s = ~12s — covers full Supavisor
|
|
* 5-10s circuit-breaker recovery window. The pre-codex `500/1000/2000`
|
|
* shape totaled 3.5s and could still exhaust before recovery.
|
|
*
|
|
* **Decorrelated jitter** (AWS-style): `nextDelay = uniform(base, prevDelay*3)`
|
|
* capped at `delayMaxMs`. Replaces naive `'full'` jitter (random in
|
|
* [0, computed]) which allowed near-zero delays — bad because near-zero
|
|
* retries re-hit the still-recovering breaker AND fail to randomize the
|
|
* thundering-herd window across workers.
|
|
*
|
|
* ABORT SUPPORT (D9 from codex review)
|
|
* ------------------------------------
|
|
* `withRetry(fn, { signal })` threads an `AbortSignal` through to the
|
|
* inter-attempt sleep. CLI shutdown signals (SIGTERM during deploys) abort
|
|
* sleeping retries cleanly instead of forcing workers to ignore the signal
|
|
* for up to `delayMaxMs` milliseconds.
|
|
*
|
|
* TYPED AUDIT-SITE ENUM (D10c from codex review)
|
|
* ----------------------------------------------
|
|
* `BATCH_AUDIT_SITES` is a closed const enum of every retry-emission site.
|
|
* The CI lint guard `scripts/check-batch-audit-site.sh` fails the build if
|
|
* a string-literal `auditSite: 'xyz'` doesn't appear in this list — catches
|
|
* typo drift before doctor output fragments.
|
|
*
|
|
* ENV OVERRIDES (D3 cherry-pick)
|
|
* ------------------------------
|
|
* - GBRAIN_BULK_MAX_RETRIES — int >= 0 (0 disables retries for debugging)
|
|
* - GBRAIN_BULK_RETRY_BASE_MS — int > 0
|
|
* - GBRAIN_BULK_RETRY_MAX_MS — int >= base
|
|
*
|
|
* Bad values throw `GBrainError` with a paste-ready fix hint. Doctor's
|
|
* `batch_retry_health` check also runs the validator at startup so misconfig
|
|
* surfaces immediately, not at first retry.
|
|
*/
|
|
|
|
import { isRetryableConnError } from './retry-matcher.ts';
|
|
|
|
export { isRetryableConnError };
|
|
|
|
/**
|
|
* Closed list of every site that emits batch-retry audit events. Add new
|
|
* sites here; the CI guard at `scripts/check-batch-audit-site.sh` validates
|
|
* that every string-literal `auditSite: '...'` in src/ matches an entry.
|
|
*/
|
|
export const BATCH_AUDIT_SITES = [
|
|
// Engine-method defaults (used when caller doesn't supply auditSite).
|
|
'addLinksBatch',
|
|
'addTimelineEntriesBatch',
|
|
'addTakesBatch',
|
|
'upsertChunks',
|
|
// extract.ts per-site labels.
|
|
'extract.links_inc',
|
|
'extract.timeline_inc',
|
|
'extract.links_fs',
|
|
'extract.timeline_fs',
|
|
'extract.links_db',
|
|
'extract.timeline_db',
|
|
'extract.by_mention',
|
|
// v0.42.7 (#1696): extract --stale incremental sweep.
|
|
'extract.stale',
|
|
// operations.ts MCP put_page auto-link path.
|
|
'mcp.put_page.autolink',
|
|
// sync.ts/reindex.ts orchestrator labels.
|
|
'sync.import_file',
|
|
'reindex.markdown',
|
|
'reindex.multimodal',
|
|
// backfill-base.ts outer connection-retry layer.
|
|
'backfill.outer',
|
|
// queue.ts Minion hot-path lock recovery (issue #1678): promoteDelayed
|
|
// self-heal on a reaped pooler socket. claim/renewLock deliberately do NOT
|
|
// route here (Codex #1/#2) — the poll loop and renewal-tick recover those.
|
|
'minion-lock',
|
|
] as const;
|
|
|
|
export type BatchAuditSite = (typeof BATCH_AUDIT_SITES)[number];
|
|
|
|
export type JitterMode = 'none' | 'full' | 'decorrelated';
|
|
|
|
export interface WithRetryOpts {
|
|
/** Maximum retry attempts (default 1 = single retry, v0.41.2.1 back-compat). */
|
|
maxRetries?: number;
|
|
/** Base delay in ms (default 500). */
|
|
delayMs?: number;
|
|
/** Maximum delay cap in ms (default 8000). */
|
|
delayMaxMs?: number;
|
|
/** Jitter policy (default 'none' for back-compat). */
|
|
jitter?: JitterMode;
|
|
/** AbortSignal for clean shutdown during inter-attempt sleep. */
|
|
signal?: AbortSignal;
|
|
/** Audit-site label for observability. Must be in BATCH_AUDIT_SITES. */
|
|
auditSite?: BatchAuditSite;
|
|
/**
|
|
* Per-attempt callback fires on each retry (attempt is 1-based).
|
|
*
|
|
* v0.41.25.0: now awaited. Sync callbacks (the only in-tree shape) work
|
|
* identically; async callbacks correctly delay the inter-attempt sleep.
|
|
*/
|
|
onRetry?: (attempt: number, err: unknown) => void | Promise<void>;
|
|
/**
|
|
* v0.41.25.0 — invoked between attempts AFTER `isRetryableConnError`
|
|
* classification but BEFORE the inter-attempt sleep. Use this to rebuild
|
|
* a dead connection / pool before the retry fires.
|
|
*
|
|
* Fail-loud posture (per codex finding 3 from /codex review): if reconnect
|
|
* throws, the throw PROPAGATES out of `withRetry` AS the new error,
|
|
* replacing the original retryable. Operators see the real cause
|
|
* ("auth failed", "EHOSTUNREACH") instead of "No database connection"
|
|
* for hours when DB credentials are bad.
|
|
*
|
|
* Engine-level callers (PostgresEngine.batchRetry) inject
|
|
* `(ctx) => this.reconnect(ctx)` which already handles both module and
|
|
* instance pools, race-safe via the `_reconnecting` guard.
|
|
*
|
|
* v0.42.x (#1685 CODEX #8): receives the triggering error so the engine can
|
|
* classify it (pooler reap vs network/auth) for the pool-recovery audit. The
|
|
* arg is optional — back-compat zero-arg callbacks (`() => this.reconnect()`)
|
|
* still satisfy the type.
|
|
*/
|
|
reconnect?: (ctx?: { error?: unknown }) => Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* Tuned defaults for bulk DB writes against Supavisor session-mode pooler.
|
|
* The single source of truth for engine-level retry behavior.
|
|
*
|
|
* Total worst-case wait: ~1s + ~3s + ~8s ≈ 12s. Covers the 5-10s Supavisor
|
|
* circuit-breaker recovery window with headroom.
|
|
*/
|
|
export const BULK_RETRY_OPTS: Required<Pick<WithRetryOpts, 'maxRetries' | 'delayMs' | 'delayMaxMs' | 'jitter'>> = {
|
|
maxRetries: 3,
|
|
delayMs: 1000,
|
|
delayMaxMs: 10_000,
|
|
jitter: 'decorrelated',
|
|
};
|
|
|
|
/**
|
|
* AbortError variant thrown when `signal` fires mid-retry-sleep. Tagged so
|
|
* callers can distinguish "user/system aborted" from "retries exhausted".
|
|
*/
|
|
export class RetryAbortError extends Error {
|
|
readonly tag = 'RETRY_ABORTED' as const;
|
|
constructor(message = 'Retry aborted via signal') {
|
|
super(message);
|
|
this.name = 'RetryAbortError';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Race a setTimeout against an AbortSignal. Resolves after `ms` ms OR rejects
|
|
* with RetryAbortError if signal fires first. Cleans up the timer either way
|
|
* (no zombie timers on abort).
|
|
*/
|
|
export function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
if (signal?.aborted) return Promise.reject(new RetryAbortError());
|
|
return new Promise<void>((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
signal?.removeEventListener('abort', onAbort);
|
|
resolve();
|
|
}, ms);
|
|
const onAbort = () => {
|
|
clearTimeout(timer);
|
|
reject(new RetryAbortError());
|
|
};
|
|
signal?.addEventListener('abort', onAbort, { once: true });
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Compute the next sleep delay given the current attempt index and prior delay.
|
|
* Pure function; deterministic given (rng, params).
|
|
*
|
|
* - jitter='none': exponential `base * 2^attempt`, capped at maxDelay
|
|
* - jitter='full': uniform in [0, computed exponential]
|
|
* - jitter='decorrelated': uniform in [base, prevDelay*3], capped at maxDelay
|
|
*/
|
|
export function computeNextDelay(
|
|
attempt: number,
|
|
prevDelay: number,
|
|
base: number,
|
|
maxDelay: number,
|
|
jitter: JitterMode,
|
|
rng: () => number = Math.random,
|
|
): number {
|
|
if (jitter === 'decorrelated') {
|
|
// AWS-style: nextDelay = uniform(base, prevDelay * 3), capped at maxDelay.
|
|
// On the very first retry (prevDelay === 0), fall back to `base` as the
|
|
// floor so we don't degenerate to uniform(base, 0) which would always
|
|
// pick base. Codex review caught the missing-prevDelay initialization.
|
|
const lo = base;
|
|
const hi = Math.max(base, prevDelay * 3);
|
|
const capped = Math.min(hi, maxDelay);
|
|
// Math.random() returns [0, 1); but tests inject rng=1 for upper-bound
|
|
// assertions, so clamp the result to [lo, capped] to keep the formula
|
|
// safe under any rng. Range can be 0 when lo === capped (first retry
|
|
// floor case); Math.floor handles that.
|
|
return Math.min(capped, Math.max(lo, Math.floor(lo + rng() * (capped - lo + 1))));
|
|
}
|
|
const exponential = Math.min(base * Math.pow(2, attempt), maxDelay);
|
|
if (jitter === 'full') {
|
|
return Math.floor(rng() * exponential);
|
|
}
|
|
return exponential;
|
|
}
|
|
|
|
/**
|
|
* Retry wrapper for transient connection errors.
|
|
*
|
|
* - Default `maxRetries=1` preserves v0.41.2.1's "single 500ms retry" contract.
|
|
* - Pass `BULK_RETRY_OPTS` for the Supavisor-tuned 3-retry exponential shape.
|
|
* - Non-retryable errors (per `isRetryableConnError`) throw immediately.
|
|
* - AbortSignal triggers `RetryAbortError` mid-sleep.
|
|
* - v0.41.25.0: optional `reconnect` callback runs between attempts AFTER
|
|
* classification but BEFORE the sleep. Fail-loud — a reconnect throw
|
|
* propagates as the new error.
|
|
*/
|
|
export async function withRetry<T>(
|
|
fn: () => Promise<T>,
|
|
opts: WithRetryOpts = {},
|
|
): Promise<T> {
|
|
const maxRetries = opts.maxRetries ?? 1;
|
|
const baseDelay = opts.delayMs ?? 500;
|
|
const maxDelay = opts.delayMaxMs ?? 8_000;
|
|
const jitter: JitterMode = opts.jitter ?? 'none';
|
|
const signal = opts.signal;
|
|
|
|
let lastErr: unknown;
|
|
let prevDelay = 0;
|
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
if (signal?.aborted) throw new RetryAbortError();
|
|
try {
|
|
return await fn();
|
|
} catch (err) {
|
|
if (!isRetryableConnError(err)) throw err;
|
|
lastErr = err;
|
|
if (attempt >= maxRetries) break;
|
|
// v0.41.25.0: onRetry is now awaited so async observability + audit
|
|
// hooks correctly run before the inter-attempt sleep. Sync arrows
|
|
// (the only in-tree shape) work identically.
|
|
await opts.onRetry?.(attempt + 1, err);
|
|
// v0.41.25.0: optional reconnect hook. PostgresEngine.batchRetry
|
|
// injects `() => this.reconnect()` so a null-singleton from a
|
|
// sibling caller's mid-process disconnect doesn't keep the retry
|
|
// hammering against a dead reference. Fail-loud: any throw from
|
|
// reconnect (auth failure, network partition) propagates AS the
|
|
// new error — operators see the real cause, not the symptom.
|
|
// v0.41.25 also ships diagnostic instrumentation on disconnect
|
|
// call sites to find the offending caller; this hook is the
|
|
// immediate-recovery half of that pair.
|
|
if (opts.reconnect) {
|
|
if (signal?.aborted) throw new RetryAbortError();
|
|
// Thread the triggering error so the engine can classify it (pooler
|
|
// reap vs other) for the pool-recovery audit (#1685 CODEX #8).
|
|
await opts.reconnect({ error: err });
|
|
}
|
|
const delay = computeNextDelay(attempt, prevDelay, baseDelay, maxDelay, jitter);
|
|
prevDelay = delay;
|
|
await abortableSleep(delay, signal);
|
|
}
|
|
}
|
|
throw lastErr;
|
|
}
|
|
|
|
/**
|
|
* Resolve BULK_RETRY_OPTS from env vars. Called at boundaries that need
|
|
* operator-tunable behavior: engine methods at first-use, doctor at startup.
|
|
*
|
|
* Throws GBrainError with paste-ready fix on bad input. Never silently
|
|
* falls back to defaults — bad config should fail loud.
|
|
*/
|
|
export function resolveBulkRetryOpts(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): typeof BULK_RETRY_OPTS {
|
|
const out = { ...BULK_RETRY_OPTS };
|
|
|
|
const maxRetries = env.GBRAIN_BULK_MAX_RETRIES;
|
|
if (maxRetries !== undefined && maxRetries !== '') {
|
|
const n = Number(maxRetries);
|
|
if (!Number.isInteger(n) || n < 0) {
|
|
throw new Error(
|
|
`GBRAIN_BULK_MAX_RETRIES must be an integer >= 0 (got "${maxRetries}"). ` +
|
|
`Fix: export GBRAIN_BULK_MAX_RETRIES=3 # or 0 to disable retries`,
|
|
);
|
|
}
|
|
out.maxRetries = n;
|
|
}
|
|
|
|
const baseMs = env.GBRAIN_BULK_RETRY_BASE_MS;
|
|
if (baseMs !== undefined && baseMs !== '') {
|
|
const n = Number(baseMs);
|
|
if (!Number.isInteger(n) || n <= 0) {
|
|
throw new Error(
|
|
`GBRAIN_BULK_RETRY_BASE_MS must be an integer > 0 (got "${baseMs}"). ` +
|
|
`Fix: export GBRAIN_BULK_RETRY_BASE_MS=1000`,
|
|
);
|
|
}
|
|
out.delayMs = n;
|
|
}
|
|
|
|
const maxMs = env.GBRAIN_BULK_RETRY_MAX_MS;
|
|
if (maxMs !== undefined && maxMs !== '') {
|
|
const n = Number(maxMs);
|
|
if (!Number.isInteger(n) || n < out.delayMs) {
|
|
throw new Error(
|
|
`GBRAIN_BULK_RETRY_MAX_MS must be an integer >= GBRAIN_BULK_RETRY_BASE_MS=${out.delayMs} ` +
|
|
`(got "${maxMs}"). Fix: export GBRAIN_BULK_RETRY_MAX_MS=10000`,
|
|
);
|
|
}
|
|
out.delayMaxMs = n;
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Type guard for valid audit-site labels. Use at call sites that accept
|
|
* runtime strings (e.g. CLI flags) to fail loudly on unknown sites.
|
|
*/
|
|
export function isBatchAuditSite(value: string): value is BatchAuditSite {
|
|
return (BATCH_AUDIT_SITES as readonly string[]).includes(value);
|
|
}
|