Files
gbrain/test/worker-pool.test.ts
T
8ab733471b v0.41.17.0 feat: --workers N on every bulk command + facts dim doctor parity (#1519)
* 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>
2026-05-26 18:29:03 -07:00

467 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
});
});