Files
gbrain/test/embed.serial.test.ts
T
24881f60fc v0.34.4.0 fix(embed): cursor-paginated --stale hardening wave (D2/D3/D4/D6/D7/D8 + regression test) (#991)
* perf(embed): cursor-paginated stale loading + rate-limit backoff + partial index

Three fixes for embed --stale on large brains (300K+ chunks):

## 1. Cursor-paginated listStaleChunks (embed timeout fix)

The previous implementation pulled ALL stale rows (up to 100K) in one
query. On a 373K-row content_chunks table with 48K stale rows, this
query took >2 min and hit Supabase's 2-min statement_timeout, causing
embed --stale to silently fail with zero progress.

Fix: keyset pagination on (page_id, chunk_index) with a default batch
size of 2000 rows. Each query finishes in <1s. The embedAllStale loop
pages through batches, embeds each batch, then advances the cursor.

## 2. Rate-limit-aware retry (429 backoff)

The OpenAI SDK's built-in retry has a ~4s max backoff window, which is
too short for TPM (tokens-per-minute) limits on large pages (~90K
tokens). The embed loop would fail after 3 SDK retries and skip the
page entirely.

Fix: embedBatchWithBackoff wrapper parses the retry delay from the
429 error message (e.g. 'try again in 248ms') and sleeps for that
duration + 500ms padding. Up to 5 retries with parsed delays (60s
fallback when unparseable).

## 3. Migration v58: partial index for NULL embeddings

`CREATE INDEX idx_chunks_embedding_null ON content_chunks (page_id,
chunk_index) WHERE embedding IS NULL` — makes countStaleChunks() and
the paginated listStaleChunks() instant instead of full-table-scanning
373K rows.

## Testing

Verified on a 99K-page / 373K-chunk brain with 48K stale chunks.
Before: embed --stale hung for 2+ min then timed out (0 progress).
After: loads 2K rows in <1s, embeds concurrently, pages through all
stale chunks without timeout.

* fix(embed): wave of hardening + tests on cursor-paginated --stale path

Lands the 9 decisions + regression test set from /plan-eng-review on PR #991's
embed-perf cherry-pick. Implements the codex outside-voice findings folded in
during plan review.

Architecture / correctness:
- D2 jitter on the parsed retry-after delay (±30%) so 20 concurrent workers
  don't relock on the next 429 wave (thundering herd fix).
- D3 + D3a + D8 wall-clock budget (GBRAIN_EMBED_TIME_BUDGET_MS, default 30
  min) threaded as an AbortSignal into THREE places: the retry sleep
  (abortableSleep), the per-key worker claim loop, and the gateway embed
  call itself (so a worker mid-fetch on a ~30s OpenAI HTTP timeout cancels
  within seconds instead of waiting it out).
- D4 structured 429 detection that unwraps the gateway's AITransientError
  wrap via cause chain (depth-limited to 5). Naive `e.status === 429` was
  silently false against normalized errors; message-match stays as
  fallback. detect429FromCause exported as @internal helper.
- D4a `maxRetries: 0` passthrough through embedBatch → gateway →
  embedMany so the AI SDK's default 2-retry stack doesn't multiply this
  wrapper's 5 attempts (was up to 15 total cycles per call).
- D6 migration v59 (embed_stale_partial_index) rewritten to use
  CREATE INDEX CONCURRENTLY + handler-based engine-branching (mirrors v14
  invalid-remnant pattern). Plain CREATE INDEX would have taken ShareLock
  on the 373K-row content_chunks table for the duration of the build.
- D7 sourceId threaded through countStaleChunks + listStaleChunks +
  embedAllStale. `gbrain embed --stale --source X` was silently dropping
  the flag pre-fix and counting/embedding across every source. Both
  Postgres and PGLite engines updated.

Tests added:
- D5 8 unit cases for embedBatchWithBackoff in test/embed.serial.test.ts:
  ms / s retry-after parse, fallback, non-rate-limit rethrow, jitter
  variance, budget abort during sleep+fetch, normalized-error cause
  unwrap, maxRetries:0 passthrough verification.
- D5a fixed every pre-existing stale-row mock to include source_id +
  page_id (required on StaleChunkRow as of v0.33.3 cursor pagination —
  TypeScript's structural typing was hiding these).
- D7 unit cases asserting CLI `--source X` parses + threads sourceId.
- Gap scan: end-to-end wall-clock budget firing in the outer pagination
  loop via runEmbedCore.
- D6 migration v59 test cases in test/migrate.test.ts: source-shape
  assertion (CONCURRENTLY + invalid-remnant DROP-before-CREATE ordering),
  PGLite handler-branch idempotency, partial-index materialization.
- REGRESSION: new test/e2e/embed-stale-pagination.test.ts covering
  static (every chunk visited exactly once), failed-page (cursor advances
  past failures, next run picks up), page-split-across-batches,
  source-scoped scan, duplicate-slug-across-sources.
- PGLite parity cases for cursor pagination, page split, source filter
  in test/pglite-engine.test.ts (pins tuple-compare against WASM build).

Gate:
- bun run test: 6305 pass / 0 fail / 0 skip across all 8 shards + serial.
- DATABASE_URL=... bun run test:e2e: 90 files, 603 tests, 0 failures.

Plan: ~/.claude/plans/system-instruction-you-are-working-iterative-torvalds.md

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

* chore: bump version and changelog (v0.34.3.0)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: garrytan-agents <garrytan-agents@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 21:22:10 -07:00

726 lines
33 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.
import { describe, test, expect, mock, beforeEach, afterEach } from 'bun:test';
import type { BrainEngine } from '../src/core/engine.ts';
// Mock the embedding module BEFORE importing runEmbed, so runEmbed picks up
// the mocked embedBatch. We track max concurrent invocations via a counter
// that increments on entry and decrements when the mock resolves.
let activeEmbedCalls = 0;
let maxConcurrentEmbedCalls = 0;
let totalEmbedCalls = 0;
// D5: capture per-call opts so tests can assert maxRetries / abortSignal
// passthrough into the gateway path.
let lastEmbedBatchOpts: unknown = undefined;
// D5: pluggable behavior for tests that need to simulate 429s or aborts.
let embedBatchBehavior: ((texts: string[], opts?: unknown) => Promise<Float32Array[]>) | null = null;
mock.module('../src/core/embedding.ts', () => ({
embedBatch: async (texts: string[], opts?: unknown) => {
activeEmbedCalls++;
totalEmbedCalls++;
lastEmbedBatchOpts = opts;
if (activeEmbedCalls > maxConcurrentEmbedCalls) {
maxConcurrentEmbedCalls = activeEmbedCalls;
}
try {
if (embedBatchBehavior) {
return await embedBatchBehavior(texts, opts);
}
// Default: simulate API latency so concurrent workers actually overlap.
await new Promise(r => setTimeout(r, 30));
return texts.map(() => new Float32Array(1536));
} finally {
activeEmbedCalls--;
}
},
}));
// Import AFTER mocking.
const { runEmbed } = await import('../src/commands/embed.ts');
// Proxy-based mock engine that matches test/import-file.test.ts pattern.
function mockEngine(overrides: Partial<Record<string, any>> = {}): BrainEngine {
const calls: { method: string; args: any[] }[] = [];
const track = (method: string) => (...args: any[]) => {
calls.push({ method, args });
if (overrides[method]) return overrides[method](...args);
return Promise.resolve(null);
};
const engine = new Proxy({} as any, {
get(_, prop: string) {
if (prop === '_calls') return calls;
if (overrides[prop]) return overrides[prop];
return track(prop);
},
});
return engine;
}
beforeEach(() => {
activeEmbedCalls = 0;
maxConcurrentEmbedCalls = 0;
totalEmbedCalls = 0;
lastEmbedBatchOpts = undefined;
embedBatchBehavior = null;
});
afterEach(() => {
delete process.env.GBRAIN_EMBED_CONCURRENCY;
delete process.env.GBRAIN_EMBED_TIME_BUDGET_MS;
});
describe('runEmbed --all (parallel)', () => {
test('runs embedBatch calls concurrently across pages', async () => {
const NUM_PAGES = 20;
const pages = Array.from({ length: NUM_PAGES }, (_, i) => ({ slug: `page-${i}` }));
// Each page has one chunk without an embedding (stale).
const chunksBySlug = new Map(
pages.map(p => [
p.slug,
[{ chunk_index: 0, chunk_text: `text for ${p.slug}`, chunk_source: 'compiled_truth', embedded_at: null, token_count: 4 }],
]),
);
const engine = mockEngine({
listPages: async () => pages,
getChunks: async (slug: string) => chunksBySlug.get(slug) || [],
upsertChunks: async () => {},
});
process.env.GBRAIN_EMBED_CONCURRENCY = '10';
await runEmbed(engine, ['--all']);
expect(totalEmbedCalls).toBe(NUM_PAGES);
// Concurrency actually happened.
expect(maxConcurrentEmbedCalls).toBeGreaterThan(1);
// And stayed within the configured limit.
expect(maxConcurrentEmbedCalls).toBeLessThanOrEqual(10);
});
test('respects GBRAIN_EMBED_CONCURRENCY=1 (serial)', async () => {
const pages = Array.from({ length: 5 }, (_, i) => ({ slug: `page-${i}` }));
const chunksBySlug = new Map(
pages.map(p => [
p.slug,
[{ chunk_index: 0, chunk_text: `text ${p.slug}`, chunk_source: 'compiled_truth', embedded_at: null, token_count: 4 }],
]),
);
const engine = mockEngine({
listPages: async () => pages,
getChunks: async (slug: string) => chunksBySlug.get(slug) || [],
upsertChunks: async () => {},
});
process.env.GBRAIN_EMBED_CONCURRENCY = '1';
await runEmbed(engine, ['--all']);
expect(totalEmbedCalls).toBe(5);
expect(maxConcurrentEmbedCalls).toBe(1);
});
test('skips pages whose chunks are all already embedded when --stale', async () => {
const chunksBySlug = new Map<string, any[]>([
['fresh', [{ chunk_index: 0, chunk_text: 'hi', chunk_source: 'compiled_truth', embedded_at: '2026-01-01', token_count: 1 }]],
['stale', [{ chunk_index: 0, chunk_text: 'hi', chunk_source: 'compiled_truth', embedded_at: null, token_count: 1 }]],
]);
// Stale path uses countStaleChunks + listStaleChunks (SQL-side filter), not listPages.
// D5a: source_id + page_id required on StaleChunkRow as of v0.33.3 cursor pagination.
const stale = [
{ slug: 'stale', chunk_index: 0, chunk_text: 'hi', chunk_source: 'compiled_truth' as const, model: null, token_count: 1, source_id: 'default', page_id: 1 },
];
const engine = mockEngine({
countStaleChunks: async () => 1,
listStaleChunks: async () => stale,
getChunks: async (slug: string) => chunksBySlug.get(slug) || [],
upsertChunks: async () => {},
});
process.env.GBRAIN_EMBED_CONCURRENCY = '5';
await runEmbed(engine, ['--stale']);
// Only the stale page triggers an embedBatch call.
expect(totalEmbedCalls).toBe(1);
});
});
// ────────────────────────────────────────────────────────────────
// runEmbedCore dry-run mode (v0.17 regression guard)
// ────────────────────────────────────────────────────────────────
describe('runEmbedCore --dry-run never calls the embedding model', () => {
test('dry-run --all with stale chunks: no embedBatch calls, accurate would_embed', async () => {
const { runEmbedCore } = await import('../src/commands/embed.ts');
const pages = Array.from({ length: 3 }, (_, i) => ({ slug: `page-${i}` }));
// All 3 pages have 2 stale chunks each (none embedded).
const chunksBySlug = new Map<string, any[]>(
pages.map(p => [
p.slug,
[
{ chunk_index: 0, chunk_text: 'a', chunk_source: 'compiled_truth', embedded_at: null, token_count: 1 },
{ chunk_index: 1, chunk_text: 'b', chunk_source: 'compiled_truth', embedded_at: null, token_count: 1 },
],
]),
);
// SQL-side stale path: 6 stale rows across 3 pages.
// D5a: source_id + page_id required on StaleChunkRow.
const stale = pages.flatMap((p, pi) => [
{ slug: p.slug, chunk_index: 0, chunk_text: 'a', chunk_source: 'compiled_truth' as const, model: null, token_count: 1, source_id: 'default', page_id: pi + 1 },
{ slug: p.slug, chunk_index: 1, chunk_text: 'b', chunk_source: 'compiled_truth' as const, model: null, token_count: 1, source_id: 'default', page_id: pi + 1 },
]);
const upserts: string[] = [];
const engine = mockEngine({
countStaleChunks: async () => 6,
listStaleChunks: async () => stale,
listPages: async () => pages,
getChunks: async (slug: string) => chunksBySlug.get(slug) || [],
upsertChunks: async (slug: string) => { upserts.push(slug); },
});
const result = await runEmbedCore(engine, { stale: true, dryRun: true });
// No OpenAI calls.
expect(totalEmbedCalls).toBe(0);
// No DB writes.
expect(upserts).toEqual([]);
// Accurate counts.
expect(result.dryRun).toBe(true);
expect(result.embedded).toBe(0);
expect(result.would_embed).toBe(6); // 3 pages * 2 chunks each
// skipped is 0 in the new SQL-side path: we never considered non-stale chunks.
expect(result.skipped).toBe(0);
expect(result.total_chunks).toBe(6); // only stale chunks counted in SQL-side path
// v0.33.3 cherry-pick: dry-run skips the cursor walk and only does a
// countStaleChunks call. pages_processed is 0 because we don't enumerate
// pages in dry-run (cheaper pre-flight).
expect(result.pages_processed).toBe(0);
});
test('dry-run --stale correctly identifies stale chunks (SQL-side path)', async () => {
const { runEmbedCore } = await import('../src/commands/embed.ts');
// SQL-side stale: only the 3 chunks where embedding IS NULL come back,
// grouped by slug. 'fresh' page has no stale rows so it's not in the result.
// D5a: source_id + page_id required on StaleChunkRow.
const stale = [
{ slug: 'partial', chunk_index: 1, chunk_text: 'b', chunk_source: 'compiled_truth' as const, model: null, token_count: 1, source_id: 'default', page_id: 1 },
{ slug: 'all-stale', chunk_index: 0, chunk_text: 'a', chunk_source: 'compiled_truth' as const, model: null, token_count: 1, source_id: 'default', page_id: 2 },
{ slug: 'all-stale', chunk_index: 1, chunk_text: 'b', chunk_source: 'compiled_truth' as const, model: null, token_count: 1, source_id: 'default', page_id: 2 },
];
const engine = mockEngine({
countStaleChunks: async () => 3,
listStaleChunks: async () => stale,
upsertChunks: async () => {},
});
const result = await runEmbedCore(engine, { stale: true, dryRun: true });
expect(totalEmbedCalls).toBe(0);
expect(result.dryRun).toBe(true);
expect(result.would_embed).toBe(3); // 1 from 'partial' + 2 from 'all-stale'
// SQL-side path does not see non-stale chunks, so skipped=0 and total_chunks=stale-count.
// Callers wanting full coverage should call engine.getStats()/getHealth() afterward.
expect(result.skipped).toBe(0);
expect(result.total_chunks).toBe(3);
// v0.33.3 cherry-pick: pages_processed=0 in dry-run because we skip
// the cursor walk (countStaleChunks-only pre-flight).
expect(result.pages_processed).toBe(0);
});
test('dry-run --slugs on a single page counts stale chunks, no API calls', async () => {
const { runEmbedCore } = await import('../src/commands/embed.ts');
const chunks = [
{ chunk_index: 0, chunk_text: 'a', chunk_source: 'compiled_truth', embedded_at: null, token_count: 1 },
{ chunk_index: 1, chunk_text: 'b', chunk_source: 'compiled_truth', embedded_at: null, token_count: 1 },
{ chunk_index: 2, chunk_text: 'c', chunk_source: 'compiled_truth', embedded_at: '2026-01-01', token_count: 1 },
];
const engine = mockEngine({
getPage: async () => ({ slug: 'my-page', compiled_truth: 'text', timeline: '' }),
getChunks: async () => chunks,
upsertChunks: async () => {},
});
const result = await runEmbedCore(engine, { slugs: ['my-page'], dryRun: true });
expect(totalEmbedCalls).toBe(0);
expect(result.dryRun).toBe(true);
expect(result.would_embed).toBe(2);
expect(result.skipped).toBe(1);
expect(result.total_chunks).toBe(3);
expect(result.pages_processed).toBe(1);
});
test('non-dry-run path reports accurate embedded count (regression guard)', async () => {
const { runEmbedCore } = await import('../src/commands/embed.ts');
const chunksBySlug = new Map<string, any[]>([
['a', [{ chunk_index: 0, chunk_text: 'a', chunk_source: 'compiled_truth', embedded_at: null, token_count: 1 }]],
['b', [
{ chunk_index: 0, chunk_text: 'x', chunk_source: 'compiled_truth', embedded_at: null, token_count: 1 },
{ chunk_index: 1, chunk_text: 'y', chunk_source: 'compiled_truth', embedded_at: null, token_count: 1 },
]],
]);
// D5a: source_id + page_id required on StaleChunkRow.
const stale = [
{ slug: 'a', chunk_index: 0, chunk_text: 'a', chunk_source: 'compiled_truth' as const, model: null, token_count: 1, source_id: 'default', page_id: 1 },
{ slug: 'b', chunk_index: 0, chunk_text: 'x', chunk_source: 'compiled_truth' as const, model: null, token_count: 1, source_id: 'default', page_id: 2 },
{ slug: 'b', chunk_index: 1, chunk_text: 'y', chunk_source: 'compiled_truth' as const, model: null, token_count: 1, source_id: 'default', page_id: 2 },
];
const engine = mockEngine({
countStaleChunks: async () => 3,
listStaleChunks: async () => stale,
getChunks: async (slug: string) => chunksBySlug.get(slug) || [],
upsertChunks: async () => {},
});
process.env.GBRAIN_EMBED_CONCURRENCY = '2';
const result = await runEmbedCore(engine, { stale: true });
expect(result.dryRun).toBe(false);
expect(result.embedded).toBe(3); // 1 from a + 2 from b
expect(result.would_embed).toBe(0);
expect(result.pages_processed).toBe(2);
});
});
// ────────────────────────────────────────────────────────────────
// runEmbedCore --stale egress fix: SQL-side staleness filter
// Replaces the listPages + per-page getChunks bomb with a count +
// slug-grouped SELECT. On a 100%-embedded brain, 0 listPages calls.
// ────────────────────────────────────────────────────────────────
describe('runEmbedCore --stale egress fix (SQL-side filter)', () => {
test('zero stale chunks: countStaleChunks short-circuits, listPages never called', async () => {
const { runEmbedCore } = await import('../src/commands/embed.ts');
let listPagesCalled = false;
let getChunksCalled = false;
let listStaleCalled = false;
const engine = mockEngine({
countStaleChunks: async () => 0,
listPages: async () => { listPagesCalled = true; return []; },
getChunks: async () => { getChunksCalled = true; return []; },
listStaleChunks: async () => { listStaleCalled = true; return []; },
upsertChunks: async () => {},
});
const result = await runEmbedCore(engine, { stale: true });
expect(result.embedded).toBe(0);
expect(result.pages_processed).toBe(0);
// The egress fix: NONE of these should have been called when count=0.
expect(listPagesCalled).toBe(false);
expect(getChunksCalled).toBe(false);
expect(listStaleCalled).toBe(false);
expect(totalEmbedCalls).toBe(0);
});
test('N stale chunks across M pages: only stale slugs re-fetched, exact stale set embedded, non-stale chunks preserved', async () => {
const { runEmbedCore } = await import('../src/commands/embed.ts');
let listPagesCalled = false;
// D5a: source_id + page_id required on StaleChunkRow.
const stale = [
{ slug: 'page-a', chunk_index: 0, chunk_text: 'x', chunk_source: 'compiled_truth' as const, model: null, token_count: null, source_id: 'default', page_id: 1 },
{ slug: 'page-b', chunk_index: 1, chunk_text: 'y', chunk_source: 'compiled_truth' as const, model: null, token_count: null, source_id: 'default', page_id: 2 },
{ slug: 'page-b', chunk_index: 2, chunk_text: 'z', chunk_source: 'compiled_truth' as const, model: null, token_count: null, source_id: 'default', page_id: 2 },
];
// page-b has a FRESH chunk at index 0 that must be preserved through the upsert.
const fullChunks: Record<string, any[]> = {
'page-a': [
{ chunk_index: 0, chunk_text: 'x', chunk_source: 'compiled_truth', embedded_at: null, token_count: 1 },
],
'page-b': [
{ chunk_index: 0, chunk_text: 'fresh', chunk_source: 'compiled_truth', embedded_at: '2026-01-01', token_count: 5 },
{ chunk_index: 1, chunk_text: 'y', chunk_source: 'compiled_truth', embedded_at: null, token_count: 1 },
{ chunk_index: 2, chunk_text: 'z', chunk_source: 'compiled_truth', embedded_at: null, token_count: 1 },
],
};
const upsertCalls: Array<{ slug: string; chunks: any[] }> = [];
const engine = mockEngine({
countStaleChunks: async () => 3,
listStaleChunks: async () => stale,
listPages: async () => { listPagesCalled = true; return []; },
getChunks: async (slug: string) => fullChunks[slug] || [],
upsertChunks: async (slug: string, chunks: any[]) => { upsertCalls.push({ slug, chunks }); },
});
const result = await runEmbedCore(engine, { stale: true });
// listPages must NOT be called in the SQL-side path.
expect(listPagesCalled).toBe(false);
// One embedBatch call per stale slug (a, b).
expect(totalEmbedCalls).toBe(2);
expect(result.embedded).toBe(3);
expect(result.pages_processed).toBe(2);
// page-b's upsert MUST include the fresh chunk (chunk_index=0) — otherwise
// it would be deleted by the upsertChunks != ALL filter. Critical regression check.
const pageBUpsert = upsertCalls.find(u => u.slug === 'page-b');
expect(pageBUpsert).toBeDefined();
const freshChunkInUpsert = pageBUpsert!.chunks.find((c: any) => c.chunk_index === 0);
expect(freshChunkInUpsert).toBeDefined();
// Fresh chunk has no `embedding` field (preserved via COALESCE in upsertChunks SQL).
expect(freshChunkInUpsert.embedding).toBeUndefined();
// Previously-stale chunks come through WITH a new embedding.
const staleChunkInUpsert = pageBUpsert!.chunks.find((c: any) => c.chunk_index === 1);
expect(staleChunkInUpsert.embedding).toBeDefined();
expect(staleChunkInUpsert.embedding).toBeInstanceOf(Float32Array);
});
test('--stale dry-run: counts stale via countStaleChunks (no listStaleChunks call), no embedBatch or upsertChunks', async () => {
const { runEmbedCore } = await import('../src/commands/embed.ts');
// v0.33.3 cherry-pick contract: dry-run path uses countStaleChunks
// ONLY — it does not call listStaleChunks. The pre-flight count is
// what gets reported; pages_processed stays at 0 because we
// intentionally skip the cursor walk in dry-run.
let listStaleCalled = false;
const upserts: string[] = [];
const engine = mockEngine({
countStaleChunks: async () => 2,
listStaleChunks: async () => { listStaleCalled = true; return []; },
upsertChunks: async (slug: string) => { upserts.push(slug); },
});
const result = await runEmbedCore(engine, { stale: true, dryRun: true });
expect(totalEmbedCalls).toBe(0);
expect(upserts).toEqual([]);
expect(result.would_embed).toBe(2);
// Cheaper dry-run: skips the cursor walk entirely.
expect(listStaleCalled).toBe(false);
expect(result.pages_processed).toBe(0);
expect(result.dryRun).toBe(true);
});
test('--all (non-stale) path is byte-identical: walks listPages and embeds every chunk', async () => {
// Regression guard for the legacy --all path. Behavior must be byte-identical
// to pre-fix: listPages + per-page getChunks + embed every chunk.
const { runEmbedCore } = await import('../src/commands/embed.ts');
let countStaleCalled = false;
let listStaleCalled = false;
const pages = [{ slug: 'a' }, { slug: 'b' }];
const chunksBySlug = new Map<string, any[]>([
['a', [{ chunk_index: 0, chunk_text: 'a', chunk_source: 'compiled_truth', embedded_at: '2026-01-01', token_count: 1 }]],
['b', [{ chunk_index: 0, chunk_text: 'b', chunk_source: 'compiled_truth', embedded_at: null, token_count: 1 }]],
]);
const engine = mockEngine({
countStaleChunks: async () => { countStaleCalled = true; return 1; },
listStaleChunks: async () => { listStaleCalled = true; return []; },
listPages: async () => pages,
getChunks: async (slug: string) => chunksBySlug.get(slug) || [],
upsertChunks: async () => {},
});
const result = await runEmbedCore(engine, { all: true });
// --all path must NOT take the new short-circuit.
expect(countStaleCalled).toBe(false);
expect(listStaleCalled).toBe(false);
// Both pages get embedded, regardless of embedded_at — that's the --all contract.
expect(totalEmbedCalls).toBe(2);
expect(result.embedded).toBe(2);
});
});
// ────────────────────────────────────────────────────────────────
// D5: embedBatchWithBackoff retry wrapper — 8 cases per plan
// (D2 jitter, D4 cause-unwrap, D4a maxRetries:0 passthrough,
// D8 abortSignal threading, plus the pure helpers).
// ────────────────────────────────────────────────────────────────
describe('embedBatchWithBackoff (D2/D4/D4a/D8)', () => {
test('case 1: parses "try again in 248ms" form and retries', async () => {
const { embedBatchWithBackoff } = await import('../src/commands/embed.ts');
let calls = 0;
embedBatchBehavior = async () => {
calls++;
if (calls === 1) {
const err = new Error('Rate limit reached. Please try again in 50ms.');
(err as any).cause = { status: 429 };
throw err;
}
return [new Float32Array(1536)];
};
const result = await embedBatchWithBackoff(['x']);
expect(calls).toBe(2);
expect(result).toHaveLength(1);
});
test('case 2: parses "try again in 1.5s" form and retries', async () => {
const { embedBatchWithBackoff, parseRetryDelayMs, RATE_LIMIT_JITTER, RATE_LIMIT_PAD_MS } = await import('../src/commands/embed.ts');
let calls = 0;
embedBatchBehavior = async () => {
calls++;
if (calls === 1) {
const err = new Error('429 — please try again in 0.05s');
(err as any).cause = { status: 429 };
throw err;
}
return [new Float32Array(1536)];
};
const result = await embedBatchWithBackoff(['x']);
expect(calls).toBe(2);
expect(result).toHaveLength(1);
// Pure-helper sanity check on the "s" form path while we're here.
const delay = parseRetryDelayMs('try again in 1.5s', () => 0.5);
// 1.5s = 1500ms + 500ms pad = 2000ms; jitter at rng=0.5 → 1.0 multiplier.
const expected = (1500 + RATE_LIMIT_PAD_MS) * (1 + (0.5 * 2 - 1) * RATE_LIMIT_JITTER);
expect(delay).toBe(Math.floor(expected));
});
test('case 3: unparseable rate-limit message uses RATE_LIMIT_FALLBACK_MS', async () => {
const { parseRetryDelayMs, RATE_LIMIT_FALLBACK_MS, RATE_LIMIT_JITTER } = await import('../src/commands/embed.ts');
// Min delay = fallback × (1 - jitter); max = fallback × (1 + jitter).
const minExpected = Math.floor(RATE_LIMIT_FALLBACK_MS * (1 - RATE_LIMIT_JITTER));
const maxExpected = Math.floor(RATE_LIMIT_FALLBACK_MS * (1 + RATE_LIMIT_JITTER));
for (let i = 0; i < 20; i++) {
const d = parseRetryDelayMs('429 too many requests');
expect(d).toBeGreaterThanOrEqual(minExpected);
expect(d).toBeLessThanOrEqual(maxExpected);
}
});
test('case 4: non-rate-limit error rethrows immediately without retry', async () => {
const { embedBatchWithBackoff } = await import('../src/commands/embed.ts');
let calls = 0;
embedBatchBehavior = async () => {
calls++;
throw new Error('500 internal server error');
};
await expect(embedBatchWithBackoff(['x'])).rejects.toThrow('500 internal server error');
// Single attempt — no retries on non-429.
expect(calls).toBe(1);
});
test('case 5: jitter range — same parsed delay produces non-identical sleeps across runs', async () => {
const { parseRetryDelayMs } = await import('../src/commands/embed.ts');
const samples = new Set<number>();
for (let i = 0; i < 50; i++) {
samples.add(parseRetryDelayMs('try again in 100ms'));
}
// 50 random samples with ±30% jitter should yield many distinct values.
expect(samples.size).toBeGreaterThan(5);
});
test('case 6: wall-clock budget mid-batch wakes the retry sleep and cancels mid-fetch', async () => {
const { embedBatchWithBackoff } = await import('../src/commands/embed.ts');
const controller = new AbortController();
let calls = 0;
embedBatchBehavior = async (_texts, opts) => {
calls++;
// The wrapper MUST pass the abortSignal into the gateway opts.
expect((opts as { abortSignal?: AbortSignal } | undefined)?.abortSignal).toBe(controller.signal);
if (calls === 1) {
const err = new Error('Rate limit reached. Please try again in 5000ms.');
(err as any).cause = { status: 429 };
throw err;
}
return [new Float32Array(1536)];
};
// Fire the budget abort during the retry sleep — abortableSleep should
// wake up early instead of waiting the full 5000ms.
setTimeout(() => controller.abort(), 50);
const t0 = Date.now();
await expect(embedBatchWithBackoff(['x'], { abortSignal: controller.signal })).rejects.toThrow();
const elapsed = Date.now() - t0;
// Should exit within ~200ms, not the 5000ms+ the retry-after would suggest.
expect(elapsed).toBeLessThan(500);
});
test('case 7: AITransientError-shaped wrap with 429 cause triggers retry; 500 cause does not', async () => {
const { embedBatchWithBackoff, detect429FromCause } = await import('../src/commands/embed.ts');
// Pure helper checks first.
expect(detect429FromCause({ cause: { status: 429 } })).toBe(true);
expect(detect429FromCause({ cause: { statusCode: 429 } })).toBe(true);
expect(detect429FromCause({ cause: { status: 500 } })).toBe(false);
expect(detect429FromCause({ status: 500 })).toBe(false);
expect(detect429FromCause(undefined)).toBe(false);
expect(detect429FromCause(null)).toBe(false);
// Deep wrap (defensive — current normalizeAIError wraps once).
expect(detect429FromCause({ cause: { cause: { status: 429 } } })).toBe(true);
// End-to-end: 429 wrapped as AITransientError-like shape → retry.
// Use a small retry-after in the wrapper message so the parsed delay
// is fast (keeps the test under the 5s timeout). The fallback delay
// of 60s would otherwise dominate.
let calls = 0;
embedBatchBehavior = async () => {
calls++;
if (calls === 1) {
// Simulate normalizeAIError wrap: message has a parseable retry-after,
// status only on cause (the structural detection path under test).
const wrapper = new Error('try again in 10ms');
(wrapper as any).cause = { status: 429 };
throw wrapper;
}
return [new Float32Array(1536)];
};
const result = await embedBatchWithBackoff(['x']);
expect(calls).toBe(2);
expect(result).toHaveLength(1);
// 500 wrapped → no retry, rethrow immediately.
embedBatchBehavior = async () => {
const wrapper = new Error('AI transient error');
(wrapper as any).cause = { status: 500 };
throw wrapper;
};
await expect(embedBatchWithBackoff(['x'])).rejects.toThrow('AI transient error');
});
test('case 8: wrapper passes maxRetries:0 through to embedBatch (no SDK retry stack)', async () => {
const { embedBatchWithBackoff } = await import('../src/commands/embed.ts');
embedBatchBehavior = async () => [new Float32Array(1536)];
await embedBatchWithBackoff(['x']);
expect(lastEmbedBatchOpts).toBeDefined();
expect((lastEmbedBatchOpts as { maxRetries?: number }).maxRetries).toBe(0);
});
});
// ────────────────────────────────────────────────────────────────
// D5/D7: embedAllStale sourceId threading — invariant tests
// ────────────────────────────────────────────────────────────────
// ────────────────────────────────────────────────────────────────
// Gap scan: CLI flag wiring + end-to-end budget firing (beyond plan)
// ────────────────────────────────────────────────────────────────
describe('runEmbed CLI flag wiring (--stale --source)', () => {
test('--source <id> on CLI threads sourceId into countStaleChunks', async () => {
let receivedOpts: unknown;
const engine = mockEngine({
countStaleChunks: async (opts: unknown) => {
receivedOpts = opts;
return 0; // short-circuit so we don't hit listStaleChunks
},
});
await runEmbed(engine, ['--stale', '--source', 'media-corpus']);
expect(receivedOpts).toEqual({ sourceId: 'media-corpus' });
});
test('--stale without --source passes undefined opts (back-compat fast path)', async () => {
let receivedOpts: unknown;
const engine = mockEngine({
countStaleChunks: async (opts: unknown) => {
receivedOpts = opts;
return 0;
},
});
await runEmbed(engine, ['--stale']);
expect(receivedOpts).toBeUndefined();
});
});
describe('embedAllStale wall-clock budget end-to-end (D3 + D3a)', () => {
test('GBRAIN_EMBED_TIME_BUDGET_MS=N cuts the outer loop short on stuck workers', async () => {
const { runEmbedCore } = await import('../src/commands/embed.ts');
// Tiny budget: 100ms. Each embed call sleeps 50ms; with budget + multiple
// small batches, the second listStaleChunks call should see the abort
// signal AND the worker loop should not claim further keys.
process.env.GBRAIN_EMBED_TIME_BUDGET_MS = '100';
process.env.GBRAIN_EMBED_CONCURRENCY = '1';
let listCallCount = 0;
let totalRowsReturned = 0;
// Return rows in chunks of 1 so the outer while-loop ticks frequently.
// 10 rows total across 10 "batches"; the budget should kill the loop
// partway through.
const allRows = Array.from({ length: 10 }, (_, i) => ({
slug: `b-${i}`,
chunk_index: 0,
chunk_text: `t${i}`,
chunk_source: 'compiled_truth' as const,
model: null,
token_count: 1,
source_id: 'default',
page_id: i + 1,
}));
const engine = mockEngine({
countStaleChunks: async () => allRows.length,
listStaleChunks: async (opts: { afterPageId?: number } = {}) => {
listCallCount++;
const startIdx = (opts.afterPageId ?? 0); // 0 means start
const idx = allRows.findIndex(r => r.page_id > startIdx);
if (idx === -1) return [];
const row = allRows[idx];
totalRowsReturned++;
return [row];
},
getChunks: async () => [],
upsertChunks: async () => {},
});
// embedBatch takes 80ms per call — budget exhausts after ~1 page.
embedBatchBehavior = async (texts) => {
await new Promise(r => setTimeout(r, 80));
return texts.map(() => new Float32Array(1536));
};
const t0 = Date.now();
const result = await runEmbedCore(engine, { stale: true });
const elapsed = Date.now() - t0;
// Should not have visited all 10 pages.
expect(result.pages_processed).toBeLessThan(10);
// Total wall-clock should be roughly the budget + the time for in-flight
// workers to drain (1 worker × 80ms latency). Generous upper bound: 1500ms.
expect(elapsed).toBeLessThan(1500);
});
});
describe('embedAllStale --source threading (D7)', () => {
test('countStaleChunks receives the sourceId opt', async () => {
const { runEmbedCore } = await import('../src/commands/embed.ts');
let receivedOpts: unknown;
const engine = mockEngine({
countStaleChunks: async (opts: unknown) => {
receivedOpts = opts;
return 0; // short-circuit
},
});
await runEmbedCore(engine, { stale: true, sourceId: 'media-corpus' });
expect(receivedOpts).toEqual({ sourceId: 'media-corpus' });
});
test('countStaleChunks receives undefined opts when --source omitted (back-compat)', async () => {
const { runEmbedCore } = await import('../src/commands/embed.ts');
let receivedOpts: unknown;
const engine = mockEngine({
countStaleChunks: async (opts: unknown) => {
receivedOpts = opts;
return 0;
},
});
await runEmbedCore(engine, { stale: true });
expect(receivedOpts).toBeUndefined();
});
test('listStaleChunks receives the sourceId in opts when running source-scoped', async () => {
const { runEmbedCore } = await import('../src/commands/embed.ts');
let firstCallOpts: unknown;
const stale = [
{ slug: 'p', chunk_index: 0, chunk_text: 'x', chunk_source: 'compiled_truth' as const, model: null, token_count: 1, source_id: 'media-corpus', page_id: 1 },
];
const engine = mockEngine({
countStaleChunks: async () => 1,
listStaleChunks: async (opts: unknown) => {
if (firstCallOpts === undefined) firstCallOpts = opts;
return stale;
},
getChunks: async () => stale.map(s => ({ chunk_index: s.chunk_index, chunk_text: s.chunk_text, chunk_source: s.chunk_source, embedded_at: null, token_count: 1 })),
upsertChunks: async () => {},
});
await runEmbedCore(engine, { stale: true, sourceId: 'media-corpus' });
expect((firstCallOpts as { sourceId?: string }).sourceId).toBe('media-corpus');
});
});