spark-test commented on code in PR #59894:
URL: https://github.com/apache/spark/pull/59894#discussion_r4407105269


##########
sql/core/src/main/scala/org/apache/spark/sql/classic/DataStreamWriter.scala:
##########
@@ -348,12 +358,15 @@ final class DataStreamWriter[T] private[sql](ds: 
Dataset[T]) extends streaming.D
           DataSourceUtils.encodePartitioningColumns(cols))
       case None => optionsWithoutClusteringKey
     }
+    // A table is written with its partitioning, which `toTable` checks 
against `partitionBy`.
+    val partitionColumns = 
catalogTable.map(_.partitionColumnNames.map(normalize(_, "Partition")))

Review Comment:
   **Blocking (P1):** This picks the catalog table's partition columns on every 
start, including restarts of a query whose sink log at the table location 
already holds files in another layout. Take a table `PARTITIONED BY (p)` that a 
query fed through `toTable` without `partitionBy` before this change: its sink 
log lists root-level files. After the restart, `FileStreamSink` appends 
`p=<v>/` files to the same `_spark_metadata` log without comparing layouts. 
Table reads (`spark.table(...)`) and path reads resolve to 
`MetadataLogFileIndex`, because `_spark_metadata` exists. The root directory 
parses to no partition values, and every `p=<v>/` directory stops at the same 
base path. Partition inference therefore raises no conflict error and returns 
the spec `(p)`, and `listFiles` serves only the `p=` directories. Every row 
committed before the upgrade silently disappears from reads while its files 
stay in the log. At the merge base, the same log held only root files, the 
inferred spec was 
 empty, and every file was listed.
   
   This applies to classic, Spark Connect, and PySpark `toTable` calls. It also 
applies to Spark Declarative Pipelines streaming tables declared `PARTITIONED 
BY` on V1 file sources, since `StreamingTableWrite` never calls `partitionBy`, 
so their next incremental update hides all earlier rows. Fresh tables, tables 
created by `toTable`, and path-based `start(path)` writes are unaffected. The 
file sink is documented as exactly-once, and the opt-in `retention` TTL is the 
only documented way committed files leave reader queries. The PR description 
notes that the old root files are no longer returned, but users get no signal, 
and recovery means rewriting the data or moving to a new location. The new 
tests always start from a fresh table and checkpoint, so they do not reach this 
path.
   
   **Recommended change:** On the toTable V1 path, before creating a file 
stream sink at the table location, read the layout already committed in that 
location's sink log, and fail the query start with a clear error when that 
layout differs from the table partitioning the sink is about to use, instead of 
silently appending files in a new layout. Add streaming table API tests that 
restart a query over a sink log written in another layout.
   
   **Why this works:** When a catalog table is present and its location already 
holds a non-empty file stream sink log, the writer derives the partition 
columns that the committed entries encode, using the same path-based inference 
that table reads apply to that log. It then compares them, under the session's 
case sensitivity, with the partition columns selected for the new sink. On a 
mismatch, the query start fails before the sink is created, with an error that 
names the table, the recorded and the expected partitioning, and the recovery: 
a new checkpoint and table location, or rewriting the existing data into the 
table's layout. Because the check runs before the first new batch, no file is 
ever committed in a conflicting layout, and readers keep seeing every committed 
row.
   
   **Scope:** Validate the existing sink-log layout at query start on the 
toTable V1 path, with a new error and restart tests.
   
   **Compatibility:** For fresh or already-matching sink locations, the 
declared contract still holds: without partitionBy the sink writes with the 
table's partitioning, and a given partitionBy must match that partitioning.
   
   **Risks:** Reading the existing sink log at query start adds driver work 
proportional to the committed entries. This is comparable to the full log read 
that the per-batch table refresh already performs for V1 table sinks. Existing 
pipelines over such tables, including incremental SDP updates of partitioned 
streaming tables, stop at start until the user moves to a new location, 
rewrites the data, or runs an SDP full refresh. The error message and migration 
notes must say so.
   
   **Constraints:** Apply the comparison only when the write targets a catalog 
table through toTable, so path-based start(path) behavior is unchanged. Compare 
column names under the session's case sensitivity, so that case-only 
differences, which the read path already maps, do not cause a false failure. 
Skip the comparison when the sink log has no committed files, so new tables and 
fresh locations keep the declared behavior.
   
   **Exclusions:**
   - Repairing or migrating sink logs that already mix layouts is outside this 
repair.
   - Path-based start(path) writers that change partitionBy between runs keep 
their existing behavior.
   - Whether previously valid queries may be stopped at all on restart, and 
whether a legacy flag or a migration-guide entry is needed, is left to the 
author's decision on the restart policy for upgraded queries.
   
   **Success:** Restarting a query on the new code over a partitioned V1 file 
table whose sink log holds root-level files fails before writing, with an error 
that names the recorded and the expected partitioning. No new file is 
committed, and table reads still return every previously committed row. A 
restart over a sink log written with a partition column the table does not 
declare fails before writing, instead of leaving the table unreadable. Fresh 
tables, tables created by toTable, and existing tables whose sink log already 
uses the table's partitioning keep starting and writing exactly as the PR 
declares. Path-based start(path) writes behave exactly as before.
   
   **Verification:**
   - **Regression:** Commit at least one batch into a partitioned V1 file table 
through a sink log in the unpartitioned layout, as the previous code wrote it, 
then restart from the same checkpoint. Show that the start fails with the new 
error, that no file is committed under a partition directory, and that table 
reads still return every previously committed row.
   - **Regression:** Commit batches into a partitioned V1 file table through a 
sink log partitioned by a column the table does not declare, then restart 
without that partitioning. Show that the start fails before writing and that 
the table stays readable.
   - **Behavior:** Starting with no existing sink log, on a table created by 
toTable, and over a sink log already partitioned like the table still writes 
partition directories that match the table's partitioning.
   - **Compatibility:** Path-based start(path) file sink writes keep their 
previous partitioning behavior, including when the sink directory already holds 
a log.



##########
sql/core/src/test/scala/org/apache/spark/sql/streaming/test/DataStreamTableAPISuite.scala:
##########
@@ -353,6 +354,98 @@ class DataStreamTableAPISuite extends StreamTest with 
BeforeAndAfter {
     }
   }
 
+  test("SPARK-60665: write to a partitioned table with the partitioning of the 
table") {
+    def writeAndCheck(tableName: String, partitionColumns: 
Option[Seq[String]]): Unit = {
+      withTempDir { checkpointDir =>
+        val input = MemoryStream[Int]
+        val writer = input.toDF().selectExpr("value AS i", "value % 2 AS p")
+          .writeStream
+          .format("parquet")
+          .option("checkpointLocation", checkpointDir.getCanonicalPath)
+        val query = partitionColumns.map(cols => writer.partitionBy(cols: 
_*)).getOrElse(writer)
+          .toTable(tableName)
+        try {
+          input.addData(1, 2)
+          query.processAllAvailable()
+        } finally {
+          query.stop()
+        }
+      }
+      checkAnswer(spark.table(tableName), Seq(Row(1, 1), Row(2, 0)))
+      assert(spark.table(tableName).inputFiles.map(new 
Path(_).getParent.getName).toSet ===
+        Set("p=0", "p=1"))
+    }
+
+    val tableName = "partition_test"
+    Seq(None, Some(Seq("P"))).foreach { partitionColumns =>
+      withTable(tableName) {
+        sql(s"CREATE TABLE $tableName (i INT, p INT) USING parquet PARTITIONED 
BY (p)")
+        writeAndCheck(tableName, partitionColumns)
+      }
+    }
+    withTable(tableName) {
+      writeAndCheck(tableName, Some(Seq("p")))
+      
assert(spark.sessionState.catalog.getTableMetadata(TableIdentifier(tableName))
+        .partitionColumnNames === Seq("p"))
+    }
+  }
+
+  test("SPARK-60665: write to an existing table with mismatched partitioning") 
{
+    val tableName = "partition_test"
+    val df = MemoryStream[Int].toDF().selectExpr("value AS i", "value % 2 AS 
p")
+    def startQuery(data: DataFrame, partitionColumns: Option[Seq[String]]): 
Unit = {
+      withTempDir { checkpointDir =>
+        val writer = data.writeStream
+          .format("parquet")
+          .option("checkpointLocation", checkpointDir.getCanonicalPath)
+        partitionColumns.map(cols => writer.partitionBy(cols: 
_*)).getOrElse(writer)
+          .toTable(tableName)
+      }
+    }
+
+    Seq(

Review Comment:
   **Nit (P3):** Every case in this list, and in the test above, uses a table 
with at most one partition column. The new guard compares `partitionBy` with 
the table's partition columns as an ordered `Seq`, the same rule as a batch 
`saveAsTable` append. No test would fail if that comparison regressed to set 
equality, or started rejecting an in-order multi-column `partitionBy`. 
Multi-column partitioning such as `(year, month, day)` is common, so a 
two-column case covering both the accepted table order and a rejected 
reordering would give the rule a failure signal.
   
   **Verification:**
   - **Behavior:** Against a table partitioned by (a, b), partitionBy('a', 'b') 
starts and writes rows under a=<v>/b=<w>/ directories, while partitionBy('b', 
'a') fails at start with _LEGACY_ERROR_TEMP_1163 that reports both the 
specified and the existing column order.



##########
sql/api/src/main/scala/org/apache/spark/sql/streaming/DataStreamWriter.scala:
##########
@@ -215,8 +215,9 @@ abstract class DataStreamWriter[T] extends 
WriteConfigMethods[DataStreamWriter[T
    * [[org.apache.spark.sql.streaming.StreamingQuery]] object can be used to 
interact with the
    * stream.
    *
-   * For v1 table, partitioning columns provided by `partitionBy` will be 
respected no matter the
-   * table exists or not. A new table will be created if the table not exists.
+   * For v1 table, a new table will be created with the partitioning columns 
provided by
+   * `partitionBy` if the table does not exist. If the table exists, the 
partitioning of the table
+   * will be respected, and `partitionBy` should match it if provided.

Review Comment:
   **Nit (P3):** For an existing v1 table, this sentence is the only 
user-facing statement of the new contract, but it reads as a recommendation. 
The implementation enforces the contract before the query starts. A 
`partitionBy` whose columns differ from the table's partition columns throws 
`AnalysisException`: `_LEGACY_ERROR_TEMP_1163`, or 
`COLUMN_NOT_DEFINED_IN_TABLE` for an unknown column. That includes the same 
columns in another order and a non-empty `partitionBy` on an unpartitioned 
table. Without `partitionBy`, a query that does not produce every partition 
column of the table also fails at start, with `_LEGACY_ERROR_TEMP_1196`. The 
next paragraph says `partitionBy` "will be ignored" for an existing v2 table, 
so readers can easily take "should match" as tolerated. Users of existing 
queries then learn about the change only from the exception on restart. The 
PySpark docstring in `python/pyspark/sql/streaming/readwriter.py` repeats this 
text, so both copies need the same change.
   
   **Verification:**
   - **Inspection:** The Scaladoc and the PySpark docstring carry the same v1 
wording, naming the start-time failure, the ordered match, and the 
partition-column requirement, and that wording stays consistent with the v2 
paragraph.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to