Skip to main content

delta_funnel/sql_server/execution/
workflow.rs

1//! Sequential multi-output SQL Server write orchestration.
2//!
3//! This module keeps the multi-output workflow layer separate from the
4//! one-output sink. The MVP runs outputs sequentially, stops on the first
5//! failure, and marks later outputs as skipped without invoking their lazy batch
6//! stream factories.
7
8use 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
37/// Lazy stream produced only when a SQL Server output is attempted.
38pub type MssqlOutputBatchStream =
39    Pin<Box<dyn Stream<Item = Result<RecordBatch, DeltaFunnelError>> + Send>>;
40
41/// Fallible future that constructs a direct batch stream for one attempted output.
42pub type MssqlOutputBatchStreamFuture =
43    Pin<Box<dyn Future<Output = Result<MssqlOutputBatchStream, DeltaFunnelError>> + Send>>;
44
45/// Async factory that constructs a direct batch stream for one attempted output.
46pub type MssqlOutputBatchStreamFactory = Box<dyn FnOnce() -> MssqlOutputBatchStreamFuture + Send>;
47
48/// One output query execution and its terminal reporting state.
49pub(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
55/// Callback that attaches a terminal query profile to one writer result.
56pub(crate) type MssqlOutputProfileCallback = Box<
57    dyn FnOnce(
58            Result<MssqlWriteReport, DeltaFunnelError>,
59        ) -> Result<MssqlWriteReport, DeltaFunnelError>
60        + Send,
61>;
62
63/// Failed output query plus timings completed before the failure.
64pub(crate) struct MssqlOutputQueryError {
65    pub(crate) error: DeltaFunnelError,
66    pub(crate) query_phase_timings: Vec<PhaseTimingReport>,
67}
68
69/// Future returned when an orchestrated write-all output starts its query.
70pub(crate) type MssqlOutputQueryFuture =
71    Pin<Box<dyn Future<Output = Result<MssqlOutputQueryExecution, MssqlOutputQueryError>> + Send>>;
72
73/// One deferred SQL Server output write job.
74///
75/// The job owns an already resolved SQL Server target plus a lazy batch stream
76/// factory. The workflow awaits the factory only after the output becomes the
77/// next attempted output. Skipped jobs keep their stream factories uncalled, so
78/// skipped outputs do not start source reads, DataFusion execution, stream
79/// setup, SQL connections, lifecycle preparation, writer initialization, or
80/// batch polling through this API.
81pub 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    /// Creates a deferred SQL Server output write job.
96    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    /// Creates an internal job whose query execution carries its stream,
134    /// phase timings, and terminal profile callback.
135    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    /// Adds phase timings that completed before this deferred write job runs.
158    #[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    /// Adds the reporter used only if this deferred output is attempted.
165    #[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    /// Adds the shared write-all trace used only if this output is attempted.
175    #[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    /// Creates a deferred SQL Server output write job using the default write backend.
187    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    /// Returns the selected output name.
209    #[must_use]
210    pub fn output_name(&self) -> &str {
211        self.resolved_target.output_name()
212    }
213
214    /// Returns a redacted target summary for reports.
215    #[must_use]
216    pub fn target_summary(&self) -> MssqlTargetSummary {
217        self.resolved_target.summary()
218    }
219
220    /// Returns phase timings that completed before this deferred write job runs.
221    #[must_use]
222    pub fn phase_timings(&self) -> &[PhaseTimingReport] {
223        &self.phase_timings
224    }
225}
226
227/// SQL Server multi-output workflow options.
228#[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    /// Creates default sequential workflow options.
243    #[must_use]
244    pub const fn new() -> Self {
245        Self {
246            max_parallel_outputs: 1,
247        }
248    }
249
250    /// Sets the requested maximum number of parallel output writers.
251    ///
252    /// The current MVP supports only `1`. Values greater than `1` are rejected
253    /// explicitly so callers do not mistake the workflow for a parallel writer
254    /// pool or cross-output transaction boundary.
255    #[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    /// Returns the requested maximum number of parallel output writers.
262    #[must_use]
263    pub const fn max_parallel_outputs(&self) -> usize {
264        self.max_parallel_outputs
265    }
266
267    /// Validates workflow write options before any output write side effects.
268    ///
269    /// # Errors
270    ///
271    /// Returns [`DeltaFunnelError::MssqlWorkflowPlanning`] when no output
272    /// writer is allowed or when parallel output writers are requested. The
273    /// current MVP is intentionally single-writer so callers cannot mistake
274    /// this workflow for a parallel writer pool or cross-output transaction.
275    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/// Structured report for a multi-output SQL Server write workflow.
291#[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    /// Returns the number of selected outputs represented by this report.
302    #[must_use]
303    pub fn len(&self) -> usize {
304        self.outputs.len()
305    }
306
307    /// Returns whether this report contains no selected outputs.
308    #[must_use]
309    pub fn is_empty(&self) -> bool {
310        self.outputs.is_empty()
311    }
312
313    /// Returns per-output statuses in caller-provided order.
314    #[must_use]
315    pub fn outputs(&self) -> &[MssqlOutputWriteStatus] {
316        &self.outputs
317    }
318
319    /// Returns whether every selected output completed successfully.
320    #[must_use]
321    pub fn all_succeeded(&self) -> bool {
322        self.outputs
323            .iter()
324            .all(MssqlOutputWriteStatus::is_succeeded)
325    }
326
327    /// Returns the number of outputs that completed successfully.
328    #[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    /// Returns the number of outputs that failed.
337    #[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    /// Returns the number of outputs skipped after a previous output failed.
346    #[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/// Final write status for one selected SQL Server output.
369#[derive(Debug, Clone, PartialEq, Eq)]
370pub enum MssqlOutputWriteStatus {
371    /// The output completed successfully.
372    Succeeded(MssqlWriteReport),
373    /// The output was the first attempted output to fail.
374    Failed(MssqlWriteFailureReport),
375    /// The output was not attempted because an earlier output failed.
376    Skipped(MssqlWriteSkippedReport),
377}
378
379impl MssqlOutputWriteStatus {
380    /// Returns the selected output name for this status.
381    #[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    /// Returns the effective SQL Server target table for this status.
391    #[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    /// Returns the requested lifecycle mode for this output.
401    #[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    /// Returns where the effective connection came from.
411    #[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    /// Returns the redacted effective connection summary.
421    #[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    /// Returns query output row evidence for this output.
431    #[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    /// Returns target-side row count evidence for this output.
441    #[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    /// Returns target-side validation status for this output.
451    #[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    /// Returns batch-shaping counters for this output.
461    #[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    /// Returns workflow phase timing reports for this output when available.
471    #[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    /// Returns whether this output succeeded.
481    #[must_use]
482    pub const fn is_succeeded(&self) -> bool {
483        matches!(self, Self::Succeeded(_))
484    }
485
486    /// Returns whether this output failed.
487    #[must_use]
488    pub const fn is_failed(&self) -> bool {
489        matches!(self, Self::Failed(_))
490    }
491
492    /// Returns whether this output was skipped before any work was attempted.
493    #[must_use]
494    pub const fn is_skipped(&self) -> bool {
495        matches!(self, Self::Skipped(_))
496    }
497}
498
499/// Structured report for the first failed SQL Server output.
500#[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    /// Returns the redacted target summary for the failed output.
549    #[must_use]
550    pub const fn target(&self) -> &MssqlTargetSummary {
551        &self.target
552    }
553
554    /// Returns the selected output name.
555    #[must_use]
556    pub fn output_name(&self) -> &str {
557        self.target.output_name()
558    }
559
560    /// Returns the sanitized error message for the failed output.
561    #[must_use]
562    pub fn error(&self) -> &str {
563        &self.error
564    }
565
566    /// Returns phase-aware write failure context when the one-output sink
567    /// provided it.
568    #[must_use]
569    pub fn context(&self) -> Option<&MssqlWriteFailureContext> {
570        self.context.as_deref()
571    }
572
573    /// Returns query output row evidence known at failure time.
574    #[must_use]
575    pub const fn output_row_count(&self) -> RowCount {
576        self.output_row_count
577    }
578
579    /// Returns target-side row count evidence known at failure time.
580    #[must_use]
581    pub const fn target_row_count(&self) -> RowCount {
582        self.target_row_count
583    }
584
585    /// Returns target-side validation status known at failure time.
586    #[must_use]
587    pub const fn validation_status(&self) -> ValidationStatus {
588        self.validation_status
589    }
590
591    /// Returns batch-shaping counters known at failure time.
592    #[must_use]
593    pub const fn batch_shaping(&self) -> MssqlBatchShapingReport {
594        self.batch_shaping
595    }
596
597    /// Returns workflow phase timing reports for this failed output.
598    #[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/// Structured report for a skipped SQL Server output.
627#[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    /// Returns the redacted target summary for the skipped output.
656    #[must_use]
657    pub const fn target(&self) -> &MssqlTargetSummary {
658        &self.target
659    }
660
661    /// Returns the selected output name.
662    #[must_use]
663    pub fn output_name(&self) -> &str {
664        self.target.output_name()
665    }
666
667    /// Returns why this output was skipped before any work was attempted.
668    #[must_use]
669    pub const fn reason(&self) -> &MssqlWriteSkippedReason {
670        &self.reason
671    }
672
673    /// Returns query output row evidence for this skipped output.
674    #[must_use]
675    pub const fn output_row_count(&self) -> RowCount {
676        self.output_row_count
677    }
678
679    /// Returns target-side row count evidence for this skipped output.
680    #[must_use]
681    pub const fn target_row_count(&self) -> RowCount {
682        self.target_row_count
683    }
684
685    /// Returns target-side validation status for this skipped output.
686    #[must_use]
687    pub const fn validation_status(&self) -> ValidationStatus {
688        self.validation_status
689    }
690
691    /// Returns batch-shaping counters for this skipped output.
692    #[must_use]
693    pub const fn batch_shaping(&self) -> MssqlBatchShapingReport {
694        self.batch_shaping
695    }
696
697    /// Returns workflow phase timing reports for this skipped output.
698    #[must_use]
699    pub fn phase_timings(&self) -> &[PhaseTimingReport] {
700        &self.phase_timings
701    }
702}
703
704/// Reason one selected SQL Server output was skipped.
705#[derive(Debug, Clone, PartialEq, Eq)]
706pub enum MssqlWriteSkippedReason {
707    /// A previous output failed and the MVP stops on first failure.
708    PreviousOutputFailed {
709        /// Output name of the first failed output.
710        failed_output_name: String,
711    },
712}
713
714/// Writes multiple SQL Server outputs sequentially.
715///
716/// This workflow calls the public one-output sink once per attempted output,
717/// in caller-provided order. It stops on the first failed output and marks all
718/// later outputs as skipped without invoking their lazy batch stream factories.
719/// The report is per-output and does not imply all-or-nothing transaction
720/// behavior across outputs, target tables, or SQL Server connections.
721pub 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    // The writer has now drained, failed, or dropped the stream, so the
981    // callback can attach the terminal query profile.
982    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}