@@ -123,13 +123,47 @@ def first_pass(self, path: Path, files: list[Path], ignore: list[str], graph: Gr
123123
124124 def second_pass (self , graph : Graph , files : list [Path ], path : Path ) -> None :
125125 """
126- Recursively analyze the contents of a directory.
127-
128- Args:
129- base (str): The base directory for analysis.
130- root (str): The current directory being analyzed.
131- executor (concurrent.futures.Executor): The executor to run tasks concurrently.
126+ Resolve symbol references across the codebase via LSP and write the
127+ resulting edges (CALLS / EXTENDS / IMPLEMENTS / RETURNS / PARAMETERS)
128+ into the graph.
129+
130+ Symbol resolution dominates index wall-time on large repos: every
131+ file's entities trigger several `lsp.request_definition` calls and
132+ most of them are I/O-bound waiting on the language server.
133+ multilspy's SyncLanguageServer schedules each request onto a single
134+ asyncio loop running in a daemon thread (via
135+ `asyncio.run_coroutine_threadsafe`), which makes concurrent calls
136+ from multiple worker threads safe and lets us pipeline them.
137+
138+ We therefore split second_pass into two phases:
139+
140+ A. Parallel resolution. A bounded thread pool processes files in
141+ parallel, calling `entity.resolved_symbol(...)` per entity so
142+ each `Symbol.resolved_symbol` set gets populated. No graph
143+ writes happen here.
144+
145+ B. Serial edge writes. The main thread iterates the same files
146+ in their original order and emits the EXTENDS / CALLS / ...
147+ edges. Keeping graph writes on one thread avoids contending on
148+ FalkorDB MERGE locks and produces a deterministic edge order
149+ matching the pre-parallel implementation.
150+
151+ Pool size is controlled by `CODE_GRAPH_INDEX_WORKERS` (default 4),
152+ so resolution runs in parallel by default. This is an intentional
153+ behaviour change, but the edge output is identical to the
154+ single-worker path (verified on a 204-file repo): phase B always
155+ writes in the original file order regardless of how phase A
156+ interleaves. Set the var to 1 to run a single resolver thread (useful
157+ when multilspy/jedi misbehaves under concurrency); note this still
158+ dispatches through the pool rather than the main thread.
159+
160+ Files whose resolution raises are logged with their traceback and
161+ excluded from phase B, so one bad file degrades to a logged skip
162+ instead of a partial or aborted graph -- and that behaviour no longer
163+ depends on the worker count.
132164 """
165+ import os
166+ from concurrent .futures import ThreadPoolExecutor , as_completed
133167
134168 logger = MultilspyLogger ()
135169 logger .logger .setLevel (logging .ERROR )
@@ -176,8 +210,23 @@ def second_pass(self, graph: Graph, files: list[Path], path: Path) -> None:
176210 lsps [".kts" ] = NullLanguageServer ()
177211 lsps [".js" ] = NullLanguageServer ()
178212 with lsps [".java" ].start_server (), lsps [".py" ].start_server (), lsps [".cs" ].start_server (), lsps [".js" ].start_server (), lsps [".kt" ].start_server (), lsps [".kts" ].start_server ():
179- files_len = len (self .files )
180- for i , file_path in enumerate (files ):
213+ try :
214+ n_workers = max (1 , int (os .environ .get ("CODE_GRAPH_INDEX_WORKERS" , "4" )))
215+ except ValueError :
216+ n_workers = 4
217+
218+ # Drop files we don't actually have an entry for and skip files
219+ # whose language has no real LSP (NullLanguageServer provides
220+ # no symbol info, so resolution would be a no-op). De-duplicate
221+ # while preserving order so a path that appears twice in `files`
222+ # isn't resolved concurrently by two workers racing on the same
223+ # entity.resolved_symbols sets.
224+ resolvable : list [Path ] = []
225+ seen : set [Path ] = set ()
226+ for file_path in files :
227+ if file_path in seen :
228+ continue
229+ seen .add (file_path )
181230 if file_path not in self .files :
182231 # first_pass skipped this file (e.g. parse error, empty,
183232 # untracked, or ignored after entering the candidate list).
@@ -188,13 +237,64 @@ def second_pass(self, graph: Graph, files: list[Path], path: Path) -> None:
188237 file_path ,
189238 )
190239 continue
191- # Skip symbol resolution when no real LSP is available
192240 if isinstance (lsps .get (file_path .suffix ), NullLanguageServer ):
193241 continue
242+ resolvable .append (file_path )
243+
244+ total = len (resolvable )
245+ logging .info (
246+ "second_pass: resolving symbols in %d files with %d worker(s)" ,
247+ total , n_workers ,
248+ )
249+
250+ def _resolve_file (file_path : Path ) -> Path :
251+ # Populate Symbol.resolved_symbol sets for every entity in
252+ # this file. Pure LSP work, safe to run from worker threads
253+ # because SyncLanguageServer multiplexes requests through a
254+ # single asyncio loop.
255+ file = self .files [file_path ]
256+ for _ , entity in file .entities .items ():
257+ entity .resolved_symbol (
258+ lambda key , symbol , fp = file_path : analyzers [fp .suffix ].resolve_symbol (
259+ self .files , lsps [fp .suffix ], fp , path , key , symbol
260+ )
261+ )
262+ return file_path
263+
264+ # Phase A: resolve symbols. A single code path for every worker
265+ # count keeps the failure policy identical regardless of
266+ # CODE_GRAPH_INDEX_WORKERS -- a ThreadPoolExecutor with
267+ # max_workers=1 simply processes one file at a time.
268+ failed : set [Path ] = set ()
269+ done = 0
270+ log_every = max (1 , total // 50 ) if total else 1
271+ with ThreadPoolExecutor (max_workers = n_workers , thread_name_prefix = "sa-resolve" ) as ex :
272+ futures = {ex .submit (_resolve_file , fp ): fp for fp in resolvable }
273+ for fut in as_completed (futures ):
274+ fp = futures [fut ]
275+ try :
276+ fut .result ()
277+ except Exception :
278+ # Exclude this file from phase B so we never persist a
279+ # partially resolved file; keep going so one bad file
280+ # doesn't abort the whole index.
281+ failed .add (fp )
282+ logging .warning (
283+ "second_pass: resolution failed for %s; excluding from edge writes" ,
284+ fp , exc_info = True ,
285+ )
286+ done += 1
287+ if done % log_every == 0 or done == total :
288+ logging .info ("second_pass: resolved %d/%d files" , done , total )
289+
290+ # Phase B: serial edge writes, in the original file order so
291+ # the graph is bit-identical to the single-threaded path. Files
292+ # whose resolution failed are skipped (see phase A).
293+ for file_path in resolvable :
294+ if file_path in failed :
295+ continue
194296 file = self .files [file_path ]
195- logging .info (f'Processing file ({ i + 1 } /{ files_len } ): { file_path } ' )
196297 for _ , entity in file .entities .items ():
197- entity .resolved_symbol (lambda key , symbol , fp = file_path : analyzers [fp .suffix ].resolve_symbol (self .files , lsps [fp .suffix ], fp , path , key , symbol ))
198298 for key , resolved_set in entity .resolved_symbols .items ():
199299 for resolved in resolved_set :
200300 if key == "base_class" :
0 commit comments