Weiqing Yang created FLINK-41451:
------------------------------------

             Summary: Release Testing: Verify FLIP-497: Early Fire Support for 
Flink SQL Interval Join
                 Key: FLINK-41451
                 URL: https://issues.apache.org/jira/browse/FLINK-41451
             Project: Flink
          Issue Type: Sub-task
            Reporter: Weiqing Yang
             Fix For: 2.4.0


[FLIP-497|https://cwiki.apache.org/confluence/display/FLINK/FLIP-497%3A+Early+Fire+Support+for+Flink+SQL+Interval+Join]
 adds the {{EARLY_FIRE}} query hint for outer interval joins. Without it, an 
unmatched outer row is emitted padded with NULLs only once its time window has 
closed. With {{/*+ EARLY_FIRE('delay'='5s') */}}, that padded row is emitted 
after the delay instead, and if a match arrives later inside the window it is 
corrected with {{\-U}} (the padded row) followed by {{+U}} (the joined row). 
Each outer row fires at most once. The join output becomes an updating stream, 
so the sink must accept updates. {{time-mode}} chooses whether the delay is 
measured against the watermark ({{rowtime}}) or wall-clock time ({{proctime}}).

Docs: [Early Fire for Outer Interval 
Joins|https://nightlies.apache.org/flink/flink-docs-release-2.4/docs/sql/reference/queries/joins/#early-fire-for-outer-interval-joins].
 Planner, operator and restore tests cover the code paths. This ticket is about 
the end-to-end behavior on a real cluster: timing, corrections, sinks, 
savepoints and compiled plans.

h3. Known issue (already filed, fix in progress)

This came up in pre-testing and is tracked for 2.4.0. There is no need to file 
it again. If you see different behavior, comment on the ticket.
* FLINK-41446: in batch mode, any query with the hint, including an inner join, 
fails at planning with {{Unknown join strategy : EARLY_FIRE}}. Without the hint 
the same query plans and runs. Step 12 shows it.

h3. 0. Setup

Build {{release-2.4}}, or use the 2.4.0 RC.

{code:bash}
./mvnw clean install -DskipTests -Dfast -Pskip-webui-build -T1C
cd build-target
mkdir -p /tmp/flip497/savepoints /tmp/flip497/checkpoints
{code}

In {{conf/config.yaml}}, change {{numberOfTaskSlots: 1}} to {{8}} and the 
TaskManager {{process.size: 1728m}} to {{4096m}}, and append:

{code:yaml}
execution.checkpointing.dir: file:///tmp/flip497/checkpoints
execution.checkpointing.savepoint-dir: file:///tmp/flip497/savepoints
execution.checkpointing.interval: 10s
{code}

Start the cluster with {{./bin/start-cluster.sh}}. Save the following as 
{{/tmp/flip497/ddl.sql}} and start the SQL Client with {{./bin/sql-client.sh 
\-i /tmp/flip497/ddl.sql}}. Run every statement below in that session. Each 
{{INSERT INTO}} runs detached on the cluster and writes to a {{print}} sink. 
Watch the output with {{tail \-f log/flink\-*\-taskexecutor\-*.out}}. Every 
line starts with the sink's {{print-identifier}}, so several jobs can share the 
file. Cancel jobs you are done with ({{STOP JOB '<id>';}}) to free slots.

The data: one order and one shipment per second. The shipment for order N is 
generated about 10 seconds after order N, and the join only accepts even ids, 
so even orders get a match about 10 seconds late and odd orders never match. 
{{order_time}} and {{ship_time}} are the wall-clock time (UTC) at which each 
row was generated, so you can compare them with when a row shows up. The window 
is one minute, so without the hint an unmatched order waits about a minute.

{code:sql}
SET 'table.local-time-zone' = 'UTC';

CREATE TABLE orders (
  order_id BIGINT,
  order_time AS LOCALTIMESTAMP,
  WATERMARK FOR order_time AS order_time - INTERVAL '1' SECOND
) WITH ('connector' = 'datagen', 'rows-per-second' = '1',
  'fields.order_id.kind' = 'sequence', 'fields.order_id.start' = '1', 
'fields.order_id.end' = '100000');

CREATE TABLE shipments (
  seq BIGINT,
  order_id AS seq - 10,
  ship_time AS LOCALTIMESTAMP,
  WATERMARK FOR ship_time AS ship_time - INTERVAL '1' SECOND
) WITH ('connector' = 'datagen', 'rows-per-second' = '1',
  'fields.seq.kind' = 'sequence', 'fields.seq.start' = '1', 'fields.seq.end' = 
'100000');

-- Event time never advances on this input, so the join's watermark stalls 
(step 6).
CREATE TABLE shipments_stalled (
  seq BIGINT,
  order_id AS seq - 10,
  ship_time AS CAST(TIMESTAMP '2026-01-01 00:00:00' AS TIMESTAMP(3)),
  WATERMARK FOR ship_time AS ship_time
) WITH ('connector' = 'datagen', 'rows-per-second' = '1',
  'fields.seq.kind' = 'sequence', 'fields.seq.start' = '1', 'fields.seq.end' = 
'100000');

-- Every fifth order has a NULL join key, which can never match (step 8).
CREATE TABLE orders_nk (
  order_id BIGINT,
  k AS CASE WHEN MOD(order_id, 5) = 0 THEN CAST(NULL AS BIGINT) ELSE order_id 
END,
  order_time AS LOCALTIMESTAMP,
  WATERMARK FOR order_time AS order_time - INTERVAL '1' SECOND
) WITH ('connector' = 'datagen', 'rows-per-second' = '1',
  'fields.order_id.kind' = 'sequence', 'fields.order_id.start' = '1', 
'fields.order_id.end' = '100000');

-- Processing-time inputs (step 5).
CREATE TABLE orders_pt (order_id BIGINT, pt AS PROCTIME()) WITH ('connector' = 
'datagen',
  'rows-per-second' = '1', 'fields.order_id.kind' = 'sequence', 
'fields.order_id.start' = '1', 'fields.order_id.end' = '100000');
CREATE TABLE shipments_pt (seq BIGINT, order_id AS seq - 10, pt AS PROCTIME()) 
WITH ('connector' = 'datagen',
  'rows-per-second' = '1', 'fields.seq.kind' = 'sequence', 'fields.seq.start' = 
'1', 'fields.seq.end' = '100000');

CREATE TABLE out_baseline (order_id BIGINT, ot TIMESTAMP(3), st TIMESTAMP(3)) 
WITH ('connector' = 'print', 'print-identifier' = 'baseline');
CREATE TABLE out_early (order_id BIGINT, ot TIMESTAMP(3), st TIMESTAMP(3)) WITH 
('connector' = 'print', 'print-identifier' = 'early');
CREATE TABLE out_stalled_rt (order_id BIGINT, ot TIMESTAMP(3), st TIMESTAMP(3)) 
WITH ('connector' = 'print', 'print-identifier' = 'stalled-rowtime');
CREATE TABLE out_stalled_pt (order_id BIGINT, ot TIMESTAMP(3), st TIMESTAMP(3)) 
WITH ('connector' = 'print', 'print-identifier' = 'stalled-proctime');
CREATE TABLE out_full (o_id BIGINT, s_id BIGINT, ot TIMESTAMP(3), st 
TIMESTAMP(3)) WITH ('connector' = 'print', 'print-identifier' = 'full');
CREATE TABLE out_nullkey (order_id BIGINT, k BIGINT, ot TIMESTAMP(3), st 
TIMESTAMP(3)) WITH ('connector' = 'print', 'print-identifier' = 'nullkey');
CREATE TABLE out_plan (order_id BIGINT, ot TIMESTAMP(3), st TIMESTAMP(3)) WITH 
('connector' = 'print', 'print-identifier' = 'plan');
CREATE TABLE out_csv (order_id BIGINT, ot TIMESTAMP(3), st TIMESTAMP(3)) WITH 
('connector' = 'filesystem', 'path' = 'file:///tmp/flip497/csv', 'format' = 
'csv');
{code}

Keep the datagen {{fields.*.end}} values small: the sequence generator holds 
its whole range in memory. The two timestamps are cast to plain 
{{TIMESTAMP(3)}} in the queries below because a sink cannot take two rowtime 
columns.

h3. 1. Baseline vs. EARLY_FIRE

{code:sql}
SET 'pipeline.name' = 'flip497-baseline';
INSERT INTO out_baseline
SELECT o.order_id, CAST(o.order_time AS TIMESTAMP(3)), CAST(s.ship_time AS 
TIMESTAMP(3))
FROM orders o LEFT JOIN shipments s
  ON o.order_id = s.order_id AND MOD(s.order_id, 2) = 0
 AND o.order_time BETWEEN s.ship_time - INTERVAL '1' MINUTE AND s.ship_time;

SET 'pipeline.name' = 'flip497-early';
INSERT INTO out_early
SELECT /*+ EARLY_FIRE('delay'='3s') */ o.order_id, CAST(o.order_time AS 
TIMESTAMP(3)), CAST(s.ship_time AS TIMESTAMP(3))
FROM orders o LEFT JOIN shipments s
  ON o.order_id = s.order_id AND MOD(s.order_id, 2) = 0
 AND o.order_time BETWEEN s.ship_time - INTERVAL '1' MINUTE AND s.ship_time;
{code}

*Expected* (keep both running, steps 9 and 10 use them):
* {{early}}: every order appears as {{+I\[N, <order_time>, null\]}} 4 to 5 
seconds after its {{order_time}}: the 3 s delay, the 1 s watermark lag, and up 
to one more second because each input's watermark only moves when its next row 
arrives (one per second) and the join waits for the slower input. About 10 
seconds after {{order_time}}, every even order is corrected:
{noformat}
early> +I[2, 2026-11-12T01:01:43.221, null]
early> -U[2, 2026-11-12T01:01:43.221, null]
early> +U[2, 2026-11-12T01:01:43.221, 2026-11-12T01:01:53.231]
{noformat}
Odd orders are never corrected.
* {{baseline}}: even orders appear once, already joined, about 10 seconds after 
{{order_time}}. Odd orders appear as {{+I\[N, <order_time>, null\]}} only 61 to 
62 seconds after {{order_time}}, when the window closes.

h3. 2. The same query interactively (optional)

{code:sql}
SET 'sql-client.execution.result-mode' = 'tableau';
SET 'pipeline.name' = 'flip497-select';
SELECT /*+ EARLY_FIRE('delay'='3s') */ o.order_id, CAST(o.order_time AS 
TIMESTAMP(3)) AS ot, CAST(s.ship_time AS TIMESTAMP(3)) AS st
FROM orders o LEFT JOIN shipments s
  ON o.order_id = s.order_id AND MOD(s.order_id, 2) = 0
 AND o.order_time BETWEEN s.ship_time - INTERVAL '1' MINUTE AND s.ship_time;
{code}

*Expected:* the {{op}} column shows the same {{+I}}, {{\-U}}, {{+U}} pattern. 
The query holds the session until you stop it with Ctrl+C. Note that {{EXPLAIN 
CHANGELOG_MODE}} on a bare {{SELECT}} shows the join as {{\[I,UA\]}}, as it 
does for any updating query such as a {{GROUP BY}}. Run it on the {{INSERT INTO 
out_early ...}} statement instead to see the {{\[I,UB,UA\]}} the sink receives.

h3. 3. The sink must accept updates

{code:sql}
INSERT INTO out_csv
SELECT /*+ EARLY_FIRE('delay'='3s') */ o.order_id, CAST(o.order_time AS 
TIMESTAMP(3)), CAST(s.ship_time AS TIMESTAMP(3))
FROM orders o LEFT JOIN shipments s
  ON o.order_id = s.order_id AND o.order_time BETWEEN s.ship_time - INTERVAL 
'1' MINUTE AND s.ship_time;
{code}

*Expected:* planning fails with {{Table sink 
'default_catalog.default_database.out_csv' doesn't support consuming update 
changes, but the EARLY_FIRE hint makes this outer interval join produce update 
changes ...}}

h3. 4. The hint is ignored on an inner join

{code:sql}
EXPLAIN CHANGELOG_MODE
SELECT /*+ EARLY_FIRE('delay'='3s') */ o.order_id, CAST(o.order_time AS 
TIMESTAMP(3)), CAST(s.ship_time AS TIMESTAMP(3))
FROM orders o JOIN shipments s
  ON o.order_id = s.order_id AND o.order_time BETWEEN s.ship_time - INTERVAL 
'1' MINUTE AND s.ship_time;
{code}

*Expected:* the {{IntervalJoin(joinType=\[InnerJoin\] ...)}} node and the whole 
plan stay {{changelogMode=\[I\]}}. The node still lists 
{{earlyFireDelay=\[3000\]}}: the hint is attached but has no effect, as 
documented for inner joins and joins with a negative window span.

h3. 5. Invalid hints fail at planning

Put each hint from the table in place of the hint in the first statement below. 
For the last row, use the second statement, which joins the processing-time 
inputs. {{EXPLAIN}} is enough, since the errors are raised during planning.

{code:sql}
EXPLAIN SELECT /*+ EARLY_FIRE('delay'='3s') */ o.order_id, CAST(o.order_time AS 
TIMESTAMP(3)), CAST(s.ship_time AS TIMESTAMP(3))
FROM orders o LEFT JOIN shipments s
  ON o.order_id = s.order_id AND o.order_time BETWEEN s.ship_time - INTERVAL 
'1' MINUTE AND s.ship_time;

EXPLAIN SELECT /*+ EARLY_FIRE('delay'='3s', 'time-mode'='rowtime') */ o.order_id
FROM orders_pt o LEFT JOIN shipments_pt s
  ON o.order_id = s.order_id AND o.pt BETWEEN s.pt - INTERVAL '1' MINUTE AND 
s.pt;
{code}

The first six errors are reported as {{java.lang.AssertionError}}. That is 
expected: every SQL hint option check (also {{LOOKUP}} and {{STATE_TTL}}) 
reports errors this way.

||Hint||Expected message contains||
|{{EARLY_FIRE('time-mode'='rowtime')}}|{{incomplete required option(s): \[Key: 
'delay'}}|
|{{EARLY_FIRE('delay'='0s')}}|{{delay value should be at least 1 millisecond}}|
|{{EARLY_FIRE('delay'='1ns')}}|{{delay value should be at least 1 millisecond}}|
|{{EARLY_FIRE('delay'='3s', 'target'='window_join')}}|{{only 'interval_join' is 
supported currently}}|
|{{EARLY_FIRE('delay'='3s', 'foo'='bar')}}|{{Unsupported EARLY_FIRE hint 
option(s) \[foo\]}}|
|{{EARLY_FIRE('3s')}}|{{only support key-value options}}|
|{{EARLY_FIRE('delay'='3s', 'time-mode'='rowtime')}} (second 
statement)|{{Row-time triggering requires a row-time interval join}}|

h3. 6. proctime mode keeps firing when the watermark stalls

{code:sql}
SET 'pipeline.name' = 'flip497-stalled-rowtime';
INSERT INTO out_stalled_rt
SELECT /*+ EARLY_FIRE('delay'='3s') */ o.order_id, CAST(o.order_time AS 
TIMESTAMP(3)), CAST(s.ship_time AS TIMESTAMP(3))
FROM orders o LEFT JOIN shipments_stalled s
  ON o.order_id = s.order_id AND o.order_time BETWEEN s.ship_time - INTERVAL 
'1' MINUTE AND s.ship_time;

SET 'pipeline.name' = 'flip497-stalled-proctime';
INSERT INTO out_stalled_pt
SELECT /*+ EARLY_FIRE('delay'='3s', 'time-mode'='proctime') */ o.order_id, 
CAST(o.order_time AS TIMESTAMP(3)), CAST(s.ship_time AS TIMESTAMP(3))
FROM orders o LEFT JOIN shipments_stalled s
  ON o.order_id = s.order_id AND o.order_time BETWEEN s.ship_time - INTERVAL 
'1' MINUTE AND s.ship_time;
{code}

*Expected:* {{stalled-rowtime}} prints nothing, because the watermark never 
passes the orders. {{stalled-proctime}} prints {{+I\[N, <order_time>, null\]}} 
3 seconds after each order, on wall-clock time.

h3. 7. FULL OUTER join

{code:sql}
SET 'pipeline.name' = 'flip497-full';
INSERT INTO out_full
SELECT /*+ EARLY_FIRE('delay'='3s') */ o.order_id, s.order_id, 
CAST(o.order_time AS TIMESTAMP(3)), CAST(s.ship_time AS TIMESTAMP(3))
FROM orders o FULL JOIN shipments s
  ON o.order_id = s.order_id AND MOD(s.order_id, 2) = 0
 AND o.order_time BETWEEN s.ship_time - INTERVAL '1' MINUTE AND s.ship_time;
{code}

*Expected:* orders behave as in step 1 ({{+I\[N, null, <order_time>, null\]}} 
after 4 to 5 s, even ones corrected later). Shipments that never match, such as 
{{s_id}} -9 to 0 and the odd ids, appear as {{+I\[null, M, null, 
<ship_time>\]}} 1 to 2 seconds after {{ship_time}}. That is not early fire. A 
shipment can only match orders at or before its own time, so its window closes 
as soon as the watermark passes it, and the same query without the hint gives 
the same 1 to 2 seconds. Early fire only shortens the wait for rows whose 
window is still open.

h3. 8. NULL join key on the preserved side

{code:sql}
SET 'pipeline.name' = 'flip497-nullkey';
INSERT INTO out_nullkey
SELECT /*+ EARLY_FIRE('delay'='3s') */ o.order_id, o.k, CAST(o.order_time AS 
TIMESTAMP(3)), CAST(s.ship_time AS TIMESTAMP(3))
FROM orders_nk o LEFT JOIN shipments s
  ON o.k = s.order_id AND MOD(s.order_id, 2) = 0
 AND o.order_time BETWEEN s.ship_time - INTERVAL '1' MINUTE AND s.ship_time;
{code}

*Expected:* orders 5, 10, 15, ... have a NULL key. Each appears once as 
{{+I\[N, null, <order_time>, null\]}} 4 to 5 seconds after its {{order_time}} 
and is never corrected, including the even ones. The other orders behave as in 
step 1.

h3. 9. Stop with a savepoint and resume

Let {{flip497-early}} from step 1 run for at least 30 seconds. Find its id with 
{{SHOW JOBS;}} (the {{RUNNING}} row with that name), then:

{code:sql}
STOP JOB '<id of flip497-early>' WITH SAVEPOINT;
SET 'execution.state-recovery.path' = '<the savepoint path printed above>';
SET 'pipeline.name' = 'flip497-early-restored';
INSERT INTO out_early
SELECT /*+ EARLY_FIRE('delay'='3s') */ o.order_id, CAST(o.order_time AS 
TIMESTAMP(3)), CAST(s.ship_time AS TIMESTAMP(3))
FROM orders o LEFT JOIN shipments s
  ON o.order_id = s.order_id AND MOD(s.order_id, 2) = 0
 AND o.order_time BETWEEN s.ship_time - INTERVAL '1' MINUTE AND s.ship_time;
RESET 'execution.state-recovery.path';
{code}

Resume within about 30 seconds, since the window is one minute. *Expected:* 
even orders that were early-fired before the stop are corrected with 
{{\-U}}/{{+U}} after the resume, and no order is padded a second time. This 
must print nothing:

{code:bash}
grep -oE 'early> \+I\[[0-9]+,' log/flink-*-taskexecutor-*.out | sort | uniq -d
{code}

h3. 10. Add the hint to a running job through a savepoint

Stop {{flip497-baseline}} from step 1 with a savepoint, then resume it from 
that savepoint with the hint added:

{code:sql}
STOP JOB '<id of flip497-baseline>' WITH SAVEPOINT;
SET 'execution.state-recovery.path' = '<the savepoint path printed above>';
SET 'pipeline.name' = 'flip497-baseline-with-hint';
INSERT INTO out_baseline
SELECT /*+ EARLY_FIRE('delay'='3s') */ o.order_id, CAST(o.order_time AS 
TIMESTAMP(3)), CAST(s.ship_time AS TIMESTAMP(3))
FROM orders o LEFT JOIN shipments s
  ON o.order_id = s.order_id AND MOD(s.order_id, 2) = 0
 AND o.order_time BETWEEN s.ship_time - INTERVAL '1' MINUTE AND s.ship_time;
RESET 'execution.state-recovery.path';
{code}

*Expected:* the job restores. Rows that were waiting in state at the savepoint 
keep their original timing: even orders arrive joined with a plain {{+I}}, and 
odd orders are padded when their window closes. Rows that arrive after the 
resume are early-fired and corrected as in step 1. The duplicate check from 
step 9 with {{baseline>}} instead of {{early>}} must print nothing.

h3. 11. Compiled plan

{code:sql}
SET 'pipeline.name' = 'flip497-plan';
COMPILE PLAN 'file:///tmp/flip497/plan.json' IF NOT EXISTS FOR INSERT INTO 
out_plan
SELECT /*+ EARLY_FIRE('delay'='3s') */ o.order_id, CAST(o.order_time AS 
TIMESTAMP(3)), CAST(s.ship_time AS TIMESTAMP(3))
FROM orders o LEFT JOIN shipments s
  ON o.order_id = s.order_id AND MOD(s.order_id, 2) = 0
 AND o.order_time BETWEEN s.ship_time - INTERVAL '1' MINUTE AND s.ship_time;
EXECUTE PLAN 'file:///tmp/flip497/plan.json';
{code}

*Expected:* {{grep earlyFire /tmp/flip497/plan.json}} shows {{"earlyFireDelay" 
: 3000}} and {{"earlyFireTimeMode" : "ROWTIME"}} (plus the join node's 
description line, which mentions both), and the {{plan}} output follows the 
step 1 pattern.

h3. 12. Batch mode

The step 1 tables are unbounded, so this step uses two small bounded ones. 
{{EXPLAIN}} is enough.

{code:sql}
CREATE TEMPORARY TABLE b_orders (order_id BIGINT, order_time TIMESTAMP(3)) WITH 
('connector' = 'datagen', 'number-of-rows' = '100');
CREATE TEMPORARY TABLE b_shipments (order_id BIGINT, ship_time TIMESTAMP(3)) 
WITH ('connector' = 'datagen', 'number-of-rows' = '100');
SET 'execution.runtime-mode' = 'batch';
EXPLAIN SELECT /*+ EARLY_FIRE('delay'='3s') */ o.order_id, o.order_time, 
s.ship_time
FROM b_orders o LEFT JOIN b_shipments s
  ON o.order_id = s.order_id AND o.order_time BETWEEN s.ship_time - INTERVAL 
'1' MINUTE AND s.ship_time;
SET 'execution.runtime-mode' = 'streaming';
{code}

*Expected* until FLINK-41446 is fixed: planning fails with {{Unknown join 
strategy : EARLY_FIRE}}, while the same {{EXPLAIN}} without the hint plans a 
{{HashJoin}}. Once the fix is in, re-run it: the query with the hint should 
plan as well.

h3. Exploratory (optional)

* Run step 1 with {{SET 'parallelism.default' = '2';}} and check that every 
{{\-U}} is directly followed by its {{+U}} and that no order is padded twice.
* Repeat steps 1 and 9 with {{SET 'state.backend.type' = 'rocksdb';}}.
* Feed the early-fire output into a {{GROUP BY}} and check that the final 
aggregates match the same query without the hint.
* Try a {{RIGHT}} join, a delay longer than the window, or extra non-equi 
conditions in {{ON}}.

h3. Known behavior (not bugs)

* On inner joins and joins with a negative window span the hint is ignored and 
the output stays append-only. {{EXPLAIN}} still shows {{earlyFireDelay}} on the 
join node.
* Each outer row fires at most once. The hint does not emit periodically.
* Early-fire timers are not removed when a row matches or expires first. They 
fire later and find nothing to do.
* {{'time-mode'='proctime'}} on an event-time join keeps extra state on each 
input to track pending early fires. Matching and state cleanup still follow 
event time.
* Writing the output to a sink with a primary key needs an {{ON CONFLICT}} 
clause on the {{INSERT}}, because the join's upsert key differs from the sink's 
primary key. The planner's error message lists the options. The same {{INSERT}} 
without the hint is accepted as is.
* The hint applies only to the interval join in its own query block. A hint on 
an outer {{SELECT}} does not reach a join inside a subquery. Early-fire output 
is an updating stream and an interval join only accepts append-only input, so 
two interval joins in one block with the hint fail at planning.
* On a regular join without a time condition, the hint is ignored.

h3. Reporting

Comment with the commit or RC and give pass or fail per section. File new bugs 
as linked issues with Fix Version 2.4.0.




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

Reply via email to