Files
gbrain/test/queue-lock-retry.test.ts
T
f868257405 v0.42.24.0 fix(minions): route lock claim/renewLock through direct session pool (#1822)
* fix(minions): route lock claim/renewLock through direct session pool

The Minion lock heartbeat (claim + renewLock) ran every UPDATE through
engine.executeRaw(), which is hardcoded to the read pool. On Supabase that
is the transaction-mode pooler (6543), which recycles connections per
transaction. A lock is held for minutes, so the pooler periodically reaps
the socket mid-heartbeat -> CONNECTION_ENDED -> the lock looks expired ->
the worker force-evicts its own job and the claim loop wedges silently.

Add BrainEngine.executeRawDirect(): same contract as executeRaw, but routes
to the direct session-mode pool (5432, GBRAIN_DIRECT_DATABASE_URL) when
dual-pool is active. No-op delegation on PGLite / non-Supabase / kill-switch.
claim/renewLock now use it. Single-statement UPDATEs only, so the double-claim
guard and the renewLock no-inline-retry contract are preserved. Statements
inside an open transaction keep their tx connection (in-transaction guard keys
on peekReadPool() !== _sql); the lock hot-path never runs inside transaction().

The Postgres impl shares its cancellation plumbing with executeRaw via a
private runUnsafe helper. New test/postgres-execute-raw-direct.test.ts covers
the routing decision (dual-pool on/off x in-tx/not + abort short-circuit)
without a live Postgres; queue-lock-retry.test.ts gains a guard that claim
can never fall back to executeRaw.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* v0.42.24.0 chore: bump version and changelog

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: update project documentation for v0.42.24.0

Document executeRawDirect on the BrainEngine contract and the
claim/renewLock direct-session-pool routing in KEY_FILES.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: make D3 executeRaw-no-retry guard refactor-aware

The DRY refactor in this PR extracted executeRaw/executeRawDirect's shared
cancellation plumbing into a private runUnsafe(conn, ...) helper, so the single
conn.unsafe() call moved out of executeRaw's body. The D3 guard read
executeRaw's source and asserted conn.unsafe( appeared exactly once there,
which now fails (it's zero — executeRaw delegates).

The D3 invariant (no per-call retry wrapper) is unchanged; it just spans the
delegate now. Update the guard to check both public methods delegate to
runUnsafe without reconnect/retry, and assert the exactly-once conn.unsafe +
cancel-only catch in runUnsafe. Also extends coverage to executeRawDirect so
the lock hot-path can't reintroduce a retry wrapper either.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:40:12 -07:00

75 lines
2.9 KiB
TypeScript

/**
* issue #1678 — Minion hot-path lock recovery contract.
*
* - promoteDelayed (idempotent) self-heals: a reaped-socket CONNECTION_ENDED
* triggers a reconnect + retry against a fresh pool.
* - claim does NOT retry inline (Codex #1): blind-retrying a claim whose
* UPDATE...RETURNING may have committed could double-claim a job. The error
* propagates; the worker poll loop reconnects + re-claims on the next tick.
*
* Hermetic: a fake BrainEngine whose executeRaw is scripted; no real DB.
*/
import { describe, it, expect } from 'bun:test';
import { tmpdir } from 'os';
import { join } from 'path';
import { MinionQueue } from '../src/core/minions/queue.ts';
import { withEnv } from './helpers/with-env.ts';
function connEndedError(): Error & { code: string } {
const e = new Error('write CONNECTION_ENDED localhost:6543') as Error & { code: string };
e.code = 'CONNECTION_ENDED';
return e;
}
const AUDIT_DIR = join(tmpdir(), `gbrain-queue-lock-retry-${process.pid}-${Date.now()}`);
// Fast retry + isolated audit dir so the test doesn't sleep ~1s or pollute ~/.gbrain.
const FAST_ENV = {
GBRAIN_BULK_RETRY_BASE_MS: '1',
GBRAIN_BULK_RETRY_MAX_MS: '2',
GBRAIN_AUDIT_DIR: AUDIT_DIR,
};
describe('MinionQueue lock-path recovery (issue #1678)', () => {
it('promoteDelayed reconnects + retries on a reaped-socket error', async () => {
await withEnv(FAST_ENV, async () => {
let calls = 0;
let reconnects = 0;
const engine = {
kind: 'postgres',
executeRaw: async () => {
calls++;
if (calls === 1) throw connEndedError();
return [];
},
reconnect: async () => { reconnects++; },
} as unknown as ConstructorParameters<typeof MinionQueue>[0];
const q = new MinionQueue(engine);
const out = await q.promoteDelayed();
expect(out).toEqual([]);
expect(calls).toBe(2); // first attempt threw, retry succeeded
expect(reconnects).toBe(1); // reconnect fired between attempts
});
});
it('claim does NOT retry inline on a reaped-socket error (Codex #1 double-claim guard)', async () => {
await withEnv(FAST_ENV, async () => {
let calls = 0;
const engine = {
kind: 'postgres',
// claim() routes through executeRawDirect (direct session pool) as of
// the lock-hot-path fix; executeRaw is kept as a throwing guard to
// prove claim never falls back to it.
executeRawDirect: async () => { calls++; throw connEndedError(); },
executeRaw: async () => { throw new Error('claim must not use executeRaw'); },
reconnect: async () => {},
} as unknown as ConstructorParameters<typeof MinionQueue>[0];
const q = new MinionQueue(engine);
await expect(q.claim('tok', 1000, 'default', ['sync'])).rejects.toThrow('CONNECTION_ENDED');
expect(calls).toBe(1); // exactly one attempt — no inline retry
});
});
});