mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +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>
86 lines
3.5 KiB
TypeScript
86 lines
3.5 KiB
TypeScript
/**
|
|
* v0.42.20.0 (Fix 3, #1775) — query-embed deadline unit tests.
|
|
*
|
|
* The regression: `search`/`query` default to cheap-hybrid, which embeds the
|
|
* query. A stalled embedding provider made the embed `await` never settle, so
|
|
* the handler never reached the keyword fallback and the CLI force-exited at 10s
|
|
* with no output. `embedQueryBounded` bounds the embed so it THROWS on timeout
|
|
* → the caller's existing try/catch falls back to keyword.
|
|
*
|
|
* These tests prove the bound fires even when the transport IGNORES the
|
|
* abortSignal (the Promise.race guarantee — codex #3: abortSignal alone is
|
|
* insufficient against a wedged provider), and that a shared/elapsed deadline
|
|
* makes a second embed fail FAST (worst case ~one timeout, not two).
|
|
*/
|
|
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
import {
|
|
configureGateway,
|
|
resetGateway,
|
|
__setEmbedTransportForTests,
|
|
} from '../../src/core/ai/gateway.ts';
|
|
import { embedQueryBounded, makeQueryEmbedDeadline } from '../../src/core/search/hybrid.ts';
|
|
|
|
describe('embedQueryBounded — query-embed deadline', () => {
|
|
beforeEach(() => {
|
|
resetGateway();
|
|
configureGateway({
|
|
embedding_model: 'voyage:voyage-4-large',
|
|
embedding_dimensions: 1024,
|
|
env: { VOYAGE_API_KEY: 'voyage-fake' },
|
|
});
|
|
});
|
|
afterEach(() => {
|
|
__setEmbedTransportForTests(null);
|
|
resetGateway();
|
|
});
|
|
|
|
test('rejects within the budget when the transport hangs (ignores abort)', async () => {
|
|
// Transport never resolves AND ignores the abort signal — only the
|
|
// Promise.race deadline can save us.
|
|
__setEmbedTransportForTests(() => new Promise(() => { /* hang forever */ }));
|
|
const dl = makeQueryEmbedDeadline(200);
|
|
const start = Date.now();
|
|
let threw = false;
|
|
try {
|
|
await embedQueryBounded('locker code', undefined, dl);
|
|
} catch (e) {
|
|
threw = true;
|
|
expect(String((e as Error).message)).toContain('deadline');
|
|
}
|
|
const elapsed = Date.now() - start;
|
|
expect(threw).toBe(true);
|
|
// The 200ms deadline is floored to MIN_QUERY_EMBED_BUDGET_MS (2s) — the bound
|
|
// still fires (not infinite hang), comfortably under the 10s CLI force-exit.
|
|
expect(elapsed).toBeLessThan(3000);
|
|
});
|
|
|
|
test('an already-elapsed shared deadline is floored, not fresh-6s (codex floor)', async () => {
|
|
__setEmbedTransportForTests(() => new Promise(() => { /* hang forever */ }));
|
|
// Simulate the inner embed reusing a deadline the cache-lookup already spent.
|
|
const dl = { signal: AbortSignal.timeout(1), deadlineAt: Date.now() - 5 };
|
|
const start = Date.now();
|
|
let threw = false;
|
|
try {
|
|
await embedQueryBounded('q', undefined, dl);
|
|
} catch {
|
|
threw = true;
|
|
}
|
|
const elapsed = Date.now() - start;
|
|
expect(threw).toBe(true);
|
|
// Floored to MIN_QUERY_EMBED_BUDGET_MS (2s) — NOT a fresh 6s (would blow the
|
|
// cached-path total past the 10s force-exit) and NOT ~0 (would starve a
|
|
// healthy inner embed). So: rejects after ~2s, comfortably under 6s.
|
|
expect(elapsed).toBeGreaterThanOrEqual(1800);
|
|
expect(elapsed).toBeLessThan(3500);
|
|
});
|
|
|
|
test('resolves with the embedding when the transport returns in time', async () => {
|
|
const vec = Array.from({ length: 1024 }, () => 0.1);
|
|
__setEmbedTransportForTests(async () => ({ embeddings: [vec], usage: { tokens: 1 } }) as any);
|
|
const dl = makeQueryEmbedDeadline(2000);
|
|
const out = await embedQueryBounded('q', undefined, dl);
|
|
expect(out).toBeInstanceOf(Float32Array);
|
|
expect(out.length).toBe(1024);
|
|
});
|
|
});
|