diff --git a/docs/assets/env_example.env b/docs/assets/env_example.env index 28461de32..92f990722 100644 --- a/docs/assets/env_example.env +++ b/docs/assets/env_example.env @@ -62,6 +62,10 @@ MINIO_SECRET_KEY=minioadmin POSTGRES_PASSWORD=postgres # POSTGRES_USER=root +# Fresh Milvus 3 installations can keep the default queue selection. During an +# upgrade, set this to the queue already used by the existing deployment. +# MILVUS_MQ_TYPE=rocksmq + # ── API and Chainlit (chat interface) authentication ────────────────────────────── # Bearer token that bootstraps the admin user and guards the API. # ⚠️ Production: replace with a strong value, e.g. `openssl rand -hex 16`. @@ -117,4 +121,4 @@ RAY_ENABLE_UV_RUN_RUNTIME_ENV=0 # RAY_memory_monitor_refresh_ms=0 # ── Logging (DEBUG on dev, INFO on prod) ── -LOG_LEVEL=DEBUG \ No newline at end of file +LOG_LEVEL=DEBUG diff --git a/docs/content/docs/documentation/env_vars.md b/docs/content/docs/documentation/env_vars.md index bced96799..b8c55bbd8 100644 --- a/docs/content/docs/documentation/env_vars.md +++ b/docs/content/docs/documentation/env_vars.md @@ -265,6 +265,7 @@ For an opt-in named-volume profile, copy the values from `infra/compose/.env.nam | `VLLM_CACHE` | `/root/.cache/huggingface` | Hugging Face cache used by vLLM, reranker, and transcriber services. | | `DB_VOLUME` | `../../db` | PostgreSQL data mounted at `/var/lib/postgresql/data`. | | `MILVUS_VOLUME_DIRECTORY` | `./volumes` | Parent directory for Milvus, etcd, and MinIO host-path storage. | +| `MILVUS_MQ_TYPE` | `default` | Milvus message queue. Keep the existing value during a version upgrade; fresh installations can use the default. | | `MILVUS_COMPOSE` | `milvus/milvus.yaml` | Milvus compose include. Use `milvus/milvus.named-volumes.yaml` for the named-volume profile. | | `ETCD_VOLUME` | `etcd` | Milvus etcd named volume, used only with `MILVUS_COMPOSE=milvus/milvus.named-volumes.yaml`. | | `MINIO_VOLUME` | `minio` | Milvus object storage named volume, used only with `MILVUS_COMPOSE=milvus/milvus.named-volumes.yaml`. | diff --git a/docs/content/docs/documentation/milvus_migration.mdx b/docs/content/docs/documentation/milvus_migration.mdx index 9ae27c581..ee07b0bda 100644 --- a/docs/content/docs/documentation/milvus_migration.mdx +++ b/docs/content/docs/documentation/milvus_migration.mdx @@ -5,9 +5,11 @@ title: Milvus Migrations import { Tabs, TabItem } from '@astrojs/starlight/components'; # Milvus Upgrade -OpenRAG has been upgraded from Milvus **2.5.4** to **2.6.11** to leverage the enhancements introduced in the latest releases, particularly the new temporal querying capabilities added in version **2.6.6+**. +OpenRAG now uses Milvus **3.0.0** with PyMilvus **3.0.1**. Existing Milvus 2.6 deployments can keep their data, but they must prepare the Milvus data directory before starting the new image because Milvus 3 runs as a non-root user. -## What's New in 2.6.x +Fresh installations need no manual ownership step. The Compose stack initializes empty bind mounts and named volumes before the server starts. If existing files are not already owned by the Milvus 3 user, startup stops with a migration error instead of risking a partially writable deployment. The procedure below remains mandatory for existing data in either storage mode because all files created by Milvus 2.6 must be transferred to the Milvus 3 user. + +## Temporal support introduced in 2.6.x Milvus 2.6.6+ introduced the **`TIMESTAMPTZ`** field type, which enables: @@ -46,12 +48,115 @@ results = client.query( ## Milvus Version Upgrade Steps :::danger[Who needs this migration?] -This migration is only required if you are upgrading from **OpenRAG <= 1.1.7**, which shipped with Milvus <= 2.5.x. If your deployment already runs Milvus 2.6.x, skip this section. +Do not skip this migration when upgrading an existing deployment: + +- If you already run Milvus **2.6.x**, follow the 2.6-to-3.0 procedure below. +- If you run OpenRAG **1.1.7 or earlier with Milvus 2.5.x**, first complete the legacy intermediate upgrade to 2.6.11, then follow the 2.6-to-3.0 procedure. +- If your Milvus server is **earlier than 2.5.x**, do not use the legacy path below. Determine the required intermediate and metadata migrations from the official guide before continuing. ::: > For the full official reference, see the [Milvus upgrade guide](https://milvus.io/docs/upgrade_milvus_standalone-docker.md#Upgrade-process). -### Step 1 — Upgrade Milvus to 2.5.16 (intermediate step) +### Upgrade from Milvus 2.6.x to 3.0.0 + +Milvus 2.6 containers ran as root, so existing bind-mounted files and Docker named-volume contents are commonly owned by `root:root`. Milvus 3 runs as UID/GID `999:999` and cannot start until it owns the directory mounted at `/var/lib/milvus`. + +The message-queue type must also remain unchanged during the upgrade. Before stopping Milvus 2.6, inspect its startup logs and identify the effective `mqType` or `walName`: + +```bash +docker compose logs --no-color milvus \ + | grep -E 'mqType=|walName=' \ + | tail -20 +``` + +Set `MILVUS_MQ_TYPE` in the Compose `.env` file to the detected value, such as `rocksmq` or `woodpecker`. If the logs do not show a clear value, confirm it in the Milvus WebUI configuration view before continuing. Do not combine the version upgrade with a queue migration; use the separate [Milvus queue-switch procedure](https://milvus.io/docs/switch-rocksmq-woodpecker.md) after version 3 is healthy. + +:::danger[Back up before changing ownership] +Stop writes and create a verified backup of the Milvus, etcd, and MinIO data before continuing. An image-only downgrade is not a safe rollback after Milvus 3 has written data. +::: + +Run the procedure for the storage profile selected in `infra/compose/.env`. + +#### Bind-mounted storage + +While the Milvus 2.6 stack still exists, confirm that `/var/lib/milvus` is a bind mount: + +```bash +MILVUS_CONTAINER="$(docker compose ps -q milvus)" +docker inspect "$MILVUS_CONTAINER" \ + --format '{{range .Mounts}}{{if eq .Destination "/var/lib/milvus"}}type={{.Type}} source={{.Source}}{{end}}{{end}}' +# Expected: type=bind +``` + +Stop the stack, take the backup described above, and then transfer ownership from a temporary service container. Running the change through Docker makes it apply to the daemon-side mount and preserves the correct ownership mapping with remote, rootless, and user-namespaced Docker. Do not add `--volumes` to the shutdown command. + +```bash +docker compose down +docker compose run --rm --no-deps --user 0:0 \ + --entrypoint chown milvus -R 999:999 /var/lib/milvus +``` + +#### Docker named-volume storage + +Docker Desktop and remote Docker contexts do not expose a named volume's daemon-side path to the local machine. Stop the stack without removing volumes, take the backup described above, and transfer ownership from a temporary service container instead: + +```bash +docker compose down +docker compose run --rm --no-deps --user 0:0 \ + --entrypoint chown milvus -R 999:999 /var/lib/milvus +``` + +The command must run with `MILVUS_COMPOSE=milvus/milvus.named-volumes.yaml`, matching the existing deployment. Do not use `docker compose down --volumes` during this procedure. + +Update OpenRAG, then pull and start the dependencies followed by Milvus: + +```bash +docker compose pull milvus +docker compose up -d etcd minio +docker compose up -d milvus +``` + +Do not restart OpenRAG until Milvus is healthy and the logs contain no permission or migration errors: + +```bash +docker compose ps milvus +docker compose logs --tail 100 milvus +docker inspect "$(docker compose ps -q milvus)" --format '{{.Config.Image}}' +# Expected: milvusdb/milvus:v3.0.0 +``` + +Once those checks pass, start the complete stack: + +```bash +docker compose up -d +``` + +### Helm deployments + +The bundled Helm chart now installs the Milvus 3-compatible chart and image. Before upgrading an existing release, stop writes and back up the MinIO and etcd volumes, together with any standalone or log volume enabled through custom values. + +The upstream repository index does not yet publish chart 5.0.26, so OpenRAG currently uses chart 5.0.25 with the Milvus 3 image selected explicitly. Resources rendered by that chart can therefore retain the label `app.kubernetes.io/version: "2.6.21"`. This label describes the chart's default application version, not the running server; verify the container image as shown below. + +Milvus 3 runs with GID `999`. The chart sets `fsGroup: 999` with the `Always` policy so Kubernetes recursively makes supported mounted volumes writable before Milvus starts. The first startup can therefore take longer when a volume contains many files. + +Some storage drivers do not support `fsGroup` ownership changes. Check the driver's `fsGroupPolicy` before upgrading. If it is `None`, migrate the affected Milvus volume to group `999` using the storage provider's procedure before starting Milvus 3; otherwise the pods can fail with permission errors. + +OpenShift's restricted security context constraints normally assign an `fsGroup` from the project's permitted range and can reject the fixed group `999`. On those clusters, set `milvus.securityContext` to `null` in the OpenRAG values so admission can assign the allowed group. Confirm the admitted pod security context and volume ownership in a staging namespace before upgrading production. + +After the upgrade, confirm that every Milvus workload is ready and uses the expected image: + +```bash +kubectl get pods -n -l app.kubernetes.io/name=milvus +kubectl get pods -n -l app.kubernetes.io/name=milvus \ + -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.containers[*].image}{"\n"}{end}' +# Expected Milvus image: milvusdb/milvus:v3.0.0 +``` + +### Legacy path: upgrade Milvus 2.5.x to 2.6.11 + +This intermediate path is only for deployments upgrading from **OpenRAG <= 1.1.7 whose current Milvus server is 2.5.x**. Do not jump directly from Milvus 2.5.x to 3.0.0. Earlier Milvus versions can require additional intermediate or metadata migrations that are outside this procedure. + +#### Step 1 — Upgrade Milvus to 2.5.16 :::caution[Do not update OpenRAG yet] During this step, keep your current version of OpenRAG (< 1.1.7) running. Only the Milvus image is changed here. OpenRAG itself is updated in Step 2. @@ -82,9 +187,19 @@ docker inspect milvus-standalone --format '{{ .Config.Image }}' # Expected: milvusdb/milvus:v2.5.16 ``` -### Step 2 — Update OpenRAG +#### Preserve the message queue -Once Milvus 2.5.16 is healthy, stop all services and update OpenRAG to the new version. The updated `infra/compose/milvus/milvus.yaml` already includes Milvus 2.6.11 and the required MinIO and etcd upgrades. +OpenRAG 2.5 standalone deployments normally use RocksMQ, but verify the effective source queue using the log check in the 2.6-to-3.0 procedure above. Keep that detected value in every Compose configuration used for the 2.6.11 and 3.0.0 steps. + +Before starting the intermediate OpenRAG release, add `MQ_TYPE: ` to the Milvus service's environment. When moving to the current release, set the corresponding value in the Compose `.env` file and keep it until the upgrade is validated: + +```bash +MILVUS_MQ_TYPE= +``` + +#### Step 2 — Upgrade Milvus to 2.6.11 + +Once Milvus 2.5.16 is healthy, stop all services and move to an OpenRAG release that still packages Milvus 2.6.11, such as OpenRAG 2.1.0. This intermediate release also provides the required MinIO and etcd versions. ```bash docker compose down @@ -109,6 +224,8 @@ docker inspect milvus-standalone --format '{{ .Config.Image }}' # Expected: milvusdb/milvus:v2.6.11 ``` +After verifying that the collections are readable and searchable on 2.6.11, continue with the 2.6-to-3.0 procedure above. + ## Schema Migrations OpenRAG ships a generic migration runner that discovers and applies all pending Milvus schema migrations in order. You never need to invoke individual migration scripts by hand. @@ -302,4 +419,4 @@ def downgrade(client: MilvusClient, collection_name: str, dry_run: bool = False) ... ``` -Use `1.add_temporal_fields.py` as a reference implementation for the full upgrade/downgrade pattern. \ No newline at end of file +Use `1.add_temporal_fields.py` as a reference implementation for the full upgrade/downgrade pattern. diff --git a/infra/charts/openrag-stack/Chart.lock b/infra/charts/openrag-stack/Chart.lock index 2fa4d73f1..2ee540c79 100644 --- a/infra/charts/openrag-stack/Chart.lock +++ b/infra/charts/openrag-stack/Chart.lock @@ -7,9 +7,9 @@ dependencies: version: 18.7.3 - name: milvus repository: https://zilliztech.github.io/milvus-helm/ - version: 5.0.0 + version: 5.0.25 - name: vllm-stack repository: https://vllm-project.github.io/production-stack version: 0.1.11 -digest: sha256:1ea11f53796e3196848d5d46cdff5a6bdeeb012427c1951ea646a22b176adec6 -generated: "2026-06-10T14:51:21.005020805+02:00" +digest: sha256:50f412b87a0f4b9fccb92b02a0ea0d21fa7d0343b86394077e4ac05cb7c16b61 +generated: "2026-08-25T13:53:27.648198879Z" diff --git a/infra/charts/openrag-stack/Chart.yaml b/infra/charts/openrag-stack/Chart.yaml index deea0a96a..700d808ee 100644 --- a/infra/charts/openrag-stack/Chart.yaml +++ b/infra/charts/openrag-stack/Chart.yaml @@ -26,7 +26,10 @@ dependencies: repository: "https://charts.bitnami.com/bitnami" condition: postgresql.enabled - name: milvus - version: "5.0.0" + # The Milvus 3 chart is 5.0.26, but that release is not yet present in the + # published repository index. Keep 5.0.25 and override its image tag in + # values.yaml until 5.0.26 can be resolved through helm dependency update. + version: "5.0.25" repository: "https://zilliztech.github.io/milvus-helm/" condition: milvus.enabled - name: vllm-stack diff --git a/infra/charts/openrag-stack/values.yaml b/infra/charts/openrag-stack/values.yaml index 59d8c08d5..7851b9b93 100644 --- a/infra/charts/openrag-stack/values.yaml +++ b/infra/charts/openrag-stack/values.yaml @@ -171,8 +171,17 @@ milvus: # note on it above. Without this, the milvus sub-chart names its own proxy # Service from the real Release.Name instead, which won't match VDB_HOST. fullnameOverride: "openrag-milvus" + # Chart 5.0.25 still defaults to Milvus 2.6.21. This override can be removed + # when the upstream 5.0.26 chart is available from its repository index. image: - all: { tag: "v2.6.0" } + all: { tag: "v3.0.0" } + # Milvus 3 runs as UID/GID 999. Applying this group on every mount lets + # Kubernetes migrate supported existing volumes before the non-root process + # starts. Storage drivers that do not support fsGroup require an + # administrator-managed ownership migration before the Helm upgrade. + securityContext: + fsGroup: 999 + fsGroupChangePolicy: Always cluster: { enabled: true } pulsarv3: { enabled: false } woodpecker: { enabled: true } diff --git a/infra/compose/.env.example b/infra/compose/.env.example index 28461de32..92f990722 100644 --- a/infra/compose/.env.example +++ b/infra/compose/.env.example @@ -62,6 +62,10 @@ MINIO_SECRET_KEY=minioadmin POSTGRES_PASSWORD=postgres # POSTGRES_USER=root +# Fresh Milvus 3 installations can keep the default queue selection. During an +# upgrade, set this to the queue already used by the existing deployment. +# MILVUS_MQ_TYPE=rocksmq + # ── API and Chainlit (chat interface) authentication ────────────────────────────── # Bearer token that bootstraps the admin user and guards the API. # ⚠️ Production: replace with a strong value, e.g. `openssl rand -hex 16`. @@ -117,4 +121,4 @@ RAY_ENABLE_UV_RUN_RUNTIME_ENV=0 # RAY_memory_monitor_refresh_ms=0 # ── Logging (DEBUG on dev, INFO on prod) ── -LOG_LEVEL=DEBUG \ No newline at end of file +LOG_LEVEL=DEBUG diff --git a/infra/compose/milvus/milvus.named-volumes.yaml b/infra/compose/milvus/milvus.named-volumes.yaml index cf48eb555..2f4ff4ef9 100644 --- a/infra/compose/milvus/milvus.named-volumes.yaml +++ b/infra/compose/milvus/milvus.named-volumes.yaml @@ -29,14 +29,42 @@ services: timeout: 20s retries: 3 + milvus-init: + image: milvusdb/milvus:v3.0.0 + user: "0:0" + environment: + LD_PRELOAD: "" + entrypoint: ["/bin/sh", "-ec"] + command: + - | + incompatible_path="$$(find /var/lib/milvus -mindepth 1 \( ! -uid 999 -o ! -gid 999 \) -print -quit)" + if [ -n "$$incompatible_path" ]; then + echo "Existing Milvus data is not owned by UID/GID 999. Back up the deployment and follow the Milvus 2.6-to-3.0 migration guide before starting Milvus 3." >&2 + exit 1 + fi + chown 999:999 /var/lib/milvus + volumes: + - ${MILVUS_VOLUME:-milvus}:/var/lib/milvus + read_only: true + cap_drop: + - ALL + cap_add: + - CHOWN + # Let the ownership guard inspect restrictive restored directories + # without granting write bypass. + - DAC_READ_SEARCH + restart: "no" + milvus: - image: milvusdb/milvus:v2.6.11 + image: milvusdb/milvus:v3.0.0 command: ["milvus", "run", "standalone"] # Run under Docker's default seccomp profile (do not disable syscall # filtering). If a specific kernel needs a wider profile, supply a vetted # custom profile rather than seccomp:unconfined. environment: ETCD_ENDPOINTS: etcd:2379 + ETCD_AUTH_ENABLED: "false" + MQ_TYPE: ${MILVUS_MQ_TYPE:-default} MINIO_ADDRESS: minio:9000 MINIO_ACCESS_KEY_ID: ${MINIO_ACCESS_KEY:?Set MINIO_ACCESS_KEY in your .env} MINIO_SECRET_ACCESS_KEY: ${MINIO_SECRET_KEY:?Set MINIO_SECRET_KEY in your .env} @@ -51,8 +79,12 @@ services: # ports: # - "${VDB_PORT:-19530}:${VDB_iPORT:-19530}" depends_on: - - "etcd" - - "minio" + etcd: + condition: service_started + minio: + condition: service_started + milvus-init: + condition: service_completed_successfully volumes: etcd: diff --git a/infra/compose/milvus/milvus.yaml b/infra/compose/milvus/milvus.yaml index 82b9616b1..19e1f5c52 100644 --- a/infra/compose/milvus/milvus.yaml +++ b/infra/compose/milvus/milvus.yaml @@ -29,14 +29,42 @@ services: timeout: 20s retries: 3 + milvus-init: + image: milvusdb/milvus:v3.0.0 + user: "0:0" + environment: + LD_PRELOAD: "" + entrypoint: ["/bin/sh", "-ec"] + command: + - | + incompatible_path="$$(find /var/lib/milvus -mindepth 1 \( ! -uid 999 -o ! -gid 999 \) -print -quit)" + if [ -n "$$incompatible_path" ]; then + echo "Existing Milvus data is not owned by UID/GID 999. Back up the deployment and follow the Milvus 2.6-to-3.0 migration guide before starting Milvus 3." >&2 + exit 1 + fi + chown 999:999 /var/lib/milvus + volumes: + - ${MILVUS_VOLUME_DIRECTORY:-./volumes}/milvus:/var/lib/milvus + read_only: true + cap_drop: + - ALL + cap_add: + - CHOWN + # Let the ownership guard inspect restrictive restored directories + # without granting write bypass. + - DAC_READ_SEARCH + restart: "no" + milvus: - image: milvusdb/milvus:v2.6.11 + image: milvusdb/milvus:v3.0.0 command: ["milvus", "run", "standalone"] # Run under Docker's default seccomp profile (do not disable syscall # filtering). If a specific kernel needs a wider profile, supply a vetted # custom profile rather than seccomp:unconfined. environment: ETCD_ENDPOINTS: etcd:2379 + ETCD_AUTH_ENABLED: "false" + MQ_TYPE: ${MILVUS_MQ_TYPE:-default} MINIO_ADDRESS: minio:9000 # Milvus must authenticate to MinIO with the same credentials; otherwise # it falls back to the built-in minioadmin default and fails to connect. @@ -53,5 +81,9 @@ services: # ports: # - "${VDB_PORT:-19530}:${VDB_iPORT:-19530}" depends_on: - - "etcd" - - "minio" + etcd: + condition: service_started + minio: + condition: service_started + milvus-init: + condition: service_completed_successfully diff --git a/openrag/services/storage/__init__.py b/openrag/services/storage/__init__.py index 19a3f9090..98e93fd9f 100644 --- a/openrag/services/storage/__init__.py +++ b/openrag/services/storage/__init__.py @@ -7,7 +7,7 @@ :class:`ConnectionManager` with every repository implementation under :mod:`services.persistence`, satisfying :class:`core.ports.catalog_store.CatalogStore`. -* :class:`milvus_store.MilvusVectorStore` — Milvus 2.6 backed vector ops +* :class:`milvus_store.MilvusVectorStore` — Milvus 3.0 backed vector ops satisfying :class:`core.vector_stores.VectorStore`. The Ray actor that callers know today lives at diff --git a/openrag/services/storage/milvus_store.py b/openrag/services/storage/milvus_store.py index ce46e3189..311cdf2d7 100644 --- a/openrag/services/storage/milvus_store.py +++ b/openrag/services/storage/milvus_store.py @@ -1,4 +1,4 @@ -"""Milvus 2.6 vector store adapter implementing :class:`VectorStore`. +"""Milvus 3.0 vector store adapter implementing :class:`VectorStore`. Scope: Pure vector operations against a single Milvus collection (the one named @@ -13,7 +13,7 @@ collection name. ``ensure_collection`` / ``drop_collection`` operate at partition-row granularity. -Client split (Milvus 2.6): +Client split (Milvus 3.0): ``AsyncMilvusClient`` covers the data plane (``insert``, ``search``, ``hybrid_search``, ``query``, ``delete``, ``upsert``). The admin/lifecycle plane (``has_collection``, ``create_collection``, ``load_collection``, @@ -22,7 +22,7 @@ :class:`MilvusClient` is kept alongside. Hybrid BM25: - Milvus 2.6 native ``Function(FunctionType.BM25)`` computes the sparse + Milvus 3.0 native ``Function(FunctionType.BM25)`` computes the sparse vector server-side from the ``text`` field at both insert and query time. Hybrid is config-driven, not a separate entry point: :meth:`search` dispatches to :meth:`_hybrid_search` when ``config.hybrid_search`` is on @@ -36,11 +36,11 @@ import asyncio import secrets import time -from collections.abc import Iterator -from contextlib import contextmanager +from collections.abc import Awaitable, Callable from datetime import UTC, datetime from typing import Any +from core.utils.logging import get_logger from pymilvus import ( AnnSearchRequest, AsyncMilvusClient, @@ -65,6 +65,8 @@ ) from openrag.core.vector_stores import VectorStore +logger = get_logger() + # --------------------------------------------------------------------------- # Module constants — lifted verbatim from the legacy MilvusDB so the schema # is bit-for-bit identical and existing collections load without migration. @@ -99,7 +101,7 @@ "params": {"drop_ratio_build": 0.2}, } -#: Native Milvus 2.6 RRF fusion constant — k=100 matches the legacy MilvusDB +#: Native Milvus 3.0 RRF fusion constant — k=100 matches the legacy MilvusDB #: tuning and the rank-fusion literature default. RRF_K = 100 @@ -137,7 +139,7 @@ class MilvusVectorStore(VectorStore): - """Milvus 2.6 implementation of :class:`VectorStore`. + """Milvus 3.0 implementation of :class:`VectorStore`. The store is constructed cheaply (no I/O); the collection is materialised on the first :meth:`initialize` call. ``initialize`` is idempotent and @@ -169,9 +171,9 @@ def __init__(self, config: VectorDBConfig) -> None: self._schema_vector_dim: int | None = None self._loaded = False self._load_lock = asyncio.Lock() - # Connection healing: pymilvus 2.6 exposes no documented client-level + # Connection healing: PyMilvus 3.0 exposes no documented client-level # reconnect knob (no retry/keepalive params on MilvusClient or - # AsyncMilvusClient — see api-reference v2.6.x). Trust the gRPC + # AsyncMilvusClient). Trust the gRPC # channel's internal handling, same as the legacy MilvusDB. If # production drops surface a real issue, revisit with evidence # rather than racing pymilvus's internal channel state. @@ -203,7 +205,7 @@ async def initialize(self, embedding_dimension: int) -> None: def _ensure_loaded(self) -> None: """Create-if-absent + load the configured collection. - Synchronous because the Milvus 2.6 admin/lifecycle endpoints + Synchronous because the Milvus 3.0 admin/lifecycle endpoints (``has_collection``, ``create_collection``, ``load_collection``, ``alter_collection_properties``, ``describe_collection``) have no async equivalents. @@ -445,7 +447,7 @@ def _resolve_collection(self, collection: str) -> str: # must go through :meth:`drop_collection`. _TAUTOLOGICAL_EXPRS = frozenset({"true", "1==1"}) - # Always-false predicate for an empty ``IN`` list. Milvus 2.6 rejects a + # Always-false predicate for an empty ``IN`` list. Milvus 3.0 rejects a # bare ``false`` literal ("predicate is not a boolean expression"), so the # match-nothing sentinel must be a comparison it can plan. _MATCH_NOTHING_EXPR = "1 == 0" @@ -541,7 +543,7 @@ def _build_filter_expr(self, filters: dict[str, Any] | None) -> str: return " and ".join(f"({part})" for part in parts) # ------------------------------------------------------------------ - # Sync paginated query helper (Milvus 2.6 query_iterator is sync-only) + # Sync paginated query helper (Milvus 3.0 query_iterator is sync-only) # ------------------------------------------------------------------ def _vector_dim(self) -> int: @@ -588,7 +590,7 @@ def _safe_batch_size(self, output_fields: list[str]) -> int: Only matters when the dense ``vector`` rides along (~dim*4 bytes/row); explicit scalar projections are small, so they keep the large default. - Milvus 2.6 returns the vector for the ``"*"`` wildcard too — the search + Milvus 3.0 returns the vector for the ``"*"`` wildcard too — the search path strips it post-hoc via ``_SEARCH_RESULT_DROPPED_KEYS`` and ``query_chunks_by_filter(["*"])`` leaks it — so ``"*"`` counts as vector-inclusive here. The dimension comes from :meth:`_vector_dim`, not @@ -608,7 +610,7 @@ def _iter_query( output_fields: list[str], batch_size: int | None = None, ) -> list[dict[str, Any]]: - """Drain a Milvus 2.6 ``query_iterator`` into a list. + """Drain a Milvus 3.0 ``query_iterator`` into a list. ``batch_size`` defaults to :meth:`_safe_batch_size`, which shrinks the page for vector-inclusive projections so one page stays under Milvus's @@ -781,12 +783,12 @@ async def upsert( collection_name=self._collection_name, ) from e - # Milvus 2.6 returns {"insert_count": N, "ids": [...], "cost": ...}. + # Milvus 3.0 returns {"insert_count": N, "ids": [...], "cost": ...}. # Fall back to len(entities) if the server omits insert_count. return int(result.get("insert_count", len(entities))) if isinstance(result, dict) else len(entities) def _parse_search_response(self, response: Any) -> list[dict[str, Any]]: - """Normalise a Milvus 2.6 search/hybrid_search response to raw dicts. + """Normalise a Milvus 3.0 search/hybrid_search response to raw dicts. Each record has ``id`` (stringified for :class:`Chunk` round-trip), ``score`` (distance for dense, fused RRF score for hybrid), and the @@ -810,28 +812,6 @@ def _parse_search_response(self, response: Any) -> list[dict[str, Any]]: out.append(record) return out - @contextmanager - def _search_errors(self, kind: str) -> Iterator[None]: - """Map Milvus failures from a search call to the VDB error taxonomy. - - Wraps the ``await`` site so :meth:`search` and :meth:`hybrid_search` - don't each repeat the same two-arm ``MilvusException`` / - ``Exception`` translation. ``kind`` names the operation for the - message (``"dense search"`` / ``"hybrid search"``). - """ - try: - yield - except MilvusException as e: - raise VDBSearchError( - f"Milvus {kind} failed: {e!s}", - collection_name=self._collection_name, - ) from e - except Exception as e: - raise UnexpectedVDBError( - f"Unexpected error during Milvus {kind}: {e!s}", - collection_name=self._collection_name, - ) from e - def _dense_search_params(self, similarity_threshold: float | None) -> dict[str, Any]: """Build the dense COSINE search params, optionally range-filtered. @@ -849,6 +829,74 @@ def _dense_search_params(self, similarity_threshold: float | None) -> dict[str, params["range_filter"] = COSINE_RANGE_FILTER_MAX return {"metric_type": DEFAULT_DENSE_SEARCH_PARAMS["metric_type"], "params": params} + async def _search_error_is_empty_result( + self, + error: MilvusException, + expr: str, + verify_empty_result: Callable[[], Awaitable[bool]] | None = None, + ) -> bool: + """Verify Milvus 3.0 server errors before treating them as no results. + + This is a compatibility workaround, not a stable Milvus API contract. + The v3.0.0 result converter emits the generic ``unsupported ID type`` + internal error when a filtered hybrid request produces no IDs: + https://github.com/milvus-io/milvus/blob/v3.0.0/internal/util/function/chain/converter.go#L385 + + The error code and message are therefore never sufficient on their + own. A second server call must prove that the collection is absent or + that every ANN leg returned no IDs. Verification failures return + ``False`` so the original error remains visible. Remove this + workaround when the supported Milvus release returns an empty search + result directly. + """ + message = str(error) + if error.code == 100 and "collection not found" in message: + try: + exists = await asyncio.to_thread(self._client.has_collection, self._collection_name) + except Exception: + return False + if not exists: + logger.bind( + collection_name=self._collection_name, + filter=expr, + reason="missing_collection", + error_code=error.code, + ).warning("Milvus search error verified as an empty result") + return True + return False + + if error.code == 5 and "unsupported ID type" in message and verify_empty_result is not None: + try: + is_empty = await verify_empty_result() + except Exception: + return False + if is_empty: + logger.bind( + collection_name=self._collection_name, + filter=expr, + reason="empty_ann_result", + error_code=error.code, + ).warning("Milvus search error verified as an empty result") + return True + return False + + return False + + async def _search_error_or_empty( + self, + error: MilvusException, + kind: str, + expr: str, + verify_empty_result: Callable[[], Awaitable[bool]] | None = None, + ) -> list[dict[str, Any]]: + """Return no results for a verified empty scope, otherwise map the error.""" + if await self._search_error_is_empty_result(error, expr, verify_empty_result): + return [] + raise VDBSearchError( + f"Milvus {kind} failed: {error!s}", + collection_name=self._collection_name, + ) from error + async def search( self, embedding: list[float], @@ -875,6 +923,37 @@ async def search( return await self._hybrid_search(embedding, query_text, top_k, collection, filters, similarity_threshold) return await self._dense_search(embedding, top_k, collection, filters, similarity_threshold) + async def _hybrid_search_legs_are_empty( + self, + embedding: list[float], + query_text: str, + top_k: int, + expr: str, + similarity_threshold: float | None, + ) -> bool: + """Verify that dense and BM25 searches both produced no candidates.""" + dense_response, sparse_response = await asyncio.gather( + self._async_client.search( + collection_name=self._collection_name, + data=[embedding], + anns_field="vector", + search_params=self._dense_search_params(similarity_threshold), + limit=top_k, + filter=expr, + output_fields=["_id"], + ), + self._async_client.search( + collection_name=self._collection_name, + data=[query_text], + anns_field="sparse", + search_params=DEFAULT_BM25_SEARCH_PARAMS, + limit=top_k, + filter=expr, + output_fields=["_id"], + ), + ) + return all(not response or not response[0] for response in (dense_response, sparse_response)) + async def _dense_search( self, embedding: list[float], @@ -891,7 +970,7 @@ async def _dense_search( self._resolve_collection(collection) expr = self._build_filter_expr(filters) - with self._search_errors("dense search"): + try: response = await self._async_client.search( collection_name=self._collection_name, data=[embedding], @@ -901,6 +980,13 @@ async def _dense_search( filter=expr, output_fields=["*"], ) + except MilvusException as e: + return await self._search_error_or_empty(e, "dense search", expr) + except Exception as e: + raise UnexpectedVDBError( + f"Unexpected error during Milvus dense search: {e!s}", + collection_name=self._collection_name, + ) from e return self._parse_search_response(response) @@ -953,7 +1039,7 @@ async def _hybrid_search( expr=expr, ) - with self._search_errors("hybrid search"): + try: response = await self._async_client.hybrid_search( collection_name=self._collection_name, reqs=[dense_req, sparse_req], @@ -961,6 +1047,24 @@ async def _hybrid_search( limit=top_k, output_fields=["*"], ) + except MilvusException as e: + return await self._search_error_or_empty( + e, + "hybrid search", + expr, + lambda: self._hybrid_search_legs_are_empty( + embedding, + query_text, + top_k, + expr, + similarity_threshold, + ), + ) + except Exception as e: + raise UnexpectedVDBError( + f"Unexpected error during Milvus hybrid search: {e!s}", + collection_name=self._collection_name, + ) from e return self._parse_search_response(response) @@ -1168,7 +1272,7 @@ async def query_ids_by_filter( ) -> list[str]: """Return ``Chunk.id`` strings for every row matching ``filters``. - Uses Milvus 2.6 ``query_iterator`` under the hood so result-set size + Uses Milvus 3.0 ``query_iterator`` under the hood so result-set size is bounded only by Milvus pagination, not by a server-side ``limit``. The returned IDs are the INT64 ``_id`` values stringified for round-trip with :class:`Chunk`. @@ -1186,7 +1290,7 @@ async def query_chunks_by_filter( ) -> list[dict[str, Any]]: """Return full row data for every chunk matching ``filters``. - ``output_fields`` defaults to ``["*"]``, which in Milvus 2.6 includes + ``output_fields`` defaults to ``["*"]``, which in Milvus 3.0 includes the dense ``vector`` field (unlike :meth:`search`, which strips it via ``_SEARCH_RESULT_DROPPED_KEYS``). Callers that don't want the vector should pass an explicit scalar projection instead of ``["*"]``. diff --git a/pyproject.toml b/pyproject.toml index c0db9d14f..64277ae76 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ dependencies = [ "fast-langdetect>=1.0.0", "ruff>=0.14.1", "cairosvg>=2.7.0", - "pymilvus>=2.6.9", + "pymilvus>=3.0.1,<3.1", "protobuf>=5.27,<6.0", "faster-whisper>=1.1.0", "authlib>=1.3", diff --git a/tests/integration/api/api_run/docker-compose.yaml b/tests/integration/api/api_run/docker-compose.yaml index fc6b6e697..407663e82 100644 --- a/tests/integration/api/api_run/docker-compose.yaml +++ b/tests/integration/api/api_run/docker-compose.yaml @@ -54,12 +54,13 @@ services: retries: 5 milvus: - image: milvusdb/milvus:v2.6.11 + image: milvusdb/milvus:v3.0.0 command: ["milvus", "run", "standalone"] security_opt: - seccomp:unconfined environment: ETCD_ENDPOINTS: etcd:2379 + ETCD_AUTH_ENABLED: "false" MINIO_ADDRESS: minio:9000 depends_on: etcd: diff --git a/tests/integration/repos/docker-compose.yaml b/tests/integration/repos/docker-compose.yaml index 0caa6907d..6bd025565 100644 --- a/tests/integration/repos/docker-compose.yaml +++ b/tests/integration/repos/docker-compose.yaml @@ -26,12 +26,13 @@ services: retries: 5 milvus: - image: milvusdb/milvus:v2.6.11 + image: milvusdb/milvus:v3.0.0 command: ["milvus", "run", "standalone"] security_opt: - seccomp:unconfined environment: ETCD_ENDPOINTS: etcd:2379 + ETCD_AUTH_ENABLED: "false" MINIO_ADDRESS: minio:9000 ports: - "19530:19530" diff --git a/tests/integration/repos/test_milvus_store_integration.py b/tests/integration/repos/test_milvus_store_integration.py index 2e6682ef7..f15c1cbcb 100644 --- a/tests/integration/repos/test_milvus_store_integration.py +++ b/tests/integration/repos/test_milvus_store_integration.py @@ -1,6 +1,6 @@ """End-to-end integration tests for :class:`MilvusVectorStore`. -These tests round-trip through a real Milvus 2.6 instance: they create a +These tests round-trip through a real Milvus 3.0 instance: they create a fresh collection per test, exercise the public surface, and drop the collection on teardown. They are gated by the ``integration`` pytest marker and auto-skip when the configured Milvus host is not reachable. @@ -203,6 +203,16 @@ async def test_initialize_is_idempotent(self, hybrid_store: MilvusVectorStore) - await hybrid_store.initialize(_EMBEDDING_DIM) assert hybrid_store._loaded is True + @pytest.mark.asyncio + async def test_search_before_collection_creation_returns_no_results(self, hybrid_store: MilvusVectorStore) -> None: + hits = await hybrid_store.search( + _embedding(0.1), + query_text="collection not created yet", + filters={"partition": "default"}, + ) + + assert hits == [] + @pytest.mark.asyncio async def test_ensure_collection_rejects_dimension_change( self, hybrid_store: MilvusVectorStore, hybrid_config: VectorDBConfig @@ -289,6 +299,37 @@ async def test_hybrid_search_returns_fused_results(self, hybrid_store: MilvusVec assert "score" in hit assert "text" in hit + @pytest.mark.asyncio + async def test_hybrid_search_empty_partition_returns_no_results(self, hybrid_store: MilvusVectorStore) -> None: + await hybrid_store.initialize(_EMBEDDING_DIM) + await hybrid_store.upsert([_chunk("existing chunk", "__populated_partition__", 0.1)]) + + hits = await hybrid_store.search( + _embedding(0.1), + query_text="no matching partition", + top_k=5, + filters={"partition": "__empty_partition__"}, + ) + + assert hits == [] + + @pytest.mark.asyncio + async def test_hybrid_search_populated_partition_without_candidates_returns_no_results( + self, hybrid_store: MilvusVectorStore + ) -> None: + await hybrid_store.initialize(_EMBEDDING_DIM) + await hybrid_store.upsert([_chunk("alpha known vocabulary", "p1", 1.0)]) + + hits = await hybrid_store.search( + [-1.0, -1.1, -1.2, -1.3], + query_text="zzzzunseenlexeme", + top_k=5, + filters={"partition": "p1"}, + similarity_threshold=0.99, + ) + + assert hits == [] + class TestDeleteByFilter: @pytest.mark.asyncio @@ -333,7 +374,7 @@ async def test_query_chunks_returns_full_records(self, hybrid_store: MilvusVecto assert rows assert rows[0]["partition"] == "p1" assert rows[0]["text"] == "only" - # Milvus 2.6 returns the dense ``vector`` for the default ``["*"]`` + # Milvus 3.0 returns the dense ``vector`` for the default ``["*"]`` # projection (unlike the search path, which strips it via # ``_SEARCH_RESULT_DROPPED_KEYS``). ``_safe_batch_size`` relies on this # to shrink the query_iterator page for wildcard reads, so assert the diff --git a/tests/integration/test_reindex_no_duplicate_integration.py b/tests/integration/test_reindex_no_duplicate_integration.py index db895723b..9610bdf96 100644 --- a/tests/integration/test_reindex_no_duplicate_integration.py +++ b/tests/integration/test_reindex_no_duplicate_integration.py @@ -1,7 +1,7 @@ """Integration proof for #657 — re-indexing must not duplicate Milvus chunks. Drives the real :class:`IndexingPipeline` (with trivial parser/chunker/embedder -fakes) against a live Milvus 2.6, indexing a file and then re-indexing it with +fakes) against a live Milvus 3.0, indexing a file and then re-indexing it with ``replace=True``. Asserts the file's chunk count stays stable (insert-before- delete) instead of doubling on every re-index. diff --git a/tests/load/workspace/README.md b/tests/load/workspace/README.md index 545650d3f..a6247b183 100644 --- a/tests/load/workspace/README.md +++ b/tests/load/workspace/README.md @@ -2,6 +2,12 @@ Performance benchmarks for OpenRAG. Each benchmark is a standalone script that shares the same Docker infrastructure (Milvus + PostgreSQL). +If this benchmark previously ran with Milvus 2.x, remove its cached volumes once before starting Milvus 3. Benchmark data is disposable and will be inserted again: + +```bash +docker compose down -v +``` + ## Quick Start ```bash @@ -21,7 +27,7 @@ docker compose down -v # Remove everything (next run re-inserts data) | Service | Image | Port | |------------|-----------------------------|-------| -| Milvus | milvusdb/milvus:v2.6.11 | 19530 | +| Milvus | milvusdb/milvus:v3.0.0 | 19530 | | PostgreSQL | postgres:16 | 5433 | | etcd | quay.io/coreos/etcd:v3.5.25 | - | | MinIO | minio/minio | - | diff --git a/tests/load/workspace/docker-compose.yml b/tests/load/workspace/docker-compose.yml index 55f4c8f66..3cacc53c0 100644 --- a/tests/load/workspace/docker-compose.yml +++ b/tests/load/workspace/docker-compose.yml @@ -33,12 +33,13 @@ services: milvus: container_name: bench-milvus - image: milvusdb/milvus:v2.6.11 + image: milvusdb/milvus:v3.0.0 command: ["milvus", "run", "standalone"] security_opt: - seccomp:unconfined environment: ETCD_ENDPOINTS: etcd:2379 + ETCD_AUTH_ENABLED: "false" MINIO_ADDRESS: minio:9000 volumes: - milvus_data:/var/lib/milvus diff --git a/tests/load/workspace/requirements.txt b/tests/load/workspace/requirements.txt index 873e5cd8c..df03583da 100644 --- a/tests/load/workspace/requirements.txt +++ b/tests/load/workspace/requirements.txt @@ -1,4 +1,4 @@ -pymilvus>=2.5.0 +pymilvus>=3.0.1,<3.1 sqlalchemy>=2.0 psycopg2-binary>=2.9 numpy>=1.24 diff --git a/tests/unit/infra/test_compose_storage.py b/tests/unit/infra/test_compose_storage.py index f19261811..4c442df17 100644 --- a/tests/unit/infra/test_compose_storage.py +++ b/tests/unit/infra/test_compose_storage.py @@ -24,6 +24,28 @@ def _load_env_example(path: Path) -> dict[str, str]: return values +def _assert_milvus_initializer(services: dict) -> None: + """Assert that Milvus starts only after its storage is safe for UID 999.""" + initializer = services["milvus-init"] + milvus = services["milvus"] + + assert initializer["image"] == milvus["image"] == "milvusdb/milvus:v3.0.0" + assert initializer["user"] == "0:0" + assert initializer["environment"]["LD_PRELOAD"] == "" + assert initializer["entrypoint"] == ["/bin/sh", "-ec"] + assert initializer["volumes"] == milvus["volumes"] + assert initializer["read_only"] is True + assert initializer["cap_drop"] == ["ALL"] + assert initializer["cap_add"] == ["CHOWN", "DAC_READ_SEARCH"] + + command = initializer["command"][0] + assert "! -uid 999" in command + assert "! -gid 999" in command + assert "exit 1" in command + assert "chown 999:999 /var/lib/milvus" in command + assert milvus["depends_on"]["milvus-init"]["condition"] == "service_completed_successfully" + + def test_compose_defaults_preserve_existing_host_paths() -> None: compose = _load_yaml(COMPOSE_DIR / "docker-compose.yaml") @@ -48,10 +70,16 @@ def test_compose_defaults_preserve_existing_host_paths() -> None: def test_milvus_compose_defaults_preserve_existing_host_paths() -> None: compose = _load_yaml(COMPOSE_DIR / "milvus" / "milvus.yaml") + services = compose["services"] + + assert services["etcd"]["volumes"] == ["${MILVUS_VOLUME_DIRECTORY:-./volumes}/etcd:/etcd"] + assert services["minio"]["volumes"] == ["${MILVUS_VOLUME_DIRECTORY:-./volumes}/minio:/minio_data"] + assert services["milvus"]["volumes"] == ["${MILVUS_VOLUME_DIRECTORY:-./volumes}/milvus:/var/lib/milvus"] - assert compose["services"]["etcd"]["volumes"] == ["${MILVUS_VOLUME_DIRECTORY:-./volumes}/etcd:/etcd"] - assert compose["services"]["minio"]["volumes"] == ["${MILVUS_VOLUME_DIRECTORY:-./volumes}/minio:/minio_data"] - assert compose["services"]["milvus"]["volumes"] == ["${MILVUS_VOLUME_DIRECTORY:-./volumes}/milvus:/var/lib/milvus"] + _assert_milvus_initializer(services) + assert services["milvus"]["environment"]["ETCD_AUTH_ENABLED"] == "false" + assert services["milvus"]["environment"]["MQ_TYPE"] == "${MILVUS_MQ_TYPE:-default}" + assert services["milvus"]["depends_on"]["milvus-init"]["condition"] == "service_completed_successfully" def test_named_volume_profile_is_opt_in() -> None: @@ -72,7 +100,11 @@ def test_named_volume_profile_is_opt_in() -> None: assert named_milvus["services"]["etcd"]["volumes"] == ["${ETCD_VOLUME:-etcd}:/etcd"] assert named_milvus["services"]["minio"]["volumes"] == ["${MINIO_VOLUME:-minio}:/minio_data"] assert named_milvus["services"]["milvus"]["volumes"] == ["${MILVUS_VOLUME:-milvus}:/var/lib/milvus"] + assert named_milvus["services"]["milvus"]["image"] == "milvusdb/milvus:v3.0.0" + assert named_milvus["services"]["milvus"]["environment"]["ETCD_AUTH_ENABLED"] == "false" + assert named_milvus["services"]["milvus"]["environment"]["MQ_TYPE"] == "${MILVUS_MQ_TYPE:-default}" assert {"etcd", "minio", "milvus"} <= set(named_milvus["volumes"]) + _assert_milvus_initializer(named_milvus["services"]) minio_env = named_milvus["services"]["minio"]["environment"] milvus_env = named_milvus["services"]["milvus"]["environment"] diff --git a/tests/unit/services/storage/test_milvus_store.py b/tests/unit/services/storage/test_milvus_store.py index 49ce9d219..d6bafd9d0 100644 --- a/tests/unit/services/storage/test_milvus_store.py +++ b/tests/unit/services/storage/test_milvus_store.py @@ -4,7 +4,7 @@ exercise filter-expression construction, ID coercion, entity layering, and the ``collection`` argument discipline without touching a live Milvus. -Integration tests that round-trip through a real Milvus 2.6 container live in +Integration tests that round-trip through a real Milvus 3.0 container live in :mod:`test_milvus_store_integration` and are gated by the ``integration`` pytest marker. """ @@ -16,6 +16,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest +from pymilvus import MilvusException from openrag.core.config.infrastructure import VectorDBConfig from openrag.core.models.chunk import Chunk, ChunkType @@ -166,7 +167,7 @@ def test_list_field_becomes_in(self, store: MilvusVectorStore) -> None: def test_empty_list_field_matches_nothing(self, store: MilvusVectorStore) -> None: # An empty IN list cannot be expressed in Milvus, so short-circuit to # an always-false comparison — callers get an empty result set instead - # of a syntax error. A bare ``false`` literal is rejected by Milvus 2.6 + # of a syntax error. A bare ``false`` literal is rejected by Milvus 3.0 # ("predicate is not a boolean expression"), so it must be ``1 == 0``. assert store._build_filter_expr({"file_id": []}) == "1 == 0" @@ -321,7 +322,7 @@ def test_explicit_scalar_projection_keeps_large_default(self, store: MilvusVecto assert store._safe_batch_size(["partition", "file_id"]) == 16_000 def test_wildcard_is_treated_as_vector_inclusive(self, store: MilvusVectorStore) -> None: - # Milvus 2.6 returns the dense vector for ``["*"]`` too, so a wildcard + # Milvus 3.0 returns the dense vector for ``["*"]`` too, so a wildcard # page must shrink even without an explicit ``"vector"`` field. _set_schema_dim(store, 1024) assert store._safe_batch_size(["*"]) == 3_276 @@ -562,6 +563,137 @@ async def test_hybrid_store_requires_query_text(self, store: MilvusVectorStore) with pytest.raises(VDBSearchError, match="query_text"): await store.search([0.1, 0.2], collection="default") + @pytest.mark.asyncio + async def test_empty_filtered_hybrid_search_returns_no_results( + self, store: MilvusVectorStore, monkeypatch: pytest.MonkeyPatch + ) -> None: + logger = MagicMock() + monkeypatch.setattr("openrag.services.storage.milvus_store.logger", logger) + store._async_client.hybrid_search = AsyncMock( # type: ignore[attr-defined] + side_effect=MilvusException(5, "service internal error: unsupported ID type") + ) + store._async_client.search = AsyncMock(side_effect=[[[]], [[]]]) # type: ignore[attr-defined] + + result = await store.search( + [0.1, 0.2], + query_text="query", + filters={"partition": "empty"}, + ) + + assert result == [] + assert store._async_client.search.await_count == 2 # type: ignore[attr-defined] + logger.bind.assert_called_once_with( + collection_name="test_collection", + filter='partition == "empty"', + reason="empty_ann_result", + error_code=5, + ) + logger.bind.return_value.warning.assert_called_once_with("Milvus search error verified as an empty result") + + @pytest.mark.asyncio + async def test_missing_collection_search_returns_no_results( + self, store: MilvusVectorStore, monkeypatch: pytest.MonkeyPatch + ) -> None: + logger = MagicMock() + monkeypatch.setattr("openrag.services.storage.milvus_store.logger", logger) + store._async_client.hybrid_search = AsyncMock( # type: ignore[attr-defined] + side_effect=MilvusException(100, "collection not found[database=default][collection=test_collection]") + ) + store._client.has_collection.return_value = False # type: ignore[attr-defined] + + result = await store.search( + [0.1, 0.2], + query_text="query", + filters={"partition": "default"}, + ) + + assert result == [] + store._client.has_collection.assert_called_once_with("test_collection") # type: ignore[attr-defined] + logger.bind.assert_called_once_with( + collection_name="test_collection", + filter='partition == "default"', + reason="missing_collection", + error_code=100, + ) + logger.bind.return_value.warning.assert_called_once_with("Milvus search error verified as an empty result") + + @pytest.mark.asyncio + async def test_missing_collection_dense_search_returns_no_results(self, store: MilvusVectorStore) -> None: + store._hybrid = False + store._async_client.search = AsyncMock( # type: ignore[attr-defined] + side_effect=MilvusException(100, "collection not found[database=default][collection=test_collection]") + ) + store._client.has_collection.return_value = False # type: ignore[attr-defined] + + result = await store.search( + [0.1, 0.2], + filters={"partition": "default"}, + ) + + assert result == [] + + @pytest.mark.asyncio + async def test_missing_collection_error_is_preserved_when_collection_exists(self, store: MilvusVectorStore) -> None: + store._async_client.hybrid_search = AsyncMock( # type: ignore[attr-defined] + side_effect=MilvusException(100, "collection not found[database=default][collection=test_collection]") + ) + store._client.has_collection.return_value = True # type: ignore[attr-defined] + + with pytest.raises(VDBSearchError, match="collection not found"): + await store.search( + [0.1, 0.2], + query_text="query", + filters={"partition": "default"}, + ) + + @pytest.mark.asyncio + async def test_empty_result_verification_failure_preserves_search_error(self, store: MilvusVectorStore) -> None: + store._async_client.hybrid_search = AsyncMock( # type: ignore[attr-defined] + side_effect=MilvusException(5, "service internal error: unsupported ID type") + ) + store._async_client.search = AsyncMock( # type: ignore[attr-defined] + side_effect=RuntimeError("verification unavailable") + ) + + with pytest.raises(VDBSearchError, match="unsupported ID type"): + await store.search( + [0.1, 0.2], + query_text="query", + filters={"partition": "empty"}, + ) + + @pytest.mark.asyncio + async def test_hybrid_search_error_is_preserved_when_an_ann_leg_has_results(self, store: MilvusVectorStore) -> None: + store._async_client.hybrid_search = AsyncMock( # type: ignore[attr-defined] + side_effect=MilvusException(5, "service internal error: unsupported ID type") + ) + store._async_client.search = AsyncMock( # type: ignore[attr-defined] + side_effect=[[[{"_id": 1}]], [[]]] + ) + + with pytest.raises(VDBSearchError, match="unsupported ID type"): + await store.search( + [0.1, 0.2], + query_text="query", + filters={"partition": "populated"}, + ) + + @pytest.mark.asyncio + async def test_populated_filter_with_no_ann_results_returns_no_results(self, store: MilvusVectorStore) -> None: + store._async_client.hybrid_search = AsyncMock( # type: ignore[attr-defined] + side_effect=MilvusException(5, "service internal error: unsupported ID type") + ) + store._async_client.search = AsyncMock(side_effect=[[[]], [[]]]) # type: ignore[attr-defined] + + result = await store.search( + [0.1, 0.2], + query_text="query with no candidates", + filters={"partition": "populated"}, + similarity_threshold=0.99, + ) + + assert result == [] + # --------------------------------------------------------------------------- # _parse_search_response @@ -569,7 +701,7 @@ async def test_hybrid_store_requires_query_text(self, store: MilvusVectorStore) class TestParseSearchResponse: - """Milvus 2.6 exposes the ``_id`` auto-id PK on the hit (and in the entity), + """Milvus 3.0 exposes the ``_id`` auto-id PK on the hit (and in the entity), never under the generic ``id`` key — the parser must surface the real id.""" def test_id_taken_from_hit_underscore_id(self, store: MilvusVectorStore) -> None: diff --git a/uv.lock b/uv.lock index 9b10a8e6f..e961ca218 100644 --- a/uv.lock +++ b/uv.lock @@ -2,7 +2,8 @@ version = 1 revision = 3 requires-python = ">=3.12" resolution-markers = [ - "python_full_version >= '3.13'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", "python_full_version < '3.13'", ] @@ -1297,6 +1298,10 @@ wheels = [ name = "grpcio" version = "1.67.1" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", +] sdist = { url = "https://files.pythonhosted.org/packages/20/53/d9282a66a5db45981499190b77790570617a604a38f3d103d0400974aeb5/grpcio-1.67.1.tar.gz", hash = "sha256:3dc2ed4cabea4dc14d5e708c2b426205956077cc5de419b4d4079315017e9732", size = 12580022, upload-time = "2024-10-29T06:30:07.787Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/6e/25/6f95bd18d5f506364379eabc0d5874873cc7dbdaf0757df8d1e82bc07a88/grpcio-1.67.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:267d1745894200e4c604958da5f856da6293f063327cb049a51fe67348e4f953", size = 5089809, upload-time = "2024-10-29T06:24:31.24Z" }, @@ -1319,6 +1324,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/96/44759eca966720d0f3e1b105c43f8ad4590c97bf8eb3cd489656e9590baa/grpcio-1.67.1-cp313-cp313-win_amd64.whl", hash = "sha256:fa0c739ad8b1996bd24823950e3cb5152ae91fca1c09cc791190bf1627ffefba", size = 4346042, upload-time = "2024-10-29T06:25:21.939Z" }, ] +[[package]] +name = "grpcio" +version = "1.83.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", +] +dependencies = [ + { name = "typing-extensions", marker = "python_full_version >= '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/98/304898ac4e04e2d5e4e4c2eadc178b1f2a16d5f4bc2f91306c87d64680b9/grpcio-1.83.0.tar.gz", hash = "sha256:7674587248fbbb2ac6e4eecf83a8a0f3d91a928f941de571acfd3a2f007fbc24", size = 13428824, upload-time = "2026-07-23T15:20:37.759Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/2b/51e32514a4e9b715375c99721aadff0f24164cc2049b8269eda4de82a814/grpcio-1.83.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:28f6c35ac8fcf10e4594f138e468f194360089dde40d126a7033e863fc479930", size = 6303167, upload-time = "2026-07-23T15:19:33.78Z" }, + { url = "https://files.pythonhosted.org/packages/39/33/b5b50fc2c6fbe350e04814047bb2d409feec7b36ef8b170254c050e06bc0/grpcio-1.83.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:33898e6a28e4ae598f1577cb1c4fec2a15c033d0ec52b9b45a09610dd045b9da", size = 12160538, upload-time = "2026-07-23T15:19:35.958Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5f/734e72e7b9f79bcf0b2c270b8d3bca0e4ebb97a27a50d06240b145f6d41e/grpcio-1.83.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6fb8a1dd0c6f0f931e69e9d0dc6d1c406ed2a44fa963414eafba07b7fb685d16", size = 6869310, upload-time = "2026-07-23T15:19:38.607Z" }, + { url = "https://files.pythonhosted.org/packages/a4/17/a1735f215b2a5cd43c38b79eac072ad197e61be9829905b6b29550abd0db/grpcio-1.83.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2b5e75c34842cd9c1b95285ca395c6a569664b81e3ffa6b714125922942abaaf", size = 7613472, upload-time = "2026-07-23T15:19:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/b2/78/c9e81f806ac704b6b145cb01628db398985b1f8dfdc10e23b55fb0902b3d/grpcio-1.83.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeb339838db07600481ef869507279b75326c75eac6d10f7afa62a0da1d2bcdd", size = 7040616, upload-time = "2026-07-23T15:19:42.349Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ba/94cd5af859876049d340480acbb61a959096c84b567f215534faa78d0424/grpcio-1.83.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f47d62808b4c0a97b78bff88a6d4ca283a2a492b9a04a87d814af95ca3b9c19c", size = 7570491, upload-time = "2026-07-23T15:19:44.357Z" }, + { url = "https://files.pythonhosted.org/packages/3e/15/108d30d5a5c964312ae8b9cb0e8cc5b3c1cc68d8f757cca52b3565534d26/grpcio-1.83.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62003babc444a606dcd1f009cd16391ce23669ae4ad6ec267a873da7937a69f5", size = 8605036, upload-time = "2026-07-23T15:19:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/ea/23/3828ae13c3db8233d123ad612747665817b952d8a954f32390230b582336/grpcio-1.83.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1aa567f8c3f19850ffd5d2858c9a8ea7c80f0db6c01186b71eb31e923ec984f5", size = 7981587, upload-time = "2026-07-23T15:19:48.913Z" }, + { url = "https://files.pythonhosted.org/packages/17/5b/77af31228f55f55a2a5112bb0077ad0a1c4d23dbb0c2853a62475bbdcc14/grpcio-1.83.0-cp312-cp312-win32.whl", hash = "sha256:cb2906c61db4f9c64cc360054b5df70eeb81846228e9e56a4944bd415a63dadc", size = 4394004, upload-time = "2026-07-23T15:19:50.618Z" }, + { url = "https://files.pythonhosted.org/packages/c0/da/f706e39550e7a3732ce2b9c5926107a93d74a802775b19b642a6df27dc96/grpcio-1.83.0-cp312-cp312-win_amd64.whl", hash = "sha256:1c699bbb20f143c8f2bff219de578aa2dc1f919399d67dc702b038b986ee62df", size = 5158525, upload-time = "2026-07-23T15:19:52.246Z" }, + { url = "https://files.pythonhosted.org/packages/56/eb/135daaa713f32d33b8f99b4153b3f8dc3b2a124996ac15581bf9ebdad3c3/grpcio-1.83.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:6662f3b1e07cc7493d437351860dc867bddc6a93c83ecf33bbfdaf0c217ab2d0", size = 6304480, upload-time = "2026-07-23T15:19:53.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a1/121806ce69f23138dabe06aa595b0e5f1ae051a37e4c1954eed7d692c800/grpcio-1.83.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:74fe6f9e8a35c7dbf32255ee154d15e3e5338a81ed39173d079d594d2e544cd1", size = 12154419, upload-time = "2026-07-23T15:19:56.3Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e8/d0389e09cd6b4c4d3089b92967ae4e3ffd64795bd349bf2f85cd6656d3da/grpcio-1.83.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10b3fa0475eb572c9a81a6fe37fa16a9c500c0c91cfc148cac15692b7e3c2867", size = 6873200, upload-time = "2026-07-23T15:19:58.701Z" }, + { url = "https://files.pythonhosted.org/packages/f8/51/f464c1d211fa50d5adbabe1b2e519948d99c13757052bfc9ea7afa28e284/grpcio-1.83.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5f20a988480b0f28207f057f7f7ae1313393c3cef0adcfeae8248f9947eaf881", size = 7618811, upload-time = "2026-07-23T15:20:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c0/539fe0832f2dd6500a28f5263071623fb34e8d4867aec632ccf81bd21156/grpcio-1.83.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bd82671b39065ba18cd536e9cd45b27ff649053f81ddd2c6a966d595067080f", size = 7042310, upload-time = "2026-07-23T15:20:02.675Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ca/ccf617d37ffa72567fa8e005ec7090c99da922799be2fb9847c8b21ca18c/grpcio-1.83.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc60215b5cb9fc8ca72942c498b551ac2305bd08f6ef8d4e3f0d21b64fbecd61", size = 7575412, upload-time = "2026-07-23T15:20:04.712Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b9/fd8d5245f823a8e0fd35d90e20ea3aa4acd47f8d5318fa8df307df52dec6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f1c3e5689d4b90987b1d72022bcfe866a9a3dc66197484cf856d96b6150e7f45", size = 8604248, upload-time = "2026-07-23T15:20:06.77Z" }, + { url = "https://files.pythonhosted.org/packages/14/1e/f37632fc11db72dfa4bba86c3a43e54358e53030df111ecae5e91a733ad6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a21cb4eeeba124443f399be2e8b624943cde864dcbe588cb42e5c483a52a906c", size = 7977458, upload-time = "2026-07-23T15:20:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/93/b6/d70b69ae5c0cfc341b9ba474980e4ed99cbf05c0e4a14e9eee8cb73db0a5/grpcio-1.83.0-cp313-cp313-win32.whl", hash = "sha256:8fe04f1050a59f875601eb55d42b4f66946fe89817f967e34db1462ccd07dadf", size = 4393993, upload-time = "2026-07-23T15:20:11.017Z" }, + { url = "https://files.pythonhosted.org/packages/0f/13/45d4cccb555cf4c476226979bf3d2fd0b0254216f7564c3a053e35117efc/grpcio-1.83.0-cp313-cp313-win_amd64.whl", hash = "sha256:6e01ecd9d8ef280abe1365138a4dc318f9a5287f4cb1b41d07816f796653f735", size = 5159650, upload-time = "2026-07-23T15:20:12.979Z" }, + { url = "https://files.pythonhosted.org/packages/9c/60/f2cca8147ea213d3e43ae9158d03ad04e020fdf32ff027253e1fe93f921d/grpcio-1.83.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3f351629f6ae16ecc0ec3553e586a6763ffd9f6114044286d0cbec3e09241bfa", size = 6305607, upload-time = "2026-07-23T15:20:15.353Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ab/d3874931d123a95e83a3ebf8aa04537988fb62425cedb8bf3cefc5ad41b2/grpcio-1.83.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d05ff664100d429335b93c91b8b34ddf9e94a112205e7fa06dede309e44a4e4c", size = 12166617, upload-time = "2026-07-23T15:20:17.435Z" }, + { url = "https://files.pythonhosted.org/packages/92/ff/6f18f9426b69306f4e00a9add3b0ee2748da8aad53836ef80cab0d62d04f/grpcio-1.83.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7936f2a56cf04f6514705c0fedf400971de01b6aa1719327e4718f410a765e2b", size = 6880213, upload-time = "2026-07-23T15:20:19.98Z" }, + { url = "https://files.pythonhosted.org/packages/70/21/706d1147c6b93b98f179240c13991fbcc56880eba0c868abb1ad40d8a0a6/grpcio-1.83.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b0a0be840e51b6b7ee9df9269770faf77bdf4b771053c257c21d12bad607714c", size = 7618335, upload-time = "2026-07-23T15:20:22.161Z" }, + { url = "https://files.pythonhosted.org/packages/74/04/1a8443c889115ec9e213a213e86bc93a71ee9088027e5befa09aaa0edd9d/grpcio-1.83.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:009667eaf3dcd5224c713589cdc98e7ca4ed0ff0b61132c6b276e930eb83a2df", size = 7043416, upload-time = "2026-07-23T15:20:24.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/c6/94e0fee5b12bc1da1370185b680988db6f739d19b42d9959db01a7ea50bf/grpcio-1.83.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bb669918fd88936b15599caff4160a77ab74bdeb25f2231f6e45b61282d6107b", size = 7583253, upload-time = "2026-07-23T15:20:26.313Z" }, + { url = "https://files.pythonhosted.org/packages/a0/97/de1ccb671fb85575bc5192faedf9ecdbdf5b390d2e6584dcf552bcbd370e/grpcio-1.83.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c19b454d3d3f28db81f2c7c4dbaee96e7f6fd149721733ffe79d6bc530f17404", size = 8605102, upload-time = "2026-07-23T15:20:28.437Z" }, + { url = "https://files.pythonhosted.org/packages/17/0f/0e0ec749a7034ffcbaa050e39779872950ead90c22e7e0116be3f28b2b46/grpcio-1.83.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:61007cd08640abc5c54547ee32505474c482cd733a53cb87551ea81faa6350af", size = 7979826, upload-time = "2026-07-23T15:20:31.182Z" }, + { url = "https://files.pythonhosted.org/packages/83/fa/c3fda157287f64bc65acee6c5aa90c41acf9e0d3a8e69a265eecff6d00a1/grpcio-1.83.0-cp314-cp314-win32.whl", hash = "sha256:32e11c37f5285b0c6fa3042c05fe06903696689749833fc64e67dec71b9bbe33", size = 4471765, upload-time = "2026-07-23T15:20:33.195Z" }, + { url = "https://files.pythonhosted.org/packages/a1/00/b1b26431c9d54eee11724fd6e5585473a2ed47fbc1fb95e5204906a642ce/grpcio-1.83.0-cp314-cp314-win_amd64.whl", hash = "sha256:2bb48cb5e6dd005ca12b89ce4b6ac0b48ff3112c747542ee7986ef611a8ca6d9", size = 5298932, upload-time = "2026-07-23T15:20:35.48Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -2826,7 +2875,7 @@ requires-dist = [ { name = "protobuf", specifier = ">=5.27,<6.0" }, { name = "psycopg2", specifier = ">=2.9.10" }, { name = "pydub", specifier = ">=0.25.1" }, - { name = "pymilvus", specifier = ">=2.6.9" }, + { name = "pymilvus", specifier = ">=3.0.1,<3.1" }, { name = "pymupdf4llm", specifier = ">=0.0.17" }, { name = "pypdfium2", specifier = ">=4.30.0" }, { name = "pytest-env", specifier = ">=1.1.5" }, @@ -2883,7 +2932,8 @@ version = "1.35.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, - { name = "grpcio" }, + { name = "grpcio", version = "1.67.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "grpcio", version = "1.83.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, { name = "opentelemetry-api" }, { name = "opentelemetry-exporter-otlp-proto-common" }, { name = "opentelemetry-proto" }, @@ -3987,21 +4037,21 @@ sdist = { url = "https://files.pythonhosted.org/packages/5d/ab/34ec41718af73c001 [[package]] name = "pymilvus" -version = "2.6.12" +version = "3.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cachetools" }, - { name = "grpcio" }, + { name = "grpcio", version = "1.67.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "grpcio", version = "1.83.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, { name = "orjson" }, { name = "pandas" }, { name = "protobuf" }, { name = "python-dotenv" }, { name = "requests" }, - { name = "setuptools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2c/d7/c5d1381248a33975ccc864a0f980f93270ecc35354de8646c8a16443cccb/pymilvus-2.6.12.tar.gz", hash = "sha256:8323e990dc305e607fef525498eb779e42940a69e0691dde009cd02d48845f7a", size = 1584521, upload-time = "2026-04-09T07:49:11.374Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/78/6bd0dba340706bc63346af96f0ebe36ff17f75404f5de935fda94d476c98/pymilvus-3.0.1.tar.gz", hash = "sha256:c02389059088b18d6e598cd175541e445c772fab4926c5e527c4913be34887f1", size = 347593, upload-time = "2026-07-29T14:55:43.061Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/5d/44b0fa94c91503381e6f12298277f84f8e7b0bb00715ab89fc273c4d681e/pymilvus-2.6.12-py3-none-any.whl", hash = "sha256:69051b8b62712f157b2b50aeb7bde7fd7cdb5940aac0122094eb3cd58bc20f0d", size = 315183, upload-time = "2026-04-09T07:49:09.013Z" }, + { url = "https://files.pythonhosted.org/packages/60/9d/7011887b29f452905745e8bd321f404068d5bfe78fe84e42c0b7cd81a065/pymilvus-3.0.1-py3-none-any.whl", hash = "sha256:c5a8d5c1fa1de7b416e3529d383d8cc2e7da2170433ffa4a2d9087e14f70171a", size = 386820, upload-time = "2026-07-29T14:55:44.279Z" }, ] [[package]] @@ -4292,7 +4342,8 @@ name = "qdrant-client" version = "1.14.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "grpcio" }, + { name = "grpcio", version = "1.67.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "grpcio", version = "1.83.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, { name = "httpx", extra = ["http2"] }, { name = "numpy" }, { name = "portalocker" }, @@ -4374,7 +4425,8 @@ default = [ { name = "aiohttp" }, { name = "aiohttp-cors" }, { name = "colorful" }, - { name = "grpcio" }, + { name = "grpcio", version = "1.67.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "grpcio", version = "1.83.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, { name = "opencensus" }, { name = "opentelemetry-exporter-prometheus" }, { name = "opentelemetry-proto" },