andygrove commented on code in PR #5361:
URL: https://github.com/apache/datafusion-comet/pull/5361#discussion_r3789709537
##########
spark/src/main/scala/org/apache/spark/sql/comet/CometMetricNode.scala:
##########
@@ -354,10 +354,22 @@ object CometMetricNode {
/**
* Creates a [[CometMetricNode]] from a [[CometPlan]].
+ *
+ * The `metrics` access is guarded against the one way it can fail: forcing
an unmaterialised
+ * `metrics` lazy val on a node whose `@transient session` is `null` NPEs
inside
+ * `SQLMetrics.createMetric(sparkContext, ...)`. That happens for a JVM-side
exec constructed
+ * off the planning thread (e.g. `AQEShuffleReadExec` built by AQE's
stage-finalisation rules
+ * when `SparkSession.getActiveSession` was `None`). It must NOT be
pre-checked via `session`:
+ * this method also runs inside task closures on executors, where `session`
is always `null`
+ * after deserialisation but `metrics` was materialised on the driver and
shipped with the plan.
+ * A node whose metrics are unreachable contributes an empty map, but its
subtree is still
+ * walked so every other operator keeps reporting.
*/
def fromCometPlan(cometPlan: SparkPlan): CometMetricNode = {
- val children = cometPlan.children.map(fromCometPlan)
- CometMetricNode(cometPlan.metrics, children)
+ val nodeMetrics =
+ try cometPlan.metrics
+ catch { case _: NullPointerException => Map.empty[String, SQLMetric] }
Review Comment:
This is much better than pruning the subtree, and the note about why the
`session` pre-check does not work is worth having. My remaining worry is that
the catch is wider than the cause you diagnosed. It will also swallow an NPE
thrown from inside any operator's own `metrics` definition, so a genuine bug in
a Comet operator's metric construction turns into that operator silently
reporting nothing instead of failing loudly.
Since the failure you are guarding is specifically a foreign JVM node built
off the planning thread, would gating on that narrow the blast radius enough?
Something like only swallowing when the node is not a `CometPlan`. Comet
operators reach here either on the driver with a live session or on executors
with `metrics` already materialised, so neither case should need the guard, and
a real NPE inside a Comet operator's metrics would still surface.
##########
native/core/src/execution/operators/iceberg_write.rs:
##########
@@ -0,0 +1,1161 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Native Iceberg write operator using iceberg-rust.
+//!
+//! Drains the upstream Arrow stream through iceberg-rust's writer stack
+//! (`ParquetWriterBuilder` -> `RollingFileWriterBuilder` ->
`DataFileWriterBuilder`
+//! -> `Unpartitioned`/`Fanout`/`Clustered`Writer) and emits a single-row,
single-column
+//! Arrow batch carrying the `Vec<DataFile>` produced for the task, packed as
an Iceberg V2
+//! data manifest via iceberg-rust's `ManifestWriter` against an in-memory
`FileIO`. The JVM
+//! decodes the bytes with `ManifestFiles.read(...)` to recover the
`DataFile`s for commit.
+
+use std::fmt;
+use std::sync::Arc;
+
+use arrow::array::{ArrayRef, BinaryArray, BooleanArray, RecordBatch};
+use arrow::datatypes::{DataType, Field, Schema as ArrowSchema, SchemaRef};
+use datafusion::error::{DataFusionError, Result as DFResult};
+use datafusion::execution::TaskContext;
+use datafusion::physical_expr::EquivalenceProperties;
+use datafusion::physical_plan::execution_plan::{Boundedness, EmissionType};
+use datafusion::physical_plan::metrics::{
+ ExecutionPlanMetricsSet, MetricBuilder, MetricsSet, Time,
+};
+use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
+use datafusion::physical_plan::{
+ DisplayAs, DisplayFormatType, ExecutionPlan, ExecutionPlanProperties,
Partitioning,
+ PlanProperties, SendableRecordBatchStream,
+};
+use futures::TryStreamExt;
+use iceberg::arrow::{
+ arrow_struct_to_literal, PartitionValueCalculator,
RecordBatchPartitionSplitter,
+};
+use iceberg::spec::{
+ DataFile, DataFileFormat, Literal, ManifestWriterBuilder, PartitionKey,
PartitionSpec,
+ PartitionSpecRef, Schema as IcebergSchema, SchemaRef as IcebergSchemaRef,
+ Struct as IcebergStruct, StructType,
+};
+use iceberg::writer::base_writer::data_file_writer::DataFileWriterBuilder;
+use iceberg::writer::file_writer::location_generator::{
+ DefaultFileNameGenerator, DefaultLocationGenerator,
+};
+use iceberg::writer::file_writer::rolling_writer::RollingFileWriterBuilder;
+use iceberg::writer::file_writer::ParquetWriterBuilder;
+use iceberg::writer::partitioning::clustered_writer::ClusteredWriter;
+use iceberg::writer::partitioning::fanout_writer::FanoutWriter;
+use iceberg::writer::partitioning::unpartitioned_writer::UnpartitionedWriter;
+#[cfg(test)]
+use parquet::arrow::PARQUET_FIELD_ID_META_KEY;
+use parquet::basic::{BrotliLevel, Compression, GzipLevel, ZstdLevel};
+use parquet::file::properties::{EnabledStatistics, WriterProperties};
+
+use datafusion_comet_proto::spark_operator::{
+ CompressionCodec as ProtoCompressionCodec, IcebergParquetWriteSettings,
IcebergWrite,
+ IcebergWriteCommon, IcebergWriterMode as ProtoIcebergWriterMode,
+};
+
+use crate::cloud::s3::credential_bridge::AccessMode;
+use crate::execution::operators::iceberg_common::load_file_io;
+
+/// Builder chain instantiated once per task and handed to the partitioning
wrapper.
+type IcebergDataFileWriterBuilder =
+ DataFileWriterBuilder<ParquetWriterBuilder, DefaultLocationGenerator,
DefaultFileNameGenerator>;
+
+/// Native Iceberg write operator. Owns the parsed Iceberg schema/spec and the
parquet writer
+/// properties; at task execution it builds the iceberg-rust writer stack,
drains the upstream
+/// Arrow stream into it, and emits a single Avro-encoded `Vec<DataFile>` row.
+pub struct IcebergWriteExec {
+ input: Arc<dyn ExecutionPlan>,
+ common: Arc<IcebergWriteCommon>,
+ iceberg_schema: IcebergSchemaRef,
+ partition_spec: PartitionSpecRef,
+ writer_mode: ProtoIcebergWriterMode,
+ writer_properties: Arc<WriterProperties>,
+ partition_id: Option<i32>,
+ task_attempt_id: Option<i64>,
+ output_schema: SchemaRef,
+ plan_properties: Arc<PlanProperties>,
+ metrics: ExecutionPlanMetricsSet,
+}
+
+impl IcebergWriteExec {
+ pub fn try_new(input: Arc<dyn ExecutionPlan>, proto: IcebergWrite) ->
DFResult<Self> {
+ let IcebergWrite {
+ common,
+ partition_id,
+ task_attempt_id,
+ } = proto;
+ let common = common.ok_or_else(|| {
+ DataFusionError::Internal("IcebergWrite missing common
payload".into())
+ })?;
+ let settings = common.parquet_settings.as_ref().ok_or_else(|| {
+ DataFusionError::Internal("IcebergWriteCommon missing
parquet_settings".into())
+ })?;
+ let writer_properties = build_writer_properties(settings)?;
+ let iceberg_schema =
parse_iceberg_schema(&common.iceberg_schema_json)?;
+ let partition_spec =
parse_partition_spec(&common.partition_spec_json)?;
+ let writer_mode =
ProtoIcebergWriterMode::try_from(common.writer_mode).map_err(|_| {
+ DataFusionError::Internal(format!(
+ "Unknown IcebergWriterMode proto value: {}",
+ common.writer_mode
+ ))
+ })?;
+ let output_schema = build_output_schema();
+ let plan_properties = Self::compute_properties(&input,
Arc::clone(&output_schema));
+ Ok(Self {
+ input,
+ common: Arc::new(common),
+ iceberg_schema,
+ partition_spec,
+ writer_mode,
+ writer_properties: Arc::new(writer_properties),
+ partition_id,
+ task_attempt_id,
+ output_schema,
+ plan_properties,
+ metrics: ExecutionPlanMetricsSet::new(),
+ })
+ }
+
+ fn compute_properties(
+ input: &Arc<dyn ExecutionPlan>,
+ schema: SchemaRef,
+ ) -> Arc<PlanProperties> {
+ Arc::new(PlanProperties::new(
+ EquivalenceProperties::new(schema),
+
Partitioning::UnknownPartitioning(input.output_partitioning().partition_count()),
+ EmissionType::Final,
+ Boundedness::Bounded,
+ ))
+ }
+}
+
+impl ExecutionPlan for IcebergWriteExec {
+ fn name(&self) -> &str {
+ "IcebergWriteExec"
+ }
+
+ fn schema(&self) -> SchemaRef {
+ Arc::clone(&self.output_schema)
+ }
+
+ fn properties(&self) -> &Arc<PlanProperties> {
+ &self.plan_properties
+ }
+
+ fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
+ vec![&self.input]
+ }
+
+ fn with_new_children(
+ self: Arc<Self>,
+ mut children: Vec<Arc<dyn ExecutionPlan>>,
+ ) -> DFResult<Arc<dyn ExecutionPlan>> {
+ if children.len() != 1 {
+ return Err(DataFusionError::Internal(
+ "IcebergWriteExec requires exactly one child".into(),
+ ));
+ }
+ Ok(Arc::new(Self {
+ input: children.pop().unwrap(),
+ common: Arc::clone(&self.common),
+ iceberg_schema: Arc::clone(&self.iceberg_schema),
+ partition_spec: Arc::clone(&self.partition_spec),
+ writer_mode: self.writer_mode,
+ writer_properties: Arc::clone(&self.writer_properties),
+ partition_id: self.partition_id,
+ task_attempt_id: self.task_attempt_id,
+ output_schema: Arc::clone(&self.output_schema),
+ plan_properties: Arc::clone(&self.plan_properties),
+ metrics: self.metrics.clone(),
+ }))
+ }
+
+ fn execute(
+ &self,
+ partition: usize,
+ context: Arc<TaskContext>,
+ ) -> DFResult<SendableRecordBatchStream> {
+ // Time spent inside the iceberg-rust writer stack (write + close),
excluding time spent
+ // waiting on the upstream input stream. Surfaced on the JVM exec's
SQL metrics by name.
+ let write_time =
MetricBuilder::new(&self.metrics).subset_time("write_time", partition);
+ let input_stream = self.input.execute(partition, context)?;
+ let common = Arc::clone(&self.common);
+ let iceberg_schema = Arc::clone(&self.iceberg_schema);
+ let partition_spec = Arc::clone(&self.partition_spec);
+ let writer_mode = self.writer_mode;
+ let writer_properties = Arc::clone(&self.writer_properties);
+ let partition_id = self.partition_id;
+ let task_attempt_id = self.task_attempt_id;
+ let output_schema = Arc::clone(&self.output_schema);
+
+ let task = async move {
+ let data_files = run_write_task(
+ input_stream,
+ Arc::clone(&common),
+ Arc::clone(&iceberg_schema),
+ Arc::clone(&partition_spec),
+ writer_mode,
+ writer_properties.as_ref().clone(),
+ partition_id,
+ task_attempt_id,
+ write_time,
+ )
+ .await?;
+ let manifest_bytes = encode_data_files_as_manifest(
+ data_files,
+ iceberg_schema,
+ partition_spec,
+ partition_id,
+ task_attempt_id,
+ &common.operation_id,
+ )
+ .await?;
+ let batch = build_output_batch(manifest_bytes, &output_schema)?;
+ Ok::<_, DataFusionError>(futures::stream::iter(vec![Ok(batch)]))
+ };
+
+ Ok(Box::pin(RecordBatchStreamAdapter::new(
+ Arc::clone(&self.output_schema),
+ futures::stream::once(task).try_flatten(),
+ )))
+ }
+
+ fn metrics(&self) -> Option<MetricsSet> {
+ Some(self.metrics.clone_inner())
+ }
+}
+
+impl fmt::Debug for IcebergWriteExec {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.debug_struct("IcebergWriteExec")
+ .field("metadata_location", &self.common.metadata_location)
+ .field("data_location", &self.common.data_location)
+ .field("operation_id", &self.common.operation_id)
+ .field("writer_mode", &self.writer_mode)
+ .field("partition_id", &self.partition_id)
+ .field("task_attempt_id", &self.task_attempt_id)
+ .finish()
+ }
+}
+
+impl DisplayAs for IcebergWriteExec {
+ fn fmt_as(&self, _t: DisplayFormatType, f: &mut fmt::Formatter) ->
fmt::Result {
+ write!(
+ f,
+ "IcebergWriteExec: metadata_location={}, data_location={},
operation_id={}",
+ self.common.metadata_location, self.common.data_location,
self.common.operation_id
+ )
+ }
+}
+
+/// One-shot per-task write coroutine. Builds the iceberg-rust writer stack,
decorates each input
+/// batch with `PARQUET_FIELD_ID_META_KEY` metadata so iceberg-rust can match
Arrow columns to
+/// Iceberg field IDs, and routes through
`UnpartitionedWriter`/`FanoutWriter`/`ClusteredWriter`
+/// depending on `writer_mode`.
+#[allow(clippy::too_many_arguments)]
+async fn run_write_task(
+ mut input: SendableRecordBatchStream,
+ common: Arc<IcebergWriteCommon>,
+ iceberg_schema: IcebergSchemaRef,
+ partition_spec: PartitionSpecRef,
+ writer_mode: ProtoIcebergWriterMode,
+ writer_properties: WriterProperties,
+ partition_id: Option<i32>,
+ task_attempt_id: Option<i64>,
+ write_time: Time,
+) -> DFResult<Vec<DataFile>> {
+ // The JVM exec wrapper stamps both ids per task; a missing id means the
plan template was
+ // executed directly, and defaulting would make every task collide on the
same file names.
+ let partition_id = partition_id.ok_or_else(|| {
+ DataFusionError::Internal("IcebergWrite executed without a
partition_id".into())
+ })?;
+ let task_attempt_id = task_attempt_id.ok_or_else(|| {
+ DataFusionError::Internal("IcebergWrite executed without a
task_attempt_id".into())
+ })?;
+ let catalog_properties = common
+ .catalog_properties
+ .iter()
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect();
+ let file_io = load_file_io(
+ &catalog_properties,
+ &common.data_location,
+ &common.catalog_name,
+ AccessMode::Write,
+ )?;
+
+ let location_generator =
+
DefaultLocationGenerator::with_data_location(common.data_location.clone());
+ let file_name_generator = DefaultFileNameGenerator::new(
+ file_name_prefix(partition_id, task_attempt_id, &common.operation_id),
+ None,
+ DataFileFormat::Parquet,
+ );
+ let parquet_builder = ParquetWriterBuilder::new(writer_properties,
Arc::clone(&iceberg_schema));
+ let rolling_builder = RollingFileWriterBuilder::new(
+ parquet_builder,
+ common.target_file_size_bytes as usize,
+ file_io,
+ location_generator,
+ file_name_generator,
+ );
+ let data_file_builder = DataFileWriterBuilder::new(rolling_builder);
+
+ let unpartitioned = partition_spec.is_unpartitioned();
+ let mut writer = match (unpartitioned, writer_mode) {
+ (true, ProtoIcebergWriterMode::IcebergWriterUnpartitioned) => {
+
InnerWriter::Unpartitioned(UnpartitionedWriter::new(data_file_builder))
+ }
+ (false, ProtoIcebergWriterMode::IcebergWriterFanout) => {
+ InnerWriter::Fanout(FanoutWriter::new(data_file_builder))
+ }
+ (false, ProtoIcebergWriterMode::IcebergWriterClustered) => {
+ InnerWriter::Clustered(ClusteredWriter::new(data_file_builder))
+ }
+ (actual, mode) => {
+ return Err(DataFusionError::Internal(format!(
+ "IcebergWrite writer_mode {mode:?} is inconsistent with the
partition spec \
+ (unpartitioned={actual})"
+ )))
+ }
+ };
+
+ let clustered_splitter = match &writer {
+ InnerWriter::Clustered(_) => Some(ClusteredBatchSplitter::try_new(
+ Arc::clone(&partition_spec),
+ Arc::clone(&iceberg_schema),
+ )?),
+ _ => None,
+ };
+ let fanout_splitter = match &writer {
+ InnerWriter::Fanout(_) => Some(
+ RecordBatchPartitionSplitter::try_new_with_computed_values(
+ Arc::clone(&iceberg_schema),
+ Arc::clone(&partition_spec),
+ )
+ .map_err(iceberg_err)?,
+ ),
+ _ => None,
+ };
+
+ // Build the field-id-decorated target schema once per task; every batch
is cast against it.
+ let target_schema =
+
Arc::new(iceberg::arrow::schema_to_arrow_schema(&iceberg_schema).map_err(iceberg_err)?);
+ while let Some(batch) = input.try_next().await? {
+ let decorated = decorate_batch_with_field_ids(batch, &target_schema)?;
+ let _timer = write_time.timer();
+ writer
+ .write(
+ decorated,
+ fanout_splitter.as_ref(),
+ clustered_splitter.as_ref(),
+ )
+ .await?;
+ }
+ let _timer = write_time.timer();
+ writer.close().await
+}
+
+/// Enum-based dispatch over the three iceberg-rust partitioning writers. Each
variant takes the
+/// same builder chain so we can keep the type fixed.
+enum InnerWriter {
+ Unpartitioned(UnpartitionedWriter<IcebergDataFileWriterBuilder>),
+ Fanout(FanoutWriter<IcebergDataFileWriterBuilder>),
+ Clustered(ClusteredWriter<IcebergDataFileWriterBuilder>),
+}
+
+impl InnerWriter {
+ async fn write(
+ &mut self,
+ batch: RecordBatch,
+ fanout_splitter: Option<&RecordBatchPartitionSplitter>,
+ clustered_splitter: Option<&ClusteredBatchSplitter>,
+ ) -> DFResult<()> {
+ use iceberg::writer::partitioning::PartitioningWriter;
+ match self {
+ InnerWriter::Unpartitioned(w) =>
w.write(batch).await.map_err(iceberg_err),
+ InnerWriter::Fanout(w) => {
+ let parts = fanout_splitter
+ .expect("fanout splitter must be Some for fanout writes")
+ .split(&batch)
+ .map_err(iceberg_err)?;
+ for (key, part) in parts {
+ w.write(key, part).await.map_err(iceberg_err)?;
+ }
+ Ok(())
+ }
+ InnerWriter::Clustered(w) => {
+ let parts = clustered_splitter
+ .expect("clustered splitter must be Some for clustered
writes")
+ .split(&batch)?;
+ for (key, part) in parts {
+ w.write(key, part).await.map_err(iceberg_err)?;
+ }
+ Ok(())
+ }
+ }
+ }
+
+ async fn close(self) -> DFResult<Vec<DataFile>> {
+ use iceberg::writer::partitioning::PartitioningWriter;
+ match self {
+ InnerWriter::Unpartitioned(w) =>
w.close().await.map_err(iceberg_err),
+ InnerWriter::Fanout(w) => w.close().await.map_err(iceberg_err),
+ InnerWriter::Clustered(w) => w.close().await.map_err(iceberg_err),
+ }
+ }
+}
+
+// --- helpers -------------------------------------------------------------
+
+fn parse_iceberg_schema(json: &str) -> DFResult<IcebergSchemaRef> {
+ let schema: IcebergSchema = serde_json::from_str(json).map_err(|e| {
+ DataFusionError::Internal(format!("Failed to parse iceberg schema
JSON: {e}"))
+ })?;
+ Ok(Arc::new(schema))
+}
+
+fn parse_partition_spec(json: &str) -> DFResult<PartitionSpecRef> {
+ let spec: PartitionSpec = serde_json::from_str(json).map_err(|e| {
+ DataFusionError::Internal(format!("Failed to parse partition spec
JSON: {e}"))
+ })?;
+ Ok(Arc::new(spec))
+}
+
+fn iceberg_err(e: iceberg::Error) -> DataFusionError {
+ DataFusionError::External(Box::new(e))
+}
+
+fn build_output_schema() -> SchemaRef {
+ Arc::new(ArrowSchema::new(vec![Field::new(
+ "iceberg_manifest",
+ DataType::Binary,
+ false,
+ )]))
+}
+
+/// Align an input batch with the field-id-decorated target schema by casting
each column. The
+/// caller is responsible for building `target_schema` once per task via
+/// `iceberg::arrow::schema_to_arrow_schema` — it carries
`PARQUET_FIELD_ID_META_KEY` on every
+/// nested field, and `arrow::compute::cast` rebuilds the column structure to
match while
+/// reusing data buffers. This is the same conformance step the iceberg-rust
DataFusion
+/// integration gets for free from DataFusion's `INSERT INTO` planner.
+fn decorate_batch_with_field_ids(
+ batch: RecordBatch,
+ target_schema: &SchemaRef,
+) -> DFResult<RecordBatch> {
+ if batch.num_columns() != target_schema.fields().len() {
+ return Err(DataFusionError::Plan(format!(
+ "Iceberg write column count mismatch: arrow batch has {} columns
but schema has {}",
+ batch.num_columns(),
+ target_schema.fields().len()
+ )));
+ }
+ // safe:false so a lossy type divergence fails the task instead of writing
silent NULLs.
+ let cast_options = arrow::compute::CastOptions {
+ safe: false,
+ ..Default::default()
+ };
+ let casted: Vec<ArrayRef> = batch
+ .columns()
+ .iter()
+ .zip(target_schema.fields().iter())
+ .map(|(col, target)| {
+ arrow::compute::cast_with_options(col, target.data_type(),
&cast_options)
+ })
+ .collect::<Result<_, _>>()
+ .map_err(DataFusionError::from)?;
+ RecordBatch::try_new(Arc::clone(target_schema),
casted).map_err(DataFusionError::from)
+}
+
+fn file_name_prefix(partition_id: i32, task_attempt_id: i64, operation_id:
&str) -> String {
+ format!("{partition_id:05}-{task_attempt_id:05}-{operation_id}")
+}
+
+/// Splits each batch into contiguous runs of equal partition value, in batch
order.
+///
+/// `RecordBatchPartitionSplitter::split` computes the partition transforms
internally and groups
+/// rows through a HashMap, which emits parts in unspecified order -- and
`ClusteredWriter`
+/// hard-errors when a closed partition is revisited, so the clustered path
needs the batch's own
+/// (partition-clustered) order back. Splitting on run boundaries preserves
that order by
+/// construction and computes the transforms exactly once. Input that is not
actually clustered
+/// yields multiple runs with the same key and surfaces the same
`ClusteredWriter` error the
+/// splitter path would have produced.
+struct ClusteredBatchSplitter {
+ calculator: PartitionValueCalculator,
+ partition_type: StructType,
+ partition_spec: PartitionSpecRef,
+ schema: IcebergSchemaRef,
+}
+
+impl ClusteredBatchSplitter {
+ fn try_new(partition_spec: PartitionSpecRef, schema: IcebergSchemaRef) ->
DFResult<Self> {
+ Ok(Self {
+ calculator: PartitionValueCalculator::try_new(&partition_spec,
&schema)
+ .map_err(iceberg_err)?,
+ partition_type: partition_spec
+ .partition_type(&schema)
+ .map_err(iceberg_err)?,
+ partition_spec,
+ schema,
+ })
+ }
+
+ fn split(&self, batch: &RecordBatch) -> DFResult<Vec<(PartitionKey,
RecordBatch)>> {
+ let partition_array =
self.calculator.calculate(batch).map_err(iceberg_err)?;
+ let literals =
+ arrow_struct_to_literal(&partition_array,
&self.partition_type).map_err(iceberg_err)?;
+ let mut runs: Vec<(IcebergStruct, usize, usize)> = Vec::new();
+ for (row, literal) in literals.into_iter().enumerate() {
+ let value = match literal {
+ Some(Literal::Struct(value)) => value,
+ other => {
+ return Err(DataFusionError::Internal(format!(
+ "partition value is not a struct literal: {other:?}"
+ )))
+ }
+ };
+ match runs.last_mut() {
+ Some((current, _, len)) if *current == value => *len += 1,
+ _ => runs.push((value, row, 1)),
+ }
+ }
+ runs.into_iter()
+ .map(|(value, start, len)| {
+ let key = PartitionKey::new(
+ self.partition_spec.as_ref().clone(),
+ Arc::clone(&self.schema),
+ value,
+ );
+ Ok((key, materialize_run(batch, start, len)?))
+ })
+ .collect()
+ }
+}
+
+/// Copy `len` rows starting at `start` out of `batch`. A zero-copy
`RecordBatch::slice` would be
+/// cheaper, but the parquet writer's NaN-count visitor reads list/map
children via
+/// `list_array.values()`, which ignores a slice's offset window -- sliced
list-of-float columns
+/// would over-count NaNs. `filter_record_batch` materialises compacted
children (exactly what
+/// `RecordBatchPartitionSplitter` produces), keeping those counts correct.
+fn materialize_run(batch: &RecordBatch, start: usize, len: usize) ->
DFResult<RecordBatch> {
+ let mut mask = vec![false; batch.num_rows()];
Review Comment:
The run-boundary split is a much better shape than what it replaced, and the
comment on why a plain `slice` is unsafe here is a good catch. But I think this
reintroduces the cost problem from the other direction.
`materialize_run` allocates a `Vec<bool>` the length of the whole batch,
bit-packs it into a `BooleanArray`, and then `filter_record_batch` has to scan
that full-length predicate to build its filter strategy. That is O(batch rows)
per run rather than O(run length), so a batch with R runs costs O(R x
batch_rows) across every column. For a low-cardinality partition that is
nothing, but a clustered write on something like `bucket(1000, id)` or an
hourly partition over a wide range can easily produce hundreds of runs in one
8192-row batch, and then this is a good deal more work than the two transform
evaluations I originally asked you to remove.
Would building one sequential `UInt32Array` of `0..num_rows` per batch and
then `take`-ing `indices.slice(start, len)` per run work here? `take` on a list
or map column gathers the referenced children into fresh compacted arrays, same
as `filter` does, so the NaN-visitor concern in your comment should still be
satisfied, and each run becomes O(run length) with the total back to O(batch
rows).
Separately, `PartitionKey::new(self.partition_spec.as_ref().clone(), ...)`
clones the whole `PartitionSpec` once per run. Small next to the above, but it
scales the same way, and it would be nice not to have both.
##########
spark/src/test/scala/org/apache/comet/CometIcebergWriteActionSuite.scala:
##########
@@ -569,6 +569,675 @@ class CometIcebergWriteActionSuite
}
}
+ // --- Round-trip parity vs Spark default path
---------------------------------------------------
+
+ // --- Native acceleration
--------------------------------------------------------------------
+
+ test("native acceleration: AppendData INSERT FROM SELECT") {
+ assumeNativeAcceleration()
+ withIcebergCatalog { warehouseDir =>
+ createTable(warehouseDir, "native_source", partitionSpec = "")
+ createTable(warehouseDir, "native_target", partitionSpec = "")
+ spark.sql(
+ "INSERT INTO cat.db.native_source VALUES " +
+ "(1, 'us-east', 10.5), (2, 'us-west', 20.3), (3, 'eu', 30.7)")
+ assertNativeWriteEngages("native_target", Seq(1, 2, 3)) {
+ spark.sql(
+ "INSERT INTO cat.db.native_target SELECT id, region, amount FROM
cat.db.native_source")
+ }
+ }
+ }
+
+ test("native acceleration: AppendData unpartitioned VALUES") {
+ assumeNativeAcceleration()
+ withIcebergCatalog { warehouseDir =>
+ createTable(warehouseDir, "native_append_values", partitionSpec = "")
+ assertNativeWriteEngages("native_append_values", Seq(1, 2, 3)) {
+ spark.sql(
+ "INSERT INTO cat.db.native_append_values VALUES " +
+ "(1, 'us-east', 10.5), (2, 'us-west', 20.3), (3, 'eu', 30.7)")
+ }
+ }
+ }
+
+ // What Iceberg's Spark writer stamps for `sort_order_id` on appended files
changed across
+ // releases: through 1.10 `SparkWrite$WriterFactory` never wires the table
sort order (files
+ // get 0 even on a sorted table); 1.11 added
`SparkWriteConf.outputSortOrderId` and stamps the
+ // resolved order id. The native path reflects the resolver when present and
defaults to 0
+ // otherwise, so pin parity against the JVM writer on the same runtime
instead of a literal.
+ test("native acceleration: appended files carry the same sort_order_id as
the JVM writer") {
+ assumeNativeAcceleration()
+ withIcebergCatalog { warehouseDir =>
+ // WRITE ORDERED BY (provided by IcebergSparkSessionExtensions, enabled
in this suite)
+ // bumps the table's sort order id to a non-default value (1).
+ Seq("sorted_native", "sorted_jvm").foreach { t =>
+ createTable(warehouseDir, t, partitionSpec = "")
+ spark.sql(s"ALTER TABLE cat.db.$t WRITE ORDERED BY id")
+ }
+ val insert = (t: String) =>
+ spark.sql(
+ s"INSERT INTO cat.db.$t VALUES " +
+ "(3, 'eu', 30.7), (1, 'us-east', 10.5), (2, 'us-west', 20.3)")
+ assertNativeWriteEngages("sorted_native", Seq(1, 2,
3))(insert("sorted_native"))
+ insert("sorted_jvm")
+
+ def sortOrderIds(t: String): Set[Int] = spark
+ .sql(s"SELECT DISTINCT sort_order_id FROM cat.db.$t.data_files")
+ .collect()
+ .map(_.getInt(0))
+ .toSet
+ val nativeIds = sortOrderIds("sorted_native")
+ val jvmIds = sortOrderIds("sorted_jvm")
+ assert(nativeIds == jvmIds, s"native sort_order_ids $nativeIds != JVM
$jvmIds")
+ assert(nativeIds.size == 1, s"expected one distinct sort_order_id, got
$nativeIds")
+ }
+ }
+
+ test("native acceleration: AppendData partitioned by identity") {
+ assumeNativeAcceleration()
+ withIcebergCatalog { warehouseDir =>
+ createTable(warehouseDir, "native_append_part", partitionSpec =
"PARTITIONED BY (region)")
+ assertNativeWriteEngages("native_append_part", Seq(1, 2, 3)) {
+ spark.sql(
+ "INSERT INTO cat.db.native_append_part VALUES " +
+ "(1, 'us-east', 10.5), (2, 'us-east', 20.3), (3, 'eu', 30.7)")
+ }
+ }
+ }
+
+ test("native acceleration: OverwriteByExpression (INSERT OVERWRITE STATIC)")
{
+ assumeNativeAcceleration()
+ withIcebergCatalog { warehouseDir =>
+ createTable(warehouseDir, "native_overwrite_static", partitionSpec = "")
+ spark.sql(
+ "INSERT INTO cat.db.native_overwrite_static VALUES " +
+ "(1, 'old', 1.0), (2, 'old', 2.0), (3, 'old', 3.0)")
+ assertNativeWriteEngages("native_overwrite_static", Seq(10, 11)) {
+ withSQLConf("spark.sql.sources.partitionOverwriteMode" -> "STATIC") {
+ spark.sql(
+ "INSERT OVERWRITE cat.db.native_overwrite_static VALUES " +
+ "(10, 'new', 100.0), (11, 'new', 110.0)")
+ }
+ }
+ }
+ }
+
+ test("native acceleration: OverwritePartitionsDynamic") {
+ assumeNativeAcceleration()
+ withIcebergCatalog { warehouseDir =>
+ createTable(warehouseDir, "native_overwrite_dyn", partitionSpec =
"PARTITIONED BY (region)")
+ spark.sql(
+ "INSERT INTO cat.db.native_overwrite_dyn VALUES " +
+ "(1, 'us-east', 1.0), (2, 'us-west', 2.0), (3, 'eu', 3.0)")
+ assertNativeWriteEngages("native_overwrite_dyn", Seq(2, 3, 10)) {
+ withSQLConf("spark.sql.sources.partitionOverwriteMode" -> "DYNAMIC") {
+ spark.sql("INSERT OVERWRITE cat.db.native_overwrite_dyn VALUES (10,
'us-east', 100.0)")
+ }
+ }
+ }
+ }
+
+ test("native acceleration: ReplaceData (CoW DELETE)") {
+ assumeNativeAcceleration()
+ withIcebergCatalog { warehouseDir =>
+ createTable(
+ warehouseDir,
+ "native_cow_delete",
+ partitionSpec = "",
+ properties = Some("'write.delete.mode'='copy-on-write'"))
+ // Seed via the JVM path so the assertion isolates native engagement to
the DELETE.
+ withSQLConf(CometConf.COMET_ICEBERG_WRITE_SPLIT_OPERATOR_ENABLED.key ->
"false") {
+ coalesceInsert(
+ "native_cow_delete",
+ Seq((1, "us-east", 10.0), (2, "us-west", 20.0), (3, "eu", 30.0), (4,
"us-east", 40.0)))
+ }
+ assertNativeWriteEngages("native_cow_delete", Seq(1, 3, 4)) {
+ spark.sql("DELETE FROM cat.db.native_cow_delete WHERE id = 2")
+ }
+ }
+ }
+
+ test("native acceleration: ReplaceData (CoW UPDATE)") {
+ assumeNativeAcceleration()
+ withIcebergCatalog { warehouseDir =>
+ createTable(
+ warehouseDir,
+ "native_cow_update",
+ partitionSpec = "",
+ properties = Some("'write.update.mode'='copy-on-write'"))
+ withSQLConf(CometConf.COMET_ICEBERG_WRITE_SPLIT_OPERATOR_ENABLED.key ->
"false") {
+ coalesceInsert(
+ "native_cow_update",
+ Seq((1, "us-east", 10.0), (2, "us-west", 20.0), (3, "eu", 30.0)))
+ }
+ // Engage natively; expected rows are the ids 1..3 (UPDATE keeps
cardinality).
+ assertNativeWriteEngages("native_cow_update", Seq(1, 2, 3)) {
+ spark.sql("UPDATE cat.db.native_cow_update SET amount = amount * 2
WHERE id = 2")
+ }
+ // Spot-check the UPDATE actually rewrote row 2 (cardinality unchanged +
value flipped).
+ val r =
+ spark.sql("SELECT id, amount FROM cat.db.native_cow_update WHERE id =
2").collect()
+ assert(r.length == 1 && r(0).getDouble(1) == 40.0, s"got ${r.toSeq}")
+ }
+ }
+
+ test("native acceleration: ReplaceData (CoW MERGE) falls back (MergeRowsExec
not Comet)") {
+ // TODO(comet-merge-rows): native MERGE engagement requires a Comet
equivalent of Iceberg's
+ // `MergeRowsExec` (the per-row dispatch operator that assigns
__row_operation codes from
+ // MATCHED/NOT MATCHED clauses). Without it, `MergeRowsExec` stays JVM,
the upstream chain
+ // breaks Comet-native partway, and `requiresNativeChildren=true` declines
the
+ // `IcebergWriteExec -> CometIcebergWriteExec` conversion. Until that
lands, MERGE
+ // falls back to the JVM two-op path -- this test pins that contract so a
future MERGE-row-exec
+ // addition surfaces clearly (the test will start failing and need to flip
back to
+ // `assertNativeWriteEngages`).
+ assumeNativeAcceleration()
+ withIcebergCatalog { warehouseDir =>
+ createTable(
+ warehouseDir,
+ "native_cow_merge",
+ partitionSpec = "",
+ properties = Some("'write.merge.mode'='copy-on-write'"))
+ withSQLConf(CometConf.COMET_ICEBERG_WRITE_SPLIT_OPERATOR_ENABLED.key ->
"false") {
+ coalesceInsert("native_cow_merge", Seq((1, "us-east", 10.0), (2,
"us-west", 20.0)))
+ }
+ assertNativeWriteDoesNotEngage("native_cow_merge", Seq(1, 2, 3)) {
+ spark.sql("""
+ |MERGE INTO cat.db.native_cow_merge t
+ |USING (SELECT 2 AS id, 'us-west' AS region, 200.0 AS amount UNION
ALL
+ | SELECT 3 AS id, 'eu' AS region, 30.0 AS amount) s
+ |ON t.id = s.id
+ |WHEN MATCHED THEN UPDATE SET t.amount = s.amount
+ |WHEN NOT MATCHED THEN INSERT (id, region, amount) VALUES (s.id,
s.region, s.amount)
+ |""".stripMargin)
+ }
+ }
+ }
+
+ test("native acceleration: complex types (struct, array, map) round-trip
with field IDs") {
+ assumeNativeAcceleration()
+ withIcebergCatalog { _ =>
+ // Three nested kinds in one schema so the recursive
PARQUET_FIELD_ID_META_KEY decoration
+ // is exercised end-to-end. Reading back via Iceberg's reader is the
proof point: Iceberg
+ // matches columns by field id, not by name, so a row that round-trips
means every nested
+ // field id made it into the Parquet metadata.
+ spark.sql(s"""
+ CREATE TABLE $catalog.$ns.native_complex (
+ id INT,
+ addr STRUCT<city: STRING, zip: INT>,
+ tags ARRAY<STRING>,
+ attrs MAP<STRING, INT>
+ ) USING iceberg
+ """)
+ val snapshot = withNativeEnabled {
+ captureWrite("native_complex") {
+ spark.sql("""
+ INSERT INTO cat.db.native_complex VALUES
+ (1, named_struct('city', 'NYC', 'zip', 10001), array('a', 'b'),
map('k1', 1, 'k2', 2)),
+ (2, named_struct('city', 'SF', 'zip', 94016), array('c'),
map('k3', 3))
+ """)
+ }
+ }
+ assert(snapshot.snapshotDelta == 1L)
+ val nativeExecs = snapshot.plans.flatMap { p =>
+ collectWithSubqueries(p) { case e: CometIcebergWriteExec => e }
+ }
+ assert(nativeExecs.nonEmpty, "expected native write exec in captured
plans")
+ val rows = spark
+ .sql(s"SELECT id, addr.city, addr.zip, tags, attrs FROM
$catalog.$ns.native_complex" +
+ " ORDER BY id")
+ .collect()
+ assert(rows.length == 2)
+ assert(rows(0).getInt(0) == 1)
+ assert(rows(0).getString(1) == "NYC")
+ assert(rows(0).getInt(2) == 10001)
+ assert(rows(1).getString(1) == "SF")
+ assert(rows(1).getInt(2) == 94016)
+ }
+ }
+
+ // Java sources float/double manifest metrics from writer-tracked state
(FloatFieldMetrics):
+ // NaN counted separately, bounds computed over non-NaN values, bounds
dropped when every
+ // value is NaN. The native path must reproduce those decisions via the
JVM-side metrics
+ // rebuild, so write identical data through both paths and pin the
aggregated per-column
+ // manifest metrics against each other.
+ test("native acceleration: NaN float/double manifest metrics match the JVM
writer") {
+ assumeNativeAcceleration()
+ withIcebergCatalog { _ =>
+ Seq("nan_native", "nan_jvm").foreach { t =>
+ spark.sql(s"""
+ CREATE TABLE $catalog.$ns.$t (
+ id INT,
+ f FLOAT,
+ d DOUBLE,
+ all_nan FLOAT
+ ) USING iceberg
+ """)
+ }
+ // `f`'s zero is deliberately -0.0: parquet-rs normalises zero lower
bounds to -0.0 while
+ // Java preserves the sign it saw (an accepted divergence), so -0.0 is
the one zero where
+ // both paths agree bit-for-bit and Row equality below stays exact.
+ def insert(t: String): Unit = {
+ spark.sql(s"""
+ INSERT INTO $catalog.$ns.$t VALUES
+ (1, CAST('NaN' AS FLOAT), 1.5D, CAST('NaN' AS FLOAT)),
+ (2, 2.5, CAST('NaN' AS DOUBLE), CAST('NaN' AS FLOAT)),
+ (3, -0.0, CAST('NaN' AS DOUBLE), CAST('NaN' AS FLOAT)),
+ (4, NULL, 0.25D, NULL)
+ """)
+ }
+
+ val snapshot = withNativeEnabled {
captureWrite("nan_native")(insert("nan_native")) }
+ assert(snapshot.snapshotDelta == 1L)
+ val nativeExecs = snapshot.plans.flatMap { p =>
+ collectWithSubqueries(p) { case e: CometIcebergWriteExec => e }
+ }
+ assert(nativeExecs.nonEmpty, "expected the NaN write to engage the
native path")
+ insert("nan_jvm")
+
+ // Aggregate across data files so the assertion is robust to how the
insert splits into
+ // tasks; both paths run the identical upstream plan, so the file split
is the same.
+ def columnMetrics(t: String): Seq[Row] = {
+ spark
+ .sql(s"""
+ SELECT
+ sum(readable_metrics.f.nan_value_count),
+ min(readable_metrics.f.lower_bound),
+ max(readable_metrics.f.upper_bound),
+ sum(readable_metrics.d.nan_value_count),
+ min(readable_metrics.d.lower_bound),
+ max(readable_metrics.d.upper_bound),
+ sum(readable_metrics.all_nan.nan_value_count),
+ min(readable_metrics.all_nan.lower_bound),
+ max(readable_metrics.all_nan.upper_bound)
+ FROM $catalog.$ns.$t.data_files
+ """)
+ .collect()
+ .toSeq
+ }
+
+ val native = columnMetrics("nan_native")
+ val jvm = columnMetrics("nan_jvm")
+ assert(
+ native == jvm,
+ s"native manifest metrics ${native.mkString} != JVM manifest metrics
${jvm.mkString}")
+
+ val row = native.head
+ assert(row.getLong(0) == 1L, "f has exactly one NaN")
+ assert(row.getFloat(1) == -0.0f && row.getFloat(2) == 2.5f, "f bounds
skip NaN")
+ assert(row.getLong(3) == 2L, "d has exactly two NaNs")
+ assert(row.getLong(6) == 3L, "all_nan counts every non-null value as
NaN")
+ assert(row.isNullAt(7) && row.isNullAt(8), "all-NaN column has no
bounds")
+ }
+ }
+
+ test("native acceleration: CTAS runs its inner append through the native
writer") {
+ assumeNativeAcceleration()
+ assume(isSpark35Plus, "CTAS re-plans its inner append only on Spark 3.5+")
+ withIcebergCatalog { _ =>
+ // A brand-new table has no metadata file yet, so this also pins the
empty
+ // metadata-location path in the proto builder.
+ val snapshot = withNativeEnabled {
+ captureWrite("ctas_native") {
+ spark.sql(s"""
+ CREATE TABLE $catalog.$ns.ctas_native USING iceberg AS
+ SELECT * FROM VALUES (1, 'us', 1.0), (2, 'eu', 2.0) AS t(id,
region, amount)
+ """)
+ }
+ }
+ assert(snapshot.snapshotDelta == 1L)
+ val nativeExecs = snapshot.plans.flatMap { p =>
+ collectWithSubqueries(p) { case e: CometIcebergWriteExec => e }
+ }
+ assert(nativeExecs.nonEmpty, "expected CTAS's inner append to engage the
native writer")
+ assertRows("ctas_native", Seq(1, 2))
+ }
+ }
+
+ test("native acceleration: RTAS replaces table contents through the native
writer") {
+ assumeNativeAcceleration()
+ assume(isSpark35Plus, "RTAS re-plans its inner append only on Spark 3.5+")
+ withIcebergCatalog { warehouseDir =>
+ createTable(warehouseDir, "rtas_native", partitionSpec = "")
+ coalesceInsert("rtas_native", Seq((1, "old", 1.0)))
+ val snapshot = withNativeEnabled {
+ captureWrite("rtas_native") {
+ spark.sql(s"""
+ REPLACE TABLE $catalog.$ns.rtas_native USING iceberg AS
+ SELECT * FROM VALUES (10, 'new', 10.0), (11, 'new', 11.0) AS t(id,
region, amount)
+ """)
+ }
+ }
+ val nativeExecs = snapshot.plans.flatMap { p =>
+ collectWithSubqueries(p) { case e: CometIcebergWriteExec => e }
+ }
+ assert(nativeExecs.nonEmpty, "expected RTAS's inner append to engage the
native writer")
+ assertRows("rtas_native", Seq(10, 11))
+ }
+ }
+
+ test("native acceleration: fanout writer handles unsorted partitioned
input") {
+ assumeNativeAcceleration()
+ withIcebergCatalog { warehouseDir =>
+ createTable(
+ warehouseDir,
+ "native_fanout",
+ partitionSpec = "PARTITIONED BY (region)",
+ properties = Some("'write.spark.fanout.enabled'='true'"))
+ // Fanout writes skip Spark's partition-local sort, so partition values
arrive
+ // interleaved -- the mode the clustered writer would reject.
+ assertNativeWriteEngages("native_fanout", Seq(1, 2, 3, 4)) {
+ spark.sql(
+ "INSERT INTO cat.db.native_fanout VALUES " +
+ "(1, 'us-east', 1.0), (2, 'eu', 2.0), (3, 'us-east', 3.0), (4,
'eu', 4.0)")
+ }
+ val byRegion = spark
+ .sql("SELECT region, count(*) FROM cat.db.native_fanout GROUP BY
region ORDER BY region")
+ .collect()
+ .map(r => r.getString(0) -> r.getLong(1))
+ .toSeq
+ assert(byRegion == Seq("eu" -> 2L, "us-east" -> 2L), s"got $byRegion")
+ val files = spark
+ .sql("SELECT count(*) FROM cat.db.native_fanout.data_files")
+ .collect()
+ .head
+ .getLong(0)
+ assert(files >= 2L, s"expected at least one data file per partition, got
$files")
+ }
+ }
+
+ test("native acceleration: target-file-size rolls one task across multiple
files") {
+ assumeNativeAcceleration()
+ withIcebergCatalog { warehouseDir =>
+ createTable(
+ warehouseDir,
+ "native_roll",
+ partitionSpec = "",
+ properties = Some("'write.target-file-size-bytes'='1'"))
+ val values = (1 to 300).map(i => s"($i, 'r', $i.0)").mkString(", ")
+ // One upstream slice + small Comet batches: the rolling writer checks
the target size
+ // per batch, so three batches against a 1-byte target must roll into
multiple files.
+ withSQLConf(
+ "spark.sql.leafNodeDefaultParallelism" -> "1",
+ CometConf.COMET_BATCH_SIZE.key -> "100") {
+ assertNativeWriteEngages("native_roll", 1 to 300) {
+ spark.sql(s"INSERT INTO cat.db.native_roll VALUES $values")
+ }
+ }
+ val fileRows = spark
+ .sql("SELECT record_count FROM cat.db.native_roll.data_files")
+ .collect()
+ .map(_.getLong(0))
+ assert(fileRows.length >= 2, s"expected a multi-file roll, got
${fileRows.length} file(s)")
+ assert(fileRows.sum == 300L, s"rows across rolled files must sum to 300,
got $fileRows")
+ }
+ }
+
+ test("native acceleration: empty append commits exactly once with zero data
files") {
Review Comment:
This pins the empty append on an unpartitioned table, so it exercises
`UnpartitionedWriter::close()` with nothing written. The partitioned equivalent
goes through `ClusteredWriter::close()` or `FanoutWriter::close()` having never
seen a batch, and that path is not covered anywhere. An `INSERT INTO ...
SELECT` whose filter matches nothing is an entirely ordinary thing to do
against a partitioned table, so if either of those writers errors or panics
with no current writer it would be a hard failure on a common case.
Could you add a partitioned variant here? It should be a couple of lines
given the helper already exists, and it would also cover the
`ClusteredBatchSplitter` never being invoked at all.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]