This is an automated email from the ASF dual-hosted git repository.

cloud-fan pushed a commit to branch branch-4.3
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/branch-4.3 by this push:
     new d67467d164f6 [SPARK-57225][SQL] Reject batch writes for DSv2 sources 
without write capability
d67467d164f6 is described below

commit d67467d164f6704dd475d328bbc5c7846025e118
Author: Shrirang Mhalgi <[email protected]>
AuthorDate: Tue Aug 4 14:45:12 2026 +0800

    [SPARK-57225][SQL] Reject batch writes for DSv2 sources without write 
capability
    
    ### What changes were proposed in this pull request?
    When `DataFrameWriter.saveCommand` encounters a DSv2 `TableProvider` whose 
table declares neither `BATCH_WRITE` nor `V1_BATCH_WRITE`, it now throws a 
clear `UNSUPPORTED_FEATURE.TABLE_OPERATION` error with operation "batch write" 
instead of silently falling through to the V1 write path and producing a 
confusing `INTERNAL_ERROR`.
    
    The V1 fallback is preserved when:
    - The table declares `V1_BATCH_WRITE` (JDBC pattern), OR
    - The provider implements `CreatableRelationProvider` (backward 
compatibility with sources that predate the `V1_BATCH_WRITE` capability)
    
    ### Why are the changes needed?
    Read-only DSv2 sources like `StateDataSource` (statestore) and 
`StateMetadataSource` (state-metadata) only declare `BATCH_READ`. Previously, 
`df.write.format("statestore").save()` fell through to the V1 write path, hit 
the `case _` branch in `DataSource.planForWriting`, and threw:
    
    ```
    [INTERNAL_ERROR] The operation "planForWriting" is not supported by the 
data source
    org.apache.spark.sql.execution.datasources.v2.state.StateDataSource does 
not allow
    create table as select.
    ```
    
    ### Does this PR introduce _any_ user-facing change?
    Yes. Write attempts on read-only DSv2 sources now produce:
    
    ```
    [UNSUPPORTED_FEATURE.TABLE_OPERATION] The feature is not supported:
    Table `read_only_table` does not support batch write.
    ```
    
    ### How was this patch tested?
    - Added test: read-only DSv2 source (BATCH_READ only) throws 
`UNSUPPORTED_FEATURE.TABLE_OPERATION` on both Append mode and default 
(ErrorIfExists) mode
    - Added test: DSv2 source with `V1_BATCH_WRITE` still falls back to V1 path 
without error
    - Verified existing `V1WriteFallbackSuite`, 
`V1WriteFallbackSessionCatalogSuite`, `TableCapabilityCheckSuite`, 
`DataFrameReaderWriterSuite` all pass.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    Yes. Using Claude-Opus 4.6
    
    Closes #56319 from shrirangmhalgi/SPARK-57225-state-datasource-readonly.
    
    Authored-by: Shrirang Mhalgi <[email protected]>
    Signed-off-by: Wenchen Fan <[email protected]>
    (cherry picked from commit ac1d66260046a3d2bf74c8f503fd9fe4b1578d12)
    Signed-off-by: Wenchen Fan <[email protected]>
---
 .../spark/sql/errors/QueryCompilationErrors.scala  |  4 ++
 .../apache/spark/sql/classic/DataFrameWriter.scala | 25 ++++---
 .../spark/sql/connector/DataSourceV2Suite.scala    | 84 +++++++++++++++++++++-
 3 files changed, 102 insertions(+), 11 deletions(-)

diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryCompilationErrors.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryCompilationErrors.scala
index e98c7966b894..b618399388e6 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryCompilationErrors.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryCompilationErrors.scala
@@ -1240,6 +1240,10 @@ private[sql] object QueryCompilationErrors extends 
QueryErrorsBase with Compilat
     unsupportedTableOperationError(table.name(), "batch scan")
   }
 
+  def unsupportedBatchWriteError(table: Table): Throwable = {
+    unsupportedTableOperationError(table.name(), "batch write")
+  }
+
   def unsupportedStreamingScanError(table: Table): Throwable = {
     unsupportedTableOperationError(table.name(), "either micro-batch or 
continuous scan")
   }
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/classic/DataFrameWriter.scala 
b/sql/core/src/main/scala/org/apache/spark/sql/classic/DataFrameWriter.scala
index a9f16ffa87be..96714a419511 100644
--- a/sql/core/src/main/scala/org/apache/spark/sql/classic/DataFrameWriter.scala
+++ b/sql/core/src/main/scala/org/apache/spark/sql/classic/DataFrameWriter.scala
@@ -41,6 +41,7 @@ import 
org.apache.spark.sql.execution.datasources.{DataSource, DataSourceUtils}
 import org.apache.spark.sql.execution.datasources.v2._
 import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.internal.SQLConf.PartitionOverwriteMode
+import org.apache.spark.sql.sources.CreatableRelationProvider
 import org.apache.spark.sql.types.StructType
 import org.apache.spark.sql.util.CaseInsensitiveStringMap
 import org.apache.spark.util.ArrayImplicits._
@@ -181,11 +182,15 @@ final class DataFrameWriter[T] private[sql](ds: 
Dataset[T]) extends sql.DataFram
               val t = getTable
               if (t.supports(BATCH_WRITE)) {
                 (t, None, None)
-              } else {
-                // Streaming also uses the data source V2 API. So it may be 
that the data source
-                // implements v2, but has no v2 implementation for batch 
writes. In that case, we
-                // fall back to saving as though it's a V1 source.
+              } else if (t.supports(V1_BATCH_WRITE) ||
+                  provider.isInstanceOf[CreatableRelationProvider]) {
+                // Fall back to V1 write path. V1_BATCH_WRITE is the explicit 
capability
+                // for DSv2 tables that opt into the V1 write path. We also 
check
+                // CreatableRelationProvider for backward compatibility with 
sources that
+                // predate the V1_BATCH_WRITE capability (SPARK-28334).
                 return saveToV1SourceCommand(path)
+              } else {
+                throw QueryCompilationErrors.unsupportedBatchWriteError(t)
               }
           }
 
@@ -229,15 +234,17 @@ final class DataFrameWriter[T] private[sql](ds: 
Dataset[T]) extends sql.DataFram
                 finalOptions,
                 ignoreIfExists = createMode == SaveMode.Ignore)
             case _: TableProvider =>
-              if (getTable.supports(BATCH_WRITE)) {
+              val t = getTable
+              if (t.supports(BATCH_WRITE)) {
                 throw 
QueryCompilationErrors.writeWithSaveModeUnsupportedBySourceError(
                   source, createMode.name())
-              } else {
-                // Streaming also uses the data source V2 API. So it may be 
that the data source
-                // implements v2, but has no v2 implementation for batch 
writes. In that case, we
-                // fallback to saving as though it's a V1 source.
+              } else if (t.supports(V1_BATCH_WRITE) ||
+                  provider.isInstanceOf[CreatableRelationProvider]) {
+                // Fall back to V1 write path (see comment in Append/Overwrite 
branch above).
                 assertSchemaEvolutionNotEnabledForV1Write()
                 saveToV1SourceCommand(path)
+              } else {
+                throw QueryCompilationErrors.unsupportedBatchWriteError(t)
               }
           }
       }
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2Suite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2Suite.scala
index 1ae10fbf9679..08afcf79f3e7 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2Suite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2Suite.scala
@@ -26,7 +26,7 @@ import scala.jdk.CollectionConverters._
 
 import test.org.apache.spark.sql.connector._
 
-import org.apache.spark.SparkUnsupportedOperationException
+import org.apache.spark.{SparkException, SparkUnsupportedOperationException}
 import org.apache.spark.sql.{AnalysisException, DataFrame, Row, SQLContext}
 import org.apache.spark.sql.catalyst.InternalRow
 import org.apache.spark.sql.catalyst.expressions.{
@@ -53,7 +53,7 @@ import 
org.apache.spark.sql.execution.vectorized.OnHeapColumnVector
 import org.apache.spark.sql.expressions.Window
 import org.apache.spark.sql.functions._
 import org.apache.spark.sql.internal.SQLConf
-import org.apache.spark.sql.internal.connector.SupportsPushDownCatalystFilters
+import org.apache.spark.sql.internal.connector.{SimpleTableProvider, 
SupportsPushDownCatalystFilters}
 import org.apache.spark.sql.sources.{BaseRelation, Filter, GreaterThan, 
TableScan}
 import org.apache.spark.sql.test.SharedSparkSession
 import org.apache.spark.sql.types.{IntegerType, StructField, StructType}
@@ -1687,6 +1687,48 @@ class DataSourceV2Suite extends SharedSparkSession with 
AdaptiveSparkPlanHelper
     }
   }
 
+  test("SPARK-57225: DSv2 source without batch write capability throws clear 
error") {
+    val cls = classOf[ReadOnlyV2DataSource].getName
+    val df = spark.range(1).toDF("i")
+    checkError(
+      exception = intercept[AnalysisException] {
+        df.write.format(cls).mode("append").save()
+      },
+      condition = "UNSUPPORTED_FEATURE.TABLE_OPERATION",
+      parameters = Map(
+        "tableName" -> "`read_only_v2_test`",
+        "operation" -> "batch write"
+      )
+    )
+
+    checkError(
+      exception = intercept[AnalysisException] {
+        df.write.format(cls).save()
+      },
+      condition = "UNSUPPORTED_FEATURE.TABLE_OPERATION",
+      parameters = Map(
+        "tableName" -> "`read_only_v2_test`",
+        "operation" -> "batch write"
+      )
+    )
+  }
+
+  test("SPARK-57225: DSv2 source with V1_BATCH_WRITE still falls back to V1 
path") {
+    val cls = classOf[V1BatchWriteV2DataSource].getName
+    val df = spark.range(1).toDF("i")
+    // V1_BATCH_WRITE sources should fall through to saveToV1SourceCommand, 
which calls
+    // DataSource.planForWriting. Since our test source doesn't implement
+    // CreatableRelationProvider or FileFormat, planForWriting hits the case _ 
branch
+    // and throws INTERNAL_ERROR with "does not allow create table as select".
+    // This proves V1_BATCH_WRITE reached the V1 write path.
+    val ex = intercept[SparkException] {
+      df.write.format(cls).mode("append").save()
+    }
+    assert(ex.getCondition == "INTERNAL_ERROR",
+      "V1_BATCH_WRITE source should reach DataSource.planForWriting (V1 path)")
+    assert(ex.getMessage.contains("does not allow create table as select"))
+  }
+
 }
 
 case class RangeInputPartition(start: Int, end: Int) extends InputPartition
@@ -2545,3 +2587,41 @@ class InvalidDataSource extends TestingV2Source {
 
   override def getTable(options: CaseInsensitiveStringMap): Table = null
 }
+
+/**
+ * A read-only DSv2 data source that only declares BATCH_READ capability.
+ * Used to test that write attempts produce a clear error instead of falling 
through to V1.
+ */
+class ReadOnlyV2DataSource extends SimpleTableProvider {
+  override def getTable(options: CaseInsensitiveStringMap): Table = {
+    new ReadOnlyV2Table
+  }
+}
+
+class ReadOnlyV2Table extends Table with SupportsRead {
+  override def name(): String = "read_only_v2_test"
+  override def schema(): StructType = new StructType().add("i", "long")
+  override def capabilities(): java.util.Set[TableCapability] =
+    java.util.EnumSet.of(TableCapability.BATCH_READ)
+  override def newScanBuilder(options: CaseInsensitiveStringMap): ScanBuilder =
+    throw new UnsupportedOperationException("scan not needed for write tests")
+}
+
+/**
+ * A DSv2 data source that declares V1_BATCH_WRITE, simulating the JDBC 
pattern.
+ * Used to verify that V1 fallback is preserved for sources that opt into it.
+ */
+class V1BatchWriteV2DataSource extends SimpleTableProvider {
+  override def getTable(options: CaseInsensitiveStringMap): Table = {
+    new V1BatchWriteV2Table
+  }
+}
+
+class V1BatchWriteV2Table extends Table with SupportsRead {
+  override def name(): String = "v1_batch_write_test"
+  override def schema(): StructType = new StructType().add("i", "long")
+  override def capabilities(): java.util.Set[TableCapability] =
+    java.util.EnumSet.of(TableCapability.BATCH_READ, 
TableCapability.V1_BATCH_WRITE)
+  override def newScanBuilder(options: CaseInsensitiveStringMap): ScanBuilder =
+    throw new UnsupportedOperationException("scan not needed for write tests")
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to