blob: 183788ab39d2e99e760095267321de9616aa2827 [file]
// 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.
//! CALL procedure support for Paimon tables.
//!
//! Supported procedures:
//! - `CALL sys.create_tag(table => '...', tag => '...', snapshot_id => ...)`
//! - `CALL sys.delete_tag(table => '...', tag => '...')`
//! - `CALL sys.rollback_to(table => '...', snapshot_id => ... | tag => '...')`
//! - `CALL sys.rollback_to_timestamp(table => '...', timestamp => ...)`
//! - `CALL sys.create_tag_from_timestamp(table => '...', tag => '...', timestamp => ...)`
use std::collections::HashMap;
use std::sync::Arc;
use datafusion::arrow::array::StringArray;
use datafusion::arrow::datatypes::{DataType as ArrowDataType, Field, Schema};
use datafusion::arrow::record_batch::RecordBatch;
use datafusion::error::{DataFusionError, Result as DFResult};
use datafusion::prelude::{DataFrame, SessionContext};
use datafusion::sql::sqlparser::ast::{
Expr as SqlExpr, Function, FunctionArg, FunctionArgExpr, FunctionArgOperator,
FunctionArguments, ObjectName, Value as SqlValue,
};
use paimon::catalog::{Catalog, Identifier};
use paimon::spec::Snapshot;
use paimon::table::{SnapshotManager, Table, TagManager};
use crate::error::to_datafusion_error;
/// Resolve a snapshot by id: try live snapshot file first, then fall back to tag metadata.
async fn resolve_snapshot_by_id(
sm: &SnapshotManager,
tm: &TagManager,
snapshot_id: i64,
) -> DFResult<Snapshot> {
if let Ok(snap) = sm.get_snapshot(snapshot_id).await {
return Ok(snap);
}
let tags = tm.list_all().await.map_err(to_datafusion_error)?;
for (_, snap) in &tags {
if snap.id() == snapshot_id {
return Ok(snap.clone());
}
}
Err(DataFusionError::Plan(format!(
"Snapshot '{snapshot_id}' does not exist in live files or tag metadata"
)))
}
/// Find the earliest snapshot with commit time >= timestamp_millis,
/// considering both live snapshots and tag-retained snapshots.
async fn later_or_equal_from_all(
sm: &SnapshotManager,
tm: &TagManager,
timestamp_millis: i64,
) -> DFResult<Option<Snapshot>> {
let live = sm
.later_or_equal_time_millis(timestamp_millis)
.await
.map_err(to_datafusion_error)?;
let tags = tm.list_all().await.map_err(to_datafusion_error)?;
let tag_candidate = tags
.into_iter()
.map(|(_, snap)| snap)
.filter(|s| (s.time_millis() as i64) >= timestamp_millis)
.min_by_key(|s| s.time_millis());
match (live, tag_candidate) {
(Some(a), Some(b)) => {
if a.time_millis() <= b.time_millis() {
Ok(Some(a))
} else {
Ok(Some(b))
}
}
(Some(a), None) => Ok(Some(a)),
(None, Some(b)) => Ok(Some(b)),
(None, None) => Ok(None),
}
}
/// Find the latest snapshot with commit time <= timestamp_millis,
/// considering both live snapshots and tag-retained snapshots.
async fn earlier_or_equal_from_all(
sm: &SnapshotManager,
tm: &TagManager,
timestamp_millis: i64,
) -> DFResult<Option<Snapshot>> {
let live = sm
.earlier_or_equal_time_millis(timestamp_millis)
.await
.map_err(to_datafusion_error)?;
let tags = tm.list_all().await.map_err(to_datafusion_error)?;
let tag_candidate = tags
.into_iter()
.map(|(_, snap)| snap)
.filter(|s| (s.time_millis() as i64) <= timestamp_millis)
.max_by_key(|s| s.time_millis());
match (live, tag_candidate) {
(Some(a), Some(b)) => {
if a.time_millis() >= b.time_millis() {
Ok(Some(a))
} else {
Ok(Some(b))
}
}
(Some(a), None) => Ok(Some(a)),
(None, Some(b)) => Ok(Some(b)),
(None, None) => Ok(None),
}
}
pub async fn execute_call(
ctx: &SessionContext,
catalogs: &HashMap<String, Arc<dyn Catalog>>,
current_catalog: &str,
func: &Function,
) -> DFResult<DataFrame> {
let (explicit_catalog, proc_name) = extract_procedure_name(&func.name)?;
let catalog_name = explicit_catalog.as_deref().unwrap_or(current_catalog);
let catalog = catalogs
.get(catalog_name)
.ok_or_else(|| DataFusionError::Plan(format!("Unknown catalog '{catalog_name}'")))?;
let args = extract_named_args(&func.args)?;
match proc_name.as_str() {
"create_tag" => proc_create_tag(ctx, catalog, catalog_name, &args).await,
"delete_tag" => proc_delete_tag(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
}
"create_tag_from_timestamp" => {
proc_create_tag_from_timestamp(ctx, catalog, catalog_name, &args).await
}
_ => Err(DataFusionError::Plan(format!(
"Unknown procedure: {proc_name}"
))),
}
}
/// Returns (optional_catalog_name, procedure_name).
fn extract_procedure_name(name: &ObjectName) -> DFResult<(Option<String>, String)> {
let parts: Vec<String> = name
.0
.iter()
.filter_map(|p| p.as_ident().map(|id| id.value.clone()))
.collect();
match parts.len() {
1 => Ok((None, parts[0].clone())),
2 => Ok((None, parts[1].clone())),
3 => Ok((Some(parts[0].clone()), parts[2].clone())),
_ => Err(DataFusionError::Plan(format!(
"Invalid procedure name: {name}. Expected procedure_name, sys.procedure_name, or catalog.sys.procedure_name"
))),
}
}
fn extract_named_args(args: &FunctionArguments) -> DFResult<HashMap<String, String>> {
let arg_list = match args {
FunctionArguments::List(list) => &list.args,
FunctionArguments::None => return Ok(HashMap::new()),
_ => {
return Err(DataFusionError::Plan(
"Unsupported argument format for CALL".to_string(),
))
}
};
let mut map = HashMap::new();
for arg in arg_list {
match arg {
FunctionArg::Named {
name,
arg: FunctionArgExpr::Expr(expr),
operator: FunctionArgOperator::RightArrow,
} => {
let value = expr_to_string(expr)?;
map.insert(name.value.to_lowercase(), value);
}
_ => return Err(DataFusionError::Plan(
"CALL procedures require named arguments with '=>' syntax, e.g. table => 'db.t'"
.to_string(),
)),
}
}
Ok(map)
}
fn expr_to_string(expr: &SqlExpr) -> DFResult<String> {
match expr {
SqlExpr::Value(v) => match &v.value {
SqlValue::SingleQuotedString(s) => Ok(s.clone()),
SqlValue::Number(n, _) => Ok(n.clone()),
SqlValue::Boolean(b) => Ok(b.to_string()),
_ => Err(DataFusionError::Plan(format!(
"Unsupported argument value: {v}"
))),
},
SqlExpr::UnaryOp {
op: datafusion::sql::sqlparser::ast::UnaryOperator::Minus,
expr,
} => {
let inner = expr_to_string(expr)?;
Ok(format!("-{inner}"))
}
_ => Err(DataFusionError::Plan(format!(
"Unsupported argument expression: {expr}"
))),
}
}
fn require_arg<'a>(args: &'a HashMap<String, String>, name: &str) -> DFResult<&'a str> {
args.get(name)
.map(|s| s.as_str())
.ok_or_else(|| DataFusionError::Plan(format!("Missing required argument: '{name}'")))
}
fn resolve_table_identifier(table_str: &str, catalog_name: &str) -> DFResult<Identifier> {
let parts: Vec<&str> = table_str.split('.').collect();
match parts.len() {
2 => Ok(Identifier::new(parts[0], parts[1])),
3 => {
if parts[0] != catalog_name {
return Err(DataFusionError::Plan(format!(
"Catalog name mismatch: expected '{catalog_name}', got '{}'",
parts[0]
)));
}
Ok(Identifier::new(parts[1], parts[2]))
}
_ => Err(DataFusionError::Plan(format!(
"Invalid table identifier: '{table_str}'. Expected 'database.table' or 'catalog.database.table'"
))),
}
}
async fn get_table(
catalog: &Arc<dyn Catalog>,
catalog_name: &str,
args: &HashMap<String, String>,
) -> DFResult<Table> {
let table_str = require_arg(args, "table")?;
let identifier = resolve_table_identifier(table_str, catalog_name)?;
catalog
.get_table(&identifier)
.await
.map_err(to_datafusion_error)
}
fn managers(table: &Table) -> (SnapshotManager, TagManager) {
let sm = SnapshotManager::new(table.file_io().clone(), table.location().to_string());
let tm = TagManager::new(table.file_io().clone(), table.location().to_string());
(sm, tm)
}
async fn proc_create_tag(
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 tag_name = require_arg(args, "tag")?;
let snapshot_id: Option<i64> = args
.get("snapshot_id")
.map(|s| {
s.parse()
.map_err(|_| DataFusionError::Plan(format!("Invalid snapshot_id: '{s}'")))
})
.transpose()?;
let (sm, tm) = managers(&table);
if tm.tag_exists(tag_name).await.map_err(to_datafusion_error)? {
return Err(DataFusionError::Plan(format!(
"Tag '{tag_name}' already exists"
)));
}
let snapshot = if let Some(id) = snapshot_id {
resolve_snapshot_by_id(&sm, &tm, id).await?
} else {
sm.get_latest_snapshot()
.await
.map_err(to_datafusion_error)?
.ok_or_else(|| DataFusionError::Plan("No snapshots exist".to_string()))?
};
tm.create(tag_name, &snapshot)
.await
.map_err(to_datafusion_error)?;
ok_result(ctx)
}
async fn proc_delete_tag(
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 tag_str = require_arg(args, "tag")?;
let (_, tm) = managers(&table);
for tag_name in tag_str.split(',') {
let tag_name = tag_name.trim();
if tag_name.is_empty() {
continue;
}
if !tm.tag_exists(tag_name).await.map_err(to_datafusion_error)? {
continue;
}
tm.delete(tag_name).await.map_err(to_datafusion_error)?;
}
ok_result(ctx)
}
async fn clean_larger_than(
sm: &SnapshotManager,
tm: &TagManager,
retained_snapshot_id: i64,
) -> DFResult<()> {
// 1. Update LATEST hint
sm.write_latest_hint(retained_snapshot_id)
.await
.map_err(to_datafusion_error)?;
// 2. Delete snapshots newer than the target
let all_ids = sm.list_all_ids().await.map_err(to_datafusion_error)?;
for &id in all_ids.iter().rev() {
if id <= retained_snapshot_id {
break;
}
sm.delete_snapshot(id).await.map_err(to_datafusion_error)?;
}
// TODO: clean long-lived changelogs newer than retained_snapshot_id
// Java's RollbackHelper.cleanLargerThan also calls cleanLongLivedChangelogs here.
// Implement once ChangelogManager is available.
// 3. Delete tags that reference snapshots newer than the target
let tags = tm.list_all().await.map_err(to_datafusion_error)?;
for (name, snap) in tags.iter().rev() {
if snap.id() <= retained_snapshot_id {
continue;
}
tm.delete(name).await.map_err(to_datafusion_error)?;
}
Ok(())
}
async fn proc_rollback_to(
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 let Some(rest_env) = table.rest_env() {
if let Some(id_str) = args.get("snapshot_id") {
let id: i64 = id_str
.parse()
.map_err(|_| DataFusionError::Plan(format!("Invalid snapshot_id: '{id_str}'")))?;
rest_env
.api()
.rollback_to_snapshot(rest_env.identifier(), id)
.await
.map_err(to_datafusion_error)?;
} else if let Some(tag_name) = args.get("tag") {
rest_env
.api()
.rollback_to_tag(rest_env.identifier(), tag_name)
.await
.map_err(to_datafusion_error)?;
} else {
return Err(DataFusionError::Plan(
"rollback_to requires either 'snapshot_id' or 'tag' argument".to_string(),
));
}
} else {
let (sm, tm) = managers(&table);
if let Some(id_str) = args.get("snapshot_id") {
let id: i64 = id_str
.parse()
.map_err(|_| DataFusionError::Plan(format!("Invalid snapshot_id: '{id_str}'")))?;
let snapshot = resolve_snapshot_by_id(&sm, &tm, id).await?;
clean_larger_than(&sm, &tm, id).await?;
if !sm
.file_io()
.exists(&sm.snapshot_path(id))
.await
.map_err(to_datafusion_error)?
{
sm.commit_snapshot(&snapshot)
.await
.map_err(to_datafusion_error)?;
sm.write_earliest_hint(id)
.await
.map_err(to_datafusion_error)?;
}
} else if let Some(tag_name) = args.get("tag") {
let snapshot = tm
.get(tag_name)
.await
.map_err(to_datafusion_error)?
.ok_or_else(|| DataFusionError::Plan(format!("Tag '{tag_name}' does not exist")))?;
let snapshot_id = snapshot.id();
clean_larger_than(&sm, &tm, snapshot_id).await?;
if !sm
.file_io()
.exists(&sm.snapshot_path(snapshot_id))
.await
.map_err(to_datafusion_error)?
{
sm.commit_snapshot(&snapshot)
.await
.map_err(to_datafusion_error)?;
sm.write_earliest_hint(snapshot_id)
.await
.map_err(to_datafusion_error)?;
}
} else {
return Err(DataFusionError::Plan(
"rollback_to requires either 'snapshot_id' or 'tag' argument".to_string(),
));
}
}
ok_result(ctx)
}
async fn proc_rollback_to_timestamp(
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 ts_str = require_arg(args, "timestamp")?;
let timestamp: i64 = ts_str
.parse()
.map_err(|_| DataFusionError::Plan(format!("Invalid timestamp: '{ts_str}'")))?;
let (sm, tm) = managers(&table);
let snapshot = earlier_or_equal_from_all(&sm, &tm, timestamp)
.await?
.ok_or_else(|| {
DataFusionError::Plan(format!("No snapshot found with commit time <= {timestamp}"))
})?;
if let Some(rest_env) = table.rest_env() {
rest_env
.api()
.rollback_to_snapshot(rest_env.identifier(), snapshot.id())
.await
.map_err(to_datafusion_error)?;
} else {
clean_larger_than(&sm, &tm, snapshot.id()).await?;
}
ok_result(ctx)
}
async fn proc_create_tag_from_timestamp(
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 tag_name = require_arg(args, "tag")?;
let ts_str = require_arg(args, "timestamp")?;
let timestamp: i64 = ts_str
.parse()
.map_err(|_| DataFusionError::Plan(format!("Invalid timestamp: '{ts_str}'")))?;
let (sm, tm) = managers(&table);
let snapshot = later_or_equal_from_all(&sm, &tm, timestamp)
.await?
.ok_or_else(|| {
DataFusionError::Plan(format!("No snapshot found with commit time >= {timestamp}"))
})?;
if tm.tag_exists(tag_name).await.map_err(to_datafusion_error)? {
return Err(DataFusionError::Plan(format!(
"Tag '{tag_name}' already exists"
)));
}
tm.create(tag_name, &snapshot)
.await
.map_err(to_datafusion_error)?;
ok_result(ctx)
}
fn ok_result(ctx: &SessionContext) -> DFResult<DataFrame> {
let schema = Arc::new(Schema::new(vec![Field::new(
"result",
ArrowDataType::Utf8,
false,
)]));
let batch = RecordBatch::try_new(
schema.clone(),
vec![Arc::new(StringArray::from(vec!["OK"]))],
)?;
ctx.read_batch(batch)
}
#[cfg(test)]
mod tests {
use super::*;
use paimon::io::FileIOBuilder;
use paimon::spec::CommitKind;
fn test_file_io() -> paimon::io::FileIO {
FileIOBuilder::new("memory").build().unwrap()
}
fn test_snapshot(id: i64, time_millis: u64) -> Snapshot {
Snapshot::builder()
.version(3)
.id(id)
.schema_id(0)
.base_manifest_list("base-list".to_string())
.delta_manifest_list("delta-list".to_string())
.commit_user("test-user".to_string())
.commit_identifier(0)
.commit_kind(CommitKind::APPEND)
.time_millis(time_millis)
.build()
}
async fn setup(table_path: &str) -> (paimon::io::FileIO, SnapshotManager, TagManager) {
let file_io = test_file_io();
file_io
.mkdirs(&format!("{table_path}/snapshot/"))
.await
.unwrap();
file_io.mkdirs(&format!("{table_path}/tag/")).await.unwrap();
let sm = SnapshotManager::new(file_io.clone(), table_path.to_string());
let tm = TagManager::new(file_io.clone(), table_path.to_string());
(file_io, sm, tm)
}
#[tokio::test]
async fn test_resolve_snapshot_by_id_live() {
let (_, sm, tm) = setup("memory:/test_resolve_live").await;
let snap = test_snapshot(1, 1000);
sm.commit_snapshot(&snap).await.unwrap();
let result = resolve_snapshot_by_id(&sm, &tm, 1).await.unwrap();
assert_eq!(result.id(), 1);
}
#[tokio::test]
async fn test_resolve_snapshot_by_id_tag_fallback() {
let (_, sm, tm) = setup("memory:/test_resolve_tag").await;
let snap = test_snapshot(1, 1000);
tm.create("v1", &snap).await.unwrap();
let result = resolve_snapshot_by_id(&sm, &tm, 1).await.unwrap();
assert_eq!(result.id(), 1);
}
#[tokio::test]
async fn test_resolve_snapshot_by_id_not_found() {
let (_, sm, tm) = setup("memory:/test_resolve_none").await;
let result = resolve_snapshot_by_id(&sm, &tm, 99).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_later_or_equal_exact_live() {
let (_, sm, tm) = setup("memory:/test_later_exact").await;
sm.commit_snapshot(&test_snapshot(1, 1000)).await.unwrap();
sm.commit_snapshot(&test_snapshot(2, 2000)).await.unwrap();
let result = later_or_equal_from_all(&sm, &tm, 1000).await.unwrap();
assert_eq!(result.unwrap().id(), 1);
}
#[tokio::test]
async fn test_later_or_equal_tag_better() {
let (_, sm, tm) = setup("memory:/test_later_tag_better").await;
sm.commit_snapshot(&test_snapshot(3, 3000)).await.unwrap();
tm.create("v2", &test_snapshot(2, 2000)).await.unwrap();
let result = later_or_equal_from_all(&sm, &tm, 1500).await.unwrap();
assert_eq!(result.unwrap().id(), 2);
}
#[tokio::test]
async fn test_later_or_equal_only_tag() {
let (_, sm, tm) = setup("memory:/test_later_only_tag").await;
tm.create("v1", &test_snapshot(1, 1000)).await.unwrap();
let result = later_or_equal_from_all(&sm, &tm, 500).await.unwrap();
assert_eq!(result.unwrap().id(), 1);
}
#[tokio::test]
async fn test_earlier_or_equal_exact_live() {
let (_, sm, tm) = setup("memory:/test_earlier_exact").await;
sm.commit_snapshot(&test_snapshot(1, 1000)).await.unwrap();
sm.commit_snapshot(&test_snapshot(2, 2000)).await.unwrap();
let result = earlier_or_equal_from_all(&sm, &tm, 2000).await.unwrap();
assert_eq!(result.unwrap().id(), 2);
}
#[tokio::test]
async fn test_earlier_or_equal_tag_better() {
let (_, sm, tm) = setup("memory:/test_earlier_tag_better").await;
sm.commit_snapshot(&test_snapshot(1, 1000)).await.unwrap();
tm.create("v2", &test_snapshot(2, 2000)).await.unwrap();
let result = earlier_or_equal_from_all(&sm, &tm, 2500).await.unwrap();
assert_eq!(result.unwrap().id(), 2);
}
#[tokio::test]
async fn test_earlier_or_equal_only_tag() {
let (_, sm, tm) = setup("memory:/test_earlier_only_tag").await;
tm.create("v1", &test_snapshot(1, 1000)).await.unwrap();
let result = earlier_or_equal_from_all(&sm, &tm, 1500).await.unwrap();
assert_eq!(result.unwrap().id(), 1);
}
}