1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum BatchPipelinePhase {
30 Configuration,
32 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct BatchHandoffOutcome {
48 stats: BatchHandoffStats,
49}
50
51impl BatchHandoffOutcome {
52 pub fn stats(&self) -> BatchHandoffStats {
54 self.stats
55 }
56}
57
58#[derive(Debug, Snafu)]
63pub enum BatchHandoffError {
64 #[snafu(display("DataFusion query output handoff setup failed: {source}"))]
66 QueryOutputSetup {
67 source: DataFusionError,
69 stats: BatchHandoffStats,
71 },
72 #[snafu(display("upstream RecordBatch stream failed: {source}"))]
74 Upstream {
75 source: DataFusionError,
77 stats: BatchHandoffStats,
79 },
80 #[snafu(display("downstream RecordBatch consumer failed: {source}"))]
82 Downstream {
83 source: DeltaFunnelError,
85 stats: BatchHandoffStats,
87 },
88}
89
90impl BatchHandoffError {
91 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#[async_trait]
112pub trait RecordBatchConsumer: Send {
113 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
137pub struct BatchHandoffStats {
138 pub input_batches: u64,
140 pub input_rows: u64,
142 pub output_batches: u64,
144 pub output_rows: u64,
146}
147
148impl BatchHandoffStats {
149 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 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
162pub 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
196pub 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#[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}