feat: v0.41.19.0 Supavisor Retry Cathedral (#1537)
Engine-level retry primitive that closes the v0.41.17 production incident
where ~3,000 wiki links + timeline entries were silently lost per dream
cycle on a 16K-page brain. Supavisor's circuit-breaker takes 5-10s to
recover; the prior single-500ms-retry shape couldn't survive it.
ARCHITECTURE
============
Retry becomes a data-primitive contract, not a caller responsibility.
postgres-engine.ts + pglite-engine.ts now self-retry inside addLinksBatch,
addTimelineEntriesBatch, and upsertChunks. Every caller — current AND
future — inherits retry-for-free. CI lint guard `scripts/check-no-double-retry.sh`
fails the build if anyone re-wraps an engine batch method (preventing
3×3=9 retry amplification on incomplete reverts).
CODEX-HARDENED DEFAULTS
=======================
BULK_RETRY_OPTS = {maxRetries:3, delayMs:1000, delayMaxMs:10000,
jitter:'decorrelated'}. Total worst-case wait ≈12s covers full Supavisor
recovery window. Decorrelated jitter (AWS-style uniform(base, prevDelay*3)
capped at maxDelay) replaces 'full' which allowed near-zero retries that
re-hit the still-recovering breaker.
AbortSignal threading from MinionWorker.shutdownAbort.signal through
engine method opts → withRetry → abortableSleep. SIGTERM aborts sleeping
retries instead of blocking deploys for up to delayMaxMs.
OBSERVABILITY
=============
`~/.gbrain/audit/batch-retry-YYYY-Www.jsonl` records every retry event
(success-after-blip AND exhausted-retries). Built on the v0.40.4.0
audit-writer cathedral. Privacy posture: never logs slugs / page IDs /
content (mirrors shell-audit.ts).
`gbrain doctor` learns `batch_retry_health` check. Reads last 24h
(not 7d — codex H-9: avoid permanent noise from one historical blip).
Thresholds: ok (zero or <3 same-site), warn (>=3 same-site OR >=5
cross-site), fail (>=20 sustained breaker). Surfaces bad GBRAIN_BULK_*
env at startup (codex M-10). Corrupt-JSONL tolerant.
30-day audit pruning hooked into the dream cycle's purge phase (codex H-8
— implements the 'pruning convention' for real).
OPERATOR TUNING
===============
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 paste-ready fix hints at doctor startup,
not at first-retry mid-cycle.
VERIFICATION
============
- bun run verify: 28/28 checks green (includes 2 new lint guards:
check-no-double-retry, check-batch-audit-site)
- bun run test: 11453 pass / 1 pre-existing flake (schema-cli.test.ts —
confirmed by running on clean master, NOT introduced by this wave)
- bun run test:slow: 40/40 including new test/core/retry-stress.slow.test.ts
(100 batches × 30% blip rate × decorrelated jitter, zero row loss)
- bunx tsc --noEmit: 0 errors
REVIEWS
=======
- CEO review (SELECTIVE EXPANSION): 4 cherry-picks proposed, 4 accepted
- Eng review (2 passes): 10 findings, 0 critical gaps, architectural
pivot from per-site to engine-level wrap
- Codex independent review: 23 findings; 10 critical/high absorbed
(decorrelated jitter, 12s backoff window, AbortSignal, idempotency
proof, backfill unification, typed audit-site enum, doctor expiry
thresholds, audit pruning, env validation at doctor startup)
PR #1523 closed and absorbed (@garrytan-agents original extract.ts fix
preserved via co-author trailer; 5 test cases moved to test/core/retry.test.ts
with assertions adjusted for the v0.41.19.0 BULK_RETRY_OPTS defaults).
Co-authored-by: garrytan-agents <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import postgres from 'postgres';
|
||||
import type {
|
||||
BrainEngine,
|
||||
BatchOpts,
|
||||
LinkBatchInput, TimelineBatchInput,
|
||||
ReservedConnection,
|
||||
DreamVerdict, DreamVerdictInput,
|
||||
@@ -12,6 +13,8 @@ import type {
|
||||
NewFact, FactListOpts, FactsHealth,
|
||||
SourceRow,
|
||||
} from './engine.ts';
|
||||
import { withRetry, BULK_RETRY_OPTS, resolveBulkRetryOpts, computeNextDelay, type BatchAuditSite } from './retry.ts';
|
||||
import { logBatchRetry as auditLogBatchRetry, logBatchExhausted as auditLogBatchExhausted } from './audit/batch-retry-audit.ts';
|
||||
import type {
|
||||
DomainBankSampleOpts, CorpusSampleOpts, DomainBankRow,
|
||||
} from './types.ts';
|
||||
@@ -1810,8 +1813,79 @@ export class PostgresEngine implements BrainEngine {
|
||||
return result;
|
||||
}
|
||||
|
||||
// v0.41.18.0: lazy-cached resolveBulkRetryOpts result. Constructor-time
|
||||
// resolution would force env validation at module-load, which breaks tests
|
||||
// that withEnv-mutate after engine construction. Lazy + cache-once preserves
|
||||
// doctor's "bad env surfaces at startup" UX (codex M-10) for the production
|
||||
// path where doctor runs first.
|
||||
private _bulkRetryOptsCache?: ReturnType<typeof resolveBulkRetryOpts>;
|
||||
private getBulkRetryOpts(): ReturnType<typeof resolveBulkRetryOpts> {
|
||||
if (!this._bulkRetryOptsCache) this._bulkRetryOptsCache = resolveBulkRetryOpts();
|
||||
return this._bulkRetryOptsCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.41.18.0 — internal retry helper for the 3 batch primitives. Wraps fn
|
||||
* in withRetry with BULK_RETRY_OPTS defaults + env overrides + audit-site
|
||||
* label + AbortSignal. Audit JSONL emission on every retry attempt
|
||||
* (success path) and on exhausted retries (lost rows).
|
||||
*
|
||||
* The auditSite kwarg is type-guarded via BatchAuditSite enum; CI lint
|
||||
* `scripts/check-batch-audit-site.sh` enforces enum membership at build.
|
||||
*/
|
||||
private async batchRetry<T>(
|
||||
auditSite: BatchAuditSite,
|
||||
signal: AbortSignal | undefined,
|
||||
fn: () => Promise<T>,
|
||||
batchSize: number,
|
||||
): Promise<T> {
|
||||
const opts = this.getBulkRetryOpts();
|
||||
let prevDelay = 0;
|
||||
try {
|
||||
return await withRetry(fn, {
|
||||
maxRetries: opts.maxRetries,
|
||||
delayMs: opts.delayMs,
|
||||
delayMaxMs: opts.delayMaxMs,
|
||||
jitter: BULK_RETRY_OPTS.jitter,
|
||||
auditSite,
|
||||
signal,
|
||||
onRetry: (attempt, err) => {
|
||||
// Compute delay for this attempt for the audit record. withRetry
|
||||
// re-computes internally; this mirrors the math so the audit value
|
||||
// matches what actually sleeps.
|
||||
const delay = computeNextDelay(attempt - 1, prevDelay, opts.delayMs, opts.delayMaxMs, BULK_RETRY_OPTS.jitter);
|
||||
prevDelay = delay;
|
||||
auditLogBatchRetry(auditSite, batchSize, attempt, delay, err);
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(`[${auditSite}] connection blip, retrying (attempt ${attempt}/${opts.maxRetries}): ${msg}\n`);
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// Distinguish "retries exhausted" (a retryable error that ran out of
|
||||
// attempts) from "non-retryable" (caller bug, constraint violation,
|
||||
// etc.). Only the former counts as an exhausted-retry audit event.
|
||||
// withRetry propagates the last retryable error after exhausting
|
||||
// attempts — we re-classify via isRetryableConnError indirectly: if
|
||||
// the error reached us AND opts.maxRetries was hit, the audit row
|
||||
// matters. RetryAbortError (clean shutdown) skips audit.
|
||||
if (err instanceof Error && err.name === 'RetryAbortError') throw err;
|
||||
// Best-effort exhausted-retry log. If the error wasn't retryable in
|
||||
// the first place, isRetryableConnError(err) is false and we skip.
|
||||
// Lazy-import to avoid a circular dep concern.
|
||||
const { isRetryableConnError } = await import('./retry.ts');
|
||||
if (isRetryableConnError(err)) {
|
||||
auditLogBatchExhausted(auditSite, batchSize, opts.maxRetries + 1, err);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Chunks
|
||||
async upsertChunks(slug: string, chunks: ChunkInput[], opts?: { sourceId?: string }): Promise<void> {
|
||||
async upsertChunks(slug: string, chunks: ChunkInput[], opts?: { sourceId?: string } & BatchOpts): Promise<void> {
|
||||
return this.batchRetry(opts?.auditSite ?? 'upsertChunks', opts?.signal, () => this._upsertChunksOnce(slug, chunks, opts), chunks.length);
|
||||
}
|
||||
|
||||
private async _upsertChunksOnce(slug: string, chunks: ChunkInput[], opts?: { sourceId?: string }): Promise<void> {
|
||||
const sql = this.sql;
|
||||
const sourceId = opts?.sourceId ?? 'default';
|
||||
|
||||
@@ -2165,8 +2239,12 @@ export class PostgresEngine implements BrainEngine {
|
||||
`;
|
||||
}
|
||||
|
||||
async addLinksBatch(links: LinkBatchInput[]): Promise<number> {
|
||||
async addLinksBatch(links: LinkBatchInput[], opts?: BatchOpts): Promise<number> {
|
||||
if (links.length === 0) return 0;
|
||||
return this.batchRetry(opts?.auditSite ?? 'addLinksBatch', opts?.signal, () => this._addLinksBatchOnce(links), links.length);
|
||||
}
|
||||
|
||||
private async _addLinksBatchOnce(links: LinkBatchInput[]): Promise<number> {
|
||||
const sql = this.sql;
|
||||
// unnest() pattern: 7 array-typed bound parameters regardless of batch size.
|
||||
// Avoids the 65535-parameter cap and the postgres-js sql(rows, ...) helper's
|
||||
@@ -2800,8 +2878,12 @@ export class PostgresEngine implements BrainEngine {
|
||||
`;
|
||||
}
|
||||
|
||||
async addTimelineEntriesBatch(entries: TimelineBatchInput[]): Promise<number> {
|
||||
async addTimelineEntriesBatch(entries: TimelineBatchInput[], opts?: BatchOpts): Promise<number> {
|
||||
if (entries.length === 0) return 0;
|
||||
return this.batchRetry(opts?.auditSite ?? 'addTimelineEntriesBatch', opts?.signal, () => this._addTimelineEntriesBatchOnce(entries), entries.length);
|
||||
}
|
||||
|
||||
private async _addTimelineEntriesBatchOnce(entries: TimelineBatchInput[]): Promise<number> {
|
||||
const sql = this.sql;
|
||||
const slugs = entries.map(e => e.slug);
|
||||
const dates = entries.map(e => e.date);
|
||||
|
||||
Reference in New Issue
Block a user