Files
gbrain/test/e2e/sync-status-pglite.test.ts
T
677142a680 v0.40.6.0 feat(sync): parallel sync --all + per-source lock invariant + sources status dashboard (productionized from PR #1314) (#1324)
* v0.40.4.0 feat(sync): parallel sync --all + per-source lock invariant + sources status dashboard (productionized from PR #1314)

Lands the community-authored PR #1314 with the structural fixes Codex's
outside-voice review caught: the original PR's lock-id change only fired
inside the --all parallel path, which would have introduced a worse race
than the global-lock contention it fixed (sync --all on per-source lock
racing against sync --source foo on the still-global lock). The landed
version makes the per-source lock the invariant for every source-scoped
sync, paired with withRefreshingLock for sources that exceed 30 minutes.

What's new
- gbrain sync --all parallel fan-out via continuous worker pool (D2);
  --parallel N flag, default min(sourceCount, --workers, 4); per-source
  [<source-id>] line prefix via AsyncLocalStorage (D6 + D12 + D13);
  stable --json envelope {schema_version:1, ...} on stdout with banners
  on stderr (D4 + D14); --skip-failed/--retry-failed reject under
  --parallel > 1 (D15 — sync-failures.jsonl is brain-global today;
  source-scoping filed as v0.40.4 TODO).
- gbrain sources status [--json] read-only dashboard (D3 — sibling to
  sources list/add/remove/archive, not a sync flag, so reads + writes
  don't share a verb). Counts pages + chunks + embedding coverage per
  source. Active embedding column resolved via the registry (D16) so
  Voyage / multimodal brains see the right column. Archived sources
  excluded by caller filter.
- Connection-budget stderr warning when parallel × workers × 2 > 16 with
  the formula in the message text (D1 + D10 — Codex P0 #3: each per-file
  worker opens its own PostgresEngine with poolSize=2, so the
  multiplication factor is 2, not 1).

The load-bearing structural fix
- performSync defaults to per-source lock id (gbrain-sync:<sourceId>)
  whenever opts.sourceId is set + wraps in withRefreshingLock. Legacy
  single-default-source brains keep the bare tryAcquireDbLock(SYNC_LOCK_ID)
  path for back-compat.
- Dashboard SQL is the canonical content_chunks ch JOIN pages pg ON
  pg.id = ch.page_id WHERE pg.deleted_at IS NULL shape — the original PR
  shipped chunks ch JOIN ON page_slug, which would have crashed on PGLite
  parse and silently zeroed on Postgres via a swallow-catch. Errors from
  the dashboard SQL propagate (no silent zero-counts on real DB errors).

Tests
- New test/console-prefix.test.ts — 8 cases pinning ALS propagation,
  nested wraps, embedded-newline prefixing, back-compat fast path.
- New test/sync-all-parallel.test.ts (replaces PR's stubbed tests) —
  16 cases covering resolveParallelism, per-source lock format,
  buildSyncStatusReport SQL math + error propagation + envelope shape,
  connection-budget math, per-source prefix routing.
- New test/e2e/sync-status-pglite.test.ts — IRON RULE regression: real
  PGLite seeds 2 sources × pages × chunks (mixed embedded/unembedded,
  1 soft-deleted, 1 archived source). Validates SQL excludes both AND
  the active embedding column is the one used. This is the case that
  would have caught the PR's original broken SQL.

Compatibility
- No schema changes. No new dependencies.
- Single-source / non-`--all` paths: bit-for-bit identical to v0.40.2.
- PGLite users get serial behavior (single-connection engine).

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

* v0.40.6.0 — version bump for ship (skipping 0.40.4 + 0.40.5 for in-flight work)

Reserves v0.40.4 + v0.40.5 slots for parallel waves (salem's graph-signals
work and any other in-flight branches) and lands this PR's parallel-sync
work at v0.40.6.0. No code change beyond the version triple and the
TODOS / CLAUDE.md / CHANGELOG cross-references which were updated from
"v0.40.4" to "v0.41+" to match the new follow-up version.

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

---------

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

286 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* IRON RULE E2E regression for v0.40.3.0 `buildSyncStatusReport` SQL.
*
* Why this exists:
* PR #1314 shipped a broken SQL query for the per-source dashboard:
*
* FROM chunks ch JOIN pages pg ON pg.slug = ch.page_slug
*
* The actual schema is `content_chunks` joined on `page_id`. Every
* unit test in `test/sync-all-parallel.test.ts` stubbed `executeRaw`
* with regex-keyed canned responses, so the broken SQL never ran.
* The defensive `try/catch { countRows = [] }` would have silently
* returned "0 chunks for every source" in production — a misleading
* "your brain is empty" report on a real Postgres brain.
*
* This case exercises the REAL SQL against PGLite. If buildSyncStatusReport
* ever drifts back to a bad table name or join key, this test fails
* loudly at parse time (PGLite rejects unknown columns).
*
* Per CLAUDE.md's IRON RULE: "regression test is added to the plan as
* a critical requirement. No skipping." Codex's outside-voice review
* of the original plan caught this missing case.
*
* Coverage:
* - Canonical SQL parses and returns rows (Blocker 1 / Codex P0 #1)
* - Soft-deleted pages excluded from pages count + chunks count
* (v0.26.5 soft-delete shipped without updating this query path)
* - Archived sources excluded from the dashboard input
* (Expansion 3 from plan review)
* - Embedding column resolved via the registry (D16) — counts
* against the active column, not a hardcoded name
* - Errors propagate instead of returning lying zeroes (Q2 sub-fix)
*
* No DATABASE_URL needed; PGLite is in-memory.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
import { buildSyncStatusReport } from '../../src/commands/sync.ts';
import type { BrainEngine } from '../../src/core/engine.ts';
import type { ChunkInput } from '../../src/core/types.ts';
let engine: PGLiteEngine;
// Basis-vector embeddings keep the seed cheap (no real model needed) and
// deterministic. The dashboard only cares about NULL vs non-NULL on the
// embedding column, not the vector content.
function basisEmbedding(idx: number, dim = 1536): Float32Array {
const emb = new Float32Array(dim);
emb[idx % dim] = 1.0;
return emb;
}
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
// Seed two non-default sources: 'source-a' (active) + 'source-b' (active)
// + 'source-c' (archived — must be excluded by dashboard input filter).
// 'default' is auto-seeded by pglite-schema.ts:50.
await engine.executeRaw(
`INSERT INTO sources (id, name, local_path, last_commit, last_sync_at, config, archived)
VALUES
('source-a', 'source-a', '/tmp/source-a', 'aaa', NOW() - INTERVAL '1 hour', '{"syncEnabled": true}'::jsonb, FALSE),
('source-b', 'source-b', '/tmp/source-b', 'bbb', NOW() - INTERVAL '30 hours', '{"syncEnabled": true}'::jsonb, FALSE),
('source-c', 'source-c', '/tmp/source-c', 'ccc', NOW() - INTERVAL '100 hours', '{}'::jsonb, TRUE)`,
);
// Seed pages + chunks per source via the canonical engine API.
// source-a: 3 pages, 6 chunks, 4 embedded (2 unembedded)
// source-b: 2 pages, 4 chunks, 4 embedded (0 unembedded)
// Plus a soft-deleted page on source-a that should NOT count toward
// either pages or chunks (the v0.26.5 soft-delete-aware regression).
// source-a page 1: 2 chunks, both embedded
await engine.putPage('a/page-1', {
type: 'note',
title: 'A page 1',
compiled_truth: 'content for a/page-1',
timeline: '',
}, { sourceId: 'source-a' });
await engine.upsertChunks('a/page-1', [
{ chunk_index: 0, chunk_text: 'chunk a/1/0', chunk_source: 'compiled_truth', embedding: basisEmbedding(0), token_count: 4 },
{ chunk_index: 1, chunk_text: 'chunk a/1/1', chunk_source: 'compiled_truth', embedding: basisEmbedding(1), token_count: 4 },
] satisfies ChunkInput[], { sourceId: 'source-a' });
// source-a page 2: 2 chunks, both unembedded (no embedding field)
await engine.putPage('a/page-2', {
type: 'note',
title: 'A page 2',
compiled_truth: 'content for a/page-2',
timeline: '',
}, { sourceId: 'source-a' });
await engine.upsertChunks('a/page-2', [
{ chunk_index: 0, chunk_text: 'chunk a/2/0', chunk_source: 'compiled_truth', token_count: 4 },
{ chunk_index: 1, chunk_text: 'chunk a/2/1', chunk_source: 'compiled_truth', token_count: 4 },
] satisfies ChunkInput[], { sourceId: 'source-a' });
// source-a page 3: 2 chunks, both embedded
await engine.putPage('a/page-3', {
type: 'note',
title: 'A page 3',
compiled_truth: 'content for a/page-3',
timeline: '',
}, { sourceId: 'source-a' });
await engine.upsertChunks('a/page-3', [
{ chunk_index: 0, chunk_text: 'chunk a/3/0', chunk_source: 'compiled_truth', embedding: basisEmbedding(2), token_count: 4 },
{ chunk_index: 1, chunk_text: 'chunk a/3/1', chunk_source: 'compiled_truth', embedding: basisEmbedding(3), token_count: 4 },
] satisfies ChunkInput[], { sourceId: 'source-a' });
// SOFT-DELETED page on source-a: must NOT count toward pages or chunks.
// This is the v0.26.5 regression — pre-fix, soft-deleted pages were
// double-counted in the dashboard.
await engine.putPage('a/page-deleted', {
type: 'note',
title: 'A page deleted',
compiled_truth: 'content for a/page-deleted (will be soft-deleted)',
timeline: '',
}, { sourceId: 'source-a' });
await engine.upsertChunks('a/page-deleted', [
{ chunk_index: 0, chunk_text: 'should-not-count', chunk_source: 'compiled_truth', embedding: basisEmbedding(4), token_count: 4 },
{ chunk_index: 1, chunk_text: 'should-not-count', chunk_source: 'compiled_truth', token_count: 4 },
] satisfies ChunkInput[], { sourceId: 'source-a' });
await engine.softDeletePage('a/page-deleted', { sourceId: 'source-a' });
// source-b: 2 pages × 2 chunks, all embedded
await engine.putPage('b/page-1', {
type: 'note',
title: 'B page 1',
compiled_truth: 'content for b/page-1',
timeline: '',
}, { sourceId: 'source-b' });
await engine.upsertChunks('b/page-1', [
{ chunk_index: 0, chunk_text: 'chunk b/1/0', chunk_source: 'compiled_truth', embedding: basisEmbedding(5), token_count: 4 },
{ chunk_index: 1, chunk_text: 'chunk b/1/1', chunk_source: 'compiled_truth', embedding: basisEmbedding(6), token_count: 4 },
] satisfies ChunkInput[], { sourceId: 'source-b' });
await engine.putPage('b/page-2', {
type: 'note',
title: 'B page 2',
compiled_truth: 'content for b/page-2',
timeline: '',
}, { sourceId: 'source-b' });
await engine.upsertChunks('b/page-2', [
{ chunk_index: 0, chunk_text: 'chunk b/2/0', chunk_source: 'compiled_truth', embedding: basisEmbedding(7), token_count: 4 },
{ chunk_index: 1, chunk_text: 'chunk b/2/1', chunk_source: 'compiled_truth', embedding: basisEmbedding(8), token_count: 4 },
] satisfies ChunkInput[], { sourceId: 'source-b' });
});
afterAll(async () => {
await engine.disconnect();
});
describe('buildSyncStatusReport against real PGLite (IRON RULE regression for Blocker 1)', () => {
test('correct SQL: content_chunks JOIN pages ON page_id (NOT chunks/page_slug)', async () => {
// Caller-side source list mirrors what `gbrain sources status` (the
// CLI route in sources.ts) would supply: `WHERE local_path IS NOT NULL
// AND archived IS NOT TRUE`.
const sources = await engine.executeRaw<{
id: string;
name: string;
local_path: string | null;
config: Record<string, unknown>;
}>(
`SELECT id, name, local_path, config FROM sources
WHERE local_path IS NOT NULL AND archived IS NOT TRUE
ORDER BY id`,
);
// source-a + source-b only (source-c is archived; 'default' has no local_path).
expect(sources.map((s) => s.id).sort()).toEqual(['source-a', 'source-b']);
// The SQL must not throw. Pre-fix, the broken SQL would have thrown
// `relation "chunks" does not exist` on PGLite (and Postgres). Post-
// fix, the canonical `content_chunks JOIN pages ON page_id` shape parses.
const report = await buildSyncStatusReport(engine, sources);
expect(report.schema_version).toBe(1);
expect(report.sources).toHaveLength(2);
const byId = new Map(report.sources.map((s) => [s.source_id, s]));
// source-a: 3 active pages (4th is soft-deleted and excluded).
// 6 active chunks (2 from the soft-deleted page are excluded).
// 4 chunks embedded, 2 unembedded.
const a = byId.get('source-a')!;
expect(a.pages).toBe(3);
expect(a.chunks_total).toBe(6);
expect(a.chunks_unembedded).toBe(2);
expect(a.embedding_coverage_pct).toBeCloseTo(66.7, 1);
// source-b: 2 pages × 2 chunks = 4 chunks, all embedded.
const b = byId.get('source-b')!;
expect(b.pages).toBe(2);
expect(b.chunks_total).toBe(4);
expect(b.chunks_unembedded).toBe(0);
expect(b.embedding_coverage_pct).toBe(100);
});
test('soft-deleted pages excluded from pages count (v0.26.5 regression)', async () => {
// Verifies the `WHERE pg.deleted_at IS NULL` clause in BOTH subqueries
// of the dashboard SQL. Pre-fix the original PR query would have
// counted the soft-deleted page as part of source-a's totals.
const sources = await engine.executeRaw<{
id: string;
name: string;
local_path: string | null;
config: Record<string, unknown>;
}>(
`SELECT id, name, local_path, config FROM sources WHERE id = 'source-a'`,
);
const report = await buildSyncStatusReport(engine, sources);
const a = report.sources.find((s) => s.source_id === 'source-a')!;
expect(a.pages).toBe(3); // 4 raw rows, 1 soft-deleted → 3 active
expect(a.chunks_total).toBe(6); // 8 raw chunks, 2 on soft-deleted page → 6 active
});
test('archived sources are excluded by the caller-side filter (Expansion 3)', async () => {
// The dashboard input filter is `archived IS NOT TRUE`. source-c was
// seeded with archived=true and must not appear in the report.
const sources = await engine.executeRaw<{
id: string;
name: string;
local_path: string | null;
config: Record<string, unknown>;
}>(
`SELECT id, name, local_path, config FROM sources
WHERE local_path IS NOT NULL AND archived IS NOT TRUE`,
);
const ids = sources.map((s) => s.id);
expect(ids).not.toContain('source-c');
});
test('embedding column reported in envelope is what the SQL counted against (D16)', async () => {
// D16 → A: dashboard counts unembedded chunks against the ACTIVE
// embedding column (resolved via the registry). The envelope's
// `embedding_column` field exposes which column the SQL used so
// operators can verify Voyage / multimodal setups are reported
// correctly. Default brains (no embedding_model config) resolve to
// 'embedding'. Non-default brains would see their override here.
const sources = await engine.executeRaw<{
id: string;
name: string;
local_path: string | null;
config: Record<string, unknown>;
}>(
`SELECT id, name, local_path, config FROM sources
WHERE local_path IS NOT NULL AND archived IS NOT TRUE`,
);
const report = await buildSyncStatusReport(engine, sources);
expect(typeof report.embedding_column).toBe('string');
expect(report.embedding_column.length).toBeGreaterThan(0);
});
test('errors propagate (Q2 sub-fix — no silent swallowing of real DB errors)', async () => {
// Wrap a wrapped engine that throws on the count query. Pre-fix the
// PR's bare `catch { countRows = [] }` would have masked this and
// returned "0 chunks for every source" — exactly the misleading
// report the operator should NEVER see when their DB is actually
// broken.
const sources = await engine.executeRaw<{
id: string;
name: string;
local_path: string | null;
config: Record<string, unknown>;
}>(
`SELECT id, name, local_path, config FROM sources
WHERE local_path IS NOT NULL AND archived IS NOT TRUE`,
);
const proxyEngine: BrainEngine = {
kind: engine.kind,
executeRaw: async (sql: string, params?: unknown[]) => {
if (/WITH s AS \(\s*SELECT unnest/.test(sql)) {
throw new Error('synthetic test error: count query failed');
}
return (engine.executeRaw as (sql: string, params?: unknown[]) => Promise<unknown[]>)(sql, params);
},
} as unknown as BrainEngine;
await expect(buildSyncStatusReport(proxyEngine, sources)).rejects.toThrow(
/count query failed/,
);
});
});