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>
259 lines
12 KiB
TypeScript
259 lines
12 KiB
TypeScript
/**
|
|
* v0.41.26.1 — source-shape behavioral pins for the lock-renewal
|
|
* cathedral wave.
|
|
*
|
|
* These tests grep the actual worker.ts source for the patterns the
|
|
* locked decisions promised. They're not behavioral in the
|
|
* "run-the-code" sense — they're structural-regression guards that
|
|
* fail loud if a future refactor strips a load-bearing piece.
|
|
*
|
|
* Why source-shape pins instead of runtime integration tests:
|
|
* bun:test's serial runner has an unresolved interaction with PGLite
|
|
* + multiple MinionWorker-driven tests in the same file (the second
|
|
* test's queue.add hangs indefinitely). The headline regression (gap
|
|
* H) lives in its own single-test file
|
|
* (test/worker-lock-renewal-e2e.serial.test.ts); A, B, C, E, G are
|
|
* pinned here via source-shape grep.
|
|
*
|
|
* These pins are bug-pattern-specific, not implementation-specific —
|
|
* a future refactor that changes the SHAPE of how each guarantee is
|
|
* provided (e.g., AbortController.signal events → a different
|
|
* notification mechanism) would need to update both the source AND
|
|
* this test, which is the right level of friction. A refactor that
|
|
* accidentally REMOVES the guarantee would fail this test loudly.
|
|
*
|
|
* Companion files:
|
|
* - test/worker-lock-renewal.test.ts — pure state machine (18 unit tests)
|
|
* - test/worker-lock-renewal-e2e.serial.test.ts — H gold-standard E2E (1 test)
|
|
* - test/audit/lock-renewal-audit.test.ts — audit primitive (11 tests)
|
|
* - test/audit/redact-connection-info.test.ts — privacy redactor (15 tests)
|
|
* - test/audit/batch-retry-redaction.test.ts — sibling privacy backfill (3 tests)
|
|
* - test/scripts/check-worker-lock-renewal-shape.test.ts — CI guard meta (5 tests)
|
|
*
|
|
* Coverage map:
|
|
* - A. launchJob wires runLockRenewalTick → pinned here + the CI guard
|
|
* - B. executeJob skip-failJob on infra abort → pinned here
|
|
* - C. .catch() on stored executeJob.finally promise → pinned here
|
|
* - D. INFRASTRUCTURE_ABORT_REASONS export → pinned here + pure unit tests
|
|
* - E. universal grace-evict listener → pinned here
|
|
* - F. logExecuteJobRejected end-to-end → folded into C pin (call site)
|
|
* - G. tickInFlight re-entrancy guard → pinned here
|
|
* - H. gold-standard regression → behavioral E2E (sibling file)
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { INFRASTRUCTURE_ABORT_REASONS } from '../src/core/minions/worker.ts';
|
|
|
|
const REPO_ROOT = path.resolve(import.meta.dir, '..');
|
|
const WORKER_PATH = path.join(REPO_ROOT, 'src/core/minions/worker.ts');
|
|
|
|
// Read once at module load — failure to find the file is a strong signal
|
|
// the test file was moved without updating the path.
|
|
let workerSource: string;
|
|
try {
|
|
workerSource = fs.readFileSync(WORKER_PATH, 'utf8');
|
|
} catch (err) {
|
|
throw new Error(`Cannot read ${WORKER_PATH}: ${(err as Error).message}`);
|
|
}
|
|
|
|
// Helper: scope text to a single function body. Looks for `private launchJob(`
|
|
// (or whatever signature) and returns everything from that line to the next
|
|
// top-level `}` (heuristic — works for the project's bracing style).
|
|
function extractFunctionBody(source: string, signatureMarker: string): string {
|
|
const startIdx = source.indexOf(signatureMarker);
|
|
if (startIdx === -1) {
|
|
throw new Error(`Marker not found: ${signatureMarker}`);
|
|
}
|
|
// Find the opening brace of the function body.
|
|
const braceIdx = source.indexOf('{', startIdx);
|
|
if (braceIdx === -1) {
|
|
throw new Error(`No opening brace after marker: ${signatureMarker}`);
|
|
}
|
|
// Walk forward counting braces.
|
|
let depth = 1;
|
|
let i = braceIdx + 1;
|
|
while (i < source.length && depth > 0) {
|
|
const ch = source[i];
|
|
if (ch === '{') depth++;
|
|
else if (ch === '}') depth--;
|
|
i++;
|
|
if (depth === 0) break;
|
|
}
|
|
return source.slice(braceIdx, i);
|
|
}
|
|
|
|
// =============================================================================
|
|
// D — INFRASTRUCTURE_ABORT_REASONS export contract (also covered in pure tests)
|
|
// =============================================================================
|
|
|
|
describe('D: INFRASTRUCTURE_ABORT_REASONS export contract', () => {
|
|
test('exported Set contains exactly lock-renewal-failed + lock-lost', () => {
|
|
// Named-constant regression: any change to this set is a deliberate
|
|
// two-line edit (the constant + this test).
|
|
expect(INFRASTRUCTURE_ABORT_REASONS).toBeInstanceOf(Set);
|
|
expect(INFRASTRUCTURE_ABORT_REASONS.size).toBe(2);
|
|
expect(INFRASTRUCTURE_ABORT_REASONS.has('lock-renewal-failed')).toBe(true);
|
|
expect(INFRASTRUCTURE_ABORT_REASONS.has('lock-lost')).toBe(true);
|
|
});
|
|
|
|
test('source exports the constant (so executeJob.catch can import it)', () => {
|
|
// Without the `export const` shape, executeJob's infrastructure-
|
|
// abort guard can't reach the set; the import would fail at compile
|
|
// time but pinning the export site here documents the contract.
|
|
expect(workerSource).toMatch(/export const INFRASTRUCTURE_ABORT_REASONS/);
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// A — launchJob wires runLockRenewalTick
|
|
// =============================================================================
|
|
|
|
describe('A: launchJob wires the pure tick function', () => {
|
|
let launchJobBody: string;
|
|
test('extracts launchJob function body for further assertions', () => {
|
|
launchJobBody = extractFunctionBody(workerSource, 'private launchJob(');
|
|
expect(launchJobBody.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('launchJob calls runLockRenewalTick (the extracted pure function)', () => {
|
|
expect(launchJobBody).toMatch(/runLockRenewalTick\s*\(/);
|
|
});
|
|
|
|
test('launchJob constructs the LockRenewalState via the documented helper', () => {
|
|
// resolveLockRenewalKnobs reads the env knobs (D2). If it disappears
|
|
// from launchJob, operators can't tune via env vars.
|
|
expect(launchJobBody).toMatch(/resolveLockRenewalKnobs\s*\(/);
|
|
});
|
|
|
|
test('launchJob uses the lockRenewalAudit sink (not a fake / inline)', () => {
|
|
expect(launchJobBody).toMatch(/lockRenewalAudit/);
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// G — tickInFlight re-entrancy guard at the worker layer
|
|
// =============================================================================
|
|
|
|
describe('G: tickInFlight re-entrancy guard', () => {
|
|
test('launchJob declares the tickInFlight flag', () => {
|
|
const launchJobBody = extractFunctionBody(workerSource, 'private launchJob(');
|
|
expect(launchJobBody).toMatch(/let\s+tickInFlight\s*=\s*false/);
|
|
});
|
|
|
|
test('the setInterval callback checks tickInFlight and bails on re-entry', () => {
|
|
const launchJobBody = extractFunctionBody(workerSource, 'private launchJob(');
|
|
// The pattern is `if (tickInFlight) return;` — minor whitespace
|
|
// variation tolerated.
|
|
expect(launchJobBody).toMatch(/if\s*\(\s*tickInFlight\s*\)\s*return/);
|
|
});
|
|
|
|
test('the setInterval callback sets tickInFlight=true before scheduling work', () => {
|
|
const launchJobBody = extractFunctionBody(workerSource, 'private launchJob(');
|
|
expect(launchJobBody).toMatch(/tickInFlight\s*=\s*true/);
|
|
});
|
|
|
|
test('the post-tick finally clears tickInFlight back to false', () => {
|
|
const launchJobBody = extractFunctionBody(workerSource, 'private launchJob(');
|
|
expect(launchJobBody).toMatch(/tickInFlight\s*=\s*false/);
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// C + F — .catch() on stored executeJob.finally promise +
|
|
// logExecuteJobRejected end-to-end via the catch
|
|
// =============================================================================
|
|
|
|
describe('C + F: .catch() on stored executeJob promise + logExecuteJobRejected', () => {
|
|
test('the stored executeJob promise has a .catch() handler', () => {
|
|
const launchJobBody = extractFunctionBody(workerSource, 'private launchJob(');
|
|
// The pattern is `.executeJob(...).finally(...).catch(...)` — the
|
|
// catch closes the SECOND unhandledRejection vector codex caught.
|
|
// Match any `.catch(` after `.finally(` within launchJob's body.
|
|
const finallyIdx = launchJobBody.indexOf('.finally(');
|
|
expect(finallyIdx).toBeGreaterThan(-1);
|
|
// Search for .catch( after the .finally(
|
|
const tail = launchJobBody.slice(finallyIdx);
|
|
expect(tail).toMatch(/\.catch\s*\(/);
|
|
});
|
|
|
|
test('the .catch() handler calls lockRenewalAudit.logExecuteJobRejected', () => {
|
|
const launchJobBody = extractFunctionBody(workerSource, 'private launchJob(');
|
|
expect(launchJobBody).toMatch(/logExecuteJobRejected\s*\(/);
|
|
});
|
|
|
|
test('the .catch() handler also logs to stderr (operator visibility)', () => {
|
|
const launchJobBody = extractFunctionBody(workerSource, 'private launchJob(');
|
|
// The implementation uses console.error for the human-visible trail.
|
|
// Pin the call so a future refactor that drops it leaves the operator
|
|
// with audit JSONL only (which is harder to grep live during an
|
|
// incident).
|
|
expect(launchJobBody).toMatch(/console\.error[^)]*executeJob unhandled/);
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// E — Universal grace-evict listener fires on any abort reason
|
|
// =============================================================================
|
|
|
|
describe('E: universal grace-evict listener (D8b)', () => {
|
|
test('launchJob registers an abort.signal.addEventListener for grace-evict', () => {
|
|
const launchJobBody = extractFunctionBody(workerSource, 'private launchJob(');
|
|
// The pre-v0.41.26.1 form lived inside `if (job.timeout_ms != null)`.
|
|
// Now it's at launchJob top level and listens to the abort signal
|
|
// directly. Pin the listener registration.
|
|
expect(launchJobBody).toMatch(/abort\.signal\.addEventListener\s*\(\s*['"]abort['"]/);
|
|
});
|
|
|
|
test('the grace-evict path consults INFRASTRUCTURE_ABORT_REASONS before failJob', () => {
|
|
const launchJobBody = extractFunctionBody(workerSource, 'private launchJob(');
|
|
// The infrastructure-reason guard ensures lock-renewal aborts don't
|
|
// burn job attempts even if the handler is still wedged at the 30s
|
|
// force-evict deadline.
|
|
expect(launchJobBody).toMatch(/INFRASTRUCTURE_ABORT_REASONS/);
|
|
});
|
|
|
|
test('the 30s grace timer fires for any abort, not just timeout_ms', () => {
|
|
const launchJobBody = extractFunctionBody(workerSource, 'private launchJob(');
|
|
// The 30_000 literal must appear OUTSIDE the `if (job.timeout_ms != null)`
|
|
// branch. Check the addEventListener block contains the 30_000 literal.
|
|
// Find the addEventListener and look in its function body.
|
|
const listenerIdx = launchJobBody.indexOf("abort.signal.addEventListener('abort'");
|
|
expect(listenerIdx).toBeGreaterThan(-1);
|
|
// Grab ~1500 chars after the listener to capture its body.
|
|
const listenerWindow = launchJobBody.slice(listenerIdx, listenerIdx + 1500);
|
|
expect(listenerWindow).toMatch(/30_000|30000/);
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// B — executeJob skips failJob on infrastructure aborts
|
|
// =============================================================================
|
|
|
|
describe('B: executeJob skip-failJob on infrastructure abort (D8a)', () => {
|
|
test('executeJob detects the infrastructure abort reason and returns early', () => {
|
|
const executeJobBody = extractFunctionBody(workerSource, 'private async executeJob(');
|
|
// The skip-failJob branch checks abort.signal.reason against
|
|
// INFRASTRUCTURE_ABORT_REASONS and returns BEFORE the failJob call
|
|
// path. Pin both the constant reference AND the early-return shape.
|
|
expect(executeJobBody).toMatch(/INFRASTRUCTURE_ABORT_REASONS\.has/);
|
|
// The return-early shape: after the INFRASTRUCTURE_ABORT_REASONS
|
|
// check there must be a `return;` to skip the rest of catch.
|
|
// Pin the structural shape by locating the check + finding `return;`
|
|
// within ~500 chars after.
|
|
const idx = executeJobBody.indexOf('INFRASTRUCTURE_ABORT_REASONS.has');
|
|
expect(idx).toBeGreaterThan(-1);
|
|
const window = executeJobBody.slice(idx, idx + 500);
|
|
expect(window).toMatch(/return\s*;/);
|
|
});
|
|
|
|
test('executeJob still calls failJob for non-infrastructure errors', () => {
|
|
const executeJobBody = extractFunctionBody(workerSource, 'private async executeJob(');
|
|
// Regression guard for the negative side: a future refactor that
|
|
// accidentally removes failJob entirely would make handler defects
|
|
// silently disappear. Pin that failJob remains in the catch path.
|
|
expect(executeJobBody).toMatch(/this\.queue\.failJob\s*\(/);
|
|
});
|
|
});
|