| /* |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| use crate::Result; |
| use crate::config::HudiConfigs; |
| use crate::config::plan::HudiPlanConfig::ListingParallelism; |
| use crate::config::table::BaseFileFormatValue; |
| use crate::error::CoreError; |
| use crate::file_group::FileGroup; |
| use crate::file_group::base_file::BaseFile; |
| use crate::file_group::log_file::LogFile; |
| use crate::metadata::LAKE_FORMAT_METADATA_DIRS; |
| use crate::statistics::estimator::FileStatsEstimator; |
| use crate::storage::{Storage, get_leaf_dirs}; |
| use crate::table::partition::{ |
| EMPTY_PARTITION_PATH, PARTITION_METAFIELD_PREFIX, PartitionPruner, is_table_partitioned, |
| }; |
| use crate::timeline::completion_time::CompletionTimeView; |
| use dashmap::DashMap; |
| use futures::{StreamExt, TryStreamExt, stream}; |
| use std::collections::HashMap; |
| use std::sync::Arc; |
| |
| #[derive(Clone, Debug)] |
| #[allow(dead_code)] |
| pub struct FileLister { |
| hudi_configs: Arc<HudiConfigs>, |
| storage: Arc<Storage>, |
| partition_pruner: PartitionPruner, |
| } |
| |
| impl FileLister { |
| pub fn new( |
| hudi_configs: Arc<HudiConfigs>, |
| storage: Arc<Storage>, |
| partition_pruner: PartitionPruner, |
| ) -> Self { |
| Self { |
| hudi_configs, |
| storage, |
| partition_pruner, |
| } |
| } |
| |
| fn should_exclude_for_listing(file_name: &str) -> bool { |
| file_name.starts_with(PARTITION_METAFIELD_PREFIX) |
| } |
| |
| /// List file groups for a partition, setting completion timestamps from the view. |
| /// |
| /// # Arguments |
| /// * `partition_path` - The partition path to list files from |
| /// * `completion_time_view` - View to look up completion timestamps. |
| /// |
| /// Files whose commit timestamps are not found in the completion time view |
| /// (i.e., uncommitted files) will have `completion_timestamp = None`. |
| async fn list_file_groups_for_partition<V: CompletionTimeView>( |
| &self, |
| partition_path: &str, |
| completion_time_view: &V, |
| estimator: Option<&FileStatsEstimator>, |
| ) -> Result<Vec<FileGroup>> { |
| let configured_base_file_format = BaseFileFormatValue::from_configs(&self.hudi_configs)?; |
| |
| let listed_file_metadata = self.storage.list_files(Some(partition_path)).await?; |
| |
| let mut file_id_to_base_files: HashMap<String, Vec<BaseFile>> = HashMap::new(); |
| let mut file_id_to_log_files: HashMap<String, Vec<LogFile>> = HashMap::new(); |
| |
| for file_metadata in listed_file_metadata { |
| if FileLister::should_exclude_for_listing(&file_metadata.name) { |
| continue; |
| } |
| |
| let is_base_file = configured_base_file_format.as_ref().map_or_else( |
| || BaseFileFormatValue::from_extension(&file_metadata.name).is_some(), |
| |format| format.matches_extension(&file_metadata.name), |
| ); |
| if is_base_file { |
| // After excluding the unintended files, |
| // we expect a file that has the base file extension to be a valid base file. |
| let mut base_file = BaseFile::try_from(file_metadata)?; |
| |
| // Look up completion timestamp, and skip the file outright if the |
| // commit that wrote it never completed. |
| base_file.set_completion_time(completion_time_view); |
| if !completion_time_view.is_committed(&base_file.commit_timestamp) { |
| continue; |
| } |
| if let Some(metadata) = base_file.file_metadata.as_mut() { |
| // Populate estimated stats for storage-listing paths so |
| // snapshot/time-travel metadata matches MDT-backed paths. |
| if metadata.size > 0 { |
| let (byte_size, num_records) = estimator |
| .map(|e| e.estimate(metadata.size)) |
| .unwrap_or((0, 0)); |
| metadata.byte_size = byte_size; |
| metadata.num_records = num_records; |
| } |
| } |
| |
| let file_id = &base_file.file_id; |
| file_id_to_base_files |
| .entry(file_id.to_owned()) |
| .or_default() |
| .push(base_file); |
| } else { |
| match LogFile::try_from(file_metadata) { |
| Ok(mut log_file) => { |
| // Look up completion timestamp, and skip the file outright |
| // if the commit that wrote it never completed. |
| log_file.set_completion_time(completion_time_view); |
| if !completion_time_view.is_committed(&log_file.timestamp) { |
| continue; |
| } |
| |
| let file_id = &log_file.file_id; |
| file_id_to_log_files |
| .entry(file_id.to_owned()) |
| .or_default() |
| .push(log_file); |
| } |
| Err(e) => { |
| // We don't support cdc log files yet, hence skipping error when parsing |
| // fails. However, once we support all data files, we should return error |
| // here because we expect all files to be either base files or log files, |
| // after excluding the unintended files. |
| log::warn!("Failed to create a log file: {e}"); |
| continue; |
| } |
| } |
| } |
| } |
| |
| let mut file_groups: Vec<FileGroup> = Vec::new(); |
| for (file_id, base_files) in file_id_to_base_files.into_iter() { |
| let mut file_group = FileGroup::new(file_id.to_owned(), partition_path.to_string()); |
| |
| file_group.add_base_files(base_files)?; |
| |
| let log_files = file_id_to_log_files.remove(&file_id).unwrap_or_default(); |
| file_group.add_log_files(log_files)?; |
| |
| file_groups.push(file_group); |
| } |
| |
| // Whatever log files are left belong to file groups with no base file: |
| // inserts that went straight to a log file, which is what Flink |
| // ingestion, a bucket index's first write to a bucket, and any |
| // merge-on-read file group before its first compaction all produce. |
| // Dropping them loses every record in the group. |
| for (file_id, log_files) in file_id_to_log_files.into_iter() { |
| let mut file_group = FileGroup::new(file_id, partition_path.to_string()); |
| file_group.add_log_files(log_files)?; |
| file_groups.push(file_group); |
| } |
| Ok(file_groups) |
| } |
| |
| async fn list_relevant_partition_paths(&self) -> Result<Vec<String>> { |
| if !is_table_partitioned(&self.hudi_configs)? { |
| return Ok(vec![EMPTY_PARTITION_PATH.to_string()]); |
| } |
| |
| let top_level_dirs: Vec<String> = self |
| .storage |
| .list_dirs(None) |
| .await? |
| .into_iter() |
| .filter(|dir| !LAKE_FORMAT_METADATA_DIRS.contains(&dir.as_str())) |
| .collect(); |
| |
| let mut partition_paths = Vec::new(); |
| for dir in top_level_dirs { |
| partition_paths.extend(get_leaf_dirs(&self.storage, Some(&dir)).await?); |
| } |
| |
| if partition_paths.is_empty() || self.partition_pruner.is_empty() { |
| return Ok(partition_paths); |
| } |
| |
| Ok(partition_paths |
| .into_iter() |
| .filter(|path_str| self.partition_pruner.should_include(path_str)) |
| .collect()) |
| } |
| |
| /// List file groups for all relevant partitions. |
| /// |
| /// # Arguments |
| /// * `completion_time_view` - View to look up completion timestamps. |
| pub async fn list_file_groups_for_relevant_partitions<V: CompletionTimeView + Sync>( |
| &self, |
| completion_time_view: &V, |
| estimator: Option<&FileStatsEstimator>, |
| ) -> Result<DashMap<String, Vec<FileGroup>>> { |
| if !is_table_partitioned(&self.hudi_configs)? { |
| let file_groups = self |
| .list_file_groups_for_partition( |
| EMPTY_PARTITION_PATH, |
| completion_time_view, |
| estimator, |
| ) |
| .await?; |
| let file_groups_map = DashMap::with_capacity(1); |
| file_groups_map.insert(EMPTY_PARTITION_PATH.to_string(), file_groups); |
| return Ok(file_groups_map); |
| } |
| |
| let pruned_partition_paths = self.list_relevant_partition_paths().await?; |
| let file_groups_map = Arc::new(DashMap::with_capacity(pruned_partition_paths.len())); |
| let parallelism = self.hudi_configs.get_or_default(ListingParallelism).into(); |
| stream::iter(pruned_partition_paths) |
| .map(|p| async move { |
| let file_groups = self |
| .list_file_groups_for_partition(&p, completion_time_view, estimator) |
| .await?; |
| Ok::<_, CoreError>((p, file_groups)) |
| }) |
| .buffer_unordered(parallelism) |
| .try_for_each(|(p, file_groups)| { |
| let file_groups_map = file_groups_map.clone(); |
| async move { |
| file_groups_map.insert(p, file_groups); |
| Ok(()) |
| } |
| }) |
| .await?; |
| |
| Ok(file_groups_map.as_ref().to_owned()) |
| } |
| } |
| |
| #[cfg(test)] |
| mod test { |
| use super::*; |
| use crate::config::table::HudiTableConfig::BasePath; |
| use crate::table::Table; |
| use crate::timeline::view::TimelineView; |
| use hudi_test::SampleTable; |
| use std::collections::{HashMap, HashSet}; |
| use std::sync::Arc; |
| use tempfile::tempdir; |
| use url::Url; |
| |
| /// A view that admits every file, so these tests stay about extension |
| /// handling rather than commit visibility: the far-future archival boundary |
| /// puts every instant below it, i.e. archived and therefore committed. |
| /// Commit visibility is covered in `timeline::view`. |
| fn layout_v1_view() -> TimelineView { |
| TimelineView::new_with_archival_boundary( |
| "99999999999999999".to_string(), |
| None, |
| &[], |
| HashSet::new(), |
| &Arc::new(HudiConfigs::new([("hoodie.timeline.layout.version", "1")])), |
| Some("99999999999999999".to_string()), |
| ) |
| } |
| |
| #[tokio::test] |
| async fn list_partition_paths_for_nonpartitioned_table() { |
| let base_url = SampleTable::V6Nonpartitioned.url_to_cow(); |
| let hudi_table = Table::new(base_url.path()).await.unwrap(); |
| let lister = FileLister::new( |
| hudi_table.hudi_configs.clone(), |
| hudi_table.file_system_view.storage.clone(), |
| PartitionPruner::empty(), |
| ); |
| let partition_paths = lister.list_relevant_partition_paths().await.unwrap(); |
| let partition_path_set: HashSet<&str> = |
| HashSet::from_iter(partition_paths.iter().map(|p| p.as_str())); |
| assert_eq!(partition_path_set, HashSet::from([""])) |
| } |
| |
| #[tokio::test] |
| async fn list_partition_paths_for_complexkeygen_table() { |
| let base_url = SampleTable::V6ComplexkeygenHivestyle.url_to_cow(); |
| let hudi_table = Table::new(base_url.path()).await.unwrap(); |
| let fs_view = &hudi_table.file_system_view; |
| let lister = FileLister::new( |
| fs_view.hudi_configs.clone(), |
| fs_view.storage.clone(), |
| PartitionPruner::empty(), |
| ); |
| let partition_paths = lister.list_relevant_partition_paths().await.unwrap(); |
| let partition_path_set: HashSet<&str> = |
| HashSet::from_iter(partition_paths.iter().map(|p| p.as_str())); |
| assert_eq!( |
| partition_path_set, |
| HashSet::from_iter(vec![ |
| "byteField=10/shortField=300", |
| "byteField=20/shortField=100", |
| "byteField=30/shortField=100" |
| ]) |
| ) |
| } |
| |
| #[tokio::test] |
| async fn list_file_groups_uses_extension_fallback_when_format_config_is_absent() { |
| let temp_dir = tempdir().unwrap(); |
| std::fs::write( |
| temp_dir |
| .path() |
| .join("file-id-0_0-7-24_20240418173200000.lance"), |
| [], |
| ) |
| .unwrap(); |
| std::fs::write( |
| temp_dir |
| .path() |
| .join("file-id-1_0-8-25_20240418173210000.parquet"), |
| [], |
| ) |
| .unwrap(); |
| std::fs::write(temp_dir.path().join("ignored.txt"), []).unwrap(); |
| |
| let base_url = Url::from_directory_path(temp_dir.path()).unwrap(); |
| let hudi_configs = Arc::new(HudiConfigs::new([(BasePath.as_ref(), base_url.as_str())])); |
| let storage = Storage::new(Arc::new(HashMap::new()), hudi_configs.clone()).unwrap(); |
| let lister = FileLister::new(hudi_configs, storage, PartitionPruner::empty()); |
| let view = layout_v1_view(); |
| |
| let file_groups = lister |
| .list_file_groups_for_partition(EMPTY_PARTITION_PATH, &view, None) |
| .await |
| .unwrap(); |
| let extensions: HashSet<_> = file_groups |
| .iter() |
| .flat_map(|fg| fg.file_slices.values()) |
| .map(|slice| slice.base_file.as_ref().unwrap().extension.as_str()) |
| .collect(); |
| |
| assert_eq!(extensions, HashSet::from(["lance", "parquet"])); |
| } |
| } |