chore: 添加虚拟环境到仓库
- 添加 backend_service/venv 虚拟环境 - 包含所有Python依赖包 - 注意:虚拟环境约393MB,包含12655个文件
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import uuid
|
||||
from chromadb.api import ClientAPI
|
||||
from chromadb.errors import ChromaError, UniqueConstraintError
|
||||
|
||||
|
||||
def test_duplicate_collection_create(
|
||||
client: ClientAPI,
|
||||
) -> None:
|
||||
client.reset()
|
||||
|
||||
client.create_collection(
|
||||
name="test",
|
||||
metadata={"hnsw:construction_ef": 128, "hnsw:search_ef": 128, "hnsw:M": 128},
|
||||
)
|
||||
|
||||
try:
|
||||
client.create_collection(
|
||||
name="test",
|
||||
metadata={
|
||||
"hnsw:construction_ef": 128,
|
||||
"hnsw:search_ef": 128,
|
||||
"hnsw:M": 128,
|
||||
},
|
||||
)
|
||||
assert False, "Expected exception"
|
||||
except Exception as e:
|
||||
print("Collection creation failed as expected with error ", e)
|
||||
assert "already exists" in e.args[0] or isinstance(e, UniqueConstraintError)
|
||||
|
||||
|
||||
def test_not_existing_collection_delete(
|
||||
client: ClientAPI,
|
||||
) -> None:
|
||||
try:
|
||||
client.delete_collection(
|
||||
name="test101",
|
||||
)
|
||||
assert False, "Expected exception"
|
||||
except Exception as e:
|
||||
print("Collection deletion failed as expected with error ", e)
|
||||
assert "does not exist" in e.args[0]
|
||||
|
||||
|
||||
def test_multithreaded_get_or_create(client: ClientAPI) -> None:
|
||||
N_THREADS = 50
|
||||
new_name = str(uuid.uuid4())
|
||||
|
||||
def create_maybe_delete_collection(i: int) -> None:
|
||||
try:
|
||||
coll = client.get_or_create_collection(new_name)
|
||||
assert coll.name == new_name
|
||||
except ChromaError as e:
|
||||
if "concurrent" not in e.message():
|
||||
raise e
|
||||
|
||||
try:
|
||||
if i % 2 == 0:
|
||||
client.delete_collection(new_name)
|
||||
except ChromaError as e:
|
||||
if "does not exist" not in e.message():
|
||||
raise e
|
||||
|
||||
# Stress to trigger a potential race condition
|
||||
with ThreadPoolExecutor(max_workers=N_THREADS) as executor:
|
||||
futures = [
|
||||
executor.submit(create_maybe_delete_collection, i) for i in range(N_THREADS)
|
||||
]
|
||||
for future in futures:
|
||||
try:
|
||||
future.result()
|
||||
except Exception as e:
|
||||
assert False, f"Thread raised an exception: {e}"
|
||||
@@ -0,0 +1,82 @@
|
||||
import pytest
|
||||
from chromadb.api.client import AdminClient, Client
|
||||
from chromadb.config import System
|
||||
from chromadb.db.impl.sqlite import SqliteDB
|
||||
from chromadb.errors import NotFoundError
|
||||
from chromadb.test.conftest import ClientFactories
|
||||
|
||||
|
||||
def test_deletes_database(client_factories: ClientFactories) -> None:
|
||||
client = client_factories.create_client()
|
||||
client.reset()
|
||||
|
||||
admin_client = client_factories.create_admin_client_from_system()
|
||||
|
||||
admin_client.create_database("test_delete_database")
|
||||
|
||||
client = client_factories.create_client(database="test_delete_database")
|
||||
collection = client.create_collection("foo")
|
||||
|
||||
admin_client.delete_database("test_delete_database")
|
||||
|
||||
with pytest.raises(NotFoundError):
|
||||
admin_client.get_database("test_delete_database")
|
||||
|
||||
with pytest.raises(NotFoundError):
|
||||
client.get_collection("foo")
|
||||
|
||||
with pytest.raises(NotFoundError):
|
||||
collection.upsert(["foo"], [0.0, 0.0, 0.0])
|
||||
|
||||
|
||||
def test_does_not_affect_other_databases(client_factories: ClientFactories) -> None:
|
||||
client = client_factories.create_client()
|
||||
client.reset()
|
||||
|
||||
admin_client = client_factories.create_admin_client_from_system()
|
||||
|
||||
admin_client.create_database("first")
|
||||
admin_client.create_database("second")
|
||||
|
||||
first_client = client_factories.create_client(database="first")
|
||||
first_client.create_collection("test")
|
||||
|
||||
second_client = client_factories.create_client(database="second")
|
||||
second_collection = second_client.create_collection("test")
|
||||
|
||||
admin_client.delete_database("first")
|
||||
|
||||
assert second_client.get_collection("test").id == second_collection.id
|
||||
|
||||
with pytest.raises(NotFoundError):
|
||||
first_client.get_collection("test")
|
||||
|
||||
|
||||
def test_collection_was_removed(sqlite_persistent: System) -> None:
|
||||
sqlite = sqlite_persistent.instance(SqliteDB)
|
||||
|
||||
admin_client = AdminClient.from_system(sqlite_persistent)
|
||||
admin_client.create_database("test_delete_database")
|
||||
|
||||
client = Client.from_system(sqlite_persistent, database="test_delete_database")
|
||||
client.create_collection("foo")
|
||||
|
||||
admin_client.delete_database("test_delete_database")
|
||||
|
||||
with pytest.raises(NotFoundError):
|
||||
client.get_collection("foo")
|
||||
|
||||
# Check table
|
||||
with sqlite.tx() as cur:
|
||||
row = cur.execute("SELECT COUNT(*) from collections").fetchone()
|
||||
assert row[0] == 0
|
||||
|
||||
|
||||
def test_errors_when_database_does_not_exist(client_factories: ClientFactories) -> None:
|
||||
client = client_factories.create_client()
|
||||
client.reset()
|
||||
|
||||
admin_client = client_factories.create_admin_client_from_system()
|
||||
|
||||
with pytest.raises(NotFoundError):
|
||||
admin_client.delete_database("foo")
|
||||
@@ -0,0 +1,8 @@
|
||||
import pytest
|
||||
from chromadb.errors import NotFoundError
|
||||
from chromadb.test.conftest import ClientFactories
|
||||
|
||||
|
||||
def test_get_database_not_found(client_factories: ClientFactories) -> None:
|
||||
with pytest.raises(NotFoundError):
|
||||
client_factories.create_client(database="does_not_exist")
|
||||
@@ -0,0 +1,17 @@
|
||||
import numpy as np
|
||||
from chromadb.api import ClientAPI
|
||||
|
||||
|
||||
def test_invalid_update(client: ClientAPI) -> None:
|
||||
client.reset()
|
||||
|
||||
collection = client.create_collection("test")
|
||||
|
||||
# Update is invalid because ID does not exist
|
||||
collection.update(ids=["foo"], embeddings=[[0.0, 0.0, 0.0]])
|
||||
|
||||
collection.add(ids=["foo"], embeddings=[[1.0, 1.0, 1.0]])
|
||||
result = collection.get(ids=["foo"], include=["embeddings"])
|
||||
# Embeddings should be the same as what was provided to .add()
|
||||
assert result["embeddings"] is not None
|
||||
assert np.allclose(result["embeddings"][0], np.array([1.0, 1.0, 1.0]))
|
||||
@@ -0,0 +1,66 @@
|
||||
import logging
|
||||
|
||||
import chromadb.test.property.strategies as strategies
|
||||
import hypothesis.strategies as st
|
||||
from chromadb.api import ClientAPI
|
||||
from chromadb.test.conftest import NOT_CLUSTER_ONLY, reset
|
||||
from chromadb.test.property import invariants
|
||||
from chromadb.test.utils.wait_for_version_increase import wait_for_version_increase
|
||||
from hypothesis import HealthCheck, given, settings
|
||||
|
||||
collection_st = st.shared(
|
||||
strategies.collections(add_filterable_data=True, with_hnsw_params=True),
|
||||
key="coll",
|
||||
)
|
||||
recordset_st = st.shared(
|
||||
strategies.recordsets(collection_st, max_size=1000), key="recordset"
|
||||
)
|
||||
|
||||
|
||||
@settings(
|
||||
deadline=90000,
|
||||
suppress_health_check=[
|
||||
HealthCheck.function_scoped_fixture,
|
||||
HealthCheck.large_base_example,
|
||||
HealthCheck.filter_too_much,
|
||||
],
|
||||
) # type: ignore
|
||||
@given(
|
||||
collection=collection_st,
|
||||
record_set=recordset_st,
|
||||
limit=st.integers(min_value=1, max_value=10),
|
||||
offset=st.integers(min_value=0, max_value=10),
|
||||
should_compact=st.booleans(),
|
||||
)
|
||||
def test_get_limit_offset(
|
||||
caplog,
|
||||
client: ClientAPI,
|
||||
collection: strategies.Collection,
|
||||
record_set: dict,
|
||||
limit: int,
|
||||
offset: int,
|
||||
should_compact: bool,
|
||||
) -> None:
|
||||
caplog.set_level(logging.ERROR)
|
||||
|
||||
reset(client)
|
||||
coll = client.create_collection(
|
||||
name=collection.name,
|
||||
metadata=collection.metadata, # type: ignore
|
||||
embedding_function=collection.embedding_function,
|
||||
)
|
||||
|
||||
initial_version = coll.get_model()["version"]
|
||||
|
||||
coll.add(**record_set)
|
||||
|
||||
if not NOT_CLUSTER_ONLY:
|
||||
# Only wait for compaction if the size of the collection is
|
||||
# some minimal size
|
||||
if should_compact and len(invariants.wrap(record_set["ids"])) > 10:
|
||||
# Wait for the model to be updated
|
||||
wait_for_version_increase(client, collection.name, initial_version)
|
||||
|
||||
result_ids = coll.get(offset=offset, limit=limit)["ids"]
|
||||
all_offset_ids = coll.get()["ids"]
|
||||
assert result_ids == all_offset_ids[offset : offset + limit]
|
||||
@@ -0,0 +1,89 @@
|
||||
from typing import Dict, List
|
||||
from hypothesis import given
|
||||
from chromadb.test.conftest import ClientFactories
|
||||
import hypothesis.strategies as st
|
||||
|
||||
|
||||
def test_list_databases(client_factories: ClientFactories) -> None:
|
||||
client = client_factories.create_client()
|
||||
client.reset()
|
||||
admin_client = client_factories.create_admin_client_from_system()
|
||||
|
||||
for i in range(10):
|
||||
admin_client.create_database(f"test_list_databases_{i}")
|
||||
|
||||
databases = admin_client.list_databases()
|
||||
assert len(databases) == 11 # add 1 for the default_database
|
||||
|
||||
for i in range(10):
|
||||
assert any(d["name"] == f"test_list_databases_{i}" for d in databases)
|
||||
|
||||
assert any(d["name"] == "default_database" for d in databases)
|
||||
|
||||
|
||||
@st.composite
|
||||
def tenants_and_databases_st(
|
||||
draw: st.DrawFn, max_tenants: int, max_databases: int
|
||||
) -> Dict[str, List[str]]:
|
||||
"""Generates a set of random tenants and databases. Each database is assigned to a random tenant. Returns a dictionary where the key is the tenant name and the value is a list of database names for that tenant."""
|
||||
num_tenants = draw(st.integers(min_value=1, max_value=max_tenants))
|
||||
num_databases = draw(st.integers(min_value=0, max_value=max_databases))
|
||||
|
||||
database_i_to_tenant_i = draw(
|
||||
st.lists(
|
||||
st.integers(min_value=0, max_value=num_tenants - 1),
|
||||
min_size=num_databases,
|
||||
max_size=num_databases,
|
||||
)
|
||||
)
|
||||
|
||||
tenants = [f"tenant_{i}" for i in range(num_tenants)]
|
||||
databases = [f"database_{i}" for i in range(num_databases)]
|
||||
|
||||
result: Dict[str, List[str]] = {}
|
||||
for database_i, tenant_i in enumerate(database_i_to_tenant_i):
|
||||
tenant = tenants[tenant_i]
|
||||
database = databases[database_i]
|
||||
|
||||
if tenant not in result:
|
||||
result[tenant] = []
|
||||
|
||||
result[tenant].append(database)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@given(
|
||||
limit=st.integers(min_value=1, max_value=10),
|
||||
offset=st.integers(min_value=0, max_value=10),
|
||||
tenants_and_databases=tenants_and_databases_st(max_tenants=10, max_databases=10),
|
||||
)
|
||||
def test_list_databases_with_limit_offset(
|
||||
limit: int,
|
||||
offset: int,
|
||||
tenants_and_databases: Dict[str, List[str]],
|
||||
client_factories: ClientFactories,
|
||||
) -> None:
|
||||
client = client_factories.create_client()
|
||||
client.reset()
|
||||
|
||||
admin_client = client_factories.create_admin_client_from_system()
|
||||
|
||||
for tenant, databases in tenants_and_databases.items():
|
||||
admin_client.create_tenant(tenant)
|
||||
|
||||
for database in databases:
|
||||
admin_client.create_database(database, tenant)
|
||||
|
||||
for tenant, all_databases in tenants_and_databases.items():
|
||||
listed_databases = admin_client.list_databases(
|
||||
limit=limit, offset=offset, tenant=tenant
|
||||
)
|
||||
expected_databases = all_databases[offset : offset + limit]
|
||||
|
||||
if limit + offset > len(all_databases):
|
||||
assert len(listed_databases) == max(len(all_databases) - offset, 0)
|
||||
assert [d["name"] for d in listed_databases] == expected_databases
|
||||
else:
|
||||
assert len(listed_databases) == limit
|
||||
assert [d["name"] for d in listed_databases] == expected_databases
|
||||
@@ -0,0 +1,62 @@
|
||||
# Tests that various combinations of numpy and python lists work as expected as inputs
|
||||
# to add/query/update/upsert operations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
import numpy as np
|
||||
from chromadb.api import ClientAPI
|
||||
from chromadb.api.models.Collection import Collection
|
||||
from chromadb.test.conftest import reset
|
||||
|
||||
|
||||
def add_and_validate(
|
||||
collection: Collection,
|
||||
ids: List[str],
|
||||
embeddings: Any,
|
||||
metadatas: List[Dict[str, Any]],
|
||||
documents: List[str],
|
||||
) -> None:
|
||||
collection.add(ids=ids, embeddings=embeddings, metadatas=metadatas, documents=documents) # type: ignore
|
||||
|
||||
results = collection.get(include=["metadatas", "documents", "embeddings"]) # type: ignore
|
||||
assert results["ids"] == ids
|
||||
assert results["metadatas"] == metadatas
|
||||
assert results["documents"] == documents
|
||||
# Using integers instead of floats to avoid floating point comparison issues
|
||||
assert np.array_equal(results["embeddings"], embeddings) # type: ignore
|
||||
|
||||
|
||||
def test_py_list_of_numpy(client: ClientAPI) -> None:
|
||||
reset(client)
|
||||
coll = client.create_collection("test")
|
||||
ids = ["1", "2", "3"]
|
||||
embeddings = [np.array([1, 2, 3]), np.array([1, 2, 3]), np.array([1, 2, 3])]
|
||||
metadatas = [{"a": 1}, {"a": 2}, {"a": 3}]
|
||||
documents = ["a", "b", "c"]
|
||||
|
||||
# List of numpy arrays
|
||||
add_and_validate(coll, ids, embeddings, metadatas, documents)
|
||||
|
||||
|
||||
def test_py_list_of_py(client: ClientAPI) -> None:
|
||||
reset(client)
|
||||
coll = client.create_collection("test")
|
||||
ids = ["4", "5", "6"]
|
||||
embeddings = [[1, 2, 3], [1, 2, 3], [1, 2, 3]]
|
||||
metadatas = [{"a": 4}, {"a": 5}, {"a": 6}]
|
||||
documents = ["d", "e", "f"]
|
||||
|
||||
# List of python lists
|
||||
add_and_validate(coll, ids, embeddings, metadatas, documents)
|
||||
|
||||
|
||||
def test_numpy(client: ClientAPI) -> None:
|
||||
reset(client)
|
||||
coll = client.create_collection("test")
|
||||
|
||||
ids = ["7", "8", "9"]
|
||||
embeddings = np.array([[1, 2, 3], [1, 2, 3], [1, 2, 3]])
|
||||
metadata = [{"a": 7}, {"a": 8}, {"a": 9}]
|
||||
documents = ["g", "h", "i"]
|
||||
|
||||
# Numpy array
|
||||
add_and_validate(coll, ids, embeddings, metadata, documents)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,105 @@
|
||||
import pytest
|
||||
from typing import List, cast, Dict, Any
|
||||
from chromadb.api.types import Documents, Image, Document, Embeddings
|
||||
from chromadb.utils.embedding_functions import (
|
||||
EmbeddingFunction,
|
||||
register_embedding_function,
|
||||
)
|
||||
import numpy as np
|
||||
|
||||
|
||||
def random_embeddings() -> Embeddings:
|
||||
return cast(
|
||||
Embeddings, [embedding for embedding in np.random.random(size=(10, 10))]
|
||||
)
|
||||
|
||||
|
||||
def random_image() -> Image:
|
||||
return np.random.randint(0, 255, size=(10, 10, 3), dtype=np.int64)
|
||||
|
||||
|
||||
def random_documents() -> List[Document]:
|
||||
return [str(random_image()) for _ in range(10)]
|
||||
|
||||
|
||||
def test_embedding_function_results_format_when_response_is_valid() -> None:
|
||||
valid_embeddings = random_embeddings()
|
||||
|
||||
@register_embedding_function
|
||||
class TestEmbeddingFunction(EmbeddingFunction[Documents]):
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def name() -> str:
|
||||
return "test"
|
||||
|
||||
@staticmethod
|
||||
def build_from_config(config: Dict[str, Any]) -> "EmbeddingFunction[Documents]":
|
||||
return TestEmbeddingFunction()
|
||||
|
||||
def get_config(self) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
def __call__(self, input: Documents) -> Embeddings:
|
||||
return valid_embeddings
|
||||
|
||||
@staticmethod
|
||||
def validate_config(config: Dict[str, Any]) -> None:
|
||||
pass
|
||||
|
||||
def validate_config_update(
|
||||
self, old_config: Dict[str, Any], new_config: Dict[str, Any]
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
ef = TestEmbeddingFunction()
|
||||
|
||||
embeddings = ef(random_documents())
|
||||
for i, e in enumerate(embeddings):
|
||||
assert np.array_equal(e, valid_embeddings[i])
|
||||
|
||||
|
||||
def test_embedding_function_results_format_when_response_is_invalid() -> None:
|
||||
invalid_embedding = {"error": "test"}
|
||||
|
||||
@register_embedding_function
|
||||
class TestEmbeddingFunction(EmbeddingFunction[Documents]):
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def name() -> str:
|
||||
return "test"
|
||||
|
||||
@staticmethod
|
||||
def build_from_config(config: Dict[str, Any]) -> "EmbeddingFunction[Documents]":
|
||||
return TestEmbeddingFunction()
|
||||
|
||||
def get_config(self) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def validate_config(config: Dict[str, Any]) -> None:
|
||||
pass
|
||||
|
||||
def validate_config_update(
|
||||
self, old_config: Dict[str, Any], new_config: Dict[str, Any]
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
def __call__(self, input: Documents) -> Embeddings:
|
||||
# Return something that's not a valid Embeddings type
|
||||
return cast(Embeddings, invalid_embedding)
|
||||
|
||||
ef = TestEmbeddingFunction()
|
||||
|
||||
# The EmbeddingFunction protocol should validate the return value
|
||||
# but we need to bypass the protocol's __call__ wrapper for this test
|
||||
with pytest.raises(ValueError):
|
||||
# This should raise a ValueError during normalization/validation
|
||||
result = ef.__call__(random_documents())
|
||||
# The normalize_embeddings function will raise a ValueError when given an invalid embedding
|
||||
from chromadb.api.types import normalize_embeddings
|
||||
|
||||
normalize_embeddings(result)
|
||||
@@ -0,0 +1,87 @@
|
||||
import pytest
|
||||
|
||||
from chromadb.auth.utils import maybe_set_tenant_and_database
|
||||
from chromadb.auth import UserIdentity
|
||||
from chromadb.config import DEFAULT_DATABASE, DEFAULT_TENANT
|
||||
from chromadb.errors import ChromaAuthError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_identity() -> UserIdentity:
|
||||
return UserIdentity(
|
||||
user_id="test_user_id",
|
||||
tenant="test_tenant",
|
||||
databases=["test_database"],
|
||||
)
|
||||
|
||||
|
||||
def test_doesnt_overrite_from_auth(user_identity: UserIdentity) -> None:
|
||||
resolved_tenant, resolved_database = maybe_set_tenant_and_database(
|
||||
user_identity=user_identity,
|
||||
overwrite_singleton_tenant_database_access_from_auth=False,
|
||||
user_provided_tenant="user_provided_tenant",
|
||||
user_provided_database="user_provided_database",
|
||||
)
|
||||
|
||||
assert resolved_tenant == "user_provided_tenant"
|
||||
assert resolved_database == "user_provided_database"
|
||||
|
||||
|
||||
def test_sets_tenant_and_database_when_none_or_default_provided(
|
||||
user_identity: UserIdentity,
|
||||
) -> None:
|
||||
resolved_tenant, resolved_database = maybe_set_tenant_and_database(
|
||||
user_identity=user_identity,
|
||||
overwrite_singleton_tenant_database_access_from_auth=True,
|
||||
user_provided_tenant=DEFAULT_TENANT,
|
||||
user_provided_database=DEFAULT_DATABASE,
|
||||
)
|
||||
|
||||
assert resolved_tenant == "test_tenant"
|
||||
assert resolved_database == "test_database"
|
||||
|
||||
resolved_tenant, resolved_database = maybe_set_tenant_and_database(
|
||||
user_identity=user_identity,
|
||||
overwrite_singleton_tenant_database_access_from_auth=True,
|
||||
user_provided_tenant=None,
|
||||
user_provided_database=None,
|
||||
)
|
||||
|
||||
assert resolved_tenant == "test_tenant"
|
||||
assert resolved_database == "test_database"
|
||||
|
||||
|
||||
def test_errors_when_provided_tenant_and_database_dont_match_from_auth(
|
||||
user_identity: UserIdentity,
|
||||
) -> None:
|
||||
with pytest.raises(ChromaAuthError):
|
||||
maybe_set_tenant_and_database(
|
||||
user_identity=user_identity,
|
||||
overwrite_singleton_tenant_database_access_from_auth=True,
|
||||
user_provided_tenant="user_provided_tenant",
|
||||
user_provided_database="user_provided_database",
|
||||
)
|
||||
|
||||
|
||||
def test_doesnt_overrite_from_auth_when_ambiguous(user_identity: UserIdentity) -> None:
|
||||
user_identity.tenant = "*"
|
||||
user_identity.databases = ["*"]
|
||||
resolved_tenant, resolved_database = maybe_set_tenant_and_database(
|
||||
user_identity=user_identity,
|
||||
overwrite_singleton_tenant_database_access_from_auth=True,
|
||||
user_provided_tenant=None,
|
||||
user_provided_database=None,
|
||||
)
|
||||
|
||||
assert resolved_tenant is None
|
||||
assert resolved_database is None
|
||||
|
||||
resolved_tenant, resolved_database = maybe_set_tenant_and_database(
|
||||
user_identity=user_identity,
|
||||
overwrite_singleton_tenant_database_access_from_auth=True,
|
||||
user_provided_tenant="user_provided_tenant",
|
||||
user_provided_database="user_provided_database",
|
||||
)
|
||||
|
||||
assert resolved_tenant == "user_provided_tenant"
|
||||
assert resolved_database == "user_provided_database"
|
||||
@@ -0,0 +1,28 @@
|
||||
# This file is used by test_create_http_client.py to test the initialization
|
||||
# of an HttpClient class with auth settings.
|
||||
#
|
||||
# See https://github.com/chroma-core/chroma/issues/1554
|
||||
|
||||
import chromadb
|
||||
from chromadb.config import Settings
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
chromadb.HttpClient(
|
||||
host="localhost",
|
||||
port=8000,
|
||||
settings=Settings(
|
||||
chroma_client_auth_provider="chromadb.auth.basic_authn.BasicAuthClientProvider",
|
||||
chroma_client_auth_credentials="admin:testDb@home2",
|
||||
),
|
||||
)
|
||||
except ValueError:
|
||||
# We don't expect to be able to connect to Chroma. We just want to make sure
|
||||
# there isn't an ImportError.
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,334 @@
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
from chromadb import CloudClient
|
||||
from chromadb.errors import ChromaAuthError, NotFoundError
|
||||
from chromadb.auth import UserIdentity
|
||||
from chromadb.types import Tenant, Database
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
def test_valid_key() -> None:
|
||||
with patch(
|
||||
"chromadb.api.fastapi.FastAPI.get_user_identity"
|
||||
) as mock_get_user_identity, patch(
|
||||
"chromadb.api.client.AdminClient.get_tenant"
|
||||
) as mock_get_tenant, patch(
|
||||
"chromadb.api.client.AdminClient.get_database"
|
||||
) as mock_get_database, patch(
|
||||
"chromadb.api.fastapi.FastAPI.heartbeat"
|
||||
) as mock_heartbeat:
|
||||
mock_get_user_identity.return_value = UserIdentity(
|
||||
user_id="test_user", tenant="default_tenant", databases=["testdb"]
|
||||
)
|
||||
mock_get_tenant.return_value = Tenant(name="default_tenant")
|
||||
mock_get_database.return_value = Database(
|
||||
id=uuid4(), name="testdb", tenant="default_tenant"
|
||||
)
|
||||
mock_heartbeat.return_value = 1234567890
|
||||
|
||||
client = CloudClient(database="testdb", api_key="valid_token")
|
||||
|
||||
assert client.get_user_identity().user_id == "test_user"
|
||||
assert client.get_user_identity().tenant == "default_tenant"
|
||||
assert client.get_user_identity().databases == ["testdb"]
|
||||
|
||||
settings = client.get_settings()
|
||||
assert settings.chroma_client_auth_credentials == "valid_token"
|
||||
assert (
|
||||
settings.chroma_client_auth_provider
|
||||
== "chromadb.auth.token_authn.TokenAuthClientProvider"
|
||||
)
|
||||
|
||||
assert client.heartbeat() == 1234567890
|
||||
|
||||
|
||||
def test_invalid_key() -> None:
|
||||
with patch(
|
||||
"chromadb.api.fastapi.FastAPI.get_user_identity"
|
||||
) as mock_get_user_identity:
|
||||
mock_get_user_identity.side_effect = ChromaAuthError("Authentication failed")
|
||||
|
||||
with pytest.raises(ChromaAuthError):
|
||||
CloudClient(database="testdb", api_key="invalid_token")
|
||||
|
||||
|
||||
# Scoped API key to 1 database tests
|
||||
def test_scoped_api_key_to_single_db_with_api_key_only() -> None:
|
||||
with patch(
|
||||
"chromadb.api.fastapi.FastAPI.get_user_identity"
|
||||
) as mock_get_user_identity, patch(
|
||||
"chromadb.api.client.AdminClient.get_tenant"
|
||||
) as mock_get_tenant, patch(
|
||||
"chromadb.api.client.AdminClient.get_database"
|
||||
) as mock_get_database:
|
||||
# mock single db scoped api key
|
||||
mock_get_user_identity.return_value = UserIdentity(
|
||||
user_id="test_user", tenant="123-456-789", databases=["right-db"]
|
||||
)
|
||||
mock_get_tenant.return_value = Tenant(name="123-456-789")
|
||||
mock_get_database.return_value = Database(
|
||||
id=uuid4(), name="right-db", tenant="123-456-789"
|
||||
)
|
||||
|
||||
client = CloudClient(api_key="valid_token")
|
||||
|
||||
# should resolve to single db
|
||||
assert client.database == "right-db"
|
||||
assert client.tenant == "123-456-789"
|
||||
|
||||
|
||||
def test_scoped_api_key_to_single_db_with_correct_tenant() -> None:
|
||||
with patch(
|
||||
"chromadb.api.fastapi.FastAPI.get_user_identity"
|
||||
) as mock_get_user_identity, patch(
|
||||
"chromadb.api.client.AdminClient.get_tenant"
|
||||
) as mock_get_tenant, patch(
|
||||
"chromadb.api.client.AdminClient.get_database"
|
||||
) as mock_get_database:
|
||||
mock_get_user_identity.return_value = UserIdentity(
|
||||
user_id="test_user", tenant="123-456-789", databases=["right-db"]
|
||||
)
|
||||
mock_get_tenant.return_value = Tenant(name="123-456-789")
|
||||
mock_get_database.return_value = Database(
|
||||
id=uuid4(), name="right-db", tenant="123-456-789"
|
||||
)
|
||||
|
||||
client = CloudClient(tenant="123-456-789", api_key="valid_token")
|
||||
|
||||
assert client.tenant == "123-456-789"
|
||||
assert client.database == "right-db"
|
||||
|
||||
|
||||
def test_scoped_api_key_to_single_db_with_correct_db() -> None:
|
||||
with patch(
|
||||
"chromadb.api.fastapi.FastAPI.get_user_identity"
|
||||
) as mock_get_user_identity, patch(
|
||||
"chromadb.api.client.AdminClient.get_tenant"
|
||||
) as mock_get_tenant, patch(
|
||||
"chromadb.api.client.AdminClient.get_database"
|
||||
) as mock_get_database:
|
||||
mock_get_user_identity.return_value = UserIdentity(
|
||||
user_id="test_user", tenant="123-456-789", databases=["right-db"]
|
||||
)
|
||||
mock_get_tenant.return_value = Tenant(name="123-456-789")
|
||||
mock_get_database.return_value = Database(
|
||||
id=uuid4(), name="right-db", tenant="123-456-789"
|
||||
)
|
||||
|
||||
client = CloudClient(database="right-db", api_key="valid_token")
|
||||
|
||||
assert client.tenant == "123-456-789"
|
||||
assert client.database == "right-db"
|
||||
|
||||
|
||||
def test_scoped_api_key_to_single_db_with_correct_tenant_and_db() -> None:
|
||||
with patch(
|
||||
"chromadb.api.fastapi.FastAPI.get_user_identity"
|
||||
) as mock_get_user_identity, patch(
|
||||
"chromadb.api.client.AdminClient.get_tenant"
|
||||
) as mock_get_tenant, patch(
|
||||
"chromadb.api.client.AdminClient.get_database"
|
||||
) as mock_get_database:
|
||||
mock_get_user_identity.return_value = UserIdentity(
|
||||
user_id="test_user", tenant="123-456-789", databases=["right-db"]
|
||||
)
|
||||
mock_get_tenant.return_value = Tenant(name="123-456-789")
|
||||
mock_get_database.return_value = Database(
|
||||
id=uuid4(), name="right-db", tenant="123-456-789"
|
||||
)
|
||||
|
||||
client = CloudClient(
|
||||
tenant="123-456-789", database="right-db", api_key="valid_token"
|
||||
)
|
||||
|
||||
assert client.tenant == "123-456-789"
|
||||
assert client.database == "right-db"
|
||||
|
||||
|
||||
def test_scoped_api_key_to_single_db_with_wrong_tenant() -> None:
|
||||
with patch(
|
||||
"chromadb.api.fastapi.FastAPI.get_user_identity"
|
||||
) as mock_get_user_identity:
|
||||
mock_get_user_identity.return_value = UserIdentity(
|
||||
user_id="test_user", tenant="123-456-789", databases=["right-db"]
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
ChromaAuthError,
|
||||
match="Tenant wrong-tenant does not match 123-456-789 from the server. Are you sure the tenant is correct?",
|
||||
):
|
||||
CloudClient(tenant="wrong-tenant", api_key="valid_token")
|
||||
|
||||
|
||||
def test_scoped_api_key_to_single_db_with_wrong_database() -> None:
|
||||
with patch(
|
||||
"chromadb.api.fastapi.FastAPI.get_user_identity"
|
||||
) as mock_get_user_identity:
|
||||
mock_get_user_identity.return_value = UserIdentity(
|
||||
user_id="test_user", tenant="123-456-789", databases=["right-db"]
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
ChromaAuthError,
|
||||
match="Database wrong-db does not match right-db from the server. Are you sure the database is correct?",
|
||||
):
|
||||
CloudClient(database="wrong-db", api_key="valid_token")
|
||||
|
||||
|
||||
def test_scoped_api_key_to_single_db_with_wrong_api_key() -> None:
|
||||
with patch(
|
||||
"chromadb.api.fastapi.FastAPI.get_user_identity"
|
||||
) as mock_get_user_identity:
|
||||
mock_get_user_identity.side_effect = ChromaAuthError("Permission denied.")
|
||||
|
||||
with pytest.raises(ChromaAuthError, match="Permission denied."):
|
||||
CloudClient(database="right-db", api_key="wrong-api-key")
|
||||
|
||||
|
||||
# Scoped API key to multiple databases tests
|
||||
def test_scoped_api_key_to_multiple_dbs_with_wrong_tenant() -> None:
|
||||
with patch(
|
||||
"chromadb.api.fastapi.FastAPI.get_user_identity"
|
||||
) as mock_get_user_identity:
|
||||
mock_get_user_identity.return_value = UserIdentity(
|
||||
user_id="test_user",
|
||||
tenant="123-456-789",
|
||||
databases=["right-db", "another-db"],
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
ChromaAuthError,
|
||||
match="Tenant wrong-tenant does not match 123-456-789 from the server. Are you sure the tenant is correct?",
|
||||
):
|
||||
CloudClient(
|
||||
tenant="wrong-tenant", database="right-db", api_key="valid_token"
|
||||
)
|
||||
|
||||
|
||||
def test_scoped_api_key_to_multiple_dbs_with_correct_tenant_and_db() -> None:
|
||||
with patch(
|
||||
"chromadb.api.fastapi.FastAPI.get_user_identity"
|
||||
) as mock_get_user_identity, patch(
|
||||
"chromadb.api.client.AdminClient.get_tenant"
|
||||
) as mock_get_tenant, patch(
|
||||
"chromadb.api.client.AdminClient.get_database"
|
||||
) as mock_get_database:
|
||||
mock_get_user_identity.return_value = UserIdentity(
|
||||
user_id="test_user",
|
||||
tenant="123-456-789",
|
||||
databases=["right-db", "another-db"],
|
||||
)
|
||||
mock_get_tenant.return_value = Tenant(name="123-456-789")
|
||||
mock_get_database.return_value = Database(
|
||||
id=uuid4(), name="right-db", tenant="123-456-789"
|
||||
)
|
||||
|
||||
client = CloudClient(
|
||||
tenant="123-456-789", database="right-db", api_key="valid_token"
|
||||
)
|
||||
|
||||
assert client.tenant == "123-456-789"
|
||||
assert client.database == "right-db"
|
||||
|
||||
|
||||
def test_scoped_api_key_to_multiple_dbs_with_nonexistent_database() -> None:
|
||||
with patch(
|
||||
"chromadb.api.fastapi.FastAPI.get_user_identity"
|
||||
) as mock_get_user_identity, patch(
|
||||
"chromadb.api.client.AdminClient.get_tenant"
|
||||
) as mock_get_tenant, patch(
|
||||
"chromadb.api.client.AdminClient.get_database"
|
||||
) as mock_get_database:
|
||||
mock_get_user_identity.return_value = UserIdentity(
|
||||
user_id="test_user",
|
||||
tenant="123-456-789",
|
||||
databases=["right-db", "another-db"],
|
||||
)
|
||||
mock_get_tenant.return_value = Tenant(name="123-456-789")
|
||||
mock_get_database.side_effect = NotFoundError(
|
||||
"Database [wrong-db] not found. Are you sure it exists?"
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
NotFoundError,
|
||||
match="Database \\[wrong-db\\] not found. Are you sure it exists?",
|
||||
):
|
||||
CloudClient(database="wrong-db", api_key="valid_token")
|
||||
|
||||
|
||||
def test_scoped_api_key_to_multiple_dbs_with_api_key_only() -> None:
|
||||
with patch(
|
||||
"chromadb.api.fastapi.FastAPI.get_user_identity"
|
||||
) as mock_get_user_identity:
|
||||
mock_get_user_identity.return_value = UserIdentity(
|
||||
user_id="test_user",
|
||||
tenant="123-456-789",
|
||||
databases=["right-db", "another-db"],
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
ChromaAuthError,
|
||||
match="Could not determine a database name from the current authentication method. Please provide a database name.",
|
||||
):
|
||||
CloudClient(api_key="valid_token")
|
||||
|
||||
|
||||
# Unscoped API key tests
|
||||
def test_api_key_with_unscoped_tenant() -> None:
|
||||
with patch(
|
||||
"chromadb.api.fastapi.FastAPI.get_user_identity"
|
||||
) as mock_get_user_identity:
|
||||
mock_get_user_identity.return_value = UserIdentity(
|
||||
user_id="test_user", tenant="*", databases=["right-db"]
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
ChromaAuthError,
|
||||
match="Could not determine a tenant from the current authentication method. Please provide a tenant.",
|
||||
):
|
||||
CloudClient(api_key="valid_token")
|
||||
|
||||
|
||||
def test_api_key_with_unscoped_db() -> None:
|
||||
with patch(
|
||||
"chromadb.api.fastapi.FastAPI.get_user_identity"
|
||||
) as mock_get_user_identity:
|
||||
mock_get_user_identity.return_value = UserIdentity(
|
||||
user_id="test_user", tenant="123-456-789", databases=["*"]
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
ChromaAuthError,
|
||||
match="Could not determine a database name from the current authentication method. Please provide a database name.",
|
||||
):
|
||||
CloudClient(api_key="valid_token")
|
||||
|
||||
|
||||
def test_api_key_with_no_db_access() -> None:
|
||||
with patch(
|
||||
"chromadb.api.fastapi.FastAPI.get_user_identity"
|
||||
) as mock_get_user_identity:
|
||||
mock_get_user_identity.return_value = UserIdentity(
|
||||
user_id="test_user", tenant="123-456-789", databases=[]
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
ChromaAuthError,
|
||||
match="Could not determine a database name from the current authentication method. Please provide a database name.",
|
||||
):
|
||||
CloudClient(api_key="valid_token")
|
||||
|
||||
|
||||
def test_api_key_with_no_tenant_access() -> None:
|
||||
with patch(
|
||||
"chromadb.api.fastapi.FastAPI.get_user_identity"
|
||||
) as mock_get_user_identity:
|
||||
mock_get_user_identity.return_value = UserIdentity(
|
||||
user_id="test_user", tenant=None, databases=["right-db"]
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
ChromaAuthError,
|
||||
match="Could not determine a tenant from the current authentication method. Please provide a tenant.",
|
||||
):
|
||||
CloudClient(api_key="valid_token")
|
||||
@@ -0,0 +1,15 @@
|
||||
import subprocess
|
||||
|
||||
# Needs to be a module, not a file, so that local imports work.
|
||||
TEST_MODULE = "chromadb.test.client.create_http_client_with_basic_auth"
|
||||
|
||||
|
||||
def test_main() -> None:
|
||||
# This is the only way to test what we want to test: pytest does a bunch of
|
||||
# importing and other module stuff in the background, so we need a clean
|
||||
# python process to make sure we're not circular-importing.
|
||||
#
|
||||
# See https://github.com/chroma-core/chroma/issues/1554
|
||||
|
||||
res = subprocess.run(["python", "-m", TEST_MODULE])
|
||||
assert res.returncode == 0
|
||||
@@ -0,0 +1,184 @@
|
||||
import pytest
|
||||
from chromadb.config import DEFAULT_DATABASE, DEFAULT_TENANT
|
||||
from chromadb.test.conftest import ClientFactories
|
||||
from chromadb.errors import InvalidArgumentError
|
||||
from chromadb.api.types import GetResult
|
||||
from typing import Dict, Any
|
||||
import numpy as np
|
||||
|
||||
|
||||
def test_database_tenant_collections(client_factories: ClientFactories) -> None:
|
||||
client = client_factories.create_client_from_system()
|
||||
client.reset()
|
||||
# Create a new database in the default tenant
|
||||
admin_client = client_factories.create_admin_client_from_system()
|
||||
admin_client.create_database("test_db")
|
||||
|
||||
# Create collections in this new database
|
||||
client.set_tenant(tenant=DEFAULT_TENANT, database="test_db")
|
||||
client.create_collection("collection", metadata={"database": "test_db"})
|
||||
|
||||
# Create collections in the default database
|
||||
client.set_tenant(tenant=DEFAULT_TENANT, database=DEFAULT_DATABASE)
|
||||
client.create_collection("collection", metadata={"database": DEFAULT_DATABASE})
|
||||
|
||||
# List collections in the default database
|
||||
collections = client.list_collections()
|
||||
assert len(collections) == 1
|
||||
assert collections[0].name == "collection"
|
||||
collection = client.get_collection(collections[0].name)
|
||||
assert collection.metadata == {"database": DEFAULT_DATABASE}
|
||||
|
||||
# List collections in the new database
|
||||
client.set_tenant(tenant=DEFAULT_TENANT, database="test_db")
|
||||
collections = client.list_collections()
|
||||
assert len(collections) == 1
|
||||
assert collections[0].metadata == {"database": "test_db"}
|
||||
|
||||
# Update the metadata in both databases to different values
|
||||
client.set_tenant(tenant=DEFAULT_TENANT, database=DEFAULT_DATABASE)
|
||||
client.list_collections()[0].modify(metadata={"database": "default2"})
|
||||
|
||||
client.set_tenant(tenant=DEFAULT_TENANT, database="test_db")
|
||||
client.list_collections()[0].modify(metadata={"database": "test_db2"})
|
||||
|
||||
# Validate that the metadata was updated
|
||||
client.set_tenant(tenant=DEFAULT_TENANT, database=DEFAULT_DATABASE)
|
||||
collections = client.list_collections()
|
||||
assert len(collections) == 1
|
||||
assert collections[0].metadata == {"database": "default2"}
|
||||
|
||||
client.set_tenant(tenant=DEFAULT_TENANT, database="test_db")
|
||||
collections = client.list_collections()
|
||||
assert len(collections) == 1
|
||||
assert collections[0].metadata == {"database": "test_db2"}
|
||||
|
||||
# Delete the collections and make sure databases are isolated
|
||||
client.set_tenant(tenant=DEFAULT_TENANT, database=DEFAULT_DATABASE)
|
||||
client.delete_collection("collection")
|
||||
|
||||
collections = client.list_collections()
|
||||
assert len(collections) == 0
|
||||
|
||||
client.set_tenant(tenant=DEFAULT_TENANT, database="test_db")
|
||||
collections = client.list_collections()
|
||||
assert len(collections) == 1
|
||||
|
||||
client.delete_collection("collection")
|
||||
collections = client.list_collections()
|
||||
assert len(collections) == 0
|
||||
|
||||
|
||||
def test_database_collections_add(client_factories: ClientFactories) -> None:
|
||||
client = client_factories.create_client_from_system()
|
||||
client.reset()
|
||||
|
||||
# Create a new database in the default tenant
|
||||
admin_client = client_factories.create_admin_client_from_system()
|
||||
admin_client.create_database("test_db")
|
||||
|
||||
# Create collections in this new database
|
||||
client.set_database(database="test_db")
|
||||
coll_new = client.create_collection("collection_new")
|
||||
|
||||
# Create collections in the default database
|
||||
client.set_database(database=DEFAULT_DATABASE)
|
||||
coll_default = client.create_collection("collection_default")
|
||||
|
||||
records_new = {
|
||||
"ids": ["a", "b", "c"],
|
||||
"embeddings": [[1.0, 2.0, 3.0] for _ in range(3)],
|
||||
"documents": ["a", "b", "c"],
|
||||
}
|
||||
|
||||
records_default = {
|
||||
"ids": ["c", "d", "e"],
|
||||
"embeddings": [[4.0, 5.0, 6.0] for _ in range(3)],
|
||||
"documents": ["c", "d", "e"],
|
||||
}
|
||||
|
||||
# Add to the new coll
|
||||
coll_new.add(**records_new) # type: ignore
|
||||
|
||||
# Add to the default coll
|
||||
coll_default.add(**records_default) # type: ignore
|
||||
|
||||
# Make sure the collections are isolated
|
||||
res = coll_new.get(include=["embeddings", "documents"]) # type: ignore
|
||||
assert res["ids"] == records_new["ids"]
|
||||
check_embeddings(res=res, records=records_new)
|
||||
assert res["documents"] == records_new["documents"]
|
||||
|
||||
res = coll_default.get(include=["embeddings", "documents"]) # type: ignore
|
||||
assert res["ids"] == records_default["ids"]
|
||||
check_embeddings(res=res, records=records_default)
|
||||
assert res["documents"] == records_default["documents"]
|
||||
|
||||
|
||||
def test_tenant_collections_add(client_factories: ClientFactories) -> None:
|
||||
client = client_factories.create_client_from_system()
|
||||
client.reset()
|
||||
|
||||
# Create two databases with same name in different tenants
|
||||
admin_client = client_factories.create_admin_client_from_system()
|
||||
admin_client.create_tenant("test_tenant1")
|
||||
admin_client.create_tenant("test_tenant2")
|
||||
admin_client.create_database("test_db", tenant="test_tenant1")
|
||||
admin_client.create_database("test_db", tenant="test_tenant2")
|
||||
|
||||
# Create collections in each database with same name
|
||||
client.set_tenant(tenant="test_tenant1", database="test_db")
|
||||
coll_tenant1 = client.create_collection("collection")
|
||||
client.set_tenant(tenant="test_tenant2", database="test_db")
|
||||
coll_tenant2 = client.create_collection("collection")
|
||||
|
||||
records_tenant1 = {
|
||||
"ids": ["a", "b", "c"],
|
||||
"embeddings": [[1.0, 2.0, 3.0] for _ in range(3)],
|
||||
"documents": ["a", "b", "c"],
|
||||
}
|
||||
|
||||
records_tenant2 = {
|
||||
"ids": ["c", "d", "e"],
|
||||
"embeddings": [[4.0, 5.0, 6.0] for _ in range(3)],
|
||||
"documents": ["c", "d", "e"],
|
||||
}
|
||||
|
||||
# Add to the tenant1 coll
|
||||
coll_tenant1.add(**records_tenant1) # type: ignore
|
||||
|
||||
# Add to the tenant2 coll
|
||||
coll_tenant2.add(**records_tenant2) # type: ignore
|
||||
|
||||
# Make sure the collections are isolated
|
||||
res = coll_tenant1.get(include=["embeddings", "documents"]) # type: ignore
|
||||
assert res["ids"] == records_tenant1["ids"]
|
||||
check_embeddings(res=res, records=records_tenant1)
|
||||
assert res["documents"] == records_tenant1["documents"]
|
||||
|
||||
res = coll_tenant2.get(include=["embeddings", "documents"]) # type: ignore
|
||||
assert res["ids"] == records_tenant2["ids"]
|
||||
check_embeddings(res=res, records=records_tenant2)
|
||||
assert res["documents"] == records_tenant2["documents"]
|
||||
|
||||
|
||||
def test_min_len_name(client_factories: ClientFactories) -> None:
|
||||
client = client_factories.create_client_from_system()
|
||||
client.reset()
|
||||
|
||||
# Create a new database in the default tenant with a name of length 1
|
||||
# and expect an error
|
||||
admin_client = client_factories.create_admin_client_from_system()
|
||||
with pytest.raises((Exception, InvalidArgumentError)):
|
||||
admin_client.create_database("a")
|
||||
|
||||
# Create a tenant with a name of length 1 and expect an error
|
||||
with pytest.raises((Exception, InvalidArgumentError)):
|
||||
admin_client.create_tenant("a")
|
||||
|
||||
|
||||
def check_embeddings(res: GetResult, records: Dict[str, Any]) -> None:
|
||||
if res["embeddings"] is not None:
|
||||
assert np.array_equal(res["embeddings"], records["embeddings"])
|
||||
else:
|
||||
assert records["embeddings"] is None
|
||||
@@ -0,0 +1,43 @@
|
||||
from typing import Dict
|
||||
from fastapi import HTTPException
|
||||
from overrides import override
|
||||
from chromadb.auth import (
|
||||
AuthzAction,
|
||||
AuthzResource,
|
||||
ServerAuthenticationProvider,
|
||||
ServerAuthorizationProvider,
|
||||
UserIdentity,
|
||||
)
|
||||
from chromadb.config import System
|
||||
|
||||
|
||||
class ExampleAuthenticationProvider(ServerAuthenticationProvider):
|
||||
"""In practice the tenant would likely be resolved from some other opaque value (e.g. key/token). Here, it's just passed directly as a header for simplicity."""
|
||||
|
||||
@override
|
||||
def authenticate_or_raise(self, headers: Dict[str, str]) -> UserIdentity:
|
||||
return UserIdentity(
|
||||
user_id="test",
|
||||
tenant=headers.get("x-tenant", None),
|
||||
)
|
||||
|
||||
|
||||
class ExampleAuthorizationProvider(ServerAuthorizationProvider):
|
||||
"""A simple authz provider that asserts the user's tenant matches the resource's tenant."""
|
||||
|
||||
def __init__(self, system: System) -> None:
|
||||
super().__init__(system)
|
||||
self._settings = system.settings
|
||||
|
||||
@override
|
||||
def authorize_or_raise(
|
||||
self, user: UserIdentity, action: AuthzAction, resource: AuthzResource
|
||||
) -> None:
|
||||
if user.tenant is None:
|
||||
return
|
||||
|
||||
if action == AuthzAction.RESET:
|
||||
return
|
||||
|
||||
if user.tenant != resource.tenant:
|
||||
raise HTTPException(status_code=403, detail="Unauthorized")
|
||||
@@ -0,0 +1,49 @@
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from chromadb.config import DEFAULT_TENANT
|
||||
from chromadb.test.conftest import ClientFactories
|
||||
|
||||
|
||||
def test_multiple_clients_concurrently(client_factories: ClientFactories) -> None:
|
||||
"""Tests running multiple clients, each against their own database, concurrently."""
|
||||
client = client_factories.create_client()
|
||||
client.reset()
|
||||
admin_client = client_factories.create_admin_client_from_system()
|
||||
admin_client.create_database("test_db")
|
||||
|
||||
CLIENT_COUNT = 50
|
||||
COLLECTION_COUNT = 10
|
||||
|
||||
# Each database will create the same collections by name, with differing metadata
|
||||
databases = [f"db{i}" for i in range(CLIENT_COUNT)]
|
||||
for database in databases:
|
||||
admin_client.create_database(database)
|
||||
|
||||
collections = [f"collection{i}" for i in range(COLLECTION_COUNT)]
|
||||
|
||||
# Create N clients, each on a seperate thread, each with their own database
|
||||
def run_target(n: int) -> None:
|
||||
thread_client = client_factories.create_client(
|
||||
tenant=DEFAULT_TENANT,
|
||||
database=databases[n],
|
||||
settings=client._system.settings,
|
||||
)
|
||||
for collection in collections:
|
||||
thread_client.create_collection(
|
||||
collection, metadata={"database": databases[n]}
|
||||
)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=CLIENT_COUNT) as executor:
|
||||
executor.map(run_target, range(CLIENT_COUNT))
|
||||
executor.shutdown(wait=True)
|
||||
# Create a final client, which will be used to verify the collections were created
|
||||
client = client_factories.create_client(settings=client._system.settings)
|
||||
|
||||
# Verify that the collections were created
|
||||
for database in databases:
|
||||
client.set_database(database)
|
||||
seen_collections = client.list_collections()
|
||||
assert len(seen_collections) == COLLECTION_COUNT
|
||||
for collection in seen_collections:
|
||||
assert collection.name in collections
|
||||
assert collection.metadata == {"database": database}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,110 @@
|
||||
from overrides import overrides
|
||||
import pytest
|
||||
from chromadb.api.configuration import (
|
||||
ConfigurationInternal,
|
||||
ConfigurationDefinition,
|
||||
InvalidConfigurationError,
|
||||
StaticParameterError,
|
||||
ConfigurationParameter,
|
||||
HNSWConfiguration,
|
||||
)
|
||||
|
||||
|
||||
class TestConfiguration(ConfigurationInternal):
|
||||
definitions = {
|
||||
"static_str_value": ConfigurationDefinition(
|
||||
name="static_str_value",
|
||||
validator=lambda value: isinstance(value, str),
|
||||
is_static=True,
|
||||
default_value="default",
|
||||
),
|
||||
"int_value": ConfigurationDefinition(
|
||||
name="int_value",
|
||||
validator=lambda value: isinstance(value, int),
|
||||
is_static=False,
|
||||
default_value=0,
|
||||
),
|
||||
}
|
||||
|
||||
@overrides
|
||||
def configuration_validator(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def test_default_values() -> None:
|
||||
default_test_configuration = TestConfiguration()
|
||||
assert default_test_configuration.get_parameter("static_str_value") is not None
|
||||
assert (
|
||||
default_test_configuration.get_parameter("static_str_value").value
|
||||
== TestConfiguration.definitions["static_str_value"].default_value
|
||||
)
|
||||
assert default_test_configuration.get_parameter("static_str_value") is not None
|
||||
assert (
|
||||
default_test_configuration.get_parameter("int_value").value
|
||||
== TestConfiguration.definitions["int_value"].default_value
|
||||
)
|
||||
|
||||
|
||||
def test_set_values() -> None:
|
||||
test_configuration = TestConfiguration()
|
||||
|
||||
with pytest.raises(StaticParameterError):
|
||||
test_configuration.set_parameter("static_str_value", "new_value")
|
||||
test_configuration.set_parameter("int_value", 1)
|
||||
assert test_configuration.get_parameter("int_value").value == 1
|
||||
|
||||
|
||||
def test_get_invalid_parameter() -> None:
|
||||
test_configuration = TestConfiguration()
|
||||
with pytest.raises(ValueError):
|
||||
test_configuration.get_parameter("invalid_name")
|
||||
|
||||
|
||||
def test_validation() -> None:
|
||||
valid_parameters = [
|
||||
ConfigurationParameter(name="static_str_value", value="valid_value"),
|
||||
ConfigurationParameter(name="int_value", value=1),
|
||||
]
|
||||
valid_test_configuration = TestConfiguration(parameters=valid_parameters)
|
||||
assert (
|
||||
valid_test_configuration.get_parameter("static_str_value").value
|
||||
== "valid_value"
|
||||
)
|
||||
assert valid_test_configuration.get_parameter("int_value").value == 1
|
||||
|
||||
invalid_parameter_values = [
|
||||
ConfigurationParameter(name="static_str_value", value=1.0)
|
||||
]
|
||||
with pytest.raises(ValueError):
|
||||
TestConfiguration(parameters=invalid_parameter_values)
|
||||
|
||||
invalid_parameter_names = [
|
||||
ConfigurationParameter(name="invalid_name", value="some_value")
|
||||
]
|
||||
with pytest.raises(ValueError):
|
||||
TestConfiguration(parameters=invalid_parameter_names)
|
||||
|
||||
|
||||
def test_configuration_validation() -> None:
|
||||
class FooConfiguration(ConfigurationInternal):
|
||||
definitions = {
|
||||
"foo": ConfigurationDefinition(
|
||||
name="foo",
|
||||
validator=lambda value: isinstance(value, str),
|
||||
is_static=False,
|
||||
default_value="default",
|
||||
),
|
||||
}
|
||||
|
||||
@overrides
|
||||
def configuration_validator(self) -> None:
|
||||
if self.parameter_map.get("foo") != "bar":
|
||||
raise InvalidConfigurationError("foo must be 'bar'")
|
||||
|
||||
with pytest.raises(ValueError, match="foo must be 'bar'"):
|
||||
FooConfiguration(parameters=[ConfigurationParameter(name="foo", value="baz")])
|
||||
|
||||
|
||||
def test_hnsw_validation() -> None:
|
||||
with pytest.raises(ValueError, match="must be less than or equal"):
|
||||
HNSWConfiguration(batch_size=500, sync_threshold=100)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,122 @@
|
||||
from typing import Dict, Generator, List, Optional, Sequence, Union
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
|
||||
import pytest
|
||||
import chromadb
|
||||
from chromadb.api.types import URI, DataLoader, Documents, IDs, Image, URIs
|
||||
from chromadb.api import ClientAPI
|
||||
from chromadb.test.conftest import reset
|
||||
from chromadb.test.ef.test_multimodal_ef import hashing_multimodal_ef
|
||||
|
||||
|
||||
def encode_data(data: str) -> NDArray[np.uint8]:
|
||||
return np.array(data.encode())
|
||||
|
||||
|
||||
class DefaultDataLoader(DataLoader[List[Optional[Image]]]):
|
||||
def __call__(self, uris: Sequence[Optional[URI]]) -> List[Optional[Image]]:
|
||||
# Convert each URI to a numpy array
|
||||
return [None if uri is None else encode_data(uri) for uri in uris]
|
||||
|
||||
|
||||
def record_set_with_uris(n: int = 3) -> Dict[str, Union[IDs, Documents, URIs]]:
|
||||
return {
|
||||
"ids": [f"{i}" for i in range(n)],
|
||||
"documents": [f"document_{i}" for i in range(n)],
|
||||
"uris": [f"uri_{i}" for i in range(n)],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def collection_with_data_loader(
|
||||
client: ClientAPI,
|
||||
) -> Generator[chromadb.Collection, None, None]:
|
||||
reset(client)
|
||||
collection = client.create_collection(
|
||||
name="collection_with_data_loader",
|
||||
data_loader=DefaultDataLoader(),
|
||||
embedding_function=hashing_multimodal_ef(),
|
||||
)
|
||||
yield collection
|
||||
client.delete_collection(collection.name)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def collection_without_data_loader(
|
||||
client: ClientAPI,
|
||||
) -> Generator[chromadb.Collection, None, None]:
|
||||
reset(client)
|
||||
collection = client.create_collection(
|
||||
name="collection_without_data_loader",
|
||||
embedding_function=hashing_multimodal_ef(),
|
||||
)
|
||||
yield collection
|
||||
client.delete_collection(collection.name)
|
||||
|
||||
|
||||
def test_without_data_loader(
|
||||
collection_without_data_loader: chromadb.Collection,
|
||||
n_examples: int = 3,
|
||||
) -> None:
|
||||
record_set = record_set_with_uris(n=n_examples)
|
||||
|
||||
# Can't embed data in URIs without a data loader
|
||||
with pytest.raises(ValueError):
|
||||
collection_without_data_loader.add(
|
||||
ids=record_set["ids"],
|
||||
uris=record_set["uris"],
|
||||
)
|
||||
|
||||
# Can't get data from URIs without a data loader
|
||||
with pytest.raises(ValueError):
|
||||
collection_without_data_loader.get(include=["data"])
|
||||
|
||||
|
||||
def test_without_uris(
|
||||
collection_with_data_loader: chromadb.Collection, n_examples: int = 3
|
||||
) -> None:
|
||||
record_set = record_set_with_uris(n=n_examples)
|
||||
|
||||
collection_with_data_loader.add(
|
||||
ids=record_set["ids"],
|
||||
documents=record_set["documents"],
|
||||
)
|
||||
|
||||
get_result = collection_with_data_loader.get(include=["data"])
|
||||
|
||||
assert get_result["data"] is not None
|
||||
for data in get_result["data"]:
|
||||
assert data is None
|
||||
|
||||
|
||||
def test_data_loader(
|
||||
collection_with_data_loader: chromadb.Collection, n_examples: int = 3
|
||||
) -> None:
|
||||
record_set = record_set_with_uris(n=n_examples)
|
||||
|
||||
collection_with_data_loader.add(
|
||||
ids=record_set["ids"],
|
||||
uris=record_set["uris"],
|
||||
)
|
||||
|
||||
# Get with "data"
|
||||
get_result = collection_with_data_loader.get(include=["data"])
|
||||
|
||||
assert get_result["data"] is not None
|
||||
for i, data in enumerate(get_result["data"]):
|
||||
assert data is not None
|
||||
assert data == encode_data(record_set["uris"][i])
|
||||
|
||||
# Query by URI
|
||||
query_result = collection_with_data_loader.query(
|
||||
query_uris=record_set["uris"],
|
||||
n_results=len(record_set["uris"][0]),
|
||||
include=["data", "uris"],
|
||||
)
|
||||
|
||||
assert query_result["data"] is not None
|
||||
for i, data in enumerate(query_result["data"][0]):
|
||||
assert data is not None
|
||||
assert query_result["uris"] is not None
|
||||
assert data == encode_data(query_result["uris"][0][i])
|
||||
@@ -0,0 +1,50 @@
|
||||
from chromadb.api.client import Client
|
||||
from chromadb.config import System
|
||||
from chromadb.test.property import invariants
|
||||
|
||||
|
||||
def test_log_purge(sqlite_persistent: System) -> None:
|
||||
client = Client.from_system(sqlite_persistent)
|
||||
|
||||
first_collection = client.create_collection(
|
||||
"first_collection", metadata={"hnsw:sync_threshold": 10, "hnsw:batch_size": 10}
|
||||
)
|
||||
second_collection = client.create_collection(
|
||||
"second_collection", metadata={"hnsw:sync_threshold": 10, "hnsw:batch_size": 10}
|
||||
)
|
||||
collections = [first_collection, second_collection]
|
||||
|
||||
# (Does not trigger a purge)
|
||||
for i in range(5):
|
||||
first_collection.add(ids=str(i), embeddings=[i, i])
|
||||
|
||||
# (Should trigger a purge)
|
||||
for i in range(100):
|
||||
second_collection.add(ids=str(i), embeddings=[i, i])
|
||||
|
||||
# The purge of the second collection should not be blocked by the first
|
||||
invariants.log_size_below_max(client._system, collections, True)
|
||||
|
||||
|
||||
def test_log_purge_with_multiple_collections(sqlite_persistent: System) -> None:
|
||||
client = Client.from_system(sqlite_persistent)
|
||||
|
||||
first_collection = client.create_collection(
|
||||
"first_collection", metadata={"hnsw:sync_threshold": 10, "hnsw:batch_size": 10}
|
||||
)
|
||||
second_collection = client.create_collection(
|
||||
"second_collection", metadata={"hnsw:sync_threshold": 10, "hnsw:batch_size": 10}
|
||||
)
|
||||
collections = [first_collection, second_collection]
|
||||
|
||||
# (Does not trigger a purge)
|
||||
for i in range(15):
|
||||
first_collection.add(ids=str(i), embeddings=[i, i])
|
||||
|
||||
# (Should trigger a purge)
|
||||
for i in range(25):
|
||||
second_collection.add(ids=str(i), embeddings=[i, i])
|
||||
|
||||
invariants.log_size_for_collections_match_expected(
|
||||
client._system, collections, True
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
# This folder holds basic sanity checks for the distributed version of chromadb
|
||||
# while it is in development. In the future, it may hold more extensive tests
|
||||
# in tandem with the main test suite, targeted at the distributed version.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Add up to 200k records until the log-is-full message is seen.
|
||||
|
||||
import grpc
|
||||
import math
|
||||
import random
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
from chromadb.api import ClientAPI
|
||||
from chromadb.proto.logservice_pb2 import SealLogRequest, MigrateLogRequest
|
||||
from chromadb.proto.logservice_pb2_grpc import LogServiceStub
|
||||
from chromadb.test.conftest import (
|
||||
reset,
|
||||
skip_if_not_cluster,
|
||||
)
|
||||
from chromadb.test.property import invariants
|
||||
from chromadb.test.utils.wait_for_version_increase import wait_for_version_increase
|
||||
|
||||
RECORDS = 2000000
|
||||
BATCH_SIZE = 100
|
||||
|
||||
@skip_if_not_cluster()
|
||||
def test_log_backpressure(
|
||||
client: ClientAPI,
|
||||
) -> None:
|
||||
seed = time.time()
|
||||
random.seed(seed)
|
||||
print("Generating data with seed ", seed)
|
||||
reset(client)
|
||||
collection = client.create_collection(
|
||||
name="test",
|
||||
metadata={"hnsw:construction_ef": 128, "hnsw:search_ef": 128, "hnsw:M": 128},
|
||||
)
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
print('backpressuring for', collection.id)
|
||||
|
||||
excepted = False
|
||||
# Add RECORDS records, where each embedding has 3 dimensions randomly generated between 0 and 1
|
||||
for i in range(0, RECORDS, BATCH_SIZE):
|
||||
ids = []
|
||||
embeddings = []
|
||||
ids.extend([str(x) for x in range(i, i + BATCH_SIZE)])
|
||||
embeddings.extend([np.random.rand(1, 3)[0] for x in range(i, i + BATCH_SIZE)])
|
||||
try:
|
||||
collection.add(ids=ids, embeddings=embeddings)
|
||||
except Exception as x:
|
||||
print(f"Caught exception:\n{x}")
|
||||
if 'Backoff and retry' in str(x):
|
||||
excepted = True
|
||||
break
|
||||
assert excepted, "Expected an exception to be thrown."
|
||||
@@ -0,0 +1,73 @@
|
||||
# Add some records, wait for compaction, then roll back the log offset.
|
||||
# Poll the log for up to 30s to see if the offset gets repaired.
|
||||
|
||||
import grpc
|
||||
import random
|
||||
import time
|
||||
from typing import cast, List, Any, Dict
|
||||
|
||||
import numpy as np
|
||||
|
||||
from chromadb.api import ClientAPI
|
||||
from chromadb.proto.logservice_pb2 import InspectLogStateRequest, UpdateCollectionLogOffsetRequest
|
||||
from chromadb.proto.logservice_pb2_grpc import LogServiceStub
|
||||
from chromadb.test.conftest import (
|
||||
reset,
|
||||
skip_if_not_cluster,
|
||||
)
|
||||
from chromadb.test.utils.wait_for_version_increase import wait_for_version_increase
|
||||
|
||||
RECORDS = 1000
|
||||
BATCH_SIZE = 100
|
||||
|
||||
@skip_if_not_cluster()
|
||||
def test_repair_collection_log_offset(
|
||||
client: ClientAPI,
|
||||
) -> None:
|
||||
seed = time.time()
|
||||
random.seed(seed)
|
||||
print("Generating data with seed ", seed)
|
||||
reset(client)
|
||||
|
||||
channel = grpc.insecure_channel('localhost:50054')
|
||||
log_service_stub = LogServiceStub(channel)
|
||||
|
||||
collection = client.create_collection(
|
||||
name="test_repair_collection_log_offset",
|
||||
metadata={"hnsw:construction_ef": 128, "hnsw:search_ef": 128, "hnsw:M": 128},
|
||||
)
|
||||
print("collection_id =", collection.id)
|
||||
|
||||
initial_version = cast(int, collection.get_model()["version"])
|
||||
|
||||
# Add RECORDS records, where each embedding has 3 dimensions randomly generated between 0 and 1
|
||||
for i in range(0, RECORDS, BATCH_SIZE):
|
||||
ids = []
|
||||
embeddings = []
|
||||
ids.extend([str(x) for x in range(i, i + BATCH_SIZE)])
|
||||
embeddings.extend([np.random.rand(1, 3)[0] for x in range(i, i + BATCH_SIZE)])
|
||||
collection.add(ids=ids, embeddings=embeddings)
|
||||
|
||||
wait_for_version_increase(client, collection.name, initial_version)
|
||||
|
||||
found = False
|
||||
now = time.time()
|
||||
while time.time() - now < 240:
|
||||
request = InspectLogStateRequest(collection_id=str(collection.id))
|
||||
response = log_service_stub.InspectLogState(request, timeout=60)
|
||||
if '''LogPosition { offset: 1001 }''' in response.debug:
|
||||
found = True
|
||||
break
|
||||
assert found
|
||||
|
||||
request = UpdateCollectionLogOffsetRequest (collection_id=str(collection.id), log_offset=1)
|
||||
response = log_service_stub.RollbackCollectionLogOffset(request, timeout=60)
|
||||
|
||||
now = time.time()
|
||||
while time.time() - now < 240:
|
||||
request = InspectLogStateRequest(collection_id=str(collection.id))
|
||||
response = log_service_stub.InspectLogState(request, timeout=60)
|
||||
if '''LogPosition { offset: 1001 }''' in response.debug:
|
||||
return
|
||||
time.sleep(1)
|
||||
raise RuntimeError("Test timed out without repair")
|
||||
@@ -0,0 +1,74 @@
|
||||
from typing import Sequence
|
||||
from chromadb.test.conftest import (
|
||||
reset,
|
||||
skip_if_not_cluster,
|
||||
)
|
||||
from chromadb.api import ClientAPI
|
||||
from kubernetes import client as k8s_client, config
|
||||
import time
|
||||
|
||||
|
||||
@skip_if_not_cluster()
|
||||
def test_reroute(
|
||||
client: ClientAPI,
|
||||
) -> None:
|
||||
reset(client)
|
||||
collection = client.create_collection(
|
||||
name="test",
|
||||
metadata={"hnsw:construction_ef": 128, "hnsw:search_ef": 128, "hnsw:M": 128},
|
||||
)
|
||||
|
||||
ids = [str(i) for i in range(10)]
|
||||
embeddings: list[Sequence[float]] = [
|
||||
[float(i), float(i), float(i)] for i in range(10)
|
||||
]
|
||||
collection.add(ids=ids, embeddings=embeddings)
|
||||
collection.query(query_embeddings=[embeddings[0]])
|
||||
|
||||
# Restart the query service using k8s api, in order to trigger a reroute
|
||||
# of the query service
|
||||
config.load_kube_config()
|
||||
v1 = k8s_client.CoreV1Api()
|
||||
# Find all pods with the label "app=query"
|
||||
res = v1.list_namespaced_pod("chroma", label_selector="app=query-service")
|
||||
assert len(res.items) > 0
|
||||
items = res.items
|
||||
seen_ids = set()
|
||||
|
||||
# Restart all the pods by deleting them
|
||||
for item in items:
|
||||
seen_ids.add(item.metadata.uid)
|
||||
name = item.metadata.name
|
||||
namespace = item.metadata.namespace
|
||||
v1.delete_namespaced_pod(name, namespace)
|
||||
|
||||
# Wait until we have len(seen_ids) pods running with new UIDs
|
||||
timeout_secs = 10
|
||||
start_time = time.time()
|
||||
while True:
|
||||
res = v1.list_namespaced_pod("chroma", label_selector="app=query-service")
|
||||
items = res.items
|
||||
new_ids = set([item.metadata.uid for item in items])
|
||||
if len(new_ids) == len(seen_ids) and len(new_ids.intersection(seen_ids)) == 0:
|
||||
break
|
||||
if time.time() - start_time > timeout_secs:
|
||||
assert False, "Timed out waiting for new pods to start"
|
||||
time.sleep(1)
|
||||
|
||||
# Wait for the query service to be ready, or timeout
|
||||
while True:
|
||||
res = v1.list_namespaced_pod("chroma", label_selector="app=query-service")
|
||||
items = res.items
|
||||
ready = True
|
||||
for item in items:
|
||||
if item.status.phase != "Running":
|
||||
ready = False
|
||||
break
|
||||
if ready:
|
||||
break
|
||||
if time.time() - start_time > timeout_secs:
|
||||
assert False, "Timed out waiting for new pods to be ready"
|
||||
time.sleep(1)
|
||||
|
||||
time.sleep(1)
|
||||
collection.query(query_embeddings=[embeddings[0]])
|
||||
@@ -0,0 +1,102 @@
|
||||
# This tests a very minimal of test_add in test_add.py as a example based test
|
||||
# instead of a property based test. We can use the delta to get the property
|
||||
# test working and then enable
|
||||
import random
|
||||
import time
|
||||
from chromadb.api import ClientAPI
|
||||
from chromadb.test.conftest import (
|
||||
reset,
|
||||
skip_if_not_cluster,
|
||||
)
|
||||
from chromadb.test.property import invariants
|
||||
from chromadb.test.utils.wait_for_version_increase import (
|
||||
wait_for_version_increase,
|
||||
get_collection_version,
|
||||
)
|
||||
import numpy as np
|
||||
|
||||
|
||||
@skip_if_not_cluster()
|
||||
def test_add(
|
||||
client: ClientAPI,
|
||||
) -> None:
|
||||
seed = time.time()
|
||||
random.seed(seed)
|
||||
print("Generating data with seed ", seed)
|
||||
reset(client)
|
||||
collection = client.create_collection(
|
||||
name="test",
|
||||
metadata={"hnsw:construction_ef": 128, "hnsw:search_ef": 128, "hnsw:M": 128},
|
||||
)
|
||||
|
||||
# Add 1000 records, where each embedding has 3 dimensions randomly generated
|
||||
# between 0 and 1
|
||||
ids = []
|
||||
embeddings = []
|
||||
for i in range(1000):
|
||||
ids.append(str(i))
|
||||
embeddings.append(np.random.rand(1, 3)[0])
|
||||
collection.add(
|
||||
ids=[str(i)],
|
||||
embeddings=[embeddings[-1]],
|
||||
)
|
||||
|
||||
random_query = np.random.rand(1, 3)[0]
|
||||
print("Generated data with seed ", seed)
|
||||
|
||||
invariants.ann_accuracy(
|
||||
collection,
|
||||
{
|
||||
"ids": ids,
|
||||
"embeddings": embeddings,
|
||||
"metadatas": None,
|
||||
"documents": None,
|
||||
},
|
||||
10,
|
||||
query_embeddings=[random_query],
|
||||
)
|
||||
|
||||
|
||||
@skip_if_not_cluster()
|
||||
def test_add_include_all_with_compaction_delay(client: ClientAPI) -> None:
|
||||
seed = time.time()
|
||||
random.seed(seed)
|
||||
print("Generating data with seed ", seed)
|
||||
reset(client)
|
||||
collection = client.create_collection(
|
||||
name="test_add_include_all_with_compaction_delay",
|
||||
metadata={"hnsw:construction_ef": 128, "hnsw:search_ef": 128, "hnsw:M": 128},
|
||||
)
|
||||
initial_version = get_collection_version(client, collection.name)
|
||||
|
||||
ids = []
|
||||
embeddings = []
|
||||
documents = []
|
||||
for i in range(1000):
|
||||
ids.append(str(i))
|
||||
embeddings.append(np.random.rand(1, 3)[0])
|
||||
documents.append(f"document_{i}")
|
||||
collection.add(
|
||||
ids=[str(i)],
|
||||
embeddings=[embeddings[-1]],
|
||||
documents=[documents[-1]],
|
||||
)
|
||||
|
||||
wait_for_version_increase(client, collection.name, initial_version, 120)
|
||||
|
||||
random_query_1 = np.random.rand(1, 3)[0]
|
||||
random_query_2 = np.random.rand(1, 3)[0]
|
||||
print("Generated data with seed ", seed)
|
||||
|
||||
# Query the collection with a random query
|
||||
invariants.ann_accuracy(
|
||||
collection,
|
||||
{
|
||||
"ids": ids,
|
||||
"embeddings": embeddings,
|
||||
"metadatas": None,
|
||||
"documents": documents,
|
||||
},
|
||||
10,
|
||||
query_embeddings=[random_query_1, random_query_2],
|
||||
)
|
||||
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
Integration test for Chroma's Task API
|
||||
|
||||
Tests the task creation, execution, and removal functionality
|
||||
for automatically processing collections.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from chromadb.api.client import Client as ClientCreator
|
||||
from chromadb.config import System
|
||||
from chromadb.errors import ChromaError, NotFoundError
|
||||
|
||||
|
||||
def test_function_attach_and_detach(basic_http_client: System) -> None:
|
||||
"""Test creating and removing a function with the record_counter operator"""
|
||||
client = ClientCreator.from_system(basic_http_client)
|
||||
client.reset()
|
||||
|
||||
# Create a collection
|
||||
collection = client.get_or_create_collection(
|
||||
name="my_document",
|
||||
metadata={"description": "Sample documents for task processing"},
|
||||
)
|
||||
|
||||
# Add initial documents
|
||||
collection.add(
|
||||
ids=["doc1", "doc2", "doc3"],
|
||||
documents=[
|
||||
"The quick brown fox jumps over the lazy dog",
|
||||
"Machine learning is a subset of artificial intelligence",
|
||||
"Python is a popular programming language",
|
||||
],
|
||||
metadatas=[{"source": "proverb"}, {"source": "tech"}, {"source": "tech"}],
|
||||
)
|
||||
|
||||
# Verify collection has documents
|
||||
assert collection.count() == 3
|
||||
|
||||
# Create a task that counts records in the collection
|
||||
attached_fn = collection.attach_function(
|
||||
name="count_my_docs",
|
||||
function_id="record_counter", # Built-in operator that counts records
|
||||
output_collection="my_documents_counts",
|
||||
params=None,
|
||||
)
|
||||
|
||||
# Verify task creation succeeded
|
||||
assert attached_fn is not None
|
||||
|
||||
# Add more documents
|
||||
collection.add(
|
||||
ids=["doc4", "doc5"],
|
||||
documents=[
|
||||
"Chroma is a vector database",
|
||||
"Tasks automate data processing",
|
||||
],
|
||||
)
|
||||
|
||||
# Verify documents were added
|
||||
assert collection.count() == 5
|
||||
|
||||
# Remove the task
|
||||
success = attached_fn.detach(
|
||||
delete_output_collection=True,
|
||||
)
|
||||
|
||||
# Verify task removal succeeded
|
||||
assert success is True
|
||||
|
||||
|
||||
def test_task_with_invalid_function(basic_http_client: System) -> None:
|
||||
"""Test that creating a task with an invalid function raises an error"""
|
||||
client = ClientCreator.from_system(basic_http_client)
|
||||
client.reset()
|
||||
|
||||
collection = client.get_or_create_collection(name="test_invalid_function")
|
||||
collection.add(ids=["id1"], documents=["test document"])
|
||||
|
||||
# Attempt to create task with non-existent function should raise ChromaError
|
||||
with pytest.raises(ChromaError, match="function not found"):
|
||||
collection.attach_function(
|
||||
name="invalid_task",
|
||||
function_id="nonexistent_function",
|
||||
output_collection="output_collection",
|
||||
params=None,
|
||||
)
|
||||
|
||||
|
||||
def test_function_multiple_collections(basic_http_client: System) -> None:
|
||||
"""Test attaching functions on multiple collections"""
|
||||
client = ClientCreator.from_system(basic_http_client)
|
||||
client.reset()
|
||||
|
||||
# Create first collection and task
|
||||
collection1 = client.create_collection(name="collection_1")
|
||||
collection1.add(ids=["id1", "id2"], documents=["doc1", "doc2"])
|
||||
|
||||
attached_fn1 = collection1.attach_function(
|
||||
name="task_1",
|
||||
function_id="record_counter",
|
||||
output_collection="output_1",
|
||||
params=None,
|
||||
)
|
||||
|
||||
assert attached_fn1 is not None
|
||||
|
||||
# Create second collection and task
|
||||
collection2 = client.create_collection(name="collection_2")
|
||||
collection2.add(ids=["id3", "id4"], documents=["doc3", "doc4"])
|
||||
|
||||
attached_fn2 = collection2.attach_function(
|
||||
name="task_2",
|
||||
function_id="record_counter",
|
||||
output_collection="output_2",
|
||||
params=None,
|
||||
)
|
||||
|
||||
assert attached_fn2 is not None
|
||||
|
||||
# Task IDs should be different
|
||||
assert attached_fn1.id != attached_fn2.id
|
||||
|
||||
# Clean up
|
||||
assert attached_fn1.detach(delete_output_collection=True) is True
|
||||
assert attached_fn2.detach(delete_output_collection=True) is True
|
||||
|
||||
|
||||
def test_functions_multiple_attached_functions(basic_http_client: System) -> None:
|
||||
"""Test attaching multiple functions on the same collection"""
|
||||
client = ClientCreator.from_system(basic_http_client)
|
||||
client.reset()
|
||||
|
||||
# Create a single collection
|
||||
collection = client.create_collection(name="multi_task_collection")
|
||||
collection.add(ids=["id1", "id2", "id3"], documents=["doc1", "doc2", "doc3"])
|
||||
|
||||
# Create first task on the collection
|
||||
attached_fn1 = collection.attach_function(
|
||||
name="task_1",
|
||||
function_id="record_counter",
|
||||
output_collection="output_1",
|
||||
params=None,
|
||||
)
|
||||
|
||||
assert attached_fn1 is not None
|
||||
|
||||
# Create second task on the SAME collection with a different name
|
||||
attached_fn2 = collection.attach_function(
|
||||
name="task_2",
|
||||
function_id="record_counter",
|
||||
output_collection="output_2",
|
||||
params=None,
|
||||
)
|
||||
|
||||
assert attached_fn2 is not None
|
||||
|
||||
# Task IDs should be different even though they're on the same collection
|
||||
assert attached_fn1.id != attached_fn2.id
|
||||
|
||||
# Create third task on the same collection
|
||||
attached_fn3 = collection.attach_function(
|
||||
name="task_3",
|
||||
function_id="record_counter",
|
||||
output_collection="output_3",
|
||||
params=None,
|
||||
)
|
||||
|
||||
assert attached_fn3 is not None
|
||||
assert attached_fn3.id != attached_fn1.id
|
||||
assert attached_fn3.id != attached_fn2.id
|
||||
|
||||
# Attempt to create a task with duplicate name on same collection should fail
|
||||
with pytest.raises(ChromaError, match="already exists"):
|
||||
collection.attach_function(
|
||||
name="task_1", # Duplicate name
|
||||
function_id="record_counter",
|
||||
output_collection="output_duplicate",
|
||||
params=None,
|
||||
)
|
||||
|
||||
# Clean up - remove each task individually
|
||||
assert attached_fn1.detach(delete_output_collection=True) is True
|
||||
assert attached_fn2.detach(delete_output_collection=True) is True
|
||||
assert attached_fn3.detach(delete_output_collection=True) is True
|
||||
|
||||
|
||||
def test_function_remove_nonexistent(basic_http_client: System) -> None:
|
||||
"""Test removing a task that doesn't exist raises NotFoundError"""
|
||||
client = ClientCreator.from_system(basic_http_client)
|
||||
client.reset()
|
||||
|
||||
collection = client.create_collection(name="test_collection")
|
||||
collection.add(ids=["id1"], documents=["test"])
|
||||
attached_fn = collection.attach_function(
|
||||
name="test_function",
|
||||
function_id="record_counter",
|
||||
output_collection="output_collection",
|
||||
params=None,
|
||||
)
|
||||
|
||||
attached_fn.detach(delete_output_collection=True)
|
||||
|
||||
# Trying to detach this function again should raise NotFoundError
|
||||
with pytest.raises(NotFoundError, match="does not exist"):
|
||||
attached_fn.detach(delete_output_collection=True)
|
||||
@@ -0,0 +1,139 @@
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from chromadb.utils.embedding_functions.chroma_bm25_embedding_function import (
|
||||
DEFAULT_CHROMA_BM25_STOPWORDS,
|
||||
ChromaBm25EmbeddingFunction,
|
||||
)
|
||||
|
||||
|
||||
def _is_sorted(values: list[int]) -> bool:
|
||||
return all(values[i] >= values[i - 1] for i in range(1, len(values)))
|
||||
|
||||
|
||||
def test_comprehensive_tokenization_matches_reference() -> None:
|
||||
embedder = ChromaBm25EmbeddingFunction()
|
||||
embedding = embedder(
|
||||
[
|
||||
"Usain Bolt's top speed reached ~27.8 mph (44.72 km/h)",
|
||||
]
|
||||
)[0]
|
||||
|
||||
expected_indices = [
|
||||
230246813,
|
||||
395514983,
|
||||
458027949,
|
||||
488165615,
|
||||
729632045,
|
||||
734978415,
|
||||
997512866,
|
||||
1114505193,
|
||||
1381820790,
|
||||
1501587190,
|
||||
1649421877,
|
||||
1837285388,
|
||||
]
|
||||
expected_value = 1.6391153
|
||||
|
||||
assert embedding.indices == expected_indices
|
||||
for value in embedding.values:
|
||||
assert value == pytest.approx(expected_value, abs=1e-5)
|
||||
|
||||
|
||||
def test_matches_rust_reference_values() -> None:
|
||||
embedder = ChromaBm25EmbeddingFunction()
|
||||
embedding = embedder(
|
||||
[
|
||||
"The space-time continuum WARPS near massive objects...",
|
||||
]
|
||||
)[0]
|
||||
|
||||
expected_indices = [
|
||||
90097469,
|
||||
519064992,
|
||||
737893654,
|
||||
1110755108,
|
||||
1950894484,
|
||||
2031641008,
|
||||
2058513491,
|
||||
]
|
||||
expected_value = 1.660867
|
||||
|
||||
assert embedding.indices == expected_indices
|
||||
for value in embedding.values:
|
||||
assert value == pytest.approx(expected_value, abs=1e-5)
|
||||
|
||||
|
||||
def test_generates_embeddings_for_multiple_documents() -> None:
|
||||
embedder = ChromaBm25EmbeddingFunction()
|
||||
texts = [
|
||||
"Usain Bolt's top speed reached ~27.8 mph (44.72 km/h)",
|
||||
"The space-time continuum WARPS near massive objects...",
|
||||
"BM25 is great for sparse retrieval tasks",
|
||||
]
|
||||
|
||||
embeddings = embedder(texts)
|
||||
|
||||
assert len(embeddings) == len(texts)
|
||||
for embedding in embeddings:
|
||||
assert embedding.indices
|
||||
assert len(embedding.indices) == len(embedding.values)
|
||||
assert _is_sorted(embedding.indices)
|
||||
for value in embedding.values:
|
||||
assert value > 0
|
||||
assert math.isfinite(value)
|
||||
|
||||
|
||||
def test_embed_query_matches_call() -> None:
|
||||
embedder = ChromaBm25EmbeddingFunction()
|
||||
query = "retrieve BM25 docs"
|
||||
|
||||
query_embedding = embedder.embed_query([query])[0]
|
||||
doc_embedding = embedder([query])[0]
|
||||
|
||||
assert query_embedding.indices == doc_embedding.indices
|
||||
assert query_embedding.values == doc_embedding.values
|
||||
|
||||
|
||||
def test_config_round_trip() -> None:
|
||||
embedder = ChromaBm25EmbeddingFunction()
|
||||
config = embedder.get_config()
|
||||
|
||||
assert config["k"] == pytest.approx(1.2, abs=1e-9)
|
||||
assert config["b"] == pytest.approx(0.75, abs=1e-9)
|
||||
assert config["avg_doc_length"] == pytest.approx(256.0, abs=1e-9)
|
||||
assert config["token_max_length"] == 40
|
||||
assert "stopwords" not in config
|
||||
|
||||
custom_stopwords = DEFAULT_CHROMA_BM25_STOPWORDS[:10]
|
||||
rebuilt = ChromaBm25EmbeddingFunction.build_from_config(
|
||||
{
|
||||
**config,
|
||||
"stopwords": custom_stopwords,
|
||||
}
|
||||
)
|
||||
|
||||
rebuilt_config = rebuilt.get_config()
|
||||
assert rebuilt_config["stopwords"] == custom_stopwords
|
||||
assert rebuilt_config["token_max_length"] == config["token_max_length"]
|
||||
assert rebuilt_config["k"] == pytest.approx(config["k"], abs=1e-9)
|
||||
assert rebuilt_config["b"] == pytest.approx(config["b"], abs=1e-9)
|
||||
assert rebuilt_config["avg_doc_length"] == pytest.approx(
|
||||
config["avg_doc_length"], abs=1e-9
|
||||
)
|
||||
|
||||
|
||||
def test_validate_config_update_rejects_unknown_keys() -> None:
|
||||
embedder = ChromaBm25EmbeddingFunction()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
embedder.validate_config_update(embedder.get_config(), {"unknown": 123})
|
||||
|
||||
|
||||
def test_validate_config_update_allows_known_keys() -> None:
|
||||
embedder = ChromaBm25EmbeddingFunction()
|
||||
|
||||
embedder.validate_config_update(
|
||||
embedder.get_config(), {"k": 1.1, "stopwords": ["custom"]}
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
from chromadb.api.types import EmbeddingFunction, Embeddable, Embeddings
|
||||
import numpy as np
|
||||
from typing import cast, Any
|
||||
from chromadb.utils.embedding_functions import (
|
||||
register_embedding_function,
|
||||
known_embedding_functions,
|
||||
)
|
||||
|
||||
|
||||
class LegacyCustomEmbeddingFunction(EmbeddingFunction[Embeddable]):
|
||||
def __call__(self, input: Embeddable) -> Embeddings:
|
||||
return cast(Embeddings, np.array([1, 2, 3]).tolist())
|
||||
|
||||
|
||||
class CustomEmbeddingFunction(EmbeddingFunction[Embeddable]):
|
||||
def __call__(self, input: Embeddable) -> Embeddings:
|
||||
return cast(Embeddings, np.array([1, 2, 3]).tolist())
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def name() -> str:
|
||||
return "custom_embedding_function"
|
||||
|
||||
@staticmethod
|
||||
def build_from_config(config: dict[str, Any]) -> "CustomEmbeddingFunction":
|
||||
return CustomEmbeddingFunction()
|
||||
|
||||
def get_config(self) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
@register_embedding_function
|
||||
class CustomEmbeddingFunctionWithRegistration(EmbeddingFunction[Embeddable]):
|
||||
def __call__(self, input: Embeddable) -> Embeddings:
|
||||
return cast(Embeddings, np.array([1, 2, 3]).tolist())
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def name() -> str:
|
||||
return "custom_embedding_function_with_registration"
|
||||
|
||||
@staticmethod
|
||||
def build_from_config(
|
||||
config: dict[str, Any]
|
||||
) -> "CustomEmbeddingFunctionWithRegistration":
|
||||
return CustomEmbeddingFunctionWithRegistration()
|
||||
|
||||
def get_config(self) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
def test_legacy_custom_ef() -> None:
|
||||
ef = LegacyCustomEmbeddingFunction()
|
||||
result = ef(["test"])
|
||||
|
||||
# Check the structure: we expect a list with one NumPy array
|
||||
assert isinstance(result, list), "Result should be a list"
|
||||
assert len(result) == 1, "Result should contain exactly one element"
|
||||
assert isinstance(result[0], np.ndarray), "Result element should be a NumPy array"
|
||||
|
||||
# Compare the contents of the array
|
||||
expected = np.array([1, 2, 3], dtype=np.float32)
|
||||
assert np.array_equal(
|
||||
result[0], expected
|
||||
), f"Arrays not equal: {result[0]} vs {expected}"
|
||||
|
||||
|
||||
def test_custom_ef() -> None:
|
||||
ef = CustomEmbeddingFunction()
|
||||
result = ef(["test"])
|
||||
|
||||
# Same checks as above
|
||||
assert isinstance(result, list), "Result should be a list"
|
||||
assert len(result) == 1, "Result should contain exactly one element"
|
||||
assert isinstance(result[0], np.ndarray), "Result element should be a NumPy array"
|
||||
|
||||
expected = np.array([1, 2, 3], dtype=np.float32)
|
||||
assert np.array_equal(
|
||||
result[0], expected
|
||||
), f"Arrays not equal: {result[0]} vs {expected}"
|
||||
|
||||
|
||||
def test_custom_ef_registration() -> None:
|
||||
# check all 4 embedding functions for registration.
|
||||
# LegacyCustomEmbeddingFunction should not be in known_embedding_functions
|
||||
# CustomEmbeddingFunction should not be in known_embedding_functions
|
||||
# CustomEmbeddingFunctionWithRegistration should be in known_embedding_functions
|
||||
|
||||
assert "legacy_custom_embedding_function" not in known_embedding_functions
|
||||
assert "custom_embedding_function" not in known_embedding_functions
|
||||
assert "custom_embedding_function_with_registration" in known_embedding_functions
|
||||
@@ -0,0 +1,90 @@
|
||||
import shutil
|
||||
import os
|
||||
from typing import List, Hashable
|
||||
|
||||
import hypothesis.strategies as st
|
||||
import onnxruntime
|
||||
import pytest
|
||||
from hypothesis import given, settings
|
||||
|
||||
from chromadb.utils.embedding_functions.onnx_mini_lm_l6_v2 import (
|
||||
ONNXMiniLM_L6_V2,
|
||||
)
|
||||
|
||||
from chromadb.utils.embedding_functions.onnx_mini_lm_l6_v2 import _verify_sha256
|
||||
|
||||
|
||||
def unique_by(x: Hashable) -> Hashable:
|
||||
return x
|
||||
|
||||
|
||||
@settings(deadline=None)
|
||||
@given(
|
||||
providers=st.lists(
|
||||
st.sampled_from(onnxruntime.get_all_providers()).filter(
|
||||
lambda x: x not in onnxruntime.get_available_providers()
|
||||
),
|
||||
unique_by=unique_by,
|
||||
min_size=1,
|
||||
)
|
||||
)
|
||||
def test_unavailable_provider_multiple(providers: List[str]) -> None:
|
||||
with pytest.raises(ValueError) as e:
|
||||
ef = ONNXMiniLM_L6_V2(preferred_providers=providers)
|
||||
ef(["test"])
|
||||
assert "Preferred providers must be subset of available providers" in str(e.value)
|
||||
|
||||
|
||||
@given(
|
||||
providers=st.lists(
|
||||
st.sampled_from(onnxruntime.get_available_providers()),
|
||||
min_size=1,
|
||||
unique_by=unique_by,
|
||||
)
|
||||
)
|
||||
def test_available_provider(providers: List[str]) -> None:
|
||||
ef = ONNXMiniLM_L6_V2(preferred_providers=providers)
|
||||
ef(["test"])
|
||||
|
||||
|
||||
def test_warning_no_providers_supplied() -> None:
|
||||
ef = ONNXMiniLM_L6_V2()
|
||||
ef(["test"])
|
||||
|
||||
|
||||
@given(
|
||||
providers=st.lists(
|
||||
st.sampled_from(onnxruntime.get_available_providers()),
|
||||
min_size=1,
|
||||
).filter(lambda x: len(x) > len(set(x)))
|
||||
)
|
||||
def test_provider_repeating(providers: List[str]) -> None:
|
||||
with pytest.raises(ValueError) as e:
|
||||
ef = ONNXMiniLM_L6_V2(preferred_providers=providers)
|
||||
ef(["test"])
|
||||
assert "Preferred providers must be unique" in str(e.value)
|
||||
|
||||
|
||||
def test_invalid_sha256() -> None:
|
||||
ef = ONNXMiniLM_L6_V2()
|
||||
shutil.rmtree(ef.DOWNLOAD_PATH) # clean up any existing models
|
||||
with pytest.raises(ValueError) as e:
|
||||
ef._MODEL_SHA256 = "invalid"
|
||||
ef(["test"])
|
||||
assert "does not match expected SHA256 hash" in str(e.value)
|
||||
|
||||
|
||||
def test_partial_download() -> None:
|
||||
ef = ONNXMiniLM_L6_V2()
|
||||
shutil.rmtree(ef.DOWNLOAD_PATH, ignore_errors=True) # clean up any existing models
|
||||
os.makedirs(ef.DOWNLOAD_PATH, exist_ok=True)
|
||||
path = os.path.join(ef.DOWNLOAD_PATH, ef.ARCHIVE_FILENAME)
|
||||
with open(path, "wb") as f: # create invalid file to simulate partial download
|
||||
f.write(b"invalid")
|
||||
ef._download_model_if_not_exists() # re-download model
|
||||
assert os.path.exists(path)
|
||||
assert _verify_sha256(
|
||||
str(os.path.join(ef.DOWNLOAD_PATH, ef.ARCHIVE_FILENAME)),
|
||||
ef._MODEL_SHA256,
|
||||
)
|
||||
assert len(ef(["test"])) == 1
|
||||
@@ -0,0 +1,118 @@
|
||||
from chromadb.utils import embedding_functions
|
||||
from chromadb.utils.embedding_functions import (
|
||||
EmbeddingFunction,
|
||||
register_embedding_function,
|
||||
)
|
||||
from typing import Dict, Any
|
||||
import pytest
|
||||
from chromadb.api.types import (
|
||||
Embeddings,
|
||||
Space,
|
||||
Embeddable,
|
||||
SparseEmbeddingFunction,
|
||||
)
|
||||
from chromadb.api.models.CollectionCommon import validation_context
|
||||
|
||||
|
||||
def test_get_builtins_holds() -> None:
|
||||
"""
|
||||
Ensure that `get_builtins` is consistent after the ef migration.
|
||||
|
||||
This test is intended to be temporary until the ef migration is complete as
|
||||
these expected builtins are likely to grow as long as users add new
|
||||
embedding functions.
|
||||
|
||||
REMOVE ME ON THE NEXT EF ADDITION
|
||||
"""
|
||||
expected_builtins = {
|
||||
"AmazonBedrockEmbeddingFunction",
|
||||
"BasetenEmbeddingFunction",
|
||||
"CloudflareWorkersAIEmbeddingFunction",
|
||||
"CohereEmbeddingFunction",
|
||||
"VoyageAIEmbeddingFunction",
|
||||
"GoogleGenerativeAiEmbeddingFunction",
|
||||
"GooglePalmEmbeddingFunction",
|
||||
"GoogleVertexEmbeddingFunction",
|
||||
"HuggingFaceEmbeddingFunction",
|
||||
"HuggingFaceEmbeddingServer",
|
||||
"InstructorEmbeddingFunction",
|
||||
"JinaEmbeddingFunction",
|
||||
"MistralEmbeddingFunction",
|
||||
"MorphEmbeddingFunction",
|
||||
"ONNXMiniLM_L6_V2",
|
||||
"OllamaEmbeddingFunction",
|
||||
"OpenAIEmbeddingFunction",
|
||||
"OpenCLIPEmbeddingFunction",
|
||||
"RoboflowEmbeddingFunction",
|
||||
"SentenceTransformerEmbeddingFunction",
|
||||
"Text2VecEmbeddingFunction",
|
||||
"ChromaLangchainEmbeddingFunction",
|
||||
"TogetherAIEmbeddingFunction",
|
||||
"DefaultEmbeddingFunction",
|
||||
"HuggingFaceSparseEmbeddingFunction",
|
||||
"FastembedSparseEmbeddingFunction",
|
||||
"Bm25EmbeddingFunction",
|
||||
"ChromaCloudQwenEmbeddingFunction",
|
||||
"ChromaCloudSpladeEmbeddingFunction",
|
||||
"ChromaBm25EmbeddingFunction",
|
||||
}
|
||||
|
||||
assert expected_builtins == embedding_functions.get_builtins()
|
||||
|
||||
|
||||
def test_default_ef_exists() -> None:
|
||||
assert hasattr(embedding_functions, "DefaultEmbeddingFunction")
|
||||
default_ef = embedding_functions.DefaultEmbeddingFunction()
|
||||
|
||||
assert default_ef is not None
|
||||
assert isinstance(default_ef, EmbeddingFunction) or isinstance(
|
||||
default_ef, SparseEmbeddingFunction
|
||||
)
|
||||
|
||||
|
||||
def test_ef_imports() -> None:
|
||||
for ef in embedding_functions.get_builtins():
|
||||
# Langchain embedding function is a special snowflake
|
||||
if ef == "ChromaLangchainEmbeddingFunction":
|
||||
continue
|
||||
assert hasattr(embedding_functions, ef)
|
||||
assert isinstance(getattr(embedding_functions, ef), type)
|
||||
assert issubclass(
|
||||
getattr(embedding_functions, ef), EmbeddingFunction
|
||||
) or issubclass(getattr(embedding_functions, ef), SparseEmbeddingFunction)
|
||||
|
||||
|
||||
@register_embedding_function
|
||||
class CustomEmbeddingFunction(EmbeddingFunction[Embeddable]):
|
||||
def __init__(self, dim: int = 3):
|
||||
self._dim = dim
|
||||
|
||||
@validation_context("custom_ef_call")
|
||||
def __call__(self, input: Embeddable) -> Embeddings:
|
||||
raise Exception("This is a test exception")
|
||||
|
||||
@staticmethod
|
||||
def name() -> str:
|
||||
return "custom_ef"
|
||||
|
||||
def get_config(self) -> Dict[str, Any]:
|
||||
return {"dim": self._dim}
|
||||
|
||||
@staticmethod
|
||||
def build_from_config(config: Dict[str, Any]) -> "CustomEmbeddingFunction":
|
||||
return CustomEmbeddingFunction(dim=config["dim"])
|
||||
|
||||
def default_space(self) -> Space:
|
||||
return "cosine"
|
||||
|
||||
|
||||
def test_validation_context_with_custom_ef() -> None:
|
||||
custom_ef = CustomEmbeddingFunction()
|
||||
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
custom_ef(["test data"])
|
||||
|
||||
original_msg = "This is a test exception"
|
||||
expected_msg = f"{original_msg} in custom_ef_call."
|
||||
assert str(excinfo.value) == expected_msg
|
||||
assert excinfo.value.args == (expected_msg,)
|
||||
@@ -0,0 +1,135 @@
|
||||
import os
|
||||
import pytest
|
||||
import numpy as np
|
||||
from chromadb.utils.embedding_functions.morph_embedding_function import (
|
||||
MorphEmbeddingFunction,
|
||||
)
|
||||
|
||||
|
||||
def test_morph_embedding_function_with_api_key() -> None:
|
||||
"""Test Morph embedding function when API key is available."""
|
||||
if os.environ.get("MORPH_API_KEY") is None:
|
||||
pytest.skip("MORPH_API_KEY not set")
|
||||
|
||||
ef = MorphEmbeddingFunction(
|
||||
model_name="morph-embedding-v2"
|
||||
)
|
||||
|
||||
# Test with code snippets (Morph's specialty)
|
||||
code_snippets = [
|
||||
"def hello_world():\n print('Hello, World!')",
|
||||
"class Calculator:\n def add(self, a, b):\n return a + b"
|
||||
]
|
||||
|
||||
embeddings = ef(code_snippets)
|
||||
assert embeddings is not None
|
||||
assert len(embeddings) == 2
|
||||
assert all(isinstance(emb, np.ndarray) for emb in embeddings)
|
||||
assert all(len(emb) > 0 for emb in embeddings)
|
||||
|
||||
|
||||
def test_morph_embedding_function_with_custom_parameters() -> None:
|
||||
"""Test Morph embedding function with custom parameters."""
|
||||
if os.environ.get("MORPH_API_KEY") is None:
|
||||
pytest.skip("MORPH_API_KEY not set")
|
||||
|
||||
ef = MorphEmbeddingFunction(
|
||||
model_name="morph-embedding-v2",
|
||||
api_base="https://api.morphllm.com/v1",
|
||||
encoding_format="float",
|
||||
api_key_env_var="MORPH_API_KEY"
|
||||
)
|
||||
|
||||
# Test with a simple function
|
||||
code_snippet = ["function add(a, b) { return a + b; }"]
|
||||
|
||||
embeddings = ef(code_snippet)
|
||||
assert embeddings is not None
|
||||
assert len(embeddings) == 1
|
||||
assert isinstance(embeddings[0], np.ndarray)
|
||||
assert len(embeddings[0]) > 0
|
||||
|
||||
|
||||
def test_morph_embedding_function_config_roundtrip() -> None:
|
||||
"""Test that Morph embedding function configuration can be saved and restored."""
|
||||
try:
|
||||
import openai
|
||||
except ImportError:
|
||||
pytest.skip("openai package not installed")
|
||||
|
||||
ef = MorphEmbeddingFunction(
|
||||
model_name="morph-embedding-v2",
|
||||
api_base="https://api.morphllm.com/v1",
|
||||
encoding_format="float",
|
||||
api_key_env_var="MORPH_API_KEY"
|
||||
)
|
||||
|
||||
# Get configuration
|
||||
config = ef.get_config()
|
||||
|
||||
# Verify configuration contains expected keys
|
||||
assert "model_name" in config
|
||||
assert "api_base" in config
|
||||
assert "encoding_format" in config
|
||||
assert "api_key_env_var" in config
|
||||
|
||||
# Verify values
|
||||
assert config["model_name"] == "morph-embedding-v2"
|
||||
assert config["api_base"] == "https://api.morphllm.com/v1"
|
||||
assert config["encoding_format"] == "float"
|
||||
assert config["api_key_env_var"] == "MORPH_API_KEY"
|
||||
|
||||
# Test building from config
|
||||
new_ef = MorphEmbeddingFunction.build_from_config(config)
|
||||
new_config = new_ef.get_config()
|
||||
|
||||
# Configurations should match
|
||||
assert config == new_config
|
||||
|
||||
|
||||
def test_morph_embedding_function_name() -> None:
|
||||
"""Test that Morph embedding function returns correct name."""
|
||||
assert MorphEmbeddingFunction.name() == "morph"
|
||||
|
||||
|
||||
def test_morph_embedding_function_spaces() -> None:
|
||||
"""Test that Morph embedding function supports expected spaces."""
|
||||
try:
|
||||
import openai
|
||||
except ImportError:
|
||||
pytest.skip("openai package not installed")
|
||||
|
||||
ef = MorphEmbeddingFunction(
|
||||
model_name="morph-embedding-v2",
|
||||
api_key_env_var="MORPH_API_KEY"
|
||||
)
|
||||
|
||||
# Test default space
|
||||
assert ef.default_space() == "cosine"
|
||||
|
||||
# Test supported spaces
|
||||
supported_spaces = ef.supported_spaces()
|
||||
assert "cosine" in supported_spaces
|
||||
assert "l2" in supported_spaces
|
||||
assert "ip" in supported_spaces
|
||||
|
||||
|
||||
def test_morph_embedding_function_validate_config() -> None:
|
||||
"""Test that Morph embedding function validates configuration correctly."""
|
||||
# Valid configuration
|
||||
valid_config = {
|
||||
"model_name": "morph-embedding-v2",
|
||||
"api_key_env_var": "MORPH_API_KEY"
|
||||
}
|
||||
|
||||
# This should not raise an exception
|
||||
MorphEmbeddingFunction.validate_config(valid_config)
|
||||
|
||||
# Invalid configuration (missing required fields)
|
||||
invalid_config = {
|
||||
"model_name": "morph-embedding-v2"
|
||||
# Missing api_key_env_var
|
||||
}
|
||||
|
||||
with pytest.raises(Exception):
|
||||
MorphEmbeddingFunction.validate_config(invalid_config)
|
||||
@@ -0,0 +1,169 @@
|
||||
import os
|
||||
from typing import Generator, cast
|
||||
import numpy as np
|
||||
import pytest
|
||||
import chromadb
|
||||
from chromadb.api.types import (
|
||||
Embeddable,
|
||||
EmbeddingFunction,
|
||||
Embeddings,
|
||||
Image,
|
||||
Document,
|
||||
)
|
||||
from chromadb.test.property.strategies import hashing_embedding_function
|
||||
from chromadb.test.property.invariants import _exact_distances
|
||||
from chromadb.config import Settings
|
||||
|
||||
|
||||
# A 'standard' multimodal embedding function, which converts inputs to strings
|
||||
# then hashes them to a fixed dimension.
|
||||
class hashing_multimodal_ef(EmbeddingFunction[Embeddable]):
|
||||
def __init__(self) -> None:
|
||||
self._hef = hashing_embedding_function(dim=10, dtype=np.float64)
|
||||
|
||||
def __call__(self, input: Embeddable) -> Embeddings:
|
||||
to_texts = [str(i) for i in input]
|
||||
embeddings = np.array(self._hef(to_texts))
|
||||
# Normalize the embeddings
|
||||
# This is so we can generate random unit vectors and have them be close to the embeddings
|
||||
embeddings /= np.linalg.norm(embeddings, axis=1, keepdims=True) # type: ignore[misc]
|
||||
return cast(Embeddings, embeddings.tolist())
|
||||
|
||||
|
||||
def random_image() -> Image:
|
||||
return np.random.randint(0, 255, size=(10, 10, 3), dtype=np.int64)
|
||||
|
||||
|
||||
def random_document() -> Document:
|
||||
return str(random_image())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def multimodal_collection(
|
||||
default_ef: EmbeddingFunction[Embeddable] = hashing_multimodal_ef(),
|
||||
) -> Generator[chromadb.Collection, None, None]:
|
||||
settings = Settings()
|
||||
if os.environ.get("CHROMA_INTEGRATION_TEST_ONLY"):
|
||||
host = os.environ.get("CHROMA_SERVER_HOST", "localhost")
|
||||
port = int(os.environ.get("CHROMA_SERVER_HTTP_PORT", 0))
|
||||
settings.chroma_api_impl = "chromadb.api.fastapi.FastAPI"
|
||||
settings.chroma_server_http_port = port
|
||||
settings.chroma_server_host = host
|
||||
|
||||
client = chromadb.Client(settings=settings)
|
||||
collection = client.create_collection(
|
||||
name="multimodal_collection", embedding_function=default_ef
|
||||
)
|
||||
yield collection
|
||||
client.clear_system_cache()
|
||||
|
||||
|
||||
# Test adding and querying of a multimodal collection consisting of images and documents
|
||||
def test_multimodal(
|
||||
multimodal_collection: chromadb.Collection,
|
||||
default_ef: EmbeddingFunction[Embeddable] = hashing_multimodal_ef(),
|
||||
n_examples: int = 10,
|
||||
n_query_results: int = 3,
|
||||
) -> None:
|
||||
# Fix numpy's random seed for reproducibility
|
||||
random_state = np.random.get_state()
|
||||
np.random.seed(0)
|
||||
|
||||
image_ids = [str(i) for i in range(n_examples)]
|
||||
images = [random_image() for _ in range(n_examples)]
|
||||
image_embeddings = default_ef(images)
|
||||
|
||||
document_ids = [str(i) for i in range(n_examples, 2 * n_examples)]
|
||||
documents = [random_document() for _ in range(n_examples)]
|
||||
document_embeddings = default_ef(documents)
|
||||
|
||||
# Trying to add a document and an image at the same time should fail
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
# This error string may be in any order
|
||||
match=r"Exactly one of (images|documents|uris)(?:, (images|documents|uris))?(?:, (images|documents|uris))? must be provided in add\.",
|
||||
):
|
||||
multimodal_collection.add(
|
||||
ids=image_ids[0], documents=documents[0], images=images[0]
|
||||
)
|
||||
|
||||
# Add some documents
|
||||
multimodal_collection.add(ids=document_ids, documents=documents)
|
||||
# Add some images
|
||||
multimodal_collection.add(ids=image_ids, images=images)
|
||||
|
||||
# get() should return all the documents and images
|
||||
# ids corresponding to images should not have documents
|
||||
get_result = multimodal_collection.get(include=["documents"])
|
||||
assert len(get_result["ids"]) == len(document_ids) + len(image_ids)
|
||||
for i, id in enumerate(get_result["ids"]):
|
||||
assert id in document_ids or id in image_ids
|
||||
assert get_result["documents"] is not None
|
||||
if id in document_ids:
|
||||
assert get_result["documents"][i] == documents[document_ids.index(id)]
|
||||
if id in image_ids:
|
||||
assert get_result["documents"][i] is None
|
||||
|
||||
# Generate a random query image
|
||||
query_image = random_image()
|
||||
query_image_embedding = default_ef([query_image])
|
||||
|
||||
image_neighbor_indices, _ = _exact_distances(
|
||||
query_image_embedding, image_embeddings + document_embeddings
|
||||
)
|
||||
# Get the ids of the nearest neighbors
|
||||
nearest_image_neighbor_ids = [
|
||||
image_ids[i] if i < n_examples else document_ids[i % n_examples]
|
||||
for i in image_neighbor_indices[0][:n_query_results]
|
||||
]
|
||||
|
||||
# Generate a random query document
|
||||
query_document = random_document()
|
||||
query_document_embedding = default_ef([query_document])
|
||||
document_neighbor_indices, _ = _exact_distances(
|
||||
query_document_embedding, image_embeddings + document_embeddings
|
||||
)
|
||||
nearest_document_neighbor_ids = [
|
||||
image_ids[i] if i < n_examples else document_ids[i % n_examples]
|
||||
for i in document_neighbor_indices[0][:n_query_results]
|
||||
]
|
||||
|
||||
# Querying with both images and documents should fail
|
||||
with pytest.raises(ValueError):
|
||||
multimodal_collection.query(
|
||||
query_images=[query_image], query_texts=[query_document]
|
||||
)
|
||||
|
||||
# Query with images
|
||||
query_result = multimodal_collection.query(
|
||||
query_images=[query_image], n_results=n_query_results, include=["documents"]
|
||||
)
|
||||
|
||||
assert query_result["ids"][0] == nearest_image_neighbor_ids
|
||||
|
||||
# Query with documents
|
||||
query_result = multimodal_collection.query(
|
||||
query_texts=[query_document], n_results=n_query_results, include=["documents"]
|
||||
)
|
||||
|
||||
assert query_result["ids"][0] == nearest_document_neighbor_ids
|
||||
np.random.set_state(random_state)
|
||||
|
||||
|
||||
@pytest.mark.xfail
|
||||
def test_multimodal_update_with_image(
|
||||
multimodal_collection: chromadb.Collection,
|
||||
) -> None:
|
||||
# Updating an entry with an existing document should remove the documentß
|
||||
|
||||
document = random_document()
|
||||
image = random_image()
|
||||
id = "0"
|
||||
|
||||
multimodal_collection.add(ids=id, documents=document)
|
||||
|
||||
multimodal_collection.update(ids=id, images=image)
|
||||
|
||||
get_result = multimodal_collection.get(ids=id, include=["documents"])
|
||||
assert get_result["documents"] is not None
|
||||
assert get_result["documents"][0] is None
|
||||
@@ -0,0 +1,50 @@
|
||||
import pytest
|
||||
|
||||
from chromadb.utils.embedding_functions.ollama_embedding_function import (
|
||||
OllamaEmbeddingFunction,
|
||||
)
|
||||
|
||||
|
||||
def test_ollama_default_model() -> None:
|
||||
pytest.importorskip("ollama", reason="ollama not installed")
|
||||
ef = OllamaEmbeddingFunction()
|
||||
embeddings = ef(["Here is an article about llamas...", "this is another article"])
|
||||
assert embeddings is not None
|
||||
assert len(embeddings) == 2
|
||||
assert all(len(e) == 384 for e in embeddings)
|
||||
|
||||
|
||||
def test_ollama_unknown_model() -> None:
|
||||
pytest.importorskip("ollama", reason="ollama not installed")
|
||||
model_name = "unknown-model"
|
||||
ef = OllamaEmbeddingFunction(model_name=model_name)
|
||||
with pytest.raises(Exception) as e:
|
||||
ef(["Here is an article about llamas...", "this is another article"])
|
||||
assert f'model "{model_name}" not found' in str(e.value)
|
||||
|
||||
|
||||
def test_ollama_backward_compat() -> None:
|
||||
pytest.importorskip("ollama", reason="ollama not installed")
|
||||
ef = OllamaEmbeddingFunction(url="http://localhost:11434/api/embeddings")
|
||||
embeddings = ef(["Here is an article about llamas...", "this is another article"])
|
||||
assert embeddings is not None
|
||||
|
||||
|
||||
def test_wrong_url() -> None:
|
||||
pytest.importorskip("ollama", reason="ollama not installed")
|
||||
ef = OllamaEmbeddingFunction(url="http://localhost:11434/this_is_wrong")
|
||||
with pytest.raises(Exception) as e:
|
||||
ef(["Here is an article about llamas...", "this is another article"])
|
||||
assert "404" in str(e.value)
|
||||
|
||||
|
||||
def test_ollama_ask_user_to_install() -> None:
|
||||
try:
|
||||
from ollama import Client # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
pytest.skip("ollama python package is installed")
|
||||
with pytest.raises(ValueError) as e:
|
||||
OllamaEmbeddingFunction()
|
||||
assert "The ollama python package is not installed" in str(e.value)
|
||||
@@ -0,0 +1,204 @@
|
||||
import os
|
||||
import tempfile
|
||||
from typing import Dict, Any
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
import pytest
|
||||
import onnxruntime
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from chromadb.utils.embedding_functions import ONNXMiniLM_L6_V2, EmbeddingFunction
|
||||
|
||||
|
||||
class TestONNXMiniLM_L6_V2:
|
||||
"""Test suite for ONNXMiniLM_L6_V2 embedding function."""
|
||||
|
||||
def test_initialization(self) -> None:
|
||||
"""Test that the embedding function initializes correctly."""
|
||||
ef = ONNXMiniLM_L6_V2()
|
||||
assert ef is not None
|
||||
assert isinstance(ef, EmbeddingFunction)
|
||||
|
||||
# Test with valid providers
|
||||
available_providers = onnxruntime.get_available_providers()
|
||||
if available_providers:
|
||||
ef = ONNXMiniLM_L6_V2(preferred_providers=[available_providers[0]])
|
||||
assert ef is not None
|
||||
|
||||
# Test with None providers
|
||||
ef = ONNXMiniLM_L6_V2(preferred_providers=None)
|
||||
assert ef is not None
|
||||
|
||||
def test_embedding_shape_and_normalization(self) -> None:
|
||||
"""Test that embeddings have the correct shape and are normalized."""
|
||||
ef = ONNXMiniLM_L6_V2()
|
||||
|
||||
# Test with a single document
|
||||
docs = ["This is a test document"]
|
||||
embeddings = ef(docs)
|
||||
|
||||
# Check shape and type
|
||||
assert isinstance(embeddings, list)
|
||||
assert len(embeddings) == 1
|
||||
assert (
|
||||
len(embeddings[0]) == 384
|
||||
) # MiniLM-L6-v2 produces 384-dimensional embeddings
|
||||
|
||||
# Check normalization (for cosine similarity)
|
||||
embedding_np = np.array(embeddings[0])
|
||||
norm = np.linalg.norm(embedding_np)
|
||||
assert np.isclose(norm, 1.0, atol=1e-5)
|
||||
|
||||
# Test with multiple documents
|
||||
docs = ["First document", "Second document", "Third document"]
|
||||
embeddings = ef(docs)
|
||||
|
||||
# Check shape
|
||||
assert len(embeddings) == 3
|
||||
assert all(len(emb) == 384 for emb in embeddings)
|
||||
|
||||
def test_batch_processing(self) -> None:
|
||||
"""Test that the embedding function correctly processes batches."""
|
||||
ef = ONNXMiniLM_L6_V2()
|
||||
|
||||
# Create a list of documents larger than the default batch size (32)
|
||||
docs = [f"Document {i}" for i in range(40)]
|
||||
|
||||
# Get embeddings
|
||||
embeddings = ef(docs)
|
||||
|
||||
# Check that all documents were processed
|
||||
assert len(embeddings) == 40
|
||||
assert all(len(emb) == 384 for emb in embeddings)
|
||||
|
||||
def test_config_serialization(self) -> None:
|
||||
"""Test that the embedding function can be serialized and deserialized."""
|
||||
# Create an embedding function with specific providers
|
||||
available_providers = onnxruntime.get_available_providers()
|
||||
providers = available_providers[:1] if available_providers else None
|
||||
ef = ONNXMiniLM_L6_V2(preferred_providers=providers)
|
||||
|
||||
# Get config
|
||||
config = ef.get_config()
|
||||
|
||||
# Check config
|
||||
assert isinstance(config, dict)
|
||||
assert "preferred_providers" in config
|
||||
|
||||
# Build from config
|
||||
ef2 = ONNXMiniLM_L6_V2.build_from_config(config)
|
||||
|
||||
# Check that the new instance works
|
||||
docs = ["Test document"]
|
||||
embeddings = ef2(docs)
|
||||
assert len(embeddings) == 1
|
||||
assert len(embeddings[0]) == 384
|
||||
|
||||
def test_max_tokens(self) -> None:
|
||||
"""Test the max_tokens method."""
|
||||
ef = ONNXMiniLM_L6_V2()
|
||||
assert ef.max_tokens() == 256 # Default for this model
|
||||
|
||||
@patch("httpx.stream")
|
||||
def test_download_functionality(self, mock_stream: MagicMock) -> None:
|
||||
"""Test the model download functionality with mocking."""
|
||||
# Setup mock response
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.headers.get.return_value = "1000"
|
||||
mock_response.iter_bytes.return_value = [b"test data"]
|
||||
mock_stream.return_value.__enter__.return_value = mock_response
|
||||
|
||||
# Create a temporary directory for testing
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Patch the download path
|
||||
with patch.object(ONNXMiniLM_L6_V2, "DOWNLOAD_PATH", temp_dir):
|
||||
with patch(
|
||||
"chromadb.utils.embedding_functions.onnx_mini_lm_l6_v2._verify_sha256",
|
||||
return_value=True,
|
||||
):
|
||||
ef = ONNXMiniLM_L6_V2()
|
||||
# Call download method directly
|
||||
ef._download(
|
||||
url="https://test.url",
|
||||
fname=os.path.join(temp_dir, "test_file"),
|
||||
)
|
||||
|
||||
# Check that the file was created
|
||||
assert os.path.exists(os.path.join(temp_dir, "test_file"))
|
||||
|
||||
def test_validate_config(self) -> None:
|
||||
"""Test config validation."""
|
||||
ef = ONNXMiniLM_L6_V2()
|
||||
|
||||
# Test validate_config
|
||||
config: Dict[str, Any] = {"preferred_providers": ["CPUExecutionProvider"]}
|
||||
ef.validate_config(config) # Should not raise
|
||||
|
||||
# Test validate_config_update
|
||||
old_config: Dict[str, Any] = {"preferred_providers": ["CPUExecutionProvider"]}
|
||||
new_config: Dict[str, Any] = {"preferred_providers": ["CUDAExecutionProvider"]}
|
||||
ef.validate_config_update(old_config, new_config) # Should not raise
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"input_text",
|
||||
[
|
||||
"Short text",
|
||||
"A longer text that contains multiple words and should be embedded properly",
|
||||
"", # Empty string
|
||||
"Special characters: !@#$%^&*()",
|
||||
"Numbers: 1234567890",
|
||||
"Unicode: 你好, こんにちは, 안녕하세요",
|
||||
],
|
||||
)
|
||||
def test_various_inputs(self, input_text: str) -> None:
|
||||
"""Test the embedding function with various types of input text."""
|
||||
ef = ONNXMiniLM_L6_V2()
|
||||
|
||||
# Get embeddings
|
||||
embeddings = ef([input_text])
|
||||
|
||||
# Check that embeddings were generated
|
||||
assert len(embeddings) == 1
|
||||
assert len(embeddings[0]) == 384
|
||||
|
||||
def test_consistency(self) -> None:
|
||||
"""Test that the embedding function produces consistent results."""
|
||||
ef = ONNXMiniLM_L6_V2()
|
||||
|
||||
# Get embeddings for the same text twice
|
||||
text = "This is a test document"
|
||||
embeddings1 = ef([text])
|
||||
embeddings2 = ef([text])
|
||||
|
||||
# Check that the embeddings are the same
|
||||
np.testing.assert_allclose(embeddings1[0], embeddings2[0])
|
||||
|
||||
def test_similar_texts_have_similar_embeddings(self) -> None:
|
||||
"""Test that similar texts have similar embeddings."""
|
||||
ef = ONNXMiniLM_L6_V2()
|
||||
|
||||
# Get embeddings for similar texts
|
||||
text1 = "The cat sat on the mat"
|
||||
text2 = "A cat was sitting on a mat"
|
||||
text3 = "Quantum physics is fascinating"
|
||||
|
||||
embeddings = ef([text1, text2, text3])
|
||||
|
||||
# Calculate cosine similarities
|
||||
def cosine_similarity(a: NDArray[np.float32], b: NDArray[np.float32]) -> float:
|
||||
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
|
||||
|
||||
# Similar texts should have higher similarity
|
||||
sim_1_2 = cosine_similarity(
|
||||
np.array(embeddings[0], dtype=np.float32),
|
||||
np.array(embeddings[1], dtype=np.float32),
|
||||
)
|
||||
sim_1_3 = cosine_similarity(
|
||||
np.array(embeddings[0], dtype=np.float32),
|
||||
np.array(embeddings[2], dtype=np.float32),
|
||||
)
|
||||
|
||||
# The similarity between text1 and text2 should be higher than between text1 and text3
|
||||
assert sim_1_2 > sim_1_3
|
||||
@@ -0,0 +1,38 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from chromadb.utils.embedding_functions.openai_embedding_function import (
|
||||
OpenAIEmbeddingFunction,
|
||||
)
|
||||
|
||||
|
||||
def test_with_embedding_dimensions() -> None:
|
||||
if os.environ.get("OPENAI_API_KEY") is None:
|
||||
pytest.skip("OPENAI_API_KEY not set")
|
||||
ef = OpenAIEmbeddingFunction(
|
||||
api_key=os.environ["OPENAI_API_KEY"],
|
||||
model_name="text-embedding-3-small",
|
||||
dimensions=64,
|
||||
)
|
||||
embeddings = ef(["hello world"])
|
||||
assert embeddings is not None
|
||||
assert len(embeddings) == 1
|
||||
assert len(embeddings[0]) == 64
|
||||
|
||||
|
||||
def test_with_embedding_dimensions_not_working_with_old_model() -> None:
|
||||
if os.environ.get("OPENAI_API_KEY") is None:
|
||||
pytest.skip("OPENAI_API_KEY not set")
|
||||
ef = OpenAIEmbeddingFunction(api_key=os.environ["OPENAI_API_KEY"], dimensions=64)
|
||||
with pytest.raises(
|
||||
Exception, match="This model does not support specifying dimensions"
|
||||
):
|
||||
ef(["hello world"])
|
||||
|
||||
|
||||
def test_with_incorrect_api_key() -> None:
|
||||
pytest.importorskip("openai", reason="openai not installed")
|
||||
ef = OpenAIEmbeddingFunction(api_key="incorrect_api_key", dimensions=64)
|
||||
with pytest.raises(Exception, match="Incorrect API key provided"):
|
||||
ef(["hello world"])
|
||||
@@ -0,0 +1,19 @@
|
||||
import os
|
||||
import pytest
|
||||
from chromadb.utils.embedding_functions.voyageai_embedding_function import (
|
||||
VoyageAIEmbeddingFunction,
|
||||
)
|
||||
|
||||
voyageai = pytest.importorskip("voyageai", reason="voyageai not installed")
|
||||
|
||||
|
||||
def test_with_embedding_dimensions() -> None:
|
||||
if os.environ.get("CHROMA_VOYAGE_API_KEY") is None:
|
||||
pytest.skip("CHROMA_VOYAGE_API_KEY not set")
|
||||
ef = VoyageAIEmbeddingFunction(
|
||||
api_key=os.environ["CHROMA_VOYAGE_API_KEY"]
|
||||
)
|
||||
embeddings = ef(["hello world"])
|
||||
assert embeddings is not None
|
||||
assert len(embeddings) == 1
|
||||
assert len(embeddings[0]) == 1536
|
||||
@@ -0,0 +1,12 @@
|
||||
[req]
|
||||
distinguished_name = req_distinguished_name
|
||||
x509_extensions = usr_cert
|
||||
|
||||
[req_distinguished_name]
|
||||
CN = localhost
|
||||
|
||||
[usr_cert]
|
||||
subjectAltName = @alt_names
|
||||
|
||||
[alt_names]
|
||||
DNS.1 = localhost
|
||||
@@ -0,0 +1,620 @@
|
||||
import gc
|
||||
import math
|
||||
import os.path
|
||||
from uuid import UUID
|
||||
from contextlib import contextmanager
|
||||
|
||||
from chromadb.api.segment import SegmentAPI
|
||||
from chromadb.db.system import SysDB
|
||||
from chromadb.ingest.impl.utils import create_topic_name
|
||||
|
||||
from chromadb.config import System
|
||||
from chromadb.db.base import get_sql
|
||||
from chromadb.db.impl.sqlite import SqliteDB
|
||||
from time import sleep
|
||||
import psutil
|
||||
|
||||
from chromadb.segment import SegmentType
|
||||
from chromadb.test.property.strategies import NormalizedRecordSet, RecordSet
|
||||
from typing import Callable, Optional, Tuple, Union, List, TypeVar, cast, Any, Dict
|
||||
from typing_extensions import Literal
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
from chromadb.api import types, ClientAPI
|
||||
from chromadb.api.models.Collection import Collection
|
||||
from hypothesis import note
|
||||
from hypothesis.errors import InvalidArgument
|
||||
from pypika import Table, functions
|
||||
|
||||
from chromadb.utils import distance_functions
|
||||
from chromadb.execution.expression.plan import Search
|
||||
from chromadb.execution.expression.operator import Knn, Select, Limit, Key
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def wrap(value: Union[T, List[T]]) -> List[T]:
|
||||
"""Wrap a value in a list if it is not a list"""
|
||||
if value is None:
|
||||
raise InvalidArgument("value cannot be None")
|
||||
elif isinstance(value, List):
|
||||
return value
|
||||
else:
|
||||
return [value]
|
||||
|
||||
|
||||
def wrap_all(record_set: RecordSet) -> NormalizedRecordSet:
|
||||
"""Ensure that an embedding set has lists for all its values"""
|
||||
|
||||
embedding_list: Optional[types.Embeddings]
|
||||
if record_set["embeddings"] is None:
|
||||
embedding_list = None
|
||||
elif isinstance(record_set["embeddings"], list):
|
||||
assert record_set["embeddings"] is not None
|
||||
if len(record_set["embeddings"]) > 0:
|
||||
if all(
|
||||
isinstance(embedding, list) for embedding in record_set["embeddings"]
|
||||
):
|
||||
embedding_list = cast(types.Embeddings, record_set["embeddings"])
|
||||
elif all(
|
||||
isinstance(embedding, np.ndarray)
|
||||
for embedding in record_set["embeddings"]
|
||||
):
|
||||
embedding_list = cast(types.Embeddings, record_set["embeddings"])
|
||||
else:
|
||||
if all(
|
||||
isinstance(e, (int, float, np.integer, np.floating))
|
||||
for e in record_set["embeddings"]
|
||||
):
|
||||
embedding_list = cast(types.Embeddings, [record_set["embeddings"]])
|
||||
else:
|
||||
raise InvalidArgument(
|
||||
"an embedding must be a list of floats or ints"
|
||||
)
|
||||
else:
|
||||
embedding_list = cast(types.Embeddings, record_set["embeddings"])
|
||||
else:
|
||||
raise InvalidArgument(
|
||||
"embeddings must be a list of lists, a list of numpy arrays, a list of numbers, or None"
|
||||
)
|
||||
|
||||
return {
|
||||
"ids": wrap(record_set["ids"]),
|
||||
"documents": wrap(record_set["documents"])
|
||||
if record_set["documents"] is not None
|
||||
else None,
|
||||
"metadatas": wrap(record_set["metadatas"])
|
||||
if record_set["metadatas"] is not None
|
||||
else None,
|
||||
"embeddings": embedding_list,
|
||||
}
|
||||
|
||||
|
||||
def check_metadata(
|
||||
expected: Optional[types.Metadata], got: Optional[types.Metadata]
|
||||
) -> None:
|
||||
assert (expected is None and got is None) or (
|
||||
expected is not None and got is not None
|
||||
)
|
||||
if expected is not None and got is not None:
|
||||
assert len(expected) == len(got)
|
||||
for key, val in expected.items():
|
||||
assert key in got
|
||||
if isinstance(expected[key], float) and isinstance(got[key], float):
|
||||
assert abs(cast(float, expected[key]) - cast(float, got[key])) < 1e-6
|
||||
else:
|
||||
assert expected[key] == got[key]
|
||||
|
||||
|
||||
def count(collection: Collection, record_set: RecordSet) -> None:
|
||||
"""The given collection count is equal to the number of embeddings"""
|
||||
count = collection.count()
|
||||
normalized_record_set = wrap_all(record_set)
|
||||
if count != len(normalized_record_set["ids"]):
|
||||
print("count mismatch:", count, "=!", len(normalized_record_set["ids"]))
|
||||
assert count == len(normalized_record_set["ids"])
|
||||
|
||||
|
||||
def _field_matches(
|
||||
collection: Collection,
|
||||
normalized_record_set: NormalizedRecordSet,
|
||||
field_name: Union[
|
||||
Literal["documents"], Literal["metadatas"], Literal["embeddings"]
|
||||
],
|
||||
) -> None:
|
||||
"""
|
||||
The actual embedding field is equal to the expected field
|
||||
field_name: one of [documents, metadatas]
|
||||
"""
|
||||
result = collection.get(ids=normalized_record_set["ids"], include=[field_name]) # type: ignore[list-item]
|
||||
# The test_out_of_order_ids test fails because of this in test_add.py
|
||||
# Here we sort by the ids to match the input order
|
||||
embedding_id_to_index = {id: i for i, id in enumerate(normalized_record_set["ids"])}
|
||||
actual_field = result[field_name]
|
||||
|
||||
if len(normalized_record_set["ids"]) == 0:
|
||||
if field_name == "embeddings":
|
||||
assert cast(npt.NDArray[Any], actual_field).size == 0
|
||||
else:
|
||||
assert actual_field == []
|
||||
return
|
||||
|
||||
# This assert should never happen, if we include metadatas/documents it will be
|
||||
# [None, None..] if there is no metadata. It will not be just None.
|
||||
assert actual_field is not None
|
||||
sorted_field = sorted(
|
||||
enumerate(actual_field),
|
||||
key=lambda index_and_field_value: embedding_id_to_index[
|
||||
result["ids"][index_and_field_value[0]]
|
||||
],
|
||||
)
|
||||
field_values = [field_value for _, field_value in sorted_field]
|
||||
|
||||
expected_field = normalized_record_set[field_name]
|
||||
if expected_field is None:
|
||||
# Since an RecordSet is the user input, we need to convert the documents to
|
||||
# a List since thats what the API returns -> none per entry
|
||||
expected_field = [None] * len(normalized_record_set["ids"]) # type: ignore
|
||||
if field_name == "embeddings":
|
||||
assert np.allclose(np.array(field_values), np.array(expected_field))
|
||||
else:
|
||||
assert len(field_values) == len(expected_field)
|
||||
|
||||
for field_value, expected_field in zip(field_values, expected_field):
|
||||
if isinstance(expected_field, dict):
|
||||
check_metadata(
|
||||
cast(types.Metadata, field_value),
|
||||
cast(types.Metadata, expected_field),
|
||||
)
|
||||
else:
|
||||
assert field_value == expected_field
|
||||
|
||||
|
||||
def ids_match(collection: Collection, record_set: RecordSet) -> None:
|
||||
"""The actual embedding ids is equal to the expected ids"""
|
||||
normalized_record_set = wrap_all(record_set)
|
||||
actual_ids = collection.get(ids=normalized_record_set["ids"], include=[])["ids"]
|
||||
# The test_out_of_order_ids test fails because of this in test_add.py
|
||||
# Here we sort the ids to match the input order
|
||||
embedding_id_to_index = {id: i for i, id in enumerate(normalized_record_set["ids"])}
|
||||
actual_ids = sorted(actual_ids, key=lambda id: embedding_id_to_index[id])
|
||||
assert actual_ids == normalized_record_set["ids"]
|
||||
|
||||
|
||||
def metadatas_match(collection: Collection, record_set: RecordSet) -> None:
|
||||
"""The actual embedding metadata is equal to the expected metadata"""
|
||||
normalized_record_set = wrap_all(record_set)
|
||||
_field_matches(collection, normalized_record_set, "metadatas")
|
||||
|
||||
|
||||
def documents_match(collection: Collection, record_set: RecordSet) -> None:
|
||||
"""The actual embedding documents is equal to the expected documents"""
|
||||
normalized_record_set = wrap_all(record_set)
|
||||
_field_matches(collection, normalized_record_set, "documents")
|
||||
|
||||
|
||||
def embeddings_match(collection: Collection, record_set: RecordSet) -> None:
|
||||
"""The actual embedding documents is equal to the expected documents"""
|
||||
normalized_record_set = wrap_all(record_set)
|
||||
_field_matches(collection, normalized_record_set, "embeddings")
|
||||
|
||||
|
||||
def no_duplicates(collection: Collection) -> None:
|
||||
ids = collection.get()["ids"]
|
||||
assert len(ids) == len(set(ids))
|
||||
|
||||
|
||||
def _exact_distances(
|
||||
query: types.Embeddings,
|
||||
targets: types.Embeddings,
|
||||
distance_fn: Callable[
|
||||
[npt.ArrayLike, npt.ArrayLike], float
|
||||
] = distance_functions.l2,
|
||||
) -> Tuple[List[List[int]], List[List[float]]]:
|
||||
"""Return the ordered indices and distances from each query to each target"""
|
||||
np_query = np.array(query, dtype=np.float32)
|
||||
np_targets = np.array(targets, dtype=np.float32)
|
||||
|
||||
# Compute the distance between each query and each target, using the distance function
|
||||
distances = np.apply_along_axis(
|
||||
lambda query: np.apply_along_axis(distance_fn, 1, np_targets, query),
|
||||
1,
|
||||
np_query,
|
||||
)
|
||||
# Sort the distances and return the indices
|
||||
return np.argsort(distances).tolist(), distances.tolist()
|
||||
|
||||
|
||||
def fd_not_exceeding_threadpool_size(threadpool_size: int) -> None:
|
||||
"""
|
||||
Checks that the open file descriptors are not exceeding the threadpool size
|
||||
works only for SegmentAPI
|
||||
"""
|
||||
current_process = psutil.Process()
|
||||
open_files = current_process.open_files()
|
||||
max_retries = 5
|
||||
retry_count = 0
|
||||
# we probably don't need the below but we keep it to avoid flaky tests.
|
||||
while (
|
||||
len([p.path for p in open_files if "sqlite3" in p.path]) - 1 > threadpool_size
|
||||
and retry_count < max_retries
|
||||
):
|
||||
gc.collect() # GC to collect the orphaned TLS objects
|
||||
open_files = current_process.open_files()
|
||||
retry_count += 1
|
||||
sleep(1)
|
||||
assert (
|
||||
len([p.path for p in open_files if "sqlite3" in p.path]) - 1 <= threadpool_size
|
||||
)
|
||||
|
||||
|
||||
def get_space(collection: Collection):
|
||||
# TODO: this is a hack to get the space
|
||||
# We should update the tests to not pass space via metadata instead use collection
|
||||
# configuration_json
|
||||
space = None
|
||||
if "hnsw:space" in collection.metadata:
|
||||
space = collection.metadata["hnsw:space"]
|
||||
if collection._model.configuration_json is None:
|
||||
return space
|
||||
if (
|
||||
"spann" in collection._model.configuration_json
|
||||
and collection._model.configuration_json.get("spann") is not None
|
||||
and "space" in collection._model.configuration_json.get("spann")
|
||||
):
|
||||
space = collection._model.configuration_json.get("spann").get("space")
|
||||
elif (
|
||||
"hnsw" in collection._model.configuration_json
|
||||
and collection._model.configuration_json.get("hnsw") is not None
|
||||
and "space" in collection._model.configuration_json.get("hnsw")
|
||||
):
|
||||
if space is None:
|
||||
space = collection._model.configuration_json.get("hnsw").get("space")
|
||||
return space
|
||||
|
||||
|
||||
def ann_accuracy(
|
||||
collection: Collection,
|
||||
record_set: RecordSet,
|
||||
n_results: int = 1,
|
||||
min_recall: float = 0.95,
|
||||
embedding_function: Optional[types.EmbeddingFunction] = None, # type: ignore[type-arg]
|
||||
query_indices: Optional[List[int]] = None,
|
||||
query_embeddings: Optional[types.Embeddings] = None,
|
||||
use_search: bool = False,
|
||||
) -> None:
|
||||
"""Validate that the API performs nearest_neighbor searches correctly"""
|
||||
normalized_record_set = wrap_all(record_set)
|
||||
|
||||
if len(normalized_record_set["ids"]) == 0:
|
||||
return # nothing to test here
|
||||
|
||||
embeddings: Optional[types.Embeddings] = normalized_record_set["embeddings"]
|
||||
have_embeddings = embeddings is not None and len(embeddings) > 0
|
||||
if not have_embeddings:
|
||||
assert embedding_function is not None
|
||||
assert normalized_record_set["documents"] is not None
|
||||
assert isinstance(normalized_record_set["documents"], list)
|
||||
# Compute the embeddings for the documents
|
||||
embeddings = embedding_function(normalized_record_set["documents"])
|
||||
|
||||
space = get_space(collection)
|
||||
if space is None:
|
||||
distance_function = distance_functions.l2
|
||||
elif space == "cosine":
|
||||
distance_function = distance_functions.cosine
|
||||
elif space == "ip":
|
||||
distance_function = distance_functions.ip
|
||||
elif space == "l2":
|
||||
distance_function = distance_functions.l2
|
||||
|
||||
accuracy_threshold = 1e-6
|
||||
assert collection.metadata is not None
|
||||
assert embeddings is not None
|
||||
# TODO: ip and cosine are numerically unstable in HNSW.
|
||||
# The higher the dimensionality, the more noise is introduced, since each float element
|
||||
# of the vector has noise added, which is then subsequently included in all normalization calculations.
|
||||
# This means that higher dimensions will have more noise, and thus more error.
|
||||
assert all(isinstance(e, (list, np.ndarray)) for e in embeddings)
|
||||
dim = len(embeddings[0])
|
||||
accuracy_threshold = accuracy_threshold * math.pow(10, int(math.log10(dim)))
|
||||
|
||||
# Perform exact distance computation
|
||||
if query_embeddings is None:
|
||||
query_embeddings = (
|
||||
embeddings
|
||||
if query_indices is None
|
||||
else [embeddings[i] for i in query_indices]
|
||||
)
|
||||
query_documents = normalized_record_set["documents"]
|
||||
if query_indices is not None and query_documents is not None:
|
||||
query_documents = [query_documents[i] for i in query_indices]
|
||||
|
||||
indices, distances = _exact_distances(
|
||||
query_embeddings, embeddings, distance_fn=distance_function
|
||||
)
|
||||
|
||||
if use_search:
|
||||
# Use search API instead of query
|
||||
search_requests = []
|
||||
for query_embedding in query_embeddings:
|
||||
# Convert numpy array to list if needed
|
||||
if isinstance(query_embedding, np.ndarray):
|
||||
query_embedding_list = query_embedding.tolist()
|
||||
else:
|
||||
query_embedding_list = query_embedding
|
||||
search = Search(
|
||||
rank=Knn(query=query_embedding_list),
|
||||
limit=Limit(limit=n_results),
|
||||
).select_all()
|
||||
search_requests.append(search)
|
||||
|
||||
# Call _search API
|
||||
api = collection._client # type: ignore
|
||||
search_results = api._search(
|
||||
collection_id=collection.id,
|
||||
searches=search_requests,
|
||||
tenant='default_tenant',
|
||||
database='default_database',
|
||||
)
|
||||
|
||||
# Convert search results to query-like format
|
||||
query_results = cast(types.QueryResult, {
|
||||
"ids": search_results["ids"],
|
||||
"distances": search_results["scores"], # scores is distances in search API
|
||||
"embeddings": search_results["embeddings"],
|
||||
"documents": search_results["documents"],
|
||||
"metadatas": search_results["metadatas"],
|
||||
})
|
||||
else:
|
||||
query_results = collection.query(
|
||||
query_embeddings=query_embeddings if have_embeddings else None,
|
||||
query_texts=query_documents if not have_embeddings else None,
|
||||
n_results=n_results,
|
||||
include=["embeddings", "documents", "metadatas", "distances"], # type: ignore[list-item]
|
||||
)
|
||||
|
||||
_query_results_are_correct_shape(query_results, n_results)
|
||||
|
||||
# Assert fields are not None for type checking
|
||||
assert query_results["ids"] is not None
|
||||
assert query_results["distances"] is not None
|
||||
assert query_results["embeddings"] is not None
|
||||
assert query_results["documents"] is not None
|
||||
assert query_results["metadatas"] is not None
|
||||
|
||||
# Dict of ids to indices
|
||||
id_to_index = {id: i for i, id in enumerate(normalized_record_set["ids"])}
|
||||
missing = 0
|
||||
for i, (indices_i, distances_i) in enumerate(zip(indices, distances)):
|
||||
expected_ids = np.array(normalized_record_set["ids"])[indices_i[:n_results]]
|
||||
missing += len(set(expected_ids) - set(query_results["ids"][i]))
|
||||
|
||||
# For each id in the query results, find the index in the embeddings set
|
||||
# and assert that the embeddings are the same
|
||||
for j, id in enumerate(query_results["ids"][i]):
|
||||
# This may be because the true nth nearest neighbor didn't get returned by the ANN query
|
||||
unexpected_id = id not in expected_ids
|
||||
index = id_to_index[id]
|
||||
|
||||
correct_distance = np.allclose(
|
||||
distances_i[index],
|
||||
query_results["distances"][i][j],
|
||||
atol=accuracy_threshold,
|
||||
)
|
||||
if unexpected_id:
|
||||
# If the ID is unexpcted, but the distance is correct, then we
|
||||
# have a duplicate in the data. In this case, we should not reduce recall.
|
||||
if correct_distance:
|
||||
missing -= 1
|
||||
else:
|
||||
continue
|
||||
else:
|
||||
assert correct_distance
|
||||
|
||||
assert np.allclose(embeddings[index], query_results["embeddings"][i][j])
|
||||
if normalized_record_set["documents"] is not None:
|
||||
assert (
|
||||
normalized_record_set["documents"][index]
|
||||
== query_results["documents"][i][j]
|
||||
)
|
||||
if normalized_record_set["metadatas"] is not None:
|
||||
check_metadata(
|
||||
normalized_record_set["metadatas"][index],
|
||||
query_results["metadatas"][i][j],
|
||||
)
|
||||
|
||||
size = len(normalized_record_set["ids"])
|
||||
recall = (size - missing) / size
|
||||
|
||||
try:
|
||||
note(
|
||||
f"# recall: {recall}, missing {missing} out of {size}, accuracy threshold {accuracy_threshold}"
|
||||
)
|
||||
except InvalidArgument:
|
||||
pass # it's ok if we're running outside hypothesis
|
||||
|
||||
assert recall >= min_recall
|
||||
|
||||
# Ensure that the query results are sorted by distance
|
||||
for distance_result in query_results["distances"]:
|
||||
assert np.allclose(np.sort(distance_result), distance_result)
|
||||
|
||||
|
||||
def _query_results_are_correct_shape(
|
||||
query_results: types.QueryResult, n_results: int
|
||||
) -> None:
|
||||
for result_type in ["distances", "embeddings", "documents", "metadatas"]:
|
||||
assert query_results[result_type] is not None # type: ignore[literal-required]
|
||||
assert all(
|
||||
len(result) == n_results for result in query_results[result_type] # type: ignore[literal-required]
|
||||
)
|
||||
|
||||
|
||||
def _total_embedding_queue_log_size(sqlite: SqliteDB) -> int:
|
||||
t = Table("embeddings_queue")
|
||||
q = sqlite.querybuilder().from_(t)
|
||||
|
||||
with sqlite.tx() as cur:
|
||||
sql, params = get_sql(
|
||||
q.select(functions.Count(t.seq_id)), sqlite.parameter_format()
|
||||
)
|
||||
result = cur.execute(sql, params)
|
||||
return cast(int, result.fetchone()[0])
|
||||
|
||||
|
||||
def log_size_below_max(
|
||||
system: System, collections: List[Collection], has_collection_mutated: bool
|
||||
) -> None:
|
||||
sqlite = system.instance(SqliteDB)
|
||||
|
||||
# Ephemeral Rust client is using its own sqlite impl, which cannot be accessed from Python
|
||||
if (
|
||||
not system.settings.is_persistent
|
||||
and system.settings.chroma_api_impl == "chromadb.api.rust.RustBindingsAPI"
|
||||
):
|
||||
return
|
||||
|
||||
if has_collection_mutated:
|
||||
# Must always keep one entry to avoid reusing seq_ids
|
||||
assert _total_embedding_queue_log_size(sqlite) >= 1
|
||||
|
||||
# We purge per-collection as the sync_threshold is a per-collection setting
|
||||
sync_threshold_sum = sum(
|
||||
collection.metadata.get("hnsw:sync_threshold", 1000)
|
||||
if collection.metadata is not None
|
||||
else 1000
|
||||
for collection in collections
|
||||
)
|
||||
batch_size_sum = sum(
|
||||
collection.metadata.get("hnsw:batch_size", 100)
|
||||
if collection.metadata is not None
|
||||
else 100
|
||||
for collection in collections
|
||||
)
|
||||
|
||||
limit = (
|
||||
sync_threshold_sum
|
||||
if system.settings.chroma_api_impl == "chromadb.api.rust.RustBindingsAPI"
|
||||
else sync_threshold_sum + batch_size_sum
|
||||
)
|
||||
|
||||
# -1 is used because the queue is always at least 1 entry long, so deletion stops before the max ack'ed sequence ID.
|
||||
# And for python impl if the batch_size != sync_threshold, the queue can have up to batch_size more entries.
|
||||
assert _total_embedding_queue_log_size(sqlite) - 1 <= limit
|
||||
else:
|
||||
assert _total_embedding_queue_log_size(sqlite) == 0
|
||||
|
||||
|
||||
def _total_embedding_queue_log_size_per_collection(
|
||||
system: System,
|
||||
collections: List[Collection],
|
||||
) -> Dict[UUID, int]:
|
||||
sqlite = system.instance(SqliteDB)
|
||||
t = Table("embeddings_queue")
|
||||
q = sqlite.querybuilder().from_(t)
|
||||
_tenant = system.settings.require("tenant_id")
|
||||
_topic_namespace = system.settings.require("topic_namespace")
|
||||
topic_mappings = {
|
||||
create_topic_name(_tenant, _topic_namespace, collection.id): collection
|
||||
for collection in collections
|
||||
}
|
||||
with sqlite.tx() as cur:
|
||||
sql, params = get_sql(
|
||||
q.select(t.topic, functions.Count(t.seq_id)).groupby("topic"),
|
||||
sqlite.parameter_format(),
|
||||
)
|
||||
result = cur.execute(sql, params)
|
||||
out = {}
|
||||
for res in result.fetchall():
|
||||
out[topic_mappings[res[0]].id] = res[1]
|
||||
return out
|
||||
|
||||
|
||||
def log_size_for_collections_match_expected(
|
||||
system: System, collections: List[Collection], has_collection_mutated: bool
|
||||
) -> None:
|
||||
if system.settings.chroma_api_impl == "chromadb.api.rust.RustBindingsAPI":
|
||||
# The rust impl does not use batch size
|
||||
return
|
||||
|
||||
sqlite = system.instance(SqliteDB)
|
||||
|
||||
if has_collection_mutated:
|
||||
# Must always keep one entry to avoid reusing seq_ids
|
||||
assert _total_embedding_queue_log_size(sqlite) >= 1
|
||||
|
||||
batch_size_sum = {
|
||||
collection.id: collection.metadata.get("hnsw:batch_size", 100)
|
||||
if collection.metadata is not None
|
||||
else 100
|
||||
for collection in collections
|
||||
}
|
||||
expected_sizes = {
|
||||
collection.id: collection.count() % batch_size_sum[collection.id] + 1
|
||||
for collection in collections
|
||||
}
|
||||
|
||||
actual_sizes = _total_embedding_queue_log_size_per_collection(
|
||||
system, collections
|
||||
)
|
||||
assert set(actual_sizes.keys()) == set(expected_sizes.keys())
|
||||
assert all(
|
||||
actual_sizes[collection.id] == expected_sizes[collection.id]
|
||||
for collection in collections
|
||||
)
|
||||
|
||||
else:
|
||||
assert _total_embedding_queue_log_size(sqlite) == 0
|
||||
|
||||
|
||||
@contextmanager
|
||||
def collection_deleted(client: ClientAPI, collection_name: str):
|
||||
# Invariant checks before deletion
|
||||
collection_names = [c.name for c in client.list_collections()]
|
||||
assert collection_name in collection_names
|
||||
collection = client.get_collection(collection_name)
|
||||
segments = []
|
||||
if isinstance(client._server, SegmentAPI): # type: ignore
|
||||
sysdb: SysDB = client._server._sysdb # type: ignore
|
||||
segments = sysdb.get_segments(collection=collection.id)
|
||||
segment_types = {}
|
||||
should_have_hnsw = False
|
||||
for segment in segments:
|
||||
segment_types[segment["type"]] = True
|
||||
if segment["type"] == SegmentType.HNSW_LOCAL_PERSISTED.value:
|
||||
sync_threshold = (
|
||||
collection.metadata["hnsw:sync_threshold"]
|
||||
if collection.metadata is not None
|
||||
and "hnsw:sync_threshold" in collection.metadata
|
||||
else 1000
|
||||
)
|
||||
if (
|
||||
collection.count() > sync_threshold
|
||||
): # we only check if vector segment dir exists if we've synced at least once
|
||||
should_have_hnsw = True
|
||||
assert os.path.exists(
|
||||
os.path.join(
|
||||
client.get_settings().persist_directory, str(segment["id"])
|
||||
)
|
||||
)
|
||||
if should_have_hnsw:
|
||||
assert segment_types[SegmentType.HNSW_LOCAL_PERSISTED.value]
|
||||
assert segment_types[SegmentType.SQLITE.value]
|
||||
|
||||
yield
|
||||
|
||||
# Invariant checks after deletion
|
||||
collection_names = [c.name for c in client.list_collections()]
|
||||
assert collection_name not in collection_names
|
||||
if len(segments) > 0:
|
||||
sysdb: SysDB = client._server._sysdb # type: ignore
|
||||
segments_after = sysdb.get_segments(collection=collection.id)
|
||||
assert len(segments_after) == 0
|
||||
for segment in segments:
|
||||
if segment["type"] == SegmentType.HNSW_LOCAL_PERSISTED.value:
|
||||
assert not os.path.exists(
|
||||
os.path.join(
|
||||
client.get_settings().persist_directory, str(segment["id"])
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,720 @@
|
||||
import hashlib
|
||||
import hypothesis
|
||||
import hypothesis.strategies as st
|
||||
from typing import Any, Optional, List, Dict, Union, cast
|
||||
from typing_extensions import TypedDict
|
||||
import uuid
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
import chromadb.api.types as types
|
||||
import re
|
||||
from hypothesis.strategies._internal.strategies import SearchStrategy
|
||||
from chromadb.test.conftest import NOT_CLUSTER_ONLY
|
||||
from dataclasses import dataclass
|
||||
from chromadb.api.types import (
|
||||
Documents,
|
||||
Embeddable,
|
||||
EmbeddingFunction,
|
||||
Embeddings,
|
||||
Metadata,
|
||||
)
|
||||
from chromadb.types import LiteralValue, WhereOperator, LogicalOperator
|
||||
from chromadb.test.conftest import is_spann_disabled_mode, skip_reason_spann_disabled
|
||||
from chromadb.api.collection_configuration import (
|
||||
CreateCollectionConfiguration,
|
||||
CreateSpannConfiguration,
|
||||
)
|
||||
|
||||
# Set the random seed for reproducibility
|
||||
np.random.seed(0) # unnecessary, hypothesis does this for us
|
||||
|
||||
# See Hypothesis documentation for creating strategies at
|
||||
# https://hypothesis.readthedocs.io/en/latest/data.html
|
||||
|
||||
# NOTE: Because these strategies are used in state machines, we need to
|
||||
# work around an issue with state machines, in which strategies that frequently
|
||||
# are marked as invalid (i.e. through the use of `assume` or `.filter`) can cause the
|
||||
# state machine tests to fail with an hypothesis.errors.Unsatisfiable.
|
||||
|
||||
# Ultimately this is because the entire state machine is run as a single Hypothesis
|
||||
# example, which ends up drawing from the same strategies an enormous number of times.
|
||||
# Whenever a strategy marks itself as invalid, Hypothesis tries to start the entire
|
||||
# state machine run over. See https://github.com/HypothesisWorks/hypothesis/issues/3618
|
||||
|
||||
# Because strategy generation is all interrelated, seemingly small changes (especially
|
||||
# ones called early in a test) can have an outside effect. Generating lists with
|
||||
# unique=True, or dictionaries with a min size seems especially bad.
|
||||
|
||||
# Please make changes to these strategies incrementally, testing to make sure they don't
|
||||
# start generating unsatisfiable examples.
|
||||
|
||||
test_hnsw_config = {
|
||||
"hnsw:construction_ef": 128,
|
||||
"hnsw:search_ef": 128,
|
||||
"hnsw:M": 128,
|
||||
}
|
||||
|
||||
|
||||
class RecordSet(TypedDict):
|
||||
"""
|
||||
A generated set of embeddings, ids, metadatas, and documents that
|
||||
represent what a user would pass to the API.
|
||||
"""
|
||||
|
||||
ids: Union[types.ID, List[types.ID]]
|
||||
embeddings: Optional[Union[types.Embeddings, types.Embedding]]
|
||||
metadatas: Optional[Union[List[Optional[types.Metadata]], types.Metadata]]
|
||||
documents: Optional[Union[List[types.Document], types.Document]]
|
||||
|
||||
|
||||
class NormalizedRecordSet(TypedDict):
|
||||
"""
|
||||
A RecordSet, with all fields normalized to lists.
|
||||
"""
|
||||
|
||||
ids: List[types.ID]
|
||||
embeddings: Optional[types.Embeddings]
|
||||
metadatas: Optional[List[Optional[types.Metadata]]]
|
||||
documents: Optional[List[types.Document]]
|
||||
|
||||
|
||||
class StateMachineRecordSet(TypedDict):
|
||||
"""
|
||||
Represents the internal state of a state machine in hypothesis tests.
|
||||
"""
|
||||
|
||||
ids: List[types.ID]
|
||||
embeddings: types.Embeddings
|
||||
metadatas: List[Optional[types.Metadata]]
|
||||
documents: List[Optional[types.Document]]
|
||||
|
||||
|
||||
class Record(TypedDict):
|
||||
"""
|
||||
A single generated record.
|
||||
"""
|
||||
|
||||
id: types.ID
|
||||
embedding: Optional[types.Embedding]
|
||||
metadata: Optional[types.Metadata]
|
||||
document: Optional[types.Document]
|
||||
|
||||
|
||||
# TODO: support arbitrary text everywhere so we don't SQL-inject ourselves.
|
||||
# TODO: support empty strings everywhere
|
||||
sql_alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"
|
||||
safe_text = st.text(alphabet=sql_alphabet, min_size=1)
|
||||
sql_alphabet_minus_underscore = (
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-"
|
||||
)
|
||||
safe_text_min_size_3 = st.text(alphabet=sql_alphabet_minus_underscore, min_size=3)
|
||||
tenant_database_name = st.text(alphabet=sql_alphabet, min_size=3)
|
||||
|
||||
# Workaround for FastAPI json encoding peculiarities
|
||||
# https://github.com/tiangolo/fastapi/blob/8ac8d70d52bb0dd9eb55ba4e22d3e383943da05c/fastapi/encoders.py#L104
|
||||
safe_text = safe_text.filter(lambda s: not s.startswith("_sa"))
|
||||
safe_text_min_size_3 = safe_text_min_size_3.filter(lambda s: not s.startswith("_sa"))
|
||||
tenant_database_name = tenant_database_name.filter(lambda s: not s.startswith("_sa"))
|
||||
|
||||
safe_integers = st.integers(
|
||||
min_value=-(2**31), max_value=2**31 - 1
|
||||
) # TODO: handle longs
|
||||
# In distributed chroma, floats are 32 bit hence we need to
|
||||
# restrict the generation to generate only 32 bit floats.
|
||||
safe_floats = st.floats(
|
||||
allow_infinity=False,
|
||||
allow_nan=False,
|
||||
allow_subnormal=False,
|
||||
width=32,
|
||||
min_value=-1e6,
|
||||
max_value=1e6,
|
||||
) # TODO: handle infinity and NAN
|
||||
|
||||
safe_values: List[SearchStrategy[Union[int, float, str, bool]]] = [
|
||||
safe_text,
|
||||
safe_integers,
|
||||
safe_floats,
|
||||
st.booleans(),
|
||||
]
|
||||
|
||||
|
||||
def one_or_both(
|
||||
strategy_a: st.SearchStrategy[Any], strategy_b: st.SearchStrategy[Any]
|
||||
) -> st.SearchStrategy[Any]:
|
||||
return st.one_of(
|
||||
st.tuples(strategy_a, strategy_b),
|
||||
st.tuples(strategy_a, st.none()),
|
||||
st.tuples(st.none(), strategy_b),
|
||||
)
|
||||
|
||||
|
||||
# Temporarily generate only these to avoid SQL formatting issues.
|
||||
legal_id_characters = (
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_./+"
|
||||
)
|
||||
|
||||
float_types = [np.float16, np.float32, np.float64]
|
||||
int_types = [np.int16, np.int32, np.int64] # TODO: handle int types
|
||||
|
||||
|
||||
@st.composite
|
||||
def collection_name(draw: st.DrawFn) -> str:
|
||||
_collection_name_re = re.compile(r"^[a-zA-Z][a-zA-Z0-9-]{1,60}[a-zA-Z0-9]$")
|
||||
_ipv4_address_re = re.compile(r"^([0-9]{1,3}\.){3}[0-9]{1,3}$")
|
||||
_two_periods_re = re.compile(r"\.\.")
|
||||
|
||||
name: str = draw(st.from_regex(_collection_name_re)).strip()
|
||||
hypothesis.assume(not _ipv4_address_re.match(name))
|
||||
hypothesis.assume(not _two_periods_re.search(name))
|
||||
|
||||
return name
|
||||
|
||||
|
||||
collection_metadata = st.one_of(
|
||||
st.none(), st.dictionaries(safe_text, st.one_of(*safe_values))
|
||||
)
|
||||
|
||||
|
||||
# TODO: Use a hypothesis strategy while maintaining embedding uniqueness
|
||||
# Or handle duplicate embeddings within a known epsilon
|
||||
def create_embeddings(
|
||||
dim: int,
|
||||
count: int,
|
||||
dtype: npt.DTypeLike,
|
||||
) -> types.Embeddings:
|
||||
embeddings: types.Embeddings = cast(
|
||||
types.Embeddings,
|
||||
(
|
||||
np.random.uniform(
|
||||
low=-1.0,
|
||||
high=1.0,
|
||||
size=(count, dim),
|
||||
)
|
||||
.astype(dtype)
|
||||
.tolist()
|
||||
),
|
||||
)
|
||||
|
||||
return embeddings
|
||||
|
||||
|
||||
def create_embeddings_ndarray(
|
||||
dim: int,
|
||||
count: int,
|
||||
dtype: npt.DTypeLike,
|
||||
) -> np.typing.NDArray[Any]:
|
||||
return np.random.uniform(
|
||||
low=-1.0,
|
||||
high=1.0,
|
||||
size=(count, dim),
|
||||
).astype(dtype)
|
||||
|
||||
|
||||
class hashing_embedding_function(types.EmbeddingFunction[Documents]):
|
||||
def __init__(self, dim: int, dtype: npt.DTypeLike) -> None:
|
||||
self.dim = dim
|
||||
self.dtype = dtype
|
||||
|
||||
def __call__(self, input: types.Documents) -> types.Embeddings:
|
||||
# Hash the texts and convert to hex strings
|
||||
hashed_texts = [
|
||||
list(hashlib.sha256(text.encode("utf-8")).hexdigest()) for text in input
|
||||
]
|
||||
# Pad with repetition, or truncate the hex strings to the desired dimension
|
||||
padded_texts = [
|
||||
text * (self.dim // len(text)) + text[: self.dim % len(text)]
|
||||
for text in hashed_texts
|
||||
]
|
||||
|
||||
# Convert the hex strings to dtype
|
||||
embeddings: types.Embeddings = [
|
||||
np.array([int(char, 16) / 15.0 for char in text], dtype=self.dtype)
|
||||
for text in padded_texts
|
||||
]
|
||||
|
||||
return embeddings
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"hashing_embedding_function(dim={self.dim}, dtype={self.dtype})"
|
||||
|
||||
|
||||
class not_implemented_embedding_function(types.EmbeddingFunction[Documents]):
|
||||
def __call__(self, input: Documents) -> Embeddings:
|
||||
assert False, "This embedding function is not implemented"
|
||||
|
||||
|
||||
def embedding_function_strategy(
|
||||
dim: int, dtype: npt.DTypeLike
|
||||
) -> st.SearchStrategy[types.EmbeddingFunction[Embeddable]]:
|
||||
return st.just(
|
||||
cast(EmbeddingFunction[Embeddable], hashing_embedding_function(dim, dtype))
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExternalCollection:
|
||||
"""
|
||||
An external view of a collection.
|
||||
|
||||
This strategy only contains information about a collection that a client of Chroma
|
||||
sees -- that is, it contains none of Chroma's internal bookkeeping. It should
|
||||
be used to test the API and client code.
|
||||
"""
|
||||
|
||||
name: str
|
||||
metadata: Optional[types.Metadata]
|
||||
embedding_function: Optional[types.EmbeddingFunction[Embeddable]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Collection(ExternalCollection):
|
||||
"""
|
||||
An internal view of a collection.
|
||||
|
||||
This strategy contains all the information Chroma uses internally to manage a
|
||||
collection. It is a superset of ExternalCollection and should be used to test
|
||||
internal Chroma logic.
|
||||
"""
|
||||
|
||||
id: uuid.UUID
|
||||
dimension: int
|
||||
dtype: npt.DTypeLike
|
||||
known_metadata_keys: types.Metadata
|
||||
known_document_keywords: List[str]
|
||||
has_documents: bool = False
|
||||
has_embeddings: bool = False
|
||||
collection_config: Optional[CreateCollectionConfiguration] = None
|
||||
|
||||
|
||||
@st.composite
|
||||
def collections(
|
||||
draw: st.DrawFn,
|
||||
add_filterable_data: bool = False,
|
||||
with_hnsw_params: bool = False,
|
||||
has_embeddings: Optional[bool] = None,
|
||||
has_documents: Optional[bool] = None,
|
||||
with_persistent_hnsw_params: st.SearchStrategy[bool] = st.just(False),
|
||||
max_hnsw_batch_size: int = 2000,
|
||||
max_hnsw_sync_threshold: int = 2000,
|
||||
) -> Collection:
|
||||
"""Strategy to generate a Collection object. If add_filterable_data is True, then known_metadata_keys and known_document_keywords will be populated with consistent data."""
|
||||
|
||||
assert not ((has_embeddings is False) and (has_documents is False))
|
||||
|
||||
name = draw(collection_name())
|
||||
metadata = draw(collection_metadata)
|
||||
dimension = draw(st.integers(min_value=2, max_value=2048))
|
||||
dtype = draw(st.sampled_from(float_types))
|
||||
|
||||
use_persistent_hnsw_params = draw(with_persistent_hnsw_params)
|
||||
|
||||
if use_persistent_hnsw_params and not with_hnsw_params:
|
||||
raise ValueError(
|
||||
"with_persistent_hnsw_params requires with_hnsw_params to be true"
|
||||
)
|
||||
|
||||
if with_hnsw_params:
|
||||
if metadata is None:
|
||||
metadata = {}
|
||||
metadata.update(test_hnsw_config)
|
||||
if use_persistent_hnsw_params:
|
||||
metadata["hnsw:sync_threshold"] = draw(
|
||||
st.integers(min_value=3, max_value=max_hnsw_sync_threshold)
|
||||
)
|
||||
metadata["hnsw:batch_size"] = draw(
|
||||
st.integers(
|
||||
min_value=3,
|
||||
max_value=min(
|
||||
[metadata["hnsw:sync_threshold"], max_hnsw_batch_size]
|
||||
),
|
||||
)
|
||||
)
|
||||
# Sometimes, select a space at random
|
||||
if draw(st.booleans()):
|
||||
# TODO: pull the distance functions from a source of truth that lives not
|
||||
# in tests once https://github.com/chroma-core/issues/issues/61 lands
|
||||
metadata["hnsw:space"] = draw(st.sampled_from(["cosine", "l2", "ip"]))
|
||||
|
||||
collection_config: Optional[CreateCollectionConfiguration] = None
|
||||
# Generate a spann config if in spann mode
|
||||
if not is_spann_disabled_mode:
|
||||
# Use metadata["hnsw:space"] if it exists, otherwise default to "l2"
|
||||
spann_space = metadata.get("hnsw:space", "l2") if metadata else "l2"
|
||||
|
||||
spann_config: CreateSpannConfiguration = {
|
||||
"space": spann_space,
|
||||
"write_nprobe": 4,
|
||||
"reassign_neighbor_count": 4
|
||||
}
|
||||
collection_config = {
|
||||
"spann": spann_config,
|
||||
}
|
||||
|
||||
known_metadata_keys: Dict[str, Union[int, str, float]] = {}
|
||||
if add_filterable_data:
|
||||
while len(known_metadata_keys) < 5:
|
||||
key = draw(safe_text)
|
||||
known_metadata_keys[key] = draw(st.one_of(*safe_values))
|
||||
|
||||
if has_documents is None:
|
||||
has_documents = draw(st.booleans())
|
||||
assert has_documents is not None
|
||||
# For cluster tests, we want to avoid generating documents and where_document
|
||||
# clauses of length < 3. We also don't want them to contain certan special
|
||||
# characters like _ and % that implicitly involve searching for a regex in sqlite.
|
||||
if not NOT_CLUSTER_ONLY:
|
||||
if has_documents and add_filterable_data:
|
||||
known_document_keywords = draw(
|
||||
st.lists(safe_text_min_size_3, min_size=5, max_size=5)
|
||||
)
|
||||
else:
|
||||
known_document_keywords = []
|
||||
else:
|
||||
if has_documents and add_filterable_data:
|
||||
known_document_keywords = draw(st.lists(safe_text, min_size=5, max_size=5))
|
||||
else:
|
||||
known_document_keywords = []
|
||||
|
||||
if not has_documents:
|
||||
has_embeddings = True
|
||||
else:
|
||||
if has_embeddings is None:
|
||||
has_embeddings = draw(st.booleans())
|
||||
assert has_embeddings is not None
|
||||
|
||||
embedding_function = draw(embedding_function_strategy(dimension, dtype))
|
||||
|
||||
return Collection(
|
||||
id=uuid.uuid4(),
|
||||
name=name,
|
||||
metadata=metadata,
|
||||
dimension=dimension,
|
||||
dtype=dtype,
|
||||
known_metadata_keys=known_metadata_keys,
|
||||
has_documents=has_documents,
|
||||
known_document_keywords=known_document_keywords,
|
||||
has_embeddings=has_embeddings,
|
||||
embedding_function=embedding_function,
|
||||
collection_config=collection_config
|
||||
)
|
||||
|
||||
|
||||
@st.composite
|
||||
def metadata(
|
||||
draw: st.DrawFn,
|
||||
collection: Collection,
|
||||
min_size: int = 0,
|
||||
max_size: Optional[int] = None,
|
||||
) -> Optional[types.Metadata]:
|
||||
"""Strategy for generating metadata that could be a part of the given collection"""
|
||||
# First draw a random dictionary.
|
||||
metadata: types.Metadata = draw(
|
||||
st.dictionaries(
|
||||
safe_text, st.one_of(*safe_values), min_size=min_size, max_size=max_size
|
||||
)
|
||||
)
|
||||
# Then, remove keys that overlap with the known keys for the coll
|
||||
# to avoid type errors when comparing.
|
||||
if collection.known_metadata_keys:
|
||||
for key in collection.known_metadata_keys.keys():
|
||||
if key in metadata:
|
||||
del metadata[key] # type: ignore
|
||||
# Finally, add in some of the known keys for the collection
|
||||
sampling_dict: Dict[str, st.SearchStrategy[Union[str, int, float]]] = {
|
||||
k: st.just(v) for k, v in collection.known_metadata_keys.items()
|
||||
}
|
||||
metadata.update(draw(st.fixed_dictionaries({}, optional=sampling_dict))) # type: ignore
|
||||
# We don't allow submitting empty metadata
|
||||
if metadata == {}:
|
||||
return None
|
||||
return metadata
|
||||
|
||||
|
||||
@st.composite
|
||||
def document(draw: st.DrawFn, collection: Collection) -> types.Document:
|
||||
"""Strategy for generating documents that could be a part of the given collection"""
|
||||
# For cluster tests, we want to avoid generating documents of length < 3.
|
||||
# We also don't want them to contain certan special
|
||||
# characters like _ and % that implicitly involve searching for a regex in sqlite.
|
||||
if not NOT_CLUSTER_ONLY:
|
||||
# Blacklist certain unicode characters that affect sqlite processing.
|
||||
# For example, the null (/x00) character makes sqlite stop processing a string.
|
||||
# Also, blacklist _ and % for cluster tests.
|
||||
blacklist_categories = ("Cc", "Cs", "Pc", "Po")
|
||||
if collection.known_document_keywords:
|
||||
known_words_st = st.sampled_from(collection.known_document_keywords)
|
||||
else:
|
||||
known_words_st = st.text(
|
||||
min_size=3,
|
||||
alphabet=st.characters(blacklist_categories=blacklist_categories), # type: ignore
|
||||
)
|
||||
|
||||
random_words_st = st.text(
|
||||
min_size=3, alphabet=st.characters(blacklist_categories=blacklist_categories) # type: ignore
|
||||
)
|
||||
words = draw(st.lists(st.one_of(known_words_st, random_words_st), min_size=1))
|
||||
return " ".join(words)
|
||||
|
||||
# Blacklist certain unicode characters that affect sqlite processing.
|
||||
# For example, the null (/x00) character makes sqlite stop processing a string.
|
||||
blacklist_categories = ("Cc", "Cs") # type: ignore
|
||||
if collection.known_document_keywords:
|
||||
known_words_st = st.sampled_from(collection.known_document_keywords)
|
||||
else:
|
||||
known_words_st = st.text(
|
||||
min_size=1,
|
||||
alphabet=st.characters(blacklist_categories=blacklist_categories), # type: ignore
|
||||
)
|
||||
|
||||
random_words_st = st.text(
|
||||
min_size=1, alphabet=st.characters(blacklist_categories=blacklist_categories) # type: ignore
|
||||
)
|
||||
words = draw(st.lists(st.one_of(known_words_st, random_words_st), min_size=1))
|
||||
return " ".join(words)
|
||||
|
||||
|
||||
@st.composite
|
||||
def recordsets(
|
||||
draw: st.DrawFn,
|
||||
collection_strategy: SearchStrategy[Collection] = collections(),
|
||||
id_strategy: SearchStrategy[str] = safe_text,
|
||||
min_size: int = 1,
|
||||
max_size: int = 50,
|
||||
# If num_unique_metadata is not None, then the number of metadata generations
|
||||
# will be the size of the record set. If set, the number of metadata
|
||||
# generations will be the value of num_unique_metadata.
|
||||
num_unique_metadata: Optional[int] = None,
|
||||
min_metadata_size: int = 0,
|
||||
max_metadata_size: Optional[int] = None,
|
||||
) -> RecordSet:
|
||||
collection = draw(collection_strategy)
|
||||
|
||||
ids = list(
|
||||
draw(st.lists(id_strategy, min_size=min_size, max_size=max_size, unique=True))
|
||||
)
|
||||
|
||||
embeddings: Optional[Embeddings] = None
|
||||
if collection.has_embeddings:
|
||||
embeddings = create_embeddings(collection.dimension, len(ids), collection.dtype)
|
||||
num_metadata = num_unique_metadata if num_unique_metadata is not None else len(ids)
|
||||
generated_metadatas = draw(
|
||||
st.lists(
|
||||
metadata(
|
||||
collection, min_size=min_metadata_size, max_size=max_metadata_size
|
||||
),
|
||||
min_size=num_metadata,
|
||||
max_size=num_metadata,
|
||||
)
|
||||
)
|
||||
metadatas = []
|
||||
for i in range(len(ids)):
|
||||
metadatas.append(generated_metadatas[i % len(generated_metadatas)])
|
||||
|
||||
documents: Optional[Documents] = None
|
||||
if collection.has_documents:
|
||||
documents = draw(
|
||||
st.lists(document(collection), min_size=len(ids), max_size=len(ids))
|
||||
)
|
||||
|
||||
# in the case where we have a single record, sometimes exercise
|
||||
# the code that handles individual values rather than lists.
|
||||
# In this case, any field may be a list or a single value.
|
||||
if len(ids) == 1:
|
||||
single_id: Union[str, List[str]] = ids[0] if draw(st.booleans()) else ids
|
||||
single_embedding = (
|
||||
embeddings[0]
|
||||
if embeddings is not None and draw(st.booleans())
|
||||
else embeddings
|
||||
)
|
||||
single_metadata: Union[Optional[Metadata], List[Optional[Metadata]]] = (
|
||||
metadatas[0] if draw(st.booleans()) else metadatas
|
||||
)
|
||||
single_document = (
|
||||
documents[0] if documents is not None and draw(st.booleans()) else documents
|
||||
)
|
||||
return {
|
||||
"ids": single_id,
|
||||
"embeddings": single_embedding,
|
||||
"metadatas": single_metadata,
|
||||
"documents": single_document,
|
||||
}
|
||||
return {
|
||||
"ids": ids,
|
||||
"embeddings": embeddings,
|
||||
"metadatas": metadatas,
|
||||
"documents": documents,
|
||||
}
|
||||
|
||||
|
||||
def opposite_value(value: LiteralValue) -> SearchStrategy[Any]:
|
||||
"""
|
||||
Returns a strategy that will generate all valid values except the input value - testing of $nin
|
||||
"""
|
||||
if isinstance(value, float):
|
||||
return safe_floats.filter(lambda x: x != value)
|
||||
elif isinstance(value, str):
|
||||
return safe_text.filter(lambda x: x != value)
|
||||
elif isinstance(value, bool):
|
||||
return st.booleans().filter(lambda x: x != value)
|
||||
elif isinstance(value, int):
|
||||
return st.integers(min_value=-(2**31), max_value=2**31 - 1).filter(
|
||||
lambda x: x != value
|
||||
)
|
||||
else:
|
||||
return st.from_type(type(value)).filter(lambda x: x != value)
|
||||
|
||||
|
||||
@st.composite
|
||||
def where_clause(draw: st.DrawFn, collection: Collection) -> types.Where:
|
||||
"""Generate a filter that could be used in a query against the given collection"""
|
||||
|
||||
known_keys = sorted(collection.known_metadata_keys.keys())
|
||||
|
||||
key = draw(st.sampled_from(known_keys))
|
||||
value = collection.known_metadata_keys[key]
|
||||
|
||||
legal_ops: List[Optional[str]] = [None]
|
||||
|
||||
if isinstance(value, bool):
|
||||
legal_ops.extend(["$eq", "$ne", "$in", "$nin"])
|
||||
elif isinstance(value, float):
|
||||
legal_ops.extend(["$gt", "$lt", "$lte", "$gte"])
|
||||
elif isinstance(value, int):
|
||||
legal_ops.extend(["$gt", "$lt", "$lte", "$gte", "$eq", "$ne", "$in", "$nin"])
|
||||
elif isinstance(value, str):
|
||||
legal_ops.extend(["$eq", "$ne", "$in", "$nin"])
|
||||
else:
|
||||
assert False, f"Unsupported type: {type(value)}"
|
||||
|
||||
if isinstance(value, float):
|
||||
# Add or subtract a small number to avoid floating point rounding errors
|
||||
value = value + draw(st.sampled_from([1e-6, -1e-6]))
|
||||
# Truncate to 32 bit
|
||||
value = float(np.float32(value))
|
||||
|
||||
op: WhereOperator = draw(st.sampled_from(legal_ops))
|
||||
|
||||
if op is None:
|
||||
return {key: value}
|
||||
elif op == "$in": # type: ignore
|
||||
if isinstance(value, str) and not value:
|
||||
return {}
|
||||
return {key: {op: [value, *[draw(opposite_value(value)) for _ in range(3)]]}}
|
||||
elif op == "$nin": # type: ignore
|
||||
if isinstance(value, str) and not value:
|
||||
return {}
|
||||
return {key: {op: [draw(opposite_value(value)) for _ in range(3)]}}
|
||||
else:
|
||||
return {key: {op: value}} # type: ignore
|
||||
|
||||
|
||||
@st.composite
|
||||
def where_doc_clause(draw: st.DrawFn, collection: Collection) -> types.WhereDocument:
|
||||
"""Generate a where_document filter that could be used against the given collection"""
|
||||
# For cluster tests, we want to avoid generating where_document
|
||||
# clauses of length < 3. We also don't want them to contain certan special
|
||||
# characters like _ and % that implicitly involve searching for a regex in sqlite.
|
||||
if not NOT_CLUSTER_ONLY:
|
||||
if collection.known_document_keywords:
|
||||
word = draw(st.sampled_from(collection.known_document_keywords))
|
||||
else:
|
||||
word = draw(safe_text_min_size_3)
|
||||
else:
|
||||
if collection.known_document_keywords:
|
||||
word = draw(st.sampled_from(collection.known_document_keywords))
|
||||
else:
|
||||
word = draw(safe_text)
|
||||
|
||||
# This is hacky, but the distributed system does not support $not_contains
|
||||
# so we need to avoid generating these operators for now in that case.
|
||||
# TODO: Remove this once the distributed system supports $not_contains
|
||||
op = draw(st.sampled_from(["$contains", "$not_contains"]))
|
||||
|
||||
if op == "$contains":
|
||||
return {"$contains": word}
|
||||
else:
|
||||
assert op == "$not_contains"
|
||||
return {"$not_contains": word}
|
||||
|
||||
|
||||
def binary_operator_clause(
|
||||
base_st: SearchStrategy[types.Where],
|
||||
) -> SearchStrategy[types.Where]:
|
||||
op: SearchStrategy[LogicalOperator] = st.sampled_from(["$and", "$or"])
|
||||
return st.dictionaries(
|
||||
keys=op,
|
||||
values=st.lists(base_st, max_size=2, min_size=2),
|
||||
min_size=1,
|
||||
max_size=1,
|
||||
)
|
||||
|
||||
|
||||
def binary_document_operator_clause(
|
||||
base_st: SearchStrategy[types.WhereDocument],
|
||||
) -> SearchStrategy[types.WhereDocument]:
|
||||
op: SearchStrategy[LogicalOperator] = st.sampled_from(["$and", "$or"])
|
||||
return st.dictionaries(
|
||||
keys=op,
|
||||
values=st.lists(base_st, max_size=2, min_size=2),
|
||||
min_size=1,
|
||||
max_size=1,
|
||||
)
|
||||
|
||||
|
||||
@st.composite
|
||||
def recursive_where_clause(draw: st.DrawFn, collection: Collection) -> types.Where:
|
||||
base_st = where_clause(collection)
|
||||
where: types.Where = draw(st.recursive(base_st, binary_operator_clause))
|
||||
return where
|
||||
|
||||
|
||||
@st.composite
|
||||
def recursive_where_doc_clause(
|
||||
draw: st.DrawFn, collection: Collection
|
||||
) -> types.WhereDocument:
|
||||
base_st = where_doc_clause(collection)
|
||||
where: types.WhereDocument = draw(
|
||||
st.recursive(base_st, binary_document_operator_clause)
|
||||
)
|
||||
return where
|
||||
|
||||
|
||||
class Filter(TypedDict):
|
||||
where: Optional[types.Where]
|
||||
ids: Optional[Union[str, List[str]]]
|
||||
where_document: Optional[types.WhereDocument]
|
||||
|
||||
|
||||
@st.composite
|
||||
def filters(
|
||||
draw: st.DrawFn,
|
||||
collection_st: st.SearchStrategy[Collection],
|
||||
recordset_st: st.SearchStrategy[RecordSet],
|
||||
include_all_ids: bool = False,
|
||||
) -> Filter:
|
||||
collection = draw(collection_st)
|
||||
recordset = draw(recordset_st)
|
||||
|
||||
where_clause = draw(st.one_of(st.none(), recursive_where_clause(collection)))
|
||||
where_document_clause = draw(
|
||||
st.one_of(st.none(), recursive_where_doc_clause(collection))
|
||||
)
|
||||
|
||||
ids: Optional[Union[List[types.ID], types.ID]]
|
||||
# Record sets can be a value instead of a list of values if there is only one record
|
||||
if isinstance(recordset["ids"], str):
|
||||
ids = [recordset["ids"]]
|
||||
else:
|
||||
ids = recordset["ids"]
|
||||
|
||||
if not include_all_ids:
|
||||
ids = draw(st.one_of(st.none(), st.lists(st.sampled_from(ids), min_size=1)))
|
||||
if ids is not None:
|
||||
# Remove duplicates since hypothesis samples with replacement
|
||||
ids = list(set(ids))
|
||||
|
||||
# Test both the single value list and the unwrapped single value case
|
||||
if ids is not None and len(ids) == 1 and draw(st.booleans()):
|
||||
ids = ids[0]
|
||||
|
||||
return {"where": where_clause, "where_document": where_document_clause, "ids": ids}
|
||||
@@ -0,0 +1,371 @@
|
||||
import uuid
|
||||
from random import randint
|
||||
from typing import cast, List, Any, Dict
|
||||
import hypothesis
|
||||
import numpy as np
|
||||
import pytest
|
||||
import hypothesis.strategies as st
|
||||
from hypothesis import given, settings
|
||||
from chromadb.api import ClientAPI
|
||||
from chromadb.api.types import Embeddings, Metadatas
|
||||
from chromadb.test.conftest import (
|
||||
NOT_CLUSTER_ONLY,
|
||||
override_hypothesis_profile,
|
||||
create_isolated_database,
|
||||
)
|
||||
import chromadb.test.property.strategies as strategies
|
||||
import chromadb.test.property.invariants as invariants
|
||||
from chromadb.test.utils.wait_for_version_increase import wait_for_version_increase
|
||||
from chromadb.utils.batch_utils import create_batches
|
||||
|
||||
|
||||
collection_st = st.shared(strategies.collections(with_hnsw_params=True), key="coll")
|
||||
|
||||
@given(
|
||||
collection=collection_st,
|
||||
record_set=strategies.recordsets(collection_st, min_size=1, max_size=5),
|
||||
)
|
||||
@settings(
|
||||
deadline=None,
|
||||
parent=override_hypothesis_profile(
|
||||
normal=hypothesis.settings(max_examples=500),
|
||||
fast=hypothesis.settings(max_examples=200),
|
||||
),
|
||||
max_examples=2
|
||||
)
|
||||
def test_add_miniscule(
|
||||
client: ClientAPI,
|
||||
collection: strategies.Collection,
|
||||
record_set: strategies.RecordSet,
|
||||
) -> None:
|
||||
if (
|
||||
client.get_settings().chroma_api_impl
|
||||
== "chromadb.api.async_fastapi.AsyncFastAPI"
|
||||
):
|
||||
pytest.skip(
|
||||
"TODO @jai, come back and debug why CI runners fail with async + sync"
|
||||
)
|
||||
_test_add(client, collection, record_set, True, always_compact=True)
|
||||
|
||||
|
||||
# Hypothesis tends to generate smaller values so we explicitly segregate the
|
||||
# the tests into tiers, Small, Medium. Hypothesis struggles to generate large
|
||||
# record sets so we explicitly create a large record set without using Hypothesis
|
||||
@given(
|
||||
collection=collection_st,
|
||||
record_set=strategies.recordsets(collection_st, min_size=1, max_size=500),
|
||||
should_compact=st.booleans(),
|
||||
)
|
||||
@settings(
|
||||
deadline=None,
|
||||
parent=override_hypothesis_profile(
|
||||
normal=hypothesis.settings(max_examples=500),
|
||||
fast=hypothesis.settings(max_examples=200),
|
||||
),
|
||||
)
|
||||
def test_add_small(
|
||||
client: ClientAPI,
|
||||
collection: strategies.Collection,
|
||||
record_set: strategies.RecordSet,
|
||||
should_compact: bool,
|
||||
) -> None:
|
||||
if (
|
||||
client.get_settings().chroma_api_impl
|
||||
== "chromadb.api.async_fastapi.AsyncFastAPI"
|
||||
):
|
||||
pytest.skip(
|
||||
"TODO @jai, come back and debug why CI runners fail with async + sync"
|
||||
)
|
||||
_test_add(client, collection, record_set, should_compact)
|
||||
|
||||
|
||||
@given(
|
||||
collection=collection_st,
|
||||
record_set=strategies.recordsets(
|
||||
collection_st,
|
||||
min_size=250,
|
||||
max_size=500,
|
||||
num_unique_metadata=5,
|
||||
min_metadata_size=1,
|
||||
max_metadata_size=5,
|
||||
),
|
||||
should_compact=st.booleans(),
|
||||
)
|
||||
@settings(
|
||||
deadline=None,
|
||||
parent=override_hypothesis_profile(
|
||||
normal=hypothesis.settings(max_examples=10),
|
||||
fast=hypothesis.settings(max_examples=5),
|
||||
),
|
||||
suppress_health_check=[
|
||||
hypothesis.HealthCheck.too_slow,
|
||||
hypothesis.HealthCheck.data_too_large,
|
||||
hypothesis.HealthCheck.large_base_example,
|
||||
hypothesis.HealthCheck.function_scoped_fixture,
|
||||
],
|
||||
)
|
||||
def test_add_medium(
|
||||
client: ClientAPI,
|
||||
collection: strategies.Collection,
|
||||
record_set: strategies.RecordSet,
|
||||
should_compact: bool,
|
||||
) -> None:
|
||||
if (
|
||||
client.get_settings().chroma_api_impl
|
||||
== "chromadb.api.async_fastapi.AsyncFastAPI"
|
||||
):
|
||||
pytest.skip(
|
||||
"TODO @jai, come back and debug why CI runners fail with async + sync"
|
||||
)
|
||||
# Cluster tests transmit their results over grpc, which has a payload limit
|
||||
# This breaks the ann_accuracy invariant by default, since
|
||||
# the vector reader returns a payload of dataset size. So we need to batch
|
||||
# the queries in the ann_accuracy invariant
|
||||
_test_add(client, collection, record_set, should_compact, batch_ann_accuracy=True)
|
||||
|
||||
|
||||
def _test_add(
|
||||
client: ClientAPI,
|
||||
collection: strategies.Collection,
|
||||
record_set: strategies.RecordSet,
|
||||
should_compact: bool,
|
||||
batch_ann_accuracy: bool = False,
|
||||
always_compact: bool = False,
|
||||
) -> None:
|
||||
create_isolated_database(client)
|
||||
|
||||
# TODO: Generative embedding functions
|
||||
coll = client.create_collection(
|
||||
name=collection.name,
|
||||
metadata=collection.metadata, # type: ignore
|
||||
embedding_function=collection.embedding_function,
|
||||
configuration=collection.collection_config,
|
||||
)
|
||||
initial_version = cast(int, coll.get_model()["version"])
|
||||
|
||||
normalized_record_set = invariants.wrap_all(record_set)
|
||||
|
||||
# TODO: The type of add() is incorrect as it does not allow for metadatas
|
||||
# like [{"a": 1}, None, {"a": 3}]
|
||||
for batch in create_batches(
|
||||
api=client,
|
||||
ids=cast(List[str], record_set["ids"]),
|
||||
embeddings=cast(Embeddings, record_set["embeddings"]),
|
||||
metadatas=cast(Metadatas, record_set["metadatas"]),
|
||||
documents=cast(List[str], record_set["documents"]),
|
||||
):
|
||||
coll.add(*batch)
|
||||
# Only wait for compaction if the size of the collection is
|
||||
# some minimal size
|
||||
if (
|
||||
not NOT_CLUSTER_ONLY
|
||||
and should_compact
|
||||
and (len(normalized_record_set["ids"]) > 10 or always_compact)
|
||||
):
|
||||
# Wait for the model to be updated
|
||||
wait_for_version_increase(client, collection.name, initial_version)
|
||||
|
||||
invariants.count(coll, cast(strategies.RecordSet, normalized_record_set))
|
||||
n_results = max(1, (len(normalized_record_set["ids"]) // 10))
|
||||
|
||||
if batch_ann_accuracy:
|
||||
batch_size = 10
|
||||
for i in range(0, len(normalized_record_set["ids"]), batch_size):
|
||||
invariants.ann_accuracy(
|
||||
coll,
|
||||
cast(strategies.RecordSet, normalized_record_set),
|
||||
n_results=n_results,
|
||||
embedding_function=collection.embedding_function,
|
||||
query_indices=list(
|
||||
range(i, min(i + batch_size, len(normalized_record_set["ids"])))
|
||||
),
|
||||
)
|
||||
else:
|
||||
invariants.ann_accuracy(
|
||||
coll,
|
||||
cast(strategies.RecordSet, normalized_record_set),
|
||||
n_results=n_results,
|
||||
embedding_function=collection.embedding_function,
|
||||
)
|
||||
|
||||
|
||||
# Hypothesis struggles to generate large record sets so we explicitly create
|
||||
# a large record set
|
||||
def create_large_recordset(
|
||||
min_size: int = 45000,
|
||||
max_size: int = 50000,
|
||||
) -> strategies.RecordSet:
|
||||
size = randint(min_size, max_size)
|
||||
|
||||
ids = [str(uuid.uuid4()) for _ in range(size)]
|
||||
metadatas = [{"some_key": f"{i}"} for i in range(size)]
|
||||
documents = [f"Document {i}" for i in range(size)]
|
||||
embeddings = [[1, 2, 3] for _ in range(size)]
|
||||
record_set: Dict[str, List[Any]] = {
|
||||
"ids": ids,
|
||||
"embeddings": cast(Embeddings, embeddings),
|
||||
"metadatas": metadatas,
|
||||
"documents": documents,
|
||||
}
|
||||
return cast(strategies.RecordSet, record_set)
|
||||
|
||||
|
||||
@given(collection=collection_st, should_compact=st.booleans())
|
||||
@settings(deadline=None, max_examples=5)
|
||||
def test_add_large(
|
||||
client: ClientAPI, collection: strategies.Collection, should_compact: bool
|
||||
) -> None:
|
||||
create_isolated_database(client)
|
||||
|
||||
if (
|
||||
client.get_settings().chroma_api_impl
|
||||
== "chromadb.api.async_fastapi.AsyncFastAPI"
|
||||
):
|
||||
pytest.skip(
|
||||
"TODO @jai, come back and debug why CI runners fail with async + sync"
|
||||
)
|
||||
|
||||
record_set = create_large_recordset(
|
||||
min_size=10000,
|
||||
max_size=50000,
|
||||
)
|
||||
coll = client.create_collection(
|
||||
name=collection.name,
|
||||
metadata=collection.metadata, # type: ignore
|
||||
embedding_function=collection.embedding_function,
|
||||
)
|
||||
normalized_record_set = invariants.wrap_all(record_set)
|
||||
initial_version = cast(int, coll.get_model()["version"])
|
||||
|
||||
for batch in create_batches(
|
||||
api=client,
|
||||
ids=cast(List[str], record_set["ids"]),
|
||||
embeddings=cast(Embeddings, record_set["embeddings"]),
|
||||
metadatas=cast(Metadatas, record_set["metadatas"]),
|
||||
documents=cast(List[str], record_set["documents"]),
|
||||
):
|
||||
coll.add(*batch)
|
||||
|
||||
if (
|
||||
not NOT_CLUSTER_ONLY
|
||||
and should_compact
|
||||
and len(normalized_record_set["ids"]) > 10
|
||||
):
|
||||
# Wait for the model to be updated, since the record set is larger, add some additional time
|
||||
wait_for_version_increase(
|
||||
client, collection.name, initial_version, additional_time=240
|
||||
)
|
||||
|
||||
invariants.count(coll, cast(strategies.RecordSet, normalized_record_set))
|
||||
|
||||
|
||||
@given(collection=collection_st)
|
||||
@settings(deadline=None, max_examples=1)
|
||||
def test_add_large_exceeding(
|
||||
client: ClientAPI, collection: strategies.Collection
|
||||
) -> None:
|
||||
create_isolated_database(client)
|
||||
|
||||
if (
|
||||
client.get_settings().chroma_api_impl
|
||||
== "chromadb.api.async_fastapi.AsyncFastAPI"
|
||||
):
|
||||
pytest.skip(
|
||||
"TODO @jai, come back and debug why CI runners fail with async + sync"
|
||||
)
|
||||
|
||||
record_set = create_large_recordset(
|
||||
min_size=client.get_max_batch_size(),
|
||||
max_size=client.get_max_batch_size()
|
||||
+ 100, # Exceed the max batch size by 100 records
|
||||
)
|
||||
coll = client.create_collection(
|
||||
name=collection.name,
|
||||
metadata=collection.metadata, # type: ignore
|
||||
embedding_function=collection.embedding_function,
|
||||
)
|
||||
|
||||
with pytest.raises(Exception) as e:
|
||||
coll.add(**record_set) # type: ignore[arg-type]
|
||||
assert "batch size" in str(e.value)
|
||||
|
||||
|
||||
# TODO: This test fails right now because the ids are not sorted by the input order
|
||||
@pytest.mark.xfail(
|
||||
reason="This is expected to fail right now. We should change the API to sort the \
|
||||
ids by input order."
|
||||
)
|
||||
def test_out_of_order_ids(client: ClientAPI) -> None:
|
||||
if (
|
||||
client.get_settings().chroma_api_impl
|
||||
== "chromadb.api.async_fastapi.AsyncFastAPI"
|
||||
):
|
||||
pytest.skip(
|
||||
"TODO @jai, come back and debug why CI runners fail with async + sync"
|
||||
)
|
||||
ooo_ids = [
|
||||
"40",
|
||||
"05",
|
||||
"8",
|
||||
"6",
|
||||
"10",
|
||||
"01",
|
||||
"00",
|
||||
"3",
|
||||
"04",
|
||||
"20",
|
||||
"02",
|
||||
"9",
|
||||
"30",
|
||||
"11",
|
||||
"13",
|
||||
"2",
|
||||
"0",
|
||||
"7",
|
||||
"06",
|
||||
"5",
|
||||
"50",
|
||||
"12",
|
||||
"03",
|
||||
"4",
|
||||
"1",
|
||||
]
|
||||
|
||||
coll = client.create_collection(
|
||||
"test", embedding_function=lambda input: [[1, 2, 3] for _ in input] # type: ignore
|
||||
)
|
||||
embeddings: Embeddings = [np.array([1, 2, 3]) for _ in ooo_ids]
|
||||
coll.add(ids=ooo_ids, embeddings=embeddings)
|
||||
get_ids = coll.get(ids=ooo_ids)["ids"]
|
||||
assert get_ids == ooo_ids
|
||||
|
||||
|
||||
def test_add_partial(client: ClientAPI) -> None:
|
||||
"""Tests adding a record set with some of the fields set to None."""
|
||||
|
||||
create_isolated_database(client)
|
||||
|
||||
if (
|
||||
client.get_settings().chroma_api_impl
|
||||
== "chromadb.api.async_fastapi.AsyncFastAPI"
|
||||
):
|
||||
pytest.skip(
|
||||
"TODO @jai, come back and debug why CI runners fail with async + sync"
|
||||
)
|
||||
|
||||
coll = client.create_collection("test")
|
||||
# TODO: We need to clean up the api types to support this typing
|
||||
coll.add(
|
||||
ids=["1", "2", "3"],
|
||||
# All embeddings must be provided, or else None - no partial lists allowed
|
||||
embeddings=[[1, 2, 3], [1, 2, 3], [1, 2, 3]], # type: ignore
|
||||
# Metadatas can always be partial
|
||||
metadatas=[{"a": 1}, None, {"a": 3}], # type: ignore
|
||||
# Documents are optional if embeddings are provided
|
||||
documents=["a", "b", None], # type: ignore
|
||||
)
|
||||
|
||||
results = coll.get()
|
||||
assert results["ids"] == ["1", "2", "3"]
|
||||
assert results["metadatas"] == [{"a": 1}, None, {"a": 3}]
|
||||
assert results["documents"] == ["a", "b", None]
|
||||
@@ -0,0 +1,91 @@
|
||||
from hypothesis import given, strategies as st
|
||||
from chromadb.api.types import (
|
||||
optional_embeddings_to_base64_strings,
|
||||
optional_base64_strings_to_embeddings,
|
||||
)
|
||||
import numpy as np
|
||||
import math
|
||||
|
||||
|
||||
@given(st.lists(st.lists(st.integers(min_value=-128, max_value=127))))
|
||||
def test_base64_conversion_is_identity_i8(embeddings) -> None: # type: ignore
|
||||
b64_strings = optional_embeddings_to_base64_strings(embeddings)
|
||||
assert b64_strings is not None
|
||||
assert len(b64_strings) == len(embeddings)
|
||||
decoded_embeddings = optional_base64_strings_to_embeddings(b64_strings)
|
||||
for orig, decoded in zip(embeddings, decoded_embeddings): # type: ignore
|
||||
np.testing.assert_allclose(orig, decoded, rtol=1e-6)
|
||||
|
||||
|
||||
@given(st.lists(st.lists(st.floats(width=16))))
|
||||
def test_base64_conversion_is_identity_f16(embeddings) -> None: # type: ignore
|
||||
b64_strings = optional_embeddings_to_base64_strings(embeddings)
|
||||
assert b64_strings is not None
|
||||
assert len(b64_strings) == len(embeddings)
|
||||
decoded_embeddings = optional_base64_strings_to_embeddings(b64_strings)
|
||||
for orig, decoded in zip(embeddings, decoded_embeddings): # type: ignore
|
||||
np.testing.assert_allclose(orig, decoded, rtol=1e-6)
|
||||
|
||||
|
||||
@given(st.lists(st.lists(st.floats(width=32))))
|
||||
def test_base64_conversion_is_identity_f32(embeddings) -> None: # type: ignore
|
||||
b64_strings = optional_embeddings_to_base64_strings(embeddings)
|
||||
assert b64_strings is not None
|
||||
assert len(b64_strings) == len(embeddings)
|
||||
decoded_embeddings = optional_base64_strings_to_embeddings(b64_strings)
|
||||
for orig, decoded in zip(embeddings, decoded_embeddings): # type: ignore
|
||||
np.testing.assert_allclose(orig, decoded, rtol=1e-6)
|
||||
|
||||
|
||||
@given(st.lists(st.lists(st.floats(width=64))))
|
||||
def test_base64_conversion_is_identity_f64(embeddings) -> None: # type: ignore
|
||||
b64_strings = optional_embeddings_to_base64_strings(embeddings)
|
||||
assert b64_strings is not None
|
||||
assert len(b64_strings) == len(embeddings)
|
||||
decoded_embeddings = optional_base64_strings_to_embeddings(b64_strings)
|
||||
|
||||
expected_embeddings = []
|
||||
for embedding in embeddings:
|
||||
expected_embedding = []
|
||||
for value in embedding:
|
||||
if math.isnan(value):
|
||||
expected_embedding.append(float("nan"))
|
||||
elif value > np.finfo(np.float32).max:
|
||||
expected_embedding.append(float("inf"))
|
||||
elif value < np.finfo(np.float32).min:
|
||||
expected_embedding.append(float("-inf"))
|
||||
else:
|
||||
f32_value = np.float32(value)
|
||||
expected_embedding.append(float(f32_value))
|
||||
expected_embeddings.append(expected_embedding)
|
||||
|
||||
for orig, decoded in zip(expected_embeddings, decoded_embeddings): # type: ignore
|
||||
np.testing.assert_allclose(orig, decoded, rtol=1e-6)
|
||||
|
||||
|
||||
@given(st.lists(st.lists(st.floats(width=32))))
|
||||
def test_base64_conversion_numpy_is_identity_f32(embeddings) -> None: # type: ignore
|
||||
b64_strings = optional_embeddings_to_base64_strings(
|
||||
[np.array(embedding, dtype=np.float32) for embedding in embeddings]
|
||||
)
|
||||
assert b64_strings is not None
|
||||
assert len(b64_strings) == len(embeddings)
|
||||
decoded_embeddings = optional_base64_strings_to_embeddings(b64_strings)
|
||||
|
||||
expected_embeddings = []
|
||||
for embedding in embeddings:
|
||||
expected_embedding = []
|
||||
for value in embedding:
|
||||
if math.isnan(value):
|
||||
expected_embedding.append(float("nan"))
|
||||
elif value > np.finfo(np.float32).max:
|
||||
expected_embedding.append(float("inf"))
|
||||
elif value < np.finfo(np.float32).min:
|
||||
expected_embedding.append(float("-inf"))
|
||||
else:
|
||||
f32_value = np.float32(value)
|
||||
expected_embedding.append(float(f32_value))
|
||||
expected_embeddings.append(expected_embedding)
|
||||
|
||||
for orig, decoded in zip(expected_embeddings, decoded_embeddings): # type: ignore
|
||||
np.testing.assert_allclose(orig, decoded, rtol=1e-6)
|
||||
@@ -0,0 +1,134 @@
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
from hypothesis import given, strategies as st
|
||||
|
||||
from chromadb.api.fastapi import FastAPI
|
||||
|
||||
|
||||
def hostname_strategy() -> st.SearchStrategy[str]:
|
||||
label = st.text(
|
||||
alphabet=st.characters(min_codepoint=97, max_codepoint=122),
|
||||
min_size=1,
|
||||
max_size=63,
|
||||
)
|
||||
return st.lists(label, min_size=1, max_size=3).map("-".join)
|
||||
|
||||
|
||||
tld_list = ["com", "org", "net", "edu"]
|
||||
|
||||
|
||||
def domain_strategy() -> st.SearchStrategy[str]:
|
||||
label = st.text(
|
||||
alphabet=st.characters(min_codepoint=97, max_codepoint=122),
|
||||
min_size=1,
|
||||
max_size=63,
|
||||
)
|
||||
tld = st.sampled_from(tld_list)
|
||||
return st.tuples(label, tld).map(".".join)
|
||||
|
||||
|
||||
port_strategy = st.one_of(st.integers(min_value=1, max_value=65535), st.none())
|
||||
|
||||
ssl_enabled_strategy = st.booleans()
|
||||
|
||||
|
||||
def url_path_strategy() -> st.SearchStrategy[str]:
|
||||
path_segment = st.text(
|
||||
alphabet=st.sampled_from("abcdefghijklmnopqrstuvwxyz/-_"),
|
||||
min_size=1,
|
||||
max_size=10,
|
||||
)
|
||||
return (
|
||||
st.lists(path_segment, min_size=1, max_size=5)
|
||||
.map("/".join)
|
||||
.map(lambda x: "/" + x)
|
||||
)
|
||||
|
||||
|
||||
def is_valid_url(url: str) -> bool:
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
return all([parsed.scheme, parsed.netloc])
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def generate_valid_domain_url() -> st.SearchStrategy[str]:
|
||||
return st.builds(
|
||||
lambda url_scheme, hostname, url_path: f"{url_scheme}{hostname}{url_path}",
|
||||
url_scheme=st.sampled_from(["http://", "https://"]),
|
||||
hostname=domain_strategy(),
|
||||
url_path=url_path_strategy(),
|
||||
)
|
||||
|
||||
|
||||
def generate_invalid_domain_url() -> st.SearchStrategy[str]:
|
||||
return st.builds(
|
||||
lambda url_scheme, hostname, url_path: f"{url_scheme}{hostname}{url_path}",
|
||||
url_scheme=st.builds(
|
||||
lambda scheme, suffix: f"{scheme}{suffix}",
|
||||
scheme=st.text(max_size=10),
|
||||
suffix=st.sampled_from(["://", ":///", ":////", ""]),
|
||||
),
|
||||
hostname=domain_strategy(),
|
||||
url_path=url_path_strategy(),
|
||||
)
|
||||
|
||||
|
||||
host_or_domain_strategy = st.one_of(
|
||||
generate_valid_domain_url(), domain_strategy(), st.sampled_from(["localhost"])
|
||||
)
|
||||
|
||||
|
||||
@given(
|
||||
hostname=host_or_domain_strategy,
|
||||
port=port_strategy,
|
||||
ssl_enabled=ssl_enabled_strategy,
|
||||
default_api_path=st.sampled_from(["/api/v1", "/api/v2", None]),
|
||||
)
|
||||
def test_url_resolve(
|
||||
hostname: str,
|
||||
port: Optional[int],
|
||||
ssl_enabled: bool,
|
||||
default_api_path: Optional[str],
|
||||
) -> None:
|
||||
_url = FastAPI.resolve_url(
|
||||
chroma_server_host=hostname,
|
||||
chroma_server_http_port=port,
|
||||
chroma_server_ssl_enabled=ssl_enabled,
|
||||
default_api_path=default_api_path,
|
||||
)
|
||||
assert is_valid_url(_url), f"Invalid URL: {_url}"
|
||||
assert (
|
||||
_url.startswith("https") if ssl_enabled else _url.startswith("http")
|
||||
), f"Invalid URL: {_url} - SSL Enabled: {ssl_enabled}"
|
||||
if hostname.startswith("http"):
|
||||
assert ":" + str(port) not in _url, f"Port in URL not expected: {_url}"
|
||||
else:
|
||||
assert ":" + str(port) in _url, f"Port in URL expected: {_url}"
|
||||
if default_api_path:
|
||||
assert _url.endswith(default_api_path), f"Invalid URL: {_url}"
|
||||
|
||||
|
||||
@given(
|
||||
hostname=generate_invalid_domain_url(),
|
||||
port=port_strategy,
|
||||
ssl_enabled=ssl_enabled_strategy,
|
||||
default_api_path=st.sampled_from(["/api/v1", "/api/v2", None]),
|
||||
)
|
||||
def test_resolve_invalid(
|
||||
hostname: str,
|
||||
port: Optional[int],
|
||||
ssl_enabled: bool,
|
||||
default_api_path: Optional[str],
|
||||
) -> None:
|
||||
with pytest.raises(ValueError) as e:
|
||||
FastAPI.resolve_url(
|
||||
chroma_server_host=hostname,
|
||||
chroma_server_http_port=port,
|
||||
chroma_server_ssl_enabled=ssl_enabled,
|
||||
default_api_path=default_api_path,
|
||||
)
|
||||
assert "Invalid URL" in str(e.value)
|
||||
@@ -0,0 +1,338 @@
|
||||
import pytest
|
||||
import logging
|
||||
import hypothesis.strategies as st
|
||||
from chromadb.test.property.invariants import check_metadata
|
||||
import chromadb.test.property.strategies as strategies
|
||||
from chromadb.api import ClientAPI
|
||||
import chromadb.api.types as types
|
||||
from hypothesis.stateful import (
|
||||
Bundle,
|
||||
RuleBasedStateMachine,
|
||||
rule,
|
||||
initialize,
|
||||
multiple,
|
||||
consumes,
|
||||
run_state_machine_as_test,
|
||||
MultipleResults,
|
||||
)
|
||||
import chromadb.test.property.invariants as invariants
|
||||
from typing import Any, Dict, Mapping, Optional
|
||||
import numpy
|
||||
from chromadb.test.property.strategies import hashing_embedding_function
|
||||
|
||||
|
||||
class CollectionStateMachine(RuleBasedStateMachine):
|
||||
collections: Bundle[strategies.ExternalCollection]
|
||||
_model: Dict[str, Optional[types.CollectionMetadata]]
|
||||
|
||||
collections = Bundle("collections")
|
||||
|
||||
def __init__(self, client: ClientAPI):
|
||||
super().__init__()
|
||||
self._model = {}
|
||||
self.client = client
|
||||
|
||||
@initialize()
|
||||
def initialize(self) -> None:
|
||||
self.client.reset()
|
||||
self._model = {}
|
||||
|
||||
@rule(target=collections, coll=strategies.collections())
|
||||
def create_coll(
|
||||
self, coll: strategies.ExternalCollection
|
||||
) -> MultipleResults[strategies.ExternalCollection]:
|
||||
# Metadata can either be None or a non-empty dict
|
||||
if coll.name in self.model or (
|
||||
coll.metadata is not None and len(coll.metadata) == 0
|
||||
):
|
||||
with pytest.raises(Exception):
|
||||
c = self.client.create_collection(
|
||||
name=coll.name,
|
||||
metadata=coll.metadata, # type: ignore[arg-type]
|
||||
embedding_function=coll.embedding_function,
|
||||
)
|
||||
return multiple()
|
||||
|
||||
c = self.client.create_collection(
|
||||
name=coll.name,
|
||||
metadata=coll.metadata, # type: ignore[arg-type]
|
||||
embedding_function=coll.embedding_function,
|
||||
)
|
||||
self.set_model(coll.name, coll.metadata) # type: ignore[arg-type]
|
||||
|
||||
assert c.name == coll.name
|
||||
check_metadata(self.model[coll.name], c.metadata)
|
||||
return multiple(coll)
|
||||
|
||||
@rule(coll=collections)
|
||||
def get_coll(self, coll: strategies.ExternalCollection) -> None:
|
||||
if coll.name in self.model:
|
||||
c = self.client.get_collection(name=coll.name)
|
||||
assert c.name == coll.name
|
||||
check_metadata(self.model[coll.name], c.metadata)
|
||||
else:
|
||||
with pytest.raises(Exception):
|
||||
self.client.get_collection(name=coll.name)
|
||||
|
||||
@rule(coll=consumes(collections))
|
||||
def delete_coll(self, coll: strategies.ExternalCollection) -> None:
|
||||
if coll.name in self.model:
|
||||
with invariants.collection_deleted(self.client, coll.name):
|
||||
self.client.delete_collection(name=coll.name)
|
||||
self.delete_from_model(coll.name)
|
||||
else:
|
||||
with pytest.raises(Exception):
|
||||
self.client.delete_collection(name=coll.name)
|
||||
|
||||
with pytest.raises(Exception):
|
||||
self.client.get_collection(name=coll.name)
|
||||
|
||||
@rule()
|
||||
def list_collections(self) -> None:
|
||||
colls = self.client.list_collections()
|
||||
assert len(colls) == len(self.model)
|
||||
for c in colls:
|
||||
assert c.name in self.model
|
||||
|
||||
# @rule for list_collections with limit and offset
|
||||
@rule(
|
||||
limit=st.integers(min_value=1, max_value=5),
|
||||
offset=st.integers(min_value=0, max_value=5),
|
||||
)
|
||||
def list_collections_with_limit_offset(self, limit: int, offset: int) -> None:
|
||||
colls = self.client.list_collections(limit=limit, offset=offset)
|
||||
total_collections = self.client.count_collections()
|
||||
|
||||
# get all collections
|
||||
all_colls = self.client.list_collections()
|
||||
# manually slice the collections based on the given limit and offset
|
||||
man_colls = all_colls[offset : offset + limit]
|
||||
|
||||
# given limit and offset, make various assertions regarding the total number of collections
|
||||
if limit + offset > total_collections:
|
||||
assert len(colls) == max(total_collections - offset, 0)
|
||||
# assert that our manually sliced collections are the same as the ones returned by the API
|
||||
assert colls == man_colls
|
||||
|
||||
else:
|
||||
assert len(colls) == limit
|
||||
|
||||
@rule(
|
||||
target=collections,
|
||||
new_metadata=st.one_of(st.none(), strategies.collection_metadata),
|
||||
coll=st.one_of(consumes(collections), strategies.collections()),
|
||||
)
|
||||
def get_or_create_coll(
|
||||
self,
|
||||
coll: strategies.ExternalCollection,
|
||||
new_metadata: Optional[types.Metadata],
|
||||
) -> MultipleResults[strategies.ExternalCollection]:
|
||||
# Cases for get_or_create
|
||||
|
||||
# Case 0
|
||||
# new_metadata is none, coll is an existing collection
|
||||
# get_or_create should return the existing collection with existing metadata
|
||||
|
||||
# Case 1
|
||||
# new_metadata is none, coll is a new collection
|
||||
# get_or_create should create a new collection with the metadata of None
|
||||
|
||||
# Case 2
|
||||
# new_metadata is not none, coll is an existing collection
|
||||
# get_or_create should return the existing collection with the original metadata
|
||||
|
||||
# Case 3
|
||||
# new_metadata is not none, coll is a new collection
|
||||
# get_or_create should create a new collection with the new metadata
|
||||
|
||||
if new_metadata is not None and len(new_metadata) == 0:
|
||||
with pytest.raises(Exception):
|
||||
c = self.client.get_or_create_collection(
|
||||
name=coll.name,
|
||||
metadata=new_metadata, # type: ignore[arg-type]
|
||||
embedding_function=coll.embedding_function,
|
||||
)
|
||||
return multiple()
|
||||
|
||||
# Update model
|
||||
if coll.name not in self.model:
|
||||
# Handles case 1 and 3
|
||||
coll.metadata = new_metadata
|
||||
self.set_model(coll.name, coll.metadata) # type: ignore[arg-type]
|
||||
|
||||
# Update API
|
||||
c = self.client.get_or_create_collection(
|
||||
name=coll.name,
|
||||
metadata=new_metadata, # type: ignore[arg-type]
|
||||
embedding_function=coll.embedding_function,
|
||||
)
|
||||
|
||||
# Check that model and API are in sync
|
||||
assert c.name == coll.name
|
||||
check_metadata(self.model[coll.name], c.metadata)
|
||||
return multiple(coll)
|
||||
|
||||
@rule(
|
||||
target=collections,
|
||||
coll=consumes(collections),
|
||||
new_metadata=strategies.collection_metadata,
|
||||
new_name=st.one_of(st.none(), strategies.collection_name()),
|
||||
)
|
||||
def modify_coll(
|
||||
self,
|
||||
coll: strategies.ExternalCollection,
|
||||
new_metadata: types.Metadata,
|
||||
new_name: Optional[str],
|
||||
) -> MultipleResults[strategies.ExternalCollection]:
|
||||
if coll.name not in self.model:
|
||||
with pytest.raises(Exception):
|
||||
c = self.client.get_collection(name=coll.name)
|
||||
return multiple()
|
||||
|
||||
c = self.client.get_collection(name=coll.name)
|
||||
_metadata: Optional[Mapping[str, Any]] = self.model[coll.name]
|
||||
_name: str = coll.name
|
||||
if new_metadata is not None:
|
||||
# Can't set metadata to an empty dict
|
||||
if len(new_metadata) == 0:
|
||||
with pytest.raises(Exception):
|
||||
c = self.client.get_or_create_collection(
|
||||
name=coll.name,
|
||||
metadata=new_metadata, # type: ignore[arg-type]
|
||||
embedding_function=coll.embedding_function,
|
||||
)
|
||||
return multiple()
|
||||
|
||||
coll.metadata = new_metadata
|
||||
_metadata = new_metadata
|
||||
|
||||
if new_name is not None:
|
||||
if new_name in self.model and new_name != coll.name:
|
||||
with pytest.raises(Exception):
|
||||
c.modify(metadata=new_metadata, name=new_name) # type: ignore[arg-type]
|
||||
return multiple()
|
||||
|
||||
self.delete_from_model(coll.name)
|
||||
coll.name = new_name
|
||||
_name = new_name
|
||||
|
||||
self.set_model(_name, _metadata) # type: ignore[arg-type]
|
||||
c.modify(metadata=_metadata, name=_name) # type: ignore[arg-type]
|
||||
c = self.client.get_collection(name=coll.name)
|
||||
|
||||
assert c.name == coll.name
|
||||
check_metadata(self.model[coll.name], c.metadata)
|
||||
return multiple(coll)
|
||||
|
||||
def set_model(
|
||||
self,
|
||||
name: str,
|
||||
metadata: Optional[types.CollectionMetadata],
|
||||
) -> None:
|
||||
model = self.model
|
||||
model[name] = metadata
|
||||
|
||||
def delete_from_model(self, name: str) -> None:
|
||||
model = self.model
|
||||
del model[name]
|
||||
|
||||
@property
|
||||
def model(self) -> Dict[str, Optional[types.CollectionMetadata]]:
|
||||
return self._model
|
||||
|
||||
|
||||
def test_collections(caplog: pytest.LogCaptureFixture, client: ClientAPI) -> None:
|
||||
caplog.set_level(logging.ERROR)
|
||||
run_state_machine_as_test(lambda: CollectionStateMachine(client)) # type: ignore
|
||||
|
||||
|
||||
# Below are tests that have failed in the past. If your test fails, please add
|
||||
# it to protect against regressions in the test harness itself. If you need
|
||||
# help doing so, talk to anton.
|
||||
|
||||
|
||||
def test_previously_failing_one(client: ClientAPI) -> None:
|
||||
state = CollectionStateMachine(client)
|
||||
state.initialize()
|
||||
# I don't know why the typechecker is red here. This code is correct and is
|
||||
# pulled from the logs.
|
||||
(v1,) = state.get_or_create_coll( # type: ignore[misc]
|
||||
coll=strategies.ExternalCollection(
|
||||
name="jjn2yjLW1zp2T",
|
||||
metadata=None,
|
||||
embedding_function=hashing_embedding_function(dtype=numpy.float32, dim=863), # type: ignore[arg-type]
|
||||
),
|
||||
new_metadata=None,
|
||||
)
|
||||
(v6,) = state.get_or_create_coll( # type: ignore[misc]
|
||||
coll=strategies.ExternalCollection(
|
||||
name="jjn2yjLW1zp2T",
|
||||
metadata=None,
|
||||
embedding_function=hashing_embedding_function(dtype=numpy.float32, dim=863), # type: ignore[arg-type]
|
||||
),
|
||||
new_metadata=None,
|
||||
)
|
||||
state.modify_coll(
|
||||
coll=v1, new_metadata={"7": -1281, "fGe": -0.0, "K5j": "im"}, new_name=None
|
||||
)
|
||||
state.modify_coll(coll=v6, new_metadata=None, new_name=None)
|
||||
|
||||
|
||||
# https://github.com/chroma-core/chroma/commit/cf476d70f0cebb7c87cb30c7172ba74d6ea175cd#diff-e81868b665d149bb315d86890dea6fc6a9fc9fc9ea3089aa7728142b54f622c5R210
|
||||
def test_previously_failing_two(client: ClientAPI) -> None:
|
||||
state = CollectionStateMachine(client)
|
||||
state.initialize()
|
||||
(v13,) = state.get_or_create_coll( # type: ignore[misc]
|
||||
coll=strategies.ExternalCollection(
|
||||
name="C1030",
|
||||
metadata={},
|
||||
embedding_function=hashing_embedding_function(dim=2, dtype=numpy.float32), # type: ignore[arg-type]
|
||||
),
|
||||
new_metadata=None,
|
||||
)
|
||||
(v15,) = state.modify_coll( # type: ignore[misc]
|
||||
coll=v13,
|
||||
new_metadata={
|
||||
"0": "10",
|
||||
"40": "0",
|
||||
"p1nviWeL7fO": "qN",
|
||||
"7b": "YS",
|
||||
"VYWq4LEMWjCo": True,
|
||||
},
|
||||
new_name="OF5F0MzbQg",
|
||||
)
|
||||
state.get_or_create_coll(
|
||||
coll=strategies.ExternalCollection(
|
||||
name="VS0QGh",
|
||||
metadata={
|
||||
"h": 5.681951615025145e-227,
|
||||
"A1": 61126,
|
||||
"uhUhLEEMfeC_kN": 2147483647,
|
||||
"weF": "pSP",
|
||||
"B3DSaP": False,
|
||||
"6H533K": 1.192092896e-07,
|
||||
},
|
||||
embedding_function=hashing_embedding_function( # type: ignore[arg-type]
|
||||
dim=1915, dtype=numpy.float32
|
||||
),
|
||||
),
|
||||
new_metadata={
|
||||
"xVW09xUpDZA": 31734,
|
||||
"g": 1.1,
|
||||
"n1dUTalF-MY": -1000000.0,
|
||||
"y": "G3EtXTZ",
|
||||
"ugXZ_hK": 5494,
|
||||
},
|
||||
)
|
||||
v17 = state.modify_coll( # noqa: F841
|
||||
coll=v15, new_metadata={"L35J2S": "K0l026"}, new_name="Ai1"
|
||||
)
|
||||
v18 = state.get_or_create_coll(coll=v13, new_metadata=None) # noqa: F841
|
||||
state.get_or_create_coll(
|
||||
coll=strategies.ExternalCollection(
|
||||
name="VS0QGh",
|
||||
metadata=None,
|
||||
embedding_function=hashing_embedding_function(dim=326, dtype=numpy.float16), # type: ignore[arg-type]
|
||||
),
|
||||
new_metadata=None,
|
||||
)
|
||||
@@ -0,0 +1,152 @@
|
||||
import logging
|
||||
from typing import Dict, Optional, Tuple
|
||||
import pytest
|
||||
from chromadb.api import AdminAPI
|
||||
import chromadb.api.types as types
|
||||
from chromadb.config import DEFAULT_DATABASE, DEFAULT_TENANT
|
||||
from chromadb.test.conftest import ClientFactories
|
||||
from chromadb.test.property.test_collections import CollectionStateMachine
|
||||
from hypothesis.stateful import (
|
||||
Bundle,
|
||||
rule,
|
||||
initialize,
|
||||
multiple,
|
||||
run_state_machine_as_test,
|
||||
MultipleResults,
|
||||
)
|
||||
import chromadb.test.property.strategies as strategies
|
||||
|
||||
|
||||
class TenantDatabaseCollectionStateMachine(CollectionStateMachine):
|
||||
"""A collection state machine test that includes tenant and database information,
|
||||
and switches between them."""
|
||||
|
||||
tenants: Bundle # [str]
|
||||
databases: Bundle # [Tuple[str, str]] # database to tenant it belongs to
|
||||
tenant_to_database_to_model: Dict[
|
||||
str, Dict[str, Dict[str, Optional[types.CollectionMetadata]]]
|
||||
]
|
||||
admin_client: AdminAPI
|
||||
curr_tenant: str
|
||||
curr_database: str
|
||||
|
||||
tenants = Bundle("tenants")
|
||||
databases = Bundle("databases")
|
||||
|
||||
def __init__(self, client_factories: ClientFactories):
|
||||
client = client_factories.create_client()
|
||||
super().__init__(client)
|
||||
self.client = client
|
||||
self.admin_client = client_factories.create_admin_client_from_system()
|
||||
|
||||
@initialize()
|
||||
def initialize(self) -> None:
|
||||
self.client.reset()
|
||||
self.tenant_to_database_to_model = {}
|
||||
self.curr_tenant = DEFAULT_TENANT
|
||||
self.curr_database = DEFAULT_DATABASE
|
||||
self.client.set_tenant(DEFAULT_TENANT, DEFAULT_DATABASE)
|
||||
self.set_tenant_model(self.curr_tenant, {})
|
||||
self.set_database_model_for_tenant(self.curr_tenant, self.curr_database, {})
|
||||
|
||||
@rule(target=tenants, name=strategies.tenant_database_name)
|
||||
def create_tenant(self, name: str) -> MultipleResults: # [str]:
|
||||
tenant = self.overwrite_tenant(name)
|
||||
# Check if tenant already exists
|
||||
if self.has_tenant(tenant):
|
||||
with pytest.raises(Exception):
|
||||
self.admin_client.create_tenant(tenant)
|
||||
return multiple()
|
||||
|
||||
self.admin_client.create_tenant(tenant)
|
||||
# When we create a tenant, create a default database for it just for testing
|
||||
# since the state machine could call collection operations before creating a
|
||||
# database
|
||||
self.admin_client.create_database(DEFAULT_DATABASE, tenant=tenant)
|
||||
self.set_tenant_model(tenant, {})
|
||||
self.set_database_model_for_tenant(tenant, DEFAULT_DATABASE, {})
|
||||
return multiple(tenant)
|
||||
|
||||
@rule(target=databases, name=strategies.tenant_database_name)
|
||||
def create_database(self, name: str) -> MultipleResults: # [Tuple[str, str]]:
|
||||
database = self.overwrite_database(name)
|
||||
tenant = self.overwrite_tenant(self.curr_tenant)
|
||||
# If database already exists in current tenant, raise an error
|
||||
if self.has_database_for_tenant(tenant, database):
|
||||
with pytest.raises(Exception):
|
||||
self.admin_client.create_database(name=database, tenant=tenant)
|
||||
return multiple()
|
||||
|
||||
self.admin_client.create_database(name=database, tenant=tenant)
|
||||
self.set_database_model_for_tenant(
|
||||
tenant=tenant, database=database, database_model={}
|
||||
)
|
||||
return multiple((database, tenant))
|
||||
|
||||
@rule(database=databases)
|
||||
def set_database_and_tenant(self, database: Tuple[str, str]) -> None:
|
||||
# Get a database and switch to the database and the tenant it belongs to
|
||||
database_name = database[0]
|
||||
tenant_name = database[1]
|
||||
self.set_api_tenant_database(tenant_name, database_name)
|
||||
self.curr_database = database_name
|
||||
self.curr_tenant = tenant_name
|
||||
|
||||
@rule(tenant=tenants)
|
||||
def set_tenant(self, tenant: str) -> None:
|
||||
self.set_api_tenant_database(tenant, DEFAULT_DATABASE)
|
||||
self.curr_tenant = tenant
|
||||
self.curr_database = DEFAULT_DATABASE
|
||||
|
||||
# These methods allow other tests, namely
|
||||
# test_collections_with_database_tenant_override.py, to swap out the model
|
||||
# without needing to do a bunch of pythonic cleverness to fake a dict which
|
||||
# preteds to have every key.
|
||||
def set_api_tenant_database(self, tenant: str, database: str) -> None:
|
||||
self.client.set_tenant(tenant, database)
|
||||
|
||||
# For calls to create_database, and create_tenant we may want to override the tenant and database
|
||||
# This is a leaky abstraction that exists soley for the purpose of
|
||||
# test_collections_with_database_tenant_override.py
|
||||
def overwrite_tenant(self, tenant: str) -> str:
|
||||
return tenant
|
||||
|
||||
def overwrite_database(self, database: str) -> str:
|
||||
return database
|
||||
|
||||
def has_tenant(self, tenant: str) -> bool:
|
||||
return tenant in self.tenant_to_database_to_model
|
||||
|
||||
def get_tenant_model(
|
||||
self, tenant: str
|
||||
) -> Dict[str, Dict[str, Optional[types.CollectionMetadata]]]:
|
||||
return self.tenant_to_database_to_model[tenant]
|
||||
|
||||
def set_tenant_model(
|
||||
self,
|
||||
tenant: str,
|
||||
model: Dict[str, Dict[str, Optional[types.CollectionMetadata]]],
|
||||
) -> None:
|
||||
self.tenant_to_database_to_model[tenant] = model
|
||||
|
||||
def has_database_for_tenant(self, tenant: str, database: str) -> bool:
|
||||
return database in self.tenant_to_database_to_model[tenant]
|
||||
|
||||
def set_database_model_for_tenant(
|
||||
self,
|
||||
tenant: str,
|
||||
database: str,
|
||||
database_model: Dict[str, Optional[types.CollectionMetadata]],
|
||||
) -> None:
|
||||
self.tenant_to_database_to_model[tenant][database] = database_model
|
||||
|
||||
@property
|
||||
def model(self) -> Dict[str, Optional[types.CollectionMetadata]]:
|
||||
return self.tenant_to_database_to_model[self.curr_tenant][self.curr_database]
|
||||
|
||||
|
||||
def test_collections(
|
||||
caplog: pytest.LogCaptureFixture, client_factories: ClientFactories
|
||||
) -> None:
|
||||
caplog.set_level(logging.ERROR)
|
||||
run_state_machine_as_test(lambda: TenantDatabaseCollectionStateMachine(client_factories)) # type: ignore
|
||||
@@ -0,0 +1,214 @@
|
||||
from typing import Dict, Optional, Tuple
|
||||
from overrides import overrides
|
||||
from hypothesis.stateful import (
|
||||
initialize,
|
||||
invariant,
|
||||
rule,
|
||||
run_state_machine_as_test,
|
||||
)
|
||||
|
||||
import uuid
|
||||
import logging
|
||||
import pytest
|
||||
from chromadb.api import AdminAPI
|
||||
from chromadb.api.client import AdminClient, Client
|
||||
from chromadb.config import Settings, System
|
||||
from chromadb.test.conftest import (
|
||||
ClientFactories,
|
||||
fastapi_fixture_admin_and_singleton_tenant_db_user,
|
||||
)
|
||||
from chromadb.test.property.test_collections_with_database_tenant import (
|
||||
TenantDatabaseCollectionStateMachine,
|
||||
)
|
||||
import chromadb.test.property.strategies as strategies
|
||||
import numpy
|
||||
import chromadb.api.types as types
|
||||
|
||||
# See conftest.py
|
||||
SINGLETON_TENANT = "singleton_tenant"
|
||||
SINGLETON_DATABASE = "singleton_database"
|
||||
|
||||
|
||||
class SingletonTenantDatabaseCollectionStateMachine(
|
||||
TenantDatabaseCollectionStateMachine
|
||||
):
|
||||
singleton_client: Client
|
||||
singleton_admin_client: AdminAPI
|
||||
root_client: Client
|
||||
root_admin_client: AdminAPI
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
singleton_client: Client,
|
||||
root_client: Client,
|
||||
client_factories: ClientFactories,
|
||||
) -> None:
|
||||
super().__init__(client_factories)
|
||||
self.root_client = root_client
|
||||
self.root_admin_client = self.admin_client
|
||||
|
||||
self.singleton_client = singleton_client
|
||||
self.singleton_admin_client = AdminClient.from_system(singleton_client._system)
|
||||
|
||||
@initialize()
|
||||
def initialize(self) -> None:
|
||||
# Make sure we're back to the root client and admin client before
|
||||
# doing reset/initialize things.
|
||||
self.client = self.root_client
|
||||
self.admin_client = self.root_admin_client
|
||||
|
||||
super().initialize()
|
||||
|
||||
self.root_admin_client.create_tenant(SINGLETON_TENANT)
|
||||
self.root_admin_client.create_database(SINGLETON_DATABASE, SINGLETON_TENANT)
|
||||
|
||||
self.set_tenant_model(SINGLETON_TENANT, {})
|
||||
self.set_database_model_for_tenant(SINGLETON_TENANT, SINGLETON_DATABASE, {})
|
||||
|
||||
@invariant()
|
||||
def check_api_and_admin_client_are_in_sync(self) -> None:
|
||||
if self.client == self.singleton_client:
|
||||
assert self.admin_client == self.singleton_admin_client
|
||||
else:
|
||||
assert self.admin_client == self.root_admin_client
|
||||
|
||||
@rule()
|
||||
def change_clients(self) -> None:
|
||||
if self.client == self.singleton_client:
|
||||
self.client = self.root_client
|
||||
self.admin_client = self.root_admin_client
|
||||
else:
|
||||
self.client = self.singleton_client
|
||||
self.admin_client = self.singleton_admin_client
|
||||
|
||||
@overrides
|
||||
def set_api_tenant_database(self, tenant: str, database: str) -> None:
|
||||
self.root_client.set_tenant(tenant, database)
|
||||
|
||||
@overrides
|
||||
def get_tenant_model(
|
||||
self, tenant: str
|
||||
) -> Dict[str, Dict[str, Optional[types.CollectionMetadata]]]:
|
||||
if self.client == self.singleton_client:
|
||||
tenant = SINGLETON_TENANT
|
||||
return self.tenant_to_database_to_model[tenant]
|
||||
|
||||
@overrides
|
||||
def set_tenant_model(
|
||||
self,
|
||||
tenant: str,
|
||||
model: Dict[str, Dict[str, Optional[types.CollectionMetadata]]],
|
||||
) -> None:
|
||||
if self.client == self.singleton_client:
|
||||
# This never happens because we never actually issue a
|
||||
# create_tenant call on singleton_tenant:
|
||||
# thanks to the above overriding of get_tenant_model(),
|
||||
# the underlying state machine test should always expect an error
|
||||
# when it sends the request, so shouldn't try to update the model.
|
||||
raise ValueError("trying to overwrite the model for singleton??")
|
||||
self.tenant_to_database_to_model[tenant] = model
|
||||
|
||||
@overrides
|
||||
def set_database_model_for_tenant(
|
||||
self,
|
||||
tenant: str,
|
||||
database: str,
|
||||
database_model: Dict[str, Optional[types.CollectionMetadata]],
|
||||
) -> None:
|
||||
if self.client == self.singleton_client:
|
||||
# This never happens because we never actually issue a
|
||||
# create_database call on (singleton_tenant, singleton_database):
|
||||
# thanks to the above overriding of has_database_for_tenant(),
|
||||
# the underlying state machine test should always expect an error
|
||||
# when it sends the request, so shouldn't try to update the model.
|
||||
raise ValueError("trying to overwrite the model for singleton??")
|
||||
self.tenant_to_database_to_model[tenant][database] = database_model
|
||||
|
||||
@overrides
|
||||
def overwrite_database(self, database: str) -> str:
|
||||
if self.client == self.singleton_client:
|
||||
return SINGLETON_DATABASE
|
||||
return database
|
||||
|
||||
@overrides
|
||||
def overwrite_tenant(self, tenant: str) -> str:
|
||||
if self.client == self.singleton_client:
|
||||
return SINGLETON_TENANT
|
||||
return tenant
|
||||
|
||||
@property
|
||||
def model(self) -> Dict[str, Optional[types.CollectionMetadata]]:
|
||||
if self.client == self.singleton_client:
|
||||
return self.tenant_to_database_to_model[SINGLETON_TENANT][
|
||||
SINGLETON_DATABASE
|
||||
]
|
||||
return self.tenant_to_database_to_model[self.curr_tenant][self.curr_database]
|
||||
|
||||
|
||||
def _singleton_and_root_clients() -> Tuple[Client, Client, ClientFactories]:
|
||||
api_fixture = fastapi_fixture_admin_and_singleton_tenant_db_user()
|
||||
sys: System = next(api_fixture)
|
||||
sys.reset_state()
|
||||
client_factories = ClientFactories(sys)
|
||||
root_client = client_factories.create_client()
|
||||
_root_admin_client = client_factories.create_admin_client_from_system()
|
||||
|
||||
# This is a little awkward but we have to create the tenant and DB
|
||||
# before we can instantiate a Client which connects to them. This also
|
||||
# means we need to manually populate state in the state machine.
|
||||
_root_admin_client.create_tenant(SINGLETON_TENANT)
|
||||
_root_admin_client.create_database(SINGLETON_DATABASE, SINGLETON_TENANT)
|
||||
|
||||
singleton_settings = Settings(**dict(sys.settings))
|
||||
singleton_settings.chroma_client_auth_credentials = "singleton-token"
|
||||
singleton_system = System(singleton_settings)
|
||||
singleton_system.start()
|
||||
singleton_client = Client.from_system(singleton_system)
|
||||
|
||||
return singleton_client, root_client, client_factories
|
||||
|
||||
|
||||
def test_collections_with_tenant_database_overwrite(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
caplog.set_level(logging.ERROR)
|
||||
|
||||
singleton_client, root_client, client_factories = _singleton_and_root_clients()
|
||||
run_state_machine_as_test(
|
||||
lambda: SingletonTenantDatabaseCollectionStateMachine(
|
||||
singleton_client, root_client, client_factories
|
||||
)
|
||||
) # type: ignore
|
||||
|
||||
|
||||
def test_repeat_failure(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
caplog.set_level(logging.ERROR)
|
||||
|
||||
singleton_client, root_client, client_factories = _singleton_and_root_clients()
|
||||
|
||||
state = SingletonTenantDatabaseCollectionStateMachine(
|
||||
singleton_client, root_client, client_factories
|
||||
)
|
||||
state.initialize()
|
||||
state.check_api_and_admin_client_are_in_sync()
|
||||
state.change_clients()
|
||||
state.check_api_and_admin_client_are_in_sync()
|
||||
state.create_coll(
|
||||
coll=strategies.Collection(
|
||||
name="A00",
|
||||
metadata=None,
|
||||
embedding_function=strategies.hashing_embedding_function(
|
||||
dim=2, dtype=numpy.float16 # type: ignore
|
||||
),
|
||||
id=uuid.UUID("c9bcb72f-92b1-4604-a8cb-084162dfe98b"),
|
||||
dimension=2,
|
||||
dtype=numpy.float16,
|
||||
known_metadata_keys={},
|
||||
known_document_keywords=[],
|
||||
has_documents=False,
|
||||
has_embeddings=True,
|
||||
)
|
||||
)
|
||||
state.teardown() # type: ignore
|
||||
@@ -0,0 +1,350 @@
|
||||
from multiprocessing.connection import Connection
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from typing import Generator, List, Tuple, Dict, Any, Callable, Type
|
||||
from hypothesis import given, settings
|
||||
import hypothesis.strategies as st
|
||||
import pytest
|
||||
import json
|
||||
from urllib import request
|
||||
from chromadb import config
|
||||
from chromadb.api.configuration import (
|
||||
ConfigurationParameter,
|
||||
EmbeddingsQueueConfigurationInternal,
|
||||
)
|
||||
from chromadb.api.types import Documents, EmbeddingFunction, Embeddings
|
||||
from chromadb.db.impl.sqlite import SqliteDB
|
||||
from chromadb.ingest.impl.utils import trigger_vector_segments_max_seq_id_migration
|
||||
from chromadb.segment import SegmentManager
|
||||
from chromadb.segment.impl.manager.local import LocalSegmentManager
|
||||
import chromadb.test.property.strategies as strategies
|
||||
import chromadb.test.property.invariants as invariants
|
||||
from packaging import version as packaging_version
|
||||
import re
|
||||
import multiprocessing
|
||||
from chromadb.config import Settings
|
||||
from chromadb.api.client import Client as ClientCreator
|
||||
from chromadb.test.utils.cross_version import (
|
||||
switch_to_version,
|
||||
install_version,
|
||||
get_path_to_version_install,
|
||||
)
|
||||
|
||||
# Minimum persisted version we support, and other substantial change versions
|
||||
# 0.4.1 is the first version with persistence
|
||||
# 0.5.3 is the first version with the new API where the serverapi and client api return types and arguments differ
|
||||
BASELINE_VERSIONS = ["0.4.1", "0.5.3"]
|
||||
version_re = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$")
|
||||
|
||||
# Some modules do not work across versions, since we upgrade our support for them, and should be explicitly reimported in the subprocess
|
||||
VERSIONED_MODULES = ["pydantic", "numpy", "tokenizers"]
|
||||
|
||||
|
||||
def versions() -> List[str]:
|
||||
"""Returns the pinned minimum version and the latest version of chromadb."""
|
||||
url = "https://pypi.org/pypi/chromadb/json"
|
||||
data = json.load(request.urlopen(request.Request(url)))
|
||||
versions = list(data["releases"].keys())
|
||||
# Older versions on pypi contain "devXYZ" suffixes
|
||||
versions = [v for v in versions if version_re.match(v)]
|
||||
versions.sort(key=packaging_version.Version)
|
||||
return BASELINE_VERSIONS + [versions[-1]]
|
||||
|
||||
|
||||
def _bool_to_int(metadata: Dict[str, Any]) -> Dict[str, Any]:
|
||||
metadata.update((k, 1) for k, v in metadata.items() if v is True)
|
||||
metadata.update((k, 0) for k, v in metadata.items() if v is False)
|
||||
return metadata
|
||||
|
||||
|
||||
def _patch_boolean_metadata(
|
||||
collection: strategies.Collection,
|
||||
embeddings: strategies.RecordSet,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
# Since the old version does not support boolean value metadata, we will convert
|
||||
# boolean value metadata to int
|
||||
collection_metadata = collection.metadata
|
||||
if collection_metadata is not None:
|
||||
_bool_to_int(collection_metadata) # type: ignore
|
||||
|
||||
if embeddings["metadatas"] is not None:
|
||||
if isinstance(embeddings["metadatas"], list):
|
||||
for metadata in embeddings["metadatas"]:
|
||||
if metadata is not None and isinstance(metadata, dict):
|
||||
_bool_to_int(metadata)
|
||||
elif isinstance(embeddings["metadatas"], dict):
|
||||
metadata = embeddings["metadatas"]
|
||||
_bool_to_int(metadata)
|
||||
|
||||
|
||||
def _patch_telemetry_client(
|
||||
collection: strategies.Collection,
|
||||
embeddings: strategies.RecordSet,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
# chroma 0.4.14 added OpenTelemetry, distinct from ProductTelemetry. Before 0.4.14
|
||||
# ProductTelemetry was simply called Telemetry.
|
||||
settings.chroma_telemetry_impl = "chromadb.telemetry.posthog.Posthog"
|
||||
|
||||
|
||||
version_patches: List[
|
||||
Tuple[str, Callable[[strategies.Collection, strategies.RecordSet, Settings], None]]
|
||||
] = [
|
||||
("0.4.3", _patch_boolean_metadata),
|
||||
("0.4.14", _patch_telemetry_client),
|
||||
]
|
||||
|
||||
|
||||
def patch_for_version(
|
||||
version: str,
|
||||
collection: strategies.Collection,
|
||||
embeddings: strategies.RecordSet,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
"""Override aspects of the collection and embeddings, before testing, to account for
|
||||
breaking changes in old versions."""
|
||||
|
||||
for patch_version, patch in version_patches:
|
||||
if packaging_version.Version(version) <= packaging_version.Version(
|
||||
patch_version
|
||||
):
|
||||
patch(collection, embeddings, settings)
|
||||
|
||||
|
||||
def api_import_for_version(module: Any, version: str) -> Type: # type: ignore
|
||||
if packaging_version.Version(version) <= packaging_version.Version("0.4.14"):
|
||||
return module.api.API # type: ignore
|
||||
return module.api.ServerAPI # type: ignore
|
||||
|
||||
|
||||
def configurations(versions: List[str]) -> List[Tuple[str, Settings]]:
|
||||
return [
|
||||
(
|
||||
version,
|
||||
Settings(
|
||||
chroma_api_impl="chromadb.api.rust.RustBindingsAPI"
|
||||
if "CHROMA_RUST_BINDINGS_TEST_ONLY" in os.environ
|
||||
else "chromadb.api.segment.SegmentAPI",
|
||||
chroma_sysdb_impl="chromadb.db.impl.sqlite.SqliteDB",
|
||||
chroma_producer_impl="chromadb.db.impl.sqlite.SqliteDB",
|
||||
chroma_consumer_impl="chromadb.db.impl.sqlite.SqliteDB",
|
||||
chroma_segment_manager_impl="chromadb.segment.impl.manager.local.LocalSegmentManager",
|
||||
allow_reset=True,
|
||||
is_persistent=True,
|
||||
persist_directory=tempfile.mkdtemp(),
|
||||
),
|
||||
)
|
||||
for version in versions
|
||||
]
|
||||
|
||||
|
||||
test_old_versions = versions()
|
||||
base_install_dir = tempfile.mkdtemp()
|
||||
|
||||
|
||||
# This fixture is not shared with the rest of the tests because it is unique in how it
|
||||
# installs the versions of chromadb
|
||||
@pytest.fixture(scope="module", params=configurations(test_old_versions)) # type: ignore
|
||||
def version_settings(request) -> Generator[Tuple[str, Settings], None, None]:
|
||||
configuration = request.param
|
||||
version = configuration[0]
|
||||
|
||||
install_version(version, {})
|
||||
yield configuration
|
||||
# Cleanup the installed version
|
||||
path = get_path_to_version_install(version)
|
||||
shutil.rmtree(path)
|
||||
# Cleanup the persisted data
|
||||
data_path = configuration[1].persist_directory
|
||||
if os.path.exists(data_path):
|
||||
shutil.rmtree(data_path, ignore_errors=True)
|
||||
|
||||
|
||||
class not_implemented_ef(EmbeddingFunction[Documents]):
|
||||
def __call__(self, input: Documents) -> Embeddings:
|
||||
assert False, "Embedding function should not be called"
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def persist_generated_data_with_old_version(
|
||||
version: str,
|
||||
settings: Settings,
|
||||
collection_strategy: strategies.Collection,
|
||||
embeddings_strategy: strategies.RecordSet,
|
||||
conn: Connection,
|
||||
) -> None:
|
||||
try:
|
||||
old_module = switch_to_version(version, VERSIONED_MODULES)
|
||||
# In 0.7.0 we switch to Rust client. The old versions are using the the python SegmentAPI client
|
||||
if "CHROMA_RUST_BINDINGS_TEST_ONLY" in os.environ and packaging_version.Version(
|
||||
version
|
||||
) < packaging_version.Version("0.7.0"):
|
||||
settings.chroma_api_impl = "chromadb.api.segment.SegmentAPI"
|
||||
system = old_module.config.System(settings)
|
||||
api = system.instance(api_import_for_version(old_module, version))
|
||||
system.start()
|
||||
|
||||
api.reset()
|
||||
# In 0.5.4 we changed the API of the server api level to
|
||||
# deal with collection models instead of collections
|
||||
# in order to work with this we need to wrap the api in a client
|
||||
# for versions greater than or equal to 0.5.4
|
||||
if packaging_version.Version(version) >= packaging_version.Version("0.5.4"):
|
||||
api = old_module.api.client.Client.from_system(system)
|
||||
coll = api.create_collection(
|
||||
name=collection_strategy.name,
|
||||
metadata=collection_strategy.metadata,
|
||||
# In order to test old versions, we can't rely on the not_implemented function
|
||||
embedding_function=not_implemented_ef(),
|
||||
)
|
||||
coll.add(**embeddings_strategy)
|
||||
|
||||
# Just use some basic checks for sanity and manual testing where you break the new
|
||||
# version
|
||||
|
||||
check_embeddings = invariants.wrap_all(embeddings_strategy)
|
||||
# Check count
|
||||
assert coll.count() == len(check_embeddings["embeddings"] or [])
|
||||
# Check ids
|
||||
result = coll.get()
|
||||
actual_ids = result["ids"]
|
||||
embedding_id_to_index = {id: i for i, id in enumerate(check_embeddings["ids"])}
|
||||
actual_ids = sorted(actual_ids, key=lambda id: embedding_id_to_index[id])
|
||||
assert actual_ids == check_embeddings["ids"]
|
||||
|
||||
# Leave writes on the queue to be processed by the next version's
|
||||
# segment manager so we can test cross version serialization
|
||||
# compatibility.
|
||||
system.instance(LocalSegmentManager).stop()
|
||||
coll.upsert(**embeddings_strategy)
|
||||
|
||||
# Shutdown system
|
||||
system.stop()
|
||||
except Exception as e:
|
||||
conn.send(e)
|
||||
raise e
|
||||
|
||||
|
||||
# Since we can't pickle the embedding function, we always generate record sets with embeddings
|
||||
collection_st: st.SearchStrategy[strategies.Collection] = st.shared(
|
||||
strategies.collections(
|
||||
with_hnsw_params=True,
|
||||
has_embeddings=True,
|
||||
# By default, these are set to 2000, which makes it unlikely that index mutations will ever be fully flushed
|
||||
max_hnsw_sync_threshold=10,
|
||||
max_hnsw_batch_size=10,
|
||||
with_persistent_hnsw_params=st.booleans(),
|
||||
),
|
||||
key="coll",
|
||||
)
|
||||
|
||||
|
||||
@given(
|
||||
collection_strategy=collection_st,
|
||||
embeddings_strategy=strategies.recordsets(collection_st, max_size=200),
|
||||
)
|
||||
@settings(deadline=None)
|
||||
def test_cycle_versions(
|
||||
version_settings: Tuple[str, Settings],
|
||||
collection_strategy: strategies.Collection,
|
||||
embeddings_strategy: strategies.RecordSet,
|
||||
) -> None:
|
||||
# Test backwards compatibility
|
||||
# For the current version, ensure that we can load a collection from
|
||||
# the previous versions
|
||||
version, settings = version_settings
|
||||
# The strategies can generate metadatas of malformed inputs. Other tests
|
||||
# will error check and cover these cases to make sure they error. Here we
|
||||
# just convert them to valid values since the error cases are already tested
|
||||
if embeddings_strategy["metadatas"] == {}:
|
||||
embeddings_strategy["metadatas"] = None
|
||||
if embeddings_strategy["metadatas"] is not None and isinstance(
|
||||
embeddings_strategy["metadatas"], list
|
||||
):
|
||||
embeddings_strategy["metadatas"] = [
|
||||
m if m is None or len(m) > 0 else None
|
||||
for m in embeddings_strategy["metadatas"]
|
||||
]
|
||||
|
||||
patch_for_version(version, collection_strategy, embeddings_strategy, settings)
|
||||
|
||||
# Can't pickle a function, and we won't need them
|
||||
collection_strategy.embedding_function = None
|
||||
collection_strategy.known_metadata_keys = {}
|
||||
|
||||
# Run the task in a separate process to avoid polluting the current process
|
||||
# with the old version. Using spawn instead of fork to avoid sharing the
|
||||
# current process memory which would cause the old version to be loaded
|
||||
ctx = multiprocessing.get_context("spawn")
|
||||
conn1, conn2 = multiprocessing.Pipe()
|
||||
p = ctx.Process(
|
||||
target=persist_generated_data_with_old_version,
|
||||
args=(version, settings, collection_strategy, embeddings_strategy, conn2),
|
||||
)
|
||||
p.start()
|
||||
p.join()
|
||||
|
||||
if conn1.poll():
|
||||
e = conn1.recv()
|
||||
raise e
|
||||
|
||||
p.close()
|
||||
|
||||
# Switch to the current version (local working directory) and check the invariants
|
||||
# are preserved for the collection
|
||||
system = config.System(settings)
|
||||
system.start()
|
||||
client = ClientCreator.from_system(system)
|
||||
coll = client.get_collection(
|
||||
name=collection_strategy.name,
|
||||
embedding_function=not_implemented_ef(), # type: ignore
|
||||
)
|
||||
|
||||
embeddings_queue = system.instance(SqliteDB)
|
||||
|
||||
# Automatic pruning should be disabled since embeddings_queue is non-empty
|
||||
if packaging_version.Version(version) < packaging_version.Version(
|
||||
"0.5.7"
|
||||
): # (automatic pruning is enabled by default in 0.5.7 and later)
|
||||
assert (
|
||||
embeddings_queue.config.get_parameter("automatically_purge").value is False
|
||||
)
|
||||
|
||||
# Update to True so log_size_below_max() invariant will pass
|
||||
embeddings_queue.set_config(
|
||||
EmbeddingsQueueConfigurationInternal(
|
||||
[ConfigurationParameter("automatically_purge", True)]
|
||||
)
|
||||
)
|
||||
|
||||
# Should be able to clean log immediately after updating
|
||||
|
||||
# 07/29/24: the max_seq_id for vector segments was moved from the pickled metadata file to SQLite.
|
||||
# Cleaning the log is dependent on vector segments migrating their max_seq_id from the pickled metadata file to SQLite.
|
||||
# Vector segments migrate this field automatically on init, but at this point the segment has not been loaded yet.
|
||||
if "CHROMA_RUST_BINDINGS_TEST_ONLY" in os.environ:
|
||||
# Trigger log purge in Rust impl
|
||||
invariants.count(coll, embeddings_strategy)
|
||||
else:
|
||||
trigger_vector_segments_max_seq_id_migration(
|
||||
embeddings_queue, system.instance(SegmentManager)
|
||||
)
|
||||
embeddings_queue.purge_log(coll.id)
|
||||
invariants.log_size_below_max(system, [coll], True)
|
||||
|
||||
# Should be able to add embeddings
|
||||
coll.add(**embeddings_strategy) # type: ignore
|
||||
|
||||
invariants.count(coll, embeddings_strategy)
|
||||
invariants.metadatas_match(coll, embeddings_strategy)
|
||||
invariants.documents_match(coll, embeddings_strategy)
|
||||
invariants.ids_match(coll, embeddings_strategy)
|
||||
invariants.ann_accuracy(coll, embeddings_strategy)
|
||||
invariants.log_size_below_max(system, [coll], True)
|
||||
|
||||
# Shutdown system
|
||||
system.stop()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,839 @@
|
||||
from typing import Any, Dict, List, Optional, cast
|
||||
import uuid
|
||||
from hypothesis import example, given, settings, HealthCheck
|
||||
import pytest
|
||||
from typing import cast
|
||||
from chromadb.api import ClientAPI
|
||||
from chromadb.test.property import invariants
|
||||
from chromadb.api.types import (
|
||||
Document,
|
||||
Documents,
|
||||
Embedding,
|
||||
Embeddings,
|
||||
GetResult,
|
||||
IDs,
|
||||
Metadata,
|
||||
Metadatas,
|
||||
Where,
|
||||
WhereDocument,
|
||||
)
|
||||
from chromadb.test.conftest import reset, NOT_CLUSTER_ONLY
|
||||
import chromadb.test.property.strategies as strategies
|
||||
import hypothesis.strategies as st
|
||||
from chromadb.execution.expression.plan import Search
|
||||
from chromadb.execution.expression.operator import Knn, In, Key, Eq, And, Or, Contains, NotContains
|
||||
import logging
|
||||
from chromadb.test.utils.wait_for_version_increase import wait_for_version_increase
|
||||
import numpy as np
|
||||
from chromadb.api.models.Collection import Collection
|
||||
from chromadb.execution.expression.operator import Where as WhereExpr
|
||||
|
||||
|
||||
def _filter_where_clause(clause: Where, metadata: Optional[Metadata]) -> bool:
|
||||
"""Return true if the where clause is true for the given metadata map"""
|
||||
metadata = metadata or dict()
|
||||
key, expr = list(clause.items())[0]
|
||||
|
||||
# Handle the shorthand for equal: {key: val} where val is a simple value
|
||||
if (
|
||||
isinstance(expr, str)
|
||||
or isinstance(expr, bool)
|
||||
or isinstance(expr, int)
|
||||
or isinstance(expr, float)
|
||||
):
|
||||
return _filter_where_clause({key: {"$eq": expr}}, metadata) # type: ignore[dict-item]
|
||||
|
||||
# expr is a list of clauses
|
||||
if key == "$and":
|
||||
assert isinstance(expr, list)
|
||||
return all(_filter_where_clause(clause, metadata) for clause in expr)
|
||||
|
||||
if key == "$or":
|
||||
assert isinstance(expr, list)
|
||||
return any(_filter_where_clause(clause, metadata) for clause in expr)
|
||||
|
||||
# expr is an operator expression
|
||||
assert isinstance(expr, dict)
|
||||
op, val = list(expr.items())[0]
|
||||
assert isinstance(metadata, dict)
|
||||
if op == "$eq":
|
||||
return key in metadata and metadata[key] == val
|
||||
elif op == "$ne":
|
||||
return key not in metadata or metadata[key] != val
|
||||
elif op == "$in":
|
||||
return key in metadata and metadata[key] in val # type: ignore[operator]
|
||||
elif op == "$nin":
|
||||
return key not in metadata or metadata[key] not in val # type: ignore[operator]
|
||||
|
||||
# The following conditions only make sense for numeric values
|
||||
assert (
|
||||
key not in metadata
|
||||
or isinstance(metadata[key], int)
|
||||
or isinstance(metadata[key], float)
|
||||
)
|
||||
assert isinstance(val, int) or isinstance(val, float)
|
||||
if op == "$gt":
|
||||
return key in metadata and metadata[key] > val
|
||||
elif op == "$gte":
|
||||
return key in metadata and metadata[key] >= val
|
||||
elif op == "$lt":
|
||||
return key in metadata and metadata[key] < val
|
||||
elif op == "$lte":
|
||||
return key in metadata and metadata[key] <= val
|
||||
else:
|
||||
raise ValueError("Unknown operator: {}".format(key))
|
||||
|
||||
|
||||
def _filter_where_doc_clause(clause: WhereDocument, doc: Document) -> bool:
|
||||
key, expr = list(clause.items())[0]
|
||||
|
||||
if key == "$and":
|
||||
assert isinstance(expr, list)
|
||||
return all(_filter_where_doc_clause(clause, doc) for clause in expr)
|
||||
if key == "$or":
|
||||
assert isinstance(expr, list)
|
||||
return any(_filter_where_doc_clause(clause, doc) for clause in expr)
|
||||
|
||||
# Simple $contains clause
|
||||
assert isinstance(expr, str)
|
||||
if key == "$contains":
|
||||
if not doc:
|
||||
return False
|
||||
return expr in doc
|
||||
elif key == "$not_contains":
|
||||
if not doc:
|
||||
return True
|
||||
return expr not in doc
|
||||
else:
|
||||
raise ValueError("Unknown operator: {}".format(key))
|
||||
|
||||
|
||||
EMPTY_DICT: Dict[Any, Any] = {}
|
||||
EMPTY_STRING: str = ""
|
||||
|
||||
|
||||
def _filter_embedding_set(
|
||||
record_set: strategies.RecordSet, filter: strategies.Filter
|
||||
) -> IDs:
|
||||
"""Return IDs from the embedding set that match the given filter object
|
||||
If none match, return an empty list
|
||||
"""
|
||||
|
||||
normalized_record_set = invariants.wrap_all(record_set)
|
||||
ids = set(normalized_record_set["ids"])
|
||||
|
||||
filter_ids = filter["ids"]
|
||||
|
||||
if filter_ids is not None:
|
||||
filter_ids = invariants.wrap(filter_ids)
|
||||
assert filter_ids is not None
|
||||
# If the filter ids is an empty list then we treat that as get all
|
||||
if len(filter_ids) != 0:
|
||||
ids = ids.intersection(filter_ids)
|
||||
|
||||
for i in range(len(normalized_record_set["ids"])):
|
||||
if filter["where"]:
|
||||
metadatas: Metadatas
|
||||
if isinstance(normalized_record_set["metadatas"], list):
|
||||
metadatas = normalized_record_set["metadatas"] # type: ignore[assignment]
|
||||
else:
|
||||
metadatas = [EMPTY_DICT] * len(normalized_record_set["ids"])
|
||||
filter_where: Where = filter["where"]
|
||||
if not _filter_where_clause(filter_where, metadatas[i]):
|
||||
ids.discard(normalized_record_set["ids"][i])
|
||||
|
||||
if filter["where_document"]:
|
||||
documents = normalized_record_set["documents"] or [EMPTY_STRING] * len(
|
||||
normalized_record_set["ids"]
|
||||
)
|
||||
if not _filter_where_doc_clause(filter["where_document"], documents[i]):
|
||||
ids.discard(normalized_record_set["ids"][i])
|
||||
|
||||
return list(ids)
|
||||
|
||||
|
||||
class LegacyWhereWrapper(WhereExpr):
|
||||
"""
|
||||
Wraps old-style where/where_document dicts for testing.
|
||||
Converts where_document to use #document field and combines with where using $and.
|
||||
"""
|
||||
def __init__(self, where: Optional[Where] = None, where_document: Optional[WhereDocument] = None):
|
||||
self.where = where
|
||||
self.where_document = where_document
|
||||
|
||||
def _convert_where_document(self, where_doc: WhereDocument) -> Dict[str, Any]:
|
||||
"""Convert where_document filters to use #document field."""
|
||||
if not where_doc:
|
||||
return {}
|
||||
|
||||
# Handle logical operators recursively
|
||||
if "$and" in where_doc:
|
||||
and_clauses = where_doc["$and"]
|
||||
if isinstance(and_clauses, list):
|
||||
return {"$and": [self._convert_where_document(clause) for clause in and_clauses]}
|
||||
elif "$or" in where_doc:
|
||||
or_clauses = where_doc["$or"]
|
||||
if isinstance(or_clauses, list):
|
||||
return {"$or": [self._convert_where_document(clause) for clause in or_clauses]}
|
||||
|
||||
# Handle document operators - convert to #document field
|
||||
if "$contains" in where_doc:
|
||||
return {"#document": {"$contains": where_doc["$contains"]}}
|
||||
elif "$not_contains" in where_doc:
|
||||
return {"#document": {"$not_contains": where_doc["$not_contains"]}}
|
||||
|
||||
if "$regex" in where_doc:
|
||||
return {"#document": {"$regex": where_doc["$regex"]}}
|
||||
elif "$not_regex" in where_doc:
|
||||
return {"#document": {"$not_regex": where_doc["$not_regex"]}}
|
||||
|
||||
# Cast to dict for return
|
||||
return cast(Dict[str, Any], where_doc)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
# Combine where and where_document into a single where clause
|
||||
combined_where = None
|
||||
|
||||
# Build list of conditions to AND together
|
||||
conditions = []
|
||||
|
||||
if self.where:
|
||||
conditions.append(self.where)
|
||||
|
||||
if self.where_document:
|
||||
# Convert where_document to use #document field
|
||||
converted_doc_filter = self._convert_where_document(self.where_document)
|
||||
if converted_doc_filter:
|
||||
conditions.append(converted_doc_filter)
|
||||
|
||||
# Combine conditions with $and if needed
|
||||
if len(conditions) == 1:
|
||||
combined_where = conditions[0]
|
||||
elif len(conditions) > 1:
|
||||
combined_where = {"$and": conditions}
|
||||
|
||||
# Return the combined where clause directly
|
||||
if combined_where:
|
||||
return combined_where
|
||||
return {}
|
||||
|
||||
|
||||
def _search_with_filter(
|
||||
collection: Collection,
|
||||
filter: strategies.Filter,
|
||||
query_embedding: Optional[Embedding] = None,
|
||||
n_results: int = 10
|
||||
) -> List[str]:
|
||||
"""Use the search API to retrieve results with filters - test helper function."""
|
||||
# Build Search object
|
||||
search = Search()
|
||||
|
||||
# Add KNN if embedding provided
|
||||
if query_embedding is not None:
|
||||
search = search.rank(Knn(query=query_embedding)) # type: ignore[arg-type]
|
||||
|
||||
# Add filters using the LegacyWhereWrapper
|
||||
if filter.get("where") or filter.get("where_document") or filter.get("ids"):
|
||||
# Convert ids to list if it's a string
|
||||
ids_val = filter.get("ids")
|
||||
if isinstance(ids_val, str):
|
||||
ids_val = [ids_val]
|
||||
|
||||
# Build the where clause
|
||||
where_expr = None
|
||||
|
||||
# Add legacy where/where_document if present
|
||||
if filter.get("where") or filter.get("where_document"):
|
||||
wrapper = LegacyWhereWrapper(
|
||||
where=filter.get("where"),
|
||||
where_document=filter.get("where_document"),
|
||||
)
|
||||
if wrapper.to_dict(): # Only use if it has content
|
||||
where_expr = wrapper
|
||||
|
||||
# Add ID filter if present
|
||||
if ids_val:
|
||||
id_expr = Key.ID.is_in(ids_val)
|
||||
if where_expr:
|
||||
where_expr = where_expr & id_expr # type: ignore[assignment]
|
||||
else:
|
||||
where_expr = id_expr
|
||||
|
||||
# Apply the where clause if we have one
|
||||
if where_expr:
|
||||
search = search.where(where_expr)
|
||||
|
||||
# Set limit and select only IDs
|
||||
search = search.limit(n_results).select("id")
|
||||
|
||||
# Execute search and return IDs
|
||||
result = collection.search(search)
|
||||
return result["ids"][0] if result["ids"] else []
|
||||
|
||||
|
||||
collection_st = st.shared(
|
||||
strategies.collections(add_filterable_data=True, with_hnsw_params=True),
|
||||
key="coll",
|
||||
)
|
||||
recordset_st = st.shared(
|
||||
strategies.recordsets(collection_st, max_size=1000), key="recordset"
|
||||
)
|
||||
|
||||
|
||||
@settings(
|
||||
deadline=90000,
|
||||
suppress_health_check=[
|
||||
HealthCheck.function_scoped_fixture,
|
||||
HealthCheck.large_base_example,
|
||||
HealthCheck.filter_too_much,
|
||||
],
|
||||
) # type: ignore
|
||||
@given(
|
||||
collection=collection_st,
|
||||
record_set=recordset_st,
|
||||
filters=st.lists(strategies.filters(collection_st, recordset_st), min_size=1),
|
||||
should_compact=st.booleans(),
|
||||
)
|
||||
def test_filterable_metadata_get(
|
||||
caplog,
|
||||
client: ClientAPI,
|
||||
collection: strategies.Collection,
|
||||
record_set,
|
||||
filters,
|
||||
should_compact: bool,
|
||||
) -> None:
|
||||
caplog.set_level(logging.ERROR)
|
||||
|
||||
reset(client)
|
||||
coll = client.create_collection(
|
||||
name=collection.name,
|
||||
metadata=collection.metadata, # type: ignore
|
||||
embedding_function=collection.embedding_function,
|
||||
)
|
||||
|
||||
initial_version = coll.get_model()["version"]
|
||||
|
||||
coll.add(**record_set)
|
||||
|
||||
if not NOT_CLUSTER_ONLY:
|
||||
# Only wait for compaction if the size of the collection is
|
||||
# some minimal size
|
||||
if should_compact and len(invariants.wrap(record_set["ids"])) > 10:
|
||||
# Wait for the model to be updated
|
||||
wait_for_version_increase(client, collection.name, initial_version) # type: ignore
|
||||
|
||||
for filter in filters:
|
||||
result_ids = coll.get(**filter)["ids"]
|
||||
expected_ids = _filter_embedding_set(record_set, filter)
|
||||
assert sorted(result_ids) == sorted(expected_ids)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
NOT_CLUSTER_ONLY,
|
||||
reason="Search API only available in distributed mode"
|
||||
)
|
||||
@settings(
|
||||
deadline=90000,
|
||||
suppress_health_check=[
|
||||
HealthCheck.function_scoped_fixture,
|
||||
HealthCheck.large_base_example,
|
||||
HealthCheck.filter_too_much,
|
||||
],
|
||||
) # type: ignore
|
||||
@given(
|
||||
collection=collection_st,
|
||||
record_set=recordset_st,
|
||||
filters=st.lists(strategies.filters(collection_st, recordset_st), min_size=1),
|
||||
should_compact=st.booleans(),
|
||||
)
|
||||
def test_filterable_metadata_search(
|
||||
caplog,
|
||||
client: ClientAPI,
|
||||
collection: strategies.Collection,
|
||||
record_set,
|
||||
filters,
|
||||
should_compact: bool,
|
||||
) -> None:
|
||||
"""Test metadata filtering using search API endpoint."""
|
||||
caplog.set_level(logging.ERROR)
|
||||
|
||||
reset(client)
|
||||
coll = client.create_collection(
|
||||
name=collection.name,
|
||||
metadata=collection.metadata, # type: ignore
|
||||
embedding_function=collection.embedding_function,
|
||||
)
|
||||
|
||||
initial_version = coll.get_model()["version"]
|
||||
coll.add(**record_set)
|
||||
|
||||
if should_compact and len(invariants.wrap(record_set["ids"])) > 10:
|
||||
wait_for_version_increase(client, collection.name, initial_version) # type: ignore
|
||||
|
||||
for filter in filters:
|
||||
# Use search API instead of get
|
||||
result_ids = _search_with_filter(coll, filter, n_results=1000)
|
||||
expected_ids = _filter_embedding_set(record_set, filter)
|
||||
assert sorted(result_ids) == sorted(expected_ids)
|
||||
|
||||
|
||||
@settings(
|
||||
deadline=90000,
|
||||
suppress_health_check=[
|
||||
HealthCheck.function_scoped_fixture,
|
||||
HealthCheck.large_base_example,
|
||||
HealthCheck.filter_too_much,
|
||||
],
|
||||
) # type: ignore
|
||||
@given(
|
||||
collection=collection_st,
|
||||
record_set=recordset_st,
|
||||
filters=st.lists(strategies.filters(collection_st, recordset_st), min_size=1),
|
||||
limit=st.integers(min_value=1, max_value=10),
|
||||
offset=st.integers(min_value=0, max_value=10),
|
||||
should_compact=st.booleans(),
|
||||
)
|
||||
# Repro of a former off-by-one error in distributed Chroma. Fixed in https://github.com/chroma-core/chroma/pull/3489.
|
||||
@example(
|
||||
collection=strategies.Collection(
|
||||
name="test",
|
||||
metadata={"test": "test"},
|
||||
embedding_function=None,
|
||||
id=uuid.uuid4(),
|
||||
dimension=2,
|
||||
dtype="float32",
|
||||
known_metadata_keys={},
|
||||
known_document_keywords=[],
|
||||
),
|
||||
record_set=strategies.RecordSet(
|
||||
ids=[str(i) for i in range(11)],
|
||||
embeddings=[np.random.rand(2).tolist() for _ in range(11)],
|
||||
metadatas=[{"test": "test"} for _ in range(11)],
|
||||
documents=None,
|
||||
),
|
||||
filters=[
|
||||
strategies.Filter(
|
||||
{
|
||||
"where_document": {"$not_contains": "foo"},
|
||||
"ids": None,
|
||||
"where": None,
|
||||
}
|
||||
)
|
||||
],
|
||||
limit=10,
|
||||
offset=10,
|
||||
should_compact=True,
|
||||
)
|
||||
def test_filterable_metadata_get_limit_offset(
|
||||
caplog,
|
||||
client: ClientAPI,
|
||||
collection: strategies.Collection,
|
||||
record_set,
|
||||
filters,
|
||||
limit,
|
||||
offset,
|
||||
should_compact: bool,
|
||||
) -> None:
|
||||
caplog.set_level(logging.ERROR)
|
||||
|
||||
reset(client)
|
||||
coll = client.create_collection(
|
||||
name=collection.name,
|
||||
metadata=collection.metadata, # type: ignore
|
||||
embedding_function=collection.embedding_function,
|
||||
)
|
||||
|
||||
initial_version = coll.get_model()["version"]
|
||||
|
||||
coll.add(**record_set)
|
||||
|
||||
if not NOT_CLUSTER_ONLY:
|
||||
# Only wait for compaction if the size of the collection is
|
||||
# some minimal size
|
||||
if should_compact and len(invariants.wrap(record_set["ids"])) > 10:
|
||||
# Wait for the model to be updated
|
||||
wait_for_version_increase(client, collection.name, initial_version) # type: ignore
|
||||
|
||||
for filter in filters:
|
||||
# add limit and offset to filter
|
||||
filter["limit"] = limit
|
||||
filter["offset"] = offset
|
||||
result_ids = coll.get(**filter)["ids"]
|
||||
expected_ids = _filter_embedding_set(record_set, filter)
|
||||
if len(expected_ids) > 0:
|
||||
collection_ids = coll.get(ids=expected_ids)["ids"]
|
||||
offset_id_order = {id: index for index, id in enumerate(collection_ids)}
|
||||
assert (
|
||||
result_ids
|
||||
== sorted(expected_ids, key=lambda id: offset_id_order[id])[
|
||||
offset : offset + limit
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@settings(
|
||||
deadline=90000,
|
||||
suppress_health_check=[
|
||||
HealthCheck.function_scoped_fixture,
|
||||
HealthCheck.large_base_example,
|
||||
HealthCheck.filter_too_much,
|
||||
],
|
||||
)
|
||||
@given(
|
||||
collection=collection_st,
|
||||
record_set=recordset_st,
|
||||
filters=st.lists(
|
||||
strategies.filters(collection_st, recordset_st, include_all_ids=True),
|
||||
min_size=1,
|
||||
),
|
||||
should_compact=st.booleans(),
|
||||
data=st.data(),
|
||||
)
|
||||
def test_filterable_metadata_query(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
client: ClientAPI,
|
||||
collection: strategies.Collection,
|
||||
record_set: strategies.RecordSet,
|
||||
filters: List[strategies.Filter],
|
||||
should_compact: bool,
|
||||
data: st.DataObject,
|
||||
) -> None:
|
||||
caplog.set_level(logging.ERROR)
|
||||
|
||||
reset(client)
|
||||
coll = client.create_collection(
|
||||
name=collection.name,
|
||||
metadata=collection.metadata, # type: ignore
|
||||
embedding_function=collection.embedding_function,
|
||||
)
|
||||
initial_version = coll.get_model()["version"]
|
||||
normalized_record_set = invariants.wrap_all(record_set)
|
||||
|
||||
coll.add(**record_set) # type: ignore[arg-type]
|
||||
|
||||
if not NOT_CLUSTER_ONLY:
|
||||
# Only wait for compaction if the size of the collection is
|
||||
# some minimal size
|
||||
if should_compact and len(invariants.wrap(record_set["ids"])) > 10:
|
||||
# Wait for the model to be updated
|
||||
wait_for_version_increase(client, collection.name, initial_version) # type: ignore
|
||||
|
||||
total_count = len(normalized_record_set["ids"])
|
||||
# Pick a random vector using Hypothesis data
|
||||
random_query: Embedding
|
||||
|
||||
query_index = data.draw(st.integers(min_value=0, max_value=total_count - 1))
|
||||
if collection.has_embeddings:
|
||||
assert normalized_record_set["embeddings"] is not None
|
||||
assert all(isinstance(e, list) for e in normalized_record_set["embeddings"])
|
||||
# Use data.draw to select index
|
||||
random_query = normalized_record_set["embeddings"][query_index]
|
||||
else:
|
||||
assert isinstance(normalized_record_set["documents"], list)
|
||||
assert collection.embedding_function is not None
|
||||
# Use data.draw to select index
|
||||
random_query = collection.embedding_function(
|
||||
[normalized_record_set["documents"][query_index]]
|
||||
)[0]
|
||||
for filter in filters:
|
||||
result_ids = set(
|
||||
coll.query(
|
||||
query_embeddings=random_query,
|
||||
n_results=total_count,
|
||||
where=filter["where"],
|
||||
where_document=filter["where_document"],
|
||||
)["ids"][0]
|
||||
)
|
||||
expected_ids = set(
|
||||
_filter_embedding_set(
|
||||
cast(strategies.RecordSet, normalized_record_set), filter
|
||||
)
|
||||
)
|
||||
assert len(result_ids.intersection(expected_ids)) == len(result_ids)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
NOT_CLUSTER_ONLY,
|
||||
reason="Search API only available in distributed mode"
|
||||
)
|
||||
@settings(
|
||||
deadline=90000,
|
||||
suppress_health_check=[
|
||||
HealthCheck.function_scoped_fixture,
|
||||
HealthCheck.large_base_example,
|
||||
HealthCheck.filter_too_much,
|
||||
],
|
||||
)
|
||||
@given(
|
||||
collection=collection_st,
|
||||
record_set=recordset_st,
|
||||
filters=st.lists(
|
||||
strategies.filters(collection_st, recordset_st, include_all_ids=True),
|
||||
min_size=1,
|
||||
),
|
||||
should_compact=st.booleans(),
|
||||
data=st.data(),
|
||||
)
|
||||
def test_filterable_metadata_query_via_search(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
client: ClientAPI,
|
||||
collection: strategies.Collection,
|
||||
record_set: strategies.RecordSet,
|
||||
filters: List[strategies.Filter],
|
||||
should_compact: bool,
|
||||
data: st.DataObject,
|
||||
) -> None:
|
||||
"""Test query-like filtering using search API endpoint."""
|
||||
caplog.set_level(logging.ERROR)
|
||||
|
||||
reset(client)
|
||||
coll = client.create_collection(
|
||||
name=collection.name,
|
||||
metadata=collection.metadata, # type: ignore
|
||||
embedding_function=collection.embedding_function,
|
||||
)
|
||||
|
||||
initial_version = coll.get_model()["version"]
|
||||
normalized_record_set = invariants.wrap_all(record_set)
|
||||
coll.add(**record_set) # type: ignore[arg-type]
|
||||
|
||||
if should_compact and len(invariants.wrap(record_set["ids"])) > 10:
|
||||
wait_for_version_increase(client, collection.name, initial_version) # type: ignore
|
||||
|
||||
total_count = len(normalized_record_set["ids"])
|
||||
|
||||
# Pick a random query embedding
|
||||
query_index = data.draw(st.integers(min_value=0, max_value=total_count - 1))
|
||||
if collection.has_embeddings:
|
||||
assert normalized_record_set["embeddings"] is not None
|
||||
random_query = normalized_record_set["embeddings"][query_index]
|
||||
else:
|
||||
assert isinstance(normalized_record_set["documents"], list)
|
||||
assert collection.embedding_function is not None
|
||||
random_query = collection.embedding_function(
|
||||
[normalized_record_set["documents"][query_index]]
|
||||
)[0]
|
||||
|
||||
for filter in filters:
|
||||
# Use search API with query embedding
|
||||
result_ids = set(_search_with_filter(
|
||||
coll,
|
||||
filter,
|
||||
query_embedding=random_query,
|
||||
n_results=total_count
|
||||
))
|
||||
expected_ids = set(
|
||||
_filter_embedding_set(
|
||||
cast(strategies.RecordSet, normalized_record_set), filter
|
||||
)
|
||||
)
|
||||
assert len(result_ids.intersection(expected_ids)) == len(result_ids)
|
||||
|
||||
|
||||
def test_empty_filter(client: ClientAPI) -> None:
|
||||
"""Test that a filter where no document matches returns an empty result"""
|
||||
reset(client)
|
||||
coll = client.create_collection(name="test")
|
||||
|
||||
test_ids: IDs = ["1", "2", "3"]
|
||||
test_embeddings: Embeddings = [np.array([1, 1]), np.array([2, 2]), np.array([3, 3])]
|
||||
test_query_embedding: Embedding = np.array([1, 2])
|
||||
test_query_embeddings: Embeddings = [test_query_embedding, test_query_embedding]
|
||||
|
||||
coll.add(ids=test_ids, embeddings=test_embeddings)
|
||||
|
||||
res = coll.query(
|
||||
query_embeddings=test_query_embedding,
|
||||
where={"q": {"$eq": 4}}, # type: ignore[dict-item]
|
||||
n_results=3,
|
||||
include=["embeddings", "distances", "metadatas"],
|
||||
)
|
||||
assert res["ids"] == [[]]
|
||||
if res["embeddings"] is not None:
|
||||
assert cast(np.ndarray, res["embeddings"][0]).size == 0 # type: ignore
|
||||
assert res["distances"] == [[]]
|
||||
assert res["metadatas"] == [[]]
|
||||
assert set(res["included"]) == set(["embeddings", "distances", "metadatas"])
|
||||
|
||||
res = coll.query(
|
||||
query_embeddings=test_query_embeddings,
|
||||
where={"test": "yes"},
|
||||
n_results=3,
|
||||
)
|
||||
assert res["ids"] == [[], []]
|
||||
assert res["embeddings"] is None
|
||||
assert res["distances"] == [[], []]
|
||||
assert res["metadatas"] == [[], []]
|
||||
assert set(res["included"]) == set(["metadatas", "documents", "distances"])
|
||||
|
||||
|
||||
def test_boolean_metadata(client: ClientAPI) -> None:
|
||||
"""Test that metadata with boolean values is correctly filtered"""
|
||||
reset(client)
|
||||
coll = client.create_collection(name="test")
|
||||
|
||||
test_ids: IDs = ["1", "2", "3"]
|
||||
test_embeddings: Embeddings = [np.array([1, 1]), np.array([2, 2]), np.array([3, 3])]
|
||||
test_metadatas: Metadatas = [{"test": True}, {"test": False}, {"test": True}]
|
||||
|
||||
coll.add(ids=test_ids, embeddings=test_embeddings, metadatas=test_metadatas)
|
||||
|
||||
res = coll.get(where={"test": True})
|
||||
|
||||
assert res["ids"] == ["1", "3"]
|
||||
|
||||
|
||||
def test_get_empty(client: ClientAPI) -> None:
|
||||
"""Tests that calling get() with empty filters returns nothing"""
|
||||
|
||||
reset(client)
|
||||
coll = client.create_collection(name="test")
|
||||
|
||||
test_ids: IDs = ["1", "2", "3"]
|
||||
test_embeddings: Embeddings = [np.array([1, 1]), np.array([2, 2]), np.array([3, 3])]
|
||||
test_metadatas: Metadatas = [{"test": 10}, {"test": 20}, {"test": 30}]
|
||||
|
||||
def check_empty_res(res: GetResult) -> None:
|
||||
assert len(res["ids"]) == 0
|
||||
assert res["embeddings"] is not None
|
||||
assert len(res["embeddings"]) == 0
|
||||
assert res["documents"] is not None
|
||||
assert len(res["documents"]) == 0
|
||||
assert res["metadatas"] is not None
|
||||
|
||||
coll.add(ids=test_ids, embeddings=test_embeddings, metadatas=test_metadatas)
|
||||
|
||||
res = coll.get(ids=["nope"], include=["embeddings", "metadatas", "documents"])
|
||||
check_empty_res(res)
|
||||
res = coll.get(
|
||||
include=["embeddings", "metadatas", "documents"], where={"test": 100}
|
||||
)
|
||||
check_empty_res(res)
|
||||
|
||||
|
||||
@settings(
|
||||
deadline=90000,
|
||||
suppress_health_check=[
|
||||
HealthCheck.function_scoped_fixture,
|
||||
HealthCheck.large_base_example,
|
||||
],
|
||||
)
|
||||
@given(
|
||||
collection=collection_st,
|
||||
record_set=recordset_st,
|
||||
n_results_st=st.integers(min_value=1, max_value=100),
|
||||
should_compact=st.booleans(),
|
||||
data=st.data(),
|
||||
)
|
||||
def test_query_ids_filter_property(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
client: ClientAPI,
|
||||
collection: strategies.Collection,
|
||||
record_set: strategies.RecordSet,
|
||||
n_results_st: int,
|
||||
should_compact: bool,
|
||||
data: st.DataObject,
|
||||
) -> None:
|
||||
"""Property test for querying with only the ids filter."""
|
||||
if (
|
||||
client.get_settings().chroma_api_impl
|
||||
== "chromadb.api.async_fastapi.AsyncFastAPI"
|
||||
):
|
||||
pytest.skip(
|
||||
"Skipping test for async client due to potential resource/timeout issues"
|
||||
)
|
||||
caplog.set_level(logging.ERROR)
|
||||
reset(client)
|
||||
coll = client.create_collection(
|
||||
name=collection.name,
|
||||
metadata=collection.metadata, # type: ignore
|
||||
embedding_function=collection.embedding_function,
|
||||
)
|
||||
initial_version = coll.get_model()["version"]
|
||||
normalized_record_set = invariants.wrap_all(record_set)
|
||||
|
||||
if len(normalized_record_set["ids"]) == 0:
|
||||
# Cannot add empty record set
|
||||
return
|
||||
|
||||
coll.add(**record_set) # type: ignore[arg-type]
|
||||
|
||||
if not NOT_CLUSTER_ONLY:
|
||||
if should_compact and len(normalized_record_set["ids"]) > 10:
|
||||
wait_for_version_increase(client, collection.name, initial_version) # type: ignore
|
||||
|
||||
total_count = len(normalized_record_set["ids"])
|
||||
n_results = min(n_results_st, total_count)
|
||||
|
||||
# Generate a random subset of ids to filter on using Hypothesis data
|
||||
ids_to_query = data.draw(
|
||||
st.lists(
|
||||
st.sampled_from(normalized_record_set["ids"]),
|
||||
min_size=0,
|
||||
max_size=total_count,
|
||||
unique=True,
|
||||
)
|
||||
)
|
||||
|
||||
# Pick a random query vector using Hypothesis data
|
||||
random_query: Embedding
|
||||
query_index = data.draw(st.integers(min_value=0, max_value=total_count - 1))
|
||||
if collection.has_embeddings:
|
||||
assert normalized_record_set["embeddings"] is not None
|
||||
assert all(isinstance(e, list) for e in normalized_record_set["embeddings"])
|
||||
# Use data.draw to select index
|
||||
random_query = normalized_record_set["embeddings"][query_index]
|
||||
else:
|
||||
assert isinstance(normalized_record_set["documents"], list)
|
||||
assert collection.embedding_function is not None
|
||||
# Use data.draw to select index
|
||||
random_query = collection.embedding_function(
|
||||
[normalized_record_set["documents"][query_index]]
|
||||
)[0]
|
||||
|
||||
# Perform the query with only the ids filter
|
||||
result = coll.query(
|
||||
query_embeddings=[random_query],
|
||||
ids=ids_to_query,
|
||||
n_results=n_results,
|
||||
)
|
||||
|
||||
result_ids = set(result["ids"][0])
|
||||
filter_ids_set = set(ids_to_query)
|
||||
|
||||
# The core assertion: all returned IDs must be within the filter set
|
||||
assert result_ids.issubset(filter_ids_set)
|
||||
|
||||
# Also check that the number of results is reasonable
|
||||
assert len(result_ids) <= n_results
|
||||
assert len(result_ids) <= len(filter_ids_set)
|
||||
|
||||
|
||||
def test_regex(client: ClientAPI) -> None:
|
||||
"""Tests that regex works"""
|
||||
|
||||
reset(client)
|
||||
coll = client.create_collection(name="test")
|
||||
|
||||
test_ids: IDs = ["1", "2", "3"]
|
||||
test_documents: Documents = ["cat", "Cat", "CAT"]
|
||||
test_embeddings: Embeddings = [np.array([1, 1]), np.array([2, 2]), np.array([3, 3])]
|
||||
test_metadatas: Metadatas = [{"test": 10}, {"test": 20}, {"test": 30}]
|
||||
|
||||
coll.add(
|
||||
ids=test_ids,
|
||||
documents=test_documents,
|
||||
embeddings=test_embeddings,
|
||||
metadatas=test_metadatas,
|
||||
)
|
||||
|
||||
res = coll.get(where_document={"$regex": "cat"})
|
||||
assert res["ids"] == ["1"]
|
||||
|
||||
res = coll.get(where_document={"$regex": "(?i)cat"})
|
||||
assert sorted(res["ids"]) == ["1", "2", "3"]
|
||||
|
||||
res = coll.get(
|
||||
where={"test": {"$ne": 10}}, where_document={"$regex": "(?i)c(?-i)at"} # type: ignore[dict-item]
|
||||
)
|
||||
assert res["ids"] == ["2"]
|
||||
@@ -0,0 +1,192 @@
|
||||
import chromadb
|
||||
import chromadb.test.property.invariants as invariants
|
||||
import chromadb.test.property.strategies as strategies
|
||||
import copy
|
||||
import hypothesis.strategies as hyst
|
||||
import logging
|
||||
import pytest
|
||||
|
||||
from chromadb.api.models.Collection import Collection
|
||||
from chromadb.test.conftest import reset, skip_if_not_cluster
|
||||
from chromadb.test.utils.wait_for_version_increase import wait_for_version_increase
|
||||
from hypothesis.stateful import (
|
||||
Bundle,
|
||||
RuleBasedStateMachine,
|
||||
rule,
|
||||
initialize,
|
||||
multiple,
|
||||
consumes,
|
||||
run_state_machine_as_test,
|
||||
MultipleResults,
|
||||
)
|
||||
from overrides import overrides
|
||||
from typing import Dict, cast, Union, Tuple, Set
|
||||
|
||||
collection_st = hyst.shared(strategies.collections(with_hnsw_params=True), key="source")
|
||||
|
||||
|
||||
class ForkStateMachine(RuleBasedStateMachine):
|
||||
updated_collections: Bundle[
|
||||
Tuple[Collection, strategies.StateMachineRecordSet]
|
||||
] = Bundle("changing_collections")
|
||||
forked_collections: Bundle[
|
||||
Tuple[Collection, strategies.StateMachineRecordSet]
|
||||
] = Bundle("collections")
|
||||
collection_names: Set[str]
|
||||
|
||||
def __init__(self, client: chromadb.api.ClientAPI):
|
||||
super().__init__()
|
||||
self.client = client
|
||||
self.collection_names = set()
|
||||
|
||||
@initialize(collection=collection_st, target=updated_collections)
|
||||
def initialize(
|
||||
self, collection: strategies.Collection
|
||||
) -> Tuple[Collection, strategies.StateMachineRecordSet]:
|
||||
source = self.client.create_collection(
|
||||
name=collection.name,
|
||||
metadata=collection.metadata, # type: ignore[arg-type]
|
||||
embedding_function=collection.embedding_function,
|
||||
)
|
||||
self.collection_names.add(source.name)
|
||||
return source, strategies.StateMachineRecordSet(
|
||||
ids=[], metadatas=[], documents=[], embeddings=[]
|
||||
)
|
||||
|
||||
@overrides
|
||||
def teardown(self) -> None:
|
||||
reset(self.client)
|
||||
|
||||
@rule(
|
||||
source=consumes(updated_collections),
|
||||
new_name=strategies.collection_name(),
|
||||
target=forked_collections,
|
||||
)
|
||||
def fork(
|
||||
self, source: Tuple[Collection, strategies.StateMachineRecordSet], new_name: str
|
||||
) -> MultipleResults[Tuple[Collection, strategies.StateMachineRecordSet]]:
|
||||
collection, record_set = source
|
||||
if new_name in self.collection_names:
|
||||
with pytest.raises(Exception):
|
||||
collection.fork(new_name)
|
||||
return multiple(source)
|
||||
|
||||
target = collection.fork(new_name)
|
||||
self.collection_names.add(target.name)
|
||||
return multiple(source, (target, copy.deepcopy(record_set)))
|
||||
|
||||
@rule(
|
||||
cursor=consumes(forked_collections),
|
||||
delta=strategies.recordsets(collection_st),
|
||||
target=updated_collections,
|
||||
)
|
||||
def upsert(
|
||||
self,
|
||||
cursor: Tuple[Collection, strategies.StateMachineRecordSet],
|
||||
delta: strategies.RecordSet,
|
||||
) -> Tuple[Collection, strategies.StateMachineRecordSet]:
|
||||
collection, record_set_state = cursor
|
||||
normalized_delta: strategies.NormalizedRecordSet = invariants.wrap_all(delta)
|
||||
collection.upsert(**normalized_delta) # type: ignore[arg-type]
|
||||
for idx, id in enumerate(normalized_delta["ids"]):
|
||||
if id in record_set_state["ids"]:
|
||||
target_idx = record_set_state["ids"].index(id)
|
||||
if normalized_delta["embeddings"] is not None:
|
||||
record_set_state["embeddings"][target_idx] = normalized_delta[
|
||||
"embeddings"
|
||||
][idx]
|
||||
else:
|
||||
assert normalized_delta["documents"] is not None
|
||||
assert collection._embedding_function is not None
|
||||
record_set_state["embeddings"][
|
||||
target_idx
|
||||
] = collection._embedding_function(
|
||||
[normalized_delta["documents"][idx]]
|
||||
)[
|
||||
0
|
||||
]
|
||||
if normalized_delta["metadatas"] is not None:
|
||||
record_set_state_metadata = cast(
|
||||
Dict[str, Union[str, int, float]],
|
||||
record_set_state["metadatas"][target_idx],
|
||||
)
|
||||
if record_set_state_metadata is not None:
|
||||
if normalized_delta["metadatas"][idx] is not None:
|
||||
record_set_state_metadata.update(
|
||||
normalized_delta["metadatas"][idx] # type: ignore[arg-type]
|
||||
)
|
||||
else:
|
||||
record_set_state["metadatas"][target_idx] = normalized_delta[
|
||||
"metadatas"
|
||||
][idx]
|
||||
if normalized_delta["documents"] is not None:
|
||||
record_set_state["documents"][target_idx] = normalized_delta[
|
||||
"documents"
|
||||
][idx]
|
||||
else:
|
||||
record_set_state["ids"].append(id)
|
||||
if normalized_delta["embeddings"] is not None:
|
||||
record_set_state["embeddings"].append(
|
||||
normalized_delta["embeddings"][idx]
|
||||
)
|
||||
else:
|
||||
assert collection._embedding_function is not None
|
||||
assert normalized_delta["documents"] is not None
|
||||
record_set_state["embeddings"].append(
|
||||
collection._embedding_function(
|
||||
[normalized_delta["documents"][idx]]
|
||||
)[0]
|
||||
)
|
||||
if normalized_delta["metadatas"] is not None:
|
||||
record_set_state["metadatas"].append(
|
||||
normalized_delta["metadatas"][idx]
|
||||
)
|
||||
else:
|
||||
record_set_state["metadatas"].append(None)
|
||||
if normalized_delta["documents"] is not None:
|
||||
record_set_state["documents"].append(
|
||||
normalized_delta["documents"][idx]
|
||||
)
|
||||
else:
|
||||
record_set_state["documents"].append(None)
|
||||
return collection, record_set_state
|
||||
|
||||
@rule(
|
||||
cursor=consumes(forked_collections),
|
||||
target=updated_collections,
|
||||
)
|
||||
def delete(
|
||||
self, cursor: Tuple[Collection, strategies.StateMachineRecordSet]
|
||||
) -> Tuple[Collection, strategies.StateMachineRecordSet]:
|
||||
collection, record_set_state = cursor
|
||||
boundary = len(record_set_state["ids"]) // 10
|
||||
if boundary == 0:
|
||||
return collection, record_set_state
|
||||
ids_to_delete = record_set_state["ids"][:boundary]
|
||||
collection.delete(ids_to_delete)
|
||||
record_set_state["ids"] = record_set_state["ids"][boundary:]
|
||||
record_set_state["embeddings"] = record_set_state["embeddings"][boundary:]
|
||||
record_set_state["metadatas"] = record_set_state["metadatas"][boundary:]
|
||||
record_set_state["documents"] = record_set_state["documents"][boundary:]
|
||||
return collection, record_set_state
|
||||
|
||||
@rule(
|
||||
cursor=forked_collections,
|
||||
)
|
||||
def verify(
|
||||
self, cursor: Tuple[Collection, strategies.StateMachineRecordSet]
|
||||
) -> None:
|
||||
collection, record_set_state = cursor
|
||||
if len(record_set_state["ids"]) == 0:
|
||||
assert collection.count() == 0
|
||||
else:
|
||||
record_set = cast(strategies.RecordSet, record_set_state)
|
||||
invariants.embeddings_match(collection, record_set)
|
||||
invariants.metadatas_match(collection, record_set)
|
||||
invariants.documents_match(collection, record_set)
|
||||
|
||||
|
||||
@skip_if_not_cluster()
|
||||
def test_fork(caplog: pytest.LogCaptureFixture, client: chromadb.api.ClientAPI) -> None:
|
||||
caplog.set_level(logging.ERROR)
|
||||
run_state_machine_as_test(lambda: ForkStateMachine(client)) # type: ignore
|
||||
@@ -0,0 +1,725 @@
|
||||
import logging
|
||||
import multiprocessing
|
||||
from multiprocessing.connection import Connection
|
||||
import multiprocessing.context
|
||||
import time
|
||||
from typing import Generator, Callable, List, Tuple, cast
|
||||
from uuid import UUID
|
||||
from hypothesis import given
|
||||
import hypothesis.strategies as st
|
||||
import pytest
|
||||
import chromadb
|
||||
from chromadb.api import ClientAPI, ServerAPI
|
||||
from chromadb.config import Settings, System
|
||||
from chromadb.segment import VectorReader
|
||||
from chromadb.segment.impl.manager.local import LocalSegmentManager
|
||||
import chromadb.test.property.strategies as strategies
|
||||
import chromadb.test.property.invariants as invariants
|
||||
from chromadb.test.property.strategies import hashing_embedding_function
|
||||
from chromadb.test.property.test_embeddings import (
|
||||
EmbeddingStateMachineStates,
|
||||
trace,
|
||||
EmbeddingStateMachineBase,
|
||||
)
|
||||
from hypothesis.stateful import (
|
||||
run_state_machine_as_test,
|
||||
rule,
|
||||
precondition,
|
||||
initialize,
|
||||
MultipleResults,
|
||||
)
|
||||
import os
|
||||
from chromadb.api.client import Client as ClientCreator
|
||||
from chromadb.utils.embedding_functions import DefaultEmbeddingFunction
|
||||
import numpy as np
|
||||
import tempfile
|
||||
|
||||
CreatePersistAPI = Callable[[], ServerAPI]
|
||||
|
||||
configurations = (
|
||||
[
|
||||
Settings(
|
||||
chroma_api_impl="chromadb.api.rust.RustBindingsAPI",
|
||||
chroma_sysdb_impl="chromadb.db.impl.sqlite.SqliteDB",
|
||||
chroma_producer_impl="chromadb.db.impl.sqlite.SqliteDB",
|
||||
chroma_consumer_impl="chromadb.db.impl.sqlite.SqliteDB",
|
||||
chroma_segment_manager_impl="chromadb.segment.impl.manager.local.LocalSegmentManager",
|
||||
allow_reset=True,
|
||||
is_persistent=True,
|
||||
persist_directory=tempfile.mkdtemp(),
|
||||
)
|
||||
]
|
||||
if "CHROMA_RUST_BINDINGS_TEST_ONLY" in os.environ
|
||||
else [
|
||||
Settings(
|
||||
chroma_api_impl="chromadb.api.segment.SegmentAPI",
|
||||
chroma_sysdb_impl="chromadb.db.impl.sqlite.SqliteDB",
|
||||
chroma_producer_impl="chromadb.db.impl.sqlite.SqliteDB",
|
||||
chroma_consumer_impl="chromadb.db.impl.sqlite.SqliteDB",
|
||||
chroma_segment_manager_impl="chromadb.segment.impl.manager.local.LocalSegmentManager",
|
||||
allow_reset=True,
|
||||
is_persistent=True,
|
||||
persist_directory=tempfile.mkdtemp(),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", params=configurations)
|
||||
def settings(request: pytest.FixtureRequest) -> Generator[Settings, None, None]:
|
||||
yield request.param
|
||||
|
||||
|
||||
collection_st = st.shared(
|
||||
strategies.collections(
|
||||
with_hnsw_params=True,
|
||||
with_persistent_hnsw_params=st.just(True),
|
||||
# Makes it more likely to find persist-related bugs (by default these are set to 2000).
|
||||
# Lower values make it more likely that a test will trigger a persist to disk.
|
||||
max_hnsw_batch_size=10,
|
||||
max_hnsw_sync_threshold=10,
|
||||
),
|
||||
key="coll",
|
||||
)
|
||||
|
||||
|
||||
@st.composite
|
||||
def collection_and_recordset_strategy(
|
||||
draw: st.DrawFn,
|
||||
) -> Tuple[strategies.Collection, strategies.RecordSet]:
|
||||
collection = draw(
|
||||
strategies.collections(
|
||||
with_hnsw_params=True,
|
||||
with_persistent_hnsw_params=st.just(True),
|
||||
# Makes it more likely to find persist-related bugs (by default these are set to 2000).
|
||||
max_hnsw_batch_size=10,
|
||||
max_hnsw_sync_threshold=10,
|
||||
)
|
||||
)
|
||||
recordset = draw(strategies.recordsets(st.just(collection)))
|
||||
return collection, recordset
|
||||
|
||||
|
||||
@given(
|
||||
collection_and_recordset_strategies=st.lists(
|
||||
collection_and_recordset_strategy(),
|
||||
min_size=1,
|
||||
unique_by=(lambda x: x[0].name, lambda x: x[0].name),
|
||||
)
|
||||
)
|
||||
def test_persist(
|
||||
settings: Settings,
|
||||
collection_and_recordset_strategies: List[
|
||||
Tuple[strategies.Collection, strategies.RecordSet]
|
||||
],
|
||||
) -> None:
|
||||
system_1 = System(settings)
|
||||
system_1.start()
|
||||
client_1 = ClientCreator.from_system(system_1)
|
||||
|
||||
client_1.reset()
|
||||
for (
|
||||
collection_strategy,
|
||||
recordset_strategy,
|
||||
) in collection_and_recordset_strategies:
|
||||
coll = client_1.create_collection(
|
||||
name=collection_strategy.name,
|
||||
metadata=collection_strategy.metadata, # type: ignore[arg-type]
|
||||
embedding_function=collection_strategy.embedding_function,
|
||||
)
|
||||
|
||||
coll.add(**recordset_strategy) # type: ignore[arg-type]
|
||||
|
||||
invariants.count(coll, recordset_strategy)
|
||||
invariants.metadatas_match(coll, recordset_strategy)
|
||||
invariants.documents_match(coll, recordset_strategy)
|
||||
invariants.ids_match(coll, recordset_strategy)
|
||||
invariants.ann_accuracy(
|
||||
coll,
|
||||
recordset_strategy,
|
||||
embedding_function=collection_strategy.embedding_function,
|
||||
)
|
||||
|
||||
system_1.stop()
|
||||
del client_1
|
||||
del system_1
|
||||
|
||||
system_2 = System(settings)
|
||||
system_2.start()
|
||||
client_2 = ClientCreator.from_system(system_2)
|
||||
|
||||
for (
|
||||
collection_strategy,
|
||||
recordset_strategy,
|
||||
) in collection_and_recordset_strategies:
|
||||
coll = client_2.get_collection(
|
||||
name=collection_strategy.name,
|
||||
embedding_function=collection_strategy.embedding_function,
|
||||
)
|
||||
invariants.count(coll, recordset_strategy)
|
||||
invariants.metadatas_match(coll, recordset_strategy)
|
||||
invariants.documents_match(coll, recordset_strategy)
|
||||
invariants.ids_match(coll, recordset_strategy)
|
||||
invariants.ann_accuracy(
|
||||
coll,
|
||||
recordset_strategy,
|
||||
embedding_function=collection_strategy.embedding_function,
|
||||
)
|
||||
|
||||
system_2.stop()
|
||||
del client_2
|
||||
del system_2
|
||||
|
||||
|
||||
def test_sync_threshold(settings: Settings) -> None:
|
||||
system = System(settings)
|
||||
system.start()
|
||||
client = ClientCreator.from_system(system)
|
||||
|
||||
collection = client.create_collection(
|
||||
name="test", metadata={"hnsw:batch_size": 3, "hnsw:sync_threshold": 3}
|
||||
)
|
||||
|
||||
manager = system.instance(LocalSegmentManager)
|
||||
segment = manager.get_segment(collection.id, VectorReader)
|
||||
|
||||
def get_index_last_modified_at() -> float:
|
||||
# Time resolution on Windows can be up to 10ms
|
||||
time.sleep(0.1)
|
||||
try:
|
||||
return os.path.getmtime(segment._get_metadata_file()) # type: ignore[attr-defined]
|
||||
except FileNotFoundError:
|
||||
return -1
|
||||
|
||||
last_modified_at = get_index_last_modified_at()
|
||||
|
||||
collection.add(ids=["1", "2"], embeddings=[[1.0], [2.0]]) # type: ignore[arg-type]
|
||||
|
||||
# Should not have yet persisted
|
||||
assert get_index_last_modified_at() == last_modified_at
|
||||
last_modified_at = get_index_last_modified_at()
|
||||
|
||||
# Now there's 3 additions, and the sync threshold is 3...
|
||||
collection.add(ids=["3"], embeddings=[[3.0]]) # type: ignore[arg-type]
|
||||
|
||||
# ...so it should have persisted
|
||||
assert get_index_last_modified_at() > last_modified_at
|
||||
last_modified_at = get_index_last_modified_at()
|
||||
|
||||
# The same thing should happen with upserts
|
||||
collection.upsert(ids=["1", "2", "3"], embeddings=[[1.0], [2.0], [3.0]]) # type: ignore[arg-type]
|
||||
|
||||
# Should have persisted
|
||||
assert get_index_last_modified_at() > last_modified_at
|
||||
last_modified_at = get_index_last_modified_at()
|
||||
|
||||
# Mixed usage should also trigger persistence
|
||||
collection.add(ids=["4"], embeddings=[[4.0]]) # type: ignore[arg-type]
|
||||
collection.upsert(ids=["1", "2"], embeddings=[[1.0], [2.0]]) # type: ignore[arg-type]
|
||||
|
||||
# Should have persisted
|
||||
assert get_index_last_modified_at() > last_modified_at
|
||||
last_modified_at = get_index_last_modified_at()
|
||||
|
||||
# Invalid updates should also trigger persistence
|
||||
collection.add(ids=["5"], embeddings=[[5.0]]) # type: ignore[arg-type]
|
||||
collection.add(ids=["1", "2"], embeddings=[[1.0], [2.0]]) # type: ignore[arg-type]
|
||||
|
||||
# Should have persisted
|
||||
assert get_index_last_modified_at() > last_modified_at
|
||||
last_modified_at = get_index_last_modified_at()
|
||||
|
||||
|
||||
def load_and_check(
|
||||
settings: Settings,
|
||||
collection_name: str,
|
||||
record_set: strategies.RecordSet,
|
||||
conn: Connection,
|
||||
) -> None:
|
||||
try:
|
||||
system = System(settings)
|
||||
system.start()
|
||||
client = ClientCreator.from_system(system)
|
||||
|
||||
coll = client.get_collection(
|
||||
name=collection_name,
|
||||
embedding_function=strategies.not_implemented_embedding_function(), # type: ignore[arg-type]
|
||||
)
|
||||
invariants.count(coll, record_set)
|
||||
invariants.metadatas_match(coll, record_set)
|
||||
invariants.documents_match(coll, record_set)
|
||||
invariants.ids_match(coll, record_set)
|
||||
invariants.ann_accuracy(coll, record_set)
|
||||
|
||||
system.stop()
|
||||
except Exception as e:
|
||||
conn.send(e)
|
||||
raise e
|
||||
|
||||
|
||||
def get_multiprocessing_context(): # type: ignore[no-untyped-def]
|
||||
try:
|
||||
# Run the invariants in a new process to bypass any shared state/caching (which would defeat the purpose of the test)
|
||||
# (forkserver is used because it's much faster than spawn—it will spawn a new, minimal singleton process and then fork that singleton)
|
||||
ctx = multiprocessing.get_context("forkserver")
|
||||
# This is like running `import chromadb` in the single process that is forked rather than importing it in each forked process.
|
||||
# Gives a ~3x speedup since importing chromadb is fairly expensive.
|
||||
ctx.set_forkserver_preload(["chromadb"])
|
||||
return ctx
|
||||
except Exception:
|
||||
# forkserver/fork is not available on Windows
|
||||
return multiprocessing.get_context("spawn")
|
||||
|
||||
|
||||
class PersistEmbeddingsStateMachineStates(EmbeddingStateMachineStates):
|
||||
persist = "persist"
|
||||
|
||||
|
||||
MIN_STATE_CHANGES_BEFORE_PERSIST = 5
|
||||
|
||||
|
||||
class PersistEmbeddingsStateMachine(EmbeddingStateMachineBase):
|
||||
def __init__(self, client: ClientAPI, settings: Settings):
|
||||
self.client = client
|
||||
self.settings = settings
|
||||
self.min_state_changes_left_before_persisting = MIN_STATE_CHANGES_BEFORE_PERSIST
|
||||
self.client.reset()
|
||||
super().__init__(self.client)
|
||||
|
||||
@initialize(collection=collection_st) # type: ignore
|
||||
def initialize(self, collection: strategies.Collection):
|
||||
self.client.reset()
|
||||
self.collection = self.client.create_collection(
|
||||
name=collection.name,
|
||||
metadata=collection.metadata, # type: ignore[arg-type]
|
||||
embedding_function=collection.embedding_function,
|
||||
)
|
||||
self.embedding_function = collection.embedding_function
|
||||
trace("init")
|
||||
self.on_state_change(EmbeddingStateMachineStates.initialize)
|
||||
|
||||
self.record_set_state = strategies.StateMachineRecordSet(
|
||||
ids=[], metadatas=[], documents=[], embeddings=[]
|
||||
)
|
||||
|
||||
@precondition(
|
||||
lambda self: len(self.record_set_state["ids"]) >= 1
|
||||
and self.min_state_changes_left_before_persisting <= 0
|
||||
)
|
||||
@rule()
|
||||
def persist(self) -> None:
|
||||
self.on_state_change(PersistEmbeddingsStateMachineStates.persist)
|
||||
collection_name = self.collection.name
|
||||
conn1, conn2 = multiprocessing.Pipe()
|
||||
ctx = get_multiprocessing_context() # type: ignore[no-untyped-call]
|
||||
p = ctx.Process(
|
||||
target=load_and_check,
|
||||
args=(self.settings, collection_name, self.record_set_state, conn2),
|
||||
)
|
||||
p.start()
|
||||
p.join()
|
||||
|
||||
if conn1.poll():
|
||||
e = conn1.recv()
|
||||
raise e
|
||||
|
||||
p.close()
|
||||
|
||||
def on_state_change(self, new_state: str) -> None:
|
||||
super().on_state_change(new_state)
|
||||
if new_state == PersistEmbeddingsStateMachineStates.persist:
|
||||
self.min_state_changes_left_before_persisting = (
|
||||
MIN_STATE_CHANGES_BEFORE_PERSIST
|
||||
)
|
||||
else:
|
||||
self.min_state_changes_left_before_persisting -= 1
|
||||
|
||||
def teardown(self) -> None:
|
||||
self.client.reset()
|
||||
|
||||
|
||||
def test_persist_embeddings_state(
|
||||
caplog: pytest.LogCaptureFixture, settings: Settings
|
||||
) -> None:
|
||||
caplog.set_level(logging.ERROR)
|
||||
client = chromadb.Client(settings)
|
||||
run_state_machine_as_test(
|
||||
lambda: PersistEmbeddingsStateMachine(settings=settings, client=client),
|
||||
) # type: ignore
|
||||
|
||||
|
||||
def test_delete_less_than_k(
|
||||
caplog: pytest.LogCaptureFixture, settings: Settings
|
||||
) -> None:
|
||||
client = chromadb.Client(settings)
|
||||
state = PersistEmbeddingsStateMachine(settings=settings, client=client)
|
||||
state.initialize(
|
||||
collection=strategies.Collection(
|
||||
name="A00",
|
||||
metadata={
|
||||
"hnsw:construction_ef": 128,
|
||||
"hnsw:search_ef": 128,
|
||||
"hnsw:M": 128,
|
||||
"hnsw:sync_threshold": 3,
|
||||
"hnsw:batch_size": 3,
|
||||
},
|
||||
embedding_function=None,
|
||||
id=UUID("2d3eddc7-2314-45f4-a951-47a9a8e099d2"),
|
||||
dimension=2,
|
||||
dtype=np.float16,
|
||||
known_metadata_keys={},
|
||||
known_document_keywords=[],
|
||||
has_documents=False,
|
||||
has_embeddings=True,
|
||||
)
|
||||
)
|
||||
state.ann_accuracy()
|
||||
state.count()
|
||||
state.fields_match()
|
||||
state.log_size_below_max()
|
||||
state.no_duplicates()
|
||||
(embedding_ids_0,) = state.add_embeddings(record_set={"ids": ["0"], "embeddings": [[0.09765625, 0.430419921875]], "metadatas": [None], "documents": None}) # type: ignore
|
||||
state.ann_accuracy()
|
||||
# recall: 1.0, missing 0 out of 1, accuracy threshold 1e-06
|
||||
state.count()
|
||||
state.fields_match()
|
||||
state.log_size_below_max()
|
||||
state.no_duplicates()
|
||||
embedding_ids_1, embedding_ids_2 = state.add_embeddings(record_set={"ids": ["1", "2"], "embeddings": [[0.20556640625, 0.08978271484375], [-0.1527099609375, 0.291748046875]], "metadatas": [None, None], "documents": None}) # type: ignore
|
||||
state.ann_accuracy()
|
||||
# recall: 1.0, missing 0 out of 3, accuracy threshold 1e-06
|
||||
state.count()
|
||||
state.fields_match()
|
||||
state.log_size_below_max()
|
||||
state.no_duplicates()
|
||||
state.delete_by_ids(ids=[embedding_ids_2])
|
||||
state.ann_accuracy()
|
||||
state.teardown()
|
||||
|
||||
|
||||
# Ideally this scenario would be exercised by Hypothesis, but most runs don't seem to trigger this particular state.
|
||||
def test_delete_add_after_persist(settings: Settings) -> None:
|
||||
client = chromadb.Client(settings)
|
||||
state = PersistEmbeddingsStateMachine(settings=settings, client=client)
|
||||
|
||||
state.initialize(
|
||||
collection=strategies.Collection(
|
||||
name="A00",
|
||||
metadata={
|
||||
"hnsw:construction_ef": 128,
|
||||
"hnsw:search_ef": 128,
|
||||
"hnsw:M": 128,
|
||||
# Important: both batch_size and sync_threshold are 3
|
||||
"hnsw:batch_size": 3,
|
||||
"hnsw:sync_threshold": 3,
|
||||
},
|
||||
embedding_function=DefaultEmbeddingFunction(), # type: ignore[arg-type]
|
||||
id=UUID("0851f751-2f11-4424-ab23-4ae97074887a"),
|
||||
dimension=2,
|
||||
dtype=None,
|
||||
known_metadata_keys={},
|
||||
known_document_keywords=[],
|
||||
has_documents=False,
|
||||
has_embeddings=True,
|
||||
)
|
||||
)
|
||||
|
||||
state.add_embeddings(
|
||||
record_set={
|
||||
# Add 3 records to hit the batch_size and sync_threshold
|
||||
"ids": ["0", "1", "2"],
|
||||
"embeddings": [[0, 0], [0, 0], [0, 0]],
|
||||
"metadatas": [None, None, None],
|
||||
"documents": None,
|
||||
}
|
||||
)
|
||||
|
||||
# Delete and then re-add record
|
||||
state.delete_by_ids(ids=["0"])
|
||||
state.add_embeddings(
|
||||
record_set={
|
||||
"ids": ["0"],
|
||||
"embeddings": [[1, 1]],
|
||||
"metadatas": [None],
|
||||
"documents": None,
|
||||
}
|
||||
)
|
||||
|
||||
# At this point, the changes above are not fully persisted
|
||||
state.fields_match()
|
||||
|
||||
|
||||
def test_batch_size_less_than_sync_with_duplicate_adds_results_in_skipped_seq_ids(
|
||||
caplog: pytest.LogCaptureFixture, settings: Settings
|
||||
) -> None:
|
||||
# NOTE(hammadb) this test was autogenerate by hypothesis and added here to ensure that the test is run
|
||||
# in the future. It tests a case where the max seq id was incorrect in response to the same
|
||||
# id being added multiple times in a bathc.
|
||||
client = chromadb.Client(settings)
|
||||
state = PersistEmbeddingsStateMachine(settings=settings, client=client)
|
||||
state.initialize(
|
||||
collection=strategies.Collection(
|
||||
name="JqzMs4pPm14c",
|
||||
metadata={
|
||||
"hnsw:construction_ef": 128,
|
||||
"hnsw:search_ef": 128,
|
||||
"hnsw:M": 128,
|
||||
"hnsw:sync_threshold": 9,
|
||||
"hnsw:batch_size": 7,
|
||||
},
|
||||
embedding_function=hashing_embedding_function(dim=92, dtype=np.float64), # type: ignore[arg-type]
|
||||
id=UUID("45c5c816-0a90-4293-8d01-4325ff860040"),
|
||||
dimension=92,
|
||||
dtype=np.float64,
|
||||
known_metadata_keys={},
|
||||
known_document_keywords=[],
|
||||
has_documents=False,
|
||||
has_embeddings=True,
|
||||
)
|
||||
)
|
||||
state.ann_accuracy()
|
||||
state.count()
|
||||
state.fields_match()
|
||||
state.log_size_below_max()
|
||||
state.no_duplicates()
|
||||
(
|
||||
embedding_ids_0,
|
||||
embedding_ids_1,
|
||||
embedding_ids_2,
|
||||
embedding_ids_3,
|
||||
embedding_ids_4,
|
||||
embedding_ids_5,
|
||||
embedding_ids_6,
|
||||
) = cast(
|
||||
MultipleResults[str],
|
||||
state.add_embeddings(
|
||||
record_set={
|
||||
"ids": ["N", "e8r6", "4", "Yao", "qFjA2c", "jHCv", "2"],
|
||||
"embeddings": [
|
||||
[0.0, 0.0, 0.0],
|
||||
[1.0, 1.0, 1.0],
|
||||
[2.0, 2.0, 2.0],
|
||||
[3.0, 3.0, 3.0],
|
||||
[4.0, 4.0, 4.0],
|
||||
[5.0, 5.0, 5.0],
|
||||
[6.0, 6.0, 6.0],
|
||||
],
|
||||
"metadatas": None,
|
||||
"documents": None,
|
||||
}
|
||||
),
|
||||
)
|
||||
state.ann_accuracy()
|
||||
# recall: 1.0, missing 0 out of 7, accuracy threshold 1e-06
|
||||
state.count()
|
||||
state.fields_match()
|
||||
state.log_size_below_max()
|
||||
state.no_duplicates()
|
||||
|
||||
print("\n\n")
|
||||
(_) = state.add_embeddings(
|
||||
record_set={
|
||||
"ids": ["MVu393QTc"],
|
||||
"embeddings": [[7.0, 7.0, 7.0]],
|
||||
"metadatas": None,
|
||||
"documents": None,
|
||||
}
|
||||
)
|
||||
state.ann_accuracy()
|
||||
# recall: 1.0, missing 0 out of 8, accuracy threshold 1e-06
|
||||
state.count()
|
||||
state.fields_match()
|
||||
state.log_size_below_max()
|
||||
state.no_duplicates()
|
||||
|
||||
(
|
||||
_,
|
||||
_,
|
||||
_,
|
||||
_,
|
||||
embedding_ids_12,
|
||||
_,
|
||||
_,
|
||||
_,
|
||||
_,
|
||||
embedding_ids_17,
|
||||
embedding_ids_18,
|
||||
_,
|
||||
_,
|
||||
_,
|
||||
embedding_ids_22,
|
||||
_,
|
||||
_,
|
||||
) = cast(
|
||||
MultipleResults[str],
|
||||
state.add_embeddings(
|
||||
record_set={
|
||||
"ids": [
|
||||
"CyF0Mk-",
|
||||
"q_Fwu",
|
||||
"2D2sQSFogDgPLkcfT",
|
||||
"SrwuQHQ6w4f51qWr2enLPQw8uKYs1",
|
||||
"G",
|
||||
"wdzt",
|
||||
"5W",
|
||||
"8tpsn",
|
||||
"fJbV7z",
|
||||
"5",
|
||||
"V",
|
||||
"1iFkoJX",
|
||||
"Zw4u",
|
||||
"Fc",
|
||||
"7",
|
||||
"vEEwrP",
|
||||
"Yf",
|
||||
],
|
||||
"embeddings": [
|
||||
[8.0, 8.0, 8.0],
|
||||
[9.0, 9.0, 9.0],
|
||||
[10.0, 10.0, 10.0],
|
||||
[11.0, 11.0, 11.0],
|
||||
[12.0, 12.0, 12.0],
|
||||
[13.0, 13.0, 13.0],
|
||||
[14.0, 14.0, 14.0],
|
||||
[15.0, 15.0, 15.0],
|
||||
[16.0, 16.0, 16.0],
|
||||
[17.0, 17.0, 17.0],
|
||||
[18.0, 18.0, 18.0],
|
||||
[19.0, 19.0, 19.0],
|
||||
[20.0, 20.0, 20.0],
|
||||
[21.0, 21.0, 21.0],
|
||||
[22.0, 22.0, 22.0],
|
||||
[23.0, 23.0, 23.0],
|
||||
[24.0, 24.0, 24.0],
|
||||
],
|
||||
"metadatas": None,
|
||||
"documents": None,
|
||||
}
|
||||
),
|
||||
)
|
||||
state.ann_accuracy()
|
||||
state.count()
|
||||
state.fields_match()
|
||||
state.log_size_below_max()
|
||||
state.no_duplicates()
|
||||
|
||||
state.add_embeddings(
|
||||
record_set={
|
||||
"ids": ["0", "df_RWhR0HelOcv"],
|
||||
"embeddings": [[25.0, 25.0, 25.0], [26.0, 26.0, 26.0]],
|
||||
"metadatas": [None, None],
|
||||
"documents": None,
|
||||
}
|
||||
)
|
||||
state.ann_accuracy()
|
||||
state.count()
|
||||
state.fields_match()
|
||||
state.log_size_below_max()
|
||||
state.no_duplicates()
|
||||
|
||||
state.add_embeddings(
|
||||
record_set={
|
||||
"ids": ["3R", "9_", "44u", "3B", "MZCXZDS", "Uelx"],
|
||||
"embeddings": [
|
||||
[27.0, 27.0, 27.0],
|
||||
[28.0, 28.0, 28.0],
|
||||
[29.0, 29.0, 29.0],
|
||||
[30.0, 30.0, 30.0],
|
||||
[31.0, 31.0, 31.0],
|
||||
[32.0, 32.0, 32.0],
|
||||
],
|
||||
"metadatas": None,
|
||||
"documents": None,
|
||||
}
|
||||
)
|
||||
state.ann_accuracy()
|
||||
state.count()
|
||||
state.fields_match()
|
||||
state.log_size_below_max()
|
||||
state.no_duplicates()
|
||||
state.persist()
|
||||
state.ann_accuracy()
|
||||
state.count()
|
||||
state.fields_match()
|
||||
state.log_size_below_max()
|
||||
state.no_duplicates()
|
||||
|
||||
state.add_embeddings(
|
||||
record_set={
|
||||
"ids": "YlVm",
|
||||
"embeddings": [[33.0, 33.0, 33.0]],
|
||||
"metadatas": None,
|
||||
"documents": None,
|
||||
}
|
||||
)
|
||||
state.ann_accuracy()
|
||||
# recall: 1.0, missing 0 out of 34, accuracy threshold 1e-06
|
||||
state.count()
|
||||
state.fields_match()
|
||||
state.log_size_below_max()
|
||||
state.no_duplicates()
|
||||
|
||||
state.add_embeddings(
|
||||
record_set={
|
||||
"ids": ["Rk1", "TPL"],
|
||||
"embeddings": [[34.0, 34.0, 34.0], [35.0, 35.0, 35.0]],
|
||||
"metadatas": [None, None],
|
||||
"documents": None,
|
||||
}
|
||||
)
|
||||
state.ann_accuracy()
|
||||
# recall: 1.0, missing 0 out of 36, accuracy threshold 1e-06
|
||||
state.count()
|
||||
state.fields_match()
|
||||
state.log_size_below_max()
|
||||
state.no_duplicates()
|
||||
|
||||
state.add_embeddings(
|
||||
record_set={
|
||||
"ids": [
|
||||
"CyF0Mk-",
|
||||
"q_Fwu",
|
||||
"2D2sQSFogDgPLkcfT",
|
||||
"SrwuQHQ6w4f51qWr2enLPQw8uKYs1",
|
||||
embedding_ids_12,
|
||||
"wdzt",
|
||||
"5W",
|
||||
"8tpsn",
|
||||
"fJbV7z",
|
||||
embedding_ids_17,
|
||||
embedding_ids_18,
|
||||
"1iFkoJX",
|
||||
"Zw4u",
|
||||
"Fc",
|
||||
embedding_ids_22,
|
||||
"vEEwrP",
|
||||
"Yf",
|
||||
],
|
||||
"embeddings": [
|
||||
[8.0, 8.0, 8.0],
|
||||
[9.0, 9.0, 9.0],
|
||||
[10.0, 10.0, 10.0],
|
||||
[11.0, 11.0, 11.0],
|
||||
[12.0, 12.0, 12.0],
|
||||
[13.0, 13.0, 13.0],
|
||||
[14.0, 14.0, 14.0],
|
||||
[15.0, 15.0, 15.0],
|
||||
[16.0, 16.0, 16.0],
|
||||
[17.0, 17.0, 17.0],
|
||||
[18.0, 18.0, 18.0],
|
||||
[19.0, 19.0, 19.0],
|
||||
[20.0, 20.0, 20.0],
|
||||
[21.0, 21.0, 21.0],
|
||||
[22.0, 22.0, 22.0],
|
||||
[23.0, 23.0, 23.0],
|
||||
[24.0, 24.0, 24.0],
|
||||
],
|
||||
"metadatas": None,
|
||||
"documents": None,
|
||||
}
|
||||
)
|
||||
state.ann_accuracy()
|
||||
state.count()
|
||||
state.fields_match()
|
||||
state.log_size_below_max()
|
||||
state.teardown()
|
||||
@@ -0,0 +1,86 @@
|
||||
from overrides import overrides
|
||||
from chromadb.api.client import Client
|
||||
from chromadb.config import System
|
||||
import hypothesis.strategies as st
|
||||
from hypothesis.stateful import (
|
||||
rule,
|
||||
run_state_machine_as_test,
|
||||
initialize,
|
||||
)
|
||||
|
||||
from chromadb.test.property.test_embeddings import (
|
||||
EmbeddingStateMachineBase,
|
||||
EmbeddingStateMachineStates,
|
||||
trace,
|
||||
)
|
||||
import chromadb.test.property.strategies as strategies
|
||||
import os
|
||||
|
||||
|
||||
collection_persistent_st = st.shared(
|
||||
strategies.collections(
|
||||
with_hnsw_params=True,
|
||||
with_persistent_hnsw_params=st.just(True),
|
||||
# Makes it more likely to find persist-related bugs (by default these are set to 2000).
|
||||
max_hnsw_batch_size=10,
|
||||
max_hnsw_sync_threshold=10,
|
||||
),
|
||||
key="coll_persistent",
|
||||
)
|
||||
|
||||
|
||||
# This machine shares a lot of similarity with the machine in chromadb/test/property/test_persist.py.
|
||||
# However, test_persist.py tests correctness under complete process isolation and therefore can only check invariants on a new system--whereas this machine does not have full process isolation between systems/clients but after a restart continues to exercise the state machine with the newly-created system.
|
||||
class RestartablePersistedEmbeddingStateMachine(EmbeddingStateMachineBase):
|
||||
system: System
|
||||
|
||||
def __init__(self, system: System) -> None:
|
||||
self.system = system
|
||||
client = Client.from_system(system)
|
||||
super().__init__(client)
|
||||
|
||||
@initialize(collection=collection_persistent_st) # type: ignore
|
||||
@overrides
|
||||
def initialize(self, collection: strategies.Collection):
|
||||
self.client.reset()
|
||||
|
||||
self.collection = self.client.create_collection(
|
||||
name=collection.name,
|
||||
metadata=collection.metadata, # type: ignore
|
||||
embedding_function=collection.embedding_function,
|
||||
)
|
||||
self.embedding_function = collection.embedding_function
|
||||
trace("init")
|
||||
self.on_state_change(EmbeddingStateMachineStates.initialize)
|
||||
|
||||
self.record_set_state = strategies.StateMachineRecordSet(
|
||||
ids=[], metadatas=[], documents=[], embeddings=[]
|
||||
)
|
||||
|
||||
@rule()
|
||||
def restart_system(self) -> None:
|
||||
# Simulates restarting chromadb
|
||||
self.system.stop()
|
||||
self.system = System(self.system.settings)
|
||||
self.system.start()
|
||||
self.client.clear_system_cache()
|
||||
self.client = Client.from_system(self.system)
|
||||
self.collection = self.client.get_collection(
|
||||
self.collection.name, embedding_function=self.embedding_function
|
||||
)
|
||||
|
||||
@overrides
|
||||
def teardown(self) -> None:
|
||||
super().teardown()
|
||||
# Need to manually stop the system to cleanup resources because we may have created a new system (above rule).
|
||||
# Normally, we wouldn't have to worry about this as the system from the fixture is shared between state machine runs.
|
||||
# (This helps avoid a "too many open files" error.)
|
||||
self.system.stop()
|
||||
|
||||
|
||||
def test_restart_persisted_client(sqlite_persistent: System) -> None:
|
||||
# TODO: This test is broken for rust bindings and should be fixed
|
||||
if sqlite_persistent.settings.chroma_api_impl != "chromadb.api.rust.RustBindingsAPI":
|
||||
run_state_machine_as_test(
|
||||
lambda: RestartablePersistedEmbeddingStateMachine(sqlite_persistent),
|
||||
) # type: ignore
|
||||
@@ -0,0 +1,123 @@
|
||||
# Tests the CustomResourceMemberlist provider
|
||||
from dataclasses import asdict
|
||||
import threading
|
||||
from chromadb.test.conftest import skip_if_not_cluster
|
||||
from kubernetes import client, config
|
||||
from chromadb.config import System, Settings
|
||||
from chromadb.segment.distributed import Memberlist, Member
|
||||
from chromadb.segment.impl.distributed.segment_directory import (
|
||||
CustomResourceMemberlistProvider,
|
||||
KUBERNETES_GROUP,
|
||||
KUBERNETES_NAMESPACE,
|
||||
)
|
||||
import time
|
||||
|
||||
|
||||
# Used for testing to update the memberlist CRD
|
||||
def update_memberlist(n: int, memberlist_name: str = "test-memberlist") -> Memberlist:
|
||||
config.load_config()
|
||||
api_instance = client.CustomObjectsApi()
|
||||
|
||||
members = [
|
||||
Member(id=f"test-{i}", ip=f"10.0.0.{i}", node="node-{i}")
|
||||
for i in range(1, n + 1)
|
||||
]
|
||||
|
||||
body = {
|
||||
"kind": "MemberList",
|
||||
"metadata": {"name": memberlist_name},
|
||||
"spec": {
|
||||
"members": [
|
||||
{"member_id": m.id, "member_ip": m.ip, "member_node_name": m.node}
|
||||
for m in members
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
_ = api_instance.patch_namespaced_custom_object(
|
||||
group=KUBERNETES_GROUP,
|
||||
version="v1",
|
||||
namespace=KUBERNETES_NAMESPACE,
|
||||
plural="memberlists",
|
||||
name=memberlist_name,
|
||||
body=body,
|
||||
)
|
||||
|
||||
return members
|
||||
|
||||
|
||||
def compare_memberlists(m1: Memberlist, m2: Memberlist) -> bool:
|
||||
m1_as_dict = sorted([asdict(m) for m in m1], key=lambda x: x["id"])
|
||||
m2_as_dict = sorted([asdict(m) for m in m2], key=lambda x: x["id"])
|
||||
return m1_as_dict == m2_as_dict
|
||||
|
||||
|
||||
@skip_if_not_cluster()
|
||||
def test_can_get_memberlist() -> None:
|
||||
# This test assumes that the memberlist CRD is already created with the name "test-memberlist"
|
||||
system = System(Settings(allow_reset=True))
|
||||
provider = system.instance(CustomResourceMemberlistProvider)
|
||||
provider.set_memberlist_name("test-memberlist")
|
||||
system.reset_state()
|
||||
system.start()
|
||||
|
||||
# Update the memberlist
|
||||
members = update_memberlist(3)
|
||||
|
||||
# Check that the memberlist is updated after a short delay
|
||||
time.sleep(2)
|
||||
assert compare_memberlists(provider.get_memberlist(), members)
|
||||
|
||||
system.stop()
|
||||
|
||||
|
||||
@skip_if_not_cluster()
|
||||
def test_can_update_memberlist_multiple_times() -> None:
|
||||
# This test assumes that the memberlist CRD is already created with the name "test-memberlist"
|
||||
system = System(Settings(allow_reset=True))
|
||||
provider = system.instance(CustomResourceMemberlistProvider)
|
||||
provider.set_memberlist_name("test-memberlist")
|
||||
system.reset_state()
|
||||
system.start()
|
||||
|
||||
# Update the memberlist
|
||||
members = update_memberlist(3)
|
||||
|
||||
# Check that the memberlist is updated after a short delay
|
||||
time.sleep(2)
|
||||
assert compare_memberlists(provider.get_memberlist(), members)
|
||||
|
||||
# Update the memberlist again
|
||||
members = update_memberlist(5)
|
||||
|
||||
# Check that the memberlist is updated after a short delay
|
||||
time.sleep(2)
|
||||
assert compare_memberlists(provider.get_memberlist(), members)
|
||||
|
||||
system.stop()
|
||||
|
||||
|
||||
@skip_if_not_cluster()
|
||||
def test_stop_memberlist_kills_thread() -> None:
|
||||
# This test assumes that the memberlist CRD is already created with the name "test-memberlist"
|
||||
system = System(Settings(allow_reset=True))
|
||||
provider = system.instance(CustomResourceMemberlistProvider)
|
||||
provider.set_memberlist_name("test-memberlist")
|
||||
system.reset_state()
|
||||
system.start()
|
||||
|
||||
# Make sure a background thread is running
|
||||
assert len(threading.enumerate()) == 2
|
||||
|
||||
# Update the memberlist
|
||||
members = update_memberlist(3)
|
||||
|
||||
# Check that the memberlist is updated after a short delay
|
||||
time.sleep(2)
|
||||
assert compare_memberlists(provider.get_memberlist(), members)
|
||||
|
||||
# Stop the system
|
||||
system.stop()
|
||||
|
||||
# Check to make sure only one thread is running
|
||||
assert len(threading.enumerate()) == 1
|
||||
@@ -0,0 +1,64 @@
|
||||
from chromadb.utils.rendezvous_hash import assign, murmur3hasher
|
||||
from math import sqrt
|
||||
|
||||
|
||||
def test_rendezvous_hash() -> None:
|
||||
# Tests the assign works as expected
|
||||
members = ["a", "b", "c"]
|
||||
key = "key"
|
||||
|
||||
def mock_hasher(member: str, key: str) -> int:
|
||||
return members.index(member) # Highest index wins
|
||||
|
||||
assert assign(key, members, mock_hasher, 1)[0] == "c"
|
||||
|
||||
|
||||
def test_even_distribution() -> None:
|
||||
member_count = 10
|
||||
num_keys = 1000
|
||||
nodes = [str(i) for i in range(member_count)]
|
||||
|
||||
expected = num_keys / len(nodes)
|
||||
# Std deviation of a binomial distribution is sqrt(n * p * (1 - p))
|
||||
# where n is the number of trials, and p is the probability of success
|
||||
stddev = sqrt(num_keys * (1 / len(nodes)) * (1 - 1 / len(nodes)))
|
||||
# https://en.wikipedia.org/wiki/68%E2%80%9395%E2%80%9399.7_rule
|
||||
# For a 99.7% confidence interval
|
||||
tolerance = 3 * stddev
|
||||
|
||||
# Test if keys are evenly distributed across nodes
|
||||
key_distribution = {node: 0 for node in nodes}
|
||||
for i in range(num_keys):
|
||||
key = f"key_{i}"
|
||||
node = assign(key, nodes, murmur3hasher, 1)[0]
|
||||
key_distribution[node] += 1
|
||||
|
||||
# Check if keys are somewhat evenly distributed
|
||||
for node in nodes:
|
||||
assert abs(key_distribution[node] - expected) < tolerance
|
||||
|
||||
|
||||
def test_multi_assign_even_distribution() -> None:
|
||||
member_count = 10
|
||||
num_keys = 10000
|
||||
replication = 3
|
||||
nodes = [str(i) for i in range(member_count)]
|
||||
expected = num_keys / len(nodes) * replication
|
||||
|
||||
stddev = sqrt(num_keys * replication * (1 / len(nodes)) * (1 - 1 / len(nodes)))
|
||||
tolerance = 3 * stddev
|
||||
|
||||
# Test if keys are evenly distributed across nodes
|
||||
key_distribution = {node: 0 for node in nodes}
|
||||
for i in range(num_keys):
|
||||
key = f"key_{i}"
|
||||
nodes_assigned = assign(key, nodes, murmur3hasher, replication)
|
||||
# Should be three unique nodes
|
||||
assert len(set(nodes_assigned)) == replication
|
||||
for node in nodes_assigned:
|
||||
key_distribution[node] += 1
|
||||
|
||||
# Check if keys are somewhat evenly distributed
|
||||
for node in nodes:
|
||||
# 3k keys expected for each node (10000 keys / 10 nodes * 3 replication)
|
||||
assert abs(key_distribution[node] - expected) < tolerance
|
||||
@@ -0,0 +1,37 @@
|
||||
from typing import List
|
||||
import numpy as np
|
||||
|
||||
from chromadb.api import ServerAPI
|
||||
from chromadb.api.models.Collection import Collection
|
||||
|
||||
|
||||
def test_many_collections(client: ServerAPI) -> None:
|
||||
"""Test that we can create a large number of collections and that the system
|
||||
# remains responsive."""
|
||||
client.reset()
|
||||
|
||||
N = 10
|
||||
D = 10
|
||||
|
||||
metadata = None
|
||||
if client.get_settings().is_persistent:
|
||||
metadata = {"hnsw:batch_size": 3, "hnsw:sync_threshold": 3}
|
||||
else:
|
||||
# We only want to test persistent configurations in this way, since the main
|
||||
# point is to test the file handle limit
|
||||
return
|
||||
|
||||
num_collections = 10000
|
||||
collections: List[Collection] = []
|
||||
for i in range(num_collections):
|
||||
new_collection = client.create_collection(
|
||||
f"test_collection_{i}",
|
||||
metadata=metadata,
|
||||
)
|
||||
collections.append(new_collection)
|
||||
|
||||
# Add a few embeddings to each collection
|
||||
data = np.random.rand(N, D).tolist()
|
||||
ids = [f"test_id_{i}" for i in range(N)]
|
||||
for i in range(num_collections):
|
||||
collections[i].add(ids, data)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,116 @@
|
||||
import unittest
|
||||
import os
|
||||
from unittest.mock import patch, Mock
|
||||
import pytest
|
||||
import chromadb
|
||||
import chromadb.config
|
||||
from chromadb.db.system import SysDB
|
||||
from chromadb.ingest import Consumer, Producer
|
||||
|
||||
|
||||
class GetDBTest(unittest.TestCase):
|
||||
@patch("chromadb.db.impl.sqlite.SqliteDB", autospec=True)
|
||||
def test_default_db(self, mock: Mock) -> None:
|
||||
system = chromadb.config.System(
|
||||
chromadb.config.Settings(persist_directory="./foo")
|
||||
)
|
||||
system.instance(SysDB)
|
||||
assert mock.called
|
||||
|
||||
@patch("chromadb.db.impl.sqlite.SqliteDB", autospec=True)
|
||||
def test_sqlite_sysdb(self, mock: Mock) -> None:
|
||||
system = chromadb.config.System(
|
||||
chromadb.config.Settings(
|
||||
chroma_sysdb_impl="chromadb.db.impl.sqlite.SqliteDB",
|
||||
persist_directory="./foo",
|
||||
)
|
||||
)
|
||||
system.instance(SysDB)
|
||||
assert mock.called
|
||||
|
||||
@patch("chromadb.db.impl.sqlite.SqliteDB", autospec=True)
|
||||
def test_sqlite_queue(self, mock: Mock) -> None:
|
||||
system = chromadb.config.System(
|
||||
chromadb.config.Settings(
|
||||
chroma_sysdb_impl="chromadb.db.impl.sqlite.SqliteDB",
|
||||
chroma_producer_impl="chromadb.db.impl.sqlite.SqliteDB",
|
||||
chroma_consumer_impl="chromadb.db.impl.sqlite.SqliteDB",
|
||||
persist_directory="./foo",
|
||||
)
|
||||
)
|
||||
system.instance(Producer)
|
||||
system.instance(Consumer)
|
||||
assert mock.called
|
||||
|
||||
|
||||
class GetAPITest(unittest.TestCase):
|
||||
@patch("chromadb.api.segment.SegmentAPI", autospec=True)
|
||||
@patch.dict(
|
||||
os.environ, {"CHROMA_API_IMPL": "chromadb.api.segment.SegmentAPI"}, clear=True
|
||||
)
|
||||
def test_local(self, mock_api: Mock) -> None:
|
||||
client = chromadb.Client(chromadb.config.Settings(persist_directory="./foo"))
|
||||
assert mock_api.called
|
||||
client.clear_system_cache()
|
||||
|
||||
@patch("chromadb.db.impl.sqlite.SqliteDB", autospec=True)
|
||||
@patch.dict(
|
||||
os.environ, {"CHROMA_API_IMPL": "chromadb.api.segment.SegmentAPI"}, clear=True
|
||||
)
|
||||
def test_local_db(self, mock_db: Mock) -> None:
|
||||
client = chromadb.Client(chromadb.config.Settings(persist_directory="./foo"))
|
||||
assert mock_db.called
|
||||
client.clear_system_cache()
|
||||
|
||||
@patch("chromadb.api.fastapi.FastAPI", autospec=True)
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_fastapi(self, mock: Mock) -> None:
|
||||
client = chromadb.Client(
|
||||
chromadb.config.Settings(
|
||||
chroma_api_impl="chromadb.api.fastapi.FastAPI",
|
||||
persist_directory="./foo",
|
||||
chroma_server_host="foo",
|
||||
chroma_server_http_port=80,
|
||||
)
|
||||
)
|
||||
assert mock.called
|
||||
client.clear_system_cache()
|
||||
|
||||
@patch("chromadb.api.fastapi.FastAPI", autospec=True)
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_settings_pass_to_fastapi(self, mock: Mock) -> None:
|
||||
settings = chromadb.config.Settings(
|
||||
chroma_api_impl="chromadb.api.fastapi.FastAPI",
|
||||
chroma_server_host="foo",
|
||||
chroma_server_http_port=80,
|
||||
chroma_server_headers={"foo": "bar"},
|
||||
)
|
||||
client = chromadb.Client(settings)
|
||||
|
||||
# Check that the mock was called
|
||||
assert mock.called
|
||||
|
||||
# Retrieve the arguments with which the mock was called
|
||||
# `call_args` returns a tuple, where the first element is a tuple of positional arguments
|
||||
# and the second element is a dictionary of keyword arguments. We assume here that
|
||||
# the settings object is passed as a positional argument.
|
||||
args, kwargs = mock.call_args
|
||||
passed_settings = args[0] if args else None
|
||||
|
||||
# Check if the settings passed to the mock match the settings we used
|
||||
# raise Exception(passed_settings.settings)
|
||||
assert passed_settings.settings == settings
|
||||
client.clear_system_cache()
|
||||
|
||||
|
||||
def test_legacy_values() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
client = chromadb.Client(
|
||||
chromadb.config.Settings(
|
||||
chroma_api_impl="chromadb.api.local.LocalAPI",
|
||||
persist_directory="./foo",
|
||||
chroma_server_host="foo",
|
||||
chroma_server_http_port=80,
|
||||
)
|
||||
)
|
||||
client.clear_system_cache()
|
||||
@@ -0,0 +1,157 @@
|
||||
import multiprocessing
|
||||
import multiprocessing.context
|
||||
import sys
|
||||
import time
|
||||
from multiprocessing.synchronize import Event
|
||||
|
||||
import chromadb
|
||||
from chromadb.api.client import Client
|
||||
from chromadb.api.models.Collection import Collection
|
||||
from chromadb.cli import cli
|
||||
from chromadb.cli.cli import build_cli_args
|
||||
from chromadb.config import Settings, System
|
||||
from chromadb.db.base import get_sql
|
||||
from chromadb.db.impl.sqlite import SqliteDB
|
||||
from pypika import Table
|
||||
import numpy as np
|
||||
|
||||
from chromadb.test.property import invariants
|
||||
|
||||
|
||||
def wait_for_server(
|
||||
host: str, port: int,
|
||||
max_retries: int = 5, initial_delay: float = 1.0
|
||||
) -> bool:
|
||||
"""Wait for server to be ready using exponential backoff.
|
||||
Args:
|
||||
client: ChromaDB client instance
|
||||
max_retries: Maximum number of retry attempts
|
||||
initial_delay: Initial delay in seconds before first retry
|
||||
Returns:
|
||||
bool: True if server is ready, False if max retries exceeded
|
||||
"""
|
||||
delay = initial_delay
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
client = chromadb.HttpClient(host=host, port=port)
|
||||
heartbeat = client.heartbeat()
|
||||
if heartbeat > 0:
|
||||
return True
|
||||
except Exception:
|
||||
print("Heartbeat failed, trying again...")
|
||||
pass
|
||||
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(delay)
|
||||
delay *= 2
|
||||
|
||||
return False
|
||||
|
||||
def start_app(args: list[str]) -> None:
|
||||
sys.argv = args
|
||||
cli.app()
|
||||
|
||||
def test_app() -> None:
|
||||
kwargs = {"path": "chroma_test_data", "port": 8001}
|
||||
args = ["chroma", "run"]
|
||||
args.extend(build_cli_args(**kwargs))
|
||||
print(args)
|
||||
server_process = multiprocessing.Process(target=start_app, args=(args,))
|
||||
server_process.start()
|
||||
time.sleep(5)
|
||||
|
||||
assert wait_for_server(host="localhost", port=8001), "Server failed to start within maximum retry attempts"
|
||||
|
||||
server_process.terminate()
|
||||
server_process.join()
|
||||
|
||||
|
||||
def test_vacuum(sqlite_persistent: System) -> None:
|
||||
system = sqlite_persistent
|
||||
sqlite = system.instance(SqliteDB)
|
||||
|
||||
# This is True because it's a fresh system, so let's set it to False to test that the vacuum command enables it
|
||||
config = sqlite.config
|
||||
config.set_parameter("automatically_purge", False)
|
||||
sqlite.set_config(config)
|
||||
|
||||
# Add some data
|
||||
client = Client.from_system(system)
|
||||
collection1 = client.create_collection("collection1")
|
||||
collection2 = client.create_collection("collection2")
|
||||
|
||||
def add_records(collection: Collection, num: int) -> None:
|
||||
ids = [str(i) for i in range(num)]
|
||||
embeddings = np.random.rand(num, 2)
|
||||
collection.add(ids=ids, embeddings=embeddings)
|
||||
|
||||
add_records(collection1, 100)
|
||||
add_records(collection2, 2_000)
|
||||
|
||||
# Maintenance log should be empty
|
||||
with sqlite.tx() as cur:
|
||||
t = Table("maintenance_log")
|
||||
q = sqlite.querybuilder().from_(t).select("*")
|
||||
sql, params = get_sql(q)
|
||||
cur.execute(sql, params)
|
||||
assert cur.fetchall() == []
|
||||
|
||||
sys.argv = ["chroma", "vacuum", "--path", system.settings.persist_directory, "--force"]
|
||||
cli.app()
|
||||
|
||||
# Maintenance log should have a vacuum entry
|
||||
with sqlite.tx() as cur:
|
||||
t = Table("maintenance_log")
|
||||
q = sqlite.querybuilder().from_(t).select("*")
|
||||
sql, params = get_sql(q)
|
||||
cur.execute(sql, params)
|
||||
rows = cur.fetchall()
|
||||
assert len(rows) == 1
|
||||
assert rows[0][2] == "vacuum"
|
||||
|
||||
# Automatic pruning should have been enabled
|
||||
if hasattr(sqlite, "config"):
|
||||
del (
|
||||
sqlite.config
|
||||
) # the CLI will end up starting a new instance of sqlite, so we need to force-refresh the cached config here
|
||||
assert sqlite.config.get_parameter("automatically_purge").value
|
||||
|
||||
# Log should be clean
|
||||
invariants.log_size_below_max(system, [collection1, collection2], True)
|
||||
|
||||
|
||||
def simulate_transactional_write(
|
||||
settings: Settings, ready_event: Event, shutdown_event: Event
|
||||
) -> None:
|
||||
system = System(settings=settings)
|
||||
system.start()
|
||||
sqlite = system.instance(SqliteDB)
|
||||
|
||||
with sqlite.tx() as cur:
|
||||
cur.execute("INSERT INTO tenants DEFAULT VALUES")
|
||||
ready_event.set()
|
||||
shutdown_event.wait()
|
||||
|
||||
system.stop()
|
||||
|
||||
|
||||
def test_vacuum_errors_if_locked(sqlite_persistent: System, capfd) -> None:
|
||||
"""Vacuum command should fail with details if there is a long-lived lock on the database."""
|
||||
ctx = multiprocessing.get_context("spawn")
|
||||
ready_event = ctx.Event()
|
||||
shutdown_event = ctx.Event()
|
||||
process = ctx.Process(
|
||||
target=simulate_transactional_write,
|
||||
args=(sqlite_persistent.settings, ready_event, shutdown_event),
|
||||
)
|
||||
process.start()
|
||||
ready_event.wait()
|
||||
|
||||
try:
|
||||
sys.argv = ["chroma", "vacuum", "--path", sqlite_persistent.settings.persist_directory, "--force", "--timeout", "10"]
|
||||
cli.app()
|
||||
captured = capfd.readouterr()
|
||||
assert "Failed to vacuum Chroma" in captured.err.strip()
|
||||
finally:
|
||||
shutdown_event.set()
|
||||
process.join()
|
||||
@@ -0,0 +1,112 @@
|
||||
import asyncio
|
||||
from typing import Any, Callable, Generator, cast
|
||||
from unittest.mock import patch
|
||||
import chromadb
|
||||
from chromadb.config import Settings
|
||||
from chromadb.api import ClientAPI
|
||||
import chromadb.server.fastapi
|
||||
import pytest
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ephemeral_api() -> Generator[ClientAPI, None, None]:
|
||||
if os.environ.get("CHROMA_INTEGRATION_TEST_ONLY"):
|
||||
pytest.skip("Integration test only")
|
||||
client = chromadb.EphemeralClient()
|
||||
yield client
|
||||
client.clear_system_cache()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def persistent_api() -> Generator[ClientAPI, None, None]:
|
||||
if os.environ.get("CHROMA_INTEGRATION_TEST_ONLY"):
|
||||
pytest.skip("Integration test only")
|
||||
client = chromadb.PersistentClient(
|
||||
path=tempfile.gettempdir() + "/test_server",
|
||||
)
|
||||
yield client
|
||||
client.clear_system_cache()
|
||||
|
||||
|
||||
HttpAPIFactory = Callable[..., ClientAPI]
|
||||
|
||||
|
||||
@pytest.fixture(params=["sync_client", "async_client"])
|
||||
def http_api_factory(
|
||||
request: pytest.FixtureRequest,
|
||||
) -> Generator[HttpAPIFactory, None, None]:
|
||||
if request.param == "sync_client":
|
||||
with patch("chromadb.api.client.Client._validate_tenant_database"):
|
||||
with patch("chromadb.api.client.Client.get_user_identity"):
|
||||
yield chromadb.HttpClient
|
||||
else:
|
||||
with patch("chromadb.api.async_client.AsyncClient._validate_tenant_database"):
|
||||
with patch("chromadb.api.async_client.AsyncClient.get_user_identity"):
|
||||
|
||||
def factory(*args: Any, **kwargs: Any) -> Any:
|
||||
cls = asyncio.get_event_loop().run_until_complete(
|
||||
chromadb.AsyncHttpClient(*args, **kwargs)
|
||||
)
|
||||
return cls
|
||||
|
||||
yield cast(HttpAPIFactory, factory)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def http_api(http_api_factory: HttpAPIFactory) -> Generator[ClientAPI, None, None]:
|
||||
if os.environ.get("CHROMA_SERVER_HTTP_PORT") is not None:
|
||||
port = int(os.environ.get("CHROMA_SERVER_HTTP_PORT")) # type: ignore
|
||||
client = http_api_factory(port=port)
|
||||
else:
|
||||
client = http_api_factory()
|
||||
yield client
|
||||
client.clear_system_cache()
|
||||
|
||||
|
||||
def test_ephemeral_client(ephemeral_api: ClientAPI) -> None:
|
||||
settings = ephemeral_api.get_settings()
|
||||
assert settings.is_persistent is False
|
||||
|
||||
|
||||
def test_persistent_client(persistent_api: ClientAPI) -> None:
|
||||
settings = persistent_api.get_settings()
|
||||
assert settings.is_persistent is True
|
||||
|
||||
|
||||
def test_http_client(http_api: ClientAPI) -> None:
|
||||
settings = http_api.get_settings()
|
||||
assert (
|
||||
settings.chroma_api_impl == "chromadb.api.fastapi.FastAPI"
|
||||
or settings.chroma_api_impl == "chromadb.api.async_fastapi.AsyncFastAPI"
|
||||
)
|
||||
|
||||
|
||||
def test_http_client_with_inconsistent_host_settings(
|
||||
http_api_factory: HttpAPIFactory,
|
||||
) -> None:
|
||||
try:
|
||||
http_api_factory(settings=Settings(chroma_server_host="127.0.0.1"))
|
||||
except ValueError as e:
|
||||
assert (
|
||||
str(e)
|
||||
== "Chroma server host provided in settings[127.0.0.1] is different to the one provided in HttpClient: [localhost]"
|
||||
)
|
||||
|
||||
|
||||
def test_http_client_with_inconsistent_port_settings(
|
||||
http_api_factory: HttpAPIFactory,
|
||||
) -> None:
|
||||
try:
|
||||
http_api_factory(
|
||||
port=8002,
|
||||
settings=Settings(
|
||||
chroma_server_http_port=8001,
|
||||
),
|
||||
)
|
||||
except ValueError as e:
|
||||
assert (
|
||||
str(e)
|
||||
== "Chroma server http port provided in settings[8001] is different to the one provided in HttpClient: [8002]"
|
||||
)
|
||||
@@ -0,0 +1,191 @@
|
||||
from chromadb.config import Component, System, Settings
|
||||
from overrides import overrides
|
||||
from threading import local
|
||||
import random
|
||||
|
||||
data = local() # use thread local just in case tests ever run in parallel
|
||||
|
||||
|
||||
def reset() -> None:
|
||||
global data
|
||||
data.starts = []
|
||||
data.stops = []
|
||||
data.inits = []
|
||||
|
||||
|
||||
class ComponentA(Component):
|
||||
def __init__(self, system: System):
|
||||
data.inits += "A"
|
||||
super().__init__(system)
|
||||
self.require(ComponentB)
|
||||
self.require(ComponentC)
|
||||
|
||||
@overrides
|
||||
def start(self) -> None:
|
||||
data.starts += "A"
|
||||
|
||||
@overrides
|
||||
def stop(self) -> None:
|
||||
data.stops += "A"
|
||||
|
||||
|
||||
class ComponentB(Component):
|
||||
def __init__(self, system: System):
|
||||
data.inits += "B"
|
||||
super().__init__(system)
|
||||
self.require(ComponentC)
|
||||
self.require(ComponentD)
|
||||
|
||||
@overrides
|
||||
def start(self) -> None:
|
||||
data.starts += "B"
|
||||
|
||||
@overrides
|
||||
def stop(self) -> None:
|
||||
data.stops += "B"
|
||||
|
||||
|
||||
class ComponentC(Component):
|
||||
def __init__(self, system: System):
|
||||
data.inits += "C"
|
||||
super().__init__(system)
|
||||
self.require(ComponentD)
|
||||
|
||||
@overrides
|
||||
def start(self) -> None:
|
||||
data.starts += "C"
|
||||
|
||||
@overrides
|
||||
def stop(self) -> None:
|
||||
data.stops += "C"
|
||||
|
||||
|
||||
class ComponentD(Component):
|
||||
def __init__(self, system: System):
|
||||
data.inits += "D"
|
||||
super().__init__(system)
|
||||
|
||||
@overrides
|
||||
def start(self) -> None:
|
||||
data.starts += "D"
|
||||
|
||||
@overrides
|
||||
def stop(self) -> None:
|
||||
data.stops += "D"
|
||||
|
||||
|
||||
# Dependency Graph for tests:
|
||||
# ┌───┐
|
||||
# │ A │
|
||||
# └┬─┬┘
|
||||
# │┌▽──┐
|
||||
# ││ B │
|
||||
# │└┬─┬┘
|
||||
# ┌▽─▽┐│
|
||||
# │ C ││
|
||||
# └┬──┘│
|
||||
# ┌▽───▽┐
|
||||
# │ D │
|
||||
# └─────┘
|
||||
|
||||
|
||||
def test_leaf_only() -> None:
|
||||
settings = Settings()
|
||||
system = System(settings)
|
||||
|
||||
reset()
|
||||
|
||||
d = system.instance(ComponentD)
|
||||
assert isinstance(d, ComponentD)
|
||||
|
||||
assert data.inits == ["D"]
|
||||
system.start()
|
||||
assert data.starts == ["D"]
|
||||
system.stop()
|
||||
assert data.stops == ["D"]
|
||||
|
||||
|
||||
def test_partial() -> None:
|
||||
settings = Settings()
|
||||
system = System(settings)
|
||||
|
||||
reset()
|
||||
|
||||
c = system.instance(ComponentC)
|
||||
assert isinstance(c, ComponentC)
|
||||
|
||||
assert data.inits == ["C", "D"]
|
||||
system.start()
|
||||
assert data.starts == ["D", "C"]
|
||||
system.stop()
|
||||
assert data.stops == ["C", "D"]
|
||||
|
||||
|
||||
def test_system_startup() -> None:
|
||||
settings = Settings()
|
||||
system = System(settings)
|
||||
|
||||
reset()
|
||||
|
||||
a = system.instance(ComponentA)
|
||||
assert isinstance(a, ComponentA)
|
||||
|
||||
assert data.inits == ["A", "B", "C", "D"]
|
||||
system.start()
|
||||
assert data.starts == ["D", "C", "B", "A"]
|
||||
system.stop()
|
||||
assert data.stops == ["A", "B", "C", "D"]
|
||||
|
||||
|
||||
def test_system_override_order() -> None:
|
||||
settings = Settings()
|
||||
system = System(settings)
|
||||
|
||||
reset()
|
||||
|
||||
system.instance(ComponentA)
|
||||
|
||||
# Deterministically shuffle the instances map to prove that topsort is actually
|
||||
# working and not just implicitly working because of insertion order.
|
||||
|
||||
# This causes the test to actually fail if the deps are not wired up correctly.
|
||||
random.seed(0)
|
||||
entries = list(system._instances.items())
|
||||
random.shuffle(entries)
|
||||
system._instances = {k: v for k, v in entries}
|
||||
|
||||
system.start()
|
||||
assert data.starts == ["D", "C", "B", "A"]
|
||||
system.stop()
|
||||
assert data.stops == ["A", "B", "C", "D"]
|
||||
|
||||
|
||||
class ComponentZ(Component):
|
||||
def __init__(self, system: System):
|
||||
super().__init__(system)
|
||||
self.require(ComponentC)
|
||||
|
||||
@overrides
|
||||
def start(self) -> None:
|
||||
pass
|
||||
|
||||
@overrides
|
||||
def stop(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def test_runtime_dependencies() -> None:
|
||||
settings = Settings()
|
||||
system = System(settings)
|
||||
|
||||
reset()
|
||||
|
||||
# Nothing to do, no components were requested prior to start
|
||||
system.start()
|
||||
assert data.starts == []
|
||||
|
||||
# Constructs dependencies and starts them in the correct order
|
||||
ComponentZ(system)
|
||||
assert data.starts == ["D", "C"]
|
||||
system.stop()
|
||||
assert data.stops == ["C", "D"]
|
||||
@@ -0,0 +1,229 @@
|
||||
import multiprocessing
|
||||
from concurrent.futures import Future, ThreadPoolExecutor, wait
|
||||
import random
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple, cast
|
||||
import numpy as np
|
||||
|
||||
from chromadb.api import ClientAPI
|
||||
import chromadb.test.property.invariants as invariants
|
||||
from chromadb.api.segment import SegmentAPI
|
||||
from chromadb.test.property.strategies import RecordSet
|
||||
from chromadb.test.property.strategies import test_hnsw_config
|
||||
from chromadb.types import Metadata
|
||||
|
||||
|
||||
def generate_data_shape() -> Tuple[int, int]:
|
||||
N = random.randint(10, 10000)
|
||||
D = random.randint(10, 256)
|
||||
return (N, D)
|
||||
|
||||
|
||||
def generate_record_set(N: int, D: int) -> RecordSet:
|
||||
ids = [str(i) for i in range(N)]
|
||||
metadatas: List[Dict[str, int]] = [{f"{i}": i} for i in range(N)]
|
||||
documents = [f"doc {i}" for i in range(N)]
|
||||
embeddings = np.random.rand(N, D).tolist()
|
||||
|
||||
# Create a normalized record set to compare against
|
||||
normalized_record_set: RecordSet = {
|
||||
"ids": ids,
|
||||
"embeddings": embeddings, # type: ignore
|
||||
"metadatas": metadatas, # type: ignore
|
||||
"documents": documents,
|
||||
}
|
||||
|
||||
return normalized_record_set
|
||||
|
||||
|
||||
# Hypothesis is bad at generating large datasets so we manually generate data in
|
||||
# this test to test multithreaded add with larger datasets
|
||||
def _test_multithreaded_add(
|
||||
client: ClientAPI, N: int, D: int, num_workers: int
|
||||
) -> None:
|
||||
records_set = generate_record_set(N, D)
|
||||
ids = records_set["ids"]
|
||||
embeddings = records_set["embeddings"]
|
||||
metadatas = records_set["metadatas"]
|
||||
documents = records_set["documents"]
|
||||
|
||||
print(f"Adding {N} records with {D} dimensions on {num_workers} workers")
|
||||
|
||||
# TODO: batch_size and sync_threshold should be configurable
|
||||
client.reset()
|
||||
coll = client.create_collection(name="test", metadata=test_hnsw_config)
|
||||
with ThreadPoolExecutor(max_workers=num_workers) as executor:
|
||||
futures: List[Future[Any]] = []
|
||||
total_sent = -1
|
||||
while total_sent < len(ids):
|
||||
# Randomly grab up to 10% of the dataset and send it to the executor
|
||||
batch_size = random.randint(1, N // 10)
|
||||
to_send = min(batch_size, len(ids) - total_sent)
|
||||
start = total_sent + 1
|
||||
end = total_sent + to_send + 1
|
||||
if embeddings is not None and len(embeddings[start:end]) == 0:
|
||||
break
|
||||
future = executor.submit(
|
||||
coll.add,
|
||||
ids=ids[start:end],
|
||||
embeddings=embeddings[start:end] if embeddings is not None else None,
|
||||
metadatas=metadatas[start:end] if metadatas is not None else None, # type: ignore
|
||||
documents=documents[start:end] if documents is not None else None,
|
||||
)
|
||||
futures.append(future)
|
||||
total_sent += to_send
|
||||
|
||||
wait(futures)
|
||||
|
||||
for future in futures:
|
||||
exception = future.exception()
|
||||
if exception is not None:
|
||||
raise exception
|
||||
|
||||
# Check that invariants hold
|
||||
invariants.count(coll, records_set)
|
||||
invariants.ids_match(coll, records_set)
|
||||
invariants.metadatas_match(coll, records_set)
|
||||
invariants.no_duplicates(coll)
|
||||
|
||||
# Check that the ANN accuracy is good
|
||||
# On a random subset of the dataset
|
||||
query_indices = random.sample([i for i in range(N)], 10)
|
||||
n_results = 5
|
||||
invariants.ann_accuracy(
|
||||
coll,
|
||||
records_set,
|
||||
n_results=n_results,
|
||||
query_indices=query_indices,
|
||||
)
|
||||
|
||||
|
||||
def _test_interleaved_add_query(
|
||||
client: ClientAPI, N: int, D: int, num_workers: int
|
||||
) -> None:
|
||||
"""Test that will use multiple threads to interleave operations on the db and verify they work correctly"""
|
||||
|
||||
client.reset()
|
||||
coll = client.create_collection(name="test", metadata=test_hnsw_config)
|
||||
|
||||
records_set = generate_record_set(N, D)
|
||||
ids = cast(List[str], records_set["ids"])
|
||||
embeddings = cast(List[float], records_set["embeddings"])
|
||||
metadatas = cast(List[Metadata], records_set["metadatas"])
|
||||
documents = records_set["documents"]
|
||||
|
||||
added_ids: Set[str] = set()
|
||||
lock = threading.Lock()
|
||||
|
||||
print(f"Adding {N} records with {D} dimensions on {num_workers} workers")
|
||||
|
||||
def perform_operation(
|
||||
operation: int, ids_to_modify: Optional[List[str]] = None
|
||||
) -> None:
|
||||
"""Perform a random operation on the collection"""
|
||||
if operation == 0:
|
||||
assert ids_to_modify is not None
|
||||
indices_to_modify = [ids.index(id) for id in ids_to_modify]
|
||||
# Add a subset of the dataset
|
||||
if len(indices_to_modify) == 0:
|
||||
return
|
||||
coll.add(
|
||||
ids=ids_to_modify,
|
||||
embeddings=[embeddings[i] for i in indices_to_modify]
|
||||
if embeddings is not None
|
||||
else None,
|
||||
metadatas=[metadatas[i] for i in indices_to_modify]
|
||||
if metadatas is not None
|
||||
else None,
|
||||
documents=[documents[i] for i in indices_to_modify]
|
||||
if documents is not None
|
||||
else None,
|
||||
)
|
||||
with lock:
|
||||
added_ids.update(ids_to_modify)
|
||||
elif operation == 1:
|
||||
currently_added_ids = []
|
||||
n_results = 5
|
||||
with lock:
|
||||
currently_added_ids = list(added_ids.copy())
|
||||
currently_added_indices = [ids.index(id) for id in currently_added_ids]
|
||||
if (
|
||||
len(currently_added_ids) == 0
|
||||
or len(currently_added_indices) < n_results
|
||||
):
|
||||
return
|
||||
# Query the collection, we can't test the results because we want to interleave
|
||||
# queries and adds. We cannot do so without a lock and serializing the operations
|
||||
# which would defeat the purpose of this test. Instead we interleave queries and
|
||||
# adds and check the invariants at the end
|
||||
query_indices = random.sample(
|
||||
currently_added_indices,
|
||||
min(10, len(currently_added_indices)),
|
||||
)
|
||||
query_vectors = [embeddings[i] for i in query_indices]
|
||||
# Query the collections
|
||||
coll.query(
|
||||
query_vectors,
|
||||
n_results=n_results,
|
||||
)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=num_workers) as executor:
|
||||
futures: List[Future[Any]] = []
|
||||
total_sent = -1
|
||||
while total_sent < len(ids) - 1:
|
||||
operation = random.randint(0, 2)
|
||||
if operation == 0:
|
||||
# Randomly grab up to 10% of the dataset and send it to the executor
|
||||
batch_size = random.randint(1, N // 10)
|
||||
to_send = min(batch_size, len(ids) - total_sent)
|
||||
start = total_sent + 1
|
||||
end = total_sent + to_send + 1
|
||||
future = executor.submit(perform_operation, operation, ids[start:end])
|
||||
futures.append(future)
|
||||
total_sent += to_send
|
||||
elif operation == 1:
|
||||
future = executor.submit(
|
||||
perform_operation,
|
||||
operation,
|
||||
)
|
||||
futures.append(future)
|
||||
|
||||
wait(futures)
|
||||
|
||||
for future in futures:
|
||||
exception = future.exception()
|
||||
if exception is not None:
|
||||
raise exception
|
||||
if (
|
||||
isinstance(client, SegmentAPI) and client.get_settings().is_persistent is True
|
||||
): # we can't check invariants for FastAPI
|
||||
invariants.fd_not_exceeding_threadpool_size(num_workers)
|
||||
# Check that invariants hold
|
||||
invariants.count(coll, records_set)
|
||||
invariants.ids_match(coll, records_set)
|
||||
invariants.metadatas_match(coll, records_set)
|
||||
invariants.no_duplicates(coll)
|
||||
# Check that the ANN accuracy is good
|
||||
# On a random subset of the dataset
|
||||
query_indices = random.sample([i for i in range(N)], 10)
|
||||
n_results = 5
|
||||
invariants.ann_accuracy(
|
||||
coll,
|
||||
records_set,
|
||||
n_results=n_results,
|
||||
query_indices=query_indices,
|
||||
)
|
||||
|
||||
|
||||
def test_multithreaded_add(client: ClientAPI) -> None:
|
||||
for i in range(3):
|
||||
num_workers = random.randint(2, multiprocessing.cpu_count() * 2)
|
||||
N, D = generate_data_shape()
|
||||
_test_multithreaded_add(client, N, D, num_workers)
|
||||
|
||||
|
||||
def test_interleaved_add_query(client: ClientAPI) -> None:
|
||||
for i in range(3):
|
||||
num_workers = random.randint(2, multiprocessing.cpu_count() * 2)
|
||||
N, D = generate_data_shape()
|
||||
_test_interleaved_add_query(client, N, D, num_workers)
|
||||
@@ -0,0 +1,71 @@
|
||||
import sys
|
||||
import subprocess
|
||||
import os
|
||||
import tempfile
|
||||
from types import ModuleType
|
||||
from typing import Dict, List
|
||||
|
||||
base_install_dir = (
|
||||
tempfile.gettempdir()
|
||||
+ f"/worker-{os.environ.get('PYTEST_XDIST_WORKER', 'unknown')}"
|
||||
+ "/persistence_test_chromadb_versions"
|
||||
)
|
||||
|
||||
|
||||
def get_path_to_version_install(version: str) -> str:
|
||||
return base_install_dir + "/" + version
|
||||
|
||||
|
||||
def switch_to_version(version: str, versioned_modules: List[str]) -> ModuleType:
|
||||
module_name = "chromadb"
|
||||
# Remove old version from sys.modules, except test modules
|
||||
old_modules = {
|
||||
n: m
|
||||
for n, m in sys.modules.items()
|
||||
if n == module_name
|
||||
or (n.startswith(module_name + "."))
|
||||
or n in versioned_modules
|
||||
or (any(n.startswith(m + ".") for m in versioned_modules))
|
||||
}
|
||||
for n in old_modules:
|
||||
del sys.modules[n]
|
||||
# Load the target version and override the path to the installed version
|
||||
# https://docs.python.org/3/library/importlib.html#importing-a-source-file-directly
|
||||
sys.path.insert(0, get_path_to_version_install(version))
|
||||
import chromadb
|
||||
|
||||
assert chromadb.__version__ == version
|
||||
return chromadb
|
||||
|
||||
|
||||
def get_path_to_version_library(version: str) -> str:
|
||||
return get_path_to_version_install(version) + "/chromadb/__init__.py"
|
||||
|
||||
|
||||
def install_version(version: str, dep_overrides: Dict[str, str]) -> None:
|
||||
# Check if already installed
|
||||
version_library = get_path_to_version_library(version)
|
||||
if os.path.exists(version_library):
|
||||
return
|
||||
path = get_path_to_version_install(version)
|
||||
install(f"chromadb=={version}", path, dep_overrides)
|
||||
|
||||
|
||||
def install(pkg: str, path: str, dep_overrides: Dict[str, str]) -> int:
|
||||
os.makedirs(path, exist_ok=True)
|
||||
|
||||
# -q -q to suppress pip output to ERROR level
|
||||
# https://pip.pypa.io/en/stable/cli/pip/#quiet
|
||||
command = [sys.executable, "-m", "pip", "-q", "-q", "install", pkg]
|
||||
|
||||
for dep, operator_version in dep_overrides.items():
|
||||
command.append(f"{dep}{operator_version}")
|
||||
|
||||
# Only add --no-binary=chroma-hnswlib if it's in the dependencies
|
||||
if "chroma-hnswlib" in pkg or any("chroma-hnswlib" in dep for dep in dep_overrides):
|
||||
command.append("--no-binary=chroma-hnswlib")
|
||||
|
||||
command.append(f"--target={path}")
|
||||
|
||||
print(f"Installing chromadb version {pkg} to {path}")
|
||||
return subprocess.check_call(command)
|
||||
@@ -0,0 +1,7 @@
|
||||
from chromadb.utils.distance_functions import cosine
|
||||
import numpy as np
|
||||
|
||||
|
||||
def test_cosine_zero() -> None:
|
||||
x = np.array([0.0, 0.0], dtype=np.float16)
|
||||
assert cosine(x, x) == 1.0
|
||||
@@ -0,0 +1,145 @@
|
||||
import pytest
|
||||
from typing import List, Any, Callable
|
||||
from jsonschema import ValidationError
|
||||
from unittest.mock import MagicMock, create_autospec
|
||||
from chromadb.utils.embedding_functions.schemas import (
|
||||
validate_config_schema,
|
||||
load_schema,
|
||||
get_available_schemas,
|
||||
)
|
||||
from chromadb.utils.embedding_functions import known_embedding_functions
|
||||
from chromadb.api.types import Documents, Embeddings
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
# Skip these embedding functions in tests
|
||||
SKIP_EMBEDDING_FUNCTIONS = [
|
||||
"chroma_langchain",
|
||||
]
|
||||
|
||||
|
||||
def get_embedding_function_names() -> List[str]:
|
||||
"""Get all embedding function names to test"""
|
||||
return [
|
||||
name
|
||||
for name in known_embedding_functions.keys()
|
||||
if name not in SKIP_EMBEDDING_FUNCTIONS
|
||||
]
|
||||
|
||||
|
||||
class TestEmbeddingFunctionSchemas:
|
||||
"""Test class for embedding function schemas"""
|
||||
|
||||
@pytest.mark.parametrize("ef_name", get_embedding_function_names())
|
||||
def test_embedding_function_config_roundtrip(
|
||||
self,
|
||||
ef_name: str,
|
||||
mock_embeddings: Callable[[Documents], Embeddings],
|
||||
mock_common_deps: MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test embedding function configuration roundtrip"""
|
||||
ef_class = known_embedding_functions[ef_name]
|
||||
|
||||
# Create an autospec of the embedding function class
|
||||
mock_ef = create_autospec(ef_class, instance=True)
|
||||
|
||||
# Mock the __call__ method
|
||||
mock_call = MagicMock(return_value=mock_embeddings(["test"]))
|
||||
mock_ef.__call__ = mock_call
|
||||
|
||||
# For chroma-cloud-qwen, mock get_config to return valid data
|
||||
if ef_name == "chroma-cloud-qwen":
|
||||
from chromadb.utils.embedding_functions.chroma_cloud_qwen_embedding_function import (
|
||||
ChromaCloudQwenEmbeddingModel,
|
||||
CHROMA_CLOUD_QWEN_DEFAULT_INSTRUCTIONS,
|
||||
)
|
||||
|
||||
mock_ef.get_config.return_value = {
|
||||
"api_key_env_var": "CHROMA_API_KEY",
|
||||
"model": ChromaCloudQwenEmbeddingModel.QWEN3_EMBEDDING_0p6B.value,
|
||||
"task": "nl_to_code",
|
||||
"instructions": CHROMA_CLOUD_QWEN_DEFAULT_INSTRUCTIONS,
|
||||
}
|
||||
|
||||
# Mock the class constructor to return our mock instance
|
||||
mock_common_deps.setattr(
|
||||
ef_class, "__new__", lambda cls, *args, **kwargs: mock_ef
|
||||
)
|
||||
|
||||
# Create instance with minimal args (constructor will be mocked)
|
||||
ef_instance = ef_class()
|
||||
|
||||
# Get the config (this will use the real method)
|
||||
config = ef_instance.get_config()
|
||||
|
||||
# Test recreation from config
|
||||
new_instance = ef_class.build_from_config(config)
|
||||
new_config = new_instance.get_config()
|
||||
|
||||
# Configs should match
|
||||
assert (
|
||||
config == new_config
|
||||
), f"Configs don't match after recreation for {ef_name}"
|
||||
|
||||
def test_schema_required_fields(self) -> None:
|
||||
"""Test that schemas enforce required fields"""
|
||||
for schema_name in get_available_schemas():
|
||||
schema = load_schema(schema_name)
|
||||
if "required" not in schema:
|
||||
continue
|
||||
|
||||
# Create minimal valid config
|
||||
config = {}
|
||||
for field in schema["required"]:
|
||||
field_schema = schema["properties"][field]
|
||||
field_type = (
|
||||
field_schema["type"][0]
|
||||
if isinstance(field_schema["type"], list)
|
||||
else field_schema["type"]
|
||||
)
|
||||
config[field] = self._get_dummy_value(field_type)
|
||||
|
||||
# Test each required field
|
||||
for field in schema["required"]:
|
||||
test_config = config.copy()
|
||||
del test_config[field]
|
||||
with pytest.raises(ValidationError):
|
||||
validate_config_schema(test_config, schema_name)
|
||||
|
||||
@staticmethod
|
||||
def _get_dummy_value(field_type: str) -> Any:
|
||||
"""Get a dummy value for a given field type"""
|
||||
type_map = {
|
||||
"string": "dummy",
|
||||
"integer": 0,
|
||||
"number": 0.0,
|
||||
"boolean": False,
|
||||
"object": {},
|
||||
"array": [],
|
||||
}
|
||||
return type_map.get(field_type, "dummy")
|
||||
|
||||
def test_schema_additional_properties(self) -> None:
|
||||
"""Test that schemas reject additional properties"""
|
||||
for schema_name in get_available_schemas():
|
||||
schema = load_schema(schema_name)
|
||||
config = {}
|
||||
|
||||
# Add required fields
|
||||
if "required" in schema:
|
||||
for field in schema["required"]:
|
||||
field_schema = schema["properties"][field]
|
||||
field_type = (
|
||||
field_schema["type"][0]
|
||||
if isinstance(field_schema["type"], list)
|
||||
else field_schema["type"]
|
||||
)
|
||||
config[field] = self._get_dummy_value(field_type)
|
||||
|
||||
# Add additional property
|
||||
test_config = config.copy()
|
||||
test_config["additional_property"] = "value"
|
||||
|
||||
# Test validation
|
||||
if schema.get("additionalProperties", True) is False:
|
||||
with pytest.raises(ValidationError):
|
||||
validate_config_schema(test_config, schema_name)
|
||||
@@ -0,0 +1,184 @@
|
||||
import numpy as np
|
||||
from typing import List, Dict, Any, cast, Union
|
||||
from chromadb.utils.results import (
|
||||
_transform_embeddings,
|
||||
_add_query_fields,
|
||||
_add_get_fields,
|
||||
query_result_to_dfs,
|
||||
get_result_to_df,
|
||||
)
|
||||
from chromadb.api.types import (
|
||||
QueryResult,
|
||||
GetResult,
|
||||
)
|
||||
from numpy.typing import NDArray
|
||||
|
||||
|
||||
def test_transform_embeddings() -> None:
|
||||
# Test with None input
|
||||
assert _transform_embeddings(None) is None
|
||||
|
||||
# Test with numpy arrays
|
||||
embeddings = cast(
|
||||
List[NDArray[Union[np.int32, np.float32]]],
|
||||
[np.array([1.0, 2.0]), np.array([3.0, 4.0])],
|
||||
)
|
||||
transformed = _transform_embeddings(embeddings)
|
||||
assert isinstance(transformed, list)
|
||||
assert transformed == [[1.0, 2.0], [3.0, 4.0]]
|
||||
|
||||
# Test with list of lists
|
||||
embeddings = cast(
|
||||
List[NDArray[Union[np.int32, np.float32]]],
|
||||
[np.array([1.0, 2.0]), np.array([3.0, 4.0])],
|
||||
)
|
||||
transformed = _transform_embeddings(embeddings)
|
||||
assert transformed == [[1.0, 2.0], [3.0, 4.0]]
|
||||
|
||||
|
||||
def test_add_query_fields() -> None:
|
||||
data_dict: Dict[str, Any] = {}
|
||||
query_result: QueryResult = {
|
||||
"ids": [["id1"], ["id2"]],
|
||||
"embeddings": [[np.array([1.0, 2.0])], [np.array([3.0, 4.0])]],
|
||||
"documents": [["doc1"], ["doc2"]],
|
||||
"metadatas": [[{"key": "value1"}], [{"key": "value2"}]],
|
||||
"distances": [[0.1], [0.2]],
|
||||
"uris": [["uri1", "uri2"]],
|
||||
"data": [
|
||||
[np.array([1, 2, 3]), np.array([4, 5, 6])]
|
||||
], # Using numpy arrays as Image type
|
||||
"included": ["embeddings", "documents", "metadatas", "distances"],
|
||||
}
|
||||
|
||||
_add_query_fields(data_dict, query_result, 0)
|
||||
assert np.array_equal(data_dict["embedding"], [np.array([1.0, 2.0])])
|
||||
assert data_dict["document"] == ["doc1"]
|
||||
assert data_dict["metadata"] == [{"key": "value1"}]
|
||||
assert data_dict["distance"] == [0.1]
|
||||
|
||||
|
||||
def test_add_get_fields() -> None:
|
||||
data_dict: Dict[str, Any] = {}
|
||||
get_result: GetResult = {
|
||||
"ids": ["id1", "id2"],
|
||||
"embeddings": [np.array([1.0, 2.0]), np.array([3.0, 4.0])],
|
||||
"documents": ["doc1", "doc2"],
|
||||
"metadatas": [{"key": "value1"}, {"key": "value2"}],
|
||||
"uris": ["uri1", "uri2"],
|
||||
"data": [
|
||||
np.array([1, 2, 3]),
|
||||
np.array([4, 5, 6]),
|
||||
], # Using numpy arrays as Image type
|
||||
"included": ["embeddings", "documents", "metadatas"],
|
||||
}
|
||||
|
||||
_add_get_fields(data_dict, get_result)
|
||||
assert all(
|
||||
np.array_equal(a, b)
|
||||
for a, b in zip(
|
||||
data_dict["embedding"], [np.array([1.0, 2.0]), np.array([3.0, 4.0])]
|
||||
)
|
||||
)
|
||||
assert data_dict["document"] == ["doc1", "doc2"]
|
||||
assert data_dict["metadata"] == [{"key": "value1"}, {"key": "value2"}]
|
||||
|
||||
|
||||
def test_query_result_to_dfs() -> None:
|
||||
query_result: QueryResult = {
|
||||
"ids": [["id1", "id2"]],
|
||||
"embeddings": [[np.array([1.0, 2.0]), np.array([3.0, 4.0])]],
|
||||
"documents": [["doc1", "doc2"]],
|
||||
"metadatas": [[{"key": "value1"}, {"key": "value2"}]],
|
||||
"distances": [[0.1, 0.2]],
|
||||
"uris": [["uri1", "uri2"]],
|
||||
"data": [
|
||||
[np.array([1, 2, 3]), np.array([4, 5, 6])]
|
||||
], # Using numpy arrays as Image type
|
||||
"included": ["embeddings", "documents", "metadatas", "distances"],
|
||||
}
|
||||
|
||||
dfs = query_result_to_dfs(query_result)
|
||||
assert len(dfs) == 1 # Only one query
|
||||
|
||||
# Test DataFrame
|
||||
df = dfs[0]
|
||||
assert df.index[0] == "id1"
|
||||
assert df["document"].iloc[0] == "doc1"
|
||||
assert df["metadata"].iloc[0] == {"key": "value1"}
|
||||
assert np.array_equal(df["embedding"].iloc[0], np.array([1.0, 2.0]))
|
||||
assert df["distance"].iloc[0] == 0.1
|
||||
|
||||
# Test column order
|
||||
assert list(df.columns) == ["embedding", "document", "metadata", "distance"]
|
||||
|
||||
|
||||
def test_get_result_to_df() -> None:
|
||||
get_result: GetResult = {
|
||||
"ids": ["id1", "id2"],
|
||||
"embeddings": [np.array([1.0, 2.0]), np.array([3.0, 4.0])],
|
||||
"documents": ["doc1", "doc2"],
|
||||
"metadatas": [{"key": "value1"}, {"key": "value2"}],
|
||||
"uris": ["uri1", "uri2"],
|
||||
"data": [
|
||||
np.array([1, 2, 3]),
|
||||
np.array([4, 5, 6]),
|
||||
], # Using numpy arrays as Image type
|
||||
"included": ["embeddings", "documents", "metadatas"],
|
||||
}
|
||||
|
||||
df = get_result_to_df(get_result)
|
||||
assert len(df) == 2
|
||||
assert list(df.index) == ["id1", "id2"]
|
||||
assert df["document"].tolist() == ["doc1", "doc2"]
|
||||
assert df["metadata"].tolist() == [{"key": "value1"}, {"key": "value2"}]
|
||||
assert all(
|
||||
np.array_equal(a, b)
|
||||
for a, b in zip(
|
||||
df["embedding"].tolist(), [np.array([1.0, 2.0]), np.array([3.0, 4.0])]
|
||||
)
|
||||
)
|
||||
|
||||
# Test column order
|
||||
assert list(df.columns) == ["embedding", "document", "metadata"]
|
||||
|
||||
|
||||
def test_query_result_to_dfs_with_missing_fields() -> None:
|
||||
query_result: QueryResult = {
|
||||
"ids": [["id1"]],
|
||||
"documents": [["doc1"]],
|
||||
"embeddings": [[]], # type:ignore
|
||||
"metadatas": [[]],
|
||||
"distances": [[]],
|
||||
"uris": [[]],
|
||||
"data": [[]],
|
||||
"included": ["documents"],
|
||||
}
|
||||
|
||||
dfs = query_result_to_dfs(query_result)
|
||||
assert len(dfs) == 1
|
||||
df = dfs[0]
|
||||
assert df.index[0] == "id1"
|
||||
assert df["document"].iloc[0] == "doc1"
|
||||
assert "metadata" not in df.columns
|
||||
assert "embedding" not in df.columns
|
||||
assert "distance" not in df.columns
|
||||
|
||||
|
||||
def test_get_result_to_df_with_missing_fields() -> None:
|
||||
get_result: GetResult = {
|
||||
"ids": ["id1", "id2"],
|
||||
"documents": ["doc1", "doc2"],
|
||||
"embeddings": [],
|
||||
"metadatas": [],
|
||||
"uris": [],
|
||||
"data": [],
|
||||
"included": ["documents"],
|
||||
}
|
||||
|
||||
df = get_result_to_df(get_result)
|
||||
assert len(df) == 2
|
||||
assert list(df.index) == ["id1", "id2"]
|
||||
assert df["document"].tolist() == ["doc1", "doc2"]
|
||||
assert "metadata" not in df.columns
|
||||
assert "embedding" not in df.columns
|
||||
@@ -0,0 +1,30 @@
|
||||
import time
|
||||
from chromadb.api import ClientAPI
|
||||
from chromadb.test.conftest import COMPACTION_SLEEP
|
||||
|
||||
TIMEOUT_INTERVAL = 1
|
||||
|
||||
|
||||
def get_collection_version(client: ClientAPI, collection_name: str) -> int:
|
||||
coll = client.get_collection(collection_name)
|
||||
return coll.get_model()["version"]
|
||||
|
||||
|
||||
def wait_for_version_increase(
|
||||
client: ClientAPI,
|
||||
collection_name: str,
|
||||
initial_version: int,
|
||||
additional_time: int = 0,
|
||||
) -> int:
|
||||
timeout = COMPACTION_SLEEP
|
||||
initial_time = time.time() + additional_time
|
||||
|
||||
curr_version = get_collection_version(client, collection_name)
|
||||
while curr_version == initial_version:
|
||||
time.sleep(TIMEOUT_INTERVAL)
|
||||
if time.time() - initial_time > timeout:
|
||||
collection_id = client.get_collection(collection_name).id
|
||||
raise TimeoutError(f"Model was not updated in time for {collection_id}")
|
||||
curr_version = get_collection_version(client, collection_name)
|
||||
|
||||
return curr_version
|
||||
Reference in New Issue
Block a user