mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
feat(cli): GBRAIN_DRAIN_TIMEOUT_MS env override for the per-sink teardown drain budget (#2996)
The one-shot CLI teardown drains fire-and-forget background sinks with a hardcoded 2s per-sink budget (DEFAULT_DRAIN_TIMEOUT_MS). That budget assumes a sub-second cloud chat provider; on a self-hosted provider (e.g. an ollama model at 10-20s per completion) a facts:absorb extraction can never finish inside it, so every one-shot CLI exit — sync timers especially — aborts the in-flight chat with 'pipeline_error: The operation was aborted', and the same touched pages retry-and-abort on every subsequent sync. Facts from those pages silently never land, and doctor's facts_extraction_health warns permanently. Fix: resolveDrainTimeoutMs() — GBRAIN_DRAIN_TIMEOUT_MS env override (same env-only escape-hatch pattern as GBRAIN_TEARDOWN_DEADLINE_MS and GBRAIN_FLUSH_GRACE_MS) over the 2000ms default. Explicit drainTimeoutMs from a call site still wins; computeTeardownDeadlineMs already computes the backstop from the resolved value, so the deadline scales with it. Garbage/zero/negative env values fall back to the default. Tests: default, env override, garbage/zero/negative fallback, finishCliTeardown drains with the env-resolved budget, explicit opts still win over env. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
6498b872ea
commit
4528bfa79c
@@ -114,6 +114,26 @@ function resolveFlushGraceMs(): number {
|
||||
/** Default per-sink drain budget (matches drainAllBackgroundWorkForCliExit). */
|
||||
const DEFAULT_DRAIN_TIMEOUT_MS = 2_000;
|
||||
|
||||
/**
|
||||
* Resolve the per-sink drain budget: `GBRAIN_DRAIN_TIMEOUT_MS` env override
|
||||
* (slow-provider escape hatch, same env-only pattern as
|
||||
* GBRAIN_TEARDOWN_DEADLINE_MS) over the 2000ms default. An explicit
|
||||
* `drainTimeoutMs` from a call site still wins — the env replaces only the
|
||||
* DEFAULT. The 2s default assumes a sub-second cloud chat provider; a
|
||||
* self-hosted model (e.g. ollama at 10-20s per completion) can never finish a
|
||||
* fire-and-forget facts:absorb extraction inside it, so every one-shot CLI
|
||||
* exit — sync timers especially — aborts the in-flight chat and the
|
||||
* extraction never lands, retrying (and re-aborting) on each subsequent sync
|
||||
* of the same page. Raising the budget via env lets those installs drain
|
||||
* instead of abort; computeTeardownDeadlineMs already scales the backstop
|
||||
* from the resolved value, so the deadline widens with it.
|
||||
*/
|
||||
export function resolveDrainTimeoutMs(): number {
|
||||
const env = Number(process.env.GBRAIN_DRAIN_TIMEOUT_MS);
|
||||
if (Number.isFinite(env) && env > 0) return env;
|
||||
return DEFAULT_DRAIN_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backstop deadline for drain + disconnect COMBINED, computed from the bounds
|
||||
* it guards so it fires only when a component violated its own bound (#2084
|
||||
@@ -262,7 +282,10 @@ export function flushThenExit(code: number, opts: FlushThenExitOpts = {}): void
|
||||
export interface FinishCliTeardownOpts {
|
||||
/** Engine to disconnect. A disconnect throw is warned + swallowed (D3). */
|
||||
engine: { disconnect(): Promise<void> };
|
||||
/** Per-sink drain budget. Default 2000 (the registry default). */
|
||||
/**
|
||||
* Per-sink drain budget. Default: `GBRAIN_DRAIN_TIMEOUT_MS` env override,
|
||||
* else 2000 (the registry default).
|
||||
*/
|
||||
drainTimeoutMs?: number;
|
||||
/** Test seam — wins over the env override and the computed formula. */
|
||||
deadlineMs?: number;
|
||||
@@ -284,7 +307,7 @@ export interface FinishCliTeardownOpts {
|
||||
* exit in here, and it means a component violated its own bound.
|
||||
*/
|
||||
export async function finishCliTeardown(opts: FinishCliTeardownOpts): Promise<void> {
|
||||
const drainTimeoutMs = opts.drainTimeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS;
|
||||
const drainTimeoutMs = opts.drainTimeoutMs ?? resolveDrainTimeoutMs();
|
||||
const warn = opts.warn ?? ((m: string) => console.warn(m));
|
||||
const drain = opts.drain ?? drainAllBackgroundWorkForCliExit;
|
||||
const deadlineMs =
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
finishCliTeardown,
|
||||
flushThenExit,
|
||||
computeTeardownDeadlineMs,
|
||||
resolveDrainTimeoutMs,
|
||||
TEARDOWN_DEADLINE_FLOOR_MS,
|
||||
setCliExitVerdict,
|
||||
currentExitCode,
|
||||
@@ -115,6 +116,67 @@ describe('computeTeardownDeadlineMs', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDrainTimeoutMs', () => {
|
||||
test('defaults to the 2000ms registry budget', () => {
|
||||
expect(resolveDrainTimeoutMs()).toBe(2_000);
|
||||
});
|
||||
|
||||
test('GBRAIN_DRAIN_TIMEOUT_MS env override wins over the default', async () => {
|
||||
await withEnv({ GBRAIN_DRAIN_TIMEOUT_MS: '30000' }, async () => {
|
||||
expect(resolveDrainTimeoutMs()).toBe(30_000);
|
||||
});
|
||||
});
|
||||
|
||||
test('garbage, zero, and negative env values fall back to the default', async () => {
|
||||
await withEnv({ GBRAIN_DRAIN_TIMEOUT_MS: 'banana' }, async () => {
|
||||
expect(resolveDrainTimeoutMs()).toBe(2_000);
|
||||
});
|
||||
await withEnv({ GBRAIN_DRAIN_TIMEOUT_MS: '0' }, async () => {
|
||||
expect(resolveDrainTimeoutMs()).toBe(2_000);
|
||||
});
|
||||
await withEnv({ GBRAIN_DRAIN_TIMEOUT_MS: '-5' }, async () => {
|
||||
expect(resolveDrainTimeoutMs()).toBe(2_000);
|
||||
});
|
||||
});
|
||||
|
||||
test('finishCliTeardown drains with the env-resolved budget when no explicit drainTimeoutMs', async () => {
|
||||
await withEnv({ GBRAIN_DRAIN_TIMEOUT_MS: '12345' }, async () => {
|
||||
let drainBudget = -1;
|
||||
await finishCliTeardown({
|
||||
engine: { disconnect: async () => {} },
|
||||
deadlineMs: 250,
|
||||
drain: async ({ timeoutMs }) => {
|
||||
drainBudget = timeoutMs;
|
||||
},
|
||||
exit: () => {},
|
||||
warn: () => {},
|
||||
stdout: fakeStream(),
|
||||
stderr: fakeStream(),
|
||||
});
|
||||
expect(drainBudget).toBe(12_345);
|
||||
});
|
||||
});
|
||||
|
||||
test('an explicit drainTimeoutMs still wins over the env override', async () => {
|
||||
await withEnv({ GBRAIN_DRAIN_TIMEOUT_MS: '12345' }, async () => {
|
||||
let drainBudget = -1;
|
||||
await finishCliTeardown({
|
||||
engine: { disconnect: async () => {} },
|
||||
drainTimeoutMs: 777,
|
||||
deadlineMs: 250,
|
||||
drain: async ({ timeoutMs }) => {
|
||||
drainBudget = timeoutMs;
|
||||
},
|
||||
exit: () => {},
|
||||
warn: () => {},
|
||||
stdout: fakeStream(),
|
||||
stderr: fakeStream(),
|
||||
});
|
||||
expect(drainBudget).toBe(777);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('finishCliTeardown — clean path', () => {
|
||||
test('drains with the injected budget, disconnects, returns; no exit, no warn', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
Reference in New Issue
Block a user