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 33f3b21726 [GLUTEN][VL] Use authoritative TahoeFileIndex table root
for Delta deletion vectors (#12612)
33f3b21726 is described below
commit 33f3b21726386b39ba4d823f4069907301e4857e
Author: Ismaël Mejía <[email protected]>
AuthorDate: Fri Aug 21 11:46:53 2026 +0200
[GLUTEN][VL] Use authoritative TahoeFileIndex table root for Delta deletion
vectors (#12612)
---
.../util/delta-spark-ut/known-failures.txt | 2 -
.../delta/DeltaDeletionVectorScanInfoSuite.scala | 50 +++++++++++-
.../benchmark/DeltaPlanningBenchmark.scala | 13 ++-
.../delta/DeltaDeletionVectorScanInfoSuite.scala | 50 +++++++++++-
.../gluten/delta/DeltaDeletionVectorScanInfo.scala | 6 +-
.../gluten/delta/DeltaDeletionVectorScanInfo.scala | 6 +-
.../gluten/delta/DeltaDeletionVectorScanInfo.scala | 91 ++++-----------------
.../gluten/delta/DeltaDeletionVectorScanInfo.scala | 89 ++++-----------------
.../gluten/execution/DeltaScanTransformer.scala | 39 +++++----
.../org/apache/gluten/execution/DeltaSuite.scala | 93 ++++++++++++++++++++++
10 files changed, 260 insertions(+), 179 deletions(-)
diff --git a/.github/workflows/util/delta-spark-ut/known-failures.txt
b/.github/workflows/util/delta-spark-ut/known-failures.txt
index 0b68094b2f..085ed403a8 100644
--- a/.github/workflows/util/delta-spark-ut/known-failures.txt
+++ b/.github/workflows/util/delta-spark-ut/known-failures.txt
@@ -59,8 +59,6 @@ org.apache.spark.sql.delta.CloneTableSQLSuite#shallow clone
across file systems
org.apache.spark.sql.delta.CloneTableSQLWithCatalogOwnedBatch100Suite#shallow
clone across file systems
org.apache.spark.sql.delta.CloneTableSQLWithCatalogOwnedBatch1Suite#shallow
clone across file systems
org.apache.spark.sql.delta.CloneTableSQLWithCatalogOwnedBatch2Suite#shallow
clone across file systems
-org.apache.spark.sql.delta.CloneTableScalaDeletionVectorSuite#Cloning table
with persistent DVs and absolute parquet paths
-org.apache.spark.sql.delta.CloneTableScalaDeletionVectorSuite#Shallow clone
round-trip with DVs
org.apache.spark.sql.delta.CloneTableScalaDeletionVectorSuite#shallow clone
across file systems
org.apache.spark.sql.delta.CloneTableScalaSuite#shallow clone across file
systems
org.apache.spark.sql.delta.ConvertToDeltaSQLSuite#external tables use correct
path scheme
diff --git
a/backends-velox/src-delta33/test/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfoSuite.scala
b/backends-velox/src-delta33/test/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfoSuite.scala
index cbb70817ba..94c4bd2193 100644
---
a/backends-velox/src-delta33/test/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfoSuite.scala
+++
b/backends-velox/src-delta33/test/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfoSuite.scala
@@ -82,7 +82,7 @@ class DeltaDeletionVectorScanInfoSuite
)
)
- val scanInfo = DeltaDeletionVectorScanInfo.extract(spark, 0,
partitionedFile)
+ val scanInfo = DeltaDeletionVectorScanInfo.extract(spark,
partitionedFile, new Path(path))
val dvInfo = scanInfo.deletionVectorInfo
assert(dvInfo.hasDeletionVector)
@@ -106,7 +106,7 @@ class DeltaDeletionVectorScanInfoSuite
dataFile.size,
Map("kept_key" -> "kept_value"))
- val scanInfo = DeltaDeletionVectorScanInfo.extract(spark, 0,
partitionedFile)
+ val scanInfo = DeltaDeletionVectorScanInfo.extract(spark,
partitionedFile, new Path(path))
val dvInfo = scanInfo.deletionVectorInfo
assert(!dvInfo.hasDeletionVector)
@@ -131,12 +131,56 @@ class DeltaDeletionVectorScanInfoSuite
Map(GlutenDeltaParquetFileFormat.FILE_ROW_INDEX_FILTER_TYPE ->
"IF_CONTAINED"))
val error = intercept[IllegalStateException] {
- DeltaDeletionVectorScanInfo.extract(spark, 0, partitionedFile)
+ DeltaDeletionVectorScanInfo.extract(spark, partitionedFile, new
Path(path))
}
assert(error.getMessage.contains("must either be present or absent"))
}
}
+ test("normalize materializes DV read options using the supplied table path")
{
+ withTempDir {
+ tempDir =>
+ val tablePath = new Path(tempDir.getCanonicalPath, "table")
+ val unrelatedPath = new Path(tempDir.getCanonicalPath, "unrelated")
+ Seq((1, "a"), (2, "b"), (3, "c"), (4, "d"))
+ .toDF("id", "value")
+ .coalesce(1)
+ .write
+ .format("delta")
+ .save(tablePath.toString)
+
+ spark.sql(
+ s"ALTER TABLE delta.`$tablePath` SET TBLPROPERTIES
('delta.enableDeletionVectors' = true)")
+ spark.sql(s"DELETE FROM delta.`$tablePath` WHERE id IN (3, 4)")
+
+ val dataFile = DeltaLog
+ .forTable(spark, tablePath)
+ .update()
+ .allFiles
+ .collect()
+ .find(_.deletionVector != null)
+ .get
+ assert(dataFile.deletionVector.storageType == "u")
+ val partitionedFile = partitionedFileWithMetadata(
+ unrelatedPath.toString,
+ dataFile.path,
+ dataFile.size,
+ Map(
+ GlutenDeltaParquetFileFormat.FILE_ROW_INDEX_FILTER_ID_ENCODED ->
+ dataFile.deletionVector.serializeToBase64(),
+ GlutenDeltaParquetFileFormat.FILE_ROW_INDEX_FILTER_TYPE ->
"IF_CONTAINED"
+ )
+ )
+
+ val result =
DeltaDeletionVectorScanInfo.normalize(Seq(partitionedFile), tablePath)
+ assert(result.isDefined, "normalize should materialize DV options")
+ val opts = result.get._2.head
+ assert(opts.hasDeletionVector)
+ assert(opts.deletionVectorCardinality ==
dataFile.deletionVector.cardinality)
+ assert(opts.serializedDeletionVector.nonEmpty)
+ }
+ }
+
private def partitionedFileWithMetadata(
tablePath: String,
relativeFilePath: String,
diff --git
a/backends-velox/src-delta33/test/scala/org/apache/spark/sql/execution/benchmark/DeltaPlanningBenchmark.scala
b/backends-velox/src-delta33/test/scala/org/apache/spark/sql/execution/benchmark/DeltaPlanningBenchmark.scala
index 597a5b079d..68a2332f76 100644
---
a/backends-velox/src-delta33/test/scala/org/apache/spark/sql/execution/benchmark/DeltaPlanningBenchmark.scala
+++
b/backends-velox/src-delta33/test/scala/org/apache/spark/sql/execution/benchmark/DeltaPlanningBenchmark.scala
@@ -30,9 +30,9 @@ import org.apache.hadoop.fs.Path
*
* Measures two hot paths that our performance optimizations target:
*
- * 1. '''DV Materialization''' (`DeltaDeletionVectorScanInfo.normalize`):
resolves table paths,
- * loads DV bitmaps from storage, and serializes them into split
metadata. Our optimizations
- * (caching table path, Hadoop conf, DV store across files) target this
path.
+ * 1. '''DV Materialization''' (`DeltaDeletionVectorScanInfo.normalize`):
loads DV bitmaps from
+ * storage and serializes them into split metadata. Our optimizations
(reusing the Hadoop conf
+ * and DV store across files) target this path.
* 2. '''Post-transform rule application'''
(`DeltaPostTransformRules.rules`): traverses the
* physical plan to strip DV synthetic columns, push down
input_file_name, and apply column
* mapping. Our optimizations (early-exit guard, shallow child check,
pre-computed names,
@@ -85,7 +85,7 @@ object DeltaPlanningBenchmark extends SqlBasedBenchmark {
/**
* Benchmarks DeltaDeletionVectorScanInfo.normalize() -- the critical path
that loads DVs from
- * storage on the driver. Measures how caching table path + DV store reduces
overhead.
+ * storage on the driver. Measures how reusing the DV store across files
reduces overhead.
*/
private def runDvMaterializationBenchmark(): Unit = {
val benchmark = new Benchmark(
@@ -97,10 +97,7 @@ object DeltaPlanningBenchmark extends SqlBasedBenchmark {
withDeltaTableWithDVs(numFiles, rowsPerFile) {
(path, partitionedFiles) =>
benchmark.addCase(s"normalize() - $numFiles DV files", benchmarkIters)
{
- _ =>
- DeltaDeletionVectorScanInfo.normalize(
- partitionColumnCount = 0,
- partitionFiles = partitionedFiles)
+ _ => DeltaDeletionVectorScanInfo.normalize(partitionedFiles, new
Path(path))
}
benchmark.run()
diff --git
a/backends-velox/src-delta40/test/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfoSuite.scala
b/backends-velox/src-delta40/test/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfoSuite.scala
index cbb70817ba..94c4bd2193 100644
---
a/backends-velox/src-delta40/test/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfoSuite.scala
+++
b/backends-velox/src-delta40/test/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfoSuite.scala
@@ -82,7 +82,7 @@ class DeltaDeletionVectorScanInfoSuite
)
)
- val scanInfo = DeltaDeletionVectorScanInfo.extract(spark, 0,
partitionedFile)
+ val scanInfo = DeltaDeletionVectorScanInfo.extract(spark,
partitionedFile, new Path(path))
val dvInfo = scanInfo.deletionVectorInfo
assert(dvInfo.hasDeletionVector)
@@ -106,7 +106,7 @@ class DeltaDeletionVectorScanInfoSuite
dataFile.size,
Map("kept_key" -> "kept_value"))
- val scanInfo = DeltaDeletionVectorScanInfo.extract(spark, 0,
partitionedFile)
+ val scanInfo = DeltaDeletionVectorScanInfo.extract(spark,
partitionedFile, new Path(path))
val dvInfo = scanInfo.deletionVectorInfo
assert(!dvInfo.hasDeletionVector)
@@ -131,12 +131,56 @@ class DeltaDeletionVectorScanInfoSuite
Map(GlutenDeltaParquetFileFormat.FILE_ROW_INDEX_FILTER_TYPE ->
"IF_CONTAINED"))
val error = intercept[IllegalStateException] {
- DeltaDeletionVectorScanInfo.extract(spark, 0, partitionedFile)
+ DeltaDeletionVectorScanInfo.extract(spark, partitionedFile, new
Path(path))
}
assert(error.getMessage.contains("must either be present or absent"))
}
}
+ test("normalize materializes DV read options using the supplied table path")
{
+ withTempDir {
+ tempDir =>
+ val tablePath = new Path(tempDir.getCanonicalPath, "table")
+ val unrelatedPath = new Path(tempDir.getCanonicalPath, "unrelated")
+ Seq((1, "a"), (2, "b"), (3, "c"), (4, "d"))
+ .toDF("id", "value")
+ .coalesce(1)
+ .write
+ .format("delta")
+ .save(tablePath.toString)
+
+ spark.sql(
+ s"ALTER TABLE delta.`$tablePath` SET TBLPROPERTIES
('delta.enableDeletionVectors' = true)")
+ spark.sql(s"DELETE FROM delta.`$tablePath` WHERE id IN (3, 4)")
+
+ val dataFile = DeltaLog
+ .forTable(spark, tablePath)
+ .update()
+ .allFiles
+ .collect()
+ .find(_.deletionVector != null)
+ .get
+ assert(dataFile.deletionVector.storageType == "u")
+ val partitionedFile = partitionedFileWithMetadata(
+ unrelatedPath.toString,
+ dataFile.path,
+ dataFile.size,
+ Map(
+ GlutenDeltaParquetFileFormat.FILE_ROW_INDEX_FILTER_ID_ENCODED ->
+ dataFile.deletionVector.serializeToBase64(),
+ GlutenDeltaParquetFileFormat.FILE_ROW_INDEX_FILTER_TYPE ->
"IF_CONTAINED"
+ )
+ )
+
+ val result =
DeltaDeletionVectorScanInfo.normalize(Seq(partitionedFile), tablePath)
+ assert(result.isDefined, "normalize should materialize DV options")
+ val opts = result.get._2.head
+ assert(opts.hasDeletionVector)
+ assert(opts.deletionVectorCardinality ==
dataFile.deletionVector.cardinality)
+ assert(opts.serializedDeletionVector.nonEmpty)
+ }
+ }
+
private def partitionedFileWithMetadata(
tablePath: String,
relativeFilePath: String,
diff --git
a/gluten-delta/src-delta23/main/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfo.scala
b/gluten-delta/src-delta23/main/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfo.scala
index af8e8df7da..903a206663 100644
---
a/gluten-delta/src-delta23/main/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfo.scala
+++
b/gluten-delta/src-delta23/main/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfo.scala
@@ -20,10 +20,14 @@ import
org.apache.gluten.substrait.rel.DeltaLocalFilesNode.DeltaFileReadOptions
import org.apache.spark.sql.execution.datasources.PartitionedFile
+import org.apache.hadoop.fs.Path
+
import java.util.{Map => JMap}
/** Reading deletion vectors natively requires Delta 3.3+, so there is nothing
to materialize. */
object DeltaDeletionVectorScanInfo {
- def normalize(partitionColumnCount: Int, partitionFiles:
Seq[PartitionedFile])
+ def normalize(
+ partitionFiles: Seq[PartitionedFile],
+ tablePath: Path)
: Option[(Seq[JMap[String, Object]], Seq[DeltaFileReadOptions])] = None
}
diff --git
a/gluten-delta/src-delta24/main/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfo.scala
b/gluten-delta/src-delta24/main/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfo.scala
index af8e8df7da..903a206663 100644
---
a/gluten-delta/src-delta24/main/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfo.scala
+++
b/gluten-delta/src-delta24/main/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfo.scala
@@ -20,10 +20,14 @@ import
org.apache.gluten.substrait.rel.DeltaLocalFilesNode.DeltaFileReadOptions
import org.apache.spark.sql.execution.datasources.PartitionedFile
+import org.apache.hadoop.fs.Path
+
import java.util.{Map => JMap}
/** Reading deletion vectors natively requires Delta 3.3+, so there is nothing
to materialize. */
object DeltaDeletionVectorScanInfo {
- def normalize(partitionColumnCount: Int, partitionFiles:
Seq[PartitionedFile])
+ def normalize(
+ partitionFiles: Seq[PartitionedFile],
+ tablePath: Path)
: Option[(Seq[JMap[String, Object]], Seq[DeltaFileReadOptions])] = None
}
diff --git
a/gluten-delta/src-delta33/main/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfo.scala
b/gluten-delta/src-delta33/main/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfo.scala
index 812f9b9ec3..e5c4d8590b 100644
---
a/gluten-delta/src-delta33/main/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfo.scala
+++
b/gluten-delta/src-delta33/main/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfo.scala
@@ -30,7 +30,6 @@ import
org.apache.spark.sql.execution.datasources.PartitionedFile
import org.apache.hadoop.conf.Configuration
import org.apache.hadoop.fs.Path
-import java.io.DataInputStream
import java.util.{Map => JMap}
import scala.collection.JavaConverters._
@@ -64,11 +63,13 @@ object DeltaDeletionVectorScanInfo {
* the DV bookkeeping keys stripped. Returns None when no file in the split
carries a deletion
* vector, so callers can keep the generic split representation.
*
- * Performance: resolves the table path once (using the first file) and
reuses a single Hadoop
- * Configuration instance across all files in the partition to avoid
redundant filesystem I/O and
- * object allocation.
+ * `tablePath` is the Delta table root, supplied by the caller from
`TahoeFileIndex.path`, and is
+ * used to resolve on-disk DV locations. A single Hadoop Configuration is
reused across all files
+ * in the partition.
*/
- def normalize(partitionColumnCount: Int, partitionFiles:
Seq[PartitionedFile])
+ def normalize(
+ partitionFiles: Seq[PartitionedFile],
+ tablePath: Path)
: Option[(Seq[JMap[String, Object]], Seq[DeltaFileReadOptions])] = {
if (partitionFiles.isEmpty) {
return None
@@ -76,13 +77,8 @@ object DeltaDeletionVectorScanInfo {
val spark = activeSparkSession
// Create a single Hadoop Configuration for the entire partition.
val hadoopConf = spark.sessionState.newHadoopConf()
- // Resolve table path once using the first file -- all files in a Delta
table share the same
- // root, so this avoids N-1 redundant filesystem existence checks.
- val cachedTablePath = resolveTablePath(hadoopConf, partitionColumnCount,
partitionFiles.head)
- val scanInfos = partitionFiles.map {
- file => extract(partitionColumnCount, file, hadoopConf, cachedTablePath)
- }
+ val scanInfos = partitionFiles.map(file => extract(file, hadoopConf,
tablePath))
if (scanInfos.exists(_.deletionVectorInfo.hasDeletionVector)) {
Some(
(
@@ -96,15 +92,13 @@ object DeltaDeletionVectorScanInfo {
/** Public entry point for extracting DV info from a single file (used by
tests). */
def extract(
spark: SparkSession,
- partitionColumnCount: Int,
- file: PartitionedFile): PartitionFileScanInfo = {
+ file: PartitionedFile,
+ tablePath: Path): PartitionFileScanInfo = {
val hadoopConf = spark.sessionState.newHadoopConf()
- val tablePath = resolveTablePath(hadoopConf, partitionColumnCount, file)
- extract(partitionColumnCount, file, hadoopConf, tablePath)
+ extract(file, hadoopConf, tablePath)
}
private def extract(
- partitionColumnCount: Int,
file: PartitionedFile,
hadoopConf: Configuration,
tablePath: Path): PartitionFileScanInfo = {
@@ -236,11 +230,16 @@ object DeltaDeletionVectorScanInfo {
descriptor: DeletionVectorDescriptor): Array[Byte] = {
val dvPath = descriptor.absolutePath(tablePath)
val fs = dvPath.getFileSystem(hadoopConf)
- val stream = new DataInputStream(fs.open(dvPath))
+ // Positioned absolute seek, matching Delta's own
`HadoopFileSystemDVStore.read`. `seek` is a
+ // single positioned reposition (a ranged read on object stores), whereas
`DataInputStream.
+ // skipBytes` is best-effort -- it can skip fewer bytes than requested
without error, which would
+ // then fail the CRC check in `readRangeFromStream`. `FSDataInputStream`
is a `DataInputStream`,
+ // so it is passed through directly.
+ val stream = fs.open(dvPath)
try {
val offset = descriptor.offset.getOrElse(0)
if (offset > 0) {
- stream.skipBytes(offset)
+ stream.seek(offset.toLong)
}
DeletionVectorStore.readRangeFromStream(stream, descriptor.sizeInBytes)
} finally {
@@ -248,60 +247,4 @@ object DeltaDeletionVectorScanInfo {
}
}
- private def resolveTablePath(
- hadoopConf: org.apache.hadoop.conf.Configuration,
- partitionColumnCount: Int,
- file: PartitionedFile): Path = {
- val fileParent = new
Path(unescapePathName(file.filePath.toString)).getParent
- var tablePath = fileParent
- for (_ <- 0 until partitionColumnCount) {
- tablePath = tablePath.getParent
- }
- if (tablePath != null && isDeltaTablePath(hadoopConf, tablePath)) {
- return tablePath
- }
-
- var candidate = fileParent
- while (candidate != null && !isDeltaTablePath(hadoopConf, candidate)) {
- candidate = candidate.getParent
- }
- if (candidate != null) candidate else tablePath
- }
-
- private def isDeltaTablePath(
- hadoopConf: org.apache.hadoop.conf.Configuration,
- tablePath: Path): Boolean = {
- val deltaLogPath = new Path(tablePath, "_delta_log")
- try {
- deltaLogPath.getFileSystem(hadoopConf).exists(deltaLogPath)
- } catch {
- case NonFatal(_) => false
- }
- }
-
- private def unescapePathName(path: String): String = {
- if (path == null || path.indexOf('%') < 0) {
- path
- } else {
- val builder = new StringBuilder(path.length)
- var index = 0
- while (index < path.length) {
- if (path.charAt(index) == '%' && index + 2 < path.length) {
- val high = Character.digit(path.charAt(index + 1), 16)
- val low = Character.digit(path.charAt(index + 2), 16)
- if (high >= 0 && low >= 0) {
- builder.append(((high << 4) | low).toChar)
- index += 3
- } else {
- builder.append(path.charAt(index))
- index += 1
- }
- } else {
- builder.append(path.charAt(index))
- index += 1
- }
- }
- builder.toString()
- }
- }
}
diff --git
a/gluten-delta/src-delta40/main/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfo.scala
b/gluten-delta/src-delta40/main/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfo.scala
index 22ffb3c89a..11530d665a 100644
---
a/gluten-delta/src-delta40/main/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfo.scala
+++
b/gluten-delta/src-delta40/main/scala/org/apache/gluten/delta/DeltaDeletionVectorScanInfo.scala
@@ -30,7 +30,6 @@ import
org.apache.spark.sql.execution.datasources.PartitionedFile
import org.apache.hadoop.conf.Configuration
import org.apache.hadoop.fs.Path
-import java.io.DataInputStream
import java.util.{Map => JMap}
import scala.collection.JavaConverters._
@@ -65,22 +64,21 @@ object DeltaDeletionVectorScanInfo {
* the DV bookkeeping keys stripped. Returns None when no file in the split
carries a deletion
* vector, so callers can keep the generic split representation.
*
- * Performance: resolves the table path once (using the first file) and
reuses a single Hadoop
- * Configuration instance across all files in the partition to avoid
redundant filesystem I/O and
- * object allocation.
+ * `tablePath` is the Delta table root, supplied by the caller from
`TahoeFileIndex.path`, and is
+ * used to resolve on-disk DV locations. A single Hadoop Configuration is
reused across all files
+ * in the partition.
*/
- def normalize(partitionColumnCount: Int, partitionFiles:
Seq[PartitionedFile])
+ def normalize(
+ partitionFiles: Seq[PartitionedFile],
+ tablePath: Path)
: Option[(Seq[JMap[String, Object]], Seq[DeltaFileReadOptions])] = {
if (partitionFiles.isEmpty) {
return None
}
val spark = activeSparkSession
val hadoopConf = spark.sessionState.newHadoopConf()
- val cachedTablePath = resolveTablePath(hadoopConf, partitionColumnCount,
partitionFiles.head)
- val scanInfos = partitionFiles.map {
- file => extract(partitionColumnCount, file, hadoopConf, cachedTablePath)
- }
+ val scanInfos = partitionFiles.map(file => extract(file, hadoopConf,
tablePath))
if (scanInfos.exists(_.deletionVectorInfo.hasDeletionVector)) {
Some(
(
@@ -94,15 +92,13 @@ object DeltaDeletionVectorScanInfo {
/** Public entry point for extracting DV info from a single file (used by
tests). */
def extract(
spark: SparkSession,
- partitionColumnCount: Int,
- file: PartitionedFile): PartitionFileScanInfo = {
+ file: PartitionedFile,
+ tablePath: Path): PartitionFileScanInfo = {
val hadoopConf = spark.sessionState.newHadoopConf()
- val tablePath = resolveTablePath(hadoopConf, partitionColumnCount, file)
- extract(partitionColumnCount, file, hadoopConf, tablePath)
+ extract(file, hadoopConf, tablePath)
}
private def extract(
- partitionColumnCount: Int,
file: PartitionedFile,
hadoopConf: Configuration,
tablePath: Path): PartitionFileScanInfo = {
@@ -240,11 +236,16 @@ object DeltaDeletionVectorScanInfo {
descriptor: DeletionVectorDescriptor): Array[Byte] = {
val dvPath = descriptor.absolutePath(tablePath)
val fs = dvPath.getFileSystem(hadoopConf)
- val stream = new DataInputStream(fs.open(dvPath))
+ // Positioned absolute seek, matching Delta's own
`HadoopFileSystemDVStore.read`. `seek` is a
+ // single positioned reposition (a ranged read on object stores), whereas
`DataInputStream.
+ // skipBytes` is best-effort -- it can skip fewer bytes than requested
without error, which would
+ // then fail the CRC check in `readRangeFromStream`. `FSDataInputStream`
is a `DataInputStream`,
+ // so it is passed through directly.
+ val stream = fs.open(dvPath)
try {
val offset = descriptor.offset.getOrElse(0)
if (offset > 0) {
- stream.skipBytes(offset)
+ stream.seek(offset.toLong)
}
DeletionVectorStore.readRangeFromStream(stream, descriptor.sizeInBytes)
} finally {
@@ -252,60 +253,4 @@ object DeltaDeletionVectorScanInfo {
}
}
- private def resolveTablePath(
- hadoopConf: org.apache.hadoop.conf.Configuration,
- partitionColumnCount: Int,
- file: PartitionedFile): Path = {
- val fileParent = new
Path(unescapePathName(file.filePath.toString)).getParent
- var tablePath = fileParent
- for (_ <- 0 until partitionColumnCount) {
- tablePath = tablePath.getParent
- }
- if (tablePath != null && isDeltaTablePath(hadoopConf, tablePath)) {
- return tablePath
- }
-
- var candidate = fileParent
- while (candidate != null && !isDeltaTablePath(hadoopConf, candidate)) {
- candidate = candidate.getParent
- }
- if (candidate != null) candidate else tablePath
- }
-
- private def isDeltaTablePath(
- hadoopConf: org.apache.hadoop.conf.Configuration,
- tablePath: Path): Boolean = {
- val deltaLogPath = new Path(tablePath, "_delta_log")
- try {
- deltaLogPath.getFileSystem(hadoopConf).exists(deltaLogPath)
- } catch {
- case NonFatal(_) => false
- }
- }
-
- private def unescapePathName(path: String): String = {
- if (path == null || path.indexOf('%') < 0) {
- path
- } else {
- val builder = new StringBuilder(path.length)
- var index = 0
- while (index < path.length) {
- if (path.charAt(index) == '%' && index + 2 < path.length) {
- val high = Character.digit(path.charAt(index + 1), 16)
- val low = Character.digit(path.charAt(index + 2), 16)
- if (high >= 0 && low >= 0) {
- builder.append(((high << 4) | low).toChar)
- index += 3
- } else {
- builder.append(path.charAt(index))
- index += 1
- }
- } else {
- builder.append(path.charAt(index))
- index += 1
- }
- }
- builder.toString()
- }
- }
}
diff --git
a/gluten-delta/src/main/scala/org/apache/gluten/execution/DeltaScanTransformer.scala
b/gluten-delta/src/main/scala/org/apache/gluten/execution/DeltaScanTransformer.scala
index c2cb6604db..50073cd197 100644
---
a/gluten-delta/src/main/scala/org/apache/gluten/execution/DeltaScanTransformer.scala
+++
b/gluten-delta/src/main/scala/org/apache/gluten/execution/DeltaScanTransformer.scala
@@ -27,7 +27,7 @@ import org.apache.spark.sql.catalyst.expressions.{Attribute,
AttributeReference,
import org.apache.spark.sql.catalyst.plans.QueryPlan
import org.apache.spark.sql.connector.read.streaming.SparkDataStream
import org.apache.spark.sql.delta.{DeltaParquetFileFormat, NoMapping}
-import org.apache.spark.sql.delta.files.{CdcAddFileIndex, TahoeRemoveFileIndex}
+import org.apache.spark.sql.delta.files.{CdcAddFileIndex, TahoeFileIndex,
TahoeRemoveFileIndex}
import org.apache.spark.sql.execution.FileSourceScanExec
import org.apache.spark.sql.execution.datasources.{FilePartition,
HadoopFsRelation}
import org.apache.spark.sql.types.StructType
@@ -120,20 +120,29 @@ case class DeltaScanTransformer(
override def getSplitInfosFromPartitions(
partitions: Seq[(Partition, ReadFileFormat)]): Seq[SplitInfo] = {
val splitInfos = super.getSplitInfosFromPartitions(partitions)
- val partitionColumnCount = getPartitionSchema.fields.length
- splitInfos.zip(partitions).map {
- case (localFiles: LocalFilesNode, (filePartition: FilePartition, _)) =>
- DeltaDeletionVectorScanInfo
- .normalize(partitionColumnCount, filePartition.files.toSeq)
- .map {
- case (otherMetadataColumns, deltaReadOptions) =>
- DeltaLocalFilesBuilder.makeDeltaLocalFiles(
- localFiles,
- otherMetadataColumns.asJava,
- deltaReadOptions.asJava): SplitInfo
- }
- .getOrElse(localFiles)
- case (splitInfo, _) => splitInfo
+ // Deletion vectors only exist on Delta tables read through a
TahoeFileIndex (which also covers
+ // PreparedDeltaFileIndex). Its `path` is the authoritative table root and
is used to resolve
+ // per-file DV locations. Any other location cannot carry Delta DV
metadata, so the generic
+ // split representation is returned unchanged.
+ relation.location match {
+ case tahoe: TahoeFileIndex =>
+ val tableRootPath = tahoe.path
+ splitInfos.zip(partitions).map {
+ case (localFiles: LocalFilesNode, (filePartition: FilePartition, _))
=>
+ DeltaDeletionVectorScanInfo
+ .normalize(filePartition.files.toSeq, tableRootPath)
+ .map {
+ case (otherMetadataColumns, deltaReadOptions) =>
+ DeltaLocalFilesBuilder.makeDeltaLocalFiles(
+ localFiles,
+ otherMetadataColumns.asJava,
+ deltaReadOptions.asJava): SplitInfo
+ }
+ .getOrElse(localFiles)
+ case (splitInfo, _) => splitInfo
+ }
+ case _ =>
+ splitInfos
}
}
diff --git
a/gluten-delta/src/test/scala/org/apache/gluten/execution/DeltaSuite.scala
b/gluten-delta/src/test/scala/org/apache/gluten/execution/DeltaSuite.scala
index 138d318cff..d5fd0bfb7e 100644
--- a/gluten-delta/src/test/scala/org/apache/gluten/execution/DeltaSuite.scala
+++ b/gluten-delta/src/test/scala/org/apache/gluten/execution/DeltaSuite.scala
@@ -20,9 +20,13 @@ import org.apache.gluten.extension.DeltaPostTransformRules
import org.apache.spark.SparkConf
import org.apache.spark.sql.{DataFrame, Row}
+import org.apache.spark.sql.catalyst.TableIdentifier
+import org.apache.spark.sql.delta.DeltaLog
import org.apache.spark.sql.types._
import org.apache.spark.util.SparkVersionUtil
+import org.apache.hadoop.fs.Path
+
import scala.collection.JavaConverters._
abstract class DeltaSuite extends WholeStageTransformerSuite {
@@ -628,6 +632,95 @@ abstract class DeltaSuite extends
WholeStageTransformerSuite {
}
}
+ testWithMinSparkVersion("deletion vector on partitioned table", "3.4") {
+ withTempPath {
+ p =>
+ import testImplicits._
+ val path = p.getCanonicalPath
+ // End-to-end DV read over a partitioned table: data files live under
partition subdirs
+ // (region=.../...) while the DELETE writes table-root-relative ("u")
UUID deletion vectors.
+ // This exercises the full native DV path -- resolving each DV against
the table root
+ // (TahoeFileIndex.path) and applying it -- and asserts correct
results. The root
+ // discrimination itself is unit-tested in
DeltaDeletionVectorScanInfoSuite ("normalize
+ // materializes DV read options using the supplied table path"), which
points a
+ // PartitionedFile at an unrelated directory.
+ val data =
+ Seq((1, "a"), (2, "a"), (3, "b"), (4, "b"), (5, "a"), (6,
"b")).toDF("id", "region")
+ data.write.format("delta").partitionBy("region").save(path)
+ spark.sql(
+ s"ALTER TABLE delta.`$path` SET TBLPROPERTIES
('delta.enableDeletionVectors' = true)")
+ spark.sql(s"DELETE FROM delta.`$path` WHERE id IN (2, 3, 6)")
+ val deletionVectors = DeltaLog
+ .forTable(spark, new Path(path))
+ .update()
+ .allFiles
+ .collect()
+ .flatMap(file => Option(file.deletionVector))
+ assert(deletionVectors.nonEmpty, "DELETE should produce deletion
vectors")
+ assert(
+ deletionVectors.exists(_.storageType == "u"),
+ "DELETE should produce a table-root-relative UUID deletion vector")
+ val df = spark.read.format("delta").load(path)
+ if (SparkVersionUtil.gteSpark35) {
+ assert(
+ df.queryExecution.executedPlan
+ .collect { case _: DeltaScanTransformer => true }
+ .nonEmpty)
+ }
+ checkAnswer(df, Seq((1, "a"), (4, "b"), (5, "a")).toDF("id", "region"))
+ }
+ }
+
+ testWithMinSparkVersion("deletion vector on shallow-cloned table", "3.4") {
+ withTable("dv_clone_source", "dv_clone_target") {
+ import testImplicits._
+ // Shallow clone is the case the old data-file walk-up got wrong. The
clone's AddFile paths
+ // point ABSOLUTE into the source table, while its _delta_log (and the
DV written by a DELETE
+ // on the clone) live under the clone root. Walking up from a data file
therefore lands on the
+ // SOURCE table's _delta_log and resolves the wrong root, so the
clone-root-relative "u" DV
+ // cannot be found. Sourcing the root from TahoeFileIndex.path fixes
this. This pins the
+ // DeltaScanTransformer Tahoe arm on Spark 3.5 (the
CloneTableScalaDeletionVectorSuite shards
+ // only run on delta40).
+ spark.sql(
+ "CREATE TABLE dv_clone_source (id INT, region STRING) USING delta " +
+ "TBLPROPERTIES ('delta.enableDeletionVectors' = true)")
+ Seq((1, "a"), (2, "a"), (3, "b"), (4, "b"), (5, "a"), (6, "b"))
+ .toDF("id", "region")
+ .write
+ .format("delta")
+ .mode("append")
+ .saveAsTable("dv_clone_source")
+ spark.sql("CREATE TABLE dv_clone_target SHALLOW CLONE dv_clone_source")
+ // DELETE on the clone writes a clone-root-relative UUID ("u") DV; the
data files stay
+ // absolute into the source.
+ spark.sql("DELETE FROM dv_clone_target WHERE id IN (2, 3, 6)")
+ val targetLocation =
+
spark.sessionState.catalog.getTableMetadata(TableIdentifier("dv_clone_target")).location
+ val deletionVectors = DeltaLog
+ .forTable(spark, new Path(targetLocation))
+ .update()
+ .allFiles
+ .collect()
+ .flatMap(file => Option(file.deletionVector))
+ assert(deletionVectors.nonEmpty, "DELETE on the clone should produce
deletion vectors")
+ assert(
+ deletionVectors.exists(_.storageType == "u"),
+ "DELETE on the clone should produce a clone-root-relative UUID
deletion vector")
+ val df = spark.table("dv_clone_target")
+ if (SparkVersionUtil.gteSpark35) {
+ assert(
+ df.queryExecution.executedPlan
+ .collect { case _: DeltaScanTransformer => true }
+ .nonEmpty)
+ }
+ // The clone's DELETE must not affect the source table.
+ checkAnswer(
+ spark.table("dv_clone_source"),
+ Seq((1, "a"), (2, "a"), (3, "b"), (4, "b"), (5, "a"), (6,
"b")).toDF("id", "region"))
+ checkAnswer(df, Seq((1, "a"), (4, "b"), (5, "a")).toDF("id", "region"))
+ }
+ }
+
test("delta: push down input_file_name expression") {
withTable("source_table") {
withTable("target_table") {
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]