JunRuiLee opened a new pull request, #804:
URL: https://github.com/apache/paimon-rust/pull/804
### Purpose
Follow-up to #771, opened as a draft so the API it withdraws from that PR is
visible rather than only described. **Depends on #771** — review that one
first; this branch is built on it, so the diff here is only what this PR adds.
Linked issue: #755
#771 reads engine-planned bucket splits through one terminal: split bytes
in, Arrow rows out. That terminal cannot answer what the search found until the
rows are already materialized, so a caller cannot decide whether the read is
worth doing, and cannot read one search twice under different projections.
This adds the two-step form **alongside** it, following Java's layering:
```rust
let vector_read = search.new_vector_read()?;
let selected = vector_read.read(bucket_splits).await?; // WHICH
rows
// ... inspect: how many files matched, which positions, at what scores
let rows = read_builder.new_read()?.to_arrow_indexed(&selected)?; // WHICH
columns
```
This is the API that earlier revisions of #771 carried and that the review
asked to move out. The reviewer's point stands on its own terms: it has no
consumer independently of the bucket-split entry point, and freezing a
materialize contract before one exists is what #755 said to avoid. It belongs
in its own review, with its own contract discussion — which is what this is.
### Brief change log
- `PkVectorIndexedSplit` becomes public with getters and crate-private
construction. It carries the same three payloads as Java's
`globalindex.IndexedSplit`: the data split, the selected physical ranges, the
aligned scores. Java's is also SERIALIZABLE (its own MAGIC/VERSION frame plus
`SplitSerializer`); this one is **not**, because both halves run in one address
space.
- `VectorSearchBuilder::new_vector_read()` →
`VectorRead::read(Vec<BucketVectorSearchSplit>)` returns the selected splits
without materializing user columns.
- `TableRead::to_arrow_indexed(&[PkVectorIndexedSplit])` materializes them
under the read builder's own projection. `TableRead::indexed_read_type()`
reports that output schema, because `read_type()` describes `to_arrow` and
omits the score column, and a search that matched nothing yields no batch to
learn the schema from. The score field takes id `i32::MAX` and the name Java's
`VectorSearchProcedure.SEARCH_SCORE_FIELD` uses, declared NON-NULL because that
is what the read emits.
- C ABI: four new symbols beside the existing one-shot terminal —
`paimon_vector_search_builder_search_for_bucket_splits` returning an opaque
handle, `paimon_vector_search_splits_count`,
`paimon_vector_search_splits_free`, and `paimon_table_read_to_arrow_indexed`,
which BORROWS the handle so one search can be read again under a different
projection. Nothing is serialized. Every new terminal checks its handle's
`inner`, not only the outer pointer, since a `#[repr(C)]` wrapper can arrive
zero-initialized.
**The one-shot terminal is unchanged and still public.** They are not two
spellings of one thing: one call per bucket with ranked rows is a different
contract from a reusable selection a caller inspects, reads under its own
projection, and may read more than once. Keeping both is also what makes this
diff additive (+2168/−30) rather than a removal plus a replacement — once #771
is in, its Rust entry point and its C ABI symbol are published surface.
#### What the read refuses rather than ignores
A filter, a `with_limit`, explicit `with_row_ranges`, and a
`row_filter_factory`.
The middle two matter because neither ever reaches a `TableRead` — the read
builder keeps both for `TableScan`, which this read does not run — so accepting
them would return MORE rows than asked for, and `with_row_ranges(vec![])`
documents "selects no rows". Java does apply a read limit on this path
(`ReadBuilderImpl.newRead` → `KeyValueTableRead`'s `LimitRecordReader`), so
ignoring one would be a divergence rather than a shared property.
A filter is rejected rather than forwarded because Rust recovers physical
positions by zipping returned batches against the requested selection, so a
predicate that drops rows desyncs the position and score cursor. Java can
forward one because its reader reports each row's own `returnedPosition()`.
The search likewise refuses a `with_projection`, which belongs to the read.
Not mirrored, deliberately: no `TableScan` stage, so no read-protection tag
— Java's is opt-in on `scan.plan-auto-tag-for-read.time-retained` and Rust has
none on any route.
### Tests
Rust (15 in the bucket-split suite, 9 new here):
- `a_search_is_inspectable_before_its_rows_are_read` — asserts what the
search selected BEFORE any data file is opened: how many files, which rows, at
what scores, plus the pinned snapshot and that the derived split is not
raw-convertible. This is the property the route exists for.
- `the_two_step_route_agrees_with_the_one_shot_terminal` — the two terminals
select the same rows.
- `rows_come_back_in_physical_order_carrying_their_scores` — queries nearest
`[4,0]`, whose rank order is the reverse of physical order, so this is the test
that would catch the read starting to rank. Every other test queries `[0,0]`,
where the two coincide.
- `indexed_read_type_matches_the_rows_that_come_back` — against both real
batches and an empty result.
- One test per refusal: reserved projection, filter on the read builder,
projection on the search.
- Unit tests in `table_read.rs` and `read_builder.rs` for the fan-in guard,
per-split validation before streaming, the score-presence requirement, and the
partition-only filter case (which is what makes the `filter_set` bit
load-bearing — the data half of such a filter is empty).
C: the happy path over the Java fixture plus handle and pointer safety
(zero-initialized handles, null arguments, `count`/`free` on null). The
projection and filter contracts are asserted on the Rust side, where a failure
names the semantic that broke.
Gates: `cargo test -p paimon --lib` 2655 passed / 2 ignored; `-p paimon-c`
77 passed with the pre-existing
`vector_search_append_filter_returns_invalid_input` failure, which fails
identically on `origin/main`. `cargo fmt --all -- --check` clean; workspace
clippy `-D warnings` clean except `pypaimon_rust`, which cannot build on this
machine (pyo3 needs Python ≥ 3.10, it has 3.9) and is untouched here.
### API and Format
New public Rust API (`VectorRead`, `PkVectorIndexedSplit`,
`TableRead::{to_arrow_indexed, indexed_read_type}`) and four new C ABI symbols.
No existing symbol changes signature; the ABI signature guards in
`bindings/c/src/vector_search.rs` pin that.
No storage-format change, and deliberately no new wire format: the indexed
splits are an in-process handle, not bytes.
### Documentation
None added. The staged distributed form #755 describes — candidate-only
search, global merge/rerank, deferred materialization — is a further step and
would be where a documented wire format belongs.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]