Files
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

611 lines
25 KiB
Python

"""Factory function to get the appropriate graph client based on the graph type."""
import inspect
import os
from numbers import Number
from cognee.infrastructure.databases.dataset_queue.pinning import dataset_queue_pin_predicate
from cognee.infrastructure.databases.utils.closing_lru_cache import closing_lru_cache
from cognee.shared.lru_cache import DATABASE_MAX_LRU_CACHE_SIZE
from cognee.shared.logging_utils import get_logger
from .kuzu.adapter import DEFAULT_KUZU_BUFFER_POOL_SIZE, DEFAULT_KUZU_MAX_DB_SIZE
from .config import get_graph_context_config
from .graph_db_interface import GraphDBInterface
from .supported_databases import supported_databases
logger = get_logger("GraphEngine")
def _normalize_graph_database_provider(provider: str) -> str:
return provider.lower() if isinstance(provider, str) else provider
def _get_create_graph_engine_optional_defaults() -> dict:
"""Return default values for optional create_graph_engine parameters."""
signature = inspect.signature(create_graph_engine)
return {
name: parameter.default
for name, parameter in signature.parameters.items()
if parameter.default is not inspect.Parameter.empty
}
def _normalize_optional_create_graph_engine_params(params: dict) -> dict:
"""
Normalize optional create_graph_engine parameters:
- replace None with the function defaults
- convert numeric graph_database_port values to string
"""
defaults = _get_create_graph_engine_optional_defaults()
normalized = dict(params)
for key, default_value in defaults.items():
if normalized.get(key) is None:
normalized[key] = default_value
if isinstance(normalized.get("graph_database_port"), Number) and not isinstance(
normalized["graph_database_port"], bool
):
normalized["graph_database_port"] = str(normalized["graph_database_port"])
if not normalized.get("graph_dataset_database_handler"):
normalized["graph_dataset_database_handler"] = os.getenv(
"GRAPH_DATASET_DATABASE_HANDLER", "ladybug"
)
return normalized
class _GraphEngineHandle:
"""Stable reference to the current graph engine that survives cache invalidation.
Database engine instances are cached via ``closing_lru_cache``. Several
operations invalidate that cache — ``prune_system`` calls ``cache_clear()``,
``delete_dataset`` evicts individual entries, and the ``__aexit__`` of
``set_database_global_context_variables`` evicts subprocess-mode engines to
release file locks. When an entry is evicted the underlying adapter is
closed, so any direct proxy reference becomes a dead object that raises
"adapter is closed" on use.
This handle solves the problem by deferring resolution: every attribute
access calls ``create_graph_engine(**config)`` which either returns the
existing cached proxy (fast path) or transparently creates a fresh adapter
if the old one was evicted (recovery path). Code that stores the return
value of ``get_graph_engine()`` — even across ``cognify``, ``search``,
``prune``, or ``delete`` calls — always reaches a live adapter without
needing to re-call ``get_graph_engine()``.
For adapters that expose ``initialize()`` (Postgres, Neo4j), the handle
tracks which engine proxy was last initialized and re-runs the idempotent
schema setup when the underlying engine changes.
Known limitation (subprocess + exclusive file lock, e.g. Ladybug): the cache
leases a single shared proxy per entry, so two concurrently-held handles for
the same DB path pin the *same* proxy. If that entry is evicted while an
idle second handle keeps holding the proxy, the old worker's close stays
deferred (it does not release the file lock) until that holder lets go or
is garbage-collected. Creators for the same path deliberately do NOT wait
for such a deferred close — an idle holder can pin it indefinitely, and
waiting on it from a handle's own re-resolution self-deadlocks (this hung
CI) — so a fresh engine relies on the worker's open-retry
(``SUBPROCESS_OPEN_LOCK_RETRIES``) for the overlap. Once a close is
actually in flight, creators wait for it deterministically; the primary
multi-tenant teardown path (``dataset_queue._teardown_subprocess_engines``)
also ``await``s ``engine.close()`` to completion before any re-creation.
"""
__slots__ = ("_config", "_last_initialized_id", "_pinned")
def __init__(self, config: dict):
object.__setattr__(self, "_config", config)
object.__setattr__(self, "_last_initialized_id", None)
# Pinned leased engine proxy. Holding it avoids re-entering the cache on
# every attribute access (and the create-vs-close race that re-entry
# caused). It is dropped + re-resolved once the pin is no longer the
# live cache entry (see ``_pin_is_live``) so prune/delete eviction still
# recovers a fresh engine instead of keeping an evicted DB worker alive.
object.__setattr__(self, "_pinned", None)
@staticmethod
def _pin_is_live(engine) -> bool:
"""Whether a pinned engine is still the live cached value and safe to
reuse. A leased proxy whose entry was evicted must be released so its
deferred close can run (otherwise a pinned handle would keep an evicted
Ladybug worker alive holding the file lock, blocking a new worker)."""
active = getattr(engine, "_leased_entry_active", None)
if active is not None:
try:
if not active():
return False
except Exception:
# ``_leased_entry_active`` is two attribute reads and should
# never raise; if it does, surface it — then treat the pin as
# stale, which safely re-resolves through the cache.
logger.warning(
"Unexpected error while checking pinned engine liveness", exc_info=True
)
return False
# Subprocess adapters latch ``_permanently_closed`` on close.
if getattr(engine, "_permanently_closed", False):
return False
return True
def _release_stale_pin(self, pinned) -> None:
"""Drop the stale pinned proxy BEFORE re-resolving a replacement.
Critical for the lock race: the pinned proxy is (typically) the last
reference keeping an evicted adapter alive. Releasing it lets the
deferred close start — and a subprocess adapter's close runs off-loop,
releasing the on-disk file lock — *before* a new worker opens the same
path. Holding the pin across the re-resolution would keep the old worker
alive and the new one would fail to take the lock.
"""
object.__setattr__(self, "_pinned", None)
del pinned
def _engine(self):
"""Synchronous resolution used on the hot attribute-access path. Reuses
the pin when live; otherwise drops it and re-resolves through the (sync)
cache. A mid-flow re-resolution can't await an in-flight close — the
off-loop close + worker open-retry backstop cover that residual race."""
pinned = self._pinned
if pinned is not None and self._pin_is_live(pinned):
return pinned
if pinned is not None:
self._release_stale_pin(pinned)
pinned = None
engine = create_graph_engine(**self._config)
object.__setattr__(self, "_pinned", engine)
return engine
async def _aengine(self):
"""Async resolution used at initialization. Goes through the cache's
async acquisition path so it waits for any in-flight close of the same
key before constructing a new engine + pinning it."""
pinned = self._pinned
if pinned is not None and self._pin_is_live(pinned):
return pinned
if pinned is not None:
self._release_stale_pin(pinned)
pinned = None
engine = await acreate_graph_engine(**self._config)
object.__setattr__(self, "_pinned", engine)
return engine
async def _ensure_initialized(self):
engine = await self._aengine()
engine_id = id(engine)
if engine_id != self._last_initialized_id and hasattr(engine, "initialize"):
await engine.initialize()
object.__setattr__(self, "_last_initialized_id", engine_id)
@property
def __class__(self):
return self._engine().__class__
def __getattr__(self, name):
return getattr(self._engine(), name)
def __repr__(self):
return f"<GraphEngineHandle config={self._config!r}>"
async def get_graph_engine() -> GraphDBInterface:
"""Factory function to get the appropriate graph client based on the graph type."""
config = get_graph_context_config()
handle = _GraphEngineHandle(config)
await handle._ensure_initialized()
return handle
# def _make_pghybrid_adapter():
# """Build the uncached Postgres hybrid adapter used when
# ``USE_UNIFIED_PROVIDER=pghybrid``. Not cached — the caller owns it, matching
# the original inline behavior."""
# from .postgres.adapter import PostgresAdapter
# from cognee.infrastructure.databases.relational.get_relational_engine import (
# get_relational_engine,
# )
#
# return PostgresAdapter(connection_string=get_relational_engine().db_uri)
def _resolve_graph_engine_args(params: dict) -> tuple:
"""Normalize the engine parameters and return the positional argument tuple
passed to ``_create_graph_engine``.
Shared by the sync (:func:`create_graph_engine`) and async
(:func:`acreate_graph_engine`) entry points so both produce the *identical*
cache key (the positional tuple) — and so it matches the key built by
``evict_graph_engine`` / ``is_graph_engine_cached``.
"""
normalized = _normalize_optional_create_graph_engine_params(params)
return (
_normalize_graph_database_provider(params.get("graph_database_provider")),
params.get("graph_file_path"),
normalized["graph_database_url"],
normalized["graph_database_name"],
normalized["graph_database_username"],
normalized["graph_database_password"],
normalized["graph_database_host"],
normalized["graph_database_allow_anonymous"],
normalized["graph_database_port"],
normalized["graph_database_key"],
normalized["graph_dataset_database_handler"],
normalized["graph_database_subprocess_enabled"],
normalized["kuzu_num_threads"],
normalized["kuzu_buffer_pool_size"],
normalized["kuzu_max_db_size"],
)
def create_graph_engine(
graph_database_provider,
graph_file_path,
graph_database_url="",
graph_database_name="",
graph_database_username="",
graph_database_password="",
graph_database_host="",
graph_database_allow_anonymous=False,
graph_database_port="",
graph_database_key="",
graph_dataset_database_handler="",
graph_database_subprocess_enabled=False,
kuzu_num_threads=0,
kuzu_buffer_pool_size=DEFAULT_KUZU_BUFFER_POOL_SIZE,
kuzu_max_db_size=DEFAULT_KUZU_MAX_DB_SIZE,
):
"""
Wrapper function to call create graph engine with caching.
For a detailed description, see _create_graph_engine.
"""
# Check USE_UNIFIED_PROVIDER outside the cache so it's always re-read
# if os.environ.get("USE_UNIFIED_PROVIDER", "") == "pghybrid":
# return _make_pghybrid_adapter()
return _create_graph_engine(*_resolve_graph_engine_args(locals()))
async def acreate_graph_engine(**kwargs):
"""Async counterpart of :func:`create_graph_engine` that waits for any
in-flight close of the same cache key before constructing a new engine.
Used by ``get_graph_engine``'s handle at initialization so a freshly evicted
subprocess engine's worker has fully exited (releasing its file lock) before
a new worker opens the same DB path.
"""
# if os.environ.get("USE_UNIFIED_PROVIDER", "") == "pghybrid":
# return _make_pghybrid_adapter()
return await _create_graph_engine.acall(*_resolve_graph_engine_args(kwargs))
def evict_graph_engine(**kwargs) -> bool:
"""Evict a cached graph engine entry created via ``create_graph_engine``.
Mirrors ``create_graph_engine``'s normalization so the cache key
matches. Used by per-dataset deletion paths to drop the leased
adapter (and trigger its ``close()``) without disturbing the rest
of the cache.
Returns True if the entry existed.
"""
normalized = _normalize_optional_create_graph_engine_params(kwargs)
provider = _normalize_graph_database_provider(kwargs.get("graph_database_provider"))
return _create_graph_engine.cache_evict(
provider,
kwargs.get("graph_file_path"),
normalized["graph_database_url"],
normalized["graph_database_name"],
normalized["graph_database_username"],
normalized["graph_database_password"],
normalized["graph_database_host"],
normalized["graph_database_allow_anonymous"],
normalized["graph_database_port"],
normalized["graph_database_key"],
normalized["graph_dataset_database_handler"],
normalized["graph_database_subprocess_enabled"],
normalized["kuzu_num_threads"],
normalized["kuzu_buffer_pool_size"],
normalized["kuzu_max_db_size"],
)
def evict_graph_engines_for_database(graph_database_name: str) -> int:
"""Evict every cached graph engine bound to *graph_database_name*.
The same per-dataset database can be cached under multiple keys: the
dataset-handler creation key and the pipeline's context-config key differ
in ``graph_file_path`` and ``graph_dataset_database_handler``, so key-exact
``evict_graph_engine`` misses the pipeline's entry and leaves an engine
whose connection pool died with the dropped database. Per-dataset database
names are dataset UUIDs, so matching the name against key fields cannot
collide with other entries.
Returns the number of evicted entries.
"""
if not graph_database_name:
raise ValueError("graph_database_name must be a non-empty database name")
return _create_graph_engine.cache_evict_matching(graph_database_name=graph_database_name)
async def aevict_graph_engines_for_database(graph_database_name: str) -> int:
"""Evict every cached graph engine bound to *graph_database_name* and wait
until their IN-FLIGHT closes have completed (workers exited, file locks
released). Use before removing the database's files so a teardown that is
already running cannot race the removal.
A close still deferred behind a live caller proxy (an idle engine handle)
is NOT waited on — see the ``closing_lru_cache`` module docstring. In that
case files are removed under an engine that closes later; on POSIX the
unlinked files stay valid for the holder and the eventual close writes to
nowhere, which is acceptable for a dataset being deleted.
Returns the number of evicted entries.
"""
evicted = evict_graph_engines_for_database(graph_database_name)
await _create_graph_engine.cache_await_closed(graph_database_name=graph_database_name)
return evicted
def is_graph_engine_cached(**kwargs) -> bool:
"""Check whether a graph engine entry exists in the cache without creating."""
normalized = _normalize_optional_create_graph_engine_params(kwargs)
provider = _normalize_graph_database_provider(kwargs.get("graph_database_provider"))
return _create_graph_engine.cache_contains(
provider,
kwargs.get("graph_file_path"),
normalized["graph_database_url"],
normalized["graph_database_name"],
normalized["graph_database_username"],
normalized["graph_database_password"],
normalized["graph_database_host"],
normalized["graph_database_allow_anonymous"],
normalized["graph_database_port"],
normalized["graph_database_key"],
normalized["graph_dataset_database_handler"],
normalized["graph_database_subprocess_enabled"],
normalized["kuzu_num_threads"],
normalized["kuzu_buffer_pool_size"],
normalized["kuzu_max_db_size"],
)
@closing_lru_cache(
maxsize=DATABASE_MAX_LRU_CACHE_SIZE,
pinned_predicate=dataset_queue_pin_predicate("graph_database_name"),
)
def _create_graph_engine(
graph_database_provider,
graph_file_path,
graph_database_url="",
graph_database_name="",
graph_database_username="",
graph_database_password="",
graph_database_host="",
graph_database_allow_anonymous=False,
graph_database_port="",
graph_database_key="",
graph_dataset_database_handler="",
graph_database_subprocess_enabled=False,
kuzu_num_threads=0,
kuzu_buffer_pool_size=DEFAULT_KUZU_BUFFER_POOL_SIZE,
kuzu_max_db_size=DEFAULT_KUZU_MAX_DB_SIZE,
):
"""
Create a graph engine based on the specified provider type.
This factory function initializes and returns the appropriate graph client depending on
the database provider specified. It validates required parameters and raises an
EnvironmentError if any are missing for the respective provider implementations.
Parameters:
-----------
- graph_database_provider: The type of graph database provider to use (e.g., neo4j, falkor, ladybug).
- graph_database_url: The URL for the graph database instance. Required for neo4j and falkordb providers.
- graph_database_username: The username for authentication with the graph database.
Required for neo4j provider.
- graph_database_password: The password for authentication with the graph database.
Required for neo4j provider.
- graph_database_port: The port number for the graph database connection. Required
for the falkordb provider
- graph_file_path: The filesystem path to the graph file. Required for the ladybug
provider.
Returns:
--------
Returns an instance of the appropriate graph adapter depending on the provider type
specified.
"""
if graph_database_provider in supported_databases:
adapter = supported_databases[graph_database_provider]
return adapter(
graph_database_url=graph_database_url,
graph_database_username=graph_database_username,
graph_database_password=graph_database_password,
graph_database_port=graph_database_port,
graph_database_key=graph_database_key,
database_name=graph_database_name,
)
if graph_database_provider == "neo4j":
if not graph_database_url:
raise EnvironmentError("Missing required Neo4j URL.")
from .neo4j_driver.adapter import Neo4jAdapter
return Neo4jAdapter(
graph_database_url=graph_database_url,
graph_database_username=graph_database_username or None,
graph_database_password=graph_database_password or None,
graph_database_name=graph_database_name or None,
graph_database_allow_anonymous=graph_database_allow_anonymous,
)
elif graph_database_provider == "postgres":
from cognee.context_global_variables import backend_access_control_enabled
if backend_access_control_enabled():
if not (
graph_database_host
and graph_database_port
and graph_database_username
and graph_database_password
):
raise EnvironmentError("Missing required Postgres graph credentials.")
connection_string: str = (
f"postgresql+asyncpg://{graph_database_username}:{graph_database_password}"
f"@{graph_database_host}:{graph_database_port}/{graph_database_name}"
)
else:
if (
graph_database_port
and graph_database_username
and graph_database_password
and graph_database_host
and graph_database_name
):
connection_string: str = (
f"postgresql+asyncpg://{graph_database_username}:{graph_database_password}"
f"@{graph_database_host}:{graph_database_port}/{graph_database_name}"
)
else:
from cognee.infrastructure.databases.relational import get_relational_config
logger.warning(
"Postgres graph credentials are not fully configured; "
"falling back to the relational database configuration. "
"Set GRAPH_DATABASE_HOST/PORT/USERNAME/PASSWORD/NAME explicitly "
"to avoid this fallback."
)
relational_config = get_relational_config()
db_username = relational_config.db_username
db_password = relational_config.db_password
db_host = relational_config.db_host
db_port = relational_config.db_port
db_name = relational_config.db_name
if not (db_host and db_port and db_name and db_username and db_password):
raise EnvironmentError("Missing required Postgres graph credentials!")
connection_string: str = (
f"postgresql+asyncpg://{db_username}:{db_password}"
f"@{db_host}:{db_port}/{db_name}"
)
from .postgres.adapter import PostgresAdapter
return PostgresAdapter(connection_string=connection_string)
elif graph_database_provider in ("ladybug", "kuzu"):
if not graph_file_path:
raise EnvironmentError("Missing required Ladybug database path.")
from .ladybug.adapter import LadybugAdapter
if graph_database_subprocess_enabled:
return LadybugAdapter.create_subprocess(
db_path=graph_file_path,
kuzu_num_threads=kuzu_num_threads,
kuzu_buffer_pool_size=kuzu_buffer_pool_size,
kuzu_max_db_size=kuzu_max_db_size,
)
return LadybugAdapter(
db_path=graph_file_path,
kuzu_num_threads=kuzu_num_threads,
kuzu_buffer_pool_size=kuzu_buffer_pool_size,
kuzu_max_db_size=kuzu_max_db_size,
)
elif graph_database_provider in ("ladybug-remote", "kuzu-remote"):
if not graph_database_url:
raise EnvironmentError("Missing required Ladybug remote URL.")
from .ladybug.remote_ladybug_adapter import RemoteLadybugAdapter
return RemoteLadybugAdapter(
api_url=graph_database_url,
username=graph_database_username,
password=graph_database_password,
)
elif graph_database_provider == "neptune":
try:
from langchain_aws import NeptuneAnalyticsGraph
except ImportError:
raise ImportError(
"langchain_aws is not installed. Please install it with 'pip install langchain_aws'"
)
if not graph_database_url:
raise EnvironmentError("Missing Neptune endpoint.")
from .neptune_driver.adapter import NeptuneGraphDB, NEPTUNE_ENDPOINT_URL
if not graph_database_url.startswith(NEPTUNE_ENDPOINT_URL):
raise ValueError(
f"Neptune endpoint must have the format {NEPTUNE_ENDPOINT_URL}<GRAPH_ID>"
)
graph_identifier = graph_database_url.replace(NEPTUNE_ENDPOINT_URL, "")
return NeptuneGraphDB(
graph_id=graph_identifier,
)
elif graph_database_provider == "neptune_analytics":
"""
Creates a graph DB from config
We want to use a hybrid (graph & vector) DB and we should update this
to make a single instance of the hybrid configuration (with embedder)
instead of creating the hybrid object twice.
"""
try:
from langchain_aws import NeptuneAnalyticsGraph
except ImportError:
raise ImportError(
"langchain_aws is not installed. Please install it with 'pip install langchain_aws'"
)
if not graph_database_url:
raise EnvironmentError("Missing Neptune endpoint.")
from ..hybrid.neptune_analytics.NeptuneAnalyticsAdapter import (
NeptuneAnalyticsAdapter,
NEPTUNE_ANALYTICS_ENDPOINT_URL,
)
if not graph_database_url.startswith(NEPTUNE_ANALYTICS_ENDPOINT_URL):
raise ValueError(
f"Neptune endpoint must have the format '{NEPTUNE_ANALYTICS_ENDPOINT_URL}<GRAPH_ID>'"
)
graph_identifier = graph_database_url.replace(NEPTUNE_ANALYTICS_ENDPOINT_URL, "")
return NeptuneAnalyticsAdapter(
graph_id=graph_identifier,
)
all_providers = list(supported_databases.keys()) + [
"neo4j",
"ladybug",
"ladybug-remote",
"kuzu",
"kuzu-remote",
"postgres",
"neptune",
"neptune_analytics",
]
raise EnvironmentError(
f"Unsupported graph database provider: {graph_database_provider}. "
f"Supported providers are: {', '.join(all_providers)}"
)