mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 14:59:47 +00:00
* fix(core): drain fire-and-forget sinks before disconnect via a background-work registry (#1762) New src/core/background-work.ts registry (Map<name,drainer>, ordered drain, awaited abort). facts-queue (order 0, abort=shutdown), last-retrieved (1), and eval-capture (3, now self-tracked) register as sinks. Both CLI exit paths (op-dispatch finally + handleCliOnly finally) drain the registry before engine.disconnect() so a PGLite db.close() can't race in-flight work into the re-pump busy-loop that pinned the single-writer lock. Op-dispatch error path converts process.exit(1) to exitCode+return so the finally still drains. * fix(ai): bound every outbound AI call so a stalled provider can't hang (#1762/#1775) withDefaultTimeout composes a per-touchpoint default deadline (chat 300s, embed/multimodal 60s) with any caller signal via AbortSignal.any. Applied at the SDK call layer (chat generateText, expand generateObject, OCR, per-sub-batch embed) — covers native-anthropic + retries — plus per-request multimodal fetch. embedQuery forwards abortSignal. Env: GBRAIN_AI_{CHAT,EMBED,MULTIMODAL}_TIMEOUT_MS. * fix(postgres): module-mode reconnect preserves the shared singleton (#1745) reconnect() branches on connection style. Module-singleton engines re-establish idempotently via db.connect() (no-op when alive) + refresh the ConnectionManager read pool, never db.disconnect() — so a transient blip no longer nulls the shared sql out from under concurrent ops (which threw 'connect() has not been called'). Fail-loud on real connect failure. Instance pools keep teardown+recreate. * fix(search): bound the query-time embed so a stall falls back to keyword (#1775) search/query default to cheap-hybrid (embeds the query); a stalled provider made the embed never settle, so the keyword fallback never engaged and the command force-exited with no output. One shared QueryEmbedDeadline (6s, floored 2s per embed) covers both the cache-lookup and inner embeds via embedQueryBounded (abortSignal + Promise.race) → existing keyword fallback engages. Also registers the search-cache background-work drainer (now bounded). Env: GBRAIN_QUERY_EMBED_TIMEOUT_MS. * test+chore: reliability wave tests + v0.42.11.0 (#1762 #1745 #1775) New: background-work registry unit, query-embed deadline unit, eval-capture drain unit, postgres reconnect E2E (#1745), gbrain capture exit-clean case in the PGLite serial test. Updated fix-wave-structural assertions to the registry shape. VERSION/package.json/CHANGELOG -> 0.42.11.0; TODOS retrofit marked done. Incorporates + hardens PR #1763 (drain-before-disconnect + embed fetch timeout); the residual hung-Haiku hole is closed by the facts shutdown() abort belt. Co-Authored-By: ElliotDrel <noreply@github.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: document background-work registry + v0.42.11.0 reliability wave in CLAUDE.md (regen llms) * chore: bump release version 0.42.11.0 -> 0.42.20.0 Rename the reliability-wave release version per request. Trio (VERSION / package.json / CHANGELOG) reconciled; in-code version-tag comments and test fixtures updated; llms regenerated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: ElliotDrel <noreply@github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
137 lines
5.2 KiB
TypeScript
137 lines
5.2 KiB
TypeScript
/**
|
|
* v0.42.20.0 (#1762) — background-work registry unit tests.
|
|
*
|
|
* Pure, no DB / no engine: drives the registry via the __registerDrainerForTest
|
|
* seam. Pins the contracts the reliability wave depends on:
|
|
* - drains in explicit (order, name) order — facts (0) first
|
|
* - a drainer reporting unfinished>0 has its abort() AWAITED
|
|
* - a throwing drainer doesn't block the others
|
|
* - empty registry is a fast no-op
|
|
* - Map idempotency: re-registering the same name REPLACES (no duplicate)
|
|
* - the unregister handle removes it
|
|
*/
|
|
import { describe, test, expect } from 'bun:test';
|
|
import {
|
|
drainAllBackgroundWorkForCliExit,
|
|
__registerDrainerForTest,
|
|
__listDrainerNamesForTest,
|
|
type BackgroundWorkDrainer,
|
|
} from '../../src/core/background-work.ts';
|
|
|
|
function makeRecorder() {
|
|
const calls: string[] = [];
|
|
return { calls };
|
|
}
|
|
|
|
describe('background-work registry', () => {
|
|
test('drains in explicit (order, name) order; facts-order-0 first', async () => {
|
|
const { calls } = makeRecorder();
|
|
const mk = (name: string, order: number): [BackgroundWorkDrainer, () => void] => {
|
|
const d: BackgroundWorkDrainer = {
|
|
name,
|
|
order,
|
|
drain: async () => { calls.push(`drain:${name}`); return { unfinished: 0 }; },
|
|
};
|
|
return [d, __registerDrainerForTest(d)];
|
|
};
|
|
const [, u1] = mk('zzz', 2);
|
|
const [, u0] = mk('facts', 0);
|
|
const [, u15] = mk('mid', 1);
|
|
try {
|
|
await drainAllBackgroundWorkForCliExit({ timeoutMs: 50 });
|
|
// Only assert ordering among the ones we registered (production sinks may
|
|
// also be registered when their modules were imported).
|
|
const ours = calls.filter((c) => ['drain:facts', 'drain:mid', 'drain:zzz'].includes(c));
|
|
expect(ours).toEqual(['drain:facts', 'drain:mid', 'drain:zzz']);
|
|
} finally {
|
|
u0(); u15(); u1();
|
|
}
|
|
});
|
|
|
|
test('abort() is AWAITED only when unfinished>0', async () => {
|
|
const seq: string[] = [];
|
|
const withUnfinished: BackgroundWorkDrainer = {
|
|
name: 'test-straggler',
|
|
order: 0,
|
|
drain: async () => { seq.push('drain'); return { unfinished: 3 }; },
|
|
abort: async () => {
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
seq.push('abort-done');
|
|
},
|
|
};
|
|
const noUnfinished: BackgroundWorkDrainer = {
|
|
name: 'test-clean',
|
|
order: 1,
|
|
drain: async () => { seq.push('drain-clean'); return { unfinished: 0 }; },
|
|
abort: async () => { seq.push('abort-clean-SHOULD-NOT-RUN'); },
|
|
};
|
|
const u1 = __registerDrainerForTest(withUnfinished);
|
|
const u2 = __registerDrainerForTest(noUnfinished);
|
|
try {
|
|
await drainAllBackgroundWorkForCliExit({ timeoutMs: 50 });
|
|
// straggler: drain then abort (awaited → abort-done present); clean: no abort.
|
|
expect(seq).toContain('drain');
|
|
expect(seq).toContain('abort-done');
|
|
expect(seq).toContain('drain-clean');
|
|
expect(seq).not.toContain('abort-clean-SHOULD-NOT-RUN');
|
|
// abort-done comes after its own drain (awaited).
|
|
expect(seq.indexOf('abort-done')).toBeGreaterThan(seq.indexOf('drain'));
|
|
} finally {
|
|
u1(); u2();
|
|
}
|
|
});
|
|
|
|
test('a throwing drainer does not block the others', async () => {
|
|
const seen: string[] = [];
|
|
const boom: BackgroundWorkDrainer = {
|
|
name: 'test-boom',
|
|
order: 0,
|
|
drain: async () => { throw new Error('drain blew up'); },
|
|
};
|
|
const ok: BackgroundWorkDrainer = {
|
|
name: 'test-ok-after-boom',
|
|
order: 1,
|
|
drain: async () => { seen.push('ok'); return { unfinished: 0 }; },
|
|
};
|
|
const u1 = __registerDrainerForTest(boom);
|
|
const u2 = __registerDrainerForTest(ok);
|
|
try {
|
|
// Must not reject despite the throwing drainer.
|
|
await drainAllBackgroundWorkForCliExit({ timeoutMs: 50 });
|
|
expect(seen).toContain('ok');
|
|
} finally {
|
|
u1(); u2();
|
|
}
|
|
});
|
|
|
|
test('Map idempotency: re-registering the same name replaces, no duplicate', () => {
|
|
const before = __listDrainerNamesForTest().filter((n) => n === 'test-dup').length;
|
|
expect(before).toBe(0);
|
|
const d1: BackgroundWorkDrainer = { name: 'test-dup', order: 0, drain: async () => ({ unfinished: 0 }) };
|
|
const d2: BackgroundWorkDrainer = { name: 'test-dup', order: 9, drain: async () => ({ unfinished: 0 }) };
|
|
const u1 = __registerDrainerForTest(d1);
|
|
const u2 = __registerDrainerForTest(d2);
|
|
try {
|
|
const count = __listDrainerNamesForTest().filter((n) => n === 'test-dup').length;
|
|
expect(count).toBe(1); // replaced, not duplicated
|
|
} finally {
|
|
u1(); u2();
|
|
}
|
|
});
|
|
|
|
test('unregister handle removes the drainer', () => {
|
|
const d: BackgroundWorkDrainer = { name: 'test-unreg', order: 0, drain: async () => ({ unfinished: 0 }) };
|
|
const unreg = __registerDrainerForTest(d);
|
|
expect(__listDrainerNamesForTest()).toContain('test-unreg');
|
|
unreg();
|
|
expect(__listDrainerNamesForTest()).not.toContain('test-unreg');
|
|
});
|
|
|
|
test('empty registry (no test drainers) resolves fast', async () => {
|
|
// Production sinks may be registered, but they fast-path on empty pending
|
|
// sets. This just asserts the call resolves without throwing.
|
|
await drainAllBackgroundWorkForCliExit({ timeoutMs: 10 });
|
|
expect(true).toBe(true);
|
|
});
|
|
});
|