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

380 lines
15 KiB
Python

"""Semaphore-backed dataset queue with per-task, per-dataset slot tracking.
Each distinct dataset a task touches via :func:`set_database_global_context_variables`
(which calls :meth:`DatasetQueue.ensure_slot` under the hood) takes its own
slot against the shared budget.
Ref-counting model (per (task, dataset)):
Repeated :meth:`DatasetQueue.ensure_slot` calls for the same ``(task, dataset)``
bump a per-entry depth counter rather than re-acquiring the semaphore. The
corresponding :meth:`DatasetQueue.release_slot_for` (async) decrements that
counter; the underlying semaphore slot is freed only when the counter hits
zero. When the last holder across all tasks exits, subprocess engines are
torn down via :meth:`DatasetQueue._teardown_subprocess_engines`. This
makes nested ``async with set_database_global_context_variables(D, u)`` scopes
safe — an inner exit never steals an outer holder's slot.
Task-end cleanup is a safety net: when the current task finishes, every entry
still in ``_task_slots`` is force-released regardless of depth. This covers
``await``-style callers that never decrement, so long-lived task slots still
clean up correctly.
Re-entrancy rules:
* Same task + same dataset → depth counter increments; no new acquire.
* Same task + different dataset → acquire an additional slot (may block).
* Different task (e.g. a child task that inherited the ContextVar) → treated
as a fresh task; acquires its own independent slot.
Configuration:
* ``DATASET_QUEUE_ENABLED`` — env var. Truthy values enable the queue.
* ``DATASET_QUEUE_MAX_CONCURRENT`` — env var. Defaults to ``DATABASE_MAX_LRU_CACHE_SIZE`` for a safe baseline
"""
from __future__ import annotations
import asyncio
import os
from contextlib import asynccontextmanager
from typing import Any, Callable, Dict, Set
from cognee.shared.lru_cache import DATABASE_MAX_LRU_CACHE_SIZE
from cognee.shared.logging_utils import get_logger
logger = get_logger("DatasetQueue")
# Recognised truthy values for ``DATASET_QUEUE_ENABLED``.
TRUE_VALUES = frozenset({"1", "true", "yes", "on", "y", "t"})
class DatasetQueueSettings:
"""Effective runtime settings for the dataset queue."""
__slots__ = ("enabled", "max_concurrent")
def __init__(self, enabled: bool, max_concurrent: int) -> None:
self.enabled = enabled
self.max_concurrent = max_concurrent
def get_dataset_queue_settings() -> DatasetQueueSettings:
"""Return effective settings. Test mock seam."""
raw = os.getenv("DATASET_QUEUE_ENABLED", "true").strip().lower()
enabled = raw in TRUE_VALUES
max_concurrent = os.getenv("DATASET_QUEUE_MAX_CONCURRENT", None)
if not max_concurrent:
# Default to the same max concurrency as the LRU cache size, which is a reasonable baseline for a shared resource limit.
max_concurrent = int(DATABASE_MAX_LRU_CACHE_SIZE)
return DatasetQueueSettings(enabled=enabled, max_concurrent=max_concurrent)
def _make_release(semaphore: asyncio.Semaphore) -> Callable[[], None]:
"""Build an idempotent releaser for one semaphore acquisition.
The task-end done-callback and any direct cleanup path both call the
same returned function; the ``released`` flag guarantees the underlying
``semaphore.release()`` fires exactly once.
"""
released = False
def _release() -> None:
nonlocal released
if not released:
released = True
semaphore.release()
return _release
class SlotEntry:
"""A single acquired slot with a nesting depth counter."""
__slots__ = ("release", "depth")
def __init__(self, release: Callable[[], None], depth: int = 1) -> None:
self.release = release
self.depth = depth
class DatasetQueue:
"""Concurrency limiter for dataset-level operations.
When ``enabled`` is ``False`` all methods are pass-throughs.
"""
def __init__(self, enabled: bool, max_concurrent: int) -> None:
safe_max = int(max_concurrent)
if safe_max < 1:
self._enabled: bool = False
return
self._enabled: bool = bool(enabled)
self._max_concurrent: int = safe_max
self._semaphore: asyncio.Semaphore = asyncio.Semaphore(safe_max)
# Per-task slot registry: task_id → { slot_key → SlotEntry }.
# ``slot_key`` is ``"ds:<dataset_id>"`` for ``ensure_slot`` and
# ``"acquire:<unique>"`` for ``acquire()``. A task may hold multiple
# entries; all are released together when the task finishes.
self._task_slots: Dict[int, Dict[str, SlotEntry]] = {}
# Track which tasks already have a done-callback registered so we
# don't register multiple cleanup handlers for a single task.
self._registered_tasks: Set[int] = set()
# ------------------------------------------------------ active datasets
def active_dataset_ids(self) -> set:
"""Dataset ids (as strings) currently holding at least one slot.
Read-only snapshot for engine-cache pinning: capacity eviction must
not close an engine whose dataset an admitted pipeline is still
using. Safe to call from any thread — iterates over ``list()``
snapshots, so concurrent slot changes on the event loop can't break
the iteration; the result is advisory-fresh by nature.
"""
if not self._enabled:
return set()
active = set()
for slots in list(self._task_slots.values()):
for slot_key in list(slots):
# "ds:<none>" tracks dataset-less operations; it can never
# correspond to a database name, so don't report it.
if slot_key.startswith("ds:") and slot_key != "ds:<none>":
active.add(slot_key[3:])
return active
# ---------------------------------------------------- task cleanup setup
def _ensure_task_cleanup_registered(self, task: asyncio.Task, task_id: int) -> None:
"""Idempotently register a done-callback that releases all of
this task's slots when it finishes.
"""
if task_id in self._registered_tasks:
# Already registered; ensure the slot dict exists (may have been
# cleaned up by a previous scope-end release that emptied it).
self._task_slots.setdefault(task_id, {})
return
self._registered_tasks.add(task_id)
# Create the slot dict for this task up front so callers can write
# to it immediately after this method returns.
self._task_slots.setdefault(task_id, {})
def _release_all_on_done(_t: asyncio.Task, _tid: int = task_id) -> None:
slots = self._task_slots.pop(_tid, {})
self._registered_tasks.discard(_tid)
for entry in slots.values():
# Backstop: release whatever's left regardless of depth.
entry.release()
task.add_done_callback(_release_all_on_done)
# ------------------------------------------------------------ ensure_slot
async def ensure_slot(self, dataset_id) -> None:
"""Acquire (or bump the depth of) a slot for (current task, ``dataset_id``).
Rules:
* If the current task already has an entry for the same dataset →
increment its depth counter; do **not** re-acquire the semaphore.
* Otherwise → acquire a fresh slot; it will be released when the
matching :meth:`release_slot_for` drops the counter to zero, or
unconditionally at task-end as a backstop.
This is the mechanism behind
:func:`cognee.context_global_variables.set_database_global_context_variables`:
every call there passes through here.
"""
if not self._enabled:
return
task = asyncio.current_task()
if task is None:
# Rare: no running task, no way to track ownership.
raise RuntimeError("DatasetQueue.ensure_slot called outside of a running task")
task_id = id(task)
ds_key = f"ds:{dataset_id}" if dataset_id is not None else "ds:<none>"
entry = self._task_slots.get(task_id, {}).get(ds_key)
if entry is not None:
# Same task, same dataset — re-entrant: bump depth, do NOT re-acquire.
entry.depth += 1
return
# Acquire a fresh slot for this (task, dataset).
logger.debug("Task %d acquiring dataset queue slot for dataset_id=%s", task_id, dataset_id)
await self._semaphore.acquire()
release = _make_release(self._semaphore)
self._ensure_task_cleanup_registered(task, task_id)
# After registration, the task entry exists in ``_task_slots``.
self._task_slots[task_id][ds_key] = SlotEntry(release, depth=1)
# ----------------------------------------- subprocess engine teardown
async def _teardown_subprocess_engines(self) -> None:
"""Evict and close subprocess-mode engines so DB file locks are released.
Reads the current task's ContextVar-based graph/vector config to
identify which cached engines to tear down. Lazy imports avoid
circular dependencies at module load time.
"""
from cognee.infrastructure.databases.graph.config import get_graph_context_config
from cognee.infrastructure.databases.vector.config import get_vectordb_context_config
g_cfg = get_graph_context_config()
if g_cfg.get("graph_database_subprocess_enabled"):
from cognee.infrastructure.databases.graph.get_graph_engine import (
create_graph_engine,
evict_graph_engine,
is_graph_engine_cached,
)
if is_graph_engine_cached(**g_cfg):
engine = create_graph_engine(**g_cfg)
evict_graph_engine(**g_cfg)
if hasattr(engine, "close"):
await engine.close()
v_cfg = get_vectordb_context_config()
if v_cfg.get("vector_db_subprocess_enabled"):
from cognee.infrastructure.databases.vector.create_vector_engine import (
create_vector_engine,
evict_vector_engine,
is_vector_engine_cached,
)
if is_vector_engine_cached(**v_cfg):
engine = create_vector_engine(**v_cfg)
evict_vector_engine(**v_cfg)
if hasattr(engine, "close"):
await engine.close()
# -------------------------------------------------------- release_slot_for
async def release_slot_for(self, dataset_id: Any = None) -> None:
"""Decrement this task's depth counter for ``dataset_id`` and release
the semaphore slot when the counter reaches zero.
When this is the very last holder across all tasks for
``dataset_id``, subprocess engines are torn down via
:meth:`_teardown_subprocess_engines` while the semaphore slot is
still held so that no new operation can observe a half-torn-down
resource. The slot is freed afterwards regardless of whether the
teardown succeeds or raises.
No-op when the queue is disabled, there is no running task, or the
current task does not hold a slot for ``dataset_id``.
"""
if not self._enabled:
return
task = asyncio.current_task()
if task is None:
await self._teardown_subprocess_engines()
return
task_id = id(task)
ds_key = f"ds:{dataset_id}" if dataset_id is not None else "ds:<none>"
entry = self._task_slots.get(task_id, {}).get(ds_key)
if entry is None:
return
entry.depth -= 1
if entry.depth > 0:
return
# About to fully release. Tear down subprocess engines only when
# no other task holds the same dataset. The cross-task scan and
# the cache eviction are both synchronous, so no other task can
# interleave between the check and the eviction. The subsequent
# ``await engine.close()`` happens after eviction, so new callers
# already get a fresh engine by that point.
try:
other_holds = any(
ds_key in slots for tid, slots in self._task_slots.items() if tid != task_id
)
if not other_holds:
await self._teardown_subprocess_engines()
finally:
self._task_slots.get(task_id, {}).pop(ds_key, None)
logger.debug(
"Task %d releasing dataset queue slot for dataset_id=%s",
task_id,
dataset_id,
)
entry.release()
# ---------------------------------------------------------------- acquire
@asynccontextmanager
async def acquire(self):
"""Scoped slot for call sites without a natural dataset id.
Used by ``visualize_graph`` and the access-control-disabled search
branch — neither has a per-call dataset to key on.
Re-entrant: if the current task already holds *any* slot (via
``ensure_slot`` or a prior ``acquire``), this is a pass-through.
Otherwise a fresh slot is taken and released at block exit.
Unlike ``ensure_slot``/``release_slot_for``, ``acquire`` is always
strictly scoped (enter/exit pair in a single ``async with``), so no
depth counter is needed — the entry is popped and released on exit.
"""
if not self._enabled:
yield
return
task = asyncio.current_task()
task_id = id(task) if task is not None else None
# Re-entrant: if this task is already holding at least one slot,
# don't take another.
if (
task_id is not None and self._task_slots.get(task_id) # non-empty dict
):
yield
return
await self._semaphore.acquire()
release = _make_release(self._semaphore)
slot_key = None
if task_id is not None:
self._ensure_task_cleanup_registered(task, task_id)
slot_key = f"acquire:{id(release)}"
# Store a SlotEntry for type uniformity; acquire() is scoped
# and doesn't use the depth counter.
self._task_slots[task_id][slot_key] = SlotEntry(release, depth=1)
try:
yield
finally:
# Release on scope exit — we don't wait for task end for these.
if task_id is not None and slot_key is not None:
slots = self._task_slots.get(task_id)
if slots is not None:
slots.pop(slot_key, None)
release()
def dataset_queue() -> DatasetQueue:
"""Return the process-wide :class:`DatasetQueue` singleton."""
if dataset_queue._instance is not None: # type: ignore[attr-defined]
return dataset_queue._instance # type: ignore[attr-defined]
settings = get_dataset_queue_settings()
instance = DatasetQueue(
enabled=settings.enabled,
max_concurrent=settings.max_concurrent,
)
dataset_queue._instance = instance # type: ignore[attr-defined]
return instance
# Singleton storage — reset between tests by the reset_queue_singleton fixture.
dataset_queue._instance = None # type: ignore[attr-defined]