mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
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>
157 lines
7.5 KiB
TypeScript
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);
|
|
}
|