"""
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)}
