Skip to content

Commit ba58eb1

Browse files
fix(vector-io): persist vector store metadata to kvstore in Milvus, Chroma, and Weaviate (#6371)
# What does this PR do? Ran into #5954 while digging into why `file_search` silently returns empty results after a server restart. The Milvus, Chroma, and Weaviate providers only write vector store metadata to the in-memory `self.cache` in `register_vector_store`, but both `initialize()` and `_get_and_cache_vector_store_index()` try to read that metadata back from the kvstore. So any store created at runtime via `client.vector_stores.create(...)` is just gone after a restart, and in a multi-instance setup it's only visible to whichever instance happened to handle the create call. I saw #5997 took a swing at this a while ago but stalled out without tests. This adds the kvstore write on register and the matching delete on unregister for all three providers, same as what pgvector, sqlite_vec, and qdrant already do. One wrinkle: Weaviate's kvstore is optional (persistence can simply be unconfigured), so there the write and delete are skipped when no kvstore is set instead of raising — matching its existing "registry will not persist across restarts" behavior. Milvus and Chroma always have a kvstore by the time register can be called, so they get the same RuntimeError guard the other providers use. ## Test Plan New tests in `tests/unit/providers/vector_io/test_vector_store_kvstore_persistence.py`, parametrized over all three providers with a real sqlite kvstore and mocked backend clients: - register persists the metadata to the kvstore - a fresh adapter on the same kvstore (simulated restart) can resolve the store - unregister removes the kvstore entry The register and restart tests fail on main without the fix and pass with it. Full `tests/unit/providers/vector_io/` suite: 286 passed. Ruff check and format clean. --------- Signed-off-by: goingforstudying-ctrl <goingforstudying-ctrl@users.noreply.github.com> Co-authored-by: goingforstudying-ctrl <goingforstudying-ctrl@users.noreply.github.com>
1 parent a03a71e commit ba58eb1

4 files changed

Lines changed: 206 additions & 10 deletions

File tree

src/ogx/providers/remote/vector_io/chroma/chroma.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,11 @@ async def shutdown(self) -> None:
290290
await super().shutdown()
291291

292292
async def register_vector_store(self, vector_store: VectorStore) -> None:
293+
if self.kvstore is None:
294+
raise RuntimeError("KVStore not initialized. Call initialize() before registering vector stores.")
295+
key = f"{VECTOR_DBS_PREFIX}{vector_store.identifier}"
296+
await self.kvstore.set(key=key, value=vector_store.model_dump_json())
297+
293298
collection = await maybe_await(
294299
self.client.get_or_create_collection(
295300
name=vector_store.identifier, metadata={"vector_store": vector_store.model_dump_json()}
@@ -300,12 +305,15 @@ async def register_vector_store(self, vector_store: VectorStore) -> None:
300305
)
301306

302307
async def unregister_vector_store(self, vector_store_id: str) -> None:
303-
if vector_store_id not in self.cache:
304-
log.warning(f"Vector DB {vector_store_id} not found")
305-
return
308+
if vector_store_id in self.cache:
309+
await self.cache[vector_store_id].index.delete()
310+
del self.cache[vector_store_id]
311+
else:
312+
log.warning("Vector DB not found", vector_store_id=vector_store_id)
306313

307-
await self.cache[vector_store_id].index.delete()
308-
del self.cache[vector_store_id]
314+
if self.kvstore is None:
315+
raise RuntimeError("KVStore not initialized. Call initialize() before unregistering vector stores.")
316+
await self.kvstore.delete(key=f"{VECTOR_DBS_PREFIX}{vector_store_id}")
309317

310318
async def insert_chunks(self, request: InsertChunksRequest) -> None:
311319
index = await self._get_and_cache_vector_store_index(request.vector_store_id)

src/ogx/providers/remote/vector_io/milvus/milvus.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,11 @@ async def shutdown(self) -> None:
524524
await super().shutdown()
525525

526526
async def register_vector_store(self, vector_store: VectorStore) -> None:
527+
if self.kvstore is None:
528+
raise RuntimeError("KVStore not initialized. Call initialize() before registering vector stores.")
529+
key = f"{VECTOR_DBS_PREFIX}{vector_store.identifier}"
530+
await self.kvstore.set(key=key, value=vector_store.model_dump_json())
531+
527532
use_native_hybrid = isinstance(self.config, RemoteMilvusVectorIOConfig)
528533
if isinstance(self.config, RemoteMilvusVectorIOConfig):
529534
consistency_level = self.config.consistency_level
@@ -579,6 +584,10 @@ async def unregister_vector_store(self, vector_store_id: str) -> None:
579584
await self.cache[vector_store_id].index.delete()
580585
del self.cache[vector_store_id]
581586

587+
if self.kvstore is None:
588+
raise RuntimeError("KVStore not initialized. Call initialize() before unregistering vector stores.")
589+
await self.kvstore.delete(key=f"{VECTOR_DBS_PREFIX}{vector_store_id}")
590+
582591
async def insert_chunks(self, request: InsertChunksRequest) -> None:
583592
index = await self._get_and_cache_vector_store_index(request.vector_store_id)
584593
if not index:

src/ogx/providers/remote/vector_io/weaviate/weaviate.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -379,18 +379,24 @@ async def register_vector_store(self, vector_store: VectorStore) -> None:
379379
],
380380
)
381381

382+
if self.kvstore is not None:
383+
key = f"{VECTOR_DBS_PREFIX}{vector_store.identifier}"
384+
await self.kvstore.set(key=key, value=vector_store.model_dump_json())
385+
382386
self.cache[vector_store.identifier] = VectorStoreWithIndex(
383387
vector_store, WeaviateIndex(client=client, collection_name=sanitized_collection_name), self.inference_api
384388
)
385389

386390
async def unregister_vector_store(self, vector_store_id: str) -> None:
387391
client = self._get_client()
388392
sanitized_collection_name = sanitize_collection_name(vector_store_id, weaviate_format=True)
389-
if vector_store_id not in self.cache or client.collections.exists(sanitized_collection_name) is False:
390-
return
391-
client.collections.delete(sanitized_collection_name)
392-
await self.cache[vector_store_id].index.delete()
393-
del self.cache[vector_store_id]
393+
if vector_store_id in self.cache and client.collections.exists(sanitized_collection_name):
394+
client.collections.delete(sanitized_collection_name)
395+
await self.cache[vector_store_id].index.delete()
396+
del self.cache[vector_store_id]
397+
398+
if self.kvstore is not None:
399+
await self.kvstore.delete(key=f"{VECTOR_DBS_PREFIX}{vector_store_id}")
394400

395401
async def _get_and_cache_vector_store_index(self, vector_store_id: str) -> VectorStoreWithIndex | None:
396402
if vector_store_id in self.cache:
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
# Copyright (c) The OGX Contributors.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the terms described in the LICENSE file in
5+
# the root directory of this source tree.
6+
7+
import sys
8+
from types import ModuleType, SimpleNamespace
9+
from unittest.mock import AsyncMock, MagicMock
10+
11+
import pytest
12+
13+
# Stub optional provider dependencies so these tests run without the heavy
14+
# backend packages installed (same pattern as tests/unit/providers/test_milvus_weights.py).
15+
if "pymilvus" not in sys.modules:
16+
pymilvus = ModuleType("pymilvus")
17+
pymilvus.AnnSearchRequest = object
18+
pymilvus.DataType = SimpleNamespace(
19+
VARCHAR="VARCHAR",
20+
FLOAT_VECTOR="FLOAT_VECTOR",
21+
JSON="JSON",
22+
SPARSE_FLOAT_VECTOR="SPARSE_FLOAT_VECTOR",
23+
)
24+
pymilvus.Function = object
25+
pymilvus.FunctionType = SimpleNamespace(BM25="BM25")
26+
pymilvus.MilvusClient = object
27+
pymilvus.RRFRanker = object
28+
pymilvus.WeightedRanker = object
29+
sys.modules["pymilvus"] = pymilvus
30+
31+
if "chromadb" not in sys.modules:
32+
chromadb = MagicMock(name="chromadb")
33+
chromadb.AsyncHttpClient = AsyncMock()
34+
sys.modules["chromadb"] = chromadb
35+
36+
if "weaviate" not in sys.modules:
37+
weaviate = MagicMock(name="weaviate")
38+
sys.modules["weaviate"] = weaviate
39+
sys.modules["weaviate.classes"] = weaviate.classes
40+
sys.modules["weaviate.classes.init"] = weaviate.classes.init
41+
sys.modules["weaviate.classes.query"] = weaviate.classes.query
42+
43+
from ogx.providers.inline.vector_io.milvus import MilvusVectorIOConfig as InlineMilvusVectorIOConfig
44+
from ogx.providers.remote.vector_io.chroma import chroma as chroma_module
45+
from ogx.providers.remote.vector_io.chroma.chroma import ChromaVectorIOAdapter
46+
from ogx.providers.remote.vector_io.chroma.config import ChromaVectorIOConfig as RemoteChromaVectorIOConfig
47+
from ogx.providers.remote.vector_io.milvus import milvus as milvus_module
48+
from ogx.providers.remote.vector_io.milvus.milvus import MilvusVectorIOAdapter
49+
from ogx.providers.remote.vector_io.weaviate import weaviate as weaviate_module
50+
from ogx.providers.remote.vector_io.weaviate.config import WeaviateVectorIOConfig
51+
from ogx.providers.remote.vector_io.weaviate.weaviate import WeaviateVectorIOAdapter
52+
from ogx_api.vector_stores import VectorStore
53+
54+
55+
def _vector_store(store_id: str) -> VectorStore:
56+
return VectorStore(
57+
identifier=store_id,
58+
provider_id="test-provider",
59+
embedding_model="test-embedding-model",
60+
embedding_dimension=8,
61+
)
62+
63+
64+
async def _make_milvus_adapter(kvstore_config, tmp_path, monkeypatch):
65+
fake_client = MagicMock()
66+
fake_client.has_collection.return_value = True
67+
monkeypatch.setattr(milvus_module, "MilvusClient", lambda **kwargs: fake_client)
68+
config = InlineMilvusVectorIOConfig(
69+
db_path=str(tmp_path / "milvus.db"),
70+
persistence=kvstore_config,
71+
)
72+
adapter = MilvusVectorIOAdapter(config, inference_api=MagicMock(), files_api=None)
73+
await adapter.initialize()
74+
return adapter
75+
76+
77+
async def _make_chroma_adapter(kvstore_config, tmp_path, monkeypatch):
78+
# Always fake the Chroma HTTP client: with chromadb installed locally the
79+
# module stub above is skipped, and a real client would try to reach
80+
# localhost:8000 during initialize(), making the test environment-dependent.
81+
fake_client = MagicMock()
82+
fake_client.get_or_create_collection = AsyncMock(
83+
side_effect=lambda name, metadata=None: SimpleNamespace(name=name, metadata=metadata)
84+
)
85+
fake_client.get_collection = AsyncMock(side_effect=lambda name: SimpleNamespace(name=name))
86+
fake_client.delete_collection = AsyncMock()
87+
monkeypatch.setattr(chroma_module.chromadb, "AsyncHttpClient", AsyncMock(return_value=fake_client))
88+
config = RemoteChromaVectorIOConfig(
89+
url="http://localhost:8000",
90+
persistence=kvstore_config,
91+
)
92+
adapter = ChromaVectorIOAdapter(config, inference_api=MagicMock(), files_api=None)
93+
await adapter.initialize()
94+
return adapter
95+
96+
97+
async def _make_weaviate_adapter(kvstore_config, tmp_path, monkeypatch):
98+
config = WeaviateVectorIOConfig(
99+
weaviate_cluster_url="localhost:8080",
100+
persistence=kvstore_config,
101+
)
102+
adapter = WeaviateVectorIOAdapter(config, inference_api=MagicMock(), files_api=None)
103+
fake_client = MagicMock()
104+
monkeypatch.setattr(adapter, "_get_client", lambda: fake_client)
105+
await adapter.initialize()
106+
return adapter
107+
108+
109+
@pytest.mark.parametrize(
110+
"make_adapter,prefix_attr",
111+
[
112+
(_make_milvus_adapter, (milvus_module, "VECTOR_DBS_PREFIX")),
113+
(_make_chroma_adapter, (chroma_module, "VECTOR_DBS_PREFIX")),
114+
(_make_weaviate_adapter, (weaviate_module, "VECTOR_DBS_PREFIX")),
115+
],
116+
ids=["milvus", "chroma", "weaviate"],
117+
)
118+
async def test_register_vector_store_persists_metadata(
119+
make_adapter, prefix_attr, unique_kvstore_config, tmp_path, monkeypatch
120+
):
121+
module, attr = prefix_attr
122+
adapter = await make_adapter(unique_kvstore_config, tmp_path, monkeypatch)
123+
vector_store = _vector_store("vs-persist")
124+
125+
await adapter.register_vector_store(vector_store)
126+
127+
raw = await adapter.kvstore.get(f"{getattr(module, attr)}vs-persist")
128+
assert raw is not None
129+
persisted = VectorStore.model_validate_json(raw)
130+
assert persisted.identifier == vector_store.identifier
131+
assert persisted.embedding_model == vector_store.embedding_model
132+
assert persisted.embedding_dimension == vector_store.embedding_dimension
133+
134+
135+
@pytest.mark.parametrize(
136+
"make_adapter",
137+
[_make_milvus_adapter, _make_chroma_adapter, _make_weaviate_adapter],
138+
ids=["milvus", "chroma", "weaviate"],
139+
)
140+
async def test_vector_store_survives_restart(make_adapter, unique_kvstore_config, tmp_path, monkeypatch):
141+
adapter = await make_adapter(unique_kvstore_config, tmp_path, monkeypatch)
142+
vector_store = _vector_store("vs-restart")
143+
await adapter.register_vector_store(vector_store)
144+
145+
# Simulate a server restart: a brand new adapter backed by the same kvstore
146+
restarted = await make_adapter(unique_kvstore_config, tmp_path, monkeypatch)
147+
148+
index = await restarted._get_and_cache_vector_store_index("vs-restart")
149+
assert index is not None
150+
assert index.vector_store.identifier == "vs-restart"
151+
152+
153+
@pytest.mark.parametrize(
154+
"make_adapter,prefix_attr",
155+
[
156+
(_make_milvus_adapter, (milvus_module, "VECTOR_DBS_PREFIX")),
157+
(_make_chroma_adapter, (chroma_module, "VECTOR_DBS_PREFIX")),
158+
(_make_weaviate_adapter, (weaviate_module, "VECTOR_DBS_PREFIX")),
159+
],
160+
ids=["milvus", "chroma", "weaviate"],
161+
)
162+
async def test_unregister_vector_store_removes_metadata(
163+
make_adapter, prefix_attr, unique_kvstore_config, tmp_path, monkeypatch
164+
):
165+
module, attr = prefix_attr
166+
adapter = await make_adapter(unique_kvstore_config, tmp_path, monkeypatch)
167+
vector_store = _vector_store("vs-unregister")
168+
await adapter.register_vector_store(vector_store)
169+
170+
await adapter.unregister_vector_store("vs-unregister")
171+
172+
assert await adapter.kvstore.get(f"{getattr(module, attr)}vs-unregister") is None
173+
assert "vs-unregister" not in adapter.cache

0 commit comments

Comments
 (0)