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 d24158e feat(table): support mod and hive bucket functions (#396)
d24158e is described below
commit d24158e84ba5cb0ce7873ed769e3665061f4036f
Author: WenjunMin <[email protected]>
AuthorDate: Fri Jun 19 20:36:52 2026 +0800
feat(table): support mod and hive bucket functions (#396)
---
crates/paimon/src/spec/binary_row.rs | 2 +-
crates/paimon/src/spec/core_options.rs | 43 ++-
crates/paimon/src/table/bucket_assigner_fixed.rs | 18 +-
crates/paimon/src/table/bucket_filter.rs | 7 +-
crates/paimon/src/table/bucket_function.rs | 365 +++++++++++++++++++++++
crates/paimon/src/table/mod.rs | 1 +
crates/paimon/src/table/read_builder.rs | 4 -
crates/paimon/src/table/table_scan.rs | 131 +++++---
crates/paimon/src/table/table_write.rs | 125 +++++++-
docs/src/sql.md | 1 +
10 files changed, 635 insertions(+), 62 deletions(-)
diff --git a/crates/paimon/src/spec/binary_row.rs
b/crates/paimon/src/spec/binary_row.rs
index 104e343..145f64c 100644
--- a/crates/paimon/src/spec/binary_row.rs
+++ b/crates/paimon/src/spec/binary_row.rs
@@ -1131,7 +1131,7 @@ fn write_typed_value(
/// Build BinaryRows for all rows in the batch for the given field indices.
/// Downcasts columns once, then iterates rows — O(F) downcasts instead of
O(N*F).
-fn batch_build_binary_rows(
+pub(crate) fn batch_build_binary_rows(
batch: &RecordBatch,
field_indices: &[usize],
fields: &[crate::spec::DataField],
diff --git a/crates/paimon/src/spec/core_options.rs
b/crates/paimon/src/spec/core_options.rs
index a25fa5c..0d4e81f 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -102,6 +102,26 @@ pub enum ChangelogProducer {
Lookup,
}
+/// Bucket function used to map bucket keys to fixed bucket ids.
+///
+/// Reference: Java `CoreOptions.BucketFunctionType`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum BucketFunctionType {
+ Default,
+ Mod,
+ Hive,
+}
+
+impl BucketFunctionType {
+ pub fn as_str(&self) -> &'static str {
+ match self {
+ Self::Default => "default",
+ Self::Mod => "mod",
+ Self::Hive => "hive",
+ }
+ }
+}
+
impl ChangelogProducer {
pub fn as_str(&self) -> &'static str {
match self {
@@ -388,15 +408,22 @@ impl<'a> CoreOptions<'a> {
.unwrap_or(DEFAULT_BUCKET)
}
- /// Whether the bucket function type is the default hash-based function.
- ///
- /// Only the default function (`Math.abs(hash % numBuckets)`) is supported
- /// for bucket predicate pruning. `mod` and `hive` use different
algorithms.
- pub fn is_default_bucket_function(&self) -> bool {
- self.options
+ /// Bucket function type. Defaults to Java-compatible Paimon hash.
+ pub fn bucket_function_type(&self) -> crate::Result<BucketFunctionType> {
+ match self
+ .options
.get(BUCKET_FUNCTION_TYPE_OPTION)
- .map(|v| v.eq_ignore_ascii_case("default"))
- .unwrap_or(true)
+ .map(|v| v.to_ascii_lowercase())
+ .as_deref()
+ .unwrap_or("default")
+ {
+ "default" => Ok(BucketFunctionType::Default),
+ "mod" => Ok(BucketFunctionType::Mod),
+ "hive" => Ok(BucketFunctionType::Hive),
+ other => Err(crate::Error::ConfigInvalid {
+ message: format!("Unsupported bucket-function.type: {other}"),
+ }),
+ }
}
/// Target file size for data files. Default is 128MB.
diff --git a/crates/paimon/src/table/bucket_assigner_fixed.rs
b/crates/paimon/src/table/bucket_assigner_fixed.rs
index a31e720..428b07d 100644
--- a/crates/paimon/src/table/bucket_assigner_fixed.rs
+++ b/crates/paimon/src/table/bucket_assigner_fixed.rs
@@ -19,9 +19,10 @@
use crate::io::FileIO;
use crate::spec::{
- batch_hash_codes, batch_to_serialized_bytes, DataField, IndexFileMeta,
EMPTY_SERIALIZED_ROW,
+ batch_to_serialized_bytes, BucketFunctionType, DataField, IndexFileMeta,
EMPTY_SERIALIZED_ROW,
};
use crate::table::bucket_assigner::{BatchAssignOutput, BucketAssigner,
PartitionBucketKey};
+use crate::table::bucket_function::batch_bucket_ids;
use crate::Result;
use arrow_array::RecordBatch;
use std::collections::HashMap;
@@ -33,6 +34,7 @@ use std::collections::HashMap;
pub(crate) struct FixedBucketAssigner {
partition_field_indices: Vec<usize>,
bucket_key_indices: Vec<usize>,
+ bucket_function_type: BucketFunctionType,
total_buckets: i32,
}
@@ -40,11 +42,13 @@ impl FixedBucketAssigner {
pub fn new(
partition_field_indices: Vec<usize>,
bucket_key_indices: Vec<usize>,
+ bucket_function_type: BucketFunctionType,
total_buckets: i32,
) -> Self {
Self {
partition_field_indices,
bucket_key_indices,
+ bucket_function_type,
total_buckets,
}
}
@@ -63,11 +67,13 @@ impl BucketAssigner for FixedBucketAssigner {
batch_to_serialized_bytes(batch, &self.partition_field_indices,
fields)?
};
- let hash_codes = batch_hash_codes(batch, &self.bucket_key_indices,
fields)?;
- let buckets: Vec<i32> = hash_codes
- .iter()
- .map(|h| (h % self.total_buckets).wrapping_abs())
- .collect();
+ let buckets = batch_bucket_ids(
+ batch,
+ &self.bucket_key_indices,
+ fields,
+ self.bucket_function_type,
+ self.total_buckets,
+ )?;
Ok(BatchAssignOutput {
partition_bytes,
diff --git a/crates/paimon/src/table/bucket_filter.rs
b/crates/paimon/src/table/bucket_filter.rs
index 7091578..238cda4 100644
--- a/crates/paimon/src/table/bucket_filter.rs
+++ b/crates/paimon/src/table/bucket_filter.rs
@@ -18,8 +18,10 @@
//! Bucket and partition predicate extraction and bucket hash pruning.
use crate::spec::{
- field_idx_to_partition_idx, BinaryRow, DataField, DataType, Datum,
Predicate, PredicateOperator,
+ field_idx_to_partition_idx, BucketFunctionType, DataField, DataType,
Datum, Predicate,
+ PredicateOperator,
};
+use crate::table::bucket_function::bucket_for_datums;
use std::collections::HashSet;
pub(super) fn split_partition_and_data_predicates(
@@ -94,6 +96,7 @@ pub(super) fn extract_predicate_for_keys(
pub(super) fn compute_target_buckets(
bucket_predicate: &Predicate,
bucket_key_fields: &[DataField],
+ bucket_function_type: BucketFunctionType,
total_buckets: i32,
) -> Option<HashSet<i32>> {
if total_buckets <= 0 || bucket_key_fields.is_empty() {
@@ -125,7 +128,7 @@ pub(super) fn compute_target_buckets(
})
.collect();
- let bucket = BinaryRow::compute_bucket_from_datums(&datums,
total_buckets);
+ let bucket = bucket_for_datums(&datums, bucket_function_type,
total_buckets).ok()?;
buckets.insert(bucket);
// Advance the combination counter (rightmost first).
diff --git a/crates/paimon/src/table/bucket_function.rs
b/crates/paimon/src/table/bucket_function.rs
new file mode 100644
index 0000000..b21082f
--- /dev/null
+++ b/crates/paimon/src/table/bucket_function.rs
@@ -0,0 +1,365 @@
+// 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 crate::spec::{
+ batch_build_binary_rows, BinaryRow, BucketFunctionType, DataField,
DataType, Datum,
+};
+use arrow_array::RecordBatch;
+
+pub(crate) fn validate_bucket_function(
+ bucket_function_type: BucketFunctionType,
+ bucket_key_fields: &[DataField],
+) -> crate::Result<()> {
+ if bucket_function_type == BucketFunctionType::Mod {
+ if bucket_key_fields.len() != 1 {
+ return Err(crate::Error::ConfigInvalid {
+ message: "bucket key must have exactly one field in mod bucket
function"
+ .to_string(),
+ });
+ }
+ let data_type = bucket_key_fields[0].data_type();
+ if !matches!(data_type, DataType::Int(_) | DataType::BigInt(_)) {
+ return Err(crate::Error::ConfigInvalid {
+ message: format!(
+ "bucket key type must be INT or BIGINT in mod bucket
function, but got {data_type:?}"
+ ),
+ });
+ }
+ }
+ Ok(())
+}
+
+pub(crate) fn batch_bucket_ids(
+ batch: &RecordBatch,
+ field_indices: &[usize],
+ fields: &[DataField],
+ bucket_function_type: BucketFunctionType,
+ total_buckets: i32,
+) -> crate::Result<Vec<i32>> {
+ let rows = batch_build_binary_rows(batch, field_indices, fields)?;
+ let bucket_key_fields: Vec<DataField> = field_indices
+ .iter()
+ .map(|&idx| fields[idx].clone())
+ .collect();
+ rows.iter()
+ .map(|row| bucket_for_row(row, &bucket_key_fields,
bucket_function_type, total_buckets))
+ .collect()
+}
+
+pub(crate) fn bucket_for_datums(
+ datums: &[(Option<&Datum>, &DataType)],
+ bucket_function_type: BucketFunctionType,
+ total_buckets: i32,
+) -> crate::Result<i32> {
+ match bucket_function_type {
+ BucketFunctionType::Default => {
+ Ok(BinaryRow::compute_bucket_from_datums(datums, total_buckets))
+ }
+ BucketFunctionType::Mod => mod_bucket_from_datums(datums,
total_buckets),
+ BucketFunctionType::Hive => hive_bucket_from_datums(datums,
total_buckets),
+ }
+}
+
+fn bucket_for_row(
+ row: &BinaryRow,
+ bucket_key_fields: &[DataField],
+ bucket_function_type: BucketFunctionType,
+ total_buckets: i32,
+) -> crate::Result<i32> {
+ match bucket_function_type {
+ BucketFunctionType::Default => Ok(default_bucket(row.hash_code(),
total_buckets)),
+ BucketFunctionType::Mod => mod_bucket_from_row(row, bucket_key_fields,
total_buckets),
+ BucketFunctionType::Hive => hive_bucket_from_row(row,
bucket_key_fields, total_buckets),
+ }
+}
+
+fn default_bucket(hash: i32, total_buckets: i32) -> i32 {
+ (hash % total_buckets).wrapping_abs()
+}
+
+fn floor_mod_i64(value: i64, divisor: i32) -> i32 {
+ value.rem_euclid(divisor as i64) as i32
+}
+
+fn mod_bucket_from_row(
+ row: &BinaryRow,
+ bucket_key_fields: &[DataField],
+ total_buckets: i32,
+) -> crate::Result<i32> {
+ validate_bucket_function(BucketFunctionType::Mod, bucket_key_fields)?;
+ if row.is_null_at(0) {
+ return Ok(0);
+ }
+ match bucket_key_fields[0].data_type() {
+ DataType::Int(_) => Ok(floor_mod_i64(row.get_int(0)? as i64,
total_buckets)),
+ DataType::BigInt(_) => Ok(floor_mod_i64(row.get_long(0)?,
total_buckets)),
+ other => Err(crate::Error::Unsupported {
+ message: format!("bucket key type must be INT or BIGINT, but got
{other:?}"),
+ }),
+ }
+}
+
+fn mod_bucket_from_datums(
+ datums: &[(Option<&Datum>, &DataType)],
+ total_buckets: i32,
+) -> crate::Result<i32> {
+ if datums.len() != 1 {
+ return Err(crate::Error::ConfigInvalid {
+ message: "bucket key must have exactly one field in mod bucket
function".to_string(),
+ });
+ }
+ match datums[0] {
+ (None, DataType::Int(_) | DataType::BigInt(_)) => Ok(0),
+ (Some(Datum::Int(v)), DataType::Int(_)) => Ok(floor_mod_i64(*v as i64,
total_buckets)),
+ (Some(Datum::Long(v)), DataType::BigInt(_)) => Ok(floor_mod_i64(*v,
total_buckets)),
+ (_, data_type) => Err(crate::Error::Unsupported {
+ message: format!("bucket key type must be INT or BIGINT, but got
{data_type:?}"),
+ }),
+ }
+}
+
+fn hive_bucket_from_row(
+ row: &BinaryRow,
+ bucket_key_fields: &[DataField],
+ total_buckets: i32,
+) -> crate::Result<i32> {
+ let mut hash = 0_i32;
+ for (pos, field) in bucket_key_fields.iter().enumerate() {
+ let datum = row.get_datum(pos, field.data_type())?;
+ hash = hash
+ .wrapping_mul(31)
+ .wrapping_add(hive_hash_datum(datum.as_ref(), field.data_type())?);
+ }
+ Ok(positive_mod(hash, total_buckets))
+}
+
+fn hive_bucket_from_datums(
+ datums: &[(Option<&Datum>, &DataType)],
+ total_buckets: i32,
+) -> crate::Result<i32> {
+ let mut hash = 0_i32;
+ for (datum, data_type) in datums {
+ hash = hash
+ .wrapping_mul(31)
+ .wrapping_add(hive_hash_datum(*datum, data_type)?);
+ }
+ Ok(positive_mod(hash, total_buckets))
+}
+
+fn positive_mod(hash: i32, total_buckets: i32) -> i32 {
+ ((hash as u32 & 0x7fff_ffff) % total_buckets as u32) as i32
+}
+
+fn hive_hash_datum(datum: Option<&Datum>, data_type: &DataType) ->
crate::Result<i32> {
+ let Some(datum) = datum else {
+ return Ok(0);
+ };
+
+ match (datum, data_type) {
+ (Datum::Bool(v), DataType::Boolean(_)) => Ok(i32::from(*v)),
+ (Datum::TinyInt(v), DataType::TinyInt(_)) => Ok(*v as i32),
+ (Datum::SmallInt(v), DataType::SmallInt(_)) => Ok(*v as i32),
+ (Datum::Int(v), DataType::Int(_)) => Ok(*v),
+ (Datum::Long(v), DataType::BigInt(_)) => Ok(java_long_hash(*v)),
+ (Datum::Float(v), DataType::Float(_)) => Ok(java_float_bits(*v) as
i32),
+ (Datum::Double(v), DataType::Double(_)) =>
Ok(java_long_hash(java_double_bits(*v) as i64)),
+ (Datum::String(v), DataType::Char(_) | DataType::VarChar(_)) => {
+ Ok(hive_hash_bytes(v.as_bytes()))
+ }
+ (Datum::Bytes(v), DataType::Binary(_) | DataType::VarBinary(_)) =>
Ok(hive_hash_bytes(v)),
+ (
+ Datum::Decimal {
+ unscaled, scale, ..
+ },
+ DataType::Decimal(_),
+ ) => {
+ let (unscaled, scale) = normalize_decimal(*unscaled, *scale);
+ Ok(java_big_decimal_hash(unscaled, scale))
+ }
+ (Datum::Date(v), DataType::Date(_)) => Ok(*v),
+ (Datum::Time(v), DataType::Time(_)) => Ok(*v),
+ _ => Err(crate::Error::Unsupported {
+ message: format!("Unsupported type as bucket key type
{data_type:?}"),
+ }),
+ }
+}
+
+fn hive_hash_bytes(bytes: &[u8]) -> i32 {
+ bytes.iter().fold(0_i32, |hash, byte| {
+ hash.wrapping_mul(31).wrapping_add(*byte as i8 as i32)
+ })
+}
+
+fn java_long_hash(value: i64) -> i32 {
+ let bits = value as u64;
+ (bits ^ (bits >> 32)) as u32 as i32
+}
+
+fn java_float_bits(value: f32) -> u32 {
+ if value == 0.0 {
+ 0
+ } else if value.is_nan() {
+ 0x7fc0_0000
+ } else {
+ value.to_bits()
+ }
+}
+
+fn java_double_bits(value: f64) -> u64 {
+ if value == 0.0 {
+ 0
+ } else if value.is_nan() {
+ 0x7ff8_0000_0000_0000
+ } else {
+ value.to_bits()
+ }
+}
+
+fn normalize_decimal(mut unscaled: i128, mut scale: u32) -> (i128, u32) {
+ if unscaled == 0 {
+ return (0, 0);
+ }
+ while scale > 0 && unscaled % 10 == 0 {
+ unscaled /= 10;
+ scale -= 1;
+ }
+ (unscaled, scale)
+}
+
+fn java_big_decimal_hash(unscaled: i128, scale: u32) -> i32 {
+ if let Ok(compact) = i64::try_from(unscaled) {
+ if compact != i64::MIN {
+ let val = if compact < 0 {
+ compact.wrapping_neg() as u64
+ } else {
+ compact as u64
+ };
+ let temp = ((val >> 32) as i32)
+ .wrapping_mul(31)
+ .wrapping_add(val as u32 as i32);
+ let signed_temp = if compact < 0 {
+ temp.wrapping_neg()
+ } else {
+ temp
+ };
+ return signed_temp.wrapping_mul(31).wrapping_add(scale as i32);
+ }
+ }
+
+ java_big_integer_hash(unscaled)
+ .wrapping_mul(31)
+ .wrapping_add(scale as i32)
+}
+
+fn java_big_integer_hash(value: i128) -> i32 {
+ if value == 0 {
+ return 0;
+ }
+
+ let sign = if value < 0 { -1_i32 } else { 1_i32 };
+ let mut magnitude = value.unsigned_abs();
+ let mut words = Vec::new();
+ while magnitude != 0 {
+ words.push((magnitude & 0xffff_ffff) as u32);
+ magnitude >>= 32;
+ }
+ words.reverse();
+
+ let hash = words.into_iter().fold(0_i32, |hash, word| {
+ hash.wrapping_mul(31).wrapping_add(word as i32)
+ });
+ hash.wrapping_mul(sign)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::spec::{BigIntType, BooleanType, DecimalType, IntType,
VarBinaryType, VarCharType};
+
+ #[test]
+ fn mod_bucket_uses_floor_mod_for_int_and_bigint() {
+ let int_type = DataType::Int(IntType::new());
+ let long_type = DataType::BigInt(BigIntType::new());
+
+ assert_eq!(
+ bucket_for_datums(
+ &[(Some(&Datum::Int(-3)), &int_type)],
+ BucketFunctionType::Mod,
+ 5,
+ )
+ .unwrap(),
+ 2
+ );
+ assert_eq!(
+ bucket_for_datums(
+ &[(Some(&Datum::Long(17)), &long_type)],
+ BucketFunctionType::Mod,
+ 5,
+ )
+ .unwrap(),
+ 2
+ );
+ }
+
+ #[test]
+ fn hive_bucket_matches_java_reference_case() {
+ let bool_type = DataType::Boolean(BooleanType::new());
+ let int_type = DataType::Int(IntType::new());
+ let string_type = DataType::VarChar(VarCharType::default());
+ let bytes_type = DataType::VarBinary(VarBinaryType::default());
+ let decimal_type = DataType::Decimal(DecimalType::new(10, 4).unwrap());
+
+ let bucket = bucket_for_datums(
+ &[
+ (Some(&Datum::Bool(true)), &bool_type),
+ (Some(&Datum::Int(7)), &int_type),
+ (Some(&Datum::String("hello".into())), &string_type),
+ (Some(&Datum::Bytes(vec![1, 2, 3])), &bytes_type),
+ (
+ Some(&Datum::Decimal {
+ unscaled: 123400,
+ precision: 10,
+ scale: 4,
+ }),
+ &decimal_type,
+ ),
+ ],
+ BucketFunctionType::Hive,
+ 8,
+ )
+ .unwrap();
+
+ let expected_hash = 31_i32
+ .wrapping_mul(
+ 31_i32
+ .wrapping_mul(
+ 31_i32
+
.wrapping_mul(31_i32.wrapping_mul(1).wrapping_add(7))
+ .wrapping_add(99_162_322),
+ )
+ .wrapping_add(1_026),
+ )
+ .wrapping_add(38_256);
+ assert_eq!(bucket, positive_mod(expected_hash, 8));
+ }
+
+ #[test]
+ fn hive_decimal_hash_trims_trailing_zeros_like_big_decimal() {
+ assert_eq!(normalize_decimal(123400, 4), (1234, 2));
+ assert_eq!(normalize_decimal(0, 8), (0, 0));
+ }
+}
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 3158ffc..e7a9052 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -27,6 +27,7 @@ mod bucket_assigner_cross;
mod bucket_assigner_dynamic;
mod bucket_assigner_fixed;
mod bucket_filter;
+mod bucket_function;
mod commit_message;
pub(crate) mod cow_writer;
mod data_evolution_reader;
diff --git a/crates/paimon/src/table/read_builder.rs
b/crates/paimon/src/table/read_builder.rs
index f11acc7..099a8a0 100644
--- a/crates/paimon/src/table/read_builder.rs
+++ b/crates/paimon/src/table/read_builder.rs
@@ -70,10 +70,6 @@ pub(super) fn split_scan_predicates(
fn bucket_predicate(table: &Table, filter: &Predicate) -> Option<Predicate> {
let core_options = CoreOptions::new(table.schema().options());
- if !core_options.is_default_bucket_function() {
- return None;
- }
-
let bucket_keys = core_options.bucket_key().unwrap_or_else(|| {
if table.schema().trimmed_primary_keys().is_empty() {
Vec::new()
diff --git a/crates/paimon/src/table/table_scan.rs
b/crates/paimon/src/table/table_scan.rs
index 170405b..bd847d1 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -30,8 +30,9 @@ use super::stats_filter::{
use super::Table;
use crate::io::FileIO;
use crate::spec::{
- avro::SharedSchemaCache, bucket_dir_name, BinaryRow, CoreOptions,
DataField, DataFileMeta,
- FileKind, IndexManifest, ManifestEntry, PartitionComputer, Predicate,
Snapshot,
+ avro::SharedSchemaCache, bucket_dir_name, BinaryRow, BucketFunctionType,
CoreOptions,
+ DataField, DataFileMeta, FileKind, IndexManifest, ManifestEntry,
PartitionComputer, Predicate,
+ Snapshot,
};
use crate::table::bin_pack::split_for_batch;
use crate::table::merge_tree_split_generator::{
@@ -92,6 +93,7 @@ async fn read_all_manifest_entries(
schema_fields: &[DataField],
bucket_predicate: Option<&Predicate>,
bucket_key_fields: &[DataField],
+ bucket_function_type: BucketFunctionType,
) -> crate::Result<Vec<ManifestEntry>> {
let (mut manifest_files, delta) = futures::try_join!(
read_manifest_list(file_io, table_path, snapshot.base_manifest_list()),
@@ -142,7 +144,12 @@ async fn read_all_manifest_entries(
}
if let Some(pred) = bucket_predicate {
let targets =
bucket_cache.entry(total_buckets).or_insert_with(|| {
- compute_target_buckets(pred,
bucket_key_fields, total_buckets)
+ compute_target_buckets(
+ pred,
+ bucket_key_fields,
+ bucket_function_type,
+ total_buckets,
+ )
});
if let Some(targets) = targets {
if !targets.contains(&bucket) {
@@ -489,29 +496,29 @@ impl<'a> TableScan<'a> {
self.data_predicates.as_slice()
};
- let bucket_key_fields: Vec<DataField> =
- if self.bucket_predicate.is_none() ||
!core_options.is_default_bucket_function() {
- Vec::new()
- } else {
- let bucket_keys = core_options.bucket_key().unwrap_or_else(|| {
- if has_primary_keys {
- self.table.schema().trimmed_primary_keys()
- } else {
- Vec::new()
- }
- });
- bucket_keys
- .iter()
- .filter_map(|key| {
- self.table
- .schema()
- .fields()
- .iter()
- .find(|f| f.name() == key)
- .cloned()
- })
- .collect::<Vec<_>>()
- };
+ let bucket_key_fields: Vec<DataField> = if
self.bucket_predicate.is_none() {
+ Vec::new()
+ } else {
+ let bucket_keys = core_options.bucket_key().unwrap_or_else(|| {
+ if has_primary_keys {
+ self.table.schema().trimmed_primary_keys()
+ } else {
+ Vec::new()
+ }
+ });
+ bucket_keys
+ .iter()
+ .filter_map(|key| {
+ self.table
+ .schema()
+ .fields()
+ .iter()
+ .find(|f| f.name() == key)
+ .cloned()
+ })
+ .collect::<Vec<_>>()
+ };
+ let bucket_function_type = core_options.bucket_function_type()?;
let entries = read_all_manifest_entries(
file_io,
@@ -527,6 +534,7 @@ impl<'a> TableScan<'a> {
self.table.schema().fields(),
self.bucket_predicate.as_ref(),
&bucket_key_fields,
+ bucket_function_type,
)
.await?;
Ok(merge_manifest_entries(entries))
@@ -827,10 +835,10 @@ mod tests {
use crate::catalog::Identifier;
use crate::io::FileIOBuilder;
use crate::spec::{
- stats::BinaryTableStats, ArrayType, BinaryRow, BinaryRowBuilder,
DataField, DataFileMeta,
- DataType, Datum, DeletionVectorMeta, FileKind, IndexFileMeta,
IndexManifestEntry, IntType,
- Predicate, PredicateBuilder, PredicateOperator, Schema as
PaimonSchema, TableSchema,
- VarCharType,
+ stats::BinaryTableStats, ArrayType, BinaryRow, BinaryRowBuilder,
BucketFunctionType,
+ DataField, DataFileMeta, DataType, Datum, DeletionVectorMeta,
FileKind, IndexFileMeta,
+ IndexManifestEntry, IntType, Predicate, PredicateBuilder,
PredicateOperator,
+ Schema as PaimonSchema, TableSchema, VarCharType,
};
use crate::table::bucket_filter::{compute_target_buckets,
extract_predicate_for_keys};
use crate::table::partition_filter::PartitionFilter;
@@ -839,6 +847,7 @@ mod tests {
use crate::table::Table;
use crate::Error;
use chrono::{DateTime, Utc};
+ use std::collections::HashSet;
/// Helper to build a DataFileMeta with data evolution fields.
fn make_evo_file(
@@ -1524,7 +1533,7 @@ mod tests {
literals: vec![Datum::Int(42)],
};
- let buckets = compute_target_buckets(&pred, &fields, 4);
+ let buckets = compute_target_buckets(&pred, &fields,
BucketFunctionType::Default, 4);
assert!(buckets.is_some());
let buckets = buckets.unwrap();
assert_eq!(buckets.len(), 1);
@@ -1544,7 +1553,7 @@ mod tests {
literals: vec![Datum::Int(1), Datum::Int(2), Datum::Int(3)],
};
- let buckets = compute_target_buckets(&pred, &fields, 4);
+ let buckets = compute_target_buckets(&pred, &fields,
BucketFunctionType::Default, 4);
assert!(buckets.is_some());
let buckets = buckets.unwrap();
// Should have at most 3 buckets (could be fewer if some hash to the
same bucket)
@@ -1566,7 +1575,7 @@ mod tests {
literals: vec![Datum::Int(10)],
};
- let buckets = compute_target_buckets(&pred, &fields, 4);
+ let buckets = compute_target_buckets(&pred, &fields,
BucketFunctionType::Default, 4);
assert!(
buckets.is_none(),
"Range predicates cannot determine target buckets"
@@ -1596,7 +1605,7 @@ mod tests {
},
]);
- let buckets = compute_target_buckets(&pred, &fields, 8);
+ let buckets = compute_target_buckets(&pred, &fields,
BucketFunctionType::Default, 8);
assert!(buckets.is_some());
let buckets = buckets.unwrap();
assert_eq!(buckets.len(), 1);
@@ -1619,7 +1628,7 @@ mod tests {
literals: vec![Datum::Int(1)],
};
- let buckets = compute_target_buckets(&pred, &fields, 8);
+ let buckets = compute_target_buckets(&pred, &fields,
BucketFunctionType::Default, 8);
assert!(
buckets.is_none(),
"Partial bucket key should not determine target buckets"
@@ -1641,7 +1650,7 @@ mod tests {
literals: vec![Datum::String("alice".into())],
};
- let buckets = compute_target_buckets(&pred, &fields, 4);
+ let buckets = compute_target_buckets(&pred, &fields,
BucketFunctionType::Default, 4);
assert!(buckets.is_some());
let buckets = buckets.unwrap();
assert_eq!(buckets.len(), 1);
@@ -1649,6 +1658,52 @@ mod tests {
assert!((0..4).contains(&bucket));
}
+ #[test]
+ fn test_compute_target_buckets_mod_function() {
+ let fields = bucket_key_fields();
+ let pred = Predicate::Leaf {
+ column: "id".into(),
+ index: 0,
+ data_type: DataType::Int(IntType::new()),
+ op: PredicateOperator::Eq,
+ literals: vec![Datum::Int(-3)],
+ };
+
+ let buckets = compute_target_buckets(&pred, &fields,
BucketFunctionType::Mod, 5);
+ assert_eq!(buckets, Some(HashSet::from([2])));
+ }
+
+ #[test]
+ fn test_compute_target_buckets_hive_function() {
+ let fields = vec![
+ DataField::new(0, "id".to_string(), DataType::Int(IntType::new())),
+ DataField::new(
+ 1,
+ "name".to_string(),
+ DataType::VarChar(VarCharType::default()),
+ ),
+ ];
+ let pred = Predicate::And(vec![
+ Predicate::Leaf {
+ column: "id".into(),
+ index: 0,
+ data_type: DataType::Int(IntType::new()),
+ op: PredicateOperator::Eq,
+ literals: vec![Datum::Int(7)],
+ },
+ Predicate::Leaf {
+ column: "name".into(),
+ index: 1,
+ data_type: DataType::VarChar(VarCharType::default()),
+ op: PredicateOperator::Eq,
+ literals: vec![Datum::String("hello".into())],
+ },
+ ]);
+
+ let buckets = compute_target_buckets(&pred, &fields,
BucketFunctionType::Hive, 8);
+ assert_eq!(buckets, Some(HashSet::from([3])));
+ }
+
#[test]
fn test_compute_target_buckets_is_null() {
let fields = bucket_key_fields();
@@ -1660,7 +1715,7 @@ mod tests {
literals: vec![],
};
- let buckets = compute_target_buckets(&pred, &fields, 4);
+ let buckets = compute_target_buckets(&pred, &fields,
BucketFunctionType::Default, 4);
assert!(buckets.is_some(), "IsNull should determine a target bucket");
let buckets = buckets.unwrap();
assert_eq!(buckets.len(), 1);
@@ -1698,7 +1753,7 @@ mod tests {
},
]);
- let buckets = compute_target_buckets(&pred, &fields, 8);
+ let buckets = compute_target_buckets(&pred, &fields,
BucketFunctionType::Default, 8);
assert!(
buckets.is_some(),
"Composite key with IsNull should determine a target bucket"
diff --git a/crates/paimon/src/table/table_write.rs
b/crates/paimon/src/table/table_write.rs
index e5b62e4..2eaa6d2 100644
--- a/crates/paimon/src/table/table_write.rs
+++ b/crates/paimon/src/table/table_write.rs
@@ -23,8 +23,8 @@
use crate::arrow::build_target_arrow_schema;
use crate::spec::PartitionComputer;
use crate::spec::{
- first_row_supports_changelog_producer, BinaryRow, ChangelogProducer,
CoreOptions, DataType,
- MergeEngine, EMPTY_SERIALIZED_ROW, POSTPONE_BUCKET,
+ first_row_supports_changelog_producer, BinaryRow, ChangelogProducer,
CoreOptions, DataField,
+ DataType, MergeEngine, EMPTY_SERIALIZED_ROW, POSTPONE_BUCKET,
};
use crate::table::blob_file_writer::AppendBlobFileWriter;
use crate::table::bucket_assigner::{BucketAssignerEnum, PartitionBucketKey};
@@ -32,6 +32,7 @@ use
crate::table::bucket_assigner_constant::ConstantBucketAssigner;
use crate::table::bucket_assigner_cross::CrossPartitionAssigner;
use crate::table::bucket_assigner_dynamic::DynamicBucketAssigner;
use crate::table::bucket_assigner_fixed::FixedBucketAssigner;
+use crate::table::bucket_function::validate_bucket_function;
use crate::table::commit_message::CommitMessage;
use crate::table::data_file_writer::DataFileWriter;
use crate::table::kv_file_writer::{KeyValueFileWriter, KeyValueWriteConfig};
@@ -259,6 +260,7 @@ impl TableWrite {
}
let target_bucket_row_number =
core_options.dynamic_bucket_target_row_num();
+ let bucket_function_type = core_options.bucket_function_type()?;
let bucket_assigner = if is_dynamic_cross_partition {
BucketAssignerEnum::CrossPartition(Box::new(CrossPartitionAssigner::new(
@@ -286,9 +288,15 @@ impl TableWrite {
} else if total_buckets <= 1 || bucket_key_indices.is_empty() {
BucketAssignerEnum::Constant(ConstantBucketAssigner::new(partition_field_indices,
0))
} else {
+ let bucket_key_fields: Vec<DataField> = bucket_key_indices
+ .iter()
+ .map(|&idx| fields[idx].clone())
+ .collect();
+ validate_bucket_function(bucket_function_type,
&bucket_key_fields)?;
BucketAssignerEnum::Fixed(FixedBucketAssigner::new(
partition_field_indices,
bucket_key_indices,
+ bucket_function_type,
total_buckets,
))
};
@@ -740,7 +748,7 @@ mod tests {
};
use crate::table::{SnapshotManager, TableCommit};
use arrow_array::RecordBatchReader as _;
- use arrow_array::{Int32Array, Int64Array, Int8Array};
+ use arrow_array::{Int32Array, Int64Array, Int8Array, StringArray};
use arrow_schema::{
DataType as ArrowDataType, Field as ArrowField, Schema as ArrowSchema,
TimeUnit,
};
@@ -1319,6 +1327,117 @@ mod tests {
.unwrap()
}
+ #[tokio::test]
+ async fn test_mod_bucket_function_routes_by_floor_mod() {
+ let file_io = test_file_io();
+ let table_path = "memory:/test_mod_bucket_function";
+ setup_dirs(&file_io, table_path).await;
+
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("value", DataType::Int(IntType::new()))
+ .option("bucket", "5")
+ .option("bucket-key", "id")
+ .option("bucket-function.type", "mod")
+ .build()
+ .unwrap();
+ let table = Table::new(
+ file_io.clone(),
+ Identifier::new("default", "test_table"),
+ table_path.to_string(),
+ TableSchema::new(0, &schema),
+ None,
+ );
+
+ let fields = table.schema().fields().to_vec();
+ let mut table_write = TableWrite::new(&table,
"test-user".to_string()).unwrap();
+ let output = table_write
+ .bucket_assigner
+ .assign_batch(&make_batch(vec![-3, 17, 5], vec![10, 20, 30]),
&fields)
+ .await
+ .unwrap();
+
+ assert_eq!(output.buckets, vec![2, 2, 0]);
+ }
+
+ #[tokio::test]
+ async fn test_mod_bucket_function_rejects_non_integral_key() {
+ let file_io = test_file_io();
+ let table_path = "memory:/test_mod_bucket_invalid";
+ setup_dirs(&file_io, table_path).await;
+
+ let schema = Schema::builder()
+ .column("id", DataType::VarChar(VarCharType::string_type()))
+ .column("value", DataType::Int(IntType::new()))
+ .option("bucket", "5")
+ .option("bucket-key", "id")
+ .option("bucket-function.type", "mod")
+ .build()
+ .unwrap();
+ let table = Table::new(
+ file_io,
+ Identifier::new("default", "test_table"),
+ table_path.to_string(),
+ TableSchema::new(0, &schema),
+ None,
+ );
+
+ let err = match TableWrite::new(&table, "test-user".to_string()) {
+ Ok(_) => panic!("expected mod bucket function to reject
non-integral key"),
+ Err(err) => err,
+ };
+ assert!(
+ matches!(err, crate::Error::ConfigInvalid { ref message } if
message.contains("INT or BIGINT")),
+ "expected ConfigInvalid for non-integral mod bucket key, got
{err:?}"
+ );
+ }
+
+ #[tokio::test]
+ async fn test_hive_bucket_function_routes_by_hive_hash() {
+ let file_io = test_file_io();
+ let table_path = "memory:/test_hive_bucket_function";
+ setup_dirs(&file_io, table_path).await;
+
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("name", DataType::VarChar(VarCharType::string_type()))
+ .option("bucket", "8")
+ .option("bucket-key", "id,name")
+ .option("bucket-function.type", "hive")
+ .build()
+ .unwrap();
+ let table = Table::new(
+ file_io.clone(),
+ Identifier::new("default", "test_table"),
+ table_path.to_string(),
+ TableSchema::new(0, &schema),
+ None,
+ );
+
+ let arrow_schema = Arc::new(ArrowSchema::new(vec![
+ ArrowField::new("id", ArrowDataType::Int32, false),
+ ArrowField::new("name", ArrowDataType::Utf8, false),
+ ]));
+ let batch = RecordBatch::try_new(
+ arrow_schema,
+ vec![
+ Arc::new(Int32Array::from(vec![7])),
+ Arc::new(StringArray::from(vec!["hello"])),
+ ],
+ )
+ .unwrap();
+
+ let fields = table.schema().fields().to_vec();
+ let mut table_write = TableWrite::new(&table,
"test-user".to_string()).unwrap();
+ let output = table_write
+ .bucket_assigner
+ .assign_batch(&batch, &fields)
+ .await
+ .unwrap();
+
+ assert_eq!(output.buckets, vec![3]);
+ }
+
#[tokio::test]
async fn test_write_bucketed_with_null_bucket_key() {
let file_io = test_file_io();
diff --git a/docs/src/sql.md b/docs/src/sql.md
index 6705578..a4dc0fa 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -903,6 +903,7 @@ Set via `WITH ('key' = 'value')` at table creation time, or
dynamically via `SET
| `'bucket' = '-1'` | Dynamic bucket mode (HASH index) |
| `'bucket' = '-2'` | Postpone bucket mode (deferred assignment) |
| `'bucket-key' = 'col'` | Explicit bucket key column |
+| `'bucket-function.type' = 'default' \| 'mod' \| 'hive'` | Function used to
map fixed bucket keys to bucket ids |
### Merge Engine