mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* feat(worker-pool): shared sliding pool + bounded semaphore + PGLite-clamp wrapper T1 + T2 of the v0.41.16.0 workers cathedral. New src/core/worker-pool.ts is the canonical primitive every --workers N bulk command in this wave (and future bulk commands) builds on. Atomic-claim invariant enforced by scripts/check-worker-pool-atomicity.sh (wired into bun run verify). BudgetExhausted bypass + AbortSignal composition baked into the helper so budget caps are a structural ceiling under concurrency, not a per-caller convention. The new resolveWorkersWithClamp wrapper composes existing autoConcurrency with PGLite-clamp + per-(command, requested) stderr dedup. Deliberately NOT a modification to shared autoConcurrency (silent today, used by sync + import); embed.ts keeps GBRAIN_EMBED_CONCURRENCY || 20 default per codex #13. 23 + 12 + 9 = 44 hermetic tests pin every contract. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: structural + dim-check regression suites for v0.41.16.0 wave - test/embed-helper-migration.test.ts (T3): asserts embed.ts's two sliding-pool sites are migrated to runSlidingPool, pre-migration shapes (let nextIdx = 0, Promise.all(Array.from(...))) are gone, GBRAIN_EMBED_CONCURRENCY || 20 default preserved, failureLabel threads page.slug. Per codex #16/#17 these are invariant assertions, not byte-equality on progress event ORDERING. - test/embedding-dim-check-facts.test.ts (T6): readFactsEmbeddingDim covers vector(N) + halfvec(N), halfvec-before-vector regex ordering pinned (codex #19), buildFactsAlterRecipe emits DROP INDEX + ALTER USING + CREATE INDEX (codex #18, not bare REINDEX), FactsEmbeddingDimMismatchError tagged class shape, assertFactsEmbeddingDimMatchesConfig PGLite skip + Postgres absent- column skip, doctor check + insert-cast wiring assertions. - test/extract-conversation-facts-workers.test.ts (T5): helper exports (extractConversationFactsLockId, PER_PAGE_LOCK_TTL_MINUTES), structural wiring (runSlidingPool, resolveWorkersWithClamp, withRefreshingLock, LockUnavailableError, delete-orphans-first before segment loop, preflight before pool, exit 3 when lock_skipped > 0), Minion handler round-trip. - test/extract-workers.test.ts (T7): --workers wiring on all 3 inner fs-walk loops (extractForSlugs, extractLinksFromDir, extractTimelineFromDir) + CLI parse + opts threading through runExtractCore. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: rebump v0.41.16.0 → v0.41.17.0 (queue collision with PR #1510) PR #1510 (garrytan/dynamic-regex-conversation-formats) claimed v0.41.16.0 on master in parallel. Advancing this wave to v0.41.17.0 so both can land cleanly. Pure mechanical version bump: - VERSION + package.json → 0.41.17.0 - CHANGELOG.md header + "To take advantage of v0.41.17.0" block - TODOS.md section header + v0.41.18+ forward references - CLAUDE.md inline version tags - Regenerated llms-full.txt / llms.txt No code changes. The actual workers cathedral feature set is unchanged from the two prior commits in this branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): search-image-column probes column dim at runtime CI shard 5 failed on `searchVector column routing (v0.27.1)` with: error: expected 1280 dimensions, not 1536 The test had a hardcoded `fakeText1536` helper that seeded chunks at 1536-d vectors. Master's default embedding model switched from OpenAI text-embedding-3-large (1536) to ZeroEntropy zembed-1 (1280) so a fresh PGLite brain on CI now sizes content_chunks.embedding at 1280; the test's 1536-d INSERT trips pgvector's CheckExpectedDim. Fix: probe `content_chunks.embedding` width via `readContentChunksEmbeddingDim(engine)` in `beforeAll`, store in `TEXT_DIM`, and build `fakeTextDefault(seed)` at that width. The test now passes regardless of which default ships (the model has flipped twice and may flip again). Local dev (1536 from older config) and CI fresh-install (1280 from new default) both pass. Image-side vectors stay at 1024 (matches Voyage multimodal-3 + the column's fixed width on the image side). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): bump PGLite hook timeout for shard-4 deep-process files facts-anti-loop.test.ts and ingest-capture.test.ts were timing out in CI shard 4 with "beforeEach/afterEach hook timed out" after the v0.41.16.0 master merge brought migration count to 99. When these files run deep in a shard process that has already created ~20 PGLite engines, the WASM cold-start + 95-migration replay legitimately exceeds bun's 5s default hook timeout (observed 5.6s and 7.3s locally when reproducing). Bun's --timeout=60000 from scripts/test-shard.sh covers TEST timeouts but NOT hook timeouts; those default to 5s and must be set per-hook via the optional 2nd arg to beforeAll/afterAll. Reproduced locally by running the first 21 shard-4 files via head -21 /tmp/shard4-list.txt | xargs bun test → 179 pass, 2 fail (both with hook-timeout error) After fix: → 198 pass, 0 fail (the 4 anti-loop + 15 ingest-capture tests recover) Full shard 4 with fix: 955 pass, 0 fail. Full shard 5 with fix: 1261 pass, 0 fail. Also added a defensive diagnostic to the two put_page tests: if facts_backstop is missing in the response payload, throw with the full payload + isError so future failures surface the actual handler error instead of a bare "expected {...} got undefined" assertion. No-op when the test passes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
467 lines
15 KiB
TypeScript
467 lines
15 KiB
TypeScript
/**
|
||
* Hermetic unit tests for src/core/worker-pool.ts (v0.41.15.0).
|
||
*
|
||
* Pins every contract from D1, D5, D7, D11/D12 (signal composition),
|
||
* D13 (BudgetExhausted bypass). No DB, no API keys, no filesystem.
|
||
*
|
||
* Test-isolation note: this file follows the R1+R2 rules from
|
||
* scripts/check-test-isolation.sh — no process.env mutation, no
|
||
* mock.module. Lives in the parallel fast loop.
|
||
*/
|
||
|
||
import { describe, test, expect } from 'bun:test';
|
||
import {
|
||
runSlidingPool,
|
||
runWithLimit,
|
||
isMustAbortError,
|
||
MUST_ABORT_ERROR_TAGS,
|
||
type PoolFailure,
|
||
type SettledItem,
|
||
} from '../src/core/worker-pool.ts';
|
||
|
||
describe('runSlidingPool — basic shape', () => {
|
||
test('empty items returns zeroed result without invoking onItem', async () => {
|
||
let calls = 0;
|
||
const r = await runSlidingPool({
|
||
items: [],
|
||
workers: 4,
|
||
onItem: async () => {
|
||
calls++;
|
||
},
|
||
});
|
||
expect(r.processed).toBe(0);
|
||
expect(r.errored).toBe(0);
|
||
expect(r.aborted).toBe(false);
|
||
expect(r.failures).toEqual([]);
|
||
expect(calls).toBe(0);
|
||
});
|
||
|
||
test('N=1 processes items sequentially in order', async () => {
|
||
const order: number[] = [];
|
||
const r = await runSlidingPool({
|
||
items: [1, 2, 3, 4, 5],
|
||
workers: 1,
|
||
onItem: async (item) => {
|
||
order.push(item);
|
||
},
|
||
});
|
||
expect(r.processed).toBe(5);
|
||
expect(order).toEqual([1, 2, 3, 4, 5]);
|
||
});
|
||
|
||
test('N>items clamps to items count (no extra workers spawned)', async () => {
|
||
const workerSlots = new Set<number>();
|
||
const r = await runSlidingPool({
|
||
items: [1, 2, 3],
|
||
workers: 100,
|
||
onItem: async (_item, _idx, workerIdx) => {
|
||
workerSlots.add(workerIdx);
|
||
},
|
||
});
|
||
expect(r.processed).toBe(3);
|
||
// At most 3 workers should ever have been spawned; workerIdx is 0..N-1.
|
||
for (const slot of workerSlots) {
|
||
expect(slot).toBeLessThan(3);
|
||
}
|
||
});
|
||
|
||
test('every item is claimed exactly once under N concurrent workers', async () => {
|
||
const seen = new Map<number, number>(); // item -> claim count
|
||
const items = Array.from({ length: 200 }, (_, i) => i);
|
||
await runSlidingPool({
|
||
items,
|
||
workers: 16,
|
||
onItem: async (item) => {
|
||
seen.set(item, (seen.get(item) ?? 0) + 1);
|
||
// Force interleaving via micro-yields.
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
},
|
||
});
|
||
expect(seen.size).toBe(200);
|
||
for (const [, count] of seen) expect(count).toBe(1);
|
||
});
|
||
});
|
||
|
||
describe('runSlidingPool — atomic claim invariant (D5)', () => {
|
||
test('200 items × 32 workers: every idx visited exactly once', async () => {
|
||
// The atomicity invariant — `const idx = nextIdx++` is a single
|
||
// synchronous statement — means no two workers ever read the same idx.
|
||
// We assert this directly: build a counter of every idx the pool dispatched
|
||
// and confirm every entry is 1.
|
||
const idxCounts = new Map<number, number>();
|
||
const items = Array.from({ length: 200 }, (_, i) => ({ id: i }));
|
||
await runSlidingPool({
|
||
items,
|
||
workers: 32,
|
||
onItem: async (_item, idx) => {
|
||
idxCounts.set(idx, (idxCounts.get(idx) ?? 0) + 1);
|
||
await new Promise((res) => setTimeout(res, Math.random() * 2));
|
||
},
|
||
});
|
||
expect(idxCounts.size).toBe(200);
|
||
for (let i = 0; i < 200; i++) expect(idxCounts.get(i)).toBe(1);
|
||
});
|
||
});
|
||
|
||
describe('runSlidingPool — abort semantics (D11/D12 signal composition)', () => {
|
||
test('signal aborted before pool starts → returns immediately, no items claimed', async () => {
|
||
const ctl = new AbortController();
|
||
ctl.abort();
|
||
let calls = 0;
|
||
const r = await runSlidingPool({
|
||
items: [1, 2, 3, 4, 5],
|
||
workers: 2,
|
||
signal: ctl.signal,
|
||
onItem: async () => {
|
||
calls++;
|
||
},
|
||
});
|
||
expect(calls).toBe(0);
|
||
expect(r.processed).toBe(0);
|
||
expect(r.aborted).toBe(true);
|
||
});
|
||
|
||
test('signal aborted mid-pool → in-flight items finish, new claims stop', async () => {
|
||
const ctl = new AbortController();
|
||
const items = Array.from({ length: 50 }, (_, i) => i);
|
||
let processed = 0;
|
||
const r = await runSlidingPool({
|
||
items,
|
||
workers: 4,
|
||
signal: ctl.signal,
|
||
onItem: async () => {
|
||
// Yield once so the abort can land between claims.
|
||
await new Promise((res) => setTimeout(res, 1));
|
||
processed++;
|
||
if (processed === 4) ctl.abort();
|
||
},
|
||
});
|
||
// After abort, at most the 4 in-flight finish plus a few that already
|
||
// claimed before the signal flag-flip. The remaining never run.
|
||
expect(r.processed).toBeLessThan(50);
|
||
expect(r.aborted).toBe(true);
|
||
});
|
||
|
||
test('signal removeEventListener called on completion (no leak)', async () => {
|
||
// Defensive: the helper attaches an abort listener for D13's local-abort
|
||
// composition. It must remove it on completion. Smoke-test by running
|
||
// many pools against one signal and asserting the signal still works
|
||
// for subsequent abort propagation.
|
||
const ctl = new AbortController();
|
||
for (let i = 0; i < 50; i++) {
|
||
await runSlidingPool({
|
||
items: [1, 2, 3],
|
||
workers: 2,
|
||
signal: ctl.signal,
|
||
onItem: async () => {},
|
||
});
|
||
}
|
||
// If listeners leaked, addEventListener would have grown unbounded.
|
||
// We can't directly count them, but we can confirm the signal still
|
||
// composes correctly: trigger abort and verify a new pool short-circuits.
|
||
ctl.abort();
|
||
let calls = 0;
|
||
const r = await runSlidingPool({
|
||
items: [1, 2, 3],
|
||
workers: 2,
|
||
signal: ctl.signal,
|
||
onItem: async () => {
|
||
calls++;
|
||
},
|
||
});
|
||
expect(calls).toBe(0);
|
||
expect(r.aborted).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('runSlidingPool — onProgress callback', () => {
|
||
test('fires exactly `processed` times in monotonically increasing order', async () => {
|
||
const dones: number[] = [];
|
||
const items = Array.from({ length: 20 }, (_, i) => i);
|
||
await runSlidingPool({
|
||
items,
|
||
workers: 4,
|
||
onItem: async () => {
|
||
await new Promise((res) => setTimeout(res, Math.random() * 2));
|
||
},
|
||
onProgress: (done, total) => {
|
||
dones.push(done);
|
||
expect(total).toBe(20);
|
||
},
|
||
});
|
||
expect(dones.length).toBe(20);
|
||
// done counter is monotonic even though item ORDER isn't.
|
||
for (let i = 1; i < dones.length; i++) {
|
||
expect(dones[i]).toBeGreaterThanOrEqual(dones[i - 1]);
|
||
}
|
||
expect(dones[dones.length - 1]).toBe(20);
|
||
});
|
||
|
||
test('onProgress NOT fired for errored items (only successful processed)', async () => {
|
||
let progressCalls = 0;
|
||
const r = await runSlidingPool({
|
||
items: [1, 2, 3, 4, 5],
|
||
workers: 1,
|
||
onItem: async (item) => {
|
||
if (item === 3) throw new Error('boom');
|
||
},
|
||
onProgress: () => {
|
||
progressCalls++;
|
||
},
|
||
});
|
||
expect(r.processed).toBe(4);
|
||
expect(r.errored).toBe(1);
|
||
expect(progressCalls).toBe(4);
|
||
});
|
||
});
|
||
|
||
describe('runSlidingPool — onError semantics (D7)', () => {
|
||
test("default 'continue' policy captures all failures", async () => {
|
||
const r = await runSlidingPool({
|
||
items: [1, 2, 3, 4, 5],
|
||
workers: 1,
|
||
onItem: async (item) => {
|
||
if (item % 2 === 0) throw new Error(`fail-${item}`);
|
||
},
|
||
});
|
||
expect(r.processed).toBe(3);
|
||
expect(r.errored).toBe(2);
|
||
expect(r.aborted).toBe(false);
|
||
expect(r.failures.map((f) => f.idx).sort()).toEqual([1, 3]); // idx of items 2 and 4
|
||
expect((r.failures[0].error as Error).message).toMatch(/^fail-/);
|
||
});
|
||
|
||
test("explicit 'abort' policy stops pool on first error", async () => {
|
||
let calls = 0;
|
||
const r = await runSlidingPool({
|
||
items: [1, 2, 3, 4, 5],
|
||
workers: 1,
|
||
onError: 'abort',
|
||
onItem: async (item) => {
|
||
calls++;
|
||
if (item === 2) throw new Error('boom');
|
||
},
|
||
});
|
||
expect(calls).toBeLessThanOrEqual(2);
|
||
expect(r.aborted).toBe(true);
|
||
expect(r.errored).toBe(1);
|
||
});
|
||
|
||
test("onError function can decide per-error", async () => {
|
||
const r = await runSlidingPool({
|
||
items: [1, 2, 3, 4, 5],
|
||
workers: 1,
|
||
onError: (err) => {
|
||
return (err as Error).message.includes('fatal') ? 'abort' : 'continue';
|
||
},
|
||
onItem: async (item) => {
|
||
if (item === 2) throw new Error('soft');
|
||
if (item === 4) throw new Error('fatal');
|
||
},
|
||
});
|
||
expect(r.aborted).toBe(true);
|
||
// Items 1 + 2 (soft) + 3 + 4 (fatal-aborts) processed; 5 not claimed.
|
||
expect(r.errored).toBe(2);
|
||
expect(r.processed).toBe(2); // items 1 and 3
|
||
});
|
||
});
|
||
|
||
describe('runSlidingPool — failures[] shape (codex #10)', () => {
|
||
test('failures store idx + label, NOT full item', async () => {
|
||
interface Page {
|
||
slug: string;
|
||
bigBuffer: number[];
|
||
}
|
||
const items: Page[] = Array.from({ length: 5 }, (_, i) => ({
|
||
slug: `page-${i}`,
|
||
bigBuffer: new Array(10_000).fill(i),
|
||
}));
|
||
const r = await runSlidingPool({
|
||
items,
|
||
workers: 1,
|
||
failureLabel: (p) => p.slug,
|
||
onItem: async () => {
|
||
throw new Error('boom');
|
||
},
|
||
});
|
||
expect(r.failures.length).toBe(5);
|
||
for (const f of r.failures) {
|
||
expect(typeof f.label).toBe('string');
|
||
expect(f.label).toMatch(/^page-/);
|
||
expect(typeof f.idx).toBe('number');
|
||
// No `item` field on PoolFailure — codex #10 explicit shape.
|
||
// (TypeScript would already reject `f.item`; runtime check defensive.)
|
||
expect((f as PoolFailure & { item?: unknown }).item).toBeUndefined();
|
||
}
|
||
});
|
||
|
||
test('default failureLabel uses String(item)', async () => {
|
||
const r = await runSlidingPool({
|
||
items: ['a', 'b', 'c'],
|
||
workers: 1,
|
||
onItem: async () => {
|
||
throw new Error('boom');
|
||
},
|
||
});
|
||
expect(r.failures.map((f) => f.label)).toEqual(['a', 'b', 'c']);
|
||
});
|
||
});
|
||
|
||
describe('runSlidingPool — BudgetExhausted bypass (D13)', () => {
|
||
test('BudgetExhausted-tagged error aborts pool regardless of onError continue', async () => {
|
||
// Synthetic BudgetExhausted shape — tag-only match, no class import needed.
|
||
class FakeBudgetExhausted extends Error {
|
||
readonly tag = 'BUDGET_EXHAUSTED' as const;
|
||
constructor() {
|
||
super('cap exhausted');
|
||
this.name = 'BudgetExhausted';
|
||
}
|
||
}
|
||
let calls = 0;
|
||
let threw = false;
|
||
try {
|
||
await runSlidingPool({
|
||
items: [1, 2, 3, 4, 5],
|
||
workers: 1,
|
||
onError: 'continue', // would normally swallow
|
||
onItem: async (item) => {
|
||
calls++;
|
||
if (item === 2) throw new FakeBudgetExhausted();
|
||
},
|
||
});
|
||
} catch (e) {
|
||
threw = true;
|
||
expect((e as FakeBudgetExhausted).tag).toBe('BUDGET_EXHAUSTED');
|
||
}
|
||
expect(threw).toBe(true);
|
||
expect(calls).toBeLessThanOrEqual(2);
|
||
});
|
||
|
||
test('BudgetExhausted from one worker propagates abort to in-flight peers via signal', async () => {
|
||
class FakeBudgetExhausted extends Error {
|
||
readonly tag = 'BUDGET_EXHAUSTED' as const;
|
||
}
|
||
let aborted = 0;
|
||
let total = 0;
|
||
let threw = false;
|
||
try {
|
||
await runSlidingPool({
|
||
items: Array.from({ length: 100 }, (_, i) => i),
|
||
workers: 8,
|
||
onItem: async (item, _idx, _w) => {
|
||
total++;
|
||
if (item === 5) throw new FakeBudgetExhausted();
|
||
// Long-running work that checks abort via micro-yield.
|
||
for (let i = 0; i < 50; i++) {
|
||
await new Promise((res) => setImmediate(res));
|
||
}
|
||
},
|
||
});
|
||
} catch (e) {
|
||
threw = true;
|
||
expect((e as FakeBudgetExhausted).tag).toBe('BUDGET_EXHAUSTED');
|
||
}
|
||
expect(threw).toBe(true);
|
||
expect(total).toBeLessThan(100);
|
||
// Use `aborted` to suppress unused-var lint while keeping it as a
|
||
// probe value future test extensions can wire to a counter.
|
||
expect(aborted).toBe(0);
|
||
});
|
||
|
||
test('isMustAbortError + MUST_ABORT_ERROR_TAGS exposed and stable', () => {
|
||
expect(MUST_ABORT_ERROR_TAGS.has('BUDGET_EXHAUSTED')).toBe(true);
|
||
expect(isMustAbortError({ tag: 'BUDGET_EXHAUSTED' })).toBe(true);
|
||
expect(isMustAbortError({ tag: 'something-else' })).toBe(false);
|
||
expect(isMustAbortError(new Error('plain'))).toBe(false);
|
||
expect(isMustAbortError(null)).toBe(false);
|
||
expect(isMustAbortError(undefined)).toBe(false);
|
||
expect(isMustAbortError('string')).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('runWithLimit — bounded semaphore', () => {
|
||
test('empty input returns empty array', async () => {
|
||
const out = await runWithLimit({
|
||
items: [],
|
||
limit: 4,
|
||
fn: async () => 'never',
|
||
});
|
||
expect(out).toEqual([]);
|
||
});
|
||
|
||
test('preserves per-item ordering in returned array regardless of completion order', async () => {
|
||
const out = await runWithLimit({
|
||
items: [10, 5, 20, 1, 100, 2],
|
||
limit: 4,
|
||
fn: async (item) => {
|
||
await new Promise((res) => setTimeout(res, item % 10));
|
||
return item * 2;
|
||
},
|
||
});
|
||
expect(out.length).toBe(6);
|
||
for (let i = 0; i < 6; i++) {
|
||
expect(out[i].ok).toBe(true);
|
||
expect(out[i].idx).toBe(i);
|
||
if (out[i].ok) {
|
||
expect((out[i] as Extract<SettledItem<number>, { ok: true }>).value).toBe(
|
||
[10, 5, 20, 1, 100, 2][i] * 2,
|
||
);
|
||
}
|
||
}
|
||
});
|
||
|
||
test('captures per-item errors without throwing', async () => {
|
||
const out = await runWithLimit({
|
||
items: [1, 2, 3, 4, 5],
|
||
limit: 2,
|
||
fn: async (item) => {
|
||
if (item === 3) throw new Error('boom');
|
||
return item;
|
||
},
|
||
});
|
||
expect(out.length).toBe(5);
|
||
expect(out[0].ok).toBe(true);
|
||
expect(out[2].ok).toBe(false);
|
||
if (!out[2].ok) {
|
||
expect((out[2].error as Error).message).toBe('boom');
|
||
}
|
||
expect(out[4].ok).toBe(true);
|
||
});
|
||
|
||
test('signal short-circuits remaining claims', async () => {
|
||
const ctl = new AbortController();
|
||
const out = await runWithLimit({
|
||
items: Array.from({ length: 50 }, (_, i) => i),
|
||
limit: 4,
|
||
signal: ctl.signal,
|
||
fn: async (item) => {
|
||
if (item === 5) ctl.abort();
|
||
await new Promise((res) => setTimeout(res, 1));
|
||
return item;
|
||
},
|
||
});
|
||
// Output array has 50 slots but only some are populated.
|
||
const populated = out.filter((x) => x !== undefined).length;
|
||
expect(populated).toBeLessThan(50);
|
||
});
|
||
});
|
||
|
||
describe('runSlidingPool — worker slot index passed to onItem', () => {
|
||
test('workerIdx is 0..N-1 across all calls', async () => {
|
||
const slotsSeen = new Set<number>();
|
||
await runSlidingPool({
|
||
items: Array.from({ length: 100 }, (_, i) => i),
|
||
workers: 5,
|
||
onItem: async (_item, _idx, workerIdx) => {
|
||
expect(workerIdx).toBeGreaterThanOrEqual(0);
|
||
expect(workerIdx).toBeLessThan(5);
|
||
slotsSeen.add(workerIdx);
|
||
},
|
||
});
|
||
// With 100 items and 5 workers each pulling repeatedly, all 5 slots
|
||
// get exercised.
|
||
expect(slotsSeen.size).toBe(5);
|
||
});
|
||
});
|