Files
gbrain/src/commands/embed.ts
T
Garry TanandClaude Fable 5 567813028a fix(takes): wire the takes.embedding writer so vector takes search works (#2089)
takes.embedding had readers (searchTakesVector, think's takes_vec arm,
getTakeEmbeddings) and stale-tracking (countStaleTakes/listStaleTakes)
but no writer anywhere in src/ — the vector takes arm was structurally
dead on every install.

- BrainEngine.updateTakeEmbeddings (postgres + pglite, lockstep): writes
  embedding + embedded_at for active takes; updated_at untouched
  (embeddings are derived data, not a content change).
- embedStaleTakes in src/commands/embed.ts: claim-text backfill wired
  into the --stale path, so CLI `embed --stale`, the cycle/dream embed
  phase, and sync auto-embed all populate takes.embedding. Log-and-skip
  semantics; dry-run counts into would_embed; EmbedResult.takes_embedded
  reports the count.
- Migration v125 (takes_embedding_dim_align): the takes DDL hardcoded
  VECTOR(1536); brains configured with a different-dim embedder could
  never take writes. Since the column was writer-less it is all-NULL
  everywhere, so a guarded DROP/ADD retype to the configured dims (same
  config source as the facts migration) is lossless and works on PGLite
  too (which can't ALTER COLUMN TYPE vector(N)). No-op when dims match
  or data exists.
- test/embed-stale-takes.test.ts: fails without the writer (backfill,
  dry-run, idempotence, searchTakesVector goes live, v125 retype).

Fixes #2089

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 15:05:28 -07:00

1309 lines
55 KiB
TypeScript

import type { BrainEngine } from '../core/engine.ts';
import { embedBatch, currentEmbeddingSignature } from '../core/embedding.ts';
import type { ChunkInput } from '../core/types.ts';
import { chunkText } from '../core/chunkers/recursive.ts';
import { createProgress, type ProgressReporter } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
import { assertEmbeddingEnabled } from '../core/embedding-dim-check.ts';
import { loadConfig } from '../core/config.ts';
import { slog, serr } from '../core/console-prefix.ts';
import { filterOutEmbedSkipped } from '../core/embed-skip.ts';
import { runSlidingPool } from '../core/worker-pool.ts';
import { isAborted, anySignal, AbortError } from '../core/abort-check.ts';
import { type DbPacer, createDbPacer, createNoopPacer, observed } from '../core/db-pacer.ts';
import {
resolvePaceMode,
loadPaceModeConfig,
readPaceEnv,
type PaceKeyOverrides,
} from '../core/pace-mode.ts';
import { tryAcquireDbLock, type DbLockHandle } from '../core/db-lock.ts';
import { embedBackfillLockId } from '../core/embed-backfill-lock.ts';
export interface EmbedOpts {
/** Embed ALL pages (every chunk). */
all?: boolean;
/** Embed only stale chunks (missing embedding). */
stale?: boolean;
/** Embed specific pages by slug. */
slugs?: string[];
/** Embed a single page. */
slug?: string;
/**
* v0.31.12: scope to a specific source. When set, only pages from this
* source are embedded. When omitted, all sources are processed (but
* source_id is still threaded correctly per-page via Page.source_id).
*/
sourceId?: string;
/**
* Dry run: enumerate what WOULD be embedded (stale chunk counts)
* without calling the embedding model or writing to the engine.
* Safe to call with no API key. Used by runCycle's dryRun propagation.
*/
dryRun?: boolean;
/**
* Optional progress callback. Called after each page. CLI wrappers
* supply a reporter.tick()-backed implementation; Minion handlers
* supply a job.updateProgress()-backed one so per-job progress lives
* in the DB where `gbrain jobs get` can read it.
*/
onProgress?: (done: number, total: number, embedded: number) => void;
/**
* v0.41.18.0 (A13): override the hardcoded PAGE_SIZE=2000 page-batch.
* Smaller batches give finer progress granularity; larger batches
* reduce per-batch coordination cost. Caps internally to 10K to
* keep memory bounded.
*/
batchSize?: number;
/**
* v0.41.18.0 (A13): when 'recent', walks the stale-chunk pool in
* page.updated_at DESC order (recent-modified pages first) instead
* of the legacy stable (page_id, chunk_index) order. Threads through
* to listStaleChunks orderBy='updated_desc'. Backed by the
* content_chunks_stale_idx partial + idx_pages_updated_at_desc indexes
* (v100).
*/
priority?: 'recent';
/**
* v0.41.18.0 (A13): catch-up mode removes the wall-clock cap and loops
* until countStaleChunks() returns 0. Used by `gbrain embed --stale
* --catch-up` and by the embed-catch-up Minion handler that the onboard
* remediation submits on big stale backlogs.
*/
catchUp?: boolean;
/**
* #1737: cooperative-abort signal from the Minions worker (wall-clock
* timeout, lock loss, SIGTERM). When it fires, the embed loops break
* cleanly with partial progress preserved so the autopilot cycle's
* finally can release `gbrain_cycle_locks` instead of running for the
* full 10-15 min embed phase after the job was already killed. Composed
* with the internal wall-clock budget timer via `anySignal`.
*/
signal?: AbortSignal;
/**
* DB-contention pacing (paced-backfill). Raw inputs resolved in
* runEmbedCore via env > config > bundle (env beats config = incident
* escape hatch). `perCallMode` is from `--pace[=mode]`; `perCall` from
* `--pace-max-concurrency` etc. Absent ⇒ resolves from env/config (so a
* queued job paced by config alone still throttles). Mode `off` ⇒ no-op.
*/
pace?: {
perCallMode?: string;
perCall?: PaceKeyOverrides;
};
/**
* When the pace overrides were SERIALIZED from a background-job payload (not
* typed at an interactive CLI), resolve them at the config tier so
* `GBRAIN_PACE_*` on the worker still overrides at execution (Codex P2). Set
* by the `embed` job handler; unset for interactive CLI runs.
*/
paceFromBackground?: boolean;
/**
* E-2 (paced-backfill): single-flight the stale run by taking the SAME
* per-source lock the `embed-backfill` minion handler uses, so a hand-run CLI
* backfill and a queued job can't grind the same source at once (closing the
* NULL→non-NULL upsert race window that paced — longer — runs widen). Set
* ONLY by the CLI (`runEmbed`); the minion path already locks. All-source
* runs lock every source in sorted order. dryRun skips it.
*/
singleFlight?: boolean;
}
/**
* Structured result from a library-level embed run.
*
* In dryRun mode, `embedded = 0` and `would_embed` holds the count of
* stale chunks that WOULD have been sent to the embedding model. In
* non-dryRun mode, `embedded` holds the real count and `would_embed = 0`.
* `skipped` counts chunks that already had embeddings (nothing to do).
*/
export interface EmbedResult {
/** Chunks newly embedded in this run (0 in dryRun). */
embedded: number;
/** Chunks with pre-existing embeddings, skipped. */
skipped: number;
/** Chunks that would be embedded if not for dryRun (0 in non-dryRun). */
would_embed: number;
/** Total chunks considered across all processed pages. */
total_chunks: number;
/** Number of pages processed (whether or not they had stale chunks). */
pages_processed: number;
/** True if this run was a dry-run. */
dryRun: boolean;
/**
* #2089: take claims newly embedded in this run (the `--stale` path also
* backfills `takes.embedding` so the vector takes arm — think takes_vec —
* has data to search). Present only when > 0. In dryRun mode stale takes
* are counted into `would_embed` instead.
*/
takes_embedded?: number;
/**
* E1 (paced-backfill): end-of-run pacing telemetry. Present ONLY when pacing
* was active (enabled bundle). The number the operator could not get from an
* external wrapper ("zero pauses" ≠ "queue safe").
*/
pacing?: {
maxConcurrency: number;
/** In-band latency samples folded into the EWMA. */
samples: number;
/** Final EWMA of observed DB-op latency (ms), or null if no samples. */
ewmaMs: number | null;
/** Cumulative cooperative-sleep time (ms). */
totalSleptMs: number;
/** Number of cooperative sleeps. */
sleeps: number;
/** High-water mark of acquirers blocked on the permit (sync path). */
maxWaiters: number;
};
}
/**
* Library-level embed. Throws on validation errors; per-page embed failures
* are logged to stderr but do not throw (matches the existing CLI semantics
* for batch runs). Safe to call from Minions handlers — no process.exit.
*
* Returns EmbedResult with accurate counts so callers (runCycle, sync
* auto-embed step) can report embeddings in their own structured output.
*/
/**
* Tagged error class thrown when the schema column dim disagrees with
* the gateway's resolved dim. Caught by `runEmbed` (the CLI wrapper) to
* emit a paste-ready recipe instead of raw Postgres errors page by page.
*
* v0.37 fix wave (Lane D.2 + CDX2-9). Pre-fix the worker pool ran the
* whole queue past the first dim mismatch because per-page errors were
* silently logged + skipped. Now `runEmbedCore` pre-flights at entry +
* the worker pool catches per-page mismatches and surfaces them.
*/
export class EmbeddingDimMismatchError extends Error {
readonly kind = 'embedding_dim_mismatch' as const;
constructor(public readonly recipeMessage: string) {
super(recipeMessage);
this.name = 'EmbeddingDimMismatchError';
}
}
/**
* Pre-flight check: read the actual schema column dim and compare to the
* gateway's resolved dim. Throws `EmbeddingDimMismatchError` on mismatch
* so the entry-point catch surfaces the recipe. Catches the headline
* fresh-install bug class at the very first invocation instead of letting
* the worker pool hammer N pages with raw 22000 errors.
*/
async function preflightDimMismatch(engine: BrainEngine, dryRun: boolean): Promise<void> {
if (dryRun) return; // dry-run never embeds, no risk
const { readContentChunksEmbeddingDim, embeddingMismatchMessage } = await import('../core/embedding-dim-check.ts');
const { getEmbeddingDimensions, getEmbeddingModel } = await import('../core/ai/gateway.ts');
let existing;
try {
existing = await readContentChunksEmbeddingDim(engine);
} catch {
return; // probe failure shouldn't block embed; the worker pool will surface real errors
}
if (!existing.exists || existing.dims === null) return;
let resolvedDims: number;
let resolvedModel: string;
try {
resolvedDims = getEmbeddingDimensions();
resolvedModel = getEmbeddingModel();
} catch {
return; // gateway unconfigured — worker pool will error informatively
}
if (existing.dims === resolvedDims) return;
const databasePath = (engine as { _savedConfig?: { database_path?: string } })._savedConfig?.database_path;
const recipe = embeddingMismatchMessage({
currentDims: existing.dims,
requestedDims: resolvedDims,
requestedModel: resolvedModel,
source: 'embed',
engineKind: engine.kind,
databasePath,
});
throw new EmbeddingDimMismatchError(recipe);
}
export async function runEmbedCore(engine: BrainEngine, opts: EmbedOpts): Promise<EmbedResult> {
// v0.37.10.0 T7 (D9): refuse cleanly when init persisted the deferred-setup
// sentinel. Skipped in dryRun mode so plan-mode introspection still works.
if (!opts.dryRun) {
assertEmbeddingEnabled(loadConfig());
}
// v0.41.6.0 D1: preflight embedding credentials. Skipped in dryRun mode
// so plan-mode introspection still works (no provider calls needed).
//
// runEmbedCore is a LIBRARY function called from both the CLI (runEmbed)
// and the cycle (runCycle's embed phase + autopilot-cycle handler). THROW
// EmbeddingCredentialError so the cycle's per-phase try/catch can
// gracefully fail-the-phase without killing the worker process. The CLI
// wrapper at src/commands/embed.ts:runEmbed catches and exits.
if (!opts.dryRun) {
const { validateEmbeddingCreds } = await import('../core/embed-preflight.ts');
validateEmbeddingCreds();
}
// v0.37.11.0 (Lane D.2): pre-flight dim-mismatch check. Catches the headline
// fresh-install bug class before the worker pool spends 20 parallel calls
// hitting raw Postgres dimension errors.
await preflightDimMismatch(engine, !!opts.dryRun);
const result: EmbedResult = {
embedded: 0,
skipped: 0,
would_embed: 0,
total_chunks: 0,
pages_processed: 0,
dryRun: !!opts.dryRun,
};
if (opts.slugs && opts.slugs.length > 0) {
for (const s of opts.slugs) {
if (isAborted(opts.signal)) break; // #1737: stop the per-slug loop on abort
try {
await embedPage(engine, s, !!opts.dryRun, result, opts.sourceId, opts.signal);
} catch (e: unknown) {
serr(` Error embedding ${s}: ${e instanceof Error ? e.message : e}`);
}
}
return result;
}
if (opts.all || opts.stale) {
// E-2 (paced-backfill): CLI single-flight. Take the SAME per-source lock as
// the embed-backfill minion handler so a hand-run backfill and a queued job
// are mutually exclusive per source. All-source runs lock every source in
// sorted (deterministic) order to avoid acquire-order deadlock. Released in
// the finally below. Skipped for dryRun and when the caller didn't opt in
// (cycle / catch-up / sync-auto-embed callers never single-flight).
const sfLocks: DbLockHandle[] = [];
if (opts.singleFlight && opts.stale && !opts.dryRun) {
let lockSourceIds: string[];
if (opts.sourceId) {
lockSourceIds = [opts.sourceId];
} else {
try {
const rows = await engine.listAllSources();
lockSourceIds = rows.map((r) => r.id).sort();
} catch {
lockSourceIds = [];
}
}
for (const sid of lockSourceIds) {
let lock: DbLockHandle | null = null;
try {
lock = await tryAcquireDbLock(engine, embedBackfillLockId(sid), 60);
} catch {
// Fail-open: a lock-subsystem error must not crash a backfill. Drop
// single-flight for this run (release what we took) and proceed.
for (const h of sfLocks) {
try { await h.release(); } catch { /* best-effort */ }
}
sfLocks.length = 0;
break;
}
if (!lock) {
// Another backfill (CLI or job) holds this source. Release what we
// took and bail cleanly rather than racing the upsert path.
for (const h of sfLocks) {
try { await h.release(); } catch { /* best-effort */ }
}
serr(` [embed] another backfill is already running for source "${sid}"; skipping (single-flight).`);
return result;
}
sfLocks.push(lock);
}
}
// Resolve DB-contention pacing (env > config > bundle; env is the
// incident escape hatch). dryRun skips it — no writes to pace. A
// disabled bundle yields a no-op pacer (zero overhead on the hot path).
let pacer: DbPacer = createNoopPacer();
let paceMaxConcurrency: number | undefined;
if (!opts.dryRun) {
try {
const cfg = await loadPaceModeConfig(engine);
const { envMode, envOverrides } = readPaceEnv();
// Codex P2: an interactive CLI flag (--pace) is the most immediate
// intent and sits at the per-call tier (beats env). But a flag
// SERIALIZED into a background job payload must sit at the CONFIG tier
// so GBRAIN_PACE_* on the worker can still override it at execution
// (incident escape hatch). paceFromBackground distinguishes the two.
const fromBg = !!opts.paceFromBackground;
const knobs = resolvePaceMode({
mode: fromBg ? (opts.pace?.perCallMode ?? cfg.mode) : cfg.mode,
configOverrides: fromBg
? { ...cfg.configOverrides, ...(opts.pace?.perCall ?? {}) }
: cfg.configOverrides,
envMode,
envOverrides,
perCallMode: fromBg ? undefined : opts.pace?.perCallMode,
perCall: fromBg ? undefined : opts.pace?.perCall,
});
if (knobs.enabled) {
pacer = createDbPacer({ bundle: knobs });
paceMaxConcurrency = knobs.maxConcurrency;
}
} catch {
// Fail-open: pacing must never break a backfill.
pacer = createNoopPacer();
}
}
try {
await embedAll(engine, !!opts.stale, !!opts.dryRun, result, opts.onProgress, opts.sourceId, {
batchSize: opts.batchSize,
priority: opts.priority,
catchUp: opts.catchUp,
pacer,
paceMaxConcurrency,
}, opts.signal);
} finally {
// E1: surface pacing telemetry (human + structured) when pacing was on.
const snap = pacer.snapshot();
if (snap.enabled) {
result.pacing = {
maxConcurrency: snap.maxConcurrency,
samples: snap.sampleCount,
ewmaMs: snap.ewmaMs,
totalSleptMs: snap.totalSleptMs,
sleeps: snap.sleepCount,
maxWaiters: snap.maxWaiters,
};
serr(
` [embed] pacing: cap=${snap.maxConcurrency} samples=${snap.sampleCount} ` +
`ewma=${snap.ewmaMs === null ? 'n/a' : Math.round(snap.ewmaMs) + 'ms'} ` +
`slept=${snap.totalSleptMs}ms/${snap.sleepCount}`,
);
}
pacer.dispose();
// E-2: release single-flight locks (reverse order). Best-effort; the
// lock TTL is the backstop if a release fails.
for (const h of sfLocks.reverse()) {
try { await h.release(); } catch { /* best-effort; TTL covers it */ }
}
}
return result;
}
if (opts.slug) {
await embedPage(engine, opts.slug, !!opts.dryRun, result, opts.sourceId, opts.signal);
return result;
}
throw new Error('No embed target specified. Pass { slug }, { slugs }, { all }, or { stale }.');
}
/**
* Parse the `--pace` family from a CLI arg list. Returns ONLY the explicit
* overrides (CX5: never the full resolved bundle) so they can be serialized
* into a background-job payload and re-resolved (env > config > bundle) at
* execution. Returns undefined when no pace flag is present.
*
* Recognized: `--pace` (bare ⇒ balanced), `--pace=<mode>`,
* `--pace-max-concurrency=<n>` / `--pace-max-concurrency <n>`.
*/
export function parsePaceArgs(
args: string[],
): { perCallMode?: string; perCall?: PaceKeyOverrides } | undefined {
let perCallMode: string | undefined;
let perCall: PaceKeyOverrides | undefined;
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--pace') {
perCallMode = 'balanced';
} else if (a.startsWith('--pace=')) {
perCallMode = a.slice('--pace='.length) || 'balanced';
} else if (a.startsWith('--pace-max-concurrency=')) {
const n = parseInt(a.slice('--pace-max-concurrency='.length), 10);
if (Number.isFinite(n) && n >= 1) (perCall ??= {}).maxConcurrency = n;
} else if (a === '--pace-max-concurrency') {
const n = parseInt(args[i + 1] ?? '', 10);
if (Number.isFinite(n) && n >= 1) (perCall ??= {}).maxConcurrency = n;
i++; // consume the value token so positional parsing can't read it as a slug (Codex P2)
}
}
if (perCallMode === undefined && perCall === undefined) return undefined;
return { ...(perCallMode !== undefined && { perCallMode }), ...(perCall && { perCall }) };
}
export async function runEmbed(engine: BrainEngine, args: string[]): Promise<EmbedResult | undefined> {
// v0.36+ T7: --background submits via Minion queue, returns job_id to
// stdout, exits. Same semantics in TTY and cron (D9).
if (args.includes('--background')) {
const { maybeBackground } = await import('../core/cli-options.ts');
const backgrounded = await maybeBackground({
engine,
args,
jobName: 'embed',
paramBuilder: (cleanArgs) => {
const slugsI = cleanArgs.indexOf('--slugs');
const srcI = cleanArgs.indexOf('--source');
return {
all: cleanArgs.includes('--all'),
stale: cleanArgs.includes('--stale'),
dryRun: cleanArgs.includes('--dry-run'),
slugs: slugsI >= 0 ? cleanArgs.slice(slugsI + 1).filter(a => !a.startsWith('--')) : undefined,
sourceId: srcI >= 0 ? cleanArgs[srcI + 1] : undefined,
// CX1+CX5: carry explicit pace overrides into the `embed` job payload
// (the job name CLI --background actually submits). The handler
// re-resolves env > config > bundle at execution.
...(parsePaceArgs(cleanArgs) && { pace: parsePaceArgs(cleanArgs) }),
};
},
source: 'cli',
});
if (backgrounded) return;
// PGLite degraded to inline — fall through.
}
const slugsIdx = args.indexOf('--slugs');
const all = args.includes('--all');
const stale = args.includes('--stale');
const dryRun = args.includes('--dry-run');
// v0.31.12: --source <id> scopes to a single source.
const sourceIdx = args.indexOf('--source');
const sourceId = sourceIdx >= 0 ? args[sourceIdx + 1] : undefined;
// v0.41.18.0 (A13): --batch-size N, --priority recent, --catch-up flags.
const batchSizeIdx = args.indexOf('--batch-size');
const batchSizeRaw = batchSizeIdx >= 0 ? args[batchSizeIdx + 1] : undefined;
const batchSize = batchSizeRaw ? Math.max(1, Math.min(10_000, parseInt(batchSizeRaw, 10) || 0)) : undefined;
const priorityIdx = args.indexOf('--priority');
const priorityRaw = priorityIdx >= 0 ? args[priorityIdx + 1] : undefined;
const priority = priorityRaw === 'recent' ? 'recent' as const : undefined;
const catchUp = args.includes('--catch-up');
const pace = parsePaceArgs(args);
let opts: EmbedOpts;
if (slugsIdx >= 0) {
opts = { slugs: args.slice(slugsIdx + 1).filter(a => !a.startsWith('--')), dryRun, sourceId, batchSize, priority, catchUp };
} else if (all || stale) {
// E-2: CLI-only single-flight for stale runs (the minion path locks itself).
opts = { all, stale, dryRun, sourceId, batchSize, priority, catchUp, ...(pace && { pace }), ...(stale && { singleFlight: true }) };
} else {
const slug = args.find(a => !a.startsWith('--'));
if (!slug) {
serr('Usage: gbrain embed [<slug>|--all|--stale|--slugs s1 s2 ...] [--dry-run] [--batch-size N] [--priority recent] [--catch-up]');
process.exit(1);
}
opts = { slug, dryRun, sourceId, batchSize, priority, catchUp };
}
// CLI path: wire a reporter so --progress-json / --quiet / TTY rendering
// all work. Minion handlers call runEmbedCore directly with their own
// onProgress (see jobs.ts).
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
let progressStarted = false;
opts.onProgress = (done, total, _embedded) => {
if (!progressStarted) {
progress.start('embed.pages', total);
progressStarted = true;
}
progress.tick(1);
};
try {
const result = await runEmbedCore(engine, opts);
if (progressStarted) progress.finish();
return result;
} catch (e) {
if (progressStarted) progress.finish();
// v0.41.6.0 D1: preflight throws EmbeddingCredentialError; surface the
// paste-ready userMessage instead of the bare exception text.
const { EmbeddingCredentialError } = await import('../core/embed-preflight.ts');
if (e instanceof EmbeddingCredentialError) {
serr('');
serr(e.userMessage);
serr('');
} else if (e instanceof EmbeddingDimMismatchError) {
// D.2: surface dim-mismatch failures with the paste-ready recipe
// instead of the raw Postgres error message.
serr('\n' + e.recipeMessage + '\n');
} else {
serr(e instanceof Error ? e.message : String(e));
}
process.exit(1);
}
}
async function embedPage(
engine: BrainEngine,
slug: string,
dryRun: boolean,
result: EmbedResult,
sourceId?: string,
signal?: AbortSignal,
) {
const opts = sourceId ? { sourceId } : undefined;
const page = await engine.getPage(slug, opts);
if (!page) {
throw new Error(`Page not found: ${slug}`);
}
// Get existing chunks or create new ones.
// In dryRun, we still chunk the text locally to count what WOULD be
// embedded — but we never write chunks or call the embedding model.
let chunks = await engine.getChunks(slug, opts);
if (chunks.length === 0) {
const inputs: ChunkInput[] = [];
if (page.compiled_truth.trim()) {
for (const c of chunkText(page.compiled_truth)) {
inputs.push({ chunk_index: inputs.length, chunk_text: c.text, chunk_source: 'compiled_truth' });
}
}
if (page.timeline.trim()) {
for (const c of chunkText(page.timeline)) {
inputs.push({ chunk_index: inputs.length, chunk_text: c.text, chunk_source: 'timeline' });
}
}
if (dryRun) {
// Count what chunking WOULD produce, without writing.
result.total_chunks += inputs.length;
result.would_embed += inputs.length;
result.pages_processed++;
return;
}
if (inputs.length > 0) {
await engine.upsertChunks(slug, inputs, opts);
chunks = await engine.getChunks(slug, opts);
}
}
// Embed chunks without embeddings
const toEmbed = chunks.filter(c => !c.embedded_at);
result.total_chunks += chunks.length;
result.skipped += chunks.length - toEmbed.length;
if (toEmbed.length === 0) {
slog(`${slug}: all ${chunks.length} chunks already embedded`);
result.pages_processed++;
return;
}
if (dryRun) {
result.would_embed += toEmbed.length;
result.pages_processed++;
return;
}
const embeddings = await embedBatch(toEmbed.map(c => c.chunk_text), { abortSignal: signal });
const embeddingMap = new Map<number, Float32Array>();
for (let j = 0; j < toEmbed.length; j++) {
embeddingMap.set(toEmbed[j].chunk_index, embeddings[j]);
}
const updated: ChunkInput[] = chunks.map(c => ({
chunk_index: c.chunk_index,
chunk_text: c.chunk_text,
chunk_source: c.chunk_source,
embedding: embeddingMap.get(c.chunk_index),
token_count: c.token_count || Math.ceil(c.chunk_text.length / 4),
}));
await engine.upsertChunks(slug, updated, opts);
// v0.41.31: stamp provenance so a later model/dims swap is detectable as
// stale. embedPage is the per-slug path used by `gbrain embed <slug>` AND
// by `gbrain sync`'s post-import embed step (runEmbedCore({slugs})).
// Guard: only stamp when EVERY chunk was (re)embedded this pass. If some
// chunks were preserved from a prior embed (unknown/old provenance), the
// page is mixed — don't claim it's current. `embed --all` fully re-embeds
// such a page and then stamps it.
if (toEmbed.length === chunks.length) {
await engine.setPageEmbeddingSignature(slug, { sourceId, signature: currentEmbeddingSignature() });
}
result.embedded += toEmbed.length;
result.pages_processed++;
slog(`${slug}: embedded ${toEmbed.length} chunks`);
}
async function embedAll(
engine: BrainEngine,
staleOnly: boolean,
dryRun: boolean,
result: EmbedResult,
onProgress?: (done: number, total: number, embedded: number) => void,
sourceId?: string,
staleOpts?: {
batchSize?: number;
priority?: 'recent';
catchUp?: boolean;
/** DB-contention pacer (paced-backfill); no-op when pacing is off. */
pacer?: DbPacer;
/** Resolved concurrency cap (E-1: the worker count, no separate permit). */
paceMaxConcurrency?: number;
},
signal?: AbortSignal,
) {
// v0.41.31: current embedding provenance signature. Stamped onto pages
// when their chunks are (re)embedded so a later model/dimension swap is
// detectable as stale.
const signature = currentEmbeddingSignature();
// ─────────────────────────────────────────────────────────────
// Stale-only fast path: avoid the listPages + per-page getChunks
// bomb that pulled every page row + every chunk's embedding column
// (~76 MB on a 1.5K-page brain) only to client-side-filter for
// chunks where embedding IS NULL. The new path issues one SQL
// pre-check + at most one slug-grouped SELECT excluding the
// (always-null on stale rows) embedding column. On a 100%-embedded
// brain (the autopilot common case) we exit after ~50 bytes wire.
//
// For --all (staleOnly=false) we keep the original behavior — the
// user is explicitly asking to re-embed everything, including
// chunks that already have embeddings.
// ─────────────────────────────────────────────────────────────
if (staleOnly) {
// D7: thread sourceId so `gbrain embed --stale --source X` actually scopes.
// v0.41.18.0 (A13): thread batchSize/priority/catchUp into the stale path.
// #1737: thread the external abort signal so the cycle embed phase bails.
await embedAllStale(engine, sourceId, dryRun, result, onProgress, staleOpts, signature, signal);
// #2089: backfill takes.embedding — before this pass nothing ever wrote
// it, so the vector takes arm (searchTakesVector / think takes_vec) was
// structurally dead. Runs on every --stale caller (CLI, cycle embed
// phase, sync auto-embed). listStaleTakes is brain-wide (not
// source-scoped) — embedding another source's takes is a harmless
// idempotent write, not a read-side leak.
if (!isAborted(signal)) {
await embedStaleTakes(engine, dryRun, result, signal);
}
return;
}
// --all path: pacer (no-op when off). E-1: lower the worker count to the
// resolved cap instead of adding a separate permit.
const pacer = staleOpts?.pacer ?? createNoopPacer();
// v0.31.12: when sourceId is set, scope listPages to that source.
// v0.41 (D8 + Codex r2 #11): apply embed-skip filter via the shared
// helper so the `--all` path honors `frontmatter.embed_skip` the same
// way the `--stale` path does. Without this filter, `gbrain embed --all`
// (common after model swaps) re-embeds every soft-blocked page,
// defeating the soft-block. Filtering JS-side here mirrors the SQL-side
// filter that listStaleChunks/countStaleChunks apply on --stale.
const allPages = await engine.listPages({ limit: 100000, ...(sourceId && { sourceId }) });
const pages = filterOutEmbedSkipped(allPages);
const skippedByEmbedSkip = allPages.length - pages.length;
if (skippedByEmbedSkip > 0) {
serr(`[embed] skipped ${skippedByEmbedSkip} page(s) with frontmatter.embed_skip set`);
}
let processed = 0;
// Concurrency limit for parallel page embedding.
// Each worker pulls pages from a shared queue and makes independent
// embedBatch calls to OpenAI + upsertChunks to the engine.
//
// Default 20: keeps us well under OpenAI's embedding RPM limit
// (3000+/min for tier 1 = 50+/sec, 20 parallel is safely below) and
// avoids overwhelming postgres connection pools. Users can tune via
// GBRAIN_EMBED_CONCURRENCY env var based on their tier/infra.
// Paced runs lower this to the resolved cap (the real lever vs pooler-slot
// starvation); unpaced keeps the env/default 20. Codex P2: only ever LOWER —
// never raise above an operator's existing env cap.
const BASE_CONCURRENCY = parseInt(process.env.GBRAIN_EMBED_CONCURRENCY || '20', 10);
const CONCURRENCY = staleOpts?.paceMaxConcurrency
? Math.min(BASE_CONCURRENCY, staleOpts.paceMaxConcurrency)
: BASE_CONCURRENCY;
async function embedOnePage(page: typeof pages[number]) {
// #1737: bail before doing any work for this page if the run was aborted.
if (isAborted(signal)) return;
// v0.31.12: thread source_id from the page row so getChunks/upsertChunks
// target the correct (source_id, slug) row, not the 'default' source.
const pageSourceId = page.source_id;
const pageOpts = pageSourceId ? { sourceId: pageSourceId } : undefined;
const chunks = await observed(pacer, () => engine.getChunks(page.slug, pageOpts));
const toEmbed = chunks; // staleOnly path handled above via embedAllStale
result.total_chunks += chunks.length;
result.skipped += chunks.length - toEmbed.length;
if (toEmbed.length === 0) {
processed++;
result.pages_processed++;
onProgress?.(processed, pages.length, result.embedded);
return;
}
if (dryRun) {
result.would_embed += toEmbed.length;
processed++;
result.pages_processed++;
onProgress?.(processed, pages.length, result.embedded);
return;
}
try {
const embeddings = await embedBatch(toEmbed.map(c => c.chunk_text));
// Build a map of new embeddings by chunk_index
const embeddingMap = new Map<number, Float32Array>();
for (let j = 0; j < toEmbed.length; j++) {
embeddingMap.set(toEmbed[j].chunk_index, embeddings[j]);
}
// Preserve ALL chunks, only update embeddings for stale ones
const updated: ChunkInput[] = chunks.map(c => ({
chunk_index: c.chunk_index,
chunk_text: c.chunk_text,
chunk_source: c.chunk_source,
embedding: embeddingMap.get(c.chunk_index) ?? undefined,
token_count: c.token_count || Math.ceil(c.chunk_text.length / 4),
}));
await observed(pacer, () => engine.upsertChunks(page.slug, updated, pageOpts));
// v0.41.31: stamp embedding provenance so a later model swap is
// detectable as stale.
await observed(pacer, () =>
engine.setPageEmbeddingSignature(page.slug, { sourceId: pageSourceId, signature }),
);
result.embedded += toEmbed.length;
} catch (e: unknown) {
serr(`\n Error embedding ${page.slug}: ${e instanceof Error ? e.message : e}`);
}
processed++;
result.pages_processed++;
onProgress?.(processed, pages.length, result.embedded);
// Cooperative DB-contention pace between pages (no-op when unpaced).
try {
await pacer.pace(signal);
} catch (e) {
if (!(e instanceof AbortError)) throw e;
}
}
// v0.41.15.0: sliding worker pool extracted into src/core/worker-pool.ts.
// Throughput characteristics unchanged from the prior inline pool — N
// workers atomically claim the next page; the helper is the canonical
// primitive. embedOnePage handles its own per-page errors via try/catch
// and stderr log (no rethrow), so we don't need failures[] here and
// omitting onError means the default 'continue' policy applies cleanly
// even though no errors should reach the pool's catch.
await runSlidingPool({
items: pages,
workers: CONCURRENCY,
...(signal && { signal }), // #1737: pool stops claiming pages once aborted
onItem: (page) => embedOnePage(page),
failureLabel: (page) => page.slug,
});
// Stdout summary preserved for scripts/tests that grep for counts.
if (dryRun) {
slog(`[dry-run] Would embed ${result.would_embed} chunks across ${pages.length} pages`);
} else {
slog(`Embedded ${result.embedded} chunks across ${pages.length} pages`);
}
}
/**
* #2089: embed stale take claims (`active AND embedding IS NULL`) so the
* vector takes search arm has data. Mirrors embedAllStale's log-and-skip
* semantics: a failure leaves the remaining takes stale (retried next run)
* and never throws. Exported with an `embedFn` seam for tests (same pattern
* as embedStaleForSource).
*/
export async function embedStaleTakes(
engine: BrainEngine,
dryRun: boolean,
result: EmbedResult,
signal?: AbortSignal,
embedFn: (texts: string[], o: { abortSignal?: AbortSignal }) => Promise<Float32Array[]> =
(texts, o) => embedBatchWithBackoff(texts, { abortSignal: o.abortSignal }),
): Promise<void> {
let staleCount: number;
try {
// ?? 0: tolerate partial engines (test mocks) that don't implement takes.
staleCount = Number(await engine.countStaleTakes() ?? 0);
} catch (e: unknown) {
// Pre-takes-table brain (partial upgrade) — nothing to do.
serr(` [embed] takes: stale count failed: ${e instanceof Error ? e.message : e}`);
return;
}
if (!staleCount) return;
if (dryRun) {
result.would_embed += staleCount;
slog(`[dry-run] Would embed ${staleCount} stale take claim(s)`);
return;
}
const rows = (await engine.listStaleTakes()) ?? [];
const BATCH = 100;
let embedded = 0;
for (let i = 0; i < rows.length; i += BATCH) {
if (isAborted(signal)) break;
const batch = rows.slice(i, i + BATCH);
try {
const embeddings = await embedFn(batch.map((r) => r.claim), { abortSignal: signal });
embedded += await engine.updateTakeEmbeddings(
batch.map((r, j) => ({ take_id: r.take_id, embedding: embeddings[j] })),
);
} catch (e: unknown) {
if (isAborted(signal)) break;
// ponytail: stop on first failure (dim mismatch against the takes
// vector column, provider outage) instead of grinding every batch into
// the same error; the remaining takes stay NULL and retry next --stale.
serr(
` [embed] takes: failed at ${batch[0].page_slug}#${batch[0].row_num}: ` +
`${e instanceof Error ? e.message : e}; remaining stale takes will retry next run`,
);
break;
}
}
if (embedded > 0) {
result.takes_embedded = (result.takes_embedded ?? 0) + embedded;
slog(`Embedded ${embedded} take claim(s)`);
}
}
/**
* SQL-side stale path: replaces the listPages + per-page getChunks
* walk with a count + slug-grouped SELECT. Preserves the existing
* functional contract (every chunk where embedding IS NULL gets
* embedded; nothing else is touched) without paying egress on
* already-embedded chunks.
*
* Why a separate function: the staleOnly path doesn't need
* listPages at all and groups by slug differently. Forking the
* function makes the read-bytes path explicit and keeps the --all
* path verbatim from prior behavior.
*
* Staleness predicate: `embedding IS NULL`. We deliberately do NOT
* use `embedded_at IS NULL` here — the bulk-import path can leave
* embedded_at populated while embedding is NULL (see upsertChunks
* consistency notes), and `embedding IS NULL` is the truth source
* for "this chunk needs an embedding".
*/
async function embedAllStale(
engine: BrainEngine,
sourceId: string | undefined,
dryRun: boolean,
result: EmbedResult,
onProgress?: (done: number, total: number, embedded: number) => void,
staleOpts?: {
batchSize?: number;
priority?: 'recent';
catchUp?: boolean;
/** DB-contention pacer (paced-backfill); no-op when pacing is off. */
pacer?: DbPacer;
/** Resolved concurrency cap (E-1: the worker count, no separate permit). */
paceMaxConcurrency?: number;
},
signature?: string,
externalSignal?: AbortSignal,
) {
// D7: thread sourceId so source-scoped runs only count + visit
// that source's NULL embeddings.
const sourceOpt = sourceId ? { sourceId } : undefined;
// v0.41.31: re-embed pages whose embedding_signature drifted (model/dims
// swap). dry-run must NOT mutate, so it counts signature-stale via the
// widened predicate; a live run NULLs them first so the existing
// NULL-embedding cursor (listStaleChunks) picks them up unchanged.
if (!dryRun && signature) {
const invalidated = await engine.invalidateStaleSignatureEmbeddings({
signature,
...(sourceId && { sourceId }),
});
if (invalidated > 0) {
slog(`[embed] invalidated ${invalidated} chunk(s) embedded under a prior model signature`);
}
}
// Pre-flight: 0 stale chunks → nothing to do, no further DB reads.
// dry-run includes signature-drift in the count without mutating.
const staleCount = await engine.countStaleChunks(
dryRun && signature ? { ...sourceOpt, signature } : sourceOpt,
);
if (staleCount === 0) {
if (dryRun) {
slog('[dry-run] Would embed 0 chunks (0 stale found)');
} else {
slog('Embedded 0 chunks (0 stale found)');
}
return;
}
if (dryRun) {
result.would_embed += staleCount;
result.total_chunks += staleCount;
if (onProgress) onProgress(1, 1, 0);
slog(`[dry-run] Would embed ${staleCount} stale chunks`);
return;
}
// v0.33.3: cursor-paginated stale loading. Instead of pulling all 48K+
// rows in one query (which times out on Supabase's 2-min pooler timeout),
// we page through 2000 rows at a time via keyset pagination on
// (page_id, chunk_index). Each query finishes in <1s.
// v0.41.18.0 (A13): --batch-size N CLI flag overrides hardcoded 2000 default.
const PAGE_SIZE = staleOpts?.batchSize ?? 2000;
// Paced runs lower concurrency to the resolved cap (E-1: worker count IS the
// lever on this single pool, no separate permit). Codex P2: pacing only ever
// LOWERS concurrency — never raise above an operator's existing env cap.
const BASE_CONCURRENCY = parseInt(process.env.GBRAIN_EMBED_CONCURRENCY || '20', 10);
const CONCURRENCY = staleOpts?.paceMaxConcurrency
? Math.min(BASE_CONCURRENCY, staleOpts.paceMaxConcurrency)
: BASE_CONCURRENCY;
const pacer = staleOpts?.pacer ?? createNoopPacer();
// D3 + D3a + D8: wall-clock budget. 30 min default; env override.
// #1946: --catch-up removes the wall-clock cap. The prior code set BUDGET_MS =
// Number.MAX_SAFE_INTEGER and passed it to setTimeout — but setTimeout's delay
// is a 32-bit signed int, so MAX_SAFE_INTEGER (9e15) overflows and the timer
// fires almost immediately, aborting catch-up after a single batch. The fix is
// to NOT arm the timer in catch-up at all: the keyset pass below terminates on
// its own (the (page_id, chunk_index) cursor advances monotonically), and
// SIGINT / worker-abort still propagate via externalSignal.
const BUDGET_MS: number | null = staleOpts?.catchUp
? null
: parseInt(process.env.GBRAIN_EMBED_TIME_BUDGET_MS || `${30 * 60 * 1000}`, 10);
const budgetController = new AbortController();
const budgetStart = Date.now();
let budgetTimer = BUDGET_MS != null
? setTimeout(() => budgetController.abort(), BUDGET_MS)
: undefined;
// E-4 (paced-backfill): the budget measures WORK, not waiting. After each
// batch, re-arm the timer to fire at start + BUDGET + total-paced-sleep, so a
// contended DB that spends time in pace() sleeps converges instead of exiting
// having embedded little. No-op when unpaced (totalSleptMs stays 0) or in
// catch-up (no budget timer).
const rearmBudgetForPacing = (): void => {
if (BUDGET_MS == null) return;
const slept = pacer.snapshot().totalSleptMs;
if (budgetTimer) clearTimeout(budgetTimer);
const fireInMs = budgetStart + BUDGET_MS + slept - Date.now();
budgetTimer = setTimeout(() => budgetController.abort(), Math.max(0, fireInMs));
};
const budgetSignal = budgetController.signal;
// #1737: the effective signal fires when EITHER the internal wall-clock
// budget OR the caller's abort (worker timeout / lock loss / SIGTERM) fires.
// Replaces bare budgetSignal at every loop/pool/embed check below so the
// autopilot cycle's embed phase stops within one batch (~2s) of being
// killed instead of running the full 10-15 min and wedging the cycle lock.
const effectiveSignal = anySignal(budgetSignal, externalSignal);
// v0.41.18.0 (A13): --priority recent threads orderBy='updated_desc' to
// listStaleChunks. Composite cursor tracks (updated_at, page_id, chunk_index)
// instead of just (page_id, chunk_index); first-page cursor is sentinel
// (null, 0, -1).
const orderBy: 'page_id' | 'updated_desc' = staleOpts?.priority === 'recent'
? 'updated_desc'
: 'page_id';
let totalProcessedPages = 0;
let afterPageId = 0;
let afterChunkIndex = -1;
let afterUpdatedAt: string | null = null;
let totalChunksLoaded = 0;
let budgetExitNotified = false;
// #1946 (OV2a): track chunks that errored out so a catch-up pass that finishes
// with stale chunks still remaining (un-embeddable for a non-transient reason)
// surfaces that loudly instead of looking like a clean run.
let embedFailures = 0;
// E-3 (paced-backfill): bounded end-of-run re-entry. A longer paced run gives
// a live writer (sync / put_page) more time to insert NEW stale rows BEHIND
// the keyset cursor (TODOS:2301). When the cursor exhausts, re-scan from the
// start — capped at MAX_REENTRIES AND requiring forward progress (a pass that
// embeds 0 while count>0 stops) so a writer outrunning embed can't spin
// forever.
const MAX_REENTRIES = 3;
let reentries = 0;
let lastReentryEmbedded = 0;
const maybeReenter = async (): Promise<boolean> => {
// Scoped to PACED runs: pacing lengthens the run, which is what widens the
// behind-cursor window. Unpaced runs keep prior (single-pass) behavior.
if (!pacer.snapshot().enabled) return false;
if (effectiveSignal.aborted) return false;
if (reentries >= MAX_REENTRIES) return false;
const remaining = await engine.countStaleChunks(sourceOpt);
if (remaining === 0) return false;
if (result.embedded === lastReentryEmbedded) return false; // no forward progress
lastReentryEmbedded = result.embedded;
reentries++;
afterPageId = 0;
afterChunkIndex = -1;
afterUpdatedAt = null;
serr(`\n [embed] re-entry ${reentries}/${MAX_REENTRIES}: ${remaining} stale chunk(s) appeared during the run; rescanning from start.`);
return true;
};
try {
// eslint-disable-next-line no-constant-condition
while (true) {
if (effectiveSignal.aborted) {
if (!budgetExitNotified) {
const why = budgetSignal.aborted
? `wall-clock budget (${BUDGET_MS}ms) exceeded`
: 'aborted by caller (job timeout / lock loss / shutdown)';
serr(`\n [embed] ${why}; exiting cleanly. Re-run picks up via partial index.`);
budgetExitNotified = true;
}
break;
}
const batch = await observed(pacer, () =>
engine.listStaleChunks({
batchSize: PAGE_SIZE,
afterPageId,
afterChunkIndex,
...(orderBy === 'updated_desc' && {
orderBy,
afterUpdatedAt,
}),
...(sourceId && { sourceId }),
}),
);
if (batch.length === 0) {
if (await maybeReenter()) continue;
break;
}
totalChunksLoaded += batch.length;
// Advance cursor to last row in this batch.
const last = batch[batch.length - 1];
afterPageId = last.page_id;
afterChunkIndex = last.chunk_index;
if (orderBy === 'updated_desc') {
// engine returns `updated_at` as Date or ISO string; normalize to ISO.
const lastRow = last as unknown as { updated_at?: string | Date | null };
const u = lastRow.updated_at;
afterUpdatedAt = u instanceof Date ? u.toISOString()
: typeof u === 'string' ? u
: null;
}
// Group by composite key (source_id::slug).
const byKey = new Map<string, typeof batch>();
for (const row of batch) {
const key = `${row.source_id}::${row.slug}`;
const list = byKey.get(key);
if (list) list.push(row);
else byKey.set(key, [row]);
}
const keys = Array.from(byKey.keys());
result.total_chunks += batch.length;
async function embedOneKey(key: string) {
const stale = byKey.get(key)!;
const keySourceId = stale[0]?.source_id ?? 'default';
const slug = stale[0].slug;
try {
const embeddings = await embedBatchWithBackoff(stale.map(c => c.chunk_text), { abortSignal: effectiveSignal });
// Re-fetch existing chunks and merge to avoid deleting non-stale chunks.
const existing = await observed(pacer, () => engine.getChunks(slug, { sourceId: keySourceId }));
const staleIdxToEmbedding = new Map<number, Float32Array>();
for (let j = 0; j < stale.length; j++) {
staleIdxToEmbedding.set(stale[j].chunk_index, embeddings[j]);
}
const merged: ChunkInput[] = existing.map(c => ({
chunk_index: c.chunk_index,
chunk_text: c.chunk_text,
chunk_source: c.chunk_source,
embedding: staleIdxToEmbedding.get(c.chunk_index) ?? undefined,
token_count: c.token_count || Math.ceil(c.chunk_text.length / 4),
}));
await observed(pacer, () => engine.upsertChunks(slug, merged, { sourceId: keySourceId }));
// v0.41.31: stamp provenance after the page's chunks are embedded —
// but only when EVERY chunk was stale (fully re-embedded this pass).
// A partially-stale page keeps preserved chunks of unknown/old
// provenance, so don't claim it's current. (After invalidate, a
// signature-drifted page IS fully stale → this stamps it.)
if (signature && stale.length === existing.length) {
await observed(pacer, () =>
engine.setPageEmbeddingSignature(slug, { sourceId: keySourceId, signature }),
);
}
result.embedded += stale.length;
} catch (e: unknown) {
// Budget/abort-fired cancellations are expected on the way out; don't
// spam per-page "Error embedding" lines when we're shutting down.
if (effectiveSignal.aborted) return;
embedFailures++;
serr(`\n Error embedding ${slug}: ${e instanceof Error ? e.message : e}`);
}
totalProcessedPages++;
result.pages_processed++;
// Use staleCount as the estimated total for progress (not exact after
// pagination starts, but directionally correct).
onProgress?.(totalProcessedPages, Math.ceil(staleCount / PAGE_SIZE) * keys.length, result.embedded);
// Cooperative DB-contention pace between keys (no-op when unpaced).
// E-4 (Codex P1): pace() is subject to the EXTERNAL abort only, NOT the
// wall-clock budget — a contended DB's sleep must not be cut by the
// budget timer before its time is credited. Re-arm the budget right
// after each sleep so accrued sleep never eats into work time.
try {
await pacer.pace(externalSignal);
rearmBudgetForPacing();
} catch (e) {
if (!(e instanceof AbortError)) throw e;
}
}
// v0.41.15.0: migrated to shared runSlidingPool. The pool checks
// its `signal` argument before each claim (mirrors the pre-migration
// `!budgetSignal.aborted` gate) AND threads abort into in-flight
// onItem via the local-abort composition for D13. embedOneKey
// already handles its own per-key errors via try/catch + stderr.
await runSlidingPool({
items: keys,
workers: CONCURRENCY,
signal: effectiveSignal,
onItem: (key) => embedOneKey(key),
failureLabel: (key) => key,
});
// E-4: extend the work budget by any paced-sleep time accrued this batch.
rearmBudgetForPacing();
// If we got fewer rows than PAGE_SIZE, we've reached the end.
if (batch.length < PAGE_SIZE) {
if (await maybeReenter()) continue;
break;
}
}
} finally {
if (budgetTimer) clearTimeout(budgetTimer);
}
slog(`Embedded ${result.embedded} chunks across ${totalProcessedPages} pages`);
// #1946 (OV2a): a catch-up pass that completed without being aborted but left
// chunks unembedded means those chunks are stuck (a non-transient embed
// failure), not that we ran out of time. Surface it loudly so it doesn't read
// as a clean run — re-running won't help until the underlying failure is fixed.
if (staleOpts?.catchUp && !effectiveSignal.aborted && embedFailures > 0) {
const remaining = await engine.countStaleChunks(
signature ? { signature, ...(sourceId ? { sourceId } : {}) } : (sourceId ? { sourceId } : undefined),
);
if (remaining > 0) {
serr(`\n [embed] catch-up finished but ${remaining} chunk(s) remain stale after ${embedFailures} embed failure(s). These are not embeddable as-is; re-running won't clear them until the underlying error is resolved.`);
}
}
}
/**
* v0.33.3: rate-limit-aware embedBatch wrapper.
*
* The OpenAI SDK has built-in retry with exponential backoff, but its
* backoff window (max ~4s) is too short for TPM (tokens-per-minute)
* rate limits on large pages (~90K tokens). This wrapper catches
* 429-shaped errors, parses the retry delay from the error message
* (e.g. "Please try again in 248ms"), and sleeps before retrying.
*
* v0.33.4 hardening (codex + re-review findings):
* - D4: detect 429 via the wrapped error's `cause.status` (the gateway's
* normalizeAIError stores the original error there). Bare `e.status`
* never fires against an `AITransientError` wrap. Message-match stays
* as a fallback.
* - D4a: pass `maxRetries: 0` through `embedBatch` so the AI SDK's
* default 2-retry stack doesn't multiply this wrapper's 5 attempts.
* - D2: jitter the parsed delay ±30% so 20 concurrent workers don't
* resynchronize on the next 429 wave.
* - D3a/D8: when an external AbortSignal fires (wall-clock budget), the
* sleep wakes up early AND the abortSignal is threaded into the gateway
* embed call so an in-flight HTTP request cancels too.
*
* Up to MAX_RATE_LIMIT_RETRIES attempts with the parsed (jittered) delay
* (or a 60s fallback when the message can't be parsed).
*
* @internal Exported for unit tests; not part of the public surface.
*/
export const MAX_RATE_LIMIT_RETRIES = 5;
export const RATE_LIMIT_FALLBACK_MS = 60_000;
export const RATE_LIMIT_PAD_MS = 500;
export const RATE_LIMIT_JITTER = 0.3;
export interface EmbedBatchWithBackoffOpts {
abortSignal?: AbortSignal;
}
/**
* Walk the cause chain looking for a 429 status. The current
* `normalizeAIError` wraps once into `AITransientError` with `cause = original`,
* so one level is sufficient — but iterate to handle future wrap layers
* defensively (max 5 levels to bound a malformed cyclic chain).
*
* @internal exported for unit tests.
*/
export function detect429FromCause(e: unknown): boolean {
let cur: unknown = e;
for (let depth = 0; depth < 5 && cur !== undefined && cur !== null; depth++) {
const obj = cur as { status?: unknown; statusCode?: unknown; cause?: unknown };
if (obj.status === 429 || obj.statusCode === 429) return true;
cur = obj.cause;
}
return false;
}
/**
* Parse a Retry-After hint out of an OpenAI-style 429 message. Falls back
* to `RATE_LIMIT_FALLBACK_MS` when the message can't be parsed. Adds
* `RATE_LIMIT_PAD_MS` padding and `RATE_LIMIT_JITTER` randomization so
* concurrent workers don't resynchronize.
*
* @internal exported for unit tests.
*/
export function parseRetryDelayMs(msg: string, rng: () => number = Math.random): number {
let delayMs = RATE_LIMIT_FALLBACK_MS;
const msMatch = msg.match(/try again in (\d+)ms/i);
const secMatch = msg.match(/try again in ([\d.]+)s/i);
if (msMatch) delayMs = parseInt(msMatch[1], 10) + RATE_LIMIT_PAD_MS;
else if (secMatch) delayMs = Math.ceil(parseFloat(secMatch[1]) * 1000) + RATE_LIMIT_PAD_MS;
// D2: ±30% jitter to decorrelate the herd of 20 workers.
const jitterFactor = 1 + (rng() * 2 - 1) * RATE_LIMIT_JITTER;
return Math.max(1, Math.floor(delayMs * jitterFactor));
}
/**
* Sleep for `ms` milliseconds. Resolves early (not rejects) when `signal`
* fires, so the retry loop's caller can re-check `signal.aborted` and
* exit cleanly without an unhandled rejection.
*
* @internal exported for unit tests.
*/
export function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve) => {
if (signal?.aborted) {
resolve();
return;
}
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort);
resolve();
}, ms);
const onAbort = () => {
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
resolve();
};
signal?.addEventListener('abort', onAbort, { once: true });
});
}
export async function embedBatchWithBackoff(
texts: string[],
opts: EmbedBatchWithBackoffOpts = {},
): Promise<Float32Array[]> {
const signal = opts.abortSignal;
for (let attempt = 0; attempt <= MAX_RATE_LIMIT_RETRIES; attempt++) {
if (signal?.aborted) throw new Error('embed budget aborted');
try {
// D4a + D8: maxRetries:0 disables the SDK's stacked retries (so this
// wrapper is the single source of truth) and abortSignal threads
// through to the gateway so an in-flight HTTP request cancels mid-fetch.
return await embedBatch(texts, { maxRetries: 0, ...(signal && { abortSignal: signal }) });
} catch (e: unknown) {
// If the budget fired we may have been aborted mid-fetch; bubble out.
if (signal?.aborted) throw e;
const msg = e instanceof Error ? e.message : String(e);
// D4: structured detection first (handles gateway-wrapped errors via
// cause chain); message-match as fallback for providers whose wrappers
// strip `cause.status`.
const isRateLimit = detect429FromCause(e)
|| /rate.?limit|429/i.test(msg);
if (!isRateLimit || attempt === MAX_RATE_LIMIT_RETRIES) throw e;
const delayMs = parseRetryDelayMs(msg);
serr(` [rate-limit] attempt ${attempt + 1}/${MAX_RATE_LIMIT_RETRIES}, waiting ${delayMs}ms...`);
await abortableSleep(delayMs, signal);
}
}
// Unreachable, but TypeScript needs it.
return embedBatch(texts);
}