Skip to content

Commit ced237d

Browse files
authored
Fix OSS Delta RTAS on Spark 4.x+ [databricks] (#15411)
Fixes #15394 ### Description On OSS Spark 4.0 with Delta Lake 4.0, `CREATE OR REPLACE TABLE ... USING DELTA AS SELECT ...` succeeds on CPU but fails on GPU with: ```text [UNSUPPORTED_FEATURE.TABLE_OPERATION] Table ... does not support truncate in batch mode ``` Spark 4 plans RTAS as an unconditional `OverwriteByExpression`. Its table capability check therefore requires the staged table to support `TRUNCATE`. The RAPIDS staged Delta table advertised only `V1_BATCH_WRITE`, and its write builder did not implement `SupportsTruncate`. In addition, the Delta provider's `OverwriteByExpressionExecV1` handling recognized regular `DeltaTableV2` tables but not the RAPIDS staged Delta table used by atomic RTAS. In this PR: - Added `TRUNCATE` to the capabilities advertised by `GpuStagedDeltaTableV2`. - Implemented `SupportsTruncate` in the staged Delta V1 write builder. - Tags and converts `OverwriteByExpressionExecV1` for RAPIDS staged Delta tables to `GpuOverwriteByExpressionExecV1`. - Applied the staged-table handling to the Delta 3.3, 4.0, and 4.1 provider implementations. - Preserved the existing behavior for regular `DeltaTableV2` tables and continues to reject unrelated table implementations. - Limits the existing Delta issue #4671 RTAS xfails to the affected Spark 3.5 releases instead of also applying them to Spark 4. - Added an integration regression test. ### Testing #### OSS Spark 4.0 / Delta Lake 4.0 Focused integration test: ```text TEST=test_delta_rtas_truncate_capability ``` Result: `1 passed, 39635 deselected`. Broader focused RTAS selection: ```text TEST="test_delta_rtas_truncate_capability or test_delta_rtas_sql" ``` Result: `3 passed, 1 skipped, 39632 deselected`. ### Checklists Documentation - [ ] Updated for new or modified user-facing features or behaviors - [x] No user-facing change Testing - [x] Added or modified tests to cover new code paths - [ ] Covered by existing tests (Please provide the names of the existing tests in the PR description.) - [ ] Not required Performance - [ ] Tests ran and results are added in the PR description - [ ] Issue filed with a link in the PR description - [x] Not required --------- Signed-off-by: Niranjan Artal <nartal@nvidia.com>
1 parent 5efcd17 commit ced237d

5 files changed

Lines changed: 143 additions & 8 deletions

File tree

delta-lake/common/src/main/delta-33x-41x/scala/com/nvidia/spark/rapids/delta/GpuDeltaCatalogBase.scala

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import org.apache.spark.sql.catalyst.catalog.{CatalogTable, CatalogTableType, Ca
3333
import org.apache.spark.sql.connector.catalog.{Identifier, StagedTable, StagingTableCatalog, SupportsWrite, Table, TableCapability, TableCatalog, TableChange}
3434
import org.apache.spark.sql.connector.catalog.TableCapability._
3535
import org.apache.spark.sql.connector.expressions.Transform
36-
import org.apache.spark.sql.connector.write.{LogicalWriteInfo, V1Write, WriteBuilder}
36+
import org.apache.spark.sql.connector.write.{LogicalWriteInfo, SupportsTruncate, V1Write, WriteBuilder}
3737
import org.apache.spark.sql.delta.{ColumnWithDefaultExprUtils, DeltaConfigs, DeltaErrors, DeltaLog, DeltaOptions}
3838
import org.apache.spark.sql.delta.catalog.DeltaCatalog
3939
import org.apache.spark.sql.delta.commands.{TableCreationModes, WriteIntoDelta}
@@ -471,7 +471,7 @@ abstract class GpuDeltaCatalogBase(
471471
override def abortStagedChanges(): Unit = {}
472472

473473
override def capabilities(): util.Set[TableCapability] = {
474-
Set(V1_BATCH_WRITE).asJava
474+
Set(V1_BATCH_WRITE, TRUNCATE).asJava
475475
}
476476

477477
override def newWriteBuilder(info: LogicalWriteInfo): WriteBuilder = {
@@ -482,7 +482,9 @@ abstract class GpuDeltaCatalogBase(
482482
/*
483483
* WriteBuilder for creating a Delta table.
484484
*/
485-
private class DeltaV1WriteBuilder extends WriteBuilder {
485+
private class DeltaV1WriteBuilder extends WriteBuilder with SupportsTruncate {
486+
override def truncate(): this.type = this
487+
486488
override def build(): V1Write = new V1Write {
487489
override def toInsertableRelation(): InsertableRelation = {
488490
new InsertableRelation {

delta-lake/delta-33x/src/main/scala/com/nvidia/spark/rapids/delta/delta33x/Delta33xProvider.scala

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import org.apache.spark.sql.delta.catalog.DeltaTableV2
2626
import org.apache.spark.sql.delta.commands.{DeleteCommand, MergeIntoCommand, OptimizeTableCommand, UpdateCommand}
2727
import org.apache.spark.sql.execution.command.RunnableCommand
2828
import org.apache.spark.sql.execution.datasources.FileFormat
29-
import org.apache.spark.sql.execution.datasources.v2.AppendDataExecV1
29+
import org.apache.spark.sql.execution.datasources.v2.{AppendDataExecV1, OverwriteByExpressionExecV1}
3030

3131
object Delta33xProvider extends DeltaProviderBase with Logging {
3232

@@ -52,6 +52,21 @@ object Delta33xProvider extends DeltaProviderBase with Logging {
5252
}
5353
}
5454

55+
override def tagForGpu(
56+
cpuExec: OverwriteByExpressionExecV1,
57+
meta: OverwriteByExpressionExecV1Meta): Unit = {
58+
if (!meta.conf.isDeltaWriteEnabled) {
59+
meta.willNotWorkOnGpu("Delta Lake output acceleration has been disabled. To enable set " +
60+
s"${RapidsConf.ENABLE_DELTA_WRITE} to true")
61+
}
62+
63+
cpuExec.table match {
64+
case _: DeltaTableV2 => super.tagForGpu(cpuExec, meta)
65+
case _: GpuDeltaCatalog#GpuStagedDeltaTableV2 =>
66+
case _ => meta.willNotWorkOnGpu(s"${cpuExec.table} table class not supported on GPU")
67+
}
68+
}
69+
5570
override def getRunnableCommandRules: Map[Class[_ <: RunnableCommand],
5671
RunnableCommandRule[_ <: RunnableCommand]] = {
5772
Seq(
@@ -109,4 +124,17 @@ object Delta33xProvider extends DeltaProviderBase with Logging {
109124
case unknown => throw new IllegalStateException(s"$unknown doesn't match any of the known ")
110125
}
111126
}
127+
128+
override def convertToGpu(
129+
cpuExec: OverwriteByExpressionExecV1,
130+
meta: OverwriteByExpressionExecV1Meta): GpuExec = {
131+
cpuExec.table match {
132+
case _: DeltaTableV2 =>
133+
super.convertToGpu(cpuExec, meta)
134+
case _: GpuDeltaCatalog#GpuStagedDeltaTableV2 =>
135+
GpuOverwriteByExpressionExecV1(
136+
cpuExec.table, cpuExec.plan, cpuExec.refreshCache, cpuExec.write)
137+
case unknown => throw new IllegalStateException(s"$unknown doesn't match any of the known ")
138+
}
139+
}
112140
}

delta-lake/delta-40x/src/main/scala/com/nvidia/spark/rapids/delta/delta40x/Delta40xProvider.scala

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import org.apache.spark.sql.delta.commands.{DeleteCommand, MergeIntoCommand, Opt
2929
import org.apache.spark.sql.delta.rapids.GpuDeltaCatalog4x
3030
import org.apache.spark.sql.execution.command.RunnableCommand
3131
import org.apache.spark.sql.execution.datasources.FileFormat
32-
import org.apache.spark.sql.execution.datasources.v2.AppendDataExecV1
32+
import org.apache.spark.sql.execution.datasources.v2.{AppendDataExecV1, OverwriteByExpressionExecV1}
3333

3434
object Delta40xProvider extends DeltaProviderBase with Logging {
3535

@@ -55,6 +55,21 @@ object Delta40xProvider extends DeltaProviderBase with Logging {
5555
}
5656
}
5757

58+
override def tagForGpu(
59+
cpuExec: OverwriteByExpressionExecV1,
60+
meta: OverwriteByExpressionExecV1Meta): Unit = {
61+
if (!meta.conf.isDeltaWriteEnabled) {
62+
meta.willNotWorkOnGpu("Delta Lake output acceleration has been disabled. To enable set " +
63+
s"${RapidsConf.ENABLE_DELTA_WRITE} to true")
64+
}
65+
66+
cpuExec.table match {
67+
case _: DeltaTableV2 => super.tagForGpu(cpuExec, meta)
68+
case _: GpuDeltaCatalog4x#GpuStagedDeltaTableV2 =>
69+
case _ => meta.willNotWorkOnGpu(s"${cpuExec.table} table class not supported on GPU")
70+
}
71+
}
72+
5873
override def getRunnableCommandRules: Map[Class[_ <: RunnableCommand],
5974
RunnableCommandRule[_ <: RunnableCommand]] = {
6075
Seq(
@@ -119,4 +134,17 @@ object Delta40xProvider extends DeltaProviderBase with Logging {
119134
case unknown => throw new IllegalStateException(s"$unknown doesn't match any of the known ")
120135
}
121136
}
137+
138+
override def convertToGpu(
139+
cpuExec: OverwriteByExpressionExecV1,
140+
meta: OverwriteByExpressionExecV1Meta): GpuExec = {
141+
cpuExec.table match {
142+
case _: DeltaTableV2 =>
143+
super.convertToGpu(cpuExec, meta)
144+
case _: GpuDeltaCatalog4x#GpuStagedDeltaTableV2 =>
145+
GpuOverwriteByExpressionExecV1(
146+
cpuExec.table, cpuExec.plan, cpuExec.refreshCache, cpuExec.write)
147+
case unknown => throw new IllegalStateException(s"$unknown doesn't match any of the known ")
148+
}
149+
}
122150
}

delta-lake/delta-41x/src/main/scala/com/nvidia/spark/rapids/delta/delta41x/Delta41xProvider.scala

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import org.apache.spark.sql.delta.commands.{DeleteCommand, MergeIntoCommand, Opt
2929
import org.apache.spark.sql.delta.rapids.GpuDeltaCatalog4x
3030
import org.apache.spark.sql.execution.command.RunnableCommand
3131
import org.apache.spark.sql.execution.datasources.FileFormat
32-
import org.apache.spark.sql.execution.datasources.v2.AppendDataExecV1
32+
import org.apache.spark.sql.execution.datasources.v2.{AppendDataExecV1, OverwriteByExpressionExecV1}
3333

3434
object Delta41xProvider extends DeltaProviderBase with Logging {
3535

@@ -55,6 +55,21 @@ object Delta41xProvider extends DeltaProviderBase with Logging {
5555
}
5656
}
5757

58+
override def tagForGpu(
59+
cpuExec: OverwriteByExpressionExecV1,
60+
meta: OverwriteByExpressionExecV1Meta): Unit = {
61+
if (!meta.conf.isDeltaWriteEnabled) {
62+
meta.willNotWorkOnGpu("Delta Lake output acceleration has been disabled. To enable set " +
63+
s"${RapidsConf.ENABLE_DELTA_WRITE} to true")
64+
}
65+
66+
cpuExec.table match {
67+
case _: DeltaTableV2 => super.tagForGpu(cpuExec, meta)
68+
case _: GpuDeltaCatalog4x#GpuStagedDeltaTableV2 =>
69+
case _ => meta.willNotWorkOnGpu(s"${cpuExec.table} table class not supported on GPU")
70+
}
71+
}
72+
5873
override def getRunnableCommandRules: Map[Class[_ <: RunnableCommand],
5974
RunnableCommandRule[_ <: RunnableCommand]] = {
6075
Seq(
@@ -119,4 +134,17 @@ object Delta41xProvider extends DeltaProviderBase with Logging {
119134
case unknown => throw new IllegalStateException(s"$unknown doesn't match any of the known ")
120135
}
121136
}
137+
138+
override def convertToGpu(
139+
cpuExec: OverwriteByExpressionExecV1,
140+
meta: OverwriteByExpressionExecV1Meta): GpuExec = {
141+
cpuExec.table match {
142+
case _: DeltaTableV2 =>
143+
super.convertToGpu(cpuExec, meta)
144+
case _: GpuDeltaCatalog4x#GpuStagedDeltaTableV2 =>
145+
GpuOverwriteByExpressionExecV1(
146+
cpuExec.table, cpuExec.plan, cpuExec.refreshCache, cpuExec.write)
147+
case unknown => throw new IllegalStateException(s"$unknown doesn't match any of the known ")
148+
}
149+
}
122150
}

integration_tests/src/main/python/delta_lake_write_test.py

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -745,7 +745,8 @@ def test_delta_atomic_create_table_as_select(spark_tmp_table_factory, spark_tmp_
745745
@pytest.mark.skipif(is_before_spark_320(), reason="Delta Lake writes are not supported before Spark 3.2.x")
746746
@pytest.mark.parametrize("enable_deletion_vectors", deletion_vector_values_with_xfail_reasons(
747747
enabled_xfail_reason="https://github.com/NVIDIA/spark-rapids/issues/12041"), ids=idfn)
748-
@pytest.mark.xfail(is_spark_356_or_later(), reason="https://github.com/delta-io/delta/issues/4671")
748+
@pytest.mark.xfail(is_spark_356_or_later() and not is_spark_400_or_later(),
749+
reason="https://github.com/delta-io/delta/issues/4671")
749750
@pytest.mark.xfail(is_databricks_runtime(), reason="https://github.com/NVIDIA/spark-rapids/issues/11169")
750751
def test_delta_atomic_replace_table_as_select(spark_tmp_table_factory, spark_tmp_path, enable_deletion_vectors):
751752
_atomic_write_table_as_select(delta_write_gens, spark_tmp_table_factory, spark_tmp_path,
@@ -816,13 +817,61 @@ def test_delta_ctas_sql(spark_tmp_table_factory, enable_deletion_vectors, use_cd
816817
@pytest.mark.parametrize("enable_deletion_vectors", deletion_vector_values_with_xfail_reasons(
817818
enabled_xfail_reason="https://github.com/NVIDIA/spark-rapids/issues/12041"), ids=idfn)
818819
@pytest.mark.parametrize("use_cdf", [True, False], ids=idfn)
819-
@pytest.mark.xfail(is_spark_356_or_later(), reason="https://github.com/delta-io/delta/issues/4671")
820+
@pytest.mark.xfail(is_spark_356_or_later() and not is_spark_400_or_later(),
821+
reason="https://github.com/delta-io/delta/issues/4671")
820822
@pytest.mark.xfail(is_databricks_runtime(), reason="https://github.com/NVIDIA/spark-rapids/issues/11169")
821823
def test_delta_rtas_sql(spark_tmp_table_factory, enable_deletion_vectors, use_cdf):
822824
_atomic_write_table_as_select_sql(delta_write_gens, spark_tmp_table_factory,
823825
True, enable_deletion_vectors, use_cdf)
824826

825827

828+
@allow_non_gpu_conditional(
829+
is_databricks_runtime(),
830+
'AppendDataExecV1, AtomicCreateTableAsSelectExec, AtomicReplaceTableAsSelectExec')
831+
@allow_non_gpu('DataWritingCommandExec', 'WriteFilesExec', *delta_meta_allow)
832+
@delta_lake
833+
@ignore_order(local=True)
834+
@pytest.mark.skipif(not is_spark_400_or_later(),
835+
reason="Delta Lake 4.0 contains the native truncate capability")
836+
def test_delta_rtas_truncate_capability(spark_tmp_table_factory):
837+
cpu_table = spark_tmp_table_factory.get()
838+
gpu_table = spark_tmp_table_factory.get()
839+
confs = copy_and_update(writer_confs, delta_writes_enabled_conf)
840+
841+
def create_initial_tables(spark):
842+
for table in [cpu_table, gpu_table]:
843+
spark.sql(f"CREATE TABLE {table} USING DELTA AS SELECT id FROM range(10)")
844+
845+
def replace_table(spark, table):
846+
spark.sql(
847+
f"CREATE OR REPLACE TABLE {table} USING DELTA "
848+
f"AS SELECT id FROM range(20) WHERE id >= 10")
849+
850+
with_cpu_session(create_initial_tables, conf=confs)
851+
with_cpu_session(lambda spark: replace_table(spark, cpu_table), conf=confs)
852+
853+
callback = spark_jvm().org.apache.spark.sql.rapids.ExecutionPlanCaptureCallback
854+
callback.startCapture()
855+
try:
856+
with_gpu_session(lambda spark: replace_table(spark, gpu_table), conf=confs)
857+
plans = callback.getResultsWithTimeout(10000)
858+
assert any(callback.contains(plan, "GpuAtomicReplaceTableAsSelectExec")
859+
for plan in plans), "GpuAtomicReplaceTableAsSelectExec was not executed"
860+
if not is_databricks_runtime():
861+
assert any(callback.contains(plan, "GpuOverwriteByExpressionExecV1")
862+
for plan in plans), "GpuOverwriteByExpressionExecV1 was not executed"
863+
finally:
864+
callback.endCapture()
865+
866+
def read_ids(spark, table):
867+
return spark.sql(f"SELECT id FROM {table} ORDER BY id").collect()
868+
869+
cpu_rows = with_cpu_session(lambda spark: read_ids(spark, cpu_table), conf=confs)
870+
gpu_rows = with_cpu_session(lambda spark: read_ids(spark, gpu_table), conf=confs)
871+
assert_equal(cpu_rows, gpu_rows)
872+
assert [row.id for row in gpu_rows] == list(range(10, 20))
873+
874+
826875
@allow_non_gpu(*delta_meta_allow)
827876
@delta_lake
828877
@ignore_order(local=True)

0 commit comments

Comments
 (0)