1use 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#[async_trait]
42pub(crate) trait MssqlBulkLoadWriter: Sized + Send {
43 async fn write_batch(
45 &mut self,
46 batch: &RecordBatch,
47 ) -> Result<arrow_tiberius::WriteStats, arrow_tiberius::Error>;
48
49 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#[allow(dead_code)]
86#[async_trait]
87pub(crate) trait MssqlBulkWriterFactory: Send {
88 type Writer: MssqlBulkLoadWriter;
90
91 async fn initialize(
93 self,
94 request: MssqlBulkWriterInitializationRequest,
95 ) -> Result<Self::Writer, arrow_tiberius::Error>;
96}
97
98#[allow(dead_code)]
100pub(crate) struct MssqlConnectedBulkWriterFactory<'client> {
101 client: &'client mut arrow_tiberius::ConnectedMssqlClient,
102}
103
104impl<'client> MssqlConnectedBulkWriterFactory<'client> {
105 #[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#[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 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 #[must_use]
166 pub(crate) fn output_name(&self) -> &str {
167 &self.output_name
168 }
169
170 #[must_use]
172 pub(crate) const fn table(&self) -> &arrow_tiberius::TableName {
173 &self.table
174 }
175
176 #[must_use]
178 pub(crate) fn mappings(&self) -> &[arrow_tiberius::SchemaMapping] {
179 self.planned_schema.mappings()
180 }
181
182 #[must_use]
184 pub(crate) const fn backend(&self) -> MssqlWriteBackend {
185 self.backend
186 }
187
188 #[must_use]
190 pub(crate) const fn prepared_action(&self) -> MssqlPreparedTargetAction {
191 self.prepared_action
192 }
193
194 #[must_use]
196 pub(crate) const fn cleanup(&self) -> MssqlTargetCleanupStatus {
197 self.cleanup
198 }
199}
200
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
203pub enum MssqlWritePhase {
204 Connect,
206 PrepareTargetLifecycle,
208 InitializeWriter,
210 PollBatchStream,
212 ValidateBatchSchema,
214 WriteBatch,
216 Finalize,
218 Validation,
220 SwapTarget,
222 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
245pub enum MssqlWriteBackend {
246 #[default]
248 Auto,
249 BaselineTokenRow,
251 DirectFramedBulk,
253 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#[must_use]
279pub fn default_mssql_write_backend() -> MssqlWriteBackend {
280 MssqlWriteBackend::DirectRawBulk
281}
282
283#[must_use]
285pub fn mssql_write_backend_for_output_plan(
286 _output_plan: &MssqlTargetOutputPlan,
287) -> MssqlWriteBackend {
288 default_mssql_write_backend()
289}
290
291#[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#[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
337pub 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
365pub 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#[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}