This is an automated email from the ASF dual-hosted git repository.
twalthr pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink.git
The following commit(s) were added to refs/heads/master by this push:
new 77c3daea10d [FLINK-40961][table] Support notifying stateful sets for
broadcast tables in PTFs
77c3daea10d is described below
commit 77c3daea10dc224d3e84916d2cf77130f5e28fe2
Author: Timo Walther <[email protected]>
AuthorDate: Tue Oct 20 16:07:07 2026 +0200
[FLINK-40961][table] Support notifying stateful sets for broadcast tables
in PTFs
This closes #29525.
---
docs/content.zh/docs/dev/table/functions/ptfs.md | 199 ++++++++-
docs/content/docs/dev/table/functions/ptfs.md | 199 ++++++++-
.../flink/table/annotation/ArgumentTrait.java | 42 +-
.../table/functions/ProcessTableFunction.java | 49 ++-
.../table/types/inference/StaticArgumentTrait.java | 3 +
.../table/types/inference/SystemTypeInference.java | 38 +-
.../extraction/TypeInferenceExtractorTest.java | 17 +
.../stream/StreamExecProcessTableFunction.java | 1 +
.../stream/ProcessTableFunctionRestoreTests.java | 7 +-
.../stream/ProcessTableFunctionSemanticTests.java | 1 +
.../stream/ProcessTableFunctionTestPrograms.java | 72 ++-
.../exec/stream/ProcessTableFunctionTestUtils.java | 118 +++++
.../plan/stream/sql/ProcessTableFunctionTest.java | 17 +
.../plan/process-notify-stateful-sets-restore.json | 482 +++++++++++++++++++++
.../savepoint/_metadata | Bin 0 -> 17281 bytes
.../runtime/generated/ProcessTableRunner.java | 71 ++-
.../process/AbstractProcessTableOperator.java | 35 +-
.../operators/process/BroadcastEvalCollector.java | 5 +
.../operators/process/BroadcastStateAdapters.java | 5 +-
.../process/PassThroughCollectorBase.java | 5 +
.../operators/process/ProcessSetTableOperator.java | 6 +-
.../operators/process/RuntimeStateInfo.java | 15 +
.../operators/process/RuntimeTableSemantics.java | 7 +
...essSetTableOperatorInterruptibleTimersTest.java | 1 +
.../process/ProcessSetTableOperatorTest.java | 424 ++++++++++++++++++
25 files changed, 1770 insertions(+), 49 deletions(-)
diff --git a/docs/content.zh/docs/dev/table/functions/ptfs.md
b/docs/content.zh/docs/dev/table/functions/ptfs.md
index 39600e8132c..ee73302776f 100644
--- a/docs/content.zh/docs/dev/table/functions/ptfs.md
+++ b/docs/content.zh/docs/dev/table/functions/ptfs.md
@@ -1471,6 +1471,91 @@ The following rules apply to broadcast tables and
broadcast state:
- It is the responsibility of the PTF implementer to maintain identical
broadcast state across all virtual processors,
i.e. broadcast state should only be updated deterministically based on the
broadcast rows.
+#### Notifying Stateful Sets
+
+By default, a change to a broadcast state entry has no effect on sets that
have been processed before. Especially when
+building a rule engine, the question arises how to re-evaluate previously
processed sets with the new broadcast
+information. For this, a broadcast table can be declared with
`ArgumentTrait.NOTIFY_STATEFUL_SETS`.
+
+For every row of such a broadcast table, `eval()` is called multiple times:
+
+1. First, without scoping to a set (as described above): This call updates the
broadcast state, if available.
+2. Next, once for every set that holds at least one state entry: In this
phase, the `eval()` implementation
+ has access to the incoming broadcast row, as well as all (keyed) state
entries.
+
+The following example deduplicates events per key. A row of the broadcast
table resets the deduplication for all keys.
+
+{{< tabs "5ddc43b1-d719-4bb6-be26-4aed9476e5d3" >}}
+{{< tab "Java" >}}
+```java
+TableEnvironment env =
TableEnvironment.create(EnvironmentSettings.inStreamingMode());
+
+env.executeSql("CREATE VIEW Events(id, payload) AS VALUES (1, 'a'), (1, 'a'),
(2, 'b')");
+env.executeSql("CREATE VIEW Resets(reason) AS VALUES ('cache invalidation')");
+
+env.createFunction("ControllableDedup", ControllableDedup.class);
+
+env
+ .executeSql("SELECT * FROM ControllableDedup(data => TABLE Events PARTITION
BY id, resets => TABLE Resets)")
+ .print();
+
+// --------------------
+// Function declaration
+// --------------------
+
+// Function that deduplicates events per key until a reset is broadcast
+public static class ControllableDedup extends ProcessTableFunction<String> {
+
+ public void eval(
+ Context ctx,
+ @StateHint ValueView<String> seen,
+ @ArgumentHint(SET_SEMANTIC_TABLE) Row data,
+ @ArgumentHint({BROADCAST_SEMANTIC_TABLE, NOTIFY_STATEFUL_SETS}) Row
resets
+ ) throws Exception {
+ // Process row from table 'Events': emit only the first payload per key
+ if (data != null) {
+ if (seen.getValue() == null) {
+ String payload = data.getFieldAs("payload");
+ seen.setValue(payload);
+ collect(payload);
+ }
+ return;
+ }
+
+ // Process row from table 'Resets' - broadcast phase:
+ // state scoped to a set is null, nothing to do
+ if (resets != null && seen == null) {
+ return;
+ }
+
+ // Process row from table 'Resets' - notification phase:
+ // state scoped to a set is present, act on notification
+ if (resets != null && seen != null) {
+ ctx.clearAllState();
+ }
+ }
+}
+```
+{{< /tab >}}
+{{< /tabs >}}
+
+The following rules apply to notifying stateful sets:
+
+- At least one state entry scoped to a set must be declared. Sets without
state entries are not notified. Registered timers alone don't count as state.
+- Sets whose state entries have expired due to [state TTL](#state-ttl) might
still be notified until the state backend
+ has cleaned them up. In this case, the state entries of the set are empty,
and writing to them during notification
+ keeps the set alive.
+- During notification, state entries of the set and timers are accessible and
broadcast state is read-only. Similar to processing a row
+ of the main table(s), the PTF can emit results via `collect()` and register
timers. Results contain the partition key
+ of the set.
+
+{{< hint warning >}}
+Notifying stateful sets is expensive. For every broadcast row, all keys in
state are loaded and a PTF's eval() is called for
+each of them. Processing of other rows and checkpointing is blocked until all
sets have been notified. Thus, this trait
+should only be used if broadcast rows are rare compared to rows of the main
table(s) and the overall key space is
+relatively low.
+{{< /hint >}}
+
### Efficiency and Design Principles
A high number of input tables can negatively impact a single TaskManager or
subtask. Network buffers must be allocated
@@ -2224,12 +2309,124 @@ for `Charly` could not be found.
+----+-------------+-------------+--------------------------------+--------------------------------+
```
+{{< top >}}
+
+### Blockable Score Aggregation
+
+The following example combines various PTF concepts for influencing the
aggregation of scores:
+- a table with set semantics,
+- broadcast table with broadcast state that notifies previous stateful sets,
+- and an updating output.
+
+The PTF sums up scores per user and emits the current sum as an upsert. A
broadcast table contains blocked
+users that are stored in broadcast state. Score events of blocked users are
ignored. If a user is blocked after being
+processed, the notification removes the user's state and sends a
`RowKind.DELETE` downstream.
+
+{{< tabs "16dccd99-2d64-415b-9f5e-9683e30e9300" >}}
+{{< tab "Java" >}}
+```java
+TableEnvironment env =
TableEnvironment.create(EnvironmentSettings.inStreamingMode());
+
+env.executeSql("CREATE VIEW ScoreEvents(name, score) AS VALUES ('Bob', 12),
('Alice', 42), ('Bob', 7), ('Alice', 3)");
+env.executeSql("CREATE VIEW BlockedUsers(name) AS VALUES ('Bob')");
+
+// The PTF emits upserts, thus, the sink declares the PARTITION BY key as
primary key
+env.executeSql(
+ "CREATE TABLE UserScores (name STRING, total INT, PRIMARY KEY (name) NOT
ENFORCED) WITH ("
+ + "'connector' = 'upsert-kafka', "
+ + "'topic' = 'user-scores', "
+ + "'properties.bootstrap.servers' = 'localhost:9092', "
+ + "'key.format' = 'json', "
+ + "'value.format' = 'json')");
+
+env.createFunction("BlockableScoreSum", BlockableScoreSum.class);
+
+env.executeSql(
+ "INSERT INTO UserScores SELECT * FROM BlockableScoreSum("
+ + "scores => TABLE ScoreEvents PARTITION BY name, "
+ + "blockedUsers => TABLE BlockedUsers)");
+
+// --------------------
+// Function declaration
+// --------------------
+
+// Function that sums up and continuously updates scores per user.
+// It removes blocked users from the result when instructed to do so from a
+// controlling BlockedUsers table.
+@DataTypeHint("ROW<total INT>")
+public static class BlockableScoreSum
+ extends ProcessTableFunction<Row>
+ implements ChangelogFunction {
+
+ @Override
+ public ChangelogMode getChangelogMode(ChangelogContext changelogContext) {
+ // Tells the system that the PTF produces upserts with deletes that
contain all fields
+ return ChangelogMode.upsert(false);
+ }
+
+ public static class UserScore {
+ public String name;
+ public int total;
+ }
+
+ public void eval(
+ Context ctx,
+ @StateHint UserScore user,
+ @StateHint(StateKind.BROADCAST) MapView<String, Boolean> blockList,
+ @ArgumentHint(SET_SEMANTIC_TABLE) Row scores,
+ @ArgumentHint({BROADCAST_SEMANTIC_TABLE, NOTIFY_STATEFUL_SETS}) Row
blockedUsers
+ ) throws Exception {
+ // Process row from table 'ScoreEvents': ignore blocked users, otherwise
emit the current sum
+ if (scores != null) {
+ String name = scores.getFieldAs("name");
+ if (blockList.contains(name)) {
+ return;
+ }
+ RowKind kind = user.name == null ? RowKind.INSERT : RowKind.UPDATE_AFTER;
+ user.name = name;
+ user.total += scores.<Integer>getFieldAs("score");
+ collect(Row.ofKind(kind, user.total));
+ return;
+ }
+
+ String blockedName = blockedUsers.getFieldAs("name");
+
+ // Process row from table 'BlockedUsers' - broadcast phase:
+ // state scoped to a set is null, store the blocked user
+ if (user == null) {
+ blockList.put(blockedName, true);
+ return;
+ }
+
+ // Process row from table 'BlockedUsers' - notification phase:
+ // state scoped to a set is present, remove the blocked user from the
result and from state
+ if (blockedName.equals(user.name)) {
+ collect(Row.ofKind(RowKind.DELETE, user.total));
+ ctx.clearAllState();
+ }
+ }
+}
+```
+{{< /tab >}}
+{{< /tabs >}}
+
+If `Bob` is blocked after his first score event has been processed, the sink
receives the following changelog:
+
+```text
++I[Bob, 12]
+-D[Bob, 12]
++I[Alice, 42]
++U[Alice, 45]
+```
+
+If the broadcast row is processed before any score event of `Bob`, `Bob` never
appears in the changelog. In both cases,
+the materialized result only contains `Alice` with a total of 45.
+
Limitations
-----------
PTFs are in an early stage. The following limitations apply:
- PTFs cannot run in batch mode.
-- Broadcast state
Testing Process Table Functions
-------------------------------
diff --git a/docs/content/docs/dev/table/functions/ptfs.md
b/docs/content/docs/dev/table/functions/ptfs.md
index 9210292c6dd..63d47252401 100644
--- a/docs/content/docs/dev/table/functions/ptfs.md
+++ b/docs/content/docs/dev/table/functions/ptfs.md
@@ -1474,6 +1474,91 @@ The following rules apply to broadcast tables and
broadcast state:
- It is the responsibility of the PTF implementer to maintain identical
broadcast state across all virtual processors,
i.e. broadcast state should only be updated deterministically based on the
broadcast rows.
+#### Notifying Stateful Sets
+
+By default, a change to a broadcast state entry has no effect on sets that
have been processed before. Especially when
+building a rule engine, the question arises how to re-evaluate previously
processed sets with the new broadcast
+information. For this, a broadcast table can be declared with
`ArgumentTrait.NOTIFY_STATEFUL_SETS`.
+
+For every row of such a broadcast table, `eval()` is called multiple times:
+
+1. First, without scoping to a set (as described above): This call updates the
broadcast state, if available.
+2. Next, once for every set that holds at least one state entry: In this
phase, the `eval()` implementation
+ has access to the incoming broadcast row, as well as all (keyed) state
entries.
+
+The following example deduplicates events per key. A row of the broadcast
table resets the deduplication for all keys.
+
+{{< tabs "5ddc43b1-d719-4bb6-be26-4aed9476e5d3" >}}
+{{< tab "Java" >}}
+```java
+TableEnvironment env =
TableEnvironment.create(EnvironmentSettings.inStreamingMode());
+
+env.executeSql("CREATE VIEW Events(id, payload) AS VALUES (1, 'a'), (1, 'a'),
(2, 'b')");
+env.executeSql("CREATE VIEW Resets(reason) AS VALUES ('cache invalidation')");
+
+env.createFunction("ControllableDedup", ControllableDedup.class);
+
+env
+ .executeSql("SELECT * FROM ControllableDedup(data => TABLE Events PARTITION
BY id, resets => TABLE Resets)")
+ .print();
+
+// --------------------
+// Function declaration
+// --------------------
+
+// Function that deduplicates events per key until a reset is broadcast
+public static class ControllableDedup extends ProcessTableFunction<String> {
+
+ public void eval(
+ Context ctx,
+ @StateHint ValueView<String> seen,
+ @ArgumentHint(SET_SEMANTIC_TABLE) Row data,
+ @ArgumentHint({BROADCAST_SEMANTIC_TABLE, NOTIFY_STATEFUL_SETS}) Row
resets
+ ) throws Exception {
+ // Process row from table 'Events': emit only the first payload per key
+ if (data != null) {
+ if (seen.getValue() == null) {
+ String payload = data.getFieldAs("payload");
+ seen.setValue(payload);
+ collect(payload);
+ }
+ return;
+ }
+
+ // Process row from table 'Resets' - broadcast phase:
+ // state scoped to a set is null, nothing to do
+ if (resets != null && seen == null) {
+ return;
+ }
+
+ // Process row from table 'Resets' - notification phase:
+ // state scoped to a set is present, act on notification
+ if (resets != null && seen != null) {
+ ctx.clearAllState();
+ }
+ }
+}
+```
+{{< /tab >}}
+{{< /tabs >}}
+
+The following rules apply to notifying stateful sets:
+
+- At least one state entry scoped to a set must be declared. Sets without
state entries are not notified. Registered timers alone don't count as state.
+- Sets whose state entries have expired due to [state TTL](#state-ttl) might
still be notified until the state backend
+ has cleaned them up. In this case, the state entries of the set are empty,
and writing to them during notification
+ keeps the set alive.
+- During notification, state entries of the set and timers are accessible and
broadcast state is read-only. Similar to processing a row
+ of the main table(s), the PTF can emit results via `collect()` and register
timers. Results contain the partition key
+ of the set.
+
+{{< hint warning >}}
+Notifying stateful sets is expensive. For every broadcast row, all keys in
state are loaded and a PTF's eval() is called for
+each of them. Processing of other rows and checkpointing is blocked until all
sets have been notified. Thus, this trait
+should only be used if broadcast rows are rare compared to rows of the main
table(s) and the overall key space is
+relatively low.
+{{< /hint >}}
+
### Efficiency and Design Principles
A high number of input tables can negatively impact a single TaskManager or
subtask. Network buffers must be allocated
@@ -2227,12 +2312,124 @@ for `Charly` could not be found.
+----+-------------+-------------+--------------------------------+--------------------------------+
```
+{{< top >}}
+
+### Blockable Score Aggregation
+
+The following example combines various PTF concepts for influencing the
aggregation of scores:
+- a table with set semantics,
+- broadcast table with broadcast state that notifies previous stateful sets,
+- and an updating output.
+
+The PTF sums up scores per user and emits the current sum as an upsert. A
broadcast table contains blocked
+users that are stored in broadcast state. Score events of blocked users are
ignored. If a user is blocked after being
+processed, the notification removes the user's state and sends a
`RowKind.DELETE` downstream.
+
+{{< tabs "16dccd99-2d64-415b-9f5e-9683e30e9300" >}}
+{{< tab "Java" >}}
+```java
+TableEnvironment env =
TableEnvironment.create(EnvironmentSettings.inStreamingMode());
+
+env.executeSql("CREATE VIEW ScoreEvents(name, score) AS VALUES ('Bob', 12),
('Alice', 42), ('Bob', 7), ('Alice', 3)");
+env.executeSql("CREATE VIEW BlockedUsers(name) AS VALUES ('Bob')");
+
+// The PTF emits upserts, thus, the sink declares the PARTITION BY key as
primary key
+env.executeSql(
+ "CREATE TABLE UserScores (name STRING, total INT, PRIMARY KEY (name) NOT
ENFORCED) WITH ("
+ + "'connector' = 'upsert-kafka', "
+ + "'topic' = 'user-scores', "
+ + "'properties.bootstrap.servers' = 'localhost:9092', "
+ + "'key.format' = 'json', "
+ + "'value.format' = 'json')");
+
+env.createFunction("BlockableScoreSum", BlockableScoreSum.class);
+
+env.executeSql(
+ "INSERT INTO UserScores SELECT * FROM BlockableScoreSum("
+ + "scores => TABLE ScoreEvents PARTITION BY name, "
+ + "blockedUsers => TABLE BlockedUsers)");
+
+// --------------------
+// Function declaration
+// --------------------
+
+// Function that sums up and continuously updates scores per user.
+// It removes blocked users from the result when instructed to do so from a
+// controlling BlockedUsers table.
+@DataTypeHint("ROW<total INT>")
+public static class BlockableScoreSum
+ extends ProcessTableFunction<Row>
+ implements ChangelogFunction {
+
+ @Override
+ public ChangelogMode getChangelogMode(ChangelogContext changelogContext) {
+ // Tells the system that the PTF produces upserts with deletes that
contain all fields
+ return ChangelogMode.upsert(false);
+ }
+
+ public static class UserScore {
+ public String name;
+ public int total;
+ }
+
+ public void eval(
+ Context ctx,
+ @StateHint UserScore user,
+ @StateHint(StateKind.BROADCAST) MapView<String, Boolean> blockList,
+ @ArgumentHint(SET_SEMANTIC_TABLE) Row scores,
+ @ArgumentHint({BROADCAST_SEMANTIC_TABLE, NOTIFY_STATEFUL_SETS}) Row
blockedUsers
+ ) throws Exception {
+ // Process row from table 'ScoreEvents': ignore blocked users, otherwise
emit the current sum
+ if (scores != null) {
+ String name = scores.getFieldAs("name");
+ if (blockList.contains(name)) {
+ return;
+ }
+ RowKind kind = user.name == null ? RowKind.INSERT : RowKind.UPDATE_AFTER;
+ user.name = name;
+ user.total += scores.<Integer>getFieldAs("score");
+ collect(Row.ofKind(kind, user.total));
+ return;
+ }
+
+ String blockedName = blockedUsers.getFieldAs("name");
+
+ // Process row from table 'BlockedUsers' - broadcast phase:
+ // state scoped to a set is null, store the blocked user
+ if (user == null) {
+ blockList.put(blockedName, true);
+ return;
+ }
+
+ // Process row from table 'BlockedUsers' - notification phase:
+ // state scoped to a set is present, remove the blocked user from the
result and from state
+ if (blockedName.equals(user.name)) {
+ collect(Row.ofKind(RowKind.DELETE, user.total));
+ ctx.clearAllState();
+ }
+ }
+}
+```
+{{< /tab >}}
+{{< /tabs >}}
+
+If `Bob` is blocked after his first score event has been processed, the sink
receives the following changelog:
+
+```text
++I[Bob, 12]
+-D[Bob, 12]
++I[Alice, 42]
++U[Alice, 45]
+```
+
+If the broadcast row is processed before any score event of `Bob`, `Bob` never
appears in the changelog. In both cases,
+the materialized result only contains `Alice` with a total of 45.
+
Limitations
-----------
PTFs are in an early stage. The following limitations apply:
- PTFs cannot run in batch mode.
-- Broadcast state
Testing Process Table Functions
-------------------------------
diff --git
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/annotation/ArgumentTrait.java
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/annotation/ArgumentTrait.java
index dd94778624f..b9641e60fc9 100644
---
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/annotation/ArgumentTrait.java
+++
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/annotation/ArgumentTrait.java
@@ -93,8 +93,10 @@ public enum ArgumentTrait {
* state entries. State entries that are scoped to a set are passed as
null. The PTF must not
* emit results via {@code collect()} or register/clear timers.
*
- * <p>A change to a broadcast state entry has no effect on rows of the
main table(s) that have
- * been processed before. Rows of different tables are not processed in a
deterministic order.
+ * <p>By default, a change to a broadcast state entry has no effect on
rows of the main table(s)
+ * that have been processed before. Rows of different tables are not
processed in a
+ * deterministic order. Use {@link #NOTIFY_STATEFUL_SETS} to re-evaluate
all stateful sets based
+ * on the broadcast information.
*
* <p>A signature may declare multiple broadcast table arguments, but must
declare at least one
* {@link #ROW_SEMANTIC_TABLE} or {@link #SET_SEMANTIC_TABLE} argument
next to them.
@@ -109,6 +111,42 @@ public enum ArgumentTrait {
*/
OPTIONAL_PARTITION_BY(false, StaticArgumentTrait.OPTIONAL_PARTITION_BY),
+ /**
+ * Notifies all existing stateful sets to re-evaluate based on the given
broadcast row and the
+ * potentially updated broadcast state entries.
+ *
+ * <p>For every broadcast row, {@code eval()} is called multiple times:
+ *
+ * <ol>
+ * <li>First, without scoping to a set (as described in {@link
#BROADCAST_SEMANTIC_TABLE}):
+ * This call updates the broadcast state, if available.
+ * <li>Next, once for every set that holds at least one state entry: In
this phase, the {@code
+ * eval()} implementation has access to the incoming broadcast row,
as well as all (keyed)
+ * state entries.
+ * </ol>
+ *
+ * <p>At least one state entry scoped to a set must be declared. Sets
without state entries are
+ * not notified. Registered timers alone don't count as state. Sets whose
state entries have
+ * expired due to a time-to-live (TTL) might still be notified until the
state backend has
+ * cleaned them up. In this case, the state entries of the set are empty,
and writing to them
+ * during notification keeps the set alive.
+ *
+ * <p>During notification, state entries of the set and timers are
accessible and broadcast
+ * state is read-only. Similar to processing a row of the main table(s),
the PTF can emit
+ * results via {@code collect()} and register timers. Results contain the
partition key of the
+ * set.
+ *
+ * <p>Note: Notifying stateful sets is expensive. For every broadcast row,
all keys in state are
+ * loaded and a PTF's {@code eval()} is called for each of them.
Processing of other rows and
+ * checkpointing is blocked until all sets have been notified. Thus, this
trait should only be
+ * used if broadcast rows are rare compared to rows of the main table(s)
and the overall key
+ * space is relatively low.
+ *
+ * <p>Note: This trait is only valid for {@link #BROADCAST_SEMANTIC_TABLE}
arguments and
+ * requires at least one {@link #SET_SEMANTIC_TABLE} argument.
+ */
+ NOTIFY_STATEFUL_SETS(false, StaticArgumentTrait.NOTIFY_STATEFUL_SETS),
+
/**
* Defines that all columns of a table argument (i.e. {@link
#ROW_SEMANTIC_TABLE} or {@link
* #SET_SEMANTIC_TABLE}) are included in the output of the PTF. By
default, only columns of the
diff --git
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/ProcessTableFunction.java
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/ProcessTableFunction.java
index 4fca7bbfd8d..89f7cc1915c 100644
---
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/ProcessTableFunction.java
+++
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/ProcessTableFunction.java
@@ -401,27 +401,6 @@ import java.time.LocalDateTime;
* {@code @StateHint(StateKind.BROADCAST)}. This broadcast state is shared
across all sets and can
* be read when processing rows from the main table(s).
*
- * <p>The following rules apply to broadcast tables and broadcast state:
- *
- * <ul>
- * <li>At least one table argument with row or set semantics must be
declared next to broadcast
- * tables. Multiple broadcast tables and multiple broadcast state
entries are supported.
- * <li>Broadcast state can only be modified while processing a broadcast
row. This includes
- * clearing broadcast state. When processing rows of the main table(s),
broadcast state is
- * read-only.
- * <li>While processing a broadcast row, there is no key context. State
entries that are scoped to
- * a set are passed as null, results can not be emitted via {@code
collect()}, and timers
- * cannot be registered or cleared. {@link Context#clearAllState()} only
clears broadcast
- * state in this case.
- * <li>It is the responsibility of the PTF implementer to maintain identical
broadcast state
- * across all virtual processors, i.e. broadcast state should only be
updated
- * deterministically based on the broadcast rows.
- * </ul>
- *
- * <p>Note: The system decides which input row is streamed through the virtual
processor next. A
- * change to a broadcast state entry has no effect on rows of the main
table(s) that have been
- * processed before.
- *
* <pre>{@code
* // Function that filters sentences using a dynamically updated list of bad
words
* class RuleFunction extends ProcessTableFunction<String> {
@@ -446,6 +425,34 @@ import java.time.LocalDateTime;
* }
* }</pre>
*
+ * <p>The following rules apply to broadcast tables and broadcast state:
+ *
+ * <ul>
+ * <li>At least one table argument with row or set semantics must be
declared next to broadcast
+ * tables. Multiple broadcast tables and multiple broadcast state
entries are supported.
+ * <li>Broadcast state can only be modified while processing a broadcast
row. This includes
+ * clearing broadcast state. When processing rows of the main table(s),
broadcast state is
+ * read-only.
+ * <li>While processing a broadcast row, there is no key context. State
entries that are scoped to
+ * a set are passed as null, results can not be emitted via {@code
collect()}, and timers
+ * cannot be registered or cleared. {@link Context#clearAllState()} only
clears broadcast
+ * state in this case.
+ * <li>It is the responsibility of the PTF implementer to maintain identical
broadcast state
+ * across all virtual processors, i.e. broadcast state should only be
updated
+ * deterministically based on the broadcast rows.
+ * </ul>
+ *
+ * <p>Note: The system decides which input row is streamed through the virtual
processor next. A
+ * change to a broadcast state entry has no effect on rows of the main
table(s) that have been
+ * processed before.
+ *
+ * <p>Use {@link ArgumentTrait#NOTIFY_STATEFUL_SETS} to re-evaluate previously
processed sets. For
+ * every broadcast row, {@code eval()} is then called multiple times: First,
without scoping to a
+ * set to update the broadcast state, if available. Next, once for every set
that holds at least one
+ * state entry: In this phase, the {@code eval()} implementation has access to
the incoming
+ * broadcast row, as well as all (keyed) state entries. Notifying stateful
sets is expensive and
+ * should only be used if broadcast rows are rare and the overall key space is
relatively low.
+ *
* <h1>Time and Timers</h1>
*
* <p>A PTF supports event time natively. Time-based services are available
via {@link
diff --git
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/StaticArgumentTrait.java
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/StaticArgumentTrait.java
index 19fe3aea6df..ee495993ea8 100644
---
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/StaticArgumentTrait.java
+++
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/StaticArgumentTrait.java
@@ -50,6 +50,9 @@ public enum StaticArgumentTrait {
// For SET_SEMANTIC_TABLE
OPTIONAL_PARTITION_BY(SET_SEMANTIC_TABLE),
+ // For BROADCAST_SEMANTIC_TABLE
+ NOTIFY_STATEFUL_SETS(BROADCAST_SEMANTIC_TABLE),
+
// For SUPPORT_UPDATES
REQUIRE_UPDATE_BEFORE(SUPPORT_UPDATES),
REQUIRE_FULL_DELETE(SUPPORT_UPDATES);
diff --git
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/SystemTypeInference.java
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/SystemTypeInference.java
index a7b7d6f5837..3e72cdc280f 100644
---
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/SystemTypeInference.java
+++
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/SystemTypeInference.java
@@ -113,7 +113,7 @@ public class SystemTypeInference {
origin.getInputTypeStrategy(),
origin.disableSystemArguments()));
if (functionKind == FunctionKind.PROCESS_TABLE) {
- checkBroadcastState(systemArgs, origin.getStateTypeStrategies());
+ checkBroadcastTraits(systemArgs, origin.getStateTypeStrategies());
}
builder.stateTypeStrategies(origin.getStateTypeStrategies());
builder.outputTypeStrategy(
@@ -277,17 +277,17 @@ public class SystemTypeInference {
}
}
+ private static void checkBroadcastTraits(
+ List<StaticArgument> staticArgs, Map<String, StateTypeStrategy>
stateTypeStrategies) {
+ checkBroadcastState(staticArgs, stateTypeStrategies);
+ checkNotifyStatefulSets(staticArgs);
+ }
+
private static void checkBroadcastState(
- @Nullable List<StaticArgument> staticArgs,
- Map<String, StateTypeStrategy> stateTypeStrategies) {
+ List<StaticArgument> staticArgs, Map<String, StateTypeStrategy>
stateTypeStrategies) {
final boolean hasBroadcastTables =
- staticArgs != null
- && staticArgs.stream()
- .anyMatch(
- arg ->
- arg.is(
- StaticArgumentTrait
-
.BROADCAST_SEMANTIC_TABLE));
+ staticArgs.stream()
+ .anyMatch(arg ->
arg.is(StaticArgumentTrait.BROADCAST_SEMANTIC_TABLE));
if (hasBroadcastTables) {
return;
}
@@ -304,6 +304,24 @@ public class SystemTypeInference {
});
}
+ private static void checkNotifyStatefulSets(List<StaticArgument>
staticArgs) {
+ if (staticArgs.stream().anyMatch(arg ->
arg.is(StaticArgumentTrait.SET_SEMANTIC_TABLE))) {
+ return;
+ }
+ staticArgs.stream()
+ .filter(arg ->
arg.is(StaticArgumentTrait.NOTIFY_STATEFUL_SETS))
+ .findFirst()
+ .ifPresent(
+ arg -> {
+ throw new ValidationException(
+ String.format(
+ "Table argument '%s' notifies
stateful sets which "
+ + "requires at least one
table argument with "
+ + "set semantics.",
+ arg.getName()));
+ });
+ }
+
private static void checkPassThroughColumns(List<StaticArgument>
staticArgs) {
final Set<StaticArgumentTrait> traits =
staticArgs.stream()
diff --git
a/flink-table/flink-table-common/src/test/java/org/apache/flink/table/types/extraction/TypeInferenceExtractorTest.java
b/flink-table/flink-table-common/src/test/java/org/apache/flink/table/types/extraction/TypeInferenceExtractorTest.java
index 2fb20c161b6..ef611150c9e 100644
---
a/flink-table/flink-table-common/src/test/java/org/apache/flink/table/types/extraction/TypeInferenceExtractorTest.java
+++
b/flink-table/flink-table-common/src/test/java/org/apache/flink/table/types/extraction/TypeInferenceExtractorTest.java
@@ -905,6 +905,12 @@ class TypeInferenceExtractorTest {
.expectErrorMessage(
"Invalid argument traits for argument 'rules'.
"
+ "Trait BROADCAST_SEMANTIC_TABLE is
incompatible with PASS_COLUMNS_THROUGH."),
+ // ---
+ TestSpec.forProcessTableFunction(
+
NotifyStatefulSetsWithoutBroadcastProcessTableFunction.class)
+ .expectErrorMessage(
+ "Invalid argument traits for argument 'data'. "
+ + "Trait NOTIFY_STATEFUL_SETS requires
BROADCAST_SEMANTIC_TABLE."),
TestSpec.forScalarFunction("Bitmap in scalar function",
BitmapTypeFunction.class)
.expectStaticArgument(
StaticArgument.scalar("bm",
DataTypes.BITMAP(), false))
@@ -2761,6 +2767,17 @@ class TypeInferenceExtractorTest {
@ArgumentHint(ArgumentTrait.SET_SEMANTIC_TABLE) Row data) {}
}
+ private static class NotifyStatefulSetsWithoutBroadcastProcessTableFunction
+ extends ProcessTableFunction<Integer> {
+ public void eval(
+ @StateHint MyFirstState count,
+ @ArgumentHint({
+ ArgumentTrait.SET_SEMANTIC_TABLE,
+ ArgumentTrait.NOTIFY_STATEFUL_SETS
+ })
+ Row data) {}
+ }
+
private static class PassThroughBroadcastTableProcessTableFunction
extends ProcessTableFunction<Integer> {
public void eval(
diff --git
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/nodes/exec/stream/StreamExecProcessTableFunction.java
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/nodes/exec/stream/StreamExecProcessTableFunction.java
index 657f2e15cd6..e7cada6d076 100644
---
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/nodes/exec/stream/StreamExecProcessTableFunction.java
+++
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/nodes/exec/stream/StreamExecProcessTableFunction.java
@@ -344,6 +344,7 @@ public class StreamExecProcessTableFunction extends
ExecNodeBase<RowData>
tableArg.is(StaticArgumentTrait.PASS_COLUMNS_THROUGH),
tableArg.is(StaticArgumentTrait.SET_SEMANTIC_TABLE),
tableArg.is(StaticArgumentTrait.BROADCAST_SEMANTIC_TABLE),
+ tableArg.is(StaticArgumentTrait.NOTIFY_STATEFUL_SETS),
timeColumn,
upsertKeys);
}
diff --git
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/ProcessTableFunctionRestoreTests.java
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/ProcessTableFunctionRestoreTests.java
index a2d5cfa00ab..906ff0a6456 100644
---
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/ProcessTableFunctionRestoreTests.java
+++
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/ProcessTableFunctionRestoreTests.java
@@ -49,13 +49,16 @@ public class ProcessTableFunctionRestoreTests extends
RestoreTestBase {
ProcessTableFunctionTestPrograms.PROCESS_ORDER_BY_RESTORE,
ProcessTableFunctionTestPrograms.PROCESS_MULTI_INPUT_ORDER_BY_RESTORE,
ProcessTableFunctionTestPrograms.PROCESS_LATE_EVENTS_RESTORE,
-
ProcessTableFunctionTestPrograms.PROCESS_BROADCAST_STATE_RESTORE);
+
ProcessTableFunctionTestPrograms.PROCESS_BROADCAST_STATE_RESTORE,
+
ProcessTableFunctionTestPrograms.PROCESS_NOTIFY_STATEFUL_SETS_RESTORE);
}
@Override
protected void awaitSavepointReady(TableTestProgram program,
List<CompletableFuture<?>> futures)
throws Exception {
- if (program !=
ProcessTableFunctionTestPrograms.PROCESS_BROADCAST_STATE_RESTORE) {
+ if (program !=
ProcessTableFunctionTestPrograms.PROCESS_BROADCAST_STATE_RESTORE
+ && program
+ !=
ProcessTableFunctionTestPrograms.PROCESS_NOTIFY_STATEFUL_SETS_RESTORE) {
super.awaitSavepointReady(program, futures);
return;
}
diff --git
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/ProcessTableFunctionSemanticTests.java
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/ProcessTableFunctionSemanticTests.java
index bbf6a8644c2..1faf06beb60 100644
---
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/ProcessTableFunctionSemanticTests.java
+++
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/ProcessTableFunctionSemanticTests.java
@@ -102,6 +102,7 @@ public class ProcessTableFunctionSemanticTests extends
SemanticTestBase {
ProcessTableFunctionTestPrograms.PROCESS_BROADCAST_ON_TIME,
ProcessTableFunctionTestPrograms.PROCESS_BROADCAST_CLEAR_STATE,
ProcessTableFunctionTestPrograms.PROCESS_BROADCAST_MULTI_INPUT,
+ ProcessTableFunctionTestPrograms.PROCESS_NOTIFY_STATEFUL_SETS,
ProcessTableFunctionTestPrograms.PROCESS_INVALID_BROADCAST_COLLECT,
ProcessTableFunctionTestPrograms.PROCESS_INVALID_BROADCAST_TIMER,
ProcessTableFunctionTestPrograms.PROCESS_INVALID_BROADCAST_STATE_WRITE,
diff --git
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/ProcessTableFunctionTestPrograms.java
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/ProcessTableFunctionTestPrograms.java
index 9c7ebe85980..d2512eb0e54 100644
---
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/ProcessTableFunctionTestPrograms.java
+++
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/ProcessTableFunctionTestPrograms.java
@@ -57,6 +57,8 @@ import
org.apache.flink.table.planner.plan.nodes.exec.stream.ProcessTableFunctio
import
org.apache.flink.table.planner.plan.nodes.exec.stream.ProcessTableFunctionTestUtils.MultiStateFunction;
import
org.apache.flink.table.planner.plan.nodes.exec.stream.ProcessTableFunctionTestUtils.NamedTimersFunction;
import
org.apache.flink.table.planner.plan.nodes.exec.stream.ProcessTableFunctionTestUtils.NonNullMapStateFunction;
+import
org.apache.flink.table.planner.plan.nodes.exec.stream.ProcessTableFunctionTestUtils.NotifyRestoredSetFunction;
+import
org.apache.flink.table.planner.plan.nodes.exec.stream.ProcessTableFunctionTestUtils.NotifyStatefulSetsRulesFunction;
import
org.apache.flink.table.planner.plan.nodes.exec.stream.ProcessTableFunctionTestUtils.OptionalOnTimeFunction;
import
org.apache.flink.table.planner.plan.nodes.exec.stream.ProcessTableFunctionTestUtils.OptionalPartitionOnTimeFunction;
import
org.apache.flink.table.planner.plan.nodes.exec.stream.ProcessTableFunctionTestUtils.OrderByFunction;
@@ -1852,6 +1854,71 @@ public class ProcessTableFunctionTestPrograms {
"INSERT INTO sink SELECT * FROM f(input => TABLE t
PARTITION BY name, rule => TABLE rules)")
.build();
+ public static final TableTestProgram PROCESS_NOTIFY_STATEFUL_SETS =
+ TableTestProgram.of(
+ "process-notify-stateful-sets",
+ "broadcast rows are applied to new sets or notify
existing sets")
+ .setupTemporarySystemFunction("f",
NotifyStatefulSetsRulesFunction.class)
+ .setupTableSource(TIMED_SOURCE)
+ .setupTableSource(TIMED_BROADCAST_RULES_SOURCE)
+ .setupTableSink(
+ SinkTestStep.newBuilder("sink")
+ .addSchema(KEYED_TIMED_BASE_SINK_SCHEMA)
+ .consumedValues(
+ // Rule Eve triggers the release
of Dave to all keys
+ "+I[Bob, {{Dave=2}},
1970-01-01T00:00:00.003Z]",
+ "+I[Alice, {{Dave=2}},
1970-01-01T00:00:00.003Z]",
+ // 6 Bob events processed with all
rules broadcasted and
+ // applied
+ "+I[Bob, {6, {Dave=2, Eve=5},
{Dave=2, Eve=5}}, 1970-01-01T00:00:01Z]",
+ // 1 Alice event processed with
all rules broadcasted
+ // and applied
+ "+I[Alice, {1, {Dave=2, Eve=5},
{Dave=2, Eve=5}}, 1970-01-01T00:00:01Z]")
+ .build())
+ .runSql(
+ "INSERT INTO sink SELECT * FROM f("
+ + "input => TABLE t PARTITION BY name, "
+ + "rule => TABLE rules, "
+ + "on_time => DESCRIPTOR(ts))")
+ .build();
+
+ public static final TableTestProgram PROCESS_NOTIFY_STATEFUL_SETS_RESTORE =
+ TableTestProgram.of(
+ "process-notify-stateful-sets-restore",
+ "broadcast rows notify sets that have been
restored")
+ .setupTemporarySystemFunction("f",
NotifyRestoredSetFunction.class)
+ .setupTableSource(BROADCAST_RESTORE_SOURCE)
+ .setupTableSource(
+ SourceTestStep.newBuilder("rules")
+ .addSchema(
+ "name STRING",
+ "weight INT",
+ "ts TIMESTAMP_LTZ(3)",
+ "WATERMARK FOR ts AS ts")
+ .producedBeforeRestore(
+ Row.of("Dave", 3,
Instant.ofEpochMilli(0)))
+ // The rule after restore targets the
restored set
+ .producedAfterRestore(Row.of("Bob", 4,
Instant.ofEpochMilli(4)))
+ .build())
+ .setupTableSink(
+ SinkTestStep.newBuilder("sink")
+ .addSchema(KEYED_TIMED_BASE_SINK_SCHEMA)
+ .consumedBeforeRestore(
+ "+I[Bob, {+I[Bob, -1,
1970-01-01T00:00:00Z]}, 1970-01-01T00:00:00Z]")
+ .consumedAfterRestore(
+ "+I[Bob, {+I[Bob, 99,
1970-01-01T00:00:00.001Z]}, 1970-01-01T00:00:00.001Z]",
+ "+I[Alice, {+I[Alice, 42,
1970-01-01T00:00:00.002Z]}, 1970-01-01T00:00:00.002Z]",
+ "+I[Charly, {+I[Charly, 7,
1970-01-01T00:00:00.003Z]}, 1970-01-01T00:00:00.003Z]",
+ // Notification carries the
rowtime of the broadcast row
+ "+I[Bob, {+I[Bob, 4,
1970-01-01T00:00:00.004Z], {Bob=4, Dave=3}}, 1970-01-01T00:00:00.004Z]")
+ .build())
+ .runSql(
+ "INSERT INTO sink SELECT * FROM f("
+ + "input => TABLE t PARTITION BY name, "
+ + "rule => TABLE rules, "
+ + "on_time => DESCRIPTOR(ts))")
+ .build();
+
public static final TableTestProgram PROCESS_INVALID_BROADCAST_COLLECT =
TableTestProgram.of(
"process-invalid-broadcast-collect",
@@ -1889,8 +1956,9 @@ public class ProcessTableFunctionTestPrograms {
.runFailingSql(
"SELECT * FROM f(input => TABLE t PARTITION BY
name, rule => TABLE rules)",
TableRuntimeException.class,
- "Broadcast state entry 'weights' is read-only
while processing a table "
- + "with row or set semantics.")
+ "Broadcast state entry 'weights' is read-only. It
can only be modified while "
+ + "processing a row of a table with
broadcast semantics, not while "
+ + "processing other tables, firing timers,
or notifying stateful sets.")
.build();
public static final TableTestProgram PROCESS_MULTI_INPUT =
diff --git
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/ProcessTableFunctionTestUtils.java
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/ProcessTableFunctionTestUtils.java
index 1e5dad159c3..1122c322a0e 100644
---
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/ProcessTableFunctionTestUtils.java
+++
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/ProcessTableFunctionTestUtils.java
@@ -62,6 +62,7 @@ import java.util.Optional;
import java.util.stream.Collectors;
import static
org.apache.flink.table.annotation.ArgumentTrait.BROADCAST_SEMANTIC_TABLE;
+import static
org.apache.flink.table.annotation.ArgumentTrait.NOTIFY_STATEFUL_SETS;
import static
org.apache.flink.table.annotation.ArgumentTrait.OPTIONAL_PARTITION_BY;
import static
org.apache.flink.table.annotation.ArgumentTrait.PASS_COLUMNS_THROUGH;
import static
org.apache.flink.table.annotation.ArgumentTrait.REQUIRE_FULL_DELETE;
@@ -1492,6 +1493,123 @@ public class ProcessTableFunctionTestUtils {
}
}
+ /**
+ * Testing function that applies every broadcast rule exactly once to
every set. Depending on
+ * the order in which tables are processed, a rule is applied when a set
is created or when an
+ * existing set is notified. Timers act as synchronization barriers as
they only fire after all
+ * broadcast rows up to the timer's time have been processed.
+ */
+ public static class NotifyStatefulSetsRulesFunction extends
AppendProcessTableFunctionBase {
+ @SuppressWarnings("unused")
+ public void eval(
+ Context ctx,
+ @StateHint ValueView<Integer> count,
+ @StateHint MapView<String, Long> appliedRules,
+ @StateHint(StateKind.BROADCAST) MapView<String, Long> allRules,
+ @ArgumentHint(SET_SEMANTIC_TABLE) Row input,
+ @ArgumentHint({BROADCAST_SEMANTIC_TABLE,
NOTIFY_STATEFUL_SETS}) Row rule)
+ throws Exception {
+ final TimeContext<Long> timeCtx = ctx.timeContext(Long.class);
+
+ // Process row from table 't'
+ if (input != null) {
+ if (count.getValue() == null) {
+ // New set: catch up with all rules received so far
+ for (Map.Entry<String, Long> r : allRules.entries()) {
+ appliedRules.put(r.getKey(), r.getValue());
+ }
+ // Fires once all rules up to this time have been processed
+ // (in other words: when Eve triggers the release of Dave
to all keys)
+ timeCtx.registerOnTime("middle", 3L);
+ // Exceeds all input timestamps, thus, only fires at the
end of all tables
+ timeCtx.registerOnTime("end", 1000L);
+ count.setValue(1);
+ } else {
+ count.setValue(count.getValue() + 1);
+ }
+ return;
+ }
+
+ final String ruleName = rule.getFieldAs("name");
+ final long ruleTime = timeCtx.time();
+
+ // Process row from table 'rules' - broadcast phase:
+ // state scoped to a set is null, store the rule
+ if (count == null) {
+ allRules.put(ruleName, ruleTime);
+ return;
+ }
+
+ // Process row from table 'rules' - notification phase:
+ // state scoped to a set is present, apply the stored rule exactly
once
+ assertThat(allRules.get(ruleName)).isEqualTo(ruleTime);
+ assertThat(appliedRules.contains(ruleName)).isFalse();
+ assertThatThrownBy(() -> allRules.remove(ruleName))
+ .isInstanceOf(TableRuntimeException.class)
+ .hasMessageContaining("is read-only");
+ appliedRules.put(ruleName, ruleTime);
+ }
+
+ public void onTimer(
+ OnTimerContext ctx,
+ ValueView<Integer> count,
+ MapView<String, Long> appliedRules,
+ MapView<String, Long> allRules)
+ throws Exception {
+ if (ctx.currentTimer().equals("end")) {
+ // All rules have been applied to every set
+ collectObjects(
+ count.getValue(), toSortedString(appliedRules),
toSortedString(allRules));
+ return;
+ }
+ // Only rules up to the timer's time are guaranteed to be applied
+ final long time = ctx.timeContext(Long.class).time();
+ final MapView<String, Long> passedRules = new MapView<>();
+ for (Map.Entry<String, Long> r : appliedRules.entries()) {
+ if (r.getValue() <= time) {
+ passedRules.put(r.getKey(), r.getValue());
+ }
+ }
+ collectObjects(toSortedString(passedRules));
+ }
+ }
+
+ /**
+ * Testing function that notifies the set targeted by a rule. All output
is independent of the
+ * order in which main and broadcast tables are processed.
+ */
+ public static class NotifyRestoredSetFunction extends
AppendProcessTableFunctionBase {
+ @SuppressWarnings("unused")
+ public void eval(
+ @StateHint ValueView<String> setName,
+ @StateHint(StateKind.BROADCAST) MapView<String, Integer> rules,
+ @ArgumentHint(SET_SEMANTIC_TABLE) Row input,
+ @ArgumentHint({BROADCAST_SEMANTIC_TABLE,
NOTIFY_STATEFUL_SETS}) Row rule)
+ throws Exception {
+ // Process row from table 't'
+ if (input != null) {
+ setName.setValue(input.getFieldAs("name"));
+ collectObjects(input);
+ return;
+ }
+
+ final String ruleName = rule.getFieldAs("name");
+
+ // Process row from table 'rules' - broadcast phase:
+ // state scoped to a set is null, store the rule
+ if (setName == null) {
+ rules.put(ruleName, rule.getFieldAs("weight"));
+ return;
+ }
+
+ // Process row from table 'rules' - notification phase:
+ // only the targeted set reacts, other sets might not exist yet
+ if (ruleName.equals(setName.getValue())) {
+ collectObjects(rule, toSortedString(rules));
+ }
+ }
+ }
+
/** POJO for broadcast state. */
public static class RuleStats {
public int rules;
diff --git
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/stream/sql/ProcessTableFunctionTest.java
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/stream/sql/ProcessTableFunctionTest.java
index d1f06a38418..5e45f045098 100644
---
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/stream/sql/ProcessTableFunctionTest.java
+++
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/stream/sql/ProcessTableFunctionTest.java
@@ -80,6 +80,7 @@ import java.util.stream.Stream;
import static java.util.Collections.singletonList;
import static org.apache.flink.core.testutils.FlinkAssertions.anyCauseMatches;
import static
org.apache.flink.table.annotation.ArgumentTrait.BROADCAST_SEMANTIC_TABLE;
+import static
org.apache.flink.table.annotation.ArgumentTrait.NOTIFY_STATEFUL_SETS;
import static
org.apache.flink.table.annotation.ArgumentTrait.OPTIONAL_PARTITION_BY;
import static
org.apache.flink.table.annotation.ArgumentTrait.PASS_COLUMNS_THROUGH;
import static
org.apache.flink.table.annotation.ArgumentTrait.ROW_SEMANTIC_TABLE;
@@ -562,6 +563,12 @@ class ProcessTableFunctionTest extends TableTestBase {
InvalidBroadcastStateFunction.class,
"SELECT * FROM f(input => TABLE t PARTITION BY name)",
"Broadcast state entry 's' requires at least one table
argument with broadcast semantics."),
+ ErrorSpec.ofSelect(
+ "notify stateful sets without set semantic table",
+ InvalidNotifyStatefulSetsWithRowFunction.class,
+ "SELECT * FROM f(input => TABLE t, rule => TABLE
t_rules)",
+ "Table argument 'rule' notifies stateful sets which
requires at least one "
+ + "table argument with set semantics."),
ErrorSpec.ofSelect(
"missing on_time for broadcast table",
BroadcastTimersFunction.class,
@@ -837,6 +844,16 @@ class ProcessTableFunctionTest extends TableTestBase {
@ArgumentHint(SET_SEMANTIC_TABLE) Row input) {}
}
+ /** Testing function. */
+ public static class InvalidNotifyStatefulSetsWithRowFunction
+ extends ProcessTableFunction<String> {
+ @SuppressWarnings("unused")
+ public void eval(
+ @StateHint MapView<String, Integer> s,
+ @ArgumentHint(ROW_SEMANTIC_TABLE) Row input,
+ @ArgumentHint({BROADCAST_SEMANTIC_TABLE,
NOTIFY_STATEFUL_SETS}) Row rule) {}
+ }
+
/** Testing function. */
public static class UpdatingBroadcastFunction extends
ProcessTableFunction<String> {
@SuppressWarnings("unused")
diff --git
a/flink-table/flink-table-planner/src/test/resources/restore-tests/stream-exec-process-table-function_1/process-notify-stateful-sets-restore/plan/process-notify-stateful-sets-restore.json
b/flink-table/flink-table-planner/src/test/resources/restore-tests/stream-exec-process-table-function_1/process-notify-stateful-sets-restore/plan/process-notify-stateful-sets-restore.json
new file mode 100644
index 00000000000..ec1868b6694
--- /dev/null
+++
b/flink-table/flink-table-planner/src/test/resources/restore-tests/stream-exec-process-table-function_1/process-notify-stateful-sets-restore/plan/process-notify-stateful-sets-restore.json
@@ -0,0 +1,482 @@
+{
+ "flinkVersion" : "2.4",
+ "nodes" : [ {
+ "id" : 46,
+ "type" : "stream-exec-table-source-scan_2",
+ "scanTableSource" : {
+ "table" : {
+ "identifier" : "`default_catalog`.`default_database`.`t`",
+ "resolvedTable" : {
+ "schema" : {
+ "columns" : [ {
+ "name" : "name",
+ "dataType" : "VARCHAR(2147483647)"
+ }, {
+ "name" : "score",
+ "dataType" : "INT"
+ }, {
+ "name" : "ts",
+ "dataType" : {
+ "type" : "TIMESTAMP_WITH_LOCAL_TIME_ZONE",
+ "precision" : 3,
+ "kind" : "ROWTIME"
+ }
+ } ],
+ "watermarkSpecs" : [ {
+ "rowtimeAttribute" : "ts",
+ "expression" : {
+ "rexNode" : {
+ "kind" : "CALL",
+ "syntax" : "BINARY",
+ "internalName" : "$-$1",
+ "operands" : [ {
+ "kind" : "INPUT_REF",
+ "inputIndex" : 2,
+ "type" : "TIMESTAMP(3) WITH LOCAL TIME ZONE"
+ }, {
+ "kind" : "LITERAL",
+ "value" : "1",
+ "type" : "INTERVAL SECOND(6) NOT NULL"
+ } ],
+ "type" : "TIMESTAMP(3) WITH LOCAL TIME ZONE"
+ },
+ "serializableString" : "`ts` - INTERVAL '0.001' SECOND"
+ }
+ } ]
+ }
+ }
+ },
+ "abilities" : [ {
+ "type" : "WatermarkPushDown",
+ "watermarkExpr" : {
+ "kind" : "CALL",
+ "syntax" : "BINARY",
+ "internalName" : "$-$1",
+ "operands" : [ {
+ "kind" : "INPUT_REF",
+ "inputIndex" : 2,
+ "type" : "TIMESTAMP(3) WITH LOCAL TIME ZONE"
+ }, {
+ "kind" : "LITERAL",
+ "value" : "1",
+ "type" : "INTERVAL SECOND(6) NOT NULL"
+ } ],
+ "type" : "TIMESTAMP(3) WITH LOCAL TIME ZONE"
+ },
+ "rowtimeExpr" : {
+ "kind" : "INPUT_REF",
+ "inputIndex" : 2,
+ "type" : "TIMESTAMP(3) WITH LOCAL TIME ZONE"
+ },
+ "idleTimeoutMillis" : -1,
+ "producedType" : {
+ "type" : "ROW",
+ "nullable" : false,
+ "fields" : [ {
+ "name" : "name",
+ "fieldType" : "VARCHAR(2147483647)"
+ }, {
+ "name" : "score",
+ "fieldType" : "INT"
+ }, {
+ "name" : "ts",
+ "fieldType" : {
+ "type" : "TIMESTAMP_WITH_LOCAL_TIME_ZONE",
+ "precision" : 3,
+ "kind" : "ROWTIME"
+ }
+ } ]
+ },
+ "watermarkParams" : {
+ "emitStrategy" : "ON_EVENT",
+ "alignGroupName" : null,
+ "alignMaxDrift" : "PT0S",
+ "alignUpdateInterval" : "PT1S",
+ "sourceIdleTimeout" : -1
+ }
+ } ]
+ },
+ "outputType" : {
+ "type" : "ROW",
+ "fields" : [ {
+ "name" : "name",
+ "fieldType" : "VARCHAR(2147483647)"
+ }, {
+ "name" : "score",
+ "fieldType" : "INT"
+ }, {
+ "name" : "ts",
+ "fieldType" : {
+ "type" : "TIMESTAMP_WITH_LOCAL_TIME_ZONE",
+ "precision" : 3,
+ "kind" : "ROWTIME"
+ }
+ } ]
+ },
+ "description" : "TableSourceScan(table=[[default_catalog,
default_database, t, watermark=[-(ts, 1:INTERVAL SECOND)],
watermarkEmitStrategy=[on-event]]], fields=[name, score, ts])"
+ }, {
+ "id" : 47,
+ "type" : "stream-exec-exchange_1",
+ "inputProperties" : [ {
+ "requiredDistribution" : {
+ "type" : "HASH",
+ "keys" : [ 0 ]
+ },
+ "damBehavior" : "PIPELINED",
+ "priority" : 0
+ } ],
+ "outputType" : {
+ "type" : "ROW",
+ "fields" : [ {
+ "name" : "name",
+ "fieldType" : "VARCHAR(2147483647)"
+ }, {
+ "name" : "score",
+ "fieldType" : "INT"
+ }, {
+ "name" : "ts",
+ "fieldType" : {
+ "type" : "TIMESTAMP_WITH_LOCAL_TIME_ZONE",
+ "precision" : 3,
+ "kind" : "ROWTIME"
+ }
+ } ]
+ },
+ "description" : "Exchange(distribution=[hash[name]])"
+ }, {
+ "id" : 48,
+ "type" : "stream-exec-table-source-scan_2",
+ "scanTableSource" : {
+ "table" : {
+ "identifier" : "`default_catalog`.`default_database`.`rules`",
+ "resolvedTable" : {
+ "schema" : {
+ "columns" : [ {
+ "name" : "name",
+ "dataType" : "VARCHAR(2147483647)"
+ }, {
+ "name" : "weight",
+ "dataType" : "INT"
+ }, {
+ "name" : "ts",
+ "dataType" : {
+ "type" : "TIMESTAMP_WITH_LOCAL_TIME_ZONE",
+ "precision" : 3,
+ "kind" : "ROWTIME"
+ }
+ } ],
+ "watermarkSpecs" : [ {
+ "rowtimeAttribute" : "ts",
+ "expression" : {
+ "rexNode" : {
+ "kind" : "INPUT_REF",
+ "inputIndex" : 2,
+ "type" : "TIMESTAMP(3) WITH LOCAL TIME ZONE"
+ },
+ "serializableString" : "`ts`"
+ }
+ } ]
+ }
+ }
+ },
+ "abilities" : [ {
+ "type" : "WatermarkPushDown",
+ "watermarkExpr" : {
+ "kind" : "INPUT_REF",
+ "inputIndex" : 2,
+ "type" : "TIMESTAMP(3) WITH LOCAL TIME ZONE"
+ },
+ "rowtimeExpr" : {
+ "kind" : "INPUT_REF",
+ "inputIndex" : 2,
+ "type" : "TIMESTAMP(3) WITH LOCAL TIME ZONE"
+ },
+ "idleTimeoutMillis" : -1,
+ "producedType" : {
+ "type" : "ROW",
+ "nullable" : false,
+ "fields" : [ {
+ "name" : "name",
+ "fieldType" : "VARCHAR(2147483647)"
+ }, {
+ "name" : "weight",
+ "fieldType" : "INT"
+ }, {
+ "name" : "ts",
+ "fieldType" : {
+ "type" : "TIMESTAMP_WITH_LOCAL_TIME_ZONE",
+ "precision" : 3,
+ "kind" : "ROWTIME"
+ }
+ } ]
+ },
+ "watermarkParams" : {
+ "emitStrategy" : "ON_EVENT",
+ "alignGroupName" : null,
+ "alignMaxDrift" : "PT0S",
+ "alignUpdateInterval" : "PT1S",
+ "sourceIdleTimeout" : -1
+ }
+ } ]
+ },
+ "outputType" : {
+ "type" : "ROW",
+ "fields" : [ {
+ "name" : "name",
+ "fieldType" : "VARCHAR(2147483647)"
+ }, {
+ "name" : "weight",
+ "fieldType" : "INT"
+ }, {
+ "name" : "ts",
+ "fieldType" : {
+ "type" : "TIMESTAMP_WITH_LOCAL_TIME_ZONE",
+ "precision" : 3,
+ "kind" : "ROWTIME"
+ }
+ } ]
+ },
+ "description" : "TableSourceScan(table=[[default_catalog,
default_database, rules, watermark=[ts], watermarkEmitStrategy=[on-event]]],
fields=[name, weight, ts])"
+ }, {
+ "id" : 49,
+ "type" : "stream-exec-exchange_1",
+ "inputProperties" : [ {
+ "requiredDistribution" : {
+ "type" : "BROADCAST"
+ },
+ "damBehavior" : "PIPELINED",
+ "priority" : 0
+ } ],
+ "outputType" : {
+ "type" : "ROW",
+ "fields" : [ {
+ "name" : "name",
+ "fieldType" : "VARCHAR(2147483647)"
+ }, {
+ "name" : "weight",
+ "fieldType" : "INT"
+ }, {
+ "name" : "ts",
+ "fieldType" : {
+ "type" : "TIMESTAMP_WITH_LOCAL_TIME_ZONE",
+ "precision" : 3,
+ "kind" : "ROWTIME"
+ }
+ } ]
+ },
+ "description" : "Exchange(distribution=[broadcast])"
+ }, {
+ "id" : 50,
+ "type" : "stream-exec-process-table-function_1",
+ "inputProperties" : [ {
+ "requiredDistribution" : {
+ "type" : "UNKNOWN"
+ },
+ "damBehavior" : "PIPELINED",
+ "priority" : 0
+ }, {
+ "requiredDistribution" : {
+ "type" : "BROADCAST"
+ },
+ "damBehavior" : "PIPELINED",
+ "priority" : 0
+ } ],
+ "outputType" : {
+ "type" : "ROW",
+ "fields" : [ {
+ "name" : "name",
+ "fieldType" : "VARCHAR(2147483647)"
+ }, {
+ "name" : "out",
+ "fieldType" : "VARCHAR(2147483647)"
+ }, {
+ "name" : "rowtime",
+ "fieldType" : {
+ "type" : "TIMESTAMP_WITH_LOCAL_TIME_ZONE",
+ "nullable" : false,
+ "precision" : 3,
+ "kind" : "ROWTIME"
+ }
+ } ]
+ },
+ "description" : "ProcessTableFunction(invocation=[f(TABLE(#0) PARTITION
BY($0), TABLE(#1), DESCRIPTOR(_UTF-16LE'ts'), DEFAULT())], uid=[f],
select=[name,out,rowtime], rowType=[RecordType(VARCHAR(2147483647) name,
VARCHAR(2147483647) out, TIMESTAMP_LTZ(3) *ROWTIME* rowtime)])",
+ "uid" : "f",
+ "functionCall" : {
+ "kind" : "CALL",
+ "systemName" : "f",
+ "operands" : [ {
+ "kind" : "TABLE_ARG_CALL",
+ "inputIndex" : 0,
+ "partitionKeys" : [ 0 ],
+ "type" : {
+ "type" : "ROW",
+ "nullable" : false,
+ "fields" : [ {
+ "name" : "name",
+ "fieldType" : "VARCHAR(2147483647)"
+ }, {
+ "name" : "score",
+ "fieldType" : "INT"
+ }, {
+ "name" : "ts",
+ "fieldType" : {
+ "type" : "TIMESTAMP_WITH_LOCAL_TIME_ZONE",
+ "precision" : 3,
+ "kind" : "ROWTIME"
+ }
+ } ]
+ }
+ }, {
+ "kind" : "TABLE_ARG_CALL",
+ "inputIndex" : 1,
+ "type" : {
+ "type" : "ROW",
+ "nullable" : false,
+ "fields" : [ {
+ "name" : "name",
+ "fieldType" : "VARCHAR(2147483647)"
+ }, {
+ "name" : "weight",
+ "fieldType" : "INT"
+ }, {
+ "name" : "ts",
+ "fieldType" : {
+ "type" : "TIMESTAMP_WITH_LOCAL_TIME_ZONE",
+ "precision" : 3,
+ "kind" : "ROWTIME"
+ }
+ } ]
+ }
+ }, {
+ "kind" : "CALL",
+ "internalName" : "$DESCRIPTOR$1",
+ "operands" : [ {
+ "kind" : "LITERAL",
+ "value" : "ts",
+ "type" : "CHAR(2) NOT NULL"
+ } ],
+ "type" : "DESCRIPTOR NOT NULL"
+ }, {
+ "kind" : "CALL",
+ "syntax" : "SPECIAL",
+ "internalName" : "$DEFAULT$1",
+ "type" : "VARCHAR(2147483647)"
+ } ],
+ "type" : {
+ "type" : "ROW",
+ "nullable" : false,
+ "fields" : [ {
+ "name" : "name",
+ "fieldType" : "VARCHAR(2147483647)"
+ }, {
+ "name" : "out",
+ "fieldType" : "VARCHAR(2147483647)"
+ }, {
+ "name" : "rowtime",
+ "fieldType" : {
+ "type" : "TIMESTAMP_WITH_LOCAL_TIME_ZONE",
+ "nullable" : false,
+ "precision" : 3,
+ "kind" : "ROWTIME"
+ }
+ } ]
+ }
+ },
+ "inputChangelogModes" : [ [ "INSERT" ], [ "INSERT" ] ],
+ "outputChangelogMode" : [ "INSERT" ],
+ "inputUpsertKeys" : [ [ ], [ ] ]
+ }, {
+ "id" : 51,
+ "type" : "stream-exec-sink_2",
+ "configuration" : {
+ "table.exec.sink.keyed-shuffle" : "AUTO",
+ "table.exec.sink.not-null-enforcer" : "ERROR",
+ "table.exec.sink.rowtime-inserter" : "ENABLED",
+ "table.exec.sink.type-length-enforcer" : "IGNORE",
+ "table.exec.sink.upsert-materialize" : "AUTO"
+ },
+ "dynamicTableSink" : {
+ "table" : {
+ "identifier" : "`default_catalog`.`default_database`.`sink`",
+ "resolvedTable" : {
+ "schema" : {
+ "columns" : [ {
+ "name" : "name",
+ "dataType" : "VARCHAR(2147483647)"
+ }, {
+ "name" : "out",
+ "dataType" : "VARCHAR(2147483647)"
+ }, {
+ "name" : "rowtime",
+ "dataType" : "TIMESTAMP(3) WITH LOCAL TIME ZONE"
+ } ]
+ }
+ }
+ }
+ },
+ "inputChangelogMode" : [ "INSERT" ],
+ "upsertMaterializeStrategy" : "MAP",
+ "inputProperties" : [ {
+ "requiredDistribution" : {
+ "type" : "UNKNOWN"
+ },
+ "damBehavior" : "PIPELINED",
+ "priority" : 0
+ } ],
+ "outputType" : {
+ "type" : "ROW",
+ "fields" : [ {
+ "name" : "name",
+ "fieldType" : "VARCHAR(2147483647)"
+ }, {
+ "name" : "out",
+ "fieldType" : "VARCHAR(2147483647)"
+ }, {
+ "name" : "rowtime",
+ "fieldType" : {
+ "type" : "TIMESTAMP_WITH_LOCAL_TIME_ZONE",
+ "nullable" : false,
+ "precision" : 3,
+ "kind" : "ROWTIME"
+ }
+ } ]
+ },
+ "description" : "Sink(table=[default_catalog.default_database.sink],
fields=[name, out, rowtime])"
+ } ],
+ "edges" : [ {
+ "source" : 46,
+ "target" : 47,
+ "shuffle" : {
+ "type" : "FORWARD"
+ },
+ "shuffleMode" : "PIPELINED"
+ }, {
+ "source" : 48,
+ "target" : 49,
+ "shuffle" : {
+ "type" : "FORWARD"
+ },
+ "shuffleMode" : "PIPELINED"
+ }, {
+ "source" : 47,
+ "target" : 50,
+ "shuffle" : {
+ "type" : "FORWARD"
+ },
+ "shuffleMode" : "PIPELINED"
+ }, {
+ "source" : 49,
+ "target" : 50,
+ "shuffle" : {
+ "type" : "FORWARD"
+ },
+ "shuffleMode" : "PIPELINED"
+ }, {
+ "source" : 50,
+ "target" : 51,
+ "shuffle" : {
+ "type" : "FORWARD"
+ },
+ "shuffleMode" : "PIPELINED"
+ } ]
+}
\ No newline at end of file
diff --git
a/flink-table/flink-table-planner/src/test/resources/restore-tests/stream-exec-process-table-function_1/process-notify-stateful-sets-restore/savepoint/_metadata
b/flink-table/flink-table-planner/src/test/resources/restore-tests/stream-exec-process-table-function_1/process-notify-stateful-sets-restore/savepoint/_metadata
new file mode 100644
index 00000000000..fd8a68f5b73
Binary files /dev/null and
b/flink-table/flink-table-planner/src/test/resources/restore-tests/stream-exec-process-table-function_1/process-notify-stateful-sets-restore/savepoint/_metadata
differ
diff --git
a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/generated/ProcessTableRunner.java
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/generated/ProcessTableRunner.java
index e64dd3398e3..7bf9c914a02 100644
---
a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/generated/ProcessTableRunner.java
+++
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/generated/ProcessTableRunner.java
@@ -87,19 +87,54 @@ public abstract class ProcessTableRunner extends
AbstractRichFunction {
this.onTimerCollector = onTimerCollector;
}
+ /**
+ * Ingests a row of a table with row or set semantics for the next {@link
#processEval()} call.
+ * The output prefix is derived from the row (i.e. partition keys or all
columns).
+ *
+ * @param pos position of the table argument
+ * @param row incoming row
+ * @param timeColumn position of the time attribute in the row, or -1 if
not available
+ * @param watermark current watermark of the table
+ */
public void ingestTableEvent(int pos, RowData row, int timeColumn, long
watermark) {
evalCollector.setPrefix(pos, row);
ingestRow(pos, row, timeColumn, watermark);
- if (emitRowtime && rowtime != null) {
- evalCollector.setRowtime(rowtime);
- }
+ setOutputRowtime();
}
+ /**
+ * Ingests a row of a table with broadcast semantics for the next {@link
+ * #processBroadcastEval()} call. Broadcast rows have no key context and
must not emit results,
+ * thus, no output prefix is set.
+ *
+ * @param pos position of the table argument
+ * @param row incoming row
+ * @param timeColumn position of the time attribute in the row, or -1 if
not available
+ * @param watermark current watermark of the table
+ */
public void ingestBroadcastTableEvent(int pos, RowData row, int
timeColumn, long watermark) {
- // Broadcast rows have no key context and never emit, thus, there is
no output prefix
ingestRow(pos, row, timeColumn, watermark);
}
+ /**
+ * Ingests the key context of a stateful set for the next {@link
#processEval()} call that
+ * notifies the set about the broadcast row. The broadcast row must have
been ingested before
+ * via {@link #ingestBroadcastTableEvent}, only the key context changes.
The output prefix is
+ * derived from the key.
+ *
+ * @param key key of the stateful set
+ */
+ public void ingestBroadcastNotification(RowData key) {
+ evalCollector.setKeyPrefix(key);
+ setOutputRowtime();
+ }
+
+ private void setOutputRowtime() {
+ if (emitRowtime && rowtime != null) {
+ evalCollector.setRowtime(rowtime);
+ }
+ }
+
private void ingestRow(int pos, RowData row, int timeColumn, long
watermark) {
if (timeColumn == -1) {
rowtime = null;
@@ -111,6 +146,14 @@ public abstract class ProcessTableRunner extends
AbstractRichFunction {
tableWatermark = watermark;
}
+ /**
+ * Ingests a fired timer for the next {@link #processOnTimer()} call. The
output prefix is
+ * derived from the key.
+ *
+ * @param key key of the set that registered the timer
+ * @param name name of the timer, or null for an unnamed timer
+ * @param timerTime time for which the timer was registered
+ */
public void ingestTimerEvent(RowData key, @Nullable StringData name, long
timerTime) {
onTimerCollector.setPrefix(-1, key);
if (emitRowtime) {
@@ -217,8 +260,8 @@ public abstract class ProcessTableRunner extends
AbstractRichFunction {
}
if (stateHandle.kind == Kind.EAGER_VALUE) {
if (!processingBroadcast && stateHandle.broadcast) {
- // Broadcast state is read-only while processing other
tables, thus,
- // modifications of eager value state are discarded
+ // Broadcast state is read-only while processing main
tables or notifying
+ // stateful sets, thus, modifications of eager value state
are discarded
continue;
}
moveValueStateFromFunction(stateHandle);
@@ -278,6 +321,7 @@ public abstract class ProcessTableRunner extends
AbstractRichFunction {
EAGER_VALUE
}
+ private final String name;
private final Kind kind;
/** Whether {@link #state} is an adapter for Flink's broadcast state.
*/
@@ -302,11 +346,13 @@ public abstract class ProcessTableRunner extends
AbstractRichFunction {
private boolean cleared;
public StateHandle(
+ String name,
Kind kind,
boolean broadcast,
State state,
@Nullable HashFunction hashFunction,
@Nullable RecordEqualiser equaliser) {
+ this.name = name;
this.kind = kind;
this.broadcast = broadcast;
this.state = state;
@@ -333,5 +379,18 @@ public abstract class ProcessTableRunner extends
AbstractRichFunction {
public boolean isCleared() {
return cleared;
}
+
+ @Override
+ public String toString() {
+ return "StateHandle{"
+ + "name='"
+ + name
+ + '\''
+ + ", kind="
+ + kind
+ + ", broadcast="
+ + broadcast
+ + '}';
+ }
}
}
diff --git
a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/process/AbstractProcessTableOperator.java
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/process/AbstractProcessTableOperator.java
index b2d614e3228..b3906336968 100644
---
a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/process/AbstractProcessTableOperator.java
+++
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/process/AbstractProcessTableOperator.java
@@ -33,6 +33,7 @@ import org.apache.flink.api.common.state.StateTtlConfig;
import org.apache.flink.api.common.state.ValueStateDescriptor;
import org.apache.flink.api.common.typeutils.base.LongSerializer;
import org.apache.flink.configuration.ReadableConfig;
+import org.apache.flink.runtime.state.KeyedStateBackend;
import org.apache.flink.runtime.state.VoidNamespace;
import org.apache.flink.runtime.state.VoidNamespaceSerializer;
import org.apache.flink.streaming.api.operators.AbstractStreamOperatorV2;
@@ -75,10 +76,12 @@ import javax.annotation.Nullable;
import java.time.Instant;
import java.time.LocalDateTime;
+import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
+import java.util.stream.Stream;
/** Base class for operators for {@link ProcessTableFunction}. */
@Internal
@@ -100,6 +103,7 @@ public abstract class AbstractProcessTableOperator extends
AbstractStreamOperato
private transient PassAllCollector onTimerCollector;
private transient StateDescriptor<?, ?>[] stateDescriptors;
private transient StateHandle[] stateHandles;
+ private transient List<String> keyedStateNames;
private transient @Nullable MapState<StringData, Long> namedTimersMapState;
private transient @Nullable InternalTimerService<StringData>
namedTimerService;
@@ -181,6 +185,27 @@ public abstract class AbstractProcessTableOperator extends
AbstractStreamOperato
@Override
public void onProcessingTime(InternalTimer<RowData, Object> timer) throws
Exception {}
+ /**
+ * Calls the function for every set that holds at least one state entry on
this virtual
+ * processor. The previously ingested broadcast row is passed together
with the set's key
+ * context.
+ */
+ protected void notifyStatefulSets() throws Exception {
+ // Keys are materialized upfront because state backends don't support
modifications of
+ // state while iterating over its keys
+ final KeyedStateBackend<RowData> keyedStateBackend =
getKeyedStateBackend();
+ final List<RowData> keys;
+ try (Stream<RowData> keyStream =
+ keyedStateBackend.getKeys(keyedStateNames,
VoidNamespace.INSTANCE)) {
+ keys = keyStream.collect(Collectors.toList());
+ }
+ for (RowData key : keys) {
+ setCurrentKey(key);
+ processTableRunner.ingestBroadcastNotification(key);
+ processTableRunner.processEval();
+ }
+ }
+
//
--------------------------------------------------------------------------------------------
// Context implementation
//
--------------------------------------------------------------------------------------------
@@ -447,6 +472,7 @@ public abstract class AbstractProcessTableOperator extends
AbstractStreamOperato
private void setStateHandles() throws Exception {
final StateHandle[] stateHandles = new
StateHandle[stateDescriptors.length];
+ final List<String> keyedStateNames = new ArrayList<>();
for (int i = 0; i < stateDescriptors.length; i++) {
final RuntimeStateInfo stateInfo = stateInfos.get(i);
// The kind must be derived from the declared type, not the
descriptor, because both
@@ -458,6 +484,7 @@ public abstract class AbstractProcessTableOperator extends
AbstractStreamOperato
stateHandle =
createBroadcastStateHandle(stateInfo.getStateName(), kind, i);
} else {
stateHandle = createKeyedStateHandle(i);
+ keyedStateNames.add(stateInfo.getStateName());
}
// Hash function and equaliser are only used by eager value state
to reduce state
// updates; views access state lazily and don't need them.
@@ -466,9 +493,15 @@ public abstract class AbstractProcessTableOperator extends
AbstractStreamOperato
final RecordEqualiser equaliser = isEagerValue ? stateEquals[i] :
null;
stateHandles[i] =
new StateHandle(
- kind, stateInfo.isBroadcast(), stateHandle,
hashFunction, equaliser);
+ stateInfo.getStateName(),
+ kind,
+ stateInfo.isBroadcast(),
+ stateHandle,
+ hashFunction,
+ equaliser);
}
this.stateHandles = stateHandles;
+ this.keyedStateNames = keyedStateNames;
}
@SuppressWarnings({"unchecked", "rawtypes"})
diff --git
a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/process/BroadcastEvalCollector.java
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/process/BroadcastEvalCollector.java
index b507585b2b6..28b06a3a852 100644
---
a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/process/BroadcastEvalCollector.java
+++
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/process/BroadcastEvalCollector.java
@@ -53,6 +53,11 @@ public class BroadcastEvalCollector extends
PassThroughCollectorBase {
evalCollector.setPrefix(pos, input);
}
+ @Override
+ public void setKeyPrefix(RowData key) {
+ evalCollector.setKeyPrefix(key);
+ }
+
@Override
public void setRowtime(Long time) {
evalCollector.setRowtime(time);
diff --git
a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/process/BroadcastStateAdapters.java
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/process/BroadcastStateAdapters.java
index 2ef0c9f4f0e..03d04587d88 100644
---
a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/process/BroadcastStateAdapters.java
+++
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/process/BroadcastStateAdapters.java
@@ -218,8 +218,9 @@ public final class BroadcastStateAdapters {
public static TableRuntimeException broadcastStateIsReadOnly(String
stateName) {
return new TableRuntimeException(
String.format(
- "Broadcast state entry '%s' is read-only while
processing a table "
- + "with row or set semantics.",
+ "Broadcast state entry '%s' is read-only. It can only
be modified while "
+ + "processing a row of a table with broadcast
semantics, not while "
+ + "processing other tables, firing timers, or
notifying stateful sets.",
stateName));
}
diff --git
a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/process/PassThroughCollectorBase.java
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/process/PassThroughCollectorBase.java
index 5b032c3891f..799b203d821 100644
---
a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/process/PassThroughCollectorBase.java
+++
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/process/PassThroughCollectorBase.java
@@ -59,6 +59,11 @@ public abstract class PassThroughCollectorBase extends
StreamRecordCollector<Row
public abstract void setPrefix(int pos, RowData input);
+ /** Sets the prefix from a key, i.e. the partition keys of all tables with
set semantics. */
+ public void setKeyPrefix(RowData key) {
+ prefix = key;
+ }
+
public void setRowtime(Long time) {
rowtime = GenericRowData.of(TimestampData.fromEpochMillis(time));
}
diff --git
a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/process/ProcessSetTableOperator.java
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/process/ProcessSetTableOperator.java
index 402622c5b44..7b02d063e30 100644
---
a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/process/ProcessSetTableOperator.java
+++
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/process/ProcessSetTableOperator.java
@@ -52,7 +52,8 @@ import java.util.stream.IntStream;
* least one table with set semantics or at least one table with broadcast
semantics.
*
* <p>Tables with broadcast semantics have no key context. Similar to tables
with set semantics,
- * they contribute to the operator's watermark.
+ * they contribute to the operator's watermark. If configured, every broadcast
row is additionally
+ * forwarded to all sets that hold state.
*/
public class ProcessSetTableOperator extends AbstractProcessTableOperator
implements MultipleInputStreamOperator<RowData> {
@@ -113,6 +114,9 @@ public class ProcessSetTableOperator extends
AbstractProcessTableOperator
if
(inputSemantics.hasBroadcastSemantics()) {
processBroadcastTableEvent(
inputIdx, element.getValue(),
timeColumn);
+ if
(inputSemantics.notifiesStatefulSets()) {
+ notifyStatefulSets();
+ }
return;
}
final InputSortBuffer sortBuffer =
inputSortBuffers[inputIdx];
diff --git
a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/process/RuntimeStateInfo.java
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/process/RuntimeStateInfo.java
index 501c81d6a58..ca19574e42d 100644
---
a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/process/RuntimeStateInfo.java
+++
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/process/RuntimeStateInfo.java
@@ -59,4 +59,19 @@ public class RuntimeStateInfo implements Serializable {
public boolean isBroadcast() {
return broadcast;
}
+
+ @Override
+ public String toString() {
+ return "RuntimeStateInfo{"
+ + "stateName='"
+ + stateName
+ + '\''
+ + ", dataType="
+ + dataType
+ + ", timeToLive="
+ + timeToLive
+ + ", broadcast="
+ + broadcast
+ + '}';
+ }
}
diff --git
a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/process/RuntimeTableSemantics.java
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/process/RuntimeTableSemantics.java
index e70f201ec5d..59275e509d8 100644
---
a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/process/RuntimeTableSemantics.java
+++
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/process/RuntimeTableSemantics.java
@@ -46,6 +46,7 @@ public class RuntimeTableSemantics implements TableSemantics,
Serializable {
private final boolean passColumnsThrough;
private final boolean hasSetSemantics;
private final boolean hasBroadcastSemantics;
+ private final boolean notifiesStatefulSets;
private final int timeColumn;
private final List<int[]> upsertKeyColumns;
@@ -62,6 +63,7 @@ public class RuntimeTableSemantics implements TableSemantics,
Serializable {
boolean passColumnsThrough,
boolean hasSetSemantics,
boolean hasBroadcastSemantics,
+ boolean notifiesStatefulSets,
int timeColumn,
List<int[]> upsertKeyColumns) {
this.argName = argName;
@@ -74,6 +76,7 @@ public class RuntimeTableSemantics implements TableSemantics,
Serializable {
this.passColumnsThrough = passColumnsThrough;
this.hasSetSemantics = hasSetSemantics;
this.hasBroadcastSemantics = hasBroadcastSemantics;
+ this.notifiesStatefulSets = notifiesStatefulSets;
this.timeColumn = timeColumn;
this.upsertKeyColumns = upsertKeyColumns;
}
@@ -98,6 +101,10 @@ public class RuntimeTableSemantics implements
TableSemantics, Serializable {
return hasBroadcastSemantics;
}
+ public boolean notifiesStatefulSets() {
+ return notifiesStatefulSets;
+ }
+
public ChangelogMode getChangelogMode() {
if (changelogMode == null) {
changelogMode = consumedChangelogMode.deserialize();
diff --git
a/flink-table/flink-table-runtime/src/test/java/org/apache/flink/table/runtime/operators/process/ProcessSetTableOperatorInterruptibleTimersTest.java
b/flink-table/flink-table-runtime/src/test/java/org/apache/flink/table/runtime/operators/process/ProcessSetTableOperatorInterruptibleTimersTest.java
index 4fe65a92485..0c995e2771d 100644
---
a/flink-table/flink-table-runtime/src/test/java/org/apache/flink/table/runtime/operators/process/ProcessSetTableOperatorInterruptibleTimersTest.java
+++
b/flink-table/flink-table-runtime/src/test/java/org/apache/flink/table/runtime/operators/process/ProcessSetTableOperatorInterruptibleTimersTest.java
@@ -251,6 +251,7 @@ class ProcessSetTableOperatorInterruptibleTimersTest {
/* passColumnsThrough */ false,
/* hasSetSemantics */ true,
/* hasBroadcastSemantics */ false,
+ /* notifiesStatefulSets */ false,
/* timeColumn */ 1,
/* upsertKeyColumns */ List.of());
}
diff --git
a/flink-table/flink-table-runtime/src/test/java/org/apache/flink/table/runtime/operators/process/ProcessSetTableOperatorTest.java
b/flink-table/flink-table-runtime/src/test/java/org/apache/flink/table/runtime/operators/process/ProcessSetTableOperatorTest.java
new file mode 100644
index 00000000000..09953f4d9ec
--- /dev/null
+++
b/flink-table/flink-table-runtime/src/test/java/org/apache/flink/table/runtime/operators/process/ProcessSetTableOperatorTest.java
@@ -0,0 +1,424 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.table.runtime.operators.process;
+
+import org.apache.flink.api.common.serialization.SerializerConfigImpl;
+import org.apache.flink.api.common.state.ValueState;
+import org.apache.flink.streaming.api.operators.AbstractStreamOperatorFactory;
+import org.apache.flink.streaming.api.operators.StreamOperator;
+import org.apache.flink.streaming.api.operators.StreamOperatorParameters;
+import org.apache.flink.streaming.api.watermark.Watermark;
+import org.apache.flink.streaming.runtime.streamrecord.StreamRecord;
+import org.apache.flink.streaming.runtime.tasks.MultipleInputStreamTask;
+import org.apache.flink.streaming.runtime.tasks.StreamTaskMailboxTestHarness;
+import
org.apache.flink.streaming.runtime.tasks.StreamTaskMailboxTestHarnessBuilder;
+import
org.apache.flink.streaming.util.KeyedMultiInputStreamOperatorTestHarness;
+import org.apache.flink.table.api.DataTypes;
+import org.apache.flink.table.api.TableRuntimeException;
+import org.apache.flink.table.api.dataview.ValueView;
+import org.apache.flink.table.connector.ChangelogMode;
+import org.apache.flink.table.data.GenericRowData;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.data.StringData;
+import org.apache.flink.table.runtime.generated.HashFunction;
+import org.apache.flink.table.runtime.generated.ProcessTableRunner;
+import org.apache.flink.table.runtime.generated.RecordComparator;
+import org.apache.flink.table.runtime.generated.RecordEqualiser;
+import org.apache.flink.table.runtime.keyselector.RowDataKeySelector;
+import org.apache.flink.table.runtime.typeutils.InternalTypeInfo;
+import org.apache.flink.table.runtime.util.RuntimeChangelogMode;
+import org.apache.flink.table.types.DataType;
+import org.apache.flink.table.utils.HandwrittenSelectorUtil;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.Serializable;
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Harness tests for {@link ProcessSetTableOperator}.
+ *
+ * <p>The tests use hand-written {@link ProcessTableRunner}s instead of
generated code. This allows
+ * for full control over the order of input rows, watermarks, and state access.
+ */
+class ProcessSetTableOperatorTest {
+
+ private static final DataType MAIN_TYPE =
+ DataTypes.ROW(DataTypes.FIELD("k", DataTypes.BIGINT()));
+
+ // Output layout is driven by PassPartitionKeysCollector /
PassAllCollector in
+ // AbstractProcessTableOperator: [partition-key, PTF-emitted label].
+ private static final DataType OUTPUT_TYPE =
+ DataTypes.ROW(
+ DataTypes.FIELD("k", DataTypes.BIGINT()),
+ DataTypes.FIELD("label", DataTypes.STRING()));
+
+ private static final InternalTypeInfo<RowData> MAIN_TYPE_INFO =
+ InternalTypeInfo.of(MAIN_TYPE.getLogicalType());
+
+ private static final InternalTypeInfo<RowData> OUTPUT_TYPE_INFO =
+ InternalTypeInfo.of(OUTPUT_TYPE.getLogicalType());
+
+ private static final RowDataKeySelector KEY_SELECTOR =
+ HandwrittenSelectorUtil.getRowDataSelector(
+ new int[] {0}, MAIN_TYPE_INFO.toRowFieldTypes());
+
+ private static final RuntimeChangelogMode INSERT_ONLY =
+ RuntimeChangelogMode.serialize(ChangelogMode.insertOnly());
+
+ @Test
+ void testNotifyStatefulSets() throws Exception {
+ try (StreamTaskMailboxTestHarness<RowData> harness =
+ createBroadcastHarness(new
NotifyStatefulSetsOperatorCreator(true, 0))) {
+ processMainRows(harness, 1L, 2L, 1L);
+ // Notifies sets 1 and 2
+ processBroadcastRow(harness, "a");
+ // Notifies sets 1 and 2, which clear their state while iterating
over the keys
+ processBroadcastRow(harness, "clear");
+ // No stateful sets left
+ processBroadcastRow(harness, "b");
+ // Fires timers registered during notification
+ processWatermark(harness, TIMER_TIME, 2);
+
+ final List<String> output = describeOutput(harness);
+ assertThat(output).hasSize(9);
+ assertThat(output.subList(0, 3)).containsExactly("1:count-1",
"2:count-1", "1:count-2");
+ // The order of sets is defined by the state backend
+ assertThat(output.subList(3, 5))
+ .containsExactlyInAnyOrder(
+ "1:notified-2-a-read-only",
"2:notified-1-a-read-only");
+ assertThat(output.subList(5, 7))
+ .containsExactlyInAnyOrder(
+ "1:notified-2-clear-read-only",
"2:notified-1-clear-read-only");
+ assertThat(output.subList(7,
9)).containsExactlyInAnyOrder("1:fired", "2:fired");
+ }
+ }
+
+ @Test
+ void testWithoutNotifyStatefulSets() throws Exception {
+ try (StreamTaskMailboxTestHarness<RowData> harness =
+ createBroadcastHarness(new
NotifyStatefulSetsOperatorCreator(false, 0))) {
+ processMainRows(harness, 1L, 2L, 1L);
+ processBroadcastRow(harness, "a");
+ processWatermark(harness, TIMER_TIME, 2);
+
+ assertThat(describeOutput(harness))
+ .containsExactly("1:count-1", "2:count-1", "1:count-2");
+ }
+ }
+
+ @Test
+ void testNotifyStatefulSetsWithExpiredState() throws Exception {
+ try (KeyedMultiInputStreamOperatorTestHarness<RowData, RowData>
harness =
+ createTtlBroadcastHarness(new
NotifyStatefulSetsOperatorCreator(true, 10))) {
+ harness.setStateTtlProcessingTime(0);
+ processMainRows(harness, 1L);
+ harness.setStateTtlProcessingTime(12);
+ // Notifies set 1 with expired state, reading it cleans it up in
the heap backend
+ processBroadcastRow(harness, "a");
+ // No stateful sets left
+ processBroadcastRow(harness, "b");
+
+ processMainRows(harness, 2L);
+ harness.setStateTtlProcessingTime(24);
+ // Notifies set 2 with expired state, writing it keeps the set
alive
+ processBroadcastRow(harness, "reset");
+ harness.setStateTtlProcessingTime(30);
+ processBroadcastRow(harness, "c");
+
+ assertThat(describeOutput(harness.getOutput()))
+ .containsExactly(
+ "1:count-1",
+ "1:notified-null-a-read-only",
+ "2:count-1",
+ "2:notified-null-reset-read-only",
+ "2:notified-0-c-read-only");
+ }
+ }
+
+ //
--------------------------------------------------------------------------------------------
+ // Helper methods
+ //
--------------------------------------------------------------------------------------------
+
+ /** Creates a {@link ProcessSetTableOperator} for the given operator
parameters. */
+ private interface OperatorCreator extends Serializable {
+ ProcessSetTableOperator create(StreamOperatorParameters<RowData>
parameters);
+ }
+
+ private static StreamTaskMailboxTestHarness<RowData> createHarness(
+ OperatorCreator operatorCreator, InternalTypeInfo<?>...
sideInputTypes)
+ throws Exception {
+ final StreamTaskMailboxTestHarnessBuilder<RowData> builder =
+ new StreamTaskMailboxTestHarnessBuilder<>(
+ MultipleInputStreamTask::new, OUTPUT_TYPE_INFO)
+ .setKeyType(KEY_SELECTOR.getProducedType())
+ .addInput(MAIN_TYPE_INFO, 1, KEY_SELECTOR);
+ for (InternalTypeInfo<?> sideInputType : sideInputTypes) {
+ builder.addInput(sideInputType, 1, null);
+ }
+ return builder.setupOperatorChain(new
TestOperatorFactory(operatorCreator))
+ .name("ptf")
+ .finishForSingletonOperatorChain(
+ OUTPUT_TYPE_INFO.createSerializer(new
SerializerConfigImpl()))
+ .build();
+ }
+
+ private static ProcessSetTableOperator createOperator(
+ StreamOperatorParameters<RowData> parameters,
+ List<RuntimeTableSemantics> tableSemantics,
+ List<RuntimeStateInfo> stateInfos,
+ ProcessTableRunner runner) {
+ final int inputCount = tableSemantics.size();
+ final int stateCount = stateInfos.size();
+ return new ProcessSetTableOperator(
+ parameters,
+ tableSemantics,
+ stateInfos,
+ new RecordComparator[inputCount],
+ runner,
+ new HashFunction[stateCount],
+ new RecordEqualiser[stateCount],
+ INSERT_ONLY,
+ List.of());
+ }
+
+ /** Table with set semantics that is partitioned by its first column. */
+ private static RuntimeTableSemantics mainTableSemantics() {
+ return new RuntimeTableSemantics(
+ "main",
+ 0,
+ MAIN_TYPE,
+ new int[] {0},
+ new int[0],
+ new RuntimeTableSemantics.SortDirection[0],
+ INSERT_ONLY,
+ /* passColumnsThrough */ false,
+ /* hasSetSemantics */ true,
+ /* hasBroadcastSemantics */ false,
+ /* notifiesStatefulSets */ false,
+ /* timeColumn */ -1,
+ /* upsertKeyColumns */ List.of());
+ }
+
+ private static void processMainRows(StreamTaskMailboxTestHarness<RowData>
harness, long... keys)
+ throws Exception {
+ for (long key : keys) {
+ harness.processElement(new StreamRecord<>(GenericRowData.of(key)),
0);
+ }
+ }
+
+ private static void processMainRows(
+ KeyedMultiInputStreamOperatorTestHarness<RowData, RowData>
harness, long... keys)
+ throws Exception {
+ for (long key : keys) {
+ harness.processElement(0, new
StreamRecord<>(GenericRowData.of(key)));
+ }
+ }
+
+ /** Advances the watermark of all inputs. */
+ private static void processWatermark(
+ StreamTaskMailboxTestHarness<RowData> harness, long watermark, int
inputCount)
+ throws Exception {
+ for (int input = 0; input < inputCount; input++) {
+ harness.processElement(new Watermark(watermark), input);
+ }
+ harness.processAll();
+ }
+
+ /** Describes emitted rows as "key:label". */
+ private static List<String>
describeOutput(StreamTaskMailboxTestHarness<RowData> harness) {
+ return describeOutput(harness.getOutput());
+ }
+
+ private static List<String> describeOutput(Collection<Object> output) {
+ return output.stream()
+ .filter(StreamRecord.class::isInstance)
+ .map(r -> (RowData) ((StreamRecord<?>) r).getValue())
+ .map(r -> r.getLong(0) + ":" + r.getString(1))
+ .collect(Collectors.toList());
+ }
+
+ private static class TestOperatorFactory extends
AbstractStreamOperatorFactory<RowData> {
+ private static final long serialVersionUID = 1L;
+
+ private final OperatorCreator operatorCreator;
+
+ TestOperatorFactory(OperatorCreator operatorCreator) {
+ this.operatorCreator = operatorCreator;
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public <T extends StreamOperator<RowData>> T createStreamOperator(
+ StreamOperatorParameters<RowData> parameters) {
+ return (T) operatorCreator.create(parameters);
+ }
+
+ @Override
+ @SuppressWarnings("rawtypes")
+ public Class<? extends StreamOperator>
getStreamOperatorClass(ClassLoader classLoader) {
+ return ProcessSetTableOperator.class;
+ }
+ }
+
+ //
--------------------------------------------------------------------------------------------
+ // Broadcast
+ //
--------------------------------------------------------------------------------------------
+
+ private static final DataType BROADCAST_TYPE =
+ DataTypes.ROW(DataTypes.FIELD("rule", DataTypes.STRING()));
+
+ private static final InternalTypeInfo<RowData> BROADCAST_TYPE_INFO =
+ InternalTypeInfo.of(BROADCAST_TYPE.getLogicalType());
+
+ private static final long TIMER_TIME = 100L;
+
+ private static StreamTaskMailboxTestHarness<RowData>
createBroadcastHarness(
+ OperatorCreator operatorCreator) throws Exception {
+ return createHarness(operatorCreator, BROADCAST_TYPE_INFO);
+ }
+
+ /**
+ * Creates an operator-level harness instead of a task-level harness,
because only the former
+ * allows controlling the time of state TTL.
+ */
+ private static KeyedMultiInputStreamOperatorTestHarness<RowData, RowData>
+ createTtlBroadcastHarness(OperatorCreator operatorCreator) throws
Exception {
+ final KeyedMultiInputStreamOperatorTestHarness<RowData, RowData>
harness =
+ new KeyedMultiInputStreamOperatorTestHarness<>(
+ new TestOperatorFactory(operatorCreator),
KEY_SELECTOR.getProducedType());
+ harness.setKeySelector(0, KEY_SELECTOR);
+ harness.open();
+ return harness;
+ }
+
+ private static RuntimeTableSemantics broadcastTableSemantics(boolean
notifyStatefulSets) {
+ return new RuntimeTableSemantics(
+ "broadcast",
+ 1,
+ BROADCAST_TYPE,
+ new int[0],
+ new int[0],
+ new RuntimeTableSemantics.SortDirection[0],
+ INSERT_ONLY,
+ /* passColumnsThrough */ false,
+ /* hasSetSemantics */ false,
+ /* hasBroadcastSemantics */ true,
+ notifyStatefulSets,
+ /* timeColumn */ -1,
+ /* upsertKeyColumns */ List.of());
+ }
+
+ private static void processBroadcastRow(
+ StreamTaskMailboxTestHarness<RowData> harness, String rule) throws
Exception {
+ harness.processElement(
+ new
StreamRecord<>(GenericRowData.of(StringData.fromString(rule))), 1);
+ }
+
+ private static void processBroadcastRow(
+ KeyedMultiInputStreamOperatorTestHarness<RowData, RowData>
harness, String rule)
+ throws Exception {
+ harness.processElement(
+ 1, new
StreamRecord<>(GenericRowData.of(StringData.fromString(rule))));
+ }
+
+ /** Operator for {@link NotifyStatefulSetsRunner} with a keyed count and a
broadcast rule. */
+ private static class NotifyStatefulSetsOperatorCreator implements
OperatorCreator {
+ private static final long serialVersionUID = 1L;
+
+ private final boolean notifyStatefulSets;
+ private final long countTimeToLive;
+
+ NotifyStatefulSetsOperatorCreator(boolean notifyStatefulSets, long
countTimeToLive) {
+ this.notifyStatefulSets = notifyStatefulSets;
+ this.countTimeToLive = countTimeToLive;
+ }
+
+ @Override
+ public ProcessSetTableOperator
create(StreamOperatorParameters<RowData> parameters) {
+ return createOperator(
+ parameters,
+ List.of(mainTableSemantics(),
broadcastTableSemantics(notifyStatefulSets)),
+ List.of(
+ new RuntimeStateInfo(
+ "count",
+
ValueView.newValueViewDataType(DataTypes.INT()),
+ countTimeToLive,
+ false),
+ new RuntimeStateInfo(
+ "rule",
+
ValueView.newValueViewDataType(DataTypes.STRING()),
+ 0,
+ true)),
+ new NotifyStatefulSetsRunner());
+ }
+ }
+
+ /**
+ * {@link ProcessTableRunner} that counts rows of the main table per set.
A broadcast row
+ * updates a broadcast rule. If notified, every stateful set emits its
count together with the
+ * rule and registers a timer. The rule "clear" clears the set's state,
the rule "reset" resets
+ * the set's count. State handles are accessed directly instead of
converting state like
+ * generated code does.
+ */
+ private static class NotifyStatefulSetsRunner extends ProcessTableRunner {
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public void callEval() throws Exception {
+ final ValueState<Integer> count = (ValueState<Integer>)
stateHandles[0].getState();
+ final ValueState<String> rule = (ValueState<String>)
stateHandles[1].getState();
+ if (inputIndex == 0) {
+ final int c = Optional.ofNullable(count.value()).orElse(0) + 1;
+ count.update(c);
+ emit("count-" + c);
+ } else if (isProcessingBroadcast()) {
+ rule.update(inputRow.getString(0).toString());
+ } else {
+ String label = "notified-" + count.value() + "-" +
rule.value();
+ try {
+ rule.update("invalid");
+ } catch (TableRuntimeException e) {
+ label += "-read-only";
+ }
+ emit(label);
+
runnerContext.timeContext(Long.class).registerOnTime(TIMER_TIME);
+ if (rule.value().equals("clear")) {
+ count.clear();
+ } else if (rule.value().equals("reset")) {
+ count.update(0);
+ }
+ }
+ }
+
+ @Override
+ public void callOnTimer() {
+
onTimerCollector.collect(GenericRowData.of(StringData.fromString("fired")));
+ }
+
+ private void emit(String label) {
+
evalCollector.collect(GenericRowData.of(StringData.fromString(label)));
+ }
+ }
+}