Skip to main content

delta_funnel/pipeline/
batch_handoff.rs

1//! Batch pipeline foundation for query-result handoff.
2//!
3//! This module owns the thin async boundary between DataFusion query output and
4//! downstream batch writers. The handoff is deliberately pull-driven: one
5//! upstream batch is polled, one downstream write is awaited, and only then is
6//! the next upstream batch polled.
7
8use std::{fmt, sync::Arc};
9
10use async_trait::async_trait;
11use datafusion::{
12    arrow::record_batch::RecordBatch,
13    error::{DataFusionError, Result as DataFusionResult},
14    execution::TaskContext,
15    physical_plan::ExecutionPlan,
16};
17use futures_util::{
18    Stream, StreamExt,
19    io::{AsyncRead, AsyncWrite},
20};
21use snafu::Snafu;
22
23use crate::{
24    DeltaFunnelError, query_engine::datafusion_query_output_stream, sql_server::MssqlBulkLoadWriter,
25};
26
27/// Phase for batch pipeline setup and configuration failures.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum BatchPipelinePhase {
30    /// Caller-supplied batch pipeline or query-output configuration is invalid.
31    Configuration,
32    /// The handoff between a query output and downstream consumer cannot be set up.
33    HandoffSetup,
34}
35
36impl fmt::Display for BatchPipelinePhase {
37    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
38        formatter.write_str(match self {
39            Self::Configuration => "configuration",
40            Self::HandoffSetup => "handoff setup",
41        })
42    }
43}
44
45/// Successful result for one completed query-output handoff.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct BatchHandoffOutcome {
48    stats: BatchHandoffStats,
49}
50
51impl BatchHandoffOutcome {
52    /// Returns the final per-output handoff counters.
53    pub fn stats(&self) -> BatchHandoffStats {
54        self.stats
55    }
56}
57
58/// Terminal failure from a query-output handoff.
59///
60/// Upstream errors come from the DataFusion stream. Downstream errors come from
61/// the batch consumer, which will later be backed by `arrow-tiberius`.
62#[derive(Debug, Snafu)]
63pub enum BatchHandoffError {
64    /// The selected DataFusion query output could not be exposed as a stream.
65    #[snafu(display("DataFusion query output handoff setup failed: {source}"))]
66    QueryOutputSetup {
67        /// Original DataFusion setup error.
68        source: DataFusionError,
69        /// Handoff counters for batches accepted before the failure.
70        stats: BatchHandoffStats,
71    },
72    /// The upstream DataFusion stream failed before producing the next batch.
73    #[snafu(display("upstream RecordBatch stream failed: {source}"))]
74    Upstream {
75        /// Original DataFusion error with its existing provider/query context.
76        source: DataFusionError,
77        /// Handoff counters for batches accepted before the failure.
78        stats: BatchHandoffStats,
79    },
80    /// The downstream consumer rejected the current batch.
81    #[snafu(display("downstream RecordBatch consumer failed: {source}"))]
82    Downstream {
83        /// Original downstream writer error.
84        source: DeltaFunnelError,
85        /// Handoff counters for batches accepted before the failure.
86        stats: BatchHandoffStats,
87    },
88}
89
90impl BatchHandoffError {
91    /// Returns counters for batches accepted before the terminal failure.
92    pub fn stats(&self) -> BatchHandoffStats {
93        match self {
94            Self::QueryOutputSetup { stats, .. }
95            | Self::Upstream { stats, .. }
96            | Self::Downstream { stats, .. } => *stats,
97        }
98    }
99}
100
101/// Downstream consumer for one query-output `RecordBatch`.
102///
103/// Implementations should return only after the batch has been accepted by the
104/// downstream system. That await point is what preserves backpressure between
105/// DataFusion and the downstream writer.
106///
107/// Consumers that need a separate finalization step, such as
108/// `arrow_tiberius::BulkWriter::finish`, keep owning that step. The handoff only
109/// drives per-batch writes so callers can decide whether and when finalization
110/// is appropriate after success or failure.
111#[async_trait]
112pub trait RecordBatchConsumer: Send {
113    /// Writes one batch without changing its schema, values, or row order.
114    async fn write_record_batch(&mut self, batch: &RecordBatch) -> Result<(), DeltaFunnelError>;
115}
116
117#[async_trait]
118impl<'client, S> RecordBatchConsumer for arrow_tiberius::BulkWriter<'client, S>
119where
120    S: AsyncRead + AsyncWrite + Unpin + Send,
121{
122    async fn write_record_batch(&mut self, batch: &RecordBatch) -> Result<(), DeltaFunnelError> {
123        MssqlBulkLoadWriter::write_batch(self, batch)
124            .await
125            .map(|_stats| ())
126            .map_err(|source| DeltaFunnelError::MssqlWrite { source })
127    }
128}
129
130/// Per-output batch handoff counters.
131///
132/// `input_*` counters describe batches observed from the upstream DataFusion
133/// stream. `output_*` counters describe batches accepted by the downstream
134/// consumer. Keeping the two sides separate lets later sink-failure handling
135/// report only work that was actually accepted downstream.
136#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
137pub struct BatchHandoffStats {
138    /// Batches observed from the upstream query output.
139    pub input_batches: u64,
140    /// Rows observed from the upstream query output.
141    pub input_rows: u64,
142    /// Batches accepted by the downstream consumer.
143    pub output_batches: u64,
144    /// Rows accepted by the downstream consumer.
145    pub output_rows: u64,
146}
147
148impl BatchHandoffStats {
149    /// Records one batch observed from the upstream query output.
150    pub fn record_input_batch(&mut self, row_count: usize) {
151        self.input_batches = self.input_batches.saturating_add(1);
152        self.input_rows = self.input_rows.saturating_add(rows_to_u64(row_count));
153    }
154
155    /// Records one batch accepted by the downstream consumer.
156    pub fn record_output_batch(&mut self, row_count: usize) {
157        self.output_batches = self.output_batches.saturating_add(1);
158        self.output_rows = self.output_rows.saturating_add(rows_to_u64(row_count));
159    }
160}
161
162/// Hands one DataFusion `RecordBatch` stream to one downstream consumer.
163///
164/// This helper intentionally contains no queue and spawns no background task.
165/// The next upstream batch is not polled until the previous downstream write
166/// has completed. Empty batches are forwarded and counted when the downstream
167/// consumer accepts them, matching DataFusion's stream semantics.
168pub async fn handoff_record_batch_stream<S, C>(
169    mut stream: S,
170    consumer: &mut C,
171) -> Result<BatchHandoffOutcome, BatchHandoffError>
172where
173    S: Stream<Item = DataFusionResult<RecordBatch>> + Unpin,
174    C: RecordBatchConsumer,
175{
176    let mut stats = BatchHandoffStats::default();
177
178    while let Some(batch) = stream.next().await {
179        let batch = batch.map_err(|source| BatchHandoffError::Upstream { source, stats })?;
180        let row_count = batch.num_rows();
181        let accepted_stats = stats;
182
183        stats.record_input_batch(row_count);
184        if let Err(source) = consumer.write_record_batch(&batch).await {
185            return Err(BatchHandoffError::Downstream {
186                source,
187                stats: accepted_stats,
188            });
189        }
190        stats.record_output_batch(row_count);
191    }
192
193    Ok(BatchHandoffOutcome { stats })
194}
195
196/// Executes one selected DataFusion query output and hands it to a consumer.
197///
198/// This composes DataFusion's merged output stream execution with the
199/// pull-driven batch handoff. Multi-partition query outputs are merged by
200/// DataFusion before the handoff, preserving scan parallelism while keeping the
201/// downstream writer serial.
202pub async fn handoff_datafusion_query_output<C>(
203    plan: Arc<dyn ExecutionPlan>,
204    task_context: Arc<TaskContext>,
205    consumer: &mut C,
206) -> Result<BatchHandoffOutcome, BatchHandoffError>
207where
208    C: RecordBatchConsumer,
209{
210    let stream = datafusion_query_output_stream(plan, task_context).map_err(|source| {
211        BatchHandoffError::QueryOutputSetup {
212            source,
213            stats: BatchHandoffStats::default(),
214        }
215    })?;
216
217    handoff_record_batch_stream(stream, consumer).await
218}
219
220/// Validates a future batch/query/handoff `usize` option that must be nonzero.
221#[allow(dead_code)]
222pub(crate) fn validate_nonzero_usize_option(
223    phase: BatchPipelinePhase,
224    option: &'static str,
225    value: usize,
226) -> Result<(), DeltaFunnelError> {
227    if value == 0 {
228        return Err(DeltaFunnelError::BatchPipeline {
229            phase,
230            option,
231            message: "must be greater than zero".to_owned(),
232        });
233    }
234
235    Ok(())
236}
237
238fn rows_to_u64(row_count: usize) -> u64 {
239    u64::try_from(row_count).unwrap_or(u64::MAX)
240}
241
242#[cfg(test)]
243mod tests {
244    use std::{
245        collections::VecDeque,
246        error::Error,
247        io::Cursor,
248        pin::Pin,
249        sync::{
250            Arc, Mutex,
251            atomic::{AtomicUsize, Ordering},
252        },
253        task::{Context, Poll},
254    };
255
256    use async_trait::async_trait;
257    use datafusion::{
258        arrow::{
259            array::Int32Array,
260            datatypes::{DataType, Field, Schema, SchemaRef},
261            record_batch::RecordBatch,
262        },
263        error::DataFusionError,
264        execution::TaskContext,
265        physical_plan::{ExecutionPlan, test::TestMemoryExec},
266    };
267    use futures_util::{Stream, io::AllowStdIo, stream};
268    use tokio::sync::oneshot;
269
270    use super::{
271        BatchHandoffError, BatchHandoffStats, BatchPipelinePhase, RecordBatchConsumer,
272        handoff_datafusion_query_output, handoff_record_batch_stream, rows_to_u64,
273        validate_nonzero_usize_option,
274    };
275    use crate::DeltaFunnelError;
276
277    #[test]
278    fn stats_start_at_zero() {
279        let stats = BatchHandoffStats::default();
280
281        assert_eq!(stats.input_batches, 0);
282        assert_eq!(stats.input_rows, 0);
283        assert_eq!(stats.output_batches, 0);
284        assert_eq!(stats.output_rows, 0);
285    }
286
287    #[test]
288    fn stats_update_input_and_output_separately() {
289        let mut stats = BatchHandoffStats::default();
290
291        stats.record_input_batch(5);
292        stats.record_input_batch(7);
293        stats.record_output_batch(5);
294
295        assert_eq!(stats.input_batches, 2);
296        assert_eq!(stats.input_rows, 12);
297        assert_eq!(stats.output_batches, 1);
298        assert_eq!(stats.output_rows, 5);
299    }
300
301    #[test]
302    fn stats_updates_saturate() {
303        let mut stats = BatchHandoffStats {
304            input_batches: u64::MAX,
305            input_rows: u64::MAX - 1,
306            output_batches: u64::MAX,
307            output_rows: u64::MAX - 1,
308        };
309
310        stats.record_input_batch(10);
311        stats.record_output_batch(10);
312
313        assert_eq!(stats.input_batches, u64::MAX);
314        assert_eq!(stats.input_rows, u64::MAX);
315        assert_eq!(stats.output_batches, u64::MAX);
316        assert_eq!(stats.output_rows, u64::MAX);
317    }
318
319    #[test]
320    fn rows_to_u64_returns_exact_normal_values() {
321        assert_eq!(rows_to_u64(42), 42);
322    }
323
324    #[test]
325    fn validation_accepts_nonzero_values() -> Result<(), DeltaFunnelError> {
326        validate_nonzero_usize_option(BatchPipelinePhase::Configuration, "output_batch_size", 1)
327    }
328
329    #[test]
330    fn validation_rejects_zero_values() {
331        let error = validate_nonzero_usize_option(
332            BatchPipelinePhase::Configuration,
333            "output_batch_size",
334            0,
335        );
336
337        assert!(matches!(
338            error,
339            Err(DeltaFunnelError::BatchPipeline {
340                phase: BatchPipelinePhase::Configuration,
341                option: "output_batch_size",
342                ..
343            })
344        ));
345    }
346
347    #[test]
348    fn phase_display_is_stable() {
349        assert_eq!(
350            BatchPipelinePhase::Configuration.to_string(),
351            "configuration"
352        );
353        assert_eq!(
354            BatchPipelinePhase::HandoffSetup.to_string(),
355            "handoff setup"
356        );
357    }
358
359    #[test]
360    fn arrow_tiberius_bulk_writer_is_a_record_batch_consumer() {
361        fn assert_consumer<C: RecordBatchConsumer>() {}
362
363        assert_consumer::<arrow_tiberius::BulkWriter<'static, AllowStdIo<Cursor<Vec<u8>>>>>();
364    }
365
366    #[tokio::test]
367    async fn handoff_forwards_batches_in_order() -> Result<(), Box<dyn Error>> {
368        let batches = vec![Ok(int_batch(&[1, 2])?), Ok(int_batch(&[3, 4, 5])?)];
369        let mut consumer = RecordingConsumer::default();
370
371        let outcome = handoff_record_batch_stream(stream::iter(batches), &mut consumer).await?;
372
373        assert_eq!(consumer.accepted_row_counts, vec![2, 3]);
374        assert_eq!(
375            outcome.stats(),
376            BatchHandoffStats {
377                input_batches: 2,
378                input_rows: 5,
379                output_batches: 2,
380                output_rows: 5,
381            }
382        );
383        Ok(())
384    }
385
386    #[tokio::test]
387    async fn handoff_counts_empty_batches_when_accepted() -> Result<(), Box<dyn Error>> {
388        let batches = vec![Ok(int_batch(&[])?), Ok(int_batch(&[1, 2])?)];
389        let mut consumer = RecordingConsumer::default();
390
391        let outcome = handoff_record_batch_stream(stream::iter(batches), &mut consumer).await?;
392
393        assert_eq!(consumer.accepted_row_counts, vec![0, 2]);
394        assert_eq!(
395            outcome.stats(),
396            BatchHandoffStats {
397                input_batches: 2,
398                input_rows: 2,
399                output_batches: 2,
400                output_rows: 2,
401            }
402        );
403        Ok(())
404    }
405
406    #[tokio::test]
407    async fn handoff_keeps_selected_output_stats_independent() -> Result<(), Box<dyn Error>> {
408        let first_batches = vec![Ok(int_batch(&[1])?)];
409        let second_batches = vec![Ok(int_batch(&[10, 20])?), Ok(int_batch(&[30])?)];
410        let mut first_consumer = RecordingConsumer::default();
411        let mut second_consumer = RecordingConsumer::default();
412
413        let first =
414            handoff_record_batch_stream(stream::iter(first_batches), &mut first_consumer).await?;
415        let second =
416            handoff_record_batch_stream(stream::iter(second_batches), &mut second_consumer).await?;
417
418        assert_eq!(
419            first.stats(),
420            BatchHandoffStats {
421                input_batches: 1,
422                input_rows: 1,
423                output_batches: 1,
424                output_rows: 1,
425            }
426        );
427        assert_eq!(
428            second.stats(),
429            BatchHandoffStats {
430                input_batches: 2,
431                input_rows: 3,
432                output_batches: 2,
433                output_rows: 3,
434            }
435        );
436        assert_eq!(first_consumer.accepted_row_counts, vec![1]);
437        assert_eq!(second_consumer.accepted_row_counts, vec![2, 1]);
438        Ok(())
439    }
440
441    #[tokio::test]
442    async fn handoff_datafusion_query_output_merges_partitions() -> Result<(), Box<dyn Error>> {
443        let schema = schema();
444        let plan = TestMemoryExec::try_new_exec(
445            &[
446                vec![int_batch_with_schema(Arc::clone(&schema), &[1])?],
447                vec![int_batch_with_schema(Arc::clone(&schema), &[2, 3])?],
448            ],
449            schema,
450            None,
451        )?;
452        assert_eq!(plan.properties().output_partitioning().partition_count(), 2);
453
454        let plan: Arc<dyn ExecutionPlan> = plan;
455        let mut consumer = RecordingConsumer::default();
456
457        let outcome =
458            handoff_datafusion_query_output(plan, Arc::new(TaskContext::default()), &mut consumer)
459                .await?;
460
461        consumer.accepted_row_counts.sort_unstable();
462        assert_eq!(consumer.accepted_row_counts, vec![1, 2]);
463        assert_eq!(
464            outcome.stats(),
465            BatchHandoffStats {
466                input_batches: 2,
467                input_rows: 3,
468                output_batches: 2,
469                output_rows: 3,
470            }
471        );
472        Ok(())
473    }
474
475    #[tokio::test]
476    async fn handoff_preserves_upstream_error_context() -> Result<(), Box<dyn Error>> {
477        let batches = vec![
478            Ok(int_batch(&[1, 2])?),
479            Err(DataFusionError::Execution("upstream failed".to_owned())),
480            Ok(int_batch(&[3])?),
481        ];
482        let mut consumer = RecordingConsumer::default();
483
484        let error = handoff_record_batch_stream(stream::iter(batches), &mut consumer)
485            .await
486            .err()
487            .ok_or("handoff should fail on upstream error")?;
488
489        assert_eq!(consumer.accepted_row_counts, vec![2]);
490        assert_eq!(
491            error.stats(),
492            BatchHandoffStats {
493                input_batches: 1,
494                input_rows: 2,
495                output_batches: 1,
496                output_rows: 2,
497            }
498        );
499        assert!(matches!(error, BatchHandoffError::Upstream { .. }));
500        assert!(error.to_string().contains("upstream failed"));
501        Ok(())
502    }
503
504    #[tokio::test]
505    async fn handoff_stops_after_downstream_failure() -> Result<(), Box<dyn Error>> {
506        let batches = vec![
507            Ok(int_batch(&[1, 2])?),
508            Ok(int_batch(&[3, 4, 5])?),
509            Ok(int_batch(&[6])?),
510        ];
511        let mut consumer = RecordingConsumer {
512            fail_on_call: Some(1),
513            ..RecordingConsumer::default()
514        };
515
516        let error = handoff_record_batch_stream(stream::iter(batches), &mut consumer)
517            .await
518            .err()
519            .ok_or("handoff should fail on downstream error")?;
520
521        assert_eq!(consumer.accepted_row_counts, vec![2]);
522        assert_eq!(consumer.call_count, 2);
523        assert_eq!(
524            error.stats(),
525            BatchHandoffStats {
526                input_batches: 1,
527                input_rows: 2,
528                output_batches: 1,
529                output_rows: 2,
530            }
531        );
532        assert!(matches!(error, BatchHandoffError::Downstream { .. }));
533        assert!(error.to_string().contains("consumer failed"));
534        Ok(())
535    }
536
537    #[tokio::test]
538    async fn slow_downstream_blocks_next_upstream_poll() -> Result<(), Box<dyn Error>> {
539        let poll_count = Arc::new(AtomicUsize::new(0));
540        let accepted_row_counts = Arc::new(Mutex::new(Vec::new()));
541        let stream = PollCountingStream {
542            batches: VecDeque::from(vec![int_batch(&[1])?, int_batch(&[2])?]),
543            poll_count: Arc::clone(&poll_count),
544        };
545        let (release_write, wait_for_release) = oneshot::channel();
546        let consumer = GatedConsumer {
547            accepted_row_counts: Arc::clone(&accepted_row_counts),
548            first_write_gate: Some(wait_for_release),
549        };
550
551        let task = tokio::spawn(async move {
552            let mut consumer = consumer;
553            handoff_record_batch_stream(stream, &mut consumer).await
554        });
555
556        tokio::task::yield_now().await;
557        assert_eq!(poll_count.load(Ordering::SeqCst), 1);
558        assert!(release_write.send(()).is_ok());
559
560        let outcome = task.await??;
561
562        assert_eq!(poll_count.load(Ordering::SeqCst), 3);
563        assert_eq!(
564            *accepted_row_counts.lock().map_err(|_| "mutex poisoned")?,
565            vec![1, 1]
566        );
567        assert_eq!(
568            outcome.stats(),
569            BatchHandoffStats {
570                input_batches: 2,
571                input_rows: 2,
572                output_batches: 2,
573                output_rows: 2,
574            }
575        );
576        Ok(())
577    }
578
579    #[derive(Default)]
580    struct RecordingConsumer {
581        accepted_row_counts: Vec<usize>,
582        call_count: usize,
583        fail_on_call: Option<usize>,
584    }
585
586    #[async_trait]
587    impl RecordBatchConsumer for RecordingConsumer {
588        async fn write_record_batch(
589            &mut self,
590            batch: &RecordBatch,
591        ) -> Result<(), DeltaFunnelError> {
592            if self.fail_on_call == Some(self.call_count) {
593                self.call_count += 1;
594                return Err(consumer_error("consumer failed"));
595            }
596
597            self.call_count += 1;
598            self.accepted_row_counts.push(batch.num_rows());
599            Ok(())
600        }
601    }
602
603    struct GatedConsumer {
604        accepted_row_counts: Arc<Mutex<Vec<usize>>>,
605        first_write_gate: Option<oneshot::Receiver<()>>,
606    }
607
608    #[async_trait]
609    impl RecordBatchConsumer for GatedConsumer {
610        async fn write_record_batch(
611            &mut self,
612            batch: &RecordBatch,
613        ) -> Result<(), DeltaFunnelError> {
614            self.accepted_row_counts
615                .lock()
616                .map_err(|_| consumer_error("accepted rows lock poisoned"))?
617                .push(batch.num_rows());
618
619            if let Some(gate) = self.first_write_gate.take() {
620                let _result = gate.await;
621            }
622
623            Ok(())
624        }
625    }
626
627    struct PollCountingStream {
628        batches: VecDeque<RecordBatch>,
629        poll_count: Arc<AtomicUsize>,
630    }
631
632    impl Stream for PollCountingStream {
633        type Item = Result<RecordBatch, DataFusionError>;
634
635        fn poll_next(
636            mut self: Pin<&mut Self>,
637            _context: &mut Context<'_>,
638        ) -> Poll<Option<Self::Item>> {
639            self.poll_count.fetch_add(1, Ordering::SeqCst);
640            Poll::Ready(self.batches.pop_front().map(Ok))
641        }
642    }
643
644    fn int_batch(values: &[i32]) -> Result<RecordBatch, Box<dyn Error>> {
645        int_batch_with_schema(schema(), values)
646    }
647
648    fn int_batch_with_schema(
649        schema: SchemaRef,
650        values: &[i32],
651    ) -> Result<RecordBatch, Box<dyn Error>> {
652        RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(values.to_vec()))])
653            .map_err(Into::into)
654    }
655
656    fn schema() -> SchemaRef {
657        Arc::new(Schema::new(vec![Field::new(
658            "value",
659            DataType::Int32,
660            false,
661        )]))
662    }
663
664    fn consumer_error(message: impl Into<String>) -> DeltaFunnelError {
665        DeltaFunnelError::BatchPipeline {
666            phase: BatchPipelinePhase::HandoffSetup,
667            option: "record_batch_consumer",
668            message: message.into(),
669        }
670    }
671}