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 88091664 feat(datafusion): support reset_consumer (#1167)
88091664 is described below
commit 88091664a02f1ed22cb5cae71cf15f53979c1492
Author: QuakeWang <[email protected]>
AuthorDate: Mon Oct 19 20:15:46 2026 +0800
feat(datafusion): support reset_consumer (#1167)
---
crates/integrations/datafusion/src/procedures.rs | 61 +++-
.../datafusion/tests/reset_consumer.rs | 385 +++++++++++++++++++++
.../datafusion/tests/rest_management_procedures.rs | 26 ++
crates/paimon/src/table/consumer_manager.rs | 165 ++++++++-
docs/src/sql.md | 41 +++
5 files changed, 673 insertions(+), 5 deletions(-)
diff --git a/crates/integrations/datafusion/src/procedures.rs
b/crates/integrations/datafusion/src/procedures.rs
index 9377e2e8..6db19eda 100644
--- a/crates/integrations/datafusion/src/procedures.rs
+++ b/crates/integrations/datafusion/src/procedures.rs
@@ -21,6 +21,7 @@
//! - `CALL sys.create_tag(table => '...', tag => '...', snapshot_id => ...,
time_retained => '...')`
//! - `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.rollback_to(table => '...', snapshot_id => ... | tag => '...')`
//! - `CALL sys.rollback_to_timestamp(table => '...', timestamp => ...)`
//! - `CALL sys.create_tag_from_timestamp(table => '...', tag => '...',
timestamp => ...)`
@@ -74,8 +75,8 @@ use paimon::catalog::{Catalog, Identifier, RESTCatalog};
use paimon::lumina::{is_lumina_index_type, LUMINA_IDENTIFIER};
use paimon::spec::{CoreOptions, DataType, Datum, Predicate, PredicateBuilder,
Snapshot};
use paimon::table::{
- normalize_global_index_type, BranchManager, SnapshotManager, Table,
TagManager,
- SUPPORTED_GLOBAL_INDEX_TYPES_FOR_DROP,
+ normalize_global_index_type, BranchManager, ConsumerManager,
SnapshotManager, Table,
+ TagManager, SUPPORTED_GLOBAL_INDEX_TYPES_FOR_DROP,
};
use crate::error::to_datafusion_error;
@@ -204,6 +205,7 @@ fn declared_parameters(proc_name: &str) -> Option<&'static
[&'static str]> {
"create_tag" => &["table", "tag", "snapshot_id", "time_retained"],
"delete_tag" => &["table", "tag"],
"rename_tag" => &["table", "tag", "target_tag"],
+ "reset_consumer" => &["table", "consumer_id", "next_snapshot_id"],
"rollback_to" => &["table", "snapshot_id", "tag"],
"rollback_to_timestamp" => &["table", "timestamp"],
"create_tag_from_timestamp" => &["table", "tag", "timestamp"],
@@ -315,6 +317,7 @@ pub async fn execute_call(
"create_tag" => proc_create_tag(ctx, catalog, catalog_name,
&args).await,
"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,
"rollback_to" => proc_rollback_to(ctx, catalog, catalog_name,
&args).await,
"rollback_to_timestamp" => {
proc_rollback_to_timestamp(ctx, catalog, catalog_name, &args).await
@@ -461,6 +464,60 @@ fn managers(table: &Table) -> (SnapshotManager,
TagManager) {
(sm, tm)
}
+async fn proc_reset_consumer(
+ ctx: &SessionContext,
+ catalog: &Arc<dyn Catalog>,
+ catalog_name: &str,
+ args: &HashMap<String, String>,
+) -> DFResult<DataFrame> {
+ let consumer_id = require_arg(args, "consumer_id")?;
+
ConsumerManager::validate_consumer_id(consumer_id).map_err(to_datafusion_error)?;
+ let next_snapshot_id = args
+ .get("next_snapshot_id")
+ .map(|value| {
+ value
+ .parse::<i64>()
+ .ok()
+ .filter(|id| *id > 0)
+ .ok_or_else(|| {
+ DataFusionError::Plan(format!(
+ "Invalid next_snapshot_id: '{value}'. Expected a
positive BIGINT"
+ ))
+ })
+ })
+ .transpose()?;
+ let table = get_table(catalog, catalog_name, args).await?;
+ if table.rest_env().is_some() {
+ return Err(DataFusionError::NotImplemented(
+ "reset_consumer is not supported for tables managed by a REST
catalog".to_string(),
+ ));
+ }
+ if table.schema().core_options().is_format_table() {
+ return Err(DataFusionError::NotImplemented(
+ "reset_consumer requires a FileStoreTable; format-table is not
supported".to_string(),
+ ));
+ }
+ let manager = table.consumer_manager();
+ if let Some(id) = next_snapshot_id {
+ // Stream recovery reads live snapshot files, not tag-retained
metadata.
+ table
+ .snapshot_manager()
+ .get_snapshot(id)
+ .await
+ .map_err(to_datafusion_error)?;
+ manager
+ .reset(consumer_id, id)
+ .await
+ .map_err(to_datafusion_error)?;
+ } else {
+ manager
+ .delete(consumer_id)
+ .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/reset_consumer.rs
b/crates/integrations/datafusion/tests/reset_consumer.rs
new file mode 100644
index 00000000..6323ecce
--- /dev/null
+++ b/crates/integrations/datafusion/tests/reset_consumer.rs
@@ -0,0 +1,385 @@
+// 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 futures::TryStreamExt;
+use paimon::catalog::Identifier;
+use paimon::spec::{DataType, IntType, Schema};
+use paimon::table::{Plan, Table};
+use paimon::Catalog;
+use paimon_datafusion::SQLContext;
+
+async fn table_with_snapshots() -> (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;
+ for id in 1..=3 {
+ exec(
+ &context,
+ &format!("INSERT INTO paimon.test_db.events VALUES ({id})"),
+ )
+ .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
+}
+
+async fn scan_ids(table: &Table, plan: Plan) -> Vec<i32> {
+ let batches: Vec<_> = table
+ .new_read_builder()
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ let mut ids: Vec<_> = batches
+ .iter()
+ .flat_map(|batch| {
+ batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<arrow_array::Int32Array>()
+ .unwrap()
+ .values()
+ .iter()
+ .copied()
+ })
+ .collect();
+ ids.sort_unstable();
+ ids
+}
+
+#[tokio::test]
+async fn reset_creates_overwrites_and_deletes_only_the_requested_consumer() {
+ let (tmp, context, table) = table_with_snapshots().await;
+ let manager = table.consumer_manager();
+ manager.reset("other", 3).await.unwrap();
+ let root = tmp.path().join("test_db.db/events");
+ let consumer_path = root.join("consumer/consumer-job");
+ let original_files = table_files(&root);
+ assert!(original_files
+ .keys()
+ .any(|path| path.starts_with(root.join("snapshot"))));
+ assert!(original_files
+ .keys()
+ .any(|path| path.starts_with(root.join("manifest"))));
+
+ for id in [2, 1] {
+ assert_ok(
+ &context,
+ &format!("CALL sys.reset_consumer(table =>
'paimon.test_db.events', consumer_id => 'job', next_snapshot_id => {id})"),
+ ).await;
+ assert_eq!(manager.get("job").await.unwrap(), Some(id));
+ let mut files = table_files(&root);
+ let progress: serde_json::Value =
+
serde_json::from_slice(&files.remove(&consumer_path).unwrap()).unwrap();
+ assert_eq!(progress, serde_json::json!({"nextSnapshot": id}));
+ assert_eq!(files, original_files);
+ }
+ for _ in 0..2 {
+ assert_ok(
+ &context,
+ "CALL sys.reset_consumer(table => 'test_db.events', consumer_id =>
'job')",
+ )
+ .await;
+ assert_eq!(manager.get("job").await.unwrap(), None);
+ assert_eq!(manager.get("other").await.unwrap(), Some(3));
+ assert_eq!(table_files(&root), original_files);
+ }
+}
+
+#[tokio::test]
+async fn
deleting_a_missing_consumer_on_an_empty_table_does_not_create_metadata() {
+ let (tmp, catalog) = create_test_env();
+ let context = create_sql_context(catalog).await;
+ exec(&context, "CREATE SCHEMA paimon.test_db").await;
+ exec(&context, "CREATE TABLE paimon.test_db.events (id INT)").await;
+ let root = tmp.path().join("test_db.db/events");
+ let original = table_files(&root);
+ assert_ok(
+ &context,
+ "CALL sys.reset_consumer(table => 'test_db.events', consumer_id =>
'job')",
+ )
+ .await;
+ assert_eq!(table_files(&root), original);
+ assert!(!root.join("consumer").exists());
+ assert_sql_error(
+ &context,
+ "CALL sys.reset_consumer(table => 'test_db.events', consumer_id =>
'job', next_snapshot_id => 1)",
+ "does not exist",
+ )
+ .await;
+ assert_eq!(table_files(&root), original);
+ assert!(!root.join("consumer").exists());
+}
+
+#[tokio::test]
+async fn trailing_whitespace_never_targets_another_consumers_progress() {
+ let (tmp, context, table) = table_with_snapshots().await;
+ let manager = table.consumer_manager();
+ manager.reset("job", 2).await.unwrap();
+ let root = tmp.path().join("test_db.db/events");
+ let original = table_files(&root);
+ for id in ["job ", "job\t", "job\n", "job\u{a0}", "job\u{3000}"] {
+ assert_eq!(manager.get(id).await.unwrap(), None);
+ for snapshot in ["", ", next_snapshot_id => 1"] {
+ assert_sql_error(
+ &context,
+ &format!("CALL sys.reset_consumer(table => 'test_db.events',
consumer_id => '{id}'{snapshot})"),
+ "trailing whitespace",
+ ).await;
+ assert_eq!(manager.get("job").await.unwrap(), Some(2));
+ assert_eq!(table_files(&root), original);
+ }
+ }
+}
+
+#[tokio::test]
+async fn reset_rejects_invalid_arguments_without_changing_files() {
+ let (tmp, context, table) = table_with_snapshots().await;
+ table.consumer_manager().reset("job", 2).await.unwrap();
+ let root = tmp.path().join("test_db.db/events");
+ let original = table_files(&root);
+ for (args, message) in [
+ ("consumer_id => 'job', next_snapshot_id => 1", "Missing required
argument: 'table'"),
+ ("table => 'test_db.events'", "Missing required argument:
'consumer_id'"),
+ ("table => 'test_db.events', consumer_id => 'job', next_snaphot_id =>
1", "is unknown"),
+ ("table => 'test_db.events', consumer_id => 'job', consumer_id =>
'other'", "is duplicated"),
+ ("table => 'test_db.events', consumer_id => 'job', next_snapshot_id =>
1, NEXT_SNAPSHOT_ID => 2", "is duplicated"),
+ ("'test_db.events', 'job', 1", "require named arguments"),
+ ("table => 'test_db.events', consumer_id => 'job', next_snapshot_id =>
NULL", "Unsupported argument value"),
+ ("table => 'other.test_db.events', consumer_id => 'job'", "Catalog
name mismatch"),
+ ] {
+ assert_sql_error(&context, &format!("CALL
sys.reset_consumer({args})"), message).await;
+ assert_eq!(table_files(&root), original);
+ }
+ for id in [
+ "0",
+ "-1",
+ "1.5",
+ "9223372036854775808",
+ "'invalid'",
+ "''",
+ "true",
+ ] {
+ assert_sql_error(
+ &context,
+ &format!("CALL sys.reset_consumer(table => 'test_db.events',
consumer_id => 'job', next_snapshot_id => {id})"),
+ "Invalid next_snapshot_id",
+ ).await;
+ assert_eq!(table_files(&root), original);
+ }
+ for id in ["", ".", "..", "../job", r"job\part"] {
+ for snapshot in ["", ", next_snapshot_id => 1"] {
+ assert_sql_error(
+ &context,
+ &format!("CALL sys.reset_consumer(table => 'test_db.events',
consumer_id => '{id}'{snapshot})"),
+ "consumer id must be",
+ ).await;
+ assert_eq!(table_files(&root), original);
+ }
+ }
+ for id in [4, 99] {
+ assert_sql_error(
+ &context,
+ &format!("CALL sys.reset_consumer(table => 'test_db.events',
consumer_id => 'job', next_snapshot_id => {id})"),
+ "does not exist",
+ ).await;
+ assert_eq!(table_files(&root), original);
+ }
+}
+
+#[tokio::test]
+async fn
reset_rejects_expired_or_corrupt_snapshots_even_when_a_tag_retains_them() {
+ for corrupt in [false, true] {
+ let (tmp, context, table) = table_with_snapshots().await;
+ exec(
+ &context,
+ "CALL sys.create_tag(table => 'test_db.events', tag => 'keep',
snapshot_id => 1)",
+ )
+ .await;
+ table.consumer_manager().reset("job", 2).await.unwrap();
+ let snapshot_path = table.snapshot_manager().snapshot_path(1);
+ if corrupt {
+ table
+ .file_io()
+ .new_output(&snapshot_path)
+ .unwrap()
+ .write("{".into())
+ .await
+ .unwrap();
+ } else {
+ table.file_io().delete_file(&snapshot_path).await.unwrap();
+ }
+ assert_eq!(
+ table.tag_manager().get("keep").await.unwrap().unwrap().id(),
+ 1
+ );
+ let root = tmp.path().join("test_db.db/events");
+ let original = table_files(&root);
+ assert_sql_error(
+ &context,
+ "CALL sys.reset_consumer(table => 'test_db.events', consumer_id =>
'job', next_snapshot_id => 1)",
+ if corrupt { "snapshot JSON invalid" } else { "does not exist" },
+ ).await;
+ assert_eq!(table.consumer_manager().get("job").await.unwrap(),
Some(2));
+ assert_eq!(table_files(&root), original);
+ }
+}
+
+#[tokio::test]
+async fn reset_rejects_format_tables_before_accessing_consumer_metadata() {
+ 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();
+ let table = catalog.get_table(&identifier).await.unwrap();
+ table.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);
+ for snapshot in ["", ", next_snapshot_id => 1"] {
+ assert_sql_error(
+ &context,
+ &format!("CALL sys.reset_consumer(table => 'test_db.events',
consumer_id => 'job'{snapshot})"),
+ "requires a FileStoreTable",
+ ).await;
+ assert_eq!(table_files(&root), original);
+ }
+}
+
+#[tokio::test]
+async fn
sql_reset_is_used_by_new_scans_and_explicit_restore_takes_precedence() {
+ let (_tmp, context, table) = table_with_snapshots().await;
+ assert_ok(&context, "CALL sys.reset_consumer(table => 'test_db.events',
consumer_id => 'job', next_snapshot_id => 1)").await;
+ let mut running = table.new_stream_scan().unwrap();
+ running.with_consumer_id("job").unwrap();
+ assert_eq!(
+ scan_ids(&table, running.plan().await.unwrap().unwrap()).await,
+ vec![1]
+ );
+ assert_ok(&context, "CALL sys.reset_consumer(table => 'test_db.events',
consumer_id => 'job', next_snapshot_id => 3)").await;
+ assert_eq!(
+ scan_ids(&table, running.plan().await.unwrap().unwrap()).await,
+ vec![2]
+ );
+ 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(3));
+ assert!(plan.splits().iter().all(|split| split.is_streaming()));
+ assert_eq!(scan_ids(&table, plan).await, vec![3]);
+ let mut restored = table.new_stream_scan().unwrap();
+ restored
+ .with_consumer_id("job")
+ .unwrap()
+ .restore(Some(1))
+ .unwrap();
+ assert_eq!(
+ scan_ids(&table, restored.plan().await.unwrap().unwrap()).await,
+ vec![1]
+ );
+ assert_ok(&context, "CALL sys.reset_consumer(table => 'test_db.events',
consumer_id => 'job', next_snapshot_id => 1)").await;
+ running
+ .notify_checkpoint_complete(running.checkpoint())
+ .await
+ .unwrap();
+ assert_eq!(table.consumer_manager().get("job").await.unwrap(), Some(3));
+}
+
+#[tokio::test]
+async fn
sql_delete_restores_initial_scan_behavior_and_checkpoint_can_recreate_progress()
{
+ let (_tmp, context, table) = table_with_snapshots().await;
+ let mut waiting = table.new_stream_scan().unwrap();
+ waiting
+ .with_consumer_id("job")
+ .unwrap()
+ .restore(Some(3))
+ .unwrap();
+ waiting.plan().await.unwrap().unwrap();
+ waiting
+ .notify_checkpoint_complete(waiting.checkpoint())
+ .await
+ .unwrap();
+ assert_eq!(table.consumer_manager().get("job").await.unwrap(), Some(4));
+ assert!(table.snapshot_manager().get_snapshot(4).await.is_err());
+ assert_ok(
+ &context,
+ "CALL sys.reset_consumer(table => 'test_db.events', consumer_id =>
'job')",
+ )
+ .await;
+ assert_eq!(table.consumer_manager().get("job").await.unwrap(), 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(3));
+ assert!(!plan.splits().is_empty());
+ assert!(plan.splits().iter().all(|split| !split.is_streaming()));
+ assert_eq!(scan_ids(&table, plan).await, vec![1, 2, 3]);
+ assert!(waiting.plan().await.unwrap().is_none());
+ assert_eq!(waiting.checkpoint(), Some(4));
+ waiting
+ .notify_checkpoint_complete(waiting.checkpoint())
+ .await
+ .unwrap();
+ assert_eq!(table.consumer_manager().get("job").await.unwrap(), Some(4));
+}
diff --git a/crates/integrations/datafusion/tests/rest_management_procedures.rs
b/crates/integrations/datafusion/tests/rest_management_procedures.rs
index 039c2bfc..00ebc159 100644
--- a/crates/integrations/datafusion/tests/rest_management_procedures.rs
+++ b/crates/integrations/datafusion/tests/rest_management_procedures.rs
@@ -651,6 +651,32 @@ 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() {
+ let (tmp, server, context) = setup().await;
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .build()
+ .unwrap();
+ let path = format!("file://{}/{DATABASE}.db/{TABLE}",
tmp.path().display());
+ server.add_table_with_schema(DATABASE, TABLE, schema, &path);
+ let consumer_path = tmp
+ .path()
+ .join(format!("{DATABASE}.db/{TABLE}/consumer/consumer-job"));
+ std::fs::create_dir_all(consumer_path.parent().unwrap()).unwrap();
+ let original = br#"{"nextSnapshot":2}"#;
+ std::fs::write(&consumer_path, original).unwrap();
+
+ for snapshot in ["", ", next_snapshot_id => 1"] {
+ common::assert_sql_error(
+ &context,
+ &format!("CALL sys.reset_consumer(table => '{DATABASE}.{TABLE}',
consumer_id => 'job'{snapshot})"),
+ "reset_consumer is not supported for tables managed by a REST
catalog",
+ ).await;
+ assert_eq!(std::fs::read(&consumer_path).unwrap(), original);
+ }
+}
+
#[tokio::test]
async fn test_rename_branch_rejected_on_rest_catalog() {
// A REST catalog is authoritative for branch metadata and has no rename
diff --git a/crates/paimon/src/table/consumer_manager.rs
b/crates/paimon/src/table/consumer_manager.rs
index 20e1a084..f5febbd4 100644
--- a/crates/paimon/src/table/consumer_manager.rs
+++ b/crates/paimon/src/table/consumer_manager.rs
@@ -53,13 +53,16 @@ impl ConsumerManager {
)
}
- pub(super) fn validate_consumer_id(consumer_id: &str) -> crate::Result<()>
{
+ /// Validate a consumer name before reading or mutating its metadata.
+ pub fn validate_consumer_id(consumer_id: &str) -> crate::Result<()> {
if consumer_id.is_empty()
|| matches!(consumer_id, "." | "..")
|| consumer_id.contains(['/', '\\'])
+ // OpenDAL trims path boundaries, so trailing whitespace would
alias another ID.
+ || consumer_id.trim_end() != consumer_id
{
return Err(crate::Error::DataInvalid {
- message: "consumer id must be a non-empty name without path
separators".into(),
+ message: "consumer id must be a non-empty name without path
separators or trailing whitespace".into(),
source: None,
});
}
@@ -98,6 +101,16 @@ impl ConsumerManager {
self.file_io.new_output(&path)?.write(bytes.into()).await
}
+ /// Delete one consumer's progress. A missing file is a successful no-op.
+ pub async fn delete(&self, consumer_id: &str) -> crate::Result<()> {
+ Self::validate_consumer_id(consumer_id)?;
+ let path = format!(
+ "{}/{CONSUMER_DIR}/{CONSUMER_PREFIX}{consumer_id}",
+ self.table_path
+ );
+ self.file_io.delete_file(&path).await
+ }
+
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 {
@@ -185,10 +198,156 @@ struct Consumer {
#[cfg(test)]
mod tests {
+ use std::sync::Arc;
+
use bytes::Bytes;
use super::*;
- use crate::io::FileIOBuilder;
+ use crate::io::{FileIOBuilder, FileIOProvider};
+
+ #[tokio::test]
+ async fn delete_is_idempotent_and_preserves_other_consumers_and_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-delete".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");
+ manager.delete("missing").await.unwrap();
+ assert!(!file_io
+ .exists_dir(&format!("{table_path}/consumer"))
+ .await
+ .unwrap());
+ manager.reset("job", 1).await.unwrap();
+ manager.reset("other", 2).await.unwrap();
+ branch.reset("job", 3).await.unwrap();
+ sibling.reset("job", 4).await.unwrap();
+
+ branch.delete("job").await.unwrap();
+ branch.delete("job").await.unwrap();
+ assert_eq!(branch.get("job").await.unwrap(), None);
+ assert_eq!(manager.get("job").await.unwrap(), Some(1));
+ assert_eq!(sibling.get("job").await.unwrap(), Some(4));
+ assert!(file_io
+
.exists_dir(&format!("{table_path}/branch/branch-dev/consumer"))
+ .await
+ .unwrap());
+
+ branch.reset("job", 5).await.unwrap();
+ manager.delete("job").await.unwrap();
+ manager.delete("job").await.unwrap();
+ assert_eq!(
+ manager.list_all().await.unwrap(),
+ vec![("other".to_string(), 2)]
+ );
+ assert_eq!(branch.get("job").await.unwrap(), Some(5));
+ assert_eq!(sibling.get("job").await.unwrap(), Some(4));
+ }
+ }
+
+ #[tokio::test]
+ async fn trailing_whitespace_cannot_alias_another_consumers_progress() {
+ 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-whitespace".to_string()
+ };
+ let file_io = FileIOBuilder::new(scheme).build().unwrap();
+ let manager = ConsumerManager::new(file_io.clone(), table_path);
+ let branch = manager.with_branch("dev");
+ for scoped in [&manager, &branch] {
+ scoped.reset("job", 1).await.unwrap();
+ let path = format!("{}/consumer/consumer-job",
scoped.table_path);
+ let original =
file_io.new_input(&path).unwrap().read().await.unwrap();
+ for id in [
+ "job ",
+ "job\t",
+ "job\n",
+ "job\r\n",
+ "job\u{a0}",
+ "job\u{3000}",
+ ] {
+ assert_eq!(scoped.get(id).await.unwrap(), None);
+ assert!(scoped.delete(id).await.is_err(), "{scheme}:
{id:?}");
+ assert_eq!(
+
file_io.new_input(&path).unwrap().read().await.unwrap(),
+ original
+ );
+ assert!(scoped.reset(id, 3).await.is_err(), "{scheme}:
{id:?}");
+ assert_eq!(
+
file_io.new_input(&path).unwrap().read().await.unwrap(),
+ original
+ );
+ }
+ scoped.reset(" job", 2).await.unwrap();
+ assert_eq!(scoped.get(" job").await.unwrap(), Some(2));
+ scoped.delete(" job").await.unwrap();
+ assert_eq!(scoped.get("job").await.unwrap(), Some(1));
+ }
+ }
+ }
+
+ #[tokio::test]
+ async fn delete_rejects_invalid_ids_without_changing_files() {
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let manager =
+ ConsumerManager::new(file_io.clone(),
"memory:/consumer-delete-invalid".into());
+ manager.reset("job", 2).await.unwrap();
+ for invalid in ["", ".", "..", "x/../../snapshot/snapshot-1", "x\\y"] {
+ assert!(manager.delete(invalid).await.is_err());
+ }
+ assert_eq!(
+ manager.list_all().await.unwrap(),
+ vec![("job".to_string(), 2)]
+ );
+ }
+
+ #[tokio::test]
+ async fn delete_propagates_storage_errors() {
+ #[derive(Debug)]
+ struct DeniedProvider;
+
+ #[async_trait::async_trait]
+ impl FileIOProvider for DeniedProvider {
+ async fn create(&self, _path: &str) ->
crate::Result<(opendal::Operator, String)> {
+ Err(
+ opendal::Error::new(opendal::ErrorKind::PermissionDenied,
"access denied")
+ .into(),
+ )
+ }
+ }
+
+ let file_io = FileIOBuilder::new("memory")
+ .build()
+ .unwrap()
+ .with_provider(Arc::new(DeniedProvider));
+ let manager = ConsumerManager::new(file_io,
"memory:/consumer-delete-denied".into());
+ let error = manager.delete("job").await.unwrap_err();
+ assert!(matches!(error, crate::Error::IoUnexpected { source, .. }
+ if source.kind() == opendal::ErrorKind::PermissionDenied));
+ }
+
+ #[tokio::test]
+ async fn delete_does_not_recursively_remove_a_directory() {
+ let tmp = tempfile::tempdir().unwrap();
+ let directory = tmp.path().join("consumer/consumer-job");
+ std::fs::create_dir_all(&directory).unwrap();
+ let child = directory.join("keep");
+ std::fs::write(&child, b"keep").unwrap();
+ let manager = ConsumerManager::new(
+ FileIOBuilder::new("file").build().unwrap(),
+ format!("file://{}", tmp.path().display()),
+ );
+ assert!(manager.delete("job").await.is_err());
+ assert_eq!(std::fs::read(child).unwrap(), b"keep");
+ }
#[tokio::test]
async fn reset_is_explicit_branch_scoped_and_rejects_path_traversal() {
diff --git a/docs/src/sql.md b/docs/src/sql.md
index 4eff1e96..b00cdfaf 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -1057,6 +1057,47 @@ anything is deleted.
Use `CALL` to invoke built-in procedures. All procedures are under the `sys`
namespace.
+### reset_consumer
+
+Create or overwrite a consumer's next snapshot to consume:
+
+```sql
+CALL sys.reset_consumer(
+ table => 'paimon.test_db.events',
+ consumer_id => 'pipeline_a',
+ next_snapshot_id => 12
+);
+```
+
+`next_snapshot_id` must be a positive BIGINT identifying an existing live
snapshot.
+Missing, expired, or invalid snapshots are rejected before changing consumer
progress;
+tag-retained metadata alone is not sufficient. The ID is the next snapshot to
process
+using the existing incremental or changelog scan rules, rather than a
full-state read.
+
+Omit `next_snapshot_id` to delete the consumer record. Deleting a missing
consumer succeeds:
+
+```sql
+CALL sys.reset_consumer(
+ table => 'paimon.test_db.events',
+ consumer_id => 'pipeline_a'
+);
+```
+
+Only filesystem-managed FileStore tables are supported. REST-managed tables and
+format tables are not supported. Consumer IDs must be non-empty names without
path
+separators or trailing whitespace; IDs are never trimmed. Arguments use the
existing
+named `=>` syntax; explicit `NULL` and positional arguments are not supported.
+Successful calls return `OK`.
+These operations change only consumer metadata, without publishing snapshots or
+changing table data, snapshot files, or manifests.
+
+A new scan with the same consumer ID resumes the stored progress unless the
caller
+explicitly restores a checkpoint, which takes precedence. After deletion, a
new scan
+without an explicit checkpoint follows its existing initial scan behavior.
Scans that
+have already loaded their progress do not switch positions when the record
changes.
+A running consumer's checkpoint completion can overwrite or recreate the
record,
+so stop the consumer before adjusting its progress manually.
+
### create_tag
Create a named tag from a snapshot: