@@ -122,13 +122,38 @@ def first_pass(self, path: Path, files: list[Path], ignore: list[str], graph: Gr
122122
123123 def second_pass (self , graph : Graph , files : list [Path ], path : Path ) -> None :
124124 """
125- Recursively analyze the contents of a directory.
126-
127- Args:
128- base (str): The base directory for analysis.
129- root (str): The current directory being analyzed.
130- executor (concurrent.futures.Executor): The executor to run tasks concurrently.
125+ Resolve symbol references across the codebase via LSP and write the
126+ resulting edges (CALLS / EXTENDS / IMPLEMENTS / RETURNS / PARAMETERS)
127+ into the graph.
128+
129+ Symbol resolution dominates index wall-time on large repos: every
130+ file's entities trigger several `lsp.request_definition` calls and
131+ most of them are I/O-bound waiting on the language server.
132+ multilspy's SyncLanguageServer schedules each request onto a single
133+ asyncio loop running in a daemon thread (via
134+ `asyncio.run_coroutine_threadsafe`), which makes concurrent calls
135+ from multiple worker threads safe and lets us pipeline them.
136+
137+ We therefore split second_pass into two phases:
138+
139+ A. Parallel resolution. A bounded thread pool processes files in
140+ parallel, calling `entity.resolved_symbol(...)` per entity so
141+ each `Symbol.resolved_symbol` set gets populated. No graph
142+ writes happen here.
143+
144+ B. Serial edge writes. The main thread iterates the same files
145+ in their original order and emits the EXTENDS / CALLS / ...
146+ edges. Keeping graph writes on one thread avoids contending on
147+ FalkorDB MERGE locks and produces a deterministic edge order
148+ matching the pre-parallel implementation.
149+
150+ Pool size is controlled by `CODE_GRAPH_INDEX_WORKERS` (default 4).
151+ Set it to 1 to fall back to the historical fully-serial behavior
152+ (useful for debugging or for hosts where multilspy/jedi misbehaves
153+ under concurrency).
131154 """
155+ import os
156+ from concurrent .futures import ThreadPoolExecutor , as_completed
132157
133158 logger = MultilspyLogger ()
134159 logger .logger .setLevel (logging .ERROR )
@@ -153,17 +178,71 @@ def second_pass(self, graph: Graph, files: list[Path], path: Path) -> None:
153178 lsps [".kts" ] = NullLanguageServer ()
154179 lsps [".js" ] = NullLanguageServer ()
155180 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 ():
156- files_len = len (self .files )
157- for i , file_path in enumerate (files ):
181+ try :
182+ n_workers = max (1 , int (os .environ .get ("CODE_GRAPH_INDEX_WORKERS" , "4" )))
183+ except ValueError :
184+ n_workers = 4
185+
186+ # Drop files we don't actually have an entry for and skip files
187+ # whose language has no real LSP (NullLanguageServer provides
188+ # no symbol info, so resolution would be a no-op).
189+ resolvable : list [Path ] = []
190+ for file_path in files :
158191 if file_path not in self .files :
159192 continue
160- # Skip symbol resolution when no real LSP is available
161193 if isinstance (lsps .get (file_path .suffix ), NullLanguageServer ):
162194 continue
195+ resolvable .append (file_path )
196+
197+ total = len (resolvable )
198+ logging .info (
199+ "second_pass: resolving symbols in %d files with %d worker(s)" ,
200+ total , n_workers ,
201+ )
202+
203+ def _resolve_file (file_path : Path ) -> Path :
204+ # Populate Symbol.resolved_symbol sets for every entity in
205+ # this file. Pure LSP work, safe to run from worker threads
206+ # because SyncLanguageServer multiplexes requests through a
207+ # single asyncio loop.
208+ file = self .files [file_path ]
209+ for _ , entity in file .entities .items ():
210+ entity .resolved_symbol (
211+ lambda key , symbol , fp = file_path : analyzers [fp .suffix ].resolve_symbol (
212+ self .files , lsps [fp .suffix ], fp , path , key , symbol
213+ )
214+ )
215+ return file_path
216+
217+ done = 0
218+ log_every = max (1 , total // 50 ) if total else 1
219+ if n_workers == 1 :
220+ for fp in resolvable :
221+ _resolve_file (fp )
222+ done += 1
223+ if done % log_every == 0 or done == total :
224+ logging .info ("second_pass: resolved %d/%d files" , done , total )
225+ else :
226+ with ThreadPoolExecutor (max_workers = n_workers , thread_name_prefix = "sa-resolve" ) as ex :
227+ futures = {ex .submit (_resolve_file , fp ): fp for fp in resolvable }
228+ for fut in as_completed (futures ):
229+ fp = futures [fut ]
230+ try :
231+ fut .result ()
232+ except Exception as exc :
233+ logging .warning (
234+ "second_pass: resolution failed for %s: %s" ,
235+ fp , exc ,
236+ )
237+ done += 1
238+ if done % log_every == 0 or done == total :
239+ logging .info ("second_pass: resolved %d/%d files" , done , total )
240+
241+ # Phase B: serial edge writes, in the original file order so
242+ # the graph is bit-identical to the single-threaded path.
243+ for file_path in resolvable :
163244 file = self .files [file_path ]
164- logging .info (f'Processing file ({ i + 1 } /{ files_len } ): { file_path } ' )
165245 for _ , entity in file .entities .items ():
166- entity .resolved_symbol (lambda key , symbol , fp = file_path : analyzers [fp .suffix ].resolve_symbol (self .files , lsps [fp .suffix ], fp , path , key , symbol ))
167246 for key , resolved_set in entity .resolved_symbols .items ():
168247 for resolved in resolved_set :
169248 if key == "base_class" :
0 commit comments