Java UDTFs
A user-defined table-valued function (UDTF) takes zero or more scalar values as input and returns multiple rows as output. Each returned row can have one or more columns. This differs from a scalar function, which maps input to a single output value.
Flink distinguishes between several kinds of functions:
| Function type | Behavior |
|---|---|
| Scalar function | Maps scalar values to a new scalar value |
| Table-valued function (UDTF) | Maps scalar values to new rows |
| Aggregate function | Maps scalar values from multiple rows to a new scalar value |
| Table aggregate function | Maps scalar values from multiple rows to new rows |
Use a UDTF when your logic must produce multiple output rows from a single input row — for example, splitting a delimited string into separate rows.
For background on Flink user-defined functions, see User-defined functions.
Prerequisites
Before you begin, ensure that you have:
-
Apache Maven installed on your local machine
-
IntelliJ IDEA installed on your local machine
-
A Flink SQL job environment set up in Realtime Compute for Apache Flink
Develop a UDTF
Realtime Compute for Apache Flink provides a sample project (ASI_UDX_Demo) with pre-configured development environments and example implementations for user-defined scalar functions, user-defined aggregate functions (UDAFs), and UDTFs. Access to this third-party GitHub repository may occasionally be slow or unavailable.
-
Download and decompress the ASI_UDX_Demo sample project to your local machine. After decompression, an
ASI_UDX-mainfolder is created with the following files:-
pom.xml: The Maven project configuration file, which describes the project's coordinates, dependencies, and build settings. -
\ASI_UDX-main\src\main\java\ASI_UDF\ASI_UDTF.java: The sample Java code for the UDTF.
-
-
In IntelliJ IDEA, click File > Open and open the decompressed
ASI_UDX-mainfolder. -
Open
\ASI_UDX-main\src\main\java\ASI_UDF\ASI_UDTF.javaand implement your UDTF logic. A UDTF extendsTableFunction<T>, whereTis the output type. Theeval()method must returnvoid— instead of areturnstatement, callcollect()to emit output rows. This differs from scalar functions, which usereturn. The following example splits a string into two columns using a vertical bar (|) as the delimiter:package ASI_UDTF; import org.apache.flink.api.java.tuple.Tuple2; import org.apache.flink.table.functions.TableFunction; public class ASI_UDTF extends TableFunction<Tuple2<String, String>> { public void eval(String str) { String[] split = str.split("\\|"); String name = split[0]; String place = split[1]; // emit one row with two columns collect(Tuple2.of(name, place)); } }Declaring output types Flink supports two approaches for declaring the output schema of a UDTF:
-
Row type with `@FunctionHint` and `@DataTypeHint` (recommended): Explicitly declares field names and types, making the output schema clear to Flink and to readers.
@FunctionHint(output = @DataTypeHint("ROW<word STRING, length INT>")) public static class SplitFunction extends TableFunction<Row> { public void eval(String str) { for (String s : str.split(" ")) { // use collect(...) to emit a row collect(Row.of(s, s.length())); } } } -
Tuple type (type inferred from generics): Simpler to write, but column names are not explicit.
TableFunction<Tuple2<String, Integer>>
NoteThe data types and type inference mechanism supported by
TableFunctionvary across major Flink versions. The examples above follow the Flink 1.15 documentation ( and Type inference). Check the Flink documentation that matches your Ververica Runtime (VVR) version. To find your Flink version, see Storage management and operations. -
-
Open
pom.xmlin theASI_UDX-mainfolder and configure your dependencies. The sample project includes the following Maven dependencies for Flink 1.11:<dependencies> <dependency> <groupId>org.apache.flink</groupId> <artifactId>flink-streaming-java_2.12</artifactId> <version>1.11.0</version> <!--<scope>provided</scope>--> </dependency> <dependency> <groupId>org.apache.flink</groupId> <artifactId>flink-table</artifactId> <version>1.11.0</version> <type>pom</type> <!--<scope>provided</scope>--> </dependency> <dependency> <groupId>org.apache.flink</groupId> <artifactId>flink-core</artifactId> <version>1.11.0</version> </dependency> <dependency> <groupId>org.apache.flink</groupId> <artifactId>flink-table-common</artifactId> <version>1.11.0</version> </dependency> </dependencies>If your UDTF has no additional dependencies beyond those listed, skip to the next step. Otherwise, add the required dependency entries to
pom.xml. -
In the directory that contains
pom.xml, run the following command to package the project:mvn package -Dcheckstyle.skipIf the build succeeds,
ASI_UDX-1.0-SNAPSHOT.jaris generated in the\ASI_UDX-main\target\directory. The UDTF development is complete.
Asynchronous table functions (AsyncTableFunction)
An asynchronous table function (AsyncTableFunction) accesses external services asynchronously in Flink SQL or the Table API and returns zero, one, or multiple rows for each call. It is a type of user-defined table-valued function (UDTF). Typical scenarios include querying multiple tags by product ID, or returning multiple matching records for a search condition. An asynchronous table function can be a user-defined function invoked by LATERAL TABLE in SQL or joinLateral in the Table API to form an asynchronous correlate operation. It can also be provided by a dimension table connector as the query implementation of an asynchronous lookup join.
Configuration parameters
The following parameters control the runtime behavior of asynchronous table functions.
| Parameter | Scenario | Purpose |
|---|---|---|
table.exec.async-table.max-concurrent-operations |
Async Correlate | Controls the number of concurrent requests of the asynchronous table function. |
table.exec.async-table.timeout |
Async Correlate | Sets the total wait time of a call, including the enabled retries. |
table.exec.async-table.retry-strategy |
Async Correlate | Sets the retry strategy of function calls. |
table.exec.async-lookup.buffer-capacity |
Async Lookup Join | Controls the buffer capacity of asynchronous lookups. |
table.exec.async-lookup.timeout |
Async Lookup Join | Sets the timeout of asynchronous dimension table lookups. An applicable LOOKUP hint can also specify the timeout for a single join. |
Development example
When you implement an AsyncTableFunction, declare a public, non-static eval method. The first parameter is a CompletableFuture<Collection<T>> used to submit results, and the remaining parameters are the business parameters passed in by the SQL call.
The function returns promptly after it sends the asynchronous request, and submits results in the completion callback by calling result.complete(rows), or submits an exception by calling result.completeExceptionally(error). Multiple rows of a single call are submitted as one collection. Submit an empty collection when there is no result.
package com.example.udf;
import org.apache.flink.table.functions.AsyncTableFunction;
import org.apache.flink.table.annotation.DataTypeHint;
import org.apache.flink.table.annotation.FunctionHint;
import org.apache.flink.types.Row;
import org.apache.flink.table.functions.FunctionContext;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Collection;
import java.util.Collections;
import java.util.stream.Collectors;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
/**
* Queries product tags through an asynchronous HTTP interface.
*
* <p>Interface contract: 200 returns UTF-8 text with one tag per line; 204 and 404 indicate no tags.
* This example suits read-only queries that return short text. Other status codes are treated as invocation failures and handed over to the engine.
*/
@FunctionHint(output = @DataTypeHint("ROW<tag STRING>"))
public class AsyncProductTags extends AsyncTableFunction<Row> {
private transient HttpClient client;
private transient URI serviceBaseUri;
@Override
public void open(FunctionContext context) {
String baseUrl = context.getJobParameter("product.service.base-url", null);
if (baseUrl == null || baseUrl.isBlank()) {
throw new IllegalArgumentException("Configure product.service.base-url.");
}
serviceBaseUri = URI.create(baseUrl);
if (!("https".equalsIgnoreCase(serviceBaseUri.getScheme())
|| "http".equalsIgnoreCase(serviceBaseUri.getScheme()))
|| serviceBaseUri.getHost() == null
|| serviceBaseUri.getRawQuery() != null
|| serviceBaseUri.getRawFragment() != null
|| !baseUrl.endsWith("/")) {
throw new IllegalArgumentException("The service URL must be an HTTP(S) base URL that ends with a forward slash (/).");
}
client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)).build();
}
@Override
public boolean isDeterministic() {
// Product information may change. The same ID may return different results at different times.
return false;
}
public void eval(CompletableFuture<Collection<Row>> result, Long productId) {
if (productId == null) {
result.complete(Collections.emptyList());
return;
}
try {
HttpRequest request =
HttpRequest.newBuilder(
serviceBaseUri.resolve("products/" + productId + "/tags"))
.timeout(Duration.ofSeconds(5))
.header("Accept", "text/plain")
.GET()
.build();
client.sendAsync(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8))
.thenApply(
response -> {
if (response.statusCode() == 200) {
return response.body().lines()
.map(String::strip)
.filter(tag -> !tag.isEmpty())
.map(tag -> Row.of(tag))
.collect(Collectors.toList());
}
if (response.statusCode() == 204 || response.statusCode() == 404) {
return Collections.<Row>emptyList();
}
throw new CompletionException(
new IOException(
"Failed to query the product. HTTP status code: " + response.statusCode()));
})
.whenComplete(
(rows, error) -> {
if (error != null) {
result.completeExceptionally(error);
} else {
result.complete(rows);
}
});
} catch (RuntimeException e) {
result.completeExceptionally(e);
}
}
}
HttpClient.sendAsync performs network requests asynchronously, so the function does not need to create its own thread pool. The callback only parses short text and submits results. When you use another client, prefer its native asynchronous interface as well. For the behavior of the JDK client, see HttpClient API.
Product tags may change over time, so the example sets isDeterministic() to false. For a function that always returns the same result for the same input, keep the default behavior.
Custom timeout handling
By default, if an eval call does not complete the CompletableFuture before the timeout expires and the configured retries are exhausted, the framework throws a java.util.concurrent.TimeoutException and fails the job. You can declare a timeout(...) method that matches the eval method in an AsyncTableFunction subclass to return a fallback row or implement custom timeout handling.
Timeouts are relatively more likely when calling a remote LLM cluster, and a timeout usually should not fail the job directly. With a declared timeout method, a fallback result is returned on timeout and the job continues.
The timeout method must meet all of the following conditions:
-
Method declaration: The method must be a
publicinstance method, cannot bestatic, and must be namedtimeout. -
Signature aligned with
eval: The parameter list must be strictly identical to that of the correspondingevalmethod. The first parameter is aCompletableFuturewhose generic type must be exactly the same as that ofeval, and the types and order of the remaining parameters must also be exactly the same.timeoutsupports overloading: you can declare a separatetimeoutmethod for eachevaloverload that requires fallback logic. -
Synchronous completion (mandatory): This callback runs on the mailbox thread of the operator and must complete the future before the method returns. Immediately after the method returns, the framework checks
future.isDone(). If the future is not complete, the framework forcibly completes it with anIllegalStateException. Therefore:-
Do not start another asynchronous call in the method body, such as another retry or a second query, and expect its callback to complete the future. By the time the callback is triggered, the framework has already given up on the record.
-
Do not start a new thread to complete the future asynchronously, for the same reason.
-
The method body does only one thing: provide a lightweight fallback result, such as a constant row, a NULL row, or an empty collection, or call
completeExceptionally(...)with a custom business exception.
-
-
Throwing exceptions is safe: Throwing an exception synchronously in the method body is acceptable. The framework passes the exception to the downstream
ResultFuture, which is equivalent to explicitly callingfuture.completeExceptionally(thrown).
Beyond these constraints, the framework also guarantees stable behavior in the following scenarios:
-
No
timeoutmethod declared: The defaultTimeoutExceptionof the framework is used, and no failure occurs during code generation. -
Incompatible signature: If the
timeoutmethod has valid visibility but its parameter list cannot match the actual argument types at the call site, the framework fails during planning (code generation) with aValidationException. The error message contains the fully qualified class name of the function and shows both the expected and the actual signatures. -
Empty collection as fallback: When
future.complete(Collections.emptyList())is called, the row is discarded for an INNER lookup join, and the right-table fields are filled with NULL for a LEFT OUTER lookup join.
The following example adds a timeout callback to an asynchronous table function and shows how to complete the future synchronously and return a fallback row:
import org.apache.flink.table.functions.AsyncTableFunction;
import java.util.Collection;
import java.util.Collections;
import java.util.concurrent.CompletableFuture;
public static class BackgroundFunctionWithTimeout extends AsyncTableFunction<Long> {
public void eval(CompletableFuture<Collection<Long>> future, Integer waitMax) {
// ...same logic as the preceding development example: dispatch the asynchronous task,
// and then complete `future` in the callback thread.
}
// The parameter list must align with eval(): the same CompletableFuture generic type,
// and the same types and order for the remaining parameters (Integer waitMax).
// The method body must complete `future` synchronously. Do not start another asynchronous call
// or a new thread to complete it asynchronously. When this callback is triggered,
// the operator has already given up on this record.
public void timeout(CompletableFuture<Collection<Long>> future, Integer waitMax) {
future.complete(Collections.singletonList(-1L));
// You can also use a custom business exception instead of the default TimeoutException:
// future.completeExceptionally(new RuntimeException(waitMax));
}
}
Register a UDTF
For instructions on how to register a UDTF, see Manage user-defined functions (UDFs).
Use a UDTF
After registering the UDTF, use it in a Flink SQL job.
-
Develop a Flink SQL job. For more information, see Job development map. The following SQL splits the
messagefield inASI_UDTF_Sourceinto two columns using the|delimiter, then inserts the results intoASI_UDTF_Sink. TheLATERAL TABLEsyntax cross-joins each input row with the rows emitted by the UDTF.CREATE TEMPORARY TABLE ASI_UDTF_Source ( `message` VARCHAR ) WITH ( 'connector' = 'datagen' ); CREATE TEMPORARY TABLE ASI_UDTF_Sink ( name VARCHAR, place VARCHAR ) WITH ( 'connector' = 'blackhole' ); INSERT INTO ASI_UDTF_Sink SELECT name, place FROM ASI_UDTF_Source, LATERAL TABLE(ASI_UDTF(`message`)) AS T(name, place); -
On the Operation Center > Job O&M page, find the target job and click Start in the Actions column. After the job starts, the strings in the
messagefield ofASI_UDTF_Sourceare split by the|delimiter, and the resulting columns are inserted intoASI_UDTF_Sink.