store-writer.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. /**
  2. * StoreWriter — main-thread client for the store worker (see store-worker.ts).
  3. *
  4. * Used ONLY on the fresh-DB bulk path: bundles are posted in file order and the
  5. * worker applies them in arrival order, so rowid assignment (and therefore
  6. * resolution's insertion-order disambiguation) is byte-identical to the
  7. * main-thread store. Kill switch: CODEGRAPH_NO_STORE_WORKER=1.
  8. */
  9. import { Worker } from 'worker_threads';
  10. import { ExtractionResult, Language, Node, Edge, UnresolvedReference, FileRecord } from '../types';
  11. /** One file's complete store payload (pre-filtered — see storeFileBundle). */
  12. export interface StoreBundle {
  13. nodes: Node[];
  14. edges: Edge[];
  15. refs: UnresolvedReference[];
  16. file: FileRecord;
  17. }
  18. /**
  19. * A kernel deferred-decode payload: the file's raw table buffers plus the
  20. * FileRecord the main thread built from meta counts. The store WORKER decodes
  21. * and finalizes (same filters as the object path), so per-node objects never
  22. * exist on the main thread.
  23. */
  24. export interface KernelStoreBundle {
  25. kernel: true;
  26. filePath: string;
  27. language: Language;
  28. buffers: NonNullable<ExtractionResult['kernelBuffers']>;
  29. file: FileRecord;
  30. }
  31. /**
  32. * The validation/denormalization every bundle gets before storeFileBundle —
  33. * shared by the orchestrator's object path and the store worker's kernel
  34. * decode path so the two can never drift:
  35. * - nodes missing identity fields are dropped (#42-class safety),
  36. * - edges must connect inserted nodes (FK integrity),
  37. * - refs must originate from inserted nodes and carry the denormalized
  38. * filePath/language the resolver reads.
  39. */
  40. export function finalizeStoreBundle(
  41. result: Pick<ExtractionResult, 'nodes' | 'edges' | 'unresolvedReferences'>,
  42. filePath: string,
  43. language: Language,
  44. file: FileRecord
  45. ): StoreBundle {
  46. const validNodes = result.nodes.filter((n) => n.id && n.kind && n.name && n.filePath && n.language);
  47. const insertedIds = new Set(validNodes.map((n) => n.id));
  48. const validEdges = result.edges.filter(
  49. (e) => insertedIds.has(e.source) && insertedIds.has(e.target)
  50. );
  51. const validRefs = result.unresolvedReferences
  52. .filter((ref) => insertedIds.has(ref.fromNodeId))
  53. .map((ref) => ({
  54. ...ref,
  55. filePath: ref.filePath ?? filePath,
  56. language: ref.language ?? language,
  57. }));
  58. return { nodes: validNodes, edges: validEdges, refs: validRefs, file };
  59. }
  60. export class StoreWriter {
  61. private worker: Worker;
  62. private readyPromise: Promise<void>;
  63. private firstError: Error | null = null;
  64. private drainWaiters = new Map<number, { resolve: () => void; reject: (e: Error) => void }>();
  65. private nextDrainId = 0;
  66. private exited = false;
  67. /** Bundles posted but not yet acked — the queue-depth backpressure signal. */
  68. private outstanding = 0;
  69. private belowWaiters: Array<{ limit: number; resolve: () => void }> = [];
  70. constructor(workerScriptPath: string, dbPath: string, fastInit: boolean) {
  71. this.worker = new Worker(workerScriptPath);
  72. let readyResolve!: () => void;
  73. let readyReject!: (e: Error) => void;
  74. this.readyPromise = new Promise<void>((resolve, reject) => {
  75. readyResolve = resolve;
  76. readyReject = reject;
  77. });
  78. this.worker.on('message', (msg: { type: string; id?: number; message?: string }) => {
  79. if (msg.type === 'ready') {
  80. readyResolve();
  81. } else if (msg.type === 'ack') {
  82. this.settleOne();
  83. } else if (msg.type === 'drained' && msg.id !== undefined) {
  84. const waiter = this.drainWaiters.get(msg.id);
  85. this.drainWaiters.delete(msg.id);
  86. if (!waiter) return;
  87. if (this.firstError) waiter.reject(this.firstError);
  88. else waiter.resolve();
  89. } else if (msg.type === 'error') {
  90. if (!this.firstError) this.firstError = new Error(`store worker: ${msg.message}`);
  91. this.settleOne(); // the error reply is also the failed bundle's ack
  92. }
  93. });
  94. this.worker.on('error', (err) => {
  95. this.failAll(err instanceof Error ? err : new Error(String(err)));
  96. readyReject(this.firstError!);
  97. });
  98. this.worker.on('exit', (code) => {
  99. this.exited = true;
  100. if (code !== 0) {
  101. this.failAll(new Error(`store worker exited with code ${code}`));
  102. readyReject(this.firstError!);
  103. } else if (this.drainWaiters.size > 0 || this.belowWaiters.length > 0) {
  104. // A clean exit with waiters pending is a protocol violation (only
  105. // close() should end the worker) — settle the waiters instead of
  106. // hanging the index forever.
  107. this.failAll(new Error('store worker exited before drain completed'));
  108. }
  109. });
  110. this.worker.postMessage({ type: 'open', dbPath, fastInit });
  111. // The worker holds the event loop open only until close(); don't unref —
  112. // bundles must never be dropped because main ran out of work.
  113. }
  114. private failAll(err: Error): void {
  115. if (!this.firstError) this.firstError = err;
  116. for (const [, waiter] of this.drainWaiters) waiter.reject(this.firstError);
  117. this.drainWaiters.clear();
  118. this.outstanding = 0;
  119. const waiters = this.belowWaiters;
  120. this.belowWaiters = [];
  121. for (const w of waiters) w.resolve(); // send() will surface firstError
  122. }
  123. private settleOne(): void {
  124. if (this.outstanding > 0) this.outstanding--;
  125. if (this.belowWaiters.length === 0) return;
  126. const still: typeof this.belowWaiters = [];
  127. for (const w of this.belowWaiters) {
  128. if (this.outstanding < w.limit) w.resolve();
  129. else still.push(w);
  130. }
  131. this.belowWaiters = still;
  132. }
  133. ready(): Promise<void> {
  134. return this.readyPromise;
  135. }
  136. /** Post one file's bundle. Throws immediately if the writer already failed. */
  137. send(bundle: StoreBundle | KernelStoreBundle): void {
  138. if (this.firstError) throw this.firstError;
  139. if (this.exited) throw new Error('store worker already exited');
  140. this.outstanding++;
  141. this.worker.postMessage({ type: 'bundle', bundle });
  142. }
  143. /** Backpressure: resolves once fewer than `limit` bundles are un-acked. */
  144. waitBelow(limit: number): Promise<void> {
  145. if (this.firstError || this.exited || this.outstanding < limit) return Promise.resolve();
  146. return new Promise<void>((resolve) => {
  147. this.belowWaiters.push({ limit, resolve });
  148. });
  149. }
  150. /** Resolves when every bundle posted before this call has been applied. */
  151. drain(): Promise<void> {
  152. if (this.firstError) return Promise.reject(this.firstError);
  153. if (this.exited) return Promise.reject(new Error('store worker already exited'));
  154. const id = this.nextDrainId++;
  155. const p = new Promise<void>((resolve, reject) => {
  156. this.drainWaiters.set(id, { resolve, reject });
  157. });
  158. this.worker.postMessage({ type: 'drain', id });
  159. return p;
  160. }
  161. /** Close the worker's DB connection and join the thread. */
  162. async close(): Promise<void> {
  163. if (this.exited) return;
  164. this.worker.postMessage({ type: 'close' });
  165. await new Promise<void>((resolve) => {
  166. const t = setTimeout(() => {
  167. void this.worker.terminate().then(() => resolve());
  168. }, 5000);
  169. this.worker.once('exit', () => {
  170. clearTimeout(t);
  171. resolve();
  172. });
  173. });
  174. }
  175. }