Files
gbrain/test/core/retry-stress.slow.test.ts
Garry Tan a7b79b66d4 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>
2026-05-26 23:15:44 -07:00

225 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
});
});