gitnexus 1.6.12-rc.3 → 1.6.12-rc.4

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.
@@ -47,12 +47,12 @@ export interface WorkerExtractedData {
47
47
  */
48
48
  export declare const mergeChunkResults: (graph: KnowledgeGraph, symbolTable: SymbolTableWriter, chunkResults: readonly ParseWorkerResult[], exportedTypeMap?: ExportedTypeMap) => WorkerExtractedData;
49
49
  /**
50
- * Dispatch a chunk's files to the worker pool and return the RAW per-worker
51
- * results, WITHOUT merging them into the graph. Split out from
52
- * {@link processParsing} so the parse loop can overlap one chunk's
53
- * merge (main-thread, via {@link mergeChunkResults}) with the NEXT chunk's
54
- * worker parse the merge is the only remaining serial main-thread step once
55
- * ParsedFile serialization moved into the workers (#worker-idle pipelining).
50
+ * Dispatch ONE chunk's files to the worker pool and return the RAW per-worker
51
+ * results, WITHOUT merging them into the graph. A thin single-group wrapper
52
+ * over {@link dispatchChunkParseRound}, used by {@link processParsing}'s
53
+ * one-shot path. The chunk-to-chunk overlap this once described now lives in
54
+ * `parse-impl.ts` at ROUND granularity (`startRound` / `drainRound` /
55
+ * `closeRound`), which batches several chunks into one dispatch.
56
56
  * Returns `[]` for an all-unparseable chunk (the caller merges `[]` → empty).
57
57
  */
58
58
  export declare const dispatchChunkParse: (files: {
@@ -67,6 +67,24 @@ outRawResults?: ParseWorkerResult[],
67
67
  * (#2038). `undefined` ⇒ no durable write (tests / no-cache path).
68
68
  */
69
69
  chunkHash?: string) => Promise<ParseWorkerResult[]>;
70
+ /**
71
+ * Dispatch SEVERAL parse-cache chunks as one pool round and return their raw
72
+ * results, one array per input group in input order.
73
+ *
74
+ * `WorkerPool.dispatch` is a barrier, so one round-trip per chunk leaves most
75
+ * slots idle whenever a chunk is smaller than the pool — which stable
76
+ * `(language, hash(path) % 128)` packs usually are. Batching chunks into one
77
+ * `dispatchGroups` call removes those barriers; jobs are still cut at chunk
78
+ * boundaries, so every result stays attributable to the chunk whose cache key
79
+ * owns it.
80
+ */
81
+ export declare const dispatchChunkParseRound: (groups: ReadonlyArray<{
82
+ items: {
83
+ path: string;
84
+ content: string;
85
+ }[];
86
+ chunkHash?: string;
87
+ }>, workerPool: WorkerPool, onFileProgress?: FileProgressCallback) => Promise<ParseWorkerResult[][]>;
70
88
  export declare const processParsing: (graph: KnowledgeGraph, files: {
71
89
  path: string;
72
90
  content: string;
@@ -147,12 +147,12 @@ export const mergeChunkResults = (graph, symbolTable, chunkResults, exportedType
147
147
  };
148
148
  };
149
149
  /**
150
- * Dispatch a chunk's files to the worker pool and return the RAW per-worker
151
- * results, WITHOUT merging them into the graph. Split out from
152
- * {@link processParsing} so the parse loop can overlap one chunk's
153
- * merge (main-thread, via {@link mergeChunkResults}) with the NEXT chunk's
154
- * worker parse the merge is the only remaining serial main-thread step once
155
- * ParsedFile serialization moved into the workers (#worker-idle pipelining).
150
+ * Dispatch ONE chunk's files to the worker pool and return the RAW per-worker
151
+ * results, WITHOUT merging them into the graph. A thin single-group wrapper
152
+ * over {@link dispatchChunkParseRound}, used by {@link processParsing}'s
153
+ * one-shot path. The chunk-to-chunk overlap this once described now lives in
154
+ * `parse-impl.ts` at ROUND granularity (`startRound` / `drainRound` /
155
+ * `closeRound`), which batches several chunks into one dispatch.
156
156
  * Returns `[]` for an all-unparseable chunk (the caller merges `[]` → empty).
157
157
  */
158
158
  export const dispatchChunkParse = async (files, workerPool, onFileProgress,
@@ -164,23 +164,42 @@ outRawResults,
164
164
  * (#2038). `undefined` ⇒ no durable write (tests / no-cache path).
165
165
  */
166
166
  chunkHash) => {
167
- const parseableFiles = [];
168
- for (const file of files) {
169
- const lang = getLanguageFromFilename(file.path);
170
- if (lang)
171
- parseableFiles.push({ path: file.path, content: file.content });
172
- }
173
- if (parseableFiles.length === 0)
174
- return [];
175
- const total = files.length;
176
- const chunkResults = await workerPool.dispatch(parseableFiles, (filesProcessed) => {
177
- onFileProgress?.(Math.min(filesProcessed, total), total, 'Parsing...');
178
- }, chunkHash);
167
+ const [chunkResults = []] = await dispatchChunkParseRound([{ items: files, chunkHash }], workerPool, onFileProgress);
179
168
  // Capture raw results for the incremental parse cache before merging.
180
169
  if (outRawResults) {
181
170
  for (const r of chunkResults)
182
171
  outRawResults.push(r);
183
172
  }
173
+ return chunkResults;
174
+ };
175
+ /**
176
+ * Dispatch SEVERAL parse-cache chunks as one pool round and return their raw
177
+ * results, one array per input group in input order.
178
+ *
179
+ * `WorkerPool.dispatch` is a barrier, so one round-trip per chunk leaves most
180
+ * slots idle whenever a chunk is smaller than the pool — which stable
181
+ * `(language, hash(path) % 128)` packs usually are. Batching chunks into one
182
+ * `dispatchGroups` call removes those barriers; jobs are still cut at chunk
183
+ * boundaries, so every result stays attributable to the chunk whose cache key
184
+ * owns it.
185
+ */
186
+ export const dispatchChunkParseRound = async (groups, workerPool, onFileProgress) => {
187
+ const dispatchGroups = groups.map((group) => {
188
+ const items = [];
189
+ for (const file of group.items) {
190
+ const lang = getLanguageFromFilename(file.path);
191
+ if (lang)
192
+ items.push({ path: file.path, content: file.content });
193
+ }
194
+ return { items, chunkHash: group.chunkHash };
195
+ });
196
+ const total = groups.reduce((sum, group) => sum + group.items.length, 0);
197
+ if (dispatchGroups.every((group) => group.items.length === 0))
198
+ return groups.map(() => []);
199
+ const perGroup = await workerPool.dispatchGroups(dispatchGroups, (filesProcessed) => {
200
+ onFileProgress?.(Math.min(filesProcessed, total), total, 'Parsing...');
201
+ });
202
+ const chunkResults = perGroup.flat();
184
203
  // Skipped-language telemetry (worker output, independent of the merge).
185
204
  const skippedLanguages = new Map();
186
205
  for (const result of chunkResults) {
@@ -238,7 +257,7 @@ chunkHash) => {
238
257
  logger.warn(` Sanitized ${skippedPaths.length} file(s) with non-serializable parse output: ${shown}${more}`);
239
258
  }
240
259
  onFileProgress?.(total, total, 'done');
241
- return chunkResults;
260
+ return perGroup;
242
261
  };
243
262
  // ============================================================================
244
263
  // Public API
@@ -15,7 +15,7 @@
15
15
  * @module
16
16
  */
17
17
  import { BindingAccumulator, enrichExportedTypeMap, } from '../binding-accumulator.js';
18
- import { mergeChunkResults, dispatchChunkParse } from '../parsing-processor.js';
18
+ import { mergeChunkResults, dispatchChunkParseRound } from '../parsing-processor.js';
19
19
  import { fileContentHash, computeChunkHash, loadParseCacheChunk, persistParseCacheChunk, PARSE_CACHE_VERSION, packParseCacheChunks, } from '../../../storage/parse-cache.js';
20
20
  import { clearParsedFileStore, persistParsedFileChunk, loadParsedFilesForPaths, getDurableParsedFileDir, loadDurableParsedFileIndex, prepareDurableParsedFileChunk, durableChunkHasShards, } from '../../../storage/parsedfile-store.js';
21
21
  import { DEFAULT_PDG_MAX_FUNCTION_LINES } from '../cfg/collect.js';
@@ -132,6 +132,28 @@ const CHUNK_BYTES_PER_WORKER = DEFAULT_CHUNK_BYTE_BUDGET;
132
132
  const TARGET_JOBS_PER_WORKER = 3;
133
133
  /** Floor for a derived sub-batch so jobs don't shrink to per-file IPC churn. */
134
134
  const MIN_SUB_BATCH_BYTES = 256 * 1024;
135
+ /**
136
+ * Source bytes of cache-missing chunks allowed in flight in one pool round.
137
+ *
138
+ * A `dispatch` is a barrier, so one round-trip per cache pack leaves most slots
139
+ * idle: packs are keyed by `(language, hash(path) % 128)` and routinely land far
140
+ * under {@link DEFAULT_CHUNK_BYTE_BUDGET} (this repo: 1285 packs where the byte
141
+ * budget alone needs 16, 549 of them holding a single file). Rounds batch packs
142
+ * into one `dispatchGroups` call without touching pack identity.
143
+ *
144
+ * This is the in-flight cap, the same role Piscina's `maxQueue` plays: bigger
145
+ * rounds remove more barriers but hold more file content and more un-merged
146
+ * worker output on the main thread at once. Defaulting to one chunk budget
147
+ * keeps in-flight source bytes at the magnitude the loop already prefetched
148
+ * (`parseChunkConcurrency`, 2 chunks ahead). Override via
149
+ * `GITNEXUS_PARSE_ROUND_BYTES`.
150
+ */
151
+ function resolveParseRoundByteBudget(options) {
152
+ const env = Number(process.env.GITNEXUS_PARSE_ROUND_BYTES);
153
+ if (Number.isFinite(env) && env > 0)
154
+ return env;
155
+ return resolveChunkByteBudget(options);
156
+ }
135
157
  function resolveChunkByteBudget(options) {
136
158
  const opt = options?.chunkByteBudget;
137
159
  if (typeof opt === 'number' && Number.isFinite(opt) && opt > 0)
@@ -596,13 +618,33 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
596
618
  // the env can't change mid-run.
597
619
  const verboseThroughputLog = isDev || isVerboseIngestionEnabled();
598
620
  const heapProbeEveryN = isDebugHeapEnabled() ? 25 : 0;
599
- let pendingWorkerChunk = null;
621
+ const roundByteBudget = resolveParseRoundByteBudget(options);
622
+ let roundEntries = [];
623
+ let roundMissBytes = 0;
624
+ /**
625
+ * Bytes an open round is HOLDING, counting hits as well as misses.
626
+ *
627
+ * `roundMissBytes` alone bounds only what the workers are asked to do, so a
628
+ * warm run — where nothing misses — would never reach the close condition
629
+ * and would buffer every chunk's cached output until the tail drain. That
630
+ * is the #2649 heap failure on a large repo. Closing on either cap keeps a
631
+ * hits-only run draining at the same cadence as a cold one; `startRound`
632
+ * already supports a round with no misses.
633
+ */
634
+ let roundBufferedBytes = 0;
635
+ /**
636
+ * Files QUEUED into rounds so far. `filesParsedSoFar` only advances when a
637
+ * round drains, so it is the right number for the throughput log but would
638
+ * pin a warm run's progress bar at the phase floor for the whole loop.
639
+ */
640
+ let queuedFilesSoFar = 0;
641
+ let pendingRound = null;
600
642
  // Apply one chunk's merged worker data: per-chunk aggregation into the
601
643
  // run-level accumulators + the throughput log. Shared by the cache-hit
602
644
  // (inline) and worker (deferred) paths. The `| null` guard is defensive —
603
645
  // every live caller passes real worker data now that sequential parsing
604
646
  // (which was the only path that passed null) is gone.
605
- const applyChunkResults = async (chunkWorkerData, chunkIdx, chunkFiles, chunkStartMs) => {
647
+ const applyChunkResults = async (chunkWorkerData, chunkIdx, fileCount, chunkStartMs) => {
606
648
  if (chunkWorkerData) {
607
649
  for (const filePath of chunkWorkerData.scopeExtractionFailures) {
608
650
  scopeExtractionFailures.add(filePath);
@@ -690,27 +732,27 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
690
732
  allORMQueries.push(item);
691
733
  }
692
734
  }
693
- filesParsedSoFar += chunkFiles.length;
735
+ filesParsedSoFar += fileCount;
694
736
  if (verboseThroughputLog && chunkStartMs !== null) {
695
737
  const elapsedMs = Date.now() - chunkStartMs;
696
- const filesPerSec = elapsedMs > 0 ? (chunkFiles.length * 1000) / elapsedMs : 0;
738
+ const filesPerSec = elapsedMs > 0 ? (fileCount * 1000) / elapsedMs : 0;
697
739
  const stats = workerPool?.getStats?.();
698
740
  const poolFrag = stats
699
741
  ? ` pool: ${stats.activeSlots}/${stats.size} active, ` +
700
742
  `${stats.quarantined} quarantined${stats.poolBroken ? ', BROKEN' : ''}`
701
743
  : ' (cache replay)';
702
- logger.info(`📊 chunk ${chunkIdx + 1}/${numChunks}: ${chunkFiles.length} files in ${elapsedMs}ms ` +
744
+ logger.info(`📊 chunk ${chunkIdx + 1}/${numChunks}: ${fileCount} files in ${elapsedMs}ms ` +
703
745
  `(${filesPerSec.toFixed(1)} files/s)${poolFrag}`);
704
746
  }
705
747
  };
706
748
  // Merge + finalize a parked worker chunk: graph merge (the overlapped
707
749
  // main-thread step) → parse-cache write-guard → run-level aggregation.
708
- const finalizeWorkerChunk = async (p) => {
709
- const chunkWorkerData = mergeChunkResults(graph, symbolTable, p.rawResults, exportedTypeMap);
750
+ const finalizeWorkerChunk = async (p, rawResults) => {
751
+ const chunkWorkerData = mergeChunkResults(graph, symbolTable, rawResults, exportedTypeMap);
710
752
  // Persist raw results for this chunk hash (skipping when any chunk file
711
753
  // was worker-quarantined, so the narrower rawResults isn't cached under
712
754
  // the full-chunk key — see the original inline note / U20.U2).
713
- if (parseCache && p.chunkHash && p.rawResults.length > 0) {
755
+ if (parseCache && p.chunkHash && rawResults.length > 0) {
714
756
  const quarantineSet = new Set(workerPool?.getQuarantinedPaths?.() ?? []);
715
757
  const chunkHadQuarantine = p.chunkFiles.some((f) => quarantineSet.has(f.path));
716
758
  if (chunkHadQuarantine) {
@@ -722,13 +764,150 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
722
764
  }
723
765
  }
724
766
  else {
725
- await persistParseCacheChunk(parseCache, p.chunkHash, p.rawResults);
767
+ await persistParseCacheChunk(parseCache, p.chunkHash, rawResults);
726
768
  if (isDev) {
727
769
  logger.info(`📦 parse-cache MISS+store: chunk ${p.chunkIdx + 1}/${numChunks} (${p.chunkFiles.length} files, ${p.chunkHash.slice(0, 8)})`);
728
770
  }
729
771
  }
730
772
  }
731
- await applyChunkResults(chunkWorkerData, p.chunkIdx, p.chunkFiles, p.chunkStartMs);
773
+ await applyChunkResults(chunkWorkerData, p.chunkIdx, p.chunkFiles.length, p.chunkStartMs);
774
+ };
775
+ /**
776
+ * Dispatch a round's cache misses as ONE pool round. Returns the parked
777
+ * round; the caller drains it after starting the next one so the workers
778
+ * parse round N+1 while the main thread merges round N (the same overlap
779
+ * the per-chunk loop had, at round granularity).
780
+ */
781
+ const startRound = async (entries) => {
782
+ if (entries.length === 0)
783
+ return null;
784
+ const misses = entries.filter((entry) => entry.kind === 'miss');
785
+ if (misses.length === 0) {
786
+ return { entries, results: Promise.resolve([]) };
787
+ }
788
+ for (const miss of misses) {
789
+ if (durableParsedFileDir !== undefined && miss.chunkHash !== null) {
790
+ try {
791
+ await prepareDurableParsedFileChunk(durableParsedFileDir, miss.chunkHash);
792
+ }
793
+ catch (err) {
794
+ // The durable store is an optimization — degrade like the restore
795
+ // path does instead of failing the analyze. Workers recreate the
796
+ // directory on write, so at worst the old generation lingers.
797
+ logger.warn({ err, chunkHash: miss.chunkHash.slice(0, 8) }, 'parsedfile-cache: could not reset durable chunk generation; continuing');
798
+ }
799
+ }
800
+ }
801
+ const roundFiles = misses.reduce((sum, miss) => sum + miss.chunkFiles.length, 0);
802
+ const firstIdx = misses[0].chunkIdx;
803
+ const lastIdx = misses[misses.length - 1].chunkIdx;
804
+ const progressForRound = (current, _total, filePath) => {
805
+ // Rounds queued before this one are already counted in
806
+ // `queuedFilesSoFar`; `current` is this round's own worker progress.
807
+ const globalCurrent = queuedFilesSoFar - roundFiles + current;
808
+ // Parse phase covers 20-70 (M2). Deferred extraction handles 70-95.
809
+ const parsingProgress = 20 + (globalCurrent / totalParseable) * 50;
810
+ onProgress({
811
+ phase: 'parsing',
812
+ percent: Math.round(parsingProgress),
813
+ message: firstIdx === lastIdx
814
+ ? `Parsing chunk ${firstIdx + 1}/${numChunks}...`
815
+ : `Parsing chunks ${firstIdx + 1}-${lastIdx + 1}/${numChunks}...`,
816
+ detail: filePath,
817
+ stats: {
818
+ filesProcessed: globalCurrent,
819
+ totalFiles: totalParseable,
820
+ nodesCreated: graph.nodeCount,
821
+ },
822
+ });
823
+ };
824
+ const activeWorkerPool = getOrCreateWorkerPool();
825
+ if (verboseThroughputLog) {
826
+ logger.info(`🚚 round: ${misses.length} chunk(s) ${firstIdx + 1}-${lastIdx + 1}/${numChunks}, ` +
827
+ `${roundFiles} files in one dispatch`);
828
+ }
829
+ const results = dispatchChunkParseRound(misses.map((miss) => ({
830
+ items: miss.chunkFiles,
831
+ chunkHash: miss.chunkHash ?? undefined,
832
+ })), activeWorkerPool, progressForRound);
833
+ // Mark handled so a rejection during the overlap drain below isn't
834
+ // flagged as unhandled; the `await` in drainRound re-throws it for real
835
+ // handling.
836
+ results.catch(() => { });
837
+ return { entries, results };
838
+ };
839
+ /**
840
+ * Merge + finalize every chunk of a parked round, in `chunkIdx` order.
841
+ * Takes RESOLVED worker output: the round's dispatch must already have
842
+ * settled before this runs, because the pool allows only one dispatch in
843
+ * flight at a time (see `closeRound`).
844
+ */
845
+ const drainRound = async (round) => {
846
+ const missResults = round.missResults;
847
+ const missCount = round.entries.reduce((sum, entry) => sum + (entry.kind === 'miss' ? 1 : 0), 0);
848
+ // `dispatchGroups` returns one array per input group. If that contract
849
+ // ever breaks, every later entry in this round would silently merge the
850
+ // wrong chunk's results and skip its cache write, with a clean exit.
851
+ if (missResults.length !== missCount) {
852
+ throw new Error(`Parse round result mismatch: ${missResults.length} result group(s) for ${missCount} dispatched chunk(s).`);
853
+ }
854
+ let missIdx = 0;
855
+ for (const entry of round.entries) {
856
+ if (entry.kind === 'hit') {
857
+ const chunkWorkerData = mergeChunkResults(graph, symbolTable, entry.cachedRaw, exportedTypeMap);
858
+ await applyChunkResults(chunkWorkerData, entry.chunkIdx, entry.fileCount, entry.chunkStartMs);
859
+ continue;
860
+ }
861
+ await finalizeWorkerChunk(entry, missResults[missIdx++]);
862
+ }
863
+ };
864
+ /**
865
+ * Close the accumulated round.
866
+ *
867
+ * `WorkerPool.dispatch`/`dispatchGroups` is NOT reentrant — concurrent
868
+ * calls race on the shared per-slot busy/in-flight state and wedge the
869
+ * pool until every worker idle-times out. So exactly one dispatch is in
870
+ * flight here: start this round, merge the PREVIOUS round (whose results
871
+ * are already resolved) while these workers run, then await this round and
872
+ * park it resolved for the next close to merge.
873
+ */
874
+ const closeRound = async () => {
875
+ const started = await startRound(roundEntries);
876
+ roundEntries = [];
877
+ roundMissBytes = 0;
878
+ roundBufferedBytes = 0;
879
+ const previous = pendingRound;
880
+ pendingRound = null;
881
+ if (previous) {
882
+ try {
883
+ await drainRound(previous);
884
+ }
885
+ catch (err) {
886
+ // The round started above is still on the workers. Unwinding now
887
+ // reaches this function's `finally`, which calls `terminate()` — and
888
+ // terminate kills busy workers outright, which is the #2432
889
+ // mid-N-API SIGABRT hazard. Let the in-flight round settle first so
890
+ // the pool is idle, then propagate the original failure.
891
+ await started?.results.catch(() => undefined);
892
+ throw err;
893
+ }
894
+ }
895
+ if (!started)
896
+ return;
897
+ let missResults;
898
+ try {
899
+ missResults = await started.results;
900
+ }
901
+ catch (err) {
902
+ if (!(err instanceof WorkerPoolInitializationError))
903
+ throw err;
904
+ // Every worker crashed during startup and the pool's bounded self-heal
905
+ // was exhausted. Fail fast (#1741) — there is no sequential parser to
906
+ // degrade to. `handleWorkerStartupFailure` always throws, so
907
+ // `missResults` stays definitely assigned for the parked round below.
908
+ handleWorkerStartupFailure(err);
909
+ }
910
+ pendingRound = { entries: started.entries, missResults };
732
911
  };
733
912
  for (let chunkIdx = 0; chunkIdx < numChunks; chunkIdx++) {
734
913
  if (heapProbeEveryN > 0 && chunkIdx > 0 && chunkIdx % heapProbeEveryN === 0) {
@@ -812,13 +991,8 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
812
991
  // Cache hit: replay cached worker output. Finalize any parked worker
813
992
  // chunk FIRST so deferred aggregation stays in chunk order, then merge
814
993
  // + apply this hit inline (no worker dispatch to overlap).
815
- if (pendingWorkerChunk) {
816
- await finalizeWorkerChunk(pendingWorkerChunk);
817
- pendingWorkerChunk = null;
818
- }
819
994
  chunkCacheHits++;
820
995
  parseCacheHitFileCount += chunkFiles.length;
821
- const chunkWorkerData = mergeChunkResults(graph, symbolTable, cachedRaw, exportedTypeMap);
822
996
  if (isDev) {
823
997
  logger.info(`📦 parse-cache HIT: chunk ${chunkIdx + 1}/${numChunks} (${chunkFiles.length} files, ${chunkHash?.slice(0, 8) ?? 'unknown'})`);
824
998
  }
@@ -830,85 +1004,45 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
830
1004
  // takes 70-95 so the UI advances through the (potentially long)
831
1005
  // resolution stages instead of holding at 82 (M2 from PR #1693
832
1006
  // review).
833
- percent: Math.round(20 + ((filesParsedSoFar + cachedFiles) / totalParseable) * 50),
1007
+ percent: Math.round(20 + ((queuedFilesSoFar + cachedFiles) / totalParseable) * 50),
834
1008
  message: `Parsing chunk ${chunkIdx + 1}/${numChunks} (cache)...`,
835
1009
  stats: {
836
- filesProcessed: filesParsedSoFar + cachedFiles,
1010
+ filesProcessed: queuedFilesSoFar + cachedFiles,
837
1011
  totalFiles: totalParseable,
838
1012
  nodesCreated: graph.nodeCount,
839
1013
  },
840
1014
  });
841
1015
  // The durable gate already snapshotted warm `.v8` shards into the
842
- // run-scoped store for scope resolution.
843
- await applyChunkResults(chunkWorkerData, chunkIdx, chunkFiles, chunkStartMs);
1016
+ // run-scoped store for scope resolution. Queue into the round so this
1017
+ // hit still finalizes in `chunkIdx` order relative to its neighbours.
1018
+ roundEntries.push({
1019
+ kind: 'hit',
1020
+ chunkIdx,
1021
+ fileCount: chunkFiles.length,
1022
+ chunkStartMs,
1023
+ cachedRaw,
1024
+ });
1025
+ for (const file of chunkFiles)
1026
+ roundBufferedBytes += file.content.length;
1027
+ queuedFilesSoFar += chunkFiles.length;
844
1028
  }
845
1029
  else {
846
- // Cache miss: dispatch to workers, capture the raw results, store
847
- // them under the chunk hash for the next run.
1030
+ // Cache miss: queue for the round's single dispatch; the raw results
1031
+ // are stored under the chunk hash when the round drains.
848
1032
  chunkCacheMisses++;
849
1033
  reparsedFileCount += chunkFiles.length;
850
- if (durableParsedFileDir !== undefined && chunkHash !== null) {
851
- try {
852
- await prepareDurableParsedFileChunk(durableParsedFileDir, chunkHash);
853
- }
854
- catch (err) {
855
- // The durable store is an optimization — degrade like the restore
856
- // path does instead of failing the analyze. Workers recreate the
857
- // directory on write, so at worst the old generation lingers.
858
- logger.warn({ err, chunkHash: chunkHash.slice(0, 8) }, 'parsedfile-cache: could not reset durable chunk generation; continuing');
859
- }
860
- }
861
- const progressForChunk = (current, _total, filePath) => {
862
- const globalCurrent = filesParsedSoFar + current;
863
- // Parse phase covers 20-70 (M2). Deferred extraction handles 70-95.
864
- const parsingProgress = 20 + (globalCurrent / totalParseable) * 50;
865
- onProgress({
866
- phase: 'parsing',
867
- percent: Math.round(parsingProgress),
868
- message: `Parsing chunk ${chunkIdx + 1}/${numChunks}...`,
869
- detail: filePath,
870
- stats: {
871
- filesProcessed: globalCurrent,
872
- totalFiles: totalParseable,
873
- nodesCreated: graph.nodeCount,
874
- },
875
- });
876
- };
877
- const activeWorkerPool = getOrCreateWorkerPool();
878
- // Worker path — PIPELINE: kick off this chunk's dispatch, merge the
879
- // PREVIOUS chunk while these workers parse, then park this chunk for
880
- // the next iteration to merge (overlapping its parse). The deferred
881
- // merge + parse-cache write-guard + aggregation all run in
882
- // `finalizeWorkerChunk`, in chunk order. The pool is the sole parse
883
- // path — `getOrCreateWorkerPool` returns a pool or throws.
884
- const dispatchPromise = dispatchChunkParse(chunkFiles, activeWorkerPool, progressForChunk, undefined, chunkHash ?? undefined);
885
- // Mark handled so a rejection during the overlap drain below isn't
886
- // flagged as unhandled; the `await` re-throws it for real handling.
887
- dispatchPromise.catch(() => { });
888
- if (pendingWorkerChunk) {
889
- await finalizeWorkerChunk(pendingWorkerChunk);
890
- pendingWorkerChunk = null;
1034
+ roundEntries.push({ kind: 'miss', chunkIdx, chunkHash, chunkFiles, chunkStartMs });
1035
+ for (const file of chunkFiles) {
1036
+ roundMissBytes += file.content.length;
1037
+ roundBufferedBytes += file.content.length;
891
1038
  }
892
- let chunkResults;
893
- try {
894
- chunkResults = await dispatchPromise;
895
- }
896
- catch (err) {
897
- if (!(err instanceof WorkerPoolInitializationError))
898
- throw err;
899
- // Every worker crashed during startup and the pool's bounded
900
- // self-heal was exhausted. Fail fast (#1741) — there is no sequential
901
- // parser to degrade to. `handleWorkerStartupFailure` always throws, so
902
- // `chunkResults` stays definitely assigned for the parked chunk below.
903
- handleWorkerStartupFailure(err);
904
- }
905
- pendingWorkerChunk = {
906
- rawResults: chunkResults,
907
- chunkIdx,
908
- chunkHash,
909
- chunkFiles,
910
- chunkStartMs,
911
- };
1039
+ queuedFilesSoFar += chunkFiles.length;
1040
+ }
1041
+ // Close on EITHER cap. `roundMissBytes` sizes the worker round;
1042
+ // `roundBufferedBytes` bounds what the main thread is holding, which is
1043
+ // the only cap a warm run can ever reach.
1044
+ if (roundMissBytes >= roundByteBudget || roundBufferedBytes >= roundByteBudget) {
1045
+ await closeRound();
912
1046
  }
913
1047
  // (Per-chunk aggregation + parse-cache write + throughput log now run in
914
1048
  // `applyChunkResults` / `finalizeWorkerChunk` — see the merge-pipelining
@@ -916,11 +1050,14 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
916
1050
  // resolution in the single end-of-loop pass below, the rest by the
917
1051
  // scope-resolution phase, RING4-2 #943.)
918
1052
  }
919
- // Drain the final parked worker chunk the last pipelined chunk has no
920
- // successor to overlap its merge with, so merge + finalize it here.
921
- if (pendingWorkerChunk) {
922
- await finalizeWorkerChunk(pendingWorkerChunk);
923
- pendingWorkerChunk = null;
1053
+ // Drain the tail: close the partially-filled round, then drain the round
1054
+ // it parked — the last round has no successor to overlap its merge with.
1055
+ if (roundEntries.length > 0)
1056
+ await closeRound();
1057
+ if (pendingRound) {
1058
+ const last = pendingRound;
1059
+ pendingRound = null;
1060
+ await drainRound(last);
924
1061
  }
925
1062
  if (isDev && parseCache && (chunkCacheHits > 0 || chunkCacheMisses > 0)) {
926
1063
  logger.info(`📦 parse-cache summary: ${chunkCacheHits} chunk hit(s), ${chunkCacheMisses} miss(es) across ${numChunks} chunk(s)`);
@@ -993,11 +993,22 @@ function reportWarning(message) {
993
993
  logger.warn(message);
994
994
  }
995
995
  }
996
+ // Keep compiled queries across jobs in this worker. A language can select
997
+ // multiple native grammars, so both grammar identity and query text matter.
998
+ const compiledQueries = new WeakMap();
996
999
  const processFileGroup = (files, language, queryString, result, onFileProcessed) => {
997
1000
  let query;
998
1001
  try {
999
1002
  const lang = parser.getLanguage();
1000
- query = new Parser.Query(lang, queryString);
1003
+ let queries = compiledQueries.get(lang);
1004
+ if (!queries) {
1005
+ queries = new Map();
1006
+ compiledQueries.set(lang, queries);
1007
+ }
1008
+ const cached = queries.get(queryString);
1009
+ query = cached ?? new Parser.Query(lang, queryString);
1010
+ if (!cached)
1011
+ queries.set(queryString, query);
1001
1012
  }
1002
1013
  catch (err) {
1003
1014
  reportWarning(`Query compilation failed for ${language}: ${err instanceof Error ? err.message : String(err)}`);
@@ -1,8 +1,38 @@
1
1
  import { Worker } from 'node:worker_threads';
2
+ /**
3
+ * One content-addressed parse-cache chunk's worth of work inside a pool round.
4
+ * See {@link WorkerPool.dispatchGroups}.
5
+ */
6
+ export interface DispatchGroup<TInput> {
7
+ readonly items: readonly TInput[];
8
+ /**
9
+ * Chunk hash tagged onto every job derived from `items`, exactly as the
10
+ * `chunkHash` argument of {@link WorkerPool.dispatch} does for a lone chunk.
11
+ */
12
+ readonly chunkHash?: string;
13
+ }
2
14
  export interface WorkerPool {
3
15
  /**
4
- * Dispatch items across workers. Items are split into bounded jobs, each job
5
- * is committed independently, and stalled jobs are split/retried locally.
16
+ * Dispatch several content-addressed chunks in ONE pool round.
17
+ *
18
+ * `dispatch` is a barrier: it resolves only once every job it created has
19
+ * committed, so dispatching one small parse-cache pack at a time leaves most
20
+ * slots idle for the whole round-trip. Stable packs are keyed by
21
+ * `(language, hash(path) % 128)`, which routinely yields packs far below the
22
+ * byte budget — on this repo, 1285 packs where the budget alone needs 16, and
23
+ * 549 of them hold a single file. Batching packs into one round removes those
24
+ * barriers without touching pack identity: jobs are still cut at group
25
+ * boundaries, so each job carries exactly one `chunkHash` and every result
26
+ * stays attributable to the pack that owns its cache key.
27
+ *
28
+ * Returns one result array per input group, in input order. A group whose
29
+ * items were all quarantined yields an empty array.
30
+ */
31
+ dispatchGroups<TInput, TResult>(groups: readonly DispatchGroup<TInput>[], onProgress?: (filesProcessed: number) => void): Promise<TResult[][]>;
32
+ /**
33
+ * Dispatch ONE chunk across workers — {@link WorkerPool.dispatchGroups} with
34
+ * a single group. Items are split into bounded jobs, each job is committed
35
+ * independently, and stalled jobs are split/retried locally.
6
36
  *
7
37
  * Files in {@link WorkerPool.getQuarantinedPaths} are filtered out before
8
38
  * dispatch — they have already caused a worker death this pool lifetime and
@@ -488,9 +488,14 @@ function inFlightExcludePath(job, lastProgress) {
488
488
  const path = itemPath(job.items[lastProgress]);
489
489
  return path ? [path] : [];
490
490
  }
491
- function createJobs(items, maxItems, maxBytes, timeoutMs, chunkHash) {
491
+ /**
492
+ * Cut `items` into bounded jobs. `startIndexOffset` places those jobs on a
493
+ * shared index space so several groups can be laid out end to end in one
494
+ * dispatch round and every result still sorts back into global input order.
495
+ */
496
+ function createJobs(items, maxItems, maxBytes, timeoutMs, chunkHash, startIndexOffset = 0) {
492
497
  const jobs = [];
493
- let startIndex = 0;
498
+ let startIndex = startIndexOffset;
494
499
  let batch = [];
495
500
  let batchBytes = 0;
496
501
  const flush = () => {
@@ -827,7 +832,31 @@ export const createWorkerPool = (workerUrl, poolSize, options) => {
827
832
  // First dispatch awaits this; it settles every slot's bounded retry loop in
828
833
  // parallel and drops the unrecoverable ones before any dispatch can fire.
829
834
  const initialReadyGate = Promise.allSettled(workers.map((_, i) => bringSlotReady(i))).then(() => undefined);
830
- const dispatch = async (items, onProgress, chunkHash) => {
835
+ /**
836
+ * Guards the one-dispatch-at-a-time contract. The dispatch machinery keeps
837
+ * its jobs/busy-slot/in-flight state per call, so two concurrent dispatches
838
+ * hand the same slots out twice: both stall, and the failure surfaces only
839
+ * when every worker hits its idle timeout (10s+ of a wedged pool with no
840
+ * indication of the cause). Fail loudly at the call instead.
841
+ */
842
+ let dispatchInFlight = false;
843
+ /**
844
+ * Claim the pool synchronously, then run the dispatch. The claim CANNOT be
845
+ * taken inside `dispatchGroupsInner`: its first statement awaits the
846
+ * readiness gate, so two calls made in the same tick would both get past the
847
+ * check before either set the flag.
848
+ */
849
+ const dispatchGroups = (groups, onProgress) => {
850
+ if (dispatchInFlight) {
851
+ return Promise.reject(new WorkerPoolDispatchError('Worker pool dispatch is already in flight. `dispatch`/`dispatchGroups` is not ' +
852
+ 'reentrant — await the previous call before starting another on the same pool.', []));
853
+ }
854
+ dispatchInFlight = true;
855
+ return dispatchGroupsInner(groups, onProgress).finally(() => {
856
+ dispatchInFlight = false;
857
+ });
858
+ };
859
+ const dispatchGroupsInner = async (groups, onProgress) => {
831
860
  // Await the initial-spawn readiness gate (F13). On first dispatch
832
861
  // this blocks for up to poolOptions.workerReadyTimeoutMs while every initial
833
862
  // worker's `{type:'ready'}` handshake is checked; on subsequent
@@ -842,8 +871,9 @@ export const createWorkerPool = (workerUrl, poolSize, options) => {
842
871
  throw new WorkerPoolDispatchError(`Worker pool circuit breaker tripped${reason}. ` +
843
872
  `Subsequent dispatches require a fresh pool instance.`, []);
844
873
  }
845
- if (items.length === 0)
846
- return [];
874
+ const emptyPerGroup = () => groups.map(() => []);
875
+ if (groups.every((group) => group.items.length === 0))
876
+ return emptyPerGroup();
847
877
  if (activeSlots.size === 0) {
848
878
  const detail = initialReadinessFailures.length > 0
849
879
  ? ` after initial ready handshake: ${initialReadinessFailures.join('; ')}`
@@ -859,17 +889,38 @@ export const createWorkerPool = (workerUrl, poolSize, options) => {
859
889
  // Layer 3: filter out quarantined paths so a known-bad file never reaches
860
890
  // a worker again this pool lifetime. The caller queries
861
891
  // `getQuarantinedPaths` after dispatch to route filtered items.
862
- const dispatchableItems = [];
863
- for (const item of items) {
864
- const path = itemPath(item);
865
- if (path !== undefined && quarantine.has(path))
866
- continue;
867
- dispatchableItems.push(item);
892
+ const dispatchableGroups = groups.map((group) => {
893
+ const items = [];
894
+ for (const item of group.items) {
895
+ const path = itemPath(item);
896
+ if (path !== undefined && quarantine.has(path))
897
+ continue;
898
+ items.push(item);
899
+ }
900
+ return { items, chunkHash: group.chunkHash };
901
+ });
902
+ const dispatchableCount = dispatchableGroups.reduce((sum, group) => sum + group.items.length, 0);
903
+ if (dispatchableCount === 0)
904
+ return emptyPerGroup();
905
+ // Stable cache packs can be much smaller than either job ceiling. Split
906
+ // those packs across the live slots too, otherwise each serial dispatch
907
+ // feeds only one worker. Keep both configured ceilings as upper bounds.
908
+ const maxItemsPerJob = Math.min(poolOptions.subBatchSize, Math.max(1, Math.floor(dispatchableCount / activeSlots.size)));
909
+ // Lay the groups end to end on one index space and cut jobs at every group
910
+ // boundary. A job therefore belongs to exactly one group, which is what
911
+ // lets a result be attributed back to the parse-cache chunk that owns it
912
+ // (and what keeps `chunkHash` a per-job constant through splits/requeues).
913
+ const jobs = [];
914
+ const groupEnds = [];
915
+ let groupStart = 0;
916
+ for (const group of dispatchableGroups) {
917
+ for (const job of createJobs(group.items, maxItemsPerJob, poolOptions.subBatchMaxBytes, poolOptions.subBatchIdleTimeoutMs, group.chunkHash, groupStart)) {
918
+ jobs.push(job);
919
+ }
920
+ groupStart += group.items.length;
921
+ groupEnds.push(groupStart);
868
922
  }
869
- if (dispatchableItems.length === 0)
870
- return [];
871
- const jobs = createJobs(dispatchableItems, poolOptions.subBatchSize, poolOptions.subBatchMaxBytes, poolOptions.subBatchIdleTimeoutMs, chunkHash);
872
- return new Promise((resolve, reject) => {
923
+ return await new Promise((resolve, reject) => {
873
924
  const results = [];
874
925
  const inFlightProgress = new Array(size).fill(0);
875
926
  // Tracks which slots are currently mid-job so the "wake idle slots"
@@ -899,7 +950,7 @@ export const createWorkerPool = (workerUrl, poolSize, options) => {
899
950
  if (!onProgress)
900
951
  return;
901
952
  const inFlight = inFlightProgress.reduce((sum, value) => sum + value, 0);
902
- const next = Math.min(dispatchableItems.length, Math.max(maxReported, completedFiles + inFlight));
953
+ const next = Math.min(dispatchableCount, Math.max(maxReported, completedFiles + inFlight));
903
954
  if (next === maxReported)
904
955
  return;
905
956
  maxReported = next;
@@ -984,7 +1035,13 @@ export const createWorkerPool = (workerUrl, poolSize, options) => {
984
1035
  retireWorkerAfterTimeout(existing, workerIndex, reason);
985
1036
  return;
986
1037
  }
987
- await existing.terminate().catch(() => undefined);
1038
+ // Recovery must settle before dispatch returns, but a failed thread
1039
+ // may never acknowledge termination. Bound that wait as in shutdown.
1040
+ const termination = existing.terminate().then(() => undefined, () => undefined);
1041
+ if (!(await settledWithin(termination, poolOptions.shutdownDrainMs))) {
1042
+ existing.unref?.();
1043
+ logger.warn({ workerIndex, drainMs: poolOptions.shutdownDrainMs, reason }, `Worker ${workerIndex} did not finish terminating within the shutdown drain; continuing recovery.`);
1044
+ }
988
1045
  };
989
1046
  const replaceWorker = async (workerIndex, mode = 'terminate', reason = 'replacing worker') => {
990
1047
  await removeWorkerFromSlot(workerIndex, mode, reason);
@@ -1057,9 +1114,20 @@ export const createWorkerPool = (workerUrl, poolSize, options) => {
1057
1114
  if (jobs.length === 0 && activeWorkers === 0) {
1058
1115
  stopped = true;
1059
1116
  results.sort((a, b) => a.startIndex - b.startIndex);
1060
- if (onProgress && maxReported < dispatchableItems.length)
1061
- onProgress(dispatchableItems.length);
1062
- resolve(results.map((result) => result.data));
1117
+ if (onProgress && maxReported < dispatchableCount)
1118
+ onProgress(dispatchableCount);
1119
+ // Partition back per group. Job (and split sub-job) start indices
1120
+ // stay inside their group's span, so a single forward walk over the
1121
+ // sorted results assigns every result to exactly one group.
1122
+ const perGroup = groupEnds.map(() => []);
1123
+ let groupIdx = 0;
1124
+ for (const result of results) {
1125
+ while (groupIdx < groupEnds.length - 1 && result.startIndex >= groupEnds[groupIdx]) {
1126
+ groupIdx++;
1127
+ }
1128
+ perGroup[groupIdx].push(result.data);
1129
+ }
1130
+ resolve(perGroup);
1063
1131
  }
1064
1132
  };
1065
1133
  // Re-queue the non-quarantined remainder of a dead worker's job so a
@@ -1357,11 +1425,13 @@ export const createWorkerPool = (workerUrl, poolSize, options) => {
1357
1425
  // (`error`, `exit`, msg-channel error). Bridges the per-job teardown
1358
1426
  // into the pool-level handleWorkerDeath recovery + breaker logic.
1359
1427
  const recoverAndResume = async (reason, excludePaths) => {
1360
- activeWorkers--;
1361
1428
  busySlots.delete(workerIndex);
1362
1429
  inFlightProgress[workerIndex] = 0;
1363
1430
  requeueRemainder(job, excludePaths);
1431
+ // Keep recovery in flight so another slot finishing cannot settle
1432
+ // this dispatch before the replacement is ready for the next one.
1364
1433
  await handleWorkerDeath(workerIndex, reason, excludePaths);
1434
+ activeWorkers--;
1365
1435
  if (stopped)
1366
1436
  return;
1367
1437
  // Slot may have been dropped or respawned. Kick the current slot
@@ -1406,10 +1476,10 @@ export const createWorkerPool = (workerUrl, poolSize, options) => {
1406
1476
  // is respawned (or dropped) and can dispatch the next
1407
1477
  // job deterministically.
1408
1478
  void (async () => {
1409
- activeWorkers--;
1410
1479
  busySlots.delete(workerIndex);
1411
1480
  requeueRemainder(job, decision.excludePaths);
1412
1481
  await handleWorkerDeath(workerIndex, decision.reason, decision.excludePaths, 'retire');
1482
+ activeWorkers--;
1413
1483
  if (stopped)
1414
1484
  return;
1415
1485
  if (activeSlots.has(workerIndex))
@@ -1689,8 +1759,13 @@ export const createWorkerPool = (workerUrl, poolSize, options) => {
1689
1759
  workers.length = 0;
1690
1760
  activeSlots.clear();
1691
1761
  };
1762
+ const dispatch = async (items, onProgress, chunkHash) => {
1763
+ const [result] = await dispatchGroups([{ items, chunkHash }], onProgress);
1764
+ return result ?? [];
1765
+ };
1692
1766
  return {
1693
1767
  dispatch,
1768
+ dispatchGroups,
1694
1769
  terminate,
1695
1770
  size,
1696
1771
  getQuarantinedPaths: () => quarantine.snapshot(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.12-rc.3",
3
+ "version": "1.6.12-rc.4",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",