Yoni created FLINK-41225:
----------------------------

             Summary: Mini-batch join: the unique-key bundles fold an 
UPDATE_AFTER and the following DELETE into nothing, so the stored row is never 
retracted
                 Key: FLINK-41225
                 URL: https://issues.apache.org/jira/browse/FLINK-41225
             Project: Flink
          Issue Type: Bug
          Components: Table SQL / Runtime
    Affects Versions: 2.2.1
            Reporter: Yoni


FLINK-40681 describes this as the mini-batch join's second problem, next to the 
association count. Its sub-tasks FLINK-40809 and FLINK-40831 fixed the count, 
and none of its sub-tasks covers the fold. The bundles are unchanged on master. 
This issue tracks the fold on its own, with deterministic reproducers, and is 
linked to FLINK-40681. FLINK-40681 names only 
{{JoinKeyContainsUniqueKeyBundle}}; the second reproducer below shows that 
{{InputSideHasUniqueKeyBundle}} is affected too. cc [~gustavodemorais]

When nothing downstream requires UPDATE_BEFORE, for example an upsert sink 
keyed on the query's upsert key, the planner puts no ChangelogNormalize in 
front of a regular join's input that has a unique key. The join then reads 
upsert input {{\[I,UA,D\]}} directly, so an accumulate can replace a row the 
join already holds: a bare UPDATE_AFTER, or a repeated INSERT for the same key. 
Upsert sources such as upsert-kafka produce exactly this. Both unique-key input 
specs are affected:
* {{JoinKeyContainsUniqueKey}}: the join key contains the input's unique key 
({{JoinKeyContainsUniqueKeyBundle}}).
* {{HasUniqueKey}}: the unique key is wider than the join key, as in 
FLINK-40842's example ({{InputSideHasUniqueKeyBundle}}).

In mini-batch mode, both bundles' {{foldRecord}} apply the rule "+I/+U followed 
by -U/-D: clear both". That is correct only when the accumulate inserted a row 
that was not in state before. When it replaced a stored row, clearing the pair 
removes that row's only retraction:
* the stored row stays in the join state;
* the joined row it produced stays in the sink;
* nothing later corrects it.

Both a bare {{+U}} and a repeated {{+I}} followed by {{-D}} reproduce it.

Semi and anti joins are not affected by the fold: {{StreamExecJoin}} builds 
them as {{StreamingSemiAntiJoinOperator}}, which has no bundle, even when the 
plan shows {{miniBatch=\[true\]}}. Their count issue is FLINK-40842.

h3. Reproducer 1: JoinKeyContainsUniqueKey

Stock Flink 2.2.1, {{values}} connector, parallelism 1. {{allow-latency}} is 
set high so that only {{mini-batch.size}} closes bundles. Size 2 puts the first 
two left records in bundle 1, and the update and delete in bundle 2. 
Deterministic: 10 of 10 runs at each size gave the results below.

{code:sql}
SET 'parallelism.default' = '1';
SET 'table.exec.mini-batch.enabled' = 'true';
SET 'table.exec.mini-batch.allow-latency' = '1 h';
SET 'table.exec.mini-batch.size' = '2';

CREATE TABLE left_t  (k INT NOT NULL, v STRING, PRIMARY KEY (k) NOT ENFORCED)
  WITH ('connector' = 'values', 'changelog-mode' = 'I,UA,D', 'data-id' = 
'<left>');
CREATE TABLE right_t (k INT NOT NULL, w STRING, PRIMARY KEY (k) NOT ENFORCED)
  WITH ('connector' = 'values', 'changelog-mode' = 'I,UA,D', 'data-id' = 
'<right, empty>');
CREATE TABLE sink    (k INT NOT NULL, v STRING, w STRING, PRIMARY KEY (k) NOT 
ENFORCED)
  WITH ('connector' = 'values', 'sink-insert-only' = 'false');

INSERT INTO sink SELECT l.k, l.v, r.w FROM left_t l LEFT JOIN right_t r ON l.k 
= r.k;
{code}

The {{data-id}}s come from {{TestValuesTableFactory.registerData}}, so the SQL 
runs inside a test, for example:

{code:java}
String left = TestValuesTableFactory.registerData(List.of(
        Row.ofKind(RowKind.INSERT, 1, "a"),
        Row.ofKind(RowKind.INSERT, 2, "c"),
        Row.ofKind(RowKind.UPDATE_AFTER, 1, "b"),   // RowKind.INSERT folds the 
same way
        Row.ofKind(RowKind.DELETE, 1, "b")));
String right = TestValuesTableFactory.registerData(List.of());
{code}

Plan ({{EXPLAIN CHANGELOG_MODE}}). There is no ChangelogNormalize; the join 
reads {{\[I,UA,D\]}}:

{noformat}
Sink(table=[default_catalog.default_database.sink], fields=[k, v, w], 
changelogMode=[NONE])
+- Join(joinType=[LeftOuterJoin], where=[=(k, k0)], select=[k, v, k0, w], 
leftInputSpec=[JoinKeyContainsUniqueKey], 
rightInputSpec=[JoinKeyContainsUniqueKey], miniBatch=[true], 
changelogMode=[I,UA,D])
   :- Exchange(distribution=[hash[k]], changelogMode=[I,UA,D])
   :  +- MiniBatchAssigner(interval=[3600000ms], mode=[ProcTime], 
changelogMode=[I,UA,D])
   :     +- TableSourceScan(table=[[default_catalog, default_database, 
left_t]], fields=[k, v], changelogMode=[I,UA,D])
   +- Exchange(distribution=[hash[k]], changelogMode=[I,UA,D])
      +- MiniBatchAssigner(interval=[3600000ms], mode=[ProcTime], 
changelogMode=[I,UA,D])
         +- TableSourceScan(table=[[default_catalog, default_database, 
right_t]], fields=[k, w], changelogMode=[I,UA,D])
{noformat}

Result:

||mini-batch.size||sink changelog||final sink||
|2|+I\[2, c, null\], +I\[1, a, null\]|\[1, a, null\], \[2, c, null\]: *key 1 is 
never retracted, and keeps the old value a*|
|1 (no folding)|+I\[1, a, null\], +I\[2, c, null\], +I\[1, b, null\], -D\[1, b, 
null\]|\[2, c, null\]: correct|

Expected with size 2: the same final result as with size 1.

h3. Reproducer 2: HasUniqueKey (InputSideHasUniqueKeyBundle)

Same settings, size 2. {{wide_t}} has a unique key {{(id, k)}} that is wider 
than the join key {{k}}, and the sink is keyed on {{(id, k)}}. {{narrow_t}} is 
empty, so only {{wide_t}}'s records fill the bundles.

{code:sql}
CREATE TABLE wide_t   (id INT NOT NULL, k INT NOT NULL, w STRING, PRIMARY KEY 
(id, k) NOT ENFORCED)
  WITH ('connector' = 'values', 'changelog-mode' = 'I,UA,D', 'data-id' = 
'<wide>');
CREATE TABLE narrow_t (k INT NOT NULL, v STRING, PRIMARY KEY (k) NOT ENFORCED)
  WITH ('connector' = 'values', 'changelog-mode' = 'I,UA,D', 'data-id' = 
'<narrow, empty>');
CREATE TABLE sink2    (id INT NOT NULL, k INT NOT NULL, w STRING, v STRING, 
PRIMARY KEY (id, k) NOT ENFORCED)
  WITH ('connector' = 'values', 'sink-insert-only' = 'false');

INSERT INTO sink2 SELECT r.id, r.k, r.w, l.v FROM wide_t r LEFT JOIN narrow_t l 
ON l.k = r.k;
{code}

{{wide_t}} data, in order: {{+I(10, 1, x)}}, {{+I(20, 2, z)}}, {{+U(10, 1, 
y)}}, {{-D(10, 1, y)}}.

{noformat}
Sink(table=[default_catalog.default_database.sink2], fields=[id, k, w, v], 
changelogMode=[NONE])
+- Calc(select=[id, k, w, v], changelogMode=[I,UA,D])
   +- Join(joinType=[LeftOuterJoin], where=[=(k0, k)], select=[id, k, w, k0, 
v], leftInputSpec=[HasUniqueKey], rightInputSpec=[JoinKeyContainsUniqueKey], 
miniBatch=[true], changelogMode=[I,UA,D])
      :- Exchange(distribution=[hash[k]], changelogMode=[I,UA,D])
      :  +- MiniBatchAssigner(interval=[3600000ms], mode=[ProcTime], 
changelogMode=[I,UA,D])
      :     +- TableSourceScan(table=[[default_catalog, default_database, 
wide_t]], fields=[id, k, w], changelogMode=[I,UA,D])
      +- Exchange(distribution=[hash[k]], changelogMode=[I,UA,D])
         +- MiniBatchAssigner(interval=[3600000ms], mode=[ProcTime], 
changelogMode=[I,UA,D])
            +- TableSourceScan(table=[[default_catalog, default_database, 
narrow_t]], fields=[k, v], changelogMode=[I,UA,D])
{noformat}

With size 2, 10 of 10 runs give the sink changelog {{+I\[20, 2, z, null\]}}, 
{{+I\[10, 1, x, null\]}}, and the final sink {{\[10, 1, x, null\]}}, {{\[20, 2, 
z, null\]}}: *row (10, 1) is never retracted, and keeps the old value x*. 
Expected: {{\[20, 2, z, null\]}} only.

h3. Impact

In our deployment this showed up as rows for deleted keys that stay in the 
output forever, with no error.
* *Trigger:* the update and the delete of a key land in one bundle. In steady 
state that happens when they arrive within {{allow-latency}}; ours were 22 ms 
apart, delivered by one upsert-kafka poll.
* *Much more likely after a checkpoint restore or a stateless rebuild,* because 
the catch-up packs records into full bundles.
* *Workarounds:*
** {{table.exec.mini-batch.enabled = false}} removes the fold, but it gives up 
mini-batch for the whole job.
** {{table.exec.mini-batch.size = 1}} also removes it. It keeps mini-batch's 
operators and overhead without any batching.
** Rewrite such a join as a {{GROUP BY}} over a {{UNION ALL}}. An aggregate 
requires UPDATE_BEFORE, so the ChangelogNormalize stays. This also avoids the 
association-count bug on versions without FLINK-40809 and FLINK-40831.

h3. Suggested fix

The bundle cannot tell whether an accumulate inserted a new row or replaced a 
stored one. The planner knows at plan time whether an input carries 
UPDATE_BEFORE. Two options:
* *In the operator:* when the input does not carry UPDATE_BEFORE, do not fold 
an accumulate with the retraction that follows it; process both, in order. The 
planner can pass that flag to {{MiniBatchStreamingJoinOperator}}.
* *In the planner:* require UPDATE_BEFORE, keeping the ChangelogNormalize, for 
a mini-batch join input with {{JoinKeyContainsUniqueKey}} or {{HasUniqueKey}}.

Folding the pair down to only the retraction is NOT safe. It breaks the case 
the fold exists for, a fresh {{+I}} followed by {{-D}}: a lone retraction on 
the outer side emits a null-padded DELETE even when no row was stored, which is 
a spurious DELETE downstream.

Clearing both remains correct when the input does produce UPDATE_BEFORE. The 
bundle then also holds the stored row's {{-U}}, which survives the fold.




--
This message was sent by Atlassian Jira
(v10.3.26#10030026)

Reply via email to