mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
6ae94301a6
* v0.41.26.1 fix: lock-renewal cathedral — closes ~39 worker crashes/day (supersedes #1567)
Production worker daemons against Supabase / PgBouncer were crashing
~39 times/day with `unhandledRejection at renewLock`. PR #1567
proposed the right try/catch shape; this wave incorporates it and
closes the entire bug class (4 inside-review + 8 outside-voice
findings absorbed via 9 locked design decisions).
What's fixed:
- `setInterval(async () => await renewLock(...))` replaced with a
sync wrapper around the new pure `runLockRenewalTick` function.
No more unhandled rejections escaping the timer callback.
- Second crash vector closed: `.catch()` on the stored
`executeJob(...).finally(...)` promise so failJob/completeJob
throws during the same outage can't propagate to
`process.on('unhandledRejection')`.
- Per-call `Promise.race` timeout (default `lockDuration/3`) bounds
hung renewLock calls so the re-entrancy guard can't wedge
indefinitely.
- Time-based abort (NOT count-based) so the worker releases its
lock BEFORE another worker can reclaim. With the prior 3-strike
count + 30s lockDuration, a 15s window let other workers race.
- Infrastructure aborts (`lock-renewal-failed`, `lock-lost`) don't
burn job attempts — `executeJob`'s catch consults the exported
`INFRASTRUCTURE_ABORT_REASONS` set and skips `failJob` so the
stall detector reclaims cleanly.
- Universal grace-eviction: 30s force-evict safety net now fires
for ANY abort reason, not just `job.timeout_ms`.
What's added:
- `src/core/minions/lock-renewal-tick.ts` (NEW): pure extracted
state-machine function + env-knob resolver. Three operator-tunable
knobs via env (max-failures-for-audit, call-timeout-ms,
safety-margin-ms) with stderr-warn-once on bad input + default
fallback.
- `src/core/audit/lock-renewal-audit.ts` (NEW): sibling of
`batch-retry-audit.ts`. Four outcomes: failure /
success_after_failure / gave_up / executeJob_rejected. JSONL at
`~/.gbrain/audit/lock-renewal-YYYY-Www.jsonl`.
- `src/core/audit/redact-connection-info.ts` (NEW): shared privacy
helper. Strips Postgres URLs, host=, user=, password=, IPv4 from
error messages before they hit audit JSONL. Wired into BOTH the
new lock-renewal audit AND the existing batch-retry audit
(privacy backfill — same risk class).
- `scripts/check-worker-lock-renewal-shape.sh` (NEW): CI guard
wired into `bun run verify`. Asserts the v0.41.22.1 bug pattern
(`lockTimer = setInterval(async ...)`) stays absent AND the pure
function call site survives refactors. Bug-pattern-specific so it
doesn't fight legitimate refactors (codex C12).
Tests: 64 new cases across 5 new test files. 182 existing minion +
worker tests still pass. All hermetic — no PGLite, no real network,
no `mock.module`.
Plan + 9 decisions + codex outside-voice review at
~/.claude/plans/system-instruction-you-are-working-humming-nygaard.md
Closes #1567 (incorporates the contributor's try/catch shape; closes
the bug class structurally).
Co-Authored-By: @garrytan-agents <noreply@github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: fill A-H gaps for v0.41.26.1 lock-renewal cathedral
The original v0.41.26.1 wave shipped 64 hermetic unit tests on the
pure tick function, audit primitives, and redactor. Post-ship audit
flagged 8 wiring gaps the pure tests can't see — A (launchJob
wiring), B (executeJob skip-failJob), C (.catch on stored promise),
D (INFRASTRUCTURE_ABORT_REASONS export), E (universal grace-evict),
F (executeJob_rejected end-to-end), G (re-entrancy guard at worker
layer), H (gold-standard E2E regression).
Now closed:
- **test/worker-lock-renewal-e2e.serial.test.ts** (1 test, gap H):
the headline gold-standard regression. Real PGLite + real
MinionWorker + executeRaw wrap that injects renewLock failures on
demand. Pins that the worker process DOES NOT crash via
unhandledRejection under sustained renewLock throws, the handler
observes abort.signal.aborted = true with reason
'lock-renewal-failed', and the audit JSONL contains both `failure`
and `gave_up` events. The exact v0.41.22.1 production bug class.
Quarantined to its own file because bun:test serial + PGLite has an
unresolved interaction with multiple MinionWorker-driven tests in
the same file (second test's queue.add hangs indefinitely).
- **test/worker-lock-renewal-shape.test.ts** (18 tests, gaps A-G):
source-shape behavioral pins. Greps worker.ts function bodies for
the patterns the locked decisions promised: launchJob calls
runLockRenewalTick + resolveLockRenewalKnobs + uses
lockRenewalAudit; tickInFlight declared and gated correctly; stored
executeJob promise has .catch with logExecuteJobRejected + console
stderr; abort.signal.addEventListener fires for any abort (not just
timeout_ms); INFRASTRUCTURE_ABORT_REASONS used inside executeJob's
catch with return-early shape. Bug-pattern-specific so a refactor
that genuinely improves the shape passes; a refactor that
accidentally strips a guarantee fails loud.
All 83 lock-renewal wave tests pass in 5.2s. 205 existing minion +
worker tests still green. No production code changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: typecheck errors in worker-lock-renewal-e2e.serial.test.ts
CI verify failed on the new E2E gap-fill test (commit a8b282d4) due
to two TS errors that bun's runtime accepts but tsc rejects:
1. line 92: `originalExecuteRaw(sql, ...args)` with `args: unknown[]`
— TS can't prove args has <=2 elements, so the call site looks
like 1+1+N args against a function that accepts 1-3. Fixed by
destructuring the wrap params explicitly: `(sql, params?, opts?)`
matching the executeRaw signature, then calling
`originalExecuteRaw(sql, params, opts)` with named args.
2. line 145: `expect(abortReason).toBe('lock-renewal-failed')` where
`abortReason: string | null = null`. TS narrows the variable to
`null` because the closure assignment in worker.register isn't
observable to the inferrer. bun:test's `.toBe` overload then
picks the null variant and rejects the string literal. Fixed by
`as unknown as string` cast — the preceding `handlerAbortObserved`
assertion guarantees we entered the branch where abortReason was
assigned. Documented inline.
Local verify (29 checks) now green; E2E test still passes in 5.0s.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: @garrytan-agents <noreply@github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
175 lines
7.3 KiB
TypeScript
175 lines
7.3 KiB
TypeScript
/**
|
|
* v0.41.26.1 — gold-standard E2E regression for the lock-renewal
|
|
* cathedral wave (gap H from the post-ship coverage audit).
|
|
*
|
|
* Single test, single describe. The bun:test serial runner has an
|
|
* unresolved interaction with PGLite when multiple MinionWorker-driven
|
|
* tests share a file (the second test's `queue.add` hangs indefinitely
|
|
* even with cleanly reset state between tests). Isolating H into its
|
|
* own file sidesteps the issue without touching production code.
|
|
*
|
|
* Companion tests for gaps A, B, C, D, E, F, G live in:
|
|
* - test/worker-lock-renewal.test.ts (pure-function state machine, 18 cases)
|
|
* - test/worker-lock-renewal-shape.test.ts (source-shape behavioral pins, this wave)
|
|
* - test/audit/lock-renewal-audit.test.ts (audit module, 11 cases)
|
|
* - test/audit/redact-connection-info.test.ts (privacy redactor, 15 cases)
|
|
*
|
|
* What this test pins:
|
|
*
|
|
* - With renewLock throws happening continuously, the worker process
|
|
* MUST NOT crash via unhandledRejection (the v0.41.22.1 bug).
|
|
* - The handler observes abort.signal.aborted = true (proves the
|
|
* time-based abort fires through the wiring).
|
|
* - The audit JSONL contains both `failure` (per-throw) and `gave_up`
|
|
* (time-based deadline trip) events.
|
|
*
|
|
* Hermetic: real PGLite, no DATABASE_URL, no docker, no live PgBouncer.
|
|
* Engine wrap on executeRaw injects the simulated connection drop on
|
|
* renewLock-shaped SQL only; everything else passes through.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { MinionWorker } from '../src/core/minions/worker.ts';
|
|
import { MinionQueue } from '../src/core/minions/queue.ts';
|
|
import { readRecentLockRenewalEvents } from '../src/core/audit/lock-renewal-audit.ts';
|
|
|
|
// Module-level audit dir + env mutation. withEnv's restore semantics
|
|
// race with the worker's setInterval callbacks (audit writes can land
|
|
// AFTER withEnv exits, under the wrong env). Persistent module-level
|
|
// is the deterministic fix.
|
|
const auditDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lr-e2e-'));
|
|
const PRIOR_AUDIT_DIR = process.env.GBRAIN_AUDIT_DIR;
|
|
process.env.GBRAIN_AUDIT_DIR = auditDir;
|
|
|
|
let engine: PGLiteEngine;
|
|
let queue: MinionQueue;
|
|
let originalExecuteRaw: PGLiteEngine['executeRaw'];
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({ database_url: '' });
|
|
await engine.initSchema();
|
|
queue = new MinionQueue(engine);
|
|
originalExecuteRaw = engine.executeRaw.bind(engine);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
try { fs.rmSync(auditDir, { recursive: true, force: true }); } catch { /* */ }
|
|
if (PRIOR_AUDIT_DIR === undefined) {
|
|
delete process.env.GBRAIN_AUDIT_DIR;
|
|
} else {
|
|
process.env.GBRAIN_AUDIT_DIR = PRIOR_AUDIT_DIR;
|
|
}
|
|
});
|
|
|
|
describe('H: gold-standard regression — worker survives renewLock throws', () => {
|
|
test('the v0.41.22.1 production crash class no longer crashes the worker', async () => {
|
|
await engine.executeRaw('DELETE FROM minion_jobs');
|
|
await queue.add('long-runner', {});
|
|
|
|
// Wrap executeRaw to inject renewLock failures. The renewLock SQL
|
|
// shape (`UPDATE minion_jobs SET lock_until = now() + ...`) is narrow
|
|
// enough to skip claim / completeJob / failJob / etc.
|
|
let throwsRemaining = 50;
|
|
let renewLockCallCount = 0;
|
|
(engine as { executeRaw: PGLiteEngine['executeRaw'] }).executeRaw = async (
|
|
sql: string,
|
|
params?: unknown[],
|
|
opts?: { signal?: AbortSignal },
|
|
) => {
|
|
const isRenewLock = sql.includes('SET lock_until = now()') && sql.includes('lock_token');
|
|
if (isRenewLock) {
|
|
renewLockCallCount++;
|
|
if (throwsRemaining > 0) {
|
|
throwsRemaining--;
|
|
throw new Error('simulated PgBouncer connection drop');
|
|
}
|
|
}
|
|
return originalExecuteRaw(sql, params, opts);
|
|
};
|
|
|
|
// Short lockDuration → 50ms timer interval, abort deadline at
|
|
// lockDuration - safetyMargin = 100 - 16 = 84ms. Sustained throws
|
|
// should trip the deadline within ~150ms.
|
|
const worker = new MinionWorker(engine, {
|
|
concurrency: 1,
|
|
pollInterval: 25,
|
|
lockDuration: 100,
|
|
});
|
|
|
|
let handlerEntered = false;
|
|
let handlerAbortObserved = false;
|
|
let abortReason: string | null = null;
|
|
worker.register('long-runner', async (ctx) => {
|
|
handlerEntered = true;
|
|
const start = Date.now();
|
|
while (!ctx.signal.aborted && Date.now() - start < 4000) {
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
}
|
|
handlerAbortObserved = ctx.signal.aborted;
|
|
if (ctx.signal.aborted) {
|
|
abortReason = ctx.signal.reason instanceof Error
|
|
? ctx.signal.reason.message
|
|
: String(ctx.signal.reason);
|
|
}
|
|
});
|
|
|
|
// The headline regression check: install a process-level
|
|
// unhandledRejection listener. Pre-v0.41.26.1, a renewLock throw
|
|
// inside `setInterval(async ...)` would propagate here and the
|
|
// daemon would exit code 1.
|
|
let unhandledRejectionFired: unknown = null;
|
|
const rejectionListener = (reason: unknown) => {
|
|
unhandledRejectionFired = reason;
|
|
};
|
|
process.on('unhandledRejection', rejectionListener);
|
|
|
|
const p = worker.start();
|
|
try {
|
|
// Fixed sleep — handler enters within ~50ms, abort fires by
|
|
// ~200ms; 2s gives plenty of margin AND lets audit events
|
|
// accumulate before we read them back.
|
|
await new Promise((r) => setTimeout(r, 2000));
|
|
|
|
// Headline assertion: worker process didn't die.
|
|
expect(unhandledRejectionFired).toBe(null);
|
|
|
|
// Handler wiring assertions: launchJob plumbed the abort
|
|
// signal through correctly.
|
|
expect(handlerEntered).toBe(true);
|
|
expect(handlerAbortObserved).toBe(true);
|
|
// TS narrows `abortReason` to literal `null` because the closure
|
|
// assignment in worker.register isn't observable to the inferrer.
|
|
// The preceding `handlerAbortObserved` assertion guarantees we
|
|
// entered the if-aborted branch where abortReason was assigned;
|
|
// cast via unknown to satisfy the overload.
|
|
expect(abortReason as unknown as string).toBe('lock-renewal-failed');
|
|
|
|
// renewLock was actually called multiple times (sanity check
|
|
// that the fault injection fired).
|
|
expect(renewLockCallCount).toBeGreaterThan(0);
|
|
|
|
// Audit JSONL has the expected event shapes. `failure` (per-throw)
|
|
// and `gave_up` (time-based deadline tripped) MUST both appear.
|
|
const audit = readRecentLockRenewalEvents(48);
|
|
expect(audit.events.length).toBeGreaterThan(0);
|
|
const failures = audit.events.filter((e) => e.outcome === 'failure');
|
|
const gaveUp = audit.events.filter((e) => e.outcome === 'gave_up');
|
|
expect(failures.length).toBeGreaterThan(0);
|
|
expect(gaveUp.length).toBeGreaterThan(0);
|
|
// The error message survived through the redactor + truncator.
|
|
expect(gaveUp[0].error_message_summary).toMatch(/simulated PgBouncer/);
|
|
} finally {
|
|
process.off('unhandledRejection', rejectionListener);
|
|
(engine as { executeRaw: PGLiteEngine['executeRaw'] }).executeRaw = originalExecuteRaw;
|
|
worker.stop();
|
|
await Promise.race([p, new Promise((r) => setTimeout(r, 2000))]);
|
|
}
|
|
}, 30_000);
|
|
});
|