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 82650686 feat(table): align primary-key vector read to
physical-coordinate model (#544)
82650686 is described below
commit 826506861e8589a4501fd281377c318be238b7b5
Author: Junrui Lee <[email protected]>
AuthorDate: Mon Jul 20 11:21:56 2026 +0800
feat(table): align primary-key vector read to physical-coordinate model
(#544)
---
.licenserc.yaml | 1 +
crates/paimon/src/table/data_file_reader.rs | 230 +++++++++++++
crates/paimon/src/table/pk_vector_position_read.rs | 324 +++++++++---------
crates/paimon/src/table/vector_search_builder.rs | 374 +++++----------------
...-932a1249-f7e0-4a03-8e1f-ab8c85cbb76f-0.parquet | Bin 0 -> 1303 bytes
...-fd398030-457f-4444-8d71-fffc2753689d-0.parquet | Bin 0 -> 1303 bytes
.../index-c2314ed5-0000-4a0d-984f-2b894d6040e8-0 | Bin 0 -> 145 bytes
...manifest-7a487b9a-2053-423a-9379-77cb67346955-0 | Bin 0 -> 1422 bytes
...manifest-1328438e-8b23-4aef-a6ee-e2e2c29d90df-0 | Bin 0 -> 2096 bytes
...manifest-1328438e-8b23-4aef-a6ee-e2e2c29d90df-1 | Bin 0 -> 2139 bytes
...est-list-f5034e53-2d6f-40ae-9665-8778726154b4-0 | Bin 0 -> 1006 bytes
...est-list-f5034e53-2d6f-40ae-9665-8778726154b4-1 | Bin 0 -> 1110 bytes
...est-list-f5034e53-2d6f-40ae-9665-8778726154b4-2 | Bin 0 -> 1110 bytes
...est-list-f5034e53-2d6f-40ae-9665-8778726154b4-3 | Bin 0 -> 1109 bytes
.../pkvector/pk_vector_ivf_flat/schema/schema-0 | 32 ++
.../pkvector/pk_vector_ivf_flat/snapshot/EARLIEST | 1 +
.../pkvector/pk_vector_ivf_flat/snapshot/LATEST | 1 +
.../pk_vector_ivf_flat/snapshot/snapshot-1 | 16 +
.../pk_vector_ivf_flat/snapshot/snapshot-2 | 17 +
crates/paimon/tests/pk_vector_baseline_test.rs | 176 +++++++---
crates/paimon/tests/pk_vector_java_fixture_test.rs | 233 +++++++++++++
21 files changed, 906 insertions(+), 499 deletions(-)
diff --git a/.licenserc.yaml b/.licenserc.yaml
index 47a0ae30..ffbce011 100644
--- a/.licenserc.yaml
+++ b/.licenserc.yaml
@@ -26,6 +26,7 @@ header:
- ".gitattributes"
- ".github/PULL_REQUEST_TEMPLATE.md"
- "crates/paimon/tests/**/*.json"
+ - "crates/paimon/testdata/**"
- "**/go.sum"
- "**/DEPENDENCIES.*.tsv"
- ".devcontainer/devcontainer.json"
diff --git a/crates/paimon/src/table/data_file_reader.rs
b/crates/paimon/src/table/data_file_reader.rs
index f5b2647e..0faff2e9 100644
--- a/crates/paimon/src/table/data_file_reader.rs
+++ b/crates/paimon/src/table/data_file_reader.rs
@@ -419,6 +419,212 @@ impl DataFileReader {
}
.boxed())
}
+
+ /// Read one data file selecting rows by their file-LOCAL 0-based physical
+ /// positions. Unlike [`Self::read_single_file_stream`], the selection is
+ /// interpreted directly in file-local coordinates: it never consults
+ /// `first_row_id` (real primary-key tables never write one) and never
emits
+ /// `_ROW_ID`. A deletion vector, when present, is folded into the
selection so
+ /// any selected-but-deleted position is dropped; surviving rows are
returned in
+ /// ascending physical-position order. `local_positions` must be sorted
+ /// ascending, de-duplicated, and within `[0, file_meta.row_count)`.
+ ///
+ /// Used by `pk_vector_position_read` to recover rows by physical position
on
+ /// primary-key data files that carry no `first_row_id`.
+ pub(super) fn read_single_file_stream_local(
+ &self,
+ split: &DataSplit,
+ file_meta: DataFileMeta,
+ data_fields: Option<Vec<DataField>>,
+ dv: Option<Arc<DeletionVector>>,
+ local_positions: Vec<i64>,
+ ) -> crate::Result<ArrowRecordBatchStream> {
+ // Local-position selection is only sound against a predicate-free
reader: a
+ // row-filtering predicate drops arbitrary selected rows and desyncs
the
+ // caller's position/score cursor. Guard the invariant here (not only
at the
+ // PK-vector caller) so this `pub(super)` entry cannot be misused
within the
+ // module. This path never projects `_ROW_ID`, so a `_ROW_ID`-only
guard
+ // would be a no-op — check for any row-filtering predicate instead.
+ if self.has_row_filtering_predicate() {
+ return Err(crate::Error::DataInvalid {
+ message: "read_single_file_stream_local requires a
predicate-free reader: a \
+ row-filtering predicate would desync local-position selection"
+ .to_string(),
+ source: None,
+ });
+ }
+
+ let read_type = self.read_type.clone();
+ let table_fields = self.table_fields.clone();
+ let predicates = self.predicates.clone();
+ let file_io = self.file_io.clone();
+ let split = split.clone();
+ let blob_as_descriptor = self.blob_as_descriptor;
+
+ let target_schema = build_target_arrow_schema(&read_type)?;
+ let file_fields = data_fields.clone().unwrap_or_else(||
table_fields.clone());
+ let is_row_file = is_row_file(&file_meta);
+
+ // Compute index mapping and determine which columns to read from the
file.
+ let (projected_read_fields, index_mapping) = if let Some(ref df) =
data_fields {
+ let mapping = create_index_mapping(&read_type, df);
+ let fields_to_read = read_data_fields(df, &read_type)?;
+ (fields_to_read, mapping)
+ } else {
+ (
+ read_type
+ .iter()
+ .filter(|field| field.name() != ROW_ID_FIELD_NAME)
+ .cloned()
+ .collect(),
+ None,
+ )
+ };
+ let format_read_fields = if is_row_file {
+ file_fields.clone()
+ } else {
+ projected_read_fields
+ };
+
+ // Remap predicates from table-level to file-level indices.
+ let file_predicates = {
+ let remapped = crate::arrow::filtering::remap_predicates_to_file(
+ &predicates,
+ &table_fields,
+ &file_fields,
+ );
+ if remapped.is_empty() {
+ None
+ } else {
+ Some(crate::arrow::format::FilePredicates {
+ predicates: remapped,
+ file_fields: file_fields.clone(),
+ })
+ }
+ };
+
+ // Interpret `local_positions` directly as file-local ranges (no
+ // `to_local_row_ranges`, no `first_row_id`), then fold the DV in.
+ // `merge_row_selection` intersects the selection with the file's
+ // non-deleted ranges, so the reader emits exactly the selected,
non-deleted
+ // rows in ascending physical order.
+ let local_ranges =
coalesce_positions_to_local_ranges(&local_positions);
+ let row_selection =
+ merge_row_selection(file_meta.row_count, dv.as_deref(),
Some(&local_ranges));
+
+ Ok(try_stream! {
+ let path_to_read = split.data_file_path(&file_meta);
+ let format_reader =
+ create_format_reader(&path_to_read, blob_as_descriptor,
&format_read_fields)?;
+ let input_file = file_io.new_input(&path_to_read)?;
+ let file_reader = input_file.reader().await?;
+
+ let mut batch_stream = format_reader
+ .read_batch_stream(
+ Box::new(file_reader),
+ file_meta.file_size as u64,
+ &format_read_fields,
+ file_predicates.as_ref(),
+ None,
+ row_selection,
+ )
+ .await?;
+
+ while let Some(batch) = batch_stream.next().await {
+ let batch = batch?;
+ let result = project_file_batch(
+ &batch,
+ &target_schema,
+ index_mapping.as_deref(),
+ data_fields.as_deref(),
+ )?;
+ yield result;
+ }
+ }
+ .boxed())
+ }
+}
+
+/// Project one decoded file `batch` onto `target_schema`, resolving each
target
+/// column through the field-ID `index_mapping` (or by name when the file
schema
+/// matches the table schema), casting on type mismatch and null-filling absent
+/// columns. Unlike the inline projection in
+/// [`DataFileReader::read_single_file_stream`], this never materializes
`_ROW_ID`:
+/// the local-selection PK-vector read path never projects it.
+fn project_file_batch(
+ batch: &RecordBatch,
+ target_schema: &Arc<arrow_schema::Schema>,
+ index_mapping: Option<&[i32]>,
+ data_fields: Option<&[DataField]>,
+) -> crate::Result<RecordBatch> {
+ let num_rows = batch.num_rows();
+ let batch_schema = batch.schema();
+ let mut columns: Vec<Arc<dyn Array>> =
Vec::with_capacity(target_schema.fields().len());
+ for (i, target_field) in target_schema.fields().iter().enumerate() {
+ let source_col = if let Some(idx_map) = index_mapping {
+ let data_idx = idx_map[i];
+ if data_idx == NULL_FIELD_INDEX {
+ None
+ } else {
+ let data_field = &data_fields.unwrap()[data_idx as usize];
+ batch_schema
+ .index_of(data_field.name())
+ .ok()
+ .map(|col_idx| batch.column(col_idx))
+ }
+ } else if let Some(df) = data_fields {
+ batch_schema
+ .index_of(df[i].name())
+ .ok()
+ .map(|col_idx| batch.column(col_idx))
+ } else {
+ batch_schema
+ .index_of(target_field.name())
+ .ok()
+ .map(|col_idx| batch.column(col_idx))
+ };
+
+ match source_col {
+ Some(col) => {
+ if col.data_type() == target_field.data_type() {
+ columns.push(col.clone());
+ } else {
+ let casted = cast(col,
target_field.data_type()).map_err(|e| {
+ Error::UnexpectedError {
+ message: format!(
+ "Failed to cast column '{}' from {:?} to {:?}:
{e}",
+ target_field.name(),
+ col.data_type(),
+ target_field.data_type()
+ ),
+ source: Some(Box::new(e)),
+ }
+ })?;
+ columns.push(casted);
+ }
+ }
+ None => {
+ columns.push(arrow_array::new_null_array(
+ target_field.data_type(),
+ num_rows,
+ ));
+ }
+ }
+ }
+
+ if columns.is_empty() {
+ RecordBatch::try_new_with_options(
+ target_schema.clone(),
+ columns,
+
&arrow_array::RecordBatchOptions::new().with_row_count(Some(num_rows)),
+ )
+ } else {
+ RecordBatch::try_new(target_schema.clone(), columns)
+ }
+ .map_err(|e| Error::UnexpectedError {
+ message: format!("Failed to build schema-evolved RecordBatch: {e}"),
+ source: Some(Box::new(e)),
+ })
}
fn read_data_fields(
@@ -510,6 +716,30 @@ fn to_local_row_ranges(
.collect()
}
+/// Coalesce sorted, de-duplicated 0-based physical positions into contiguous
+/// file-LOCAL inclusive `RowRange`s. Unlike a global-range build, there is no
+/// `first_row_id` offset: the positions are already file-local coordinates.
+fn coalesce_positions_to_local_ranges(sorted_positions: &[i64]) ->
Vec<RowRange> {
+ let mut ranges = Vec::new();
+ let mut iter = sorted_positions.iter().copied();
+ let Some(first) = iter.next() else {
+ return ranges;
+ };
+ let mut start = first;
+ let mut end = first;
+ for pos in iter {
+ if end + 1 == pos {
+ end = pos;
+ } else {
+ ranges.push(RowRange::new(start, end));
+ start = pos;
+ end = pos;
+ }
+ }
+ ranges.push(RowRange::new(start, end));
+ ranges
+}
+
/// Merge DV and row_ranges into a unified list of 0-based inclusive RowRanges.
/// Returns `None` if no filtering is needed (no DV and no ranges).
///
diff --git a/crates/paimon/src/table/pk_vector_position_read.rs
b/crates/paimon/src/table/pk_vector_position_read.rs
index 9662c8b4..0c838979 100644
--- a/crates/paimon/src/table/pk_vector_position_read.rs
+++ b/crates/paimon/src/table/pk_vector_position_read.rs
@@ -32,12 +32,10 @@ use arrow_schema::{DataType as ArrowDataType, Field as
ArrowField, Fields, Schem
use futures::StreamExt;
use crate::deletion_vector::DeletionVector;
-use crate::spec::{
- BigIntType, DataField, DataFileMeta, DataType, ROW_ID_FIELD_ID,
ROW_ID_FIELD_NAME,
-};
+use crate::spec::{DataField, DataFileMeta, ROW_ID_FIELD_NAME};
use crate::table::data_file_reader::DataFileReader;
use crate::table::source::DataSplit;
-use crate::table::{ArrowRecordBatchStream, RowRange};
+use crate::table::ArrowRecordBatchStream;
pub(crate) const PKEY_VECTOR_POSITION_COLUMN: &str = "_PKEY_VECTOR_POSITION";
pub(crate) const PKEY_VECTOR_SCORE_COLUMN: &str = "_PKEY_VECTOR_SCORE";
@@ -49,44 +47,6 @@ fn data_invalid(message: impl Into<String>) -> crate::Error {
}
}
-/// Coalesce sorted, de-duplicated 0-based physical positions into contiguous
-/// global `RowRange`s (each bound offset by `first_row_id`). The read path
-/// converts these back to local ranges via
`to_local_row_ranges(first_row_id)`,
-/// so this round-trips to exactly the requested physical positions.
-fn positions_to_global_ranges(
- sorted_positions: &[i64],
- first_row_id: i64,
-) -> crate::Result<Vec<RowRange>> {
- let mut ranges = Vec::new();
- let mut iter = sorted_positions.iter().copied();
- let Some(first) = iter.next() else {
- return Ok(ranges);
- };
- let mut push_range = |start: i64, end: i64| -> crate::Result<()> {
- let from = start
- .checked_add(first_row_id)
- .ok_or_else(|| data_invalid("vector position offset overflows
i64"))?;
- let to = end
- .checked_add(first_row_id)
- .ok_or_else(|| data_invalid("vector position offset overflows
i64"))?;
- ranges.push(RowRange::new(from, to));
- Ok(())
- };
- let mut start = first;
- let mut end = first;
- for pos in iter {
- if end.checked_add(1) == Some(pos) {
- end = pos;
- } else {
- push_range(start, end)?;
- start = pos;
- end = pos;
- }
- }
- push_range(start, end)?;
- Ok(ranges)
-}
-
/// Reads selected physical rows of one data file, appending position (+
optional
/// score) metadata columns. Rust equivalent of Java
/// `PrimaryKeyVectorPositionReader`.
@@ -168,81 +128,85 @@ impl<'a> PkVectorPositionRead<'a> {
));
}
- // (5) first_row_id guard
- let first_row_id = file_meta.first_row_id.ok_or_else(|| {
- data_invalid("PK vector position read requires a data file with
first_row_id")
- })?;
-
- // Build a reader whose read_type includes _ROW_ID so the lower-level
read
- // emits real global row-ids we can convert to physical positions. A
- // caller-requested _ROW_ID was already rejected in (3), so it is
always
- // absent here and appended fresh.
- let mut inner_read_type = self.reader.read_type().to_vec();
- inner_read_type.push(DataField::new(
- ROW_ID_FIELD_ID,
- ROW_ID_FIELD_NAME.to_string(),
- DataType::BigInt(BigIntType::new()),
- ));
- let inner_reader = self.reader.clone().with_read_type(inner_read_type);
+ // Effective selection: the requested positions minus any the deletion
+ // vector marks deleted, ascending. `read_single_file_stream_local`
folds
+ // the same DV into its row selection, so the reader returns exactly
these
+ // rows in this order; the cursor below maps each returned batch back
onto
+ // its slice of `effective` to recover file-LOCAL positions (no
+ // `first_row_id`, no `_ROW_ID` round-trip).
+ let effective: Vec<i64> = match dv.as_deref() {
+ Some(dv) => sorted
+ .iter()
+ .copied()
+ .filter(|&p| !dv.is_deleted(p as u64))
+ .collect(),
+ None => sorted.clone(),
+ };
- let ranges = positions_to_global_ranges(&sorted, first_row_id)?;
- let inner = inner_reader.read_single_file_stream(
- split,
- file_meta.clone(),
- data_fields,
- dv,
- Some(ranges),
- )?;
+ let inner =
+ self.reader
+ .read_single_file_stream_local(split, file_meta, data_fields,
dv, sorted)?;
let want_score_col = scores.is_some();
let stream = async_stream::try_stream! {
futures::pin_mut!(inner);
+ let mut cursor = 0usize;
while let Some(batch) = inner.next().await {
let batch = batch?;
+ let n = batch.num_rows();
+ let end = cursor + n;
+ if end > effective.len() {
+ let overflow: crate::Result<()> = Err(data_invalid(format!(
+ "PK vector position read returned {end} rows but only
{} positions were \
+ selected",
+ effective.len()
+ )));
+ overflow?;
+ }
let out = append_metadata_columns(
batch,
- first_row_id,
+ &effective[cursor..end],
want_score_col,
scores.as_ref(),
)?;
+ cursor = end;
yield out;
}
+ if cursor != effective.len() {
+ let mismatch: crate::Result<()> = Err(data_invalid(format!(
+ "PK vector position read returned {cursor} rows but {}
positions were selected",
+ effective.len()
+ )));
+ mismatch?;
+ }
};
Ok(Box::pin(stream))
}
}
-/// Split `batch` (which contains a `_ROW_ID` column) into an output batch with
-/// `_ROW_ID` removed and `_PKEY_VECTOR_POSITION` (+ optional
`_PKEY_VECTOR_SCORE`)
-/// appended. Positions are `_ROW_ID - first_row_id`; scores are looked up by
the
-/// returned position. Returns the new batch.
+/// Append `_PKEY_VECTOR_POSITION` (and, when `want_score_col`,
`_PKEY_VECTOR_SCORE`)
+/// to `batch`. `positions` are the file-LOCAL physical positions of the
batch's
+/// rows, supplied by the caller's cursor into the effective (DV-filtered)
+/// selection, so they align 1:1 with the batch rows in order. Scores are
looked
+/// up by position. The batch carries no `_ROW_ID` column, so every existing
+/// column is retained.
fn append_metadata_columns(
batch: RecordBatch,
- first_row_id: i64,
+ positions: &[i64],
want_score_col: bool,
scores: Option<&BTreeMap<i64, f32>>,
) -> crate::Result<RecordBatch> {
+ debug_assert_eq!(
+ batch.num_rows(),
+ positions.len(),
+ "position slice must align with batch rows"
+ );
let schema = batch.schema();
- let row_id_idx = schema
- .index_of(ROW_ID_FIELD_NAME)
- .map_err(|_| data_invalid("internal: _ROW_ID column missing from
position read"))?;
- let row_ids = batch
- .column(row_id_idx)
- .as_any()
- .downcast_ref::<Int64Array>()
- .ok_or_else(|| data_invalid("internal: _ROW_ID column is not Int64"))?;
-
- let mut positions = Vec::with_capacity(row_ids.len());
- let mut score_vals = if want_score_col {
- Some(Vec::with_capacity(row_ids.len()))
- } else {
- None
- };
- for i in 0..row_ids.len() {
- let position = row_ids.value(i) - first_row_id;
- positions.push(position);
- if let Some(sv) = score_vals.as_mut() {
+
+ let score_vals = if want_score_col {
+ let mut sv = Vec::with_capacity(positions.len());
+ for &position in positions {
let score = scores
.and_then(|m| m.get(&position).copied())
.ok_or_else(|| {
@@ -252,24 +216,20 @@ fn append_metadata_columns(
})?;
sv.push(score);
}
- }
+ Some(sv)
+ } else {
+ None
+ };
- // Rebuild columns/fields excluding _ROW_ID, then append metadata columns.
- let mut fields: Vec<ArrowField> = Vec::new();
- let mut columns: Vec<Arc<dyn Array>> = Vec::new();
- for (i, f) in schema.fields().iter().enumerate() {
- if i == row_id_idx {
- continue;
- }
- fields.push(f.as_ref().clone());
- columns.push(batch.column(i).clone());
- }
+ // Retain every existing column (no `_ROW_ID` to strip), then append
metadata.
+ let mut fields: Vec<ArrowField> = schema.fields().iter().map(|f|
f.as_ref().clone()).collect();
+ let mut columns: Vec<Arc<dyn Array>> = batch.columns().to_vec();
fields.push(ArrowField::new(
PKEY_VECTOR_POSITION_COLUMN,
ArrowDataType::Int64,
false,
));
- columns.push(Arc::new(Int64Array::from(positions)));
+ columns.push(Arc::new(Int64Array::from(positions.to_vec())));
if let Some(sv) = score_vals {
fields.push(ArrowField::new(
PKEY_VECTOR_SCORE_COLUMN,
@@ -294,7 +254,8 @@ mod tests {
use crate::io::FileIOBuilder;
use crate::spec::stats::BinaryTableStats;
use crate::spec::{
- DataFileMeta, DataType, Datum, IntType, PredicateBuilder,
ROW_ID_FIELD_NAME,
+ BigIntType, DataFileMeta, DataType, Datum, IntType, PredicateBuilder,
ROW_ID_FIELD_ID,
+ ROW_ID_FIELD_NAME,
};
use crate::table::data_file_reader::DataFileReader;
use crate::table::schema_manager::SchemaManager;
@@ -454,8 +415,9 @@ mod tests {
}
/// Build a `DataFileReader` over an in-memory mosaic file plus the
matching
- /// `DataSplit`. `read_type`/`predicates` override the reader's projection
and
- /// filter; `deleted_rows`, when non-empty, writes a DV into the split.
+ /// `DataSplit`, pinning the data file's `first_row_id = Some(0)`.
+ /// `read_type`/`predicates` override the reader's projection and filter;
+ /// `deleted_rows`, when non-empty, writes a DV into the split.
async fn build_reader_and_split(
table_path: &str,
data: &Bytes,
@@ -463,6 +425,32 @@ mod tests {
read_type: Vec<DataField>,
predicates: Vec<crate::spec::Predicate>,
deleted_rows: &[u32],
+ ) -> (DataFileReader, DataSplit, Option<Arc<DeletionVector>>) {
+ build_reader_and_split_with_first_row_id(
+ table_path,
+ data,
+ row_count,
+ read_type,
+ predicates,
+ deleted_rows,
+ Some(0),
+ )
+ .await
+ }
+
+ /// As `build_reader_and_split`, but the caller controls the data file's
+ /// `first_row_id`. Real Java primary-key tables never write `first_row_id`
+ /// (row-tracking is forbidden for PK tables), so `None` is the shape the
read
+ /// path must handle by keying off file-local physical positions.
+ #[allow(clippy::too_many_arguments)]
+ async fn build_reader_and_split_with_first_row_id(
+ table_path: &str,
+ data: &Bytes,
+ row_count: i64,
+ read_type: Vec<DataField>,
+ predicates: Vec<crate::spec::Predicate>,
+ deleted_rows: &[u32],
+ first_row_id: Option<i64>,
) -> (DataFileReader, DataSplit, Option<Arc<DeletionVector>>) {
let file_io = FileIOBuilder::new("memory").build().unwrap();
let bucket_path = format!("{table_path}/bucket-0");
@@ -486,7 +474,7 @@ mod tests {
data.len() as i64,
row_count,
schema_id,
- Some(0),
+ first_row_id,
)]);
let mut dv = None;
if !deleted_rows.is_empty() {
@@ -772,6 +760,65 @@ mod tests {
);
}
+ #[tokio::test]
+ async fn
pk_position_read_aligns_positions_across_dv_and_batches_without_first_row_id() {
+ // The Java primary-key shape: the data file carries NO first_row_id,
rows
+ // span multiple row groups (batches), a DV deletes a NON-candidate
+ // position, and the candidates sit at non-contiguous local positions.
The
+ // read must recover each row's FILE-LOCAL position (not a global row
id),
+ // skip only the deleted row, and keep id/position/score aligned
best-first.
+ //
+ // Three row groups [10,11] [12,13] [14,15] -> reader yields >1 batch.
+ // Candidates [1,3,4,5] with scores keyed by local position; DV deletes
+ // position 2 (a NON-candidate) -> it must not perturb the surviving
rows.
+ // Expected surviving rows: ids [11,13,14,15], positions [1,3,4,5].
+ let data = write_mosaic_multi_group(&[
+ id_batch(vec![10, 11]),
+ id_batch(vec![12, 13]),
+ id_batch(vec![14, 15]),
+ ]);
+ let (reader, split, dv) = build_reader_and_split_with_first_row_id(
+ "memory:/pkvpr_local_dv_multibatch",
+ &data,
+ 6,
+ id_fields(),
+ Vec::new(),
+ &[2],
+ None,
+ )
+ .await;
+
+ let scores = BTreeMap::from([(1, 0.9f32), (3, 0.5), (4, 0.3), (5,
0.1)]);
+ let batches = PkVectorPositionRead::new(&reader)
+ .read(
+ &split,
+ split.data_files()[0].clone(),
+ None,
+ dv,
+ vec![1, 3, 4, 5],
+ Some(scores),
+ )
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+
+ assert!(
+ batches.len() > 1,
+ "expected multiple batches, got {}",
+ batches.len()
+ );
+ assert_eq!(collect_i32(&batches, "id"), vec![11, 13, 14, 15]);
+ assert_eq!(
+ collect_i64(&batches, PKEY_VECTOR_POSITION_COLUMN),
+ vec![1, 3, 4, 5]
+ );
+ assert_eq!(
+ collect_f32(&batches, PKEY_VECTOR_SCORE_COLUMN),
+ vec![0.9, 0.5, 0.3, 0.1]
+ );
+ }
+
#[tokio::test]
async fn test_empty_positions_is_error() {
let data = write_mosaic_single_group(&id_batch(vec![10, 11, 12]));
@@ -877,44 +924,6 @@ mod tests {
assert!(format!("{err:?}").contains("Scores keys"), "got: {err:?}");
}
- #[tokio::test]
- async fn test_missing_first_row_id_is_error() {
- // first_row_id = None -> Err mentioning first_row_id. Positions must
be
- // valid so validation reaches the first_row_id guard.
- let file_io = FileIOBuilder::new("memory").build().unwrap();
- let schema_manager = SchemaManager::new(file_io.clone(),
"memory:/pkvpr_frid".to_string());
- let reader = DataFileReader::new(
- file_io,
- schema_manager,
- 1,
- id_fields(),
- id_fields(),
- Vec::new(),
- );
- let split = DataSplitBuilder::new()
- .with_snapshot(1)
- .with_partition(crate::spec::BinaryRow::new(0))
- .with_bucket(0)
- .with_bucket_path("memory:/pkvpr_frid/bucket-0".to_string())
- .with_total_buckets(1)
- .with_data_files(vec![data_file("part-0.mosaic", 1, 5, 1, None)])
- .build()
- .unwrap();
-
- let err = PkVectorPositionRead::new(&reader)
- .read(
- &split,
- split.data_files()[0].clone(),
- None,
- None,
- vec![0],
- None,
- )
- .err()
- .expect("missing first_row_id must be an error");
- assert!(format!("{err:?}").contains("first_row_id"), "got: {err:?}");
- }
-
#[tokio::test]
async fn test_predicate_reader_is_rejected() {
// A row-filtering predicate on the reader must be rejected.
@@ -1047,29 +1056,4 @@ mod tests {
.expect("requested _ROW_ID must be an error");
assert!(format!("{err:?}").contains("_ROW_ID"), "got: {err:?}");
}
-
- #[test]
- fn test_positions_to_global_ranges_coalesces_and_offsets() {
- // positions [0,1,2,4,5] with first_row_id 100 -> global ranges
- // [100..=102] and [104..=105] (two coalesced ranges, offset by 100).
- let ranges = positions_to_global_ranges(&[0, 1, 2, 4, 5],
100).unwrap();
- assert_eq!(ranges.len(), 2);
- assert_eq!((ranges[0].from(), ranges[0].to()), (100, 102));
- assert_eq!((ranges[1].from(), ranges[1].to()), (104, 105));
- }
-
- #[test]
- fn test_positions_to_global_ranges_single() {
- let ranges = positions_to_global_ranges(&[3], 0).unwrap();
- assert_eq!(ranges.len(), 1);
- assert_eq!((ranges[0].from(), ranges[0].to()), (3, 3));
- }
-
- #[test]
- fn test_positions_to_global_ranges_overflow_is_error() {
- // position 1 offset by i64::MAX overflows -> Err.
- let err = positions_to_global_ranges(&[1], i64::MAX)
- .expect_err("offset overflow must be an error");
- assert!(format!("{err:?}").contains("overflows"), "got: {err:?}");
- }
}
diff --git a/crates/paimon/src/table/vector_search_builder.rs
b/crates/paimon/src/table/vector_search_builder.rs
index 2ce2f8df..57cde2be 100644
--- a/crates/paimon/src/table/vector_search_builder.rs
+++ b/crates/paimon/src/table/vector_search_builder.rs
@@ -16,13 +16,13 @@
// under the License.
use crate::arrow::format::FilePredicates;
-use crate::arrow::residual::{filter_record_batch_by_predicates,
widen_scan_fields};
+use crate::arrow::residual::{evaluate_predicates_mask, widen_scan_fields};
use crate::io::FileIO;
use crate::lumina::reader::LuminaVectorGlobalIndexReader;
use crate::lumina::{is_lumina_index_type, LuminaIndexMeta, LuminaVectorMetric};
use crate::spec::{
- BigIntType, CoreOptions, DataField, DataType, FileKind,
GlobalIndexSearchMode, IndexFileMeta,
- IndexManifest, IndexManifestEntry, Predicate, ROW_ID_FIELD_ID,
ROW_ID_FIELD_NAME,
+ CoreOptions, DataField, FileKind, GlobalIndexSearchMode, IndexFileMeta,
IndexManifest,
+ IndexManifestEntry, Predicate, ROW_ID_FIELD_NAME,
};
use crate::table::data_file_reader::DataFileReader;
use crate::table::global_index_scanner::{
@@ -32,8 +32,8 @@ use crate::table::global_index_scanner::{
use crate::table::pk_vector_data_file_reader::DataFilePkVectorReaderFactory;
use crate::table::pk_vector_indexed_split_read::PkVectorIndexedSplitRead;
use crate::table::pk_vector_orchestrator::{
- as_split_exact_reader_factory, build_indexed_splits,
validate_row_position, PkVectorCandidate,
- PkVectorOrchestrator, PkVectorSearchSplit,
+ as_split_exact_reader_factory, build_indexed_splits, PkVectorCandidate,
PkVectorOrchestrator,
+ PkVectorSearchSplit,
};
use crate::table::pk_vector_position_read::{
PKEY_VECTOR_POSITION_COLUMN, PKEY_VECTOR_SCORE_COLUMN,
@@ -196,22 +196,21 @@ impl<'a> VectorSearchBuilder<'a> {
// targets a configured PK-vector column; otherwise fall through to the
// data-evolution (DE) global-index path below.
//
- // Membership is resolved first via the non-erroring columns accessor
so a
- // malformed PK-vector config (e.g. more than one column, or a blank
list)
- // cannot abort an unrelated DE query. The exactly-one-column rule is
- // enforced only once this query is known to target a PK-vector column,
- // keeping fail-loud behavior for a genuinely-broken config on the path
- // where erroring is correct.
+ // Membership is resolved via the non-erroring columns accessor so a
+ // malformed PK-vector config (e.g. a blank list) cannot abort an
unrelated
+ // DE query. A query that does target the PK-vector column fails loud
here:
+ // the PK path produces physical positions, not global row ids, so
scored
+ // search is unsupported and callers must use `execute_read` instead.
if core.primary_key_vector_index_enabled() {
let targets_pk_column = core
.primary_key_vector_index_columns()
.ok()
.is_some_and(|cols| cols.iter().any(|c| c == vector_column));
if targets_pk_column {
- let pk_col = core.primary_key_vector_index_column()?;
- return self
- .execute_primary_key_vector_search(&core, &pk_col,
query_vector, limit)
- .await;
+ return Err(crate::Error::DataInvalid {
+ message: "primary-key vector search does not produce
global row ids; use the materialized read (execute_read) instead".to_string(),
+ source: None,
+ });
}
}
@@ -290,23 +289,6 @@ impl<'a> VectorSearchBuilder<'a> {
})
}
- /// Run the primary-key bucket-local vector search: plan the per-bucket
splits,
- /// build the real vindex ANN scorer and (outside FAST mode) the
exact-fallback
- /// readers, run the orchestrator, and convert the best-first candidates
into a
- /// `SearchResult`. Mirrors Java `PrimaryKeyVectorRead`.
- async fn execute_primary_key_vector_search(
- &self,
- core: &CoreOptions<'_>,
- pk_col: &str,
- query_vector: &[f32],
- limit: usize,
- ) -> crate::Result<SearchResult> {
- let (candidates, plan, metric) = self
- .plan_and_search_pk_candidates(core, pk_col, query_vector, limit)
- .await?;
- candidates_to_search_result(&candidates, &plan.splits, metric)
- }
-
/// Shared PK-vector search core for both the search-only and
search-and-read
/// paths: plan the per-bucket splits, verify the configured metric
against each
/// ANN segment, build the real vindex ANN scorer and (outside FAST mode)
the
@@ -422,9 +404,10 @@ impl<'a> VectorSearchBuilder<'a> {
// per-split allow-list is threaded into the bucket search so the
residual
// folds into recall (best-first order and Top-K are preserved). Built
only
// when a filter is set; otherwise `None` leaves the search
unfiltered. The
- // residual reader projects the predicate columns plus `_ROW_ID` (used
to
- // recover file-local physical positions) and carries no pushdown,
matching
- // `residual_positions_by_file`. A file the allow-list leaves empty is
+ // residual reader projects only the predicate columns and carries no
+ // pushdown; `residual_positions_by_file` recovers each surviving row's
+ // file-local physical position from its ordinal in the unfiltered
scan (no
+ // `_ROW_ID`, no `first_row_id`). A file the allow-list leaves empty is
// skipped by the bucket search without opening an exact reader.
let residual_by_split: Option<Vec<HashMap<String, RoaringTreemap>>> =
match &self.filter {
Some(filter) => {
@@ -432,13 +415,7 @@ impl<'a> VectorSearchBuilder<'a> {
predicates: vec![filter.clone()],
file_fields: self.table.schema().fields().to_vec(),
};
- let row_id_field = DataField::new(
- ROW_ID_FIELD_ID,
- ROW_ID_FIELD_NAME.to_string(),
- DataType::BigInt(BigIntType::new()),
- );
- let residual_read_type =
- widen_scan_fields(std::slice::from_ref(&row_id_field),
Some(&file_predicates));
+ let residual_read_type = widen_scan_fields(&[],
Some(&file_predicates));
let residual_reader = DataFileReader::new(
self.table.file_io().clone(),
self.table.schema_manager().clone(),
@@ -1003,29 +980,27 @@ fn is_vector_global_index_file(index_file:
&IndexFileMeta) -> bool {
VectorIndexBackend::from_index_type(&index_file.index_type).is_some()
}
-/// Compute, per data file in `split`, the set of physical row positions whose
-/// rows satisfy the residual predicate. Mirrors the row-collecting half of
Java
-/// `PrimaryKeyVectorRead`'s `executeFilter`: because
-/// [`DataFileReader::read_single_file_stream`] rejects projecting `_ROW_ID`
-/// alongside a row-filtering predicate (the residual filter would drop rows
-/// before `_ROW_ID` is assigned positionally, desyncing it), the predicate is
-/// NOT pushed down. Instead `reader` projects the residual columns together
with
-/// `_ROW_ID` and carries no pushdown predicate; the residual is applied here
at
-/// the Arrow level, after `_ROW_ID` is materialized, and each surviving row's
-/// `_ROW_ID - first_row_id` is the file-local physical position.
+/// Compute, per data file in `split`, the set of file-LOCAL physical row
+/// positions whose rows satisfy the residual predicate. Mirrors the
+/// row-collecting half of Java `PrimaryKeyVectorRead`'s `executeFilter`: the
+/// predicate is NOT pushed down (a pushed filter would drop rows before their
+/// position could be recovered). Instead `reader` projects only the residual
+/// columns and carries no pushdown predicate; every physical row is scanned in
+/// file order, the residual is evaluated here at the Arrow level, and each
+/// surviving row's file-local 0-based position is its running ordinal in the
scan.
+/// This needs no `_ROW_ID` and no `first_row_id` — real primary-key tables
never
+/// write one.
///
/// Every *active* data file in the split gets an entry, possibly empty. The
/// bucket search treats an absent entry and an empty entry identically (the
file
/// contributes no candidates), so the empty entries only make the map cover
every
/// active file. Non-active files (e.g. level-0 files the bucket search
excludes)
/// are skipped entirely: they are never searched, so re-reading them would be
-/// wasted IO and their possibly-absent `first_row_id` must not fail an
otherwise
-/// valid query.
+/// wasted IO.
///
-/// `reader` must project `_ROW_ID` and be predicate-free;
`residual.file_fields`
-/// are the fields the residual leaf indices point into (resolved by name
against
-/// each emitted batch). A data file without `first_row_id` fails loud,
matching
-/// the position-read guard.
+/// `reader` must be predicate-free and project the residual columns;
+/// `residual.file_fields` are the fields the residual leaf indices point into
+/// (resolved by name against each emitted batch).
async fn residual_positions_by_file(
reader: &DataFileReader,
split: &DataSplit,
@@ -1037,57 +1012,46 @@ async fn residual_positions_by_file(
let mut out: HashMap<String, RoaringTreemap> = HashMap::new();
for file_meta in split.data_files() {
// Only files the bucket search actually recalls from need residual
- // positions; skip everything else so a non-active file cannot trigger
the
- // `first_row_id` guard below or incur a wasted read.
+ // positions; skip everything else to avoid a wasted read.
if !active_names.contains(file_meta.file_name.as_str()) {
continue;
}
- let first_row_id = file_meta
- .first_row_id
- .ok_or_else(|| crate::Error::DataInvalid {
- message: format!(
- "residual position read requires data file '{}' to have
first_row_id",
- file_meta.file_name
- ),
- source: None,
- })?;
let data_fields = reader.derive_data_fields(file_meta).await?;
let mut stream =
reader.read_single_file_stream(split, file_meta.clone(),
data_fields, None, None)?;
// Register the file up front so a file whose rows all fail the
residual
// still appears in the map (empty set).
let positions = out.entry(file_meta.file_name.clone()).or_default();
+ // The scan has no row selection and no DV, so rows arrive in physical
file
+ // order with no gaps: each row's file-local 0-based position is its
running
+ // ordinal `base + row_index`.
+ let mut base: u64 = 0;
while let Some(batch) = stream.try_next().await? {
- let filtered = filter_record_batch_by_predicates(batch, residual,
&scan_fields)?;
- if filtered.num_rows() == 0 {
- continue;
- }
- let row_id_idx =
filtered.schema().index_of(ROW_ID_FIELD_NAME).map_err(|_| {
- crate::Error::DataInvalid {
- message: "residual position read batch is missing the
_ROW_ID column"
- .to_string(),
- source: None,
+ let num_rows = batch.num_rows();
+ let mask = evaluate_predicates_mask(
+ &batch,
+ &residual.predicates,
+ &residual.file_fields,
+ &scan_fields,
+ )?;
+ match mask {
+ Some(mask) => {
+ for row_index in 0..num_rows {
+ // NULL follows the same NULL -> false convention the
Arrow
+ // filter kernel applies, so a null mask slot drops
the row.
+ if mask.is_valid(row_index) && mask.value(row_index) {
+ positions.insert(base + row_index as u64);
+ }
+ }
+ }
+ // No predicate contributed a mask (identity) -> keep every
row.
+ None => {
+ for row_index in 0..num_rows {
+ positions.insert(base + row_index as u64);
+ }
}
- })?;
- let row_ids = filtered
- .column(row_id_idx)
- .as_any()
- .downcast_ref::<Int64Array>()
- .ok_or_else(|| crate::Error::DataInvalid {
- message: "residual position read _ROW_ID column is not
Int64".to_string(),
- source: None,
- })?;
- for i in 0..row_ids.len() {
- let position = row_ids.value(i) - first_row_id;
- let position = u64::try_from(position).map_err(|_|
crate::Error::DataInvalid {
- message: format!(
- "residual position {position} is negative for data
file '{}'",
- file_meta.file_name
- ),
- source: None,
- })?;
- positions.insert(position);
}
+ base += num_rows as u64;
}
}
Ok(out)
@@ -1167,62 +1131,6 @@ fn verify_pk_vector_segment_metrics(
Ok(())
}
-/// candidate order (no re-sort). Each candidate's global row id is
-/// `first_row_id + row_position` of the data file it references; the score is
-/// derived from the raw distance via the metric. A candidate referencing a
file
-/// absent from its split, or a file with no `first_row_id`, fails loud.
-fn candidates_to_search_result(
- candidates: &[PkVectorCandidate],
- splits: &[PkVectorSearchSplit],
- metric: VectorSearchMetric,
-) -> crate::Result<SearchResult> {
- let mut row_ids = Vec::with_capacity(candidates.len());
- let mut scores = Vec::with_capacity(candidates.len());
- for c in candidates {
- let split = splits
- .get(c.split_index)
- .ok_or_else(|| crate::Error::DataInvalid {
- message: format!("candidate split_index {} out of range",
c.split_index),
- source: None,
- })?;
- let file_meta = split
- .data_split
- .data_files()
- .iter()
- .find(|f| f.file_name == c.data_file_name)
- .ok_or_else(|| crate::Error::DataInvalid {
- message: format!(
- "candidate references data file {} not present in its
split",
- c.data_file_name
- ),
- source: None,
- })?;
- let first_row_id = file_meta
- .first_row_id
- .ok_or_else(|| crate::Error::DataInvalid {
- message: format!("data file {} has no first_row_id",
c.data_file_name),
- source: None,
- })?;
- validate_row_position(&c.data_file_name, c.row_position,
file_meta.row_count)?;
- let global =
- first_row_id
- .checked_add(c.row_position)
- .ok_or_else(|| crate::Error::DataInvalid {
- message: "global row id overflows i64".to_string(),
- source: None,
- })?;
- row_ids.push(
- u64::try_from(global).map_err(|_| crate::Error::DataInvalid {
- message: format!("negative global row id {global}"),
- source: None,
- })?,
- );
- scores.push(metric.distance_to_score(c.distance));
- }
- // Order preserved: best-first, as produced by the orchestrator.
- Ok(SearchResult::new(row_ids, scores))
-}
-
/// One materialized row tagged with its best-first `rank` and its
`(batch_index,
/// row_index)` location in the retained materialization batches.
struct RankedRow {
@@ -2733,75 +2641,6 @@ mod tests {
}
}
- fn pk_candidate(
- split_index: usize,
- bucket: i32,
- file: &str,
- pos: i64,
- distance: f32,
- ) -> PkVectorCandidate {
- PkVectorCandidate {
- split_index,
- partition: BinaryRow::new(0),
- bucket,
- data_file_name: file.to_string(),
- row_position: pos,
- distance,
- }
- }
-
- #[test]
- fn candidates_to_search_result_global_row_id_and_best_first_order() {
- // Two files in one split with different first_row_id. The helper is a
pure
- // order-preserving map: the orchestrator already established
best-first
- // order upstream, so the candidate INPUT order here is deliberately
NOT in
- // score order and NOT in (file, position) order. This proves the
helper
- // preserves the given sequence rather than sorting.
- let splits = vec![pk_search_split(
- 0,
- vec![
- pk_data_file("file-a", 100, Some(1000)),
- pk_data_file("file-b", 100, Some(5000)),
- ],
- )];
- // Input sequence (NOT sorted by score, NOT sorted by file/position):
- // c0: file-b pos5 d=2.0 -> WORST distance, appears FIRST
- // c1: file-b pos1 d=1.0 -> tie with c2
- // c2: file-a pos2 d=1.0 -> tie with c1
- // A score-based best-first re-sort would produce [c1, c2, c0] (worst
last);
- // a (file, position) re-sort would produce [c2 (file-a), c1, c0]. Both
- // differ from the input order, so the exact assertion below
discriminates.
- let candidates = vec![
- pk_candidate(0, 0, "file-b", 5, 2.0),
- pk_candidate(0, 0, "file-b", 1, 1.0),
- pk_candidate(0, 0, "file-a", 2, 1.0),
- ];
- let result = candidates_to_search_result(&candidates, &splits,
VectorSearchMetric::L2)
- .expect("conversion succeeds");
- // global_row_id = first_row_id + position; INPUT order preserved (not
sorted).
- assert_eq!(result.row_ids, vec![5005, 5001, 1002]);
- assert_eq!(
- result.scores,
- vec![
- VectorSearchMetric::L2.distance_to_score(2.0),
- VectorSearchMetric::L2.distance_to_score(1.0),
- VectorSearchMetric::L2.distance_to_score(1.0),
- ]
- );
- }
-
- #[test]
- fn candidates_to_search_result_absent_first_row_id_fails_loud() {
- let splits = vec![pk_search_split(0, vec![pk_data_file("file-a", 100,
None)])];
- let candidates = vec![pk_candidate(0, 0, "file-a", 0, 1.0)];
- let err = candidates_to_search_result(&candidates, &splits,
VectorSearchMetric::L2)
- .expect_err("absent first_row_id must fail loud");
- assert!(
- matches!(err, crate::Error::DataInvalid { ref message, .. } if
message.contains("first_row_id")),
- "unexpected error: {err:?}"
- );
- }
-
/// Build a real vindex IVF-flat segment trained with `metric`, returning
the
/// serialized bytes. `nlist = 1` keeps training trivial and
deterministic; the
/// only thing the metric check cares about is the persisted metadata
metric.
@@ -2873,18 +2712,6 @@ mod tests {
);
}
- #[test]
- fn candidates_to_search_result_missing_file_fails_loud() {
- let splits = vec![pk_search_split(
- 0,
- vec![pk_data_file("known", 100, Some(0))],
- )];
- let candidates = vec![pk_candidate(0, 0, "unknown", 0, 1.0)];
- let err = candidates_to_search_result(&candidates, &splits,
VectorSearchMetric::L2)
- .expect_err("missing file must fail loud");
- assert!(matches!(err, crate::Error::DataInvalid { .. }));
- }
-
fn pk_vector_table(options: &[(&str, &str)]) -> Table {
let mut builder = Schema::builder()
.column("id", DataType::Int(IntType::new()))
@@ -2923,22 +2750,29 @@ mod tests {
}
#[tokio::test]
- async fn pk_branch_enabled_empty_plan_returns_empty() {
- // pk-vector.index.columns set, but no snapshot -> empty plan -> empty
result.
+ async fn pk_branch_execute_scored_fails_loud() {
+ // On a PK-vector table `execute_scored` reports global row ids, which
the
+ // PK path cannot produce (physical (file, position) coords, no global
ids).
+ // It must fail loud rather than fabricate ids; callers use
`execute_read`.
let table = pk_vector_table(&[
("pk-vector.index.columns", "embedding"),
("fields.embedding.pk-vector.index.type", IVF_FLAT_IDENTIFIER),
("fields.embedding.pk-vector.distance.metric", "l2"),
]);
- let result = table
+ let err = table
.new_vector_search_builder()
.with_vector_column("embedding")
.with_query_vector(vec![1.0])
.with_limit(5)
.execute_scored()
.await
- .unwrap();
- assert!(result.is_empty());
+ .map(|_| ())
+ .expect_err("execute_scored on a PK-vector column must fail loud");
+ assert!(
+ matches!(err, crate::Error::DataInvalid { ref message, .. }
+ if message.contains("does not produce global row ids")),
+ "unexpected error: {err:?}"
+ );
}
#[tokio::test]
@@ -3006,34 +2840,6 @@ mod tests {
.unwrap()
}
- #[tokio::test]
- async fn pk_branch_filter_without_deletion_vectors_fails_loud() {
- // A residual filter on a PK-vector table that does NOT enable deletion
- // vectors must be rejected (merge-on-read semantics would make
physical
- // -position filtering unsound). Mirrors Java `PrimaryKeyVectorScan`.
- let table = pk_vector_table(&[
- ("pk-vector.index.columns", "embedding"),
- ("fields.embedding.pk-vector.index.type", IVF_FLAT_IDENTIFIER),
- ("fields.embedding.pk-vector.distance.metric", "l2"),
- ]);
- let filter = id_gt_filter(&table, 2);
- let err = table
- .new_vector_search_builder()
- .with_vector_column("embedding")
- .with_query_vector(vec![1.0])
- .with_limit(5)
- .with_filter(filter)
- .execute_scored()
- .await
- .map(|_| ())
- .expect_err("filter without deletion vectors must fail loud");
- assert!(
- matches!(err, crate::Error::DataInvalid { ref message, .. }
- if message.contains("deletion vectors without merge-on-read")),
- "unexpected error: {err:?}"
- );
- }
-
#[tokio::test]
async fn execute_read_filter_without_deletion_vectors_fails_loud() {
let table = pk_vector_table(&[
@@ -3085,7 +2891,7 @@ mod tests {
}
#[tokio::test]
- async fn pk_branch_filter_with_merge_on_read_fails_loud() {
+ async fn execute_read_filter_with_merge_on_read_fails_loud() {
// Deletion vectors enabled BUT merge-on-read on: still rejected,
because a
// merge-on-read scan can surface stale key versions that a
physical-row
// filter cannot reconcile.
@@ -3103,7 +2909,7 @@ mod tests {
.with_query_vector(vec![1.0])
.with_limit(5)
.with_filter(filter)
- .execute_scored()
+ .execute_read()
.await
.map(|_| ())
.expect_err("merge-on-read filter must fail loud");
@@ -3115,10 +2921,10 @@ mod tests {
}
#[tokio::test]
- async fn pk_branch_filter_with_deletion_vectors_passes_guard() {
+ async fn execute_read_filter_with_deletion_vectors_passes_guard() {
// Deletion vectors enabled, merge-on-read off (default): the residual
guard
// passes. With no snapshot the plan is empty, so the (guarded) filter
path
- // simply yields an empty result rather than erroring — proving the
guard
+ // simply yields an empty stream rather than erroring — proving the
guard
// admits a legal filtered query.
let table = pk_vector_table(&[
("pk-vector.index.columns", "embedding"),
@@ -3127,16 +2933,16 @@ mod tests {
("deletion-vectors.enabled", "true"),
]);
let filter = id_gt_filter(&table, 2);
- let result = table
+ let mut stream = table
.new_vector_search_builder()
.with_vector_column("embedding")
.with_query_vector(vec![1.0])
.with_limit(5)
.with_filter(filter)
- .execute_scored()
+ .execute_read()
.await
.expect("guarded filter query must be admitted");
- assert!(result.is_empty());
+ assert!(stream.try_next().await.unwrap().is_none());
}
fn make_lumina_entry(
@@ -3438,9 +3244,9 @@ mod tests {
}
/// Tests for [`residual_positions_by_file`]: the residual predicate is
applied at
-/// the Arrow level (no pushdown) against the predicate columns plus `_ROW_ID`,
-/// and each surviving row's `_ROW_ID` is converted back to a file-local
physical
-/// position.
+/// the Arrow level (no pushdown) against the predicate columns, and each
surviving
+/// row's file-local physical position is recovered from its ordinal in the
+/// unfiltered scan (no `_ROW_ID`, no `first_row_id`).
#[cfg(test)]
mod residual_positions_tests {
use super::*;
@@ -3712,12 +3518,15 @@ mod residual_positions_tests {
}
#[tokio::test]
- async fn test_missing_first_row_id_is_error() {
+ async fn test_missing_first_row_id_recovers_local_positions() {
+ // Real primary-key data files carry no `first_row_id`. Positions are
+ // recovered from each row's ordinal in the scan, so the residual still
+ // works: ids [1,2,3] with id > 0 -> all match -> local positions
[0,1,2].
let (reader, split, active) =
build_reader_and_split_no_first_row_id().await;
- let err = residual_positions_by_file(&reader, &split, &active,
&residual_id_gt(0))
+ let map = residual_positions_by_file(&reader, &split, &active,
&residual_id_gt(0))
.await
- .expect_err("missing first_row_id must error");
- assert!(format!("{err:?}").contains("first_row_id"), "got: {err:?}");
+ .expect("missing first_row_id must not fail the residual read");
+ assert_eq!(sorted(&map["part-0.mosaic"]), vec![0, 1, 2]);
}
async fn build_reader_and_split_no_first_row_id(
@@ -3749,7 +3558,8 @@ mod residual_positions_tests {
vec![id_field(), row_id_field()],
Vec::new(),
);
- // The lone file is active, so the `first_row_id` guard applies to it.
+ // The lone file is active and carries no first_row_id, exercising the
+ // ordinal-based position recovery.
let active = vec![BucketActiveFile {
file_name: "part-0.mosaic".to_string(),
row_count: 3,
diff --git
a/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/bucket-0/data-932a1249-f7e0-4a03-8e1f-ab8c85cbb76f-0.parquet
b/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/bucket-0/data-932a1249-f7e0-4a03-8e1f-ab8c85cbb76f-0.parquet
new file mode 100644
index 00000000..5184c7a9
Binary files /dev/null and
b/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/bucket-0/data-932a1249-f7e0-4a03-8e1f-ab8c85cbb76f-0.parquet
differ
diff --git
a/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/bucket-0/data-fd398030-457f-4444-8d71-fffc2753689d-0.parquet
b/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/bucket-0/data-fd398030-457f-4444-8d71-fffc2753689d-0.parquet
new file mode 100644
index 00000000..5184c7a9
Binary files /dev/null and
b/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/bucket-0/data-fd398030-457f-4444-8d71-fffc2753689d-0.parquet
differ
diff --git
a/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/index/index-c2314ed5-0000-4a0d-984f-2b894d6040e8-0
b/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/index/index-c2314ed5-0000-4a0d-984f-2b894d6040e8-0
new file mode 100644
index 00000000..5ebc99c7
Binary files /dev/null and
b/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/index/index-c2314ed5-0000-4a0d-984f-2b894d6040e8-0
differ
diff --git
a/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/manifest/index-manifest-7a487b9a-2053-423a-9379-77cb67346955-0
b/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/manifest/index-manifest-7a487b9a-2053-423a-9379-77cb67346955-0
new file mode 100644
index 00000000..43190db9
Binary files /dev/null and
b/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/manifest/index-manifest-7a487b9a-2053-423a-9379-77cb67346955-0
differ
diff --git
a/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/manifest/manifest-1328438e-8b23-4aef-a6ee-e2e2c29d90df-0
b/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/manifest/manifest-1328438e-8b23-4aef-a6ee-e2e2c29d90df-0
new file mode 100644
index 00000000..28905ecd
Binary files /dev/null and
b/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/manifest/manifest-1328438e-8b23-4aef-a6ee-e2e2c29d90df-0
differ
diff --git
a/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/manifest/manifest-1328438e-8b23-4aef-a6ee-e2e2c29d90df-1
b/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/manifest/manifest-1328438e-8b23-4aef-a6ee-e2e2c29d90df-1
new file mode 100644
index 00000000..bc47a20f
Binary files /dev/null and
b/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/manifest/manifest-1328438e-8b23-4aef-a6ee-e2e2c29d90df-1
differ
diff --git
a/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/manifest/manifest-list-f5034e53-2d6f-40ae-9665-8778726154b4-0
b/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/manifest/manifest-list-f5034e53-2d6f-40ae-9665-8778726154b4-0
new file mode 100644
index 00000000..a4b65b09
Binary files /dev/null and
b/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/manifest/manifest-list-f5034e53-2d6f-40ae-9665-8778726154b4-0
differ
diff --git
a/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/manifest/manifest-list-f5034e53-2d6f-40ae-9665-8778726154b4-1
b/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/manifest/manifest-list-f5034e53-2d6f-40ae-9665-8778726154b4-1
new file mode 100644
index 00000000..b693f695
Binary files /dev/null and
b/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/manifest/manifest-list-f5034e53-2d6f-40ae-9665-8778726154b4-1
differ
diff --git
a/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/manifest/manifest-list-f5034e53-2d6f-40ae-9665-8778726154b4-2
b/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/manifest/manifest-list-f5034e53-2d6f-40ae-9665-8778726154b4-2
new file mode 100644
index 00000000..afb2da6d
Binary files /dev/null and
b/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/manifest/manifest-list-f5034e53-2d6f-40ae-9665-8778726154b4-2
differ
diff --git
a/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/manifest/manifest-list-f5034e53-2d6f-40ae-9665-8778726154b4-3
b/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/manifest/manifest-list-f5034e53-2d6f-40ae-9665-8778726154b4-3
new file mode 100644
index 00000000..b15d4384
Binary files /dev/null and
b/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/manifest/manifest-list-f5034e53-2d6f-40ae-9665-8778726154b4-3
differ
diff --git a/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/schema/schema-0
b/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/schema/schema-0
new file mode 100644
index 00000000..dfaa9639
--- /dev/null
+++ b/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/schema/schema-0
@@ -0,0 +1,32 @@
+{
+ "version" : 3,
+ "id" : 0,
+ "fields" : [ {
+ "id" : 0,
+ "name" : "id",
+ "type" : "INT NOT NULL"
+ }, {
+ "id" : 1,
+ "name" : "embedding",
+ "type" : {
+ "type" : "VECTOR",
+ "element" : "FLOAT",
+ "length" : 2
+ }
+ } ],
+ "highestFieldId" : 1,
+ "partitionKeys" : [ ],
+ "primaryKeys" : [ "id" ],
+ "options" : {
+ "bucket" : "1",
+ "ivf-flat.dimension" : "2",
+ "merge-engine" : "deduplicate",
+ "fields.embedding.pk-vector.distance.metric" : "l2",
+ "ivf-flat.nlist" : "1",
+ "fields.embedding.pk-vector.index.type" : "ivf-flat",
+ "deletion-vectors.enabled" : "true",
+ "pk-vector.index.columns" : "embedding",
+ "ivf-flat.metric" : "l2"
+ },
+ "timeMillis" : 1784359714317
+}
\ No newline at end of file
diff --git
a/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/snapshot/EARLIEST
b/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/snapshot/EARLIEST
new file mode 100644
index 00000000..56a6051c
--- /dev/null
+++ b/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/snapshot/EARLIEST
@@ -0,0 +1 @@
+1
\ No newline at end of file
diff --git a/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/snapshot/LATEST
b/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/snapshot/LATEST
new file mode 100644
index 00000000..d8263ee9
--- /dev/null
+++ b/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/snapshot/LATEST
@@ -0,0 +1 @@
+2
\ No newline at end of file
diff --git
a/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/snapshot/snapshot-1
b/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/snapshot/snapshot-1
new file mode 100644
index 00000000..b4b66746
--- /dev/null
+++ b/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/snapshot/snapshot-1
@@ -0,0 +1,16 @@
+{
+ "version" : 3,
+ "id" : 1,
+ "schemaId" : 0,
+ "baseManifestList" : "manifest-list-f5034e53-2d6f-40ae-9665-8778726154b4-0",
+ "baseManifestListSize" : 1006,
+ "deltaManifestList" : "manifest-list-f5034e53-2d6f-40ae-9665-8778726154b4-1",
+ "deltaManifestListSize" : 1110,
+ "commitUser" : "44bb4893-5f61-4e16-b206-c9223ca5ae83",
+ "commitIdentifier" : 9223372036854775807,
+ "commitKind" : "APPEND",
+ "timeMillis" : 1784359716816,
+ "totalRecordCount" : 5,
+ "deltaRecordCount" : 5,
+ "nextRowId" : 0
+}
\ No newline at end of file
diff --git
a/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/snapshot/snapshot-2
b/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/snapshot/snapshot-2
new file mode 100644
index 00000000..057eb6d6
--- /dev/null
+++ b/crates/paimon/testdata/pkvector/pk_vector_ivf_flat/snapshot/snapshot-2
@@ -0,0 +1,17 @@
+{
+ "version" : 3,
+ "id" : 2,
+ "schemaId" : 0,
+ "baseManifestList" : "manifest-list-f5034e53-2d6f-40ae-9665-8778726154b4-2",
+ "baseManifestListSize" : 1110,
+ "deltaManifestList" : "manifest-list-f5034e53-2d6f-40ae-9665-8778726154b4-3",
+ "deltaManifestListSize" : 1109,
+ "indexManifest" : "index-manifest-7a487b9a-2053-423a-9379-77cb67346955-0",
+ "commitUser" : "44bb4893-5f61-4e16-b206-c9223ca5ae83",
+ "commitIdentifier" : 9223372036854775807,
+ "commitKind" : "COMPACT",
+ "timeMillis" : 1784359716943,
+ "totalRecordCount" : 5,
+ "deltaRecordCount" : 0,
+ "nextRowId" : 0
+}
\ No newline at end of file
diff --git a/crates/paimon/tests/pk_vector_baseline_test.rs
b/crates/paimon/tests/pk_vector_baseline_test.rs
index 55b144a8..18629315 100644
--- a/crates/paimon/tests/pk_vector_baseline_test.rs
+++ b/crates/paimon/tests/pk_vector_baseline_test.rs
@@ -103,9 +103,10 @@ fn analytic_topk(query: &[f32], vectors: &[[f32; DIM]], k:
usize) -> Vec<(u64, f
scored
}
-/// Table options that route searches into the primary-key vector branch
-/// (`VectorSearchBuilder::execute_primary_key_vector_search`). Default search
-/// mode is FAST, so only the ANN segment is consulted (no exact fallback).
+/// Table options that route searches into the primary-key vector branch (the
+/// `VectorSearchBuilder` detects a primary-key table with a PK-vector index
and
+/// takes the bucket-local ANN path). Default search mode is FAST, so only the
ANN
+/// segment is consulted (no exact fallback).
///
/// `deletion-vectors.enabled = true` (and merge-on-read left at its default
/// `false`) is what makes the table expose physical rows directly, the
@@ -324,6 +325,23 @@ async fn build_table(
query: &[f32],
vectors: &[[f32; DIM]],
k: usize,
+) -> (tempfile::TempDir, Table) {
+ // Default fixture: `first_row_id = Some(0)`, so a global row id equals its
+ // physical position. Tests that must decouple the two use
+ // `build_table_with_first_row_id` directly.
+ build_table_with_first_row_id(query, vectors, k, Some(0)).await
+}
+
+/// As `build_table`, but pins the indexed data file's `first_row_id` to the
+/// caller's value. A non-zero (or absent) `first_row_id` breaks the "global
row
+/// id == physical position" coincidence, so the read path must key candidate
+/// selection, position recovery, and score alignment off the file-local
physical
+/// position rather than a global row id.
+async fn build_table_with_first_row_id(
+ query: &[f32],
+ vectors: &[[f32; DIM]],
+ k: usize,
+ first_row_id: Option<i64>,
) -> (tempfile::TempDir, Table) {
let tmp = tempfile::tempdir().expect("create temp dir");
let location = format!("file://{}", tmp.path().display());
@@ -368,12 +386,14 @@ async fn build_table(
// Constraint 1 (PrimaryKeyIndexSourcePolicy.shouldRead): only a compacted,
// non-level-0 file backs the PK-vector index. Clone the real meta and set
- // level > 0 + file_source == COMPACT (1). Pin first_row_id = 0 so the
global
- // row id equals the physical position.
+ // level > 0 + file_source == COMPACT (1). `first_row_id` is
caller-controlled:
+ // real Java PK tables never write it (row-tracking is forbidden), so a
correct
+ // read path must not depend on `first_row_id == 0` for physical-position
+ // recovery.
let indexed_meta = DataFileMeta {
level: 1,
file_source: Some(1),
- first_row_id: Some(0),
+ first_row_id,
..base_meta
};
@@ -564,31 +584,21 @@ async fn
pk_vector_end_to_end_returns_expected_row_ids_and_scores() {
let expected_row_ids: Vec<u64> = expected.iter().map(|(id, _)|
*id).collect();
let expected_scores: Vec<f32> = expected.iter().map(|(_, d)|
l2_score(*d)).collect();
- // Search path: execute_scored() -> row ids + scores.
- let result = table
+ // A primary-key vector table exposes no global row ids, so the search-only
+ // `execute_scored()` path is unsupported and must fail loud, directing
callers
+ // to the materialized `execute_read()` path exercised below.
+ let scored_err = table
.new_vector_search_builder()
.with_vector_column(VECTOR_COLUMN)
.with_query_vector(query.to_vec())
.with_limit(3)
.execute_scored()
.await
- .expect("primary-key vector search failed");
-
- assert_eq!(
- result.row_ids, expected_row_ids,
- "row ids diverge from the analytic expectation"
+ .expect_err("primary-key execute_scored must fail loud");
+ assert!(
+ format!("{scored_err:?}").contains("execute_read"),
+ "primary-key execute_scored should point at execute_read, got:
{scored_err:?}"
);
- assert_eq!(
- result.scores.len(),
- expected_scores.len(),
- "score count diverges from the analytic expectation"
- );
- for (got, want) in result.scores.iter().zip(&expected_scores) {
- assert!(
- (got - want).abs() < 1e-4,
- "score diverges from the analytic expectation: got {got}, want
{want}"
- );
- }
// Search-and-read: execute_read() materializes the matching rows
best-first
// with a `_PKEY_VECTOR_SCORE` column, hiding
`_ROW_ID`/`_PKEY_VECTOR_POSITION`.
@@ -699,6 +709,88 @@ async fn
pk_vector_read_orders_rows_best_first_not_by_position() {
}
}
+/// Shared body for the two physical-coordinate contract tests below. Builds
the
+/// discriminating fixture with the caller's `first_row_id`, reads it back
with the
+/// default projection, and asserts the materialized rows are the file-LOCAL
+/// best-first top-k (id column, vector content, aligned `_PKEY_VECTOR_SCORE`),
+/// invariant to `first_row_id`. The discriminating fixture makes best-first
order
+/// [5, 1, 3] distinct from ascending physical position [1, 3, 5], so a
+/// position/global-id confusion cannot pass by coincidence.
+#[cfg(not(windows))]
+async fn assert_discriminating_local_read(first_row_id: Option<i64>) {
+ let (query, vectors) = fixture_discriminating();
+ let (_tmp, table) = build_table_with_first_row_id(&query, &vectors, 3,
first_row_id).await;
+
+ let expected = analytic_topk(&query, &vectors, 3);
+ let expected_ids: Vec<i32> = expected.iter().map(|(id, _)| *id as
i32).collect();
+ assert_eq!(
+ expected_ids,
+ vec![5, 1, 3],
+ "fixture must produce best-first order distinct from physical position
order"
+ );
+ let expected_scores: Vec<f32> = expected.iter().map(|(_, d)|
l2_score(*d)).collect();
+
+ // Default projection: id + vector column materialize. The read path must
+ // return the correct FILE-LOCAL rows regardless of `first_row_id`.
+ let (ids, scores, batches) = read_id_and_scores(&table, query.to_vec(), 3,
None).await;
+
+ assert_eq!(
+ ids, expected_ids,
+ "materialized `id` column must be file-local best-first [5, 1, 3] and \
+ independent of the data file's first_row_id"
+ );
+
+ // Row content: each emitted row's vector equals the source vector at that
+ // file-local physical position (a global-id offset would fetch the wrong
row).
+ let got_vectors = collect_vectors(&batches);
+ assert_eq!(got_vectors.len(), 3, "three rows expected");
+ for (row_idx, (id, _)) in expected.iter().enumerate() {
+ assert_eq!(
+ got_vectors[row_idx],
+ vectors[*id as usize].to_vec(),
+ "materialized vector for row id {id} diverges from source data"
+ );
+ }
+
+ // Score alignment must also key off the file-local position.
+ assert_eq!(scores.len(), 3);
+ for (got, want) in scores.iter().zip(&expected_scores) {
+ assert!(
+ (got - want).abs() < 1e-4,
+ "materialized score diverges: got {got}, want {want}"
+ );
+ }
+}
+
+/// Physical-coordinate contract: a primary-key vector read must select rows,
+/// recover positions, and align scores by the file-LOCAL physical position,
and
+/// must NOT require the data file to carry a `first_row_id`. Real Java
primary-key
+/// tables never write `first_row_id` (row-tracking is forbidden for PK
tables), so
+/// this fixture pins the indexed data file's `first_row_id = None` — the same
+/// shape the committed Java fixture has, but over a Rust-built table with a
+/// discriminating dataset (best-first [5, 1, 3] != ascending position [1, 3,
5]).
+/// A read path that keys candidate selection or position recovery off a
global row
+/// id (rather than the file-local physical position) cannot satisfy this.
+// Gated off Windows for the same `file://` tempdir reason as the tests above.
+#[cfg(not(windows))]
+#[tokio::test]
+async fn execute_read_without_first_row_id_selects_local_positions() {
+ assert_discriminating_local_read(None).await;
+}
+
+/// Regression pin for the same physical-coordinate contract with a present but
+/// deliberately NON-aligned `first_row_id = Some(100)`: recovering the
file-local
+/// physical position must not be offset by `first_row_id`. This holds on the
+/// current code (the global-range round-trip is symmetric) and must keep
holding
+/// after the read path switches to file-local coordinates, so a fix that reads
+/// local positions yet leaves a stray `first_row_id` offset would surface
here.
+// Gated off Windows for the same `file://` tempdir reason as the tests above.
+#[cfg(not(windows))]
+#[tokio::test]
+async fn execute_read_ignores_nonzero_first_row_id() {
+ assert_discriminating_local_read(Some(100)).await;
+}
+
/// Fixture #3 (residual): the unrestricted nearest neighbours sit at low ids
/// (0, 1, 2), but the residual predicate `id >= 3` excludes exactly those, so
/// the residual result set is disjoint from the unfiltered one. Among the rows
@@ -831,22 +923,23 @@ async fn
pk_vector_residual_filter_excludes_non_matching_rows() {
.greater_or_equal("id", Datum::Int(residual_threshold as i32))
.expect("build residual predicate on id");
- // Search-only: unfiltered vs residual must differ, and every residual hit
- // must satisfy the predicate (id >= 3), disjoint from the unfiltered set.
- let unfiltered_result = table
+ // A primary-key vector table exposes no global row ids, so
`execute_scored()`
+ // is unsupported on this path — with or without a residual filter — and
must
+ // fail loud, directing callers to the materialized `execute_read()` used
below.
+ let unfiltered_err = table
.new_vector_search_builder()
.with_vector_column(VECTOR_COLUMN)
.with_query_vector(query.to_vec())
.with_limit(3)
.execute_scored()
.await
- .expect("unfiltered primary-key vector search failed");
- assert_eq!(
- unfiltered_result.row_ids, unfiltered_ids,
- "unfiltered search must return [0, 1, 2]"
+ .expect_err("primary-key execute_scored must fail loud");
+ assert!(
+ format!("{unfiltered_err:?}").contains("execute_read"),
+ "got: {unfiltered_err:?}"
);
- let residual_result = table
+ let residual_scored_err = table
.new_vector_search_builder()
.with_vector_column(VECTOR_COLUMN)
.with_query_vector(query.to_vec())
@@ -854,22 +947,11 @@ async fn
pk_vector_residual_filter_excludes_non_matching_rows() {
.with_filter(residual.clone())
.execute_scored()
.await
- .expect("residual primary-key vector search failed");
- let residual_row_ids: Vec<u64> = expected_ids.iter().map(|&id| id as
u64).collect();
- assert_eq!(
- residual_result.row_ids, residual_row_ids,
- "residual search must return best-first [4, 5, 3]"
- );
- assert_ne!(
- residual_result.row_ids, unfiltered_result.row_ids,
- "residual must change the result set relative to no filter"
+ .expect_err("primary-key execute_scored must fail loud with a residual
too");
+ assert!(
+ format!("{residual_scored_err:?}").contains("execute_read"),
+ "got: {residual_scored_err:?}"
);
- for &id in &residual_result.row_ids {
- assert!(
- id >= residual_threshold,
- "residual search returned id {id} that fails the predicate id >=
{residual_threshold}"
- );
- }
// Search-and-read with the residual: default projection materializes id +
// vector column, best-first, with an aligned `_PKEY_VECTOR_SCORE`.
diff --git a/crates/paimon/tests/pk_vector_java_fixture_test.rs
b/crates/paimon/tests/pk_vector_java_fixture_test.rs
new file mode 100644
index 00000000..20384ebd
--- /dev/null
+++ b/crates/paimon/tests/pk_vector_java_fixture_test.rs
@@ -0,0 +1,233 @@
+// 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.
+
+//! Cross-language read-back of a primary-key vector table WRITTEN BY JAVA.
+//!
+//! Unlike `pk_vector_baseline_test` (which builds its table entirely on the
Rust
+//! write path), this test opens a table directory produced by Apache Paimon's
+//! Java writer with the production `ivf-flat` primary-key vector indexer, and
+//! asserts Rust reads + searches it correctly. It validates the cross-language
+//! contract at the table-metadata layer (snapshot / manifest / data-file
+//! `level`+`file_source` / `GlobalIndexMeta` / the `PkVectorSourceMeta`
frame);
+//! the ANN segment interior is the same native core on both sides
+//! (`paimon-vindex-core` <-> its JNI wrapper), so a divergence here would be a
+//! real metadata-parse bug, not a fixture artifact.
+//!
+//! Physical-position contract: Java never writes `first_row_id` on a
primary-key
+//! table (row-tracking is forbidden for PK tables), so the committed data
files
+//! below carry NO `first_row_id`. Row identity is therefore physical
+//! `(file, position)`, not a global row id. Two consequences the assertions
pin:
+//! * `execute_scored()` — which reports global row ids — MUST fail on this
+//! table, because a global row id cannot be recovered without
`first_row_id`.
+//! * `execute_read()` — which materializes rows by physical position — MUST
+//! succeed and return the rows best-first.
+//!
+//! Provenance of `testdata/pkvector/pk_vector_ivf_flat` (opaque binary table
+//! directory, regenerate rather than hand-edit):
+//! * Source: Apache Paimon Java, module `paimon-vector`, commit `7234e4c34`.
+//! * Generator: `PkVectorFixtureGenerator`.
+//! * Command: `mvn -pl paimon-vector test -Dtest=PkVectorFixtureGenerator \
+//! -Dgen.pkvector.fixture=true -Drun.e2e.tests=true`.
+//! * Config: primary key `id`, vector column `embedding`, `ivf-flat`,
+//! `nlist = 1` (exact, deterministic single inverted list), `deduplicate`
+//! merge engine, deletion-vectors enabled.
+//! * Rows: `id == row position`, vectors `[0,0] [1,0] [2,0] [3,0] [4,0]`.
+//! * Query `[0, 0]`, squared-L2 distances `[0, 1, 4, 9, 16]`; top-3 -> ids
+//! `[0, 1, 2]`, distances `[0, 1, 4]`, scores `1/(1+d) = [1.0, 0.5, 0.2]`.
+//! * Fixture tree checksum: `f6c21a447fa7be880713c3d1c27791e7dcb1db10`.
+
+use std::path::Path;
+
+use arrow_array::{Array, Float32Array, Int32Array, RecordBatch};
+use futures::TryStreamExt;
+use paimon::catalog::Identifier;
+use paimon::io::{FileIO, FileIOBuilder};
+use paimon::table::{SchemaManager, Table};
+
+const VECTOR_COLUMN: &str = "embedding";
+const FIXTURE: &str = "testdata/pkvector/pk_vector_ivf_flat";
+
+/// The exact vectors the Java generator wrote, `id == row position`. Query is
+/// `[0, 0]`; squared-L2 distances are the squared norms `[0, 1, 4, 9, 16]`.
+const VECTORS: &[[f32; 2]] = &[[0.0, 0.0], [1.0, 0.0], [2.0, 0.0], [3.0, 0.0],
[4.0, 0.0]];
+
+fn l2_score(distance: f32) -> f32 {
+ 1.0 / (1.0 + distance)
+}
+
+/// Brute-force exact squared-L2 top-k over the fixture rows -> `(position,
distance)`
+/// ascending by distance. `position == vector index == id` because the fixture
+/// pins `id == row position`.
+fn analytic_topk(query: &[f32], k: usize) -> Vec<(u64, f32)> {
+ let mut scored: Vec<(u64, f32)> = VECTORS
+ .iter()
+ .enumerate()
+ .map(|(pos, v)| {
+ let dist: f32 = v
+ .iter()
+ .zip(query.iter())
+ .map(|(a, b)| (a - b) * (a - b))
+ .sum();
+ (pos as u64, dist)
+ })
+ .collect();
+ scored.sort_by(|a, b| a.1.total_cmp(&b.1));
+ scored.truncate(k);
+ scored
+}
+
+/// Open the committed Java-written table directory. Copies the fixture into a
+/// fresh temp dir first so the read path has a private `file://` root (mirrors
+/// how `global_index_scanner.rs` fixtures stage into a temp dir), and so a
stray
+/// write during read can never mutate the committed testdata.
+async fn open_java_fixture() -> (tempfile::TempDir, Table) {
+ let manifest_dir = env!("CARGO_MANIFEST_DIR");
+ let src = Path::new(manifest_dir).join(FIXTURE);
+ let tmp = tempfile::tempdir().expect("create temp dir");
+ let dst = tmp.path().join("pk_vector_ivf_flat");
+ copy_dir(&src, &dst);
+
+ let location = format!("file://{}", dst.display());
+ let file_io: FileIO = FileIOBuilder::new("file").build().expect("build fs
FileIO");
+ let schema = SchemaManager::new(file_io.clone(), location.clone())
+ .latest()
+ .await
+ .expect("failed to list schemas")
+ .expect("fixture table has no schema");
+ let table = Table::new(
+ file_io,
+ Identifier::new("default", "pk_vector_ivf_flat"),
+ location,
+ (*schema).clone(),
+ None,
+ );
+ (tmp, table)
+}
+
+fn copy_dir(src: &Path, dst: &Path) {
+ std::fs::create_dir_all(dst).unwrap();
+ for entry in std::fs::read_dir(src).unwrap() {
+ let entry = entry.unwrap();
+ let from = entry.path();
+ let to = dst.join(entry.file_name());
+ if from.is_dir() {
+ copy_dir(&from, &to);
+ } else {
+ std::fs::copy(&from, &to).unwrap();
+ }
+ }
+}
+
+fn batch_i32(batches: &[RecordBatch], col: &str) -> Vec<i32> {
+ batches
+ .iter()
+ .flat_map(|b| {
+ let idx = b.schema().index_of(col).unwrap();
+ b.column(idx)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .values()
+ .to_vec()
+ })
+ .collect()
+}
+
+fn batch_f32(batches: &[RecordBatch], col: &str) -> Vec<f32> {
+ batches
+ .iter()
+ .flat_map(|b| {
+ let idx = b.schema().index_of(col).unwrap();
+ b.column(idx)
+ .as_any()
+ .downcast_ref::<Float32Array>()
+ .unwrap()
+ .values()
+ .to_vec()
+ })
+ .collect()
+}
+
+// Gated off Windows: the fixture is opened via a `file://` URL built from a
+// tempdir path, matching how the sibling `pk_vector_baseline_test` and
+// `rest_catalog_test` gate their `file://` tempdir tests.
+#[cfg(not(target_os = "windows"))]
+#[tokio::test]
+async fn reads_back_java_written_pk_vector_table() {
+ let (_tmp, table) = open_java_fixture().await;
+ let query = vec![0.0f32, 0.0];
+ let k = 3;
+
+ let expected = analytic_topk(&query, k);
+ let expected_ids: Vec<i32> = expected.iter().map(|(id, _)| *id as
i32).collect();
+ let expected_scores: Vec<f32> = expected.iter().map(|(_, d)|
l2_score(*d)).collect();
+ // Guard against silent drift of the analytic ground truth.
+ assert_eq!(
+ expected_ids,
+ vec![0, 1, 2],
+ "fixture top-3 ids must be [0, 1, 2]"
+ );
+
+ // execute_scored() reports global row ids. On a Java-written PK table the
data
+ // files carry no `first_row_id`, so a global row id is unrecoverable and
the
+ // scored path MUST fail loudly rather than fabricate ids.
+ let scored = table
+ .new_vector_search_builder()
+ .with_vector_column(VECTOR_COLUMN)
+ .with_query_vector(query.clone())
+ .with_limit(k)
+ .execute_scored()
+ .await;
+ assert!(
+ scored.is_err(),
+ "execute_scored() must fail on a primary-key vector table: global row
ids \
+ are unavailable when the data files carry no first_row_id"
+ );
+
+ // execute_read() materializes rows by physical position, so it MUST
succeed
+ // and emit the top-k best-first. The `id` column cross-checks the
+ // position->id mapping (the fixture pins them equal) and
`_PKEY_VECTOR_SCORE`
+ // carries the metric score.
+ let mut builder = table.new_vector_search_builder();
+ builder
+ .with_vector_column(VECTOR_COLUMN)
+ .with_query_vector(query)
+ .with_limit(k)
+ .with_projection(&["id"]);
+ let batches = builder
+ .execute_read()
+ .await
+ .expect("primary-key vector read over the Java fixture failed")
+ .try_collect::<Vec<_>>()
+ .await
+ .expect("collecting read batches failed");
+
+ let ids = batch_i32(&batches, "id");
+ assert_eq!(
+ ids, expected_ids,
+ "materialized `id` column must be best-first and match the analytic
top-k"
+ );
+
+ let scores = batch_f32(&batches, "_PKEY_VECTOR_SCORE");
+ assert_eq!(scores.len(), k);
+ for (got, want) in scores.iter().zip(&expected_scores) {
+ assert!(
+ (got - want).abs() < 1e-4,
+ "materialized score diverges: got {got}, want {want}"
+ );
+ }
+}