Skip to content

Commit 3740eee

Browse files
committed
test: add RocksDB patch compatibility fixture
1 parent 13c4c8e commit 3740eee

1 file changed

Lines changed: 288 additions & 0 deletions

File tree

scripts/rocksdb_patch_compat.py

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
#!/usr/bin/env python3
2+
# Copyright 2025-present the zvec project
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
"""Generate and validate a persisted collection across zvec versions.
16+
17+
Example:
18+
# Generate with the published release.
19+
python -m pip install zvec==0.5.1
20+
python scripts/rocksdb_patch_compat.py generate var/compatibility/v051
21+
22+
# Validate with a separately installed source build.
23+
python scripts/rocksdb_patch_compat.py validate var/compatibility/v051
24+
"""
25+
26+
from __future__ import annotations
27+
28+
import argparse
29+
import gc
30+
import json
31+
import shutil
32+
from pathlib import Path
33+
34+
import zvec
35+
from zvec import (
36+
CollectionOption,
37+
DataType,
38+
Doc,
39+
FieldSchema,
40+
Fts,
41+
FtsIndexParam,
42+
HnswIndexParam,
43+
InvertIndexParam,
44+
MetricType,
45+
Query,
46+
VectorSchema,
47+
)
48+
49+
DOC_COUNT = 256
50+
VECTOR_DIMENSION = 8
51+
GENERATOR_VERSION = "0.5.1"
52+
53+
54+
def zvec_version() -> str:
55+
return getattr(zvec, "__version__", "unknown")
56+
57+
58+
def embedding(ordinal: int) -> list[float]:
59+
"""Return a deterministic, non-degenerate vector for an ordinal."""
60+
return [
61+
((ordinal * (dimension + 3)) % 257) / 257.0
62+
for dimension in range(VECTOR_DIMENSION)
63+
]
64+
65+
66+
def documents() -> list[Doc]:
67+
docs = []
68+
for ordinal in range(DOC_COUNT):
69+
terms = ["compatibility", "fixture", f"document{ordinal}"]
70+
if ordinal % 2 == 0:
71+
terms.append("evenmarker")
72+
if ordinal % 5 == 0:
73+
terms.append("fivemarker")
74+
docs.append(
75+
Doc(
76+
id=f"doc-{ordinal:03d}",
77+
fields={
78+
"ordinal": ordinal,
79+
"category": f"category-{ordinal % 4}",
80+
"content": " ".join(terms),
81+
},
82+
vectors={"embedding": embedding(ordinal)},
83+
)
84+
)
85+
return docs
86+
87+
88+
def collection_schema():
89+
return zvec.CollectionSchema(
90+
name="rocksdb_patch_compat_v051",
91+
fields=[
92+
FieldSchema(
93+
"ordinal",
94+
DataType.INT64,
95+
nullable=False,
96+
index_param=InvertIndexParam(enable_range_optimization=True),
97+
),
98+
FieldSchema(
99+
"category",
100+
DataType.STRING,
101+
nullable=False,
102+
index_param=InvertIndexParam(),
103+
),
104+
FieldSchema(
105+
"content",
106+
DataType.STRING,
107+
nullable=False,
108+
index_param=FtsIndexParam(
109+
tokenizer_name="standard",
110+
filters=["lowercase"],
111+
),
112+
),
113+
],
114+
vectors=[
115+
VectorSchema(
116+
"embedding",
117+
DataType.VECTOR_FP32,
118+
dimension=VECTOR_DIMENSION,
119+
index_param=HnswIndexParam(metric_type=MetricType.L2),
120+
)
121+
],
122+
)
123+
124+
125+
def require_ok(statuses) -> None:
126+
for status in statuses if isinstance(statuses, list) else [statuses]:
127+
if not status.ok():
128+
raise RuntimeError(f"zvec operation failed: {status}")
129+
130+
131+
def sidecar_path(collection_path: Path) -> Path:
132+
return collection_path.with_name(f"{collection_path.name}.fixture.json")
133+
134+
135+
def generate(collection_path: Path, overwrite: bool) -> None:
136+
if zvec_version() != GENERATOR_VERSION:
137+
raise RuntimeError(
138+
f"fixture generation requires zvec=={GENERATOR_VERSION}, "
139+
f"loaded {zvec_version()}"
140+
)
141+
142+
if collection_path.exists():
143+
if not overwrite:
144+
raise FileExistsError(
145+
f"{collection_path} already exists; pass --overwrite to replace it"
146+
)
147+
shutil.rmtree(collection_path)
148+
149+
sidecar = sidecar_path(collection_path)
150+
if sidecar.exists() and overwrite:
151+
sidecar.unlink()
152+
153+
collection_path.parent.mkdir(parents=True, exist_ok=True)
154+
collection = zvec.create_and_open(
155+
path=str(collection_path),
156+
schema=collection_schema(),
157+
option=CollectionOption(read_only=False, enable_mmap=True),
158+
)
159+
require_ok(collection.insert(documents()))
160+
collection.flush()
161+
collection.optimize()
162+
collection.flush()
163+
164+
if collection.stats.doc_count != DOC_COUNT:
165+
raise AssertionError(
166+
f"expected {DOC_COUNT} documents, got {collection.stats.doc_count}"
167+
)
168+
169+
# Collection has no explicit close API in 0.5.1. Releasing the last Python
170+
# reference closes the native collection before inspecting its files.
171+
del collection
172+
gc.collect()
173+
174+
sst_files = sorted(
175+
str(path.relative_to(collection_path))
176+
for path in collection_path.rglob("*.sst")
177+
)
178+
if not sst_files:
179+
raise AssertionError("fixture contains no persisted RocksDB SST files")
180+
181+
sidecar.write_text(
182+
json.dumps(
183+
{
184+
"generator_version": zvec_version(),
185+
"doc_count": DOC_COUNT,
186+
"vector_dimension": VECTOR_DIMENSION,
187+
"sst_files": sst_files,
188+
},
189+
indent=2,
190+
sort_keys=True,
191+
)
192+
+ "\n",
193+
encoding="utf-8",
194+
)
195+
print(
196+
f"generated {collection_path} with zvec {zvec_version()}: "
197+
f"{DOC_COUNT} docs, {len(sst_files)} SST files"
198+
)
199+
200+
201+
def validate(collection_path: Path) -> None:
202+
sidecar = sidecar_path(collection_path)
203+
metadata = json.loads(sidecar.read_text(encoding="utf-8"))
204+
if metadata["generator_version"] != GENERATOR_VERSION:
205+
raise AssertionError(
206+
f"expected a {GENERATOR_VERSION} fixture, got {metadata}"
207+
)
208+
209+
collection = zvec.open(
210+
path=str(collection_path),
211+
option=CollectionOption(read_only=True, enable_mmap=True),
212+
)
213+
if collection.stats.doc_count != DOC_COUNT:
214+
raise AssertionError(
215+
f"expected {DOC_COUNT} documents, got {collection.stats.doc_count}"
216+
)
217+
218+
fetched = collection.fetch(
219+
["doc-000", "doc-127", "doc-255"], include_vector=True
220+
)
221+
if set(fetched) != {"doc-000", "doc-127", "doc-255"}:
222+
raise AssertionError(f"unexpected fetch result: {set(fetched)}")
223+
if fetched["doc-127"].fields["ordinal"] != 127:
224+
raise AssertionError("scalar field changed while reopening the fixture")
225+
226+
scalar_hits = collection.query(filter="ordinal>=240", topk=DOC_COUNT)
227+
scalar_ids = {doc.id for doc in scalar_hits}
228+
expected_scalar_ids = {f"doc-{ordinal:03d}" for ordinal in range(240, 256)}
229+
if scalar_ids != expected_scalar_ids:
230+
raise AssertionError(
231+
f"inverted-index mismatch: expected {expected_scalar_ids}, "
232+
f"got {scalar_ids}"
233+
)
234+
235+
fts_hits = collection.query(
236+
queries=Query(
237+
field_name="content", fts=Fts(match_string="evenmarker")
238+
),
239+
topk=DOC_COUNT,
240+
)
241+
expected_fts_ids = {
242+
f"doc-{ordinal:03d}" for ordinal in range(DOC_COUNT) if ordinal % 2 == 0
243+
}
244+
fts_ids = {doc.id for doc in fts_hits}
245+
if fts_ids != expected_fts_ids:
246+
raise AssertionError(
247+
f"FTS mismatch: expected {len(expected_fts_ids)} hits, "
248+
f"got {len(fts_ids)}"
249+
)
250+
251+
vector_hits = collection.query(
252+
queries=Query(field_name="embedding", vector=embedding(127)),
253+
topk=1,
254+
include_vector=True,
255+
)
256+
if len(vector_hits) != 1 or vector_hits[0].id != "doc-127":
257+
raise AssertionError(f"vector-index mismatch: {vector_hits}")
258+
259+
del collection
260+
gc.collect()
261+
print(
262+
f"validated zvec {metadata['generator_version']} fixture with "
263+
f"zvec {zvec_version()}: fetch, inverted, FTS, and vector indexes pass"
264+
)
265+
266+
267+
def parse_args() -> argparse.Namespace:
268+
parser = argparse.ArgumentParser(description=__doc__)
269+
parser.add_argument("mode", choices=("generate", "validate"))
270+
parser.add_argument("collection_path", type=Path)
271+
parser.add_argument(
272+
"--overwrite",
273+
action="store_true",
274+
help="replace an existing collection when generating",
275+
)
276+
return parser.parse_args()
277+
278+
279+
def main() -> None:
280+
args = parse_args()
281+
if args.mode == "generate":
282+
generate(args.collection_path.resolve(), args.overwrite)
283+
else:
284+
validate(args.collection_path.resolve())
285+
286+
287+
if __name__ == "__main__":
288+
main()

0 commit comments

Comments
 (0)