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