-
Notifications
You must be signed in to change notification settings - Fork 29k
[SPARK-48755][SS][PYTHON] transformWithState pyspark base implementation and ValueState support #47133
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
Conversation
...main/scala/org/apache/spark/sql/execution/python/TransformWithStateInPandasStateServer.scala
Outdated
Show resolved
Hide resolved
...main/scala/org/apache/spark/sql/execution/python/TransformWithStateInPandasStateServer.scala
Outdated
Show resolved
Hide resolved
...ore/src/test/scala/org/apache/spark/sql/streaming/util/TransformWithStateInPandasSuite.scala
Outdated
Show resolved
Hide resolved
|
Mind filing a JIRA? |
Yeah, will do, thanks! |
sahnib
left a comment
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 for making these changes. Reviewed the Python bits, still reviewing Scala bits.
core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala
Outdated
Show resolved
Hide resolved
sahnib
left a comment
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 for making the changes. Left some comments after the second pass.
| >>> class SimpleStatefulProcessor(StatefulProcessor): | ||
| ... def init(self, handle: StatefulProcessorHandle) -> None: | ||
| ... self.value_state = handle.getValueState("testValueState", state_schema) | ||
| ... def handleInputRows(self, key, rows) -> Iterator[pd.DataFrame]: | ||
| ... self.value_state.update("test_value") | ||
| ... exists = self.value_state.exists() | ||
| ... value = self.value_state.get() | ||
| ... self.value_state.clear() | ||
| ... return rows | ||
| ... def close(self) -> None: | ||
| ... pass |
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] It might be more useful to provide a running count example, where we store values above a specified threshold in the state (to keep track of violations). [something like processing temperature sensor values in a stream]
| In addition, this function further groups the return of `gen_data_and_state` by the state | ||
| instance (same semantic as grouping by grouping key) and produces an iterator of data | ||
| chunks for each group, so that the caller can lazily materialize the data chunk. |
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.
It seems like this documentation is referring to the ApplyInPandasWithState serializer which transfers both state and data.
...ain/scala/org/apache/spark/sql/execution/python/TransformWithStateInPandasPythonRunner.scala
Outdated
Show resolved
Hide resolved
...ain/scala/org/apache/spark/sql/execution/python/TransformWithStateInPandasPythonRunner.scala
Show resolved
Hide resolved
...main/scala/org/apache/spark/sql/execution/python/TransformWithStateInPandasStateServer.scala
Outdated
Show resolved
Hide resolved
...main/scala/org/apache/spark/sql/execution/python/TransformWithStateInPandasStateServer.scala
Outdated
Show resolved
Hide resolved
...main/scala/org/apache/spark/sql/execution/python/TransformWithStateInPandasStateServer.scala
Show resolved
Hide resolved
|
|
||
| def generate_data_batches(batches): | ||
| for batch in batches: | ||
| data_pandas = [self.arrow_to_pandas(c) for c in pa.Table.from_batches([batch]).itercolumns()] |
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.
Not sure if this is a common pattern in Python, but this line is a little hard to read
|
|
||
| self.assertEqual(q.name, "this_query") | ||
| self.assertTrue(q.isActive) | ||
| q.processAllAvailable() |
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.
Should we include q.awaitTermination()?
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.
+1 Shall we ensure the query to be stopped instead of relying on other test to stop leaking query?
|
|
||
| package pyspark.sql.streaming; | ||
|
|
||
| message StateRequest { |
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.
is it possible to add some high level comments here or in some other Python file ?
HyukjinKwon
left a comment
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.
Looks fine at high level
Thanks @HyukjinKwon! I addressed your comments, could you help take another look? |
|
I defer to @HeartSaVioR . I don;t have any high level concern |
|
Let's call this out as transformWithState explicitly as now we finalize the name of the API. |
HeartSaVioR
left a comment
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.
9/31 files reviewed (probably several remaining files are auto-generated) - will continue tomorrow. Please leave file-level comment for auto-generated files.
...lyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/pythonLogicalOperators.scala
Outdated
Show resolved
Hide resolved
...lyst/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/pythonLogicalOperators.scala
Outdated
Show resolved
Hide resolved
| newChild: LogicalPlan): FlatMapGroupsInPandasWithState = copy(child = newChild) | ||
| } | ||
|
|
||
| object TransformWithStateInPandas { |
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.
Any reason we can't just use the generated constructor of case class? params here are exactly the same with constructor param in case class.
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.
yeah removed it since it's redundant, thanks for catching this!
| } | ||
| } | ||
|
|
||
| case class TransformWithStateInPandas( |
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: shall we add a short description as class doc while we are here?
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.
done
...re/src/main/scala/org/apache/spark/sql/execution/python/TransformWithStateInPandasExec.scala
Show resolved
Hide resolved
...re/src/main/scala/org/apache/spark/sql/execution/python/TransformWithStateInPandasExec.scala
Show resolved
Hide resolved
|
|
||
| val outputIterator = executePython(data, output, runner) | ||
|
|
||
| CompletionIterator[InternalRow, Iterator[InternalRow]](outputIterator, { |
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.
Where we count numOutputRows in this node?
...ain/scala/org/apache/spark/sql/execution/python/TransformWithStateInPandasPythonRunner.scala
Outdated
Show resolved
Hide resolved
HeartSaVioR
left a comment
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.
27 / 31 files - I'll continue reviewing 4 files, hopefully by today (or early tomorrow).
| private val sqlConf = SQLConf.get | ||
| private val arrowMaxRecordsPerBatch = sqlConf.arrowMaxRecordsPerBatch | ||
|
|
||
| private var stateSocketSocketPort: Int = 0 |
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: Probably one of Socket should be Server?
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.
good catch!
...ain/scala/org/apache/spark/sql/execution/python/TransformWithStateInPandasPythonRunner.scala
Show resolved
Hide resolved
| * This class is used to handle the state requests from the Python side. It runs on a separate | ||
| * thread spawned by TransformWithStateInPandasStateRunner per task. It opens a dedicated socket | ||
| * to process/transfer state related info which is shut down when task finishes or there's an error | ||
| * on opening the socket. It run It processes following state requests and return responses to the |
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: It run It processes?
| * - Requests for managing state variables (e.g. valueState). | ||
| */ | ||
| class TransformWithStateInPandasStateServer( | ||
| private val stateServerSocket: ServerSocket, |
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: in many cases, having private val in constructor param in class is redundant.
| private val valueStateMapForTest: mutable.HashMap[String, ValueState[Row]] = null) | ||
| extends Runnable with Logging { | ||
| private var inputStream: DataInputStream = _ | ||
| private var outputStream: DataOutputStream = outputStreamForTest |
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.
outputStreamForTest <= is this really used? We always assign the output stream from run()
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.
OK so we do not call run() when testing...
python/pyspark/worker.py
Outdated
| elif eval_type == PythonEvalType.SQL_GROUPED_MAP_PANDAS_UDF_WITH_STATE: | ||
| return args_offsets, wrap_grouped_map_pandas_udf_with_state(func, return_type) | ||
| elif eval_type == PythonEvalType.SQL_TRANSFORM_WITH_STATE_PANDAS_UDF: | ||
| argspec = inspect.getfullargspec(chained_func) # signature was lost when wrapping it |
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.
doesn't seem to be used anywhere, blindly copied?
| ... count = 0 | ||
| ... exists = self.num_violations_state.exists() | ||
| ... if exists: | ||
| ... existing_violations_pdf = self.num_violations_state.get() |
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.
What's the expectation of the type of this state "value"? From the variable name pdf and also the way we get the number, I suspect this to be a pandas DataFrame, while the right type should be Row.
| ... new_violations += violations_pdf.count().get('temperature') | ||
| ... updated_violations = new_violations + existing_violations | ||
| ... self.num_violations_state.update((updated_violations,)) | ||
| ... yield pd.DataFrame({'id': key, 'count': count}) |
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.
I guess the explanation is to produce the number of violations instead of the number of inputs. This doesn't follow the explanation.
| +---+-----+ | ||
| | id|count| | ||
| +---+-----+ | ||
| | 0| 2| |
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.
Isn't the desired output (0, 1), (1, 1)?
|
|
||
| def dump_stream(self, iterator, stream): | ||
| """ | ||
| Read through an iterator of (iterator of pandas DataFram), serialize them to Arrow |
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: DataFrame
HeartSaVioR
left a comment
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.
First pass.
|
|
||
| class ValueState: | ||
| """ | ||
| Class used for arbitrary stateful operations with the v2 API to capture single value state. |
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.
We should not call transformWithState as v2 API as only few people would know what is v2. Please call it by the name.
| """ | ||
| return self._value_state_client.exists(self._state_name) | ||
|
|
||
| def get(self) -> Any: |
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.
Again, we expect Row as state value, not a pandas DataFrame. Please let me know if you are proposing pandas DataFrame for better suit for more state types.
|
|
||
| class StatefulProcessorHandle: | ||
| """ | ||
| Represents the operation handle provided to the stateful processor used in the arbitrary state |
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: transformWithState
| def remove_implicit_key(self) -> None: | ||
| import pyspark.sql.streaming.StateMessage_pb2 as stateMessage | ||
|
|
||
| print("calling remove_implicit_key on python side") |
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.
debugging purpose, or intentionally left for future debug context?
| if status == 0: | ||
| self.handle_state = state | ||
| else: | ||
| raise PySparkRuntimeError(f"Error setting handle state: " f"{response_message[1]}") |
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.
I see we just match all errors here to PySparkRuntimeError with error message (no classification) - shall we revisit the Scala codebase and ensure we give the same error class for the same error?
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.
Also there are internal requests vs user side requests. For example, I don't expect users to call set_implicit_key by themselves (so errors from them are internal errors), but expect users to call get_value_state (so error could be either user facing and internal). The classification of error class has to be different for these cases.
| df = self.spark.readStream.format("text").option("maxFilesPerTrigger", 1).load(input_path) | ||
| df_split = df.withColumn("split_values", split(df["value"], ",")) | ||
| df_split = df_split.select( | ||
| df_split.split_values.getItem(0).alias("id"), |
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.
Would adding cast here instead of having withColumn in L84 work?
|
|
||
| self.assertEqual(q.name, "this_query") | ||
| self.assertTrue(q.isActive) | ||
| q.processAllAvailable() |
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.
+1 Shall we ensure the query to be stopped instead of relying on other test to stop leaking query?
|
|
||
| self._test_transform_with_state_in_pandas_basic(SimpleStatefulProcessor(), check_results) | ||
|
|
||
| def test_transform_with_state_in_pandas_sad_cases(self): |
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: shall we be explicit a bit for what is the bad case? method name is test name.
| ) | ||
|
|
||
| def test_transform_with_state_in_pandas_query_restarts(self): | ||
| input_path = tempfile.mkdtemp() |
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.
While we are using three different sub-directories, shall we call this out as root_path and create a subdirectory input explicitly?
| existing_violations = 0 | ||
| for pdf in rows: | ||
| pdf_count = pdf.count() | ||
| count += pdf_count.get("temperature") |
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.
Same for the API doc example - any reason we count the inputs and count the number of violations separately?
HeartSaVioR
left a comment
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.
Only minors which could be also deferred to TODO JIRA ticket(s).
| if (valueStates(stateName)._1.exists()) { | ||
| sendResponse(0) | ||
| } else { | ||
| sendResponse(1, s"state $stateName doesn't exist") |
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.
How do we distinguish the case of "no value state is defined for the state variable name" vs "the value state is defined but not having a value yet" if we use the same status code?
| sendResponse(1, s"state $stateName doesn't exist") | ||
| } | ||
| val valueRow = PythonSQLUtils.toJVMRow(byteArray, valueStateTuple._2, valueStateTuple._3) | ||
| valueStates(stateName)._1.update(valueRow) |
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: valueStateTuple
|
|
||
| private def sendResponse(status: Int, errorMessage: String = null): Unit = { | ||
| private def sendResponse( | ||
| status: Int, |
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: 2 more spaces (while we are here)
| def get(self) -> Any: | ||
| import pandas as pd | ||
|
|
||
| def get(self) -> Row: |
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: Optional[Row]?
| status = response_message[0] | ||
| if status != 0: | ||
| raise PySparkRuntimeError(f"Error initializing value state: " f"{response_message[1]}") | ||
| raise PySparkRuntimeError( |
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.
Shall we give a better error class as it's user facing error? You can revert back and file a JIRA ticket for this as well to defer the change.
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.
I'd expect having dedicated error class, if Scala version of the implementation uses the error class then use the same, otherwise define a new one.
| return True | ||
| elif status == 1: | ||
| # server returns 1 if the state does not exist | ||
| elif status == 1 and "doesn't exist" in response_message[1]: |
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.
I'd recommend to use the different status code instead of parsing. Please consider the change relying on string/hardcode to be unacceptable except specific needs.
| else: | ||
| raise PySparkRuntimeError( | ||
| f"Error checking value state exists: " f"{response_message[1]}" | ||
| errorClass="CALL_BEFORE_INITIALIZE", |
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.
ditto, explicitly define a dedicated error class
| return row | ||
| else: | ||
| raise PySparkRuntimeError(f"Error getting value state: {response_message[1]}") | ||
| raise PySparkRuntimeError( |
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.
ditto, probably the same error class with above
| status = response_message[0] | ||
| if status != 0: | ||
| raise PySparkRuntimeError(f"Error updating value state: " f"{response_message[1]}") | ||
| raise PySparkRuntimeError( |
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.
ditto, same error class as above
| status = response_message[0] | ||
| if status != 0: | ||
| raise PySparkRuntimeError(f"Error clearing value state: " f"{response_message[1]}") | ||
| raise PySparkRuntimeError( |
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.
ditto, same error class as above
HeartSaVioR
left a comment
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.
+1
Please leave a comment listing all JIRA tickets for TODOs, for record/reference.
|
I'm going to merge as we have TODO tickets and all others look OK. Thanks! Merging to master. |
Thanks a lot @HeartSaVioR! Here are the TODOs related to this PR: |
… module ### What changes were proposed in this pull request? This pr makes the following changes to the `maven-shade-plugin` rules for the `sql/core` module: 1. To avoid being influenced by the parent `pom.xml`, use `combine.self = "override"` in the `<configuration>` of the `maven-shade-plugin` for the `sql/core` module. Before this configuration was added, the relocation result was incorrect, and `protobuf-java` was not relocated. We can unzip the packaging result to confirm this issue. We can use IntelliJ's "Show Effective POM" feature to view the result of this parameter, the result is equivalent to the effective POM log with --debug printing added during the Maven compilation: **Before** <img width="828" alt="image" src="https://github.com/user-attachments/assets/0bce810f-57e9-4a50-9fa2-b6063e040a29"> We can see that an unexpected ``` <includes> <include>org.eclipse.jetty.**</include> </includes> ``` has been added to the relocation rule. **After** <img width="787" alt="image" src="https://github.com/user-attachments/assets/0fab3422-2da7-4b8f-bd7f-9357fcdc39c2"> We can see that the extra `<includes>` in the relocation rule is no longer present. 2. Before SPARK-48755 | #47133 overwrote the `maven-shade-plugin` rules for `sql/core`, it inherited the rules from the parent `pom.xml` and shaded `org.spark-project.spark:unused`. This behavior changed after SPARK-48755, so this pr restores it. 3. The relocation rules for Guava should be retained and follow the configuration in the parent `pom.xml`, which relocates `com.google.common` to `${spark.shade.packageName}.guava`. This PR restores this configuration. 4. For `protobuf-java`, which is under the `com.google.protobuf` package, the already shaded `protobuf-java` in the `core` module can be reused instead of shading it again in `sql/core` module. Therefore, this pr only configures the corresponding relocation rule for it: `com.google.protobuf` -> `${spark.shade.packageName}.spark_core.protobuf`. 5. Regarding the `ServicesResourceTransformer` configuration, it is used to merge `META-INF/services` resources. This is not needed for Guava and `protobuf-java`, so this pr removes it. ### Why are the changes needed? Fix shade and relocation rule of `sql/core` module ### Does this PR introduce _any_ user-facing change? No ### How was this patch tested? - Pass Github Aciton - Manually inspect the packaging result: Extract `spark-sql_2.13-4.0.0-SNAPSHOT.jar` to a separate directory, then execute `grep "org.sparkproject.guava" -R *` and `grep "org.sparkproject.spark_core.protobuf" -R *` to confirm the successful relocation. - Maven test passed: https://github.com/LuciferYang/spark/runs/32278520082 <img width="960" alt="image" src="https://github.com/user-attachments/assets/5435b2ff-3785-4413-83d9-190c16c6ba75"> ### Was this patch authored or co-authored using generative AI tooling? No Closes #48675 from LuciferYang/sql-core-shade. Lead-authored-by: yangjie01 <yangjie01@baidu.com> Co-authored-by: YangJie <yangjie01@baidu.com> Signed-off-by: yangjie01 <yangjie01@baidu.com>
…ithState` ### What changes were proposed in this pull request? This follow-ups for #47133 to add missing API ref docs ### Why are the changes needed? Provide proper API ref doc for `transformWithState` ### Does this PR introduce _any_ user-facing change? No API changes but only the user-facing API ref docs will include the new API ### How was this patch tested? The existing doc build in CI should pass ### Was this patch authored or co-authored using generative AI tooling? No Closes #48840 from itholic/SPARK-48755-followup. Authored-by: Haejoon Lee <haejoon.lee@databricks.com> Signed-off-by: Hyukjin Kwon <gurwls223@apache.org>
What changes were proposed in this pull request?
Below we specifically highlight some key files/components for this change:
group_ops.py: defines transformWithStateInPandas function and its udf.serializer.py: defines how we load and dump arrow streams for data rows between the JVM and Python process.stateful_processor.py: defines StatefulProcessorHandle, ValueState functionalities and StatefulProcessor interface.state_api_client.pyandvalue_state_client.py: contains logics to send API request in protobuf format to the server (JVM)TransformWithStateInPandasExec: physical operator forTransformWithStateInPandas.TransformWithStateInPandasPythonRunner: python runner that launches python worker that executes the udf.TransformWithStateInPandasStateServer: class that handles state requests in protobuf format from python side.Why are the changes needed?
Support Python State V2 API
Does this PR introduce any user-facing change?
Yes
How was this patch tested?
Added unit tests.
Did local integration test with below command
Verified from the logs that value state methods work as expected for key
11Was this patch authored or co-authored using generative AI tooling?
No