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 41372de feat(index): add single-column btree global index build (#454)
41372de is described below
commit 41372dedd4f0d0b25a50e80c03e9ab563fab03da
Author: QuakeWang <[email protected]>
AuthorDate: Tue Jul 7 14:55:24 2026 +0800
feat(index): add single-column btree global index build (#454)
Add a BTree global index builder for single-column data-evolution append
tables. The builder plans row-id coverage conservatively, writes
Java-compatible BTree/index manifest metadata, and exposes the minimal Rust API
plus DataFusion create_global_index procedure.
---
crates/integrations/datafusion/src/procedures.rs | 31 +
crates/integrations/datafusion/tests/procedures.rs | 146 ++-
crates/paimon/src/btree/mod.rs | 1 +
crates/paimon/src/spec/core_options.rs | 43 +
.../src/table/btree_global_index_build_builder.rs | 1234 ++++++++++++++++++++
crates/paimon/src/table/mod.rs | 6 +
6 files changed, 1460 insertions(+), 1 deletion(-)
diff --git a/crates/integrations/datafusion/src/procedures.rs
b/crates/integrations/datafusion/src/procedures.rs
index 91904f5..89a3593 100644
--- a/crates/integrations/datafusion/src/procedures.rs
+++ b/crates/integrations/datafusion/src/procedures.rs
@@ -23,6 +23,7 @@
//! - `CALL sys.rollback_to(table => '...', snapshot_id => ... | tag => '...')`
//! - `CALL sys.rollback_to_timestamp(table => '...', timestamp => ...)`
//! - `CALL sys.create_tag_from_timestamp(table => '...', tag => '...',
timestamp => ...)`
+//! - `CALL sys.create_global_index(table => '...', index_column => '...',
index_type => 'btree')`
//! - `CALL sys.create_lumina_index(table => '...', index_column => '...')`
use std::collections::HashMap;
@@ -148,6 +149,7 @@ pub async fn execute_call(
"create_tag_from_timestamp" => {
proc_create_tag_from_timestamp(ctx, catalog, catalog_name,
&args).await
}
+ "create_global_index" => proc_create_global_index(ctx, catalog,
catalog_name, &args).await,
"create_lumina_index" => proc_create_lumina_index(ctx, catalog,
catalog_name, &args).await,
_ => Err(DataFusionError::Plan(format!(
"Unknown procedure: {proc_name}"
@@ -527,6 +529,35 @@ async fn proc_create_lumina_index(
ok_result(ctx)
}
+async fn proc_create_global_index(
+ ctx: &SessionContext,
+ catalog: &Arc<dyn Catalog>,
+ catalog_name: &str,
+ args: &HashMap<String, String>,
+) -> DFResult<DataFrame> {
+ let table = get_table(catalog, catalog_name, args).await?;
+ let index_column = require_arg(args, "index_column")?;
+ let index_type = args
+ .get("index_type")
+ .map(String::as_str)
+ .unwrap_or("btree");
+ if !index_type.eq_ignore_ascii_case("btree") {
+ return Err(DataFusionError::NotImplemented(format!(
+ "create_global_index only supports index_type => 'btree', got
'{index_type}'"
+ )));
+ }
+ if args.contains_key("options") {
+ return Err(DataFusionError::NotImplemented(
+ "create_global_index options are not supported for btree
yet".to_string(),
+ ));
+ }
+
+ let mut builder = table.new_btree_global_index_build_builder();
+ builder.with_index_column(index_column);
+ builder.execute().await.map_err(to_datafusion_error)?;
+ ok_result(ctx)
+}
+
fn parse_key_value_options(options: &str) -> DFResult<HashMap<String, String>>
{
let mut parsed = HashMap::new();
for entry in options.split(',').map(str::trim).filter(|s| !s.is_empty()) {
diff --git a/crates/integrations/datafusion/tests/procedures.rs
b/crates/integrations/datafusion/tests/procedures.rs
index 82aa198..ec2d486 100644
--- a/crates/integrations/datafusion/tests/procedures.rs
+++ b/crates/integrations/datafusion/tests/procedures.rs
@@ -17,7 +17,7 @@
mod common;
-use common::{assert_sql_error, exec, row_count, setup_sql_context};
+use common::{assert_sql_error, collect_id_name, exec, row_count,
setup_sql_context};
async fn setup_table_with_snapshots() -> (tempfile::TempDir,
paimon_datafusion::SQLContext) {
let (tmp, sql_context) = setup_sql_context().await;
@@ -121,6 +121,150 @@ async fn
test_create_lumina_index_rejects_invalid_options() {
.await;
}
+async fn setup_btree_global_index_table(
+ table_name: &str,
+) -> (tempfile::TempDir, paimon_datafusion::SQLContext) {
+ let (tmp, sql_context) = setup_sql_context().await;
+ exec(
+ &sql_context,
+ &format!(
+ "CREATE TABLE paimon.test_db.{table_name} (id INT, name
VARCHAR(100)) WITH (\
+ 'row-tracking.enabled' = 'true',\
+ 'data-evolution.enabled' = 'true',\
+ 'global-index.enabled' = 'true',\
+ 'sorted-index.records-per-range' = '10'\
+ )"
+ ),
+ )
+ .await;
+ (tmp, sql_context)
+}
+
+#[tokio::test]
+async fn test_create_global_index_requires_index_column() {
+ let (_tmp, sql_context) =
setup_btree_global_index_table("btree_missing_col").await;
+
+ assert_sql_error(
+ &sql_context,
+ "CALL sys.create_global_index(table => 'test_db.btree_missing_col')",
+ "Missing required argument: 'index_column'",
+ )
+ .await;
+}
+
+#[tokio::test]
+async fn test_create_global_index_rejects_non_btree() {
+ let (_tmp, sql_context) =
setup_btree_global_index_table("btree_bad_type").await;
+
+ assert_sql_error(
+ &sql_context,
+ "CALL sys.create_global_index(table => 'test_db.btree_bad_type',
index_column => 'id', index_type => 'bitmap')",
+ "only supports index_type => 'btree'",
+ )
+ .await;
+}
+
+#[tokio::test]
+async fn test_create_global_index_rejects_options() {
+ let (_tmp, sql_context) =
setup_btree_global_index_table("btree_options").await;
+
+ assert_sql_error(
+ &sql_context,
+ "CALL sys.create_global_index(table => 'test_db.btree_options',
index_column => 'id', options => 'x=y')",
+ "options are not supported",
+ )
+ .await;
+}
+
+#[tokio::test]
+async fn test_create_global_index_builds_btree_and_filter_reads() {
+ let (_tmp, sql_context) =
setup_btree_global_index_table("btree_build").await;
+ exec(
+ &sql_context,
+ "INSERT INTO paimon.test_db.btree_build (id, name) VALUES (1,
'alice'), (2, 'bob'), (3, 'carol')",
+ )
+ .await;
+
+ exec(
+ &sql_context,
+ "CALL sys.create_global_index(table => 'test_db.btree_build',
index_column => 'id', index_type => 'btree')",
+ )
+ .await;
+
+ let index_count = row_count(
+ &sql_context,
+ "SELECT * FROM paimon.test_db.`btree_build$table_indexes` \
+ WHERE index_type = 'btree' AND row_range_start = 0 AND row_range_end
= 2 \
+ AND index_field_name = 'id'",
+ )
+ .await;
+ assert_eq!(index_count, 1);
+
+ let rows = collect_id_name(
+ &sql_context,
+ "SELECT id, name FROM paimon.test_db.btree_build WHERE id = 2",
+ )
+ .await;
+ assert_eq!(rows, vec![(2, "bob".to_string())]);
+}
+
+#[tokio::test]
+async fn test_create_global_index_fast_full_detail_after_append() {
+ let (_tmp, sql_context) =
setup_btree_global_index_table("btree_coverage").await;
+ exec(
+ &sql_context,
+ "INSERT INTO paimon.test_db.btree_coverage (id, name) VALUES (1,
'alice'), (2, 'bob')",
+ )
+ .await;
+ exec(
+ &sql_context,
+ "CALL sys.create_global_index(table => 'test_db.btree_coverage',
index_column => 'id')",
+ )
+ .await;
+ exec(
+ &sql_context,
+ "INSERT INTO paimon.test_db.btree_coverage (id, name) VALUES (3,
'carol')",
+ )
+ .await;
+
+ let fast_rows = collect_id_name(
+ &sql_context,
+ "SELECT id, name FROM paimon.test_db.btree_coverage WHERE id >= 2",
+ )
+ .await;
+ assert_eq!(fast_rows, vec![(2, "bob".to_string())]);
+
+ exec(
+ &sql_context,
+ "SET 'paimon.global-index.search-mode' = 'full'",
+ )
+ .await;
+ let full_rows = collect_id_name(
+ &sql_context,
+ "SELECT id, name FROM paimon.test_db.btree_coverage WHERE id >= 2",
+ )
+ .await;
+ assert_eq!(
+ full_rows,
+ vec![(2, "bob".to_string()), (3, "carol".to_string())]
+ );
+
+ exec(
+ &sql_context,
+ "SET 'paimon.global-index.search-mode' = 'detail'",
+ )
+ .await;
+ let detail_rows = collect_id_name(
+ &sql_context,
+ "SELECT id, name FROM paimon.test_db.btree_coverage WHERE id >= 2",
+ )
+ .await;
+ assert_eq!(
+ detail_rows,
+ vec![(2, "bob".to_string()), (3, "carol".to_string())]
+ );
+}
+
#[tokio::test]
async fn test_create_tag_already_exists() {
let (_tmp, sql_context) = setup_table_with_snapshots().await;
diff --git a/crates/paimon/src/btree/mod.rs b/crates/paimon/src/btree/mod.rs
index bb0f874..ca68faa 100644
--- a/crates/paimon/src/btree/mod.rs
+++ b/crates/paimon/src/btree/mod.rs
@@ -46,6 +46,7 @@ mod sst_file;
mod var_len;
mod writer;
+pub use block::BlockCompressionType;
pub use footer::BTreeFileFooter;
pub use key_serde::{make_key_comparator, serialize_datum};
pub use meta::BTreeIndexMeta;
diff --git a/crates/paimon/src/spec/core_options.rs
b/crates/paimon/src/spec/core_options.rs
index c5f1e50..e7d0fa6 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -24,6 +24,7 @@ const GLOBAL_INDEX_ENABLED_OPTION: &str =
"global-index.enabled";
const GLOBAL_INDEX_SEARCH_MODE_OPTION: &str = "global-index.search-mode";
const GLOBAL_INDEX_ROW_COUNT_PER_SHARD_OPTION: &str =
"global-index.row-count-per-shard";
const GLOBAL_INDEX_COLUMN_UPDATE_ACTION_OPTION: &str =
"global-index.column-update-action";
+const SORTED_INDEX_RECORDS_PER_RANGE_OPTION: &str =
"sorted-index.records-per-range";
const SOURCE_SPLIT_TARGET_SIZE_OPTION: &str = "source.split.target-size";
const SOURCE_SPLIT_OPEN_FILE_COST_OPTION: &str = "source.split.open-file-cost";
const PARTITION_DEFAULT_NAME_OPTION: &str = "partition.default-name";
@@ -417,6 +418,22 @@ impl<'a> CoreOptions<'a> {
Ok(value)
}
+ pub fn sorted_index_records_per_range(&self) -> crate::Result<i64> {
+ let value = self
+ .parse_i64_option(SORTED_INDEX_RECORDS_PER_RANGE_OPTION)?
+ .unwrap_or(DEFAULT_GLOBAL_INDEX_ROW_COUNT_PER_SHARD);
+ if value <= 0 {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "Option '{}' must be greater than 0, got: {}",
+ SORTED_INDEX_RECORDS_PER_RANGE_OPTION, value
+ ),
+ source: None,
+ });
+ }
+ Ok(value)
+ }
+
pub fn global_index_column_update_action(
&self,
) -> crate::Result<GlobalIndexColumnUpdateAction> {
@@ -815,6 +832,10 @@ mod tests {
core_options.global_index_row_count_per_shard().unwrap(),
100_000
);
+ assert_eq!(
+ core_options.sorted_index_records_per_range().unwrap(),
+ 100_000
+ );
assert_eq!(
core_options.global_index_column_update_action().unwrap(),
GlobalIndexColumnUpdateAction::ThrowError
@@ -840,6 +861,10 @@ mod tests {
GLOBAL_INDEX_ROW_COUNT_PER_SHARD_OPTION.to_string(),
"2048".to_string(),
),
+ (
+ SORTED_INDEX_RECORDS_PER_RANGE_OPTION.to_string(),
+ "4096".to_string(),
+ ),
(
GLOBAL_INDEX_COLUMN_UPDATE_ACTION_OPTION.to_string(),
"DROP_PARTITION_INDEX".to_string(),
@@ -857,6 +882,7 @@ mod tests {
core_options.global_index_row_count_per_shard().unwrap(),
2048
);
+ assert_eq!(core_options.sorted_index_records_per_range().unwrap(),
4096);
assert_eq!(
core_options.global_index_column_update_action().unwrap(),
GlobalIndexColumnUpdateAction::DropPartitionIndex
@@ -912,6 +938,23 @@ mod tests {
}
}
+ #[test]
+ fn test_sorted_index_records_per_range_rejects_invalid_values() {
+ for value in ["0", "-1", "abc"] {
+ let options = HashMap::from([(
+ SORTED_INDEX_RECORDS_PER_RANGE_OPTION.to_string(),
+ value.to_string(),
+ )]);
+ let core = CoreOptions::new(&options);
+
+ let err = core
+ .sorted_index_records_per_range()
+ .expect_err("invalid records-per-range should fail");
+ assert!(matches!(err, crate::Error::DataInvalid { message, .. }
+ if
message.contains(SORTED_INDEX_RECORDS_PER_RANGE_OPTION)));
+ }
+ }
+
#[test]
fn test_parse_memory_size() {
assert_eq!(parse_memory_size("1024"), Some(1024));
diff --git a/crates/paimon/src/table/btree_global_index_build_builder.rs
b/crates/paimon/src/table/btree_global_index_build_builder.rs
new file mode 100644
index 0000000..88b1f11
--- /dev/null
+++ b/crates/paimon/src/table/btree_global_index_build_builder.rs
@@ -0,0 +1,1234 @@
+// 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::btree::{make_key_comparator, serialize_datum, BTreeIndexWriter,
BlockCompressionType};
+use crate::spec::{
+ bucket_dir_name, extract_datum_from_arrow, BinaryRow, CoreOptions,
DataField, DataFileMeta,
+ DataType, FileKind, GlobalIndexMeta, IndexFileMeta, IndexManifest,
ROW_ID_FIELD_NAME,
+};
+use crate::table::source::is_data_evolution_normal_file;
+use crate::table::stats_filter::group_by_overlapping_row_id;
+use crate::table::{
+ CommitMessage, DataSplit, DataSplitBuilder, RowRange, SnapshotManager,
Table, TableCommit,
+};
+use crate::{Error, Result};
+use arrow_array::{Array, Int64Array, RecordBatch};
+use futures::TryStreamExt;
+use std::cmp::Ordering;
+use std::collections::HashMap;
+
+const BTREE_INDEX_TYPE: &str = "btree";
+const INDEX_DIR: &str = "index";
+const BTREE_BLOCK_SIZE: usize = 4 * 1024;
+
+type BTreeKeyRow = (Option<Vec<u8>>, i64);
+
+pub struct BTreeGlobalIndexBuildBuilder<'a> {
+ table: &'a Table,
+ index_column: Option<String>,
+}
+
+impl<'a> BTreeGlobalIndexBuildBuilder<'a> {
+ pub(crate) fn new(table: &'a Table) -> Self {
+ Self {
+ table,
+ index_column: None,
+ }
+ }
+
+ pub fn with_index_column(&mut self, column: &str) -> &mut Self {
+ self.index_column = Some(column.to_string());
+ self
+ }
+
+ pub async fn execute(&self) -> Result<usize> {
+ let index_column = self
+ .index_column
+ .as_deref()
+ .ok_or_else(|| Error::DataInvalid {
+ message: "BTree global index column is required".to_string(),
+ source: None,
+ })?;
+
+ let core_options = CoreOptions::new(self.table.schema().options());
+ validate_table_options(self.table, &core_options)?;
+ let records_per_range = core_options.sorted_index_records_per_range()?;
+
+ let index_field = find_index_field(self.table, index_column)?;
+ validate_btree_field(index_field)?;
+
+ let snapshot_manager = SnapshotManager::new(
+ self.table.file_io().clone(),
+ self.table.location().to_string(),
+ );
+ let snapshot = snapshot_manager
+ .get_latest_snapshot()
+ .await?
+ .ok_or_else(|| Error::DataInvalid {
+ message: "Cannot build BTree global index without a
snapshot".to_string(),
+ source: None,
+ })?;
+
+ let manifest_entries = self
+ .table
+ .new_read_builder()
+ .new_scan()
+ .with_scan_all_files()
+ .plan_manifest_entries(&snapshot)
+ .await?;
+ let shards = plan_btree_shards(
+ self.table.location(),
+ self.table.schema().partition_keys(),
+ self.table.schema().fields(),
+ &core_options,
+ snapshot.id(),
+ manifest_entries,
+ records_per_range,
+ )?;
+ if shards.is_empty() {
+ return Ok(0);
+ }
+
+ validate_existing_index_overlap(
+ self.table,
+ snapshot.index_manifest(),
+ index_field.id(),
+ &shards,
+ )
+ .await?;
+
+ let shard_count = shards.len();
+ let mut messages = Vec::with_capacity(shard_count);
+ for shard in shards {
+ let index_file = self
+ .build_index_file(&shard, index_field, index_column)
+ .await?;
+ let mut message =
+ CommitMessage::new(shard.partition_bytes.clone(),
shard.source_bucket, vec![]);
+ message.new_index_files = vec![index_file];
+ messages.push(message);
+ }
+
+ TableCommit::new(
+ self.table.clone(),
+ format!(
+ "global-index-{}-create-{}",
+ BTREE_INDEX_TYPE,
+ uuid::Uuid::new_v4()
+ ),
+ )
+ .commit_if_latest_snapshot(messages, snapshot.id())
+ .await?;
+
+ Ok(shard_count)
+ }
+
+ async fn build_index_file(
+ &self,
+ shard: &BTreeGlobalIndexShard,
+ index_field: &DataField,
+ index_column: &str,
+ ) -> Result<IndexFileMeta> {
+ let row_count = checked_row_count(shard.row_range_start,
shard.row_range_end)?;
+ let mut rows = extract_index_rows(self.table, shard, index_column,
index_field).await?;
+ let cmp = make_key_comparator(index_field.data_type());
+ sort_index_rows(&mut rows, &cmp);
+
+ self.table
+ .file_io()
+ .mkdirs(&format!(
+ "{}/{INDEX_DIR}/",
+ self.table.location().trim_end_matches('/')
+ ))
+ .await?;
+ let file_name = format!("btree-global-index-{}.index",
uuid::Uuid::new_v4());
+ let index_path = format!(
+ "{}/{INDEX_DIR}/{}",
+ self.table.location().trim_end_matches('/'),
+ file_name
+ );
+ let output = self.table.file_io().new_output(&index_path)?;
+ let writer = output.writer().await?;
+ let mut writer = BTreeIndexWriter::with_comparator(
+ writer,
+ BTREE_BLOCK_SIZE,
+ BlockCompressionType::None,
+ cmp,
+ );
+ for (key, local_row_id) in &rows {
+ writer
+ .write(key.as_deref(), *local_row_id)
+ .await
+ .map_err(|e| Error::DataInvalid {
+ message: format!("Failed to write BTree global index file
'{file_name}'"),
+ source: Some(Box::new(e)),
+ })?;
+ }
+ let write_result = writer.finish().await.map_err(|e|
Error::DataInvalid {
+ message: format!("Failed to finish BTree global index file
'{file_name}'"),
+ source: Some(Box::new(e)),
+ })?;
+
+ if write_result.row_count != u64::try_from(row_count).unwrap() {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "BTree global index expected {} rows, wrote {}",
+ row_count, write_result.row_count
+ ),
+ source: None,
+ });
+ }
+
+ let status = self.table.file_io().get_status(&index_path).await?;
+ Ok(IndexFileMeta {
+ index_type: BTREE_INDEX_TYPE.to_string(),
+ file_name,
+ file_size: checked_i32(
+ status.size,
+ "Index file is too large for Rust IndexFileMeta",
+ )?,
+ row_count,
+ deletion_vectors_ranges: None,
+ global_index_meta: Some(GlobalIndexMeta {
+ row_range_start: shard.row_range_start,
+ row_range_end: shard.row_range_end,
+ index_field_id: index_field.id(),
+ extra_field_ids: None,
+ index_meta: Some(write_result.meta.serialize()),
+ }),
+ })
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct BTreeGlobalIndexShard {
+ pub partition: BinaryRow,
+ pub partition_bytes: Vec<u8>,
+ pub files: Vec<DataFileMeta>,
+ pub row_range_start: i64,
+ pub row_range_end: i64,
+ snapshot_id: i64,
+ source_bucket: i32,
+ total_buckets: i32,
+ bucket_path: String,
+}
+
+fn validate_table_options(table: &Table, core_options: &CoreOptions) ->
Result<()> {
+ if !core_options.row_tracking_enabled() {
+ return Err(Error::DataInvalid {
+ message: "BTree global index build requires 'row-tracking.enabled'
= 'true'"
+ .to_string(),
+ source: None,
+ });
+ }
+ if !core_options.data_evolution_enabled() {
+ return Err(Error::DataInvalid {
+ message: "BTree global index build requires
'data-evolution.enabled' = 'true'"
+ .to_string(),
+ source: None,
+ });
+ }
+ if !core_options.global_index_enabled() {
+ return Err(Error::DataInvalid {
+ message: "BTree global index build requires 'global-index.enabled'
= 'true'"
+ .to_string(),
+ source: None,
+ });
+ }
+ if !table.schema().primary_keys().is_empty() {
+ return Err(Error::Unsupported {
+ message: "BTree global index build does not support primary-key
tables".to_string(),
+ });
+ }
+ if core_options.deletion_vectors_enabled() {
+ return Err(Error::Unsupported {
+ message:
+ "BTree global index build does not support tables with
deletion-vectors.enabled=true"
+ .to_string(),
+ });
+ }
+ Ok(())
+}
+
+fn find_index_field<'a>(table: &'a Table, column: &str) -> Result<&'a
DataField> {
+ table
+ .schema()
+ .fields()
+ .iter()
+ .find(|field| field.name() == column)
+ .ok_or_else(|| Error::ColumnNotExist {
+ full_name: table.identifier().full_name(),
+ column: column.to_string(),
+ })
+}
+
+fn validate_btree_field(field: &DataField) -> Result<()> {
+ if !is_btree_supported_data_type(field.data_type()) {
+ return Err(Error::Unsupported {
+ message: format!(
+ "BTree global index only supports scalar columns, got {:?} for
column '{}'",
+ field.data_type(),
+ field.name()
+ ),
+ });
+ }
+ Ok(())
+}
+
+fn is_btree_supported_data_type(data_type: &DataType) -> bool {
+ matches!(
+ data_type,
+ DataType::Boolean(_)
+ | DataType::TinyInt(_)
+ | DataType::SmallInt(_)
+ | DataType::Int(_)
+ | DataType::BigInt(_)
+ | DataType::Decimal(_)
+ | DataType::Double(_)
+ | DataType::Float(_)
+ | DataType::Char(_)
+ | DataType::VarChar(_)
+ | DataType::Date(_)
+ | DataType::LocalZonedTimestamp(_)
+ | DataType::Time(_)
+ | DataType::Timestamp(_)
+ )
+}
+
+fn plan_btree_shards(
+ table_location: &str,
+ partition_keys: &[String],
+ schema_fields: &[DataField],
+ core_options: &CoreOptions,
+ snapshot_id: i64,
+ entries: Vec<crate::spec::ManifestEntry>,
+ records_per_range: i64,
+) -> Result<Vec<BTreeGlobalIndexShard>> {
+ if records_per_range <= 0 {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "Option 'sorted-index.records-per-range' must be greater than
0, got: {records_per_range}"
+ ),
+ source: None,
+ });
+ }
+
+ let mut by_partition_bucket: HashMap<(Vec<u8>, i32, i32),
Vec<DataFileMeta>> = HashMap::new();
+ for entry in entries {
+ if *entry.kind() != FileKind::Add {
+ continue;
+ }
+ if entry.file().first_row_id.is_none() {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "Data file '{}' is missing first_row_id; cannot build a
complete BTree global index",
+ entry.file().file_name
+ ),
+ source: None,
+ });
+ }
+ let (partition, bucket, total_buckets, file) = entry.into_parts();
+ by_partition_bucket
+ .entry((partition, bucket, total_buckets))
+ .or_default()
+ .push(file);
+ }
+
+ let mut result = Vec::new();
+ for ((partition_bytes, source_bucket, total_buckets), files) in
by_partition_bucket {
+ let partition = if partition_keys.is_empty() {
+ BinaryRow::new(0)
+ } else {
+ BinaryRow::from_serialized_bytes(&partition_bytes)?
+ };
+ let bucket_path = bucket_path(
+ table_location,
+ partition_keys,
+ schema_fields,
+ core_options,
+ &partition,
+ source_bucket,
+ )?;
+ let normal_groups = group_normal_file_ranges(files)?;
+ for group in normal_groups {
+ let (coverage_start, coverage_end) =
normal_coverage_range(&group.files)?;
+ let start_range = coverage_start / records_per_range;
+ let end_range = coverage_end / records_per_range;
+ for range_id in start_range..=end_range {
+ let range_start = range_id * records_per_range;
+ let range_end = range_start + records_per_range - 1;
+ let row_range_start = coverage_start.max(range_start);
+ let row_range_end = coverage_end.min(range_end);
+ result.push(BTreeGlobalIndexShard {
+ partition: partition.clone(),
+ partition_bytes: partition_bytes.clone(),
+ files: group.files.clone(),
+ row_range_start,
+ row_range_end,
+ snapshot_id,
+ source_bucket,
+ total_buckets,
+ bucket_path: bucket_path.clone(),
+ });
+ }
+ }
+ }
+ result.sort_by(|a, b| {
+ a.partition
+ .to_serialized_bytes()
+ .cmp(&b.partition.to_serialized_bytes())
+ .then(a.source_bucket.cmp(&b.source_bucket))
+ .then(a.row_range_start.cmp(&b.row_range_start))
+ });
+ Ok(result)
+}
+
+#[derive(Debug)]
+struct PlannedFileGroup {
+ files: Vec<DataFileMeta>,
+}
+
+fn group_normal_file_ranges(files: Vec<DataFileMeta>) ->
Result<Vec<PlannedFileGroup>> {
+ if files.is_empty() {
+ return Ok(Vec::new());
+ }
+ for file in &files {
+ file.row_id_range().ok_or_else(|| Error::DataInvalid {
+ message: format!(
+ "Data file '{}' is missing first_row_id; cannot build a
complete BTree global index",
+ file.file_name
+ ),
+ source: None,
+ })?;
+ }
+
+ let mut normal_ranges = files
+ .iter()
+ .filter(|file| is_data_evolution_normal_file(file))
+ .filter_map(DataFileMeta::row_id_range)
+ .collect::<Vec<_>>();
+ normal_ranges.sort_by_key(|(start, _)| *start);
+
+ let mut coverage_ranges: Vec<(i64, i64)> = Vec::new();
+ for (file_start, file_end) in normal_ranges {
+ match coverage_ranges.last_mut() {
+ Some((_, end)) if file_start <= *end + 1 => {
+ *end = (*end).max(file_end);
+ }
+ _ => coverage_ranges.push((file_start, file_end)),
+ }
+ }
+
+ coverage_ranges
+ .into_iter()
+ .map(|(start, end)| {
+ let mut group_files = files
+ .iter()
+ .filter(|file| {
+ file.row_id_range().is_some_and(|(file_start, file_end)| {
+ ranges_overlap(start, end, file_start, file_end)
+ })
+ })
+ .cloned()
+ .collect::<Vec<_>>();
+ group_files.sort_by_key(|file| {
+ (
+ file.first_row_id.unwrap_or(i64::MAX),
+ !is_data_evolution_normal_file(file),
+ file.file_name.clone(),
+ )
+ });
+ Ok(PlannedFileGroup { files: group_files })
+ })
+ .collect()
+}
+
+fn normal_coverage_range(files: &[DataFileMeta]) -> Result<(i64, i64)> {
+ let mut start = None;
+ let mut end = None;
+ for file in files
+ .iter()
+ .filter(|file| is_data_evolution_normal_file(file))
+ {
+ let (file_start, file_end) = file.row_id_range().ok_or_else(||
Error::DataInvalid {
+ message: format!(
+ "Data file '{}' is missing first_row_id; cannot build a
complete BTree global index",
+ file.file_name
+ ),
+ source: None,
+ })?;
+ start = Some(start.map_or(file_start, |value: i64|
value.min(file_start)));
+ end = Some(end.map_or(file_end, |value: i64| value.max(file_end)));
+ }
+ start.zip(end).ok_or_else(|| Error::DataInvalid {
+ message: "BTree global index shard has no normal data
files".to_string(),
+ source: None,
+ })
+}
+
+fn bucket_path(
+ table_location: &str,
+ partition_keys: &[String],
+ schema_fields: &[DataField],
+ core_options: &CoreOptions,
+ partition: &BinaryRow,
+ bucket: i32,
+) -> Result<String> {
+ let base = table_location.trim_end_matches('/');
+ if partition_keys.is_empty() {
+ return Ok(format!("{base}/{}", bucket_dir_name(bucket)));
+ }
+ let computer = crate::spec::PartitionComputer::new(
+ partition_keys,
+ schema_fields,
+ core_options.partition_default_name(),
+ core_options.legacy_partition_name(),
+ )?;
+ Ok(format!(
+ "{base}/{}{}",
+ computer.generate_partition_path(partition)?,
+ bucket_dir_name(bucket)
+ ))
+}
+
+async fn validate_existing_index_overlap(
+ table: &Table,
+ index_manifest_name: Option<&str>,
+ index_field_id: i32,
+ shards: &[BTreeGlobalIndexShard],
+) -> Result<()> {
+ let Some(index_manifest_name) = index_manifest_name else {
+ return Ok(());
+ };
+ let path = format!(
+ "{}/manifest/{}",
+ table.location().trim_end_matches('/'),
+ index_manifest_name
+ );
+ let entries = IndexManifest::read(table.file_io(), &path).await?;
+ for entry in entries {
+ if entry.kind != FileKind::Add {
+ continue;
+ }
+ let Some(meta) = entry.index_file.global_index_meta else {
+ continue;
+ };
+ if meta.index_field_id != index_field_id {
+ continue;
+ }
+ if shards.iter().any(|shard| {
+ ranges_overlap(
+ meta.row_range_start,
+ meta.row_range_end,
+ shard.row_range_start,
+ shard.row_range_end,
+ )
+ }) {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "Existing global index file '{}' overlaps requested row
range for field {}",
+ entry.index_file.file_name, index_field_id
+ ),
+ source: None,
+ });
+ }
+ }
+ Ok(())
+}
+
+async fn extract_index_rows(
+ table: &Table,
+ shard: &BTreeGlobalIndexShard,
+ index_column: &str,
+ index_field: &DataField,
+) -> Result<Vec<BTreeKeyRow>> {
+ let splits = build_read_splits_for_shard(shard)?;
+
+ let mut read_builder = table.new_read_builder();
+ read_builder.with_projection(&[index_column, ROW_ID_FIELD_NAME]);
+ let read = read_builder.new_read()?;
+ let batches = read.to_arrow(&splits)?.try_collect::<Vec<_>>().await?;
+ extract_index_rows_from_batches(
+ &batches,
+ index_column,
+ index_field.data_type(),
+ shard.row_range_start,
+ i64::from(checked_row_count(
+ shard.row_range_start,
+ shard.row_range_end,
+ )?),
+ )
+}
+
+fn build_read_splits_for_shard(shard: &BTreeGlobalIndexShard) ->
Result<Vec<DataSplit>> {
+ let shard_range = RowRange::new(shard.row_range_start,
shard.row_range_end);
+ group_by_overlapping_row_id(shard.files.clone())
+ .into_iter()
+ .filter_map(|files| {
+ let ranges = files
+ .iter()
+ .filter_map(|file| {
+ file.row_id_range()
+ .and_then(|(start, end)|
shard_range.intersect_inclusive(start, end))
+ })
+ .collect::<Vec<_>>();
+ let ranges = crate::table::merge_row_ranges(ranges);
+ if ranges.is_empty() {
+ return None;
+ }
+ let raw_convertible = files.len() == 1;
+ Some(
+ DataSplitBuilder::new()
+ .with_snapshot(shard.snapshot_id)
+ .with_partition(shard.partition.clone())
+ .with_bucket(shard.source_bucket)
+ .with_bucket_path(shard.bucket_path.clone())
+ .with_total_buckets(shard.total_buckets)
+ .with_data_files(files)
+ .with_row_ranges(ranges)
+ .with_raw_convertible(raw_convertible)
+ .build(),
+ )
+ })
+ .collect()
+}
+
+fn extract_index_rows_from_batches(
+ batches: &[RecordBatch],
+ index_column: &str,
+ data_type: &DataType,
+ row_range_start: i64,
+ expected_row_count: i64,
+) -> Result<Vec<BTreeKeyRow>> {
+ let row_count = batches.iter().map(RecordBatch::num_rows).sum::<usize>();
+ let mut rows = Vec::with_capacity(row_count);
+ let mut expected_row_id = row_range_start;
+ for batch in batches {
+ let value_index =
+ batch
+ .schema()
+ .index_of(index_column)
+ .map_err(|e| Error::DataInvalid {
+ message: format!("Index column '{index_column}' not found
in read batch: {e}"),
+ source: None,
+ })?;
+ let row_id_index =
+ batch
+ .schema()
+ .index_of(ROW_ID_FIELD_NAME)
+ .map_err(|e| Error::DataInvalid {
+ message: format!("_ROW_ID column not found in read batch:
{e}"),
+ source: None,
+ })?;
+ let row_ids = batch
+ .column(row_id_index)
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .ok_or_else(|| Error::DataInvalid {
+ message: "BTree global index build requires non-null Int64
_ROW_ID".to_string(),
+ source: None,
+ })?;
+
+ for row in 0..batch.num_rows() {
+ if row_ids.is_null(row) {
+ return Err(Error::DataInvalid {
+ message: "BTree global index build found null
_ROW_ID".to_string(),
+ source: None,
+ });
+ }
+ let row_id = row_ids.value(row);
+ if row_id != expected_row_id {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "BTree global index build expected _ROW_ID {}, got {}",
+ expected_row_id, row_id
+ ),
+ source: None,
+ });
+ }
+ expected_row_id += 1;
+
+ let key = extract_datum_from_arrow(batch, row, value_index,
data_type)?
+ .map(|datum| serialize_datum(&datum, data_type));
+ rows.push((key, row_id - row_range_start));
+ }
+ }
+ let actual_row_count = expected_row_id - row_range_start;
+ if actual_row_count != expected_row_count {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "BTree global index build expected {} rows, got {}",
+ expected_row_count, actual_row_count
+ ),
+ source: None,
+ });
+ }
+ Ok(rows)
+}
+
+fn sort_index_rows(rows: &mut [BTreeKeyRow], cmp: &dyn Fn(&[u8], &[u8]) ->
Ordering) {
+ rows.sort_by(|left, right| match (&left.0, &right.0) {
+ (None, None) => left.1.cmp(&right.1),
+ (None, Some(_)) => Ordering::Less,
+ (Some(_), None) => Ordering::Greater,
+ (Some(left_key), Some(right_key)) => {
+ cmp(left_key, right_key).then_with(|| left.1.cmp(&right.1))
+ }
+ });
+}
+
+fn checked_i32(value: u64, context: &str) -> Result<i32> {
+ i32::try_from(value).map_err(|_| Error::DataInvalid {
+ message: format!("{context}: {value}"),
+ source: None,
+ })
+}
+
+fn checked_row_count(row_range_start: i64, row_range_end: i64) -> Result<i32> {
+ if row_range_end < row_range_start {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "Invalid BTree global index row range [{row_range_start},
{row_range_end}]"
+ ),
+ source: None,
+ });
+ }
+ i32::try_from(row_range_end - row_range_start + 1).map_err(|_|
Error::DataInvalid {
+ message: format!(
+ "BTree global index row count is too large for Rust IndexFileMeta:
[{row_range_start}, {row_range_end}]"
+ ),
+ source: None,
+ })
+}
+
+fn ranges_overlap(left_start: i64, left_end: i64, right_start: i64, right_end:
i64) -> bool {
+ left_start <= right_end && right_start <= left_end
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::catalog::Identifier;
+ use crate::io::FileIOBuilder;
+ use crate::spec::stats::BinaryTableStats;
+ use crate::spec::{
+ BinaryType, GlobalIndexSearchMode, IntType, ManifestEntry,
PredicateBuilder, Schema,
+ TableSchema, VarBinaryType, VarCharType,
+ };
+ use crate::table::global_index_scanner::{evaluate_global_index,
GlobalIndexEvaluation};
+ use crate::table::{SnapshotManager, TableCommit, TableWrite};
+ use arrow_array::{ArrayRef, Int32Array, Int64Array, StringArray};
+ use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Schema
as ArrowSchema};
+ use chrono::{DateTime, Utc};
+ use std::sync::Arc;
+
+ fn data_file(name: &str, first_row_id: Option<i64>, row_count: i64) ->
DataFileMeta {
+ DataFileMeta {
+ file_name: name.to_string(),
+ file_size: 128,
+ row_count,
+ 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: 0,
+ extra_files: vec![],
+ creation_time: Some(
+ "2024-09-06T07:45:55.039+00:00"
+ .parse::<DateTime<Utc>>()
+ .unwrap(),
+ ),
+ delete_row_count: None,
+ embedded_index: None,
+ first_row_id,
+ write_cols: None,
+ external_path: None,
+ file_source: None,
+ value_stats_cols: None,
+ }
+ }
+
+ fn partial_file(name: &str, first_row_id: Option<i64>, row_count: i64) ->
DataFileMeta {
+ let mut file = data_file(name, first_row_id, row_count);
+ file.write_cols = Some(vec!["name".to_string()]);
+ file
+ }
+
+ fn manifest_entry(file: DataFileMeta) -> ManifestEntry {
+ manifest_entry_with_bucket(file, 0, 1)
+ }
+
+ fn manifest_entry_with_bucket(
+ file: DataFileMeta,
+ bucket: i32,
+ total_buckets: i32,
+ ) -> ManifestEntry {
+ ManifestEntry::new(FileKind::Add, vec![], bucket, total_buckets, file,
2)
+ }
+
+ fn table_options(records_per_range: &str) -> HashMap<String, String> {
+ HashMap::from([
+ ("row-tracking.enabled".to_string(), "true".to_string()),
+ ("data-evolution.enabled".to_string(), "true".to_string()),
+ ("global-index.enabled".to_string(), "true".to_string()),
+ (
+ "sorted-index.records-per-range".to_string(),
+ records_per_range.to_string(),
+ ),
+ ])
+ }
+
+ fn test_table(options: HashMap<String, String>) -> Table {
+ test_table_with_path("memory:/test_btree_global_index_builder",
options)
+ }
+
+ fn test_table_with_path(table_path: &str, options: HashMap<String,
String>) -> Table {
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("name", DataType::VarChar(VarCharType::string_type()))
+ .options(options)
+ .build()
+ .unwrap();
+ Table::new(
+ FileIOBuilder::new("memory").build().unwrap(),
+ Identifier::new("default", "test_table"),
+ table_path.to_string(),
+ TableSchema::new(0, &schema),
+ None,
+ )
+ }
+
+ fn plan(
+ entries: Vec<ManifestEntry>,
+ records_per_range: i64,
+ ) -> Result<Vec<BTreeGlobalIndexShard>> {
+ let table = test_table(table_options(&records_per_range.to_string()));
+ let core = CoreOptions::new(table.schema().options());
+ plan_btree_shards(
+ table.location(),
+ table.schema().partition_keys(),
+ table.schema().fields(),
+ &core,
+ 1,
+ entries,
+ records_per_range,
+ )
+ }
+
+ #[test]
+ fn test_planner_splits_single_file_across_ranges() {
+ let shards = plan(vec![manifest_entry(data_file("a", Some(0), 25))],
10).unwrap();
+
+ assert_eq!(
+ shards
+ .iter()
+ .map(|s| (s.row_range_start, s.row_range_end))
+ .collect::<Vec<_>>(),
+ vec![(0, 9), (10, 19), (20, 24)]
+ );
+ }
+
+ #[test]
+ fn test_planner_merges_contiguous_normal_files() {
+ let shards = plan(
+ vec![
+ manifest_entry(data_file("a", Some(0), 5)),
+ manifest_entry(data_file("b", Some(5), 5)),
+ ],
+ 20,
+ )
+ .unwrap();
+
+ assert_eq!(shards.len(), 1);
+ assert_eq!((shards[0].row_range_start, shards[0].row_range_end), (0,
9));
+ }
+
+ #[test]
+ fn test_planner_splits_row_id_gap_into_separate_shards() {
+ let shards = plan(
+ vec![
+ manifest_entry(data_file("a", Some(0), 5)),
+ manifest_entry(data_file("b", Some(10), 5)),
+ ],
+ 20,
+ )
+ .unwrap();
+
+ assert_eq!(
+ shards
+ .iter()
+ .map(|s| (s.row_range_start, s.row_range_end))
+ .collect::<Vec<_>>(),
+ vec![(0, 4), (10, 14)]
+ );
+ }
+
+ #[test]
+ fn test_planner_rejects_missing_first_row_id() {
+ let err = plan(vec![manifest_entry(data_file("a", None, 5))], 10)
+ .expect_err("missing first_row_id should fail");
+ assert!(
+ matches!(err, Error::DataInvalid { message, .. } if
message.contains("missing first_row_id"))
+ );
+ }
+
+ #[test]
+ fn test_planner_keeps_buckets_separate() {
+ let shards = plan(
+ vec![
+ manifest_entry_with_bucket(data_file("a", Some(0), 5), 0, 2),
+ manifest_entry_with_bucket(data_file("b", Some(5), 5), 1, 2),
+ ],
+ 20,
+ )
+ .unwrap();
+
+ assert_eq!(
+ shards
+ .iter()
+ .map(|s| (
+ s.source_bucket,
+ s.total_buckets,
+ s.row_range_start,
+ s.row_range_end
+ ))
+ .collect::<Vec<_>>(),
+ vec![(0, 2, 0, 4), (1, 2, 5, 9)]
+ );
+ }
+
+ #[test]
+ fn
test_planner_keeps_partial_file_in_read_group_without_expanding_coverage() {
+ let shards = plan(
+ vec![
+ manifest_entry(data_file("base", Some(0), 5)),
+ manifest_entry(partial_file("partial", Some(0), 5)),
+ ],
+ 20,
+ )
+ .unwrap();
+
+ assert_eq!(shards.len(), 1);
+ assert_eq!((shards[0].row_range_start, shards[0].row_range_end), (0,
4));
+ assert_eq!(shards[0].files.len(), 2);
+ }
+
+ #[test]
+ fn test_build_read_splits_groups_only_overlapping_partial_files() {
+ let shards = plan(
+ vec![
+ manifest_entry(data_file("a", Some(0), 5)),
+ manifest_entry(data_file("b", Some(5), 5)),
+ manifest_entry(partial_file("partial", Some(0), 5)),
+ ],
+ 20,
+ )
+ .unwrap();
+ assert_eq!(shards.len(), 1);
+
+ let splits = build_read_splits_for_shard(&shards[0]).unwrap();
+
+ assert_eq!(splits.len(), 2);
+ assert_eq!(
+ splits[0]
+ .data_files()
+ .iter()
+ .map(|file| file.file_name.as_str())
+ .collect::<Vec<_>>(),
+ vec!["a", "partial"]
+ );
+ assert_eq!(splits[0].row_ranges(), Some(&[RowRange::new(0, 4)][..]));
+ assert!(!splits[0].raw_convertible());
+
+ assert_eq!(
+ splits[1]
+ .data_files()
+ .iter()
+ .map(|file| file.file_name.as_str())
+ .collect::<Vec<_>>(),
+ vec!["b"]
+ );
+ assert_eq!(splits[1].row_ranges(), Some(&[RowRange::new(5, 9)][..]));
+ assert!(splits[1].raw_convertible());
+ }
+
+ #[test]
+ fn test_validate_btree_field_rejects_complex_type() {
+ let field = DataField::new(
+ 0,
+ "items".to_string(),
+
DataType::Array(crate::spec::ArrayType::new(DataType::Int(IntType::new()))),
+ );
+ let err = validate_btree_field(&field).expect_err("array should be
rejected");
+ assert!(matches!(err, Error::Unsupported { message } if
message.contains("scalar")));
+ }
+
+ #[test]
+ fn test_validate_btree_field_rejects_binary_types() {
+ for data_type in [
+ DataType::Binary(BinaryType::new(4).unwrap()),
+ DataType::VarBinary(VarBinaryType::try_new(true, 4).unwrap()),
+ ] {
+ let field = DataField::new(0, "bytes".to_string(), data_type);
+ let err = validate_btree_field(&field).expect_err("binary should
be rejected");
+ assert!(matches!(err, Error::Unsupported { message } if
message.contains("scalar")));
+ }
+ }
+
+ fn index_batch(values: Vec<Option<i32>>, row_ids: Vec<Option<i64>>) ->
RecordBatch {
+ let schema = Arc::new(ArrowSchema::new(vec![
+ ArrowField::new("id", ArrowDataType::Int32, true),
+ ArrowField::new(ROW_ID_FIELD_NAME, ArrowDataType::Int64, true),
+ ]));
+ RecordBatch::try_new(
+ schema,
+ vec![
+ Arc::new(Int32Array::from(values)) as ArrayRef,
+ Arc::new(Int64Array::from(row_ids)) as ArrayRef,
+ ],
+ )
+ .unwrap()
+ }
+
+ #[test]
+ fn test_extract_index_rows_serializes_keys_and_local_row_ids() {
+ let batch = index_batch(
+ vec![Some(10), None, Some(30)],
+ vec![Some(5), Some(6), Some(7)],
+ );
+ let rows =
+ extract_index_rows_from_batches(&[batch], "id",
&DataType::Int(IntType::new()), 5, 3)
+ .unwrap();
+
+ assert_eq!(
+ rows,
+ vec![
+ (Some(10i32.to_le_bytes().to_vec()), 0),
+ (None, 1),
+ (Some(30i32.to_le_bytes().to_vec()), 2),
+ ]
+ );
+ }
+
+ #[test]
+ fn test_extract_index_rows_rejects_row_id_gap() {
+ let batch = index_batch(vec![Some(10), Some(30)], vec![Some(5),
Some(7)]);
+ let err =
+ extract_index_rows_from_batches(&[batch], "id",
&DataType::Int(IntType::new()), 5, 2)
+ .expect_err("row-id gap should fail");
+
+ assert!(
+ matches!(err, Error::DataInvalid { message, .. } if
message.contains("expected _ROW_ID"))
+ );
+ }
+
+ #[test]
+ fn test_sort_index_rows_orders_nulls_then_keys() {
+ let mut rows = vec![
+ (Some(3i32.to_le_bytes().to_vec()), 0),
+ (None, 1),
+ (Some(1i32.to_le_bytes().to_vec()), 2),
+ (Some(1i32.to_le_bytes().to_vec()), 3),
+ ];
+ let cmp = make_key_comparator(&DataType::Int(IntType::new()));
+
+ sort_index_rows(&mut rows, &cmp);
+
+ assert_eq!(
+ rows,
+ vec![
+ (None, 1),
+ (Some(1i32.to_le_bytes().to_vec()), 2),
+ (Some(1i32.to_le_bytes().to_vec()), 3),
+ (Some(3i32.to_le_bytes().to_vec()), 0),
+ ]
+ );
+ }
+
+ #[test]
+ fn test_extract_index_rows_accepts_string_column() {
+ let schema = Arc::new(ArrowSchema::new(vec![
+ ArrowField::new("name", ArrowDataType::Utf8, true),
+ ArrowField::new(ROW_ID_FIELD_NAME, ArrowDataType::Int64, true),
+ ]));
+ let batch = RecordBatch::try_new(
+ schema,
+ vec![
+ Arc::new(StringArray::from(vec![Some("alice"), None])) as
ArrayRef,
+ Arc::new(Int64Array::from(vec![Some(10), Some(11)])) as
ArrayRef,
+ ],
+ )
+ .unwrap();
+
+ let rows = extract_index_rows_from_batches(
+ &[batch],
+ "name",
+ &DataType::VarChar(VarCharType::string_type()),
+ 10,
+ 2,
+ )
+ .unwrap();
+
+ assert_eq!(rows, vec![(Some(b"alice".to_vec()), 0), (None, 1)]);
+ }
+
+ fn data_batch(ids: Vec<i32>, names: Vec<&str>) -> RecordBatch {
+ let schema = Arc::new(ArrowSchema::new(vec![
+ ArrowField::new("id", ArrowDataType::Int32, false),
+ ArrowField::new("name", ArrowDataType::Utf8, true),
+ ]));
+ RecordBatch::try_new(
+ schema,
+ vec![
+ Arc::new(Int32Array::from(ids)) as ArrayRef,
+ Arc::new(StringArray::from(names)) as ArrayRef,
+ ],
+ )
+ .unwrap()
+ }
+
+ async fn setup_dirs(table: &Table) {
+ table
+ .file_io()
+ .mkdirs(&format!("{}/snapshot/", table.location()))
+ .await
+ .unwrap();
+ table
+ .file_io()
+ .mkdirs(&format!("{}/manifest/", table.location()))
+ .await
+ .unwrap();
+ }
+
+ #[tokio::test]
+ async fn test_execute_writes_btree_index_manifest_and_file() {
+ let table_path = "memory:/test_btree_global_index_builder_e2e";
+ let table = test_table_with_path(table_path, table_options("10"));
+ setup_dirs(&table).await;
+
+ let mut table_write = TableWrite::new(&table,
"test-user".to_string()).unwrap();
+ table_write
+ .write_arrow_batch(&data_batch(vec![1, 2, 3], vec!["alice", "bob",
"alice"]))
+ .await
+ .unwrap();
+ let messages = table_write.prepare_commit().await.unwrap();
+ TableCommit::new(table.clone(), "test-user".to_string())
+ .commit(messages)
+ .await
+ .unwrap();
+
+ let shard_count = table
+ .new_btree_global_index_build_builder()
+ .with_index_column("name")
+ .execute()
+ .await
+ .unwrap();
+ assert_eq!(shard_count, 1);
+
+ let snapshot_manager =
+ SnapshotManager::new(table.file_io().clone(),
table.location().to_string());
+ let snapshot = snapshot_manager
+ .get_latest_snapshot()
+ .await
+ .unwrap()
+ .unwrap();
+ let index_manifest = snapshot.index_manifest().expect("index
manifest");
+ let index_entries = IndexManifest::read(
+ table.file_io(),
+ &format!("{table_path}/manifest/{index_manifest}"),
+ )
+ .await
+ .unwrap();
+ assert_eq!(index_entries.len(), 1);
+
+ let index_file = &index_entries[0].index_file;
+ assert_eq!(index_file.index_type, BTREE_INDEX_TYPE);
+ assert!(index_file.file_name.starts_with("btree-global-index-"));
+ assert_eq!(index_file.row_count, 3);
+ assert!(index_file.file_size > 0);
+
+ let global_meta = index_file
+ .global_index_meta
+ .as_ref()
+ .expect("global index meta");
+ assert_eq!(global_meta.row_range_start, 0);
+ assert_eq!(global_meta.row_range_end, 2);
+ assert_eq!(global_meta.index_field_id, 1);
+ let btree_meta =
+
crate::btree::BTreeIndexMeta::deserialize(global_meta.index_meta.as_ref().unwrap())
+ .unwrap();
+ assert_eq!(btree_meta.first_key, Some(b"alice".to_vec()));
+ assert_eq!(btree_meta.last_key, Some(b"bob".to_vec()));
+ assert!(!btree_meta.has_nulls);
+
+ let predicate = PredicateBuilder::new(table.schema().fields())
+ .equal("name", crate::spec::Datum::String("alice".to_string()))
+ .unwrap();
+ let row_ranges = evaluate_global_index(GlobalIndexEvaluation {
+ file_io: table.file_io(),
+ table_path: table.location(),
+ index_entries: &index_entries,
+ predicates: &[predicate],
+ schema_fields: table.schema().fields(),
+ search_mode: GlobalIndexSearchMode::Fast,
+ next_row_id: snapshot.next_row_id(),
+ data_ranges: &[],
+ })
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!(row_ranges, vec![RowRange::new(0, 0), RowRange::new(2, 2)]);
+ }
+
+ #[tokio::test]
+ async fn test_execute_rejects_existing_overlap_before_commit() {
+ let table_path = "memory:/test_btree_global_index_builder_overlap";
+ let table = test_table_with_path(table_path, table_options("10"));
+ setup_dirs(&table).await;
+
+ let mut table_write = TableWrite::new(&table,
"test-user".to_string()).unwrap();
+ table_write
+ .write_arrow_batch(&data_batch(vec![1, 2], vec!["alice", "bob"]))
+ .await
+ .unwrap();
+ let messages = table_write.prepare_commit().await.unwrap();
+ TableCommit::new(table.clone(), "test-user".to_string())
+ .commit(messages)
+ .await
+ .unwrap();
+
+ table
+ .new_btree_global_index_build_builder()
+ .with_index_column("name")
+ .execute()
+ .await
+ .unwrap();
+
+ let err = table
+ .new_btree_global_index_build_builder()
+ .with_index_column("name")
+ .execute()
+ .await
+ .expect_err("second build should overlap existing index");
+ assert!(
+ matches!(err, Error::DataInvalid { message, .. } if
message.contains("overlaps requested row range"))
+ );
+ }
+}
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 10b4648..459e48a 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -21,6 +21,7 @@ pub(crate) mod aggregator;
pub(crate) mod bin_pack;
mod blob_file_writer;
mod branch_manager;
+mod btree_global_index_build_builder;
mod bucket_assigner;
mod bucket_assigner_constant;
mod bucket_assigner_cross;
@@ -68,6 +69,7 @@ mod write_builder;
use crate::Result;
use arrow_array::RecordBatch;
pub use branch_manager::BranchManager;
+pub use btree_global_index_build_builder::BTreeGlobalIndexBuildBuilder;
pub use commit_message::CommitMessage;
pub use cow_writer::{CopyOnWriteMergeWriter, FileInfo};
pub use data_evolution_writer::{DataEvolutionDeleteWriter,
DataEvolutionWriter};
@@ -195,6 +197,10 @@ impl Table {
LuminaIndexBuildBuilder::new(self)
}
+ pub fn new_btree_global_index_build_builder(&self) ->
BTreeGlobalIndexBuildBuilder<'_> {
+ BTreeGlobalIndexBuildBuilder::new(self)
+ }
+
/// Create a write builder for write/commit.
///
/// Reference: [pypaimon
FileStoreTable.new_write_builder](https://github.com/apache/paimon/blob/master/paimon-python/pypaimon/table/file_store_table.py).