pi-mega-compact 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,93 @@
1
+ /**
2
+ * pgOpenGuard.ts — bound the PGlite open so a stalled WASM init can never wedge
3
+ * a pi turn.
4
+ *
5
+ * Both index modules (vectorIndex / memoryIndex) cache their in-flight open in a
6
+ * module-level `initPromise`. That cache is what turns a single stalled open
7
+ * into a permanent hang: PGlite is a single-writer WASM Postgres over a shared
8
+ * dataDir (~/.pi/mega-compact-vector), so a second pi process opening the same
9
+ * dir can block indefinitely. `await new PGlite(...)` then never settles, the
10
+ * never-settling promise is cached, and every later caller awaits that same dead
11
+ * promise — with no timers and no sockets left, node reports
12
+ * "Promise resolution is still pending but the event loop has already resolved"
13
+ * and the pi turn that awaited it never ends.
14
+ *
15
+ * withOpenTimeout() puts a ceiling on that wait. On timeout the caller gets
16
+ * undefined (both modules already degrade to a synchronous scan), and the
17
+ * abandoned open is disowned: if it does eventually settle, the instance is
18
+ * closed so a stray PGlite can't keep the loop alive or hold the dataDir lock.
19
+ *
20
+ * A rejected open is NOT swallowed — it propagates so the callers' existing
21
+ * corrupt-dir detection (Aborted / RuntimeError → wipe + one retry) still runs.
22
+ * Only the timeout resolves to undefined.
23
+ */
24
+
25
+ /** Sentinel so a legitimately-undefined open is distinguishable from a timeout. */
26
+ const TIMED_OUT = Symbol("pglite-open-timeout");
27
+
28
+ /** Default ceiling for a PGlite open. Generous — a cold WASM + HNSW init is slow. */
29
+ export const DEFAULT_PG_OPEN_TIMEOUT_MS = 30_000;
30
+
31
+ /** Resolve the open timeout. 0 (or negative) disables the guard entirely. */
32
+ export function pgOpenTimeoutMs(): number {
33
+ const raw = process.env.MEGACOMPACT_PGLITE_OPEN_TIMEOUT_MS;
34
+ if (raw === undefined || raw.trim() === "") return DEFAULT_PG_OPEN_TIMEOUT_MS;
35
+ const n = Number(raw);
36
+ if (!Number.isFinite(n) || n < 0) return DEFAULT_PG_OPEN_TIMEOUT_MS;
37
+ return n;
38
+ }
39
+
40
+ /** A PGlite-ish handle we may need to dispose of after abandoning it. */
41
+ interface Closable {
42
+ close?: () => Promise<unknown> | unknown;
43
+ }
44
+
45
+ /**
46
+ * Race `open` against the configured timeout.
47
+ *
48
+ * Resolves to the opened value, or to undefined when the open outruns the
49
+ * timeout (`onTimeout` fires first so the caller can log and flip its own
50
+ * disabled state). Rejections propagate to the caller unchanged.
51
+ */
52
+ export async function withOpenTimeout<T>(
53
+ open: Promise<T>,
54
+ onTimeout: (reason: string) => void,
55
+ timeoutMs: number = pgOpenTimeoutMs(),
56
+ ): Promise<T | undefined> {
57
+ // Guard disabled — preserve the original unbounded behavior verbatim.
58
+ if (timeoutMs <= 0) return open;
59
+
60
+ let timer: ReturnType<typeof setTimeout> | undefined;
61
+ const expiry = new Promise<typeof TIMED_OUT>((resolve) => {
62
+ timer = setTimeout(() => resolve(TIMED_OUT), timeoutMs);
63
+ // Never hold the process open on account of the guard itself.
64
+ timer.unref?.();
65
+ });
66
+
67
+ try {
68
+ // `open` is raced as-is so a rejection rejects the race — and therefore
69
+ // this function — leaving the caller's corrupt-retry path intact.
70
+ const winner = await Promise.race([open, expiry]);
71
+
72
+ if (winner === TIMED_OUT) {
73
+ // Disown the open. If it ever settles, close the instance so an orphaned
74
+ // PGlite cannot keep the event loop alive or hold the dataDir lock.
75
+ void open
76
+ .then((late) => {
77
+ try {
78
+ void (late as Closable | undefined)?.close?.();
79
+ } catch {
80
+ /* ignore */
81
+ }
82
+ })
83
+ .catch(() => {
84
+ /* the abandoned open failed on its own — nothing left to release */
85
+ });
86
+ onTimeout(`timed out after ${timeoutMs}ms`);
87
+ return undefined;
88
+ }
89
+ return winner as T;
90
+ } finally {
91
+ if (timer) clearTimeout(timer);
92
+ }
93
+ }
@@ -40,6 +40,8 @@ export interface VectorIndexHit {
40
40
  score: number;
41
41
  }
42
42
 
43
+ import { withOpenTimeout } from "./pgOpenGuard.js";
44
+
43
45
  let db: PGliteInstance | undefined;
44
46
  let initPromise: Promise<PGliteInstance | undefined> | undefined;
45
47
  let disabled = false;
@@ -131,12 +133,19 @@ async function openPgLite(
131
133
  if (!mod) return undefined;
132
134
  const dir = indexDir();
133
135
  mkdirSync(dir, { recursive: true });
134
- const pg = await new mod.PGlite({
135
- dataDir: dir,
136
- extensions: { vector: mod.vector },
137
- });
138
- await pg.exec("CREATE EXTENSION IF NOT EXISTS vector;");
139
- await pg.exec(`
136
+ // Bounded open: PGlite is single-writer over a shared dataDir, so a second
137
+ // pi process on the same dir can block here forever. Without the ceiling the
138
+ // never-settling promise gets cached in initPromise and every later caller
139
+ // awaits it — which is how a stalled index wedged a whole pi turn.
140
+ let openTimedOut = false;
141
+ const pg = await withOpenTimeout(
142
+ (async () => {
143
+ const inst = await new mod.PGlite({
144
+ dataDir: dir,
145
+ extensions: { vector: mod.vector },
146
+ });
147
+ await inst.exec("CREATE EXTENSION IF NOT EXISTS vector;");
148
+ await inst.exec(`
140
149
  CREATE TABLE IF NOT EXISTS vector_index (
141
150
  repo_id TEXT NOT NULL,
142
151
  session_id TEXT NOT NULL,
@@ -145,10 +154,27 @@ async function openPgLite(
145
154
  PRIMARY KEY (repo_id, session_id, checkpoint_id)
146
155
  );
147
156
  `);
148
- // HNSW index over cosine distance for fast NN. Created idempotently.
149
- await pg.exec(
150
- "CREATE INDEX IF NOT EXISTS vector_index_hnsw ON vector_index USING hnsw (embedding vector_cosine_ops);",
157
+ // HNSW index over cosine distance for fast NN. Created idempotently.
158
+ await inst.exec(
159
+ "CREATE INDEX IF NOT EXISTS vector_index_hnsw ON vector_index USING hnsw (embedding vector_cosine_ops);",
160
+ );
161
+ return inst;
162
+ })(),
163
+ (reason) => {
164
+ openTimedOut = true;
165
+ logWarn(`init ${reason}`);
166
+ },
151
167
  );
168
+ if (!pg) {
169
+ if (openTimedOut) {
170
+ // Don't leave the dead open cached, and don't retry on the next call —
171
+ // a contended dataDir would just burn another full timeout per caller.
172
+ // Same terminal state as any other init failure: fall back to the scan.
173
+ initPromise = undefined;
174
+ disabled = true;
175
+ }
176
+ return undefined;
177
+ }
152
178
  db = pg;
153
179
  return pg;
154
180
  } catch (err) {