This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new a6101eef fix(lumina): decide on the index size before parsing the 
metric (#803)
a6101eef is described below

commit a6101eef032132132485a7ee94f9a58772ae8005
Author: Junrui Lee <[email protected]>
AuthorDate: Thu Sep 10 15:08:26 2026 +0800

    fix(lumina): decide on the index size before parsing the metric (#803)
---
 crates/paimon/src/lumina/reader.rs | 97 ++++++++++++++++++++++++++++++++++++--
 1 file changed, 92 insertions(+), 5 deletions(-)

diff --git a/crates/paimon/src/lumina/reader.rs 
b/crates/paimon/src/lumina/reader.rs
index 00025176..204f9d6b 100644
--- a/crates/paimon/src/lumina/reader.rs
+++ b/crates/paimon/src/lumina/reader.rs
@@ -363,12 +363,16 @@ fn search_lumina<S: LuminaSearch + ?Sized>(
     }
 
     let limit = vector_search.limit;
-    let index_metric = index_meta.metric()?;
+    // Java's order: `index.size()` decides first, and the metric is parsed 
only once
+    // there is something to search. An empty index has no hits to rank, so 
reading
+    // the metric ahead of the size would report a malformed metric for a 
query whose
+    // answer is empty whatever the metric says.
     let count = searcher.get_count()? as usize;
     let effective_k = std::cmp::min(limit, count);
     if effective_k == 0 {
         return Ok(None);
     }
+    let index_metric = index_meta.metric()?;
 
     let include_row_ids = vector_search.effective_include_row_ids();
 
@@ -460,14 +464,27 @@ fn search_lumina_batch<S: LuminaSearch + ?Sized>(
         }
     }
 
-    let filter_id_list =
-        shared_filter.map(|include_row_ids| 
include_row_ids.iter().collect::<Vec<_>>());
-    if filter_id_list.as_ref().is_some_and(Vec::is_empty) {
+    // An include-set that permits nothing can match nothing, whatever the 
index
+    // holds. Answered from the set itself, so no native call is made at all.
+    if shared_filter
+        .as_ref()
+        .is_some_and(|filter| filter.is_empty())
+    {
         return Ok(vec![None; vector_searches.len()]);
     }
 
-    let index_metric = index_meta.metric()?;
+    // Java's order, and it decides what an empty index reports: 
`index.size()` first,
+    // then the dense filter is built, and the metric is parsed last. An empty 
index
+    // has nothing to return whatever the filter says, so converting the 
filter first
+    // would report an oversized one instead, and reading the metric first 
would
+    // report a malformed one. Neither is what happened.
     let count = searcher.get_count()? as usize;
+    if count == 0 {
+        return Ok(vec![None; vector_searches.len()]);
+    }
+    let filter_id_list =
+        shared_filter.map(|include_row_ids| 
include_row_ids.iter().collect::<Vec<_>>());
+    let index_metric = index_meta.metric()?;
     let effective_k = filter_id_list.as_ref().map_or_else(
         || std::cmp::min(limit, count),
         |ids| std::cmp::min(std::cmp::min(limit, count), ids.len()),
@@ -832,6 +849,76 @@ mod tests {
             .is_empty());
     }
 
+    /// An index whose metadata names a metric Lumina does not define, so 
parsing it
+    /// fails. Everything else about the index is well formed -- only the 
ORDER the
+    /// steps run in decides whether the caller sees that failure or an empty 
answer.
+    fn test_index_meta_with_unknown_metric(dim: usize) -> LuminaIndexMeta {
+        LuminaIndexMeta::new(HashMap::from([
+            (KEY_DIMENSION.to_string(), dim.to_string()),
+            (KEY_DISTANCE_METRIC.to_string(), "not-a-metric".to_string()),
+        ]))
+    }
+
+    #[test]
+    fn an_empty_index_reports_empty_before_the_metric_is_parsed() {
+        // Java's order, scalar path: `index.size()` decides first. A query 
against an
+        // index holding nothing has an empty answer whatever the metric says, 
so
+        // reporting a malformed metric here would be an error about something 
that
+        // could not have changed the result.
+        let searcher = RecordingSearcher::new(0);
+        let search = VectorSearch::new(vec![1.0, 0.0], 2, 
"embedding".to_string()).unwrap();
+        let result = search_lumina(
+            &searcher,
+            &test_index_meta_with_unknown_metric(2),
+            &HashMap::new(),
+            &search,
+        )
+        .expect("an empty index reports no hits, not a malformed metric");
+        assert_eq!(result, None);
+        assert!(searcher
+            .unfiltered_calls
+            .lock()
+            .expect("unfiltered call lock")
+            .is_empty());
+    }
+
+    #[test]
+    fn an_empty_index_reports_empty_before_the_filter_is_densified() {
+        // Same order on the batch path, where it also keeps the dense filter 
from
+        // being built: `index.size()` first, then the filter, then the metric.
+        let searcher = RecordingSearcher::new(0);
+        let shared_filter = Arc::new(roaring::RoaringTreemap::from_iter([2, 4, 
6]));
+        let mut first = VectorSearch::new(vec![1.0, 0.0], 2, 
"embedding".to_string()).unwrap();
+        first.set_shared_include_row_ids(Arc::clone(&shared_filter));
+        let mut second = VectorSearch::new(vec![0.0, 1.0], 2, 
"embedding".to_string()).unwrap();
+        second.set_shared_include_row_ids(Arc::clone(&shared_filter));
+
+        let results = search_lumina_batch(
+            &searcher,
+            &test_index_meta_with_unknown_metric(2),
+            &HashMap::new(),
+            &[first, second],
+        )
+        .expect("an empty index reports no hits, not a malformed metric");
+
+        assert_eq!(results, vec![None, None]);
+        assert_eq!(
+            searcher.count_calls.load(Ordering::Relaxed),
+            1,
+            "the index size is what the answer came from"
+        );
+        assert!(searcher
+            .unfiltered_calls
+            .lock()
+            .expect("unfiltered call lock")
+            .is_empty());
+        assert!(searcher
+            .filtered_calls
+            .lock()
+            .expect("filtered call lock")
+            .is_empty());
+    }
+
     #[test]
     fn test_convert_distance_to_score() {
         assert_eq!(convert_distance_to_score(0.0, LuminaVectorMetric::L2), 
1.0);

Reply via email to