gitnexus 1.6.12-rc.5 → 1.6.12-rc.7
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 +5 -7
- package/dist/core/ingestion/pipeline-phases/parse-impl.js +100 -39
- package/dist/core/ingestion/pipeline-phases/parse-round-budget.d.ts +42 -0
- package/dist/core/ingestion/pipeline-phases/parse-round-budget.js +50 -0
- package/dist/core/ingestion/workers/worker-pool.d.ts +24 -0
- package/dist/core/ingestion/workers/worker-pool.js +30 -14
- package/dist/core/search/fts-indexes.js +13 -4
- package/package.json +1 -1
|
@@ -3,6 +3,7 @@ import type { SymbolTableWriter } from './model/index.js';
|
|
|
3
3
|
import { type ExportedTypeMap } from './call-processor.js';
|
|
4
4
|
import type { ParsedFile } from '../../_shared/index.js';
|
|
5
5
|
import { WorkerPool } from './workers/worker-pool.js';
|
|
6
|
+
import type { DispatchGroup } from './workers/worker-pool.js';
|
|
6
7
|
import type { ParseWorkerResult, ExtractedRoute, ExtractedFetchCall, ExtractedDecoratorRoute, ExtractedModuleConstants, ExtractedToolDef, FileScopeBindings, ExtractedORMQuery, FetchWrapperDef } from './workers/parse-worker.js';
|
|
7
8
|
import type { ExtractedRouterConstructorPrefix, ExtractedRouterImport, ExtractedRouterInclude, ExtractedRouterModuleAlias } from './route-extractors/fastapi-router-bindings.js';
|
|
8
9
|
import type { SharedSpringType } from './route-extractors/spring-shared.js';
|
|
@@ -78,13 +79,10 @@ chunkHash?: string) => Promise<ParseWorkerResult[]>;
|
|
|
78
79
|
* boundaries, so every result stays attributable to the chunk whose cache key
|
|
79
80
|
* owns it.
|
|
80
81
|
*/
|
|
81
|
-
export declare const dispatchChunkParseRound: (groups: ReadonlyArray<{
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}[];
|
|
86
|
-
chunkHash?: string;
|
|
87
|
-
}>, workerPool: WorkerPool, onFileProgress?: FileProgressCallback) => Promise<ParseWorkerResult[][]>;
|
|
82
|
+
export declare const dispatchChunkParseRound: (groups: ReadonlyArray<DispatchGroup<{
|
|
83
|
+
path: string;
|
|
84
|
+
content: string;
|
|
85
|
+
}>>, workerPool: WorkerPool, onFileProgress?: FileProgressCallback) => Promise<ParseWorkerResult[][]>;
|
|
88
86
|
export declare const processParsing: (graph: KnowledgeGraph, files: {
|
|
89
87
|
path: string;
|
|
90
88
|
content: string;
|
|
@@ -28,7 +28,7 @@ import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
|
|
|
28
28
|
import { getProvider, getProviderForFile, providers } from '../languages/index.js';
|
|
29
29
|
import { SCOPE_RESOLVERS } from '../scope-resolution/pipeline/registry.js';
|
|
30
30
|
import { DATA_ROUTE_TABLE_SOURCE } from '../route-extractors/data-route-table.js';
|
|
31
|
-
import { createWorkerPool, workerPoolDisabledByEnv, resolveAutoPoolSize, WorkerPoolInitializationError, WorkerPoolDisabledError, } from '../workers/worker-pool.js';
|
|
31
|
+
import { createWorkerPool, workerPoolDisabledByEnv, resolveAutoPoolSize, envWorkerPoolSize, resolveHostParallelism, WorkerPoolInitializationError, WorkerPoolDisabledError, } from '../workers/worker-pool.js';
|
|
32
32
|
import { normalizeExtractedRoutePath } from '../route-extractors/route-path.js';
|
|
33
33
|
import { resolveOperands } from '../route-extractors/python-const-resolver.js';
|
|
34
34
|
import { prepareRouteConstantsByProvider } from '../language-provider.js';
|
|
@@ -43,6 +43,8 @@ import { isVerboseIngestionEnabled } from '../utils/verbose.js';
|
|
|
43
43
|
import { endTimer, isDeferredResolutionProfileEnabled, logDeferredProfile, startTimer, } from '../utils/deferred-resolution-profile.js';
|
|
44
44
|
import { isDebugHeapEnabled, logHeapProbe } from '../utils/heap-probe.js';
|
|
45
45
|
import { logger } from '../../logger.js';
|
|
46
|
+
import { mapConcurrent } from '../../../lib/utils.js';
|
|
47
|
+
import { createRoundBudget } from './parse-round-budget.js';
|
|
46
48
|
// ── Constants ──────────────────────────────────────────────────────────────
|
|
47
49
|
/**
|
|
48
50
|
* Heap-scale guardrail constants (#2649). Measured on a Linux-kernel analyze:
|
|
@@ -130,10 +132,16 @@ const CHUNK_BYTES_PER_WORKER = DEFAULT_CHUNK_BYTE_BUDGET;
|
|
|
130
132
|
* while the rest finish early). Drives the derived `subBatchMaxBytes`.
|
|
131
133
|
*/
|
|
132
134
|
const TARGET_JOBS_PER_WORKER = 3;
|
|
135
|
+
/**
|
|
136
|
+
* Concurrent durable ParsedFile directory resets per round. Matches the file
|
|
137
|
+
* reader's `READ_CONCURRENCY`, because both compete for the same descriptors.
|
|
138
|
+
*/
|
|
139
|
+
const DURABLE_RESET_CONCURRENCY = 32;
|
|
133
140
|
/** Floor for a derived sub-batch so jobs don't shrink to per-file IPC churn. */
|
|
134
141
|
const MIN_SUB_BATCH_BYTES = 256 * 1024;
|
|
135
142
|
/**
|
|
136
|
-
* Source bytes
|
|
143
|
+
* Source bytes an open round may HOLD — cache hits and misses alike — before
|
|
144
|
+
* it is dispatched and drained.
|
|
137
145
|
*
|
|
138
146
|
* A `dispatch` is a barrier, so one round-trip per cache pack leaves most slots
|
|
139
147
|
* idle: packs are keyed by `(language, hash(path) % 128)` and routinely land far
|
|
@@ -413,11 +421,38 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
|
|
|
413
421
|
// cores-based auto size is capped by source bytes / CHUNK_BYTES_PER_WORKER
|
|
414
422
|
// so a tiny repo does not spawn a full idle pool. Cache pack membership
|
|
415
423
|
// is independent of this number (#3088).
|
|
416
|
-
|
|
424
|
+
// `--workers <N>` and `GITNEXUS_WORKER_POOL_SIZE` are both deliberate
|
|
425
|
+
// operator input, so both bypass the work-proportional cap below. Only the
|
|
426
|
+
// env path used to be clamped by it, which made the documented escape hatch
|
|
427
|
+
// silently do nothing: on a 30MB repo the cap resolves to 16, so an operator
|
|
428
|
+
// asking for 24 still got 16 with no warning, while `--workers 24` got 24.
|
|
429
|
+
const explicitPoolSize = options?.workerPoolSize ?? envWorkerPoolSize();
|
|
430
|
+
// Cores-based auto size, bounded by source bytes so a tiny repo does not
|
|
431
|
+
// spawn a full idle pool.
|
|
417
432
|
const workProportionalCap = Math.max(1, Math.ceil(totalBytes / CHUNK_BYTES_PER_WORKER));
|
|
433
|
+
// An operator's number is honored, but never exceeds the number of files
|
|
434
|
+
// there are to parse — `GITNEXUS_WORKER_POOL_SIZE=100000` on a five-file repo
|
|
435
|
+
// should not become the literal thread count. This bounds `--workers` and the
|
|
436
|
+
// env var identically, keeping the parity above intact. Note it does NOT
|
|
437
|
+
// shrink an incremental re-analyze: `totalParseable` counts every parseable
|
|
438
|
+
// file in the scan, not the changed ones, so a warm run of a large repo still
|
|
439
|
+
// spawns the full requested pool.
|
|
418
440
|
const effectivePoolSize = explicitPoolSize && explicitPoolSize > 0
|
|
419
|
-
? explicitPoolSize
|
|
441
|
+
? Math.min(explicitPoolSize, Math.max(1, totalParseable))
|
|
420
442
|
: Math.min(resolveAutoPoolSize(), workProportionalCap);
|
|
443
|
+
// Deliberate over-subscription is the operator's call, so this warns rather
|
|
444
|
+
// than caps — silently capping is what the override exists to stop. But an
|
|
445
|
+
// exported `GITNEXUS_WORKER_POOL_SIZE` applies to EVERY analyze in a
|
|
446
|
+
// long-lived caller (watch auto-sync, the MCP server), including small
|
|
447
|
+
// incremental ones, and that is easy to set once and forget.
|
|
448
|
+
if (explicitPoolSize && explicitPoolSize > 0) {
|
|
449
|
+
const hostParallelism = resolveHostParallelism();
|
|
450
|
+
if (effectivePoolSize > hostParallelism) {
|
|
451
|
+
logger.warn({ requested: explicitPoolSize, spawning: effectivePoolSize, hostParallelism }, `Worker pool size ${effectivePoolSize} exceeds this host's ${hostParallelism} usable core(s); ` +
|
|
452
|
+
`parsing is CPU-bound, so the extra workers add memory pressure without throughput. ` +
|
|
453
|
+
`This applies to every analyze while the override is set.`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
421
456
|
// Cache packs: stable (language, hash(path) mod 128) buckets, then the
|
|
422
457
|
// per-call byte budget inside each bucket (#3088). Pool size is used only
|
|
423
458
|
// for worker count and sub-batch fan-out, not membership.
|
|
@@ -618,20 +653,30 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
|
|
|
618
653
|
// the env can't change mid-run.
|
|
619
654
|
const verboseThroughputLog = isDev || isVerboseIngestionEnabled();
|
|
620
655
|
const heapProbeEveryN = isDebugHeapEnabled() ? 25 : 0;
|
|
656
|
+
/**
|
|
657
|
+
* Chunk hashes whose durable ParsedFile directory could not be reset. The
|
|
658
|
+
* old generation's shards are still on disk, so a warm hit would union
|
|
659
|
+
* stale shards with the new ones. Treated exactly like a quarantined chunk:
|
|
660
|
+
* skip the parse-cache write so the next run re-dispatches into a clean
|
|
661
|
+
* directory rather than trusting a generation we could not clear.
|
|
662
|
+
*/
|
|
663
|
+
const durablePrepareFailures = new Set();
|
|
621
664
|
const roundByteBudget = resolveParseRoundByteBudget(options);
|
|
622
665
|
let roundEntries = [];
|
|
623
|
-
let roundMissBytes = 0;
|
|
624
666
|
/**
|
|
625
667
|
* Bytes an open round is HOLDING, counting hits as well as misses.
|
|
626
668
|
*
|
|
627
|
-
*
|
|
628
|
-
* warm run — where nothing misses — would never reach
|
|
629
|
-
* and would buffer every chunk's cached output until
|
|
630
|
-
* is the #2649 heap failure on a large repo.
|
|
631
|
-
* hits-only run draining at the same cadence as a cold one;
|
|
632
|
-
* already supports a round with no misses.
|
|
669
|
+
* Counting only the cache-MISSING bytes would bound just what the workers
|
|
670
|
+
* are asked to do, so a warm run — where nothing misses — would never reach
|
|
671
|
+
* the close condition and would buffer every chunk's cached output until
|
|
672
|
+
* the tail drain. That is the #2649 heap failure on a large repo. Counting
|
|
673
|
+
* both keeps a hits-only run draining at the same cadence as a cold one;
|
|
674
|
+
* `startRound` already supports a round with no misses.
|
|
675
|
+
*
|
|
676
|
+
* Measured in UTF-8 bytes, matching `estimateItemBytes` in the worker pool,
|
|
677
|
+
* so the cap means the same thing here as it does for a job's payload.
|
|
633
678
|
*/
|
|
634
|
-
|
|
679
|
+
const roundBudget = createRoundBudget(roundByteBudget);
|
|
635
680
|
/**
|
|
636
681
|
* Files QUEUED into rounds so far. `filesParsedSoFar` only advances when a
|
|
637
682
|
* round drains, so it is the right number for the throughput log but would
|
|
@@ -755,7 +800,12 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
|
|
|
755
800
|
if (parseCache && p.chunkHash && rawResults.length > 0) {
|
|
756
801
|
const quarantineSet = new Set(workerPool?.getQuarantinedPaths?.() ?? []);
|
|
757
802
|
const chunkHadQuarantine = p.chunkFiles.some((f) => quarantineSet.has(f.path));
|
|
758
|
-
|
|
803
|
+
const durableGenerationStale = durablePrepareFailures.has(p.chunkHash);
|
|
804
|
+
if (durableGenerationStale) {
|
|
805
|
+
logger.warn({ chunkHash: p.chunkHash.slice(0, 8) }, 'parse-cache SKIP: durable generation for this chunk could not be reset, ' +
|
|
806
|
+
'so its shards may be stale; next run will re-dispatch it');
|
|
807
|
+
}
|
|
808
|
+
else if (chunkHadQuarantine) {
|
|
759
809
|
if (isDev) {
|
|
760
810
|
const quarantinedInChunk = p.chunkFiles.filter((f) => quarantineSet.has(f.path)).length;
|
|
761
811
|
logger.info(`📦 parse-cache SKIP: chunk ${p.chunkIdx + 1}/${numChunks} ` +
|
|
@@ -785,19 +835,34 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
|
|
|
785
835
|
if (misses.length === 0) {
|
|
786
836
|
return { entries, results: Promise.resolve([]) };
|
|
787
837
|
}
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
838
|
+
// Each chunk resets its own directory, so these are independent and run
|
|
839
|
+
// concurrently: serially they would sit on the critical path this round
|
|
840
|
+
// exists to shorten, with the pool idle and the previous round's merge
|
|
841
|
+
// waiting, once per miss.
|
|
842
|
+
//
|
|
843
|
+
// BOUNDED, though. A round can hold hundreds of small packs, and each
|
|
844
|
+
// reset is a recursive rm + mkdir. Firing all of them at once competes
|
|
845
|
+
// for descriptors with the chunk prefetch this loop already has in
|
|
846
|
+
// flight, and `readFileContents` degrades a losing read SILENTLY by
|
|
847
|
+
// contract — a dropped file would vanish from the chunk, from the graph,
|
|
848
|
+
// and from the chunk hash, shipping a narrowed index with exit 0. Same
|
|
849
|
+
// helper and width the file reads use.
|
|
850
|
+
await mapConcurrent(misses, async (miss) => {
|
|
851
|
+
if (durableParsedFileDir === undefined || miss.chunkHash === null)
|
|
852
|
+
return;
|
|
853
|
+
try {
|
|
854
|
+
await prepareDurableParsedFileChunk(durableParsedFileDir, miss.chunkHash);
|
|
799
855
|
}
|
|
800
|
-
|
|
856
|
+
catch (err) {
|
|
857
|
+
// The durable store is an optimization — degrade like the restore
|
|
858
|
+
// path does instead of failing the analyze. Workers recreate the
|
|
859
|
+
// directory on write, so at worst the old generation lingers.
|
|
860
|
+
// Caught per chunk so one failure cannot abort the others.
|
|
861
|
+
durablePrepareFailures.add(miss.chunkHash);
|
|
862
|
+
logger.warn({ err, chunkHash: miss.chunkHash.slice(0, 8) }, 'parsedfile-cache: could not reset durable chunk generation; ' +
|
|
863
|
+
'continuing without caching this chunk');
|
|
864
|
+
}
|
|
865
|
+
}, { concurrency: DURABLE_RESET_CONCURRENCY });
|
|
801
866
|
const roundFiles = misses.reduce((sum, miss) => sum + miss.chunkFiles.length, 0);
|
|
802
867
|
const firstIdx = misses[0].chunkIdx;
|
|
803
868
|
const lastIdx = misses[misses.length - 1].chunkIdx;
|
|
@@ -844,7 +909,7 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
|
|
|
844
909
|
*/
|
|
845
910
|
const drainRound = async (round) => {
|
|
846
911
|
const missResults = round.missResults;
|
|
847
|
-
const missCount = round.entries.
|
|
912
|
+
const missCount = round.entries.filter((entry) => entry.kind === 'miss').length;
|
|
848
913
|
// `dispatchGroups` returns one array per input group. If that contract
|
|
849
914
|
// ever breaks, every later entry in this round would silently merge the
|
|
850
915
|
// wrong chunk's results and skip its cache write, with a clean exit.
|
|
@@ -874,8 +939,7 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
|
|
|
874
939
|
const closeRound = async () => {
|
|
875
940
|
const started = await startRound(roundEntries);
|
|
876
941
|
roundEntries = [];
|
|
877
|
-
|
|
878
|
-
roundBufferedBytes = 0;
|
|
942
|
+
roundBudget.reset();
|
|
879
943
|
const previous = pendingRound;
|
|
880
944
|
pendingRound = null;
|
|
881
945
|
if (previous) {
|
|
@@ -987,6 +1051,8 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
|
|
|
987
1051
|
parsedFileStorePath !== undefined &&
|
|
988
1052
|
durableExpectedPaths !== undefined &&
|
|
989
1053
|
(await durableChunkHasShards(parsedFileStorePath, chunkHash, durableExpectedPaths));
|
|
1054
|
+
// Set by whichever branch queues this chunk; drives the close below.
|
|
1055
|
+
let roundIsFull = false;
|
|
990
1056
|
if (cachedRaw && cachedRaw.length > 0 && (durableHit || parsedFileStorePath === undefined)) {
|
|
991
1057
|
// Cache hit: replay cached worker output. Finalize any parked worker
|
|
992
1058
|
// chunk FIRST so deferred aggregation stays in chunk order, then merge
|
|
@@ -1022,8 +1088,7 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
|
|
|
1022
1088
|
chunkStartMs,
|
|
1023
1089
|
cachedRaw,
|
|
1024
1090
|
});
|
|
1025
|
-
|
|
1026
|
-
roundBufferedBytes += file.content.length;
|
|
1091
|
+
roundIsFull = roundBudget.addChunk(chunkFiles.map((file) => file.content));
|
|
1027
1092
|
queuedFilesSoFar += chunkFiles.length;
|
|
1028
1093
|
}
|
|
1029
1094
|
else {
|
|
@@ -1032,18 +1097,14 @@ export async function runChunkedParseAndResolve(graph, scannedFiles, allPaths, t
|
|
|
1032
1097
|
chunkCacheMisses++;
|
|
1033
1098
|
reparsedFileCount += chunkFiles.length;
|
|
1034
1099
|
roundEntries.push({ kind: 'miss', chunkIdx, chunkHash, chunkFiles, chunkStartMs });
|
|
1035
|
-
|
|
1036
|
-
roundMissBytes += file.content.length;
|
|
1037
|
-
roundBufferedBytes += file.content.length;
|
|
1038
|
-
}
|
|
1100
|
+
roundIsFull = roundBudget.addChunk(chunkFiles.map((file) => file.content));
|
|
1039
1101
|
queuedFilesSoFar += chunkFiles.length;
|
|
1040
1102
|
}
|
|
1041
|
-
//
|
|
1042
|
-
//
|
|
1043
|
-
//
|
|
1044
|
-
if (
|
|
1103
|
+
// One cap, on what the main thread is holding. That bounds the worker
|
|
1104
|
+
// round too, since a round's dispatched bytes are a subset of its
|
|
1105
|
+
// buffered bytes.
|
|
1106
|
+
if (roundIsFull)
|
|
1045
1107
|
await closeRound();
|
|
1046
|
-
}
|
|
1047
1108
|
// (Per-chunk aggregation + parse-cache write + throughput log now run in
|
|
1048
1109
|
// `applyChunkResults` / `finalizeWorkerChunk` — see the merge-pipelining
|
|
1049
1110
|
// block above. Route/import/inheritance edges are emitted later: route
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fold that decides when an open dispatch round closes.
|
|
3
|
+
*
|
|
4
|
+
* Extracted so the decision is a shared, inspectable unit rather than four
|
|
5
|
+
* loose statements inside `runChunkedParseAndResolve`. The parse loop is
|
|
6
|
+
* STREAMING — it reads chunk contents lazily, so it cannot know every chunk's
|
|
7
|
+
* size up front and cannot "plan" rounds ahead. That makes an accumulator, not
|
|
8
|
+
* a planner, the honest shape: feed it each chunk as it is queued and it tells
|
|
9
|
+
* you whether the round is now full.
|
|
10
|
+
*
|
|
11
|
+
* Being a real unit is what makes round cadence observable. Round boundaries
|
|
12
|
+
* are otherwise invisible from outside the parse phase: they change no graph
|
|
13
|
+
* output (that is the point of batching) and surface only in a log line, which
|
|
14
|
+
* is why `bench/parse-dispatch-rounds` measures this directly rather than
|
|
15
|
+
* inferring cadence from a full analyze.
|
|
16
|
+
*/
|
|
17
|
+
/** Bytes a file contributes to the open round's retained total. */
|
|
18
|
+
export declare const roundFileBytes: (content: string) => number;
|
|
19
|
+
export interface RoundBudget {
|
|
20
|
+
/**
|
|
21
|
+
* Add one queued chunk's files. Returns true when the round is now full and
|
|
22
|
+
* the caller should close it. Closing resets the accumulator.
|
|
23
|
+
*/
|
|
24
|
+
addChunk(contents: readonly string[]): boolean;
|
|
25
|
+
/** Bytes currently held by the open round. */
|
|
26
|
+
readonly bufferedBytes: number;
|
|
27
|
+
/** Reset without closing — used when the caller closes for another reason. */
|
|
28
|
+
reset(): void;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* `budgetBytes` bounds what the main thread HOLDS, counting cache hits as well
|
|
32
|
+
* as misses. Counting only cache-missing bytes would bound just the work sent
|
|
33
|
+
* to workers, so a warm run — where nothing misses — would never reach the
|
|
34
|
+
* close condition and would buffer every chunk's cached output until the tail
|
|
35
|
+
* drain. That is the #2649 heap failure on a large repo.
|
|
36
|
+
*
|
|
37
|
+
* Measured in UTF-8 bytes, matching `estimateItemBytes` in the worker pool.
|
|
38
|
+
* `String.length` would return UTF-16 code units, undercounting non-ASCII
|
|
39
|
+
* source by up to 3x and letting a CJK-heavy repo hold well past its nominal
|
|
40
|
+
* budget before draining.
|
|
41
|
+
*/
|
|
42
|
+
export declare const createRoundBudget: (budgetBytes: number) => RoundBudget;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fold that decides when an open dispatch round closes.
|
|
3
|
+
*
|
|
4
|
+
* Extracted so the decision is a shared, inspectable unit rather than four
|
|
5
|
+
* loose statements inside `runChunkedParseAndResolve`. The parse loop is
|
|
6
|
+
* STREAMING — it reads chunk contents lazily, so it cannot know every chunk's
|
|
7
|
+
* size up front and cannot "plan" rounds ahead. That makes an accumulator, not
|
|
8
|
+
* a planner, the honest shape: feed it each chunk as it is queued and it tells
|
|
9
|
+
* you whether the round is now full.
|
|
10
|
+
*
|
|
11
|
+
* Being a real unit is what makes round cadence observable. Round boundaries
|
|
12
|
+
* are otherwise invisible from outside the parse phase: they change no graph
|
|
13
|
+
* output (that is the point of batching) and surface only in a log line, which
|
|
14
|
+
* is why `bench/parse-dispatch-rounds` measures this directly rather than
|
|
15
|
+
* inferring cadence from a full analyze.
|
|
16
|
+
*/
|
|
17
|
+
/** Bytes a file contributes to the open round's retained total. */
|
|
18
|
+
export const roundFileBytes = (content) => Buffer.byteLength(content, 'utf8');
|
|
19
|
+
/**
|
|
20
|
+
* `budgetBytes` bounds what the main thread HOLDS, counting cache hits as well
|
|
21
|
+
* as misses. Counting only cache-missing bytes would bound just the work sent
|
|
22
|
+
* to workers, so a warm run — where nothing misses — would never reach the
|
|
23
|
+
* close condition and would buffer every chunk's cached output until the tail
|
|
24
|
+
* drain. That is the #2649 heap failure on a large repo.
|
|
25
|
+
*
|
|
26
|
+
* Measured in UTF-8 bytes, matching `estimateItemBytes` in the worker pool.
|
|
27
|
+
* `String.length` would return UTF-16 code units, undercounting non-ASCII
|
|
28
|
+
* source by up to 3x and letting a CJK-heavy repo hold well past its nominal
|
|
29
|
+
* budget before draining.
|
|
30
|
+
*/
|
|
31
|
+
export const createRoundBudget = (budgetBytes) => {
|
|
32
|
+
let bufferedBytes = 0;
|
|
33
|
+
return {
|
|
34
|
+
addChunk(contents) {
|
|
35
|
+
for (const content of contents)
|
|
36
|
+
bufferedBytes += roundFileBytes(content);
|
|
37
|
+
if (bufferedBytes >= budgetBytes) {
|
|
38
|
+
bufferedBytes = 0;
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
},
|
|
43
|
+
get bufferedBytes() {
|
|
44
|
+
return bufferedBytes;
|
|
45
|
+
},
|
|
46
|
+
reset() {
|
|
47
|
+
bufferedBytes = 0;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
};
|
|
@@ -27,6 +27,12 @@ export interface WorkerPool {
|
|
|
27
27
|
*
|
|
28
28
|
* Returns one result array per input group, in input order. A group whose
|
|
29
29
|
* items were all quarantined yields an empty array.
|
|
30
|
+
*
|
|
31
|
+
* Required, not optional. `getQuarantinedPaths?` and `getStats?` below are
|
|
32
|
+
* marked optional as a compatibility accommodation for `WorkerPool` shapes
|
|
33
|
+
* that predate them — not as a convention for new members. Making this one
|
|
34
|
+
* optional would force a `?.` plus a fallback branch at its only production
|
|
35
|
+
* call site, and that branch could never run.
|
|
30
36
|
*/
|
|
31
37
|
dispatchGroups<TInput, TResult>(groups: readonly DispatchGroup<TInput>[], onProgress?: (filesProcessed: number) => void): Promise<TResult[][]>;
|
|
32
38
|
/**
|
|
@@ -260,6 +266,17 @@ interface ResolvedWorkerPoolOptions {
|
|
|
260
266
|
workerReadyTimeoutMs: number;
|
|
261
267
|
}
|
|
262
268
|
export declare function resolveWorkerPoolOptions(options?: WorkerPoolOptions, poolSize?: number): ResolvedWorkerPoolOptions;
|
|
269
|
+
/**
|
|
270
|
+
* The pool size requested via the `GITNEXUS_WORKER_POOL_SIZE` env var, or
|
|
271
|
+
* `undefined` when unset, empty/whitespace, or invalid. Module-internal sizing
|
|
272
|
+
* reader consumed by {@link resolveAutoPoolSize} (the env override) and
|
|
273
|
+
* {@link workerPoolDisabledByEnv} (the disabled-channel check). Reads only —
|
|
274
|
+
* never mutates `process.env`. Empty/whitespace is treated as *unset* (falls
|
|
275
|
+
* through to the auto formula), not as 0 — an empty assignment (`export
|
|
276
|
+
* GITNEXUS_WORKER_POOL_SIZE=`) is an accident, not a request for zero workers;
|
|
277
|
+
* only a literal `0` disables the pool.
|
|
278
|
+
*/
|
|
279
|
+
export declare function envWorkerPoolSize(): number | undefined;
|
|
263
280
|
/**
|
|
264
281
|
* True when the operator set `GITNEXUS_WORKER_POOL_SIZE=0` — the env-channel
|
|
265
282
|
* equivalent of `--workers 0`. The parse phase consults this (only when no
|
|
@@ -285,6 +302,13 @@ export declare function workerPoolDisabledByEnv(): boolean;
|
|
|
285
302
|
* on the env / default.
|
|
286
303
|
*/
|
|
287
304
|
export declare function resolveAutoPoolSize(): number;
|
|
305
|
+
/**
|
|
306
|
+
* Usable parallelism for this process. Prefers `os.availableParallelism` so
|
|
307
|
+
* cgroup CPU limits are honored, falling back to `os.cpus().length` on older
|
|
308
|
+
* Node. Exported so callers that size work against the host (rather than
|
|
309
|
+
* against the pool default) do not re-derive the fallback.
|
|
310
|
+
*/
|
|
311
|
+
export declare function resolveHostParallelism(): number;
|
|
288
312
|
export declare function startHeartbeatStallTracker(): {
|
|
289
313
|
read: () => number;
|
|
290
314
|
stop: () => void;
|
|
@@ -124,7 +124,9 @@ const DEFAULT_WORKER_READY_TIMEOUT_MS = 5_000;
|
|
|
124
124
|
* extraction / structured-clone overhead, and the marginal worker adds
|
|
125
125
|
* memory pressure (tree-sitter state + sub-batch buffer) without much
|
|
126
126
|
* throughput gain. Operators on bigger machines override via
|
|
127
|
-
* `GITNEXUS_WORKER_POOL_SIZE` or `--workers <N
|
|
127
|
+
* `GITNEXUS_WORKER_POOL_SIZE` or `--workers <N>`; both are deliberate
|
|
128
|
+
* operator input and bypass the work-proportional sizing in `parse-impl`,
|
|
129
|
+
* which only bounds the AUTO default.
|
|
128
130
|
*/
|
|
129
131
|
const DEFAULT_POOL_SIZE_CAP = 16;
|
|
130
132
|
// ── Self-healing startup restart policy (#1741) ──────────────────────────────
|
|
@@ -263,7 +265,7 @@ export function resolveWorkerPoolOptions(options = {}, poolSize) {
|
|
|
263
265
|
* GITNEXUS_WORKER_POOL_SIZE=`) is an accident, not a request for zero workers;
|
|
264
266
|
* only a literal `0` disables the pool.
|
|
265
267
|
*/
|
|
266
|
-
function envWorkerPoolSize() {
|
|
268
|
+
export function envWorkerPoolSize() {
|
|
267
269
|
const raw = process.env.GITNEXUS_WORKER_POOL_SIZE;
|
|
268
270
|
if (raw === undefined || raw.trim() === '')
|
|
269
271
|
return undefined;
|
|
@@ -307,8 +309,18 @@ export function resolveAutoPoolSize() {
|
|
|
307
309
|
// pool cap exists to prevent. Falls back to os.cpus().length on
|
|
308
310
|
// older Node versions. Mirrors `capabilities.ts:85`
|
|
309
311
|
// (`defaultEmbeddingThreads`).
|
|
310
|
-
|
|
311
|
-
|
|
312
|
+
return Math.min(DEFAULT_POOL_SIZE_CAP, Math.max(1, resolveHostParallelism() - 1));
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Usable parallelism for this process. Prefers `os.availableParallelism` so
|
|
316
|
+
* cgroup CPU limits are honored, falling back to `os.cpus().length` on older
|
|
317
|
+
* Node. Exported so callers that size work against the host (rather than
|
|
318
|
+
* against the pool default) do not re-derive the fallback.
|
|
319
|
+
*/
|
|
320
|
+
export function resolveHostParallelism() {
|
|
321
|
+
return typeof os.availableParallelism === 'function'
|
|
322
|
+
? os.availableParallelism()
|
|
323
|
+
: os.cpus().length;
|
|
312
324
|
}
|
|
313
325
|
/**
|
|
314
326
|
* Max characters of a worker's stderr retained for crash diagnostics. A
|
|
@@ -889,16 +901,20 @@ export const createWorkerPool = (workerUrl, poolSize, options) => {
|
|
|
889
901
|
// Layer 3: filter out quarantined paths so a known-bad file never reaches
|
|
890
902
|
// a worker again this pool lifetime. The caller queries
|
|
891
903
|
// `getQuarantinedPaths` after dispatch to route filtered items.
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
904
|
+
// Quarantine is empty on every run that has not had a worker die, so the
|
|
905
|
+
// filter below would be an identity copy of every group's items. Skip it.
|
|
906
|
+
const dispatchableGroups = quarantine.size === 0
|
|
907
|
+
? groups
|
|
908
|
+
: groups.map((group) => {
|
|
909
|
+
const items = [];
|
|
910
|
+
for (const item of group.items) {
|
|
911
|
+
const path = itemPath(item);
|
|
912
|
+
if (path !== undefined && quarantine.has(path))
|
|
913
|
+
continue;
|
|
914
|
+
items.push(item);
|
|
915
|
+
}
|
|
916
|
+
return { items, chunkHash: group.chunkHash };
|
|
917
|
+
});
|
|
902
918
|
const dispatchableCount = dispatchableGroups.reduce((sum, group) => sum + group.items.length, 0);
|
|
903
919
|
if (dispatchableCount === 0)
|
|
904
920
|
return emptyPerGroup();
|
|
@@ -267,10 +267,19 @@ export async function createSearchFTSIndexes(options) {
|
|
|
267
267
|
// the old name+content index would silently persist. `dropFTSIndex` no-ops
|
|
268
268
|
// when the index is absent (first-ever analyze) and clears the per-connection
|
|
269
269
|
// memo so the create below actually runs.
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
// this
|
|
270
|
+
// The cost DID show up in analyze profiles — 7.5s of a 31.7s edit loop on a
|
|
271
|
+
// 5350-file repo, `bench/analyze-phase-breakdown.md` — and a "skip when the
|
|
272
|
+
// index is already present" gate is NOT the answer, so don't reach for it.
|
|
273
|
+
// Every caller that reaches this loop has already dropped the indexes it
|
|
274
|
+
// passes in `tables`: the incremental writeback drops them because Ladybug
|
|
275
|
+
// cannot DML a table with a live FTS index (#2589), and a full rebuild
|
|
276
|
+
// builds into a fresh staging DB that never had one. A presence gate would
|
|
277
|
+
// therefore never fire. The cost is inherent — Ladybug's FTS is not
|
|
278
|
+
// incremental, so one changed row means re-tokenizing the whole table, and
|
|
279
|
+
// `File` alone is ~33MB of file content at ~10MB/s. The measured floor and
|
|
280
|
+
// the four exits that were tried and closed (narrow further, build
|
|
281
|
+
// concurrently, raise the connection thread count, drop `content`) are in
|
|
282
|
+
// that document.
|
|
274
283
|
try {
|
|
275
284
|
await dropFTSIndex(table, indexName);
|
|
276
285
|
await createFTSIndex(table, indexName, [...properties], stemmer);
|
package/package.json
CHANGED