use std::collections::HashMap;
use std::str::FromStr;
use std::sync::Arc;
use crate::Result;
use crate::config::HudiConfigs;
use crate::config::properties::escape_property;
use crate::config::table::{HudiTableConfig, TableTypeValue, TimelineTimezoneValue};
use crate::error::CoreError;
use crate::metadata::HUDI_METADATA_DIR;
use crate::storage::Storage;
use crate::table::Table;
use crate::write::metadata::bootstrap_metadata_table;
const RECORD_INDEX_ENABLE_KEYS: [&str; 2] = [
"hoodie.metadata.global.record.level.index.enable",
"hoodie.metadata.record.index.enable",
];
const BUILDER_MANAGED_KEYS: &[&str] = &[
"hoodie.base.path",
"hoodie.table.name",
"hoodie.table.type",
"hoodie.table.version",
"hoodie.table.initial.version",
"hoodie.timeline.layout.version",
"hoodie.populate.meta.fields",
"hoodie.table.recordkey.fields",
"hoodie.table.partition.fields",
"hoodie.table.ordering.fields",
"hoodie.table.precombine.field",
"hoodie.table.keygenerator.type",
"hoodie.table.keygenerator.class",
"hoodie.table.base.file.format",
"hoodie.table.metadata.partitions",
"hoodie.table.metadata.partitions.inflight",
"hoodie.table.index.defs.path",
"hoodie.metadata.enable",
"hoodie.metadata.index.column.stats.enable",
"hoodie.metadata.index.partition.stats.enable",
"hoodie.datasource.write.hive_style_partitioning",
"hoodie.datasource.write.drop.partition.columns",
"hoodie.table.cdc.enabled",
"hoodie.table.multiple.base.file.formats.enable",
];
#[derive(Debug, Clone)]
pub struct TableCreateBuilder {
base_uri: String,
table_name: Option<String>,
table_type: TableTypeValue,
table_version: isize,
timeline_layout_version: isize,
populates_meta_fields: bool,
record_key_fields: Vec<String>,
partition_fields: Vec<String>,
ordering_fields: Vec<String>,
metadata_table_enabled: bool,
record_index_enabled: bool,
record_index_set_by_java_key: bool,
column_stats_enabled: bool,
partition_stats_enabled: bool,
hive_style_partitioning: bool,
options: HashMap<String, String>,
storage_options: HashMap<String, String>,
}
impl TableCreateBuilder {
pub fn new(base_uri: impl Into<String>) -> Self {
Self {
base_uri: base_uri.into(),
table_name: None,
table_type: TableTypeValue::CopyOnWrite,
table_version: 9,
timeline_layout_version: 2,
populates_meta_fields: true,
record_key_fields: Vec::new(),
partition_fields: Vec::new(),
ordering_fields: Vec::new(),
metadata_table_enabled: true,
record_index_enabled: true,
record_index_set_by_java_key: false,
column_stats_enabled: true,
partition_stats_enabled: true,
hive_style_partitioning: true,
options: HashMap::new(),
storage_options: HashMap::new(),
}
}
pub fn with_table_name(mut self, name: impl Into<String>) -> Self {
self.table_name = Some(name.into());
self
}
pub fn with_table_type(mut self, table_type: TableTypeValue) -> Self {
self.table_type = table_type;
self
}
pub fn with_table_version(mut self, version: isize) -> Self {
self.table_version = version;
self
}
pub fn with_timeline_layout_version(mut self, version: isize) -> Self {
self.timeline_layout_version = version;
self
}
pub fn with_populates_meta_fields(mut self, value: bool) -> Self {
self.populates_meta_fields = value;
self
}
pub fn with_record_key_fields<I, S>(mut self, fields: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.record_key_fields = fields.into_iter().map(Into::into).collect();
self
}
pub fn with_partition_fields<I, S>(mut self, fields: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.partition_fields = fields.into_iter().map(Into::into).collect();
self
}
pub fn with_ordering_fields<I, S>(mut self, fields: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.ordering_fields = fields.into_iter().map(Into::into).collect();
self
}
pub fn with_metadata_table_enabled(mut self, enabled: bool) -> Self {
self.metadata_table_enabled = enabled;
if enabled {
if self.table_version < 8 {
self.table_version = 8;
}
self.timeline_layout_version = 2;
} else {
self.record_index_enabled = false;
self.column_stats_enabled = false;
self.partition_stats_enabled = false;
}
self
}
pub fn with_record_index_enabled(mut self, enabled: bool) -> Self {
self.record_index_enabled = enabled;
self
}
pub fn with_column_stats_enabled(mut self, enabled: bool) -> Self {
self.column_stats_enabled = enabled;
if !enabled {
self.partition_stats_enabled = false;
}
self
}
pub fn with_partition_stats_enabled(mut self, enabled: bool) -> Self {
self.partition_stats_enabled = enabled;
self
}
pub fn with_hive_style_partitioning(mut self, enabled: bool) -> Self {
self.hive_style_partitioning = enabled;
self
}
pub fn with_option(mut self, key: impl AsRef<str>, value: impl Into<String>) -> Self {
let key = key.as_ref();
let value = value.into();
if key == RECORD_INDEX_ENABLE_KEYS[0] {
self.record_index_set_by_java_key = true;
return self.with_record_index_enabled(value.eq_ignore_ascii_case("true"));
}
if key == RECORD_INDEX_ENABLE_KEYS[1] {
if !self.record_index_set_by_java_key {
self.record_index_enabled = value.eq_ignore_ascii_case("true");
}
return self;
}
self.options.insert(key.to_string(), value);
self
}
pub fn with_storage_option(mut self, key: impl AsRef<str>, value: impl Into<String>) -> Self {
self.storage_options
.insert(key.as_ref().to_string(), value.into());
self
}
pub async fn create(mut self) -> Result<Table> {
if let Some(key) = self
.options
.keys()
.find(|key| BUILDER_MANAGED_KEYS.contains(&key.as_str()))
{
return Err(CoreError::Write(format!(
"'{key}' is set by a dedicated table builder method and cannot be passed to with_option"
)));
}
let table_name = self.table_name.ok_or_else(|| {
CoreError::Write("Table name is required to create a Hudi table".to_string())
})?;
if table_name.is_empty() {
return Err(CoreError::Write("table name must be non-empty".to_string()));
}
if self.record_index_enabled && !self.metadata_table_enabled {
return Err(CoreError::Write(
"record_index requires the metadata table; set metadata_table_enabled to true or record_index_enabled to false"
.to_string(),
));
}
if self.column_stats_enabled && !self.metadata_table_enabled {
return Err(CoreError::Write(
"column_stats requires the metadata table; set metadata_table_enabled to true or column_stats_enabled to false"
.to_string(),
));
}
if self.partition_stats_enabled && !self.column_stats_enabled {
return Err(CoreError::Write(
"partition_stats requires column_stats; set column_stats_enabled to true or partition_stats_enabled to false"
.to_string(),
));
}
let partition_stats_enabled =
self.partition_stats_enabled && !self.partition_fields.is_empty();
if !(8..=9).contains(&self.table_version) {
return Err(CoreError::Unsupported(format!(
"creating table version {} is not supported; supported versions are 8 and 9",
self.table_version
)));
}
if self.table_version < 9
&& self.record_key_fields.len() == 1
&& self.partition_fields.len() > 1
&& crate::write::complex_keygen_validation_enabled(|key| {
self.options.get(key).map(String::as_str)
})
{
return Err(CoreError::Unsupported(format!(
"a table version {} table with one record key field and several partition \
fields uses the complex key generator, which Java writers refuse below table \
version 9 unless {}=false",
self.table_version,
crate::write::keygen::COMPLEX_KEYGEN_VALIDATION
)));
}
if self.table_version >= 8 && self.timeline_layout_version != 2 {
return Err(CoreError::Write(format!(
"Table version {} requires timeline layout version 2",
self.table_version
)));
}
if self.table_version < 8 && self.timeline_layout_version != 1 {
return Err(CoreError::Write(format!(
"Table version {} requires timeline layout version 1",
self.table_version
)));
}
let mut props: HashMap<String, String> = HashMap::new();
props.insert(
HudiTableConfig::TableName.as_ref().to_string(),
table_name.clone(),
);
props.insert(
HudiTableConfig::TableType.as_ref().to_string(),
self.table_type.as_ref().to_string(),
);
props.insert(
HudiTableConfig::TableVersion.as_ref().to_string(),
self.table_version.to_string(),
);
props.insert(
"hoodie.table.initial.version".to_string(),
self.table_version.to_string(),
);
props.insert(
HudiTableConfig::TimelineLayoutVersion.as_ref().to_string(),
self.timeline_layout_version.to_string(),
);
props.insert(
HudiTableConfig::PopulatesMetaFields.as_ref().to_string(),
self.populates_meta_fields.to_string(),
);
props.insert(
HudiTableConfig::BaseFileFormat.as_ref().to_string(),
"PARQUET".to_string(),
);
props.insert(
HudiTableConfig::IsHiveStylePartitioning
.as_ref()
.to_string(),
self.hive_style_partitioning.to_string(),
);
props.insert(
HudiTableConfig::DropsPartitionFields.as_ref().to_string(),
"false".to_string(),
);
props.insert(
HudiTableConfig::IsPartitionPathUrlencoded
.as_ref()
.to_string(),
"false".to_string(),
);
props.insert(
HudiTableConfig::TimelinePath.as_ref().to_string(),
"timeline".to_string(),
);
props.insert(
HudiTableConfig::TimelineHistoryPath.as_ref().to_string(),
"history".to_string(),
);
props.insert(
HudiTableConfig::ArchiveLogFolder.as_ref().to_string(),
"history".to_string(),
);
let timeline_timezone = self
.options
.remove(HudiTableConfig::TimelineTimezone.as_ref())
.map(|zone| TimelineTimezoneValue::from_str(&zone))
.transpose()?
.unwrap_or(TimelineTimezoneValue::Local)
.java_name();
crate::write::set_commit_timezone(timeline_timezone);
props.insert(
HudiTableConfig::TimelineTimezone.as_ref().to_string(),
timeline_timezone.to_string(),
);
props.insert("hoodie.table.cdc.enabled".to_string(), "false".to_string());
props.insert(
"hoodie.partition.metafile.use.base.format".to_string(),
"false".to_string(),
);
props.insert(
"hoodie.table.metadata.partitions.inflight".to_string(),
String::new(),
);
props.insert(
"hoodie.table.multiple.base.file.formats.enable".to_string(),
"false".to_string(),
);
if self.metadata_table_enabled {
let mut partitions = vec!["files".to_string()];
if self.record_index_enabled {
partitions.push("record_index".to_string());
}
if self.column_stats_enabled {
partitions.push("column_stats".to_string());
}
if partition_stats_enabled {
partitions.push("partition_stats".to_string());
}
partitions.sort();
if self.column_stats_enabled {
props.insert(
crate::write::metadata::INDEX_DEFINITION_PATH_KEY.to_string(),
crate::write::metadata::DEFAULT_INDEX_DEFINITION_PATH.to_string(),
);
}
props.insert(
HudiTableConfig::MetadataTablePartitions
.as_ref()
.to_string(),
partitions.join(","),
);
}
if !self.record_key_fields.is_empty() {
props.insert(
HudiTableConfig::RecordKeyFields.as_ref().to_string(),
self.record_key_fields.join(","),
);
props.insert(
HudiTableConfig::KeyGeneratorType.as_ref().to_string(),
if self.partition_fields.is_empty() {
"NON_PARTITION".to_string()
} else if self.record_key_fields.len() == 1 && self.partition_fields.len() == 1 {
"SIMPLE".to_string()
} else {
"COMPLEX".to_string()
},
);
} else {
props.insert(
HudiTableConfig::KeyGeneratorType.as_ref().to_string(),
if self.partition_fields.is_empty() {
"NON_PARTITION".to_string()
} else if self.partition_fields.len() == 1 {
"SIMPLE".to_string()
} else {
"COMPLEX".to_string()
},
);
}
if !self.partition_fields.is_empty() {
props.insert(
HudiTableConfig::PartitionFields.as_ref().to_string(),
self.partition_fields.join(","),
);
}
if self.populates_meta_fields {
if !self.ordering_fields.is_empty() {
props.insert(
HudiTableConfig::OrderingFields.as_ref().to_string(),
self.ordering_fields.join(","),
);
if self.table_version < 9 {
props.insert(
"hoodie.table.precombine.field".to_string(),
self.ordering_fields.join(","),
);
}
props.insert(
"hoodie.record.merge.mode".to_string(),
"EVENT_TIME_ORDERING".to_string(),
);
props.insert(
"hoodie.record.merge.strategy.id".to_string(),
"eeb8d96f-b1e4-49fd-bbf8-28ac514178e5".to_string(),
);
} else {
props.insert(
"hoodie.record.merge.mode".to_string(),
"COMMIT_TIME_ORDERING".to_string(),
);
props.insert(
"hoodie.record.merge.strategy.id".to_string(),
"ce9acb64-bde0-424c-9b91-f6ebba25356d".to_string(),
);
}
}
for (k, v) in self.options {
props.insert(k, v);
}
let database = props
.get(HudiTableConfig::DatabaseName.as_ref())
.cloned()
.unwrap_or_default();
let checksum = table_checksum(&database, &table_name);
props.insert(
HudiTableConfig::Checksum.as_ref().to_string(),
checksum.to_string(),
);
let mut hudi_for_storage = props.clone();
hudi_for_storage.insert(
HudiTableConfig::BasePath.as_ref().to_string(),
self.base_uri.clone(),
);
let configs = HudiConfigs::new(hudi_for_storage);
crate::table::validation::validate_configs(&configs)?;
crate::write::archival::validated_keep_range(&configs)?;
let mut storage_options = self.storage_options.clone();
crate::table::builder::resolve_storage_env_vars(&mut storage_options);
let storage = Storage::new(Arc::new(storage_options), Arc::new(configs))?;
let properties_path = format!("{HUDI_METADATA_DIR}/hoodie.properties");
if storage.exists(&properties_path).await? {
return Err(CoreError::Write(format!(
"Cannot create table at '{}': {properties_path} already exists",
self.base_uri
)));
}
let mut body = String::from(
"# Generated by hudi-rs\n# Licensed under the Apache License, Version 2.0\n",
);
let mut keys: Vec<_> = props.keys().cloned().collect();
keys.sort();
keys.retain(|k| k != HudiTableConfig::Checksum.as_ref());
for key in keys {
let value = &props[&key];
body.push_str(&format!(
"{}={}\n",
escape_property(&key, true),
escape_property(value, false)
));
}
body.push_str(&format!(
"{}={}\n",
HudiTableConfig::Checksum.as_ref(),
props[HudiTableConfig::Checksum.as_ref()]
));
storage
.put_file_if_absent(&properties_path, body.into_bytes())
.await?;
let index_definition_written = self.metadata_table_enabled && self.column_stats_enabled;
let finished: Result<()> = async {
if self.timeline_layout_version == 2 {
let timeline_path: String = props
.get(HudiTableConfig::TimelinePath.as_ref())
.cloned()
.unwrap_or_else(|| "timeline".to_string());
let marker = format!("{HUDI_METADATA_DIR}/{timeline_path}/.keep");
storage.put_file(&marker, b"".as_slice()).await?;
}
if index_definition_written {
let meta_columns = crate::metadata::table::column_stats::columns_to_index(
&crate::write::append::schema_with_meta_fields(&arrow_schema::Schema::empty()),
crate::metadata::table::encode::StatsIndexVersion::V1,
&[],
0,
);
let meta_columns = if self.populates_meta_fields {
meta_columns
} else {
Vec::new()
};
let version = if self.table_version >= 9 {
crate::metadata::table::encode::StatsIndexVersion::V2
} else {
crate::metadata::table::encode::StatsIndexVersion::V1
};
if let Some(json) = crate::write::metadata::column_stats_index_definition_json(
None,
&meta_columns,
version,
)? {
storage
.put_file(crate::write::metadata::DEFAULT_INDEX_DEFINITION_PATH, json)
.await?;
}
}
if self.metadata_table_enabled {
bootstrap_metadata_table(
&storage,
&table_name,
self.table_version,
self.record_index_enabled,
self.column_stats_enabled,
partition_stats_enabled,
)
.await?;
}
Ok(())
}
.await;
if let Err(error) = finished {
let _ = remove_partial_metadata_table(&storage).await;
if index_definition_written {
let _ = storage
.delete_file(crate::write::metadata::DEFAULT_INDEX_DEFINITION_PATH)
.await;
}
let _ = storage.delete_file(&properties_path).await;
return Err(error);
}
Table::new_with_options(&self.base_uri, self.storage_options).await
}
}
pub(crate) fn table_checksum(database: &str, table: &str) -> u32 {
crc32(format!("{database}.{table}").as_bytes())
}
fn crc32(data: &[u8]) -> u32 {
let mut crc = 0xffff_ffff_u32;
for &byte in data {
crc ^= u32::from(byte);
for _ in 0..8 {
crc = if crc & 1 != 0 {
(crc >> 1) ^ 0xedb8_8320
} else {
crc >> 1
};
}
}
!crc
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn checksum_matches_java_binary_util() {
assert_eq!(table_checksum("default", "trips"), 2_200_697_520);
assert_eq!(table_checksum("", "trips"), 3_761_586_722);
assert_eq!(table_checksum("", "trips_metadata"), 1_249_152_950);
}
}
#[cfg(test)]
mod lifecycle_tests {
use crate::config::table::TableTypeValue;
use crate::table::Table;
#[tokio::test]
async fn test_create_cow_default_full_surface() {
let dir = tempfile::tempdir().unwrap();
let table = Table::create(dir.path().to_str().unwrap())
.with_table_name("trips")
.with_record_key_fields(["id"])
.with_partition_fields(["city"])
.with_ordering_fields(["ts"])
.create()
.await
.unwrap();
assert!(table.is_metadata_table_enabled());
let props = std::fs::read_to_string(dir.path().join(".hoodie/hoodie.properties")).unwrap();
for expected in [
"hoodie.table.name=trips",
"hoodie.table.type=COPY_ON_WRITE",
"hoodie.table.version=9",
"hoodie.timeline.layout.version=2",
"hoodie.table.recordkey.fields=id",
"hoodie.table.partition.fields=city",
"hoodie.record.merge.mode=EVENT_TIME_ORDERING",
"hoodie.table.checksum=",
] {
assert!(
props.contains(expected),
"missing `{expected}` in:\n{props}"
);
}
let mdt_props = std::fs::read_to_string(
dir.path()
.join(".hoodie/metadata/.hoodie/hoodie.properties"),
)
.unwrap();
assert!(mdt_props.contains("hoodie.table.name=trips_metadata"));
assert!(mdt_props.contains("hoodie.table.type=MERGE_ON_READ"));
assert!(!props.contains("hoodie.database.name"), "{props}");
assert!(props.contains(&format!(
"hoodie.table.checksum={}\n",
super::table_checksum("", "trips")
)));
let dir = tempfile::tempdir().unwrap();
Table::create(dir.path().to_str().unwrap())
.with_table_name("trips")
.with_option("hoodie.database.name", "db")
.create()
.await
.unwrap();
let props = std::fs::read_to_string(dir.path().join(".hoodie/hoodie.properties")).unwrap();
assert!(props.contains("hoodie.database.name=db\n"), "{props}");
assert!(props.contains(&format!(
"hoodie.table.checksum={}\n",
super::table_checksum("db", "trips")
)));
}
#[tokio::test]
#[serial_test::serial(env_vars)]
async fn test_create_resolves_storage_env_vars() {
const KEY: &str = "AWS_ALLOW_HTTP";
let saved = std::env::var(KEY).ok();
unsafe { std::env::set_var(KEY, "not-a-bool") };
let result = Table::create("s3://bucket/trips")
.with_table_name("trips")
.with_record_key_fields(["id"])
.create()
.await;
unsafe {
match &saved {
Some(v) => std::env::set_var(KEY, v),
None => std::env::remove_var(KEY),
}
}
let Err(err) = result else {
panic!("create must fail on the invalid env option");
};
assert!(err.to_string().contains("not-a-bool"), "{err}");
}
#[tokio::test]
async fn test_create_mor_tv8_variants() {
let dir = tempfile::tempdir().unwrap();
let _ = Table::create(dir.path().to_str().unwrap())
.with_table_name("trips8")
.with_table_type(TableTypeValue::MergeOnRead)
.with_record_key_fields(["id"])
.with_partition_fields(["city"])
.with_hive_style_partitioning(false)
.with_table_version(8)
.create()
.await
.unwrap();
let props = std::fs::read_to_string(dir.path().join(".hoodie/hoodie.properties")).unwrap();
assert!(props.contains("hoodie.table.version=8"));
assert!(props.contains("hoodie.table.type=MERGE_ON_READ"));
assert!(props.contains("hoodie.record.merge.mode=COMMIT_TIME_ORDERING"));
assert!(props.contains("hoodie.datasource.write.hive_style_partitioning=false"));
let dir = tempfile::tempdir().unwrap();
let _ = Table::create(dir.path().to_str().unwrap())
.with_table_name("nometa")
.with_metadata_table_enabled(false)
.with_record_index_enabled(false)
.with_column_stats_enabled(false)
.with_populates_meta_fields(false)
.with_option("hoodie.parquet.max.file.size", "1048576")
.create()
.await
.unwrap();
let props = std::fs::read_to_string(dir.path().join(".hoodie/hoodie.properties")).unwrap();
assert!(props.contains("hoodie.populate.meta.fields=false"));
assert!(props.contains("hoodie.parquet.max.file.size=1048576"));
assert!(!dir.path().join(".hoodie/metadata").exists());
}
}
async fn remove_partial_metadata_table(storage: &Storage) -> Result<()> {
let root = format!("{HUDI_METADATA_DIR}/metadata");
let mut dirs = vec![root.clone()];
let mut seen = 0usize;
while let Some(dir) = dirs.pop() {
seen += 1;
if seen > 64 {
break;
}
for file in storage.list_files(Some(&dir)).await.unwrap_or_default() {
let _ = storage.delete_file(&format!("{dir}/{}", file.name)).await;
}
for sub in storage.list_dirs(Some(&dir)).await.unwrap_or_default() {
dirs.push(format!("{dir}/{sub}"));
}
}
Ok(())
}