import asyncio import json import os import time from typing import Any, final, Optional, Dict from dataclasses import dataclass, fields import numpy as np from lightrag.utils import ( logger, compute_mdhash_id, _cooperative_yield, validate_workspace, ) from ..base import BaseVectorStorage from ..constants import ( DEFAULT_MAX_FILE_PATH_LENGTH, DEFAULT_QUERY_PRIORITY, GRAPH_FIELD_SEP, ) from ..kg.shared_storage import get_data_init_lock, get_namespace_lock import pipmaster as pm if not pm.is_installed("pymilvus"): pm.install("pymilvus>=2.6.2") import configparser import grpc # type: ignore from pymilvus import ( # type: ignore MilvusClient, MilvusException, DataType, CollectionSchema, FieldSchema, ) from packaging import version config = configparser.ConfigParser() config.read("config.ini", "utf-8") @dataclass class _PendingVectorDoc: """Buffered vector upsert waiting for embedding and/or bulk flush.""" source: dict[str, Any] content: str vector: list[float] | None = None # Flush-time batching limits. Milvus' server-side proxy rejects any single # gRPC message larger than ~64MB (grpc.serverMaxRecvSize); the client library # cannot raise that ceiling, so large flushes must be split client-side. # The payload-byte budget is the primary limiter; the record-count caps are a # secondary guard that only binds when individual records are small. # Upsert and delete have separate count caps on purpose: upsert records each # carry a full embedding vector and are far heavier than delete pks, so the # upsert batch count is kept much smaller than the delete one. DEFAULT_MILVUS_UPSERT_MAX_PAYLOAD_BYTES = ( 32 * 1024 * 1024 ) # 32MB, well below the 64MB gRPC ceiling DEFAULT_MILVUS_UPSERT_MAX_RECORDS_PER_BATCH = 128 DEFAULT_MILVUS_DELETE_MAX_RECORDS_PER_BATCH = 1000 # Schema-migration resilience. A transient Milvus outage during the long # iterator-based migration must not kill worker startup: when pymilvus' # internal reconnect fails it closes the gRPC channel for good, so every later # call on the same client raises "Cannot invoke RPC on closed channel!". On a # connection-class failure the whole migration attempt is therefore retried # from scratch with a rebuilt MilvusClient (the source collection is untouched # until the final rename and each attempt drops the leftover _temp collection # first, so a full re-run is always safe). The max backoff is kept above # pymilvus' connection-pool idle health-check threshold (IDLE_THRESHOLD_SECONDS # = 30s in pymilvus 3.x) so a rebuilt client is guaranteed to get a # health-checked/recovered channel rather than the same dead pooled handler. DEFAULT_MILVUS_MIGRATION_MAX_RETRIES = 5 DEFAULT_MILVUS_MIGRATION_RETRY_BACKOFF_SECONDS = 5.0 DEFAULT_MILVUS_MIGRATION_RETRY_MAX_BACKOFF_SECONDS = 60.0 MILVUS_MIGRATION_RETRY_BACKOFF_MULTIPLIER = 3.0 DEFAULT_MILVUS_MIGRATION_ITERATOR_BATCH_SIZE = 2000 # Schema-migration memory back-pressure. The bulk copy inserts the whole source # collection into the temp collection with no client-side throttle, so the # Milvus data node accumulates growing insert-buffer segments until its own # auto-flush catches up; under a large migration this can exhaust server memory # (the temp collection is not loaded, so query-node memory is already bounded — # this is the data-node write buffer). Flushing every N migrated rows seals # those segments to object storage and blocks until the flush returns, giving # the server natural back-pressure. 0 disables periodic flush (rely on Milvus # auto-flush). The interval is kept coarse so it does not spawn many tiny # segments (which would burden later compaction). An optional per-batch sleep # lets a small server breathe between batches; 0 disables it. DEFAULT_MILVUS_MIGRATION_FLUSH_INTERVAL_ROWS = 50000 DEFAULT_MILVUS_MIGRATION_BATCH_SLEEP_SECONDS = 0.0 # Substrings that mark an exception as a transient connection failure (worth a # retry with a rebuilt client) rather than a schema/parameter error. MILVUS_RETRYABLE_CONNECTION_ERROR_MARKERS = ( "unavailable", # grpc UNAVAILABLE / "server unavailable" "ping timeout", "deadline exceeded", "connection refused", "connection reset", "broken pipe", "closed channel", # ValueError: Cannot invoke RPC on closed channel! "fail connecting to server", # pymilvus _wait_for_channel_ready "failed to connect", ) MILVUS_MAX_VARCHAR_BYTES = 65535 # The Milvus primary key. Truncating it would let two distinct ids collapse to # the same key (silent overwrite) and make the row unreachable by its real id # via get_by_id/delete, so it must never be truncated under any circumstance. MILVUS_PRIMARY_KEY_FIELDS = frozenset({"id"}) # Non-primary identity fields. They are not the Milvus primary key, so the row # stays uniquely keyed by `id` even if these collide after truncation (no # storage-level overwrite). On the live upsert path we still reject oversize # values so callers fix their input; during migration of pre-existing data we # truncate-and-warn instead, so a single pathological legacy value cannot abort # the whole collection migration. MILVUS_IDENTITY_VARCHAR_FIELDS = frozenset( {"id", "entity_name", "full_doc_id", "src_id", "tgt_id"} ) # Fields whose value is a GRAPH_FIELD_SEP-joined list of ids (chunk ids / file # paths). When such a value overflows we truncate on the last separator that # fits rather than mid-id, so we drop whole ids instead of leaving a dangling # partial id that resolves to nothing. MILVUS_SEPARATOR_JOINED_FIELDS = frozenset({"source_id", "file_path"}) # Supported index types SUPPORTED_INDEX_TYPES = { "AUTOINDEX", "HNSW", "HNSW_SQ", "HNSW_PQ", "HNSW_PRQ", "IVF_FLAT", "IVF_SQ8", "IVF_PQ", "DISKANN", "SCANN", } # Supported metric types SUPPORTED_METRIC_TYPES = {"COSINE", "L2", "IP"} # HNSW_SQ quantization types SUPPORTED_SQ_TYPES = {"SQ4U", "SQ6", "SQ8", "BF16", "FP16"} SUPPORTED_REFINE_TYPES = {"SQ6", "SQ8", "BF16", "FP16", "FP32"} # Index type version requirements # Important: HNSW_SQ was first introduced in Milvus 2.6.8 (not 2.5) INDEX_VERSION_REQUIREMENTS = { "HNSW_SQ": "2.6.8", # HNSW_SQ requires Milvus 2.6.8+ (supports sq_types such as SQ4U, SQ6, SQ8, BF16, FP16) } def _get_env_bool(key: str, default: bool = False) -> bool: """Parse environment variable as boolean""" val = os.environ.get(key, "").lower() if val in ("true", "1", "yes", "on"): return True elif val in ("false", "0", "no", "off"): return False return default def _get_env_int(key: str, default: int) -> int: """Parse environment variable as integer""" val = os.environ.get(key, "") if val: try: return int(val) except ValueError: logger.warning( f"Invalid integer value for {key}: {val}, using default {default}" ) return default @dataclass class MilvusIndexConfig: """ Milvus vector index configuration class Supports configuration via environment variables or initialization parameters. Initialization parameters take precedence over environment variables. """ # Base configuration index_type: Optional[str] = None metric_type: Optional[str] = None # HNSW series parameters hnsw_m: Optional[int] = None hnsw_ef_construction: Optional[int] = None hnsw_ef: Optional[int] = None # HNSW_SQ specific parameters sq_type: Optional[str] = None sq_refine: Optional[bool] = None sq_refine_type: Optional[str] = None sq_refine_k: Optional[int] = None # IVF series parameters ivf_nlist: Optional[int] = None ivf_nprobe: Optional[int] = None def __post_init__(self): """Load configuration from environment variables (init parameters take precedence)""" # Index type self.index_type = ( self.index_type or os.environ.get("MILVUS_INDEX_TYPE", "AUTOINDEX") ).upper() # Metric type self.metric_type = ( self.metric_type or os.environ.get("MILVUS_METRIC_TYPE", "COSINE") ).upper() # HNSW parameters # Defaults aligned with Milvus 2.4+ official documentation if self.hnsw_m is None: self.hnsw_m = _get_env_int("MILVUS_HNSW_M", 16) if self.hnsw_ef_construction is None: self.hnsw_ef_construction = _get_env_int("MILVUS_HNSW_EF_CONSTRUCTION", 360) if self.hnsw_ef is None: self.hnsw_ef = _get_env_int("MILVUS_HNSW_EF", 200) # HNSW_SQ parameters if self.sq_type is None: self.sq_type = os.environ.get("MILVUS_HNSW_SQ_TYPE", "SQ8").upper() if self.sq_refine is None: self.sq_refine = _get_env_bool("MILVUS_HNSW_SQ_REFINE", False) if self.sq_refine_type is None: self.sq_refine_type = os.environ.get( "MILVUS_HNSW_SQ_REFINE_TYPE", "FP32" ).upper() if self.sq_refine_k is None: self.sq_refine_k = _get_env_int("MILVUS_HNSW_SQ_REFINE_K", 10) # IVF parameters if self.ivf_nlist is None: self.ivf_nlist = _get_env_int("MILVUS_IVF_NLIST", 1024) if self.ivf_nprobe is None: self.ivf_nprobe = _get_env_int("MILVUS_IVF_NPROBE", 16) # Validate configuration self._validate() def _validate(self): """Validate configuration validity""" if self.index_type not in SUPPORTED_INDEX_TYPES: raise ValueError( f"Unsupported index type: {self.index_type}. " f"Supported: {SUPPORTED_INDEX_TYPES}" ) if self.metric_type not in SUPPORTED_METRIC_TYPES: raise ValueError( f"Unsupported metric type: {self.metric_type}. " f"Supported: {SUPPORTED_METRIC_TYPES}" ) if self.index_type == "HNSW_SQ": if self.sq_type not in SUPPORTED_SQ_TYPES: raise ValueError( f"Unsupported sq_type: {self.sq_type}. " f"Supported: {SUPPORTED_SQ_TYPES}" ) if self.sq_refine and self.sq_refine_type not in SUPPORTED_REFINE_TYPES: raise ValueError( f"Unsupported refine_type: {self.sq_refine_type}. " f"Supported: {SUPPORTED_REFINE_TYPES}" ) # Parameter range validation if not (2 <= self.hnsw_m <= 2048): raise ValueError(f"hnsw_m must be in [2, 2048], got {self.hnsw_m}") if self.hnsw_ef_construction < 1: raise ValueError( f"hnsw_ef_construction must be >= 1, got {self.hnsw_ef_construction}" ) if self.ivf_nlist < 1 or self.ivf_nlist > 65536: raise ValueError(f"ivf_nlist must be in [1, 65536], got {self.ivf_nlist}") def validate_milvus_version(self, server_version: str) -> None: """ Validate Milvus server version supports the configured index type Args: server_version: Milvus server version string (e.g., "2.6.9") Raises: ValueError: Version does not meet index type requirements """ current_ver = version.parse( server_version.split("-")[0] ) # Handle "2.6.9-dev" format # Check HNSW_SQ index type version requirements (requires 2.6.8+) if self.index_type == "HNSW_SQ": required = INDEX_VERSION_REQUIREMENTS["HNSW_SQ"] if current_ver < version.parse(required): raise ValueError( f"HNSW_SQ requires Milvus {required}+, " f"current version: {server_version}" ) logger.info( f"Milvus version {server_version} validated for index type " f"{self.index_type}" + (f" with sq_type {self.sq_type}" if self.index_type == "HNSW_SQ" else "") ) def build_index_params(self, index_params, field_name: str = "vector"): """ Build pymilvus index parameters Args: index_params: IndexParams instance (from compatibility helper or client.prepare_index_params()) field_name: Vector field name Returns: IndexParams object, or a dict fallback when direct API creation is needed. """ if index_params is None: if self.index_type == "AUTOINDEX": logger.info( "Using AUTOINDEX with direct API fallback because IndexParams is unavailable" ) return { "field_name": field_name, "index_type": self.index_type, "metric_type": self.metric_type, "params": {}, } raise RuntimeError( f"IndexParams not available but required for index type " f"'{self.index_type}'. Ensure pymilvus is installed correctly." ) params: Dict[str, Any] = {} # HNSW series indexes if self.index_type in ("HNSW", "HNSW_SQ", "HNSW_PQ", "HNSW_PRQ"): params["M"] = self.hnsw_m params["efConstruction"] = self.hnsw_ef_construction # HNSW_SQ specific parameters if self.index_type == "HNSW_SQ": params["sq_type"] = self.sq_type if self.sq_refine: params["refine"] = True params["refine_type"] = self.sq_refine_type # IVF series indexes elif self.index_type in ("IVF_FLAT", "IVF_SQ8", "IVF_PQ"): params["nlist"] = self.ivf_nlist # DISKANN / SCANN have no additional params index_params.add_index( field_name=field_name, index_type=self.index_type, metric_type=self.metric_type, params=params, ) logger.info( f"Milvus index configured: type={self.index_type}, " f"metric={self.metric_type}, params={params}" ) return index_params def build_search_params(self) -> Dict[str, Any]: """ Build search parameters Returns: Search parameters dictionary """ search_params: Dict[str, Any] = {} if self.index_type in ("HNSW", "HNSW_SQ", "HNSW_PQ", "HNSW_PRQ"): search_params["ef"] = self.hnsw_ef if self.index_type == "HNSW_SQ" and self.sq_refine: search_params["refine_k"] = self.sq_refine_k elif self.index_type in ("IVF_FLAT", "IVF_SQ8", "IVF_PQ"): search_params["nprobe"] = self.ivf_nprobe return {"params": search_params} if search_params else {} @classmethod def get_config_field_names(cls) -> set: """Get all configuration field names from the dataclass. This method provides a single source of truth for configuration parameter names, eliminating the need to maintain duplicate hardcoded lists elsewhere. Returns: Set of field names that can be used to extract configuration from kwargs """ return {f.name for f in fields(cls)} def to_dict(self) -> Dict[str, Any]: """Export configuration as dictionary (for logging/debugging)""" return { "index_type": self.index_type, "metric_type": self.metric_type, "hnsw_m": self.hnsw_m, "hnsw_ef_construction": self.hnsw_ef_construction, "hnsw_ef": self.hnsw_ef, "sq_type": self.sq_type if self.index_type == "HNSW_SQ" else None, "sq_refine": self.sq_refine if self.index_type == "HNSW_SQ" else None, "sq_refine_type": ( self.sq_refine_type if self.index_type == "HNSW_SQ" and self.sq_refine else None ), "sq_refine_k": ( self.sq_refine_k if self.index_type == "HNSW_SQ" and self.sq_refine else None ), "ivf_nlist": ( self.ivf_nlist if self.index_type.startswith("IVF") else None ), "ivf_nprobe": ( self.ivf_nprobe if self.index_type.startswith("IVF") else None ), } @final @dataclass class MilvusVectorDBStorage(BaseVectorStorage): def _get_milvus_connection_kwargs(self, include_db_name: bool = True) -> dict: """Build Milvus connection kwargs from env/config.""" connection_kwargs = { "uri": os.environ.get( "MILVUS_URI", config.get( "milvus", "uri", fallback=os.path.join( self.global_config["working_dir"], "milvus_lite.db" ), ), ), "user": os.environ.get( "MILVUS_USER", config.get("milvus", "user", fallback=None) ), "password": os.environ.get( "MILVUS_PASSWORD", config.get("milvus", "password", fallback=None), ), "token": os.environ.get( "MILVUS_TOKEN", config.get("milvus", "token", fallback=None) ), } db_name = os.environ.get( "MILVUS_DB_NAME", config.get("milvus", "db_name", fallback=None), ) if include_db_name and db_name: connection_kwargs["db_name"] = db_name return connection_kwargs def _get_milvus_db_name(self) -> Optional[str]: """Return the configured Milvus database name, if any.""" db_name = self._get_milvus_connection_kwargs(include_db_name=True).get( "db_name" ) if db_name is None: return None normalized_name = str(db_name).strip() return normalized_name or None def _create_milvus_client(self) -> MilvusClient: """Create a Milvus client and ensure the configured database exists.""" client = MilvusClient( **self._get_milvus_connection_kwargs(include_db_name=False) ) db_name = self._get_milvus_db_name() if not db_name: return client existing_databases = set(client.list_databases()) if db_name not in existing_databases: logger.warning( f"[{self.workspace}] Milvus database '{db_name}' not found, creating it" ) client.create_database(db_name) use_database = getattr(client, "use_database", None) or getattr( client, "using_database", None ) if callable(use_database): use_database(db_name) logger.debug( f"[{self.workspace}] Using Milvus database '{db_name}' for namespace '{self.namespace}'" ) return client return MilvusClient(**self._get_milvus_connection_kwargs(include_db_name=True)) def _rebuild_milvus_client(self) -> None: """Replace the (possibly dead) client with a freshly created one. Once pymilvus' internal reconnect has failed, the gRPC channel is closed permanently and every later RPC on the same client raises "Cannot invoke RPC on closed channel!" — the client cannot heal itself, so migration retries must rebuild it. Safe during migration: it runs inside get_data_init_lock(), so this instance owns self._client exclusively. close() is best-effort — on a dead channel it is a no-op release. In pymilvus 3.x the pooled handler is health-checked and recovered in place, which also heals other clients sharing the same address. """ old_client, self._client = self._client, None if old_client is not None: try: old_client.close() except Exception as close_error: logger.warning( f"[{self.workspace}] Failed to close stale Milvus client: {close_error}" ) self._client = self._create_milvus_client() @staticmethod def _is_retryable_connection_error(error: BaseException) -> bool: """Return True when the error chain indicates a transient connection failure. Walks __cause__/__context__ because the migration wraps low-level errors in RuntimeError and pymilvus wraps grpc errors in MilvusException. Schema, dimension and parameter errors fall through to False so they keep failing fast. """ seen: set[int] = set() current: BaseException | None = error while current is not None and id(current) not in seen: seen.add(id(current)) if isinstance(current, grpc.RpcError): code_getter = getattr(current, "code", None) code = code_getter() if callable(code_getter) else None if code in ( grpc.StatusCode.UNAVAILABLE, grpc.StatusCode.DEADLINE_EXCEEDED, ): return True elif isinstance(current, MilvusException): # Status.CONNECT_FAILED == 2 (client-side connect failure) message = str(current).lower() if current.code == 2 or any( marker in message for marker in MILVUS_RETRYABLE_CONNECTION_ERROR_MARKERS ): return True elif isinstance(current, ValueError): # grpc raises ValueError("Cannot invoke RPC on closed channel!") if "closed channel" in str(current).lower(): return True current = current.__cause__ or current.__context__ return False def _create_schema_for_namespace(self) -> CollectionSchema: """Create schema based on the current instance's namespace""" # Get vector dimension from embedding_func dimension = self.embedding_func.embedding_dim varchar_limits = self._get_varchar_field_limits_for_namespace() # Base fields (common to all collections) base_fields = [ FieldSchema( name="id", dtype=DataType.VARCHAR, max_length=64, is_primary=True ), FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=dimension), FieldSchema(name="created_at", dtype=DataType.INT64), ] # Determine specific fields based on namespace if self.namespace.endswith("entities"): specific_fields = [ FieldSchema( name="entity_name", dtype=DataType.VARCHAR, max_length=varchar_limits["entity_name"], nullable=True, ), FieldSchema( name="content", dtype=DataType.VARCHAR, max_length=varchar_limits["content"], nullable=True, ), FieldSchema( name="source_id", dtype=DataType.VARCHAR, max_length=varchar_limits["source_id"], nullable=True, ), FieldSchema( name="file_path", dtype=DataType.VARCHAR, max_length=varchar_limits["file_path"], nullable=True, ), ] description = "LightRAG entities vector storage" elif self.namespace.endswith("relationships"): specific_fields = [ FieldSchema( name="src_id", dtype=DataType.VARCHAR, max_length=varchar_limits["src_id"], nullable=True, ), FieldSchema( name="tgt_id", dtype=DataType.VARCHAR, max_length=varchar_limits["tgt_id"], nullable=True, ), FieldSchema( name="content", dtype=DataType.VARCHAR, max_length=varchar_limits["content"], nullable=True, ), FieldSchema( name="source_id", dtype=DataType.VARCHAR, max_length=varchar_limits["source_id"], nullable=True, ), FieldSchema( name="file_path", dtype=DataType.VARCHAR, max_length=varchar_limits["file_path"], nullable=True, ), ] description = "LightRAG relationships vector storage" elif self.namespace.endswith("chunks"): specific_fields = [ FieldSchema( name="full_doc_id", dtype=DataType.VARCHAR, max_length=varchar_limits["full_doc_id"], nullable=True, ), FieldSchema( name="content", dtype=DataType.VARCHAR, max_length=varchar_limits["content"], nullable=True, ), FieldSchema( name="file_path", dtype=DataType.VARCHAR, max_length=varchar_limits["file_path"], nullable=True, ), ] description = "LightRAG chunks vector storage" else: # Default generic schema (backward compatibility) specific_fields = [ FieldSchema( name="file_path", dtype=DataType.VARCHAR, max_length=varchar_limits["file_path"], nullable=True, ), ] description = "LightRAG generic vector storage" # Merge all fields all_fields = base_fields + specific_fields return CollectionSchema( fields=all_fields, description=description, enable_dynamic_field=True, # Support dynamic fields ) def _get_varchar_field_limits_for_namespace(self) -> dict[str, int]: base_fields = { "id": 64, "content": MILVUS_MAX_VARCHAR_BYTES, "file_path": DEFAULT_MAX_FILE_PATH_LENGTH, } if self.namespace.endswith("entities"): return { **base_fields, "entity_name": 512, "source_id": MILVUS_MAX_VARCHAR_BYTES, } if self.namespace.endswith("relationships"): return { **base_fields, "src_id": 512, "tgt_id": 512, "source_id": MILVUS_MAX_VARCHAR_BYTES, } if self.namespace.endswith("chunks"): return {**base_fields, "full_doc_id": 64} return base_fields def _get_migrated_metadata_field_limits(self) -> dict[str, int]: if self.namespace.endswith("entities"): return { "content": MILVUS_MAX_VARCHAR_BYTES, "source_id": MILVUS_MAX_VARCHAR_BYTES, } if self.namespace.endswith("relationships"): return { "content": MILVUS_MAX_VARCHAR_BYTES, "source_id": MILVUS_MAX_VARCHAR_BYTES, } if self.namespace.endswith("chunks"): return {"content": MILVUS_MAX_VARCHAR_BYTES} return {} @staticmethod def _field_max_length(field: dict) -> int | None: max_length = field.get("params", {}).get("max_length") if max_length is None: return None try: return int(max_length) except (TypeError, ValueError): return None def _truncate_varchar_value( self, field_name: str, value: Any, record_id: str | None = None, allow_identity_truncation: bool = False, ) -> Any: limit = self._varchar_field_limits.get(field_name) if limit is None or not isinstance(value, str): return value encoded = value.encode("utf-8") if len(encoded) <= limit: return value # The primary key is never truncated: collapsing two ids into one would # silently overwrite a row and orphan it from get_by_id/delete. if field_name in MILVUS_PRIMARY_KEY_FIELDS: raise ValueError( f"[{self.workspace}] Milvus primary key '{field_name}' for record " f"'{record_id or ''}' exceeds {limit} bytes " f"({len(encoded)} bytes); primary keys cannot be truncated" ) # Other identity fields: reject on the live upsert path, but allow # truncate-and-warn during migration so legacy data can be carried over # without aborting the whole collection. if ( field_name in MILVUS_IDENTITY_VARCHAR_FIELDS and not allow_identity_truncation ): raise ValueError( f"[{self.workspace}] Milvus field '{field_name}' for record " f"'{record_id or ''}' exceeds {limit} bytes " f"({len(encoded)} bytes); identity fields cannot be truncated" ) # Cut to the byte budget on a valid UTF-8 boundary first. truncated = encoded[:limit].decode("utf-8", errors="ignore") # For separator-joined id lists, back off to the last separator that # fits so we never persist a half id. Fall back to the raw byte cut when # no separator fits (e.g. a single id longer than the limit). if field_name in MILVUS_SEPARATOR_JOINED_FIELDS: boundary = truncated.rfind(GRAPH_FIELD_SEP) if boundary > 0: truncated = truncated[:boundary] logger.warning( "[%s] Milvus field '%s' for record '%s' truncated from %d to %d bytes", self.workspace, field_name, record_id or "", len(encoded), len(truncated.encode("utf-8")), ) return truncated def _sanitize_varchar_fields( self, row: dict[str, Any], allow_identity_truncation: bool = False ) -> dict[str, Any]: record_id = str(row.get("id", "")) or None return { field_name: self._truncate_varchar_value( field_name, value, record_id, allow_identity_truncation=allow_identity_truncation, ) for field_name, value in row.items() } def _normalize_migration_row(self, row: dict[str, Any]) -> dict[str, Any]: normalized = dict(row) metadata = normalized.pop("$meta", None) if isinstance(metadata, dict): for field_name, value in metadata.items(): # Explicit nullable fields can hold None while the real value # still lives in $meta (schema-drift rows), so backfill on None # rather than mere key presence. if normalized.get(field_name) is None: normalized[field_name] = value # Migration carries pre-existing rows: non-primary identity fields are # truncated-and-warned rather than rejected (see _truncate_varchar_value). return self._sanitize_varchar_fields(normalized, allow_identity_truncation=True) def _get_index_params(self): """Get IndexParams in a version-compatible way""" try: # Try to use client's prepare_index_params method (most common) if hasattr(self._client, "prepare_index_params"): return self._client.prepare_index_params() except Exception: pass try: # Try to import IndexParams from different possible locations from pymilvus.client.prepare import IndexParams # type: ignore return IndexParams() except ImportError: pass try: from pymilvus.client.types import IndexParams # type: ignore return IndexParams() except ImportError: pass try: from pymilvus import IndexParams # type: ignore return IndexParams() except ImportError: pass # If all else fails, return None to use fallback method return None def _create_scalar_index_fallback(self, field_name: str, index_type: str): """Fallback method to create scalar index using direct API""" # Skip unsupported index types if index_type == "SORTED": logger.info( f"[{self.workspace}] Skipping SORTED index for {field_name} (not supported in this Milvus version)" ) return try: self._client.create_index( collection_name=self.final_namespace, field_name=field_name, index_params={"index_type": index_type}, ) logger.debug( f"[{self.workspace}] Created {field_name} index using fallback method" ) except Exception as e: logger.info( f"[{self.workspace}] Could not create {field_name} index using fallback method: {e}" ) def _create_indexes_after_collection(self): """Create indexes after collection is created""" # Build vector index using index configuration # Use compatibility helper to get IndexParams index_params_for_vector = self._get_index_params() vector_index_params = self.index_config.build_index_params( index_params_for_vector, field_name="vector" ) # Re-raise exceptions to surface vector index creation failures if isinstance(vector_index_params, dict): self._client.create_index( collection_name=self.final_namespace, field_name=vector_index_params["field_name"], index_params={ "index_type": vector_index_params["index_type"], "metric_type": vector_index_params["metric_type"], "params": vector_index_params["params"], }, ) else: self._client.create_index( collection_name=self.final_namespace, index_params=vector_index_params, ) logger.debug( f"[{self.workspace}] Created vector index with config: {self.index_config.to_dict()}" ) # Create scalar indexes based on namespace # Wrap scalar index creation in try-except to allow graceful degradation try: # Try to get IndexParams in a version-compatible way scalar_index_params = self._get_index_params() if scalar_index_params is not None: # Create scalar indexes based on namespace if self.namespace.endswith("entities"): # Create indexes for entity fields try: entity_name_index = self._get_index_params() entity_name_index.add_index( field_name="entity_name", index_type="INVERTED" ) self._client.create_index( collection_name=self.final_namespace, index_params=entity_name_index, ) except Exception as e: logger.debug( f"[{self.workspace}] IndexParams method failed for entity_name: {e}" ) self._create_scalar_index_fallback("entity_name", "INVERTED") elif self.namespace.endswith("relationships"): # Create indexes for relationship fields try: src_id_index = self._get_index_params() src_id_index.add_index( field_name="src_id", index_type="INVERTED" ) self._client.create_index( collection_name=self.final_namespace, index_params=src_id_index, ) except Exception as e: logger.debug( f"[{self.workspace}] IndexParams method failed for src_id: {e}" ) self._create_scalar_index_fallback("src_id", "INVERTED") try: tgt_id_index = self._get_index_params() tgt_id_index.add_index( field_name="tgt_id", index_type="INVERTED" ) self._client.create_index( collection_name=self.final_namespace, index_params=tgt_id_index, ) except Exception as e: logger.debug( f"[{self.workspace}] IndexParams method failed for tgt_id: {e}" ) self._create_scalar_index_fallback("tgt_id", "INVERTED") elif self.namespace.endswith("chunks"): # Create indexes for chunk fields try: doc_id_index = self._get_index_params() doc_id_index.add_index( field_name="full_doc_id", index_type="INVERTED" ) self._client.create_index( collection_name=self.final_namespace, index_params=doc_id_index, ) except Exception as e: logger.debug( f"[{self.workspace}] IndexParams method failed for full_doc_id: {e}" ) self._create_scalar_index_fallback("full_doc_id", "INVERTED") else: # Fallback to direct API calls if IndexParams is not available logger.info( f"[{self.workspace}] IndexParams not available, using fallback methods for {self.namespace}" ) # Create scalar indexes using fallback if self.namespace.endswith("entities"): self._create_scalar_index_fallback("entity_name", "INVERTED") elif self.namespace.endswith("relationships"): self._create_scalar_index_fallback("src_id", "INVERTED") self._create_scalar_index_fallback("tgt_id", "INVERTED") elif self.namespace.endswith("chunks"): self._create_scalar_index_fallback("full_doc_id", "INVERTED") logger.info( f"[{self.workspace}] Created indexes for collection: {self.namespace}" ) except Exception as e: # Scalar index failures are logged as warnings (not critical) logger.warning( f"[{self.workspace}] Failed to create some scalar indexes for {self.namespace}: {e}" ) def _get_required_fields_for_namespace(self) -> dict: """Get required core field definitions for current namespace""" # Base fields (common to all types) base_fields = { "id": {"type": "VarChar", "is_primary": True}, "vector": {"type": "FloatVector"}, "created_at": {"type": "Int64"}, } # Add specific fields based on namespace if self.namespace.endswith("entities"): specific_fields = { "entity_name": {"type": "VarChar"}, "content": {"type": "VarChar"}, "source_id": {"type": "VarChar"}, "file_path": {"type": "VarChar"}, } elif self.namespace.endswith("relationships"): specific_fields = { "src_id": {"type": "VarChar"}, "tgt_id": {"type": "VarChar"}, "content": {"type": "VarChar"}, "source_id": {"type": "VarChar"}, "file_path": {"type": "VarChar"}, } elif self.namespace.endswith("chunks"): specific_fields = { "full_doc_id": {"type": "VarChar"}, "content": {"type": "VarChar"}, "file_path": {"type": "VarChar"}, } else: specific_fields = { "file_path": {"type": "VarChar"}, } return {**base_fields, **specific_fields} def _is_field_compatible(self, existing_field: dict, expected_config: dict) -> bool: """Check compatibility of a single field""" field_name = existing_field.get("name", "unknown") existing_type = existing_field.get("type") expected_type = expected_config.get("type") logger.debug( f"[{self.workspace}] Checking field '{field_name}': existing_type={existing_type} (type={type(existing_type)}), expected_type={expected_type}" ) # Convert DataType enum values to string names if needed original_existing_type = existing_type if hasattr(existing_type, "name"): existing_type = existing_type.name logger.debug( f"[{self.workspace}] Converted enum to name: {original_existing_type} -> {existing_type}" ) elif isinstance(existing_type, int): # Map common Milvus internal type codes to type names for backward compatibility type_mapping = { 21: "VarChar", 101: "FloatVector", 5: "Int64", 9: "Double", } mapped_type = type_mapping.get(existing_type, str(existing_type)) logger.debug( f"[{self.workspace}] Mapped numeric type: {existing_type} -> {mapped_type}" ) existing_type = mapped_type # Normalize type names for comparison type_aliases = { "VARCHAR": "VarChar", "String": "VarChar", "FLOAT_VECTOR": "FloatVector", "INT64": "Int64", "BigInt": "Int64", "DOUBLE": "Double", "Float": "Double", } original_existing = existing_type original_expected = expected_type existing_type = type_aliases.get(existing_type, existing_type) expected_type = type_aliases.get(expected_type, expected_type) if original_existing != existing_type or original_expected != expected_type: logger.debug( f"[{self.workspace}] Applied aliases: {original_existing} -> {existing_type}, {original_expected} -> {expected_type}" ) # Basic type compatibility check type_compatible = existing_type == expected_type logger.debug( f"[{self.workspace}] Type compatibility for '{field_name}': {existing_type} == {expected_type} -> {type_compatible}" ) if not type_compatible: logger.warning( f"[{self.workspace}] Type mismatch for field '{field_name}': expected {expected_type}, got {existing_type}" ) return False # Primary key check - be more flexible about primary key detection if expected_config.get("is_primary"): # Check multiple possible field names for primary key status is_primary = ( existing_field.get("is_primary_key", False) or existing_field.get("is_primary", False) or existing_field.get("primary_key", False) ) logger.debug( f"[{self.workspace}] Primary key check for '{field_name}': expected=True, actual={is_primary}" ) logger.debug( f"[{self.workspace}] Raw field data for '{field_name}': {existing_field}" ) # For ID field, be more lenient - if it's the ID field, assume it should be primary if field_name == "id" and not is_primary: logger.info( f"[{self.workspace}] ID field '{field_name}' not marked as primary in existing collection, but treating as compatible" ) # Don't fail for ID field primary key mismatch elif not is_primary: logger.warning( f"[{self.workspace}] Primary key mismatch for field '{field_name}': expected primary key, but field is not primary" ) return False logger.debug(f"[{self.workspace}] Field '{field_name}' is compatible") return True def _check_vector_dimension(self, collection_info: dict): """Check vector dimension compatibility""" current_dimension = self.embedding_func.embedding_dim # Find vector field dimension for field in collection_info.get("fields", []): if field.get("name") == "vector": field_type = field.get("type") # Extract type name from DataType enum or string type_name = None if hasattr(field_type, "name"): type_name = field_type.name elif isinstance(field_type, str): type_name = field_type else: type_name = str(field_type) # Check if it's a vector type (supports multiple formats) if type_name in ["FloatVector", "FLOAT_VECTOR"]: existing_dimension = field.get("params", {}).get("dim") # Convert both to int for comparison to handle type mismatches # (Milvus API may return string "1024" vs int 1024) try: existing_dim_int = ( int(existing_dimension) if existing_dimension is not None else None ) current_dim_int = ( int(current_dimension) if current_dimension is not None else None ) except (TypeError, ValueError) as e: logger.error( f"[{self.workspace}] Failed to parse dimensions: existing={existing_dimension} (type={type(existing_dimension)}), " f"current={current_dimension} (type={type(current_dimension)}), error={e}" ) raise ValueError( f"Invalid dimension values for collection '{self.final_namespace}': " f"existing={existing_dimension}, current={current_dimension}" ) from e if existing_dim_int != current_dim_int: raise ValueError( f"Vector dimension mismatch for collection '{self.final_namespace}': " f"existing={existing_dim_int}, current={current_dim_int}" ) logger.debug( f"[{self.workspace}] Vector dimension check passed: {current_dim_int}" ) return # If no vector field found, this might be an old collection created with simple schema logger.warning( f"[{self.workspace}] Vector field not found in collection '{self.namespace}'. This might be an old collection created with simple schema." ) logger.warning( f"[{self.workspace}] Consider recreating the collection for optimal performance." ) return @staticmethod def _has_vector_field(collection_info: dict) -> bool: """Return True when the collection exposes a 'vector' field. Old simple-schema collections may lack a vector field entirely. Their rows therefore carry no vector data, and copying them into the new schema (whose vector field is required) would fail at insert time, so callers use this to skip migration for such collections. """ return any( field.get("name") == "vector" for field in collection_info.get("fields", []) ) def _check_file_path_length_restriction(self, collection_info: dict) -> bool: """Check if collection has file_path length restrictions that need migration Returns: bool: True if migration is needed, False otherwise """ existing_fields = { field["name"]: field for field in collection_info.get("fields", []) } # Check if file_path field exists and has length restrictions if "file_path" in existing_fields: file_path_field = existing_fields["file_path"] # Get max_length from field params max_length = file_path_field.get("params", {}).get("max_length") if max_length and max_length < DEFAULT_MAX_FILE_PATH_LENGTH: logger.info( f"[{self.workspace}] Collection {self.namespace} has file_path max_length={max_length}, " f"needs migration to {DEFAULT_MAX_FILE_PATH_LENGTH}" ) return True return False def _check_metadata_schema_migration_needed(self, collection_info: dict) -> bool: existing_fields = { field["name"]: field for field in collection_info.get("fields", []) } for ( field_name, expected_max_length, ) in self._get_migrated_metadata_field_limits().items(): existing_field = existing_fields.get(field_name) if existing_field is None: logger.info( f"[{self.workspace}] Collection {self.namespace} missing explicit Milvus field '{field_name}', needs migration" ) return True if not self._is_field_compatible(existing_field, {"type": "VarChar"}): logger.info( f"[{self.workspace}] Collection {self.namespace} has incompatible Milvus field '{field_name}', needs migration" ) return True max_length = self._field_max_length(existing_field) if max_length is not None and max_length < expected_max_length: logger.info( f"[{self.workspace}] Collection {self.namespace} has {field_name} max_length={max_length}, " f"needs migration to {expected_max_length}" ) return True return False def _check_schema_compatibility(self, collection_info: dict): """Check schema field compatibility and detect migration needs""" existing_fields = { field["name"]: field for field in collection_info.get("fields", []) } # Check if this is an old collection created with simple schema has_vector_field = self._has_vector_field(collection_info) if not has_vector_field: logger.warning( f"[{self.workspace}] Collection {self.namespace} appears to be created with old simple schema (no vector field)" ) logger.warning( f"[{self.workspace}] This collection will work but may have suboptimal performance" ) logger.warning( f"[{self.workspace}] Consider recreating the collection for optimal performance" ) return if self._check_file_path_length_restriction( collection_info ) or self._check_metadata_schema_migration_needed(collection_info): logger.info( f"[{self.workspace}] Starting automatic migration for collection {self.namespace}" ) self._migrate_collection_schema() return # For collections with vector field, check basic compatibility # Only check for critical incompatibilities, not missing optional fields critical_fields = {"id": {"type": "VarChar", "is_primary": True}} incompatible_fields = [] for field_name, expected_config in critical_fields.items(): if field_name in existing_fields: existing_field = existing_fields[field_name] if not self._is_field_compatible(existing_field, expected_config): incompatible_fields.append( f"{field_name}: expected {expected_config['type']}, " f"got {existing_field.get('type')}" ) if incompatible_fields: raise ValueError( f"Critical schema incompatibility in collection '{self.final_namespace}': {incompatible_fields}" ) # Get all expected fields for informational purposes expected_fields = self._get_required_fields_for_namespace() missing_fields = [ field for field in expected_fields if field not in existing_fields ] if missing_fields: logger.info( f"[{self.workspace}] Collection {self.namespace} missing optional fields: {missing_fields}" ) logger.info( "These fields would be available in a newly created collection for better performance" ) logger.debug( f"[{self.workspace}] Schema compatibility check passed for {self.namespace}" ) def _create_collection_with_schema( self, collection_name: str, ignore_index_errors: bool = False ) -> None: original_final_namespace = self.final_namespace try: self.final_namespace = collection_name schema = self._create_schema_for_namespace() self._client.create_collection( collection_name=collection_name, schema=schema ) try: self._create_indexes_after_collection() except Exception as index_error: if not ignore_index_errors: raise logger.warning( f"[{self.workspace}] Failed to create indexes for new collection: {index_error}" ) finally: self.final_namespace = original_final_namespace def _recover_interrupted_inplace_migration( self, target_collection_name: str ) -> str: """Recover from a crash/disconnect inside the in-place migration commit window. An in-place migration vacates the source collection (rename to _old, or the drop-source fallback) in Step 3 *before* promoting the temp collection to the target name in Step 4. A failure between those steps leaves the target name free but the migrated rows stranded in the temp collection (and the pre-migration rows, if any, in _old). Both are recoverable state, never scratch: dropping the temp collection here — as the normal Step 1 reset and the failed-attempt cleanup would — could destroy the only surviving copy. Only call this for the in-place case (source == target); a suffix migration never vacates its source. It is a no-op unless the target name is actually missing. Returns: "promoted" - temp was renamed to the target; the migration is complete and the caller should stop. "restored" - the _old backup was renamed back to the target; the source is restored and a fresh migration can re-run. "none" - no interrupted commit detected; proceed normally. """ if self._client.has_collection(target_collection_name): return "none" temp_collection_name = f"{target_collection_name}_temp" old_backup_name = f"{target_collection_name}_old" if self._client.has_collection(temp_collection_name): logger.warning( f"[{self.workspace}] Resuming interrupted migration: promoting " f"{temp_collection_name} -> {target_collection_name}" ) self._client.rename_collection(temp_collection_name, target_collection_name) # The migrated data is now safe under the target name; the _old # backup (if any) is no longer the active copy, so release it. if self._client.has_collection(old_backup_name): try: self._client.release_collection(old_backup_name) except Exception as release_error: logger.warning( f"[{self.workspace}] Failed to release backup collection " f"{old_backup_name}: {release_error}" ) return "promoted" if self._client.has_collection(old_backup_name): logger.warning( f"[{self.workspace}] Resuming interrupted migration: restoring " f"{old_backup_name} -> {target_collection_name} (no migrated copy survived)" ) self._client.rename_collection(old_backup_name, target_collection_name) return "restored" return "none" def _migrate_collection_schema( self, source_collection_name: str | None = None, target_collection_name: str | None = None, ): """Run the iterator-based migration, retrying transient connection failures. Each attempt is idempotent: it starts by dropping the leftover _temp collection and the source collection is never touched until the final rename, so a failed attempt can always be re-run from scratch. A connection-class failure leaves the current MilvusClient permanently dead (see _rebuild_milvus_client), so every retry rebuilds the client before re-running the attempt. """ attempt = 0 backoff = self._migration_retry_backoff needs_client_rebuild = False while True: try: if needs_client_rebuild: self._rebuild_milvus_client() needs_client_rebuild = False return self._migrate_collection_schema_attempt( source_collection_name=source_collection_name, target_collection_name=target_collection_name, ) except Exception as e: if not self._is_retryable_connection_error(e): raise attempt += 1 if attempt > self._migration_max_retries: logger.error( f"[{self.workspace}] Migration of {self.namespace} failed after " f"{attempt} attempt(s) due to connection errors" ) raise needs_client_rebuild = True logger.warning( f"[{self.workspace}] Migration attempt " f"{attempt}/{self._migration_max_retries + 1} for {self.namespace} " f"failed with a connection error: {e}. " f"Rebuilding Milvus client and retrying in {backoff:.0f}s" ) time.sleep(backoff) backoff = min( backoff * MILVUS_MIGRATION_RETRY_BACKOFF_MULTIPLIER, self._migration_retry_max_backoff, ) def _migrate_collection_schema_attempt( self, source_collection_name: str | None = None, target_collection_name: str | None = None, ): source_collection_name = source_collection_name or self.final_namespace target_collection_name = target_collection_name or self.final_namespace temp_collection_name = f"{target_collection_name}_temp" original_final_namespace = self.final_namespace is_inplace = source_collection_name == target_collection_name iterator = None # Once an in-place migration has vacated its source (Step 3), the temp # collection holds the only migrated copy and must not be dropped by # the failure cleanup below — a later attempt or startup recovers it. commit_phase = False # True once we explicitly loaded a suffix migration's legacy source, so # the failure cleanup can release it again (in-place sources are the # active collection and are left as-is). source_loaded = False try: logger.info( f"[{self.workspace}] Starting iterator-based schema migration for {self.namespace}: " f"{source_collection_name} -> {target_collection_name}" ) # A previous attempt may have failed inside the in-place commit # window (after the source was vacated). Finish that commit instead # of restarting the copy, which would drop the recovery copy. if is_inplace: recovery = self._recover_interrupted_inplace_migration( target_collection_name ) if recovery == "promoted": self.final_namespace = target_collection_name return # "restored": the source is back, fall through to a clean copy. # "none": nothing to recover, proceed normally. logger.info( f"[{self.workspace}] Step 1: Creating temporary collection: {temp_collection_name}" ) if self._client.has_collection(temp_collection_name): self._client.drop_collection(temp_collection_name) self._create_collection_with_schema( temp_collection_name, ignore_index_errors=True ) # The temp collection is deliberately NOT loaded here: insert does # not require a loaded collection, and loading it would keep every # migrated row's growing segments in query-node memory for the # whole bulk copy (nearly doubling the collection's footprint on # the server). The final collection is loaded after the rename via # _ensure_collection_loaded. logger.info( f"[{self.workspace}] Step 2: Copying data using query_iterator from: {source_collection_name}" ) # query_iterator issues a server-side query, which requires the # source collection to be loaded. An in-place source is the active # collection and is already loaded, but a legacy/suffix source is # typically NOT loaded (it is an old backup), so the iterator setup # fails with "collection not loaded" (code 101). Load it explicitly # (idempotent); on a successful migration the source becomes the # backup and is released again from memory at the end. self._client.load_collection(source_collection_name) # Only track suffix loads: an in-place source is the active # collection and must not be released on failure. source_loaded = not is_inplace try: iterator = self._client.query_iterator( collection_name=source_collection_name, batch_size=self._migration_iterator_batch_size, output_fields=["*"], ) logger.debug(f"[{self.workspace}] Query iterator created successfully") except Exception as iterator_error: logger.error( f"[{self.workspace}] Failed to create query iterator: {iterator_error}" ) raise total_migrated = 0 batch_number = 1 rows_since_flush = 0 while True: try: batch_data = iterator.next() if not batch_data: # No more data available break sanitized_batch_data = [ self._normalize_migration_row(row) for row in batch_data ] insert_batches = self._build_upsert_batches( sanitized_batch_data, max_payload_bytes=self._max_upsert_payload_bytes, max_records_per_batch=self._max_upsert_records_per_batch, ) try: for insert_batch_number, ( records_batch, estimated_bytes, ) in enumerate(insert_batches, 1): logger.debug( f"[{self.workspace}] Milvus migration insert batch " f"{batch_number}.{insert_batch_number}/{len(insert_batches)}: " f"records={len(records_batch)}, estimated_payload_bytes={estimated_bytes}" ) self._client.insert( collection_name=temp_collection_name, data=records_batch, ) total_migrated += len(batch_data) rows_since_flush += len(batch_data) logger.info( f"[{self.workspace}] Iterator batch {batch_number}: " f"processed {len(batch_data)} records, total migrated: {total_migrated}" ) batch_number += 1 # Seal the accumulated insert buffer to bound the # data-node memory and block until the flush returns, # giving the server back-pressure during a large copy. if ( self._migration_flush_interval_rows > 0 and rows_since_flush >= self._migration_flush_interval_rows ): logger.info( f"[{self.workspace}] Flushing temp collection after " f"{rows_since_flush} rows (total migrated: {total_migrated})" ) self._client.flush(temp_collection_name) rows_since_flush = 0 # Optional throttle to let a small server breathe. if self._migration_batch_sleep > 0: time.sleep(self._migration_batch_sleep) except Exception as batch_error: logger.error( f"[{self.workspace}] Failed to insert iterator batch {batch_number}: {batch_error}" ) raise except Exception as next_error: logger.error( f"[{self.workspace}] Iterator next() failed at batch {batch_number}: {next_error}" ) raise # Final flush so the last unsealed insert buffer is persisted and # released before the collection is promoted and loaded. if self._migration_flush_interval_rows > 0 and total_migrated > 0: logger.info( f"[{self.workspace}] Final flush of temp collection ({total_migrated} rows migrated)" ) self._client.flush(temp_collection_name) if total_migrated > 0: logger.info( f"[{self.workspace}] Successfully migrated {total_migrated} records using iterator" ) else: logger.info( f"[{self.workspace}] No data found in original collection, migration completed" ) backup_collection_name: str | None = None if is_inplace: logger.info( f"[{self.workspace}] Step 3: Rename origin collection to {source_collection_name}_old" ) # Entering the commit window: from here the source is vacated, # so the temp collection becomes the recovery copy and must # survive a failure rather than being cleaned up as scratch. commit_phase = True old_backup_name = f"{source_collection_name}_old" try: # Drop a stale backup from a previous migration first: # rename_collection cannot overwrite an existing name, and # a failed rename here falls back to dropping the source # collection outright (losing the backup entirely). if self._client.has_collection(old_backup_name): logger.info( f"[{self.workspace}] Dropping stale backup collection {old_backup_name}" ) self._client.drop_collection(old_backup_name) self._client.rename_collection( source_collection_name, old_backup_name ) backup_collection_name = old_backup_name except Exception as rename_error: try: logger.warning( f"[{self.workspace}] Try to drop origin collection instead" ) self._client.drop_collection(source_collection_name) except Exception as e: logger.error( f"[{self.workspace}] Rename operation failed: {rename_error}" ) raise e elif self._client.has_collection(target_collection_name): raise RuntimeError( f"Target collection already exists: {target_collection_name}" ) else: # Suffix migration: the legacy source collection stays in # place as the backup. backup_collection_name = source_collection_name logger.info( f"[{self.workspace}] Step 4: Renaming collection {temp_collection_name} -> {target_collection_name}" ) try: self._client.rename_collection( temp_collection_name, target_collection_name ) logger.info(f"[{self.workspace}] Rename operation completed") except Exception as rename_error: if source_collection_name == target_collection_name: logger.error( f"[{self.workspace}] Rename operation failed: {rename_error}" ) else: logger.error( f"[{self.workspace}] Target rename operation failed: {rename_error}" ) raise RuntimeError( f"Failed to rename collection: {rename_error}" ) from rename_error self.final_namespace = target_collection_name # The backup collection (legacy source or renamed _old) inherits # the loaded state of the pre-migration active collection, which # would keep a full second copy of the data in query-node memory # forever. Release it so the backup only occupies disk. if backup_collection_name is not None: try: self._client.release_collection(backup_collection_name) logger.info( f"[{self.workspace}] Released backup collection {backup_collection_name} from memory" ) except Exception as release_error: logger.warning( f"[{self.workspace}] Failed to release backup collection " f"{backup_collection_name}: {release_error}" ) except Exception as e: self.final_namespace = original_final_namespace logger.error( f"[{self.workspace}] Iterator-based migration failed for {self.namespace}: {e}" ) if commit_phase: # The source has already been vacated, so the temp collection # is the only migrated copy: keep it as recovery state. The # next attempt (or startup) finishes the interrupted commit via # _recover_interrupted_inplace_migration. logger.warning( f"[{self.workspace}] Migration failed after the source was vacated; " f"keeping {temp_collection_name} as recovery state for the next attempt or startup" ) else: try: if self._client and self._client.has_collection( temp_collection_name ): logger.info( f"[{self.workspace}] Cleaning up failed migration temporary collection" ) self._client.drop_collection(temp_collection_name) except Exception as cleanup_error: logger.warning( f"[{self.workspace}] Failed to cleanup temporary collection: {cleanup_error}. " f"The leftover temp collection will be dropped on the next migration attempt or startup" ) # Release the suffix source we loaded above so a failed startup or # retry does not leave a full backup resident in query-node memory. # In-place sources are the active collection and are left as-is. if source_loaded: try: self._client.release_collection(source_collection_name) logger.info( f"[{self.workspace}] Released source collection " f"{source_collection_name} from memory after failed migration" ) except Exception as release_error: logger.warning( f"[{self.workspace}] Failed to release source collection " f"{source_collection_name}: {release_error}" ) raise RuntimeError( f"Iterator-based migration failed for collection {self.namespace}: {e}" ) from e finally: if iterator: try: iterator.close() logger.debug( f"[{self.workspace}] Query iterator closed successfully" ) except Exception as close_error: logger.warning( f"[{self.workspace}] Failed to close query iterator: {close_error}" ) def _validate_collection_compatibility(self): """Validate existing collection's dimension and schema compatibility""" try: collection_info = self._client.describe_collection(self.final_namespace) # 1. Check vector dimension self._check_vector_dimension(collection_info) # 2. Check schema compatibility self._check_schema_compatibility(collection_info) logger.info( f"[{self.workspace}] VectorDB Collection '{self.namespace}' compatibility validation passed" ) except Exception as e: logger.error( f"[{self.workspace}] Collection compatibility validation failed for {self.namespace}: {e}" ) raise def _validate_collection_and_load(self) -> None: try: self._client.describe_collection(self.final_namespace) self._validate_collection_compatibility() except Exception as validation_error: logger.error( f"[{self.workspace}] CRITICAL ERROR: Collection '{self.namespace}' exists but validation failed!" ) logger.error( f"[{self.workspace}] This indicates potential data migration failure or schema incompatibility." ) logger.error(f"[{self.workspace}] Validation error: {validation_error}") logger.error(f"[{self.workspace}] MANUAL INTERVENTION REQUIRED:") logger.error( f"[{self.workspace}] 1. Check the existing collection schema and data integrity" ) logger.error(f"[{self.workspace}] 2. Backup existing data if needed") logger.error( f"[{self.workspace}] 3. Manually resolve schema compatibility issues" ) logger.error( f"[{self.workspace}] 4. Consider recreating the collection using: lightrag-rebuild-vdb " ) logger.error( f"[{self.workspace}] Program execution stopped to prevent potential data loss." ) raise RuntimeError( f"Collection validation failed for '{self.final_namespace}'. " f"Data migration failure detected. Manual intervention required to prevent data loss. " f"Original error: {validation_error}" ) try: self._ensure_collection_loaded() except Exception as load_error: if not self._is_missing_vector_index_error(load_error): raise try: self._repair_missing_vector_index() self._ensure_collection_loaded() logger.info( f"[{self.workspace}] Repaired missing vector index for existing collection '{self.namespace}'" ) except Exception as repair_error: raise RuntimeError( f"Index repair failed for collection '{self.final_namespace}'. " f"Original error: {repair_error}" ) from repair_error @staticmethod def _is_missing_vector_index_error(error: Exception) -> bool: """Return True when the error indicates the collection lacks a vector index.""" error_message = str(error).lower() return ( "no vector index" in error_message or "please create index firstly" in error_message ) def _repair_missing_vector_index(self): """Create indexes for an existing collection that is missing its vector index.""" logger.warning( f"[{self.workspace}] Collection '{self.namespace}' is missing a vector index, attempting repair" ) self._create_indexes_after_collection() def _ensure_collection_loaded(self): """Ensure the collection is loaded into memory for search operations""" try: # Check if collection exists first if not self._client.has_collection(self.final_namespace): logger.error( f"[{self.workspace}] Collection {self.namespace} does not exist" ) raise ValueError(f"Collection {self.final_namespace} does not exist") # Load the collection if it's not already loaded # In Milvus, collections need to be loaded before they can be searched self._client.load_collection(self.final_namespace) # logger.debug(f"[{self.workspace}] Collection {self.namespace} loaded successfully") except Exception as e: logger.error( f"[{self.workspace}] Failed to load collection {self.namespace}: {e}" ) raise def _create_collection_if_not_exist(self): """Create collection if not exists and check existing collection compatibility""" try: collection_exists = self._client.has_collection(self.final_namespace) logger.info( f"[{self.workspace}] VectorDB collection '{self.namespace}' exists check: {collection_exists}" ) if collection_exists: self._validate_collection_and_load() return legacy_collection_exists = ( self.legacy_namespace != self.final_namespace and self._client.has_collection(self.legacy_namespace) ) # Interrupted in-place commit recovery — MUST run before the legacy # migration below. An in-place migration vacates its source (Step 3) # before promoting the temp collection to the target name (Step 4); # a crash in that window leaves the target name free with the # completed copy stranded in {final}_temp and the pre-migration data # in {final}_old. If a legacy migration ran first it would migrate # the stale legacy collection over the target and drop {final}_temp # as scratch (Step 1), silently losing every write made to the # suffixed collection since the legacy split. # # {final}_old is the reliable marker that an in-place copy COMPLETED # (it only ever exists once Step 3 renamed the source): when it is # present, recover unconditionally. A lone {final}_temp next to a # live legacy source is instead an aborted *partial* suffix copy and # must be treated as scratch by the legacy migration — so only # recover a lone temp when there is no legacy source to fall back to. temp_collection_name = f"{self.final_namespace}_temp" old_backup_name = f"{self.final_namespace}_old" has_old_backup = self._client.has_collection(old_backup_name) has_temp = self._client.has_collection(temp_collection_name) if has_old_backup or (has_temp and not legacy_collection_exists): recovery = self._recover_interrupted_inplace_migration( self.final_namespace ) if recovery in ("promoted", "restored"): self._ensure_collection_loaded() return if legacy_collection_exists: legacy_collection_info = self._client.describe_collection( self.legacy_namespace ) if not self._has_vector_field(legacy_collection_info): # Old simple-schema collection with no vector field: its rows # carry no vectors, so migrating them into the required-vector # schema would fail at insert and block startup. Skip the # migration and create a fresh suffixed collection instead. logger.warning( f"[{self.workspace}] Legacy collection '{self.legacy_namespace}' " f"has no vector field (old simple schema); cannot migrate its rows " f"into '{self.final_namespace}'. Creating a new collection instead." ) else: try: self._check_vector_dimension(legacy_collection_info) except ValueError as legacy_error: logger.warning( f"[{self.workspace}] Legacy collection '{self.legacy_namespace}' " f"is not compatible with '{self.final_namespace}': {legacy_error}. " f"Creating a new collection without migrating legacy vectors." ) else: self._migrate_collection_schema( source_collection_name=self.legacy_namespace, target_collection_name=self.final_namespace, ) self._ensure_collection_loaded() return # Collection doesn't exist, create new collection logger.info(f"[{self.workspace}] Creating new collection: {self.namespace}") self._create_collection_with_schema(self.final_namespace) self._ensure_collection_loaded() logger.info( f"[{self.workspace}] Successfully created Milvus collection: {self.namespace}" ) except RuntimeError: # Re-raise RuntimeError (validation failures) without modification # These are critical errors that should stop execution raise except Exception as e: # A transient connection failure must never trigger the # force-create fallback below: dropping and recreating the # collection on a flaky connection would destroy healthy data # (or create an empty suffixed collection that permanently # shadows an unmigrated legacy collection). Let it propagate so # initialize() fails and can be retried. if self._is_retryable_connection_error(e): logger.error( f"[{self.workspace}] Connection error in _create_collection_if_not_exist " f"for {self.namespace}: {e}" ) raise logger.error( f"[{self.workspace}] Error in _create_collection_if_not_exist for {self.namespace}: {e}" ) # If there's any error (other than validation failure), try to force create the collection logger.info( f"[{self.workspace}] Attempting to force create collection {self.namespace}..." ) try: # Try to drop the collection first if it exists in a bad state try: if self._client.has_collection(self.final_namespace): logger.info( f"[{self.workspace}] Dropping potentially corrupted collection {self.namespace}" ) self._client.drop_collection(self.final_namespace) except Exception as drop_error: logger.warning( f"[{self.workspace}] Could not drop collection {self.namespace}: {drop_error}" ) # Create fresh collection self._create_collection_with_schema(self.final_namespace) # Load the newly created collection self._ensure_collection_loaded() logger.info( f"[{self.workspace}] Successfully force-created collection {self.namespace}" ) except Exception as create_error: logger.error( f"[{self.workspace}] Failed to force-create collection {self.namespace}: {create_error}" ) raise def __post_init__(self): validate_workspace(self.workspace) self._validate_embedding_func() # Extract MilvusIndexConfig parameters from vector_db_storage_cls_kwargs # # IMPORTANT: This approach allows Milvus index configuration via vector_db_storage_cls_kwargs, # which is the RECOMMENDED method for framework integration (e.g., RAGAnything). # # All 11 index configuration parameters can be passed through vector_db_storage_cls_kwargs: # - index_type, metric_type # - hnsw_m, hnsw_ef_construction, hnsw_ef # - sq_type, sq_refine, sq_refine_type, sq_refine_k # - ivf_nlist, ivf_nprobe # # Example: # LightRAG( # vector_storage="MilvusVectorDBStorage", # vector_db_storage_cls_kwargs={ # "cosine_better_than_threshold": 0.2, # "index_type": "HNSW", # "metric_type": "COSINE", # "hnsw_m": 32, # "hnsw_ef_construction": 256, # } # ) # # Use MilvusIndexConfig.get_config_field_names() to dynamically extract valid parameters. # This ensures we always stay in sync with the MilvusIndexConfig dataclass definition. kwargs = self.global_config.get("vector_db_storage_cls_kwargs", {}) index_config_keys = MilvusIndexConfig.get_config_field_names() index_config_params = { k: v for k, v in kwargs.items() if k in index_config_keys } # Initialize index configuration (if not already set) # Configuration priority: init params from kwargs > environment variables > defaults if not hasattr(self, "index_config") or self.index_config is None: self.index_config = MilvusIndexConfig(**index_config_params) # Check for MILVUS_WORKSPACE environment variable first (higher priority) # This allows administrators to force a specific workspace for all Milvus storage instances milvus_workspace = os.environ.get("MILVUS_WORKSPACE") if milvus_workspace and milvus_workspace.strip(): # Use environment variable value, overriding the passed workspace parameter effective_workspace = milvus_workspace.strip() logger.info( f"Using MILVUS_WORKSPACE environment variable: '{effective_workspace}' (overriding '{self.workspace}/{self.namespace}')" ) else: # Use the workspace parameter passed during initialization effective_workspace = self.workspace if effective_workspace: logger.debug( f"Using passed workspace parameter: '{effective_workspace}'" ) self.workspace = effective_workspace or "" self.model_suffix = self._generate_collection_suffix() if self.workspace: self.legacy_namespace = f"{self.workspace}_{self.namespace}" logger.debug( f"Legacy namespace with workspace prefix: '{self.legacy_namespace}'" ) else: self.legacy_namespace = self.namespace logger.debug(f"Legacy namespace (no workspace): '{self.legacy_namespace}'") if self.model_suffix: self.final_namespace = f"{self.legacy_namespace}_{self.model_suffix}" logger.info(f"Milvus collection: {self.final_namespace}") else: self.final_namespace = self.legacy_namespace logger.warning( f"Milvus collection: {self.final_namespace} missing suffix. Please add model_name to embedding_func for proper model-based data isolation." ) 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 # Ensure created_at is in meta_fields if "created_at" not in self.meta_fields: self.meta_fields.add("created_at") self._varchar_field_limits = self._get_varchar_field_limits_for_namespace() # Initialize client as None - will be created in initialize() method self._client = None self._max_batch_size = self.global_config["embedding_batch_num"] # Flush-time batching limits (see module-level DEFAULT_MILVUS_* constants). # A non-positive value disables that splitting dimension. self._max_upsert_payload_bytes = int( os.getenv( "MILVUS_UPSERT_MAX_PAYLOAD_BYTES", str(DEFAULT_MILVUS_UPSERT_MAX_PAYLOAD_BYTES), ) ) self._max_upsert_records_per_batch = int( os.getenv( "MILVUS_UPSERT_MAX_RECORDS_PER_BATCH", str(DEFAULT_MILVUS_UPSERT_MAX_RECORDS_PER_BATCH), ) ) self._max_delete_records_per_batch = int( os.getenv( "MILVUS_DELETE_MAX_RECORDS_PER_BATCH", str(DEFAULT_MILVUS_DELETE_MAX_RECORDS_PER_BATCH), ) ) if self._max_upsert_payload_bytes <= 0: logger.warning( f"MILVUS_UPSERT_MAX_PAYLOAD_BYTES={self._max_upsert_payload_bytes} is non-positive, disable payload-size splitting" ) if self._max_upsert_records_per_batch <= 0: logger.warning( f"MILVUS_UPSERT_MAX_RECORDS_PER_BATCH={self._max_upsert_records_per_batch} is non-positive, disable upsert record-count splitting" ) if self._max_delete_records_per_batch <= 0: logger.warning( f"MILVUS_DELETE_MAX_RECORDS_PER_BATCH={self._max_delete_records_per_batch} is non-positive, disable delete record-count splitting" ) # Schema-migration retry knobs (see DEFAULT_MILVUS_MIGRATION_* # constants). MILVUS_MIGRATION_MAX_RETRIES=0 restores fail-fast. self._migration_max_retries = max( 0, _get_env_int( "MILVUS_MIGRATION_MAX_RETRIES", DEFAULT_MILVUS_MIGRATION_MAX_RETRIES ), ) self._migration_retry_backoff = float( os.getenv( "MILVUS_MIGRATION_RETRY_BACKOFF", str(DEFAULT_MILVUS_MIGRATION_RETRY_BACKOFF_SECONDS), ) ) self._migration_retry_max_backoff = float( os.getenv( "MILVUS_MIGRATION_RETRY_MAX_BACKOFF", str(DEFAULT_MILVUS_MIGRATION_RETRY_MAX_BACKOFF_SECONDS), ) ) self._migration_iterator_batch_size = _get_env_int( "MILVUS_MIGRATION_ITERATOR_BATCH_SIZE", DEFAULT_MILVUS_MIGRATION_ITERATOR_BATCH_SIZE, ) # Schema-migration memory back-pressure knobs (see the # DEFAULT_MILVUS_MIGRATION_FLUSH_*/_BATCH_SLEEP_* constants). self._migration_flush_interval_rows = max( 0, _get_env_int( "MILVUS_MIGRATION_FLUSH_INTERVAL_ROWS", DEFAULT_MILVUS_MIGRATION_FLUSH_INTERVAL_ROWS, ), ) self._migration_batch_sleep = max( 0.0, float( os.getenv( "MILVUS_MIGRATION_BATCH_SLEEP", str(DEFAULT_MILVUS_MIGRATION_BATCH_SLEEP_SECONDS), ) ), ) self._initialized = False # Deferred-embedding buffers and the per-namespace flush lock. # The lock keys on final_namespace so two instances pointing at the # same Milvus collection (e.g. when MILVUS_WORKSPACE env override is # used) share a single writer lock. We construct it here in # __post_init__ — not in initialize() — so any code path that # touches the buffer before initialize() still has a valid lock. self._pending_vector_docs: dict[str, _PendingVectorDoc] = {} self._pending_vector_deletes: set[str] = set() self._flush_lock = get_namespace_lock( namespace=self.final_namespace, workspace="" ) async def initialize(self): """Initialize Milvus collection""" async with get_data_init_lock(): if self._initialized: return try: # Create MilvusClient if not already created if self._client is None: self._client = self._create_milvus_client() logger.debug( f"[{self.workspace}] MilvusClient created successfully" ) # Validate Milvus version compatibility with configured index if self.index_config.index_type in INDEX_VERSION_REQUIREMENTS: try: server_version = self._client.get_server_version() self.index_config.validate_milvus_version(server_version) except Exception as version_error: logger.error( f"[{self.workspace}] Milvus version validation failed: {version_error}" ) raise # Create collection and check compatibility self._create_collection_if_not_exist() self._initialized = True logger.info( f"[{self.workspace}] Milvus collection '{self.namespace}' initialized successfully" ) except Exception as e: logger.error( f"[{self.workspace}] Failed to initialize Milvus collection '{self.namespace}': {e}" ) raise async def upsert(self, data: dict[str, dict[str, Any]]) -> None: """Buffer vector docs for embedding and batched flush. Embedding deliberately does NOT happen here: repeated upserts of the same id, or many small batches, collapse into a single flush-time embedding pass. Reads (`get_by_id`/`get_by_ids`/`get_vectors_by_ids`) observe pending docs via the same lock for read-your-writes. """ if not data: return current_time = int(time.time()) pending_docs: list[tuple[str, _PendingVectorDoc]] = [] for i, (k, v) in enumerate(data.items(), start=1): # _sanitize_varchar_fields already byte-truncates the stored # `content` when it is a meta field; the pending doc keeps the full # untruncated text so the embedding sees the complete chunk. source = self._sanitize_varchar_fields( { "id": k, "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=v["content"]), ) ) await _cooperative_yield(i) # An upsert overrides any pending delete on the same id; installing # a fresh _PendingVectorDoc instance invalidates any vector cached # by a prior get_vectors_by_ids() call on a stale revision. async with self._flush_lock: for doc_id, pdoc in pending_docs: self._pending_vector_deletes.discard(doc_id) self._pending_vector_docs[doc_id] = pdoc async def query( self, query: str, top_k: int, query_embedding: list[float] = None ) -> list[dict[str, Any]]: """Similarity search against the persisted Milvus collection. Note: buffered-but-unflushed upserts are NOT visible to this method — they exist only in `_pending_vector_docs` until `index_done_callback()` embeds and writes them. Callers that need read-after-write visibility for similarity search must run an explicit flush first. """ # Ensure collection is loaded before querying self._ensure_collection_loaded() # Use provided embedding or compute it if query_embedding is not None: embedding = [query_embedding] # Milvus expects a list of embeddings else: embedding = await self.embedding_func( [query], context="query", _priority=DEFAULT_QUERY_PRIORITY ) # higher priority for query # Include all meta_fields (created_at is now always included) output_fields = list(self.meta_fields) # Build search params from index config search_params_base = self.index_config.build_search_params() # Merge with metric type and radius threshold search_params = { "metric_type": self.index_config.metric_type, "params": { **search_params_base.get("params", {}), "radius": self.cosine_better_than_threshold, }, } results = self._client.search( collection_name=self.final_namespace, data=embedding, limit=top_k, output_fields=output_fields, search_params=search_params, ) return [ { **dp["entity"], "id": dp["id"], "distance": dp["distance"], "created_at": dp.get("created_at"), } for dp in results[0] ] @staticmethod def _build_upsert_batches( records: list[dict[str, Any]], max_payload_bytes: int, max_records_per_batch: int, ) -> list[tuple[list[dict[str, Any]], int]]: """Split upsert records into batches by estimated payload size and count. The byte budget is the primary limiter: records accumulate until adding the next one would exceed ``max_payload_bytes``, then a new batch starts. Size is estimated by JSON-serializing each record; this overestimates the actual gRPC protobuf size (a JSON float string is far longer than the 4 protobuf bytes it encodes), so the split stays conservatively below the server limit and never underestimates. A single record larger than the byte budget is emitted as its own batch rather than raising: JSON overestimation means such a record's real protobuf size is often still under Milvus' 64MB ceiling, so we let the server be the final arbiter instead of failing client-side. Returns a list of ``(batch, estimated_bytes)`` tuples (estimate used for logging). """ if not records: return [] payload_limit = max_payload_bytes if max_payload_bytes > 0 else float("inf") records_limit = ( max_records_per_batch if max_records_per_batch > 0 else float("inf") ) batches: list[tuple[list[dict[str, Any]], int]] = [] current_batch: list[dict[str, Any]] = [] # JSON array overhead ("[]") current_estimated_bytes = 2 for record in records: record_size = len( json.dumps( record, ensure_ascii=False, separators=(",", ":"), default=str, ).encode("utf-8") ) # If current batch not empty, a comma is needed before next element. separator_overhead = 1 if current_batch else 0 next_batch_size = current_estimated_bytes + separator_overhead + record_size if current_batch and ( len(current_batch) >= records_limit or next_batch_size > payload_limit ): batches.append((current_batch, current_estimated_bytes)) current_batch = [] current_estimated_bytes = 2 next_batch_size = current_estimated_bytes + record_size current_batch.append(record) current_estimated_bytes = next_batch_size if current_batch: batches.append((current_batch, current_estimated_bytes)) return batches async def index_done_callback(self) -> None: """Flush all buffered vector ops to Milvus before returning. Contract: on a successful return, every previously buffered upsert has been embedded and committed to the collection, and every buffered delete has been issued — i.e. all pending vectors are durable in Milvus (which persists automatically once written). On any embed- or server-side failure this method raises and leaves both buffers intact for the next callback to retry; the caller MUST NOT assume clean persistence in that case. """ await self._flush_pending_vector_ops() 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 _flush_pending_vector_ops(self) -> None: """Flush buffered vector upserts and deletes to Milvus. Embedding runs *inside* this lock (not in `upsert` or lock-free): it makes deferred embedding and bulk indexing atomic against concurrent upserts and destructive mutations. Any failure (embed or server write) raises and leaves both buffers intact; the next `index_done_callback` retries automatically. """ async with self._flush_lock: if not self._pending_vector_docs and not self._pending_vector_deletes: return if self._client is None: return # Milvus requires the collection to be loaded before upsert/delete. self._ensure_collection_loaded() pending_docs = self._pending_vector_docs pending_deletes = self._pending_vector_deletes docs_to_embed: list[tuple[str, _PendingVectorDoc]] = [ (doc_id, pdoc) for doc_id, pdoc in pending_docs.items() if pdoc.vector is None ] if docs_to_embed: contents = [pdoc.content for _, pdoc in docs_to_embed] batches = [ contents[i : i + self._max_batch_size] for i in range(0, len(contents), self._max_batch_size) ] 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) 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, ((_, pdoc), embedding) in enumerate( zip(docs_to_embed, embeddings), start=1 ): # Cache as float32 so a second flush after a server-side # error doesn't re-embed, and so the upsert JSON payload # stays compact (float32 serializes to a shorter string # than float64, and Milvus stores FLOAT_VECTOR as float32 # anyway, so the cast is lossless). pdoc.vector = np.array(embedding, dtype=np.float32).tolist() await _cooperative_yield(i) # Assemble final upsert payload. After the embed loop above every # pending doc has a non-None vector (count-mismatch was checked), # so we can iterate without re-guarding. committed_ids: list[str] = list(pending_docs.keys()) # source was already byte-truncated in upsert(); no need to # re-sanitize here (vector is not a VarChar field). list_data: list[dict[str, Any]] = [ { **pending_docs[doc_id].source, "vector": pending_docs[doc_id].vector, } for doc_id in committed_ids ] try: if list_data: # Split the upsert into batches that stay under the server-side # 64MB gRPC message limit. Fail-fast: any batch failure raises # immediately and the full buffer is retained for the next flush. upsert_batches = self._build_upsert_batches( list_data, max_payload_bytes=self._max_upsert_payload_bytes, max_records_per_batch=self._max_upsert_records_per_batch, ) if len(upsert_batches) > 1: logger.info( f"[{self.workspace}] {self.namespace} flush: upsert split into " f"{len(upsert_batches)} batches for {len(list_data)} records " f"(max_payload={self._max_upsert_payload_bytes} batch={self._max_upsert_records_per_batch})" ) for batch_index, (records_batch, estimated_bytes) in enumerate( upsert_batches, 1 ): if ( len(records_batch) == 1 and self._max_upsert_payload_bytes > 0 and estimated_bytes > self._max_upsert_payload_bytes ): logger.warning( f"[{self.workspace}] {self.namespace} flush: single record " f"id={records_batch[0].get('id')} estimated {estimated_bytes} bytes " f"exceeds {self._max_upsert_payload_bytes}" ) logger.debug( f"[{self.workspace}] Milvus upsert batch {batch_index}/{len(upsert_batches)}: " f"records={len(records_batch)}, estimated_payload_bytes={estimated_bytes}" ) self._client.upsert( collection_name=self.final_namespace, data=records_batch ) if pending_deletes: # Chunk deletes by record count; pks are short strings so a # count cap is enough to stay under the gRPC message limit. delete_ids = list(pending_deletes) delete_chunk = ( self._max_delete_records_per_batch if self._max_delete_records_per_batch > 0 else len(delete_ids) ) for i in range(0, len(delete_ids), delete_chunk): self._client.delete( collection_name=self.final_namespace, pks=delete_ids[i : i + delete_chunk], ) except Exception as e: logger.error( f"[{self.workspace}] Error flushing vector ops " f"(upserts={len(pending_docs)}, " f"deletes={len(pending_deletes)}): {e}" ) raise # On success, clear the buffers in-place so external references # (e.g. drop()) see the cleared state. for doc_id in committed_ids: pending_docs.pop(doc_id, None) pending_deletes.clear() 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} (id={entity_id})" ) 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 server-side query + delete cannot interleave with an in-flight bulk upsert. Server-side failures are re-raised (no log-and-swallow): the caller decides whether to retry. Buffer semantics — post-prune with caller short-circuit contract: Matching pending upserts in ``_pending_vector_docs`` are pruned **only after** the server-side query + delete succeeds. On failure the pending buffer stays intact and the exception propagates so the caller (``adelete_by_entity`` in ``utils_graph.py``) can short-circuit before ``_persist_graph_updates`` flushes a half-cleaned buffer. Semantic note (deferred-buffer ↔ persisted divergence): pruning only consults the *current* buffered ``src_id`` / ``tgt_id`` view; we do not re-read the persisted row a buffered upsert is about to overwrite. So if a pending upsert is rewriting an already-persisted ``rel-X-Y`` so that its new ``src_id`` / ``tgt_id`` matches ``entity_name`` while the persisted row's do not (or vice versa), the persisted row will not be deleted by the server-side filter and the pending overwrite is dropped — i.e. the final state can diverge from the eager-flush ordering (upsert → flush → delete). Callers that require eager-equivalent semantics should call ``index_done_callback()`` before ``delete_entity_relation``. """ 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 self._client is None: # No server state to mutate; buffer prune is the only # delete intent we can record. _prune_pending() return self._ensure_collection_loaded() expr = f'src_id == "{entity_name}" or tgt_id == "{entity_name}"' results = self._client.query( collection_name=self.final_namespace, filter=expr, output_fields=["id"], ) if not results: # No server rows to delete — still safe to prune any # pending upserts so they can't re-create the relation. _prune_pending() logger.debug( f"[{self.workspace}] No relations found for entity {entity_name}" ) return relation_ids = [item["id"] for item in results] self._client.delete(collection_name=self.final_namespace, pks=relation_ids) # 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 {len(relation_ids)} relations for {entity_name}" ) async def delete(self, ids: list[str]) -> None: """Buffer vector deletes for batched flush.""" 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 get_by_id(self, id: str) -> dict[str, Any] | None: """Get vector data by its ID, with read-your-writes against the buffer.""" 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: doc = dict(pending.source) doc["id"] = id return doc try: # Ensure collection is loaded before querying self._ensure_collection_loaded() # Include all meta_fields (created_at is now always included) plus id output_fields = list(self.meta_fields) + ["id"] result = self._client.query( collection_name=self.final_namespace, filter=f'id == "{id}"', output_fields=output_fields, ) if not result or len(result) == 0: return None return result[0] except Exception as e: logger.error( f"[{self.workspace}] Error retrieving vector data for ID {id}: {e}" ) return None async def get_by_ids(self, ids: list[str]) -> list[dict[str, Any]]: """Get multiple vector data by their IDs (read-your-writes), preserving order.""" 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: doc = dict(pending.source) doc["id"] = doc_id buffered[doc_id] = doc continue remaining.append(doc_id) result_map: dict[str, dict[str, Any]] = {} if remaining: try: # Ensure collection is loaded before querying self._ensure_collection_loaded() # Include all meta_fields (created_at is now always included) plus id output_fields = list(self.meta_fields) + ["id"] id_list = '", "'.join(remaining) filter_expr = f'id in ["{id_list}"]' result = self._client.query( collection_name=self.final_namespace, filter=filter_expr, output_fields=output_fields, ) if result: for row in result: if not row: continue row_id = row.get("id") if row_id is not None: result_map[str(row_id)] = row except Exception as e: logger.error( f"[{self.workspace}] Error retrieving vector data for IDs {remaining}: {e}" ) return [] return [ buffered[doc_id] if doc_id in buffered else result_map.get(str(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. Pending docs with `vector is None` trigger a lazy embed inside the lock; the resulting vector is cached on the buffered `_PendingVectorDoc` so the next flush won't re-embed the same content. """ 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) if docs_to_embed: contents = [pdoc.content for _, pdoc 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) 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, pdoc), embedding) in enumerate( zip(docs_to_embed, embeddings), start=1 ): # Cache float32 to match the flush path so the buffered # vector dtype is uniform regardless of which path embedded. pdoc.vector = np.array(embedding, dtype=np.float32).tolist() result[doc_id] = pdoc.vector await _cooperative_yield(i) if not remaining: return result try: self._ensure_collection_loaded() id_list = '", "'.join(remaining) filter_expr = f'id in ["{id_list}"]' rows = self._client.query( collection_name=self.final_namespace, filter=filter_expr, output_fields=["id", "vector"], ) for item in rows or []: if item and "vector" in item and "id" in item: vector_data = item["vector"] if isinstance(vector_data, np.ndarray): vector_data = vector_data.tolist() # Match get_by_ids: stringify the server-returned id so # callers can index the dict by the original requested id. result[str(item["id"])] = vector_data return result except Exception as e: logger.error( f"[{self.workspace}] Error retrieving vectors by IDs from {self.namespace}: {e}" ) return result async def finalize(self): """Flush pending vector ops, then release the Milvus gRPC channel. Every other server-backed storage releases its client here (Neo4j driver, Postgres pool, Mongo client, OpenSearch ClientManager); the MilvusClient owns a gRPC channel that should be closed explicitly too — ``close()`` is already used for that purpose by ``_rebuild_milvus_client``. We still fail loudly when a transient bulk error left writes buffered — the caller must not believe storage finalized cleanly. """ flush_error: Exception | None = None try: await self._flush_pending_vector_ops() except Exception as e: flush_error = e # Release the gRPC channel after the flush so the flush can still use # the client. Best-effort: close() on a dead channel is a no-op (see # _rebuild_milvus_client). The connection is freed on every exit path, # matching the close-on-release pattern of the other server-backed # storages (Neo4j / Postgres / Mongo / OpenSearch). if self._client is not None: try: self._client.close() except Exception as close_error: logger.warning( f"[{self.workspace}] Failed to close Milvus client: {close_error}" ) # Read the residual buffer sizes under the flush lock so the # snapshot is consistent with any racing late-arriving mutator # (cancellation paths can land an upsert/delete between the flush # above and the post-mortem check below). async with self._flush_lock: 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}] MilvusVectorDBStorage.finalize() flush raised; " f"{pending_docs} pending upserts and {pending_deletes} pending " f"deletes were left buffered (data lost)" ) from flush_error if pending_docs or pending_deletes: raise RuntimeError( f"[{self.workspace}] MilvusVectorDBStorage.finalize() left " f"{pending_docs} pending upserts and {pending_deletes} pending " f"deletes buffered after final flush attempt (these writes have been lost)" ) async def drop(self) -> dict[str, str]: """Drop all data from the Milvus collection. Destructive. MUST only be called when ``pipeline_status`` is idle (see the Pipeline concurrency contract in ``AGENTS.md``); the only in-tree caller ``clear_documents`` enforces this. Caveat — only this instance's buffers are cleared. Other ``MilvusVectorDBStorage`` instances aliased onto the same ``final_namespace`` (multi-worker processes, or distinct workspaces collapsed by ``MILVUS_WORKSPACE``) keep their own buffers; a sibling whose prior flush failed and left buffers intact will, on its next flush, upsert those stale rows into the freshly recreated collection. Direct callers bypassing the idle precondition MUST flush every aliased instance first. Returns: dict[str, str]: ``{"status": "success"|"error", "message": str}`` """ try: async with self._flush_lock: # Discard any buffered writes before the collection is gone; # a concurrent flush would otherwise resurrect them. self._pending_vector_docs.clear() self._pending_vector_deletes.clear() # Drop the collection and recreate it empty. if self._client.has_collection(self.final_namespace): self._client.drop_collection(self.final_namespace) # Recreate an EMPTY collection. Do NOT route through # _create_collection_if_not_exist here: with the suffixed # collection now gone it would see the intentionally-kept legacy # collection and re-run the legacy->suffixed migration, pulling # the just-dropped rows back in. That makes drop() non-empty # (clear_documents would leave stale legacy data behind) and # forces a needless full migration on every rebuild/clear. self._create_collection_with_schema(self.final_namespace) self._ensure_collection_loaded() logger.info( f"[{self.workspace}] Process {os.getpid()} drop Milvus collection {self.namespace}" ) return {"status": "success", "message": "data dropped"} except Exception as e: logger.error( f"[{self.workspace}] Error dropping Milvus collection {self.namespace}: {e}" ) return {"status": "error", "message": str(e)}