""" OpenSearch Storage Implementation for LightRAG This module provides OpenSearch-based storage backends for LightRAG, including KV storage, document status storage, graph storage, and vector storage. Requirements: - opensearch-py >= 3.0.0 - OpenSearch 3.x or higher with k-NN plugin enabled """ import os import re import ssl as ssl_module import time import asyncio from dataclasses import dataclass, field from typing import Any, AsyncIterator, Union, final import numpy as np import configparser from ..base import ( BaseGraphStorage, BaseKVStorage, BaseVectorStorage, DocProcessingStatus, DocStatus, DocStatusStorage, ) from ..utils import ( logger, compute_mdhash_id, _cooperative_yield, merge_source_ids, validate_workspace, ) from ..types import KnowledgeGraph, KnowledgeGraphNode, KnowledgeGraphEdge from ..constants import GRAPH_FIELD_SEP, DEFAULT_QUERY_PRIORITY from ..kg.shared_storage import get_data_init_lock, get_namespace_lock import pipmaster as pm if not pm.is_installed("opensearch-py"): pm.install("opensearch-py") from opensearchpy import AsyncOpenSearch, helpers # type: ignore from opensearchpy.exceptions import ( # type: ignore OpenSearchException, NotFoundError, RequestError, ConflictError, ) config = configparser.ConfigParser() config.read("config.ini", "utf-8") def _get_opensearch_env(key, fallback): cfg_key = key.replace("OPENSEARCH_", "").lower() return os.environ.get(key, config.get("opensearch", cfg_key, fallback=fallback)) def _get_index_number_of_shards() -> int: return int(_get_opensearch_env("OPENSEARCH_NUMBER_OF_SHARDS", "1")) def _get_index_number_of_replicas() -> int: return int(_get_opensearch_env("OPENSEARCH_NUMBER_OF_REPLICAS", "0")) def _sanitize_index_name(name: str) -> str: """Sanitize a string to be a valid OpenSearch index name.""" sanitized = re.sub(r"[^a-z0-9_-]", "_", name.lower()) if sanitized and sanitized[0] in "-_+": sanitized = "x" + sanitized return sanitized # HTTP statuses that indicate a transient failure where retrying makes sense: # request timeout, rate limit, and the standard 5xx server-error range. # A missing status (None) typically means a network or parse error before the # server responded, which is also retriable. _RETRYABLE_BULK_STATUSES: frozenset[int] = frozenset({408, 429, 500, 502, 503, 504}) # Cap the length of error summaries dumped to logs so a multi-MB mapping # explanation can't flood the log file. _BULK_ERROR_SUMMARY_MAX_LEN = 200 @dataclass(frozen=True) class _FailedBulkOp: """Structured representation of a non-retryable per-action bulk failure.""" op: str doc_id: str status: int | None error: str @dataclass class _PendingVectorDoc: """Buffered vector upsert waiting for embedding and/or bulk flush.""" source: dict[str, Any] content: str vector: list[float] | None = None def _summarize_bulk_error(error: Any) -> str: """Turn an opensearch-py per-action ``error`` payload into a short string. The field may be a string, dict (``{"type": ..., "reason": ...}``) or something else entirely. We prefer ``reason`` / ``type`` from dicts to keep the log readable. """ if error is None: return "" if isinstance(error, str): summary = error elif isinstance(error, dict): reason = error.get("reason") or error.get("type") summary = reason if isinstance(reason, str) else repr(error) else: summary = repr(error) if len(summary) > _BULK_ERROR_SUMMARY_MAX_LEN: summary = summary[: _BULK_ERROR_SUMMARY_MAX_LEN - 3] + "..." return summary def _extract_bulk_failed_ids( failed: list[Any] | None, ) -> tuple[set[str], list[_FailedBulkOp]]: """Split an opensearch-py bulk ``failed`` list into retryable / dead ops. ``async_bulk(raise_on_error=False)`` returns ``(success, failed)`` where ``failed`` is a list of per-action error dicts shaped like:: {"index": {"_id": "...", "status": 500, "error": {...}}} {"delete": {"_id": "...", "status": 404, ...}} {"create": {"_id": "...", "status": 409, ...}} Returns ``(retryable, non_retryable)``: * ``retryable`` — ``set[str]`` of ids that should be retried on the next flush (408 / 429 / 5xx, plus a missing status which usually means a network-level failure before the server responded). * ``non_retryable`` — ``list[_FailedBulkOp]`` of permanent failures (most 4xx, mapping errors, etc.) carrying op-name, id, status and a short ``error`` summary so callers can log meaningful context. ``404`` on a delete is treated as success-equivalent and dropped from both sets. Unrecognised or malformed entries are skipped so a stray dict shape never crashes the flush path. """ retryable: set[str] = set() non_retryable: list[_FailedBulkOp] = [] if not failed: return retryable, non_retryable for entry in failed: if not isinstance(entry, dict): continue for op_name, op_payload in entry.items(): if not isinstance(op_payload, dict): continue doc_id = op_payload.get("_id") if not isinstance(doc_id, str): continue status = op_payload.get("status") # Deleting a missing doc is not a real failure -- the row is # already gone, so we don't carry it forward on every flush. if op_name == "delete" and status == 404: continue if status is None or status in _RETRYABLE_BULK_STATUSES: retryable.add(doc_id) else: non_retryable.append( _FailedBulkOp( op=op_name, doc_id=doc_id, status=status if isinstance(status, int) else None, error=_summarize_bulk_error(op_payload.get("error")), ) ) return retryable, non_retryable # Flush-time bulk batching limits. opensearch-py's helpers.async_bulk already # splits a request by payload-byte budget (primary) and record count # (secondary) via _ActionChunker -- semantically identical to MongoDB's # _chunk_by_budget. We only expose those two limiter dimensions as env vars and # pass them through as `max_chunk_bytes` / `chunk_size`, mirroring the MONGO_* # knobs (lightrag/kg/mongo_impl.py) so behaviour stays consistent across # backends. Defaults are tuned for OpenSearch: 100 MiB sits at the typical # `http.max_content_length` ceiling, while the record caps match Mongo's. DEFAULT_OPENSEARCH_UPSERT_MAX_PAYLOAD_BYTES = 100 * 1024 * 1024 # 100 MiB DEFAULT_OPENSEARCH_UPSERT_MAX_RECORDS_PER_BATCH = 128 DEFAULT_OPENSEARCH_DELETE_MAX_RECORDS_PER_BATCH = 1000 # Sentinel "effectively unbounded" byte budget when payload splitting is # disabled (env value <= 0). async_bulk needs a positive int here, so we use a # large finite value in place of Mongo's float("inf"). _OPENSEARCH_UNBOUNDED_PAYLOAD_BYTES = 1 << 62 def _resolve_bulk_batch_limits() -> tuple[int, int, int]: """Resolve flush-time bulk batching limits from env, with module defaults. Shared by every OpenSearch write path so the byte/record caps that bound a single ``async_bulk`` request are consistent across all of them. A non-positive value disables that splitting dimension (see ``_run_chunked_async_bulk``). Returns ``(upsert_payload_bytes, upsert_records, delete_records)``. """ upsert_payload_bytes = int( _get_opensearch_env( "OPENSEARCH_UPSERT_MAX_PAYLOAD_BYTES", str(DEFAULT_OPENSEARCH_UPSERT_MAX_PAYLOAD_BYTES), ) ) upsert_records = int( _get_opensearch_env( "OPENSEARCH_UPSERT_MAX_RECORDS_PER_BATCH", str(DEFAULT_OPENSEARCH_UPSERT_MAX_RECORDS_PER_BATCH), ) ) delete_records = int( _get_opensearch_env( "OPENSEARCH_DELETE_MAX_RECORDS_PER_BATCH", str(DEFAULT_OPENSEARCH_DELETE_MAX_RECORDS_PER_BATCH), ) ) if upsert_payload_bytes <= 0: logger.warning( f"OPENSEARCH_UPSERT_MAX_PAYLOAD_BYTES={upsert_payload_bytes} is non-positive, disable payload-size splitting" ) if upsert_records <= 0: logger.warning( f"OPENSEARCH_UPSERT_MAX_RECORDS_PER_BATCH={upsert_records} is non-positive, disable upsert record-count splitting" ) if delete_records <= 0: logger.warning( f"OPENSEARCH_DELETE_MAX_RECORDS_PER_BATCH={delete_records} is non-positive, disable delete record-count splitting" ) return upsert_payload_bytes, upsert_records, delete_records async def _run_chunked_async_bulk( client: Any, actions: list[dict[str, Any]], *, max_payload_bytes: int, max_records_per_batch: int, log_prefix: str, what: str, raise_on_error: bool = False, **bulk_kwargs: Any, ) -> tuple[int, list[Any]]: """Run ``helpers.async_bulk`` with payload-size/record-count bounded chunks. A thin wrapper that mirrors ``mongo_impl._run_batched_bulk_write`` in shape, but delegates the actual splitting to opensearch-py's ``_ActionChunker`` (byte budget primary, record count secondary, oversized single action emitted as its own chunk -- the same semantics as Mongo's ``_chunk_by_budget``). A non-positive limit disables that dimension. Extra keyword arguments (e.g. ``refresh``) are forwarded to ``async_bulk``. Returns ``async_bulk``'s ``(success, failed)`` tuple (``failed`` is empty when ``raise_on_error=True``). """ if not actions: return 0, [] chunk_size = max_records_per_batch if max_records_per_batch > 0 else len(actions) max_chunk_bytes = ( max_payload_bytes if max_payload_bytes > 0 else _OPENSEARCH_UNBOUNDED_PAYLOAD_BYTES ) if len(actions) > chunk_size: # Log format aligned with mongo_impl's flush split log # (max_payload=/batch= field names, raw configured values). Unlike # Mongo we cannot report the final batch count up front: async_bulk's # _ActionChunker decides it at stream time by byte budget, so this # record-count condition only catches count-driven splits. logger.info( f"{log_prefix} {what} split for {len(actions)} records " f"(max_payload={max_payload_bytes} batch={max_records_per_batch})" ) return await helpers.async_bulk( client, actions, chunk_size=chunk_size, max_chunk_bytes=max_chunk_bytes, raise_on_error=raise_on_error, **bulk_kwargs, ) # Index _meta flag marking that an edges index has been migrated to canonical # (sorted-pair) document ids. Guards the one-time reindex in # PGGraphStorage-style startup so it runs at most once per index. _EDGE_ID_CANONICAL_META_FLAG = "edge_id_canonical_v1" # Emit a migration progress line every this many scanned edges, so operators # watching a large-index reindex see liveness and an X/total denominator. _EDGE_MIGRATION_PROGRESS_INTERVAL = 50_000 def _canonical_edge_id(source_node_id: str, target_node_id: str) -> str: """Direction-independent edge document ``_id``. ``hash(sorted(src, tgt))`` collapses an edge and its reverse onto the same ``_id``, so concurrent ``(A,B)``/``(B,A)`` writes overwrite one document (last-write-wins) instead of racing into two separate docs. This makes ``upsert_edge`` idempotent by construction — no ``exists(reverse)`` read-then-write and no lock needed. The canonical id is always one of the two directed ids ``hash("src-tgt")``/``hash("tgt-src")``, so the bidirectional ``mget`` in ``has_edge``/``get_edge`` keeps finding it. """ lo, hi = sorted((source_node_id, target_node_id)) return compute_mdhash_id(f"{lo}-{hi}", prefix="edge-") def _edge_source_id_list(doc: dict[str, Any]) -> list[str]: """Return an edge doc's source ids, from the ``source_ids`` array or by splitting the ``GRAPH_FIELD_SEP``-joined ``source_id`` string.""" sids = doc.get("source_ids") if not sids and doc.get("source_id"): sids = doc["source_id"].split(GRAPH_FIELD_SEP) return list(sids or []) def _coerce_weight(weight: Any) -> float | None: """Coerce a (possibly string) edge weight to float, or None if non-numeric.""" if weight is None: return None try: return float(weight) except (TypeError, ValueError): return None def _merge_edge_payloads(docs: list[dict[str, Any]]) -> dict[str, Any]: """Merge edge-doc relation payloads when consolidating legacy duplicates. ``docs[0]`` is the survivor/base; the rest are duplicates folded into it. Mirrors ``mongo_impl``'s dedupe merge and ``operate.py``'s ``_merge_edges_then_upsert`` field semantics (minus LLM description summarisation): ``source_id``/``source_ids``/``file_path``/``description`` union their ``GRAPH_FIELD_SEP`` components, ``keywords`` are comma-set- unioned, and ``weight`` is **summed across every fragment** (base + each duplicate). Returns only the merged fields (to be layered onto the surviving doc). Weight summing deliberately does NOT dedup by ``source_id``: just like ``_merge_edges_then_upsert``, every edge fragment contributes its weight even when fragments share a source/chunk id — reciprocal duplicates that came from the same chunk still carry separate accumulated weight, so skipping them would undercount the relation. This function is therefore not idempotent on its own; idempotency across fail-fast retries is a property of the migration flow, not this math: each folded reverse doc is deleted right after the canonical write (see ``_merge_into_canonical_edge``), so a re-scan never re-presents an already-folded reverse for summing. Legacy string weights are coerced; non-numeric values are skipped so a bad value cannot crash the migration. """ source_ids: list[str] = [] file_paths: list[str] = [] descriptions: list[str] = [] keywords: set[str] = set() weights: list[float] = [] for d in docs: source_ids = merge_source_ids(source_ids, _edge_source_id_list(d)) fp = d.get("file_path") file_paths = merge_source_ids( file_paths, fp.split(GRAPH_FIELD_SEP) if fp else [] ) desc = d.get("description") descriptions = merge_source_ids( descriptions, desc.split(GRAPH_FIELD_SEP) if desc else [] ) kw = d.get("keywords") if kw: keywords.update(k.strip() for k in kw.split(",") if k.strip()) dw = _coerce_weight(d.get("weight")) if dw is not None: weights.append(dw) merged: dict[str, Any] = {} if source_ids: merged["source_ids"] = source_ids merged["source_id"] = GRAPH_FIELD_SEP.join(source_ids) if file_paths: merged["file_path"] = GRAPH_FIELD_SEP.join(file_paths) if descriptions: merged["description"] = GRAPH_FIELD_SEP.join(descriptions) if keywords: merged["keywords"] = ",".join(sorted(keywords)) if weights: merged["weight"] = sum(weights) return merged # Detected at first connection; True when OpenSearch >= 3.3.0. _shard_doc_supported: bool | None = None def _pit_sort_with_field(field: str) -> list[dict]: """Return PIT sort clause with a unique field as primary sort. Used purely as a pagination tiebreaker — order is fixed to asc since the business sort (when present) is applied separately by the caller. >= 3.3.0: _shard_doc only (most efficient, already unique within PIT). < 3.3.0: field + _doc (field is unique, _doc for efficiency). """ if _shard_doc_supported: return [{"_shard_doc": "asc"}] return [{field: {"order": "asc"}}, {"_doc": "asc"}] def _pit_sort_with_composite_key(*fields: str) -> list[dict]: """Return PIT sort clause with multiple fields forming a composite unique key. >= 3.3.0: _shard_doc (most efficient, ignores the fields). < 3.3.0: field1 + field2 + ... + _doc (composite is unique, _doc for efficiency). """ if _shard_doc_supported: return [{"_shard_doc": "asc"}] return [{f: {"order": "asc"}} for f in fields] + [{"_doc": "asc"}] async def _detect_shard_doc_support(client: AsyncOpenSearch) -> bool: """Check if the cluster supports _shard_doc (OpenSearch >= 3.3.0).""" try: info = await client.info() version_str = info.get("version", {}).get("number", "0.0.0") # Strip pre-release suffixes (e.g. "3.3.0-SNAPSHOT" → "3", "3", "0") parts = [p.split("-")[0] for p in version_str.split(".")] major = int(parts[0]) if parts[0].isdigit() else 0 minor = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 0 supported = (major > 3) or (major == 3 and minor >= 3) logger.info( f"OpenSearch version {version_str}: " f"_shard_doc {'supported' if supported else 'not supported, using field+_doc fallback'}" ) return supported except Exception as e: logger.warning( f"Failed to detect OpenSearch version, assuming _shard_doc not supported: {e}" ) return False class ClientManager: """Singleton manager for OpenSearch client connections.""" _instances = {"client": None, "ref_count": 0} _lock = asyncio.Lock() @classmethod async def get_client(cls) -> AsyncOpenSearch: """Get or create a shared AsyncOpenSearch client with reference counting.""" global _shard_doc_supported async with cls._lock: if cls._instances["client"] is None: hosts_str = _get_opensearch_env("OPENSEARCH_HOSTS", "localhost:9200") hosts = [h.strip() for h in hosts_str.split(",") if h.strip()] username = _get_opensearch_env("OPENSEARCH_USER", "admin") password = _get_opensearch_env("OPENSEARCH_PASSWORD", "admin") use_ssl = _get_opensearch_env("OPENSEARCH_USE_SSL", "true").lower() in ( "true", "1", "yes", ) verify_certs = _get_opensearch_env( "OPENSEARCH_VERIFY_CERTS", "false" ).lower() in ("true", "1", "yes") timeout = int(_get_opensearch_env("OPENSEARCH_TIMEOUT", "30")) max_retries = int(_get_opensearch_env("OPENSEARCH_MAX_RETRIES", "3")) ssl_context = None if use_ssl and not verify_certs: ssl_context = ssl_module.create_default_context() ssl_context.check_hostname = False ssl_context.verify_mode = ssl_module.CERT_NONE client = AsyncOpenSearch( hosts=hosts, http_auth=(username, password) if username else None, use_ssl=use_ssl, verify_certs=verify_certs, ssl_context=ssl_context, ssl_show_warn=False, timeout=timeout, max_retries=max_retries, retry_on_timeout=True, ) cls._instances["client"] = client cls._instances["ref_count"] = 0 _shard_doc_supported = await _detect_shard_doc_support(client) logger.info(f"OpenSearch client connected to {hosts}") cls._instances["ref_count"] += 1 return cls._instances["client"] @classmethod async def release_client(cls, client: AsyncOpenSearch): """Release a client reference. Closes the connection when ref count reaches 0.""" global _shard_doc_supported async with cls._lock: if client is not None and client is cls._instances["client"]: cls._instances["ref_count"] -= 1 if cls._instances["ref_count"] <= 0: try: await cls._instances["client"].close() except Exception: pass cls._instances["client"] = None cls._instances["ref_count"] = 0 _shard_doc_supported = None logger.info("OpenSearch client connection closed") def _resolve_workspace(workspace: str, namespace: str): """Resolve effective workspace from env or parameter.""" opensearch_workspace = os.environ.get("OPENSEARCH_WORKSPACE") if opensearch_workspace and opensearch_workspace.strip(): effective = opensearch_workspace.strip() logger.info( f"Using OPENSEARCH_WORKSPACE: '{effective}' (overriding '{workspace}/{namespace}')" ) return effective return workspace def _build_index_name(workspace: str, namespace: str) -> tuple[str, str, str]: """Build index name and return (effective_workspace, final_namespace, index_name).""" effective = _resolve_workspace(workspace, namespace) if effective: final_ns = f"{effective}_{namespace}" else: final_ns = namespace effective = "" index_name = _sanitize_index_name(final_ns) return effective, final_ns, index_name async def _mget_optional_doc( client: AsyncOpenSearch, index_name: str, doc_id: str, source_excludes: list[str] | None = None, ) -> dict[str, Any] | None: """Fetch a single document via mget and return None when it is absent. ``source_excludes`` is forwarded to OpenSearch's ``_source_excludes`` so callers can ask the server to omit specific fields (e.g. ``["vector"]``) and save network bandwidth. """ kwargs: dict[str, Any] = {"index": index_name, "body": {"ids": [doc_id]}} if source_excludes: kwargs["_source_excludes"] = source_excludes response = await client.mget(**kwargs) docs = response.get("docs", []) if not docs: return None doc = docs[0] if not doc.get("found"): return None return doc def _is_missing_index_error(exc: Exception) -> bool: """Return True when an OpenSearch exception means the target index is missing.""" return "index_not_found_exception" in str(exc) async def _verify_mirrored_id_mapping(client: AsyncOpenSearch, index_name: str) -> None: """Fail-fast when an existing index lacks the __mirrored_id keyword mapping. Only enforced on OpenSearch < 3.3.0, where __mirrored_id serves as the cross-shard pagination tiebreaker. Indices created by older LightRAG releases will be missing this mapping; sorting by a missing field on a multi-shard index can drop or duplicate documents during PIT pagination. """ if _shard_doc_supported: return try: mapping = await client.indices.get_mapping(index=index_name) except OpenSearchException: return props = mapping.get(index_name, {}).get("mappings", {}).get("properties", {}) if "__mirrored_id" not in props: raise RuntimeError( f"Index '{index_name}' lacks the '__mirrored_id' keyword mapping " f"required for stable PIT pagination on OpenSearch < 3.3.0. " f"This index was likely created by an older LightRAG release. " f"Please reindex the data, or upgrade the cluster to OpenSearch >= 3.3.0." ) @final @dataclass class OpenSearchKVStorage(BaseKVStorage): """Key-Value storage using OpenSearch. Uses dynamic mapping to support varied schemas.""" client: AsyncOpenSearch = field(default=None) _index_name: str = field(default="", init=False) _index_ready: bool = field(default=False, init=False) def __init__(self, namespace, global_config, embedding_func, workspace=None): super().__init__( namespace=namespace, workspace=workspace or "", global_config=global_config, embedding_func=embedding_func, ) self.__post_init__() def __post_init__(self): validate_workspace(self.workspace) self.workspace, self.final_namespace, self._index_name = _build_index_name( self.workspace, self.namespace ) # Pending writes are flushed via _flush_pending_kv_ops() during # index_done_callback() / finalize(). Buffering many small upsert() # invocations into a single async_bulk roundtrip avoids the per-call # HTTP overhead profiled in issue #2785; the lock-everywhere model # mirrors what #3043 introduced for OpenSearchVectorDBStorage. self._pending_upserts: dict[str, dict[str, Any]] = {} self._pending_kv_deletes: set[str] = set() # Namespace-keyed lock (multi-process aware) is assigned in # initialize(). All buffer reads / writes and the flush itself # acquire this lock so an in-flight flush cannot interleave with # concurrent get_by_id / upsert / delete on the same workspace. self._flush_lock = None ( self._max_upsert_payload_bytes, self._max_upsert_records_per_batch, self._max_delete_records_per_batch, ) = _resolve_bulk_batch_limits() async def initialize(self): """Initialize client connection and create index if needed.""" async with get_data_init_lock(): if self.client is None: self.client = await ClientManager.get_client() await self._create_index_if_not_exists() self._index_ready = True logger.debug( f"[{self.workspace}] OpenSearch KV storage initialized: {self._index_name}" ) if self._flush_lock is None: self._flush_lock = get_namespace_lock( self.namespace, workspace=self.workspace ) async def _ensure_index_ready(self): """Recreate the KV index after drop before the next write.""" if self._index_ready: return async with get_data_init_lock(): if self.client is None: self.client = await ClientManager.get_client() if not self._index_ready: await self._create_index_if_not_exists() self._index_ready = True def _mark_index_missing(self): """Mark the KV index as unavailable for subsequent read short-circuiting.""" self._index_ready = False async def _create_index_if_not_exists(self): try: if not await self.client.indices.exists(index=self._index_name): # Use dynamic mapping so any namespace schema works body = { "mappings": { "dynamic": True, "properties": { "__mirrored_id": {"type": "keyword"}, }, }, "settings": { "index": { "number_of_shards": _get_index_number_of_shards(), "number_of_replicas": _get_index_number_of_replicas(), }, }, } await self.client.indices.create(index=self._index_name, body=body) logger.info(f"[{self.workspace}] Created index: {self._index_name}") else: await _verify_mirrored_id_mapping(self.client, self._index_name) except RequestError as e: if "resource_already_exists_exception" not in str(e): raise except OpenSearchException as e: logger.error(f"[{self.workspace}] Error creating index: {e}") raise async def finalize(self): """Flush pending writes and release the OpenSearch client connection. Regular flush failures (any ``Exception``) are captured so they can be re-surfaced as a ``RuntimeError`` that names the unflushed buffer counts -- otherwise ``LightRAG.finalize_storages()`` would log the storage as successfully finalized while writes silently failed to reach OpenSearch. ``BaseException`` subclasses other than ``Exception`` (notably ``asyncio.CancelledError`` / ``KeyboardInterrupt`` / ``SystemExit``) are NOT caught: they propagate through the ``finally`` block so shutdown cancellation is honoured and not silently swallowed. The client is released in ``finally`` so it does not leak whether the flush succeeded, failed, or was cancelled. """ flush_error: Exception | None = None try: try: await self._flush_pending_kv_ops() except Exception as e: # _flush_pending_kv_ops leaves the buffers intact on raise. flush_error = e finally: if self.client is not None: await ClientManager.release_client(self.client) self.client = None # Reached only when no BaseException propagated through the # finally above. Snapshot remaining buffer state to report # concrete counts. pending_upserts = len(self._pending_upserts) pending_deletes = len(self._pending_kv_deletes) if flush_error is not None: raise RuntimeError( f"[{self.workspace}] OpenSearchKVStorage.finalize() flush " f"raised; {pending_upserts} pending upserts and " f"{pending_deletes} pending deletes were left buffered " f"(client released, data lost)" ) from flush_error if pending_upserts or pending_deletes: raise RuntimeError( f"[{self.workspace}] OpenSearchKVStorage.finalize() left " f"{pending_upserts} pending upserts and {pending_deletes} " f"pending deletes buffered after final flush attempt " f"(transient bulk failure); these writes have been lost" ) async def _iter_raw_docs( self, batch_size: int = 1000 ) -> AsyncIterator[list[dict[str, Any]]]: """Yield raw OpenSearch hits using PIT + search_after pagination.""" if not self._index_ready: return try: pit = await self.client.create_pit( index=self._index_name, params={"keep_alive": "1m"} ) pit_id = pit["pit_id"] try: search_after = None while True: body = { "query": {"match_all": {}}, "size": batch_size, "pit": {"id": pit_id, "keep_alive": "1m"}, "sort": _pit_sort_with_field("__mirrored_id"), } if search_after: body["search_after"] = search_after response = await self.client.search(body=body) hits = response["hits"]["hits"] if not hits: break yield hits search_after = hits[-1]["sort"] if len(hits) < batch_size: break finally: try: await self.client.delete_pit(body={"pit_id": [pit_id]}) except Exception: pass except OpenSearchException as e: if _is_missing_index_error(e): self._mark_index_missing() return logger.error(f"[{self.workspace}] Error scanning documents: {e}") raise def _materialize_pending_kv_doc( self, doc_id: str, source: dict[str, Any] ) -> dict[str, Any]: """Return a get_by_id-shaped view of a buffered upsert. Mirrors the post-processing applied to mget hits: drops the ``__mirrored_id`` PIT sort key, attaches the ``_id`` field and ensures ``create_time`` / ``update_time`` defaults are populated. The buffer entry itself is not mutated. """ doc = {k: v for k, v in source.items() if k != "__mirrored_id"} doc["_id"] = doc_id doc.setdefault("create_time", 0) doc.setdefault("update_time", 0) return doc async def get_by_id(self, id: str) -> dict[str, Any] | None: """Get a document by its ID, with read-your-writes against the buffer. Priority: pending delete (tombstone) → pending upsert (buffered write) → OpenSearch via mget. The buffered path strips ``__mirrored_id`` so the returned dict has the same shape as the mget path. """ async with self._flush_lock: if id in self._pending_kv_deletes: return None pending = self._pending_upserts.get(id) if pending is not None: return self._materialize_pending_kv_doc(id, pending) if not self._index_ready: return None try: response = await _mget_optional_doc(self.client, self._index_name, id) if response is None: return None doc = response["_source"] doc.pop("__mirrored_id", None) doc["_id"] = response["_id"] doc.setdefault("create_time", 0) doc.setdefault("update_time", 0) return doc except OpenSearchException as e: if _is_missing_index_error(e): self._mark_index_missing() return None logger.error(f"[{self.workspace}] Error getting document {id}: {e}") return None async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]: """Get multiple documents by IDs (read-your-writes), preserving order. Buffer is consulted under the lock with the same three-tier priority as ``get_by_id``; remaining ids fall through to mget outside the lock so the network call does not stall the flush. """ if not ids: return [] buffered: dict[str, dict[str, Any] | None] = {} remaining: list[str] = [] async with self._flush_lock: for doc_id in ids: if doc_id in self._pending_kv_deletes: buffered[doc_id] = None continue pending = self._pending_upserts.get(doc_id) if pending is not None: buffered[doc_id] = self._materialize_pending_kv_doc(doc_id, pending) continue remaining.append(doc_id) index_ready = self._index_ready doc_map: dict[str, dict[str, Any] | None] = {} if remaining and index_ready: try: response = await self.client.mget( index=self._index_name, body={"ids": remaining} ) for doc in response["docs"]: if doc.get("found"): data = doc["_source"] data.pop("__mirrored_id", None) data["_id"] = doc["_id"] data.setdefault("create_time", 0) data.setdefault("update_time", 0) doc_map[doc["_id"]] = data except OpenSearchException as e: if _is_missing_index_error(e): self._mark_index_missing() else: logger.error(f"[{self.workspace}] Error getting documents: {e}") return [ buffered[doc_id] if doc_id in buffered else doc_map.get(doc_id) for doc_id in ids ] async def filter_keys(self, keys: set[str]) -> set[str]: """Return the subset of keys that do not exist in storage. Buffer-aware: buffered upserts count as "exists" (and so are removed from the missing set), buffered deletes count as "missing" and are NOT queried via mget (a persisted-but-pending- delete row would otherwise be misclassified as existing). """ async with self._flush_lock: pending_upserts = set(self._pending_upserts) pending_deletes = set(self._pending_kv_deletes) index_ready = self._index_ready # Buffered upserts shadow OpenSearch -- they will exist after flush. to_check = keys - pending_upserts - pending_deletes if not to_check: # All keys are accounted for by the buffer alone. return keys - pending_upserts if not index_ready: return keys - pending_upserts try: response = await self.client.mget( index=self._index_name, body={"ids": list(to_check)}, _source=False, ) existing_on_server = { doc["_id"] for doc in response["docs"] if doc.get("found") } return (keys - pending_upserts) - existing_on_server except OpenSearchException as e: if _is_missing_index_error(e): self._mark_index_missing() return keys - pending_upserts logger.error(f"[{self.workspace}] Error filtering keys: {e}") return keys - pending_upserts async def upsert(self, data: dict[str, dict[str, Any]]) -> None: """Buffer documents for batched flush. Time-stamping and ``__mirrored_id`` injection happen eagerly so the persisted shape matches what reads expect; the actual ``async_bulk`` call is deferred to ``_flush_pending_kv_ops()`` invoked from ``index_done_callback`` / ``finalize``. Multi-worker note: the buffer is process-local. Other workers will not see these writes until ``index_done_callback()`` flushes them. """ if not data: return await self._ensure_index_ready() logger.debug( f"[{self.workspace}] Buffering {len(data)} documents for {self.namespace}" ) current_time = int(time.time()) # Construct sources outside the lock (no IO; just dict shuffling) # so we hold the lock only for the buffer-swap step. prepared: list[tuple[str, dict[str, Any]]] = [] for i, (doc_id, doc_data) in enumerate(data.items(), start=1): doc_data["update_time"] = current_time doc_data.setdefault("create_time", current_time) source = {k: v for k, v in doc_data.items() if k != "_id"} source["__mirrored_id"] = doc_id prepared.append((doc_id, source)) await _cooperative_yield(i) # Buffer: an upsert cancels any pending delete on the same id. async with self._flush_lock: for doc_id, source in prepared: self._pending_kv_deletes.discard(doc_id) self._pending_upserts[doc_id] = source async def delete(self, ids: list[str]) -> None: """Buffer document deletes for batched flush. A delete cancels any pending upsert on the same id; the actual bulk delete is performed by ``_flush_pending_kv_ops`` during the next ``index_done_callback`` / ``finalize`` call. ``_index_ready`` is intentionally NOT checked here: even if the index has been marked missing, the buffered upsert (if any) must still be invalidated, otherwise a subsequent flush would resurrect a logically-deleted key. """ if not ids: return if isinstance(ids, set): ids = list(ids) async with self._flush_lock: for doc_id in ids: self._pending_upserts.pop(doc_id, None) self._pending_kv_deletes.add(doc_id) logger.debug( f"[{self.workspace}] Buffered delete for {len(ids)} documents in {self.namespace}" ) async def _flush_pending_kv_ops(self) -> None: """Flush buffered upserts + deletes via a single async_bulk call. Concurrency contract: the entire flush runs under ``_flush_lock``; ``upsert`` / ``delete`` / reads / ``drop`` all acquire the same lock so an in-flight flush cannot interleave with concurrent buffer mutations. Failure handling mirrors the Vector-side helper: * If ``_ensure_index_ready`` raises, the buffers are left intact and the next flush retries. * If ``async_bulk`` raises, the buffers are left intact. * Per-doc retryable failures (408 / 429 / 5xx) stay in the buffer. * Per-doc non-retryable failures (most 4xx) are cleared and a sample is logged at WARNING with op / id / status / error. """ async with self._flush_lock: if not self._pending_upserts and not self._pending_kv_deletes: return if self.client is None: return await self._ensure_index_ready() pending_upserts = self._pending_upserts pending_deletes = self._pending_kv_deletes # Deletes are flushed before upserts so a delete followed (in time) # by an upsert on the same id still ends as an index; the two # buffers are disjoint anyway (upsert/delete pop each other), so # running them as separate async_bulk requests is safe and lets the # delete record-count cap differ from the upsert cap (mirrors # mongo_impl's separate upsert/delete phases). delete_actions: list[dict[str, Any]] = [ { "_op_type": "delete", "_index": self._index_name, "_id": doc_id, } for doc_id in pending_deletes ] index_actions: list[dict[str, Any]] = [ { "_op_type": "index", "_index": self._index_name, "_id": doc_id, "_source": source, } for doc_id, source in pending_upserts.items() ] try: log_prefix = f"[{self.workspace}] {self.namespace} flush:" del_success, del_failed = await _run_chunked_async_bulk( self.client, delete_actions, max_payload_bytes=self._max_upsert_payload_bytes, max_records_per_batch=self._max_delete_records_per_batch, log_prefix=log_prefix, what="delete", raise_on_error=False, ) idx_success, idx_failed = await _run_chunked_async_bulk( self.client, index_actions, max_payload_bytes=self._max_upsert_payload_bytes, max_records_per_batch=self._max_upsert_records_per_batch, log_prefix=log_prefix, what="upsert", raise_on_error=False, ) success = del_success + idx_success failed = list(del_failed) + list(idx_failed) except OpenSearchException as e: logger.error( f"[{self.workspace}] Error flushing KV ops " f"(upserts={len(pending_upserts)}, " f"deletes={len(pending_deletes)}): {e}" ) raise retryable_ids, non_retryable_ops = _extract_bulk_failed_ids(failed) non_retryable_ids = {op.doc_id for op in non_retryable_ops} # Keep ONLY retryable ops buffered for the next flush. Successful # ops are popped; non-retryable (permanent 4xx) ops are dropped # here, not retained: a permanently-unwritable op can never land, # so keeping it would replay-and-refail on every later flush and # poison every caller that shares this buffer — including direct # flush paths (e.g. _persist_parsed_full_docs) that never run the # pipeline's cleanup. The raise below (not retention) is what # surfaces the failure and prevents a silent PROCESSED. keep_ids = retryable_ids for doc_id in list(pending_upserts.keys()): if doc_id not in keep_ids: pending_upserts.pop(doc_id, None) new_deletes: set[str] = { doc_id for doc_id in pending_deletes if doc_id in keep_ids } pending_deletes.clear() pending_deletes.update(new_deletes) if retryable_ids: logger.warning( f"[{self.workspace}] {len(retryable_ids)} KV ops will " f"retry on the next flush (transient failure)" ) logger.debug( f"[{self.workspace}] Flushed KV ops: {success} ok, " f"retry={len(retryable_ids)}, permanent_fail={len(non_retryable_ids)}" ) if non_retryable_ops: sample = non_retryable_ops[:5] sample_text = ", ".join( f"{op.op}/{op.doc_id}/status={op.status}/{op.error}" for op in sample ) # A permanent (non-retryable) bulk failure means the data did # not land. Raise so index_done_callback surfaces it and the # pipeline aborts instead of marking the document PROCESSED. raise RuntimeError( f"[{self.workspace}] {self.namespace} flush: " f"{len(non_retryable_ops)} KV ops failed permanently " f"(non-retryable). Sample: {sample_text}" ) async def drop_pending_index_ops(self) -> None: """Discard buffered upserts/deletes (pipeline aborting on error).""" async with self._flush_lock: self._pending_upserts.clear() self._pending_kv_deletes.clear() async def index_done_callback(self) -> None: """Flush pending KV ops and refresh the index for search visibility. Flush runs first so a previously-missing index gets recreated by ``_flush_pending_kv_ops`` (via ``_ensure_index_ready``) before any buffered writes are abandoned. The refresh step is skipped only when the index is still not ready after the flush attempt. """ await self._flush_pending_kv_ops() if not self._index_ready: return try: await self.client.indices.refresh(index=self._index_name) except OpenSearchException as e: if _is_missing_index_error(e): self._mark_index_missing() return raise async def is_empty(self) -> bool: """Return True if the index (plus pending buffer) contains no docs. Buffer-aware: a pending upsert makes is_empty False immediately, avoiding the counterintuitive "I just upserted but is_empty returned True" case. Pending deletes alone are not enough to flip the answer because we cannot tell whether other persisted rows survive without flushing. """ async with self._flush_lock: if self._pending_upserts: return False index_ready = self._index_ready if not index_ready: return True try: response = await self.client.count(index=self._index_name) return response["count"] == 0 except OpenSearchException as e: if _is_missing_index_error(e): self._mark_index_missing() return True async def drop(self) -> dict[str, str]: """Delete the entire index, discarding pending buffers. Runs entirely under ``_flush_lock`` so a concurrent flush / upsert cannot land writes against an index that is being deleted. """ async with self._flush_lock: # Pending writes are meaningless once the index is dropped. self._pending_upserts.clear() self._pending_kv_deletes.clear() try: try: await self.client.indices.delete(index=self._index_name) logger.info(f"[{self.workspace}] Dropped index: {self._index_name}") except NotFoundError: logger.info( f"[{self.workspace}] Index already missing during drop: {self._index_name}" ) self._mark_index_missing() return { "status": "success", "message": f"Index {self._index_name} dropped", } except OpenSearchException as e: self._mark_index_missing() logger.error(f"[{self.workspace}] Error dropping index: {e}") return {"status": "error", "message": str(e)} except Exception as e: self._mark_index_missing() logger.error(f"[{self.workspace}] Unexpected error dropping index: {e}") return {"status": "error", "message": str(e)} @final @dataclass class OpenSearchDocStatusStorage(DocStatusStorage): """Document status storage using OpenSearch.""" client: AsyncOpenSearch = field(default=None) _index_name: str = field(default="", init=False) _index_ready: bool = field(default=False, init=False) def __init__(self, namespace, global_config, embedding_func, workspace=None): super().__init__( namespace=namespace, workspace=workspace or "", global_config=global_config, embedding_func=embedding_func, ) self.__post_init__() def __post_init__(self): validate_workspace(self.workspace) self.workspace, self.final_namespace, self._index_name = _build_index_name( self.workspace, self.namespace ) ( self._max_upsert_payload_bytes, self._max_upsert_records_per_batch, self._max_delete_records_per_batch, ) = _resolve_bulk_batch_limits() def _prepare_doc_status_data(self, doc: dict[str, Any]) -> dict[str, Any]: """Normalize a raw OpenSearch document to DocProcessingStatus-compatible dict.""" data = doc.copy() data.pop("_id", None) data.pop("__mirrored_id", None) if "file_path" not in data: data["file_path"] = "no-file-path" data.setdefault("metadata", {}) data.setdefault("error_msg", None) if "error" in data: if not data.get("error_msg"): data["error_msg"] = data.pop("error") else: data.pop("error", None) return data async def initialize(self): """Initialize client connection and create doc status index.""" async with get_data_init_lock(): if self.client is None: self.client = await ClientManager.get_client() await self._create_index_if_not_exists() self._index_ready = True logger.debug( f"[{self.workspace}] OpenSearch DocStatus storage initialized: {self._index_name}" ) async def _ensure_index_ready(self): """Recreate the doc status index after drop before the next write.""" if self._index_ready: return async with get_data_init_lock(): if self.client is None: self.client = await ClientManager.get_client() if not self._index_ready: await self._create_index_if_not_exists() self._index_ready = True def _mark_index_missing(self): """Mark the doc status index as unavailable for subsequent read short-circuiting.""" self._index_ready = False async def _create_index_if_not_exists(self): try: if not await self.client.indices.exists(index=self._index_name): body = { "mappings": { "dynamic": True, "properties": { "__mirrored_id": {"type": "keyword"}, "status": {"type": "keyword"}, "file_path": {"type": "keyword"}, "track_id": {"type": "keyword"}, "content_hash": {"type": "keyword"}, "created_at": {"type": "date"}, "updated_at": {"type": "date"}, }, }, "settings": { "index": { "number_of_shards": _get_index_number_of_shards(), "number_of_replicas": _get_index_number_of_replicas(), }, }, } await self.client.indices.create(index=self._index_name, body=body) logger.info( f"[{self.workspace}] Created doc status index: {self._index_name}" ) else: await _verify_mirrored_id_mapping(self.client, self._index_name) await self._ensure_content_hash_mapping() except RequestError as e: if "resource_already_exists_exception" not in str(e): raise except OpenSearchException as e: logger.error(f"[{self.workspace}] Error creating doc status index: {e}") raise async def _ensure_content_hash_mapping(self) -> None: """Add the content_hash keyword mapping to a pre-existing doc status index. Indices created by older LightRAG releases lack content_hash entirely. put_mapping is idempotent for new fields, so this is safe to call every startup; we only fail loudly when the cluster reports a mapping conflict (which would indicate dynamic mapping already coerced content_hash to a different type). """ try: mapping = await self.client.indices.get_mapping(index=self._index_name) except OpenSearchException: return props = ( mapping.get(self._index_name, {}).get("mappings", {}).get("properties", {}) ) if "content_hash" in props: return try: await self.client.indices.put_mapping( index=self._index_name, body={"properties": {"content_hash": {"type": "keyword"}}}, ) logger.info( f"[{self.workspace}] Added content_hash keyword mapping to {self._index_name}" ) except OpenSearchException as e: logger.warning( f"[{self.workspace}] Failed to add content_hash mapping to " f"{self._index_name}: {e}" ) async def finalize(self): """Release the OpenSearch client connection.""" if self.client is not None: await ClientManager.release_client(self.client) self.client = None async def get_by_id(self, id: str) -> Union[dict[str, Any], None]: """Get a document status record by ID.""" if not self._index_ready: return None try: response = await _mget_optional_doc(self.client, self._index_name, id) if response is None: return None doc = response["_source"] doc["_id"] = response["_id"] return doc except OpenSearchException as e: if _is_missing_index_error(e): self._mark_index_missing() return None logger.error(f"[{self.workspace}] Error getting doc status {id}: {e}") return None async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]: """Get multiple document status records by IDs.""" if not self._index_ready: return [None] * len(ids) try: response = await self.client.mget(index=self._index_name, body={"ids": ids}) doc_map = {} for doc in response["docs"]: if doc.get("found"): data = doc["_source"] data["_id"] = doc["_id"] doc_map[doc["_id"]] = data return [doc_map.get(id) for id in ids] except OpenSearchException as e: if _is_missing_index_error(e): self._mark_index_missing() return [None] * len(ids) logger.error(f"[{self.workspace}] Error getting doc statuses: {e}") return [None] * len(ids) async def filter_keys(self, keys: set[str]) -> set[str]: """Return the subset of keys that do not exist in storage.""" if not self._index_ready: return keys try: response = await self.client.mget( index=self._index_name, body={"ids": list(keys)}, _source=False ) existing_ids = {doc["_id"] for doc in response["docs"] if doc.get("found")} return keys - existing_ids except OpenSearchException as e: if _is_missing_index_error(e): self._mark_index_missing() return keys logger.error(f"[{self.workspace}] Error filtering keys: {e}") return keys async def upsert(self, data: dict[str, dict[str, Any]]) -> None: """Insert or update document status records.""" if not data: return await self._ensure_index_ready() logger.debug(f"[{self.workspace}] Upserting {len(data)} doc statuses") actions = [] for i, (k, v) in enumerate(data.items(), start=1): v.setdefault("chunks_list", []) source = {fk: fv for fk, fv in v.items() if fk != "_id"} source["__mirrored_id"] = k actions.append( { "_op_type": "index", "_index": self._index_name, "_id": k, "_source": source, } ) await _cooperative_yield(i) try: # DocStatus needs refresh="wait_for" because get_docs_by_status # (search-based) is called immediately after enqueue upserts. await _run_chunked_async_bulk( self.client, actions, max_payload_bytes=self._max_upsert_payload_bytes, max_records_per_batch=self._max_upsert_records_per_batch, log_prefix=f"[{self.workspace}] {self.namespace} upsert:", what="doc-status upsert", raise_on_error=False, refresh="wait_for", ) except OpenSearchException as e: logger.error(f"[{self.workspace}] Error upserting doc statuses: {e}") # Surface the failure instead of returning as if the status write # succeeded — a silently-lost doc-status row corrupts pipeline # bookkeeping (a doc could never be recorded FAILED/PROCESSED). raise async def get_status_counts(self) -> dict[str, int]: """Get document counts grouped by status.""" if not self._index_ready: return {} try: body = { "size": 0, "aggs": {"status_counts": {"terms": {"field": "status", "size": 100}}}, } response = await self.client.search(index=self._index_name, body=body) return { bucket["key"]: bucket["doc_count"] for bucket in response["aggregations"]["status_counts"]["buckets"] } except OpenSearchException as e: if _is_missing_index_error(e): self._mark_index_missing() return {} logger.error(f"[{self.workspace}] Error getting status counts: {e}") return {} async def _search_all_docs(self, query: dict) -> dict[str, DocProcessingStatus]: """Fetch all documents matching a query using PIT + search_after.""" if not self._index_ready: return {} result = {} batch_size = 10000 try: pit = await self.client.create_pit( index=self._index_name, params={"keep_alive": "1m"} ) pit_id = pit["pit_id"] try: search_after = None while True: body = { "query": query, "size": batch_size, "pit": {"id": pit_id, "keep_alive": "1m"}, "sort": _pit_sort_with_field("__mirrored_id"), } if search_after: body["search_after"] = search_after response = await self.client.search(body=body) hits = response["hits"]["hits"] if not hits: break for hit in hits: try: data = self._prepare_doc_status_data(hit["_source"]) result[hit["_id"]] = DocProcessingStatus(**data) except (KeyError, TypeError) as e: logger.error( f"[{self.workspace}] Error parsing doc {hit['_id']}: {e}" ) search_after = hits[-1]["sort"] if len(hits) < batch_size: break finally: try: await self.client.delete_pit(body={"pit_id": [pit_id]}) except Exception: pass except OpenSearchException as e: if _is_missing_index_error(e): self._mark_index_missing() return {} logger.error(f"[{self.workspace}] Error fetching docs: {e}") return result async def get_docs_by_status( self, status: DocStatus ) -> dict[str, DocProcessingStatus]: """Get all documents matching a specific processing status.""" return await self.get_docs_by_statuses([status]) async def get_docs_by_statuses( self, statuses: list[DocStatus] ) -> dict[str, DocProcessingStatus]: """Get all documents matching any of the given statuses in a single query. Uses OpenSearch's terms query (multi-value equivalent of term) to fetch all matching statuses in one PIT + search_after pass instead of one full scan per status. """ if not statuses: return {} status_values = [s.value for s in statuses] return await self._search_all_docs({"terms": {"status": status_values}}) async def get_docs_by_track_id( self, track_id: str ) -> dict[str, DocProcessingStatus]: """Get all documents matching a specific track ID.""" return await self._search_all_docs({"term": {"track_id": track_id}}) async def get_docs_paginated( self, status_filter: DocStatus | None = None, status_filters: list[DocStatus] | None = None, page: int = 1, page_size: int = 50, sort_field: str = "updated_at", sort_direction: str = "desc", ) -> tuple[list[tuple[str, DocProcessingStatus]], int]: """Get documents with pagination using PIT + search_after.""" if not self._index_ready: return [], 0 status_filter_values = self.resolve_status_filter_values( status_filter=status_filter, status_filters=status_filters, ) page = max(1, page) page_size = max(10, min(200, page_size)) if sort_field == "id": sort_field = "_id" if sort_field not in ("created_at", "updated_at", "_id", "file_path"): sort_field = "updated_at" sort_order = "asc" if sort_direction.lower() == "asc" else "desc" query = {"match_all": {}} if status_filter_values is not None: if len(status_filter_values) == 1: query = {"term": {"status": next(iter(status_filter_values))}} else: query = {"terms": {"status": sorted(status_filter_values)}} skip_count = (page - 1) * page_size try: count_resp = await self.client.count( index=self._index_name, body={"query": query} ) total_count = count_resp.get("count", 0) if total_count == 0 or skip_count >= total_count: return [], total_count sort_clause = [{sort_field: {"order": sort_order}}] + _pit_sort_with_field( "__mirrored_id" ) pit = await self.client.create_pit( index=self._index_name, params={"keep_alive": "1m"} ) pit_id = pit["pit_id"] try: search_after = None skipped = 0 while skipped < skip_count: batch = min(page_size, skip_count - skipped) body = { "query": query, "sort": sort_clause, "size": batch, "pit": {"id": pit_id, "keep_alive": "1m"}, } if search_after: body["search_after"] = search_after resp = await self.client.search(body=body) hits = resp["hits"]["hits"] if not hits: return [], total_count search_after = hits[-1]["sort"] skipped += len(hits) body = { "query": query, "sort": sort_clause, "size": page_size, "pit": {"id": pit_id, "keep_alive": "1m"}, } if search_after: body["search_after"] = search_after response = await self.client.search(body=body) finally: try: await self.client.delete_pit(body={"pit_id": [pit_id]}) except Exception: pass documents = [] for hit in response["hits"]["hits"]: try: data = self._prepare_doc_status_data(hit["_source"]) documents.append((hit["_id"], DocProcessingStatus(**data))) except (KeyError, TypeError) as e: logger.error( f"[{self.workspace}] Error parsing doc {hit['_id']}: {e}" ) return documents, total_count except OpenSearchException as e: if _is_missing_index_error(e): self._mark_index_missing() return [], 0 logger.error(f"[{self.workspace}] Error in paginated query: {e}") return [], 0 async def get_all_status_counts(self) -> dict[str, int]: """Get document counts for all statuses including an 'all' total.""" if not self._index_ready: return {} try: body = { "size": 0, "aggs": {"status_counts": {"terms": {"field": "status", "size": 100}}}, } response = await self.client.search(index=self._index_name, body=body) counts = {} total = 0 for bucket in response["aggregations"]["status_counts"]["buckets"]: counts[bucket["key"]] = bucket["doc_count"] total += bucket["doc_count"] counts["all"] = total return counts except OpenSearchException as e: if _is_missing_index_error(e): self._mark_index_missing() return {} logger.error(f"[{self.workspace}] Error getting all status counts: {e}") return {} async def get_doc_by_file_path(self, file_path: str) -> Union[dict[str, Any], None]: """Find a document status record by its file_path field.""" if not self._index_ready: return None try: body = {"query": {"term": {"file_path": file_path}}, "size": 1} response = await self.client.search(index=self._index_name, body=body) hits = response["hits"]["hits"] if hits: doc = hits[0]["_source"] doc["_id"] = hits[0]["_id"] return doc return None except OpenSearchException as e: if _is_missing_index_error(e): self._mark_index_missing() return None logger.error(f"[{self.workspace}] Error getting doc by file_path: {e}") return None async def get_doc_by_file_basename( self, basename: str ) -> Union[tuple[str, dict[str, Any]], None]: """Find an existing record whose canonical basename matches. The caller is responsible for passing an already-canonical basename; stored ``file_path`` values are canonicalized by the business layer, so this lookup performs an exact term query against the file_path keyword field. """ if not basename: return None if basename == "unknown_source": return None if not self._index_ready: return None try: body = {"query": {"term": {"file_path": basename}}, "size": 1} response = await self.client.search(index=self._index_name, body=body) hits = response["hits"]["hits"] if not hits: return None hit = hits[0] doc = hit["_source"] return hit["_id"], doc except OpenSearchException as e: if _is_missing_index_error(e): self._mark_index_missing() return None logger.error(f"[{self.workspace}] Error getting doc by file_basename: {e}") return None async def get_doc_by_content_hash( self, content_hash: str ) -> Union[tuple[str, dict[str, Any]], None]: """Find an existing record whose content_hash field matches. Uses the content_hash keyword mapping created by ``_create_index_if_not_exists`` / ``_ensure_content_hash_mapping``. Empty values short-circuit so legacy rows without the field cannot accidentally match via type coercion. """ if not content_hash: return None if not self._index_ready: return None try: body = {"query": {"term": {"content_hash": content_hash}}, "size": 1} response = await self.client.search(index=self._index_name, body=body) hits = response["hits"]["hits"] if not hits: return None hit = hits[0] doc = hit["_source"] return hit["_id"], doc except OpenSearchException as e: if _is_missing_index_error(e): self._mark_index_missing() return None logger.error(f"[{self.workspace}] Error getting doc by content_hash: {e}") return None async def index_done_callback(self) -> None: """Refresh index to make recently indexed documents searchable.""" if not self._index_ready: return try: await self.client.indices.refresh(index=self._index_name) except OpenSearchException as e: if _is_missing_index_error(e): self._mark_index_missing() return raise async def is_empty(self) -> bool: """Return True if the index contains no documents.""" if not self._index_ready: return True try: response = await self.client.count(index=self._index_name) return response["count"] == 0 except OpenSearchException as e: if _is_missing_index_error(e): self._mark_index_missing() return True async def delete(self, ids: list[str]) -> None: """Delete document status records by IDs.""" if not ids: return if not self._index_ready: return if isinstance(ids, set): ids = list(ids) try: # DocStatus needs refresh="wait_for" because downstream readers # (get_docs_by_status, get_docs_paginated, etc.) are search-based # and callers like _validate_and_fix_document_consistency() may # query immediately after deletion without index_done_callback(). actions = [ {"_op_type": "delete", "_index": self._index_name, "_id": doc_id} for doc_id in ids ] await _run_chunked_async_bulk( self.client, actions, max_payload_bytes=self._max_upsert_payload_bytes, max_records_per_batch=self._max_delete_records_per_batch, log_prefix=f"[{self.workspace}] {self.namespace} delete:", what="doc-status delete", raise_on_error=False, refresh="wait_for", ) except OpenSearchException as e: if _is_missing_index_error(e): self._mark_index_missing() return logger.error(f"[{self.workspace}] Error deleting doc statuses: {e}") async def drop(self) -> dict[str, str]: """Delete the entire doc status index.""" try: try: await self.client.indices.delete(index=self._index_name) logger.info( f"[{self.workspace}] Dropped doc status index: {self._index_name}" ) except NotFoundError: logger.info( f"[{self.workspace}] Doc status index already missing during drop: {self._index_name}" ) self._mark_index_missing() return {"status": "success", "message": f"Index {self._index_name} dropped"} except OpenSearchException as e: self._mark_index_missing() logger.error(f"[{self.workspace}] Error dropping doc status index: {e}") return {"status": "error", "message": str(e)} except Exception as e: self._mark_index_missing() logger.error( f"[{self.workspace}] Unexpected error dropping doc status index: {e}" ) return {"status": "error", "message": str(e)} @final @dataclass class OpenSearchGraphStorage(BaseGraphStorage): """Graph storage using OpenSearch with separate nodes and edges indices. Supports two BFS traversal strategies: - PPL graphlookup (server-side BFS, requires OpenSearch SQL plugin with Calcite engine) - Application-level batched BFS (fallback, works on any OpenSearch 3.x+) The strategy is auto-detected during initialize() and can be overridden via the OPENSEARCH_USE_PPL_GRAPHLOOKUP environment variable (true/false). """ client: AsyncOpenSearch = field(default=None) _nodes_index: str = field(default="", init=False) _edges_index: str = field(default="", init=False) _indices_ready: bool = field(default=False, init=False) _nodes_dirty: bool = field(default=False, init=False) _edges_dirty: bool = field(default=False, init=False) _ppl_graphlookup_available: bool = field(default=False, init=False) def __init__(self, namespace, global_config, embedding_func, workspace=None): super().__init__( namespace=namespace, workspace=workspace or "", global_config=global_config, embedding_func=embedding_func, ) self.__post_init__() def __post_init__(self): validate_workspace(self.workspace) self.workspace, self.final_namespace, base_name = _build_index_name( self.workspace, self.namespace ) self._nodes_index = f"{base_name}-nodes" self._edges_index = f"{base_name}-edges" ( self._max_upsert_payload_bytes, self._max_upsert_records_per_batch, self._max_delete_records_per_batch, ) = _resolve_bulk_batch_limits() async def initialize(self): """Initialize client, create indices, and detect PPL graphlookup support.""" async with get_data_init_lock(): if self.client is None: self.client = await ClientManager.get_client() await self._create_indices_if_not_exist() await self._migrate_edges_to_canonical_id_if_needed() self._indices_ready = True self._nodes_dirty = False self._edges_dirty = False await self._detect_ppl_graphlookup() logger.debug( f"[{self.workspace}] OpenSearch Graph storage initialized: " f"{self._nodes_index}, {self._edges_index} " f"(PPL graphlookup: {self._ppl_graphlookup_available})" ) async def _ensure_indices_ready(self): """Recreate graph indices after drop before the next write.""" if self._indices_ready: return async with get_data_init_lock(): if self.client is None: self.client = await ClientManager.get_client() if not self._indices_ready: await self._create_indices_if_not_exist() self._indices_ready = True def _mark_indices_missing(self): """Mark graph indices as unavailable for subsequent read short-circuiting.""" self._indices_ready = False self._nodes_dirty = False self._edges_dirty = False async def _refresh_graph_indices_if_dirty( self, *, refresh_nodes: bool = False, refresh_edges: bool = False ) -> None: """Refresh graph indices only when prior writes made search views stale.""" if not self._indices_ready: return if not ( (refresh_nodes and self._nodes_dirty) or (refresh_edges and self._edges_dirty) ): return try: async with get_data_init_lock(): if refresh_nodes and self._nodes_dirty: await self.client.indices.refresh(index=self._nodes_index) self._nodes_dirty = False if refresh_edges and self._edges_dirty: await self.client.indices.refresh(index=self._edges_index) self._edges_dirty = False except OpenSearchException as e: if _is_missing_index_error(e): self._mark_indices_missing() return raise async def _detect_ppl_graphlookup(self): """Detect whether PPL graphlookup command is available on this cluster.""" env_override = os.environ.get("OPENSEARCH_USE_PPL_GRAPHLOOKUP", "").lower() if env_override == "true": self._ppl_graphlookup_available = True return if env_override == "false": self._ppl_graphlookup_available = False return # Auto-detect by sending a minimal PPL query try: await self.client.transport.perform_request( "POST", "/_plugins/_ppl", body={"query": f"source = {self._edges_index} | head 0"}, ) # PPL endpoint works; now test graphlookup syntax with a no-op query await self.client.transport.perform_request( "POST", "/_plugins/_ppl", body={ "query": ( f"source = {self._edges_index} | head 1 " f"| graphLookup {self._edges_index} " f"start=source_node_id edge=target_node_id-->source_node_id " f"maxDepth=0 as _gl_probe" ) }, ) self._ppl_graphlookup_available = True logger.info( f"[{self.workspace}] PPL graphlookup is available, using server-side BFS" ) except Exception: self._ppl_graphlookup_available = False logger.info( f"[{self.workspace}] PPL graphlookup not available, using client-side BFS" ) async def _create_indices_if_not_exist(self): try: if not await self.client.indices.exists(index=self._nodes_index): body = { "mappings": { "dynamic": True, "properties": { "entity_id": {"type": "keyword"}, "entity_type": {"type": "keyword"}, "description": {"type": "text"}, "source_id": {"type": "text"}, "source_ids": {"type": "keyword"}, "file_path": {"type": "keyword"}, "created_at": {"type": "long"}, }, }, "settings": { "index": { "number_of_shards": _get_index_number_of_shards(), "number_of_replicas": _get_index_number_of_replicas(), } }, } await self.client.indices.create(index=self._nodes_index, body=body) logger.info( f"[{self.workspace}] Created nodes index: {self._nodes_index}" ) except RequestError as e: if "resource_already_exists_exception" not in str(e): raise try: if not await self.client.indices.exists(index=self._edges_index): body = { "mappings": { "dynamic": True, "properties": { "source_node_id": {"type": "keyword"}, "target_node_id": {"type": "keyword"}, "relationship": {"type": "keyword"}, "description": {"type": "text"}, "weight": {"type": "float"}, "keywords": {"type": "text"}, "source_id": {"type": "text"}, "source_ids": {"type": "keyword"}, "file_path": {"type": "keyword"}, "created_at": {"type": "long"}, }, }, "settings": { "index": { "number_of_shards": _get_index_number_of_shards(), "number_of_replicas": _get_index_number_of_replicas(), } }, } await self.client.indices.create(index=self._edges_index, body=body) logger.info( f"[{self.workspace}] Created edges index: {self._edges_index}" ) except RequestError as e: if "resource_already_exists_exception" not in str(e): raise async def _migrate_edges_to_canonical_id_if_needed(self) -> None: """One-time reindex of edge docs onto canonical (sorted-pair) ``_id``s. Legacy edges were keyed by ``hash("src-tgt")`` in the *call* direction, so an edge could live under either orientation's id. After ``upsert_edge`` switched to a canonical sorted-pair id, a fresh write lands on a different ``_id`` than a legacy reverse-direction doc, leaving two documents for one edge (``node_degree``/``get_node_edges`` double-count). This re-keys every non-canonical doc onto its canonical ``_id`` and deletes the stale id. **Fail-fast.** Runs in ``initialize`` inside ``get_data_init_lock`` (which serialises one deployment's worker pool — only the first worker migrates, the rest skip via the ``_meta`` flag). On any non-benign per-item error (e.g. 429/503) it raises, so the service does not start until the index is fully canonical; the next startup rescans (the flag is only set on full success). Because the service is gated on a complete migration and every later write is canonical, there is no need for a per-write reverse-orientation cleanup. The canonical write uses ``op_type=create`` (insert-only): a legacy reciprocal duplicate (both directed docs present) collapses onto the existing forward/canonical doc (create 409, benign) and the reverse copy is deleted. A create that fails fast happens *before* any delete, so a source row is never dropped without its canonical counterpart existing. Assumes no concurrent *old-version* writer adds non-canonical docs after this completes (true for stop-the-world / single-deployment restarts). A true rolling deploy with two code versions writing the same index could leave a straggler reverse doc; the remedy is to clear the ``_meta`` flag and let the next startup re-migrate. """ try: if not await self.client.indices.exists(index=self._edges_index): logger.debug( f"[{self.workspace}] Edge index {self._edges_index} does not " f"exist yet; skipping canonical edge-id migration" ) return mapping = await self.client.indices.get_mapping(index=self._edges_index) meta = ( mapping.get(self._edges_index, {}).get("mappings", {}).get("_meta", {}) ) if meta.get(_EDGE_ID_CANONICAL_META_FLAG): logger.info( f"[{self.workspace}] Edge index {self._edges_index} already on " f"canonical ids; skipping migration" ) return # Count upfront so operators get an X/total denominator; best-effort # (migration still works if count is unavailable). try: total = (await self.client.count(index=self._edges_index)).get("count") except OpenSearchException: total = None logger.info( f"[{self.workspace}] Starting canonical edge-id migration for " f"{self._edges_index}" + (f" (~{total} edges to scan)" if total is not None else "") ) scanned = 0 migrated = 0 # Each entry is (canonical_id, old_id, source) for one non-canonical # doc to be re-keyed. Flush roughly one bulk chunk at a time so a huge # index does not buffer every action in memory before writing. pending: list[tuple[str, str, dict[str, Any]]] = [] flush_at = max(self._max_upsert_records_per_batch, 1) next_progress = _EDGE_MIGRATION_PROGRESS_INTERVAL async def _flush_pending() -> None: nonlocal pending if not pending: return batch, pending = pending, [] # Group this batch by canonical id. A node pair can have >1 # pending non-canonical doc (e.g. several legacy reciprocal # duplicates), and they all map to the same canonical id; carry # each doc's old _id so it can be deleted after consolidation. docs_by_canonical: dict[str, list[tuple[str, dict[str, Any]]]] = {} for canonical, old_id, source in batch: docs_by_canonical.setdefault(canonical, []).append((old_id, source)) # Phase 1 — create exactly ONE canonical doc per canonical id. # When a canonical has >1 pending doc we pre-merge their payloads # in memory so the single create carries the summed weight/unioned # provenance: issuing one create per doc instead would let one win # the insert and the rest 409, and folding those 409'd docs back in # would re-merge the create winner (its source is now the base), # double-counting its weight. op_type=create (insert-only): a 409 # then means the canonical already existed *independently of this # batch* (a forward legacy doc, or a prior batch/run), so every doc # this batch mapped to it is a loser to fold in. raise_on_error= # False so a 409 does not abort. create_actions = [] for canonical, reverse_docs in docs_by_canonical.items(): sources = [source for _old_id, source in reverse_docs] source = ( {**sources[0], **_merge_edge_payloads(sources)} if len(sources) > 1 else sources[0] ) create_actions.append( { "_op_type": "create", "_index": self._edges_index, "_id": canonical, "_source": source, } ) _success, errors = await _run_chunked_async_bulk( self.client, create_actions, max_payload_bytes=self._max_upsert_payload_bytes, max_records_per_batch=self._max_upsert_records_per_batch, log_prefix=f"[{self.workspace}] {self.namespace} edges:", what="canonical edge-id migration (create)", raise_on_error=False, ) # A create 409 means the canonical doc already exists: merge the # pending doc(s)' relation payload into it (below) so deleting them # loses no evidence. Any other create error (e.g. 429/503) fails # fast BEFORE any delete, so no edge is dropped without its # canonical counterpart in place; the flag stays unset and the # next startup rescans. conflicted_canonicals: set[str] = set() real_create_errors = [] for e in errors: info = e.get("create") if isinstance(e, dict) else None if info is not None and info.get("status") == 409: if info.get("_id"): conflicted_canonicals.add(info["_id"]) continue real_create_errors.append(e) if real_create_errors: raise RuntimeError( f"Canonical edge-id migration: {len(real_create_errors)} " f"create error(s) in {self._edges_index}; aborting startup " f"(no source rows deleted)" ) # The canonical pre-existed (the create did not write it), so fold # *every* doc this batch mapped to it — none is the create winner — # then delete their old ids (in _merge_into_canonical_edge). for canonical in conflicted_canonicals: await self._merge_into_canonical_edge( canonical, docs_by_canonical[canonical] ) # Phase 2 — every create succeeded, 409'd-then-merged, so the # canonical now exists for all; delete the old ids. delete 404 is # benign (another run already removed it); any other delete error # fails fast. delete_actions = [ {"_op_type": "delete", "_index": self._edges_index, "_id": old_id} for _canonical, old_id, _source in batch ] _ds, derrors = await _run_chunked_async_bulk( self.client, delete_actions, max_payload_bytes=self._max_upsert_payload_bytes, max_records_per_batch=self._max_delete_records_per_batch, log_prefix=f"[{self.workspace}] {self.namespace} edges:", what="canonical edge-id migration (delete)", raise_on_error=False, ) real_delete_errors = [ e for e in derrors if not ( isinstance(e, dict) and e.get("delete", {}).get("status") == 404 ) ] if real_delete_errors: raise RuntimeError( f"Canonical edge-id migration: {len(real_delete_errors)} " f"delete error(s) in {self._edges_index}; aborting startup" ) scroll_id = None try: response = await self.client.search( index=self._edges_index, body={"query": {"match_all": {}}, "sort": ["_doc"]}, scroll="5m", size=1000, ) while True: scroll_id = response.get("_scroll_id") hits = response.get("hits", {}).get("hits", []) if not hits: break for hit in hits: scanned += 1 source = hit.get("_source", {}) src = source.get("source_node_id") tgt = source.get("target_node_id") if not src or not tgt: continue canonical = _canonical_edge_id(src, tgt) if hit["_id"] == canonical: continue # Queue (canonical, old_id, source); the create/delete # split happens in _flush_pending so a failed create never # takes its source row with it. pending.append((canonical, hit["_id"], source)) migrated += 1 if len(pending) >= flush_at: await _flush_pending() if scanned >= next_progress: logger.info( f"[{self.workspace}] Canonical edge-id migration " f"progress: scanned {scanned}" + (f"/{total}" if total is not None else "") + f", migrated {migrated} so far" ) next_progress += _EDGE_MIGRATION_PROGRESS_INTERVAL response = await self.client.scroll( scroll_id=scroll_id, scroll="5m" ) await _flush_pending() finally: if scroll_id is not None: try: await self.client.clear_scroll(scroll_id=scroll_id) except OpenSearchException: pass if migrated: # Make migrated docs visible to subsequent searches in one go. try: await self.client.indices.refresh(index=self._edges_index) except OpenSearchException: pass logger.info( f"[{self.workspace}] Canonical edge-id migration complete for " f"{self._edges_index}: scanned {scanned}, migrated {migrated}" ) # Mark complete (only reached on full success) so subsequent startups # skip the full scan. Legacy reciprocal duplicates collapsed onto one # canonical doc: the reverse doc's relation payload was merged into # the existing canonical (see _merge_into_canonical_edge) and the # reverse orientation deleted — no relation evidence lost. await self.client.indices.put_mapping( index=self._edges_index, body={"_meta": {**meta, _EDGE_ID_CANONICAL_META_FLAG: True}}, ) except OpenSearchException as e: # Fail fast: a transport/cluster error during migration must abort # startup (flag stays unset) rather than serve a half-migrated index. logger.error( f"[{self.workspace}] Canonical edge-id migration failed for " f"{self._edges_index}: {e}; aborting startup" ) raise async def _merge_into_canonical_edge( self, canonical_id: str, reverse_docs: list[tuple[str, dict[str, Any]]] ) -> None: """Merge legacy reverse-orientation doc(s)' payload into an existing canonical doc (the create-409 reciprocal-duplicate case) so deleting the reverse loses no relation evidence (mirrors mongo_impl's dedupe merge). ``reverse_docs`` carries every pending reverse doc in this batch that maps to ``canonical_id`` as ``(old_id, source)`` pairs (usually one, but >1 when a node pair has 3+ legacy docs); all are folded into the canonical in a single write, then deleted by their old ids. Uses optimistic concurrency (``if_seq_no``/``if_primary_term``) so a concurrent live write during a rolling deploy is never clobbered: on a version conflict we re-read — now including that write — and re-merge. Weight summing is per-fragment and not self-idempotent (see ``_merge_edge_payloads``), so idempotency across fail-fast retries comes from deleting each folded reverse right after the canonical write: a re-scan no longer finds it, so it is never folded (and summed) twice. The delete is best-effort — the batch's Phase-2 delete re-attempts the same ids — so it deliberately does not abort the migration; the only window in which a weight could double-count is a crash strictly between the canonical write and this delete, a bounded and non-fatal ranking perturbation for a one-time migration. """ for _attempt in range(3): try: current = await self.client.get( index=self._edges_index, id=canonical_id ) except NotFoundError: # Canonical vanished between the create-409 and now; recreate it # by merging the reverse sources together (nothing else to merge # against). reverse_sources = [source for _old_id, source in reverse_docs] recreated = { **reverse_sources[0], **_merge_edge_payloads(reverse_sources), } await self.client.index( index=self._edges_index, id=canonical_id, body=recreated ) await self._delete_folded_reverse_edges(reverse_docs) return base = current.get("_source", {}) reverse_sources = [source for _old_id, source in reverse_docs] merged = {**base, **_merge_edge_payloads([base, *reverse_sources])} try: await self.client.index( index=self._edges_index, id=canonical_id, body=merged, if_seq_no=current["_seq_no"], if_primary_term=current["_primary_term"], ) except ConflictError: # A concurrent write changed the canonical doc; re-read and # re-merge so we never overwrite that write with stale data. continue await self._delete_folded_reverse_edges(reverse_docs) return raise RuntimeError( f"Canonical edge-id migration: could not merge into {canonical_id} " f"after retries in {self._edges_index}; aborting startup" ) async def _delete_folded_reverse_edges( self, reverse_docs: list[tuple[str, dict[str, Any]]] ) -> None: """Delete legacy reverse docs whose payload was just folded into the canonical, so a fail-fast re-scan never re-folds (and re-sums) them. Best-effort: a 404 means another run already removed it, and any other error is swallowed (logged) rather than raised — the migration's Phase-2 bulk delete re-attempts these same ids, so failing here must not abort startup nor leave the canonical without its merged evidence. """ for old_id, _source in reverse_docs: try: await self.client.delete(index=self._edges_index, id=old_id) except NotFoundError: pass except OpenSearchException as e: logger.warning( f"[{self.workspace}] Canonical edge-id migration: could not " f"delete folded reverse doc {old_id} in {self._edges_index} " f"(Phase-2 delete will retry): {e}" ) async def finalize(self): """Release the OpenSearch client connection.""" if self.client is not None: await ClientManager.release_client(self.client) self.client = None # --- Basic queries --- async def has_node(self, node_id: str) -> bool: """Check whether a node exists in the graph.""" if not self._indices_ready: return False try: return await self.client.exists(index=self._nodes_index, id=node_id) except OpenSearchException as e: if _is_missing_index_error(e): self._mark_indices_missing() return False async def has_edge(self, source_node_id: str, target_node_id: str) -> bool: """Check whether an edge exists between two nodes. Startup migration is fail-fast, so after initialize() every edge is keyed by its canonical (sorted-pair) ``_id``. Point-check that single id with exists(), mirroring has_node() — real-time (translog-backed), independent of the index refresh cycle, no candidate-id fan-out. """ if not self._indices_ready: return False try: edge_id = _canonical_edge_id(source_node_id, target_node_id) return await self.client.exists(index=self._edges_index, id=edge_id) except OpenSearchException as e: if _is_missing_index_error(e): self._mark_indices_missing() return False async def node_degree(self, node_id: str) -> int: """Count the number of edges connected to a node.""" if not self._indices_ready: return 0 try: await self._refresh_graph_indices_if_dirty(refresh_edges=True) response = await self.client.count( index=self._edges_index, body={ "query": { "bool": { "should": [ {"term": {"source_node_id": node_id}}, {"term": {"target_node_id": node_id}}, ] } } }, ) return response.get("count", 0) except OpenSearchException as e: if _is_missing_index_error(e): self._mark_indices_missing() return 0 async def edge_degree(self, src_id: str, tgt_id: str) -> int: """Sum of degrees of both endpoint nodes.""" src_degree = await self.node_degree(src_id) tgt_degree = await self.node_degree(tgt_id) return src_degree + tgt_degree async def get_node(self, node_id: str) -> dict[str, str] | None: """Get a node document by ID, or None if not found.""" if not self._indices_ready: return None try: response = await _mget_optional_doc(self.client, self._nodes_index, node_id) if response is None: return None doc = response["_source"] doc["_id"] = response["_id"] return doc except OpenSearchException as e: if _is_missing_index_error(e): self._mark_indices_missing() return None async def get_edge( self, source_node_id: str, target_node_id: str ) -> dict[str, str] | None: """Get an edge between two nodes, or None. Edges are stored under their canonical (sorted-pair) ``_id`` once the fail-fast startup migration completes, so read that single id directly via mget — real-time (translog-backed), no candidate-id fan-out. """ if not self._indices_ready: return None try: edge_id = _canonical_edge_id(source_node_id, target_node_id) response = await _mget_optional_doc(self.client, self._edges_index, edge_id) if response is None: return None result = response["_source"] result["_id"] = response["_id"] return result except OpenSearchException as e: if _is_missing_index_error(e): self._mark_indices_missing() return None async def get_node_edges(self, source_node_id: str) -> list[tuple[str, str]] | None: """Get all (source, target) edge tuples connected to a node.""" if not self._indices_ready: return None try: await self._refresh_graph_indices_if_dirty(refresh_edges=True) query = { "bool": { "should": [ {"term": {"source_node_id": source_node_id}}, {"term": {"target_node_id": source_node_id}}, ] } } edges = [] pit = await self.client.create_pit( index=self._edges_index, params={"keep_alive": "1m"} ) pit_id = pit["pit_id"] try: search_after = None while True: body = { "query": query, "_source": ["source_node_id", "target_node_id"], "size": 10000, "pit": {"id": pit_id, "keep_alive": "1m"}, "sort": _pit_sort_with_composite_key( "source_node_id", "target_node_id" ), } if search_after: body["search_after"] = search_after response = await self.client.search(body=body) hits = response["hits"]["hits"] if not hits: break for hit in hits: edges.append( ( hit["_source"]["source_node_id"], hit["_source"]["target_node_id"], ) ) search_after = hits[-1]["sort"] if len(hits) < 10000: break finally: try: await self.client.delete_pit(body={"pit_id": [pit_id]}) except Exception: pass return edges except OpenSearchException as e: if _is_missing_index_error(e): self._mark_indices_missing() return None # --- Batch operations --- async def get_nodes_batch(self, node_ids: list[str]) -> dict[str, dict]: """Batch-fetch multiple nodes by ID.""" if not self._indices_ready: return {} try: response = await self.client.mget( index=self._nodes_index, body={"ids": node_ids} ) result = {} for doc in response["docs"]: if doc.get("found"): data = doc["_source"] data["_id"] = doc["_id"] result[doc["_id"]] = data return result except OpenSearchException as e: if _is_missing_index_error(e): self._mark_indices_missing() return {} async def node_degrees_batch(self, node_ids: list[str]) -> dict[str, int]: """Batch-fetch edge counts for multiple nodes using aggregations.""" if not node_ids: return {} if not self._indices_ready: return {} try: await self._refresh_graph_indices_if_dirty(refresh_edges=True) # Use a single query with aggregations for both source and target body = { "size": 0, "query": { "bool": { "should": [ {"terms": {"source_node_id": node_ids}}, {"terms": {"target_node_id": node_ids}}, ] } }, "aggs": { "source_degrees": { "terms": { "field": "source_node_id", "size": len(node_ids) * 2, } }, "target_degrees": { "terms": { "field": "target_node_id", "size": len(node_ids) * 2, } }, }, } response = await self.client.search(index=self._edges_index, body=body) result = {} for bucket in response["aggregations"]["source_degrees"]["buckets"]: if bucket["key"] in node_ids: result[bucket["key"]] = ( result.get(bucket["key"], 0) + bucket["doc_count"] ) for bucket in response["aggregations"]["target_degrees"]["buckets"]: if bucket["key"] in node_ids: result[bucket["key"]] = ( result.get(bucket["key"], 0) + bucket["doc_count"] ) return result except OpenSearchException as e: if _is_missing_index_error(e): self._mark_indices_missing() return {} async def get_nodes_edges_batch( self, node_ids: list[str] ) -> dict[str, list[tuple[str, str]]]: """Batch-fetch edge tuples for multiple nodes.""" result = {nid: [] for nid in node_ids} if not self._indices_ready: return result try: await self._refresh_graph_indices_if_dirty(refresh_edges=True) query = { "bool": { "should": [ {"terms": {"source_node_id": node_ids}}, {"terms": {"target_node_id": node_ids}}, ] } } pit = await self.client.create_pit( index=self._edges_index, params={"keep_alive": "1m"} ) pit_id = pit["pit_id"] try: search_after = None while True: body = { "query": query, "_source": ["source_node_id", "target_node_id"], "size": 10000, "pit": {"id": pit_id, "keep_alive": "1m"}, "sort": _pit_sort_with_composite_key( "source_node_id", "target_node_id" ), } if search_after: body["search_after"] = search_after response = await self.client.search(body=body) hits = response["hits"]["hits"] if not hits: break for hit in hits: src = hit["_source"]["source_node_id"] tgt = hit["_source"]["target_node_id"] if src in result: result[src].append((src, tgt)) if tgt in result: result[tgt].append((src, tgt)) search_after = hits[-1]["sort"] if len(hits) < 10000: break finally: try: await self.client.delete_pit(body={"pit_id": [pit_id]}) except Exception: pass except OpenSearchException as e: if _is_missing_index_error(e): self._mark_indices_missing() pass return result # --- Upsert operations --- async def upsert_node(self, node_id: str, node_data: dict[str, str]) -> None: """Insert or update a node. Adds entity_id for PPL compatibility.""" try: await self._ensure_indices_ready() doc = {k: v for k, v in node_data.items() if k != "_id"} doc["entity_id"] = node_id if node_data.get("source_id", ""): doc["source_ids"] = node_data["source_id"].split(GRAPH_FIELD_SEP) # No per-operation refresh: node reads use ID-based mget/exists # (translog, real-time). Search visibility after index_done_callback(). await self.client.index(index=self._nodes_index, id=node_id, body=doc) self._nodes_dirty = True except OpenSearchException as e: logger.error(f"[{self.workspace}] Error upserting node {node_id}: {e}") async def upsert_edge( self, source_node_id: str, target_node_id: str, edge_data: dict[str, str] ) -> None: """Insert or update an edge keyed by a canonical (sorted-pair) ``_id``. The canonical id collapses ``(src, tgt)`` and ``(tgt, src)`` onto one document, so this is idempotent by construction: concurrent reciprocal writers overwrite the same ``_id`` (last-write-wins) instead of racing into two docs. No ``exists(reverse)`` read-then-write needed. New writes are always canonical, and the startup migration is fail-fast (the service does not start until every legacy doc is on its canonical id), so there is no need to delete a reverse-orientation doc on each write — the index is canonical before any write happens. """ try: await self._ensure_indices_ready() # Ensure source node exists (don't overwrite if it already has data) if not await self.has_node(source_node_id): await self.upsert_node(source_node_id, {}) doc = {k: v for k, v in edge_data.items() if k != "_id"} doc["source_node_id"] = source_node_id doc["target_node_id"] = target_node_id if edge_data.get("source_id", ""): doc["source_ids"] = edge_data["source_id"].split(GRAPH_FIELD_SEP) edge_id = _canonical_edge_id(source_node_id, target_node_id) await self.client.index(index=self._edges_index, id=edge_id, body=doc) self._edges_dirty = True except OpenSearchException as e: logger.error( f"[{self.workspace}] Error upserting edge {source_node_id}->{target_node_id}: {e}" ) async def upsert_nodes_batch(self, nodes: list[tuple[str, dict[str, str]]]) -> None: """Batch insert/update multiple nodes using the OpenSearch bulk API. Args: nodes: List of (node_id, node_data) tuples. """ if not nodes: return try: await self._ensure_indices_ready() actions = [] for node_id, node_data in nodes: doc = {k: v for k, v in node_data.items() if k != "_id"} doc["entity_id"] = node_id if node_data.get("source_id", ""): doc["source_ids"] = node_data["source_id"].split(GRAPH_FIELD_SEP) actions.append( { "_op_type": "index", "_index": self._nodes_index, "_id": node_id, "_source": doc, } ) await _run_chunked_async_bulk( self.client, actions, max_payload_bytes=self._max_upsert_payload_bytes, max_records_per_batch=self._max_upsert_records_per_batch, log_prefix=f"[{self.workspace}] {self.namespace} nodes:", what="node upsert", raise_on_error=True, ) self._nodes_dirty = True except OpenSearchException as e: logger.error(f"[{self.workspace}] Error during batch node upsert: {e}") async def has_nodes_batch(self, node_ids: list[str]) -> set[str]: """Check existence of multiple nodes using a single mget request. Args: node_ids: List of node IDs to check. Returns: Set of node_ids that exist in the graph. """ if not node_ids: return set() if not self._indices_ready: return set() try: response = await self.client.mget( index=self._nodes_index, body={"ids": node_ids} ) return {doc["_id"] for doc in response.get("docs", []) if doc.get("found")} except OpenSearchException as e: if _is_missing_index_error(e): self._mark_indices_missing() return set() async def upsert_edges_batch( self, edges: list[tuple[str, str, dict[str, str]]] ) -> None: """Batch insert/update multiple edges using the OpenSearch bulk API. Each edge is keyed by its canonical (sorted-pair) ``_id`` (see ``_canonical_edge_id``), so reciprocal directions collapse onto one document with no reverse-direction look-up. Edges that map to the same canonical id within this batch are deduplicated last-write-wins. Args: edges: List of (source_node_id, target_node_id, edge_data) tuples. """ if not edges: return try: await self._ensure_indices_ready() # Ensure all source nodes exist (mirrors upsert_edge behaviour) source_ids = list({src for src, _tgt, _data in edges}) existing_sources = await self.has_nodes_batch(source_ids) missing_sources = [ (nid, {}) for nid in source_ids if nid not in existing_sources ] if missing_sources: await self.upsert_nodes_batch(missing_sources) # Key every edge by its canonical id and dedupe within the batch # (last-write-wins) so a single bulk request carries one action per # logical edge regardless of direction. actions_by_id: dict[str, dict[str, Any]] = {} for src, tgt, edge_data in edges: doc = {k: v for k, v in edge_data.items() if k != "_id"} doc["source_node_id"] = src doc["target_node_id"] = tgt if edge_data.get("source_id", ""): doc["source_ids"] = edge_data["source_id"].split(GRAPH_FIELD_SEP) edge_id = _canonical_edge_id(src, tgt) actions_by_id[edge_id] = { "_op_type": "index", "_index": self._edges_index, "_id": edge_id, "_source": doc, } actions = list(actions_by_id.values()) await _run_chunked_async_bulk( self.client, actions, max_payload_bytes=self._max_upsert_payload_bytes, max_records_per_batch=self._max_upsert_records_per_batch, log_prefix=f"[{self.workspace}] {self.namespace} edges:", what="edge upsert", raise_on_error=True, ) self._edges_dirty = True except OpenSearchException as e: logger.error(f"[{self.workspace}] Error during batch edge upsert: {e}") # --- Delete operations --- async def delete_node(self, node_id: str) -> None: """Delete a node and all its connected edges. Marks node and edge search views dirty so refresh happens lazily on the next search/count-based graph read. Uses conflicts="proceed" to tolerate already-deleted matches. """ try: # Refresh edge search view so delete_by_query sees all un-flushed writes. await self._refresh_graph_indices_if_dirty(refresh_edges=True) # Delete all edges referencing this node body = { "query": { "bool": { "should": [ {"term": {"source_node_id": node_id}}, {"term": {"target_node_id": node_id}}, ] } } } await self.client.delete_by_query( index=self._edges_index, body=body, params={"conflicts": "proceed"}, ) # Delete the node try: await self.client.delete(index=self._nodes_index, id=node_id) except NotFoundError: pass self._nodes_dirty = True self._edges_dirty = True except OpenSearchException as e: logger.error(f"[{self.workspace}] Error deleting node {node_id}: {e}") async def remove_nodes(self, nodes: list[str]) -> None: """Batch-delete multiple nodes and their connected edges. Marks node and edge search views dirty so refresh happens lazily on the next search/count-based graph read. Uses conflicts="proceed" to tolerate already-deleted matches. """ if not nodes: return logger.info(f"[{self.workspace}] Deleting {len(nodes)} nodes") try: # Refresh edge search view so delete_by_query sees all un-flushed writes. await self._refresh_graph_indices_if_dirty(refresh_edges=True) # Delete edges body = { "query": { "bool": { "should": [ {"terms": {"source_node_id": nodes}}, {"terms": {"target_node_id": nodes}}, ] } } } await self.client.delete_by_query( index=self._edges_index, body=body, params={"conflicts": "proceed"}, ) # Delete nodes actions = [ {"_op_type": "delete", "_index": self._nodes_index, "_id": nid} for nid in nodes ] await _run_chunked_async_bulk( self.client, actions, max_payload_bytes=self._max_upsert_payload_bytes, max_records_per_batch=self._max_delete_records_per_batch, log_prefix=f"[{self.workspace}] {self.namespace} nodes:", what="node delete", raise_on_error=False, ) self._nodes_dirty = True self._edges_dirty = True except OpenSearchException as e: logger.error(f"[{self.workspace}] Error removing nodes: {e}") async def remove_edges(self, edges: list[tuple[str, str]]) -> None: """Batch-delete multiple edges by canonical ID (real-time). Startup migration is fail-fast, so every edge is keyed by its canonical (sorted-pair) ``_id``. Delete that single id per edge — dedup via a set so reciprocal inputs ``(A,B)`` and ``(B,A)`` collapse to one delete op. The raw bulk API does not raise on a 404 delete. Marks edge search views dirty so refresh happens lazily on the next search/count-based graph read. """ if not edges: return logger.info(f"[{self.workspace}] Deleting {len(edges)} edges") try: edge_ids = {_canonical_edge_id(src, tgt) for src, tgt in edges} operations = [ { "delete": { "_index": self._edges_index, "_id": edge_id, } } for edge_id in edge_ids ] # The raw bulk API does not auto-chunk (unlike helpers.async_bulk), # so split the operation list by the delete record-count cap to keep # each request bounded (mirrors mongo_impl's chunked delete_many). chunk = ( self._max_delete_records_per_batch if self._max_delete_records_per_batch > 0 else len(operations) ) if len(operations) > chunk: logger.info( f"[{self.workspace}] {self.namespace} edges: edge delete " f"{len(operations)} ops split into bulk chunks (chunk={chunk})" ) for i in range(0, len(operations), chunk): await self.client.bulk(body=operations[i : i + chunk]) self._edges_dirty = True except OpenSearchException as e: logger.error(f"[{self.workspace}] Error removing edges: {e}") # --- Query operations --- async def get_all_labels(self) -> list[str]: """Get all node IDs (entity names) sorted alphabetically.""" if not self._indices_ready: return [] try: await self._refresh_graph_indices_if_dirty(refresh_nodes=True) labels = [] pit = await self.client.create_pit( index=self._nodes_index, params={"keep_alive": "1m"} ) pit_id = pit["pit_id"] try: search_after = None while True: body = { "query": {"match_all": {}}, "_source": False, "size": 10000, "pit": {"id": pit_id, "keep_alive": "1m"}, "sort": _pit_sort_with_field("entity_id"), } if search_after: body["search_after"] = search_after response = await self.client.search(body=body) hits = response["hits"]["hits"] if not hits: break for hit in hits: labels.append(hit["_id"]) search_after = hits[-1]["sort"] if len(hits) < 10000: break finally: try: await self.client.delete_pit(body={"pit_id": [pit_id]}) except Exception: pass labels.sort() return labels except OpenSearchException as e: if _is_missing_index_error(e): self._mark_indices_missing() return [] async def _collect_node_ids( self, limit: int, exclude_ids: set[str] | None = None ) -> list[str]: """Collect up to `limit` node IDs, optionally skipping known IDs.""" if limit <= 0: return [] excluded = exclude_ids or set() if not excluded and limit <= 10000: body = { "query": {"match_all": {}}, "_source": False, "size": limit, } resp = await self.client.search(index=self._nodes_index, body=body) return [hit["_id"] for hit in resp["hits"]["hits"]] node_ids: list[str] = [] pit = await self.client.create_pit( index=self._nodes_index, params={"keep_alive": "1m"} ) pit_id = pit["pit_id"] try: search_after = None while len(node_ids) < limit: body = { "query": {"match_all": {}}, "_source": False, "size": 10000, "pit": {"id": pit_id, "keep_alive": "1m"}, "sort": _pit_sort_with_field("entity_id"), } if search_after: body["search_after"] = search_after resp = await self.client.search(body=body) hits = resp["hits"]["hits"] if not hits: break for hit in hits: node_id = hit["_id"] if node_id in excluded: continue node_ids.append(node_id) if len(node_ids) >= limit: break search_after = hits[-1].get("sort") if len(hits) < 10000: break finally: try: await self.client.delete_pit(body={"pit_id": [pit_id]}) except Exception: pass return node_ids @staticmethod def _edge_rank_key(edge: dict[str, Any]) -> tuple[int, float]: """Rank traversal edges by shallower depth first, then higher weight.""" depth = edge.get("_depth", edge.get("depth", 0)) try: depth_value = int(depth) except (TypeError, ValueError): depth_value = 0 weight = edge.get("weight", 0) try: weight_value = float(weight) except (TypeError, ValueError): weight_value = 0.0 return (depth_value, -weight_value) async def _append_edges_between_nodes( self, node_ids: list[str], result: KnowledgeGraph ) -> None: """Append all edges whose source and target are both in `node_ids`.""" if not node_ids: return edge_query = { "bool": { "must": [ {"terms": {"source_node_id": node_ids}}, {"terms": {"target_node_id": node_ids}}, ] } } seen_edges = set() pit = await self.client.create_pit( index=self._edges_index, params={"keep_alive": "1m"} ) pit_id = pit["pit_id"] try: search_after = None while True: edge_body = { "query": edge_query, "size": 10000, "pit": {"id": pit_id, "keep_alive": "1m"}, "sort": _pit_sort_with_composite_key( "source_node_id", "target_node_id" ), } if search_after: edge_body["search_after"] = search_after edge_resp = await self.client.search(body=edge_body) hits = edge_resp["hits"]["hits"] if not hits: break for hit in hits: e = hit["_source"] eid = f"{e['source_node_id']}-{e['target_node_id']}" if eid not in seen_edges: seen_edges.add(eid) result.edges.append(self._construct_graph_edge(eid, e)) search_after = hits[-1].get("sort") if len(hits) < 10000: break finally: try: await self.client.delete_pit(body={"pit_id": [pit_id]}) except Exception: pass def _construct_graph_node(self, node_id, node_data: dict) -> KnowledgeGraphNode: return KnowledgeGraphNode( id=node_id, labels=[node_id], properties={ k: v for k, v in node_data.items() if k not in ( "_id", "source_ids", "connected_edges", "edge_count", ) }, ) def _construct_graph_edge(self, edge_id: str, edge: dict) -> KnowledgeGraphEdge: return KnowledgeGraphEdge( id=edge_id, type=edge.get("relationship", ""), source=edge["source_node_id"], target=edge["target_node_id"], properties={ k: v for k, v in edge.items() if k not in ( "_id", "source_node_id", "target_node_id", "relationship", "source_ids", ) }, ) async def get_knowledge_graph( self, node_label: str, max_depth: int = 3, max_nodes: int = None, ) -> KnowledgeGraph: """Retrieve a subgraph via PPL graphlookup (if available) or client-side BFS.""" if not self._indices_ready: return KnowledgeGraph() if max_nodes is None: max_nodes = self.global_config.get("max_graph_nodes", 1000) else: max_nodes = min(max_nodes, self.global_config.get("max_graph_nodes", 1000)) result = KnowledgeGraph() start = time.perf_counter() try: await self._refresh_graph_indices_if_dirty( refresh_nodes=True, refresh_edges=True ) if node_label == "*": result = await self._get_knowledge_graph_all(max_nodes) elif self._ppl_graphlookup_available: result = await self._bfs_subgraph_ppl(node_label, max_depth, max_nodes) else: result = await self._bfs_subgraph(node_label, max_depth, max_nodes) duration = time.perf_counter() - start logger.info( f"[{self.workspace}] Subgraph query in {duration:.4f}s | " f"Nodes: {len(result.nodes)} | Edges: {len(result.edges)} | Truncated: {result.is_truncated}" ) except OpenSearchException as e: if _is_missing_index_error(e): self._mark_indices_missing() return KnowledgeGraph() logger.error(f"[{self.workspace}] Graph query failed: {e}") return result async def _get_knowledge_graph_all(self, max_nodes: int) -> KnowledgeGraph: """Get all nodes (up to max_nodes, ranked by degree) and their interconnecting edges.""" result = KnowledgeGraph() if not self._indices_ready: return result try: total = (await self.client.count(index=self._nodes_index))["count"] result.is_truncated = total > max_nodes if result.is_truncated: # Get top nodes by degree body = { "size": 0, "aggs": { "src": { "terms": { "field": "source_node_id", "size": max_nodes, } }, "tgt": { "terms": { "field": "target_node_id", "size": max_nodes, } }, }, } resp = await self.client.search(index=self._edges_index, body=body) degree_map = {} for bucket in resp["aggregations"]["src"]["buckets"]: degree_map[bucket["key"]] = ( degree_map.get(bucket["key"], 0) + bucket["doc_count"] ) for bucket in resp["aggregations"]["tgt"]["buckets"]: degree_map[bucket["key"]] = ( degree_map.get(bucket["key"], 0) + bucket["doc_count"] ) top_ids = sorted(degree_map, key=degree_map.get, reverse=True)[ :max_nodes ] if len(top_ids) < max_nodes: top_ids.extend( await self._collect_node_ids( max_nodes - len(top_ids), exclude_ids=set(top_ids) ) ) else: top_ids = await self._collect_node_ids(max_nodes) # Fetch node data if top_ids: node_resp = await self.client.mget( index=self._nodes_index, body={"ids": top_ids} ) found_node_ids = [] for doc in node_resp["docs"]: if doc.get("found"): found_node_ids.append(doc["_id"]) result.nodes.append( self._construct_graph_node(doc["_id"], doc["_source"]) ) await self._append_edges_between_nodes(found_node_ids, result) except OpenSearchException as e: if _is_missing_index_error(e): self._mark_indices_missing() return result logger.error(f"[{self.workspace}] Error in get_knowledge_graph_all: {e}") return result async def _bfs_subgraph_ppl( self, start_label: str, max_depth: int, max_nodes: int ) -> KnowledgeGraph: """Server-side BFS using PPL graphlookup command. Queries the nodes index for the start node, then uses graphLookup to traverse the edges index with bidirectional BFS. Uses `flatten` to unnest results and `depthField` for depth-based sorting. Falls back to client-side BFS on failure. """ result = KnowledgeGraph() # Verify start node exists start_node = await self.get_node(start_label) if not start_node: return result result.nodes.append(self._construct_graph_node(start_label, start_node)) if max_depth == 0: return result # PPL maxDepth=0 means 1 hop (direct match), so max_depth-1 ppl_depth = max(0, max_depth - 1) escaped = self._escape_ppl(start_label) ppl_query = ( f"source = {self._nodes_index}" f" | where entity_id = '{escaped}'" f" | graphLookup {self._edges_index}" f" start=entity_id" f" edge=target_node_id<->source_node_id" f" maxDepth={ppl_depth}" f" depthField=_depth" f" usePIT=true" f" as connected_edges" ) try: resp = await self.client.transport.perform_request( "POST", "/_plugins/_ppl", body={"query": ppl_query}, ) except Exception as e: logger.warning( f"[{self.workspace}] PPL graphlookup failed, falling back to client BFS: {e}" ) return await self._bfs_subgraph(start_label, max_depth, max_nodes) # Parse PPL response — schema-driven to avoid fragile positional access try: datarows = resp.get("datarows", []) schema = [col["name"] for col in resp.get("schema", [])] ce_idx = ( schema.index("connected_edges") if "connected_edges" in schema else -1 ) # Collect all edge rows from connected_edges arrays all_edge_rows = [] for row in datarows: edges_arr = row[ce_idx] if ce_idx >= 0 else [] if isinstance(edges_arr, list): all_edge_rows.extend(edges_arr) if not all_edge_rows: return result if isinstance(all_edge_rows[0], dict): sorted_edge_rows = sorted(all_edge_rows, key=self._edge_rank_key) else: # Positional array — column positions are unknown, fall back to client BFS logger.warning( f"[{self.workspace}] PPL returned positional arrays, falling back to client BFS" ) return await self._bfs_subgraph(start_label, max_depth, max_nodes) except (KeyError, IndexError, TypeError, ValueError) as e: logger.warning( f"[{self.workspace}] Error parsing PPL response, falling back: {e}" ) return await self._bfs_subgraph(start_label, max_depth, max_nodes) ordered_node_ids = [start_label] discovered_nodes = {start_label} for edge_row in sorted_edge_rows: for node_id in ( edge_row.get("source_node_id"), edge_row.get("target_node_id"), ): if not node_id or node_id in discovered_nodes: continue discovered_nodes.add(node_id) if len(ordered_node_ids) < max_nodes: ordered_node_ids.append(node_id) result.is_truncated = len(discovered_nodes) > max_nodes # Batch fetch node data (start node already added) new_node_ids = [nid for nid in ordered_node_ids if nid != start_label] if new_node_ids: node_resp = await self.client.mget( index=self._nodes_index, body={"ids": new_node_ids} ) for doc in node_resp["docs"]: if doc.get("found"): result.nodes.append( self._construct_graph_node(doc["_id"], doc["_source"]) ) await self._append_edges_between_nodes(ordered_node_ids, result) return result @staticmethod def _escape_ppl(value: str) -> str: """Escape a string for safe inclusion in a PPL single-quoted literal. Escapes backslashes, single quotes, and control characters that could interfere with PPL query parsing. """ value = value.replace("\\", "\\\\").replace("'", "\\'") # Strip control characters that could break the PPL string literal value = value.replace("\n", " ").replace("\r", " ").replace("\t", " ") return value @staticmethod def _escape_wildcard(value: str) -> str: """Escape OpenSearch wildcard special characters in user input. Escapes \\, *, and ? so they are treated as literal characters rather than wildcard operators, preventing DoS via expensive patterns. """ # Escape backslash first, then wildcard metacharacters return value.replace("\\", "\\\\").replace("*", "\\*").replace("?", "\\?") async def _bfs_subgraph( self, start_label: str, max_depth: int, max_nodes: int ) -> KnowledgeGraph: """BFS traversal from a starting node, batching neighbor lookups per level.""" result = KnowledgeGraph() seen_nodes = set() # Verify start node exists start_node = await self.get_node(start_label) if not start_node: return result seen_nodes.add(start_label) result.nodes.append(self._construct_graph_node(start_label, start_node)) current_level = [start_label] for _ in range(max_depth): if not current_level or len(seen_nodes) >= max_nodes: break # Batch fetch all edges for current level body = { "query": { "bool": { "should": [ {"terms": {"source_node_id": current_level}}, {"terms": {"target_node_id": current_level}}, ] } }, "_source": ["source_node_id", "target_node_id"], "size": 10000, } try: resp = await self.client.search(index=self._edges_index, body=body) except OpenSearchException: break next_level = set() for hit in resp["hits"]["hits"]: src = hit["_source"]["source_node_id"] tgt = hit["_source"]["target_node_id"] if src not in seen_nodes: next_level.add(src) if tgt not in seen_nodes: next_level.add(tgt) # Limit to max_nodes new_ids = [] for nid in next_level: if len(seen_nodes) + len(new_ids) >= max_nodes: break new_ids.append(nid) if new_ids: # Batch fetch node data node_resp = await self.client.mget( index=self._nodes_index, body={"ids": new_ids} ) for doc in node_resp["docs"]: if doc.get("found"): seen_nodes.add(doc["_id"]) result.nodes.append( self._construct_graph_node(doc["_id"], doc["_source"]) ) current_level = new_ids # Fetch all edges between seen nodes using PIT scrolling all_ids = list(seen_nodes) if all_ids: try: await self._append_edges_between_nodes(all_ids, result) except OpenSearchException: pass result.is_truncated = len(seen_nodes) >= max_nodes return result async def get_all_nodes(self) -> list[dict]: """Get all nodes with their properties.""" if not self._indices_ready: return [] try: await self._refresh_graph_indices_if_dirty(refresh_nodes=True) nodes = [] pit = await self.client.create_pit( index=self._nodes_index, params={"keep_alive": "1m"} ) pit_id = pit["pit_id"] try: search_after = None while True: body = { "query": {"match_all": {}}, "size": 10000, "pit": {"id": pit_id, "keep_alive": "1m"}, "sort": _pit_sort_with_field("entity_id"), } if search_after: body["search_after"] = search_after response = await self.client.search(body=body) hits = response["hits"]["hits"] if not hits: break for hit in hits: node = hit["_source"] node["id"] = hit["_id"] nodes.append(node) search_after = hits[-1]["sort"] if len(hits) < 10000: break finally: try: await self.client.delete_pit(body={"pit_id": [pit_id]}) except Exception: pass return nodes except OpenSearchException as e: if _is_missing_index_error(e): self._mark_indices_missing() return [] async def get_all_edges(self) -> list[dict]: """Get all edges with source/target fields added.""" if not self._indices_ready: return [] try: await self._refresh_graph_indices_if_dirty(refresh_edges=True) edges = [] pit = await self.client.create_pit( index=self._edges_index, params={"keep_alive": "1m"} ) pit_id = pit["pit_id"] try: search_after = None while True: body = { "query": {"match_all": {}}, "size": 10000, "pit": {"id": pit_id, "keep_alive": "1m"}, "sort": _pit_sort_with_composite_key( "source_node_id", "target_node_id" ), } if search_after: body["search_after"] = search_after response = await self.client.search(body=body) hits = response["hits"]["hits"] if not hits: break for hit in hits: edge = hit["_source"] edge["source"] = edge.get("source_node_id") edge["target"] = edge.get("target_node_id") edges.append(edge) search_after = hits[-1]["sort"] if len(hits) < 10000: break finally: try: await self.client.delete_pit(body={"pit_id": [pit_id]}) except Exception: pass return edges except OpenSearchException as e: if _is_missing_index_error(e): self._mark_indices_missing() return [] async def get_popular_labels(self, limit: int = 300) -> list[str]: """Get node labels ranked by edge degree (most connected first).""" if not self._indices_ready: return [] try: await self._refresh_graph_indices_if_dirty(refresh_edges=True) body = { "size": 0, "aggs": { "src": {"terms": {"field": "source_node_id", "size": limit * 2}}, "tgt": {"terms": {"field": "target_node_id", "size": limit * 2}}, }, } response = await self.client.search(index=self._edges_index, body=body) degree_map = {} for bucket in response["aggregations"]["src"]["buckets"]: degree_map[bucket["key"]] = ( degree_map.get(bucket["key"], 0) + bucket["doc_count"] ) for bucket in response["aggregations"]["tgt"]["buckets"]: degree_map[bucket["key"]] = ( degree_map.get(bucket["key"], 0) + bucket["doc_count"] ) sorted_labels = sorted(degree_map, key=degree_map.get, reverse=True)[:limit] return sorted_labels except OpenSearchException as e: if _is_missing_index_error(e): self._mark_indices_missing() return [] async def search_labels(self, query: str, limit: int = 50) -> list[str]: """Search node labels with wildcard and prefix matching.""" query = query.strip() if not query: return [] if not self._indices_ready: return [] try: await self._refresh_graph_indices_if_dirty(refresh_nodes=True) body = { "query": { "bool": { "should": [ {"term": {"entity_id": {"value": query, "boost": 10}}}, { "prefix": { "entity_id": {"value": query.lower(), "boost": 5} } }, { "wildcard": { "entity_id": { "value": f"*{self._escape_wildcard(query.lower())}*", "case_insensitive": True, "boost": 2, } } }, ] } }, "_source": False, "size": limit, } response = await self.client.search(index=self._nodes_index, body=body) return [hit["_id"] for hit in response["hits"]["hits"]] except OpenSearchException as e: if _is_missing_index_error(e): self._mark_indices_missing() return [] async def index_done_callback(self) -> None: """Refresh both node and edge indices.""" if not self._indices_ready: return try: await self._refresh_graph_indices_if_dirty( refresh_nodes=True, refresh_edges=True ) except OpenSearchException as e: if _is_missing_index_error(e): self._mark_indices_missing() return raise async def drop(self) -> dict[str, str]: """Delete both node and edge indices.""" errors = [] for idx in (self._nodes_index, self._edges_index): try: await self.client.indices.delete(index=idx) logger.info(f"[{self.workspace}] Dropped graph index: {idx}") except NotFoundError: logger.info( f"[{self.workspace}] Graph index already missing during drop: {idx}" ) except OpenSearchException as e: errors.append(f"{idx}: {e}") logger.error( f"[{self.workspace}] Error dropping graph index {idx}: {e}" ) except Exception as e: errors.append(f"{idx}: {e}") logger.error( f"[{self.workspace}] Unexpected error dropping graph index {idx}: {e}" ) self._mark_indices_missing() if errors: return { "status": "error", "message": "Failed to drop graph indices: " + "; ".join(errors), } try: logger.info(f"[{self.workspace}] Dropped graph indices") return {"status": "success", "message": "Graph indices dropped"} except Exception as e: logger.error(f"[{self.workspace}] Error finalizing graph drop: {e}") return {"status": "error", "message": str(e)} @final @dataclass class OpenSearchVectorDBStorage(BaseVectorStorage): """Vector storage using OpenSearch k-NN plugin with corrected cosine score handling.""" client: AsyncOpenSearch = field(default=None) _index_name: str = field(default="", init=False) _index_ready: bool = field(default=False, init=False) def __init__( self, namespace, global_config, embedding_func, workspace=None, meta_fields=None ): super().__init__( namespace=namespace, workspace=workspace or "", global_config=global_config, embedding_func=embedding_func, meta_fields=meta_fields or set(), ) self.__post_init__() def __post_init__(self): validate_workspace(self.workspace) self._validate_embedding_func() self.workspace, self.final_namespace, self._index_name = _build_index_name( self.workspace, self.namespace ) kwargs = self.global_config.get("vector_db_storage_cls_kwargs", {}) cosine_threshold = kwargs.get("cosine_better_than_threshold") if cosine_threshold is None: raise ValueError( "cosine_better_than_threshold must be specified in vector_db_storage_cls_kwargs" ) self.cosine_better_than_threshold = cosine_threshold self._max_batch_size = self.global_config["embedding_batch_num"] # Pending writes are flushed via _flush_pending_vector_ops() during # index_done_callback() / finalize(). This batches many small upsert() # invocations into a single async_bulk roundtrip. See issue #2785. self._pending_vector_docs: dict[str, _PendingVectorDoc] = {} self._pending_vector_deletes: set[str] = set() # Namespace-keyed lock (multi-process safe) is initialised in # initialize(). All buffer reads / writes and any destructive server # mutation (delete_by_query, drop, finalize) are serialised through # this lock to keep in-process readers race-free during a flush and # to order cross-worker flushes against the same OpenSearch index. self._flush_lock = None ( self._max_upsert_payload_bytes, self._max_upsert_records_per_batch, self._max_delete_records_per_batch, ) = _resolve_bulk_batch_limits() async def initialize(self): """Initialize client and create k-NN vector index.""" async with get_data_init_lock(): if self.client is None: self.client = await ClientManager.get_client() await self._create_knn_index_if_not_exists() self._index_ready = True logger.debug( f"[{self.workspace}] OpenSearch Vector storage initialized: {self._index_name}" ) if self._flush_lock is None: self._flush_lock = get_namespace_lock( self.namespace, workspace=self.workspace ) async def _ensure_index_ready(self): """Recreate the vector index before the next write if it is missing.""" if self._index_ready: return async with get_data_init_lock(): if self.client is None: self.client = await ClientManager.get_client() if not self._index_ready: await self._create_knn_index_if_not_exists() self._index_ready = True def _mark_index_missing(self): """Mark the vector index as unavailable for subsequent read short-circuiting.""" self._index_ready = False async def _create_knn_index_if_not_exists(self): try: if await self.client.indices.exists(index=self._index_name): # Validate existing index dimension try: mapping = await self.client.indices.get_mapping( index=self._index_name ) existing_dim = ( mapping[self._index_name]["mappings"]["properties"] .get("vector", {}) .get("dimension") ) expected_dim = self.embedding_func.embedding_dim if existing_dim is not None and existing_dim != expected_dim: raise ValueError( f"Vector dimension mismatch! Index '{self._index_name}' has " f"dimension {existing_dim}, but current embedding model expects " f"dimension {expected_dim}. Please drop the existing index or " f"use an embedding model with matching dimensions." ) except (KeyError, TypeError): logger.warning( f"[{self.workspace}] Could not read vector mapping for index " f"'{self._index_name}'; skipping dimension validation" ) return ef_construction = int( _get_opensearch_env("OPENSEARCH_KNN_EF_CONSTRUCTION", "200") ) m = int(_get_opensearch_env("OPENSEARCH_KNN_M", "16")) ef_search = int(_get_opensearch_env("OPENSEARCH_KNN_EF_SEARCH", "100")) body = { "settings": { "index": { "knn": True, "knn.algo_param.ef_search": ef_search, "number_of_shards": _get_index_number_of_shards(), "number_of_replicas": _get_index_number_of_replicas(), } }, "mappings": { "properties": { "vector": { "type": "knn_vector", "dimension": self.embedding_func.embedding_dim, "method": { "name": "hnsw", "space_type": "cosinesimil", "engine": "lucene", "parameters": { "ef_construction": ef_construction, "m": m, }, }, }, "content": {"type": "text"}, "entity_name": {"type": "keyword"}, "src_id": {"type": "keyword"}, "tgt_id": {"type": "keyword"}, "file_path": {"type": "keyword"}, "created_at": {"type": "long"}, }, "dynamic": True, }, } await self.client.indices.create(index=self._index_name, body=body) logger.info( f"[{self.workspace}] Created k-NN index: {self._index_name} " f"(dim={self.embedding_func.embedding_dim})" ) except RequestError as e: if "resource_already_exists_exception" not in str(e): logger.error(f"[{self.workspace}] Error creating k-NN index: {e}") raise except OpenSearchException as e: logger.error(f"[{self.workspace}] Error creating k-NN index: {e}") raise async def finalize(self): """Flush pending writes and release the OpenSearch client connection. Regular flush failures (any ``Exception``) are captured so they can be re-surfaced as a ``RuntimeError`` that names the unflushed buffer counts -- otherwise ``LightRAG.finalize_storages()`` would log the storage as successfully finalized while writes silently failed to reach OpenSearch. ``BaseException`` subclasses other than ``Exception`` (notably ``asyncio.CancelledError`` / ``KeyboardInterrupt`` / ``SystemExit``) are NOT caught: they propagate through the ``finally`` block so shutdown cancellation is honoured and not silently swallowed. The client is released in ``finally`` so it does not leak whether the flush succeeded, failed, or was cancelled. """ flush_error: Exception | None = None try: try: await self._flush_pending_vector_ops() except Exception as e: flush_error = e finally: if self.client is not None: await ClientManager.release_client(self.client) self.client = None pending_docs = len(self._pending_vector_docs) pending_deletes = len(self._pending_vector_deletes) if flush_error is not None: raise RuntimeError( f"[{self.workspace}] OpenSearchVectorDBStorage.finalize() " f"flush raised; {pending_docs} pending upserts and " f"{pending_deletes} pending deletes were left buffered " f"(client released, data lost)" ) from flush_error if pending_docs or pending_deletes: raise RuntimeError( f"[{self.workspace}] OpenSearchVectorDBStorage.finalize() " f"left {pending_docs} pending upserts and {pending_deletes} " f"pending deletes buffered after final flush attempt " f"(transient bulk failure); these writes have been lost" ) async def upsert(self, data: dict[str, dict[str, Any]]) -> None: """Buffer vector docs for embedding and batched flush. Docs are buffered in ``self._pending_vector_docs`` and flushed in a single ``async_bulk`` call during ``index_done_callback()`` / ``finalize()``. This is a behavioral change relative to per-call ``async_bulk``: writes are not durable in OpenSearch until the next flush, which matches the contract used by other LightRAG storage backends ("changes will be persisted during the next index_done_callback"). Embedding is deferred to the flush path so repeated upserts of the same id and many small upsert calls can be embedded once in a single batch. Flush holds the namespace lock while embedding and bulk indexing so cross-worker destructive mutations cannot interleave with partially-flushed vector writes. """ if not data: return await self._ensure_index_ready() logger.debug( f"[{self.workspace}] Buffering {len(data)} vectors for {self.namespace}" ) current_time = int(time.time()) pending_docs: list[tuple[str, _PendingVectorDoc]] = [] for i, (k, v) in enumerate(data.items(), start=1): content = v["content"] source = { "created_at": current_time, **{k1: v1 for k1, v1 in v.items() if k1 in self.meta_fields}, } pending_docs.append( ( k, _PendingVectorDoc( source=source, content=content, ), ) ) await _cooperative_yield(i) # Buffer: an upsert overrides a pending delete on the same id. async with self._flush_lock: for doc_id, pending_doc in pending_docs: self._pending_vector_deletes.discard(doc_id) self._pending_vector_docs[doc_id] = pending_doc async def _flush_pending_vector_ops(self) -> None: """Flush buffered vector upserts and deletes via a single async_bulk call. Concurrency contract: the entire flush, including deferred embedding, runs under ``_flush_lock`` (a ``get_namespace_lock`` instance), and so do all buffer reads / writes and destructive server mutations on this storage. That keeps the operation sequential within the process and orders concurrent cross-worker flushes against the same OpenSearch index. Embedding deliberately runs *inside* this lock (not in ``upsert`` or lock-free): it makes deferred embedding and bulk indexing atomic against concurrent upserts and destructive mutations (``drop`` / ``delete_entity_relation``). This is what lets ``index_done_callback`` / ``finalize`` promise that every buffered vector is embedded and persisted on return. Moving embedding out of the lock to avoid blocking reads would let a destructive op interleave between embed and bulk and resurrect or drop vectors out of order -- do not do it. Failure handling: * If ``_ensure_index_ready`` raises, the buffers are left intact and the next flush will retry. * If embedding raises, the buffers are left intact and the next flush will retry. Model providers already retry internally, so this is treated like a persistence failure. * If ``async_bulk`` itself raises (network / parse error), the buffers are left intact and the next flush will retry. Index ops are idempotent on ``_id`` and a re-issued delete on a missing doc is filtered out as 404 by ``_extract_bulk_failed_ids``. * Per-doc retryable failures (408 / 429 / 5xx) stay in the buffer for the next flush. * Per-doc non-retryable failures (most 4xx, mapping errors) are cleared from the buffer and logged with a sample of (op, id, status, error) so operators can diagnose them. """ async with self._flush_lock: if not self._pending_vector_docs and not self._pending_vector_deletes: return if self.client is None: return # If the index disappeared between writes (e.g. read path # marked it missing), recreate it now. Failure leaves the # buffers untouched and bubbles up to the caller. await self._ensure_index_ready() pending_docs = self._pending_vector_docs pending_deletes = self._pending_vector_deletes docs_to_embed = [ (doc_id, pending_doc) for doc_id, pending_doc in pending_docs.items() if pending_doc.vector is None ] if docs_to_embed: contents = [pending_doc.content for _, pending_doc in docs_to_embed] batches = [ contents[i : i + self._max_batch_size] for i in range(0, len(contents), self._max_batch_size) ] # TEMP diagnostic (remove later): confirm deferred batching is # actually coalescing per-id upserts. defer working -> docs >> # batches; eager/per-id -> docs == batches == 1 every flush. logger.info( f"[{self.workspace}] {self.namespace} flush: embedding " f"{len(docs_to_embed)} vectors in {len(batches)} batch(es) " f"(batch_num={self._max_batch_size})" ) try: embeddings_list = await asyncio.gather( *[ self.embedding_func(batch, context="document") for batch in batches ] ) except Exception as e: logger.error( f"[{self.workspace}] Error embedding pending vector ops " f"(upserts={len(docs_to_embed)}): {e}" ) raise embeddings = np.concatenate(embeddings_list) # Explicit check (not assert): a count mismatch would silently # truncate via zip() under `python -O`, mis-pairing vectors with # docs. Raise instead so buffers stay intact for the next flush. if len(embeddings) != len(docs_to_embed): raise RuntimeError( f"[{self.workspace}] Embedding count mismatch: expected " f"{len(docs_to_embed)}, got {len(embeddings)}" ) for i, ((_, pending_doc), embedding) in enumerate( zip(docs_to_embed, embeddings), start=1 ): pending_doc.vector = embedding.tolist() await _cooperative_yield(i) # Deletes and upserts go as separate async_bulk requests so the # delete record-count cap can differ from the upsert cap (mirrors # mongo_impl's separate upsert/delete phases). The two buffers are # disjoint -- delete() pops from pending_docs and upsert() discards # from pending_deletes -- so request ordering is irrelevant. delete_actions: list[dict[str, Any]] = [ { "_op_type": "delete", "_index": self._index_name, "_id": doc_id, } for doc_id in pending_deletes ] committed_doc_ids: set[str] = set() index_actions: list[dict[str, Any]] = [] for doc_id, pending_doc in pending_docs.items(): if pending_doc.vector is None: continue committed_doc_ids.add(doc_id) index_actions.append( { "_op_type": "index", "_index": self._index_name, "_id": doc_id, "_source": { **pending_doc.source, "vector": pending_doc.vector, }, } ) if not delete_actions and not index_actions: return try: # No per-operation refresh: search visibility is established # by the refresh in index_done_callback(). log_prefix = f"[{self.workspace}] {self.namespace} flush:" _, del_failed = await _run_chunked_async_bulk( self.client, delete_actions, max_payload_bytes=self._max_upsert_payload_bytes, max_records_per_batch=self._max_delete_records_per_batch, log_prefix=log_prefix, what="delete", raise_on_error=False, ) _, idx_failed = await _run_chunked_async_bulk( self.client, index_actions, max_payload_bytes=self._max_upsert_payload_bytes, max_records_per_batch=self._max_upsert_records_per_batch, log_prefix=log_prefix, what="upsert", raise_on_error=False, ) failed = list(del_failed) + list(idx_failed) except OpenSearchException as e: logger.error( f"[{self.workspace}] Error flushing vector ops " f"(upserts={len(pending_docs)}, " f"deletes={len(pending_deletes)}): {e}" ) # Bulk did not return per-doc statuses, so keep everything # buffered for the next flush. raise retryable_ids, non_retryable_ops = _extract_bulk_failed_ids(failed) # Keep ONLY retryable ops buffered for the next flush. Successful # ops are popped; non-retryable (permanent 4xx) ops are dropped # here, not retained: a permanently-unwritable op can never land, # so keeping it would replay-and-refail on every later flush and # poison every caller that shares this buffer — including direct # flush paths that never run the pipeline's cleanup. The raise # below (not retention) is what surfaces the failure and prevents # a silent PROCESSED. keep_ids = retryable_ids for doc_id in committed_doc_ids: if doc_id not in keep_ids: pending_docs.pop(doc_id, None) new_deletes: set[str] = { doc_id for doc_id in pending_deletes if doc_id in keep_ids } pending_deletes.clear() pending_deletes.update(new_deletes) if retryable_ids: logger.warning( f"[{self.workspace}] {len(retryable_ids)} vector ops will " f"retry on the next flush (transient failure)" ) if non_retryable_ops: sample = non_retryable_ops[:5] sample_text = ", ".join( f"{op.op}/{op.doc_id}/status={op.status}/{op.error}" for op in sample ) # A permanent (non-retryable) bulk failure means the data did # not land. Raise so index_done_callback surfaces it and the # pipeline aborts instead of marking the document PROCESSED. raise RuntimeError( f"[{self.workspace}] {self.namespace} flush: " f"{len(non_retryable_ops)} vector ops failed permanently " f"(non-retryable). Sample: {sample_text}" ) async def query( self, query: str, top_k: int, query_embedding: list[float] = None ) -> list[dict[str, Any]]: """k-NN similarity search with cosine score conversion for lucene engine.""" if not self._index_ready: return [] if query_embedding is not None: query_vector = ( query_embedding.tolist() if hasattr(query_embedding, "tolist") else list(query_embedding) ) else: embedding = await self.embedding_func( [query], context="query", _priority=DEFAULT_QUERY_PRIORITY ) query_vector = embedding[0].tolist() search_body = { "size": top_k, "query": {"knn": {"vector": {"vector": query_vector, "k": top_k}}}, "_source": {"excludes": ["vector"]}, } try: response = await self.client.search( index=self._index_name, body=search_body ) results = [] for hit in response["hits"]["hits"]: # OpenSearch k-NN with lucene engine and cosinesimil space type # returns scores that can be used directly as similarity measure. score = hit["_score"] if score >= self.cosine_better_than_threshold: doc = hit["_source"] doc["id"] = hit["_id"] doc["distance"] = score results.append(doc) logger.info( f"[{self.workspace}] Vector query on {self._index_name}: " f"top_k={top_k}, threshold={self.cosine_better_than_threshold}, " f"total_hits={len(response['hits']['hits'])}, " f"passed_filter={len(results)}, " f"score_range=[{min((h['_score'] for h in response['hits']['hits']), default=0):.4f}, " f"{max((h['_score'] for h in response['hits']['hits']), default=0):.4f}]" ) return results except OpenSearchException as e: if _is_missing_index_error(e): self._mark_index_missing() return [] logger.error(f"[{self.workspace}] Error querying vectors: {e}") return [] async def drop_pending_index_ops(self) -> None: """Discard buffered upserts/deletes (pipeline aborting on error).""" async with self._flush_lock: self._pending_vector_docs.clear() self._pending_vector_deletes.clear() async def index_done_callback(self) -> None: """Flush pending vector ops and refresh the index for k-NN visibility. Flush runs first so that a previously-missing index gets recreated by ``_flush_pending_vector_ops`` (via ``_ensure_index_ready``) before any buffered writes are abandoned. The refresh step is skipped only when the index is still not ready after the flush attempt -- refreshing a half-built index is pointless. Durability contract: each call embeds and bulk-indexes the *entire* pending buffer in one shot. Deferred embedding runs inside ``_flush_pending_vector_ops``'s ``_flush_lock`` section (not in ``upsert``) precisely so this callback can guarantee every buffered vector is embedded and flushed together; only transient per-doc failures stay buffered for the next flush. Do not move embedding out of the lock -- see ``_flush_pending_vector_ops`` for why. """ await self._flush_pending_vector_ops() if not self._index_ready: return try: await self.client.indices.refresh(index=self._index_name) except OpenSearchException as e: if _is_missing_index_error(e): self._mark_index_missing() return raise async def get_by_id(self, id: str) -> dict[str, Any] | None: """Get a vector document by ID, with read-your-writes against the buffer. The ``vector`` field is stripped from the result to match every other LightRAG vector backend (see ``NanoVectorDBStorage.get_by_id``). Callers that need the embedding itself must use ``get_vectors_by_ids``. """ # Buffer lookups happen under the namespace lock so an in-flight # flush is observed as either "completely before" or "completely # after" -- never as a snapshot-swapped intermediate state. async with self._flush_lock: if id in self._pending_vector_deletes: return None pending = self._pending_vector_docs.get(id) if pending is not None: # pending.source is built in upsert from created_at + meta_fields # and never carries the embedding, so no "vector" strip is needed # here (unlike the mget path below, which excludes it server-side). doc = dict(pending.source) doc["id"] = id return doc if not self._index_ready: return None # Network IO outside the lock so mget RTT doesn't block flush. try: response = await _mget_optional_doc( self.client, self._index_name, id, source_excludes=["vector"], ) if response is None: return None doc = response["_source"] doc.pop("vector", None) # defensive in case _source_excludes is ignored doc["id"] = response["_id"] return doc except OpenSearchException as e: if _is_missing_index_error(e): self._mark_index_missing() return None logger.error(f"[{self.workspace}] Error getting vector {id}: {e}") return None async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]: """Get multiple vector documents by IDs (read-your-writes), preserving order. The ``vector`` field is stripped from each result; see ``get_by_id``. """ if not ids: return [] buffered: dict[str, dict[str, Any] | None] = {} remaining: list[str] = [] async with self._flush_lock: for doc_id in ids: if doc_id in self._pending_vector_deletes: buffered[doc_id] = None continue pending = self._pending_vector_docs.get(doc_id) if pending is not None: # pending.source never carries the embedding; see get_by_id. doc = dict(pending.source) doc["id"] = doc_id buffered[doc_id] = doc continue remaining.append(doc_id) index_ready = self._index_ready doc_map: dict[str, dict[str, Any] | None] = {} if remaining and index_ready: try: response = await self.client.mget( index=self._index_name, body={"ids": remaining}, _source_excludes=["vector"], ) for doc in response["docs"]: if doc.get("found"): data = doc["_source"] data.pop("vector", None) data["id"] = doc["_id"] doc_map[doc["_id"]] = data except OpenSearchException as e: if _is_missing_index_error(e): self._mark_index_missing() else: logger.error( f"[{self.workspace}] Error getting vectors by ids: {e}" ) return [ buffered[doc_id] if doc_id in buffered else doc_map.get(doc_id) for doc_id in ids ] async def get_vectors_by_ids(self, ids: list[str]) -> dict[str, list[float]]: """Get vector embeddings for given IDs, with read-your-writes.""" if not ids: return {} result: dict[str, list[float]] = {} remaining: list[str] = [] async with self._flush_lock: docs_to_embed: list[tuple[str, _PendingVectorDoc]] = [] for doc_id in ids: if doc_id in self._pending_vector_deletes: continue pending = self._pending_vector_docs.get(doc_id) if pending is not None: if pending.vector is None: docs_to_embed.append((doc_id, pending)) else: result[doc_id] = pending.vector continue remaining.append(doc_id) index_ready = self._index_ready if docs_to_embed: contents = [pending_doc.content for _, pending_doc in docs_to_embed] batches = [ contents[i : i + self._max_batch_size] for i in range(0, len(contents), self._max_batch_size) ] try: embeddings_list = await asyncio.gather( *[ self.embedding_func(batch, context="document") for batch in batches ] ) except Exception as e: logger.error( f"[{self.workspace}] Error lazily embedding pending vectors " f"(upserts={len(docs_to_embed)}): {e}" ) raise embeddings = np.concatenate(embeddings_list) # Explicit check (not assert): see _flush_pending_vector_ops. if len(embeddings) != len(docs_to_embed): raise RuntimeError( f"[{self.workspace}] Embedding count mismatch: expected " f"{len(docs_to_embed)}, got {len(embeddings)}" ) for i, ((doc_id, pending_doc), embedding) in enumerate( zip(docs_to_embed, embeddings), start=1 ): pending_doc.vector = embedding.tolist() result[doc_id] = pending_doc.vector await _cooperative_yield(i) if not remaining: return result if not index_ready: return result try: response = await self.client.mget( index=self._index_name, body={"ids": remaining}, _source_includes=["vector"], ) for doc in response["docs"]: if doc.get("found") and "vector" in doc.get("_source", {}): result[doc["_id"]] = doc["_source"]["vector"] return result except OpenSearchException as e: if _is_missing_index_error(e): self._mark_index_missing() return result logger.error(f"[{self.workspace}] Error getting vectors: {e}") return result async def delete(self, ids: list[str]) -> None: """Buffer vector deletes for batched flush. A delete cancels any pending upsert for the same id; the actual bulk delete is performed by ``_flush_pending_vector_ops`` during the next ``index_done_callback`` / ``finalize`` call. """ if not ids: return if isinstance(ids, set): ids = list(ids) async with self._flush_lock: for doc_id in ids: self._pending_vector_docs.pop(doc_id, None) self._pending_vector_deletes.add(doc_id) logger.debug( f"[{self.workspace}] Buffered delete for {len(ids)} vectors in {self.namespace}" ) async def delete_entity(self, entity_name: str) -> None: """Buffer an entity vector delete by computing its hash ID.""" entity_id = compute_mdhash_id(entity_name, prefix="ent-") async with self._flush_lock: self._pending_vector_docs.pop(entity_id, None) self._pending_vector_deletes.add(entity_id) logger.debug(f"[{self.workspace}] Buffered delete for entity {entity_name}") async def delete_entity_relation(self, entity_name: str) -> None: """Delete all relation vectors where entity appears as src or tgt. The whole method runs under ``_flush_lock`` so the ``delete_by_query`` cannot interleave with an in-flight bulk indexing of a related doc. Buffered upserts that match are pruned in-memory; persisted rows are removed via the server-side ``delete_by_query``. Buffer semantics — post-prune with caller short-circuit contract: Matching pending upserts are pruned **only after** the server-side ``delete_by_query`` succeeds (or returns the equivalent of "index already missing"). On any other server failure the exception is re-raised and the pending buffer stays intact so a higher-level retry can still observe the buffered relation vectors. Correctness relies on the caller short-circuiting before ``index_done_callback`` can run; ``adelete_by_entity`` in ``utils_graph.py`` honors this. Previously this method pre-pruned the buffer and swallowed ``OpenSearchException`` into a ``logger.error`` — that combination silently dropped both the buffered relation vectors and the server-side failure signal, leaving the caller's graph + vector store permanently inconsistent. """ def _prune_pending() -> None: for doc_id in [ k for k, v in self._pending_vector_docs.items() if v.source.get("src_id") == entity_name or v.source.get("tgt_id") == entity_name ]: self._pending_vector_docs.pop(doc_id, None) async with self._flush_lock: if not self._index_ready: # No server state to mutate; buffer prune is the only # delete intent we can record. _prune_pending() return body = { "query": { "bool": { "should": [ {"term": {"src_id": entity_name}}, {"term": {"tgt_id": entity_name}}, ] } } } try: # conflicts="proceed" tolerates stale search view after refresh removal. await self.client.delete_by_query( index=self._index_name, body=body, params={"conflicts": "proceed"} ) except OpenSearchException as e: if _is_missing_index_error(e): # Index gone is equivalent to "all rows already # deleted" — safe to prune pending and treat as # success. self._mark_index_missing() _prune_pending() return logger.error( f"[{self.workspace}] Error deleting relations for {entity_name}: {e}" ) raise # Server-side delete succeeded — safe to prune the pending # buffer so subsequent flushes don't re-upsert the deleted # relations. _prune_pending() logger.debug( f"[{self.workspace}] Deleted relations for entity {entity_name}" ) async def drop(self) -> dict[str, str]: """Delete and recreate the vector index, discarding pending buffers. Runs entirely under ``_flush_lock`` so a concurrent flush / upsert cannot land writes against an index that is being deleted and rebuilt. """ async with self._flush_lock: # Pending writes are meaningless once the index is dropped. self._pending_vector_docs.clear() self._pending_vector_deletes.clear() try: try: await self.client.indices.delete(index=self._index_name) logger.info( f"[{self.workspace}] Dropped vector index: {self._index_name}" ) except NotFoundError: logger.info( f"[{self.workspace}] Vector index already missing during drop: {self._index_name}" ) # Recreate the index await self._create_knn_index_if_not_exists() self._index_ready = True logger.info( f"[{self.workspace}] Dropped and recreated vector index: {self._index_name}" ) return { "status": "success", "message": f"Vector index {self._index_name} dropped and recreated", } except OpenSearchException as e: self._mark_index_missing() logger.error(f"[{self.workspace}] Error dropping vector index: {e}") return {"status": "error", "message": str(e)} except Exception as e: self._mark_index_missing() logger.error( f"[{self.workspace}] Unexpected error dropping vector index: {e}" ) return {"status": "error", "message": str(e)}