This is an automated email from the ASF dual-hosted git repository.
dongjoon-hyun pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/branch-4.x by this push:
new b84bc05c6c60 [SPARK-60563][SS] Check `INTERVAL` columns and empty
schemas when starting a file stream sink query
b84bc05c6c60 is described below
commit b84bc05c6c60e8a153584ce9dd5cd8d58f18b0ac
Author: Dongjoon Hyun <[email protected]>
AuthorDate: Mon Oct 26 16:37:54 2026 -0700
[SPARK-60563][SS] Check `INTERVAL` columns and empty schemas when starting
a file stream sink query
### What changes were proposed in this pull request?
This PR aims to check `INTERVAL` columns and empty schemas when a streaming
query with a file stream sink starts, like batch file writes.
`DataSource.createSink` now checks the schema of the streaming query before
creating `FileStreamSink`, in the same way as the corresponding batch writes.
| Streaming write | Batch write | `INTERVAL` columns | Empty schemas |
| --- | --- | --- | --- |
| `DataStreamWriter.start(path)` | `DataFrameWriter.save(path)`
(`DataSource.planForWriting`) | Rejected | Rejected |
| `DataStreamWriter.toTable(tableName)` | `DataFrameWriter.saveAsTable` and
`CREATE TABLE AS SELECT` (`DataSource.writeAndRead`) | Rejected | Not checked |
To tell them apart, `DataStreamWriter` passes the target `CatalogTable` of
`toTable` to `DataSource`. Like batch writes, ANSI interval types are allowed,
and `spark.sql.legacy.allowEmptySchemaWrite` disables the empty schema check.
Spark Connect is also covered because its server uses `DataStreamWriter`.
### Why are the changes needed?
Batch file writes do the above checks during analysis. For example,
SPARK-23372 added the empty schema check in Apache Spark 2.4.0
([v2.4.0](https://github.com/apache/spark/releases/tag/v2.4.0), 2018-10-29).
However, the file stream sink has never done these checks when a query starts.
As a result, streaming queries fail late, or write data which cannot be read
back correctly.
- A query with `INTERVAL` columns starts and creates `_spark_metadata` in
the output path, and then fails at the first micro-batch with data.
```
org.apache.spark.sql.streaming.StreamingQueryException: [STREAM_FAILED]
Query [id = ..., runId = ...] terminated with exception:
[UNSUPPORTED_DATA_TYPE_FOR_DATASOURCE] The Parquet datasource doesn't support
the column `d` of the type "INTERVAL". SQLSTATE: 0A000 SQLSTATE: XXKST
```
- Empty struct columns fail at tasks with Parquet (`InvalidSchemaException:
Cannot write a schema with an empty group`) and at the first micro-batch with
CSV. JSON writes them as `{}`, and they disappear when the output is read back.
ORC writes them although batch writes reject them.
```scala
scala> spark.readStream.format("rate").load().selectExpr("value AS i",
"struct() AS s").writeStream.format("json").option("checkpointLocation",
"/tmp/checkpoint").start("/tmp/output")
scala> spark.read.json("/tmp/output").printSchema()
root
|-- i: long (nullable = true)
```
- Empty schemas without any column are written as `{}` lines with JSON and
as empty files with CSV, and ORC writes files which cannot be read back
(`UNABLE_TO_INFER_SCHEMA`).
### Does this PR introduce _any_ user-facing change?
Yes. A streaming query with a file stream sink fails at start with the same
errors as the corresponding batch writes.
```
org.apache.spark.sql.AnalysisException:
[UNSUPPORTED_DATA_TYPE_FOR_DATASOURCE] The Parquet datasource doesn't support
the column `d` of the type "INTERVAL". SQLSTATE: 0A000
```
```
org.apache.spark.sql.AnalysisException:
[EMPTY_SCHEMA_NOT_SUPPORTED_FOR_DATASOURCE] The JSON datasource does not
support writing empty or nested empty schemas. Please make sure the data schema
has at least one or more column(s). SQLSTATE: 0A000
```
Note that existing streaming queries which write empty or nested empty
schemas to a path, e.g., with JSON or ORC, fail to restart.
`spark.sql.legacy.allowEmptySchemaWrite` restores the previous behavior.
### How was this patch tested?
Pass the CIs with the newly added test case in `FileStreamSinkSuite`, which
fails without this fix. It checks the following with `start(path)`.
- `INTERVAL` columns and empty struct columns are rejected at start with
Parquet, ORC, JSON, and CSV.
- An empty struct column is accepted with JSON when
`spark.sql.legacy.allowEmptySchemaWrite` is enabled.
It also checks that `toTable` does not check empty struct columns of an
existing table, like `saveAsTable`.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Opus 5.5
Closes #59797 from dongjoon-hyun/SPARK-60563.
Authored-by: Dongjoon Hyun <[email protected]>
Signed-off-by: Dongjoon Hyun <[email protected]>
(cherry picked from commit 97bf1e15020f8861d63fa2dcc21946f95eec55ff)
Signed-off-by: Dongjoon Hyun <[email protected]>
---
.../spark/sql/classic/DataStreamWriter.scala | 9 ++--
.../sql/execution/datasources/DataSource.scala | 5 ++
.../spark/sql/streaming/FileStreamSinkSuite.scala | 62 ++++++++++++++++++++++
3 files changed, 73 insertions(+), 3 deletions(-)
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/classic/DataStreamWriter.scala
b/sql/core/src/main/scala/org/apache/spark/sql/classic/DataStreamWriter.scala
index 62849aab56e1..56f010b7966b 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/classic/DataStreamWriter.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/classic/DataStreamWriter.scala
@@ -292,10 +292,10 @@ final class DataStreamWriter[T] private[sql](ds:
Dataset[T]) extends streaming.D
table match {
case table: SupportsWrite if table.supports(STREAMING_WRITE) =>
table
- case _ => createV1Sink(optionsWithPath)
+ case _ => createV1Sink(optionsWithPath, catalogTable)
}
} else {
- createV1Sink(optionsWithPath)
+ createV1Sink(optionsWithPath, catalogTable)
}
startQuery(sink, optionsWithPath, catalogTable = catalogTable)
@@ -333,7 +333,9 @@ final class DataStreamWriter[T] private[sql](ds:
Dataset[T]) extends streaming.D
catalogTable = catalogTable)
}
- private def createV1Sink(optionsWithPath: CaseInsensitiveMap[String]): Sink
= {
+ private def createV1Sink(
+ optionsWithPath: CaseInsensitiveMap[String],
+ catalogTable: Option[CatalogTable]): Sink = {
// Do not allow the user to specify clustering columns in the options.
Ignoring this option is
// consistent with the behavior of DataFrameWriter on non Path-based
tables and with the
// behavior of DataStreamWriter on partitioning columns specified in
options.
@@ -350,6 +352,7 @@ final class DataStreamWriter[T] private[sql](ds:
Dataset[T]) extends streaming.D
this.ds.sparkSession,
className = source,
options = optionsWithClusteringColumns,
+ catalogTable = catalogTable,
partitionColumns = normalizedParCols.getOrElse(Nil))
ds.createSink(outputMode, this.ds.schema)
}
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/DataSource.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/DataSource.scala
index 93711d583907..e95a17898ca8 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/DataSource.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/DataSource.scala
@@ -343,6 +343,11 @@ case class DataSource(
s.createSink(sparkSession.sqlContext, caseInsensitiveOptions,
partitionColumns, outputMode)
case fileFormat: FileFormat =>
+ disallowWritingIntervals(schema, fileFormat.toString,
forbidAnsiIntervals = false)
+ // Like batch writes, check empty schemas only when writing to a path,
not to a table.
+ if (catalogTable.isEmpty) {
+ DataSource.validateSchema(fileFormat.toString, schema, conf)
+ }
val path = makeQualified(new
Path(caseInsensitiveOptions.getOrElse("path", {
throw QueryExecutionErrors.dataPathNotSpecifiedError()
}))).toString
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/streaming/FileStreamSinkSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/streaming/FileStreamSinkSuite.scala
index 8bc5037f86c0..c244d9a28030 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/streaming/FileStreamSinkSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/streaming/FileStreamSinkSuite.scala
@@ -293,6 +293,68 @@ abstract class FileStreamSinkSuite extends StreamTest {
}
}
+ test("SPARK-60563: Check INTERVAL and empty struct columns when starting a
query") {
+ val df = MemoryStream[Int].toDF()
+ val intervalDf = df.selectExpr("value AS i", "make_interval(0, 0, 0,
value) AS d")
+ val emptyStructDf = df.selectExpr("value AS i", "struct() AS s")
+
+ val ansiIntervalDf = df.selectExpr(
+ "value AS i", "make_ym_interval(0, value) AS ym", "make_dt_interval(0,
0, 0, value) AS dt")
+
+ def startQuery(data: DataFrame, format: String): StreamingQuery = {
+ data.writeStream
+ .option("checkpointLocation",
+ Utils.createTempDir(namePrefix =
"stream.checkpoint").getCanonicalPath)
+ .format(format)
+ .start(Utils.createTempDir(namePrefix =
"stream.output").getCanonicalPath)
+ }
+
+ def startTableQuery(data: DataFrame, table: String): StreamingQuery = {
+ data.writeStream
+ .option("checkpointLocation",
+ Utils.createTempDir(namePrefix =
"stream.checkpoint").getCanonicalPath)
+ .format("json")
+ .toTable(table)
+ }
+
+ Seq("parquet" -> "Parquet", "orc" -> "ORC", "json" -> "JSON", "csv" ->
"CSV").foreach {
+ case (format, formatName) =>
+ checkError(
+ exception = intercept[AnalysisException] {
+ startQuery(intervalDf, format)
+ },
+ condition = "UNSUPPORTED_DATA_TYPE_FOR_DATASOURCE",
+ parameters = Map(
+ "columnName" -> "`d`", "columnType" -> "\"INTERVAL\"", "format" ->
formatName))
+ checkError(
+ exception = intercept[AnalysisException] {
+ startQuery(emptyStructDf, format)
+ },
+ condition = "EMPTY_SCHEMA_NOT_SUPPORTED_FOR_DATASOURCE",
+ parameters = Map("format" -> formatName))
+ // Like batch writes, ANSI interval types are allowed.
+ startQuery(ansiIntervalDf, format).stop()
+ }
+
+ withSQLConf(SQLConf.ALLOW_EMPTY_SCHEMAS_FOR_WRITES.key -> "true") {
+ startQuery(emptyStructDf, "json").stop()
+ }
+
+ withTable("t") {
+ sql("CREATE TABLE t (i INT, s STRUCT<>) USING json")
+ // Like `DataFrameWriter.saveAsTable`, writing to a table does not check
empty structs.
+ startTableQuery(emptyStructDf, "t").stop()
+ // However, it checks INTERVAL columns.
+ checkError(
+ exception = intercept[AnalysisException] {
+ startTableQuery(intervalDf, "t")
+ },
+ condition = "UNSUPPORTED_DATA_TYPE_FOR_DATASOURCE",
+ parameters = Map(
+ "columnName" -> "`d`", "columnType" -> "\"INTERVAL\"", "format" ->
"JSON"))
+ }
+ }
+
test("parquet") {
testFormat(None) // should not throw error as default format parquet when
not specified
testFormat(Some("parquet"))
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]