use std::{collections::BTreeMap, path::Path, vec};
use arrow::{
datatypes::{DataType, Schema},
error::ArrowError,
};
use arrow_array::{Array, ArrayRef, LargeStringArray, StringArray};
use bytes::Bytes;
use parquet::{
arrow::{ProjectionMask, arrow_reader::ParquetRecordBatchReaderBuilder},
errors::ParquetError,
file::metadata::ParquetMetaData,
};
use snafu::prelude::*;
use crate::storage::StorageError;
pub type EntityIdentity = BTreeMap<String, String>;
#[derive(Debug, Snafu)]
pub enum SegmentEntityIdentityError {
#[snafu(display("Storage error while reading {path}: {source}"))]
Storage {
path: String,
#[snafu(backtrace)]
source: StorageError,
},
#[snafu(display("Parquet read error for {path}: {source}"))]
ParquetRead {
path: String,
source: ParquetError,
},
#[snafu(display("Arrow read error for {path}: {source}"))]
ArrowRead {
path: String,
source: ArrowError,
},
#[snafu(display("Entity column not found in {path}: {column}"))]
EntityColumnNotFound {
path: String,
column: String,
},
#[snafu(display("Unsupported entity column type in {path}: {column} has {datatype}"))]
EntityColumnUnsupportedType {
path: String,
column: String,
datatype: String,
},
#[snafu(display("Entity column contains nulls in {path}: {column}"))]
EntityColumnHasNull {
path: String,
column: String,
},
#[snafu(display(
"Entity column has multiple values in {path}: {column} (first={first}, other={other})"
))]
EntityColumnMultipleValues {
path: String,
column: String,
first: String,
other: String,
},
#[snafu(display("Entity column has no values (empty segment) in {path}: {column}"))]
EntityColumnEmpty {
path: String,
column: String,
},
}
fn try_entity_identity_from_stats(
meta: &ParquetMetaData,
rel_path: &str,
entity_columns: &[String],
arrow_schema: &Schema,
) -> Result<Option<EntityIdentity>, SegmentEntityIdentityError> {
if entity_columns.is_empty() {
return Ok(Some(EntityIdentity::new()));
}
if meta.file_metadata().num_rows() == 0 {
return Err(SegmentEntityIdentityError::EntityColumnEmpty {
path: rel_path.to_string(),
column: entity_columns[0].clone(),
});
}
let schema_descr = meta.file_metadata().schema_descr();
let mut parquet_col_idxs = Vec::with_capacity(entity_columns.len());
for col_name in entity_columns {
let dt = arrow_schema
.field_with_name(col_name)
.map_err(|_| SegmentEntityIdentityError::EntityColumnNotFound {
path: rel_path.to_string(),
column: col_name.clone(),
})?
.data_type();
match dt {
DataType::Utf8 | DataType::LargeUtf8 => {}
other => {
return Err(SegmentEntityIdentityError::EntityColumnUnsupportedType {
path: rel_path.to_string(),
column: col_name.clone(),
datatype: other.to_string(),
});
}
}
let idx = schema_descr
.columns()
.iter()
.position(|c| c.path().string() == *col_name)
.ok_or_else(|| SegmentEntityIdentityError::EntityColumnNotFound {
path: rel_path.to_string(),
column: col_name.clone(),
})?;
parquet_col_idxs.push(idx);
}
let mut pinned = vec![None; entity_columns.len()];
for rg in meta.row_groups() {
for (i, (col_name, &col_idx)) in entity_columns
.iter()
.zip(parquet_col_idxs.iter())
.enumerate()
{
let col_chunk = rg.column(col_idx);
let Some(stats) = col_chunk.statistics() else {
return Ok(None);
};
match stats.null_count_opt() {
Some(0) => {}
Some(_) => {
return Err(SegmentEntityIdentityError::EntityColumnHasNull {
path: rel_path.to_string(),
column: col_name.clone(),
});
}
None => return Ok(None),
}
if let Some(d) = stats.distinct_count_opt()
&& d != 1
{
let (first, other) = match (stats.min_bytes_opt(), stats.max_bytes_opt()) {
(Some(minb), Some(maxb)) => {
let a = std::str::from_utf8(minb)
.unwrap_or("<non-utf8>")
.to_string();
let b = std::str::from_utf8(maxb)
.unwrap_or("<non-utf8>")
.to_string();
(a, b)
}
_ => ("<unknown>".to_string(), "<unknown>".to_string()),
};
return Err(SegmentEntityIdentityError::EntityColumnMultipleValues {
path: rel_path.to_string(),
column: col_name.clone(),
first,
other,
});
}
if !stats.min_is_exact() || !stats.max_is_exact() {
return Ok(None);
}
let (Some(minb), Some(maxb)) = (stats.min_bytes_opt(), stats.max_bytes_opt()) else {
return Ok(None);
};
if minb != maxb {
let first = std::str::from_utf8(minb)
.map_err(
|_| SegmentEntityIdentityError::EntityColumnUnsupportedType {
path: rel_path.to_string(),
column: col_name.clone(),
datatype: "non-utf8 bytes".to_string(),
},
)?
.to_string();
let other = std::str::from_utf8(maxb)
.map_err(
|_| SegmentEntityIdentityError::EntityColumnUnsupportedType {
path: rel_path.to_string(),
column: col_name.clone(),
datatype: "non-utf8 bytes".to_string(),
},
)?
.to_string();
return Err(SegmentEntityIdentityError::EntityColumnMultipleValues {
path: rel_path.to_string(),
column: col_name.clone(),
first,
other,
});
}
let v = std::str::from_utf8(minb).map_err(|_| {
SegmentEntityIdentityError::EntityColumnUnsupportedType {
path: rel_path.to_string(),
column: col_name.clone(),
datatype: "non-utf8 bytes".to_string(),
}
})?;
match pinned[i].as_deref() {
None => pinned[i] = Some(v.to_string()),
Some(first) if first == v => {}
Some(first) => {
return Err(SegmentEntityIdentityError::EntityColumnMultipleValues {
path: rel_path.to_string(),
column: col_name.clone(),
first: first.to_string(),
other: v.to_string(),
});
}
}
}
}
let mut out = EntityIdentity::new();
for (col, v) in entity_columns.iter().zip(pinned) {
let Some(v) = v else {
return Err(SegmentEntityIdentityError::EntityColumnEmpty {
path: rel_path.to_string(),
column: col.clone(),
});
};
out.insert(col.clone(), v);
}
Ok(Some(out))
}
fn feed_entity_column(
path_str: &str,
col_name: &str,
array: &ArrayRef,
pinned: &mut Option<String>,
) -> Result<(), SegmentEntityIdentityError> {
match array.data_type() {
DataType::Utf8 => {
let arr = array
.as_any()
.downcast_ref::<StringArray>()
.ok_or_else(|| SegmentEntityIdentityError::EntityColumnUnsupportedType {
path: path_str.to_string(),
column: col_name.to_string(),
datatype: array.data_type().to_string(),
})?;
if arr.null_count() > 0 {
return Err(SegmentEntityIdentityError::EntityColumnHasNull {
path: path_str.to_string(),
column: col_name.to_string(),
});
}
for row in 0..arr.len() {
let v = arr.value(row);
match pinned.as_deref() {
None => *pinned = Some(v.to_string()),
Some(first) if first == v => {}
Some(first) => {
return Err(SegmentEntityIdentityError::EntityColumnMultipleValues {
path: path_str.to_string(),
column: col_name.to_string(),
first: first.to_string(),
other: v.to_string(),
});
}
}
}
Ok(())
}
DataType::LargeUtf8 => {
let arr = array
.as_any()
.downcast_ref::<LargeStringArray>()
.ok_or_else(|| SegmentEntityIdentityError::EntityColumnUnsupportedType {
path: path_str.to_string(),
column: col_name.to_string(),
datatype: array.data_type().to_string(),
})?;
if arr.null_count() > 0 {
return Err(SegmentEntityIdentityError::EntityColumnHasNull {
path: path_str.to_string(),
column: col_name.to_string(),
});
}
for row in 0..arr.len() {
let v = arr.value(row);
match pinned.as_deref() {
None => *pinned = Some(v.to_string()),
Some(first) if first == v => {}
Some(first) => {
return Err(SegmentEntityIdentityError::EntityColumnMultipleValues {
path: path_str.to_string(),
column: col_name.to_string(),
first: first.to_string(),
other: v.to_string(),
});
}
}
}
Ok(())
}
other => Err(SegmentEntityIdentityError::EntityColumnUnsupportedType {
path: path_str.to_string(),
column: col_name.to_string(),
datatype: other.to_string(),
}),
}
}
pub fn segment_entity_identity_from_parquet_bytes(
parquet_bytes: Bytes,
rel_path: &Path,
entity_columns: &[String],
) -> Result<EntityIdentity, SegmentEntityIdentityError> {
let path_str = rel_path.display().to_string();
if entity_columns.is_empty() {
return Ok(EntityIdentity::new());
}
let builder = ParquetRecordBatchReaderBuilder::try_new(parquet_bytes).map_err(|source| {
SegmentEntityIdentityError::ParquetRead {
path: path_str.clone(),
source,
}
})?;
let arrow_schema = builder.schema();
for c in entity_columns {
if arrow_schema.index_of(c).is_err() {
return Err(SegmentEntityIdentityError::EntityColumnNotFound {
path: path_str.clone(),
column: c.clone(),
});
}
}
if let Some(identity) = try_entity_identity_from_stats(
builder.metadata(),
&path_str,
entity_columns,
arrow_schema.as_ref(),
)? {
return Ok(identity);
}
let cols_as_str: Vec<&str> = entity_columns.iter().map(|s| s.as_str()).collect();
let mask = ProjectionMask::columns(builder.parquet_schema(), cols_as_str);
let reader = builder.with_projection(mask).build().map_err(|source| {
SegmentEntityIdentityError::ParquetRead {
path: path_str.clone(),
source,
}
})?;
let mut pinned = vec![None; entity_columns.len()];
for batch_res in reader {
let batch = batch_res.map_err(|source| SegmentEntityIdentityError::ArrowRead {
path: path_str.clone(),
source,
})?;
let batch_schema = batch.schema();
for (i, col_name) in entity_columns.iter().enumerate() {
let idx = batch_schema.index_of(col_name).map_err(|_| {
SegmentEntityIdentityError::EntityColumnNotFound {
path: path_str.clone(),
column: col_name.clone(),
}
})?;
let col = batch.column(idx);
feed_entity_column(&path_str, col_name, col, &mut pinned[i])?;
}
}
let mut out = EntityIdentity::new();
for (col, v) in entity_columns.iter().zip(pinned) {
let Some(v) = v else {
return Err(SegmentEntityIdentityError::EntityColumnEmpty {
path: path_str.clone(),
column: col.clone(),
});
};
out.insert(col.clone(), v);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use std::{io::Cursor, path::Path, sync::Arc};
use arrow::{
datatypes::{DataType, Field, Schema},
record_batch::RecordBatch,
};
use arrow_array::{ArrayRef, Int32Array, LargeStringArray, StringArray};
use parquet::arrow::ArrowWriter;
use parquet::file::properties::{EnabledStatistics, WriterProperties};
fn make_batch(schema: Arc<Schema>, columns: Vec<ArrayRef>) -> RecordBatch {
RecordBatch::try_new(schema, columns).expect("record batch")
}
fn parquet_bytes_from_batches(
schema: Arc<Schema>,
batches: Vec<RecordBatch>,
props: WriterProperties,
) -> Vec<u8> {
let cursor = Cursor::new(Vec::new());
let mut writer = ArrowWriter::try_new(cursor, schema, Some(props)).expect("arrow writer");
for batch in batches {
writer.write(&batch).expect("write batch");
}
let cursor = writer.into_inner().expect("finalize parquet");
cursor.into_inner()
}
fn identity_from_bytes(
bytes: Vec<u8>,
entity_columns: &[String],
) -> Result<EntityIdentity, SegmentEntityIdentityError> {
segment_entity_identity_from_parquet_bytes(
Bytes::from(bytes),
Path::new("segment.parquet"),
entity_columns,
)
}
fn string_array(values: &[Option<&str>]) -> ArrayRef {
Arc::new(StringArray::from(values.to_vec()))
}
fn large_string_array(values: &[Option<&str>]) -> ArrayRef {
Arc::new(LargeStringArray::from(values.to_vec()))
}
#[test]
fn identity_empty_columns_returns_empty() {
let identity = segment_entity_identity_from_parquet_bytes(
Bytes::from_static(b"not parquet"),
Path::new("segment.parquet"),
&[],
)
.expect("empty columns");
assert!(identity.is_empty());
}
#[test]
fn identity_happy_path_utf8() {
let schema = Arc::new(Schema::new(vec![
Field::new("entity", DataType::Utf8, false),
Field::new("value", DataType::Int32, false),
]));
let batch = make_batch(
Arc::clone(&schema),
vec![
string_array(&[Some("alpha"), Some("alpha")]),
Arc::new(Int32Array::from(vec![1, 2])) as ArrayRef,
],
);
let bytes = parquet_bytes_from_batches(
Arc::clone(&schema),
vec![batch],
WriterProperties::builder().build(),
);
let identity = identity_from_bytes(bytes, &[String::from("entity")]).expect("identity");
assert_eq!(identity.get("entity").map(String::as_str), Some("alpha"));
}
#[test]
fn identity_happy_path_large_utf8() {
let schema = Arc::new(Schema::new(vec![Field::new(
"entity",
DataType::LargeUtf8,
false,
)]));
let batch = make_batch(
Arc::clone(&schema),
vec![large_string_array(&[Some("alpha"), Some("alpha")])],
);
let bytes = parquet_bytes_from_batches(
Arc::clone(&schema),
vec![batch],
WriterProperties::builder().build(),
);
let identity = identity_from_bytes(bytes, &[String::from("entity")]).expect("identity");
assert_eq!(identity.get("entity").map(String::as_str), Some("alpha"));
}
#[test]
fn identity_missing_column_returns_error() {
let schema = Arc::new(Schema::new(vec![Field::new(
"entity",
DataType::Utf8,
false,
)]));
let batch = make_batch(
Arc::clone(&schema),
vec![string_array(&[Some("alpha"), Some("alpha")])],
);
let bytes = parquet_bytes_from_batches(
Arc::clone(&schema),
vec![batch],
WriterProperties::builder().build(),
);
let err = identity_from_bytes(bytes, &[String::from("missing")]).unwrap_err();
assert!(matches!(
err,
SegmentEntityIdentityError::EntityColumnNotFound { .. }
));
}
#[test]
fn identity_unsupported_type_returns_error() {
let schema = Arc::new(Schema::new(vec![Field::new(
"entity",
DataType::Int32,
false,
)]));
let batch = make_batch(
Arc::clone(&schema),
vec![Arc::new(Int32Array::from(vec![1, 1])) as ArrayRef],
);
let bytes = parquet_bytes_from_batches(
Arc::clone(&schema),
vec![batch],
WriterProperties::builder().build(),
);
let err = identity_from_bytes(bytes, &[String::from("entity")]).unwrap_err();
assert!(matches!(
err,
SegmentEntityIdentityError::EntityColumnUnsupportedType { .. }
));
}
#[test]
fn identity_column_has_null_returns_error() {
let schema = Arc::new(Schema::new(vec![Field::new(
"entity",
DataType::Utf8,
true,
)]));
let batch = make_batch(
Arc::clone(&schema),
vec![string_array(&[Some("alpha"), None])],
);
let bytes = parquet_bytes_from_batches(
Arc::clone(&schema),
vec![batch],
WriterProperties::builder().build(),
);
let err = identity_from_bytes(bytes, &[String::from("entity")]).unwrap_err();
assert!(matches!(
err,
SegmentEntityIdentityError::EntityColumnHasNull { .. }
));
}
#[test]
fn identity_multiple_values_returns_error() {
let schema = Arc::new(Schema::new(vec![Field::new(
"entity",
DataType::Utf8,
false,
)]));
let batch = make_batch(
Arc::clone(&schema),
vec![string_array(&[Some("alpha"), Some("beta")])],
);
let bytes = parquet_bytes_from_batches(
Arc::clone(&schema),
vec![batch],
WriterProperties::builder().build(),
);
let err = identity_from_bytes(bytes, &[String::from("entity")]).unwrap_err();
assert!(matches!(
err,
SegmentEntityIdentityError::EntityColumnMultipleValues { .. }
));
}
#[test]
fn identity_empty_segment_returns_error() {
let schema = Arc::new(Schema::new(vec![Field::new(
"entity",
DataType::Utf8,
false,
)]));
let batch = make_batch(Arc::clone(&schema), vec![string_array(&[])]);
let bytes = parquet_bytes_from_batches(
Arc::clone(&schema),
vec![batch],
WriterProperties::builder().build(),
);
let err = identity_from_bytes(bytes, &[String::from("entity")]).unwrap_err();
assert!(matches!(
err,
SegmentEntityIdentityError::EntityColumnEmpty { .. }
));
}
#[test]
fn identity_fallback_scan_success() {
let schema = Arc::new(Schema::new(vec![Field::new(
"entity",
DataType::Utf8,
false,
)]));
let batch = make_batch(
Arc::clone(&schema),
vec![string_array(&[Some("alpha"), Some("alpha")])],
);
let props = WriterProperties::builder()
.set_statistics_enabled(EnabledStatistics::None)
.build();
let bytes = parquet_bytes_from_batches(Arc::clone(&schema), vec![batch], props);
let identity = identity_from_bytes(bytes, &[String::from("entity")]).expect("identity");
assert_eq!(identity.get("entity").map(String::as_str), Some("alpha"));
}
#[test]
fn identity_fallback_scan_nulls_return_error() {
let schema = Arc::new(Schema::new(vec![Field::new(
"entity",
DataType::Utf8,
true,
)]));
let batch = make_batch(
Arc::clone(&schema),
vec![string_array(&[Some("alpha"), None])],
);
let props = WriterProperties::builder()
.set_statistics_enabled(EnabledStatistics::None)
.build();
let bytes = parquet_bytes_from_batches(Arc::clone(&schema), vec![batch], props);
let err = identity_from_bytes(bytes, &[String::from("entity")]).unwrap_err();
assert!(matches!(
err,
SegmentEntityIdentityError::EntityColumnHasNull { .. }
));
}
#[test]
fn identity_fallback_scan_multiple_values_return_error() {
let schema = Arc::new(Schema::new(vec![Field::new(
"entity",
DataType::Utf8,
false,
)]));
let batch = make_batch(
Arc::clone(&schema),
vec![string_array(&[Some("alpha"), Some("beta")])],
);
let props = WriterProperties::builder()
.set_statistics_enabled(EnabledStatistics::None)
.build();
let bytes = parquet_bytes_from_batches(Arc::clone(&schema), vec![batch], props);
let err = identity_from_bytes(bytes, &[String::from("entity")]).unwrap_err();
assert!(matches!(
err,
SegmentEntityIdentityError::EntityColumnMultipleValues { .. }
));
}
#[test]
fn identity_arrow_read_error_on_invalid_utf8() {
let schema = Arc::new(Schema::new(vec![Field::new(
"entity",
DataType::Utf8,
false,
)]));
let batch = make_batch(
Arc::clone(&schema),
vec![string_array(&[Some("alpha"), Some("alpha")])],
);
let props = WriterProperties::builder()
.set_statistics_enabled(EnabledStatistics::None)
.build();
let mut bytes = parquet_bytes_from_batches(Arc::clone(&schema), vec![batch], props);
let needle = b"alpha";
let pos = bytes
.windows(needle.len())
.position(|window| window == needle)
.expect("needle in parquet data");
bytes[pos] = 0xFF;
let err = identity_from_bytes(bytes, &[String::from("entity")]).unwrap_err();
assert!(matches!(err, SegmentEntityIdentityError::ArrowRead { .. }));
}
#[test]
fn identity_parquet_read_error_on_invalid_bytes() {
let err = segment_entity_identity_from_parquet_bytes(
Bytes::from_static(b"not parquet"),
Path::new("segment.parquet"),
&[String::from("entity")],
)
.unwrap_err();
assert!(matches!(
err,
SegmentEntityIdentityError::ParquetRead { .. }
));
}
}