This is an automated email from the ASF dual-hosted git repository.
zhouyuan pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gluten.git
The following commit(s) were added to refs/heads/main by this push:
new c7713ce0ae [GLUTEN-10215][VL] Enforce file limits in native
partitioned Delta writes (#13160)
c7713ce0ae is described below
commit c7713ce0ae7704091f1945ccb1039952e2518688
Author: Mohammad Linjawi <[email protected]>
AuthorDate: Tue Oct 6 14:20:53 2026 +0300
[GLUTEN-10215][VL] Enforce file limits in native partitioned Delta writes
(#13160)
---
.../delta/files/GlutenDeltaFileFormatWriter.scala | 111 +++++++++++-----
.../delta/files/GlutenDeltaFileFormatWriter.scala | 106 +++++++++++----
gluten-ut/spark35/pom.xml | 11 ++
.../GlutenDeltaNativePartitionWriteSuite.scala | 144 +++++++++++++++++++++
gluten-ut/spark40/pom.xml | 30 +++++
gluten-ut/spark41/pom.xml | 30 +++++
6 files changed, 374 insertions(+), 58 deletions(-)
diff --git
a/backends-velox/src-delta33/main/scala/org/apache/spark/sql/delta/files/GlutenDeltaFileFormatWriter.scala
b/backends-velox/src-delta33/main/scala/org/apache/spark/sql/delta/files/GlutenDeltaFileFormatWriter.scala
index e9ea3e4753..2bcd7de021 100644
---
a/backends-velox/src-delta33/main/scala/org/apache/spark/sql/delta/files/GlutenDeltaFileFormatWriter.scala
+++
b/backends-velox/src-delta33/main/scala/org/apache/spark/sql/delta/files/GlutenDeltaFileFormatWriter.scala
@@ -18,6 +18,7 @@ package org.apache.spark.sql.delta.files
import org.apache.gluten.backendsapi.BackendsApiManager
import org.apache.gluten.backendsapi.velox.VeloxBatchType
+import org.apache.gluten.columnarbatch.VeloxColumnarBatches
import org.apache.gluten.config.GlutenConfig
import org.apache.gluten.execution._
import org.apache.gluten.execution.datasource.GlutenFormatFactory
@@ -45,6 +46,7 @@ import
org.apache.spark.sql.execution.datasources.FileFormatWriter._
import org.apache.spark.sql.execution.metric.SQLMetric
import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.types.DataType
+import org.apache.spark.sql.vectorized.ColumnarBatch
import org.apache.spark.util.{SerializableConfiguration, Utils}
import org.apache.hadoop.conf.Configuration
@@ -590,43 +592,92 @@ object GlutenDeltaFileFormatWriter extends LoggingShims {
record match {
case carrierRow: BatchCarrierRow =>
carrierRow match {
- case placeholderRow: PlaceholderRow =>
+ case _: PlaceholderRow =>
// Do nothing.
case terminalRow: TerminalRow =>
- val numRows = terminalRow.batch().numRows()
- if (numRows > 0) {
- val blockStripes = GlutenFormatFactory.rowSplitter
- .splitBlockByPartitionAndBucket(
- terminalRow.batch(),
- partitionColIndice,
- isBucketed)
- val iter = blockStripes.iterator()
- while (iter.hasNext) {
- val blockStripe = iter.next()
- val headingRow = blockStripe.getHeadingRow
- beforeWrite(headingRow)
- val currentColumnBatch = blockStripe.getColumnarBatch
- val numRowsOfCurrentColumnarBatch =
currentColumnBatch.numRows()
- assert(numRowsOfCurrentColumnarBatch > 0)
- val currentTerminalRow =
terminalRow.withNewBatch(currentColumnBatch)
- currentWriter.write(currentTerminalRow)
- statsTrackers.foreach {
- tracker =>
- tracker.newRow(currentWriter.path, currentTerminalRow)
- for (_ <- 0 until numRowsOfCurrentColumnarBatch - 1) {
- tracker.newRow(currentWriter.path, new
PlaceholderRow())
- }
- }
- currentColumnBatch.close()
- }
- blockStripes.release()
- recordsInFile += numRows
- }
+ writePartitionedBatch(terminalRow)
}
case _ =>
beforeWrite(record)
writeRecord(record)
}
}
+
+ private def writeCurrentBatch(terminalRow: TerminalRow, rowCount: Int):
Unit = {
+ assert(rowCount > 0)
+ currentWriter.write(terminalRow)
+ statsTrackers.foreach(_.newRow(currentWriter.path, terminalRow))
+ recordsInFile += rowCount
+ }
+
+ private def writeCurrentBatchWithMaxRecords(
+ terminalRow: TerminalRow,
+ columnBatch: ColumnarBatch): Unit = {
+ val numRows = columnBatch.numRows()
+ var offset = 0
+ while (offset < numRows) {
+ val rowsRemaining = numRows - offset
+ val rowsToWrite = if (description.maxRecordsPerFile > 0) {
+ if (recordsInFile >= description.maxRecordsPerFile) {
+ renewCurrentWriterIfTooManyRecords(currentPartitionValues,
currentBucketId)
+ }
+ math.min(rowsRemaining.toLong, description.maxRecordsPerFile -
recordsInFile).toInt
+ } else {
+ rowsRemaining
+ }
+
+ assert(rowsToWrite > 0)
+ val batchToWrite =
+ if (offset == 0 && rowsToWrite == numRows) {
+ columnBatch
+ } else {
+ VeloxColumnarBatches.slice(columnBatch, offset, rowsToWrite)
+ }
+ Utils.tryWithSafeFinally {
+ writeCurrentBatch(terminalRow.withNewBatch(batchToWrite),
rowsToWrite)
+ } {
+ if (batchToWrite ne columnBatch) {
+ batchToWrite.close()
+ }
+ }
+ offset += rowsToWrite
+ }
+ }
+
+ private def writePartitionStripe(terminalRow: TerminalRow, blockStripe:
BlockStripe): Unit = {
+ val currentColumnBatch = blockStripe.getColumnarBatch
+ Utils.tryWithSafeFinally {
+ beforeWrite(blockStripe.getHeadingRow)
+ assert(currentColumnBatch.numRows() > 0)
+ writeCurrentBatchWithMaxRecords(terminalRow, currentColumnBatch)
+ } {
+ currentColumnBatch.close()
+ }
+ }
+
+ private def writePartitionedBatch(terminalRow: TerminalRow): Unit = {
+ val numRows = terminalRow.batch().numRows()
+ if (numRows > 0) {
+ val blockStripes = GlutenFormatFactory.rowSplitter
+ .splitBlockByPartitionAndBucket(
+ terminalRow.batch(),
+ partitionColIndice,
+ isBucketed)
+ val iter = blockStripes.iterator()
+ Utils.tryWithSafeFinally {
+ while (iter.hasNext) {
+ writePartitionStripe(terminalRow, iter.next())
+ }
+ } {
+ // Velox creates all stripe batches eagerly, and release() does not
close them.
+ // On failure, the current stripe has already been closed by
writePartitionStripe.
+ while (iter.hasNext) {
+ val remainingBatch = iter.next().getColumnarBatch
+ Utils.tryLogNonFatalError(remainingBatch.close())
+ }
+ blockStripes.release()
+ }
+ }
+ }
}
}
diff --git
a/backends-velox/src-delta40/main/scala/org/apache/spark/sql/delta/files/GlutenDeltaFileFormatWriter.scala
b/backends-velox/src-delta40/main/scala/org/apache/spark/sql/delta/files/GlutenDeltaFileFormatWriter.scala
index 73b9a8fdba..4d8c660687 100644
---
a/backends-velox/src-delta40/main/scala/org/apache/spark/sql/delta/files/GlutenDeltaFileFormatWriter.scala
+++
b/backends-velox/src-delta40/main/scala/org/apache/spark/sql/delta/files/GlutenDeltaFileFormatWriter.scala
@@ -18,6 +18,7 @@ package org.apache.spark.sql.delta.files
import org.apache.gluten.backendsapi.BackendsApiManager
import org.apache.gluten.backendsapi.velox.VeloxBatchType
+import org.apache.gluten.columnarbatch.VeloxColumnarBatches
import org.apache.gluten.config.GlutenConfig
import org.apache.gluten.execution._
import org.apache.gluten.execution.datasource.GlutenFormatFactory
@@ -46,6 +47,7 @@ import
org.apache.spark.sql.execution.datasources.FileFormatWriter._
import org.apache.spark.sql.execution.metric.SQLMetric
import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.types.DataType
+import org.apache.spark.sql.vectorized.ColumnarBatch
import org.apache.spark.util.{SerializableConfiguration, Utils}
import org.apache.hadoop.conf.Configuration
@@ -583,42 +585,90 @@ object GlutenDeltaFileFormatWriter extends Logging {
record match {
case carrierRow: BatchCarrierRow =>
carrierRow match {
- case placeholderRow: PlaceholderRow =>
+ case _: PlaceholderRow =>
// Do nothing.
case terminalRow: TerminalRow =>
- val numRows = terminalRow.batch().numRows()
- if (numRows > 0) {
- val blockStripes = GlutenFormatFactory.rowSplitter
- .splitBlockByPartitionAndBucket(terminalRow.batch(),
partitionColIndice,
- isBucketed)
- val iter = blockStripes.iterator()
- while (iter.hasNext) {
- val blockStripe = iter.next()
- val headingRow = blockStripe.getHeadingRow
- beforeWrite(headingRow)
- val currentColumnBatch = blockStripe.getColumnarBatch
- val numRowsOfCurrentColumnarBatch =
currentColumnBatch.numRows()
- assert(numRowsOfCurrentColumnarBatch > 0)
- val currentTerminalRow =
terminalRow.withNewBatch(currentColumnBatch)
- currentWriter.write(currentTerminalRow)
- statsTrackers.foreach {
- tracker =>
- tracker.newRow(currentWriter.path, currentTerminalRow)
- for (_ <- 0 until numRowsOfCurrentColumnarBatch - 1) {
- tracker.newRow(currentWriter.path, new
PlaceholderRow())
- }
- }
- currentColumnBatch.close()
- }
- blockStripes.release()
- recordsInFile += numRows
- }
+ writePartitionedBatch(terminalRow)
}
case _ =>
beforeWrite(record)
writeRecord(record)
}
}
+
+ private def writeCurrentBatch(terminalRow: TerminalRow, rowCount: Int):
Unit = {
+ assert(rowCount > 0)
+ currentWriter.write(terminalRow)
+ statsTrackers.foreach(_.newRow(currentWriter.path, terminalRow))
+ recordsInFile += rowCount
+ }
+
+ private def writeCurrentBatchWithMaxRecords(
+ terminalRow: TerminalRow,
+ columnBatch: ColumnarBatch): Unit = {
+ val numRows = columnBatch.numRows()
+ var offset = 0
+ while (offset < numRows) {
+ val rowsRemaining = numRows - offset
+ val rowsToWrite = if (description.maxRecordsPerFile > 0) {
+ if (recordsInFile >= description.maxRecordsPerFile) {
+ renewCurrentWriterIfTooManyRecords(currentPartitionValues,
currentBucketId)
+ }
+ math.min(rowsRemaining.toLong, description.maxRecordsPerFile -
recordsInFile).toInt
+ } else {
+ rowsRemaining
+ }
+
+ assert(rowsToWrite > 0)
+ val batchToWrite =
+ if (offset == 0 && rowsToWrite == numRows) {
+ columnBatch
+ } else {
+ VeloxColumnarBatches.slice(columnBatch, offset, rowsToWrite)
+ }
+ Utils.tryWithSafeFinally {
+ writeCurrentBatch(terminalRow.withNewBatch(batchToWrite),
rowsToWrite)
+ } {
+ if (batchToWrite ne columnBatch) {
+ batchToWrite.close()
+ }
+ }
+ offset += rowsToWrite
+ }
+ }
+
+ private def writePartitionStripe(terminalRow: TerminalRow, blockStripe:
BlockStripe): Unit = {
+ val currentColumnBatch = blockStripe.getColumnarBatch
+ Utils.tryWithSafeFinally {
+ beforeWrite(blockStripe.getHeadingRow)
+ assert(currentColumnBatch.numRows() > 0)
+ writeCurrentBatchWithMaxRecords(terminalRow, currentColumnBatch)
+ } {
+ currentColumnBatch.close()
+ }
+ }
+
+ private def writePartitionedBatch(terminalRow: TerminalRow): Unit = {
+ val numRows = terminalRow.batch().numRows()
+ if (numRows > 0) {
+ val blockStripes = GlutenFormatFactory.rowSplitter
+ .splitBlockByPartitionAndBucket(terminalRow.batch(),
partitionColIndice, isBucketed)
+ val iter = blockStripes.iterator()
+ Utils.tryWithSafeFinally {
+ while (iter.hasNext) {
+ writePartitionStripe(terminalRow, iter.next())
+ }
+ } {
+ // Velox creates all stripe batches eagerly, and release() does not
close them.
+ // On failure, the current stripe has already been closed by
writePartitionStripe.
+ while (iter.hasNext) {
+ val remainingBatch = iter.next().getColumnarBatch
+ Utils.tryLogNonFatalError(remainingBatch.close())
+ }
+ blockStripes.release()
+ }
+ }
+ }
}
}
// spotless:on
diff --git a/gluten-ut/spark35/pom.xml b/gluten-ut/spark35/pom.xml
index 97fac48c18..ccd46169d7 100644
--- a/gluten-ut/spark35/pom.xml
+++ b/gluten-ut/spark35/pom.xml
@@ -296,5 +296,16 @@
</build>
</profile>
+ <profile>
+ <id>delta</id>
+ <dependencies>
+ <dependency>
+ <groupId>org.apache.gluten</groupId>
+ <artifactId>gluten-delta</artifactId>
+ <version>${project.version}</version>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
+ </profile>
</profiles>
</project>
diff --git
a/gluten-ut/spark35/src-delta/test/scala/org/apache/spark/sql/execution/datasources/GlutenDeltaNativePartitionWriteSuite.scala
b/gluten-ut/spark35/src-delta/test/scala/org/apache/spark/sql/execution/datasources/GlutenDeltaNativePartitionWriteSuite.scala
new file mode 100644
index 0000000000..858d0e1444
--- /dev/null
+++
b/gluten-ut/spark35/src-delta/test/scala/org/apache/spark/sql/execution/datasources/GlutenDeltaNativePartitionWriteSuite.scala
@@ -0,0 +1,144 @@
+/*
+ * 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.
+ */
+package org.apache.spark.sql.execution.datasources
+
+import org.apache.gluten.config.GlutenConfig
+import org.apache.gluten.utils.BackendTestUtils
+
+import org.apache.spark.SparkConf
+import org.apache.spark.sql.{GlutenQueryTest, Row}
+import org.apache.spark.sql.delta.DeltaLog
+import org.apache.spark.sql.delta.util.DeltaFileOperations
+import org.apache.spark.sql.execution.{QueryExecution, SparkPlan}
+import org.apache.spark.sql.execution.command.ExecutedCommandExec
+import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types.{IntegerType, LongType, StructType}
+import org.apache.spark.sql.util.QueryExecutionListener
+
+import org.json4s.{JInt, JNothing}
+import org.json4s.jackson.JsonMethods.parse
+
+import java.util.concurrent.ConcurrentLinkedQueue
+
+import scala.collection.JavaConverters._
+
+class GlutenDeltaNativePartitionWriteSuite extends GlutenQueryTest with
SharedSparkSession {
+
+ override protected def sparkConf: SparkConf = {
+ super.sparkConf
+ .set("spark.plugins", "org.apache.gluten.GlutenPlugin")
+ .set("spark.shuffle.manager",
"org.apache.spark.shuffle.sort.ColumnarShuffleManager")
+ .set("spark.sql.shuffle.partitions", "1")
+ .set("spark.memory.offHeap.enabled", "true")
+ .set("spark.memory.offHeap.size", "1024MB")
+ .set("spark.ui.enabled", "false")
+ .set(GlutenConfig.GLUTEN_UI_ENABLED.key, "false")
+ .set("spark.sql.extensions", "io.delta.sql.DeltaSparkSessionExtension")
+ .set("spark.sql.catalog.spark_catalog",
"org.apache.spark.sql.delta.catalog.DeltaCatalog")
+ .set("spark.gluten.sql.columnar.backend.velox.delta.enableNativeWrite",
"true")
+ .set("spark.databricks.delta.snapshotPartitions", "1")
+ .set("spark.databricks.delta.optimizeWrite.enabled", "false")
+ .set("spark.sql.ansi.enabled", "false")
+ .set(GlutenConfig.GLUTEN_ANSI_FALLBACK_ENABLED.key, "false")
+ .set("spark.sql.adaptive.enabled", "false")
+ .set("spark.sql.maxConcurrentOutputFileWriters", "0")
+ }
+
+ // Small batches cross partition boundaries; a large batch requires
splitting a single stripe.
+ for ((batchSize, limit, collectStats) <- Seq((4, 4, false), (16, 4, true),
(4, 0, true))) {
+ test(s"native Delta partitioned files: batch=$batchSize, limit=$limit,
stats=$collectStats") {
+ assume(BackendTestUtils.isVeloxBackendLoaded())
+ val schema = new StructType().add("id", LongType).add("part",
IntegerType)
+ val expected = Seq((null, 3), (Int.box(0), 4), (Int.box(1), 9)).flatMap {
+ case (part, count) =>
+ (0 until count).map(i => Row(if (i % 3 == 0) null else Long.box(i /
2), part))
+ }
+ withSQLConf(
+ GlutenConfig.COLUMNAR_MAX_BATCH_SIZE.key -> batchSize.toString,
+ "spark.databricks.delta.stats.collect" -> collectStats.toString,
+ "spark.sql.jsonGenerator.ignoreNullFields" -> "true"
+ ) {
+ withTempDir {
+ dir =>
+ val path = dir.getCanonicalPath
+ val plans = new ConcurrentLinkedQueue[SparkPlan]()
+ val listener = new QueryExecutionListener {
+ override def onSuccess(name: String, qe: QueryExecution,
duration: Long): Unit =
+ plans.add(qe.executedPlan)
+ override def onFailure(name: String, qe: QueryExecution, error:
Exception): Unit = {}
+ }
+ spark.listenerManager.register(listener)
+ try {
+ spark.createDataFrame(spark.sparkContext.parallelize(expected,
1), schema)
+ .write.format("delta").partitionBy("part")
+ .option("maxRecordsPerFile", limit.toString).save(path)
+ spark.sparkContext.listenerBus.waitUntilEmpty(10000)
+ } finally {
+ spark.listenerManager.unregister(listener)
+ }
+ // This source also compiles for other backends, which do not
provide these classes.
+ assert(
+ plans.asScala.exists(_.exists {
+ case ExecutedCommandExec(command) =>
+ Set("GlutenDeltaLeafRunnableCommand",
"GlutenDeltaRunnableCommand")
+ .contains(command.getClass.getSimpleName)
+ case plan => plan.getClass.getSimpleName ==
"GlutenDeltaLeafV2CommandExec"
+ }),
+ s"Expected a native Delta
write:\n${plans.asScala.map(_.treeString).mkString}"
+ )
+
+ // Read the physical files with Spark to independently check
layout and per-file stats.
+ withSQLConf(GlutenConfig.GLUTEN_ENABLED.key -> "false") {
+ val files = DeltaLog.forTable(spark,
path).update().allFiles.collect()
+ val fileSizes = files.toSeq.map {
+ file =>
+ val filePath = DeltaFileOperations.absolutePath(path,
file.path).toString
+ val rows =
spark.read.parquet(filePath).select("id").collect().toSeq
+ if (collectStats) {
+ val stats = parse(file.stats)
+ val values =
rows.filterNot(_.isNullAt(0)).map(_.getLong(0))
+ assert((stats \ "numRecords") == JInt(rows.size),
file.stats)
+ assert(
+ (stats \ "nullCount" \ "id") == JInt(rows.size -
values.size),
+ file.stats)
+ assert(
+ (stats \ "minValues" \ "id") ==
+ (if (values.isEmpty) JNothing else JInt(values.min)),
+ file.stats)
+ assert(
+ (stats \ "maxValues" \ "id") ==
+ (if (values.isEmpty) JNothing else JInt(values.max)),
+ file.stats)
+ } else {
+ assert(file.stats == null || file.stats.isEmpty)
+ }
+ file.partitionValues("part") -> rows.size
+ }.groupBy(_._1).map {
+ case (part, sizes) => part -> sizes.map(_._2).sorted
+ }
+ // Exact sizes detect premature rotation as well as files
exceeding the limit.
+ assert(fileSizes == Map(
+ (null, Seq(3)),
+ "0" -> Seq(4),
+ "1" -> (if (limit == 0) Seq(9) else Seq(1, 4, 4))))
+ checkAnswer(spark.read.format("delta").load(path).select("id",
"part"), expected)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/gluten-ut/spark40/pom.xml b/gluten-ut/spark40/pom.xml
index e5cea2743b..179d479172 100644
--- a/gluten-ut/spark40/pom.xml
+++ b/gluten-ut/spark40/pom.xml
@@ -364,5 +364,35 @@
</plugins>
</build>
</profile>
+
+ <profile>
+ <id>delta</id>
+ <dependencies>
+ <dependency>
+ <groupId>org.apache.gluten</groupId>
+ <artifactId>gluten-delta</artifactId>
+ <version>${project.version}</version>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.codehaus.mojo</groupId>
+ <artifactId>build-helper-maven-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>add-delta-test-sources</id>
+ <configuration>
+ <sources combine.children="append">
+
<source>${project.basedir}/../spark35/src-delta/test/scala</source>
+ </sources>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+ </profile>
</profiles>
</project>
diff --git a/gluten-ut/spark41/pom.xml b/gluten-ut/spark41/pom.xml
index 4d75307c29..dbb761d93f 100644
--- a/gluten-ut/spark41/pom.xml
+++ b/gluten-ut/spark41/pom.xml
@@ -365,5 +365,35 @@
</plugins>
</build>
</profile>
+
+ <profile>
+ <id>delta</id>
+ <dependencies>
+ <dependency>
+ <groupId>org.apache.gluten</groupId>
+ <artifactId>gluten-delta</artifactId>
+ <version>${project.version}</version>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.codehaus.mojo</groupId>
+ <artifactId>build-helper-maven-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>add-delta-test-sources</id>
+ <configuration>
+ <sources combine.children="append">
+
<source>${project.basedir}/../spark35/src-delta/test/scala</source>
+ </sources>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+ </profile>
</profiles>
</project>
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]