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>
225 lines
8.0 KiB
TypeScript
225 lines
8.0 KiB
TypeScript
// v0.41.18.0 — withRetry stress regression test (T7 / CEO D6).
|
||
//
|
||
// Pins the contract the engine-level batch wrap depends on:
|
||
// - 30% blip rate (matches the production Supavisor incident shape)
|
||
// - 100 simulated batches
|
||
// - BULK_RETRY_OPTS defaults (maxRetries=3, decorrelated jitter)
|
||
// - Asserts zero row loss when failures are bounded by maxRetries
|
||
// - Asserts retries DO fire (so a future "optimize" can't silently
|
||
// disable retries and pass the suite)
|
||
// - Audit JSONL records correct outcome per case
|
||
//
|
||
// Lives in .slow.test.ts tier per CLAUDE.md test taxonomy. Uses delayMs=1
|
||
// for hermeticity — the test exercises the retry/jitter math + audit emission,
|
||
// not real timing.
|
||
|
||
import { describe, expect, test, beforeEach, afterEach } from 'bun:test';
|
||
import * as fs from 'fs';
|
||
import * as os from 'os';
|
||
import * as path from 'path';
|
||
import { withEnv } from '../helpers/with-env.ts';
|
||
import {
|
||
withRetry,
|
||
computeNextDelay,
|
||
BULK_RETRY_OPTS,
|
||
} from '../../src/core/retry.ts';
|
||
import {
|
||
logBatchRetry,
|
||
logBatchExhausted,
|
||
readRecentBatchRetryEvents,
|
||
} from '../../src/core/audit/batch-retry-audit.ts';
|
||
|
||
let tmpDir: string;
|
||
|
||
beforeEach(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'retry-stress-'));
|
||
});
|
||
|
||
afterEach(() => {
|
||
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* */ }
|
||
});
|
||
|
||
/**
|
||
* Simulate the engine-level batchRetry helper without spinning up a real
|
||
* engine. Mirrors postgres-engine.ts batchRetry: withRetry + audit emission
|
||
* for both success-after-retry AND exhausted-retry paths.
|
||
*/
|
||
async function simulateEngineBatchRetry<T>(
|
||
auditSite: 'addLinksBatch',
|
||
batchSize: number,
|
||
fn: () => Promise<T>,
|
||
): Promise<T> {
|
||
let prevDelay = 0;
|
||
let onRetryCount = 0;
|
||
try {
|
||
return await withRetry(fn, {
|
||
maxRetries: BULK_RETRY_OPTS.maxRetries,
|
||
delayMs: 1, // hermeticity — not testing real timing
|
||
delayMaxMs: 10,
|
||
jitter: BULK_RETRY_OPTS.jitter,
|
||
onRetry: (attempt, err) => {
|
||
onRetryCount++;
|
||
const delay = computeNextDelay(attempt - 1, prevDelay, 1, 10, BULK_RETRY_OPTS.jitter);
|
||
prevDelay = delay;
|
||
logBatchRetry(auditSite, batchSize, attempt, delay, err);
|
||
},
|
||
});
|
||
} catch (err) {
|
||
if (err instanceof Error && err.name === 'RetryAbortError') throw err;
|
||
const { isRetryableConnError } = await import('../../src/core/retry.ts');
|
||
if (isRetryableConnError(err)) {
|
||
logBatchExhausted(auditSite, batchSize, BULK_RETRY_OPTS.maxRetries + 1, err);
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
describe('stress: 100 batches × 30% blip rate with BULK_RETRY_OPTS', () => {
|
||
test('eventual success on all batches when blip count <= maxRetries', async () => {
|
||
await withEnv({ GBRAIN_AUDIT_DIR: tmpDir }, async () => {
|
||
// Deterministic seeded "blip" generator: a fixed sequence so failures
|
||
// happen at predictable batch positions. 30% blip rate, but the
|
||
// number of CONSECUTIVE blips on any one batch never exceeds 2 (which
|
||
// is comfortably within BULK_RETRY_OPTS.maxRetries=3). This validates
|
||
// the recovery path — zero row loss expected.
|
||
let totalBatches = 0;
|
||
let totalLost = 0;
|
||
let totalRetries = 0;
|
||
|
||
for (let batchIdx = 0; batchIdx < 100; batchIdx++) {
|
||
// Each batch fails 0-2 times then succeeds. Pseudo-random based on
|
||
// batchIdx for deterministic test runs.
|
||
const failureBudget = batchIdx % 10 < 3 ? (batchIdx % 3) + 1 : 0;
|
||
let calls = 0;
|
||
try {
|
||
await simulateEngineBatchRetry('addLinksBatch', 100, async () => {
|
||
calls++;
|
||
if (calls <= failureBudget) {
|
||
throw new Error('Connection terminated unexpectedly');
|
||
}
|
||
return 'ok';
|
||
});
|
||
totalBatches++;
|
||
if (failureBudget > 0) totalRetries += failureBudget;
|
||
} catch {
|
||
totalLost++;
|
||
}
|
||
}
|
||
|
||
expect(totalBatches).toBe(100);
|
||
expect(totalLost).toBe(0);
|
||
// 30% of batches blip; each blipping batch retries 1-3 times.
|
||
// At minimum some retries should have fired.
|
||
expect(totalRetries).toBeGreaterThan(0);
|
||
|
||
// Audit JSONL should record every retry attempt.
|
||
const auditResult = readRecentBatchRetryEvents(24);
|
||
const successEvents = auditResult.events.filter((e) => e.outcome === 'success');
|
||
const exhaustedEvents = auditResult.events.filter((e) => e.outcome === 'exhausted');
|
||
expect(successEvents.length).toBe(totalRetries);
|
||
expect(exhaustedEvents.length).toBe(0);
|
||
});
|
||
}, 30_000);
|
||
|
||
test('exhausted retries recorded when blip count > maxRetries', async () => {
|
||
await withEnv({ GBRAIN_AUDIT_DIR: tmpDir }, async () => {
|
||
// Force batch 7 to fail more times than maxRetries allows.
|
||
const failOn = 7;
|
||
const failureCount = BULK_RETRY_OPTS.maxRetries + 1; // exceeds budget
|
||
let lostBatches = 0;
|
||
let successfulBatches = 0;
|
||
|
||
for (let batchIdx = 0; batchIdx < 20; batchIdx++) {
|
||
let calls = 0;
|
||
try {
|
||
await simulateEngineBatchRetry('addLinksBatch', 50, async () => {
|
||
calls++;
|
||
if (batchIdx === failOn && calls <= failureCount) {
|
||
throw new Error('Connection terminated unexpectedly');
|
||
}
|
||
return 'ok';
|
||
});
|
||
successfulBatches++;
|
||
} catch {
|
||
lostBatches++;
|
||
}
|
||
}
|
||
|
||
expect(successfulBatches).toBe(19);
|
||
expect(lostBatches).toBe(1);
|
||
|
||
const auditResult = readRecentBatchRetryEvents(24);
|
||
const exhausted = auditResult.events.filter((e) => e.outcome === 'exhausted');
|
||
expect(exhausted.length).toBe(1);
|
||
expect(exhausted[0].site).toBe('addLinksBatch');
|
||
expect(exhausted[0].batch_size).toBe(50);
|
||
});
|
||
}, 30_000);
|
||
|
||
test('non-retryable error propagates immediately, no exhausted audit', async () => {
|
||
await withEnv({ GBRAIN_AUDIT_DIR: tmpDir }, async () => {
|
||
let calls = 0;
|
||
try {
|
||
await simulateEngineBatchRetry('addLinksBatch', 10, async () => {
|
||
calls++;
|
||
const err = Object.assign(new Error('duplicate key'), { code: '23505' });
|
||
throw err;
|
||
});
|
||
expect.unreachable();
|
||
} catch (e) {
|
||
expect((e as Error).message).toContain('duplicate key');
|
||
}
|
||
expect(calls).toBe(1); // no retry on non-retryable
|
||
|
||
const auditResult = readRecentBatchRetryEvents(24);
|
||
// Non-retryable errors are NOT audited — they're caller bugs/data
|
||
// issues, not retry-budget exhaustion.
|
||
expect(auditResult.events.length).toBe(0);
|
||
});
|
||
}, 30_000);
|
||
});
|
||
|
||
describe('decorrelated jitter sanity over 100 attempts', () => {
|
||
test('never produces near-zero delays (codex C-2 regression lock)', () => {
|
||
// Sample 100 retries against BULK_RETRY_OPTS to confirm the floor.
|
||
// Pre-codex-C-2, jitter='full' would have allowed near-zero delays
|
||
// here ~10% of the time. Decorrelated jitter floors at delayMs.
|
||
let prev = 0;
|
||
for (let attempt = 0; attempt < 100; attempt++) {
|
||
const d = computeNextDelay(
|
||
attempt % BULK_RETRY_OPTS.maxRetries,
|
||
prev,
|
||
BULK_RETRY_OPTS.delayMs,
|
||
BULK_RETRY_OPTS.delayMaxMs,
|
||
BULK_RETRY_OPTS.jitter,
|
||
);
|
||
// Floor at delayMs = 1000ms (Supavisor recovery floor).
|
||
expect(d).toBeGreaterThanOrEqual(BULK_RETRY_OPTS.delayMs);
|
||
expect(d).toBeLessThanOrEqual(BULK_RETRY_OPTS.delayMaxMs);
|
||
prev = d;
|
||
}
|
||
});
|
||
|
||
test('cumulative wait reaches >= 8s in worst-case (covers 5-10s Supavisor window)', () => {
|
||
let bestTotal = 0;
|
||
for (let trial = 0; trial < 100; trial++) {
|
||
let prev = 0;
|
||
let total = 0;
|
||
for (let attempt = 0; attempt < BULK_RETRY_OPTS.maxRetries; attempt++) {
|
||
const d = computeNextDelay(
|
||
attempt,
|
||
prev,
|
||
BULK_RETRY_OPTS.delayMs,
|
||
BULK_RETRY_OPTS.delayMaxMs,
|
||
BULK_RETRY_OPTS.jitter,
|
||
);
|
||
prev = d;
|
||
total += d;
|
||
}
|
||
if (total > bestTotal) bestTotal = total;
|
||
}
|
||
// BULK_RETRY_OPTS produces decorrelated worst case ~10+s+ (delayMaxMs cap).
|
||
expect(bestTotal).toBeGreaterThanOrEqual(8000);
|
||
});
|
||
});
|