mirror of
https://github.com/milvus-io/milvus.git
synced 2026-10-28 17:05:53 +08:00
## What this PR does Strengthens compaction coverage in the PR gate and removes false-positive and flaky paths. - preserves terminal success/failure/timeout outcomes after task cleanup and uses `EndTime` (with a legacy `StartTime` fallback) for post-finish GC retention - resolves target-based manual compaction through durable target metadata: an executable ACTIVE target remains `Executing` across reconciliation batches, INACTIVE completes only after every task is classified, inert ACTIVE targets and unknown/GCed IDs remain undefined, and `compact_id=-1` remains the explicit no-op sentinel - keeps unknown task states and invalid cleaned terminal states fail-closed even when their target is runtime-active - refuses successful aggregation when any task state or cleaned terminal outcome is unclassified, and covers INACTIVE targets with still-executing tasks - makes the Python compaction waiter consume structured plan counts with context metadata and one monotonic deadline across raw RPC attempts, reconnect, and backoff - restores channel recovery for `UNAVAILABLE` through the synchronous, timeout-aware SDK `reconnect()` interface without leaving a recovery worker that can mutate the handler after timeout - polls compaction-eligible segments through a single raw `GetPersistentSegmentInfo` RPC per observation, preserving call context and status mapping without the SDK retry decorator reusing a stale timeout - retries transient gRPC and rate-limit failures within the remaining deadline, and recognizes both current `code=600` and legacy `error_code=SegmentNotFound` wire formats - retries recoverable `Status.CONNECT_FAILED` reconnect-handshake failures in segment polling and PK pagination within the existing deadline/backoff; non-recoverable handshake errors still propagate - disables auto compaction before topology/concurrency setup and validates MixCompaction source/target multiplicity, exact topology, row count, PK uniqueness, and exact PK set - proves explicit ForceMerge `target_size` behavior with a fixed `segment.maxSize == 64MB` fixture: equivalent datasets must produce 3 outputs at 64MB and 2 outputs at 128MB - exposes finite ForceMerge QueryNode/DataNode memory factors in DataCoord system metrics; non-finite dynamic values fall back to the default before serialization - reads the unfiltered system topology and skips the target-size comparison when QueryNode/DataNode metrics are erroneous or the observed machine-safe ceiling cannot preserve the larger effective target - directly regression-tests the L3 machine-safe wrapper with healthy typed nodes plus an untyped metrics error, preventing a future return to filtered topology input - verifies every planned ForceMerge source leaves the active topology, every exposed target becomes active, the complete PK set survives, and physical rewrite bytes are conserved - releases and reloads the collection before MixCompaction and ForceMerge PK validation, then verifies the complete loaded segment set, retired-source exclusion, and node assignments so readable old inputs cannot mask missing compaction outputs - submits reload asynchronously through single-attempt SDK calls and polls loading progress with fresh remaining budgets; release, load submission, progress, and topology checks share one monotonic deadline - replaces `QueryIterator` validation with deterministic PK-range pages; every raw query RPC receives a freshly calculated remaining timeout, retains MilvusClient `cluster_id`, and retries transient gRPC/rate-limit failures within the same total deadline - verifies reconnect receives only the remaining pagination deadline, an over-budget reconnect cannot issue another query RPC, ignored gRPC codes and real rc83 non-rate-limit business statuses fail fast, and both current and legacy `(code=0, compatible_code=49)` rate-limit mappings retry within budget - preserves valid ForceMerge QueryNode/DataNode memory factors, while non-finite values fall back and all finite factors below 1—including positive fractional values—clamp to 1 - sustains checked insert/CreateIndex/search workloads across compaction and propagates every worker failure, including the last in-flight request during cleanup; an Executing sample counts as overlap evidence only when the same workload call spans the observation - waits for two matching Flushed/L1/sorted source snapshots before the index workload, then expects unselected sources plus Mix targets; selected sources must retire and every loaded segment must have an index and node assignment - validates final PKs, indexed targets, and search results before marking an unobserved overlap window as insufficient coverage (pytest skip); repeated CreateIndex requests establish request concurrency, not repeated index builds - validates complete binary-search results against exact Jaccard ground truth - adds DataCoord aggregation/target lifecycle UT and compaction stats-loader UT coverage `CompactionMergeInfo` exposes one legacy `target` field although a ForceMerge task may split into multiple result segments. Split-output validation therefore requires each exposed target to be active and separately requires every planned source to be retired; complete active-output multiplicity is verified through the persistent-segment topology. Fixes #53272 ## Test plan - [x] Partial-plan review follow-up (2026-10-22): 82 focused local tests passed with Python 3.12 / PyMilvus rc98, plus Ruff lint/format and diff checks. The regression invokes the actual index E2E case with an 8-of-9 Mix plan: the prior implementation times out, the fix accepts the valid indexed target plus unselected source. Negative cases reject retained selected sources, missing/unexpected/unindexed/unassigned segments, and missing PKs; the ORM adapter exercises sorting convergence and context preservation. - [ ] Live index concurrency rerun is blocked: both Dev CLI deployment-list and `deploy status compaction-pr-ci` requests return HTTP 500. No live-cluster pass is claimed for this follow-up. - [x] Review follow-up on 2026-10-22: Python 3.12 / pinned PyMilvus 3.1.0rc98 focused suite: 72 passed; Ruff lint/format and diff whitespace checks passed; all three revised concurrency E2E cases collect successfully. Fault injection covers fast completion, unobserved overlap, worker failures before and after terminal observation, and cleanup. Real SDK loading regressions fail on the previous implementation (5-second budget returns at t=8) and pass with fresh per-poll budgets. - [ ] Re-run the three sustained-workload cases and release/reload integrity cases against a live cluster; this follow-up has local regression coverage only. - [ ] Full `make verifiers` attempted on 2026-10-22 but stopped in `build-3rdparty`: this macOS host lacks a supported LLVM 14–18 toolchain. - [x] Review follow-up on 2026-10-20: Python 3.12 / pinned PyMilvus 3.1.0rc98 focused suite: 56 passed; Ruff lint/format, Python syntax, and diff whitespace checks passed. Reconnect regressions exercise the actual SDK reconnect/readiness functions; topology tests inject delayed/partial/stuck loading, reload failures, and late responses. - [ ] Re-run live MixCompaction / ForceMerge integrity E2E with the new release/reload validation; the review follow-up was verified locally with fault-injection tests and server-path inspection. - [x] Go formatting, Python 3.12 compile, Ruff check/format, protobuf submodule compile, and `git diff --check` - [x] Local Python 3.12 waiter/raw-adapter/pagination/machine-safe matrix: 38 passed - [x] Local focused paramtable + metrics UT: `go test -tags dynamic,test -gcflags='all=-N -l'` - [x] Dev CLI exact non-finite memory-factor/serialization UT: `go-ut-local-yanliang-test-compaction-pr-5aa7d24-ce47cff0-u3805` - [x] Dev CLI exact full memory-factor preservation/fallback matrix: `go-ut-local-yanliang-test-compaction-pr-fab7e57-ce47cff0-u3805` - [x] Dev CLI exact fractional-boundary memory-factor matrix: `go-ut-local-yanliang-test-compaction-pr-3152b2a-ce47cff0-u3805` - [ ] Dev CLI targeted retry L0 could not start pytest because the runner Pod could not resolve the COS source host: `pysdk-e2e-local-yanliang-test-compaction-d642871-420126f0-u3805`; the same 3-case rc83 test passes locally - [x] Dev CLI current server image build: `build-local-yanliang-qia-test-compac-5aa7d24-bf331245b298-u3805` - [x] Deploy `harbor.tencent-stage.zilliz.cn/manta/milvus:local-u3805-test-compaction-pr-ci-coverage-5aa7d24-bf331245b298` to `compaction-pr-ci`; verify `Healthy` and `updated=true` - [x] Dev CLI current DataCoord + metrics UT: `go-ut-local-yanliang-test-compaction-pr-d3b086b-57710439-u3805` - [x] Dev CLI exact GC-retention suite: `go-ut-local-yanliang-test-compaction-pr-c9996b9-29281152-u3805` - [x] Dev CLI full `internal/compaction` + `internal/datacoord` Go UT for the broader PR changes: `go-ut-local-yanliang-test-compaction-pr-7b125fe-6ba90598-u3805` - [x] Dev CLI real no-op trigger plus unknown-ID E2E (3/3): `pysdk-e2e-local-yanliang-test-compaction-710c3bc-cef493b1-u3805` - [x] Dev CLI exact binary-search validation (1/1): `pysdk-e2e-local-yanliang-test-compaction-0728a4d-714e12a8-u3805` - [ ] Run the final fixed 64/128MB ForceMerge topology/full-PK E2E. The latest local-source runner still fails before pytest because its Pod cannot resolve the COS source host: `pysdk-e2e-local-yanliang-test-compaction-5aa7d24-4e733021-u3805`; the PR-source channel independently cannot resolve GitHub: `pysdk-e2e-pr-53273-5aa7d24-8424db8b-u3805` - [ ] Re-run the final Mix multiplicity assertion and insert/index result-binding cases after the same source-runner DNS incident is resolved ## Notes Upgrade, chaos, performance, and experimental-configuration coverage remain lower-priority follow-up work. Signed-off-by: Yanliang Qiao <yanliang.qiao@zilliz.com>
658 lines
27 KiB
Python
658 lines
27 KiB
Python
import sys
|
|
|
|
from base.database_wrapper import ApiDatabaseWrapper
|
|
from pymilvus import DefaultConfig
|
|
|
|
sys.path.append("..")
|
|
import pymilvus
|
|
from base.async_milvus_client_wrapper import AsyncMilvusClientWrapper
|
|
from base.collection_wrapper import ApiCollectionWrapper
|
|
from base.connections_wrapper import ApiConnectionsWrapper
|
|
from base.index_wrapper import ApiIndexWrapper
|
|
from base.partition_wrapper import ApiPartitionWrapper
|
|
from base.schema_wrapper import ApiCollectionSchemaWrapper, ApiFieldSchemaWrapper
|
|
from base.utility_wrapper import ApiUtilityWrapper
|
|
from common import common_func as cf
|
|
from common import common_type as ct
|
|
from common.common_params import IndexPrams
|
|
from pymilvus import DataType, MilvusClient, ResourceGroupInfo, utility
|
|
from utils.util_log import test_log as log
|
|
|
|
|
|
class Base:
|
|
"""Initialize class object"""
|
|
|
|
connection_wrap = None
|
|
collection_wrap = None
|
|
partition_wrap = None
|
|
index_wrap = None
|
|
utility_wrap = None
|
|
collection_schema_wrap = None
|
|
field_schema_wrap = None
|
|
database_wrap = None
|
|
tear_down_collection_names = []
|
|
tear_down_role_names = []
|
|
tear_down_user_names = []
|
|
resource_group_list = []
|
|
async_milvus_client_wrap = None
|
|
skip_connection = False
|
|
skip_global_role_cleanup = False
|
|
|
|
def setup_class(self):
|
|
log.info("[setup_class] Start setup class...")
|
|
|
|
def teardown_class(self):
|
|
log.info("[teardown_class] Start teardown class...")
|
|
|
|
def setup_method(self, method):
|
|
log.info(("*" * 35) + " setup " + ("*" * 35))
|
|
log.info(f"pymilvus version: {pymilvus.__version__}")
|
|
log.info(f"[setup_method] Start setup test case {method.__name__}.")
|
|
self._setup_objects()
|
|
|
|
def _setup_objects(self):
|
|
self.connection_wrap = ApiConnectionsWrapper()
|
|
self.utility_wrap = ApiUtilityWrapper()
|
|
self.collection_wrap = ApiCollectionWrapper()
|
|
self.partition_wrap = ApiPartitionWrapper()
|
|
self.index_wrap = ApiIndexWrapper()
|
|
self.collection_schema_wrap = ApiCollectionSchemaWrapper()
|
|
self.field_schema_wrap = ApiFieldSchemaWrapper()
|
|
self.database_wrap = ApiDatabaseWrapper()
|
|
self.async_milvus_client_wrap = AsyncMilvusClientWrapper()
|
|
|
|
def teardown_method(self, method):
|
|
log.info(("*" * 35) + " teardown " + ("*" * 35))
|
|
log.info(f"[teardown_method] Start teardown test case {method.__name__}...")
|
|
self._teardown_objects()
|
|
|
|
def _teardown_objects(self):
|
|
# Prioritize uri and token for connection
|
|
if cf.param_info.param_uri:
|
|
uri = cf.param_info.param_uri
|
|
else:
|
|
uri = "http://" + cf.param_info.param_host + ":" + str(cf.param_info.param_port)
|
|
|
|
if cf.param_info.param_token:
|
|
token = cf.param_info.param_token
|
|
else:
|
|
token = (
|
|
f"{cf.param_info.param_user}:{cf.param_info.param_password}"
|
|
if cf.param_info.param_user and cf.param_info.param_password
|
|
else None
|
|
)
|
|
|
|
try:
|
|
""" Drop collection before disconnect """
|
|
if not self.connection_wrap.has_connection(alias=DefaultConfig.DEFAULT_USING)[0]:
|
|
if token:
|
|
self.connection_wrap.connect(alias=DefaultConfig.DEFAULT_USING, uri=uri, token=token)
|
|
else:
|
|
self.connection_wrap.connect(alias=DefaultConfig.DEFAULT_USING, uri=uri)
|
|
|
|
if self.collection_wrap.collection is not None:
|
|
if self.collection_wrap.collection.name.startswith("alias"):
|
|
log.info(f"collection {self.collection_wrap.collection.name} is alias, skip drop operation")
|
|
else:
|
|
self.collection_wrap.drop(check_task=ct.CheckTasks.check_nothing)
|
|
|
|
collection_list = self.utility_wrap.list_collections()[0]
|
|
for collection_name in self.tear_down_collection_names:
|
|
if collection_name is not None and collection_name in collection_list:
|
|
alias_list = self.utility_wrap.list_aliases(collection_name)[0]
|
|
if alias_list:
|
|
for alias in alias_list:
|
|
self.utility_wrap.drop_alias(alias)
|
|
self.utility_wrap.drop_collection(collection_name)
|
|
|
|
""" Clean up the rgs before disconnect """
|
|
rgs_list = self.utility_wrap.list_resource_groups()[0]
|
|
for rg_name in self.resource_group_list:
|
|
if rg_name is not None and rg_name in rgs_list:
|
|
rg = self.utility_wrap.describe_resource_group(
|
|
name=rg_name, check_task=ct.CheckTasks.check_nothing
|
|
)[0]
|
|
if isinstance(rg, ResourceGroupInfo):
|
|
if rg.num_available_node > 0:
|
|
self.utility_wrap.transfer_node(
|
|
source=rg_name, target=ct.default_resource_group_name, num_node=rg.num_available_node
|
|
)
|
|
self.utility_wrap.drop_resource_group(rg_name, check_task=ct.CheckTasks.check_nothing)
|
|
|
|
except Exception as e:
|
|
log.debug(str(e))
|
|
|
|
if not self.skip_global_role_cleanup:
|
|
try:
|
|
""" Drop roles before disconnect """
|
|
if not self.connection_wrap.has_connection(alias=DefaultConfig.DEFAULT_USING)[0]:
|
|
if token:
|
|
self.connection_wrap.connect(alias=DefaultConfig.DEFAULT_USING, uri=uri, token=token)
|
|
else:
|
|
self.connection_wrap.connect(alias=DefaultConfig.DEFAULT_USING, uri=uri)
|
|
|
|
role_list = self.utility_wrap.list_roles(False)[0]
|
|
for role in role_list.groups:
|
|
role_name = role.role_name
|
|
if role_name not in ["admin", "public"]:
|
|
each_role = self.utility_wrap.init_role(name=role_name)[0]
|
|
each_role.drop()
|
|
|
|
except Exception as e:
|
|
log.debug(str(e))
|
|
|
|
try:
|
|
""" Delete connection and reset configuration"""
|
|
res = self.connection_wrap.list_connections()
|
|
for i in res[0]:
|
|
self.connection_wrap.remove_connection(i[0])
|
|
|
|
# because the connection is in singleton mode, it needs to be restored to the original state after teardown
|
|
self.connection_wrap.add_connection(
|
|
default={"host": DefaultConfig.DEFAULT_HOST, "port": DefaultConfig.DEFAULT_PORT}
|
|
)
|
|
except Exception as e:
|
|
log.debug(str(e))
|
|
|
|
|
|
class TestcaseBase(Base):
|
|
"""
|
|
Additional methods;
|
|
Public methods that can be used for test cases.
|
|
"""
|
|
|
|
client = None
|
|
|
|
def _connect(self, enable_milvus_client_api=False):
|
|
"""Add a connection and create the connect"""
|
|
if self.skip_connection:
|
|
return None
|
|
|
|
# Prioritize uri and token for connection
|
|
if cf.param_info.param_uri:
|
|
uri = cf.param_info.param_uri
|
|
else:
|
|
uri = "http://" + cf.param_info.param_host + ":" + str(cf.param_info.param_port)
|
|
|
|
if cf.param_info.param_token:
|
|
token = cf.param_info.param_token
|
|
else:
|
|
token = (
|
|
f"{cf.param_info.param_user}:{cf.param_info.param_password}"
|
|
if cf.param_info.param_user and cf.param_info.param_password
|
|
else None
|
|
)
|
|
|
|
if enable_milvus_client_api:
|
|
self.connection_wrap.connect(alias=DefaultConfig.DEFAULT_USING, uri=uri, token=token)
|
|
res, is_succ = self.connection_wrap.MilvusClient(uri=uri, token=token)
|
|
self.client = MilvusClient(uri=uri, token=token)
|
|
else:
|
|
if token:
|
|
res, is_succ = self.connection_wrap.connect(
|
|
alias=DefaultConfig.DEFAULT_USING, uri=uri, token=token, secure=cf.param_info.param_secure
|
|
)
|
|
else:
|
|
res, is_succ = self.connection_wrap.connect(alias=DefaultConfig.DEFAULT_USING, uri=uri)
|
|
|
|
self.client = MilvusClient(uri=uri, token=token)
|
|
server_version = utility.get_server_version()
|
|
log.info(f"server version: {server_version}")
|
|
return res
|
|
|
|
def get_tokens_by_analyzer(self, text, analyzer_params):
|
|
if cf.param_info.param_uri:
|
|
uri = cf.param_info.param_uri
|
|
else:
|
|
uri = "http://" + cf.param_info.param_host + ":" + str(cf.param_info.param_port)
|
|
|
|
client = MilvusClient(uri=uri, token=cf.param_info.param_token)
|
|
res = client.run_analyzer(text, analyzer_params, with_detail=True, with_hash=True)
|
|
tokens = [r["token"] for r in res.tokens]
|
|
return tokens
|
|
|
|
# def init_async_milvus_client(self):
|
|
# uri = cf.param_info.param_uri or f"http://{cf.param_info.param_host}:{cf.param_info.param_port}"
|
|
# kwargs = {
|
|
# "uri": uri,
|
|
# "user": cf.param_info.param_user,
|
|
# "password": cf.param_info.param_password,
|
|
# "token": cf.param_info.param_token,
|
|
# }
|
|
# self.async_milvus_client_wrap.init_async_client(**kwargs)
|
|
|
|
def init_collection_wrap(
|
|
self,
|
|
name=None,
|
|
schema=None,
|
|
check_task=None,
|
|
check_items=None,
|
|
enable_dynamic_field=False,
|
|
with_json=True,
|
|
**kwargs,
|
|
):
|
|
name = cf.gen_collection_name_by_testcase_name(2) if name is None else name
|
|
schema = (
|
|
cf.gen_default_collection_schema(enable_dynamic_field=enable_dynamic_field, with_json=with_json)
|
|
if schema is None
|
|
else schema
|
|
)
|
|
if not self.connection_wrap.has_connection(alias=DefaultConfig.DEFAULT_USING)[0]:
|
|
self._connect()
|
|
collection_w = ApiCollectionWrapper()
|
|
collection_w.init_collection(name=name, schema=schema, check_task=check_task, check_items=check_items, **kwargs)
|
|
self.tear_down_collection_names.append(name)
|
|
return collection_w
|
|
|
|
def init_multi_fields_collection_wrap(self, name=cf.gen_unique_str()):
|
|
vec_fields = [cf.gen_float_vec_field(ct.another_float_vec_field_name)]
|
|
schema = cf.gen_schema_multi_vector_fields(vec_fields)
|
|
collection_w = self.init_collection_wrap(name=name, schema=schema)
|
|
df = cf.gen_dataframe_multi_vec_fields(vec_fields=vec_fields)
|
|
collection_w.insert(df)
|
|
assert collection_w.num_entities == ct.default_nb
|
|
return collection_w, df
|
|
|
|
def init_partition_wrap(
|
|
self, collection_wrap=None, name=None, description=None, check_task=None, check_items=None, **kwargs
|
|
):
|
|
name = cf.gen_unique_str("partition_") if name is None else name
|
|
description = cf.gen_unique_str("partition_des_") if description is None else description
|
|
collection_wrap = self.init_collection_wrap() if collection_wrap is None else collection_wrap
|
|
partition_wrap = ApiPartitionWrapper()
|
|
partition_wrap.init_partition(
|
|
collection_wrap.collection, name, description, check_task=check_task, check_items=check_items, **kwargs
|
|
)
|
|
return partition_wrap
|
|
|
|
def insert_data_general(
|
|
self,
|
|
prefix="test",
|
|
insert_data=False,
|
|
nb=ct.default_nb,
|
|
partition_num=0,
|
|
is_binary=False,
|
|
is_all_data_type=False,
|
|
auto_id=False,
|
|
dim=ct.default_dim,
|
|
primary_field=ct.default_int64_field_name,
|
|
is_flush=True,
|
|
name=None,
|
|
enable_dynamic_field=False,
|
|
with_json=True,
|
|
**kwargs,
|
|
):
|
|
""" """
|
|
self._connect()
|
|
collection_name = cf.gen_unique_str(prefix)
|
|
if name is not None:
|
|
collection_name = name
|
|
vectors = []
|
|
binary_raw_vectors = []
|
|
insert_ids = []
|
|
time_stamp = 0
|
|
# 1 create collection
|
|
default_schema = cf.gen_default_collection_schema(
|
|
auto_id=auto_id,
|
|
dim=dim,
|
|
primary_field=primary_field,
|
|
enable_dynamic_field=enable_dynamic_field,
|
|
with_json=with_json,
|
|
)
|
|
if is_binary:
|
|
default_schema = cf.gen_default_binary_collection_schema(
|
|
auto_id=auto_id, dim=dim, primary_field=primary_field
|
|
)
|
|
if is_all_data_type:
|
|
default_schema = cf.gen_collection_schema_all_datatype(
|
|
auto_id=auto_id,
|
|
dim=dim,
|
|
primary_field=primary_field,
|
|
enable_dynamic_field=enable_dynamic_field,
|
|
with_json=with_json,
|
|
)
|
|
log.info("insert_data_general: collection creation")
|
|
collection_w = self.init_collection_wrap(name=collection_name, schema=default_schema, **kwargs)
|
|
pre_entities = collection_w.num_entities
|
|
if insert_data:
|
|
collection_w, vectors, binary_raw_vectors, insert_ids, time_stamp = cf.insert_data(
|
|
collection_w,
|
|
nb,
|
|
is_binary,
|
|
is_all_data_type,
|
|
auto_id=auto_id,
|
|
dim=dim,
|
|
enable_dynamic_field=enable_dynamic_field,
|
|
with_json=with_json,
|
|
)
|
|
if is_flush:
|
|
collection_w.flush()
|
|
assert collection_w.num_entities == nb + pre_entities
|
|
|
|
return collection_w, vectors, binary_raw_vectors, insert_ids, time_stamp
|
|
|
|
def init_collection_general(
|
|
self,
|
|
prefix="test",
|
|
insert_data=False,
|
|
nb=ct.default_nb,
|
|
partition_num=0,
|
|
is_binary=False,
|
|
is_all_data_type=False,
|
|
auto_id=False,
|
|
dim=ct.default_dim,
|
|
is_index=True,
|
|
primary_field=ct.default_int64_field_name,
|
|
is_flush=True,
|
|
name=None,
|
|
enable_dynamic_field=False,
|
|
with_json=True,
|
|
random_primary_key=False,
|
|
multiple_dim_array=[],
|
|
is_partition_key=None,
|
|
vector_data_type=DataType.FLOAT_VECTOR,
|
|
nullable_fields={},
|
|
default_value_fields={},
|
|
language=None,
|
|
**kwargs,
|
|
):
|
|
"""
|
|
target: create specified collections
|
|
method: 1. create collections (binary/non-binary, default/all data type, auto_id or not)
|
|
2. create partitions if specified
|
|
3. insert specified (binary/non-binary, default/all data type) data
|
|
into each partition if any
|
|
4. not load if specifying is_index as True
|
|
5. enable insert null data: nullable_fields = {"nullable_fields_name": null data percent}
|
|
6. enable insert default value: default_value_fields = {"default_fields_name": default value}
|
|
expected: return collection and raw data, insert ids
|
|
"""
|
|
log.info("Test case of search interface: initialize before test case")
|
|
if not self.connection_wrap.has_connection(alias=DefaultConfig.DEFAULT_USING)[0]:
|
|
self._connect()
|
|
collection_name = cf.gen_collection_name_by_testcase_name(2)
|
|
if name is not None:
|
|
collection_name = name
|
|
if not isinstance(nullable_fields, dict):
|
|
log.error("nullable_fields should a dict like {'nullable_fields_name': null data percent}")
|
|
assert False
|
|
if not isinstance(default_value_fields, dict):
|
|
log.error("default_value_fields should a dict like {'default_fields_name': default value}")
|
|
assert False
|
|
vectors = []
|
|
binary_raw_vectors = []
|
|
insert_ids = []
|
|
time_stamp = 0
|
|
# 1 create collection
|
|
default_schema = cf.gen_default_collection_schema(
|
|
auto_id=auto_id,
|
|
dim=dim,
|
|
primary_field=primary_field,
|
|
enable_dynamic_field=enable_dynamic_field,
|
|
with_json=with_json,
|
|
multiple_dim_array=multiple_dim_array,
|
|
is_partition_key=is_partition_key,
|
|
vector_data_type=vector_data_type,
|
|
nullable_fields=nullable_fields,
|
|
default_value_fields=default_value_fields,
|
|
)
|
|
if is_binary:
|
|
default_schema = cf.gen_default_binary_collection_schema(
|
|
auto_id=auto_id,
|
|
dim=dim,
|
|
primary_field=primary_field,
|
|
nullable_fields=nullable_fields,
|
|
default_value_fields=default_value_fields,
|
|
)
|
|
if vector_data_type == DataType.SPARSE_FLOAT_VECTOR:
|
|
default_schema = cf.gen_default_sparse_schema(
|
|
auto_id=auto_id,
|
|
primary_field=primary_field,
|
|
enable_dynamic_field=enable_dynamic_field,
|
|
with_json=with_json,
|
|
multiple_dim_array=multiple_dim_array,
|
|
nullable_fields=nullable_fields,
|
|
default_value_fields=default_value_fields,
|
|
)
|
|
if is_all_data_type:
|
|
default_schema = cf.gen_collection_schema_all_datatype(
|
|
auto_id=auto_id,
|
|
dim=dim,
|
|
primary_field=primary_field,
|
|
enable_dynamic_field=enable_dynamic_field,
|
|
with_json=with_json,
|
|
multiple_dim_array=multiple_dim_array,
|
|
nullable_fields=nullable_fields,
|
|
default_value_fields=default_value_fields,
|
|
)
|
|
log.info("init_collection_general: collection creation")
|
|
collection_w = self.init_collection_wrap(name=collection_name, schema=default_schema, **kwargs)
|
|
vector_name_list = cf.extract_vector_field_name_list(collection_w)
|
|
# 2 add extra partitions if specified (default is 1 partition named "_default")
|
|
if partition_num > 0:
|
|
cf.gen_partitions(collection_w, partition_num)
|
|
# 3 insert data if specified
|
|
if insert_data:
|
|
collection_w, vectors, binary_raw_vectors, insert_ids, time_stamp = cf.insert_data(
|
|
collection_w,
|
|
nb,
|
|
is_binary,
|
|
is_all_data_type,
|
|
auto_id=auto_id,
|
|
dim=dim,
|
|
enable_dynamic_field=enable_dynamic_field,
|
|
with_json=with_json,
|
|
random_primary_key=random_primary_key,
|
|
multiple_dim_array=multiple_dim_array,
|
|
primary_field=primary_field,
|
|
vector_data_type=vector_data_type,
|
|
nullable_fields=nullable_fields,
|
|
language=language,
|
|
)
|
|
if is_flush:
|
|
assert collection_w.is_empty is False
|
|
assert collection_w.num_entities == nb
|
|
# 4 create default index if specified
|
|
if is_index:
|
|
# This condition will be removed after auto index feature
|
|
if is_binary:
|
|
collection_w.create_index(ct.default_binary_vec_field_name, ct.default_bin_flat_index)
|
|
elif vector_data_type == DataType.SPARSE_FLOAT_VECTOR:
|
|
for vector_name in vector_name_list:
|
|
collection_w.create_index(vector_name, ct.default_sparse_inverted_index)
|
|
else:
|
|
if len(multiple_dim_array) == 0 or is_all_data_type is False:
|
|
vector_name_list.append(ct.default_float_vec_field_name)
|
|
for vector_name in vector_name_list:
|
|
# Unlike dense vectors, sparse vectors cannot create flat index.
|
|
if DataType.SPARSE_FLOAT_VECTOR.name in vector_name:
|
|
collection_w.create_index(vector_name, ct.default_sparse_inverted_index)
|
|
elif vector_data_type == DataType.INT8_VECTOR:
|
|
collection_w.create_index(vector_name, ct.int8_vector_index)
|
|
else:
|
|
collection_w.create_index(vector_name, ct.default_flat_index)
|
|
|
|
collection_w.load()
|
|
|
|
return collection_w, vectors, binary_raw_vectors, insert_ids, time_stamp
|
|
|
|
def insert_entities_into_two_partitions_in_half(self, half, prefix="query"):
|
|
"""
|
|
insert default entities into two partitions(partition_w and _default) in half(int64 and float fields values)
|
|
:param half: half of nb
|
|
:return: collection wrap and partition wrap
|
|
"""
|
|
self._connect()
|
|
collection_name = cf.gen_collection_name_by_testcase_name(2)
|
|
collection_w = self.init_collection_wrap(name=collection_name)
|
|
partition_w = self.init_partition_wrap(collection_wrap=collection_w)
|
|
# insert [0, half) into partition_w
|
|
df_partition = cf.gen_default_dataframe_data(nb=half, start=0)
|
|
partition_w.insert(df_partition)
|
|
# insert [half, nb) into _default
|
|
df_default = cf.gen_default_dataframe_data(nb=half, start=half)
|
|
collection_w.insert(df_default)
|
|
# flush
|
|
collection_w.num_entities
|
|
collection_w.create_index(ct.default_float_vec_field_name, index_params=ct.default_flat_index)
|
|
collection_w.load(partition_names=[partition_w.name, "_default"])
|
|
return collection_w, partition_w, df_partition, df_default
|
|
|
|
def collection_insert_multi_segments_one_shard(
|
|
self,
|
|
collection_prefix,
|
|
num_of_segment=2,
|
|
nb_of_segment=1,
|
|
is_dup=True,
|
|
collection_properties=None,
|
|
):
|
|
"""
|
|
init collection with one shard, insert data into two segments on one shard (they can be merged)
|
|
:param collection_prefix: collection name prefix
|
|
:param num_of_segment: number of segments
|
|
:param nb_of_segment: number of entities per segment
|
|
:param is_dup: whether the primary keys of each segment is duplicated
|
|
:param collection_properties: properties to set before inserting data
|
|
:return: collection wrap and partition wrap
|
|
"""
|
|
collection_name = cf.gen_collection_name_by_testcase_name(2)
|
|
collection_w = self.init_collection_wrap(name=collection_name, shards_num=1)
|
|
if collection_properties is not None:
|
|
collection_w.set_properties(collection_properties)
|
|
|
|
for i in range(num_of_segment):
|
|
start = 0 if is_dup else i * nb_of_segment
|
|
df = cf.gen_default_dataframe_data(nb_of_segment, start=start)
|
|
collection_w.insert(df)
|
|
assert collection_w.num_entities == nb_of_segment * (i + 1)
|
|
return collection_w
|
|
|
|
def init_resource_group(self, name, using="default", timeout=None, check_task=None, check_items=None, **kwargs):
|
|
if not self.connection_wrap.has_connection(alias=DefaultConfig.DEFAULT_USING)[0]:
|
|
self._connect()
|
|
utility_w = ApiUtilityWrapper()
|
|
res, check_result = utility_w.create_resource_group(
|
|
name=name, using=using, timeout=timeout, check_task=check_task, check_items=check_items, **kwargs
|
|
)
|
|
if res is None and check_result:
|
|
self.resource_group_list.append(name)
|
|
return res, check_result
|
|
|
|
def init_user_with_privilege(self, privilege_object, object_name, privilege, db_name="default"):
|
|
"""
|
|
init a user and role, grant privilege to the role with the db, then bind the role to the user
|
|
:param privilege_object: privilege object: Global, Collection, User
|
|
:type privilege_object: str
|
|
:param object_name: privilege object name
|
|
:type object_name: str
|
|
:param privilege: privilege
|
|
:type privilege: str
|
|
:param db_name: database name
|
|
:type db_name: str
|
|
:return: user name, user pwd, role name
|
|
:rtype: str, str, str
|
|
"""
|
|
tmp_user = cf.gen_unique_str("user")
|
|
tmp_pwd = cf.gen_unique_str("pwd")
|
|
tmp_role = cf.gen_unique_str("role")
|
|
|
|
# create user
|
|
self.utility_wrap.create_user(tmp_user, tmp_pwd)
|
|
|
|
# create role
|
|
self.utility_wrap.init_role(tmp_role)
|
|
self.utility_wrap.create_role()
|
|
|
|
# grant privilege to the role
|
|
self.utility_wrap.role_grant(
|
|
object=privilege_object, object_name=object_name, privilege=privilege, db_name=db_name
|
|
)
|
|
|
|
# bind the role to the user
|
|
self.utility_wrap.role_add_user(tmp_user)
|
|
|
|
return tmp_user, tmp_pwd, tmp_role
|
|
|
|
def build_multi_index(self, index_params: dict[str, IndexPrams], collection_obj: ApiCollectionWrapper = None):
|
|
collection_obj = collection_obj or self.collection_wrap
|
|
for k, v in index_params.items():
|
|
collection_obj.create_index(field_name=k, index_params=v.to_dict, index_name=k)
|
|
log.info(f"[TestcaseBase] Build all indexes done: {list(index_params.keys())}")
|
|
return collection_obj
|
|
|
|
def drop_multi_index(
|
|
self, index_names: list[str], collection_obj: ApiCollectionWrapper = None, check_task=None, check_items=None
|
|
):
|
|
collection_obj = collection_obj or self.collection_wrap
|
|
for n in index_names:
|
|
collection_obj.drop_index(index_name=n, check_task=check_task, check_items=check_items)
|
|
log.info(f"[TestcaseBase] Drop all indexes done: {index_names}")
|
|
return collection_obj
|
|
|
|
def show_indexes(self, collection_obj: ApiCollectionWrapper = None):
|
|
collection_obj = collection_obj or self.collection_wrap
|
|
indexes = {n.field_name: n.params for n in self.collection_wrap.indexes}
|
|
log.info(f"[TestcaseBase] Collection: `{collection_obj.name}` index: {indexes}")
|
|
return indexes
|
|
|
|
""" Property """
|
|
|
|
@property
|
|
def all_scalar_fields(self):
|
|
dtypes = [
|
|
DataType.INT8,
|
|
DataType.INT16,
|
|
DataType.INT32,
|
|
DataType.INT64,
|
|
DataType.VARCHAR,
|
|
DataType.BOOL,
|
|
DataType.FLOAT,
|
|
DataType.DOUBLE,
|
|
]
|
|
dtype_names = [f"{n.name}" for n in dtypes] + [f"ARRAY_{n.name}" for n in dtypes] + [DataType.JSON.name]
|
|
return dtype_names
|
|
|
|
@property
|
|
def all_index_scalar_fields(self):
|
|
return list(set(self.all_scalar_fields) - {DataType.JSON.name})
|
|
|
|
@property
|
|
def inverted_support_dtype_names(self):
|
|
return self.all_index_scalar_fields
|
|
|
|
@property
|
|
def inverted_not_support_dtype_names(self):
|
|
return [DataType.JSON.name]
|
|
|
|
@property
|
|
def bitmap_support_dtype_names(self):
|
|
dtypes = [DataType.INT8, DataType.INT16, DataType.INT32, DataType.INT64, DataType.BOOL, DataType.VARCHAR]
|
|
dtype_names = [f"{n.name}" for n in dtypes] + [f"ARRAY_{n.name}" for n in dtypes]
|
|
return dtype_names
|
|
|
|
@property
|
|
def bitmap_not_support_dtype_names(self):
|
|
return list(set(self.all_scalar_fields) - set(self.bitmap_support_dtype_names))
|
|
|
|
|
|
class TestCaseClassBase(TestcaseBase):
|
|
"""
|
|
Setup objects on class
|
|
"""
|
|
|
|
def setup_class(self):
|
|
log.info("[setup_class] " + " Start setup class ".center(100, "~"))
|
|
self._setup_objects(self)
|
|
|
|
def teardown_class(self):
|
|
log.info("[teardown_class]" + " Start teardown class ".center(100, "~"))
|
|
self._teardown_objects(self)
|
|
|
|
def setup_method(self, method):
|
|
log.info(" setup ".center(80, "*"))
|
|
log.info(f"[setup_method] Start setup test case {method.__name__}.")
|
|
|
|
def teardown_method(self, method):
|
|
log.info(" teardown ".center(80, "*"))
|
|
log.info(f"[teardown_method] Start teardown test case {method.__name__}...")
|