feat(pace): composable DB-contention pacer primitive

createDbPacer/createNoopPacer (concurrency permit + in-band EWMA + jittered
cooperative sleep, abort-throws, fail-open) + named pace-mode bundles
(env>config>bundle, default off) + shared embed-backfill lock key.
This commit is contained in:
Garry Tan
2026-06-16 22:08:42 -07:00
parent 4ee530f3c5
commit e96abf32a8
5 changed files with 929 additions and 0 deletions
+304
View File
@@ -0,0 +1,304 @@
/**
* db-pacer.ts — composable DB-contention pacing primitive (paced-backfill internals).
*
* gbrain's bulk write paths (embed --stale, sync) can saturate a PgBouncer
* transaction-mode pooler and starve the minion supervisor's lock renewals.
* An operator's external SIGSTOP/SIGCONT wrapper proved the idea but was blind
* (out-of-band probe pool), unsafe (froze the child mid-transaction), and
* couldn't touch PEAK pressure (it paused between batches while N workers were
* already in flight).
*
* This primitive fixes all three:
* - CONCURRENCY CAP is the real lever. `acquire()` is a counting semaphore
* that bounds simultaneous in-flight DB writes — directly limiting pooler
* slots held at once. (Single-pool callers like embed instead lower their
* worker count to `maxConcurrency`; the permit exists for the MULTI-pool
* case — sync's separate engine per worker — where one budget must span
* pools a single worker-count can't.)
* - IN-BAND latency is the signal. `observe(ms)` feeds an EWMA from the work's
* OWN queries — it can never be blind the way a separate probe pool was.
* - COOPERATIVE sleep, never SIGSTOP. `pace()` sleeps between safe points on
* `setTimeout` (timers phase), so the lock-heartbeat `setInterval` keeps
* firing. Per-call jitter prevents a 20-worker thundering-herd resume.
*
* Contracts:
* - `acquire()` / `pace()` THROW `AbortError` if the signal fires while
* waiting — a cancel can never fall through into a DB call.
* - FAIL-OPEN: any unexpected internal error degrades to a no-op; a pacer bug
* must never kill a backfill, and nothing here throws an unhandledRejection
* (the failure class behind the prior #1972 lock-renewal crash).
* - `off` mode / PGLite → `createNoopPacer()`: unbounded permits, zero sleeps.
*/
import { AbortError, anySignal } from './abort-check.ts';
import type { PaceBundle } from './pace-mode.ts';
/** Snapshot of pacer state for telemetry + tests. */
export interface PaceSnapshot {
enabled: boolean;
maxConcurrency: number;
/** Permits currently held. */
active: number;
/** EWMA of observed DB-op latency (ms); null until the first sample. */
ewmaMs: number | null;
/** Cumulative ms spent in cooperative sleeps. */
totalSleptMs: number;
/** Number of `pace()` calls that actually slept. */
sleepCount: number;
/** High-water mark of waiters blocked in `acquire()`. */
maxWaiters: number;
/** Count of `observe()` samples folded into the EWMA. */
sampleCount: number;
}
/** A held permit. `release()` is idempotent. */
export interface Permit {
release(): void;
}
export interface DbPacer {
/**
* Acquire a DB-write permit (caps simultaneous in-flight writes to
* `maxConcurrency`). THROWS `AbortError` if `signal` fires while waiting.
* On a disabled pacer, resolves immediately with a no-op permit.
*/
acquire(signal?: AbortSignal): Promise<Permit>;
/** Feed an observed DB-op latency sample (ms). NaN / negative is ignored. */
observe(latencyMs: number): void;
/**
* Cooperative sleep when recent latency is high (EWMA > `paceAtMs`), jittered
* per call and capped at `maxSleepMs`. No-op when latency is fine. THROWS
* `AbortError` if `signal` fires during the sleep.
*/
pace(signal?: AbortSignal): Promise<void>;
snapshot(): PaceSnapshot;
/** Stop accepting waiters and release any blocked acquirers. Idempotent. */
dispose(): void;
}
/** Test/impl seams. Production defaults read real timers + Math.random. */
export interface DbPacerSeams {
/** Sleep `ms`, rejecting `AbortError` if `signal` fires first. */
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
/** Jitter source in [0, 1). */
rng?: () => number;
}
export interface CreateDbPacerOpts extends DbPacerSeams {
bundle: PaceBundle;
}
interface Waiter {
resolve: (p: Permit) => void;
reject: (e: unknown) => void;
onAbort?: () => void;
cleanup?: () => void;
signal?: AbortSignal;
}
/**
* Default abortable sleep. Resolves after `ms`; rejects `AbortError` if the
* signal fires first. `ms <= 0` resolves on the next microtask.
*/
function defaultSleep(ms: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted) return Promise.reject(new AbortError(abortReason(signal)));
if (!(ms > 0)) return Promise.resolve();
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
resolve();
}, ms);
// Don't keep the event loop alive purely for a pacing sleep.
(timer as { unref?: () => void }).unref?.();
const onAbort = () => {
cleanup();
reject(new AbortError(abortReason(signal)));
};
const cleanup = () => {
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
};
signal?.addEventListener('abort', onAbort, { once: true });
});
}
function abortReason(signal?: AbortSignal | null): string {
const r = signal?.reason;
if (r instanceof Error) return r.message;
return String(r ?? 'aborted');
}
const NOOP_PERMIT: Permit = { release() {} };
/** No-op pacer: unbounded concurrency, zero sleeps. For `off` mode / PGLite. */
export function createNoopPacer(): DbPacer {
return {
acquire: () => Promise.resolve(NOOP_PERMIT),
observe: () => {},
pace: () => Promise.resolve(),
snapshot: () => ({
enabled: false,
maxConcurrency: 0,
active: 0,
ewmaMs: null,
totalSleptMs: 0,
sleepCount: 0,
maxWaiters: 0,
sampleCount: 0,
}),
dispose: () => {},
};
}
export function createDbPacer(opts: CreateDbPacerOpts): DbPacer {
const { bundle } = opts;
if (!bundle.enabled) return createNoopPacer();
const max = Math.max(1, Math.floor(bundle.maxConcurrency));
const paceAtMs = Math.max(0, bundle.paceAtMs);
const maxSleepMs = Math.max(0, bundle.maxSleepMs);
const alpha = bundle.ewmaAlpha > 0 && bundle.ewmaAlpha <= 1 ? bundle.ewmaAlpha : 0.3;
const sleep = opts.sleep ?? defaultSleep;
const rng = opts.rng ?? Math.random;
let active = 0;
let disposed = false;
let ewma: number | null = null;
let totalSleptMs = 0;
let sleepCount = 0;
let maxWaiters = 0;
let sampleCount = 0;
const waiters: Waiter[] = [];
function makePermit(): Permit {
let released = false;
return {
release() {
if (released) return;
released = true;
// Hand the slot to the next waiter without dropping `active`, so the
// cap is never momentarily exceeded.
const next = waiters.shift();
if (next) {
next.cleanup?.();
next.resolve(makePermit());
} else {
active = Math.max(0, active - 1);
}
},
};
}
async function acquire(signal?: AbortSignal): Promise<Permit> {
try {
if (signal?.aborted) throw new AbortError(abortReason(signal));
if (disposed) return NOOP_PERMIT;
if (active < max) {
active++;
return makePermit();
}
return await new Promise<Permit>((resolve, reject) => {
const waiter: Waiter = { resolve, reject, signal };
const onAbort = () => {
const i = waiters.indexOf(waiter);
if (i >= 0) waiters.splice(i, 1);
reject(new AbortError(abortReason(signal)));
};
waiter.onAbort = onAbort;
waiter.cleanup = () => signal?.removeEventListener('abort', onAbort);
signal?.addEventListener('abort', onAbort, { once: true });
waiters.push(waiter);
if (waiters.length > maxWaiters) maxWaiters = waiters.length;
});
} catch (err) {
// Abort must propagate so the loop can't fall into a DB call.
if (err instanceof AbortError) throw err;
// Anything else: fail-open (a pacer bug must not kill the backfill).
return NOOP_PERMIT;
}
}
function observe(latencyMs: number): void {
try {
if (typeof latencyMs !== 'number' || !Number.isFinite(latencyMs) || latencyMs < 0) return;
ewma = ewma === null ? latencyMs : alpha * latencyMs + (1 - alpha) * ewma;
sampleCount++;
} catch {
/* fail-open */
}
}
async function pace(signal?: AbortSignal): Promise<void> {
let ms = 0;
try {
if (ewma === null || ewma <= paceAtMs || maxSleepMs <= 0) return;
const base = Math.min(maxSleepMs, ewma);
// Jitter to 50-100% of base so N workers don't resume in lockstep.
const jitter = 0.5 + 0.5 * clamp01(rng());
ms = Math.round(base * jitter);
if (ms <= 0) return;
} catch {
return; // fail-open: never let knob math kill the loop
}
try {
await sleep(ms, signal);
totalSleptMs += ms;
sleepCount++;
} catch (err) {
// Abort must propagate (a cancel can't be swallowed); any other sleep
// failure is fail-open — a pacer bug never kills the backfill.
if (err instanceof AbortError) throw err;
}
}
function snapshot(): PaceSnapshot {
return {
enabled: true,
maxConcurrency: max,
active,
ewmaMs: ewma,
totalSleptMs,
sleepCount,
maxWaiters,
sampleCount,
};
}
function dispose(): void {
if (disposed) return;
disposed = true;
// Release blocked acquirers with no-op permits so their loops end cleanly
// rather than hanging forever.
while (waiters.length > 0) {
const w = waiters.shift();
w?.cleanup?.();
w?.resolve(NOOP_PERMIT);
}
}
return { acquire, observe, pace, snapshot, dispose };
}
function clamp01(n: number): number {
if (!Number.isFinite(n)) return 0;
if (n < 0) return 0;
if (n > 1) return 1;
return n;
}
/**
* Convenience: time a DB op and feed its latency to the pacer. Keeps call sites
* a one-liner — `await observed(pacer, () => engine.upsertChunks(...))`.
*/
export async function observed<T>(pacer: DbPacer, fn: () => Promise<T>): Promise<T> {
const t0 = Date.now();
try {
return await fn();
} finally {
pacer.observe(Date.now() - t0);
}
}
// anySignal is re-exported intentionally so call sites that compose a budget
// signal with the pacer can import from one place.
export { anySignal };
+17
View File
@@ -0,0 +1,17 @@
/**
* Shared lock identity for embed backfills (paced-backfill E-2).
*
* The per-source lock key + TTL live here, in a zero-dependency module, so BOTH
* the `embed-backfill` minion handler AND the CLI `embed --stale` single-flight
* take the SAME key — a hand-run backfill and a queued job are then mutually
* exclusive per source. Kept dependency-free to avoid an import cycle between
* embed.ts, embed-stale.ts, and the handler.
*/
/** Per-source embed-backfill lock id, namespaced like sync's. */
export function embedBackfillLockId(sourceId: string): string {
return `gbrain-embed-backfill:${sourceId}`;
}
/** Lock TTL (minutes) for embed backfills. */
export const EMBED_BACKFILL_LOCK_TTL_MIN = 60;
+286
View File
@@ -0,0 +1,286 @@
/**
* DB-contention pacing mode bundles (paced-backfill internals).
*
* Named modes that bundle the DB-pacer knobs into a single config key so an
* operator picks once and stops thinking about it. Mirrors the v0.32.3
* search-mode pattern at `src/core/search/mode.ts` (named bundle + per-key
* overrides + per-call opts), with ONE deliberate difference: the resolution
* chain puts ENV ABOVE CONFIG so `GBRAIN_PACE_*` is a real incident escape
* hatch — an operator can override bad production config without a redeploy.
*
* per-call flag → GBRAIN_PACE_* env → config (pace.*) → MODE_BUNDLES[mode] → off
*
* `DEFAULT_PACE_MODE` is `'off'`: pacing is strictly opt-in and never changes
* default behavior. `off` resolves to `enabled: false`, which the DB-pacer
* turns into a no-op (unbounded concurrency, zero sleeps).
*
* This module is PURE — no DB calls, no env reads inside `resolvePaceMode`.
* The caller pre-loads config (`loadPaceModeConfig`) and env (`readPaceEnv`)
* and passes them in, so the resolver is trivially testable.
*/
export type PaceMode = 'off' | 'gentle' | 'balanced' | 'aggressive';
export const PACE_MODES: ReadonlyArray<PaceMode> = Object.freeze([
'off',
'gentle',
'balanced',
'aggressive',
]);
export const DEFAULT_PACE_MODE: PaceMode = 'off';
/**
* A complete knob set for one pace mode. Every field is required so the bundle
* is self-contained and per-key overrides are obvious diffs.
*/
export interface PaceBundle {
/** Master switch. `false` ⇒ the DB-pacer is a no-op (off mode / PGLite). */
enabled: boolean;
/**
* Primary lever: cap on simultaneous in-flight DB writes. For single-pool
* paths (embed) this becomes the worker count; for multi-pool paths (sync)
* it's enforced by the shared `acquire()` permit. The real defense against
* pooler-slot starvation.
*/
maxConcurrency: number;
/** EWMA of observed DB-op latency (ms) above which `pace()` starts sleeping. */
paceAtMs: number;
/** Ceiling on a single cooperative sleep (ms). Jittered downward per call. */
maxSleepMs: number;
/** EWMA smoothing factor in (0, 1]. Higher = reacts faster to recent latency. */
ewmaAlpha: number;
}
/**
* The four bundles. Frozen at import so a typo can't redefine "balanced" to
* mean different things on different installs. `off` is the disabled sentinel;
* its numeric fields are inert (the pacer short-circuits on `enabled: false`).
*
* Concurrency picks are deliberately well below the default embed concurrency
* (`GBRAIN_EMBED_CONCURRENCY`, 20) — the whole point is to hold fewer pooler
* slots than an unpaced run.
*/
export const PACE_BUNDLES: Readonly<Record<PaceMode, Readonly<PaceBundle>>> = Object.freeze({
off: Object.freeze({
enabled: false,
maxConcurrency: 0,
paceAtMs: 0,
maxSleepMs: 0,
ewmaAlpha: 0,
}),
gentle: Object.freeze({
enabled: true,
maxConcurrency: 4,
paceAtMs: 250,
maxSleepMs: 2000,
ewmaAlpha: 0.3,
}),
balanced: Object.freeze({
enabled: true,
maxConcurrency: 8,
paceAtMs: 500,
maxSleepMs: 1500,
ewmaAlpha: 0.3,
}),
aggressive: Object.freeze({
enabled: true,
maxConcurrency: 16,
paceAtMs: 1000,
maxSleepMs: 1000,
ewmaAlpha: 0.3,
}),
});
export function isPaceMode(x: unknown): x is PaceMode {
return typeof x === 'string' && (PACE_MODES as ReadonlyArray<string>).includes(x);
}
/**
* Per-key overrides (from the config table OR from env). Every field optional;
* undefined ⇒ fall through to the next precedence layer.
*/
export interface PaceKeyOverrides {
enabled?: boolean;
maxConcurrency?: number;
paceAtMs?: number;
maxSleepMs?: number;
ewmaAlpha?: number;
}
/**
* Resolve the active pace knob set. Pure: the caller supplies the resolved
* config + env layers.
*
* Mode precedence: perCallMode → envMode → mode(config) → DEFAULT_PACE_MODE
* Knob precedence: perCall → env → config → MODE_BUNDLES[mode]
*
* Env above config is intentional (incident escape hatch — see file header).
*/
export interface ResolvePaceModeInput {
/** `config.pace.mode`. */
mode?: string;
/** `GBRAIN_PACE_MODE`. */
envMode?: string;
/** `--pace=<mode>` (or bare `--pace` resolved to 'balanced' by the caller). */
perCallMode?: string;
/** Per-key overrides from the config table. */
configOverrides?: PaceKeyOverrides;
/** Per-key overrides from `GBRAIN_PACE_*` env. */
envOverrides?: PaceKeyOverrides;
/** Per-call overrides (e.g. `--pace-max-concurrency`). */
perCall?: PaceKeyOverrides;
}
export interface ResolvedPaceKnobs extends PaceBundle {
/** Which bundle supplied the defaults (after fallback). */
resolved_mode: PaceMode;
/** True if the resolved mode string was a recognized PaceMode. */
mode_valid: boolean;
}
export function resolvePaceMode(input: ResolvePaceModeInput): ResolvedPaceKnobs {
const rawMode =
firstString(input.perCallMode) ?? firstString(input.envMode) ?? firstString(input.mode);
const normalized = rawMode ? rawMode.trim().toLowerCase() : '';
const valid = isPaceMode(normalized);
const resolved_mode: PaceMode = valid ? (normalized as PaceMode) : DEFAULT_PACE_MODE;
const bundle = PACE_BUNDLES[resolved_mode];
const pc = input.perCall ?? {};
const env = input.envOverrides ?? {};
const cfg = input.configOverrides ?? {};
const pick = <K extends keyof PaceBundle>(key: K): PaceBundle[K] => {
if (pc[key] !== undefined) return pc[key] as PaceBundle[K];
if (env[key] !== undefined) return env[key] as PaceBundle[K];
if (cfg[key] !== undefined) return cfg[key] as PaceBundle[K];
return bundle[key];
};
const enabled = pick('enabled');
// Clamp to safe ranges so a fat-fingered override can't disable the cap
// (maxConcurrency must be >= 1 when enabled) or wedge the EWMA.
let maxConcurrency = pick('maxConcurrency');
if (enabled && (!Number.isFinite(maxConcurrency) || maxConcurrency < 1)) {
maxConcurrency = bundle.enabled ? bundle.maxConcurrency : 8;
}
let ewmaAlpha = pick('ewmaAlpha');
if (!Number.isFinite(ewmaAlpha) || ewmaAlpha <= 0 || ewmaAlpha > 1) {
ewmaAlpha = bundle.enabled ? bundle.ewmaAlpha : 0.3;
}
const paceAtMs = Math.max(0, pick('paceAtMs'));
const maxSleepMs = Math.max(0, pick('maxSleepMs'));
return {
enabled,
maxConcurrency,
paceAtMs,
maxSleepMs,
ewmaAlpha,
resolved_mode,
mode_valid: valid,
};
}
function firstString(v: unknown): string | undefined {
return typeof v === 'string' && v.trim().length > 0 ? v : undefined;
}
/** Config-table key for the mode selection (separate from the override keys). */
export const PACE_MODE_KEY = 'pace.mode';
/** Per-knob config keys this module reads. Used by a future `--reset`. */
export const PACE_MODE_CONFIG_KEYS: ReadonlyArray<string> = Object.freeze([
'pace.enabled',
'pace.max_concurrency',
'pace.pace_at_ms',
'pace.max_sleep_ms',
'pace.ewma_alpha',
]);
/** Build PaceKeyOverrides from a flat config-table snapshot (sparse). */
export function loadOverridesFromConfig(
configMap: Record<string, string | undefined>,
): PaceKeyOverrides {
return parseOverrides((k) => configMap[k], {
enabled: 'pace.enabled',
maxConcurrency: 'pace.max_concurrency',
paceAtMs: 'pace.pace_at_ms',
maxSleepMs: 'pace.max_sleep_ms',
ewmaAlpha: 'pace.ewma_alpha',
});
}
/** Build PaceKeyOverrides from `GBRAIN_PACE_*` env (incident escape hatch). */
export function readPaceEnv(env: Record<string, string | undefined> = process.env): {
envMode?: string;
envOverrides: PaceKeyOverrides;
} {
const overrides = parseOverrides((k) => env[k], {
enabled: 'GBRAIN_PACE_ENABLED',
maxConcurrency: 'GBRAIN_PACE_MAX_CONCURRENCY',
paceAtMs: 'GBRAIN_PACE_AT_MS',
maxSleepMs: 'GBRAIN_PACE_MAX_SLEEP_MS',
ewmaAlpha: 'GBRAIN_PACE_EWMA_ALPHA',
});
return { envMode: env.GBRAIN_PACE_MODE, envOverrides: overrides };
}
function parseOverrides(
get: (k: string) => string | undefined,
keys: Record<keyof PaceKeyOverrides, string>,
): PaceKeyOverrides {
const out: PaceKeyOverrides = {};
const en = get(keys.enabled);
if (en !== undefined) out.enabled = en === '1' || en.toLowerCase() === 'true';
const mc = get(keys.maxConcurrency);
if (mc !== undefined) {
const n = parseInt(mc, 10);
if (Number.isFinite(n) && n >= 1 && n <= 256) out.maxConcurrency = n;
}
const pa = get(keys.paceAtMs);
if (pa !== undefined) {
const n = parseInt(pa, 10);
if (Number.isFinite(n) && n >= 0) out.paceAtMs = n;
}
const ms = get(keys.maxSleepMs);
if (ms !== undefined) {
const n = parseInt(ms, 10);
if (Number.isFinite(n) && n >= 0) out.maxSleepMs = n;
}
const ea = get(keys.ewmaAlpha);
if (ea !== undefined) {
const n = parseFloat(ea);
if (Number.isFinite(n) && n > 0 && n <= 1) out.ewmaAlpha = n;
}
return out;
}
/**
* Load the live pace config (mode + per-key overrides) from the brain engine.
* Errors swallowed → mode-bundle defaults (the config table may predate this
* feature on old brains). Does NOT read env — the caller layers `readPaceEnv`
* on top so env can beat config.
*/
export async function loadPaceModeConfig(engine: {
getConfig(key: string): Promise<string | null>;
}): Promise<{ mode?: string; configOverrides: PaceKeyOverrides }> {
const safeGet = async (k: string): Promise<string | undefined> => {
try {
const v = await engine.getConfig(k);
return typeof v === 'string' ? v : undefined;
} catch {
return undefined;
}
};
const [mode, ...vals] = await Promise.all([
safeGet(PACE_MODE_KEY),
...PACE_MODE_CONFIG_KEYS.map(safeGet),
]);
const configMap: Record<string, string | undefined> = {};
PACE_MODE_CONFIG_KEYS.forEach((key, i) => {
if (vals[i] !== undefined) configMap[key] = vals[i];
});
return { mode, configOverrides: loadOverridesFromConfig(configMap) };
}
+202
View File
@@ -0,0 +1,202 @@
/**
* Pins the DB-pacer primitive: concurrency cap (the real lever), abort-throws,
* in-band EWMA, jittered cooperative sleep, fail-open, and the no-op path.
*
* Uses a fake sleep + deterministic rng so there's no real wall-clock wait and
* no flakiness — the whole suite runs in microtask time.
*/
import { describe, expect, test } from 'bun:test';
import { createDbPacer, createNoopPacer, type DbPacer } from '../src/core/db-pacer.ts';
import { AbortError } from '../src/core/abort-check.ts';
import { PACE_BUNDLES, type PaceBundle } from '../src/core/pace-mode.ts';
const BUNDLE: PaceBundle = {
enabled: true,
maxConcurrency: 2,
paceAtMs: 100,
maxSleepMs: 1000,
ewmaAlpha: 0.5,
};
/** A fake sleep that resolves immediately but still honors abort. */
function fakeSleep(ms: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted) return Promise.reject(new AbortError('aborted'));
return Promise.resolve();
}
function pacer(over: Partial<PaceBundle> = {}, rng = () => 0.5): DbPacer {
return createDbPacer({ bundle: { ...BUNDLE, ...over }, sleep: fakeSleep, rng });
}
describe('concurrency cap (the real lever)', () => {
test('caps simultaneous permits at maxConcurrency; release hands off FIFO', async () => {
const p = pacer({ maxConcurrency: 2 });
const a = await p.acquire();
const b = await p.acquire();
expect(p.snapshot().active).toBe(2);
let cResolved = false;
const cPromise = p.acquire().then((permit) => {
cResolved = true;
return permit;
});
// c is blocked: still 2 active, 1 waiter.
await Promise.resolve();
expect(cResolved).toBe(false);
expect(p.snapshot().active).toBe(2);
expect(p.snapshot().maxWaiters).toBe(1);
a.release(); // hands the slot to c
const c = await cPromise;
expect(cResolved).toBe(true);
expect(p.snapshot().active).toBe(2); // handoff kept the cap, never 3
b.release();
c.release();
expect(p.snapshot().active).toBe(0);
});
test('double release is idempotent', async () => {
const p = pacer({ maxConcurrency: 1 });
const a = await p.acquire();
a.release();
a.release();
expect(p.snapshot().active).toBe(0);
});
});
describe('abort throws (never falls into a DB call)', () => {
test('acquire throws AbortError when the signal fires while waiting', async () => {
const p = pacer({ maxConcurrency: 1 });
await p.acquire(); // exhausts the single slot
const ac = new AbortController();
const blocked = p.acquire(ac.signal);
ac.abort();
await expect(blocked).rejects.toBeInstanceOf(AbortError);
expect(p.snapshot().maxWaiters).toBe(1);
});
test('acquire throws immediately when already aborted', async () => {
const p = pacer();
const ac = new AbortController();
ac.abort();
await expect(p.acquire(ac.signal)).rejects.toBeInstanceOf(AbortError);
});
test('pace throws AbortError when aborted during sleep', async () => {
// sleep that rejects with AbortError when the signal is set.
const p = createDbPacer({
bundle: BUNDLE,
sleep: (_ms, signal) =>
signal?.aborted ? Promise.reject(new AbortError('aborted')) : Promise.resolve(),
rng: () => 0.5,
});
p.observe(1000); // EWMA well above paceAtMs → will sleep
const ac = new AbortController();
ac.abort();
await expect(p.pace(ac.signal)).rejects.toBeInstanceOf(AbortError);
});
});
describe('in-band EWMA + observe guard', () => {
test('first sample seeds EWMA; later samples smooth it', () => {
const p = pacer({ ewmaAlpha: 0.5 });
expect(p.snapshot().ewmaMs).toBeNull();
p.observe(200);
expect(p.snapshot().ewmaMs).toBe(200);
p.observe(400);
expect(p.snapshot().ewmaMs).toBe(300); // 0.5*400 + 0.5*200
expect(p.snapshot().sampleCount).toBe(2);
});
test('NaN / negative samples are ignored', () => {
const p = pacer();
p.observe(Number.NaN);
p.observe(-5);
p.observe(Infinity);
expect(p.snapshot().ewmaMs).toBeNull();
expect(p.snapshot().sampleCount).toBe(0);
});
});
describe('cooperative sleep', () => {
test('no sleep when EWMA is below paceAtMs', async () => {
const p = pacer({ paceAtMs: 100 });
p.observe(50);
await p.pace();
expect(p.snapshot().sleepCount).toBe(0);
expect(p.snapshot().totalSleptMs).toBe(0);
});
test('sleeps when EWMA exceeds paceAtMs; jittered to 50-100% of base, capped', async () => {
// rng=0 → jitter factor 0.5 → ms = round(base * 0.5).
const p = pacer({ paceAtMs: 100, maxSleepMs: 1000 }, () => 0);
p.observe(800); // base = min(1000, 800) = 800; ms = 400
await p.pace();
expect(p.snapshot().sleepCount).toBe(1);
expect(p.snapshot().totalSleptMs).toBe(400);
});
test('sleep base is capped at maxSleepMs', async () => {
const p = pacer({ paceAtMs: 100, maxSleepMs: 300 }, () => 1); // jitter factor 1.0
p.observe(5000); // base = min(300, 5000) = 300; ms = 300
await p.pace();
expect(p.snapshot().totalSleptMs).toBe(300);
});
test('different rng values decorrelate sleep durations (anti-thundering-herd)', async () => {
const lo = pacer({ paceAtMs: 100 }, () => 0); // factor 0.5
const hi = pacer({ paceAtMs: 100 }, () => 1); // factor 1.0
lo.observe(800);
hi.observe(800);
await lo.pace();
await hi.pace();
expect(lo.snapshot().totalSleptMs).toBe(400);
expect(hi.snapshot().totalSleptMs).toBe(800);
});
});
describe('fail-open', () => {
test('an unexpected (non-abort) sleep failure does not throw', async () => {
const p = createDbPacer({
bundle: BUNDLE,
sleep: () => Promise.reject(new Error('boom')),
rng: () => 0.5,
});
p.observe(1000);
await expect(p.pace()).resolves.toBeUndefined();
// The failed sleep is not counted.
expect(p.snapshot().sleepCount).toBe(0);
});
});
describe('no-op pacer (off mode / PGLite)', () => {
test('createNoopPacer: unbounded acquire, zero sleeps', async () => {
const p = createNoopPacer();
const permits = await Promise.all([p.acquire(), p.acquire(), p.acquire()]);
expect(permits).toHaveLength(3);
p.observe(99999);
await p.pace();
expect(p.snapshot().enabled).toBe(false);
expect(p.snapshot().totalSleptMs).toBe(0);
});
test('an off bundle yields a no-op pacer', async () => {
const p = createDbPacer({ bundle: PACE_BUNDLES.off });
expect(p.snapshot().enabled).toBe(false);
await p.acquire();
await p.acquire(); // never blocks despite maxConcurrency 0
expect(p.snapshot().active).toBe(0);
});
});
describe('dispose', () => {
test('dispose releases blocked acquirers with a no-op permit', async () => {
const p = pacer({ maxConcurrency: 1 });
await p.acquire();
const blocked = p.acquire();
p.dispose();
const permit = await blocked; // resolves instead of hanging
expect(permit).toBeDefined();
});
});
+120
View File
@@ -0,0 +1,120 @@
/**
* Pins the DB-pacing mode bundles + resolution chain.
* The load-bearing claim: env beats config (incident escape hatch), per-call
* beats env, and `off` resolves to a disabled bundle.
*/
import { describe, expect, test } from 'bun:test';
import {
PACE_BUNDLES,
PACE_MODES,
DEFAULT_PACE_MODE,
isPaceMode,
resolvePaceMode,
loadOverridesFromConfig,
readPaceEnv,
} from '../src/core/pace-mode.ts';
describe('pace-mode bundles', () => {
test('off is the default and is disabled', () => {
expect(DEFAULT_PACE_MODE).toBe('off');
expect(PACE_BUNDLES.off.enabled).toBe(false);
});
test('enabled bundles cap concurrency below the unpaced default (20)', () => {
for (const m of ['gentle', 'balanced', 'aggressive'] as const) {
expect(PACE_BUNDLES[m].enabled).toBe(true);
expect(PACE_BUNDLES[m].maxConcurrency).toBeGreaterThanOrEqual(1);
expect(PACE_BUNDLES[m].maxConcurrency).toBeLessThan(20);
}
});
test('isPaceMode guards the union', () => {
expect(PACE_MODES.every(isPaceMode)).toBe(true);
expect(isPaceMode('turbo')).toBe(false);
expect(isPaceMode(undefined)).toBe(false);
});
});
describe('resolvePaceMode precedence', () => {
test('unknown mode falls back to off (disabled), mode_valid=false', () => {
const r = resolvePaceMode({ mode: 'turbo' });
expect(r.resolved_mode).toBe('off');
expect(r.mode_valid).toBe(false);
expect(r.enabled).toBe(false);
});
test('config mode applies its bundle', () => {
const r = resolvePaceMode({ mode: 'balanced' });
expect(r.resolved_mode).toBe('balanced');
expect(r.maxConcurrency).toBe(PACE_BUNDLES.balanced.maxConcurrency);
});
test('env mode beats config mode (incident escape hatch)', () => {
const r = resolvePaceMode({ mode: 'gentle', envMode: 'aggressive' });
expect(r.resolved_mode).toBe('aggressive');
});
test('per-call mode beats env and config', () => {
const r = resolvePaceMode({ mode: 'gentle', envMode: 'balanced', perCallMode: 'aggressive' });
expect(r.resolved_mode).toBe('aggressive');
});
test('env knob override beats config knob override', () => {
const r = resolvePaceMode({
mode: 'balanced',
configOverrides: { maxConcurrency: 6 },
envOverrides: { maxConcurrency: 2 },
});
expect(r.maxConcurrency).toBe(2);
});
test('per-call knob beats env and config', () => {
const r = resolvePaceMode({
mode: 'balanced',
configOverrides: { maxConcurrency: 6 },
envOverrides: { maxConcurrency: 2 },
perCall: { maxConcurrency: 12 },
});
expect(r.maxConcurrency).toBe(12);
});
test('clamps an out-of-range maxConcurrency back to the bundle when enabled', () => {
const r = resolvePaceMode({ mode: 'balanced', perCall: { maxConcurrency: 0 } });
expect(r.enabled).toBe(true);
expect(r.maxConcurrency).toBe(PACE_BUNDLES.balanced.maxConcurrency);
});
test('clamps a bad ewmaAlpha back to a sane default', () => {
const r = resolvePaceMode({ mode: 'balanced', perCall: { ewmaAlpha: 5 } });
expect(r.ewmaAlpha).toBeGreaterThan(0);
expect(r.ewmaAlpha).toBeLessThanOrEqual(1);
});
});
describe('config + env parsing', () => {
test('loadOverridesFromConfig parses present keys only', () => {
const ov = loadOverridesFromConfig({
'pace.max_concurrency': '5',
'pace.pace_at_ms': '400',
});
expect(ov.maxConcurrency).toBe(5);
expect(ov.paceAtMs).toBe(400);
expect(ov.maxSleepMs).toBeUndefined();
});
test('readPaceEnv reads GBRAIN_PACE_* including mode', () => {
const { envMode, envOverrides } = readPaceEnv({
GBRAIN_PACE_MODE: 'gentle',
GBRAIN_PACE_MAX_CONCURRENCY: '3',
GBRAIN_PACE_ENABLED: 'true',
});
expect(envMode).toBe('gentle');
expect(envOverrides.maxConcurrency).toBe(3);
expect(envOverrides.enabled).toBe(true);
});
test('rejects out-of-range env concurrency (falls through)', () => {
const { envOverrides } = readPaceEnv({ GBRAIN_PACE_MAX_CONCURRENCY: '99999' });
expect(envOverrides.maxConcurrency).toBeUndefined();
});
});