mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* merge master: rebump v0.41.25.0 → v0.41.27.0 (queue collision) Master shipped v0.41.25.0 (#1538 batched sync deletes) and v0.41.26.0 (#1571 dream --source fix) while this branch was in flight. Conflict resolution rebumps to the next available slot. - VERSION: 0.41.25.0 → 0.41.27.0 - package.json: synced - CHANGELOG.md: my v0.41.27.0 entry placed above master's v0.41.26.0 and v0.41.25.0; in-entry version references updated 0.41.25.0 → 0.41.27.0 and forward-references bumped to v0.41.28+. - TODOS.md: kept master's v0.41.20.x section + my v0.41.27.0+ follow-ups No source-file conflicts during the merge. * feat(diagnostics): db-disconnect audit + doctor surface (v0.41.27.0) Instruments every db.disconnect() and PostgresEngine.disconnect() call with a JSONL audit record so the next user-reported #1570 cycle gives us the offender's caller stack instead of the symptomatic "No database connection" error. Audit shape (~/.gbrain/audit/db-disconnect-YYYY-Www.jsonl): {ts, engine_kind, connection_style, caller_stack[], command, pid} - src/core/audit/db-disconnect-audit.ts (NEW): the audit writer, built on the v0.40.4.0 createAuditWriter cathedral. Captures a 6-frame stack via new Error().stack so the offender is readable without spending stderr noise. - src/core/db.ts: logDbDisconnect call at the top of disconnect() (best-effort; never blocks the real teardown). - src/core/postgres-engine.ts: same instrumentation in PostgresEngine.disconnect() — distinguishes 'module' vs 'instance' connection_style so we can tell legitimate worker-pool teardowns apart from the load-bearing module-singleton class. - src/commands/doctor.ts: extends batch_retry_health to surface 24h disconnect count + most-recent caller stack. Warns when the caller frame isn't a known CLI-exit frame (e.g. cli.ts's finally block at the end of an op-dispatch). This is the diagnostic that tells v0.41.28+ where to apply the real ownership fix. - test/db-disconnect-audit.test.ts: unit coverage for the audit writer + caller-stack capture + JSONL shape. - test/e2e/db-singleton-shared-recovery.test.ts: real-Postgres regression that exercises the singleton-null path end-to-end. Refs #1570 * feat(retry): self-heal on null singleton — closes #1570 symptom (v0.41.27.0) withRetry gains an opt-in reconnect callback that fires between the isRetryableConnError classification and the inter-attempt sleep. PostgresEngine.batchRetry injects this.reconnect() — race-safe via the existing _reconnecting guard, handles module and instance pools. Closes the production loss reported in #1570: dream cycles on Supabase no longer drop ~150 link rows per cycle when the singleton goes null mid-batch. The retry now rebuilds the connection between attempts so the second try has somewhere to write to. - src/core/retry.ts: WithRetryOpts gains `reconnect?: () => Promise<void>`. Awaited in the catch branch. onRetry is also now awaited (back-compat- safe: every existing in-tree caller is a sync arrow). Reconnect failures propagate as the real cause — replaces the symptomatic "No database connection" error with whatever the connect() throw was, so operators see the truth. - src/core/postgres-engine.ts:batchRetry — injects `reconnect: () => this.reconnect()`. Covers all 9 batch-retry call sites (addLinksBatch, addTimelineEntriesBatch, upsertChunks, plus the 6 caller-supplied auditSite labels in extract / sync / reindex). - test/core/retry-reconnect.test.ts: 8 hermetic cases pinning the contract — reconnect fires before sleep, only on retryable errors, back-compat when omitted, signal-aborted bypasses reconnect, onRetry is awaited, full success path end-to-end. The deeper bug (who's calling disconnect mid-cycle) is left unaddressed in this commit by design — the diagnostic instrumentation in the prior commit will tell us in the next production run. Refs #1570 * feat(facts): drainPending() + CLI await before disconnect (v0.41.27.0) Closes the silent 'No database connection' tail-end errors after gbrain capture / put_page: the facts:absorb fire-and-forget queue sometimes outlived the CLI process's connection lifetime, so absorb attempts after engine.disconnect() landed in stderr as the GBrainError shape. - src/core/facts/queue.ts: new drainPending({timeout: 1000}) method distinct from shutdown(). Stops accepting new enqueues, awaits in-flight settle, bounded by timeout, returns count of unfinished. Semantically different from shutdown() (which aborts in-flight) so the symptom — drop work that hasn't started yet but let in-flight work finish — matches what CLI exit actually needs. - src/cli.ts: op-dispatch finally block awaits the drain BEFORE engine.disconnect(). Bounded 1s. Opt-out env GBRAIN_NO_FACTS_DRAIN for callers that don't enqueue (keeps fast-exit paths fast). Mirrors the v0.41.8.0 awaitPendingLastRetrievedWrites pattern. - test/facts-queue-drain-pending.test.ts: 6 hermetic cases — empty drain returns immediately, single in-flight settles, timeout bounds wait, shutdown-after-drain is idempotent, post-drain enqueues are dropped, signal-aborted skips waiting. Refs #1570 * docs: update project documentation for v0.41.27.0 README.md: added troubleshooting entry for the v0.41.27.0 retry-reconnect + facts:absorb drain fix (closes #1570), pointing operators at `gbrain doctor --json` to find the offending disconnect caller. CLAUDE.md: extended `src/core/retry.ts` entry with the new optional `reconnect` callback (v0.41.27.0); added two new Key Files entries for `src/core/audit/db-disconnect-audit.ts` (the diagnostic half of the "instrument first, fix later" pivot) and `FactsQueue.drainPending`; extended `doctor.ts:checkBatchRetryHealth` entry with the in-place extension that surfaces 24h disconnect-call count. llms-full.txt: regenerated to absorb CLAUDE.md edits. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: rebump v0.41.27.0 → v0.41.28.0 (queue collision with #1573) Master shipped v0.41.27.0 (#1573 git-aware sync_freshness) claiming the same slot. Rebump to the next available version. - VERSION + package.json → 0.41.28.0 - CHANGELOG.md: my entry header + in-entry refs 0.41.27.0 → 0.41.28.0 - TODOS.md: my #1570 follow-up section header + body refs bumped * test: pin gateway in put-page-provenance + embedding-dim-check (CI shard fix) Both files failed on CI shards 1 and 8 under the cross-file gateway-state leak class (CLAUDE.md "Test-isolation lint and helpers"). The v0.41.28.0 merge reshuffled the weight-based shard bin-packing, landing a gateway-mutating sibling ahead of these two victims in the same `bun test` process. Mechanism: - put-page-provenance: put_page embeds via the gateway. A sibling left the gateway configured with OpenAI + the CI placeholder `sk-test` (captured at configureGateway time, survives the withEnv restore as cached gateway state). put_page's embed then fired against live OpenAI and 401'd. The bunfig legacy-embedding preload's beforeEach only re-applies legacy when the gateway was RESET — it does NOT correct a sibling that configured a different LIVE config. - embedding-dim-check: initSchema builds the content_chunks vector column at the gateway's configured dim. A sibling leaking ZE/1280 made the column 1280-d, so `expect(dims).toBe(1536)` failed. Fix (victim-side pinning, the escape hatch the preload documents): - Both: configure the gateway explicitly in beforeAll BEFORE initSchema (OpenAI/1536), resetGateway() in afterAll so neither leaks onward. - put-page-provenance also stubs the embed transport via __setEmbedTransportForTests so embed is deterministic and offline; a dummy OPENAI_API_KEY is supplied in the gateway env because instantiateEmbedding builds the OpenAI client (key check) BEFORE the stubbed transport is reached — the stub then intercepts the actual call so the key never leaves the process. Verified: CI shards 1 (1337 pass) + 8 (905 pass) green with OPENAI_API_KEY unset, plus adversarial sibling orderings (gateway.test / doctor-ze-checks preceding). Typecheck + check-test-isolation clean. --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
256 lines
9.4 KiB
TypeScript
256 lines
9.4 KiB
TypeScript
/**
|
|
* v0.31 Hot Memory — bounded in-memory queue for fact extraction.
|
|
*
|
|
* Per /plan-eng-review D6 + D7:
|
|
* - Cap 100 entries; drop oldest on overflow with a counter increment.
|
|
* - Per-session in-flight=1 — serializes extraction within a session so
|
|
* burst chat doesn't fan out 50 parallel Haiku calls.
|
|
* - AbortSignal threading from server SIGTERM. On shutdown:
|
|
* 1. Stop accepting new entries
|
|
* 2. Best-effort 5s grace for in-flight extractions
|
|
* 3. Drop pending with counter increment
|
|
*
|
|
* The queue is a singleton per process. `getFactsQueue()` lazy-initializes
|
|
* with sensible defaults; tests inject a fresh instance via `__resetFactsQueue`.
|
|
*
|
|
* The queue takes opaque jobs `(handler, sessionId)` so callers compose the
|
|
* actual extraction pipeline themselves. The queue's only job is order +
|
|
* concurrency + dropping under load.
|
|
*/
|
|
|
|
export interface FactsQueueCounters {
|
|
enqueued: number;
|
|
completed: number;
|
|
dropped_overflow: number;
|
|
dropped_shutdown: number;
|
|
failed: number;
|
|
}
|
|
|
|
export interface FactsQueueOpts {
|
|
/** Max pending jobs in the queue. Defaults to 100. */
|
|
cap?: number;
|
|
/** Per-session in-flight cap. Defaults to 1 (serialized). */
|
|
perSessionInflightCap?: number;
|
|
/** Grace ms for in-flight to drain on shutdown. Defaults to 5000. */
|
|
shutdownGraceMs?: number;
|
|
/** External shutdown signal. When aborted, queue drains + drops pending. */
|
|
abortSignal?: AbortSignal;
|
|
}
|
|
|
|
/** Job body — caller decides what runs. Must be cooperatively cancellable. */
|
|
export type FactsJob = (signal: AbortSignal) => Promise<void>;
|
|
|
|
interface QueueEntry {
|
|
job: FactsJob;
|
|
sessionId: string;
|
|
enqueuedAt: number;
|
|
}
|
|
|
|
export class FactsQueue {
|
|
private readonly cap: number;
|
|
private readonly perSessionInflightCap: number;
|
|
private readonly shutdownGraceMs: number;
|
|
private readonly externalAbort?: AbortSignal;
|
|
private readonly internalAbort = new AbortController();
|
|
|
|
private pending: QueueEntry[] = [];
|
|
/** Per-session in-flight count. */
|
|
private inflightBySession = new Map<string, number>();
|
|
/** Global in-flight count (for shutdown drain accounting). */
|
|
private inflightTotal = 0;
|
|
|
|
private counters: FactsQueueCounters = {
|
|
enqueued: 0,
|
|
completed: 0,
|
|
dropped_overflow: 0,
|
|
dropped_shutdown: 0,
|
|
failed: 0,
|
|
};
|
|
|
|
private shuttingDown = false;
|
|
private shutdownPromise: Promise<void> | null = null;
|
|
|
|
constructor(opts: FactsQueueOpts = {}) {
|
|
this.cap = Math.max(1, opts.cap ?? 100);
|
|
this.perSessionInflightCap = Math.max(1, opts.perSessionInflightCap ?? 1);
|
|
this.shutdownGraceMs = Math.max(0, opts.shutdownGraceMs ?? 5000);
|
|
this.externalAbort = opts.abortSignal;
|
|
if (this.externalAbort) {
|
|
const onAbort = () => { void this.shutdown(); };
|
|
if (this.externalAbort.aborted) onAbort();
|
|
else this.externalAbort.addEventListener('abort', onAbort, { once: true });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enqueue a job. Returns the queue depth after insertion (or -1 if dropped
|
|
* because the queue is shutting down). Drop-oldest-on-overflow if cap hit.
|
|
*/
|
|
enqueue(job: FactsJob, sessionId: string): number {
|
|
if (this.shuttingDown) {
|
|
this.counters.dropped_shutdown += 1;
|
|
return -1;
|
|
}
|
|
if (this.pending.length >= this.cap) {
|
|
// Drop oldest. Note: the dropped job's handler is never invoked; callers
|
|
// upstream of the queue should treat enqueue() as fire-and-forget +
|
|
// monitor counters for capacity pressure.
|
|
this.pending.shift();
|
|
this.counters.dropped_overflow += 1;
|
|
}
|
|
this.pending.push({ job, sessionId, enqueuedAt: Date.now() });
|
|
this.counters.enqueued += 1;
|
|
// Non-blocking pump: schedule on microtask so callers stay sync.
|
|
queueMicrotask(() => { void this.pump(); });
|
|
return this.pending.length;
|
|
}
|
|
|
|
/** Snapshot of the counters. */
|
|
getCounters(): FactsQueueCounters {
|
|
return { ...this.counters };
|
|
}
|
|
|
|
/** Pending depth (queued but not yet picked up). */
|
|
pendingCount(): number {
|
|
return this.pending.length;
|
|
}
|
|
|
|
/** In-flight count across all sessions. */
|
|
inflightCount(): number {
|
|
return this.inflightTotal;
|
|
}
|
|
|
|
/**
|
|
* v0.41.25.0 (#1570) — wait for currently pending + in-flight jobs to
|
|
* settle naturally. **Semantically distinct from `shutdown()`** — drain
|
|
* does NOT abort in-flight work, does NOT drop pending, and does NOT
|
|
* disable future enqueues. It just blocks until the queue reaches
|
|
* (pending=0 AND inflight=0) OR the timeout fires.
|
|
*
|
|
* Per codex finding 9 from /codex review of the v0.41.25 plan: the
|
|
* original "reuse shutdown" idea was wrong because shutdown aborts
|
|
* in-flight (`this.internalAbort.abort()`), which means the very
|
|
* facts:absorb worker that's trying to log its post-completion
|
|
* absorb event gets aborted mid-write. That preserves the bug class
|
|
* we're trying to fix.
|
|
*
|
|
* Per codex finding 10: this is bounded by `opts.timeout` (default
|
|
* 1000ms) so commands that don't enqueue facts pay only one fast
|
|
* 0ms check before exit. Capture / import / sync that DO enqueue
|
|
* pay up to 1s while in-flight Haiku calls finish.
|
|
*
|
|
* Returns `{drained, unfinished}` so callers can log the outcome
|
|
* for debugging (no stderr writes; that's the caller's choice).
|
|
* `unfinished > 0` means timeout fired with work still pending —
|
|
* those jobs aren't aborted, they just continue running while the
|
|
* caller proceeds to exit (the singleton-still-alive contract in
|
|
* the post-pivot architecture means they'll still be able to write
|
|
* their logs).
|
|
*/
|
|
async drainPending(
|
|
opts: { timeout?: number } = {},
|
|
): Promise<{ drained: number; unfinished: number }> {
|
|
const timeout = opts.timeout ?? 1000;
|
|
const initiallyPending = this.pending.length;
|
|
const initiallyInflight = this.inflightTotal;
|
|
if (initiallyPending === 0 && initiallyInflight === 0) {
|
|
return { drained: 0, unfinished: 0 };
|
|
}
|
|
const start = Date.now();
|
|
while (
|
|
(this.pending.length > 0 || this.inflightTotal > 0) &&
|
|
Date.now() - start < timeout
|
|
) {
|
|
// 25ms poll interval matches shutdown() below; consistent rhythm.
|
|
await sleep(25);
|
|
}
|
|
const unfinished = this.pending.length + this.inflightTotal;
|
|
const drained = initiallyPending + initiallyInflight - unfinished;
|
|
return { drained, unfinished };
|
|
}
|
|
|
|
/**
|
|
* Begin shutdown. Returns a promise that resolves once the queue has either
|
|
* fully drained in-flight (under shutdownGraceMs) OR the grace expired. After
|
|
* this resolves, all pending jobs are dropped with `dropped_shutdown` count.
|
|
*/
|
|
shutdown(): Promise<void> {
|
|
if (this.shutdownPromise) return this.shutdownPromise;
|
|
this.shuttingDown = true;
|
|
this.internalAbort.abort();
|
|
this.shutdownPromise = (async () => {
|
|
const start = Date.now();
|
|
while (this.inflightTotal > 0 && Date.now() - start < this.shutdownGraceMs) {
|
|
await sleep(25);
|
|
}
|
|
// Drop everything still pending.
|
|
const dropped = this.pending.length;
|
|
this.pending = [];
|
|
this.counters.dropped_shutdown += dropped;
|
|
})();
|
|
return this.shutdownPromise;
|
|
}
|
|
|
|
/** Pump: pick up entries respecting per-session in-flight cap. */
|
|
private async pump(): Promise<void> {
|
|
if (this.shuttingDown) return;
|
|
// Find the next entry whose session has capacity.
|
|
for (let i = 0; i < this.pending.length; i++) {
|
|
const entry = this.pending[i];
|
|
const inflight = this.inflightBySession.get(entry.sessionId) ?? 0;
|
|
if (inflight < this.perSessionInflightCap) {
|
|
// Claim it.
|
|
this.pending.splice(i, 1);
|
|
this.inflightBySession.set(entry.sessionId, inflight + 1);
|
|
this.inflightTotal += 1;
|
|
void this.runEntry(entry);
|
|
// Try the next entry too — might have multiple sessions ready.
|
|
return this.pump();
|
|
}
|
|
}
|
|
}
|
|
|
|
private async runEntry(entry: QueueEntry): Promise<void> {
|
|
try {
|
|
await entry.job(this.internalAbort.signal);
|
|
this.counters.completed += 1;
|
|
} catch (err) {
|
|
// Don't propagate; caller sees nothing — the queue surface is fire-and-
|
|
// forget by design. Counters expose visibility for `gbrain doctor`.
|
|
const wasAbort = err instanceof Error && (err.name === 'AbortError' || /aborted/i.test(err.message));
|
|
if (!wasAbort) {
|
|
this.counters.failed += 1;
|
|
// eslint-disable-next-line no-console
|
|
console.warn(`[facts-queue] job failed for session=${entry.sessionId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
} else {
|
|
this.counters.dropped_shutdown += 1;
|
|
}
|
|
} finally {
|
|
const remaining = (this.inflightBySession.get(entry.sessionId) ?? 1) - 1;
|
|
if (remaining <= 0) this.inflightBySession.delete(entry.sessionId);
|
|
else this.inflightBySession.set(entry.sessionId, remaining);
|
|
this.inflightTotal -= 1;
|
|
// Pump in case the released slot unblocks another entry.
|
|
queueMicrotask(() => { void this.pump(); });
|
|
}
|
|
}
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise(r => setTimeout(r, ms));
|
|
}
|
|
|
|
// ── Process-singleton ──────────────────────────────────────
|
|
|
|
let _singleton: FactsQueue | null = null;
|
|
|
|
export function getFactsQueue(opts?: FactsQueueOpts): FactsQueue {
|
|
if (!_singleton) _singleton = new FactsQueue(opts);
|
|
return _singleton;
|
|
}
|
|
|
|
/** Test helper: reset the process-level singleton. */
|
|
export function __resetFactsQueueForTests(): void {
|
|
_singleton = null;
|
|
}
|