Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions ast_rag/services/parsing/parser_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import logging
import os
import threading
from pathlib import Path
from typing import Optional, Union

Expand Down Expand Up @@ -99,6 +100,20 @@ class ParserManager:

# Explicit injection (useful in tests)
pm = ParserManager(cache=SQLiteParseCache("/tmp/test.sqlite"))

Thread safety
-------------
A single ``ParserManager`` may be shared across threads. ``WorkspaceWatcher``
already relies on this: it builds the manager on the main thread but parses
from ``threading.Timer`` callbacks, a new thread per debounce cycle.

``tree_sitter.Parser`` holds mutable state for the duration of a parse, so
parsers are **not** shared: each thread lazily builds its own via
:meth:`_get_parser`. The immutable pieces — ``Language`` objects and
compiled ``Query`` objects — are shared, which is safe because extractors
allocate a fresh ``QueryCursor`` per call and never mutate the ``Query``.

Cache backends do their own locking (see ``ast_rag.utils.parse_cache``).
"""

def __init__(
Expand All @@ -111,6 +126,8 @@ def __init__(
self._parsers: dict[str, Parser] = {}
self._compiled_queries: dict[str, dict[str, object]] = {}
self._project_id: str = project_id
# Per-thread Parser instances; see the class docstring on thread safety.
self._thread_local = threading.local()

self._node_extractor = NodeExtractor(project_id=project_id)
self._edge_extractor = EdgeExtractor(project_id=project_id)
Expand Down Expand Up @@ -158,6 +175,26 @@ def _init_languages(self) -> None:
except Exception as exc:
logger.warning("Failed to compile query '%s' for '%s': %s", qname, name, exc)
self._compiled_queries[name] = compiled
# Hand the freshly built parsers to the constructing thread so
# single-threaded callers reuse them instead of allocating a second set.
self._thread_local.parsers = dict(self._parsers)

def _get_parser(self, lang: str) -> Parser:
"""Return this thread's ``Parser`` for ``lang``, creating it on first use.

The parser built in ``_init_languages`` stays the one handed to the
thread that constructed the manager, so single-threaded callers keep
their existing object and behaviour is unchanged.
"""
parsers: Optional[dict[str, Parser]] = getattr(self._thread_local, "parsers", None)
if parsers is None:
parsers = {}
self._thread_local.parsers = parsers
parser = parsers.get(lang)
if parser is None:
parser = Parser(self._languages[lang])
parsers[lang] = parser
return parser

def detect_language(self, file_path: str) -> Optional[str]:
ext = Path(file_path).suffix.lower()
Expand Down Expand Up @@ -204,12 +241,12 @@ def parse_file(
lazy = self._cache.get(
abs_path,
source,
loader=lambda: self._parsers[_lang].parse(_src),
loader=lambda: self._get_parser(_lang).parse(_src),
)
if lazy is not None:
return lazy.resolve() if resolve else lazy

parser = self._parsers[lang]
parser = self._get_parser(lang)
tree = parser.parse(source, old_tree) if old_tree is not None else parser.parse(source)
self._cache.put(abs_path, source, tree)
return tree
Expand Down
69 changes: 43 additions & 26 deletions ast_rag/utils/bounded_ast_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import hashlib
import logging
import sys
import threading
from collections import OrderedDict
from typing import Any, Callable, ItemsView, Optional, Tuple

Expand Down Expand Up @@ -223,6 +224,10 @@ def __init__(
self._misses: int = 0
# Store content hashes alongside entries for staleness checks
self._hashes: dict[str, str] = {}
# Guards the compound read-then-act sequences below: a hit touches LRU
# order in _inner *and* reads _hashes, and those two must not interleave
# with another thread's put()/evict().
self._lock = threading.RLock()

# ------------------------------------------------------------------
# Core interface (same as ParseCache / SQLiteParseCache)
Expand All @@ -249,20 +254,22 @@ def get(
Returns:
A pre-loaded ``LazyTree`` on a hit, or ``None`` on a miss.
"""
if abs_path not in self._inner:
self._misses += 1
logger.debug("BoundedParseCache MISS: %s", abs_path)
return None

stored_hash = self._hashes.get(abs_path)
if stored_hash != self.hash_source(source):
self._misses += 1
logger.debug("BoundedParseCache MISS (stale): %s", abs_path)
return None

self._hits += 1
# Touch for LRU ordering
tree, _lang = self._inner[abs_path]
content_hash = self.hash_source(source)
with self._lock:
if abs_path not in self._inner:
self._misses += 1
logger.debug("BoundedParseCache MISS: %s", abs_path)
return None

stored_hash = self._hashes.get(abs_path)
if stored_hash != content_hash:
self._misses += 1
logger.debug("BoundedParseCache MISS (stale): %s", abs_path)
return None

self._hits += 1
# Touch for LRU ordering
tree, _lang = self._inner[abs_path]

# Return a pre-loaded LazyTree (same pattern as ParseCache)
lazy = LazyTree(loader=lambda t=tree: t)
Expand All @@ -281,8 +288,12 @@ def put(self, abs_path: str, source: bytes, tree: Tree) -> None:
"""
content_hash = self.hash_source(source)
lang = "" # language is not critical for cache storage
self._hashes[abs_path] = content_hash
self._inner.set_with_source(abs_path, (tree, lang), source)
with self._lock:
self._hashes[abs_path] = content_hash
self._inner.set_with_source(abs_path, (tree, lang), source)
# set_with_source may evict other keys to stay within limits;
# drop their hashes so _hashes cannot outgrow the cache.
self._hashes = {k: v for k, v in self._hashes.items() if k in self._inner}
logger.debug("BoundedParseCache PUT : %s", abs_path)

def evict(self, abs_path: str) -> None:
Expand All @@ -291,15 +302,19 @@ def evict(self, abs_path: str) -> None:
Args:
abs_path: Absolute path of the file to evict.
"""
if abs_path in self._inner:
del self._inner[abs_path]
self._hashes.pop(abs_path, None)
with self._lock:
present = abs_path in self._inner
if present:
del self._inner[abs_path]
self._hashes.pop(abs_path, None)
if present:
logger.debug("BoundedParseCache EVICT: %s", abs_path)

def clear(self) -> None:
"""Evict all cached trees."""
self._inner.clear()
self._hashes.clear()
with self._lock:
self._inner.clear()
self._hashes.clear()
logger.debug("BoundedParseCache cleared")

# ------------------------------------------------------------------
Expand All @@ -321,13 +336,15 @@ def stats(self) -> dict:
'max_memory_mb': float,
}
"""
total = self._hits + self._misses
inner_stats = self._inner.get_stats()
with self._lock:
hits, misses = self._hits, self._misses
inner_stats = self._inner.get_stats()
total = hits + misses
return {
"size": inner_stats["entries"],
"hits": self._hits,
"misses": self._misses,
"hit_rate": self._hits / total if total else 0.0,
"hits": hits,
"misses": misses,
"hit_rate": hits / total if total else 0.0,
"max_entries": inner_stats["max_entries"],
"memory_mb": inner_stats["memory_mb"],
"max_memory_mb": inner_stats["max_memory_mb"],
Expand Down
Loading
Loading