Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 21 additions & 22 deletions crates/paimon/src/table/pk_vector_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -752,17 +752,11 @@ mod tests {
)
}

/// Build a real single-file primary-key table via the public write path, in a
/// fresh temp dir. Persists the schema and writes one data batch, then commits
/// the written data file with real `value_stats` for the `id`/`score` columns.
/// Build a real single-file primary-key table via the public write path.
///
/// The stats injection mirrors the meta-modification the baseline fixture uses
/// for `level`/`file_source`: the Rust key-value (primary-key) writer records
/// column stats in `key_stats` and leaves `value_stats` empty, but scan-time
/// file pruning reads `value_stats`. Java primary-key writers populate value
/// stats, so committing them here makes the file prunable exactly as it would be
/// in a table written by the Java engine. Returns the temp dir (kept alive by
/// the caller) and the opened table.
/// The Rust key-value writer records primary-key stats in `key_stats`. For the
/// deletion-vector test, also populate `value_stats` so its non-key predicate
/// has the same metadata a Java primary-key writer produces.
async fn build_pruning_test_table(
with_deletion_vectors: bool,
) -> (tempfile::TempDir, Table) {
Expand Down Expand Up @@ -793,19 +787,24 @@ mod tests {
let base_meta = written.new_files[0].clone();
let bucket = written.bucket;
let partition = written.partition.clone();

// Real value stats over the `id` (col 0) and `score` (col 1) columns, so a
// predicate outside the written [0, PRUNE_ROWS) range can prune the file.
let int = DataType::Int(IntType::new());
let value_stats: BinaryTableStats =
compute_column_stats(&batch, &[0, 1], &[int.clone(), int]).unwrap();
let indexed_meta = DataFileMeta {
value_stats,
value_stats_cols: Some(vec!["id".to_string(), "score".to_string()]),
..base_meta
assert_eq!(base_meta.key_stats.null_counts(), &vec![Some(0)]);
assert!(base_meta.value_stats.null_counts().is_empty());
assert_eq!(base_meta.value_stats_cols, Some(vec![]));

let file_meta = if with_deletion_vectors {
let int = DataType::Int(IntType::new());
let value_stats: BinaryTableStats =
compute_column_stats(&batch, &[0, 1], &[int.clone(), int]).unwrap();
DataFileMeta {
value_stats,
value_stats_cols: Some(vec!["id".to_string(), "score".to_string()]),
..base_meta
}
} else {
base_meta
};

let message = CommitMessage::new(partition, bucket, vec![indexed_meta]);
let message = CommitMessage::new(partition, bucket, vec![file_meta]);
TableCommit::new(table.clone(), "pkvector-prune".to_string())
.commit(vec![message])
.await
Expand All @@ -831,7 +830,7 @@ mod tests {
}

#[tokio::test]
async fn plan_prunes_file_when_pk_predicate_excludes_it() {
async fn plan_prunes_rust_pk_file_using_key_stats() {
// Real PK table, one data file with id in [0, PRUNE_ROWS). A predicate
// `id = OUT_OF_RANGE` cannot match the file's id stats, so the scan drops
// the file and plan() returns no splits. Control (no filter) returns one.
Expand Down
165 changes: 119 additions & 46 deletions crates/paimon/src/table/stats_filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,26 @@ pub(super) struct FileStatsRows {
null_counts: Vec<Option<i64>>,
supports_in_min_max_pruning: bool,
/// Maps schema field index → stats index. `None` means identity mapping
/// (stats cover all schema fields in order). `Some` is used when
/// `value_stats_cols` or `write_cols` is present (dense mode).
/// (stats cover all schema fields in order). `Some` maps dense stats such
/// as `value_stats_cols`, `write_cols`, or trimmed primary-key stats.
stats_col_mapping: Option<Vec<Option<usize>>>,
}

fn dense_stats_col_mapping(
schema_fields: &[DataField],
stats_columns: &[String],
) -> Vec<Option<usize>> {
let col_index: HashMap<&str, usize> = stats_columns
.iter()
.enumerate()
.map(|(i, c)| (c.as_str(), i))
.collect();
schema_fields
.iter()
.map(|field| col_index.get(field.name()).copied())
.collect()
}

impl FileStatsRows {
/// Build a `FileStatsRows` for manifest-level partition stats (no column mapping).
pub(super) fn for_manifest_partition(
Expand All @@ -66,31 +81,11 @@ impl FileStatsRows {
/// When `value_stats_cols` is `Some`, stats are in dense mode — only covering those
/// columns, and the mapping from schema field index to stats index is built by name.
pub(super) fn from_data_file(file: &DataFileMeta, schema_fields: &[DataField]) -> Self {
let stats_col_mapping = if let Some(cols) = &file.value_stats_cols {
let col_index: HashMap<&str, usize> = cols
.iter()
.enumerate()
.map(|(i, c)| (c.as_str(), i))
.collect();
let mapping: Vec<Option<usize>> = schema_fields
.iter()
.map(|field| col_index.get(field.name()).copied())
.collect();
Some(mapping)
} else if let Some(cols) = &file.write_cols {
let col_index: HashMap<&str, usize> = cols
.iter()
.enumerate()
.map(|(i, c)| (c.as_str(), i))
.collect();
let mapping: Vec<Option<usize>> = schema_fields
.iter()
.map(|field| col_index.get(field.name()).copied())
.collect();
Some(mapping)
} else {
None
};
let stats_col_mapping = file
.value_stats_cols
.as_ref()
.or(file.write_cols.as_ref())
.map(|cols| dense_stats_col_mapping(schema_fields, cols));

Self {
row_count: file.row_count,
Expand All @@ -102,6 +97,25 @@ impl FileStatsRows {
}
}

/// Build file stats from `_KEY_STATS`.
///
/// Key stats are dense and follow the table's trimmed primary-key order
/// (primary-key columns with partition columns removed).
pub(super) fn from_key_stats(
file: &DataFileMeta,
schema_fields: &[DataField],
key_fields: &[String],
) -> Self {
Self {
row_count: file.row_count,
min_values: BinaryRow::from_serialized_bytes(file.key_stats.min_values()).ok(),
max_values: BinaryRow::from_serialized_bytes(file.key_stats.max_values()).ok(),
null_counts: file.key_stats.null_counts().clone(),
supports_in_min_max_pruning: true,
stats_col_mapping: Some(dense_stats_col_mapping(schema_fields, key_fields)),
}
}

/// Resolve a schema field index to the corresponding stats index.
fn stats_index(&self, schema_index: usize) -> Option<usize> {
match &self.stats_col_mapping {
Expand Down Expand Up @@ -148,6 +162,7 @@ impl StatsAccessor for FileStatsRows {
pub(super) struct ResolvedStatsSchema {
file_fields: Vec<DataField>,
field_mapping: Vec<Option<usize>>,
key_fields: Vec<String>,
}

fn identity_field_mapping(num_fields: usize) -> Vec<Option<usize>> {
Expand All @@ -165,41 +180,89 @@ fn normalize_field_mapping(mapping: Option<Vec<i32>>, num_fields: usize) -> Vec<
.unwrap_or_else(|| identity_field_mapping(num_fields))
}

fn has_always_false(predicates: &[Predicate], key_predicates: &[Predicate]) -> bool {
predicates
.iter()
.chain(key_predicates)
.any(|p| matches!(p, Predicate::AlwaysFalse))
}

fn matches_file_stats(
file: &DataFileMeta,
predicates: &[Predicate],
key_predicates: &[Predicate],
field_mapping: &[Option<usize>],
schema_fields: &[DataField],
key_fields: &[String],
) -> bool {
let value_stats = FileStatsRows::from_data_file(file, schema_fields);
if !predicates_may_match_with_schema(predicates, &value_stats, field_mapping, schema_fields) {
return false;
}

if key_predicates.is_empty() {
return true;
}

let key_stats = FileStatsRows::from_key_stats(file, schema_fields, key_fields);
predicates_may_match_with_schema(key_predicates, &key_stats, field_mapping, schema_fields)
}

/// Check whether a data file *may* contain rows matching all `predicates`.
///
/// Value predicates are evaluated against `_VALUE_STATS`; primary-key
/// predicates are additionally evaluated against `_KEY_STATS`. Both sources
/// must report that the file may match.
///
/// Pruning is evaluated per file and fails open when stats cannot be
/// interpreted safely, including schema mismatches, incompatible stats arity,
/// and missing or corrupted stats.
pub(super) fn data_file_matches_predicates(
pub(super) fn data_file_matches_predicates_with_key_stats(
file: &DataFileMeta,
predicates: &[Predicate],
key_predicates: &[Predicate],
current_schema_id: i64,
schema_fields: &[DataField],
key_fields: &[String],
) -> bool {
if predicates.is_empty() {
if predicates.is_empty() && key_predicates.is_empty() {
return true;
}

if predicates
.iter()
.any(|p| matches!(p, Predicate::AlwaysFalse))
{
if has_always_false(predicates, key_predicates) {
return false;
}
if predicates
.iter()
.all(|p| matches!(p, Predicate::AlwaysTrue))
{
return true;
}

if file.schema_id != current_schema_id {
return true;
}

let stats = FileStatsRows::from_data_file(file, schema_fields);
let field_mapping = identity_field_mapping(schema_fields.len());
predicates_may_match_with_schema(predicates, &stats, &field_mapping, schema_fields)
matches_file_stats(
file,
predicates,
key_predicates,
&field_mapping,
schema_fields,
key_fields,
)
}

#[cfg(test)]
pub(super) fn data_file_matches_predicates(
file: &DataFileMeta,
predicates: &[Predicate],
current_schema_id: i64,
schema_fields: &[DataField],
) -> bool {
data_file_matches_predicates_with_key_stats(
file,
predicates,
&[],
current_schema_id,
schema_fields,
&[],
)
}

async fn resolve_stats_schema(
Expand All @@ -217,6 +280,7 @@ async fn resolve_stats_schema(
Some(Arc::new(ResolvedStatsSchema {
file_fields: current_fields.to_vec(),
field_mapping: identity_field_mapping(current_fields.len()),
key_fields: table_schema.trimmed_primary_keys(),
}))
} else {
let file_schema = table.schema_manager().schema(file_schema_id).await.ok()?;
Expand All @@ -227,6 +291,7 @@ async fn resolve_stats_schema(
current_fields.len(),
),
file_fields,
key_fields: file_schema.trimmed_primary_keys(),
}))
};

Expand All @@ -238,31 +303,39 @@ pub(super) async fn data_file_matches_predicates_for_table(
table: &Table,
file: &DataFileMeta,
predicates: &[Predicate],
key_predicates: &[Predicate],
schema_cache: &mut HashMap<i64, Option<Arc<ResolvedStatsSchema>>>,
) -> bool {
if predicates.is_empty() {
if predicates.is_empty() && key_predicates.is_empty() {
return true;
}

if has_always_false(predicates, key_predicates) {
return false;
}

if file.schema_id == table.schema().id() {
return data_file_matches_predicates(
return data_file_matches_predicates_with_key_stats(
file,
predicates,
key_predicates,
table.schema().id(),
table.schema().fields(),
&table.schema().trimmed_primary_keys(),
);
}

let Some(resolved) = resolve_stats_schema(table, file.schema_id, schema_cache).await else {
return true;
};

let stats = FileStatsRows::from_data_file(file, &resolved.file_fields);
predicates_may_match_with_schema(
matches_file_stats(
file,
predicates,
&stats,
key_predicates,
&resolved.field_mapping,
&resolved.file_fields,
&resolved.key_fields,
)
}

Expand Down
Loading