Files
gbrain/src/core/worker-pool.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

316 lines
12 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.
/**
* Sliding worker pool + bounded semaphore (v0.41.15.0).
*
* Single source of truth for bounded-concurrency work over a list of
* items. Extracted from `src/commands/embed.ts` (the gold-standard
* sliding pool, both `embedAll` simple path and `embedAllStale`
* paginated+abort path) and `src/commands/eval-cross-modal.ts`
* (`runWithLimit` semaphore).
*
* Two exports:
* - `runSlidingPool({items, workers, onItem, ...})` — N workers
* atomically claim items from a shared queue. Throughput beats
* fixed-window `Promise.all([Promise.all(batch), ...])` because
* fast workers don't wait for slow workers to finish a whole batch.
* - `runWithLimit({items, limit, fn})` — bounded `Promise.allSettled`
* shape. Returns a tagged result array so callers see per-item
* success/failure without throwing on the first error.
*
* ============================================================
* ATOMICITY INVARIANT (load-bearing — pinned by CI guard at
* scripts/check-worker-pool-atomicity.sh; wired into `bun run verify`)
* ============================================================
*
* The sliding pool's correctness rests on `const idx = nextIdx++`
* being atomic across the N concurrent `worker()` invocations.
*
* This is TRUE on Node.js / Bun because:
* 1. JS single-threaded event loop: no two `worker()` invocations
* run statements concurrently on different threads.
* 2. The claim is ONE synchronous statement — there is NO `await`
* between the read and the write. The scheduler cannot yield
* between `nextIdx` (read) and `nextIdx + 1` (write).
*
* Two failure modes would silently break the invariant:
* - `worker_threads` import in any file that uses `runSlidingPool`:
* pool work would cross kernel threads, no event-loop guarantee.
* Two workers could claim the same idx; duplicate work + duplicate
* DB writes. Same failure shape as D2's per-page lock exists to
* defend against, but the lock is a defense-in-depth safety net,
* NOT the primary correctness story.
* - `const idx = await getNextIdx()` style refactor: introduces an
* `await` in the claim line; another worker can run during the
* yield window between the function call and the assignment.
*
* The CI guard rejects both patterns. Do NOT remove it without
* understanding what it guards.
*
* ============================================================
* MUST-ABORT ERROR CLASSES (D13)
* ============================================================
*
* Most onItem errors flow through `onError` (default: collect into
* `failures[]` and continue). But some error classes MUST hard-abort
* the pool regardless of onError policy:
* - BudgetExhausted: when one worker hits --max-cost-usd, every
* other worker must stop immediately, not race to spend more.
* Pre-D13, N workers each independently hit reserve() and burned
* N × per-call-cost over the cap.
*
* The helper checks `err.tag` against `MUST_ABORT_ERROR_TAGS` BEFORE
* dispatching to onError. Matched errors: set aborted=true, call
* AbortController.abort() (propagates to in-flight onItem via
* `signal`), rethrow. This makes "cap is a hard ceiling" a structural
* property of the helper, not a per-caller convention.
*
* Future tagged classes (UnrecoverableError, etc.) add their tag to
* MUST_ABORT_ERROR_TAGS. Property-tag match avoids cross-module
* import dependencies.
*
* ============================================================
* FAILURE CAPTURE SHAPE (D7 + codex #10)
* ============================================================
*
* `failures[]` stores `{ idx, label, error }`, NOT the full item.
* Callers supply `failureLabel(item) => string` (default: `String(item)`)
* so a 197K-page brain on a worst-case all-failure run doesn't carry
* 197K Page objects in memory.
*/
/** Tagged error classes that bypass `onError` and hard-abort the pool. */
export const MUST_ABORT_ERROR_TAGS: ReadonlySet<string> = new Set([
'BUDGET_EXHAUSTED', // src/core/budget/budget-tracker.ts BudgetExhausted.tag
]);
/**
* Check if an error is a must-abort class. Uses property-tag match
* (`err.tag === 'BUDGET_EXHAUSTED'`) to avoid cross-module import
* dependencies. Tolerates any thrown value shape.
*/
export function isMustAbortError(err: unknown): boolean {
if (typeof err !== 'object' || err === null) return false;
const tag = (err as { tag?: unknown }).tag;
return typeof tag === 'string' && MUST_ABORT_ERROR_TAGS.has(tag);
}
/** Per-item failure record stored in `SlidingPoolResult.failures`. */
export interface PoolFailure {
/** Index in the original items array. */
idx: number;
/** Caller-provided label (defaults to `String(item)`). */
label: string;
/** The thrown error or value. */
error: unknown;
}
export interface SlidingPoolOpts<T> {
/** Pre-enumerated work list. Empty input is a no-op (returns immediately). */
items: readonly T[];
/** Worker count. Clamped to `[1, items.length]` internally. */
workers: number;
/**
* Per-item work. Receives item + its position in `items` + the worker
* slot index (0-based; useful for per-worker logging). Throwing flows
* through `onError` unless the error is a must-abort class (see header).
*/
onItem: (item: T, idx: number, workerIdx: number) => Promise<void>;
/**
* AbortSignal honored at TWO points:
* 1. Before each claim — aborted workers exit cleanly without
* claiming new items.
* 2. Passed-through implicitly: callers that want mid-item abort
* must thread the same signal INTO their `onItem`.
*/
signal?: AbortSignal;
/**
* Fires after each successful item with running totals. Order is
* non-deterministic (sliding pool semantics — fast workers report
* sooner). Tests asserting event ORDER will be flaky; assert
* INVARIANTS (count == processed, monotonic done counter).
*/
onProgress?: (done: number, total: number) => void;
/**
* Per-error policy. Default: 'continue' (collect into failures[],
* keep claiming). Pass 'abort' or a function returning 'abort' to
* stop the pool on first error. MUST_ABORT_ERROR_TAGS always
* bypass this and hard-abort regardless.
*
* Default 'continue' picked because every real bulk caller wants
* tolerant semantics for I/O errors (one bad page shouldn't kill
* a 6594-page backfill). Per D7.
*/
onError?: 'continue' | 'abort' | ((err: unknown, item: T, idx: number) => 'continue' | 'abort');
/**
* Project a stable label from an item for failure records. Default
* `String(item)`. Pass a projector like `p => p.slug` to keep
* failures small in memory on large brains. Per codex #10.
*/
failureLabel?: (item: T) => string;
}
export interface SlidingPoolResult {
/** Number of items that completed successfully (onItem did not throw). */
processed: number;
/** Number of items whose onItem threw (also length of failures[]). */
errored: number;
/** True when the pool aborted before claiming all items. */
aborted: boolean;
/**
* Per-failure records. Bounded shape (idx + label + error), NOT
* full items, to keep memory bounded on large brains.
*/
failures: PoolFailure[];
}
/**
* Run N workers over `items`. Workers atomically claim the next item
* from a shared queue index (see ATOMICITY INVARIANT in module header).
* Returns when every item has been claimed AND every worker has
* finished its current item, OR when aborted.
*
* Empty `items` returns immediately with zeroed result. Worker count
* is clamped to `[1, items.length]` so we never spawn more workers
* than work.
*/
export async function runSlidingPool<T>(opts: SlidingPoolOpts<T>): Promise<SlidingPoolResult> {
const items = opts.items;
const total = items.length;
const result: SlidingPoolResult = {
processed: 0,
errored: 0,
aborted: false,
failures: [],
};
if (total === 0) return result;
const workerCount = Math.max(1, Math.min(opts.workers, total));
const labelFn = opts.failureLabel ?? ((x: T) => String(x));
// Local AbortController composed with the caller's signal so a
// must-abort error from one worker also signals every other worker's
// in-flight onItem (if the onItem honors signals).
const localAbort = new AbortController();
const onCallerAbort = () => localAbort.abort();
if (opts.signal) {
if (opts.signal.aborted) localAbort.abort();
else opts.signal.addEventListener('abort', onCallerAbort, { once: true });
}
// Resolve onError into a uniform function form.
const errorPolicy = opts.onError ?? 'continue';
const decideOnError = (err: unknown, item: T, idx: number): 'continue' | 'abort' => {
if (typeof errorPolicy === 'function') return errorPolicy(err, item, idx);
return errorPolicy;
};
// Sliding worker pool. See ATOMICITY INVARIANT in module header.
// CI guard `scripts/check-worker-pool-atomicity.sh` rejects refactors
// that put an `await` between the `nextIdx` read and write OR import
// `worker_threads` alongside this module.
let nextIdx = 0;
async function worker(workerIdx: number): Promise<void> {
while (true) {
if (localAbort.signal.aborted) {
result.aborted = true;
return;
}
if (nextIdx >= total) return;
// ATOMICITY INVARIANT: this line is the load-bearing claim.
// Read + increment must remain a single synchronous statement.
// Do NOT insert `await` between them. See module header.
const idx = nextIdx++;
const item = items[idx];
try {
await opts.onItem(item, idx, workerIdx);
result.processed++;
opts.onProgress?.(result.processed, total);
} catch (err) {
// D13: must-abort error classes (BudgetExhausted, etc.) bypass
// onError and hard-abort the pool. Rethrowing propagates up
// through Promise.all; the local abort signals other workers.
if (isMustAbortError(err)) {
result.aborted = true;
result.errored++;
result.failures.push({ idx, label: labelFn(item), error: err });
localAbort.abort();
throw err;
}
result.errored++;
result.failures.push({ idx, label: labelFn(item), error: err });
const decision = decideOnError(err, item, idx);
if (decision === 'abort') {
result.aborted = true;
localAbort.abort();
return;
}
// 'continue' — claim the next item.
}
}
}
try {
await Promise.all(
Array.from({ length: workerCount }, (_, w) => worker(w)),
);
} finally {
if (opts.signal) opts.signal.removeEventListener('abort', onCallerAbort);
}
return result;
}
/**
* Bounded-concurrency settled-result runner. Extracted from
* `src/commands/eval-cross-modal.ts:248-274` so callers don't roll their
* own.
*
* Semantics:
* - Up to `limit` items in flight at any moment.
* - Per-item errors are CAPTURED into the result array, not thrown
* (matches `Promise.allSettled` shape, not `Promise.all`).
* - Return array preserves input order regardless of completion order.
* - `signal.aborted` short-circuits remaining claims; in-flight items
* complete (or throw if the caller's `fn` honors the signal).
*/
export interface RunWithLimitOpts<TIn, TOut> {
items: readonly TIn[];
limit: number;
fn: (item: TIn, idx: number) => Promise<TOut>;
signal?: AbortSignal;
}
export type SettledItem<TOut> =
| { ok: true; value: TOut; idx: number }
| { ok: false; error: unknown; idx: number };
export async function runWithLimit<TIn, TOut>(
opts: RunWithLimitOpts<TIn, TOut>,
): Promise<SettledItem<TOut>[]> {
const items = opts.items;
const total = items.length;
const out: SettledItem<TOut>[] = new Array(total);
if (total === 0) return out;
const workerCount = Math.max(1, Math.min(opts.limit, total));
// ATOMICITY INVARIANT: same load-bearing claim as runSlidingPool.
// CI guard pins this too.
let nextIdx = 0;
async function worker(): Promise<void> {
while (true) {
if (opts.signal?.aborted) return;
if (nextIdx >= total) return;
const idx = nextIdx++;
try {
const value = await opts.fn(items[idx], idx);
out[idx] = { ok: true, value, idx };
} catch (error) {
out[idx] = { ok: false, error, idx };
}
}
}
await Promise.all(Array.from({ length: workerCount }, () => worker()));
return out;
}