mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-29 19:01:39 +00:00
* perf: batch-load integrity scan — 500 round-trips → 1 SQL query
doctor's integrity_sample check called getPage() sequentially for 500
pages through PgBouncer transaction-mode pooling. Each call required a
full connection acquire/release cycle, causing doctor to timeout (~90s+)
on production deployments.
Replace with a single SQL query that fetches slug, compiled_truth, and
frontmatter for all candidate pages at once. Falls back to the
sequential path for PGLite or when no DB connection is available.
Before: doctor timeout (killed at 60s)
After: doctor completes in ~6s (full run including all other checks)
143 existing minions tests pass unchanged.
* fix: skillpack acquireLock negative-age on Linux sub-ms fs timestamps
On Linux ext4, statSync().mtimeMs has sub-ms precision while Date.now() is
integer ms. A just-written lockfile can report an mtime ~0.3ms ahead of
Date.now(), making age negative. The acquireLock check `age >= staleMs`
then evaluated false on staleMs:0, falling through the forceUnlock branch
and throwing "Another skillpack install appears to be running" instead of
unlocking. macOS rounds to integer ms so this only surfaced on Linux CI.
Clamp age to zero and add a utimesSync-based regression test that pushes
the lock mtime 10ms into the future to deterministically reproduce the
negative-age case on any platform.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: scanIntegrity batch path scopes by unique slug + Postgres-only gate
Codex review caught that the batch SQL scanned raw (source_id, slug) rows
while sequential's getAllSlugs() returned a Set<string>. On multi-source
brains (UNIQUE(source_id, slug) since v0.18.0), the batch path overcounted
hits and exhausted the LIMIT before covering N distinct pages.
Three changes:
- SELECT DISTINCT ON (slug) ... ORDER BY slug mirrors Set<string>
semantics; multi-source brains now get exact unique-slug counts.
- engine.kind === 'postgres' gate at the call site so PGLite never
enters the batch branch (catch{} fallback was firing on every PGLite
doctor run, polluting the GBRAIN_DEBUG log signal).
- Replace bare catch{} with debug-gated console.error so real Postgres
errors (deadlock, connection drop, SQL bug) are diagnosable instead
of silently swallowed.
Plus inline comments explaining the WHY for DISTINCT ON, the engine.kind
gate, the GBRAIN_DEBUG fallback, and the validate filter divergence
(boolean is the documented contract; stringly-typed handled at lint).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test: scanIntegrity batch parity (dedup, hits, validate, topPages)
Real-Postgres E2E tests asserting the batch fast path returns identical
results to the sequential path on the four cases that matter:
- dedup: multi-source duplicate slugs scan once (regression guard for
the codex catch). Raw SQL fixture seeds the alt-source row since
engine.putPage doesn't take a source_id.
- hits: bareHits and externalHits arrays match between paths.
- validate: validate:false (boolean) page is skipped on both paths.
- topPages: ordering matches.
Skip when DATABASE_URL is not set (matches existing test/e2e/ pattern).
Per-test TRUNCATE keeps fixture state isolated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: bump version, changelog, and CLAUDE.md (v0.22.7)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: regenerate llms-full.txt for v0.22.7 CLAUDE.md updates
CLAUDE.md gained the integrity.ts inventory entry and the new
test/e2e/integrity-batch.test.ts test file in commit edd4329.
The committed llms-full.txt bundle inlines CLAUDE.md content,
so it needs to be regenerated to match.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: bump v0.22.7 → v0.22.8
Same content as v0.22.7 (doctor integrity batch-load + multi-source
correctness + skillpack Linux fs-timestamp fix), retitled to v0.22.8 to
slot above master's pending v0.22.7 if/when that releases first.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: root <root@localhost>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
169 lines
6.0 KiB
TypeScript
169 lines
6.0 KiB
TypeScript
/**
|
|
* E2E parity tests — scanIntegrity batch path vs sequential path.
|
|
*
|
|
* The batch path (Postgres-only fast path added in v0.20.x) and the sequential
|
|
* path (engine.getAllSlugs + getPage loop) MUST return the same result for
|
|
* every supported case, otherwise gbrain doctor reports different numbers
|
|
* depending on engine type or whether batch was attempted.
|
|
*
|
|
* Codex review of the original perf commit caught a multi-source dedup
|
|
* regression: the batch SQL scanned raw (source_id, slug) rows while
|
|
* sequential's getAllSlugs() returned a Set<string>. v0.22.7 adds
|
|
* SELECT DISTINCT ON (slug) to the batch SQL; these tests prove parity.
|
|
*
|
|
* Run: DATABASE_URL=... bun test test/e2e/integrity-batch.test.ts
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { hasDatabase, setupDB, teardownDB, getEngine, getConn } from './helpers.ts';
|
|
import { scanIntegrity } from '../../src/commands/integrity.ts';
|
|
|
|
const skip = !hasDatabase();
|
|
const describeE2E = skip ? describe.skip : describe;
|
|
|
|
if (skip) {
|
|
console.log('Skipping E2E integrity batch parity tests (DATABASE_URL not set)');
|
|
}
|
|
|
|
describeE2E('scanIntegrity batch parity (E2E, Postgres-only)', () => {
|
|
beforeAll(async () => {
|
|
await setupDB();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await teardownDB();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
// Clean slate per case so fixtures don't leak across describes.
|
|
const conn = getConn();
|
|
await conn.unsafe(`TRUNCATE pages CASCADE`);
|
|
});
|
|
|
|
describe('dedup', () => {
|
|
test('multi-source duplicate slugs scan once, not once-per-source', async () => {
|
|
const engine = getEngine();
|
|
const conn = getConn();
|
|
|
|
// Seed default-source page via the engine.
|
|
await engine.putPage('people/alice', {
|
|
type: 'person',
|
|
title: 'Alice',
|
|
compiled_truth: 'Alice writes about AI safety.',
|
|
timeline: '',
|
|
frontmatter: {},
|
|
});
|
|
|
|
// Seed alt-source row via raw SQL — engine.putPage doesn't take a source_id,
|
|
// and we specifically need to test that DISTINCT ON (slug) collapses
|
|
// the multi-source rows into one scan.
|
|
await conn.unsafe(`
|
|
INSERT INTO sources (id, name) VALUES ('test-source-2', 'test-source-2')
|
|
ON CONFLICT DO NOTHING
|
|
`);
|
|
await conn.unsafe(`
|
|
INSERT INTO pages (source_id, slug, type, title, compiled_truth, timeline, frontmatter)
|
|
VALUES ('test-source-2', 'people/alice', 'person', 'Alice (alt source)',
|
|
'Alice from another source.', '', '{}'::jsonb)
|
|
`);
|
|
|
|
const batchResult = await scanIntegrity(engine, { limit: 100, batchLoad: true });
|
|
const seqResult = await scanIntegrity(engine, { limit: 100, batchLoad: false });
|
|
|
|
// Both paths must report the same number of distinct slugs scanned.
|
|
// Pre-fix: batch reported 2 (one per source row), sequential reported 1.
|
|
expect(batchResult.pagesScanned).toBe(seqResult.pagesScanned);
|
|
expect(batchResult.pagesScanned).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('hits', () => {
|
|
test('bareHits and externalHits arrays match between paths', async () => {
|
|
const engine = getEngine();
|
|
|
|
await engine.putPage('people/alice', {
|
|
type: 'person',
|
|
title: 'Alice',
|
|
compiled_truth: 'Alice tweeted about AI safety last week.',
|
|
timeline: '',
|
|
frontmatter: {},
|
|
});
|
|
await engine.putPage('people/bob', {
|
|
type: 'person',
|
|
title: 'Bob',
|
|
compiled_truth: 'Bob wrote at [example](https://example.com/bob).',
|
|
timeline: '',
|
|
frontmatter: {},
|
|
});
|
|
|
|
const batchResult = await scanIntegrity(engine, { limit: 100, batchLoad: true });
|
|
const seqResult = await scanIntegrity(engine, { limit: 100, batchLoad: false });
|
|
|
|
expect(batchResult.bareHits.length).toBe(seqResult.bareHits.length);
|
|
expect(batchResult.externalHits.length).toBe(seqResult.externalHits.length);
|
|
expect(batchResult.bareHits.map(h => h.slug).sort()).toEqual(
|
|
seqResult.bareHits.map(h => h.slug).sort(),
|
|
);
|
|
expect(batchResult.externalHits.map(h => h.slug).sort()).toEqual(
|
|
seqResult.externalHits.map(h => h.slug).sort(),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('validate', () => {
|
|
test('validate:false (boolean) page is skipped on both paths', async () => {
|
|
const engine = getEngine();
|
|
|
|
await engine.putPage('people/alice', {
|
|
type: 'person',
|
|
title: 'Alice',
|
|
compiled_truth: 'Alice tweeted about something.',
|
|
timeline: '',
|
|
frontmatter: {},
|
|
});
|
|
await engine.putPage('people/legacy', {
|
|
type: 'person',
|
|
title: 'Legacy',
|
|
compiled_truth: 'Legacy tweeted about old stuff.',
|
|
timeline: '',
|
|
frontmatter: { validate: false },
|
|
});
|
|
|
|
const batchResult = await scanIntegrity(engine, { limit: 100, batchLoad: true });
|
|
const seqResult = await scanIntegrity(engine, { limit: 100, batchLoad: false });
|
|
|
|
expect(batchResult.pagesScanned).toBe(seqResult.pagesScanned);
|
|
expect(batchResult.pagesScanned).toBe(1);
|
|
expect(batchResult.bareHits.map(h => h.slug)).not.toContain('people/legacy');
|
|
expect(seqResult.bareHits.map(h => h.slug)).not.toContain('people/legacy');
|
|
});
|
|
});
|
|
|
|
describe('topPages', () => {
|
|
test('topPages ordering matches between paths', async () => {
|
|
const engine = getEngine();
|
|
|
|
// Alice has 2 bare-tweet hits; Bob has 1.
|
|
await engine.putPage('people/alice', {
|
|
type: 'person',
|
|
title: 'Alice',
|
|
compiled_truth: 'Alice tweeted today. Alice tweeted yesterday too.',
|
|
timeline: '',
|
|
frontmatter: {},
|
|
});
|
|
await engine.putPage('people/bob', {
|
|
type: 'person',
|
|
title: 'Bob',
|
|
compiled_truth: 'Bob tweeted once.',
|
|
timeline: '',
|
|
frontmatter: {},
|
|
});
|
|
|
|
const batchResult = await scanIntegrity(engine, { limit: 100, batchLoad: true });
|
|
const seqResult = await scanIntegrity(engine, { limit: 100, batchLoad: false });
|
|
|
|
expect(batchResult.topPages).toEqual(seqResult.topPages);
|
|
});
|
|
});
|
|
});
|