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:
Nazim22
2026-07-20 13:00:16 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 6498b872ea
commit 4528bfa79c
2 changed files with 87 additions and 2 deletions
+25 -2
View File
@@ -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 =
+62
View File
@@ -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[] = [];