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 4cf34379 feat(datafusion): support clear_consumers (#1213)
4cf34379 is described below

commit 4cf34379c2f56527039f7e706488582d72a8e6ab
Author: QuakeWang <[email protected]>
AuthorDate: Sat Oct 24 10:25:35 2026 +0800

    feat(datafusion): support clear_consumers (#1213)
---
 crates/integrations/datafusion/src/procedures.rs   |  31 ++
 .../datafusion/tests/clear_consumers.rs            | 325 ++++++++++++++++
 .../datafusion/tests/rest_management_procedures.rs |   9 +-
 crates/paimon/src/table/consumer_manager.rs        | 418 +++++++++++++++++++++
 docs/src/sql.md                                    |  56 +++
 5 files changed, 838 insertions(+), 1 deletion(-)

diff --git a/crates/integrations/datafusion/src/procedures.rs 
b/crates/integrations/datafusion/src/procedures.rs
index 6db19eda..0f743224 100644
--- a/crates/integrations/datafusion/src/procedures.rs
+++ b/crates/integrations/datafusion/src/procedures.rs
@@ -22,6 +22,7 @@
 //! - `CALL sys.delete_tag(table => '...', tag => '...')`
 //! - `CALL sys.rename_tag(table => '...', tag => '...', target_tag => '...')`
 //! - `CALL sys.reset_consumer(table => '...', consumer_id => '...'[, 
next_snapshot_id => ...])`
+//! - `CALL sys.clear_consumers(table => '...'[, including_consumers => '...', 
excluding_consumers => '...'])`
 //! - `CALL sys.rollback_to(table => '...', snapshot_id => ... | tag => '...')`
 //! - `CALL sys.rollback_to_timestamp(table => '...', timestamp => ...)`
 //! - `CALL sys.create_tag_from_timestamp(table => '...', tag => '...', 
timestamp => ...)`
@@ -206,6 +207,7 @@ fn declared_parameters(proc_name: &str) -> Option<&'static 
[&'static str]> {
         "delete_tag" => &["table", "tag"],
         "rename_tag" => &["table", "tag", "target_tag"],
         "reset_consumer" => &["table", "consumer_id", "next_snapshot_id"],
+        "clear_consumers" => &["table", "including_consumers", 
"excluding_consumers"],
         "rollback_to" => &["table", "snapshot_id", "tag"],
         "rollback_to_timestamp" => &["table", "timestamp"],
         "create_tag_from_timestamp" => &["table", "tag", "timestamp"],
@@ -318,6 +320,7 @@ pub async fn execute_call(
         "delete_tag" => proc_delete_tag(ctx, catalog, catalog_name, 
&args).await,
         "rename_tag" => proc_rename_tag(ctx, catalog, catalog_name, 
&args).await,
         "reset_consumer" => proc_reset_consumer(ctx, catalog, catalog_name, 
&args).await,
+        "clear_consumers" => proc_clear_consumers(ctx, catalog, catalog_name, 
&args).await,
         "rollback_to" => proc_rollback_to(ctx, catalog, catalog_name, 
&args).await,
         "rollback_to_timestamp" => {
             proc_rollback_to_timestamp(ctx, catalog, catalog_name, &args).await
@@ -518,6 +521,34 @@ async fn proc_reset_consumer(
     ok_result(ctx)
 }
 
+async fn proc_clear_consumers(
+    ctx: &SessionContext,
+    catalog: &Arc<dyn Catalog>,
+    catalog_name: &str,
+    args: &HashMap<String, String>,
+) -> DFResult<DataFrame> {
+    let table = get_table(catalog, catalog_name, args).await?;
+    if table.rest_env().is_some() {
+        return Err(DataFusionError::NotImplemented(
+            "clear_consumers is not supported for tables managed by a REST 
catalog".to_string(),
+        ));
+    }
+    if table.schema().core_options().is_format_table() {
+        return Err(DataFusionError::NotImplemented(
+            "clear_consumers requires a FileStoreTable; format-table is not 
supported".to_string(),
+        ));
+    }
+    table
+        .consumer_manager()
+        .clear(
+            args.get("including_consumers").map(String::as_str),
+            args.get("excluding_consumers").map(String::as_str),
+        )
+        .await
+        .map_err(to_datafusion_error)?;
+    ok_result(ctx)
+}
+
 async fn proc_create_tag(
     ctx: &SessionContext,
     catalog: &Arc<dyn Catalog>,
diff --git a/crates/integrations/datafusion/tests/clear_consumers.rs 
b/crates/integrations/datafusion/tests/clear_consumers.rs
new file mode 100644
index 00000000..16d7fd9d
--- /dev/null
+++ b/crates/integrations/datafusion/tests/clear_consumers.rs
@@ -0,0 +1,325 @@
+// 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.
+
+mod common;
+
+use std::collections::BTreeMap;
+use std::path::{Path, PathBuf};
+
+use common::{assert_sql_error, create_sql_context, create_test_env, exec, 
string_value};
+use paimon::catalog::Identifier;
+use paimon::spec::{DataType, IntType, Schema};
+use paimon::table::{BranchManager, Table};
+use paimon::Catalog;
+use paimon_datafusion::SQLContext;
+
+async fn table_with_snapshot() -> (tempfile::TempDir, SQLContext, Table) {
+    let (tmp, catalog) = create_test_env();
+    let context = create_sql_context(catalog.clone()).await;
+    exec(&context, "CREATE SCHEMA paimon.test_db").await;
+    exec(&context, "CREATE TABLE paimon.test_db.events (id INT)").await;
+    exec(&context, "INSERT INTO paimon.test_db.events VALUES (1)").await;
+    let table = catalog
+        .get_table(&Identifier::new("test_db", "events"))
+        .await
+        .unwrap();
+    (tmp, context, table)
+}
+
+async fn assert_ok(context: &SQLContext, sql: &str) {
+    let batches = context.sql(sql).await.unwrap().collect().await.unwrap();
+    assert_eq!(batches.len(), 1);
+    assert_eq!(batches[0].num_rows(), 1);
+    assert_eq!(batches[0].num_columns(), 1);
+    assert_eq!(batches[0].schema().field(0).name(), "result");
+    assert_eq!(string_value(batches[0].column(0).as_ref(), 0), "OK");
+}
+
+fn table_files(root: &Path) -> BTreeMap<PathBuf, Vec<u8>> {
+    let mut files = BTreeMap::new();
+    for entry in std::fs::read_dir(root).unwrap() {
+        let path = entry.unwrap().path();
+        if path.is_dir() {
+            files.extend(table_files(&path));
+        } else {
+            files.insert(path.clone(), std::fs::read(path).unwrap());
+        }
+    }
+    files
+}
+
+#[tokio::test]
+async fn clear_filters_ids_and_preserves_exceptions_other_files_and_branches() 
{
+    let (tmp, context, table) = table_with_snapshot().await;
+    let manager = table.consumer_manager();
+    manager.reset("job-old", 1).await.unwrap();
+    manager.reset("job-keep", 2).await.unwrap();
+    let snapshot = table.snapshot_manager().get_snapshot(1).await.unwrap();
+    table.tag_manager().create("base", &snapshot).await.unwrap();
+    BranchManager::new(table.file_io().clone(), table.location().to_string())
+        .create_branch_from_tag("dev", "base")
+        .await
+        .unwrap();
+    let branch = table.copy_with_branch("dev").await.unwrap();
+    branch.consumer_manager().reset("job-old", 3).await.unwrap();
+
+    let root = tmp.path().join("test_db.db/events");
+    let directory = root.join("consumer");
+    for name in [
+        "consumer-job-old",
+        "consumer-job-keep-extra",
+        "consumer-other",
+    ] {
+        std::fs::write(directory.join(name), "{").unwrap();
+    }
+    std::fs::write(
+        directory.join("consumer-job-keep"),
+        "{ \"nextSnapshot\": 2 }\n",
+    )
+    .unwrap();
+    std::fs::write(directory.join("notes"), "keep").unwrap();
+    std::fs::write(directory.join("consumer-pre-job-old"), "keep").unwrap();
+    std::fs::create_dir(directory.join("consumer-job-directory")).unwrap();
+    std::fs::write(directory.join("consumer-job-directory/checkpoint"), 
"keep").unwrap();
+    let mut expected = table_files(&root);
+    expected
+        .remove(&directory.join("consumer-job-old"))
+        .unwrap();
+    expected
+        .remove(&directory.join("consumer-job-keep-extra"))
+        .unwrap();
+
+    for _ in 0..2 {
+        assert_ok(&context, "CALL paimon.sys.clear_consumers(table => 
'paimon.test_db.events', including_consumers => 'job-.*', excluding_consumers 
=> 'job-keep')").await;
+        assert_eq!(table_files(&root), expected);
+        assert_eq!(manager.get("job-keep").await.unwrap(), Some(2));
+        assert_eq!(
+            branch.consumer_manager().get("job-old").await.unwrap(),
+            Some(3)
+        );
+    }
+    assert_sql_error(
+        &context,
+        "CALL sys.clear_consumers(table => 'test_db.events$branch_dev')",
+        "does not exist",
+    )
+    .await;
+    assert_eq!(table_files(&root), expected);
+}
+
+#[tokio::test]
+async fn clear_accepts_defaults_and_does_not_create_a_missing_directory() {
+    let (tmp, catalog) = create_test_env();
+    let context = create_sql_context(catalog.clone()).await;
+    exec(&context, "CREATE SCHEMA paimon.test_db").await;
+    exec(&context, "CREATE TABLE paimon.test_db.events (id INT)").await;
+    let table = catalog
+        .get_table(&Identifier::new("test_db", "events"))
+        .await
+        .unwrap();
+    let root = tmp.path().join("test_db.db/events");
+    let original = table_files(&root);
+    for _ in 0..2 {
+        assert_ok(
+            &context,
+            "CALL sys.clear_consumers(table => 'test_db.events')",
+        )
+        .await;
+        assert_eq!(table_files(&root), original);
+        assert!(!root.join("consumer").exists());
+    }
+    for args in [
+        "",
+        ", including_consumers => ''",
+        ", including_consumers => ' \t', excluding_consumers => ''",
+    ] {
+        table.consumer_manager().reset("job", 2).await.unwrap();
+        assert_ok(
+            &context,
+            &format!("CALL sys.clear_consumers(table => 
'test_db.events'{args})"),
+        )
+        .await;
+        assert_eq!(table.consumer_manager().get("job").await.unwrap(), None);
+        assert_eq!(table_files(&root), original);
+    }
+    table.consumer_manager().reset("job", 2).await.unwrap();
+    assert_ok(
+        &context,
+        "CALL sys.clear_consumers(table => 'test_db.events', 
excluding_consumers => 'job')",
+    )
+    .await;
+    assert_eq!(table.consumer_manager().get("job").await.unwrap(), Some(2));
+}
+
+#[tokio::test]
+async fn clear_rejects_invalid_arguments_before_changing_files() {
+    let (tmp, context, table) = table_with_snapshot().await;
+    table.consumer_manager().reset("job", 1).await.unwrap();
+    let root = tmp.path().join("test_db.db/events");
+    let original = table_files(&root);
+    for (args, message) in [
+        ("", "Missing required argument: 'table'"),
+        ("'test_db.events'", "require named arguments"),
+        ("table => 'other.test_db.events'", "Catalog name mismatch"),
+        ("table => 'test_db.events', including_consumer => 'job'", "is 
unknown"),
+        ("table => 'test_db.events', excluding_consumer => 'job'", "is 
unknown"),
+        ("table => 'test_db.events', including_consumers => 'job', 
INCLUDING_CONSUMERS => 'other'", "is duplicated"),
+        ("table => 'test_db.events', excluding_consumers => 'job', 
excluding_consumers => 'other'", "is duplicated"),
+        ("table => 'test_db.events', including_consumers => NULL", 
"Unsupported argument value"),
+        ("table => 'test_db.events', excluding_consumers => NULL", 
"Unsupported argument value"),
+        ("table => 'test_db.events', including_consumers => '['", "Invalid 
including_consumers regex"),
+        ("table => 'test_db.events', excluding_consumers => '['", "Invalid 
excluding_consumers regex"),
+        ("table => 'test_db.events', including_consumers => 'no-match', 
excluding_consumers => '(?=job)job'", "Invalid excluding_consumers regex"),
+        (r"table => 'test_db.events', including_consumers => '(job)\1'", 
"Invalid including_consumers regex"),
+    ] {
+        assert_sql_error(&context, &format!("CALL 
sys.clear_consumers({args})"), message).await;
+        assert_eq!(table_files(&root), original);
+    }
+}
+
+#[tokio::test]
+async fn clear_uses_whole_id_matching_with_rust_regex_flags() {
+    let (_tmp, context, table) = table_with_snapshot().await;
+    for (pattern, id, deleted) in [
+        ("job", "job-old", false),
+        ("a|ab", "ab", true),
+        ("(?m)^job$", "job\nother", false),
+        ("(?s)job.*", "job\nother", true),
+        ("(?i)job", "JOB", true),
+        ("(?x) job # trailing comment", "job", true),
+        (" job", "job", false),
+    ] {
+        table.consumer_manager().reset(id, 2).await.unwrap();
+        assert_ok(&context, &format!("CALL sys.clear_consumers(table => 
'test_db.events', including_consumers => '{pattern}')")).await;
+        assert_eq!(
+            table.consumer_manager().get(id).await.unwrap(),
+            if deleted { None } else { Some(2) },
+            "{pattern:?}: {id:?}"
+        );
+        table.consumer_manager().delete(id).await.unwrap();
+    }
+}
+
+#[tokio::test]
+async fn clear_rejects_format_tables_without_changing_files() {
+    let (tmp, catalog) = create_test_env();
+    catalog
+        .create_database("test_db", false, Default::default())
+        .await
+        .unwrap();
+    let identifier = Identifier::new("test_db", "events");
+    let schema = Schema::builder()
+        .column("id", DataType::Int(IntType::new()))
+        .option("type", "format-table")
+        .option("file.format", "parquet")
+        .build()
+        .unwrap();
+    catalog
+        .create_table(&identifier, schema, false)
+        .await
+        .unwrap();
+    catalog
+        .get_table(&identifier)
+        .await
+        .unwrap()
+        .consumer_manager()
+        .reset("job", 2)
+        .await
+        .unwrap();
+    let context = create_sql_context(catalog).await;
+    let root = tmp.path().join("test_db.db/events");
+    let original = table_files(&root);
+    assert_sql_error(
+        &context,
+        "CALL sys.clear_consumers(table => 'test_db.events')",
+        "requires a FileStoreTable",
+    )
+    .await;
+    assert_eq!(table_files(&root), original);
+}
+
+#[tokio::test]
+async fn clear_rejects_engine_served_table_types_without_changing_files() {
+    for table_type in ["object-table", "iceberg-table", "lance-table"] {
+        let (tmp, catalog) = create_test_env();
+        catalog
+            .create_database("test_db", false, Default::default())
+            .await
+            .unwrap();
+        let schema = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .option("type", table_type)
+            .build()
+            .unwrap();
+        catalog
+            .create_table(&Identifier::new("test_db", "events"), schema, false)
+            .await
+            .unwrap();
+        let root = tmp.path().join("test_db.db/events");
+        std::fs::create_dir(root.join("consumer")).unwrap();
+        std::fs::write(root.join("consumer/consumer-job"), "{").unwrap();
+        let original = table_files(&root);
+        let context = create_sql_context(catalog).await;
+        assert_sql_error(
+            &context,
+            "CALL sys.clear_consumers(table => 'test_db.events')",
+            "cannot be read as a Paimon table",
+        )
+        .await;
+        assert_eq!(table_files(&root), original);
+    }
+}
+
+#[tokio::test]
+async fn clear_preserves_running_scan_progress_and_checkpoint_protocol() {
+    let (_tmp, context, table) = table_with_snapshot().await;
+    table.consumer_manager().reset("keep", 2).await.unwrap();
+    let mut running = table.new_stream_scan().unwrap();
+    running
+        .with_consumer_id("job")
+        .unwrap()
+        .restore(Some(1))
+        .unwrap();
+    running.plan().await.unwrap().unwrap();
+    running
+        .notify_checkpoint_complete(running.checkpoint())
+        .await
+        .unwrap();
+    assert_eq!(table.consumer_manager().get("job").await.unwrap(), Some(2));
+
+    assert_ok(
+        &context,
+        "CALL sys.clear_consumers(table => 'test_db.events', 
excluding_consumers => 'keep')",
+    )
+    .await;
+    assert_eq!(table.consumer_manager().get("job").await.unwrap(), None);
+    assert_eq!(table.consumer_manager().get("keep").await.unwrap(), Some(2));
+    assert_eq!(running.checkpoint(), Some(2));
+    assert!(running.plan().await.unwrap().is_none());
+    let mut fresh = table.new_stream_scan().unwrap();
+    fresh.with_consumer_id("job").unwrap();
+    let plan = fresh.plan().await.unwrap().unwrap();
+    assert_eq!(plan.snapshot_id(), Some(1));
+    assert!(plan.splits().iter().all(|split| !split.is_streaming()));
+
+    running
+        .notify_checkpoint_complete(running.checkpoint())
+        .await
+        .unwrap();
+    assert_eq!(table.consumer_manager().get("job").await.unwrap(), Some(2));
+    assert_eq!(table.consumer_manager().get("keep").await.unwrap(), Some(2));
+}
diff --git a/crates/integrations/datafusion/tests/rest_management_procedures.rs 
b/crates/integrations/datafusion/tests/rest_management_procedures.rs
index 00ebc159..22568abd 100644
--- a/crates/integrations/datafusion/tests/rest_management_procedures.rs
+++ b/crates/integrations/datafusion/tests/rest_management_procedures.rs
@@ -652,7 +652,7 @@ async fn 
test_non_rest_catalog_is_rejected_by_every_procedure() {
 }
 
 #[tokio::test]
-async fn 
test_reset_consumer_rejected_on_rest_catalog_without_changing_progress() {
+async fn 
test_consumer_procedures_rejected_on_rest_catalog_without_changing_progress() {
     let (tmp, server, context) = setup().await;
     let schema = Schema::builder()
         .column("id", DataType::Int(IntType::new()))
@@ -675,6 +675,13 @@ async fn 
test_reset_consumer_rejected_on_rest_catalog_without_changing_progress(
         ).await;
         assert_eq!(std::fs::read(&consumer_path).unwrap(), original);
     }
+    common::assert_sql_error(
+        &context,
+        &format!("CALL sys.clear_consumers(table => '{DATABASE}.{TABLE}')"),
+        "clear_consumers is not supported for tables managed by a REST 
catalog",
+    )
+    .await;
+    assert_eq!(std::fs::read(&consumer_path).unwrap(), original);
 }
 
 #[tokio::test]
diff --git a/crates/paimon/src/table/consumer_manager.rs 
b/crates/paimon/src/table/consumer_manager.rs
index f5febbd4..1ba94647 100644
--- a/crates/paimon/src/table/consumer_manager.rs
+++ b/crates/paimon/src/table/consumer_manager.rs
@@ -22,6 +22,7 @@ use std::time::Duration;
 
 use crate::io::FileIO;
 use futures::{stream, StreamExt, TryStreamExt};
+use regex::Regex;
 use serde::{Deserialize, Serialize};
 
 const CONSUMER_DIR: &str = "consumer";
@@ -111,6 +112,74 @@ impl ConsumerManager {
         self.file_io.delete_file(&path).await
     }
 
+    /// Delete consumer files whose ids fully match the inclusion regex but 
not the exclusion.
+    ///
+    /// Patterns use Rust `regex` syntax, including Unicode classes and inline 
flags, but
+    /// not look-around or backreferences. Omitted, empty, or 
Java-whitespace-only inclusion
+    /// defaults to `.*`; the equivalent exclusion means no exclusions. 
Nonblank patterns
+    /// are never trimmed. Both patterns are validated before listing or 
deleting files.
+    /// Only filenames are inspected, so corrupt consumer JSON can also be 
cleared.
+    /// Missing directories are a no-op; other storage errors are propagated. 
Deletion is
+    /// not atomic, and a running consumer can recreate its progress at 
checkpoint completion.
+    pub async fn clear(
+        &self,
+        including_consumers: Option<&str>,
+        excluding_consumers: Option<&str>,
+    ) -> crate::Result<()> {
+        let including = consumer_pattern(
+            including_consumers
+                .filter(|pattern| !is_java_blank(pattern))
+                .unwrap_or(".*"),
+            "including_consumers",
+        )?;
+        let excluding = excluding_consumers
+            .filter(|pattern| !is_java_blank(pattern))
+            .map(|pattern| consumer_pattern(pattern, "excluding_consumers"))
+            .transpose()?;
+
+        let directory = format!("{}/{CONSUMER_DIR}", self.table_path);
+        let statuses = match self.file_io.list_status(&directory).await {
+            Ok(statuses) => statuses,
+            Err(crate::Error::IoUnexpected { ref source, .. })
+                if source.kind() == opendal::ErrorKind::NotFound =>
+            {
+                return Ok(());
+            }
+            Err(error) => return Err(error),
+        };
+        let mut paths = Vec::new();
+        for status in statuses {
+            if status.is_dir {
+                continue;
+            }
+            let name = status.path.rsplit('/').next().unwrap_or(&status.path);
+            let Some(id) = name.strip_prefix(CONSUMER_PREFIX) else {
+                continue;
+            };
+            if !including.is_match(id)
+                || excluding
+                    .as_ref()
+                    .is_some_and(|pattern| pattern.is_match(id))
+            {
+                continue;
+            }
+            // OpenDAL would trim this path and could delete an excluded 
consumer instead.
+            if status.path.trim_end() != status.path {
+                return Err(crate::Error::DataInvalid {
+                    message: format!(
+                        "Cannot clear consumer '{id}': trailing whitespace in 
filename"
+                    ),
+                    source: None,
+                });
+            }
+            paths.push(status.path);
+        }
+        for path in paths {
+            self.file_io.delete_file(&path).await?;
+        }
+        Ok(())
+    }
+
     async fn read_path(&self, consumer_id: &str, path: &str) -> 
crate::Result<Option<i64>> {
         let input = self.file_io.new_input(path)?;
         for attempt in 0..READ_RETRIES {
@@ -190,6 +259,36 @@ impl ConsumerManager {
     }
 }
 
+fn consumer_pattern(pattern: &str, argument: &str) -> crate::Result<Regex> {
+    let invalid = |error: regex::Error| crate::Error::DataInvalid {
+        message: format!("Invalid {argument} regex: {error}"),
+        source: Some(Box::new(error)),
+    };
+    // Validate independently so unmatched groups cannot escape the boundary 
wrapper.
+    Regex::new(pattern).map_err(invalid)?;
+    Regex::new(&format!(r"\A(?:{pattern})\z"))
+        // A trailing verbose-mode comment can otherwise swallow the closing 
wrapper.
+        .or_else(|_| Regex::new(&format!("\\A(?:{pattern}\n)\\z")))
+        .map_err(invalid)
+}
+
+fn is_java_blank(value: &str) -> bool {
+    // Character.isWhitespace excludes nonbreaking spaces, unlike Rust's 
trim().
+    value.chars().all(|c| {
+        matches!(
+            c,
+            '\u{0009}'..='\u{000d}'
+                | '\u{001c}'..='\u{0020}'
+                | '\u{1680}'
+                | '\u{2000}'..='\u{2006}'
+                | '\u{2008}'..='\u{200a}'
+                | '\u{2028}'..='\u{2029}'
+                | '\u{205f}'
+                | '\u{3000}'
+        )
+    })
+}
+
 #[derive(Deserialize, Serialize)]
 struct Consumer {
     #[serde(rename = "nextSnapshot")]
@@ -205,6 +304,325 @@ mod tests {
     use super::*;
     use crate::io::{FileIOBuilder, FileIOProvider};
 
+    #[tokio::test]
+    async fn 
clear_matches_filenames_without_reading_json_and_preserves_other_branches() {
+        for scheme in ["memory", "file"] {
+            let tmp = tempfile::tempdir().unwrap();
+            let table_path = if scheme == "file" {
+                format!("file://{}/table", tmp.path().display())
+            } else {
+                "memory:/consumer-clear".to_string()
+            };
+            let file_io = FileIOBuilder::new(scheme).build().unwrap();
+            let manager = ConsumerManager::new(file_io.clone(), 
table_path.clone());
+            let branch = manager.with_branch("dev");
+            let sibling = manager.with_branch("release");
+            for scoped in [&manager, &branch, &sibling] {
+                scoped.reset("job-old", 2).await.unwrap();
+                scoped.reset("job-keep", 3).await.unwrap();
+            }
+            let directory = format!("{table_path}/consumer");
+            for name in [
+                "consumer-job-old",
+                "consumer-job-keep",
+                "consumer-job-keep-extra",
+            ] {
+                file_io
+                    .new_output(&format!("{directory}/{name}"))
+                    .unwrap()
+                    .write("{".into())
+                    .await
+                    .unwrap();
+            }
+            for name in ["notes", "consumer-job", "consumer-pre-job-old"] {
+                file_io
+                    .new_output(&format!("{directory}/{name}"))
+                    .unwrap()
+                    .write("keep".into())
+                    .await
+                    .unwrap();
+            }
+            let nested = format!("{directory}/consumer-job-directory");
+            file_io.mkdirs(&nested).await.unwrap();
+            file_io
+                .new_output(&format!("{nested}/consumer-job-child"))
+                .unwrap()
+                .write("keep child".into())
+                .await
+                .unwrap();
+
+            branch
+                .clear(Some("job-.*"), Some("job-keep"))
+                .await
+                .unwrap();
+            assert_eq!(branch.get("job-old").await.unwrap(), None);
+            assert_eq!(branch.get("job-keep").await.unwrap(), Some(3));
+            assert!(file_io
+                .new_input(&format!("{directory}/consumer-job-old"))
+                .unwrap()
+                .exists()
+                .await
+                .unwrap());
+            for _ in 0..2 {
+                manager
+                    .clear(Some("job-.*"), Some("job-keep"))
+                    .await
+                    .unwrap();
+                for name in ["consumer-job-old", "consumer-job-keep-extra"] {
+                    assert!(!file_io
+                        .new_input(&format!("{directory}/{name}"))
+                        .unwrap()
+                        .exists()
+                        .await
+                        .unwrap());
+                }
+                for (name, expected) in [
+                    ("consumer-job-keep", "{"),
+                    ("notes", "keep"),
+                    ("consumer-job", "keep"),
+                    ("consumer-pre-job-old", "keep"),
+                    ("consumer-job-directory/consumer-job-child", "keep 
child"),
+                ] {
+                    assert_eq!(
+                        file_io
+                            .new_input(&format!("{directory}/{name}"))
+                            .unwrap()
+                            .read()
+                            .await
+                            .unwrap()
+                            .as_ref(),
+                        expected.as_bytes()
+                    );
+                }
+                assert_eq!(branch.get("job-keep").await.unwrap(), Some(3));
+                assert_eq!(sibling.get("job-old").await.unwrap(), Some(2));
+                assert_eq!(sibling.get("job-keep").await.unwrap(), Some(3));
+            }
+        }
+    }
+
+    #[tokio::test]
+    async fn clear_defaults_and_empty_directories_are_idempotent() {
+        for scheme in ["memory", "file"] {
+            let tmp = tempfile::tempdir().unwrap();
+            let table_path = if scheme == "file" {
+                format!("file://{}/table", tmp.path().display())
+            } else {
+                "memory:/consumer-clear-defaults".to_string()
+            };
+            let file_io = FileIOBuilder::new(scheme).build().unwrap();
+            let manager = ConsumerManager::new(file_io.clone(), 
table_path.clone());
+            manager.clear(None, None).await.unwrap();
+            assert!(!file_io
+                .exists_dir(&format!("{table_path}/consumer"))
+                .await
+                .unwrap());
+            for blank in [None, Some(""), Some(" \t\r\n"), 
Some("\u{001c}\u{3000}")] {
+                manager.reset("job", 1).await.unwrap();
+                manager.clear(blank, Some("job")).await.unwrap();
+                assert_eq!(manager.get("job").await.unwrap(), Some(1));
+                manager.clear(blank, blank).await.unwrap();
+                assert_eq!(manager.get("job").await.unwrap(), None);
+                manager.clear(blank, blank).await.unwrap();
+            }
+            manager.reset("job", 1).await.unwrap();
+            for nonblank in [
+                "\u{0085}", "\u{00a0}", "\u{2007}", "\u{202f}", " job", "job ",
+            ] {
+                manager.clear(Some(nonblank), None).await.unwrap();
+                assert_eq!(manager.get("job").await.unwrap(), Some(1));
+            }
+            manager.clear(None, Some("\u{00a0}")).await.unwrap();
+            assert_eq!(manager.get("job").await.unwrap(), None);
+        }
+    }
+
+    #[test]
+    fn consumer_patterns_match_entire_ids_using_rust_regex_syntax() {
+        for (pattern, id, expected) in [
+            ("job", "job-old", false),
+            ("job", "pre-job", false),
+            ("a|ab", "ab", true),
+            ("job|other", "job-extra", false),
+            ("(?m)^job$", "job\nother", false),
+            ("(?s)job.*", "job\nother", true),
+            ("(?i)job", "JOB", true),
+            ("(?x) job # trailing comment", "job", true),
+            ("(?x) job # trailing comment", "job\n", false),
+            (r"\w+", "\u{4efb}\u{52a1}", true),
+            (r"(?-u:\w+)", "\u{4efb}\u{52a1}", false),
+            ("job.*", "job\rold", true),
+            (".*", "job\nold", false),
+            (" job", " job", true),
+        ] {
+            let regex = consumer_pattern(pattern, 
"including_consumers").unwrap();
+            assert_eq!(regex.is_match(id), expected, "{pattern:?}: {id:?}");
+        }
+    }
+
+    #[tokio::test]
+    async fn clear_validates_both_patterns_before_deleting_any_files() {
+        let file_io = FileIOBuilder::new("memory").build().unwrap();
+        let manager = ConsumerManager::new(file_io, 
"memory:/consumer-clear-invalid".into());
+        for pattern in ["[", r"(job)\1", "(?=job)job", ")|(", "\\"] {
+            for (including, excluding, argument) in [
+                (Some(pattern), None, "including_consumers"),
+                (None, Some(pattern), "excluding_consumers"),
+                (Some("no-match"), Some(pattern), "excluding_consumers"),
+            ] {
+                manager.reset("job", 1).await.unwrap();
+                let error = manager.clear(including, 
excluding).await.unwrap_err();
+                assert!(error
+                    .to_string()
+                    .contains(&format!("Invalid {argument} regex")));
+                assert_eq!(manager.get("job").await.unwrap(), Some(1));
+            }
+        }
+    }
+
+    #[tokio::test]
+    async fn clear_propagates_listing_and_deletion_errors() {
+        #[derive(Debug)]
+        struct DeniedProvider {
+            operator: opendal::Operator,
+            deny_listing: bool,
+        }
+
+        #[async_trait::async_trait]
+        impl FileIOProvider for DeniedProvider {
+            async fn create(&self, path: &str) -> 
crate::Result<(opendal::Operator, String)> {
+                if self.deny_listing || path.ends_with("/consumer-job") {
+                    return Err(opendal::Error::new(
+                        opendal::ErrorKind::PermissionDenied,
+                        "access denied",
+                    )
+                    .into());
+                }
+                Ok((
+                    self.operator.clone(),
+                    path.strip_prefix("memory:/").unwrap().to_string(),
+                ))
+            }
+        }
+
+        let operator = 
opendal::Operator::new(opendal::services::Memory::default()).unwrap();
+        operator
+            .write("table/consumer/consumer-job", "{")
+            .await
+            .unwrap();
+        for deny_listing in [true, false] {
+            let file_io = FileIOBuilder::new("memory")
+                .build()
+                .unwrap()
+                .with_provider(Arc::new(DeniedProvider {
+                    operator: operator.clone(),
+                    deny_listing,
+                }));
+            let manager = ConsumerManager::new(file_io, 
"memory:/table".into());
+            let error = manager.clear(None, None).await.unwrap_err();
+            assert!(matches!(error, crate::Error::IoUnexpected { source, .. }
+                if source.kind() == opendal::ErrorKind::PermissionDenied));
+            assert!(operator
+                .exists("table/consumer/consumer-job")
+                .await
+                .unwrap());
+        }
+    }
+
+    #[cfg(not(windows))]
+    #[tokio::test]
+    async fn clear_uses_listed_paths_without_aliasing_excluded_consumers() {
+        let tmp = tempfile::tempdir().unwrap();
+        let directory = tmp.path().join("consumer");
+        std::fs::create_dir(&directory).unwrap();
+        let manager = ConsumerManager::new(
+            FileIOBuilder::new("file").build().unwrap(),
+            format!("file://{}", tmp.path().display()),
+        );
+        std::fs::write(directory.join(r"consumer-job\part"), "{").unwrap();
+        manager.clear(Some(r"job\\part"), None).await.unwrap();
+        assert!(!directory.join(r"consumer-job\part").exists());
+
+        std::fs::write(directory.join("consumer-job"), "keep").unwrap();
+        for suffix in [" ", "\t", "\u{00a0}", "\u{3000}"] {
+            let name = format!("consumer-job{suffix}");
+            std::fs::write(directory.join(&name), "keep alias").unwrap();
+            // The filesystem lister normalizes these names to "consumer-job".
+            manager
+                .clear(Some(&format!("job{suffix}")), Some("job"))
+                .await
+                .unwrap();
+            assert_eq!(
+                std::fs::read(directory.join("consumer-job")).unwrap(),
+                b"keep"
+            );
+            assert_eq!(std::fs::read(directory.join(&name)).unwrap(), b"keep 
alias");
+        }
+        manager.clear(None, Some("job")).await.unwrap();
+        assert_eq!(
+            std::fs::read(directory.join("consumer-job")).unwrap(),
+            b"keep"
+        );
+    }
+
+    #[tokio::test]
+    async fn clear_rejects_literal_whitespace_paths_before_any_deletion() {
+        use opendal::raw::{oio::Write, OpWrite, Service};
+
+        let operator = 
opendal::Operator::new(opendal::services::Memory::default()).unwrap();
+        let file_io = FileIOBuilder::new("file")
+            .with_fs_operator(operator.clone())
+            .build()
+            .unwrap();
+        let manager = ConsumerManager::new(file_io, "file:///table".into());
+        operator
+            .write("table/consumer/consumer-job", "keep")
+            .await
+            .unwrap();
+        operator
+            .write("table/consumer/consumer-delete", "keep")
+            .await
+            .unwrap();
+        for suffix in [" ", "\t", "\u{00a0}", "\u{3000}"] {
+            let path = format!("table/consumer/consumer-job{suffix}");
+            // Seed a literal object key without Operator's input path 
normalization.
+            let mut writer = operator
+                .service()
+                .write(operator.context(), &path, OpWrite::default())
+                .unwrap();
+            writer.write("keep alias".into()).await.unwrap();
+            writer.close().await.unwrap();
+            let error = manager
+                .clear(Some(&format!("job{suffix}")), Some("job"))
+                .await
+                .unwrap_err();
+            assert!(error.to_string().contains("trailing whitespace"));
+            assert!(manager.clear(None, Some("job")).await.is_err());
+            assert_eq!(
+                operator
+                    .read("table/consumer/consumer-job")
+                    .await
+                    .unwrap()
+                    .to_bytes(),
+                "keep"
+            );
+            assert_eq!(
+                operator
+                    .read("table/consumer/consumer-delete")
+                    .await
+                    .unwrap()
+                    .to_bytes(),
+                "keep"
+            );
+            assert!(operator
+                .list("table/consumer/")
+                .await
+                .unwrap()
+                .iter()
+                .any(|entry| entry.path() == path));
+        }
+    }
+
     #[tokio::test]
     async fn delete_is_idempotent_and_preserves_other_consumers_and_branches() 
{
         for scheme in ["memory", "file"] {
diff --git a/docs/src/sql.md b/docs/src/sql.md
index 13f79fb9..82553172 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -1098,6 +1098,62 @@ have already loaded their progress do not switch 
positions when the record chang
 A running consumer's checkpoint completion can overwrite or recreate the 
record,
 so stop the consumer before adjusting its progress manually.
 
+### clear_consumers
+
+Delete a group of consumer records while retaining exceptions:
+
+```sql
+CALL sys.clear_consumers(
+    table => 'paimon.test_db.events',
+    including_consumers => 'pipeline_.*',
+    excluding_consumers => 'pipeline_critical|pipeline_backup'
+);
+```
+
+Both expressions match the **entire consumer ID**, excluding the `consumer-` 
filename
+prefix. A record is deleted only when inclusion matches and exclusion does not.
+For example, `pipeline_a` does not match `pipeline_a_backup`; use 
`pipeline_a.*`
+to match that suffix. Alternation and inline multiline flags do not bypass the
+whole-ID requirement.
+
+Patterns use [Rust `regex` syntax](https://docs.rs/regex/1/regex/#syntax), 
including
+groups, alternation, character classes, repetition, Unicode classes, and inline
+flags such as `(?i)`, `(?m)`, `(?s)`, and `(?x)`. Look-around and 
backreferences are
+not supported. This is not the Java Pattern dialect: for example, `\w`, `\d`, 
and
+`\s` are Unicode-aware by default, and `.` excludes LF but matches CR. Use 
explicit
+classes or flags when those distinctions matter. Invalid expressions fail 
before
+any files are deleted, including an invalid exclusion when inclusion matches 
nothing.
+
+Omitting `including_consumers`, or passing an empty or Java-whitespace-only 
string,
+uses `.*`. The equivalent exclusion means no exclusions. Nonbreaking spaces 
such
+as U+00A0 are literal patterns, not blank placeholders. Nonblank patterns are 
never
+trimmed. The default `.*` does not match IDs containing LF; use `(?s).*` if 
needed.
+To clear all ordinary consumer IDs:
+
+```sql
+CALL sys.clear_consumers(table => 'paimon.test_db.events');
+```
+
+The procedure uses the same table resolution and branch-scoped manager as
+`reset_consumer`; it does not add branch-qualified name resolution to catalogs.
+REST-managed tables and format tables are not supported. Arguments require 
named
+`=>` syntax; explicit `NULL` and positional arguments are not supported. 
Successful
+calls return `OK`, including when the consumer directory is absent or nothing 
matches.
+
+Only filenames are inspected, so corrupt consumer JSON does not prevent 
cleanup.
+Directories and unrelated files are skipped. A matching listed path with 
trailing
+whitespace is rejected before deletion because OpenDAL could otherwise 
normalize it
+to a different consumer's path. Filenames are taken from the storage listing; 
the
+filesystem backend normalizes trailing whitespace before returning entries.
+Retained consumer records, table data, snapshots, and manifests are unchanged.
+Storage errors are propagated; deletion is not atomic and an error can leave 
some
+matching records already removed.
+
+This operation does not determine whether a consumer has stopped. Stop the 
target
+consumers before cleanup: running scans retain their loaded positions, and 
checkpoint
+completion can recreate deleted records. A fresh scan without an explicit 
checkpoint
+follows its existing initial scan behavior after its consumer record is 
deleted.
+
 ### create_tag
 
 Create a named tag from a snapshot:

Reply via email to