use std::collections::HashSet;
use std::sync::Arc;
use delta_kernel_derive::internal_api;
use derive_more::Constructor;
use tracing::{debug, warn};
use url::Url;
use crate::engine_data::GetData;
use crate::log_replay::deduplicator::{Deduplicator, FileActionInfo};
use crate::{EngineData, KernelResult};
pub(crate) mod deduplicator;
#[derive(Debug, Hash, Eq, PartialEq, serde::Serialize, serde::Deserialize, Clone)]
pub struct FileActionKey {
path: String,
dv_unique_id: Option<String>,
}
impl FileActionKey {
pub fn new(path: impl Into<String>, dv_unique_id: Option<String>) -> Self {
let path = path.into();
Self { path, dv_unique_id }
}
pub fn path(&self) -> &str {
&self.path
}
pub fn dv_unique_id(&self) -> Option<&str> {
self.dv_unique_id.as_deref()
}
}
pub(crate) struct FileActionDeduplicator<'seen> {
seen_file_keys: &'seen mut HashSet<FileActionKey>,
is_log_batch: bool,
add_path_index: usize,
add_size_index: usize,
remove_path_index: usize,
add_dv_start_index: usize,
remove_dv_start_index: usize,
dv_normalization_root: Option<Url>,
}
impl<'seen> FileActionDeduplicator<'seen> {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
seen_file_keys: &'seen mut HashSet<FileActionKey>,
is_log_batch: bool,
add_path_index: usize,
add_size_index: usize,
remove_path_index: usize,
add_dv_start_index: usize,
remove_dv_start_index: usize,
dv_normalization_root: Option<Url>,
) -> Self {
Self {
seen_file_keys,
is_log_batch,
add_path_index,
add_size_index,
remove_path_index,
add_dv_start_index,
remove_dv_start_index,
dv_normalization_root,
}
}
}
impl Deduplicator for FileActionDeduplicator<'_> {
fn check_and_record_seen(&mut self, key: FileActionKey) -> bool {
if self.seen_file_keys.contains(&key) {
debug!(
"Ignoring duplicate ({}, {:?}) in scan, is log {}",
key.path, key.dv_unique_id, self.is_log_batch
);
true
} else {
debug!(
"Including ({}, {:?}) in scan, is log {}",
key.path, key.dv_unique_id, self.is_log_batch
);
if self.is_log_batch {
self.seen_file_keys.insert(key);
}
false
}
}
fn extract_file_action<'a>(
&self,
i: usize,
getters: &[&'a dyn GetData<'a>],
skip_removes: bool,
) -> KernelResult<Option<FileActionInfo>> {
if let Some(path) = getters[self.add_path_index].get_str(i, "add.path")? {
let size = match getters[self.add_size_index].get_long(i, "add.size")? {
Some(s) => u64::try_from(s).unwrap_or_else(|e| {
warn!("Could not convert add.size {s} to u64: {e}");
0
}),
None => {
warn!("Add action without required size field");
0
}
};
let dv_unique_id = self.extract_dv_unique_id(i, getters, self.add_dv_start_index)?;
return Ok(Some(FileActionInfo {
key: FileActionKey::new(path, dv_unique_id),
size,
is_add: true,
}));
}
if skip_removes {
return Ok(None);
}
if let Some(path) = getters[self.remove_path_index].get_str(i, "remove.path")? {
let dv_unique_id = self.extract_dv_unique_id(i, getters, self.remove_dv_start_index)?;
return Ok(Some(FileActionInfo {
key: FileActionKey::new(path, dv_unique_id),
size: 0,
is_add: false,
}));
}
Ok(None)
}
fn is_log_batch(&self) -> bool {
self.is_log_batch
}
fn dv_normalization_root(&self) -> Option<&Url> {
self.dv_normalization_root.as_ref()
}
}
#[internal_api]
#[derive(Constructor)]
pub(crate) struct ActionsBatch {
pub actions: Box<dyn EngineData>,
pub is_log_batch: bool,
}
impl ActionsBatch {
#[allow(unused)]
#[internal_api]
pub(crate) fn actions(&self) -> &dyn EngineData {
self.actions.as_ref()
}
}
#[internal_api]
pub(crate) trait ParallelLogReplayProcessor {
type Output;
fn process_actions_batch_parallel(
&self,
actions_batch: ActionsBatch,
) -> KernelResult<Self::Output>;
}
impl<T> ParallelLogReplayProcessor for Arc<T>
where
T: ParallelLogReplayProcessor,
{
type Output = T::Output;
fn process_actions_batch_parallel(
&self,
actions_batch: ActionsBatch,
) -> KernelResult<Self::Output> {
T::process_actions_batch_parallel(self, actions_batch)
}
}
#[internal_api]
pub(crate) trait HasSelectionVector {
fn has_selected_rows(&self) -> bool;
}
#[cfg(test)]
mod tests {
use std::collections::{HashMap, HashSet};
use rstest::rstest;
use super::deduplicator::CheckpointDeduplicator;
use super::*;
use crate::engine_data::GetData;
#[cfg(feature = "adaptive-metadata-in-dev")]
use crate::unit_test_utils::assert_result_error_with_message;
use crate::KernelResult;
struct MockGetData {
string_values: HashMap<(usize, String), String>,
int_values: HashMap<(usize, String), i32>,
long_values: HashMap<(usize, String), i64>,
errors: HashMap<(usize, String), String>,
}
impl MockGetData {
fn new() -> Self {
Self {
string_values: HashMap::new(),
int_values: HashMap::new(),
long_values: HashMap::new(),
errors: HashMap::new(),
}
}
fn add_string(&mut self, row: usize, field: &str, value: &str) {
self.string_values
.insert((row, field.to_string()), value.to_string());
}
fn add_int(&mut self, row: usize, field: &str, value: i32) {
self.int_values.insert((row, field.to_string()), value);
}
fn add_long(&mut self, row: usize, field: &str, value: i64) {
self.long_values.insert((row, field.to_string()), value);
}
}
impl<'a> GetData<'a> for MockGetData {
fn get_str(&'a self, row_index: usize, field_name: &str) -> KernelResult<Option<&'a str>> {
if let Some(error_msg) = self.errors.get(&(row_index, field_name.to_string())) {
return Err(crate::KernelError::Generic(error_msg.clone()));
}
Ok(self
.string_values
.get(&(row_index, field_name.to_string()))
.map(|s| s.as_str()))
}
fn get_int(&'a self, row_index: usize, field_name: &str) -> KernelResult<Option<i32>> {
if let Some(error_msg) = self.errors.get(&(row_index, field_name.to_string())) {
return Err(crate::KernelError::Generic(error_msg.clone()));
}
Ok(self
.int_values
.get(&(row_index, field_name.to_string()))
.cloned())
}
fn get_long(&'a self, row_index: usize, field_name: &str) -> KernelResult<Option<i64>> {
if let Some(error_msg) = self.errors.get(&(row_index, field_name.to_string())) {
return Err(crate::KernelError::Generic(error_msg.clone()));
}
Ok(self
.long_values
.get(&(row_index, field_name.to_string()))
.cloned())
}
}
fn create_deduplicator(
seen: &mut HashSet<FileActionKey>,
is_log_batch: bool,
) -> FileActionDeduplicator<'_> {
create_deduplicator_with_root(seen, is_log_batch, None)
}
fn create_deduplicator_with_root(
seen: &mut HashSet<FileActionKey>,
is_log_batch: bool,
dv_normalization_root: Option<Url>,
) -> FileActionDeduplicator<'_> {
FileActionDeduplicator::new(
seen,
is_log_batch,
0, 1, 5, 2, 6, dv_normalization_root,
)
}
#[cfg(feature = "adaptive-metadata-in-dev")]
fn file_action_with_dv(path_field: &str, storage_type: &str, dv_path: &str) -> MockGetData {
let mut mock = MockGetData::new();
mock.add_string(0, path_field, "f.parquet");
mock.add_string(0, "deletionVector.storageType", storage_type);
mock.add_string(0, "deletionVector.pathOrInlineDv", dv_path);
mock.add_int(0, "deletionVector.offset", 4);
mock
}
fn create_getters_with_mocks<'a>(
add_mock: Option<&'a MockGetData>,
remove_mock: Option<&'a MockGetData>,
) -> Vec<&'a dyn GetData<'a>> {
use std::sync::LazyLock;
static EMPTY: LazyLock<MockGetData> = LazyLock::new(MockGetData::new);
let empty_ref = &*EMPTY;
vec![
add_mock.unwrap_or(empty_ref), add_mock.unwrap_or(empty_ref), add_mock.unwrap_or(empty_ref), add_mock.unwrap_or(empty_ref), add_mock.unwrap_or(empty_ref), remove_mock.unwrap_or(empty_ref), remove_mock.unwrap_or(empty_ref), remove_mock.unwrap_or(empty_ref), remove_mock.unwrap_or(empty_ref), ]
}
#[cfg(feature = "adaptive-metadata-in-dev")]
#[rstest]
#[case::normalized(
Some("s3://mytable/"),
"rab/deletion_vector_d2c639aa-8816-431a-aaf6-d3fe2512ff61.bin@4",
true
)]
#[case::legacy(None, "uab^-aqEH.-t@S}K{vb[*k^@4", false)]
fn test_remove_cancels_add_across_dv_storage_types(
#[case] root: Option<&str>,
#[case] expected_add_dv_id: &str,
#[case] expect_cancelled: bool,
) -> KernelResult<()> {
let mut seen = HashSet::new();
let root = root.map(|r| Url::parse(r).unwrap());
let mut deduplicator = create_deduplicator_with_root(&mut seen, true, root.clone());
let remove = file_action_with_dv(
"remove.path",
"r",
"ab/deletion_vector_d2c639aa-8816-431a-aaf6-d3fe2512ff61.bin",
);
let add = file_action_with_dv("add.path", "u", "ab^-aqEH.-t@S}K{vb[*k^");
let remove_key = deduplicator
.extract_file_action(0, &create_getters_with_mocks(None, Some(&remove)), false)?
.unwrap()
.key;
let add_key = deduplicator
.extract_file_action(0, &create_getters_with_mocks(Some(&add), None), false)?
.unwrap()
.key;
assert_eq!(
remove_key.dv_unique_id(),
Some("rab/deletion_vector_d2c639aa-8816-431a-aaf6-d3fe2512ff61.bin@4")
);
assert_eq!(add_key.dv_unique_id(), Some(expected_add_dv_id));
let commit_removes = HashSet::from([remove_key.clone()]);
assert!(!deduplicator.check_and_record_seen(remove_key));
assert_eq!(
deduplicator.check_and_record_seen(add_key),
expect_cancelled
);
let mut checkpoint = CheckpointDeduplicator::try_new(&commit_removes, 0, 1, 2, root)?;
let checkpoint_add_key = checkpoint
.extract_file_action(0, &create_getters_with_mocks(Some(&add), None), false)?
.unwrap()
.key;
assert_eq!(
checkpoint.check_and_record_seen(checkpoint_add_key),
expect_cancelled
);
Ok(())
}
#[cfg(feature = "adaptive-metadata-in-dev")]
#[test]
fn test_normalized_dv_identity_rejects_unknown_storage_type() {
let mut seen = HashSet::new();
let root = Url::parse("s3://mytable/").unwrap();
let deduplicator = create_deduplicator_with_root(&mut seen, true, Some(root));
let add = file_action_with_dv("add.path", "z", "ABC");
assert_result_error_with_message(
deduplicator.extract_file_action(
0,
&create_getters_with_mocks(Some(&add), None),
false,
),
"unsupported deletion vector storage type: z",
);
}
#[rstest]
#[case::valid_size(Some(100), 100)]
#[case::missing_size_defaults_to_zero(None, 0)]
#[case::negative_size_defaults_to_zero(Some(-1), 0)]
fn test_extract_file_action_add(
#[case] raw_size: Option<i64>,
#[case] expected_size: u64,
) -> KernelResult<()> {
let mut seen = HashSet::new();
let deduplicator = create_deduplicator(&mut seen, true);
let mut mock_add = MockGetData::new();
mock_add.add_string(0, "add.path", "file1.parquet");
if let Some(s) = raw_size {
mock_add.add_long(0, "add.size", s);
}
let getters = create_getters_with_mocks(Some(&mock_add), None);
let result = deduplicator.extract_file_action(0, &getters, false)?;
let FileActionInfo { key, size, is_add } = result.unwrap();
assert_eq!(key.path, "file1.parquet");
assert!(key.dv_unique_id.is_none());
assert!(is_add);
assert_eq!(size, expected_size);
Ok(())
}
#[test]
fn test_extract_file_action_remove() -> KernelResult<()> {
let mut seen = HashSet::new();
let deduplicator = create_deduplicator(&mut seen, true);
let mut mock_remove = MockGetData::new();
mock_remove.add_string(0, "remove.path", "file2.parquet");
let getters = create_getters_with_mocks(None, Some(&mock_remove));
let result = deduplicator.extract_file_action(0, &getters, false)?;
assert!(result.is_some());
let FileActionInfo { key, is_add, .. } = result.unwrap();
assert_eq!(key.path, "file2.parquet");
assert!(!is_add);
Ok(())
}
#[test]
fn test_extract_file_action_with_deletion_vector() -> KernelResult<()> {
let mut seen = HashSet::new();
let deduplicator = create_deduplicator(&mut seen, true);
let mut mock_dv = MockGetData::new();
mock_dv.add_string(0, "add.path", "file_with_dv.parquet");
mock_dv.add_string(0, "deletionVector.storageType", "s3");
mock_dv.add_string(0, "deletionVector.pathOrInlineDv", "path/to/dv");
mock_dv.add_int(0, "deletionVector.offset", 100);
let getters = create_getters_with_mocks(Some(&mock_dv), None);
let result = deduplicator.extract_file_action(0, &getters, false)?;
assert!(result.is_some());
let FileActionInfo { key, is_add, .. } = result.unwrap();
assert!(matches!(
key.dv_unique_id.as_deref(),
Some("s3path/to/dv@100")
));
assert!(is_add);
Ok(())
}
#[test]
fn test_extract_file_action_skip_removes() -> KernelResult<()> {
let mut seen = HashSet::new();
let deduplicator = create_deduplicator(&mut seen, true);
let mut mock_remove = MockGetData::new();
mock_remove.add_string(0, "remove.path", "file2.parquet");
let getters = create_getters_with_mocks(None, Some(&mock_remove));
assert!(deduplicator
.extract_file_action(0, &getters, true)?
.is_none());
assert!(deduplicator
.extract_file_action(0, &getters, false)?
.is_some());
Ok(())
}
#[test]
fn test_extract_file_action_no_action_found() -> KernelResult<()> {
let mut seen = HashSet::new();
let deduplicator = create_deduplicator(&mut seen, true);
let getters = create_getters_with_mocks(None, None);
assert!(deduplicator
.extract_file_action(0, &getters, false)?
.is_none());
Ok(())
}
#[test]
fn test_check_and_record_seen() {
let mut seen = HashSet::new();
let pre_existing_key = FileActionKey::new("existing.parquet", None);
seen.insert(pre_existing_key.clone());
let key1 = FileActionKey::new("file1.parquet", None);
let key2 = FileActionKey::new("file2.parquet", None);
let key_with_dv = FileActionKey::new("file1.parquet", Some("dv1".to_string()));
{
let mut deduplicator = create_deduplicator(&mut seen, true);
assert!(deduplicator.check_and_record_seen(pre_existing_key.clone()));
assert!(!deduplicator.check_and_record_seen(key1.clone()));
assert!(!deduplicator.check_and_record_seen(key2.clone()));
assert!(!deduplicator.check_and_record_seen(key_with_dv.clone()));
assert!(deduplicator.check_and_record_seen(key1.clone()));
assert!(deduplicator.check_and_record_seen(key_with_dv.clone()));
}
assert!(seen.contains(&key1));
assert!(seen.contains(&key2));
assert!(seen.contains(&key_with_dv));
{
let mut deduplicator = create_deduplicator(&mut seen, false);
let new_key = FileActionKey::new("new.parquet", None);
assert!(!deduplicator.check_and_record_seen(new_key.clone()));
assert!(!deduplicator.check_and_record_seen(new_key.clone()));
assert!(deduplicator.check_and_record_seen(key1.clone()));
}
}
#[test]
fn test_is_log_batch() {
let mut seen = HashSet::new();
let deduplicator_log = create_deduplicator(&mut seen, true);
assert!(deduplicator_log.is_log_batch());
let deduplicator_checkpoint = create_deduplicator(&mut seen, false);
assert!(!deduplicator_checkpoint.is_log_batch());
}
#[test]
fn test_checkpoint_extract_file_action_add() -> KernelResult<()> {
let seen = HashSet::new();
let deduplicator = CheckpointDeduplicator::try_new(&seen, 0, 2, 3, None)?;
let mut mock_add = MockGetData::new();
mock_add.add_string(0, "add.path", "checkpoint_file.parquet");
let getters = create_getters_with_mocks(Some(&mock_add), None);
let result = deduplicator.extract_file_action(0, &getters, false)?;
assert!(result.is_some());
let FileActionInfo { key, is_add, .. } = result.unwrap();
assert_eq!(key.path, "checkpoint_file.parquet");
assert!(key.dv_unique_id.is_none());
assert!(is_add);
Ok(())
}
#[test]
fn test_checkpoint_extract_file_action_with_deletion_vector() -> KernelResult<()> {
let seen = HashSet::new();
let deduplicator = CheckpointDeduplicator::try_new(&seen, 0, 1, 2, None)?;
let mut mock_dv = MockGetData::new();
mock_dv.add_string(0, "add.path", "file_with_dv.parquet");
mock_dv.add_string(0, "deletionVector.storageType", "s3");
mock_dv.add_string(0, "deletionVector.pathOrInlineDv", "path/to/dv");
mock_dv.add_int(0, "deletionVector.offset", 100);
let getters = create_getters_with_mocks(Some(&mock_dv), None);
let result = deduplicator.extract_file_action(0, &getters, false)?;
assert!(result.is_some());
let FileActionInfo { key, is_add, .. } = result.unwrap();
assert_eq!(key.path, "file_with_dv.parquet");
assert!(matches!(
key.dv_unique_id.as_deref(),
Some("s3path/to/dv@100")
));
assert!(is_add);
Ok(())
}
#[test]
fn test_checkpoint_deduplicator_filters_commit_duplicates() -> KernelResult<()> {
let mut seen = HashSet::new();
seen.insert(FileActionKey::new("modified_in_commit.parquet", None));
seen.insert(FileActionKey::new(
"modified_with_dv.parquet",
Some("dv123".to_string()),
));
let mut deduplicator = CheckpointDeduplicator::try_new(&seen, 0, 2, 3, None)?;
let commit_modified = FileActionKey::new("modified_in_commit.parquet", None);
assert!(
deduplicator.check_and_record_seen(commit_modified),
"Files seen in commits should be filtered from checkpoint"
);
let commit_modified_dv =
FileActionKey::new("modified_with_dv.parquet", Some("dv123".to_string()));
assert!(
deduplicator.check_and_record_seen(commit_modified_dv),
"Files with DVs seen in commits should be filtered from checkpoint"
);
let checkpoint_only = FileActionKey::new("checkpoint_only.parquet", None);
assert!(
!deduplicator.check_and_record_seen(checkpoint_only),
"Files only in checkpoint should not be filtered"
);
Ok(())
}
}