1use std::str::FromStr;
52use std::sync::Arc;
53
54use async_trait::async_trait;
55use faucet_core::FaucetError;
56use iceberg::io::FileIO;
57use iceberg::spec::{DataFile, Transform, UnboundPartitionSpec};
58use iceberg::table::Table;
59use iceberg::transaction::{AddColumn, ApplyTransactionAction, Transaction};
60use iceberg::{Catalog, ErrorKind, NamespaceIdent, TableCreation, TableIdent};
61use serde_json::Value;
62use tokio::sync::Mutex;
63
64use crate::catalog::build_catalog;
65use crate::config::{IcebergSinkConfig, PartitionField};
66use crate::schema::{
67 arrow_to_iceberg_schema, arrow_to_json_schema, iceberg_to_arrow_schema, infer_arrow_schema,
68 json_to_record_batch,
69};
70use crate::writer::{TableWriter, compression_from_str};
71
72struct SinkState {
79 table: Option<Table>,
82
83 writer: Option<TableWriter>,
85
86 pending_files: Vec<DataFile>,
88
89 pending_commit: Option<(String, String)>,
92}
93
94impl SinkState {
95 fn new(preloaded: Option<Table>) -> Self {
96 Self {
97 table: preloaded,
98 writer: None,
99 pending_files: Vec::new(),
100 pending_commit: None,
101 }
102 }
103}
104
105pub struct IcebergSink {
112 config: IcebergSinkConfig,
113 catalog: Arc<dyn Catalog>,
114 state: Mutex<SinkState>,
115}
116
117impl std::fmt::Debug for IcebergSink {
118 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119 f.debug_struct("IcebergSink")
120 .field("config", &self.config)
121 .finish_non_exhaustive()
122 }
123}
124
125impl IcebergSink {
126 pub async fn new(config: IcebergSinkConfig) -> Result<Self, FaucetError> {
131 config.validate()?;
132
133 if config.write_mode != faucet_core::WriteMode::Append {
134 return Err(FaucetError::Config(format!(
135 "iceberg sink: write_mode '{}' is not supported (append only; \
136 upsert is a version-gated follow-up tracked in #179 / #190)",
137 config.write_mode.as_str()
138 )));
139 }
140
141 let catalog = build_catalog(&config.catalog).await?;
142
143 let preloaded: Option<Table> = if !config.create_if_missing {
144 let ns = NamespaceIdent::from_strs(config.namespace.iter().map(String::as_str))
147 .map_err(|e| FaucetError::Sink(format!("iceberg: invalid namespace: {e}")))?;
148 let tid = TableIdent::new(ns, config.table.clone());
149 let table = catalog.load_table(&tid).await.map_err(|e| {
150 FaucetError::Sink(format!(
151 "iceberg: table '{}' does not exist and create_if_missing is false: {e}",
152 config.table
153 ))
154 })?;
155 Some(table)
156 } else {
157 None
158 };
159
160 Ok(Self {
161 config,
162 catalog,
163 state: Mutex::new(SinkState::new(preloaded)),
164 })
165 }
166
167 fn build_partition_spec(
173 pfs: &[PartitionField],
174 iceberg_schema: &iceberg::spec::Schema,
175 ) -> Result<Option<UnboundPartitionSpec>, FaucetError> {
176 if pfs.is_empty() {
177 return Ok(None);
178 }
179
180 let struct_type = iceberg_schema.as_struct();
181 let mut builder = UnboundPartitionSpec::builder();
182
183 for pf in pfs {
184 let field_ref = struct_type.field_by_name(&pf.source).ok_or_else(|| {
186 FaucetError::Config(format!(
187 "iceberg: partition source column {:?} not found in schema",
188 pf.source
189 ))
190 })?;
191
192 let transform = Transform::from_str(&pf.transform).map_err(|e| {
193 FaucetError::Config(format!(
194 "iceberg: invalid transform {:?}: {e}",
195 pf.transform
196 ))
197 })?;
198
199 builder = builder
200 .add_partition_field(field_ref.id, pf.source.clone(), transform)
201 .map_err(|e| {
202 FaucetError::Config(format!(
203 "iceberg: could not add partition field {:?}: {e}",
204 pf.source
205 ))
206 })?;
207 }
208
209 Ok(Some(builder.build()))
210 }
211
212 async fn resolve_table(
218 &self,
219 state: &mut SinkState,
220 records: &[Value],
221 ) -> Result<Table, FaucetError> {
222 if let Some(ref table) = state.table {
223 return Ok(table.clone());
224 }
225
226 let arrow_schema = infer_arrow_schema(records, records.len().min(100))?;
228 let iceberg_schema = arrow_to_iceberg_schema(&arrow_schema)?;
229
230 let ns = NamespaceIdent::from_strs(self.config.namespace.iter().map(String::as_str))
231 .map_err(|e| FaucetError::Sink(format!("iceberg: invalid namespace: {e}")))?;
232 let table_name = self.config.table.clone();
233
234 let table_ident = TableIdent::new(ns.clone(), table_name.clone());
235
236 let table =
237 if self.catalog.table_exists(&table_ident).await.map_err(|e| {
238 FaucetError::Sink(format!("iceberg: table_exists check failed: {e}"))
239 })? {
240 self.catalog
242 .load_table(&table_ident)
243 .await
244 .map_err(|e| FaucetError::Sink(format!("iceberg: load_table failed: {e}")))?
245 } else {
246 let ns_exists = self.catalog.namespace_exists(&ns).await.map_err(|e| {
253 FaucetError::Sink(format!("iceberg: namespace_exists check failed: {e}"))
254 })?;
255 if !ns_exists {
256 self.catalog
257 .create_namespace(&ns, std::collections::HashMap::new())
258 .await
259 .map_err(|e| {
260 FaucetError::Sink(format!(
261 "iceberg: create_namespace {:?} failed: {e}",
262 self.config.namespace
263 ))
264 })?;
265 }
266
267 let partition_spec =
269 Self::build_partition_spec(&self.config.partition_spec, &iceberg_schema)?;
270
271 let creation = if let Some(ps) = partition_spec {
276 TableCreation::builder()
277 .name(table_name)
278 .schema(iceberg_schema)
279 .partition_spec(ps)
280 .properties(self.config.snapshot_properties.clone())
281 .build()
282 } else {
283 TableCreation::builder()
284 .name(table_name)
285 .schema(iceberg_schema)
286 .properties(self.config.snapshot_properties.clone())
287 .build()
288 };
289
290 self.catalog
291 .create_table(&ns, creation)
292 .await
293 .map_err(|e| FaucetError::Sink(format!("iceberg: create_table failed: {e}")))?
294 };
295
296 state.table = Some(table.clone());
297 Ok(table)
298 }
299
300 async fn ensure_writer(&self, state: &mut SinkState, table: &Table) -> Result<(), FaucetError> {
303 if state.writer.is_none() {
304 let compression = compression_from_str(&self.config.parquet.compression)?;
305 let writer =
306 TableWriter::new(table, compression, self.config.target_file_size_mb).await?;
307 state.writer = Some(writer);
308 }
309 Ok(())
310 }
311
312 async fn write_chunk(
317 &self,
318 state: &mut SinkState,
319 records: &[Value],
320 ) -> Result<usize, FaucetError> {
321 if records.is_empty() {
322 return Ok(0);
323 }
324
325 let table = self.resolve_table(state, records).await?;
326 self.ensure_writer(state, &table).await?;
327
328 let arrow_schema = iceberg_to_arrow_schema(table.metadata().current_schema())?;
330 let batch = json_to_record_batch(records, &arrow_schema)?;
331 let row_count = batch.num_rows();
332
333 let writer = state.writer.as_mut().expect("writer is set above");
334 writer.write(batch).await?;
335
336 Ok(row_count)
337 }
338
339 async fn close_writer(state: &mut SinkState) -> Result<(), FaucetError> {
342 if let Some(writer) = state.writer.take() {
343 let files = writer.close().await?;
344 state.pending_files.extend(files);
345 }
346 Ok(())
347 }
348
349 async fn load_table_readonly(&self) -> Result<Option<Table>, FaucetError> {
355 let ns = NamespaceIdent::from_strs(self.config.namespace.iter().map(String::as_str))
356 .map_err(|e| FaucetError::Sink(format!("iceberg: invalid namespace: {e}")))?;
357 let tid = TableIdent::new(ns, self.config.table.clone());
358
359 let exists =
360 self.catalog.table_exists(&tid).await.map_err(|e| {
361 FaucetError::Sink(format!("iceberg: table_exists check failed: {e}"))
362 })?;
363
364 if !exists {
365 return Ok(None);
366 }
367
368 let table = self
369 .catalog
370 .load_table(&tid)
371 .await
372 .map_err(|e| FaucetError::Sink(format!("iceberg: load_table failed: {e}")))?;
373
374 Ok(Some(table))
375 }
376
377 async fn commit_pending(&self, state: &mut SinkState) -> Result<(), FaucetError> {
391 let files = std::mem::take(&mut state.pending_files);
392
393 if files.is_empty() {
394 return Ok(());
396 }
397
398 let table = state
399 .table
400 .as_ref()
401 .ok_or_else(|| {
402 FaucetError::Sink(
403 "iceberg: flush called with pending files but no table loaded".to_string(),
404 )
405 })?
406 .clone();
407
408 let file_paths: Vec<String> = files.iter().map(|f| f.file_path().to_string()).collect();
412
413 let tx = Transaction::new(&table);
414
415 let mut props = self.config.snapshot_properties.clone();
419 if let Some((scope, token)) = state.pending_commit.take() {
420 props.insert(
421 faucet_core::idempotency::ICEBERG_SCOPE_PROP.to_string(),
422 scope,
423 );
424 props.insert(
425 faucet_core::idempotency::ICEBERG_TOKEN_PROP.to_string(),
426 token,
427 );
428 }
429
430 let mut action = tx.fast_append().add_data_files(files);
431 if !props.is_empty() {
432 action = action.set_snapshot_properties(props);
433 }
434
435 let tx = match action.apply(tx) {
436 Ok(tx) => tx,
437 Err(e) => {
438 maybe_cleanup_orphans(
441 table.file_io(),
442 &self.config.table,
443 self.config.cleanup_orphans_on_failure,
444 true,
445 &file_paths,
446 )
447 .await;
448 return Err(FaucetError::Sink(format!(
449 "iceberg: fast_append apply failed: {e}"
450 )));
451 }
452 };
453
454 let updated_table = match tx.commit(self.catalog.as_ref()).await {
455 Ok(updated_table) => updated_table,
456 Err(e) => {
457 maybe_cleanup_orphans(
458 table.file_io(),
459 &self.config.table,
460 self.config.cleanup_orphans_on_failure,
461 commit_failure_is_definite_loss(e.kind()),
462 &file_paths,
463 )
464 .await;
465 return Err(FaucetError::Sink(format!(
466 "iceberg: transaction commit failed ({}): {e}",
467 e.kind()
468 )));
469 }
470 };
471
472 state.table = Some(updated_table);
475 Ok(())
476 }
477}
478
479pub(crate) async fn maybe_cleanup_orphans(
486 file_io: &FileIO,
487 table_name: &str,
488 enabled: bool,
489 definite_loss: bool,
490 file_paths: &[String],
491) {
492 if !enabled {
493 tracing::warn!(
494 table = %table_name,
495 orphans = file_paths.len(),
496 "iceberg: commit failed; {} data file(s) orphaned. Set \
497 cleanup_orphans_on_failure to delete them automatically, or run \
498 Iceberg's remove_orphan_files maintenance.",
499 file_paths.len()
500 );
501 return;
502 }
503
504 if !definite_loss {
505 tracing::warn!(
506 table = %table_name,
507 orphans = file_paths.len(),
508 "iceberg: commit outcome ambiguous; NOT deleting {} data file(s) \
509 (a possibly-succeeded commit may reference them). Run \
510 remove_orphan_files if the commit is confirmed failed.",
511 file_paths.len()
512 );
513 return;
514 }
515
516 let (deleted, failed) = delete_data_files(file_io, file_paths).await;
517 tracing::info!(
518 table = %table_name,
519 deleted,
520 failed,
521 "iceberg: cleaned up orphaned data files after a definitive commit failure"
522 );
523}
524
525pub(crate) fn commit_failure_is_definite_loss(kind: ErrorKind) -> bool {
536 matches!(
537 kind,
538 ErrorKind::CatalogCommitConflicts | ErrorKind::DataInvalid
539 )
540}
541
542fn evolve_schema_err(e: iceberg::Error) -> FaucetError {
546 FaucetError::Sink(format!(
547 "iceberg: schema evolution failed ({}): {e}",
548 e.kind()
549 ))
550}
551
552pub(crate) fn max_token_for_scope<'a, I>(snapshots: I, scope: &str) -> Option<String>
565where
566 I: IntoIterator<Item = (Option<&'a str>, Option<&'a str>)>,
567{
568 snapshots
569 .into_iter()
570 .filter(|(snap_scope, _)| *snap_scope == Some(scope))
571 .filter_map(|(_, token)| {
572 let token = token?;
573 let seq = faucet_core::idempotency::parse_token(token)?;
574 Some((seq, token.to_string()))
575 })
576 .max_by_key(|(seq, _)| *seq)
577 .map(|(_, token)| token)
578}
579
580pub(crate) async fn delete_data_files(file_io: &FileIO, paths: &[String]) -> (usize, usize) {
587 let mut deleted = 0usize;
588 let mut failed = 0usize;
589 for path in paths {
590 match file_io.delete(path).await {
591 Ok(()) => deleted += 1,
592 Err(e) => {
593 failed += 1;
594 tracing::warn!(path = %path, error = %e, "iceberg: failed to delete orphaned data file");
595 }
596 }
597 }
598 (deleted, failed)
599}
600
601#[async_trait]
604impl faucet_core::Sink for IcebergSink {
605 fn connector_name(&self) -> &'static str {
606 "iceberg"
607 }
608
609 fn config_schema(&self) -> Value {
610 serde_json::to_value(faucet_core::schema_for!(IcebergSinkConfig))
611 .expect("schema serialization infallible")
612 }
613
614 fn dataset_uri(&self) -> String {
615 use crate::config::CatalogConfig;
616 let kind = match &self.config.catalog {
617 CatalogConfig::Rest(_) => "rest",
618 CatalogConfig::Glue(_) => "glue",
619 CatalogConfig::Sql(_) => "sql",
620 CatalogConfig::Hms(_) => "hms",
621 };
622 format!(
623 "iceberg://{}/{}.{}",
624 kind,
625 self.config.namespace.join("."),
626 self.config.table
627 )
628 }
629
630 async fn check(
636 &self,
637 ctx: &faucet_core::check::CheckContext,
638 ) -> Result<faucet_core::check::CheckReport, FaucetError> {
639 use faucet_core::check::{CheckReport, Probe};
640
641 let started = std::time::Instant::now();
642
643 let ns_result = NamespaceIdent::from_strs(self.config.namespace.iter().map(String::as_str));
644 let tid_result = ns_result.map(|ns| TableIdent::new(ns, self.config.table.clone()));
645
646 let tid = match tid_result {
647 Err(e) => {
648 return Ok(CheckReport::single(Probe::fail(
649 "catalog",
650 started.elapsed(),
651 format!("iceberg: invalid namespace config: {e}"),
652 )));
653 }
654 Ok(t) => t,
655 };
656
657 let probe_result = tokio::time::timeout(ctx.timeout, self.catalog.table_exists(&tid)).await;
658
659 let probe = match probe_result {
660 Ok(Ok(_exists)) => Probe::pass("catalog", started.elapsed()),
664 Ok(Err(e)) => Probe::fail_hint(
665 "catalog",
666 started.elapsed(),
667 format!("iceberg catalog probe failed: {e}"),
668 "Verify the catalog URI, credentials, and network reachability.",
669 ),
670 Err(_elapsed) => Probe::fail_hint(
671 "catalog",
672 started.elapsed(),
673 format!("iceberg catalog probe timed out after {:?}", ctx.timeout),
674 "Check network reachability to the catalog endpoint.",
675 ),
676 };
677
678 Ok(CheckReport::single(probe))
679 }
680
681 fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
682 &[faucet_core::WriteMode::Append]
685 }
686
687 async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
698 let table = match self.load_table_readonly().await? {
699 Some(t) => t,
700 None => return Ok(None),
701 };
702 let arrow_schema = iceberg_to_arrow_schema(table.metadata().current_schema())?;
703 Ok(Some(arrow_to_json_schema(&arrow_schema)))
704 }
705
706 fn supports_schema_evolution(&self) -> bool {
710 true
711 }
712
713 async fn evolve_schema(
723 &self,
724 evolution: &faucet_core::SchemaEvolution,
725 ) -> Result<(), FaucetError> {
726 if !evolution.widenings.is_empty() || !evolution.relax_nullability.is_empty() {
727 return Err(FaucetError::Sink(format!(
728 "iceberg: additive schema evolution supports new columns only in iceberg-rust \
729 0.10.0 (no in-place type promotion or nullability relaxation); requested \
730 {} widening(s) + {} nullability relaxation(s)",
731 evolution.widenings.len(),
732 evolution.relax_nullability.len()
733 )));
734 }
735 if evolution.additions.is_empty() {
736 return Ok(());
737 }
738 let table = match self.load_table_readonly().await? {
741 Some(t) => t,
742 None => return Ok(()),
743 };
744
745 let tx = Transaction::new(&table);
746 let mut action = tx.update_schema();
747 for add in &evolution.additions {
748 let field_type = crate::schema::json_fragment_to_iceberg_type(&add.to)?;
749 action = action.add_column(AddColumn::optional(&add.name, field_type));
750 }
751 let tx = action.apply(tx).map_err(evolve_schema_err)?;
752 tx.commit(self.catalog.as_ref())
753 .await
754 .map_err(evolve_schema_err)?;
755 Ok(())
756 }
757
758 fn supports_idempotent_writes(&self) -> bool {
759 true
760 }
761
762 async fn write_batch_idempotent(
771 &self,
772 records: &[Value],
773 scope: &str,
774 token: &str,
775 ) -> Result<usize, FaucetError> {
776 let n = self.write_batch(records).await?;
777 let mut state = self.state.lock().await;
778 state.pending_commit = Some((scope.to_string(), token.to_string()));
779 Ok(n)
780 }
781
782 async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
796 let table = match self.load_table_readonly().await? {
797 Some(t) => t,
798 None => return Ok(None),
799 };
800
801 let meta = table.metadata();
802 let props = meta.snapshots().map(|s| {
803 let summary = s.summary();
804 (
805 summary
806 .additional_properties
807 .get(faucet_core::idempotency::ICEBERG_SCOPE_PROP)
808 .map(String::as_str),
809 summary
810 .additional_properties
811 .get(faucet_core::idempotency::ICEBERG_TOKEN_PROP)
812 .map(String::as_str),
813 )
814 });
815
816 Ok(max_token_for_scope(props, scope))
817 }
818
819 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
824 if records.is_empty() {
825 return Ok(0);
826 }
827
828 let mut state = self.state.lock().await;
829
830 let chunk_size = self.config.batch_size;
831 let mut total = 0usize;
832
833 if chunk_size == 0 || records.len() <= chunk_size {
834 total += self.write_chunk(&mut state, records).await?;
835 } else {
836 for chunk in records.chunks(chunk_size) {
837 total += self.write_chunk(&mut state, chunk).await?;
838 }
839 }
840
841 tracing::debug!(
842 table = %self.config.table,
843 rows = total,
844 "iceberg: write_batch complete"
845 );
846
847 Ok(total)
848 }
849
850 async fn flush(&self) -> Result<(), FaucetError> {
856 let mut state = self.state.lock().await;
857
858 Self::close_writer(&mut state).await?;
860
861 self.commit_pending(&mut state).await?;
863
864 tracing::debug!(table = %self.config.table, "iceberg: flush complete");
865 Ok(())
866 }
867
868 async fn write_batch_partial(
883 &self,
884 records: &[Value],
885 ) -> Result<Vec<faucet_core::RowOutcome>, FaucetError> {
886 if records.is_empty() {
887 return Ok(Vec::new());
888 }
889
890 let mut state = self.state.lock().await;
891
892 let table = self.resolve_table(&mut state, records).await?;
895 let arrow_schema = iceberg_to_arrow_schema(table.metadata().current_schema())?;
896
897 let mut outcomes: Vec<faucet_core::RowOutcome> = Vec::with_capacity(records.len());
899 let mut good_records: Vec<Value> = Vec::with_capacity(records.len());
900 let mut good_indices: Vec<usize> = Vec::with_capacity(records.len());
901
902 for (i, record) in records.iter().enumerate() {
903 match json_to_record_batch(std::slice::from_ref(record), &arrow_schema) {
904 Ok(_) => {
905 good_records.push(record.clone());
906 good_indices.push(i);
907 outcomes.push(Ok(()));
909 }
910 Err(e) => {
911 outcomes.push(Err(FaucetError::Sink(format!(
912 "iceberg: row {i} failed Arrow conversion: {e}"
913 ))));
914 }
915 }
916 }
917
918 if !good_records.is_empty() {
920 self.ensure_writer(&mut state, &table).await?;
921 let batch = json_to_record_batch(&good_records, &arrow_schema)?;
922 let writer = state.writer.as_mut().expect("writer set above");
923 writer.write(batch).await?;
924 }
925
926 Ok(outcomes)
927 }
928}
929
930#[cfg(test)]
933mod tests {
934 use super::*;
935 use faucet_core::FaucetError;
936
937 fn minimal_config() -> IcebergSinkConfig {
941 serde_json::from_value(serde_json::json!({
942 "catalog": { "type": "rest", "uri": "http://localhost:8181" },
943 "namespace": ["analytics"],
944 "table": "events",
945 "create_if_missing": true
946 }))
947 .unwrap()
948 }
949
950 #[cfg(not(feature = "catalog-glue"))]
954 #[tokio::test]
955 async fn new_with_disabled_catalog_returns_config_error() {
956 let config: IcebergSinkConfig = serde_json::from_value(serde_json::json!({
957 "catalog": { "type": "glue", "warehouse": "s3://lake/wh" },
958 "namespace": ["analytics"],
959 "table": "events"
960 }))
961 .unwrap();
962
963 let err = IcebergSink::new(config).await.unwrap_err();
964 assert!(
965 matches!(err, FaucetError::Config(_)),
966 "expected Config error, got {err:?}"
967 );
968 let msg = err.to_string();
969 assert!(
970 msg.contains("catalog-glue"),
971 "should mention the missing feature: {msg}"
972 );
973 }
974
975 #[tokio::test]
977 async fn new_rejects_empty_namespace() {
978 let config: IcebergSinkConfig = serde_json::from_value(serde_json::json!({
979 "catalog": { "type": "rest", "uri": "http://localhost:8181" },
980 "namespace": [],
981 "table": "events"
982 }))
983 .unwrap();
984
985 let err = IcebergSink::new(config).await.unwrap_err();
986 assert!(
987 matches!(err, FaucetError::Config(_)),
988 "expected Config error, got {err:?}"
989 );
990 }
991
992 #[tokio::test]
994 async fn new_rejects_empty_table_name() {
995 let config: IcebergSinkConfig = serde_json::from_value(serde_json::json!({
996 "catalog": { "type": "rest", "uri": "http://localhost:8181" },
997 "namespace": ["ns"],
998 "table": ""
999 }))
1000 .unwrap();
1001
1002 let err = IcebergSink::new(config).await.unwrap_err();
1003 assert!(matches!(err, FaucetError::Config(_)));
1004 }
1005
1006 #[test]
1009 fn default_compression_parses_ok() {
1010 let cfg = minimal_config();
1011 assert_eq!(cfg.parquet.compression, "snappy");
1012 assert!(crate::writer::compression_from_str("snappy").is_ok());
1013 }
1014
1015 #[test]
1017 fn build_partition_spec_empty_returns_none() {
1018 use crate::schema::{arrow_to_iceberg_schema, infer_arrow_schema};
1019 use serde_json::json;
1020
1021 let records = vec![json!({"id": 1, "name": "alice"})];
1022 let arrow_schema = infer_arrow_schema(&records, 10).unwrap();
1023 let iceberg_schema = arrow_to_iceberg_schema(&arrow_schema).unwrap();
1024
1025 let result = IcebergSink::build_partition_spec(&[], &iceberg_schema).unwrap();
1026 assert!(result.is_none(), "empty partition_spec should yield None");
1027 }
1028
1029 #[test]
1031 fn build_partition_spec_unknown_column_errors() {
1032 use crate::schema::{arrow_to_iceberg_schema, infer_arrow_schema};
1033 use serde_json::json;
1034
1035 let records = vec![json!({"id": 1})];
1036 let arrow_schema = infer_arrow_schema(&records, 10).unwrap();
1037 let iceberg_schema = arrow_to_iceberg_schema(&arrow_schema).unwrap();
1038
1039 let pfs = vec![PartitionField {
1040 source: "nonexistent_col".to_string(),
1041 transform: "identity".to_string(),
1042 }];
1043
1044 let err = IcebergSink::build_partition_spec(&pfs, &iceberg_schema).unwrap_err();
1045 assert!(
1046 matches!(err, FaucetError::Config(_)),
1047 "unknown column should give Config error: {err:?}"
1048 );
1049 let msg = err.to_string();
1050 assert!(
1051 msg.contains("nonexistent_col"),
1052 "error should name the bad column: {msg}"
1053 );
1054 }
1055
1056 use iceberg::ErrorKind;
1059 use iceberg::io::FileIO;
1060
1061 #[test]
1064 fn definite_loss_classification() {
1065 assert!(
1066 commit_failure_is_definite_loss(ErrorKind::CatalogCommitConflicts),
1067 "an exhausted commit conflict means our commit definitively lost"
1068 );
1069 assert!(
1070 commit_failure_is_definite_loss(ErrorKind::DataInvalid),
1071 "a catalog-rejected commit definitively did not apply"
1072 );
1073 assert!(
1075 !commit_failure_is_definite_loss(ErrorKind::Unexpected),
1076 "a transport error is ambiguous — the commit may have landed"
1077 );
1078 assert!(!commit_failure_is_definite_loss(
1079 ErrorKind::PreconditionFailed
1080 ));
1081 assert!(!commit_failure_is_definite_loss(
1082 ErrorKind::FeatureUnsupported
1083 ));
1084 }
1085
1086 async fn seed_files(io: &FileIO, dir: &std::path::Path, n: usize) -> Vec<String> {
1088 let mut paths = Vec::new();
1089 for i in 0..n {
1090 let p = format!("file://{}/orphan-{i}.parquet", dir.display());
1091 io.new_output(&p)
1092 .expect("new_output")
1093 .write(bytes::Bytes::from_static(b"parquet"))
1094 .await
1095 .expect("write orphan file");
1096 assert!(
1097 io.exists(&p).await.expect("exists check"),
1098 "seed file present"
1099 );
1100 paths.push(p);
1101 }
1102 paths
1103 }
1104
1105 #[tokio::test]
1107 async fn delete_data_files_removes_all() {
1108 let dir = tempfile::TempDir::new().expect("tempdir");
1109 let io = FileIO::new_with_fs();
1110 let paths = seed_files(&io, dir.path(), 3).await;
1111
1112 let (deleted, failed) = delete_data_files(&io, &paths).await;
1113 assert_eq!(deleted, 3);
1114 assert_eq!(failed, 0);
1115 for p in &paths {
1116 assert!(!io.exists(p).await.expect("exists check"), "file deleted");
1117 }
1118 }
1119
1120 #[tokio::test]
1126 async fn delete_data_files_idempotent_on_missing() {
1127 let dir = tempfile::TempDir::new().expect("tempdir");
1128 let io = FileIO::new_with_fs();
1129 let mut paths = seed_files(&io, dir.path(), 1).await;
1130 let present = paths[0].clone();
1131 paths.push(format!(
1132 "file://{}/never-written.parquet",
1133 dir.path().display()
1134 ));
1135
1136 let (deleted, failed) = delete_data_files(&io, &paths).await;
1137 assert_eq!(
1138 failed, 0,
1139 "idempotent delete of a missing file is not a failure"
1140 );
1141 assert_eq!(deleted, 2);
1142 assert!(
1143 !io.exists(&present).await.expect("exists check"),
1144 "the present file was deleted"
1145 );
1146 }
1147
1148 #[tokio::test]
1150 async fn maybe_cleanup_disabled_keeps_files() {
1151 let dir = tempfile::TempDir::new().expect("tempdir");
1152 let io = FileIO::new_with_fs();
1153 let paths = seed_files(&io, dir.path(), 2).await;
1154
1155 maybe_cleanup_orphans(
1156 &io, "t", false, true, &paths,
1157 )
1158 .await;
1159
1160 for p in &paths {
1161 assert!(io.exists(p).await.expect("exists check"), "disabled → kept");
1162 }
1163 }
1164
1165 #[tokio::test]
1168 async fn maybe_cleanup_ambiguous_keeps_files() {
1169 let dir = tempfile::TempDir::new().expect("tempdir");
1170 let io = FileIO::new_with_fs();
1171 let paths = seed_files(&io, dir.path(), 2).await;
1172
1173 maybe_cleanup_orphans(
1174 &io, "t", true, false, &paths,
1175 )
1176 .await;
1177
1178 for p in &paths {
1179 assert!(
1180 io.exists(p).await.expect("exists check"),
1181 "ambiguous → kept"
1182 );
1183 }
1184 }
1185
1186 #[tokio::test]
1188 async fn maybe_cleanup_enabled_definite_deletes_files() {
1189 let dir = tempfile::TempDir::new().expect("tempdir");
1190 let io = FileIO::new_with_fs();
1191 let paths = seed_files(&io, dir.path(), 2).await;
1192
1193 maybe_cleanup_orphans(
1194 &io, "t", true, true, &paths,
1195 )
1196 .await;
1197
1198 for p in &paths {
1199 assert!(
1200 !io.exists(p).await.expect("exists check"),
1201 "enabled + definite → deleted"
1202 );
1203 }
1204 }
1205
1206 #[test]
1208 fn build_partition_spec_identity_succeeds() {
1209 use crate::schema::{arrow_to_iceberg_schema, infer_arrow_schema};
1210 use serde_json::json;
1211
1212 let records = vec![json!({"id": 1, "ts": "2024-01-01"})];
1213 let arrow_schema = infer_arrow_schema(&records, 10).unwrap();
1214 let iceberg_schema = arrow_to_iceberg_schema(&arrow_schema).unwrap();
1215
1216 let pfs = vec![PartitionField {
1217 source: "id".to_string(),
1218 transform: "identity".to_string(),
1219 }];
1220
1221 let result = IcebergSink::build_partition_spec(&pfs, &iceberg_schema).unwrap();
1222 assert!(result.is_some(), "should produce a partition spec");
1223 }
1224
1225 use faucet_core::idempotency::format_token;
1228
1229 #[test]
1234 fn max_token_for_scope_returns_largest_token_ignoring_order() {
1235 let t1 = format_token(1);
1236 let t5 = format_token(5);
1237 let t10 = format_token(10);
1238 let snaps = vec![
1241 (Some("scopeA"), Some(t10.as_str())),
1242 (Some("scopeA"), Some(t1.as_str())),
1243 (Some("scopeA"), Some(t5.as_str())),
1244 ];
1245 assert_eq!(
1246 max_token_for_scope(snaps, "scopeA"),
1247 Some(t10.clone()),
1248 "must return the maximum token, not the last/newest-timestamp one"
1249 );
1250 }
1251
1252 #[test]
1257 fn max_token_for_scope_smaller_late_token_does_not_win() {
1258 let big = format_token(99);
1259 let small = format_token(3);
1260 let snaps = vec![
1261 (Some("s"), Some(big.as_str())), (Some("s"), Some(small.as_str())), ];
1264 assert_eq!(max_token_for_scope(snaps, "s"), Some(big));
1265 }
1266
1267 #[test]
1268 fn max_token_for_scope_isolates_per_scope() {
1269 let a_hi = format_token(7);
1270 let a_lo = format_token(2);
1271 let b_hi = format_token(100);
1272 let snaps = vec![
1273 (Some("a"), Some(a_lo.as_str())),
1274 (Some("b"), Some(b_hi.as_str())),
1275 (Some("a"), Some(a_hi.as_str())),
1276 ];
1277 assert_eq!(max_token_for_scope(snaps.clone(), "a"), Some(a_hi.clone()));
1278 assert_eq!(max_token_for_scope(snaps, "b"), Some(b_hi.clone()));
1279 }
1280
1281 #[test]
1282 fn max_token_for_scope_no_match_returns_none() {
1283 let t = format_token(5);
1284 let snaps = vec![(Some("other"), Some(t.as_str()))];
1285 assert_eq!(max_token_for_scope(snaps, "missing"), None);
1286 }
1287
1288 #[test]
1289 fn max_token_for_scope_single_match() {
1290 let t = format_token(42);
1291 let snaps = vec![(Some("only"), Some(t.as_str()))];
1292 assert_eq!(max_token_for_scope(snaps, "only"), Some(t));
1293 }
1294
1295 #[test]
1296 fn max_token_for_scope_empty_returns_none() {
1297 let snaps: Vec<(Option<&str>, Option<&str>)> = vec![];
1298 assert_eq!(max_token_for_scope(snaps, "any"), None);
1299 }
1300
1301 #[test]
1304 fn max_token_for_scope_skips_missing_and_garbage_tokens() {
1305 let good = format_token(8);
1306 let snaps = vec![
1307 (Some("s"), None), (Some("s"), Some("garbage")), (Some("s"), Some(good.as_str())),
1310 ];
1311 assert_eq!(max_token_for_scope(snaps, "s"), Some(good));
1312 }
1313
1314 #[test]
1317 fn max_token_for_scope_all_unparseable_returns_none() {
1318 let snaps = vec![(Some("s"), Some("xyz")), (Some("s"), None)];
1319 assert_eq!(max_token_for_scope(snaps, "s"), None);
1320 }
1321}