Skip to main content

delta_funnel/sql_server/execution/
write.rs

1//! SQL Server write execution.
2//!
3//! This module owns Delta Funnel write defaults around `arrow-tiberius`.
4
5use std::{
6    fmt,
7    time::{Duration, Instant},
8};
9
10use arrow_schema::Schema;
11use async_trait::async_trait;
12use datafusion::arrow::record_batch::RecordBatch;
13use futures_util::{
14    Stream, StreamExt,
15    io::{AsyncRead, AsyncWrite},
16    pin_mut,
17};
18
19use crate::{
20    DeltaFunnelError, PhaseTimingReport, ReportReasonCode, RowCount, ValidationStatus,
21    observability,
22    report::sql_server::{
23        MssqlBatchShapingReport, MssqlOutputBatchValidationReport, MssqlTargetCleanupStatus,
24        MssqlWriteFailureContext, MssqlWriteReport, MssqlWriteReportMetrics,
25        write::MssqlWriteDiagnostic,
26    },
27};
28
29const POLL_BATCH_STREAM_PHASE: &str = "poll_batch_stream";
30const VALIDATE_BATCH_SCHEMA_PHASE: &str = "validate_batch_schema";
31const WRITE_BATCH_PHASE: &str = "write_batch";
32const FINALIZE_PHASE: &str = "finalize";
33const VALIDATION_PHASE: &str = "validation";
34
35use super::{
36    LoadMode, MssqlLifecycleExecutionGuardrail, MssqlPreparedTarget, MssqlPreparedTargetAction,
37    MssqlTargetOutputPlan,
38};
39
40/// Fakeable bulk-load writer boundary for one planned SQL Server output.
41#[async_trait]
42pub(crate) trait MssqlBulkLoadWriter: Sized + Send {
43    /// Writes one record batch.
44    async fn write_batch(
45        &mut self,
46        batch: &RecordBatch,
47    ) -> Result<arrow_tiberius::WriteStats, arrow_tiberius::Error>;
48
49    /// Finalizes the writer and consumes it, matching `arrow-tiberius`.
50    async fn finish(self) -> Result<arrow_tiberius::WriteStats, arrow_tiberius::Error>;
51}
52
53#[async_trait]
54impl<'client, S> MssqlBulkLoadWriter for arrow_tiberius::BulkWriter<'client, S>
55where
56    S: AsyncRead + AsyncWrite + Unpin + Send,
57{
58    async fn write_batch(
59        &mut self,
60        batch: &RecordBatch,
61    ) -> Result<arrow_tiberius::WriteStats, arrow_tiberius::Error> {
62        arrow_tiberius::BulkWriter::write_batch(self, batch).await
63    }
64
65    async fn finish(self) -> Result<arrow_tiberius::WriteStats, arrow_tiberius::Error> {
66        arrow_tiberius::BulkWriter::finish(self).await
67    }
68}
69
70#[async_trait]
71impl MssqlBulkLoadWriter for arrow_tiberius::ConnectedBulkWriter<'_> {
72    async fn write_batch(
73        &mut self,
74        batch: &RecordBatch,
75    ) -> Result<arrow_tiberius::WriteStats, arrow_tiberius::Error> {
76        arrow_tiberius::ConnectedBulkWriter::write_batch(self, batch).await
77    }
78
79    async fn finish(self) -> Result<arrow_tiberius::WriteStats, arrow_tiberius::Error> {
80        arrow_tiberius::ConnectedBulkWriter::finish(self).await
81    }
82}
83
84/// Fakeable boundary for constructing one SQL Server bulk writer.
85#[allow(dead_code)]
86#[async_trait]
87pub(crate) trait MssqlBulkWriterFactory: Send {
88    /// Writer type returned by this factory.
89    type Writer: MssqlBulkLoadWriter;
90
91    /// Constructs a bulk writer from an already prepared target request.
92    async fn initialize(
93        self,
94        request: MssqlBulkWriterInitializationRequest,
95    ) -> Result<Self::Writer, arrow_tiberius::Error>;
96}
97
98/// Production bulk writer factory for an already connected SQL Server client.
99#[allow(dead_code)]
100pub(crate) struct MssqlConnectedBulkWriterFactory<'client> {
101    client: &'client mut arrow_tiberius::ConnectedMssqlClient,
102}
103
104impl<'client> MssqlConnectedBulkWriterFactory<'client> {
105    /// Wraps the already connected SQL Server client used for lifecycle work.
106    #[must_use]
107    pub(crate) const fn new(client: &'client mut arrow_tiberius::ConnectedMssqlClient) -> Self {
108        Self { client }
109    }
110}
111
112#[async_trait]
113impl<'client> MssqlBulkWriterFactory for MssqlConnectedBulkWriterFactory<'client> {
114    type Writer = arrow_tiberius::ConnectedBulkWriter<'client>;
115
116    async fn initialize(
117        self,
118        request: MssqlBulkWriterInitializationRequest,
119    ) -> Result<Self::Writer, arrow_tiberius::Error> {
120        let MssqlBulkWriterInitializationRequest {
121            table,
122            planned_schema,
123            backend,
124            ..
125        } = request;
126
127        self.client
128            .bulk_writer(table, planned_schema, backend.into())
129            .await
130    }
131}
132
133/// Planned inputs for constructing one SQL Server bulk writer.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub(crate) struct MssqlBulkWriterInitializationRequest {
136    output_name: String,
137    table: arrow_tiberius::TableName,
138    planned_schema: arrow_tiberius::PlannedSchema,
139    backend: MssqlWriteBackend,
140    prepared_action: MssqlPreparedTargetAction,
141    cleanup: MssqlTargetCleanupStatus,
142}
143
144#[allow(dead_code)]
145impl MssqlBulkWriterInitializationRequest {
146    /// Builds writer initialization inputs from a previously prepared target.
147    pub(crate) fn from_prepared_target(
148        output_plan: &MssqlTargetOutputPlan,
149        prepared_target: &MssqlPreparedTarget,
150        backend: MssqlWriteBackend,
151    ) -> Result<Self, DeltaFunnelError> {
152        ensure_prepared_target_matches_output_plan(output_plan, prepared_target)?;
153
154        Ok(Self {
155            output_name: output_plan.output_name().to_owned(),
156            table: prepared_target.table_name().clone(),
157            planned_schema: output_plan.planned_schema().clone(),
158            backend,
159            prepared_action: prepared_target.report().action(),
160            cleanup: prepared_target.report().cleanup(),
161        })
162    }
163
164    /// Returns the selected output name.
165    #[must_use]
166    pub(crate) fn output_name(&self) -> &str {
167        &self.output_name
168    }
169
170    /// Returns the prepared SQL Server table identity.
171    #[must_use]
172    pub(crate) const fn table(&self) -> &arrow_tiberius::TableName {
173        &self.table
174    }
175
176    /// Returns the planned schema mappings passed to the writer.
177    #[must_use]
178    pub(crate) fn mappings(&self) -> &[arrow_tiberius::SchemaMapping] {
179        self.planned_schema.mappings()
180    }
181
182    /// Returns the write backend passed to the writer.
183    #[must_use]
184    pub(crate) const fn backend(&self) -> MssqlWriteBackend {
185        self.backend
186    }
187
188    /// Returns the prepared lifecycle action that permits writer initialization.
189    #[must_use]
190    pub(crate) const fn prepared_action(&self) -> MssqlPreparedTargetAction {
191        self.prepared_action
192    }
193
194    /// Returns cleanup state if writer initialization fails.
195    #[must_use]
196    pub(crate) const fn cleanup(&self) -> MssqlTargetCleanupStatus {
197        self.cleanup
198    }
199}
200
201/// Phase of one-output SQL Server write execution.
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
203pub enum MssqlWritePhase {
204    /// Establish the SQL Server connection.
205    Connect,
206    /// Execute target lifecycle preparation before writer construction.
207    PrepareTargetLifecycle,
208    /// Construct the SQL Server bulk writer and validate target metadata.
209    InitializeWriter,
210    /// Poll the selected output batch stream.
211    PollBatchStream,
212    /// Validate an incoming batch schema against the planned schema.
213    ValidateBatchSchema,
214    /// Write an accepted batch into SQL Server.
215    WriteBatch,
216    /// Finalize the SQL Server bulk writer.
217    Finalize,
218    /// Validate target-side write evidence after finalize succeeds.
219    Validation,
220    /// Atomically swap a staged replace table into the final target name.
221    SwapTarget,
222    /// Clean up a Delta Funnel-created target after a later failure.
223    Cleanup,
224}
225
226impl fmt::Display for MssqlWritePhase {
227    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
228        formatter.write_str(match self {
229            Self::Connect => "connect",
230            Self::PrepareTargetLifecycle => "prepare target lifecycle",
231            Self::InitializeWriter => "initialize writer",
232            Self::PollBatchStream => "poll batch stream",
233            Self::ValidateBatchSchema => "validate batch schema",
234            Self::WriteBatch => "write batch",
235            Self::Finalize => "finalize",
236            Self::Validation => "validation",
237            Self::SwapTarget => "swap target",
238            Self::Cleanup => "cleanup",
239        })
240    }
241}
242
243/// SQL Server write backend selection.
244#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
245pub enum MssqlWriteBackend {
246    /// Select the best available backend for the current plan.
247    #[default]
248    Auto,
249    /// Use Tiberius' row-oriented TokenRow bulk-load path.
250    BaselineTokenRow,
251    /// Use direct bulk-row payload encoding through Tiberius' framed sink.
252    DirectFramedBulk,
253    /// Use the raw bulk-row payload path exposed by the Tiberius fork.
254    DirectRawBulk,
255}
256
257impl From<MssqlWriteBackend> for arrow_tiberius::WriteBackend {
258    fn from(backend: MssqlWriteBackend) -> Self {
259        match backend {
260            MssqlWriteBackend::Auto => Self::Auto,
261            MssqlWriteBackend::BaselineTokenRow => Self::BaselineTokenRow,
262            MssqlWriteBackend::DirectFramedBulk => Self::DirectFramedBulk,
263            MssqlWriteBackend::DirectRawBulk => Self::DirectRawBulk,
264        }
265    }
266}
267
268impl From<MssqlWriteBackend> for arrow_tiberius::WriteOptions {
269    fn from(backend: MssqlWriteBackend) -> Self {
270        Self {
271            backend: backend.into(),
272            ..Self::default()
273        }
274    }
275}
276
277/// Returns Delta Funnel's default SQL Server write backend.
278#[must_use]
279pub fn default_mssql_write_backend() -> MssqlWriteBackend {
280    MssqlWriteBackend::DirectRawBulk
281}
282
283/// Builds a write backend from a planned SQL Server output target.
284#[must_use]
285pub fn mssql_write_backend_for_output_plan(
286    _output_plan: &MssqlTargetOutputPlan,
287) -> MssqlWriteBackend {
288    default_mssql_write_backend()
289}
290
291/// Initializes one SQL Server bulk writer after target lifecycle preparation.
292#[allow(dead_code)]
293pub(crate) async fn initialize_mssql_bulk_writer<'client>(
294    client: &'client mut arrow_tiberius::ConnectedMssqlClient,
295    output_plan: &MssqlTargetOutputPlan,
296    prepared_target: &MssqlPreparedTarget,
297    options: MssqlWriteBackend,
298) -> Result<arrow_tiberius::ConnectedBulkWriter<'client>, DeltaFunnelError> {
299    initialize_mssql_bulk_writer_with_factory(
300        output_plan,
301        prepared_target,
302        options,
303        MssqlConnectedBulkWriterFactory::new(client),
304    )
305    .await
306}
307
308/// Initializes one SQL Server bulk writer through an injected factory.
309#[allow(dead_code)]
310pub(crate) async fn initialize_mssql_bulk_writer_with_factory<F>(
311    output_plan: &MssqlTargetOutputPlan,
312    prepared_target: &MssqlPreparedTarget,
313    options: MssqlWriteBackend,
314    factory: F,
315) -> Result<F::Writer, DeltaFunnelError>
316where
317    F: MssqlBulkWriterFactory,
318{
319    let request = MssqlBulkWriterInitializationRequest::from_prepared_target(
320        output_plan,
321        prepared_target,
322        options,
323    )?;
324    let cleanup = request.cleanup();
325    let prepared_action = request.prepared_action();
326
327    factory.initialize(request).await.map_err(|source| {
328        mssql_arrow_tiberius_writer_initialization_error(
329            output_plan,
330            prepared_action,
331            cleanup,
332            &source,
333        )
334    })
335}
336
337/// Validates a runtime Arrow schema against a planned SQL Server output.
338///
339/// Delta Funnel owns the output context and redacted report shape, while the
340/// schema contract comparison is delegated to `arrow-tiberius`.
341pub fn validate_mssql_output_schema(
342    output_plan: &MssqlTargetOutputPlan,
343    schema: &Schema,
344) -> Result<MssqlOutputBatchValidationReport, DeltaFunnelError> {
345    arrow_tiberius::validate_arrow_schema_against_mappings(schema, output_plan.schema_mappings())
346        .map_err(|source| {
347        mssql_batch_schema_validation_error(
348            output_plan,
349            source,
350            write_report_metrics(
351                RowCount::unavailable(),
352                MssqlBatchShapingReport::not_started(ReportReasonCode::NotExecuted),
353                MssqlWriteProgress::zero(),
354                false,
355                MssqlTargetCleanupStatus::NotApplicable,
356            ),
357        )
358    })?;
359
360    Ok(MssqlOutputBatchValidationReport::from_output_plan(
361        output_plan,
362    ))
363}
364
365/// Validates a runtime `RecordBatch` schema against a planned SQL Server output.
366///
367/// This helper validates `batch.schema()` before row writes and does not inspect
368/// row values, connect to SQL Server, or construct a writer.
369pub fn validate_mssql_output_record_batch(
370    output_plan: &MssqlTargetOutputPlan,
371    batch: &RecordBatch,
372) -> Result<MssqlOutputBatchValidationReport, DeltaFunnelError> {
373    arrow_tiberius::validate_record_batch_schema_against_mappings(
374        batch,
375        output_plan.schema_mappings(),
376    )
377    .map_err(|source| {
378        mssql_batch_schema_validation_error(
379            output_plan,
380            source,
381            write_report_metrics(
382                RowCount::unavailable(),
383                MssqlBatchShapingReport::not_started(ReportReasonCode::NotExecuted),
384                MssqlWriteProgress::zero(),
385                false,
386                MssqlTargetCleanupStatus::NotApplicable,
387            ),
388        )
389    })?;
390
391    Ok(MssqlOutputBatchValidationReport::from_output_plan(
392        output_plan,
393    ))
394}
395
396fn mssql_batch_schema_validation_error(
397    output_plan: &MssqlTargetOutputPlan,
398    source: arrow_tiberius::Error,
399    metrics: MssqlWriteReportMetrics,
400) -> DeltaFunnelError {
401    let diagnostics = batch_validation_diagnostics(&source);
402    DeltaFunnelError::MssqlBatchSchemaValidation {
403        context: Box::new(
404            MssqlWriteFailureContext::from_output_plan_with_metrics(
405                output_plan,
406                MssqlWritePhase::ValidateBatchSchema,
407                metrics,
408            )
409            .with_diagnostics(diagnostics),
410        ),
411        source,
412    }
413}
414
415fn batch_validation_diagnostics(source: &arrow_tiberius::Error) -> Vec<MssqlWriteDiagnostic> {
416    match source {
417        arrow_tiberius::Error::ValueConversion { diagnostics } => diagnostics
418            .all()
419            .iter()
420            .map(MssqlWriteDiagnostic::from_arrow_tiberius)
421            .collect(),
422        _ => Vec::new(),
423    }
424}
425
426fn write_report_metrics(
427    output_row_count: RowCount,
428    batch_shaping: MssqlBatchShapingReport,
429    progress: MssqlWriteProgress,
430    partial_write_possible: bool,
431    cleanup: MssqlTargetCleanupStatus,
432) -> MssqlWriteReportMetrics {
433    MssqlWriteReportMetrics::new(
434        output_row_count,
435        batch_shaping,
436        progress.rows_written,
437        progress.batches_written,
438        progress.elapsed_ms,
439        partial_write_possible,
440        cleanup,
441    )
442}
443
444fn write_failure_report_metrics(
445    output_row_count: RowCount,
446    batch_shaping: MssqlBatchShapingReport,
447    progress: MssqlWriteProgress,
448    partial_write_possible: bool,
449    cleanup: MssqlTargetCleanupStatus,
450) -> MssqlWriteReportMetrics {
451    write_report_metrics(
452        output_row_count,
453        batch_shaping,
454        progress,
455        partial_write_possible,
456        cleanup,
457    )
458    .with_target_validation(
459        RowCount::unavailable(),
460        ValidationStatus::skipped(ReportReasonCode::FailureBeforeValidation),
461    )
462}
463
464#[derive(Default)]
465struct MssqlWriteLoopPhaseTimings {
466    poll_batch_stream: Duration,
467    validate_batch_schema: Duration,
468    write_batch: Duration,
469    validation_attempted: bool,
470    write_attempted: bool,
471}
472
473impl MssqlWriteLoopPhaseTimings {
474    fn add_poll_batch_stream(&mut self, elapsed: Duration) {
475        self.poll_batch_stream = self.poll_batch_stream.saturating_add(elapsed);
476    }
477
478    fn add_validate_batch_schema(&mut self, elapsed: Duration) {
479        self.validation_attempted = true;
480        self.validate_batch_schema = self.validate_batch_schema.saturating_add(elapsed);
481    }
482
483    fn add_write_batch(&mut self, elapsed: Duration) {
484        self.write_attempted = true;
485        self.write_batch = self.write_batch.saturating_add(elapsed);
486    }
487
488    fn completed(&self, finalize_elapsed: Duration) -> Vec<PhaseTimingReport> {
489        vec![
490            PhaseTimingReport::completed(POLL_BATCH_STREAM_PHASE, self.poll_batch_stream),
491            self.validate_batch_schema_completed_or_not_started(),
492            self.write_batch_completed_or_not_started(),
493            PhaseTimingReport::completed(FINALIZE_PHASE, finalize_elapsed),
494            PhaseTimingReport::not_started(VALIDATION_PHASE, ReportReasonCode::NotExecuted),
495        ]
496    }
497
498    fn poll_batch_stream_failed(&self) -> Vec<PhaseTimingReport> {
499        vec![
500            PhaseTimingReport::failed(POLL_BATCH_STREAM_PHASE, self.poll_batch_stream),
501            self.validate_batch_schema_completed_or_not_started(),
502            self.write_batch_completed_or_not_started(),
503            PhaseTimingReport::not_started(FINALIZE_PHASE, ReportReasonCode::NotExecuted),
504            PhaseTimingReport::not_started(
505                VALIDATION_PHASE,
506                ReportReasonCode::FailureBeforeValidation,
507            ),
508        ]
509    }
510
511    fn validate_batch_schema_failed(&self) -> Vec<PhaseTimingReport> {
512        vec![
513            PhaseTimingReport::completed(POLL_BATCH_STREAM_PHASE, self.poll_batch_stream),
514            PhaseTimingReport::failed(VALIDATE_BATCH_SCHEMA_PHASE, self.validate_batch_schema),
515            self.write_batch_completed_or_not_started(),
516            PhaseTimingReport::not_started(FINALIZE_PHASE, ReportReasonCode::NotExecuted),
517            PhaseTimingReport::not_started(
518                VALIDATION_PHASE,
519                ReportReasonCode::FailureBeforeValidation,
520            ),
521        ]
522    }
523
524    fn write_batch_failed(&self) -> Vec<PhaseTimingReport> {
525        vec![
526            PhaseTimingReport::completed(POLL_BATCH_STREAM_PHASE, self.poll_batch_stream),
527            self.validate_batch_schema_completed_or_not_started(),
528            PhaseTimingReport::failed(WRITE_BATCH_PHASE, self.write_batch),
529            PhaseTimingReport::not_started(FINALIZE_PHASE, ReportReasonCode::NotExecuted),
530            PhaseTimingReport::not_started(
531                VALIDATION_PHASE,
532                ReportReasonCode::FailureBeforeValidation,
533            ),
534        ]
535    }
536
537    fn finalize_failed(&self, finalize_elapsed: Duration) -> Vec<PhaseTimingReport> {
538        vec![
539            PhaseTimingReport::completed(POLL_BATCH_STREAM_PHASE, self.poll_batch_stream),
540            self.validate_batch_schema_completed_or_not_started(),
541            self.write_batch_completed_or_not_started(),
542            PhaseTimingReport::failed(FINALIZE_PHASE, finalize_elapsed),
543            PhaseTimingReport::not_started(
544                VALIDATION_PHASE,
545                ReportReasonCode::FailureBeforeValidation,
546            ),
547        ]
548    }
549
550    fn validate_batch_schema_completed_or_not_started(&self) -> PhaseTimingReport {
551        if self.validation_attempted {
552            PhaseTimingReport::completed(VALIDATE_BATCH_SCHEMA_PHASE, self.validate_batch_schema)
553        } else {
554            PhaseTimingReport::not_started(
555                VALIDATE_BATCH_SCHEMA_PHASE,
556                ReportReasonCode::NotExecuted,
557            )
558        }
559    }
560
561    fn write_batch_completed_or_not_started(&self) -> PhaseTimingReport {
562        if self.write_attempted {
563            PhaseTimingReport::completed(WRITE_BATCH_PHASE, self.write_batch)
564        } else {
565            PhaseTimingReport::not_started(WRITE_BATCH_PHASE, ReportReasonCode::NotExecuted)
566        }
567    }
568}
569
570/// Writes one planned SQL Server output through an injected bulk-load writer.
571#[allow(dead_code)]
572pub(crate) async fn write_mssql_batches_with_writer<W, S>(
573    output_plan: &MssqlTargetOutputPlan,
574    batches: S,
575    mut writer: W,
576    _options: MssqlWriteBackend,
577) -> Result<MssqlWriteReport, DeltaFunnelError>
578where
579    W: MssqlBulkLoadWriter,
580    S: Stream<Item = Result<RecordBatch, DeltaFunnelError>>,
581{
582    let mut rows_written = 0_u64;
583    let mut batches_written = 0_u64;
584    let mut input_rows = 0_u64;
585    let mut input_batches = 0_u64;
586    let mut shaped_rows = 0_u64;
587    let mut shaped_batches = 0_u64;
588    let cleanup = MssqlTargetCleanupStatus::NotApplicable;
589    let started_at = Instant::now();
590    let mut phase_timings = MssqlWriteLoopPhaseTimings::default();
591    pin_mut!(batches);
592    observability::datafusion_batch_stream_started(
593        output_plan.output_name(),
594        output_plan.target_table(),
595        output_plan.load_mode(),
596    );
597
598    loop {
599        let poll_started_at = Instant::now();
600        let Some(batch) = batches.next().await else {
601            phase_timings.add_poll_batch_stream(poll_started_at.elapsed());
602            break;
603        };
604        phase_timings.add_poll_batch_stream(poll_started_at.elapsed());
605
606        let batch = batch.map_err(|source| {
607            let elapsed_ms = elapsed_ms_since(started_at);
608            let batch_shaping = trace_datafusion_batch_stream_finished(
609                output_plan,
610                MssqlBatchShapingReport::failed(
611                    input_batches,
612                    input_rows,
613                    shaped_batches,
614                    shaped_rows,
615                ),
616            );
617            mssql_write_phase_error(
618                output_plan,
619                MssqlWritePhase::PollBatchStream,
620                write_failure_report_metrics(
621                    RowCount::partial(input_rows),
622                    batch_shaping,
623                    MssqlWriteProgress::new(rows_written, batches_written, elapsed_ms),
624                    partial_write_possible(output_plan, rows_written, batches_written),
625                    cleanup,
626                )
627                .with_phase_timings(phase_timings.poll_batch_stream_failed()),
628                source.to_string(),
629            )
630        })?;
631
632        let row_count = batch_row_count(batch.num_rows());
633        input_rows = input_rows.saturating_add(row_count);
634        input_batches = input_batches.saturating_add(1);
635
636        let validation_started_at = Instant::now();
637        let validation_result = arrow_tiberius::validate_record_batch_schema_against_mappings(
638            &batch,
639            output_plan.schema_mappings(),
640        );
641        phase_timings.add_validate_batch_schema(validation_started_at.elapsed());
642        validation_result.map_err(|source| {
643            let elapsed_ms = elapsed_ms_since(started_at);
644            let batch_shaping = trace_datafusion_batch_stream_finished(
645                output_plan,
646                MssqlBatchShapingReport::failed(
647                    input_batches,
648                    input_rows,
649                    shaped_batches,
650                    shaped_rows,
651                ),
652            );
653            mssql_batch_schema_validation_error(
654                output_plan,
655                source,
656                write_failure_report_metrics(
657                    RowCount::partial(input_rows),
658                    batch_shaping,
659                    MssqlWriteProgress::new(rows_written, batches_written, elapsed_ms),
660                    partial_write_possible(output_plan, rows_written, batches_written),
661                    cleanup,
662                )
663                .with_phase_timings(phase_timings.validate_batch_schema_failed()),
664            )
665        })?;
666
667        let write_batch_started_at = Instant::now();
668        let write_batch_result = MssqlBulkLoadWriter::write_batch(&mut writer, &batch).await;
669        phase_timings.add_write_batch(write_batch_started_at.elapsed());
670        write_batch_result.map_err(|source| {
671            let elapsed_ms = elapsed_ms_since(started_at);
672            let batch_shaping = trace_datafusion_batch_stream_finished(
673                output_plan,
674                MssqlBatchShapingReport::failed(
675                    input_batches,
676                    input_rows,
677                    shaped_batches,
678                    shaped_rows,
679                ),
680            );
681            mssql_arrow_tiberius_write_phase_error(
682                output_plan,
683                MssqlWritePhase::WriteBatch,
684                write_failure_report_metrics(
685                    RowCount::partial(input_rows),
686                    batch_shaping,
687                    MssqlWriteProgress::new(rows_written, batches_written, elapsed_ms),
688                    partial_write_possible(output_plan, rows_written, batches_written),
689                    cleanup,
690                )
691                .with_phase_timings(phase_timings.write_batch_failed()),
692                &source,
693            )
694        })?;
695
696        rows_written = rows_written.saturating_add(row_count);
697        batches_written = batches_written.saturating_add(1);
698        shaped_rows = shaped_rows.saturating_add(row_count);
699        shaped_batches = shaped_batches.saturating_add(1);
700    }
701
702    let finalize_started_at = Instant::now();
703    let finish_result = MssqlBulkLoadWriter::finish(writer).await;
704    let finalize_elapsed = finalize_started_at.elapsed();
705    finish_result.map_err(|source| {
706        let elapsed_ms = elapsed_ms_since(started_at);
707        let batch_shaping = trace_datafusion_batch_stream_finished(
708            output_plan,
709            MssqlBatchShapingReport::completed(
710                input_batches,
711                input_rows,
712                shaped_batches,
713                shaped_rows,
714            ),
715        );
716        mssql_arrow_tiberius_write_phase_error(
717            output_plan,
718            MssqlWritePhase::Finalize,
719            write_failure_report_metrics(
720                RowCount::exact(input_rows),
721                batch_shaping,
722                MssqlWriteProgress::new(rows_written, batches_written, elapsed_ms),
723                partial_write_possible(output_plan, rows_written, batches_written),
724                cleanup,
725            )
726            .with_phase_timings(phase_timings.finalize_failed(finalize_elapsed)),
727            &source,
728        )
729    })?;
730
731    Ok(MssqlWriteReport::from_output_plan_with_metrics(
732        output_plan,
733        write_report_metrics(
734            RowCount::exact(input_rows),
735            trace_datafusion_batch_stream_finished(
736                output_plan,
737                MssqlBatchShapingReport::completed(
738                    input_batches,
739                    input_rows,
740                    shaped_batches,
741                    shaped_rows,
742                ),
743            ),
744            MssqlWriteProgress::new(rows_written, batches_written, elapsed_ms_since(started_at)),
745            false,
746            cleanup,
747        )
748        .with_phase_timings(phase_timings.completed(finalize_elapsed)),
749    ))
750}
751
752fn trace_datafusion_batch_stream_finished(
753    output_plan: &MssqlTargetOutputPlan,
754    report: MssqlBatchShapingReport,
755) -> MssqlBatchShapingReport {
756    observability::datafusion_batch_stream_finished(
757        output_plan.output_name(),
758        output_plan.target_table(),
759        output_plan.load_mode(),
760        report,
761    );
762    report
763}
764
765fn mssql_write_phase_error(
766    output_plan: &MssqlTargetOutputPlan,
767    phase: MssqlWritePhase,
768    metrics: MssqlWriteReportMetrics,
769    message: String,
770) -> DeltaFunnelError {
771    DeltaFunnelError::MssqlWritePhase {
772        context: Box::new(MssqlWriteFailureContext::from_output_plan_with_metrics(
773            output_plan,
774            phase,
775            metrics,
776        )),
777        message,
778    }
779}
780
781fn mssql_arrow_tiberius_write_phase_error(
782    output_plan: &MssqlTargetOutputPlan,
783    phase: MssqlWritePhase,
784    metrics: MssqlWriteReportMetrics,
785    source: &arrow_tiberius::Error,
786) -> DeltaFunnelError {
787    DeltaFunnelError::MssqlWritePhase {
788        context: Box::new(
789            MssqlWriteFailureContext::from_output_plan_with_metrics(output_plan, phase, metrics)
790                .with_diagnostics(arrow_tiberius_write_diagnostics(source)),
791        ),
792        message: arrow_tiberius_safe_error_message(source),
793    }
794}
795
796fn mssql_writer_initialization_error(
797    output_plan: &MssqlTargetOutputPlan,
798    prepared_action: MssqlPreparedTargetAction,
799    cleanup: MssqlTargetCleanupStatus,
800    message: String,
801) -> DeltaFunnelError {
802    mssql_write_phase_error(
803        output_plan,
804        MssqlWritePhase::InitializeWriter,
805        write_report_metrics(
806            RowCount::unavailable(),
807            MssqlBatchShapingReport::not_started(ReportReasonCode::NotExecuted),
808            MssqlWriteProgress::zero(),
809            false,
810            cleanup,
811        ),
812        format!("prepared target action {prepared_action:?}: {message}"),
813    )
814}
815
816fn mssql_arrow_tiberius_writer_initialization_error(
817    output_plan: &MssqlTargetOutputPlan,
818    prepared_action: MssqlPreparedTargetAction,
819    cleanup: MssqlTargetCleanupStatus,
820    source: &arrow_tiberius::Error,
821) -> DeltaFunnelError {
822    DeltaFunnelError::MssqlWritePhase {
823        context: Box::new(
824            MssqlWriteFailureContext::from_output_plan_with_metrics(
825                output_plan,
826                MssqlWritePhase::InitializeWriter,
827                write_report_metrics(
828                    RowCount::unavailable(),
829                    MssqlBatchShapingReport::not_started(ReportReasonCode::NotExecuted),
830                    MssqlWriteProgress::zero(),
831                    false,
832                    cleanup,
833                ),
834            )
835            .with_diagnostics(arrow_tiberius_write_diagnostics(source)),
836        ),
837        message: format!(
838            "prepared target action {prepared_action:?}: {}",
839            arrow_tiberius_safe_error_message(source)
840        ),
841    }
842}
843
844fn arrow_tiberius_safe_error_message(source: &arrow_tiberius::Error) -> String {
845    let info = source.safe_error_info();
846    let phase = info.phase().map(|phase| phase.as_str());
847
848    match phase {
849        Some(phase) => format!(
850            "arrow-tiberius {phase} failed: {} ({})",
851            info.summary(),
852            info.kind()
853        ),
854        None => format!("arrow-tiberius error: {} ({})", info.summary(), info.kind()),
855    }
856}
857
858fn arrow_tiberius_write_diagnostics(source: &arrow_tiberius::Error) -> Vec<MssqlWriteDiagnostic> {
859    source
860        .safe_error_info()
861        .diagnostics()
862        .map(|diagnostics| {
863            diagnostics
864                .all()
865                .iter()
866                .map(MssqlWriteDiagnostic::from_arrow_tiberius)
867                .collect()
868        })
869        .unwrap_or_default()
870}
871
872fn ensure_prepared_target_matches_output_plan(
873    output_plan: &MssqlTargetOutputPlan,
874    prepared_target: &MssqlPreparedTarget,
875) -> Result<(), DeltaFunnelError> {
876    let report = prepared_target.report();
877    let matches_plan = report.output_name() == output_plan.output_name()
878        && report.target_table() == output_plan.target_table()
879        && report.load_mode() == output_plan.load_mode()
880        && report.connection_source() == output_plan.connection_source()
881        && report.connection() == output_plan.connection()
882        && report.expected_target_state() == output_plan.lifecycle_plan().expected_target_state();
883
884    if !matches_plan {
885        return Err(mssql_writer_initialization_error(
886            output_plan,
887            report.action(),
888            report.cleanup(),
889            "prepared target does not match output plan".to_owned(),
890        ));
891    }
892
893    if !output_plan
894        .lifecycle_plan()
895        .execution_guardrails()
896        .contains(&MssqlLifecycleExecutionGuardrail::BulkWriterConstruction)
897    {
898        return Err(mssql_writer_initialization_error(
899            output_plan,
900            report.action(),
901            report.cleanup(),
902            "target lifecycle plan does not allow BulkWriterConstruction".to_owned(),
903        ));
904    }
905
906    Ok(())
907}
908
909fn partial_write_possible(
910    output_plan: &MssqlTargetOutputPlan,
911    rows_written: u64,
912    batches_written: u64,
913) -> bool {
914    output_plan.load_mode() == LoadMode::AppendExisting && (rows_written > 0 || batches_written > 0)
915}
916
917#[derive(Clone, Copy)]
918struct MssqlWriteProgress {
919    rows_written: u64,
920    batches_written: u64,
921    elapsed_ms: u64,
922}
923
924impl MssqlWriteProgress {
925    const fn new(rows_written: u64, batches_written: u64, elapsed_ms: u64) -> Self {
926        Self {
927            rows_written,
928            batches_written,
929            elapsed_ms,
930        }
931    }
932
933    const fn zero() -> Self {
934        Self::new(0, 0, 0)
935    }
936}
937
938fn elapsed_ms_since(started_at: Instant) -> u64 {
939    u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX)
940}
941
942fn batch_row_count(row_count: usize) -> u64 {
943    u64::try_from(row_count).unwrap_or(u64::MAX)
944}
945
946#[cfg(test)]
947mod tests {
948    use std::{
949        sync::{Arc, Mutex, MutexGuard},
950        time::Duration,
951    };
952
953    use arrow_schema::{DataType, Field, Schema};
954    use arrow_tiberius::{
955        Diagnostic, DiagnosticCode, DiagnosticSet, DiagnosticSeverity, PlanOptions, StringPolicy,
956        WritePhase,
957    };
958    use datafusion::arrow::{
959        array::{Int64Array, StringArray, StringViewArray},
960        record_batch::RecordBatch,
961    };
962    use futures_util::stream;
963
964    use super::*;
965    use crate::MssqlWriteBackend as WriteBackend;
966    use crate::{
967        DeltaFunnelError, MssqlBatchShapingReport, MssqlConnectionConfig, MssqlConnectionSource,
968        MssqlOutputFieldReport, MssqlTargetConfig, MssqlTargetTable, MssqlWriteStats, PhaseStatus,
969        PhaseTimingReport, ReportReasonCode, ValidationStatus, plan_mssql_target_for_output,
970    };
971
972    fn secret_connection() -> Result<MssqlConnectionConfig, DeltaFunnelError> {
973        Ok(MssqlConnectionConfig::new(
974            "server=tcp:sql.example.com;database=warehouse;user=admin;password=secret-token",
975        )?
976        .with_display_label("warehouse-primary"))
977    }
978
979    fn orders_schema() -> Schema {
980        Schema::new(vec![
981            Field::new("order_id", DataType::Int64, false),
982            Field::new("status", DataType::Utf8, true),
983        ])
984    }
985
986    #[derive(Debug, Default)]
987    struct FakeBulkLoadWriter {
988        accepted_rows: u64,
989        accepted_batches: u64,
990        batch_rows: Vec<usize>,
991        log: Option<Arc<Mutex<FakeBulkLoadWriterLog>>>,
992        fail_on_write_batch: Option<u64>,
993        fail_on_finish: bool,
994        write_delay: Option<Duration>,
995    }
996
997    #[derive(Debug, Default, PartialEq, Eq)]
998    struct FakeBulkLoadWriterLog {
999        batch_rows: Vec<usize>,
1000        finish_count: u64,
1001    }
1002
1003    #[derive(Clone, Default)]
1004    struct RecordingBulkWriterFactory {
1005        request: Arc<Mutex<Option<MssqlBulkWriterInitializationRequest>>>,
1006        error: Option<String>,
1007    }
1008
1009    impl FakeBulkLoadWriter {
1010        fn with_log(log: Arc<Mutex<FakeBulkLoadWriterLog>>) -> Self {
1011            Self {
1012                log: Some(log),
1013                ..Self::default()
1014            }
1015        }
1016
1017        fn fail_on_write_batch(mut self, write_batch_call: u64) -> Self {
1018            self.fail_on_write_batch = Some(write_batch_call);
1019            self
1020        }
1021
1022        fn delay_writes_by(mut self, delay: Duration) -> Self {
1023            self.write_delay = Some(delay);
1024            self
1025        }
1026
1027        fn fail_on_finish(mut self) -> Self {
1028            self.fail_on_finish = true;
1029            self
1030        }
1031    }
1032
1033    impl RecordingBulkWriterFactory {
1034        fn with_request_log(
1035            request: Arc<Mutex<Option<MssqlBulkWriterInitializationRequest>>>,
1036        ) -> Self {
1037            Self {
1038                request,
1039                error: None,
1040            }
1041        }
1042
1043        fn fail_with(mut self, error: impl Into<String>) -> Self {
1044            self.error = Some(error.into());
1045            self
1046        }
1047    }
1048
1049    #[async_trait]
1050    impl MssqlBulkLoadWriter for FakeBulkLoadWriter {
1051        async fn write_batch(
1052            &mut self,
1053            batch: &RecordBatch,
1054        ) -> Result<arrow_tiberius::WriteStats, arrow_tiberius::Error> {
1055            let row_count = batch.num_rows();
1056            self.batch_rows.push(row_count);
1057            if let Some(log) = &self.log {
1058                let Ok(mut log) = log.lock() else {
1059                    return Err(arrow_tiberius::Error::BackendUnavailable {
1060                        backend: arrow_tiberius::WriteBackend::DirectRawBulk,
1061                        reason: "fake writer log mutex was poisoned".to_owned(),
1062                    });
1063                };
1064                log.batch_rows.push(row_count);
1065            }
1066            if let Some(delay) = self.write_delay {
1067                tokio::time::sleep(delay).await;
1068            }
1069            let write_batch_call = self.accepted_batches.saturating_add(1);
1070            if self.fail_on_write_batch == Some(write_batch_call) {
1071                return Err(arrow_tiberius::Error::BackendUnavailable {
1072                    backend: arrow_tiberius::WriteBackend::DirectRawBulk,
1073                    reason: format!("fake writer failed on batch {write_batch_call}"),
1074                });
1075            }
1076            self.accepted_rows = self.accepted_rows.saturating_add(row_count as u64);
1077            self.accepted_batches = self.accepted_batches.saturating_add(1);
1078
1079            Ok(arrow_tiberius::WriteStats {
1080                rows_written: self.accepted_rows,
1081                batches_written: self.accepted_batches,
1082            })
1083        }
1084
1085        async fn finish(self) -> Result<arrow_tiberius::WriteStats, arrow_tiberius::Error> {
1086            if let Some(log) = &self.log {
1087                let Ok(mut log) = log.lock() else {
1088                    return Err(arrow_tiberius::Error::BackendUnavailable {
1089                        backend: arrow_tiberius::WriteBackend::DirectRawBulk,
1090                        reason: "fake writer log mutex was poisoned".to_owned(),
1091                    });
1092                };
1093                log.finish_count = log.finish_count.saturating_add(1);
1094            }
1095            if self.fail_on_finish {
1096                return Err(arrow_tiberius::Error::BackendUnavailable {
1097                    backend: arrow_tiberius::WriteBackend::DirectRawBulk,
1098                    reason: "fake writer failed on finish".to_owned(),
1099                });
1100            }
1101
1102            Ok(arrow_tiberius::WriteStats {
1103                rows_written: self.accepted_rows,
1104                batches_written: self.accepted_batches,
1105            })
1106        }
1107    }
1108
1109    #[async_trait]
1110    impl MssqlBulkWriterFactory for RecordingBulkWriterFactory {
1111        type Writer = FakeBulkLoadWriter;
1112
1113        async fn initialize(
1114            self,
1115            request: MssqlBulkWriterInitializationRequest,
1116        ) -> Result<Self::Writer, arrow_tiberius::Error> {
1117            let Ok(mut request_log) = self.request.lock() else {
1118                return Err(arrow_tiberius::Error::BackendUnavailable {
1119                    backend: arrow_tiberius::WriteBackend::DirectRawBulk,
1120                    reason: "fake initializer request log mutex was poisoned".to_owned(),
1121                });
1122            };
1123            *request_log = Some(request);
1124
1125            if let Some(reason) = self.error {
1126                return Err(arrow_tiberius::Error::BackendUnavailable {
1127                    backend: arrow_tiberius::WriteBackend::DirectRawBulk,
1128                    reason,
1129                });
1130            }
1131
1132            Ok(FakeBulkLoadWriter::default())
1133        }
1134    }
1135
1136    fn orders_batch(
1137        order_ids: Vec<i64>,
1138        statuses: Vec<Option<&str>>,
1139    ) -> Result<RecordBatch, DeltaFunnelError> {
1140        RecordBatch::try_new(
1141            Arc::new(orders_schema()),
1142            vec![
1143                Arc::new(Int64Array::from(order_ids)),
1144                Arc::new(StringArray::from(statuses)),
1145            ],
1146        )
1147        .map_err(|error| DeltaFunnelError::Config {
1148            message: error.to_string(),
1149        })
1150    }
1151
1152    fn orders_batch_with_int32_order_id() -> Result<RecordBatch, DeltaFunnelError> {
1153        let schema = Schema::new(vec![
1154            Field::new("order_id", DataType::Int32, false),
1155            Field::new("status", DataType::Utf8, true),
1156        ]);
1157
1158        RecordBatch::try_new(
1159            Arc::new(schema),
1160            vec![
1161                Arc::new(datafusion::arrow::array::Int32Array::from(vec![1_i32])),
1162                Arc::new(StringArray::from(vec![Some("open")])),
1163            ],
1164        )
1165        .map_err(|error| DeltaFunnelError::Config {
1166            message: error.to_string(),
1167        })
1168    }
1169
1170    fn orders_batch_with_utf8_view_status() -> Result<RecordBatch, DeltaFunnelError> {
1171        let schema = Schema::new(vec![
1172            Field::new("order_id", DataType::Int64, false),
1173            Field::new("status", DataType::Utf8View, true),
1174        ]);
1175
1176        RecordBatch::try_new(
1177            Arc::new(schema),
1178            vec![
1179                Arc::new(Int64Array::from(vec![1_i64, 2])),
1180                Arc::new(StringViewArray::from(vec![Some("open"), None])),
1181            ],
1182        )
1183        .map_err(|error| DeltaFunnelError::Config {
1184            message: error.to_string(),
1185        })
1186    }
1187
1188    fn lock_fake_writer_log(
1189        log: &Arc<Mutex<FakeBulkLoadWriterLog>>,
1190    ) -> Result<MutexGuard<'_, FakeBulkLoadWriterLog>, DeltaFunnelError> {
1191        log.lock().map_err(|_| DeltaFunnelError::Config {
1192            message: "fake writer log mutex was poisoned".to_owned(),
1193        })
1194    }
1195
1196    fn take_initialization_request(
1197        request: &Arc<Mutex<Option<MssqlBulkWriterInitializationRequest>>>,
1198    ) -> Result<MssqlBulkWriterInitializationRequest, DeltaFunnelError> {
1199        let mut request = request.lock().map_err(|_| DeltaFunnelError::Config {
1200            message: "fake initializer request log mutex was poisoned".to_owned(),
1201        })?;
1202
1203        request.take().ok_or_else(|| DeltaFunnelError::Config {
1204            message: "expected fake initializer request".to_owned(),
1205        })
1206    }
1207
1208    fn output_plan_for_orders_schema() -> Result<MssqlTargetOutputPlan, DeltaFunnelError> {
1209        output_plan_for_orders_schema_with_load_mode(LoadMode::AppendExisting)
1210    }
1211
1212    fn output_plan_for_orders_schema_with_load_mode(
1213        load_mode: LoadMode,
1214    ) -> Result<MssqlTargetOutputPlan, DeltaFunnelError> {
1215        let connection = secret_connection()?;
1216        let target_config = MssqlTargetConfig::new(MssqlTargetTable::new("dbo", "orders")?)
1217            .with_load_mode(load_mode);
1218
1219        plan_mssql_target_for_output(
1220            orders_schema(),
1221            "orders_output",
1222            &target_config,
1223            Some(&connection),
1224            PlanOptions::default(),
1225        )
1226    }
1227
1228    fn assert_batch_schema_validation_error(
1229        error: DeltaFunnelError,
1230        expected_field: Option<(usize, &str)>,
1231    ) -> Result<Box<MssqlWriteFailureContext>, DeltaFunnelError> {
1232        let DeltaFunnelError::MssqlBatchSchemaValidation { context, source } = error else {
1233            return Err(DeltaFunnelError::Config {
1234                message: "expected MSSQL batch schema validation error".to_owned(),
1235            });
1236        };
1237
1238        assert_eq!(context.phase(), MssqlWritePhase::ValidateBatchSchema);
1239        assert_eq!(context.output_name(), "orders_output");
1240        assert_eq!(context.target_table().schema(), Some("dbo"));
1241        assert_eq!(context.target_table().table(), "orders");
1242        assert_eq!(context.load_mode(), LoadMode::AppendExisting);
1243        assert!(!context.partial_write_possible());
1244
1245        let arrow_tiberius::Error::ValueConversion { diagnostics } = source else {
1246            return Err(DeltaFunnelError::Config {
1247                message: "expected arrow-tiberius value conversion error".to_owned(),
1248            });
1249        };
1250        assert_eq!(diagnostics.len(), 1);
1251        let diagnostic = &diagnostics.all()[0];
1252        assert_eq!(diagnostic.code(), DiagnosticCode::SchemaMismatch);
1253        assert_eq!(
1254            diagnostic
1255                .field()
1256                .map(|field| (field.index(), field.name())),
1257            expected_field
1258        );
1259        assert_eq!(context.diagnostics().len(), 1);
1260        let context_diagnostic = &context.diagnostics()[0];
1261        assert_eq!(context_diagnostic.severity(), DiagnosticSeverity::Error);
1262        assert_eq!(context_diagnostic.code(), DiagnosticCode::SchemaMismatch);
1263        assert_eq!(
1264            context_diagnostic
1265                .field()
1266                .map(|field| (field.index(), field.name())),
1267            expected_field.map(|(index, name)| (u64::try_from(index).unwrap_or(u64::MAX), name))
1268        );
1269        let value = context.to_json_value();
1270        assert_eq!(value["diagnostics"][0]["severity"], "error");
1271        assert_eq!(value["diagnostics"][0]["code"], "schema_mismatch");
1272        assert_eq!(value["cleanup_error"], serde_json::Value::Null);
1273        Ok(context)
1274    }
1275
1276    fn assert_write_phase_error(
1277        error: DeltaFunnelError,
1278        expected_phase: MssqlWritePhase,
1279        expected_rows_written: u64,
1280        expected_batches_written: u64,
1281        expected_partial_write_possible: bool,
1282    ) -> Result<Box<MssqlWriteFailureContext>, DeltaFunnelError> {
1283        let DeltaFunnelError::MssqlWritePhase { context, .. } = error else {
1284            return Err(DeltaFunnelError::Config {
1285                message: "expected MSSQL write phase error".to_owned(),
1286            });
1287        };
1288
1289        assert_eq!(context.phase(), expected_phase);
1290        assert_eq!(context.output_name(), "orders_output");
1291        assert_eq!(context.target_table().schema(), Some("dbo"));
1292        assert_eq!(context.target_table().table(), "orders");
1293        assert_eq!(context.load_mode(), LoadMode::AppendExisting);
1294        assert_eq!(context.stats().rows_written(), expected_rows_written);
1295        assert_eq!(context.stats().batches_written(), expected_batches_written);
1296        assert_eq!(
1297            context.partial_write_possible(),
1298            expected_partial_write_possible
1299        );
1300        assert_eq!(context.cleanup(), MssqlTargetCleanupStatus::NotApplicable);
1301        Ok(context)
1302    }
1303
1304    fn assert_output_schema(fields: &[MssqlOutputFieldReport]) {
1305        assert_eq!(fields.len(), 2);
1306        assert_eq!(fields[0].index(), 0);
1307        assert_eq!(fields[0].name(), "order_id");
1308        assert_eq!(fields[0].arrow_type(), "Int64");
1309        assert!(!fields[0].nullable());
1310        assert_eq!(fields[1].index(), 1);
1311        assert_eq!(fields[1].name(), "status");
1312        assert_eq!(fields[1].arrow_type(), "Utf8");
1313        assert!(fields[1].nullable());
1314    }
1315
1316    fn assert_batch_shaping(
1317        report: MssqlBatchShapingReport,
1318        expected_status: PhaseStatus,
1319        expected_input_batches: u64,
1320        expected_input_rows: u64,
1321        expected_output_batches: u64,
1322        expected_output_rows: u64,
1323    ) {
1324        assert_eq!(report.status(), expected_status);
1325        assert_eq!(report.input_batches(), expected_input_batches);
1326        assert_eq!(report.input_rows(), expected_input_rows);
1327        assert_eq!(report.output_batches(), expected_output_batches);
1328        assert_eq!(report.output_rows(), expected_output_rows);
1329    }
1330
1331    fn assert_initialize_writer_error(
1332        error: DeltaFunnelError,
1333        expected_message: &str,
1334        expected_cleanup: MssqlTargetCleanupStatus,
1335    ) -> Result<String, DeltaFunnelError> {
1336        let display = error.to_string();
1337        assert!(display.contains("orders_output"));
1338        assert!(display.contains("initialize writer"));
1339        assert!(display.contains(expected_message));
1340        assert!(!display.contains("secret-token"));
1341        assert!(!display.contains("password"));
1342        assert!(!display.contains("server=tcp"));
1343        let DeltaFunnelError::MssqlWritePhase { context, message } = error else {
1344            return Err(DeltaFunnelError::Config {
1345                message: "expected MSSQL write phase error".to_owned(),
1346            });
1347        };
1348
1349        assert_eq!(context.phase(), MssqlWritePhase::InitializeWriter);
1350        assert_eq!(context.output_name(), "orders_output");
1351        assert_eq!(context.stats().rows_written(), 0);
1352        assert_eq!(context.stats().batches_written(), 0);
1353        assert!(!context.partial_write_possible());
1354        assert_eq!(context.cleanup(), expected_cleanup);
1355        Ok(message)
1356    }
1357
1358    #[test]
1359    fn write_stats_preserve_output_counts_and_elapsed_time() {
1360        let stats = MssqlWriteStats::new("orders", 42, 3, 125);
1361
1362        assert_eq!(stats.output_name(), "orders");
1363        assert_eq!(stats.rows_written(), 42);
1364        assert_eq!(stats.batches_written(), 3);
1365        assert_eq!(stats.elapsed_ms(), 125);
1366    }
1367
1368    #[test]
1369    fn connected_bulk_writer_adapts_to_bulk_load_writer_trait() {
1370        fn assert_bulk_load_writer<W: MssqlBulkLoadWriter>() {}
1371
1372        assert_bulk_load_writer::<arrow_tiberius::ConnectedBulkWriter<'static>>();
1373    }
1374
1375    #[tokio::test]
1376    async fn bulk_writer_initialization_passes_prepared_identity_mappings_and_options()
1377    -> Result<(), DeltaFunnelError> {
1378        let output_plan = output_plan_for_orders_schema()?;
1379        let prepared_target = MssqlPreparedTarget::from_output_plan(
1380            &output_plan,
1381            MssqlPreparedTargetAction::VerifiedExisting,
1382        )?;
1383        let backend = WriteBackend::BaselineTokenRow;
1384        let request_log = Arc::new(Mutex::new(None));
1385        let factory = RecordingBulkWriterFactory::with_request_log(Arc::clone(&request_log));
1386
1387        let writer = initialize_mssql_bulk_writer_with_factory(
1388            &output_plan,
1389            &prepared_target,
1390            backend,
1391            factory,
1392        )
1393        .await?;
1394        let final_stats = MssqlBulkLoadWriter::finish(writer)
1395            .await
1396            .map_err(|source| DeltaFunnelError::MssqlWrite { source })?;
1397        let request = take_initialization_request(&request_log)?;
1398
1399        assert_eq!(final_stats.rows_written, 0);
1400        assert_eq!(final_stats.batches_written, 0);
1401        assert_eq!(request.output_name(), "orders_output");
1402        assert_eq!(request.table().quoted_sql(), "[dbo].[orders]");
1403        assert_eq!(request.mappings(), output_plan.schema_mappings());
1404        assert_eq!(request.backend(), backend);
1405        assert_eq!(
1406            request.prepared_action(),
1407            MssqlPreparedTargetAction::VerifiedExisting
1408        );
1409        assert_eq!(request.cleanup(), MssqlTargetCleanupStatus::NotApplicable);
1410        Ok(())
1411    }
1412
1413    #[tokio::test]
1414    async fn bulk_writer_initialization_errors_map_to_initialize_writer_phase()
1415    -> Result<(), DeltaFunnelError> {
1416        let output_plan = output_plan_for_orders_schema_with_load_mode(LoadMode::CreateAndLoad)?;
1417        let prepared_target = MssqlPreparedTarget::from_output_plan(
1418            &output_plan,
1419            MssqlPreparedTargetAction::CreatedTable,
1420        )?;
1421        let request_log = Arc::new(Mutex::new(None));
1422        let factory = RecordingBulkWriterFactory::with_request_log(Arc::clone(&request_log))
1423            .fail_with("target metadata failed\nfor test");
1424
1425        let error = initialize_mssql_bulk_writer_with_factory(
1426            &output_plan,
1427            &prepared_target,
1428            default_mssql_write_backend(),
1429            factory,
1430        )
1431        .await
1432        .err()
1433        .ok_or_else(|| DeltaFunnelError::Config {
1434            message: "expected bulk writer initialization error".to_owned(),
1435        })?;
1436        let message = assert_initialize_writer_error(
1437            error,
1438            "write backend unavailable",
1439            MssqlTargetCleanupStatus::NotAttempted,
1440        )?;
1441        let request = take_initialization_request(&request_log)?;
1442
1443        assert!(message.contains("CreatedTable"));
1444        assert!(!message.contains("target metadata failed"));
1445        assert_eq!(request.cleanup(), MssqlTargetCleanupStatus::NotAttempted);
1446        assert_eq!(
1447            request.prepared_action(),
1448            MssqlPreparedTargetAction::CreatedTable
1449        );
1450        Ok(())
1451    }
1452
1453    #[test]
1454    fn arrow_tiberius_writer_errors_use_safe_message_and_structured_diagnostics()
1455    -> Result<(), DeltaFunnelError> {
1456        let output_plan = output_plan_for_orders_schema()?;
1457        let source = arrow_tiberius::Error::DirectEncoding {
1458            diagnostics: DiagnosticSet::from(vec![
1459                Diagnostic::error(
1460                    DiagnosticCode::DirectEncodingUnsupportedBatch,
1461                    "direct encoder rejected test batch\nwith detail",
1462                )
1463                .with_field(arrow_tiberius::FieldRef::new(1, "status"))
1464                .with_row(7),
1465            ]),
1466        }
1467        .with_write_phase(WritePhase::DirectEncoding);
1468
1469        let error = mssql_arrow_tiberius_write_phase_error(
1470            &output_plan,
1471            MssqlWritePhase::Finalize,
1472            write_failure_report_metrics(
1473                RowCount::partial(2),
1474                MssqlBatchShapingReport::completed(1, 2, 1, 2),
1475                MssqlWriteProgress::new(2, 1, 25),
1476                true,
1477                MssqlTargetCleanupStatus::NotApplicable,
1478            ),
1479            &source,
1480        );
1481
1482        let DeltaFunnelError::MssqlWritePhase { context, message } = error else {
1483            return Err(DeltaFunnelError::Config {
1484                message: "expected MSSQL write phase error".to_owned(),
1485            });
1486        };
1487
1488        assert_eq!(
1489            message,
1490            "arrow-tiberius direct_encoding failed: direct encoding failed with diagnostics (DirectEncoding)"
1491        );
1492        assert_eq!(context.phase(), MssqlWritePhase::Finalize);
1493        assert_eq!(context.diagnostics().len(), 1);
1494        let diagnostic = &context.diagnostics()[0];
1495        assert_eq!(
1496            diagnostic.code(),
1497            DiagnosticCode::DirectEncodingUnsupportedBatch
1498        );
1499        assert_eq!(
1500            diagnostic.message(),
1501            r"direct encoder rejected test batch\nwith detail"
1502        );
1503        assert_eq!(
1504            diagnostic
1505                .field()
1506                .map(|field| (field.index(), field.name())),
1507            Some((1, "status"))
1508        );
1509        assert_eq!(diagnostic.row(), Some(7));
1510        Ok(())
1511    }
1512
1513    #[tokio::test]
1514    async fn bulk_writer_initialization_rejects_mismatched_prepared_target_before_factory()
1515    -> Result<(), DeltaFunnelError> {
1516        let output_plan = output_plan_for_orders_schema()?;
1517        let connection = secret_connection()?;
1518        let other_target_config =
1519            MssqlTargetConfig::new(MssqlTargetTable::new("dbo", "other_orders")?);
1520        let other_output_plan = plan_mssql_target_for_output(
1521            orders_schema(),
1522            "other_output",
1523            &other_target_config,
1524            Some(&connection),
1525            PlanOptions::default(),
1526        )?;
1527        let prepared_target = MssqlPreparedTarget::from_output_plan(
1528            &other_output_plan,
1529            MssqlPreparedTargetAction::VerifiedExisting,
1530        )?;
1531        let request_log = Arc::new(Mutex::new(None));
1532        let factory = RecordingBulkWriterFactory::with_request_log(Arc::clone(&request_log));
1533
1534        let error = initialize_mssql_bulk_writer_with_factory(
1535            &output_plan,
1536            &prepared_target,
1537            default_mssql_write_backend(),
1538            factory,
1539        )
1540        .await
1541        .err()
1542        .ok_or_else(|| DeltaFunnelError::Config {
1543            message: "expected mismatched prepared target error".to_owned(),
1544        })?;
1545
1546        assert_initialize_writer_error(
1547            error,
1548            "prepared target does not match output plan",
1549            MssqlTargetCleanupStatus::NotApplicable,
1550        )?;
1551        assert!(take_initialization_request(&request_log).is_err());
1552        Ok(())
1553    }
1554
1555    #[tokio::test]
1556    async fn bulk_load_writer_trait_consumes_writer_on_finish() -> Result<(), DeltaFunnelError> {
1557        let mut writer = FakeBulkLoadWriter::default();
1558        let first = orders_batch(vec![1, 2], vec![Some("open"), Some("closed")])?;
1559        let second = orders_batch(vec![3], vec![None])?;
1560
1561        let first_stats = MssqlBulkLoadWriter::write_batch(&mut writer, &first)
1562            .await
1563            .map_err(|source| DeltaFunnelError::MssqlWrite { source })?;
1564        let second_stats = MssqlBulkLoadWriter::write_batch(&mut writer, &second)
1565            .await
1566            .map_err(|source| DeltaFunnelError::MssqlWrite { source })?;
1567        assert_eq!(writer.batch_rows, vec![2, 1]);
1568
1569        let final_stats = MssqlBulkLoadWriter::finish(writer)
1570            .await
1571            .map_err(|source| DeltaFunnelError::MssqlWrite { source })?;
1572
1573        assert_eq!(first_stats.rows_written, 2);
1574        assert_eq!(first_stats.batches_written, 1);
1575        assert_eq!(second_stats.rows_written, 3);
1576        assert_eq!(second_stats.batches_written, 2);
1577        assert_eq!(final_stats.rows_written, 3);
1578        assert_eq!(final_stats.batches_written, 2);
1579        Ok(())
1580    }
1581
1582    #[tokio::test]
1583    async fn write_loop_writes_batches_in_order_counts_accepted_and_finishes()
1584    -> Result<(), DeltaFunnelError> {
1585        let output_plan = output_plan_for_orders_schema()?;
1586        let log = Arc::new(Mutex::new(FakeBulkLoadWriterLog::default()));
1587        let writer = FakeBulkLoadWriter::with_log(Arc::clone(&log))
1588            .delay_writes_by(Duration::from_millis(2));
1589        let first = orders_batch(vec![1, 2], vec![Some("open"), Some("closed")])?;
1590        let second = orders_batch(vec![3], vec![None])?;
1591        let batches = stream::iter(vec![Ok(first), Ok(second)]);
1592
1593        let report = write_mssql_batches_with_writer(
1594            &output_plan,
1595            batches,
1596            writer,
1597            default_mssql_write_backend(),
1598        )
1599        .await?;
1600
1601        assert_eq!(report.output_name(), "orders_output");
1602        assert_eq!(report.stats().rows_written(), 3);
1603        assert_eq!(report.stats().batches_written(), 2);
1604        assert!(report.stats().elapsed_ms() > 0);
1605        assert!(!report.partial_write_possible());
1606        assert_eq!(report.cleanup(), MssqlTargetCleanupStatus::NotApplicable);
1607        assert_output_schema(report.output_schema());
1608        assert_eq!(report.output_row_count(), RowCount::exact(3));
1609        assert_batch_shaping(report.batch_shaping(), PhaseStatus::completed(), 2, 3, 2, 3);
1610        assert_phase_timing(
1611            report.phase_timings(),
1612            POLL_BATCH_STREAM_PHASE,
1613            PhaseStatus::completed(),
1614        )?;
1615        assert_phase_timing(
1616            report.phase_timings(),
1617            VALIDATE_BATCH_SCHEMA_PHASE,
1618            PhaseStatus::completed(),
1619        )?;
1620        assert_phase_timing(
1621            report.phase_timings(),
1622            WRITE_BATCH_PHASE,
1623            PhaseStatus::completed(),
1624        )?;
1625        assert_phase_timing(
1626            report.phase_timings(),
1627            FINALIZE_PHASE,
1628            PhaseStatus::completed(),
1629        )?;
1630        assert_phase_timing(
1631            report.phase_timings(),
1632            VALIDATION_PHASE,
1633            PhaseStatus::not_started(ReportReasonCode::NotExecuted),
1634        )?;
1635        let debug = format!("{report:?}");
1636        assert!(!debug.contains("open"));
1637        assert!(!debug.contains("closed"));
1638
1639        let log = lock_fake_writer_log(&log)?;
1640        assert_eq!(log.batch_rows, vec![2, 1]);
1641        assert_eq!(log.finish_count, 1);
1642        Ok(())
1643    }
1644
1645    #[tokio::test]
1646    async fn write_loop_accepts_utf8_view_for_planned_utf8() -> Result<(), DeltaFunnelError> {
1647        let output_plan = output_plan_for_orders_schema()?;
1648        let log = Arc::new(Mutex::new(FakeBulkLoadWriterLog::default()));
1649        let writer = FakeBulkLoadWriter::with_log(Arc::clone(&log));
1650        let batches = stream::iter(vec![Ok(orders_batch_with_utf8_view_status()?)]);
1651
1652        let report = write_mssql_batches_with_writer(
1653            &output_plan,
1654            batches,
1655            writer,
1656            default_mssql_write_backend(),
1657        )
1658        .await?;
1659
1660        assert_eq!(report.stats().rows_written(), 2);
1661        assert_eq!(report.stats().batches_written(), 1);
1662        let log = lock_fake_writer_log(&log)?;
1663        assert_eq!(log.batch_rows, vec![2]);
1664        assert_eq!(log.finish_count, 1);
1665        Ok(())
1666    }
1667
1668    #[tokio::test]
1669    async fn write_loop_empty_stream_finishes_with_zero_stats() -> Result<(), DeltaFunnelError> {
1670        let output_plan = output_plan_for_orders_schema()?;
1671        let log = Arc::new(Mutex::new(FakeBulkLoadWriterLog::default()));
1672        let writer = FakeBulkLoadWriter::with_log(Arc::clone(&log));
1673        let batches = stream::empty::<Result<RecordBatch, DeltaFunnelError>>();
1674
1675        let report = write_mssql_batches_with_writer(
1676            &output_plan,
1677            batches,
1678            writer,
1679            default_mssql_write_backend(),
1680        )
1681        .await?;
1682
1683        assert_eq!(report.output_name(), "orders_output");
1684        assert_eq!(report.stats().rows_written(), 0);
1685        assert_eq!(report.stats().batches_written(), 0);
1686        assert!(!report.partial_write_possible());
1687        assert_eq!(report.output_row_count(), RowCount::exact(0));
1688        assert_batch_shaping(report.batch_shaping(), PhaseStatus::completed(), 0, 0, 0, 0);
1689        let log = lock_fake_writer_log(&log)?;
1690        assert!(log.batch_rows.is_empty());
1691        assert_eq!(log.finish_count, 1);
1692        Ok(())
1693    }
1694
1695    #[tokio::test]
1696    async fn write_loop_stream_error_preserves_stats_and_skips_finish()
1697    -> Result<(), DeltaFunnelError> {
1698        let output_plan = output_plan_for_orders_schema()?;
1699        let log = Arc::new(Mutex::new(FakeBulkLoadWriterLog::default()));
1700        let writer = FakeBulkLoadWriter::with_log(Arc::clone(&log));
1701        let first = orders_batch(vec![1, 2], vec![Some("open"), Some("closed")])?;
1702        let batches = stream::iter(vec![
1703            Ok(first),
1704            Err(DeltaFunnelError::Config {
1705                message: "stream failed after first batch".to_owned(),
1706            }),
1707        ]);
1708
1709        let error = match write_mssql_batches_with_writer(
1710            &output_plan,
1711            batches,
1712            writer,
1713            default_mssql_write_backend(),
1714        )
1715        .await
1716        {
1717            Ok(_) => {
1718                return Err(DeltaFunnelError::Config {
1719                    message: "expected stream error".to_owned(),
1720                });
1721            }
1722            Err(error) => error,
1723        };
1724
1725        let context =
1726            assert_write_phase_error(error, MssqlWritePhase::PollBatchStream, 2, 1, true)?;
1727        assert_eq!(context.output_row_count(), RowCount::partial(2));
1728        assert_batch_shaping(context.batch_shaping(), PhaseStatus::failed(), 1, 2, 1, 2);
1729        assert_phase_timing(
1730            context.phase_timings(),
1731            POLL_BATCH_STREAM_PHASE,
1732            PhaseStatus::failed(),
1733        )?;
1734        assert_phase_timing(
1735            context.phase_timings(),
1736            FINALIZE_PHASE,
1737            PhaseStatus::not_started(ReportReasonCode::NotExecuted),
1738        )?;
1739        assert_phase_timing(
1740            context.phase_timings(),
1741            VALIDATION_PHASE,
1742            PhaseStatus::not_started(ReportReasonCode::FailureBeforeValidation),
1743        )?;
1744        let log = lock_fake_writer_log(&log)?;
1745        assert_eq!(log.batch_rows, vec![2]);
1746        assert_eq!(log.finish_count, 0);
1747        Ok(())
1748    }
1749
1750    #[tokio::test]
1751    async fn write_loop_schema_mismatch_skips_writer_and_finish() -> Result<(), DeltaFunnelError> {
1752        let output_plan = output_plan_for_orders_schema()?;
1753        let log = Arc::new(Mutex::new(FakeBulkLoadWriterLog::default()));
1754        let writer = FakeBulkLoadWriter::with_log(Arc::clone(&log));
1755        let batches = stream::iter(vec![Ok(orders_batch_with_int32_order_id()?)]);
1756
1757        let error = match write_mssql_batches_with_writer(
1758            &output_plan,
1759            batches,
1760            writer,
1761            default_mssql_write_backend(),
1762        )
1763        .await
1764        {
1765            Ok(_) => {
1766                return Err(DeltaFunnelError::Config {
1767                    message: "expected batch schema validation error".to_owned(),
1768                });
1769            }
1770            Err(error) => error,
1771        };
1772
1773        let context = assert_batch_schema_validation_error(error, Some((0, "order_id")))?;
1774        assert_eq!(context.output_row_count(), RowCount::partial(1));
1775        assert_batch_shaping(context.batch_shaping(), PhaseStatus::failed(), 1, 1, 0, 0);
1776        assert_phase_timing(
1777            context.phase_timings(),
1778            VALIDATE_BATCH_SCHEMA_PHASE,
1779            PhaseStatus::failed(),
1780        )?;
1781        assert_phase_timing(
1782            context.phase_timings(),
1783            WRITE_BATCH_PHASE,
1784            PhaseStatus::not_started(ReportReasonCode::NotExecuted),
1785        )?;
1786        let log = lock_fake_writer_log(&log)?;
1787        assert!(log.batch_rows.is_empty());
1788        assert_eq!(log.finish_count, 0);
1789        Ok(())
1790    }
1791
1792    #[tokio::test]
1793    async fn write_loop_write_failure_preserves_stats_and_skips_finish()
1794    -> Result<(), DeltaFunnelError> {
1795        let output_plan = output_plan_for_orders_schema()?;
1796        let log = Arc::new(Mutex::new(FakeBulkLoadWriterLog::default()));
1797        let writer = FakeBulkLoadWriter::with_log(Arc::clone(&log))
1798            .fail_on_write_batch(2)
1799            .delay_writes_by(Duration::from_millis(2));
1800        let first = orders_batch(vec![1, 2], vec![Some("open"), Some("closed")])?;
1801        let second = orders_batch(vec![3], vec![None])?;
1802        let batches = stream::iter(vec![Ok(first), Ok(second)]);
1803
1804        let error = match write_mssql_batches_with_writer(
1805            &output_plan,
1806            batches,
1807            writer,
1808            default_mssql_write_backend(),
1809        )
1810        .await
1811        {
1812            Ok(_) => {
1813                return Err(DeltaFunnelError::Config {
1814                    message: "expected write batch error".to_owned(),
1815                });
1816            }
1817            Err(error) => error,
1818        };
1819
1820        let DeltaFunnelError::MssqlWritePhase { context, .. } = error else {
1821            return Err(DeltaFunnelError::Config {
1822                message: "expected MSSQL write phase error".to_owned(),
1823            });
1824        };
1825        assert_eq!(context.phase(), MssqlWritePhase::WriteBatch);
1826        assert_eq!(context.output_name(), "orders_output");
1827        assert_eq!(context.stats().rows_written(), 2);
1828        assert_eq!(context.stats().batches_written(), 1);
1829        assert_eq!(context.output_row_count(), RowCount::partial(3));
1830        assert_batch_shaping(context.batch_shaping(), PhaseStatus::failed(), 2, 3, 1, 2);
1831        assert!(context.stats().elapsed_ms() > 0);
1832        assert!(context.partial_write_possible());
1833        assert_eq!(context.cleanup(), MssqlTargetCleanupStatus::NotApplicable);
1834        assert_phase_timing(
1835            context.phase_timings(),
1836            WRITE_BATCH_PHASE,
1837            PhaseStatus::failed(),
1838        )?;
1839        assert_phase_timing(
1840            context.phase_timings(),
1841            FINALIZE_PHASE,
1842            PhaseStatus::not_started(ReportReasonCode::NotExecuted),
1843        )?;
1844        assert_phase_timing(
1845            context.phase_timings(),
1846            VALIDATION_PHASE,
1847            PhaseStatus::not_started(ReportReasonCode::FailureBeforeValidation),
1848        )?;
1849        let log = lock_fake_writer_log(&log)?;
1850        assert_eq!(log.batch_rows, vec![2, 1]);
1851        assert_eq!(log.finish_count, 0);
1852        Ok(())
1853    }
1854
1855    #[tokio::test]
1856    async fn write_loop_create_and_load_write_failure_does_not_report_partial_write()
1857    -> Result<(), DeltaFunnelError> {
1858        let output_plan = output_plan_for_orders_schema_with_load_mode(LoadMode::CreateAndLoad)?;
1859        let log = Arc::new(Mutex::new(FakeBulkLoadWriterLog::default()));
1860        let writer = FakeBulkLoadWriter::with_log(Arc::clone(&log)).fail_on_write_batch(2);
1861        let first = orders_batch(vec![1, 2], vec![Some("open"), Some("closed")])?;
1862        let second = orders_batch(vec![3], vec![None])?;
1863        let batches = stream::iter(vec![Ok(first), Ok(second)]);
1864
1865        let error = match write_mssql_batches_with_writer(
1866            &output_plan,
1867            batches,
1868            writer,
1869            default_mssql_write_backend(),
1870        )
1871        .await
1872        {
1873            Ok(_) => {
1874                return Err(DeltaFunnelError::Config {
1875                    message: "expected create-and-load write batch error".to_owned(),
1876                });
1877            }
1878            Err(error) => error,
1879        };
1880
1881        let DeltaFunnelError::MssqlWritePhase { context, .. } = error else {
1882            return Err(DeltaFunnelError::Config {
1883                message: "expected MSSQL write phase error".to_owned(),
1884            });
1885        };
1886        assert_eq!(context.phase(), MssqlWritePhase::WriteBatch);
1887        assert_eq!(context.load_mode(), LoadMode::CreateAndLoad);
1888        assert_eq!(context.stats().rows_written(), 2);
1889        assert_eq!(context.stats().batches_written(), 1);
1890        assert!(!context.partial_write_possible());
1891        let log = lock_fake_writer_log(&log)?;
1892        assert_eq!(log.batch_rows, vec![2, 1]);
1893        assert_eq!(log.finish_count, 0);
1894        Ok(())
1895    }
1896
1897    #[tokio::test]
1898    async fn write_loop_finalize_failure_preserves_stats() -> Result<(), DeltaFunnelError> {
1899        let output_plan = output_plan_for_orders_schema()?;
1900        let log = Arc::new(Mutex::new(FakeBulkLoadWriterLog::default()));
1901        let writer = FakeBulkLoadWriter::with_log(Arc::clone(&log)).fail_on_finish();
1902        let first = orders_batch(vec![1, 2], vec![Some("open"), Some("closed")])?;
1903        let second = orders_batch(vec![3], vec![None])?;
1904        let batches = stream::iter(vec![Ok(first), Ok(second)]);
1905
1906        let error = match write_mssql_batches_with_writer(
1907            &output_plan,
1908            batches,
1909            writer,
1910            default_mssql_write_backend(),
1911        )
1912        .await
1913        {
1914            Ok(_) => {
1915                return Err(DeltaFunnelError::Config {
1916                    message: "expected finalize error".to_owned(),
1917                });
1918            }
1919            Err(error) => error,
1920        };
1921
1922        let context = assert_write_phase_error(error, MssqlWritePhase::Finalize, 3, 2, true)?;
1923        assert_eq!(context.output_row_count(), RowCount::exact(3));
1924        assert_batch_shaping(
1925            context.batch_shaping(),
1926            PhaseStatus::completed(),
1927            2,
1928            3,
1929            2,
1930            3,
1931        );
1932        assert_phase_timing(
1933            context.phase_timings(),
1934            FINALIZE_PHASE,
1935            PhaseStatus::failed(),
1936        )?;
1937        assert_phase_timing(
1938            context.phase_timings(),
1939            VALIDATION_PHASE,
1940            PhaseStatus::not_started(ReportReasonCode::FailureBeforeValidation),
1941        )?;
1942        let log = lock_fake_writer_log(&log)?;
1943        assert_eq!(log.batch_rows, vec![2, 1]);
1944        assert_eq!(log.finish_count, 1);
1945        Ok(())
1946    }
1947
1948    #[test]
1949    fn write_phase_display_is_stable() {
1950        let phases = [
1951            (MssqlWritePhase::Connect, "connect"),
1952            (
1953                MssqlWritePhase::PrepareTargetLifecycle,
1954                "prepare target lifecycle",
1955            ),
1956            (MssqlWritePhase::InitializeWriter, "initialize writer"),
1957            (MssqlWritePhase::PollBatchStream, "poll batch stream"),
1958            (
1959                MssqlWritePhase::ValidateBatchSchema,
1960                "validate batch schema",
1961            ),
1962            (MssqlWritePhase::WriteBatch, "write batch"),
1963            (MssqlWritePhase::Finalize, "finalize"),
1964            (MssqlWritePhase::Validation, "validation"),
1965            (MssqlWritePhase::SwapTarget, "swap target"),
1966            (MssqlWritePhase::Cleanup, "cleanup"),
1967        ];
1968
1969        for (phase, expected) in phases {
1970            assert_eq!(phase.to_string(), expected);
1971            assert!(!format!("{phase:?}").contains("password"));
1972        }
1973    }
1974
1975    #[test]
1976    fn cleanup_status_display_is_stable() {
1977        let statuses = [
1978            (MssqlTargetCleanupStatus::NotApplicable, "not applicable"),
1979            (MssqlTargetCleanupStatus::NotAttempted, "not attempted"),
1980            (MssqlTargetCleanupStatus::Succeeded, "succeeded"),
1981            (MssqlTargetCleanupStatus::Failed, "failed"),
1982        ];
1983
1984        for (status, expected) in statuses {
1985            assert_eq!(status.to_string(), expected);
1986            assert!(!format!("{status:?}").contains("password"));
1987        }
1988    }
1989
1990    #[test]
1991    fn write_report_preserves_plan_context_stats_and_cleanup() -> Result<(), DeltaFunnelError> {
1992        let connection = secret_connection()?;
1993        let target_config = MssqlTargetConfig::new(MssqlTargetTable::new("dbo", "orders")?);
1994        let output_plan = plan_mssql_target_for_output(
1995            orders_schema(),
1996            "orders_output",
1997            &target_config,
1998            Some(&connection),
1999            PlanOptions::default(),
2000        )?;
2001
2002        let report = MssqlWriteReport::from_output_plan(
2003            &output_plan,
2004            42,
2005            3,
2006            125,
2007            true,
2008            MssqlTargetCleanupStatus::NotApplicable,
2009        );
2010
2011        assert_eq!(report.output_name(), "orders_output");
2012        assert_eq!(report.target_table().schema(), Some("dbo"));
2013        assert_eq!(report.target_table().table(), "orders");
2014        assert_eq!(report.load_mode(), LoadMode::AppendExisting);
2015        assert_eq!(
2016            report.connection_source(),
2017            MssqlConnectionSource::ContextDefault
2018        );
2019        assert_eq!(
2020            report.connection().display_label(),
2021            Some("warehouse-primary")
2022        );
2023        assert_eq!(report.stats().output_name(), "orders_output");
2024        assert_eq!(report.stats().rows_written(), 42);
2025        assert_eq!(report.stats().batches_written(), 3);
2026        assert_eq!(report.stats().elapsed_ms(), 125);
2027        assert_output_schema(report.output_schema());
2028        assert_eq!(report.output_row_count(), RowCount::exact(42));
2029        assert_eq!(report.target_row_count(), RowCount::unavailable());
2030        assert_eq!(
2031            report.target_row_count_before_write(),
2032            RowCount::unavailable()
2033        );
2034        assert_eq!(
2035            report.target_row_count_after_write(),
2036            RowCount::unavailable()
2037        );
2038        assert_eq!(
2039            report.validation_status(),
2040            ValidationStatus::skipped(ReportReasonCode::NotExecuted)
2041        );
2042        assert_batch_shaping(
2043            report.batch_shaping(),
2044            PhaseStatus::completed(),
2045            3,
2046            42,
2047            3,
2048            42,
2049        );
2050        assert!(report.partial_write_possible());
2051        assert_eq!(report.cleanup(), MssqlTargetCleanupStatus::NotApplicable);
2052        Ok(())
2053    }
2054
2055    #[test]
2056    fn write_report_records_target_validation_outcome() -> Result<(), DeltaFunnelError> {
2057        let connection = secret_connection()?;
2058        let target_config = MssqlTargetConfig::new(MssqlTargetTable::new("dbo", "orders")?);
2059        let output_plan = plan_mssql_target_for_output(
2060            orders_schema(),
2061            "orders_output",
2062            &target_config,
2063            Some(&connection),
2064            PlanOptions::default(),
2065        )?;
2066        let report = MssqlWriteReport::from_output_plan_with_metrics(
2067            &output_plan,
2068            MssqlWriteReportMetrics::new(
2069                RowCount::exact(42),
2070                MssqlBatchShapingReport::completed(3, 42, 3, 42),
2071                42,
2072                3,
2073                125,
2074                false,
2075                MssqlTargetCleanupStatus::NotApplicable,
2076            )
2077            .with_phase_timings(vec![PhaseTimingReport::not_started(
2078                VALIDATION_PHASE,
2079                ReportReasonCode::NotExecuted,
2080            )]),
2081        );
2082
2083        let report = report.with_target_validation(
2084            RowCount::exact(42),
2085            ValidationStatus::passed(),
2086            PhaseTimingReport::completed(VALIDATION_PHASE, Duration::from_micros(7)),
2087        );
2088
2089        assert_eq!(report.target_row_count(), RowCount::exact(42));
2090        assert_eq!(
2091            report.target_row_count_before_write(),
2092            RowCount::unavailable()
2093        );
2094        assert_eq!(report.target_row_count_after_write(), RowCount::exact(42));
2095        assert_eq!(report.validation_status(), ValidationStatus::passed());
2096        assert_eq!(
2097            report
2098                .phase_timings()
2099                .iter()
2100                .filter(|timing| timing.phase_name() == VALIDATION_PHASE)
2101                .count(),
2102            1
2103        );
2104        assert_phase_timing(
2105            report.phase_timings(),
2106            VALIDATION_PHASE,
2107            PhaseStatus::completed(),
2108        )?;
2109        Ok(())
2110    }
2111
2112    #[test]
2113    fn write_report_records_target_delta_validation_counts() -> Result<(), DeltaFunnelError> {
2114        let connection = secret_connection()?;
2115        let target_config = MssqlTargetConfig::new(MssqlTargetTable::new("dbo", "orders")?);
2116        let output_plan = plan_mssql_target_for_output(
2117            orders_schema(),
2118            "orders_output",
2119            &target_config,
2120            Some(&connection),
2121            PlanOptions::default(),
2122        )?;
2123        let report = MssqlWriteReport::from_output_plan_with_metrics(
2124            &output_plan,
2125            MssqlWriteReportMetrics::new(
2126                RowCount::exact(3),
2127                MssqlBatchShapingReport::completed(1, 3, 1, 3),
2128                3,
2129                1,
2130                125,
2131                false,
2132                MssqlTargetCleanupStatus::NotApplicable,
2133            )
2134            .with_phase_timings(vec![PhaseTimingReport::not_started(
2135                VALIDATION_PHASE,
2136                ReportReasonCode::NotExecuted,
2137            )]),
2138        );
2139
2140        let report = report.with_target_delta_validation(
2141            RowCount::exact(10),
2142            RowCount::exact(13),
2143            ValidationStatus::passed(),
2144            PhaseTimingReport::completed(VALIDATION_PHASE, Duration::from_micros(7)),
2145        );
2146
2147        assert_eq!(report.target_row_count(), RowCount::exact(13));
2148        assert_eq!(report.target_row_count_before_write(), RowCount::exact(10));
2149        assert_eq!(report.target_row_count_after_write(), RowCount::exact(13));
2150        assert_eq!(report.validation_status(), ValidationStatus::passed());
2151        assert_phase_timing(
2152            report.phase_timings(),
2153            VALIDATION_PHASE,
2154            PhaseStatus::completed(),
2155        )?;
2156        Ok(())
2157    }
2158
2159    #[test]
2160    fn write_report_debug_redacts_connection_secret() -> Result<(), DeltaFunnelError> {
2161        let connection = secret_connection()?;
2162        let target_config = MssqlTargetConfig::new(MssqlTargetTable::new("dbo", "orders")?);
2163        let output_plan = plan_mssql_target_for_output(
2164            orders_schema(),
2165            "orders_output",
2166            &target_config,
2167            Some(&connection),
2168            PlanOptions::default(),
2169        )?;
2170
2171        let report = MssqlWriteReport::from_output_plan(
2172            &output_plan,
2173            0,
2174            0,
2175            0,
2176            false,
2177            MssqlTargetCleanupStatus::NotApplicable,
2178        );
2179        let debug = format!("{report:?}");
2180
2181        assert!(debug.contains("warehouse-primary"));
2182        assert!(!debug.contains("secret-token"));
2183        assert!(!debug.contains("password"));
2184        assert!(!debug.contains("server=tcp"));
2185        Ok(())
2186    }
2187
2188    #[test]
2189    fn write_failure_context_preserves_phase_report_and_accepted_stats()
2190    -> Result<(), DeltaFunnelError> {
2191        let connection = secret_connection()?;
2192        let target_config = MssqlTargetConfig::new(MssqlTargetTable::new("dbo", "orders")?);
2193        let output_plan = plan_mssql_target_for_output(
2194            orders_schema(),
2195            "orders_output",
2196            &target_config,
2197            Some(&connection),
2198            PlanOptions::default(),
2199        )?;
2200
2201        let context = MssqlWriteFailureContext::from_output_plan(
2202            &output_plan,
2203            MssqlWritePhase::WriteBatch,
2204            42,
2205            3,
2206            125,
2207            true,
2208            MssqlTargetCleanupStatus::NotApplicable,
2209        );
2210
2211        assert_eq!(context.phase(), MssqlWritePhase::WriteBatch);
2212        assert_eq!(context.output_name(), "orders_output");
2213        assert_eq!(context.target_table().table(), "orders");
2214        assert_eq!(context.load_mode(), LoadMode::AppendExisting);
2215        assert_eq!(
2216            context.connection_source(),
2217            MssqlConnectionSource::ContextDefault
2218        );
2219        assert_eq!(
2220            context.connection().display_label(),
2221            Some("warehouse-primary")
2222        );
2223        assert_eq!(context.stats().rows_written(), 42);
2224        assert_eq!(context.stats().batches_written(), 3);
2225        assert_eq!(context.stats().elapsed_ms(), 125);
2226        assert_eq!(context.output_row_count(), RowCount::partial(42));
2227        assert_eq!(context.target_row_count(), RowCount::unavailable());
2228        assert_eq!(
2229            context.validation_status(),
2230            ValidationStatus::skipped(ReportReasonCode::NotExecuted)
2231        );
2232        assert_batch_shaping(context.batch_shaping(), PhaseStatus::failed(), 3, 42, 3, 42);
2233        assert!(context.partial_write_possible());
2234        assert_eq!(context.cleanup(), MssqlTargetCleanupStatus::NotApplicable);
2235        assert_eq!(context.report().output_name(), "orders_output");
2236        Ok(())
2237    }
2238
2239    fn assert_phase_timing(
2240        timings: &[PhaseTimingReport],
2241        phase_name: &str,
2242        expected_status: PhaseStatus,
2243    ) -> Result<(), DeltaFunnelError> {
2244        let timing = timings
2245            .iter()
2246            .find(|timing| timing.phase_name() == phase_name)
2247            .ok_or_else(|| DeltaFunnelError::Config {
2248                message: format!("missing phase timing {phase_name}"),
2249            })?;
2250
2251        assert_eq!(timing.status(), expected_status);
2252        if expected_status.is_completed() || expected_status.is_failed() {
2253            assert!(timing.elapsed_micros().is_some());
2254        } else {
2255            assert_eq!(timing.elapsed_micros(), None);
2256        }
2257        Ok(())
2258    }
2259
2260    #[test]
2261    fn write_failure_context_debug_redacts_connection_secret() -> Result<(), DeltaFunnelError> {
2262        let connection = secret_connection()?;
2263        let target_config = MssqlTargetConfig::new(MssqlTargetTable::new("dbo", "orders")?);
2264        let output_plan = plan_mssql_target_for_output(
2265            orders_schema(),
2266            "orders_output",
2267            &target_config,
2268            Some(&connection),
2269            PlanOptions::default(),
2270        )?;
2271
2272        let context = MssqlWriteFailureContext::from_output_plan(
2273            &output_plan,
2274            MssqlWritePhase::InitializeWriter,
2275            0,
2276            0,
2277            0,
2278            false,
2279            MssqlTargetCleanupStatus::NotAttempted,
2280        );
2281        let debug = format!("{context:?}");
2282
2283        assert!(debug.contains("warehouse-primary"));
2284        assert!(!debug.contains("secret-token"));
2285        assert!(!debug.contains("password"));
2286        assert!(!debug.contains("server=tcp"));
2287        Ok(())
2288    }
2289
2290    #[test]
2291    fn default_backend_pins_direct_raw_bulk() {
2292        let backend = default_mssql_write_backend();
2293
2294        assert_eq!(backend, WriteBackend::DirectRawBulk);
2295    }
2296
2297    #[test]
2298    fn write_backend_conversion_preserves_arrow_tiberius_schema_check_default() {
2299        let backend = default_mssql_write_backend();
2300        let arrow_options = arrow_tiberius::WriteOptions::from(backend);
2301
2302        assert_eq!(
2303            arrow_options.schema_check,
2304            arrow_tiberius::SchemaCheck::Strict
2305        );
2306    }
2307
2308    #[test]
2309    fn output_plan_preserves_schema_plan_options() -> Result<(), DeltaFunnelError> {
2310        let connection = secret_connection()?;
2311        let target_config = MssqlTargetConfig::new(MssqlTargetTable::new("dbo", "orders")?);
2312        let plan_options = PlanOptions {
2313            string_policy: StringPolicy::NVarChar(128),
2314            ..PlanOptions::default()
2315        };
2316        let output_plan = plan_mssql_target_for_output(
2317            orders_schema(),
2318            "orders_output",
2319            &target_config,
2320            Some(&connection),
2321            plan_options,
2322        )?;
2323
2324        let backend = mssql_write_backend_for_output_plan(&output_plan);
2325
2326        assert_eq!(output_plan.planned_schema().plan_options(), plan_options);
2327        assert_eq!(backend, WriteBackend::DirectRawBulk);
2328        Ok(())
2329    }
2330
2331    #[test]
2332    fn output_record_batch_validation_accepts_matching_planned_schema()
2333    -> Result<(), DeltaFunnelError> {
2334        let schema = Arc::new(orders_schema());
2335        let output_plan = output_plan_for_orders_schema()?;
2336        let batch = RecordBatch::try_new(
2337            schema,
2338            vec![
2339                Arc::new(Int64Array::from(vec![1_i64, 2])),
2340                Arc::new(StringArray::from(vec![Some("open"), None])),
2341            ],
2342        )
2343        .map_err(|error| DeltaFunnelError::Config {
2344            message: error.to_string(),
2345        })?;
2346
2347        let report = validate_mssql_output_record_batch(&output_plan, &batch)?;
2348
2349        assert_eq!(report.output_name(), "orders_output");
2350        assert_eq!(report.target_table().schema(), Some("dbo"));
2351        assert_eq!(report.target_table().table(), "orders");
2352        assert_eq!(report.load_mode(), LoadMode::AppendExisting);
2353        assert_eq!(
2354            report.connection_source(),
2355            MssqlConnectionSource::ContextDefault
2356        );
2357        assert_eq!(
2358            report.connection().display_label(),
2359            Some("warehouse-primary")
2360        );
2361        assert!(!format!("{report:?}").contains("secret-token"));
2362        Ok(())
2363    }
2364
2365    #[test]
2366    fn output_record_batch_validation_accepts_utf8_view_for_planned_utf8()
2367    -> Result<(), DeltaFunnelError> {
2368        let output_plan = output_plan_for_orders_schema()?;
2369        let batch = orders_batch_with_utf8_view_status()?;
2370
2371        let report = validate_mssql_output_record_batch(&output_plan, &batch)?;
2372
2373        assert_eq!(report.output_name(), "orders_output");
2374        assert_eq!(report.target_table().schema(), Some("dbo"));
2375        assert_eq!(report.target_table().table(), "orders");
2376        Ok(())
2377    }
2378
2379    #[test]
2380    fn output_schema_validation_accepts_aliased_output_field_names() -> Result<(), DeltaFunnelError>
2381    {
2382        let connection = secret_connection()?;
2383        let target_config = MssqlTargetConfig::new(MssqlTargetTable::new("dbo", "orders")?);
2384        let schema = Schema::new(vec![
2385            Field::new("gross_total", DataType::Float64, true),
2386            Field::new("order_id", DataType::Int32, false),
2387        ]);
2388        let output_plan = plan_mssql_target_for_output(
2389            schema.clone(),
2390            "orders_output",
2391            &target_config,
2392            Some(&connection),
2393            PlanOptions::default(),
2394        )?;
2395
2396        let report = validate_mssql_output_schema(&output_plan, &schema)?;
2397
2398        assert_eq!(
2399            output_plan.schema_mappings()[0].arrow().name(),
2400            "gross_total"
2401        );
2402        assert_eq!(output_plan.schema_mappings()[1].arrow().name(), "order_id");
2403        assert_eq!(report.output_name(), "orders_output");
2404        assert_eq!(report.target_table().schema(), Some("dbo"));
2405        assert_eq!(report.target_table().table(), "orders");
2406        Ok(())
2407    }
2408
2409    #[test]
2410    fn output_schema_validation_rejects_reordered_fields() -> Result<(), DeltaFunnelError> {
2411        let output_plan = output_plan_for_orders_schema()?;
2412        let schema = Schema::new(vec![
2413            Field::new("status", DataType::Utf8, true),
2414            Field::new("order_id", DataType::Int64, false),
2415        ]);
2416
2417        let error = validate_mssql_output_schema(&output_plan, &schema).unwrap_err();
2418
2419        assert_batch_schema_validation_error(error, Some((0, "order_id")))?;
2420        Ok(())
2421    }
2422
2423    #[test]
2424    fn output_schema_validation_rejects_type_mismatch() -> Result<(), DeltaFunnelError> {
2425        let output_plan = output_plan_for_orders_schema()?;
2426        let schema = Schema::new(vec![
2427            Field::new("order_id", DataType::Int32, false),
2428            Field::new("status", DataType::Utf8, true),
2429        ]);
2430
2431        let error = validate_mssql_output_schema(&output_plan, &schema).unwrap_err();
2432
2433        assert_batch_schema_validation_error(error, Some((0, "order_id")))?;
2434        Ok(())
2435    }
2436
2437    #[test]
2438    fn output_schema_validation_rejects_missing_field() -> Result<(), DeltaFunnelError> {
2439        let output_plan = output_plan_for_orders_schema()?;
2440        let schema = Schema::new(vec![Field::new("order_id", DataType::Int64, false)]);
2441
2442        let error = validate_mssql_output_schema(&output_plan, &schema).unwrap_err();
2443
2444        assert_batch_schema_validation_error(error, Some((1, "status")))?;
2445        Ok(())
2446    }
2447
2448    #[test]
2449    fn output_schema_validation_rejects_extra_field() -> Result<(), DeltaFunnelError> {
2450        let output_plan = output_plan_for_orders_schema()?;
2451        let schema = Schema::new(vec![
2452            Field::new("order_id", DataType::Int64, false),
2453            Field::new("status", DataType::Utf8, true),
2454            Field::new("extra", DataType::Utf8, true),
2455        ]);
2456
2457        let error = validate_mssql_output_schema(&output_plan, &schema).unwrap_err();
2458
2459        assert_batch_schema_validation_error(error, None)?;
2460        Ok(())
2461    }
2462
2463    #[test]
2464    fn output_schema_validation_rejects_nullability_mismatch() -> Result<(), DeltaFunnelError> {
2465        let output_plan = output_plan_for_orders_schema()?;
2466        let schema = Schema::new(vec![
2467            Field::new("order_id", DataType::Int64, true),
2468            Field::new("status", DataType::Utf8, true),
2469        ]);
2470
2471        let error = validate_mssql_output_schema(&output_plan, &schema).unwrap_err();
2472
2473        assert_batch_schema_validation_error(error, Some((0, "order_id")))?;
2474        Ok(())
2475    }
2476}