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>
47 lines
2.2 KiB
TypeScript
47 lines
2.2 KiB
TypeScript
/**
|
|
* v0.40 D16 regression test: phantom-redirect uses per-source lock.
|
|
*
|
|
* Pre-v0.40 phantom-redirect acquired the bare `gbrain-sync` lock, blocking
|
|
* every concurrent sync brain-wide. After D16, phantom acquires
|
|
* `gbrain-sync:<sourceId>` — same-source sync still serializes; cross-source
|
|
* sync proceeds unblocked.
|
|
*
|
|
* Pure source-text regression guard. The full behavior is covered by
|
|
* `test/e2e/phantom-redirect.test.ts` (DATABASE_URL-gated). This file pins
|
|
* the lock-id construction so any future drift back to the bare constant
|
|
* fails loudly in the fast unit loop.
|
|
*/
|
|
import { describe, test, expect } from 'bun:test';
|
|
import { readFileSync } from 'node:fs';
|
|
import { syncLockId } from '../src/core/db-lock.ts';
|
|
|
|
const SRC = readFileSync('src/core/cycle/phantom-redirect.ts', 'utf8');
|
|
|
|
describe('phantom-redirect lock contract', () => {
|
|
test('IRON-RULE: import line uses syncLockId, not bare SYNC_LOCK_ID', () => {
|
|
// syncLockId must be imported; SYNC_LOCK_ID must NOT be (per-source posture).
|
|
const importLine = SRC.split('\n').find((l) =>
|
|
l.includes("from '../db-lock.ts'") && l.includes('import'),
|
|
);
|
|
expect(importLine).toBeDefined();
|
|
expect(importLine).toContain('syncLockId');
|
|
expect(importLine).not.toMatch(/\bSYNC_LOCK_ID\b/);
|
|
});
|
|
|
|
test('IRON-RULE: acquireLockWithRetry call passes syncLockId(sourceId)', () => {
|
|
// Banned: the bare `SYNC_LOCK_ID` constant slipped back in.
|
|
// Required: the per-source helper threaded with the active sourceId.
|
|
// #1972: the call now threads an optional abort signal as a third arg
|
|
// (acquireLockWithRetry(engine, syncLockId(sourceId), signal)). The IRON-RULE
|
|
// is unchanged: it must pass the per-source syncLockId(sourceId), never bare
|
|
// SYNC_LOCK_ID. Allow the optional trailing signal arg.
|
|
expect(SRC).toMatch(/acquireLockWithRetry\s*\(\s*engine\s*,\s*syncLockId\s*\(\s*sourceId\s*\)\s*(?:,\s*signal\s*)?\)/);
|
|
expect(SRC).not.toMatch(/acquireLockWithRetry\s*\(\s*engine\s*,\s*SYNC_LOCK_ID\s*\)/);
|
|
});
|
|
|
|
test('helper sanity: syncLockId returns gbrain-sync:<source> shape', () => {
|
|
expect(syncLockId('default')).toBe('gbrain-sync:default');
|
|
expect(syncLockId('zion-brain')).toBe('gbrain-sync:zion-brain');
|
|
});
|
|
});
|