blob: 70a60ccc2e0b168801773f5febe8cf4c510c88f1 [file] [view]
<!--
~ Licensed to the Apache Software Foundation (ASF) under one
~ or more contributor license agreements. See the NOTICE file
~ distributed with this work for additional information
~ regarding copyright ownership. The ASF licenses this file
~ to you under the Apache License, Version 2.0 (the
~ "License"); you may not use this file except in compliance
~ with the License. You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing,
~ software distributed under the License is distributed on an
~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
~ KIND, either express or implied. See the License for the
~ specific language governing permissions and limitations
~ under the License.
-->
# hudi-rs Reader Spec
The public reader APIs hudi-rs exposes for Apache Hudi tables: the methods callers can call, the options they accept, and what callers can expect back.
For tutorials and end-to-end examples, see the [README](../README.md). Covers Rust core (`hudi`) and the Python bindings (`hudi`); writer APIs, DataFusion, and C++ bindings are out of scope here.
## Contents
1. [Query types](#1-query-types)
2. [`ReadOptions`](#2-readoptions)
3. [Filter expressions](#3-filter-expressions)
4. [Rust API](#4-rust-api)
5. [Python API](#5-python-api)
6. [Caller expectations](#6-caller-expectations)
7. [Stability and out of scope](#7-stability-and-out-of-scope)
## 1. Query types
The two `QueryType` variants on `ReadOptions`:
- **Snapshot** — latest table state at one commit (the latest by default; an explicit `as_of_timestamp` for time-travel).
- **Incremental** — records changed in the half-open range (`start_timestamp`, `end_timestamp`].
Snapshot reads have an **eager** form returning all batches and a **streaming** form yielding batches as they're read. Incremental reads currently expose only the eager form.
Per-slice reads — reading a single `FileSlice` the caller already selected (typically obtained from `get_file_slices` with the right options) — are not a query type; they live on `FileGroupReader` and have both eager and streaming forms.
## 2. `ReadOptions`
`ReadOptions` (Rust) / `HudiReadOptions` (Python) is the single config object accepted by every reader API. The struct stores three fields — `filters`, `projection`, and `hudi_options`. Convenience builders (`with_query_type`, `with_as_of_timestamp`, `with_start_timestamp`, `with_end_timestamp`, `with_batch_size`) translate into `hudi_options` under the matching `HudiReadConfig` key, so `hudi_options` is the single source of truth for per-read Hudi configs. Typed accessors (`query_type()`, `as_of_timestamp()`, `start_timestamp()`, `end_timestamp()`, `batch_size()`) read back from the bag.
| Stored field | Type | Default | Notes |
|----------------|-------------------------------|---------|----------------------------------------------------|
| `filters` | `Vec<(field, op, value)>` | empty | pruning + row-level mask |
| `projection` | `Option<Vec<String>>` | None | all columns when None |
| `hudi_options` | `HashMap<String, String>` | empty | per-read `hoodie.*` overrides |
| Convenience builder | Stored under `hudi_options` key | Default at use time |
|----------------------------|------------------------------------------------|-------------------------------------------|
| `with_query_type(QueryType)` | `hoodie.read.query.type` | `snapshot` |
| `with_as_of_timestamp(ts)` | `hoodie.read.as.of.timestamp` | latest commit (Snapshot only) |
| `with_start_timestamp(ts)` | `hoodie.read.start.timestamp` | `19700101000000000` (Incremental only) |
| `with_end_timestamp(ts)` | `hoodie.read.end.timestamp` | latest commit (Incremental only) |
| `with_batch_size(n)` | `hoodie.read.stream.batch_size` | `1024` (streaming only) |
Timestamp resolution: all four public entry points — `read`, `read_stream`, `get_file_slices`, and `create_file_group_reader_with_options` — go through a single `prepare_reader_options` step that (1) strips timestamps irrelevant to the query type (snapshot discards `start/end_timestamp`; incremental discards `as_of_timestamp`) and (2) resolves the remaining timestamps into the `EndTimestamp` / `StartTimestamp` that `FileGroupReader` needs for log-scan bounds and commit-time filtering. Callers may set all three for convenience; only the applicable ones take effect.
Which knobs each API consumes:
| API | query type | as-of | start/end | filters | projection | batch size | hudi_options pass-through |
|------------------------------------------------------------------|:----------:|:-----:|:---------:|:-------:|:----------:|:----------:|:-------------------------:|
| `read` / `read_stream` | yes | when Snapshot | when Incremental | yes | yes | streaming | yes |
| `get_file_slices` | yes | when Snapshot | when Incremental | yes | — | — | — |
| `create_file_group_reader_with_options` | yes | when Snapshot | when Incremental | — | — | — | yes |
| `FileGroupReader::read_file_slice` / `_from_paths` | — | — | — | yes | yes | — | — |
| `FileGroupReader::read_file_slice_stream` / `_from_paths_stream` | — | — | — | yes | yes | yes | — |
Notes:
- `read_stream` errors with `Unsupported` for `query_type = Incremental` — incremental streaming is not yet implemented.
- The `hudi_options` bag is a per-read override layer — set arbitrary `hoodie.read.*` configs (e.g. `hoodie.read.use.read_optimized.mode = true`) for this single read. Read configs (`hoodie.read.*`) are not stored in the `Table` instance; they flow exclusively through `ReadOptions`.
- Per-slice reads are exposed only by `FileGroupReader`. The `Table` type owns logical reads (snapshot, incremental); per-slice reads are physical and belong at the file-group layer. To read one slice with table-level configs, build a `FileGroupReader` via `Table::create_file_group_reader_with_options` and call its per-slice methods. The method resolves timestamps automatically (e.g. `AsOfTimestamp` → `EndTimestamp`), so callers can pass the same `ReadOptions` used for `get_file_slices`.
- For parallel reads, call `get_file_slices(...)` and bucket the result with `hudi::util::collection::split_into_chunks` or your engine's preferred partitioning policy.
Timestamp formats are documented in [§6](#timestamps).
## 3. Filter expressions
A filter is a `(field, operator, value)` tuple of strings.
| Operator (case-insensitive) | Cardinality |
|-------------------------------------|:-----------:|
| `=` `!=` `<` `<=` `>` `>=` | 1 |
| `IN` `NOT IN` | ≥1 |
For `IN` / `NOT IN`, the value string is split on unescaped commas and trimmed: `("city", "IN", "sf,la,nyc")`. `\,` is a literal comma and `\\` is a literal backslash, so values that contain commas survive: `("name", "IN", "Smith\\, John,Jane")` parses to `["Smith, John", "Jane"]`.
`with_filters` parses and cardinality-validates upfront; an unrecognized operator or empty `IN` / `NOT IN` value list errors at the builder rather than at read time.
The `field` may be any column. Filters drive three things:
- **Partition pruning** when the field is a partition column. Always applied.
- **File-level stats pruning** when the field is a data column with min/max stats in the metadata table. Snapshot/time-travel only — incremental file planning does not stats-prune.
- **Row-level mask** applied to every returned batch. This is the authoritative filter; pruning is best-effort.
Values are strings; they are cast to the target column's Arrow type at filter time. Unparseable values (e.g. `"abc"` against `Int64`) error.
A filter on an unknown column errors before any rows are returned. `Table` / `HudiTable` paths validate up front against the loaded schemas. `FileGroupReader` paths validate strictly against the read batch schema on the first batch — direct callers must not pass filters on columns absent from the parquet (notably partition columns when `hoodie.datasource.write.drop.partition.columns = true`); the table-level paths strip such filters automatically after using them for pruning.
## 4. Rust API
All public symbols are re-exported from the `hudi` crate.
### `Table`
| Method | Returns |
|----------------------------------------------------------------------------|------------------------------------------------------|
| `Table::new(base_uri)` | `Result<Table>` |
| `Table::new_with_options(base_uri, options)` | `Result<Table>` |
| `hudi_options()` / `storage_options()` | `HashMap<String, String>` |
| `base_url()` / `table_name()` / `table_type()` / `is_mor()` / `timezone()` | `Url` / `String` / `String` / `bool` / `String` |
| `get_schema()` / `get_schema_with_meta_fields()` | `Result<Schema>` |
| `get_schema_in_avro_str()` / `get_schema_in_avro_str_with_meta_fields()` | `Result<String>` |
| `get_partition_schema()` | `Result<Schema>` |
| `get_timeline()` | `&Timeline` |
| `get_file_slices(&ReadOptions)` | `Result<Vec<FileSlice>>` (dispatches on `query_type`) |
| `create_file_group_reader_with_options(read_options, extra_storage_overrides)` | `Result<FileGroupReader>` |
| `read(&ReadOptions)` | `Result<Vec<RecordBatch>>` (dispatches on `query_type`) |
| `read_stream(&ReadOptions)` | `Result<BoxStream<'static, Result<RecordBatch>>>` (errors on `Incremental`) |
| `compute_table_stats(Option<&ReadOptions>)` | `Option<(u64, u64)>` — `(rows, byte_size)`; see §7 |
### `FileGroupReader`
| Method | Returns |
|-------------------------------------------------------------------------|------------------------------------------------------|
| `FileGroupReader::new_with_options(base_uri, options)` | `Result<FileGroupReader>` |
| `read_file_slice(&FileSlice, &ReadOptions)` | `Result<RecordBatch>` (base + merge logs) |
| `read_file_slice_from_paths(base_path, log_paths, &ReadOptions)` | `Result<RecordBatch>` (pass empty log_paths for base-only) |
| `read_file_slice_stream(&FileSlice, &ReadOptions)` | `Result<BoxStream<'static, Result<RecordBatch>>>` |
| `read_file_slice_from_paths_stream(base_path, log_paths, &ReadOptions)` | `Result<BoxStream<'static, Result<RecordBatch>>>` |
| `is_metadata_table()` | `bool` |
### `ReadOptions` builder
`with_filters` and `with_batch_size` validate eagerly and return `Result<Self>`; the others are infallible. Chains intermix with `?` propagation:
```rust
// Snapshot with time-travel
let options = ReadOptions::new()
.with_query_type(QueryType::Snapshot)
.with_filters([("city", "=", "san_francisco")])?
.with_projection(["rider", "city", "ts", "fare"])
.with_batch_size(4096)?
.with_as_of_timestamp("20240101000000000")
.with_hudi_option("hoodie.read.use.read_optimized.mode", "true");
// Incremental
let options = ReadOptions::new()
.with_query_type(QueryType::Incremental)
.with_start_timestamp("20240101000000000")
.with_end_timestamp("20240201000000000");
```
`with_batch_size(0)` errors at the builder (a zero-row batch yields no batches at the parquet stream reader). `with_filters` parses + cardinality-validates upfront; an unrecognized operator or empty `IN` / `NOT IN` value list errors here rather than at read time.
### `TableBuilder`
```rust
use hudi::table::builder::TableBuilder as HudiTableBuilder;
let table = HudiTableBuilder::from_base_uri("/tmp/trips_table")
.with_hudi_option("hoodie.metadata.enable", "true")
.with_storage_option("aws_region", "us-west-2")
.build()
.await?;
```
Available pairs: `with_hudi_option` / `with_hudi_options`, `with_storage_option` / `with_storage_options`, `with_option` / `with_options` (the generic forms route by key prefix). Read configs (`hoodie.read.*`) passed at table construction are silently dropped — they belong in `ReadOptions` per-call.
### Filter, Timeline, FileSlice
| Item | Notes |
|---------------------------------------------------------------------------------------------------------------|--------------------------------------------------------|
| `Filter { field, operator, values }`, `Filter::new(...)`, `Filter::negate()` | One column predicate; cardinality-validated. |
| `from_str_tuples(tuples)` | Parse `(&str, &str, &str)` tuples into `Vec<Filter>`. |
| `enum ExprOperator { Eq, Ne, Lt, Lte, Gt, Gte, In, NotIn }` | Comparison operators. |
| `col(name).eq / ne / lt / lte / gt / gte / in_list / not_in_list` | DSL for building filters. |
| `Timeline::get_completed_commits(desc)` and `..._deltacommits` / `_replacecommits` / `_clustering_commits` | Successful instants. `desc` reverses order. |
| `Timeline::get_latest_commit_timestamp()` | Latest commit timestamp. |
| `Timeline::get_instant_metadata_in_json(&Instant)` | Commit metadata for one instant. |
| `Timeline::get_latest_avro_schema()` / `get_latest_schema()` | Latest schema (Avro string / Arrow `Schema`). |
| `FileSlice::file_id()` / `creation_instant_time()` / `has_log_file()` | Slice identity / version / MOR-with-deltas flag. |
| `FileSlice::base_file_relative_path()` / `log_file_relative_path(&LogFile)` | Paths relative to the table base URI. |
| `FileSlice::total_size_bytes()` | Sum of base + log file on-disk sizes. Missing metadata contributes 0. |
`FileSlice` uses the base file's `num_records` as the record count for the slice. Log file records are not counted separately because they merge into the base file record batch during reads — the base file count represents the materialized output. This applies to both snapshot and incremental stats. `total_size_bytes()` includes log file on-disk sizes since they contribute to I/O cost. File slices without a base file (log-only groups) are not yet supported.
## 5. Python API
All symbols are exported from the top-level `hudi` package.
### `HudiTableBuilder`
```python
from hudi import HudiTableBuilder
table = (
HudiTableBuilder
.from_base_uri("/tmp/trips_table")
.with_hudi_option("hoodie.metadata.enable", "true")
.with_storage_option("aws_region", "us-west-2")
.build()
)
```
`with_hudi_option` and `with_option` accept a string key or a `HudiReadConfig` / `HudiTableConfig` enum member. The bulk variants (`with_hudi_options`, `with_options`) currently accept dicts of string keys. Read configs (`hoodie.read.*`) are silently dropped at table construction — pass them via `HudiReadOptions` per-call instead.
### `HudiTable`
| Method / property | Returns |
|----------------------------------------------------------------------------------------------------|------------------------------------------|
| `HudiTable(base_uri, options=None)` | `HudiTable` |
| `hudi_options()` / `storage_options()` | `Dict[str, str]` |
| properties: `table_name`, `table_type`, `is_mor`, `timezone`, `base_url` | table-level metadata |
| `get_schema()` / `get_schema_with_meta_fields()` / `get_partition_schema()` | `pyarrow.Schema` |
| `get_schema_in_avro_str()` / `get_schema_in_avro_str_with_meta_fields()` | `str` |
| `get_timeline()` | `HudiTimeline` |
| `get_file_slices(options=None)` | `List[HudiFileSlice]` (dispatches on `options.query_type`) |
| `create_file_group_reader_with_options(read_options=None, extra_storage_overrides=None)` | `HudiFileGroupReader` |
| `read(options=None)` | `List[pyarrow.RecordBatch]` (dispatches on `query_type`) |
| `read_stream(options=None)` | `HudiRecordBatchStream` (errors on `Incremental`) |
| `compute_table_stats(options=None)` | `Optional[Tuple[int, int]]`; see §7 |
### `HudiFileGroupReader`
| Method | Returns |
|----------------------------------------------------------------------------|----------------------------------|
| `HudiFileGroupReader(base_uri, options=None)` | `HudiFileGroupReader` |
| `read_file_slice(file_slice, options=None)` | `pyarrow.RecordBatch` |
| `read_file_slice_from_paths(base_path, log_paths, options=None)` | `pyarrow.RecordBatch` (pass empty log_paths for base-only) |
| `read_file_slice_stream(file_slice, options=None)` | `HudiRecordBatchStream` |
| `read_file_slice_from_paths_stream(base_path, log_paths, options=None)` | `HudiRecordBatchStream` |
| property: `is_metadata_table` | `bool` |
### `HudiReadOptions`
```python
from hudi import HudiQueryType, HudiReadOptions
# Snapshot with time-travel
options = (
HudiReadOptions(
filters=[("city", "=", "san_francisco")],
projection=["rider", "city", "ts", "fare"],
hudi_options={"hoodie.read.use.read_optimized.mode": "true"},
)
.with_query_type(HudiQueryType.Snapshot)
.with_batch_size(4096)
.with_as_of_timestamp("20240101000000000")
)
# Incremental
options = (
HudiReadOptions()
.with_query_type(HudiQueryType.Incremental)
.with_start_timestamp("20240101000000000")
.with_end_timestamp("20240201000000000")
)
```
All builders return a new `HudiReadOptions` for chaining. Typed accessors
(`query_type()`, `as_of_timestamp()`, `start_timestamp()`, `end_timestamp()`,
`batch_size()`) read back from the bag. Defaults match [§2](#2-readoptions).
`with_batch_size(0)` raises immediately, and `with_filters` parses + cardinality-validates upfront — an unrecognized operator or empty `IN` / `NOT IN` value list raises here rather than at read time. The constructor's `filters` argument has the same eager validation.
### `HudiRecordBatchStream`
A single-use iterator returned by streaming APIs. `for batch in stream:` or `next(stream)`. Each yielded value is a `pyarrow.RecordBatch`.
### `HudiTimeline`, `HudiInstant`, `HudiFileSlice`
| Item | Notes |
|--------------------------------------------------------------------------------------------------|--------------------------------------------------------|
| `HudiTimeline.get_completed_commits(desc=False)` and `..._deltacommits` / `_replacecommits` / `_clustering_commits` | `List[HudiInstant]`. `desc` reverses order. |
| `HudiTimeline.get_instant_metadata_in_json(instant)` | `str` |
| `HudiTimeline.get_latest_commit_timestamp()` | `str` |
| `HudiTimeline.get_latest_avro_schema()` / `get_latest_schema()` | `str` / `pyarrow.Schema` |
| `HudiInstant` properties: `timestamp`, `action`, `state`, `epoch_mills` | read-only |
| `HudiFileSlice` attributes: `file_id`, `partition_path`, `creation_instant_time`, `base_file_name`, `base_file_size`, `base_file_byte_size`, `log_file_names`, `log_file_sizes`, `num_records` | read-only |
| `HudiFileSlice.base_file_relative_path()` / `log_files_relative_paths()` | `str` / `List[str]` |
| `HudiFileSlice.total_size_bytes()` / `has_log_files()` | `int` / `bool` |
## 6. Caller expectations
### Snapshot atomicity
A snapshot read is pinned to one completed commit timestamp; mid-read writes are not visible. The timeline is loaded once when `Table` / `HudiTable` is constructed and reused for every subsequent read on that instance — commits that land *after* construction require building a new instance to observe. When `as_of_timestamp` is unset, "latest commit" means the latest commit in the cached timeline, not in storage at call time.
### Incremental semantics
The range is half-open: (`start_timestamp`, `end_timestamp`]. A record updated multiple times within the range yields its latest in-range state, not the full update history. Records updated only outside the range are not returned.
### MOR streaming fallback
Streaming yields true streaming batches when the slice is base-file-only or `hoodie.read.use.read_optimized.mode = true`. For MOR slices with log files, the implementation collects-and-merges and yields the result as a single batch on the stream.
### `batch_size` and `projection`
`batch_size` controls rows per batch for streaming reads (default 1024); eager reads return one merged batch per file slice and ignore `batch_size`. Streaming pushes `projection` down to the parquet reader; eager reads project after merging. When `projection` is combined with `filters` on data columns not in `projection`, the read transparently widens to read those columns, then projects back down after the filter mask runs.
### Timestamps
`as_of_timestamp`, `start_timestamp`, and `end_timestamp` accept:
- Hudi timeline format (highest precedence): `yyyyMMddHHmmssSSS` or `yyyyMMddHHmmss`.
- Unix epoch in seconds, milliseconds, microseconds, or nanoseconds.
- RFC 3339 with timezone offset: `2024-03-15T14:25:30Z`, `2024-03-15T14:25:30+00:00`, `2024-03-15T14:25:30.123Z`.
A timezone offset (`Z` or `±HH:MM`) is required for RFC 3339 inputs — naive `T`-separated strings and date-only strings are rejected. Inputs are normalized into `hoodie.table.timeline.timezone`.
### Empty results
A table with no completed commits yields empty `Vec` / `List` for eager reads and `get_file_slices`, and an empty stream for `read_stream`.
### Errors
- Unknown filter column: schema error before any rows are returned. `Table` paths validate up front; `FileGroupReader` paths validate strictly on the first batch.
- Unparseable filter value (e.g. `"abc"` against `Int64`): schema error at filter evaluation.
- I/O failures (missing files, permission errors, malformed parquet): read errors.
## 7. Stability and out of scope
Reader APIs documented here are the supported public surface as of this release. The `FileGroupReader` direct-paths APIs are still labeled experimental in the README; expect minor signature evolution before they finalize.
`compute_table_stats(options)` returns `(estimated_num_rows, estimated_total_byte_size)` for **snapshot queries only**. The estimates are derived from base file on-disk sizes in the metadata table (MDT), scaled by a compression ratio and average row size sampled from one Parquet footer. Returns `None` when:
- The MDT is not enabled. A fallback to full table file listing is intentionally omitted — scanning every file to compute planning stats would cost as much as the read itself. Tables that want snapshot stats should enable the metadata table.
- The base file format is non-Parquet or footer sampling fails.
- The query type is **incremental**. Commit metadata does not reliably carry base file sizes for all commit types — MOR delta commits record the log file size in `fileSizeInBytes`, not the base file size. A mix of COW/compaction commits (with base file sizes) and delta commits (without) would produce misleading partial stats, so `None` is returned unconditionally for incremental queries.
For I/O cost estimation (on-disk base + log file sizes), use `FileSlice::total_size_bytes()` instead.
`Table` / `HudiTable` only stores table configs (`HudiTableConfig`). Read configs (`HudiReadConfig`, keyed under `hoodie.read.*`) are filtered out during construction and flow exclusively through `ReadOptions` / `HudiReadOptions` per-call. `hudi_options()` on the table reflects the stored table configs, not any read configs the caller may have passed at construction.
Out of scope for this version: writer APIs, internal architecture (timeline parsing, log-record merging, metadata table layout), the full configuration key glossary (`HudiReadConfig` / `HudiTableConfig` members), and the DataFusion and C++ bindings — separate spec follow-ups.