feat(iterator): add DocIterator for full collection traversal - #597
feat(iterator): add DocIterator for full collection traversal#597YongqiYin wants to merge 13 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces a full-collection document iterator (“DocIterator”) across the C++ core, C API, and Python bindings to enable streaming traversal/export without relying on large topk queries (relates to #380).
Changes:
- Add C++
Collection::CreateIterator()andDocIteratorwith snapshot isolation, segment concatenation, and delete filtering. - Expose the iterator via the C API (
zvec_collection_create_iterator/next/close) and Python (Collection.iter_docs()generator). - Add C++/C/Python tests covering basic iteration, delete filtering, field selection, and concurrency/isolation.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/db/iterator_test.cc | New C++ unit/integration/concurrency/perf tests for full traversal. |
| tests/c/c_api_test.c | Adds C API iterator tests and option handling coverage. |
| src/include/zvec/db/options.h | Introduces IteratorOptions (output_fields/include_vector). |
| src/include/zvec/db/doc_iterator.h | Public C++ DocIterator interface. |
| src/include/zvec/db/collection.h | Adds Collection::CreateIterator() API. |
| src/include/zvec/c_api.h | Adds public C iterator and iterator-options API. |
| src/db/index/segment/filtering_reader.h | New Arrow reader wrapper to filter deleted docs. |
| src/db/index/segment/concatenating_reader.h | New Arrow reader to concatenate readers across segments. |
| src/db/doc_iterator.cc | Implements row-by-row Doc materialization + optional vector prefetch. |
| src/db/doc_iterator_internal.h | Internal DocIterator::Impl definition (lifetime ordering, caches). |
| src/db/collection.cc | Implements iterator creation, snapshot scan, and reader chain construction. |
| src/binding/python/model/python_collection.cc | Exposes _DocIterator + Collection.CreateIterator() to Python. |
| src/binding/python/include/python_collection.h | Declares bind_iterator() hook. |
| src/binding/c/c_api.cc | Implements iterator options + iterator handles for the C API. |
| python/zvec/model/collection.py | Adds Collection.iter_docs() streaming generator. |
| python/tests/test_iter_docs.py | New Python tests for iterator behavior and isolation. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
b596564 to
4aca7cb
Compare
|
@copilot resolve the merge conflicts in this pull request |
4aca7cb to
feef1d7
Compare
96273fc to
4f542e0
Compare
Add streaming full-collection traversal across C++/C/Python: - C++: Collection::CreateIterator + DocIterator (isolated Flush+snapshot scan, ConcatenatingReader across segments, FilteringReader for deletes, batch-prefetched vectors) - C API: zvec_collection_create_iterator/next/close + iterator options - Python: collection.iter_docs() generator (constant memory) - Tests: C++ (unit/integration/concurrency/perf), C API, Python Relates to alibaba#380
4f542e0 to
16c0bd2
Compare
- iterate segment-by-segment: one FilteringReader per segment (drop ConcatenatingReader), so each batch's owning segment is known directly - hide CollectionImpl from the public doc_iterator.h (Pimpl fwd-decl + public ctor, no friend/Passkey in the public header) - propagate errors to callers instead of logging: Segment::scan failure, missing vector indexer, vector fetch failure, buffer conversion failure - fetch vectors via the segment-local row id column (LOCAL_ROW_ID) instead of g_doc_id - min_doc_id arithmetic (safe for compacted segments with non-contiguous doc ids) - use has_record() for the writable flush condition (aligns with alibaba#618) - drop stale comments and unused includes
Address review comments on shared conversion helpers: - Add db/index/common/doc_field_converter with two shared helpers: ConvertVectorDataBufferToDocField (dense + sparse vector buffers) and ConvertArrowRowToDocField (all 9 scalar + 9 array data types). - DocIterator, SegmentImpl::Fetch and sqlengine fill_doc_field now share the same row-level conversion, removing ~650 lines of duplicated type-switch code. - SegmentImpl::Fetch boxes its single-value scalars via arrow::MakeArrayFromScalar to reuse the row converter, keeping its lenient log-and-continue contract unchanged. - fill_doc_vector/fill_doc_sparse_vector stay in sqlengine: they decode a query-result-specific Arrow encoding with a single consumer.
|
|
||
| std::pair<std::vector<IndexType>, std::vector<ValueType>> sparse_vector_pair( | ||
| std::move(indices_vector), std::move(values_vector)); | ||
| doc->set(field->name(), sparse_vector_pair); |
| const T *data_ptr = reinterpret_cast<const T *>(buffer.data.data()); | ||
| size_t data_size = buffer.data.size() / sizeof(T); | ||
| std::vector<T> vector_data(data_ptr, data_ptr + data_size); | ||
| doc->set(field->name(), vector_data); |
| template <typename ArrowArrayT> | ||
| Status SetScalarField(const std::shared_ptr<arrow::Array> &array, int64_t row, | ||
| const std::string &name, Doc *doc) { | ||
| auto typed_array = std::dynamic_pointer_cast<ArrowArrayT>(array); |
There was a problem hiding this comment.
已改为裸指针入参 + static_cast
| template <typename ArrowArrayT, typename T> | ||
| Status SetListField(const std::shared_ptr<arrow::Array> &array, int64_t row, | ||
| const std::string &name, Doc *doc) { | ||
| auto list_array = std::dynamic_pointer_cast<arrow::ListArray>(array); |
| if (!typed_array) { | ||
| return Status::InternalError("Arrow array type mismatch for field: ", name); | ||
| } | ||
| if constexpr (std::is_same_v<ArrowArrayT, arrow::StringArray> || |
There was a problem hiding this comment.
需要检查是否null,以string为例,""和null是不一样的
There was a problem hiding this comment.
已加,ConvertArrowRowToDocField 入口已有array->IsNull(row) 检查,此处加了第二道,保证若未来直接调SetScalarField 行为正确
| impl_->vector_cache_.clear(); | ||
| impl_->segments.clear(); | ||
| impl_->delete_store.reset(); | ||
| impl_->schema.reset(); |
There was a problem hiding this comment.
可以直接reset impl_吗?简单一点
如果不行的话,最后也reset一下impl_吧,当前虽然没问题,但万一后面逻辑有修改引入问题
There was a problem hiding this comment.
已在最后impl_.reset()
没有直接impl_.reset()是将现在的析构顺序固化,相对更安全
若进一步保证未来安全可以加一个单测,我觉得目前不需要做到这程度
| auto uid_array = | ||
| std::dynamic_pointer_cast<arrow::StringArray>(batch.column(uid_col)); | ||
| if (uid_array) { | ||
| // GetView avoids the per-row Scalar allocation of GetScalar()->ToString() |
There was a problem hiding this comment.
这里现场马上构造了一个string,使用这个注释没什么意义,可以去掉。
| if (impl_->include_vector && impl_->schema) { | ||
| for (const auto &field : impl_->schema->vector_fields()) { | ||
| auto it = impl_->vector_cache_.find(field->name()); | ||
| if (it == impl_->vector_cache_.end()) continue; |
|
|
||
| namespace zvec { | ||
|
|
||
| class DocIterator { |
There was a problem hiding this comment.
rebase最新代码,加上ZVEC_API,其他类似问题一并改下
| /** | ||
| * @brief Opaque handle for iterator options. | ||
| * | ||
| * Follows the same pattern as zvec_collection_options_t (opaque type + setters) |
There was a problem hiding this comment.
这个描述是开发者的内心独白,作为注释对用户没什么意义,可以精简下
| >>> for doc in collection.iter_docs(include_vector=False): | ||
| ... print(doc.id, doc.field("title")) | ||
| """ | ||
| iterator = self._obj.CreateIterator(output_fields, include_vector) |
There was a problem hiding this comment.
Python snapshot 仍延迟到第一次 next()。 [collection.py (line 407)]位于包含 yield 的 generator body 中,因此 iter_docs() 调用时不会执行 CreateIterator。it = iter_docs(); insert(...); next(it) 会看到调用后写入的数据,与“snapshot taken at call time”不符。现有测试先执行 next(it) 再写入,未覆盖该问题。
参考
def iter_docs(...):
iterator = self._obj.CreateIterator(output_fields, include_vector)
def generate():
try:
for core_doc in iterator:
py_doc = convert_to_py_doc(core_doc, self.schema)
if py_doc is not None:
yield py_doc
finally:
iterator.close()
return generate()
| bool loaded = false; | ||
| while (impl_->current_segment_index < impl_->readers.size()) { | ||
| auto &reader = impl_->readers[impl_->current_segment_index]; | ||
| auto status = reader->ReadNext(&impl_->current_batch); |
| std::vector<RecordBatchReaderPtr> readers; | ||
| readers.reserve(segments.size()); | ||
| for (const auto &seg : segments) { | ||
| auto scalar_reader = seg->scan(scan_columns); |
There was a problem hiding this comment.
可以一次只打开一个reader,用完了释放,减少整体的资源需求
…view feedback Per review feedback on the shared-lock model: a maintenance operation waiting for a running Optimize became a pending exclusive acquirer of the schema lock, blocking new readers on typical shared_mutex implementations. Changes: - Replace optimize_mtx_ with maintenance_mtx_ (acquired by Optimize, schema DDLs, Flush, Close and Destroy before the schema lock). Lock order: maintenance -> schema -> write -> SegmentManager. - Restructure Optimize into three phases: exclusive seal, lock-free compact (MoveDirectory + Segment::Open moved to lock-free phase 2 tail, so an open failure aborts before the manifest is persisted), and short exclusive commit that restores in-place reload_vector_index with eager file removal (no reader can hold indexers under exclusive schema lock). - Downgrade read-only Stats()/Schema()/Options() to shared locking. - Remove replace_segments() and the deferred index-file removal machinery introduced earlier in this PR; they are only needed by lock-free consumers (DocIterator, alibaba#597) and will land there. - Keep and adapt the two regression UTs (concurrent read-write and superseded index file count).
- doc_field_converter: pass raw pointers instead of shared_ptr, add per-row null check, move vectors into Doc::set, direct return per case - filtering_reader: static_cast instead of dynamic_pointer_cast - collection: validate output_fields (schema membership + no duplicates), build_scan_columns returns Result, move scalar_reader into reader - doc_iterator: reset impl_ in Close, report vector cache errors instead of silently skipping, drop stale comment - doc_iterator.h: add ZVEC_API export macro - c_api.h: trim implementation-detail comment - collection.py: take the snapshot eagerly at iter_docs() call time
Extend the reviewer's shared_ptr-reduction feedback to the remaining hot paths: - ConvertArrowRowToDocField now takes a raw Array* (callers pass batch.columns()[i].get() / chunk.get()), removing per-row atomic ref-count traffic. - DocIterator PK/doc_id/row-id extraction uses type_id() + static_cast instead of per-row dynamic_pointer_cast. - Update the Scan comment after alibaba#614: snapshot consistency comes from the write_mtx_ atomic snapshot + shared_ptr keep-alive, not from blocking Optimize (which now takes the schema lock in shared mode).
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 19 out of 19 changed files in this pull request and generated no new comments.
Suppressed comments (12)
tests/db/iterator_test.cc:183
- The iterator is still alive when
collection->Destroy()is called, which can leave Arrow/segment resources open during destruction and cause flaky cleanup. Close the iterator before destroying the collection.
EXPECT_EQ(count, N - static_cast<int>(pks_to_delete.size()));
collection->Destroy();
tests/db/iterator_test.cc:271
- The iterator is still alive when
collection->Destroy()is called, which can keep segment/Arrow resources open during destruction. Close the iterator before destroying the collection to avoid flaky cleanup.
EXPECT_EQ(count, N);
collection->Destroy();
tests/db/iterator_test.cc:317
- The iterator is still alive when
collection->Destroy()is called, which can keep segment/Arrow resources open during destruction. Close the iterator before destroying the collection to avoid flaky cleanup.
EXPECT_EQ(count, 5);
collection->Destroy();
tests/db/iterator_test.cc:391
- The iterator is not closed before
collection->Destroy(). Keeping the iterator alive can keep file handles open and makeDestroy()flaky on some platforms. Close the iterator before destroying the collection.
EXPECT_EQ((*a_s)[0], "value_" + std::to_string(kId));
collection->Destroy();
tests/db/iterator_test.cc:447
- The iterator is not closed before
collection->Destroy(). If the iterator still holds segment/Arrow resources, destroying the collection can be flaky (especially on Windows). Close the iterator before destroying the collection.
EXPECT_EQ(count, N);
EXPECT_EQ(seen_pks.size(), (size_t)N);
collection->Destroy();
tests/db/iterator_test.cc:509
- Both iterators (
iteranditer2) are still alive whencollection->Destroy()is called. Close them before destroying the collection to ensure underlying segment/Arrow resources are released and cleanup is reliable.
EXPECT_EQ(count2, N + 200);
collection->Destroy();
tests/db/iterator_test.cc:556
- The iterator is still alive when
collection->Destroy()is called, which can keep resources open during destruction and cause flaky cleanup. Close the iterator before destroying the collection.
EXPECT_EQ(count, N);
collection->Destroy();
}
tests/db/iterator_test.cc:607
- The iterator is still alive when
collection->Destroy()is called. Close it before destroying the collection to ensure segment/Arrow resources are released and test cleanup is reliable.
<< std::endl;
collection->Destroy();
}
tests/db/iterator_test.cc:16
std::coutis used later in this test file (Performance100k), but<iostream>is not included. This can break compilation depending on transitive includes.
#include <chrono>
tests/db/iterator_test.cc:126
- The iterator is still alive when
collection->Destroy()is called. On platforms with strict file-handle semantics (and per DocIterator's internal comment about releasing Arrow handles before segment cleanup), this can makeDestroy()fail or become flaky. Close the iterator (or let it go out of scope) before destroying the collection.
This issue also appears in the following locations of the same file:
- line 181
- line 270
- line 316
- line 389
- line 445
- ...and 3 more
EXPECT_EQ(r.value(), nullptr) << "Expected EOF on empty collection";
collection->Destroy();
src/binding/c/c_api.cc:7333
zvec_collection_create_iteratoralways maps C++ iterator creation failures toZVEC_ERROR_INTERNAL_ERROR, even when the underlyingStatusisINVALID_ARGUMENT(e.g., unknown/duplicate output fields). This loses actionable error information for C callers; use the existingstatus_to_error_code()helper for consistent mapping.
SET_LAST_ERROR(ZVEC_ERROR_INTERNAL_ERROR,
"Failed to create iterator: " +
result.error().message());
return ZVEC_ERROR_INTERNAL_ERROR;
src/binding/c/c_api.cc:7361
zvec_doc_iterator_nextconverts all iterator failures intoZVEC_ERROR_INTERNAL_ERROR, which hides the underlyingStatusCodeand is inconsistent with other C API wrappers that usestatus_to_error_code(). Map the error code fromresult.error()so callers can distinguish invalid arguments vs internal failures.
if (!result.has_value()) {
SET_LAST_ERROR(ZVEC_ERROR_INTERNAL_ERROR,
"Iterator next failed: " + result.error().message());
return ZVEC_ERROR_INTERNAL_ERROR;
}
…ue patterns - Gate per-element IsNull checks behind an O(1) null_count() precheck (the common null-free case skips validity checks entirely, matching the optimization the reviewer asked to preserve). - Unify element extraction on Value() for all list element types (string/binary Value returns string_view, emplace_back converts it to std::string in place), removing the if-constexpr type branch.
Regression test for the deferred-snapshot issue: creating the iterator without consuming it must already freeze the snapshot, so documents inserted between iter_docs() and the first next() stay invisible. Verified red on the lazy-generator version, green on the fix.
- Fetch reads Scalars directly again and drops MakeArrayFromScalar boxing; its LIST branch now shares value extraction with the iterator/SQL-engine path via ExtractTypedArrayValues . - Open one segment reader lazily and release it when the segment is exhausted, so at most one segment's files stay open. - Prefetch vectors in bounded windows (kIteratorVectorPrefetchWindow) so a large Parquet row group cannot cache a million vectors. - Skip empty batches defensively; fail instead of silently emitting docs when uid/g_doc_id columns are missing; cache column indices per batch to avoid per-row GetFieldIndex lookups.
…pshot side effects - output_fields accepts only forward fields and rejects duplicates; validation runs before the writing segment is sealed so invalid options fail fast with no side effect. - Map CreateIterator/Next failures through status_to_error_code in the C API so callers see INVALID_ARGUMENT instead of INTERNAL_ERROR. - Document the seal side effect and the close-before-destroy contract in the C++/C/Python API docs.
- Add OutputFieldsSelection and InvalidOutputFieldsRejected. - Add ParquetVectorPrefetchWindows: a row group larger than the prefetch window verifies window-refill alignment. - Close iterators before Destroy() and add the missing <iostream> include.

Add streaming full-collection traversal across C++/C/Python:
Relates to #380