This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-rust.git
The following commit(s) were added to refs/heads/main by this push:
new 5370b6163 feat(arrow): read manifest lists into Arrow with arrow-avro
(#3631)
5370b6163 is described below
commit 5370b61638e205d7caf97889affe5da63aea9b16
Author: Matt Butrovich <[email protected]>
AuthorDate: Fri Nov 6 18:05:35 2026 +0000
feat(arrow): read manifest lists into Arrow with arrow-avro (#3631)
* feat(arrow): read manifest lists into Arrow with arrow-avro
* refactor(spec): define manifest field IDs next to their fields.
* fix(arrow): reject unknown manifest list format versions
---
crates/iceberg/public-api.txt | 1 +
crates/iceberg/src/arrow/manifest.rs | 635 ++++++++++++++++++---
crates/iceberg/src/arrow/mod.rs | 2 +-
crates/iceberg/src/spec/manifest/entry.rs | 37 +-
.../src/spec/manifest_list/_const_schema.rs | 43 +-
crates/iceberg/src/spec/manifest_list/mod.rs | 4 +
6 files changed, 627 insertions(+), 95 deletions(-)
diff --git a/crates/iceberg/public-api.txt b/crates/iceberg/public-api.txt
index 0ba3b6ed7..1c5af7cae 100644
--- a/crates/iceberg/public-api.txt
+++ b/crates/iceberg/public-api.txt
@@ -132,6 +132,7 @@ pub fn
iceberg::arrow::arrow_struct_to_literal(struct_array: &arrow_array::array
pub fn iceberg::arrow::arrow_type_to_type(ty:
&arrow_schema::datatype::DataType) -> iceberg::Result<iceberg::spec::Type>
pub fn iceberg::arrow::datum_to_arrow_type_with_ree(datum:
&iceberg::spec::Datum) -> arrow_schema::datatype::DataType
pub fn iceberg::arrow::read_manifest_entries(bytes: &[u8], manifest_file:
&iceberg::spec::ManifestFile) ->
iceberg::Result<arrow_array::record_batch::RecordBatch>
+pub fn iceberg::arrow::read_manifest_list(bytes: &[u8]) ->
iceberg::Result<arrow_array::record_batch::RecordBatch>
pub fn iceberg::arrow::schema_to_arrow_schema(schema: &iceberg::spec::Schema)
-> iceberg::Result<arrow_schema::schema::Schema>
pub fn iceberg::arrow::strip_metadata_from_schema(schema:
&arrow_schema::schema::Schema) -> iceberg::Result<arrow_schema::schema::Schema>
pub fn iceberg::arrow::type_to_arrow_type(ty: &iceberg::spec::Type) ->
iceberg::Result<arrow_schema::datatype::DataType>
diff --git a/crates/iceberg/src/arrow/manifest.rs
b/crates/iceberg/src/arrow/manifest.rs
index 57bb2402a..08bd9ed4c 100644
--- a/crates/iceberg/src/arrow/manifest.rs
+++ b/crates/iceberg/src/arrow/manifest.rs
@@ -15,7 +15,7 @@
// specific language governing permissions and limitations
// under the License.
-//! Reading manifest entries into Arrow.
+//! Reading manifests and manifest lists into Arrow.
use std::collections::HashMap;
use std::sync::Arc;
@@ -28,7 +28,7 @@ use arrow_array::{
Array, ArrayRef, BooleanArray, Int32Array, Int64Array, ListArray,
MapArray, RecordBatch,
StructArray, new_null_array,
};
-use arrow_avro::reader::ReaderBuilder;
+use arrow_avro::reader::{Reader, ReaderBuilder};
use arrow_avro::schema::AvroSchema;
use arrow_cast::{CastOptions, cast_with_options};
use arrow_ord::cmp::eq;
@@ -40,22 +40,14 @@ use serde_json::Value as JsonValue;
use crate::arrow::schema_to_arrow_schema;
use crate::error::invalid_data;
use crate::spec::{
- FormatVersion, ManifestContentType, ManifestFile, ManifestMetadata,
ManifestStatus, Schema,
- manifest_entry_fields,
+ DATA_FILE_CONTENT_FIELD_ID, DATA_FILE_FIELD_ID,
DATA_FILE_PARTITION_FIELD_ID,
+ FILE_SEQUENCE_NUMBER_FIELD_ID, FormatVersion, MANIFEST_CONTENT_FIELD_ID,
+ MANIFEST_SEQUENCE_NUMBER_FIELD_ID, MIN_SEQUENCE_NUMBER_FIELD_ID,
ManifestContentType,
+ ManifestFile, ManifestMetadata, ManifestStatus, SEQUENCE_NUMBER_FIELD_ID,
Schema,
+ manifest_entry_fields, manifest_file_fields,
};
use crate::{Error, Result};
-/// Field ID of `data_file`.
-const DATA_FILE_FIELD_ID: i64 = 2;
-/// Field ID of `data_file.partition`.
-const PARTITION_FIELD_ID: i64 = 102;
-/// Field ID of `data_file.content`.
-const CONTENT_FIELD_ID: i32 = 134;
-/// Field ID of `sequence_number`.
-const SEQUENCE_NUMBER_FIELD_ID: i32 = 3;
-/// Field ID of `file_sequence_number`.
-const FILE_SEQUENCE_NUMBER_FIELD_ID: i32 = 4;
-
/// Reads the entries of a manifest file into a [`RecordBatch`] with one row
/// per entry.
///
@@ -142,30 +134,128 @@ pub fn read_manifest_entries(bytes: &[u8],
manifest_file: &ManifestFile) -> Resu
.map_err(invalid_manifest)?;
}
- let Some(batch) = reader.next() else {
+ let Some(batch) = only_batch(reader, invalid_manifest)? else {
return Ok(RecordBatch::new_empty(schema));
};
- let batch = batch.map_err(invalid_manifest)?;
- // Taking only the first batch relies on arrow-avro filling it to the batch
- // size, so fail instead of dropping entries if that changes.
- if reader.next().is_some() {
- return Err(invalid_data!(
- "arrow-avro split the manifest into more than one batch"
- ));
- }
+ // v1 manifests have no content or sequence number columns, which v2+
+ // readers read as 0. A v2+ manifest may lack file_sequence_number (Java
+ // wrote none before 1.1.0), which reads as null and is inherited.
+ let zero_defaults: &[i32] = match metadata.format_version() {
+ FormatVersion::V1 => &[
+ DATA_FILE_CONTENT_FIELD_ID,
+ SEQUENCE_NUMBER_FIELD_ID,
+ FILE_SEQUENCE_NUMBER_FIELD_ID,
+ ],
+ _ => &[DATA_FILE_CONTENT_FIELD_ID],
+ };
let entries = project_struct(
&StructArray::from(batch),
&avro_schema,
schema.fields(),
- *metadata.format_version(),
+ zero_defaults,
)?;
Ok(RecordBatch::from(inherit(entries, manifest_file)?))
}
+/// Reads the entries of a manifest list into a [`RecordBatch`] with one row
+/// per manifest.
+///
+/// The schema is the spec's `manifest_file` struct for the latest format
+/// version, with field IDs in the Arrow field metadata, so manifest lists of
+/// every format version read into the same schema. Fields are matched to the
+/// manifest list's Avro schema by field ID. The format version comes from the
+/// `format-version` key that writers put in the file's metadata, and a
+/// manifest list without one is v1. A v1 manifest list reads with the spec's
+/// v2+ defaults. Partition summary bounds keep the binary single-value
+/// serialization they were written with.
+///
+/// # Example
+///
+/// ```
+/// use iceberg::arrow::read_manifest_list;
+///
+/// let bytes = std::fs::read(concat!(
+/// env!("CARGO_MANIFEST_DIR"),
+/// "/testdata/manifests_lists/manifest-list-v2-2.avro"
+/// ))?;
+/// let batch = read_manifest_list(&bytes)?;
+/// assert_eq!(batch.num_rows(), 1);
+/// # Ok::<(), Box<dyn std::error::Error>>(())
+/// ```
+pub fn read_manifest_list(bytes: &[u8]) -> Result<RecordBatch> {
+ // usize::MAX decodes the whole manifest list into one batch, which
+ // `only_batch` checks.
+ let reader = ReaderBuilder::new()
+ .with_batch_size(usize::MAX)
+ .build(bytes)
+ .map_err(invalid_manifest_list)?;
+ let header = reader.avro_header();
+ let avro_schema: JsonValue = serde_json::from_slice(
+ header
+ .get("avro.schema")
+ .ok_or_else(|| invalid_data!("manifest list has no avro.schema"))?,
+ )?;
+ // The spec doesn't define this key, but Java, PyIceberg, and iceberg-rust
+ // all write it, and rejecting an unknown version keeps a newer manifest
+ // list from reading as an older one.
+ let format_version = match header.get("format-version") {
+ Some(bytes) =>
serde_json::from_slice::<FormatVersion>(bytes).map_err(|err| {
+ invalid_data!("failed to parse format version in manifest list
metadata")
+ .with_source(err)
+ })?,
+ None => FormatVersion::V1,
+ };
+ let schema = Arc::new(schema_to_arrow_schema(
+ &Schema::builder()
+ .with_fields(manifest_file_fields())
+ .build()?,
+ )?);
+ let Some(batch) = only_batch(reader, invalid_manifest_list)? else {
+ return Ok(RecordBatch::new_empty(schema));
+ };
+ // v1 manifest lists have no content or sequence number columns, which v2+
+ // readers read as 0.
+ let zero_defaults: &[i32] = match format_version {
+ FormatVersion::V1 => &[
+ MANIFEST_CONTENT_FIELD_ID,
+ MANIFEST_SEQUENCE_NUMBER_FIELD_ID,
+ MIN_SEQUENCE_NUMBER_FIELD_ID,
+ ],
+ _ => &[],
+ };
+ let manifests = project_struct(
+ &StructArray::from(batch),
+ &avro_schema,
+ schema.fields(),
+ zero_defaults,
+ )?;
+ Ok(RecordBatch::from(manifests))
+}
+
+/// Returns the only batch that `reader` decodes, or `None` for a file without
+/// records.
+fn only_batch(
+ mut reader: Reader<&[u8]>,
+ invalid: fn(ArrowError) -> Error,
+) -> Result<Option<RecordBatch>> {
+ let Some(batch) = reader.next() else {
+ return Ok(None);
+ };
+ let batch = batch.map_err(invalid)?;
+ // Taking only the first batch relies on arrow-avro filling it to the batch
+ // size, so fail instead of dropping records if that changes.
+ if reader.next().is_some() {
+ return Err(invalid_data!(
+ "arrow-avro split the file into more than one batch"
+ ));
+ }
+ Ok(Some(batch))
+}
+
/// Removes `data_file.partition` from a manifest's Avro schema if it has no
/// fields, and returns whether it did.
fn remove_empty_partition(avro_schema: &mut JsonValue) -> bool {
- let has_field_id = |field: &JsonValue, id: i64| field["field-id"].as_i64()
== Some(id);
+ let has_field_id = |field: &JsonValue, id: i32| field["field-id"].as_i64()
== Some(id.into());
let Some(data_file_fields) = avro_schema["fields"]
.as_array_mut()
.and_then(|fields| {
@@ -179,7 +269,7 @@ fn remove_empty_partition(avro_schema: &mut JsonValue) ->
bool {
};
let len = data_file_fields.len();
data_file_fields.retain(|field| {
- !(has_field_id(field, PARTITION_FIELD_ID)
+ !(has_field_id(field, DATA_FILE_PARTITION_FIELD_ID)
&& field["type"]["fields"]
.as_array()
.is_some_and(Vec::is_empty))
@@ -191,8 +281,17 @@ fn invalid_manifest(err: ArrowError) -> Error {
invalid_data!("failed to read manifest").with_source(err)
}
+fn invalid_manifest_list(err: ArrowError) -> Error {
+ invalid_data!("failed to read manifest list").with_source(err)
+}
+
+fn invalid_column(err: ArrowError) -> Error {
+ invalid_data!("failed to build Arrow column from Avro").with_source(err)
+}
+
/// Returns the field ID of a field in the schema built from
-/// `manifest_entry_fields`, which gives every field one.
+/// `manifest_entry_fields` or `manifest_file_fields`, which give every field
+/// one.
fn field_id(field: &Field) -> i32 {
field.metadata()[PARQUET_FIELD_ID_META_KEY].parse().unwrap()
}
@@ -210,16 +309,17 @@ fn non_null_type(avro_type: &JsonValue) -> &JsonValue {
}
/// Builds each of `fields` from the column of `array` that the Avro record
-/// `record` gives the same field ID.
+/// `record` gives the same field ID. A field that `record` lacks reads as 0 if
+/// its ID is in `zero_defaults`, and as null otherwise.
fn project_struct(
array: &StructArray,
record: &JsonValue,
fields: &Fields,
- format_version: FormatVersion,
+ zero_defaults: &[i32],
) -> Result<StructArray> {
let avro_fields = record["fields"]
.as_array()
- .ok_or_else(|| invalid_data!("manifest Avro type {record} is not a
record"))?;
+ .ok_or_else(|| invalid_data!("Avro type {record} is not a record"))?;
let mut columns = Vec::with_capacity(fields.len());
for field in fields {
let id = field_id(field);
@@ -232,34 +332,29 @@ fn project_struct(
array.column(index),
non_null_type(&avro_fields[index]["type"]),
field,
- format_version,
+ zero_defaults,
)?,
- None => missing_field(field, array.len(), format_version)?,
+ None => missing_field(field, array.len(), zero_defaults)?,
},
);
}
StructArray::try_new_with_length(fields.clone(), columns,
array.nulls().cloned(), array.len())
- .map_err(invalid_manifest)
+ .map_err(invalid_column)
}
-/// Builds the column for a field that the manifest doesn't have.
-fn missing_field(field: &Field, len: usize, format_version: FormatVersion) ->
Result<ArrayRef> {
- // v1 manifests have no content or sequence number columns, which v2+
- // readers read as 0. A v2+ manifest may lack file_sequence_number (Java
- // wrote none before 1.1.0), which reads as null and is inherited.
- match field_id(field) {
- CONTENT_FIELD_ID => Ok(Arc::new(Int32Array::from_value(0, len))),
- SEQUENCE_NUMBER_FIELD_ID | FILE_SEQUENCE_NUMBER_FIELD_ID
- if format_version == FormatVersion::V1 =>
- {
- Ok(Arc::new(Int64Array::from_value(0, len)))
- }
+/// Builds the column for a field that the Avro file doesn't have.
+fn missing_field(field: &Field, len: usize, zero_defaults: &[i32]) ->
Result<ArrayRef> {
+ let id = field_id(field);
+ let zero = zero_defaults.contains(&id);
+ match field.data_type() {
+ DataType::Int32 if zero => Ok(Arc::new(Int32Array::from_value(0,
len))),
+ DataType::Int64 if zero => Ok(Arc::new(Int64Array::from_value(0,
len))),
_ if field.is_nullable() => Ok(new_null_array(field.data_type(), len)),
- _ if matches!(field.data_type(), DataType::Struct(fields) if
fields.is_empty()) => {
+ DataType::Struct(fields) if fields.is_empty() => {
Ok(Arc::new(StructArray::new_empty_fields(len, None)))
}
- id => Err(invalid_data!(
- "manifest has no required field {} with field ID {id}",
+ _ => Err(invalid_data!(
+ "Avro file has no required field {} with field ID {id}",
field.name()
)),
}
@@ -271,11 +366,11 @@ fn project(
array: &ArrayRef,
avro_type: &JsonValue,
field: &Field,
- format_version: FormatVersion,
+ zero_defaults: &[i32],
) -> Result<ArrayRef> {
let type_mismatch = || {
invalid_data!(
- "manifest field {} has Avro type {avro_type}, which doesn't match
{}",
+ "field {} has Avro type {avro_type}, which doesn't match {}",
field.name(),
field.data_type()
)
@@ -285,7 +380,7 @@ fn project(
array.as_struct_opt().ok_or_else(type_mismatch)?,
avro_type,
fields,
- format_version,
+ zero_defaults,
)?),
DataType::List(element) => {
let list = array.as_list_opt::<i32>().ok_or_else(type_mismatch)?;
@@ -293,7 +388,7 @@ fn project(
list.values(),
non_null_type(&avro_type["items"]),
element,
- format_version,
+ zero_defaults,
)?;
Arc::new(
ListArray::try_new(
@@ -302,7 +397,7 @@ fn project(
values,
list.nulls().cloned(),
)
- .map_err(invalid_manifest)?,
+ .map_err(invalid_column)?,
)
}
// Avro stores maps with int keys as arrays of key-value records.
@@ -315,7 +410,7 @@ fn project(
list.values().as_struct_opt().ok_or_else(type_mismatch)?,
&avro_type["items"],
key_value,
- format_version,
+ zero_defaults,
)?;
Arc::new(
MapArray::try_new(
@@ -325,7 +420,7 @@ fn project(
list.nulls().cloned(),
false,
)
- .map_err(invalid_manifest)?,
+ .map_err(invalid_column)?,
)
}
// arrow-avro gives every timestamp a UTC time zone, so only the type
@@ -351,7 +446,7 @@ fn project(
safe: false,
..Default::default()
})
- .map_err(invalid_manifest)?,
+ .map_err(invalid_column)?,
})
}
@@ -477,16 +572,18 @@ mod tests {
FixedSizeBinaryArray, Float32Array, Float64Array, Int32Array,
Int64Array, StringArray,
Time64MicrosecondArray, TimestampMicrosecondArray,
TimestampNanosecondArray,
};
+ use arrow_buffer::{NullBuffer, OffsetBuffer};
use super::*;
use crate::ErrorKind;
use crate::arrow::arrow_schema_to_schema;
use crate::io::FileIO;
use crate::spec::{
- DataContentType, DataFile, DataFileBuilder, DataFileFormat, Datum,
ListType, Literal,
- Manifest, ManifestEntry, ManifestStatus, ManifestWriter,
ManifestWriterBuilder, MapType,
- NestedField, PartitionSpec, PrimitiveLiteral, PrimitiveType, Schema,
Struct, StructType,
- Transform, Type,
+ DataContentType, DataFile, DataFileBuilder, DataFileFormat, Datum,
FieldSummary, ListType,
+ Literal, Manifest, ManifestEntry, ManifestList, ManifestListWriter,
ManifestStatus,
+ ManifestWriter, ManifestWriterBuilder, MapType, NestedField,
PartitionSpec,
+ PrimitiveLiteral, PrimitiveType, Schema, Struct, StructType,
Transform, Type,
+ UNASSIGNED_SEQUENCE_NUMBER,
};
fn table_schema() -> Schema {
@@ -629,6 +726,26 @@ mod tests {
))
}
+ /// Rewrites an Avro file with the user metadata `key` set to `value`, or
+ /// without it if `value` is `None`.
+ fn set_avro_metadata(bytes: &[u8], key: &str, value: Option<&str>) ->
Vec<u8> {
+ let reader = apache_avro::Reader::new(bytes).unwrap();
+ let schema = reader.writer_schema().clone();
+ let mut metadata = reader.user_metadata().clone();
+ match value {
+ Some(value) => metadata.insert(key.to_string(),
value.as_bytes().to_vec()),
+ None => metadata.remove(key),
+ };
+ let mut writer = apache_avro::Writer::new(&schema,
Vec::new()).unwrap();
+ for (key, value) in metadata {
+ writer.add_user_metadata(key, value).unwrap();
+ }
+ for record in reader {
+ writer.append_value(record.unwrap()).unwrap();
+ }
+ writer.into_inner().unwrap()
+ }
+
/// Rewrites a manifest with `codec`, `edit_schema` applied to its Avro
/// schema, and `edit_record` applied to each record.
fn rewrite_manifest(
@@ -698,6 +815,29 @@ mod tests {
}
}
+ /// Renames record fields in an Avro file's schema and records, keeping
+ /// their field IDs.
+ fn rename_fields(bytes: &[u8], renames: &[(&str, &str)]) -> Vec<u8> {
+ rewrite_manifest(
+ bytes,
+ Codec::Null,
+ |schema| {
+ for_each_avro_field(schema, &|field| {
+ for (from, to) in renames {
+ if field["name"] == *from {
+ field["name"] = (*to).into();
+ }
+ }
+ })
+ },
+ |record| {
+ for (from, to) in renames {
+ rename_avro_field(record, from, to);
+ }
+ },
+ )
+ }
+
#[tokio::test]
async fn test_schema_is_spec_manifest_entry() {
let spec = partition_spec(&[("i", Transform::Identity), ("s",
Transform::Truncate(2))]);
@@ -1304,25 +1444,10 @@ mod tests {
|writer| writer.add_file(file, 1).unwrap(),
)
.await;
- let renames = [("file_path", "path"), ("i_identity",
"renamed_partition")];
- let bytes = rewrite_manifest(
- &bytes,
- Codec::Null,
- |schema| {
- for_each_avro_field(schema, &|field| {
- for (from, to) in renames {
- if field["name"] == from {
- field["name"] = to.into();
- }
- }
- })
- },
- |record| {
- for (from, to) in renames {
- rename_avro_field(record, from, to);
- }
- },
- );
+ let bytes = rename_fields(&bytes, &[
+ ("file_path", "path"),
+ ("i_identity", "renamed_partition"),
+ ]);
let batch = read_manifest_entries(&bytes, &manifest_file).unwrap();
assert_eq!(
@@ -1419,4 +1544,354 @@ mod tests {
);
}
}
+
+ /// A manifest list entry that a manifest list writer for snapshot 1
+ /// assigns sequence numbers to.
+ fn listed_manifest(path: &str, content: ManifestContentType) ->
ManifestFile {
+ ManifestFile {
+ manifest_path: path.to_string(),
+ manifest_length: 100,
+ partition_spec_id: 0,
+ content,
+ sequence_number: UNASSIGNED_SEQUENCE_NUMBER,
+ min_sequence_number: UNASSIGNED_SEQUENCE_NUMBER,
+ added_snapshot_id: 1,
+ added_files_count: Some(1),
+ existing_files_count: Some(2),
+ deleted_files_count: Some(3),
+ added_rows_count: Some(10),
+ existing_rows_count: Some(20),
+ deleted_rows_count: Some(30),
+ partitions: None,
+ key_metadata: None,
+ first_row_id: None,
+ }
+ }
+
+ /// Writes a manifest list for snapshot 1 with sequence number 5 and, in
+ /// v3, first row ID 100.
+ async fn write_manifest_list(version: FormatVersion, manifests:
Vec<ManifestFile>) -> Vec<u8> {
+ let io = FileIO::new_with_memory();
+ let path = "memory:///manifest-list.avro";
+ let output = io.new_output(path).unwrap().writer().await.unwrap();
+ let mut writer = match version {
+ FormatVersion::V1 => ManifestListWriter::v1(output, 1, None),
+ FormatVersion::V2 => ManifestListWriter::v2(output, 1, None, 5),
+ FormatVersion::V3 => ManifestListWriter::v3(output, 1, None, 5,
Some(100)),
+ };
+ writer.add_manifests(manifests.into_iter()).unwrap();
+ writer.close().await.unwrap();
+ io.new_input(path).unwrap().read().await.unwrap().to_vec()
+ }
+
+ #[tokio::test]
+ async fn test_schema_is_spec_manifest_file() {
+ let bytes = write_manifest_list(FormatVersion::V3, vec![]).await;
+ let batch = read_manifest_list(&bytes).unwrap();
+ assert_eq!(batch.num_rows(), 0);
+
+ let long = || Type::Primitive(PrimitiveType::Long);
+ let int = || Type::Primitive(PrimitiveType::Int);
+ let boolean = || Type::Primitive(PrimitiveType::Boolean);
+ let binary = || Type::Primitive(PrimitiveType::Binary);
+ let field_summary = StructType::new(vec![
+ NestedField::required(509, "contains_null", boolean()).into(),
+ NestedField::optional(518, "contains_nan", boolean()).into(),
+ NestedField::optional(510, "lower_bound", binary()).into(),
+ NestedField::optional(511, "upper_bound", binary()).into(),
+ ]);
+ let expected = Schema::builder()
+ .with_fields(vec![
+ NestedField::required(500, "manifest_path",
Type::Primitive(PrimitiveType::String))
+ .into(),
+ NestedField::required(501, "manifest_length", long()).into(),
+ NestedField::required(502, "partition_spec_id", int()).into(),
+ NestedField::required(517, "content", int()).into(),
+ NestedField::required(515, "sequence_number", long()).into(),
+ NestedField::required(516, "min_sequence_number",
long()).into(),
+ NestedField::required(503, "added_snapshot_id", long()).into(),
+ NestedField::optional(504, "added_files_count", int()).into(),
+ NestedField::optional(505, "existing_files_count",
int()).into(),
+ NestedField::optional(506, "deleted_files_count",
int()).into(),
+ NestedField::optional(512, "added_rows_count", long()).into(),
+ NestedField::optional(513, "existing_rows_count",
long()).into(),
+ NestedField::optional(514, "deleted_rows_count",
long()).into(),
+ NestedField::optional(
+ 507,
+ "partitions",
+ Type::List(ListType::new(
+ NestedField::list_element(508,
Type::Struct(field_summary), true).into(),
+ )),
+ )
+ .into(),
+ NestedField::optional(519, "key_metadata", binary()).into(),
+ NestedField::optional(520, "first_row_id", long()).into(),
+ ])
+ .build()
+ .unwrap();
+ assert_eq!(
+ arrow_schema_to_schema(&batch.schema()).unwrap().as_struct(),
+ expected.as_struct()
+ );
+ }
+
+ #[tokio::test]
+ async fn test_manifest_list_columns() {
+ let data = ManifestFile {
+ partitions: Some(vec![FieldSummary {
+ contains_null: true,
+ contains_nan: Some(false),
+ lower_bound: Some(Datum::int(1).to_bytes().unwrap()),
+ upper_bound: Some(Datum::int(9).to_bytes().unwrap()),
+ }]),
+ key_metadata: Some(vec![1, 2]),
+ ..listed_manifest("s3://b/metadata/data.avro",
ManifestContentType::Data)
+ };
+ let existing_deletes = ManifestFile {
+ partition_spec_id: 1,
+ sequence_number: 3,
+ min_sequence_number: 2,
+ added_snapshot_id: 0,
+ added_files_count: Some(0),
+ ..listed_manifest("s3://b/metadata/deletes.avro",
ManifestContentType::Deletes)
+ };
+ let bytes = write_manifest_list(FormatVersion::V3, vec![data,
existing_deletes]).await;
+ let batch = read_manifest_list(&bytes).unwrap();
+
+ let schema = batch.schema();
+ let DataType::List(summary) =
schema.field_with_name("partitions").unwrap().data_type()
+ else {
+ panic!("partitions is not a list");
+ };
+ let DataType::Struct(summary_fields) = summary.data_type() else {
+ panic!("partitions element is not a struct");
+ };
+ let summaries = StructArray::new(
+ summary_fields.clone(),
+ vec![
+ Arc::new(BooleanArray::from(vec![true])),
+ Arc::new(BooleanArray::from(vec![Some(false)])),
+ Arc::new(BinaryArray::from(vec![&1i32.to_le_bytes()[..]])),
+ Arc::new(BinaryArray::from(vec![&9i32.to_le_bytes()[..]])),
+ ],
+ None,
+ );
+ let columns: Vec<ArrayRef> = vec![
+ Arc::new(StringArray::from(vec![
+ "s3://b/metadata/data.avro",
+ "s3://b/metadata/deletes.avro",
+ ])),
+ Arc::new(Int64Array::from(vec![100, 100])),
+ Arc::new(Int32Array::from(vec![0, 1])),
+ Arc::new(Int32Array::from(vec![0, 1])),
+ Arc::new(Int64Array::from(vec![5, 3])),
+ Arc::new(Int64Array::from(vec![5, 2])),
+ Arc::new(Int64Array::from(vec![1, 0])),
+ Arc::new(Int32Array::from(vec![1, 0])),
+ Arc::new(Int32Array::from(vec![2, 2])),
+ Arc::new(Int32Array::from(vec![3, 3])),
+ Arc::new(Int64Array::from(vec![10, 10])),
+ Arc::new(Int64Array::from(vec![20, 20])),
+ Arc::new(Int64Array::from(vec![30, 30])),
+ Arc::new(ListArray::new(
+ summary.clone(),
+ OffsetBuffer::from_lengths([1, 0]),
+ Arc::new(summaries),
+ Some(NullBuffer::from(vec![true, false])),
+ )),
+ Arc::new(BinaryArray::from(vec![Some(&[1u8, 2][..]), None])),
+ // The writer assigns the data manifest the snapshot's first row
ID.
+ Arc::new(Int64Array::from(vec![Some(100), None])),
+ ];
+ assert_eq!(batch, RecordBatch::try_new(schema, columns).unwrap());
+ }
+
+ #[tokio::test]
+ async fn test_v1_manifest_list_reads_with_v2_defaults() {
+ let unknown_counts = ManifestFile {
+ added_files_count: None,
+ existing_files_count: None,
+ deleted_files_count: None,
+ added_rows_count: None,
+ existing_rows_count: None,
+ deleted_rows_count: None,
+ ..listed_manifest("s3://b/metadata/1.avro",
ManifestContentType::Data)
+ };
+ let bytes = write_manifest_list(FormatVersion::V1, vec![
+ listed_manifest("s3://b/metadata/0.avro",
ManifestContentType::Data),
+ unknown_counts,
+ ])
+ .await;
+ // A manifest list without a format version reads as v1.
+ let without_format_version = set_avro_metadata(&bytes,
"format-version", None);
+ for bytes in [bytes, without_format_version] {
+ let batch = read_manifest_list(&bytes).unwrap();
+
+ assert_eq!(
+ column(&batch, "content").as_ref(),
+ &Int32Array::from(vec![0, 0]) as &dyn Array
+ );
+ for name in ["sequence_number", "min_sequence_number"] {
+ assert_eq!(
+ column(&batch, name).as_ref(),
+ &Int64Array::from(vec![0, 0]) as &dyn Array,
+ "{name}"
+ );
+ }
+ assert_eq!(
+ column(&batch, "added_files_count").as_ref(),
+ &Int32Array::from(vec![Some(1), None]) as &dyn Array
+ );
+ assert_eq!(
+ column(&batch, "deleted_rows_count").as_ref(),
+ &Int64Array::from(vec![Some(30), None]) as &dyn Array
+ );
+ assert_eq!(column(&batch, "first_row_id").null_count(), 2);
+ }
+ }
+
+ #[tokio::test]
+ async fn test_manifest_list_fields_are_matched_by_field_id() {
+ let manifest = ManifestFile {
+ partitions: Some(vec![FieldSummary {
+ contains_null: true,
+ contains_nan: None,
+ lower_bound: None,
+ upper_bound: None,
+ }]),
+ ..listed_manifest("s3://b/metadata/0.avro",
ManifestContentType::Data)
+ };
+ let bytes = write_manifest_list(FormatVersion::V2,
vec![manifest]).await;
+ let bytes = rename_fields(&bytes, &[
+ ("manifest_path", "path"),
+ ("added_files_count", "added_data_files_count"),
+ ("contains_null", "has_null"),
+ ]);
+ let batch = read_manifest_list(&bytes).unwrap();
+
+ assert_eq!(
+ column(&batch, "manifest_path").as_ref(),
+ &StringArray::from(vec!["s3://b/metadata/0.avro"]) as &dyn Array
+ );
+ assert_eq!(
+ column(&batch, "added_files_count").as_ref(),
+ &Int32Array::from(vec![1]) as &dyn Array
+ );
+ let partitions = column(&batch, "partitions");
+ assert_eq!(
+ partitions
+ .as_list::<i32>()
+ .value(0)
+ .as_struct()
+ .column(0)
+ .as_ref(),
+ &BooleanArray::from(vec![true]) as &dyn Array
+ );
+ }
+
+ #[tokio::test]
+ async fn test_invalid_manifest_list_is_an_error() {
+ let err = read_manifest_list(&[]).unwrap_err();
+ assert_eq!(err.kind(), ErrorKind::DataInvalid);
+
+ let bytes = write_manifest_list(FormatVersion::V2,
vec![listed_manifest(
+ "s3://b/metadata/0.avro",
+ ManifestContentType::Data,
+ )])
+ .await;
+ // A manifest_path under another field ID leaves the required
+ // manifest_path missing.
+ let moved_manifest_path = rewrite_manifest(
+ &bytes,
+ Codec::Null,
+ |schema| {
+ for_each_avro_field(schema, &|field| {
+ if field["name"] == "manifest_path" {
+ field["field-id"] = 999.into();
+ }
+ })
+ },
+ |_| {},
+ );
+ // Only v1 manifest lists read a missing sequence number as 0.
+ let without_sequence_number = rewrite_manifest(
+ &bytes,
+ Codec::Null,
+ |schema| {
+ schema["fields"]
+ .as_array_mut()
+ .unwrap()
+ .retain(|field| field["name"] != "sequence_number")
+ },
+ |record| {
+ let AvroValue::Record(fields) = record else {
+ panic!("manifest list entry is not a record");
+ };
+ fields.retain(|(name, _)| name != "sequence_number");
+ },
+ );
+ let unknown_format_version = set_avro_metadata(&bytes,
"format-version", Some("4"));
+ for bytes in [
+ moved_manifest_path,
+ without_sequence_number,
+ unknown_format_version,
+ ] {
+ let err = read_manifest_list(&bytes).unwrap_err();
+ assert_eq!(err.kind(), ErrorKind::DataInvalid);
+ }
+ }
+
+ #[test]
+ fn test_manifest_lists_written_by_java() {
+ // Written by Java 1.4.0, with the v1 file count names, and 1.5.0.
+ for name in ["manifest-list-v2-1.avro", "manifest-list-v2-2.avro"] {
+ let bytes = std::fs::read(format!(
+ "{}/testdata/manifests_lists/{name}",
+ env!("CARGO_MANIFEST_DIR")
+ ))
+ .unwrap();
+ let manifest_list =
+ ManifestList::parse_with_version(&bytes,
FormatVersion::V2).unwrap();
+ let batch = read_manifest_list(&bytes).unwrap();
+
+ assert_eq!(batch.num_rows(), manifest_list.entries().len(),
"{name}");
+ let paths = column(&batch, "manifest_path");
+ let sequence_numbers = column(&batch, "sequence_number");
+ let added_files_counts = column(&batch, "added_files_count");
+ let partitions = column(&batch, "partitions");
+ for (row, manifest) in manifest_list.entries().iter().enumerate() {
+ assert_eq!(
+ paths.as_string::<i32>().value(row),
+ manifest.manifest_path,
+ "{name}"
+ );
+ assert_eq!(
+ sequence_numbers.as_primitive::<Int64Type>().value(row),
+ manifest.sequence_number,
+ "{name}"
+ );
+ assert_eq!(
+ added_files_counts.as_primitive::<Int32Type>().value(row),
+ manifest.added_files_count.unwrap() as i32,
+ "{name}"
+ );
+ let summaries = partitions.as_list::<i32>().value(row);
+ let lower_bounds: Vec<_> = summaries
+ .as_struct()
+ .column_by_name("lower_bound")
+ .unwrap()
+ .as_binary::<i32>()
+ .iter()
+ .map(|bound| bound.map(<[u8]>::to_vec))
+ .collect();
+ let expected: Vec<_> = manifest
+ .partitions
+ .iter()
+ .flatten()
+ .map(|summary| summary.lower_bound.as_ref().map(|bound|
bound.to_vec()))
+ .collect();
+ assert_eq!(lower_bounds, expected, "{name}");
+ }
+ }
+ }
}
diff --git a/crates/iceberg/src/arrow/mod.rs b/crates/iceberg/src/arrow/mod.rs
index c74b20d7c..d8343ef1e 100644
--- a/crates/iceberg/src/arrow/mod.rs
+++ b/crates/iceberg/src/arrow/mod.rs
@@ -29,7 +29,7 @@ pub(crate) mod delete_filter;
mod int96;
mod manifest;
-pub use manifest::read_manifest_entries;
+pub use manifest::{read_manifest_entries, read_manifest_list};
mod reader;
/// RecordBatch projection utilities
pub mod record_batch_projector;
diff --git a/crates/iceberg/src/spec/manifest/entry.rs
b/crates/iceberg/src/spec/manifest/entry.rs
index bbd2965d5..c8ed0ea1b 100644
--- a/crates/iceberg/src/spec/manifest/entry.rs
+++ b/crates/iceberg/src/spec/manifest/entry.rs
@@ -177,6 +177,17 @@ impl TryFrom<i32> for ManifestStatus {
use super::DataFileFormat;
+/// Field ID of `manifest_entry.data_file`.
+pub(crate) const DATA_FILE_FIELD_ID: i32 = 2;
+/// Field ID of `manifest_entry.sequence_number`.
+pub(crate) const SEQUENCE_NUMBER_FIELD_ID: i32 = 3;
+/// Field ID of `manifest_entry.file_sequence_number`.
+pub(crate) const FILE_SEQUENCE_NUMBER_FIELD_ID: i32 = 4;
+/// Field ID of `data_file.partition`.
+pub(crate) const DATA_FILE_PARTITION_FIELD_ID: i32 = 102;
+/// Field ID of `data_file.content`.
+pub(crate) const DATA_FILE_CONTENT_FIELD_ID: i32 = 134;
+
static STATUS: Lazy<NestedFieldRef> = {
Lazy::new(|| {
Arc::new(NestedField::required(
@@ -210,7 +221,7 @@ static SNAPSHOT_ID_V2: Lazy<NestedFieldRef> = {
static SEQUENCE_NUMBER: Lazy<NestedFieldRef> = {
Lazy::new(|| {
Arc::new(NestedField::optional(
- 3,
+ SEQUENCE_NUMBER_FIELD_ID,
"sequence_number",
Type::Primitive(PrimitiveType::Long),
))
@@ -220,7 +231,7 @@ static SEQUENCE_NUMBER: Lazy<NestedFieldRef> = {
static FILE_SEQUENCE_NUMBER: Lazy<NestedFieldRef> = {
Lazy::new(|| {
Arc::new(NestedField::optional(
- 4,
+ FILE_SEQUENCE_NUMBER_FIELD_ID,
"file_sequence_number",
Type::Primitive(PrimitiveType::Long),
))
@@ -230,9 +241,13 @@ static FILE_SEQUENCE_NUMBER: Lazy<NestedFieldRef> = {
static CONTENT: Lazy<NestedFieldRef> = {
Lazy::new(|| {
Arc::new(
- NestedField::required(134, "content",
Type::Primitive(PrimitiveType::Int))
- // 0 refers to DataContentType::DATA
-
.with_initial_default(Literal::Primitive(PrimitiveLiteral::Int(0))),
+ NestedField::required(
+ DATA_FILE_CONTENT_FIELD_ID,
+ "content",
+ Type::Primitive(PrimitiveType::Int),
+ )
+ // 0 refers to DataContentType::DATA
+
.with_initial_default(Literal::Primitive(PrimitiveLiteral::Int(0))),
)
})
};
@@ -512,7 +527,7 @@ fn data_file_fields_v3(partition_type: &StructType) ->
Vec<NestedFieldRef> {
FILE_PATH.clone(),
FILE_FORMAT.clone(),
Arc::new(NestedField::required(
- 102,
+ DATA_FILE_PARTITION_FIELD_ID,
"partition",
Type::Struct(partition_type.clone()),
)),
@@ -546,7 +561,7 @@ pub(crate) fn manifest_entry_fields(partition_type:
&StructType) -> Vec<NestedFi
SEQUENCE_NUMBER.clone(),
FILE_SEQUENCE_NUMBER.clone(),
Arc::new(NestedField::required(
- 2,
+ DATA_FILE_FIELD_ID,
"data_file",
Type::Struct(StructType::new(data_file_fields_v3(partition_type))),
)),
@@ -566,7 +581,7 @@ fn data_file_fields_v2(partition_type: &StructType) ->
Vec<NestedFieldRef> {
FILE_PATH.clone(),
FILE_FORMAT.clone(),
Arc::new(NestedField::required(
- 102,
+ DATA_FILE_PARTITION_FIELD_ID,
"partition",
Type::Struct(partition_type.clone()),
)),
@@ -605,7 +620,7 @@ pub(super) fn manifest_schema_v2(partition_type:
&StructType) -> Result<AvroSche
SEQUENCE_NUMBER.clone(),
FILE_SEQUENCE_NUMBER.clone(),
Arc::new(NestedField::required(
- 2,
+ DATA_FILE_FIELD_ID,
"data_file",
Type::Struct(StructType::new(data_file_fields_v2(partition_type))),
)),
@@ -619,7 +634,7 @@ fn data_file_fields_v1(partition_type: &StructType) ->
Vec<NestedFieldRef> {
FILE_PATH.clone(),
FILE_FORMAT.clone(),
Arc::new(NestedField::required(
- 102,
+ DATA_FILE_PARTITION_FIELD_ID,
"partition",
Type::Struct(partition_type.clone()),
)),
@@ -650,7 +665,7 @@ pub(super) fn manifest_schema_v1(partition_type:
&StructType) -> Result<AvroSche
STATUS.clone(),
SNAPSHOT_ID_V1.clone(),
Arc::new(NestedField::required(
- 2,
+ DATA_FILE_FIELD_ID,
"data_file",
Type::Struct(StructType::new(data_file_fields_v1(partition_type))),
)),
diff --git a/crates/iceberg/src/spec/manifest_list/_const_schema.rs
b/crates/iceberg/src/spec/manifest_list/_const_schema.rs
index d72ead9a4..ae417f336 100644
--- a/crates/iceberg/src/spec/manifest_list/_const_schema.rs
+++ b/crates/iceberg/src/spec/manifest_list/_const_schema.rs
@@ -24,6 +24,13 @@ use once_cell::sync::Lazy;
use crate::avro::schema_to_avro_schema;
use crate::spec::{ListType, NestedField, NestedFieldRef, PrimitiveType,
Schema, StructType, Type};
+/// Field ID of `manifest_file.sequence_number`.
+pub(crate) const MANIFEST_SEQUENCE_NUMBER_FIELD_ID: i32 = 515;
+/// Field ID of `manifest_file.min_sequence_number`.
+pub(crate) const MIN_SEQUENCE_NUMBER_FIELD_ID: i32 = 516;
+/// Field ID of `manifest_file.content`.
+pub(crate) const MANIFEST_CONTENT_FIELD_ID: i32 = 517;
+
static MANIFEST_PATH: Lazy<NestedFieldRef> = {
Lazy::new(|| {
Arc::new(NestedField::required(
@@ -54,7 +61,7 @@ static PARTITION_SPEC_ID: Lazy<NestedFieldRef> = {
static CONTENT: Lazy<NestedFieldRef> = {
Lazy::new(|| {
Arc::new(NestedField::required(
- 517,
+ MANIFEST_CONTENT_FIELD_ID,
"content",
Type::Primitive(PrimitiveType::Int),
))
@@ -63,7 +70,7 @@ static CONTENT: Lazy<NestedFieldRef> = {
static SEQUENCE_NUMBER: Lazy<NestedFieldRef> = {
Lazy::new(|| {
Arc::new(NestedField::required(
- 515,
+ MANIFEST_SEQUENCE_NUMBER_FIELD_ID,
"sequence_number",
Type::Primitive(PrimitiveType::Long),
))
@@ -72,7 +79,7 @@ static SEQUENCE_NUMBER: Lazy<NestedFieldRef> = {
static MIN_SEQUENCE_NUMBER: Lazy<NestedFieldRef> = {
Lazy::new(|| {
Arc::new(NestedField::required(
- 516,
+ MIN_SEQUENCE_NUMBER_FIELD_ID,
"min_sequence_number",
Type::Primitive(PrimitiveType::Long),
))
@@ -318,6 +325,36 @@ static V3_SCHEMA: Lazy<Schema> = {
})
};
+/// Fields of the `manifest_file` struct in the latest format version, with the
+/// file and row counts optional as in v1, so a manifest list of any version
+/// fits them.
+pub(crate) fn manifest_file_fields() -> Vec<NestedFieldRef> {
+ let optional = |field: &NestedFieldRef| {
+ Arc::new(NestedField {
+ required: false,
+ ..field.as_ref().clone()
+ })
+ };
+ vec![
+ MANIFEST_PATH.clone(),
+ MANIFEST_LENGTH.clone(),
+ PARTITION_SPEC_ID.clone(),
+ CONTENT.clone(),
+ SEQUENCE_NUMBER.clone(),
+ MIN_SEQUENCE_NUMBER.clone(),
+ ADDED_SNAPSHOT_ID.clone(),
+ optional(&ADDED_FILES_COUNT_V2),
+ optional(&EXISTING_FILES_COUNT_V2),
+ optional(&DELETED_FILES_COUNT_V2),
+ optional(&ADDED_ROWS_COUNT_V2),
+ optional(&EXISTING_ROWS_COUNT_V2),
+ optional(&DELETED_ROWS_COUNT_V2),
+ PARTITIONS.clone(),
+ KEY_METADATA.clone(),
+ FIRST_ROW_ID.clone(),
+ ]
+}
+
pub(super) static MANIFEST_LIST_AVRO_SCHEMA_V1: Lazy<AvroSchema> =
Lazy::new(|| schema_to_avro_schema("manifest_file", &V1_SCHEMA).unwrap());
diff --git a/crates/iceberg/src/spec/manifest_list/mod.rs
b/crates/iceberg/src/spec/manifest_list/mod.rs
index 24a4debf9..8a5db38f3 100644
--- a/crates/iceberg/src/spec/manifest_list/mod.rs
+++ b/crates/iceberg/src/spec/manifest_list/mod.rs
@@ -31,6 +31,10 @@ pub use serde_bytes::ByteBuf;
pub use writer::*;
use self::_const_schema::MANIFEST_LIST_AVRO_SCHEMA_V1;
+pub(crate) use self::_const_schema::{
+ MANIFEST_CONTENT_FIELD_ID, MANIFEST_SEQUENCE_NUMBER_FIELD_ID,
MIN_SEQUENCE_NUMBER_FIELD_ID,
+ manifest_file_fields,
+};
use super::FormatVersion;
use crate::error::Result;