项目文件夹

文件
wehub-resource-sync c889a57b6b
Test Suites / Build CI Environment (push) Has been cancelled
Test Suites / Basic Tests (push) Has been cancelled
Test Suites / End-to-End Tests (push) Has been cancelled
Test Suites / CLI Tests (push) Has been cancelled
Test Suites / Slow End-to-End Tests (push) Has been cancelled
Test Suites / Graph Database Tests (push) Has been cancelled
Test Suites / Vector DB Tests (push) Has been cancelled
Test Suites / Temporal Graph Test (push) Has been cancelled
Test Suites / Search Test on Different DBs (push) Has been cancelled
Test Suites / Example Tests (push) Has been cancelled
Test Suites / Notebook Tests (push) Has been cancelled
Test Suites / OS and Python Tests Ubuntu (push) Has been cancelled
Test Suites / OS and Python Tests Extended (push) Has been cancelled
Test Suites / LLM Test Suite (push) Has been cancelled
Test Suites / S3 File Storage Test (push) Has been cancelled
Test Suites / Run Integration Tests (push) Has been cancelled
Test Suites / MCP Tests (push) Has been cancelled
Test Suites / Docker Compose Test (push) Has been cancelled
Test Suites / Docker CI test (push) Has been cancelled
Test Suites / Relational DB Migration Tests (push) Has been cancelled
Test Suites / Distributed Cognee Test (push) Has been cancelled
Test Suites / DB Examples Tests (push) Has been cancelled
Test Suites / Test Completion Status (push) Has been cancelled
Test Suites / Claude Code Review (push) Has been cancelled
Test Suites / basic checks (push) Has been cancelled
build | Build and Push Cognee MCP Docker Image to dockerhub / docker-build-and-push (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
build | Build and Push Docker Image to dockerhub / docker-build-and-push (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Core Functionality (3.11) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Core Functionality (3.12) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges with Different Graph Databases (kuzu, kuzu) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges with Different Graph Databases (neo4j, neo4j) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Examples (push) Has been cancelled
Weighted Edges Tests / Code Quality for Weighted Edges (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:02:24 +08:00

3680 行
148 KiB
Python

"""Adapter for Ladybug graph database."""
import os
import json
import asyncio
import threading
import tempfile
from uuid import UUID, uuid5, NAMESPACE_OID
from ladybug import Connection
from ladybug.database import Database
from datetime import datetime, timezone
from contextlib import asynccontextmanager
from concurrent.futures import ThreadPoolExecutor
from typing import Dict, Any, List, Union, Optional, Tuple, Type, Set
from cognee.modules.observability import OtelStatusCode as StatusCode
from cognee.exceptions import CogneeValidationError
from cognee.shared.logging_utils import get_logger
from cognee.infrastructure.utils.run_sync import run_sync
from cognee.infrastructure.files.storage import get_file_storage
from cognee.infrastructure.databases.graph.graph_db_interface import (
GraphDBInterface,
)
from cognee.infrastructure.databases.provenance import (
EdgeDeleteData,
EdgeIdentity,
NodeDeleteData,
)
from cognee.infrastructure.databases.provenance.source_refs import (
get_dataset_id_from_source_ref_key,
get_pipeline_run_id_from_source_run_ref,
get_source_ref_key_from_source_run_ref,
)
from cognee.infrastructure.databases.provenance.source_ref_state import (
provenance_after_attach,
provenance_after_remove,
provenance_attach_inputs,
)
from cognee.infrastructure.engine import DataPoint
from cognee.modules.storage.utils import JSONEncoder
from cognee.modules.engine.utils.generate_timestamp_datapoint import date_to_int
from cognee.tasks.temporal_graph.models import Timestamp
from cognee.infrastructure.databases.cache.config import get_cache_config
from cognee.modules.observability import new_span
from cognee.modules.observability.tracing import (
COGNEE_DB_SYSTEM,
COGNEE_DB_QUERY,
COGNEE_DB_ROW_COUNT,
redact_secrets,
)
logger = get_logger()
DEFAULT_KUZU_BUFFER_POOL_SIZE = 1 << 35 # 32 GB (must be a power of 2 for Kuzu)
DEFAULT_KUZU_MAX_DB_SIZE = 1 << 35 # 32 GB (must be a power of 2 for Kuzu)
# Graph provenance (COG-5522 Part 1). These four fields live in declared scalar
# STRING columns on both Node and EDGE — never inside the JSON `properties` blob
# — so delete/rollback can filter by source ref, dataset id, or pipeline run id
# with a column scan. The stored form is a delimiter-wrapped string, e.g.
# "|ref-a|ref-b|"; "|" means empty provenance.
PROVENANCE_COLUMNS = (
"source_ref_keys",
"source_dataset_ids",
"source_run_ids",
"source_run_refs",
)
def _provenance_fold_clause(alias: str) -> str:
"""Cypher ``SET`` fragment that stamps provenance inside the artifact write.
Appended to the ``MERGE`` in ``add_nodes`` / ``add_edges`` so a node/edge is
created and stamped in one atomic statement — there is no read-then-write
window (closes the write-then-attach gap and the concurrent lost update,
COG-5522 #4/#8). Set-merge is done with delimiter-token membership against
the committed column. The run ref/id are appended only when the key is *not*
already present (Model A): the ``CASE`` guard reads ``source_ref_keys`` from
the pre-SET row, so it sees ownership as it was before this write.
``alias`` is the bound variable for the artifact (``n`` for nodes, ``r`` for
edges). The provenance ``$``-params are scalars shared across the UNWIND
batch because a single source ref key is attached per call.
"""
return f"""
SET {alias}.source_run_refs = CASE
WHEN coalesce({alias}.source_ref_keys, '|') CONTAINS $sr_token
THEN coalesce({alias}.source_run_refs, '|')
ELSE concat(coalesce({alias}.source_run_refs, '|'), $run_ref_tail)
END,
{alias}.source_run_ids = CASE
WHEN coalesce({alias}.source_ref_keys, '|') CONTAINS $sr_token
THEN coalesce({alias}.source_run_ids, '|')
ELSE concat(coalesce({alias}.source_run_ids, '|'), $run_id_tail)
END,
{alias}.source_ref_keys = CASE
WHEN coalesce({alias}.source_ref_keys, '|') CONTAINS $sr_token
THEN coalesce({alias}.source_ref_keys, '|')
ELSE concat(coalesce({alias}.source_ref_keys, '|'), $sr_tail)
END,
{alias}.source_dataset_ids = CASE
WHEN coalesce({alias}.source_dataset_ids, '|') CONTAINS $ds_token
THEN coalesce({alias}.source_dataset_ids, '|')
ELSE concat(coalesce({alias}.source_dataset_ids, '|'), $ds_tail)
END
"""
def _provenance_fold_params(source_ref_key: str, pipeline_run_id: Optional[str]) -> dict:
"""Scalar query params consumed by :func:`_provenance_fold_clause`."""
inputs = provenance_attach_inputs(source_ref_key, pipeline_run_id)
return {
"sr_token": _provenance_token(inputs.source_ref_key),
"sr_tail": inputs.add_keys[0] + "|",
"ds_token": _provenance_token(inputs.add_dataset_ids[0]),
"ds_tail": inputs.add_dataset_ids[0] + "|",
"run_ref_tail": (inputs.add_run_refs[0] + "|") if inputs.add_run_refs else "",
"run_id_tail": (inputs.add_run_ids[0] + "|") if inputs.add_run_ids else "",
}
def _provenance_token(value: str) -> str:
return f"|{value}|"
def _encode_refs(items: List[str]) -> str:
if not items:
return "|"
for item in items:
if "|" in item:
raise ValueError("provenance entry must not contain '|'")
return "|" + "|".join(items) + "|"
def _decode_refs(value: Any) -> List[str]:
"""Normalize a delimiter-wrapped provenance string into ``list[str]``."""
if value is None or value == "":
return []
if not isinstance(value, str):
raise TypeError("provenance column must be a string or None")
return [item for item in value.strip("|").split("|") if item]
def _encode_provenance_row(row: dict) -> dict:
encoded = dict(row)
encoded["refs"] = _encode_refs(row["refs"])
encoded["datasets"] = _encode_refs(row["datasets"])
encoded["runs"] = _encode_refs(row["runs"])
encoded["run_refs"] = _encode_refs(row["run_refs"])
return encoded
def _parse_properties_blob(raw: Any) -> Dict[str, Any]:
"""Decode a node/edge JSON ``properties`` blob, tolerating empty/invalid input."""
if not raw:
return {}
try:
return json.loads(raw)
except json.JSONDecodeError:
return {}
cache_config = get_cache_config()
if cache_config.shared_ladybug_lock:
from cognee.infrastructure.databases.cache.get_cache_engine import get_cache_engine
class LadybugAdapter(GraphDBInterface):
"""
Adapter for Ladybug graph database operations with improved consistency and async support.
This class facilitates operations for working with the Ladybug graph database, supporting
both direct database queries and a structured asynchronous interface for node and edge
management. It contains methods for querying, adding, and deleting nodes and edges as
well as for graph metrics and data extraction.
"""
@classmethod
def create_subprocess(
cls,
db_path: str,
kuzu_num_threads: int = 0,
kuzu_buffer_pool_size: int = DEFAULT_KUZU_BUFFER_POOL_SIZE,
kuzu_max_db_size: int = DEFAULT_KUZU_MAX_DB_SIZE,
) -> "LadybugAdapter":
"""Create a LadybugAdapter running in subprocess-proxy mode."""
db_parent = os.path.dirname(os.path.abspath(db_path))
if db_parent:
os.makedirs(db_parent, exist_ok=True)
from cognee.infrastructure.databases.graph.kuzu.subprocess.proxy import (
KuzuSubprocessSession,
RemoteKuzuConnection,
RemoteKuzuDatabase,
install_json_extension,
)
session = KuzuSubprocessSession.start()
try:
install_json_extension(session, kuzu_buffer_pool_size)
if cache_config.shared_ladybug_lock:
# Don't open persistent handles — the per-query Redis lock
# path will open/close them via _rebuild_subprocess_proxies
# and _drop_native_resources on each query.
return cls(
db_path=db_path,
kuzu_num_threads=kuzu_num_threads,
kuzu_buffer_pool_size=kuzu_buffer_pool_size,
kuzu_max_db_size=kuzu_max_db_size,
subprocess_mode=True,
session=session,
)
db = RemoteKuzuDatabase(
session,
db_path=db_path,
buffer_pool_size=kuzu_buffer_pool_size,
max_num_threads=kuzu_num_threads,
max_db_size=kuzu_max_db_size,
)
db.init_database()
conn = RemoteKuzuConnection(session, db)
conn.load_extension("JSON")
return cls(
db_path=db_path,
kuzu_num_threads=kuzu_num_threads,
kuzu_buffer_pool_size=kuzu_buffer_pool_size,
kuzu_max_db_size=kuzu_max_db_size,
subprocess_mode=True,
database=db,
connection=conn,
session=session,
)
except Exception:
session.shutdown(timeout=2.0)
raise
def __init__(
self,
db_path: str,
kuzu_num_threads: int = 0,
kuzu_buffer_pool_size: int = DEFAULT_KUZU_BUFFER_POOL_SIZE,
kuzu_max_db_size: int = DEFAULT_KUZU_MAX_DB_SIZE,
*,
subprocess_mode: bool = False,
database: Optional[Any] = None,
connection: Optional[Any] = None,
session: Optional[Any] = None,
):
"""Initialize Ladybug database connection and schema.
Parameters
----------
db_path:
Path to the Kuzu database directory.
kuzu_num_threads:
Maximum number of threads Kuzu uses to execute queries. ``0`` keeps
Kuzu's internal default (one per CPU).
kuzu_buffer_pool_size:
Maximum size of the Kuzu buffer pool in bytes.
kuzu_max_db_size:
Maximum on-disk database size in bytes. Configurable via the
``KUZU_MAX_DB_SIZE`` env var (see ``GraphConfig``); some users
need this above the default 4 GB for large graphs.
subprocess_mode:
When True, the adapter runs in subprocess-proxy mode: the
native ladybug.Database/Connection live in a dedicated worker
process. Requires ``session``. When ``shared_ladybug_lock`` is
disabled, ``database`` and ``connection`` must also be provided
(persistent handles). When ``shared_ladybug_lock`` is enabled,
handles are opened/closed per query via the Redis lock path,
so ``database`` and ``connection`` are left None.
database, connection:
Pre-built Database/Connection proxies for the subprocess worker.
session:
The subprocess session that owns the worker process. After a
transient drop (e.g. ``delete_graph``) the adapter rebuilds
proxies lazily against the surviving session; after ``close()``
the session is zeroed and the adapter is in a permanent error
state.
"""
if subprocess_mode:
if session is None:
raise ValueError("subprocess_mode requires a session.")
if not cache_config.shared_ladybug_lock and (database is None or connection is None):
raise ValueError(
"subprocess_mode without shared_ladybug_lock requires database and connection."
)
self.open_connections = 0
self.db_path = db_path
self.kuzu_num_threads = kuzu_num_threads
self.kuzu_buffer_pool_size = kuzu_buffer_pool_size
self.kuzu_max_db_size = kuzu_max_db_size
self._session = session
self._subprocess_mode = subprocess_mode
self._permanently_closed = False
self.db: Optional[Database] = database
self.connection: Optional[Connection] = connection
# Always construct the executor — the shared-lock query path still
# runs ``blocking_query`` through ``loop.run_in_executor(self.executor,
# ...)`` and would hit AttributeError without it.
self.executor = ThreadPoolExecutor()
if cache_config.shared_ladybug_lock:
self.redis_lock = get_cache_engine(
lock_key="ladybug-lock-" + str(uuid5(NAMESPACE_OID, db_path))
)
else:
if subprocess_mode:
self._ensure_schema()
else:
self._initialize_connection()
self.LADYBUG_ASYNC_LOCK = asyncio.Lock()
self._source_ref_change_lock = asyncio.Lock()
self._connection_lock = asyncio.Lock()
# Set when ``open_connections == 0``; used by transient teardown
# paths (e.g. ``delete_graph``) to wait for in-flight queries to
# finish before dropping native resources. ``close()`` does NOT use
# this — see ``close()``'s docstring for the cross-loop reason.
self._all_queries_drained = asyncio.Event()
self._all_queries_drained.set()
# Brief sync lock for atomic counter+event mutations. Cannot reuse
# ``_connection_lock`` here: teardown holds that lock across the
# ``await`` on ``_all_queries_drained``, and the query's finally
# needs to decrement+set under SOME lock to make those mutations
# atomic relative to other queries' increment+clear. If the same
# lock were reused, the finally would deadlock waiting for
# teardown to release it. ``threading.Lock`` is held for
# microseconds (no awaits inside) so it can't deadlock the loop.
self._counter_lock = threading.Lock()
# Brief sync lock that makes ``_permanently_closed`` AND the
# ``self.executor`` reference move together. ``query()`` captures
# both under this lock; ``close()`` flips closed AND nulls
# ``self.executor`` under it before shutting the captured
# executor down. Without this, query could pass the closed check
# and then call ``run_in_executor`` after close shut the executor
# down, surfacing "cannot schedule new futures after shutdown".
# ``threading.Lock`` (not ``asyncio.Lock``) for cross-loop safety:
# ``close()`` may be invoked from a foreign loop via
# ``closing_lru_cache._start_close`` running ``asyncio.run``.
self._lifecycle_lock = threading.Lock()
def _ensure_schema(self) -> None:
"""Create Node + EDGE tables on the current ``self.connection``.
Extracted from ``_initialize_connection`` so the subprocess path (where
the native db/connection are constructed by the factory) can still run
the same schema bootstrap.
"""
# Explicit check rather than ``assert`` — assertions are stripped
# under ``python -O``, which would turn this into a confusing
# ``AttributeError`` on the next line instead of a clear message.
if self.connection is None:
raise RuntimeError("Ladybug connection is not initialized; cannot ensure schema.")
self.connection.execute("""
CREATE NODE TABLE IF NOT EXISTS Node(
id STRING PRIMARY KEY,
name STRING,
type STRING,
created_at TIMESTAMP,
updated_at TIMESTAMP,
properties STRING,
source_ref_keys STRING,
source_dataset_ids STRING,
source_run_ids STRING,
source_run_refs STRING
)
""")
self.connection.execute("""
CREATE REL TABLE IF NOT EXISTS EDGE(
FROM Node TO Node,
relationship_name STRING,
created_at TIMESTAMP,
updated_at TIMESTAMP,
properties STRING,
source_ref_keys STRING,
source_dataset_ids STRING,
source_run_ids STRING,
source_run_refs STRING
)
""")
self._ensure_graph_metadata_table()
logger.debug("Ladybug database schema ensured")
def _ensure_graph_metadata_table(self) -> None:
"""Create the GraphMetadata key/value table used by graph-provenance markers.
A dedicated node table — rather than a sentinel Node row — keeps marker
rows out of every ``:Node``-scoped data query and out of ``is_empty()``,
so a marked-but-data-empty graph still reads as empty. It carries no
EDGE relationships, so the edge-traversal scans never surface it either.
"""
if self.connection is None:
raise RuntimeError("Ladybug connection is not initialized; cannot ensure schema.")
self.connection.execute("""
CREATE NODE TABLE IF NOT EXISTS GraphMetadata(
key STRING PRIMARY KEY,
value STRING
)
""")
def _initialize_connection(self) -> None:
"""Initialize the Ladybug database connection and schema."""
# Install the JSON extension via a throwaway DB so its presence is
# cached before we open the real database. Shared helper lives in
# cognee_db_workers so the subprocess worker can use the same code
# without importing cognee. Pass the instance's configured limits
# so callers that tune ``kuzu_buffer_pool_size`` / ``kuzu_max_db_size``
# via env or config aren't silently ignored during the install step.
from cognee_db_workers._kuzu_helpers import install_json_extension_local
install_json_extension_local(
buffer_pool_size=self.kuzu_buffer_pool_size,
max_db_size=self.kuzu_max_db_size,
)
try:
if "s3://" in self.db_path:
with tempfile.NamedTemporaryFile(mode="w", delete=False) as temp_file:
self.temp_graph_file = temp_file.name
run_sync(self.pull_from_s3())
self.db = Database(
self.temp_graph_file,
buffer_pool_size=self.kuzu_buffer_pool_size,
max_num_threads=self.kuzu_num_threads,
max_db_size=self.kuzu_max_db_size,
)
else:
# Ensure the parent directory exists before creating the database
db_dir = os.path.dirname(self.db_path)
# If db_path is just a filename, db_dir will be empty string
# In this case, use the directory containing the db_path or current directory
if not db_dir:
# If no directory in path, use the absolute path's directory
abs_path = os.path.abspath(self.db_path)
db_dir = os.path.dirname(abs_path)
file_storage = get_file_storage(db_dir)
run_sync(file_storage.ensure_directory_exists())
try:
self.db = Database(
self.db_path,
buffer_pool_size=self.kuzu_buffer_pool_size,
max_num_threads=self.kuzu_num_threads,
max_db_size=self.kuzu_max_db_size,
)
except RuntimeError as e:
if "wal" in str(e).lower():
wal_path = self.db_path + ".wal"
logger.warning(
"Corrupted WAL detected at %s — removing to recover. "
"Uncommitted transactions from the previous session will be lost.",
wal_path,
)
try:
os.remove(wal_path)
except FileNotFoundError:
pass
else:
import ladybug
from .ladybug_migrate import needs_migration, ladybug_migration
should_migrate, old_version = needs_migration(
self.db_path, ladybug.__version__
)
if should_migrate:
ladybug_migration(
new_db=self.db_path + "_new",
old_db=self.db_path,
new_version=ladybug.__version__,
old_version=old_version,
overwrite=True,
)
# After WAL or migration mitigation try initialization again
self.db = Database(
self.db_path,
buffer_pool_size=self.kuzu_buffer_pool_size,
max_num_threads=self.kuzu_num_threads,
max_db_size=self.kuzu_max_db_size,
)
self.db.init_database()
self.connection = Connection(self.db)
try:
self.connection.execute("LOAD EXTENSION JSON;")
logger.info("Loaded JSON extension")
except Exception:
# LOAD failed — the extension is not installed for this
# connection's extension dir (the throwaway pre-install above can
# miss it when offline, or when it cached to a different path).
# Try installing + loading directly on the real connection before
# giving up. INSTALL is idempotent and a no-op when already cached.
try:
self.connection.execute("INSTALL JSON;")
self.connection.execute("LOAD EXTENSION JSON;")
logger.info("Installed and loaded JSON extension")
except Exception as e:
# Surface loudly: queries that use JSON (recall, temporal
# search) will otherwise fail later with a cryptic Binder
# error. This usually means no network access to download the
# extension at startup.
logger.warning(
"Could not install/load the Kuzu/Ladybug JSON extension (%s). "
"Graph queries that use JSON (e.g. recall, temporal search) will "
"fail with 'Extension: json ... has not been installed'. Ensure the "
"process has network access at startup to download the extension, "
"pre-install it in your image, or run `INSTALL json; LOAD json;` "
"once against the database.",
e,
)
self._ensure_schema()
logger.debug("Ladybug database initialized successfully")
except Exception as e:
logger.error(f"Failed to initialize Ladybug database: {e}")
raise e
async def push_to_s3(self) -> None:
if os.getenv("STORAGE_BACKEND", "").lower() == "s3" and hasattr(self, "temp_graph_file"):
from cognee.infrastructure.files.storage.S3FileStorage import S3FileStorage
s3_file_storage = S3FileStorage("")
if self.connection:
async with self.LADYBUG_ASYNC_LOCK:
self.connection.execute("CHECKPOINT;")
s3_file_storage.s3.put(self.temp_graph_file, self.db_path, recursive=True)
async def pull_from_s3(self) -> None:
from cognee.infrastructure.files.storage.S3FileStorage import S3FileStorage
s3_file_storage = S3FileStorage("")
try:
s3_file_storage.s3.get(self.db_path, self.temp_graph_file, recursive=True)
except FileNotFoundError:
logger.warning(f"Ladybug S3 storage file not found: {self.db_path}")
async def is_empty(self) -> bool:
# Scoped to :Node so the GraphMetadata marker table (and any future
# non-data node table) never makes a data-empty graph read as non-empty.
query = """
MATCH (n:Node)
RETURN true
LIMIT 1;
"""
query_result = await self.query(query)
return len(query_result) == 0
async def query(self, query: str, params: Optional[dict] = None) -> List[Tuple]:
"""
Execute a Ladybug query asynchronously.
This method runs a database query while managing lazy connection initialization. It handles
parameters in a dictionary and processes results to return structured data. The method
raises any exceptions encountered during query execution.
Parameters:
-----------
- query (str): The Ladybug query string to be executed.
- params (Optional[dict]): A dictionary of parameters for the query, if applicable.
(default None)
Returns:
--------
- List[Tuple]: A list of tuples representing the query results.
"""
# Note on ``close()`` synchronization: actual submission of the
# blocking work happens under ``_lifecycle_lock`` further down
# via ``_submit_to_executor_locked``. Capturing the executor
# reference *before* ``run_in_executor`` would not be enough —
# ``close()`` could call ``executor.shutdown()`` on the captured
# ref between capture and submit, surfacing "cannot schedule
# new futures after shutdown" anyway. Submitting under the lock
# closes that window: ``close()`` either runs first (and we
# raise from the helper) or runs after (and ``shutdown(wait=True)``
# waits for our queued future to complete).
with new_span("cognee.db.graph.query") as otel_span:
otel_span.set_attribute(COGNEE_DB_SYSTEM, "ladybug")
otel_span.set_attribute(COGNEE_DB_QUERY, redact_secrets(query[:500]))
params = params or {}
def blocking_query(connection):
try:
result = connection.execute(query, params)
rows = []
while result.has_next():
row = result.get_next()
processed_rows = []
for val in row:
if hasattr(val, "as_py"):
val = val.as_py()
processed_rows.append(val)
rows.append(tuple(processed_rows))
return rows
except Exception as e:
logger.error(f"Query execution failed: {str(e)}")
raise
try:
if cache_config.shared_ladybug_lock:
# Shared-lock path: the Redis lock MUST be acquired before
# any native ``ladybug.Database`` is opened on this file.
# Opening Ladybug takes the on-disk file lock, and if we open
# first we race the previous Redis-lock holder that's
# still releasing its own native handles.
assert self.redis_lock is not None
# ``acquire_lock()`` is sync and can block on Redis I/O
# for up to ``blocking_timeout`` (default 300 s) waiting
# for the previous holder. Offload so we don't freeze
# the event loop while another process holds the lock.
redis_lock_handle = await asyncio.to_thread(self.redis_lock.acquire_lock)
try:
# Increment under ``_connection_lock`` so a transient
# teardown waiting in ``_drain_in_flight_queries``
# can't see ``open_connections == 0`` between the
# lock release and our increment. The counter+event
# mutation itself uses ``_counter_lock`` so it stays
# atomic against other queries' decrement+set.
async with self._connection_lock:
connection = self.get_or_init_connection()
with self._counter_lock:
self.open_connections += 1
self._all_queries_drained.clear()
logger.debug(f"Open connections after open: {self.open_connections}")
try:
# Submit + check-closed atomically under
# ``_lifecycle_lock``. See top-of-method note.
future = self._submit_to_executor_locked(blocking_query, connection)
result = await asyncio.wrap_future(future)
finally:
# Decrement under ``_counter_lock`` (not
# ``_connection_lock`` — teardown holds the
# latter across its ``await`` and we'd deadlock).
with self._counter_lock:
self.open_connections -= 1
if self.open_connections == 0:
self._all_queries_drained.set()
logger.debug(f"Open connections after close: {self.open_connections}")
# Drop native handles BEFORE releasing the Redis
# lock so the next holder can take the on-disk
# file lock without fighting us. Drain first for
# symmetry with ``delete_graph``: the redis lock
# already serializes us in practice, but the
# drain costs nothing here (we just decremented
# to zero) and stays correct under any future
# change to redis-lock reentrancy.
async with self._connection_lock:
await self._drain_in_flight_queries()
if self._subprocess_mode:
await asyncio.to_thread(self._drop_native_resources)
else:
self._drop_native_resources()
finally:
# ``release_lock()`` is also sync and does Redis
# I/O — offload for symmetry with the acquire path.
await asyncio.to_thread(self.redis_lock.release_lock, redis_lock_handle)
else:
# Hold _connection_lock only for init + counter bookkeeping;
# the actual query runs unlocked so multiple queries can
# execute concurrently. Counter increment must be inside
# ``_connection_lock`` so ``_drain_in_flight_queries``
# can't miss us, AND inside ``_counter_lock`` so the
# increment+clear is atomic against other queries'
# decrement+set in their ``finally``.
async with self._connection_lock:
connection = self.get_or_init_connection()
with self._counter_lock:
self.open_connections += 1
self._all_queries_drained.clear()
try:
# Submit + check-closed atomically under
# ``_lifecycle_lock``. See top-of-method note.
future = self._submit_to_executor_locked(blocking_query, connection)
result = await asyncio.wrap_future(future)
finally:
# Decrement under ``_counter_lock`` (not
# ``_connection_lock`` — teardown holds the latter
# across its ``await`` and we'd deadlock).
with self._counter_lock:
self.open_connections -= 1
if self.open_connections == 0:
self._all_queries_drained.set()
otel_span.set_attribute(COGNEE_DB_ROW_COUNT, len(result))
return result
except Exception as e:
otel_span.set_status(StatusCode.ERROR, str(e))
otel_span.record_exception(e)
raise
def get_or_init_connection(self) -> Connection:
"""Return the current connection, initializing it first if needed.
Subprocess mode rebuilds proxies through the surviving
``self._session`` rather than falling through to a local
``ladybug.Database`` init — opening the same DB path in the main
process would conflict with the subprocess on the Ladybug file
lock. If ``self._session`` itself is gone the adapter is a
permanent error state (only ``close()`` zeroes the session).
Callers must hold ``_connection_lock`` to prevent races with
explicit calls to ``close()``.
"""
# Top-level closed check applies in BOTH modes. Read under
# ``_lifecycle_lock`` so a concurrent ``close()`` either hasn't
# started (we proceed) or has already flipped the flag (we
# raise). ``close()`` latches the flag at the very start of
# teardown, before touching any resources.
with self._lifecycle_lock:
if self._permanently_closed:
raise RuntimeError("LadybugAdapter is closed; a new adapter must be created.")
if not self.connection:
if self._subprocess_mode:
if self._session is None:
raise RuntimeError(
"LadybugAdapter subprocess session is gone; adapter "
"cannot be re-initialized."
)
self._rebuild_subprocess_proxies()
else:
self._initialize_connection()
# Re-check the closed latch after init: ``close()`` may have
# flipped it while we were inside ``_initialize_connection``
# (which opens a ladybug.Database and takes the on-disk file
# lock). Without this re-check we'd publish the freshly
# opened native handles onto an already-closed adapter,
# keeping the file lock alive for the rest of the process.
with self._lifecycle_lock:
closed = self._permanently_closed
if closed:
self._drop_native_resources()
raise RuntimeError("LadybugAdapter is closed; a new adapter must be created.")
# Explicit check rather than ``assert`` — assertions are stripped
# under ``python -O`` and would degrade to a confusing
# ``AttributeError`` in callers if init silently failed.
if self.connection is None:
raise RuntimeError("LadybugAdapter connection initialization failed.")
return self.connection
def _submit_to_executor_locked(self, fn, *args):
"""Atomically check ``_permanently_closed`` AND submit ``fn`` to
``self.executor``, all under ``_lifecycle_lock``.
Submitting under the lock (rather than capturing the executor
ref and submitting later) is what closes the close-vs-query
race: if ``close()`` is interleaving, it must take the same
lock to flip the flag and pull the executor reference. So the
only two outcomes here are (a) we observe the closed flag and
raise, or (b) we get a stable reference and ``executor.submit``
succeeds — at which point ``close()``'s
``executor.shutdown(wait=True)`` will wait for our just-queued
future to complete, instead of refusing to schedule it.
Returns a ``concurrent.futures.Future``; await
``asyncio.wrap_future(future)`` to consume the result on the
calling event loop.
"""
with self._lifecycle_lock:
if self._permanently_closed or self.executor is None:
raise RuntimeError("LadybugAdapter is closed; a new adapter must be created.")
return self.executor.submit(fn, *args)
def _drop_native_resources(self) -> None:
"""Synchronously drop the native Ladybug Database + Connection handles.
Does **not** latch ``_permanently_closed`` and does **not** touch the
subprocess session. Used by the shared_ladybug_lock per-query path (where
we want to release the on-disk file lock between queries) and by
``delete_graph`` (which needs the file handles closed before removing
the db directory). The adapter remains reusable — a subsequent query
will lazily re-initialize via ``get_or_init_connection``.
"""
if self.connection is not None:
try:
self.connection.close()
except Exception as e:
logger.warning(f"Error closing Ladybug connection: {e}")
self.connection = None
if self.db is not None:
try:
self.db.close()
except Exception as e:
logger.warning(f"Error closing Ladybug database: {e}")
self.db = None
def _rebuild_subprocess_proxies(self) -> None:
"""Recreate ``self.db`` + ``self.connection`` against the existing
``self._session`` after a transient drop (e.g. files removed by
``delete_graph`` or native handles dropped by the shared-lock
per-query path).
Subprocess-mode counterpart to local mode's ``_initialize_connection``.
Called lazily from ``get_or_init_connection`` on the next query, not
eagerly — that way ``delete_graph`` does not silently recreate the
on-disk store it just removed. Sync method: the proxy constructors
issue blocking RPCs through the session, but the call site already
runs sync from inside ``get_or_init_connection`` (matching the
local-mode pattern).
"""
# Imported here to avoid a top-level cycle with the proxy module.
from cognee.infrastructure.databases.graph.kuzu.subprocess.proxy import (
RemoteKuzuConnection,
RemoteKuzuDatabase,
)
self.db = RemoteKuzuDatabase(
self._session,
db_path=self.db_path,
buffer_pool_size=self.kuzu_buffer_pool_size,
max_num_threads=self.kuzu_num_threads,
max_db_size=self.kuzu_max_db_size,
)
self.db.init_database()
self.connection = RemoteKuzuConnection(self._session, self.db)
# Re-load the JSON extension on the fresh connection — the
# original setup path did this and queries that touch JSON would
# otherwise fail with "extension not loaded" after delete_graph.
try:
self.connection.load_extension("JSON")
except Exception as e:
logger.warning(f"Could not load JSON extension after reopen: {e}")
# Recreate the Node/EDGE schema — ``delete_graph`` removed the
# on-disk store, so the worker is now talking to a fresh empty
# DB with no tables. Without this, the very next graph query
# after ``delete_graph`` raises "table Node does not exist".
self._ensure_schema()
async def _drain_in_flight_queries(self) -> None:
"""Wait until every query that's currently mid-``run_in_executor``
has finished. The caller MUST hold ``_connection_lock`` so new
queries can't start while we wait — otherwise the drain would
race a fresh increment.
Used by transient-teardown paths (currently ``delete_graph`` and
the shared-lock per-query cleanup) so ``_drop_native_resources``
doesn't tear out a connection an executor thread is still using.
``close()`` does NOT use this — see its docstring for why
(cross-loop ``asyncio.Event.wait()`` would raise).
Reads the counter under ``_counter_lock`` so a stale ``set()``
from a finishing query can't race a fresh increment from a new
one and trick us into busy-spinning on an event that's set while
``open_connections > 0``.
"""
while True:
with self._counter_lock:
if self.open_connections == 0:
return
await self._all_queries_drained.wait()
async def close(self):
"""Permanently close the adapter, releasing native resources and (in
subprocess mode) shutting down the worker process.
Intentionally does **not** hold ``_connection_lock``: that lock is an
``asyncio.Lock`` bound to the loop on which the adapter was created.
LRU eviction may invoke ``close()`` from a different loop (for
example via ``asyncio.run`` in ``closing_lru_cache._start_close``),
and awaiting a foreign-loop lock raises "got Future attached to a
different loop". After this call the adapter is not reusable — see
``_drop_native_resources`` if you want a transient drop.
Shuts down our ``ThreadPoolExecutor`` with ``wait=True`` first — this
serves two purposes: (a) drains any in-flight ``blocking_query``
submissions, preventing a race where ``close()`` tears down
``self.connection`` while an executor thread is still mid-
``connection.execute``; (b) reaps the executor threads that would
otherwise leak on every LRU eviction.
Note: transient-teardown paths (``delete_graph``) use the asyncio
``_drain_in_flight_queries`` helper instead, but that's not safe
from a foreign loop — ``executor.shutdown(wait=True)`` is the
cross-loop equivalent and the only correct choice here.
Idempotent — repeated calls observe ``_permanently_closed`` and
return early without re-shutting-down anything.
"""
# Atomically: flip the closed flag, capture the executor
# reference, and null out ``self.executor``. A concurrent
# ``query()`` either sees the closed flag (raises clean) or
# captures a still-live executor (its run_in_executor will
# complete normally because we shut down with ``wait=True``
# below). Without nulling self.executor under the lock, a query
# that captured ``self.executor`` *after* the flag flipped
# could still submit to the about-to-be-shut-down executor.
# Idempotent — a second close() sees the flag and returns.
with self._lifecycle_lock:
if self._permanently_closed:
return
self._permanently_closed = True
executor = self.executor
self.executor = None
# Both ``executor.shutdown(wait=True)`` and
# ``SubprocessSession.shutdown()`` are sync-blocking calls that can
# take seconds (executor: thread join; session: join/terminate/kill
# chain plus a bounded ``_rpc_lock`` acquire). Offload them to a
# worker thread so awaiting ``close()`` doesn't freeze the calling
# event loop. ``asyncio.to_thread`` is safe across loops — required
# because ``close()`` may be invoked from a foreign loop via
# ``closing_lru_cache._start_close`` running ``asyncio.run``.
if executor is not None:
await asyncio.to_thread(executor.shutdown, True)
# In subprocess mode, ``_drop_native_resources`` calls
# ``self.connection.close()`` and ``self.db.close()`` which are
# proxy RPCs through ``session.call(...)`` and can block on
# the worker for hundreds of ms. Offload to a thread so we
# don't freeze the event loop. Local mode stays sync — closing
# an in-process Ladybug Database/Connection is fast.
if self._subprocess_mode:
await asyncio.to_thread(self._drop_native_resources)
else:
self._drop_native_resources()
if self._session is not None:
try:
await asyncio.to_thread(self._session.shutdown)
except Exception as e:
logger.warning(f"Error shutting down Ladybug subprocess: {e}")
self._session = None
logger.info("Ladybug database closed successfully")
@asynccontextmanager
async def get_session(self):
"""
Get a database session.
This provides an API-compatible session management for Ladybug, even though it does not
have built-in session management like other databases. It yields the current connection
and on exit performs cleanup if necessary.
"""
try:
yield self.connection
finally:
pass
def _parse_node(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Convert a raw node result (with JSON properties) into a dictionary."""
if data.get("properties"):
try:
props = json.loads(data["properties"])
# Remove the JSON field and merge its contents
data.pop("properties")
data.update(props)
except json.JSONDecodeError:
logger.warning(f"Failed to parse properties JSON for node {data.get('id')}")
return data
def _parse_node_properties(self, data: Dict[str, Any]) -> Dict[str, Any]:
try:
if isinstance(data, dict) and "properties" in data and data["properties"]:
props = json.loads(data["properties"])
data.update(props)
del data["properties"]
return data
except json.JSONDecodeError:
logger.warning(f"Failed to parse properties JSON for node {data.get('id')}")
return data
# Helper method for building edge queries
def _edge_query_and_params(
self, from_node: str, to_node: str, relationship_name: str, properties: Dict[str, Any]
) -> Tuple[str, dict]:
"""Build the edge creation query and parameters."""
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")
query = """
MATCH (from:Node), (to:Node)
WHERE from.id = $from_id AND to.id = $to_id
MERGE (from)-[r:EDGE {
relationship_name: $relationship_name
}]->(to)
ON CREATE SET
r.created_at = timestamp($created_at),
r.updated_at = timestamp($updated_at),
r.properties = $properties
ON MATCH SET
r.updated_at = timestamp($updated_at),
r.properties = $properties
"""
params = {
"from_id": from_node,
"to_id": to_node,
"relationship_name": relationship_name,
"created_at": now,
"updated_at": now,
"properties": json.dumps(properties, cls=JSONEncoder),
}
return query, params
# Node Operations
async def has_node(self, node_id: str) -> bool:
"""
Check if a node exists.
This method checks for the existence of a node in the database by its identifier. It
returns a boolean indicating whether the node is present or not.
Parameters:
-----------
- node_id (str): The identifier of the node to check.
Returns:
--------
- bool: True if the node exists, False otherwise.
"""
query_str = "MATCH (n:Node) WHERE n.id = $id RETURN COUNT(n) > 0"
result = await self.query(query_str, {"id": node_id})
return result[0][0] if result else False
async def add_node(self, node: DataPoint) -> None:
"""
Add a single node to the graph if it doesn't exist.
This method constructs and executes a query to add a node to the graph, ensuring that it
is not duplicated by checking its existence first. An error is raised if the operation
fails.
Parameters:
-----------
- node (DataPoint): The node to be added, represented as a DataPoint.
"""
try:
properties = node.model_dump() if hasattr(node, "model_dump") else vars(node)
# Extract core fields with defaults if not present
core_properties = {
"id": str(properties.get("id", "")),
"name": str(properties.get("name", "")),
"type": str(properties.get("type", "")),
}
# Remove core fields from other properties
for key in core_properties:
properties.pop(key, None)
# Provenance lives in declared STRING columns, never the JSON blob.
for key in PROVENANCE_COLUMNS:
properties.pop(key, None)
core_properties["properties"] = json.dumps(properties, cls=JSONEncoder)
# Add timestamps for new node
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")
fields = []
params = {}
for key, value in core_properties.items():
if value is not None:
param_name = f"param_{key}"
fields.append(f"{key}: ${param_name}")
params[param_name] = value
# Add timestamp fields
fields.extend(
["created_at: timestamp($created_at)", "updated_at: timestamp($updated_at)"]
)
params.update({"created_at": now, "updated_at": now})
merge_query = f"""
MERGE (n:Node {{id: $param_id}})
ON CREATE SET n += {{{", ".join(fields)}}}
"""
await self.query(merge_query, params)
except Exception as e:
logger.error(f"Failed to add node: {e}")
raise
async def add_nodes(
self,
nodes: List[DataPoint],
source_ref_key: Optional[str] = None,
pipeline_run_id: Optional[str] = None,
) -> None:
"""
Add multiple nodes to the graph in a batch operation.
This method allows for the addition of multiple nodes in a single operation to enhance
performance. It processes a list of nodes and constructs the necessary query for
insertion. Errors encountered during the addition will be logged and raised.
Parameters:
-----------
- nodes (List[DataPoint]): A list of nodes to be added to the graph, each
represented as a DataPoint.
- source_ref_key (Optional[str]): When set, graph provenance for this
source ref is stamped atomically in the same statement that writes the nodes
(no separate attach pass). Omit for non-graph-provenance writes.
- pipeline_run_id (Optional[str]): Run id recorded alongside the provenance
stamp, so the write is rollbackable by run. Ignored when source_ref_key is None.
"""
if not nodes:
return
try:
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")
# Prepare all nodes data
node_params = []
for node in nodes:
properties = node.model_dump() if hasattr(node, "model_dump") else vars(node)
core_properties = {
"id": str(properties.get("id", "")),
"name": str(properties.get("name", "")),
"type": str(properties.get("type", "")),
}
# Remove core fields from other properties
for key in core_properties:
properties.pop(key, None)
# Provenance lives in declared STRING columns, never the JSON blob.
for key in PROVENANCE_COLUMNS:
properties.pop(key, None)
node_params.append(
{
**core_properties,
"properties": json.dumps(properties, cls=JSONEncoder),
"created_at": now,
"updated_at": now,
}
)
if node_params:
# Batch merge nodes
merge_query = """
UNWIND $nodes AS node
MERGE (n:Node {id: node.id})
ON CREATE SET
n.name = node.name,
n.type = node.type,
n.properties = node.properties,
n.created_at = timestamp(node.created_at),
n.updated_at = timestamp(node.updated_at)
ON MATCH SET
n.name = node.name,
n.type = node.type,
n.properties = node.properties,
n.updated_at = timestamp(node.updated_at)
"""
query_params = {"nodes": node_params}
if source_ref_key is not None:
merge_query += _provenance_fold_clause("n")
query_params.update(_provenance_fold_params(source_ref_key, pipeline_run_id))
await self.query(merge_query, query_params)
await self.checkpoint()
logger.debug(f"Processed {len(node_params)} nodes in batch")
except Exception as e:
logger.error(f"Failed to add nodes in batch: {e}")
raise
async def checkpoint(self) -> None:
"""Flush the WAL to disk so all preceding writes are durable."""
await self.query("CHECKPOINT;")
async def delete_node(self, node_id: str) -> None:
"""
Delete a node and its relationships.
This method removes a node identified by its ID along with all associated relationships.
It encapsulates the delete operation for simplicity in usage.
Parameters:
-----------
- node_id (str): The identifier of the node to be deleted.
"""
query_str = "MATCH (n:Node) WHERE n.id = $id DETACH DELETE n"
await self.query(query_str, {"id": node_id})
async def delete_nodes(self, node_ids: List[str]) -> None:
"""
Delete multiple nodes at once.
This method facilitates the deletion of a list of nodes, identified by their IDs,
concurrently. It ensures efficiency by using a single query to detach deletes for all
nodes in the list.
Parameters:
-----------
- node_ids (List[str]): A list of identifiers for the nodes to be deleted.
"""
query_str = "MATCH (n:Node) WHERE n.id IN $ids DETACH DELETE n"
await self.query(query_str, {"ids": node_ids})
# ------------------------------------------------------------------
# Graph provenance (COG-5522 Part 1)
#
# The four provenance fields live in declared scalar STRING columns on Node
# and EDGE. attach/remove do a per-artifact read-modify-write
# (delete/rollback is a maintenance path, not a hot path); lookups are full
# column scans via delimiter-token string filters. Every read normalizes a
# NULL column to [].
# ------------------------------------------------------------------
async def _read_node_provenance(
self, node_ids: List[str]
) -> Dict[str, Tuple[List[str], List[str]]]:
"""Return ``{node_id: (source_ref_keys, source_run_refs)}`` for existing nodes."""
rows = await self.query(
"""
MATCH (n:Node) WHERE n.id IN $ids
RETURN n.id, n.source_ref_keys, n.source_run_refs
""",
{"ids": list(node_ids)},
)
return {row[0]: (_decode_refs(row[1]), _decode_refs(row[2])) for row in rows}
async def _write_node_provenance(self, batch: List[dict]) -> None:
if not batch:
return
encoded_batch = [_encode_provenance_row(row) for row in batch]
await self.query(
"""
UNWIND $batch AS row
MATCH (n:Node) WHERE n.id = row.id
SET n.source_ref_keys = row.refs,
n.source_dataset_ids = row.datasets,
n.source_run_ids = row.runs,
n.source_run_refs = row.run_refs
""",
{"batch": encoded_batch},
)
await self.checkpoint()
async def _read_edge_provenance(
self, edges: list[EdgeIdentity]
) -> Dict[EdgeIdentity, Tuple[List[str], List[str]]]:
"""Return ``{edge: (source_ref_keys, source_run_refs)}`` for existing edges."""
edge_params = [
{"s": edge.source_id, "t": edge.target_id, "rel": edge.relationship_name}
for edge in edges
]
rows = await self.query(
"""
UNWIND $edges AS e
MATCH (a:Node)-[r:EDGE]->(b:Node)
WHERE a.id = e.s AND b.id = e.t AND r.relationship_name = e.rel
RETURN a.id, b.id, r.relationship_name, r.source_ref_keys, r.source_run_refs
""",
{"edges": edge_params},
)
result: Dict[EdgeIdentity, Tuple[List[str], List[str]]] = {}
for row in rows:
edge = EdgeIdentity(source_id=row[0], target_id=row[1], relationship_name=row[2])
result[edge] = (_decode_refs(row[3]), _decode_refs(row[4]))
return result
async def _write_edge_provenance(self, batch: List[dict]) -> None:
if not batch:
return
encoded_batch = [_encode_provenance_row(row) for row in batch]
await self.query(
"""
UNWIND $batch AS row
MATCH (a:Node)-[r:EDGE]->(b:Node)
WHERE a.id = row.s AND b.id = row.t AND r.relationship_name = row.rel
SET r.source_ref_keys = row.refs,
r.source_dataset_ids = row.datasets,
r.source_run_ids = row.runs,
r.source_run_refs = row.run_refs
""",
{"batch": encoded_batch},
)
await self.checkpoint()
@staticmethod
def _node_row(node_id: str) -> dict:
return {"id": node_id}
@staticmethod
def _edge_row(edge: EdgeIdentity) -> dict:
return {"s": edge.source_id, "t": edge.target_id, "rel": edge.relationship_name}
async def _apply_source_ref_change(
self,
artifacts,
read_provenance,
write_provenance,
identity_row,
transition,
) -> None:
"""Read each artifact's provenance, apply a pure transition, write it back.
Shared by attach/remove for both nodes and edges: ``read_provenance`` /
``write_provenance`` are the Cypher node|edge helpers, ``identity_row``
maps an artifact identity to the batch row's match fields, and
``transition`` is the pure ``provenance_after_*`` function.
The lock serializes this two-query sequence within one adapter instance
so concurrent explicit attach/remove calls do not overwrite each other's
provenance updates.
"""
if not artifacts:
return
async with self._source_ref_change_lock:
current = await read_provenance(artifacts)
batch = []
for identity, (keys, run_refs) in current.items():
cols = transition(keys, run_refs)
batch.append(
{
**identity_row(identity),
"refs": cols.source_ref_keys,
"datasets": cols.source_dataset_ids,
"runs": cols.source_run_ids,
"run_refs": cols.source_run_refs,
}
)
await write_provenance(batch)
async def attach_node_source_refs(
self,
node_ids: list[str],
source_ref_keys: list[str],
pipeline_run_id: str | None = None,
) -> None:
if not source_ref_keys:
return
add_keys = list(source_ref_keys)
await self._apply_source_ref_change(
node_ids,
self._read_node_provenance,
self._write_node_provenance,
self._node_row,
lambda keys, run_refs: provenance_after_attach(
keys, run_refs, add_keys, pipeline_run_id
),
)
async def attach_edge_source_refs(
self,
edges: list[EdgeIdentity],
source_ref_keys: list[str],
pipeline_run_id: str | None = None,
) -> None:
if not source_ref_keys:
return
add_keys = list(source_ref_keys)
await self._apply_source_ref_change(
edges,
self._read_edge_provenance,
self._write_edge_provenance,
self._edge_row,
lambda keys, run_refs: provenance_after_attach(
keys, run_refs, add_keys, pipeline_run_id
),
)
async def remove_node_source_refs(
self,
node_ids: list[str],
source_ref_keys: list[str],
) -> None:
if not source_ref_keys:
return
remove_keys = list(source_ref_keys)
await self._apply_source_ref_change(
node_ids,
self._read_node_provenance,
self._write_node_provenance,
self._node_row,
lambda keys, run_refs: provenance_after_remove(keys, run_refs, remove_keys),
)
async def remove_edge_source_refs(
self,
edges: list[EdgeIdentity],
source_ref_keys: list[str],
) -> None:
if not source_ref_keys:
return
remove_keys = list(source_ref_keys)
await self._apply_source_ref_change(
edges,
self._read_edge_provenance,
self._write_edge_provenance,
self._edge_row,
lambda keys, run_refs: provenance_after_remove(keys, run_refs, remove_keys),
)
async def delete_edge_triples(self, edges: list[EdgeIdentity]) -> None:
if not edges:
return
edge_params = [
{"s": edge.source_id, "t": edge.target_id, "rel": edge.relationship_name}
for edge in edges
]
# DELETE r (not DETACH DELETE) removes only the matched relationships and
# preserves the endpoint nodes.
await self.query(
"""
UNWIND $edges AS e
MATCH (a:Node)-[r:EDGE]->(b:Node)
WHERE a.id = e.s AND b.id = e.t AND r.relationship_name = e.rel
DELETE r
""",
{"edges": edge_params},
)
await self.checkpoint()
async def get_node_delete_data(self, node_ids: list[str]) -> dict[str, NodeDeleteData]:
if not node_ids:
return {}
rows = await self.query(
"""
MATCH (n:Node) WHERE n.id IN $ids
RETURN n.id, n.name, n.type, n.properties,
n.source_ref_keys, n.source_dataset_ids, n.source_run_ids, n.source_run_refs
""",
{"ids": list(node_ids)},
)
result: dict[str, NodeDeleteData] = {}
for row in rows:
node_id, name, node_type, raw_props = row[0], row[1], row[2], row[3]
properties = _parse_properties_blob(raw_props)
# Reconstruct the flat payload the way get_node does: core columns
# merged over the JSON blob.
properties["id"] = node_id
properties["name"] = name
properties["type"] = node_type
metadata = properties.get("metadata") or {}
indexed_fields = (
list(metadata.get("index_fields") or []) if isinstance(metadata, dict) else []
)
result[node_id] = NodeDeleteData(
node_id=node_id,
node_type=node_type or "",
indexed_fields=indexed_fields,
node_properties=properties,
source_ref_keys=_decode_refs(row[4]),
source_dataset_ids=_decode_refs(row[5]),
source_run_ids=_decode_refs(row[6]),
source_run_refs=_decode_refs(row[7]),
)
return result
async def get_edge_delete_data(
self, edges: list[EdgeIdentity]
) -> dict[EdgeIdentity, EdgeDeleteData]:
if not edges:
return {}
edge_params = [
{"s": edge.source_id, "t": edge.target_id, "rel": edge.relationship_name}
for edge in edges
]
rows = await self.query(
"""
UNWIND $edges AS e
MATCH (a:Node)-[r:EDGE]->(b:Node)
WHERE a.id = e.s AND b.id = e.t AND r.relationship_name = e.rel
RETURN a.id, b.id, r.relationship_name, r.properties,
r.source_ref_keys, r.source_dataset_ids, r.source_run_ids, r.source_run_refs
""",
{"edges": edge_params},
)
# Lazy import: prepare_edges_for_storage lives in the modules layer, whose
# package __init__ imports get_graph_engine -> this adapter. Importing it
# at module load would create a cycle; at delete-time it is safe.
from cognee.modules.graph.utils.prepare_edges_for_storage import get_edge_retrieval_text
result: dict[EdgeIdentity, EdgeDeleteData] = {}
for row in rows:
edge = EdgeIdentity(source_id=row[0], target_id=row[1], relationship_name=row[2])
properties = _parse_properties_blob(row[3])
# Stored edge_text wins; fall back to relationship_name when absent.
edge_text = get_edge_retrieval_text(properties.get("edge_text"), edge.relationship_name)
result[edge] = EdgeDeleteData(
edge=edge,
edge_text=edge_text,
edge_properties=properties,
source_ref_keys=_decode_refs(row[4]),
source_dataset_ids=_decode_refs(row[5]),
source_run_ids=_decode_refs(row[6]),
source_run_refs=_decode_refs(row[7]),
)
return result
async def find_nodes_by_source_ref(self, source_ref_key: str) -> list[str]:
rows = await self.query(
"""
MATCH (n:Node)
WHERE coalesce(n.source_ref_keys, '|') CONTAINS $token
RETURN n.id
""",
{"token": _provenance_token(source_ref_key)},
)
return [row[0] for row in rows]
async def find_edges_by_source_ref(self, source_ref_key: str) -> list[EdgeIdentity]:
rows = await self.query(
"""
MATCH (a:Node)-[r:EDGE]->(b:Node)
WHERE coalesce(r.source_ref_keys, '|') CONTAINS $token
RETURN a.id, b.id, r.relationship_name
""",
{"token": _provenance_token(source_ref_key)},
)
return [
EdgeIdentity(source_id=row[0], target_id=row[1], relationship_name=row[2])
for row in rows
]
async def find_node_source_refs_by_dataset(self, dataset_id: str) -> dict[str, list[str]]:
rows = await self.query(
"""
MATCH (n:Node)
WHERE coalesce(n.source_dataset_ids, '|') CONTAINS $token
RETURN n.id, n.source_ref_keys
""",
{"token": _provenance_token(dataset_id)},
)
result: dict[str, list[str]] = {}
for row in rows:
owned = [
key
for key in _decode_refs(row[1])
if str(get_dataset_id_from_source_ref_key(key)) == dataset_id
]
if owned:
result[row[0]] = owned
return result
async def find_edge_source_refs_by_dataset(
self, dataset_id: str
) -> dict[EdgeIdentity, list[str]]:
rows = await self.query(
"""
MATCH (a:Node)-[r:EDGE]->(b:Node)
WHERE coalesce(r.source_dataset_ids, '|') CONTAINS $token
RETURN a.id, b.id, r.relationship_name, r.source_ref_keys
""",
{"token": _provenance_token(dataset_id)},
)
result: dict[EdgeIdentity, list[str]] = {}
for row in rows:
owned = [
key
for key in _decode_refs(row[3])
if str(get_dataset_id_from_source_ref_key(key)) == dataset_id
]
if owned:
edge = EdgeIdentity(source_id=row[0], target_id=row[1], relationship_name=row[2])
result[edge] = owned
return result
async def find_node_source_refs_by_pipeline_run(
self, pipeline_run_id: str
) -> dict[str, list[str]]:
rows = await self.query(
"""
MATCH (n:Node)
WHERE coalesce(n.source_run_ids, '|') CONTAINS $token
RETURN n.id, n.source_run_refs
""",
{"token": _provenance_token(pipeline_run_id)},
)
result: dict[str, list[str]] = {}
for row in rows:
contributed = [
get_source_ref_key_from_source_run_ref(ref)
for ref in _decode_refs(row[1])
if str(get_pipeline_run_id_from_source_run_ref(ref)) == pipeline_run_id
]
if contributed:
result[row[0]] = contributed
return result
async def find_edge_source_refs_by_pipeline_run(
self, pipeline_run_id: str
) -> dict[EdgeIdentity, list[str]]:
rows = await self.query(
"""
MATCH (a:Node)-[r:EDGE]->(b:Node)
WHERE coalesce(r.source_run_ids, '|') CONTAINS $token
RETURN a.id, b.id, r.relationship_name, r.source_run_refs
""",
{"token": _provenance_token(pipeline_run_id)},
)
result: dict[EdgeIdentity, list[str]] = {}
for row in rows:
contributed = [
get_source_ref_key_from_source_run_ref(ref)
for ref in _decode_refs(row[3])
if str(get_pipeline_run_id_from_source_run_ref(ref)) == pipeline_run_id
]
if contributed:
edge = EdgeIdentity(source_id=row[0], target_id=row[1], relationship_name=row[2])
result[edge] = contributed
return result
async def set_graph_metadata(self, metadata: dict[str, str]) -> None:
if not metadata:
return
for key, value in metadata.items():
await self.query(
"MERGE (m:GraphMetadata {key: $k}) SET m.value = $v",
{"k": str(key), "v": str(value)},
)
await self.checkpoint()
async def get_graph_metadata(self) -> dict[str, str]:
rows = await self.query("MATCH (m:GraphMetadata) RETURN m.key, m.value")
return {row[0]: row[1] for row in rows}
async def remove_belongs_to_set_tags(
self,
tags: List[str],
node_ids: Optional[List[str]] = None,
) -> None:
if not tags:
return None
if node_ids is not None and not node_ids:
return None
tag_set = set(tags)
if node_ids is not None:
rows = await self.query(
"MATCH (n:Node) WHERE n.id IN $ids RETURN n.id, n.properties",
{"ids": [str(nid) for nid in node_ids]},
)
else:
rows = await self.query("MATCH (n:Node) RETURN n.id, n.properties")
updates = []
for row in rows:
raw_props = row[1]
if not raw_props:
continue
try:
properties = json.loads(raw_props)
except json.JSONDecodeError:
continue
current = properties.get("belongs_to_set")
if not isinstance(current, list) or not any(tag in tag_set for tag in current):
continue
properties["belongs_to_set"] = [tag for tag in current if tag not in tag_set]
updates.append({"id": row[0], "properties": json.dumps(properties, cls=JSONEncoder)})
if updates:
await self.query(
"""
UNWIND $rows AS row
MATCH (n:Node) WHERE n.id = row.id
SET n.properties = row.properties
""",
{"rows": updates},
)
await self.checkpoint()
return None
async def extract_node(self, node_id: str) -> Optional[Dict[str, Any]]:
"""
Extract a node by its ID.
This method retrieves a node's data by its identifier and returns it as a dictionary. If
the node is not found or an error occurs, it returns None.
Parameters:
-----------
- node_id (str): The identifier of the node to be extracted.
Returns:
--------
- Optional[Dict[str, Any]]: A dictionary of the node's properties if found,
otherwise None.
"""
query_str = """
MATCH (n:Node)
WHERE n.id = $id
RETURN {
id: n.id,
name: n.name,
type: n.type,
properties: n.properties
}
"""
try:
result = await self.query(query_str, {"id": node_id})
if result and result[0]:
node_data = self._parse_node(result[0][0])
return node_data
return None
except Exception as e:
logger.error(f"Failed to extract node {node_id}: {e}")
return None
async def extract_nodes(self, node_ids: List[str]) -> List[Dict[str, Any]]:
"""
Extract multiple nodes by their IDs.
This method retrieves a list of nodes identified by their IDs and returns their data as
a list of dictionaries. It handles possible retrieval errors internally and will return
an empty list if no nodes are found.
Parameters:
-----------
- node_ids (List[str]): A list of identifiers for the nodes to be extracted.
Returns:
--------
- List[Dict[str, Any]]: A list of dictionaries containing the properties of the
extracted nodes.
"""
query_str = """
MATCH (n:Node)
WHERE n.id IN $node_ids
RETURN {
id: n.id,
name: n.name,
type: n.type,
properties: n.properties
}
"""
try:
results = await self.query(query_str, {"node_ids": node_ids})
# Parse each node using the same helper function
nodes = [self._parse_node(row[0]) for row in results if row[0]]
return nodes
except Exception as e:
logger.error(f"Failed to extract nodes: {e}")
return []
# Edge Operations
async def has_edge(self, from_node: str, to_node: str, edge_label: str) -> bool:
"""
Check if an edge exists between nodes with the given relationship name.
This method verifies the existence of a directed edge defined by the relationship name
between two specified nodes. It returns a boolean value indicating presence or absence
of the edge.
Parameters:
-----------
- from_node (str): The identifier of the source node.
- to_node (str): The identifier of the target node.
- edge_label (str): The label of the edge representing the relationship name.
Returns:
--------
- bool: True if the edge exists, False otherwise.
"""
query_str = """
MATCH (from:Node)-[r:EDGE]->(to:Node)
WHERE from.id = $from_id AND to.id = $to_id AND r.relationship_name = $edge_label
RETURN COUNT(r) > 0
"""
result = await self.query(
query_str, {"from_id": from_node, "to_id": to_node, "edge_label": edge_label}
)
return result[0][0] if result else False
async def has_edges(self, edges: List[Tuple[str, str, str]]) -> List[Tuple[str, str, str]]:
"""
Check if multiple edges exist in a batch operation.
This method checks for the presence of specified edges in the database and returns a
list of edges that exist. It is beneficial for efficiency in checking multiple edges
simultaneously.
Parameters:
-----------
- edges (List[Tuple[str, str, str]]): A list of edges where each edge is represented
as a tuple of (from_node, to_node, edge_label).
Returns:
--------
- List[Tuple[str, str, str]]: A list of tuples representing the existing edges from
the provided list.
"""
if not edges:
return []
try:
# Transform edges into format needed for batch query
edge_params = [
{
"from_id": str(from_node), # Ensure string type
"to_id": str(to_node), # Ensure string type
"relationship_name": str(edge_label), # Ensure string type
}
for from_node, to_node, edge_label in edges
]
# Batch check query with direct string comparison
query = """
UNWIND $edges AS edge
MATCH (from:Node)-[r:EDGE]->(to:Node)
WHERE from.id = edge.from_id
AND to.id = edge.to_id
AND r.relationship_name = edge.relationship_name
RETURN from.id, to.id, r.relationship_name
"""
results = await self.query(query, {"edges": edge_params})
# Convert results back to tuples and ensure string types
existing_edges = [(str(row[0]), str(row[1]), str(row[2])) for row in results]
logger.debug(f"Found {len(existing_edges)} existing edges out of {len(edges)} checked")
return existing_edges
except Exception as e:
logger.error(f"Failed to check edges in batch: {e}")
return []
async def add_edge(
self,
from_node: str,
to_node: str,
relationship_name: str,
edge_properties: Dict[str, Any] = {},
) -> None:
"""
Add an edge between two nodes.
This method constructs and executes a query to create a directed edge between two
specified nodes with certain properties. It will raise an error if the addition fails
during execution.
Parameters:
-----------
- from_node (str): The identifier of the source node from which the edge originates.
- to_node (str): The identifier of the target node to which the edge points.
- relationship_name (str): The label of the edge to be created, representing the
relationship name.
- edge_properties (Dict[str, Any]): A dictionary containing properties for the edge.
(default {})
"""
try:
query, params = self._edge_query_and_params(
from_node, to_node, relationship_name, edge_properties
)
await self.query(query, params)
except Exception as e:
logger.error(f"Failed to add edge: {e}")
raise
async def add_edges(
self,
edges: List[Tuple[str, str, str, Dict[str, Any]]],
source_ref_key: Optional[str] = None,
pipeline_run_id: Optional[str] = None,
) -> None:
"""
Add multiple edges in a batch operation.
This method enables efficient insertion of multiple edges at once by processing a list
of edge details. It improves performance for batch operations compared to adding edges
individually. Errors during execution are logged and raised as necessary.
Parameters:
-----------
- edges (List[Tuple[str, str, str, Dict[str, Any]]]): A list of edges represented as
tuples of (from_node, to_node, relationship_name, edge_properties).
- source_ref_key (Optional[str]): When set, graph provenance for this
source ref is stamped atomically in the same statement that writes the edges
(no separate attach pass). Omit for non-graph-provenance writes.
- pipeline_run_id (Optional[str]): Run id recorded alongside the provenance
stamp, so the write is rollbackable by run. Ignored when source_ref_key is None.
"""
if not edges:
return
try:
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")
edge_params = [
{
"from_id": from_node,
"to_id": to_node,
"relationship_name": relationship_name,
"properties": json.dumps(properties, cls=JSONEncoder),
"created_at": now,
"updated_at": now,
}
for from_node, to_node, relationship_name, properties in edges
]
query = """
UNWIND $edges AS edge
MATCH (from:Node), (to:Node)
WHERE from.id = edge.from_id AND to.id = edge.to_id
MERGE (from)-[r:EDGE {
relationship_name: edge.relationship_name
}]->(to)
ON CREATE SET
r.created_at = timestamp(edge.created_at),
r.updated_at = timestamp(edge.updated_at),
r.properties = edge.properties
ON MATCH SET
r.updated_at = timestamp(edge.updated_at),
r.properties = edge.properties
"""
query_params = {"edges": edge_params}
if source_ref_key is not None:
query += _provenance_fold_clause("r")
query_params.update(_provenance_fold_params(source_ref_key, pipeline_run_id))
await self.query(query, query_params)
await self.checkpoint()
except Exception as e:
logger.error(f"Failed to add edges in batch: {e}")
raise
async def get_edges(self, node_id: str) -> List[Tuple[Dict[str, Any], str, Dict[str, Any]]]:
"""
Get all edges connected to a node.
This method retrieves all edges that are linked to a specified node and returns them in
a structured format. If an error occurs or no edges exist, an empty list is returned.
Parameters:
-----------
- node_id (str): The identifier of the node for which to retrieve edges.
Returns:
--------
- List[Tuple[Dict[str, Any], str, Dict[str, Any]]]: A list of tuples where each
tuple contains (source_node, relationship_name, target_node), with source_node and
target_node as dictionaries of node properties.
"""
query_str = """
MATCH (n:Node)-[r]-(m:Node)
WHERE n.id = $node_id
RETURN {
id: n.id,
name: n.name,
type: n.type,
properties: n.properties
},
r.relationship_name,
{
id: m.id,
name: m.name,
type: m.type,
properties: m.properties
}
"""
try:
results = await self.query(query_str, {"node_id": node_id})
edges = []
for row in results:
if row and len(row) == 3:
source_node = self._parse_node_properties(row[0])
target_node = self._parse_node_properties(row[2])
edges.append((source_node, row[1], target_node))
return edges
except Exception as e:
logger.error(f"Failed to get edges for node {node_id}: {e}")
return []
# Neighbor Operations
async def get_neighbors(self, node_id: str) -> List[Dict[str, Any]]:
"""
Get all neighboring nodes.
This method simply calls the get_neighbours method for API compatibility and retrieves
connected nodes neighboring the specified node. It returns a list of neighbor nodes'
properties as dictionaries.
Parameters:
-----------
- node_id (str): The identifier of the node for which to find neighbors.
Returns:
--------
- List[Dict[str, Any]]: A list of dictionaries representing neighboring nodes'
properties.
"""
query_str = """
MATCH (n:Node)-[r]-(m:Node)
WHERE n.id = $id
RETURN DISTINCT {
id: m.id,
name: m.name,
type: m.type,
properties: m.properties
}
"""
try:
result = await self.query(query_str, {"id": node_id})
return [self._parse_node_properties(row[0]) for row in result] if result else []
except Exception as e:
logger.error(f"Failed to get neighbours for node {node_id}: {e}")
return []
async def get_node(self, node_id: str) -> Optional[Dict[str, Any]]:
"""
Get a single node by ID.
This method retrieves the properties of a node identified by its ID and returns them as
a dictionary. If the node does not exist, None is returned.
Parameters:
-----------
- node_id (str): The identifier of the node to retrieve.
Returns:
--------
- Optional[Dict[str, Any]]: A dictionary containing the properties of the node if
found, otherwise None.
"""
query_str = """
MATCH (n:Node)
WHERE n.id = $id
RETURN {
id: n.id,
name: n.name,
type: n.type,
properties: n.properties
}
"""
try:
result = await self.query(query_str, {"id": node_id})
if result and result[0]:
return self._parse_node(result[0][0])
return None
except Exception as e:
logger.error(f"Failed to get node {node_id}: {e}")
return None
async def get_nodes(self, node_ids: List[str]) -> List[Dict[str, Any]]:
"""
Get multiple nodes by their IDs.
This method retrieves properties for multiple nodes identified by their IDs and returns
them as a list of dictionaries. An empty list is returned if no nodes are found or an
error occurs.
Parameters:
-----------
- node_ids (List[str]): A list of identifiers for the nodes to be retrieved.
Returns:
--------
- List[Dict[str, Any]]: A list of dictionaries containing properties of each
retrieved node.
"""
query_str = """
MATCH (n:Node)
WHERE n.id IN $node_ids
RETURN {
id: n.id,
name: n.name,
type: n.type,
properties: n.properties
}
"""
try:
results = await self.query(query_str, {"node_ids": node_ids})
return [self._parse_node(row[0]) for row in results if row[0]]
except Exception as e:
logger.error(f"Failed to get nodes: {e}")
return []
def _rows_to_dicts(self, rows: List, column_names: List[str]) -> List[Dict[str, Any]]:
"""Convert query result rows to a list of dicts keyed by column names."""
result = []
for row in rows:
if not row or len(row) < len(column_names):
continue
result.append(dict(zip(column_names, row)))
return result
@staticmethod
def _resolve_edge_object_id(
properties: Dict[str, Any], edge_object_id_json: Optional[str]
) -> Optional[str]:
"""Resolve edge_object_id from properties or from edge_object_id_json string."""
edge_object_id = properties.get("edge_object_id")
if (not isinstance(edge_object_id, str) or not edge_object_id) and isinstance(
edge_object_id_json, str
):
try:
parsed = json.loads(edge_object_id_json)
edge_object_id = parsed if isinstance(parsed, str) else None
except (TypeError, json.JSONDecodeError):
edge_object_id = None
return edge_object_id if isinstance(edge_object_id, str) and edge_object_id else None
_EDGE_BY_OBJECT_ID_COLUMNS = [
"from_id",
"to_id",
"relationship_name",
"edge_object_id_json",
"properties",
]
async def _fetch_edge_rows_by_object_ids(
self, edge_object_ids: Set[str]
) -> List[Dict[str, Any]]:
"""Fetch edge rows (as dicts) for the given edge_object_ids."""
if not edge_object_ids:
return []
requested_ids_json = [json.dumps(eid) for eid in edge_object_ids]
query = """
MATCH (from:Node)-[r:EDGE]->(to:Node)
WITH from, to, r, CAST(json_extract(r.properties, '$.edge_object_id') AS STRING) AS edge_object_id_json
WHERE edge_object_id_json IN $edge_object_ids_json
RETURN from.id AS from_id, to.id AS to_id, r.relationship_name AS relationship_name,
edge_object_id_json AS edge_object_id_json, r.properties AS properties
"""
rows = await self.query(query, {"edge_object_ids_json": requested_ids_json})
return self._rows_to_dicts(rows, self._EDGE_BY_OBJECT_ID_COLUMNS)
def _build_node_feedback_updates(
self,
nodes: List[Dict[str, Any]],
node_feedback_weights: Dict[str, float],
) -> List[Dict[str, Any]]:
"""Build UNWIND items for node feedback weight updates."""
updates = []
for node in nodes:
node_id = node.get("id")
if not isinstance(node_id, str) or node_id not in node_feedback_weights:
continue
properties = {
k: v
for k, v in node.items()
if k not in {"id", "name", "type", "created_at", "updated_at"}
}
properties["feedback_weight"] = float(node_feedback_weights[node_id])
updates.append(
{"node_id": node_id, "properties": json.dumps(properties, cls=JSONEncoder)}
)
return updates
async def _execute_node_feedback_updates(self, updates: List[Dict[str, Any]]) -> Set[str]:
"""Run node feedback weight UNWIND/SET; return set of updated node_ids."""
if not updates:
return set()
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")
query = """
UNWIND $items AS item
MATCH (n:Node)
WHERE n.id = item.node_id
SET n.properties = item.properties,
n.updated_at = timestamp($updated_at)
RETURN n.id AS node_id
"""
result = await self.query(query, {"items": updates, "updated_at": now})
rows_dicts = self._rows_to_dicts(result, ["node_id"])
return {str(r["node_id"]) for r in rows_dicts if r.get("node_id") is not None}
def _build_node_truth_state_updates(
self,
nodes: List[Dict[str, Any]],
node_truth_state: Dict[str, Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Build UNWIND items for node truth state updates."""
updates = []
for node in nodes:
node_id = node.get("id")
if not isinstance(node_id, str) or node_id not in node_truth_state:
continue
state = node_truth_state[node_id]
properties = {
k: v
for k, v in node.items()
if k not in {"id", "name", "type", "created_at", "updated_at"}
}
properties["truth_alignment"] = list(state.get("truth_alignment") or [])
if state.get("truth_epoch") is not None:
properties["truth_epoch"] = int(state["truth_epoch"])
updates.append(
{"node_id": node_id, "properties": json.dumps(properties, cls=JSONEncoder)}
)
return updates
async def _execute_node_truth_state_updates(self, updates: List[Dict[str, Any]]) -> Set[str]:
"""Run node truth state UNWIND/SET; return set of updated node_ids."""
if not updates:
return set()
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")
query = """
UNWIND $items AS item
MATCH (n:Node)
WHERE n.id = item.node_id
SET n.properties = item.properties,
n.updated_at = timestamp($updated_at)
RETURN n.id AS node_id
"""
result = await self.query(query, {"items": updates, "updated_at": now})
rows_dicts = self._rows_to_dicts(result, ["node_id"])
return {str(r["node_id"]) for r in rows_dicts if r.get("node_id") is not None}
def _build_edge_feedback_updates(
self,
edge_rows: List[Dict[str, Any]],
edge_feedback_weights: Dict[str, float],
) -> List[Dict[str, Any]]:
"""Build UNWIND items for edge feedback weight updates."""
edge_updates = []
for row in edge_rows:
properties_raw = row.get("properties")
if not properties_raw:
continue
try:
properties = json.loads(properties_raw)
except (TypeError, json.JSONDecodeError):
continue
edge_object_id = self._resolve_edge_object_id(
properties, row.get("edge_object_id_json")
)
if not edge_object_id or edge_object_id not in edge_feedback_weights:
continue
properties["feedback_weight"] = float(edge_feedback_weights[edge_object_id])
edge_updates.append(
{
"edge_object_id": edge_object_id,
"from_id": str(row.get("from_id")),
"to_id": str(row.get("to_id")),
"relationship_name": str(row.get("relationship_name")),
"properties": json.dumps(properties, cls=JSONEncoder),
}
)
return edge_updates
async def _execute_edge_feedback_updates(self, edge_updates: List[Dict[str, Any]]) -> Set[str]:
"""Run edge feedback weight UNWIND/SET; return set of updated edge_object_ids."""
if not edge_updates:
return set()
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f")
query = """
UNWIND $items AS item
MATCH (from:Node)-[r:EDGE]->(to:Node)
WHERE from.id = item.from_id
AND to.id = item.to_id
AND r.relationship_name = item.relationship_name
SET r.properties = item.properties,
r.updated_at = timestamp($updated_at)
RETURN item.edge_object_id AS edge_object_id
"""
result = await self.query(query, {"items": edge_updates, "updated_at": now})
rows_dicts = self._rows_to_dicts(result, ["edge_object_id"])
return {str(r["edge_object_id"]) for r in rows_dicts if r.get("edge_object_id") is not None}
async def get_node_feedback_weights(self, node_ids: List[str]) -> Dict[str, float]:
if not node_ids:
return {}
valid_node_ids = [node_id for node_id in node_ids if isinstance(node_id, str) and node_id]
if not valid_node_ids:
return {}
nodes = await self.get_nodes(valid_node_ids)
result: Dict[str, float] = {}
for node in nodes:
node_id = node.get("id")
if not isinstance(node_id, str):
continue
value = node.get("feedback_weight", 0.5)
try:
result[node_id] = float(value)
except (TypeError, ValueError):
result[node_id] = 0.5
return result
async def set_node_feedback_weights(
self, node_feedback_weights: Dict[str, float]
) -> Dict[str, bool]:
if not node_feedback_weights:
return {}
node_ids = list(node_feedback_weights.keys())
valid_node_ids = [nid for nid in node_ids if isinstance(nid, str) and nid]
if not valid_node_ids:
return {nid: False for nid in node_ids}
nodes = await self.get_nodes(valid_node_ids)
updates = self._build_node_feedback_updates(nodes, node_feedback_weights)
if not updates:
return {nid: False for nid in node_ids}
updated_ids = await self._execute_node_feedback_updates(updates)
return {nid: (nid in updated_ids) for nid in node_ids}
async def get_node_truth_state(self, node_ids: List[str]) -> Dict[str, Dict[str, Any]]:
if not node_ids:
return {}
valid_node_ids = [node_id for node_id in node_ids if isinstance(node_id, str) and node_id]
if not valid_node_ids:
return {}
nodes = await self.get_nodes(valid_node_ids)
result: Dict[str, Dict[str, Any]] = {}
for node in nodes:
node_id = node.get("id")
if not isinstance(node_id, str):
continue
value = node.get("truth_alignment", [])
epoch = node.get("truth_epoch")
if isinstance(value, (list, tuple)):
alignment = list(value)
else:
alignment = []
try:
truth_epoch = int(epoch) if epoch is not None else None
except (TypeError, ValueError):
truth_epoch = None
result[node_id] = {"truth_alignment": alignment, "truth_epoch": truth_epoch}
return result
async def set_node_truth_state(
self, node_truth_state: Dict[str, Dict[str, Any]]
) -> Dict[str, bool]:
if not node_truth_state:
return {}
node_ids = list(node_truth_state.keys())
valid_node_ids = [nid for nid in node_ids if isinstance(nid, str) and nid]
if not valid_node_ids:
return {nid: False for nid in node_ids}
nodes = await self.get_nodes(valid_node_ids)
updates = self._build_node_truth_state_updates(nodes, node_truth_state)
if not updates:
return {nid: False for nid in node_ids}
updated_ids = await self._execute_node_truth_state_updates(updates)
return {nid: (nid in updated_ids) for nid in node_ids}
async def get_edge_feedback_weights(self, edge_object_ids: List[str]) -> Dict[str, float]:
if not edge_object_ids:
return {}
requested_ids = {eid for eid in edge_object_ids if isinstance(eid, str) and eid}
if not requested_ids:
return {}
edge_rows = await self._fetch_edge_rows_by_object_ids(requested_ids)
result: Dict[str, float] = {}
for row in edge_rows:
properties_raw = row.get("properties")
if not properties_raw:
continue
try:
properties = json.loads(properties_raw)
except (TypeError, json.JSONDecodeError):
continue
edge_object_id = self._resolve_edge_object_id(
properties, row.get("edge_object_id_json")
)
if not edge_object_id or edge_object_id not in requested_ids:
continue
value = properties.get("feedback_weight", 0.5)
try:
result[edge_object_id] = float(value)
except (TypeError, ValueError):
result[edge_object_id] = 0.5
return result
async def set_edge_feedback_weights(
self, edge_feedback_weights: Dict[str, float]
) -> Dict[str, bool]:
if not edge_feedback_weights:
return {}
requested_ids = {eid for eid in edge_feedback_weights if isinstance(eid, str) and eid}
if not requested_ids:
return {eid: False for eid in edge_feedback_weights}
edge_rows = await self._fetch_edge_rows_by_object_ids(requested_ids)
edge_updates = self._build_edge_feedback_updates(edge_rows, edge_feedback_weights)
if not edge_updates:
return {eid: False for eid in edge_feedback_weights}
updated_ids = await self._execute_edge_feedback_updates(edge_updates)
return {eid: (eid in updated_ids) for eid in edge_feedback_weights}
async def get_predecessors(
self, node_id: Union[str, UUID], edge_label: Optional[str] = None
) -> List[Dict[str, Any]]:
"""
Get all predecessor nodes.
This method retrieves all nodes that are predecessors of the specified node. If an edge
label is provided, it filters the results accordingly. It returns a list of dictionaries
containing properties of these predecessor nodes.
Parameters:
-----------
- node_id (Union[str, UUID]): The identifier of the specified node.
- edge_label (Optional[str]): An optional label to filter the edges by relationship
name. (default None)
Returns:
--------
- List[Dict[str, Any]]: A list of dictionaries representing all predecessor nodes'
properties.
"""
try:
if edge_label:
query_str = """
MATCH (n)<-[r:EDGE]-(m)
WHERE n.id = $id AND r.relationship_name = $edge_label
RETURN properties(m)
"""
params = {"id": str(node_id), "edge_label": edge_label}
else:
query_str = """
MATCH (n)<-[r:EDGE]-(m)
WHERE n.id = $id
RETURN properties(m)
"""
params = {"id": str(node_id)}
result = await self.query(query_str, params)
return [row[0] for row in result] if result else []
except Exception as e:
logger.error(f"Failed to get predecessors for node {node_id}: {e}")
return []
async def get_successors(
self, node_id: Union[str, UUID], edge_label: Optional[str] = None
) -> List[Dict[str, Any]]:
"""
Get all successor nodes.
This method retrieves all nodes that are successors of the specified node. An edge label
can be provided to filter the results. It returns a list of dictionaries detailing these
successor nodes' properties.
Parameters:
-----------
- node_id (Union[str, UUID]): The identifier of the specified node.
- edge_label (Optional[str]): An optional label to filter the edges by relationship
name. (default None)
Returns:
--------
- List[Dict[str, Any]]: A list of dictionaries representing all successor nodes'
properties.
"""
try:
if edge_label:
query_str = """
MATCH (n)-[r:EDGE]->(m)
WHERE n.id = $id AND r.relationship_name = $edge_label
RETURN properties(m)
"""
params = {"id": str(node_id), "edge_label": edge_label}
else:
query_str = """
MATCH (n)-[r:EDGE]->(m)
WHERE n.id = $id
RETURN properties(m)
"""
params = {"id": str(node_id)}
result = await self.query(query_str, params)
return [row[0] for row in result] if result else []
except Exception as e:
logger.error(f"Failed to get successors for node {node_id}: {e}")
return []
async def get_connections(
self, node_id: str
) -> List[Tuple[Dict[str, Any], Dict[str, Any], Dict[str, Any]]]:
"""
Get all nodes connected to a given node.
This method retrieves all nodes directly connected to a specified node along with the
relationships between them, returning structured data in a list of tuples. Each tuple
contains source and target node properties along with the relationship information.
Parameters:
-----------
- node_id (str): The identifier of the node for which to retrieve connections.
Returns:
--------
- List[Tuple[Dict[str, Any], Dict[str, Any], Dict[str, Any]]]}: A list of tuples
containing (source_node, relationship_name, target_node) with dictionaries for
source_node and target_node properties.
"""
query_str = """
MATCH (n:Node)-[r:EDGE]-(m:Node)
WHERE n.id = $node_id
RETURN {
id: n.id,
name: n.name,
type: n.type,
properties: n.properties
},
{
relationship_name: r.relationship_name,
properties: r.properties
},
{
id: m.id,
name: m.name,
type: m.type,
properties: m.properties
}
"""
try:
results = await self.query(query_str, {"node_id": node_id})
edges = []
for row in results:
if row and len(row) == 3:
processed_rows = []
for i, item in enumerate(row):
if isinstance(item, dict):
if "properties" in item and item["properties"]:
try:
props = json.loads(item["properties"])
item.update(props)
del item["properties"]
except json.JSONDecodeError:
logger.warning(
f"Failed to parse JSON properties for node/edge {i}"
)
processed_rows.append(item)
edges.append(tuple(processed_rows))
return edges if edges else [] # Always return a list, even if empty
except Exception as e:
logger.error(f"Failed to get connections for node {node_id}: {e}")
return [] # Return empty list on error
async def remove_connection_to_predecessors_of(
self, node_ids: List[str], edge_label: str
) -> None:
"""
Remove all incoming edges of specified type for given nodes.
This method disconnects predecessor relationships of a specific type for the specified
nodes, managing edges in a single operation effectively.
Parameters:
-----------
- node_ids (List[str]): A list of identifiers for the nodes whose relationships to
be removed.
- edge_label (str): The label of the edge to be removed.
"""
query_str = """
MATCH (n)<-[r:EDGE]-(m)
WHERE n.id IN $node_ids AND r.relationship_name = $edge_label
DELETE r
"""
await self.query(query_str, {"node_ids": node_ids, "edge_label": edge_label})
async def remove_connection_to_successors_of(
self, node_ids: List[str], edge_label: str
) -> None:
"""
Remove all outgoing edges of specified type for given nodes.
This method disconnects successor relationships of a specified type for the specified
nodes in a single efficient operation.
Parameters:
-----------
- node_ids (List[str]): A list of identifiers for the nodes whose relationships to
be removed.
- edge_label (str): The label of the edge to be removed.
"""
query_str = """
MATCH (n)-[r:EDGE]->(m)
WHERE n.id IN $node_ids AND r.relationship_name = $edge_label
DELETE r
"""
await self.query(query_str, {"node_ids": node_ids, "edge_label": edge_label})
# Graph-wide Operations
async def get_graph_data(
self,
) -> Tuple[List[Tuple[str, Dict[str, Any]]], List[Tuple[str, str, str, Dict[str, Any]]]]:
"""
Get all nodes and edges in the graph.
This method fetches the entire graph's structure, including all nodes and their
properties as well as relationships and their details, returning them in a structured
format. Errors during query execution will result in raised exceptions.
Returns:
--------
- Tuple[List[Tuple[str, Dict[str, Any]]], List[Tuple[str, str, str, Dict[str, Any]]]]:
A tuple with two elements: a list of tuples of (node_id, properties) and a list of
tuples of (source_id, target_id, relationship_name, properties).
"""
import time
start_time = time.time()
try:
nodes_query = """
MATCH (n:Node)
RETURN n.id, {
name: n.name,
type: n.type,
properties: n.properties
}
"""
nodes = await self.query(nodes_query)
formatted_nodes = []
for n in nodes:
if n[0]:
node_id = str(n[0])
props = n[1]
if props.get("properties"):
try:
additional_props = json.loads(props["properties"])
props.update(additional_props)
del props["properties"]
except json.JSONDecodeError:
logger.warning(f"Failed to parse properties JSON for node {node_id}")
formatted_nodes.append((node_id, props))
if not formatted_nodes:
logger.warning("No nodes found in the database")
return [], []
edges_query = """
MATCH (n:Node)-[r]->(m:Node)
RETURN n.id, m.id, r.relationship_name, r.properties
"""
edges = await self.query(edges_query)
formatted_edges = []
for e in edges:
if e and len(e) >= 3:
source_id = str(e[0])
target_id = str(e[1])
rel_type = str(e[2])
props = {}
if len(e) > 3 and e[3]:
try:
props = json.loads(e[3])
except (json.JSONDecodeError, TypeError):
logger.warning(
f"Failed to parse edge properties for {source_id}->{target_id}"
)
formatted_edges.append((source_id, target_id, rel_type, props))
if formatted_nodes and not formatted_edges:
logger.debug("No edges found, creating self-referential edges for nodes")
for node_id, _ in formatted_nodes:
formatted_edges.append(
(
node_id,
node_id,
"SELF",
{
"relationship_name": "SELF",
"relationship_type": "SELF",
"vector_distance": 0.0,
},
)
)
retrieval_time = time.time() - start_time
logger.info(
f"Retrieved {len(nodes)} nodes and {len(edges)} edges in {retrieval_time:.2f} seconds"
)
return formatted_nodes, formatted_edges
except Exception as e:
logger.error(f"Failed to get graph data: {e}")
raise
async def get_neighborhood(
self,
node_ids: List[str],
depth: int = 1,
edge_types: Optional[List[str]] = None,
) -> Tuple[List[Tuple[str, Dict[str, Any]]], List[Tuple[str, str, str, Dict[str, Any]]]]:
"""
Get the k-hop neighborhood subgraph around a set of seed nodes.
Returns all nodes and edges within `depth` hops of any seed node,
in the same format as get_graph_data().
"""
import time
start_time = time.time()
try:
if not node_ids:
logger.warning("No node IDs provided for neighborhood retrieval.")
return [], []
# Use variable-length path to find all nodes within depth hops
path_query = f"""
MATCH (seed:Node)-[r*1..{depth}]-(neighbor:Node)
WHERE seed.id IN $node_ids{" AND ALL(rel IN r WHERE rel.relationship_name IN $edge_types)" if edge_types else ""}
RETURN DISTINCT neighbor.id
"""
params = {"node_ids": node_ids}
if edge_types:
params["edge_types"] = edge_types
neighbor_rows = await self.query(path_query, params)
neighbor_ids = [row[0] for row in neighbor_rows if row[0]]
# Combine seed nodes and neighbor nodes
all_ids = list(set(node_ids) | set(neighbor_ids))
# Fetch all nodes
nodes_query = """
MATCH (n:Node)
WHERE n.id IN $ids
RETURN n.id, {
name: n.name,
type: n.type,
properties: n.properties
}
"""
node_rows = await self.query(nodes_query, {"ids": all_ids})
formatted_nodes = []
for n in node_rows:
if n[0]:
node_id = str(n[0])
props = n[1]
if props.get("properties"):
try:
additional_props = json.loads(props["properties"])
props.update(additional_props)
del props["properties"]
except json.JSONDecodeError:
logger.warning(f"Failed to parse properties JSON for node {node_id}")
formatted_nodes.append((node_id, props))
if not formatted_nodes:
logger.warning("No nodes found in neighborhood.")
return [], []
# Fetch all edges between the collected nodes
edges_query = """
MATCH (n:Node)-[r]->(m:Node)
WHERE n.id IN $ids AND m.id IN $ids
RETURN n.id, m.id, r.relationship_name, r.properties
"""
edge_rows = await self.query(edges_query, {"ids": all_ids})
formatted_edges = []
for e in edge_rows:
if e and len(e) >= 3:
source_id = str(e[0])
target_id = str(e[1])
rel_type = str(e[2])
props = {}
if len(e) > 3 and e[3]:
try:
props = json.loads(e[3])
except (json.JSONDecodeError, TypeError):
logger.warning(
f"Failed to parse edge properties for {source_id}->{target_id}"
)
formatted_edges.append((source_id, target_id, rel_type, props))
retrieval_time = time.time() - start_time
logger.info(
f"Neighborhood retrieval ({depth}-hop): {len(formatted_nodes)} nodes and "
f"{len(formatted_edges)} edges in {retrieval_time:.2f}s"
)
return formatted_nodes, formatted_edges
except Exception as e:
logger.error(f"Failed to get neighborhood: {e}")
raise
async def get_nodeset_subgraph(
self, node_type: Type[Any], node_name: List[str], node_name_filter_operator: str = "OR"
) -> Tuple[List[Tuple[str, dict]], List[Tuple[str, str, str, dict]]]:
"""
Get subgraph for a set of nodes based on type and names.
This method queries for nodes of a specific type and their corresponding neighbors,
returning both nodes and edges connecting them. It's useful for analyzing a targeted
subset of the graph.
Parameters:
-----------
- node_type (Type[Any]): Type of nodes to retrieve as specified by the user.
- node_name (List[str]): List of names corresponding to the nodes to be retrieved.
Returns:
--------
- Tuple[List[Tuple[str, dict]], List[Tuple[str, str, str, dict]]]}: A tuple
containing a list of nodes and a list of edges related to those nodes.
"""
label = node_type.__name__
primary_query = """
UNWIND $names AS wantedName
MATCH (n:Node)
WHERE n.type = $label AND n.name = wantedName
RETURN DISTINCT n.id
"""
primary_rows = await self.query(primary_query, {"names": node_name, "label": label})
primary_ids = [row[0] for row in primary_rows]
if not primary_ids:
return [], []
if node_name_filter_operator == "OR":
neighbor_query = """
MATCH (n:Node)-[:EDGE]-(nbr:Node)
WHERE n.id IN $ids
RETURN DISTINCT nbr.id
"""
params = {"ids": primary_ids}
else:
neighbor_query = """
MATCH (n:Node)-[:EDGE]-(nbr:Node)
WHERE n.id IN $ids
WITH nbr.id AS nbr_id, COUNT(DISTINCT n.id) AS matched_count
WHERE matched_count = $primary_count
RETURN nbr_id
"""
params = {"ids": primary_ids, "primary_count": len(primary_ids)}
nbr_rows = await self.query(neighbor_query, params)
neighbor_ids = [row[0] for row in nbr_rows]
all_ids = list({*primary_ids, *neighbor_ids})
nodes_query = """
MATCH (n:Node)
WHERE n.id IN $ids
RETURN n.id, n.name, n.type, n.properties
"""
node_rows = await self.query(nodes_query, {"ids": all_ids})
nodes: List[Tuple[str, dict]] = []
for node_id, name, typ, props in node_rows:
data = {"id": node_id, "name": name, "type": typ}
if props:
try:
data.update(json.loads(props))
except json.JSONDecodeError:
logger.warning(f"Failed to parse JSON props for node {node_id}")
nodes.append((node_id, data))
edges_query = """
MATCH (a:Node)-[r:EDGE]-(b:Node)
WHERE a.id IN $ids AND b.id IN $ids
RETURN a.id, b.id, r.relationship_name, r.properties
"""
edge_rows = await self.query(edges_query, {"ids": all_ids})
edges: List[Tuple[str, str, str, dict]] = []
for from_id, to_id, rel_type, props in edge_rows:
data = {}
if props:
try:
data = json.loads(props)
except json.JSONDecodeError:
logger.warning(f"Failed to parse JSON props for edge {from_id}->{to_id}")
edges.append((from_id, to_id, rel_type, data))
return nodes, edges
async def get_filtered_graph_data(
self, attribute_filters: List[Dict[str, List[Union[str, int]]]]
):
"""
Get filtered nodes and relationships based on attributes.
This method accepts attribute filters and retrieves nodes and relationships that match
the specified conditions. It allows complex filtering across node properties and edge
attributes.
Parameters:
-----------
- attribute_filters (List[Dict[str, List[Union[str, int]]]]): A list of dictionaries
specifying attributes and their corresponding values for filtering nodes and
edges.
Returns:
--------
A tuple containing a list of filtered node properties and a list of filtered edge
properties.
"""
where_clauses = []
params = {}
if not attribute_filters:
return [], []
for i, filter_dict in enumerate(attribute_filters):
for attr, values in filter_dict.items():
if not attr.isidentifier():
raise CogneeValidationError(
f"Invalid attribute filter key '{attr}'. Only identifiers are allowed."
)
if not values:
continue
param_name = f"values_{i}_{attr}"
where_clauses.append(f"n.{attr} IN ${param_name}")
params[param_name] = values
if not where_clauses:
return [], []
where_clause = " AND ".join(where_clauses)
nodes_query = f"""
MATCH (n:Node)
WHERE {where_clause}
RETURN n.id, {{
name: n.name,
type: n.type,
properties: n.properties
}}
"""
edges_query = f"""
MATCH (n1:Node)-[r:EDGE]->(n2:Node)
WHERE {where_clause.replace("n.", "n1.")} AND {where_clause.replace("n.", "n2.")}
RETURN n1.id, n2.id, r.relationship_name, r.properties
"""
nodes, edges = await asyncio.gather(
self.query(nodes_query, params), self.query(edges_query, params)
)
formatted_nodes = []
for n in nodes:
if n[0]:
node_id = str(n[0])
props = n[1]
if props.get("properties"):
try:
additional_props = json.loads(props["properties"])
props.update(additional_props)
del props["properties"]
except json.JSONDecodeError:
logger.warning(f"Failed to parse properties JSON for node {node_id}")
formatted_nodes.append((node_id, props))
if not formatted_nodes:
logger.warning("No nodes found in the database")
return [], []
formatted_edges = []
for e in edges:
if e and len(e) >= 3:
source_id = str(e[0])
target_id = str(e[1])
rel_type = str(e[2])
props = {}
if len(e) > 3 and e[3]:
try:
props = json.loads(e[3])
except (json.JSONDecodeError, TypeError):
logger.warning(
f"Failed to parse edge properties for {source_id}->{target_id}"
)
formatted_edges.append((source_id, target_id, rel_type, props))
return formatted_nodes, formatted_edges
async def get_id_filtered_graph_data(self, target_ids: list[str]):
"""
Retrieve graph data filtered by specific node IDs, including their direct neighbors
and only edges where one endpoint matches those IDs.
Returns:
nodes: List[dict] -> Each dict includes "id" and all node properties
edges: List[dict] -> Each dict includes "source", "target", "type", "properties"
"""
import time
start_time = time.time()
try:
if not target_ids:
logger.warning("No target IDs provided for ID-filtered graph retrieval.")
return [], []
if not all(isinstance(x, str) for x in target_ids):
raise CogneeValidationError("target_ids must be a list of strings")
query = """
MATCH (n:Node)-[r]->(m:Node)
WHERE n.id IN $target_ids OR m.id IN $target_ids
RETURN n.id, {
name: n.name,
type: n.type,
properties: n.properties
}, m.id, {
name: m.name,
type: m.type,
properties: m.properties
}, r.relationship_name, r.properties
"""
result = await self.query(query, {"target_ids": target_ids})
if not result:
logger.info("No data returned for the supplied IDs")
return [], []
nodes_dict = {}
edges = []
for n_id, n_props, m_id, m_props, r_type, r_props_raw in result:
if n_props.get("properties"):
try:
additional_props = json.loads(n_props["properties"])
n_props.update(additional_props)
del n_props["properties"]
except json.JSONDecodeError:
logger.warning(f"Failed to parse properties JSON for node {n_id}")
if m_props.get("properties"):
try:
additional_props = json.loads(m_props["properties"])
m_props.update(additional_props)
del m_props["properties"]
except json.JSONDecodeError:
logger.warning(f"Failed to parse properties JSON for node {m_id}")
nodes_dict[n_id] = (n_id, n_props)
nodes_dict[m_id] = (m_id, m_props)
edge_props = {}
if r_props_raw:
try:
edge_props = json.loads(r_props_raw)
except (json.JSONDecodeError, TypeError):
logger.warning(f"Failed to parse edge properties for {n_id}->{m_id}")
source_id = edge_props.get("source_node_id", n_id)
target_id = edge_props.get("target_node_id", m_id)
edges.append((source_id, target_id, r_type, edge_props))
retrieval_time = time.time() - start_time
logger.info(
f"ID-filtered retrieval: {len(nodes_dict)} nodes and {len(edges)} edges in {retrieval_time:.2f}s"
)
return list(nodes_dict.values()), edges
except Exception as e:
logger.error(f"Error during ID-filtered graph data retrieval: {str(e)}")
raise
async def get_graph_metrics(self, include_optional=False) -> Dict[str, Any]:
"""
Get metrics on graph structure and connectivity.
This method computes various metrics around the graph, such as node and edge counts,
mean degree, and connected component sizes. Optionally, it can include additional
metrics based on user request.
Parameters:
-----------
- include_optional: A boolean flag indicating whether to include optional metrics in
the output. (default False)
Returns:
--------
- Dict[str, Any]: A dictionary containing various metrics related to the graph.
"""
try:
node_count_result = await self.query("MATCH (n:Node) RETURN COUNT(n)")
edge_count_result = await self.query("MATCH ()-[r:EDGE]->() RETURN COUNT(r)")
num_nodes = node_count_result[0][0] if node_count_result else 0
num_edges = edge_count_result[0][0] if edge_count_result else 0
# Calculate mandatory metrics
mandatory_metrics = {
"num_nodes": num_nodes,
"num_edges": num_edges,
"mean_degree": (2 * num_edges) / num_nodes if num_nodes != 0 else None,
"edge_density": num_edges / (num_nodes * (num_nodes - 1)) if num_nodes > 1 else 0,
"num_connected_components": await self._get_num_connected_components(),
"sizes_of_connected_components": await self._get_size_of_connected_components(),
}
if include_optional:
# Calculate optional metrics
shortest_path_lengths = await self._get_shortest_path_lengths()
optional_metrics = {
"num_selfloops": await self._count_self_loops(),
"diameter": max(shortest_path_lengths) if shortest_path_lengths else -1,
"avg_shortest_path_length": sum(shortest_path_lengths)
/ len(shortest_path_lengths)
if shortest_path_lengths
else -1,
"avg_clustering": await self._get_avg_clustering(),
}
else:
optional_metrics = {
"num_selfloops": -1,
"diameter": -1,
"avg_shortest_path_length": -1,
"avg_clustering": -1,
}
return {**mandatory_metrics, **optional_metrics}
except Exception as e:
logger.error(f"Failed to get graph metrics: {e}")
return {
"num_nodes": 0,
"num_edges": 0,
"mean_degree": 0,
"edge_density": 0,
"num_connected_components": 0,
"sizes_of_connected_components": [],
"num_selfloops": -1,
"diameter": -1,
"avg_shortest_path_length": -1,
"avg_clustering": -1,
}
async def _get_num_connected_components(self) -> int:
"""Get the number of connected components in the graph."""
query = """
MATCH (n:Node)
WITH n, n.id AS node_id
MATCH path = (n)-[:EDGE*1..3]-(m)
WITH node_id, COLLECT(DISTINCT m.id) AS connected_nodes
WITH COLLECT(DISTINCT connected_nodes + [node_id]) AS components
RETURN SIZE(components) AS num_components
"""
result = await self.query(query)
return result[0][0] if result else 0
async def _get_size_of_connected_components(self) -> List[int]:
"""Get the sizes of all connected components in the graph."""
query = """
MATCH (n:Node)
WITH n, n.id AS node_id
MATCH path = (n)-[:EDGE*1..3]-(m)
WITH node_id, COLLECT(DISTINCT m.id) AS connected_nodes
WITH COLLECT(DISTINCT connected_nodes + [node_id]) AS components
UNWIND components AS component
RETURN SIZE(component) AS component_size
"""
result = await self.query(query)
return [row[0] for row in result] if result else []
async def _get_shortest_path_lengths(self) -> List[int]:
"""Get the lengths of shortest paths between all pairs of nodes."""
query = """
MATCH (n:Node), (m:Node)
WHERE n.id < m.id
MATCH path = (n)-[:EDGE*]-(m)
RETURN MIN(LENGTH(path)) AS length
"""
result = await self.query(query)
return [row[0] for row in result if row[0] is not None] if result else []
async def _count_self_loops(self) -> int:
"""Count the number of self-loops in the graph."""
query = """
MATCH (n:Node)-[r:EDGE]->(n)
RETURN COUNT(r) AS count
"""
result = await self.query(query)
return result[0][0] if result else 0
async def _get_avg_clustering(self) -> float:
"""Calculate the average clustering coefficient of the graph."""
query = """
MATCH (n:Node)-[:EDGE]-(neighbor)
WITH n, COUNT(DISTINCT neighbor) as degree
MATCH (n)-[:EDGE]-(n1)-[:EDGE]-(n2)-[:EDGE]-(n)
WHERE n1 <> n2
RETURN AVG(CASE WHEN degree <= 1 THEN 0 ELSE COUNT(DISTINCT n2) / (degree * (degree-1)) END) AS avg_clustering
"""
result = await self.query(query)
return result[0][0] if result and result[0][0] is not None else -1
async def get_disconnected_nodes(self) -> List[str]:
"""
Get nodes that are not connected to any other node.
This method retrieves identifiers of nodes that lack any relationships in the graph,
indicating they are standalone. It will return an empty list if no disconnected nodes
exist.
Returns:
--------
- List[str]: A list of identifiers for disconnected nodes.
"""
query_str = """
MATCH (n:Node)
WHERE NOT EXISTS((n)-[]-())
RETURN n.id
"""
result = await self.query(query_str)
return [str(row[0]) for row in result]
# Graph Meta-Data Operations
async def get_model_independent_graph_data(self) -> Dict[str, List[str]]:
"""
Get graph data independent of any specific data model.
This method returns a representation of the graph that includes distinct node labels and
relationship types, making it easier to analyze the graph's structure without tying it
to a specific implementation.
Returns:
--------
- Dict[str, List[str]]: A dictionary summarizing the node labels and relationship
types present in the graph.
"""
node_labels = await self.query("MATCH (n:Node) RETURN DISTINCT labels(n)")
rel_types = await self.query("MATCH ()-[r:EDGE]->() RETURN DISTINCT r.relationship_name")
return {
"node_labels": [label[0] for label in node_labels],
"relationship_types": [rel[0] for rel in rel_types],
}
async def delete_graph(self) -> None:
"""
Delete all data from the graph database.
This method deletes all nodes and relationships from the graph database.
It raises exceptions for failures occurring during deletion processes.
"""
# In ``shared_ladybug_lock`` mode the Redis lock is the cross-process
# mutex that ``query()`` uses to keep two processes from opening
# the same Ladybug DB on disk concurrently. ``delete_graph`` removes
# those files, so it MUST hold the same lock — otherwise a peer
# process could be mid-query (holding the on-disk file lock) when
# we delete its files, or could open the DB right between our
# drop and our reopen.
held_redis_lock = None
if cache_config.shared_ladybug_lock and self.redis_lock is not None:
held_redis_lock = await asyncio.to_thread(self.redis_lock.acquire_lock)
try:
# Transient drop: release the file handles so we can delete the
# db files, but do NOT latch ``_permanently_closed`` — callers
# expect to keep using this adapter after ``delete_graph`` and
# have the store lazily reinitialize. Drain in-flight queries
# under ``_connection_lock`` so we don't tear out a Connection
# an executor thread is still using (the query path releases
# ``_connection_lock`` before ``run_in_executor`` so multiple
# queries can run concurrently — the lock alone wouldn't block
# us against them).
async with self._connection_lock:
await self._drain_in_flight_queries()
# Subprocess mode: ``_drop_native_resources`` issues two
# RPCs (OP_CONN_CLOSE + OP_DB_CLOSE) which block on the
# worker. Offload so we don't freeze the event loop.
if self._subprocess_mode:
await asyncio.to_thread(self._drop_native_resources)
else:
self._drop_native_resources()
db_dir = os.path.dirname(self.db_path)
db_name = os.path.basename(self.db_path)
file_storage = get_file_storage(db_dir)
if await file_storage.is_file(db_name):
await file_storage.remove(db_name)
await file_storage.remove(f"{db_name}.lock")
else:
await file_storage.remove_all(db_name)
logger.info(f"Deleted Ladybug database files at {self.db_path}")
# No eager reopen here: we just removed the on-disk store, and
# callers asking us to delete it should not get a freshly
# recreated empty database as a side effect. If the same
# adapter is reused for a query later, ``get_or_init_connection``
# rebuilds proxies + schema lazily — same shape local mode has
# always had. If the caller is the dataset-deletion handler,
# the cache evicts this entry and ``close()`` shuts the
# subprocess down cleanly.
except Exception as e:
logger.error(f"Failed to delete graph data: {e}")
raise
finally:
if held_redis_lock is not None:
# Offloaded for symmetry with the acquire path; release
# does Redis I/O too.
await asyncio.to_thread(self.redis_lock.release_lock, held_redis_lock)
async def get_document_subgraph(self, data_id: str):
"""
Get all nodes that should be deleted when removing a document.
This method constructs a complex query that identifies all nodes related to a specified
document and returns a dictionary of these nodes. Ensures thorough checks for orphaned
entities and inaccurate relationships that should be removed alongside the document.
Parameters:
-----------
- data_id (str): The identifier for the document to query against.
Returns:
--------
A dictionary containing details of the document and associated nodes that need to be
deleted, or None if no related nodes are found.
"""
query = """
MATCH (doc:Node)
WHERE (doc.type = 'TextDocument' OR doc.type = 'PdfDocument' OR doc.type = 'AudioDocument' OR doc.type = 'ImageDocument' OR doc.type = 'UnstructuredDocument') AND doc.id = $data_id
OPTIONAL MATCH (doc)<-[e1:EDGE]-(chunk:Node)
WHERE e1.relationship_name = 'is_part_of' AND chunk.type = 'DocumentChunk'
OPTIONAL MATCH (chunk)-[e2:EDGE]->(entity:Node)
WHERE e2.relationship_name = 'contains' AND entity.type = 'Entity'
AND NOT EXISTS {
MATCH (entity)<-[e3:EDGE]-(otherChunk:Node)-[e4:EDGE]->(otherDoc:Node)
WHERE e3.relationship_name = 'contains'
AND e4.relationship_name = 'is_part_of'
AND (otherDoc.type = 'TextDocument' OR otherDoc.type = 'PdfDocument' OR otherDoc.type = 'AudioDocument' OR otherDoc.type = 'ImageDocument' OR otherDoc.type = 'UnstructuredDocument')
AND otherDoc.id <> doc.id
}
OPTIONAL MATCH (chunk)<-[e5:EDGE]-(made_node:Node)
WHERE e5.relationship_name = 'made_from' AND made_node.type = 'TextSummary'
OPTIONAL MATCH (entity)-[e6:EDGE]->(type:Node)
WHERE e6.relationship_name = 'is_a' AND type.type = 'EntityType'
AND NOT EXISTS {
MATCH (type)<-[e7:EDGE]-(otherEntity:Node)-[e8:EDGE]-(otherChunk:Node)-[e9:EDGE]-(otherDoc:Node)
WHERE e7.relationship_name = 'is_a'
AND e8.relationship_name = 'contains'
AND e9.relationship_name = 'is_part_of'
AND otherEntity.type = 'Entity'
AND otherChunk.type = 'DocumentChunk'
AND (otherDoc.type = 'TextDocument' OR otherDoc.type = 'PdfDocument' OR otherDoc.type = 'AudioDocument' OR otherDoc.type = 'ImageDocument' OR otherDoc.type = 'UnstructuredDocument')
AND otherDoc.id <> doc.id
}
RETURN
COLLECT(DISTINCT doc) as document,
COLLECT(DISTINCT chunk) as chunks,
COLLECT(DISTINCT entity) as orphan_entities,
COLLECT(DISTINCT made_node) as made_from_nodes,
COLLECT(DISTINCT type) as orphan_types
"""
result = await self.query(query, {"data_id": f"{data_id}"})
if not result or not result[0]:
return None
# Convert tuple to dictionary
return {
"document": result[0][0],
"chunks": result[0][1],
"orphan_entities": result[0][2],
"made_from_nodes": result[0][3],
"orphan_types": result[0][4],
}
async def get_degree_one_nodes(self, node_type: str):
"""
Get all nodes that have only one connection.
This method retrieves nodes which are connected to exactly one other node, identified by
their specific type. It raises a ValueError if the input type is invalid and processes
queries efficiently to return targeted results.
Parameters:
-----------
- node_type (str): The type of nodes to filter by, must be 'Entity' or 'EntityType'.
Returns:
--------
A list of nodes that have only one connection, as identified by the specified type.
"""
if not node_type or node_type not in ["Entity", "EntityType"]:
raise ValueError("node_type must be either 'Entity' or 'EntityType'")
query = f"""
MATCH (n:Node)
WHERE n.type = '{node_type}'
WITH n, COUNT {{ MATCH (n)--() }} as degree
WHERE degree = 1
RETURN n
"""
result = await self.query(query)
return [record[0] for record in result] if result else []
def _normalize_temporal_ids(self, ids: Union[List[str], str]) -> List[str]:
if isinstance(ids, str):
return [uid.strip().strip("'\"") for uid in ids.split(",") if uid.strip()]
return ids
async def collect_events(self, ids: Union[List[str], str]) -> Any:
"""
Collect all Event-type nodes reachable within 1..2 hops
from the given node IDs.
Args:
graph_engine: Object exposing an async .query(str) -> Any
ids: List of node IDs (strings)
Returns:
List of events
"""
event_collection_cypher = """UNWIND $ids AS uid
MATCH (start {id: uid})
MATCH (start)-[*1..2]-(event)
WHERE event.type = 'Event'
WITH DISTINCT event
RETURN collect(event) AS events;
"""
ids = self._normalize_temporal_ids(ids)
result = await self.query(event_collection_cypher, {"ids": ids})
events = []
if not result or not result[0] or not result[0][0]:
return [{"events": events}]
for node in result[0][0]:
props = json.loads(node["properties"])
event = {
"id": node["id"],
"name": node["name"],
"description": props.get("description"),
}
if props.get("location"):
event["location"] = props["location"]
events.append(event)
return [{"events": events}]
async def collect_time_ids(
self,
time_from: Optional[Timestamp] = None,
time_to: Optional[Timestamp] = None,
) -> List[str]:
"""
Collect IDs of Timestamp nodes between time_from and time_to.
Args:
graph_engine: Object exposing an async .query(query, params) -> list[dict]
time_from: Lower bound int (inclusive), optional
time_to: Upper bound int (inclusive), optional
Returns:
A list of timestamp node IDs.
"""
ids: List[str] = []
if time_from and time_to:
time_from = date_to_int(time_from)
time_to = date_to_int(time_to)
cypher = f"""
MATCH (n:Node)
WHERE n.type = 'Timestamp'
// Extract time_at from the JSON string and cast to INT64
WITH n, json_extract(n.properties, '$.time_at') AS t_str
WITH n,
CASE
WHEN t_str IS NULL OR t_str = '' THEN NULL
ELSE CAST(t_str AS INT64)
END AS t
WHERE t >= {time_from}
AND t <= {time_to}
RETURN n.id as id
"""
elif time_from:
time_from = date_to_int(time_from)
cypher = f"""
MATCH (n:Node)
WHERE n.type = 'Timestamp'
// Extract time_at from the JSON string and cast to INT64
WITH n, json_extract(n.properties, '$.time_at') AS t_str
WITH n,
CASE
WHEN t_str IS NULL OR t_str = '' THEN NULL
ELSE CAST(t_str AS INT64)
END AS t
WHERE t >= {time_from}
RETURN n.id as id
"""
elif time_to:
time_to = date_to_int(time_to)
cypher = f"""
MATCH (n:Node)
WHERE n.type = 'Timestamp'
// Extract time_at from the JSON string and cast to INT64
WITH n, json_extract(n.properties, '$.time_at') AS t_str
WITH n,
CASE
WHEN t_str IS NULL OR t_str = '' THEN NULL
ELSE CAST(t_str AS INT64)
END AS t
WHERE t <= {time_to}
RETURN n.id as id
"""
else:
return ids
time_nodes = await self.query(cypher)
time_ids_list = [item[0] for item in time_nodes]
return time_ids_list
async def get_triplets_batch(self, offset: int, limit: int) -> list[dict[str, Any]]:
"""
Retrieve a batch of triplets (start_node, relationship, end_node) from the graph.
Parameters:
-----------
- offset (int): Number of triplets to skip before returning results.
- limit (int): Maximum number of triplets to return.
Returns:
--------
- list[dict[str, Any]]: A list of triplets, where each triplet is a dictionary
with keys: 'start_node', 'relationship_properties', 'end_node'.
Raises:
-------
- ValueError: If offset or limit are negative.
- Exception: Re-raises any exceptions from query execution.
"""
if offset < 0:
raise ValueError(f"Offset must be non-negative, got {offset}")
if limit < 0:
raise ValueError(f"Limit must be non-negative, got {limit}")
query = """
MATCH (start_node:Node)-[relationship:EDGE]->(end_node:Node)
RETURN {
start_node: {
id: start_node.id,
name: start_node.name,
type: start_node.type,
properties: start_node.properties
},
relationship_properties: {
relationship_name: relationship.relationship_name,
properties: relationship.properties
},
end_node: {
id: end_node.id,
name: end_node.name,
type: end_node.type,
properties: end_node.properties
}
} AS triplet
SKIP $offset LIMIT $limit
"""
try:
results = await self.query(query, {"offset": offset, "limit": limit})
except Exception as e:
logger.error(f"Failed to execute triplet query: {str(e)}")
logger.error(f"Query: {query}")
logger.error(f"Parameters: offset={offset}, limit={limit}")
raise
triplets = []
for idx, row in enumerate(results):
try:
if not row or len(row) == 0:
logger.warning(f"Skipping empty row at index {idx} in triplet batch")
continue
if not isinstance(row[0], dict):
logger.warning(
f"Skipping invalid row at index {idx}: expected dict, got {type(row[0])}"
)
continue
triplet = row[0]
if "start_node" not in triplet:
logger.warning(f"Skipping triplet at index {idx}: missing 'start_node' key")
continue
if not isinstance(triplet["start_node"], dict):
logger.warning(f"Skipping triplet at index {idx}: 'start_node' is not a dict")
continue
triplet["start_node"] = self._parse_node_properties(triplet["start_node"].copy())
if "relationship_properties" not in triplet:
logger.warning(
f"Skipping triplet at index {idx}: missing 'relationship_properties' key"
)
continue
if not isinstance(triplet["relationship_properties"], dict):
logger.warning(
f"Skipping triplet at index {idx}: 'relationship_properties' is not a dict"
)
continue
rel_props = triplet["relationship_properties"].copy()
relationship_name = rel_props.get("relationship_name") or ""
if rel_props.get("properties"):
try:
parsed_props = json.loads(rel_props["properties"])
if isinstance(parsed_props, dict):
rel_props.update(parsed_props)
del rel_props["properties"]
else:
logger.warning(
f"Parsed relationship properties is not a dict for triplet at index {idx}"
)
except (json.JSONDecodeError, TypeError) as e:
logger.warning(
f"Failed to parse relationship properties JSON for triplet at index {idx}: {e}"
)
rel_props["relationship_name"] = relationship_name
triplet["relationship_properties"] = rel_props
if "end_node" not in triplet:
logger.warning(f"Skipping triplet at index {idx}: missing 'end_node' key")
continue
if not isinstance(triplet["end_node"], dict):
logger.warning(f"Skipping triplet at index {idx}: 'end_node' is not a dict")
continue
triplet["end_node"] = self._parse_node_properties(triplet["end_node"].copy())
triplets.append(triplet)
except Exception as e:
logger.error(f"Error processing triplet at index {idx}: {e}", exc_info=True)
continue
return triplets