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 8aaed4e2 feat: plan source-backed primary-key sorted indexes (#1086)
8aaed4e2 is described below
commit 8aaed4e20823d1b9434a8ca3f0f715466f52f035
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Oct 9 19:54:59 2026 +0800
feat: plan source-backed primary-key sorted indexes (#1086)
---
crates/paimon/src/table/data_file_reader.rs | 26 +-
crates/paimon/src/table/global_index_scanner.rs | 15 +-
.../src/table/global_index_scanner/row_ranges.rs | 2 +-
crates/paimon/src/table/kv_file_reader.rs | 6 +-
crates/paimon/src/table/mod.rs | 1 +
crates/paimon/src/table/pk_sorted_index_scan.rs | 511 ++++++++++++++
.../paimon/src/table/pk_sorted_index_scan/tests.rs | 743 +++++++++++++++++++++
crates/paimon/src/table/source.rs | 27 +-
crates/paimon/src/table/table_scan.rs | 101 ++-
9 files changed, 1406 insertions(+), 26 deletions(-)
diff --git a/crates/paimon/src/table/data_file_reader.rs
b/crates/paimon/src/table/data_file_reader.rs
index 4c32b659..1b4ac987 100644
--- a/crates/paimon/src/table/data_file_reader.rs
+++ b/crates/paimon/src/table/data_file_reader.rs
@@ -486,34 +486,30 @@ impl DataFileReader {
)
}
- /// Read one file with the complete physical data schema supplied by a
- /// caller such as the KV reader. The format chooses whether it needs the
- /// full schema or only the projected fields.
- pub(super) fn read_single_file_stream_with_schema(
+ /// Read one file with the complete physical data schema and optional
+ /// file-local row positions, independent of stable row IDs. The format
+ /// chooses whether it needs the full schema or only the projected fields.
+ pub(super) fn read_single_file_stream_with_schema_and_local_ranges(
&self,
split: &DataSplit,
file_meta: DataFileMeta,
data_fields: Option<Vec<DataField>>,
data_schema_fields: Vec<DataField>,
dv: Option<Arc<DeletionVector>>,
- row_ranges: Option<Vec<RowRange>>,
+ local_row_ranges: Option<Vec<RowRange>>,
) -> crate::Result<ArrowRecordBatchStream> {
- let local_ranges = row_ranges.as_ref().map(|ranges| {
- to_local_row_ranges(
- ranges,
- file_meta.first_row_id.unwrap_or(0),
- file_meta.row_count,
- )
- });
- let row_selection =
- merge_row_selection(file_meta.row_count, dv.as_deref(),
local_ranges.as_deref());
+ let row_selection = merge_row_selection(
+ file_meta.row_count,
+ dv.as_deref(),
+ local_row_ranges.as_deref(),
+ );
self.read_single_file_stream_with_selection(
split,
file_meta,
data_fields,
Some(data_schema_fields),
row_selection,
- local_ranges,
+ local_row_ranges,
)
}
diff --git a/crates/paimon/src/table/global_index_scanner.rs
b/crates/paimon/src/table/global_index_scanner.rs
index 008b323a..b9f7f058 100644
--- a/crates/paimon/src/table/global_index_scanner.rs
+++ b/crates/paimon/src/table/global_index_scanner.rs
@@ -32,6 +32,7 @@ mod row_ranges;
pub(crate) use deletion_vectors::deleted_row_ranges_for_data_evolution_dvs;
use entry::{validate_fm_file_sets, GlobalIndexEntry, GlobalIndexEntryMeta,
GlobalIndexFileKind};
+pub(super) use row_ranges::intersect_sorted_ranges;
use row_ranges::unindexed_ranges_for_indexed_coverage;
pub(crate) use row_ranges::{
search_limit_with_deleted_rows, unindexed_ranges_for_global_index_entries,
RowRangeIndex,
@@ -168,7 +169,7 @@ impl GlobalIndexScanner {
}
#[allow(clippy::too_many_arguments)]
- fn create_with_fm_options(
+ pub(super) fn create_with_fm_options(
file_io: &FileIO,
table_path: &str,
global_index_thread_num: usize,
@@ -364,6 +365,18 @@ impl GlobalIndexScanner {
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))),
)
}
+
+ /// Matching positions without data-evolution's unindexed row-id extension.
+ /// Source-backed PK indexes cover a validated immutable source group.
+ pub(super) async fn matching_ranges(
+ &self,
+ predicate: &Predicate,
+ ) -> Result<Option<Vec<RowRange>>> {
+ Ok(self
+ .evaluate(predicate)
+ .await?
+ .map(|result| result.row_ranges))
+ }
}
/// Create a GlobalIndexScanner and evaluate predicates, returning row ranges.
diff --git a/crates/paimon/src/table/global_index_scanner/row_ranges.rs
b/crates/paimon/src/table/global_index_scanner/row_ranges.rs
index a306d88a..3c4c7f12 100644
--- a/crates/paimon/src/table/global_index_scanner/row_ranges.rs
+++ b/crates/paimon/src/table/global_index_scanner/row_ranges.rs
@@ -46,7 +46,7 @@ pub(super) fn bitmap_to_ranges(bitmap: &RoaringTreemap) ->
Vec<RowRange> {
}
/// Intersect two sorted range lists using RowRangeIndex for efficient binary
search.
-pub(super) fn intersect_sorted_ranges(a: &[RowRange], b: &[RowRange]) ->
Vec<RowRange> {
+pub(crate) fn intersect_sorted_ranges(a: &[RowRange], b: &[RowRange]) ->
Vec<RowRange> {
let idx = RowRangeIndex::from_sorted_ranges(a.to_vec());
let mut result = Vec::new();
for r in b {
diff --git a/crates/paimon/src/table/kv_file_reader.rs
b/crates/paimon/src/table/kv_file_reader.rs
index cbe1aa6c..a9d9cdf6 100644
--- a/crates/paimon/src/table/kv_file_reader.rs
+++ b/crates/paimon/src/table/kv_file_reader.rs
@@ -717,7 +717,7 @@ impl KeyValueFileReader {
// Only the PK-only predicate projection may
run before
// sort-merge. A value index can match an old
version of
// a key, so it must not prune merge inputs
here.
- let row_ranges = if file_index_read_enabled {
+ let local_row_ranges = if
file_index_read_enabled {
match evaluate_file_index(
&run_file_io,
split.bucket_path(),
@@ -750,13 +750,13 @@ impl KeyValueFileReader {
)),
None => None,
};
- let mut file_stream =
reader.read_single_file_stream_with_schema(
+ let mut file_stream =
reader.read_single_file_stream_with_schema_and_local_ranges(
split.as_ref(),
file_meta,
data_fields,
data_schema_fields,
deletion_vector,
- row_ranges,
+ local_row_ranges,
)?;
while let Some(batch) =
file_stream.next().await {
yield batch?;
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 443f4848..5c577881 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -108,6 +108,7 @@ mod pk_full_text_read;
mod pk_full_text_scan;
mod pk_search_position;
mod pk_search_ranker;
+mod pk_sorted_index_scan;
mod pk_vector_bucket_split;
mod pk_vector_data_file_reader;
mod pk_vector_indexed_split_read;
diff --git a/crates/paimon/src/table/pk_sorted_index_scan.rs
b/crates/paimon/src/table/pk_sorted_index_scan.rs
new file mode 100644
index 00000000..75a17ab2
--- /dev/null
+++ b/crates/paimon/src/table/pk_sorted_index_scan.rs
@@ -0,0 +1,511 @@
+// 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.
+
+//! Source-backed BTREE/BITMAP planning for batch primary-key reads.
+//! Mirrors Java PrimaryKeySortedIndexScan and PkSortedBucketIndexState:
+//! immutable compacted files may use physical positions; merge inputs stay
whole.
+
+use super::global_index_scanner::{intersect_sorted_ranges, GlobalIndexScanner};
+use super::index_file_path::IndexFileLocation;
+use super::{merge_row_ranges, DataSplit, RowRange, Table};
+use crate::spec::{
+ should_read_pk_index_source, CoreOptions, DataField, DataFileMeta,
FileKind,
+ IndexManifestEntry, Predicate, PrimaryKeyIndexSourceMeta,
+};
+use crate::{Error, Result};
+use std::collections::{BTreeMap, HashMap, HashSet};
+use std::future::Future;
+use std::pin::Pin;
+
+const MAX_POSITION_RANGES: usize = 4096;
+
+type Options = HashMap<String, String>;
+type SourceKey = (Vec<u8>, i32, String, i32);
+type SourceGroups = HashMap<SourceKey, (usize, RowRange)>;
+type EvaluateFuture<'a> = Pin<Box<dyn Future<Output =
Result<Option<Vec<RowRange>>>> + Send + 'a>>;
+
+pub(super) struct Definition {
+ field_id: i32,
+ index_type: &'static str,
+ options: Options,
+}
+
+pub(super) fn definitions(fields: &[DataField], options: &Options) ->
Result<Vec<Definition>> {
+ let mut owners = HashMap::new();
+ for family in ["btree", "bitmap", "vector", "full-text", "multivalue",
"fm"] {
+ let key = format!("pk-{family}.index.columns");
+ if let Some(columns) = options.get(&key) {
+ let mut unique = HashSet::new();
+ for column in columns.split(',').map(str::trim) {
+ if !unique.insert(column) {
+ return Err(config_invalid(format!(
+ "{key} contains duplicate column '{column}'"
+ )));
+ }
+ if owners.insert(column, family).is_some() {
+ return Err(config_invalid(format!(
+ "Column '{column}' can own at most one primary-key
index"
+ )));
+ }
+ }
+ }
+ }
+ let mut result = Vec::new();
+ for field in fields {
+ let Some(&family @ ("btree" | "bitmap")) = owners.get(field.name())
else {
+ continue;
+ };
+ result.push(Definition {
+ field_id: field.id(),
+ index_type: family,
+ options: definition_options(options, field.name(), family)?,
+ });
+ }
+ Ok(result)
+}
+
+fn config_invalid(message: String) -> Error {
+ Error::ConfigInvalid { message }
+}
+
+fn definition_options(options: &Options, column: &str, family: &str) ->
Result<Options> {
+ let mut resolved = options.clone();
+ resolved.remove("sorted-index.records-per-file");
+ resolved.remove("sorted-index.records-per-range");
+ let option_key = format!("fields.{column}.pk-{family}.index.options");
+ let Some(raw) = options
+ .get(&option_key)
+ .filter(|raw| !raw.trim().is_empty())
+ else {
+ return Ok(resolved);
+ };
+ let parsed: serde_json::Map<String, serde_json::Value> =
+ serde_json::from_str(raw).map_err(|_| {
+ config_invalid(format!(
+ "{option_key} must be a JSON object of option key-value pairs"
+ ))
+ })?;
+ let prefix = format!("{family}-index.");
+ for (key, value) in parsed {
+ if key.trim().is_empty() {
+ return Err(config_invalid(format!(
+ "{option_key} contains an empty option key"
+ )));
+ }
+ let value = match value {
+ serde_json::Value::String(value) => value,
+ serde_json::Value::Number(_) | serde_json::Value::Bool(_) =>
value.to_string(),
+ _ => {
+ return Err(config_invalid(format!(
+ "{option_key} contains an invalid option value for {key}"
+ )))
+ }
+ };
+ let qualified = if key.starts_with(&prefix) ||
key.starts_with("fields.") {
+ key
+ } else {
+ format!("{prefix}{key}")
+ };
+ if resolved
+ .get(&qualified)
+ .is_some_and(|previous| previous != &value)
+ {
+ return Err(config_invalid(format!(
+ "{option_key} defines conflicting values for {qualified}"
+ )));
+ }
+ resolved.insert(qualified, value);
+ }
+ Ok(resolved)
+}
+
+struct Group {
+ entry: IndexManifestEntry,
+ definition: usize,
+ row_count: i64,
+ scanner: Option<GlobalIndexScanner>,
+ opened: bool,
+ // Shared across all active source files: each query reads the payload
once.
+ queries: Vec<(Predicate, Option<Vec<RowRange>>)>,
+}
+
+struct Candidate {
+ entry: IndexManifestEntry,
+ row_count: i64,
+ active_sources: Vec<(String, RowRange)>,
+}
+
+fn candidate(entry: &IndexManifestEntry, active: &[&DataFileMeta]) ->
Option<(i32, Candidate)> {
+ let meta = entry.index_file.global_index_meta.as_ref()?;
+ if meta
+ .extra_field_ids
+ .as_ref()
+ .is_some_and(|ids| !ids.is_empty())
+ {
+ return None;
+ }
+ let source = PrimaryKeyIndexSourceMeta::from_global_index_meta(meta).ok()?;
+ let active: HashMap<_, _> = active
+ .iter()
+ .filter(|file| file.level == source.data_level() &&
should_read_pk_index_source(file))
+ .map(|file| (file.file_name.as_str(), file.row_count))
+ .collect();
+ let mut previous: Option<&str> = None;
+ let mut offset = 0i64;
+ let mut active_sources = Vec::new();
+ for file in source.source_files() {
+ if previous.is_some_and(|name| name >= file.file_name()) {
+ return None;
+ }
+ previous = Some(file.file_name());
+ let end = offset.checked_add(file.row_count())?;
+ if let Some(&rows) = active.get(file.file_name()) {
+ if rows != file.row_count() {
+ return None;
+ }
+ if end > offset {
+ active_sources.push((file.file_name().to_string(),
RowRange::new(offset, end - 1)));
+ }
+ }
+ offset = end;
+ }
+ if active_sources.is_empty()
+ || offset <= 0
+ || meta.row_range_start != 0
+ || meta.row_range_end != offset - 1
+ || entry.index_file.row_count != offset
+ {
+ return None;
+ }
+ Some((
+ source.data_level(),
+ Candidate {
+ entry: entry.clone(),
+ row_count: offset,
+ active_sources,
+ },
+ ))
+}
+
+fn plan_groups(
+ table: &Table,
+ splits: &[DataSplit],
+ entries: &[IndexManifestEntry],
+ definitions: &[Definition],
+) -> (Vec<Group>, SourceGroups) {
+ let mut buckets: BTreeMap<_, (Vec<&DataFileMeta>, &str)> = BTreeMap::new();
+ for split in splits {
+ let (files, _) = buckets
+ .entry((split.partition().to_serialized_bytes(), split.bucket()))
+ .or_insert_with(|| (Vec::new(), split.bucket_path()));
+ files.extend(split.data_files());
+ }
+ let mut entries_by_bucket: HashMap<_, Vec<_>> = HashMap::new();
+ for entry in entries.iter().filter(|entry| entry.kind == FileKind::Add) {
+ entries_by_bucket
+ .entry((entry.partition.as_slice(), entry.bucket))
+ .or_default()
+ .push(entry);
+ }
+ let mut groups = Vec::new();
+ let mut sources = HashMap::new();
+ for ((partition, bucket), (files, bucket_path)) in buckets {
+ let location = IndexFileLocation::BucketLocal {
+ table_path: table.location().trim_end_matches('/'),
+ bucket_path,
+ index_file_in_data_file_dir: table
+ .schema()
+ .core_options()
+ .index_file_in_data_file_dir(),
+ };
+ for (definition_index, definition) in definitions.iter().enumerate() {
+ let mut levels: BTreeMap<i32, Vec<Candidate>> = BTreeMap::new();
+ for entry in entries_by_bucket
+ .get(&(partition.as_slice(), bucket))
+ .into_iter()
+ .flatten()
+ {
+ if entry.index_file.index_type != definition.index_type
+ || entry
+ .index_file
+ .global_index_meta
+ .as_ref()
+ .is_none_or(|meta| meta.index_field_id !=
definition.field_id)
+ {
+ continue;
+ }
+ if let Some((level, candidate)) = candidate(entry, &files) {
+ levels.entry(level).or_default().push(candidate);
+ }
+ }
+ for candidates in levels.into_values() {
+ // Java rejects ambiguous payload coverage at the same level.
+ if candidates.len() != 1 {
+ continue;
+ }
+ let Candidate {
+ mut entry,
+ row_count,
+ active_sources,
+ } = candidates.into_iter().next().unwrap();
+ entry.index_file.external_path = Some(location.resolve(
+ &entry.index_file.file_name,
+ entry.index_file.external_path.as_deref(),
+ ));
+ let group_id = groups.len();
+ for (file, range) in active_sources {
+ sources.insert(
+ (partition.clone(), bucket, file, definition.field_id),
+ (group_id, range),
+ );
+ }
+ groups.push(Group {
+ entry,
+ definition: definition_index,
+ row_count,
+ scanner: None,
+ opened: false,
+ queries: Vec::new(),
+ });
+ }
+ }
+ }
+ (groups, sources)
+}
+
+impl Group {
+ async fn query(
+ &mut self,
+ table: &Table,
+ definition: &Definition,
+ predicate: &Predicate,
+ ) -> Result<Option<Vec<RowRange>>> {
+ if let Some((_, result)) = self.queries.iter().find(|(query, _)| query
== predicate) {
+ return Ok(result.clone());
+ }
+ let result = self.evaluate(table, definition, predicate).await;
+ let ranges = match result {
+ Ok(Some(ranges))
+ if ranges
+ .iter()
+ .all(|range| range.from() >= 0 && range.to() <
self.row_count) =>
+ {
+ Some(ranges)
+ }
+ Ok(_) => None,
+ Err(error) => {
+ log::warn!(
+ "Ignoring source-backed PK index '{}': {error}",
+ self.entry.index_file.file_name
+ );
+ None
+ }
+ };
+ self.queries.push((predicate.clone(), ranges.clone()));
+ Ok(ranges)
+ }
+
+ async fn evaluate(
+ &mut self,
+ table: &Table,
+ definition: &Definition,
+ predicate: &Predicate,
+ ) -> Result<Option<Vec<RowRange>>> {
+ if !self.opened {
+ self.opened = true;
+ let options = CoreOptions::new(&definition.options);
+ self.scanner = GlobalIndexScanner::create_with_fm_options(
+ table.file_io(),
+ table.location(),
+ options.global_index_thread_num()?,
+ options.btree_index_fallback_scan_max_size()?,
+ options.btree_index_data_block_cache_size()?,
+ options.bitmap_index_fallback_scan_max_size()?,
+ std::slice::from_ref(&self.entry),
+ table.schema().fields(),
+ crate::fm_index::FMReadOptions::default(),
+ )?;
+ }
+ match &self.scanner {
+ Some(scanner) => scanner.matching_ranges(predicate).await,
+ None => Ok(None),
+ }
+ }
+}
+
+struct Evaluation<'a> {
+ table: &'a Table,
+ definitions: &'a [Definition],
+ groups: Vec<Group>,
+ sources: SourceGroups,
+}
+
+impl Evaluation<'_> {
+ fn evaluate<'a>(
+ &'a mut self,
+ split: &'a DataSplit,
+ file: &'a DataFileMeta,
+ predicate: &'a Predicate,
+ ) -> EvaluateFuture<'a> {
+ Box::pin(async move {
+ match predicate {
+ Predicate::Leaf { column, .. } => {
+ self.evaluate_field(split, file, column, predicate).await
+ }
+ Predicate::And(_) => {
+ let mut result: Option<Vec<RowRange>> = None;
+ let mut fields: BTreeMap<String, Vec<Predicate>> =
BTreeMap::new();
+ let mut other = Vec::new();
+ for child in predicate.clone().split_and() {
+ if let Predicate::Leaf { column, .. } = &child {
+
fields.entry(column.clone()).or_default().push(child);
+ } else {
+ other.push(child);
+ }
+ }
+ // Same-field bounds must reach the scalar scanner
together:
+ // it combines BETWEEN and reuses a single opened reader.
+ for (column, predicates) in fields {
+ let combined = Predicate::and(predicates);
+ if let Some(ranges) =
+ self.evaluate_field(split, file, &column,
&combined).await?
+ {
+ result = Some(match result {
+ None => ranges,
+ Some(previous) =>
intersect_sorted_ranges(&previous, &ranges),
+ });
+ }
+ }
+ for child in &other {
+ if let Some(ranges) = self.evaluate(split, file,
child).await? {
+ result = Some(match result {
+ None => ranges,
+ Some(previous) =>
intersect_sorted_ranges(&previous, &ranges),
+ });
+ }
+ }
+ Ok(result)
+ }
+ Predicate::Or(children) if !children.is_empty() => {
+ let mut result = Vec::new();
+ for child in children {
+ let Some(ranges) = self.evaluate(split, file,
child).await? else {
+ return Ok(None);
+ };
+ result.extend(ranges);
+ }
+ Ok(Some(merge_row_ranges(result)))
+ }
+ _ => Ok(None),
+ }
+ })
+ }
+ async fn evaluate_field(
+ &mut self,
+ split: &DataSplit,
+ file: &DataFileMeta,
+ column: &str,
+ predicate: &Predicate,
+ ) -> Result<Option<Vec<RowRange>>> {
+ let Some(field) = self
+ .table
+ .schema()
+ .fields()
+ .iter()
+ .find(|field| field.name() == column)
+ else {
+ return Ok(None);
+ };
+ let key = (
+ split.partition().to_serialized_bytes(),
+ split.bucket(),
+ file.file_name.clone(),
+ field.id(),
+ );
+ let Some((group_id, range)) = self.sources.get(&key).cloned() else {
+ return Ok(None);
+ };
+ let group = &mut self.groups[group_id];
+ let definition = &self.definitions[group.definition];
+ Ok(group
+ .query(self.table, definition, predicate)
+ .await?
+ .map(|ranges| localize(&ranges, range)))
+ }
+}
+
+fn localize(ranges: &[RowRange], source: RowRange) -> Vec<RowRange> {
+ let start = ranges.partition_point(|range| range.to() < source.from());
+ ranges[start..]
+ .iter()
+ .take_while(|range| range.from() <= source.to())
+ .filter_map(|range| {
+ let from = range.from().max(source.from());
+ let to = range.to().min(source.to());
+ (from <= to).then(|| RowRange::new(from - source.from(), to -
source.from()))
+ })
+ .collect()
+}
+
+pub(super) async fn refine(
+ table: &Table,
+ splits: Vec<DataSplit>,
+ entries: &[IndexManifestEntry],
+ definitions: &[Definition],
+ predicates: &[Predicate],
+) -> Result<Vec<DataSplit>> {
+ let (groups, sources) = plan_groups(table, &splits, entries, definitions);
+ if groups.is_empty() {
+ return Ok(splits);
+ }
+ let mut evaluation = Evaluation {
+ table,
+ definitions,
+ groups,
+ sources,
+ };
+ let predicate = Predicate::and(predicates.to_vec());
+ let mut result = Vec::new();
+ for split in splits {
+ if !split.raw_convertible() || split.is_streaming() ||
split.row_ranges().is_some() {
+ result.push(split);
+ continue;
+ }
+ for (file_index, file) in split.data_files().iter().enumerate() {
+ let ranges = evaluation.evaluate(&split, file, &predicate).await?;
+ if ranges.as_ref().is_some_and(Vec::is_empty) {
+ continue;
+ }
+ let mut selected = split.for_pk_index_file(file_index);
+ if let Some(ranges) = ranges.filter(|ranges| {
+ ranges.len() <= MAX_POSITION_RANGES
+ && ranges.iter().all(|range| {
+ range.from() >= 0
+ && range.to() < file.row_count
+ && range.to() <= i64::from(i32::MAX)
+ })
+ }) {
+ selected = selected.with_selected_row_ranges(ranges);
+ }
+ result.push(selected);
+ }
+ }
+ Ok(result)
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/crates/paimon/src/table/pk_sorted_index_scan/tests.rs
b/crates/paimon/src/table/pk_sorted_index_scan/tests.rs
new file mode 100644
index 00000000..be7ffb65
--- /dev/null
+++ b/crates/paimon/src/table/pk_sorted_index_scan/tests.rs
@@ -0,0 +1,743 @@
+// 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.
+
+use super::*;
+use crate::btree::{
+ make_key_comparator, serialize_datum, test_util::VecFileWrite,
BTreeIndexWriter,
+ BlockCompressionType,
+};
+use crate::catalog::Identifier;
+use crate::io::FileIOBuilder;
+use crate::spec::{
+ stats::BinaryTableStats, BinaryRow, DataType, Datum, GlobalIndexMeta,
IndexFileMeta, IntType,
+ PredicateBuilder, Schema, TableSchema,
+};
+use crate::table::bitmap_global_index_format::{
+ make_bitmap_key_comparator, serialize_bitmap_datum,
+};
+use crate::table::bitmap_global_index_writer::BitmapGlobalIndexWriter;
+use crate::table::{DataSplitBuilder, DeletionFile};
+
+fn table(extra: &[(&str, &str)]) -> Table {
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("a", DataType::Int(IntType::new()))
+ .column("b", DataType::Int(IntType::new()))
+ .primary_key(["id"])
+ .option("bucket", "1")
+ .option("pk-btree.index.columns", "a")
+ .option("pk-bitmap.index.columns", "b")
+ .options(extra.iter().map(|(k, v)| (k.to_string(), v.to_string())))
+ .build()
+ .unwrap();
+ Table::new(
+ FileIOBuilder::new("memory").build().unwrap(),
+ Identifier::new("db", "t"),
+ "memory:/table".into(),
+ TableSchema::new(0, &schema),
+ None,
+ )
+}
+
+fn file(name: &str, rows: i64) -> DataFileMeta {
+ DataFileMeta {
+ file_name: name.into(),
+ file_size: 100,
+ row_count: rows,
+ min_key: vec![],
+ max_key: vec![],
+ key_stats: BinaryTableStats::new(vec![], vec![], vec![]),
+ value_stats: BinaryTableStats::new(vec![], vec![], vec![]),
+ min_sequence_number: 0,
+ max_sequence_number: 0,
+ schema_id: 0,
+ level: 1,
+ extra_files: vec![],
+ creation_time: None,
+ delete_row_count: Some(0),
+ embedded_index: None,
+ file_source: Some(1),
+ value_stats_cols: None,
+ external_path: None,
+ first_row_id: None,
+ write_cols: None,
+ column_max_sequence_numbers: None,
+ }
+}
+
+fn split(files: Vec<DataFileMeta>, raw: bool) -> DataSplit {
+ DataSplitBuilder::new()
+ .with_snapshot(7)
+ .with_partition(BinaryRow::new(0))
+ .with_bucket(0)
+ .with_bucket_path("memory:/table/bucket-0".into())
+ .with_total_buckets(1)
+ .with_data_files(files)
+ .with_raw_convertible(raw)
+ .build()
+ .unwrap()
+}
+
+fn source_meta(files: &[(&str, i64)], level: i32) -> Vec<u8> {
+ let mut bytes = vec![];
+ bytes.extend_from_slice(&1i32.to_be_bytes());
+ bytes.extend_from_slice(&level.to_be_bytes());
+ bytes.extend_from_slice(&(files.len() as i32).to_be_bytes());
+ for (name, count) in files {
+ assert!(name.is_ascii());
+ bytes.extend_from_slice(&(name.len() as u16).to_be_bytes());
+ bytes.extend_from_slice(name.as_bytes());
+ bytes.extend_from_slice(&count.to_be_bytes());
+ }
+ bytes
+}
+
+async fn payload(
+ table: &Table,
+ field: &str,
+ sources: &[(&str, i64)],
+ values: &[Option<i32>],
+ name: &str,
+ path: &str,
+) -> IndexManifestEntry {
+ let field = table
+ .schema()
+ .fields()
+ .iter()
+ .find(|f| f.name() == field)
+ .unwrap();
+ let ty = field.data_type();
+ let output = VecFileWrite::new();
+ let (meta, row_count) = if field.name() == "a" {
+ let mut writer = BTreeIndexWriter::with_comparator(
+ Box::new(output.clone()),
+ 256,
+ BlockCompressionType::None,
+ make_key_comparator(ty),
+ );
+ let mut sorted_values: Vec<_> = values.iter().enumerate().collect();
+ sorted_values.sort_by_key(|(_, value)| **value);
+ for (position, value) in sorted_values {
+ let key = value.map(|v| serialize_datum(&Datum::Int(v), ty));
+ writer.write(key.as_deref(), position as i64).await.unwrap();
+ }
+ let result = writer.finish().await.unwrap();
+ (result.meta, result.row_count as i64)
+ } else {
+ let mut writer = BitmapGlobalIndexWriter::new(
+ Box::new(output.clone()),
+ 256,
+ BlockCompressionType::None,
+ make_bitmap_key_comparator(ty),
+ );
+ for (position, value) in values.iter().enumerate() {
+ let key = value.map(|v| serialize_bitmap_datum(&Datum::Int(v),
ty));
+ writer.write(key.as_deref(), position as i64).unwrap();
+ }
+ let result = writer.finish().await.unwrap();
+ (result.meta, result.row_count as i64)
+ };
+ let bytes = output.to_vec();
+ table
+ .file_io()
+ .new_output(path)
+ .unwrap()
+ .write(bytes::Bytes::from(bytes.clone()))
+ .await
+ .unwrap();
+ IndexManifestEntry {
+ version: 1,
+ kind: FileKind::Add,
+ partition: BinaryRow::new(0).to_serialized_bytes(),
+ bucket: 0,
+ index_file: IndexFileMeta {
+ index_type: if field.name() == "a" {
+ "btree"
+ } else {
+ "bitmap"
+ }
+ .into(),
+ file_name: name.into(),
+ file_size: bytes.len() as i64,
+ row_count,
+ deletion_vectors_ranges: None,
+ external_path: None,
+ global_index_meta: Some(GlobalIndexMeta {
+ row_range_start: 0,
+ row_range_end: row_count - 1,
+ index_field_id: field.id(),
+ extra_field_ids: None,
+ index_meta: Some(meta.serialize()),
+ source_meta: Some(source_meta(sources, 1)),
+ }),
+ },
+ }
+}
+
+async fn run(
+ table: &Table,
+ splits: Vec<DataSplit>,
+ entries: &[IndexManifestEntry],
+ predicate: Predicate,
+) -> Vec<DataSplit> {
+ let defs = definitions(table.schema().fields(),
table.schema().options()).unwrap();
+ refine(table, splits, entries, &defs, &[predicate])
+ .await
+ .unwrap()
+}
+
+fn equal(table: &Table, column: &str, value: i32) -> Predicate {
+ PredicateBuilder::new(table.schema().fields())
+ .equal(column, Datum::Int(value))
+ .unwrap()
+}
+
+#[tokio::test]
+async fn retired_sources_keep_offsets_and_uncovered_sources_scan() {
+ let table = table(&[]);
+ let entry = payload(
+ &table,
+ "a",
+ &[("a-retired", 2), ("b-active", 3)],
+ &[Some(9), Some(9), Some(1), Some(9), Some(1)],
+ "a.idx",
+ "memory:/table/index/a.idx",
+ )
+ .await;
+ let result = run(
+ &table,
+ vec![split(vec![file("b-active", 3), file("c-new", 2)], true)],
+ &[entry],
+ equal(&table, "a", 9),
+ )
+ .await;
+ assert_eq!(result.len(), 2);
+ assert_eq!(
+ result[0].row_ranges(),
+ Some([RowRange::new(1, 1)].as_slice())
+ );
+ assert_eq!(result[1].row_ranges(), None);
+ assert!(result.iter().all(|s| !s.raw_convertible()));
+}
+
+#[tokio::test]
+async fn predicates_combine_after_each_fields_source_offset_is_removed() {
+ let table = table(&[]);
+ let a = payload(
+ &table,
+ "a",
+ &[("a-retired", 1), ("c-active", 3)],
+ &[Some(5), Some(5), Some(5), Some(0)],
+ "a.idx",
+ "memory:/table/index/a.idx",
+ )
+ .await;
+ let b = payload(
+ &table,
+ "b",
+ &[("b-retired", 2), ("c-active", 3)],
+ &[Some(5), Some(5), Some(0), Some(5), Some(5)],
+ "b.idx",
+ "memory:/table/index/b.idx",
+ )
+ .await;
+ let input = vec![split(vec![file("c-active", 3)], true)];
+ let entries = [a, b];
+ let and = run(
+ &table,
+ input.clone(),
+ &entries,
+ Predicate::and(vec![equal(&table, "a", 5), equal(&table, "b", 5)]),
+ )
+ .await;
+ assert_eq!(and[0].row_ranges(), Some([RowRange::new(1, 1)].as_slice()));
+ let or = run(
+ &table,
+ input.clone(),
+ &entries,
+ Predicate::or(vec![equal(&table, "a", 5), equal(&table, "b", 5)]),
+ )
+ .await;
+ assert_eq!(or[0].row_ranges(), Some([RowRange::new(0, 2)].as_slice()));
+ let unsupported_or = run(
+ &table,
+ input,
+ &entries,
+ Predicate::or(vec![equal(&table, "a", 5), equal(&table, "id", 8)]),
+ )
+ .await;
+ assert_eq!(unsupported_or[0].row_ranges(), None);
+}
+
+#[tokio::test]
+async fn non_raw_merge_inputs_stay_whole_even_when_index_returns_no_matches() {
+ let table = table(&[]);
+ let entry = payload(
+ &table,
+ "a",
+ &[("old", 2)],
+ &[Some(0), Some(0)],
+ "a.idx",
+ "memory:/table/index/a.idx",
+ )
+ .await;
+ let mut newer = file("new", 1);
+ newer.level = 0;
+ newer.file_source = Some(0);
+ let original = split(vec![file("old", 2), newer], false);
+ let result = run(
+ &table,
+ vec![original.clone()],
+ &[entry],
+ equal(&table, "a", 7),
+ )
+ .await;
+ assert_eq!(result, vec![original]);
+}
+
+#[tokio::test]
+async fn index_paths_and_aligned_deletion_files_are_preserved() {
+ for (bucket_local, external) in [(false, false), (true, false), (true,
true)] {
+ let table = table(&[(
+ "index-file-in-data-file-dir",
+ if bucket_local { "true" } else { "false" },
+ )]);
+ let path = if external {
+ "memory:/outside/index"
+ } else if bucket_local {
+ "memory:/table/bucket-0/a.idx"
+ } else {
+ "memory:/table/index/a.idx"
+ };
+ let mut entry = payload(
+ &table,
+ "a",
+ &[("a", 2), ("b", 2)],
+ &[Some(0), Some(1), Some(1), Some(0)],
+ "a.idx",
+ path,
+ )
+ .await;
+ if external {
+ entry.index_file.external_path = Some(path.into());
+ }
+ let deletion = DeletionFile::new("memory:/dv".into(), 3, 9, Some(1));
+ let input = DataSplitBuilder::new()
+ .with_snapshot(7)
+ .with_partition(BinaryRow::new(0))
+ .with_bucket(0)
+ .with_bucket_path("memory:/table/bucket-0".into())
+ .with_data_files(vec![file("a", 2), file("b", 2)])
+ .with_data_deletion_files(vec![None, Some(deletion.clone())])
+ .build()
+ .unwrap();
+ let result = run(&table, vec![input], &[entry], equal(&table, "a",
1)).await;
+ assert_eq!(
+ result[0].row_ranges(),
+ Some([RowRange::new(1, 1)].as_slice())
+ );
+ assert_eq!(
+ result[1].row_ranges(),
+ Some([RowRange::new(0, 0)].as_slice())
+ );
+ assert_eq!(result[0].data_deletion_files(), Some([None].as_slice()));
+ assert_eq!(
+ result[1].data_deletion_files(),
+ Some([Some(deletion)].as_slice())
+ );
+ }
+}
+
+#[tokio::test]
+async fn malformed_or_ambiguous_coverage_never_drops_data() {
+ let table = table(&[]);
+ let entry = payload(
+ &table,
+ "a",
+ &[("a", 2)],
+ &[Some(1), Some(2)],
+ "a.idx",
+ "memory:/table/index/a.idx",
+ )
+ .await;
+ let input = vec![split(vec![file("a", 2)], true)];
+ for case in 0..12 {
+ let mut broken = entry.clone();
+ let mut active = input.clone();
+ let meta = broken.index_file.global_index_meta.as_mut().unwrap();
+ match case {
+ 0 => meta.source_meta = Some(source_meta(&[("a", 1)], 1)),
+ 1 => meta.source_meta = Some(source_meta(&[("retired", 2)], 1)),
+ 2 => meta.source_meta = Some(source_meta(&[("a", 1), ("a", 1)],
1)),
+ 3 => meta.source_meta = Some(source_meta(&[("z", 1), ("a", 1)],
1)),
+ 4 => meta.row_range_start = 1,
+ 5 => meta.row_range_end = 99,
+ 6 => broken.index_file.row_count = 1,
+ 7 => {
+ active = vec![split(
+ vec![{
+ let mut f = file("a", 2);
+ f.file_source = Some(0);
+ f
+ }],
+ true,
+ )]
+ }
+ 8 => meta.source_meta = Some(source_meta(&[("a", 2)], 2)),
+ 9 => broken.bucket = 1,
+ 10 => broken.partition = BinaryRow::new(1).to_serialized_bytes(),
+ 11 => meta.index_field_id = 0,
+ _ => unreachable!(),
+ }
+ assert_eq!(
+ run(&table, active.clone(), &[broken], equal(&table, "a",
99)).await,
+ active,
+ "case {case}"
+ );
+ }
+ let mut duplicate = entry.clone();
+ duplicate.index_file.file_name = "other.idx".into();
+ assert_eq!(
+ run(
+ &table,
+ input.clone(),
+ &[entry, duplicate],
+ equal(&table, "a", 99)
+ )
+ .await,
+ input
+ );
+}
+
+#[tokio::test]
+async fn corrupt_payload_and_unsupported_and_conjunct_fall_back_safely() {
+ let table = table(&[]);
+ let entry = payload(
+ &table,
+ "a",
+ &[("a", 2)],
+ &[Some(1), Some(2)],
+ "a.idx",
+ "memory:/table/index/a.idx",
+ )
+ .await;
+ let input = vec![split(vec![file("a", 2)], true)];
+ let and = run(
+ &table,
+ input.clone(),
+ std::slice::from_ref(&entry),
+ Predicate::and(vec![equal(&table, "a", 2), equal(&table, "id", 1)]),
+ )
+ .await;
+ assert_eq!(and[0].row_ranges(), Some([RowRange::new(1, 1)].as_slice()));
+ table
+ .file_io()
+ .new_output("memory:/table/index/a.idx")
+ .unwrap()
+ .write(bytes::Bytes::from_static(b"corrupt"))
+ .await
+ .unwrap();
+ let result = run(&table, input, &[entry], equal(&table, "a", 2)).await;
+ assert_eq!(result.len(), 1);
+ assert_eq!(result[0].row_ranges(), None);
+}
+
+#[test]
+fn per_column_read_options_follow_java_resolution_and_conflicts() {
+ let table = table(&[(
+ "fields.a.pk-btree.index.options",
+ r#"{"fallback-scan-max-size":"17 kb","cache-size":"0"}"#,
+ )]);
+ let defs = definitions(table.schema().fields(),
table.schema().options()).unwrap();
+ let opts = CoreOptions::new(&defs[0].options);
+ assert_eq!(
+ opts.btree_index_fallback_scan_max_size().unwrap(),
+ 17 * 1024
+ );
+ assert_eq!(opts.btree_index_data_block_cache_size().unwrap(), 0);
+ for json in [
+ "[]",
+ "null",
+ r#"{"a":null}"#,
+ r#"{" ":"v"}"#,
+ r#"{"fallback-scan-max-size":"10 kb"}"#,
+ ] {
+ let options = Options::from([
+ ("pk-btree.index.columns".into(), "a".into()),
+ ("btree-index.fallback-scan-max-size".into(), "1 kb".into()),
+ ("fields.a.pk-btree.index.options".into(), json.into()),
+ ]);
+ assert!(
+ definitions(table.schema().fields(), &options).is_err(),
+ "{json}"
+ );
+ }
+ for options in [
+ Options::from([("pk-btree.index.columns".into(), "a, a".into())]),
+ Options::from([
+ ("pk-btree.index.columns".into(), "a".into()),
+ ("pk-bitmap.index.columns".into(), "a".into()),
+ ]),
+ ] {
+ assert!(definitions(table.schema().fields(), &options).is_err());
+ }
+}
+
+#[tokio::test]
+async fn combined_bounds_respect_per_column_fallback_scan_budget() {
+ for budget in ["0", "17 kb"] {
+ let json = format!(r#"{{"fallback-scan-max-size":"{budget}"}}"#);
+ let table = table(&[("fields.a.pk-btree.index.options", &json)]);
+ let entry = payload(
+ &table,
+ "a",
+ &[("a", 4)],
+ &[Some(1), Some(2), Some(3), Some(4)],
+ "a.idx",
+ "memory:/table/index/a.idx",
+ )
+ .await;
+ let pb = PredicateBuilder::new(table.schema().fields());
+ let predicate = Predicate::and(vec![
+ pb.greater_or_equal("a", Datum::Int(2)).unwrap(),
+ pb.less_or_equal("a", Datum::Int(3)).unwrap(),
+ ]);
+ let result = run(
+ &table,
+ vec![split(vec![file("a", 4)], true)],
+ &[entry],
+ predicate,
+ )
+ .await;
+ if budget == "0" {
+ assert_eq!(result[0].row_ranges(), None);
+ } else {
+ assert_eq!(
+ result[0].row_ranges(),
+ Some([RowRange::new(1, 2)].as_slice())
+ );
+ }
+ }
+}
+
+#[tokio::test]
+async fn shared_query_is_cached_before_localizing_each_source() {
+ let table = table(&[]);
+ let entry = payload(
+ &table,
+ "b",
+ &[("a", 2), ("b", 2)],
+ &[Some(1), Some(0), Some(0), Some(1)],
+ "b.idx",
+ "memory:/table/index/b.idx",
+ )
+ .await;
+ let defs = definitions(table.schema().fields(),
table.schema().options()).unwrap();
+ let input = split(vec![file("a", 2), file("b", 2)], true);
+ let (groups, sources) = plan_groups(&table, std::slice::from_ref(&input),
&[entry], &defs);
+ let mut evaluation = Evaluation {
+ table: &table,
+ definitions: &defs,
+ groups,
+ sources,
+ };
+ let predicate = equal(&table, "b", 1);
+ assert_eq!(
+ evaluation
+ .evaluate_field(&input, &input.data_files()[0], "b", &predicate)
+ .await
+ .unwrap(),
+ Some(vec![RowRange::new(0, 0)])
+ );
+ table
+ .file_io()
+ .delete_file("memory:/table/index/b.idx")
+ .await
+ .unwrap();
+ assert_eq!(
+ evaluation
+ .evaluate_field(&input, &input.data_files()[1], "b", &predicate)
+ .await
+ .unwrap(),
+ Some(vec![RowRange::new(1, 1)])
+ );
+}
+
+#[tokio::test]
+async fn invalid_payload_positions_and_fragmentation_keep_full_file() {
+ let table = table(&[]);
+ let mut invalid = payload(
+ &table,
+ "b",
+ &[("a", 2)],
+ &[Some(0), Some(0), Some(1)],
+ "bad.idx",
+ "memory:/table/index/bad.idx",
+ )
+ .await;
+ invalid.index_file.row_count = 2;
+ invalid
+ .index_file
+ .global_index_meta
+ .as_mut()
+ .unwrap()
+ .row_range_end = 1;
+ let result = run(
+ &table,
+ vec![split(vec![file("a", 2)], true)],
+ &[invalid],
+ equal(&table, "b", 1),
+ )
+ .await;
+ assert_eq!(result.len(), 1);
+ assert_eq!(result[0].row_ranges(), None);
+ for matching_rows in [MAX_POSITION_RANGES, MAX_POSITION_RANGES + 1] {
+ let row_count = matching_rows * 2;
+ let values: Vec<_> = (0..row_count).map(|i| Some((i % 2) as
i32)).collect();
+ let entry = payload(
+ &table,
+ "b",
+ &[("a", row_count as i64)],
+ &values,
+ "fragmented.idx",
+ "memory:/table/index/fragmented.idx",
+ )
+ .await;
+ let result = run(
+ &table,
+ vec![split(vec![file("a", row_count as i64)], true)],
+ &[entry],
+ equal(&table, "b", 1),
+ )
+ .await;
+ if matching_rows == MAX_POSITION_RANGES {
+ assert_eq!(result[0].row_ranges().unwrap().len(),
MAX_POSITION_RANGES);
+ } else {
+ assert_eq!(result[0].row_ranges(), None);
+ }
+ }
+}
+
+#[tokio::test]
+async fn nulls_and_empty_matches_preserve_index_semantics() {
+ let table = table(&[]);
+ for column in ["a", "b"] {
+ let name = format!("{column}.idx");
+ let entry = payload(
+ &table,
+ column,
+ &[("a", 4)],
+ &[None, Some(1), None, Some(2)],
+ &name,
+ &format!("memory:/table/index/{name}"),
+ )
+ .await;
+ let pb = PredicateBuilder::new(table.schema().fields());
+ let result = run(
+ &table,
+ vec![split(vec![file("a", 4)], true)],
+ std::slice::from_ref(&entry),
+ pb.is_null(column).unwrap(),
+ )
+ .await;
+ assert_eq!(
+ result[0].row_ranges(),
+ Some([RowRange::new(0, 0), RowRange::new(2, 2)].as_slice())
+ );
+ assert!(run(
+ &table,
+ vec![split(vec![file("a", 4)], true)],
+ &[entry],
+ equal(&table, column, 99)
+ )
+ .await
+ .is_empty());
+ }
+}
+
+#[tokio::test]
+async fn
positions_at_java_integer_max_are_allowed_but_larger_positions_fall_back() {
+ let table = table(&[]);
+ let ty = table.schema().fields()[2].data_type();
+ for position in [i64::from(i32::MAX), i64::from(i32::MAX) + 1] {
+ let row_count = position + 1;
+ let output = VecFileWrite::new();
+ let mut writer = BitmapGlobalIndexWriter::new(
+ Box::new(output.clone()),
+ 256,
+ BlockCompressionType::None,
+ make_bitmap_key_comparator(ty),
+ );
+ // Sparse postings exercise the position boundary with a few bytes,
+ // without allocating a multi-billion-row data file.
+ writer
+ .write(Some(&serialize_bitmap_datum(&Datum::Int(0), ty)), 0)
+ .unwrap();
+ writer
+ .write(Some(&serialize_bitmap_datum(&Datum::Int(1), ty)), position)
+ .unwrap();
+ let result = writer
+ .finish_with_source_row_count(row_count as u64)
+ .await
+ .unwrap();
+ let bytes = output.to_vec();
+ table
+ .file_io()
+ .new_output("memory:/table/index/large.idx")
+ .unwrap()
+ .write(bytes::Bytes::from(bytes.clone()))
+ .await
+ .unwrap();
+ let entry = IndexManifestEntry {
+ version: 1,
+ kind: FileKind::Add,
+ partition: BinaryRow::new(0).to_serialized_bytes(),
+ bucket: 0,
+ index_file: IndexFileMeta {
+ index_type: "bitmap".into(),
+ file_name: "large.idx".into(),
+ file_size: bytes.len() as i64,
+ row_count,
+ deletion_vectors_ranges: None,
+ external_path: None,
+ global_index_meta: Some(GlobalIndexMeta {
+ row_range_start: 0,
+ row_range_end: row_count - 1,
+ index_field_id: 2,
+ extra_field_ids: None,
+ index_meta: Some(result.meta.serialize()),
+ source_meta: Some(source_meta(&[("a", row_count)], 1)),
+ }),
+ },
+ };
+ let selected = run(
+ &table,
+ vec![split(vec![file("a", row_count)], true)],
+ &[entry],
+ equal(&table, "b", 1),
+ )
+ .await;
+ if position == i64::from(i32::MAX) {
+ assert_eq!(
+ selected[0].row_ranges(),
+ Some([RowRange::new(position, position)].as_slice())
+ );
+ } else {
+ assert_eq!(selected[0].row_ranges(), None);
+ }
+ }
+}
diff --git a/crates/paimon/src/table/source.rs
b/crates/paimon/src/table/source.rs
index 7c106fc1..d50980ab 100644
--- a/crates/paimon/src/table/source.rs
+++ b/crates/paimon/src/table/source.rs
@@ -47,8 +47,9 @@ pub(crate) fn data_evolution_anchor_file(files:
&[DataFileMeta]) -> crate::Resul
// ======================= RowRange ===============================
/// An inclusive row range `[from, to]` in the coordinate system of the read
-/// path: stable row IDs for row-tracked tables, or physical positions for raw
-/// tables without row tracking.
+/// path: stable row IDs for row-tracked append/Data Evolution tables,
+/// split-local positions for ordinary append tables, or file-local physical
+/// positions for primary-key IndexedSplits.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RowRange {
from: i64,
@@ -498,9 +499,10 @@ pub struct DataSplit {
/// Deletion file for each data file, same order as `data_files`.
/// `None` at index `i` means no deletion file for `data_files[i]`
(matches Java getDeletionFiles() / List<DeletionFile> with null elements).
data_deletion_files: Option<Arc<[Option<DeletionFile>]>>,
- /// IndexedSplit-compatible ranges. Row-tracked tables interpret these as
- /// stable row IDs; raw tables without row tracking interpret them as
- /// split-local physical positions over `data_files` in list order.
+ /// IndexedSplit-compatible ranges. Append/Data Evolution reads use stable
+ /// row IDs when tracked, otherwise split-local positions over
`data_files`.
+ /// Primary-key IndexedSplits use file-local physical positions,
independent
+ /// of row tracking.
row_ranges: Option<Arc<[RowRange]>>,
/// Whether the split can be read raw, without the merge reader: its
/// physical rows are exactly its logical rows (modulo deletion files).
@@ -587,6 +589,21 @@ impl DataSplit {
self
}
+ /// Java PrimaryKeySortedIndexResult preserves each non-raw split whole,
+ /// and turns raw splits into single-file merge-reader inputs. Its ranges
+ /// address physical positions, including when the file tracks row IDs.
+ pub(crate) fn for_pk_index_file(&self, file_index: usize) -> Self {
+ let mut split = self.clone();
+ split.data_files = vec![self.data_files[file_index].clone()].into();
+ split.data_deletion_files = self
+ .data_deletion_files
+ .as_ref()
+ .map(|files| vec![files[file_index].clone()].into());
+ split.row_ranges = None;
+ split.raw_convertible = false;
+ split
+ }
+
/// Whether this split can be read raw (no sort-merge needed); see the
/// field doc. Mirrors Java `DataSplit#rawConvertible`.
pub fn raw_convertible(&self) -> bool {
diff --git a/crates/paimon/src/table/table_scan.rs
b/crates/paimon/src/table/table_scan.rs
index 367e0de1..b56bf4a4 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -2178,6 +2178,25 @@ impl<'a> PaimonTableScan<'a> {
}
}
+ fn pk_sorted_index_definitions(
+ &self,
+ ) -> crate::Result<Vec<super::pk_sorted_index_scan::Definition>> {
+ let schema = self.table.schema();
+ let options = schema.core_options();
+ if schema.primary_keys().is_empty()
+ || self.is_streaming()
+ || self.scan_all_files
+ || self.row_range_optimization_disabled
+ || self.snapshot_mode().is_some()
+ || options.query_auth_enabled()
+ || !options.global_index_enabled()
+ || self.data_predicates.is_empty()
+ {
+ return Ok(Vec::new());
+ }
+ super::pk_sorted_index_scan::definitions(schema.fields(),
schema.options())
+ }
+
async fn read_index_manifest_entries(
&self,
snapshot: &Snapshot,
@@ -2487,6 +2506,7 @@ impl<'a> PaimonTableScan<'a> {
None,
before_index_entries,
None,
+ &[],
None,
)
.await?;
@@ -2497,6 +2517,7 @@ impl<'a> PaimonTableScan<'a> {
None,
after_index_entries,
None,
+ &[],
None,
)
.await?;
@@ -2558,10 +2579,11 @@ impl<'a> PaimonTableScan<'a> {
let data_evolution_enabled = core_options.data_evolution_enabled();
let global_index_settings =
self.global_index_scan_settings(&core_options,
data_evolution_enabled)?;
+ let pk_sorted_index_definitions = self.pk_sorted_index_definitions()?;
let index_entries = self
.read_index_manifest_entries(
snapshot,
- global_index_settings.is_some(),
+ global_index_settings.is_some() ||
!pk_sorted_index_definitions.is_empty(),
core_options.deletion_vectors_enabled() &&
!self.is_streaming(),
)
.await?;
@@ -2604,11 +2626,13 @@ impl<'a> PaimonTableScan<'a> {
data_evolution_read_field_ids,
index_entries,
effective_row_ranges,
+ &pk_sorted_index_definitions,
trace,
)
.await
}
+ #[allow(clippy::too_many_arguments)]
async fn plan_snapshot_from_entries(
&self,
snapshot: Snapshot,
@@ -2616,6 +2640,7 @@ impl<'a> PaimonTableScan<'a> {
data_evolution_read_field_ids: Option<&HashSet<i32>>,
index_entries: Option<Vec<IndexManifestEntry>>,
effective_row_ranges: Option<Vec<RowRange>>,
+ pk_sorted_index_definitions:
&[super::pk_sorted_index_scan::Definition],
mut trace: Option<&mut ScanTrace>,
) -> crate::Result<Plan> {
let table_path = self.table.location();
@@ -3034,6 +3059,18 @@ impl<'a> PaimonTableScan<'a> {
} else {
splits
};
+ let splits = if !pk_sorted_index_definitions.is_empty() {
+ super::pk_sorted_index_scan::refine(
+ &self.table,
+ splits,
+ index_entries.as_deref().unwrap_or_default(),
+ pk_sorted_index_definitions,
+ &self.data_predicates,
+ )
+ .await?
+ } else {
+ splits
+ };
let splits_before_limit = split_candidates_built;
if let Some(trace) = trace {
let final_files = splits.iter().map(|split|
split.data_files().len()).sum();
@@ -3506,6 +3543,7 @@ mod tests {
None,
None,
None,
+ &[],
None,
)
.await
@@ -4974,6 +5012,7 @@ mod tests {
None,
None,
None,
+ &[],
None,
)
.await
@@ -5051,6 +5090,7 @@ mod tests {
None,
None,
None,
+ &[],
None,
)
.await
@@ -5147,6 +5187,7 @@ mod tests {
None,
None,
None,
+ &[],
None,
)
.await
@@ -6596,6 +6637,64 @@ mod tests {
assert!(entries.is_none());
}
+ #[tokio::test]
+ async fn
test_pk_index_scan_uses_resolved_definitions_instead_of_option_presence() {
+ let table = pk_stats_gate_table("memory:/pk_index_definition_gate");
+ setup_scan_trace_dirs(&table).await;
+ TableCommit::new(table.clone(), "definition-gate".into())
+ .commit(vec![CommitMessage::new(
+ BinaryRow::new(0).to_serialized_bytes(),
+ 0,
+ vec![pk_stats_file("data.parquet", (1, 2), (10, 20))],
+ )])
+ .await
+ .unwrap();
+ let snapshot = table
+ .snapshot_manager()
+ .get_latest_snapshot()
+ .await
+ .unwrap()
+ .unwrap();
+ let mut json = serde_json::to_value(&snapshot).unwrap();
+ json["indexManifest"] = serde_json::json!("missing-index-manifest");
+ let snapshot: Snapshot = serde_json::from_value(json).unwrap();
+ let predicate = PredicateBuilder::new(table.schema().fields())
+ .equal("value", Datum::Int(10))
+ .unwrap();
+ for (key, columns, enabled, uses_index) in [
+ ("pk-btree.index.columns", "", true, false),
+ ("pk-btree.index.columns", "unknown", true, false),
+ ("pk-bitmap.index.columns", "unknown", true, false),
+ ("pk-btree.index.columns", "value", false, false),
+ ("pk-btree.index.columns", "value", true, true),
+ ("pk-bitmap.index.columns", "value", true, true),
+ ] {
+ let configured = table.copy_with_options(HashMap::from([
+ (key.into(), columns.into()),
+ ("global-index.enabled".into(), enabled.to_string()),
+ ]));
+ let scan =
+ PaimonTableScan::new(&configured, None,
vec![predicate.clone()], None, None, None);
+ let plan = scan.plan_snapshot(snapshot.clone(), None, None).await;
+ if uses_index {
+ assert!(plan
+ .unwrap_err()
+ .to_string()
+ .contains("missing-index-manifest"));
+ } else {
+ let plan = plan.unwrap();
+ assert_eq!(plan.snapshot_id(), Some(snapshot.id()));
+ let files: Vec<_> = plan
+ .splits()
+ .iter()
+ .flat_map(|split| split.data_files())
+ .map(|file| file.file_name.as_str())
+ .collect();
+ assert_eq!(files, vec!["data.parquet"]);
+ }
+ }
+ }
+
// ======================== Bucket predicate filtering
========================
fn bucket_key_fields() -> Vec<DataField> {