Java UDTFs

Updated at:

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

Note

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.

  1. Download and decompress the ASI_UDX_Demo sample project to your local machine. After decompression, an ASI_UDX-main folder 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.

  2. In IntelliJ IDEA, click File > Open and open the decompressed ASI_UDX-main folder.

  3. Open \ASI_UDX-main\src\main\java\ASI_UDF\ASI_UDTF.java and implement your UDTF logic. A UDTF extends TableFunction<T>, where T is the output type. The eval() method must return void — instead of a return statement, call collect() to emit output rows. This differs from scalar functions, which use return. 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>>
    Note

    The data types and type inference mechanism supported by TableFunction vary 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.

  4. Open pom.xml in the ASI_UDX-main folder 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.

  5. In the directory that contains pom.xml, run the following command to package the project:

    mvn package -Dcheckstyle.skip

    If the build succeeds, ASI_UDX-1.0-SNAPSHOT.jar is 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 public instance method, cannot be static, and must be named timeout.

  • Signature aligned with eval: The parameter list must be strictly identical to that of the corresponding eval method. The first parameter is a CompletableFuture whose generic type must be exactly the same as that of eval, and the types and order of the remaining parameters must also be exactly the same. timeout supports overloading: you can declare a separate timeout method for each eval overload 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 an IllegalStateException. 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 calling future.completeExceptionally(thrown).

Beyond these constraints, the framework also guarantees stable behavior in the following scenarios:

  • No timeout method declared: The default TimeoutException of the framework is used, and no failure occurs during code generation.

  • Incompatible signature: If the timeout method 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 a ValidationException. 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.

  1. Develop a Flink SQL job. For more information, see Job development map. The following SQL splits the message field in ASI_UDTF_Source into two columns using the | delimiter, then inserts the results into ASI_UDTF_Sink. The LATERAL TABLE syntax 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);
  2. 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 message field of ASI_UDTF_Source are split by the | delimiter, and the resulting columns are inserted into ASI_UDTF_Sink.

What's next