1use std::{fmt, future::Future, pin::Pin};
9
10use arrow_schema::SchemaRef;
11use async_trait::async_trait;
12use datafusion::arrow::record_batch::RecordBatch;
13use futures_util::Stream;
14use tracing::Instrument;
15
16use crate::{
17 DeltaFunnelError, PhaseTimingReport, ReportReasonCode, RowCount, ValidationOptions,
18 ValidationStatus, observability, plan_mssql_target_for_resolved_output,
19 profiling::{OperationStageContext, OperationStageTrace, OperationTraceContext},
20 progress::{ProgressEvent, ProgressPhase, ProgressReporter},
21 report::PhaseTimer,
22 support::sanitize_text_for_display,
23};
24
25use super::{
26 LoadMode, MssqlBatchShapingReport, MssqlConnectionSource, MssqlConnectionSummary,
27 MssqlSchemaPlanOptions, MssqlTargetSummary, MssqlTargetTable, MssqlWriteBackend,
28 MssqlWriteFailureContext, MssqlWriteReport, ResolvedMssqlTarget, default_mssql_write_backend,
29 drain_mssql_batches_for_stream_benchmark,
30 write_output_batches_to_mssql_for_workflow_with_stage_context,
31};
32
33const OUTPUT_STREAM_SETUP_PHASE: &str = "output_stream_setup";
34const SQL_WRITE_PHASE: &str = "sql_write";
35const VALIDATION_PHASE: &str = "validation";
36
37pub type MssqlOutputBatchStream =
39 Pin<Box<dyn Stream<Item = Result<RecordBatch, DeltaFunnelError>> + Send>>;
40
41pub type MssqlOutputBatchStreamFuture =
43 Pin<Box<dyn Future<Output = Result<MssqlOutputBatchStream, DeltaFunnelError>> + Send>>;
44
45pub type MssqlOutputBatchStreamFactory = Box<dyn FnOnce() -> MssqlOutputBatchStreamFuture + Send>;
47
48pub(crate) struct MssqlOutputQueryExecution {
50 pub(crate) stream: MssqlOutputBatchStream,
51 pub(crate) query_phase_timings: Vec<PhaseTimingReport>,
52 pub(crate) attach_profile_to_result: Option<MssqlOutputProfileCallback>,
53}
54
55pub(crate) type MssqlOutputProfileCallback = Box<
57 dyn FnOnce(
58 Result<MssqlWriteReport, DeltaFunnelError>,
59 ) -> Result<MssqlWriteReport, DeltaFunnelError>
60 + Send,
61>;
62
63pub(crate) struct MssqlOutputQueryError {
65 pub(crate) error: DeltaFunnelError,
66 pub(crate) query_phase_timings: Vec<PhaseTimingReport>,
67}
68
69pub(crate) type MssqlOutputQueryFuture =
71 Pin<Box<dyn Future<Output = Result<MssqlOutputQueryExecution, MssqlOutputQueryError>> + Send>>;
72
73pub struct MssqlOutputWriteJob {
82 output_schema: SchemaRef,
83 resolved_target: ResolvedMssqlTarget,
84 schema_options: MssqlSchemaPlanOptions,
85 create_query_execution: Box<dyn FnOnce() -> MssqlOutputQueryFuture + Send>,
86 write_backend: MssqlWriteBackend,
87 validation_options: ValidationOptions,
88 phase_timings: Vec<PhaseTimingReport>,
89 progress_reporter: Option<ProgressReporter>,
90 operation_trace_context: Option<OperationTraceContext>,
91 stage_owner_id: Option<u64>,
92}
93
94impl MssqlOutputWriteJob {
95 pub fn new<F, Fut, S>(
97 output_schema: SchemaRef,
98 resolved_target: ResolvedMssqlTarget,
99 schema_options: MssqlSchemaPlanOptions,
100 stream_factory: F,
101 write_backend: MssqlWriteBackend,
102 validation_options: ValidationOptions,
103 ) -> Self
104 where
105 F: FnOnce() -> Fut + Send + 'static,
106 Fut: Future<Output = Result<S, DeltaFunnelError>> + Send + 'static,
107 S: Stream<Item = Result<RecordBatch, DeltaFunnelError>> + Send + 'static,
108 {
109 Self::new_with_query_execution_factory(
110 output_schema,
111 resolved_target,
112 schema_options,
113 Box::new(move || {
114 Box::pin(async move {
115 let stream = stream_factory()
116 .await
117 .map_err(|error| MssqlOutputQueryError {
118 error,
119 query_phase_timings: Vec::new(),
120 })?;
121 Ok(MssqlOutputQueryExecution {
122 stream: Box::pin(stream),
123 query_phase_timings: Vec::new(),
124 attach_profile_to_result: None,
125 })
126 })
127 }),
128 write_backend,
129 validation_options,
130 )
131 }
132
133 pub(crate) fn new_with_query_execution_factory(
136 output_schema: SchemaRef,
137 resolved_target: ResolvedMssqlTarget,
138 schema_options: MssqlSchemaPlanOptions,
139 create_query_execution: Box<dyn FnOnce() -> MssqlOutputQueryFuture + Send>,
140 write_backend: MssqlWriteBackend,
141 validation_options: ValidationOptions,
142 ) -> Self {
143 Self {
144 output_schema,
145 resolved_target,
146 schema_options,
147 create_query_execution,
148 write_backend,
149 validation_options,
150 phase_timings: Vec::new(),
151 progress_reporter: None,
152 operation_trace_context: None,
153 stage_owner_id: None,
154 }
155 }
156
157 #[must_use]
159 pub fn with_phase_timings(mut self, phase_timings: Vec<PhaseTimingReport>) -> Self {
160 self.phase_timings = phase_timings;
161 self
162 }
163
164 #[must_use]
166 pub(crate) fn with_progress_reporter(
167 mut self,
168 progress_reporter: Option<ProgressReporter>,
169 ) -> Self {
170 self.progress_reporter = progress_reporter;
171 self
172 }
173
174 #[must_use]
176 pub(crate) fn with_operation_trace_context(
177 mut self,
178 operation_trace_context: Option<OperationTraceContext>,
179 stage_owner_id: u64,
180 ) -> Self {
181 self.operation_trace_context = operation_trace_context;
182 self.stage_owner_id = Some(stage_owner_id);
183 self
184 }
185
186 pub fn with_default_write_backend<F, Fut, S>(
188 output_schema: SchemaRef,
189 resolved_target: ResolvedMssqlTarget,
190 schema_options: MssqlSchemaPlanOptions,
191 batches: F,
192 ) -> Self
193 where
194 F: FnOnce() -> Fut + Send + 'static,
195 Fut: Future<Output = Result<S, DeltaFunnelError>> + Send + 'static,
196 S: Stream<Item = Result<RecordBatch, DeltaFunnelError>> + Send + 'static,
197 {
198 Self::new(
199 output_schema,
200 resolved_target,
201 schema_options,
202 batches,
203 default_mssql_write_backend(),
204 ValidationOptions::default(),
205 )
206 }
207
208 #[must_use]
210 pub fn output_name(&self) -> &str {
211 self.resolved_target.output_name()
212 }
213
214 #[must_use]
216 pub fn target_summary(&self) -> MssqlTargetSummary {
217 self.resolved_target.summary()
218 }
219
220 #[must_use]
222 pub fn phase_timings(&self) -> &[PhaseTimingReport] {
223 &self.phase_timings
224 }
225}
226
227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
229pub struct MssqlWorkflowWriteOptions {
230 max_parallel_outputs: usize,
231}
232
233impl Default for MssqlWorkflowWriteOptions {
234 fn default() -> Self {
235 Self {
236 max_parallel_outputs: 1,
237 }
238 }
239}
240
241impl MssqlWorkflowWriteOptions {
242 #[must_use]
244 pub const fn new() -> Self {
245 Self {
246 max_parallel_outputs: 1,
247 }
248 }
249
250 #[must_use]
256 pub const fn with_max_parallel_outputs(mut self, max_parallel_outputs: usize) -> Self {
257 self.max_parallel_outputs = max_parallel_outputs;
258 self
259 }
260
261 #[must_use]
263 pub const fn max_parallel_outputs(&self) -> usize {
264 self.max_parallel_outputs
265 }
266
267 pub fn validate(&self) -> Result<(), DeltaFunnelError> {
276 match self.max_parallel_outputs() {
277 1 => Ok(()),
278 0 => Err(DeltaFunnelError::MssqlWorkflowPlanning {
279 message: "max_parallel_outputs must be at least 1".to_owned(),
280 }),
281 max_parallel_outputs => Err(DeltaFunnelError::MssqlWorkflowPlanning {
282 message: format!(
283 "parallel MSSQL output writers are not supported; requested {max_parallel_outputs}"
284 ),
285 }),
286 }
287 }
288}
289
290#[derive(Debug, Clone, PartialEq, Eq)]
292pub struct MssqlWorkflowWriteReport {
293 outputs: Vec<MssqlOutputWriteStatus>,
294}
295
296impl MssqlWorkflowWriteReport {
297 fn new(outputs: Vec<MssqlOutputWriteStatus>) -> Self {
298 Self { outputs }
299 }
300
301 #[must_use]
303 pub fn len(&self) -> usize {
304 self.outputs.len()
305 }
306
307 #[must_use]
309 pub fn is_empty(&self) -> bool {
310 self.outputs.is_empty()
311 }
312
313 #[must_use]
315 pub fn outputs(&self) -> &[MssqlOutputWriteStatus] {
316 &self.outputs
317 }
318
319 #[must_use]
321 pub fn all_succeeded(&self) -> bool {
322 self.outputs
323 .iter()
324 .all(MssqlOutputWriteStatus::is_succeeded)
325 }
326
327 #[must_use]
329 pub fn succeeded_count(&self) -> usize {
330 self.outputs
331 .iter()
332 .filter(|status| status.is_succeeded())
333 .count()
334 }
335
336 #[must_use]
338 pub fn failed_count(&self) -> usize {
339 self.outputs
340 .iter()
341 .filter(|status| status.is_failed())
342 .count()
343 }
344
345 #[must_use]
347 pub fn skipped_count(&self) -> usize {
348 self.outputs
349 .iter()
350 .filter(|status| status.is_skipped())
351 .count()
352 }
353}
354
355impl fmt::Display for MssqlWorkflowWriteReport {
356 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
357 let succeeded = self.succeeded_count();
358 let failed = self.failed_count();
359 let skipped = self.skipped_count();
360
361 write!(
362 formatter,
363 "MSSQL workflow write report: {succeeded} succeeded, {failed} failed, {skipped} skipped"
364 )
365 }
366}
367
368#[derive(Debug, Clone, PartialEq, Eq)]
370pub enum MssqlOutputWriteStatus {
371 Succeeded(MssqlWriteReport),
373 Failed(MssqlWriteFailureReport),
375 Skipped(MssqlWriteSkippedReport),
377}
378
379impl MssqlOutputWriteStatus {
380 #[must_use]
382 pub fn output_name(&self) -> &str {
383 match self {
384 Self::Succeeded(report) => report.output_name(),
385 Self::Failed(report) => report.output_name(),
386 Self::Skipped(report) => report.output_name(),
387 }
388 }
389
390 #[must_use]
392 pub fn target_table(&self) -> &MssqlTargetTable {
393 match self {
394 Self::Succeeded(report) => report.target_table(),
395 Self::Failed(report) => report.target().table(),
396 Self::Skipped(report) => report.target().table(),
397 }
398 }
399
400 #[must_use]
402 pub fn load_mode(&self) -> LoadMode {
403 match self {
404 Self::Succeeded(report) => report.load_mode(),
405 Self::Failed(report) => report.target().load_mode(),
406 Self::Skipped(report) => report.target().load_mode(),
407 }
408 }
409
410 #[must_use]
412 pub fn connection_source(&self) -> MssqlConnectionSource {
413 match self {
414 Self::Succeeded(report) => report.connection_source(),
415 Self::Failed(report) => report.target().connection_source(),
416 Self::Skipped(report) => report.target().connection_source(),
417 }
418 }
419
420 #[must_use]
422 pub fn connection(&self) -> &MssqlConnectionSummary {
423 match self {
424 Self::Succeeded(report) => report.connection(),
425 Self::Failed(report) => report.target().connection(),
426 Self::Skipped(report) => report.target().connection(),
427 }
428 }
429
430 #[must_use]
432 pub fn output_row_count(&self) -> RowCount {
433 match self {
434 Self::Succeeded(report) => report.output_row_count(),
435 Self::Failed(report) => report.output_row_count(),
436 Self::Skipped(report) => report.output_row_count(),
437 }
438 }
439
440 #[must_use]
442 pub fn target_row_count(&self) -> RowCount {
443 match self {
444 Self::Succeeded(report) => report.target_row_count(),
445 Self::Failed(report) => report.target_row_count(),
446 Self::Skipped(report) => report.target_row_count(),
447 }
448 }
449
450 #[must_use]
452 pub fn validation_status(&self) -> ValidationStatus {
453 match self {
454 Self::Succeeded(report) => report.validation_status(),
455 Self::Failed(report) => report.validation_status(),
456 Self::Skipped(report) => report.validation_status(),
457 }
458 }
459
460 #[must_use]
462 pub fn batch_shaping(&self) -> MssqlBatchShapingReport {
463 match self {
464 Self::Succeeded(report) => report.batch_shaping(),
465 Self::Failed(report) => report.batch_shaping(),
466 Self::Skipped(report) => report.batch_shaping(),
467 }
468 }
469
470 #[must_use]
472 pub fn phase_timings(&self) -> &[PhaseTimingReport] {
473 match self {
474 Self::Succeeded(report) => report.phase_timings(),
475 Self::Failed(report) => report.phase_timings(),
476 Self::Skipped(report) => report.phase_timings(),
477 }
478 }
479
480 #[must_use]
482 pub const fn is_succeeded(&self) -> bool {
483 matches!(self, Self::Succeeded(_))
484 }
485
486 #[must_use]
488 pub const fn is_failed(&self) -> bool {
489 matches!(self, Self::Failed(_))
490 }
491
492 #[must_use]
494 pub const fn is_skipped(&self) -> bool {
495 matches!(self, Self::Skipped(_))
496 }
497}
498
499#[derive(Debug, Clone, PartialEq, Eq)]
501pub struct MssqlWriteFailureReport {
502 target: MssqlTargetSummary,
503 error: String,
504 context: Option<Box<MssqlWriteFailureContext>>,
505 output_row_count: RowCount,
506 target_row_count: RowCount,
507 validation_status: ValidationStatus,
508 batch_shaping: MssqlBatchShapingReport,
509 phase_timings: Vec<PhaseTimingReport>,
510}
511
512impl MssqlWriteFailureReport {
513 fn from_error(
514 target: MssqlTargetSummary,
515 error: DeltaFunnelError,
516 phase_timings: Vec<PhaseTimingReport>,
517 ) -> Self {
518 let context = failure_context(&error).cloned().map(Box::new);
519 let phase_timings = merged_failure_phase_timings(phase_timings, context.as_deref());
520 let output_row_count = context.as_deref().map_or(
521 RowCount::unavailable(),
522 MssqlWriteFailureContext::output_row_count,
523 );
524 let target_row_count = context.as_deref().map_or(
525 RowCount::unavailable(),
526 MssqlWriteFailureContext::target_row_count,
527 );
528 let validation_status = context.as_deref().map_or(
529 ValidationStatus::skipped(ReportReasonCode::FailureBeforeValidation),
530 MssqlWriteFailureContext::validation_status,
531 );
532 let batch_shaping = context.as_deref().map_or_else(
533 || MssqlBatchShapingReport::not_started(ReportReasonCode::NotExecuted),
534 MssqlWriteFailureContext::batch_shaping,
535 );
536 Self {
537 target,
538 error: sanitize_text_for_display(&error.to_string()),
539 context,
540 output_row_count,
541 target_row_count,
542 validation_status,
543 batch_shaping,
544 phase_timings,
545 }
546 }
547
548 #[must_use]
550 pub const fn target(&self) -> &MssqlTargetSummary {
551 &self.target
552 }
553
554 #[must_use]
556 pub fn output_name(&self) -> &str {
557 self.target.output_name()
558 }
559
560 #[must_use]
562 pub fn error(&self) -> &str {
563 &self.error
564 }
565
566 #[must_use]
569 pub fn context(&self) -> Option<&MssqlWriteFailureContext> {
570 self.context.as_deref()
571 }
572
573 #[must_use]
575 pub const fn output_row_count(&self) -> RowCount {
576 self.output_row_count
577 }
578
579 #[must_use]
581 pub const fn target_row_count(&self) -> RowCount {
582 self.target_row_count
583 }
584
585 #[must_use]
587 pub const fn validation_status(&self) -> ValidationStatus {
588 self.validation_status
589 }
590
591 #[must_use]
593 pub const fn batch_shaping(&self) -> MssqlBatchShapingReport {
594 self.batch_shaping
595 }
596
597 #[must_use]
599 pub fn phase_timings(&self) -> &[PhaseTimingReport] {
600 &self.phase_timings
601 }
602}
603
604fn merged_failure_phase_timings(
605 mut phase_timings: Vec<PhaseTimingReport>,
606 context: Option<&MssqlWriteFailureContext>,
607) -> Vec<PhaseTimingReport> {
608 let Some(context) = context else {
609 return phase_timings;
610 };
611
612 for timing in context.phase_timings() {
613 if let Some(existing) = phase_timings
614 .iter_mut()
615 .find(|existing| existing.phase_name() == timing.phase_name())
616 {
617 *existing = timing.clone();
618 } else {
619 phase_timings.push(timing.clone());
620 }
621 }
622
623 phase_timings
624}
625
626#[derive(Debug, Clone, PartialEq, Eq)]
628pub struct MssqlWriteSkippedReport {
629 target: MssqlTargetSummary,
630 reason: MssqlWriteSkippedReason,
631 output_row_count: RowCount,
632 target_row_count: RowCount,
633 validation_status: ValidationStatus,
634 batch_shaping: MssqlBatchShapingReport,
635 phase_timings: Vec<PhaseTimingReport>,
636}
637
638impl MssqlWriteSkippedReport {
639 fn previous_output_failed(
640 target: MssqlTargetSummary,
641 failed_output_name: String,
642 phase_timings: Vec<PhaseTimingReport>,
643 ) -> Self {
644 Self {
645 target,
646 reason: MssqlWriteSkippedReason::PreviousOutputFailed { failed_output_name },
647 output_row_count: RowCount::unavailable(),
648 target_row_count: RowCount::unavailable(),
649 validation_status: ValidationStatus::skipped(ReportReasonCode::PriorFailure),
650 batch_shaping: MssqlBatchShapingReport::skipped(ReportReasonCode::PriorFailure),
651 phase_timings: skipped_after_prior_failure_phase_timings(phase_timings),
652 }
653 }
654
655 #[must_use]
657 pub const fn target(&self) -> &MssqlTargetSummary {
658 &self.target
659 }
660
661 #[must_use]
663 pub fn output_name(&self) -> &str {
664 self.target.output_name()
665 }
666
667 #[must_use]
669 pub const fn reason(&self) -> &MssqlWriteSkippedReason {
670 &self.reason
671 }
672
673 #[must_use]
675 pub const fn output_row_count(&self) -> RowCount {
676 self.output_row_count
677 }
678
679 #[must_use]
681 pub const fn target_row_count(&self) -> RowCount {
682 self.target_row_count
683 }
684
685 #[must_use]
687 pub const fn validation_status(&self) -> ValidationStatus {
688 self.validation_status
689 }
690
691 #[must_use]
693 pub const fn batch_shaping(&self) -> MssqlBatchShapingReport {
694 self.batch_shaping
695 }
696
697 #[must_use]
699 pub fn phase_timings(&self) -> &[PhaseTimingReport] {
700 &self.phase_timings
701 }
702}
703
704#[derive(Debug, Clone, PartialEq, Eq)]
706pub enum MssqlWriteSkippedReason {
707 PreviousOutputFailed {
709 failed_output_name: String,
711 },
712}
713
714pub async fn write_mssql_outputs_to_mssql(
722 jobs: impl IntoIterator<Item = MssqlOutputWriteJob>,
723 options: MssqlWorkflowWriteOptions,
724) -> Result<MssqlWorkflowWriteReport, DeltaFunnelError> {
725 write_mssql_outputs_with_writer(jobs, options, MssqlWorkflowSinkWriter).await
726}
727
728pub(crate) struct MssqlStreamBenchmarkOutputWriter;
729
730#[async_trait]
731pub(crate) trait MssqlWorkflowOutputWriter: Send {
732 fn reports_sql_write_phase(&self) -> bool {
733 true
734 }
735
736 #[allow(
737 clippy::too_many_arguments,
738 reason = "the workflow writer receives one planned write plus profiling state"
739 )]
740 async fn write_output(
741 &mut self,
742 output_schema: SchemaRef,
743 resolved_target: ResolvedMssqlTarget,
744 schema_options: MssqlSchemaPlanOptions,
745 batches: MssqlOutputBatchStream,
746 write_backend: MssqlWriteBackend,
747 validation_options: ValidationOptions,
748 reporter: Option<&ProgressReporter>,
749 stage_context: OperationStageContext<'_>,
750 ) -> Result<MssqlWriteReport, DeltaFunnelError>;
751}
752
753pub(crate) struct MssqlWorkflowSinkWriter;
754
755#[async_trait]
756impl MssqlWorkflowOutputWriter for MssqlWorkflowSinkWriter {
757 async fn write_output(
758 &mut self,
759 output_schema: SchemaRef,
760 resolved_target: ResolvedMssqlTarget,
761 schema_options: MssqlSchemaPlanOptions,
762 batches: MssqlOutputBatchStream,
763 write_backend: MssqlWriteBackend,
764 validation_options: ValidationOptions,
765 reporter: Option<&ProgressReporter>,
766 stage_context: OperationStageContext<'_>,
767 ) -> Result<MssqlWriteReport, DeltaFunnelError> {
768 write_output_batches_to_mssql_for_workflow_with_stage_context(
769 output_schema.as_ref(),
770 resolved_target,
771 schema_options,
772 batches,
773 write_backend,
774 validation_options,
775 reporter,
776 stage_context,
777 )
778 .await
779 }
780}
781
782#[async_trait]
783impl MssqlWorkflowOutputWriter for MssqlStreamBenchmarkOutputWriter {
784 fn reports_sql_write_phase(&self) -> bool {
785 false
786 }
787
788 async fn write_output(
789 &mut self,
790 output_schema: SchemaRef,
791 resolved_target: ResolvedMssqlTarget,
792 schema_options: MssqlSchemaPlanOptions,
793 batches: MssqlOutputBatchStream,
794 _write_backend: MssqlWriteBackend,
795 _validation_options: ValidationOptions,
796 _reporter: Option<&ProgressReporter>,
797 _stage_context: OperationStageContext<'_>,
798 ) -> Result<MssqlWriteReport, DeltaFunnelError> {
799 let output_plan = plan_mssql_target_for_resolved_output(
800 output_schema.as_ref(),
801 &resolved_target,
802 schema_options,
803 )?;
804
805 drain_mssql_batches_for_stream_benchmark(&output_plan, batches).await
806 }
807}
808
809pub(crate) async fn write_mssql_outputs_with_writer<W>(
810 jobs: impl IntoIterator<Item = MssqlOutputWriteJob>,
811 options: MssqlWorkflowWriteOptions,
812 mut writer: W,
813) -> Result<MssqlWorkflowWriteReport, DeltaFunnelError>
814where
815 W: MssqlWorkflowOutputWriter,
816{
817 ensure_sequential_options(options)?;
818
819 let mut statuses = Vec::new();
820 let mut failed_output_name = None::<String>;
821
822 for job in jobs {
823 if let Some(failed_output_name) = failed_output_name.as_ref() {
824 statuses.push(skipped_output_status_with_tracing(
825 job.target_summary(),
826 failed_output_name.clone(),
827 job.phase_timings().to_vec(),
828 ));
829 continue;
830 }
831
832 let status = write_mssql_output_job_with_tracing(job, &mut writer).await;
833 if let MssqlOutputWriteStatus::Failed(failure) = &status {
834 failed_output_name = Some(failure.output_name().to_owned());
835 }
836 statuses.push(status);
837 }
838
839 Ok(MssqlWorkflowWriteReport::new(statuses))
840}
841
842fn skipped_output_status_with_tracing(
843 target: MssqlTargetSummary,
844 failed_output_name: String,
845 planned_phase_timings: Vec<PhaseTimingReport>,
846) -> MssqlOutputWriteStatus {
847 let output_span =
848 observability::output_span(target.output_name(), target.table(), target.load_mode());
849 output_span.in_scope(|| {
850 let skipped = skipped_output_status(target, failed_output_name, planned_phase_timings);
851 observability::output_skipped(
852 skipped.output_name(),
853 skipped.target().table(),
854 skipped.target().load_mode(),
855 "prior_failure",
856 );
857 MssqlOutputWriteStatus::Skipped(skipped)
858 })
859}
860
861fn skipped_output_status(
862 target: MssqlTargetSummary,
863 failed_output_name: String,
864 planned_phase_timings: Vec<PhaseTimingReport>,
865) -> MssqlWriteSkippedReport {
866 MssqlWriteSkippedReport::previous_output_failed(
867 target,
868 failed_output_name,
869 planned_phase_timings,
870 )
871}
872
873async fn write_mssql_output_job_with_tracing<W>(
874 job: MssqlOutputWriteJob,
875 writer: &mut W,
876) -> MssqlOutputWriteStatus
877where
878 W: MssqlWorkflowOutputWriter,
879{
880 let target = job.target_summary();
881 let output_span =
882 observability::output_span(target.output_name(), target.table(), target.load_mode());
883
884 async move {
885 observability::output_started(target.output_name(), target.table(), target.load_mode());
886 let status = write_mssql_output_job(job, writer).await;
887 match &status {
888 MssqlOutputWriteStatus::Succeeded(report) => {
889 observability::output_completed(
890 report.output_name(),
891 report.target_table(),
892 report.load_mode(),
893 );
894 }
895 MssqlOutputWriteStatus::Failed(failure) => {
896 observability::output_failed(
897 failure.output_name(),
898 failure.target().table(),
899 failure.target().load_mode(),
900 failure.error(),
901 );
902 }
903 MssqlOutputWriteStatus::Skipped(_) => {}
904 }
905 status
906 }
907 .instrument(output_span)
908 .await
909}
910
911async fn write_mssql_output_job<W>(
912 job: MssqlOutputWriteJob,
913 writer: &mut W,
914) -> MssqlOutputWriteStatus
915where
916 W: MssqlWorkflowOutputWriter,
917{
918 let target = job.target_summary();
919 let MssqlOutputWriteJob {
920 output_schema,
921 resolved_target,
922 schema_options,
923 create_query_execution,
924 write_backend,
925 validation_options,
926 phase_timings: mut planned_phase_timings,
927 progress_reporter,
928 operation_trace_context,
929 stage_owner_id,
930 } = job;
931 let stage_context =
932 OperationStageContext::new(operation_trace_context.as_ref(), stage_owner_id);
933 let output_stage = operation_trace_context
934 .as_ref()
935 .and_then(|_| stage_context.start("Execute output", "delta_funnel.write_all.output"));
936 if let Some(reporter) = progress_reporter.as_ref() {
937 reporter.emit(&ProgressEvent::phase_changed(
938 ProgressPhase::SettingUpStream,
939 Some(target.output_name()),
940 ));
941 }
942 let stream_setup_timer = PhaseTimer::start(OUTPUT_STREAM_SETUP_PHASE);
943 let (query_execution, stream_setup_timing) = match create_query_execution().await {
944 Ok(query_execution) => (query_execution, stream_setup_timer.completed()),
945 Err(failure) => {
946 fail_output_stage(output_stage);
947 planned_phase_timings.extend(failure.query_phase_timings);
948 let failure = MssqlWriteFailureReport::from_error(
949 target,
950 failure.error,
951 stream_setup_failure_phase_timings(
952 planned_phase_timings,
953 stream_setup_timer.failed(),
954 ),
955 );
956 return MssqlOutputWriteStatus::Failed(failure);
957 }
958 };
959 let MssqlOutputQueryExecution {
960 stream,
961 query_phase_timings,
962 attach_profile_to_result,
963 } = query_execution;
964 planned_phase_timings.extend(query_phase_timings);
965
966 let reports_sql_write_phase = writer.reports_sql_write_phase();
967 let write_timer = PhaseTimer::start(SQL_WRITE_PHASE);
968 let write_result = writer
969 .write_output(
970 output_schema,
971 resolved_target,
972 schema_options,
973 stream,
974 write_backend,
975 validation_options,
976 progress_reporter.as_ref(),
977 stage_context,
978 )
979 .await;
980 let write_result = match attach_profile_to_result {
983 Some(attach_profile) => attach_profile(write_result),
984 None => write_result,
985 };
986 let write_timing = if !reports_sql_write_phase {
987 PhaseTimingReport::not_started(SQL_WRITE_PHASE, ReportReasonCode::NotExecuted)
988 } else if write_result.is_ok() {
989 write_timer.completed()
990 } else {
991 write_timer.failed()
992 };
993 match &write_result {
994 Ok(_) => complete_output_stage(output_stage),
995 Err(_) => fail_output_stage(output_stage),
996 }
997 match write_result {
998 Ok(report) => {
999 let report = report.with_phase_timings(output_write_phase_timings(
1000 planned_phase_timings,
1001 stream_setup_timing,
1002 write_timing,
1003 ));
1004 MssqlOutputWriteStatus::Succeeded(report)
1005 }
1006 Err(error) => {
1007 let failure = MssqlWriteFailureReport::from_error(
1008 target,
1009 error,
1010 output_write_failure_phase_timings(
1011 planned_phase_timings,
1012 stream_setup_timing,
1013 write_timing,
1014 ),
1015 );
1016 MssqlOutputWriteStatus::Failed(failure)
1017 }
1018 }
1019}
1020
1021fn complete_output_stage(span: Option<OperationStageTrace>) {
1022 if let Some(span) = span {
1023 span.completed();
1024 }
1025}
1026
1027fn fail_output_stage(span: Option<OperationStageTrace>) {
1028 if let Some(span) = span {
1029 span.failed();
1030 }
1031}
1032
1033fn ensure_sequential_options(options: MssqlWorkflowWriteOptions) -> Result<(), DeltaFunnelError> {
1034 options.validate()
1035}
1036
1037fn failure_context(error: &DeltaFunnelError) -> Option<&MssqlWriteFailureContext> {
1038 match error {
1039 DeltaFunnelError::MssqlWritePhase { context, .. }
1040 | DeltaFunnelError::MssqlQueryPhase { context, .. }
1041 | DeltaFunnelError::MssqlBatchSchemaValidation { context, .. } => Some(context.as_ref()),
1042 _ => None,
1043 }
1044}
1045
1046fn output_write_phase_timings(
1047 mut phase_timings: Vec<PhaseTimingReport>,
1048 stream_setup_timing: PhaseTimingReport,
1049 write_timing: PhaseTimingReport,
1050) -> Vec<PhaseTimingReport> {
1051 phase_timings.extend([stream_setup_timing, write_timing]);
1052 phase_timings
1053}
1054
1055fn output_write_failure_phase_timings(
1056 mut phase_timings: Vec<PhaseTimingReport>,
1057 stream_setup_timing: PhaseTimingReport,
1058 write_timing: PhaseTimingReport,
1059) -> Vec<PhaseTimingReport> {
1060 phase_timings.extend([
1061 stream_setup_timing,
1062 write_timing,
1063 PhaseTimingReport::not_started(VALIDATION_PHASE, ReportReasonCode::FailureBeforeValidation),
1064 ]);
1065 phase_timings
1066}
1067
1068fn stream_setup_failure_phase_timings(
1069 mut phase_timings: Vec<PhaseTimingReport>,
1070 stream_setup_timing: PhaseTimingReport,
1071) -> Vec<PhaseTimingReport> {
1072 phase_timings.extend([
1073 stream_setup_timing,
1074 PhaseTimingReport::not_started(SQL_WRITE_PHASE, ReportReasonCode::NotExecuted),
1075 PhaseTimingReport::not_started(VALIDATION_PHASE, ReportReasonCode::FailureBeforeValidation),
1076 ]);
1077 phase_timings
1078}
1079
1080fn skipped_after_prior_failure_phase_timings(
1081 mut phase_timings: Vec<PhaseTimingReport>,
1082) -> Vec<PhaseTimingReport> {
1083 phase_timings.extend([
1084 PhaseTimingReport::skipped(OUTPUT_STREAM_SETUP_PHASE, ReportReasonCode::PriorFailure),
1085 PhaseTimingReport::skipped(SQL_WRITE_PHASE, ReportReasonCode::PriorFailure),
1086 PhaseTimingReport::skipped(VALIDATION_PHASE, ReportReasonCode::PriorFailure),
1087 ]);
1088 phase_timings
1089}
1090
1091#[cfg(test)]
1092mod tests {
1093 use std::collections::VecDeque;
1094 use std::sync::{Arc, Mutex, MutexGuard};
1095 use std::time::Duration;
1096
1097 use arrow_schema::{DataType, Field, Schema};
1098 use async_trait::async_trait;
1099 use futures_util::{StreamExt, stream};
1100
1101 use super::*;
1102 use crate::{
1103 LoadMode, MssqlConnectionConfig, MssqlTargetCleanupStatus, MssqlTargetConfig,
1104 MssqlTargetOutputPlan, MssqlTargetResolutionContext, MssqlTargetTable, MssqlWritePhase,
1105 PhaseStatus, PhaseTimingReport, ValidationStatus, plan_mssql_target_for_output,
1106 report::sql_server::MssqlWriteReportMetrics,
1107 };
1108
1109 const PLANNED_PHASE: &str = "planned_phase";
1110 const DEFERRED_QUERY_PHASE: &str = "deferred_query_phase";
1111
1112 #[derive(Default)]
1113 struct FakeWorkflowWriter {
1114 outcomes: VecDeque<Result<MssqlWriteReport, DeltaFunnelError>>,
1115 attempted_outputs: Arc<Mutex<Vec<String>>>,
1116 }
1117
1118 #[derive(Default)]
1119 struct StreamPollingWorkflowWriter {
1120 attempted_outputs: Arc<Mutex<Vec<String>>>,
1121 }
1122
1123 impl FakeWorkflowWriter {
1124 fn new(outcomes: Vec<Result<MssqlWriteReport, DeltaFunnelError>>) -> Self {
1125 Self {
1126 outcomes: outcomes.into(),
1127 attempted_outputs: Arc::new(Mutex::new(Vec::new())),
1128 }
1129 }
1130
1131 fn attempted_outputs(&self) -> Arc<Mutex<Vec<String>>> {
1132 Arc::clone(&self.attempted_outputs)
1133 }
1134 }
1135
1136 impl StreamPollingWorkflowWriter {
1137 fn attempted_outputs(&self) -> Arc<Mutex<Vec<String>>> {
1138 Arc::clone(&self.attempted_outputs)
1139 }
1140 }
1141
1142 #[async_trait]
1143 impl MssqlWorkflowOutputWriter for FakeWorkflowWriter {
1144 async fn write_output(
1145 &mut self,
1146 _output_schema: SchemaRef,
1147 resolved_target: ResolvedMssqlTarget,
1148 _schema_options: MssqlSchemaPlanOptions,
1149 _batches: MssqlOutputBatchStream,
1150 _write_backend: MssqlWriteBackend,
1151 _validation_options: ValidationOptions,
1152 _reporter: Option<&ProgressReporter>,
1153 _stage_context: OperationStageContext<'_>,
1154 ) -> Result<MssqlWriteReport, DeltaFunnelError> {
1155 self.attempted_outputs
1156 .lock()
1157 .map_err(|_| test_error("attempted output lock poisoned"))?
1158 .push(resolved_target.output_name().to_owned());
1159
1160 self.outcomes
1161 .pop_front()
1162 .ok_or_else(|| test_error("missing fake writer outcome"))?
1163 }
1164 }
1165
1166 #[async_trait]
1167 impl MssqlWorkflowOutputWriter for StreamPollingWorkflowWriter {
1168 async fn write_output(
1169 &mut self,
1170 _output_schema: SchemaRef,
1171 resolved_target: ResolvedMssqlTarget,
1172 _schema_options: MssqlSchemaPlanOptions,
1173 mut batches: MssqlOutputBatchStream,
1174 _write_backend: MssqlWriteBackend,
1175 _validation_options: ValidationOptions,
1176 _reporter: Option<&ProgressReporter>,
1177 _stage_context: OperationStageContext<'_>,
1178 ) -> Result<MssqlWriteReport, DeltaFunnelError> {
1179 self.attempted_outputs
1180 .lock()
1181 .map_err(|_| test_error("attempted output lock poisoned"))?
1182 .push(resolved_target.output_name().to_owned());
1183
1184 match batches.next().await {
1185 Some(Ok(_batch)) => Err(test_error("expected stream polling error")),
1186 Some(Err(error)) => Err(error),
1187 None => Err(test_error("expected at least one stream item")),
1188 }
1189 }
1190 }
1191
1192 #[tokio::test]
1193 async fn empty_workflow_report_has_zero_counts() -> Result<(), DeltaFunnelError> {
1194 let writer = FakeWorkflowWriter::default();
1195
1196 let report = write_mssql_outputs_with_writer(
1197 Vec::new(),
1198 MssqlWorkflowWriteOptions::default(),
1199 writer,
1200 )
1201 .await?;
1202
1203 assert!(report.is_empty());
1204 assert_eq!(report.len(), 0);
1205 assert_eq!(report.outputs(), []);
1206 assert!(report.all_succeeded());
1207 assert_eq!(report.succeeded_count(), 0);
1208 assert_eq!(report.failed_count(), 0);
1209 assert_eq!(report.skipped_count(), 0);
1210 assert_eq!(
1211 report.to_string(),
1212 "MSSQL workflow write report: 0 succeeded, 0 failed, 0 skipped"
1213 );
1214
1215 Ok(())
1216 }
1217
1218 #[tokio::test]
1219 async fn two_successful_outputs_produce_two_success_statuses() -> Result<(), DeltaFunnelError> {
1220 let first = output_plan("first", LoadMode::AppendExisting)?;
1221 let second = output_plan("second", LoadMode::AppendExisting)?;
1222 let first_report =
1223 write_report(&first, 2, 1, false, MssqlTargetCleanupStatus::NotApplicable);
1224 let second_report = write_report(
1225 &second,
1226 3,
1227 2,
1228 false,
1229 MssqlTargetCleanupStatus::NotApplicable,
1230 );
1231 let writer = FakeWorkflowWriter::new(vec![Ok(first_report), Ok(second_report)]);
1232 let attempted = writer.attempted_outputs();
1233
1234 let report = write_mssql_outputs_with_writer(
1235 vec![job(first)?, job(second)?],
1236 MssqlWorkflowWriteOptions::default(),
1237 writer,
1238 )
1239 .await?;
1240
1241 assert_eq!(report.outputs().len(), 2);
1242 assert!(report.all_succeeded());
1243 assert_status_output(report.outputs(), 0, "first")?;
1244 assert_status_output(report.outputs(), 1, "second")?;
1245 assert_eq!(report.outputs()[0].output_row_count(), RowCount::exact(2));
1246 assert_batch_shaping(
1247 report.outputs()[0].batch_shaping(),
1248 PhaseStatus::completed(),
1249 1,
1250 2,
1251 1,
1252 2,
1253 );
1254 assert_eq!(report.outputs()[1].output_row_count(), RowCount::exact(3));
1255 assert_batch_shaping(
1256 report.outputs()[1].batch_shaping(),
1257 PhaseStatus::completed(),
1258 2,
1259 3,
1260 2,
1261 3,
1262 );
1263 assert_phase_timing(
1264 &report.outputs()[0],
1265 PLANNED_PHASE,
1266 PhaseStatus::completed(),
1267 )?;
1268 assert_phase_timing(
1269 &report.outputs()[0],
1270 OUTPUT_STREAM_SETUP_PHASE,
1271 PhaseStatus::completed(),
1272 )?;
1273 assert_phase_timing(
1274 &report.outputs()[0],
1275 SQL_WRITE_PHASE,
1276 PhaseStatus::completed(),
1277 )?;
1278 assert_eq!(
1279 locked(&attempted)?.as_slice(),
1280 ["first".to_owned(), "second".to_owned()]
1281 );
1282
1283 Ok(())
1284 }
1285
1286 #[tokio::test]
1287 async fn first_success_remains_successful_when_second_output_fails()
1288 -> Result<(), DeltaFunnelError> {
1289 let first = output_plan("first", LoadMode::AppendExisting)?;
1290 let second = output_plan("second", LoadMode::AppendExisting)?;
1291 let first_report =
1292 write_report(&first, 2, 1, false, MssqlTargetCleanupStatus::NotApplicable);
1293 let failure_context = MssqlWriteFailureContext::from_output_plan(
1294 &second,
1295 MssqlWritePhase::WriteBatch,
1296 1,
1297 1,
1298 0,
1299 true,
1300 MssqlTargetCleanupStatus::NotApplicable,
1301 )
1302 .with_phase_timings(vec![
1303 PhaseTimingReport::completed("prepare_target_lifecycle", Duration::from_micros(10)),
1304 PhaseTimingReport::failed("write_batch", Duration::from_micros(20)),
1305 PhaseTimingReport::not_started(
1306 VALIDATION_PHASE,
1307 ReportReasonCode::FailureBeforeValidation,
1308 ),
1309 ]);
1310 let failure = phase_error_with_context(failure_context, "write failed");
1311 let writer = FakeWorkflowWriter::new(vec![Ok(first_report), Err(failure)]);
1312
1313 let report = write_mssql_outputs_with_writer(
1314 vec![job(first)?, job(second)?],
1315 MssqlWorkflowWriteOptions::default(),
1316 writer,
1317 )
1318 .await?;
1319
1320 let [first_status, second_status] = report.outputs() else {
1321 return Err(test_error("expected two output statuses"));
1322 };
1323 assert!(matches!(first_status, MssqlOutputWriteStatus::Succeeded(_)));
1324 let MssqlOutputWriteStatus::Failed(failure) = second_status else {
1325 return Err(test_error("expected second output to fail"));
1326 };
1327 assert_eq!(failure.output_name(), "second");
1328 let context = failure
1329 .context()
1330 .ok_or_else(|| test_error("expected write failure context"))?;
1331 assert_eq!(context.phase(), MssqlWritePhase::WriteBatch);
1332 assert!(context.partial_write_possible());
1333 assert_eq!(context.stats().rows_written(), 1);
1334 assert_eq!(context.stats().batches_written(), 1);
1335 assert_phase_timing(
1336 second_status,
1337 OUTPUT_STREAM_SETUP_PHASE,
1338 PhaseStatus::completed(),
1339 )?;
1340 assert_phase_timing(second_status, SQL_WRITE_PHASE, PhaseStatus::failed())?;
1341 assert_phase_timing(second_status, "write_batch", PhaseStatus::failed())?;
1342 assert_phase_timing(
1343 second_status,
1344 VALIDATION_PHASE,
1345 PhaseStatus::not_started(ReportReasonCode::FailureBeforeValidation),
1346 )?;
1347 assert_eq!(
1348 second_status
1349 .phase_timings()
1350 .iter()
1351 .filter(|timing| timing.phase_name() == VALIDATION_PHASE)
1352 .count(),
1353 1
1354 );
1355
1356 Ok(())
1357 }
1358
1359 #[tokio::test]
1360 async fn batch_schema_validation_failure_preserves_failure_context()
1361 -> Result<(), DeltaFunnelError> {
1362 let output = output_plan("schema_failure", LoadMode::AppendExisting)?;
1363 let context = MssqlWriteFailureContext::from_output_plan(
1364 &output,
1365 MssqlWritePhase::ValidateBatchSchema,
1366 0,
1367 0,
1368 0,
1369 false,
1370 MssqlTargetCleanupStatus::NotApplicable,
1371 );
1372 let failure = DeltaFunnelError::MssqlBatchSchemaValidation {
1373 context: Box::new(context),
1374 source: arrow_sql_server::Error::BackendUnavailable {
1375 backend: arrow_sql_server::WriteBackend::DirectRawBulk,
1376 reason: "schema mismatch".to_owned(),
1377 },
1378 };
1379 let writer = FakeWorkflowWriter::new(vec![Err(failure)]);
1380
1381 let report = write_mssql_outputs_with_writer(
1382 vec![job(output)?],
1383 MssqlWorkflowWriteOptions::default(),
1384 writer,
1385 )
1386 .await?;
1387
1388 let [MssqlOutputWriteStatus::Failed(failure)] = report.outputs() else {
1389 return Err(test_error("expected failed output status"));
1390 };
1391 let context = failure
1392 .context()
1393 .ok_or_else(|| test_error("expected schema validation context"))?;
1394 assert_eq!(failure.output_name(), "schema_failure");
1395 assert_eq!(context.phase(), MssqlWritePhase::ValidateBatchSchema);
1396 assert!(!context.partial_write_possible());
1397 assert_eq!(context.stats().rows_written(), 0);
1398
1399 Ok(())
1400 }
1401
1402 #[tokio::test]
1403 async fn first_failure_marks_later_outputs_skipped_without_attempting_them()
1404 -> Result<(), DeltaFunnelError> {
1405 let first = output_plan("first", LoadMode::AppendExisting)?;
1406 let second = output_plan("second", LoadMode::AppendExisting)?;
1407 let third = output_plan("third", LoadMode::AppendExisting)?;
1408 let failure = phase_error(
1409 &first,
1410 MssqlWritePhase::Connect,
1411 0,
1412 0,
1413 false,
1414 MssqlTargetCleanupStatus::NotApplicable,
1415 "connect failed",
1416 );
1417 let writer = FakeWorkflowWriter::new(vec![Err(failure)]);
1418 let attempted = writer.attempted_outputs();
1419
1420 let report = write_mssql_outputs_with_writer(
1421 vec![job(first)?, job(second)?, job(third)?],
1422 MssqlWorkflowWriteOptions::default(),
1423 writer,
1424 )
1425 .await?;
1426
1427 assert_eq!(locked(&attempted)?.as_slice(), ["first".to_owned()]);
1428 let [failed, skipped_second, skipped_third] = report.outputs() else {
1429 return Err(test_error("expected three output statuses"));
1430 };
1431 assert_eq!(report.len(), 3);
1432 assert_eq!(report.succeeded_count(), 0);
1433 assert_eq!(report.failed_count(), 1);
1434 assert_eq!(report.skipped_count(), 2);
1435 assert!(matches!(failed, MssqlOutputWriteStatus::Failed(_)));
1436 assert_eq!(failed.output_row_count(), RowCount::partial(0));
1437 assert_batch_shaping(failed.batch_shaping(), PhaseStatus::failed(), 0, 0, 0, 0);
1438 assert_phase_timing(failed, OUTPUT_STREAM_SETUP_PHASE, PhaseStatus::completed())?;
1439 assert_phase_timing(failed, SQL_WRITE_PHASE, PhaseStatus::failed())?;
1440 assert_skipped_after(skipped_second, "second", "first")?;
1441 assert_skipped_after(skipped_third, "third", "first")?;
1442
1443 Ok(())
1444 }
1445
1446 #[tokio::test]
1447 async fn output_status_accessors_cover_success_failure_and_skipped_variants()
1448 -> Result<(), DeltaFunnelError> {
1449 let first = output_plan("first", LoadMode::AppendExisting)?;
1450 let second = output_plan("second", LoadMode::CreateAndLoad)?;
1451 let third = output_plan("third", LoadMode::AppendExisting)?;
1452 let first_report =
1453 write_report(&first, 2, 1, false, MssqlTargetCleanupStatus::NotApplicable);
1454 let failure = phase_error(
1455 &second,
1456 MssqlWritePhase::InitializeWriter,
1457 0,
1458 0,
1459 false,
1460 MssqlTargetCleanupStatus::NotAttempted,
1461 "writer init failed",
1462 );
1463 let writer = FakeWorkflowWriter::new(vec![Ok(first_report), Err(failure)]);
1464
1465 let report = write_mssql_outputs_with_writer(
1466 vec![job(first)?, job(second)?, job(third)?],
1467 MssqlWorkflowWriteOptions::default(),
1468 writer,
1469 )
1470 .await?;
1471
1472 let [success, failed, skipped] = report.outputs() else {
1473 return Err(test_error("expected three output statuses"));
1474 };
1475 assert!(success.is_succeeded());
1476 assert_eq!(success.output_name(), "first");
1477 assert_eq!(success.target_table().table(), "first_orders");
1478 assert_eq!(success.load_mode(), LoadMode::AppendExisting);
1479 assert_eq!(
1480 success.connection().display_label(),
1481 Some("test connection")
1482 );
1483 assert_eq!(success.target_row_count(), RowCount::unavailable());
1484 assert_eq!(
1485 success.validation_status(),
1486 ValidationStatus::skipped(ReportReasonCode::NotExecuted)
1487 );
1488
1489 assert!(failed.is_failed());
1490 assert_eq!(failed.output_name(), "second");
1491 assert_eq!(failed.target_table().table(), "second_orders");
1492 assert_eq!(failed.load_mode(), LoadMode::CreateAndLoad);
1493 assert_eq!(failed.connection().display_label(), Some("test connection"));
1494 assert_eq!(failed.target_row_count(), RowCount::unavailable());
1495 assert_eq!(
1496 failed.validation_status(),
1497 ValidationStatus::skipped(ReportReasonCode::NotExecuted)
1498 );
1499
1500 assert!(skipped.is_skipped());
1501 assert_eq!(skipped.output_name(), "third");
1502 assert_eq!(skipped.target_table().table(), "third_orders");
1503 assert_eq!(skipped.load_mode(), LoadMode::AppendExisting);
1504 assert_eq!(
1505 skipped.connection().display_label(),
1506 Some("test connection")
1507 );
1508 assert_eq!(skipped.target_row_count(), RowCount::unavailable());
1509 assert_eq!(
1510 skipped.validation_status(),
1511 ValidationStatus::skipped(ReportReasonCode::PriorFailure)
1512 );
1513
1514 Ok(())
1515 }
1516
1517 #[tokio::test]
1518 async fn failed_output_status_exposes_validation_evidence() -> Result<(), DeltaFunnelError> {
1519 let output = output_plan("validation_failed", LoadMode::CreateAndLoad)?;
1520 let metrics = MssqlWriteReportMetrics::new(
1521 RowCount::exact(3),
1522 MssqlBatchShapingReport::completed(1, 3, 1, 3),
1523 3,
1524 1,
1525 0,
1526 false,
1527 MssqlTargetCleanupStatus::Succeeded,
1528 )
1529 .with_target_validation(RowCount::exact(4), ValidationStatus::failed())
1530 .with_phase_timings(vec![PhaseTimingReport::failed(
1531 VALIDATION_PHASE,
1532 Duration::from_micros(5),
1533 )]);
1534 let failure = phase_error_with_context(
1535 MssqlWriteFailureContext::from_output_plan_with_metrics(
1536 &output,
1537 MssqlWritePhase::Validation,
1538 metrics,
1539 ),
1540 "target row count did not match exact output rows",
1541 );
1542 let writer = FakeWorkflowWriter::new(vec![Err(failure)]);
1543
1544 let report = write_mssql_outputs_with_writer(
1545 vec![job(output)?],
1546 MssqlWorkflowWriteOptions::default(),
1547 writer,
1548 )
1549 .await?;
1550
1551 let [status] = report.outputs() else {
1552 return Err(test_error("expected one output status"));
1553 };
1554 let MssqlOutputWriteStatus::Failed(failure) = status else {
1555 return Err(test_error("expected failed output status"));
1556 };
1557 assert_eq!(failure.target_row_count(), RowCount::exact(4));
1558 assert_eq!(failure.validation_status(), ValidationStatus::failed());
1559 assert_eq!(status.target_row_count(), RowCount::exact(4));
1560 assert_eq!(status.validation_status(), ValidationStatus::failed());
1561 assert_phase_timing(status, VALIDATION_PHASE, PhaseStatus::failed())?;
1562 Ok(())
1563 }
1564
1565 #[tokio::test]
1566 async fn skipped_output_stream_factories_are_not_invoked() -> Result<(), DeltaFunnelError> {
1567 let first = output_plan("first", LoadMode::AppendExisting)?;
1568 let second = output_plan("second", LoadMode::AppendExisting)?;
1569 let factory_calls = Arc::new(Mutex::new(Vec::new()));
1570 let failure = phase_error(
1571 &first,
1572 MssqlWritePhase::Connect,
1573 0,
1574 0,
1575 false,
1576 MssqlTargetCleanupStatus::NotApplicable,
1577 "connect failed",
1578 );
1579 let writer = FakeWorkflowWriter::new(vec![Err(failure)]);
1580
1581 let report = write_mssql_outputs_with_writer(
1582 vec![
1583 counted_job(first, Arc::clone(&factory_calls))?,
1584 counted_job(second, Arc::clone(&factory_calls))?,
1585 ],
1586 MssqlWorkflowWriteOptions::default(),
1587 writer,
1588 )
1589 .await?;
1590
1591 assert_eq!(locked(&factory_calls)?.as_slice(), ["first".to_owned()]);
1592 assert_eq!(report.outputs().len(), 2);
1593 assert!(report.outputs()[1].is_skipped());
1594
1595 Ok(())
1596 }
1597
1598 #[tokio::test]
1599 async fn deferred_query_execution_metadata_follows_writer_results()
1600 -> Result<(), DeltaFunnelError> {
1601 let first = output_plan("first", LoadMode::AppendExisting)?;
1602 let second = output_plan("second", LoadMode::AppendExisting)?;
1603 let first_report =
1604 write_report(&first, 1, 1, false, MssqlTargetCleanupStatus::NotApplicable);
1605 let second_failure = phase_error(
1606 &second,
1607 MssqlWritePhase::Connect,
1608 0,
1609 0,
1610 false,
1611 MssqlTargetCleanupStatus::NotApplicable,
1612 "connect failed",
1613 );
1614 let attachment_calls = Arc::new(Mutex::new(Vec::new()));
1615 let writer = FakeWorkflowWriter::new(vec![Ok(first_report), Err(second_failure)]);
1616
1617 let report = write_mssql_outputs_with_writer(
1618 vec![
1619 query_execution_job(first, Arc::clone(&attachment_calls))?,
1620 query_execution_job(second, Arc::clone(&attachment_calls))?,
1621 ],
1622 MssqlWorkflowWriteOptions::default(),
1623 writer,
1624 )
1625 .await?;
1626
1627 assert_eq!(
1628 locked(&attachment_calls)?.as_slice(),
1629 ["first:succeeded".to_owned(), "second:failed".to_owned()]
1630 );
1631 for status in report.outputs() {
1632 let phase_names = status
1633 .phase_timings()
1634 .iter()
1635 .map(PhaseTimingReport::phase_name)
1636 .collect::<Vec<_>>();
1637 assert_eq!(
1638 &phase_names[..4],
1639 [
1640 PLANNED_PHASE,
1641 DEFERRED_QUERY_PHASE,
1642 OUTPUT_STREAM_SETUP_PHASE,
1643 SQL_WRITE_PHASE,
1644 ]
1645 );
1646 }
1647
1648 Ok(())
1649 }
1650
1651 #[tokio::test]
1652 async fn deferred_query_failure_keeps_its_timings_before_workflow_failure_timings()
1653 -> Result<(), DeltaFunnelError> {
1654 let output = output_plan("failed_setup", LoadMode::AppendExisting)?;
1655 let writer = FakeWorkflowWriter::default();
1656 let job = failed_query_execution_job(output)?;
1657
1658 let report = write_mssql_outputs_with_writer(
1659 vec![job],
1660 MssqlWorkflowWriteOptions::default(),
1661 writer,
1662 )
1663 .await?;
1664
1665 let [status] = report.outputs() else {
1666 return Err(test_error("expected one output status"));
1667 };
1668 assert!(status.is_failed());
1669 let phase_names = status
1670 .phase_timings()
1671 .iter()
1672 .map(PhaseTimingReport::phase_name)
1673 .collect::<Vec<_>>();
1674 assert_eq!(
1675 phase_names,
1676 [
1677 PLANNED_PHASE,
1678 DEFERRED_QUERY_PHASE,
1679 OUTPUT_STREAM_SETUP_PHASE,
1680 SQL_WRITE_PHASE,
1681 VALIDATION_PHASE,
1682 ]
1683 );
1684
1685 Ok(())
1686 }
1687
1688 #[tokio::test]
1689 async fn skipped_outputs_do_not_reach_one_output_writer() -> Result<(), DeltaFunnelError> {
1690 let first = output_plan("first", LoadMode::AppendExisting)?;
1691 let second = output_plan("second", LoadMode::AppendExisting)?;
1692 let failure = phase_error(
1693 &first,
1694 MssqlWritePhase::PrepareTargetLifecycle,
1695 0,
1696 0,
1697 false,
1698 MssqlTargetCleanupStatus::NotApplicable,
1699 "prepare failed",
1700 );
1701 let writer = FakeWorkflowWriter::new(vec![Err(failure)]);
1702 let attempted = writer.attempted_outputs();
1703
1704 let report = write_mssql_outputs_with_writer(
1705 vec![job(first)?, job(second)?],
1706 MssqlWorkflowWriteOptions::default(),
1707 writer,
1708 )
1709 .await?;
1710
1711 assert_eq!(locked(&attempted)?.as_slice(), ["first".to_owned()]);
1712 assert!(report.outputs()[1].is_skipped());
1713
1714 Ok(())
1715 }
1716
1717 #[tokio::test]
1718 async fn stream_factory_setup_failure_fails_output_before_writer_and_skips_later_factories()
1719 -> Result<(), DeltaFunnelError> {
1720 let first = output_plan("first", LoadMode::AppendExisting)?;
1721 let second = output_plan("second", LoadMode::AppendExisting)?;
1722 let third = output_plan("third", LoadMode::AppendExisting)?;
1723 let first_report =
1724 write_report(&first, 1, 1, false, MssqlTargetCleanupStatus::NotApplicable);
1725 let factory_calls = Arc::new(Mutex::new(Vec::new()));
1726 let writer = FakeWorkflowWriter::new(vec![Ok(first_report)]);
1727 let attempted = writer.attempted_outputs();
1728
1729 let report = write_mssql_outputs_with_writer(
1730 vec![
1731 counted_job(first, Arc::clone(&factory_calls))?,
1732 failing_factory_job(
1733 second,
1734 Arc::clone(&factory_calls),
1735 "stream setup failed before SQL writer",
1736 )?,
1737 counted_job(third, Arc::clone(&factory_calls))?,
1738 ],
1739 MssqlWorkflowWriteOptions::default(),
1740 writer,
1741 )
1742 .await?;
1743
1744 assert_eq!(
1745 locked(&factory_calls)?.as_slice(),
1746 ["first".to_owned(), "second".to_owned()]
1747 );
1748 assert_eq!(locked(&attempted)?.as_slice(), ["first".to_owned()]);
1749 let [first_status, second_status, third_status] = report.outputs() else {
1750 return Err(test_error("expected three output statuses"));
1751 };
1752 assert!(first_status.is_succeeded());
1753 let MssqlOutputWriteStatus::Failed(failure) = second_status else {
1754 return Err(test_error("expected second output to fail"));
1755 };
1756 assert_eq!(failure.output_name(), "second");
1757 assert!(failure.context().is_none());
1758 assert_eq!(failure.output_row_count(), RowCount::unavailable());
1759 assert_batch_shaping(
1760 failure.batch_shaping(),
1761 PhaseStatus::not_started(ReportReasonCode::NotExecuted),
1762 0,
1763 0,
1764 0,
1765 0,
1766 );
1767 assert_phase_timing(second_status, PLANNED_PHASE, PhaseStatus::completed())?;
1768 assert_phase_timing(
1769 second_status,
1770 OUTPUT_STREAM_SETUP_PHASE,
1771 PhaseStatus::failed(),
1772 )?;
1773 assert_phase_timing(
1774 second_status,
1775 SQL_WRITE_PHASE,
1776 PhaseStatus::not_started(ReportReasonCode::NotExecuted),
1777 )?;
1778 assert_phase_timing(
1779 second_status,
1780 VALIDATION_PHASE,
1781 PhaseStatus::not_started(ReportReasonCode::FailureBeforeValidation),
1782 )?;
1783 assert!(
1784 failure
1785 .error()
1786 .contains("stream setup failed before SQL writer")
1787 );
1788 assert_skipped_after(third_status, "third", "second")?;
1789
1790 Ok(())
1791 }
1792
1793 #[tokio::test]
1794 async fn stream_polling_failure_after_setup_reaches_writer_boundary()
1795 -> Result<(), DeltaFunnelError> {
1796 let output = output_plan("poll_failure", LoadMode::AppendExisting)?;
1797 let factory_calls = Arc::new(Mutex::new(Vec::new()));
1798 let writer = StreamPollingWorkflowWriter::default();
1799 let attempted = writer.attempted_outputs();
1800
1801 let report = write_mssql_outputs_with_writer(
1802 vec![polling_error_job(output, Arc::clone(&factory_calls))?],
1803 MssqlWorkflowWriteOptions::default(),
1804 writer,
1805 )
1806 .await?;
1807
1808 assert_eq!(
1809 locked(&factory_calls)?.as_slice(),
1810 ["poll_failure".to_owned()]
1811 );
1812 assert_eq!(locked(&attempted)?.as_slice(), ["poll_failure".to_owned()]);
1813 let [MssqlOutputWriteStatus::Failed(failure)] = report.outputs() else {
1814 return Err(test_error("expected failed output status"));
1815 };
1816 assert_eq!(failure.output_name(), "poll_failure");
1817 assert!(failure.error().contains("stream failed during polling"));
1818 assert!(failure.context().is_none());
1819 assert_eq!(failure.output_row_count(), RowCount::unavailable());
1820 assert_batch_shaping(
1821 failure.batch_shaping(),
1822 PhaseStatus::not_started(ReportReasonCode::NotExecuted),
1823 0,
1824 0,
1825 0,
1826 0,
1827 );
1828 assert_phase_timing(
1829 &report.outputs()[0],
1830 OUTPUT_STREAM_SETUP_PHASE,
1831 PhaseStatus::completed(),
1832 )?;
1833 assert_phase_timing(&report.outputs()[0], SQL_WRITE_PHASE, PhaseStatus::failed())?;
1834
1835 Ok(())
1836 }
1837
1838 #[tokio::test]
1839 async fn failed_create_and_load_cleanup_status_is_preserved() -> Result<(), DeltaFunnelError> {
1840 let output = output_plan("created", LoadMode::CreateAndLoad)?;
1841 let failure = phase_error(
1842 &output,
1843 MssqlWritePhase::Finalize,
1844 2,
1845 1,
1846 false,
1847 MssqlTargetCleanupStatus::Succeeded,
1848 "finalize failed",
1849 );
1850 let writer = FakeWorkflowWriter::new(vec![Err(failure)]);
1851
1852 let report = write_mssql_outputs_with_writer(
1853 vec![job(output)?],
1854 MssqlWorkflowWriteOptions::default(),
1855 writer,
1856 )
1857 .await?;
1858
1859 let [MssqlOutputWriteStatus::Failed(failure)] = report.outputs() else {
1860 return Err(test_error("expected failed output status"));
1861 };
1862 let context = failure
1863 .context()
1864 .ok_or_else(|| test_error("expected write failure context"))?;
1865 assert_eq!(context.cleanup(), MssqlTargetCleanupStatus::Succeeded);
1866 assert_eq!(context.stats().rows_written(), 2);
1867 assert_eq!(context.stats().batches_written(), 1);
1868
1869 Ok(())
1870 }
1871
1872 #[tokio::test]
1873 async fn parallel_writer_configuration_is_rejected() -> Result<(), DeltaFunnelError> {
1874 let output = output_plan("first", LoadMode::AppendExisting)?;
1875 let writer = FakeWorkflowWriter::new(vec![Ok(write_report(
1876 &output,
1877 1,
1878 1,
1879 false,
1880 MssqlTargetCleanupStatus::NotApplicable,
1881 ))]);
1882
1883 let error = write_mssql_outputs_with_writer(
1884 vec![job(output)?],
1885 MssqlWorkflowWriteOptions::new().with_max_parallel_outputs(2),
1886 writer,
1887 )
1888 .await;
1889 let Err(error) = error else {
1890 return Err(test_error("parallel writer config should be rejected"));
1891 };
1892
1893 assert!(error.to_string().contains("parallel MSSQL output writers"));
1894
1895 Ok(())
1896 }
1897
1898 #[tokio::test]
1899 async fn zero_parallel_writer_configuration_is_rejected_before_attempting_outputs()
1900 -> Result<(), DeltaFunnelError> {
1901 let output = output_plan("first", LoadMode::AppendExisting)?;
1902 let writer = FakeWorkflowWriter::new(vec![Ok(write_report(
1903 &output,
1904 1,
1905 1,
1906 false,
1907 MssqlTargetCleanupStatus::NotApplicable,
1908 ))]);
1909 let attempted = writer.attempted_outputs();
1910
1911 let error = write_mssql_outputs_with_writer(
1912 vec![job(output)?],
1913 MssqlWorkflowWriteOptions::new().with_max_parallel_outputs(0),
1914 writer,
1915 )
1916 .await;
1917 let Err(error) = error else {
1918 return Err(test_error("zero writer config should be rejected"));
1919 };
1920
1921 assert!(error.to_string().contains("must be at least 1"));
1922 assert!(locked(&attempted)?.is_empty());
1923
1924 Ok(())
1925 }
1926
1927 #[tokio::test]
1928 async fn workflow_report_debug_and_display_redact_connection_credentials()
1929 -> Result<(), DeltaFunnelError> {
1930 let output = output_plan("first", LoadMode::AppendExisting)?;
1931 let report = write_report(
1932 &output,
1933 1,
1934 1,
1935 false,
1936 MssqlTargetCleanupStatus::NotApplicable,
1937 );
1938 let writer = FakeWorkflowWriter::new(vec![Ok(report)]);
1939
1940 let report = write_mssql_outputs_with_writer(
1941 vec![job(output)?],
1942 MssqlWorkflowWriteOptions::default(),
1943 writer,
1944 )
1945 .await?;
1946
1947 let debug = format!("{report:?}");
1948 let display = report.to_string();
1949 assert!(!debug.contains("secret"));
1950 assert!(!display.contains("secret"));
1951 assert!(display.contains("1 succeeded"));
1952 assert!(!display.to_lowercase().contains("transaction"));
1953
1954 Ok(())
1955 }
1956
1957 fn job(output_plan: MssqlTargetOutputPlan) -> Result<MssqlOutputWriteJob, DeltaFunnelError> {
1958 counted_job(output_plan, Arc::new(Mutex::new(Vec::new())))
1959 }
1960
1961 fn counted_job(
1962 output_plan: MssqlTargetOutputPlan,
1963 factory_calls: Arc<Mutex<Vec<String>>>,
1964 ) -> Result<MssqlOutputWriteJob, DeltaFunnelError> {
1965 let output_name = output_plan.output_name().to_owned();
1966 Ok(MssqlOutputWriteJob::with_default_write_backend(
1967 output_schema(),
1968 resolved_target(output_plan)?,
1969 MssqlSchemaPlanOptions::default(),
1970 move || {
1971 if let Ok(mut calls) = factory_calls.lock() {
1972 calls.push(output_name);
1973 }
1974 async { Ok(stream::empty()) }
1975 },
1976 )
1977 .with_phase_timings(planned_phase_timings()))
1978 }
1979
1980 fn query_execution_job(
1981 output_plan: MssqlTargetOutputPlan,
1982 attachment_calls: Arc<Mutex<Vec<String>>>,
1983 ) -> Result<MssqlOutputWriteJob, DeltaFunnelError> {
1984 let attachment_output_name = output_plan.output_name().to_owned();
1985 Ok(MssqlOutputWriteJob::new_with_query_execution_factory(
1986 output_schema(),
1987 resolved_target(output_plan)?,
1988 MssqlSchemaPlanOptions::default(),
1989 Box::new(move || {
1990 Box::pin(async move {
1991 Ok(MssqlOutputQueryExecution {
1992 stream: Box::pin(stream::empty()),
1993 query_phase_timings: vec![PhaseTimingReport::completed(
1994 DEFERRED_QUERY_PHASE,
1995 Duration::from_micros(1),
1996 )],
1997 attach_profile_to_result: Some(Box::new(move |result| {
1998 if let Ok(mut calls) = attachment_calls.lock() {
1999 let outcome = if result.is_ok() {
2000 "succeeded"
2001 } else {
2002 "failed"
2003 };
2004 calls.push(format!("{attachment_output_name}:{outcome}"));
2005 }
2006 result
2007 })),
2008 })
2009 })
2010 }),
2011 default_mssql_write_backend(),
2012 ValidationOptions::default(),
2013 )
2014 .with_phase_timings(planned_phase_timings()))
2015 }
2016
2017 fn failed_query_execution_job(
2018 output_plan: MssqlTargetOutputPlan,
2019 ) -> Result<MssqlOutputWriteJob, DeltaFunnelError> {
2020 Ok(MssqlOutputWriteJob::new_with_query_execution_factory(
2021 output_schema(),
2022 resolved_target(output_plan)?,
2023 MssqlSchemaPlanOptions::default(),
2024 Box::new(|| {
2025 Box::pin(async {
2026 Err(MssqlOutputQueryError {
2027 error: test_error("deferred setup failed"),
2028 query_phase_timings: vec![PhaseTimingReport::failed(
2029 DEFERRED_QUERY_PHASE,
2030 Duration::from_micros(1),
2031 )],
2032 })
2033 })
2034 }),
2035 default_mssql_write_backend(),
2036 ValidationOptions::default(),
2037 )
2038 .with_phase_timings(planned_phase_timings()))
2039 }
2040
2041 fn failing_factory_job(
2042 output_plan: MssqlTargetOutputPlan,
2043 factory_calls: Arc<Mutex<Vec<String>>>,
2044 message: &'static str,
2045 ) -> Result<MssqlOutputWriteJob, DeltaFunnelError> {
2046 let output_name = output_plan.output_name().to_owned();
2047 Ok(MssqlOutputWriteJob::with_default_write_backend(
2048 output_schema(),
2049 resolved_target(output_plan)?,
2050 MssqlSchemaPlanOptions::default(),
2051 move || {
2052 if let Ok(mut calls) = factory_calls.lock() {
2053 calls.push(output_name);
2054 }
2055 async move {
2056 Err::<stream::Empty<Result<RecordBatch, DeltaFunnelError>>, DeltaFunnelError>(
2057 test_error(message),
2058 )
2059 }
2060 },
2061 )
2062 .with_phase_timings(planned_phase_timings()))
2063 }
2064
2065 fn polling_error_job(
2066 output_plan: MssqlTargetOutputPlan,
2067 factory_calls: Arc<Mutex<Vec<String>>>,
2068 ) -> Result<MssqlOutputWriteJob, DeltaFunnelError> {
2069 let output_name = output_plan.output_name().to_owned();
2070 Ok(MssqlOutputWriteJob::with_default_write_backend(
2071 output_schema(),
2072 resolved_target(output_plan)?,
2073 MssqlSchemaPlanOptions::default(),
2074 move || {
2075 if let Ok(mut calls) = factory_calls.lock() {
2076 calls.push(output_name);
2077 }
2078 async {
2079 Ok(stream::iter(vec![Err(
2080 DeltaFunnelError::MssqlWorkflowPlanning {
2081 message: "stream failed during polling".to_owned(),
2082 },
2083 )]))
2084 }
2085 },
2086 )
2087 .with_phase_timings(planned_phase_timings()))
2088 }
2089
2090 fn planned_phase_timings() -> Vec<PhaseTimingReport> {
2091 vec![PhaseTimingReport::completed(
2092 PLANNED_PHASE,
2093 Duration::from_micros(1),
2094 )]
2095 }
2096
2097 fn resolved_target(
2098 output_plan: MssqlTargetOutputPlan,
2099 ) -> Result<ResolvedMssqlTarget, DeltaFunnelError> {
2100 let connection = secret_connection()?;
2101
2102 MssqlTargetConfig::new(output_plan.target_table().clone())
2103 .with_load_mode(output_plan.load_mode())
2104 .resolve(MssqlTargetResolutionContext {
2105 output_name: Some(output_plan.output_name()),
2106 default_connection: Some(&connection),
2107 })
2108 }
2109
2110 fn output_plan(
2111 output_name: &str,
2112 load_mode: LoadMode,
2113 ) -> Result<MssqlTargetOutputPlan, DeltaFunnelError> {
2114 let connection = secret_connection()?;
2115 let target = MssqlTargetConfig::new(MssqlTargetTable::new(
2116 "dbo",
2117 format!("{output_name}_orders"),
2118 )?)
2119 .with_load_mode(load_mode);
2120 plan_mssql_target_for_output(
2121 output_schema(),
2122 output_name,
2123 &target,
2124 Some(&connection),
2125 MssqlSchemaPlanOptions::default(),
2126 )
2127 }
2128
2129 fn output_schema() -> SchemaRef {
2130 Arc::new(Schema::new(vec![Field::new(
2131 "order_id",
2132 DataType::Int64,
2133 false,
2134 )]))
2135 }
2136
2137 fn secret_connection() -> Result<MssqlConnectionConfig, DeltaFunnelError> {
2138 Ok(MssqlConnectionConfig::new(
2139 "server=tcp:example.invalid,1433;user id=sa;password=secret",
2140 )?
2141 .with_display_label("test connection"))
2142 }
2143
2144 fn write_report(
2145 output_plan: &MssqlTargetOutputPlan,
2146 rows_written: u64,
2147 batches_written: u64,
2148 partial_write_possible: bool,
2149 cleanup: MssqlTargetCleanupStatus,
2150 ) -> MssqlWriteReport {
2151 MssqlWriteReport::from_output_plan(
2152 output_plan,
2153 rows_written,
2154 batches_written,
2155 0,
2156 partial_write_possible,
2157 cleanup,
2158 )
2159 }
2160
2161 fn phase_error(
2162 output_plan: &MssqlTargetOutputPlan,
2163 phase: MssqlWritePhase,
2164 rows_written: u64,
2165 batches_written: u64,
2166 partial_write_possible: bool,
2167 cleanup: MssqlTargetCleanupStatus,
2168 message: &str,
2169 ) -> DeltaFunnelError {
2170 phase_error_with_context(
2171 MssqlWriteFailureContext::from_output_plan(
2172 output_plan,
2173 phase,
2174 rows_written,
2175 batches_written,
2176 0,
2177 partial_write_possible,
2178 cleanup,
2179 ),
2180 message,
2181 )
2182 }
2183
2184 fn phase_error_with_context(
2185 context: MssqlWriteFailureContext,
2186 message: &str,
2187 ) -> DeltaFunnelError {
2188 DeltaFunnelError::MssqlWritePhase {
2189 context: Box::new(context),
2190 message: message.to_owned(),
2191 }
2192 }
2193
2194 fn assert_status_output(
2195 statuses: &[MssqlOutputWriteStatus],
2196 index: usize,
2197 expected_output_name: &str,
2198 ) -> Result<(), DeltaFunnelError> {
2199 match statuses.get(index) {
2200 Some(MssqlOutputWriteStatus::Succeeded(report)) => {
2201 assert_eq!(report.output_name(), expected_output_name);
2202 Ok(())
2203 }
2204 Some(other) => Err(test_error(format!(
2205 "expected success at index {index}, got {other:?}"
2206 ))),
2207 None => Err(test_error(format!("missing status at index {index}"))),
2208 }
2209 }
2210
2211 fn assert_skipped_after(
2212 status: &MssqlOutputWriteStatus,
2213 expected_output_name: &str,
2214 expected_failed_output_name: &str,
2215 ) -> Result<(), DeltaFunnelError> {
2216 let MssqlOutputWriteStatus::Skipped(skipped) = status else {
2217 return Err(test_error(format!(
2218 "expected skipped status, got {status:?}"
2219 )));
2220 };
2221 assert_eq!(skipped.output_name(), expected_output_name);
2222 assert_eq!(
2223 skipped.reason(),
2224 &MssqlWriteSkippedReason::PreviousOutputFailed {
2225 failed_output_name: expected_failed_output_name.to_owned()
2226 }
2227 );
2228 assert_eq!(status.output_row_count(), RowCount::unavailable());
2229 assert_batch_shaping(
2230 status.batch_shaping(),
2231 PhaseStatus::skipped(ReportReasonCode::PriorFailure),
2232 0,
2233 0,
2234 0,
2235 0,
2236 );
2237 assert_phase_timing(status, PLANNED_PHASE, PhaseStatus::completed())?;
2238 assert_phase_timing(
2239 status,
2240 OUTPUT_STREAM_SETUP_PHASE,
2241 PhaseStatus::skipped(ReportReasonCode::PriorFailure),
2242 )?;
2243 assert_phase_timing(
2244 status,
2245 SQL_WRITE_PHASE,
2246 PhaseStatus::skipped(ReportReasonCode::PriorFailure),
2247 )?;
2248 assert_phase_timing(
2249 status,
2250 VALIDATION_PHASE,
2251 PhaseStatus::skipped(ReportReasonCode::PriorFailure),
2252 )?;
2253 Ok(())
2254 }
2255
2256 fn assert_phase_timing(
2257 status: &MssqlOutputWriteStatus,
2258 phase_name: &str,
2259 expected_status: PhaseStatus,
2260 ) -> Result<(), DeltaFunnelError> {
2261 let timing = status
2262 .phase_timings()
2263 .iter()
2264 .find(|timing| timing.phase_name() == phase_name)
2265 .ok_or_else(|| test_error(format!("missing phase timing {phase_name}")))?;
2266
2267 assert_eq!(timing.status(), expected_status);
2268 if expected_status.is_completed() || expected_status.is_failed() {
2269 assert!(timing.elapsed_micros().is_some());
2270 } else {
2271 assert_eq!(timing.elapsed_micros(), None);
2272 }
2273 Ok(())
2274 }
2275
2276 fn assert_batch_shaping(
2277 report: MssqlBatchShapingReport,
2278 expected_status: PhaseStatus,
2279 expected_input_batches: u64,
2280 expected_input_rows: u64,
2281 expected_output_batches: u64,
2282 expected_output_rows: u64,
2283 ) {
2284 assert_eq!(report.status(), expected_status);
2285 assert_eq!(report.input_batches(), expected_input_batches);
2286 assert_eq!(report.input_rows(), expected_input_rows);
2287 assert_eq!(report.output_batches(), expected_output_batches);
2288 assert_eq!(report.output_rows(), expected_output_rows);
2289 }
2290
2291 fn locked<T>(mutex: &Mutex<T>) -> Result<MutexGuard<'_, T>, DeltaFunnelError> {
2292 mutex.lock().map_err(|_| test_error("mutex lock poisoned"))
2293 }
2294
2295 fn test_error(message: impl Into<String>) -> DeltaFunnelError {
2296 DeltaFunnelError::MssqlWorkflowPlanning {
2297 message: message.into(),
2298 }
2299 }
2300}