Skip to content

Commit af18355

Browse files
ShreeBoharaclaude
andcommitted
Add a Go indexer service with a gRPC streaming contract
Extracts the walk-and-parse stage behind rpc IndexRepo(IndexRequest) returns (stream IndexEvent). Opt-in: IndexingService still uses the Python parser, and nothing changes until indexer_grpc_enabled is set. WHY, STATED HONESTLY: NOT SPEED The obvious claim is false and was measured before writing any Go. On 900 files: sequential 1115ms, ThreadPool(4) 1092ms (1.02x), ProcessPool(4) 393ms (2.84x). py-tree-sitter never releases the GIL, so threads gain nothing -- which also means the obvious anyio.to_thread fix would not have worked. Only ~41% of the parse phase is C, so a perfect native rewrite ceilings near 2.4x, BELOW what ProcessPoolExecutor on the existing Python already delivers for half a day of work. The actual reason is a process and failure boundary: repos.py runs indexing by spinning a new event loop inside an anyio threadpool thread and blocking it for minutes, in the same process that serves chat. Server streaming is why gRPC rather than one big response -- progress becomes part of the contract instead of shared mutable state, the same bug class that made the SSE progress bar show only 0% or 100%. BINDING CHOICE Official tree-sitter/go-tree-sitter, not smacker/go-tree-sitter -- which has roughly twice the stars (562 vs 288) and outranks it in search results but was last pushed 2024-08-27 with 42 open issues and no deprecation notice. Verified against the GitHub API rather than assumed, the same way the Terraform provider choice was checked. TWO BUGS NOT REPEATED Both were fixed in the Python parser earlier; this gets them right from the start. - .tsx uses LanguageTSX(), not LanguageTypescript(). The plain TS grammar cannot parse JSX. - Chunk text comes from node.Utf8Text(source) over the source BYTES, because tree-sitter offsets are byte offsets and slicing a decoded string with them corrupts every chunk after the first multi-byte character. had_parse_error is on the wire because tree-sitter returns a partial tree rather than failing, so without it the Python caller cannot decide to fall back to raw indexing. ONE BUG FIXED IN THE PORT Python's _find_files max-files cap never worked: its `break` left only the inner filename loop, so os.walk continued into the next directory and kept appending. The Go walker uses fs.SkipAll and reports Truncated, so a partial index is not mistaken for full coverage. VERIFIED END TO END, Go server streaming to the Python client over real code (apps/api/src): 5 progress events, 3 chunk batches, 509 chunks from 59 files in 99ms, 0 parse errors; kinds {module 17, class 121, function 109, method 262}; spot-check class 'Level' in api/graphql/schema.py L47-53 matches the file. Correctness cases: a .tsx component parses with had_parse_error=False under the tsx grammar, and after a line containing "héllo — wörld 日本語 🎉" the chunks still slice as 'class Café:' and 'def método(self):' rather than misaligned fragments. Suite: 168 passed, ruff clean. Generated *_pb2*.py are excluded from ruff, since any fix there is overwritten by the next protoc run. NOT DONE, deliberately: the service is not wired into the pipeline. That swap needs a placement decision this commit does not make -- the service must be co-located with the API on the shared volume, because clones live under ./data/repos/<owner>/<name> and that volume attaches to exactly one machine. There is also no Go CI job yet (ci.yml has Python 3.11 and Node 20 only, no cgo toolchain, no grammar-compile caching, no cross-language contract test), and this build links 5 grammars against the Python side's 9 -- Health() reports which, so the caller never assumes parity. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 906849d commit af18355

15 files changed

Lines changed: 2403 additions & 0 deletions

File tree

apps/api/pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ asyncio_mode = "auto"
1717
[tool.ruff]
1818
line-length = 100
1919
target-version = "py311"
20+
# Generated by protoc; any fix here is overwritten on the next `protoc` run, so linting it
21+
# would produce a permanently dirty tree.
22+
extend-exclude = ["src/core/indexer/indexer_pb2.py", "src/core/indexer/indexer_pb2_grpc.py"]
2023

2124
[tool.ruff.lint]
2225
select = ["E", "F", "I", "W"]

apps/api/src/core/indexer/__init__.py

Whitespace-only changes.
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
"""
2+
Async client for the Go indexer service.
3+
4+
Optional and off by default: the Python walk-and-parse path in IndexingService remains the
5+
default, and this is used only when indexer_grpc_enabled is set. Both produce the same
6+
chunk shape, so the caller does not branch beyond choosing a source.
7+
8+
Uses grpc.aio, which grpc documents as stable, and grpcio is already an installed
9+
dependency (transitively via chromadb) so enabling this adds no new runtime wheel.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import logging
15+
from dataclasses import dataclass
16+
from typing import AsyncIterator, List, Optional
17+
18+
logger = logging.getLogger(__name__)
19+
20+
21+
@dataclass
22+
class RemoteChunk:
23+
"""Mirrors the Chunk message. Deliberately not the ORM model: this is wire data."""
24+
file_path: str
25+
language: str
26+
chunk_type: str
27+
name: str
28+
content: str
29+
start_line: int
30+
end_line: int
31+
had_parse_error: bool
32+
33+
34+
@dataclass
35+
class IndexProgress:
36+
stage: str
37+
current_path: str
38+
files_processed: int
39+
total_files: int
40+
percent: float
41+
42+
43+
@dataclass
44+
class IndexSummary:
45+
files_walked: int
46+
files_parsed: int
47+
files_skipped: int
48+
chunks_emitted: int
49+
files_with_errors: int
50+
duration_ms: int
51+
52+
53+
class IndexerUnavailable(RuntimeError):
54+
"""Raised when the service cannot be reached, so the caller can fall back to Python."""
55+
56+
57+
class IndexerClient:
58+
def __init__(self, target: str, timeout_seconds: float = 900.0):
59+
self._target = target
60+
self._timeout = timeout_seconds
61+
62+
async def health(self) -> Optional[dict]:
63+
"""Returns the service's linked grammars, or None when unreachable."""
64+
import grpc
65+
66+
from src.core.indexer import indexer_pb2, indexer_pb2_grpc
67+
68+
try:
69+
async with grpc.aio.insecure_channel(self._target) as channel:
70+
stub = indexer_pb2_grpc.IndexerStub(channel)
71+
res = await stub.Health(indexer_pb2.HealthRequest(), timeout=10)
72+
return {
73+
"ok": res.ok,
74+
"version": res.version,
75+
# Reported rather than assumed: the Go build links a narrower grammar
76+
# set than the Python parser, and pretending otherwise would silently
77+
# drop languages.
78+
"parsers": {p.language: list(p.extensions) for p in res.parsers},
79+
}
80+
except Exception as exc:
81+
logger.warning("Indexer service health check failed (%s): %s", self._target, exc)
82+
return None
83+
84+
async def index_repo(
85+
self,
86+
repo_id: str,
87+
root_path: str,
88+
max_files: int = 0,
89+
max_file_size_kb: int = 0,
90+
batch_size: int = 0,
91+
) -> AsyncIterator[object]:
92+
"""
93+
Stream IndexProgress / List[RemoteChunk] / IndexSummary as the server produces them.
94+
95+
Yields heterogeneous types on purpose: the caller wants progress promptly and
96+
chunks in batches, and forcing both into one shape would mean buffering the whole
97+
repository before anything is usable.
98+
"""
99+
import grpc
100+
101+
from src.core.indexer import indexer_pb2, indexer_pb2_grpc
102+
103+
request = indexer_pb2.IndexRequest(
104+
repo_id=repo_id,
105+
root_path=root_path,
106+
max_files=max_files,
107+
max_file_size_kb=max_file_size_kb,
108+
batch_size=batch_size,
109+
)
110+
111+
try:
112+
async with grpc.aio.insecure_channel(self._target) as channel:
113+
stub = indexer_pb2_grpc.IndexerStub(channel)
114+
async for event in stub.IndexRepo(request, timeout=self._timeout):
115+
which = event.WhichOneof("event")
116+
if which == "progress":
117+
p = event.progress
118+
yield IndexProgress(
119+
stage=p.stage, current_path=p.current_path,
120+
files_processed=p.files_processed, total_files=p.total_files,
121+
percent=p.percent,
122+
)
123+
elif which == "chunks":
124+
yield [
125+
RemoteChunk(
126+
file_path=c.file_path, language=c.language,
127+
chunk_type=c.chunk_type, name=c.name, content=c.content,
128+
start_line=c.start_line, end_line=c.end_line,
129+
had_parse_error=c.had_parse_error,
130+
)
131+
for c in event.chunks.chunks
132+
]
133+
elif which == "completed":
134+
c = event.completed
135+
yield IndexSummary(
136+
files_walked=c.files_walked, files_parsed=c.files_parsed,
137+
files_skipped=c.files_skipped, chunks_emitted=c.chunks_emitted,
138+
files_with_errors=c.files_with_errors, duration_ms=c.duration_ms,
139+
)
140+
elif which == "failed":
141+
# A server-side failure arrives as a stream event, not a status
142+
# code, so it has to be re-raised here to stop the caller treating
143+
# a truncated stream as a complete index.
144+
raise IndexerUnavailable(
145+
f"indexer failed: {event.failed.message} ({event.failed.path})"
146+
)
147+
except IndexerUnavailable:
148+
raise
149+
except Exception as exc:
150+
raise IndexerUnavailable(f"indexer stream failed: {exc}") from exc
151+
152+
153+
async def collect_chunks(client: IndexerClient, repo_id: str, root_path: str) -> List[RemoteChunk]:
154+
"""Convenience for tests and one-shot use; drains the stream into a list."""
155+
out: List[RemoteChunk] = []
156+
async for item in client.index_repo(repo_id, root_path):
157+
if isinstance(item, list):
158+
out.extend(item)
159+
return out

apps/api/src/core/indexer/indexer_pb2.py

Lines changed: 57 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)