use std::collections::HashSet;
use std::fmt::{self, Debug};
use std::sync::Arc;
use arrow::array::{ArrayRef, RecordBatch, UInt64Array};
use arrow::datatypes::{DataType, Field, Schema, SchemaRef};
use datafusion::catalog::Session;
use datafusion::error::{DataFusionError, Result as DataFusionResult};
use datafusion::execution::object_store::ObjectStoreUrl;
use datafusion::execution::{SendableRecordBatchStream, SessionState, TaskContext};
use datafusion::physical_expr::{EquivalenceProperties, Partitioning, PhysicalExpr};
use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
use datafusion::physical_plan::{DisplayAs, DisplayFormatType, ExecutionPlan, PlanProperties};
use futures::stream;
use crate::metadata_writer::{DeleteFileEntry, MetadataWriter};
use crate::table::DuckLakeTable;
use crate::table_writer::DuckLakeTableWriter;
fn make_delete_count_schema() -> SchemaRef {
Arc::new(Schema::new(vec![Field::new(
"count",
DataType::UInt64,
false,
)]))
}
pub struct DuckLakeDeleteExec {
table: Arc<DuckLakeTable>,
session_state: SessionState,
predicate: Option<Arc<dyn PhysicalExpr>>,
writer: Arc<dyn MetadataWriter>,
object_store_url: Arc<ObjectStoreUrl>,
schema_name: String,
table_name: String,
table_id: i64,
base_snapshot: i64,
cache: Arc<PlanProperties>,
}
impl DuckLakeDeleteExec {
#[allow(clippy::too_many_arguments)]
pub fn new(
table: Arc<DuckLakeTable>,
session_state: SessionState,
predicate: Option<Arc<dyn PhysicalExpr>>,
writer: Arc<dyn MetadataWriter>,
schema_name: String,
table_name: String,
table_id: i64,
base_snapshot: i64,
object_store_url: Arc<ObjectStoreUrl>,
) -> Self {
let cache = Self::compute_properties();
Self {
table,
session_state,
predicate,
writer,
object_store_url,
schema_name,
table_name,
table_id,
base_snapshot,
cache,
}
}
fn compute_properties() -> Arc<PlanProperties> {
Arc::new(PlanProperties::new(
EquivalenceProperties::new(make_delete_count_schema()),
Partitioning::UnknownPartitioning(1),
datafusion::physical_plan::execution_plan::EmissionType::Final,
datafusion::physical_plan::execution_plan::Boundedness::Bounded,
))
}
}
impl Debug for DuckLakeDeleteExec {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DuckLakeDeleteExec")
.field("schema_name", &self.schema_name)
.field("table_name", &self.table_name)
.field("table_id", &self.table_id)
.field("delete_all", &self.predicate.is_none())
.finish_non_exhaustive()
}
}
impl DisplayAs for DuckLakeDeleteExec {
fn fmt_as(&self, t: DisplayFormatType, f: &mut fmt::Formatter) -> fmt::Result {
match t {
DisplayFormatType::Default
| DisplayFormatType::Verbose
| DisplayFormatType::TreeRender => {
write!(
f,
"DuckLakeDeleteExec: table={}.{}, predicate={}",
self.schema_name,
self.table_name,
if self.predicate.is_some() {
"filter"
} else {
"all-rows"
}
)
},
}
}
}
impl ExecutionPlan for DuckLakeDeleteExec {
fn name(&self) -> &str {
"DuckLakeDeleteExec"
}
fn properties(&self) -> &Arc<PlanProperties> {
&self.cache
}
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(
"DuckLakeDeleteExec does not take any children".to_string(),
));
}
Ok(self)
}
fn execute(
&self,
partition: usize,
_context: Arc<TaskContext>,
) -> DataFusionResult<SendableRecordBatchStream> {
if partition != 0 {
return Err(DataFusionError::Internal(format!(
"DuckLakeDeleteExec only supports partition 0, got {}",
partition
)));
}
let table = Arc::clone(&self.table);
let session_state = self.session_state.clone();
let predicate = self.predicate.clone();
let writer = Arc::clone(&self.writer);
let object_store_url = Arc::clone(&self.object_store_url);
let schema_name = self.schema_name.clone();
let table_name = self.table_name.clone();
let table_id = self.table_id;
let base_snapshot = self.base_snapshot;
let output_schema = make_delete_count_schema();
let stream = stream::once(async move {
let deleted = run_delete(
table,
&session_state,
predicate,
writer,
object_store_url,
&schema_name,
&table_name,
table_id,
base_snapshot,
)
.await?;
let count: ArrayRef = Arc::new(UInt64Array::from(vec![deleted]));
Ok(RecordBatch::try_new(output_schema, vec![count])?)
});
Ok(Box::pin(RecordBatchStreamAdapter::new(
make_delete_count_schema(),
stream,
)))
}
}
#[allow(clippy::too_many_arguments)]
async fn run_delete(
table: Arc<DuckLakeTable>,
session_state: &SessionState,
predicate: Option<Arc<dyn PhysicalExpr>>,
writer: Arc<dyn MetadataWriter>,
object_store_url: Arc<ObjectStoreUrl>,
schema_name: &str,
table_name: &str,
table_id: i64,
base_snapshot: i64,
) -> DataFusionResult<u64> {
let state: &dyn Session = session_state;
let predicate = match predicate {
None => {
if table.files().is_empty() {
return Ok(0);
}
return writer
.commit_truncate(table_id, schema_name, table_name, base_snapshot)
.map_err(|e| DataFusionError::External(Box::new(e)));
},
Some(p) => p,
};
let object_store = state
.runtime_env()
.object_store(object_store_url.as_ref())?;
let table_writer = DuckLakeTableWriter::new(Arc::clone(&writer), object_store)
.map_err(|e| DataFusionError::External(Box::new(e)))?;
let mut entries: Vec<DeleteFileEntry> = Vec::new();
let mut total_deleted: u64 = 0;
for tf in table.files() {
if table.file_has_embedded_rowid(state, &tf.file).await? {
return Err(DataFusionError::NotImplemented(format!(
"DELETE on data file '{}' is not supported: the file was rewritten by an \
UPDATE or compaction (it embeds a row-id column), and v1 resolves delete \
positions only for insert-only files",
tf.file.path
)));
}
let matched = table
.resolve_positions(state, &tf.file, Arc::clone(&predicate))
.await?;
if matched.is_empty() {
continue;
}
let existing = match tf.delete_file {
Some(ref df) => table.read_delete_file_positions(state, df).await?,
None => HashSet::new(),
};
let newly_deleted = matched.difference(&existing).count() as u64;
if newly_deleted == 0 {
continue;
}
let mut cumulative: Vec<i64> = existing.union(&matched).copied().collect();
cumulative.sort_unstable();
let delete_info = table_writer
.write_delete_file(schema_name, table_name, &tf.file.path, &cumulative)
.await
.map_err(|e| DataFusionError::External(Box::new(e)))?;
entries.push(DeleteFileEntry {
data_file_id: tf.data_file_id,
expected_prev_delete_file: tf.delete_file_id,
delete: delete_info,
});
total_deleted += newly_deleted;
}
if entries.is_empty() {
return Ok(0);
}
writer
.commit_positional_deletes(table_id, schema_name, table_name, base_snapshot, &entries)
.map_err(|e| DataFusionError::External(Box::new(e)))?;
Ok(total_deleted)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_delete_count_schema() {
let schema = make_delete_count_schema();
assert_eq!(schema.fields().len(), 1);
assert_eq!(schema.field(0).name(), "count");
assert_eq!(schema.field(0).data_type(), &DataType::UInt64);
}
}