Files
Yin Yongqi 1f7c0696af feat(iterator): add DocIterator for full collection traversal (#597)
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).
2026-08-21 10:12:01 +08:00

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)