1use crate::BatchPipelinePhase;
4use crate::support::{sanitize_text_for_display, sanitize_uri_for_display};
5
6use snafu::Snafu;
7
8#[derive(Debug, Snafu)]
10#[snafu(visibility(pub(crate)))]
11pub enum DeltaFunnelError {
12 #[snafu(display("configuration error: {message}"))]
14 Config {
15 message: String,
17 },
18
19 #[snafu(display(
21 "preview failed during {}: {}",
22 context.failed_phase(),
23 sanitize_reason_for_display(&source.to_string())
24 ))]
25 PreviewFailed {
26 context: Box<crate::PreviewFailureContext>,
28 source: Box<DeltaFunnelError>,
30 },
31
32 #[snafu(display(
34 "write_all cache failed: {}",
35 sanitize_reason_for_display(&source.to_string())
36 ))]
37 WriteAllCache {
38 failure: Box<crate::WriteAllCacheFailure>,
40 source: Box<DeltaFunnelError>,
42 },
43
44 #[snafu(display(
46 "invalid Delta source name `{}`: {reason}",
47 sanitize_source_name_for_display(name)
48 ))]
49 InvalidSourceName {
50 name: String,
52 reason: &'static str,
54 },
55
56 #[snafu(display(
58 "duplicate Delta source name `{}`",
59 sanitize_source_name_for_display(name)
60 ))]
61 DuplicateSourceName {
62 name: String,
64 },
65
66 #[snafu(display("invalid Delta source URI: {reason}"))]
68 InvalidSourceUri {
69 reason: &'static str,
71 },
72
73 #[snafu(display("Delta source engine error: {reason}"))]
75 DeltaSourceEngine {
76 reason: &'static str,
78 },
79
80 #[snafu(display("Delta snapshot load error: {reason}"))]
82 DeltaSnapshotLoad {
83 reason: String,
85 },
86
87 #[snafu(display(
89 "Delta protocol compatibility error for source `{}` at snapshot version {snapshot_version} ({}): {reason}",
90 sanitize_source_name_for_display(source_name),
91 sanitize_uri_for_display(table_uri)
92 ))]
93 DeltaProtocolCompatibility {
94 source_name: String,
96 table_uri: String,
98 snapshot_version: u64,
100 reason: String,
102 },
103
104 #[snafu(display(
106 "Delta source schema error for source `{}` ({}): {}",
107 sanitize_source_name_for_display(source_name),
108 sanitize_uri_for_display(table_uri),
109 sanitize_reason_for_display(reason)
110 ))]
111 DeltaSourceSchema {
112 source_name: String,
114 table_uri: String,
116 reason: String,
118 },
119
120 #[snafu(display(
122 "DataFusion registration error for source `{}` ({}): {}",
123 sanitize_source_name_for_display(source_name),
124 sanitize_uri_for_display(table_uri),
125 sanitize_reason_for_display(reason)
126 ))]
127 DataFusionRegistration {
128 source_name: String,
130 table_uri: String,
132 reason: String,
134 },
135
136 #[snafu(display(
138 "SQL table error during {phase}: {}",
139 sanitize_reason_for_display(message)
140 ))]
141 SqlTable {
142 phase: SqlTablePhase,
144 message: String,
146 },
147
148 #[snafu(display("dependency compatibility error: {message}"))]
150 DependencyCompatibility {
151 message: String,
153 },
154
155 #[snafu(display(
157 "batch pipeline {phase} error for option `{option}`: {}",
158 sanitize_reason_for_display(message)
159 ))]
160 BatchPipeline {
161 phase: BatchPipelinePhase,
163 option: &'static str,
165 message: String,
167 },
168
169 #[snafu(display(
171 "MSSQL target configuration error for option `{option}`: {}",
172 sanitize_reason_for_display(message)
173 ))]
174 MssqlTargetConfig {
175 option: &'static str,
177 message: String,
179 },
180
181 #[snafu(display(
183 "MSSQL target for output `{}` has no effective connection",
184 sanitize_text_for_display(output_name)
185 ))]
186 MissingMssqlConnection {
187 output_name: String,
189 },
190
191 #[snafu(display(
193 "MSSQL schema planning error for output `{}`: {reason}",
194 sanitize_text_for_display(output_name)
195 ))]
196 InvalidMssqlOutputIdentity {
197 output_name: String,
199 reason: &'static str,
201 },
202
203 #[snafu(display(
205 "MSSQL schema planning error for output `{}`: duplicate field name `{}` at indexes {first_index} and {duplicate_index}",
206 sanitize_text_for_display(output_name),
207 sanitize_text_for_display(field_name)
208 ))]
209 DuplicateMssqlOutputField {
210 output_name: String,
212 field_name: String,
214 first_index: usize,
216 duplicate_index: usize,
218 },
219
220 #[snafu(display(
222 "MSSQL schema planning error for output `{}`: arrow-sql-server returned {} diagnostic(s)",
223 sanitize_text_for_display(output_name),
224 diagnostics.len()
225 ))]
226 MssqlSchemaPlanning {
227 output_name: String,
229 diagnostics: arrow_sql_server::DiagnosticSet,
231 },
232
233 #[snafu(display(
235 "MSSQL schema planning error for output `{}`: {}",
236 sanitize_text_for_display(output_name),
237 sanitize_reason_for_display(&source.to_string())
238 ))]
239 MssqlSchemaPlanningFailed {
240 output_name: String,
242 source: arrow_sql_server::Error,
244 },
245
246 #[snafu(display(
248 "MSSQL DDL planning error for output `{}`: {}",
249 sanitize_text_for_display(output_name),
250 sanitize_reason_for_display(&source.to_string())
251 ))]
252 MssqlDdlTargetIdentifier {
253 output_name: String,
255 source: arrow_sql_server::Error,
257 },
258
259 #[snafu(display(
261 "MSSQL DDL planning error for output `{}`: {}",
262 sanitize_text_for_display(output_name),
263 sanitize_reason_for_display(message)
264 ))]
265 MssqlDdlPlanning {
266 output_name: String,
268 message: String,
270 },
271
272 #[snafu(display(
274 "MSSQL lifecycle planning error for output `{}`: {}",
275 sanitize_text_for_display(output_name),
276 sanitize_reason_for_display(message)
277 ))]
278 MssqlLifecyclePlanning {
279 output_name: String,
281 message: String,
283 },
284
285 #[snafu(display(
287 "MSSQL write error: {}",
288 sanitize_reason_for_display(&source.to_string())
289 ))]
290 MssqlWrite {
291 source: arrow_sql_server::Error,
293 },
294
295 #[snafu(display(
297 "MSSQL write error for output `{}` during {}: {}",
298 sanitize_text_for_display(context.output_name()),
299 context.phase(),
300 sanitize_reason_for_display(message)
301 ))]
302 MssqlWritePhase {
303 context: Box<crate::MssqlWriteFailureContext>,
305 message: String,
307 },
308
309 #[snafu(display(
311 "MSSQL write error for output `{}` during {}: {}",
312 sanitize_text_for_display(context.output_name()),
313 context.phase(),
314 sanitize_reason_for_display(&source.to_string())
315 ))]
316 MssqlQueryPhase {
317 context: Box<crate::MssqlWriteFailureContext>,
319 source: Box<DeltaFunnelError>,
321 },
322
323 #[snafu(display(
325 "MSSQL write error for output `{}` during {}: {}",
326 sanitize_text_for_display(context.output_name()),
327 context.phase(),
328 sanitize_reason_for_display(&source.to_string())
329 ))]
330 MssqlBatchSchemaValidation {
331 context: Box<crate::MssqlWriteFailureContext>,
333 source: arrow_sql_server::Error,
335 },
336
337 #[snafu(display(
339 "MSSQL workflow planning error: {}",
340 sanitize_reason_for_display(message)
341 ))]
342 MssqlWorkflowPlanning {
343 message: String,
345 },
346}
347
348#[derive(Debug, Clone, Copy, PartialEq, Eq)]
350pub enum SqlTablePhase {
351 ValidateSql,
353 PlanSql,
355 RegisterDerivedAlias,
357}
358
359impl std::fmt::Display for SqlTablePhase {
360 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
361 formatter.write_str(match self {
362 Self::ValidateSql => "SQL validation",
363 Self::PlanSql => "SQL planning",
364 Self::RegisterDerivedAlias => "derived alias registration",
365 })
366 }
367}
368
369fn sanitize_source_name_for_display(name: &str) -> String {
370 sanitize_text_for_display(name)
371}
372
373fn sanitize_reason_for_display(reason: &str) -> String {
374 sanitize_text_for_display(reason)
375}
376
377#[cfg(test)]
378mod tests {
379 use std::error::Error;
380
381 use super::DeltaFunnelError;
382
383 #[test]
384 fn config_error_has_sanitized_display() {
385 let error = DeltaFunnelError::Config {
386 message: "max_concurrent_file_reads_per_scan must be greater than zero".to_owned(),
387 };
388
389 assert_eq!(
390 error.to_string(),
391 "configuration error: max_concurrent_file_reads_per_scan must be greater than zero"
392 );
393 }
394
395 #[test]
396 fn preview_failure_sanitizes_display_and_preserves_source() {
397 let context = crate::PreviewFailureContext::new(
398 "preview_dataframe_planning".to_owned(),
399 vec![crate::PhaseTimingReport::failed(
400 "preview_dataframe_planning",
401 std::time::Duration::ZERO,
402 )],
403 None,
404 );
405 let error = DeltaFunnelError::PreviewFailed {
406 context: Box::new(context),
407 source: Box::new(DeltaFunnelError::Config {
408 message: "planning failed\nfor test".to_owned(),
409 }),
410 };
411
412 let display = error.to_string();
413
414 assert!(!display.contains('\n'));
415 assert!(display.contains(r"planning failed\nfor test"));
416 assert!(Error::source(&error).is_some());
417 }
418
419 #[test]
420 fn dependency_error_has_sanitized_display() {
421 let error = DeltaFunnelError::DependencyCompatibility {
422 message: "delta_kernel API smoke test failed".to_owned(),
423 };
424
425 assert_eq!(
426 error.to_string(),
427 "dependency compatibility error: delta_kernel API smoke test failed"
428 );
429 }
430
431 #[test]
432 fn batch_pipeline_error_has_sanitized_display() {
433 let error = DeltaFunnelError::BatchPipeline {
434 phase: super::BatchPipelinePhase::Configuration,
435 option: "output_batch_size",
436 message: "must be greater than zero".to_owned(),
437 };
438
439 assert_eq!(
440 error.to_string(),
441 "batch pipeline configuration error for option `output_batch_size`: must be greater than zero"
442 );
443 }
444
445 #[test]
446 fn batch_pipeline_error_display_escapes_control_characters() {
447 let error = DeltaFunnelError::BatchPipeline {
448 phase: super::BatchPipelinePhase::HandoffSetup,
449 option: "consumer_capacity",
450 message: "invalid\nvalue\tprovided".to_owned(),
451 };
452
453 let display = error.to_string();
454
455 assert!(!display.contains('\n'));
456 assert!(!display.contains('\t'));
457 assert!(display.contains(r"invalid\nvalue\tprovided"));
458 }
459
460 #[test]
461 fn mssql_write_error_has_sanitized_display() {
462 let error = DeltaFunnelError::MssqlWrite {
463 source: arrow_sql_server::Error::BackendUnavailable {
464 backend: arrow_sql_server::WriteBackend::DirectRawBulk,
465 reason: "not available\nfor test".to_owned(),
466 },
467 };
468
469 let display = error.to_string();
470
471 assert!(!display.contains('\n'));
472 assert!(display.contains(r"not available\nfor test"));
473 }
474
475 #[test]
476 fn mssql_write_phase_error_has_sanitized_display_and_context() -> Result<(), DeltaFunnelError> {
477 let connection = crate::MssqlConnectionConfig::new(
478 "server=tcp:sql.example.com;database=warehouse;user=admin;password=secret-token",
479 )?
480 .with_display_label("warehouse-primary");
481 let target_config =
482 crate::MssqlTargetConfig::new(crate::MssqlTargetTable::new("dbo", "orders")?);
483 let schema = arrow_schema::Schema::new(vec![arrow_schema::Field::new(
484 "order_id",
485 arrow_schema::DataType::Int64,
486 false,
487 )]);
488 let output_plan = crate::plan_mssql_target_for_output(
489 schema,
490 "orders_output",
491 &target_config,
492 Some(&connection),
493 arrow_sql_server::PlanOptions::default(),
494 )?;
495 let context = crate::MssqlWriteFailureContext::from_output_plan(
496 &output_plan,
497 crate::MssqlWritePhase::WriteBatch,
498 42,
499 3,
500 125,
501 true,
502 crate::MssqlTargetCleanupStatus::NotApplicable,
503 );
504
505 let error = DeltaFunnelError::MssqlWritePhase {
506 context: Box::new(context),
507 message: "batch failed\nwhile writing".to_owned(),
508 };
509
510 let display = error.to_string();
511
512 assert!(display.contains("orders_output"));
513 assert!(display.contains("write batch"));
514 assert!(!display.contains('\n'));
515 assert!(display.contains(r"batch failed\nwhile writing"));
516 assert!(!display.contains("secret-token"));
517 assert!(!display.contains("server=tcp"));
518 let DeltaFunnelError::MssqlWritePhase { context, .. } = error else {
519 return Err(DeltaFunnelError::Config {
520 message: "expected MssqlWritePhase error".to_owned(),
521 });
522 };
523 assert_eq!(context.phase(), crate::MssqlWritePhase::WriteBatch);
524 assert_eq!(context.output_name(), "orders_output");
525 assert_eq!(context.stats().rows_written(), 42);
526 assert!(context.partial_write_possible());
527 Ok(())
528 }
529
530 #[test]
531 fn mssql_batch_schema_validation_error_has_sanitized_display_and_context()
532 -> Result<(), DeltaFunnelError> {
533 let connection = crate::MssqlConnectionConfig::new(
534 "server=tcp:sql.example.com;database=warehouse;user=admin;password=secret-token",
535 )?
536 .with_display_label("warehouse-primary");
537 let target_config =
538 crate::MssqlTargetConfig::new(crate::MssqlTargetTable::new("dbo", "orders")?);
539 let schema = arrow_schema::Schema::new(vec![arrow_schema::Field::new(
540 "order_id",
541 arrow_schema::DataType::Int64,
542 false,
543 )]);
544 let output_plan = crate::plan_mssql_target_for_output(
545 schema,
546 "orders_output",
547 &target_config,
548 Some(&connection),
549 arrow_sql_server::PlanOptions::default(),
550 )?;
551 let context = crate::MssqlWriteFailureContext::from_output_plan(
552 &output_plan,
553 crate::MssqlWritePhase::ValidateBatchSchema,
554 0,
555 0,
556 0,
557 false,
558 crate::MssqlTargetCleanupStatus::NotApplicable,
559 );
560
561 let error = DeltaFunnelError::MssqlBatchSchemaValidation {
562 context: Box::new(context),
563 source: arrow_sql_server::Error::BackendUnavailable {
564 backend: arrow_sql_server::WriteBackend::DirectRawBulk,
565 reason: "schema mismatch\nfor test".to_owned(),
566 },
567 };
568
569 let display = error.to_string();
570
571 assert!(display.contains("orders_output"));
572 assert!(display.contains("validate batch schema"));
573 assert!(!display.contains('\n'));
574 assert!(display.contains(r"schema mismatch\nfor test"));
575 assert!(!display.contains("secret-token"));
576 assert!(!display.contains("server=tcp"));
577 let DeltaFunnelError::MssqlBatchSchemaValidation { context, source } = error else {
578 return Err(DeltaFunnelError::Config {
579 message: "expected MssqlBatchSchemaValidation error".to_owned(),
580 });
581 };
582 assert_eq!(context.phase(), crate::MssqlWritePhase::ValidateBatchSchema);
583 assert_eq!(context.output_name(), "orders_output");
584 assert_eq!(context.stats().rows_written(), 0);
585 assert!(!context.partial_write_possible());
586 assert!(matches!(
587 source,
588 arrow_sql_server::Error::BackendUnavailable { .. }
589 ));
590 Ok(())
591 }
592
593 #[test]
594 fn invalid_source_name_error_has_sanitized_display() {
595 let error = DeltaFunnelError::InvalidSourceName {
596 name: "orders.latest".to_owned(),
597 reason: "source names may contain only ASCII letters, digits, and underscores",
598 };
599
600 assert_eq!(
601 error.to_string(),
602 "invalid Delta source name `orders.latest`: source names may contain only ASCII letters, digits, and underscores"
603 );
604 }
605
606 #[test]
607 fn invalid_source_name_display_escapes_control_characters() {
608 let error = DeltaFunnelError::InvalidSourceName {
609 name: "orders\nlatest\tname".to_owned(),
610 reason: "source names may contain only ASCII letters, digits, and underscores",
611 };
612
613 let display = error.to_string();
614
615 assert!(!display.contains('\n'));
616 assert!(!display.contains('\t'));
617 assert!(display.contains(r"orders\nlatest\tname"));
618 }
619
620 #[test]
621 fn duplicate_source_name_error_has_sanitized_display() {
622 let error = DeltaFunnelError::DuplicateSourceName {
623 name: "Orders".to_owned(),
624 };
625
626 assert_eq!(error.to_string(), "duplicate Delta source name `Orders`");
627 }
628
629 #[test]
630 fn invalid_source_uri_error_has_sanitized_display() {
631 let error = DeltaFunnelError::InvalidSourceUri {
632 reason: "table location could not be parsed or normalized",
633 };
634
635 assert_eq!(
636 error.to_string(),
637 "invalid Delta source URI: table location could not be parsed or normalized"
638 );
639 }
640
641 #[test]
642 fn source_engine_error_has_sanitized_display() {
643 let error = DeltaFunnelError::DeltaSourceEngine {
644 reason: "object store engine could not be constructed",
645 };
646
647 assert_eq!(
648 error.to_string(),
649 "Delta source engine error: object store engine could not be constructed"
650 );
651 }
652
653 #[test]
654 fn snapshot_load_error_has_sanitized_display() {
655 let error = DeltaFunnelError::DeltaSnapshotLoad {
656 reason: "snapshot could not be loaded".to_owned(),
657 };
658
659 assert_eq!(
660 error.to_string(),
661 "Delta snapshot load error: snapshot could not be loaded"
662 );
663 }
664
665 #[test]
666 fn protocol_compatibility_error_has_sanitized_display() {
667 let error = DeltaFunnelError::DeltaProtocolCompatibility {
668 source_name: "orders\nlatest".to_owned(),
669 table_uri: "s3://user:password@example.com/table?token=secret".to_owned(),
670 snapshot_version: 7,
671 reason: "unsupported Delta reader feature `deletionVectors`".to_owned(),
672 };
673
674 let display = error.to_string();
675
676 assert!(display.contains(r"orders\nlatest"));
677 assert!(display.contains("snapshot version 7"));
678 assert!(display.contains("s3://example.com/table"));
679 assert!(display.contains("deletionVectors"));
680 assert!(!display.contains('\n'));
681 assert!(!display.contains("user"));
682 assert!(!display.contains("password"));
683 assert!(!display.contains("token"));
684 assert!(!display.contains("secret"));
685 }
686
687 #[test]
688 fn source_schema_error_has_sanitized_display() {
689 let error = DeltaFunnelError::DeltaSourceSchema {
690 source_name: "orders\nlatest".to_owned(),
691 table_uri: "s3://user:password@example.com/table?token=secret".to_owned(),
692 reason: "field\nname could not be converted".to_owned(),
693 };
694
695 let display = error.to_string();
696
697 assert!(display.contains(r"orders\nlatest"));
698 assert!(display.contains("s3://example.com/table"));
699 assert!(display.contains(r"field\nname"));
700 assert!(!display.contains('\n'));
701 assert!(!display.contains("user"));
702 assert!(!display.contains("password"));
703 assert!(!display.contains("token"));
704 assert!(!display.contains("secret"));
705 }
706
707 #[test]
708 fn datafusion_registration_error_has_sanitized_display() {
709 let error = DeltaFunnelError::DataFusionRegistration {
710 source_name: "orders\nlatest".to_owned(),
711 table_uri: "s3://user:password@example.com/table?token=secret".to_owned(),
712 reason: "table\nalready exists".to_owned(),
713 };
714
715 let display = error.to_string();
716
717 assert!(display.contains(r"orders\nlatest"));
718 assert!(display.contains("s3://example.com/table"));
719 assert!(display.contains(r"table\nalready exists"));
720 assert!(!display.contains('\n'));
721 assert!(!display.contains("user"));
722 assert!(!display.contains("password"));
723 assert!(!display.contains("token"));
724 assert!(!display.contains("secret"));
725 }
726}