mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* fix(jobs): reap stale dead-holder cycle/sync locks (#1972) A crashed sync (OOM, recycle, SIGKILL) stranded its gbrain_cycle_locks row until something contended for it — reclaim was on-contention only. Add a host-scoped background reaper: reapDeadHolderLocks deletes locks whose holder PID is provably dead on this host, scoped to the gbrain-sync:*/gbrain-cycle* namespaces only (never elections/supervisor/reindex), with a snapshot-matched delete (date_trunc on acquired_at) that is TOCTOU-safe against PID reuse. Reuses isHolderDeadLocally (same-host + ESRCH + 60s grace). doctor --fix now auto-reaps for no-autopilot brains. DRY: selectLockRows + shared mapper now back inspectLock + listStaleLocks (killed the triplication). * fix(db): bound pool disconnect so teardown can't eat CLI output (#1972) pool.end() against PgBouncer transaction-mode never drained, so disconnect blocked until the CLI's 10s force-exit fired and process.exit()'d mid-write, truncating stdout (e.g. #1959's relational query returned empty). Add a gbrain-owned endPoolBounded(pool): Promise.race of pool.end({timeout}) against a hard timer, so teardown is bounded regardless of what postgres.js does and is testable. connection-manager ends its direct + read pools concurrently so the per-pool bounds don't stack. PGLite disconnect is unaffected. * fix(cycle): complete cooperative-abort coverage + wire lock reaper (#1972) v0.42.29 made only the embed phase honor the abort signal; a 24h pull still showed force-evicts from a long non-embed phase ignoring it. Thread the signal into every cycle-reachable long loop: extract (extractForSlugs + the full-walk extractLinksFromDir/extractTimelineFromDir), extract_facts (per-page loop + embed signal + the phantom-redirect 30s lock-retry), and consolidate's bucket loop. Add a terminal abort check so an aborted cycle never stamps last_full_cycle_at as a completed run (Codex #9). lint now yields + checks abort every 200 pages (it's synchronous; the yield is what lets the signal land). New phase-duration force-evict attribution log names any phase that crosses the 30s deadline. Wire reapDeadHolderLocks at cycle start. * chore: bump version and changelog (v0.42.38.0) #1972 — stale-lock reaper, bounded pool disconnect, and complete cooperative-abort coverage across cycle phases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(key-files): current-state for the #1972 reaper, bounded disconnect, abort coverage document-release: update db-lock.ts (reapDeadHolderLocks + selectLockRows DRY), db.ts (endPoolBounded), and abort-check.ts (coverage now spans extract/ extract_facts/consolidate/lint + terminal guard) entries to current truth. * test(isolation): fix shard-order flakes exposed by #1972's new test files Adding 3 new test files reshuffled the hash-based shards, exposing two pre-existing test-isolation bugs: - cycle-consolidate.test.ts assumed the global legacy-embedding preload's 1536-d gateway config still held at initSchema, but a co-sharded test that calls resetGateway() in teardown nulls it, so initSchema fell back to the 1280-d default and built a halfvec(1280) facts column its 1536-d fixtures can't fill. Re-pin the legacy OpenAI/1536 config in beforeAll (the pattern legacy-embedding-preload.ts documents for 1536-d fixture tests). - db-lock-heartbeat-takeover.test.ts (merged from master's #1794) mutated process.env.GBRAIN_LOCK_STEAL_GRACE_SECONDS raw, tripping check:test-isolation rule R1. Convert to withEnv(). --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
171 lines
7.0 KiB
TypeScript
171 lines
7.0 KiB
TypeScript
/**
|
|
* v0.31 Phase 6 — dream-cycle `consolidate` phase tests.
|
|
*
|
|
* Pins:
|
|
* - Below-threshold buckets are skipped (count < 3 OR oldest < 24h)
|
|
* - Cluster of >=2 same-vector facts produces 1 take, marks all facts consolidated
|
|
* - Never DELETE — facts stay as audit trail
|
|
* - dryRun honored
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { configureGateway } from '../src/core/ai/gateway.ts';
|
|
import { runPhaseConsolidate } from '../src/core/cycle/phases/consolidate.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
// initSchema() bakes the facts.embedding dim from the gateway's configured
|
|
// embedding model; the default is now 1280-d (ZE). This file's fixtures are
|
|
// 1536-d, so pin the legacy 1536-d OpenAI config (matching
|
|
// test/helpers/legacy-embedding-preload.ts) right before initSchema. The
|
|
// global preload sets this, but a co-sharded test that calls resetGateway()
|
|
// in its teardown nulls it, leaving initSchema to fall back to the 1280-d
|
|
// default and build a halfvec(1280) column the 1536-d inserts can't fill.
|
|
// Re-pinning here makes the schema deterministic regardless of shard
|
|
// neighbors (surfaced when #1972's new test files reshuffled the shards).
|
|
configureGateway({
|
|
embedding_model: 'openai:text-embedding-3-large',
|
|
embedding_dimensions: 1536,
|
|
env: { ...process.env },
|
|
});
|
|
await engine.initSchema();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
// Clean facts + takes between tests for hermetic state.
|
|
await engine.executeRaw(`DELETE FROM facts`);
|
|
await engine.executeRaw(`DELETE FROM takes`);
|
|
});
|
|
|
|
const oldDate = () => new Date(Date.now() - 30 * 60 * 60 * 1000).toISOString();
|
|
const recentDate = () => new Date(Date.now() - 60 * 1000).toISOString();
|
|
function unitVec(): string {
|
|
const a = new Float32Array(1536);
|
|
a[0] = 1.0;
|
|
return '[' + Array.from(a).join(',') + ']';
|
|
}
|
|
|
|
async function seedPage(slug: string): Promise<number> {
|
|
await engine.executeRaw(
|
|
`INSERT INTO pages (slug, type, title) VALUES ($1, 'concept', 'Test') ON CONFLICT DO NOTHING`,
|
|
[slug],
|
|
);
|
|
const r = await engine.executeRaw<{ id: number }>(
|
|
`SELECT id FROM pages WHERE slug = $1 AND source_id = 'default'`,
|
|
[slug],
|
|
);
|
|
return r[0].id;
|
|
}
|
|
|
|
describe('runPhaseConsolidate', () => {
|
|
test('below threshold (count < 3) → skipped', async () => {
|
|
await seedPage('cons-skip-count');
|
|
for (let i = 0; i < 2; i++) {
|
|
await engine.executeRaw(
|
|
`INSERT INTO facts (source_id, entity_slug, fact, kind, source, valid_from, embedding, embedded_at)
|
|
VALUES ('default', 'cons-skip-count', $1, 'fact', 'test', $2::timestamptz, $3::vector, $2::timestamptz)`,
|
|
[`fact ${i}`, oldDate(), unitVec()],
|
|
);
|
|
}
|
|
const r = await runPhaseConsolidate(engine, {});
|
|
expect(r.details.facts_consolidated).toBe(0);
|
|
expect(r.details.takes_written).toBe(0);
|
|
});
|
|
|
|
test('all facts too recent → bucket processed but skipped, 0 work', async () => {
|
|
await seedPage('cons-skip-age');
|
|
for (let i = 0; i < 4; i++) {
|
|
await engine.executeRaw(
|
|
`INSERT INTO facts (source_id, entity_slug, fact, kind, source, valid_from, embedding, embedded_at)
|
|
VALUES ('default', 'cons-skip-age', $1, 'fact', 'test', $2::timestamptz, $3::vector, $2::timestamptz)`,
|
|
[`fact ${i}`, recentDate(), unitVec()],
|
|
);
|
|
}
|
|
const r = await runPhaseConsolidate(engine, {});
|
|
expect(r.details.facts_consolidated).toBe(0);
|
|
expect(r.details.buckets_skipped).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
test('happy path: 4 same-vector facts on a page → 1 take, all consolidated', async () => {
|
|
const pageId = await seedPage('people/alice-example');
|
|
expect(pageId).toBeGreaterThan(0);
|
|
for (let i = 0; i < 4; i++) {
|
|
await engine.executeRaw(
|
|
`INSERT INTO facts (source_id, entity_slug, fact, kind, source, valid_from, confidence, embedding, embedded_at)
|
|
VALUES ('default', 'people/alice-example', $1, 'fact', 'test', $2::timestamptz, 0.9, $3::vector, $2::timestamptz)`,
|
|
[`alice fact ${i}`, oldDate(), unitVec()],
|
|
);
|
|
}
|
|
const r = await runPhaseConsolidate(engine, {});
|
|
expect(r.details.facts_consolidated).toBe(4);
|
|
expect(r.details.takes_written).toBe(1);
|
|
|
|
// Take row created on the right page.
|
|
const takes = await engine.executeRaw<{ page_id: number; kind: string; weight: number; holder: string }>(
|
|
`SELECT page_id, kind, weight, holder FROM takes`,
|
|
);
|
|
expect(takes.length).toBe(1);
|
|
expect(takes[0].page_id).toBe(pageId);
|
|
expect(takes[0].kind).toBe('fact');
|
|
expect(takes[0].holder).toBe('self');
|
|
expect(takes[0].weight).toBeCloseTo(0.9, 2);
|
|
|
|
// Facts marked consolidated, NEVER deleted.
|
|
const facts = await engine.executeRaw<{ id: number; consolidated_at: Date | null; consolidated_into: number | null }>(
|
|
`SELECT id, consolidated_at, consolidated_into FROM facts ORDER BY id`,
|
|
);
|
|
expect(facts.length).toBe(4);
|
|
for (const f of facts) {
|
|
expect(f.consolidated_at).not.toBeNull();
|
|
expect(f.consolidated_into).not.toBeNull();
|
|
}
|
|
});
|
|
|
|
test('dryRun honored: counters tick but no rows written', async () => {
|
|
await seedPage('cons-dryrun');
|
|
for (let i = 0; i < 3; i++) {
|
|
await engine.executeRaw(
|
|
`INSERT INTO facts (source_id, entity_slug, fact, kind, source, valid_from, embedding, embedded_at)
|
|
VALUES ('default', 'cons-dryrun', $1, 'fact', 'test', $2::timestamptz, $3::vector, $2::timestamptz)`,
|
|
[`dryrun fact ${i}`, oldDate(), unitVec()],
|
|
);
|
|
}
|
|
const r = await runPhaseConsolidate(engine, { dryRun: true });
|
|
expect(r.details.dryRun).toBe(true);
|
|
expect(r.details.facts_consolidated).toBe(3);
|
|
expect(r.details.takes_written).toBe(1);
|
|
const takes = await engine.executeRaw<{ id: number }>(`SELECT id FROM takes`);
|
|
expect(takes.length).toBe(0);
|
|
const facts = await engine.executeRaw<{ id: number; consolidated_at: Date | null }>(
|
|
`SELECT id, consolidated_at FROM facts ORDER BY id`,
|
|
);
|
|
for (const f of facts) {
|
|
expect(f.consolidated_at).toBeNull();
|
|
}
|
|
});
|
|
|
|
test('skips bucket when no matching page exists in source', async () => {
|
|
// Don't seed a page — entity_slug 'no-page' won't resolve.
|
|
for (let i = 0; i < 4; i++) {
|
|
await engine.executeRaw(
|
|
`INSERT INTO facts (source_id, entity_slug, fact, kind, source, valid_from, embedding, embedded_at)
|
|
VALUES ('default', 'no-page', $1, 'fact', 'test', $2::timestamptz, $3::vector, $2::timestamptz)`,
|
|
[`orphan fact ${i}`, oldDate(), unitVec()],
|
|
);
|
|
}
|
|
const r = await runPhaseConsolidate(engine, {});
|
|
// Bucket processed (passes count + age gates) but cluster skipped (no page).
|
|
expect(r.details.buckets_processed).toBeGreaterThanOrEqual(1);
|
|
expect(r.details.facts_consolidated).toBe(0);
|
|
expect(r.details.takes_written).toBe(0);
|
|
});
|
|
});
|