HyukjinKwon commented on code in PR #59247:
URL: https://github.com/apache/spark/pull/59247#discussion_r4277360367


##########
sql/core/src/test/scala/org/apache/spark/sql/streaming/StreamingQueryStatusAndProgressSuite.scala:
##########
@@ -43,6 +46,111 @@ import org.apache.spark.sql.types.StructType
 import org.apache.spark.util.ArrayImplicits._
 
 class StreamingQueryStatusAndProgressSuite extends StreamTest with Eventually 
with Matchers {
+  gridTest("SPARK-59986: trigger metadata")(Seq[(Trigger, String)](
+    Trigger.ProcessingTime(0) -> 
"""{"type":"ProcessingTime","intervalMs":0}""",
+    Trigger.ProcessingTime(3000000000L) ->
+      """{"type":"ProcessingTime","intervalMs":3000000000}""",
+    Trigger.Continuous(2000) -> """{"type":"Continuous","intervalMs":2000}""",
+    Trigger.RealTime(3000) -> """{"type":"RealTime","batchDurationMs":3000}""",
+    Trigger.AvailableNow() -> """{"type":"AvailableNow"}""",
+    Trigger.Once() -> """{"type":"Once"}""")) {
+    case (trigger, expected) =>
+      assert(Triggers.toJson(trigger) === expected)
+      assert(Triggers.fromJson(expected) === trigger)
+  }
+
+  test("SPARK-59986: unknown trigger metadata identifies the unsupported 
trigger") {
+    val trigger = new Trigger {}
+    val exception = intercept[IllegalStateException] {
+      Triggers.toJson(trigger)
+    }
+    assert(exception.getMessage === s"Unknown type of trigger: $trigger")
+  }
+
+  test("SPARK-59986: starting a query with an unknown trigger identifies the 
input") {
+    import testImplicits._
+
+    val input = MemoryStream[Int]
+    val trigger = new Trigger {}
+    val exception = intercept[IllegalStateException] {

Review Comment:
   **Non-blocking (P2):** This intercept no longer sees an exception. Since 
96b71ac, `ProgressReporter` keeps `triggerConfig` instead of eagerly calling 
`Triggers.toJson` in its constructor, so nothing on the `start()` path 
validates the trigger. The only check left is `MicroBatchExecution.getTrigger`, 
reached through the `logicalPlan` lazy val on the query thread after 
`startLatch` has let `start()` return. `stop()` only joins the thread and 
doesn't rethrow. So `start().stop()` completes normally, and the test fails 
with "Expected exception java.lang.IllegalStateException to be thrown, but no 
exception was thrown".
   
   The query still terminates asynchronously with `Unknown type of trigger: 
...`, the same as on master. But this test and the PR description's "fails 
query construction" claim no longer match the code.
   
   To keep the documented behavior, restore a construction-time check. For 
example, call `Triggers.toJson(triggerConfig)` when `ProgressReporter` is 
constructed, which happens synchronously in `StreamExecution`'s constructor 
inside `createQuery`. That makes `start()` throw as described.
   
   **Verification:**
   - **Regression:** Starting a streaming query with a custom Trigger subclass 
fails synchronously with an IllegalStateException whose message identifies the 
trigger.
   - **Behavior:** Micro-batch, real-time, and continuous progress for built-in 
triggers still reports the configured trigger metadata.



##########
sql/api/src/main/scala/org/apache/spark/sql/execution/streaming/Triggers.scala:
##########
@@ -30,7 +31,33 @@ import org.apache.spark.sql.catalyst.util.SparkIntervalUtils
 import org.apache.spark.sql.streaming.Trigger
 import org.apache.spark.unsafe.types.UTF8String
 
-private object Triggers {
+private[spark] object Triggers {
+  private implicit val formats: DefaultFormats = DefaultFormats
+
+  def toJson(trigger: Trigger): String = trigger match {
+    case ProcessingTimeTrigger(intervalMs) =>
+      s"""{"type":"ProcessingTime","intervalMs":$intervalMs}"""
+    case ContinuousTrigger(intervalMs) =>
+      s"""{"type":"Continuous","intervalMs":$intervalMs}"""
+    case RealTimeTrigger(batchDurationMs) =>
+      s"""{"type":"RealTime","batchDurationMs":$batchDurationMs}"""
+    case AvailableNowTrigger => """{"type":"AvailableNow"}"""
+    case OneTimeTrigger => """{"type":"Once"}"""
+    case other => throw new IllegalStateException(s"Unknown type of trigger: 
$other")
+  }
+
+  def fromJson(json: String): Trigger = {
+    val value = parse(json)
+    (value \ "type").extract[String] match {
+      case "ProcessingTime" => ProcessingTimeTrigger((value \ 
"intervalMs").extract[Long])
+      case "Continuous" => ContinuousTrigger((value \ 
"intervalMs").extract[Long])
+      case "RealTime" => RealTimeTrigger((value \ 
"batchDurationMs").extract[Long])
+      case "AvailableNow" => AvailableNowTrigger
+      case "Once" => OneTimeTrigger
+      case other => throw new IllegalStateException(s"Unknown type of trigger: 
$other")

Review Comment:
   **Non-blocking (P2):** This decoder is the one part of progress decoding 
that isn't forward compatible. `fromJson` throws for any `type` outside the 
five built-ins, and the `extract[Long]` calls throw when an interval key is 
missing. Both JVM decoders call it with no fallback: 
`ObjectToTriggerDeserializer`, used by `StreamingQueryProgress.fromJson` and 
`QueryProgressEvent.fromJson`, and 
`StreamingQueryProgressSerializer.deserialize` for the status store.
   
   Suppose a Scala Spark Connect client with this change talks to a later 
server that adds a trigger kind (`RealTime` shows the set does grow) or changes 
a key. `RemoteStreamingQuery.lastProgress` and `recentProgress` then throw. In 
`StreamingQueryListenerBus.queryEventHandler`, the same exception reaches the 
loop-level catch, which removes every client-side listener. An older history 
server replaying a newer event log also stops at that event, because 
`ReplayListenerBus` only skips `ClassNotFoundException` and 
`UnrecognizedPropertyException`.
   
   Everything around this degrades instead: both mappers set 
`FAIL_ON_UNKNOWN_PROPERTIES=false`, offsets go through 
`ObjectToStringDeserializer`, and the listener bus only warns on unknown 
events. The PySpark `fromJson` in this PR already keeps the dict as-is. Classic 
in-process `lastProgress` is unaffected because it never decodes.
   
   Released clients can't be patched, so the tolerance needs to ship with the 
field's first release. I'd make this helper total: return `null`, the 
documented "metadata unavailable" value, for an unknown `type` or a missing or 
malformed interval key. Then use it from both deserializers.
   
   **Verification:**
   - **Regression:** Decoding progress JSON with an unknown trigger type, and 
with a missing interval key, returns a progress record whose trigger is null 
instead of throwing, on both the Jackson and status-store protobuf paths.
   - **Compatibility:** Every built-in trigger type still serializes to the 
same JSON and decodes back to the same Trigger value.



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