Skip to content

Commit 4a5653a

Browse files
authored
Merge branch 'staging' into dvirdukhan/mcp-t4-index-repo
2 parents cc841b6 + 8fec6a9 commit 4a5653a

2 files changed

Lines changed: 164 additions & 11 deletions

File tree

api/analyzers/source_analyzer.py

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

tests/test_py_analyzer.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
11
import os
22
import unittest
3+
from collections import Counter
34
from pathlib import Path
45

56
from api import SourceAnalyzer, Graph
67

78

9+
def _edge_snapshot(g: Graph) -> Counter:
10+
"""Return a multiset of all relationships in the graph keyed by
11+
(rel_type, src_path, src_name, dst_path, dst_name) so two indexing runs
12+
can be compared independent of node ids or write order."""
13+
rows = g._query(
14+
"MATCH (a)-[r]->(b) "
15+
"RETURN type(r), a.path, a.name, b.path, b.name"
16+
).result_set
17+
return Counter(tuple(row) for row in rows)
18+
19+
820
class Test_PY_Analyzer(unittest.TestCase):
921
def test_analyzer(self):
1022
path = Path(__file__).parent
@@ -73,3 +85,44 @@ def test_analyzer(self):
7385
self.assertIn('__init__', callers)
7486
self.assertIn('log', callers)
7587

88+
def test_index_workers_edges_are_deterministic(self):
89+
"""second_pass must produce identical edges regardless of
90+
CODE_GRAPH_INDEX_WORKERS, since phase A only parallelises symbol
91+
resolution while phase B writes edges in a fixed order.
92+
93+
Regression guard for the parallel-resolution path (#688): runs the
94+
same fixture serially (workers=1) and in parallel (workers=4) and
95+
asserts the full relationship multiset matches.
96+
"""
97+
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'source_files', 'py')
98+
99+
prev = os.environ.get("CODE_GRAPH_INDEX_WORKERS")
100+
g1 = Graph("py_workers1")
101+
g4 = Graph("py_workers4")
102+
try:
103+
os.environ["CODE_GRAPH_INDEX_WORKERS"] = "1"
104+
SourceAnalyzer().analyze_local_folder(path, g1)
105+
serial = _edge_snapshot(g1)
106+
107+
os.environ["CODE_GRAPH_INDEX_WORKERS"] = "4"
108+
SourceAnalyzer().analyze_local_folder(path, g4)
109+
parallel = _edge_snapshot(g4)
110+
111+
self.assertEqual(
112+
serial, parallel,
113+
"edge multiset differs between workers=1 and workers=4",
114+
)
115+
# Sanity: the fixture has resolvable CALLS, so guard against a
116+
# vacuous all-empty comparison when an LSP is available.
117+
self.assertGreater(
118+
sum(serial.values()), 0,
119+
"expected at least one resolved edge in the fixture",
120+
)
121+
finally:
122+
if prev is None:
123+
os.environ.pop("CODE_GRAPH_INDEX_WORKERS", None)
124+
else:
125+
os.environ["CODE_GRAPH_INDEX_WORKERS"] = prev
126+
g1.delete()
127+
g4.delete()
128+

0 commit comments

Comments
 (0)