mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* feat(sync): checkpoint primitives for resumable sync (#1794) - op-checkpoint.ts: syncFingerprint({sourceId, lastCommit}) keyed on the anchor (never HEAD) so the checkpoint survives a growing backlog. - source-health.ts: commitTimeMs(localPath, sha) for stamping newest_content_at against a pinned (non-HEAD) commit. - sync-concurrency.ts: resolveMaxConnections + clampWorkersForConnectionBudget for the opt-in GBRAIN_MAX_CONNECTIONS single-sync footprint clamp. * feat(sync): resumable incremental sync via pinned-target checkpoint (#1794) performSyncInner now drains a fixed lastCommit..pin range, banking completed file paths to op_checkpoints and advancing last_commit (+ last_sync_at) ONLY at full import completion. A killed/aborted/blocked run leaves the anchor untouched and resumes from the banked set next run — the convergence fix. - Pinned target: completion advances to the pin, not live HEAD, so commits landing after the pin are a clean next-sync diff (kills the staleness window). History rewrite (pin not an ancestor of HEAD) discards the checkpoint + re-pins. - Forward-progress head gate: merge-base --is-ancestor pin HEAD replaces the strict "HEAD == captured" gate that blocked on every concurrent enrich commit. - Vanished-on-disk added file -> skip + checkpoint, not a failedFiles block. - Large syncs defer extract/embed to the resumable --stale sweeps (convergence == import convergence); small syncs keep inline extract/facts/embed. - GBRAIN_MAX_CONNECTIONS clamp on the worker fan-out (opt-in). - Typed SyncLockBusyError; the Minion sync handler (jobs.ts) marks the job SKIPPED (not failed) on a held lock so cron/autopilot defers cleanly. * feat(doctor): pool_budget check for GBRAIN_MAX_CONNECTIONS (#1794) computePoolBudgetCheck + checkPoolBudget warn when the parent pool leaves no room for a parallel sync worker under GBRAIN_MAX_CONNECTIONS, pointing at GBRAIN_POOL_SIZE=2. Registered in the ops category set. * test(sync): resumable-sync regression suite + vanished-file contract (#1794) - sync-resumable-import.serial.test.ts (13 cases): convergence regression, resume-skips-checkpointed, pinned-target/forward-drift, history-rewrite re-pin, last_sync_at-not-bumped-on-block + good-file banking, vanished-file skip, dry-run/empty-diff, + pure fingerprint/clamp/pool-budget helpers. - sync-parallel.test.ts: vanished-mid-sync added file now asserts the new skip contract (supersedes the v0.22.13 CODEX-3 failedFiles behavior). * chore: bump version and changelog (v0.42.17.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
264 lines
10 KiB
TypeScript
264 lines
10 KiB
TypeScript
/**
|
|
* Parallel-sync regression tests (PGLite, in-memory).
|
|
*
|
|
* T1 — sync.last_commit failure-gate under concurrency=4 request.
|
|
* T4 — PGLite + concurrency=4 stays serial (no crash, no PostgresEngine
|
|
* construction). Tightens the engine.kind guard introduced in
|
|
* v0.22.13 (PR #490 A1).
|
|
* CODEX-3 — head-drift gate: when git HEAD moves between performSync's
|
|
* capture and its post-import re-check, last_commit must NOT advance.
|
|
*
|
|
* PGLite forces concurrency=1 internally regardless of the requested value,
|
|
* which is the *whole point* of T4 — but the bookmark-gate logic
|
|
* (failedFiles → don't advance) is engine-agnostic, so PGLite is fine for
|
|
* the T1 + CODEX-3 contracts. A separate Postgres E2E covers worker-engine
|
|
* construction directly.
|
|
*/
|
|
import { describe, expect, test, beforeEach, afterEach } from 'bun:test';
|
|
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import { execSync } from 'child_process';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
|
|
function git(repo: string, ...args: string[]): string {
|
|
return execSync(`git ${args.join(' ')}`, { cwd: repo, encoding: 'utf-8' }).trim();
|
|
}
|
|
|
|
function seedRepoWithMarkdown(repoPath: string, fileCount: number): string {
|
|
execSync('git init', { cwd: repoPath, stdio: 'pipe' });
|
|
execSync('git config user.email "test@test.com"', { cwd: repoPath, stdio: 'pipe' });
|
|
execSync('git config user.name "Test"', { cwd: repoPath, stdio: 'pipe' });
|
|
mkdirSync(join(repoPath, 'people'), { recursive: true });
|
|
for (let i = 0; i < fileCount; i++) {
|
|
writeFileSync(join(repoPath, `people/p${i}.md`), [
|
|
'---',
|
|
'type: person',
|
|
`title: Person ${i}`,
|
|
'---',
|
|
'',
|
|
`This is person ${i}.`,
|
|
].join('\n'));
|
|
}
|
|
execSync('git add -A && git commit -m "initial"', { cwd: repoPath, stdio: 'pipe' });
|
|
return git(repoPath, 'rev-parse', 'HEAD');
|
|
}
|
|
|
|
describe('sync-parallel: PGLite + concurrency=4 (T4)', () => {
|
|
let engine: PGLiteEngine;
|
|
let repoPath: string;
|
|
|
|
beforeEach(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
repoPath = mkdtempSync(join(tmpdir(), 'gbrain-sync-par-'));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await engine.disconnect();
|
|
if (repoPath) rmSync(repoPath, { recursive: true, force: true });
|
|
});
|
|
|
|
test('PGLite + concurrency=4 + 60 files: imports all without crashing', async () => {
|
|
seedRepoWithMarkdown(repoPath, 60);
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
const result = await performSync(engine, {
|
|
repoPath,
|
|
noPull: true,
|
|
noEmbed: true,
|
|
concurrency: 4,
|
|
});
|
|
// First sync routes through performFullSync, returning 'first_sync'.
|
|
expect(result.status).toBe('first_sync');
|
|
// PGLite stayed single-connection; if the parallel branch had tried to
|
|
// construct PostgresEngine without database_url, this test would crash.
|
|
});
|
|
|
|
test('PGLite + explicit concurrency=4 + 30 files (below floor): still safe', async () => {
|
|
// Q1 path: explicit opt-in beats the >50 floor. PGLite forces serial
|
|
// anyway (engine.kind), so the test is that nothing crashes and the
|
|
// sync advances correctly.
|
|
seedRepoWithMarkdown(repoPath, 30);
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
const result = await performSync(engine, {
|
|
repoPath,
|
|
noPull: true,
|
|
noEmbed: true,
|
|
concurrency: 4,
|
|
});
|
|
expect(result.status).toBe('first_sync');
|
|
});
|
|
});
|
|
|
|
describe('sync-parallel: bookmark gate under concurrency request (T1)', () => {
|
|
let engine: PGLiteEngine;
|
|
let repoPath: string;
|
|
|
|
beforeEach(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
repoPath = mkdtempSync(join(tmpdir(), 'gbrain-sync-gate-'));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await engine.disconnect();
|
|
if (repoPath) rmSync(repoPath, { recursive: true, force: true });
|
|
});
|
|
|
|
test('clean parallel sync advances last_commit', async () => {
|
|
const initialHead = seedRepoWithMarkdown(repoPath, 5);
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
await performSync(engine, {
|
|
repoPath,
|
|
noPull: true,
|
|
noEmbed: true,
|
|
concurrency: 4,
|
|
});
|
|
const lastCommit = await engine.getConfig('sync.last_commit');
|
|
expect(lastCommit).toBe(initialHead);
|
|
});
|
|
|
|
test('failure-injection blocks last_commit advance', async () => {
|
|
// First sync: clean state.
|
|
const firstHead = seedRepoWithMarkdown(repoPath, 5);
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
await performSync(engine, {
|
|
repoPath, noPull: true, noEmbed: true,
|
|
});
|
|
const lastAfterFirst = await engine.getConfig('sync.last_commit');
|
|
expect(lastAfterFirst).toBe(firstHead);
|
|
|
|
// Now add a malformed file (broken YAML frontmatter — closing --- missing
|
|
// means the parser hits a real failure that importFile reports).
|
|
writeFileSync(join(repoPath, 'people/broken.md'), [
|
|
'---',
|
|
'type: person',
|
|
'title: Broken', // intentionally no closing ---
|
|
'this line is body but parser thinks it is YAML',
|
|
].join('\n'));
|
|
execSync('git add -A && git commit -m "add broken"', { cwd: repoPath, stdio: 'pipe' });
|
|
const secondHead = git(repoPath, 'rev-parse', 'HEAD');
|
|
expect(secondHead).not.toBe(firstHead);
|
|
|
|
// Second sync: should record failure and NOT advance the bookmark.
|
|
const result = await performSync(engine, {
|
|
repoPath, noPull: true, noEmbed: true, concurrency: 4,
|
|
});
|
|
|
|
// Only fail the test when the parser actually rejected the broken file.
|
|
// Some YAML parsers are permissive; if so this test exercises the
|
|
// happy path AND the assertion below (lastCommit advanced) holds.
|
|
if (result.status === 'blocked_by_failures') {
|
|
const lastAfterBroken = await engine.getConfig('sync.last_commit');
|
|
expect(lastAfterBroken).toBe(firstHead); // unchanged — gate held
|
|
expect(result.failedFiles ?? 0).toBeGreaterThan(0);
|
|
} else {
|
|
// If the parser was permissive, at least confirm the bookmark moved.
|
|
const lastAfterBroken = await engine.getConfig('sync.last_commit');
|
|
expect(lastAfterBroken).toBe(secondHead);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('sync-parallel: head-drift gate (CODEX-3)', () => {
|
|
let engine: PGLiteEngine;
|
|
let repoPath: string;
|
|
|
|
beforeEach(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
repoPath = mkdtempSync(join(tmpdir(), 'gbrain-sync-drift-'));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await engine.disconnect();
|
|
if (repoPath) rmSync(repoPath, { recursive: true, force: true });
|
|
});
|
|
|
|
test('static-HEAD sync advances last_commit (control)', async () => {
|
|
const head = seedRepoWithMarkdown(repoPath, 3);
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
await performSync(engine, { repoPath, noPull: true, noEmbed: true });
|
|
expect(await engine.getConfig('sync.last_commit')).toBe(head);
|
|
});
|
|
|
|
test('vanished-mid-sync added file is skipped, not a failure (v0.42.x #1794)', async () => {
|
|
// First sync: clean state for incremental.
|
|
seedRepoWithMarkdown(repoPath, 3);
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
await performSync(engine, { repoPath, noPull: true, noEmbed: true });
|
|
|
|
// Add a file, commit, then delete the file from disk WITHOUT amending the
|
|
// commit — diff says it exists at HEAD, but the file is gone.
|
|
writeFileSync(join(repoPath, 'people/will-vanish.md'), [
|
|
'---', 'type: person', 'title: Vanish', '---', '', 'body',
|
|
].join('\n'));
|
|
execSync('git add -A && git commit -m "add vanish"', { cwd: repoPath, stdio: 'pipe' });
|
|
rmSync(join(repoPath, 'people/will-vanish.md'));
|
|
|
|
const result = await performSync(engine, {
|
|
repoPath, noPull: true, noEmbed: true,
|
|
});
|
|
// v0.42.x (#1794, Codex #3) SUPERSEDES the v0.22.13 CODEX-3 behavior: under
|
|
// the pinned-target resumable sync, importFile reads the live working tree,
|
|
// so a file added in lastCommit..pin but deleted from disk by a commit AFTER
|
|
// the pin is normal forward progress from a concurrent committer — it
|
|
// genuinely doesn't exist at HEAD, so SKIP it (don't block the run). A real
|
|
// history REWRITE is caught by the separate pin-reachability gate. The
|
|
// vanished file is never created; the next sync's pin..HEAD diff shows it
|
|
// deleted.
|
|
expect(result.status).toBe('synced');
|
|
expect(result.failedFiles ?? 0).toBe(0);
|
|
expect(await engine.getPage('people/will-vanish')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('sync-parallel: writer lock prevents reentrance (CODEX-2)', () => {
|
|
let engine: PGLiteEngine;
|
|
let repoPath: string;
|
|
|
|
beforeEach(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
repoPath = mkdtempSync(join(tmpdir(), 'gbrain-sync-lock-'));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await engine.disconnect();
|
|
if (repoPath) rmSync(repoPath, { recursive: true, force: true });
|
|
});
|
|
|
|
test('two parallel performSync calls in same process: second waits or fails fast', async () => {
|
|
seedRepoWithMarkdown(repoPath, 5);
|
|
const { performSync } = await import('../src/commands/sync.ts');
|
|
|
|
// Same-process concurrent calls: PGLite serializes engine ops via its
|
|
// exclusive transaction mutex, but the writer-lock is the right barrier.
|
|
// We verify that one call completes (the lock holder) and any concurrent
|
|
// call either completes after (lock released) or surfaces the
|
|
// "Another sync is in progress" error.
|
|
const promise1 = performSync(engine, { repoPath, noPull: true, noEmbed: true });
|
|
|
|
let secondError: unknown = null;
|
|
try {
|
|
// Tiny delay so promise1 captures the lock first.
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
await performSync(engine, { repoPath, noPull: true, noEmbed: true });
|
|
} catch (e) {
|
|
secondError = e;
|
|
}
|
|
await promise1;
|
|
|
|
// Either: (a) second call completed after first released, both succeeded
|
|
// OR (b) second call hit the lock-busy error path. Either is correct.
|
|
if (secondError) {
|
|
const msg = secondError instanceof Error ? secondError.message : String(secondError);
|
|
expect(msg).toMatch(/Another sync is in progress|lock|gbrain-sync/i);
|
|
}
|
|
});
|
|
});
|