1f7c0696af
Add streaming full-collection traversal across C++/C/Python (relates to #380). ## API - C++: `Collection::create_iterator(IteratorOptions)` → `DocIterator` (`next()` returns `Result<Doc::Ptr>`: error / `nullptr` EOF / doc; `close()`, idempotent, also run by the destructor). - C API: opaque `zvec_doc_iterator_t` + `zvec_iterator_options_t` handles; `zvec_collection_create_iterator` / `zvec_doc_iterator_next` / `zvec_doc_iterator_close`; errors mapped precisely to `zvec_error_code_t`. - Python: `collection.iter_docs()` returns a `DocIterator` (iterator protocol + context manager; prefer `with collection.iter_docs() as docs:`). The snapshot is taken at call time; the iterator closes itself when exhausted, on `with` exit, or via `close()`, releasing the native slot on every path (including early break and exceptions). ## Snapshot semantics - `create_iterator()` seals the current writing segment on writable collections (read-only collections scan directly, including their writing segment — no flush, no data loss); the snapshot captures the segment set, a deep copy of the delete bitmap, and the schema. Writes after creation are invisible to the iterator; deletions after creation do not affect it. - `IteratorOptions.output_fields_` selects forward fields (unknown/duplicate names rejected with an error); `include_vector_` controls vector materialization. ## Concurrency (admission control + active-iterator count) - Iterators and maintenance operations are mutually exclusive via `maintenance_mtx_`: `create_iterator()` fails fast with `FailedPrecondition` while a maintenance operation (optimize, schema DDL, close, destroy) is running. - While any iterator is open (active-iterator count under the schema lock): schema DDL (create/drop index, add/alter/drop column), destroy and close return `FailedPrecondition`; the destructor path instead logs and waits for open iterators (it cannot report errors); optimize fails at its start. flush, writes and queries are not affected; Stats/Schema/Options (shared lock) are not affected. - The collection must outlive its iterators: close every iterator before closing/releasing the collection (documented in the C++/C API headers). ## Implementation notes - Per-segment readers opened lazily (at most one open at a time); deleted rows filtered by `FilteringReader`, wrapped only when the snapshot's delete bitmap is non-empty (`src/db/index/segment/filtering_reader.*`). - Each batch is materialized column by column in bounded windows of at most 4096 rows (`kMaxRecordBatchNumRows`): a Parquet scan returns a whole row group per ReadNext (up to ~1M rows), so windows cap doc materialization and keep memory constant. Column indices are resolved and validated once per segment reader; scalars/arrays go through the shared column-level converter; vectors are fetched per field using segment-local row ids (`_zvec_row_id_`, correct for compacted segments). Materialization and reader failures are sticky (the error keeps being returned; no partial docs are ever handed out). - Shared converters in `src/db/index/common/doc_field_converter.*` also serve the SQL engine (`ConvertVectorDataBufferToDocField` / `ConvertArrowColumnToDocFields`; `SegmentImpl::Fetch` keeps its pre-existing implementation). - Known limitation: docs without a value for a vector field are accepted by insert (the write path only warns and skips) and are indistinguishable from fetch errors today, so iteration with `include_vector` fails on such docs — documented in code with a TODO; tracked in a separate issue. ## Tests - C++ iterator_test: 22 tests — basic/empty/deleted/close-then-next, include/exclude vector, output_fields selection + rejection, scalar type mapping, 1000-doc integration, read-only collection, performance (100k docs, constant memory), Parquet large-row-group windowed materialization, and concurrency: snapshot isolation under writes, optimize/DDL/close/destroy rejected while open (recovered after the iterator closes), create-iterator rejected while optimize runs, the destructor waits for open iterators, create-iterator-on-closed-collection rejected, slot released by destructor, multiple iterators. - C c_api_test: 6 iterator tests (basic/concurrent-semantics incl. destroy rejection + FAILED_PRECONDITION mapping, exclude-vector, output-fields, empty, null-args) inside the 75-test C API suite. - Python test_iter_docs.py: 11 tests (basic fields/vectors, deletion filtering, output fields, isolation, snapshot-at-call-time, iterator protocol, context manager, early close releases the slot).
283 lines
8.5 KiB
Python
283 lines
8.5 KiB
Python
# Copyright 2025-present the zvec project
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
"""Tests for Collection.iter_docs (document iterator)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
import zvec
|
|
from zvec import (
|
|
CollectionOption,
|
|
DataType,
|
|
Doc,
|
|
FieldSchema,
|
|
HnswIndexParam,
|
|
VectorSchema,
|
|
)
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def iter_schema():
|
|
return zvec.CollectionSchema(
|
|
name="iter_test_collection",
|
|
fields=[
|
|
FieldSchema("id", DataType.INT64, nullable=False),
|
|
FieldSchema("name", DataType.STRING, nullable=False),
|
|
FieldSchema("weight", DataType.FLOAT, nullable=True),
|
|
],
|
|
vectors=[
|
|
VectorSchema(
|
|
"dense",
|
|
DataType.VECTOR_FP32,
|
|
dimension=8,
|
|
index_param=HnswIndexParam(),
|
|
),
|
|
],
|
|
)
|
|
|
|
|
|
@pytest.fixture(scope="function")
|
|
def iter_collection(tmp_path_factory, iter_schema):
|
|
temp_dir = tmp_path_factory.mktemp("zvec_iter")
|
|
path = temp_dir / "iter_collection"
|
|
coll = zvec.create_and_open(
|
|
path=str(path),
|
|
schema=iter_schema,
|
|
option=CollectionOption(read_only=False, enable_mmap=True),
|
|
)
|
|
assert coll is not None
|
|
try:
|
|
yield coll
|
|
finally:
|
|
try:
|
|
coll.destroy()
|
|
except Exception as e:
|
|
print(f"Warning: failed to destroy collection: {e}")
|
|
|
|
|
|
def _make_docs(n: int) -> list[Doc]:
|
|
return [
|
|
Doc(
|
|
id=f"{i}",
|
|
fields={"id": i, "name": f"name_{i}", "weight": float(i)},
|
|
vectors={"dense": [float(i)] * 8},
|
|
)
|
|
for i in range(n)
|
|
]
|
|
|
|
|
|
def test_iter_docs_basic(iter_collection):
|
|
"""Insert N docs, iterate, verify count + PK + scalar fields."""
|
|
n = 50
|
|
result = iter_collection.insert(_make_docs(n))
|
|
assert bool(result)
|
|
iter_collection.flush()
|
|
|
|
seen_ids = set()
|
|
count = 0
|
|
for doc in iter_collection.iter_docs():
|
|
assert isinstance(doc, Doc)
|
|
assert doc.id != ""
|
|
# scalar fields present
|
|
assert doc.field("id") is not None
|
|
assert doc.field("name") is not None
|
|
seen_ids.add(doc.id)
|
|
count += 1
|
|
|
|
assert count == n
|
|
assert len(seen_ids) == n
|
|
assert seen_ids == {f"{i}" for i in range(n)}
|
|
|
|
|
|
def test_iter_docs_include_vector(iter_collection):
|
|
"""include_vector=True (default) returns vectors of correct dimension."""
|
|
iter_collection.insert(_make_docs(10))
|
|
iter_collection.flush()
|
|
|
|
count = 0
|
|
for doc in iter_collection.iter_docs(include_vector=True):
|
|
vec = doc.vector("dense")
|
|
assert vec is not None, f"dense vector missing for {doc.id}"
|
|
assert len(vec) == 8
|
|
count += 1
|
|
assert count == 10
|
|
|
|
|
|
def test_iter_docs_exclude_vector(iter_collection):
|
|
"""include_vector=False omits vector fields."""
|
|
iter_collection.insert(_make_docs(10))
|
|
iter_collection.flush()
|
|
|
|
count = 0
|
|
for doc in iter_collection.iter_docs(include_vector=False):
|
|
# scalar present, vector absent
|
|
assert doc.field("id") is not None
|
|
# Doc.vector() returns {} (falsy) when no vectors are present.
|
|
assert not doc.vector("dense")
|
|
assert "dense" not in doc.vector_names()
|
|
count += 1
|
|
assert count == 10
|
|
|
|
|
|
def test_iter_docs_output_fields(iter_collection):
|
|
"""output_fields limits returned scalar fields."""
|
|
iter_collection.insert(_make_docs(10))
|
|
iter_collection.flush()
|
|
|
|
count = 0
|
|
for doc in iter_collection.iter_docs(output_fields=["id"], include_vector=False):
|
|
assert doc.field("id") is not None
|
|
assert not doc.has_field("name")
|
|
assert not doc.has_field("weight")
|
|
count += 1
|
|
assert count == 10
|
|
|
|
|
|
def test_iter_docs_early_close_releases_slot(iter_collection):
|
|
"""Closing the iterator early releases the active-iterator slot."""
|
|
iter_collection.insert(_make_docs(10))
|
|
iter_collection.flush()
|
|
|
|
it = iter_collection.iter_docs()
|
|
next(it) # consume one doc, iterator stays open
|
|
# Exclusive operations are rejected while the iterator is open.
|
|
with pytest.raises(Exception):
|
|
iter_collection.destroy()
|
|
|
|
it.close() # explicit close releases the slot
|
|
# Exclusive operations succeed again; close is idempotent.
|
|
iter_collection.flush()
|
|
it.close()
|
|
|
|
|
|
def test_iter_docs_context_manager(iter_collection):
|
|
"""with-statement closes the iterator, even on early break."""
|
|
iter_collection.insert(_make_docs(10))
|
|
iter_collection.flush()
|
|
|
|
# Full traversal inside a with-block.
|
|
count = 0
|
|
with iter_collection.iter_docs(include_vector=False) as docs:
|
|
for doc in docs:
|
|
assert doc.field("id") is not None
|
|
count += 1
|
|
assert count == 10
|
|
iter_collection.flush() # slot released after exhaustion
|
|
|
|
# Early break: leaving the with-block must release the slot.
|
|
with iter_collection.iter_docs(include_vector=False) as docs:
|
|
for doc in docs:
|
|
break
|
|
iter_collection.flush()
|
|
|
|
# Exceptions inside the block must not leak the slot either.
|
|
with pytest.raises(RuntimeError):
|
|
with iter_collection.iter_docs(include_vector=False) as docs:
|
|
for doc in docs:
|
|
raise RuntimeError("boom")
|
|
iter_collection.flush()
|
|
|
|
|
|
def test_iter_docs_empty(iter_collection):
|
|
"""Empty collection yields nothing."""
|
|
docs = list(iter_collection.iter_docs())
|
|
assert docs == []
|
|
|
|
|
|
def test_iter_docs_after_delete(iter_collection):
|
|
"""Deleted docs must not appear in iteration."""
|
|
iter_collection.insert(_make_docs(20))
|
|
# delete even ids
|
|
to_delete = [f"{i}" for i in range(0, 20, 2)]
|
|
iter_collection.delete(to_delete)
|
|
iter_collection.flush()
|
|
|
|
deleted = set(to_delete)
|
|
ids = []
|
|
for doc in iter_collection.iter_docs():
|
|
assert doc.id not in deleted
|
|
ids.append(doc.id)
|
|
assert len(ids) == 10
|
|
|
|
|
|
def test_iter_docs_isolation(iter_collection):
|
|
"""Docs written after the iterator is created are not visible."""
|
|
iter_collection.insert(_make_docs(10))
|
|
iter_collection.flush()
|
|
|
|
it = iter_collection.iter_docs()
|
|
# Consume the first doc so the snapshot is established.
|
|
first = next(it)
|
|
assert first is not None
|
|
|
|
# Insert more docs after the iterator started. Writes are allowed while
|
|
# the iterator is open; exclusive schema operations are not (the
|
|
# active-iterator count rejects them), and the new docs are invisible
|
|
# to the snapshot either way.
|
|
iter_collection.insert(
|
|
[
|
|
Doc(
|
|
id=f"new_{i}",
|
|
fields={"id": 1000 + i, "name": "new", "weight": 1.0},
|
|
vectors={"dense": [1.0] * 8},
|
|
)
|
|
for i in range(5)
|
|
]
|
|
)
|
|
with pytest.raises(RuntimeError):
|
|
iter_collection.destroy()
|
|
|
|
# Count remaining from the original snapshot (should be 9, total 10).
|
|
remaining = sum(1 for _ in it)
|
|
assert remaining == 9
|
|
|
|
# A fresh iterator sees all 15.
|
|
assert sum(1 for _ in iter_collection.iter_docs()) == 15
|
|
|
|
|
|
def test_iter_docs_snapshot_at_call_time(iter_collection):
|
|
"""The snapshot is taken when iter_docs() is called, not at first next()."""
|
|
iter_collection.insert(_make_docs(10))
|
|
iter_collection.flush()
|
|
|
|
# Create the iterator but do not consume it yet.
|
|
it = iter_collection.iter_docs()
|
|
|
|
# Writes between the call and the first next() must not be visible
|
|
# (no flush needed: the snapshot already sealed the writing segment).
|
|
iter_collection.insert(
|
|
[
|
|
Doc(
|
|
id="late",
|
|
fields={"id": 999, "name": "late", "weight": 1.0},
|
|
vectors={"dense": [1.0] * 8},
|
|
)
|
|
]
|
|
)
|
|
|
|
assert sum(1 for _ in it) == 10
|
|
|
|
|
|
def test_iter_docs_is_generator(iter_collection):
|
|
"""iter_docs returns a lazy iterator (constant memory)."""
|
|
iter_collection.insert(_make_docs(5))
|
|
iter_collection.flush()
|
|
|
|
gen = iter_collection.iter_docs()
|
|
# It is an iterator: next() works and StopIteration terminates it.
|
|
got = [next(gen) for _ in range(5)]
|
|
assert len(got) == 5
|
|
with pytest.raises(StopIteration):
|
|
next(gen)
|