use std::sync::{Arc, LazyLock};
use crate::actions::{ADD_NAME, ADD_SCHEMA, LOG_ADD_SCHEMA};
use crate::content_tree::{
struct_expr_from_schema, ContentTreeNodeEntry, DataContentType, DataFileFormat, TrackingStatus,
CONTENT_TYPE, DV_INFO, DV_SNAPSHOT_ID, FILE_FORMAT, FILE_SIZE_IN_BYTES, FIRST_ROW_ID, LOCATION,
PARTITION_SPEC_ID, SEQUENCE_NUMBER, TRACKING, TRACKING_STATUS,
};
use crate::engine_data::{EngineData, FilteredEngineData, GetData, RowVisitor, TypedGetData as _};
use crate::expressions::{lit, ColumnName, Expression, MapData, Scalar};
use crate::scan::log_replay::{
BASE_ROW_ID_NAME, DATA_CHANGE_NAME, DEFAULT_ROW_COMMIT_VERSION_NAME, MODIFICATION_TIME_NAME,
PARTITION_VALUES_NAME, PATH_NAME, SIZE_NAME,
};
use crate::schema::{
ColumnNamesAndTypes, DataType, MapType, SchemaRef, StructField, ToSchema as _,
};
use crate::{Engine, ExpressionEvaluator, KernelError, KernelResult};
pub(crate) struct ReadContext {
pub(crate) modification_time: i64,
pub(crate) data_change: bool,
}
struct AddFieldSources {
base_row_id: Expression,
default_row_commit_version: Expression,
}
impl AddFieldSources {
fn root() -> Self {
Self {
base_row_id: Expression::column([TRACKING, FIRST_ROW_ID]),
default_row_commit_version: Expression::column([TRACKING, SEQUENCE_NUMBER]),
}
}
}
pub(crate) struct EntryConverter {
evaluator: Arc<dyn ExpressionEvaluator>,
}
impl EntryConverter {
pub(crate) fn convert(&self, entries: &dyn EngineData) -> KernelResult<FilteredEngineData> {
let mut selector = AddSelectionVisitor::default();
selector.visit_rows_of(entries)?;
let actions = self.evaluator.evaluate(entries)?;
FilteredEngineData::try_new(actions, selector.selection)
}
}
pub(crate) fn make_root_entry_converter(
engine: &dyn Engine,
ctx: &ReadContext,
) -> KernelResult<EntryConverter> {
make_entry_converter(
engine,
ctx,
&AddFieldSources::root(),
Arc::new(ContentTreeNodeEntry::to_schema()),
)
}
fn make_entry_converter(
engine: &dyn Engine,
ctx: &ReadContext,
sources: &AddFieldSources,
input_schema: SchemaRef,
) -> KernelResult<EntryConverter> {
let output_type = DataType::from(LOG_ADD_SCHEMA.as_ref().clone());
let expr = build_entry_to_add_expression(ctx, sources)?;
let evaluator = engine.evaluation_handler().new_expression_evaluator(
input_schema,
Arc::new(expr),
output_type,
)?;
Ok(EntryConverter { evaluator })
}
fn build_entry_to_add_expression(
ctx: &ReadContext,
sources: &AddFieldSources,
) -> KernelResult<Expression> {
let empty_partition_values = lit(Scalar::Map(MapData::try_new(
partition_values_map_type()?,
Vec::<(Scalar, Scalar)>::new(),
)?));
let add = struct_expr_from_schema(&ADD_SCHEMA, |name| {
Some(match name {
n if n == PATH_NAME => Expression::column([LOCATION]),
n if n == PARTITION_VALUES_NAME => empty_partition_values.clone(),
n if n == SIZE_NAME => Expression::column([FILE_SIZE_IN_BYTES]),
n if n == MODIFICATION_TIME_NAME => lit(ctx.modification_time),
n if n == DATA_CHANGE_NAME => lit(ctx.data_change),
n if n == BASE_ROW_ID_NAME => sources.base_row_id.clone(),
n if n == DEFAULT_ROW_COMMIT_VERSION_NAME => sources.default_row_commit_version.clone(),
_ => return None,
})
})?;
struct_expr_from_schema(&LOG_ADD_SCHEMA, |name| match name {
ADD_NAME => Some(add.clone()),
_ => None,
})
}
fn partition_values_map_type() -> KernelResult<MapType> {
match ADD_SCHEMA
.field(PARTITION_VALUES_NAME)
.map(StructField::data_type)
{
Some(DataType::Map(map)) => Ok(map.as_ref().clone()),
other => Err(KernelError::generic(format!(
"Add schema `{PARTITION_VALUES_NAME}` field is not a map: {other:?}"
))),
}
}
#[derive(Default)]
struct AddSelectionVisitor {
selection: Vec<bool>,
}
impl RowVisitor for AddSelectionVisitor {
fn selected_column_names_and_types(&self) -> (&'static [ColumnName], &'static [DataType]) {
static NAMES_AND_TYPES: LazyLock<ColumnNamesAndTypes> = LazyLock::new(|| {
(
vec![
ColumnName::new([CONTENT_TYPE]),
ColumnName::new([TRACKING, TRACKING_STATUS]),
ColumnName::new([LOCATION]),
ColumnName::new([FILE_FORMAT]),
ColumnName::new([PARTITION_SPEC_ID]),
ColumnName::new([DV_INFO, LOCATION]),
ColumnName::new([TRACKING, DV_SNAPSHOT_ID]),
],
vec![
DataType::INTEGER,
DataType::INTEGER,
DataType::STRING,
DataType::STRING,
DataType::INTEGER,
DataType::STRING,
DataType::LONG,
],
)
.into()
});
NAMES_AND_TYPES.as_ref()
}
fn visit<'a>(&mut self, row_count: usize, getters: &[&'a dyn GetData<'a>]) -> KernelResult<()> {
self.selection.reserve(row_count);
for row in 0..row_count {
let content_type = DataContentType::try_from_repr(getters[0].get(row, CONTENT_TYPE)?)?;
let status = TrackingStatus::try_from_repr(getters[1].get(row, TRACKING_STATUS)?)?;
let location: &str = getters[2].get(row, LOCATION)?;
let selected = match content_type {
DataContentType::Data => status.is_live(),
DataContentType::DataManifest => false,
DataContentType::PositionDeletes
| DataContentType::EqualityDeletes
| DataContentType::DeleteManifest => {
if status.is_live() {
return Err(KernelError::unsupported(format!(
"AMT content-tree read path does not yet support live \
{content_type:?} entry '{location}'"
)));
}
false
}
};
if selected {
check_selected_entry(row, location, getters)?;
}
self.selection.push(selected);
}
Ok(())
}
}
fn check_selected_entry<'a>(
row: usize,
location: &str,
getters: &[&'a dyn GetData<'a>],
) -> KernelResult<()> {
let file_format: &str = getters[3].get(row, FILE_FORMAT)?;
let parquet = DataFileFormat::Parquet.name();
if !file_format.eq_ignore_ascii_case(parquet) {
return Err(KernelError::unsupported(format!(
"AMT content-tree read path only supports {parquet} data files; entry '{location}' \
has file format '{file_format}'"
)));
}
let spec_id: Option<i32> = getters[4].get_opt(row, PARTITION_SPEC_ID)?;
if let Some(spec_id) = spec_id.filter(|&id| id != 0) {
return Err(KernelError::unsupported(format!(
"AMT content-tree read path does not yet support partition specs; entry \
'{location}' has {PARTITION_SPEC_ID} {spec_id}"
)));
}
let dv_location: Option<&str> = getters[5].get_opt(row, LOCATION)?;
let dv_snapshot_id: Option<i64> = getters[6].get_opt(row, DV_SNAPSHOT_ID)?;
if let Some(field) = dv_location
.map(|_| DV_INFO)
.or_else(|| dv_snapshot_id.map(|_| DV_SNAPSHOT_ID))
{
return Err(KernelError::unsupported(format!(
"AMT content-tree read path does not yet support a live entry with a deletion vector \
(entry '{location}' has non-null '{field}')"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
use crate::content_tree::{DeletionVectorInfo, ManifestInfo, TrackingInfo};
use crate::engine::arrow_conversion::TryFromArrow as _;
use crate::engine::arrow_data::EngineDataArrowExt as _;
use crate::engine::sync::SyncEngine;
use crate::expressions::StructData;
use crate::schema::StructType;
use crate::unit_test_utils::assert_result_error_with_message;
const AMT_FORMAT_VERSION: i32 = 4;
fn read_ctx() -> ReadContext {
ReadContext {
modification_time: 1234,
data_change: false,
}
}
fn convert_root_entries_to_add_actions(
engine: &dyn Engine,
entries: &dyn EngineData,
ctx: &ReadContext,
) -> KernelResult<FilteredEngineData> {
make_root_entry_converter(engine, ctx)?.convert(entries)
}
fn added_data_entry(
path: &str,
size: i64,
num_records: i64,
base_row_id: i64,
commit_version: i64,
) -> ContentTreeNodeEntry {
ContentTreeNodeEntry {
content_type: DataContentType::Data,
location: path.to_string(),
file_format: DataFileFormat::Parquet,
tracking: TrackingInfo {
status: TrackingStatus::Added,
snapshot_id: Some(1),
dv_snapshot_id: None,
sequence_number: Some(commit_version),
file_sequence_number: Some(commit_version),
first_row_id: Some(base_row_id),
deleted_positions: None,
replaced_positions: None,
},
deletion_vector: None,
spec_id: Some(0),
partition: None,
sort_order_id: None,
record_count: num_records,
file_size_in_bytes: size,
content_stats: None,
manifest_info: None,
key_metadata: None,
split_offsets: None,
equality_ids: None,
format_version: AMT_FORMAT_VERSION,
tags: None,
}
}
fn entry_batch(engine: &dyn Engine, entries: &[ContentTreeNodeEntry]) -> Box<dyn EngineData> {
let structs: Vec<StructData> = entries.iter().cloned().map(Into::into).collect();
struct_batch(engine, &structs)
}
fn struct_batch(engine: &dyn Engine, entries: &[StructData]) -> Box<dyn EngineData> {
let rows: Vec<Vec<Scalar>> = entries.iter().map(|s| s.values().to_vec()).collect();
engine
.evaluation_handler()
.create_many(Arc::new(ContentTreeNodeEntry::to_schema()), rows)
.unwrap()
}
fn with_field(data: &StructData, path: &[&str], value: Scalar) -> StructData {
let idx = data
.fields()
.iter()
.position(|f| f.name() == path[0])
.unwrap();
let mut values = data.values().to_vec();
values[idx] = match &path[1..] {
[] => value,
rest => match &values[idx] {
Scalar::Struct(child) => Scalar::Struct(with_field(child, rest, value)),
other => panic!("field '{}' is not a struct: {other:?}", path[0]),
},
};
StructData::try_new(data.fields().to_vec(), values).unwrap()
}
fn convert(entries: &[ContentTreeNodeEntry]) -> KernelResult<FilteredEngineData> {
let engine = SyncEngine::new();
convert_root_entries_to_add_actions(
&engine,
entry_batch(&engine, entries).as_ref(),
&read_ctx(),
)
}
fn selection_of(entries: &[ContentTreeNodeEntry]) -> Vec<bool> {
convert(entries).unwrap().selection_vector().to_vec()
}
fn convert_struct(entry: StructData) -> KernelResult<FilteredEngineData> {
let engine = SyncEngine::new();
convert_root_entries_to_add_actions(
&engine,
struct_batch(&engine, &[entry]).as_ref(),
&read_ctx(),
)
}
fn test_dv() -> DeletionVectorInfo {
DeletionVectorInfo {
location: "dv.bin".to_string(),
offset: 0,
size_in_bytes: 1,
cardinality: 1,
}
}
fn expected_add_row(
path: &str,
size: i64,
base_row_id: i64,
commit_version: i64,
) -> Vec<Scalar> {
let add = StructData::try_new(
ADD_SCHEMA.fields().cloned().collect(),
vec![
Scalar::from(path),
Scalar::Map(
MapData::try_new(
partition_values_map_type().unwrap(),
Vec::<(Scalar, Scalar)>::new(),
)
.unwrap(),
),
Scalar::Long(size),
Scalar::Long(read_ctx().modification_time),
Scalar::Boolean(read_ctx().data_change),
null_of("stats"),
null_of("tags"),
null_of("deletionVector"),
Scalar::Long(base_row_id),
Scalar::Long(commit_version),
null_of("clusteringProvider"),
null_of("backReference"),
],
)
.unwrap();
vec![Scalar::Struct(add)]
}
fn null_of(field: &str) -> Scalar {
Scalar::Null(ADD_SCHEMA.field(field).unwrap().data_type().clone())
}
fn expected_batch(engine: &dyn Engine, rows: &[Vec<Scalar>]) -> Box<dyn EngineData> {
engine
.evaluation_handler()
.create_many(LOG_ADD_SCHEMA.clone(), rows.to_vec())
.unwrap()
}
#[test]
fn converts_added_data_entries_to_add_actions() {
let engine = SyncEngine::new();
let entries = [
added_data_entry("a.parquet", 100, 10, 0, 5),
added_data_entry("b.parquet", 200, 20, 10, 5),
];
let out = filtered_to_batch(
convert_root_entries_to_add_actions(
&engine,
entry_batch(&engine, &entries).as_ref(),
&read_ctx(),
)
.unwrap(),
);
let expected = expected_batch(
&engine,
&[
expected_add_row("a.parquet", 100, 0, 5),
expected_add_row("b.parquet", 200, 10, 5),
],
);
assert_eq!(
out.try_into_record_batch().unwrap(),
expected.try_into_record_batch().unwrap()
);
}
#[test]
fn root_entry_converter_is_reusable_across_batches() {
let engine = SyncEngine::new();
let converter = make_root_entry_converter(&engine, &read_ctx()).unwrap();
let mut deleted = added_data_entry("deleted.parquet", 1, 1, 0, 0);
deleted.tracking.status = TrackingStatus::Deleted;
let batches = [
vec![added_data_entry("a.parquet", 100, 10, 0, 5)],
vec![deleted, added_data_entry("b.parquet", 200, 20, 10, 6)],
];
let expected = [
(vec![true], expected_add_row("a.parquet", 100, 0, 5)),
(vec![false, true], expected_add_row("b.parquet", 200, 10, 6)),
];
for (entries, (selection, row)) in batches.iter().zip(expected) {
let filtered = converter
.convert(entry_batch(&engine, entries).as_ref())
.unwrap();
assert_eq!(filtered.selection_vector(), selection.as_slice());
assert_eq!(
filtered_to_batch(filtered).try_into_record_batch().unwrap(),
expected_batch(&engine, &[row])
.try_into_record_batch()
.unwrap()
);
}
}
#[test]
fn output_schema_matches_log_add_schema() {
let engine = SyncEngine::new();
let entries = [added_data_entry("a.parquet", 1, 1, 0, 0)];
let out = filtered_to_batch(
convert_root_entries_to_add_actions(
&engine,
entry_batch(&engine, &entries).as_ref(),
&read_ctx(),
)
.unwrap(),
)
.try_into_record_batch()
.unwrap();
let schema = StructType::try_from_arrow(out.schema().as_ref()).unwrap();
assert_eq!(&schema, LOG_ADD_SCHEMA.as_ref());
}
#[test]
fn drops_tombstone_and_non_data_entries() {
let engine = SyncEngine::new();
let mut deleted = added_data_entry("deleted.parquet", 1, 1, 0, 0);
deleted.tracking.status = TrackingStatus::Deleted;
let mut manifest = added_data_entry("manifest.parquet", 1, 1, 0, 0);
manifest.content_type = DataContentType::DataManifest;
manifest.manifest_info = Some(ManifestInfo::default());
let live = added_data_entry("live.parquet", 42, 7, 3, 9);
let filtered = convert_root_entries_to_add_actions(
&engine,
entry_batch(&engine, &[deleted, manifest, live]).as_ref(),
&read_ctx(),
)
.unwrap();
assert_eq!(filtered.selection_vector(), &[false, false, true]);
let out = filtered_to_batch(filtered);
let expected = expected_batch(&engine, &[expected_add_row("live.parquet", 42, 3, 9)]);
assert_eq!(
out.try_into_record_batch().unwrap(),
expected.try_into_record_batch().unwrap()
);
}
#[rstest]
#[case(TrackingStatus::Existing, true)]
#[case(TrackingStatus::Added, true)]
#[case(TrackingStatus::Modified, true)]
#[case(TrackingStatus::Deleted, false)]
#[case(TrackingStatus::Replaced, false)]
fn selects_only_live_data_entries(#[case] status: TrackingStatus, #[case] kept: bool) {
let mut entry = added_data_entry("f.parquet", 10, 5, 0, 1);
entry.tracking.status = status;
assert_eq!(selection_of(&[entry]), [kept]);
}
#[rstest]
fn live_data_manifest_entry_is_dropped(
#[values(TrackingStatus::Existing, TrackingStatus::Added)] status: TrackingStatus,
) {
let mut entry = added_data_entry("manifest.parquet", 1, 1, 0, 0);
entry.content_type = DataContentType::DataManifest;
entry.manifest_info = Some(ManifestInfo::default());
entry.tracking.status = status;
assert_eq!(selection_of(&[entry]), [false]);
}
#[rstest]
fn live_delete_entry_errors(
#[values(
DataContentType::PositionDeletes,
DataContentType::EqualityDeletes,
DataContentType::DeleteManifest
)]
content_type: DataContentType,
#[values(TrackingStatus::Existing, TrackingStatus::Added)] status: TrackingStatus,
) {
let mut entry = added_data_entry("deletes.parquet", 1, 1, 0, 0);
entry.content_type = content_type;
entry.tracking.status = status;
let result = convert(&[entry]);
assert_result_error_with_message(result, &format!("live {content_type:?} entry"));
}
#[rstest]
fn dropped_entry_with_unsupported_fields_is_not_rejected(
#[values(
DataContentType::Data,
DataContentType::PositionDeletes,
DataContentType::EqualityDeletes,
DataContentType::DataManifest,
DataContentType::DeleteManifest
)]
content_type: DataContentType,
#[values(TrackingStatus::Deleted, TrackingStatus::Replaced)] status: TrackingStatus,
) {
let mut entry = added_data_entry("dropped.parquet", 1, 1, 0, 0);
entry.content_type = content_type;
entry.tracking.status = status;
entry.file_format = DataFileFormat::Puffin;
entry.spec_id = Some(7);
entry.deletion_vector = Some(test_dv());
entry.tracking.dv_snapshot_id = Some(1);
assert_eq!(selection_of(&[entry]), [false]);
}
#[rstest]
#[case::content_type(&[CONTENT_TYPE], "Invalid AMT content type value: 5")]
#[case::tracking_status(&[TRACKING, TRACKING_STATUS], "Invalid AMT tracking status value: 5")]
fn unknown_enum_value_errors(#[case] path: &[&str], #[case] message: &str) {
let entry: StructData = added_data_entry("f.parquet", 1, 1, 0, 0).into();
let result = convert_struct(with_field(&entry, path, Scalar::Integer(5)));
assert_result_error_with_message(result, message);
}
#[rstest]
fn live_data_entry_accepts_supported_file_format_and_spec_id(
#[values("parquet", "PARQUET")] file_format: &str,
#[values(None, Some(0))] spec_id: Option<i32>,
) {
let mut entry = added_data_entry("f.parquet", 1, 1, 0, 0);
entry.spec_id = spec_id;
let entry = with_field(&entry.into(), &[FILE_FORMAT], Scalar::from(file_format));
assert_eq!(convert_struct(entry).unwrap().selection_vector(), &[true]);
}
#[rstest]
#[case::puffin_file_format(DataFileFormat::Puffin, Some(0), "has file format 'puffin'")]
#[case::non_zero_spec_id(DataFileFormat::Parquet, Some(1), "has specId 1")]
fn live_data_entry_with_unsupported_field_errors(
#[case] file_format: DataFileFormat,
#[case] spec_id: Option<i32>,
#[case] message: &str,
) {
let mut entry = added_data_entry("f.parquet", 1, 1, 0, 0);
entry.file_format = file_format;
entry.spec_id = spec_id;
let result = convert(&[entry]);
assert_result_error_with_message(result, message);
}
#[rstest]
#[case::added_dv_struct(TrackingStatus::Added, true, false)]
#[case::added_dv_snapshot_id(TrackingStatus::Added, false, true)]
#[case::modified_dv_struct(TrackingStatus::Modified, true, false)]
fn live_entry_with_deletion_vector_errors(
#[case] status: TrackingStatus,
#[case] set_deletion_vector: bool,
#[case] set_dv_snapshot_id: bool,
) {
let mut entry = added_data_entry("f.parquet", 10, 5, 0, 1);
entry.tracking.status = status;
if set_deletion_vector {
entry.deletion_vector = Some(test_dv());
}
if set_dv_snapshot_id {
entry.tracking.dv_snapshot_id = Some(1);
}
let result = convert(&[entry]);
assert_result_error_with_message(result, "deletion vector");
}
#[test]
fn empty_input_yields_empty_batch() {
let engine = SyncEngine::new();
let out = filtered_to_batch(
convert_root_entries_to_add_actions(
&engine,
entry_batch(&engine, &[]).as_ref(),
&read_ctx(),
)
.unwrap(),
);
assert_eq!(out.len(), 0);
}
fn filtered_to_batch(filtered: FilteredEngineData) -> Box<dyn EngineData> {
filtered.apply_selection_vector().unwrap()
}
}