Skip to content

Commit 328e18e

Browse files
authored
feat: support metadata table "snapshots" (#822)
1 parent 044750f commit 328e18e

File tree

8 files changed

+300
-4
lines changed

8 files changed

+300
-4
lines changed

Cargo.lock

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,4 @@ volo-thrift = "0.10"
101101
hive_metastore = "0.1"
102102
tera = "1"
103103
zstd = "0.13.2"
104+
expect-test = "1"

crates/iceberg/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ zstd = { workspace = true }
8686

8787
[dev-dependencies]
8888
ctor = { workspace = true }
89+
expect-test = { workspace = true }
8990
iceberg-catalog-memory = { workspace = true }
9091
iceberg_test_utils = { path = "../test_utils", features = ["tests"] }
9192
pretty_assertions = { workspace = true }

crates/iceberg/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ mod avro;
7373
pub mod io;
7474
pub mod spec;
7575

76+
pub mod metadata_scan;
7677
pub mod scan;
7778

7879
pub mod expr;
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
//! Metadata table api.
19+
20+
use std::sync::Arc;
21+
22+
use arrow_array::builder::{MapBuilder, PrimitiveBuilder, StringBuilder};
23+
use arrow_array::types::{Int64Type, TimestampMillisecondType};
24+
use arrow_array::RecordBatch;
25+
use arrow_schema::{DataType, Field, Schema, TimeUnit};
26+
27+
use crate::spec::TableMetadata;
28+
use crate::table::Table;
29+
use crate::Result;
30+
31+
/// Metadata table is used to inspect a table's history, snapshots, and other metadata as a table.
32+
///
33+
/// References:
34+
/// - <https://github.com/apache/iceberg/blob/ac865e334e143dfd9e33011d8cf710b46d91f1e5/core/src/main/java/org/apache/iceberg/MetadataTableType.java#L23-L39>
35+
/// - <https://iceberg.apache.org/docs/latest/spark-queries/#querying-with-sql>
36+
/// - <https://py.iceberg.apache.org/api/#inspecting-tables>
37+
#[derive(Debug)]
38+
pub struct MetadataTable(Table);
39+
40+
impl MetadataTable {
41+
/// Creates a new metadata scan.
42+
pub(super) fn new(table: Table) -> Self {
43+
Self(table)
44+
}
45+
46+
/// Get the snapshots table.
47+
pub fn snapshots(&self) -> SnapshotsTable {
48+
SnapshotsTable {
49+
metadata_table: self,
50+
}
51+
}
52+
53+
fn metadata(&self) -> &TableMetadata {
54+
self.0.metadata()
55+
}
56+
}
57+
58+
/// Snapshots table.
59+
pub struct SnapshotsTable<'a> {
60+
metadata_table: &'a MetadataTable,
61+
}
62+
63+
impl<'a> SnapshotsTable<'a> {
64+
/// Returns the schema of the snapshots table.
65+
pub fn schema(&self) -> Schema {
66+
Schema::new(vec![
67+
Field::new(
68+
"committed_at",
69+
DataType::Timestamp(TimeUnit::Millisecond, Some("+00:00".into())),
70+
false,
71+
),
72+
Field::new("snapshot_id", DataType::Int64, false),
73+
Field::new("parent_id", DataType::Int64, true),
74+
Field::new("operation", DataType::Utf8, false),
75+
Field::new("manifest_list", DataType::Utf8, false),
76+
Field::new(
77+
"summary",
78+
DataType::Map(
79+
Arc::new(Field::new(
80+
"entries",
81+
DataType::Struct(
82+
vec![
83+
Field::new("keys", DataType::Utf8, false),
84+
Field::new("values", DataType::Utf8, true),
85+
]
86+
.into(),
87+
),
88+
false,
89+
)),
90+
false,
91+
),
92+
false,
93+
),
94+
])
95+
}
96+
97+
/// Scans the snapshots table.
98+
pub fn scan(&self) -> Result<RecordBatch> {
99+
let mut committed_at =
100+
PrimitiveBuilder::<TimestampMillisecondType>::new().with_timezone("+00:00");
101+
let mut snapshot_id = PrimitiveBuilder::<Int64Type>::new();
102+
let mut parent_id = PrimitiveBuilder::<Int64Type>::new();
103+
let mut operation = StringBuilder::new();
104+
let mut manifest_list = StringBuilder::new();
105+
let mut summary = MapBuilder::new(None, StringBuilder::new(), StringBuilder::new());
106+
107+
for snapshot in self.metadata_table.metadata().snapshots() {
108+
committed_at.append_value(snapshot.timestamp_ms());
109+
snapshot_id.append_value(snapshot.snapshot_id());
110+
parent_id.append_option(snapshot.parent_snapshot_id());
111+
manifest_list.append_value(snapshot.manifest_list());
112+
operation.append_value(snapshot.summary().operation.as_str());
113+
for (key, value) in &snapshot.summary().additional_properties {
114+
summary.keys().append_value(key);
115+
summary.values().append_value(value);
116+
}
117+
summary.append(true)?;
118+
}
119+
120+
Ok(RecordBatch::try_new(Arc::new(self.schema()), vec![
121+
Arc::new(committed_at.finish()),
122+
Arc::new(snapshot_id.finish()),
123+
Arc::new(parent_id.finish()),
124+
Arc::new(operation.finish()),
125+
Arc::new(manifest_list.finish()),
126+
Arc::new(summary.finish()),
127+
])?)
128+
}
129+
}
130+
131+
#[cfg(test)]
132+
mod tests {
133+
use expect_test::{expect, Expect};
134+
use itertools::Itertools;
135+
136+
use super::*;
137+
use crate::scan::tests::TableTestFixture;
138+
139+
/// Snapshot testing to check the resulting record batch.
140+
///
141+
/// - `expected_schema/data`: put `expect![[""]]` as a placeholder,
142+
/// and then run test with `UPDATE_EXPECT=1 cargo test` to automatically update the result,
143+
/// or use rust-analyzer (see [video](https://github.com/rust-analyzer/expect-test)).
144+
/// Check the doc of [`expect_test`] for more details.
145+
/// - `ignore_check_columns`: Some columns are not stable, so we can skip them.
146+
/// - `sort_column`: The order of the data might be non-deterministic, so we can sort it by a column.
147+
fn check_record_batch(
148+
record_batch: RecordBatch,
149+
expected_schema: Expect,
150+
expected_data: Expect,
151+
ignore_check_columns: &[&str],
152+
sort_column: Option<&str>,
153+
) {
154+
let mut columns = record_batch.columns().to_vec();
155+
if let Some(sort_column) = sort_column {
156+
let column = record_batch.column_by_name(sort_column).unwrap();
157+
let indices = arrow_ord::sort::sort_to_indices(column, None, None).unwrap();
158+
columns = columns
159+
.iter()
160+
.map(|column| arrow_select::take::take(column.as_ref(), &indices, None).unwrap())
161+
.collect_vec();
162+
}
163+
164+
expected_schema.assert_eq(&format!(
165+
"{}",
166+
record_batch.schema().fields().iter().format(",\n")
167+
));
168+
expected_data.assert_eq(&format!(
169+
"{}",
170+
record_batch
171+
.schema()
172+
.fields()
173+
.iter()
174+
.zip_eq(columns)
175+
.map(|(field, column)| {
176+
if ignore_check_columns.contains(&field.name().as_str()) {
177+
format!("{}: (skipped)", field.name())
178+
} else {
179+
format!("{}: {:?}", field.name(), column)
180+
}
181+
})
182+
.format(",\n")
183+
));
184+
}
185+
186+
#[test]
187+
fn test_snapshots_table() {
188+
let table = TableTestFixture::new().table;
189+
let record_batch = table.metadata_table().snapshots().scan().unwrap();
190+
check_record_batch(
191+
record_batch,
192+
expect![[r#"
193+
Field { name: "committed_at", data_type: Timestamp(Millisecond, Some("+00:00")), nullable: false, dict_id: 0, dict_is_ordered: false, metadata: {} },
194+
Field { name: "snapshot_id", data_type: Int64, nullable: false, dict_id: 0, dict_is_ordered: false, metadata: {} },
195+
Field { name: "parent_id", data_type: Int64, nullable: true, dict_id: 0, dict_is_ordered: false, metadata: {} },
196+
Field { name: "operation", data_type: Utf8, nullable: false, dict_id: 0, dict_is_ordered: false, metadata: {} },
197+
Field { name: "manifest_list", data_type: Utf8, nullable: false, dict_id: 0, dict_is_ordered: false, metadata: {} },
198+
Field { name: "summary", data_type: Map(Field { name: "entries", data_type: Struct([Field { name: "keys", data_type: Utf8, nullable: false, dict_id: 0, dict_is_ordered: false, metadata: {} }, Field { name: "values", data_type: Utf8, nullable: true, dict_id: 0, dict_is_ordered: false, metadata: {} }]), nullable: false, dict_id: 0, dict_is_ordered: false, metadata: {} }, false), nullable: false, dict_id: 0, dict_is_ordered: false, metadata: {} }"#]],
199+
expect![[r#"
200+
committed_at: PrimitiveArray<Timestamp(Millisecond, Some("+00:00"))>
201+
[
202+
2018-01-04T21:22:35.770+00:00,
203+
2019-04-12T20:29:15.770+00:00,
204+
],
205+
snapshot_id: PrimitiveArray<Int64>
206+
[
207+
3051729675574597004,
208+
3055729675574597004,
209+
],
210+
parent_id: PrimitiveArray<Int64>
211+
[
212+
null,
213+
3051729675574597004,
214+
],
215+
operation: StringArray
216+
[
217+
"append",
218+
"append",
219+
],
220+
manifest_list: (skipped),
221+
summary: MapArray
222+
[
223+
StructArray
224+
-- validity:
225+
[
226+
]
227+
[
228+
-- child 0: "keys" (Utf8)
229+
StringArray
230+
[
231+
]
232+
-- child 1: "values" (Utf8)
233+
StringArray
234+
[
235+
]
236+
],
237+
StructArray
238+
-- validity:
239+
[
240+
]
241+
[
242+
-- child 0: "keys" (Utf8)
243+
StringArray
244+
[
245+
]
246+
-- child 1: "values" (Utf8)
247+
StringArray
248+
[
249+
]
250+
],
251+
]"#]],
252+
&["manifest_list"],
253+
Some("committed_at"),
254+
);
255+
}
256+
}

crates/iceberg/src/scan.rs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -961,7 +961,7 @@ impl FileScanTask {
961961
}
962962

963963
#[cfg(test)]
964-
mod tests {
964+
pub mod tests {
965965
use std::collections::HashMap;
966966
use std::fs;
967967
use std::fs::File;
@@ -990,13 +990,14 @@ mod tests {
990990
use crate::table::Table;
991991
use crate::TableIdent;
992992

993-
struct TableTestFixture {
993+
pub struct TableTestFixture {
994994
table_location: String,
995-
table: Table,
995+
pub table: Table,
996996
}
997997

998998
impl TableTestFixture {
999-
fn new() -> Self {
999+
#[allow(clippy::new_without_default)]
1000+
pub fn new() -> Self {
10001001
let tmp_dir = TempDir::new().unwrap();
10011002
let table_location = tmp_dir.path().join("table1");
10021003
let manifest_list1_location = table_location.join("metadata/manifests_list_1.avro");

crates/iceberg/src/spec/snapshot.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,18 @@ pub enum Operation {
5252
Delete,
5353
}
5454

55+
impl Operation {
56+
/// Returns the string representation (lowercase) of the operation.
57+
pub fn as_str(&self) -> &str {
58+
match self {
59+
Operation::Append => "append",
60+
Operation::Replace => "replace",
61+
Operation::Overwrite => "overwrite",
62+
Operation::Delete => "delete",
63+
}
64+
}
65+
}
66+
5567
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)]
5668
/// Summarises the changes in the snapshot.
5769
pub struct Summary {

crates/iceberg/src/table.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ use std::sync::Arc;
2222
use crate::arrow::ArrowReaderBuilder;
2323
use crate::io::object_cache::ObjectCache;
2424
use crate::io::FileIO;
25+
use crate::metadata_scan::MetadataTable;
2526
use crate::scan::TableScanBuilder;
2627
use crate::spec::{TableMetadata, TableMetadataRef};
2728
use crate::{Error, ErrorKind, Result, TableIdent};
@@ -200,6 +201,12 @@ impl Table {
200201
TableScanBuilder::new(self)
201202
}
202203

204+
/// Creates a metadata table which provides table-like APIs for inspecting metadata.
205+
/// See [`MetadataTable`] for more details.
206+
pub fn metadata_table(self) -> MetadataTable {
207+
MetadataTable::new(self)
208+
}
209+
203210
/// Returns the flag indicating whether the `Table` is readonly or not
204211
pub fn readonly(&self) -> bool {
205212
self.readonly

0 commit comments

Comments
 (0)