orchestrata_ultimate.knowledge_graph.graph_client

Reference › orchestrata_ultimate › knowledge_graph › graph_client

GraphClient is the low-level async access layer to the operational knowledge graph, the orchkg database on the orchgraph engine (a Memgraph fork). It is the class that most of the knowledge_graph package, the gateway RPC layer, the kernel and several volks write and read through (Spec 008, REQ-008-001). It is built from two mixins, GraphNodeCreationMixin and GraphFileQueryMixin, defined in graph_node_mixins; the client itself supplies connection handling and the generic node, relationship and query operations.

connect creates an async Bolt driver through core.graph_factory.create_async_graph using the pool size, lifetime and timeout from GraphClientConfig (see config) and verifies connectivity. It returns False and logs a warning on failure instead of raising. A driver passed to the constructor is treated as shared: connect only verifies it and close leaves it open. The neo4j name in the driver type is Bolt-client naming only; Neo4j itself is retired.

The generic operations are create_node, get_node, update_node, delete_node, create_relationship, delete_relationship, query, count_nodes and ensure_schema, which applies constraint and index statements. All of them funnel through _execute, which opens a session on the configured database and returns records as dictionaries. It raises GraphConnectionError when not connected. On transient concurrency errors, including the write-conflict abort that Memgraph's storage engine raises where Neo4j would block, it retries up to a fixed number of attempts with exponential backoff starting at 50 ms; any other failure becomes a GraphQueryError carrying the Cypher text. Exception types come from exceptions.

At a glance

Source src/orchestrata_ultimate/knowledge_graph/graph_client.py
Lines of code 453
Classes 1
Functions 0
Imports (internal) orchestrata_ultimate.core.base.traces, orchestrata_ultimate.core.graph.retry, orchestrata_ultimate.core.graph_factory, orchestrata_ultimate.knowledge_graph.config, orchestrata_ultimate.knowledge_graph.exceptions, orchestrata_ultimate.knowledge_graph.graph_node_mixins, orchestrata_ultimate.knowledge_graph.nodes, orchestrata_ultimate.knowledge_graph.schema, orchestrata_ultimate.knowledge_graph.types
Imported by orchestrata_ultimate.agents.volks.architect.architect_context, orchestrata_ultimate.gateway.rpc, orchestrata_ultimate.kernel.pipeline_cli, orchestrata_ultimate.knowledge_graph.anomaly_detector, orchestrata_ultimate.knowledge_graph.file_watcher, orchestrata_ultimate.knowledge_graph.portfolio, orchestrata_ultimate.knowledge_graph.query_engine, orchestrata_ultimate.knowledge_graph.search, orchestrata_ultimate.knowledge_graph.synchronizer, orchestrata_ultimate.knowledge_graph.temporal, orchestrata_ultimate.orchestrator.impact.dependency_analyzer
Tests that import it tests/conftest.py, tests/gateway/handlers/test_knowledge.py, tests/kernel/test_volk_kg_sync.py, tests/knowledge_graph/test_anomaly_detector.py, tests/knowledge_graph/test_anomaly_detector_extended.py, tests/knowledge_graph/test_artifact_provenance_sync.py, tests/knowledge_graph/test_code_intelligence_graph.py, tests/knowledge_graph/test_component_work_context.py, tests/knowledge_graph/test_cov_knowledge_query_engine.py, tests/knowledge_graph/test_cov_knowledge_search.py, tests/knowledge_graph/test_graph_client_coverage.py, tests/knowledge_graph/test_graph_sync_integration.py, tests/knowledge_graph/test_graph_transient_error_regression.py, tests/knowledge_graph/test_knowledge_graph_client.py, tests/knowledge_graph/test_knowledge_interfaces.py, tests/knowledge_graph/test_knowledge_signatures.py, tests/knowledge_graph/test_query_engine_cache.py, tests/knowledge_graph/test_query_engine_coverage_wave2.py, tests/knowledge_graph/test_real_graph_knowledge.py, tests/knowledge_graph/test_temporal.py, tests/knowledge_graph/test_volk_work_context.py

Class diagram

Class diagram orchestrata_ultimate.knowledge_graph.graph_client

Dependency diagram

Dependency diagram orchestrata_ultimate.knowledge_graph.graph_client

API

Classes

GraphClient

Bases: GraphNodeCreationMixin, GraphFileQueryMixin · Decorators: traces(spec='008', story='US-001', reqs=['REQ-008-001']) · Line 44

Graph database client for the knowledge graph.

Method Signature Summary
__init__ __init__(config: GraphClientConfig | None = None, driver: neo4j.AsyncDriver | None = None) -> None
connect async connect() -> bool Establish connection to the graph.
close async close() -> None Close the graph connection.
connected property connected() -> bool Return True if currently connected to the graph.
ensure_schema async ensure_schema(statements: list[str] | None = None) -> int Create constraints and indexes.
create_node async create_node(node: Node) -> str Create a generic node.
get_node async get_node(node_id: str, node_type: str | None = None) -> Node | None Get a node by ID.
update_node async update_node(node_id: str, properties: dict[str, Any], node_type: str | None = None) -> bool Update node properties.
delete_node async delete_node(node_id: str, node_type: str | None = None) -> bool Delete a node and all its relationships.
create_relationship async create_relationship(source_id: str, target_id: str, rel_type: str, properties: dict[str, Any] | None = None, source_type: str | None = None, target_type: str | None = None) -> bool Create a relationship between two nodes.
delete_relationship async delete_relationship(source_id: str, target_id: str, rel_type: str, source_type: str | None = None, target_type: str | None = None) -> bool Delete a relationship between two nodes.
query async query(cypher: str, parameters: dict[str, Any] | None = None) -> list[dict[str, Any]] Execute a Cypher query and return results.
count_nodes async count_nodes(label: str | None = None) -> int Count nodes, optionally filtered by label.
_execute async _execute(mode: str, cypher: str, params: dict[str, Any]) -> list[dict[str, Any]] Execute a Cypher statement.
_is_transient_error staticmethod _is_transient_error(msg: str) -> bool Return True if msg is a transient concurrency error worth retrying.

Source

Permalink: https://gitlab.com/MSamy/orchestrata-ultimate-architecture/-/blob/a4cbc2bdf/src/orchestrata_ultimate/knowledge_graph/graph_client.py

# mypy: ignore-errors
"""Graph client for the knowledge graph.

Handles connection, CRUD operations, schema management, and query execution
against the graph. Typed-node creation and file-query helpers are provided by
``GraphNodeCreationMixin`` and ``GraphFileQueryMixin`` (graph_node_mixins).
"""

from __future__ import annotations

import asyncio
import logging
from typing import TYPE_CHECKING, Any, LiteralString, cast

from orchestrata_ultimate.core.base.traces import traces
from orchestrata_ultimate.core.graph.retry import MAX_ATTEMPTS, is_mvcc_conflict_message
from orchestrata_ultimate.core.graph_factory import resolve_kg_dialect
from orchestrata_ultimate.knowledge_graph.config import GraphClientConfig
from orchestrata_ultimate.knowledge_graph.exceptions import (
    GraphConnectionError,
    GraphQueryError,
)
from orchestrata_ultimate.knowledge_graph.graph_node_mixins import (
    GraphFileQueryMixin,
    GraphNodeCreationMixin,
)
from orchestrata_ultimate.knowledge_graph.nodes import (
    cypher_record_to_node,
    node_to_cypher_props,
)
from orchestrata_ultimate.knowledge_graph.schema import (
    get_node_label,
    get_unified_ddl,
)
from orchestrata_ultimate.knowledge_graph.types import Node

if TYPE_CHECKING:
    import neo4j

logger = logging.getLogger(__name__)


@traces(spec="008", story="US-001", reqs=["REQ-008-001"])
class GraphClient(GraphNodeCreationMixin, GraphFileQueryMixin):
    """Graph database client for the knowledge graph.

    Provides async CRUD operations, schema management,
    and query execution.

    Typed-node creation (``create_file_node``, ``create_class_node``, etc.)
    and file-query helpers (``get_project_files``, ``delete_file_cascade``,
    etc.) are inherited from the mixin classes — all callers are unaffected.
    """

    def __init__(
        self,
        config: GraphClientConfig | None = None,
        driver: neo4j.AsyncDriver | None = None,
    ) -> None:
        self._config = config or GraphClientConfig()
        self._driver = driver
        self._connected = False
        self._owns_driver = driver is None

    @traces(spec="001", story="US-010", reqs=["REQ-001-US-010-013"])
    async def connect(self) -> bool:
        """Establish connection to the graph."""
        if self._driver and not self._owns_driver:
            try:
                await self._driver.verify_connectivity()
                self._connected = True
                return True
            except Exception as exc:
                logger.warning("Shared driver verify failed: %s", exc)
                self._connected = False
                return False
        try:
            from orchestrata_ultimate.core.graph_factory import create_async_graph

            self._driver = create_async_graph(
                self._config.uri,
                auth=(self._config.username, self._config.password),
                max_connection_pool_size=self._config.max_pool_size,
                max_connection_lifetime=self._config.max_connection_lifetime,
                connection_timeout=self._config.connection_timeout,
            )
            await self._driver.verify_connectivity()
            self._connected = True
            logger.info("Connected to the graph at %s", self._config.uri)
            return True
        except Exception as exc:
            logger.warning("Graph connection failed: %s", exc)
            self._connected = False
            return False

    async def close(self) -> None:
        """Close the graph connection."""
        if self._driver and self._owns_driver:
            await self._driver.close()
        self._driver = None
        self._connected = False

    @property
    def connected(self) -> bool:
        """Return True if currently connected to the graph."""
        return self._connected

    @traces(spec="008", story="US-001", reqs=["REQ-008-037"])
    async def ensure_schema(self, statements: list[str] | None = None) -> int:
        """Create constraints and indexes.

        Applies the canonical identity constraints (SPEC-106
        ``canonical_schema.get_all_ddl()`` — THE single identity source,
        generated from ``CANONICAL_MERGE_KEYS``) first, then the lookup
        layer still owned by ``schema.py`` (extra constraints, lookup
        indexes, fulltext indexes), deduplicated.

        Failure policy: only genuine already-exists conflicts (Neo4j
        ``*AlreadyExists`` / equivalent-schema-rule codes) are skipped;
        every other error raises ``GraphQueryError``. The previous blanket
        ``except Exception -> logger.debug`` swallowed real failures —
        against a dialect mismatch the KG silently booted with no indexes
        and no fulltext (plans/graph-consolidation-decision.md).

        Args:
            statements: Override the DDL set (tests / scoped migrations),
                mirroring ``canonical_schema.apply``.

        Returns:
            Count of DDL statements executed successfully.
        """
        if not self._connected or not self._driver:
            raise GraphConnectionError("Not connected to the graph")
        if statements is None:
            # Dialect at CALL time: the same client class now serves Neo4j
            # and orchgraph/Memgraph, whose schema grammar has no
            # ``IF NOT EXISTS`` and no ``CREATE TEXT INDEX ... FOR ... ON``.
            # Defaulting to the graph DDL made ensure_schema raise on the new
            # engine, and the failure policy below (correctly) does not
            # swallow it.
            statements = get_unified_ddl(dialect=resolve_kg_dialect())
        count = 0
        async with self._driver.session(database=self._config.database) as session:
            for ddl in statements:
                try:
                    await session.run(cast(LiteralString, ddl))
                    count += 1
                except Exception as exc:
                    # Neo4j reports this in the error CODE
                    # (Neo.ClientError.Schema.*AlreadyExists). orchgraph/
                    # Memgraph has one generic code and would say it in the
                    # MESSAGE — measured live 2026-07-26, re-creating an
                    # identical constraint or index on that engine is a silent
                    # no-op, so it does not normally reach here at all; the
                    # message check covers the forms that do (e.g. a plain
                    # index colliding with a constraint-backed one) and
                    # matches canonical_schema.apply's policy.
                    already = (
                        "AlreadyExists" in (getattr(exc, "code", "") or "")
                        or "already exists" in str(exc).lower()
                    )
                    if already:
                        # IF NOT EXISTS covers same-name duplicates; this is
                        # the equivalent-rule-under-a-different-name case.
                        logger.debug("Schema DDL already satisfied: %s", exc)
                        continue
                    logger.error("Schema DDL failed: %s -- %s", ddl, exc)
                    raise GraphQueryError(str(exc), query=ddl) from exc
        return count

    # -- Node CRUD ---------------------------------------------------------

    async def create_node(self, node: Node) -> str:
        """Create a generic node.

        Args:
            node: The Node to persist.

        Returns:
            The node ID.
        """
        if not self._connected or not self._driver:
            raise GraphConnectionError("Not connected")
        label = get_node_label(node.type.value)
        props = node_to_cypher_props(node)
        cypher = f"CREATE (n:{label} $props) RETURN n.id AS id"
        result = await self._execute("write", cypher, {"props": props})
        return result[0]["id"] if result else node.id

    async def get_node(self, node_id: str, node_type: str | None = None) -> Node | None:
        """Get a node by ID.

        Args:
            node_id: The ID of the node to retrieve.
            node_type: Optional node type (``types_enums.NodeType`` value,
                resolved via ``schema.get_node_label``) to plan a labeled
                index seek instead of an unlabeled ``MATCH (n {id: ...})`` --
                the latter cannot use the per-label unique constraints and
                plans an AllNodesScan on every call (measured live:
                ~7,900 dbHits vs. 15 for a labeled seek on the 768K-node KG).
                Pass it whenever the caller already knows the type; the bulk
                UNWIND writes in ``sync.py`` apply the same labeled-seek
                shape. Defaults to the prior unlabeled behaviour.

        Returns:
            The Node if found, otherwise None.
        """
        match_clause = "MATCH (n {id: $id})"
        if node_type is not None:
            label = get_node_label(node_type)
            match_clause = f"MATCH (n:{label} {{id: $id}})"
        cypher = f"{match_clause} RETURN properties(n) AS props"
        result = await self._execute("read", cypher, {"id": node_id})
        if result:
            return cypher_record_to_node(result[0].get("props", {}))
        return None

    async def update_node(
        self,
        node_id: str,
        properties: dict[str, Any],
        node_type: str | None = None,
    ) -> bool:
        """Update node properties.

        Args:
            node_id: The ID of the node to update.
            properties: Key/value pairs to merge onto the node.
            node_type: Optional node type -- see ``get_node``'s labeled-seek
                note. Defaults to the prior unlabeled behaviour.

        Returns:
            True if the node was found and updated.
        """
        match_clause = "MATCH (n {id: $id})"
        if node_type is not None:
            label = get_node_label(node_type)
            match_clause = f"MATCH (n:{label} {{id: $id}})"
        cypher = f"{match_clause} SET n += $props RETURN n.id AS id"
        result = await self._execute(
            "write", cypher, {"id": node_id, "props": properties}
        )
        return len(result) > 0

    @traces(spec="008", story="US-008-001", reqs=["REQ-008-089"])
    async def delete_node(self, node_id: str, node_type: str | None = None) -> bool:
        """Delete a node and all its relationships.

        Args:
            node_id: The ID of the node to delete.
            node_type: Optional node type -- see ``get_node``'s labeled-seek
                note. Defaults to the prior unlabeled behaviour.

        Returns:
            True if a node was deleted.
        """
        match_clause = "MATCH (n {id: $id})"
        if node_type is not None:
            label = get_node_label(node_type)
            match_clause = f"MATCH (n:{label} {{id: $id}})"
        cypher = f"{match_clause} DETACH DELETE n RETURN count(*) AS deleted"
        result = await self._execute("write", cypher, {"id": node_id})
        return result[0].get("deleted", 0) > 0 if result else False

    # -- Relationship CRUD -------------------------------------------------

    async def create_relationship(
        self,
        source_id: str,
        target_id: str,
        rel_type: str,
        properties: dict[str, Any] | None = None,
        source_type: str | None = None,
        target_type: str | None = None,
    ) -> bool:
        """Create a relationship between two nodes.

        Args:
            source_id: ID of the source node.
            target_id: ID of the target node.
            rel_type: Cypher relationship type label.
            properties: Optional properties to set on the relationship.
            source_type: Optional type of the source node -- see
                ``get_node``'s labeled-seek note. Defaults to the prior
                unlabeled behaviour.
            target_type: Optional type of the target node, independent of
                ``source_type`` (the two ends can be different node types).

        Returns:
            True if the relationship was created or already existed.
        """
        source_label = (
            f":{get_node_label(source_type)}" if source_type is not None else ""
        )
        target_label = (
            f":{get_node_label(target_type)}" if target_type is not None else ""
        )
        set_clause = ""
        params: dict[str, Any] = {"source_id": source_id, "target_id": target_id}
        if properties:
            # Neo4j rejects a parameter map embedded directly in a MERGE
            # pattern ("Parameter maps cannot be used in MERGE patterns");
            # properties must be applied afterwards via SET.
            set_clause = " SET r += $rel_props"
            params["rel_props"] = properties
        cypher = (
            f"MATCH (a{source_label} {{id: $source_id}}), (b{target_label} {{id: $target_id}}) "
            f"MERGE (a)-[r:{rel_type}]->(b)"
            f"{set_clause} "
            "RETURN type(r) AS rel_type"
        )
        result = await self._execute("write", cypher, params)
        return len(result) > 0

    async def delete_relationship(
        self,
        source_id: str,
        target_id: str,
        rel_type: str,
        source_type: str | None = None,
        target_type: str | None = None,
    ) -> bool:
        """Delete a relationship between two nodes.

        Args:
            source_id: ID of the source node.
            target_id: ID of the target node.
            rel_type: Cypher relationship type label.
            source_type: Optional type of the source node -- see
                ``get_node``'s labeled-seek note. Defaults to the prior
                unlabeled behaviour.
            target_type: Optional type of the target node.

        Returns:
            True if a relationship was deleted.
        """
        source_label = (
            f":{get_node_label(source_type)}" if source_type is not None else ""
        )
        target_label = (
            f":{get_node_label(target_type)}" if target_type is not None else ""
        )
        cypher = (
            f"MATCH (a{source_label} {{id: $source_id}})-[r:{rel_type}]->"
            f"(b{target_label} {{id: $target_id}}) "
            "DELETE r RETURN count(*) AS deleted"
        )
        params = {"source_id": source_id, "target_id": target_id}
        result = await self._execute("write", cypher, params)
        return result[0].get("deleted", 0) > 0 if result else False

    # -- Query -------------------------------------------------------------

    async def query(
        self, cypher: str, parameters: dict[str, Any] | None = None
    ) -> list[dict[str, Any]]:
        """Execute a Cypher query and return results.

        Args:
            cypher: The Cypher query string.
            parameters: Optional query parameters.

        Returns:
            List of result records as dicts.
        """
        return await self._execute("read", cypher, parameters or {})

    async def count_nodes(self, label: str | None = None) -> int:
        """Count nodes, optionally filtered by label.

        Args:
            label: Optional node label to filter by.

        Returns:
            The node count.
        """
        if label:
            cypher = f"MATCH (n:{label}) RETURN count(n) AS cnt"
        else:
            cypher = "MATCH (n) RETURN count(n) AS cnt"
        result = await self._execute("read", cypher, {})
        return result[0]["cnt"] if result else 0

    # -- Internal ----------------------------------------------------------

    async def _execute(
        self, mode: str, cypher: str, params: dict[str, Any]
    ) -> list[dict[str, Any]]:
        """Execute a Cypher statement.

        Retries on transient concurrency errors (e.g., nodes deleted by
        parallel tests) to keep the test suite stable under ``pytest -n auto``.

        Args:
            mode: ``'read'`` or ``'write'``.
            cypher: The Cypher query string.
            params: Query parameters.

        Returns:
            List of result records as dicts.

        Raises:
            GraphConnectionError: If not connected.
            GraphQueryError: If the query fails after retries.
        """
        if not self._connected or not self._driver:
            raise GraphConnectionError("Not connected to the graph")

        last_exc: Exception | None = None
        for attempt in range(MAX_ATTEMPTS):
            try:
                async with self._driver.session(
                    database=self._config.database
                ) as session:
                    result = await session.run(cast(LiteralString, cypher), params)
                    records = [dict(record) async for record in result]
                    return records
            except Exception as exc:
                last_exc = exc
                msg = str(exc)
                # Retry on transient concurrency errors caused by parallel
                # test cleanup deleting nodes mid-query, AND on a Memgraph
                # MVCC write-conflict abort (plans/neo4j-to-orchgraph-
                # migration-plan.md Phase 2 Step 2.1): Memgraph's storage
                # engine ABORTS one of two concurrently conflicting writers
                # instead of blocking on a lock the way Neo4j does.
                if self._is_transient_error(msg):
                    await asyncio.sleep(0.05 * (2**attempt))
                    continue
                raise GraphQueryError(msg, query=cypher)
        raise GraphQueryError(str(last_exc), query=cypher)

    @staticmethod
    def _is_transient_error(msg: str) -> bool:
        """Return True if *msg* is a transient concurrency error worth retrying.

        Matches Neo4j errors raised when parallel cleanup deletes or
        recreates nodes mid-query. Uses plain substring checks — the
        previous ``"Node.*already exists"`` treated ``.*`` as literal
        characters, so the retry branch never fired. Also matches a
        Memgraph MVCC write-conflict abort via
        ``core.graph.retry.is_mvcc_conflict_message`` (shared marker list
        with the ``core.graph`` repository adapters, not duplicated here).
        """
        return (
            "EntityNotFound" in msg
            or "already exists" in msg
            or is_mvcc_conflict_message(msg)
        )


# Alias for backward compatibility with generated tests
GraphClientAlias = GraphClient