1313 - dict-form inserts with `None` for vector fields are the official
1414 nullable-vector ingest path documented in the user guide;
1515 - describe_collection on MilvusClient exposes per-field `nullable` directly.
16- """
1716
17+ Each test gates on a capability probe that performs a small nullable-vector
18+ round-trip (create -> insert NULL -> backup -> restore). If the target
19+ Milvus does not support nullable-vector binlog import yet, all tests in
20+ the class are skipped with an actionable message.
21+ """
1822
1923import numpy as np
2024import ml_dtypes
3135BACKUP_PREFIX = "backup_nullable_vec"
3236SUFFIX = "_bak"
3337
38+ _PROBE_BACKUP_NAME = "probe_nullable_vec"
39+
40+ # ---- capability probe -------------------------------------------------------
41+
42+ def _probe_nullable_vector_roundtrip (client , milvus_client ):
43+ """Return True if the Milvus server supports nullable vector binlog import.
44+
45+ The probe creates a tiny collection with a nullable FloatVector field,
46+ inserts one NULL and one non-NULL row, creates a backup, and tries to
47+ restore it. If the restore succeeds the server's BulkImport path
48+ understands the nullable-vector Arrow format; otherwise it does not.
49+ """
50+ coll = cf .gen_unique_str ("probe_nullable_vec" )
51+ restored_coll = coll + SUFFIX
52+ dim = 16
53+
54+ try :
55+ schema = milvus_client .create_schema (auto_id = False , enable_dynamic_field = False )
56+ schema .add_field ("id" , DataType .INT64 , is_primary = True )
57+ schema .add_field ("embedding" , DataType .FLOAT_VECTOR , dim = dim , nullable = True )
58+ milvus_client .create_collection (collection_name = coll , schema = schema )
59+
60+ rng = np .random .default_rng (seed = 0 )
61+ milvus_client .insert (collection_name = coll , data = [
62+ {"id" : 1 , "embedding" : [np .float32 (x ) for x in rng .random (dim )]},
63+ {"id" : 2 , "embedding" : None },
64+ ])
65+ milvus_client .flush (collection_name = coll )
66+
67+ bp = client .create_backup ({
68+ "async" : False ,
69+ "backup_name" : _PROBE_BACKUP_NAME ,
70+ "collection_names" : [coll ],
71+ })
72+ if bp .get ("msg" , "" ) != "success" :
73+ return False
74+
75+ rp = client .restore_backup ({
76+ "async" : False ,
77+ "backup_name" : _PROBE_BACKUP_NAME ,
78+ "collection_names" : [coll ],
79+ "collection_suffix" : SUFFIX ,
80+ })
81+ return rp .get ("msg" , "" ) == "success"
82+ except Exception :
83+ return False
84+ finally :
85+ # best-effort cleanup
86+ for name in (restored_coll , coll ):
87+ try :
88+ milvus_client .drop_collection (name )
89+ except Exception :
90+ pass
91+ try :
92+ client .delete_backup (_PROBE_BACKUP_NAME )
93+ except Exception :
94+ pass
95+
96+
97+ def _check_nullable_vector_support (test_case ):
98+ """Run the capability probe once per class and skip if unsupported."""
99+ cls = type (test_case )
100+ if hasattr (cls , "_nullable_vec_checked" ):
101+ if not cls ._nullable_vec_supported :
102+ pytest .skip (
103+ "target Milvus does not support nullable vector binlog import "
104+ "(Arrow *array.Binary deserialization in BulkInsert); requires "
105+ "Milvus >= 2.6.18 or >= 3.0.0"
106+ )
107+ return
108+
109+ cls ._nullable_vec_checked = True
110+ cls ._nullable_vec_supported = _probe_nullable_vector_roundtrip (
111+ test_case .client ,
112+ test_case .milvus_client ,
113+ )
114+ if not cls ._nullable_vec_supported :
115+ pytest .skip (
116+ "target Milvus does not support nullable vector binlog import "
117+ "(Arrow *array.Binary deserialization in BulkInsert); requires "
118+ "Milvus >= 2.6.18 or >= 3.0.0"
119+ )
120+
121+
122+ # ---- helpers ----------------------------------------------------------------
34123
35124def _vector_value (data_type : DataType , dim : int , seed : int ):
36125 """Generate a deterministic non-NULL vector value for the given dtype."""
@@ -44,10 +133,8 @@ def _vector_value(data_type: DataType, dim: int, seed: int):
44133 if data_type == DataType .INT8_VECTOR :
45134 return np .asarray (rng .integers (- 128 , 127 , size = dim ), dtype = np .int8 )
46135 if data_type == DataType .BINARY_VECTOR :
47- # BINARY_VECTOR expects bytes; dim is in bits, so dim // 8 bytes.
48136 return bytes (rng .integers (0 , 256 , size = dim // 8 , dtype = np .uint8 ).tolist ())
49137 if data_type == DataType .SPARSE_FLOAT_VECTOR :
50- # Sparse vectors are dict[int -> float] in pymilvus.
51138 return {int (i ): float (rng .random ()) for i in rng .choice (10_000 , size = 8 , replace = False )}
52139 raise AssertionError (f"unsupported vector data type: { data_type } " )
53140
@@ -60,9 +147,6 @@ def _add_vector_field(schema, data_type: DataType, name: str, dim: int, nullable
60147
61148
62149def _flush (client , collection_name ):
63- """pymilvus flush() blocks until the underlying FlushAll returns, so a
64- helper here is mostly for readability and future hardening if we need
65- to add row-count polling."""
66150 client .flush (collection_name = collection_name )
67151
68152
@@ -86,6 +170,8 @@ def _describe_field(client, collection_name, field_name):
86170]
87171
88172
173+ # ---- test cases -------------------------------------------------------------
174+
89175class TestRestoreNullableVector (TestcaseBase ):
90176 """Backup/restore behavior for vector fields declared with nullable=True."""
91177
@@ -96,6 +182,7 @@ def test_restore_nullable_vector_round_trip(self, data_type, dim):
96182
97183 Verifies (a) schema nullable flag preserved, (b) per-row NULL preserved.
98184 """
185+ _check_nullable_vector_support (self )
99186 self ._connect ()
100187 collection_name = cf .gen_unique_str (PREFIX )
101188 backup_name = cf .gen_unique_str (BACKUP_PREFIX )
@@ -197,6 +284,7 @@ def test_restore_nullable_vector_round_trip(self, data_type, dim):
197284 @pytest .mark .tags (CaseLabel .L1 )
198285 def test_restore_nullable_vector_search_skips_null (self ):
199286 """Search on the restored nullable vector should skip NULL rows."""
287+ _check_nullable_vector_support (self )
200288 self ._connect ()
201289 collection_name = cf .gen_unique_str (PREFIX )
202290 backup_name = cf .gen_unique_str (BACKUP_PREFIX )
@@ -270,6 +358,7 @@ def test_restore_nullable_vector_search_skips_null(self):
270358 @pytest .mark .tags (CaseLabel .L1 )
271359 def test_restore_add_nullable_vector_field (self ):
272360 """add_collection_field with a nullable vector field round-trips through backup."""
361+ _check_nullable_vector_support (self )
273362 self ._connect ()
274363 collection_name = cf .gen_unique_str (PREFIX )
275364 backup_name = cf .gen_unique_str (BACKUP_PREFIX )
@@ -369,6 +458,7 @@ def test_restore_nullable_vector_with_skip_create_collection(self):
369458
370459 Marked L2 because it exercises the negative path.
371460 """
461+ _check_nullable_vector_support (self )
372462 self ._connect ()
373463 collection_name = cf .gen_unique_str (PREFIX )
374464 backup_name = cf .gen_unique_str (BACKUP_PREFIX )
0 commit comments