This is an automated email from the ASF dual-hosted git repository.
yihua pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hudi-rs.git
The following commit(s) were added to refs/heads/main by this push:
new 85810b05 docs(rust): document the user-facing API and build docs.rs
with datafusion (#938)
85810b05 is described below
commit 85810b05111471e1706978b8564bb2c2733593c1
Author: Y Ethan Guo <[email protected]>
AuthorDate: Fri Oct 30 13:49:25 2026 -0700
docs(rust): document the user-facing API and build docs.rs with datafusion
(#938)
---
crates/core/src/config/error.rs | 8 +
crates/core/src/config/internal.rs | 1 +
crates/core/src/config/mod.rs | 20 +-
crates/core/src/config/plan.rs | 1 +
crates/core/src/config/read.rs | 2 +-
crates/core/src/config/read_options.rs | 8 +-
crates/core/src/config/table.rs | 8 +
crates/core/src/error.rs | 29 +++
crates/core/src/expr/filter.rs | 63 +++++-
crates/core/src/expr/mod.rs | 18 +-
crates/core/src/file_group/base_file/mod.rs | 4 +-
crates/core/src/file_group/file_slice.rs | 19 +-
crates/core/src/file_group/log_file/reader.rs | 4 +-
crates/core/src/file_group/mod.rs | 2 +
crates/core/src/file_group/reader.rs | 28 ++-
crates/core/src/hfile/reader.rs | 4 +-
crates/core/src/index/mod.rs | 7 +
crates/core/src/lib.rs | 78 +++++--
crates/core/src/metadata/table/column_stats.rs | 2 +-
crates/core/src/metadata/table/hash.rs | 2 +-
crates/core/src/metadata/table/mod.rs | 2 +-
crates/core/src/schema/avsc.rs | 2 +-
crates/core/src/schema/batch_evolution.rs | 2 +-
crates/core/src/schema/extended_promotion.rs | 4 +-
crates/core/src/schema/resolver.rs | 2 +-
crates/core/src/storage/file_metadata.rs | 4 +-
crates/core/src/table/builder.rs | 23 +-
crates/core/src/table/mod.rs | 163 +++++++------
crates/core/src/table/partition.rs | 7 +
crates/core/src/timeline/instant.rs | 2 +-
crates/core/src/timeline/loader.rs | 2 +-
crates/core/src/timeline/mod.rs | 8 +-
crates/core/src/timeline/selector.rs | 2 +-
crates/core/src/write/create.rs | 24 ++
crates/core/src/write/lock.rs | 1 +
crates/core/src/write/mod.rs | 11 +-
crates/datafusion/src/lib.rs | 35 ++-
crates/hudi/Cargo.toml | 8 +-
crates/hudi/src/lib.rs | 302 +++++++++++++++++++++++++
39 files changed, 761 insertions(+), 151 deletions(-)
diff --git a/crates/core/src/config/error.rs b/crates/core/src/config/error.rs
index 6d852f6c..186e0d31 100644
--- a/crates/core/src/config/error.rs
+++ b/crates/core/src/config/error.rs
@@ -16,12 +16,16 @@
* specific language governing permissions and limitations
* under the License.
*/
+//! The error type of config parsing and validation.
+
use std::num::ParseIntError;
use std::str::ParseBoolError;
use thiserror::Error;
+/// Result type of config parsing, failing with [`ConfigError`] by default.
pub type Result<T, E = ConfigError> = std::result::Result<T, E>;
+/// Errors from parsing or validating a config.
#[derive(Error, Debug)]
pub enum ConfigError {
/// ParseBool(key, value, source)
@@ -36,12 +40,16 @@ pub enum ConfigError {
#[error("Failed to parse line: {0}'")]
ParseLine(String),
+ /// InvalidValue(value or message): the value is not one the config
accepts. The payload is
+ /// the rejected value or a message describing it.
#[error("Config value '{0}' is not valid")]
InvalidValue(String),
+ /// NotFound(key): the config is not set.
#[error("Config '{0}' not found")]
NotFound(String),
+ /// UnsupportedValue(message): the value is valid but not supported by
hudi-rs.
#[error("Config value '{0}' is not supported")]
UnsupportedValue(String),
}
diff --git a/crates/core/src/config/internal.rs
b/crates/core/src/config/internal.rs
index 8d8e8184..51cfbe1d 100644
--- a/crates/core/src/config/internal.rs
+++ b/crates/core/src/config/internal.rs
@@ -45,6 +45,7 @@ use crate::config::{ConfigParser, HudiConfigValue};
///
#[derive(Clone, Debug, PartialEq, Eq, Hash, EnumIter)]
pub enum HudiInternalConfig {
+ /// Skip validating table configs when a table is opened. Defaults to
`false`.
SkipConfigValidation,
/// Enable reading archived timeline (v1) and LSM history (v2).
///
diff --git a/crates/core/src/config/mod.rs b/crates/core/src/config/mod.rs
index 9f270a76..886d36d5 100644
--- a/crates/core/src/config/mod.rs
+++ b/crates/core/src/config/mod.rs
@@ -16,7 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
-//! Hudi Configurations.
+//! Hudi configurations.
+//!
+//! Each config is an enum variant that knows its `hoodie.*` key, default, and
how to parse its
+//! value: [`table::HudiTableConfig`] (mostly persisted in
`hoodie.properties`),
+//! [`read::HudiReadConfig`], [`plan::HudiPlanConfig`], and
[`internal::HudiInternalConfig`].
+//! [`HudiConfigs`] holds the resolved key-value options of a table, and
+//! [`read_options::ReadOptions`] those of a single read.
use std::any::type_name;
use std::collections::HashMap;
use std::sync::Arc;
@@ -39,15 +45,20 @@ pub mod util;
pub use read_options::{QueryType, ReadOptions};
+/// Environment variable naming the directory that holds `hudi-defaults.conf`,
whose options a
+/// table picks up when they are not set otherwise. Defaults to
`/etc/hudi/conf` when unset.
pub const HUDI_CONF_DIR: &str = "HUDI_CONF_DIR";
/// An alternative key for a configuration, optionally marked as deprecated.
pub struct ConfigAlias {
+ /// The alternative key.
pub key: &'static str,
+ /// Whether using this key logs a deprecation warning.
pub deprecated: bool,
}
impl ConfigAlias {
+ /// An alias that is not deprecated.
pub const fn new(key: &'static str) -> Self {
Self {
key,
@@ -55,6 +66,7 @@ impl ConfigAlias {
}
}
+ /// A deprecated alias.
pub const fn deprecated(key: &'static str) -> Self {
Self {
key,
@@ -71,6 +83,7 @@ pub trait ConfigParser: AsRef<str> {
/// Supplies the default value of the configuration.
fn default_value(&self) -> Option<Self::Output>;
+ /// The config key, such as `hoodie.table.name`.
fn key(&self) -> String {
self.as_ref().to_string()
}
@@ -136,10 +149,15 @@ pub trait ConfigParser: AsRef<str> {
/// All possible data types for Hudi Configuration values.
#[derive(Clone, Debug)]
pub enum HudiConfigValue {
+ /// A boolean value.
Boolean(bool),
+ /// A signed integer value.
Integer(isize),
+ /// An unsigned integer value.
UInteger(usize),
+ /// A string value.
String(String),
+ /// A comma-separated list, split into its items.
List(Vec<String>),
}
diff --git a/crates/core/src/config/plan.rs b/crates/core/src/config/plan.rs
index b2664eb9..d9746b88 100644
--- a/crates/core/src/config/plan.rs
+++ b/crates/core/src/config/plan.rs
@@ -54,6 +54,7 @@ pub enum HudiPlanConfig {
}
impl HudiPlanConfig {
+ /// The config key, usable in `const` contexts.
pub const fn key_str(&self) -> &'static str {
match self {
Self::ListingParallelism => "hoodie.plan.listing.parallelism",
diff --git a/crates/core/src/config/read.rs b/crates/core/src/config/read.rs
index bec51459..593aaed3 100644
--- a/crates/core/src/config/read.rs
+++ b/crates/core/src/config/read.rs
@@ -113,7 +113,7 @@ pub enum HudiReadConfig {
/// Number of input partitions to read the data in parallel.
///
- /// For processing 100 files, [InputPartitions] being 5 will produce 5
partitions, with each partition having 20 files.
+ /// For processing 100 files, [Self::InputPartitions] being 5 will produce
5 partitions, with each partition having 20 files.
///
/// On the parquet-source path (copy-on-write, or read-optimized
merge-on-read)
/// DataFusion may split these buckets further once one exceeds
diff --git a/crates/core/src/config/read_options.rs
b/crates/core/src/config/read_options.rs
index 2cec1214..6860c449 100644
--- a/crates/core/src/config/read_options.rs
+++ b/crates/core/src/config/read_options.rs
@@ -275,10 +275,6 @@ impl ReadOptions {
}
}
- /// The target batch size (rows per batch) for streaming reads, if set.
- /// Errors if the stored string is not a valid `usize` or if the value is
`0`
- /// (a zero-row batch yields no batches at the parquet stream reader and is
- /// almost certainly a caller mistake).
/// Fill in defaults from the given configs for keys not already set.
///
/// Values already present in this `ReadOptions` take precedence.
@@ -296,6 +292,10 @@ impl ReadOptions {
Ok(resolved)
}
+ /// The target batch size (rows per batch) for streaming reads, if set.
+ /// Errors if the stored string is not a valid `usize` or if the value is
`0`
+ /// (a zero-row batch yields no batches at the parquet stream reader and is
+ /// almost certainly a caller mistake).
pub fn batch_size(&self) -> crate::Result<Option<usize>> {
let key = HudiReadConfig::StreamBatchSize.as_ref();
match self.hudi_options.get(key) {
diff --git a/crates/core/src/config/table.rs b/crates/core/src/config/table.rs
index 5a69eab8..c67c19ad 100644
--- a/crates/core/src/config/table.rs
+++ b/crates/core/src/config/table.rs
@@ -359,10 +359,14 @@ impl ConfigParser for HudiTableConfig {
}
/// Config value for [HudiTableConfig::TableType].
+///
+/// Parsing is case-insensitive and also accepts `cow` / `mor` and hyphenated
forms.
#[derive(Clone, Debug, PartialEq, AsRefStr)]
pub enum TableTypeValue {
+ /// `COPY_ON_WRITE`: updates rewrite base files.
#[strum(serialize = "COPY_ON_WRITE")]
CopyOnWrite,
+ /// `MERGE_ON_READ`: updates go to log files, merged with base files when
read.
#[strum(serialize = "MERGE_ON_READ")]
MergeOnRead,
}
@@ -382,11 +386,13 @@ impl FromStr for TableTypeValue {
/// Config value for [HudiTableConfig::BaseFileFormat].
#[derive(Clone, Debug, PartialEq, AsRefStr)]
pub enum BaseFileFormatValue {
+ /// Config value `parquet`, the default.
#[strum(serialize = "parquet")]
Parquet,
/// HFile format - only valid for metadata tables.
#[strum(serialize = "hfile")]
HFile,
+ /// Config value `lance`.
#[strum(serialize = "lance")]
Lance,
}
@@ -471,9 +477,11 @@ impl FromStr for BaseFileFormatValue {
/// Config value for [HudiTableConfig::TimelineTimezone].
#[derive(Clone, Debug, PartialEq, AsRefStr, Default)]
pub enum TimelineTimezoneValue {
+ /// Instant times are in UTC, assumed when the config is unset.
#[strum(serialize = "utc")]
#[default]
UTC,
+ /// Instant times are in the local timezone of the process.
#[strum(serialize = "local")]
Local,
}
diff --git a/crates/core/src/error.rs b/crates/core/src/error.rs
index c49e7f3e..ffd39158 100644
--- a/crates/core/src/error.rs
+++ b/crates/core/src/error.rs
@@ -16,26 +16,35 @@
* specific language governing permissions and limitations
* under the License.
*/
+//! The error and result types returned by hudi-core APIs.
+
use crate::config::error::ConfigError;
use crate::storage::error::StorageError;
use thiserror::Error;
+/// Result type of hudi-core APIs, failing with [`CoreError`] by default.
pub type Result<T, E = CoreError> = std::result::Result<T, E>;
+/// Errors returned by hudi-core APIs.
#[derive(Error, Debug)]
pub enum CoreError {
+ /// An Arrow operation failed (schema mismatch, cast, or batch
construction).
#[error(transparent)]
ArrowError(#[from] arrow::error::ArrowError),
+ /// Avro encoding or decoding failed.
#[error(transparent)]
AvroError(#[from] apache_avro::Error),
+ /// A config is missing or has an invalid value.
#[error("Config error: {0}")]
Config(#[from] ConfigError),
+ /// Commit metadata on the timeline could not be parsed or lacks a
required field.
#[error("Commit metadata error: {0}")]
CommitMetadata(String),
+ /// Records could not be merged.
#[error("{0}")]
MergeRecordError(String),
@@ -43,60 +52,80 @@ pub enum CoreError {
#[error("{0}")]
MemoryLimitExceeded(String),
+ /// A schema or data type is invalid for the operation, such as a filter
on an unknown
+ /// column or a write whose columns do not match the table.
#[error("Data type error: {0}")]
Schema(String),
+ /// The table schema could not be resolved.
#[error("{0}")]
SchemaNotFound(String),
+ /// A file group, file slice, or data file name is invalid.
#[error("File group error: {0}")]
FileGroup(String),
+ /// Reading or merging a file slice failed.
#[error("{0}")]
ReadFileSliceError(String),
+ /// A log file does not follow the Hudi log format.
#[error("{0}")]
LogFormatError(String),
+ /// A log block could not be decoded.
#[error("{0}")]
LogBlockError(String),
+ /// Reading or writing an HFile failed.
#[error("HFile error: {0}")]
HFile(String),
+ /// Reading or writing the metadata table failed.
#[error("Metadata table error: {0}")]
MetadataTable(String),
+ /// A partition path does not match the table's partition fields.
#[error("{0}")]
InvalidPartitionPath(String),
+ /// An argument or stored value is invalid.
#[error("{0}")]
InvalidValue(String),
+ /// Reading or writing a Parquet file failed.
#[error(transparent)]
ParquetError(#[from] parquet::errors::ParquetError),
+ /// An I/O error, raised mostly while reading log files.
#[error("{0}")]
ReadLogFileError(#[from] std::io::Error),
+ /// An object store operation failed.
#[error("Storage error: {0}")]
Storage(#[from] StorageError),
+ /// The timeline could not be loaded or holds an invalid instant.
#[error("Timeline error: {0}")]
Timeline(String),
+ /// The timeline has no completed commit.
#[error("Timeline has no completed commit.")]
TimelineNoCommit,
+ /// A timestamp string could not be parsed.
#[error("{0}")]
TimestampParsingError(String),
+ /// The table, config, or operation is not supported.
#[error("{0}")]
Unsupported(String),
+ /// A write was rejected or failed.
#[error("Write error: {0}")]
Write(String),
+ /// Bytes expected to be UTF-8 were not.
#[error(transparent)]
Utf8Error(#[from] std::str::Utf8Error),
}
diff --git a/crates/core/src/expr/filter.rs b/crates/core/src/expr/filter.rs
index afbf3cd7..a2cdf903 100644
--- a/crates/core/src/expr/filter.rs
+++ b/crates/core/src/expr/filter.rs
@@ -17,6 +17,8 @@
* under the License.
*/
+//! The [`Filter`] type and helpers to build and evaluate filters.
+
use crate::Result;
use crate::error::CoreError;
use crate::expr::ExprOperator;
@@ -30,14 +32,28 @@ use arrow_schema::{DataType, Field, Schema};
use std::collections::HashSet;
use std::str::FromStr;
+/// A comparison of one column against one or more values, such as `city =
'sf'`.
+///
+/// Values are kept as strings and cast to the column's Arrow type when the
filter is evaluated.
+/// Build one with [`Filter::new`], from a `(field, operator, value)` tuple
with
+/// `Filter::try_from`, or with [`col`].
#[derive(Debug, Clone)]
pub struct Filter {
+ /// Column name.
pub field: String,
+ /// Comparison operator.
pub operator: ExprOperator,
+ /// Values to compare against: exactly one, or at least one for `IN` /
`NOT IN`.
pub values: Vec<String>,
}
impl Filter {
+ /// Creates a filter, checking the number of values against the operator.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`CoreError::Schema`] when `IN` / `NOT IN` gets no values, or
another
+ /// operator does not get exactly one.
pub fn new(field: String, operator: ExprOperator, values: Vec<String>) ->
Result<Self> {
if operator.is_multi_value() {
if values.is_empty() {
@@ -60,6 +76,7 @@ impl Filter {
}
impl Filter {
+ /// The filter with its operator negated (see [`ExprOperator::negate`]).
pub fn negate(&self) -> Option<Self> {
self.operator.negate().map(|op| Self {
operator: op,
@@ -156,6 +173,10 @@ fn escape_in_value(v: &str) -> String {
out
}
+/// Parses `(field, operator, value)` tuples into filters, as
`Filter::try_from` does.
+///
+/// For `IN` / `NOT IN` the value is a comma-separated list; `\,` is a literal
comma and `\\`
+/// a literal backslash.
pub fn from_str_tuples<I, S>(tuples: I) -> Result<Vec<Filter>>
where
I: IntoIterator<Item = (S, S, S)>,
@@ -167,7 +188,7 @@ where
.collect()
}
-/// Evaluate a slice of [`Filter`]s against a [`RecordBatch`] and return a
row-level mask
+/// Evaluate a slice of [`Filter`]s against a
[`RecordBatch`](arrow_array::RecordBatch) and return a row-level mask
/// where `true` indicates the row should be retained.
///
/// This is a low-level primitive: filters whose field is not present in the
batch are
@@ -224,19 +245,29 @@ where
Ok(())
}
+/// A column to build [`Filter`]s on; see [`col`].
+///
+/// The builders do not check the number of values, so an empty
+/// [`in_list`](Self::in_list) is not rejected here the way [`Filter::new`]
rejects it. Such a
+/// filter panics when evaluated, for example by [`filters_to_row_mask`]
during a read that has
+/// it in [`ReadOptions::filters`](crate::table::ReadOptions::filters).
pub struct FilterField {
+ /// Column name.
pub name: String,
}
impl FilterField {
+ /// Creates a field for the column `name`.
pub fn new(name: impl Into<String>) -> Self {
Self { name: name.into() }
}
+ /// The column name.
pub fn name(&self) -> &str {
&self.name
}
+ /// `field = value`
pub fn eq(&self, value: impl Into<String>) -> Filter {
Filter {
field: self.name.clone(),
@@ -245,6 +276,7 @@ impl FilterField {
}
}
+ /// `field != value`
pub fn ne(&self, value: impl Into<String>) -> Filter {
Filter {
field: self.name.clone(),
@@ -253,6 +285,7 @@ impl FilterField {
}
}
+ /// `field < value`
pub fn lt(&self, value: impl Into<String>) -> Filter {
Filter {
field: self.name.clone(),
@@ -261,6 +294,7 @@ impl FilterField {
}
}
+ /// `field <= value`
pub fn lte(&self, value: impl Into<String>) -> Filter {
Filter {
field: self.name.clone(),
@@ -269,6 +303,7 @@ impl FilterField {
}
}
+ /// `field > value`
pub fn gt(&self, value: impl Into<String>) -> Filter {
Filter {
field: self.name.clone(),
@@ -277,6 +312,7 @@ impl FilterField {
}
}
+ /// `field >= value`
pub fn gte(&self, value: impl Into<String>) -> Filter {
Filter {
field: self.name.clone(),
@@ -285,6 +321,7 @@ impl FilterField {
}
}
+ /// `field IN (values)`
pub fn in_list<I, S>(&self, values: I) -> Filter
where
I: IntoIterator<Item = S>,
@@ -297,6 +334,7 @@ impl FilterField {
}
}
+ /// `field NOT IN (values)`
pub fn not_in_list<I, S>(&self, values: I) -> Filter
where
I: IntoIterator<Item = S>,
@@ -310,14 +348,29 @@ impl FilterField {
}
}
+/// Starts a [`Filter`] on the column `name`.
+///
+/// ```
+/// use hudi_core::expr::filter::col;
+///
+/// let filter = col("city").in_list(["sf", "nyc"]);
+/// assert_eq!(filter.values, vec!["sf", "nyc"]);
+/// ```
pub fn col(name: impl Into<String>) -> FilterField {
FilterField::new(name)
}
+/// A [`Filter`] resolved against a schema, with its values cast to the
column's type.
+///
+/// Created with `SchemableFilter::try_from((filter, &schema))`, which fails
with
+/// [`CoreError::Schema`] when the column is missing or a value cannot be cast.
#[derive(Debug, Clone)]
pub struct SchemableFilter {
+ /// The schema field the filter applies to.
pub field: Field,
+ /// Comparison operator.
pub operator: ExprOperator,
+ /// Values cast to the field's data type.
pub values: Vec<Scalar<ArrayRef>>,
}
@@ -345,6 +398,8 @@ impl TryFrom<(Filter, &Schema)> for SchemableFilter {
}
impl SchemableFilter {
+ /// Casts a string value to `data_type`, failing with
[`CoreError::Schema`] rather than
+ /// producing a null when the value does not parse.
pub fn cast_value(value: &[&str; 1], data_type: &DataType) ->
Result<Scalar<ArrayRef>> {
let cast_options = CastOptions {
safe: false,
@@ -359,6 +414,12 @@ impl SchemableFilter {
))
}
+ /// Evaluates the filter against `value` (typically a column), returning
`true` for rows
+ /// that match.
+ ///
+ /// # Panics
+ ///
+ /// Panics if [`values`](Self::values) is empty.
pub fn apply_comparison(&self, value: &dyn Datum) -> Result<BooleanArray> {
match self.operator {
ExprOperator::Eq => eq(value, &self.values[0]),
diff --git a/crates/core/src/expr/mod.rs b/crates/core/src/expr/mod.rs
index cc7c84ca..6b596201 100644
--- a/crates/core/src/expr/mod.rs
+++ b/crates/core/src/expr/mod.rs
@@ -17,6 +17,11 @@
* under the License.
*/
+//! Column filter expressions for reads and writes.
+//!
+//! A [`filter::Filter`] compares one column against string values with an
[`ExprOperator`].
+//! Values are cast to the column's Arrow type when the filter is evaluated.
+
pub mod filter;
use crate::error::CoreError;
@@ -26,16 +31,24 @@ use std::cmp::PartialEq;
use std::fmt::{Display, Formatter, Result as FmtResult};
use std::str::FromStr;
-/// An operator that represents a comparison operation used in a partition
filter expression.
+/// A comparison operator in a [`filter::Filter`].
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ExprOperator {
+ /// `=`
Eq,
+ /// `!=`
Ne,
+ /// `<`
Lt,
+ /// `<=`
Lte,
+ /// `>`
Gt,
+ /// `>=`
Gte,
+ /// `IN`: equal to any of the values.
In,
+ /// `NOT IN`: equal to none of the values.
NotIn,
}
@@ -56,6 +69,7 @@ impl Display for ExprOperator {
}
impl ExprOperator {
+ /// The token for each operator, as accepted by [`FromStr`]
(case-insensitive).
pub const TOKEN_OP_PAIRS: [(&'static str, ExprOperator); 8] = [
("=", ExprOperator::Eq),
("!=", ExprOperator::Ne),
@@ -72,7 +86,7 @@ impl ExprOperator {
matches!(self, ExprOperator::In | ExprOperator::NotIn)
}
- /// Negates the operator.
+ /// The logical negation, such as `<` for `>=`. Always `Some` for the
current operators.
pub fn negate(&self) -> Option<ExprOperator> {
match self {
ExprOperator::Eq => Some(ExprOperator::Ne),
diff --git a/crates/core/src/file_group/base_file/mod.rs
b/crates/core/src/file_group/base_file/mod.rs
index e72619d3..f08b710a 100644
--- a/crates/core/src/file_group/base_file/mod.rs
+++ b/crates/core/src/file_group/base_file/mod.rs
@@ -30,10 +30,10 @@ use crate::timeline::completion_time::CompletionTimeView;
use std::fmt::Display;
use std::str::FromStr;
-/// Hudi Base file, part of a [FileSlice].
+/// Hudi Base file, part of a
[FileSlice](crate::file_group::file_slice::FileSlice).
#[derive(Clone, Debug)]
pub struct BaseFile {
- /// The id of the enclosing [FileGroup].
+ /// The id of the enclosing [FileGroup](crate::file_group::FileGroup).
pub file_id: String,
/// Monotonically increasing token for every attempt to write the
[BaseFile].
diff --git a/crates/core/src/file_group/file_slice.rs
b/crates/core/src/file_group/file_slice.rs
index 5e8088d5..36fc8b1f 100644
--- a/crates/core/src/file_group/file_slice.rs
+++ b/crates/core/src/file_group/file_slice.rs
@@ -16,6 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
+//! [`FileSlice`], the unit a file group reader reads.
+
use crate::Result;
use crate::error::CoreError;
use crate::file_group::base_file::BaseFile;
@@ -43,7 +45,9 @@ pub struct FileSlice {
/// The instant that created this slice: its base file's commit, or — with
/// no base file — the earliest log file's own instant.
creation_instant_time: String,
+ /// The log files that apply on top of the base file, in [`LogFile`] order.
pub log_files: BTreeSet<LogFile>,
+ /// The partition path relative to the table base, empty for an
unpartitioned table.
pub partition_path: String,
/// Column statistics from the base file's Parquet footer.
///
@@ -74,6 +78,8 @@ impl PartialEq for FileSlice {
impl Eq for FileSlice {}
impl FileSlice {
+ /// A slice with `base_file` and no log files, taking its file id and
creation instant from
+ /// the base file.
pub fn new(base_file: BaseFile, partition_path: String) -> Self {
Self {
file_id: base_file.file_id.clone(),
@@ -101,11 +107,18 @@ impl FileSlice {
}
}
+ /// Whether the slice has any log files.
#[inline]
pub fn has_log_file(&self) -> bool {
!self.log_files.is_empty()
}
+ /// Adds the log files of `other`, which must be the same slice.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`CoreError::FileGroup`] when `other` differs in base file,
partition path, file
+ /// id, or creation instant.
pub fn merge(&mut self, other: &FileSlice) -> Result<()> {
if self != other {
return Err(CoreError::FileGroup(format!(
@@ -139,7 +152,7 @@ impl FileSlice {
self.relative_path_for_file(file_name)
}
- /// Returns the enclosing [FileGroup]'s id.
+ /// Returns the enclosing [FileGroup](crate::file_group::FileGroup)'s id.
#[inline]
pub fn file_id(&self) -> &str {
&self.file_id
@@ -147,7 +160,7 @@ impl FileSlice {
/// Returns the instant time that marks the [FileSlice] creation.
///
- /// This is also an instant time stored in the [Timeline].
+ /// This is also an instant time stored in the
[Timeline](crate::timeline::Timeline).
#[inline]
pub fn creation_instant_time(&self) -> &str {
&self.creation_instant_time
@@ -157,7 +170,7 @@ impl FileSlice {
///
/// Use this for I/O cost estimation and split sizing (e.g., balancing
/// parallel read splits by storage volume). For estimated in-memory size
- /// and record count for query planning, use [`Table::compute_table_stats`]
+ /// and record count for query planning, use
[`Table::compute_table_stats`](crate::table::Table::compute_table_stats)
/// instead — it returns values derived from the base file only.
///
/// Files whose `file_metadata` is `None` contribute 0 to the sum.
diff --git a/crates/core/src/file_group/log_file/reader.rs
b/crates/core/src/file_group/log_file/reader.rs
index 0d09ba6c..e4c36d4e 100644
--- a/crates/core/src/file_group/log_file/reader.rs
+++ b/crates/core/src/file_group/log_file/reader.rs
@@ -191,7 +191,7 @@ impl LogFileReader {
/// Push a predicate into this file's parquet log blocks.
///
/// Left unset, no predicate is pushed and every row is read. Whether
pushing
- /// is sound is the caller's decision — see [`Decoder::with_row_filter`].
+ /// is sound is the caller's decision (see `Decoder::with_row_filter`).
pub fn with_row_filter(mut self, row_filter: Option<RowFilterBuilder>) ->
Self {
self.row_filter = row_filter;
self
@@ -207,7 +207,7 @@ impl LogFileReader {
}
/// Resolve Avro blocks up to this schema. See
- /// [`Decoder::with_reader_schema`].
+ /// `Decoder::with_reader_schema`.
pub fn with_reader_schema(mut self, reader_schema_json: Option<String>) ->
Self {
self.reader_schema_json = reader_schema_json;
self
diff --git a/crates/core/src/file_group/mod.rs
b/crates/core/src/file_group/mod.rs
index b2ca4402..388a27af 100644
--- a/crates/core/src/file_group/mod.rs
+++ b/crates/core/src/file_group/mod.rs
@@ -23,9 +23,11 @@
pub mod admission;
pub mod base_file;
pub mod builder;
+#[warn(missing_docs)]
pub mod file_slice;
pub mod log_file;
pub(crate) mod pushdown;
+#[warn(missing_docs)]
pub mod reader;
pub(crate) mod reader_v2;
pub mod record_batches;
diff --git a/crates/core/src/file_group/reader.rs
b/crates/core/src/file_group/reader.rs
index e2996e28..ac6a2cf6 100644
--- a/crates/core/src/file_group/reader.rs
+++ b/crates/core/src/file_group/reader.rs
@@ -16,6 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
+//! [`FileGroupReader`] reads and merges the records of one file slice.
+
use crate::Result;
use crate::config::HudiConfigs;
use crate::config::internal::HudiInternalConfig;
@@ -120,14 +122,6 @@ impl FileGroupReader {
})
}
- /// Creates a new reader with the given base URI and options.
- ///
- /// # Arguments
- /// * `base_uri` - The base URI of the file group's residing table.
- /// * `options` - Additional options for the reader.
- ///
- /// # Notes
- /// This API uses [`OptionResolver`] that loads table properties from
storage to resolve options.
/// Refuse a scan-wide memory budget on a reader that reads one file slice.
///
/// `hoodie.read.scan.max.memory.size` bounds a scan by deciding how many
slices
@@ -155,6 +149,24 @@ impl FileGroupReader {
Ok(())
}
+ /// Creates a new reader with the given base URI and options.
+ ///
+ /// # Arguments
+ /// * `base_uri` - The base URI of the file group's residing table.
+ /// * `options` - Additional options for the reader.
+ ///
+ /// # Notes
+ /// This API uses [`OptionResolver`] that loads table properties from
storage to resolve options.
+ ///
+ /// A reader built this way has no timeline, so it reads with the base
file's schema and does
+ /// not check whether a log block's instant has committed. Prefer
+ ///
[`Table::create_file_group_reader_with_options`](crate::table::Table::create_file_group_reader_with_options)
+ /// when a [`Table`](crate::table::Table) is at hand.
+ ///
+ /// # Errors
+ ///
+ /// Fails when the table properties cannot be loaded, and with
[`CoreError::InvalidValue`] when
+ /// `hoodie.read.scan.max.memory.size` is set, since this reader reads one
slice per call.
pub async fn new_with_options<I, K, V>(base_uri: &str, options: I) ->
Result<Self>
where
I: IntoIterator<Item = (K, V)>,
diff --git a/crates/core/src/hfile/reader.rs b/crates/core/src/hfile/reader.rs
index 2cdc412b..219365e9 100644
--- a/crates/core/src/hfile/reader.rs
+++ b/crates/core/src/hfile/reader.rs
@@ -294,7 +294,7 @@ impl HFileReader {
/// Open an HFile without holding it.
///
/// Two ranged reads before any data is touched: the trailer, which is the
- /// final [`TRAILER_SIZE`] bytes, then the load-on-open section it points
at,
+ /// final `TRAILER_SIZE` bytes, then the load-on-open section it points at,
/// which carries the index and the file info. Data blocks are read later,
/// through [`Self::read_records_batched`].
pub async fn open_ranged(storage: &Storage, relative_path: &str) ->
Result<Self> {
@@ -1480,7 +1480,7 @@ impl HFileReader {
/// Keys must be sorted in ascending order. This method efficiently
/// scans forward through the file to find matching keys.
///
- /// Returns a vector of (key, Option<HFileRecord>) tuples where
+ /// Returns a vector of `(key, Option<HFileRecord>)` tuples where
/// the Option is Some if the key was found.
pub fn lookup_records(&mut self, keys: &[&str]) -> Result<Vec<(String,
Option<HFileRecord>)>> {
let mut results = Vec::with_capacity(keys.len());
diff --git a/crates/core/src/index/mod.rs b/crates/core/src/index/mod.rs
index 806e8321..e9534e5a 100644
--- a/crates/core/src/index/mod.rs
+++ b/crates/core/src/index/mod.rs
@@ -33,7 +33,14 @@ use crate::table::Table;
/// A record key and its partition path.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct HoodieKey {
+ /// The record key as stored, such as `a:1,b:x` for a composite key.
pub record_key: String,
+ /// The partition path relative to the table base, such as `city=sf`;
empty for an
+ /// unpartitioned table.
+ ///
+ /// [`Table::delete_keys`](crate::table::Table::delete_keys) locates keys
by record key. On a
+ /// copy-on-write table, an empty `partition_path` there deletes the key
in any partition and a
+ /// non-empty one only in that partition.
pub partition_path: String,
}
diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs
index 72e6b4f5..9055e32d 100644
--- a/crates/core/src/lib.rs
+++ b/crates/core/src/lib.rs
@@ -16,34 +16,69 @@
* specific language governing permissions and limitations
* under the License.
*/
-//! Crate `hudi-core`.
+//! Core library of hudi-rs, a native Rust implementation of [Apache
Hudi](https://hudi.apache.org).
//!
-//! # The [config] module is responsible for managing configurations.
+//! Most applications depend on the [`hudi`](https://docs.rs/hudi) crate,
which re-exports this one;
+//! the [Python and Rust quick start guide][quick start guide] walks through
it, and the `hudi`
+//! crate docs list every resource (API references, quick starts, examples,
specs). The main entry
+//! points:
//!
-//! **Example**
+//! - [`table::Table`] opens a table ([`Table::new`](table::Table::new),
+//! [`TableBuilder`](table::builder::TableBuilder)) or creates one
+//! ([`Table::create`](table::Table::create)), and carries both the read API
(`read`,
+//! `read_stream`, `get_file_slices`) and the write API (`append`, `upsert`,
`delete`, `update`,
+//! `overwrite`, and the experimental `compact` for merge-on-read tables).
+//! - [`table::ReadOptions`] selects the query type ([`table::QueryType`]),
timestamps, filters,
+//! and projection for every read.
+//! - [`write`](mod@write) holds the types the write API takes and returns,
such as [`WriteResult`] and
+//! [`UpsertOptions`].
+//! - [`file_group::reader::FileGroupReader`] reads one file slice at a time,
for query engines
+//! that plan their own scans with
[`Table::get_file_slices`](table::Table::get_file_slices).
+//! - [`config`] defines the typed table and read configs, such as
+//! [`HudiTableConfig`](config::table::HudiTableConfig) and
+//! [`HudiReadConfig`](config::read::HudiReadConfig).
//!
-//! ```rust
-//! use hudi_core::config::read::HudiReadConfig::InputPartitions;
-//! use hudi_core::table::Table as HudiTable;
+//! Most fallible APIs return [`error::Result`], whose error type is
[`error::CoreError`]; config
+//! parsing returns [`config::error::Result`].
//!
-//! # #[tokio::main]
-//! # async fn main() {
-//! let options = [(InputPartitions, "2")];
-//! HudiTable::new_with_options("/tmp/hudi_data", options).await;
-//! # }
-//! ```
+//! [quick start guide]:
https://hudi.apache.org/docs/python-rust-quick-start-guide
//!
-//! # The [table] module is responsible for managing Hudi tables.
+//! # Example
//!
-//! **Example**
+//! Plan a snapshot read as file slices and read each slice with a file group
reader, the way a
+//! query engine distributes a scan.
//!
-//! create hudi table
-//! ```rust
-//! use hudi_core::table::Table;
+//! ```
+//! # use std::sync::Arc;
+//! # use arrow::array::{ArrayRef, RecordBatch, StringArray};
+//! # use arrow::datatypes::{DataType, Field, Schema};
+//! use hudi_core::config::util::empty_options;
+//! use hudi_core::table::{ReadOptions, Table};
//!
-//! pub async fn test() {
-//! let hudi_table = Table::new("/tmp/hudi_data").await.unwrap();
+//! # #[tokio::main]
+//! # async fn main() -> hudi_core::error::Result<()> {
+//! # let dir = tempfile::tempdir()?;
+//! # let base_uri = dir.path().to_str().unwrap();
+//! # let mut writer = Table::create(base_uri)
+//! # .with_table_name("docs")
+//! # .with_record_key_fields(["id"])
+//! # .create()
+//! # .await?;
+//! # let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Utf8,
false)]));
+//! # let ids: ArrayRef = Arc::new(StringArray::from(vec!["a", "b"]));
+//! # writer.append([RecordBatch::try_new(schema, vec![ids])?]).await?;
+//! let table = Table::new(base_uri).await?;
+//! let options = ReadOptions::new();
+//! let reader = table
+//! .create_file_group_reader_with_options(Some(&options), empty_options())
+//! .await?;
+//! let mut num_rows = 0;
+//! for slice in table.get_file_slices(&options).await? {
+//! num_rows += reader.read_file_slice(&slice, &options).await?.num_rows();
//! }
+//! assert_eq!(num_rows, 2);
+//! # Ok(())
+//! # }
//! ```
// The write-path async fns (e.g. `Table::delete_keys`) nest deeply enough
that computing their
@@ -52,8 +87,11 @@
mod avro_to_arrow;
mod bloom;
+#[warn(missing_docs)]
pub mod config;
+#[warn(missing_docs)]
pub mod error;
+#[warn(missing_docs)]
pub mod expr;
pub mod file_group;
pub mod hfile;
@@ -65,9 +103,11 @@ mod record;
pub mod schema;
pub mod statistics;
pub mod storage;
+#[warn(missing_docs)]
pub mod table;
pub mod timeline;
pub mod util;
+#[warn(missing_docs)]
pub mod write;
pub use index::{HoodieKey, SimpleIndex};
diff --git a/crates/core/src/metadata/table/column_stats.rs
b/crates/core/src/metadata/table/column_stats.rs
index 494bb1db..458c950b 100644
--- a/crates/core/src/metadata/table/column_stats.rs
+++ b/crates/core/src/metadata/table/column_stats.rs
@@ -199,7 +199,7 @@ pub fn column_stats_for_file(
/// Aggregate file-level ranges into partition-level stats (same column name).
///
/// Ranges must already be expressed in their column's type (see
-/// [`coerce_stat_value`]); values of different types fail the aggregation
+/// `coerce_stat_value`); values of different types fail the aggregation
/// rather than producing a bound ordered by type instead of by value.
pub fn aggregate_partition_stats(
partition_path: &str,
diff --git a/crates/core/src/metadata/table/hash.rs
b/crates/core/src/metadata/table/hash.rs
index d6590ba2..3acbff25 100644
--- a/crates/core/src/metadata/table/hash.rs
+++ b/crates/core/src/metadata/table/hash.rs
@@ -36,7 +36,7 @@ pub fn partition_identifier(partition_path: &str) -> &str {
}
}
-/// XXHash64 (seed [`HASH_SEED`]) as big-endian bytes, Java `HashID` BITS_64.
+/// XXHash64 (seed `HASH_SEED`) as big-endian bytes, Java `HashID` BITS_64.
pub fn xxhash64_bytes(message: &str) -> [u8; 8] {
xxh64(message.as_bytes(), HASH_SEED).to_be_bytes()
}
diff --git a/crates/core/src/metadata/table/mod.rs
b/crates/core/src/metadata/table/mod.rs
index 7d2ad912..8a563a1a 100644
--- a/crates/core/src/metadata/table/mod.rs
+++ b/crates/core/src/metadata/table/mod.rs
@@ -170,7 +170,7 @@ impl Table {
///
/// Records are returned with normalized partition keys. For
non-partitioned tables,
/// the key is "" (empty string) instead of the internal "."
representation.
- /// Normalization happens at decode time in
[`decode_files_partition_record_with_schema`].
+ /// Normalization happens at decode time in
`decode_files_partition_record_with_schema`.
///
/// # Note
/// Must be called on a DATA table, not a METADATA table.
diff --git a/crates/core/src/schema/avsc.rs b/crates/core/src/schema/avsc.rs
index e695aa35..aa801417 100644
--- a/crates/core/src/schema/avsc.rs
+++ b/crates/core/src/schema/avsc.rs
@@ -63,7 +63,7 @@ fn parse_vendored(avsc: &str, name: &str) -> Result<Schema> {
/// Serialize `value` to Avro OCF using a Java writer schema.
///
-/// Goes JSON → untyped Avro [`Value`] → [`Value::resolve`] so union indexes
and
+/// Goes JSON → untyped Avro `Value` → `Value::resolve` so union indexes and
/// field order match the schema (unlike `to_value`/`append_ser`, which assume
/// null-first Option unions and Serialize field order).
pub fn encode_with_schema<T: serde::Serialize>(value: &T, schema: &Schema) ->
Result<Vec<u8>> {
diff --git a/crates/core/src/schema/batch_evolution.rs
b/crates/core/src/schema/batch_evolution.rs
index 0bd8978a..c6d4bd26 100644
--- a/crates/core/src/schema/batch_evolution.rs
+++ b/crates/core/src/schema/batch_evolution.rs
@@ -232,7 +232,7 @@ fn is_repair_target(table: &DataType) -> bool {
/// judged from the TABLE schema alone.
///
/// Meant to be computed ONCE per scan by whoever supplies the predicate, and
handed
-/// to the reader via
[`HoodieFileGroupReaderBuilder::with_repair_risk_columns`]
+/// to the reader via `HoodieFileGroupReaderBuilder::with_repair_risk_columns`
/// (crate::file_group::reader_v2). Two properties earn it that place:
///
/// * **It is usually empty.** Spark's `TimestampType` maps to micros, so a
diff --git a/crates/core/src/schema/extended_promotion.rs
b/crates/core/src/schema/extended_promotion.rs
index 052956bb..c12def24 100644
--- a/crates/core/src/schema/extended_promotion.rs
+++ b/crates/core/src/schema/extended_promotion.rs
@@ -44,7 +44,7 @@
//!
//! ## Union handling — keyed on the reader, throws on malformed
//! Like Java, the UNION case is keyed on the READER type (Java line 1482):
only when
-//! the reader is a union are both sides unwrapped via
[`actual_schema_from_union`]
+//! the reader is a union are both sides unwrapped via
`actual_schema_from_union`
//! (Java's `getActualSchemaFromUnion(.., null)`). A writer union feeding a
*plain*
//! reader is therefore NOT unwrapped — `physical_type` reports it as `UNION`,
so a
//! `[null,int]` writer → plain `long` reader rewrites, matching Java's
@@ -237,7 +237,7 @@ fn needs_rewrite_to_string(writer: &Schema, reader_is_enum:
bool) -> bool {
/// See module docs. Port of Java's
`recordNeedsRewriteForExtendedAvroTypePromotion`
/// (`HoodieAvroUtils.java:1451-1494`).
///
-/// Returns `Err` for a malformed union (see [`actual_schema_from_union`]),
exactly
+/// Returns `Err` for a malformed union (see `actual_schema_from_union`),
exactly
/// where Java throws `HoodieAvroSchemaException`.
pub fn record_needs_rewrite_for_extended_promotion(
writer: &Schema,
diff --git a/crates/core/src/schema/resolver.rs
b/crates/core/src/schema/resolver.rs
index 852e2d7d..6f190177 100644
--- a/crates/core/src/schema/resolver.rs
+++ b/crates/core/src/schema/resolver.rs
@@ -61,7 +61,7 @@ pub async fn resolve_schema(table: &Table) -> Result<Schema> {
/// ### Note
///
/// - For resolving Avro schema, we don't read the schema from a base file
like we do when resolving Arrow schema.
-/// - Avro schema does not contain [`MetaField`]s.
+/// - Avro schema does not contain
[`MetaField`](crate::metadata::meta_field::MetaField)s.
pub async fn resolve_avro_schema(table: &Table) -> Result<String> {
match table.get_timeline().get_latest_avro_schema().await {
Ok(schema) => Ok(schema),
diff --git a/crates/core/src/storage/file_metadata.rs
b/crates/core/src/storage/file_metadata.rs
index ae1e453f..a83bdcfd 100644
--- a/crates/core/src/storage/file_metadata.rs
+++ b/crates/core/src/storage/file_metadata.rs
@@ -26,12 +26,12 @@ pub struct FileMetadata {
pub size: u64,
/// Uncompressed size in bytes in memory.
- /// Currently populated using
[crate::statistics::estimator::FileStatsEstimator]
+ /// Currently populated using `FileStatsEstimator`
/// when an estimator is available; otherwise stays 0.
pub byte_size: i64,
/// Number of records in the file.
- /// Currently populated using
[crate::statistics::estimator::FileStatsEstimator]
+ /// Currently populated using `FileStatsEstimator`
/// when an estimator is available; otherwise stays 0.
pub num_records: i64,
}
diff --git a/crates/core/src/table/builder.rs b/crates/core/src/table/builder.rs
index a798ec64..1c5effe9 100644
--- a/crates/core/src/table/builder.rs
+++ b/crates/core/src/table/builder.rs
@@ -17,6 +17,8 @@
* under the License.
*/
+//! [`TableBuilder`] opens a [`Table`] with options.
+
use paste::paste;
use std::collections::HashMap;
use std::path::PathBuf;
@@ -35,7 +37,11 @@ use crate::table::validation::validate_configs;
use crate::timeline::Timeline;
use crate::util::collection::extend_if_absent;
-/// Builder for creating a [Table] instance.
+/// Builder that opens an existing [`Table`] with options.
+///
+/// `with_hudi_option(s)` set Hudi configs, `with_storage_option(s)` set
storage options, and
+/// `with_option(s)` take either, routing keys that start with `hoodie.` to
Hudi configs.
+/// [`OptionResolver::resolve_options`] describes how these combine with the
table's own configs.
#[derive(Debug, Clone)]
pub struct TableBuilder {
option_resolver: OptionResolver,
@@ -44,9 +50,13 @@ pub struct TableBuilder {
/// Resolver for options including Hudi options, storage options, and generic
options.
#[derive(Debug, Clone)]
pub struct OptionResolver {
+ /// The table's base URI.
pub base_uri: String,
+ /// Hudi configs (`hoodie.*`).
pub hudi_options: HashMap<String, String>,
+ /// Storage options, such as cloud credentials.
pub storage_options: HashMap<String, String>,
+ /// Options not yet split into Hudi configs and storage options.
pub options: HashMap<String, String>,
}
@@ -102,6 +112,15 @@ impl TableBuilder {
TableBuilder { option_resolver }
}
+ /// Resolves the options, validates the table configs, and loads the
timeline.
+ ///
+ /// Read configs that select which read to perform (query type and
timestamps) are dropped
+ /// with a warning; pass them in
[`ReadOptions`](crate::table::ReadOptions) instead.
+ ///
+ /// # Errors
+ ///
+ /// Fails, among other cases, when `.hoodie/hoodie.properties` cannot be
read, or the table
+ /// configs are invalid or unsupported (for example a table version other
than 6, 8, or 9).
pub async fn build(&mut self) -> Result<Table> {
let option_resolver = &mut self.option_resolver;
@@ -238,7 +257,7 @@ impl OptionResolver {
/// - Cloud storage options specified via env vars
/// 4. Global Hudi configs
///
- /// [note] Error may occur when 1 and 2 have conflicts.
+ /// Note: an error may occur when 1 and 2 have conflicts.
pub async fn resolve_options(&mut self) -> Result<()> {
self.resolve_user_provided_options();
diff --git a/crates/core/src/table/mod.rs b/crates/core/src/table/mod.rs
index cd61dcfd..a6cbf845 100644
--- a/crates/core/src/table/mod.rs
+++ b/crates/core/src/table/mod.rs
@@ -16,77 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
-//! This module is responsible for Hudi table APIs.
+//! The [`Table`] API for reading and writing a Hudi table.
//!
-//! It provides a quick entry point for reading Hudi table metadata and data,
-//! facilitating adaptation and compatibility across various engines.
+//! Open an existing table with [`Table::new`] or [`builder::TableBuilder`],
or create one with
+//! [`Table::create`]. Reads take [`ReadOptions`]; [`Table::read`] and
[`Table::read_stream`] return
+//! records, while [`Table::get_file_slices`] and
[`Table::create_file_group_reader_with_options`]
+//! let a query engine plan and distribute the scan itself. A write
([`Table::append`],
+//! [`Table::upsert`], [`Table::delete`], [`Table::update`],
[`Table::overwrite`]) that changes
+//! data creates one commit and refreshes the handle so later reads see it. On
a merge-on-read
+//! table, [`Table::compact`] folds log files into new base files.
//!
-//! **Example**
-//! 1. create hudi table
-//! ```rust
-//! use url::Url;
-//! use hudi_core::table::Table;
-//!
-//! pub async fn test() {
-//! let base_uri = Url::from_file_path("/tmp/hudi_data").unwrap();
-//! let hudi_table = Table::new(base_uri.path()).await.unwrap();
-//! }
-//! ```
-//! 2. get hudi table schema(arrow_schema::Schema)
-//! ```rust
-//! use url::Url;
-//! use hudi_core::table::Table;
-//!
-//! pub async fn test() {
-//! use arrow_schema::Schema;
-//! let base_uri = Url::from_file_path("/tmp/hudi_data").unwrap();
-//! let hudi_table = Table::new(base_uri.path()).await.unwrap();
-//! let schema = hudi_table.get_schema().await.unwrap();
-//! }
-//! ```
-//! 3. read hudi table
-//! ```rust
-//! use url::Url;
-//! use hudi_core::table::{ReadOptions, Table};
-//!
-//! pub async fn test() {
-//! let base_uri = Url::from_file_path("/tmp/hudi_data").unwrap();
-//! let hudi_table = Table::new(base_uri.path()).await.unwrap();
-//! let record_read = hudi_table.read(&ReadOptions::new()).await.unwrap();
-//! }
-//! ```
-//! 4. get file slice
-//! Users can obtain metadata to customize reading methods, read in
batches, perform parallel reads, and more.
-//! ```rust
-//! use url::Url;
-//! use hudi_core::table::{ReadOptions, Table};
-//! use hudi_core::storage::util::parse_uri;
-//! use hudi_core::storage::util::join_url_segments;
-//!
-//! pub async fn test() {
-//! let base_uri = Url::from_file_path("/tmp/hudi_data").unwrap();
-//! let hudi_table = Table::new(base_uri.path()).await.unwrap();
-//! let flat_slices = hudi_table
-//! .get_file_slices(&ReadOptions::new())
-//! .await.unwrap();
-//! let file_slices =
hudi_core::util::collection::split_into_chunks(flat_slices, 2);
-//! // define every parquet task reader how many slice
-//! let mut parquet_file_groups: Vec<Vec<String>> = Vec::new();
-//! for file_slice_vec in file_slices {
-//! let file_group_vec = file_slice_vec
-//! .iter()
-//! .filter_map(|f| {
-//! // None when the slice's records live entirely in log
-//! // files, so there is no base file to read.
-//! let relative_path =
f.base_file_relative_path().unwrap()?;
-//! let url = join_url_segments(&base_uri,
&[relative_path.as_str()]).unwrap();
-//! Some(url.path().to_string())
-//! })
-//! .collect();
-//! parquet_file_groups.push(file_group_vec)
-//! }
-//! }
-//! ```
+//! See the crate-level docs for examples.
pub mod builder;
pub mod file_pruner;
@@ -134,12 +74,19 @@ use std::sync::Arc;
use tokio::sync::OnceCell;
use url::Url;
-/// The main struct that provides table APIs for interacting with a Hudi table.
+/// A handle to a Hudi table, with the read and write APIs.
+///
+/// Clones share configs and caches. Write methods take `&mut self` and refresh
+/// that handle's timeline; reopen or rebuild other handles to see the new
commit.
#[derive(Debug)]
pub struct Table {
+ /// Resolved Hudi configs: user options, `hoodie.properties`, and
`hudi-defaults.conf`.
pub hudi_configs: Arc<HudiConfigs>,
+ /// Options for the storage backend, such as cloud credentials.
pub storage_options: Arc<HashMap<String, String>>,
+ /// The table's timeline of commits.
pub timeline: Timeline,
+ /// Listing of the table's file groups and file slices.
pub file_system_view: FileSystemView,
/// Cached metadata table instance, lazily initialized on first use.
/// Shared across clones via `Arc`; [`Table::clear_read_caches`] gives this
@@ -354,12 +301,19 @@ impl Table {
None
}
- /// Create hudi table by base_uri
+ /// Opens the table at `base_uri`, a local path or an object store URI
(`s3://`, `az://`,
+ /// `gs://`, `file://`).
+ ///
+ /// Equivalent to `TableBuilder::from_base_uri(base_uri).build()`; see
+ /// [`builder::TableBuilder`] for how options are resolved.
pub async fn new(base_uri: &str) -> Result<Self> {
TableBuilder::from_base_uri(base_uri).build().await
}
- /// Create hudi table with options
+ /// Opens the table at `base_uri` with extra options.
+ ///
+ /// Keys starting with `hoodie.` are Hudi configs; all others are storage
options, such as
+ /// `aws_region`.
pub async fn new_with_options<I, K, V>(base_uri: &str, options: I) ->
Result<Self>
where
I: IntoIterator<Item = (K, V)>,
@@ -375,14 +329,18 @@ impl Table {
/// Start creating a new Hudi table at `base_uri` (does not open until
[`TableCreateBuilder::create`]).
///
/// # Example
- /// ```no_run
- /// # async fn example() -> hudi_core::error::Result<()> {
+ /// ```
+ /// # #[tokio::main]
+ /// # async fn main() -> hudi_core::error::Result<()> {
+ /// # let dir = tempfile::tempdir()?;
+ /// # let base_uri = dir.path().to_str().unwrap();
/// use hudi_core::table::Table;
- /// let table = Table::create("/tmp/hudi_table")
+ /// let table = Table::create(base_uri)
/// .with_table_name("trips")
/// .with_record_key_fields(["id"])
/// .create()
/// .await?;
+ /// assert_eq!(table.table_name(), "trips");
/// # Ok(())
/// # }
/// ```
@@ -412,7 +370,12 @@ impl Table {
crate::write::upsert_batches(self, &batches, options).await
}
- /// Replace all data in a copy-on-write table.
+ /// Replaces all data in the table with `batches`
(`INSERT_OVERWRITE_TABLE`), as one
+ /// replacecommit.
+ ///
+ /// # Errors
+ ///
+ /// Fails, among other cases, when `batches` is empty or does not match
the table schema.
pub async fn overwrite(
&mut self,
batches: impl IntoIterator<Item = RecordBatch>,
@@ -421,7 +384,13 @@ impl Table {
crate::write::overwrite_batches(self, &batches).await
}
- /// Replace only the partitions present in `batches` in a partitioned
copy-on-write table.
+ /// Replaces only the partitions present in `batches`
(`INSERT_OVERWRITE`), leaving the others
+ /// untouched, as one replacecommit.
+ ///
+ /// # Errors
+ ///
+ /// Fails, among other cases, with [`CoreError::Unsupported`] on an
unpartitioned table, and
+ /// when `batches` is empty or does not match the table schema.
pub async fn dynamic_partition_overwrite(
&mut self,
batches: impl IntoIterator<Item = RecordBatch>,
@@ -455,9 +424,9 @@ impl Table {
crate::write::delete_keys(self, &keys).await
}
- /// Compact a merge-on-read table: every file group whose latest file
- /// slice carries log files gets a new base file holding its merged rows,
- /// under one compaction instant that completes as a `commit`.
+ /// Compact a merge-on-read table (experimental): every file group whose
+ /// latest file slice carries log files gets a new base file holding its
+ /// merged rows, under one compaction instant that completes as a `commit`.
///
/// Rows keep their `_hoodie_commit_time`, so incremental reads see no new
/// rows from a compaction. Returns a result whose `requested_time` is
@@ -471,8 +440,8 @@ impl Table {
///
/// # Errors
///
- /// Returns
[`CoreError::Unsupported`](crate::error::CoreError::Unsupported)
- /// on a copy-on-write table, which has no log files to compact.
+ /// Returns [`CoreError::Unsupported`] on a copy-on-write table, which has
no log files to
+ /// compact.
pub async fn compact(&mut self) -> Result<CompactionResult> {
crate::write::compact_table(self).await
}
@@ -502,14 +471,18 @@ impl Table {
crate::write::update_filter(self, filter, updates).await
}
+ /// A copy of the resolved Hudi configs as key-value pairs.
pub fn hudi_options(&self) -> HashMap<String, String> {
self.hudi_configs.as_options()
}
+ /// A copy of the storage options as key-value pairs.
pub fn storage_options(&self) -> HashMap<String, String> {
self.storage_options.as_ref().clone()
}
+ /// Registers the table's object stores with a DataFusion runtime, so
DataFusion can read the
+ /// table's files.
#[cfg(feature = "datafusion")]
pub fn register_storage(
&self,
@@ -523,6 +496,12 @@ impl Table {
.register_object_store(runtime_env.clone());
}
+ /// The table's base path as a URL.
+ ///
+ /// # Panics
+ ///
+ /// Panics if `hoodie.base.path` is missing or not a valid URI, which a
table opened through
+ /// [`builder::TableBuilder`] always has.
pub fn base_url(&self) -> Url {
let err_msg = format!("{:?} is missing or invalid.",
HudiTableConfig::BasePath);
self.hudi_configs
@@ -532,6 +511,12 @@ impl Table {
.expect(&err_msg)
}
+ /// The table name (`hoodie.table.name`).
+ ///
+ /// # Panics
+ ///
+ /// Panics if the config is missing, which table opening rejects unless
config validation is
+ /// skipped.
pub fn table_name(&self) -> String {
let err_msg = format!("{:?} is missing or invalid.",
HudiTableConfig::TableName);
self.hudi_configs
@@ -540,6 +525,12 @@ impl Table {
.into()
}
+ /// The table type: `COPY_ON_WRITE` or `MERGE_ON_READ`.
+ ///
+ /// # Panics
+ ///
+ /// Panics if the config is missing or not a valid table type, which table
opening rejects
+ /// unless config validation is skipped.
pub fn table_type(&self) -> String {
let err_msg = format!("{:?} is missing or invalid.",
HudiTableConfig::TableType);
self.hudi_configs
@@ -548,6 +539,11 @@ impl Table {
.into()
}
+ /// Whether this is a merge-on-read table.
+ ///
+ /// # Panics
+ ///
+ /// Panics under the same condition as [`Self::table_type`].
pub fn is_mor(&self) -> bool {
self.table_type() == TableTypeValue::MergeOnRead.as_ref()
}
@@ -556,6 +552,9 @@ impl Table {
Ok(!self.is_mor() || options.is_read_optimized()?)
}
+ /// The timezone of the timeline's instant times
(`hoodie.table.timeline.timezone`): `utc` or
+ /// `local`, and `utc` when the config is unset. [`Table::create`] records
`local` unless that
+ /// config is passed with [`TableCreateBuilder::with_option`].
pub fn timezone(&self) -> String {
self.hudi_configs
.get_or_default(HudiTableConfig::TimelineTimezone)
diff --git a/crates/core/src/table/partition.rs
b/crates/core/src/table/partition.rs
index b9a6a9de..1539658d 100644
--- a/crates/core/src/table/partition.rs
+++ b/crates/core/src/table/partition.rs
@@ -16,6 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
+//! Partition pruning and partition path helpers.
+
use crate::Result;
use crate::config::HudiConfigs;
use crate::config::table::HudiTableConfig;
@@ -33,7 +35,9 @@ use crate::metadata::meta_field::MetaField;
use std::collections::HashMap;
use std::sync::Arc;
+/// Name of the metadata file in each partition directory.
pub const PARTITION_METAFIELD_PREFIX: &str = ".hoodie_partition_metadata";
+/// The partition path of an unpartitioned table.
pub const EMPTY_PARTITION_PATH: &str = "";
/// Build the partition [Schema] in the order declared by
`partition_field_names`.
@@ -65,6 +69,7 @@ pub(crate) fn project_partition_schema(
Ok(Schema::new(fields))
}
+/// Whether the table has partition fields and does not use a non-partitioned
key generator.
pub fn is_table_partitioned(hudi_configs: &HudiConfigs) -> Result<bool> {
let has_partition_fields = {
let partition_fields: Vec<String> =
hudi_configs.get_or_default(PartitionFields).into();
@@ -102,6 +107,8 @@ pub struct PartitionPruner {
}
impl PartitionPruner {
+ /// Creates a pruner from the filters that apply to `partition_schema`.
Filters on other
+ /// columns, and filters whose value cannot be cast to the column type,
are ignored.
pub fn new(
and_filters: &[Filter],
partition_schema: &Schema,
diff --git a/crates/core/src/timeline/instant.rs
b/crates/core/src/timeline/instant.rs
index 30c19a53..74903eb3 100644
--- a/crates/core/src/timeline/instant.rs
+++ b/crates/core/src/timeline/instant.rs
@@ -267,7 +267,7 @@ impl Instant {
/// 1. Date format: `yyyyMMddHHmmss` (14 chars) or `yyyyMMddHHmmssSSS` (17
chars)
/// 2. Epoch milliseconds: 17-digit number representing milliseconds since
Unix epoch
/// (used by metadata table for timestamps like `00000000000000000`),
or one
- /// of the [`Self::BOOTSTRAP_INSTANT_TIMES`] sentinels.
+ /// of the `Self::BOOTSTRAP_INSTANT_TIMES` sentinels.
///
/// The function tries date format first, then falls back to epoch
milliseconds
/// if the date parsing fails (e.g., invalid month/day like `00`).
diff --git a/crates/core/src/timeline/loader.rs
b/crates/core/src/timeline/loader.rs
index c94613bd..46d12a9c 100644
--- a/crates/core/src/timeline/loader.rs
+++ b/crates/core/src/timeline/loader.rs
@@ -156,7 +156,7 @@ impl TimelineLoader {
/// Every instant time that has a `requested` or `inflight` file and no
/// completed one, for **any** action.
///
- /// Deliberately not built on [`TimelineSelector`], which resolves the
action
+ /// Deliberately not built on `TimelineSelector`, which resolves the action
/// through [`Action`](crate::timeline::instant::Action) and so can only
see
/// the three commit actions the enum names. Java's
`getValidInstantTimestamps`
/// takes its `datasetPendingInstants` from `filterInflightsAndRequested()`
diff --git a/crates/core/src/timeline/mod.rs b/crates/core/src/timeline/mod.rs
index 7b2801b2..a2c40573 100644
--- a/crates/core/src/timeline/mod.rs
+++ b/crates/core/src/timeline/mod.rs
@@ -89,7 +89,7 @@ pub const EARLIEST_START_TIMESTAMP: &str =
"19700101000000000";
/// The actions a read loads from the timeline.
///
-/// Narrowing this is no longer only a listing decision:
[`Timeline::completion_gate_inputs`]
+/// Narrowing this is no longer only a listing decision:
`Timeline::completion_gate_inputs`
/// derives the log scan's committed set from these, so an action that
completes outside this
/// list and wrote data or delete log blocks would have its rows dropped from
a merge-on-read
/// read — silently, since the gate excludes rather than errors. The three
here cover every
@@ -423,8 +423,8 @@ impl Timeline {
/// Get the latest [apache_avro::schema::Schema] as [String] from the
[Timeline].
///
/// ### Note
- /// This API behaves differently from
[crate::table::Table::get_avro_schema],
- /// which additionally looks for [HudiTableConfig::CreateSchema] in the
table config.
+ /// This API behaves differently from
[crate::table::Table::get_schema_in_avro_str],
+ /// which additionally looks for
[HudiTableConfig::CreateSchema](crate::config::table::HudiTableConfig::CreateSchema)
in the table config.
pub async fn get_latest_avro_schema(&self) -> Result<String> {
let commit_metadata = self.get_latest_commit_metadata().await?;
resolve_avro_schema_from_commit_metadata(&commit_metadata)
@@ -434,7 +434,7 @@ impl Timeline {
///
/// ### Note
/// This API behaves differently from [crate::table::Table::get_schema],
- /// which additionally looks for [HudiTableConfig::CreateSchema] in the
table config.
+ /// which additionally looks for
[HudiTableConfig::CreateSchema](crate::config::table::HudiTableConfig::CreateSchema)
in the table config.
pub async fn get_latest_schema(&self) -> Result<Schema> {
let commit_metadata = self.get_latest_commit_metadata().await?;
resolve_data_schema_from_commit_metadata(&commit_metadata,
self.storage.clone()).await
diff --git a/crates/core/src/timeline/selector.rs
b/crates/core/src/timeline/selector.rs
index a9c0e887..7c784771 100644
--- a/crates/core/src/timeline/selector.rs
+++ b/crates/core/src/timeline/selector.rs
@@ -70,7 +70,7 @@ impl InstantRange {
/// A range that admits exactly the instants in `instants`, and nothing
else.
///
- /// See [`Self::explicit_instants`] for why a window cannot stand in for
this.
+ /// See `Self::explicit_instants` for why a window cannot stand in for
this.
pub fn exact_match<I, S>(instants: I, timezone: &str) -> Self
where
I: IntoIterator<Item = S>,
diff --git a/crates/core/src/write/create.rs b/crates/core/src/write/create.rs
index b21ad23f..e0aa68e1 100644
--- a/crates/core/src/write/create.rs
+++ b/crates/core/src/write/create.rs
@@ -92,6 +92,8 @@ pub struct TableCreateBuilder {
}
impl TableCreateBuilder {
+ /// Starts a builder for a table at `base_uri`; same as
+ /// [`Table::create`](crate::table::Table::create).
pub fn new(base_uri: impl Into<String>) -> Self {
Self {
base_uri: base_uri.into(),
@@ -116,31 +118,43 @@ impl TableCreateBuilder {
}
}
+ /// Sets the table name, which is required and must be non-empty.
pub fn with_table_name(mut self, name: impl Into<String>) -> Self {
self.table_name = Some(name.into());
self
}
+ /// Sets the table type. Defaults to [`TableTypeValue::CopyOnWrite`].
pub fn with_table_type(mut self, table_type: TableTypeValue) -> Self {
self.table_type = table_type;
self
}
+ /// Sets the table version: `9` (the default) or `8`; [`Self::create`]
rejects other values.
+ /// A later [`Self::with_metadata_table_enabled`]`(true)` raises a version
below 8 to 8.
pub fn with_table_version(mut self, version: isize) -> Self {
self.table_version = version;
self
}
+ /// Sets the timeline layout version. Defaults to `2`, the only layout
table versions 8 and 9
+ /// accept.
pub fn with_timeline_layout_version(mut self, version: isize) -> Self {
self.timeline_layout_version = version;
self
}
+ /// Whether records carry the `_hoodie_*` meta fields. Defaults to `true`.
pub fn with_populates_meta_fields(mut self, value: bool) -> Self {
self.populates_meta_fields = value;
self
}
+ /// Sets the record key fields. Several fields form a composite key,
stored as `field:value`
+ /// pairs joined by commas. With none, appends generate keys, and
+ /// [`Table::upsert`](crate::table::Table::upsert),
+ /// [`Table::delete_keys`](crate::table::Table::delete_keys), and
+ /// [`Table::update`](crate::table::Table::update) on a merge-on-read
table are rejected.
pub fn with_record_key_fields<I, S>(mut self, fields: I) -> Self
where
I: IntoIterator<Item = S>,
@@ -150,6 +164,7 @@ impl TableCreateBuilder {
self
}
+ /// Sets the columns whose values form the partition path. Unset means
unpartitioned.
pub fn with_partition_fields<I, S>(mut self, fields: I) -> Self
where
I: IntoIterator<Item = S>,
@@ -251,6 +266,7 @@ impl TableCreateBuilder {
self
}
+ /// Sets a storage option, such as a cloud credential, used to create and
then open the table.
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());
@@ -258,6 +274,14 @@ impl TableCreateBuilder {
}
/// Create the table layout on storage and open a [`Table`] handle.
+ ///
+ /// # Errors
+ ///
+ /// Fails, among other cases, when a table already exists at the base URI,
the table name is
+ /// missing or empty, [`Self::with_option`] set a key that a dedicated
builder method owns,
+ /// the version settings are unsupported, or an index is enabled without
what it depends on
+ /// (the record index and `column_stats` need the metadata table,
`partition_stats` needs
+ /// `column_stats`).
pub async fn create(mut self) -> Result<Table> {
// The builder derives the metadata table layout, key generator and
// checksum from these, so an override would persist a table that
diff --git a/crates/core/src/write/lock.rs b/crates/core/src/write/lock.rs
index 70df42f6..87da18d6 100644
--- a/crates/core/src/write/lock.rs
+++ b/crates/core/src/write/lock.rs
@@ -63,6 +63,7 @@ pub struct InProcessLockProvider {
}
impl InProcessLockProvider {
+ /// A provider for the table at `base_path`. Providers for the same path
share one lock.
pub fn new(base_path: impl Into<String>) -> Self {
Self {
base_path: base_path.into(),
diff --git a/crates/core/src/write/mod.rs b/crates/core/src/write/mod.rs
index c81505d7..28421980 100644
--- a/crates/core/src/write/mod.rs
+++ b/crates/core/src/write/mod.rs
@@ -16,7 +16,16 @@
* specific language governing permissions and limitations
* under the License.
*/
-//! Write APIs for Hudi tables (`Table` verbs).
+//! Write APIs for Hudi tables.
+//!
+//! The writes are methods on [`Table`](crate::table::Table): `append`,
`upsert`, `upsert_with`,
+//! `update`, `delete`, `delete_keys`, `overwrite`, and
`dynamic_partition_overwrite`. A write that
+//! changes data is one commit, and updates the metadata table (when enabled)
in the same
+//! transaction. Merge-on-read tables also compact (experimental) with
`compact` and
+//! `await_compaction`. This module holds [`WriteResult`], [`UpsertOptions`],
+//! [`CompactionResult`], the [`TableCreateBuilder`] behind
+//! [`Table::create`](crate::table::Table::create), and the lock provider
types. Every write
+//! returns a [`WriteResult`].
pub(crate) mod append;
pub(crate) mod archival;
diff --git a/crates/datafusion/src/lib.rs b/crates/datafusion/src/lib.rs
index 2900fbd9..7f64c7cb 100644
--- a/crates/datafusion/src/lib.rs
+++ b/crates/datafusion/src/lib.rs
@@ -17,6 +17,21 @@
* under the License.
*/
+//! [Apache DataFusion](https://datafusion.apache.org) integration for Apache
Hudi tables.
+//!
+//! [`HudiDataSource`] is a DataFusion `TableProvider` to register with a
`SessionContext`;
+//! [`HudiTableFactory`] serves `CREATE EXTERNAL TABLE ... STORED AS HUDI`.
Both read through
+//! `hudi-core` and accept the same table options.
+//!
+//! Applications usually reach this crate through the
[`hudi`](https://docs.rs/hudi) crate's
+//! `datafusion` feature. The [Python and Rust quick start guide][quick start
guide] shows
+//! DataFusion queries on a hudi-rs table, and the
[`hudi`](https://docs.rs/hudi) crate docs list
+//! every resource (API references, quick starts, examples, specs).
+//!
+//! [quick start guide]:
https://hudi.apache.org/docs/python-rust-quick-start-guide
+
+#![warn(missing_docs)]
+
pub(crate) mod hudi_exec;
pub(crate) mod util;
@@ -87,8 +102,10 @@ fn filter_field_matches_partition_column(filter_field:
&str, partition_column: &
.is_some_and(|(_, name)| name == partition_column)
}
-/// Create a `HudiDataSource`.
-/// Used for Datafusion to query Hudi tables
+/// A DataFusion table provider for Apache Hudi tables.
+///
+/// The provider reads the table as of its creation, including its schema and
statistics; create
+/// a new provider to see later commits.
///
/// # Examples
///
@@ -116,7 +133,6 @@ fn filter_field_matches_partition_column(filter_field:
&str, partition_column: &
/// Ok(())
/// }
/// ```
-/// A DataFusion table provider for Apache Hudi tables.
#[derive(Clone)]
pub struct HudiDataSource {
table: Arc<HudiTable>,
@@ -172,10 +188,22 @@ impl std::fmt::Debug for HudiDataSource {
}
impl HudiDataSource {
+ /// Creates a provider for the table at `base_uri` with default options.
pub async fn new(base_uri: &str) -> Result<Self> {
Self::new_with_options(base_uri, empty_options()).await
}
+ /// Creates a provider for the table at `base_uri` with options, which are
passed on to
+ /// [`Table::new_with_options`](hudi_core::table::Table::new_with_options).
+ ///
+ /// The provider also reads `hoodie.read.input.partitions` (default `0`,
which defers to
+ /// DataFusion's `target_partitions`),
`hoodie.read.use.read_optimized.mode`, and
+ /// `hoodie.read.file.slice.read.concurrency` from these options.
+ ///
+ /// # Errors
+ ///
+ /// Fails when one of those options has an invalid value, the table cannot
be opened, or the
+ /// table's base file format is HFile.
pub async fn new_with_options<I, K, V>(base_uri: &str, options: I) ->
Result<Self>
where
I: IntoIterator<Item = (K, V)>,
@@ -1014,6 +1042,7 @@ impl TableProvider for HudiDataSource {
pub struct HudiTableFactory {}
impl HudiTableFactory {
+ /// Creates a factory.
pub fn new() -> Self {
Self {}
}
diff --git a/crates/hudi/Cargo.toml b/crates/hudi/Cargo.toml
index 5b72f12f..eca2ec21 100644
--- a/crates/hudi/Cargo.toml
+++ b/crates/hudi/Cargo.toml
@@ -48,4 +48,10 @@ datafusion = [
]
[dev-dependencies]
-tokio = { workspace = true }
+arrow = { workspace = true }
+datafusion = { workspace = true }
+tempfile = { workspace = true }
+tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
+
+[package.metadata.docs.rs]
+features = ["datafusion"]
diff --git a/crates/hudi/src/lib.rs b/crates/hudi/src/lib.rs
index 2fa10969..14d19c5c 100644
--- a/crates/hudi/src/lib.rs
+++ b/crates/hudi/src/lib.rs
@@ -16,6 +16,308 @@
* specific language governing permissions and limitations
* under the License.
*/
+//! A native Rust implementation of [Apache Hudi](https://hudi.apache.org).
+//!
+//! This crate creates, writes, and reads Hudi tables on local disk or cloud
object storage
+//! (`s3://`, `az://`, `gs://`), exchanging data as Arrow [`RecordBatch`]es.
It re-exports
+//! [`hudi_core`], and with the `datafusion` feature also the DataFusion table
provider from
+//! `hudi-datafusion`. [`table::Table`] is the entry point for both reads and
writes.
+//!
+//! # Resources
+//!
+//! - Quick start: the [Python and Rust quick start guide][quick start guide]
on the Hudi website,
+//! and the shorter [README quick start].
+//! - Rust API reference: this crate,
[`hudi-core`](https://docs.rs/hudi-core), and
+//! [`hudi-datafusion`](https://docs.rs/hudi-datafusion).
+//! - Python API reference: the docstrings in the package's type stubs,
+//! [`python/hudi/_internal.pyi`][python stubs].
+//! - Runnable Rust example: [`crates/core/examples/table_writes.rs`][rust
example].
+//! - Reader semantics: [`docs/reader-spec.md`][reader spec]. Writer design:
+//! [`docs/writer-design.md`][writer design].
+//! - Packages: [crates.io](https://crates.io/crates/hudi) and
+//! [PyPI](https://pypi.org/project/hudi/). Source:
+//! [github.com/apache/hudi-rs](https://github.com/apache/hudi-rs).
+//! - Hudi concepts (timeline, table types, query types):
+//! [hudi.apache.org/docs/overview](https://hudi.apache.org/docs/overview).
+//!
+//! [quick start guide]:
https://hudi.apache.org/docs/python-rust-quick-start-guide
+//! [README quick start]: https://github.com/apache/hudi-rs#quick-start
+//! [python stubs]:
https://github.com/apache/hudi-rs/blob/main/python/hudi/_internal.pyi
+//! [rust example]:
https://github.com/apache/hudi-rs/blob/main/crates/core/examples/table_writes.rs
+//! [reader spec]:
https://github.com/apache/hudi-rs/blob/main/docs/reader-spec.md
+//! [writer design]:
https://github.com/apache/hudi-rs/blob/main/docs/writer-design.md
+//!
+//! [`RecordBatch`]:
https://docs.rs/arrow/latest/arrow/array/struct.RecordBatch.html
+//!
+//! # Features
+//!
+//! - `datafusion` (off by default): adds `HudiDataSource`, a DataFusion
`TableProvider`, and
+//! `HudiTableFactory` for `CREATE EXTERNAL TABLE ... STORED AS HUDI`.
+//! - `spill-rocksdb` (on by default): the on-disk tier of the merge-on-read
merge map, used when a
+//! merge exceeds `hoodie.memory.merge.max.size`. RocksDB is built from
source with `bindgen`, so
+//! it needs `libclang` and a C++ toolchain. With `default-features = false`
the tier is gone, and
+//! a merge past the budget fails with
[`CoreError::Unsupported`](error::CoreError::Unsupported)
+//! naming the config instead of spilling.
+//!
+//! # Create a table and write to it
+//!
+//! A write that changes data is one Hudi commit.
[`Table::append`](table::Table::append) inserts without an
+//! index lookup; [`Table::upsert`](table::Table::upsert) updates rows whose
record key exists and
+//! inserts the rest; [`Table::delete`](table::Table::delete) and
+//! [`Table::delete_keys`](table::Table::delete_keys) remove rows by predicate
or by key.
+//!
+//! ```
+//! # use std::sync::Arc;
+//! # use arrow::array::{ArrayRef, Int64Array, RecordBatch, StringArray};
+//! use hudi::config::table::TableTypeValue;
+//! use hudi::table::{HoodieKey, Table};
+//!
+//! # fn trips(rows: &[(&str, &str, i64)]) -> RecordBatch {
+//! # let id: ArrayRef =
Arc::new(StringArray::from_iter_values(rows.iter().map(|r| r.0)));
+//! # let city: ArrayRef =
Arc::new(StringArray::from_iter_values(rows.iter().map(|r| r.1)));
+//! # let ts: ArrayRef =
Arc::new(Int64Array::from_iter_values(rows.iter().map(|r| r.2)));
+//! # RecordBatch::try_from_iter([("id", id), ("city", city), ("ts",
ts)]).unwrap()
+//! # }
+//! # #[tokio::main]
+//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
+//! # let dir = tempfile::tempdir()?;
+//! # let base_uri = dir.path().to_str().unwrap();
+//! let mut table = Table::create(base_uri)
+//! .with_table_name("trips")
+//! .with_table_type(TableTypeValue::CopyOnWrite) // or MergeOnRead
+//! .with_record_key_fields(["id"])
+//! .with_partition_fields(["city"])
+//! .with_ordering_fields(["ts"]) // the larger `ts` wins when keys collide
+//! .create()
+//! .await?;
+//!
+//! // `trips` builds a batch with columns `id`, `city`, and `ts`.
+//! table.append([trips(&[("a", "sf", 1), ("b", "sf", 1), ("c", "nyc",
1)])]).await?;
+//!
+//! let result = table.upsert([trips(&[("b", "sf", 2), ("d", "nyc",
2)])]).await?;
+//! assert_eq!((result.num_updates, result.num_inserts), (1, 1));
+//!
+//! let result = table.delete("id = 'a'").await?;
+//! assert_eq!(result.num_deletes, 1);
+//!
+//! let result = table
+//! .delete_keys([HoodieKey {
+//! record_key: "c".to_string(),
+//! partition_path: "city=nyc".to_string(),
+//! }])
+//! .await?;
+//! assert_eq!(result.num_deletes, 1);
+//! # Ok(())
+//! # }
+//! ```
+//!
+//! # Compact a merge-on-read table
+//!
+//! On a merge-on-read table, upserts, updates, and deletes write log files.
+//! [`Table::compact`](table::Table::compact) (experimental) folds them into
new base files under
+//! one compaction commit. Rows keep their commit time, so incremental reads
see no new rows from
+//! it. Compaction can also run after every
`hoodie.compact.inline.max.delta.commits` (default 5)
+//! delta commits: in the write call with `hoodie.compact.inline=true`, or on
a background task
+//! with `hoodie.datasource.compaction.async.enable=true`, which
+//! [`Table::await_compaction`](table::Table::await_compaction) waits for.
+//!
+//! ```
+//! # use std::sync::Arc;
+//! # use arrow::array::{ArrayRef, Int64Array, RecordBatch, StringArray};
+//! use hudi::config::table::TableTypeValue;
+//! use hudi::table::Table;
+//!
+//! # fn trips(rows: &[(&str, &str, i64)]) -> RecordBatch {
+//! # let id: ArrayRef =
Arc::new(StringArray::from_iter_values(rows.iter().map(|r| r.0)));
+//! # let city: ArrayRef =
Arc::new(StringArray::from_iter_values(rows.iter().map(|r| r.1)));
+//! # let ts: ArrayRef =
Arc::new(Int64Array::from_iter_values(rows.iter().map(|r| r.2)));
+//! # RecordBatch::try_from_iter([("id", id), ("city", city), ("ts",
ts)]).unwrap()
+//! # }
+//! # #[tokio::main]
+//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
+//! # let dir = tempfile::tempdir()?;
+//! # let base_uri = dir.path().to_str().unwrap();
+//! let mut table = Table::create(base_uri)
+//! .with_table_name("trips")
+//! .with_table_type(TableTypeValue::MergeOnRead)
+//! .with_record_key_fields(["id"])
+//! .with_partition_fields(["city"])
+//! .with_ordering_fields(["ts"])
+//! .create()
+//! .await?;
+//! table.append([trips(&[("a", "sf", 1), ("b", "nyc", 1)])]).await?;
+//! table.upsert([trips(&[("a", "sf", 2)])]).await?; // writes a log file in
`city=sf`
+//!
+//! let result = table.compact().await?;
+//! assert_eq!((result.num_file_groups, result.num_log_files), (1, 1));
+//!
+//! // Nothing left to compact: no commit is created.
+//! assert!(table.compact().await?.requested_time.is_none());
+//! # Ok(())
+//! # }
+//! ```
+//!
+//! # Snapshot read
+//!
+//! [`Table::new`](table::Table::new) opens an existing table. A read returns
the latest committed
+//! state, narrowed by optional filters and a projection from
[`ReadOptions`](table::ReadOptions).
+//!
+//! ```
+//! # use std::sync::Arc;
+//! # use arrow::array::{ArrayRef, Int64Array, RecordBatch, StringArray};
+//! use hudi::table::{ReadOptions, Table};
+//! # fn trips(rows: &[(&str, &str, i64)]) -> RecordBatch {
+//! # let id: ArrayRef =
Arc::new(StringArray::from_iter_values(rows.iter().map(|r| r.0)));
+//! # let city: ArrayRef =
Arc::new(StringArray::from_iter_values(rows.iter().map(|r| r.1)));
+//! # let ts: ArrayRef =
Arc::new(Int64Array::from_iter_values(rows.iter().map(|r| r.2)));
+//! # RecordBatch::try_from_iter([("id", id), ("city", city), ("ts",
ts)]).unwrap()
+//! # }
+//! # #[tokio::main]
+//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
+//! # let dir = tempfile::tempdir()?;
+//! # let base_uri = dir.path().to_str().unwrap();
+//! # let mut writer = Table::create(base_uri)
+//! # .with_table_name("trips")
+//! # .with_record_key_fields(["id"])
+//! # .with_partition_fields(["city"])
+//! # .create()
+//! # .await?;
+//! # writer.append([trips(&[("a", "sf", 1), ("b", "sf", 1), ("c", "nyc",
1)])]).await?;
+//! let table = Table::new(base_uri).await?;
+//! let options = ReadOptions::new()
+//! .with_filters([("city", "=", "sf")])?
+//! .with_projection(["id", "ts"]);
+//! let batches = table.read(&options).await?;
+//! let num_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
+//! assert_eq!(num_rows, 2);
+//! # Ok(())
+//! # }
+//! ```
+//!
+//! [`Table::read_stream`](table::Table::read_stream) yields the same snapshot
one batch at a time.
+//!
+//! # Time-travel read
+//!
+//!
[`ReadOptions::with_as_of_timestamp`](table::ReadOptions::with_as_of_timestamp)
reads the table
+//! as of an earlier commit. It accepts a Hudi instant time
(`yyyyMMddHHmmssSSS` or
+//! `yyyyMMddHHmmss`), Unix epoch time, or RFC 3339 with a timezone offset.
+//!
+//! ```
+//! # use std::sync::Arc;
+//! # use arrow::array::{ArrayRef, Int64Array, RecordBatch, StringArray};
+//! use hudi::table::{ReadOptions, Table};
+//! # fn trips(rows: &[(&str, &str, i64)]) -> RecordBatch {
+//! # let id: ArrayRef =
Arc::new(StringArray::from_iter_values(rows.iter().map(|r| r.0)));
+//! # let city: ArrayRef =
Arc::new(StringArray::from_iter_values(rows.iter().map(|r| r.1)));
+//! # let ts: ArrayRef =
Arc::new(Int64Array::from_iter_values(rows.iter().map(|r| r.2)));
+//! # RecordBatch::try_from_iter([("id", id), ("city", city), ("ts",
ts)]).unwrap()
+//! # }
+//! # #[tokio::main]
+//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
+//! # let dir = tempfile::tempdir()?;
+//! # let base_uri = dir.path().to_str().unwrap();
+//! # let mut table = Table::create(base_uri)
+//! # .with_table_name("trips")
+//! # .with_record_key_fields(["id"])
+//! # .with_partition_fields(["city"])
+//! # .create()
+//! # .await?;
+//! let first = table.append([trips(&[("a", "sf", 1), ("b", "sf",
1)])]).await?;
+//! table.append([trips(&[("c", "nyc", 2)])]).await?;
+//!
+//! // A write's requested time names its commit for time travel.
+//! let as_of = first.requested_time.expect("the append made a commit");
+//! let options = ReadOptions::new().with_as_of_timestamp(as_of);
+//! let batches = table.read(&options).await?;
+//! let num_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
+//! assert_eq!(num_rows, 2);
+//! # Ok(())
+//! # }
+//! ```
+//!
+//! # Incremental read
+//!
+//! An incremental query returns the records changed by commits in the
half-open window
+//! `(start, end]`; `end` defaults to everything committed so far. On tables
at version 8 and
+//! above (timeline layout 2) the window bounds commit completion times, so
start from a write's
+//! [`completion_time`](table::WriteResult::completion_time), not its
`requested_time`.
+//!
+//! ```
+//! # use std::sync::Arc;
+//! # use arrow::array::{ArrayRef, Int64Array, RecordBatch, StringArray};
+//! use hudi::table::{QueryType, ReadOptions, Table};
+//! # fn trips(rows: &[(&str, &str, i64)]) -> RecordBatch {
+//! # let id: ArrayRef =
Arc::new(StringArray::from_iter_values(rows.iter().map(|r| r.0)));
+//! # let city: ArrayRef =
Arc::new(StringArray::from_iter_values(rows.iter().map(|r| r.1)));
+//! # let ts: ArrayRef =
Arc::new(Int64Array::from_iter_values(rows.iter().map(|r| r.2)));
+//! # RecordBatch::try_from_iter([("id", id), ("city", city), ("ts",
ts)]).unwrap()
+//! # }
+//! # #[tokio::main]
+//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
+//! # let dir = tempfile::tempdir()?;
+//! # let base_uri = dir.path().to_str().unwrap();
+//! # let mut table = Table::create(base_uri)
+//! # .with_table_name("trips")
+//! # .with_record_key_fields(["id"])
+//! # .with_partition_fields(["city"])
+//! # .create()
+//! # .await?;
+//! let first = table.append([trips(&[("a", "sf", 1), ("b", "sf",
1)])]).await?;
+//! table.upsert([trips(&[("b", "sf", 2), ("c", "nyc", 2)])]).await?;
+//!
+//! let start = first.completion_time.ok_or("layout 2 records a completion
time")?;
+//! let options = ReadOptions::new()
+//! .with_query_type(QueryType::Incremental)
+//! .with_start_timestamp(start);
+//! let changed = table.read(&options).await?;
+//! let num_rows: usize = changed.iter().map(|b| b.num_rows()).sum();
+//! assert_eq!(num_rows, 2); // `b` updated, `c` inserted
+//! # Ok(())
+//! # }
+//! ```
+//!
+//! # Query with DataFusion
+//!
+//! With the `datafusion` feature, register a table with a DataFusion
`SessionContext` and query
+//! it with SQL.
+//!
+//! ```
+//! # use std::sync::Arc;
+//! # use arrow::array::{ArrayRef, Int64Array, RecordBatch, StringArray};
+//! # fn trips(rows: &[(&str, &str, i64)]) -> RecordBatch {
+//! # let id: ArrayRef =
Arc::new(StringArray::from_iter_values(rows.iter().map(|r| r.0)));
+//! # let city: ArrayRef =
Arc::new(StringArray::from_iter_values(rows.iter().map(|r| r.1)));
+//! # let ts: ArrayRef =
Arc::new(Int64Array::from_iter_values(rows.iter().map(|r| r.2)));
+//! # RecordBatch::try_from_iter([("id", id), ("city", city), ("ts",
ts)]).unwrap()
+//! # }
+//! # #[tokio::main]
+//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
+//! # let dir = tempfile::tempdir()?;
+//! # let base_uri = dir.path().to_str().unwrap();
+//! # let mut table = hudi::table::Table::create(base_uri)
+//! # .with_table_name("trips")
+//! # .with_record_key_fields(["id"])
+//! # .with_partition_fields(["city"])
+//! # .create()
+//! # .await?;
+//! # table.append([trips(&[("a", "sf", 1), ("b", "sf", 1), ("c", "nyc",
1)])]).await?;
+//! # #[cfg(feature = "datafusion")]
+//! # {
+//! use datafusion::prelude::SessionContext;
+//! use hudi::HudiDataSource;
+//!
+//! let ctx = SessionContext::new();
+//! let provider =
+//! HudiDataSource::new_with_options(base_uri,
[("hoodie.read.input.partitions", "2")]).await?;
+//! ctx.register_table("trips", Arc::new(provider))?;
+//! let df = ctx.sql("SELECT id, ts FROM trips WHERE city = 'sf'").await?;
+//! let batches = df.collect().await?;
+//! let num_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
+//! assert_eq!(num_rows, 2);
+//! # }
+//! # Ok(())
+//! # }
+//! ```
pub use hudi_core::*;