This is an automated email from the ASF dual-hosted git repository.
snuyanzin pushed a commit to branch release-2.4
in repository https://gitbox.apache.org/repos/asf/flink.git
The following commit(s) were added to refs/heads/release-2.4 by this push:
new eb55b8a02cb [FLINK-40842][table] Handle upsert inputs in the streaming
semi/anti join
eb55b8a02cb is described below
commit eb55b8a02cb0dbb887b6bbdbd0c252e332738c3e
Author: Rion Williams <[email protected]>
AuthorDate: Fri Oct 30 16:10:35 2026 -0500
[FLINK-40842][table] Handle upsert inputs in the streaming semi/anti join
---
.../plan/nodes/exec/stream/StreamExecJoin.java | 13 +-
.../plan/nodes/exec/common/JoinTestPrograms.java | 62 ++++
.../plan/nodes/exec/stream/JoinRestoreTest.java | 4 +-
...n-semi-join-upsert-inputs-non-equi-pre-2-4.json | 211 ++++++++++++
.../savepoint/_metadata | Bin 0 -> 9670 bytes
.../join-semi-join-upsert-inputs-non-equi.json | 213 ++++++++++++
.../savepoint/_metadata | Bin 0 -> 9670 bytes
.../join/stream/AbstractStreamingJoinOperator.java | 108 ++++++
.../join/stream/StreamingJoinOperator.java | 143 ++------
.../join/stream/StreamingSemiAntiJoinOperator.java | 199 +++++++----
.../stream/state/OuterJoinRecordStateView.java | 9 +
.../stream/state/OuterJoinRecordStateViews.java | 17 +
.../join/stream/StreamingJoinOperatorTest.java | 16 -
.../join/stream/StreamingJoinOperatorTestBase.java | 24 ++
.../stream/StreamingSemiAntiJoinOperatorTest.java | 362 ++++++++++++++++++++-
15 files changed, 1157 insertions(+), 224 deletions(-)
diff --git
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/nodes/exec/stream/StreamExecJoin.java
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/nodes/exec/stream/StreamExecJoin.java
index 684bdc1e10b..aa3175b1080 100644
---
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/nodes/exec/stream/StreamExecJoin.java
+++
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/nodes/exec/stream/StreamExecJoin.java
@@ -224,6 +224,11 @@ public class StreamExecJoin extends ExecNodeBase<RowData>
TwoInputStreamOperator<RowData, RowData, RowData> operator;
FlinkJoinType joinType = joinSpec.getJoinType();
final boolean isMiniBatchEnabled =
MinibatchUtil.isMiniBatchEnabled(config);
+ final RuntimeChangelogMode leftRuntimeChangelogMode =
+ toRuntimeChangelogMode(leftInputChangelogMode);
+ final RuntimeChangelogMode rightRuntimeChangelogMode =
+ toRuntimeChangelogMode(rightInputChangelogMode);
+ final boolean hasNonEquiCondition =
joinSpec.getNonEquiCondition().isPresent();
if (joinType == FlinkJoinType.ANTI || joinType == FlinkJoinType.SEMI) {
operator =
new StreamingSemiAntiJoinOperator(
@@ -233,6 +238,9 @@ public class StreamExecJoin extends ExecNodeBase<RowData>
generatedCondition,
leftInputSpec,
rightInputSpec,
+ leftRuntimeChangelogMode,
+ rightRuntimeChangelogMode,
+ hasNonEquiCondition,
joinSpec.getFilterNulls(),
leftStateRetentionTime,
rightStateRetentionTime);
@@ -240,11 +248,6 @@ public class StreamExecJoin extends ExecNodeBase<RowData>
boolean leftIsOuter = joinType == FlinkJoinType.LEFT || joinType
== FlinkJoinType.FULL;
boolean rightIsOuter =
joinType == FlinkJoinType.RIGHT || joinType ==
FlinkJoinType.FULL;
- final RuntimeChangelogMode leftRuntimeChangelogMode =
- toRuntimeChangelogMode(leftInputChangelogMode);
- final RuntimeChangelogMode rightRuntimeChangelogMode =
- toRuntimeChangelogMode(rightInputChangelogMode);
- final boolean hasNonEquiCondition =
joinSpec.getNonEquiCondition().isPresent();
if (isMiniBatchEnabled) {
operator =
MiniBatchStreamingJoinOperator.newMiniBatchStreamJoinOperator(
diff --git
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/common/JoinTestPrograms.java
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/common/JoinTestPrograms.java
index 150a2b31b60..7cb27378d12 100644
---
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/common/JoinTestPrograms.java
+++
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/common/JoinTestPrograms.java
@@ -45,6 +45,8 @@ public class JoinTestPrograms {
public static final TableTestProgram LEFT_JOIN_UPSERT_INPUT_NON_EQUI;
public static final TableTestProgram
LEFT_JOIN_UPSERT_INPUT_NON_EQUI_PRE_2_4;
public static final TableTestProgram INNER_JOIN_KEY_ONLY_DELETES_NON_EQUI;
+ public static final TableTestProgram SEMI_JOIN_UPSERT_INPUTS_NON_EQUI;
+ public static final TableTestProgram
SEMI_JOIN_UPSERT_INPUTS_NON_EQUI_PRE_2_4;
static final SourceTestStep EMPLOYEE =
SourceTestStep.newBuilder("EMPLOYEE")
@@ -565,6 +567,66 @@ public class JoinTestPrograms {
+ "FROM orders o JOIN lines l "
+ "ON o.order_id = l.order_id AND
l.status <> o.excluded_status")
.build();
+
+ SEMI_JOIN_UPSERT_INPUTS_NON_EQUI =
+ semiJoinWithUpsertInputsAndNonEquiCondition(
+ "join-semi-join-upsert-inputs-non-equi",
+ "semi join with upsert inputs that delete by key and a
non-equi condition");
+
+ // its plan and savepoint are compiled with Flink 2.3, whose plans
have no input changelog
+ // modes
+ SEMI_JOIN_UPSERT_INPUTS_NON_EQUI_PRE_2_4 =
+ semiJoinWithUpsertInputsAndNonEquiCondition(
+ "join-semi-join-upsert-inputs-non-equi-pre-2-4",
+ "semi join with upsert inputs that delete by key and a
non-equi condition restored from a Flink 2.3 plan");
+ }
+
+ /**
+ * The line of order 1 is updated without UPDATE_BEFORE and deleted by
key, the replaced version
+ * of the line is only in the restored state; order 2 stops matching its
line.
+ */
+ private static TableTestProgram
semiJoinWithUpsertInputsAndNonEquiCondition(
+ String id, String description) {
+ return TableTestProgram.of(id, description)
+ .setupTableSource(
+ SourceTestStep.newBuilder("orders")
+ .addSchema(
+ "order_id INT PRIMARY KEY NOT
ENFORCED",
+ "excluded_status STRING")
+ .addOption("changelog-mode", "I,UA,D")
+ .addOption("source.produces-delete-by-key",
"true")
+ .producedBeforeRestore(
+ Row.ofKind(RowKind.INSERT, 1,
"cancelled"),
+ Row.ofKind(RowKind.INSERT, 2,
"cancelled"))
+
.producedAfterRestore(Row.ofKind(RowKind.UPDATE_AFTER, 2, "paid"))
+ .build())
+ .setupTableSource(
+ SourceTestStep.newBuilder("lines")
+ .addSchema("order_id INT PRIMARY KEY NOT
ENFORCED", "status STRING")
+ .addOption("changelog-mode", "I,UA,D")
+ .addOption("source.produces-delete-by-key",
"true")
+ .producedBeforeRestore(
+ Row.ofKind(RowKind.INSERT, 1, "new"),
+ Row.ofKind(RowKind.INSERT, 2, "paid"))
+ .producedAfterRestore(
+ Row.ofKind(RowKind.UPDATE_AFTER, 1,
"paid"),
+ Row.ofKind(RowKind.DELETE, 1, null))
+ .build())
+ .setupTableSink(
+ SinkTestStep.newBuilder("MySink")
+ .addSchema(
+ "order_id INT PRIMARY KEY NOT
ENFORCED",
+ "excluded_status STRING")
+ .addOption("changelog-mode", "I,UA,D")
+ .addOption("sink.supports-delete-by-key",
"true")
+ .consumedBeforeRestore("+I[1, cancelled]",
"+I[2, cancelled]")
+ .consumedAfterRestore("-D[2, cancelled]",
"-D[1, cancelled]")
+ .build())
+ .runSql(
+ "INSERT INTO MySink SELECT * FROM orders o WHERE
EXISTS ("
+ + "SELECT 1 FROM lines l WHERE l.order_id =
o.order_id "
+ + "AND l.status <> o.excluded_status)")
+ .build();
}
/**
diff --git
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/JoinRestoreTest.java
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/JoinRestoreTest.java
index 813d85e45c3..517c5b8c8a8 100644
---
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/JoinRestoreTest.java
+++
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/JoinRestoreTest.java
@@ -52,6 +52,8 @@ public class JoinRestoreTest extends RestoreTestBase {
JoinTestPrograms.SEMI_ANTI_JOIN_WITH_LITERAL_AGG,
JoinTestPrograms.LEFT_JOIN_UPSERT_INPUT_NON_EQUI,
JoinTestPrograms.LEFT_JOIN_UPSERT_INPUT_NON_EQUI_PRE_2_4,
- JoinTestPrograms.INNER_JOIN_KEY_ONLY_DELETES_NON_EQUI);
+ JoinTestPrograms.INNER_JOIN_KEY_ONLY_DELETES_NON_EQUI,
+ JoinTestPrograms.SEMI_JOIN_UPSERT_INPUTS_NON_EQUI,
+ JoinTestPrograms.SEMI_JOIN_UPSERT_INPUTS_NON_EQUI_PRE_2_4);
}
}
diff --git
a/flink-table/flink-table-planner/src/test/resources/restore-tests/stream-exec-join_1/join-semi-join-upsert-inputs-non-equi-pre-2-4/plan/join-semi-join-upsert-inputs-non-equi-pre-2-4.json
b/flink-table/flink-table-planner/src/test/resources/restore-tests/stream-exec-join_1/join-semi-join-upsert-inputs-non-equi-pre-2-4/plan/join-semi-join-upsert-inputs-non-equi-pre-2-4.json
new file mode 100644
index 00000000000..e1dcdd79b3a
--- /dev/null
+++
b/flink-table/flink-table-planner/src/test/resources/restore-tests/stream-exec-join_1/join-semi-join-upsert-inputs-non-equi-pre-2-4/plan/join-semi-join-upsert-inputs-non-equi-pre-2-4.json
@@ -0,0 +1,211 @@
+{
+ "flinkVersion" : "2.3",
+ "nodes" : [ {
+ "id" : 1,
+ "type" : "stream-exec-table-source-scan_2",
+ "scanTableSource" : {
+ "table" : {
+ "identifier" : "`default_catalog`.`default_database`.`orders`",
+ "resolvedTable" : {
+ "schema" : {
+ "columns" : [ {
+ "name" : "order_id",
+ "dataType" : "INT NOT NULL"
+ }, {
+ "name" : "excluded_status",
+ "dataType" : "VARCHAR(2147483647)"
+ } ],
+ "primaryKey" : {
+ "name" : "PK_order_id",
+ "type" : "PRIMARY_KEY",
+ "columns" : [ "order_id" ]
+ }
+ }
+ }
+ }
+ },
+ "outputType" : "ROW<`order_id` INT NOT NULL, `excluded_status`
VARCHAR(2147483647)>",
+ "description" : "TableSourceScan(table=[[default_catalog,
default_database, orders]], fields=[order_id, excluded_status])"
+ }, {
+ "id" : 2,
+ "type" : "stream-exec-exchange_1",
+ "inputProperties" : [ {
+ "requiredDistribution" : {
+ "type" : "HASH",
+ "keys" : [ 0 ]
+ },
+ "damBehavior" : "PIPELINED",
+ "priority" : 0
+ } ],
+ "outputType" : "ROW<`order_id` INT NOT NULL, `excluded_status`
VARCHAR(2147483647)>",
+ "description" : "Exchange(distribution=[hash[order_id]])"
+ }, {
+ "id" : 3,
+ "type" : "stream-exec-table-source-scan_2",
+ "scanTableSource" : {
+ "table" : {
+ "identifier" : "`default_catalog`.`default_database`.`lines`",
+ "resolvedTable" : {
+ "schema" : {
+ "columns" : [ {
+ "name" : "order_id",
+ "dataType" : "INT NOT NULL"
+ }, {
+ "name" : "status",
+ "dataType" : "VARCHAR(2147483647)"
+ } ],
+ "primaryKey" : {
+ "name" : "PK_order_id",
+ "type" : "PRIMARY_KEY",
+ "columns" : [ "order_id" ]
+ }
+ }
+ }
+ }
+ },
+ "outputType" : "ROW<`order_id` INT NOT NULL, `status`
VARCHAR(2147483647)>",
+ "description" : "TableSourceScan(table=[[default_catalog,
default_database, lines]], fields=[order_id, status])"
+ }, {
+ "id" : 4,
+ "type" : "stream-exec-exchange_1",
+ "inputProperties" : [ {
+ "requiredDistribution" : {
+ "type" : "HASH",
+ "keys" : [ 0 ]
+ },
+ "damBehavior" : "PIPELINED",
+ "priority" : 0
+ } ],
+ "outputType" : "ROW<`order_id` INT NOT NULL, `status`
VARCHAR(2147483647)>",
+ "description" : "Exchange(distribution=[hash[order_id]])"
+ }, {
+ "id" : 5,
+ "type" : "stream-exec-join_1",
+ "joinSpec" : {
+ "joinType" : "SEMI",
+ "leftKeys" : [ 0 ],
+ "rightKeys" : [ 0 ],
+ "filterNulls" : [ true ],
+ "nonEquiCondition" : {
+ "kind" : "CALL",
+ "syntax" : "BINARY",
+ "internalName" : "$<>$1",
+ "operands" : [ {
+ "kind" : "INPUT_REF",
+ "inputIndex" : 3,
+ "type" : "VARCHAR(2147483647)"
+ }, {
+ "kind" : "INPUT_REF",
+ "inputIndex" : 1,
+ "type" : "VARCHAR(2147483647)"
+ } ],
+ "type" : "BOOLEAN"
+ }
+ },
+ "leftUpsertKeys" : [ [ 0 ] ],
+ "rightUpsertKeys" : [ [ 0 ] ],
+ "state" : [ {
+ "index" : 0,
+ "ttl" : "0 ms",
+ "name" : "leftState"
+ }, {
+ "index" : 1,
+ "ttl" : "0 ms",
+ "name" : "rightState"
+ } ],
+ "inputProperties" : [ {
+ "requiredDistribution" : {
+ "type" : "UNKNOWN"
+ },
+ "damBehavior" : "PIPELINED",
+ "priority" : 0
+ }, {
+ "requiredDistribution" : {
+ "type" : "UNKNOWN"
+ },
+ "damBehavior" : "PIPELINED",
+ "priority" : 0
+ } ],
+ "outputType" : "ROW<`order_id` INT NOT NULL, `excluded_status`
VARCHAR(2147483647)>",
+ "description" : "Join(joinType=[LeftSemiJoin], where=[((order_id0 =
order_id) AND (status <> excluded_status))], select=[order_id,
excluded_status], leftInputSpec=[JoinKeyContainsUniqueKey],
rightInputSpec=[JoinKeyContainsUniqueKey])"
+ }, {
+ "id" : 6,
+ "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`.`MySink`",
+ "resolvedTable" : {
+ "schema" : {
+ "columns" : [ {
+ "name" : "order_id",
+ "dataType" : "INT NOT NULL"
+ }, {
+ "name" : "excluded_status",
+ "dataType" : "VARCHAR(2147483647)"
+ } ],
+ "primaryKey" : {
+ "name" : "PK_order_id",
+ "type" : "PRIMARY_KEY",
+ "columns" : [ "order_id" ]
+ }
+ }
+ }
+ }
+ },
+ "inputChangelogMode" : [ "INSERT", "UPDATE_AFTER", "~DELETE" ],
+ "upsertMaterializeStrategy" : "ADAPTIVE",
+ "inputUpsertKey" : [ 0 ],
+ "inputProperties" : [ {
+ "requiredDistribution" : {
+ "type" : "UNKNOWN"
+ },
+ "damBehavior" : "PIPELINED",
+ "priority" : 0
+ } ],
+ "outputType" : "ROW<`order_id` INT NOT NULL, `excluded_status`
VARCHAR(2147483647)>",
+ "description" : "Sink(table=[default_catalog.default_database.MySink],
fields=[order_id, excluded_status])"
+ } ],
+ "edges" : [ {
+ "source" : 1,
+ "target" : 2,
+ "shuffle" : {
+ "type" : "FORWARD"
+ },
+ "shuffleMode" : "PIPELINED"
+ }, {
+ "source" : 3,
+ "target" : 4,
+ "shuffle" : {
+ "type" : "FORWARD"
+ },
+ "shuffleMode" : "PIPELINED"
+ }, {
+ "source" : 2,
+ "target" : 5,
+ "shuffle" : {
+ "type" : "FORWARD"
+ },
+ "shuffleMode" : "PIPELINED"
+ }, {
+ "source" : 4,
+ "target" : 5,
+ "shuffle" : {
+ "type" : "FORWARD"
+ },
+ "shuffleMode" : "PIPELINED"
+ }, {
+ "source" : 5,
+ "target" : 6,
+ "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-join_1/join-semi-join-upsert-inputs-non-equi-pre-2-4/savepoint/_metadata
b/flink-table/flink-table-planner/src/test/resources/restore-tests/stream-exec-join_1/join-semi-join-upsert-inputs-non-equi-pre-2-4/savepoint/_metadata
new file mode 100644
index 00000000000..cdd3061de08
Binary files /dev/null and
b/flink-table/flink-table-planner/src/test/resources/restore-tests/stream-exec-join_1/join-semi-join-upsert-inputs-non-equi-pre-2-4/savepoint/_metadata
differ
diff --git
a/flink-table/flink-table-planner/src/test/resources/restore-tests/stream-exec-join_1/join-semi-join-upsert-inputs-non-equi/plan/join-semi-join-upsert-inputs-non-equi.json
b/flink-table/flink-table-planner/src/test/resources/restore-tests/stream-exec-join_1/join-semi-join-upsert-inputs-non-equi/plan/join-semi-join-upsert-inputs-non-equi.json
new file mode 100644
index 00000000000..24e34cee0ab
--- /dev/null
+++
b/flink-table/flink-table-planner/src/test/resources/restore-tests/stream-exec-join_1/join-semi-join-upsert-inputs-non-equi/plan/join-semi-join-upsert-inputs-non-equi.json
@@ -0,0 +1,213 @@
+{
+ "flinkVersion" : "2.4",
+ "nodes" : [ {
+ "id" : 1,
+ "type" : "stream-exec-table-source-scan_2",
+ "scanTableSource" : {
+ "table" : {
+ "identifier" : "`default_catalog`.`default_database`.`orders`",
+ "resolvedTable" : {
+ "schema" : {
+ "columns" : [ {
+ "name" : "order_id",
+ "dataType" : "INT NOT NULL"
+ }, {
+ "name" : "excluded_status",
+ "dataType" : "VARCHAR(2147483647)"
+ } ],
+ "primaryKey" : {
+ "name" : "PK_order_id",
+ "type" : "PRIMARY_KEY",
+ "columns" : [ "order_id" ]
+ }
+ }
+ }
+ }
+ },
+ "outputType" : "ROW<`order_id` INT NOT NULL, `excluded_status`
VARCHAR(2147483647)>",
+ "description" : "TableSourceScan(table=[[default_catalog,
default_database, orders]], fields=[order_id, excluded_status])"
+ }, {
+ "id" : 2,
+ "type" : "stream-exec-exchange_1",
+ "inputProperties" : [ {
+ "requiredDistribution" : {
+ "type" : "HASH",
+ "keys" : [ 0 ]
+ },
+ "damBehavior" : "PIPELINED",
+ "priority" : 0
+ } ],
+ "outputType" : "ROW<`order_id` INT NOT NULL, `excluded_status`
VARCHAR(2147483647)>",
+ "description" : "Exchange(distribution=[hash[order_id]])"
+ }, {
+ "id" : 3,
+ "type" : "stream-exec-table-source-scan_2",
+ "scanTableSource" : {
+ "table" : {
+ "identifier" : "`default_catalog`.`default_database`.`lines`",
+ "resolvedTable" : {
+ "schema" : {
+ "columns" : [ {
+ "name" : "order_id",
+ "dataType" : "INT NOT NULL"
+ }, {
+ "name" : "status",
+ "dataType" : "VARCHAR(2147483647)"
+ } ],
+ "primaryKey" : {
+ "name" : "PK_order_id",
+ "type" : "PRIMARY_KEY",
+ "columns" : [ "order_id" ]
+ }
+ }
+ }
+ }
+ },
+ "outputType" : "ROW<`order_id` INT NOT NULL, `status`
VARCHAR(2147483647)>",
+ "description" : "TableSourceScan(table=[[default_catalog,
default_database, lines]], fields=[order_id, status])"
+ }, {
+ "id" : 4,
+ "type" : "stream-exec-exchange_1",
+ "inputProperties" : [ {
+ "requiredDistribution" : {
+ "type" : "HASH",
+ "keys" : [ 0 ]
+ },
+ "damBehavior" : "PIPELINED",
+ "priority" : 0
+ } ],
+ "outputType" : "ROW<`order_id` INT NOT NULL, `status`
VARCHAR(2147483647)>",
+ "description" : "Exchange(distribution=[hash[order_id]])"
+ }, {
+ "id" : 5,
+ "type" : "stream-exec-join_1",
+ "joinSpec" : {
+ "joinType" : "SEMI",
+ "leftKeys" : [ 0 ],
+ "rightKeys" : [ 0 ],
+ "filterNulls" : [ true ],
+ "nonEquiCondition" : {
+ "kind" : "CALL",
+ "syntax" : "BINARY",
+ "internalName" : "$<>$1",
+ "operands" : [ {
+ "kind" : "INPUT_REF",
+ "inputIndex" : 3,
+ "type" : "VARCHAR(2147483647)"
+ }, {
+ "kind" : "INPUT_REF",
+ "inputIndex" : 1,
+ "type" : "VARCHAR(2147483647)"
+ } ],
+ "type" : "BOOLEAN"
+ }
+ },
+ "leftUpsertKeys" : [ [ 0 ] ],
+ "rightUpsertKeys" : [ [ 0 ] ],
+ "leftInputChangelogMode" : [ "INSERT", "UPDATE_AFTER", "~DELETE" ],
+ "rightInputChangelogMode" : [ "INSERT", "UPDATE_AFTER", "~DELETE" ],
+ "state" : [ {
+ "index" : 0,
+ "ttl" : "0 ms",
+ "name" : "leftState"
+ }, {
+ "index" : 1,
+ "ttl" : "0 ms",
+ "name" : "rightState"
+ } ],
+ "inputProperties" : [ {
+ "requiredDistribution" : {
+ "type" : "UNKNOWN"
+ },
+ "damBehavior" : "PIPELINED",
+ "priority" : 0
+ }, {
+ "requiredDistribution" : {
+ "type" : "UNKNOWN"
+ },
+ "damBehavior" : "PIPELINED",
+ "priority" : 0
+ } ],
+ "outputType" : "ROW<`order_id` INT NOT NULL, `excluded_status`
VARCHAR(2147483647)>",
+ "description" : "Join(joinType=[LeftSemiJoin], where=[((order_id0 =
order_id) AND (status <> excluded_status))], select=[order_id,
excluded_status], leftInputSpec=[JoinKeyContainsUniqueKey],
rightInputSpec=[JoinKeyContainsUniqueKey])"
+ }, {
+ "id" : 6,
+ "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`.`MySink`",
+ "resolvedTable" : {
+ "schema" : {
+ "columns" : [ {
+ "name" : "order_id",
+ "dataType" : "INT NOT NULL"
+ }, {
+ "name" : "excluded_status",
+ "dataType" : "VARCHAR(2147483647)"
+ } ],
+ "primaryKey" : {
+ "name" : "PK_order_id",
+ "type" : "PRIMARY_KEY",
+ "columns" : [ "order_id" ]
+ }
+ }
+ }
+ }
+ },
+ "inputChangelogMode" : [ "INSERT", "UPDATE_AFTER", "~DELETE" ],
+ "upsertMaterializeStrategy" : "VALUE",
+ "inputUpsertKey" : [ 0 ],
+ "inputProperties" : [ {
+ "requiredDistribution" : {
+ "type" : "UNKNOWN"
+ },
+ "damBehavior" : "PIPELINED",
+ "priority" : 0
+ } ],
+ "outputType" : "ROW<`order_id` INT NOT NULL, `excluded_status`
VARCHAR(2147483647)>",
+ "description" : "Sink(table=[default_catalog.default_database.MySink],
fields=[order_id, excluded_status])"
+ } ],
+ "edges" : [ {
+ "source" : 1,
+ "target" : 2,
+ "shuffle" : {
+ "type" : "FORWARD"
+ },
+ "shuffleMode" : "PIPELINED"
+ }, {
+ "source" : 3,
+ "target" : 4,
+ "shuffle" : {
+ "type" : "FORWARD"
+ },
+ "shuffleMode" : "PIPELINED"
+ }, {
+ "source" : 2,
+ "target" : 5,
+ "shuffle" : {
+ "type" : "FORWARD"
+ },
+ "shuffleMode" : "PIPELINED"
+ }, {
+ "source" : 4,
+ "target" : 5,
+ "shuffle" : {
+ "type" : "FORWARD"
+ },
+ "shuffleMode" : "PIPELINED"
+ }, {
+ "source" : 5,
+ "target" : 6,
+ "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-join_1/join-semi-join-upsert-inputs-non-equi/savepoint/_metadata
b/flink-table/flink-table-planner/src/test/resources/restore-tests/stream-exec-join_1/join-semi-join-upsert-inputs-non-equi/savepoint/_metadata
new file mode 100644
index 00000000000..ed6c84374d1
Binary files /dev/null and
b/flink-table/flink-table-planner/src/test/resources/restore-tests/stream-exec-join_1/join-semi-join-upsert-inputs-non-equi/savepoint/_metadata
differ
diff --git
a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/join/stream/AbstractStreamingJoinOperator.java
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/join/stream/AbstractStreamingJoinOperator.java
index 507d06cbe73..9594365830c 100644
---
a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/join/stream/AbstractStreamingJoinOperator.java
+++
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/join/stream/AbstractStreamingJoinOperator.java
@@ -22,6 +22,7 @@ import
org.apache.flink.api.common.functions.DefaultOpenContext;
import org.apache.flink.streaming.api.operators.AbstractStreamOperator;
import org.apache.flink.streaming.api.operators.TimestampedCollector;
import org.apache.flink.streaming.api.operators.TwoInputStreamOperator;
+import org.apache.flink.table.connector.ChangelogMode;
import org.apache.flink.table.data.RowData;
import org.apache.flink.table.runtime.generated.GeneratedJoinCondition;
import org.apache.flink.table.runtime.generated.JoinCondition;
@@ -30,6 +31,10 @@ import
org.apache.flink.table.runtime.operators.join.stream.state.JoinRecordStat
import
org.apache.flink.table.runtime.operators.join.stream.state.OuterJoinRecordStateView;
import
org.apache.flink.table.runtime.operators.join.stream.utils.JoinInputSideSpec;
import org.apache.flink.table.runtime.typeutils.InternalTypeInfo;
+import org.apache.flink.table.runtime.util.RuntimeChangelogMode;
+import org.apache.flink.types.RowKind;
+
+import javax.annotation.Nullable;
import java.util.Iterator;
import java.util.function.Function;
@@ -55,6 +60,17 @@ public abstract class AbstractStreamingJoinOperator extends
AbstractStreamOperat
protected final JoinInputSideSpec leftInputSideSpec;
protected final JoinInputSideSpec rightInputSideSpec;
+ // whether the input may update a record without retracting it first
(upsert), so that the
+ // record it replaces has to be looked up in state
+ private final boolean leftIsUpsertOrUnknown;
+ private final boolean rightIsUpsertOrUnknown;
+ // whether a DELETE of the input may contain only the unique key, so that
the full record has
+ // to be looked up in state to evaluate a non-equi condition
+ private final boolean leftHasKeyOnlyDeletesOrUnknown;
+ private final boolean rightHasKeyOnlyDeletesOrUnknown;
+ // whether the join condition has a non-equi part, e.g. A JOIN B ON A.k =
B.k AND A.v > B.v
+ protected final boolean hasNonEquiCondition;
+
private final boolean[] filterNullKeys;
protected final long leftStateRetentionTime;
@@ -69,6 +85,9 @@ public abstract class AbstractStreamingJoinOperator extends
AbstractStreamOperat
GeneratedJoinCondition generatedJoinCondition,
JoinInputSideSpec leftInputSideSpec,
JoinInputSideSpec rightInputSideSpec,
+ @Nullable RuntimeChangelogMode leftInputChangelogMode,
+ @Nullable RuntimeChangelogMode rightInputChangelogMode,
+ boolean hasNonEquiCondition,
boolean[] filterNullKeys,
long leftStateRetentionTime,
long rightStateRetentionTime) {
@@ -77,6 +96,11 @@ public abstract class AbstractStreamingJoinOperator extends
AbstractStreamOperat
this.generatedJoinCondition = generatedJoinCondition;
this.leftInputSideSpec = leftInputSideSpec;
this.rightInputSideSpec = rightInputSideSpec;
+ this.leftIsUpsertOrUnknown = isUpsertOrUnknown(leftInputChangelogMode);
+ this.rightIsUpsertOrUnknown =
isUpsertOrUnknown(rightInputChangelogMode);
+ this.leftHasKeyOnlyDeletesOrUnknown =
hasKeyOnlyDeletesOrUnknown(leftInputChangelogMode);
+ this.rightHasKeyOnlyDeletesOrUnknown =
hasKeyOnlyDeletesOrUnknown(rightInputChangelogMode);
+ this.hasNonEquiCondition = hasNonEquiCondition;
this.leftStateRetentionTime = leftStateRetentionTime;
this.rightStateRetentionTime = rightStateRetentionTime;
this.filterNullKeys = filterNullKeys;
@@ -102,6 +126,90 @@ public abstract class AbstractStreamingJoinOperator
extends AbstractStreamOperat
}
}
+ /**
+ * Returns the stored record that the given record replaces, or null if
there is none. An upsert
+ * input updates a record by sending only the new version, without
retracting the old one.
+ *
+ * <p>The lookup only happens if it can change the result.
+ */
+ protected @Nullable RowData replacedRecord(
+ RowData record, JoinRecordStateView stateView, boolean isLeft,
boolean otherIsOuter)
+ throws Exception {
+ return needsReplacedRecord(isLeft, otherIsOuter) ?
stateView.getRecord(record) : null;
+ }
+
+ /**
+ * Returns whether looking up the record that an input record replaces can
change the result.
+ */
+ protected boolean needsReplacedRecord(boolean isLeft, boolean
otherIsOuter) {
+ final boolean inputIsUpsertOrUnknown =
+ isLeft ? leftIsUpsertOrUnknown : rightIsUpsertOrUnknown;
+ // with at most one record per join key, the count of the other side
is right without it
+ final boolean mayCountTwice = otherIsOuter &&
!joinKeyContainsUniqueKey(isLeft);
+ final boolean mayMatchDifferently = hasNonEquiCondition;
+ return inputIsUpsertOrUnknown && (mayCountTwice ||
mayMatchDifferently);
+ }
+
+ /**
+ * Returns the full record to retract.
+ *
+ * <p>A DELETE may contain only the unique key, e.g. from an upsert
source, but a non-equi
+ * condition needs all fields. In that case, the stored record with the
same unique key is
+ * returned, or the given retraction if nothing is stored.
+ */
+ protected RowData retractedRecord(
+ RowData retraction,
+ RowKind retractionKind,
+ JoinRecordStateView stateView,
+ boolean isLeft)
+ throws Exception {
+ final boolean inputHasKeyOnlyDeletesOrUnknown =
+ isLeft ? leftHasKeyOnlyDeletesOrUnknown :
rightHasKeyOnlyDeletesOrUnknown;
+ final boolean mayBeKeyOnlyDelete =
+ retractionKind == RowKind.DELETE
+ && inputHasKeyOnlyDeletesOrUnknown
+ && hasUniqueKey(isLeft);
+ if (!mayBeKeyOnlyDelete || !hasNonEquiCondition) {
+ return retraction;
+ }
+ final RowData storedRecord = stateView.getRecord(retraction);
+ return storedRecord != null ? storedRecord : retraction;
+ }
+
+ protected boolean matches(RowData inputRecord, RowData otherRecord,
boolean inputIsLeft) {
+ return inputIsLeft
+ ? joinCondition.apply(inputRecord, otherRecord)
+ : joinCondition.apply(otherRecord, inputRecord);
+ }
+
+ protected boolean joinKeyContainsUniqueKey(boolean isLeft) {
+ return (isLeft ? leftInputSideSpec :
rightInputSideSpec).joinKeyContainsUniqueKey();
+ }
+
+ private boolean hasUniqueKey(boolean isLeft) {
+ return (isLeft ? leftInputSideSpec :
rightInputSideSpec).hasUniqueKey();
+ }
+
+ /** Returns whether the input may update a record without retracting it
first. */
+ private static boolean isUpsertOrUnknown(@Nullable RuntimeChangelogMode
inputChangelogMode) {
+ // compiled plans before Flink 2.4 do not contain the changelog mode,
and handling
+ // their inputs as upsert is always correct
+ if (inputChangelogMode == null) {
+ return true;
+ }
+ final ChangelogMode changelogMode = inputChangelogMode.deserialize();
+ return changelogMode.contains(RowKind.UPDATE_AFTER)
+ && !changelogMode.contains(RowKind.UPDATE_BEFORE);
+ }
+
+ /** Returns whether a DELETE of the input may contain only the unique key.
*/
+ private static boolean hasKeyOnlyDeletesOrUnknown(
+ @Nullable RuntimeChangelogMode inputChangelogMode) {
+ // compiled plans before Flink 2.4 do not contain the changelog mode,
and handling
+ // their deletes as key-only is always correct
+ return inputChangelogMode == null ||
inputChangelogMode.deserialize().keyOnlyDeletes();
+ }
+
/** Wrap the passed iterator with filter and mapper. */
private static <I, O> Iterator<O> collect(
final Iterator<I> delegate, Function<I, Boolean> matcher,
Function<I, O> mapper) {
diff --git
a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/join/stream/StreamingJoinOperator.java
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/join/stream/StreamingJoinOperator.java
index 16b879ee9e3..00c4f9395e2 100644
---
a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/join/stream/StreamingJoinOperator.java
+++
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/join/stream/StreamingJoinOperator.java
@@ -19,7 +19,6 @@
package org.apache.flink.table.runtime.operators.join.stream;
import org.apache.flink.streaming.runtime.streamrecord.StreamRecord;
-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.util.RowDataUtil;
@@ -47,20 +46,6 @@ public class StreamingJoinOperator extends
AbstractStreamingJoinOperator {
protected final boolean leftIsOuter;
// whether right side is outer side, e.g. right is outer but left is not
when RIGHT OUTER JOIN
protected final boolean rightIsOuter;
- // changelog mode of the left input, null for compiled plans before Flink
2.4
- @Nullable protected final RuntimeChangelogMode leftInputChangelogMode;
- // changelog mode of the right input, null for compiled plans before Flink
2.4
- @Nullable protected final RuntimeChangelogMode rightInputChangelogMode;
- // whether the input may update a record without retracting it first
(upsert), so that the
- // record it replaces has to be looked up in state
- private final boolean leftIsUpsertOrUnknown;
- private final boolean rightIsUpsertOrUnknown;
- // whether a DELETE of the input may contain only the unique key, so that
the full record has
- // to be looked up in state to evaluate a non-equi condition
- private final boolean leftHasKeyOnlyDeletesOrUnknown;
- private final boolean rightHasKeyOnlyDeletesOrUnknown;
- // whether the join condition has a non-equi part, e.g. A JOIN B ON A.k =
B.k AND A.v > B.v
- protected final boolean hasNonEquiCondition;
private transient JoinedRowData outRow;
private transient RowData leftNullRow;
@@ -91,18 +76,14 @@ public class StreamingJoinOperator extends
AbstractStreamingJoinOperator {
generatedJoinCondition,
leftInputSideSpec,
rightInputSideSpec,
+ leftInputChangelogMode,
+ rightInputChangelogMode,
+ hasNonEquiCondition,
filterNullKeys,
leftStateRetentionTime,
rightStateRetentionTime);
this.leftIsOuter = leftIsOuter;
this.rightIsOuter = rightIsOuter;
- this.leftInputChangelogMode = leftInputChangelogMode;
- this.rightInputChangelogMode = rightInputChangelogMode;
- this.leftIsUpsertOrUnknown = isUpsertOrUnknown(leftInputChangelogMode);
- this.rightIsUpsertOrUnknown =
isUpsertOrUnknown(rightInputChangelogMode);
- this.leftHasKeyOnlyDeletesOrUnknown =
hasKeyOnlyDeletesOrUnknown(leftInputChangelogMode);
- this.rightHasKeyOnlyDeletesOrUnknown =
hasKeyOnlyDeletesOrUnknown(rightInputChangelogMode);
- this.hasNonEquiCondition = hasNonEquiCondition;
}
@Override
@@ -273,8 +254,11 @@ public class StreamingJoinOperator extends
AbstractStreamingJoinOperator {
}
return;
}
+ // in a mini-batch pair, the retraction already handled the old
version
final RowData replacedRecord =
- replacedRecord(input, inputSideStateView, inputIsLeft,
isSuppress);
+ isSuppress
+ ? null
+ : replacedRecord(input, inputSideStateView,
inputIsLeft, otherIsOuter);
boolean replacedRecordHadNoMatches = false;
if (replacedRecord != null && hasNonEquiCondition) {
replacedRecordHadNoMatches =
@@ -365,9 +349,11 @@ public class StreamingJoinOperator extends
AbstractStreamingJoinOperator {
// skip when there is no matched rows on the other side
}
} else { // input record is retract
+ // the retraction of a mini-batch pair is used as it is
final RowData retractedRecord =
- retractedRecord(
- input, inputRowKind, inputSideStateView,
inputIsLeft, isSuppress);
+ isSuppress
+ ? input
+ : retractedRecord(input, inputRowKind,
inputSideStateView, inputIsLeft);
// state.retract(record)
if (!isSuppress) {
inputSideStateView.retractRecord(retractedRecord);
@@ -414,56 +400,19 @@ public class StreamingJoinOperator extends
AbstractStreamingJoinOperator {
}
/**
- * Returns the stored record that the given record replaces, or null if
there is none. An upsert
- * input updates a record by sending only the new version, without
retracting the old one.
- *
- * <p>The lookup only happens if it can change the result. In a mini-batch
pair, the retraction
- * already handled the old version.
- */
- private @Nullable RowData replacedRecord(
- RowData record, JoinRecordStateView stateView, boolean isLeft,
boolean isSuppress)
- throws Exception {
- final boolean inputIsUpsertOrUnknown =
- isLeft ? leftIsUpsertOrUnknown : rightIsUpsertOrUnknown;
- final boolean otherIsOuter = isLeft ? rightIsOuter : leftIsOuter;
- // with at most one record per join key, the count of the other side
is right without it
- final boolean mayCountTwice = otherIsOuter &&
!joinKeyContainsUniqueKey(isLeft);
- final boolean mayMatchDifferently = hasNonEquiCondition;
- if (isSuppress || !inputIsUpsertOrUnknown || !(mayCountTwice ||
mayMatchDifferently)) {
- return null;
- }
- return stateView.getRecord(record);
- }
-
- /**
- * Returns the full record to retract.
- *
- * <p>A DELETE may contain only the unique key, e.g. from an upsert
source, but a non-equi
- * condition needs all fields. In that case, the stored record with the
same unique key is
- * returned, or the given retraction if nothing is stored. The retraction
of a mini-batch pair
- * is returned as it is.
+ * Returns whether the other record gains a match, so that its number of
associations increases.
+ * It does not if the replaced record matched it already.
*/
- private RowData retractedRecord(
- RowData retraction,
- RowKind retractionKind,
- JoinRecordStateView stateView,
- boolean isLeft,
- boolean isSuppress)
- throws Exception {
- if (isSuppress) {
- return retraction;
+ private boolean isNewMatch(
+ OuterRecord other, @Nullable RowData replacedRecord, boolean
inputIsLeft) {
+ if (other.numOfAssociations == 0) {
+ return true;
}
- final boolean inputHasKeyOnlyDeletesOrUnknown =
- isLeft ? leftHasKeyOnlyDeletesOrUnknown :
rightHasKeyOnlyDeletesOrUnknown;
- final boolean mayBeKeyOnlyDelete =
- retractionKind == RowKind.DELETE
- && inputHasKeyOnlyDeletesOrUnknown
- && hasUniqueKey(isLeft);
- if (!mayBeKeyOnlyDelete || !hasNonEquiCondition) {
- return retraction;
+ // with at most one record per join key, the existing match is the
replaced record
+ if (joinKeyContainsUniqueKey(inputIsLeft)) {
+ return false;
}
- final RowData storedRecord = stateView.getRecord(retraction);
- return storedRecord != null ? storedRecord : retraction;
+ return replacedRecord == null || !matches(replacedRecord,
other.record, inputIsLeft);
}
/**
@@ -506,22 +455,6 @@ public class StreamingJoinOperator extends
AbstractStreamingJoinOperator {
return replacedRecordHadMatches;
}
- /**
- * Returns whether the other record gains a match, so that its number of
associations increases.
- * It does not if the replaced record matched it already.
- */
- private boolean isNewMatch(
- OuterRecord other, @Nullable RowData replacedRecord, boolean
inputIsLeft) {
- if (other.numOfAssociations == 0) {
- return true;
- }
- // with at most one record per join key, the existing match is the
replaced record
- if (joinKeyContainsUniqueKey(inputIsLeft)) {
- return false;
- }
- return replacedRecord == null || !matches(replacedRecord,
other.record, inputIsLeft);
- }
-
/**
* Returns whether the paired record of a -U/+U pair cancels out the null
padding change of the
* other record, which is the case if both records of the pair match it.
@@ -532,40 +465,6 @@ public class StreamingJoinOperator extends
AbstractStreamingJoinOperator {
&& (!hasNonEquiCondition || matches(pairedRecord, other,
inputIsLeft));
}
- /** Returns whether the input may update a record without retracting it
first. */
- private static boolean isUpsertOrUnknown(@Nullable RuntimeChangelogMode
inputChangelogMode) {
- // compiled plans before Flink 2.4 do not contain the changelog mode,
and handling
- // their inputs as upsert is always correct
- if (inputChangelogMode == null) {
- return true;
- }
- final ChangelogMode changelogMode = inputChangelogMode.deserialize();
- return changelogMode.contains(RowKind.UPDATE_AFTER)
- && !changelogMode.contains(RowKind.UPDATE_BEFORE);
- }
-
- /** Returns whether a DELETE of the input may contain only the unique key.
*/
- private static boolean hasKeyOnlyDeletesOrUnknown(
- @Nullable RuntimeChangelogMode inputChangelogMode) {
- // compiled plans before Flink 2.4 do not contain the changelog mode,
and handling
- // their deletes as key-only is always correct
- return inputChangelogMode == null ||
inputChangelogMode.deserialize().keyOnlyDeletes();
- }
-
- private boolean hasUniqueKey(boolean isLeft) {
- return (isLeft ? leftInputSideSpec :
rightInputSideSpec).hasUniqueKey();
- }
-
- private boolean joinKeyContainsUniqueKey(boolean isLeft) {
- return (isLeft ? leftInputSideSpec :
rightInputSideSpec).joinKeyContainsUniqueKey();
- }
-
- private boolean matches(RowData inputRecord, RowData otherRecord, boolean
inputIsLeft) {
- return inputIsLeft
- ? joinCondition.apply(inputRecord, otherRecord)
- : joinCondition.apply(otherRecord, inputRecord);
- }
-
//
-------------------------------------------------------------------------------------
private void output(RowData inputRow, RowData otherRow, boolean
inputIsLeft) {
diff --git
a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/join/stream/StreamingSemiAntiJoinOperator.java
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/join/stream/StreamingSemiAntiJoinOperator.java
index cc770771bb4..8d3c6482598 100644
---
a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/join/stream/StreamingSemiAntiJoinOperator.java
+++
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/join/stream/StreamingSemiAntiJoinOperator.java
@@ -18,6 +18,7 @@
package org.apache.flink.table.runtime.operators.join.stream;
+import org.apache.flink.api.java.tuple.Tuple2;
import org.apache.flink.streaming.runtime.streamrecord.StreamRecord;
import org.apache.flink.table.data.RowData;
import org.apache.flink.table.data.util.RowDataUtil;
@@ -28,10 +29,13 @@ import
org.apache.flink.table.runtime.operators.join.stream.state.OuterJoinRecor
import
org.apache.flink.table.runtime.operators.join.stream.state.OuterJoinRecordStateViews;
import
org.apache.flink.table.runtime.operators.join.stream.utils.JoinInputSideSpec;
import org.apache.flink.table.runtime.typeutils.InternalTypeInfo;
+import org.apache.flink.table.runtime.util.RuntimeChangelogMode;
import org.apache.flink.types.RowKind;
import org.apache.flink.shaded.guava33.com.google.common.collect.Iterators;
+import javax.annotation.Nullable;
+
import java.util.Iterator;
/** Streaming unbounded Join operator which supports SEMI/ANTI JOIN. */
@@ -54,6 +58,9 @@ public class StreamingSemiAntiJoinOperator extends
AbstractStreamingJoinOperator
GeneratedJoinCondition generatedJoinCondition,
JoinInputSideSpec leftInputSideSpec,
JoinInputSideSpec rightInputSideSpec,
+ @Nullable RuntimeChangelogMode leftInputChangelogMode,
+ @Nullable RuntimeChangelogMode rightInputChangelogMode,
+ boolean hasNonEquiCondition,
boolean[] filterNullKeys,
long leftStateRetentionTime,
long rightStateRetentionTIme) {
@@ -63,6 +70,9 @@ public class StreamingSemiAntiJoinOperator extends
AbstractStreamingJoinOperator
generatedJoinCondition,
leftInputSideSpec,
rightInputSideSpec,
+ leftInputChangelogMode,
+ rightInputChangelogMode,
+ hasNonEquiCondition,
filterNullKeys,
leftStateRetentionTime,
rightStateRetentionTIme);
@@ -97,6 +107,10 @@ public class StreamingSemiAntiJoinOperator extends
AbstractStreamingJoinOperator
* <p>Following is the pseudo code to describe the core logic of this
method.
*
* <pre>
+ * if record is a DELETE that may contain only the unique key and the
condition is non-equi
+ * record = the stored record with the same unique key, if any
+ * if record replaces a stored record (upsert) and the condition is
non-equi
+ * if the replaced record is sent (by its matched num) but the record is
not, send -D[replaced]
* if there is no matched rows on the other side
* if anti join, send input record
* if there are matched rows on the other side
@@ -109,29 +123,40 @@ public class StreamingSemiAntiJoinOperator extends
AbstractStreamingJoinOperator
@Override
public void processElement1(StreamRecord<RowData> element) throws
Exception {
RowData input = element.getValue();
- Iterator<OuterRecord> associatedRecords =
- AbstractStreamingJoinOperator.iterator(
- input, true, rightRecordStateView, joinCondition);
- if (!associatedRecords.hasNext()) {
- if (isAntiJoin) {
- collector.collect(input);
- }
- } else { // there are matched rows on the other side
- if (!isAntiJoin) {
- collector.collect(input);
+ boolean isAccumulateMsg = RowDataUtil.isAccumulateMsg(input);
+ RowKind inputRowKind = input.getRowKind();
+ input.setRowKind(RowKind.INSERT); // erase RowKind for later state
updating
+
+ if (isAccumulateMsg) {
+ final int numOfAssociations =
+ Iterators.size(
+ AbstractStreamingJoinOperator.iterator(
+ input, true, rightRecordStateView,
joinCondition));
+ if (isSent(numOfAssociations)) {
+ collect(input, inputRowKind);
+ } else if (needsReplacedRecord(true, false)) {
+ final Tuple2<RowData, Integer> replaced =
+
leftRecordStateView.getRecordAndNumOfAssociations(input);
+ if (replaced != null && isSent(replaced.f1)) {
+ // send -D[replaced]
+ collect(replaced.f0, RowKind.DELETE);
+ }
}
- }
- if (RowDataUtil.isAccumulateMsg(input)) {
- // erase RowKind for state updating
- input.setRowKind(RowKind.INSERT);
// do not store a record that can never match
if (!joinCondition.currentKeyCannotMatch()) {
- leftRecordStateView.addRecord(input,
Iterators.size(associatedRecords));
+ leftRecordStateView.addRecord(input, numOfAssociations);
+ }
+ } else {
+ final RowData retractedRecord =
+ retractedRecord(input, inputRowKind, leftRecordStateView,
true);
+ final boolean hasMatches =
+ AbstractStreamingJoinOperator.iterator(
+ retractedRecord, true,
rightRecordStateView, joinCondition)
+ .hasNext();
+ if (isAntiJoin != hasMatches) {
+ collect(retractedRecord, inputRowKind);
}
- } else { // input is retract
- // erase RowKind for state updating
- input.setRowKind(RowKind.INSERT);
- leftRecordStateView.retractRecord(input);
+ leftRecordStateView.retractRecord(retractedRecord);
}
}
@@ -147,27 +172,34 @@ public class StreamingSemiAntiJoinOperator extends
AbstractStreamingJoinOperator
* <pre>
* if input record is accumulate
* | state.add(record), unless the join key can never match
- * | if there is no matched rows on the other side, skip
- * | if there are matched rows on the other side
- * | | if the matched num in the matched rows == 0
- * | | if anti join, send -D[other]s
- * | | if semi join, send +I/+U[other]s (using input RowKind)
- * | | if the matched num in the matched rows > 0, skip
- * | | otherState.update(other, old+1)
- * | endif
+ * | for each other on the other side
+ * | | if record matches other
+ * | | | if the matched num of other <= 0
+ * | | | if anti join, send -D[other]
+ * | | | if semi join, send +I/+U[other] (using input RowKind)
+ * | | | otherState.update(other, 1)
+ * | | | else if the record replaces a stored record (upsert) that did not
match other
+ * | | | otherState.update(other, old+1)
+ * | | else if the record replaces a stored record (upsert) that matched
other
+ * | | | remove the match of other
+ * | endfor
* endif
* if input record is retract
- * | state.retract(record)
- * | if there is no matched rows on the other side, skip
- * | if there are matched rows on the other side
- * | | if the matched num in the matched rows == 0, this should never
happen!
- * | | if the matched num in the matched rows == 1
- * | | if semi join, send -D/-U[other] (using input RowKind)
- * | | if anti join, send +I[other]
- * | | if the matched num in the matched rows > 1, skip
- * | | otherState.update(other, old-1)
+ * | if record is a DELETE that may contain only the unique key and the
condition is non-equi
+ * | | record = the stored record with the same unique key, if any
* | endif
+ * | state.retract(record)
+ * | for each other on the other side that record matches
+ * | | remove the match of other
+ * | endfor
* endif
+ *
+ * remove the match of other:
+ * if the matched num of other <= 0, skip, as the record was not counted
+ * if the matched num of other == 1
+ * if semi join, send -D/-U[other] (using input RowKind)
+ * if anti join, send +I[other]
+ * otherState.update(other, old-1)
* </pre>
*/
@Override
@@ -177,52 +209,75 @@ public class StreamingSemiAntiJoinOperator extends
AbstractStreamingJoinOperator
RowKind inputRowKind = input.getRowKind();
input.setRowKind(RowKind.INSERT); // erase RowKind for later state
updating
- Iterator<OuterRecord> associatedRecords =
- AbstractStreamingJoinOperator.iterator(
- input, false, leftRecordStateView, joinCondition);
if (isAccumulateMsg) { // record is accumulate
+ final RowData replacedRecord = replacedRecord(input,
rightRecordStateView, false, true);
// do not store a record that can never match
if (!joinCondition.currentKeyCannotMatch()) {
rightRecordStateView.addRecord(input);
}
- while (associatedRecords.hasNext()) {
- OuterRecord outerRecord = associatedRecords.next();
- RowData other = outerRecord.record;
- if (outerRecord.numOfAssociations == 0) {
- if (isAntiJoin) {
- // send -D[other]
- other.setRowKind(RowKind.DELETE);
- } else {
- // send +I/+U[other] (using input RowKind)
- other.setRowKind(inputRowKind);
+ // a single pass for the matches that the record gains and the
replaced record loses
+ for (Tuple2<RowData, Integer> left :
+ leftRecordStateView.getRecordsAndNumOfAssociations()) {
+ final RowData other = left.f0;
+ final int numOfAssociations = left.f1;
+ final boolean replacedRecordMatches =
+ replacedRecord != null && matches(replacedRecord,
other, false);
+ if (matches(input, other, false)) {
+ if (numOfAssociations <= 0) {
+ // send -D[other] if anti join, +I/+U[other] (using
input RowKind) if semi
+ collect(other, isAntiJoin ? RowKind.DELETE :
inputRowKind);
+ leftRecordStateView.updateNumOfAssociations(other, 1);
+ } else if (!replacedRecordMatches &&
!joinKeyContainsUniqueKey(false)) {
+ // with at most one record per join key, the existing
match is the
+ // replaced record
+ leftRecordStateView.updateNumOfAssociations(other,
numOfAssociations + 1);
}
- collector.collect(other);
- // set header back to INSERT, because we will update the
other row to state
- other.setRowKind(RowKind.INSERT);
- } // ignore when number > 0
- leftRecordStateView.updateNumOfAssociations(
- other, outerRecord.numOfAssociations + 1);
+ } else if (replacedRecordMatches) {
+ removeMatch(other, numOfAssociations, RowKind.DELETE);
+ }
}
} else { // retract input
- rightRecordStateView.retractRecord(input);
+ final RowData retractedRecord =
+ retractedRecord(input, inputRowKind, rightRecordStateView,
false);
+ rightRecordStateView.retractRecord(retractedRecord);
+ Iterator<OuterRecord> associatedRecords =
+ AbstractStreamingJoinOperator.iterator(
+ retractedRecord, false, leftRecordStateView,
joinCondition);
while (associatedRecords.hasNext()) {
- OuterRecord outerRecord = associatedRecords.next();
- RowData other = outerRecord.record;
- if (outerRecord.numOfAssociations == 1) {
- if (!isAntiJoin) {
- // send -D/-U[other] (using input RowKind)
- other.setRowKind(inputRowKind);
- } else {
- // send +I[other]
- other.setRowKind(RowKind.INSERT);
- }
- collector.collect(other);
- // set RowKind back, because we will update the other row
to state
- other.setRowKind(RowKind.INSERT);
- } // ignore when number > 0
- leftRecordStateView.updateNumOfAssociations(
- other, outerRecord.numOfAssociations - 1);
+ final OuterRecord outerRecord = associatedRecords.next();
+ removeMatch(outerRecord.record, outerRecord.numOfAssociations,
inputRowKind);
}
}
}
+
+ /**
+ * Removes a match of the left record. If it was the last one, a semi join
retracts the record
+ * with the given RowKind and an anti join sends it.
+ */
+ private void removeMatch(RowData other, int numOfAssociations, RowKind
retractionKind)
+ throws Exception {
+ if (numOfAssociations <= 0) {
+ // the retracted record was not counted, e.g. it had expired when
the left record was
+ // stored
+ return;
+ }
+ if (numOfAssociations == 1) {
+ // send -D/-U[other] if semi join, +I[other] if anti join
+ collect(other, isAntiJoin ? RowKind.INSERT : retractionKind);
+ } // ignore when number > 1
+ leftRecordStateView.updateNumOfAssociations(other, numOfAssociations -
1);
+ }
+
+ /** Returns whether a left record with the given number of matches is
sent. */
+ private boolean isSent(int numOfAssociations) {
+ return isAntiJoin != (numOfAssociations > 0);
+ }
+
+ /** Sends the left record with the given RowKind. */
+ private void collect(RowData left, RowKind rowKind) {
+ left.setRowKind(rowKind);
+ collector.collect(left);
+ // set RowKind back, because the record may be the one in state
+ left.setRowKind(RowKind.INSERT);
+ }
}
diff --git
a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/join/stream/state/OuterJoinRecordStateView.java
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/join/stream/state/OuterJoinRecordStateView.java
index e9171e2cc19..e2547cf8021 100644
---
a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/join/stream/state/OuterJoinRecordStateView.java
+++
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/join/stream/state/OuterJoinRecordStateView.java
@@ -21,6 +21,8 @@ package
org.apache.flink.table.runtime.operators.join.stream.state;
import org.apache.flink.api.java.tuple.Tuple2;
import org.apache.flink.table.data.RowData;
+import javax.annotation.Nullable;
+
/**
* A {@link OuterJoinRecordStateView} is an extension to {@link
JoinRecordStateView}. The {@link
* OuterJoinRecordStateView} is used to store records for the outer input side
of the Join, e.g. the
@@ -54,4 +56,11 @@ public interface OuterJoinRecordStateView extends
JoinRecordStateView {
* Gets all the records and number of associations under the current
context (i.e. join key).
*/
Iterable<Tuple2<RowData, Integer>> getRecordsAndNumOfAssociations() throws
Exception;
+
+ /**
+ * Returns the record with the same unique key as the given record and its
number of
+ * associations, or null if there is none. Always null if the input side
has no unique key.
+ */
+ @Nullable
+ Tuple2<RowData, Integer> getRecordAndNumOfAssociations(RowData record)
throws Exception;
}
diff --git
a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/join/stream/state/OuterJoinRecordStateViews.java
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/join/stream/state/OuterJoinRecordStateViews.java
index c077933d57b..a792c687eac 100644
---
a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/join/stream/state/OuterJoinRecordStateViews.java
+++
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/join/stream/state/OuterJoinRecordStateViews.java
@@ -124,6 +124,12 @@ public final class OuterJoinRecordStateViews {
return tuple == null ? null : tuple.f0;
}
+ @Override
+ public Tuple2<RowData, Integer> getRecordAndNumOfAssociations(RowData
record)
+ throws Exception {
+ return recordState.value();
+ }
+
@Override
public Iterable<RowData> getRecords() throws Exception {
Tuple2<RowData, Integer> tuple = recordState.value();
@@ -204,6 +210,12 @@ public final class OuterJoinRecordStateViews {
return tuple == null ? null : tuple.f0;
}
+ @Override
+ public Tuple2<RowData, Integer> getRecordAndNumOfAssociations(RowData
record)
+ throws Exception {
+ return recordState.get(uniqueKeySelector.getKey(record));
+ }
+
@Override
public Iterable<RowData> getRecords() throws Exception {
return new RecordsIterable(getRecordsAndNumOfAssociations());
@@ -285,6 +297,11 @@ public final class OuterJoinRecordStateViews {
return null;
}
+ @Override
+ public Tuple2<RowData, Integer> getRecordAndNumOfAssociations(RowData
record) {
+ return null;
+ }
+
@Override
public Iterable<RowData> getRecords() throws Exception {
return new RecordsIterable(getRecordsAndNumOfAssociations());
diff --git
a/flink-table/flink-table-runtime/src/test/java/org/apache/flink/table/runtime/operators/join/stream/StreamingJoinOperatorTest.java
b/flink-table/flink-table-runtime/src/test/java/org/apache/flink/table/runtime/operators/join/stream/StreamingJoinOperatorTest.java
index 3a5b3083b69..c5b9b64ee7e 100644
---
a/flink-table/flink-table-runtime/src/test/java/org/apache/flink/table/runtime/operators/join/stream/StreamingJoinOperatorTest.java
+++
b/flink-table/flink-table-runtime/src/test/java/org/apache/flink/table/runtime/operators/join/stream/StreamingJoinOperatorTest.java
@@ -25,13 +25,11 @@ import
org.apache.flink.streaming.util.KeyedTwoInputStreamOperatorTestHarness;
import
org.apache.flink.streaming.util.asyncprocessing.AsyncKeyedTwoInputStreamOperatorTestHarness;
import org.apache.flink.table.data.RowData;
import org.apache.flink.table.runtime.generated.GeneratedJoinCondition;
-import org.apache.flink.table.runtime.keyselector.RowDataKeySelector;
import
org.apache.flink.table.runtime.operators.join.stream.asyncprocessing.AsyncStateStreamingJoinOperator;
import
org.apache.flink.table.runtime.operators.join.stream.utils.JoinInputSideSpec;
import org.apache.flink.table.runtime.util.RuntimeChangelogMode;
import org.apache.flink.table.types.logical.LogicalType;
import org.apache.flink.table.types.logical.RowType;
-import org.apache.flink.table.utils.HandwrittenSelectorUtil;
import org.apache.flink.testutils.junit.extensions.parameterized.Parameter;
import
org.apache.flink.testutils.junit.extensions.parameterized.ParameterizedTestExtension;
import org.apache.flink.testutils.junit.extensions.parameterized.Parameters;
@@ -1374,20 +1372,6 @@ class StreamingJoinOperatorTest extends
StreamingJoinOperatorTestBase {
.isEqualTo(hasUniqueKey ? right : null);
}
- /**
- * The unique key (order_id, line_order_id) contains the join key
(line_order_id), which the
- * planner requires for inputs without UPDATE_BEFORE, so a replacing
record has the same join
- * key.
- */
- private JoinInputSideSpec leftHasUniqueKeySpec() {
- final RowDataKeySelector uniqueKeySelector =
- HandwrittenSelectorUtil.getRowDataSelector(
- new int[] {0, 1},
- leftTypeInfo.toRowType().getChildren().toArray(new
LogicalType[0]));
- return JoinInputSideSpec.withUniqueKey(
- uniqueKeySelector.getProducedType(), uniqueKeySelector);
- }
-
private static final String ADDRESS = "3 Bellevue Drive, Pottstown, PA
19464";
private static final String NEW_ADDRESS = "68 Manor Station Street,
Honolulu, HI 96815";
diff --git
a/flink-table/flink-table-runtime/src/test/java/org/apache/flink/table/runtime/operators/join/stream/StreamingJoinOperatorTestBase.java
b/flink-table/flink-table-runtime/src/test/java/org/apache/flink/table/runtime/operators/join/stream/StreamingJoinOperatorTestBase.java
index 71f00ea758a..88c73101cd4 100644
---
a/flink-table/flink-table-runtime/src/test/java/org/apache/flink/table/runtime/operators/join/stream/StreamingJoinOperatorTestBase.java
+++
b/flink-table/flink-table-runtime/src/test/java/org/apache/flink/table/runtime/operators/join/stream/StreamingJoinOperatorTestBase.java
@@ -127,6 +127,30 @@ public abstract class StreamingJoinOperatorTestBase {
return new GeneratedJoinCondition("KnownValuesNonEquiCondition", code,
new Object[0]);
}
+ /**
+ * The unique key (order_id, line_order_id) contains the join key
(line_order_id), which the
+ * planner requires for inputs without UPDATE_BEFORE, so a replacing
record has the same join
+ * key.
+ */
+ protected JoinInputSideSpec leftHasUniqueKeySpec() {
+ final RowDataKeySelector uniqueKeySelector =
+ HandwrittenSelectorUtil.getRowDataSelector(
+ new int[] {0, 1},
+ leftTypeInfo.toRowType().getChildren().toArray(new
LogicalType[0]));
+ return JoinInputSideSpec.withUniqueKey(
+ uniqueKeySelector.getProducedType(), uniqueKeySelector);
+ }
+
+ /** The unique key (line_order_id, line_order_ship_mode) contains the join
key. */
+ protected JoinInputSideSpec rightHasUniqueKeySpec() {
+ final RowDataKeySelector uniqueKeySelector =
+ HandwrittenSelectorUtil.getRowDataSelector(
+ new int[] {0, 1},
+ rightTypeInfo.toRowType().getChildren().toArray(new
LogicalType[0]));
+ return JoinInputSideSpec.withUniqueKey(
+ uniqueKeySelector.getProducedType(), uniqueKeySelector);
+ }
+
protected RowDataHarnessAssertor assertor;
protected KeyedTwoInputStreamOperatorTestHarness<RowData, RowData,
RowData, RowData>
diff --git
a/flink-table/flink-table-runtime/src/test/java/org/apache/flink/table/runtime/operators/join/stream/StreamingSemiAntiJoinOperatorTest.java
b/flink-table/flink-table-runtime/src/test/java/org/apache/flink/table/runtime/operators/join/stream/StreamingSemiAntiJoinOperatorTest.java
index f9d82da1616..36a2a1b2360 100644
---
a/flink-table/flink-table-runtime/src/test/java/org/apache/flink/table/runtime/operators/join/stream/StreamingSemiAntiJoinOperatorTest.java
+++
b/flink-table/flink-table-runtime/src/test/java/org/apache/flink/table/runtime/operators/join/stream/StreamingSemiAntiJoinOperatorTest.java
@@ -18,12 +18,22 @@
package org.apache.flink.table.runtime.operators.join.stream;
+import org.apache.flink.streaming.runtime.streamrecord.StreamRecord;
+import org.apache.flink.streaming.util.KeyedTwoInputStreamOperatorTestHarness;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.runtime.generated.GeneratedJoinCondition;
+import
org.apache.flink.table.runtime.operators.join.stream.state.OuterJoinRecordStateViews;
+import
org.apache.flink.table.runtime.operators.join.stream.utils.JoinInputSideSpec;
import org.apache.flink.table.types.logical.RowType;
import org.apache.flink.types.RowKind;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInfo;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import javax.annotation.Nullable;
import java.util.function.Predicate;
@@ -45,6 +55,9 @@ class StreamingSemiAntiJoinOperatorTest extends
StreamingJoinOperatorTestBase {
joinCondition,
leftInputSpec,
rightInputSpec,
+ null,
+ null,
+ false,
new boolean[] {true},
ttl[0],
ttl[1]);
@@ -88,10 +101,6 @@ class StreamingSemiAntiJoinOperatorTest extends
StreamingJoinOperatorTestBase {
assertor.shouldEmitNothing(testHarness);
testHarness.processElement2(deleteRecord("LineOrd#2", "TRUCK"));
- assertor.shouldEmitNothing(testHarness);
-
- // numOfAssociations is reduced to 1, retract the record
- testHarness.processElement2(deleteRecord("LineOrd#2", "AIR"));
assertor.shouldEmit(
testHarness,
rowOfKind(
@@ -141,10 +150,6 @@ class StreamingSemiAntiJoinOperatorTest extends
StreamingJoinOperatorTestBase {
assertor.shouldEmitNothing(testHarness);
testHarness.processElement2(deleteRecord("LineOrd#2", "TRUCK"));
- assertor.shouldEmitNothing(testHarness);
-
- // numOfAssociations is reduced to 1, retract the record
- testHarness.processElement2(deleteRecord("LineOrd#2", "AIR"));
assertor.shouldEmit(
testHarness,
rowOfKind(
@@ -327,6 +332,347 @@ class StreamingSemiAntiJoinOperatorTest extends
StreamingJoinOperatorTestBase {
assertThat(testHarness.numKeyedStateEntries()).isOne();
}
+ /** A right row replaced without UPDATE_BEFORE is counted once, so its
DELETE removes it. */
+ @ParameterizedTest(name = "isAntiJoin = {0}")
+ @ValueSource(booleans = {false, true})
+ void testRightUpsertIsCountedOnce(boolean isAntiJoin) throws Exception {
+ useSemiAntiJoinOperator(isAntiJoin, rightInputSpec, joinCondition,
false);
+ assertRightUpsertIsCountedOnce(isAntiJoin, "TRUCK");
+ }
+
+ /** The same holds if the unique key of the right side contains more than
the join key. */
+ @ParameterizedTest(name = "isAntiJoin = {0}")
+ @ValueSource(booleans = {false, true})
+ void testRightUpsertWithUniqueKeyIsCountedOnce(boolean isAntiJoin) throws
Exception {
+ useSemiAntiJoinOperator(isAntiJoin, rightHasUniqueKeySpec(),
joinCondition, false);
+ assertRightUpsertIsCountedOnce(isAntiJoin, "AIR");
+ }
+
+ private void assertRightUpsertIsCountedOnce(boolean isAntiJoin, String
newShipMode)
+ throws Exception {
+ testHarness.processElement1(insertRecord("Ord#1", "LineOrd#1",
ADDRESS));
+ testHarness.processElement2(insertRecord("LineOrd#1", "AIR"));
+ testHarness.getOutput().clear();
+
+ testHarness.processElement2(updateAfterRecord("LineOrd#1",
newShipMode));
+ assertor.shouldEmitNothing(testHarness);
+
+ testHarness.processElement2(deleteRecord("LineOrd#1", newShipMode));
+ assertor.shouldEmit(
+ testHarness,
+ emitted(
+ isAntiJoin,
+ rowOfKind(RowKind.DELETE, "Ord#1", "LineOrd#1",
ADDRESS),
+ rowOfKind(RowKind.INSERT, "Ord#1", "LineOrd#1",
ADDRESS)));
+ }
+
+ /** A right row replaced without UPDATE_BEFORE that no longer matches
removes its match. */
+ @ParameterizedTest(name = "isAntiJoin = {0}")
+ @ValueSource(booleans = {false, true})
+ void testRightUpsertRemovesLostMatch(boolean isAntiJoin) throws Exception {
+ useSemiAntiJoinOperator(isAntiJoin, rightInputSpec,
knownValuesNonEquiCondition(), true);
+ testHarness.processElement1(insertRecord("Ord#1", "LineOrd#1",
ADDRESS));
+ testHarness.processElement2(insertRecord("LineOrd#1", "AIR"));
+ testHarness.getOutput().clear();
+
+ testHarness.processElement2(updateAfterRecord("LineOrd#1", UNKNOWN));
+ assertor.shouldEmit(
+ testHarness,
+ emitted(
+ isAntiJoin,
+ rowOfKind(RowKind.DELETE, "Ord#1", "LineOrd#1",
ADDRESS),
+ rowOfKind(RowKind.INSERT, "Ord#1", "LineOrd#1",
ADDRESS)));
+
+ testHarness.processElement2(updateAfterRecord("LineOrd#1", "SHIP"));
+ assertor.shouldEmit(
+ testHarness,
+ emitted(
+ isAntiJoin,
+ rowOfKind(RowKind.UPDATE_AFTER, "Ord#1", "LineOrd#1",
ADDRESS),
+ rowOfKind(RowKind.DELETE, "Ord#1", "LineOrd#1",
ADDRESS)));
+ }
+
+ /** A DELETE of only the key of a right row is joined with the stored row.
*/
+ @ParameterizedTest(name = "isAntiJoin = {0}")
+ @ValueSource(booleans = {false, true})
+ void testRightKeyOnlyDeleteRemovesMatch(boolean isAntiJoin) throws
Exception {
+ useSemiAntiJoinOperator(isAntiJoin, rightInputSpec,
knownValuesNonEquiCondition(), true);
+ testHarness.processElement1(insertRecord("Ord#1", "LineOrd#1",
ADDRESS));
+ testHarness.processElement2(insertRecord("LineOrd#1", "AIR"));
+ testHarness.getOutput().clear();
+
+ testHarness.processElement2(deleteRecord("LineOrd#1", null));
+ assertor.shouldEmit(
+ testHarness,
+ emitted(
+ isAntiJoin,
+ rowOfKind(RowKind.DELETE, "Ord#1", "LineOrd#1",
ADDRESS),
+ rowOfKind(RowKind.INSERT, "Ord#1", "LineOrd#1",
ADDRESS)));
+ }
+
+ /**
+ * A left row replaced without UPDATE_BEFORE retracts the old version if
it was emitted but the
+ * new version is not.
+ */
+ @ParameterizedTest(name = "isAntiJoin = {0}")
+ @ValueSource(booleans = {false, true})
+ void testLeftUpsertRetractsOldVersion(boolean isAntiJoin) throws Exception
{
+ useSemiAntiJoinOperator(isAntiJoin, rightInputSpec,
knownValuesNonEquiCondition(), true);
+ testHarness.processElement2(insertRecord("LineOrd#1", "AIR"));
+ testHarness.processElement1(insertRecord("Ord#1", "LineOrd#1",
ADDRESS));
+ assertor.shouldEmit(
+ testHarness,
+ emitted(
+ isAntiJoin,
+ rowOfKind(RowKind.INSERT, "Ord#1", "LineOrd#1",
ADDRESS),
+ null));
+
+ testHarness.processElement1(updateAfterRecord("Ord#1", "LineOrd#1",
UNKNOWN));
+ assertor.shouldEmit(
+ testHarness,
+ emitted(
+ isAntiJoin,
+ rowOfKind(RowKind.DELETE, "Ord#1", "LineOrd#1",
ADDRESS),
+ rowOfKind(RowKind.UPDATE_AFTER, "Ord#1", "LineOrd#1",
UNKNOWN)));
+
+ testHarness.processElement1(updateAfterRecord("Ord#1", "LineOrd#1",
NEW_ADDRESS));
+ assertor.shouldEmit(
+ testHarness,
+ emitted(
+ isAntiJoin,
+ rowOfKind(RowKind.UPDATE_AFTER, "Ord#1", "LineOrd#1",
NEW_ADDRESS),
+ rowOfKind(RowKind.DELETE, "Ord#1", "LineOrd#1",
UNKNOWN)));
+ }
+
+ /**
+ * A DELETE of only the key of a left row is joined with the stored row,
so it is emitted with
+ * all fields if the row was emitted, and not at all otherwise.
+ */
+ @ParameterizedTest(name = "isAntiJoin = {0}")
+ @ValueSource(booleans = {false, true})
+ void testLeftKeyOnlyDelete(boolean isAntiJoin) throws Exception {
+ useSemiAntiJoinOperator(isAntiJoin, rightInputSpec,
knownValuesNonEquiCondition(), true);
+ testHarness.processElement2(insertRecord("LineOrd#1", "AIR"));
+ testHarness.processElement1(insertRecord("Ord#1", "LineOrd#1",
ADDRESS));
+ testHarness.processElement1(insertRecord("Ord#2", "LineOrd#2",
ADDRESS));
+ assertor.shouldEmit(
+ testHarness,
+ emitted(
+ isAntiJoin,
+ rowOfKind(RowKind.INSERT, "Ord#1", "LineOrd#1",
ADDRESS),
+ rowOfKind(RowKind.INSERT, "Ord#2", "LineOrd#2",
ADDRESS)));
+
+ testHarness.processElement1(deleteRecord("Ord#1", "LineOrd#1", null));
+ assertor.shouldEmit(
+ testHarness,
+ emitted(
+ isAntiJoin,
+ rowOfKind(RowKind.DELETE, "Ord#1", "LineOrd#1",
ADDRESS),
+ null));
+
+ testHarness.processElement1(deleteRecord("Ord#2", "LineOrd#2", null));
+ assertor.shouldEmit(
+ testHarness,
+ emitted(
+ isAntiJoin,
+ null,
+ rowOfKind(RowKind.DELETE, "Ord#2", "LineOrd#2",
ADDRESS)));
+ }
+
+ /**
+ * A retraction of a right row that the left row did not count, because
the right row had
+ * expired when the left row was stored, does not make the count negative.
+ */
+ @ParameterizedTest(name = "isAntiJoin = {0}")
+ @ValueSource(booleans = {false, true})
+ void testRightRetractionOfExpiredRecord(boolean isAntiJoin) throws
Exception {
+ useSemiAntiJoinOperator(
+ isAntiJoin, leftInputSpec, rightInputSpec, joinCondition,
false, 4000L, 1000L);
+ testHarness.setStateTtlProcessingTime(1);
+ testHarness.processElement2(insertRecord("LineOrd#1", "AIR"));
+ testHarness.setStateTtlProcessingTime(2001);
+ testHarness.processElement1(insertRecord("Ord#1", "LineOrd#1",
ADDRESS));
+ testHarness.getOutput().clear();
+
+ testHarness.processElement2(deleteRecord("LineOrd#1", "AIR"));
+ assertor.shouldEmitNothing(testHarness);
+
+ testHarness.processElement2(insertRecord("LineOrd#1", "SHIP"));
+ assertor.shouldEmit(
+ testHarness,
+ emitted(
+ isAntiJoin,
+ rowOfKind(RowKind.INSERT, "Ord#1", "LineOrd#1",
ADDRESS),
+ rowOfKind(RowKind.DELETE, "Ord#1", "LineOrd#1",
ADDRESS)));
+ }
+
+ /**
+ * Whether the replaced left row was emitted is decided by its stored
number of matches, so it
+ * is retracted even if its matches expired.
+ */
+ @ParameterizedTest(name = "isAntiJoin = {0}")
+ @ValueSource(booleans = {false, true})
+ void testLeftUpsertRetractsOldVersionAfterMatchExpired(boolean isAntiJoin)
throws Exception {
+ useSemiAntiJoinOperator(
+ isAntiJoin,
+ leftInputSpec,
+ rightInputSpec,
+ knownValuesNonEquiCondition(),
+ true,
+ 4000L,
+ 1000L);
+ testHarness.setStateTtlProcessingTime(1);
+ testHarness.processElement2(insertRecord("LineOrd#1", "AIR"));
+ testHarness.processElement1(insertRecord("Ord#1", "LineOrd#1",
ADDRESS));
+ testHarness.getOutput().clear();
+
+ testHarness.setStateTtlProcessingTime(2001);
+ testHarness.processElement1(updateAfterRecord("Ord#1", "LineOrd#1",
UNKNOWN));
+ assertor.shouldEmit(
+ testHarness,
+ emitted(
+ isAntiJoin,
+ rowOfKind(RowKind.DELETE, "Ord#1", "LineOrd#1",
ADDRESS),
+ rowOfKind(RowKind.UPDATE_AFTER, "Ord#1", "LineOrd#1",
UNKNOWN)));
+ }
+
+ /**
+ * Left rows whose unique key is wider than the join key are updated and
deleted by their stored
+ * version.
+ */
+ @ParameterizedTest(name = "isAntiJoin = {0}")
+ @ValueSource(booleans = {false, true})
+ void testLeftUpsertsWithUniqueKeyWiderThanJoinKey(boolean isAntiJoin)
throws Exception {
+ useSemiAntiJoinOperator(
+ isAntiJoin,
+ leftHasUniqueKeySpec(),
+ rightInputSpec,
+ knownValuesNonEquiCondition(),
+ true,
+ 0L,
+ 0L);
+ testHarness.processElement2(insertRecord("LineOrd#1", "AIR"));
+ testHarness.processElement1(insertRecord("Ord#1", "LineOrd#1",
ADDRESS));
+ testHarness.processElement1(insertRecord("Ord#2", "LineOrd#1",
ADDRESS));
+ testHarness.getOutput().clear();
+
+ testHarness.processElement1(updateAfterRecord("Ord#1", "LineOrd#1",
UNKNOWN));
+ assertor.shouldEmit(
+ testHarness,
+ emitted(
+ isAntiJoin,
+ rowOfKind(RowKind.DELETE, "Ord#1", "LineOrd#1",
ADDRESS),
+ rowOfKind(RowKind.UPDATE_AFTER, "Ord#1", "LineOrd#1",
UNKNOWN)));
+
+ testHarness.processElement1(deleteRecord("Ord#2", "LineOrd#1", null));
+ assertor.shouldEmit(
+ testHarness,
+ emitted(
+ isAntiJoin,
+ rowOfKind(RowKind.DELETE, "Ord#2", "LineOrd#1",
ADDRESS),
+ null));
+
+ testHarness.processElement1(deleteRecord("Ord#1", "LineOrd#1", null));
+ assertor.shouldEmit(
+ testHarness,
+ emitted(
+ isAntiJoin,
+ null,
+ rowOfKind(RowKind.DELETE, "Ord#1", "LineOrd#1",
UNKNOWN)));
+ }
+
+ /** A negative number of matches, as state written before Flink 2.4 may
contain, recovers. */
+ @ParameterizedTest(name = "isAntiJoin = {0}")
+ @ValueSource(booleans = {false, true})
+ void testRightInsertMatchesLeftRecordWithNegativeCount(boolean isAntiJoin)
throws Exception {
+ useSemiAntiJoinOperator(isAntiJoin, rightInputSpec, joinCondition,
false);
+ final RowData order = rowOfKind(RowKind.INSERT, "Ord#1", "LineOrd#1",
ADDRESS);
+ testHarness.processElement1(new StreamRecord<>(order));
+ setNumOfAssociations(order, -1);
+ testHarness.getOutput().clear();
+
+ testHarness.processElement2(insertRecord("LineOrd#1", "AIR"));
+ assertor.shouldEmit(
+ testHarness,
+ emitted(
+ isAntiJoin,
+ rowOfKind(RowKind.INSERT, "Ord#1", "LineOrd#1",
ADDRESS),
+ rowOfKind(RowKind.DELETE, "Ord#1", "LineOrd#1",
ADDRESS)));
+
+ testHarness.processElement2(deleteRecord("LineOrd#1", null));
+ assertor.shouldEmit(
+ testHarness,
+ emitted(
+ isAntiJoin,
+ rowOfKind(RowKind.DELETE, "Ord#1", "LineOrd#1",
ADDRESS),
+ rowOfKind(RowKind.INSERT, "Ord#1", "LineOrd#1",
ADDRESS)));
+ }
+
+ private void useSemiAntiJoinOperator(
+ boolean isAntiJoin,
+ JoinInputSideSpec rightSpec,
+ GeneratedJoinCondition condition,
+ boolean hasNonEquiCondition)
+ throws Exception {
+ useSemiAntiJoinOperator(
+ isAntiJoin, leftInputSpec, rightSpec, condition,
hasNonEquiCondition, 0L, 0L);
+ }
+
+ private void useSemiAntiJoinOperator(
+ boolean isAntiJoin,
+ JoinInputSideSpec leftSpec,
+ JoinInputSideSpec rightSpec,
+ GeneratedJoinCondition condition,
+ boolean hasNonEquiCondition,
+ long leftStateRetentionTime,
+ long rightStateRetentionTime)
+ throws Exception {
+ testHarness.close();
+ testHarness =
+ new KeyedTwoInputStreamOperatorTestHarness<>(
+ new StreamingSemiAntiJoinOperator(
+ isAntiJoin,
+ leftTypeInfo,
+ rightTypeInfo,
+ condition,
+ leftSpec,
+ rightSpec,
+ UPSERT_WITH_KEY_ONLY_DELETES,
+ UPSERT_WITH_KEY_ONLY_DELETES,
+ hasNonEquiCondition,
+ new boolean[] {true},
+ leftStateRetentionTime,
+ rightStateRetentionTime),
+ leftKeySelector,
+ rightKeySelector,
+ joinKeyTypeInfo);
+ testHarness.open();
+ }
+
+ /** Overwrites the stored number of matches of the left row, e.g. as old
state may contain. */
+ private void setNumOfAssociations(RowData record, int numOfAssociations)
throws Exception {
+ final StreamingSemiAntiJoinOperator operator =
+ (StreamingSemiAntiJoinOperator) testHarness.getOperator();
+ operator.setCurrentKey(leftKeySelector.getKey(record));
+ OuterJoinRecordStateViews.create(
+ operator.getRuntimeContext(),
+ AbstractStreamingJoinOperator.LEFT_RECORDS_STATE_NAME,
+ leftInputSpec,
+ leftTypeInfo,
+ 0L)
+ .updateNumOfAssociations(record, numOfAssociations);
+ }
+
+ /** Returns the row that the semi or the anti join emits, if any. */
+ private static RowData[] emitted(
+ boolean isAntiJoin, @Nullable RowData semiJoinRow, @Nullable
RowData antiJoinRow) {
+ final RowData row = isAntiJoin ? antiJoinRow : semiJoinRow;
+ return row == null ? new RowData[0] : new RowData[] {row};
+ }
+
+ private static final String ADDRESS = "3 Bellevue Drive, Pottstown, PA
19464";
+ private static final String NEW_ADDRESS = "68 Manor Station Street,
Honolulu, HI 96815";
+
private static final Predicate<String> ANTI_JOIN_CHECKER =
(testDisplayName) -> testDisplayName.contains("Anti");
}