-
Notifications
You must be signed in to change notification settings - Fork 29.3k
[SPARK-40434][SS][PYTHON] Implement applyInPandasWithState in PySpark #37893
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
Changes from 8 commits
444f9a4
79ba311
0000994
caa4924
76bb404
a2f25e3
bb3a80a
4d3c7e9
b93e488
5d23a6d
e757be0
43929ac
e725995
9c6bf60
69bb3e8
516fa4f
3ded763
f00486f
2fb8da0
1a4f158
c5b35a4
a95df28
0fee506
7051799
5142941
95a1400
295bc9b
9f52c80
8d23d46
fc3dca2
119daf3
4b7c667
426f5e7
83f2555
38eec2d
8133dcd
f100048
dd7a655
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 |
|---|---|---|
|
|
@@ -15,18 +15,20 @@ | |
| # limitations under the License. | ||
| # | ||
| import sys | ||
| from typing import List, Union, TYPE_CHECKING | ||
| from typing import List, Union, TYPE_CHECKING, cast | ||
| import warnings | ||
|
|
||
| from pyspark.rdd import PythonEvalType | ||
| from pyspark.sql.column import Column | ||
| from pyspark.sql.dataframe import DataFrame | ||
| from pyspark.sql.types import StructType | ||
| from pyspark.sql.streaming.state import GroupStateTimeout | ||
| from pyspark.sql.types import StructType, _parse_datatype_string | ||
|
|
||
| if TYPE_CHECKING: | ||
| from pyspark.sql.pandas._typing import ( | ||
| GroupedMapPandasUserDefinedFunction, | ||
| PandasGroupedMapFunction, | ||
| PandasGroupedMapFunctionWithState, | ||
| PandasCogroupedMapFunction, | ||
| ) | ||
| from pyspark.sql.group import GroupedData | ||
|
|
@@ -216,6 +218,105 @@ def applyInPandas( | |
| jdf = self._jgd.flatMapGroupsInPandas(udf_column._jc.expr()) | ||
| return DataFrame(jdf, self.session) | ||
|
|
||
| def applyInPandasWithState( | ||
| self, | ||
| func: "PandasGroupedMapFunctionWithState", | ||
| outputStructType: Union[StructType, str], | ||
| stateStructType: Union[StructType, str], | ||
| outputMode: str, | ||
| timeoutConf: str, | ||
| ) -> DataFrame: | ||
| """ | ||
| Applies the given function to each group of data, while maintaining a user-defined | ||
| per-group state. The result Dataset will represent the flattened record returned by the | ||
| function. | ||
|
|
||
| For a streaming Dataset, the function will be invoked for each group repeatedly in every | ||
|
Contributor
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. remove 'repeatedly' - f'for each group' implies that. |
||
| trigger, and updates to each group's state will be saved across invocations. The function | ||
| will also be invoked for each timed-out state repeatedly. The sequence of the invocation | ||
|
Contributor
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. again, I think 'repeatedly' is unnecessary. |
||
| will be input data -> state timeout. When the function is invoked for state timeout, there | ||
|
Contributor
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. I do not like '->' here - this is supposed to be text. How about: 'The function will be invoked first for all input groups and then for al timed out states where the input data will be null. |
||
| will be no data being presented. | ||
|
|
||
| The function should takes parameters (key, Iterator[`pandas.DataFrame`], state) and | ||
|
Contributor
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. The function takes parameters ... and returns Iterator[...]
Contributor
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. This follows the existing method doc in applyInPandas. The "function" here refers to user function end users will provide, not a function Spark provides as public API, so using |
||
| returns another Iterator[`pandas.DataFrame`]. The grouping key(s) will be passed as a tuple | ||
|
Contributor
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. return another ... |
||
| of numpy data types, e.g., `numpy.int32` and `numpy.float64`. The state will be passed as | ||
| :class:`pyspark.sql.streaming.state.GroupStateImpl`. | ||
|
Contributor
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. again, can we drop the Impl from the state class?
Contributor
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. Let me handle it altogether once the direction has made. |
||
|
|
||
| For each group, all columns are passed together as `pandas.DataFrame` to the user-function, | ||
|
Contributor
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. 'all columns are passed together as Each group is passed as one or more pandas.DataFrame group of records with all columns packed into the DataFrame.
Contributor
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. Same here, this follows the existing method doc in applyInPandas. I'm OK with change it though as I agree it's not mandatory to call out all columns will be passed. Neither user function nor public API specify columns, which is implicitly expected to all columns. Probably worth to discuss a bit more and change altogether in both function? cc. @HyukjinKwon |
||
| and the returned `pandas.DataFrame` across all invocations are combined as a | ||
| :class:`DataFrame`. Note that the user function should loop through and process all | ||
|
Contributor
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. 'Note that the user function should loop through and process all elements in the iterator. The user function should not make a guess of the number of elements in the iterator.'
'Note that the group data may be split as multiple Iterator records and the user function should not assume that it receives a single record.' I would still suggest we have a design discussion about splitting groups unnecessary as I believe we should not do this.
Contributor
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.
I agree this is too conservative and we can remove that once there is technically no issue. I don't think we never have such a test for even existing flatMapGroupsWithState so we actually don't clearly know what happens if we pull a part of data from group.
I think there is a room for discussion on how to split group with in mind we also binpack in terms of performance, but I really doubt this has to be an interface contract. For former, it's not a first class concern and we shouldn't block this PR. For latter, I really want to see what is the real use case which leverages the interface contract, and how much it will be harder to implement for the same if we do not guarantee such contract. Stricter interface contract can be loose without breaking anything, looser interface contract can never be stricter without breaking compatibility. Why not we go with conservative till we are very clear there is a clear use case? |
||
| elements in the iterator. The user function should not make a guess of the number of | ||
| elements in the iterator. | ||
|
|
||
| The `outputStructType` should be a :class:`StructType` describing the schema of all | ||
| elements in returned value, `pandas.DataFrame`. The column labels of all elements in | ||
|
Contributor
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. in the returned value |
||
| returned value, `pandas.DataFrame` must either match the field names in the defined | ||
|
Contributor
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. returned pandas.DataFrame must ... |
||
| schema if specified as strings, or match the field data types by position if not strings, | ||
| e.g. integer indices. | ||
|
|
||
| The `stateStructType` should be :class:`StructType` describing the schema of user-defined | ||
|
Contributor
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. ... describing the schema of the user-defined state. The value of the state will be presented as a tuple and the update should be performed with a tuple. |
||
| state. The value of state will be presented as a tuple, as well as the update should be | ||
| performed with the tuple. User defined types e.g. native Python class types are not | ||
|
Contributor
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. Not StructType types, e.g. user-defined or native Python types are not supported.
Contributor
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. It's a bit tricky - native Python types contain int, float, str, ... and of course they are supported. Probably the clear definition is "python types are supported as long as the default encoder can convert to the Spark SQL type". Not sure we have a clear documentation describing the matrix of compatibility. cc. @HyukjinKwon could you please help us make this clear?
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. I think we can just say that "the corresponding Python types for :class: |
||
| supported. Alternatively, you can pickle the data and produce the data as BinaryType, but | ||
|
Contributor
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. 'Alternatively, you can pickle the data ...' - instead say 'For such cases, the user should pickle the data into BinaryType. Note that this approach may be sensitive to backwards and forward compatibility issues of Python picks and Spark can not guarantee compatibility. though I think you could drop the note as that is orthogonal to Spark.
Contributor
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. Let's simply just remove the suggestion. |
||
| it is tied to the backward and forward compatibility of pickle in Python, and Spark itself | ||
| does not guarantee the compatibility. | ||
|
|
||
| The length of each element in both input and returned value, `pandas.DataFrame`, can be | ||
|
Contributor
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. 'The size of each DataFrame in both the input and output ...' 'The number of DataFrames in both the input and output can also be arbitrary.' |
||
| arbitrary. The length of iterator in both input and returned value can be also arbitrary. | ||
|
|
||
| .. versionadded:: 3.4.0 | ||
|
|
||
| Parameters | ||
| ---------- | ||
| func : function | ||
| a Python native function to be called on every group. It should takes parameters | ||
|
Contributor
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. it should take parameters. ... and return Iterator... |
||
| (key, Iterator[`pandas.DataFrame`], state) and returns Iterator[`pandas.DataFrame`]. | ||
| Note that the type of key is tuple, and the type of state is | ||
|
Contributor
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. Note that the type of the key is tuple and the type of the state is ... |
||
| :class:`pyspark.sql.streaming.state.GroupStateImpl`. | ||
| outputStructType : :class:`pyspark.sql.types.DataType` or str | ||
| the type of the output records. The value can be either a | ||
| :class:`pyspark.sql.types.DataType` object or a DDL-formatted type string. | ||
|
Contributor
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. can you provide an example here of the string?
Contributor
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. In the doc or here? All other PySpark method docs do not have example of this string. Maybe we could have examples like other APIs do and provide DDL-formatted type string to compensate. |
||
| stateStructType : :class:`pyspark.sql.types.DataType` or str | ||
| the type of the user-defined state. The value can be either a | ||
| :class:`pyspark.sql.types.DataType` object or a DDL-formatted type string. | ||
|
Contributor
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. same - can you provide an example of the string
Contributor
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. same. |
||
| outputMode : str | ||
| the output mode of the function. | ||
| timeoutConf : str | ||
| timeout configuration for groups that do not receive data for a while. valid values | ||
| are defined in :class:`pyspark.sql.streaming.state.GroupStateTimeout`. | ||
| """ | ||
|
|
||
| from pyspark.sql import GroupedData | ||
| from pyspark.sql.functions import pandas_udf | ||
|
|
||
| assert isinstance(self, GroupedData) | ||
| assert timeoutConf in [ | ||
| GroupStateTimeout.NoTimeout, | ||
| GroupStateTimeout.ProcessingTimeTimeout, | ||
| GroupStateTimeout.EventTimeTimeout, | ||
| ] | ||
|
|
||
| if isinstance(outputStructType, str): | ||
| outputStructType = cast(StructType, _parse_datatype_string(outputStructType)) | ||
| if isinstance(stateStructType, str): | ||
| stateStructType = cast(StructType, _parse_datatype_string(stateStructType)) | ||
|
|
||
| udf = pandas_udf( | ||
| func, # type: ignore[call-overload] | ||
| returnType=outputStructType, | ||
| functionType=PythonEvalType.SQL_GROUPED_MAP_PANDAS_UDF_WITH_STATE, | ||
| ) | ||
| df = self._df | ||
| udf_column = udf(*[df[col] for col in df.columns]) | ||
| jdf = self._jgd.applyInPandasWithState( | ||
| udf_column._jc.expr(), | ||
| self.session._jsparkSession.parseDataType(outputStructType.json()), | ||
| self.session._jsparkSession.parseDataType(stateStructType.json()), | ||
| outputMode, | ||
| timeoutConf, | ||
| ) | ||
| return DataFrame(jdf, self.session) | ||
|
|
||
| def cogroup(self, other: "GroupedData") -> "PandasCogroupedOps": | ||
| """ | ||
| Cogroups this group with another group so that we can run cogrouped operations. | ||
|
|
||
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.
Can the type be GroupState without the 'Impl' - looks bad in public api.
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.
Either we can split out interface and implementation, or just change the name. I'm fine with any direction.
cc. @HyukjinKwon What'd be the best practice of such case?
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 am fine either way too. Users aren't able to create this instance directly anyway.
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.
One concern is that if we happen to have a different implementation of
GroupStatein the far future. But the type is dynamic anyway so I don't worry too much.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.
Thanks, I just renamed GroupStateImpl to GroupState. Once we find the necessity we can use the same name to become interface and move out the implementation (I guess this is what @HyukjinKwon said the type is dynamic but please let me know if I miss something.)