use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use arrow::array::{Array, Int64Array};
use arrow::datatypes::SchemaRef;
use arrow::record_batch::RecordBatch;
use datafusion::error::{DataFusionError, Result as DataFusionResult};
use datafusion::execution::{RecordBatchStream, SendableRecordBatchStream, TaskContext};
use datafusion::physical_plan::{DisplayAs, DisplayFormatType, ExecutionPlan, PlanProperties};
use futures::Stream;
#[derive(Debug)]
pub struct SnapshotFilterExec {
input: Arc<dyn ExecutionPlan>,
snapshot_column: String,
read_snapshot: i64,
snapshot_index: usize,
properties: Arc<PlanProperties>,
}
impl SnapshotFilterExec {
pub fn try_new(
input: Arc<dyn ExecutionPlan>,
snapshot_column: String,
read_snapshot: i64,
) -> DataFusionResult<Self> {
let snapshot_index = input.schema().index_of(&snapshot_column).map_err(|_| {
DataFusionError::Internal(format!(
"SnapshotFilterExec input is missing the `{snapshot_column}` column"
))
})?;
let properties = input.properties().clone();
Ok(Self {
input,
snapshot_column,
read_snapshot,
snapshot_index,
properties,
})
}
}
impl DisplayAs for SnapshotFilterExec {
fn fmt_as(&self, _t: DisplayFormatType, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(
f,
"SnapshotFilterExec: keep origin <= {}",
self.read_snapshot
)
}
}
impl ExecutionPlan for SnapshotFilterExec {
fn name(&self) -> &str {
"SnapshotFilterExec"
}
fn properties(&self) -> &Arc<PlanProperties> {
&self.properties
}
fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
vec![&self.input]
}
fn maintains_input_order(&self) -> Vec<bool> {
vec![true]
}
fn with_new_children(
self: Arc<Self>,
children: Vec<Arc<dyn ExecutionPlan>>,
) -> DataFusionResult<Arc<dyn ExecutionPlan>> {
if children.len() != 1 {
return Err(DataFusionError::Internal(
"SnapshotFilterExec expects exactly one child".into(),
));
}
Ok(Arc::new(SnapshotFilterExec::try_new(
children.into_iter().next().unwrap(),
self.snapshot_column.clone(),
self.read_snapshot,
)?))
}
fn execute(
&self,
partition: usize,
context: Arc<TaskContext>,
) -> DataFusionResult<SendableRecordBatchStream> {
Ok(Box::pin(SnapshotFilterStream {
input: self.input.execute(partition, context)?,
snapshot_column: self.snapshot_column.clone(),
read_snapshot: self.read_snapshot,
snapshot_index: self.snapshot_index,
}))
}
}
struct SnapshotFilterStream {
input: SendableRecordBatchStream,
snapshot_column: String,
read_snapshot: i64,
snapshot_index: usize,
}
impl Stream for SnapshotFilterStream {
type Item = DataFusionResult<RecordBatch>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
match Pin::new(&mut self.input).poll_next(cx) {
Poll::Ready(Some(Ok(batch))) => Poll::Ready(Some(self.filter_batch(&batch))),
Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
Poll::Ready(None) => Poll::Ready(None),
Poll::Pending => Poll::Pending,
}
}
}
impl SnapshotFilterStream {
fn filter_batch(&self, batch: &RecordBatch) -> DataFusionResult<RecordBatch> {
let snap = batch
.column(self.snapshot_index)
.as_any()
.downcast_ref::<Int64Array>()
.ok_or_else(|| {
DataFusionError::Internal(format!("`{}` column is not Int64", self.snapshot_column))
})?;
let num_rows = batch.num_rows();
let mut keep_indices: Vec<u32> = Vec::with_capacity(num_rows);
for i in 0..num_rows {
if snap.is_null(i) || snap.value(i) <= self.read_snapshot {
keep_indices.push(i as u32);
}
}
if keep_indices.len() == num_rows {
return Ok(batch.clone());
}
use arrow::array::UInt32Array;
use arrow::compute::take;
let indices = UInt32Array::from(keep_indices);
let filtered_columns: DataFusionResult<Vec<_>> = batch
.columns()
.iter()
.map(|col| {
take(col.as_ref(), &indices, None)
.map_err(|e| DataFusionError::ArrowError(Box::new(e), None))
})
.collect();
RecordBatch::try_new(batch.schema(), filtered_columns?)
.map_err(|e| DataFusionError::ArrowError(Box::new(e), None))
}
}
impl RecordBatchStream for SnapshotFilterStream {
fn schema(&self) -> SchemaRef {
self.input.schema()
}
}
#[cfg(test)]
mod tests {
use super::*;
use arrow::array::{Array, ArrayRef, Int32Array};
use arrow::datatypes::{DataType, Field, Schema};
use datafusion::physical_plan::EmptyRecordBatchStream;
fn batch(values: &[i32], origins: &[i64]) -> (SchemaRef, RecordBatch) {
let schema = Arc::new(Schema::new(vec![
Field::new("id", DataType::Int32, false),
Field::new("_ducklake_internal_snapshot_id", DataType::Int64, true),
]));
let b = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(Int32Array::from(values.to_vec())) as ArrayRef,
Arc::new(Int64Array::from(origins.to_vec())) as ArrayRef,
],
)
.unwrap();
(schema, b)
}
fn stream(schema: SchemaRef, read_snapshot: i64) -> SnapshotFilterStream {
SnapshotFilterStream {
input: Box::pin(EmptyRecordBatchStream::new(schema)),
snapshot_column: "_ducklake_internal_snapshot_id".to_string(),
read_snapshot,
snapshot_index: 1,
}
}
fn ids(b: &RecordBatch) -> Vec<i32> {
b.column(0)
.as_any()
.downcast_ref::<Int32Array>()
.unwrap()
.values()
.to_vec()
}
#[test]
fn keeps_rows_at_or_below_read_snapshot() {
let (schema, b) = batch(&[10, 20, 30], &[1, 2, 3]);
let filtered = stream(schema, 2).filter_batch(&b).unwrap();
assert_eq!(ids(&filtered), vec![10, 20]);
}
#[test]
fn keeps_all_when_read_snapshot_at_or_above_max() {
let (schema, b) = batch(&[10, 20, 30], &[1, 2, 3]);
let filtered = stream(schema, 3).filter_batch(&b).unwrap();
assert_eq!(ids(&filtered), vec![10, 20, 30]);
}
#[test]
fn drops_all_when_read_snapshot_below_min() {
let (schema, b) = batch(&[10, 20], &[5, 6]);
let filtered = stream(schema, 1).filter_batch(&b).unwrap();
assert!(ids(&filtered).is_empty());
}
}