| // 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. |
| |
| //! Branch manager for reading and managing branch metadata using FileIO. |
| //! |
| //! Reference: [org.apache.paimon.utils.BranchManager](https://github.com/apache/paimon/blob/master/paimon-core/src/main/java/org/apache/paimon/utils/BranchManager.java) |
| //! and [org.apache.paimon.utils.FileSystemBranchManager](https://github.com/apache/paimon/blob/master/paimon-core/src/main/java/org/apache/paimon/utils/FileSystemBranchManager.java) |
| |
| use crate::catalog::DEFAULT_MAIN_BRANCH; |
| use crate::io::FileIO; |
| use crate::table::{SchemaManager, SnapshotManager, TagManager}; |
| use opendal::raw::get_basename; |
| |
| const BRANCH_DIR: &str = "branch"; |
| const BRANCH_PREFIX: &str = "branch-"; |
| |
| /// Manager for branch directories using unified FileIO. |
| /// |
| /// Branches are stored as sub-directories at `{table_path}/branch/branch-{name}`. |
| /// |
| /// Reference: [org.apache.paimon.utils.BranchManager](https://github.com/apache/paimon/blob/master/paimon-core/src/main/java/org/apache/paimon/utils/BranchManager.java) |
| #[derive(Debug, Clone)] |
| pub struct BranchManager { |
| file_io: FileIO, |
| table_path: String, |
| } |
| |
| impl BranchManager { |
| pub fn new(file_io: FileIO, table_path: String) -> Self { |
| Self { |
| file_io, |
| table_path, |
| } |
| } |
| |
| /// Path to the branch directory (e.g. `table_path/branch`). |
| pub fn branch_directory(&self) -> String { |
| format!("{}/{}", self.table_path, BRANCH_DIR) |
| } |
| |
| /// Path to the branch sub-directory for the given name (e.g. `branch/branch-my_branch`). |
| pub fn branch_path(&self, branch_name: &str) -> String { |
| format!( |
| "{}/{}{}", |
| self.branch_directory(), |
| BRANCH_PREFIX, |
| branch_name |
| ) |
| } |
| |
| /// Validate branch name format. |
| /// |
| /// Rules: |
| /// - Cannot be "main" |
| /// - Cannot be blank or whitespace only |
| /// - Cannot be a pure numeric string |
| fn validate_branch_name(branch_name: &str) -> crate::Result<()> { |
| if branch_name == DEFAULT_MAIN_BRANCH { |
| return Err(crate::Error::DataInvalid { |
| message: format!( |
| "Branch name '{}' is the default branch and cannot be used.", |
| DEFAULT_MAIN_BRANCH |
| ), |
| source: None, |
| }); |
| } |
| if branch_name.trim().is_empty() { |
| return Err(crate::Error::DataInvalid { |
| message: format!("Branch name '{}' is blank.", branch_name), |
| source: None, |
| }); |
| } |
| if branch_name.chars().all(|c| c.is_ascii_digit()) { |
| return Err(crate::Error::DataInvalid { |
| message: format!( |
| "Branch name cannot be pure numeric string but is '{}'.", |
| branch_name |
| ), |
| source: None, |
| }); |
| } |
| Ok(()) |
| } |
| |
| /// Validate branch name and ensure it does not already exist. |
| pub async fn validate_branch(&self, branch_name: &str) -> crate::Result<()> { |
| Self::validate_branch_name(branch_name)?; |
| if self.branch_exists(branch_name).await? { |
| return Err(crate::Error::DataInvalid { |
| message: format!("Branch name '{}' already exists.", branch_name), |
| source: None, |
| }); |
| } |
| Ok(()) |
| } |
| |
| /// Check if a branch exists. |
| pub async fn branch_exists(&self, branch_name: &str) -> crate::Result<bool> { |
| let path = format!("{}/", self.branch_path(branch_name)); |
| self.file_io.exists(&path).await |
| } |
| |
| /// List all branch names sorted ascending. Returns an empty vector when the |
| /// branch directory does not exist. |
| pub async fn list_all(&self) -> crate::Result<Vec<String>> { |
| let branch_dir = self.branch_directory(); |
| let statuses = match self.file_io.list_status(&branch_dir).await { |
| Ok(s) => s, |
| Err(crate::Error::IoUnexpected { ref source, .. }) |
| if source.kind() == opendal::ErrorKind::NotFound => |
| { |
| return Ok(Vec::new()); |
| } |
| Err(e) => return Err(e), |
| }; |
| let mut names: Vec<String> = statuses |
| .into_iter() |
| .filter(|s| s.is_dir) |
| .filter_map(|s| { |
| get_basename(&s.path) |
| .strip_suffix("/") |
| .and_then(|base| base.strip_prefix(BRANCH_PREFIX)) |
| .map(str::to_string) |
| }) |
| .collect(); |
| names.sort_unstable(); |
| Ok(names) |
| } |
| |
| /// Create a new branch by copying the latest schema to the branch directory. |
| pub async fn create_branch(&self, branch_name: &str) -> crate::Result<()> { |
| self.validate_branch(branch_name).await?; |
| let schema_manager = SchemaManager::new(self.file_io.clone(), self.table_path.clone()); |
| if let Some(latest) = schema_manager.latest().await? { |
| self.copy_schemas_to_branch(branch_name, latest.id()) |
| .await?; |
| } |
| Ok(()) |
| } |
| |
| /// Create a new branch from a tag by copying the tag, snapshot and schemas. |
| pub async fn create_branch_from_tag( |
| &self, |
| branch_name: &str, |
| tag_name: &str, |
| ) -> crate::Result<()> { |
| self.validate_branch(branch_name).await?; |
| let tag_manager = TagManager::new(self.file_io.clone(), self.table_path.clone()); |
| let snapshot = |
| tag_manager |
| .get(tag_name) |
| .await? |
| .ok_or_else(|| crate::Error::DataInvalid { |
| message: format!("Tag '{}' does not exist.", tag_name), |
| source: None, |
| })?; |
| |
| let snapshot_manager = SnapshotManager::new(self.file_io.clone(), self.table_path.clone()); |
| |
| // Copy tag file to branch |
| let tag_src = tag_manager.tag_path(tag_name); |
| let tag_dst = tag_manager.with_branch(branch_name).tag_path(tag_name); |
| self.file_io.copy_file(&tag_src, &tag_dst).await?; |
| |
| // Copy snapshot file to branch |
| let snap_src = snapshot_manager.snapshot_path(snapshot.id()); |
| let snap_dst = snapshot_manager |
| .with_branch(branch_name) |
| .snapshot_path(snapshot.id()); |
| self.file_io.copy_file(&snap_src, &snap_dst).await?; |
| |
| // Copy schemas to branch |
| self.copy_schemas_to_branch(branch_name, snapshot.schema_id()) |
| .await?; |
| |
| Ok(()) |
| } |
| |
| /// Drop an existing branch. |
| pub async fn drop_branch(&self, branch_name: &str) -> crate::Result<()> { |
| if !self.branch_exists(branch_name).await? { |
| return Err(crate::Error::DataInvalid { |
| message: format!("Branch name '{}' doesn't exist.", branch_name), |
| source: None, |
| }); |
| } |
| let path = self.branch_path(branch_name); |
| self.file_io.delete_dir(&path).await?; |
| Ok(()) |
| } |
| |
| /// Rename an existing branch. |
| pub async fn rename_branch(&self, from: &str, to: &str) -> crate::Result<()> { |
| if from == DEFAULT_MAIN_BRANCH { |
| return Err(crate::Error::DataInvalid { |
| message: "Cannot rename the main branch.".to_string(), |
| source: None, |
| }); |
| } |
| if !self.branch_exists(from).await? { |
| return Err(crate::Error::DataInvalid { |
| message: format!("Branch name '{}' doesn't exist.", from), |
| source: None, |
| }); |
| } |
| Self::validate_branch_name(to)?; |
| if self.branch_exists(to).await? { |
| return Err(crate::Error::DataInvalid { |
| message: format!("Branch name '{}' already exists.", to), |
| source: None, |
| }); |
| } |
| let src = self.branch_path(from); |
| let dst = self.branch_path(to); |
| match self.file_io.rename(&src, &dst).await { |
| Ok(()) => Ok(()), |
| Err(crate::Error::IoUnexpected { ref source, .. }) |
| if source.kind() == opendal::ErrorKind::Unsupported => |
| { |
| self.copy_dir_recursive(&src, &dst).await?; |
| self.file_io.delete_dir(&src).await?; |
| Ok(()) |
| } |
| Err(e) => Err(e), |
| } |
| } |
| |
| /// Recursively copy a directory tree from src to dst. |
| async fn copy_dir_recursive(&self, src: &str, dst: &str) -> crate::Result<()> { |
| use std::collections::VecDeque; |
| |
| self.file_io.mkdirs(dst).await?; |
| let mut queue = VecDeque::new(); |
| queue.push_back((src.to_string(), dst.to_string())); |
| |
| while let Some((current_src, current_dst)) = queue.pop_front() { |
| self.file_io.mkdirs(¤t_dst).await?; |
| let statuses = self.file_io.list_status(¤t_src).await?; |
| for status in statuses { |
| let name = get_basename(&status.path).trim_end_matches('/'); |
| let src_path = format!("{}/{}", current_src.trim_end_matches('/'), name); |
| let dst_path = format!("{}/{}", current_dst.trim_end_matches('/'), name); |
| if status.is_dir { |
| queue.push_back((src_path, dst_path)); |
| } else { |
| self.file_io.copy_file(&src_path, &dst_path).await?; |
| } |
| } |
| } |
| Ok(()) |
| } |
| |
| /// Copy all schemas with id <= schema_id to the branch directory. |
| async fn copy_schemas_to_branch(&self, branch_name: &str, schema_id: i64) -> crate::Result<()> { |
| let schema_manager = SchemaManager::new(self.file_io.clone(), self.table_path.clone()); |
| let ids = schema_manager.list_all_ids().await?; |
| let branch_schema_manager = schema_manager.with_branch(branch_name); |
| for id in ids { |
| if id <= schema_id { |
| let src = schema_manager.schema_path(id); |
| let dst = branch_schema_manager.schema_path(id); |
| self.file_io.copy_file(&src, &dst).await?; |
| } |
| } |
| Ok(()) |
| } |
| } |
| |
| #[cfg(test)] |
| mod tests { |
| use super::*; |
| use crate::io::FileIOBuilder; |
| use crate::spec::{CommitKind, Schema, Snapshot, TableSchema}; |
| use bytes::Bytes; |
| |
| fn test_file_io() -> FileIO { |
| FileIOBuilder::new("memory").build().unwrap() |
| } |
| |
| fn test_schema() -> TableSchema { |
| let schema = Schema::builder().build().unwrap(); |
| TableSchema::new(0, &schema) |
| } |
| |
| fn test_snapshot(id: i64) -> 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(1000 * id as u64) |
| .build() |
| } |
| |
| async fn write_schema(file_io: &FileIO, schema_manager: &SchemaManager, schema: &TableSchema) { |
| let path = schema_manager.schema_path(schema.id()); |
| let json = serde_json::to_string(schema).unwrap(); |
| let output = file_io.new_output(&path).unwrap(); |
| output.write(Bytes::from(json)).await.unwrap(); |
| } |
| |
| async fn write_snapshot( |
| file_io: &FileIO, |
| snapshot_manager: &SnapshotManager, |
| snapshot: &Snapshot, |
| ) { |
| let path = snapshot_manager.snapshot_path(snapshot.id()); |
| let json = serde_json::to_string(snapshot).unwrap(); |
| let output = file_io.new_output(&path).unwrap(); |
| output.write(Bytes::from(json)).await.unwrap(); |
| } |
| |
| async fn write_tag( |
| file_io: &FileIO, |
| tag_manager: &TagManager, |
| name: &str, |
| snapshot: &Snapshot, |
| ) { |
| let path = tag_manager.tag_path(name); |
| let json = serde_json::to_string(snapshot).unwrap(); |
| let output = file_io.new_output(&path).unwrap(); |
| output.write(Bytes::from(json)).await.unwrap(); |
| } |
| |
| #[tokio::test] |
| async fn test_list_all_missing_dir_returns_empty() { |
| let file_io = test_file_io(); |
| let bm = BranchManager::new(file_io, "memory:/test_branch_missing".to_string()); |
| assert!(bm.list_all().await.unwrap().is_empty()); |
| } |
| |
| #[tokio::test] |
| async fn test_list_all_sorted() { |
| let file_io = test_file_io(); |
| let table_path = "memory:/test_branch_sorted".to_string(); |
| let branch_dir = format!("{table_path}/branch"); |
| file_io.mkdirs(&branch_dir).await.unwrap(); |
| |
| for name in ["b", "a", "c"] { |
| let path = format!("{branch_dir}/branch-{name}"); |
| file_io.mkdirs(&path).await.unwrap(); |
| } |
| |
| let bm = BranchManager::new(file_io, table_path); |
| assert_eq!(bm.list_all().await.unwrap(), vec!["a", "b", "c"]); |
| } |
| |
| #[tokio::test] |
| async fn test_branch_exists() { |
| let file_io = test_file_io(); |
| let table_path = "memory:/test_branch_exists".to_string(); |
| let branch_dir = format!("{table_path}/branch"); |
| file_io.mkdirs(&branch_dir).await.unwrap(); |
| file_io |
| .mkdirs(&format!("{branch_dir}/branch-foo")) |
| .await |
| .unwrap(); |
| |
| let bm = BranchManager::new(file_io, table_path); |
| assert!(bm.branch_exists("foo").await.unwrap()); |
| assert!(!bm.branch_exists("bar").await.unwrap()); |
| } |
| |
| #[tokio::test] |
| async fn test_validate_branch_name_rejects_main() { |
| let result = BranchManager::validate_branch_name("main"); |
| assert!(result.is_err()); |
| let msg = format!("{}", result.unwrap_err()); |
| assert!(msg.contains("main")); |
| } |
| |
| #[tokio::test] |
| async fn test_validate_branch_name_rejects_blank() { |
| let result = BranchManager::validate_branch_name(""); |
| assert!(result.is_err()); |
| let msg = format!("{}", result.unwrap_err()); |
| assert!(msg.contains("blank")); |
| } |
| |
| #[tokio::test] |
| async fn test_validate_branch_name_rejects_numeric() { |
| let result = BranchManager::validate_branch_name("123"); |
| assert!(result.is_err()); |
| let msg = format!("{}", result.unwrap_err()); |
| assert!(msg.contains("numeric")); |
| } |
| |
| #[tokio::test] |
| async fn test_validate_branch_name_accepts_valid() { |
| assert!(BranchManager::validate_branch_name("my_branch").is_ok()); |
| assert!(BranchManager::validate_branch_name("branch-1").is_ok()); |
| } |
| |
| #[tokio::test] |
| async fn test_create_branch() { |
| let file_io = test_file_io(); |
| let table_path = "memory:/test_create_branch".to_string(); |
| let schema_manager = SchemaManager::new(file_io.clone(), table_path.clone()); |
| write_schema(&file_io, &schema_manager, &test_schema()).await; |
| |
| let bm = BranchManager::new(file_io.clone(), table_path.clone()); |
| bm.create_branch("my_branch").await.unwrap(); |
| |
| assert!(bm.branch_exists("my_branch").await.unwrap()); |
| // Verify schema was copied |
| let branch_schema_manager = schema_manager.with_branch("my_branch"); |
| let schemas = branch_schema_manager.list_all().await.unwrap(); |
| assert_eq!(schemas.len(), 1); |
| assert_eq!(schemas[0].id(), 0); |
| } |
| |
| #[tokio::test] |
| async fn test_create_branch_already_exists() { |
| let file_io = test_file_io(); |
| let table_path = "memory:/test_create_branch_exists".to_string(); |
| let schema_manager = SchemaManager::new(file_io.clone(), table_path.clone()); |
| write_schema(&file_io, &schema_manager, &test_schema()).await; |
| |
| let bm = BranchManager::new(file_io, table_path); |
| bm.create_branch("my_branch").await.unwrap(); |
| let result = bm.create_branch("my_branch").await; |
| assert!(result.is_err()); |
| let msg = format!("{}", result.unwrap_err()); |
| assert!(msg.contains("already exists")); |
| } |
| |
| #[tokio::test] |
| async fn test_create_branch_from_tag() { |
| let file_io = test_file_io(); |
| let table_path = "memory:/test_create_branch_from_tag".to_string(); |
| let schema_manager = SchemaManager::new(file_io.clone(), table_path.clone()); |
| let snapshot_manager = SnapshotManager::new(file_io.clone(), table_path.clone()); |
| let tag_manager = TagManager::new(file_io.clone(), table_path.clone()); |
| |
| write_schema(&file_io, &schema_manager, &test_schema()).await; |
| let snap = test_snapshot(1); |
| write_snapshot(&file_io, &snapshot_manager, &snap).await; |
| write_tag(&file_io, &tag_manager, "v1", &snap).await; |
| |
| let bm = BranchManager::new(file_io.clone(), table_path.clone()); |
| bm.create_branch_from_tag("my_branch", "v1").await.unwrap(); |
| |
| assert!(bm.branch_exists("my_branch").await.unwrap()); |
| |
| // Verify snapshot was copied |
| let branch_snap_manager = snapshot_manager.with_branch("my_branch"); |
| let copied = branch_snap_manager.get_snapshot(1).await.unwrap(); |
| assert_eq!(copied.id(), 1); |
| |
| // Verify tag was copied |
| let branch_tag_manager = tag_manager.with_branch("my_branch"); |
| let tag_snap = branch_tag_manager.get("v1").await.unwrap(); |
| assert!(tag_snap.is_some()); |
| |
| // Verify schema was copied |
| let branch_schema_manager = schema_manager.with_branch("my_branch"); |
| let schemas = branch_schema_manager.list_all().await.unwrap(); |
| assert_eq!(schemas.len(), 1); |
| } |
| |
| #[tokio::test] |
| async fn test_drop_branch() { |
| let file_io = test_file_io(); |
| let table_path = "memory:/test_drop_branch".to_string(); |
| let schema_manager = SchemaManager::new(file_io.clone(), table_path.clone()); |
| write_schema(&file_io, &schema_manager, &test_schema()).await; |
| |
| let bm = BranchManager::new(file_io, table_path); |
| bm.create_branch("to_drop").await.unwrap(); |
| assert!(bm.branch_exists("to_drop").await.unwrap()); |
| |
| bm.drop_branch("to_drop").await.unwrap(); |
| assert!(!bm.branch_exists("to_drop").await.unwrap()); |
| } |
| |
| #[tokio::test] |
| async fn test_drop_nonexistent_branch() { |
| let file_io = test_file_io(); |
| let bm = BranchManager::new(file_io, "memory:/test_drop_none".to_string()); |
| let result = bm.drop_branch("nonexistent").await; |
| assert!(result.is_err()); |
| let msg = format!("{}", result.unwrap_err()); |
| assert!(msg.contains("doesn't exist")); |
| } |
| |
| #[tokio::test] |
| async fn test_rename_branch() { |
| let file_io = test_file_io(); |
| let table_path = "memory:/test_rename_branch".to_string(); |
| let schema_manager = SchemaManager::new(file_io.clone(), table_path.clone()); |
| write_schema(&file_io, &schema_manager, &test_schema()).await; |
| |
| let bm = BranchManager::new(file_io, table_path); |
| bm.create_branch("old_branch").await.unwrap(); |
| assert!(bm.branch_exists("old_branch").await.unwrap()); |
| |
| bm.rename_branch("old_branch", "new_branch").await.unwrap(); |
| assert!(!bm.branch_exists("old_branch").await.unwrap()); |
| assert!(bm.branch_exists("new_branch").await.unwrap()); |
| |
| let branches = bm.list_all().await.unwrap(); |
| assert!(branches.contains(&"new_branch".to_string())); |
| assert!(!branches.contains(&"old_branch".to_string())); |
| } |
| |
| #[tokio::test] |
| async fn test_rename_nonexistent_branch() { |
| let file_io = test_file_io(); |
| let bm = BranchManager::new(file_io, "memory:/test_rename_none".to_string()); |
| let result = bm.rename_branch("nonexistent", "new_name").await; |
| assert!(result.is_err()); |
| let msg = format!("{}", result.unwrap_err()); |
| assert!(msg.contains("doesn't exist")); |
| } |
| |
| #[tokio::test] |
| async fn test_rename_to_existing_branch() { |
| let file_io = test_file_io(); |
| let table_path = "memory:/test_rename_to_existing".to_string(); |
| let schema_manager = SchemaManager::new(file_io.clone(), table_path.clone()); |
| write_schema(&file_io, &schema_manager, &test_schema()).await; |
| |
| let bm = BranchManager::new(file_io, table_path); |
| bm.create_branch("branch1").await.unwrap(); |
| bm.create_branch("branch2").await.unwrap(); |
| |
| let result = bm.rename_branch("branch1", "branch2").await; |
| assert!(result.is_err()); |
| let msg = format!("{}", result.unwrap_err()); |
| assert!(msg.contains("already exists")); |
| } |
| |
| #[tokio::test] |
| async fn test_rename_main_branch_should_fail() { |
| let file_io = test_file_io(); |
| let bm = BranchManager::new(file_io, "memory:/test_rename_main".to_string()); |
| let result = bm.rename_branch("main", "new_name").await; |
| assert!(result.is_err()); |
| let msg = format!("{}", result.unwrap_err()); |
| assert!(msg.contains("Cannot rename the main branch")); |
| } |
| |
| #[tokio::test] |
| async fn test_rename_branch_multiple_times() { |
| let file_io = test_file_io(); |
| let table_path = "memory:/test_rename_multi".to_string(); |
| let schema_manager = SchemaManager::new(file_io.clone(), table_path.clone()); |
| write_schema(&file_io, &schema_manager, &test_schema()).await; |
| |
| let bm = BranchManager::new(file_io, table_path); |
| bm.create_branch("branch1").await.unwrap(); |
| bm.rename_branch("branch1", "branch2").await.unwrap(); |
| bm.rename_branch("branch2", "branch3").await.unwrap(); |
| |
| assert!(!bm.branch_exists("branch1").await.unwrap()); |
| assert!(!bm.branch_exists("branch2").await.unwrap()); |
| assert!(bm.branch_exists("branch3").await.unwrap()); |
| } |
| } |