Skip to main content

risingwave_connector/sink/iceberg/
writer.rs

1// Copyright 2026 RisingWave Labs
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::sync::Arc;
16
17use anyhow::anyhow;
18use async_trait::async_trait;
19use await_tree::InstrumentAwait;
20use iceberg::arrow::{
21    RecordBatchPartitionSplitter, arrow_schema_to_schema, schema_to_arrow_schema,
22};
23use iceberg::spec::{
24    DataFile, FormatVersion, PartitionSpecRef, Schema, SchemaRef as IcebergSchemaRef,
25    SerializedDataFile, StructType,
26};
27use iceberg::table::Table;
28use iceberg::writer::base_writer::data_file_writer::DataFileWriterBuilder;
29use iceberg::writer::base_writer::deletion_vector_writer::{
30    DeletionVectorWriter, DeletionVectorWriterBuilder,
31};
32use iceberg::writer::base_writer::equality_delete_writer::{
33    EqualityDeleteFileWriterBuilder, EqualityDeleteWriterConfig,
34};
35use iceberg::writer::base_writer::position_delete_file_writer::{
36    POSITION_DELETE_SCHEMA, PositionDeleteFileWriter, PositionDeleteFileWriterBuilder,
37    PositionDeleteInput,
38};
39use iceberg::writer::delta_writer::{DELETE_OP, DeltaWriterBuilder, INSERT_OP};
40use iceberg::writer::file_writer::ParquetWriterBuilder;
41use iceberg::writer::file_writer::location_generator::{
42    DefaultFileNameGenerator, DefaultLocationGenerator,
43};
44use iceberg::writer::file_writer::rolling_writer::RollingFileWriterBuilder;
45use iceberg::writer::task_writer::TaskWriter;
46use iceberg::writer::{IcebergWriter, IcebergWriterBuilder};
47use itertools::Itertools;
48use parquet::file::properties::WriterProperties;
49use risingwave_common::array::arrow::IcebergArrowConvert;
50use risingwave_common::array::arrow::arrow_array_iceberg::{Int32Array, RecordBatch};
51use risingwave_common::array::arrow::arrow_schema_iceberg::{
52    DataType as ArrowDataType, Field as ArrowField, Schema as ArrowSchema, SchemaRef,
53};
54use risingwave_common::array::{Op, StreamChunk};
55use risingwave_common::bitmap::Bitmap;
56use risingwave_common::metrics::{LabelGuardedHistogram, LabelGuardedIntCounter};
57use risingwave_common_estimate_size::EstimateSize;
58use risingwave_pb::connector_service::SinkMetadata;
59use thiserror_ext::AsReport;
60use uuid::Uuid;
61
62use super::prometheus::monitored_general_writer::MonitoredGeneralWriterBuilder;
63use super::{
64    GLOBAL_SINK_METRICS, IcebergCommitResult, IcebergConfig, PARQUET_CREATED_BY, SinkError,
65    SinkWriterParam, create_and_validate_table_impl,
66};
67use crate::sink::writer::SinkWriter;
68use crate::sink::{Result, SinkParam};
69
70/// `None` means no projection is needed.
71/// `Prepare` stores the extra partition column index.
72/// `Done` stores the finalized projection indices.
73///
74/// `ProjectIdxVec` is initialized lazily. When we first encounter `Prepare`, we
75/// derive the projection indices from the current chunk schema and cache them in
76/// `Done`.
77enum ProjectIdxVec {
78    None,
79    Prepare(usize),
80    Done(Vec<usize>),
81}
82
83type DataFileWriterBuilderType =
84    DataFileWriterBuilder<ParquetWriterBuilder, DefaultLocationGenerator, DefaultFileNameGenerator>;
85/// The concrete V2 position-delete Parquet writer builder type, shared by the
86/// iceberg sink writer and the pk-index position-delete merger.
87pub type PositionDeleteFileWriterBuilderType = PositionDeleteFileWriterBuilder<
88    ParquetWriterBuilder,
89    DefaultLocationGenerator,
90    DefaultFileNameGenerator,
91>;
92type PositionDeleteFileWriterType = PositionDeleteFileWriter<
93    ParquetWriterBuilder,
94    DefaultLocationGenerator,
95    DefaultFileNameGenerator,
96>;
97type DeletionVectorWriterBuilderType =
98    DeletionVectorWriterBuilder<DefaultLocationGenerator, DefaultFileNameGenerator>;
99type DeletionVectorWriterType =
100    DeletionVectorWriter<DefaultLocationGenerator, DefaultFileNameGenerator>;
101type EqualityDeleteFileWriterBuilderType = EqualityDeleteFileWriterBuilder<
102    ParquetWriterBuilder,
103    DefaultLocationGenerator,
104    DefaultFileNameGenerator,
105>;
106
107#[derive(Clone)]
108enum PositionDeleteWriterBuilderType {
109    PositionDelete(PositionDeleteFileWriterBuilderType),
110    DeletionVector(DeletionVectorWriterBuilderType),
111}
112
113enum PositionDeleteWriterType {
114    PositionDelete(PositionDeleteFileWriterType),
115    DeletionVector(DeletionVectorWriterType),
116}
117
118#[async_trait]
119impl IcebergWriterBuilder<Vec<PositionDeleteInput>> for PositionDeleteWriterBuilderType {
120    type R = PositionDeleteWriterType;
121
122    async fn build(
123        &self,
124        partition_key: Option<iceberg::spec::PartitionKey>,
125    ) -> iceberg::Result<Self::R> {
126        match self {
127            PositionDeleteWriterBuilderType::PositionDelete(builder) => Ok(
128                PositionDeleteWriterType::PositionDelete(builder.build(partition_key).await?),
129            ),
130            PositionDeleteWriterBuilderType::DeletionVector(builder) => Ok(
131                PositionDeleteWriterType::DeletionVector(builder.build(partition_key).await?),
132            ),
133        }
134    }
135}
136
137#[async_trait]
138impl IcebergWriter<Vec<PositionDeleteInput>> for PositionDeleteWriterType {
139    async fn write(&mut self, input: Vec<PositionDeleteInput>) -> iceberg::Result<()> {
140        match self {
141            PositionDeleteWriterType::PositionDelete(writer) => writer.write(input).await,
142            PositionDeleteWriterType::DeletionVector(writer) => writer.write(input).await,
143        }
144    }
145
146    async fn close(&mut self) -> iceberg::Result<Vec<DataFile>> {
147        match self {
148            PositionDeleteWriterType::PositionDelete(writer) => writer.close().await,
149            PositionDeleteWriterType::DeletionVector(writer) => writer.close().await,
150        }
151    }
152}
153
154#[derive(Clone)]
155struct SharedIcebergWriterBuilder<B>(Arc<B>);
156
157#[async_trait]
158impl<B: IcebergWriterBuilder> IcebergWriterBuilder for SharedIcebergWriterBuilder<B> {
159    type R = B::R;
160
161    async fn build(
162        &self,
163        partition_key: Option<iceberg::spec::PartitionKey>,
164    ) -> iceberg::Result<Self::R> {
165        self.0.build(partition_key).await
166    }
167}
168
169#[derive(Clone)]
170struct TaskWriterBuilderWrapper<B: IcebergWriterBuilder> {
171    inner: Arc<B>,
172    fanout_enabled: bool,
173    schema: IcebergSchemaRef,
174    partition_spec: PartitionSpecRef,
175    compute_partition: bool,
176}
177
178impl<B: IcebergWriterBuilder> TaskWriterBuilderWrapper<B> {
179    fn new(
180        inner: B,
181        fanout_enabled: bool,
182        schema: IcebergSchemaRef,
183        partition_spec: PartitionSpecRef,
184        compute_partition: bool,
185    ) -> Self {
186        Self {
187            inner: Arc::new(inner),
188            fanout_enabled,
189            schema,
190            partition_spec,
191            compute_partition,
192        }
193    }
194
195    fn build(&self) -> iceberg::Result<TaskWriter<SharedIcebergWriterBuilder<B>>> {
196        let partition_splitter = match (
197            self.partition_spec.is_unpartitioned(),
198            self.compute_partition,
199        ) {
200            (true, _) => None,
201            (false, true) => Some(RecordBatchPartitionSplitter::try_new_with_computed_values(
202                self.schema.clone(),
203                self.partition_spec.clone(),
204            )?),
205            (false, false) => Some(
206                RecordBatchPartitionSplitter::try_new_with_precomputed_values(
207                    self.schema.clone(),
208                    self.partition_spec.clone(),
209                )?,
210            ),
211        };
212
213        Ok(TaskWriter::new_with_partition_splitter(
214            SharedIcebergWriterBuilder(self.inner.clone()),
215            self.fanout_enabled,
216            self.schema.clone(),
217            self.partition_spec.clone(),
218            partition_splitter,
219        ))
220    }
221}
222
223pub enum IcebergSinkWriter {
224    Created(IcebergSinkWriterArgs),
225    Initialized(IcebergSinkWriterInner),
226}
227
228pub struct IcebergSinkWriterArgs {
229    config: IcebergConfig,
230    sink_param: SinkParam,
231    writer_param: SinkWriterParam,
232    upsert_primary_key_column_names: Option<Vec<String>>,
233}
234
235pub struct IcebergSinkWriterInner {
236    writer: IcebergWriterDispatch,
237    arrow_schema: SchemaRef,
238    // See comments below
239    metrics: IcebergWriterMetrics,
240    // State of iceberg table for this writer
241    table: Table,
242    actor_id: String,
243    sink_id: String,
244    sink_name: String,
245    table_name: String,
246    writer_mode: &'static str,
247    // For chunk with extra partition column, we should remove this column before write.
248    // This project index vec is used to avoid create project idx each time.
249    project_idx_vec: ProjectIdxVec,
250}
251
252enum IcebergWriterDispatch {
253    Append {
254        writer: Option<Box<dyn IcebergWriter>>,
255        writer_builder:
256            TaskWriterBuilderWrapper<MonitoredGeneralWriterBuilder<DataFileWriterBuilderType>>,
257    },
258    Upsert {
259        writer: Option<Box<dyn IcebergWriter>>,
260        writer_builder: TaskWriterBuilderWrapper<
261            MonitoredGeneralWriterBuilder<
262                DeltaWriterBuilder<
263                    DataFileWriterBuilderType,
264                    PositionDeleteWriterBuilderType,
265                    EqualityDeleteFileWriterBuilderType,
266                >,
267            >,
268        >,
269        arrow_schema_with_op_column: SchemaRef,
270    },
271}
272
273impl IcebergWriterDispatch {
274    pub fn get_writer(&mut self) -> Option<&mut Box<dyn IcebergWriter>> {
275        match self {
276            IcebergWriterDispatch::Append { writer, .. }
277            | IcebergWriterDispatch::Upsert { writer, .. } => writer.as_mut(),
278        }
279    }
280}
281
282pub struct IcebergWriterMetrics {
283    // NOTE: These 2 metrics are not used directly by us, but only kept for lifecycle management.
284    // They are actually used in `PrometheusWriterBuilder`:
285    //     WriterMetrics::new(write_qps.deref().clone(), write_latency.deref().clone())
286    // We keep them here to let the guard cleans the labels from metrics registry when dropped
287    _write_qps: LabelGuardedIntCounter,
288    _write_latency: LabelGuardedHistogram,
289    write_bytes: LabelGuardedIntCounter,
290}
291
292impl IcebergSinkWriter {
293    pub fn new(
294        config: IcebergConfig,
295        sink_param: SinkParam,
296        writer_param: SinkWriterParam,
297        upsert_primary_key_column_names: Option<Vec<String>>,
298    ) -> Self {
299        Self::Created(IcebergSinkWriterArgs {
300            config,
301            sink_param,
302            writer_param,
303            upsert_primary_key_column_names,
304        })
305    }
306}
307
308pub(super) fn resolve_equality_delete_field_ids(
309    primary_key_column_names: &[String],
310    iceberg_schema: &iceberg::spec::Schema,
311) -> Result<Vec<i32>> {
312    primary_key_column_names
313        .iter()
314        .map(|column_name| {
315            iceberg_schema.field_id_by_name(column_name).ok_or_else(|| {
316                SinkError::Config(anyhow!(
317                    "Primary key column {} not found in Iceberg schema",
318                    column_name
319                ))
320            })
321        })
322        .collect()
323}
324
325impl IcebergSinkWriterInner {
326    pub fn build_append_only(
327        config: &IcebergConfig,
328        table: Table,
329        writer_param: &SinkWriterParam,
330    ) -> Result<Self> {
331        let SinkWriterParam {
332            extra_partition_col_idx,
333            actor_id,
334            sink_id,
335            sink_name,
336            ..
337        } = writer_param;
338        let metrics_labels = [
339            &actor_id.to_string(),
340            &sink_id.to_string(),
341            sink_name.as_str(),
342        ];
343
344        // Metrics
345        let write_qps = GLOBAL_SINK_METRICS
346            .iceberg_write_qps
347            .with_guarded_label_values(&metrics_labels);
348        let write_latency = GLOBAL_SINK_METRICS
349            .iceberg_write_latency
350            .with_guarded_label_values(&metrics_labels);
351        // # TODO
352        // Unused. Add this metrics later.
353        let _rolling_unflushed_data_file = GLOBAL_SINK_METRICS
354            .iceberg_rolling_unflushed_data_file
355            .with_guarded_label_values(&metrics_labels);
356        let write_bytes = GLOBAL_SINK_METRICS
357            .iceberg_write_bytes
358            .with_guarded_label_values(&metrics_labels);
359
360        let schema = table.metadata().current_schema();
361        let partition_spec = table.metadata().default_partition_spec();
362        let fanout_enabled = !partition_spec.fields().is_empty();
363        let table_name = table.identifier().to_string();
364        // To avoid duplicate file name, each time the sink created will generate a unique uuid as file name suffix.
365        let unique_uuid_suffix = Uuid::now_v7();
366        tracing::info!(
367            iceberg_component = "sink_writer",
368            iceberg_operation = "build_writer",
369            actor_id = %actor_id,
370            sink_id = %sink_id,
371            sink_name = %sink_name,
372            table = %table_name,
373            writer_mode = "append_only",
374            schema_id = table.metadata().current_schema_id(),
375            partition_spec_id = table.metadata().default_partition_spec_id(),
376            format_version = ?table.metadata().format_version(),
377            fanout_enabled,
378            target_file_size_mb = config.target_file_size_mb(),
379            parquet_compression = ?config.get_parquet_compression(),
380            "iceberg_sink_writer_initialized",
381        );
382
383        let parquet_writer_properties = WriterProperties::builder()
384            .set_compression(config.get_parquet_compression())
385            .set_max_row_group_bytes(config.write_parquet_max_row_group_bytes())
386            .set_created_by(PARQUET_CREATED_BY.to_owned())
387            .build();
388
389        let parquet_writer_builder =
390            ParquetWriterBuilder::new(parquet_writer_properties, schema.clone());
391        let rolling_builder = RollingFileWriterBuilder::new(
392            parquet_writer_builder,
393            (config.target_file_size_mb() * 1024 * 1024) as usize,
394            table.file_io().clone(),
395            DefaultLocationGenerator::new(table.metadata().clone())
396                .map_err(|err| SinkError::Iceberg(anyhow!(err)))?,
397            DefaultFileNameGenerator::new(
398                writer_param.actor_id.to_string(),
399                Some(unique_uuid_suffix.to_string()),
400                iceberg::spec::DataFileFormat::Parquet,
401            ),
402        );
403        let data_file_builder = DataFileWriterBuilder::new(rolling_builder);
404        let monitored_builder = MonitoredGeneralWriterBuilder::new(
405            data_file_builder,
406            write_qps.clone(),
407            write_latency.clone(),
408        );
409        let writer_builder = TaskWriterBuilderWrapper::new(
410            monitored_builder,
411            fanout_enabled,
412            schema.clone(),
413            partition_spec.clone(),
414            true,
415        );
416        let inner_writer = Some(Box::new(
417            writer_builder
418                .build()
419                .map_err(|err| SinkError::Iceberg(anyhow!(err)))?,
420        ) as Box<dyn IcebergWriter>);
421        Ok(Self {
422            arrow_schema: Arc::new(
423                schema_to_arrow_schema(table.metadata().current_schema())
424                    .map_err(|err| SinkError::Iceberg(anyhow!(err)))?,
425            ),
426            metrics: IcebergWriterMetrics {
427                _write_qps: write_qps,
428                _write_latency: write_latency,
429                write_bytes,
430            },
431            writer: IcebergWriterDispatch::Append {
432                writer: inner_writer,
433                writer_builder,
434            },
435            table,
436            actor_id: actor_id.to_string(),
437            sink_id: sink_id.to_string(),
438            sink_name: sink_name.clone(),
439            table_name,
440            writer_mode: "append_only",
441            project_idx_vec: {
442                if let Some(extra_partition_col_idx) = extra_partition_col_idx {
443                    ProjectIdxVec::Prepare(*extra_partition_col_idx)
444                } else {
445                    ProjectIdxVec::None
446                }
447            },
448        })
449    }
450
451    pub fn build_upsert(
452        config: &IcebergConfig,
453        table: Table,
454        primary_key_column_names: Vec<String>,
455        writer_param: &SinkWriterParam,
456    ) -> Result<Self> {
457        let SinkWriterParam {
458            extra_partition_col_idx,
459            actor_id,
460            sink_id,
461            sink_name,
462            ..
463        } = writer_param;
464        let metrics_labels = [
465            &actor_id.to_string(),
466            &sink_id.to_string(),
467            sink_name.as_str(),
468        ];
469
470        // Metrics
471        let write_qps = GLOBAL_SINK_METRICS
472            .iceberg_write_qps
473            .with_guarded_label_values(&metrics_labels);
474        let write_latency = GLOBAL_SINK_METRICS
475            .iceberg_write_latency
476            .with_guarded_label_values(&metrics_labels);
477        // # TODO
478        // Unused. Add this metrics later.
479        let _rolling_unflushed_data_file = GLOBAL_SINK_METRICS
480            .iceberg_rolling_unflushed_data_file
481            .with_guarded_label_values(&metrics_labels);
482        let write_bytes = GLOBAL_SINK_METRICS
483            .iceberg_write_bytes
484            .with_guarded_label_values(&metrics_labels);
485
486        // Determine the schema id and partition spec id
487        let schema = table.metadata().current_schema();
488        let unique_column_ids =
489            resolve_equality_delete_field_ids(&primary_key_column_names, schema)?;
490        let partition_spec = table.metadata().default_partition_spec();
491        let fanout_enabled = !partition_spec.fields().is_empty();
492        let use_deletion_vectors = table.metadata().format_version() >= FormatVersion::V3;
493        let table_name = table.identifier().to_string();
494
495        // To avoid duplicate file name, each time the sink created will generate a unique uuid as file name suffix.
496        let unique_uuid_suffix = Uuid::now_v7();
497        tracing::info!(
498            iceberg_component = "sink_writer",
499            iceberg_operation = "build_writer",
500            actor_id = %actor_id,
501            sink_id = %sink_id,
502            sink_name = %sink_name,
503            table = %table_name,
504            writer_mode = "upsert",
505            schema_id = table.metadata().current_schema_id(),
506            partition_spec_id = table.metadata().default_partition_spec_id(),
507            format_version = ?table.metadata().format_version(),
508            primary_key_columns = ?primary_key_column_names,
509            equality_delete_field_ids = ?unique_column_ids,
510            use_deletion_vectors,
511            fanout_enabled,
512            target_file_size_mb = config.target_file_size_mb(),
513            parquet_compression = ?config.get_parquet_compression(),
514            "iceberg_sink_writer_initialized",
515        );
516
517        let parquet_writer_properties = WriterProperties::builder()
518            .set_compression(config.get_parquet_compression())
519            .set_max_row_group_bytes(config.write_parquet_max_row_group_bytes())
520            .set_created_by(PARQUET_CREATED_BY.to_owned())
521            .build();
522
523        let data_file_builder = {
524            let parquet_writer_builder =
525                ParquetWriterBuilder::new(parquet_writer_properties.clone(), schema.clone());
526            let rolling_writer_builder = RollingFileWriterBuilder::new(
527                parquet_writer_builder,
528                (config.target_file_size_mb() * 1024 * 1024) as usize,
529                table.file_io().clone(),
530                DefaultLocationGenerator::new(table.metadata().clone())
531                    .map_err(|err| SinkError::Iceberg(anyhow!(err)))?,
532                DefaultFileNameGenerator::new(
533                    writer_param.actor_id.to_string(),
534                    Some(unique_uuid_suffix.to_string()),
535                    iceberg::spec::DataFileFormat::Parquet,
536                ),
537            );
538            DataFileWriterBuilder::new(rolling_writer_builder)
539        };
540        let position_delete_builder = if use_deletion_vectors {
541            let location_generator = DefaultLocationGenerator::new(table.metadata().clone())
542                .map_err(|err| SinkError::Iceberg(anyhow!(err)))?;
543            PositionDeleteWriterBuilderType::DeletionVector(DeletionVectorWriterBuilder::new(
544                table.file_io().clone(),
545                location_generator,
546                DefaultFileNameGenerator::new(
547                    writer_param.actor_id.to_string(),
548                    Some(format!("delvec-{}", unique_uuid_suffix)),
549                    iceberg::spec::DataFileFormat::Puffin,
550                ),
551            ))
552        } else {
553            let parquet_writer_builder = ParquetWriterBuilder::new(
554                parquet_writer_properties.clone(),
555                POSITION_DELETE_SCHEMA.clone().into(),
556            );
557            let rolling_writer_builder = RollingFileWriterBuilder::new(
558                parquet_writer_builder,
559                (config.target_file_size_mb() * 1024 * 1024) as usize,
560                table.file_io().clone(),
561                DefaultLocationGenerator::new(table.metadata().clone())
562                    .map_err(|err| SinkError::Iceberg(anyhow!(err)))?,
563                DefaultFileNameGenerator::new(
564                    writer_param.actor_id.to_string(),
565                    Some(format!("pos-del-{}", unique_uuid_suffix)),
566                    iceberg::spec::DataFileFormat::Parquet,
567                ),
568            );
569            PositionDeleteWriterBuilderType::PositionDelete(PositionDeleteFileWriterBuilder::new(
570                rolling_writer_builder,
571            ))
572        };
573        let equality_delete_builder = {
574            let eq_del_config = EqualityDeleteWriterConfig::new(
575                unique_column_ids.clone(),
576                table.metadata().current_schema().clone(),
577            )
578            .map_err(|err| SinkError::Iceberg(anyhow!(err)))?;
579            let parquet_writer_builder = ParquetWriterBuilder::new(
580                parquet_writer_properties,
581                Arc::new(
582                    arrow_schema_to_schema(eq_del_config.projected_arrow_schema_ref())
583                        .map_err(|err| SinkError::Iceberg(anyhow!(err)))?,
584                ),
585            );
586            let rolling_writer_builder = RollingFileWriterBuilder::new(
587                parquet_writer_builder,
588                (config.target_file_size_mb() * 1024 * 1024) as usize,
589                table.file_io().clone(),
590                DefaultLocationGenerator::new(table.metadata().clone())
591                    .map_err(|err| SinkError::Iceberg(anyhow!(err)))?,
592                DefaultFileNameGenerator::new(
593                    writer_param.actor_id.to_string(),
594                    Some(format!("eq-del-{}", unique_uuid_suffix)),
595                    iceberg::spec::DataFileFormat::Parquet,
596                ),
597            );
598
599            EqualityDeleteFileWriterBuilder::new(rolling_writer_builder, eq_del_config)
600        };
601        let delta_builder = DeltaWriterBuilder::new(
602            data_file_builder,
603            position_delete_builder,
604            equality_delete_builder,
605            unique_column_ids,
606            schema.clone(),
607        );
608        let original_arrow_schema = Arc::new(
609            schema_to_arrow_schema(table.metadata().current_schema())
610                .map_err(|err| SinkError::Iceberg(anyhow!(err)))?,
611        );
612        let schema_with_extra_op_column = {
613            let mut new_fields = original_arrow_schema.fields().iter().cloned().collect_vec();
614            new_fields.push(Arc::new(ArrowField::new(
615                "op".to_owned(),
616                ArrowDataType::Int32,
617                false,
618            )));
619            Arc::new(ArrowSchema::new(new_fields))
620        };
621        let writer_builder = TaskWriterBuilderWrapper::new(
622            MonitoredGeneralWriterBuilder::new(
623                delta_builder,
624                write_qps.clone(),
625                write_latency.clone(),
626            ),
627            fanout_enabled,
628            schema.clone(),
629            partition_spec.clone(),
630            true,
631        );
632        let inner_writer = Some(Box::new(
633            writer_builder
634                .build()
635                .map_err(|err| SinkError::Iceberg(anyhow!(err)))?,
636        ) as Box<dyn IcebergWriter>);
637        Ok(Self {
638            arrow_schema: original_arrow_schema,
639            metrics: IcebergWriterMetrics {
640                _write_qps: write_qps,
641                _write_latency: write_latency,
642                write_bytes,
643            },
644            table,
645            writer: IcebergWriterDispatch::Upsert {
646                writer: inner_writer,
647                writer_builder,
648                arrow_schema_with_op_column: schema_with_extra_op_column,
649            },
650            project_idx_vec: {
651                if let Some(extra_partition_col_idx) = extra_partition_col_idx {
652                    ProjectIdxVec::Prepare(*extra_partition_col_idx)
653                } else {
654                    ProjectIdxVec::None
655                }
656            },
657            actor_id: actor_id.to_string(),
658            sink_id: sink_id.to_string(),
659            sink_name: sink_name.clone(),
660            table_name,
661            writer_mode: "upsert",
662        })
663    }
664
665    fn prepare_writer(&mut self) -> Result<()> {
666        match &mut self.writer {
667            IcebergWriterDispatch::Append {
668                writer,
669                writer_builder,
670            } => {
671                if writer.is_none() {
672                    *writer = Some(Box::new(
673                        writer_builder
674                            .build()
675                            .map_err(|err| SinkError::Iceberg(anyhow!(err)))?,
676                    ));
677                }
678            }
679            IcebergWriterDispatch::Upsert {
680                writer,
681                writer_builder,
682                ..
683            } => {
684                if writer.is_none() {
685                    *writer = Some(Box::new(
686                        writer_builder
687                            .build()
688                            .map_err(|err| SinkError::Iceberg(anyhow!(err)))?,
689                    ));
690                }
691            }
692        };
693        Ok(())
694    }
695
696    fn process_chunk(&mut self, chunk: StreamChunk) -> Result<Option<(RecordBatch, usize)>> {
697        let (mut chunk, ops) = chunk.compact_vis().into_parts();
698        match &mut self.project_idx_vec {
699            ProjectIdxVec::None => {}
700            ProjectIdxVec::Prepare(idx) => {
701                if *idx >= chunk.columns().len() {
702                    return Err(SinkError::Iceberg(anyhow!(
703                        "invalid extra partition column index {}",
704                        idx
705                    )));
706                }
707                let project_idx_vec = (0..*idx)
708                    .chain(*idx + 1..chunk.columns().len())
709                    .collect_vec();
710                chunk = chunk.project(&project_idx_vec);
711                self.project_idx_vec = ProjectIdxVec::Done(project_idx_vec);
712            }
713            ProjectIdxVec::Done(idx_vec) => {
714                chunk = chunk.project(idx_vec);
715            }
716        }
717        if ops.is_empty() {
718            return Ok(None);
719        }
720        let write_batch_size = chunk.estimated_heap_size();
721        let batch = match &self.writer {
722            IcebergWriterDispatch::Append { .. } => {
723                // separate out insert chunk
724                let filters =
725                    chunk.visibility() & ops.iter().map(|op| *op == Op::Insert).collect::<Bitmap>();
726                chunk.set_visibility(filters);
727                IcebergArrowConvert
728                    .to_record_batch(self.arrow_schema.clone(), &chunk.compact_vis())
729                    .map_err(|err| SinkError::Iceberg(anyhow!(err)))?
730            }
731            IcebergWriterDispatch::Upsert {
732                arrow_schema_with_op_column,
733                ..
734            } => {
735                let chunk = IcebergArrowConvert
736                    .to_record_batch(self.arrow_schema.clone(), &chunk)
737                    .map_err(|err| SinkError::Iceberg(anyhow!(err)))?;
738                let ops = Arc::new(Int32Array::from(
739                    ops.iter()
740                        .map(|op| match op {
741                            Op::UpdateInsert | Op::Insert => INSERT_OP,
742                            Op::UpdateDelete | Op::Delete => DELETE_OP,
743                        })
744                        .collect_vec(),
745                ));
746                let mut columns = chunk.columns().to_vec();
747                columns.push(ops);
748                RecordBatch::try_new(arrow_schema_with_op_column.clone(), columns)
749                    .map_err(|err| SinkError::Iceberg(anyhow!(err)))?
750            }
751        };
752        Ok(Some((batch, write_batch_size)))
753    }
754
755    pub async fn write_batch(&mut self, chunk: StreamChunk) -> Result<()> {
756        self.prepare_writer()?;
757        let Some((batch, write_batch_size)) = self.process_chunk(chunk)? else {
758            return Ok(());
759        };
760
761        let writer = self.writer.get_writer().unwrap();
762        let batch_rows = batch.num_rows();
763        let batch_columns = batch.num_columns();
764        writer
765            .write(batch)
766            .instrument_await("iceberg_write")
767            .await
768            .inspect_err(|err| {
769                tracing::error!(
770                    iceberg_component = "sink_writer",
771                    iceberg_operation = "write_batch",
772                    actor_id = %self.actor_id,
773                    sink_id = %self.sink_id,
774                    sink_name = %self.sink_name,
775                    table = %self.table_name,
776                    writer_mode = self.writer_mode,
777                    batch_rows,
778                    batch_columns,
779                    write_bytes = write_batch_size,
780                    error = %err.as_report(),
781                    "iceberg_sink_writer_write_failed",
782                );
783            })?;
784        self.metrics.write_bytes.inc_by(write_batch_size as _);
785        Ok(())
786    }
787
788    pub async fn write_batch_with_position(
789        &mut self,
790        chunk: StreamChunk,
791    ) -> Result<Vec<PositionDeleteInput>> {
792        debug_assert!(
793            matches!(self.writer, IcebergWriterDispatch::Append { .. }),
794            "write_batch_with_position should only be called for append-only writer"
795        );
796
797        self.prepare_writer()?;
798        let Some((batch, write_batch_size)) = self.process_chunk(chunk)? else {
799            return Ok(vec![]);
800        };
801
802        let writer = self.writer.get_writer().unwrap();
803        let batch_rows = batch.num_rows();
804        let batch_columns = batch.num_columns();
805        let positions = writer
806            .write_with_position(batch)
807            .instrument_await("iceberg_write")
808            .await
809            .inspect_err(|err| {
810                tracing::error!(
811                    iceberg_component = "sink_writer",
812                    iceberg_operation = "write_batch_with_position",
813                    actor_id = %self.actor_id,
814                    sink_id = %self.sink_id,
815                    sink_name = %self.sink_name,
816                    table = %self.table_name,
817                    writer_mode = self.writer_mode,
818                    batch_rows,
819                    batch_columns,
820                    write_bytes = write_batch_size,
821                    error = %err.as_report(),
822                    "iceberg_sink_writer_write_with_position_failed",
823                );
824            })?;
825        self.metrics.write_bytes.inc_by(write_batch_size as _);
826        Ok(positions)
827    }
828
829    pub async fn close(&mut self) -> Result<Option<Vec<DataFile>>> {
830        let res = match &mut self.writer {
831            IcebergWriterDispatch::Append {
832                writer,
833                writer_builder,
834            } => {
835                let close_result = match writer.take() {
836                    Some(mut writer) => {
837                        let data_files = writer.close().instrument_await("iceberg_close").await?;
838                        Some(data_files)
839                    }
840                    _ => None,
841                };
842                match writer_builder.build() {
843                    Ok(new_writer) => {
844                        *writer = Some(Box::new(new_writer));
845                    }
846                    Err(err) => {
847                        // In this case, the writer is closed and we can't build a new writer. But we can't return the error
848                        // here because current writer may close successfully. So we just log the error.
849                        tracing::warn!(
850                            iceberg_component = "sink_writer",
851                            iceberg_operation = "rebuild_writer",
852                            actor_id = %self.actor_id,
853                            sink_id = %self.sink_id,
854                            sink_name = %self.sink_name,
855                            table = %self.table_name,
856                            writer_mode = self.writer_mode,
857                            error = %err.as_report(),
858                            "iceberg_sink_writer_rebuild_failed_after_close",
859                        );
860                    }
861                }
862                close_result
863            }
864            IcebergWriterDispatch::Upsert {
865                writer,
866                writer_builder,
867                ..
868            } => {
869                let close_result = match writer.take() {
870                    Some(mut writer) => {
871                        let data_files = writer.close().instrument_await("iceberg_close").await?;
872                        Some(data_files)
873                    }
874                    _ => None,
875                };
876                match writer_builder.build() {
877                    Ok(new_writer) => {
878                        *writer = Some(Box::new(new_writer));
879                    }
880                    Err(err) => {
881                        // In this case, the writer is closed and we can't build a new writer. But we can't return the error
882                        // here because current writer may close successfully. So we just log the error.
883                        tracing::warn!(
884                            iceberg_component = "sink_writer",
885                            iceberg_operation = "rebuild_writer",
886                            actor_id = %self.actor_id,
887                            sink_id = %self.sink_id,
888                            sink_name = %self.sink_name,
889                            table = %self.table_name,
890                            writer_mode = self.writer_mode,
891                            error = %err.as_report(),
892                            "iceberg_sink_writer_rebuild_failed_after_close",
893                        );
894                    }
895                }
896                close_result
897            }
898        };
899        Ok(res)
900    }
901
902    pub fn generate_commit_metadata(&self, data_files: Vec<DataFile>) -> Result<SinkMetadata> {
903        let serialized_data_files = serialize_data_files_default_spec(&self.table, data_files)?;
904        SinkMetadata::try_from(&IcebergCommitResult {
905            data_files: serialized_data_files,
906            schema_id: self.table.metadata().current_schema_id(),
907            partition_spec_id: self.table.metadata().default_partition_spec_id(),
908        })
909    }
910
911    pub fn partition_type(&self) -> &iceberg::spec::StructType {
912        self.table.metadata().default_partition_type()
913    }
914}
915
916#[async_trait]
917impl SinkWriter for IcebergSinkWriter {
918    type CommitMetadata = Option<SinkMetadata>;
919
920    /// Begin a new epoch
921    async fn begin_epoch(&mut self, _epoch: u64) -> Result<()> {
922        let Self::Created(args) = self else {
923            return Ok(());
924        };
925
926        let table = create_and_validate_table_impl(&args.config, &args.sink_param).await?;
927        let inner = match &args.upsert_primary_key_column_names {
928            Some(primary_key_column_names) => IcebergSinkWriterInner::build_upsert(
929                &args.config,
930                table,
931                primary_key_column_names.clone(),
932                &args.writer_param,
933            )?,
934            None => {
935                IcebergSinkWriterInner::build_append_only(&args.config, table, &args.writer_param)?
936            }
937        };
938
939        *self = IcebergSinkWriter::Initialized(inner);
940        Ok(())
941    }
942
943    /// Write a stream chunk to sink
944    async fn write_batch(&mut self, chunk: StreamChunk) -> Result<()> {
945        let Self::Initialized(inner) = self else {
946            unreachable!("IcebergSinkWriter should be initialized before barrier");
947        };
948        inner.write_batch(chunk).await
949    }
950
951    /// Receive a barrier and mark the end of current epoch. When `is_checkpoint` is true, the sink
952    /// writer should commit the current epoch.
953    async fn barrier(&mut self, is_checkpoint: bool) -> Result<Option<SinkMetadata>> {
954        let Self::Initialized(inner) = self else {
955            unreachable!("IcebergSinkWriter should be initialized before barrier");
956        };
957
958        // Skip it if not checkpoint
959        if !is_checkpoint {
960            return Ok(None);
961        }
962
963        let data_files = inner
964            .close()
965            .await?
966            .ok_or_else(|| anyhow!("No writer to close"))?;
967        let res = inner.generate_commit_metadata(data_files)?;
968        Ok(Some(res))
969    }
970}
971
972/// Maximum size for column statistics (min/max values) in bytes.
973/// Column statistics larger than this will be truncated to avoid metadata bloat.
974/// This is especially important for large fields like JSONB, TEXT, BINARY, etc.
975///
976/// Fix for large column statistics in `DataFile` metadata that can cause OOM errors.
977/// We truncate at the `DataFile` level (before serialization) by directly modifying
978/// the public `lower_bounds` and `upper_bounds` fields.
979///
980/// This prevents metadata from ballooning to gigabytes when dealing with large
981/// JSONB, TEXT, or BINARY fields, while still preserving statistics for small fields
982/// that benefit from query optimization.
983const MAX_COLUMN_STAT_SIZE: usize = 10240; // 10KB
984
985/// Serializes `files` against the table's current default partition spec, after
986/// truncating oversized column statistics. Shared by the iceberg sink writer's
987/// commit-metadata generation and the pk-index merger's delete-file serialization.
988///
989/// Only the DEFAULT-spec path is shared here; callers that must serialize each
990/// file against its own (potentially older) partition spec keep their own logic.
991pub fn serialize_data_files_default_spec(
992    table: &Table,
993    files: Vec<DataFile>,
994) -> Result<Vec<SerializedDataFile>> {
995    let format_version = table.metadata().format_version();
996    let partition_type = table.metadata().default_partition_type();
997    files
998        .into_iter()
999        .map(|f| {
1000            // Truncate large column statistics BEFORE serialization.
1001            let truncated = truncate_datafile(f);
1002            Ok(SerializedDataFile::try_from(
1003                truncated,
1004                partition_type,
1005                format_version,
1006            )?)
1007        })
1008        .collect()
1009}
1010
1011/// Resolves a partition spec by id from the table metadata, with a consolidated
1012/// error message. Shared spec-lookup mechanic for the pk-index merger, commit
1013/// coordinator, and sink commit paths.
1014pub fn resolve_partition_spec(table: &Table, spec_id: i32) -> Result<PartitionSpecRef> {
1015    table
1016        .metadata()
1017        .partition_spec_by_id(spec_id)
1018        .cloned()
1019        .ok_or_else(|| SinkError::Iceberg(anyhow!("partition spec {} not found", spec_id)))
1020}
1021
1022/// Resolves the partition [`StructType`] for the given `spec_id` against `schema`.
1023///
1024/// `schema` is passed explicitly (rather than read from the table) so callers can
1025/// preserve their chosen schema, and `spec_id` is passed explicitly so callers can
1026/// preserve their chosen spec (e.g. a file's own spec vs. the default spec).
1027pub fn resolve_partition_type(table: &Table, spec_id: i32, schema: &Schema) -> Result<StructType> {
1028    resolve_partition_spec(table, spec_id)?
1029        .partition_type(schema)
1030        .map_err(|e| SinkError::Iceberg(anyhow!(e)))
1031}
1032
1033/// Truncate large column statistics from `DataFile` BEFORE serialization.
1034///
1035/// This function directly modifies `DataFile`'s `lower_bounds` and `upper_bounds`
1036/// to remove entries that exceed `MAX_COLUMN_STAT_SIZE`.
1037///
1038/// # Arguments
1039/// * `data_file` - A `DataFile` to process
1040///
1041/// # Returns
1042/// The modified `DataFile` with large statistics truncated
1043pub fn truncate_datafile(mut data_file: DataFile) -> DataFile {
1044    // Process lower_bounds - remove entries with large values
1045    data_file.lower_bounds.retain(|field_id, datum| {
1046        // Use to_bytes() to get the actual binary size without JSON serialization overhead
1047        let size = match datum.to_bytes() {
1048            Ok(bytes) => bytes.len(),
1049            Err(_) => 0,
1050        };
1051
1052        if size > MAX_COLUMN_STAT_SIZE {
1053            tracing::debug!(
1054                field_id = field_id,
1055                size = size,
1056                "Truncating large lower_bound statistic"
1057            );
1058            return false;
1059        }
1060        true
1061    });
1062
1063    // Process upper_bounds - remove entries with large values
1064    data_file.upper_bounds.retain(|field_id, datum| {
1065        // Use to_bytes() to get the actual binary size without JSON serialization overhead
1066        let size = match datum.to_bytes() {
1067            Ok(bytes) => bytes.len(),
1068            Err(_) => 0,
1069        };
1070
1071        if size > MAX_COLUMN_STAT_SIZE {
1072            tracing::debug!(
1073                field_id = field_id,
1074                size = size,
1075                "Truncating large upper_bound statistic"
1076            );
1077            return false;
1078        }
1079        true
1080    });
1081
1082    data_file
1083}