Files
gbrain/src/core/facts/queue.ts
T
ffac8ce0f4 v0.41.27.0 fix: withRetry self-heals on null singleton + facts:absorb drain + disconnect audit (closes #1570) (#1608)
* 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>
2026-05-28 19:04:48 -07:00

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;
}