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 e312ca6 feat(index): add btree global index drop (#472)
e312ca6 is described below
commit e312ca63165cddf00e72120205a6baca2150269f
Author: QuakeWang <[email protected]>
AuthorDate: Tue Jul 7 20:38:28 2026 +0800
feat(index): add btree global index drop (#472)
---
crates/integrations/datafusion/src/procedures.rs | 36 +++
crates/integrations/datafusion/tests/procedures.rs | 95 ++++++
.../src/table/btree_global_index_drop_builder.rs | 357 +++++++++++++++++++++
crates/paimon/src/table/mod.rs | 6 +
4 files changed, 494 insertions(+)
diff --git a/crates/integrations/datafusion/src/procedures.rs
b/crates/integrations/datafusion/src/procedures.rs
index 89a3593..6587983 100644
--- a/crates/integrations/datafusion/src/procedures.rs
+++ b/crates/integrations/datafusion/src/procedures.rs
@@ -24,6 +24,7 @@
//! - `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.drop_global_index(table => '...', index_column => '...',
index_type => 'btree')`
//! - `CALL sys.create_lumina_index(table => '...', index_column => '...')`
use std::collections::HashMap;
@@ -150,6 +151,7 @@ pub async fn execute_call(
proc_create_tag_from_timestamp(ctx, catalog, catalog_name,
&args).await
}
"create_global_index" => proc_create_global_index(ctx, catalog,
catalog_name, &args).await,
+ "drop_global_index" => proc_drop_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}"
@@ -558,6 +560,40 @@ async fn proc_create_global_index(
ok_result(ctx)
}
+async fn proc_drop_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!(
+ "drop_global_index only supports index_type => 'btree', got
'{index_type}'"
+ )));
+ }
+ if args.contains_key("partitions") {
+ return Err(DataFusionError::NotImplemented(
+ "drop_global_index partitions are not supported for btree
yet".to_string(),
+ ));
+ }
+ if args.contains_key("dry_run") {
+ return Err(DataFusionError::NotImplemented(
+ "drop_global_index dry_run is not supported for btree
yet".to_string(),
+ ));
+ }
+
+ let mut builder = table.new_btree_global_index_drop_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 ec2d486..9e6a256 100644
--- a/crates/integrations/datafusion/tests/procedures.rs
+++ b/crates/integrations/datafusion/tests/procedures.rs
@@ -208,6 +208,101 @@ async fn
test_create_global_index_builds_btree_and_filter_reads() {
assert_eq!(rows, vec![(2, "bob".to_string())]);
}
+#[tokio::test]
+async fn test_drop_global_index_removes_btree_and_reads_fallback() {
+ let (_tmp, sql_context) =
setup_btree_global_index_table("btree_drop").await;
+ exec(
+ &sql_context,
+ "INSERT INTO paimon.test_db.btree_drop (id, name) VALUES (1, 'alice'),
(2, 'bob')",
+ )
+ .await;
+ exec(
+ &sql_context,
+ "CALL sys.create_global_index(table => 'test_db.btree_drop',
index_column => 'id')",
+ )
+ .await;
+ exec(
+ &sql_context,
+ "INSERT INTO paimon.test_db.btree_drop (id, name) VALUES (3, 'carol')",
+ )
+ .await;
+
+ let fast_rows = collect_id_name(
+ &sql_context,
+ "SELECT id, name FROM paimon.test_db.btree_drop WHERE id >= 2",
+ )
+ .await;
+ assert_eq!(fast_rows, vec![(2, "bob".to_string())]);
+
+ exec(
+ &sql_context,
+ "CALL sys.drop_global_index(table => 'test_db.btree_drop',
index_column => 'id', index_type => 'btree')",
+ )
+ .await;
+
+ let index_count = row_count(
+ &sql_context,
+ "SELECT * FROM paimon.test_db.`btree_drop$table_indexes` \
+ WHERE index_type = 'btree' AND index_field_name = 'id'",
+ )
+ .await;
+ assert_eq!(index_count, 0);
+
+ let rows = collect_id_name(
+ &sql_context,
+ "SELECT id, name FROM paimon.test_db.btree_drop WHERE id >= 2",
+ )
+ .await;
+ assert_eq!(rows, vec![(2, "bob".to_string()), (3, "carol".to_string())]);
+}
+
+#[tokio::test]
+async fn test_drop_global_index_requires_index_column() {
+ let (_tmp, sql_context) =
setup_btree_global_index_table("btree_drop_missing_col").await;
+
+ assert_sql_error(
+ &sql_context,
+ "CALL sys.drop_global_index(table =>
'test_db.btree_drop_missing_col')",
+ "Missing required argument: 'index_column'",
+ )
+ .await;
+}
+
+#[tokio::test]
+async fn test_drop_global_index_rejects_non_btree() {
+ let (_tmp, sql_context) =
setup_btree_global_index_table("btree_drop_bad_type").await;
+
+ assert_sql_error(
+ &sql_context,
+ "CALL sys.drop_global_index(table => 'test_db.btree_drop_bad_type',
index_column => 'id', index_type => 'bitmap')",
+ "only supports index_type => 'btree'",
+ )
+ .await;
+}
+
+#[tokio::test]
+async fn test_drop_global_index_without_match_succeeds() {
+ let (_tmp, sql_context) =
setup_btree_global_index_table("btree_drop_no_match").await;
+ exec(
+ &sql_context,
+ "INSERT INTO paimon.test_db.btree_drop_no_match (id, name) VALUES (1,
'alice')",
+ )
+ .await;
+
+ exec(
+ &sql_context,
+ "CALL sys.drop_global_index(table => 'test_db.btree_drop_no_match',
index_column => 'id')",
+ )
+ .await;
+
+ let rows = collect_id_name(
+ &sql_context,
+ "SELECT id, name FROM paimon.test_db.btree_drop_no_match WHERE id = 1",
+ )
+ .await;
+ assert_eq!(rows, vec![(1, "alice".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;
diff --git a/crates/paimon/src/table/btree_global_index_drop_builder.rs
b/crates/paimon/src/table/btree_global_index_drop_builder.rs
new file mode 100644
index 0000000..50f0e30
--- /dev/null
+++ b/crates/paimon/src/table/btree_global_index_drop_builder.rs
@@ -0,0 +1,357 @@
+// 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::{DataField, FileKind, IndexFileMeta, IndexManifest};
+use crate::table::{CommitMessage, SnapshotManager, Table, TableCommit};
+use crate::{Error, Result};
+use std::collections::HashMap;
+
+const BTREE_INDEX_TYPE: &str = "btree";
+
+pub struct BTreeGlobalIndexDropBuilder<'a> {
+ table: &'a Table,
+ index_column: Option<String>,
+}
+
+impl<'a> BTreeGlobalIndexDropBuilder<'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 index_field = find_index_field(self.table, index_column)?;
+
+ let snapshot_manager = SnapshotManager::new(
+ self.table.file_io().clone(),
+ self.table.location().to_string(),
+ );
+ let Some(snapshot) = snapshot_manager.get_latest_snapshot().await?
else {
+ return Ok(0);
+ };
+ let Some(index_manifest_name) = snapshot.index_manifest() else {
+ return Ok(0);
+ };
+
+ let index_entries = IndexManifest::read(
+ self.table.file_io(),
+ &snapshot_manager.manifest_path(index_manifest_name),
+ )
+ .await?;
+ let mut deletions_by_partition_bucket: HashMap<(Vec<u8>, i32),
Vec<IndexFileMeta>> =
+ HashMap::new();
+ let mut dropped = 0;
+ for entry in index_entries {
+ if entry.kind != FileKind::Add || entry.index_file.index_type !=
BTREE_INDEX_TYPE {
+ continue;
+ }
+ let Some(global_meta) =
entry.index_file.global_index_meta.as_ref() else {
+ continue;
+ };
+ if global_meta.index_field_id != index_field.id() {
+ continue;
+ }
+ dropped += 1;
+ deletions_by_partition_bucket
+ .entry((entry.partition, entry.bucket))
+ .or_default()
+ .push(entry.index_file);
+ }
+ if dropped == 0 {
+ return Ok(0);
+ }
+
+ let mut groups = deletions_by_partition_bucket
+ .into_iter()
+ .collect::<Vec<_>>();
+ groups.sort_by(
+ |((left_partition, left_bucket), _), ((right_partition,
right_bucket), _)| {
+ left_partition
+ .cmp(right_partition)
+ .then(left_bucket.cmp(right_bucket))
+ },
+ );
+ let messages = groups
+ .into_iter()
+ .map(|((partition, bucket), deleted_index_files)| {
+ let mut message = CommitMessage::new(partition, bucket,
vec![]);
+ message.deleted_index_files = deleted_index_files;
+ message
+ })
+ .collect::<Vec<_>>();
+
+ TableCommit::new(
+ self.table.clone(),
+ format!(
+ "global-index-{}-drop-{}",
+ BTREE_INDEX_TYPE,
+ uuid::Uuid::new_v4()
+ ),
+ )
+ .commit_if_latest_snapshot(messages, snapshot.id())
+ .await?;
+
+ Ok(dropped)
+ }
+}
+
+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(),
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::catalog::Identifier;
+ use crate::io::FileIOBuilder;
+ use crate::spec::stats::BinaryTableStats;
+ use crate::spec::{
+ BinaryRow, DataFileMeta, DataType, DeletionVectorMeta,
GlobalIndexMeta, IndexManifestEntry,
+ IntType, Schema, TableSchema, VarCharType,
+ };
+ use crate::table::TableCommit;
+ use chrono::{DateTime, Utc};
+ use indexmap::IndexMap;
+
+ fn test_table(table_path: &str) -> Table {
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("name", DataType::VarChar(VarCharType::string_type()))
+ .build()
+ .unwrap();
+ Table::new(
+ FileIOBuilder::new("memory").build().unwrap(),
+ Identifier::new("default", "test_table"),
+ table_path.to_string(),
+ TableSchema::new(0, &schema),
+ None,
+ )
+ }
+
+ 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();
+ }
+
+ fn global_index_file(
+ index_type: &str,
+ name: &str,
+ index_field_id: i32,
+ row_range_start: i64,
+ row_range_end: i64,
+ ) -> IndexFileMeta {
+ IndexFileMeta {
+ index_type: index_type.to_string(),
+ file_name: name.to_string(),
+ file_size: 128,
+ row_count: (row_range_end - row_range_start + 1) as i32,
+ deletion_vectors_ranges: None,
+ global_index_meta: Some(GlobalIndexMeta {
+ row_range_start,
+ row_range_end,
+ index_field_id,
+ extra_field_ids: None,
+ index_meta: None,
+ }),
+ }
+ }
+
+ fn hash_index_file(name: &str) -> IndexFileMeta {
+ IndexFileMeta {
+ index_type: "HASH".to_string(),
+ file_name: name.to_string(),
+ file_size: 64,
+ row_count: 1,
+ deletion_vectors_ranges: None,
+ global_index_meta: None,
+ }
+ }
+
+ fn deletion_vector_index_file(name: &str) -> IndexFileMeta {
+ IndexFileMeta {
+ index_type: "DELETION_VECTORS".to_string(),
+ file_name: name.to_string(),
+ file_size: 64,
+ row_count: 1,
+ deletion_vectors_ranges: Some(IndexMap::from([(
+ "data-0.parquet".to_string(),
+ DeletionVectorMeta {
+ offset: 1,
+ length: 8,
+ cardinality: Some(1),
+ },
+ )])),
+ global_index_meta: None,
+ }
+ }
+
+ fn data_file(name: &str) -> DataFileMeta {
+ DataFileMeta {
+ file_name: name.to_string(),
+ file_size: 128,
+ row_count: 1,
+ min_key: vec![],
+ max_key: vec![],
+ key_stats: BinaryTableStats::empty(),
+ value_stats: BinaryTableStats::empty(),
+ 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: None,
+ write_cols: None,
+ external_path: None,
+ file_source: None,
+ value_stats_cols: None,
+ }
+ }
+
+ async fn latest_index_entries(table: &Table) -> Vec<IndexManifestEntry> {
+ let snapshot_manager =
+ SnapshotManager::new(table.file_io().clone(),
table.location().to_string());
+ let snapshot = snapshot_manager
+ .get_latest_snapshot()
+ .await
+ .unwrap()
+ .unwrap();
+ let Some(index_manifest_name) = snapshot.index_manifest() else {
+ return Vec::new();
+ };
+ IndexManifest::read(
+ table.file_io(),
+ &snapshot_manager.manifest_path(index_manifest_name),
+ )
+ .await
+ .unwrap()
+ }
+
+ #[tokio::test]
+ async fn test_drop_btree_global_index_only_removes_target_entries() {
+ let table = test_table("memory:/test_drop_btree_global_index");
+ setup_dirs(&table).await;
+
+ let mut message = CommitMessage::new(
+ BinaryRow::new(0).to_serialized_bytes(),
+ 0,
+ vec![data_file("data-0.parquet")],
+ );
+ message.new_index_files = vec![
+ global_index_file(BTREE_INDEX_TYPE, "btree-id.index", 0, 0, 9),
+ global_index_file(BTREE_INDEX_TYPE, "btree-name.index", 1, 0, 9),
+ global_index_file("full-text", "fulltext-id.index", 0, 100, 109),
+ hash_index_file("hash.index"),
+ deletion_vector_index_file("dv.index"),
+ ];
+ TableCommit::new(table.clone(), "test-user".to_string())
+ .commit(vec![message])
+ .await
+ .unwrap();
+
+ let dropped = table
+ .new_btree_global_index_drop_builder()
+ .with_index_column("id")
+ .execute()
+ .await
+ .unwrap();
+ assert_eq!(dropped, 1);
+
+ let mut remaining = latest_index_entries(&table)
+ .await
+ .into_iter()
+ .map(|entry| entry.index_file.file_name)
+ .collect::<Vec<_>>();
+ remaining.sort();
+ assert_eq!(
+ remaining,
+ vec![
+ "btree-name.index".to_string(),
+ "dv.index".to_string(),
+ "fulltext-id.index".to_string(),
+ "hash.index".to_string(),
+ ]
+ );
+ }
+
+ #[tokio::test]
+ async fn test_drop_btree_global_index_is_idempotent_without_match() {
+ let table =
test_table("memory:/test_drop_btree_global_index_idempotent");
+ setup_dirs(&table).await;
+
+ let mut message = CommitMessage::new(vec![], 0,
vec![data_file("data-0.parquet")]);
+ message.new_index_files = vec![global_index_file(
+ BTREE_INDEX_TYPE,
+ "btree-name.index",
+ 1,
+ 0,
+ 9,
+ )];
+ TableCommit::new(table.clone(), "test-user".to_string())
+ .commit(vec![message])
+ .await
+ .unwrap();
+
+ let dropped = table
+ .new_btree_global_index_drop_builder()
+ .with_index_column("id")
+ .execute()
+ .await
+ .unwrap();
+ assert_eq!(dropped, 0);
+
+ let remaining = latest_index_entries(&table).await;
+ assert_eq!(remaining.len(), 1);
+ assert_eq!(remaining[0].index_file.file_name, "btree-name.index");
+ }
+}
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 930f9ef..edc3719 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -22,6 +22,7 @@ pub(crate) mod bin_pack;
mod blob_file_writer;
mod branch_manager;
mod btree_global_index_build_builder;
+mod btree_global_index_drop_builder;
mod bucket_assigner;
mod bucket_assigner_constant;
mod bucket_assigner_cross;
@@ -71,6 +72,7 @@ use crate::Result;
use arrow_array::RecordBatch;
pub use branch_manager::BranchManager;
pub use btree_global_index_build_builder::BTreeGlobalIndexBuildBuilder;
+pub use btree_global_index_drop_builder::BTreeGlobalIndexDropBuilder;
pub use commit_message::CommitMessage;
pub use cow_writer::{CopyOnWriteMergeWriter, FileInfo};
pub use data_evolution_writer::{DataEvolutionDeleteWriter,
DataEvolutionWriter};
@@ -212,6 +214,10 @@ impl Table {
BTreeGlobalIndexBuildBuilder::new(self)
}
+ pub fn new_btree_global_index_drop_builder(&self) ->
BTreeGlobalIndexDropBuilder<'_> {
+ BTreeGlobalIndexDropBuilder::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).