JingsongLi commented on code in PR #9667:
URL: https://github.com/apache/paimon/pull/9667#discussion_r4051926683


##########
paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/sources/PaimonSink.scala:
##########
@@ -19,23 +19,95 @@
 package org.apache.paimon.spark.sources
 
 import org.apache.paimon.options.Options
-import org.apache.paimon.spark.{InsertInto, Overwrite}
+import org.apache.paimon.spark.{InsertInto, Overwrite, SparkConnectorOptions}
 import org.apache.paimon.spark.commands.{SchemaEvolutionHelper, 
WriteIntoPaimonTable}
 import org.apache.paimon.table.FileStoreTable
 
+import org.apache.spark.internal.Logging
 import org.apache.spark.sql.{DataFrame, PaimonUtils, SQLContext}
 import org.apache.spark.sql.execution.streaming.Sink
 import org.apache.spark.sql.sources.AlwaysTrue
 import org.apache.spark.sql.streaming.OutputMode
 
+import java.nio.charset.StandardCharsets.UTF_8
+import java.util.UUID
+
+import scala.collection.JavaConverters._
+
 class PaimonSink(
     sqlContext: SQLContext,
     override val originTable: FileStoreTable,
     partitionColumns: Seq[String],
     outputMode: OutputMode,
     options: Options)
   extends Sink
-  with SchemaEvolutionHelper {
+  with SchemaEvolutionHelper
+  with Logging {
+
+  /**
+   * Structured Streaming replays a micro-batch with its original batch id 
when a query is restarted
+   * after failing between this sink returning from [[addBatch]] and Spark 
recording the batch as
+   * completed. Committing every batch under a commit user that is stable 
across restarts lets
+   * Paimon skip such a replay instead of committing its data twice.
+   *
+   * What the commit user has to identify is one incarnation of a checkpoint, 
not the place it is
+   * stored. Paimon skips a batch whose id a previous run committed under the 
same user, so reusing
+   * a user across two different queries drops the data of the second one, 
while changing it within
+   * one query brings back the duplicate. The query id Spark persists in the 
checkpoint metadata is
+   * exactly that identity: it is new when a checkpoint is recreated, 
unchanged when a query resumes
+   * from one, and independent of how the location is spelled.
+   *
+   * Resolved lazily: neither the query id nor the checkpoint location is 
available on the thread
+   * that constructs the sink.
+   */
+  private lazy val commitUser: String = {
+    configuredCommitUser.getOrElse {
+      queryId
+        .map(derivedCommitUser("query", _))
+        // Only reachable outside a stream execution, e.g. a direct addBatch 
call. A location
+        // cannot tell a recreated checkpoint from a resumed one, so it is a 
last resort.
+        .orElse(checkpointLocation.map(derivedCommitUser("checkpoint", _)))
+        .getOrElse {
+          logWarning(
+            "This streaming write has neither a query id nor a checkpoint 
location to derive a " +
+              "stable commit user from, so a replayed micro-batch cannot be 
recognised and may " +
+              s"be committed twice. Set 
'${SparkConnectorOptions.STREAM_WRITE_COMMIT_USER.key}' " +
+              "to make the write idempotent.")
+          UUID.randomUUID().toString
+        }
+    }
+  }
+
+  // Like the read side, which takes its 'read.stream.*' options from the 
table, so that a
+  // 'spark.paimon.<key>' session conf works the same as an option of the 
writer.
+  private def configuredCommitUser: Option[String] = {
+    val fromWriter = 
options.get(SparkConnectorOptions.STREAM_WRITE_COMMIT_USER)
+    val fromTable =
+      
Options.fromMap(originTable.options()).get(SparkConnectorOptions.STREAM_WRITE_COMMIT_USER)

Review Comment:
   [P1] Don't let a table property pin the streaming commit user
   
   `configuredCommitUser` also reads `write.stream.commit-user` from 
`originTable.options()`, so the identity can be set in `TBLPROPERTIES`, the 
channel the connector documents for persistent write options 
(`docs/docs/spark/configuration.mdx`). But this option is not a preference, it 
is a writer identity, and a table-wide identity is not per query: 
`filterCommitted` drops every committable whose identifier is not greater than 
the latest one published under the same user, and it drops it silently.
   
   Trigger, traced through the three paths involved (code-level trace, not a 
runtime run): table `T` with `TBLPROPERTIES ('write.stream.commit-user' = 
'job')`. Streaming query A commits up to identifier 100. A second streaming 
query B on the same table starts at batch 0, so every committable with 
identifier 1..100 is filtered out by `FileStoreCommitImpl.filterCommitted`, and 
since both queries advance one identifier per batch while A keeps committing, B 
never catches up and none of its data is ever written. Nothing is logged, no 
exception is raised, `PaimonSparkWriter.commit` returns normally, Spark marks 
the batch completed and never retries it, and the files B wrote stay 
uncommitted until orphan cleaning. From the outside this is indistinguishable 
from a genuine replay, although B's batch holds entirely new data: the 
identifier comparison is content-blind, so skipping is only correct when the 
new run re-reads exactly what the previous run committed.
   
   The four-spark guide this PR adds advertises only two channels, the writer 
option and the `spark.paimon.write.stream.commit-user` session conf, and both 
are per query; the selected option's description states no scope. The read-side 
analogy in the comment on this method (`read.stream.*` taken from the table) 
does not carry over to a writer identity, whose whole point is to be unique per 
streaming writer, and the existing table-level knob for commit identity, 
`commit.user-prefix`, deliberately keeps a runtime-unique suffix so that a 
table property cannot alias two writers.
   
   Fix: take the option from the per-query sources only, i.e. drop the 
`originTable.options()` lookup; if the table scope is really intended, then the 
option description and the guide should require the user to be unique to one 
streaming writer, and a committable dropped because its identifier was already 
published should at least be logged so the loss is diagnosable.
   



-- 
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]

Reply via email to