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 33a14cc9 Complete native global-index lifecycle and search planning
(#1136)
33a14cc9 is described below
commit 33a14cc9be88fc26e59aafc624e349994e980289
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Oct 16 09:35:09 2026 +0800
Complete native global-index lifecycle and search planning (#1136)
---
Cargo.lock | 2 +
bindings/python/Cargo.toml | 2 +
.../python/python/pypaimon_rust/datafusion.pyi | 32 +++
bindings/python/src/blob_uri_reader.rs | 10 +-
bindings/python/src/context.rs | 2 +
bindings/python/src/full_text_search.rs | 11 +
bindings/python/src/index_build.rs | 99 ++++++++-
bindings/python/src/lib.rs | 1 +
bindings/python/src/table.rs | 4 +
bindings/python/src/vector_search.rs | 67 +++++-
bindings/python/src/vindex.rs | 197 ++++++++++++++++++
crates/paimon/src/table/de_vector_read.rs | 120 ++++++++---
crates/paimon/src/table/de_vector_read/tests.rs | 145 +++++++++++++
.../src/table/full_text_index_build_builder.rs | 26 +--
.../src/table/full_text_search_builder/scan.rs | 30 +++
.../paimon/src/table/global_index_build_builder.rs | 88 ++++++++
.../paimon/src/table/global_index_build_common.rs | 27 +++
.../paimon/src/table/global_index_drop_builder.rs | 139 +++++++++++--
crates/paimon/src/table/mod.rs | 2 +
crates/paimon/src/table/pk_vector_read.rs | 137 +++++++------
crates/paimon/src/table/pk_vector_scan.rs | 19 +-
crates/paimon/src/table/search_scan_split.rs | 224 +++++++++++++++++++++
.../src/table/sorted_global_index_build_builder.rs | 4 +-
.../extraction.rs | 3 +
.../sorted_global_index_build_builder/tests.rs | 140 +++++++++++++
.../validation.rs | 40 +---
crates/paimon/src/table/vector_read.rs | 2 +-
crates/paimon/src/table/vector_scan.rs | 145 ++++++++++++-
crates/paimon/src/table/vector_search_common.rs | 56 +++++-
.../paimon/src/table/vindex_index_build_builder.rs | 6 +-
.../table/vindex_index_build_builder/extraction.rs | 3 +
.../src/table/vindex_index_build_builder/tests.rs | 114 +++++++++++
.../table/vindex_index_build_builder/validation.rs | 36 +---
crates/paimon/src/vindex/reader.rs | 154 ++++++++++----
34 files changed, 1814 insertions(+), 273 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index e1b2da67..0efb9144 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5393,7 +5393,9 @@ dependencies = [
"futures",
"paimon",
"paimon-datafusion",
+ "paimon-vindex-core",
"pyo3",
+ "roaring",
"serde_json",
"tokio",
]
diff --git a/bindings/python/Cargo.toml b/bindings/python/Cargo.toml
index 2739e62d..733b7b6d 100644
--- a/bindings/python/Cargo.toml
+++ b/bindings/python/Cargo.toml
@@ -38,7 +38,9 @@ datafusion-ffi = { workspace = true }
futures = "0.3"
paimon = { path = "../../crates/paimon", features = ["storage-all"] }
paimon-datafusion = { path = "../../crates/integrations/datafusion", features
= ["fulltext"] }
+paimon-vindex-core = "0.5.0"
pyo3 = { version = "0.28", features = ["abi3-py310", "chrono"] }
+roaring = "0.11"
serde_json = "1.0"
tokio = { workspace = true }
diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index 5b132e81..3af2afc5 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -67,6 +67,20 @@ class Plan:
def splits(self) -> List[Split]: ...
def __len__(self) -> int: ...
+class VindexVectorGlobalIndexReader:
+ """Rust index search over an input exposing pread_many(ranges)."""
+ def __init__(
+ self, input: Any, file_path: str, file_size: int,
+ options: Optional[Dict[str, str]] = None,
+ ) -> None: ...
+ def metric(self) -> str: ...
+ def search(
+ self, vectors: List[List[float]], limit: int,
+ filter_bytes: Optional[bytes] = None,
+ options: Optional[Dict[str, str]] = None,
+ ) -> List[Optional[Dict[int, float]]]: ...
+ def close(self) -> None: ...
+
class GlobalIndexResult:
"""Index selection shared by table searches and scans; PK selections
retain their snapshot."""
@staticmethod
@@ -276,6 +290,7 @@ class Table:
def new_vector_search_builder(self) -> "VectorSearchBuilder": ...
def new_batch_vector_search_builder(self) -> "BatchVectorSearchBuilder":
...
def new_global_index_build_builder(self) -> "GlobalIndexBuildBuilder": ...
+ def new_global_index_drop_builder(self) -> "GlobalIndexDropBuilder": ...
def new_batch_write_builder(self) -> "BatchWriteBuilder": ...
def new_postpone_fixed_bucket_write_builder(self) ->
"PostponeFixedBucketWriteBuilder": ...
def new_stream_write_builder(self) -> "StreamWriteBuilder": ...
@@ -327,6 +342,21 @@ class GlobalIndexBuildBuilder:
"""Build and publish indexes, rejecting a change to the planned
snapshot."""
...
+class GlobalIndexDropBuilder:
+ @staticmethod
+ def supports_index_type(index_type: str) -> bool: ...
+ def with_index_columns(self, columns: List[str]) ->
"GlobalIndexDropBuilder": ...
+ def with_index_column(self, column: str) -> "GlobalIndexDropBuilder": ...
+ def with_index_type(self, index_type: str) -> "GlobalIndexDropBuilder": ...
+ def with_partition_filter(self, predicate: Dict[str, Any]) ->
"GlobalIndexDropBuilder": ...
+ def with_dry_run(self, dry_run: bool) -> "GlobalIndexDropBuilder": ...
+ def build(self) -> List["CommitMessage"]:
+ """Prepare metadata deletions; the caller decides whether to commit."""
+ ...
+ def execute(self) -> int:
+ """Drop matched indexes, or report their count for dry runs."""
+ ...
+
class CommitMessage:
@staticmethod
def deserialize(data: bytes, *, version: int = 14) -> "CommitMessage":
@@ -774,6 +804,7 @@ class VectorScan:
def scan(self) -> "VectorScanPlan": ...
class VectorScanPlan:
+ def _metadata(self) -> str: ...
def snapshot_id(self) -> Optional[int]: ...
class VectorRead:
@@ -817,6 +848,7 @@ class FullTextScan:
def scan(self) -> FullTextScanPlan: ...
class FullTextScanPlan:
+ def _metadata(self) -> str: ...
def snapshot_id(self) -> Optional[int]: ...
class FullTextRead:
diff --git a/bindings/python/src/blob_uri_reader.rs
b/bindings/python/src/blob_uri_reader.rs
index 95b9b38d..c5cd2f91 100644
--- a/bindings/python/src/blob_uri_reader.rs
+++ b/bindings/python/src/blob_uri_reader.rs
@@ -63,7 +63,15 @@ pub(crate) fn to_callback_error(error: paimon::Error) ->
PyErr {
) => source
.as_deref()
.map(|source| source as &(dyn std::error::Error + 'static)),
- _ => cause.source(),
+ // std::io::Error::source skips its custom error itself. Inspect
+ // that value too, since positional index I/O wraps Paimon errors.
+ _ => match cause.downcast_ref::<std::io::Error>() {
+ Some(error) => error
+ .get_ref()
+ .map(|error| error as &(dyn std::error::Error + 'static))
+ .or_else(|| cause.source()),
+ None => cause.source(),
+ },
};
match next {
Some(source) => cause = source,
diff --git a/bindings/python/src/context.rs b/bindings/python/src/context.rs
index d312192a..edd49205 100644
--- a/bindings/python/src/context.rs
+++ b/bindings/python/src/context.rs
@@ -797,12 +797,14 @@ pub fn register_module(py: Python<'_>, m: &Bound<'_,
PyModule>) -> PyResult<()>
this.add_class::<PaimonCatalog>()?;
this.add_class::<crate::table::PyTable>()?;
this.add_class::<crate::index_build::PyGlobalIndexBuildBuilder>()?;
+ this.add_class::<crate::index_build::PyGlobalIndexDropBuilder>()?;
this.add_class::<crate::full_text_search::PyFullTextSearchBuilder>()?;
this.add_class::<crate::full_text_search::PyFullTextScan>()?;
this.add_class::<crate::full_text_search::PyFullTextScanPlan>()?;
this.add_class::<crate::full_text_search::PyFullTextRead>()?;
this.add_class::<crate::hybrid_search::PyHybridSearchBuilder>()?;
this.add_class::<crate::vector_search::PyVectorSearchBuilder>()?;
+ this.add_class::<crate::vindex::PyVindexVectorGlobalIndexReader>()?;
this.add_class::<crate::vector_search::PyBatchVectorSearchBuilder>()?;
this.add_class::<crate::vector_search::PyVectorScan>()?;
this.add_class::<crate::vector_search::PyVectorScanPlan>()?;
diff --git a/bindings/python/src/full_text_search.rs
b/bindings/python/src/full_text_search.rs
index a3fc6a9b..acdc76e9 100644
--- a/bindings/python/src/full_text_search.rs
+++ b/bindings/python/src/full_text_search.rs
@@ -150,6 +150,17 @@ pub struct PyFullTextScanPlan {
}
#[pymethods]
impl PyFullTextScanPlan {
+ fn _metadata(&self, py: Python<'_>) -> PyResult<String> {
+ let splits = py
+ .detach(|| runtime().block_on(self.inner.splits()))
+ .map_err(to_py_err)?;
+ crate::vector_search::search_plan_metadata_json(
+ self.inner.snapshot(),
+ self.inner.snapshot_id(),
+ splits,
+ )
+ }
+
fn snapshot_id(&self) -> Option<i64> {
self.inner.snapshot_id()
}
diff --git a/bindings/python/src/index_build.rs
b/bindings/python/src/index_build.rs
index d81e7252..ff5079c6 100644
--- a/bindings/python/src/index_build.rs
+++ b/bindings/python/src/index_build.rs
@@ -21,7 +21,7 @@ use std::collections::HashMap;
use std::sync::Arc;
use paimon::spec::Predicate;
-use paimon::table::{GlobalIndexBuildBuilder, Table};
+use paimon::table::{GlobalIndexBuildBuilder, GlobalIndexDropBuilder, Table};
use paimon_datafusion::runtime::runtime;
use pyo3::prelude::*;
use pyo3::types::PyDict;
@@ -76,6 +76,103 @@ impl PyGlobalIndexBuildBuilder {
}
}
+/// Parameter transport for metadata-only index drops; planning stays in core.
+#[pyclass(name = "GlobalIndexDropBuilder", module =
"pypaimon_rust.datafusion")]
+pub struct PyGlobalIndexDropBuilder {
+ table: Arc<Table>,
+ columns: Option<PyIndexColumns>,
+ index_type: String,
+ partition_filters: Vec<Predicate>,
+ dry_run: bool,
+}
+
+impl PyGlobalIndexDropBuilder {
+ pub(crate) fn new(table: Arc<Table>) -> Self {
+ Self {
+ table,
+ columns: None,
+ index_type: "btree".into(),
+ partition_filters: vec![],
+ dry_run: false,
+ }
+ }
+
+ fn builder(&self) -> paimon::Result<GlobalIndexDropBuilder<'_>> {
+ let mut builder = self.table.new_global_index_drop_builder();
+ match &self.columns {
+ Some(PyIndexColumns::Column(column)) => {
+ builder.with_index_column(column);
+ }
+ Some(PyIndexColumns::Columns(columns)) => {
+
builder.with_index_columns(&columns.iter().map(String::as_str).collect::<Vec<_>>());
+ }
+ None => {}
+ }
+ builder
+ .with_index_type(&self.index_type)
+ .with_dry_run(self.dry_run);
+ for filter in &self.partition_filters {
+ builder.with_partition_filter(filter.clone())?;
+ }
+ Ok(builder)
+ }
+}
+
+#[pymethods]
+impl PyGlobalIndexDropBuilder {
+ #[staticmethod]
+ fn supports_index_type(index_type: &str) -> bool {
+ GlobalIndexDropBuilder::supports_index_type(index_type)
+ }
+
+ fn with_index_columns(mut slf: PyRefMut<'_, Self>, columns: Vec<String>)
-> PyRefMut<'_, Self> {
+ slf.columns = Some(PyIndexColumns::Columns(columns));
+ slf
+ }
+
+ fn with_index_column(mut slf: PyRefMut<'_, Self>, column: String) ->
PyRefMut<'_, Self> {
+ slf.columns = Some(PyIndexColumns::Column(column));
+ slf
+ }
+
+ fn with_index_type(mut slf: PyRefMut<'_, Self>, index_type: String) ->
PyRefMut<'_, Self> {
+ slf.index_type = index_type;
+ slf
+ }
+
+ fn with_dry_run(mut slf: PyRefMut<'_, Self>, dry_run: bool) ->
PyRefMut<'_, Self> {
+ slf.dry_run = dry_run;
+ slf
+ }
+
+ fn with_partition_filter<'py>(
+ mut slf: PyRefMut<'py, Self>,
+ predicate: &Bound<'_, PyDict>,
+ ) -> PyResult<PyRefMut<'py, Self>> {
+ let filter = dict_to_table_predicate(predicate, slf.table.schema(),
true)?;
+ let mut builder = slf.builder().map_err(to_py_err)?;
+ builder
+ .with_partition_filter(filter.clone())
+ .map_err(to_py_err)?;
+ slf.partition_filters.push(filter);
+ Ok(slf)
+ }
+
+ fn build(&self, py: Python<'_>) -> PyResult<Vec<PyCommitMessage>> {
+ let builder = self.builder().map_err(to_py_err)?;
+ let messages = py
+ .detach(|| runtime().block_on(builder.build()))
+ .map_err(to_py_err)?;
+ Ok(messages.into_iter().map(PyCommitMessage::new).collect())
+ }
+
+ fn execute(&self, py: Python<'_>) -> PyResult<usize> {
+ let builder = self.builder().map_err(to_py_err)?;
+ py.detach(|| runtime().block_on(builder.execute()))
+ .map_err(to_py_err)
+ }
+}
+
#[pymethods]
impl PyGlobalIndexBuildBuilder {
#[staticmethod]
diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs
index 4ae51342..9b92ae83 100644
--- a/bindings/python/src/lib.rs
+++ b/bindings/python/src/lib.rs
@@ -38,6 +38,7 @@ mod udf;
mod update_assignment;
mod variant;
mod vector_search;
+mod vindex;
mod write;
// ---- #285: observability ----
mod partition;
diff --git a/bindings/python/src/table.rs b/bindings/python/src/table.rs
index 6eb49675..14e4298f 100644
--- a/bindings/python/src/table.rs
+++ b/bindings/python/src/table.rs
@@ -230,6 +230,10 @@ impl PyTable {
crate::index_build::PyGlobalIndexBuildBuilder::new(Arc::clone(&self.inner))
}
+ fn new_global_index_drop_builder(&self) ->
crate::index_build::PyGlobalIndexDropBuilder {
+
crate::index_build::PyGlobalIndexDropBuilder::new(Arc::clone(&self.inner))
+ }
+
fn new_batch_write_builder(&self) -> PyBatchWriteBuilder {
PyBatchWriteBuilder::new(Arc::clone(&self.inner))
}
diff --git a/bindings/python/src/vector_search.rs
b/bindings/python/src/vector_search.rs
index 4c1173f2..8b7d4290 100644
--- a/bindings/python/src/vector_search.rs
+++ b/bindings/python/src/vector_search.rs
@@ -28,6 +28,39 @@ use crate::error::to_py_err;
use crate::global_index_result::PyGlobalIndexResult;
use crate::predicate::dict_to_table_predicate;
+pub(crate) fn search_plan_metadata_json(
+ snapshot: Option<&paimon::spec::Snapshot>,
+ snapshot_id: Option<i64>,
+ splits: Vec<paimon::table::SearchScanSplit>,
+) -> PyResult<String> {
+ use paimon::table::SearchScanSplit;
+ use serde_json::json;
+ let files = |files: &[paimon::spec::IndexFileMeta]| {
+ files
+ .iter()
+ .map(|file| file.to_serialized_row_data().map_err(to_py_err))
+ .collect::<PyResult<Vec<_>>>()
+ };
+ let splits = splits.into_iter().map(|split| Ok(match split {
+ SearchScanSplit::Index { column, row_range, index_files,
scalar_index_files } => json!({
+ "kind": "index", "column": column, "row_range": row_range,
+ "index_files": files(&index_files)?, "scalar_index_files":
files(&scalar_index_files)?,
+ }),
+ SearchScanSplit::Raw { row_ranges, scalar_index_files, index_type } =>
json!({
+ "kind": "raw", "row_ranges": row_ranges, "scalar_index_files":
files(&scalar_index_files)?, "index_type": index_type,
+ }),
+ SearchScanSplit::PrimaryKey { data_split, payloads,
uncovered_data_files, row_ranges_by_file } => json!({
+ "kind": "primary-key", "data_split":
data_split.serialize_split_v1().map_err(to_py_err)?,
+ "payloads": files(&payloads)?, "uncovered_data_files":
uncovered_data_files,
+ "row_ranges_by_file": row_ranges_by_file,
+ }),
+ })).collect::<PyResult<Vec<_>>>()?;
+ serde_json::to_string(
+ &json!({"snapshot": snapshot, "snapshot_id": snapshot_id, "splits":
splits}),
+ )
+ .map_err(|error|
pyo3::exceptions::PyValueError::new_err(error.to_string()))
+}
+
#[derive(Clone, Default)]
struct SearchConfig {
column: Option<String>,
@@ -155,7 +188,15 @@ impl PyVectorSearchBuilder {
.map_err(to_py_err)?
.new_scan()
.map_err(to_py_err)?;
- Ok(PyVectorScan { inner })
+ Ok(PyVectorScan {
+ inner,
+ options: self
+ .config
+ .options
+ .iter()
+ .flat_map(|options| options.clone())
+ .collect(),
+ })
}
fn new_vector_search_read(&self) -> PyResult<PyVectorRead> {
let mut builder = self.config.single(&self.table).map_err(to_py_err)?;
@@ -250,7 +291,15 @@ impl PyBatchVectorSearchBuilder {
.map_err(to_py_err)?
.new_scan()
.map_err(to_py_err)?;
- Ok(PyVectorScan { inner })
+ Ok(PyVectorScan {
+ inner,
+ options: self
+ .config
+ .options
+ .iter()
+ .flat_map(|options| options.clone())
+ .collect(),
+ })
}
fn new_batch_vector_search_read(&self) -> PyResult<PyBatchVectorRead> {
let mut builder = self.config.batch(&self.table).map_err(to_py_err)?;
@@ -275,6 +324,7 @@ impl PyBatchVectorSearchBuilder {
#[pyclass(name = "VectorScan", module = "pypaimon_rust.datafusion")]
pub struct PyVectorScan {
inner: VectorScan,
+ options: HashMap<String, String>,
}
#[pymethods]
@@ -283,17 +333,28 @@ impl PyVectorScan {
let inner = py
.detach(|| runtime().block_on(self.inner.plan()))
.map_err(to_py_err)?;
- Ok(PyVectorScanPlan { inner })
+ Ok(PyVectorScanPlan {
+ inner,
+ options: self.options.clone(),
+ })
}
}
#[pyclass(name = "VectorScanPlan", module = "pypaimon_rust.datafusion")]
pub struct PyVectorScanPlan {
inner: VectorScanPlan,
+ options: HashMap<String, String>,
}
#[pymethods]
impl PyVectorScanPlan {
+ fn _metadata(&self, py: Python<'_>) -> PyResult<String> {
+ let splits = py
+ .detach(|| runtime().block_on(self.inner.splits(&self.options)))
+ .map_err(to_py_err)?;
+ search_plan_metadata_json(self.inner.snapshot(),
self.inner.snapshot_id(), splits)
+ }
+
fn snapshot_id(&self) -> Option<i64> {
self.inner.snapshot_id()
}
diff --git a/bindings/python/src/vindex.rs b/bindings/python/src/vindex.rs
new file mode 100644
index 00000000..58cd9d2a
--- /dev/null
+++ b/bindings/python/src/vindex.rs
@@ -0,0 +1,197 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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.
+
+//! Python positional I/O over the reusable Rust core vector-index reader.
+
+use crate::blob_uri_reader::{callback_error, to_callback_error};
+use crate::error::to_py_err;
+use paimon::vector_search::{GlobalIndexIOMeta, VectorSearch};
+use paimon::vindex::reader::VindexVectorGlobalIndexReader;
+use paimon_vindex_core::io::{ReadRequest, SeekRead};
+use pyo3::exceptions::{PyRuntimeError, PyTypeError, PyValueError};
+use pyo3::prelude::*;
+use roaring::RoaringTreemap;
+use std::collections::HashMap;
+use std::io::{self, Cursor};
+use std::sync::Mutex;
+
+struct PythonIndexInput(Py<PyAny>);
+
+impl SeekRead for PythonIndexInput {
+ fn pread(&mut self, ranges: &mut [ReadRequest<'_>]) -> io::Result<()> {
+ Python::attach(|py| {
+ let requests = ranges
+ .iter()
+ .map(|range| (range.pos, range.buf.len()))
+ .collect::<Vec<_>>();
+ let chunks = self
+ .0
+ .bind(py)
+ .call_method1("pread_many", (requests,))?
+ .extract::<Vec<Vec<u8>>>()?;
+ if chunks.len() != ranges.len() {
+ return Err(PyValueError::new_err(
+ "Index input returned a different number of ranges",
+ ));
+ }
+ for (chunk, range) in chunks.iter().zip(ranges) {
+ if chunk.len() != range.buf.len() {
+ return Err(PyValueError::new_err(format!(
+ "Index input short read at {}: expected {} bytes, got
{}",
+ range.pos,
+ range.buf.len(),
+ chunk.len()
+ )));
+ }
+ range.buf.copy_from_slice(chunk);
+ }
+ Ok(())
+ })
+ .map_err(|error| io::Error::other(callback_error(error)))
+ }
+}
+
+/// The input only supplies positional I/O; core owns query and score
semantics.
+#[pyclass(
+ name = "VindexVectorGlobalIndexReader",
+ module = "pypaimon_rust.datafusion"
+)]
+pub struct PyVindexVectorGlobalIndexReader {
+ reader: Mutex<Option<VindexVectorGlobalIndexReader>>,
+ metric: String,
+ process_id: u32,
+}
+
+impl PyVindexVectorGlobalIndexReader {
+ fn ensure_process(&self) -> PyResult<()> {
+ if self.process_id != std::process::id() {
+ return Err(to_py_err(paimon::Error::ProcessForkUnsupported {
+ message: "An inherited vector-index reader cannot be used
after fork; reopen it in the child process".into(),
+ }));
+ }
+ Ok(())
+ }
+}
+
+#[pymethods]
+impl PyVindexVectorGlobalIndexReader {
+ #[new]
+ #[pyo3(signature = (input, file_path, file_size, options=None))]
+ fn new(
+ py: Python<'_>,
+ input: Py<PyAny>,
+ file_path: String,
+ file_size: u64,
+ options: Option<HashMap<String, String>>,
+ ) -> PyResult<Self> {
+ if !input.bind(py).getattr("pread_many")?.is_callable() {
+ return Err(PyTypeError::new_err(
+ "Index input must provide callable pread_many",
+ ));
+ }
+ let mut reader = VindexVectorGlobalIndexReader::new(
+ GlobalIndexIOMeta::new(file_path, file_size, Vec::new()),
+ options.unwrap_or_default(),
+ );
+ py.detach(|| reader.load(|_| Ok(PythonIndexInput(input))))
+ .map_err(to_callback_error)?;
+ let metric =
reader.metadata().map_err(to_py_err)?.metric.as_str().into();
+ Ok(Self {
+ reader: Mutex::new(Some(reader)),
+ metric,
+ process_id: std::process::id(),
+ })
+ }
+
+ /// Returns the native index metric, including before the first query.
+ fn metric(&self, py: Python<'_>) -> PyResult<String> {
+ self.ensure_process()?;
+ py.detach(|| {
+ let reader = self
+ .reader
+ .lock()
+ .map_err(|_| PyRuntimeError::new_err("Vector-index reader lock
was poisoned"))?;
+ if reader.is_none() {
+ return Err(PyValueError::new_err("Vector-index reader is
closed"));
+ }
+ Ok(self.metric.clone())
+ })
+ }
+
+ /// Each query returns its actual hits, which may be fewer than the limit.
+ #[pyo3(signature = (vectors, limit, filter_bytes=None, options=None))]
+ fn search(
+ &self,
+ py: Python<'_>,
+ vectors: Vec<Vec<f32>>,
+ limit: usize,
+ filter_bytes: Option<Vec<u8>>,
+ options: Option<HashMap<String, String>>,
+ ) -> PyResult<Vec<Option<HashMap<u64, f32>>>> {
+ self.ensure_process()?;
+ let filter = filter_bytes
+ .map(|bytes| {
+ let mut input = Cursor::new(&bytes);
+ let filter = RoaringTreemap::deserialize_from(&mut input)
+ .map_err(|error|
PyValueError::new_err(error.to_string()))?;
+ if input.position() != bytes.len() as u64 {
+ return Err(PyValueError::new_err(
+ "Trailing bytes after vector-index row-ID filter",
+ ));
+ }
+ Ok(filter)
+ })
+ .transpose()?;
+ let options = options.unwrap_or_default();
+ let searches = vectors
+ .into_iter()
+ .map(|vector| {
+ let mut search = VectorSearch::new(vector, limit,
"vector".into())?
+ .with_options(options.clone());
+ if let Some(filter) = &filter {
+ search = search.with_include_row_ids(filter.clone());
+ }
+ Ok(search)
+ })
+ .collect::<paimon::Result<Vec<_>>>()
+ .map_err(to_py_err)?;
+ py.detach(|| {
+ let mut reader = self
+ .reader
+ .lock()
+ .map_err(|_| PyRuntimeError::new_err("Vector-index reader lock
was poisoned"))?;
+ reader
+ .as_mut()
+ .ok_or_else(|| PyValueError::new_err("Vector-index reader is
closed"))?
+ .visit_batch_vector_search::<PythonIndexInput>(&searches, |_| {
+ unreachable!("the index was opened by the constructor")
+ })
+ .map_err(to_callback_error)
+ })
+ }
+
+ fn close(&self, py: Python<'_>) -> PyResult<()> {
+ self.ensure_process()?;
+ py.detach(|| {
+ self.reader
+ .lock()
+ .map_err(|_| PyRuntimeError::new_err("Vector-index reader lock
was poisoned"))?
+ .take();
+ Ok(())
+ })
+ }
+}
diff --git a/crates/paimon/src/table/de_vector_read.rs
b/crates/paimon/src/table/de_vector_read.rs
index 6de0f5f3..a96f2f8b 100644
--- a/crates/paimon/src/table/de_vector_read.rs
+++ b/crates/paimon/src/table/de_vector_read.rs
@@ -36,8 +36,9 @@ use
crate::table::pk_vector_position_read::SEARCH_SCORE_COLUMN;
use crate::table::row_id_predicate::intersect_sorted_ranges;
use crate::table::vector_read::Read;
use crate::table::vector_search_common::{
- configured_refine_factor, indexed_search_limit, log_vindex_range_io_stats,
normalize_metric,
- resolve_materialize_read_type, vindex_concurrency_limits,
VectorIndexBackend,
+ configured_refine_factor, configured_vector_index_type,
indexed_search_limit,
+ log_vindex_range_io_stats, normalize_metric, resolve_materialize_read_type,
+ validate_global_vector_index_types, vindex_concurrency_limits,
VectorIndexBackend,
};
use crate::table::{
find_field_id_by_name, merge_row_ranges, ArrowRecordBatchStream, RowRange,
Table,
@@ -312,14 +313,14 @@ async fn execute_vindex_searches<S: SeekRead + 'static,
G: Send + 'static>(
}
#[derive(Clone, Copy)]
-struct VectorSearchEvaluation<'a> {
- table: Option<&'a Table>,
- file_io: &'a FileIO,
- table_path: &'a str,
- table_options: &'a HashMap<String, String>,
- schema_fields: &'a [DataField],
- next_row_id: Option<i64>,
- filter: Option<&'a Predicate>,
+pub(super) struct VectorSearchEvaluation<'a> {
+ pub(super) table: Option<&'a Table>,
+ pub(super) file_io: &'a FileIO,
+ pub(super) table_path: &'a str,
+ pub(super) table_options: &'a HashMap<String, String>,
+ pub(super) schema_fields: &'a [DataField],
+ pub(super) next_row_id: Option<i64>,
+ pub(super) filter: Option<&'a Predicate>,
}
#[derive(Clone)]
@@ -506,6 +507,7 @@ async fn evaluate_batch_vector_search(
Some(id) => id,
None => return Ok(vec![ScoredRowIds::empty(); vector_searches.len()]),
};
+ validate_global_vector_index_types(index_entries, field_id, field_name)?;
let vector_entries: Vec<_> = index_entries
.iter()
@@ -636,9 +638,15 @@ async fn evaluate_batch_vector_search(
.map(|entry| (entry, None))
.collect::<Vec<_>>()
};
+ let configured_index_type =
+ configured_vector_index_type(evaluation.table_options,
&search_options, field_name);
+ let index_type = vector_entries
+ .first()
+ .map(|entry| entry.index_file.index_type.as_str())
+ .or(configured_index_type.as_deref());
let metric_check = VectorMetricCheck {
persisted: Arc::new(std::sync::Mutex::new(None)),
- requested: configured_raw_vector_metric_override(&search_options,
field_name)?,
+ requested: vector_metric_override(&search_options, field_name,
index_type)?,
};
let mut permit_wait = Duration::ZERO;
let mut file_reader_open = Duration::ZERO;
@@ -1067,7 +1075,7 @@ async fn evaluate_batch_vector_search(
/// Predicate coverage requires every referenced field to be covered by a
/// single-field scalar index. Reuse the table scanner's coverage accounting.
-fn scalar_unindexed_ranges(
+pub(super) fn scalar_unindexed_ranges(
evaluation: VectorSearchEvaluation<'_>,
entries: &[IndexManifestEntry],
filter: &Predicate,
@@ -1436,7 +1444,7 @@ fn localize_shared_include_row_ids(
.collect())
}
-async fn detail_data_ranges_for_table(table: &Table) ->
crate::Result<Vec<RowRange>> {
+pub(super) async fn detail_data_ranges_for_table(table: &Table) ->
crate::Result<Vec<RowRange>> {
let plan = table
.new_read_builder()
.new_scan()
@@ -1504,9 +1512,23 @@ async fn resolve_raw_vector_metric(
field_id: i32,
field_name: &str,
) -> crate::Result<RawVectorMetric> {
+ let configured_type = configured_vector_index_type(table_options,
search_options, field_name);
+ let index_type = index_entries
+ .iter()
+ .filter(|entry| entry.kind == FileKind::Add)
+ .find(|entry| {
+
VectorIndexBackend::from_index_type(&entry.index_file.index_type).is_some()
+ && entry
+ .index_file
+ .global_index_meta
+ .as_ref()
+ .is_some_and(|meta| meta.index_field_id == field_id)
+ })
+ .map(|entry| entry.index_file.index_type.as_str())
+ .or(configured_type.as_deref());
let check = VectorMetricCheck {
persisted: Arc::new(std::sync::Mutex::new(None)),
- requested: configured_raw_vector_metric_override(search_options,
field_name)?,
+ requested: vector_metric_override(search_options, field_name,
index_type)?,
};
let mut persisted = None;
for entry in index_entries {
@@ -1530,9 +1552,18 @@ async fn resolve_raw_vector_metric(
if let Some(metric) = check.requested {
return Ok(metric);
}
+ let table_metric = vector_metric_override(table_options, field_name,
index_type)?;
+ if let Some(metric) = table_metric {
+ return Ok(metric);
+ }
+ // Before files exist, Java creates the configured indexer for raw search.
+ // NativeVectorGlobalIndexer and LuminaVectorIndexOptions both default to
IP.
Ok(
- configured_raw_vector_metric_override(table_options, field_name)?
- .unwrap_or(RawVectorMetric::L2),
+ if index_type.is_some_and(|kind|
VectorIndexBackend::from_index_type(kind).is_some()) {
+ RawVectorMetric::InnerProduct
+ } else {
+ RawVectorMetric::L2
+ },
)
}
@@ -1607,24 +1638,27 @@ async fn read_index_vector_metric(
Ok(None)
}
-fn configured_raw_vector_metric_override(
+fn vector_metric_override(
options: &HashMap<String, String>,
field_name: &str,
+ index_type: Option<&str>,
) -> crate::Result<Option<RawVectorMetric>> {
- let direct_keys = [
- format!("fields.{field_name}.distance.metric"),
- format!("fields.{field_name}.metric"),
- "test.vector.metric".to_string(),
- "lumina.distance.metric".to_string(),
- "distance.metric".to_string(),
- "metric".to_string(),
- ];
- for key in direct_keys {
- if let Some(value) = options.get(&key) {
- return RawVectorMetric::parse(value).map(Some);
- }
+ // A known index family determines its own defaults and option namespace.
+ // Only a search without a family may infer a metric from suffixes.
+ if index_type.is_some() {
+ direct_raw_vector_metric_override(options, field_name, index_type)
+ } else {
+ configured_raw_vector_metric_override(options, field_name)
}
+}
+fn configured_raw_vector_metric_override(
+ options: &HashMap<String, String>,
+ field_name: &str,
+) -> crate::Result<Option<RawVectorMetric>> {
+ if let Some(metric) = direct_raw_vector_metric_override(options,
field_name, None)? {
+ return Ok(Some(metric));
+ }
let mut inferred = None;
for (key, value) in options {
if !(key.ends_with(".distance.metric") || key.ends_with(".metric")) {
@@ -1645,6 +1679,34 @@ fn configured_raw_vector_metric_override(
Ok(inferred)
}
+fn direct_raw_vector_metric_override(
+ options: &HashMap<String, String>,
+ field_name: &str,
+ index_type: Option<&str>,
+) -> crate::Result<Option<RawVectorMetric>> {
+ let mut direct_keys = vec![
+ format!("fields.{field_name}.distance.metric"),
+ format!("fields.{field_name}.metric"),
+ ];
+ if let Some(index_type) = index_type {
+ direct_keys.push(format!("{index_type}.distance.metric"));
+ direct_keys.push(format!("{index_type}.metric"));
+ }
+ direct_keys.extend([
+ "test.vector.metric".to_string(),
+ "lumina.distance.metric".to_string(),
+ "distance.metric".to_string(),
+ "metric".to_string(),
+ ]);
+ for key in direct_keys {
+ if let Some(value) = options.get(&key) {
+ return RawVectorMetric::parse(value).map(Some);
+ }
+ }
+
+ Ok(None)
+}
+
#[derive(Default)]
struct RawVectorReadTiming {
plan: Duration,
diff --git a/crates/paimon/src/table/de_vector_read/tests.rs
b/crates/paimon/src/table/de_vector_read/tests.rs
index 293499f7..a2718e81 100644
--- a/crates/paimon/src/table/de_vector_read/tests.rs
+++ b/crates/paimon/src/table/de_vector_read/tests.rs
@@ -446,6 +446,151 @@ fn
test_configured_raw_vector_metric_precedence_and_conflict_default() {
);
}
+#[tokio::test]
+async fn raw_search_without_files_uses_the_configured_indexer_default() {
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ for (index_type, expected) in [
+ (" IVF-FLAT ", RawVectorMetric::InnerProduct),
+ ("diskann", RawVectorMetric::InnerProduct),
+ ("lumina", RawVectorMetric::InnerProduct),
+ ("lumina-vector-ann", RawVectorMetric::InnerProduct),
+ ] {
+ let options = HashMap::from([(
+ "fields.embedding.index-type".to_string(),
+ index_type.to_string(),
+ )]);
+ assert_eq!(
+ resolve_raw_vector_metric(
+ &file_io,
+ "memory:///test_table",
+ &options,
+ &HashMap::new(),
+ &[],
+ 2,
+ "embedding",
+ )
+ .await
+ .unwrap(),
+ expected
+ );
+ let override_options = HashMap::from([("metric".to_string(),
"cosine".to_string())]);
+ assert_eq!(
+ resolve_raw_vector_metric(
+ &file_io,
+ "memory:///test_table",
+ &options,
+ &override_options,
+ &[],
+ 2,
+ "embedding",
+ )
+ .await
+ .unwrap(),
+ RawVectorMetric::Cosine
+ );
+ }
+ let mut table_options = HashMap::from([
+ (
+ "fields.embedding.index-type".to_string(),
+ "ivf-flat".to_string(),
+ ),
+ ("fields.other.distance.metric".to_string(), "l2".to_string()),
+ ]);
+ for (family_metric, expected) in [
+ (None, RawVectorMetric::InnerProduct),
+ (Some("cosine"), RawVectorMetric::Cosine),
+ ] {
+ if let Some(metric) = family_metric {
+ table_options.insert("ivf-flat.distance.metric".to_string(),
metric.to_string());
+ }
+ for query_options in [
+ HashMap::new(),
+ HashMap::from([("fields.other.metric".to_string(),
"l2".to_string())]),
+ ] {
+ assert_eq!(
+ resolve_raw_vector_metric(
+ &file_io,
+ "memory:///test_table",
+ &table_options,
+ &query_options,
+ &[],
+ 2,
+ "embedding",
+ )
+ .await
+ .unwrap(),
+ expected
+ );
+ }
+ }
+}
+
+#[tokio::test]
+async fn mixed_vector_formats_fail_before_opening_files() {
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let fields = vec![make_field(2, "embedding")];
+ let entries = [
+ make_lumina_entry("missing-flat", IVF_FLAT_IDENTIFIER, FileKind::Add,
2),
+ make_lumina_entry("missing-diskann", "diskann", FileKind::Add, 2),
+ ];
+ let query = VectorSearch::new(vec![1.0, 0.0], 1,
"embedding".to_string()).unwrap();
+ let error = evaluate_vector_search(
+ eval_context(&file_io, &HashMap::new(), &fields, Some(10)),
+ &entries,
+ &query,
+ )
+ .await
+ .unwrap_err();
+ assert!(error.to_string().contains("multiple index types"));
+ let mut ignored = entries[1].clone();
+ ignored.kind = FileKind::Delete;
+ assert!(
+ validate_global_vector_index_types(&[entries[0].clone(), ignored], 2,
"embedding").is_ok()
+ );
+ assert!(validate_global_vector_index_types(&entries, 99,
"another_column").is_ok());
+}
+
+#[tokio::test]
+async fn indexed_vector_metric_ignores_unrelated_column_options() {
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let index = build_vindex_segment_bytes("inner_product");
+ file_io
+ .new_output("memory:///test_table/index/test.idx")
+ .unwrap()
+ .write(bytes::Bytes::from(index.clone()))
+ .await
+ .unwrap();
+ let mut entry = make_lumina_entry("test.idx", IVF_FLAT_IDENTIFIER,
FileKind::Add, 2);
+ entry.index_file.file_size = index.len() as i64;
+ let fields = vec![make_field(2, "embedding")];
+ let options = HashMap::from([("fields.other.metric".to_string(),
"l2".to_string())]);
+ let query = VectorSearch::new(vec![1.0, 0.0], 2, "embedding".to_string())
+ .unwrap()
+ .with_options(options.clone());
+ let results = evaluate_batch_vector_search(
+ eval_context(&file_io, &HashMap::new(), &fields, Some(10)),
+ std::slice::from_ref(&entry),
+ &[query],
+ )
+ .await
+ .unwrap();
+ assert_eq!(results[0].len(), 2);
+ assert_eq!(
+ resolve_raw_vector_metric(
+ &file_io,
+ "memory:///test_table",
+ &HashMap::new(),
+ &options,
+ &[entry],
+ 2,
+ "embedding",
+ )
+ .await
+ .unwrap(),
+ RawVectorMetric::InnerProduct
+ );
+}
+
#[tokio::test]
async fn test_resolve_raw_vector_metric_uses_vindex_manifest_metadata() {
let file_io = FileIOBuilder::new("memory").build().unwrap();
diff --git a/crates/paimon/src/table/full_text_index_build_builder.rs
b/crates/paimon/src/table/full_text_index_build_builder.rs
index 49802a1b..39fb547f 100644
--- a/crates/paimon/src/table/full_text_index_build_builder.rs
+++ b/crates/paimon/src/table/full_text_index_build_builder.rs
@@ -421,31 +421,7 @@ impl<'a> TextColumn<'a> {
}
fn validate_table_options(table: &Table, core_options: &CoreOptions) ->
Result<()> {
- if !table.schema().primary_keys().is_empty() {
- return Err(Error::Unsupported {
- message: "Full-text index build does not support primary-key
tables; \
- use 'pk-full-text.index.columns' instead"
- .to_string(),
- });
- }
- if !core_options.row_tracking_enabled() {
- return Err(Error::DataInvalid {
- message: "Full-text index build requires 'row-tracking.enabled' =
'true'".to_string(),
- source: None,
- });
- }
- if !core_options.data_evolution_enabled() {
- return Err(Error::DataInvalid {
- message: "Full-text index build requires 'data-evolution.enabled'
= 'true'".to_string(),
- source: None,
- });
- }
- if !core_options.global_index_enabled() {
- return Err(Error::DataInvalid {
- message: "Full-text index build requires 'global-index.enabled' =
'true'".to_string(),
- source: None,
- });
- }
+ super::global_index_build_common::validate_build_table(table)?;
if core_options.deletion_vectors_enabled() {
return Err(Error::Unsupported {
message:
diff --git a/crates/paimon/src/table/full_text_search_builder/scan.rs
b/crates/paimon/src/table/full_text_search_builder/scan.rs
index 96e49471..3f7926b4 100644
--- a/crates/paimon/src/table/full_text_search_builder/scan.rs
+++ b/crates/paimon/src/table/full_text_search_builder/scan.rs
@@ -95,6 +95,36 @@ pub struct FullTextScanPlan {
}
impl FullTextScanPlan {
+ /// The snapshot captured by the scan; accessing it never resolves latest.
+ pub fn snapshot(&self) -> Option<&crate::spec::Snapshot> {
+ self.table.travel_snapshot()
+ }
+
+ /// Source metadata for inspection or transport to another search reader.
+ pub async fn splits(&self) ->
crate::Result<Vec<crate::table::SearchScanSplit>> {
+ if let Some(plan) = &self.primary_key_plan {
+ return Ok(plan
+ .splits
+ .iter()
+ .map(|split| crate::table::SearchScanSplit::PrimaryKey {
+ data_split: split.data_split.clone(),
+ payloads: split.current_payloads.clone(),
+ uncovered_data_files: split.uncovered_data_files.clone(),
+ row_ranges_by_file: Default::default(),
+ })
+ .collect());
+ }
+ super::super::search_scan_split::global_search_splits(
+ &self.table,
+ &self.entries,
+ &self.context.column,
+ self.context.filter.as_ref(),
+ &Default::default(),
+ true,
+ )
+ .await
+ }
+
pub fn snapshot_id(&self) -> Option<i64> {
self.snapshot_id
}
diff --git a/crates/paimon/src/table/global_index_build_builder.rs
b/crates/paimon/src/table/global_index_build_builder.rs
index a16e6a14..cb203619 100644
--- a/crates/paimon/src/table/global_index_build_builder.rs
+++ b/crates/paimon/src/table/global_index_build_builder.rs
@@ -38,6 +38,10 @@ use crate::{Error, Result};
/// Build global indexes by type, like Java's `GlobalIndexer.create`.
/// Sorting and native writer selection are internal implementation details.
+/// Global builds require a row-tracked unaware-bucket table. Data evolution
+/// and the query-side global-index optimization may both be disabled.
+/// Scalar and vindex builds include physical rows deleted by deletion vectors;
+/// queries filter those rows using the snapshot selected for the query.
pub struct GlobalIndexBuildBuilder<'a> {
table: &'a Table,
columns: Option<IndexColumns>,
@@ -299,6 +303,90 @@ mod tests {
.is_none());
}
+ fn with_table_options(table: &Table, options: HashMap<String, String>) ->
Table {
+ Table::new(
+ table.file_io().clone(),
+ table.identifier().clone(),
+ table.location().to_string(),
+ table.schema().copy_with_replaced_options(options),
+ None,
+ )
+ }
+
+ #[tokio::test]
+ async fn
build_eligibility_uses_table_row_tracking_not_read_optimizations() {
+ for (evolution, enabled) in [(false, true), (true, false), (false,
false)] {
+ let original = empty_table();
+ let mut options = original.schema().options().clone();
+ options.insert("data-evolution.enabled".into(),
evolution.to_string());
+ options.insert("global-index.enabled".into(), enabled.to_string());
+ let table = with_table_options(&original, options);
+ for (kind, column) in [
+ ("btree", "id"),
+ ("bitmap", "id"),
+ ("full-text", "text"),
+ ("ivf-flat", "vector"),
+ ("ivf-pq", "vector"),
+ ("ivf-sq", "vector"),
+ ("ivf-rq", "vector"),
+ ("diskann", "vector"),
+ ] {
+ if !GlobalIndexBuildBuilder::supports_index_type(kind) {
+ continue;
+ }
+ let mut build_options =
HashMap::from([(format!("{kind}.dimension"), "2".into())]);
+ if kind == "ivf-pq" {
+ build_options.insert("ivf-pq.pq.m".into(), "1".into());
+ }
+ if ["btree", "bitmap", "full-text"].contains(&kind) {
+ // Procedure options tune index generation, not table
identity.
+ build_options.insert("row-tracking.enabled".into(),
"false".into());
+ build_options.insert("bucket".into(), "5".into());
+ }
+ let mut builder = table.new_global_index_build_builder();
+ builder
+ .with_index_column(column)
+ .with_index_type(kind)
+ .with_options(build_options);
+ assert!(builder.build().await.unwrap().is_empty(), "{kind}");
+ }
+ }
+ }
+
+ #[tokio::test]
+ async fn build_options_cannot_enable_row_tracking_or_change_table_bucket()
{
+ for kind in ["btree", "bitmap", "full-text", "ivf-flat"] {
+ if !GlobalIndexBuildBuilder::supports_index_type(kind) {
+ continue;
+ }
+ for (key, value, error) in [
+ ("row-tracking.enabled", "false", "row-tracking.enabled"),
+ ("bucket", "4", "unaware-bucket"),
+ ] {
+ let original = empty_table();
+ let mut options = original.schema().options().clone();
+ options.insert("data-evolution.enabled".into(),
"false".into());
+ options.insert(key.into(), value.into());
+ let table = with_table_options(&original, options);
+ let result = table
+ .new_global_index_build_builder()
+ .with_index_type(kind)
+ .with_index_column(if kind == "ivf-flat" { "vector" } else
{ "text" })
+ .with_options(HashMap::from([
+ ("row-tracking.enabled".into(), "true".into()),
+ ("bucket".into(), "-1".into()),
+ ("ivf-flat.dimension".into(), "2".into()),
+ ]))
+ .build()
+ .await;
+ assert!(
+ result.unwrap_err().to_string().contains(error),
+ "{kind}: {error}"
+ );
+ }
+ }
+ }
+
#[tokio::test]
async fn column_validation_precedes_any_index_io() {
let table = empty_table();
diff --git a/crates/paimon/src/table/global_index_build_common.rs
b/crates/paimon/src/table/global_index_build_common.rs
index 8466624e..be66da97 100644
--- a/crates/paimon/src/table/global_index_build_common.rs
+++ b/crates/paimon/src/table/global_index_build_common.rs
@@ -38,6 +38,33 @@ pub(crate) mod vector;
const COPY_BUFFER_SIZE: usize = 1024 * 1024;
+/// Validate the table that owns global row IDs. Procedure options only tune
+/// index generation; they cannot enable row tracking or change bucket layout.
+/// Data evolution and `global-index.enabled` govern reads, not index creation.
+pub(crate) fn validate_build_table(table: &Table) -> Result<()> {
+ let options = table.schema().core_options();
+ if !table.schema().primary_keys().is_empty() {
+ return Err(Error::Unsupported {
+ message: "Global index build does not support primary-key tables;
use primary-key file indexes instead".into(),
+ });
+ }
+ if !options.row_tracking_enabled() {
+ return Err(Error::DataInvalid {
+ message: "Global index build requires 'row-tracking.enabled' =
'true'".into(),
+ source: None,
+ });
+ }
+ if options.bucket() != -1 {
+ return Err(Error::Unsupported {
+ message: format!(
+ "Global index build only supports unaware-bucket tables
(bucket = -1), got {}",
+ options.bucket()
+ ),
+ });
+ }
+ Ok(())
+}
+
/// Stream a locally built index file to its table location. Native index
/// builders (Lumina, full-text) serialize to a local temporary file first
/// because their writers need synchronous I/O.
diff --git a/crates/paimon/src/table/global_index_drop_builder.rs
b/crates/paimon/src/table/global_index_drop_builder.rs
index d0bb2bf5..ddd7ca4e 100644
--- a/crates/paimon/src/table/global_index_drop_builder.rs
+++ b/crates/paimon/src/table/global_index_drop_builder.rs
@@ -46,6 +46,11 @@ impl<'a> GlobalIndexDropBuilder<'a> {
}
}
+ /// Whether this index identity can be selected by this dropper.
+ pub fn supports_index_type(index_type: &str) -> bool {
+ normalize_global_index_type(index_type.trim()).is_some()
+ }
+
pub fn with_index_column(&mut self, column: &str) -> &mut Self {
self.index_column = Some(IndexColumns::Column(column.to_string()));
self
@@ -80,19 +85,49 @@ impl<'a> GlobalIndexDropBuilder<'a> {
self
}
+ /// Prepare metadata deletions without publishing a snapshot or removing
files.
+ /// The caller decides whether to commit the returned messages.
+ pub async fn build(&self) -> Result<Vec<CommitMessage>> {
+ Ok(self.prepare().await?.1)
+ }
+
pub async fn execute(&self) -> Result<usize> {
+ let (snapshot_id, messages) = self.prepare().await?;
+ let dropped = messages
+ .iter()
+ .map(|message| message.deleted_index_files.len())
+ .sum();
+ if self.dry_run || dropped == 0 {
+ return Ok(dropped);
+ }
+ TableCommit::new(
+ self.table.clone(),
+ format!(
+ "global-index-{}-drop-{}",
+ self.index_type,
+ uuid::Uuid::new_v4()
+ ),
+ )
+ .commit_if_latest_snapshot(messages, snapshot_id.expect("nonempty drop
has a snapshot"))
+ .await?;
+ // A failed commit may have been published. Never remove index files
here.
+ Ok(dropped)
+ }
+
+ async fn prepare(&self) -> Result<(Option<i64>, Vec<CommitMessage>)> {
// Dropping an index reads the index manifest.
crate::spec::CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
self.table.ensure_not_branch_reference_for_write()?;
- let index_type =
- normalize_global_index_type(&self.index_type).ok_or_else(||
Error::Unsupported {
+ let index_type =
normalize_global_index_type(self.index_type.trim()).ok_or_else(|| {
+ Error::Unsupported {
message: format!(
"unsupported global index type '{}'; supported: {}",
self.index_type, SUPPORTED_GLOBAL_INDEX_TYPES_FOR_DROP
),
- })?;
+ }
+ })?;
let index_column = self
.index_column
.as_ref()
@@ -130,10 +165,10 @@ impl<'a> GlobalIndexDropBuilder<'a> {
// through the catalog, like reads and commit validation do.
let snapshot_manager = self.table.snapshot_manager();
let Some(snapshot) = snapshot_manager.get_latest_snapshot().await?
else {
- return Ok(0);
+ return Ok((None, vec![]));
};
let Some(index_manifest_name) = snapshot.index_manifest() else {
- return Ok(0);
+ return Ok((Some(snapshot.id()), vec![]));
};
let index_entries = IndexManifest::read(
@@ -143,7 +178,6 @@ impl<'a> GlobalIndexDropBuilder<'a> {
.await?;
let mut deletions_by_partition_bucket: HashMap<(Vec<u8>, i32),
Vec<IndexFileMeta>> =
HashMap::new();
- let mut dropped = 0;
for entry in index_entries {
if entry.kind != FileKind::Add {
continue;
@@ -176,16 +210,11 @@ impl<'a> GlobalIndexDropBuilder<'a> {
continue;
}
}
- dropped += 1;
deletions_by_partition_bucket
.entry((entry.partition, entry.bucket))
.or_default()
.push(entry.index_file);
}
- if self.dry_run || dropped == 0 {
- return Ok(dropped);
- }
-
let mut groups = deletions_by_partition_bucket
.into_iter()
.collect::<Vec<_>>();
@@ -205,14 +234,7 @@ impl<'a> GlobalIndexDropBuilder<'a> {
})
.collect::<Vec<_>>();
- TableCommit::new(
- self.table.clone(),
- format!("global-index-{}-drop-{}", index_type,
uuid::Uuid::new_v4()),
- )
- .commit_if_latest_snapshot(messages, snapshot.id())
- .await?;
-
- Ok(dropped)
+ Ok((Some(snapshot.id()), messages))
}
}
@@ -581,6 +603,85 @@ mod tests {
.unwrap()
}
+ #[tokio::test]
+ async fn build_prepares_only_metadata_deletions_until_explicit_commit() {
+ for dry_run in [false, true] {
+ let table =
test_table(&format!("memory:/prepared_index_drop_{dry_run}"));
+ setup_dirs(&table).await;
+ let mut message = CommitMessage::new(vec![], 0,
vec![data_file("data.parquet")]);
+ message.new_index_files = vec![
+ global_index_file("btree", "id.index", 0, 0, 9),
+ global_index_file("bitmap", "id-bitmap.index", 0, 0, 9),
+ global_index_file("btree", "name.index", 1, 0, 9),
+ ];
+ TableCommit::new(table.clone(), "prepare-test".into())
+ .commit(vec![message])
+ .await
+ .unwrap();
+ let snapshot_id = table
+ .snapshot_manager()
+ .get_latest_snapshot_id()
+ .await
+ .unwrap();
+ let before = table_files(&table).await;
+ let mut builder = table.new_global_index_drop_builder();
+ builder.with_index_column("id").with_dry_run(dry_run);
+ let messages = builder.build().await.unwrap();
+ assert_eq!(messages.len(), 1);
+ assert!(messages[0].new_files.is_empty());
+ assert!(messages[0].new_index_files.is_empty());
+ assert_eq!(messages[0].deleted_index_files.len(), 1);
+ assert_eq!(messages[0].deleted_index_files[0].file_name,
"id.index");
+ assert_eq!(latest_index_entries(&table).await.len(), 3);
+ assert_eq!(table_files(&table).await, before);
+ assert_eq!(
+ table
+ .snapshot_manager()
+ .get_latest_snapshot_id()
+ .await
+ .unwrap(),
+ snapshot_id
+ );
+ // build() is always preparation, even on a builder configured for
+ // execute()'s dry run; only this explicit call publishes
deletions.
+ TableCommit::new(table.clone(), "explicit-drop".into())
+ .commit(messages)
+ .await
+ .unwrap();
+ let mut names = latest_index_entries(&table)
+ .await
+ .into_iter()
+ .map(|entry| entry.index_file.file_name)
+ .collect::<Vec<_>>();
+ names.sort();
+ assert_eq!(names, vec!["id-bitmap.index", "name.index"]);
+ assert!(builder.build().await.unwrap().is_empty());
+ }
+ }
+
+ #[tokio::test]
+ async fn
build_validates_empty_tables_and_authorization_without_publication() {
+ let table = test_table("memory:/prepared_index_drop_empty");
+ setup_dirs(&table).await;
+ assert!(table.new_global_index_drop_builder().build().await.is_err());
+ assert!(table
+ .new_global_index_drop_builder()
+ .with_index_column("id")
+ .build()
+ .await
+ .unwrap()
+ .is_empty());
+ assert!(crate::table::query_auth_table()
+ .new_global_index_drop_builder()
+ .with_index_column("id")
+ .build()
+ .await
+ .is_err());
+ assert!(GlobalIndexDropBuilder::supports_index_type(" bTrEe "));
+ assert!(GlobalIndexDropBuilder::supports_index_type("FULL-TEXT"));
+ assert!(!GlobalIndexDropBuilder::supports_index_type("hash"));
+ }
+
async fn table_files(table: &Table) -> BTreeMap<String, Vec<u8>> {
let mut files = BTreeMap::new();
for status in table
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index bf59ab56..0608b331 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -139,6 +139,7 @@ mod row_position_selection;
mod row_sidecar;
mod scan_trace;
pub(crate) mod schema_manager;
+mod search_scan_split;
mod shard_table_updater;
pub(crate) mod snapshot_commit;
mod snapshot_manager;
@@ -225,6 +226,7 @@ pub use read_builder::ReadBuilder;
pub use rest_env::RESTEnv;
pub use scan_trace::ScanTrace;
pub use schema_manager::SchemaManager;
+pub use search_scan_split::SearchScanSplit;
pub use shard_table_updater::ShardTableUpdater;
pub use snapshot_commit::{RESTSnapshotCommit, RenamingSnapshotCommit,
SnapshotCommit};
pub use snapshot_manager::SnapshotManager;
diff --git a/crates/paimon/src/table/pk_vector_read.rs
b/crates/paimon/src/table/pk_vector_read.rs
index 7194d35a..38210bab 100644
--- a/crates/paimon/src/table/pk_vector_read.rs
+++ b/crates/paimon/src/table/pk_vector_read.rs
@@ -417,70 +417,7 @@ async fn search_pk_raw_candidates_batch_with_plan(
// them. Partition-only predicates were already applied by the scan. The
// residual depends on the filter and plan, so its file-local ranges are
// shared by all queries. A file with an empty range list is skipped.
- let residual_by_split: Option<Vec<RowRangesByFile>> = match filter {
- Some(filter) => {
- // The whole filter is pushed into scan planning (`PkVectorScan`),
where
- // partition-only conjuncts already prune partitions/files.
Re-applying
- // them as a per-row residual would be redundant, so keep only the
data
- // conjuncts here — a partition-only filter then needs no residual
at
- // all. Mixed partition/data conjuncts stay whole in
`data_predicates`
- // and evaluate against the materialized partition column
(partition
- // columns are physically present in primary-key data files), so
there
- // is no missing-column case to reject.
- let (_partition_predicate, data_predicates) =
split_partition_and_data_predicates(
- filter.clone(),
- table.schema().fields(),
- table.schema().partition_keys(),
- );
- if data_predicates.is_empty() {
- None
- } else {
- let file_predicates = FilePredicates {
- predicates: data_predicates,
- row_filter_factory: None,
- file_fields: table.schema().fields().to_vec(),
- };
- let residual_read_type = widen_scan_fields(&[],
Some(&file_predicates));
- let residual_reader = DataFileReader::new(
- table.file_io().clone(),
- table.schema_manager().clone(),
- table.schema().id(),
- table.schema().fields().to_vec(),
- residual_read_type,
- Vec::new(),
- )
- .with_table_options(table.schema().options().clone());
- let mut per_split = Vec::with_capacity(plan.splits.len());
- for (index, split) in plan.splits.iter().enumerate() {
- // The plan's selection for this split, so the residual is
- // evaluated over the rows an engine-supplied split allows
rather
- // than over the whole file.
- let allowed_rows = plan
- .physical_row_ranges_by_split
- .as_ref()
- .and_then(|per_split| per_split.get(index));
- per_split.push(
- residual_row_ranges_by_file(
- &residual_reader,
- &split.data_split,
- &split.active_files,
- &file_predicates,
- allowed_rows,
- )
- .await?,
- );
- }
- Some(per_split)
- }
- }
- None => None,
- };
- // Preserve the external plan's ranges as well as the residual restriction.
- let row_ranges_by_split = intersect_row_ranges_by_split(
- plan.physical_row_ranges_by_split.as_deref(),
- residual_by_split,
- plan.splits.len(),
- )?;
+ let row_ranges_by_split = filtered_row_ranges(table, filter, plan).await?;
// Build the exact-fallback search on demand: the kernel calls this only
for a
// file it actually searches (uncovered by ANN, residual-allowed, and only
when
@@ -634,6 +571,78 @@ async fn search_pk_candidates_batch_with_plan(
Ok(per_query_candidates)
}
+/// Resolve the plan's residual physical selection once for any search reader.
+pub(super) async fn filtered_row_ranges(
+ table: &Table,
+ filter: Option<&Predicate>,
+ plan: &PkVectorScanPlan,
+) -> crate::Result<Option<Vec<RowRangesByFile>>> {
+ let residual_by_split: Option<Vec<RowRangesByFile>> = match filter {
+ Some(filter) => {
+ // The whole filter is pushed into scan planning (`PkVectorScan`),
where
+ // partition-only conjuncts already prune partitions/files.
Re-applying
+ // them as a per-row residual would be redundant, so keep only the
data
+ // conjuncts here — a partition-only filter then needs no residual
at
+ // all. Mixed partition/data conjuncts stay whole in
`data_predicates`
+ // and evaluate against the materialized partition column
(partition
+ // columns are physically present in primary-key data files), so
there
+ // is no missing-column case to reject.
+ let (_partition_predicate, data_predicates) =
split_partition_and_data_predicates(
+ filter.clone(),
+ table.schema().fields(),
+ table.schema().partition_keys(),
+ );
+ if data_predicates.is_empty() {
+ None
+ } else {
+ let file_predicates = FilePredicates {
+ predicates: data_predicates,
+ row_filter_factory: None,
+ file_fields: table.schema().fields().to_vec(),
+ };
+ let residual_read_type = widen_scan_fields(&[],
Some(&file_predicates));
+ let residual_reader = DataFileReader::new(
+ table.file_io().clone(),
+ table.schema_manager().clone(),
+ table.schema().id(),
+ table.schema().fields().to_vec(),
+ residual_read_type,
+ Vec::new(),
+ )
+ .with_table_options(table.schema().options().clone());
+ let mut per_split = Vec::with_capacity(plan.splits.len());
+ for (index, split) in plan.splits.iter().enumerate() {
+ // The plan's selection for this split, so the residual is
+ // evaluated over the rows an engine-supplied split allows
rather
+ // than over the whole file.
+ let allowed_rows = plan
+ .physical_row_ranges_by_split
+ .as_ref()
+ .and_then(|per_split| per_split.get(index));
+ per_split.push(
+ residual_row_ranges_by_file(
+ &residual_reader,
+ &split.data_split,
+ &split.active_files,
+ &file_predicates,
+ allowed_rows,
+ )
+ .await?,
+ );
+ }
+ Some(per_split)
+ }
+ }
+ None => None,
+ };
+ // Preserve the external plan's ranges as well as the residual restriction.
+ intersect_row_ranges_by_split(
+ plan.physical_row_ranges_by_split.as_deref(),
+ residual_by_split,
+ plan.splits.len(),
+ )
+}
+
/// Read the plan's allowed physical rows and return merged ranges matching the
/// residual, as in Java `PrimaryKeyVectorRead.residualRowRanges`.
///
diff --git a/crates/paimon/src/table/pk_vector_scan.rs
b/crates/paimon/src/table/pk_vector_scan.rs
index 3a17c807..0f6ad524 100644
--- a/crates/paimon/src/table/pk_vector_scan.rs
+++ b/crates/paimon/src/table/pk_vector_scan.rs
@@ -25,8 +25,8 @@ use std::collections::{BTreeMap, HashMap, HashSet};
use indexmap::IndexMap;
use crate::spec::{
- should_read_pk_index_source, BinaryRow, DataFileMeta, FileKind,
IndexManifest, Predicate,
- PrimaryKeyIndexSourceFile, PrimaryKeyIndexSourceMeta,
+ should_read_pk_index_source, BinaryRow, DataFileMeta, FileKind,
IndexFileMeta, IndexManifest,
+ Predicate, PrimaryKeyIndexSourceFile, PrimaryKeyIndexSourceMeta, Snapshot,
};
use crate::table::bucket_filter::split_partition_and_data_predicates;
use crate::table::index_file_path::IndexFileLocation;
@@ -211,6 +211,8 @@ impl BucketAccumulator {
/// The per-bucket search splits produced by planning.
#[derive(Clone)]
pub(crate) struct PkVectorScanPlan {
+ pub snapshot: Option<Snapshot>,
+ pub payloads: HashMap<BucketKey, Vec<IndexFileMeta>>,
// The snapshot the plan resolved during planning (pinned before the index
// manifest is read). It is authoritative even when planning yields zero
// searchable splits, so a cross-route consistency guard can require one
@@ -340,6 +342,8 @@ impl Scan for PkVectorScan {
}
if incremental {
return Ok(PkVectorScanPlan {
+ snapshot: None,
+ payloads: HashMap::new(),
snapshot_id: 0,
splits: Vec::new(),
physical_row_ranges_by_split: None,
@@ -347,6 +351,8 @@ impl Scan for PkVectorScan {
}
let Some(snapshot) = snapshot else {
return Ok(PkVectorScanPlan {
+ snapshot: None,
+ payloads: HashMap::new(),
snapshot_id: 0,
splits: Vec::new(),
physical_row_ranges_by_split: None,
@@ -357,6 +363,7 @@ impl Scan for PkVectorScan {
// Index-manifest scan into filtered ANN payload tuples.
let table_path = self.table.location().trim_end_matches('/');
let mut entries = Vec::new();
+ let mut payloads = HashMap::<BucketKey, Vec<IndexFileMeta>>::new();
if let Some(name) = snapshot.index_manifest() {
let path = snapshot_manager.manifest_path(name);
for entry in IndexManifest::read(self.table.file_io(),
&path).await? {
@@ -381,6 +388,10 @@ impl Scan for PkVectorScan {
if gim.source_meta.is_none() {
continue;
}
+ payloads
+ .entry((entry.partition.clone(), entry.bucket))
+ .or_default()
+ .push(entry.index_file.clone());
let partition =
BinaryRow::from_serialized_bytes(&entry.partition)?;
let file_size = u64::try_from(entry.index_file.file_size)
.map_err(|_| data_invalid("index file size must not be
negative"))?;
@@ -421,6 +432,8 @@ impl Scan for PkVectorScan {
index_file_in_data_file_dir,
)?;
Ok(PkVectorScanPlan {
+ snapshot: Some(snapshot),
+ payloads,
snapshot_id,
splits,
physical_row_ranges_by_split: None,
@@ -594,6 +607,8 @@ fn plan_from_bucket_splits(
.collect::<Vec<_>>();
Ok(PkVectorScanPlan {
+ snapshot: None,
+ payloads: HashMap::new(),
snapshot_id,
splits,
physical_row_ranges_by_split: Some(physical_row_ranges_by_split),
diff --git a/crates/paimon/src/table/search_scan_split.rs
b/crates/paimon/src/table/search_scan_split.rs
new file mode 100644
index 00000000..692ae15f
--- /dev/null
+++ b/crates/paimon/src/table/search_scan_split.rs
@@ -0,0 +1,224 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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.
+
+//! Read-only source work retained by a vector or full-text search plan.
+
+use super::bucket_filter::split_partition_and_data_predicates;
+use super::global_index_scanner::unindexed_ranges_for_global_index_entries;
+use super::global_index_types::normalize_queryable_global_index_type;
+use super::row_id_predicate::intersect_sorted_ranges;
+use super::vector_search_common::{configured_vector_index_type,
VectorIndexBackend};
+use super::{find_field_id_by_name, merge_row_ranges, DataSplit, RowRange,
Table};
+use crate::spec::{
+ CoreOptions, FileKind, GlobalIndexSearchMode, IndexFileMeta,
IndexManifestEntry, Predicate,
+};
+use std::collections::{BTreeMap, HashMap, HashSet};
+
+/// Search source metadata, without live readers or filesystem handles.
+/// The global row-ID and primary-key physical address spaces remain distinct.
+#[derive(Clone, Debug)]
+pub enum SearchScanSplit {
+ Index {
+ column: String,
+ row_range: RowRange,
+ index_files: Vec<IndexFileMeta>,
+ scalar_index_files: Vec<IndexFileMeta>,
+ },
+ Raw {
+ row_ranges: Vec<RowRange>,
+ scalar_index_files: Vec<IndexFileMeta>,
+ index_type: Option<String>,
+ },
+ PrimaryKey {
+ data_split: DataSplit,
+ payloads: Vec<IndexFileMeta>,
+ uncovered_data_files: Vec<String>,
+ row_ranges_by_file: HashMap<String, Vec<RowRange>>,
+ },
+}
+
+pub(super) async fn global_search_splits(
+ table: &Table,
+ entries: &[IndexManifestEntry],
+ column: &str,
+ filter: Option<&Predicate>,
+ options: &HashMap<String, String>,
+ full_text: bool,
+) -> crate::Result<Vec<SearchScanSplit>> {
+ let field_id = find_field_id_by_name(table.schema().fields(),
column).ok_or_else(|| {
+ crate::Error::ConfigInvalid {
+ message: format!("Search column '{column}' does not exist"),
+ }
+ })?;
+ let mut effective_options = table.schema().options().clone();
+ effective_options.extend(options.clone());
+ let core = CoreOptions::new(&effective_options);
+ let mode = if full_text {
+ core.full_text_index_search_mode()?
+ } else {
+ core.vector_index_search_mode()?
+ };
+ let belongs = |file: &IndexFileMeta| {
+ file.global_index_meta
+ .as_ref()
+ .is_some_and(|meta| meta.index_field_id == field_id)
+ && if full_text {
+ file.index_type == "full-text"
+ } else {
+ VectorIndexBackend::from_index_type(&file.index_type).is_some()
+ }
+ };
+ let selected = entries
+ .iter()
+ .filter(|entry| entry.kind == FileKind::Add &&
belongs(&entry.index_file))
+ .collect::<Vec<_>>();
+ // Full-text admits raw fallback only after finding its index definition.
+ if full_text && selected.is_empty() {
+ return Ok(vec![]);
+ }
+ let mut filter_fields = Vec::new();
+ let row_filter = filter.and_then(|filter| {
+ let (_, data) = split_partition_and_data_predicates(
+ filter.clone(),
+ table.schema().fields(),
+ table.schema().partition_keys(),
+ );
+ (!data.is_empty()).then(|| Predicate::and(data))
+ });
+ if let Some(filter) = &row_filter {
+ crate::arrow::residual::collect_predicate_leaf_refs(filter, &mut
filter_fields);
+ }
+ let filter_ids = filter_fields
+ .into_iter()
+ .filter_map(|(name, _)| find_field_id_by_name(table.schema().fields(),
name))
+ .collect::<HashSet<_>>();
+ let scalar_files = entries
+ .iter()
+ .filter(|entry| entry.kind == FileKind::Add)
+ .map(|entry| &entry.index_file)
+ .filter(|file| {
+ normalize_queryable_global_index_type(&file.index_type).is_some()
+ && file.global_index_meta.as_ref().is_some_and(|meta| {
+ filter_ids.contains(&meta.index_field_id)
+ || meta
+ .extra_field_ids
+ .as_ref()
+ .is_some_and(|ids| ids.iter().any(|id|
filter_ids.contains(id)))
+ })
+ })
+ .cloned()
+ .collect::<Vec<_>>();
+ let mut grouped = BTreeMap::<(i64, i64), Vec<IndexFileMeta>>::new();
+ for entry in selected {
+ let meta = entry.index_file.global_index_meta.as_ref().unwrap();
+ grouped
+ .entry((meta.row_range_start, meta.row_range_end))
+ .or_default()
+ .push(entry.index_file.clone());
+ }
+ let intersecting_scalar = |ranges: &[RowRange]| {
+ scalar_files
+ .iter()
+ .filter(|file| {
+ let meta = file.global_index_meta.as_ref().unwrap();
+ ranges
+ .iter()
+ .any(|range|
range.overlaps_inclusive(meta.row_range_start, meta.row_range_end))
+ })
+ .cloned()
+ .collect::<Vec<_>>()
+ };
+ let mut result = grouped
+ .iter()
+ .map(|(&(from, to), files)| {
+ let row_range = RowRange::new(from, to);
+ SearchScanSplit::Index {
+ column: column.into(),
+ scalar_index_files:
intersecting_scalar(std::slice::from_ref(&row_range)),
+ row_range,
+ index_files: files.clone(),
+ }
+ })
+ .collect::<Vec<_>>();
+ let scalar_mode = row_filter
+ .as_ref()
+ .map(|_| core.scalar_index_search_mode())
+ .transpose()?;
+ let detail_ranges = if mode == GlobalIndexSearchMode::Detail
+ || scalar_mode == Some(GlobalIndexSearchMode::Detail)
+ {
+ // These readers use this same helper against the plan's pinned table.
+ super::de_vector_read::detail_data_ranges_for_table(table).await?
+ } else {
+ vec![]
+ };
+ let next_row_id = table
+ .travel_snapshot()
+ .and_then(|snapshot| snapshot.next_row_id());
+ let mut raw_ranges = unindexed_ranges_for_global_index_entries(
+ entries,
+ &HashSet::from([field_id]),
+ mode,
+ next_row_id,
+ &detail_ranges,
+ belongs,
+ );
+ if !full_text {
+ if let (Some(filter), Some(scalar_mode)) = (&row_filter, scalar_mode) {
+ let mut scalar_ranges =
super::de_vector_read::scalar_unindexed_ranges(
+ super::de_vector_read::VectorSearchEvaluation {
+ table: Some(table),
+ file_io: table.file_io(),
+ table_path: table.location(),
+ table_options: table.schema().options(),
+ schema_fields: table.schema().fields(),
+ next_row_id,
+ filter: Some(filter),
+ },
+ entries,
+ filter,
+ scalar_mode,
+ &detail_ranges,
+ );
+ if mode == GlobalIndexSearchMode::Fast {
+ let indexed = merge_row_ranges(
+ grouped
+ .keys()
+ .map(|&(from, to)| RowRange::new(from, to))
+ .collect(),
+ );
+ scalar_ranges = intersect_sorted_ranges(&scalar_ranges,
&indexed);
+ }
+ raw_ranges.extend(scalar_ranges);
+ }
+ }
+ let raw_ranges = merge_row_ranges(raw_ranges);
+ if !raw_ranges.is_empty() {
+ let index_type = grouped
+ .values()
+ .flat_map(|files| files.iter())
+ .next()
+ .map(|file| file.index_type.clone())
+ .or_else(|| configured_vector_index_type(table.schema().options(),
options, column));
+ result.push(SearchScanSplit::Raw {
+ scalar_index_files: intersecting_scalar(&raw_ranges),
+ row_ranges: raw_ranges,
+ index_type,
+ });
+ }
+ Ok(result)
+}
diff --git a/crates/paimon/src/table/sorted_global_index_build_builder.rs
b/crates/paimon/src/table/sorted_global_index_build_builder.rs
index 1c116235..598f2c2e 100644
--- a/crates/paimon/src/table/sorted_global_index_build_builder.rs
+++ b/crates/paimon/src/table/sorted_global_index_build_builder.rs
@@ -147,7 +147,7 @@ impl<'a> SortedGlobalIndexBuildBuilder<'a> {
let mut resolved_options = self.table.schema().options().clone();
resolved_options.extend(self.options.clone());
let core_options = CoreOptions::new(&resolved_options);
- validate_table_options(self.table, &core_options)?;
+ super::global_index_build_common::validate_build_table(self.table)?;
let records_per_range = core_options.sorted_index_records_per_range()?;
let write_options = if index_type == FM_GLOBAL_INDEX_TYPE {
GlobalIndexWriteOptions::FM(FMOptions::from_options(&resolved_options)?.write)
@@ -200,7 +200,7 @@ impl<'a> SortedGlobalIndexBuildBuilder<'a> {
self.table.location(),
self.table.schema().partition_keys(),
self.table.schema().fields(),
- &core_options,
+ &self.table.schema().core_options(),
snapshot.id(),
manifest_entries,
records_per_range,
diff --git
a/crates/paimon/src/table/sorted_global_index_build_builder/extraction.rs
b/crates/paimon/src/table/sorted_global_index_build_builder/extraction.rs
index 69f0fdae..a31fb165 100644
--- a/crates/paimon/src/table/sorted_global_index_build_builder/extraction.rs
+++ b/crates/paimon/src/table/sorted_global_index_build_builder/extraction.rs
@@ -179,6 +179,9 @@ pub(super) fn build_read_splits_for_shard(
return None;
}
let raw_convertible = files.len() == 1;
+ // Like Java's global-index build splits, deliberately omit
deletion
+ // files: indexes cover physical row IDs. Readers apply the query
+ // snapshot's deletion vectors, including deletes after this build.
Some(
DataSplitBuilder::new()
.with_snapshot(shard.snapshot_id)
diff --git a/crates/paimon/src/table/sorted_global_index_build_builder/tests.rs
b/crates/paimon/src/table/sorted_global_index_build_builder/tests.rs
index 8f9008a5..74ac730d 100644
--- a/crates/paimon/src/table/sorted_global_index_build_builder/tests.rs
+++ b/crates/paimon/src/table/sorted_global_index_build_builder/tests.rs
@@ -690,6 +690,146 @@ async fn scan_ids(table: &Table, predicate: Predicate) ->
Vec<i32> {
ids
}
+#[tokio::test]
+async fn build_options_do_not_change_source_data_paths() {
+ for kind in [BTREE_GLOBAL_INDEX_TYPE, BITMAP_GLOBAL_INDEX_TYPE] {
+ let mut options = table_options("10");
+ options.insert("data-file.path-directory".into(),
"actual-data".into());
+ options.insert("data-evolution.enabled".into(), "false".into());
+ let table =
+
test_table_with_path(&format!("memory:/index_build_source_path_{kind}"),
options);
+ setup_dirs(&table).await;
+ let writer = table.new_write_builder();
+ let mut write = writer.new_write().unwrap();
+ write
+ .write_arrow_batch(&data_batch(vec![1, 2], vec!["a", "b"]))
+ .await
+ .unwrap();
+ writer
+ .new_commit()
+ .commit(write.prepare_commit().await.unwrap())
+ .await
+ .unwrap();
+ let count = table
+ .new_global_index_build_builder()
+ .with_index_column("name")
+ .with_index_type(kind)
+ .with_options(HashMap::from([(
+ "data-file.path-directory".into(),
+ "wrong-directory".into(),
+ )]))
+ .execute()
+ .await
+ .unwrap();
+ assert_eq!(count, 1);
+ let predicate = PredicateBuilder::new(table.schema().fields())
+ .equal("name", Datum::String("b".into()))
+ .unwrap();
+ assert_eq!(scan_ids(&table, predicate).await, vec![2]);
+ }
+}
+
+#[tokio::test]
+async fn scalar_build_with_deletion_vectors_preserves_physical_coverage() {
+ for kind in [BTREE_GLOBAL_INDEX_TYPE, BITMAP_GLOBAL_INDEX_TYPE] {
+ for deleted in [vec![0, 2], vec![0, 1, 2, 3]] {
+ let mut options = table_options("2");
+ options.insert("deletion-vectors.enabled".into(), "true".into());
+ options.insert("global-index.search-mode".into(), "full".into());
+ let table = test_table_with_path(
+ &format!("memory:/scalar_build_dv_{kind}_{}", deleted.len()),
+ options,
+ );
+ setup_dirs(&table).await;
+ let writer = table.new_write_builder();
+ let mut write = writer.new_write().unwrap();
+ write
+ .write_arrow_batch(&data_batch(vec![1, 2, 3, 4], vec!["a",
"b", "a", "b"]))
+ .await
+ .unwrap();
+ writer
+ .new_commit()
+ .commit(write.prepare_commit().await.unwrap())
+ .await
+ .unwrap();
+ let delete = writer.new_update().unwrap();
+ writer
+ .new_commit()
+
.commit(delete.delete_by_row_id(deleted.clone()).await.unwrap())
+ .await
+ .unwrap();
+
+ let mut builder = table.new_global_index_build_builder();
+ builder.with_index_column("name").with_index_type(kind);
+ let messages = builder.build().await.unwrap();
+ let files = messages
+ .iter()
+ .flat_map(|m| &m.new_index_files)
+ .collect::<Vec<_>>();
+ // Like Java, index writers consume the physical rows, even when
+ // all rows are deleted. The query snapshot owns live-row
filtering.
+ assert_eq!(files.iter().map(|f| f.row_count).sum::<i64>(), 4);
+ assert_eq!(
+ merge_row_ranges(
+ files
+ .iter()
+ .map(|f| {
+ let meta = f.global_index_meta.as_ref().unwrap();
+ RowRange::new(meta.row_range_start,
meta.row_range_end)
+ })
+ .collect()
+ ),
+ vec![RowRange::new(0, 3)]
+ );
+ writer.new_commit().commit(messages).await.unwrap();
+ let predicates = PredicateBuilder::new(table.schema().fields());
+ let filter = predicates.equal("name",
Datum::String("b".into())).unwrap();
+ assert_eq!(
+ scan_ids(&table, filter.clone()).await,
+ if deleted.len() == 2 {
+ vec![2, 4]
+ } else {
+ vec![]
+ }
+ );
+ assert!(builder.build().await.unwrap().is_empty());
+
+ // A delete after preparation does not invalidate a physical index,
+ // and subsequent appends still receive their original global IDs.
+ let mut write = writer.new_write().unwrap();
+ write
+ .write_arrow_batch(&data_batch(vec![5, 6], vec!["b", "a"]))
+ .await
+ .unwrap();
+ writer
+ .new_commit()
+ .commit(write.prepare_commit().await.unwrap())
+ .await
+ .unwrap();
+ let messages = builder.build().await.unwrap();
+ assert_eq!(
+ messages
+ .iter()
+ .flat_map(|m| &m.new_index_files)
+ .map(|f| f.row_count)
+ .sum::<i64>(),
+ 2
+ );
+ writer
+ .new_commit()
+ .commit(delete.delete_by_row_id(vec![1, 4]).await.unwrap())
+ .await
+ .unwrap();
+ writer.new_commit().commit(messages).await.unwrap();
+ assert_eq!(
+ scan_ids(&table, filter).await,
+ if deleted.len() == 2 { vec![4] } else { vec![] }
+ );
+ assert!(builder.build().await.unwrap().is_empty());
+ }
+ }
+}
+
#[tokio::test]
async fn test_execute_writes_btree_index_manifest_and_file() {
for version in [1, 2] {
diff --git
a/crates/paimon/src/table/sorted_global_index_build_builder/validation.rs
b/crates/paimon/src/table/sorted_global_index_build_builder/validation.rs
index c1e64c1f..ae90987a 100644
--- a/crates/paimon/src/table/sorted_global_index_build_builder/validation.rs
+++ b/crates/paimon/src/table/sorted_global_index_build_builder/validation.rs
@@ -17,48 +17,12 @@
//! Build-option, field-type, and row-range validation.
-use crate::spec::{CoreOptions, DataField, DataType};
+use crate::spec::{DataField, DataType};
use crate::table::global_index_types::{FM_GLOBAL_INDEX_TYPE,
MULTIVALUE_GLOBAL_INDEX_TYPE};
+#[cfg(test)]
use crate::table::Table;
use crate::{Error, Result};
-pub(super) fn validate_table_options(table: &Table, core_options:
&CoreOptions) -> Result<()> {
- if !core_options.row_tracking_enabled() {
- return Err(Error::DataInvalid {
- message: "Sorted global index build requires
'row-tracking.enabled' = 'true'"
- .to_string(),
- source: None,
- });
- }
- if !core_options.data_evolution_enabled() {
- return Err(Error::DataInvalid {
- message: "Sorted global index build requires
'data-evolution.enabled' = 'true'"
- .to_string(),
- source: None,
- });
- }
- if !core_options.global_index_enabled() {
- return Err(Error::DataInvalid {
- message: "Sorted global index build requires
'global-index.enabled' = 'true'"
- .to_string(),
- source: None,
- });
- }
- if !table.schema().primary_keys().is_empty() {
- return Err(Error::Unsupported {
- message: "Sorted global index build does not support primary-key
tables".to_string(),
- });
- }
- if core_options.deletion_vectors_enabled() {
- return Err(Error::Unsupported {
- message:
- "Sorted global index build does not support tables with
deletion-vectors.enabled=true"
- .to_string(),
- });
- }
- Ok(())
-}
-
#[cfg(test)]
pub(super) fn find_index_field<'a>(table: &'a Table, column: &str) ->
Result<&'a DataField> {
table
diff --git a/crates/paimon/src/table/vector_read.rs
b/crates/paimon/src/table/vector_read.rs
index c8ae740d..5feef481 100644
--- a/crates/paimon/src/table/vector_read.rs
+++ b/crates/paimon/src/table/vector_read.rs
@@ -101,7 +101,7 @@ impl BatchVectorRead {
reader.read(*plan).await
}
(VectorReadKind::PrimaryKey(reader),
VectorScanWork::PrimaryKey(plan)) => {
- reader.read(plan).await
+ reader.read(*plan).await
}
_ => Err(crate::Error::DataInvalid {
message: "vector plan and reader use different DE/PK address
spaces".to_string(),
diff --git a/crates/paimon/src/table/vector_scan.rs
b/crates/paimon/src/table/vector_scan.rs
index 39683352..f8411fb0 100644
--- a/crates/paimon/src/table/vector_scan.rs
+++ b/crates/paimon/src/table/vector_scan.rs
@@ -22,11 +22,15 @@ use std::sync::Arc;
use roaring::RoaringTreemap;
-use crate::spec::{CoreOptions, Predicate};
+use crate::spec::{CoreOptions, Predicate, Snapshot};
use crate::table::de_vector_scan::{DeVectorScan, DeVectorScanPlan,
PreparedVectorSearchFilter};
use crate::table::pk_vector_scan::{PkVectorScan, PkVectorScanPlan};
-use crate::table::vector_search_common::targets_primary_key_column;
+use crate::table::vector_search_common::{
+ targets_primary_key_column, validate_global_vector_index_types,
+};
+use crate::table::SearchScanSplit;
use crate::table::{find_field_id_by_name, BucketVectorSearchSplit, Table};
+use std::collections::{HashMap, HashSet};
/// Resolve one snapshot and the work a vector reader will consume.
///
@@ -44,15 +48,123 @@ pub(super) trait Scan: Sync {
pub struct VectorScanPlan {
pub(super) context: PlanContext,
pub(super) work: VectorScanWork,
+ table: Table,
}
#[derive(Clone)]
pub(super) enum VectorScanWork {
DataEvolution(Box<DeVectorScanPlan>),
- PrimaryKey(PkVectorScanPlan),
+ PrimaryKey(Box<PkVectorScanPlan>),
}
impl VectorScanPlan {
+ /// The snapshot captured by planning, including retained tag snapshots.
+ pub fn snapshot(&self) -> Option<&Snapshot> {
+ match &self.work {
+ VectorScanWork::DataEvolution(plan) =>
plan.table.travel_snapshot(),
+ VectorScanWork::PrimaryKey(plan) => plan.snapshot.as_ref(),
+ }
+ }
+
+ /// Inspect source work using the scan's effective search options. This
does
+ /// not resolve a newer snapshot or reread its index manifest. Query
readers
+ /// may still use different query vectors, limits and search options.
+ pub async fn splits(
+ &self,
+ options: &HashMap<String, String>,
+ ) -> crate::Result<Vec<SearchScanSplit>> {
+ match &self.work {
+ VectorScanWork::DataEvolution(plan) => {
+ if plan.skip_search {
+ return Ok(vec![]);
+ }
+ super::search_scan_split::global_search_splits(
+ &plan.table,
+ &plan.index_entries,
+ &self.context.column,
+ self.context.filter.as_ref(),
+ options,
+ false,
+ )
+ .await
+ }
+ VectorScanWork::PrimaryKey(plan) => {
+ if plan.snapshot.is_none()
+ && plan
+ .splits
+ .iter()
+ .any(|split| !split.ann_segments.is_empty())
+ {
+ return Err(crate::Error::Unsupported {
+ message:
+ "Source metadata export requires a
manifest-planned primary-key search"
+ .into(),
+ });
+ }
+ let selected = super::pk_vector_read::filtered_row_ranges(
+ &self.table,
+ self.context.filter.as_ref(),
+ plan,
+ )
+ .await?;
+ plan.splits
+ .iter()
+ .enumerate()
+ .map(|(index, split)| {
+ let payloads = plan
+ .payloads
+ .get(&(
+
split.data_split.partition().to_serialized_bytes(),
+ split.data_split.bucket(),
+ ))
+ .into_iter()
+ .flatten()
+ .filter(|payload| {
+ split.ann_segments.iter().any(|segment| {
+ let location =
+
super::index_file_path::IndexFileLocation::BucketLocal {
+ table_path: &self.context.location,
+ bucket_path:
split.data_split.bucket_path(),
+ index_file_in_data_file_dir: false,
+ };
+ segment.path
+ == location.resolve(
+ &payload.file_name,
+ payload.external_path.as_deref(),
+ )
+ || segment
+ .path
+ .ends_with(&format!("/{}",
payload.file_name))
+ })
+ })
+ .cloned()
+ .collect::<Vec<_>>();
+ let covered = split
+ .ann_segments
+ .iter()
+ .flat_map(|segment|
segment.source_meta.source_files())
+ .map(|file| file.file_name())
+ .collect::<HashSet<_>>();
+ Ok(SearchScanSplit::PrimaryKey {
+ data_split: split.data_split.clone(),
+ payloads,
+ uncovered_data_files: split
+ .active_files
+ .iter()
+ .filter(|file|
!covered.contains(file.file_name.as_str()))
+ .map(|file| file.file_name.clone())
+ .collect(),
+ row_ranges_by_file: selected
+ .as_ref()
+ .map(|ranges| ranges[index].clone())
+ .unwrap_or_default(),
+ })
+ })
+ .collect()
+ }
+ }
+ }
+
pub fn snapshot_id(&self) -> Option<i64> {
match &self.work {
VectorScanWork::DataEvolution(plan) =>
plan.table.travel_snapshot().map(|s| s.id()),
@@ -122,6 +234,7 @@ impl PlanContext {
/// Creates query-independent plans for DE or primary-key vector search.
pub struct VectorScan {
+ table: Table,
context: PlanContext,
scan: VectorScanKind,
}
@@ -164,17 +277,34 @@ impl VectorScan {
prepared,
)))
};
- Ok(Self { context, scan })
+ Ok(Self {
+ table: table.clone(),
+ context,
+ scan,
+ })
}
pub async fn plan(&self) -> crate::Result<VectorScanPlan> {
let work = match &self.scan {
VectorScanKind::DataEvolution(scan) => {
- VectorScanWork::DataEvolution(Box::new(scan.plan().await?))
+ let plan = scan.plan().await?;
+ if let Some(field_id) =
+ find_field_id_by_name(plan.table.schema().fields(),
&self.context.column)
+ {
+ validate_global_vector_index_types(
+ &plan.index_entries,
+ field_id,
+ &self.context.column,
+ )?;
+ }
+ VectorScanWork::DataEvolution(Box::new(plan))
+ }
+ VectorScanKind::PrimaryKey(scan) => {
+ VectorScanWork::PrimaryKey(Box::new(scan.plan().await?))
}
- VectorScanKind::PrimaryKey(scan) =>
VectorScanWork::PrimaryKey(scan.plan().await?),
};
Ok(VectorScanPlan {
+ table: self.table.clone(),
context: self.context.clone(),
work,
})
@@ -194,8 +324,9 @@ impl VectorScan {
});
};
Ok(VectorScanPlan {
+ table: self.table.clone(),
context: self.context.clone(),
- work:
VectorScanWork::PrimaryKey(scan.plan_for_bucket_vector_splits(splits)?),
+ work:
VectorScanWork::PrimaryKey(Box::new(scan.plan_for_bucket_vector_splits(splits)?)),
})
}
}
diff --git a/crates/paimon/src/table/vector_search_common.rs
b/crates/paimon/src/table/vector_search_common.rs
index 10dcab6f..9fd80e60 100644
--- a/crates/paimon/src/table/vector_search_common.rs
+++ b/crates/paimon/src/table/vector_search_common.rs
@@ -18,7 +18,9 @@
//! Shared vector-search option validation, index I/O helpers, and result
ordering.
use crate::lumina::is_lumina_index_type;
-use crate::spec::{CoreOptions, DataField, Predicate, ROW_ID_FIELD_NAME};
+use crate::spec::{
+ CoreOptions, DataField, FileKind, IndexManifestEntry, Predicate,
ROW_ID_FIELD_NAME,
+};
use crate::table::pk_vector_position_read::{PKEY_VECTOR_POSITION_COLUMN,
SEARCH_SCORE_COLUMN};
use crate::table::read_builder::resolve_projected_fields;
use crate::table::Table;
@@ -62,6 +64,58 @@ impl VectorIndexBackend {
}
}
+/// A search column selects one persisted vector format, as in Java's scan.
+/// Different columns, removed entries and scalar indexes do not participate.
+pub(super) fn validate_global_vector_index_types(
+ entries: &[IndexManifestEntry],
+ field_id: i32,
+ column: &str,
+) -> crate::Result<()> {
+ let mut index_type = None;
+ for entry in entries {
+ let file = &entry.index_file;
+ if entry.kind != FileKind::Add
+ || VectorIndexBackend::from_index_type(&file.index_type).is_none()
+ || !file
+ .global_index_meta
+ .as_ref()
+ .is_some_and(|meta| meta.index_field_id == field_id)
+ {
+ continue;
+ }
+ if let Some(previous) = index_type {
+ if previous != file.index_type.as_str() {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "Vector column '{column}' has multiple index types:
{previous} and {}",
+ file.index_type
+ ),
+ source: None,
+ });
+ }
+ } else {
+ index_type = Some(file.index_type.as_str());
+ }
+ }
+ Ok(())
+}
+
+pub(super) fn configured_vector_index_type(
+ table_options: &HashMap<String, String>,
+ query_options: &HashMap<String, String>,
+ column: &str,
+) -> Option<String> {
+ [
+ "index_type".to_string(),
+ "index-type".into(),
+ "vector.index-type".into(),
+ format!("fields.{column}.index-type"),
+ ]
+ .iter()
+ .find_map(|key| query_options.get(key).or_else(|| table_options.get(key)))
+ .map(|value| value.trim().to_ascii_lowercase())
+}
+
pub(super) fn current_tokio_runtime_handle() ->
crate::Result<tokio::runtime::Handle> {
tokio::runtime::Handle::try_current().map_err(|error|
crate::Error::UnexpectedError {
message: "Vector index range reader requires a Tokio
runtime".to_string(),
diff --git a/crates/paimon/src/table/vindex_index_build_builder.rs
b/crates/paimon/src/table/vindex_index_build_builder.rs
index ddb4470e..bfc1ecf6 100644
--- a/crates/paimon/src/table/vindex_index_build_builder.rs
+++ b/crates/paimon/src/table/vindex_index_build_builder.rs
@@ -23,7 +23,7 @@ mod validation;
mod writer;
use planning::plan_vindex_shards;
-use validation::{checked_i32, find_index_field, validate_table_options,
validate_vector_field};
+use validation::{checked_i32, find_index_field, validate_vector_field};
use crate::spec::{CoreOptions, Predicate};
use crate::table::{CommitMessage, RowRange, Table};
@@ -90,7 +90,7 @@ impl<'a> VindexIndexBuildBuilder<'a> {
let mut merged_options = self.table.schema().options().clone();
merged_options.extend(self.options.clone());
let core_options = CoreOptions::new(&merged_options);
- validate_table_options(self.table, &core_options)?;
+ super::global_index_build_common::validate_build_table(self.table)?;
let rows_per_shard = core_options.global_index_row_count_per_shard()?;
let parallelism = core_options.global_index_build_parallelism()?;
@@ -138,7 +138,7 @@ impl<'a> VindexIndexBuildBuilder<'a> {
self.table.location(),
self.table.schema().partition_keys(),
self.table.schema().fields(),
- &core_options,
+ &self.table.schema().core_options(),
snapshot.id(),
manifest_entries,
rows_per_shard,
diff --git a/crates/paimon/src/table/vindex_index_build_builder/extraction.rs
b/crates/paimon/src/table/vindex_index_build_builder/extraction.rs
index b8aabaf7..49a4351a 100644
--- a/crates/paimon/src/table/vindex_index_build_builder/extraction.rs
+++ b/crates/paimon/src/table/vindex_index_build_builder/extraction.rs
@@ -34,6 +34,9 @@ pub(super) fn data_split_for_shard_ranges(
shard: &VindexIndexShard,
row_ranges: Vec<RowRange>,
) -> Result<DataSplit> {
+ // Java builds over stable physical row IDs, including deleted vectors.
+ // Do not attach deletion files: live-row filtering belongs to the query
+ // snapshot, and filtering here would leave holes in the training input.
DataSplitBuilder::new()
.with_snapshot(shard.snapshot_id)
.with_partition(shard.partition.clone())
diff --git a/crates/paimon/src/table/vindex_index_build_builder/tests.rs
b/crates/paimon/src/table/vindex_index_build_builder/tests.rs
index 998791ef..617a8dbc 100644
--- a/crates/paimon/src/table/vindex_index_build_builder/tests.rs
+++ b/crates/paimon/src/table/vindex_index_build_builder/tests.rs
@@ -481,6 +481,120 @@ async fn write_vectors(table: &Table, ids: Vec<i32>,
vectors: Vec<Vec<f32>>) {
.unwrap();
}
+#[tokio::test]
+async fn vector_build_with_deletion_vectors_preserves_physical_coverage() {
+ for granule in [false, true] {
+ for deleted in [vec![0, 2], vec![0, 1, 2, 3]] {
+ let path = format!("memory:/vector_build_dv_{granule}_{}",
deleted.len());
+ let mut options = vindex_e2e_options("10");
+ options.insert("ivf-flat.metric".into(), "l2".into());
+ options.insert("deletion-vectors.enabled".into(), "true".into());
+ options.insert("vindex.build.granule.enabled".into(),
granule.to_string());
+ let table = test_table_with_io(
+ FileIOBuilder::new("memory").build().unwrap(),
+ &path,
+ vindex_schema_builder(options).build().unwrap(),
+ );
+ setup_dirs(table.file_io(), &path).await;
+ write_vectors(
+ &table,
+ vec![1, 2, 3, 4],
+ vec![vec![1., 0.], vec![0., 1.], vec![1., 1.], vec![2., 2.]],
+ )
+ .await;
+ let writer = table.new_write_builder();
+ let delete = writer.new_update().unwrap();
+ writer
+ .new_commit()
+
.commit(delete.delete_by_row_id(deleted.clone()).await.unwrap())
+ .await
+ .unwrap();
+ let mut builder = table.new_global_index_build_builder();
+ builder
+ .with_index_type(IVF_FLAT_IDENTIFIER)
+ .with_index_column("embedding");
+ let messages = builder.build().await.unwrap();
+ let files = messages
+ .iter()
+ .flat_map(|m| &m.new_index_files)
+ .collect::<Vec<_>>();
+ assert_eq!(files.len(), 1);
+ assert_eq!(files[0].row_count, 4);
+ let meta = files[0].global_index_meta.as_ref().unwrap();
+ assert_eq!((meta.row_range_start, meta.row_range_end), (0, 3));
+ writer.new_commit().commit(messages).await.unwrap();
+ let result = table
+ .new_vector_search_builder()
+ .with_vector_column("embedding")
+ .with_query_vector(vec![1., 1.])
+ .with_limit(10)
+ .with_options(HashMap::from([("ivf.nprobe".into(),
"10".into())]))
+ .execute()
+ .await
+ .unwrap();
+ let mut ids = result.row_ids().unwrap().row_ids.clone();
+ ids.sort_unstable();
+ assert_eq!(
+ ids,
+ if deleted.len() == 2 {
+ vec![1, 3]
+ } else {
+ vec![]
+ }
+ );
+ // The closest physical vector (row 2) is deleted. Top-K must
+ // continue to a live candidate rather than return an empty result.
+ let result = table
+ .new_vector_search_builder()
+ .with_vector_column("embedding")
+ .with_query_vector(vec![1., 1.])
+ .with_limit(1)
+ .with_options(HashMap::from([("ivf.nprobe".into(),
"10".into())]))
+ .execute()
+ .await
+ .unwrap();
+ assert_eq!(
+ result.row_ids().unwrap().row_ids,
+ if deleted.len() == 2 { vec![1] } else { vec![] }
+ );
+ assert!(builder.build().await.unwrap().is_empty());
+
+ write_vectors(&table, vec![5, 6], vec![vec![3., 3.], vec![4.,
4.]]).await;
+ let messages = builder.build().await.unwrap();
+ let file = &messages[0].new_index_files[0];
+ assert_eq!(file.row_count, 2);
+ let meta = file.global_index_meta.as_ref().unwrap();
+ assert_eq!((meta.row_range_start, meta.row_range_end), (4, 5));
+ writer
+ .new_commit()
+ .commit(delete.delete_by_row_id(vec![1, 4]).await.unwrap())
+ .await
+ .unwrap();
+ writer.new_commit().commit(messages).await.unwrap();
+ let result = table
+ .new_vector_search_builder()
+ .with_vector_column("embedding")
+ .with_query_vector(vec![1., 1.])
+ .with_limit(10)
+ .with_options(HashMap::from([("ivf.nprobe".into(),
"10".into())]))
+ .execute()
+ .await
+ .unwrap();
+ let mut ids = result.row_ids().unwrap().row_ids.clone();
+ ids.sort_unstable();
+ assert_eq!(
+ ids,
+ if deleted.len() == 2 {
+ vec![3, 5]
+ } else {
+ vec![5]
+ }
+ );
+ assert!(builder.build().await.unwrap().is_empty());
+ }
+ }
+}
+
/// Commit a synthetic vindex `IndexFileMeta` covering `[start, end]` for
/// `field_id` directly into the index manifest, without invoking the native
/// builder. Mirrors the Lumina/btree tests so the incremental gap logic can
diff --git a/crates/paimon/src/table/vindex_index_build_builder/validation.rs
b/crates/paimon/src/table/vindex_index_build_builder/validation.rs
index f1c5270d..298cbc42 100644
--- a/crates/paimon/src/table/vindex_index_build_builder/validation.rs
+++ b/crates/paimon/src/table/vindex_index_build_builder/validation.rs
@@ -15,7 +15,7 @@
// specific language governing permissions and limitations
// under the License.
-use crate::spec::{CoreOptions, DataField};
+use crate::spec::DataField;
use crate::table::global_index_build_common::vector::{
find_index_field as find_vector_index_field,
validate_vector_field as validate_common_vector_field,
@@ -23,40 +23,6 @@ use crate::table::global_index_build_common::vector::{
use crate::table::Table;
use crate::{Error, Result};
-pub(super) fn validate_table_options(table: &Table, core_options:
&CoreOptions) -> Result<()> {
- if !core_options.row_tracking_enabled() {
- return Err(Error::DataInvalid {
- message: "vindex index build requires 'row-tracking.enabled' =
'true'".to_string(),
- source: None,
- });
- }
- if !core_options.data_evolution_enabled() {
- return Err(Error::DataInvalid {
- message: "vindex index build requires 'data-evolution.enabled' =
'true'".to_string(),
- source: None,
- });
- }
- if !core_options.global_index_enabled() {
- return Err(Error::DataInvalid {
- message: "vindex index build requires 'global-index.enabled' =
'true'".to_string(),
- source: None,
- });
- }
- if !table.schema().primary_keys().is_empty() {
- return Err(Error::Unsupported {
- message: "vindex index build does not support primary-key
tables".to_string(),
- });
- }
- if core_options.deletion_vectors_enabled() {
- return Err(Error::Unsupported {
- message:
- "vindex index build does not support tables with
deletion-vectors.enabled=true"
- .to_string(),
- });
- }
- Ok(())
-}
-
pub(super) fn find_index_field<'a>(table: &'a Table, column: &str) ->
Result<&'a DataField> {
find_vector_index_field(table, column)
}
diff --git a/crates/paimon/src/vindex/reader.rs
b/crates/paimon/src/vindex/reader.rs
index 557c2ee9..a203fe76 100644
--- a/crates/paimon/src/vindex/reader.rs
+++ b/crates/paimon/src/vindex/reader.rs
@@ -267,15 +267,15 @@ impl VindexVectorGlobalIndexReader {
Ok(results)
}
- #[cfg(test)]
- pub(crate) fn load<S: SeekRead + 'static>(
+ /// Open and optimize the index without executing a query.
+ pub fn load<S: SeekRead + 'static>(
&mut self,
stream_fn: impl FnOnce(&str) -> crate::Result<S>,
) -> crate::Result<()> {
self.ensure_loaded(stream_fn, |_| Ok(()))
}
- pub(crate) fn metadata(&self) -> crate::Result<&VectorIndexMetadata> {
+ pub fn metadata(&self) -> crate::Result<&VectorIndexMetadata> {
self.metadata
.as_ref()
.ok_or_else(|| crate::Error::DataInvalid {
@@ -454,6 +454,15 @@ fn prepare_search_with_shared_filter(
vector_search: &VectorSearch,
shared_filter_bytes: Option<&Arc<[u8]>>,
) -> crate::Result<Option<PreparedSearch>> {
+ // Query options override the defaults of a reusable index reader.
+ let mut merged_options;
+ let options = if vector_search.options.is_empty() {
+ options
+ } else {
+ merged_options = options.clone();
+ merged_options.extend(vector_search.options.clone());
+ &merged_options
+ };
if vector_search.vector.len() != metadata.dimension {
return Err(crate::Error::DataInvalid {
message: format!(
@@ -470,30 +479,32 @@ fn prepare_search_with_shared_filter(
if top_k == 0 {
return Ok(None);
}
- let mut params = match metadata.index_type {
- IndexType::DiskAnn => match options.get(L_SEARCH_PARAMETER) {
- Some(value) => {
- let l_search = value
- .parse::<usize>()
- .ok()
- .filter(|value| *value > 0)
- .ok_or_else(|| crate::Error::DataInvalid {
- message: format!(
- "Invalid value for '{}': {}. Must be a positive
integer.",
- L_SEARCH_PARAMETER, value
- ),
- source: None,
- })?;
- VectorSearchParams::with_l_search(top_k, l_search)
- }
- None => VectorSearchParams::automatic(top_k),
- },
- _ => match options.get(NPROBE_PARAMETER) {
- Some(value) => {
- VectorSearchParams::new(top_k,
parse_int_parameter(NPROBE_PARAMETER, value)?)
+ let mut params = match (
+ options.get(NPROBE_PARAMETER),
+ options.get(L_SEARCH_PARAMETER),
+ ) {
+ (Some(_), Some(_)) => {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "Cannot set both '{NPROBE_PARAMETER}' and
'{L_SEARCH_PARAMETER}'."
+ ),
+ source: None,
+ });
+ }
+ (Some(value), None) => {
+ VectorSearchParams::new(top_k,
parse_search_width(NPROBE_PARAMETER, value)?)
+ }
+ (None, Some(value)) => {
+ let l_search = parse_search_width(L_SEARCH_PARAMETER, value)?;
+ if l_search == 0 {
+ return Err(crate::Error::DataInvalid {
+ message: format!("'{L_SEARCH_PARAMETER}' must be a
positive integer"),
+ source: None,
+ });
}
- None => VectorSearchParams::automatic(top_k),
- },
+ VectorSearchParams::with_l_search(top_k, l_search)
+ }
+ (None, None) => VectorSearchParams::automatic(top_k),
};
let filter_bytes = if let Some(include_ids) =
vector_search.effective_include_row_ids() {
@@ -849,6 +860,30 @@ fn int_parameter(
}
}
+// Java's NativeVectorGlobalIndexReader uses Integer.parseInt, including BMP
+// decimal digits, with no whitespace/fraction coercion or values beyond i32.
+fn parse_search_width(key: &str, value: &str) -> crate::Result<usize> {
+ let text = value
+ .chars()
+ .enumerate()
+ .map(|(index, ch)| {
+ if index == 0 && matches!(ch, '+' | '-') {
+ Some(ch)
+ } else {
+ crate::common::number::java_decimal_digit(ch).map(|digit|
char::from(b'0' + digit))
+ }
+ })
+ .collect::<Option<String>>();
+ text.and_then(|text| text.parse::<i32>().ok())
+ .and_then(|width| usize::try_from(width).ok())
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: format!(
+ "Invalid value for '{key}': {value}. Must be a non-negative
32-bit integer."
+ ),
+ source: None,
+ })
+}
+
fn parse_int_parameter(key: &str, value: &str) -> crate::Result<usize> {
value
.parse::<usize>()
@@ -864,6 +899,28 @@ fn parse_int_parameter(key: &str, value: &str) ->
crate::Result<usize> {
#[cfg(test)]
mod tests {
use super::*;
+
+ #[test]
+ fn query_widths_follow_java_integer_text_and_bounds() {
+ for key in [NPROBE_PARAMETER, L_SEARCH_PARAMETER] {
+ for (text, expected) in [
+ ("+12", 12),
+ ("12", 12),
+ ("٢", 2),
+ ("2147483647", 2147483647),
+ ] {
+ assert_eq!(parse_search_width(key, text).unwrap(), expected);
+ }
+ for text in ["2147483648", " 2", "2 ", "2.0", "1_0", "-1", "𝟙",
"+", ""] {
+ assert!(parse_search_width(key, text).is_err(), "{text}");
+ }
+ }
+ // Reader memory budgets are usize byte counts, not Java search widths.
+ assert_eq!(
+ parse_int_parameter(READER_MEMORY_BUDGET_PARAMETER,
"2147483648").unwrap(),
+ 2147483648
+ );
+ }
use crate::io::FileRead;
use crate::vindex::range_reader::VindexFileReader;
use async_trait::async_trait;
@@ -1051,6 +1108,33 @@ mod tests {
assert!(!result.contains_key(&3));
}
+ #[test]
+ fn reusable_reader_query_options_override_defaults_without_leaking() {
+ let metadata = VectorIndexMetadata {
+ index_type: IndexType::IvfFlat,
+ dimension: TEST_DIMENSION,
+ nlist: 16,
+ metric: MetricType::L2,
+ total_vectors: 8192,
+ pq_m: None,
+ pq_bits: None,
+ rq_bits: None,
+ diskann: None,
+ };
+ let defaults = HashMap::from([(NPROBE_PARAMETER.into(), "1".into())]);
+ let explicit =
query().with_options(HashMap::from([(NPROBE_PARAMETER.into(), "7".into())]));
+ let prepared = prepare_batch_searches(&metadata, &defaults,
&[explicit, query()]).unwrap();
+ assert_eq!(prepared[0].as_ref().unwrap().params.width, 7);
+ assert_eq!(prepared[1].as_ref().unwrap().params.width, 1);
+ let conflicting =
+ query().with_options(HashMap::from([(L_SEARCH_PARAMETER.into(),
"32".into())]));
+ assert!(prepare_search(&metadata, &defaults, &conflicting)
+ .err()
+ .expect("conflicting query widths must be rejected")
+ .to_string()
+ .contains("Cannot set both"));
+ }
+
#[test]
fn native_batch_chunk_size_tracks_working_set_inputs() {
let base_metadata = VectorIndexMetadata {
@@ -1321,25 +1405,11 @@ mod tests {
("ivf.nprobe".to_string(), "4".to_string()),
("diskann.l_search".to_string(), "64".to_string()),
]);
- let diskann = prepare_search(&metadata, &mixed_options, &query())
- .unwrap()
- .unwrap();
- assert_eq!(
- diskann.params.search_width,
- paimon_vindex_core::index::SearchWidth::DiskAnnLSearch
- );
- assert_eq!(diskann.params.width, 64);
+ assert!(prepare_search(&metadata, &mixed_options, &query()).is_err());
let mut ivf_metadata = metadata;
ivf_metadata.index_type = IndexType::IvfFlat;
- let ivf = prepare_search(&ivf_metadata, &mixed_options, &query())
- .unwrap()
- .unwrap();
- assert_eq!(
- ivf.params.search_width,
- paimon_vindex_core::index::SearchWidth::IvfNProbe
- );
- assert_eq!(ivf.params.width, 4);
+ assert!(prepare_search(&ivf_metadata, &mixed_options,
&query()).is_err());
}
#[test]