chore: 添加虚拟环境到仓库

- 添加 backend_service/venv 虚拟环境
- 包含所有Python依赖包
- 注意:虚拟环境约393MB,包含12655个文件
This commit is contained in:
2025-12-03 10:19:25 +08:00
parent a6c2027caa
commit c4f851d387
12655 changed files with 3009376 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@@ -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]))

View File

@@ -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]

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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"

View File

@@ -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()

View File

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

View File

@@ -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

View File

@@ -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

View File

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

View File

@@ -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}

View File

@@ -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

View File

@@ -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])

View File

@@ -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
)

View File

@@ -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.

View File

@@ -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."

View File

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

View File

@@ -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]])

View File

@@ -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],
)

View File

@@ -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)

View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -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,)

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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"])

View File

@@ -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

View File

@@ -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

View File

@@ -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"])
)
)

View File

@@ -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}

View File

@@ -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]

View File

@@ -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)

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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"]

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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()

View File

@@ -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]"
)

View File

@@ -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"]

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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