mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* 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>
291 lines
12 KiB
TypeScript
291 lines
12 KiB
TypeScript
/**
|
||
* E2E regression: cursor-paginated `embed --stale` (D7 + IRON RULE from the
|
||
* v0.33.4 plan-eng-review). Seeds >PAGE_SIZE chunks with `embedding IS NULL`
|
||
* and walks the production cursor to verify:
|
||
*
|
||
* - Static case: every chunk visited exactly once across multiple batches.
|
||
* - Cursor monotonically advances on `(page_id, chunk_index)`.
|
||
* - Migration v66's partial index `idx_chunks_embedding_null` exists.
|
||
* - D7: source-scoped scan returns ONLY that source's NULLs even when
|
||
* same-slug pages exist across sources.
|
||
* - Failed-page semantics: a failed upsert keeps `embedding IS NULL` and
|
||
* the next run picks it up (covers the "cursor advances past failures
|
||
* in the same run" intended behavior).
|
||
*
|
||
* Requires DATABASE_URL. Skips gracefully otherwise.
|
||
*
|
||
* Run: DATABASE_URL=... bun test test/e2e/embed-stale-pagination.test.ts
|
||
*/
|
||
|
||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||
import { hasDatabase, setupDB, teardownDB, getEngine } from './helpers.ts';
|
||
import { getConnection } from '../../src/core/db.ts';
|
||
|
||
const getConn = getConnection;
|
||
|
||
const skip = !hasDatabase();
|
||
const describeE2E = skip ? describe.skip : describe;
|
||
|
||
if (skip) {
|
||
console.log('Skipping E2E embed-stale-pagination tests (DATABASE_URL not set)');
|
||
}
|
||
|
||
const PAGE_SIZE = 2000; // matches embed.ts production constant
|
||
|
||
/**
|
||
* Seed N chunks across M pages, all with `embedding IS NULL`.
|
||
* Uses raw SQL because the public engine API doesn't expose page+chunk
|
||
* insertion at this granularity without going through importFromContent
|
||
* (which would compute embeddings if a model is configured).
|
||
*/
|
||
async function seedNullChunks(opts: {
|
||
sourceId: string;
|
||
pageCount: number;
|
||
chunksPerPage: number;
|
||
slugPrefix: string;
|
||
}) {
|
||
const sql = getConn();
|
||
// Bulk-insert all pages in one round-trip via unnest, then bulk-insert
|
||
// all chunks. Avoids 3000+ sequential round-trips on the "static case"
|
||
// seed which would otherwise blow the test's 5s default timeout.
|
||
const slugs = Array.from({ length: opts.pageCount }, (_, p) =>
|
||
`${opts.slugPrefix}-${p.toString().padStart(4, '0')}`,
|
||
);
|
||
const titles = slugs;
|
||
const sourceIds = slugs.map(() => opts.sourceId);
|
||
const pageRows = await sql`
|
||
INSERT INTO pages (slug, title, type, compiled_truth, timeline, frontmatter, source_id)
|
||
SELECT s, t, 'page', '', '', '{}'::jsonb, src
|
||
FROM unnest(${sql.array(slugs)}::text[], ${sql.array(titles)}::text[], ${sql.array(sourceIds)}::text[]) AS u(s, t, src)
|
||
RETURNING id, slug
|
||
`;
|
||
const slugToId = new Map<string, number>();
|
||
for (const r of pageRows as unknown as Array<{ id: number; slug: string }>) {
|
||
slugToId.set(r.slug, r.id);
|
||
}
|
||
// Build the flat (page_id, chunk_index, chunk_text) arrays.
|
||
const pageIds: number[] = [];
|
||
const indices: number[] = [];
|
||
const texts: string[] = [];
|
||
for (let p = 0; p < opts.pageCount; p++) {
|
||
const slug = slugs[p];
|
||
const pid = slugToId.get(slug);
|
||
if (pid === undefined) throw new Error(`seed: missing pageId for ${slug}`);
|
||
for (let c = 0; c < opts.chunksPerPage; c++) {
|
||
pageIds.push(pid);
|
||
indices.push(c);
|
||
texts.push(`chunk-${p}-${c}`);
|
||
}
|
||
}
|
||
// `model` column is NOT NULL with a default ('text-embedding-3-large')
|
||
// — omit it from the INSERT so the default applies. `embedding` is
|
||
// nullable; we intentionally leave it NULL so the chunk is "stale".
|
||
await sql`
|
||
INSERT INTO content_chunks (page_id, chunk_index, chunk_text, chunk_source, token_count, embedding, embedded_at)
|
||
SELECT pid, idx, txt, 'compiled_truth', 5, NULL, NULL
|
||
FROM unnest(${sql.array(pageIds)}::int[], ${sql.array(indices)}::int[], ${sql.array(texts)}::text[]) AS u(pid, idx, txt)
|
||
`;
|
||
}
|
||
|
||
/** Ensure the migration v66 partial index exists in the seeded DB. */
|
||
async function indexExists(name: string): Promise<boolean> {
|
||
const sql = getConn();
|
||
const rows = await sql`
|
||
SELECT 1 FROM pg_indexes WHERE indexname = ${name}
|
||
`;
|
||
return rows.length > 0;
|
||
}
|
||
|
||
describeE2E('embed --stale cursor pagination (D7 + REGRESSION)', () => {
|
||
beforeAll(async () => {
|
||
await setupDB();
|
||
// setupDB() seeds one default source; D7 cases add a second source
|
||
// via raw SQL on demand.
|
||
});
|
||
|
||
afterAll(async () => {
|
||
// setupDB's ALL_TABLES truncate list does NOT include `sources`
|
||
// (the `default` row is treated as fixed). This test adds an
|
||
// `other-source` row for the D7 cases; clean it up so later tests
|
||
// running on the same DB don't see a never-synced source (which
|
||
// mechanical.test.ts:`gbrain doctor` would correctly fail on).
|
||
try {
|
||
await getConn()`DELETE FROM sources WHERE id <> 'default'`;
|
||
} catch {
|
||
// If the connection is already torn down, that's fine.
|
||
}
|
||
await teardownDB();
|
||
});
|
||
|
||
test('migration v66 created partial index idx_chunks_embedding_null', async () => {
|
||
const exists = await indexExists('idx_chunks_embedding_null');
|
||
expect(exists).toBe(true);
|
||
});
|
||
|
||
test('static case: every chunk visited exactly once across multiple batches', async () => {
|
||
const engine = getEngine();
|
||
// Truncate so other tests don't bleed in.
|
||
await getConn()`TRUNCATE content_chunks, pages CASCADE`;
|
||
// Seed PAGE_SIZE + 500 chunks to force at least 2 cursor pages.
|
||
const TOTAL_PAGES = Math.ceil((PAGE_SIZE + 500) / 5); // 5 chunks/page
|
||
const CHUNKS_PER_PAGE = 5;
|
||
await seedNullChunks({
|
||
sourceId: 'default',
|
||
pageCount: TOTAL_PAGES,
|
||
chunksPerPage: CHUNKS_PER_PAGE,
|
||
slugPrefix: 'static-case',
|
||
});
|
||
const expectedTotal = TOTAL_PAGES * CHUNKS_PER_PAGE;
|
||
|
||
const count = await engine.countStaleChunks();
|
||
expect(count).toBe(expectedTotal);
|
||
|
||
// Walk the cursor manually (production code in embedAllStale does
|
||
// the same; we re-implement the loop here so the test asserts on
|
||
// the engine contract, not the caller's wrapper).
|
||
const visited = new Set<string>();
|
||
let lastPageId = -1;
|
||
let lastChunkIndex = -1;
|
||
let afterPageId = 0;
|
||
let afterChunkIndex = -1;
|
||
let cursorMonotonic = true;
|
||
let batchCount = 0;
|
||
|
||
// eslint-disable-next-line no-constant-condition
|
||
while (true) {
|
||
const batch = await engine.listStaleChunks({
|
||
batchSize: PAGE_SIZE,
|
||
afterPageId,
|
||
afterChunkIndex,
|
||
});
|
||
if (batch.length === 0) break;
|
||
batchCount++;
|
||
for (const row of batch) {
|
||
const key = `${row.page_id}::${row.chunk_index}`;
|
||
// No duplicate visits.
|
||
expect(visited.has(key)).toBe(false);
|
||
visited.add(key);
|
||
// Monotonic advance on (page_id, chunk_index).
|
||
const adv = row.page_id > lastPageId
|
||
|| (row.page_id === lastPageId && row.chunk_index > lastChunkIndex);
|
||
if (!adv) cursorMonotonic = false;
|
||
lastPageId = row.page_id;
|
||
lastChunkIndex = row.chunk_index;
|
||
}
|
||
const tail = batch[batch.length - 1];
|
||
afterPageId = tail.page_id;
|
||
afterChunkIndex = tail.chunk_index;
|
||
if (batch.length < PAGE_SIZE) break;
|
||
}
|
||
|
||
expect(visited.size).toBe(expectedTotal);
|
||
expect(cursorMonotonic).toBe(true);
|
||
expect(batchCount).toBeGreaterThanOrEqual(2); // forced split
|
||
});
|
||
|
||
test('source-scoped scan only returns the target source (D7)', async () => {
|
||
const engine = getEngine();
|
||
await getConn()`TRUNCATE content_chunks, pages CASCADE`;
|
||
// Insert the second source row (default already exists from setupDB).
|
||
await getConn()`
|
||
INSERT INTO sources (id, name, local_path)
|
||
VALUES ('other-source', 'other-source', '/tmp/other')
|
||
ON CONFLICT (id) DO NOTHING
|
||
`;
|
||
// Same slug-prefix in both sources to verify the filter is true source
|
||
// separation, not slug separation.
|
||
await seedNullChunks({ sourceId: 'default', pageCount: 3, chunksPerPage: 2, slugPrefix: 'shared' });
|
||
await seedNullChunks({ sourceId: 'other-source', pageCount: 5, chunksPerPage: 2, slugPrefix: 'shared' });
|
||
|
||
// Global count: 8 pages × 2 chunks = 16.
|
||
expect(await engine.countStaleChunks()).toBe(16);
|
||
// Default-only: 3 × 2 = 6.
|
||
expect(await engine.countStaleChunks({ sourceId: 'default' })).toBe(6);
|
||
// Other-only: 5 × 2 = 10.
|
||
expect(await engine.countStaleChunks({ sourceId: 'other-source' })).toBe(10);
|
||
|
||
// listStaleChunks should return ONLY the requested source.
|
||
const defaultRows = await engine.listStaleChunks({ sourceId: 'default', batchSize: 100 });
|
||
expect(defaultRows).toHaveLength(6);
|
||
for (const row of defaultRows) expect(row.source_id).toBe('default');
|
||
|
||
const otherRows = await engine.listStaleChunks({ sourceId: 'other-source', batchSize: 100 });
|
||
expect(otherRows).toHaveLength(10);
|
||
for (const row of otherRows) expect(row.source_id).toBe('other-source');
|
||
});
|
||
|
||
test('duplicate slug across sources: cursor on (page_id, chunk_index) keeps them separate', async () => {
|
||
const engine = getEngine();
|
||
await getConn()`TRUNCATE content_chunks, pages CASCADE`;
|
||
await getConn()`
|
||
INSERT INTO sources (id, name, local_path)
|
||
VALUES ('other-source', 'other-source', '/tmp/other')
|
||
ON CONFLICT (id) DO NOTHING
|
||
`;
|
||
// Same slug, different sources.
|
||
await seedNullChunks({ sourceId: 'default', pageCount: 1, chunksPerPage: 3, slugPrefix: 'collide' });
|
||
await seedNullChunks({ sourceId: 'other-source', pageCount: 1, chunksPerPage: 3, slugPrefix: 'collide' });
|
||
|
||
const allRows = await engine.listStaleChunks({ batchSize: 100 });
|
||
expect(allRows).toHaveLength(6);
|
||
const seen = new Set<string>();
|
||
for (const row of allRows) {
|
||
// Each (source_id, slug, chunk_index) is unique.
|
||
const key = `${row.source_id}::${row.slug}::${row.chunk_index}`;
|
||
expect(seen.has(key)).toBe(false);
|
||
seen.add(key);
|
||
}
|
||
// Two distinct source_ids.
|
||
const sourceIds = new Set(allRows.map(r => r.source_id));
|
||
expect(sourceIds.size).toBe(2);
|
||
});
|
||
|
||
test('failed embedding stays NULL; next run picks it up', async () => {
|
||
const engine = getEngine();
|
||
await getConn()`TRUNCATE content_chunks, pages CASCADE`;
|
||
await seedNullChunks({ sourceId: 'default', pageCount: 1, chunksPerPage: 3, slugPrefix: 'fail-case' });
|
||
|
||
// First walk — simulate that we read all 3 chunks but did not upsert
|
||
// anything (i.e. the embedding step "failed" for every page). Cursor
|
||
// advance is observed; the chunks should still be NULL.
|
||
const firstRun = await engine.listStaleChunks({ batchSize: 100 });
|
||
expect(firstRun).toHaveLength(3);
|
||
|
||
// Verify the NULL rows are still there.
|
||
expect(await engine.countStaleChunks()).toBe(3);
|
||
|
||
// Second walk picks them up because the partial index still finds them.
|
||
const secondRun = await engine.listStaleChunks({ batchSize: 100 });
|
||
expect(secondRun).toHaveLength(3);
|
||
// Same (page_id, chunk_index) set.
|
||
const firstKeys = new Set(firstRun.map(r => `${r.page_id}::${r.chunk_index}`));
|
||
const secondKeys = new Set(secondRun.map(r => `${r.page_id}::${r.chunk_index}`));
|
||
expect(firstKeys).toEqual(secondKeys);
|
||
});
|
||
|
||
test('page split across batches: a multi-chunk page can land in two cursor pages', async () => {
|
||
const engine = getEngine();
|
||
await getConn()`TRUNCATE content_chunks, pages CASCADE`;
|
||
// One page with 5 chunks; use batchSize=2 to force splits.
|
||
await seedNullChunks({ sourceId: 'default', pageCount: 1, chunksPerPage: 5, slugPrefix: 'split' });
|
||
const visited: number[] = [];
|
||
let after_pid = 0;
|
||
let after_idx = -1;
|
||
// eslint-disable-next-line no-constant-condition
|
||
while (true) {
|
||
const batch = await engine.listStaleChunks({
|
||
batchSize: 2,
|
||
afterPageId: after_pid,
|
||
afterChunkIndex: after_idx,
|
||
});
|
||
if (batch.length === 0) break;
|
||
for (const r of batch) visited.push(r.chunk_index);
|
||
const tail = batch[batch.length - 1];
|
||
after_pid = tail.page_id;
|
||
after_idx = tail.chunk_index;
|
||
if (batch.length < 2) break;
|
||
}
|
||
expect(visited).toEqual([0, 1, 2, 3, 4]);
|
||
});
|
||
});
|