gustavodemorais commented on code in PR #29570:
URL: https://github.com/apache/flink/pull/29570#discussion_r4404033244


##########
flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/join/stream/AbstractStreamingJoinOperator.java:
##########
@@ -102,6 +126,114 @@ public void close() throws Exception {
         }
     }
 
+    /**
+     * 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, or null if it is not stored and 
matches nothing.
+     *
+     * <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. If nothing is stored, e.g. because the record was never 
inserted, its null fields
+     * must not match the other side.
+     */
+    protected @Nullable 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;
+        }
+        return stateView.getRecord(retraction);
+    }
+
+    /**
+     * Returns whether a DELETE of the input may be for a record that was 
never inserted, e.g. an
+     * upsert source deletes a key that it never inserted.
+     */
+    protected boolean mayDeleteRecordsThatAreNotStored(boolean isLeft) {
+        return (isLeft ? leftIsUpsertOrUnknown : rightIsUpsertOrUnknown) && 
hasUniqueKey(isLeft);
+    }
+
+    /**
+     * 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.
+     */
+    protected 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);
+    }
+
+    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

Review Comment:
   Plans compiled before 2.4 have no changelog mode, so retract inputs now also 
skip DELETEs of rows that are not stored. With state TTL, the DELETE of an 
expired row is lost:
   
   ```
   -- 2.3 plan, TTL 1s: SELECT * FROM orders o WHERE o.line_order_id IN (SELECT 
line_order_id FROM lines)
   t=1     R +I(LineOrd#1, AIR)
   t=500   L +I(Ord#1, LineOrd#1, ADDR)   -- +I[Ord#1]
   t=1200  R -D(LineOrd#1, AIR)           -- expected -D[Ord#1], nothing emitted
   ```
   
   Same for the regular join and the semi/anti left side. Could we skip these 
DELETEs only when the mode is known, keep the old fallback for null modes, and 
add a test for both?



##########
flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/join/stream/StreamingSemiAntiJoinOperator.java:
##########
@@ -177,52 +219,81 @@ public void processElement2(StreamRecord<RowData> 
element) throws Exception {
         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 =
+                    inputRowKind == RowKind.DELETE && 
mayDeleteRecordsThatAreNotStored(false)
+                            ? rightRecordStateView.getRecord(input)
+                            : retractedRecord(input, inputRowKind, 
rightRecordStateView, false);
+            if (retractedRecord == null) {
+                // nothing is stored, so no left record counts it as a match
+                return;
+            }
+            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) {

Review Comment:
   Old savepoints hold inflated counts (2, 5, ...) when the right PK is the 
join key, and a DELETE only takes 1 off, so the left row stays. With at most 
one right row per key here, could we write 0 for any positive count when 
`joinKeyContainsUniqueKey(false)`?



##########
flink-table/flink-table-runtime/src/test/java/org/apache/flink/table/runtime/operators/join/stream/StreamingSemiAntiJoinOperatorTest.java:
##########
@@ -327,6 +335,355 @@ private void assertSkipsNullKeys(boolean isAntiJoin) 
throws Exception {
         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 DELETE of a right row that was never inserted does not remove a 
match. */
+    @ParameterizedTest(name = "isAntiJoin = {0}")
+    @ValueSource(booleans = {false, true})
+    void testRightDeleteBeforeInsert(boolean isAntiJoin) throws Exception {
+        useSemiAntiJoinOperator(isAntiJoin, rightInputSpec, joinCondition, 
false);
+        testHarness.processElement1(insertRecord("Ord#1", "LineOrd#1", 
ADDRESS));
+        testHarness.getOutput().clear();
+
+        testHarness.processElement2(deleteRecord("LineOrd#1", null));
+        assertor.shouldEmitNothing(testHarness);
+
+        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(updateAfterRecord("LineOrd#1", "SHIP"));
+        assertor.shouldEmitNothing(testHarness);
+
+        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 DELETE of a right row that was never inserted does not remove the 
match of another right
+     * row with the same join key.
+     */
+    @ParameterizedTest(name = "isAntiJoin = {0}")
+    @ValueSource(booleans = {false, true})
+    void testRightDeleteOfRecordThatIsNotStored(boolean isAntiJoin) throws 
Exception {
+        useSemiAntiJoinOperator(isAntiJoin, rightHasUniqueKeySpec(), 
joinCondition, false);
+        testHarness.processElement1(insertRecord("Ord#1", "LineOrd#1", 
ADDRESS));
+        testHarness.processElement2(insertRecord("LineOrd#1", "AIR"));
+        testHarness.getOutput().clear();
+
+        testHarness.processElement2(deleteRecord("LineOrd#1", "SHIP"));
+        assertor.shouldEmitNothing(testHarness);
+
+        testHarness.processElement2(deleteRecord("LineOrd#1", "AIR"));
+        assertor.shouldEmit(
+                testHarness,
+                emitted(
+                        isAntiJoin,
+                        rowOfKind(RowKind.DELETE, "Ord#1", "LineOrd#1", 
ADDRESS),
+                        rowOfKind(RowKind.INSERT, "Ord#1", "LineOrd#1", 
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,
+                rightInputSpec,
+                joinCondition,
+                false,
+                RuntimeChangelogMode.serialize(ChangelogMode.all()),
+                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,
+                rightInputSpec,
+                knownValuesNonEquiCondition(),
+                true,
+                UPSERT_WITH_KEY_ONLY_DELETES,
+                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)));
+    }
+
+    /**
+     * A DELETE of only the key of a left row that is not stored matches 
nothing, even if its null
+     * fields would match.
+     */
+    @ParameterizedTest(name = "isAntiJoin = {0}")
+    @ValueSource(booleans = {false, true})
+    void testLeftKeyOnlyDeleteOfRecordThatIsNotStored(boolean isAntiJoin) 
throws Exception {
+        useSemiAntiJoinOperator(isAntiJoin, rightInputSpec, 
nullTolerantNonEquiCondition(), true);
+        testHarness.processElement2(insertRecord("LineOrd#1", "AIR"));
+        testHarness.getOutput().clear();
+
+        testHarness.processElement1(deleteRecord("Ord#1", "LineOrd#1", null));
+        assertor.shouldEmit(
+                testHarness,
+                emitted(isAntiJoin, null, rowOfKind(RowKind.DELETE, "Ord#1", 
"LineOrd#1", null)));
+    }
+
+    private void useSemiAntiJoinOperator(
+            boolean isAntiJoin,
+            JoinInputSideSpec rightSpec,
+            GeneratedJoinCondition condition,
+            boolean hasNonEquiCondition)
+            throws Exception {
+        useSemiAntiJoinOperator(
+                isAntiJoin,
+                rightSpec,
+                condition,
+                hasNonEquiCondition,
+                UPSERT_WITH_KEY_ONLY_DELETES,
+                0L,
+                0L);
+    }
+
+    private void useSemiAntiJoinOperator(
+            boolean isAntiJoin,
+            JoinInputSideSpec rightSpec,
+            GeneratedJoinCondition condition,
+            boolean hasNonEquiCondition,
+            RuntimeChangelogMode rightChangelogMode,
+            long leftStateRetentionTime,
+            long rightStateRetentionTime)
+            throws Exception {
+        testHarness.close();
+        testHarness =
+                new KeyedTwoInputStreamOperatorTestHarness<>(
+                        new StreamingSemiAntiJoinOperator(
+                                isAntiJoin,
+                                leftTypeInfo,
+                                rightTypeInfo,
+                                condition,
+                                leftInputSpec,

Review Comment:
   All tests use `leftInputSpec` (unique key = join key), so 
`InputSideHasUniqueKey#getRecordAndNumOfAssociations` never runs. Could we add 
a left spec with unique key `(order_id, line_order_id)` and two left rows per 
join key?



##########
flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/join/stream/StreamingJoinOperator.java:
##########
@@ -365,16 +350,26 @@ protected void processElement(
                 // skip when there is no matched rows on the other side
             }
         } else { // input record is retract
-            final RowData retractedRecord =
-                    retractedRecord(
-                            input, inputRowKind, inputSideStateView, 
inputIsLeft, isSuppress);
+            // the retraction of a mini-batch pair is used as it is
+            final RowData fullRecord =
+                    isSuppress
+                            ? input
+                            : retractedRecord(input, inputRowKind, 
inputSideStateView, inputIsLeft);
+            // a key-only DELETE of a record that is not stored matches nothing

Review Comment:
   This changes the regular join (FLINK-40858) under the semi/anti ticket. 
Could we keep `StreamingJoinOperator` as before and move this to the separate 
ticket?



##########
flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/join/stream/AbstractStreamingJoinOperator.java:
##########
@@ -102,6 +126,114 @@ public void close() throws Exception {
         }
     }
 
+    /**
+     * 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, or null if it is not stored and 
matches nothing.
+     *
+     * <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. If nothing is stored, e.g. because the record was never 
inserted, its null fields
+     * must not match the other side.
+     */
+    protected @Nullable 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;
+        }
+        return stateView.getRecord(retraction);
+    }
+
+    /**
+     * Returns whether a DELETE of the input may be for a record that was 
never inserted, e.g. an
+     * upsert source deletes a key that it never inserted.
+     */
+    protected boolean mayDeleteRecordsThatAreNotStored(boolean isLeft) {
+        return (isLeft ? leftIsUpsertOrUnknown : rightIsUpsertOrUnknown) && 
hasUniqueKey(isLeft);

Review Comment:
   When the join key contains the right unique key, the clamp in `removeMatch` 
already covers this. Could we add `&& !joinKeyContainsUniqueKey(isLeft)`, as in 
your reply? It saves a state read per right DELETE in the most common shape.



##########
flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/join/stream/StreamingSemiAntiJoinOperator.java:
##########
@@ -177,52 +219,81 @@ public void processElement2(StreamRecord<RowData> 
element) throws Exception {
         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) {

Review Comment:
   Only old state has negative counts here, and no test reaches this (reverting 
to `== 0` keeps all tests green). Could we add a test that writes a left row 
with count -1 and then inserts a matching right row?



##########
flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/join/stream/StreamingSemiAntiJoinOperator.java:
##########
@@ -109,29 +124,47 @@ public void open() throws Exception {
     @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 { // input is retract
-            // erase RowKind for state updating
-            input.setRowKind(RowKind.INSERT);
-            leftRecordStateView.retractRecord(input);
+        } else {
+            final RowData retractedRecord =
+                    retractedRecord(input, inputRowKind, leftRecordStateView, 
true);
+            if (retractedRecord == null) {
+                // nothing is stored for the key-only DELETE, which matches 
nothing
+                if (isAntiJoin) {
+                    collect(input, inputRowKind);
+                }
+                return;
+            }
+            final boolean hasMatches =

Review Comment:
   Suggestion: a left DELETE still scans the right state. If the stored count 
disagrees (expired match, inflated count), the semi join doesn't retract and 
the anti join sends a second -D. Could we use the stored count here too, like 
the upsert branch?



##########
flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/common/JoinTestPrograms.java:
##########
@@ -565,6 +566,57 @@ public class JoinTestPrograms {
                                         + "FROM orders o JOIN lines l "
                                         + "ON o.order_id = l.order_id AND 
l.status <> o.excluded_status")
                         .build();
+
+        // the line of order 1 is updated without UPDATE_BEFORE and deleted by 
key, the full image
+        // of the deleted line is only in the restored state; order 2 stops 
matching its line
+        SEMI_JOIN_UPSERT_INPUTS_NON_EQUI =

Review Comment:
   Could we add a 2.3 plan variant of this program, like 
`LEFT_JOIN_UPSERT_INPUT_NON_EQUI_PRE_2_4`, that updates and deletes restored 
rows? The existing pre-2.4 semi/anti programs only insert after the restore, so 
they never reach the new lookups.



##########
flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/join/stream/AbstractStreamingJoinOperator.java:
##########
@@ -102,6 +126,114 @@ public void close() throws Exception {
         }
     }
 
+    /**
+     * 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, or null if it is not stored and 
matches nothing.
+     *
+     * <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. If nothing is stored, e.g. because the record was never 
inserted, its null fields
+     * must not match the other side.
+     */
+    protected @Nullable 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;
+        }
+        return stateView.getRecord(retraction);
+    }
+
+    /**
+     * Returns whether a DELETE of the input may be for a record that was 
never inserted, e.g. an
+     * upsert source deletes a key that it never inserted.
+     */
+    protected boolean mayDeleteRecordsThatAreNotStored(boolean isLeft) {
+        return (isLeft ? leftIsUpsertOrUnknown : rightIsUpsertOrUnknown) && 
hasUniqueKey(isLeft);
+    }
+
+    /**
+     * 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.
+     */
+    protected boolean isNewMatch(

Review Comment:
   nit: only `StreamingJoinOperator` uses `isNewMatch` now. Could we keep it 
private there?



##########
flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/common/JoinTestPrograms.java:
##########
@@ -565,6 +566,57 @@ public class JoinTestPrograms {
                                         + "FROM orders o JOIN lines l "
                                         + "ON o.order_id = l.order_id AND 
l.status <> o.excluded_status")
                         .build();
+
+        // the line of order 1 is updated without UPDATE_BEFORE and deleted by 
key, the full image
+        // of the deleted line is only in the restored state; order 2 stops 
matching its line

Review Comment:
   nit: the deleted line's full image comes from the UPDATE_AFTER after the 
restore; only the replaced version is in the restored state.
   
   ```suggestion
           // 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
   ```



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to