1use std::cmp::Ordering;
86use std::collections::{BTreeMap, VecDeque};
87use std::convert::Infallible;
88use std::future::Future;
89use std::time::Instant;
90use std::{cell::RefCell, rc::Rc, sync::Arc};
91
92use anyhow::{Context, anyhow};
93use arrow::array::{ArrayRef, Int32Array, Int64Array, RecordBatch};
94use arrow::datatypes::{DataType, Field, Schema as ArrowSchema};
95use differential_dataflow::lattice::Lattice;
96use differential_dataflow::{AsCollection, Hashable, VecCollection};
97use futures::StreamExt;
98use iceberg::ErrorKind;
99use iceberg::arrow::{arrow_schema_to_schema, schema_to_arrow_schema};
100use iceberg::spec::{
101 DataFile, FormatVersion, Snapshot, StructType, read_data_files_from_avro,
102 write_data_files_to_avro,
103};
104use iceberg::spec::{Schema, SchemaRef};
105use iceberg::table::Table;
106use iceberg::transaction::{ApplyTransactionAction, Transaction};
107use iceberg::writer::base_writer::data_file_writer::DataFileWriterBuilder;
108use iceberg::writer::base_writer::equality_delete_writer::{
109 EqualityDeleteFileWriterBuilder, EqualityDeleteWriterConfig,
110};
111use iceberg::writer::base_writer::position_delete_writer::{
112 PositionDeleteFileWriterBuilder, PositionDeleteWriterConfig,
113};
114use iceberg::writer::combined_writer::delta_writer::DeltaWriterBuilder;
115use iceberg::writer::file_writer::ParquetWriterBuilder;
116use iceberg::writer::file_writer::location_generator::{
117 DefaultFileNameGenerator, DefaultLocationGenerator,
118};
119use iceberg::writer::file_writer::rolling_writer::RollingFileWriterBuilder;
120use iceberg::writer::{IcebergWriter, IcebergWriterBuilder};
121use iceberg::{Catalog, NamespaceIdent, TableCreation, TableIdent};
122use itertools::Itertools;
123use mz_arrow_util::builder::{ARROW_EXTENSION_NAME_KEY, ArrowBuilder};
124use mz_interchange::avro::DiffPair;
125use mz_interchange::envelopes::for_each_diff_pair;
126use mz_ore::cast::CastFrom;
127use mz_ore::error::ErrorExt;
128use mz_ore::future::InTask;
129use mz_ore::result::ResultExt;
130use mz_ore::retry::{Retry, RetryResult};
131use mz_persist_client::Diagnostics;
132use mz_persist_client::write::WriteHandle;
133use mz_persist_types::codec_impls::UnitSchema;
134use mz_repr::{Diff, GlobalId, Row, Timestamp};
135use mz_storage_types::StorageDiff;
136use mz_storage_types::configuration::StorageConfiguration;
137use mz_storage_types::controller::CollectionMetadata;
138use mz_storage_types::errors::DataflowError;
139use mz_storage_types::sinks::{
140 IcebergSinkConnection, SinkEnvelope, StorageSinkDesc, iceberg_type_overrides,
141};
142use mz_storage_types::sources::SourceData;
143use mz_timely_util::antichain::AntichainExt;
144use mz_timely_util::builder_async::{Event, OperatorBuilder, PressOnDropButton};
145use parquet::arrow::PARQUET_FIELD_ID_META_KEY;
146use parquet::file::properties::WriterProperties;
147use serde::{Deserialize, Serialize};
148use timely::PartialOrder;
149use timely::container::CapacityContainerBuilder;
150use timely::dataflow::StreamVec;
151use timely::dataflow::channels::pact::{Exchange, Pipeline};
152use timely::dataflow::operators::vec::{Broadcast, Map, ToStream};
153use timely::dataflow::operators::{CapabilitySet, Concatenate};
154use timely::progress::{Antichain, Timestamp as _};
155use tracing::debug;
156
157use crate::healthcheck::{HealthStatusMessage, HealthStatusUpdate, StatusNamespace};
158use crate::metrics::sink::iceberg::IcebergSinkMetrics;
159use crate::render::sinks::{PkViolationWarner, SinkBatchStream, SinkRender};
160use crate::statistics::SinkStatistics;
161use crate::storage_state::StorageState;
162
163const DEFAULT_ARRAY_BUILDER_ITEM_CAPACITY: usize = 1024;
166const DEFAULT_ARRAY_BUILDER_DATA_CAPACITY: usize = 1024;
170
171const PARQUET_FILE_PREFIX: &str = "mz_data";
173const INITIAL_DESCRIPTIONS_TO_MINT: u64 = 3;
176
177struct WriterContext {
180 arrow_schema: Arc<ArrowSchema>,
182 current_schema: Arc<Schema>,
184 file_io: iceberg::io::FileIO,
186 location_generator: DefaultLocationGenerator,
188 file_name_generator: DefaultFileNameGenerator,
190 writer_properties: WriterProperties,
191}
192
193trait EnvelopeHandler: Send {
195 fn new(
197 ctx: WriterContext,
198 connection: &IcebergSinkConnection,
199 materialize_arrow_schema: &Arc<ArrowSchema>,
200 ) -> anyhow::Result<Self>
201 where
202 Self: Sized;
203
204 async fn create_writer(&self, is_snapshot: bool) -> anyhow::Result<Box<dyn IcebergWriter>>;
210
211 fn row_to_batch(&self, diff_pair: DiffPair<Row>, ts: Timestamp) -> anyhow::Result<RecordBatch>;
212}
213
214struct UpsertEnvelopeHandler {
215 ctx: WriterContext,
216 equality_ids: Vec<i32>,
218 pos_schema: Arc<Schema>,
220 eq_schema: Arc<Schema>,
222 eq_config: EqualityDeleteWriterConfig,
224 schema_with_op: Arc<ArrowSchema>,
228}
229
230impl EnvelopeHandler for UpsertEnvelopeHandler {
231 fn new(
232 ctx: WriterContext,
233 connection: &IcebergSinkConnection,
234 materialize_arrow_schema: &Arc<ArrowSchema>,
235 ) -> anyhow::Result<Self> {
236 let Some((_, equality_indices)) = &connection.key_desc_and_indices else {
237 return Err(anyhow::anyhow!(
238 "Iceberg sink requires key columns for equality deletes"
239 ));
240 };
241
242 let equality_ids = equality_ids_for_indices(
243 ctx.current_schema.as_ref(),
244 materialize_arrow_schema.as_ref(),
245 equality_indices,
246 )?;
247
248 let pos_arrow_schema = PositionDeleteWriterConfig::arrow_schema();
249 let pos_schema = Arc::new(
250 arrow_schema_to_schema(&pos_arrow_schema)
251 .context("Failed to convert position delete Arrow schema to Iceberg schema")?,
252 );
253
254 let eq_config =
255 EqualityDeleteWriterConfig::new(equality_ids.clone(), Arc::clone(&ctx.current_schema))
256 .context("Failed to create EqualityDeleteWriterConfig")?;
257 let eq_schema = Arc::new(
258 arrow_schema_to_schema(eq_config.projected_arrow_schema_ref())
259 .context("Failed to convert equality delete Arrow schema to Iceberg schema")?,
260 );
261
262 let schema_with_op = Arc::new(build_schema_with_op_column(&ctx.arrow_schema));
263
264 Ok(Self {
265 ctx,
266 equality_ids,
267 pos_schema,
268 eq_schema,
269 eq_config,
270 schema_with_op,
271 })
272 }
273
274 async fn create_writer(&self, is_snapshot: bool) -> anyhow::Result<Box<dyn IcebergWriter>> {
275 let data_parquet_writer = ParquetWriterBuilder::new(
276 self.ctx.writer_properties.clone(),
277 Arc::clone(&self.ctx.current_schema),
278 )
279 .with_arrow_schema(Arc::clone(&self.ctx.arrow_schema))
280 .context("Arrow schema validation failed")?;
281 let data_rolling_writer = RollingFileWriterBuilder::new_with_default_file_size(
282 data_parquet_writer,
283 Arc::clone(&self.ctx.current_schema),
284 self.ctx.file_io.clone(),
285 self.ctx.location_generator.clone(),
286 self.ctx.file_name_generator.clone(),
287 );
288 let data_writer_builder = DataFileWriterBuilder::new(data_rolling_writer);
289
290 let pos_config = PositionDeleteWriterConfig::new(None, 0, None);
291 let pos_parquet_writer = ParquetWriterBuilder::new(
292 self.ctx.writer_properties.clone(),
293 Arc::clone(&self.pos_schema),
294 );
295 let pos_rolling_writer = RollingFileWriterBuilder::new_with_default_file_size(
296 pos_parquet_writer,
297 Arc::clone(&self.ctx.current_schema),
298 self.ctx.file_io.clone(),
299 self.ctx.location_generator.clone(),
300 self.ctx.file_name_generator.clone(),
301 );
302 let pos_delete_writer_builder =
303 PositionDeleteFileWriterBuilder::new(pos_rolling_writer, pos_config);
304
305 let eq_parquet_writer = ParquetWriterBuilder::new(
306 self.ctx.writer_properties.clone(),
307 Arc::clone(&self.eq_schema),
308 );
309 let eq_rolling_writer = RollingFileWriterBuilder::new_with_default_file_size(
310 eq_parquet_writer,
311 Arc::clone(&self.ctx.current_schema),
312 self.ctx.file_io.clone(),
313 self.ctx.location_generator.clone(),
314 self.ctx.file_name_generator.clone(),
315 );
316 let eq_delete_writer_builder =
317 EqualityDeleteFileWriterBuilder::new(eq_rolling_writer, self.eq_config.clone());
318
319 let mut builder = DeltaWriterBuilder::new(
320 data_writer_builder,
321 pos_delete_writer_builder,
322 eq_delete_writer_builder,
323 self.equality_ids.clone(),
324 );
325
326 builder = if is_snapshot {
327 builder.with_max_seen_rows(0)
329 } else {
330 builder.with_max_seen_rows(usize::MAX)
342 };
343
344 Ok(Box::new(
345 builder
346 .build(None)
347 .await
348 .context("Failed to create DeltaWriter")?,
349 ))
350 }
351
352 fn row_to_batch(
355 &self,
356 diff_pair: DiffPair<Row>,
357 _ts: Timestamp,
358 ) -> anyhow::Result<RecordBatch> {
359 let mut builder = ArrowBuilder::new_with_schema(
360 Arc::clone(&self.ctx.arrow_schema),
361 DEFAULT_ARRAY_BUILDER_ITEM_CAPACITY,
362 DEFAULT_ARRAY_BUILDER_DATA_CAPACITY,
363 )
364 .context("Failed to create builder")?;
365
366 let mut op_values = Vec::new();
367
368 if let Some(before) = diff_pair.before {
369 builder
370 .add_row(&before)
371 .context("Failed to add delete row to builder")?;
372 op_values.push(-1i32);
373 }
374 if let Some(after) = diff_pair.after {
375 builder
376 .add_row(&after)
377 .context("Failed to add insert row to builder")?;
378 op_values.push(1i32);
379 }
380
381 let batch = builder
382 .to_record_batch()
383 .context("Failed to create record batch")?;
384
385 let mut columns: Vec<ArrayRef> = batch.columns().to_vec();
386 columns.push(Arc::new(Int32Array::from(op_values)));
387
388 RecordBatch::try_new(Arc::clone(&self.schema_with_op), columns)
389 .context("Failed to create batch with op column")
390 }
391}
392
393struct AppendEnvelopeHandler {
394 ctx: WriterContext,
395 user_schema_for_append: Arc<ArrowSchema>,
398}
399
400impl EnvelopeHandler for AppendEnvelopeHandler {
401 fn new(
402 ctx: WriterContext,
403 _connection: &IcebergSinkConnection,
404 _materialize_arrow_schema: &Arc<ArrowSchema>,
405 ) -> anyhow::Result<Self> {
406 let n = ctx.arrow_schema.fields().len().saturating_sub(2);
409 let user_schema_for_append =
410 Arc::new(ArrowSchema::new(ctx.arrow_schema.fields()[..n].to_vec()));
411
412 Ok(Self {
413 ctx,
414 user_schema_for_append,
415 })
416 }
417
418 async fn create_writer(&self, _is_snapshot: bool) -> anyhow::Result<Box<dyn IcebergWriter>> {
419 let data_parquet_writer = ParquetWriterBuilder::new(
420 self.ctx.writer_properties.clone(),
421 Arc::clone(&self.ctx.current_schema),
422 )
423 .with_arrow_schema(Arc::clone(&self.ctx.arrow_schema))
424 .context("Arrow schema validation failed")?;
425 let data_rolling_writer = RollingFileWriterBuilder::new_with_default_file_size(
426 data_parquet_writer,
427 Arc::clone(&self.ctx.current_schema),
428 self.ctx.file_io.clone(),
429 self.ctx.location_generator.clone(),
430 self.ctx.file_name_generator.clone(),
431 );
432 Ok(Box::new(
433 DataFileWriterBuilder::new(data_rolling_writer)
434 .build(None)
435 .await
436 .context("Failed to create DataFileWriter")?,
437 ))
438 }
439
440 fn row_to_batch(&self, diff_pair: DiffPair<Row>, ts: Timestamp) -> anyhow::Result<RecordBatch> {
443 let mut builder = ArrowBuilder::new_with_schema(
444 Arc::clone(&self.user_schema_for_append),
445 DEFAULT_ARRAY_BUILDER_ITEM_CAPACITY,
446 DEFAULT_ARRAY_BUILDER_DATA_CAPACITY,
447 )
448 .context("Failed to create builder")?;
449
450 let mut diff_values: Vec<i32> = Vec::new();
451 let ts_i64 = i64::try_from(u64::from(ts)).unwrap_or(i64::MAX);
452
453 if let Some(before) = diff_pair.before {
454 builder
455 .add_row(&before)
456 .context("Failed to add before row to builder")?;
457 diff_values.push(-1i32);
458 }
459 if let Some(after) = diff_pair.after {
460 builder
461 .add_row(&after)
462 .context("Failed to add after row to builder")?;
463 diff_values.push(1i32);
464 }
465
466 let n = diff_values.len();
467 let batch = builder
468 .to_record_batch()
469 .context("Failed to create record batch")?;
470
471 let mut columns: Vec<ArrayRef> = batch.columns().to_vec();
472 columns.push(Arc::new(Int32Array::from(diff_values)));
473 columns.push(Arc::new(Int64Array::from(vec![ts_i64; n])));
474
475 RecordBatch::try_new(Arc::clone(&self.ctx.arrow_schema), columns)
476 .context("Failed to create append record batch")
477 }
478}
479
480fn add_field_ids_to_arrow_schema(schema: ArrowSchema) -> ArrowSchema {
485 let mut next_field_id = 1i32;
486 let fields: Vec<Field> = schema
487 .fields()
488 .iter()
489 .map(|field| add_field_ids_recursive(field, &mut next_field_id))
490 .collect();
491 ArrowSchema::new(fields).with_metadata(schema.metadata().clone())
492}
493
494fn add_field_ids_recursive(field: &Field, next_id: &mut i32) -> Field {
496 let current_id = *next_id;
497 *next_id += 1;
498
499 let mut metadata = field.metadata().clone();
500 metadata.insert(
501 PARQUET_FIELD_ID_META_KEY.to_string(),
502 current_id.to_string(),
503 );
504
505 let new_data_type = add_field_ids_to_datatype(field.data_type(), next_id);
506
507 Field::new(field.name(), new_data_type, field.is_nullable()).with_metadata(metadata)
508}
509
510fn add_field_ids_to_datatype(data_type: &DataType, next_id: &mut i32) -> DataType {
512 match data_type {
513 DataType::Struct(fields) => {
514 let new_fields: Vec<Field> = fields
515 .iter()
516 .map(|f| add_field_ids_recursive(f, next_id))
517 .collect();
518 DataType::Struct(new_fields.into())
519 }
520 DataType::List(element_field) => {
521 let new_element = add_field_ids_recursive(element_field, next_id);
522 DataType::List(Arc::new(new_element))
523 }
524 DataType::LargeList(element_field) => {
525 let new_element = add_field_ids_recursive(element_field, next_id);
526 DataType::LargeList(Arc::new(new_element))
527 }
528 DataType::Map(entries_field, sorted) => {
529 let new_entries = add_field_ids_recursive(entries_field, next_id);
530 DataType::Map(Arc::new(new_entries), *sorted)
531 }
532 _ => data_type.clone(),
533 }
534}
535
536fn merge_materialize_metadata_into_iceberg_schema(
541 materialize_arrow_schema: &ArrowSchema,
542 iceberg_schema: &Schema,
543) -> anyhow::Result<ArrowSchema> {
544 let iceberg_arrow_schema = schema_to_arrow_schema(iceberg_schema)
546 .context("Failed to convert Iceberg schema to Arrow schema")?;
547
548 let fields: Vec<Field> = iceberg_arrow_schema
550 .fields()
551 .iter()
552 .map(|iceberg_field| {
553 let mz_field = materialize_arrow_schema
555 .field_with_name(iceberg_field.name())
556 .with_context(|| {
557 format!(
558 "Field '{}' not found in Materialize schema",
559 iceberg_field.name()
560 )
561 })?;
562
563 merge_field_metadata_recursive(iceberg_field, Some(mz_field))
564 })
565 .collect::<anyhow::Result<Vec<_>>>()?;
566
567 Ok(ArrowSchema::new(fields).with_metadata(iceberg_arrow_schema.metadata().clone()))
568}
569
570fn merge_field_metadata_recursive(
572 iceberg_field: &Field,
573 mz_field: Option<&Field>,
574) -> anyhow::Result<Field> {
575 let mut metadata = iceberg_field.metadata().clone();
577
578 if let Some(mz_f) = mz_field {
580 if let Some(extension_name) = mz_f.metadata().get(ARROW_EXTENSION_NAME_KEY) {
581 metadata.insert(ARROW_EXTENSION_NAME_KEY.to_string(), extension_name.clone());
582 }
583 }
584
585 let new_data_type = match iceberg_field.data_type() {
587 DataType::Struct(iceberg_fields) => {
588 let mz_struct_fields = match mz_field {
589 Some(f) => match f.data_type() {
590 DataType::Struct(fields) => Some(fields),
591 other => anyhow::bail!(
592 "Type mismatch for field '{}': Iceberg schema has Struct, but Materialize schema has {:?}",
593 iceberg_field.name(),
594 other
595 ),
596 },
597 None => None,
598 };
599
600 let new_fields: Vec<Field> = iceberg_fields
601 .iter()
602 .map(|iceberg_inner| {
603 let mz_inner = mz_struct_fields.and_then(|fields| {
604 fields.iter().find(|f| f.name() == iceberg_inner.name())
605 });
606 merge_field_metadata_recursive(iceberg_inner, mz_inner.map(|f| f.as_ref()))
607 })
608 .collect::<anyhow::Result<Vec<_>>>()?;
609
610 DataType::Struct(new_fields.into())
611 }
612 DataType::List(iceberg_element) => {
613 let mz_element = match mz_field {
614 Some(f) => match f.data_type() {
615 DataType::List(element) => Some(element.as_ref()),
616 other => anyhow::bail!(
617 "Type mismatch for field '{}': Iceberg schema has List, but Materialize schema has {:?}",
618 iceberg_field.name(),
619 other
620 ),
621 },
622 None => None,
623 };
624 let new_element = merge_field_metadata_recursive(iceberg_element, mz_element)?;
625 DataType::List(Arc::new(new_element))
626 }
627 DataType::LargeList(iceberg_element) => {
628 let mz_element = match mz_field {
629 Some(f) => match f.data_type() {
630 DataType::LargeList(element) => Some(element.as_ref()),
631 other => anyhow::bail!(
632 "Type mismatch for field '{}': Iceberg schema has LargeList, but Materialize schema has {:?}",
633 iceberg_field.name(),
634 other
635 ),
636 },
637 None => None,
638 };
639 let new_element = merge_field_metadata_recursive(iceberg_element, mz_element)?;
640 DataType::LargeList(Arc::new(new_element))
641 }
642 DataType::Map(iceberg_entries, sorted) => {
643 let mz_entries = match mz_field {
644 Some(f) => match f.data_type() {
645 DataType::Map(entries, _) => Some(entries.as_ref()),
646 other => anyhow::bail!(
647 "Type mismatch for field '{}': Iceberg schema has Map, but Materialize schema has {:?}",
648 iceberg_field.name(),
649 other
650 ),
651 },
652 None => None,
653 };
654 let new_entries = match mz_entries {
659 Some(mz_entries) => merge_map_entries_metadata(iceberg_entries, mz_entries)?,
660 None => iceberg_entries.as_ref().clone(),
661 };
662 DataType::Map(Arc::new(new_entries), *sorted)
663 }
664 other => other.clone(),
665 };
666
667 Ok(Field::new(
668 iceberg_field.name(),
669 new_data_type,
670 iceberg_field.is_nullable(),
671 )
672 .with_metadata(metadata))
673}
674
675fn merge_map_entries_metadata(
697 iceberg_entries: &Field,
698 mz_entries: &Field,
699) -> anyhow::Result<Field> {
700 let mut metadata = iceberg_entries.metadata().clone();
701 if let Some(extension_name) = mz_entries.metadata().get(ARROW_EXTENSION_NAME_KEY) {
702 metadata.insert(ARROW_EXTENSION_NAME_KEY.to_string(), extension_name.clone());
703 }
704
705 let iceberg_fields = match iceberg_entries.data_type() {
706 DataType::Struct(fields) => fields,
707 other => anyhow::bail!(
708 "Iceberg map entries field '{}' is not a Struct: {:?}",
709 iceberg_entries.name(),
710 other
711 ),
712 };
713 let mz_fields = match mz_entries.data_type() {
714 DataType::Struct(fields) => fields,
715 other => anyhow::bail!(
716 "Materialize map entries field '{}' is not a Struct: {:?}",
717 mz_entries.name(),
718 other
719 ),
720 };
721
722 let new_fields: Vec<Field> = iceberg_fields
723 .iter()
724 .enumerate()
725 .map(|(idx, iceberg_inner)| {
726 let mz_inner = mz_fields.get(idx).map(|f| f.as_ref());
727 merge_field_metadata_recursive(iceberg_inner, mz_inner)
728 })
729 .collect::<anyhow::Result<Vec<_>>>()?;
730
731 Ok(Field::new(
732 iceberg_entries.name(),
733 DataType::Struct(new_fields.into()),
734 iceberg_entries.is_nullable(),
735 )
736 .with_metadata(metadata))
737}
738
739async fn reload_table(
740 catalog: &dyn Catalog,
741 namespace: String,
742 table_name: String,
743 current_table: Table,
744) -> anyhow::Result<Table> {
745 let namespace_ident = NamespaceIdent::new(namespace.clone());
746 let table_ident = TableIdent::new(namespace_ident, table_name.clone());
747 let current_schema = current_table.metadata().current_schema_id();
748 let current_partition_spec = current_table.metadata().default_partition_spec_id();
749
750 match catalog.load_table(&table_ident).await {
751 Ok(table) => {
752 let reloaded_schema = table.metadata().current_schema_id();
753 let reloaded_partition_spec = table.metadata().default_partition_spec_id();
754 if reloaded_schema != current_schema {
755 return Err(anyhow::anyhow!(
756 "Iceberg table '{}' schema changed during operation but schema evolution isn't supported, expected schema ID {}, got {}",
757 table_name,
758 current_schema,
759 reloaded_schema
760 ));
761 }
762
763 if reloaded_partition_spec != current_partition_spec {
764 return Err(anyhow::anyhow!(
765 "Iceberg table '{}' partition spec changed during operation but partition spec evolution isn't supported, expected partition spec ID {}, got {}",
766 table_name,
767 current_partition_spec,
768 reloaded_partition_spec
769 ));
770 }
771
772 Ok(table)
773 }
774 Err(err) => Err(err).context("Failed to reload Iceberg table"),
775 }
776}
777
778async fn try_commit_batch(
782 mut table: Table,
783 snapshot_properties: Vec<(String, String)>,
784 data_files: Vec<DataFile>,
785 delete_files: Vec<DataFile>,
786 catalog: &dyn Catalog,
787 conn_namespace: &str,
788 conn_table: &str,
789 sink_version: u64,
790 frontier: &Antichain<Timestamp>,
791 batch_lower: &Antichain<Timestamp>,
792 batch_upper: &Antichain<Timestamp>,
793 metrics: &IcebergSinkMetrics,
794) -> (Table, RetryResult<(), anyhow::Error>) {
795 let tx = Transaction::new(&table);
796 let mut action = tx
797 .row_delta()
798 .set_snapshot_properties(snapshot_properties.into_iter().collect())
799 .with_check_duplicate(false);
800
801 if !data_files.is_empty() || !delete_files.is_empty() {
802 action = action
803 .add_data_files(data_files)
804 .add_delete_files(delete_files);
805 }
806
807 let tx = match action
808 .apply(tx)
809 .context("Failed to apply data file addition to iceberg table transaction")
810 {
811 Ok(tx) => tx,
812 Err(e) => {
813 match reload_table(
814 catalog,
815 conn_namespace.to_string(),
816 conn_table.to_string(),
817 table.clone(),
818 )
819 .await
820 {
821 Ok(reloaded) => table = reloaded,
822 Err(reload_err) => {
823 return (table, RetryResult::RetryableErr(anyhow!(reload_err)));
824 }
825 }
826 return (
827 table,
828 RetryResult::RetryableErr(anyhow!(
829 "Failed to apply data file addition to iceberg table transaction: {}",
830 e
831 )),
832 );
833 }
834 };
835
836 let new_table = tx.commit(catalog).await;
837 match new_table {
838 Err(e) if matches!(e.kind(), ErrorKind::CatalogCommitConflicts) => {
839 metrics.commit_conflicts.inc();
840 match reload_table(
841 catalog,
842 conn_namespace.to_string(),
843 conn_table.to_string(),
844 table.clone(),
845 )
846 .await
847 {
848 Ok(reloaded) => table = reloaded,
849 Err(e) => {
850 return (table, RetryResult::RetryableErr(anyhow!(e)));
851 }
852 };
853
854 let mut snapshots: Vec<_> = table.metadata().snapshots().cloned().collect();
855 let last = retrieve_upper_from_snapshots(&mut snapshots);
856 let last = match last {
857 Ok(val) => val,
858 Err(e) => {
859 return (table, RetryResult::RetryableErr(anyhow!(e)));
860 }
861 };
862
863 if let Some((last_frontier, last_version)) = last {
865 if last_version > sink_version {
866 return (
867 table,
868 RetryResult::FatalErr(anyhow!(
869 "Iceberg table '{}' has been modified by another writer \
870 with version {}. Current sink version: {}. \
871 Frontiers may be out of sync, aborting to avoid data loss.",
872 conn_table,
873 last_version,
874 sink_version,
875 )),
876 );
877 }
878 if PartialOrder::less_equal(frontier, &last_frontier) {
879 return (
880 table,
881 RetryResult::FatalErr(anyhow!(
882 "Iceberg table '{}' has been modified by another writer. \
883 Current frontier: {:?}, last frontier: {:?}.",
884 conn_table,
885 frontier,
886 last_frontier,
887 )),
888 );
889 }
890 }
891
892 (
893 table,
894 RetryResult::RetryableErr(anyhow!(
895 "Commit conflict detected when committing batch [{}, {}) \
896 to Iceberg table '{}.{}'. Retrying...",
897 batch_lower.pretty(),
898 batch_upper.pretty(),
899 conn_namespace,
900 conn_table
901 )),
902 )
903 }
904 Err(e) => {
905 metrics.commit_failures.inc();
906 (table, RetryResult::RetryableErr(anyhow!(e)))
907 }
908 Ok(new_table) => (new_table, RetryResult::Ok(())),
909 }
910}
911
912async fn load_or_create_table(
914 catalog: &dyn Catalog,
915 namespace: String,
916 table_name: String,
917 schema: &Schema,
918) -> anyhow::Result<iceberg::table::Table> {
919 let namespace_ident = NamespaceIdent::new(namespace.clone());
920 let table_ident = TableIdent::new(namespace_ident.clone(), table_name.clone());
921
922 match catalog.load_table(&table_ident).await {
924 Ok(table) => {
925 Ok(table)
928 }
929 Err(err) => {
930 if matches!(err.kind(), ErrorKind::TableNotFound { .. })
931 || err
932 .message()
933 .contains("Tried to load a table that does not exist")
934 {
935 let table_creation = TableCreation::builder()
939 .name(table_name.clone())
940 .schema(schema.clone())
941 .build();
945
946 catalog
947 .create_table(&namespace_ident, table_creation)
948 .await
949 .with_context(|| {
950 format!(
951 "Failed to create Iceberg table '{}' in namespace '{}'",
952 table_name, namespace
953 )
954 })
955 } else {
956 Err(err).context("Failed to load Iceberg table")
958 }
959 }
960 }
961}
962
963fn retrieve_upper_from_snapshots(
968 snapshots: &mut [Arc<Snapshot>],
969) -> anyhow::Result<Option<(Antichain<Timestamp>, u64)>> {
970 snapshots.sort_by(|a, b| Ord::cmp(&b.sequence_number(), &a.sequence_number()));
971
972 for snapshot in snapshots {
973 let props = &snapshot.summary().additional_properties;
974 if let (Some(frontier_json), Some(sink_version_str)) =
975 (props.get("mz-frontier"), props.get("mz-sink-version"))
976 {
977 let frontier: Vec<Timestamp> = serde_json::from_str(frontier_json)
978 .context("Failed to deserialize frontier from snapshot properties")?;
979 let frontier = Antichain::from_iter(frontier);
980
981 let sink_version = sink_version_str
982 .parse::<u64>()
983 .context("Failed to parse mz-sink-version from snapshot properties")?;
984
985 return Ok(Some((frontier, sink_version)));
986 }
987 if snapshot.summary().operation.as_str() != "replace" {
988 anyhow::bail!(
993 "Iceberg table is in an inconsistent state: snapshot {} has operation '{}' but is missing 'mz-frontier' property. Schema or partition spec evolution is not supported.",
994 snapshot.snapshot_id(),
995 snapshot.summary().operation.as_str(),
996 );
997 }
998 }
999
1000 Ok(None)
1001}
1002
1003fn relation_desc_to_iceberg_schema(
1013 desc: &mz_repr::RelationDesc,
1014) -> anyhow::Result<(ArrowSchema, SchemaRef)> {
1015 let arrow_schema =
1016 mz_arrow_util::builder::desc_to_schema_with_overrides(desc, iceberg_type_overrides)
1017 .context("Failed to convert RelationDesc to Iceberg-compatible Arrow schema")?;
1018
1019 let arrow_schema_with_ids = add_field_ids_to_arrow_schema(arrow_schema);
1020
1021 let iceberg_schema = arrow_schema_to_schema(&arrow_schema_with_ids)
1022 .context("Failed to convert Arrow schema to Iceberg schema")?;
1023
1024 Ok((arrow_schema_with_ids, Arc::new(iceberg_schema)))
1025}
1026
1027fn equality_ids_for_indices(
1032 current_schema: &Schema,
1033 materialize_arrow_schema: &ArrowSchema,
1034 equality_indices: &[usize],
1035) -> anyhow::Result<Vec<i32>> {
1036 let top_level_fields = current_schema.as_struct();
1037
1038 equality_indices
1039 .iter()
1040 .map(|index| {
1041 let mz_field = materialize_arrow_schema
1042 .fields()
1043 .get(*index)
1044 .with_context(|| format!("Equality delete key index {index} is out of bounds"))?;
1045 let field_name = mz_field.name();
1046 let iceberg_field = top_level_fields
1047 .field_by_name(field_name)
1048 .with_context(|| {
1049 format!(
1050 "Equality delete key column '{}' not found in Iceberg table schema",
1051 field_name
1052 )
1053 })?;
1054 Ok(iceberg_field.id)
1055 })
1056 .collect()
1057}
1058
1059fn build_schema_with_op_column(schema: &ArrowSchema) -> ArrowSchema {
1061 let mut fields: Vec<Arc<Field>> = schema.fields().iter().cloned().collect();
1062 fields.push(Arc::new(Field::new("__op", DataType::Int32, false)));
1063 ArrowSchema::new(fields)
1064}
1065
1066#[allow(clippy::disallowed_types)]
1071fn build_schema_with_append_columns(schema: &ArrowSchema) -> ArrowSchema {
1072 use mz_storage_types::sinks::{ICEBERG_APPEND_DIFF_COLUMN, ICEBERG_APPEND_TIMESTAMP_COLUMN};
1073 let mut fields: Vec<Arc<Field>> = schema.fields().iter().cloned().collect();
1074 fields.push(Arc::new(Field::new(
1075 ICEBERG_APPEND_DIFF_COLUMN,
1076 DataType::Int32,
1077 false,
1078 )));
1079 fields.push(Arc::new(Field::new(
1080 ICEBERG_APPEND_TIMESTAMP_COLUMN,
1081 DataType::Int64,
1082 false,
1083 )));
1084
1085 add_field_ids_to_arrow_schema(ArrowSchema::new(fields).with_metadata(schema.metadata().clone()))
1086}
1087
1088fn mint_batch_descriptions<'scope, D>(
1093 name: String,
1094 sink_id: GlobalId,
1095 input: VecCollection<'scope, Timestamp, D, Diff>,
1096 sink: &StorageSinkDesc<CollectionMetadata, Timestamp>,
1097 connection: IcebergSinkConnection,
1098 storage_configuration: StorageConfiguration,
1099 initial_schema: SchemaRef,
1100) -> (
1101 VecCollection<'scope, Timestamp, D, Diff>,
1102 StreamVec<'scope, Timestamp, (Antichain<Timestamp>, Antichain<Timestamp>)>,
1103 StreamVec<'scope, Timestamp, Infallible>,
1104 StreamVec<'scope, Timestamp, HealthStatusMessage>,
1105 PressOnDropButton,
1106)
1107where
1108 D: Clone + 'static,
1109{
1110 let scope = input.scope();
1111 let name_for_error = name.clone();
1112 let name_for_logging = name.clone();
1113 let mut builder = OperatorBuilder::new(name, scope.clone());
1114 let sink_version = sink.version;
1115
1116 let hashed_id = sink_id.hashed();
1117 let is_active_worker = usize::cast_from(hashed_id) % scope.peers() == scope.index();
1118 let (_, table_ready_stream) = builder.new_output::<CapacityContainerBuilder<Vec<_>>>();
1119 let (output, output_stream) = builder.new_output();
1120 let (batch_desc_output, batch_desc_stream) =
1121 builder.new_output::<CapacityContainerBuilder<Vec<_>>>();
1122 let mut input =
1123 builder.new_input_for_many(input.inner, Pipeline, [&output, &batch_desc_output]);
1124
1125 let as_of = sink.as_of.clone();
1126 let commit_interval = sink
1127 .commit_interval
1128 .expect("the planner should have enforced this")
1129 .clone();
1130
1131 let (button, errors): (_, StreamVec<'scope, Timestamp, Rc<anyhow::Error>>) =
1132 builder.build_fallible(move |caps| {
1133 Box::pin(async move {
1134 let [table_ready_capset, data_capset, capset]: &mut [_; 3] = caps.try_into().unwrap();
1135 *data_capset = CapabilitySet::new();
1136
1137 if !is_active_worker {
1138 *capset = CapabilitySet::new();
1139 *data_capset = CapabilitySet::new();
1140 *table_ready_capset = CapabilitySet::new();
1141 while let Some(event) = input.next().await {
1142 match event {
1143 Event::Data([output_cap, _], mut data) => {
1144 output.give_container(&output_cap, &mut data);
1145 }
1146 Event::Progress(_) => {}
1147 }
1148 }
1149 return Ok(());
1150 }
1151
1152 let catalog = connection
1153 .catalog_connection
1154 .connect(&storage_configuration, InTask::Yes)
1155 .await
1156 .with_context(|| {
1157 format!(
1158 "Failed to connect to Iceberg catalog '{}' for table '{}.{}'",
1159 connection.catalog_connection.uri, connection.namespace, connection.table
1160 )
1161 })?;
1162
1163 let table = load_or_create_table(
1164 catalog.as_ref(),
1165 connection.namespace.clone(),
1166 connection.table.clone(),
1167 initial_schema.as_ref(),
1168 )
1169 .await?;
1170 debug!(
1171 ?sink_id,
1172 %name_for_logging,
1173 namespace = %connection.namespace,
1174 table = %connection.table,
1175 "iceberg mint loaded/created table"
1176 );
1177
1178 *table_ready_capset = CapabilitySet::new();
1179
1180 let mut snapshots: Vec<_> = table.metadata().snapshots().cloned().collect();
1181 let resume = retrieve_upper_from_snapshots(&mut snapshots)?;
1182 let (resume_upper, resume_version) = match resume {
1183 Some((f, v)) => (f, v),
1184 None => (Antichain::from_elem(Timestamp::minimum()), 0),
1185 };
1186 debug!(
1187 ?sink_id,
1188 %name_for_logging,
1189 resume_upper = %resume_upper.pretty(),
1190 resume_version,
1191 as_of = %as_of.pretty(),
1192 "iceberg mint resume position loaded"
1193 );
1194
1195 let overcompacted =
1197 *resume_upper != [Timestamp::minimum()] &&
1199 PartialOrder::less_than(&resume_upper, &as_of);
1201
1202 if overcompacted {
1203 let err = format!(
1204 "{name_for_error}: input compacted past resume upper: as_of {}, resume_upper: {}",
1205 as_of.pretty(),
1206 resume_upper.pretty()
1207 );
1208 return Err(anyhow::anyhow!("{err}"));
1212 };
1213
1214 if resume_version > sink_version {
1215 anyhow::bail!("Fenced off by newer sink version: resume_version {}, sink_version {}", resume_version, sink_version);
1216 }
1217
1218 let mut initialized = false;
1219 let mut observed_frontier;
1220 let mut max_seen_ts: Option<Timestamp> = None;
1221 let mut minted_batches = VecDeque::new();
1226
1227 let catchup_start = if *resume_upper == [Timestamp::minimum()] {
1230 let batch_upper = Antichain::from_elem(
1232 as_of.as_option().expect("as_of not empty").step_forward());
1233 let batch = (as_of.clone(), batch_upper.clone());
1234 minted_batches.push_back(batch.clone());
1235 batch_desc_output.give(&capset[0], batch);
1236 capset.downgrade(batch_upper.clone());
1237
1238 batch_upper
1240 } else {
1241 resume_upper.clone()
1243 };
1244
1245 loop {
1246 if let Some(event) = input.next().await {
1247 match event {
1248 Event::Data([output_cap, _], mut data) => {
1249 if !initialized {
1250 for (_, ts, _) in data.iter() {
1251 match max_seen_ts.as_mut() {
1252 Some(max) => {
1253 if max.less_than(ts) {
1254 *max = ts.clone();
1255 }
1256 }
1257 None => {
1258 max_seen_ts = Some(ts.clone());
1259 }
1260 }
1261 }
1262 }
1263 output.give_container(&output_cap, &mut data);
1264 continue;
1265 }
1266 Event::Progress(frontier) => {
1267 observed_frontier = frontier;
1268 }
1269 }
1270 } else {
1271 return Ok(());
1272 }
1273
1274 if !initialized {
1275 if observed_frontier.is_empty() {
1276 if let Some(max_ts) = max_seen_ts.as_ref() {
1282 let synthesized_upper =
1283 Antichain::from_elem(max_ts.step_forward());
1284 debug!(
1285 ?sink_id,
1286 %name_for_logging,
1287 max_seen_ts = %max_ts,
1288 synthesized_upper = %synthesized_upper.pretty(),
1289 "iceberg mint input closed before initialization; using max seen ts"
1290 );
1291 observed_frontier = synthesized_upper;
1292 } else {
1293 debug!(
1294 ?sink_id,
1295 %name_for_logging,
1296 "iceberg mint input closed before initialization with no data"
1297 );
1298 return Ok(());
1300 }
1301 }
1302
1303 if !PartialOrder::less_than(&catchup_start, &observed_frontier)
1306 {
1307 continue;
1308 }
1309
1310 let mut batch_descriptions = vec![];
1311 let mut current_upper = observed_frontier.clone();
1312 let current_upper_ts = observed_frontier.as_option().expect("frontier not empty").clone();
1313 debug!(
1314 ?sink_id,
1315 %name_for_logging,
1316 batch_lower = %catchup_start.pretty(),
1317 current_upper = %current_upper.pretty(),
1318 "iceberg mint initializing (catch-up batch)"
1319 );
1320 debug!(
1321 "{}: creating catch-up batch [{}, {})",
1322 name_for_logging,
1323 catchup_start.pretty(),
1324 current_upper.pretty()
1325 );
1326 batch_descriptions.push((catchup_start.clone(), current_upper.clone()));
1327
1328 for i in 1..INITIAL_DESCRIPTIONS_TO_MINT {
1330 let duration_millis = commit_interval.as_millis()
1331 .checked_mul(u128::from(i))
1332 .expect("commit interval multiplication overflow");
1333 let duration_ts = Timestamp::new(
1334 u64::try_from(duration_millis)
1335 .expect("commit interval too large for u64"),
1336 );
1337 let desired_batch_upper = Antichain::from_elem(
1338 current_upper_ts.step_forward_by(&duration_ts),
1339 );
1340
1341 let batch_description =
1342 (current_upper.clone(), desired_batch_upper.clone());
1343 debug!(
1344 "{}: minting future batch {}/{} [{}, {})",
1345 name_for_logging,
1346 i,
1347 INITIAL_DESCRIPTIONS_TO_MINT,
1348 current_upper.pretty(),
1349 desired_batch_upper.pretty()
1350 );
1351 current_upper = batch_description.1.clone();
1352 batch_descriptions.push(batch_description);
1353 }
1354
1355 minted_batches.extend(batch_descriptions.clone());
1356
1357 for desc in batch_descriptions {
1358 batch_desc_output.give(&capset[0], desc);
1359 }
1360
1361 capset.downgrade(current_upper);
1362
1363 initialized = true;
1364 } else {
1365 if observed_frontier.is_empty() {
1366 return Ok(());
1368 }
1369 while let Some(oldest_desc) = minted_batches.front() {
1372 let oldest_upper = &oldest_desc.1;
1373 if !PartialOrder::less_equal(oldest_upper, &observed_frontier) {
1374 break;
1375 }
1376
1377 let newest_upper = minted_batches.back().unwrap().1.clone();
1378 let new_lower = newest_upper.clone();
1379 let duration_ts = Timestamp::new(commit_interval.as_millis()
1380 .try_into()
1381 .expect("commit interval too large for u64"));
1382 let new_upper = Antichain::from_elem(newest_upper
1383 .as_option()
1384 .unwrap()
1385 .step_forward_by(&duration_ts));
1386
1387 let new_batch_description = (new_lower.clone(), new_upper.clone());
1388 minted_batches.pop_front();
1389 minted_batches.push_back(new_batch_description.clone());
1390
1391 batch_desc_output.give(&capset[0], new_batch_description);
1392
1393 capset.downgrade(new_upper);
1394 }
1395 }
1396 }
1397 })
1398 });
1399
1400 let statuses = errors.map(|error| HealthStatusMessage {
1401 id: None,
1402 update: HealthStatusUpdate::halting(format!("{}", error.display_with_causes()), None),
1403 namespace: StatusNamespace::Iceberg,
1404 });
1405 (
1406 output_stream.as_collection(),
1407 batch_desc_stream,
1408 table_ready_stream,
1409 statuses,
1410 button.press_on_drop(),
1411 )
1412}
1413
1414#[derive(Clone, Debug, Serialize, Deserialize)]
1415#[serde(try_from = "AvroDataFile", into = "AvroDataFile")]
1416struct SerializableDataFile {
1417 pub data_file: DataFile,
1418 pub schema: Schema,
1419}
1420
1421#[derive(Clone, Debug, Serialize, Deserialize)]
1429struct AvroDataFile {
1430 pub data_file: Vec<u8>,
1431 pub schema: Vec<u8>,
1433}
1434
1435impl From<SerializableDataFile> for AvroDataFile {
1436 fn from(value: SerializableDataFile) -> Self {
1437 let mut data_file = Vec::new();
1438 write_data_files_to_avro(
1439 &mut data_file,
1440 [value.data_file],
1441 &StructType::new(vec![]),
1442 FormatVersion::V2,
1443 )
1444 .expect("serialization into buffer");
1445 let schema = serde_json::to_vec(&value.schema).expect("schema serialization");
1446 AvroDataFile { data_file, schema }
1447 }
1448}
1449
1450impl TryFrom<AvroDataFile> for SerializableDataFile {
1451 type Error = String;
1452
1453 fn try_from(value: AvroDataFile) -> Result<Self, Self::Error> {
1454 let schema: Schema = serde_json::from_slice(&value.schema)
1455 .map_err(|e| format!("Failed to deserialize schema: {}", e))?;
1456 let data_files = read_data_files_from_avro(
1457 &mut &*value.data_file,
1458 &schema,
1459 0,
1460 &StructType::new(vec![]),
1461 FormatVersion::V2,
1462 )
1463 .map_err_to_string_with_causes()?;
1464 let Some(data_file) = data_files.into_iter().next() else {
1465 return Err("No DataFile found in Avro data".into());
1466 };
1467 Ok(SerializableDataFile { data_file, schema })
1468 }
1469}
1470
1471#[derive(Clone, Debug, Serialize, Deserialize)]
1473struct BoundedDataFile {
1474 pub data_file: SerializableDataFile,
1475 pub batch_desc: (Antichain<Timestamp>, Antichain<Timestamp>),
1476}
1477
1478impl BoundedDataFile {
1479 pub fn new(
1480 file: DataFile,
1481 schema: Schema,
1482 batch_desc: (Antichain<Timestamp>, Antichain<Timestamp>),
1483 ) -> Self {
1484 Self {
1485 data_file: SerializableDataFile {
1486 data_file: file,
1487 schema,
1488 },
1489 batch_desc,
1490 }
1491 }
1492
1493 pub fn batch_desc(&self) -> &(Antichain<Timestamp>, Antichain<Timestamp>) {
1494 &self.batch_desc
1495 }
1496
1497 pub fn data_file(&self) -> &DataFile {
1498 &self.data_file.data_file
1499 }
1500
1501 pub fn into_data_file(self) -> DataFile {
1502 self.data_file.data_file
1503 }
1504}
1505
1506#[derive(Clone, Debug, Default)]
1508struct BoundedDataFileSet {
1509 pub data_files: Vec<BoundedDataFile>,
1510}
1511
1512fn write_data_files<'scope, H: EnvelopeHandler + 'static>(
1518 name: String,
1519 input: VecCollection<'scope, Timestamp, (Option<Row>, DiffPair<Row>), Diff>,
1520 batch_desc_input: StreamVec<'scope, Timestamp, (Antichain<Timestamp>, Antichain<Timestamp>)>,
1521 table_ready_stream: StreamVec<'scope, Timestamp, Infallible>,
1522 as_of: Antichain<Timestamp>,
1523 connection: IcebergSinkConnection,
1524 storage_configuration: StorageConfiguration,
1525 materialize_arrow_schema: Arc<ArrowSchema>,
1526 metrics: Arc<IcebergSinkMetrics>,
1527 statistics: SinkStatistics,
1528) -> (
1529 StreamVec<'scope, Timestamp, BoundedDataFile>,
1530 StreamVec<'scope, Timestamp, HealthStatusMessage>,
1531 PressOnDropButton,
1532) {
1533 let scope = input.scope();
1534 let name_for_logging = name.clone();
1535 let mut builder = OperatorBuilder::new(name, scope.clone());
1536
1537 let (output, output_stream) = builder.new_output::<CapacityContainerBuilder<_>>();
1538
1539 let mut table_ready_input = builder.new_disconnected_input(table_ready_stream, Pipeline);
1540 let mut batch_desc_input =
1541 builder.new_input_for(batch_desc_input.broadcast(), Pipeline, &output);
1542 let mut input = builder.new_disconnected_input(input.inner, Pipeline);
1543
1544 let (button, errors) = builder.build_fallible(move |caps| {
1545 Box::pin(async move {
1546 let [capset]: &mut [_; 1] = caps.try_into().unwrap();
1547 let catalog = connection
1548 .catalog_connection
1549 .connect(&storage_configuration, InTask::Yes)
1550 .await
1551 .with_context(|| {
1552 format!(
1553 "Failed to connect to Iceberg catalog '{}' for table '{}.{}'",
1554 connection.catalog_connection.uri, connection.namespace, connection.table
1555 )
1556 })?;
1557
1558 let namespace_ident = NamespaceIdent::new(connection.namespace.clone());
1559 let table_ident = TableIdent::new(namespace_ident, connection.table.clone());
1560 while let Some(_) = table_ready_input.next().await {
1561 }
1563 let table = catalog
1564 .load_table(&table_ident)
1565 .await
1566 .with_context(|| {
1567 format!(
1568 "Failed to load Iceberg table '{}.{}' in write_data_files operator",
1569 connection.namespace, connection.table
1570 )
1571 })?;
1572
1573 let table_metadata = table.metadata().clone();
1574 let current_schema = Arc::clone(table_metadata.current_schema());
1575
1576 let arrow_schema = Arc::new(
1580 merge_materialize_metadata_into_iceberg_schema(
1581 materialize_arrow_schema.as_ref(),
1582 current_schema.as_ref(),
1583 )
1584 .context("Failed to merge Materialize metadata into Iceberg schema")?,
1585 );
1586
1587 let location = table_metadata.location();
1591 let corrected_location = match location.rsplit_once("/metadata/") {
1592 Some((a, b)) if b.ends_with(".metadata.json") => a,
1593 _ => location,
1594 };
1595
1596 let data_location = format!("{}/data", corrected_location);
1597 let location_generator = DefaultLocationGenerator::with_data_location(data_location);
1598
1599 let unique_suffix = format!("-{}", uuid::Uuid::new_v4());
1601 let file_name_generator = DefaultFileNameGenerator::new(
1602 PARQUET_FILE_PREFIX.to_string(),
1603 Some(unique_suffix),
1604 iceberg::spec::DataFileFormat::Parquet,
1605 );
1606
1607 let file_io = table.file_io().clone();
1608
1609 let writer_properties = WriterProperties::new();
1610
1611 let ctx = WriterContext {
1612 arrow_schema,
1613 current_schema: Arc::clone(¤t_schema),
1614 file_io,
1615 location_generator,
1616 file_name_generator,
1617 writer_properties,
1618 };
1619 let handler = H::new(ctx, &connection, &materialize_arrow_schema)?;
1620
1621 let mut stashed_rows: BTreeMap<Timestamp, Vec<(Option<Row>, DiffPair<Row>)>> =
1624 BTreeMap::new();
1625
1626 #[allow(clippy::disallowed_types)]
1631 let mut in_flight_batches: std::collections::HashMap<
1632 (Antichain<Timestamp>, Antichain<Timestamp>),
1633 Box<dyn IcebergWriter>,
1634 > = std::collections::HashMap::new();
1635
1636 let mut batch_description_frontier = Antichain::from_elem(Timestamp::minimum());
1637 let mut processed_batch_description_frontier =
1638 Antichain::from_elem(Timestamp::minimum());
1639 let mut input_frontier = Antichain::from_elem(Timestamp::minimum());
1640 let mut processed_input_frontier = Antichain::from_elem(Timestamp::minimum());
1641
1642 let mut min_batch_lower: Option<Antichain<Timestamp>> = None;
1644
1645 while !(batch_description_frontier.is_empty() && input_frontier.is_empty()) {
1646 let mut staged_messages_since_flush: u64 = 0;
1647 tokio::select! {
1648 _ = batch_desc_input.ready() => {},
1649 _ = input.ready() => {}
1650 }
1651
1652 while let Some(event) = batch_desc_input.next_sync() {
1653 match event {
1654 Event::Data(_cap, data) => {
1655 for batch_desc in data {
1656 let (lower, upper) = &batch_desc;
1657
1658 if min_batch_lower.is_none() {
1660 min_batch_lower = Some(lower.clone());
1661 debug!(
1662 "{}: set min_batch_lower to {}",
1663 name_for_logging,
1664 lower.pretty()
1665 );
1666
1667 let to_remove: Vec<_> = stashed_rows
1669 .keys()
1670 .filter(|ts| {
1671 let ts_antichain = Antichain::from_elem((*ts).clone());
1672 PartialOrder::less_than(&ts_antichain, lower)
1673 })
1674 .cloned()
1675 .collect();
1676
1677 if !to_remove.is_empty() {
1678 let mut removed_count = 0;
1679 for ts in to_remove {
1680 if let Some(rows) = stashed_rows.remove(&ts) {
1681 removed_count += rows.len();
1682 for _ in &rows {
1683 metrics.stashed_rows.dec();
1684 }
1685 }
1686 }
1687 debug!(
1688 "{}: pruned {} already-committed rows (< min_batch_lower)",
1689 name_for_logging,
1690 removed_count
1691 );
1692 }
1693 }
1694
1695 let is_snapshot = lower == &as_of;
1697 debug!(
1698 "{}: received batch description [{}, {}), snapshot={}",
1699 name_for_logging,
1700 lower.pretty(),
1701 upper.pretty(),
1702 is_snapshot
1703 );
1704 let mut batch_writer =
1705 handler.create_writer(is_snapshot).await?;
1706 let row_ts_keys: Vec<_> = stashed_rows.keys().cloned().collect();
1708 let mut drained_count = 0;
1709 for row_ts in row_ts_keys {
1710 let ts = Antichain::from_elem(row_ts.clone());
1711 if PartialOrder::less_equal(lower, &ts)
1712 && PartialOrder::less_than(&ts, upper)
1713 {
1714 if let Some(rows) = stashed_rows.remove(&row_ts) {
1715 drained_count += rows.len();
1716 for (_row, diff_pair) in rows {
1717 metrics.stashed_rows.dec();
1718 let record_batch = handler.row_to_batch(
1719 diff_pair.clone(),
1720 row_ts.clone(),
1721 )
1722 .context("failed to convert row to recordbatch")?;
1723 batch_writer.write(record_batch).await?;
1724 staged_messages_since_flush += 1;
1725 if staged_messages_since_flush >= 10_000 {
1726 statistics.inc_messages_staged_by(
1727 staged_messages_since_flush,
1728 );
1729 staged_messages_since_flush = 0;
1730 }
1731 }
1732 }
1733 }
1734 }
1735 if drained_count > 0 {
1736 debug!(
1737 "{}: drained {} stashed rows into batch [{}, {})",
1738 name_for_logging,
1739 drained_count,
1740 lower.pretty(),
1741 upper.pretty()
1742 );
1743 }
1744 let prev =
1745 in_flight_batches.insert(batch_desc.clone(), batch_writer);
1746 if prev.is_some() {
1747 anyhow::bail!(
1748 "Duplicate batch description received for description {:?}",
1749 batch_desc
1750 );
1751 }
1752 }
1753 }
1754 Event::Progress(frontier) => {
1755 batch_description_frontier = frontier;
1756 }
1757 }
1758 }
1759
1760 let ready_events = std::iter::from_fn(|| input.next_sync()).collect_vec();
1761 for event in ready_events {
1762 match event {
1763 Event::Data(_cap, data) => {
1764 let mut dropped_per_time = BTreeMap::new();
1765 let mut stashed_per_time = BTreeMap::new();
1766 for ((row, diff_pair), ts, _diff) in data {
1767 let row_ts = ts.clone();
1768 let ts_antichain = Antichain::from_elem(row_ts.clone());
1769 let mut written = false;
1770 for (batch_desc, batch_writer) in in_flight_batches.iter_mut() {
1772 let (lower, upper) = batch_desc;
1773 if PartialOrder::less_equal(lower, &ts_antichain)
1774 && PartialOrder::less_than(&ts_antichain, upper)
1775 {
1776 let record_batch = handler.row_to_batch(
1777 diff_pair.clone(),
1778 row_ts.clone(),
1779 )
1780 .context("failed to convert row to recordbatch")?;
1781 batch_writer.write(record_batch).await?;
1782 staged_messages_since_flush += 1;
1783 if staged_messages_since_flush >= 10_000 {
1784 statistics.inc_messages_staged_by(
1785 staged_messages_since_flush,
1786 );
1787 staged_messages_since_flush = 0;
1788 }
1789 written = true;
1790 break;
1791 }
1792 }
1793 if !written {
1794 if let Some(ref min_lower) = min_batch_lower {
1796 if PartialOrder::less_than(&ts_antichain, min_lower) {
1797 dropped_per_time
1798 .entry(ts_antichain.into_option().unwrap())
1799 .and_modify(|c| *c += 1)
1800 .or_insert(1);
1801 continue;
1802 }
1803 }
1804
1805 stashed_per_time.entry(ts).and_modify(|c| *c += 1).or_insert(1);
1806 let entry = stashed_rows.entry(row_ts).or_default();
1807 metrics.stashed_rows.inc();
1808 entry.push((row, diff_pair));
1809 }
1810 }
1811
1812 for (ts, count) in dropped_per_time {
1813 debug!(
1814 "{}: dropped {} rows at timestamp {} (< min_batch_lower, already committed)",
1815 name_for_logging, count, ts
1816 );
1817 }
1818
1819 for (ts, count) in stashed_per_time {
1820 debug!(
1821 "{}: stashed {} rows at timestamp {} (waiting for batch description)",
1822 name_for_logging, count, ts
1823 );
1824 }
1825 }
1826 Event::Progress(frontier) => {
1827 input_frontier = frontier;
1828 }
1829 }
1830 }
1831 if staged_messages_since_flush > 0 {
1832 statistics.inc_messages_staged_by(staged_messages_since_flush);
1833 }
1834
1835 if PartialOrder::less_than(
1837 &processed_batch_description_frontier,
1838 &batch_description_frontier,
1839 ) || PartialOrder::less_than(&processed_input_frontier, &input_frontier)
1840 {
1841 let ready_batches: Vec<_> = in_flight_batches
1847 .extract_if(|(lower, upper), _| {
1848 PartialOrder::less_than(lower, &batch_description_frontier)
1849 && PartialOrder::less_equal(upper, &input_frontier)
1850 })
1851 .collect();
1852
1853 if !ready_batches.is_empty() {
1854 debug!(
1855 "{}: closing {} batches (batch_frontier: {}, input_frontier: {})",
1856 name_for_logging,
1857 ready_batches.len(),
1858 batch_description_frontier.pretty(),
1859 input_frontier.pretty()
1860 );
1861 let mut max_upper = Antichain::from_elem(Timestamp::minimum());
1862 for (desc, mut batch_writer) in ready_batches {
1863 let close_started_at = Instant::now();
1864 let data_files = batch_writer.close().await;
1865 metrics
1866 .writer_close_duration_seconds
1867 .observe(close_started_at.elapsed().as_secs_f64());
1868 let data_files = data_files.context("Failed to close batch writer")?;
1869 debug!(
1870 "{}: closed batch [{}, {}), wrote {} files",
1871 name_for_logging,
1872 desc.0.pretty(),
1873 desc.1.pretty(),
1874 data_files.len()
1875 );
1876 for data_file in data_files {
1877 match data_file.content_type() {
1878 iceberg::spec::DataContentType::Data => {
1879 metrics.data_files_written.inc();
1880 }
1881 iceberg::spec::DataContentType::PositionDeletes
1882 | iceberg::spec::DataContentType::EqualityDeletes => {
1883 metrics.delete_files_written.inc();
1884 }
1885 }
1886 statistics.inc_messages_staged_by(data_file.record_count());
1887 statistics.inc_bytes_staged_by(data_file.file_size_in_bytes());
1888 let file = BoundedDataFile::new(
1889 data_file,
1890 current_schema.as_ref().clone(),
1891 desc.clone(),
1892 );
1893 output.give(&capset[0], file);
1894 }
1895
1896 max_upper = max_upper.join(&desc.1);
1897 }
1898
1899 capset.downgrade(max_upper);
1900 }
1901 processed_batch_description_frontier.clone_from(&batch_description_frontier);
1902 processed_input_frontier.clone_from(&input_frontier);
1903 }
1904 }
1905 Ok(())
1906 })
1907 });
1908
1909 let statuses = errors.map(|error| HealthStatusMessage {
1910 id: None,
1911 update: HealthStatusUpdate::halting(format!("{}", error.display_with_causes()), None),
1912 namespace: StatusNamespace::Iceberg,
1913 });
1914 (output_stream, statuses, button.press_on_drop())
1915}
1916
1917#[cfg(test)]
1918mod tests {
1919 use super::*;
1920 use iceberg::spec::{PrimitiveType, Type};
1921 use mz_repr::SqlScalarType;
1922 use mz_storage_types::sinks::ICEBERG_UINT64_DECIMAL_PRECISION;
1923
1924 #[mz_ore::test]
1925 fn test_iceberg_type_overrides() {
1926 let result = iceberg_type_overrides(&SqlScalarType::UInt16);
1928 assert_eq!(result.unwrap().0, DataType::Int32);
1929
1930 let result = iceberg_type_overrides(&SqlScalarType::UInt32);
1932 assert_eq!(result.unwrap().0, DataType::Int64);
1933
1934 let result = iceberg_type_overrides(&SqlScalarType::UInt64);
1936 assert_eq!(
1937 result.unwrap().0,
1938 DataType::Decimal128(ICEBERG_UINT64_DECIMAL_PRECISION, 0)
1939 );
1940
1941 let result = iceberg_type_overrides(&SqlScalarType::MzTimestamp);
1943 assert_eq!(
1944 result.unwrap().0,
1945 DataType::Decimal128(ICEBERG_UINT64_DECIMAL_PRECISION, 0)
1946 );
1947
1948 assert!(iceberg_type_overrides(&SqlScalarType::Int32).is_none());
1950 assert!(iceberg_type_overrides(&SqlScalarType::String).is_none());
1951 assert!(iceberg_type_overrides(&SqlScalarType::Bool).is_none());
1952 }
1953
1954 #[mz_ore::test]
1955 fn test_iceberg_schema_with_nested_uint64() {
1956 let desc = mz_repr::RelationDesc::builder()
1959 .with_column(
1960 "items",
1961 SqlScalarType::List {
1962 element_type: Box::new(SqlScalarType::UInt64),
1963 custom_id: None,
1964 }
1965 .nullable(true),
1966 )
1967 .finish();
1968
1969 let schema =
1970 mz_arrow_util::builder::desc_to_schema_with_overrides(&desc, iceberg_type_overrides)
1971 .expect("schema conversion should succeed");
1972
1973 if let DataType::List(field) = schema.field(0).data_type() {
1975 assert_eq!(
1976 field.data_type(),
1977 &DataType::Decimal128(ICEBERG_UINT64_DECIMAL_PRECISION, 0)
1978 );
1979 } else {
1980 panic!("Expected List type");
1981 }
1982 }
1983
1984 #[mz_ore::test]
1985 fn test_iceberg_interval_override() {
1986 let result = iceberg_type_overrides(&SqlScalarType::Interval);
1988 assert_eq!(result.unwrap().0, DataType::LargeUtf8);
1989
1990 let desc = mz_repr::RelationDesc::builder()
1992 .with_column("id", SqlScalarType::Int32.nullable(false))
1993 .with_column("dur", SqlScalarType::Interval.nullable(true))
1994 .finish();
1995
1996 let (arrow_schema, iceberg_schema) =
1997 relation_desc_to_iceberg_schema(&desc).expect("schema conversion should succeed");
1998
1999 assert_eq!(arrow_schema.field(1).data_type(), &DataType::LargeUtf8);
2001
2002 let field = iceberg_schema
2004 .as_struct()
2005 .field_by_name("dur")
2006 .expect("field should exist");
2007 assert_eq!(*field.field_type, Type::Primitive(PrimitiveType::String));
2008 }
2009
2010 #[mz_ore::test]
2011 fn test_iceberg_range_schema() {
2012 let desc = mz_repr::RelationDesc::builder()
2014 .with_column("id", SqlScalarType::Int32.nullable(false))
2015 .with_column(
2016 "r",
2017 SqlScalarType::Range {
2018 element_type: Box::new(SqlScalarType::Int32),
2019 }
2020 .nullable(true),
2021 )
2022 .finish();
2023
2024 let (_arrow_schema, iceberg_schema) =
2025 relation_desc_to_iceberg_schema(&desc).expect("schema conversion should succeed");
2026
2027 let field = iceberg_schema
2029 .as_struct()
2030 .field_by_name("r")
2031 .expect("field should exist");
2032 assert!(
2033 matches!(&*field.field_type, Type::Struct(_)),
2034 "range should be struct, got: {:?}",
2035 field.field_type
2036 );
2037 }
2038
2039 #[mz_ore::test]
2040 fn equality_ids_follow_iceberg_field_ids() {
2041 let map_entries = Field::new(
2042 "entries",
2043 DataType::Struct(
2044 vec![
2045 Field::new("key", DataType::Utf8, false),
2046 Field::new("value", DataType::Utf8, true),
2047 ]
2048 .into(),
2049 ),
2050 false,
2051 );
2052 let materialize_arrow_schema = ArrowSchema::new(vec![
2053 Field::new("attrs", DataType::Map(Arc::new(map_entries), false), true),
2054 Field::new("key_col", DataType::Int32, false),
2055 ]);
2056 let materialize_arrow_schema = add_field_ids_to_arrow_schema(materialize_arrow_schema);
2057 let iceberg_schema = arrow_schema_to_schema(&materialize_arrow_schema)
2058 .expect("schema conversion should succeed");
2059
2060 let equality_ids =
2061 equality_ids_for_indices(&iceberg_schema, &materialize_arrow_schema, &[1])
2062 .expect("field lookup should succeed");
2063
2064 let expected_id = iceberg_schema
2065 .as_struct()
2066 .field_by_name("key_col")
2067 .expect("top-level field should exist")
2068 .id;
2069 assert_eq!(equality_ids, vec![expected_id]);
2070 assert_ne!(expected_id, 2);
2071 }
2072
2073 #[mz_ore::test]
2078 #[allow(clippy::disallowed_types)]
2079 fn merge_map_entries_preserves_value_extension_metadata() {
2080 use std::collections::HashMap;
2081
2082 let mz_value_metadata = HashMap::from([(
2083 ARROW_EXTENSION_NAME_KEY.to_string(),
2084 "materialize.v1.string".to_string(),
2085 )]);
2086 let mz_entries = Field::new(
2087 "entries",
2088 DataType::Struct(
2089 vec![
2090 Field::new("keys", DataType::Utf8, false),
2091 Field::new("values", DataType::Utf8, true).with_metadata(mz_value_metadata),
2092 ]
2093 .into(),
2094 ),
2095 false,
2096 );
2097 let mz_map = Field::new("m", DataType::Map(Arc::new(mz_entries), false), true)
2098 .with_metadata(HashMap::from([(
2099 ARROW_EXTENSION_NAME_KEY.to_string(),
2100 "materialize.v1.map".to_string(),
2101 )]));
2102
2103 let iceberg_entries = Field::new(
2104 "key_value",
2105 DataType::Struct(
2106 vec![
2107 Field::new("key", DataType::Utf8, false),
2108 Field::new("value", DataType::Utf8, true),
2109 ]
2110 .into(),
2111 ),
2112 false,
2113 );
2114 let iceberg_map = Field::new("m", DataType::Map(Arc::new(iceberg_entries), false), true);
2115
2116 let merged = merge_field_metadata_recursive(&iceberg_map, Some(&mz_map))
2117 .expect("merge should succeed");
2118
2119 let entries = match merged.data_type() {
2120 DataType::Map(entries, _) => entries.as_ref(),
2121 other => panic!("expected Map, got {other:?}"),
2122 };
2123 let entry_fields = match entries.data_type() {
2124 DataType::Struct(fields) => fields,
2125 other => panic!("expected Struct, got {other:?}"),
2126 };
2127 assert_eq!(entry_fields[0].name(), "key");
2129 assert_eq!(entry_fields[1].name(), "value");
2130 assert_eq!(
2133 entry_fields[1].metadata().get(ARROW_EXTENSION_NAME_KEY),
2134 Some(&"materialize.v1.string".to_string()),
2135 );
2136 }
2137}
2138
2139fn commit_to_iceberg<'scope>(
2143 name: String,
2144 sink_id: GlobalId,
2145 sink_version: u64,
2146 batch_input: StreamVec<'scope, Timestamp, BoundedDataFile>,
2147 batch_desc_input: StreamVec<'scope, Timestamp, (Antichain<Timestamp>, Antichain<Timestamp>)>,
2148 table_ready_stream: StreamVec<'scope, Timestamp, Infallible>,
2149 write_frontier: Rc<RefCell<Antichain<Timestamp>>>,
2150 connection: IcebergSinkConnection,
2151 storage_configuration: StorageConfiguration,
2152 write_handle: impl Future<
2153 Output = anyhow::Result<WriteHandle<SourceData, (), Timestamp, StorageDiff>>,
2154 > + 'static,
2155 metrics: Arc<IcebergSinkMetrics>,
2156 statistics: SinkStatistics,
2157) -> (
2158 StreamVec<'scope, Timestamp, HealthStatusMessage>,
2159 PressOnDropButton,
2160) {
2161 let scope = batch_input.scope();
2162 let mut builder = OperatorBuilder::new(name, scope.clone());
2163
2164 let hashed_id = sink_id.hashed();
2165 let is_active_worker = usize::cast_from(hashed_id) % scope.peers() == scope.index();
2166 let name_for_logging = format!("{sink_id}-commit-to-iceberg");
2167
2168 let mut input = builder.new_disconnected_input(batch_input, Exchange::new(move |_| hashed_id));
2169 let mut batch_desc_input =
2170 builder.new_disconnected_input(batch_desc_input, Exchange::new(move |_| hashed_id));
2171 let mut table_ready_input = builder.new_disconnected_input(table_ready_stream, Pipeline);
2172
2173 let (button, errors) = builder.build_fallible(move |_caps| {
2174 Box::pin(async move {
2175 if !is_active_worker {
2176 write_frontier.borrow_mut().clear();
2177 return Ok(());
2178 }
2179
2180 let catalog = connection
2181 .catalog_connection
2182 .connect(&storage_configuration, InTask::Yes)
2183 .await
2184 .with_context(|| {
2185 format!(
2186 "Failed to connect to Iceberg catalog '{}' for table '{}.{}'",
2187 connection.catalog_connection.uri, connection.namespace, connection.table
2188 )
2189 })?;
2190
2191 let mut write_handle = write_handle.await?;
2192
2193 let namespace_ident = NamespaceIdent::new(connection.namespace.clone());
2194 let table_ident = TableIdent::new(namespace_ident, connection.table.clone());
2195 while let Some(_) = table_ready_input.next().await {
2196 }
2198 let mut table = catalog.load_table(&table_ident).await.with_context(|| {
2199 format!(
2200 "Failed to load Iceberg table '{}.{}' in commit_to_iceberg operator",
2201 connection.namespace, connection.table
2202 )
2203 })?;
2204
2205 #[allow(clippy::disallowed_types)]
2206 let mut batch_descriptions: std::collections::HashMap<
2207 (Antichain<Timestamp>, Antichain<Timestamp>),
2208 BoundedDataFileSet,
2209 > = std::collections::HashMap::new();
2210
2211 let mut batch_description_frontier = Antichain::from_elem(Timestamp::minimum());
2212 let mut input_frontier = Antichain::from_elem(Timestamp::minimum());
2213
2214 while !(batch_description_frontier.is_empty() && input_frontier.is_empty()) {
2215 tokio::select! {
2216 _ = batch_desc_input.ready() => {},
2217 _ = input.ready() => {}
2218 }
2219
2220 while let Some(event) = batch_desc_input.next_sync() {
2221 match event {
2222 Event::Data(_cap, data) => {
2223 for batch_desc in data {
2224 let prev = batch_descriptions
2225 .insert(batch_desc, BoundedDataFileSet { data_files: vec![] });
2226 if let Some(prev) = prev {
2227 anyhow::bail!(
2228 "Duplicate batch description received \
2229 in commit operator: {:?}",
2230 prev
2231 );
2232 }
2233 }
2234 }
2235 Event::Progress(frontier) => {
2236 batch_description_frontier = frontier;
2237 }
2238 }
2239 }
2240
2241 let ready_events = std::iter::from_fn(|| input.next_sync()).collect_vec();
2242 for event in ready_events {
2243 match event {
2244 Event::Data(_cap, data) => {
2245 for bounded_data_file in data {
2246 let entry = batch_descriptions
2247 .entry(bounded_data_file.batch_desc().clone())
2248 .or_default();
2249 entry.data_files.push(bounded_data_file);
2250 }
2251 }
2252 Event::Progress(frontier) => {
2253 input_frontier = frontier;
2254 }
2255 }
2256 }
2257
2258 let mut done_batches: Vec<_> = batch_descriptions
2264 .keys()
2265 .filter(|(lower, _upper)| PartialOrder::less_than(lower, &input_frontier))
2266 .cloned()
2267 .collect();
2268
2269 done_batches.sort_by(|a, b| {
2271 if PartialOrder::less_than(a, b) {
2272 Ordering::Less
2273 } else if PartialOrder::less_than(b, a) {
2274 Ordering::Greater
2275 } else {
2276 Ordering::Equal
2277 }
2278 });
2279
2280 for batch in done_batches {
2281 let file_set = batch_descriptions.remove(&batch).unwrap();
2282
2283 let mut data_files = vec![];
2284 let mut delete_files = vec![];
2285 let mut total_messages: u64 = 0;
2287 let mut total_bytes: u64 = 0;
2288 for file in file_set.data_files {
2289 total_messages += file.data_file().record_count();
2290 total_bytes += file.data_file().file_size_in_bytes();
2291 match file.data_file().content_type() {
2292 iceberg::spec::DataContentType::Data => {
2293 data_files.push(file.into_data_file());
2294 }
2295 iceberg::spec::DataContentType::PositionDeletes
2296 | iceberg::spec::DataContentType::EqualityDeletes => {
2297 delete_files.push(file.into_data_file());
2298 }
2299 }
2300 }
2301
2302 debug!(
2303 ?sink_id,
2304 %name_for_logging,
2305 lower = %batch.0.pretty(),
2306 upper = %batch.1.pretty(),
2307 data_files = data_files.len(),
2308 delete_files = delete_files.len(),
2309 total_messages,
2310 total_bytes,
2311 "iceberg commit applying batch"
2312 );
2313
2314 let instant = Instant::now();
2315
2316 let frontier = batch.1.clone();
2317 let frontier_json = serde_json::to_string(&frontier.elements())
2318 .context("Failed to serialize frontier to JSON")?;
2319 let snapshot_properties = vec![
2320 ("mz-sink-id".to_string(), sink_id.to_string()),
2321 ("mz-frontier".to_string(), frontier_json),
2322 ("mz-sink-version".to_string(), sink_version.to_string()),
2323 ];
2324
2325 let (table_state, commit_result) = Retry::default()
2326 .max_tries(5)
2327 .retry_async_with_state(table, |_, table| {
2328 let snapshot_properties = snapshot_properties.clone();
2329 let data_files = data_files.clone();
2330 let delete_files = delete_files.clone();
2331 let metrics = Arc::clone(&metrics);
2332 let catalog = Arc::clone(&catalog);
2333 let conn_namespace = connection.namespace.clone();
2334 let conn_table = connection.table.clone();
2335 let frontier = frontier.clone();
2336 let batch_lower = batch.0.clone();
2337 let batch_upper = batch.1.clone();
2338 async move {
2339 try_commit_batch(
2340 table,
2341 snapshot_properties,
2342 data_files,
2343 delete_files,
2344 catalog.as_ref(),
2345 &conn_namespace,
2346 &conn_table,
2347 sink_version,
2348 &frontier,
2349 &batch_lower,
2350 &batch_upper,
2351 &metrics,
2352 )
2353 .await
2354 }
2355 })
2356 .await;
2357 let commit_result = commit_result.with_context(|| {
2358 format!(
2359 "failed to commit batch to Iceberg table '{}.{}'",
2360 connection.namespace, connection.table
2361 )
2362 });
2363 table = table_state;
2364 let duration = instant.elapsed();
2365 metrics
2366 .commit_duration_seconds
2367 .observe(duration.as_secs_f64());
2368 commit_result?;
2369
2370 debug!(
2371 ?sink_id,
2372 %name_for_logging,
2373 lower = %batch.0.pretty(),
2374 upper = %batch.1.pretty(),
2375 total_messages,
2376 total_bytes,
2377 ?duration,
2378 "iceberg commit applied batch"
2379 );
2380
2381 metrics.snapshots_committed.inc();
2382 statistics.inc_messages_committed_by(total_messages);
2383 statistics.inc_bytes_committed_by(total_bytes);
2384
2385 let mut expect_upper = write_handle.shared_upper();
2386 loop {
2387 if PartialOrder::less_equal(&frontier, &expect_upper) {
2388 break;
2390 }
2391
2392 const EMPTY: &[((SourceData, ()), Timestamp, StorageDiff)] = &[];
2393 match write_handle
2394 .compare_and_append(EMPTY, expect_upper, frontier.clone())
2395 .await
2396 .expect("valid usage")
2397 {
2398 Ok(()) => break,
2399 Err(mismatch) => {
2400 expect_upper = mismatch.current;
2401 }
2402 }
2403 }
2404 write_frontier.borrow_mut().clone_from(&frontier);
2405 }
2406 }
2407
2408 Ok(())
2409 })
2410 });
2411
2412 let statuses = errors.map(|error| HealthStatusMessage {
2413 id: None,
2414 update: HealthStatusUpdate::halting(format!("{}", error.display_with_causes()), None),
2415 namespace: StatusNamespace::Iceberg,
2416 });
2417
2418 (statuses, button.press_on_drop())
2419}
2420
2421impl<'scope> SinkRender<'scope> for IcebergSinkConnection {
2422 fn get_key_indices(&self) -> Option<&[usize]> {
2423 self.key_desc_and_indices
2424 .as_ref()
2425 .map(|(_, indices)| indices.as_slice())
2426 }
2427
2428 fn get_relation_key_indices(&self) -> Option<&[usize]> {
2429 self.relation_key_indices.as_deref()
2430 }
2431
2432 fn render_sink(
2433 &self,
2434 storage_state: &mut StorageState,
2435 sink: &StorageSinkDesc<CollectionMetadata, Timestamp>,
2436 sink_id: GlobalId,
2437 batches: SinkBatchStream<'scope>,
2438 key_is_synthetic: bool,
2439 _err_collection: VecCollection<'scope, Timestamp, DataflowError, Diff>,
2440 ) -> (
2441 StreamVec<'scope, Timestamp, HealthStatusMessage>,
2442 Vec<PressOnDropButton>,
2443 ) {
2444 let scope = batches.scope();
2445
2446 let (input, walker_button) = walk_sink_arrangement(
2447 format!("{sink_id}-iceberg-walker"),
2448 batches,
2449 sink_id,
2450 sink.from,
2451 key_is_synthetic,
2452 );
2453
2454 let write_handle = {
2455 let persist = Arc::clone(&storage_state.persist_clients);
2456 let shard_meta = sink.to_storage_metadata.clone();
2457 async move {
2458 let client = persist.open(shard_meta.persist_location).await?;
2459 let handle = client
2460 .open_writer(
2461 shard_meta.data_shard,
2462 Arc::new(shard_meta.relation_desc),
2463 Arc::new(UnitSchema),
2464 Diagnostics::from_purpose("sink handle"),
2465 )
2466 .await?;
2467 Ok(handle)
2468 }
2469 };
2470
2471 let write_frontier = Rc::new(RefCell::new(Antichain::from_elem(Timestamp::minimum())));
2472 storage_state
2473 .sink_write_frontiers
2474 .insert(sink_id, Rc::clone(&write_frontier));
2475
2476 let (arrow_schema_with_ids, iceberg_schema) =
2477 match (|| -> Result<(ArrowSchema, Arc<Schema>), anyhow::Error> {
2478 let (arrow_schema_with_ids, iceberg_schema) =
2479 relation_desc_to_iceberg_schema(&sink.from_desc)?;
2480
2481 Ok(if sink.envelope == SinkEnvelope::Append {
2482 let extended_arrow = build_schema_with_append_columns(&arrow_schema_with_ids);
2487 let extended_iceberg = Arc::new(
2488 arrow_schema_to_schema(&extended_arrow)
2489 .context("Failed to build Iceberg schema with append columns")?,
2490 );
2491 (extended_arrow, extended_iceberg)
2492 } else {
2493 (arrow_schema_with_ids, iceberg_schema)
2494 })
2495 })() {
2496 Ok(schemas) => schemas,
2497 Err(err) => {
2498 let error_stream = std::iter::once(HealthStatusMessage {
2499 id: None,
2500 update: HealthStatusUpdate::halting(
2501 format!("{}", err.display_with_causes()),
2502 None,
2503 ),
2504 namespace: StatusNamespace::Iceberg,
2505 })
2506 .to_stream(scope);
2507 return (error_stream, vec![]);
2508 }
2509 };
2510
2511 let metrics = Arc::new(
2512 storage_state
2513 .metrics
2514 .get_iceberg_sink_metrics(sink_id, scope.index()),
2515 );
2516
2517 let statistics = storage_state
2518 .aggregated_statistics
2519 .get_sink(&sink_id)
2520 .expect("statistics initialized")
2521 .clone();
2522
2523 let connection_for_minter = self.clone();
2524 let (minted_input, batch_descriptions, table_ready, mint_status, mint_button) =
2525 mint_batch_descriptions(
2526 format!("{sink_id}-iceberg-mint"),
2527 sink_id,
2528 input,
2529 sink,
2530 connection_for_minter,
2531 storage_state.storage_configuration.clone(),
2532 Arc::clone(&iceberg_schema),
2533 );
2534
2535 let connection_for_writer = self.clone();
2536 let (datafiles, write_status, write_button) = match sink.envelope {
2537 SinkEnvelope::Upsert => write_data_files::<UpsertEnvelopeHandler>(
2538 format!("{sink_id}-write-data-files"),
2539 minted_input,
2540 batch_descriptions.clone(),
2541 table_ready.clone(),
2542 sink.as_of.clone(),
2543 connection_for_writer,
2544 storage_state.storage_configuration.clone(),
2545 Arc::new(arrow_schema_with_ids.clone()),
2546 Arc::clone(&metrics),
2547 statistics.clone(),
2548 ),
2549 SinkEnvelope::Append => write_data_files::<AppendEnvelopeHandler>(
2550 format!("{sink_id}-write-data-files"),
2551 minted_input,
2552 batch_descriptions.clone(),
2553 table_ready.clone(),
2554 sink.as_of.clone(),
2555 connection_for_writer,
2556 storage_state.storage_configuration.clone(),
2557 Arc::new(arrow_schema_with_ids.clone()),
2558 Arc::clone(&metrics),
2559 statistics.clone(),
2560 ),
2561 SinkEnvelope::Debezium => {
2562 unreachable!("Iceberg sink only supports Upsert and Append envelopes")
2563 }
2564 };
2565
2566 let connection_for_committer = self.clone();
2567 let (commit_status, commit_button) = commit_to_iceberg(
2568 format!("{sink_id}-commit-to-iceberg"),
2569 sink_id,
2570 sink.version,
2571 datafiles,
2572 batch_descriptions,
2573 table_ready,
2574 Rc::clone(&write_frontier),
2575 connection_for_committer,
2576 storage_state.storage_configuration.clone(),
2577 write_handle,
2578 Arc::clone(&metrics),
2579 statistics,
2580 );
2581
2582 let running_status = Some(HealthStatusMessage {
2583 id: None,
2584 update: HealthStatusUpdate::running(),
2585 namespace: StatusNamespace::Iceberg,
2586 })
2587 .to_stream(scope);
2588
2589 let statuses =
2590 scope.concatenate([running_status, mint_status, write_status, commit_status]);
2591
2592 (
2593 statuses,
2594 vec![walker_button, mint_button, write_button, commit_button],
2595 )
2596 }
2597}
2598
2599fn walk_sink_arrangement<'scope>(
2606 name: String,
2607 batches: SinkBatchStream<'scope>,
2608 sink_id: GlobalId,
2609 from_id: GlobalId,
2610 key_is_synthetic: bool,
2611) -> (
2612 VecCollection<'scope, Timestamp, (Option<Row>, DiffPair<Row>), Diff>,
2613 PressOnDropButton,
2614) {
2615 let mut builder = OperatorBuilder::new(name, batches.scope());
2616 let (output, stream) = builder.new_output::<CapacityContainerBuilder<Vec<_>>>();
2617 let mut input = builder.new_input_for(batches, Pipeline, &output);
2618
2619 let button = builder.build(move |_caps| async move {
2620 let mut pk_warner = (!key_is_synthetic).then(|| PkViolationWarner::new(sink_id, from_id));
2621
2622 while let Some(event) = input.next().await {
2623 if let Event::Data(cap, mut batches) = event {
2624 for batch in batches.drain(..) {
2625 for_each_diff_pair(&batch, |key, time, diff_pair| {
2626 if let Some(warner) = pk_warner.as_mut() {
2627 warner.observe(key, time);
2628 }
2629 output.give(&cap, ((None, diff_pair), time, Diff::ONE));
2633 });
2634 if let Some(warner) = pk_warner.as_mut() {
2638 warner.flush();
2639 }
2640 }
2641 }
2642 }
2643 });
2644
2645 (stream.as_collection(), button.press_on_drop())
2646}