User-defined process table functions (UDPTFs)
This topic describes how to develop, register, and use user-defined process table functions (UDPTFs) for Flink.
Definition
A user-defined process table function (UDPTF) is an advanced custom function for Flink SQL and the Table API. A PTF can accept zero, one, or more table arguments and scalar arguments, and produce zero, one, or more output rows. Unlike a UDTF, a PTF not only transforms data row by row but also processes groups of related data by key. It can use Flink-managed state, event time, timers, and changelogs to implement state aggregation, event matching, timeout handling, custom joins, and other logic. For more information, see Process Table Functions (PTFs).
UDPTFs are supported only in VVR 11.9 and later.
Develop a UDPTF
The implementation principles of UDPTFs are similar to those of other custom functions. For more information, see the implementation guide for user-defined functions.
To develop a UDPTF, extend org.apache.flink.table.functions.ProcessTableFunction<T> and implement the eval(...) method. The eval() method must be public, must not be static, and cannot be overloaded. The method signature is ordered as follows:
eval( <context>? , <state entry>* , <call argument>* )
Input and output types are inferred by reflection by default. You can also declare them explicitly with @FunctionHint, @ArgumentHint, @StateHint, and @DataTypeHint. The eval() method returns no result; it emits zero, one, or more rows through collect(T).
For details about Context, state arguments, and call arguments, see the PTF implementation guide.
In a Maven project, set the scope of the corresponding Flink dependency to provided. The following example shows the minimal dependency:
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-table-common</artifactId>
<version>2.3.0</version>
<scope>provided</scope>
</dependency>
Supported capabilities
PTFs support the following features:
|
Feature |
Description |
|
Table arguments with row semantics and set semantics |
The semantics determine whether each row can be processed independently or rows are related by a key and must be processed together |
|
State access |
PTFs with set semantics can declare and use state |
|
Time and timers |
A PTF can use event time. PTFs with set semantics can use watermark-driven timers |
|
Ordering |
PTFs with set semantics can define sort keys so that data triggers the |
|
Multi-table processing |
All table arguments must be declared with set semantics and use the same partitioning. This enables business logic such as custom joins. |
|
Pass-through columns |
Whether to add all columns of the input table to the PTF output |
|
Processing updates and changelogs |
PTFs can consume and produce update streams |
The following sections describe each feature.
Table arguments with row and set semantics
A PTF with the row-semantics table argument ArgumentTrait.ROW_SEMANTIC_TABLE assumes that rows are unrelated, so each row can be processed independently. The framework freely decides which PTF instance handles the current row.
A PTF with the set-semantics table argument ArgumentTrait.SET_SEMANTIC_TABLE assumes that rows are related. The PARTITION BY clause defines the columns used for the relation. The framework shuffles data by partition key to ensure that all rows with the same partition key are processed by the same parallel instance. You can also declare the argument ArgumentTrait.OPTIONAL_PARTITION_BY to omit the partition key, in which case a single PTF instance processes all data.
For example:
// A UDPTF with row semantics
// Used as SELECT * FROM f(r => TABLE t)
public void eval(
@ArgumentHint(ArgumentTrait.ROW_SEMANTIC_TABLE) Row input) {
...
}
// A UDPTF with set semantics
// Used as SELECT * FROM f(r => TABLE t PARTITION BY name)
// or SELECT * FROM f(r => TABLE t)
public void eval(
@ArgumentHint(ArgumentTrait.SET_SEMANTIC_TABLE) Row input) {
...
}
For more information, see table semantics and virtual processors.
State access
A PTF with set semantics can be stateful. The framework stores and restores the state across failures and restarts. State is partitioned by the partition key defined in the PARTITION BY clause. If no partition key is defined, all data forms a single partition (that is, keyed state in Flink).
State is declared in a PTF with StateHint, where you can explicitly specify parameters such as the state data type and time to live (TTL). For example:
class StateFunction extends ProcessTableFunction<String> {
/** POJO for state. All fields nullable. */
public static class Score {
public String s;
public Integer i;
}
public void eval(
Context ctx,
// Explicitly declare the data type of this state
@StateHint(type = @DataTypeHint("ROW<emitted BOOLEAN>")) Row s0,
// Set the TTL of this state to 5 days
@StateHint(ttl = "5 days") Score s1,
// Set the TTL of this state to 0 ms (never expires). A value without a unit is in ms
@StateHint(ttl = "0") Score s2,
// The TTL of this state follows table.exec.state.ttl
@StateHint Score s3,
@ArgumentHint(ArgumentTrait.SET_SEMANTIC_TABLE) Row r) {
...
// Clean up state with ctx.clearState("s1") or ctx.clearAllState()
// to prevent state bloat
}
}
Currently, the following three state types are available in a PTF:
-
Value State: a single value. This is the default state type in a PTF.
-
List State: a list of values that supports appends, removals, and iteration.
-
Map State: a mapping of key-value pairs for efficient lookup, modification, and removal of entries.
By default, state in a PTF is Value State: every access reads and writes the entire data from and to the state backend. To optimize state access and avoid unnecessary serialization and deserialization, the state object can be declared as:
-
org.apache.flink.table.api.dataview.ListView(that is, List State) -
org.apache.flink.table.api.dataview.MapView(that is, Map State)
For more information, see state.
Time and timers
PTFs natively support event time. You can use event time and timers in a PTF to implement business logic such as delayed triggering and late data handling.
Time
Each PTF call has an optional on_time argument that declares the time attribute column used as event time. You can also declare the argument ArgumentTrait.REQUIRE_ON_TIME to make the on_time column mandatory in every call.
SELECT * FROM f(..., on_time => DESCRIPTOR(`my_timestamp`));
You can access time for custom operations by calling Context#timeContext(Class) to obtain a TimeContext. The scope of the time context is always limited to the event being processed, which can be the current input row or the timer being fired. Time and timer timestamps can be represented as java.time.Instant, java.time.LocalDateTime, or Long. These timestamps are based on milliseconds since the epoch and ignore the local session time zone. You can pass a time class to timeContext().
/** Sends a ping for each key one minute after the last observed event of the key. */
public static class PingLaterFunction
extends ProcessTableFunction<String> {
public void eval(
Context ctx,
@ArgumentHint({ArgumentTrait.SET_SEMANTIC_TABLE,
ArgumentTrait.REQUIRE_ON_TIME})
Row input
) {
TimeContext<Instant> timeCtx = ctx.timeContext(Instant.class);
// Replace the existing timer, which may restart the one-minute countdown
timeCtx.registerOnTime("ping", timeCtx.time().plus(Duration.ofMinutes(1)));
}
public void onTimer(OnTimerContext onTimerCtx) {
collect("ping");
}
}
TimeContext provides functions to get the event time of the record being processed, the timestamp that fired a timer, the watermark of the current input, and the watermark of the current PTF instance. For more information, see time.
Timers
PTFs with set semantics support timers. A timer lets you process data at a later point in time to implement waiting, synchronization, or timeout logic. When the watermark passes the registered time, the timer fires.
A PTF that uses timers must declare an onTimer() method to handle timer events. The signature of onTimer() is as follows:
onTimer( <on timer context>? , <state entry>* )
The framework stores and restores timers across failures and restarts, so timers are a special kind of state. Likewise, a timer is scoped to the state partition defined by the PARTITION BY clause and can be registered and removed only within the current partition key. If the number of timers affects performance, clean up unneeded timers with Context#clearAllTimers() or TimeContext#clearTimer(String). For more information, see timers.
Sorting
PTFs with set semantics can optionally specify an ORDER BY clause in a call to define the processing order of rows within each partition. The ORDER BY clause guarantees that rows are passed to the eval() method in the specified order.
The first column of the ORDER BY clause must be a time attribute column (a TIMESTAMP or TIMESTAMP_LTZ column with a declared watermark), and the first ORDER BY column must be in ascending order to guarantee event-time processing. You can add more columns as secondary sort keys to order rows that share the same timestamp. For more information, see ordering.
SELECT * FROM my_ptf(
input_table => TABLE source_table
PARTITION BY user_id
ORDER BY (event_time ASC, priority DESC NULLS FIRST)
)
The framework maintains a watermark-driven sort buffer for the PTF to reorder out-of-order events. When the watermark advances, every buffered row with a timestamp less than or equal to the watermark is passed to the eval() method in sorted order. Later-arriving events (data whose event time is below the watermark) are discarded.
Multi-table processing
A PTF can process multiple tables at the same time. The eval() method can take multiple table arguments to support multiple inputs. All table arguments must be declared with set semantics and use consistent partitioning: the number of columns and their data types in the PARTITION BY clauses of all related table arguments must be identical.
Rows from the inputs are passed to the function one row at a time, so only one table argument is non-null at any moment. Use null checks to determine which input is being processed.
The following example implements a custom join:
/** Greets a customer and recommends the last purchased item when the information is available. */
public static class GreetingWithLastPurchase extends ProcessTableFunction<String> {
/** Keeps the most recently purchased item in state. */
public static class LastItemState {
public String lastItem;
}
/** The eval() method takes two @ArgumentHint(SET_SEMANTIC_TABLE) arguments */
public void eval(
@StateHint LastItemState state,
@ArgumentHint(SET_SEMANTIC_TABLE) Row visit,
@ArgumentHint(SET_SEMANTIC_TABLE) Row purchase) {
// Handles rows from the Purchases table
if (purchase != null) {
state.lastItem = purchase.getFieldAs("item");
}
// Handles rows from the Visits table
else if (visit != null) {
if (state.lastItem == null) {
collect("Hello " + visit.getFieldAs("name") + ", let me know if I can help!");
} else {
collect("Hello " + visit.getFieldAs("name") + ", here to buy " + state.lastItem + " again?");
}
}
}
}
For more information, see multiple tables.
Too many input tables degrade job performance. The number of input tables is limited to 20.
Pass-through columns
Depending on the table semantics and whether the on_time argument is defined, the framework adds extra columns to the output of the PTF.
For PTFs with set semantics, the output is prefixed with the PARTITION BY columns. For calls with the on_time argument, the output is suffixed with <rowtime>. The default output format is therefore:
<PARTITION BY keys> | <function output> | <rowtime>
You can declare the ArgumentTrait.PASS_COLUMNS_THROUGH argument to make the PTF output all columns of the input table. For example, given an input table t with columns k and v, and a PTF f() that produces columns c1 and c2, the query SELECT * FROM f(table_arg => TABLE t PARTITION BY k) has the following output schema:
Default:
| k | c1 | c2 |
With ArgumentTrait.PASS_COLUMNS_THROUGH:
| k | v | c1 | c2 |
For more information, see pass-through columns.
Currently, the ArgumentTrait.PASS_COLUMNS_THROUGH argument applies only to PTFs that take a single table argument, use no timers, and consume and produce append-only streams.
Updates and changelogs
By default, a PTF can consume and produce only append-only streams. The following arguments and interfaces enable a PTF to process update streams:
|
Argument or interface |
Description |
|
|
Allows a PTF to consume update streams |
|
|
Forces the upstream to produce a retract stream that contains complete By default, no requirement is imposed on the upstream. |
|
|
Allows a PTF to produce update streams |
The following example consumes an update stream:
@DataTypeHint("ROW<flag STRING, sum INT>")
public static class ToChangelogFunction extends ProcessTableFunction<Row> {
public void eval(@ArgumentHint({SET_SEMANTIC_TABLE, SUPPORT_UPDATES}) Row input) {
// Forwards the sum column and includes the row kind as a string column.
Row changelogRow =
Row.of(
input.getKind().toString(),
input.getField("sum"));
collect(changelogRow);
}
}
The following example produces an update stream:
@DataTypeHint("ROW<sum INT>")
public static class CustomAggregation
extends ProcessTableFunction<Row>
implements ChangelogFunction {
@Override
public ChangelogMode getChangelogMode(ChangelogContext changelogContext) {
// Tells the framework that the PTF produces a retract stream
return ChangelogMode.all();
}
public static class Accumulator {
public Integer sum = 0;
}
public void eval(
@StateHint Accumulator state,
@ArgumentHint(SET_SEMANTIC_TABLE) Row input) {
int score = input.getFieldAs("score");
// A negative state value indicates that the partition
// key was previously marked as invalid
if (state.sum == -1) {
return;
}
// A negative score invalidates the entire aggregation result.
if (score < 0) {
// Emits -D for the affected partition key and
// marks the state as invalid. All subsequent
// operations and the sink remove the aggregation result.
collect(Row.ofKind(RowKind.DELETE, state.sum));
state.sum = -1;
} else {
if (state.sum == 0) {
// Emits +I for the first valid aggregation result.
state.sum += score;
collect(Row.ofKind(RowKind.INSERT, state.sum));
} else {
// Emits -U (old aggregation result) and +U (new aggregation result)
// to encode an update.
collect(Row.ofKind(RowKind.UPDATE_BEFORE, state.sum));
state.sum += score;
collect(Row.ofKind(RowKind.UPDATE_AFTER, state.sum));
}
}
}
}
For more information, see updates and changelogs.
-
The
ArgumentTrait.SUPPORT_UPDATESargument cannot be used together with theArgumentTrait.PASS_COLUMNS_THROUGHargument. -
PTFs with an
on_timeargument do not support consuming update streams. -
Unlike the Flink community edition, the
REQUIRE_FULL_DELETEargument does not take effect for PTFs in VVR 11.9.
Register a UDPTF
For the registration procedure of a UDPTF, see Manage UDFs.
UDPTFs cannot be registered as global custom functions. Register them as job-level custom functions.
Use a UDPTF
You can call a UDPTF directly in an SQL job.
The following UDPTF counts the number of visits per user:
package com.example.ptf;
import org.apache.flink.table.annotation.ArgumentHint;
import org.apache.flink.table.annotation.ArgumentTrait;
import org.apache.flink.table.annotation.DataTypeHint;
import org.apache.flink.table.annotation.StateHint;
import org.apache.flink.table.functions.ProcessTableFunction;
import org.apache.flink.types.Row;
@DataTypeHint("ROW<visit_count BIGINT>")
public class VisitCount extends ProcessTableFunction<Row> {
public static class CountState {
public Long count;
}
public void eval(
@StateHint CountState state,
@ArgumentHint(
value = ArgumentTrait.SET_SEMANTIC_TABLE,
name = "input")
Row input) {
if (state.count == null) {
state.count = 0L;
}
state.count++;
collect(Row.of(state.count));
}
}
The UDPTF can then be used in SQL as follows:
CREATE TEMPORARY TABLE UserEvents (
user_id STRING,
event_name STRING
) WITH (
'connector' = 'datagen',
-- Limit the length of STRING fields to generate repeated user_id values
'fields.user_id.length' = '3'
);
CREATE TEMPORARY TABLE ResultSink (
user_id STRING,
visit_count BIGINT
) WITH (
'connector' = 'blackhole'
);
CREATE TEMPORARY FUNCTION VisitCount AS 'com.example.ptf.VisitCount';
INSERT INTO ResultSink
SELECT user_id, visit_count
FROM VisitCount(
input => TABLE UserEvents PARTITION BY user_id
);
After the call, VisitCount keeps state isolated per user_id. The system automatically prepends the partition columns to the function output, so the result contains user_id and visit_count.
Limits
PTFs in the Flink community are still evolving. The following limits currently apply:
-
PTFs support only streaming mode, not batch mode.
-
PTFs do not currently support broadcast state.