Files
gbrain/test/doctor-batch-retry.test.ts
T
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

157 lines
7.5 KiB
TypeScript

// v0.41.18.0 — batch_retry_health doctor check (codex H-9 thresholds).
//
// Hermetic: never touches a real engine. Stubs the audit-writer read by
// pointing GBRAIN_AUDIT_DIR at a tempdir and writing synthetic events.
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 { checkBatchRetryHealth } from '../src/commands/doctor.ts';
import type { BrainEngine } from '../src/core/engine.ts';
import { BATCH_RETRY_FEATURE_NAME } from '../src/core/audit/batch-retry-audit.ts';
// Minimal stub engine — checkBatchRetryHealth doesn't use it (the audit
// read is via filesystem). Cast suppresses BrainEngine's many required
// methods we don't need here.
const stubEngine = {} as BrainEngine;
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'doctor-batch-retry-'));
});
afterEach(() => {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* */ }
});
function writeEvent(now: Date, event: Record<string, unknown>) {
const filename = `${BATCH_RETRY_FEATURE_NAME}-${now.getUTCFullYear()}-W${String(getIsoWeek(now)).padStart(2, '0')}.jsonl`;
const filePath = path.join(tmpDir, filename);
fs.appendFileSync(filePath, JSON.stringify({ ts: now.toISOString(), ...event }) + '\n');
}
describe('checkBatchRetryHealth — ok states', () => {
test('no events in window = ok', async () => {
await withEnv({ GBRAIN_AUDIT_DIR: tmpDir }, async () => {
const check = await checkBatchRetryHealth(stubEngine);
expect(check.status).toBe('ok');
expect(check.message).toContain('No exhausted batch retries');
});
});
test('only successful retries = ok with recovery count', async () => {
await withEnv({ GBRAIN_AUDIT_DIR: tmpDir }, async () => {
const now = new Date();
writeEvent(now, { site: 'extract.links_inc', batch_size: 100, attempt: 1, outcome: 'success', delay_ms: 1000, error_message_summary: 'blip' });
writeEvent(now, { site: 'extract.links_inc', batch_size: 100, attempt: 2, outcome: 'success', delay_ms: 3000, error_message_summary: 'blip' });
const check = await checkBatchRetryHealth(stubEngine);
expect(check.status).toBe('ok');
expect(check.message).toContain('transient retry');
});
});
test('1-2 exhausted from same site (under per-site threshold of 3) = ok', async () => {
await withEnv({ GBRAIN_AUDIT_DIR: tmpDir }, async () => {
const now = new Date();
writeEvent(now, { site: 'extract.links_inc', batch_size: 100, attempt: 4, outcome: 'exhausted', delay_ms: 0, error_message_summary: 'breaker' });
writeEvent(now, { site: 'extract.links_inc', batch_size: 100, attempt: 4, outcome: 'exhausted', delay_ms: 0, error_message_summary: 'breaker' });
const check = await checkBatchRetryHealth(stubEngine);
expect(check.status).toBe('ok');
expect(check.message).toContain('below per-site threshold');
});
});
});
describe('checkBatchRetryHealth — warn states (codex H-9 thresholds)', () => {
test('>=3 exhausted from same site in 24h = warn', async () => {
await withEnv({ GBRAIN_AUDIT_DIR: tmpDir }, async () => {
const now = new Date();
for (let i = 0; i < 3; i++) {
writeEvent(now, { site: 'extract.links_inc', batch_size: 100, attempt: 4, outcome: 'exhausted', delay_ms: 0, error_message_summary: 'breaker' });
}
const check = await checkBatchRetryHealth(stubEngine);
expect(check.status).toBe('warn');
expect(check.message).toContain('extract.links_inc');
expect(check.message).toContain('GBRAIN_BULK_MAX_RETRIES');
});
});
test('>=5 cross-site exhausted in 24h = warn', async () => {
await withEnv({ GBRAIN_AUDIT_DIR: tmpDir }, async () => {
const now = new Date();
// 2 from one site, 2 from another, 1 from a third = 5 total, none >=3 per site.
writeEvent(now, { site: 'extract.links_inc', batch_size: 100, attempt: 4, outcome: 'exhausted', delay_ms: 0, error_message_summary: 'a' });
writeEvent(now, { site: 'extract.links_inc', batch_size: 100, attempt: 4, outcome: 'exhausted', delay_ms: 0, error_message_summary: 'a' });
writeEvent(now, { site: 'extract.timeline_fs', batch_size: 50, attempt: 4, outcome: 'exhausted', delay_ms: 0, error_message_summary: 'a' });
writeEvent(now, { site: 'extract.timeline_fs', batch_size: 50, attempt: 4, outcome: 'exhausted', delay_ms: 0, error_message_summary: 'a' });
writeEvent(now, { site: 'mcp.put_page.autolink', batch_size: 25, attempt: 4, outcome: 'exhausted', delay_ms: 0, error_message_summary: 'a' });
const check = await checkBatchRetryHealth(stubEngine);
expect(check.status).toBe('warn');
});
});
});
describe('checkBatchRetryHealth — fail state', () => {
test('>=20 exhausted in 24h = fail (sustained breaker)', async () => {
await withEnv({ GBRAIN_AUDIT_DIR: tmpDir }, async () => {
const now = new Date();
for (let i = 0; i < 20; i++) {
writeEvent(now, { site: 'extract.links_inc', batch_size: 100, attempt: 4, outcome: 'exhausted', delay_ms: 0, error_message_summary: 'breaker' });
}
const check = await checkBatchRetryHealth(stubEngine);
expect(check.status).toBe('fail');
expect(check.message).toContain('Sustained circuit-breaker');
});
});
});
describe('checkBatchRetryHealth — codex M-10 env validation at doctor time', () => {
test('invalid GBRAIN_BULK_MAX_RETRIES surfaces at doctor time with paste-ready hint', async () => {
await withEnv({ GBRAIN_AUDIT_DIR: tmpDir, GBRAIN_BULK_MAX_RETRIES: '-1' }, async () => {
const check = await checkBatchRetryHealth(stubEngine);
expect(check.status).toBe('warn');
expect(check.message).toContain('GBRAIN_BULK_*');
expect(check.message).toContain('export GBRAIN_BULK_MAX_RETRIES');
});
});
test('valid GBRAIN_BULK_MAX_RETRIES=0 (debug-mode disable) is accepted', async () => {
await withEnv({ GBRAIN_AUDIT_DIR: tmpDir, GBRAIN_BULK_MAX_RETRIES: '0' }, async () => {
const check = await checkBatchRetryHealth(stubEngine);
expect(check.status).toBe('ok'); // 0 retries is valid; no exhausted events either
});
});
});
describe('checkBatchRetryHealth — codex H-9 corruption tolerance', () => {
test('corrupted JSONL lines are counted, not crashed-on', async () => {
await withEnv({ GBRAIN_AUDIT_DIR: tmpDir }, async () => {
const now = new Date();
writeEvent(now, { site: 'extract.links_inc', batch_size: 100, attempt: 1, outcome: 'success', delay_ms: 1000, error_message_summary: 'a' });
const filename = `${BATCH_RETRY_FEATURE_NAME}-${now.getUTCFullYear()}-W${String(getIsoWeek(now)).padStart(2, '0')}.jsonl`;
const filePath = path.join(tmpDir, filename);
fs.appendFileSync(filePath, '{not json}\nstill not\n');
const check = await checkBatchRetryHealth(stubEngine);
// Successful retry only, no exhausted events = ok. The corrupt count
// appears in the message as a note.
expect(check.status).toBe('ok');
expect(check.message).toContain('corrupt JSONL');
});
});
});
function getIsoWeek(d: Date): number {
const target = new Date(d.valueOf());
const dayNumber = (d.getUTCDay() + 6) % 7;
target.setUTCDate(target.getUTCDate() - dayNumber + 3);
const firstThursday = target.valueOf();
target.setUTCMonth(0, 1);
if (target.getUTCDay() !== 4) {
target.setUTCMonth(0, 1 + ((4 - target.getUTCDay()) + 7) % 7);
}
return 1 + Math.ceil((firstThursday - target.valueOf()) / 604800000);
}