Repository navigation
[SPARK-57189][CONNECT][SDP] Fix double execution and guard bypass in handleSqlCommand for WITH_RELATIONS #59425
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
e50853b
3921af2
d4ed446
64236ec
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1164,4 +1164,179 @@ class SparkDeclarativePipelinesServerSuite | |
| relation.getCommand.getSqlCommand.getInput) | ||
| } | ||
| } | ||
|
|
||
| // Helper to build a SqlCommand ExecutePlanRequest with PipelineAnalysisContext. A flow name | ||
| // marks the request as coming from inside a pipeline flow function. | ||
| private def buildSqlCommandRequestWithPipelineContext( | ||
| graphId: String, | ||
| input: proto.Relation, | ||
| flowName: Option[String] = None): proto.ExecutePlanRequest = { | ||
| val contextBuilder = proto.PipelineAnalysisContext | ||
| .newBuilder() | ||
| .setDataflowGraphId(graphId) | ||
| flowName.foreach(contextBuilder.setFlowName) | ||
| val context = contextBuilder.build() | ||
| val userContext = proto.UserContext | ||
| .newBuilder() | ||
| .addExtensions(com.google.protobuf.Any.pack(context)) | ||
| .setUserId("test_user") | ||
| .build() | ||
| val plan = proto.Plan | ||
| .newBuilder() | ||
| .setCommand( | ||
| proto.Command | ||
| .newBuilder() | ||
| .setSqlCommand(proto.SqlCommand.newBuilder().setInput(input).build()) | ||
| .build()) | ||
| .build() | ||
| proto.ExecutePlanRequest | ||
| .newBuilder() | ||
| .setUserContext(userContext) | ||
| .setPlan(plan) | ||
| .setSessionId(UUID.randomUUID().toString) | ||
| .build() | ||
| } | ||
|
|
||
| // Helper to build a SQL Relation. | ||
| private def sqlRelation(query: String): proto.Relation = | ||
| proto.Relation | ||
| .newBuilder() | ||
| .setSql(proto.SQL.newBuilder().setQuery(query)) | ||
| .build() | ||
|
|
||
| // Helper to build a WITH_RELATIONS Relation with the given root SQL and reference SQLs. | ||
| private def withRelationsRelation( | ||
| rootSql: String, | ||
| refs: Seq[(String, String)]): proto.Relation = | ||
| withRelationsRelationOf( | ||
| rootSql, | ||
| refs.map { case (alias, refSql) => | ||
| alias -> sqlRelation(refSql) | ||
| }) | ||
|
|
||
| // Helper to build a WITH_RELATIONS Relation with the given root SQL and reference inputs. | ||
| private def withRelationsRelationOf( | ||
| rootSql: String, | ||
| refs: Seq[(String, proto.Relation)]): proto.Relation = { | ||
| val withRelationsBuilder = proto.WithRelations.newBuilder().setRoot(sqlRelation(rootSql)) | ||
| refs.foreach { case (alias, input) => | ||
| withRelationsBuilder.addReferences( | ||
| proto.Relation | ||
| .newBuilder() | ||
| .setSubqueryAlias( | ||
| proto.SubqueryAlias | ||
| .newBuilder() | ||
| .setAlias(alias) | ||
| .setInput(input) | ||
| .build()) | ||
| .build()) | ||
| } | ||
| proto.Relation.newBuilder().setWithRelations(withRelationsBuilder.build()).build() | ||
| } | ||
|
|
||
| namedGridTest( | ||
| "SPARK-57189: blockUnsupportedSqlCommand catches Command at root of WITH_RELATIONS " + | ||
| "without executing it")( | ||
| Map("outside a flow function" -> None, "inside a flow function" -> Some("flow1"))) { | ||
| flowName => | ||
| withRawBlockingStub { implicit stub => | ||
| val graphId = createDataflowGraph | ||
| val tableName = "spark_catalog.default.test_with_relations_root_table" | ||
| try { | ||
| sql(s"DROP TABLE IF EXISTS $tableName") | ||
| val request = buildSqlCommandRequestWithPipelineContext( | ||
| graphId, | ||
| flowName = flowName, | ||
| input = withRelationsRelation( | ||
| rootSql = s"CREATE TABLE $tableName (id INT) USING parquet", | ||
| refs = Seq("ref0" -> "SELECT 1 AS id"))) | ||
| val ex = intercept[RuntimeException] { | ||
| stub.executePlan(request).next() | ||
| } | ||
| assert(ex.getMessage.contains("UNSUPPORTED_PIPELINE_SPARK_SQL_COMMAND")) | ||
| // Verify the guard caught the Command before any side effect could materialize. | ||
| assert(!spark.catalog.tableExists(tableName)) | ||
| } finally { | ||
| sql(s"DROP TABLE IF EXISTS $tableName") | ||
| } | ||
| } | ||
| } | ||
|
|
||
| namedGridTest( | ||
| "SPARK-57189: blockUnsupportedSqlCommand catches Command inside reference of " + | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit (P3): None of the new tests reaches the recursive arm for references. That shape is reachable from PySpark: a DataFrame returned by Verification:
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks, added in d4ed446. It fails if the recursion is replaced with a plain |
||
| "WITH_RELATIONS without executing it")( | ||
| Map("outside a flow function" -> None, "inside a flow function" -> Some("flow1"))) { | ||
| flowName => | ||
| withRawBlockingStub { implicit stub => | ||
| val graphId = createDataflowGraph | ||
| val tableName = "spark_catalog.default.test_with_relations_ref_table" | ||
| try { | ||
| sql(s"DROP TABLE IF EXISTS $tableName") | ||
| val request = buildSqlCommandRequestWithPipelineContext( | ||
| graphId, | ||
| flowName = flowName, | ||
| input = withRelationsRelation( | ||
| rootSql = "SELECT * FROM ref0", | ||
| refs = Seq("ref0" -> s"CREATE TABLE $tableName (id INT) USING parquet"))) | ||
| val ex = intercept[RuntimeException] { | ||
| stub.executePlan(request).next() | ||
| } | ||
| assert(ex.getMessage.contains("UNSUPPORTED_PIPELINE_SPARK_SQL_COMMAND")) | ||
| // Verify the Command in the reference did not execute as a side effect. | ||
| assert(!spark.catalog.tableExists(tableName)) | ||
| } finally { | ||
| sql(s"DROP TABLE IF EXISTS $tableName") | ||
| } | ||
| } | ||
| } | ||
|
|
||
| namedGridTest( | ||
| "SPARK-57189: blockUnsupportedSqlCommand catches Command in a WITH_RELATIONS used as a " + | ||
| "reference input without executing it")( | ||
| Map("outside a flow function" -> None, "inside a flow function" -> Some("flow1"))) { | ||
| flowName => | ||
| withRawBlockingStub { implicit stub => | ||
| val graphId = createDataflowGraph | ||
| val tableName = "spark_catalog.default.test_with_relations_nested_ref_table" | ||
| try { | ||
| sql(s"DROP TABLE IF EXISTS $tableName") | ||
| // A DataFrame from spark.sql("...", df=...) carries a WITH_RELATIONS relation, and it | ||
| // becomes a reference input when passed to another spark.sql call. | ||
| val nested = withRelationsRelation( | ||
| rootSql = s"CREATE TABLE $tableName (id INT) USING parquet", | ||
| refs = Seq("inner0" -> "SELECT 1 AS id")) | ||
| val request = buildSqlCommandRequestWithPipelineContext( | ||
| graphId, | ||
| flowName = flowName, | ||
| input = withRelationsRelationOf( | ||
| rootSql = "SELECT * FROM ref0", | ||
| refs = Seq("ref0" -> nested))) | ||
| val ex = intercept[RuntimeException] { | ||
| stub.executePlan(request).next() | ||
| } | ||
| assert(ex.getMessage.contains("UNSUPPORTED_PIPELINE_SPARK_SQL_COMMAND")) | ||
| // Verify the Command in the nested WITH_RELATIONS did not execute as a side effect. | ||
| assert(!spark.catalog.tableExists(tableName)) | ||
| } finally { | ||
| sql(s"DROP TABLE IF EXISTS $tableName") | ||
| } | ||
| } | ||
| } | ||
|
|
||
| test( | ||
| "SPARK-57189: blockUnsupportedSqlCommand does not over-block a valid SELECT " + | ||
| "WITH_RELATIONS in pipeline analysis context") { | ||
| // This passes with and without the fix. It keeps the new recursive check from blocking a | ||
| // valid SELECT in the future. The two tests above are the ones that verify the fix. | ||
| withRawBlockingStub { implicit stub => | ||
| val graphId = createDataflowGraph | ||
| val request = buildSqlCommandRequestWithPipelineContext( | ||
| graphId, | ||
| withRelationsRelation( | ||
| rootSql = "SELECT * FROM ref0", | ||
| refs = Seq("ref0" -> "SELECT 1 AS id"))) | ||
| val response = stub.executePlan(request).next() | ||
| assert(response.hasSqlCommandResult) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This positive test passes both before and after the fix: reverting the production change does not turn it red (the old guard did notblock a plain SELECT either, so there was no over-block to begin with). It is therefore a forward-looking guard that protects the new recursive
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good point, thanks. I renamed it to |
||
| } | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit, not blocking. This helper only sets
dataflowGraphId, neverflowName, so all three new tests exercise the path outside a pipeline flow function. The guard runs at thehasPipelineAnalysisContextcheck, which is before theisInsidePipelineFlowFunctionearly return, so the bug existed inside a flow function too: on the old code the guard ranexecuteSQLWithRefs(e.g. aCREATE TABLEroot) and the resultingCommandResultpassed the blocklist. There was no second execution there (the handler then returns the relation), but the command still ran. That is also the most common real shape: inside a query function,spark.sql("... {df}", df=...)sends exactly this WITH_RELATIONS withSubqueryAliasreferences, andregister_flowwrapsflow.func()inadd_pipeline_analysis_context(..., flow_name=flow.name).Could the helper take an optional
flowNameand the two command tests run both with and without it (e.g. viagridTest)? On the old code the flow-function variant would also fail on bothintercept[RuntimeException]andassert(!tableExists); on the current code it pins that the guard keeps running before the flow-function return. This dovetails with @HyukjinKwon's suggestion below to give the helper a reference-input variant, so both are about widening what the new tests actually cover.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, done in 64236ec. The helper takes an optional
flowName, and the command tests run both inside and outside a flow function. All of them fail without the fix.