JingsongLi commented on code in PR #771:
URL: https://github.com/apache/paimon-rust/pull/771#discussion_r3911862455


##########
crates/paimon/src/table/vector_search_builder.rs:
##########
@@ -416,6 +417,131 @@ impl<'a> VectorSearchBuilder<'a> {
             .await
     }
 
+    /// Run this search over bucket splits an engine planned elsewhere, and
+    /// materialize the hits.
+    ///
+    /// The unit of work is Java's `BucketVectorSearchSplit` byte form: a 
planner
+    /// running in Paimon Java enumerates one split per bucket -- a bucket is 
never
+    /// divided, because the ANN current-segment decision needs the bucket's 
whole
+    /// active file set -- and ships each to a worker that calls this. The 
splits
+    /// are the plan: their payload files, their per-file row ranges and the
+    /// snapshot they pin are used as given, and this table's index manifest 
is not
+    /// read.
+    ///
+    /// Everything after planning is the ordinary primary-key vector read, so
+    /// search, optional refine, local Top-K and materialization stay 
identical to
+    /// [`execute_read`](Self::execute_read): output is the projected user 
columns
+    /// plus `__paimon_search_score`, best-first. The Top-K is local to the 
supplied
+    /// splits; a caller distributing one call per bucket merges the per-bucket
+    /// results itself.
+    ///
+    /// Only a primary-key vector column can be read this way. The 
data-evolution
+    /// route plans through the global index rather than through bucket 
splits, so
+    /// it is rejected rather than silently answered from a different plan.
+    pub async fn execute_read_for_bucket_splits(
+        &self,
+        split_bytes: &[&[u8]],
+    ) -> crate::Result<ArrowRecordBatchStream> {
+        // Fail closed: returns data outside `TableScan`/`TableRead`.
+        let core = CoreOptions::new(self.table.schema().options());
+        core.ensure_read_authorized()?;
+        let vector_column =
+            self.vector_column
+                .as_deref()
+                .ok_or_else(|| crate::Error::ConfigInvalid {
+                    message: "Vector column must be set via 
with_vector_column()".to_string(),
+                })?;
+        let query_vector =
+            self.query_vector
+                .as_ref()
+                .ok_or_else(|| crate::Error::ConfigInvalid {
+                    message: "Query vector must be set via 
with_query_vector()".to_string(),
+                })?;
+        let limit = self.limit.ok_or_else(|| crate::Error::ConfigInvalid {
+            message: "Limit must be set via with_limit()".to_string(),
+        })?;
+
+        let pk_col = if core.primary_key_vector_index_enabled() {
+            let targets_pk_column = core
+                .primary_key_vector_index_columns()
+                .ok()
+                .is_some_and(|cols| cols.iter().any(|c| c == vector_column));
+            if targets_pk_column {
+                core.primary_key_vector_index_column()?
+            } else {
+                return Err(bucket_split_route_error(vector_column));
+            }
+        } else {
+            return Err(bucket_split_route_error(vector_column));
+        };
+
+        // Decoding is the trust boundary: these bytes come from outside the
+        // process. Reject an empty request here rather than let it reach 
planning
+        // as "no splits", which cannot pin a snapshot.
+        if split_bytes.is_empty() {
+            return Err(crate::Error::DataInvalid {
+                message: "bucket-split read requires at least one 
split".to_string(),
+                source: None,
+            });
+        }
+        let splits = split_bytes
+            .iter()
+            .map(|bytes| BucketVectorSearchSplit::deserialize(bytes))
+            .collect::<crate::Result<Vec<_>>>()?;
+
+        // Resolve the query parameters (and reject a query the search cannot 
answer
+        // correctly) before planning, exactly as the manifest route does.
+        let params = resolve_pk_vector_search_params(
+            self.table,
+            &self.options,
+            self.filter.as_ref(),
+            &core,
+            &pk_col,
+            &[query_vector.as_slice()],
+            limit,
+        )?;
+        let plan = PkVectorScan::new(
+            self.table,
+            params.field_id,
+            params.index_type.clone(),
+            self.filter.clone(),
+        )
+        .plan_for_bucket_vector_splits(splits)?;

Review Comment:
   [P1] Bound or preserve decoded row ranges before bitmap expansion
   
   This new entry point forwards engine-supplied DataFileMeta.row_count values 
into plan_for_bucket_vector_splits without a resource bound. When a file omits 
its range entry (which is valid in the Java encoding), planning synthesizes [0, 
row_count - 1], and the search path materializes that range into a 
RoaringTreemap. Both roaring 0.11.4 and the current 0.11.5 implementation 
iterate every high-u32 shard in insert_range and construct a full u32 bitmap 
for each intermediate shard; row_count = 2^40 already creates 256 full bitmaps 
(about 16.8 million containers), while a value near i64::MAX can hang or OOM 
the worker before any storage I/O. The later i32 row-position validation runs 
only after this expansion. Please reject unreasonable row counts/endpoints and, 
preferably, preserve unrestricted or interval-form selections through the ANN 
layer instead of eagerly materializing every allowed position.



##########
crates/paimon/src/table/vector_search_builder.rs:
##########
@@ -416,6 +417,131 @@ impl<'a> VectorSearchBuilder<'a> {
             .await
     }
 
+    /// Run this search over bucket splits an engine planned elsewhere, and
+    /// materialize the hits.
+    ///
+    /// The unit of work is Java's `BucketVectorSearchSplit` byte form: a 
planner
+    /// running in Paimon Java enumerates one split per bucket -- a bucket is 
never
+    /// divided, because the ANN current-segment decision needs the bucket's 
whole
+    /// active file set -- and ships each to a worker that calls this. The 
splits
+    /// are the plan: their payload files, their per-file row ranges and the
+    /// snapshot they pin are used as given, and this table's index manifest 
is not
+    /// read.
+    ///
+    /// Everything after planning is the ordinary primary-key vector read, so
+    /// search, optional refine, local Top-K and materialization stay 
identical to
+    /// [`execute_read`](Self::execute_read): output is the projected user 
columns
+    /// plus `__paimon_search_score`, best-first. The Top-K is local to the 
supplied
+    /// splits; a caller distributing one call per bucket merges the per-bucket
+    /// results itself.
+    ///
+    /// Only a primary-key vector column can be read this way. The 
data-evolution
+    /// route plans through the global index rather than through bucket 
splits, so
+    /// it is rejected rather than silently answered from a different plan.
+    pub async fn execute_read_for_bucket_splits(
+        &self,
+        split_bytes: &[&[u8]],
+    ) -> crate::Result<ArrowRecordBatchStream> {
+        // Fail closed: returns data outside `TableScan`/`TableRead`.
+        let core = CoreOptions::new(self.table.schema().options());
+        core.ensure_read_authorized()?;
+        let vector_column =
+            self.vector_column
+                .as_deref()
+                .ok_or_else(|| crate::Error::ConfigInvalid {
+                    message: "Vector column must be set via 
with_vector_column()".to_string(),
+                })?;
+        let query_vector =
+            self.query_vector
+                .as_ref()
+                .ok_or_else(|| crate::Error::ConfigInvalid {
+                    message: "Query vector must be set via 
with_query_vector()".to_string(),
+                })?;
+        let limit = self.limit.ok_or_else(|| crate::Error::ConfigInvalid {
+            message: "Limit must be set via with_limit()".to_string(),
+        })?;
+
+        let pk_col = if core.primary_key_vector_index_enabled() {
+            let targets_pk_column = core
+                .primary_key_vector_index_columns()
+                .ok()
+                .is_some_and(|cols| cols.iter().any(|c| c == vector_column));
+            if targets_pk_column {
+                core.primary_key_vector_index_column()?
+            } else {
+                return Err(bucket_split_route_error(vector_column));
+            }
+        } else {
+            return Err(bucket_split_route_error(vector_column));
+        };
+
+        // Decoding is the trust boundary: these bytes come from outside the
+        // process. Reject an empty request here rather than let it reach 
planning
+        // as "no splits", which cannot pin a snapshot.
+        if split_bytes.is_empty() {
+            return Err(crate::Error::DataInvalid {
+                message: "bucket-split read requires at least one 
split".to_string(),
+                source: None,
+            });
+        }
+        let splits = split_bytes
+            .iter()
+            .map(|bytes| BucketVectorSearchSplit::deserialize(bytes))
+            .collect::<crate::Result<Vec<_>>>()?;
+
+        // Resolve the query parameters (and reject a query the search cannot 
answer
+        // correctly) before planning, exactly as the manifest route does.
+        let params = resolve_pk_vector_search_params(
+            self.table,
+            &self.options,
+            self.filter.as_ref(),
+            &core,
+            &pk_col,
+            &[query_vector.as_slice()],
+            limit,
+        )?;
+        let plan = PkVectorScan::new(
+            self.table,
+            params.field_id,
+            params.index_type.clone(),
+            self.filter.clone(),
+        )
+        .plan_for_bucket_vector_splits(splits)?;
+
+        // Resolve the materialization read-type up front so an invalid 
projection
+        // fails loud even when the plan is empty and no rows will be read.
+        let read_type = self.resolve_materialize_read_type()?;
+
+        let mut candidates = search_pk_candidates_batch_with_plan(
+            self.table,
+            &self.options,
+            self.filter.as_ref(),
+            &core,
+            &pk_col,
+            &[query_vector.as_slice()],
+            limit,
+            &plan,
+            &params,
+        )

Review Comment:
   [P2] Keep unrestricted files off the filtered ANN path
   
   A Java split with rangeFileCount == 0 is the normal no-prefilter form 
(including the committed fixture), but the planner normalizes every omitted 
file into an explicit full-file range. This call then converts those ranges 
into RoaringTreemaps and passes Some(filter) to ANN even though every row is 
allowed. Lumina subsequently collects the bitmap into a Vec<u64> and invokes 
search_with_filter, adding O(live_rows) setup and at least 8 * live_rows bytes 
for the ID vector per segment search. Please preserve an unrestricted sentinel 
(with a per-file unrestricted/restricted/excluded state for mixed splits), and 
add a backend-facing test asserting that the no-prefilter fixture uses the 
unfiltered ANN path.



-- 
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]

Reply via email to