Navegador

navegador / navegador / ingestion / optimization.py
Blame History Raw 437 lines
1
"""
2
AST optimization utilities for navegador ingestion.
3
4
Provides four independent classes that can be composed to accelerate
5
re-ingestion of large repositories:
6
7
TreeCache — LRU cache for parsed tree-sitter trees (#42)
8
IncrementalParser — Wraps tree-sitter parse() with old_tree support (#43)
9
GraphDiffer — Node-level diffing to skip unchanged writes (#44)
10
ParallelIngester — ThreadPoolExecutor wrapper around RepoIngester (#45)
11
"""
12
13
from __future__ import annotations
14
15
import concurrent.futures
16
import logging
17
import threading
18
from collections import OrderedDict
19
from dataclasses import dataclass, field
20
from pathlib import Path
21
from typing import Any
22
23
logger = logging.getLogger(__name__)
24
25
26
# ── #42 — LRU cache for parsed trees ─────────────────────────────────────────
27
28
29
class TreeCache:
30
"""
31
Thread-safe LRU cache that maps ``(path, content_hash)`` to a parsed
32
tree-sitter tree.
33
34
Args:
35
max_size: Maximum number of trees to hold in memory. When the cache
36
is full the least-recently-used entry is evicted.
37
"""
38
39
def __init__(self, max_size: int = 256) -> None:
40
if max_size < 1:
41
raise ValueError("max_size must be >= 1")
42
self._max_size = max_size
43
# OrderedDict used as an LRU store: most-recently used is at the end.
44
self._cache: OrderedDict[tuple[str, str], Any] = OrderedDict()
45
self._hits = 0
46
self._misses = 0
47
self._lock = threading.Lock()
48
49
# ── public API ────────────────────────────────────────────────────────────
50
51
def get(self, path: str, content_hash: str) -> Any | None:
52
"""Return the cached tree or ``None`` on a cache miss."""
53
key = (path, content_hash)
54
with self._lock:
55
if key in self._cache:
56
self._hits += 1
57
# Move to the end (most recently used).
58
self._cache.move_to_end(key)
59
return self._cache[key]
60
self._misses += 1
61
return None
62
63
def put(self, path: str, content_hash: str, tree: Any) -> None:
64
"""Insert (or update) a tree in the cache, evicting LRU entry if full."""
65
key = (path, content_hash)
66
with self._lock:
67
if key in self._cache:
68
self._cache.move_to_end(key)
69
self._cache[key] = tree
70
else:
71
if len(self._cache) >= self._max_size:
72
# Evict oldest entry (front of the OrderedDict).
73
self._cache.popitem(last=False)
74
self._cache[key] = tree
75
76
def clear(self) -> None:
77
"""Remove all cached trees and reset statistics."""
78
with self._lock:
79
self._cache.clear()
80
self._hits = 0
81
self._misses = 0
82
83
def stats(self) -> dict[str, int]:
84
"""Return a snapshot of cache statistics."""
85
with self._lock:
86
return {
87
"hits": self._hits,
88
"misses": self._misses,
89
"size": len(self._cache),
90
"max_size": self._max_size,
91
}
92
93
# ── dunder helpers ────────────────────────────────────────────────────────
94
95
def __len__(self) -> int:
96
with self._lock:
97
return len(self._cache)
98
99
100
# ── #43 — Incremental re-parsing via tree-sitter old_tree API ────────────────
101
102
103
class IncrementalParser:
104
"""
105
Wraps tree-sitter's ``parser.parse()`` to pass ``old_tree`` when a
106
previously-parsed tree for the same path is available.
107
108
Parsed trees are stored in a :class:`TreeCache` so subsequent calls for
109
an unchanged file return instantly without hitting the tree-sitter C
110
extension.
111
112
Args:
113
cache: A :class:`TreeCache` instance. A default cache is created if
114
none is provided.
115
"""
116
117
def __init__(self, cache: TreeCache | None = None) -> None:
118
self._cache = cache if cache is not None else TreeCache()
119
120
def parse(
121
self,
122
source_bytes: bytes,
123
language: Any,
124
path: str,
125
content_hash: str,
126
) -> Any:
127
"""
128
Parse *source_bytes* using *language* and return the tree.
129
130
If a cached tree exists for *(path, content_hash)* it is returned
131
immediately. Otherwise the most recent tree for *path* (with a
132
different hash, if any) is retrieved from the cache and passed as
133
``old_tree`` to ``language.parser.parse()`` to enable incremental
134
parsing, then the new tree is stored.
135
136
Args:
137
source_bytes: Raw UTF-8 source code.
138
language: A tree-sitter ``Language`` object (or mock in tests).
139
path: Repository-relative path, used as cache key.
140
content_hash: Content hash, used as cache key.
141
142
Returns:
143
A parsed tree-sitter ``Tree``.
144
"""
145
# Fast path: tree for this exact content is already cached.
146
cached = self._cache.get(path, content_hash)
147
if cached is not None:
148
return cached
149
150
# Look for a stale tree for this path to use as old_tree.
151
old_tree = self._get_stale_tree(path)
152
153
# Build a parser using the language object. tree-sitter parsers are
154
# instantiated differently depending on version; we support both the
155
# legacy API (tree_sitter.Parser) and the new Language.parser attribute.
156
try:
157
import tree_sitter # type: ignore[import]
158
159
parser = tree_sitter.Parser()
160
parser.set_language(language)
161
except Exception:
162
# Fallback: language might already be a parser-like object.
163
parser = language
164
165
if old_tree is not None:
166
tree = parser.parse(source_bytes, old_tree)
167
else:
168
tree = parser.parse(source_bytes)
169
170
self._cache.put(path, content_hash, tree)
171
return tree
172
173
# ── internal helpers ──────────────────────────────────────────────────────
174
175
def _get_stale_tree(self, path: str) -> Any | None:
176
"""Return any cached tree for *path* regardless of hash, or ``None``."""
177
with self._cache._lock:
178
for (cached_path, _), tree in self._cache._cache.items():
179
if cached_path == path:
180
return tree
181
return None
182
183
@property
184
def cache(self) -> TreeCache:
185
return self._cache
186
187
188
# ── #44 — Graph node diffing ──────────────────────────────────────────────────
189
190
191
@dataclass
192
class DiffResult:
193
"""Summary of a node-level diff for one file."""
194
195
added: int = 0
196
modified: int = 0
197
unchanged: int = 0
198
removed: int = 0
199
200
@property
201
def total_changes(self) -> int:
202
return self.added + self.modified + self.removed
203
204
205
@dataclass
206
class NodeDescriptor:
207
"""Minimal, hashable description of a graph node used for comparison."""
208
209
label: str
210
name: str
211
line_start: int
212
# Extra properties that contribute to the "modified" check.
213
extra: dict[str, Any] = field(default_factory=dict)
214
215
def identity_key(self) -> tuple[str, str, int]:
216
return (self.label, self.name, self.line_start)
217
218
def __eq__(self, other: object) -> bool:
219
if not isinstance(other, NodeDescriptor):
220
return NotImplemented
221
return self.identity_key() == other.identity_key() and self.extra == other.extra
222
223
224
class GraphDiffer:
225
"""
226
Compares newly-parsed nodes against what is already stored in the graph
227
so that only genuinely changed nodes need to be written.
228
229
Args:
230
store: A :class:`~navegador.graph.store.GraphStore` instance.
231
"""
232
233
def __init__(self, store: Any) -> None:
234
self._store = store
235
236
# ── public API ────────────────────────────────────────────────────────────
237
238
def diff_file(
239
self,
240
file_path: str,
241
new_nodes: list[NodeDescriptor],
242
) -> DiffResult:
243
"""
244
Compare *new_nodes* against graph nodes currently stored for
245
*file_path*.
246
247
Args:
248
file_path: Repository-relative path of the file being re-parsed.
249
new_nodes: Nodes produced by the latest parse pass.
250
251
Returns:
252
A :class:`DiffResult` with counts of added / modified / unchanged /
253
removed nodes.
254
"""
255
existing_nodes = self._fetch_existing_nodes(file_path)
256
257
existing_by_key: dict[tuple[str, str, int], NodeDescriptor] = {
258
n.identity_key(): n for n in existing_nodes
259
}
260
new_by_key: dict[tuple[str, str, int], NodeDescriptor] = {
261
n.identity_key(): n for n in new_nodes
262
}
263
264
result = DiffResult()
265
266
for key, new_node in new_by_key.items():
267
if key not in existing_by_key:
268
result.added += 1
269
elif new_node != existing_by_key[key]:
270
result.modified += 1
271
else:
272
result.unchanged += 1
273
274
for key in existing_by_key:
275
if key not in new_by_key:
276
result.removed += 1
277
278
return result
279
280
# ── internal helpers ──────────────────────────────────────────────────────
281
282
def _fetch_existing_nodes(self, file_path: str) -> list[NodeDescriptor]:
283
"""
284
Query the graph for all nodes associated with *file_path* and return
285
them as :class:`NodeDescriptor` objects.
286
287
The query returns rows of ``[label, name, line_start]``. Any row
288
where *name* or *line_start* is ``None`` is skipped.
289
"""
290
cypher = (
291
"MATCH (n {file_path: $file_path}) "
292
"RETURN labels(n)[0] AS label, n.name AS name, n.line_start AS line_start"
293
)
294
result = self._store.query(cypher, {"file_path": file_path})
295
rows = result.result_set or []
296
nodes: list[NodeDescriptor] = []
297
for row in rows:
298
label, name, line_start = row[0], row[1], row[2]
299
if name is None or line_start is None:
300
continue
301
nodes.append(
302
NodeDescriptor(label=str(label), name=str(name), line_start=int(line_start))
303
)
304
return nodes
305
306
307
# ── #45 — Parallel ingestion with worker pool ─────────────────────────────────
308
309
310
class ParallelIngester:
311
"""
312
Processes repository files concurrently using a
313
:class:`concurrent.futures.ThreadPoolExecutor`.
314
315
The class wraps a :class:`~navegador.ingestion.parser.RepoIngester` and
316
overrides its sequential file-iteration loop with a parallel one while
317
reusing all of its parsing, hashing and graph-writing logic.
318
319
Args:
320
store: Graph store passed through to the underlying
321
:class:`~navegador.ingestion.parser.RepoIngester`.
322
redact: Enable sensitive-content redaction.
323
"""
324
325
def __init__(self, store: Any, redact: bool = False) -> None:
326
from navegador.ingestion.parser import RepoIngester
327
328
self._store = store
329
self._redact = redact
330
self._ingester = RepoIngester(store, redact=redact)
331
332
def ingest_parallel(
333
self,
334
repo_path: str | Path,
335
max_workers: int | None = None,
336
clear: bool = False,
337
incremental: bool = False,
338
) -> dict[str, int]:
339
"""
340
Ingest a repository, parsing files concurrently.
341
342
Args:
343
repo_path: Path to the repository root.
344
max_workers: Number of worker threads. Defaults to
345
``min(32, cpu_count + 4)`` via
346
:class:`~concurrent.futures.ThreadPoolExecutor`.
347
clear: Wipe the graph before ingesting.
348
incremental: Skip files whose content hash hasn't changed.
349
350
Returns:
351
Aggregated stats dict with keys: files, functions, classes,
352
edges, skipped, errors.
353
"""
354
from navegador.graph.schema import NodeLabel
355
from navegador.ingestion.parser import LANGUAGE_MAP, _file_hash
356
357
repo_path = Path(repo_path).resolve()
358
if not repo_path.exists():
359
raise FileNotFoundError(f"Repository not found: {repo_path}")
360
361
if clear:
362
self._store.clear()
363
364
# Repository node (same as RepoIngester.ingest).
365
self._store.create_node(
366
NodeLabel.Repository,
367
{
368
"name": repo_path.name,
369
"path": str(repo_path),
370
},
371
)
372
373
# Collect all candidate files up-front (fast, single-threaded).
374
candidate_files = [
375
f for f in self._ingester._iter_source_files(repo_path) if LANGUAGE_MAP.get(f.suffix)
376
]
377
378
aggregated: dict[str, int] = {
379
"files": 0,
380
"functions": 0,
381
"classes": 0,
382
"edges": 0,
383
"skipped": 0,
384
"errors": 0,
385
}
386
lock = threading.Lock()
387
388
def _process_file(source_file: Path) -> None:
389
language = LANGUAGE_MAP[source_file.suffix]
390
rel_path = str(source_file.relative_to(repo_path))
391
content_hash = _file_hash(source_file)
392
393
if incremental and self._ingester._file_unchanged(rel_path, content_hash):
394
with lock:
395
aggregated["skipped"] += 1
396
return
397
398
if incremental:
399
self._ingester._clear_file_subgraph(rel_path)
400
401
parse_path, effective_root = self._ingester._maybe_redact_to_tmp(source_file, repo_path)
402
try:
403
parser = self._ingester._get_parser(language)
404
file_stats = parser.parse_file(parse_path, effective_root, self._store)
405
self._ingester._store_file_hash(rel_path, content_hash)
406
with lock:
407
aggregated["files"] += 1
408
aggregated["functions"] += file_stats.get("functions", 0)
409
aggregated["classes"] += file_stats.get("classes", 0)
410
aggregated["edges"] += file_stats.get("edges", 0)
411
except Exception:
412
logger.exception("Failed to parse %s", source_file)
413
with lock:
414
aggregated["errors"] += 1
415
finally:
416
import shutil
417
418
if effective_root is not repo_path:
419
shutil.rmtree(effective_root, ignore_errors=True)
420
421
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
422
futures = {executor.submit(_process_file, f): f for f in candidate_files}
423
for future in concurrent.futures.as_completed(futures):
424
# Exceptions are already caught inside _process_file; this
425
# re-raises any unexpected ones that slipped through.
426
future.result()
427
428
logger.info(
429
"ParallelIngester finished %s: %d files, %d functions, %d skipped, %d errors",
430
repo_path.name,
431
aggregated["files"],
432
aggregated["functions"],
433
aggregated["skipped"],
434
aggregated["errors"],
435
)
436
return aggregated
437

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button