use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
use arrow::array::{Array, ArrayRef, Int64Array, StringArray, UInt32Array};
use arrow::compute::take;
use arrow::datatypes::{DataType, Field, FieldRef, Schema, SchemaRef};
use arrow::record_batch::RecordBatch;
use async_trait::async_trait;
use datafusion::catalog::Session;
use datafusion::common::Result as DataFusionResult;
use datafusion::datasource::listing::PartitionedFile;
use datafusion::datasource::physical_plan::{FileGroup, FileScanConfigBuilder, ParquetSource};
use datafusion::datasource::source::DataSourceExec;
use datafusion::datasource::{TableProvider, TableType};
use datafusion::error::DataFusionError;
use datafusion::execution::object_store::ObjectStoreUrl;
use datafusion::execution::{SendableRecordBatchStream, TaskContext};
use datafusion::physical_expr::expressions::Column;
use datafusion::physical_expr::{EquivalenceProperties, PhysicalExpr};
use datafusion::physical_plan::execution_plan::{Boundedness, EmissionType};
use datafusion::physical_plan::projection::ProjectionExec;
use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
use datafusion::physical_plan::union::UnionExec;
use datafusion::physical_plan::{
DisplayAs, DisplayFormatType, ExecutionPlan, PlanProperties, collect,
};
use futures::stream::BoxStream;
use futures::{StreamExt, TryStreamExt};
use crate::metadata_provider::{DeleteFileChange, DuckLakeTableColumn, MetadataProvider};
use crate::path_resolver::resolve_path;
use crate::positional_source::PositionalFileSource;
use crate::row_id::{FileRowNumberExec, ROW_POS_COLUMN_NAME, SNAPSHOT_ID_PARQUET_FIELD_ID};
use crate::table::{
ParquetFileLayout, read_parquet_file_layout, read_parquet_footer_facts, validated_file_size,
validated_record_count,
};
use crate::table_changes::{check_column_count, present_catalog_schema};
fn delete_file_schema() -> SchemaRef {
Arc::new(Schema::new(vec![
Field::new("file_path", DataType::Utf8, false),
Field::new("pos", DataType::Int64, false),
]))
}
#[derive(Debug)]
pub struct TableDeletionsTable {
provider: Arc<dyn MetadataProvider>,
table_id: i64,
start_snapshot: i64,
end_snapshot: i64,
object_store_url: Arc<ObjectStoreUrl>,
table_path: String,
table_schema: SchemaRef,
output_schema: SchemaRef,
columns: Option<Arc<Vec<DuckLakeTableColumn>>>,
layout_cache: Mutex<HashMap<String, Arc<ParquetFileLayout>>>,
}
impl TableDeletionsTable {
pub fn new(
provider: Arc<dyn MetadataProvider>,
table_id: i64,
start_snapshot: i64,
end_snapshot: i64,
object_store_url: Arc<ObjectStoreUrl>,
table_path: String,
table_schema: SchemaRef,
) -> Self {
let mut fields: Vec<Field> = Vec::with_capacity(table_schema.fields().len() + 3);
fields.push(Field::new("snapshot_id", DataType::Int64, false));
fields.push(Field::new("rowid", DataType::Int64, true));
fields.push(Field::new("change_type", DataType::Utf8, false));
fields.extend(table_schema.fields().iter().map(|f| f.as_ref().clone()));
let output_schema = Arc::new(Schema::new(fields));
Self {
provider,
table_id,
start_snapshot,
end_snapshot,
object_store_url,
table_path,
table_schema,
output_schema,
columns: None,
layout_cache: Mutex::new(HashMap::new()),
}
}
pub fn with_columns(mut self, columns: Vec<DuckLakeTableColumn>) -> Self {
self.columns = Some(Arc::new(columns));
self
}
fn resolve_columns(&self) -> DataFusionResult<Arc<Vec<DuckLakeTableColumn>>> {
match &self.columns {
Some(columns) => Ok(Arc::clone(columns)),
None => {
let columns = self
.provider
.get_table_structure(self.table_id, self.end_snapshot)
.map_err(|e| DataFusionError::External(Box::new(e)))?;
Ok(Arc::new(columns))
},
}
}
async fn file_layout(
&self,
state: &dyn Session,
columns: &[DuckLakeTableColumn],
path: &str,
is_relative: bool,
) -> DataFusionResult<Arc<ParquetFileLayout>> {
let resolved = resolve_path(&self.table_path, path, is_relative)
.map_err(|e| DataFusionError::External(Box::new(e)))?;
{
let cache = self.layout_cache.lock().unwrap();
if let Some(layout) = cache.get(&resolved) {
return Ok(Arc::clone(layout));
}
}
let layout = read_parquet_file_layout(
state,
self.object_store_url.as_ref(),
&resolved,
None,
columns,
&self.table_schema,
)
.await?;
self.layout_cache
.lock()
.unwrap()
.entry(resolved)
.or_insert_with(|| Arc::clone(&layout));
Ok(layout)
}
async fn build_exec_for_delete_entry(
&self,
state: &dyn Session,
columns: &[DuckLakeTableColumn],
need_rowid: bool,
delete_file: &DeleteFileChange,
) -> DataFusionResult<Arc<dyn ExecutionPlan>> {
let data_file_path = resolve_path(
&self.table_path,
&delete_file.data_file_path,
delete_file.data_file_path_is_relative,
)
.map_err(|e| DataFusionError::External(Box::new(e)))?;
let snapshot_name = match &delete_file.current_delete_path {
Some(p) => {
self.detect_delete_file_snapshot_name(
state,
p,
delete_file.current_delete_path_is_relative.unwrap_or(true),
)
.await?
},
None => None,
};
if snapshot_name.is_none() && delete_file.snapshot_id < self.start_snapshot {
return Err(DataFusionError::External(
format!(
"delete file {:?} begins before the query window but carries no embedded \
per-row snapshot column; its deletions cannot be attributed",
delete_file.current_delete_path
)
.into(),
));
}
let current_delete_exec = if let Some(ref current_path) = delete_file.current_delete_path {
Some(self.build_delete_file_scan(
current_path,
delete_file.current_delete_path_is_relative.unwrap_or(true),
delete_file.current_delete_file_size_bytes.unwrap_or(0),
delete_file.current_delete_footer_size.unwrap_or(0),
&snapshot_name,
)?)
} else {
None
};
let previous_delete_exec = match &delete_file.previous_delete_path {
Some(prev_path) if snapshot_name.is_none() => Some(self.build_delete_file_scan(
prev_path,
delete_file.previous_delete_path_is_relative.unwrap_or(true),
delete_file.previous_delete_file_size_bytes.unwrap_or(0),
delete_file.previous_delete_footer_size.unwrap_or(0),
&None,
)?),
_ => None,
};
let table_len = self.table_schema.fields().len();
check_column_count(table_len, columns.len())?;
let layout = self
.file_layout(
state,
columns,
&delete_file.data_file_path,
delete_file.data_file_path_is_relative,
)
.await?;
let embedded_name = if need_rowid {
layout.embedded_rowid_parquet_name.clone()
} else {
None
};
let embedded_col_idx = embedded_name.as_ref().map(|_| table_len);
let pos_col_idx = table_len + usize::from(embedded_name.is_some());
let data_file_exec = self.build_data_file_scan(
&data_file_path,
delete_file.data_file_size_bytes,
delete_file.data_file_footer_size.unwrap_or(0),
&layout,
&embedded_name,
)?;
validated_record_count(delete_file.data_record_count, &delete_file.data_file_path)?;
Ok(Arc::new(DeletedRowsExec::new(DeletionUnit {
current_delete_scan: current_delete_exec,
previous_delete_scan: previous_delete_exec,
data_file_scan: data_file_exec,
record_count: delete_file.data_record_count,
snapshot_id: delete_file.snapshot_id,
row_id_start: delete_file.data_row_id_start,
table_len,
embedded_col_idx,
pos_col_idx,
need_rowid,
cumulative: snapshot_name.is_some(),
window: (self.start_snapshot, self.end_snapshot),
output_schema: self.output_schema.clone(),
})))
}
async fn detect_delete_file_snapshot_name(
&self,
state: &dyn Session,
path: &str,
is_relative: bool,
) -> DataFusionResult<Option<String>> {
let resolved = resolve_path(&self.table_path, path, is_relative)
.map_err(|e| DataFusionError::External(Box::new(e)))?;
let facts =
read_parquet_footer_facts(state, self.object_store_url.as_ref(), &resolved, None)
.await?;
Ok(facts.field_ids.get(&SNAPSHOT_ID_PARQUET_FIELD_ID).cloned())
}
fn build_delete_file_scan(
&self,
path: &str,
is_relative: bool,
size_bytes: i64,
footer_size: i64,
snapshot_name: &Option<String>,
) -> DataFusionResult<Arc<dyn ExecutionPlan>> {
let resolved_path = resolve_path(&self.table_path, path, is_relative)
.map_err(|e| DataFusionError::External(Box::new(e)))?;
let mut pf = PartitionedFile::new(
&resolved_path,
validated_file_size(size_bytes, &resolved_path)?,
);
if footer_size > 0
&& let Ok(hint) = usize::try_from(footer_size)
{
pf = pf.with_metadata_size_hint(hint);
}
let schema = match snapshot_name {
Some(name) => {
let mut fields: Vec<Field> = delete_file_schema()
.fields()
.iter()
.map(|f| f.as_ref().clone())
.collect();
fields.push(Field::new(name, DataType::Int64, true));
Arc::new(Schema::new(fields))
},
None => delete_file_schema(),
};
let builder = FileScanConfigBuilder::new(
self.object_store_url.as_ref().clone(),
Arc::new(ParquetSource::new(schema)),
)
.with_file_group(FileGroup::new(vec![pf]));
Ok(DataSourceExec::from_data_source(builder.build()))
}
fn build_data_file_scan(
&self,
path: &str,
size_bytes: i64,
footer_size: i64,
layout: &ParquetFileLayout,
embedded_name: &Option<String>,
) -> DataFusionResult<Arc<dyn ExecutionPlan>> {
let mut pf = PartitionedFile::new(path, validated_file_size(size_bytes, path)?);
if footer_size > 0
&& let Ok(hint) = usize::try_from(footer_size)
{
pf = pf.with_metadata_size_hint(hint);
}
let read_schema = match embedded_name {
Some(name) => {
let mut fields: Vec<FieldRef> =
layout.read_schema.fields().iter().cloned().collect();
fields.push(Arc::new(Field::new(name, DataType::Int64, true)));
Arc::new(Schema::new(fields))
},
None => Arc::clone(&layout.read_schema),
};
let source = PositionalFileSource::wrap(Arc::new(ParquetSource::new(read_schema)));
let builder = FileScanConfigBuilder::new(self.object_store_url.as_ref().clone(), source)
.with_file_group(FileGroup::new(vec![pf]))
.with_partitioned_by_file_group(true);
let scan = DataSourceExec::from_data_source(builder.build());
let table_fields: Vec<FieldRef> = self.table_schema.fields().iter().cloned().collect();
Ok(present_catalog_schema(
Arc::new(FileRowNumberExec::new(scan, vec![0])),
&table_fields,
&layout.name_mapping,
))
}
}
#[async_trait]
impl TableProvider for TableDeletionsTable {
fn schema(&self) -> SchemaRef {
self.output_schema.clone()
}
fn table_type(&self) -> TableType {
TableType::View
}
async fn scan(
&self,
state: &dyn Session,
projection: Option<&Vec<usize>>,
_filters: &[datafusion::prelude::Expr],
_limit: Option<usize>,
) -> DataFusionResult<Arc<dyn ExecutionPlan>> {
let delete_files = self
.provider
.get_delete_files_added_between_snapshots(
self.table_id,
self.start_snapshot,
self.end_snapshot,
)
.map_err(|e| DataFusionError::External(Box::new(e)))?;
if delete_files.is_empty() {
use datafusion::physical_plan::empty::EmptyExec;
let output_schema = match projection {
Some(indices) => {
let fields: Vec<Field> = indices
.iter()
.map(|&i| self.output_schema.field(i).clone())
.collect();
Arc::new(Schema::new(fields))
},
None => self.output_schema.clone(),
};
return Ok(Arc::new(EmptyExec::new(output_schema)));
}
let need_rowid = projection.is_none_or(|indices| indices.contains(&1));
let columns = self.resolve_columns()?;
let mut execs: Vec<Arc<dyn ExecutionPlan>> = Vec::with_capacity(delete_files.len());
for delete_file in &delete_files {
let exec = self
.build_exec_for_delete_entry(state, &columns, need_rowid, delete_file)
.await?;
execs.push(exec);
}
let full: Arc<dyn ExecutionPlan> = if execs.len() == 1 {
execs.into_iter().next().unwrap()
} else {
UnionExec::try_new(execs)?
};
match projection {
None => Ok(full),
Some(indices) => {
let exprs: Vec<(Arc<dyn PhysicalExpr>, String)> = indices
.iter()
.map(|&i| {
let f = self.output_schema.field(i);
(
Arc::new(Column::new(f.name(), i)) as Arc<dyn PhysicalExpr>,
f.name().to_string(),
)
})
.collect();
Ok(Arc::new(ProjectionExec::try_new(exprs, full)?))
},
}
}
}
#[derive(Debug, Clone)]
struct DeletionUnit {
current_delete_scan: Option<Arc<dyn ExecutionPlan>>,
previous_delete_scan: Option<Arc<dyn ExecutionPlan>>,
data_file_scan: Arc<dyn ExecutionPlan>,
record_count: i64,
snapshot_id: i64,
row_id_start: Option<i64>,
table_len: usize,
embedded_col_idx: Option<usize>,
pos_col_idx: usize,
need_rowid: bool,
cumulative: bool,
window: (i64, i64),
output_schema: SchemaRef,
}
#[derive(Debug)]
pub struct DeletedRowsExec {
unit: DeletionUnit,
properties: Arc<PlanProperties>,
}
impl DeletedRowsExec {
fn new(unit: DeletionUnit) -> Self {
let properties = Arc::new(PlanProperties::new(
EquivalenceProperties::new(unit.output_schema.clone()),
datafusion::physical_expr::Partitioning::UnknownPartitioning(1),
EmissionType::Final,
Boundedness::Bounded,
));
Self {
unit,
properties,
}
}
}
impl DisplayAs for DeletedRowsExec {
fn fmt_as(&self, t: DisplayFormatType, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match t {
DisplayFormatType::Default
| DisplayFormatType::Verbose
| DisplayFormatType::TreeRender => {
write!(
f,
"DeletedRowsExec: snapshot_id={}, full_delete={}, has_previous={}",
self.unit.snapshot_id,
self.unit.current_delete_scan.is_none(),
self.unit.previous_delete_scan.is_some()
)
},
}
}
}
impl ExecutionPlan for DeletedRowsExec {
fn name(&self) -> &str {
"DeletedRowsExec"
}
fn properties(&self) -> &Arc<PlanProperties> {
&self.properties
}
fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
vec![]
}
fn with_new_children(
self: Arc<Self>,
children: Vec<Arc<dyn ExecutionPlan>>,
) -> DataFusionResult<Arc<dyn ExecutionPlan>> {
if !children.is_empty() {
return Err(DataFusionError::Internal(
"DeletedRowsExec has no children".to_string(),
));
}
Ok(self)
}
fn execute(
&self,
partition: usize,
context: Arc<TaskContext>,
) -> DataFusionResult<SendableRecordBatchStream> {
if partition != 0 {
return Err(DataFusionError::Internal(format!(
"DeletedRowsExec only supports partition 0, got {partition}"
)));
}
let unit = self.unit.clone();
let schema = self.unit.output_schema.clone();
let stream = futures::stream::once(deleted_rows_stream(unit, context)).try_flatten();
Ok(Box::pin(RecordBatchStreamAdapter::new(schema, stream)))
}
fn schema(&self) -> SchemaRef {
self.unit.output_schema.clone()
}
}
async fn deleted_rows_stream(
unit: DeletionUnit,
context: Arc<TaskContext>,
) -> DataFusionResult<BoxStream<'static, DataFusionResult<RecordBatch>>> {
let mut position_snapshots: HashMap<i64, i64> = HashMap::new();
let current_positions: HashSet<i64> = match &unit.current_delete_scan {
Some(scan) => {
let batches = collect(Arc::clone(scan), context.clone()).await?;
let mut positions = HashSet::new();
for batch in &batches {
if unit.cumulative {
extract_windowed_positions(
batch,
unit.window,
&mut positions,
&mut position_snapshots,
)?;
} else {
positions.extend(extract_positions(batch)?);
}
}
positions
},
None => (0..unit.record_count).collect(),
};
let deleted_positions: HashSet<i64> = match &unit.previous_delete_scan {
Some(scan) => {
let batches = collect(Arc::clone(scan), context.clone()).await?;
let mut previous = HashSet::new();
for batch in &batches {
previous.extend(extract_positions(batch)?);
}
current_positions
.into_iter()
.filter(|pos| !previous.contains(pos))
.collect()
},
None => current_positions,
};
if deleted_positions.is_empty() {
return Ok(futures::stream::empty().boxed());
}
let data_stream = unit.data_file_scan.execute(0, context)?;
Ok(data_stream
.try_filter_map(move |batch| {
futures::future::ready(filter_batch(
&unit,
&batch,
&deleted_positions,
&position_snapshots,
))
})
.boxed())
}
fn extract_positions(batch: &RecordBatch) -> DataFusionResult<Vec<i64>> {
if batch.num_columns() < 2 {
return Ok(Vec::new());
}
let pos_array = batch
.column(1)
.as_any()
.downcast_ref::<Int64Array>()
.ok_or_else(|| DataFusionError::Internal("delete `pos` column is not Int64".to_string()))?;
Ok(pos_array.values().iter().copied().collect())
}
fn extract_windowed_positions(
batch: &RecordBatch,
window: (i64, i64),
positions: &mut HashSet<i64>,
position_snapshots: &mut HashMap<i64, i64>,
) -> DataFusionResult<()> {
if batch.num_columns() < 3 {
return Err(DataFusionError::Internal(
"cumulative delete file batch is missing its snapshot column".to_string(),
));
}
let pos = batch
.column(1)
.as_any()
.downcast_ref::<Int64Array>()
.ok_or_else(|| DataFusionError::Internal("delete `pos` column is not Int64".to_string()))?;
let snaps = batch
.column(2)
.as_any()
.downcast_ref::<Int64Array>()
.ok_or_else(|| {
DataFusionError::Internal("delete snapshot column is not Int64".to_string())
})?;
for i in 0..batch.num_rows() {
if snaps.is_null(i) {
return Err(DataFusionError::Internal(
"cumulative delete file has a NULL per-row snapshot".to_string(),
));
}
let s = snaps.value(i);
if s >= window.0 && s <= window.1 {
let p = pos.value(i);
positions.insert(p);
position_snapshots.insert(p, s);
}
}
Ok(())
}
fn filter_batch(
unit: &DeletionUnit,
batch: &RecordBatch,
deleted_positions: &HashSet<i64>,
position_snapshots: &HashMap<i64, i64>,
) -> DataFusionResult<Option<RecordBatch>> {
let num_rows = batch.num_rows();
let pos = batch
.column(unit.pos_col_idx)
.as_any()
.downcast_ref::<Int64Array>()
.ok_or_else(|| {
DataFusionError::Internal(format!(
"physical-position column {ROW_POS_COLUMN_NAME} is missing or not Int64"
))
})?;
let embedded = match unit.embedded_col_idx {
Some(idx) => Some(
batch
.column(idx)
.as_any()
.downcast_ref::<Int64Array>()
.ok_or_else(|| {
DataFusionError::Internal("embedded rowid column is not Int64".to_string())
})?,
),
None => None,
};
let synth_start: Option<i64> = if unit.need_rowid && embedded.is_none() {
Some(unit.row_id_start.ok_or_else(|| {
DataFusionError::Internal(
"cannot synthesize deleted rowid: source file has neither an embedded \
rowid nor a row_id_start"
.to_string(),
)
})?)
} else {
None
};
let mut keep_indices: Vec<u32> = Vec::new();
let mut rowids: Vec<i64> = Vec::new();
let mut snapshots: Vec<i64> = Vec::new();
for i in 0..num_rows {
let physical_pos = pos.value(i);
if deleted_positions.contains(&physical_pos) {
keep_indices.push(i as u32);
let rowid = if !unit.need_rowid {
0
} else {
match (embedded, synth_start) {
(Some(arr), _) => arr.value(i),
(None, Some(start)) => start + physical_pos,
(None, None) => unreachable!("row_id_start resolved above"),
}
};
rowids.push(rowid);
snapshots.push(if unit.cumulative {
*position_snapshots
.get(&physical_pos)
.unwrap_or(&unit.snapshot_id)
} else {
unit.snapshot_id
});
}
}
if keep_indices.is_empty() {
return Ok(None);
}
let indices = UInt32Array::from(keep_indices.clone());
let mut columns: Vec<ArrayRef> = Vec::with_capacity(unit.table_len + 3);
columns.push(Arc::new(Int64Array::from(snapshots)));
columns.push(Arc::new(Int64Array::from(rowids)));
columns.push(Arc::new(StringArray::from(vec![
"delete";
keep_indices.len()
])));
for col in batch.columns().iter().take(unit.table_len) {
let filtered = take(col.as_ref(), &indices, None)
.map_err(|e| DataFusionError::ArrowError(Box::new(e), None))?;
columns.push(filtered);
}
RecordBatch::try_new(unit.output_schema.clone(), columns)
.map(Some)
.map_err(|e| DataFusionError::ArrowError(Box::new(e), None))
}