Skip to content

Commit 3b743e7

Browse files
DvirDukhanCopilot
andcommitted
refactor(analyzer): split second_pass into resolve + write phases
Prepares the analyzer for parallel symbol resolution (issue #687). This change is a no-op on wall-time when CODE_GRAPH_INDEX_WORKERS=1 (the default behavior pre-change). With workers>1, files are dispatched to a ThreadPoolExecutor that calls entity.resolved_symbol concurrently; graph writes are then emitted serially in the original file order so edge contents and ordering match the pre-refactor path. Note: in practice this currently produces ~no speedup because multilspy's SyncLanguageServer multiplexes all requests onto a single asyncio loop. The split is still worth landing because it gives us a clean place to plug in either (a) multiple LSP servers per language, or (b) multilspy's async API. See issue #687 comment thread for the experiment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b066064 commit 3b743e7

1 file changed

Lines changed: 90 additions & 11 deletions

File tree

api/analyzers/source_analyzer.py

Lines changed: 90 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)