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.
- package/dist/core/ingestion/parsing-processor.d.ts +24 -6
- package/dist/core/ingestion/parsing-processor.js +38 -19
- package/dist/core/ingestion/pipeline-phases/parse-impl.js +225 -88
- package/dist/core/ingestion/workers/parse-worker.js +12 -1
- package/dist/core/ingestion/workers/worker-pool.d.ts +32 -2
- package/dist/core/ingestion/workers/worker-pool.js +97 -22
- package/package.json +1 -1
|
@@ -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
|
|
51
|
-
* results, WITHOUT merging them into the graph.
|
|
52
|
-
* {@link
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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
|
|
151
|
-
* results, WITHOUT merging them into the graph.
|
|
152
|
-
* {@link
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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 +=
|
|
735
|
+
filesParsedSoFar += fileCount;
|
|
694
736
|
if (verboseThroughputLog && chunkStartMs !== null) {
|
|
695
737
|
const elapsedMs = Date.now() - chunkStartMs;
|
|
696
|
-
const filesPerSec = 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}: ${
|
|
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,
|
|
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 &&
|
|
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,
|
|
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 + ((
|
|
1007
|
+
percent: Math.round(20 + ((queuedFilesSoFar + cachedFiles) / totalParseable) * 50),
|
|
834
1008
|
message: `Parsing chunk ${chunkIdx + 1}/${numChunks} (cache)...`,
|
|
835
1009
|
stats: {
|
|
836
|
-
filesProcessed:
|
|
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
|
-
|
|
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:
|
|
847
|
-
//
|
|
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
|
-
|
|
851
|
-
|
|
852
|
-
|
|
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
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
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
|
|
920
|
-
// successor to overlap its merge with
|
|
921
|
-
if (
|
|
922
|
-
await
|
|
923
|
-
|
|
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
|
-
|
|
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
|
|
5
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
846
|
-
|
|
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
|
|
863
|
-
|
|
864
|
-
const
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 <
|
|
1061
|
-
onProgress(
|
|
1062
|
-
|
|
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