cloud-fan commented on code in PR #59665:
URL: https://github.com/apache/spark/pull/59665#discussion_r4336683685


##########
sql/core/src/test/scala/org/apache/spark/sql/SQLQueryTestSuite.scala:
##########
@@ -496,8 +705,24 @@ class SQLQueryTestSuite extends SharedSparkSession
             schema = None,
             output = normalizeTestResults(output.mkString("\n")))
         case _ =>
+          val parsedPlan = tryParsePlan(localSparkSession, sql)

Review Comment:
   **Non-blocking (P2):** `tryParsePlan` now runs here, outside 
`handleExceptions`, for every statement of every execution golden, and it 
catches only `ParseException`. Some parse-time errors are not 
`ParseException`s. For `select interval 10 nanoseconds` (interval.sql:150, 
which nonansi/interval.sql also runs via `--IMPORT interval.sql`), 
`AstBuilder.visitMultiUnitsInterval` rethrows a 
`SparkIllegalArgumentException`. `AbstractParser.parse` converts only 
`SparkThrowable with WithOrigin`, which this exception is not, so it escapes 
`parsePlan`, `tryParsePlan`, and then `runQueries`. Before this change, the 
same exception was raised inside 
`handleExceptions(getNormalizedQueryExecutionResult(...))` and recorded as the 
statement's golden output (results/interval.sql.out:1444-1456). Now the 
interval.sql and nonansi/interval.sql execution cases abort instead of 
comparing against their goldens, and regenerating those goldens aborts too.
   
   Suggested fix: move the pre-parse and the flow dispatch into a single 
`handleExceptions` block, so a failure during the pre-parse is recorded exactly 
as before. Leave `tryParsePlan` itself unchanged, since `runDebugQueries` 
shares it and debug mode intentionally lets these errors propagate.
   
   **Verification:**
   - **Regression:** Run the regular and non-ANSI interval execution golden 
cases and confirm they match their unchanged goldens, including the 
SparkIllegalArgumentException recorded for 'select interval 10 nanoseconds'.
   - **Behavior:** Run the four streaming execution golden cases and confirm 
their recorded outputs are unchanged.



##########
sql/core/src/test/resources/sql-tests/inputs/streaming/available-now-window-aggregation.sql:
##########
@@ -0,0 +1,42 @@
+DROP TABLE IF EXISTS streaming_sql_golden_aggregation_events;
+DROP TABLE IF EXISTS streaming_sql_golden_aggregation_sink;
+
+CREATE TABLE streaming_sql_golden_aggregation_events (
+  event_time TIMESTAMP, category STRING, amount INT) USING parquet;
+INSERT INTO streaming_sql_golden_aggregation_events VALUES
+  (TIMESTAMP '2024-01-01 00:00:01', 'books', 2),
+  (TIMESTAMP '2024-01-01 00:00:05', 'books', 3),
+  (TIMESTAMP '2024-01-01 00:01:02', 'games', 7);
+
+CREATE TABLE streaming_sql_golden_aggregation_sink (
+  window_start TIMESTAMP,
+  window_end TIMESTAMP,
+  category STRING,
+  total BIGINT,
+  event_count BIGINT) USING parquet;
+
+CREATE FLOW streaming_sql_golden_aggregation_flow AS
+INSERT INTO streaming_sql_golden_aggregation_sink BY NAME
+SELECT
+  window(event_time, '1 minute').start AS window_start,
+  window(event_time, '1 minute').end AS window_end,
+  category,
+  sum(amount) AS total,
+  count(*) AS event_count
+FROM STREAM(streaming_sql_golden_aggregation_events)
+  WATERMARK event_time DELAY OF INTERVAL 10 SECONDS
+GROUP BY window(event_time, '1 minute'), category;
+
+REFRESH FLOW streaming_sql_golden_aggregation_flow;

Review Comment:
   **Nit (P3):** Nothing in this file would catch a checkpoint-reuse 
regression. This is the only golden with two `REFRESH FLOW` statements, and it 
reads the sink only after the second one (line 37). If each refresh got a fresh 
checkpoint, the second refresh would reprocess all four events, reach the same 
00:09:50 watermark, and emit the same two windows (books 5/2, games 7/1). If 
the windows were wrongly emitted on the first refresh, the append-only sink 
would end in the same state. So the golden observes neither the declared 
checkpoint reuse nor watermark-gated emission.
   
   Suggested fix: add a `SELECT` from `streaming_sql_golden_aggregation_sink` 
between the two refreshes, expected to be empty. Also add a stateless 
refresh/insert/refresh case like the `copy_source` example in the class doc, 
whose final sink must contain each source row exactly once; that is the shape 
where losing the checkpoint would duplicate rows. Then regenerate the goldens.
   
   **Verification:**
   - **Regression:** Temporarily give each REFRESH a fresh checkpoint and 
confirm that the stateless multi-refresh golden fails with duplicated rows; 
then restore checkpoint reuse and confirm it passes.
   - **Behavior:** Run the streaming golden cases and confirm the intermediate 
empty-sink result and the final windows match the regenerated goldens.



##########
sql/core/src/test/scala/org/apache/spark/sql/SQLQueryTestSuite.scala:
##########
@@ -496,8 +705,24 @@ class SQLQueryTestSuite extends SharedSparkSession
             schema = None,
             output = normalizeTestResults(output.mkString("\n")))
         case _ =>
+          val parsedPlan = tryParsePlan(localSparkSession, sql)
           val (schema, output) =
-            
handleExceptions(getNormalizedQueryExecutionResult(localSparkSession, sql))
+            parseTriggeredFlowStatement(localSparkSession, sql, parsedPlan) 
match {

Review Comment:
   **Nit (P3):** This flow dispatch exists only in the execution branch. 
`listTestCases` also creates an `_analyzer_test` copy of each new streaming 
input, and the unchanged `AnalyzerTest` branch above sends every statement to 
`getNormalizedQueryAnalysisResult`. As a result, all four 
`analyzer-results/streaming/*.sql.out` files record `CREATE FLOW` as 
`ExtendedAnalysisException` `_LEGACY_ERROR_TEMP_3102` ("Queries with streaming 
sources must be executed with writeStream.start()...") and `REFRESH FLOW` as a 
`ParseException` (`INVALID_SQL_SYNTAX.INVALID_REFRESH_RESOURCE_PATH`). That 
contradicts class doc item 7, which describes both statements as harness syntax 
without limiting that to execution goldens.
   
   As a result, the analyzer goldens cover none of the analysis of the flow 
queries: the watermarked window aggregate, the stream-static join, and the 
streaming union. They will also need regeneration whenever unrelated 
streaming-checker or REFRESH-grammar error text changes. And because no golden 
contains an analyzed streaming plan, the new `EventTimeWatermark` UUID 
normalization in `SQLQueryTestHelper` never matches anything.
   
   **Recommended change:** Teach the analyzer-golden path the two harness flow 
statements and regenerate the streaming analyzer golden files. For CREATE FLOW, 
register the flow and record the normalized analyzed plan of its INSERT ... BY 
NAME query. For REFRESH FLOW, record empty output without starting a streaming 
query. Every other statement stays on the existing analysis path, and all of it 
runs inside the existing per-statement error capture.
   
   **Why this works:** Recognizing the harness statements before the generic 
analysis call stops the analyzer copy from analyzing CREATE FLOW as a batch 
command and from parsing REFRESH FLOW as Spark SQL. The copy then records the 
analyzed streaming plans the inputs are written to cover: the windowed 
aggregation with a watermark, the stream-static join, and the streaming union. 
Because that output passes through the existing normalization, the watermark 
plan appears with its UUID masked.
   
   **Scope:** Give the analyzer golden runner analyzer-mode handling of CREATE 
FLOW and REFRESH FLOW, and regenerate the four streaming analyzer goldens.
   
   **Compatibility:** Analyzer golden output for every statement other than 
CREATE FLOW and REFRESH FLOW stays exactly as it is today.
   
   **Risks:** Analyzed streaming plans may print other run-specific identifiers 
besides the EventTimeWatermark UUID, which would make the regenerated goldens 
nondeterministic; two consecutive regenerations must produce identical output.
   
   **Constraints:** Keep the new analyzer-mode dispatch inside the 
per-statement error capture, so that a parse-time failure is recorded instead 
of aborting the analyzer copy.
   
   **Success:** The streaming analyzer goldens contain the analyzed plan of 
each flow query and empty output for REFRESH FLOW, with no 
_LEGACY_ERROR_TEMP_3102 or INVALID_REFRESH_RESOURCE_PATH entries. The 
window-aggregation analyzer golden shows EventTimeWatermark with its UUID 
masked. Analyzer goldens for inputs that contain no flow statements are 
unchanged.
   
   **Verification:**
   - **Behavior:** Regenerate and then re-run the streaming analyzer golden 
cases; confirm they pass, record the flow queries' analyzed plans, and show the 
masked watermark UUID.
   - **Regression:** Regenerate the streaming analyzer goldens twice and 
confirm the output is identical, so no run-specific identifier leaks into them.
   - **Compatibility:** Run the complete analyzer golden suite and confirm that 
no analyzer golden for a non-streaming input changes.



##########
sql/core/src/test/scala/org/apache/spark/sql/SQLQueryTestSuite.scala:
##########
@@ -366,6 +394,173 @@ class SQLQueryTestSuite extends SharedSparkSession
     localSparkSession
   }
 
+  private def triggeredFlowQueryName(
+      testCase: TestCase,
+      sparkConfigSet: Seq[(String, String)],
+      flowName: Seq[String]): String = {
+    val identity =
+      Seq(testCase.name, sparkConfigSet.mkString(","), 
formatFlowName(flowName)).mkString("\u0000")
+    "sql-query-test-" + 
UUID.nameUUIDFromBytes(identity.getBytes(StandardCharsets.UTF_8))
+  }
+
+  private case class TriggeredFlow(
+      flowName: Seq[String],
+      query: LogicalPlan,
+      targetTable: String)
+
+  private sealed trait TriggeredFlowStatement
+  private case class CreateTriggeredFlow(command: CreateFlowCommand)
+    extends TriggeredFlowStatement
+  private case class RefreshTriggeredFlow(flowName: Seq[String])
+    extends TriggeredFlowStatement
+
+  private def formatFlowName(flowName: Seq[String]): String =
+    flowName.map(quoteIdentifier).mkString(".")
+
+  private def sameFlowName(
+      session: SparkSession,
+      left: Seq[String],
+      right: Seq[String]): Boolean = {
+    left.length == right.length && left.zip(right).forall { case (l, r) =>
+      session.sessionState.conf.resolver(l, r)
+    }
+  }
+
+  private def parseCreateTriggeredFlow(command: CreateFlowCommand): 
TriggeredFlow = {
+    val flowName = command.name match {
+      case identifier: UnresolvedIdentifier => identifier.nameParts
+      case _ =>
+        throw new IllegalArgumentException(
+          "SQL golden triggered flows require a static flow name.")
+    }
+    val insert = command.flowOperation match {
+      case insert: UnresolvedInsert => insert
+      case _ =>
+        throw new IllegalArgumentException(
+          "SQL golden triggered flows require CREATE FLOW AS " +
+            "INSERT INTO ... BY NAME SELECT.")
+    }
+    require(insert.byName && !insert.overwrite && insert.partitionSpec.isEmpty 
&&
+        insert.userSpecifiedCols.isEmpty && insert.replaceCriteriaOpt.isEmpty 
&&
+        !insert.withSchemaEvolution,
+      "SQL golden triggered flows require CREATE FLOW AS INSERT INTO ... BY 
NAME SELECT.")
+
+    val targetTable = insert.table match {
+      case target: UnresolvedInsertTarget =>
+        target.multipartIdentifier.map(quoteIdentifier).mkString(".")
+      case _ =>
+        throw new IllegalArgumentException(
+          "SQL golden triggered flows require a static table identifier as the 
INSERT target.")
+    }
+    TriggeredFlow(flowName, insert.query, targetTable)
+  }
+
+  private def registerTriggeredFlow(
+      session: SparkSession,
+      command: CreateFlowCommand,
+      flows: mutable.Map[Seq[String], TriggeredFlow]): (String, Seq[String]) = 
{
+    val flow = parseCreateTriggeredFlow(command)
+    require(!flows.keys.exists(sameFlowName(session, _, flow.flowName)),
+      s"Triggered flow ${formatFlowName(flow.flowName)} is already defined in 
this test run.")
+    flows += flow.flowName -> flow
+    (emptySchema, Seq.empty)
+  }
+
+  private def resolveTriggeredFlow(
+      session: SparkSession,
+      flowName: Seq[String],
+      flows: mutable.Map[Seq[String], TriggeredFlow]): TriggeredFlow = {
+    flows.collectFirst {
+      case (registeredName, flow) if sameFlowName(session, registeredName, 
flowName) => flow
+    }.getOrElse {
+      throw new IllegalArgumentException(
+        s"Triggered flow ${formatFlowName(flowName)} has not been defined in 
this test run.")
+    }
+  }
+
+  private def tryParsePlan(session: SparkSession, sql: String): 
Option[LogicalPlan] = {
+    try {
+      Some(session.sessionState.sqlParser.parsePlan(sql))
+    } catch {
+      case _: ParseException => None
+    }
+  }
+
+  private def parseRefreshTriggeredFlowStatement(
+      session: SparkSession,
+      sql: String): Option[RefreshTriggeredFlow] = {
+    val tokens = sql.trim.split("\\s+", 3)
+    if (tokens.length == 3 && tokens(0).equalsIgnoreCase("REFRESH") &&
+        tokens(1).equalsIgnoreCase("FLOW")) {
+      try {
+        Some(RefreshTriggeredFlow(
+          session.sessionState.sqlParser.parseMultipartIdentifier(tokens(2))))
+      } catch {
+        case _: ParseException => None
+      }
+    } else {
+      None
+    }
+  }
+
+  private def parseTriggeredFlowStatement(
+      session: SparkSession,
+      sql: String,
+      parsedPlan: Option[LogicalPlan]): Option[TriggeredFlowStatement] = {
+    parsedPlan match {
+      case Some(command: CreateFlowCommand) => 
Some(CreateTriggeredFlow(command))
+      case _ => parseRefreshTriggeredFlowStatement(session, sql)
+    }
+  }
+
+  /** Runs one REFRESH FLOW statement to completion. */
+  private def runTriggeredFlow(
+      session: SparkSession,
+      sql: String,
+      flowName: Seq[String],
+      testCase: TestCase,
+      sparkConfigSet: Seq[(String, String)],
+      flows: mutable.Map[Seq[String], TriggeredFlow]): (String, Seq[String]) = 
{
+    val flow = resolveTriggeredFlow(session, flowName, flows)
+    val df = 
classic.Dataset.ofRows(session.asInstanceOf[classic.SparkSession], flow.query)
+    require(df.isStreaming,
+      s"SQL golden triggered flows require a streaming query, but '$sql' is 
batch SQL.")

Review Comment:
   **Nit (P3):** `sql` here is the `REFRESH FLOW` statement that `runQueries` / 
`runDebugQueries` pass in, not the query captured by `CREATE FLOW`. For a flow 
defined as `CREATE FLOW f AS INSERT INTO sink BY NAME SELECT * FROM source`, 
with no `STREAM(...)`, refreshing it reports "... but 'REFRESH FLOW f' is batch 
SQL." That statement isn't batch SQL, and the test author has to go find the 
definition themselves. Consider naming the flow instead, for example `s"SQL 
golden triggered flow ${formatFlowName(flow.flowName)} requires a streaming 
query, but its CREATE FLOW query reads no streaming source."`.
   
   **Verification:**
   - **Behavior:** Define a flow over a batch SELECT, refresh it, and assert 
that the error names the flow and does not describe the REFRESH statement as 
batch SQL.



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