1e606553d4
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 15s
CI / build (pull_request) Successful in 15s
CI / quality (pull_request) Successful in 18s
CI / security (pull_request) Successful in 35s
CI / typecheck (pull_request) Successful in 38s
CI / unit_tests (pull_request) Successful in 2m32s
CI / integration_tests (pull_request) Successful in 3m18s
CI / docker (pull_request) Successful in 49s
CI / coverage (pull_request) Successful in 5m29s
CI / benchmark-regression (pull_request) Successful in 33m48s
Implement the UKOIndexer service that produces UKO triples from resources using pluggable domain-specific analyzers, wraps each triple with provenance metadata, and simultaneously indexes into text, vector, and graph backends. Key design decisions and components: - UKOIndexer orchestrates the full index lifecycle: add_resource, update_resource (remove-then-add), remove_resource, and maintenance triggers. Each operation fires lifecycle hooks (on_indexed, on_removed, on_error) so callers can observe progress. - Analyzer selection is pluggable via ContentAnalyzer protocol. The indexer accepts a registry mapping resource types to analyzers. PythonAnalyzer and MarkdownAnalyzer are provided as built-in implementations. - LocationContentReader protocol abstracts file I/O with a base_dir parameter for path-traversal prevention (post-resolve validation rejects paths escaping the base directory and non-regular files). - UKOTriple model includes a @model_validator ensuring at least one of object_uri or object_value is populated, preventing empty triples at construction time. - Triple removal uses scoped deletion via uko:sourceResource predicate to avoid shared-subject collision — only triples originating from the specific resource are removed, not all triples for a shared subject. - _resource_subjects.pop is deferred until after all backend removal operations succeed, preventing inconsistent state on partial failure. - analyzer.analyze() is wrapped in try/except so that analyzer errors produce an IndexResult with error details rather than propagating exceptions to callers. - All lifecycle hook calls are guarded via _fire_on_indexed, _fire_on_removed, and _fire_on_error helpers that catch and log hook exceptions without disrupting the indexing pipeline. - max_triples parameter (default 50,000) bounds analyzer output size to prevent runaway resource consumption. - ResourceFileWatcher monitors filesystem paths via watchdog and triggers re-indexing callbacks on file changes with configurable debouncing. Emits RESOURCE_MODIFIED domain events via EventBus when file changes are detected. Debounce timers coalesce rapid edits into a single callback invocation. Thread-safe design with daemon threads for clean shutdown. - SearchResult.__post_init__ validates score is in [0.0, 1.0], correctly rejecting NaN values. - Placeholder embedding uses [1.0] instead of [float(len(content))] to avoid leaking content size information. - isinstance check on graph_backend ensures GraphIndexBackend protocol compliance at runtime. - Test doubles extracted to features/mocks/uko_indexer_mocks.py for reuse across BDD steps and Robot helpers. Spec reference: Architecture > ACMS > Real-time Index Synchronization (specification.md lines ~43205-43300). ISSUES CLOSED: #578
225 lines
7.4 KiB
Python
225 lines
7.4 KiB
Python
"""ASV benchmarks for UKO Indexer Real-time Index Synchronization.
|
|
|
|
Measures the performance of:
|
|
- UKOIndexer.index_resource() pipeline (analyze → provenance → graph → text → vector)
|
|
- UKOIndexer.remove_resource() cleanup
|
|
- UKOIndexer.reindex_resource() lifecycle
|
|
- In-memory index backend operations
|
|
- Graceful degradation (no optional backends)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# Ensure the local *source* tree is importable even when ASV has an
|
|
# older build of the package installed.
|
|
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
|
if _SRC not in sys.path:
|
|
sys.path.insert(0, _SRC)
|
|
|
|
# Force-reload so ASV picks up the source tree version.
|
|
import cleveragents # noqa: E402
|
|
|
|
importlib.reload(cleveragents)
|
|
|
|
from cleveragents.application.services.uko_indexer import ( # noqa: E402
|
|
UKOIndexer,
|
|
)
|
|
from cleveragents.domain.models.acms.analyzers import ( # noqa: E402
|
|
AnalyzerRegistry,
|
|
)
|
|
from cleveragents.domain.models.acms.index_stubs import ( # noqa: E402
|
|
InMemoryGraphIndexBackend,
|
|
InMemoryTextIndexBackend,
|
|
InMemoryVectorIndexBackend,
|
|
)
|
|
from cleveragents.domain.models.acms.python_analyzer import ( # noqa: E402
|
|
PythonAnalyzer,
|
|
)
|
|
from cleveragents.domain.models.core.resource import Resource # noqa: E402
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
ULID_1 = "01HQ8ZDRX50000000000000001"
|
|
ULID_2 = "01HQ8ZDRX50000000000000002"
|
|
PROJECT = "local/bench"
|
|
|
|
_SMALL_PYTHON = 'import os\n\ndef hello():\n """Say hello."""\n pass\n'
|
|
|
|
_MEDIUM_PYTHON = (
|
|
'"""Module docstring."""\n\nimport os\nfrom pathlib import Path\n\n'
|
|
+ "\n\n".join(
|
|
f'class Cls{i}:\n """Class {i}."""\n'
|
|
f" def method_{i}(self):\n"
|
|
f' """Method {i}."""\n pass\n'
|
|
for i in range(20)
|
|
)
|
|
)
|
|
|
|
|
|
class _InMemoryContentReader:
|
|
"""Content reader for benchmarks."""
|
|
|
|
def __init__(self) -> None:
|
|
self._content: dict[str, str] = {}
|
|
|
|
def set_content(self, resource_id: str, content: str) -> None:
|
|
self._content[resource_id] = content
|
|
|
|
def read_content(self, resource: Resource) -> str:
|
|
return self._content[resource.resource_id]
|
|
|
|
|
|
def _make_resource(resource_id: str) -> Resource:
|
|
return Resource(
|
|
resource_id=resource_id,
|
|
resource_type_name="git-checkout",
|
|
classification="physical",
|
|
location="src/bench.py",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Index pipeline benchmarks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class IndexResourceSuite:
|
|
"""Benchmark UKOIndexer.index_resource() pipeline."""
|
|
|
|
timeout = 120
|
|
|
|
def setup(self) -> None:
|
|
self.resource = _make_resource(ULID_1)
|
|
|
|
def time_index_small_python(self) -> None:
|
|
# Re-create indexer each time to avoid accumulation
|
|
registry = AnalyzerRegistry()
|
|
registry.register(PythonAnalyzer())
|
|
reader = _InMemoryContentReader()
|
|
reader.set_content(ULID_1, _SMALL_PYTHON)
|
|
indexer = UKOIndexer(
|
|
analyzer_registry=registry,
|
|
graph_backend=InMemoryGraphIndexBackend(),
|
|
text_backend=InMemoryTextIndexBackend(),
|
|
vector_backend=InMemoryVectorIndexBackend(),
|
|
content_reader=reader,
|
|
)
|
|
indexer.index_resource(self.resource, project=PROJECT)
|
|
|
|
def time_index_medium_python(self) -> None:
|
|
registry = AnalyzerRegistry()
|
|
registry.register(PythonAnalyzer())
|
|
reader = _InMemoryContentReader()
|
|
reader.set_content(ULID_1, _MEDIUM_PYTHON)
|
|
indexer = UKOIndexer(
|
|
analyzer_registry=registry,
|
|
graph_backend=InMemoryGraphIndexBackend(),
|
|
text_backend=InMemoryTextIndexBackend(),
|
|
vector_backend=InMemoryVectorIndexBackend(),
|
|
content_reader=reader,
|
|
)
|
|
indexer.index_resource(self.resource, project=PROJECT)
|
|
|
|
|
|
class IndexGracefulDegradationSuite:
|
|
"""Benchmark indexing without optional backends."""
|
|
|
|
timeout = 120
|
|
|
|
def setup(self) -> None:
|
|
self.resource = _make_resource(ULID_1)
|
|
|
|
def time_index_graph_only(self) -> None:
|
|
registry = AnalyzerRegistry()
|
|
registry.register(PythonAnalyzer())
|
|
reader = _InMemoryContentReader()
|
|
reader.set_content(ULID_1, _SMALL_PYTHON)
|
|
indexer = UKOIndexer(
|
|
analyzer_registry=registry,
|
|
graph_backend=InMemoryGraphIndexBackend(),
|
|
content_reader=reader,
|
|
)
|
|
indexer.index_resource(self.resource, project=PROJECT)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lifecycle benchmarks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class LifecycleSuite:
|
|
"""Benchmark index lifecycle (add/remove/reindex)."""
|
|
|
|
timeout = 120
|
|
|
|
def setup(self) -> None:
|
|
self.resource = _make_resource(ULID_1)
|
|
|
|
def time_remove_resource(self) -> None:
|
|
registry = AnalyzerRegistry()
|
|
registry.register(PythonAnalyzer())
|
|
reader = _InMemoryContentReader()
|
|
reader.set_content(ULID_1, _SMALL_PYTHON)
|
|
indexer = UKOIndexer(
|
|
analyzer_registry=registry,
|
|
graph_backend=InMemoryGraphIndexBackend(),
|
|
text_backend=InMemoryTextIndexBackend(),
|
|
vector_backend=InMemoryVectorIndexBackend(),
|
|
content_reader=reader,
|
|
)
|
|
indexer.index_resource(self.resource, project=PROJECT)
|
|
indexer.remove_resource(ULID_1, project=PROJECT)
|
|
|
|
def time_reindex_resource(self) -> None:
|
|
registry = AnalyzerRegistry()
|
|
registry.register(PythonAnalyzer())
|
|
reader = _InMemoryContentReader()
|
|
reader.set_content(ULID_1, _SMALL_PYTHON)
|
|
indexer = UKOIndexer(
|
|
analyzer_registry=registry,
|
|
graph_backend=InMemoryGraphIndexBackend(),
|
|
text_backend=InMemoryTextIndexBackend(),
|
|
vector_backend=InMemoryVectorIndexBackend(),
|
|
content_reader=reader,
|
|
)
|
|
indexer.index_resource(self.resource, project=PROJECT)
|
|
reader.set_content(ULID_1, _MEDIUM_PYTHON)
|
|
indexer.reindex_resource(self.resource, project=PROJECT)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Backend operation benchmarks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class BackendOperationsSuite:
|
|
"""Benchmark raw in-memory backend operations."""
|
|
|
|
timeout = 60
|
|
|
|
def time_text_index_and_search(self) -> None:
|
|
be = InMemoryTextIndexBackend()
|
|
be.index_document(PROJECT, "doc1", "hello world", {"k": "v"})
|
|
be.search(PROJECT, "hello", limit=10)
|
|
|
|
def time_vector_index_and_search(self) -> None:
|
|
be = InMemoryVectorIndexBackend()
|
|
be.index_embedding(PROJECT, "emb1", [1.0, 2.0, 3.0], {"k": "v"})
|
|
be.search_similar(PROJECT, [1.0, 2.0, 3.0], limit=10)
|
|
|
|
def time_graph_add_and_query(self) -> None:
|
|
be = InMemoryGraphIndexBackend()
|
|
be.add_triple(PROJECT, "s1", "p1", "o1")
|
|
be.query(PROJECT, "SELECT *")
|
|
|
|
def time_graph_add_and_remove(self) -> None:
|
|
be = InMemoryGraphIndexBackend()
|
|
be.add_triple(PROJECT, "s1", "p1", "o1")
|
|
be.remove_triples(PROJECT, subject="s1", predicate=None, obj=None)
|