[FLINK-40431][python] Add scalar UDF support to DataFrame API - #29029
[FLINK-40431][python] Add scalar UDF support to DataFrame API#29029auroflow wants to merge 16 commits into
Conversation
Add expression-oriented general, async, and pandas scalar UDF support using the release-11 implementation structure adapted for community PyFlink. Generated-by: OpenAI Codex (GPT-5)
Introduce a resolved source descriptor that centralizes callable classification, construction, invocation, lifecycle, async detection, and annotation inspection. Generated-by: OpenAI Codex (GPT-5)
Defer zero-argument callable, ScalarFunction, and AsyncScalarFunction class construction while keeping configured instances client-created. Resolve class annotations statically and clean up partial lifecycle initialization. Generated-by: OpenAI Codex (GPT-5)
Keep scalar-function test fixtures compatible with the variadic Table API eval contract while preserving unary behavior and type-hint inference. Generated-by: OpenAI Codex (GPT-5)
| from pyflink.util.api_stability_decorators import PublicEvolving | ||
|
|
||
| __all__ = ["DataFrameUDFWrapper", "udf"] | ||
|
|
There was a problem hiding this comment.
Could you add some examples in DataFrame.with_column on how to use UDF in it?
There was a problem hiding this comment.
Sure, I have added an example using UDF in the docstring of with_column.
| ) | ||
| return value[index] | ||
| attributes = getattr(value, "__dict__", None) | ||
| if isinstance(attributes, Mapping): |
There was a problem hiding this comment.
It can silently convert a valid property-backed field into NULL. For the following example:
class Score:
def __init__(self, raw):
self.raw = raw
@property
def doubled(self):
return self.raw * 2
@pf.udf(
return_dtype=pf.DataType.struct({
"doubled": pf.DataType.int64(),
})
)
def calculate(value):
return Score(value)
For input value 3, the expected output should be Row(doubled=6), the actual result is Row(doubled=None)
There was a problem hiding this comment.
Thanks for catching this! Now structured object results evaluate fields with getattr() first, so property backed fields are preserved.
| declaration_metadata = _unwrap_partial(source.source) | ||
| functools.update_wrapper(self, declaration_metadata, updated=()) | ||
| object.__setattr__(self, "__name__", name) | ||
| object.__setattr__(self, "__wrapped__", source.source) |
There was a problem hiding this comment.
It makes inspect.signature() follow the wrong object for class-based UDFs. For example:
class Add:
def __call__(self, value: int, offset: int = 1) -> int:
return value + offset
add = pf.udf(Add)
inspect.signature(add) # currently: (), expected: (value, offset=1)
The same issue is more visible for a ScalarFunction instance: source.source is not callable, so inspect.signature(pf.udf(MyScalarFunction())) raises TypeError, even though the actual invocation target is eval. This can produce incorrect signatures in help(), IDEs, and documentation/introspection tooling.
Could we set signature from source.inspection_target, removing the implicit self/cls parameter for class declarations, or otherwise avoid advertising source.source as the wrapped callable?
There was a problem hiding this comment.
Good catch! I fixed this issue by setting an explicit __signature__ from the correct invocation target, so the result in your example is correct.
|
|
||
| def _normalize_user_value(value: Any, data_type: Any) -> Any: | ||
| """Normalize nested user values to the Python shape expected by Table coders.""" | ||
| if value is None: |
There was a problem hiding this comment.
The result type is constant, but _normalize_user_value() performs dynamic type dispatch for every record and every nested element. Primitive results also pay three isinstance checks even though no conversion is needed. _normalize_user_value will be executed for each result and so it's performance is very important.
Could we pre-build a type-specific normalizer in _bind_func()—identity for primitive types and recursively composed converters for Row/Array/Map—to avoid repeated dispatch on this per-record hot path?
There was a problem hiding this comment.
Makes sense. I replaced _normalize_user_value() with _create_result_normalizer(), which recursively constructs the Row, Array, and Map normalizers once.
| ) | ||
| from pyflink.util.api_stability_decorators import PublicEvolving | ||
|
|
||
| __all__ = ["DataFrameUDFWrapper", "udf"] |
There was a problem hiding this comment.
Do we really need to expose DataFrameUDFWrapper?
There was a problem hiding this comment.
I agree that DataFrameUDFWrapper does not need to be exposed. Accordingly, I changed the return type of udf() to Callable[..., Expression], which slightly deviates from our original design, but provides a simpler public API without the implementation detail. Let me know if you are happy with it!
Generated-by: OpenAI Codex (GPT-5)
Generated-by: OpenAI Codex (GPT-5)
Generated-by: OpenAI Codex (GPT-5)
Generated-by: OpenAI Codex (GPT-5)
27d88f6 to
135d38f
Compare
Resolve callable annotations independently so unrelated unresolved hints do not hide pandas annotations. Reuse recursive type-hint conversion for explicit TypedDict return types. Generated-by: OpenAI Codex (GPT-5)
Generated-by: OpenAI Codex (GPT-5)
Generated-by: OpenAI Codex (GPT-5)
Generated-by: OpenAI Codex (GPT-5)
Generated-by: OpenAI Codex (GPT-5)
Generated-by: OpenAI Codex (GPT-5)
Generated-by: OpenAI Codex (GPT-5)
Validate the AsyncScalarFunction eval protocol, preserve method binding through functools.wraps, and share determinism agreement validation. Generated-by: OpenAI Codex (GPT-5)
What is the purpose of the change
This pull request adds DataFrame-oriented scalar UDF support to the PyFlink DataFrame API.
It introduces the public
pyflink.dataframe.udfdecorator for constructing single-column expressions used by operations such aswith_column,with_columns, andselect. The implementation builds on the existing PyFlink Table API scalar UDF execution paths without requiring Java, planner, or Table API changes.The supported execution modes are:
The decorator supports Python functions, callable objects, callable classes,
ScalarFunction, andAsyncScalarFunction. Zero-argument class declarations are initialized on the TaskManager, while configured instances are created on the client and serialized to the TaskManager.Brief change log
udfdecorator andDataFrameUDFWrapper.__call__, andeval, including Python types, SQL type strings, and nestedTypedDictresults.ScalarFunction, andAsyncScalarFunctionclasses.Intentionally left out of this pull request
This pull request intentionally implements only the first stage of DataFrame UDF support. The following capabilities are deferred to future pull requests:
concurrencyandbatch_sizeoptions. These will be addressed by FLINK-40471.func_type="arrow". This will be addressed by FLINK-40472.Verifying this change
This change added tests and can be verified as follows:
ScalarFunctionandAsyncScalarFunctionclasses and instancesopenandcloselifecycle delegationcollect()covering general synchronous, general asynchronous, pandas, structured, callable-class, and scalar-function-class UDFs.Does this pull request potentially affect one of the following parts:
@Public(Evolving): yesDocumentation
Was generative AI tooling used to co-author this PR?
Generated-by: OpenAI Codex (GPT-5)