Skip to content

Commit c3f6bfa

Browse files
DvirDukhanCopilot
andcommitted
fix(analyzer): address review comments on second_pass parallelization (#688)
- Unify the resolve path: drop the `n_workers == 1` special case and always dispatch through ThreadPoolExecutor (max_workers=1 just runs one file at a time). This makes the per-file failure policy identical for every worker count (galshubeli, Copilot). - Track files whose resolution raises and exclude them from phase B edge writes, so a worker failure no longer leaves a partial graph when workers > 1 (coderabbit). One bad file degrades to a logged skip instead of aborting or partially writing. - Preserve the traceback on resolution failures via exc_info=True (Copilot). - De-duplicate the resolvable file list while preserving order so a path appearing twice can't be resolved concurrently by two workers racing on the same entity.resolved_symbols sets (Copilot). - Document that the default (CODE_GRAPH_INDEX_WORKERS=4) runs resolution in parallel by default and that workers=1 is a single resolver thread, not a main-thread serial path. - Add a determinism regression test asserting the full relationship multiset is identical for workers=1 vs workers=4 (Copilot). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1ab7e25 commit c3f6bfa

2 files changed

Lines changed: 98 additions & 24 deletions

File tree

api/analyzers/source_analyzer.py

Lines changed: 45 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -148,10 +148,19 @@ def second_pass(self, graph: Graph, files: list[Path], path: Path) -> None:
148148
FalkorDB MERGE locks and produces a deterministic edge order
149149
matching the pre-parallel implementation.
150150
151-
Pool size is controlled by `CODE_GRAPH_INDEX_WORKERS` (default 4).
152-
Set it to 1 to fall back to the historical fully-serial behavior
153-
(useful for debugging or for hosts where multilspy/jedi misbehaves
154-
under concurrency).
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.
155164
"""
156165
import os
157166
from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -208,9 +217,16 @@ def second_pass(self, graph: Graph, files: list[Path], path: Path) -> None:
208217

209218
# Drop files we don't actually have an entry for and skip files
210219
# whose language has no real LSP (NullLanguageServer provides
211-
# no symbol info, so resolution would be a no-op).
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.
212224
resolvable: list[Path] = []
225+
seen: set[Path] = set()
213226
for file_path in files:
227+
if file_path in seen:
228+
continue
229+
seen.add(file_path)
214230
if file_path not in self.files:
215231
# first_pass skipped this file (e.g. parse error, empty,
216232
# untracked, or ignored after entering the candidate list).
@@ -245,33 +261,38 @@ def _resolve_file(file_path: Path) -> Path:
245261
)
246262
return file_path
247263

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()
248269
done = 0
249270
log_every = max(1, total // 50) if total else 1
250-
if n_workers == 1:
251-
for fp in resolvable:
252-
_resolve_file(fp)
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+
)
253286
done += 1
254287
if done % log_every == 0 or done == total:
255288
logging.info("second_pass: resolved %d/%d files", done, total)
256-
else:
257-
with ThreadPoolExecutor(max_workers=n_workers, thread_name_prefix="sa-resolve") as ex:
258-
futures = {ex.submit(_resolve_file, fp): fp for fp in resolvable}
259-
for fut in as_completed(futures):
260-
fp = futures[fut]
261-
try:
262-
fut.result()
263-
except Exception as exc:
264-
logging.warning(
265-
"second_pass: resolution failed for %s: %s",
266-
fp, exc,
267-
)
268-
done += 1
269-
if done % log_every == 0 or done == total:
270-
logging.info("second_pass: resolved %d/%d files", done, total)
271289

272290
# Phase B: serial edge writes, in the original file order so
273-
# the graph is bit-identical to the single-threaded path.
291+
# the graph is bit-identical to the single-threaded path. Files
292+
# whose resolution failed are skipped (see phase A).
274293
for file_path in resolvable:
294+
if file_path in failed:
295+
continue
275296
file = self.files[file_path]
276297
for _, entity in file.entities.items():
277298
for key, resolved_set in entity.resolved_symbols.items():

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)